My agentic slop goes here. Not intended for anyone else!
at jsont 28 kB view raw
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)