···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.
66+ provides temporal support for time-bounded information like historical
77+ email addresses and employment records.
7889 {b Storage:}
9101010- Contact metadata is stored as JSON files in the XDG data directory,
1111- with one file per contact using the handle as the filename.
1111+ Contact metadata is stored as YAML files in the XDG data directory,
1212+ with one file per contact using the handle as the filename. The YAML
1313+ format uses the same Jsont codec definitions as JSON for seamless
1414+ compatibility.
12151316 {b Typical Usage:}
1417···31343235(** {1 Core Modules} *)
33363737+(** Temporal validity support for time-bounded contact fields. *)
3838+module Temporal = Sortal_temporal
3939+3440(** Feed subscription metadata. *)
3541module Feed = Sortal_feed
36423737-(** Contact metadata. *)
4343+(** Contact metadata with temporal support. *)
3844module Contact = Sortal_contact
39454046(** Contact store with XDG-compliant storage. *)
+319-14
lib/sortal_cmd.ml
···2929 let store = Sortal_store.create_from_xdg xdg in
3030 match Sortal_store.lookup store handle with
3131 | Some c ->
3232- Printf.printf "@%s: %s\n" (Sortal_contact.handle c) (Sortal_contact.name c);
3333- Option.iter (fun e -> Printf.printf "Email: %s\n" e) (Sortal_contact.email c);
3434- Option.iter (fun g -> Printf.printf "GitHub: https://github.com/%s\n" g) (Sortal_contact.github c);
3535- Option.iter (fun u -> Printf.printf "URL: %s\n" u) (Sortal_contact.best_url c);
3636- Option.iter (fun tw -> Printf.printf "Twitter: https://twitter.com/%s\n" tw) (Sortal_contact.twitter c);
3737- Option.iter (fun b -> Printf.printf "Bluesky: %s\n" b) (Sortal_contact.bluesky c);
3838- Option.iter (fun m -> Printf.printf "Mastodon: %s\n" m) (Sortal_contact.mastodon c);
3939- Option.iter (fun o -> Printf.printf "ORCID: https://orcid.org/%s\n" o) (Sortal_contact.orcid c);
3232+ (* Use the pretty printer for rich temporal display *)
3333+ Fmt.pr "%a@." Sortal_contact.pp c;
4034 0
4135 | None -> Logs.err (fun m -> m "Contact not found: %s" handle); 1
4236···5246 (if List.length matches = 1 then "" else "es"));
5347 List.iter (fun c ->
5448 Logs.app (fun m -> m "@%s: %s" (Sortal_contact.handle c) (Sortal_contact.name c));
5555- Option.iter (fun e -> Logs.app (fun m -> m " Email: %s" e)) (Sortal_contact.email c);
5656- Option.iter (fun g -> Logs.app (fun m -> m " GitHub: @%s" g)) (Sortal_contact.github c);
4949+ Option.iter (fun e -> Logs.app (fun m -> m " Email: %s" e)) (Sortal_contact.current_email c);
5750 Option.iter (fun u -> Logs.app (fun m -> m " URL: %s" u)) (Sortal_contact.best_url c)
5851 ) matches;
5952 0
···6356 let contacts = Sortal_store.list store in
6457 let total = List.length contacts in
6558 let count pred = List.filter pred contacts |> List.length in
6666- let with_email = count (fun c -> Option.is_some (Sortal_contact.email c)) in
6767- let with_github = count (fun c -> Option.is_some (Sortal_contact.github c)) in
5959+ let with_email = count (fun c -> Sortal_contact.emails c <> []) in
6060+ let with_org = count (fun c -> Sortal_contact.organizations c <> []) in
6161+ let with_url = count (fun c -> Sortal_contact.urls c <> []) in
6262+ let with_service = count (fun c -> Sortal_contact.services c <> []) in
6863 let with_orcid = count (fun c -> Option.is_some (Sortal_contact.orcid c)) in
6969- let with_url = count (fun c -> Option.is_some (Sortal_contact.url c)) in
7064 let with_feeds = count (fun c -> Option.is_some (Sortal_contact.feeds c)) in
7165 let total_feeds =
7266 List.fold_left (fun acc c ->
7367 acc + Option.fold ~none:0 ~some:List.length (Sortal_contact.feeds c)
7468 ) 0 contacts
7569 in
7070+ let total_services =
7171+ List.fold_left (fun acc c ->
7272+ acc + List.length (Sortal_contact.services c)
7373+ ) 0 contacts
7474+ in
7675 let pct n = float_of_int n /. float_of_int total *. 100. in
7776 Logs.app (fun m -> m "Contact Database Statistics:");
7877 Logs.app (fun m -> m " Total contacts: %d" total);
7978 Logs.app (fun m -> m " With email: %d (%.1f%%)" with_email (pct with_email));
8080- Logs.app (fun m -> m " With GitHub: %d (%.1f%%)" with_github (pct with_github));
7979+ Logs.app (fun m -> m " With organization: %d (%.1f%%)" with_org (pct with_org));
8080+ Logs.app (fun m -> m " With services: %d (%.1f%%), total %d services" with_service (pct with_service) total_services);
8181 Logs.app (fun m -> m " With ORCID: %d (%.1f%%)" with_orcid (pct with_orcid));
8282 Logs.app (fun m -> m " With URL: %d (%.1f%%)" with_url (pct with_url));
8383 Logs.app (fun m -> m " With feeds: %d (%.1f%%), total %d feeds" with_feeds (pct with_feeds) total_feeds);
···121121 Logs.app (fun m -> m " %d errors" !errors);
122122 if !errors > 0 then 1 else 0
123123124124+(* Initialize git repository *)
125125+let git_init_cmd xdg env =
126126+ let store = Sortal_store.create_from_xdg xdg in
127127+ let git_store = Sortal_git_store.create store env in
128128+ match Sortal_git_store.init git_store with
129129+ | Ok () ->
130130+ if Sortal_git_store.is_initialized git_store then
131131+ Logs.app (fun m -> m "Git repository initialized in data directory")
132132+ else
133133+ Logs.app (fun m -> m "Git repository already initialized");
134134+ 0
135135+ | Error msg ->
136136+ Logs.err (fun m -> m "Failed to initialize git repository: %s" msg);
137137+ 1
138138+139139+(* Add a new contact *)
140140+let add_cmd handle names kind email github url orcid xdg env =
141141+ let store = Sortal_store.create_from_xdg xdg in
142142+ let git_store = Sortal_git_store.create store env in
143143+ (* Check if contact already exists *)
144144+ match Sortal_store.lookup store handle with
145145+ | Some _ ->
146146+ Logs.err (fun m -> m "Contact @%s already exists" handle);
147147+ 1
148148+ | None ->
149149+ let emails = match email with
150150+ | Some e -> [Sortal_contact.make_email e]
151151+ | None -> []
152152+ in
153153+ let services = match github with
154154+ | Some gh -> [Sortal_contact.make_service ~kind:Sortal_contact.Github ~handle:gh (Printf.sprintf "https://github.com/%s" gh)]
155155+ | None -> []
156156+ in
157157+ let urls = match url with
158158+ | Some u -> [Sortal_contact.make_url u]
159159+ | None -> []
160160+ in
161161+ let contact = Sortal_contact.make
162162+ ~handle
163163+ ~names
164164+ ?kind
165165+ ~emails
166166+ ~services
167167+ ~urls
168168+ ?orcid
169169+ ()
170170+ in
171171+ match Sortal_git_store.save git_store contact with
172172+ | Ok () ->
173173+ Logs.app (fun m -> m "Created contact @%s: %s" handle (Sortal_contact.name contact));
174174+ 0
175175+ | Error msg ->
176176+ Logs.err (fun m -> m "Failed to save contact: %s" msg);
177177+ 1
178178+179179+(* Delete a contact *)
180180+let delete_cmd handle xdg env =
181181+ let store = Sortal_store.create_from_xdg xdg in
182182+ let git_store = Sortal_git_store.create store env in
183183+ match Sortal_git_store.delete git_store handle with
184184+ | Ok () ->
185185+ Logs.app (fun m -> m "Deleted contact @%s" handle);
186186+ 0
187187+ | Error msg ->
188188+ Logs.err (fun m -> m "%s" msg);
189189+ 1
190190+191191+(* Add email to existing contact *)
192192+let add_email_cmd handle address type_ from until note xdg env =
193193+ let store = Sortal_store.create_from_xdg xdg in
194194+ let git_store = Sortal_git_store.create store env in
195195+ let email = Sortal_contact.make_email ?type_ ?from ?until ?note address in
196196+ match Sortal_git_store.add_email git_store handle email with
197197+ | Ok () ->
198198+ Logs.app (fun m -> m "Added email %s to @%s" address handle);
199199+ 0
200200+ | Error msg ->
201201+ Logs.err (fun m -> m "%s" msg);
202202+ 1
203203+204204+(* Remove email from contact *)
205205+let remove_email_cmd handle address xdg env =
206206+ let store = Sortal_store.create_from_xdg xdg in
207207+ let git_store = Sortal_git_store.create store env in
208208+ match Sortal_git_store.remove_email git_store handle address with
209209+ | Ok () ->
210210+ Logs.app (fun m -> m "Removed email %s from @%s" address handle);
211211+ 0
212212+ | Error msg ->
213213+ Logs.err (fun m -> m "%s" msg);
214214+ 1
215215+216216+(* Add service to existing contact *)
217217+let add_service_cmd handle url kind service_handle label xdg env =
218218+ let store = Sortal_store.create_from_xdg xdg in
219219+ let git_store = Sortal_git_store.create store env in
220220+ let service = Sortal_contact.make_service ?kind ?handle:service_handle ?label url in
221221+ match Sortal_git_store.add_service git_store handle service with
222222+ | Ok () ->
223223+ Logs.app (fun m -> m "Added service %s to @%s" url handle);
224224+ 0
225225+ | Error msg ->
226226+ Logs.err (fun m -> m "%s" msg);
227227+ 1
228228+229229+(* Remove service from contact *)
230230+let remove_service_cmd handle url xdg env =
231231+ let store = Sortal_store.create_from_xdg xdg in
232232+ let git_store = Sortal_git_store.create store env in
233233+ match Sortal_git_store.remove_service git_store handle url with
234234+ | Ok () ->
235235+ Logs.app (fun m -> m "Removed service %s from @%s" url handle);
236236+ 0
237237+ | Error msg ->
238238+ Logs.err (fun m -> m "%s" msg);
239239+ 1
240240+241241+(* Add organization to existing contact *)
242242+let add_org_cmd handle org_name title department from until org_email org_url xdg env =
243243+ let store = Sortal_store.create_from_xdg xdg in
244244+ let git_store = Sortal_git_store.create store env in
245245+ let org = Sortal_contact.make_org ?title ?department ?from ?until ?email:org_email ?url:org_url org_name in
246246+ match Sortal_git_store.add_organization git_store handle org with
247247+ | Ok () ->
248248+ Logs.app (fun m -> m "Added organization %s to @%s" org_name handle);
249249+ 0
250250+ | Error msg ->
251251+ Logs.err (fun m -> m "%s" msg);
252252+ 1
253253+254254+(* Remove organization from contact *)
255255+let remove_org_cmd handle org_name xdg env =
256256+ let store = Sortal_store.create_from_xdg xdg in
257257+ let git_store = Sortal_git_store.create store env in
258258+ match Sortal_git_store.remove_organization git_store handle org_name with
259259+ | Ok () ->
260260+ Logs.app (fun m -> m "Removed organization %s from @%s" org_name handle);
261261+ 0
262262+ | Error msg ->
263263+ Logs.err (fun m -> m "%s" msg);
264264+ 1
265265+266266+(* Add URL to existing contact *)
267267+let add_url_cmd handle url label xdg env =
268268+ let store = Sortal_store.create_from_xdg xdg in
269269+ let git_store = Sortal_git_store.create store env in
270270+ let url_entry = Sortal_contact.make_url ?label url in
271271+ match Sortal_git_store.add_url git_store handle url_entry with
272272+ | Ok () ->
273273+ Logs.app (fun m -> m "Added URL %s to @%s" url handle);
274274+ 0
275275+ | Error msg ->
276276+ Logs.err (fun m -> m "%s" msg);
277277+ 1
278278+279279+(* Remove URL from contact *)
280280+let remove_url_cmd handle url xdg env =
281281+ let store = Sortal_store.create_from_xdg xdg in
282282+ let git_store = Sortal_git_store.create store env in
283283+ match Sortal_git_store.remove_url git_store handle url with
284284+ | Ok () ->
285285+ Logs.app (fun m -> m "Removed URL %s from @%s" url handle);
286286+ 0
287287+ | Error msg ->
288288+ Logs.err (fun m -> m "%s" msg);
289289+ 1
290290+291291+(* Command info and args *)
124292let list_info = Cmd.info "list" ~doc:"List all contacts"
125293let show_info = Cmd.info "show" ~doc:"Show detailed information about a contact"
126294let search_info = Cmd.info "search" ~doc:"Search contacts by name"
127295let stats_info = Cmd.info "stats" ~doc:"Show statistics about the contact database"
128296let sync_info = Cmd.info "sync" ~doc:"Synchronize and normalize contact data"
129297298298+let git_init_info = Cmd.info "git-init" ~doc:"Initialize git repository for contact versioning"
299299+ ~man:[
300300+ `S Manpage.s_description;
301301+ `P "Initialize a git repository in the XDG data directory to track contact changes.";
302302+ `P "Once initialized, all contact modifications will be automatically committed with descriptive messages.";
303303+ ]
304304+305305+let add_info = Cmd.info "add" ~doc:"Create a new contact"
306306+ ~man:[
307307+ `S Manpage.s_description;
308308+ `P "Create a new contact with the given handle and name.";
309309+ `P "Additional metadata can be added using options or via add-email, add-service, etc. commands.";
310310+ ]
311311+312312+let delete_info = Cmd.info "delete" ~doc:"Delete a contact"
313313+let add_email_info = Cmd.info "add-email" ~doc:"Add an email address to a contact"
314314+let remove_email_info = Cmd.info "remove-email" ~doc:"Remove an email address from a contact"
315315+let add_service_info = Cmd.info "add-service" ~doc:"Add a service (GitHub, Twitter, etc.) to a contact"
316316+let remove_service_info = Cmd.info "remove-service" ~doc:"Remove a service from a contact"
317317+let add_org_info = Cmd.info "add-org" ~doc:"Add an organization/affiliation to a contact"
318318+let remove_org_info = Cmd.info "remove-org" ~doc:"Remove an organization from a contact"
319319+let add_url_info = Cmd.info "add-url" ~doc:"Add a URL to a contact"
320320+let remove_url_info = Cmd.info "remove-url" ~doc:"Remove a URL from a contact"
321321+130322let handle_arg =
131323 Arg.(required & pos 0 (some string) None & info [] ~docv:"HANDLE"
132324 ~doc:"Contact handle to display")
···134326let query_arg =
135327 Arg.(required & pos 0 (some string) None & info [] ~docv:"QUERY"
136328 ~doc:"Name or partial name to search for")
329329+330330+(* Add command arguments *)
331331+let add_handle_arg =
332332+ Arg.(required & pos 0 (some string) None & info [] ~docv:"HANDLE"
333333+ ~doc:"Contact handle (unique identifier)")
334334+335335+let add_names_arg =
336336+ Arg.(non_empty & opt_all string [] & info ["n"; "name"] ~docv:"NAME"
337337+ ~doc:"Full name (can be specified multiple times for aliases)")
338338+339339+let add_kind_arg =
340340+ let kind_conv =
341341+ let parse s = match Sortal_contact.contact_kind_of_string s with
342342+ | Some k -> Ok k
343343+ | None -> Error (`Msg (Printf.sprintf "Invalid kind: %s" s))
344344+ in
345345+ let print ppf k = Format.pp_print_string ppf (Sortal_contact.contact_kind_to_string k) in
346346+ Arg.conv (parse, print)
347347+ in
348348+ Arg.(value & opt (some kind_conv) None & info ["k"; "kind"] ~docv:"KIND"
349349+ ~doc:"Contact kind (person, organization, group, role)")
350350+351351+let add_email_arg =
352352+ Arg.(value & opt (some string) None & info ["e"; "email"] ~docv:"EMAIL"
353353+ ~doc:"Email address")
354354+355355+let add_github_arg =
356356+ Arg.(value & opt (some string) None & info ["g"; "github"] ~docv:"HANDLE"
357357+ ~doc:"GitHub handle")
358358+359359+let add_url_arg =
360360+ Arg.(value & opt (some string) None & info ["u"; "url"] ~docv:"URL"
361361+ ~doc:"Personal/professional website URL")
362362+363363+let add_orcid_arg =
364364+ Arg.(value & opt (some string) None & info ["orcid"] ~docv:"ORCID"
365365+ ~doc:"ORCID identifier")
366366+367367+(* Add-email command arguments *)
368368+let email_address_arg =
369369+ Arg.(required & pos 1 (some string) None & info [] ~docv:"EMAIL"
370370+ ~doc:"Email address")
371371+372372+let email_type_arg =
373373+ let type_conv =
374374+ let parse s = match Sortal_contact.email_type_of_string s with
375375+ | Some t -> Ok t
376376+ | None -> Error (`Msg (Printf.sprintf "Invalid email type: %s" s))
377377+ in
378378+ let print ppf t = Format.pp_print_string ppf (Sortal_contact.email_type_to_string t) in
379379+ Arg.conv (parse, print)
380380+ in
381381+ Arg.(value & opt (some type_conv) None & info ["t"; "type"] ~docv:"TYPE"
382382+ ~doc:"Email type (work, personal, other)")
383383+384384+let date_arg name =
385385+ Arg.(value & opt (some string) None & info [name] ~docv:"DATE"
386386+ ~doc:"ISO 8601 date (e.g., 2023, 2023-01, 2023-01-15)")
387387+388388+let note_arg =
389389+ Arg.(value & opt (some string) None & info ["note"] ~docv:"NOTE"
390390+ ~doc:"Contextual note")
391391+392392+(* Add-service command arguments *)
393393+let service_url_arg =
394394+ Arg.(required & pos 1 (some string) None & info [] ~docv:"URL"
395395+ ~doc:"Service URL")
396396+397397+let service_kind_arg =
398398+ let kind_conv =
399399+ let parse s = match Sortal_contact.service_kind_of_string s with
400400+ | Some k -> Ok k
401401+ | None -> Error (`Msg (Printf.sprintf "Invalid service kind: %s" s))
402402+ in
403403+ let print ppf k = Format.pp_print_string ppf (Sortal_contact.service_kind_to_string k) in
404404+ Arg.conv (parse, print)
405405+ in
406406+ Arg.(value & opt (some kind_conv) None & info ["k"; "kind"] ~docv:"KIND"
407407+ ~doc:"Service kind (github, git, social, activitypub, photo)")
408408+409409+let service_handle_arg =
410410+ Arg.(value & opt (some string) None & info ["handle"] ~docv:"HANDLE"
411411+ ~doc:"Service handle/username")
412412+413413+let label_arg =
414414+ Arg.(value & opt (some string) None & info ["l"; "label"] ~docv:"LABEL"
415415+ ~doc:"Human-readable label")
416416+417417+(* Add-org command arguments *)
418418+let org_name_arg =
419419+ Arg.(required & pos 1 (some string) None & info [] ~docv:"ORG"
420420+ ~doc:"Organization name")
421421+422422+let org_title_arg =
423423+ Arg.(value & opt (some string) None & info ["title"] ~docv:"TITLE"
424424+ ~doc:"Job title")
425425+426426+let org_department_arg =
427427+ Arg.(value & opt (some string) None & info ["dept"; "department"] ~docv:"DEPT"
428428+ ~doc:"Department")
429429+430430+let org_email_arg =
431431+ Arg.(value & opt (some string) None & info ["email"] ~docv:"EMAIL"
432432+ ~doc:"Work email during this period")
433433+434434+let org_url_arg =
435435+ Arg.(value & opt (some string) None & info ["url"] ~docv:"URL"
436436+ ~doc:"Work homepage during this period")
437437+438438+(* URL command arguments *)
439439+let url_value_arg =
440440+ Arg.(required & pos 1 (some string) None & info [] ~docv:"URL"
441441+ ~doc:"URL")
+172
lib/sortal_cmd.mli
···2929 - Converts non-JPG thumbnail images to PNG using ImageMagick *)
3030val sync_cmd : unit -> (Xdge.t -> int)
31313232+(** [git_init_cmd xdg env] initializes a git repository in the data directory.
3333+3434+ Once initialized, all contact modifications will be automatically committed.
3535+ @param xdg XDG context
3636+ @param env Eio environment for process spawning *)
3737+val git_init_cmd : Xdge.t -> Eio_unix.Stdenv.base -> int
3838+3939+(** [add_cmd handle names kind email github url orcid xdg env] creates a new contact.
4040+4141+ @param handle Contact handle (unique identifier)
4242+ @param names List of names (first is primary)
4343+ @param kind Optional contact kind
4444+ @param email Optional email address
4545+ @param github Optional GitHub handle
4646+ @param url Optional personal/professional website
4747+ @param orcid Optional ORCID identifier
4848+ @param xdg XDG context
4949+ @param env Eio environment for git operations *)
5050+val add_cmd : string -> string list -> Sortal_contact.contact_kind option ->
5151+ string option -> string option -> string option -> string option ->
5252+ Xdge.t -> Eio_unix.Stdenv.base -> int
5353+5454+(** [delete_cmd handle xdg env] deletes a contact.
5555+5656+ @param handle The contact handle to delete
5757+ @param xdg XDG context
5858+ @param env Eio environment for git operations *)
5959+val delete_cmd : string -> Xdge.t -> Eio_unix.Stdenv.base -> int
6060+6161+(** [add_email_cmd handle address type_ from until note xdg env] adds an email to a contact.
6262+6363+ @param handle Contact handle
6464+ @param address Email address
6565+ @param type_ Email type (work, personal, other)
6666+ @param from Start date of validity
6767+ @param until End date of validity
6868+ @param note Contextual note
6969+ @param xdg XDG context
7070+ @param env Eio environment for git operations *)
7171+val add_email_cmd : string -> string -> Sortal_contact.email_type option ->
7272+ string option -> string option -> string option ->
7373+ Xdge.t -> Eio_unix.Stdenv.base -> int
7474+7575+(** [remove_email_cmd handle address xdg env] removes an email from a contact. *)
7676+val remove_email_cmd : string -> string -> Xdge.t -> Eio_unix.Stdenv.base -> int
7777+7878+(** [add_service_cmd handle url kind service_handle label xdg env] adds a service to a contact.
7979+8080+ @param handle Contact handle
8181+ @param url Service URL
8282+ @param kind Service kind
8383+ @param service_handle Service username/handle
8484+ @param label Human-readable label
8585+ @param xdg XDG context
8686+ @param env Eio environment for git operations *)
8787+val add_service_cmd : string -> string -> Sortal_contact.service_kind option ->
8888+ string option -> string option -> Xdge.t -> Eio_unix.Stdenv.base -> int
8989+9090+(** [remove_service_cmd handle url xdg env] removes a service from a contact. *)
9191+val remove_service_cmd : string -> string -> Xdge.t -> Eio_unix.Stdenv.base -> int
9292+9393+(** [add_org_cmd handle org_name title department from until org_email org_url xdg env]
9494+ adds an organization to a contact. *)
9595+val add_org_cmd : string -> string -> string option -> string option ->
9696+ string option -> string option -> string option -> string option ->
9797+ Xdge.t -> Eio_unix.Stdenv.base -> int
9898+9999+(** [remove_org_cmd handle org_name xdg env] removes an organization from a contact. *)
100100+val remove_org_cmd : string -> string -> Xdge.t -> Eio_unix.Stdenv.base -> int
101101+102102+(** [add_url_cmd handle url label xdg env] adds a URL to a contact. *)
103103+val add_url_cmd : string -> string -> string option -> Xdge.t -> Eio_unix.Stdenv.base -> int
104104+105105+(** [remove_url_cmd handle url xdg env] removes a URL from a contact. *)
106106+val remove_url_cmd : string -> string -> Xdge.t -> Eio_unix.Stdenv.base -> int
107107+32108(** {1 Cmdliner Info Objects} *)
3310934110(** [list_info] is the command info for the list command. *)
···46122(** [sync_info] is the command info for the sync command. *)
47123val sync_info : Cmdliner.Cmd.info
48124125125+(** [git_init_info] is the command info for the git-init command. *)
126126+val git_init_info : Cmdliner.Cmd.info
127127+128128+(** [add_info] is the command info for the add command. *)
129129+val add_info : Cmdliner.Cmd.info
130130+131131+(** [delete_info] is the command info for the delete command. *)
132132+val delete_info : Cmdliner.Cmd.info
133133+134134+(** [add_email_info] is the command info for the add-email command. *)
135135+val add_email_info : Cmdliner.Cmd.info
136136+137137+(** [remove_email_info] is the command info for the remove-email command. *)
138138+val remove_email_info : Cmdliner.Cmd.info
139139+140140+(** [add_service_info] is the command info for the add-service command. *)
141141+val add_service_info : Cmdliner.Cmd.info
142142+143143+(** [remove_service_info] is the command info for the remove-service command. *)
144144+val remove_service_info : Cmdliner.Cmd.info
145145+146146+(** [add_org_info] is the command info for the add-org command. *)
147147+val add_org_info : Cmdliner.Cmd.info
148148+149149+(** [remove_org_info] is the command info for the remove-org command. *)
150150+val remove_org_info : Cmdliner.Cmd.info
151151+152152+(** [add_url_info] is the command info for the add-url command. *)
153153+val add_url_info : Cmdliner.Cmd.info
154154+155155+(** [remove_url_info] is the command info for the remove-url command. *)
156156+val remove_url_info : Cmdliner.Cmd.info
157157+49158(** {1 Cmdliner Argument Definitions} *)
5015951160(** [handle_arg] is the positional argument for a contact handle. *)
···5316254163(** [query_arg] is the positional argument for a search query. *)
55164val query_arg : string Cmdliner.Term.t
165165+166166+(** [add_handle_arg] is the positional argument for a new contact handle. *)
167167+val add_handle_arg : string Cmdliner.Term.t
168168+169169+(** [add_names_arg] is the repeatable option for contact names. *)
170170+val add_names_arg : string list Cmdliner.Term.t
171171+172172+(** [add_kind_arg] is the optional argument for contact kind. *)
173173+val add_kind_arg : Sortal_contact.contact_kind option Cmdliner.Term.t
174174+175175+(** [add_email_arg] is the optional argument for email. *)
176176+val add_email_arg : string option Cmdliner.Term.t
177177+178178+(** [add_github_arg] is the optional argument for GitHub handle. *)
179179+val add_github_arg : string option Cmdliner.Term.t
180180+181181+(** [add_url_arg] is the optional argument for URL. *)
182182+val add_url_arg : string option Cmdliner.Term.t
183183+184184+(** [add_orcid_arg] is the optional argument for ORCID. *)
185185+val add_orcid_arg : string option Cmdliner.Term.t
186186+187187+(** [email_address_arg] is the positional argument for email address. *)
188188+val email_address_arg : string Cmdliner.Term.t
189189+190190+(** [email_type_arg] is the optional argument for email type. *)
191191+val email_type_arg : Sortal_contact.email_type option Cmdliner.Term.t
192192+193193+(** [date_arg name] creates a date argument with the given option name. *)
194194+val date_arg : string -> string option Cmdliner.Term.t
195195+196196+(** [note_arg] is the optional argument for notes. *)
197197+val note_arg : string option Cmdliner.Term.t
198198+199199+(** [service_url_arg] is the positional argument for service URL. *)
200200+val service_url_arg : string Cmdliner.Term.t
201201+202202+(** [service_kind_arg] is the optional argument for service kind. *)
203203+val service_kind_arg : Sortal_contact.service_kind option Cmdliner.Term.t
204204+205205+(** [service_handle_arg] is the optional argument for service handle. *)
206206+val service_handle_arg : string option Cmdliner.Term.t
207207+208208+(** [label_arg] is the optional argument for labels. *)
209209+val label_arg : string option Cmdliner.Term.t
210210+211211+(** [org_name_arg] is the positional argument for organization name. *)
212212+val org_name_arg : string Cmdliner.Term.t
213213+214214+(** [org_title_arg] is the optional argument for job title. *)
215215+val org_title_arg : string option Cmdliner.Term.t
216216+217217+(** [org_department_arg] is the optional argument for department. *)
218218+val org_department_arg : string option Cmdliner.Term.t
219219+220220+(** [org_email_arg] is the optional argument for work email. *)
221221+val org_email_arg : string option Cmdliner.Term.t
222222+223223+(** [org_url_arg] is the optional argument for work URL. *)
224224+val org_url_arg : string option Cmdliner.Term.t
225225+226226+(** [url_value_arg] is the positional argument for URL. *)
227227+val url_value_arg : string Cmdliner.Term.t
+2-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 "@]"
11+module V1 = Sortal_contact_v1
22+include Sortal_contact_v1
+7-132
lib/sortal_contact.mli
···11(** Individual contact metadata.
2233- 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
33+ This module re-exports the current contact schema version (V1).
44+ See {!Sortal_contact_v1} for the full API documentation. *)
4554646-(** [names t] returns all names associated with this contact. *)
4747-val names : t -> string list
66+(** {1 Current Schema Version} *)
4874949-(** [name t] returns the primary (first) name. *)
5050-val name : t -> string
88+module V1 = Sortal_contact_v1
99+(** Current schema version. All functions below are aliases to V1. *)
51105252-(** [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
1111+include module type of Sortal_contact_v1
1212+(** @inline *)
+463
lib/sortal_contact_v1.ml
···11+let version = 1
22+33+type contact_kind = Person | Organization | Group | Role
44+55+type service_kind =
66+ | ActivityPub
77+ | Github
88+ | Git
99+ | Social
1010+ | Photo
1111+ | Custom of string
1212+1313+type service = {
1414+ url: string;
1515+ kind: service_kind option;
1616+ handle: string option;
1717+ label: string option;
1818+ range: Sortal_temporal.range option;
1919+ primary: bool;
2020+}
2121+2222+type email_type = Work | Personal | Other
2323+2424+type email = {
2525+ address: string;
2626+ type_: email_type option;
2727+ range: Sortal_temporal.range option;
2828+ note: string option;
2929+}
3030+3131+type organization = {
3232+ name: string;
3333+ title: string option;
3434+ department: string option;
3535+ range: Sortal_temporal.range option;
3636+ email: string option;
3737+ url: string option;
3838+}
3939+4040+type url_entry = {
4141+ url: string;
4242+ label: string option;
4343+ range: Sortal_temporal.range option;
4444+}
4545+4646+type t = {
4747+ version: int;
4848+ kind: contact_kind;
4949+ handle: string;
5050+ names: string list;
5151+ emails: email list;
5252+ organizations: organization list;
5353+ urls: url_entry list;
5454+ services: service list;
5555+ icon: string option;
5656+ thumbnail: string option;
5757+ orcid: string option;
5858+ feeds: Sortal_feed.t list option;
5959+}
6060+6161+(* Helpers *)
6262+let make_email ?type_ ?from ?until ?note address =
6363+ let range = match from, until with
6464+ | None, None -> None
6565+ | _, _ -> Some (Sortal_temporal.make ?from ?until ())
6666+ in
6767+ { address; type_; range; note }
6868+6969+let email_of_string address =
7070+ { address; type_ = Some Personal; range = None; note = None }
7171+7272+let make_org ?title ?department ?from ?until ?email ?url name =
7373+ let range = match from, until with
7474+ | None, None -> None
7575+ | _, _ -> Some (Sortal_temporal.make ?from ?until ())
7676+ in
7777+ { name; title; department; range; email; url }
7878+7979+let make_url ?label ?from ?until url =
8080+ let range = match from, until with
8181+ | None, None -> None
8282+ | _, _ -> Some (Sortal_temporal.make ?from ?until ())
8383+ in
8484+ { url; label; range }
8585+8686+let url_of_string url =
8787+ { url; label = None; range = None }
8888+8989+let make_service ?kind ?handle ?label ?from ?until ?(primary = false) url =
9090+ let range = match from, until with
9191+ | None, None -> None
9292+ | _, _ -> Some (Sortal_temporal.make ?from ?until ())
9393+ in
9494+ { url; kind; handle; label; range; primary }
9595+9696+let service_of_url url =
9797+ { url; kind = None; handle = None; label = None; range = None; primary = false }
9898+9999+let make ~handle ~names ?(kind = Person) ?(emails = []) ?(organizations = [])
100100+ ?(urls = []) ?(services = []) ?icon ?thumbnail ?orcid ?feeds () =
101101+ { version; kind; handle; names; emails; organizations; urls; services;
102102+ icon; thumbnail; orcid; feeds }
103103+104104+(* Accessors *)
105105+let version_of t = t.version
106106+let kind t = t.kind
107107+let handle t = t.handle
108108+let names t = t.names
109109+let name t = List.hd t.names
110110+let primary_name = name
111111+let emails t = t.emails
112112+let organizations t = t.organizations
113113+let urls t = t.urls
114114+let services t = t.services
115115+let icon t = t.icon
116116+let thumbnail t = t.thumbnail
117117+let orcid t = t.orcid
118118+let feeds t = t.feeds
119119+120120+(* Temporal queries *)
121121+let emails_at t ~date =
122122+ Sortal_temporal.at_date ~get:(fun (e : email) -> e.range) ~date t.emails
123123+124124+let email_at t ~date =
125125+ match emails_at t ~date with
126126+ | e :: _ -> Some e.address
127127+ | [] -> None
128128+129129+let current_email t =
130130+ match Sortal_temporal.current ~get:(fun (e : email) -> e.range) t.emails with
131131+ | Some e -> Some e.address
132132+ | None -> None
133133+134134+let organization_at t ~date =
135135+ match Sortal_temporal.at_date ~get:(fun (o : organization) -> o.range) ~date t.organizations with
136136+ | o :: _ -> Some o
137137+ | [] -> None
138138+139139+let current_organization t =
140140+ Sortal_temporal.current ~get:(fun (o : organization) -> o.range) t.organizations
141141+142142+let url_at t ~date =
143143+ match Sortal_temporal.at_date ~get:(fun (u : url_entry) -> u.range) ~date t.urls with
144144+ | u :: _ -> Some u.url
145145+ | [] -> None
146146+147147+let current_url t =
148148+ match Sortal_temporal.current ~get:(fun (u : url_entry) -> u.range) t.urls with
149149+ | Some u -> Some u.url
150150+ | None -> None
151151+152152+let all_email_addresses t =
153153+ List.map (fun e -> e.address) t.emails
154154+155155+(* Service queries *)
156156+let services_of_kind t (kind : service_kind) =
157157+ List.filter (fun (s : service) ->
158158+ match (s.kind : service_kind option) with
159159+ | Some k when k = kind -> true
160160+ | _ -> false
161161+ ) t.services
162162+163163+let services_at t ~date =
164164+ Sortal_temporal.at_date ~get:(fun (s : service) -> s.range) ~date t.services
165165+166166+let current_services t =
167167+ List.filter (fun (s : service) -> Sortal_temporal.is_current s.range) t.services
168168+169169+let primary_service t (kind : service_kind) =
170170+ List.find_opt (fun (s : service) ->
171171+ match (s.kind : service_kind option) with
172172+ | Some k when k = kind && s.primary -> true
173173+ | _ -> false
174174+ ) t.services
175175+176176+let best_url t =
177177+ current_url t
178178+ |> Option.fold ~none:(
179179+ match current_services t with
180180+ | s :: _ -> Some s.url
181181+ | [] -> current_email t |> Option.map (fun e -> "mailto:" ^ e)
182182+ ) ~some:Option.some
183183+184184+(* Modification *)
185185+let add_feed t feed =
186186+ { t with feeds = Some (feed :: Option.value t.feeds ~default:[]) }
187187+188188+let remove_feed t url =
189189+ { t with feeds = Option.map (List.filter (fun f -> Sortal_feed.url f <> url)) t.feeds }
190190+191191+(* Comparison *)
192192+let compare a b = String.compare a.handle b.handle
193193+194194+(* Type conversions *)
195195+let contact_kind_to_string = function
196196+ | Person -> "person"
197197+ | Organization -> "organization"
198198+ | Group -> "group"
199199+ | Role -> "role"
200200+201201+let contact_kind_of_string = function
202202+ | "person" -> Some Person
203203+ | "organization" -> Some Organization
204204+ | "group" -> Some Group
205205+ | "role" -> Some Role
206206+ | _ -> None
207207+208208+let service_kind_to_string = function
209209+ | ActivityPub -> "activitypub"
210210+ | Github -> "github"
211211+ | Git -> "git"
212212+ | Social -> "social"
213213+ | Photo -> "photo"
214214+ | Custom s -> s
215215+216216+let service_kind_of_string s =
217217+ match String.lowercase_ascii s with
218218+ | "activitypub" -> Some ActivityPub
219219+ | "github" -> Some Github
220220+ | "git" -> Some Git
221221+ | "social" -> Some Social
222222+ | "photo" -> Some Photo
223223+ | "" | "custom" -> None
224224+ | _ -> Some (Custom s)
225225+226226+let email_type_to_string = function
227227+ | Work -> "work"
228228+ | Personal -> "personal"
229229+ | Other -> "other"
230230+231231+let email_type_of_string = function
232232+ | "work" -> Some Work
233233+ | "personal" -> Some Personal
234234+ | "other" -> Some Other
235235+ | _ -> None
236236+237237+(* JSON encoding *)
238238+239239+(* Helper: case-insensitive enum decoder *)
240240+let case_insensitive_enum ~kind:kind_name cases =
241241+ let open Jsont in
242242+ let lowercase_cases = List.map (fun (s, v) -> (String.lowercase_ascii s, v)) cases in
243243+ let dec s =
244244+ match List.assoc_opt (String.lowercase_ascii s) lowercase_cases with
245245+ | Some v -> v
246246+ | None -> failwith ("unknown " ^ kind_name ^ ": " ^ s)
247247+ in
248248+ let enc v =
249249+ match List.find_opt (fun (_, v') -> v = v') cases with
250250+ | Some (s, _) -> s
251251+ | None -> failwith ("invalid " ^ kind_name)
252252+ in
253253+ let t = map ~kind:kind_name ~dec ~enc string in
254254+ t
255255+256256+let contact_kind_json =
257257+ case_insensitive_enum ~kind:"ContactKind" [
258258+ "person", Person;
259259+ "organization", Organization;
260260+ "group", Group;
261261+ "role", Role;
262262+ ]
263263+264264+let service_json : service Jsont.t =
265265+ let open Jsont in
266266+ let open Jsont.Object in
267267+ let mem_opt f v ~enc = mem f v ~dec_absent:None ~enc_omit:Option.is_none ~enc in
268268+ (* Convert string option to/from service_kind option *)
269269+ let dec_kind_opt kind_str =
270270+ match kind_str with
271271+ | None -> None
272272+ | Some s -> service_kind_of_string s
273273+ in
274274+ let enc_kind_opt = Option.map service_kind_to_string in
275275+ let make url kind_str handle label range primary : service =
276276+ let kind = dec_kind_opt kind_str in
277277+ { url; kind; handle; label; range; primary }
278278+ in
279279+ map ~kind:"Service" make
280280+ |> mem "url" string ~enc:(fun (s : service) -> s.url)
281281+ |> mem_opt "kind" (some string) ~enc:(fun (s : service) -> enc_kind_opt s.kind)
282282+ |> mem_opt "handle" (some string) ~enc:(fun (s : service) -> s.handle)
283283+ |> mem_opt "label" (some string) ~enc:(fun (s : service) -> s.label)
284284+ |> mem_opt "range" (some Sortal_temporal.json_t) ~enc:(fun (s : service) -> s.range)
285285+ |> mem "primary" bool ~dec_absent:false ~enc:(fun (s : service) -> s.primary)
286286+ |> finish
287287+288288+let email_type_json =
289289+ case_insensitive_enum ~kind:"EmailType" [
290290+ "work", Work;
291291+ "personal", Personal;
292292+ "other", Other;
293293+ ]
294294+295295+let email_json : email Jsont.t =
296296+ let open Jsont in
297297+ let open Jsont.Object in
298298+ let mem_opt f v ~enc = mem f v ~dec_absent:None ~enc_omit:Option.is_none ~enc in
299299+ let make address type_ range note : email = { address; type_; range; note } in
300300+ map ~kind:"Email" make
301301+ |> mem "address" string ~enc:(fun (e : email) -> e.address)
302302+ |> mem_opt "type" (some email_type_json) ~enc:(fun (e : email) -> e.type_)
303303+ |> mem_opt "range" (some Sortal_temporal.json_t) ~enc:(fun (e : email) -> e.range)
304304+ |> mem_opt "note" (some string) ~enc:(fun (e : email) -> e.note)
305305+ |> finish
306306+307307+let organization_json : organization Jsont.t =
308308+ let open Jsont in
309309+ let open Jsont.Object in
310310+ let mem_opt f v ~enc = mem f v ~dec_absent:None ~enc_omit:Option.is_none ~enc in
311311+ let make name title department range email url : organization =
312312+ { name; title; department; range; email; url }
313313+ in
314314+ map ~kind:"Organization" make
315315+ |> mem "name" string ~enc:(fun (o : organization) -> o.name)
316316+ |> mem_opt "title" (some string) ~enc:(fun (o : organization) -> o.title)
317317+ |> mem_opt "department" (some string) ~enc:(fun (o : organization) -> o.department)
318318+ |> mem_opt "range" (some Sortal_temporal.json_t) ~enc:(fun (o : organization) -> o.range)
319319+ |> mem_opt "email" (some string) ~enc:(fun (o : organization) -> o.email)
320320+ |> mem_opt "url" (some string) ~enc:(fun (o : organization) -> o.url)
321321+ |> finish
322322+323323+let url_entry_json : url_entry Jsont.t =
324324+ let open Jsont in
325325+ let open Jsont.Object in
326326+ let mem_opt f v ~enc = mem f v ~dec_absent:None ~enc_omit:Option.is_none ~enc in
327327+ let make url label range : url_entry = { url; label; range } in
328328+ map ~kind:"URL" make
329329+ |> mem "url" string ~enc:(fun (u : url_entry) -> u.url)
330330+ |> mem_opt "label" (some string) ~enc:(fun (u : url_entry) -> u.label)
331331+ |> mem_opt "range" (some Sortal_temporal.json_t) ~enc:(fun (u : url_entry) -> u.range)
332332+ |> finish
333333+334334+let json_t =
335335+ let open Jsont in
336336+ let open Jsont.Object in
337337+ let mem_opt f v ~enc = mem f v ~dec_absent:None ~enc_omit:Option.is_none ~enc in
338338+ let make version kind handle names emails organizations urls services
339339+ icon thumbnail orcid feeds =
340340+ if version <> 1 then
341341+ failwith (Printf.sprintf "Unsupported contact schema version: %d" version);
342342+ { version; kind; handle; names; emails; organizations; urls; services;
343343+ icon; thumbnail; orcid; feeds }
344344+ in
345345+ map ~kind:"Contact" make
346346+ |> mem "version" int ~enc:(fun _ -> 1)
347347+ |> mem "kind" contact_kind_json ~dec_absent:Person ~enc:(fun c -> c.kind)
348348+ |> mem "handle" string ~enc:(fun c -> c.handle)
349349+ |> mem "names" (list string) ~dec_absent:[] ~enc:(fun c -> c.names)
350350+ |> mem "emails" (list email_json) ~dec_absent:[] ~enc:(fun c -> c.emails)
351351+ |> mem "organizations" (list organization_json) ~dec_absent:[] ~enc:(fun c -> c.organizations)
352352+ |> mem "urls" (list url_entry_json) ~dec_absent:[] ~enc:(fun c -> c.urls)
353353+ |> mem "services" (list service_json) ~dec_absent:[] ~enc:(fun c -> c.services)
354354+ |> mem_opt "icon" (some string) ~enc:(fun c -> c.icon)
355355+ |> mem_opt "thumbnail" (some string) ~enc:(fun c -> c.thumbnail)
356356+ |> mem_opt "orcid" (some string) ~enc:(fun c -> c.orcid)
357357+ |> mem_opt "feeds" (some (list Sortal_feed.json_t)) ~enc:(fun c -> c.feeds)
358358+ |> finish
359359+360360+(* Pretty printing *)
361361+let pp ppf t =
362362+ let open Fmt in
363363+ let label = styled (`Fg `Cyan) string in
364364+ let url_style = styled (`Fg `Blue) in
365365+ let date_style = styled (`Fg `Green) in
366366+ let field lbl fmt_v = Option.iter (fun v -> pf ppf "%a: %a@," label lbl fmt_v v) in
367367+368368+ let pp_range ppf = function
369369+ | None -> ()
370370+ | Some { Sortal_temporal.from; until } ->
371371+ match from, until with
372372+ | Some f, Some u -> pf ppf " %a" (date_style string) (Printf.sprintf "[%s to %s]" f u)
373373+ | Some f, None -> pf ppf " %a" (date_style string) (Printf.sprintf "[from %s]" f)
374374+ | None, Some u -> pf ppf " %a" (date_style string) (Printf.sprintf "[until %s]" u)
375375+ | None, None -> ()
376376+ in
377377+378378+ pf ppf "@[<v>";
379379+ pf ppf "%a: %a@," label "Handle" (styled `Bold (fun ppf s -> pf ppf "@%s" s)) t.handle;
380380+381381+ (* Show kind if not a person *)
382382+ (match t.kind with
383383+ | Person -> ()
384384+ | k -> pf ppf "%a: %a@," label "Kind" (styled (`Fg `Magenta) string) (contact_kind_to_string k));
385385+386386+ pf ppf "%a: %a@," label "Name" (styled `Bold string) (name t);
387387+388388+ if List.length (names t) > 1 then
389389+ pf ppf "%a: @[<h>%a@]@," label "Aliases"
390390+ (list ~sep:comma string) (List.tl (names t));
391391+392392+ (* Emails with temporal info *)
393393+ if emails t <> [] then begin
394394+ pf ppf "%a:@," label "Emails";
395395+ List.iter (fun e ->
396396+ pf ppf " %a%s%s%a%a@,"
397397+ (styled (`Fg `Yellow) string) e.address
398398+ (match e.type_ with Some Work -> " (work)" | Some Personal -> " (personal)" | Some Other -> " (other)" | None -> "")
399399+ (match e.note with Some n -> " - " ^ n | None -> "")
400400+ pp_range e.range
401401+ (fun ppf current -> if current then pf ppf " %a" (styled (`Fg `Magenta) string) "[current]" else ())
402402+ (Sortal_temporal.is_current e.range)
403403+ ) (emails t)
404404+ end;
405405+406406+ (* Organizations with temporal info *)
407407+ if organizations t <> [] then begin
408408+ pf ppf "%a:@," label "Organizations";
409409+ List.iter (fun o ->
410410+ pf ppf " %a" (styled `Bold string) o.name;
411411+ Option.iter (fun title -> pf ppf " - %s" title) o.title;
412412+ Option.iter (fun dept -> pf ppf " (%s)" dept) o.department;
413413+ pf ppf "%a" pp_range o.range;
414414+ if Sortal_temporal.is_current o.range then
415415+ pf ppf " %a" (styled (`Fg `Magenta) string) "[current]";
416416+ pf ppf "@,";
417417+ Option.iter (fun email -> pf ppf " Email: %a@," (styled (`Fg `Yellow) string) email) o.email;
418418+ Option.iter (fun url -> pf ppf " URL: %a@," (url_style string) url) o.url;
419419+ ) (organizations t)
420420+ end;
421421+422422+ (* URLs *)
423423+ if urls t <> [] then begin
424424+ pf ppf "%a:@," label "URLs";
425425+ List.iter (fun u ->
426426+ pf ppf " %a" (url_style string) u.url;
427427+ Option.iter (fun lbl -> pf ppf " (%s)" lbl) u.label;
428428+ pf ppf "%a" pp_range u.range;
429429+ if Sortal_temporal.is_current u.range then
430430+ pf ppf " %a" (styled (`Fg `Magenta) string) "[current]";
431431+ pf ppf "@,"
432432+ ) (urls t)
433433+ end;
434434+435435+ (* Services *)
436436+ if services t <> [] then begin
437437+ pf ppf "%a:@," label "Services";
438438+ List.iter (fun (s : service) ->
439439+ pf ppf " %a" (url_style string) s.url;
440440+ Option.iter (fun k -> pf ppf " (%s)" (service_kind_to_string k)) s.kind;
441441+ Option.iter (fun h -> pf ppf " [@%s]" h) s.handle;
442442+ Option.iter (fun lbl -> pf ppf " - %s" lbl) s.label;
443443+ pf ppf "%a" pp_range s.range;
444444+ if s.primary then pf ppf " %a" (styled (`Fg `Yellow) string) "[primary]";
445445+ if Sortal_temporal.is_current s.range then
446446+ pf ppf " %a" (styled (`Fg `Magenta) string) "[current]";
447447+ pf ppf "@,"
448448+ ) (services t)
449449+ end;
450450+451451+ field "ORCID" (url_style (fun ppf o -> pf ppf "https://orcid.org/%s" o)) t.orcid;
452452+453453+ field "Icon" (url_style string) t.icon;
454454+ field "Thumbnail" (styled (`Fg `White) string) t.thumbnail;
455455+456456+ Option.iter (function
457457+ | [] -> ()
458458+ | feeds ->
459459+ pf ppf "%a:@," label "Feeds";
460460+ List.iter (fun feed -> pf ppf " - %a@," Sortal_feed.pp feed) feeds
461461+ ) t.feeds;
462462+463463+ pf ppf "@]"
+272
lib/sortal_contact_v1.mli
···11+(** Contact schema V1 with temporal support.
22+33+ This module defines the V1 contact schema with support for time-bounded
44+ information such as emails and organizations that are valid only during
55+ specific periods.
66+77+ {b Schema Version Policy:}
88+ - New optional fields can be added without bumping the version
99+ - The version must be bumped only if the {i meaning} of an existing
1010+ field changes
1111+ - This allows forward compatibility: older readers can ignore new fields *)
1212+1313+(** {1 Schema Version} *)
1414+1515+val version : int
1616+(** The schema version number for V1. Currently [1]. *)
1717+1818+(** {1 Types} *)
1919+2020+(** Contact kind - what type of entity this represents. *)
2121+type contact_kind =
2222+ | Person (** Individual person *)
2323+ | Organization (** Company, lab, department *)
2424+ | Group (** Research group, project team *)
2525+ | Role (** Generic role email like info@, admin@ *)
2626+2727+(** Service kind - categorization of online presence. *)
2828+type service_kind =
2929+ | ActivityPub (** Mastodon, Pixelfed, PeerTube, etc *)
3030+ | Github (** GitHub *)
3131+ | Git (** GitLab, Gitea, Codeberg, etc *)
3232+ | Social (** Twitter/X, LinkedIn, etc *)
3333+ | Photo (** Immich, Flickr, Instagram, etc *)
3434+ | Custom of string (** Other service types *)
3535+3636+(** An online service/identity. *)
3737+type service = {
3838+ url: string; (** Full URL (primary identifier) *)
3939+ kind: service_kind option; (** Optional service categorization *)
4040+ handle: string option; (** Optional short handle/username *)
4141+ label: string option; (** Human description: "Cambridge GitLab", "Work account" *)
4242+ range: Sortal_temporal.range option; (** Temporal validity *)
4343+ primary: bool; (** Is this the primary/preferred service of its kind? *)
4444+}
4545+4646+type email_type = Work | Personal | Other
4747+4848+type email = {
4949+ address: string;
5050+ type_: email_type option;
5151+ range: Sortal_temporal.range option; (** Validity period *)
5252+ note: string option; (** Context note, e.g., "NetApp position" *)
5353+}
5454+5555+type organization = {
5656+ name: string;
5757+ title: string option;
5858+ department: string option;
5959+ range: Sortal_temporal.range option; (** Employment period *)
6060+ email: string option; (** Work email during this period *)
6161+ url: string option; (** Work homepage during this period *)
6262+}
6363+6464+type url_entry = {
6565+ url: string;
6666+ label: string option; (** Human-readable label *)
6767+ range: Sortal_temporal.range option; (** Validity period *)
6868+}
6969+7070+type t = {
7171+ version: int; (** Schema version (always 1 for V1) *)
7272+ kind: contact_kind; (** Type of entity (Person, Organization, etc) *)
7373+ handle: string; (** Unique identifier *)
7474+ names: string list; (** Names, first is primary *)
7575+7676+ (* Temporal fields *)
7777+ emails: email list; (** Email addresses with temporal validity *)
7878+ organizations: organization list; (** Employment/affiliation history *)
7979+ urls: url_entry list; (** URLs with optional temporal validity *)
8080+ services: service list; (** Online services/identities *)
8181+8282+ (* Simple fields - rarely change over time *)
8383+ icon: string option; (** Avatar URL *)
8484+ thumbnail: string option; (** Local thumbnail path *)
8585+ orcid: string option; (** ORCID identifier *)
8686+8787+ (* Other *)
8888+ feeds: Sortal_feed.t list option; (** Feed subscriptions *)
8989+}
9090+9191+(** {1 Construction} *)
9292+9393+(** [make ~handle ~names ?kind ?emails ?organizations ?urls ?services
9494+ ?icon ?thumbnail ?orcid ?feeds ()]
9595+ creates a new V1 contact.
9696+9797+ The [version] field is automatically set to [1].
9898+ The [kind] defaults to [Person] if not specified. *)
9999+val make :
100100+ handle:string ->
101101+ names:string list ->
102102+ ?kind:contact_kind ->
103103+ ?emails:email list ->
104104+ ?organizations:organization list ->
105105+ ?urls:url_entry list ->
106106+ ?services:service list ->
107107+ ?icon:string ->
108108+ ?thumbnail:string ->
109109+ ?orcid:string ->
110110+ ?feeds:Sortal_feed.t list ->
111111+ unit ->
112112+ t
113113+114114+(** {1 Email Helpers} *)
115115+116116+(** [make_email ?type_ ?from ?until ?note address] creates an email entry.
117117+118118+ @param type_ Email type (Work, Personal, Other)
119119+ @param from Start date of validity
120120+ @param until End date of validity (exclusive)
121121+ @param note Contextual note *)
122122+val make_email :
123123+ ?type_:email_type ->
124124+ ?from:Sortal_temporal.date ->
125125+ ?until:Sortal_temporal.date ->
126126+ ?note:string ->
127127+ string ->
128128+ email
129129+130130+(** [email_of_string s] creates a simple always-valid personal email. *)
131131+val email_of_string : string -> email
132132+133133+(** {1 Organization Helpers} *)
134134+135135+(** [make_org ?title ?department ?from ?until ?email ?url name]
136136+ creates an organization entry. *)
137137+val make_org :
138138+ ?title:string ->
139139+ ?department:string ->
140140+ ?from:Sortal_temporal.date ->
141141+ ?until:Sortal_temporal.date ->
142142+ ?email:string ->
143143+ ?url:string ->
144144+ string ->
145145+ organization
146146+147147+(** {1 URL Helpers} *)
148148+149149+(** [make_url ?label ?from ?until url] creates a URL entry. *)
150150+val make_url :
151151+ ?label:string ->
152152+ ?from:Sortal_temporal.date ->
153153+ ?until:Sortal_temporal.date ->
154154+ string ->
155155+ url_entry
156156+157157+(** [url_of_string s] creates a simple always-valid URL. *)
158158+val url_of_string : string -> url_entry
159159+160160+(** {1 Service Helpers} *)
161161+162162+(** [make_service ?kind ?handle ?label ?from ?until ?primary url]
163163+ creates a service entry.
164164+165165+ @param kind Optional service categorization
166166+ @param handle Optional short handle/username
167167+ @param label Optional description (e.g., "Work account", "Cambridge GitLab")
168168+ @param from Start date of validity
169169+ @param until End date of validity (exclusive)
170170+ @param primary Whether this is the primary service of its kind
171171+ @param url Full URL to the service (required) *)
172172+val make_service :
173173+ ?kind:service_kind ->
174174+ ?handle:string ->
175175+ ?label:string ->
176176+ ?from:Sortal_temporal.date ->
177177+ ?until:Sortal_temporal.date ->
178178+ ?primary:bool ->
179179+ string ->
180180+ service
181181+182182+(** [service_of_url url] creates a simple always-valid service from just a URL. *)
183183+val service_of_url : string -> service
184184+185185+(** {1 Accessors} *)
186186+187187+val version_of : t -> int
188188+val kind : t -> contact_kind
189189+val handle : t -> string
190190+val names : t -> string list
191191+val name : t -> string
192192+val primary_name : t -> string
193193+val emails : t -> email list
194194+val organizations : t -> organization list
195195+val urls : t -> url_entry list
196196+val services : t -> service list
197197+val icon : t -> string option
198198+val thumbnail : t -> string option
199199+val orcid : t -> string option
200200+val feeds : t -> Sortal_feed.t list option
201201+202202+(** {1 Temporal Queries} *)
203203+204204+(** [email_at t ~date] returns the primary email valid at [date]. *)
205205+val email_at : t -> date:Sortal_temporal.date -> string option
206206+207207+(** [emails_at t ~date] returns all emails valid at [date]. *)
208208+val emails_at : t -> date:Sortal_temporal.date -> email list
209209+210210+(** [current_email t] returns the current primary email. *)
211211+val current_email : t -> string option
212212+213213+(** [organization_at t ~date] returns the organization at [date]. *)
214214+val organization_at : t -> date:Sortal_temporal.date -> organization option
215215+216216+(** [current_organization t] returns the current organization. *)
217217+val current_organization : t -> organization option
218218+219219+(** [url_at t ~date] returns the primary URL valid at [date]. *)
220220+val url_at : t -> date:Sortal_temporal.date -> string option
221221+222222+(** [current_url t] returns the current primary URL. *)
223223+val current_url : t -> string option
224224+225225+(** [all_email_addresses t] returns all email addresses (any period). *)
226226+val all_email_addresses : t -> string list
227227+228228+(** [best_url t] returns the best available URL (current URL or service fallback). *)
229229+val best_url : t -> string option
230230+231231+(** {1 Service Queries} *)
232232+233233+(** [services_of_kind t kind] returns all services matching the given kind. *)
234234+val services_of_kind : t -> service_kind -> service list
235235+236236+(** [services_at t ~date] returns all services valid at [date]. *)
237237+val services_at : t -> date:Sortal_temporal.date -> service list
238238+239239+(** [current_services t] returns all currently valid services. *)
240240+val current_services : t -> service list
241241+242242+(** [primary_service t kind] returns the primary service of the given kind. *)
243243+val primary_service : t -> service_kind -> service option
244244+245245+(** {1 Modification} *)
246246+247247+val add_feed : t -> Sortal_feed.t -> t
248248+val remove_feed : t -> string -> t
249249+250250+(** {1 Comparison and Display} *)
251251+252252+val compare : t -> t -> int
253253+val pp : Format.formatter -> t -> unit
254254+255255+(** {1 JSON Encoding} *)
256256+257257+(** [json_t] is the jsont encoder/decoder for V1 contacts.
258258+259259+ The schema includes a [version] field that is always encoded and
260260+ must equal [1] when decoded. *)
261261+val json_t : t Jsont.t
262262+263263+(** {1 Type Utilities} *)
264264+265265+val contact_kind_to_string : contact_kind -> string
266266+val contact_kind_of_string : string -> contact_kind option
267267+268268+val service_kind_to_string : service_kind -> string
269269+val service_kind_of_string : string -> service_kind option
270270+271271+val email_type_to_string : email_type -> string
272272+val email_type_of_string : string -> email_type option
+2-1
lib/sortal_feed.ml
···2323 | Rss -> "rss"
2424 | Json -> "json"
25252626-let feed_type_of_string = function
2626+let feed_type_of_string s =
2727+ match String.lowercase_ascii s with
2728 | "atom" -> Some Atom
2829 | "rss" -> Some Rss
2930 | "json" -> Some Json
+226
lib/sortal_git_store.ml
···11+type t = {
22+ store : Sortal_store.t;
33+ env : Eio_unix.Stdenv.base;
44+}
55+66+let create store env = { store; env }
77+88+let store t = t.store
99+1010+(* Helper to check if a string contains a substring *)
1111+let contains_substring ~needle haystack =
1212+ try
1313+ let _ = Str.search_forward (Str.regexp_string needle) haystack 0 in
1414+ true
1515+ with Not_found -> false
1616+1717+(* Helper to get the data directory path as a native string *)
1818+let data_dir_path t =
1919+ (* We need to extract the data directory from the store somehow.
2020+ For now, we'll use the XDG environment to locate it. *)
2121+ let xdg = Xdge.create t.env#fs "sortal" in
2222+ let data_path = Xdge.data_dir xdg in
2323+ Eio.Path.native_exn data_path
2424+2525+(* Execute a git command in the data directory *)
2626+let run_git t args =
2727+ let data_dir = data_dir_path t in
2828+ Eio.Switch.run @@ fun sw ->
2929+ try
3030+ let mgr = t.env#process_mgr in
3131+ let cmd = ["git"; "-C"; data_dir] @ args in
3232+ let proc = Eio.Process.spawn ~sw mgr cmd in
3333+ match Eio.Process.await proc with
3434+ | `Exited 0 -> Ok ()
3535+ | `Exited n -> Error (Printf.sprintf "git %s exited with code %d" (String.concat " " args) n)
3636+ | `Signaled n -> Error (Printf.sprintf "git killed by signal %d" n)
3737+ with
3838+ | exn ->
3939+ let msg = Printexc.to_string exn in
4040+ if contains_substring ~needle:"not found" msg ||
4141+ contains_substring ~needle:"No such file" msg then
4242+ Error "git executable not found - please install git"
4343+ else
4444+ Error (Printf.sprintf "git command failed: %s" msg)
4545+4646+let is_initialized t =
4747+ let data_dir = data_dir_path t in
4848+ let git_dir = Filename.concat data_dir ".git" in
4949+ Sys.file_exists git_dir && Sys.is_directory git_dir
5050+5151+let init t =
5252+ if is_initialized t then
5353+ Ok ()
5454+ else begin
5555+ match run_git t ["init"] with
5656+ | Error _ as e -> e
5757+ | Ok () ->
5858+ (* Create initial commit *)
5959+ match run_git t ["add"; "."] with
6060+ | Error _ as e -> e
6161+ | Ok () ->
6262+ let msg = "Initialize sortal contact database" in
6363+ run_git t ["commit"; "--allow-empty"; "-m"; msg]
6464+ end
6565+6666+(* Helper to commit a file with a message *)
6767+let commit_file t filename msg =
6868+ match run_git t ["add"; filename] with
6969+ | Error _ as e -> e
7070+ | Ok () ->
7171+ run_git t ["commit"; "-m"; msg]
7272+7373+(* Helper to commit a deletion *)
7474+let commit_deletion t filename msg =
7575+ match run_git t ["rm"; filename] with
7676+ | Error _ as e -> e
7777+ | Ok () ->
7878+ run_git t ["commit"; "-m"; msg]
7979+8080+let save t contact =
8181+ let handle = Sortal_contact.handle contact in
8282+ let name = Sortal_contact.name contact in
8383+ let filename = handle ^ ".yaml" in
8484+8585+ (* Check if contact already exists *)
8686+ let is_new = match Sortal_store.lookup t.store handle with
8787+ | None -> true
8888+ | Some _ -> false
8989+ in
9090+9191+ (* Save to store *)
9292+ Sortal_store.save t.store contact;
9393+9494+ (* Commit to git *)
9595+ if not (is_initialized t) then
9696+ Ok ()
9797+ else
9898+ let msg = if is_new then
9999+ Printf.sprintf "Add contact @%s (%s)" handle name
100100+ else
101101+ Printf.sprintf "Update contact @%s (%s)" handle name
102102+ in
103103+ commit_file t filename msg
104104+105105+let delete t handle =
106106+ match Sortal_store.lookup t.store handle with
107107+ | None -> Error (Printf.sprintf "Contact not found: %s" handle)
108108+ | Some contact ->
109109+ let name = Sortal_contact.name contact in
110110+ let filename = handle ^ ".yaml" in
111111+112112+ (* Delete from store *)
113113+ Sortal_store.delete t.store handle;
114114+115115+ (* Commit deletion to git *)
116116+ if not (is_initialized t) then
117117+ Ok ()
118118+ else
119119+ let msg = Printf.sprintf "Delete contact @%s (%s)" handle name in
120120+ commit_deletion t filename msg
121121+122122+let update_contact t handle f ~msg =
123123+ match Sortal_store.update_contact t.store handle f with
124124+ | Error _ as e -> e
125125+ | Ok () ->
126126+ if not (is_initialized t) then
127127+ Ok ()
128128+ else
129129+ let filename = handle ^ ".yaml" in
130130+ commit_file t filename msg
131131+132132+let add_email t handle (email : Sortal_contact_v1.email) =
133133+ let msg = Printf.sprintf "Update @%s: add email %s"
134134+ handle email.address in
135135+ match Sortal_store.add_email t.store handle email with
136136+ | Error _ as e -> e
137137+ | Ok () ->
138138+ if not (is_initialized t) then
139139+ Ok ()
140140+ else
141141+ let filename = handle ^ ".yaml" in
142142+ commit_file t filename msg
143143+144144+let remove_email t handle address =
145145+ let msg = Printf.sprintf "Update @%s: remove email %s" handle address in
146146+ match Sortal_store.remove_email t.store handle address with
147147+ | Error _ as e -> e
148148+ | Ok () ->
149149+ if not (is_initialized t) then
150150+ Ok ()
151151+ else
152152+ let filename = handle ^ ".yaml" in
153153+ commit_file t filename msg
154154+155155+let add_service t handle (service : Sortal_contact_v1.service) =
156156+ let kind_str = match service.kind with
157157+ | Some k -> Sortal_contact.service_kind_to_string k
158158+ | None -> "unknown"
159159+ in
160160+ let msg = Printf.sprintf "Update @%s: add service %s (%s)"
161161+ handle kind_str service.url in
162162+ match Sortal_store.add_service t.store handle service with
163163+ | Error _ as e -> e
164164+ | Ok () ->
165165+ if not (is_initialized t) then
166166+ Ok ()
167167+ else
168168+ let filename = handle ^ ".yaml" in
169169+ commit_file t filename msg
170170+171171+let remove_service t handle url =
172172+ let msg = Printf.sprintf "Update @%s: remove service %s" handle url in
173173+ match Sortal_store.remove_service t.store handle url with
174174+ | Error _ as e -> e
175175+ | Ok () ->
176176+ if not (is_initialized t) then
177177+ Ok ()
178178+ else
179179+ let filename = handle ^ ".yaml" in
180180+ commit_file t filename msg
181181+182182+let add_organization t handle (org : Sortal_contact_v1.organization) =
183183+ let msg = Printf.sprintf "Update @%s: add organization %s"
184184+ handle org.name in
185185+ match Sortal_store.add_organization t.store handle org with
186186+ | Error _ as e -> e
187187+ | Ok () ->
188188+ if not (is_initialized t) then
189189+ Ok ()
190190+ else
191191+ let filename = handle ^ ".yaml" in
192192+ commit_file t filename msg
193193+194194+let remove_organization t handle name =
195195+ let msg = Printf.sprintf "Update @%s: remove organization %s" handle name in
196196+ match Sortal_store.remove_organization t.store handle name with
197197+ | Error _ as e -> e
198198+ | Ok () ->
199199+ if not (is_initialized t) then
200200+ Ok ()
201201+ else
202202+ let filename = handle ^ ".yaml" in
203203+ commit_file t filename msg
204204+205205+let add_url t handle (url_entry : Sortal_contact_v1.url_entry) =
206206+ let msg = Printf.sprintf "Update @%s: add URL %s"
207207+ handle url_entry.url in
208208+ match Sortal_store.add_url t.store handle url_entry with
209209+ | Error _ as e -> e
210210+ | Ok () ->
211211+ if not (is_initialized t) then
212212+ Ok ()
213213+ else
214214+ let filename = handle ^ ".yaml" in
215215+ commit_file t filename msg
216216+217217+let remove_url t handle url =
218218+ let msg = Printf.sprintf "Update @%s: remove URL %s" handle url in
219219+ match Sortal_store.remove_url t.store handle url with
220220+ | Error _ as e -> e
221221+ | Ok () ->
222222+ if not (is_initialized t) then
223223+ Ok ()
224224+ else
225225+ let filename = handle ^ ".yaml" in
226226+ commit_file t filename msg
+109
lib/sortal_git_store.mli
···11+(** Git-backed contact store with automatic version control.
22+33+ This module wraps {!Sortal_store} to provide automatic git versioning
44+ of all contact modifications. Each change (add, update, delete) is
55+ automatically committed to a git repository with descriptive commit
66+ messages. *)
77+88+type t
99+(** A git-backed contact store. *)
1010+1111+(** {1 Creation and Initialization} *)
1212+1313+val create : Sortal_store.t -> Eio_unix.Stdenv.base -> t
1414+(** [create store env] creates a git-backed store wrapping [store].
1515+1616+ @param store The underlying contact store
1717+ @param env The Eio environment for spawning git processes *)
1818+1919+val init : t -> (unit, string) result
2020+(** [init t] initializes a git repository in the data directory.
2121+2222+ Creates a new git repository with an initial commit if one doesn't exist.
2323+ Safe to call multiple times - returns [Ok ()] if already initialized.
2424+2525+ @return [Ok ()] if initialized successfully or already initialized,
2626+ [Error msg] if git initialization fails *)
2727+2828+val is_initialized : t -> bool
2929+(** [is_initialized t] checks if the data directory is a git repository.
3030+3131+ @return [true] if a .git directory exists, [false] otherwise *)
3232+3333+(** {1 Contact Operations} *)
3434+3535+val save : t -> Sortal_contact.t -> (unit, string) result
3636+(** [save t contact] saves a contact and commits the change to git.
3737+3838+ If the contact is new, commits with message "Add contact @handle (Name)".
3939+ If updating an existing contact, commits with "Update contact @handle (Name)".
4040+4141+ @param contact The contact to save *)
4242+4343+val delete : t -> string -> (unit, string) result
4444+(** [delete t handle] deletes a contact and commits the removal to git.
4545+4646+ Commits with message "Delete contact @handle (Name)".
4747+4848+ @param handle The contact handle to delete
4949+ @return [Error msg] if contact not found *)
5050+5151+(** {1 Contact Modification} *)
5252+5353+val add_email : t -> string -> Sortal_contact.email -> (unit, string) result
5454+(** [add_email t handle email] adds an email to a contact and commits.
5555+5656+ Commits with message "Update @handle: add email address@example.com". *)
5757+5858+val remove_email : t -> string -> string -> (unit, string) result
5959+(** [remove_email t handle address] removes an email and commits.
6060+6161+ Commits with message "Update @handle: remove email address@example.com". *)
6262+6363+val add_service : t -> string -> Sortal_contact.service -> (unit, string) result
6464+(** [add_service t handle service] adds a service to a contact and commits.
6565+6666+ Commits with message "Update @handle: add service Kind (url)". *)
6767+6868+val remove_service : t -> string -> string -> (unit, string) result
6969+(** [remove_service t handle url] removes a service and commits.
7070+7171+ Commits with message "Update @handle: remove service url". *)
7272+7373+val add_organization : t -> string -> Sortal_contact.organization -> (unit, string) result
7474+(** [add_organization t handle org] adds an organization and commits.
7575+7676+ Commits with message "Update @handle: add organization Org Name". *)
7777+7878+val remove_organization : t -> string -> string -> (unit, string) result
7979+(** [remove_organization t handle name] removes an organization and commits.
8080+8181+ Commits with message "Update @handle: remove organization Org Name". *)
8282+8383+val add_url : t -> string -> Sortal_contact.url_entry -> (unit, string) result
8484+(** [add_url t handle url_entry] adds a URL and commits.
8585+8686+ Commits with message "Update @handle: add URL url". *)
8787+8888+val remove_url : t -> string -> string -> (unit, string) result
8989+(** [remove_url t handle url] removes a URL and commits.
9090+9191+ Commits with message "Update @handle: remove URL url". *)
9292+9393+(** {1 Low-level Operations} *)
9494+9595+val update_contact : t -> string -> (Sortal_contact.t -> Sortal_contact.t) ->
9696+ msg:string -> (unit, string) result
9797+(** [update_contact t handle f ~msg] updates a contact and commits with custom message.
9898+9999+ This is a low-level function that applies transformation [f] to the contact
100100+ and commits with the provided commit message.
101101+102102+ @param handle The contact handle
103103+ @param f Function to transform the contact
104104+ @param msg The git commit message *)
105105+106106+val store : t -> Sortal_store.t
107107+(** [store t] returns the underlying contact store.
108108+109109+ Use this when you need direct store access without git commits. *)
+236-3
lib/sortal_store.ml
···2828 try
2929 let yaml_str = Eio.Path.load path in
3030 let reader = Bytesrw.Bytes.Reader.of_string yaml_str in
3131- Yamlt.decode Sortal_contact.json_t reader
3232- |> Result.to_option
3333- with _ -> None
3131+ match Yamlt.decode Sortal_contact.json_t reader with
3232+ | Ok contact -> Some contact
3333+ | Error msg ->
3434+ Logs.warn (fun m -> m "Failed to decode contact %s: %s" handle msg);
3535+ None
3636+ with exn ->
3737+ Logs.warn (fun m -> m "Failed to load contact %s: %s" handle (Printexc.to_string exn));
3838+ None
34393540let delete t handle =
3641 let path = contact_file t handle in
···3944 with
4045 | _ -> ()
41464747+(* Contact modification helpers *)
4848+let update_contact t handle f =
4949+ match lookup t handle with
5050+ | None -> Error (Printf.sprintf "Contact not found: %s" handle)
5151+ | Some contact ->
5252+ let updated = f contact in
5353+ save t updated;
5454+ Ok ()
5555+5656+let add_email t handle (email : Sortal_contact_v1.email) =
5757+ match lookup t handle with
5858+ | None -> Error (Printf.sprintf "Contact not found: %s" handle)
5959+ | Some contact ->
6060+ let emails = Sortal_contact.emails contact in
6161+ (* Check for duplicate email address *)
6262+ if List.exists (fun (e : Sortal_contact_v1.email) -> e.address = email.address) emails then
6363+ Error (Printf.sprintf "Email %s already exists for contact @%s" email.address handle)
6464+ else
6565+ update_contact t handle (fun contact ->
6666+ let emails = Sortal_contact.emails contact in
6767+ Sortal_contact.make
6868+ ~handle:(Sortal_contact.handle contact)
6969+ ~names:(Sortal_contact.names contact)
7070+ ~kind:(Sortal_contact.kind contact)
7171+ ~emails:(emails @ [email])
7272+ ~organizations:(Sortal_contact.organizations contact)
7373+ ~urls:(Sortal_contact.urls contact)
7474+ ~services:(Sortal_contact.services contact)
7575+ ?icon:(Sortal_contact.icon contact)
7676+ ?thumbnail:(Sortal_contact.thumbnail contact)
7777+ ?orcid:(Sortal_contact.orcid contact)
7878+ ?feeds:(Sortal_contact.feeds contact)
7979+ ()
8080+ )
8181+8282+let remove_email t handle address =
8383+ update_contact t handle (fun contact ->
8484+ let emails = Sortal_contact.emails contact
8585+ |> List.filter (fun (e : Sortal_contact.email) -> e.address <> address) in
8686+ Sortal_contact.make
8787+ ~handle:(Sortal_contact.handle contact)
8888+ ~names:(Sortal_contact.names contact)
8989+ ~kind:(Sortal_contact.kind contact)
9090+ ~emails
9191+ ~organizations:(Sortal_contact.organizations contact)
9292+ ~urls:(Sortal_contact.urls contact)
9393+ ~services:(Sortal_contact.services contact)
9494+ ?icon:(Sortal_contact.icon contact)
9595+ ?thumbnail:(Sortal_contact.thumbnail contact)
9696+ ?orcid:(Sortal_contact.orcid contact)
9797+ ?feeds:(Sortal_contact.feeds contact)
9898+ ()
9999+ )
100100+101101+let add_service t handle (service : Sortal_contact_v1.service) =
102102+ match lookup t handle with
103103+ | None -> Error (Printf.sprintf "Contact not found: %s" handle)
104104+ | Some contact ->
105105+ let services = Sortal_contact.services contact in
106106+ (* Check for duplicate service URL *)
107107+ if List.exists (fun (s : Sortal_contact_v1.service) -> s.url = service.url) services then
108108+ Error (Printf.sprintf "Service URL %s already exists for contact @%s" service.url handle)
109109+ else
110110+ update_contact t handle (fun contact ->
111111+ let services = Sortal_contact.services contact in
112112+ Sortal_contact.make
113113+ ~handle:(Sortal_contact.handle contact)
114114+ ~names:(Sortal_contact.names contact)
115115+ ~kind:(Sortal_contact.kind contact)
116116+ ~emails:(Sortal_contact.emails contact)
117117+ ~organizations:(Sortal_contact.organizations contact)
118118+ ~urls:(Sortal_contact.urls contact)
119119+ ~services:(services @ [service])
120120+ ?icon:(Sortal_contact.icon contact)
121121+ ?thumbnail:(Sortal_contact.thumbnail contact)
122122+ ?orcid:(Sortal_contact.orcid contact)
123123+ ?feeds:(Sortal_contact.feeds contact)
124124+ ()
125125+ )
126126+127127+let remove_service t handle url =
128128+ update_contact t handle (fun contact ->
129129+ let services = Sortal_contact.services contact
130130+ |> List.filter (fun (s : Sortal_contact.service) -> s.url <> url) in
131131+ Sortal_contact.make
132132+ ~handle:(Sortal_contact.handle contact)
133133+ ~names:(Sortal_contact.names contact)
134134+ ~kind:(Sortal_contact.kind contact)
135135+ ~emails:(Sortal_contact.emails contact)
136136+ ~organizations:(Sortal_contact.organizations contact)
137137+ ~urls:(Sortal_contact.urls contact)
138138+ ~services
139139+ ?icon:(Sortal_contact.icon contact)
140140+ ?thumbnail:(Sortal_contact.thumbnail contact)
141141+ ?orcid:(Sortal_contact.orcid contact)
142142+ ?feeds:(Sortal_contact.feeds contact)
143143+ ()
144144+ )
145145+146146+let add_organization t handle (org : Sortal_contact_v1.organization) =
147147+ match lookup t handle with
148148+ | None -> Error (Printf.sprintf "Contact not found: %s" handle)
149149+ | Some contact ->
150150+ let orgs = Sortal_contact.organizations contact in
151151+ (* Check for exact duplicate organization (same name, title, and department) *)
152152+ let is_duplicate = List.exists (fun (o : Sortal_contact_v1.organization) ->
153153+ o.name = org.name &&
154154+ o.title = org.title &&
155155+ o.department = org.department
156156+ ) orgs in
157157+ if is_duplicate then
158158+ Error (Printf.sprintf "Organization %s with the same title/department already exists for contact @%s" org.name handle)
159159+ else
160160+ update_contact t handle (fun contact ->
161161+ let orgs = Sortal_contact.organizations contact in
162162+ Sortal_contact.make
163163+ ~handle:(Sortal_contact.handle contact)
164164+ ~names:(Sortal_contact.names contact)
165165+ ~kind:(Sortal_contact.kind contact)
166166+ ~emails:(Sortal_contact.emails contact)
167167+ ~organizations:(orgs @ [org])
168168+ ~urls:(Sortal_contact.urls contact)
169169+ ~services:(Sortal_contact.services contact)
170170+ ?icon:(Sortal_contact.icon contact)
171171+ ?thumbnail:(Sortal_contact.thumbnail contact)
172172+ ?orcid:(Sortal_contact.orcid contact)
173173+ ?feeds:(Sortal_contact.feeds contact)
174174+ ()
175175+ )
176176+177177+let remove_organization t handle name =
178178+ update_contact t handle (fun contact ->
179179+ let orgs = Sortal_contact.organizations contact
180180+ |> List.filter (fun (o : Sortal_contact.organization) -> o.name <> name) in
181181+ Sortal_contact.make
182182+ ~handle:(Sortal_contact.handle contact)
183183+ ~names:(Sortal_contact.names contact)
184184+ ~kind:(Sortal_contact.kind contact)
185185+ ~emails:(Sortal_contact.emails contact)
186186+ ~organizations:orgs
187187+ ~urls:(Sortal_contact.urls contact)
188188+ ~services:(Sortal_contact.services contact)
189189+ ?icon:(Sortal_contact.icon contact)
190190+ ?thumbnail:(Sortal_contact.thumbnail contact)
191191+ ?orcid:(Sortal_contact.orcid contact)
192192+ ?feeds:(Sortal_contact.feeds contact)
193193+ ()
194194+ )
195195+196196+let add_url t handle (url_entry : Sortal_contact_v1.url_entry) =
197197+ match lookup t handle with
198198+ | None -> Error (Printf.sprintf "Contact not found: %s" handle)
199199+ | Some contact ->
200200+ let urls = Sortal_contact.urls contact in
201201+ (* Check for duplicate URL *)
202202+ if List.exists (fun (u : Sortal_contact_v1.url_entry) -> u.url = url_entry.url) urls then
203203+ Error (Printf.sprintf "URL %s already exists for contact @%s" url_entry.url handle)
204204+ else
205205+ update_contact t handle (fun contact ->
206206+ let urls = Sortal_contact.urls contact in
207207+ Sortal_contact.make
208208+ ~handle:(Sortal_contact.handle contact)
209209+ ~names:(Sortal_contact.names contact)
210210+ ~kind:(Sortal_contact.kind contact)
211211+ ~emails:(Sortal_contact.emails contact)
212212+ ~organizations:(Sortal_contact.organizations contact)
213213+ ~urls:(urls @ [url_entry])
214214+ ~services:(Sortal_contact.services contact)
215215+ ?icon:(Sortal_contact.icon contact)
216216+ ?thumbnail:(Sortal_contact.thumbnail contact)
217217+ ?orcid:(Sortal_contact.orcid contact)
218218+ ?feeds:(Sortal_contact.feeds contact)
219219+ ()
220220+ )
221221+222222+let remove_url t handle url =
223223+ update_contact t handle (fun contact ->
224224+ let urls = Sortal_contact.urls contact
225225+ |> List.filter (fun (u : Sortal_contact.url_entry) -> u.url <> url) in
226226+ Sortal_contact.make
227227+ ~handle:(Sortal_contact.handle contact)
228228+ ~names:(Sortal_contact.names contact)
229229+ ~kind:(Sortal_contact.kind contact)
230230+ ~emails:(Sortal_contact.emails contact)
231231+ ~organizations:(Sortal_contact.organizations contact)
232232+ ~urls
233233+ ~services:(Sortal_contact.services contact)
234234+ ?icon:(Sortal_contact.icon contact)
235235+ ?thumbnail:(Sortal_contact.thumbnail contact)
236236+ ?orcid:(Sortal_contact.orcid contact)
237237+ ?feeds:(Sortal_contact.feeds contact)
238238+ ()
239239+ )
240240+42241let list t =
43242 try
44243 let entries = Eio.Path.read_dir t.data_dir in
···121320 ) (Sortal_contact.names c)
122321 ) all in
123322 List.sort Sortal_contact.compare matches
323323+324324+let find_by_email_at t ~email ~date =
325325+ let all = list t in
326326+ List.find_opt (fun c ->
327327+ let emails_at_date = Sortal_contact.emails_at c ~date in
328328+ List.exists (fun e -> e.Sortal_contact_v1.address = email) emails_at_date
329329+ ) all
330330+331331+let find_by_org t ~org ?from ?until () =
332332+ let org_lower = String.lowercase_ascii org in
333333+ let all = list t in
334334+ let matches = List.filter (fun c ->
335335+ let orgs : Sortal_contact_v1.organization list = Sortal_contact.organizations c in
336336+ let filtered_orgs = match from, until with
337337+ | None, None -> orgs
338338+ | _, _ -> Sortal_temporal.filter ~get:(fun (o : Sortal_contact_v1.organization) -> o.range)
339339+ ~from ~until orgs
340340+ in
341341+ List.exists (fun (o : Sortal_contact_v1.organization) ->
342342+ contains_substring ~needle:org_lower
343343+ (String.lowercase_ascii o.name)
344344+ ) filtered_orgs
345345+ ) all in
346346+ List.sort Sortal_contact.compare matches
347347+348348+let list_at t ~date =
349349+ let all = list t in
350350+ List.filter (fun c ->
351351+ (* Contact is active if it has any email, org, or URL valid at date *)
352352+ let has_email = Sortal_contact.emails_at c ~date <> [] in
353353+ let has_org = Sortal_contact.organization_at c ~date <> None in
354354+ let has_url = Sortal_contact.url_at c ~date <> None in
355355+ has_email || has_org || has_url
356356+ ) all
124357125358let pp ppf t =
126359 let all = list t in
+126-7
lib/sortal_store.mli
···11(** Contact store with XDG-compliant storage.
2233 The contact store manages reading and writing contact metadata
44- using XDG-compliant storage locations. *)
44+ using XDG-compliant storage locations. Contacts are stored as
55+ YAML files (one per contact) using the handle as the filename. *)
5667type t
7889(** [create fs app_name] creates a new contact store.
9101011 The store will use XDG data directories for persistent storage
1111- of contact metadata. Each contact is stored as a separate JSON
1212+ of contact metadata. Each contact is stored as a separate YAML
1213 file named after its handle.
13141415 @param fs Eio filesystem for file operations
···29303031(** [save t contact] saves a contact to the store.
31323232- The contact is serialized to JSON and written to a file
3333- named "handle.json" in the XDG data directory.
3333+ The contact is serialized to YAML and written to a file
3434+ named "handle.yaml" in the XDG data directory.
34353536 If a contact with the same handle already exists, it is overwritten. *)
3637val save : t -> Sortal_contact.t -> unit
37383839(** [lookup t handle] retrieves a contact by handle.
39404040- Searches for a file named "handle.json" in the XDG data directory
4141+ Searches for a file named "handle.yaml" in the XDG data directory
4142 and deserializes it if found.
42434344 @return [Some contact] if found, [None] if not found or deserialization fails *)
···45464647(** [delete t handle] removes a contact from the store.
47484848- Deletes the file "handle.json" from the XDG data directory.
4949+ Deletes the file "handle.yaml" from the XDG data directory.
4950 Does nothing if the contact does not exist. *)
5051val delete : t -> string -> unit
51525353+(** {1 Contact Modification} *)
5454+5555+(** [add_email t handle email] adds an email to an existing contact.
5656+5757+ @param t The store
5858+ @param handle The contact handle
5959+ @param email The email entry to add
6060+ @return [Ok ()] on success, [Error msg] if contact not found
6161+ @raise Failure if the contact cannot be saved *)
6262+val add_email : t -> string -> Sortal_contact.email -> (unit, string) result
6363+6464+(** [remove_email t handle address] removes an email from a contact.
6565+6666+ Removes all email entries with the given address.
6767+6868+ @param t The store
6969+ @param handle The contact handle
7070+ @param address The email address to remove
7171+ @return [Ok ()] on success, [Error msg] if contact not found *)
7272+val remove_email : t -> string -> string -> (unit, string) result
7373+7474+(** [add_service t handle service] adds a service to an existing contact.
7575+7676+ @param t The store
7777+ @param handle The contact handle
7878+ @param service The service entry to add
7979+ @return [Ok ()] on success, [Error msg] if contact not found *)
8080+val add_service : t -> string -> Sortal_contact.service -> (unit, string) result
8181+8282+(** [remove_service t handle url] removes a service from a contact.
8383+8484+ Removes all service entries with the given URL.
8585+8686+ @param t The store
8787+ @param handle The contact handle
8888+ @param url The service URL to remove
8989+ @return [Ok ()] on success, [Error msg] if contact not found *)
9090+val remove_service : t -> string -> string -> (unit, string) result
9191+9292+(** [add_organization t handle org] adds an organization to an existing contact.
9393+9494+ @param t The store
9595+ @param handle The contact handle
9696+ @param org The organization entry to add
9797+ @return [Ok ()] on success, [Error msg] if contact not found *)
9898+val add_organization : t -> string -> Sortal_contact.organization -> (unit, string) result
9999+100100+(** [remove_organization t handle name] removes an organization from a contact.
101101+102102+ Removes all organization entries with the given name.
103103+104104+ @param t The store
105105+ @param handle The contact handle
106106+ @param name The organization name to remove
107107+ @return [Ok ()] on success, [Error msg] if contact not found *)
108108+val remove_organization : t -> string -> string -> (unit, string) result
109109+110110+(** [add_url t handle url_entry] adds a URL to an existing contact.
111111+112112+ @param t The store
113113+ @param handle The contact handle
114114+ @param url_entry The URL entry to add
115115+ @return [Ok ()] on success, [Error msg] if contact not found *)
116116+val add_url : t -> string -> Sortal_contact.url_entry -> (unit, string) result
117117+118118+(** [remove_url t handle url] removes a URL from a contact.
119119+120120+ Removes all URL entries with the given URL.
121121+122122+ @param t The store
123123+ @param handle The contact handle
124124+ @param url The URL to remove
125125+ @return [Ok ()] on success, [Error msg] if contact not found *)
126126+val remove_url : t -> string -> string -> (unit, string) result
127127+128128+(** [update_contact t handle f] updates a contact by applying function [f].
129129+130130+ Looks up the contact, applies [f] to transform it, and saves the result.
131131+132132+ @param t The store
133133+ @param handle The contact handle
134134+ @param f Function to transform the contact
135135+ @return [Ok ()] on success, [Error msg] if contact not found *)
136136+val update_contact : t -> string -> (Sortal_contact.t -> Sortal_contact.t) -> (unit, string) result
137137+52138(** [list t] returns all contacts in the store.
531395454- Scans the XDG data directory for all .json files and attempts
140140+ Scans the XDG data directory for all .yaml files and attempts
55141 to deserialize them as contacts. Files that fail to parse are
56142 silently skipped.
57143···112198 @param query The search query (case-insensitive)
113199 @return A list of matching contacts, sorted by handle *)
114200val search_all : t -> string -> Sortal_contact.t list
201201+202202+(** {1 Temporal Queries} *)
203203+204204+(** [find_by_email_at t ~email ~date] finds a contact by email address at a specific date.
205205+206206+ Searches for a contact that had the given email address valid at [date].
207207+208208+ @param email Email address to search for
209209+ @param date ISO 8601 date string
210210+ @return The first matching contact, or [None] if not found *)
211211+val find_by_email_at : t -> email:string -> date:Sortal_temporal.date ->
212212+ Sortal_contact.t option
213213+214214+(** [find_by_org t ~org ?from ?until ()] finds contacts who worked at an organization.
215215+216216+ Searches for contacts whose organization records overlap with the given period.
217217+ If [from] and [until] are omitted, returns all contacts who ever worked there.
218218+219219+ @param org Organization name (case-insensitive substring match)
220220+ @param from Start date of period to check (inclusive, optional)
221221+ @param until End date of period to check (exclusive, optional)
222222+ @return List of matching contacts, sorted by handle *)
223223+val find_by_org : t -> org:string -> ?from:Sortal_temporal.date ->
224224+ ?until:Sortal_temporal.date -> unit -> Sortal_contact.t list
225225+226226+(** [list_at t ~date] returns contacts that were active at a specific date.
227227+228228+ A contact is considered active at a date if it has at least one
229229+ email, organization, or URL valid at that date.
230230+231231+ @param date ISO 8601 date string
232232+ @return List of active contacts at that date *)
233233+val list_at : t -> date:Sortal_temporal.date -> Sortal_contact.t list
115234116235(** {1 Utilities} *)
117236
+87
lib/sortal_temporal.ml
···11+type date = string
22+33+type range = {
44+ from: date option;
55+ until: date option;
66+}
77+88+let make ?from ?until () = { from; until }
99+1010+let always = { from = None; until = None }
1111+1212+(* Compare ISO 8601 dates lexicographically - works for YYYY, YYYY-MM, YYYY-MM-DD *)
1313+let date_compare (d1 : date) (d2 : date) : int =
1414+ String.compare d1 d2
1515+1616+let date_gte d1 d2 = date_compare d1 d2 >= 0
1717+1818+let valid_at range_opt ~date =
1919+ match range_opt with
2020+ | None -> true (* No range = always valid *)
2121+ | Some { from; until } ->
2222+ let after_start = match from with
2323+ | None -> true
2424+ | Some f -> date_gte date f
2525+ in
2626+ let before_end = match until with
2727+ | None -> true
2828+ | Some u -> date_compare date u < 0 (* until is exclusive *)
2929+ in
3030+ after_start && before_end
3131+3232+let overlaps r1 r2 =
3333+ (* Two ranges overlap if neither ends before the other starts *)
3434+ let r1_starts_before_r2_ends = match r2.until with
3535+ | None -> true
3636+ | Some u2 -> match r1.from with
3737+ | None -> true
3838+ | Some f1 -> date_compare f1 u2 < 0
3939+ in
4040+ let r2_starts_before_r1_ends = match r1.until with
4141+ | None -> true
4242+ | Some u1 -> match r2.from with
4343+ | None -> true
4444+ | Some f2 -> date_compare f2 u1 < 0
4545+ in
4646+ r1_starts_before_r2_ends && r2_starts_before_r1_ends
4747+4848+let today () =
4949+ let open Unix in
5050+ let tm = localtime (time ()) in
5151+ Printf.sprintf "%04d-%02d-%02d"
5252+ (tm.tm_year + 1900)
5353+ (tm.tm_mon + 1)
5454+ tm.tm_mday
5555+5656+let is_current range_opt =
5757+ valid_at range_opt ~date:(today ())
5858+5959+let current ~get list =
6060+ (* Find first currently valid item, or first item without temporal bounds *)
6161+ let current_items = List.filter (fun item -> is_current (get item)) list in
6262+ match current_items with
6363+ | x :: _ -> Some x
6464+ | [] ->
6565+ (* No current items, try to find one without temporal bounds *)
6666+ List.find_opt (fun item -> get item = None) list
6767+6868+let at_date ~get ~date list =
6969+ List.filter (fun item -> valid_at (get item) ~date) list
7070+7171+let filter ~get ~from ~until list =
7272+ let query_range = { from; until } in
7373+ List.filter (fun item ->
7474+ match get item with
7575+ | None -> true (* Items without range match all queries *)
7676+ | Some r -> overlaps r query_range
7777+ ) list
7878+7979+let json_t =
8080+ let open Jsont in
8181+ let open Jsont.Object in
8282+ let mem_opt f v ~enc = mem f v ~dec_absent:None ~enc_omit:Option.is_none ~enc in
8383+ let make_range from until = { from; until } in
8484+ map ~kind:"TemporalRange" make_range
8585+ |> mem_opt "from" (some string) ~enc:(fun r -> r.from)
8686+ |> mem_opt "until" (some string) ~enc:(fun r -> r.until)
8787+ |> finish
+76
lib/sortal_temporal.mli
···11+(** Temporal validity support for contact fields.
22+33+ This module provides types and functions for managing time-bounded
44+ information in contacts, such as emails valid only during certain
55+ employment periods. *)
66+77+(** ISO 8601 date string.
88+99+ Supports multiple granularities:
1010+ - Year: ["2001"]
1111+ - Year-Month: ["2001-01"]
1212+ - Full date: ["2001-01-15"]
1313+1414+ For querying, partial dates are treated as inclusive ranges. *)
1515+type date = string
1616+1717+(** A temporal range indicating validity period. *)
1818+type range = {
1919+ from: date option; (** Start date (inclusive). [None] means from the beginning. *)
2020+ until: date option; (** End date (exclusive). [None] means continuing/indefinite. *)
2121+}
2222+2323+(** {1 Range Construction} *)
2424+2525+(** [make ?from ?until ()] creates a temporal range. *)
2626+val make : ?from:date -> ?until:date -> unit -> range
2727+2828+(** [always] is a range that is always valid (no from/until bounds). *)
2929+val always : range
3030+3131+(** {1 Range Queries} *)
3232+3333+(** [valid_at range ~date] checks if [range] is valid at the given [date].
3434+3535+ - [None] range means always valid
3636+ - [None] from means valid from beginning
3737+ - [None] until means valid continuing *)
3838+val valid_at : range option -> date:date -> bool
3939+4040+(** [overlaps r1 r2] checks if two ranges overlap in time. *)
4141+val overlaps : range -> range -> bool
4242+4343+(** [is_current range] checks if range is valid at the current date.
4444+ Uses today's date for the check. *)
4545+val is_current : range option -> bool
4646+4747+(** {1 List Filtering} *)
4848+4949+(** [current ~get list] returns the first current/valid item from [list].
5050+5151+ @param get Function to extract the temporal range from an item.
5252+ Returns the first item where the range is currently valid,
5353+ or the first item without temporal bounds if none are current. *)
5454+val current : get:('a -> range option) -> 'a list -> 'a option
5555+5656+(** [at_date ~get ~date list] filters [list] to items valid at [date].
5757+5858+ @param get Function to extract the temporal range from an item.
5959+ @param date The date to check validity against. *)
6060+val at_date : get:('a -> range option) -> date:date -> 'a list -> 'a list
6161+6262+(** [filter ~get ~from ~until list] filters [list] to items overlapping the period.
6363+6464+ Returns items whose temporal range overlaps with the given period. *)
6565+val filter : get:('a -> range option) -> from:date option -> until:date option ->
6666+ 'a list -> 'a list
6767+6868+(** {1 JSON Encoding} *)
6969+7070+(** [json_t] is the jsont encoder/decoder for temporal ranges.
7171+7272+ Encodes as a JSON object with optional [from] and [until] fields:
7373+ {[ { "from": "2001-01", "until": "2003-12" } ]}
7474+7575+ Empty object [\{\}] or missing field represents [always]. *)
7676+val json_t : range Jsont.t
+28-35
test/test_sortal.ml
···66 let c = Sortal.Contact.make
77 ~handle:"test"
88 ~names:["Test User"; "T. User"]
99- ~email:"test@example.com"
1010- ~github:"testuser"
99+ ~emails:[Sortal.Contact.email_of_string "test@example.com"]
1010+ ~services:[Sortal.Contact.make_service ~kind:Git ~handle:"testuser" "https://github.com/testuser"]
1111 () in
1212 assert (Sortal.Contact.handle c = "test");
1313 assert (Sortal.Contact.name c = "Test User");
1414 assert (List.length (Sortal.Contact.names c) = 2);
1515- assert (Sortal.Contact.email c = Some "test@example.com");
1616- assert (Sortal.Contact.github c = Some "testuser");
1717- assert (Sortal.Contact.twitter c = None);
1515+ assert (Sortal.Contact.current_email c = Some "test@example.com");
1616+ assert (List.length (Sortal.Contact.services c) = 1);
1717+ assert (List.length (Sortal.Contact.services_of_kind c Git) = 1);
1818 traceln "✓ Contact creation works"
19192020let test_best_url () =
2121 let c1 = Sortal.Contact.make
2222 ~handle:"test1"
2323 ~names:["Test 1"]
2424- ~url:"https://example.com"
2525- ~github:"test1"
2424+ ~urls:[Sortal.Contact.url_of_string "https://example.com"]
2525+ ~services:[Sortal.Contact.service_of_url "https://github.com/test1"]
2626 () in
2727 assert (Sortal.Contact.best_url c1 = Some "https://example.com");
28282929 let c2 = Sortal.Contact.make
3030 ~handle:"test2"
3131 ~names:["Test 2"]
3232- ~github:"test2"
3232+ ~services:[Sortal.Contact.service_of_url "https://github.com/test2"]
3333 () in
3434 assert (Sortal.Contact.best_url c2 = Some "https://github.com/test2");
35353636 let c3 = Sortal.Contact.make
3737 ~handle:"test3"
3838 ~names:["Test 3"]
3939- ~email:"test3@example.com"
3939+ ~emails:[Sortal.Contact.email_of_string "test3@example.com"]
4040 () in
4141 assert (Sortal.Contact.best_url c3 = Some "mailto:test3@example.com");
4242···5252 let c = Sortal.Contact.make
5353 ~handle:"json_test"
5454 ~names:["JSON Test"]
5555- ~email:"json@example.com"
5656- ~github:"jsontest"
5555+ ~emails:[Sortal.Contact.email_of_string "json@example.com"]
5656+ ~services:[Sortal.Contact.make_service ~kind:Git ~handle:"jsontest" "https://github.com/jsontest"]
5757 ~orcid:"0000-0001-2345-6789"
5858 () in
5959···6262 (match Jsont_bytesrw.decode_string Sortal.Contact.json_t json_str with
6363 | Ok decoded ->
6464 assert (Sortal.Contact.handle decoded = "json_test");
6565- assert (Sortal.Contact.email decoded = Some "json@example.com");
6666- assert (Sortal.Contact.github decoded = Some "jsontest");
6565+ assert (Sortal.Contact.current_email decoded = Some "json@example.com");
6666+ assert (List.length (Sortal.Contact.services_of_kind decoded Git) = 1);
6767 assert (Sortal.Contact.orcid decoded = Some "0000-0001-2345-6789");
6868 traceln "✓ JSON encoding/decoding works"
6969 | Error err ->
···8787 let c1 = Sortal.Contact.make
8888 ~handle:"alice"
8989 ~names:["Alice Anderson"]
9090- ~email:"alice@example.com"
9090+ ~emails:[Sortal.Contact.email_of_string "alice@example.com"]
9191 () in
92929393 let c2 = Sortal.Contact.make
9494 ~handle:"bob"
9595 ~names:["Bob Brown"; "Robert Brown"]
9696- ~github:"bobbrown"
9696+ ~services:[Sortal.Contact.service_of_url "https://github.com/bobbrown"]
9797 () in
98989999 (* Test save *)
···159159 let c1 = Sortal.Contact.make
160160 ~handle:"test1"
161161 ~names:["Test 1"]
162162- ~url:"https://example.com"
162162+ ~urls:[Sortal.Contact.url_of_string "https://example.com"]
163163 () in
164164- assert (Sortal.Contact.url c1 = Some "https://example.com");
165165- assert (Sortal.Contact.urls c1 = ["https://example.com"]);
164164+ assert (Sortal.Contact.current_url c1 = Some "https://example.com");
165165+ assert (List.length (Sortal.Contact.urls c1) = 1);
166166167167- (* Test with only urls set *)
167167+ (* Test with multiple urls *)
168168 let c2 = Sortal.Contact.make
169169 ~handle:"test2"
170170 ~names:["Test 2"]
171171- ~urls:["https://one.com"; "https://two.com"]
171171+ ~urls:[
172172+ Sortal.Contact.url_of_string "https://one.com";
173173+ Sortal.Contact.url_of_string "https://two.com"
174174+ ]
172175 () in
173173- assert (Sortal.Contact.url c2 = Some "https://one.com");
174174- assert (Sortal.Contact.urls c2 = ["https://one.com"; "https://two.com"]);
176176+ assert (Sortal.Contact.current_url c2 = Some "https://one.com");
177177+ assert (List.length (Sortal.Contact.urls c2) = 2);
175178176176- (* Test with both url and urls set *)
179179+ (* Test with no urls *)
177180 let c3 = Sortal.Contact.make
178181 ~handle:"test3"
179182 ~names:["Test 3"]
180180- ~url:"https://primary.com"
181181- ~urls:["https://secondary.com"; "https://tertiary.com"]
182183 () in
183183- assert (Sortal.Contact.url c3 = Some "https://primary.com");
184184- assert (Sortal.Contact.urls c3 = ["https://primary.com"; "https://secondary.com"; "https://tertiary.com"]);
185185-186186- (* Test with neither set *)
187187- let c4 = Sortal.Contact.make
188188- ~handle:"test4"
189189- ~names:["Test 4"]
190190- () in
191191- assert (Sortal.Contact.url c4 = None);
192192- assert (Sortal.Contact.urls c4 = []);
184184+ assert (Sortal.Contact.current_url c3 = None);
185185+ assert (Sortal.Contact.urls c3 = []);
193186194187 traceln "✓ URLs field works correctly"
195188