···100100 (Source.url source) (String.length body));
101101 body
102102 | Error (status, msg) ->
103103+ let truncated_msg =
104104+ if String.length msg > 200
105105+ then String.sub msg 0 200 ^ "..."
106106+ else msg
107107+ in
103108 Log.err (fun m -> m "Failed to fetch feed '%s': HTTP %d - %s"
104104- (Source.name source) status msg);
105105- failwith (Printf.sprintf "HTTP %d: %s" status msg)
109109+ (Source.name source) status truncated_msg);
110110+ failwith (Printf.sprintf "HTTP %d: %s" status truncated_msg)
106111 in
107112108113 let content = classify_feed ~xmlbase response in
+35-40
stack/river/lib/river.mli
···237237(** {1 User Management} *)
238238239239module User : sig
240240+ (** River user composed from Sortal contact data + sync state.
241241+242242+ User data is stored in Sortal and read on-demand. River only persists
243243+ sync timestamps and optional per-user overrides. *)
244244+240245 type t
241241- (** User configuration with feed subscriptions. *)
246246+ (** A River user composed from Sortal.Contact + sync metadata. *)
242247243243- val make :
244244- username:string ->
245245- fullname:string ->
246246- ?email:string ->
247247- ?feeds:Source.t list ->
248248- ?last_synced:string ->
249249- unit ->
250250- t
251251- (** [make ~username ~fullname ()] creates a new user.
248248+ val of_contact : Sortal.Contact.t -> ?last_synced:string -> unit -> t
249249+ (** [of_contact contact ()] creates a River user from a Sortal contact.
252250253253- @param username Unique username identifier
254254- @param fullname User's display name
255255- @param email Optional email address
256256- @param feeds Optional list of feed sources (default: [])
251251+ @param contact The Sortal contact to base this user on
257252 @param last_synced Optional ISO 8601 timestamp of last sync *)
258253259254 val username : t -> string
260260- (** [username user] returns the username. *)
255255+ (** [username user] returns the username (from Sortal.Contact.handle). *)
261256262257 val fullname : t -> string
263263- (** [fullname user] returns the full name. *)
258258+ (** [fullname user] returns the full name (from Sortal.Contact.primary_name). *)
264259265260 val email : t -> string option
266266- (** [email user] returns the email address if set. *)
261261+ (** [email user] returns the email address (from Sortal.Contact). *)
267262268263 val feeds : t -> Source.t list
269269- (** [feeds user] returns the list of subscribed feeds. *)
264264+ (** [feeds user] returns the list of subscribed feeds (from Sortal.Contact). *)
270265271266 val last_synced : t -> string option
272267 (** [last_synced user] returns the last sync timestamp if set. *)
273268274274- val add_feed : t -> Source.t -> t
275275- (** [add_feed user source] returns a new user with the feed added. *)
276276-277277- val remove_feed : t -> url:string -> t
278278- (** [remove_feed user ~url] returns a new user with the feed removed by URL. *)
269269+ val contact : t -> Sortal.Contact.t
270270+ (** [contact user] returns the underlying Sortal contact. *)
279271280272 val set_last_synced : t -> string -> t
281273 (** [set_last_synced user timestamp] returns a new user with updated sync time. *)
282282-283283- val jsont : t Jsont.t
284284- (** JSON codec for users. *)
285274end
286275287276(** {1 Feed Quality Analysis} *)
···331320332321module State : sig
333322 type t
334334- (** State handle for managing user data and feeds on disk. *)
323323+ (** State handle for managing sync state and feeds on disk.
324324+325325+ User contact data is read from Sortal on-demand. River only persists
326326+ sync timestamps and feed data. *)
335327336328 val create :
337329 < fs : Eio.Fs.dir_ty Eio.Path.t; .. > ->
···340332 (** [create env ~app_name] creates a state handle using XDG directories.
341333342334 Data is stored in:
343343- - Users: $XDG_STATE_HOME/[app_name]/users/
344344- - Feeds: $XDG_STATE_HOME/[app_name]/feeds/user/
335335+ - Sync state: $XDG_STATE_HOME/[app_name]/sync_state.json
336336+ - Feeds: $XDG_STATE_HOME/[app_name]/feeds/[username]/
337337+338338+ User contact data is read from Sortal's XDG location.
345339346340 @param env The Eio environment with filesystem access
347341 @param app_name Application name for XDG paths *)
348342349343 (** {2 User Operations} *)
350344351351- val create_user : t -> User.t -> (unit, string) result
352352- (** [create_user state user] creates a new user.
345345+ val get_user : t -> username:string -> User.t option
346346+ (** [get_user state ~username] retrieves a user by username.
353347354354- Returns [Error] if the user already exists. *)
348348+ This reads contact data from Sortal and combines it with River's sync state.
349349+ Returns [None] if the username doesn't exist in Sortal or has no feeds. *)
355350356356- val delete_user : t -> username:string -> (unit, string) result
357357- (** [delete_user state ~username] deletes a user and their feed data. *)
351351+ val list_users : t -> string list
352352+ (** [list_users state] returns all usernames with feeds from Sortal. *)
358353359359- val get_user : t -> username:string -> User.t option
360360- (** [get_user state ~username] retrieves a user by username. *)
354354+ val get_all_users : t -> User.t list
355355+ (** [get_all_users state] returns all users from Sortal with their sync state. *)
361356362362- val update_user : t -> User.t -> (unit, string) result
363363- (** [update_user state user] saves updated user configuration. *)
357357+ val update_sync_state : t -> username:string -> timestamp:string -> (unit, string) result
358358+ (** [update_sync_state state ~username ~timestamp] updates the last sync timestamp.
364359365365- val list_users : t -> string list
366366- (** [list_users state] returns all usernames. *)
360360+ @param username The user to update
361361+ @param timestamp ISO 8601 timestamp of the sync *)
367362368363 (** {2 Feed Operations} *)
369364
+89-83
stack/river/lib/state.ml
···1515 * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
1616 *)
17171818-(** State management for user data and feeds. *)
1818+(** State management for sync state and feeds.
1919+2020+ User contact data is read from Sortal on-demand. River only persists
2121+ sync timestamps and feed data. *)
19222023let src = Logs.Src.create "river" ~doc:"River RSS/Atom aggregator"
2124module Log = (val Logs.src_log src : Logs.LOG)
22252326type t = {
2427 xdg : Xdge.t;
2828+ sortal : Sortal.t;
2529}
26302731module Paths = struct
2828- (** Get the users directory path *)
2929- let users_dir state = Eio.Path.(Xdge.state_dir state.xdg / "users")
3030-3132 (** Get the feeds directory path *)
3233 let feeds_dir state = Eio.Path.(Xdge.state_dir state.xdg / "feeds")
33343435 (** Get the user feeds directory path *)
3536 let user_feeds_dir state = Eio.Path.(feeds_dir state / "user")
36373737- (** Get the path to a user's JSON file *)
3838- let user_file state username =
3939- Eio.Path.(users_dir state / (username ^ ".json"))
3838+ (** Get the sync state file path *)
3939+ let sync_state_file state = Eio.Path.(Xdge.state_dir state.xdg / "sync_state.json")
40404141 (** Get the path to a user's Atom feed file *)
4242 let user_feed_file state username =
···4545 (** Ensure all necessary directories exist *)
4646 let ensure_directories state =
4747 let dirs = [
4848- users_dir state;
4948 feeds_dir state;
5049 user_feeds_dir state;
5150 ] in
···5554 ) dirs
5655end
57565858-module Json = struct
5959- (** Decode a user from JSON string *)
6060- let user_of_string s =
6161- match Jsont_bytesrw.decode_string' User.jsont s with
6262- | Ok user -> Some user
6363- | Error err ->
6464- Log.err (fun m -> m "Failed to parse user JSON: %s" (Jsont.Error.to_string err));
6565- None
6666-6767- (** Encode a user to JSON string *)
6868- let user_to_string user =
6969- match Jsont_bytesrw.encode_string' ~format:Jsont.Indent User.jsont user with
7070- | Ok s -> s
7171- | Error err -> failwith ("Failed to encode user: " ^ Jsont.Error.to_string err)
7272-end
5757+(** Sync state management - maps username to last_synced timestamp *)
5858+module Sync_state = struct
5959+ let jsont =
6060+ let pair_t =
6161+ let make username timestamp = (username, timestamp) in
6262+ Jsont.Object.map ~kind:"SyncEntry" make
6363+ |> Jsont.Object.mem "username" Jsont.string ~enc:fst
6464+ |> Jsont.Object.mem "timestamp" Jsont.string ~enc:snd
6565+ |> Jsont.Object.finish
6666+ in
6767+ Jsont.Object.map ~kind:"SyncState" (fun pairs -> pairs)
6868+ |> Jsont.Object.mem "synced_users" (Jsont.list pair_t) ~enc:(fun s -> s)
6969+ |> Jsont.Object.finish
73707474-module Storage = struct
7575- (** Load a user from disk *)
7676- let load_user state username =
7777- let file = Paths.user_file state username in
7171+ let load state =
7272+ let file = Paths.sync_state_file state in
7873 try
7974 let content = Eio.Path.load file in
8080- Json.user_of_string content
7575+ match Jsont_bytesrw.decode_string' jsont content with
7676+ | Ok pairs -> pairs
7777+ | Error err ->
7878+ Log.warn (fun m -> m "Failed to parse sync state: %s" (Jsont.Error.to_string err));
7979+ []
8180 with
8282- | Eio.Io (Eio.Fs.E (Not_found _), _) -> None
8181+ | Eio.Io (Eio.Fs.E (Not_found _), _) -> []
8382 | e ->
8484- Log.err (fun m -> m "Error loading user %s: %s" username (Printexc.to_string e));
8585- None
8383+ Log.err (fun m -> m "Error loading sync state: %s" (Printexc.to_string e));
8484+ []
8585+8686+ let save state sync_state =
8787+ let file = Paths.sync_state_file state in
8888+ match Jsont_bytesrw.encode_string' ~format:Jsont.Indent jsont sync_state with
8989+ | Ok json -> Eio.Path.save ~create:(`Or_truncate 0o644) file json
9090+ | Error err -> failwith ("Failed to encode sync state: " ^ Jsont.Error.to_string err)
86918787- (** Save a user to disk *)
8888- let save_user state user =
8989- let file = Paths.user_file state (User.username user) in
9090- let json = Json.user_to_string user in
9191- Eio.Path.save ~create:(`Or_truncate 0o644) file json
9292+ let get_timestamp state username =
9393+ load state |> List.assoc_opt username
9494+9595+ let set_timestamp state username timestamp =
9696+ let sync_state = load state in
9797+ let updated = (username, timestamp) :: List.remove_assoc username sync_state in
9898+ save state updated
9999+end
921009393- (** List all usernames *)
101101+module Storage = struct
102102+ (** List all usernames with feeds from Sortal *)
94103 let list_users state =
95104 try
9696- Eio.Path.read_dir (Paths.users_dir state)
9797- |> List.filter_map (fun name ->
9898- if Filename.check_suffix name ".json" then
9999- Some (Filename.chop_suffix name ".json")
100100- else None
101101- )
102102- with _ -> []
105105+ Sortal.list state.sortal
106106+ |> List.filter (fun contact -> Sortal.Contact.feeds contact <> None)
107107+ |> List.map Sortal.Contact.handle
108108+ with e ->
109109+ Log.err (fun m -> m "Error listing Sortal users: %s" (Printexc.to_string e));
110110+ []
111111+112112+ (** Get a user from Sortal with sync state *)
113113+ let get_user state username =
114114+ match Sortal.lookup state.sortal username with
115115+ | None -> None
116116+ | Some contact ->
117117+ (* Only return users with feeds *)
118118+ if Sortal.Contact.feeds contact = None then None
119119+ else
120120+ let last_synced = Sync_state.get_timestamp state username in
121121+ Some (User.of_contact contact ?last_synced ())
122122+123123+ (** Get all users from Sortal with sync state *)
124124+ let get_all_users state =
125125+ try
126126+ Sortal.list state.sortal
127127+ |> List.filter (fun contact -> Sortal.Contact.feeds contact <> None)
128128+ |> List.map (fun contact ->
129129+ let username = Sortal.Contact.handle contact in
130130+ let last_synced = Sync_state.get_timestamp state username in
131131+ User.of_contact contact ?last_synced ())
132132+ with e ->
133133+ Log.err (fun m -> m "Error getting all users: %s" (Printexc.to_string e));
134134+ []
103135104136 (** Load existing Atom entries for a user *)
105137 let load_existing_posts state username =
···123155 let feed = Format.Atom.feed_of_entries ~title:username entries in
124156 let xml = Format.Atom.to_string feed in
125157 Eio.Path.save ~create:(`Or_truncate 0o644) file xml
126126-127127- (** Delete a user and their feed file *)
128128- let delete_user state username =
129129- let user_file = Paths.user_file state username in
130130- let feed_file = Paths.user_feed_file state username in
131131- (try Eio.Path.unlink user_file with _ -> ());
132132- (try Eio.Path.unlink feed_file with _ -> ())
133158end
134159135160module Sync = struct
···169194170195 (** Sync feeds for a single user *)
171196 let sync_user session state ~username =
172172- match Storage.load_user state username with
197197+ match Storage.get_user state username with
173198 | None ->
174199 Error (Printf.sprintf "User %s not found" username)
175200 | Some user when User.feeds user = [] ->
···215240216241 (* Update last_synced timestamp *)
217242 let now = current_timestamp () in
218218- let user = User.set_last_synced user now in
219219- Storage.save_user state user;
243243+ Sync_state.set_timestamp state username now;
220244221245 Log.info (fun m -> m "Sync completed for user %s" username);
222246 Ok ()
···312336313337let create env ~app_name =
314338 let xdg = Xdge.create env#fs app_name in
315315- let state = { xdg } in
339339+ (* Sortal always uses "sortal" as the app name for shared contact database *)
340340+ let sortal = Sortal.create env#fs "sortal" in
341341+ let state = { xdg; sortal } in
316342 Paths.ensure_directories state;
317343 state
318344319319-let create_user state user =
320320- match Storage.load_user state (User.username user) with
321321- | Some _ ->
322322- Error (Printf.sprintf "User %s already exists" (User.username user))
323323- | None ->
324324- Storage.save_user state user;
325325- Log.info (fun m -> m "User %s created" (User.username user));
326326- Ok ()
327327-328328-let delete_user state ~username =
329329- match Storage.load_user state username with
330330- | None ->
331331- Error (Printf.sprintf "User %s not found" username)
332332- | Some _ ->
333333- Storage.delete_user state username;
334334- Log.info (fun m -> m "User %s deleted" username);
335335- Ok ()
336336-337345let get_user state ~username =
338338- Storage.load_user state username
346346+ Storage.get_user state username
339347340340-let update_user state user =
341341- match Storage.load_user state (User.username user) with
342342- | None ->
343343- Error (Printf.sprintf "User %s not found" (User.username user))
344344- | Some _ ->
345345- Storage.save_user state user;
346346- Log.info (fun m -> m "User %s updated" (User.username user));
347347- Ok ()
348348+let get_all_users state =
349349+ Storage.get_all_users state
348350349351let list_users state =
350352 Storage.list_users state
353353+354354+let update_sync_state state ~username ~timestamp =
355355+ Sync_state.set_timestamp state username timestamp;
356356+ Ok ()
351357352358let sync_user env state ~username =
353359 Session.with_session env @@ fun session ->
···425431 Export.export_jsonfeed ~title entries
426432427433let analyze_user_quality state ~username =
428428- match Storage.load_user state username with
434434+ match Storage.get_user state username with
429435 | None ->
430436 Error (Printf.sprintf "User %s not found" username)
431437 | Some _ ->
+21-15
stack/river/lib/state.mli
···1515 * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
1616 *)
17171818-(** State management for user data and feeds. *)
1818+(** State management for sync state and feeds.
1919+2020+ User contact data is read from Sortal on-demand. River only persists
2121+ sync timestamps and feed data. *)
19222023type t
2121-(** State handle for managing user data and feeds on disk. *)
2424+(** State handle for managing sync state and feeds on disk. *)
22252326val create :
2427 < fs : Eio.Fs.dir_ty Eio.Path.t; .. > ->
···2730(** [create env ~app_name] creates a state handle using XDG directories.
28312932 Data is stored in:
3030- - Users: $XDG_STATE_HOME/[app_name]/users/
3131- - Feeds: $XDG_STATE_HOME/[app_name]/feeds/user/
3333+ - Sync state: $XDG_STATE_HOME/[app_name]/sync_state.json
3434+ - Feeds: $XDG_STATE_HOME/[app_name]/feeds/[username]/
3535+3636+ User contact data is read from Sortal's XDG location.
32373338 @param env The Eio environment with filesystem access
3439 @param app_name Application name for XDG paths *)
35403641(** {2 User Operations} *)
37423838-val create_user : t -> User.t -> (unit, string) result
3939-(** [create_user state user] creates a new user.
4343+val get_user : t -> username:string -> User.t option
4444+(** [get_user state ~username] retrieves a user by username.
40454141- Returns [Error] if the user already exists. *)
4646+ This reads contact data from Sortal and combines it with River's sync state.
4747+ Returns [None] if the username doesn't exist in Sortal or has no feeds. *)
42484343-val delete_user : t -> username:string -> (unit, string) result
4444-(** [delete_user state ~username] deletes a user and their feed data. *)
4949+val list_users : t -> string list
5050+(** [list_users state] returns all usernames with feeds from Sortal. *)
45514646-val get_user : t -> username:string -> User.t option
4747-(** [get_user state ~username] retrieves a user by username. *)
5252+val get_all_users : t -> User.t list
5353+(** [get_all_users state] returns all users from Sortal with their sync state. *)
48544949-val update_user : t -> User.t -> (unit, string) result
5050-(** [update_user state user] saves updated user configuration. *)
5555+val update_sync_state : t -> username:string -> timestamp:string -> (unit, string) result
5656+(** [update_sync_state state ~username ~timestamp] updates the last sync timestamp.
51575252-val list_users : t -> string list
5353-(** [list_users state] returns all usernames. *)
5858+ @param username The user to update
5959+ @param timestamp ISO 8601 timestamp of the sync *)
54605561(** {2 Feed Operations} *)
5662
+19-29
stack/river/lib/user.ml
···1515 * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
1616 *)
17171818-(** User management. *)
1818+(** River user composed from Sortal contact data + sync state. *)
19192020type t = {
2121- username : string;
2222- fullname : string;
2323- email : string option;
2424- feeds : Source.t list;
2121+ contact : Sortal.Contact.t;
2522 last_synced : string option;
2623}
27242828-let make ~username ~fullname ?email ?(feeds = []) ?last_synced () =
2929- { username; fullname; email; feeds; last_synced }
2525+let of_contact contact ?last_synced () =
2626+ { contact; last_synced }
30273131-let username t = t.username
3232-let fullname t = t.fullname
3333-let email t = t.email
3434-let feeds t = t.feeds
2828+let username t = Sortal.Contact.handle t.contact
2929+let fullname t = Sortal.Contact.primary_name t.contact
3030+let email t = Sortal.Contact.email t.contact
3131+let contact t = t.contact
3532let last_synced t = t.last_synced
36333737-let add_feed t source =
3838- { t with feeds = source :: t.feeds }
3939-4040-let remove_feed t ~url =
4141- let feeds = List.filter (fun s -> Source.url s <> url) t.feeds in
4242- { t with feeds }
3434+let feeds t =
3535+ match Sortal.Contact.feeds t.contact with
3636+ | None -> []
3737+ | Some sortal_feeds ->
3838+ List.map (fun feed ->
3939+ let name = match Sortal.Feed.name feed with
4040+ | Some n -> n
4141+ | None -> Sortal.Contact.primary_name t.contact ^ " feed"
4242+ in
4343+ Source.make ~name ~url:(Sortal.Feed.url feed)
4444+ ) sortal_feeds
43454446let set_last_synced t timestamp =
4547 { t with last_synced = Some timestamp }
4646-4747-let jsont =
4848- let make username fullname email feeds last_synced =
4949- { username; fullname; email; feeds; last_synced }
5050- in
5151- Jsont.Object.map ~kind:"User" make
5252- |> Jsont.Object.mem "username" Jsont.string ~enc:(fun u -> u.username)
5353- |> Jsont.Object.mem "fullname" Jsont.string ~enc:(fun u -> u.fullname)
5454- |> Jsont.Object.opt_mem "email" Jsont.string ~enc:(fun u -> u.email)
5555- |> Jsont.Object.mem "feeds" (Jsont.list Source.jsont) ~enc:(fun u -> u.feeds)
5656- |> Jsont.Object.opt_mem "last_synced" Jsont.string ~enc:(fun u -> u.last_synced)
5757- |> Jsont.Object.finish
+14-27
stack/river/lib/user.mli
···1515 * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
1616 *)
17171818-(** User management. *)
1818+(** River user composed from Sortal contact data + sync state.
1919+2020+ User data is stored in Sortal and read on-demand. River only persists
2121+ sync timestamps and optional per-user overrides. *)
19222023type t
2121-(** User configuration with feed subscriptions. *)
2424+(** A River user composed from Sortal.Contact + sync metadata. *)
22252323-val make :
2424- username:string ->
2525- fullname:string ->
2626- ?email:string ->
2727- ?feeds:Source.t list ->
2828- ?last_synced:string ->
2929- unit ->
3030- t
3131-(** [make ~username ~fullname ()] creates a new user.
2626+val of_contact : Sortal.Contact.t -> ?last_synced:string -> unit -> t
2727+(** [of_contact contact ()] creates a River user from a Sortal contact.
32283333- @param username Unique username identifier
3434- @param fullname User's display name
3535- @param email Optional email address
3636- @param feeds Optional list of feed sources (default: [])
2929+ @param contact The Sortal contact to base this user on
3730 @param last_synced Optional ISO 8601 timestamp of last sync *)
38313932val username : t -> string
4040-(** [username user] returns the username. *)
3333+(** [username user] returns the username (from Sortal.Contact.handle). *)
41344235val fullname : t -> string
4343-(** [fullname user] returns the full name. *)
3636+(** [fullname user] returns the full name (from Sortal.Contact.primary_name). *)
44374538val email : t -> string option
4646-(** [email user] returns the email address if set. *)
3939+(** [email user] returns the email address (from Sortal.Contact). *)
47404841val feeds : t -> Source.t list
4949-(** [feeds user] returns the list of subscribed feeds. *)
4242+(** [feeds user] returns the list of subscribed feeds (from Sortal.Contact). *)
50435144val last_synced : t -> string option
5245(** [last_synced user] returns the last sync timestamp if set. *)
53465454-val add_feed : t -> Source.t -> t
5555-(** [add_feed user source] returns a new user with the feed added. *)
5656-5757-val remove_feed : t -> url:string -> t
5858-(** [remove_feed user ~url] returns a new user with the feed removed by URL. *)
4747+val contact : t -> Sortal.Contact.t
4848+(** [contact user] returns the underlying Sortal contact. *)
59496050val set_last_synced : t -> string -> t
6151(** [set_last_synced user timestamp] returns a new user with updated sync time. *)
6262-6363-val jsont : t Jsont.t
6464-(** JSON codec for users. *)