···66type user = {
77 username : string;
88 fullname : string;
99- email : string;
99+ email : string option;
1010 feeds : River.source list;
1111 last_synced : string option;
1212}
···4949 Some {
5050 username = json |> member "username" |> to_string;
5151 fullname = json |> member "fullname" |> to_string;
5252- email = json |> member "email" |> to_string;
5252+ email = json |> member "email" |> to_string_option;
5353 feeds;
5454 last_synced = json |> member "last_synced" |> to_string_option;
5555 }
···7777 `Assoc [
7878 "username", `String user.username;
7979 "fullname", `String user.fullname;
8080- "email", `String user.email;
8080+ "email", (match user.email with
8181+ | Some e -> `String e
8282+ | None -> `Null);
8183 "feeds", `List feeds_json;
8284 "last_synced", (match user.last_synced with
8385 | Some s -> `String s
···170172 let list state =
171173 let users = State.list_users state in
172174 if users = [] then
173173- Log.info (fun m -> m "No users found")
175175+ Printf.printf "No users found\n"
174176 else begin
175175- Log.info (fun m -> m "Users:");
177177+ Printf.printf "Users:\n";
176178 List.iter (fun username ->
177179 match State.load_user state username with
178180 | Some user ->
179179- Log.info (fun m -> m " %s (%s <%s>) - %d feeds"
180180- username user.fullname user.email (List.length user.feeds))
181181+ let email_str = match user.email with
182182+ | Some e -> " <" ^ e ^ ">"
183183+ | None -> ""
184184+ in
185185+ Printf.printf " %s (%s%s) - %d feeds\n"
186186+ username user.fullname email_str (List.length user.feeds)
181187 | None -> ()
182188 ) users
183189 end;
···225231 | Some user ->
226232 Printf.printf "Username: %s\n" user.username;
227233 Printf.printf "Full name: %s\n" user.fullname;
228228- Printf.printf "Email: %s\n" user.email;
234234+ Printf.printf "Email: %s\n"
235235+ (Option.value user.email ~default:"(none)");
229236 Printf.printf "Last synced: %s\n"
230237 (Option.value user.last_synced ~default:"never");
231238 Printf.printf "Feeds (%d):\n" (List.length user.feeds);
···238245(* Sync command *)
239246module Sync = struct
240247 let merge_entries ~existing ~new_entries =
241241- (* Create a set of existing entry IDs for deduplication *)
242242- let module UriSet = Set.Make(Uri) in
243243- let existing_ids =
248248+ (* Create a map of new entry IDs for efficient lookup and updates *)
249249+ let module UriMap = Map.Make(Uri) in
250250+ let new_entries_map =
244251 List.fold_left (fun acc (entry : Syndic.Atom.entry) ->
245245- UriSet.add entry.id acc
246246- ) UriSet.empty existing
252252+ UriMap.add entry.id entry acc
253253+ ) UriMap.empty new_entries
247254 in
248255249249- (* Filter out duplicates from new entries *)
250250- let unique_new =
251251- List.filter (fun (entry : Syndic.Atom.entry) ->
252252- not (UriSet.mem entry.id existing_ids)
253253- ) new_entries
256256+ (* Update existing entries with new ones if IDs match, otherwise keep existing *)
257257+ let updated_existing =
258258+ List.filter_map (fun (entry : Syndic.Atom.entry) ->
259259+ if UriMap.mem entry.id new_entries_map then
260260+ None (* Will be replaced by new entry *)
261261+ else
262262+ Some entry (* Keep existing entry *)
263263+ ) existing
254264 in
255265256256- (* Combine and sort by updated date (newest first) *)
257257- let combined = unique_new @ existing in
266266+ (* Combine new entries with non-replaced existing entries *)
267267+ let combined = new_entries @ updated_existing in
258268 List.sort (fun (a : Syndic.Atom.entry) (b : Syndic.Atom.entry) ->
259269 Ptime.compare b.updated a.updated
260270 ) combined
261271262262- let sync_user ~sw env state ~username =
272272+ let sync_user ~sw ~requests env state ~username =
263273 match State.load_user state username with
264274 | None ->
265275 Log.err (fun m -> m "User %s not found" username);
···270280 | Some user ->
271281 Log.info (fun m -> m "Syncing feeds for user %s..." username);
272282273273- (* Create a single Requests session for all feeds *)
274274- let requests = Requests.create ~sw env
275275- ~follow_redirects:true
276276- ~max_redirects:5 in
277277-278278- (* Fetch all feeds using the shared session and switch *)
283283+ (* Fetch all feeds concurrently using the shared session *)
279284 let fetched_feeds =
280280- List.filter_map (fun source ->
285285+ Eio.Fiber.List.filter_map (fun source ->
281286 try
282287 Log.info (fun m -> m " Fetching %s (%s)..." source.River.name source.River.url);
283288 Some (River.fetch ~sw ~requests env source)
···325330 0
326331 end
327332328328- let sync_all ~sw env state =
333333+ let sync_all ~sw ~requests env state =
329334 let users = State.list_users state in
330335 if users = [] then begin
331336 Log.info (fun m -> m "No users to sync");
332337 0
333338 end else begin
334334- Log.info (fun m -> m "Syncing %d users..." (List.length users));
339339+ Log.info (fun m -> m "Syncing %d users concurrently..." (List.length users));
340340+335341 let results =
336336- List.map (fun username ->
337337- let result = sync_user ~sw env state ~username in
342342+ Eio.Fiber.List.map (fun username ->
343343+ let result = sync_user ~sw ~requests env state ~username in
338344 Log.debug (fun m -> m "Completed sync for user");
339345 result
340346 ) users
···350356 end
351357end
352358359359+(* Post listing commands *)
360360+module Post = struct
361361+ let format_date ptime =
362362+ let open Ptime in
363363+ let (y, m, d), _ = to_date_time ptime in
364364+ Printf.sprintf "%02d/%02d/%04d" d m y
365365+366366+ let format_text_construct : Syndic.Atom.text_construct -> string = function
367367+ | Syndic.Atom.Text s -> s
368368+ | Syndic.Atom.Html (_, s) -> s
369369+ | Syndic.Atom.Xhtml (_, _) -> "<xhtml content>"
370370+371371+ let get_content_length (entry : Syndic.Atom.entry) =
372372+ match entry.content with
373373+ | Some (Syndic.Atom.Text s) -> String.length s
374374+ | Some (Syndic.Atom.Html (_, s)) -> String.length s
375375+ | Some (Syndic.Atom.Xhtml (_, _)) -> 0 (* Could calculate but complex *)
376376+ | Some (Syndic.Atom.Mime _) -> 0
377377+ | Some (Syndic.Atom.Src _) -> 0
378378+ | None -> (
379379+ match entry.summary with
380380+ | Some (Syndic.Atom.Text s) -> String.length s
381381+ | Some (Syndic.Atom.Html (_, s)) -> String.length s
382382+ | Some (Syndic.Atom.Xhtml (_, _)) -> 0
383383+ | None -> 0)
384384+385385+ let list state ~username_opt ~limit =
386386+ match username_opt with
387387+ | Some username ->
388388+ (* List posts for a specific user *)
389389+ (match State.load_user state username with
390390+ | None ->
391391+ Log.err (fun m -> m "User %s not found" username);
392392+ 1
393393+ | Some user ->
394394+ let entries = State.load_existing_posts state username in
395395+ if entries = [] then begin
396396+ Fmt.pr "%a@." Fmt.(styled `Yellow string)
397397+ ("No posts found for user " ^ username);
398398+ Fmt.pr "%a@." Fmt.(styled `Faint string)
399399+ ("(Run 'river-cli sync " ^ username ^ "' to fetch posts)");
400400+ 0
401401+ end else begin
402402+ let to_show = match limit with
403403+ | Some n -> List.filteri (fun i _ -> i < n) entries
404404+ | None -> entries
405405+ in
406406+ Fmt.pr "%a@."
407407+ Fmt.(styled `Bold string)
408408+ (Printf.sprintf "Posts for %s (%d total, showing %d):"
409409+ user.fullname (List.length entries) (List.length to_show));
410410+411411+ List.iteri (fun i (entry : Syndic.Atom.entry) ->
412412+ (* Use user's full name for all entries *)
413413+ let author_name = user.fullname in
414414+ let content_len = get_content_length entry in
415415+ Fmt.pr "%a %a %a %a %a %a %a %a@."
416416+ Fmt.(styled `Cyan string) (Printf.sprintf "[%d]" (i + 1))
417417+ Fmt.(styled (`Fg `Blue) string) (format_text_construct entry.title)
418418+ Fmt.(styled `Faint string) "-"
419419+ Fmt.(styled `Green string) author_name
420420+ Fmt.(styled `Faint string) "-"
421421+ Fmt.(styled `Magenta string) (format_date entry.updated)
422422+ Fmt.(styled `Faint string) "-"
423423+ Fmt.(styled `Yellow string) (Printf.sprintf "%d chars" content_len)
424424+ ) to_show;
425425+ 0
426426+ end)
427427+ | None ->
428428+ (* List posts from all users *)
429429+ let users = State.list_users state in
430430+ if users = [] then begin
431431+ Fmt.pr "%a@." Fmt.(styled `Yellow string)
432432+ "No users found";
433433+ Fmt.pr "%a@." Fmt.(styled `Faint string)
434434+ "(Run 'river-cli user add' to create a user)";
435435+ 0
436436+ end else begin
437437+ (* Load user data to get full names *)
438438+ let user_map =
439439+ List.fold_left (fun acc username ->
440440+ match State.load_user state username with
441441+ | Some user -> (username, user) :: acc
442442+ | None -> acc
443443+ ) [] users
444444+ in
445445+446446+ (* Collect all entries from all users with username tag *)
447447+ let all_entries =
448448+ List.concat_map (fun username ->
449449+ let entries = State.load_existing_posts state username in
450450+ List.map (fun entry -> (username, entry)) entries
451451+ ) users
452452+ in
453453+454454+ if all_entries = [] then begin
455455+ Fmt.pr "%a@." Fmt.(styled `Yellow string)
456456+ "No posts found for any users";
457457+ Fmt.pr "%a@." Fmt.(styled `Faint string)
458458+ "(Run 'river-cli sync' to fetch posts)";
459459+ 0
460460+ end else begin
461461+ (* Sort by date (newest first) *)
462462+ let sorted = List.sort (fun (_, a : string * Syndic.Atom.entry) (_, b) ->
463463+ Ptime.compare b.updated a.updated
464464+ ) all_entries in
465465+466466+ let to_show = match limit with
467467+ | Some n -> List.filteri (fun i _ -> i < n) sorted
468468+ | None -> sorted
469469+ in
470470+471471+ Fmt.pr "%a@."
472472+ Fmt.(styled `Bold string)
473473+ (Printf.sprintf "Posts from all users (%d total, showing %d):"
474474+ (List.length all_entries) (List.length to_show));
475475+476476+ List.iteri (fun i (username, entry : string * Syndic.Atom.entry) ->
477477+ (* Use user's full name instead of feed author *)
478478+ let author_name =
479479+ match List.assoc_opt username user_map with
480480+ | Some user -> user.fullname
481481+ | None ->
482482+ (* Fallback to entry author if user not found *)
483483+ let (author, _) = entry.authors in
484484+ String.trim author.name
485485+ in
486486+ let content_len = get_content_length entry in
487487+ Fmt.pr "%a %a %a %a %a %a %a %a@."
488488+ Fmt.(styled `Cyan string) (Printf.sprintf "[%d]" (i + 1))
489489+ Fmt.(styled (`Fg `Blue) string) (format_text_construct entry.title)
490490+ Fmt.(styled `Faint string) "-"
491491+ Fmt.(styled `Green string) author_name
492492+ Fmt.(styled `Faint string) "-"
493493+ Fmt.(styled `Magenta string) (format_date entry.updated)
494494+ Fmt.(styled `Faint string) "-"
495495+ Fmt.(styled `Yellow string) (Printf.sprintf "%d chars" content_len)
496496+ ) to_show;
497497+ 0
498498+ end
499499+ end
500500+end
501501+353502(* Cmdliner interface *)
354503open Cmdliner
355504···362511 Arg.(required & opt (some string) None & info ["name"; "n"] ~doc)
363512364513let email_arg =
365365- let doc = "Email address of the user" in
366366- Arg.(required & opt (some string) None & info ["email"; "e"] ~doc)
514514+ let doc = "Email address of the user (optional)" in
515515+ Arg.(value & opt (some string) None & info ["email"; "e"] ~doc)
367516368517let feed_name_arg =
369518 let doc = "Feed name/label" in
···476625 Logs.info (fun m -> m "Creating switch for sync operations");
477626 let result = Eio.Switch.run @@ fun sw ->
478627 Logs.info (fun m -> m "Switch created, running sync");
628628+629629+ (* Create a single Requests session for all operations *)
630630+ let requests = Requests.create ~sw env
631631+ ~follow_redirects:true
632632+ ~max_redirects:5 in
633633+479634 let res = match username_opt with
480480- | Some username -> Sync.sync_user ~sw env state ~username
481481- | None -> Sync.sync_all ~sw env state
635635+ | Some username -> Sync.sync_user ~sw ~requests env state ~username
636636+ | None -> Sync.sync_all ~sw ~requests env state
482637 in
483638 Logs.info (fun m -> m "Sync completed, about to exit switch");
484639 res
···489644 let term = Term.(const run $ log_level $ log_style_renderer $ xdg_term $ username_opt) in
490645 Cmd.v (Cmd.info "sync" ~doc) term
491646647647+let list_cmd fs =
648648+ let doc = "List recent posts (from all users by default, or specify a user)" in
649649+ let xdg_term = Xdge.Cmd.term "river" fs ~config:false ~data:false ~cache:false ~runtime:false () in
650650+ let username_opt_arg =
651651+ let doc = "Username (optional - defaults to all users)" in
652652+ Arg.(value & pos 0 (some string) None & info [] ~docv:"USERNAME" ~doc)
653653+ in
654654+ let limit_arg =
655655+ let doc = "Limit number of posts to display (default: all)" in
656656+ Arg.(value & opt (some int) None & info ["limit"; "n"] ~doc)
657657+ in
658658+ let run log_level style_renderer (xdg, _cfg) username_opt limit =
659659+ setup_logs style_renderer log_level;
660660+ let state = { xdg } in
661661+ Post.list state ~username_opt ~limit
662662+ in
663663+ let term = Term.(const run $ log_level $ log_style_renderer $ xdg_term $ username_opt_arg $ limit_arg) in
664664+ Cmd.v (Cmd.info "list" ~doc) term
665665+492666let main_cmd fs env =
493667 let doc = "River feed management CLI" in
494668 let info = Cmd.info "river-cli" ~version:"1.0" ~doc in
495495- Cmd.group info [user_cmd fs; sync_cmd fs env]
669669+ Cmd.group info [user_cmd fs; sync_cmd fs env; list_cmd fs]
496670497671let () =
498672 (* Initialize the Mirage_crypto RNG for TLS.
+11-1
stack/river/lib/feed.ml
···3232 let feed = Atom (Syndic.Atom.parse ~xmlbase (Xmlm.make_input (`String (0, xml)))) in
3333 Log.debug (fun m -> m "Successfully parsed as Atom feed");
3434 feed
3535- with Syndic.Atom.Error.Error (pos, msg) -> (
3535+ with
3636+ | Syndic.Atom.Error.Error (pos, msg) -> (
3637 Log.debug (fun m -> m "Not an Atom feed: %s at position (%d, %d)"
3738 msg (fst pos) (snd pos));
3839 try
···4344 Log.err (fun m -> m "Failed to parse as RSS2: %s at position (%d, %d)"
4445 msg (fst pos) (snd pos));
4546 failwith "Neither Atom nor RSS2 feed")
4747+ | Not_found as e ->
4848+ Log.err (fun m -> m "Not_found exception during Atom feed parsing");
4949+ Log.err (fun m -> m "Backtrace:\n%s" (Printexc.get_backtrace ()));
5050+ raise e
5151+ | e ->
5252+ Log.err (fun m -> m "Unexpected exception during feed parsing: %s"
5353+ (Printexc.to_string e));
5454+ Log.err (fun m -> m "Backtrace:\n%s" (Printexc.get_backtrace ()));
5555+ raise e
46564757let fetch ~sw ?requests env (source : source) =
4858 Log.info (fun m -> m "Fetching feed '%s' from %s" source.name source.url);
+35-2
stack/river/lib/post.ml
···8282 ***********************************************************************)
83838484let post_of_atom ~(feed : Feed.t) (e : Syndic.Atom.entry) =
8585+ Log.debug (fun m -> m "Processing Atom entry: %s"
8686+ (Util.string_of_text_construct e.title));
8787+8588 let link =
8689 try
8790 Some
8891 (List.find (fun l -> l.Syndic.Atom.rel = Syndic.Atom.Alternate) e.links)
8992 .href
9093 with Not_found -> (
9494+ Log.debug (fun m -> m "No alternate link found, trying fallback");
9195 match e.links with
9296 | l :: _ -> Some l.href
9397 | [] -> (
···111115 | Some (Xhtml (xmlbase, h)) -> html_of_syndic ?xmlbase h
112116 | None -> Soup.parse "")
113117 in
114114- let author, _ = e.authors in
118118+ let is_valid_author_name name =
119119+ (* Filter out empty strings and placeholder values like "Unknown" *)
120120+ let trimmed = String.trim name in
121121+ trimmed <> "" && trimmed <> "Unknown"
122122+ in
123123+ let author_name =
124124+ (* Fallback chain for author:
125125+ 1. Entry author (if present, not empty, and not "Unknown")
126126+ 2. Feed-level author (from Atom feed metadata)
127127+ 3. Feed title (from Atom feed metadata)
128128+ 4. Source name (manually entered feed name) *)
129129+ try
130130+ let author, _ = e.authors in
131131+ let trimmed = String.trim author.name in
132132+ if is_valid_author_name author.name then trimmed
133133+ else raise Not_found (* Try feed-level author *)
134134+ with Not_found -> (
135135+ match feed.content with
136136+ | Feed.Atom atom_feed -> (
137137+ (* Try feed-level authors *)
138138+ match atom_feed.Syndic.Atom.authors with
139139+ | author :: _ when is_valid_author_name author.name ->
140140+ String.trim author.name
141141+ | _ ->
142142+ (* Use feed title *)
143143+ Util.string_of_text_construct atom_feed.Syndic.Atom.title)
144144+ | Feed.Rss2 _ ->
145145+ (* For RSS2, use the feed name which is the source name *)
146146+ feed.name)
147147+ in
115148 {
116149 title = Util.string_of_text_construct e.title;
117150 link;
118151 date;
119152 feed;
120120- author = author.name;
153153+ author = author_name;
121154 email = "";
122155 content;
123156 link_response = None;