My agentic slop goes here. Not intended for anyone else!

fix

Changed files
+257 -40
stack
river
+211 -37
stack/river/bin/river_cli.ml
···
type user = {
username : string;
fullname : string;
-
email : string;
+
email : string option;
feeds : River.source list;
last_synced : string option;
}
···
Some {
username = json |> member "username" |> to_string;
fullname = json |> member "fullname" |> to_string;
-
email = json |> member "email" |> to_string;
+
email = json |> member "email" |> to_string_option;
feeds;
last_synced = json |> member "last_synced" |> to_string_option;
}
···
`Assoc [
"username", `String user.username;
"fullname", `String user.fullname;
-
"email", `String user.email;
+
"email", (match user.email with
+
| Some e -> `String e
+
| None -> `Null);
"feeds", `List feeds_json;
"last_synced", (match user.last_synced with
| Some s -> `String s
···
let list state =
let users = State.list_users state in
if users = [] then
-
Log.info (fun m -> m "No users found")
+
Printf.printf "No users found\n"
else begin
-
Log.info (fun m -> m "Users:");
+
Printf.printf "Users:\n";
List.iter (fun username ->
match State.load_user state username with
| Some user ->
-
Log.info (fun m -> m " %s (%s <%s>) - %d feeds"
-
username user.fullname user.email (List.length user.feeds))
+
let email_str = match user.email with
+
| Some e -> " <" ^ e ^ ">"
+
| None -> ""
+
in
+
Printf.printf " %s (%s%s) - %d feeds\n"
+
username user.fullname email_str (List.length user.feeds)
| None -> ()
) users
end;
···
| Some user ->
Printf.printf "Username: %s\n" user.username;
Printf.printf "Full name: %s\n" user.fullname;
-
Printf.printf "Email: %s\n" user.email;
+
Printf.printf "Email: %s\n"
+
(Option.value user.email ~default:"(none)");
Printf.printf "Last synced: %s\n"
(Option.value user.last_synced ~default:"never");
Printf.printf "Feeds (%d):\n" (List.length user.feeds);
···
(* Sync command *)
module Sync = struct
let merge_entries ~existing ~new_entries =
-
(* Create a set of existing entry IDs for deduplication *)
-
let module UriSet = Set.Make(Uri) in
-
let existing_ids =
+
(* Create a map of new entry IDs for efficient lookup and updates *)
+
let module UriMap = Map.Make(Uri) in
+
let new_entries_map =
List.fold_left (fun acc (entry : Syndic.Atom.entry) ->
-
UriSet.add entry.id acc
-
) UriSet.empty existing
+
UriMap.add entry.id entry acc
+
) UriMap.empty new_entries
in
-
(* Filter out duplicates from new entries *)
-
let unique_new =
-
List.filter (fun (entry : Syndic.Atom.entry) ->
-
not (UriSet.mem entry.id existing_ids)
-
) new_entries
+
(* Update existing entries with new ones if IDs match, otherwise keep existing *)
+
let updated_existing =
+
List.filter_map (fun (entry : Syndic.Atom.entry) ->
+
if UriMap.mem entry.id new_entries_map then
+
None (* Will be replaced by new entry *)
+
else
+
Some entry (* Keep existing entry *)
+
) existing
in
-
(* Combine and sort by updated date (newest first) *)
-
let combined = unique_new @ existing in
+
(* Combine new entries with non-replaced existing entries *)
+
let combined = new_entries @ updated_existing in
List.sort (fun (a : Syndic.Atom.entry) (b : Syndic.Atom.entry) ->
Ptime.compare b.updated a.updated
) combined
-
let sync_user ~sw env state ~username =
+
let sync_user ~sw ~requests env state ~username =
match State.load_user state username with
| None ->
Log.err (fun m -> m "User %s not found" username);
···
| Some user ->
Log.info (fun m -> m "Syncing feeds for user %s..." username);
-
(* Create a single Requests session for all feeds *)
-
let requests = Requests.create ~sw env
-
~follow_redirects:true
-
~max_redirects:5 in
-
-
(* Fetch all feeds using the shared session and switch *)
+
(* Fetch all feeds concurrently using the shared session *)
let fetched_feeds =
-
List.filter_map (fun source ->
+
Eio.Fiber.List.filter_map (fun source ->
try
Log.info (fun m -> m " Fetching %s (%s)..." source.River.name source.River.url);
Some (River.fetch ~sw ~requests env source)
···
0
end
-
let sync_all ~sw env state =
+
let sync_all ~sw ~requests env state =
let users = State.list_users state in
if users = [] then begin
Log.info (fun m -> m "No users to sync");
0
end else begin
-
Log.info (fun m -> m "Syncing %d users..." (List.length users));
+
Log.info (fun m -> m "Syncing %d users concurrently..." (List.length users));
+
let results =
-
List.map (fun username ->
-
let result = sync_user ~sw env state ~username in
+
Eio.Fiber.List.map (fun username ->
+
let result = sync_user ~sw ~requests env state ~username in
Log.debug (fun m -> m "Completed sync for user");
result
) users
···
end
end
+
(* Post listing commands *)
+
module Post = struct
+
let format_date ptime =
+
let open Ptime in
+
let (y, m, d), _ = to_date_time ptime in
+
Printf.sprintf "%02d/%02d/%04d" d m y
+
+
let format_text_construct : Syndic.Atom.text_construct -> string = function
+
| Syndic.Atom.Text s -> s
+
| Syndic.Atom.Html (_, s) -> s
+
| Syndic.Atom.Xhtml (_, _) -> "<xhtml content>"
+
+
let get_content_length (entry : Syndic.Atom.entry) =
+
match entry.content with
+
| Some (Syndic.Atom.Text s) -> String.length s
+
| Some (Syndic.Atom.Html (_, s)) -> String.length s
+
| Some (Syndic.Atom.Xhtml (_, _)) -> 0 (* Could calculate but complex *)
+
| Some (Syndic.Atom.Mime _) -> 0
+
| Some (Syndic.Atom.Src _) -> 0
+
| None -> (
+
match entry.summary with
+
| Some (Syndic.Atom.Text s) -> String.length s
+
| Some (Syndic.Atom.Html (_, s)) -> String.length s
+
| Some (Syndic.Atom.Xhtml (_, _)) -> 0
+
| None -> 0)
+
+
let list state ~username_opt ~limit =
+
match username_opt with
+
| Some username ->
+
(* List posts for a specific user *)
+
(match State.load_user state username with
+
| None ->
+
Log.err (fun m -> m "User %s not found" username);
+
1
+
| Some user ->
+
let entries = State.load_existing_posts state username in
+
if entries = [] then begin
+
Fmt.pr "%a@." Fmt.(styled `Yellow string)
+
("No posts found for user " ^ username);
+
Fmt.pr "%a@." Fmt.(styled `Faint string)
+
("(Run 'river-cli sync " ^ username ^ "' to fetch posts)");
+
0
+
end else begin
+
let to_show = match limit with
+
| Some n -> List.filteri (fun i _ -> i < n) entries
+
| None -> entries
+
in
+
Fmt.pr "%a@."
+
Fmt.(styled `Bold string)
+
(Printf.sprintf "Posts for %s (%d total, showing %d):"
+
user.fullname (List.length entries) (List.length to_show));
+
+
List.iteri (fun i (entry : Syndic.Atom.entry) ->
+
(* Use user's full name for all entries *)
+
let author_name = user.fullname in
+
let content_len = get_content_length entry in
+
Fmt.pr "%a %a %a %a %a %a %a %a@."
+
Fmt.(styled `Cyan string) (Printf.sprintf "[%d]" (i + 1))
+
Fmt.(styled (`Fg `Blue) string) (format_text_construct entry.title)
+
Fmt.(styled `Faint string) "-"
+
Fmt.(styled `Green string) author_name
+
Fmt.(styled `Faint string) "-"
+
Fmt.(styled `Magenta string) (format_date entry.updated)
+
Fmt.(styled `Faint string) "-"
+
Fmt.(styled `Yellow string) (Printf.sprintf "%d chars" content_len)
+
) to_show;
+
0
+
end)
+
| None ->
+
(* List posts from all users *)
+
let users = State.list_users state in
+
if users = [] then begin
+
Fmt.pr "%a@." Fmt.(styled `Yellow string)
+
"No users found";
+
Fmt.pr "%a@." Fmt.(styled `Faint string)
+
"(Run 'river-cli user add' to create a user)";
+
0
+
end else begin
+
(* Load user data to get full names *)
+
let user_map =
+
List.fold_left (fun acc username ->
+
match State.load_user state username with
+
| Some user -> (username, user) :: acc
+
| None -> acc
+
) [] users
+
in
+
+
(* Collect all entries from all users with username tag *)
+
let all_entries =
+
List.concat_map (fun username ->
+
let entries = State.load_existing_posts state username in
+
List.map (fun entry -> (username, entry)) entries
+
) users
+
in
+
+
if all_entries = [] then begin
+
Fmt.pr "%a@." Fmt.(styled `Yellow string)
+
"No posts found for any users";
+
Fmt.pr "%a@." Fmt.(styled `Faint string)
+
"(Run 'river-cli sync' to fetch posts)";
+
0
+
end else begin
+
(* Sort by date (newest first) *)
+
let sorted = List.sort (fun (_, a : string * Syndic.Atom.entry) (_, b) ->
+
Ptime.compare b.updated a.updated
+
) all_entries in
+
+
let to_show = match limit with
+
| Some n -> List.filteri (fun i _ -> i < n) sorted
+
| None -> sorted
+
in
+
+
Fmt.pr "%a@."
+
Fmt.(styled `Bold string)
+
(Printf.sprintf "Posts from all users (%d total, showing %d):"
+
(List.length all_entries) (List.length to_show));
+
+
List.iteri (fun i (username, entry : string * Syndic.Atom.entry) ->
+
(* Use user's full name instead of feed author *)
+
let author_name =
+
match List.assoc_opt username user_map with
+
| Some user -> user.fullname
+
| None ->
+
(* Fallback to entry author if user not found *)
+
let (author, _) = entry.authors in
+
String.trim author.name
+
in
+
let content_len = get_content_length entry in
+
Fmt.pr "%a %a %a %a %a %a %a %a@."
+
Fmt.(styled `Cyan string) (Printf.sprintf "[%d]" (i + 1))
+
Fmt.(styled (`Fg `Blue) string) (format_text_construct entry.title)
+
Fmt.(styled `Faint string) "-"
+
Fmt.(styled `Green string) author_name
+
Fmt.(styled `Faint string) "-"
+
Fmt.(styled `Magenta string) (format_date entry.updated)
+
Fmt.(styled `Faint string) "-"
+
Fmt.(styled `Yellow string) (Printf.sprintf "%d chars" content_len)
+
) to_show;
+
0
+
end
+
end
+
end
+
(* Cmdliner interface *)
open Cmdliner
···
Arg.(required & opt (some string) None & info ["name"; "n"] ~doc)
let email_arg =
-
let doc = "Email address of the user" in
-
Arg.(required & opt (some string) None & info ["email"; "e"] ~doc)
+
let doc = "Email address of the user (optional)" in
+
Arg.(value & opt (some string) None & info ["email"; "e"] ~doc)
let feed_name_arg =
let doc = "Feed name/label" in
···
Logs.info (fun m -> m "Creating switch for sync operations");
let result = Eio.Switch.run @@ fun sw ->
Logs.info (fun m -> m "Switch created, running sync");
+
+
(* Create a single Requests session for all operations *)
+
let requests = Requests.create ~sw env
+
~follow_redirects:true
+
~max_redirects:5 in
+
let res = match username_opt with
-
| Some username -> Sync.sync_user ~sw env state ~username
-
| None -> Sync.sync_all ~sw env state
+
| Some username -> Sync.sync_user ~sw ~requests env state ~username
+
| None -> Sync.sync_all ~sw ~requests env state
in
Logs.info (fun m -> m "Sync completed, about to exit switch");
res
···
let term = Term.(const run $ log_level $ log_style_renderer $ xdg_term $ username_opt) in
Cmd.v (Cmd.info "sync" ~doc) term
+
let list_cmd fs =
+
let doc = "List recent posts (from all users by default, or specify a user)" in
+
let xdg_term = Xdge.Cmd.term "river" fs ~config:false ~data:false ~cache:false ~runtime:false () in
+
let username_opt_arg =
+
let doc = "Username (optional - defaults to all users)" in
+
Arg.(value & pos 0 (some string) None & info [] ~docv:"USERNAME" ~doc)
+
in
+
let limit_arg =
+
let doc = "Limit number of posts to display (default: all)" in
+
Arg.(value & opt (some int) None & info ["limit"; "n"] ~doc)
+
in
+
let run log_level style_renderer (xdg, _cfg) username_opt limit =
+
setup_logs style_renderer log_level;
+
let state = { xdg } in
+
Post.list state ~username_opt ~limit
+
in
+
let term = Term.(const run $ log_level $ log_style_renderer $ xdg_term $ username_opt_arg $ limit_arg) in
+
Cmd.v (Cmd.info "list" ~doc) term
+
let main_cmd fs env =
let doc = "River feed management CLI" in
let info = Cmd.info "river-cli" ~version:"1.0" ~doc in
-
Cmd.group info [user_cmd fs; sync_cmd fs env]
+
Cmd.group info [user_cmd fs; sync_cmd fs env; list_cmd fs]
let () =
(* Initialize the Mirage_crypto RNG for TLS.
+11 -1
stack/river/lib/feed.ml
···
let feed = Atom (Syndic.Atom.parse ~xmlbase (Xmlm.make_input (`String (0, xml)))) in
Log.debug (fun m -> m "Successfully parsed as Atom feed");
feed
-
with Syndic.Atom.Error.Error (pos, msg) -> (
+
with
+
| Syndic.Atom.Error.Error (pos, msg) -> (
Log.debug (fun m -> m "Not an Atom feed: %s at position (%d, %d)"
msg (fst pos) (snd pos));
try
···
Log.err (fun m -> m "Failed to parse as RSS2: %s at position (%d, %d)"
msg (fst pos) (snd pos));
failwith "Neither Atom nor RSS2 feed")
+
| Not_found as e ->
+
Log.err (fun m -> m "Not_found exception during Atom feed parsing");
+
Log.err (fun m -> m "Backtrace:\n%s" (Printexc.get_backtrace ()));
+
raise e
+
| e ->
+
Log.err (fun m -> m "Unexpected exception during feed parsing: %s"
+
(Printexc.to_string e));
+
Log.err (fun m -> m "Backtrace:\n%s" (Printexc.get_backtrace ()));
+
raise e
let fetch ~sw ?requests env (source : source) =
Log.info (fun m -> m "Fetching feed '%s' from %s" source.name source.url);
+35 -2
stack/river/lib/post.ml
···
***********************************************************************)
let post_of_atom ~(feed : Feed.t) (e : Syndic.Atom.entry) =
+
Log.debug (fun m -> m "Processing Atom entry: %s"
+
(Util.string_of_text_construct e.title));
+
let link =
try
Some
(List.find (fun l -> l.Syndic.Atom.rel = Syndic.Atom.Alternate) e.links)
.href
with Not_found -> (
+
Log.debug (fun m -> m "No alternate link found, trying fallback");
match e.links with
| l :: _ -> Some l.href
| [] -> (
···
| Some (Xhtml (xmlbase, h)) -> html_of_syndic ?xmlbase h
| None -> Soup.parse "")
in
-
let author, _ = e.authors in
+
let is_valid_author_name name =
+
(* Filter out empty strings and placeholder values like "Unknown" *)
+
let trimmed = String.trim name in
+
trimmed <> "" && trimmed <> "Unknown"
+
in
+
let author_name =
+
(* Fallback chain for author:
+
1. Entry author (if present, not empty, and not "Unknown")
+
2. Feed-level author (from Atom feed metadata)
+
3. Feed title (from Atom feed metadata)
+
4. Source name (manually entered feed name) *)
+
try
+
let author, _ = e.authors in
+
let trimmed = String.trim author.name in
+
if is_valid_author_name author.name then trimmed
+
else raise Not_found (* Try feed-level author *)
+
with Not_found -> (
+
match feed.content with
+
| Feed.Atom atom_feed -> (
+
(* Try feed-level authors *)
+
match atom_feed.Syndic.Atom.authors with
+
| author :: _ when is_valid_author_name author.name ->
+
String.trim author.name
+
| _ ->
+
(* Use feed title *)
+
Util.string_of_text_construct atom_feed.Syndic.Atom.title)
+
| Feed.Rss2 _ ->
+
(* For RSS2, use the feed name which is the source name *)
+
feed.name)
+
in
{
title = Util.string_of_text_construct e.title;
link;
date;
feed;
-
author = author.name;
+
author = author_name;
email = "";
content;
link_response = None;