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

river

+1 -1
stack/river/bin/dune
···
(executable
-
(public_name river-cli)
(name river_cli)
(libraries river river_cmd cmdliner jsont jsont.bytesrw fmt fmt.tty fmt.cli eio eio_main eiocmd unix ptime syndic xdge))
···
(executable
+
(public_name river)
(name river_cli)
(libraries river river_cmd cmdliner jsont jsont.bytesrw fmt fmt.tty fmt.cli eio eio_main eiocmd unix ptime syndic xdge))
+46 -280
stack/river/cmd/river_cmd.ml
···
let src = Logs.Src.create "river-cli" ~doc:"River CLI application"
module Log = (val Logs.src_log src : Logs.LOG)
-
(* User management commands *)
module User = struct
-
let add state ~username ~fullname ~email =
-
let user = River.User.make ~username ~fullname ?email () in
-
match River.State.create_user state user with
-
| Ok () ->
-
Fmt.pr "@.%a %a %a@.@."
-
Fmt.(styled (`Fg `Green) string) "✓"
-
Fmt.(styled `Bold string) "User created:"
-
Fmt.(styled (`Fg `Cyan) string) username;
-
0
-
| Error err ->
-
Fmt.pr "@.%a %s@.@."
-
Fmt.(styled (`Fg `Red) string) "✗ Error:"
-
err;
-
1
-
-
let remove state ~username =
-
match River.State.delete_user state ~username with
-
| Ok () ->
-
Fmt.pr "@.%a %a %a@.@."
-
Fmt.(styled (`Fg `Green) string) "✓"
-
Fmt.(styled `Bold string) "User removed:"
-
Fmt.(styled (`Fg `Cyan) string) username;
-
0
-
| Error err ->
-
Fmt.pr "@.%a %s@.@."
-
Fmt.(styled (`Fg `Red) string) "✗ Error:"
-
err;
-
1
-
let list state =
let users = River.State.list_users state in
if users = [] then begin
Fmt.pr "@.%a@.@."
Fmt.(styled `Yellow string)
-
"No users found. Use 'river-cli user add' to create one."
end else begin
Fmt.pr "@.%a@."
Fmt.(styled `Bold (styled (`Fg `Cyan) string))
···
end;
0
-
let add_feed state ~username ~name ~url =
-
match River.State.get_user state ~username with
-
| None ->
-
Fmt.pr "@.%a User %a not found@.@."
-
Fmt.(styled (`Fg `Red) string) "✗ Error:"
-
Fmt.(styled `Bold string) username;
-
1
-
| Some user ->
-
let source = River.Source.make ~name ~url in
-
let user = River.User.add_feed user source in
-
(match River.State.update_user state user with
-
| Ok () ->
-
Fmt.pr "@.%a Feed added to %a@."
-
Fmt.(styled (`Fg `Green) string) "✓"
-
Fmt.(styled (`Fg `Cyan) string) username;
-
Fmt.pr " %a %a@."
-
Fmt.(styled `Faint string) "Name:"
-
Fmt.(styled `Bold string) name;
-
Fmt.pr " %a %a@.@."
-
Fmt.(styled `Faint string) "URL: "
-
Fmt.(styled (`Fg `Blue) string) url;
-
0
-
| Error err ->
-
Fmt.pr "@.%a %s@.@."
-
Fmt.(styled (`Fg `Red) string) "✗ Error:"
-
err;
-
1)
-
-
let remove_feed state ~username ~url =
-
match River.State.get_user state ~username with
-
| None ->
-
Fmt.pr "@.%a User %a not found@.@."
-
Fmt.(styled (`Fg `Red) string) "✗ Error:"
-
Fmt.(styled `Bold string) username;
-
1
-
| Some user ->
-
let user = River.User.remove_feed user ~url in
-
(match River.State.update_user state user with
-
| Ok () ->
-
Fmt.pr "@.%a Feed removed from %a@.@."
-
Fmt.(styled (`Fg `Green) string) "✓"
-
Fmt.(styled (`Fg `Cyan) string) username;
-
0
-
| Error err ->
-
Fmt.pr "@.%a %s@.@."
-
Fmt.(styled (`Fg `Red) string) "✗ Error:"
-
err;
-
1)
-
let show state ~username =
match River.State.get_user state ~username with
| None ->
···
Fmt.(styled `Yellow string)
(Option.value (River.User.last_synced user) ~default:"never");
let feeds = River.User.feeds user in
Fmt.pr "%a@."
Fmt.(styled `Bold string)
···
if feeds = [] then
Fmt.pr "%a@.@."
Fmt.(styled `Faint string)
-
" No feeds configured. Use 'river-cli user add-feed' to add one."
else
List.iter (fun feed ->
Fmt.pr "@.%a@."
···
Fmt.(styled (`Fg `Red) string) "✗"
err;
1
end
(* Post listing commands *)
···
(format_text_construct entry.title);
Fmt.pr "%a@.@." Fmt.(styled `Bold string) (String.make 70 '=');
-
(* Author and date *)
-
let author_name =
-
match River.State.get_user state ~username with
-
| Some user -> River.User.fullname user
-
| None ->
let (author, _) = entry.authors in
-
String.trim author.name
-
in
-
Fmt.pr "%a %a@." Fmt.(styled `Cyan string) "Author:"
-
Fmt.(styled `Green string) author_name;
Fmt.pr "%a %a@." Fmt.(styled `Cyan string) "Published:"
Fmt.(styled `Magenta string) (format_date entry.updated);
Fmt.pr "%a %a@.@." Fmt.(styled `Cyan string) "ID:"
···
let doc = "Username" in
Arg.(required & pos 0 (some string) None & info [] ~docv:"USERNAME" ~doc)
-
let fullname_arg =
-
let doc = "Full name of the user" in
-
Arg.(required & opt (some string) None & info ["name"; "n"] ~doc)
-
-
let email_arg =
-
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
-
Arg.(required & opt (some string) None & info ["name"; "n"] ~doc)
-
-
let feed_url_arg =
-
let doc = "Feed URL" in
-
Arg.(required & opt (some string) None & info ["url"; "u"] ~doc)
-
-
(* User commands - these don't need network, just filesystem access *)
-
let user_add =
-
Term.(const (fun username fullname email env _xdg _profile ->
-
let state = River.State.create env ~app_name:"river" in
-
User.add state ~username ~fullname ~email
-
) $ username_arg $ fullname_arg $ email_arg)
-
-
let user_remove =
-
Term.(const (fun username env _xdg _profile ->
-
let state = River.State.create env ~app_name:"river" in
-
User.remove state ~username
-
) $ username_arg)
-
let user_list =
Term.(const (fun env _xdg _profile ->
let state = River.State.create env ~app_name:"river" in
···
User.show state ~username
) $ username_arg)
-
let user_add_feed =
-
Term.(const (fun username name url env _xdg _profile ->
-
let state = River.State.create env ~app_name:"river" in
-
User.add_feed state ~username ~name ~url
-
) $ username_arg $ feed_name_arg $ feed_url_arg)
-
-
let user_remove_feed =
-
Term.(const (fun username url env _xdg _profile ->
-
let state = River.State.create env ~app_name:"river" in
-
User.remove_feed state ~username ~url
-
) $ username_arg $ feed_url_arg)
-
let user_cmd =
-
let doc = "Manage users" in
let info = Cmd.info "user" ~doc in
-
let user_add_cmd =
-
Eiocmd.run
-
~use_keyeio:false
-
~info:(Cmd.info "add" ~doc:"Add a new user")
-
~app_name:"river"
-
~service:"river"
-
user_add
-
in
-
let user_remove_cmd =
-
Eiocmd.run
-
~use_keyeio:false
-
~info:(Cmd.info "remove" ~doc:"Remove a user")
-
~app_name:"river"
-
~service:"river"
-
user_remove
-
in
let user_list_cmd =
Eiocmd.run
~use_keyeio:false
-
~info:(Cmd.info "list" ~doc:"List all users")
~app_name:"river"
~service:"river"
user_list
···
~service:"river"
user_show
in
-
let user_add_feed_cmd =
-
Eiocmd.run
-
~use_keyeio:false
-
~info:(Cmd.info "add-feed" ~doc:"Add a feed to a user")
-
~app_name:"river"
-
~service:"river"
-
user_add_feed
-
in
-
let user_remove_feed_cmd =
-
Eiocmd.run
-
~use_keyeio:false
-
~info:(Cmd.info "remove-feed" ~doc:"Remove a feed from a user")
-
~app_name:"river"
-
~service:"river"
-
user_remove_feed
-
in
Cmd.group info [
-
user_add_cmd;
-
user_remove_cmd;
user_list_cmd;
user_show_cmd;
-
user_add_feed_cmd;
-
user_remove_feed_cmd;
]
(* Sync command - needs Eio environment for HTTP requests *)
···
Log.err (fun m -> m "Failed to export merged feed: %s" err);
1
) $ format_arg $ title_arg $ limit_arg)
-
-
(* Quality command - analyze feed quality *)
-
let quality =
-
let username_arg =
-
let doc = "Username to analyze" in
-
Arg.(required & pos 0 (some string) None & info [] ~docv:"USERNAME" ~doc)
-
in
-
Term.(const (fun username env _xdg _profile ->
-
let state = River.State.create env ~app_name:"river" in
-
match River.State.analyze_user_quality state ~username with
-
| Error err ->
-
Log.err (fun m -> m "%s" err);
-
1
-
| Ok metrics ->
-
(* Display quality metrics *)
-
Fmt.pr "@.%a@."
-
Fmt.(styled `Bold (styled (`Fg `Cyan) string))
-
(Printf.sprintf "Feed Quality Analysis: %s" username);
-
Fmt.pr "%a@.@." Fmt.(styled `Faint string) (String.make 70 '=');
-
-
(* Overall quality score with visual indicator *)
-
let score = River.Quality.quality_score metrics in
-
let score_color, score_label = match score with
-
| s when s >= 80.0 -> `Green, "Excellent"
-
| s when s >= 60.0 -> `Yellow, "Good"
-
| s when s >= 40.0 -> `Magenta, "Fair"
-
| _ -> `Red, "Poor"
-
in
-
let bar_width = 40 in
-
let filled = int_of_float (score /. 100.0 *. float_of_int bar_width) in
-
let bar = String.make filled '#' ^ String.make (bar_width - filled) '-' in
-
Fmt.pr "%a@."
-
Fmt.(styled `Bold string) "Overall Quality Score";
-
Fmt.pr " %a %.1f/100 %a@.@."
-
Fmt.(styled (`Fg score_color) string) bar
-
score
-
Fmt.(styled (`Fg score_color) (styled `Bold string)) (Printf.sprintf "(%s)" score_label);
-
-
(* Entry statistics *)
-
Fmt.pr "%a %a@."
-
Fmt.(styled `Bold string) "📊 Entries:"
-
Fmt.(styled (`Fg `Yellow) (styled `Bold string))
-
(string_of_int (River.Quality.total_entries metrics));
-
Fmt.pr "@.";
-
-
(* Completeness metrics with visual indicators *)
-
Fmt.pr "%a@." Fmt.(styled `Bold string) "Completeness";
-
let total = River.Quality.total_entries metrics in
-
let pct entries =
-
float_of_int entries /. float_of_int total *. 100.0
-
in
-
let show_metric label count =
-
let p = pct count in
-
let icon, color = match p with
-
| p when p >= 90.0 -> "✓", `Green
-
| p when p >= 50.0 -> "○", `Yellow
-
| _ -> "✗", `Red
-
in
-
Fmt.pr " %a %s %3d/%d %a@."
-
Fmt.(styled (`Fg color) string) icon
-
label
-
count total
-
Fmt.(styled `Faint string) (Printf.sprintf "(%.1f%%)" p)
-
in
-
show_metric "Content: " (River.Quality.entries_with_content metrics);
-
show_metric "Dates: " (River.Quality.entries_with_date metrics);
-
show_metric "Authors: " (River.Quality.entries_with_author metrics);
-
show_metric "Summaries:" (River.Quality.entries_with_summary metrics);
-
show_metric "Tags: " (River.Quality.entries_with_tags metrics);
-
Fmt.pr "@.";
-
-
(* Content statistics *)
-
if River.Quality.entries_with_content metrics > 0 then begin
-
Fmt.pr "%a@." Fmt.(styled `Bold string) "Content Statistics";
-
Fmt.pr " %a %.0f chars@."
-
Fmt.(styled `Faint string) "Average:"
-
(River.Quality.avg_content_length metrics);
-
Fmt.pr " %a %a ... %a@.@."
-
Fmt.(styled `Faint string) "Range: "
-
Fmt.(styled (`Fg `Cyan) string) (string_of_int (River.Quality.min_content_length metrics))
-
Fmt.(styled (`Fg `Cyan) string) (string_of_int (River.Quality.max_content_length metrics))
-
end;
-
-
(* Posting frequency *)
-
(match River.Quality.posting_frequency_days metrics with
-
| Some freq ->
-
Fmt.pr "%a@." Fmt.(styled `Bold string) "Posting Frequency";
-
let posts_per_week = 7.0 /. freq in
-
Fmt.pr " %a %.1f days between posts@."
-
Fmt.(styled `Faint string) "Average:"
-
freq;
-
Fmt.pr " %a ~%.1f posts/week@.@."
-
Fmt.(styled `Faint string) " "
-
posts_per_week
-
| None ->
-
Fmt.pr "%a@.@." Fmt.(styled `Faint string)
-
"Not enough data to calculate posting frequency");
-
-
Fmt.pr "@.";
-
0
-
) $ username_arg)
-
let main_cmd =
let doc = "River feed management CLI" in
let main_info = Cmd.info "river-cli" ~version:"1.0" ~doc in
···
~service:"river"
merge
in
-
let quality_cmd =
-
Eiocmd.run
-
~use_keyeio:false
-
~info:(Cmd.info "quality" ~doc:"Analyze feed quality metrics for a user")
-
~app_name:"river"
-
~service:"river"
-
quality
-
in
-
Cmd.group main_info [user_cmd; sync_cmd; list_cmd; info_cmd; merge_cmd; quality_cmd]
···
let src = Logs.Src.create "river-cli" ~doc:"River CLI application"
module Log = (val Logs.src_log src : Logs.LOG)
+
(* User display formatting *)
+
module User_fmt = struct
+
let pp_user_with_handle ppf (handle, fullname) =
+
Fmt.pf ppf "%a (%a)"
+
Fmt.(styled (`Fg `Cyan) string) handle
+
Fmt.(styled `Green string) fullname
+
end
+
+
(* User management commands - read-only, users managed in Sortal *)
module User = struct
let list state =
let users = River.State.list_users state in
if users = [] then begin
Fmt.pr "@.%a@.@."
Fmt.(styled `Yellow string)
+
"No users found. Add contacts with feeds to Sortal to see them here."
end else begin
Fmt.pr "@.%a@."
Fmt.(styled `Bold (styled (`Fg `Cyan) string))
···
end;
0
let show state ~username =
match River.State.get_user state ~username with
| None ->
···
Fmt.(styled `Yellow string)
(Option.value (River.User.last_synced user) ~default:"never");
+
(* Quality analysis *)
+
(match River.State.analyze_user_quality state ~username with
+
| Ok metrics ->
+
let score = River.Quality.quality_score metrics in
+
let total = River.Quality.total_entries metrics in
+
let score_color, score_label = match score with
+
| s when s >= 80.0 -> `Green, "Excellent"
+
| s when s >= 60.0 -> `Yellow, "Good"
+
| s when s >= 40.0 -> `Magenta, "Fair"
+
| _ -> `Red, "Poor"
+
in
+
Fmt.pr "%a %a %.1f/100 %a - %d posts@.@."
+
Fmt.(styled `Faint string) "Quality: "
+
Fmt.(styled (`Fg score_color) string) "●"
+
score
+
Fmt.(styled (`Fg score_color) string) (Printf.sprintf "(%s)" score_label)
+
total
+
| Error _ ->
+
Fmt.pr "%a %a@.@."
+
Fmt.(styled `Faint string) "Quality: "
+
Fmt.(styled `Faint string) "(not synced yet)");
+
let feeds = River.User.feeds user in
Fmt.pr "%a@."
Fmt.(styled `Bold string)
···
if feeds = [] then
Fmt.pr "%a@.@."
Fmt.(styled `Faint string)
+
" No feeds configured for this contact."
else
List.iter (fun feed ->
Fmt.pr "@.%a@."
···
Fmt.(styled (`Fg `Red) string) "✗"
err;
1
+
end
(* Post listing commands *)
···
(format_text_construct entry.title);
Fmt.pr "%a@.@." Fmt.(styled `Bold string) (String.make 70 '=');
+
(* Author and date - show handle and full name *)
+
(match River.State.get_user state ~username with
+
| Some user ->
+
Fmt.pr "%a %a@." Fmt.(styled `Cyan string) "Author:"
+
User_fmt.pp_user_with_handle (username, River.User.fullname user)
+
| None ->
let (author, _) = entry.authors in
+
Fmt.pr "%a %a@." Fmt.(styled `Cyan string) "Author:"
+
Fmt.(styled `Green string) (String.trim author.name));
Fmt.pr "%a %a@." Fmt.(styled `Cyan string) "Published:"
Fmt.(styled `Magenta string) (format_date entry.updated);
Fmt.pr "%a %a@.@." Fmt.(styled `Cyan string) "ID:"
···
let doc = "Username" in
Arg.(required & pos 0 (some string) None & info [] ~docv:"USERNAME" ~doc)
+
(* User commands - read-only, users managed in Sortal *)
let user_list =
Term.(const (fun env _xdg _profile ->
let state = River.State.create env ~app_name:"river" in
···
User.show state ~username
) $ username_arg)
let user_cmd =
+
let doc = "View users from Sortal" in
let info = Cmd.info "user" ~doc in
let user_list_cmd =
Eiocmd.run
~use_keyeio:false
+
~info:(Cmd.info "list" ~doc:"List all users from Sortal")
~app_name:"river"
~service:"river"
user_list
···
~service:"river"
user_show
in
Cmd.group info [
user_list_cmd;
user_show_cmd;
]
(* Sync command - needs Eio environment for HTTP requests *)
···
Log.err (fun m -> m "Failed to export merged feed: %s" err);
1
) $ format_arg $ title_arg $ limit_arg)
let main_cmd =
let doc = "River feed management CLI" in
let main_info = Cmd.info "river-cli" ~version:"1.0" ~doc in
···
~service:"river"
merge
in
+
Cmd.group main_info [user_cmd; sync_cmd; list_cmd; info_cmd; merge_cmd]
+3 -39
stack/river/cmd/river_cmd.mli
···
(** {2 User Management Commands} *)
-
val user_add :
-
(Eio_unix.Stdenv.base -> Xdge.t -> Keyeio.Profile.t -> int) Term.t
-
(** [user_add] command term for adding a new user.
-
-
Reads: username, fullname, email from command-line arguments.
-
Calls: [River.State.create_user] *)
-
-
val user_remove :
-
(Eio_unix.Stdenv.base -> Xdge.t -> Keyeio.Profile.t -> int) Term.t
-
(** [user_remove] command term for removing a user.
-
-
Reads: username from command-line arguments.
-
Calls: [River.State.delete_user] *)
-
val user_list :
(Eio_unix.Stdenv.base -> Xdge.t -> Keyeio.Profile.t -> int) Term.t
-
(** [user_list] command term for listing all users.
Calls: [River.State.list_users] *)
val user_show :
···
Reads: username from command-line arguments.
Calls: [River.State.get_user] *)
-
val user_add_feed :
-
(Eio_unix.Stdenv.base -> Xdge.t -> Keyeio.Profile.t -> int) Term.t
-
(** [user_add_feed] command term for adding a feed to a user.
-
-
Reads: username, name, url from command-line arguments.
-
Calls: [River.State.get_user], [River.User.add_feed], [River.State.update_user] *)
-
-
val user_remove_feed :
-
(Eio_unix.Stdenv.base -> Xdge.t -> Keyeio.Profile.t -> int) Term.t
-
(** [user_remove_feed] command term for removing a feed from a user.
-
-
Reads: username, url from command-line arguments.
-
Calls: [River.State.get_user], [River.User.remove_feed], [River.State.update_user] *)
-
val user_cmd : int Cmd.t
-
(** [user_cmd] is the user management command group containing all user subcommands. *)
(** {2 Feed Sync Commands} *)
···
Reads: format (atom|jsonfeed), title, limit from command-line arguments.
Calls: [River.State.export_merged_feed] *)
-
-
(** {2 Quality Analysis Commands} *)
-
-
val quality :
-
(Eio_unix.Stdenv.base -> Xdge.t -> Keyeio.Profile.t -> int) Term.t
-
(** [quality] command term for analyzing feed quality.
-
-
Reads: username from command-line arguments.
-
Calls: [River.State.analyze_user_quality] *)
(** {2 Main Command} *)
···
(** {2 User Management Commands} *)
val user_list :
(Eio_unix.Stdenv.base -> Xdge.t -> Keyeio.Profile.t -> int) Term.t
+
(** [user_list] command term for listing all users from Sortal.
+
Reads from Sortal contact database.
Calls: [River.State.list_users] *)
val user_show :
···
Reads: username from command-line arguments.
Calls: [River.State.get_user] *)
val user_cmd : int Cmd.t
+
(** [user_cmd] is the user viewing command group (read-only, users managed in Sortal). *)
(** {2 Feed Sync Commands} *)
···
Reads: format (atom|jsonfeed), title, limit from command-line arguments.
Calls: [River.State.export_merged_feed] *)
(** {2 Main Command} *)
+1
stack/river/dune-project
···
(jsonfeed (>= 1.1.0))
(jsont (>= 0.2.0))
(jsont.bytesrw (>= 0.2.0))
(odoc :with-doc)))
···
(jsonfeed (>= 1.1.0))
(jsont (>= 0.2.0))
(jsont.bytesrw (>= 0.2.0))
+
sortal
(odoc :with-doc)))
+1 -1
stack/river/lib/dune
···
(library
(name river)
(public_name river)
-
(libraries eio eio_main requests requests_json_api logs str syndic lambdasoup uri ptime jsonfeed jsont jsont.bytesrw xdge cmdliner eiocmd fmt))
···
(library
(name river)
(public_name river)
+
(libraries eio eio_main requests requests_json_api logs str syndic lambdasoup uri ptime jsonfeed jsont jsont.bytesrw xdge cmdliner eiocmd fmt sortal))
+7 -2
stack/river/lib/feed.ml
···
(Source.url source) (String.length body));
body
| Error (status, msg) ->
Log.err (fun m -> m "Failed to fetch feed '%s': HTTP %d - %s"
-
(Source.name source) status msg);
-
failwith (Printf.sprintf "HTTP %d: %s" status msg)
in
let content = classify_feed ~xmlbase response in
···
(Source.url source) (String.length body));
body
| Error (status, msg) ->
+
let truncated_msg =
+
if String.length msg > 200
+
then String.sub msg 0 200 ^ "..."
+
else msg
+
in
Log.err (fun m -> m "Failed to fetch feed '%s': HTTP %d - %s"
+
(Source.name source) status truncated_msg);
+
failwith (Printf.sprintf "HTTP %d: %s" status truncated_msg)
in
let content = classify_feed ~xmlbase response in
+35 -40
stack/river/lib/river.mli
···
(** {1 User Management} *)
module User : sig
type t
-
(** User configuration with feed subscriptions. *)
-
val make :
-
username:string ->
-
fullname:string ->
-
?email:string ->
-
?feeds:Source.t list ->
-
?last_synced:string ->
-
unit ->
-
t
-
(** [make ~username ~fullname ()] creates a new user.
-
@param username Unique username identifier
-
@param fullname User's display name
-
@param email Optional email address
-
@param feeds Optional list of feed sources (default: [])
@param last_synced Optional ISO 8601 timestamp of last sync *)
val username : t -> string
-
(** [username user] returns the username. *)
val fullname : t -> string
-
(** [fullname user] returns the full name. *)
val email : t -> string option
-
(** [email user] returns the email address if set. *)
val feeds : t -> Source.t list
-
(** [feeds user] returns the list of subscribed feeds. *)
val last_synced : t -> string option
(** [last_synced user] returns the last sync timestamp if set. *)
-
val add_feed : t -> Source.t -> t
-
(** [add_feed user source] returns a new user with the feed added. *)
-
-
val remove_feed : t -> url:string -> t
-
(** [remove_feed user ~url] returns a new user with the feed removed by URL. *)
val set_last_synced : t -> string -> t
(** [set_last_synced user timestamp] returns a new user with updated sync time. *)
-
-
val jsont : t Jsont.t
-
(** JSON codec for users. *)
end
(** {1 Feed Quality Analysis} *)
···
module State : sig
type t
-
(** State handle for managing user data and feeds on disk. *)
val create :
< fs : Eio.Fs.dir_ty Eio.Path.t; .. > ->
···
(** [create env ~app_name] creates a state handle using XDG directories.
Data is stored in:
-
- Users: $XDG_STATE_HOME/[app_name]/users/
-
- Feeds: $XDG_STATE_HOME/[app_name]/feeds/user/
@param env The Eio environment with filesystem access
@param app_name Application name for XDG paths *)
(** {2 User Operations} *)
-
val create_user : t -> User.t -> (unit, string) result
-
(** [create_user state user] creates a new user.
-
Returns [Error] if the user already exists. *)
-
val delete_user : t -> username:string -> (unit, string) result
-
(** [delete_user state ~username] deletes a user and their feed data. *)
-
val get_user : t -> username:string -> User.t option
-
(** [get_user state ~username] retrieves a user by username. *)
-
val update_user : t -> User.t -> (unit, string) result
-
(** [update_user state user] saves updated user configuration. *)
-
val list_users : t -> string list
-
(** [list_users state] returns all usernames. *)
(** {2 Feed Operations} *)
···
(** {1 User Management} *)
module User : sig
+
(** River user composed from Sortal contact data + sync state.
+
+
User data is stored in Sortal and read on-demand. River only persists
+
sync timestamps and optional per-user overrides. *)
+
type t
+
(** A River user composed from Sortal.Contact + sync metadata. *)
+
val of_contact : Sortal.Contact.t -> ?last_synced:string -> unit -> t
+
(** [of_contact contact ()] creates a River user from a Sortal contact.
+
@param contact The Sortal contact to base this user on
@param last_synced Optional ISO 8601 timestamp of last sync *)
val username : t -> string
+
(** [username user] returns the username (from Sortal.Contact.handle). *)
val fullname : t -> string
+
(** [fullname user] returns the full name (from Sortal.Contact.primary_name). *)
val email : t -> string option
+
(** [email user] returns the email address (from Sortal.Contact). *)
val feeds : t -> Source.t list
+
(** [feeds user] returns the list of subscribed feeds (from Sortal.Contact). *)
val last_synced : t -> string option
(** [last_synced user] returns the last sync timestamp if set. *)
+
val contact : t -> Sortal.Contact.t
+
(** [contact user] returns the underlying Sortal contact. *)
val set_last_synced : t -> string -> t
(** [set_last_synced user timestamp] returns a new user with updated sync time. *)
end
(** {1 Feed Quality Analysis} *)
···
module State : sig
type t
+
(** State handle for managing sync state and feeds on disk.
+
+
User contact data is read from Sortal on-demand. River only persists
+
sync timestamps and feed data. *)
val create :
< fs : Eio.Fs.dir_ty Eio.Path.t; .. > ->
···
(** [create env ~app_name] creates a state handle using XDG directories.
Data is stored in:
+
- Sync state: $XDG_STATE_HOME/[app_name]/sync_state.json
+
- Feeds: $XDG_STATE_HOME/[app_name]/feeds/[username]/
+
+
User contact data is read from Sortal's XDG location.
@param env The Eio environment with filesystem access
@param app_name Application name for XDG paths *)
(** {2 User Operations} *)
+
val get_user : t -> username:string -> User.t option
+
(** [get_user state ~username] retrieves a user by username.
+
This reads contact data from Sortal and combines it with River's sync state.
+
Returns [None] if the username doesn't exist in Sortal or has no feeds. *)
+
val list_users : t -> string list
+
(** [list_users state] returns all usernames with feeds from Sortal. *)
+
val get_all_users : t -> User.t list
+
(** [get_all_users state] returns all users from Sortal with their sync state. *)
+
val update_sync_state : t -> username:string -> timestamp:string -> (unit, string) result
+
(** [update_sync_state state ~username ~timestamp] updates the last sync timestamp.
+
@param username The user to update
+
@param timestamp ISO 8601 timestamp of the sync *)
(** {2 Feed Operations} *)
+89 -83
stack/river/lib/state.ml
···
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*)
-
(** State management for user data and feeds. *)
let src = Logs.Src.create "river" ~doc:"River RSS/Atom aggregator"
module Log = (val Logs.src_log src : Logs.LOG)
type t = {
xdg : Xdge.t;
}
module Paths = struct
-
(** Get the users directory path *)
-
let users_dir state = Eio.Path.(Xdge.state_dir state.xdg / "users")
-
(** Get the feeds directory path *)
let feeds_dir state = Eio.Path.(Xdge.state_dir state.xdg / "feeds")
(** Get the user feeds directory path *)
let user_feeds_dir state = Eio.Path.(feeds_dir state / "user")
-
(** Get the path to a user's JSON file *)
-
let user_file state username =
-
Eio.Path.(users_dir state / (username ^ ".json"))
(** Get the path to a user's Atom feed file *)
let user_feed_file state username =
···
(** Ensure all necessary directories exist *)
let ensure_directories state =
let dirs = [
-
users_dir state;
feeds_dir state;
user_feeds_dir state;
] in
···
) dirs
end
-
module Json = struct
-
(** Decode a user from JSON string *)
-
let user_of_string s =
-
match Jsont_bytesrw.decode_string' User.jsont s with
-
| Ok user -> Some user
-
| Error err ->
-
Log.err (fun m -> m "Failed to parse user JSON: %s" (Jsont.Error.to_string err));
-
None
-
-
(** Encode a user to JSON string *)
-
let user_to_string user =
-
match Jsont_bytesrw.encode_string' ~format:Jsont.Indent User.jsont user with
-
| Ok s -> s
-
| Error err -> failwith ("Failed to encode user: " ^ Jsont.Error.to_string err)
-
end
-
module Storage = struct
-
(** Load a user from disk *)
-
let load_user state username =
-
let file = Paths.user_file state username in
try
let content = Eio.Path.load file in
-
Json.user_of_string content
with
-
| Eio.Io (Eio.Fs.E (Not_found _), _) -> None
| e ->
-
Log.err (fun m -> m "Error loading user %s: %s" username (Printexc.to_string e));
-
None
-
(** Save a user to disk *)
-
let save_user state user =
-
let file = Paths.user_file state (User.username user) in
-
let json = Json.user_to_string user in
-
Eio.Path.save ~create:(`Or_truncate 0o644) file json
-
(** List all usernames *)
let list_users state =
try
-
Eio.Path.read_dir (Paths.users_dir state)
-
|> List.filter_map (fun name ->
-
if Filename.check_suffix name ".json" then
-
Some (Filename.chop_suffix name ".json")
-
else None
-
)
-
with _ -> []
(** Load existing Atom entries for a user *)
let load_existing_posts state username =
···
let feed = Format.Atom.feed_of_entries ~title:username entries in
let xml = Format.Atom.to_string feed in
Eio.Path.save ~create:(`Or_truncate 0o644) file xml
-
-
(** Delete a user and their feed file *)
-
let delete_user state username =
-
let user_file = Paths.user_file state username in
-
let feed_file = Paths.user_feed_file state username in
-
(try Eio.Path.unlink user_file with _ -> ());
-
(try Eio.Path.unlink feed_file with _ -> ())
end
module Sync = struct
···
(** Sync feeds for a single user *)
let sync_user session state ~username =
-
match Storage.load_user state username with
| None ->
Error (Printf.sprintf "User %s not found" username)
| Some user when User.feeds user = [] ->
···
(* Update last_synced timestamp *)
let now = current_timestamp () in
-
let user = User.set_last_synced user now in
-
Storage.save_user state user;
Log.info (fun m -> m "Sync completed for user %s" username);
Ok ()
···
let create env ~app_name =
let xdg = Xdge.create env#fs app_name in
-
let state = { xdg } in
Paths.ensure_directories state;
state
-
let create_user state user =
-
match Storage.load_user state (User.username user) with
-
| Some _ ->
-
Error (Printf.sprintf "User %s already exists" (User.username user))
-
| None ->
-
Storage.save_user state user;
-
Log.info (fun m -> m "User %s created" (User.username user));
-
Ok ()
-
-
let delete_user state ~username =
-
match Storage.load_user state username with
-
| None ->
-
Error (Printf.sprintf "User %s not found" username)
-
| Some _ ->
-
Storage.delete_user state username;
-
Log.info (fun m -> m "User %s deleted" username);
-
Ok ()
-
let get_user state ~username =
-
Storage.load_user state username
-
let update_user state user =
-
match Storage.load_user state (User.username user) with
-
| None ->
-
Error (Printf.sprintf "User %s not found" (User.username user))
-
| Some _ ->
-
Storage.save_user state user;
-
Log.info (fun m -> m "User %s updated" (User.username user));
-
Ok ()
let list_users state =
Storage.list_users state
let sync_user env state ~username =
Session.with_session env @@ fun session ->
···
Export.export_jsonfeed ~title entries
let analyze_user_quality state ~username =
-
match Storage.load_user state username with
| None ->
Error (Printf.sprintf "User %s not found" username)
| Some _ ->
···
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*)
+
(** State management for sync state and feeds.
+
+
User contact data is read from Sortal on-demand. River only persists
+
sync timestamps and feed data. *)
let src = Logs.Src.create "river" ~doc:"River RSS/Atom aggregator"
module Log = (val Logs.src_log src : Logs.LOG)
type t = {
xdg : Xdge.t;
+
sortal : Sortal.t;
}
module Paths = struct
(** Get the feeds directory path *)
let feeds_dir state = Eio.Path.(Xdge.state_dir state.xdg / "feeds")
(** Get the user feeds directory path *)
let user_feeds_dir state = Eio.Path.(feeds_dir state / "user")
+
(** Get the sync state file path *)
+
let sync_state_file state = Eio.Path.(Xdge.state_dir state.xdg / "sync_state.json")
(** Get the path to a user's Atom feed file *)
let user_feed_file state username =
···
(** Ensure all necessary directories exist *)
let ensure_directories state =
let dirs = [
feeds_dir state;
user_feeds_dir state;
] in
···
) dirs
end
+
(** Sync state management - maps username to last_synced timestamp *)
+
module Sync_state = struct
+
let jsont =
+
let pair_t =
+
let make username timestamp = (username, timestamp) in
+
Jsont.Object.map ~kind:"SyncEntry" make
+
|> Jsont.Object.mem "username" Jsont.string ~enc:fst
+
|> Jsont.Object.mem "timestamp" Jsont.string ~enc:snd
+
|> Jsont.Object.finish
+
in
+
Jsont.Object.map ~kind:"SyncState" (fun pairs -> pairs)
+
|> Jsont.Object.mem "synced_users" (Jsont.list pair_t) ~enc:(fun s -> s)
+
|> Jsont.Object.finish
+
let load state =
+
let file = Paths.sync_state_file state in
try
let content = Eio.Path.load file in
+
match Jsont_bytesrw.decode_string' jsont content with
+
| Ok pairs -> pairs
+
| Error err ->
+
Log.warn (fun m -> m "Failed to parse sync state: %s" (Jsont.Error.to_string err));
+
[]
with
+
| Eio.Io (Eio.Fs.E (Not_found _), _) -> []
| e ->
+
Log.err (fun m -> m "Error loading sync state: %s" (Printexc.to_string e));
+
[]
+
+
let save state sync_state =
+
let file = Paths.sync_state_file state in
+
match Jsont_bytesrw.encode_string' ~format:Jsont.Indent jsont sync_state with
+
| Ok json -> Eio.Path.save ~create:(`Or_truncate 0o644) file json
+
| Error err -> failwith ("Failed to encode sync state: " ^ Jsont.Error.to_string err)
+
let get_timestamp state username =
+
load state |> List.assoc_opt username
+
+
let set_timestamp state username timestamp =
+
let sync_state = load state in
+
let updated = (username, timestamp) :: List.remove_assoc username sync_state in
+
save state updated
+
end
+
module Storage = struct
+
(** List all usernames with feeds from Sortal *)
let list_users state =
try
+
Sortal.list state.sortal
+
|> List.filter (fun contact -> Sortal.Contact.feeds contact <> None)
+
|> List.map Sortal.Contact.handle
+
with e ->
+
Log.err (fun m -> m "Error listing Sortal users: %s" (Printexc.to_string e));
+
[]
+
+
(** Get a user from Sortal with sync state *)
+
let get_user state username =
+
match Sortal.lookup state.sortal username with
+
| None -> None
+
| Some contact ->
+
(* Only return users with feeds *)
+
if Sortal.Contact.feeds contact = None then None
+
else
+
let last_synced = Sync_state.get_timestamp state username in
+
Some (User.of_contact contact ?last_synced ())
+
+
(** Get all users from Sortal with sync state *)
+
let get_all_users state =
+
try
+
Sortal.list state.sortal
+
|> List.filter (fun contact -> Sortal.Contact.feeds contact <> None)
+
|> List.map (fun contact ->
+
let username = Sortal.Contact.handle contact in
+
let last_synced = Sync_state.get_timestamp state username in
+
User.of_contact contact ?last_synced ())
+
with e ->
+
Log.err (fun m -> m "Error getting all users: %s" (Printexc.to_string e));
+
[]
(** Load existing Atom entries for a user *)
let load_existing_posts state username =
···
let feed = Format.Atom.feed_of_entries ~title:username entries in
let xml = Format.Atom.to_string feed in
Eio.Path.save ~create:(`Or_truncate 0o644) file xml
end
module Sync = struct
···
(** Sync feeds for a single user *)
let sync_user session state ~username =
+
match Storage.get_user state username with
| None ->
Error (Printf.sprintf "User %s not found" username)
| Some user when User.feeds user = [] ->
···
(* Update last_synced timestamp *)
let now = current_timestamp () in
+
Sync_state.set_timestamp state username now;
Log.info (fun m -> m "Sync completed for user %s" username);
Ok ()
···
let create env ~app_name =
let xdg = Xdge.create env#fs app_name in
+
(* Sortal always uses "sortal" as the app name for shared contact database *)
+
let sortal = Sortal.create env#fs "sortal" in
+
let state = { xdg; sortal } in
Paths.ensure_directories state;
state
let get_user state ~username =
+
Storage.get_user state username
+
let get_all_users state =
+
Storage.get_all_users state
let list_users state =
Storage.list_users state
+
+
let update_sync_state state ~username ~timestamp =
+
Sync_state.set_timestamp state username timestamp;
+
Ok ()
let sync_user env state ~username =
Session.with_session env @@ fun session ->
···
Export.export_jsonfeed ~title entries
let analyze_user_quality state ~username =
+
match Storage.get_user state username with
| None ->
Error (Printf.sprintf "User %s not found" username)
| Some _ ->
+21 -15
stack/river/lib/state.mli
···
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*)
-
(** State management for user data and feeds. *)
type t
-
(** State handle for managing user data and feeds on disk. *)
val create :
< fs : Eio.Fs.dir_ty Eio.Path.t; .. > ->
···
(** [create env ~app_name] creates a state handle using XDG directories.
Data is stored in:
-
- Users: $XDG_STATE_HOME/[app_name]/users/
-
- Feeds: $XDG_STATE_HOME/[app_name]/feeds/user/
@param env The Eio environment with filesystem access
@param app_name Application name for XDG paths *)
(** {2 User Operations} *)
-
val create_user : t -> User.t -> (unit, string) result
-
(** [create_user state user] creates a new user.
-
Returns [Error] if the user already exists. *)
-
val delete_user : t -> username:string -> (unit, string) result
-
(** [delete_user state ~username] deletes a user and their feed data. *)
-
val get_user : t -> username:string -> User.t option
-
(** [get_user state ~username] retrieves a user by username. *)
-
val update_user : t -> User.t -> (unit, string) result
-
(** [update_user state user] saves updated user configuration. *)
-
val list_users : t -> string list
-
(** [list_users state] returns all usernames. *)
(** {2 Feed Operations} *)
···
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*)
+
(** State management for sync state and feeds.
+
+
User contact data is read from Sortal on-demand. River only persists
+
sync timestamps and feed data. *)
type t
+
(** State handle for managing sync state and feeds on disk. *)
val create :
< fs : Eio.Fs.dir_ty Eio.Path.t; .. > ->
···
(** [create env ~app_name] creates a state handle using XDG directories.
Data is stored in:
+
- Sync state: $XDG_STATE_HOME/[app_name]/sync_state.json
+
- Feeds: $XDG_STATE_HOME/[app_name]/feeds/[username]/
+
+
User contact data is read from Sortal's XDG location.
@param env The Eio environment with filesystem access
@param app_name Application name for XDG paths *)
(** {2 User Operations} *)
+
val get_user : t -> username:string -> User.t option
+
(** [get_user state ~username] retrieves a user by username.
+
This reads contact data from Sortal and combines it with River's sync state.
+
Returns [None] if the username doesn't exist in Sortal or has no feeds. *)
+
val list_users : t -> string list
+
(** [list_users state] returns all usernames with feeds from Sortal. *)
+
val get_all_users : t -> User.t list
+
(** [get_all_users state] returns all users from Sortal with their sync state. *)
+
val update_sync_state : t -> username:string -> timestamp:string -> (unit, string) result
+
(** [update_sync_state state ~username ~timestamp] updates the last sync timestamp.
+
@param username The user to update
+
@param timestamp ISO 8601 timestamp of the sync *)
(** {2 Feed Operations} *)
+19 -29
stack/river/lib/user.ml
···
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*)
-
(** User management. *)
type t = {
-
username : string;
-
fullname : string;
-
email : string option;
-
feeds : Source.t list;
last_synced : string option;
}
-
let make ~username ~fullname ?email ?(feeds = []) ?last_synced () =
-
{ username; fullname; email; feeds; last_synced }
-
let username t = t.username
-
let fullname t = t.fullname
-
let email t = t.email
-
let feeds t = t.feeds
let last_synced t = t.last_synced
-
let add_feed t source =
-
{ t with feeds = source :: t.feeds }
-
-
let remove_feed t ~url =
-
let feeds = List.filter (fun s -> Source.url s <> url) t.feeds in
-
{ t with feeds }
let set_last_synced t timestamp =
{ t with last_synced = Some timestamp }
-
-
let jsont =
-
let make username fullname email feeds last_synced =
-
{ username; fullname; email; feeds; last_synced }
-
in
-
Jsont.Object.map ~kind:"User" make
-
|> Jsont.Object.mem "username" Jsont.string ~enc:(fun u -> u.username)
-
|> Jsont.Object.mem "fullname" Jsont.string ~enc:(fun u -> u.fullname)
-
|> Jsont.Object.opt_mem "email" Jsont.string ~enc:(fun u -> u.email)
-
|> Jsont.Object.mem "feeds" (Jsont.list Source.jsont) ~enc:(fun u -> u.feeds)
-
|> Jsont.Object.opt_mem "last_synced" Jsont.string ~enc:(fun u -> u.last_synced)
-
|> Jsont.Object.finish
···
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*)
+
(** River user composed from Sortal contact data + sync state. *)
type t = {
+
contact : Sortal.Contact.t;
last_synced : string option;
}
+
let of_contact contact ?last_synced () =
+
{ contact; last_synced }
+
let username t = Sortal.Contact.handle t.contact
+
let fullname t = Sortal.Contact.primary_name t.contact
+
let email t = Sortal.Contact.email t.contact
+
let contact t = t.contact
let last_synced t = t.last_synced
+
let feeds t =
+
match Sortal.Contact.feeds t.contact with
+
| None -> []
+
| Some sortal_feeds ->
+
List.map (fun feed ->
+
let name = match Sortal.Feed.name feed with
+
| Some n -> n
+
| None -> Sortal.Contact.primary_name t.contact ^ " feed"
+
in
+
Source.make ~name ~url:(Sortal.Feed.url feed)
+
) sortal_feeds
let set_last_synced t timestamp =
{ t with last_synced = Some timestamp }
+14 -27
stack/river/lib/user.mli
···
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*)
-
(** User management. *)
type t
-
(** User configuration with feed subscriptions. *)
-
val make :
-
username:string ->
-
fullname:string ->
-
?email:string ->
-
?feeds:Source.t list ->
-
?last_synced:string ->
-
unit ->
-
t
-
(** [make ~username ~fullname ()] creates a new user.
-
@param username Unique username identifier
-
@param fullname User's display name
-
@param email Optional email address
-
@param feeds Optional list of feed sources (default: [])
@param last_synced Optional ISO 8601 timestamp of last sync *)
val username : t -> string
-
(** [username user] returns the username. *)
val fullname : t -> string
-
(** [fullname user] returns the full name. *)
val email : t -> string option
-
(** [email user] returns the email address if set. *)
val feeds : t -> Source.t list
-
(** [feeds user] returns the list of subscribed feeds. *)
val last_synced : t -> string option
(** [last_synced user] returns the last sync timestamp if set. *)
-
val add_feed : t -> Source.t -> t
-
(** [add_feed user source] returns a new user with the feed added. *)
-
-
val remove_feed : t -> url:string -> t
-
(** [remove_feed user ~url] returns a new user with the feed removed by URL. *)
val set_last_synced : t -> string -> t
(** [set_last_synced user timestamp] returns a new user with updated sync time. *)
-
-
val jsont : t Jsont.t
-
(** JSON codec for users. *)
···
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*)
+
(** River user composed from Sortal contact data + sync state.
+
+
User data is stored in Sortal and read on-demand. River only persists
+
sync timestamps and optional per-user overrides. *)
type t
+
(** A River user composed from Sortal.Contact + sync metadata. *)
+
val of_contact : Sortal.Contact.t -> ?last_synced:string -> unit -> t
+
(** [of_contact contact ()] creates a River user from a Sortal contact.
+
@param contact The Sortal contact to base this user on
@param last_synced Optional ISO 8601 timestamp of last sync *)
val username : t -> string
+
(** [username user] returns the username (from Sortal.Contact.handle). *)
val fullname : t -> string
+
(** [fullname user] returns the full name (from Sortal.Contact.primary_name). *)
val email : t -> string option
+
(** [email user] returns the email address (from Sortal.Contact). *)
val feeds : t -> Source.t list
+
(** [feeds user] returns the list of subscribed feeds (from Sortal.Contact). *)
val last_synced : t -> string option
(** [last_synced user] returns the last sync timestamp if set. *)
+
val contact : t -> Sortal.Contact.t
+
(** [contact user] returns the underlying Sortal contact. *)
val set_last_synced : t -> string -> t
(** [set_last_synced user timestamp] returns a new user with updated sync time. *)
+1
stack/river/river.opam
···
"jsonfeed" {>= "1.1.0"}
"jsont" {>= "0.2.0"}
"jsont.bytesrw" {>= "0.2.0"}
"odoc" {with-doc}
]
build: [
···
"jsonfeed" {>= "1.1.0"}
"jsont" {>= "0.2.0"}
"jsont.bytesrw" {>= "0.2.0"}
+
"sortal"
"odoc" {with-doc}
]
build: [