My agentic slop goes here. Not intended for anyone else!
1(* Logging setup *)
2let src = Logs.Src.create "river-cli" ~doc:"River CLI application"
3module Log = (val Logs.src_log src : Logs.LOG)
4
5(* Types *)
6type user = {
7 username : string;
8 fullname : string;
9 email : string option;
10 feeds : River.source list;
11 last_synced : string option;
12}
13
14type state = {
15 xdg : Xdge.t;
16}
17
18(* State directory management *)
19module State = struct
20 let users_dir state = Eio.Path.(Xdge.state_dir state.xdg / "users")
21 let feeds_dir state = Eio.Path.(Xdge.state_dir state.xdg / "feeds")
22 let user_feeds_dir state = Eio.Path.(feeds_dir state / "user")
23
24 let user_file state username =
25 Eio.Path.(users_dir state / (username ^ ".json"))
26
27 let user_feed_file state username =
28 Eio.Path.(user_feeds_dir state / (username ^ ".xml"))
29
30 let ensure_directories state =
31 let dirs = [
32 users_dir state;
33 feeds_dir state;
34 user_feeds_dir state;
35 ] in
36 List.iter (fun dir ->
37 try Eio.Path.mkdir ~perm:0o755 dir
38 with Eio.Io (Eio.Fs.E (Already_exists _), _) -> ()
39 ) dirs
40
41 (* JSON codecs for user data *)
42
43 (* Codec for River.source (feed) *)
44 let source_jsont =
45 let make name url = { River.name; url } in
46 Jsont.Object.map ~kind:"Source" make
47 |> Jsont.Object.mem "name" Jsont.string ~enc:(fun s -> s.River.name)
48 |> Jsont.Object.mem "url" Jsont.string ~enc:(fun s -> s.River.url)
49 |> Jsont.Object.finish
50
51 (* Codec for user *)
52 let user_jsont =
53 let make username fullname email feeds last_synced =
54 { username; fullname; email; feeds; last_synced }
55 in
56 Jsont.Object.map ~kind:"User" make
57 |> Jsont.Object.mem "username" Jsont.string ~enc:(fun u -> u.username)
58 |> Jsont.Object.mem "fullname" Jsont.string ~enc:(fun u -> u.fullname)
59 |> Jsont.Object.opt_mem "email" Jsont.string ~enc:(fun u -> u.email)
60 |> Jsont.Object.mem "feeds" (Jsont.list source_jsont) ~enc:(fun u -> u.feeds)
61 |> Jsont.Object.opt_mem "last_synced" Jsont.string ~enc:(fun u -> u.last_synced)
62 |> Jsont.Object.finish
63
64 let user_of_string s =
65 match Jsont_bytesrw.decode_string' user_jsont s with
66 | Ok user -> Some user
67 | Error err ->
68 Log.err (fun m -> m "Failed to parse user JSON: %s" (Jsont.Error.to_string err));
69 None
70
71 let user_to_string user =
72 match Jsont_bytesrw.encode_string' ~format:Jsont.Indent user_jsont user with
73 | Ok s -> s
74 | Error err -> failwith ("Failed to encode user: " ^ Jsont.Error.to_string err)
75
76 let load_user state username =
77 let file = user_file state username in
78 try
79 let content = Eio.Path.load file in
80 user_of_string content
81 with
82 | Eio.Io (Eio.Fs.E (Not_found _), _) -> None
83 | e ->
84 Log.err (fun m -> m "Error loading user %s: %s" username (Printexc.to_string e));
85 None
86
87 let save_user state user =
88 let file = user_file state user.username in
89 let json = user_to_string user in
90 Eio.Path.save ~create:(`Or_truncate 0o644) file json
91
92 let list_users state =
93 try
94 Eio.Path.read_dir (users_dir state)
95 |> List.filter_map (fun name ->
96 if Filename.check_suffix name ".json" then
97 Some (Filename.chop_suffix name ".json")
98 else None
99 )
100 with _ -> []
101
102 let load_existing_posts state username =
103 let file = user_feed_file state username in
104 try
105 let content = Eio.Path.load file in
106 (* Parse existing Atom feed *)
107 let input = Xmlm.make_input (`String (0, content)) in
108 let feed = Syndic.Atom.parse input in
109 feed.Syndic.Atom.entries
110 with
111 | Eio.Io (Eio.Fs.E (Not_found _), _) -> []
112 | e ->
113 Log.err (fun m -> m "Error loading existing posts for %s: %s"
114 username (Printexc.to_string e));
115 []
116
117 let save_atom_feed state username entries =
118 let file = user_feed_file state username in
119 let feed : Syndic.Atom.feed = {
120 id = Uri.of_string ("urn:river:user:" ^ username);
121 title = Syndic.Atom.Text username;
122 updated = Ptime.of_float_s (Unix.time ()) |> Option.get;
123 entries;
124 authors = [];
125 categories = [];
126 contributors = [];
127 generator = Some {
128 Syndic.Atom.version = Some "1.0";
129 uri = None;
130 content = "River Feed Aggregator";
131 };
132 icon = None;
133 links = [];
134 logo = None;
135 rights = None;
136 subtitle = None;
137 } in
138 let output = Buffer.create 1024 in
139 Syndic.Atom.output feed (`Buffer output);
140 Eio.Path.save ~create:(`Or_truncate 0o644) file (Buffer.contents output)
141end
142
143(* User management commands *)
144module User = struct
145 let add state ~username ~fullname ~email =
146 match State.load_user state username with
147 | Some _ ->
148 Log.err (fun m -> m "User %s already exists" username);
149 1
150 | None ->
151 let user = { username; fullname; email; feeds = []; last_synced = None } in
152 State.save_user state user;
153 Log.info (fun m -> m "User %s created" username);
154 0
155
156 let remove state ~username =
157 match State.load_user state username with
158 | None ->
159 Log.err (fun m -> m "User %s not found" username);
160 1
161 | Some _ ->
162 (* Remove user file and feed file *)
163 let user_file = State.user_file state username in
164 let feed_file = State.user_feed_file state username in
165 (try Eio.Path.unlink user_file with _ -> ());
166 (try Eio.Path.unlink feed_file with _ -> ());
167 Log.info (fun m -> m "User %s removed" username);
168 0
169
170 let list state =
171 let users = State.list_users state in
172 if users = [] then
173 Printf.printf "No users found\n"
174 else begin
175 Printf.printf "Users:\n";
176 List.iter (fun username ->
177 match State.load_user state username with
178 | Some user ->
179 let email_str = match user.email with
180 | Some e -> " <" ^ e ^ ">"
181 | None -> ""
182 in
183 Printf.printf " %s (%s%s) - %d feeds\n"
184 username user.fullname email_str (List.length user.feeds)
185 | None -> ()
186 ) users
187 end;
188 0
189
190 let add_feed state ~username ~name ~url =
191 match State.load_user state username with
192 | None ->
193 Log.err (fun m -> m "User %s not found" username);
194 1
195 | Some user ->
196 let feed = { River.name; url } in
197 if List.exists (fun f -> f.River.url = url) user.feeds then begin
198 Log.err (fun m -> m "Feed %s already exists for user %s" url username);
199 1
200 end else begin
201 let user = { user with feeds = feed :: user.feeds } in
202 State.save_user state user;
203 Log.info (fun m -> m "Feed %s added to user %s" name username);
204 0
205 end
206
207 let remove_feed state ~username ~url =
208 match State.load_user state username with
209 | None ->
210 Log.err (fun m -> m "User %s not found" username);
211 1
212 | Some user ->
213 let feeds = List.filter (fun f -> f.River.url <> url) user.feeds in
214 if List.length feeds = List.length user.feeds then begin
215 Log.err (fun m -> m "Feed %s not found for user %s" url username);
216 1
217 end else begin
218 let user = { user with feeds } in
219 State.save_user state user;
220 Log.info (fun m -> m "Feed removed from user %s" username);
221 0
222 end
223
224 let show state ~username =
225 match State.load_user state username with
226 | None ->
227 Log.err (fun m -> m "User %s not found" username);
228 1
229 | Some user ->
230 Printf.printf "Username: %s\n" user.username;
231 Printf.printf "Full name: %s\n" user.fullname;
232 Printf.printf "Email: %s\n"
233 (Option.value user.email ~default:"(none)");
234 Printf.printf "Last synced: %s\n"
235 (Option.value user.last_synced ~default:"never");
236 Printf.printf "Feeds (%d):\n" (List.length user.feeds);
237 List.iter (fun feed ->
238 Printf.printf " - %s: %s\n" feed.River.name feed.River.url
239 ) user.feeds;
240 0
241end
242
243(* Sync command *)
244module Sync = struct
245 let merge_entries ~existing ~new_entries =
246 (* Create a map of new entry IDs for efficient lookup and updates *)
247 let module UriMap = Map.Make(Uri) in
248 let new_entries_map =
249 List.fold_left (fun acc (entry : Syndic.Atom.entry) ->
250 UriMap.add entry.id entry acc
251 ) UriMap.empty new_entries
252 in
253
254 (* Update existing entries with new ones if IDs match, otherwise keep existing *)
255 let updated_existing =
256 List.filter_map (fun (entry : Syndic.Atom.entry) ->
257 if UriMap.mem entry.id new_entries_map then
258 None (* Will be replaced by new entry *)
259 else
260 Some entry (* Keep existing entry *)
261 ) existing
262 in
263
264 (* Combine new entries with non-replaced existing entries *)
265 let combined = new_entries @ updated_existing in
266 List.sort (fun (a : Syndic.Atom.entry) (b : Syndic.Atom.entry) ->
267 Ptime.compare b.updated a.updated
268 ) combined
269
270 let sync_user session state ~username =
271 match State.load_user state username with
272 | None ->
273 Log.err (fun m -> m "User %s not found" username);
274 1
275 | Some user when user.feeds = [] ->
276 Log.info (fun m -> m "No feeds configured for user %s" username);
277 0
278 | Some user ->
279 Log.info (fun m -> m "Syncing feeds for user %s..." username);
280
281 (* Fetch all feeds concurrently using the session *)
282 let fetched_feeds =
283 Eio.Fiber.List.filter_map (fun source ->
284 try
285 Log.info (fun m -> m " Fetching %s (%s)..." source.River.name source.River.url);
286 Some (River.fetch session source)
287 with e ->
288 Log.err (fun m -> m " Failed to fetch %s: %s"
289 source.River.name (Printexc.to_string e));
290 None
291 ) user.feeds
292 in
293
294 if fetched_feeds = [] then begin
295 Log.err (fun m -> m "No feeds successfully fetched");
296 1
297 end else begin
298 (* Get posts from fetched feeds *)
299 let posts = River.posts fetched_feeds in
300 Log.info (fun m -> m " Found %d new posts" (List.length posts));
301
302 (* Convert to Atom entries *)
303 let new_entries = River.create_atom_entries posts in
304
305 (* Load existing entries *)
306 let existing = State.load_existing_posts state username in
307 Log.info (fun m -> m " Found %d existing posts" (List.length existing));
308
309 (* Merge entries *)
310 let merged = merge_entries ~existing ~new_entries in
311 Log.info (fun m -> m " Total posts after merge: %d" (List.length merged));
312
313 (* Save updated feed *)
314 State.save_atom_feed state username merged;
315
316 (* Update last_synced timestamp *)
317 let now =
318 let open Unix in
319 let tm = gmtime (time ()) in
320 Printf.sprintf "%04d-%02d-%02dT%02d:%02d:%02dZ"
321 (tm.tm_year + 1900) (tm.tm_mon + 1) tm.tm_mday
322 tm.tm_hour tm.tm_min tm.tm_sec
323 in
324 let user = { user with last_synced = Some now } in
325 State.save_user state user;
326
327 Log.info (fun m -> m "Sync completed for user %s" username);
328 0
329 end
330
331 let sync_all session state =
332 let users = State.list_users state in
333 if users = [] then begin
334 Log.info (fun m -> m "No users to sync");
335 0
336 end else begin
337 Log.info (fun m -> m "Syncing %d users concurrently..." (List.length users));
338
339 let results =
340 Eio.Fiber.List.map (fun username ->
341 let result = sync_user session state ~username in
342 Log.debug (fun m -> m "Completed sync for user");
343 result
344 ) users
345 in
346 let failures = List.filter ((<>) 0) results in
347 if failures = [] then begin
348 Log.info (fun m -> m "All users synced successfully");
349 0
350 end else begin
351 Log.err (fun m -> m "Failed to sync %d users" (List.length failures));
352 1
353 end
354 end
355end
356
357(* Post listing commands *)
358module Post = struct
359 let format_date ptime =
360 let open Ptime in
361 let (y, m, d), _ = to_date_time ptime in
362 Printf.sprintf "%02d/%02d/%04d" d m y
363
364 let format_text_construct : Syndic.Atom.text_construct -> string = function
365 | Syndic.Atom.Text s -> s
366 | Syndic.Atom.Html (_, s) -> s
367 | Syndic.Atom.Xhtml (_, _) -> "<xhtml content>"
368
369 let get_content_length (entry : Syndic.Atom.entry) =
370 match entry.content with
371 | Some (Syndic.Atom.Text s) -> String.length s
372 | Some (Syndic.Atom.Html (_, s)) -> String.length s
373 | Some (Syndic.Atom.Xhtml (_, _)) -> 0 (* Could calculate but complex *)
374 | Some (Syndic.Atom.Mime _) -> 0
375 | Some (Syndic.Atom.Src _) -> 0
376 | None -> (
377 match entry.summary with
378 | Some (Syndic.Atom.Text s) -> String.length s
379 | Some (Syndic.Atom.Html (_, s)) -> String.length s
380 | Some (Syndic.Atom.Xhtml (_, _)) -> 0
381 | None -> 0)
382
383 let list state ~username_opt ~limit =
384 match username_opt with
385 | Some username ->
386 (* List posts for a specific user *)
387 (match State.load_user state username with
388 | None ->
389 Log.err (fun m -> m "User %s not found" username);
390 1
391 | Some user ->
392 let entries = State.load_existing_posts state username in
393 if entries = [] then begin
394 Fmt.pr "%a@." Fmt.(styled `Yellow string)
395 ("No posts found for user " ^ username);
396 Fmt.pr "%a@." Fmt.(styled `Faint string)
397 ("(Run 'river-cli sync " ^ username ^ "' to fetch posts)");
398 0
399 end else begin
400 let to_show = match limit with
401 | Some n -> List.filteri (fun i _ -> i < n) entries
402 | None -> entries
403 in
404 Fmt.pr "%a@."
405 Fmt.(styled `Bold string)
406 (Printf.sprintf "Posts for %s (%d total, showing %d):"
407 user.fullname (List.length entries) (List.length to_show));
408
409 List.iteri (fun i (entry : Syndic.Atom.entry) ->
410 (* Use user's full name for all entries *)
411 let author_name = user.fullname in
412 let content_len = get_content_length entry in
413 let entry_id = Uri.to_string entry.id in
414 Fmt.pr "%a %a@."
415 Fmt.(styled `Cyan string) (Printf.sprintf "[%d]" (i + 1))
416 Fmt.(styled (`Fg `Blue) string) (format_text_construct entry.title);
417 Fmt.pr " %a %a@."
418 Fmt.(styled `Faint string) "ID:"
419 Fmt.(styled `Faint string) entry_id;
420 Fmt.pr " %a - %a - %a chars@."
421 Fmt.(styled `Green string) author_name
422 Fmt.(styled `Magenta string) (format_date entry.updated)
423 Fmt.(styled `Yellow string) (string_of_int content_len)
424 ) to_show;
425 0
426 end)
427 | None ->
428 (* List posts from all users *)
429 let users = State.list_users state in
430 if users = [] then begin
431 Fmt.pr "%a@." Fmt.(styled `Yellow string)
432 "No users found";
433 Fmt.pr "%a@." Fmt.(styled `Faint string)
434 "(Run 'river-cli user add' to create a user)";
435 0
436 end else begin
437 (* Load user data to get full names *)
438 let user_map =
439 List.fold_left (fun acc username ->
440 match State.load_user state username with
441 | Some user -> (username, user) :: acc
442 | None -> acc
443 ) [] users
444 in
445
446 (* Collect all entries from all users with username tag *)
447 let all_entries =
448 List.concat_map (fun username ->
449 let entries = State.load_existing_posts state username in
450 List.map (fun entry -> (username, entry)) entries
451 ) users
452 in
453
454 if all_entries = [] then begin
455 Fmt.pr "%a@." Fmt.(styled `Yellow string)
456 "No posts found for any users";
457 Fmt.pr "%a@." Fmt.(styled `Faint string)
458 "(Run 'river-cli sync' to fetch posts)";
459 0
460 end else begin
461 (* Sort by date (newest first) *)
462 let sorted = List.sort (fun (_, a : string * Syndic.Atom.entry) (_, b) ->
463 Ptime.compare b.updated a.updated
464 ) all_entries in
465
466 let to_show = match limit with
467 | Some n -> List.filteri (fun i _ -> i < n) sorted
468 | None -> sorted
469 in
470
471 Fmt.pr "%a@."
472 Fmt.(styled `Bold string)
473 (Printf.sprintf "Posts from all users (%d total, showing %d):"
474 (List.length all_entries) (List.length to_show));
475
476 List.iteri (fun i (username, entry : string * Syndic.Atom.entry) ->
477 (* Use user's full name instead of feed author *)
478 let author_name =
479 match List.assoc_opt username user_map with
480 | Some user -> user.fullname
481 | None ->
482 (* Fallback to entry author if user not found *)
483 let (author, _) = entry.authors in
484 String.trim author.name
485 in
486 let content_len = get_content_length entry in
487 let entry_id = Uri.to_string entry.id in
488 (* Shorten ID for display if it's too long *)
489 Fmt.pr "%a %a@."
490 Fmt.(styled `Cyan string) (Printf.sprintf "[%d]" (i + 1))
491 Fmt.(styled (`Fg `Blue) string) (format_text_construct entry.title);
492 Fmt.pr " %a %a@."
493 Fmt.(styled `Faint string) "ID:"
494 Fmt.(styled `Faint string) entry_id;
495 Fmt.pr " %a - %a - %a chars@."
496 Fmt.(styled `Green string) author_name
497 Fmt.(styled `Magenta string) (format_date entry.updated)
498 Fmt.(styled `Yellow string) (string_of_int content_len)
499 ) to_show;
500 0
501 end
502 end
503end
504
505(* Cmdliner interface *)
506open Cmdliner
507
508let username_arg =
509 let doc = "Username" in
510 Arg.(required & pos 0 (some string) None & info [] ~docv:"USERNAME" ~doc)
511
512let fullname_arg =
513 let doc = "Full name of the user" in
514 Arg.(required & opt (some string) None & info ["name"; "n"] ~doc)
515
516let email_arg =
517 let doc = "Email address of the user (optional)" in
518 Arg.(value & opt (some string) None & info ["email"; "e"] ~doc)
519
520let feed_name_arg =
521 let doc = "Feed name/label" in
522 Arg.(required & opt (some string) None & info ["name"; "n"] ~doc)
523
524let feed_url_arg =
525 let doc = "Feed URL" in
526 Arg.(required & opt (some string) None & info ["url"; "u"] ~doc)
527
528(* Note: eiocmd handles all logging setup automatically via Logs_cli *)
529
530(* User commands - these don't need network, just filesystem access via Xdge *)
531let user_add_cmd =
532 let doc = "Add a new user" in
533 Eiocmd.run
534 ~use_keyeio:false
535 ~info:(Cmd.info "add" ~doc)
536 ~app_name:"river"
537 ~service:"river"
538 Term.(const (fun username fullname email _env xdg _profile ->
539 let state = { xdg } in
540 State.ensure_directories state;
541 User.add state ~username ~fullname ~email
542 ) $ username_arg $ fullname_arg $ email_arg)
543
544let user_remove_cmd =
545 let doc = "Remove a user" in
546 Eiocmd.run
547 ~use_keyeio:false
548 ~info:(Cmd.info "remove" ~doc)
549 ~app_name:"river"
550 ~service:"river"
551 Term.(const (fun username _env xdg _profile ->
552 let state = { xdg } in
553 User.remove state ~username
554 ) $ username_arg)
555
556let user_list_cmd =
557 let doc = "List all users" in
558 Eiocmd.run
559 ~use_keyeio:false
560 ~info:(Cmd.info "list" ~doc)
561 ~app_name:"river"
562 ~service:"river"
563 Term.(const (fun _env xdg _profile ->
564 let state = { xdg } in
565 User.list state
566 ))
567
568let user_show_cmd =
569 let doc = "Show user details" in
570 Eiocmd.run
571 ~use_keyeio:false
572 ~info:(Cmd.info "show" ~doc)
573 ~app_name:"river"
574 ~service:"river"
575 Term.(const (fun username _env xdg _profile ->
576 let state = { xdg } in
577 User.show state ~username
578 ) $ username_arg)
579
580let user_add_feed_cmd =
581 let doc = "Add a feed to a user" in
582 Eiocmd.run
583 ~use_keyeio:false
584 ~info:(Cmd.info "add-feed" ~doc)
585 ~app_name:"river"
586 ~service:"river"
587 Term.(const (fun username name url _env xdg _profile ->
588 let state = { xdg } in
589 User.add_feed state ~username ~name ~url
590 ) $ username_arg $ feed_name_arg $ feed_url_arg)
591
592let user_remove_feed_cmd =
593 let doc = "Remove a feed from a user" in
594 Eiocmd.run
595 ~use_keyeio:false
596 ~info:(Cmd.info "remove-feed" ~doc)
597 ~app_name:"river"
598 ~service:"river"
599 Term.(const (fun username url _env xdg _profile ->
600 let state = { xdg } in
601 User.remove_feed state ~username ~url
602 ) $ username_arg $ feed_url_arg)
603
604let user_cmd =
605 let doc = "Manage users" in
606 let info = Cmd.info "user" ~doc in
607 Cmd.group info [
608 user_add_cmd;
609 user_remove_cmd;
610 user_list_cmd;
611 user_show_cmd;
612 user_add_feed_cmd;
613 user_remove_feed_cmd;
614 ]
615
616(* Sync command - needs Eio environment for HTTP requests *)
617let sync_cmd =
618 let doc = "Sync feeds for users" in
619 let username_opt =
620 let doc = "Sync specific user (omit to sync all)" in
621 Arg.(value & pos 0 (some string) None & info [] ~docv:"USERNAME" ~doc)
622 in
623 Eiocmd.run
624 ~use_keyeio:false
625 ~info:(Cmd.info "sync" ~doc)
626 ~app_name:"river"
627 ~service:"river"
628 Term.(const (fun username_opt env xdg _profile ->
629 let state = { xdg } in
630 State.ensure_directories state;
631
632 (* Use River.with_session for resource management *)
633 River.with_session env @@ fun session ->
634 match username_opt with
635 | Some username -> Sync.sync_user session state ~username
636 | None -> Sync.sync_all session state
637 ) $ username_opt)
638
639(* List command - doesn't need network, just reads local files *)
640let list_cmd =
641 let doc = "List recent posts (from all users by default, or specify a user)" in
642 let username_opt_arg =
643 let doc = "Username (optional - defaults to all users)" in
644 Arg.(value & pos 0 (some string) None & info [] ~docv:"USERNAME" ~doc)
645 in
646 let limit_arg =
647 let doc = "Limit number of posts to display (default: all)" in
648 Arg.(value & opt (some int) None & info ["limit"; "n"] ~doc)
649 in
650 Eiocmd.run
651 ~use_keyeio:false
652 ~info:(Cmd.info "list" ~doc)
653 ~app_name:"river"
654 ~service:"river"
655 Term.(const (fun username_opt limit _env xdg _profile ->
656 let state = { xdg } in
657 Post.list state ~username_opt ~limit
658 ) $ username_opt_arg $ limit_arg)
659
660(* Info command - show detailed post information *)
661let info_cmd =
662 let doc = "Display detailed information about a post by ID" in
663 let id_arg =
664 let doc = "Exact post ID to display" in
665 Arg.(required & pos 0 (some string) None & info [] ~docv:"ID" ~doc)
666 in
667 let verbose_flag =
668 let doc = "Show full content and all metadata" in
669 Arg.(value & flag & info ["full"; "f"] ~doc)
670 in
671 Eiocmd.run
672 ~use_keyeio:false
673 ~info:(Cmd.info "info" ~doc)
674 ~app_name:"river"
675 ~service:"river"
676 Term.(const (fun id verbose _env xdg _profile ->
677 let state = { xdg } in
678 let users = State.list_users state in
679
680 (* Load all entries from all users *)
681 let all_entries =
682 List.concat_map (fun username ->
683 let entries = State.load_existing_posts state username in
684 List.map (fun entry -> (username, entry)) entries
685 ) users
686 in
687
688 (* Find entry with matching ID *)
689 let entry_opt = List.find_opt (fun (_username, entry : string * Syndic.Atom.entry) ->
690 Uri.to_string entry.id = id
691 ) all_entries in
692
693 match entry_opt with
694 | None ->
695 Fmt.pr "%a@." Fmt.(styled `Red string) (Printf.sprintf "No post found with ID: %s" id);
696 Fmt.pr "%a@." Fmt.(styled `Faint string) "Hint: Use 'river-cli list' to see available posts and their IDs";
697 1
698 | Some (username, entry) ->
699 (* Get user info for author name *)
700 let user_opt = State.load_user state username in
701 let author_name = match user_opt with
702 | Some user -> user.fullname
703 | None ->
704 let (author, _) = entry.authors in
705 String.trim author.name
706 in
707
708 (* Print header *)
709 Fmt.pr "@.";
710 Fmt.pr "%a@." Fmt.(styled `Bold string)
711 (String.make 70 '=');
712 Fmt.pr " %a@." Fmt.(styled `Bold (styled (`Fg `Blue) string))
713 (Post.format_text_construct entry.title);
714 Fmt.pr "%a@.@." Fmt.(styled `Bold string)
715 (String.make 70 '=');
716
717 (* Basic metadata *)
718 Fmt.pr "%a %s@." Fmt.(styled `Cyan string) "ID: " (Uri.to_string entry.id);
719
720 (* Links *)
721 let links = entry.links in
722 (match links with
723 | [] -> ()
724 | link :: _ ->
725 Fmt.pr "%a %s@." Fmt.(styled `Cyan string) "URL: " (Uri.to_string link.href));
726
727 Fmt.pr "%a %s@." Fmt.(styled `Cyan string) "Author: " author_name;
728
729 Fmt.pr "%a %s@." Fmt.(styled `Cyan string) "Updated: " (Ptime.to_rfc3339 entry.updated);
730
731 (* Summary *)
732 (match entry.summary with
733 | Some summary ->
734 Fmt.pr "@.%a@." Fmt.(styled (`Fg `Yellow) string) "Summary:";
735 let summary_text = Post.format_text_construct summary in
736 Fmt.pr " %s@." summary_text
737 | None -> ());
738
739 (* Content *)
740 (match entry.content with
741 | Some content ->
742 let content_html = match content with
743 | Syndic.Atom.Text s -> s
744 | Syndic.Atom.Html (_, s) -> s
745 | Syndic.Atom.Xhtml (_, nodes) ->
746 String.concat "" (List.map Syndic.XML.to_string nodes)
747 | Syndic.Atom.Mime _ -> "(MIME content)"
748 | Syndic.Atom.Src _ -> "(External content)"
749 in
750
751 (* Extract outgoing links *)
752 let links = Markdown_converter.extract_links content_html in
753
754 (* Convert to markdown *)
755 let content_markdown = Markdown_converter.to_markdown content_html in
756
757 Fmt.pr "@.%a@." Fmt.(styled (`Fg `Green) string) "Content:";
758 if verbose then
759 Fmt.pr "%s@." content_markdown
760 else begin
761 let preview =
762 if String.length content_markdown > 500 then
763 String.sub content_markdown 0 500 ^ "..."
764 else
765 content_markdown
766 in
767 Fmt.pr "%s@." preview;
768 if String.length content_markdown > 500 then
769 Fmt.pr "@.%a@." Fmt.(styled `Faint string) "(Use --full to see full content)"
770 end;
771
772 (* Display outgoing links *)
773 if links <> [] then begin
774 Fmt.pr "@.%a (%d)@." Fmt.(styled (`Fg `Cyan) string) "Outgoing Links:" (List.length links);
775 List.iteri (fun i (href, text) ->
776 let link_text = if text = "" then "(no text)" else text in
777 Fmt.pr " %a %s@."
778 Fmt.(styled `Faint string) (Printf.sprintf "[%d]" (i + 1))
779 (Uri.to_string (Uri.of_string href));
780 if text <> "" && String.length text < 80 then
781 Fmt.pr " %a %s@." Fmt.(styled `Faint string) "→" link_text
782 ) links
783 end
784 | None -> ());
785
786 Fmt.pr "@.";
787 0
788 ) $ id_arg $ verbose_flag)
789
790let main_cmd =
791 let doc = "River feed management CLI" in
792 let info = Cmd.info "river-cli" ~version:"1.0" ~doc in
793 Cmd.group info [user_cmd; sync_cmd; list_cmd; info_cmd]
794
795let () = exit (Cmd.eval' main_cmd)