···11+open Cmdliner
22+33+let run ~info main_term =
44+ let run_main main =
55+ Eio_main.run @@ fun env ->
66+ let xdg = Xdge.create env#fs "sortal" in
77+ main xdg
88+ in
99+ let term =
1010+ let open Term.Syntax in
1111+ let+ main = main_term
1212+ and+ log_level = Logs_cli.level () in
1313+ Fmt.set_style_renderer Fmt.stdout `Ansi_tty;
1414+ Fmt.set_style_renderer Fmt.stderr `Ansi_tty;
1515+ Logs.set_reporter (Logs_fmt.reporter ~app:Fmt.stdout ~dst:Fmt.stderr ());
1616+ Logs.set_level log_level;
1717+ run_main main
1818+ in
1919+ Cmd.v info term
2020+2121+(* Main command *)
2222+let () =
2323+ let info = Cmd.info "sortal"
2424+ ~version:"0.1.0"
2525+ ~doc:"Contact metadata management"
2626+ ~man:[
2727+ `S Manpage.s_description;
2828+ `P "Sortal manages contact metadata including URLs, emails, ORCID identifiers, \
2929+ and social media handles. Data is stored as JSON in XDG-compliant locations.";
3030+ `S Manpage.s_commands;
3131+ `P "Use $(b,sortal COMMAND --help) for detailed help on each command.";
3232+ ]
3333+ in
3434+3535+ let list_cmd_term = Term.(const (fun () -> Sortal.Cmd.list_cmd ()) $ const ()) in
3636+ let list_cmd = run ~info:Sortal.Cmd.list_info list_cmd_term in
3737+3838+ let show_cmd_term = Term.(const (fun handle -> Sortal.Cmd.show_cmd handle) $ Sortal.Cmd.handle_arg) in
3939+ let show_cmd = run ~info:Sortal.Cmd.show_info show_cmd_term in
4040+4141+ let search_cmd_term = Term.(const (fun query -> Sortal.Cmd.search_cmd query) $ Sortal.Cmd.query_arg) in
4242+ let search_cmd = run ~info:Sortal.Cmd.search_info search_cmd_term in
4343+4444+ let stats_cmd_term = Term.(const (fun () -> Sortal.Cmd.stats_cmd ()) $ const ()) in
4545+ let stats_cmd = run ~info:Sortal.Cmd.stats_info stats_cmd_term in
4646+4747+ let sync_cmd_term = Term.(const (fun () -> Sortal.Cmd.sync_cmd ()) $ const ()) in
4848+ let sync_cmd = run ~info:Sortal.Cmd.sync_info sync_cmd_term in
4949+5050+ let default_term = Term.(ret (const (`Help (`Pager, None)))) in
5151+5252+ let cmd = Cmd.group info ~default:default_term [
5353+ list_cmd;
5454+ show_cmd;
5555+ search_cmd;
5656+ stats_cmd;
5757+ sync_cmd;
5858+ ] in
5959+6060+ exit (Cmd.eval' cmd)
+16
dune-project
···11+(lang dune 3.20)
22+33+(name sortal)
44+55+(package
66+ (name sortal)
77+ (synopsis "Keep track of users and their metadata in a collective web")
88+ (description
99+ "Sortal provides a system for mapping usernames to various metadata including URLs, emails, ORCID identifiers, and social media handles.")
1010+ (depends
1111+ (ocaml (>= 5.1.0))
1212+ eio
1313+ eio_main
1414+ xdge
1515+ jsont
1616+ fmt))
···11+(** Sortal - Username to metadata mapping with XDG storage
22+33+ This library provides a system for mapping usernames to various metadata
44+ including URLs, emails, ORCID identifiers, and social media handles.
55+ It uses XDG Base Directory Specification for storage locations and
66+ jsont for JSON encoding/decoding.
77+88+ {b Storage:}
99+1010+ Contact metadata is stored as JSON files in the XDG data directory,
1111+ with one file per contact using the handle as the filename.
1212+1313+ {b Typical Usage:}
1414+1515+ {[
1616+ let store = Sortal.create env#fs "myapp" in
1717+ let contact = Sortal.Contact.make
1818+ ~handle:"avsm"
1919+ ~names:["Anil Madhavapeddy"]
2020+ ~email:"anil@recoil.org"
2121+ ~github:"avsm"
2222+ ~orcid:"0000-0002-7890-1234"
2323+ () in
2424+ Sortal.save store contact;
2525+2626+ match Sortal.lookup store "avsm" with
2727+ | Some c -> Printf.printf "Found: %s\n" (Sortal.Contact.name c)
2828+ | None -> Printf.printf "Not found\n"
2929+ ]}
3030+*)
3131+3232+(** {1 Core Modules} *)
3333+3434+(** Feed subscription metadata. *)
3535+module Feed = Sortal_feed
3636+3737+(** Contact metadata. *)
3838+module Contact = Sortal_contact
3939+4040+(** Contact store with XDG-compliant storage. *)
4141+module Store = Sortal_store
4242+4343+(** Cmdliner integration for CLI applications. *)
4444+module Cmd = Sortal_cmd
4545+4646+(** {1 Convenience Re-exports}
4747+4848+ These are re-exported from {!Store} for easier top-level access. *)
4949+5050+(** The contact store type. *)
5151+type t = Store.t
5252+5353+(** [create fs app_name] creates a new contact store.
5454+ See {!Store.create} for details. *)
5555+val create : Eio.Fs.dir_ty Eio.Path.t -> string -> t
5656+5757+(** [create_from_xdg xdg] creates a contact store from an XDG context.
5858+ See {!Store.create_from_xdg} for details. *)
5959+val create_from_xdg : Xdge.t -> t
6060+6161+(** [save t contact] saves a contact to the store.
6262+ See {!Store.save} for details. *)
6363+val save : t -> Contact.t -> unit
6464+6565+(** [lookup t handle] retrieves a contact by handle.
6666+ See {!Store.lookup} for details. *)
6767+val lookup : t -> string -> Contact.t option
6868+6969+(** [delete t handle] removes a contact from the store.
7070+ See {!Store.delete} for details. *)
7171+val delete : t -> string -> unit
7272+7373+(** [list t] returns all contacts in the store.
7474+ See {!Store.list} for details. *)
7575+val list : t -> Contact.t list
7676+7777+(** [thumbnail_path t contact] returns the path to a contact's thumbnail.
7878+ See {!Store.thumbnail_path} for details. *)
7979+val thumbnail_path : t -> Contact.t -> Eio.Fs.dir_ty Eio.Path.t option
8080+8181+(** [find_by_name t name] searches for contacts by name.
8282+ See {!Store.find_by_name} for details. *)
8383+val find_by_name : t -> string -> Contact.t
8484+8585+(** [find_by_name_opt t name] searches for contacts by name.
8686+ See {!Store.find_by_name_opt} for details. *)
8787+val find_by_name_opt : t -> string -> Contact.t option
8888+8989+(** [search_all t query] searches for contacts matching a query.
9090+ See {!Store.search_all} for details. *)
9191+val search_all : t -> string -> Contact.t list
9292+9393+(** [handle_of_name name] generates a handle from a full name.
9494+ See {!Store.handle_of_name} for details. *)
9595+val handle_of_name : string -> string
9696+9797+(** [pp ppf t] pretty prints the contact store.
9898+ See {!Store.pp} for details. *)
9999+val pp : Format.formatter -> t -> unit
+235
lib/sortal_cmd.ml
···11+open Cmdliner
22+33+let image_columns = 9 (* columns for 4-row square-ish thumbnail + 3 padding *)
44+55+let supports_kitty_graphics () =
66+ let check_env var =
77+ match Sys.getenv_opt var with
88+ | Some _ -> true
99+ | None -> false
1010+ in
1111+ let check_env_contains var substr =
1212+ match Sys.getenv_opt var with
1313+ | Some v -> String.lowercase_ascii v |> fun s ->
1414+ String.length s >= String.length substr &&
1515+ let rec check i =
1616+ if i > String.length s - String.length substr then false
1717+ else if String.sub s i (String.length substr) = substr then true
1818+ else check (i + 1)
1919+ in check 0
2020+ | None -> false
2121+ in
2222+ check_env "KITTY_WINDOW_ID" ||
2323+ check_env "WEZTERM_PANE" ||
2424+ check_env "GHOSTTY_RESOURCES_DIR" ||
2525+ check_env_contains "TERM_PROGRAM" "kitty" ||
2626+ check_env_contains "TERM_PROGRAM" "wezterm" ||
2727+ check_env_contains "TERM_PROGRAM" "ghostty" ||
2828+ check_env_contains "TERM" "kitty"
2929+3030+let display_png_thumbnail path =
3131+ let png_data = Eio.Path.load path in
3232+ let placement = Kgp.Placement.make ~rows:4 ~cursor:`Static () in
3333+ let cmd = Kgp.transmit_and_display ~format:`Png ~placement () in
3434+ Kgp.to_string cmd ~data:png_data
3535+3636+let display_block_placeholder () =
3737+ (* 4 rows of patterned block characters as fallback *)
3838+ let row1 = "▓▒░▒▓▒" in
3939+ let row2 = "▒░▒▓▒░" in
4040+ let row3 = "░▒▓▒░▒" in
4141+ let row4 = "▒▓▒░▒▓" in
4242+ Printf.sprintf "%s\n%s\n%s\n%s\x1b[4A" row1 row2 row3 row4 (* print 4 rows, move up 4 *)
4343+4444+let move_right n = Printf.sprintf "\x1b[%dC" n
4545+let move_down_and_back () = Printf.sprintf "\n\x1b[%dC" image_columns
4646+4747+let image_rows = 4 (* height of thumbnail in rows *)
4848+4949+(* 1-row thumbnail for listings *)
5050+let display_small_thumbnail path =
5151+ let png_data = Eio.Path.load path in
5252+ let placement = Kgp.Placement.make ~rows:1 () in
5353+ let cmd = Kgp.transmit_and_display ~format:`Png ~placement () in
5454+ Kgp.to_string cmd ~data:png_data
5555+5656+let small_placeholder () = "▓░ " (* 1-row patterned placeholder *)
5757+5858+let list_cmd () xdg =
5959+ let store = Sortal_store.create_from_xdg xdg in
6060+ let contacts = Sortal_store.list store in
6161+ let sorted = List.sort Sortal_contact.compare contacts in
6262+ let use_graphics = supports_kitty_graphics () in
6363+ Printf.printf "Total contacts: %d\n" (List.length sorted);
6464+ List.iter (fun c ->
6565+ (match Sortal_store.png_thumbnail_path store c with
6666+ | Some path ->
6767+ if use_graphics then
6868+ print_string (display_small_thumbnail path)
6969+ else
7070+ print_string (small_placeholder ())
7171+ | None ->
7272+ print_string " "); (* spacing when no thumbnail *)
7373+ Printf.printf "@%s: %s\n" (Sortal_contact.handle c) (Sortal_contact.name c)
7474+ ) sorted;
7575+ 0
7676+7777+let show_cmd handle xdg =
7878+ let store = Sortal_store.create_from_xdg xdg in
7979+ match Sortal_store.lookup store handle with
8080+ | Some c ->
8181+ let has_thumbnail = match Sortal_store.png_thumbnail_path store c with
8282+ | Some path ->
8383+ if supports_kitty_graphics () then
8484+ print_string (display_png_thumbnail path)
8585+ else
8686+ print_string (display_block_placeholder ());
8787+ print_string (move_right image_columns);
8888+ true
8989+ | None -> false
9090+ in
9191+ let lines_output = ref 0 in
9292+ let indent () =
9393+ incr lines_output;
9494+ if has_thumbnail && !lines_output < image_rows then
9595+ print_string (move_down_and_back ())
9696+ else
9797+ print_newline ()
9898+ in
9999+ let field label value =
100100+ match value with
101101+ | Some v -> Printf.printf "%s: %s" label v; indent ()
102102+ | None -> ()
103103+ in
104104+ (* Line 1: Handle and Name *)
105105+ Printf.printf "@%s: %s" (Sortal_contact.handle c) (Sortal_contact.name c);
106106+ indent ();
107107+ (* Line 2: Email *)
108108+ field "Email" (Sortal_contact.email c);
109109+ (* Line 3: GitHub *)
110110+ field "GitHub" (Option.map (fun g -> "https://github.com/" ^ g) (Sortal_contact.github c));
111111+ (* Line 4: URL *)
112112+ field "URL" (Sortal_contact.best_url c);
113113+ (* Ensure we've output enough lines to clear the image area *)
114114+ if has_thumbnail then begin
115115+ while !lines_output < image_rows do
116116+ indent ()
117117+ done
118118+ end;
119119+ (* Additional fields below the image *)
120120+ Option.iter (fun tw -> Printf.printf "Twitter: https://twitter.com/%s\n" tw) (Sortal_contact.twitter c);
121121+ Option.iter (fun b -> Printf.printf "Bluesky: %s\n" b) (Sortal_contact.bluesky c);
122122+ Option.iter (fun m -> Printf.printf "Mastodon: %s\n" m) (Sortal_contact.mastodon c);
123123+ Option.iter (fun o -> Printf.printf "ORCID: https://orcid.org/%s\n" o) (Sortal_contact.orcid c);
124124+ 0
125125+ | None -> Logs.err (fun m -> m "Contact not found: %s" handle); 1
126126+127127+let search_cmd query xdg =
128128+ let store = Sortal_store.create_from_xdg xdg in
129129+ match Sortal_store.search_all store query with
130130+ | [] ->
131131+ Logs.warn (fun m -> m "No contacts found matching: %s" query);
132132+ 1
133133+ | matches ->
134134+ Logs.app (fun m -> m "Found %d match%s:"
135135+ (List.length matches)
136136+ (if List.length matches = 1 then "" else "es"));
137137+ List.iter (fun c ->
138138+ Logs.app (fun m -> m "@%s: %s" (Sortal_contact.handle c) (Sortal_contact.name c));
139139+ Option.iter (fun e -> Logs.app (fun m -> m " Email: %s" e)) (Sortal_contact.email c);
140140+ Option.iter (fun g -> Logs.app (fun m -> m " GitHub: @%s" g)) (Sortal_contact.github c);
141141+ Option.iter (fun u -> Logs.app (fun m -> m " URL: %s" u)) (Sortal_contact.best_url c)
142142+ ) matches;
143143+ 0
144144+145145+let stats_cmd () xdg =
146146+ let store = Sortal_store.create_from_xdg xdg in
147147+ let contacts = Sortal_store.list store in
148148+ let total = List.length contacts in
149149+ let count pred = List.filter pred contacts |> List.length in
150150+ let with_email = count (fun c -> Option.is_some (Sortal_contact.email c)) in
151151+ let with_github = count (fun c -> Option.is_some (Sortal_contact.github c)) in
152152+ let with_orcid = count (fun c -> Option.is_some (Sortal_contact.orcid c)) in
153153+ let with_url = count (fun c -> Option.is_some (Sortal_contact.url c)) in
154154+ let with_feeds = count (fun c -> Option.is_some (Sortal_contact.feeds c)) in
155155+ let total_feeds =
156156+ List.fold_left (fun acc c ->
157157+ acc + Option.fold ~none:0 ~some:List.length (Sortal_contact.feeds c)
158158+ ) 0 contacts
159159+ in
160160+ let pct n = float_of_int n /. float_of_int total *. 100. in
161161+ Logs.app (fun m -> m "Contact Database Statistics:");
162162+ Logs.app (fun m -> m " Total contacts: %d" total);
163163+ Logs.app (fun m -> m " With email: %d (%.1f%%)" with_email (pct with_email));
164164+ Logs.app (fun m -> m " With GitHub: %d (%.1f%%)" with_github (pct with_github));
165165+ Logs.app (fun m -> m " With ORCID: %d (%.1f%%)" with_orcid (pct with_orcid));
166166+ Logs.app (fun m -> m " With URL: %d (%.1f%%)" with_url (pct with_url));
167167+ Logs.app (fun m -> m " With feeds: %d (%.1f%%), total %d feeds" with_feeds (pct with_feeds) total_feeds);
168168+ 0
169169+170170+let is_png path =
171171+ let ext = String.lowercase_ascii (Filename.extension path) in
172172+ ext = ".png"
173173+174174+let convert_to_png src_path =
175175+ let base = Filename.remove_extension src_path in
176176+ let dst_path = base ^ ".png" in
177177+ let cmd = Printf.sprintf "magick %s %s" (Filename.quote src_path) (Filename.quote dst_path) in
178178+ let ret = Unix.system cmd in
179179+ match ret with
180180+ | Unix.WEXITED 0 -> Ok dst_path
181181+ | Unix.WEXITED n -> Error (Printf.sprintf "magick exited with code %d" n)
182182+ | Unix.WSIGNALED n -> Error (Printf.sprintf "magick killed by signal %d" n)
183183+ | Unix.WSTOPPED n -> Error (Printf.sprintf "magick stopped by signal %d" n)
184184+185185+let sync_cmd () xdg =
186186+ let store = Sortal_store.create_from_xdg xdg in
187187+ let contacts = Sortal_store.list store in
188188+ Logs.app (fun m -> m "Syncing %d contacts..." (List.length contacts));
189189+ let converted = ref 0 in
190190+ let skipped = ref 0 in
191191+ let no_thumbnail = ref 0 in
192192+ let errors = ref 0 in
193193+ List.iter (fun contact ->
194194+ let handle = Sortal_contact.handle contact in
195195+ match Sortal_store.thumbnail_path store contact with
196196+ | None ->
197197+ Logs.info (fun m -> m "@%s: no thumbnail" handle);
198198+ incr no_thumbnail
199199+ | Some eio_path ->
200200+ let path = Eio.Path.native_exn eio_path in
201201+ if is_png path then begin
202202+ Logs.info (fun m -> m "@%s: already PNG (%s)" handle (Filename.basename path));
203203+ incr skipped
204204+ end else begin
205205+ Logs.app (fun m -> m "@%s: converting %s to PNG..." handle (Filename.basename path));
206206+ match convert_to_png path with
207207+ | Ok new_path ->
208208+ Logs.app (fun m -> m " Converted: %s -> %s"
209209+ (Filename.basename path) (Filename.basename new_path));
210210+ incr converted
211211+ | Error msg ->
212212+ Logs.err (fun m -> m " Failed to convert %s: %s" path msg);
213213+ incr errors
214214+ end
215215+ ) contacts;
216216+ Logs.app (fun m -> m "Sync complete:");
217217+ Logs.app (fun m -> m " %d contacts without thumbnails" !no_thumbnail);
218218+ Logs.app (fun m -> m " %d already PNG (skipped)" !skipped);
219219+ Logs.app (fun m -> m " %d converted to PNG" !converted);
220220+ Logs.app (fun m -> m " %d errors" !errors);
221221+ if !errors > 0 then 1 else 0
222222+223223+let list_info = Cmd.info "list" ~doc:"List all contacts"
224224+let show_info = Cmd.info "show" ~doc:"Show detailed information about a contact"
225225+let search_info = Cmd.info "search" ~doc:"Search contacts by name"
226226+let stats_info = Cmd.info "stats" ~doc:"Show statistics about the contact database"
227227+let sync_info = Cmd.info "sync" ~doc:"Synchronize and normalize contact data"
228228+229229+let handle_arg =
230230+ Arg.(required & pos 0 (some string) None & info [] ~docv:"HANDLE"
231231+ ~doc:"Contact handle to display")
232232+233233+let query_arg =
234234+ Arg.(required & pos 0 (some string) None & info [] ~docv:"QUERY"
235235+ ~doc:"Name or partial name to search for")
+56
lib/sortal_cmd.mli
···11+(** Cmdliner terms and commands for contact management.
22+33+ This module provides ready-to-use Cmdliner terms for building
44+ CLI applications that work with contact metadata. *)
55+66+(** {1 Command Implementations} *)
77+88+(** [list_cmd] is a Cmdliner command that lists all contacts.
99+1010+ Usage: Integrate into your CLI with [Cmd.group] or use standalone.
1111+ Returns a function that takes an XDG context and returns an exit code. *)
1212+val list_cmd : unit -> (Xdge.t -> int)
1313+1414+(** [show_cmd handle] creates a command to show detailed contact information.
1515+1616+ @param handle The contact handle to display *)
1717+val show_cmd : string -> (Xdge.t -> int)
1818+1919+(** [search_cmd query] creates a command to search contacts by name.
2020+2121+ @param query The search query string *)
2222+val search_cmd : string -> (Xdge.t -> int)
2323+2424+(** [stats_cmd] is a command that shows database statistics. *)
2525+val stats_cmd : unit -> (Xdge.t -> int)
2626+2727+(** [sync_cmd] is a command that synchronizes and normalizes contact data.
2828+2929+ Currently performs the following operations:
3030+ - Converts non-JPG thumbnail images to PNG using ImageMagick *)
3131+val sync_cmd : unit -> (Xdge.t -> int)
3232+3333+(** {1 Cmdliner Info Objects} *)
3434+3535+(** [list_info] is the command info for the list command. *)
3636+val list_info : Cmdliner.Cmd.info
3737+3838+(** [show_info] is the command info for the show command. *)
3939+val show_info : Cmdliner.Cmd.info
4040+4141+(** [search_info] is the command info for the search command. *)
4242+val search_info : Cmdliner.Cmd.info
4343+4444+(** [stats_info] is the command info for the stats command. *)
4545+val stats_info : Cmdliner.Cmd.info
4646+4747+(** [sync_info] is the command info for the sync command. *)
4848+val sync_info : Cmdliner.Cmd.info
4949+5050+(** {1 Cmdliner Argument Definitions} *)
5151+5252+(** [handle_arg] is the positional argument for a contact handle. *)
5353+val handle_arg : string Cmdliner.Term.t
5454+5555+(** [query_arg] is the positional argument for a search query. *)
5656+val query_arg : string Cmdliner.Term.t
+115
lib/sortal_contact.ml
···11+type t = {
22+ handle : string;
33+ names : string list;
44+ email : string option;
55+ icon : string option;
66+ thumbnail : string option;
77+ github : string option;
88+ twitter : string option;
99+ bluesky : string option;
1010+ mastodon : string option;
1111+ orcid : string option;
1212+ url_ : string option;
1313+ urls_ : string list option;
1414+ feeds : Sortal_feed.t list option;
1515+}
1616+1717+let make ~handle ~names ?email ?icon ?thumbnail ?github ?twitter ?bluesky ?mastodon
1818+ ?orcid ?url ?urls ?feeds () =
1919+ { handle; names; email; icon; thumbnail; github; twitter; bluesky; mastodon;
2020+ orcid; url_ = url; urls_ = urls; feeds }
2121+2222+let handle t = t.handle
2323+let names t = t.names
2424+let name t = List.hd t.names
2525+let primary_name = name
2626+let email t = t.email
2727+let icon t = t.icon
2828+let thumbnail t = t.thumbnail
2929+let github t = t.github
3030+let twitter t = t.twitter
3131+let bluesky t = t.bluesky
3232+let mastodon t = t.mastodon
3333+let orcid t = t.orcid
3434+3535+let url t =
3636+ t.url_ |> Option.fold ~none:(Option.bind t.urls_ (Fun.flip List.nth_opt 0)) ~some:Option.some
3737+3838+let urls t =
3939+ match t.url_, t.urls_ with
4040+ | Some u, Some us -> u :: us
4141+ | Some u, None -> [u]
4242+ | None, Some us -> us
4343+ | None, None -> []
4444+4545+let feeds t = t.feeds
4646+4747+let add_feed t feed =
4848+ { t with feeds = Some (feed :: Option.value t.feeds ~default:[]) }
4949+5050+let remove_feed t url =
5151+ { t with feeds = Option.map (List.filter (fun f -> Sortal_feed.url f <> url)) t.feeds }
5252+5353+let best_url t =
5454+ url t
5555+ |> Option.fold ~none:(Option.map (fun g -> "https://github.com/" ^ g) t.github) ~some:Option.some
5656+ |> Option.fold ~none:(Option.map (fun e -> "mailto:" ^ e) t.email) ~some:Option.some
5757+5858+let json_t =
5959+ let open Jsont in
6060+ let open Jsont.Object in
6161+ let mem_opt f v ~enc = mem f v ~dec_absent:None ~enc_omit:Option.is_none ~enc in
6262+ let make handle names email icon thumbnail github twitter bluesky mastodon orcid url urls feeds =
6363+ { handle; names; email; icon; thumbnail; github; twitter; bluesky; mastodon;
6464+ orcid; url_ = url; urls_ = urls; feeds }
6565+ in
6666+ map ~kind:"Contact" make
6767+ |> mem "handle" string ~enc:handle
6868+ |> mem "names" (list string) ~dec_absent:[] ~enc:names
6969+ |> mem_opt "email" (some string) ~enc:email
7070+ |> mem_opt "icon" (some string) ~enc:icon
7171+ |> mem_opt "thumbnail" (some string) ~enc:thumbnail
7272+ |> mem_opt "github" (some string) ~enc:github
7373+ |> mem_opt "twitter" (some string) ~enc:twitter
7474+ |> mem_opt "bluesky" (some string) ~enc:bluesky
7575+ |> mem_opt "mastodon" (some string) ~enc:mastodon
7676+ |> mem_opt "orcid" (some string) ~enc:orcid
7777+ |> mem_opt "url" (some string) ~enc:(fun t -> t.url_)
7878+ |> mem_opt "urls" (some (list string)) ~enc:(fun t -> t.urls_)
7979+ |> mem_opt "feeds" (some (list Sortal_feed.json_t)) ~enc:feeds
8080+ |> finish
8181+8282+let compare a b = String.compare a.handle b.handle
8383+8484+let pp ppf t =
8585+ let open Fmt in
8686+ let label = styled (`Fg `Cyan) string in
8787+ let url_style = styled (`Fg `Blue) in
8888+ let field lbl fmt_v = Option.iter (fun v -> pf ppf "%a: %a@," label lbl fmt_v v) in
8989+ pf ppf "@[<v>";
9090+ pf ppf "%a: %a@," label "Handle" (styled `Bold (fun ppf s -> pf ppf "@%s" s)) t.handle;
9191+ pf ppf "%a: %a@," label "Name" (styled `Bold string) (name t);
9292+ if List.length (names t) > 1 then
9393+ pf ppf "%a: @[<h>%a@]@," label "Aliases"
9494+ (list ~sep:comma string) (List.tl (names t));
9595+ field "Email" (styled (`Fg `Yellow) string) t.email;
9696+ field "GitHub" (url_style (fun ppf g -> pf ppf "https://github.com/%s" g)) t.github;
9797+ field "Twitter" (url_style (fun ppf tw -> pf ppf "https://twitter.com/%s" tw)) t.twitter;
9898+ field "Bluesky" (styled (`Fg `Magenta) string) t.bluesky;
9999+ field "Mastodon" (styled (`Fg `Magenta) string) t.mastodon;
100100+ field "ORCID" (url_style (fun ppf o -> pf ppf "https://orcid.org/%s" o)) t.orcid;
101101+ (match urls t with
102102+ | [] -> ()
103103+ | [u] -> pf ppf "%a: %a@," label "URL" (url_style string) u
104104+ | all_urls ->
105105+ pf ppf "%a:@," label "URLs";
106106+ List.iter (fun u -> pf ppf " - %a@," (url_style string) u) all_urls);
107107+ field "Icon" (url_style string) t.icon;
108108+ field "Thumbnail" (styled (`Fg `White) string) t.thumbnail;
109109+ Option.iter (function
110110+ | [] -> ()
111111+ | feeds ->
112112+ pf ppf "%a:@," label "Feeds";
113113+ List.iter (fun feed -> pf ppf " - %a@," Sortal_feed.pp feed) feeds
114114+ ) t.feeds;
115115+ pf ppf "@]"
+137
lib/sortal_contact.mli
···11+(** Individual contact metadata.
22+33+ A contact represents metadata about a person, including their name(s),
44+ social media handles, professional identifiers, and other contact information. *)
55+66+type t
77+88+(** [make ~handle ~names ?email ?icon ?thumbnail ?github ?twitter ?bluesky ?mastodon
99+ ?orcid ?url ?feeds ()] creates a new contact.
1010+1111+ @param handle A unique identifier/username for this contact (required)
1212+ @param names A list of names for this contact, with the first being primary (required)
1313+ @param email Email address
1414+ @param icon URL to an avatar/icon image
1515+ @param thumbnail Path to a local thumbnail image file
1616+ @param github GitHub username (without the [\@] prefix)
1717+ @param twitter Twitter/X username (without the [\@] prefix)
1818+ @param bluesky Bluesky handle
1919+ @param mastodon Mastodon handle (including instance)
2020+ @param orcid ORCID identifier
2121+ @param url Personal or professional website URL (primary URL)
2222+ @param urls Additional website URLs
2323+ @param feeds List of feed subscriptions (Atom/RSS/JSON) associated with this contact *)
2424+val make :
2525+ handle:string ->
2626+ names:string list ->
2727+ ?email:string ->
2828+ ?icon:string ->
2929+ ?thumbnail:string ->
3030+ ?github:string ->
3131+ ?twitter:string ->
3232+ ?bluesky:string ->
3333+ ?mastodon:string ->
3434+ ?orcid:string ->
3535+ ?url:string ->
3636+ ?urls:string list ->
3737+ ?feeds:Sortal_feed.t list ->
3838+ unit ->
3939+ t
4040+4141+(** {1 Accessors} *)
4242+4343+(** [handle t] returns the unique handle/username. *)
4444+val handle : t -> string
4545+4646+(** [names t] returns all names associated with this contact. *)
4747+val names : t -> string list
4848+4949+(** [name t] returns the primary (first) name. *)
5050+val name : t -> string
5151+5252+(** [primary_name t] returns the primary (first) name.
5353+ This is an alias for {!name} for clarity. *)
5454+val primary_name : t -> string
5555+5656+(** [email t] returns the email address if available. *)
5757+val email : t -> string option
5858+5959+(** [icon t] returns the icon/avatar URL if available. *)
6060+val icon : t -> string option
6161+6262+(** [thumbnail t] returns the path to the local thumbnail image if available.
6363+ This is a relative path from the Sortal data directory. *)
6464+val thumbnail : t -> string option
6565+6666+(** [github t] returns the GitHub username if available. *)
6767+val github : t -> string option
6868+6969+(** [twitter t] returns the Twitter/X username if available. *)
7070+val twitter : t -> string option
7171+7272+(** [bluesky t] returns the Bluesky handle if available. *)
7373+val bluesky : t -> string option
7474+7575+(** [mastodon t] returns the Mastodon handle if available. *)
7676+val mastodon : t -> string option
7777+7878+(** [orcid t] returns the ORCID identifier if available. *)
7979+val orcid : t -> string option
8080+8181+(** [url t] returns the primary URL if available.
8282+8383+ Returns the [url] field if set, otherwise returns the first element
8484+ of [urls] if available, or [None] if neither is set. *)
8585+val url : t -> string option
8686+8787+(** [urls t] returns all URLs associated with this contact.
8888+8989+ Combines the [url] field (if set) with the [urls] list (if set).
9090+ The primary [url] appears first if present. Returns an empty list
9191+ if neither [url] nor [urls] is set. *)
9292+val urls : t -> string list
9393+9494+(** [feeds t] returns the list of feed subscriptions if available. *)
9595+val feeds : t -> Sortal_feed.t list option
9696+9797+(** [add_feed t feed] returns a new contact with the feed added. *)
9898+val add_feed : t -> Sortal_feed.t -> t
9999+100100+(** [remove_feed t url] returns a new contact with the feed matching the URL removed. *)
101101+val remove_feed : t -> string -> t
102102+103103+(** {1 Derived Information} *)
104104+105105+(** [best_url t] returns the best available URL for this contact.
106106+107107+ Priority order:
108108+ 1. Personal URL (if set)
109109+ 2. GitHub profile URL (if GitHub username is set)
110110+ 3. Email as mailto: link (if email is set)
111111+ 4. None if no URL-like information is available *)
112112+val best_url : t -> string option
113113+114114+(** {1 JSON Encoding} *)
115115+116116+(** [json_t] is the jsont encoder/decoder for contacts.
117117+118118+ The JSON schema includes all contact fields with optional values
119119+ omitted when not present:
120120+ {[
121121+ {
122122+ "handle": "avsm",
123123+ "names": ["Anil Madhavapeddy"],
124124+ "email": "anil@recoil.org",
125125+ "github": "avsm",
126126+ "orcid": "0000-0002-7890-1234"
127127+ }
128128+ ]} *)
129129+val json_t : t Jsont.t
130130+131131+(** {1 Utilities} *)
132132+133133+(** [compare a b] compares two contacts by their handles. *)
134134+val compare : t -> t -> int
135135+136136+(** [pp ppf t] pretty prints a contact with formatting. *)
137137+val pp : Format.formatter -> t -> unit
+51
lib/sortal_feed.ml
···11+type feed_type =
22+ | Atom
33+ | Rss
44+ | Json
55+66+type t = {
77+ feed_type : feed_type;
88+ url : string;
99+ name : string option;
1010+}
1111+1212+let make ~feed_type ~url ?name () =
1313+ { feed_type; url; name }
1414+1515+let feed_type t = t.feed_type
1616+let url t = t.url
1717+let name t = t.name
1818+1919+let set_name t name = { t with name = Some name }
2020+2121+let feed_type_to_string = function
2222+ | Atom -> "atom"
2323+ | Rss -> "rss"
2424+ | Json -> "json"
2525+2626+let feed_type_of_string = function
2727+ | "atom" -> Some Atom
2828+ | "rss" -> Some Rss
2929+ | "json" -> Some Json
3030+ | _ -> None
3131+3232+let json_t =
3333+ let open Jsont in
3434+ let open Jsont.Object in
3535+ let make feed_type url name =
3636+ match feed_type_of_string feed_type with
3737+ | Some ft -> { feed_type = ft; url; name }
3838+ | None -> failwith ("Invalid feed type: " ^ feed_type)
3939+ in
4040+ map ~kind:"Feed" make
4141+ |> mem "type" string ~enc:(fun f -> feed_type_to_string f.feed_type)
4242+ |> mem "url" string ~enc:(fun f -> f.url)
4343+ |> opt_mem "name" string ~enc:(fun f -> f.name)
4444+ |> finish
4545+4646+let pp ppf t =
4747+ let open Fmt in
4848+ pf ppf "%a: %a%a"
4949+ (styled (`Fg `Green) string) (feed_type_to_string t.feed_type)
5050+ (styled (`Fg `Blue) string) t.url
5151+ (option (fun ppf name -> pf ppf " (%a)" (styled `Faint string) name)) t.name
+43
lib/sortal_feed.mli
···11+(** Feed subscription with type and URL.
22+33+ A feed represents a subscription to a content source (Atom, RSS, or JSONFeed). *)
44+55+type t
66+77+(** Feed type identifier. *)
88+type feed_type =
99+ | Atom (** Atom feed format *)
1010+ | Rss (** RSS feed format *)
1111+ | Json (** JSON Feed format *)
1212+1313+(** [make ~feed_type ~url ?name ()] creates a new feed.
1414+1515+ @param feed_type The type of feed (Atom, RSS, or JSON)
1616+ @param url The feed URL
1717+ @param name Optional human-readable name/label for the feed *)
1818+val make : feed_type:feed_type -> url:string -> ?name:string -> unit -> t
1919+2020+(** [feed_type t] returns the feed type. *)
2121+val feed_type : t -> feed_type
2222+2323+(** [url t] returns the feed URL. *)
2424+val url : t -> string
2525+2626+(** [name t] returns the feed name if set. *)
2727+val name : t -> string option
2828+2929+(** [set_name t name] returns a new feed with the name updated. *)
3030+val set_name : t -> string -> t
3131+3232+(** [feed_type_to_string ft] converts a feed type to a string. *)
3333+val feed_type_to_string : feed_type -> string
3434+3535+(** [feed_type_of_string s] parses a feed type from a string.
3636+ Returns [None] if the string is not recognized. *)
3737+val feed_type_of_string : string -> feed_type option
3838+3939+(** [json_t] is the jsont encoder/decoder for feeds. *)
4040+val json_t : t Jsont.t
4141+4242+(** [pp ppf t] pretty prints a feed. *)
4343+val pp : Format.formatter -> t -> unit
+126
lib/sortal_store.ml
···11+type t = {
22+ xdg : Xdge.t; [@warning "-69"]
33+ data_dir : Eio.Fs.dir_ty Eio.Path.t;
44+}
55+66+let create fs app_name =
77+ let xdg = Xdge.create fs app_name in
88+ let data_dir = Xdge.data_dir xdg in
99+ { xdg; data_dir }
1010+1111+let create_from_xdg xdg =
1212+ let data_dir = Xdge.data_dir xdg in
1313+ { xdg; data_dir }
1414+1515+let contact_file t handle =
1616+ Eio.Path.(t.data_dir / (handle ^ ".json"))
1717+1818+let save t contact =
1919+ let path = contact_file t (Sortal_contact.handle contact) in
2020+ match Jsont_bytesrw.encode_string Sortal_contact.json_t contact with
2121+ | Ok json_str -> Eio.Path.save ~create:(`Or_truncate 0o644) path json_str
2222+ | Error err -> failwith ("Failed to encode contact: " ^ err)
2323+2424+let lookup t handle =
2525+ let path = contact_file t handle in
2626+ try
2727+ Eio.Path.load path
2828+ |> Jsont_bytesrw.decode_string Sortal_contact.json_t
2929+ |> Result.to_option
3030+ with _ -> None
3131+3232+let delete t handle =
3333+ let path = contact_file t handle in
3434+ try
3535+ Eio.Path.unlink path
3636+ with
3737+ | _ -> ()
3838+3939+let list t =
4040+ try
4141+ let entries = Eio.Path.read_dir t.data_dir in
4242+ List.filter_map (fun entry ->
4343+ if Filename.check_suffix entry ".json" then
4444+ let handle = Filename.chop_suffix entry ".json" in
4545+ lookup t handle
4646+ else
4747+ None
4848+ ) entries
4949+ with
5050+ | _ -> []
5151+5252+let thumbnail_path t contact =
5353+ Sortal_contact.thumbnail contact
5454+ |> Option.map (fun relative_path -> Eio.Path.(t.data_dir / relative_path))
5555+5656+let png_thumbnail_path t contact =
5757+ match Sortal_contact.thumbnail contact with
5858+ | None -> None
5959+ | Some relative_path ->
6060+ let base = Filename.remove_extension relative_path in
6161+ let png_path = base ^ ".png" in
6262+ let full_path = Eio.Path.(t.data_dir / png_path) in
6363+ try
6464+ ignore (Eio.Path.load full_path);
6565+ Some full_path
6666+ with _ -> None
6767+6868+let handle_of_name name =
6969+ let name = String.lowercase_ascii name in
7070+ let words = String.split_on_char ' ' name in
7171+ let initials = String.concat "" (List.map (fun w -> String.sub w 0 1) words) in
7272+ initials ^ List.hd (List.rev words)
7373+7474+let find_by_name t name =
7575+ let name_lower = String.lowercase_ascii name in
7676+ let all_contacts = list t in
7777+ let matches = List.filter (fun c ->
7878+ List.exists (fun n -> String.lowercase_ascii n = name_lower)
7979+ (Sortal_contact.names c)
8080+ ) all_contacts in
8181+ match matches with
8282+ | [contact] -> contact
8383+ | [] -> raise Not_found
8484+ | _ -> raise (Invalid_argument ("Multiple contacts match: " ^ name))
8585+8686+let find_by_name_opt t name =
8787+ try
8888+ Some (find_by_name t name)
8989+ with
9090+ | Not_found | Invalid_argument _ -> None
9191+9292+let contains_substring ~needle haystack =
9393+ let needle_len = String.length needle in
9494+ let haystack_len = String.length haystack in
9595+ if needle_len = 0 then true
9696+ else if needle_len > haystack_len then false
9797+ else
9898+ let rec check i =
9999+ if i > haystack_len - needle_len then false
100100+ else if String.sub haystack i needle_len = needle then true
101101+ else check (i + 1)
102102+ in
103103+ check 0
104104+105105+let search_all t query =
106106+ let query_lower = String.lowercase_ascii query in
107107+ let all = list t in
108108+ let matches = List.filter (fun c ->
109109+ List.exists (fun name ->
110110+ let name_lower = String.lowercase_ascii name in
111111+ String.equal name_lower query_lower ||
112112+ String.starts_with ~prefix:query_lower name_lower ||
113113+ contains_substring ~needle:query_lower name_lower ||
114114+ (String.contains name_lower ' ' &&
115115+ String.split_on_char ' ' name_lower |> List.exists (fun word ->
116116+ String.starts_with ~prefix:query_lower word
117117+ ))
118118+ ) (Sortal_contact.names c)
119119+ ) all in
120120+ List.sort Sortal_contact.compare matches
121121+122122+let pp ppf t =
123123+ let all = list t in
124124+ Fmt.pf ppf "@[<v>%a: %d contacts stored in XDG data directory@]"
125125+ (Fmt.styled `Bold Fmt.string) "Sortal Store"
126126+ (List.length all)
+134
lib/sortal_store.mli
···11+(** Contact store with XDG-compliant storage.
22+33+ The contact store manages reading and writing contact metadata
44+ using XDG-compliant storage locations. *)
55+66+type t
77+88+(** [create fs app_name] creates a new contact store.
99+1010+ The store will use XDG data directories for persistent storage
1111+ of contact metadata. Each contact is stored as a separate JSON
1212+ file named after its handle.
1313+1414+ @param fs Eio filesystem for file operations
1515+ @param app_name Application name for XDG directory structure *)
1616+val create : Eio.Fs.dir_ty Eio.Path.t -> string -> t
1717+1818+(** [create_from_xdg xdg] creates a contact store from an XDG context.
1919+2020+ This is a convenience function for creating a store when you already
2121+ have an XDG context (e.g., from your own XDG initialization).
2222+ The store will use the XDG data directory for the application.
2323+2424+ @param xdg An existing XDG context
2525+ @return A contact store using the XDG data directory *)
2626+val create_from_xdg : Xdge.t -> t
2727+2828+(** {1 Storage Operations} *)
2929+3030+(** [save t contact] saves a contact to the store.
3131+3232+ The contact is serialized to JSON and written to a file
3333+ named "handle.json" in the XDG data directory.
3434+3535+ If a contact with the same handle already exists, it is overwritten. *)
3636+val save : t -> Sortal_contact.t -> unit
3737+3838+(** [lookup t handle] retrieves a contact by handle.
3939+4040+ Searches for a file named "handle.json" in the XDG data directory
4141+ and deserializes it if found.
4242+4343+ @return [Some contact] if found, [None] if not found or deserialization fails *)
4444+val lookup : t -> string -> Sortal_contact.t option
4545+4646+(** [delete t handle] removes a contact from the store.
4747+4848+ Deletes the file "handle.json" from the XDG data directory.
4949+ Does nothing if the contact does not exist. *)
5050+val delete : t -> string -> unit
5151+5252+(** [list t] returns all contacts in the store.
5353+5454+ Scans the XDG data directory for all .json files and attempts
5555+ to deserialize them as contacts. Files that fail to parse are
5656+ silently skipped.
5757+5858+ @return A list of all successfully loaded contacts *)
5959+val list : t -> Sortal_contact.t list
6060+6161+(** [thumbnail_path t contact] returns the absolute filesystem path to the contact's thumbnail.
6262+6363+ Returns [None] if the contact has no thumbnail set, or [Some path] with
6464+ the full path to the thumbnail file in Sortal's data directory.
6565+6666+ @param t The Sortal store
6767+ @param contact The contact whose thumbnail path to retrieve *)
6868+val thumbnail_path : t -> Sortal_contact.t -> Eio.Fs.dir_ty Eio.Path.t option
6969+7070+(** [png_thumbnail_path t contact] returns the path to the PNG version of the contact's thumbnail.
7171+7272+ Returns [None] if the contact has no thumbnail set or if no PNG version exists.
7373+ This looks for a .png file with the same base name as the contact's thumbnail.
7474+ Use this after running [sync] to get the converted PNG thumbnails.
7575+7676+ @param t The Sortal store
7777+ @param contact The contact whose PNG thumbnail path to retrieve *)
7878+val png_thumbnail_path : t -> Sortal_contact.t -> Eio.Fs.dir_ty Eio.Path.t option
7979+8080+(** {1 Searching} *)
8181+8282+(** [find_by_name t name] searches for contacts by name.
8383+8484+ Performs a case-insensitive search through all contacts,
8585+ checking if any of their names match the provided name.
8686+8787+ @param name The name to search for (case-insensitive)
8888+ @return The matching contact if exactly one match is found
8989+ @raise Not_found if no contacts match the name
9090+ @raise Invalid_argument if multiple contacts match the name *)
9191+val find_by_name : t -> string -> Sortal_contact.t
9292+9393+(** [find_by_name_opt t name] searches for contacts by name, returning an option.
9494+9595+ Like {!find_by_name} but returns [None] instead of raising exceptions
9696+ when no match or multiple matches are found.
9797+9898+ @param name The name to search for (case-insensitive)
9999+ @return [Some contact] if exactly one match is found, [None] otherwise *)
100100+val find_by_name_opt : t -> string -> Sortal_contact.t option
101101+102102+(** [search_all t query] searches for contacts matching a query string.
103103+104104+ Performs a flexible search through all contact names, looking for:
105105+ - Exact matches (case-insensitive)
106106+ - Names that start with the query
107107+ - Multi-word names where any word starts with the query
108108+109109+ This is useful for autocomplete or fuzzy search functionality.
110110+111111+ @param t The contact store
112112+ @param query The search query (case-insensitive)
113113+ @return A list of matching contacts, sorted by handle *)
114114+val search_all : t -> string -> Sortal_contact.t list
115115+116116+(** {1 Utilities} *)
117117+118118+(** [handle_of_name name] generates a handle from a full name.
119119+120120+ Creates a handle by concatenating the initials of all words
121121+ in the name with the full last name, all in lowercase.
122122+123123+ Examples:
124124+ - "Anil Madhavapeddy" -> "ammadhavapeddy"
125125+ - "John Smith" -> "jssmith"
126126+127127+ @param name The full name to convert
128128+ @return A suggested handle *)
129129+val handle_of_name : string -> string
130130+131131+(** {1 Pretty Printing} *)
132132+133133+(** [pp ppf t] pretty prints the contact store showing statistics. *)
134134+val pp : Format.formatter -> t -> unit