···11+ISC License
22+33+Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>
44+55+Permission to use, copy, modify, and distribute this software for any
66+purpose with or without fee is hereby granted, provided that the above
77+copyright notice and this permission notice appear in all copies.
88+99+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
1010+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
1111+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
1212+ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
1313+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
1414+ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
1515+OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+170
README.md
···11+# Sortal - Contact Metadata Management Library
22+33+Sortal is an OCaml library that provides a comprehensive system for managing
44+contact metadata with temporal validity tracking. It stores data in
55+XDG-compliant locations using the YAML format and optionally versions all changes
66+with git.
77+88+## Features
99+1010+- **Temporal Support**: Track how contact information changes over time (emails, organizations, URLs)
1111+- **XDG-compliant storage**: Contact metadata stored in standard XDG data directories
1212+- **YAML format**: Human-readable YAML files with type-safe encoding/decoding using yamlt
1313+- **Rich metadata**: Support for multiple names, emails (typed), organizations, services (GitHub, social media), ORCID, URLs, and Atom feeds
1414+- **Git Versioning**: Optional automatic git commits for all changes with descriptive messages
1515+- **CLI Interface**: Full command-line interface for CRUD operations on contacts
1616+- **Simple API**: Easy-to-use functions for saving, loading, searching, and deleting contacts
1717+1818+## Metadata Fields
1919+2020+Each contact can include:
2121+2222+- `handle`: Unique identifier/username (required)
2323+- `names`: List of full names with primary name first (required)
2424+- `email`: Email address
2525+- `icon`: Avatar/icon URL
2626+- `thumbnail`: Path to a local thumbnail image file
2727+- `github`: GitHub username
2828+- `twitter`: Twitter/X username
2929+- `bluesky`: Bluesky handle
3030+- `mastodon`: Mastodon handle (with instance)
3131+- `orcid`: ORCID identifier
3232+- `url`: Personal/professional website
3333+- `atom_feeds`: List of Atom/RSS feed URLs
3434+3535+## Storage
3636+3737+Contact data is stored as individual YAML files in the XDG data directory:
3838+3939+- Default location: `$HOME/.local/share/sortal/`
4040+- Override with: `SORTAL_DATA_DIR` or `XDG_DATA_HOME`
4141+- Each contact stored as: `{handle}.yaml`
4242+- Format: Human-readable YAML with temporal data support
4343+4444+## Usage Example
4545+4646+### Basic Usage
4747+4848+```ocaml
4949+(* Create a contact store from filesystem *)
5050+let store = Sortal.create env#fs "myapp" in
5151+5252+(* Or create from an existing XDG context (recommended when using eiocmd) *)
5353+let store = Sortal.create_from_xdg xdg in
5454+5555+(* Create a new contact *)
5656+let contact = Sortal.Contact.make
5757+ ~handle:"avsm"
5858+ ~names:["Anil Madhavapeddy"]
5959+ ~email:"anil@recoil.org"
6060+ ~github:"avsm"
6161+ ~orcid:"0000-0002-7890-1234"
6262+ () in
6363+6464+(* Save the contact *)
6565+Sortal.save store contact;
6666+6767+(* Lookup by handle *)
6868+match Sortal.lookup store "avsm" with
6969+| Some c -> Printf.printf "Found: %s\n" (Sortal.Contact.name c)
7070+| None -> Printf.printf "Not found\n"
7171+7272+(* Search for contacts by name *)
7373+let matches = Sortal.search_all store "Anil" in
7474+List.iter (fun c ->
7575+ Printf.printf "%s: %s\n"
7676+ (Sortal.Contact.handle c)
7777+ (Sortal.Contact.name c)
7878+) matches
7979+8080+(* List all contacts *)
8181+let all_contacts = Sortal.list store in
8282+List.iter (fun c ->
8383+ Printf.printf "%s: %s\n"
8484+ (Sortal.Contact.handle c)
8585+ (Sortal.Contact.name c)
8686+) all_contacts
8787+```
8888+8989+## CLI Tool
9090+9191+The library includes a standalone `sortal` CLI tool with full CRUD functionality:
9292+9393+```bash
9494+# Initialize git versioning (optional)
9595+sortal git-init
9696+9797+# List all contacts
9898+sortal list
9999+100100+# Show details for a specific contact
101101+sortal show avsm
102102+103103+# Search for contacts
104104+sortal search "Anil"
105105+106106+# Show database statistics
107107+sortal stats
108108+109109+# Add a new contact
110110+sortal add jsmith --name "John Smith" --email "john@example.com" --kind person
111111+112112+# Add metadata to contacts
113113+sortal add-org jsmith "Acme Corp" --title "Software Engineer" --from 2020-01
114114+sortal add-service jsmith "https://github.com/jsmith" --kind github --handle jsmith
115115+sortal add-email jsmith "john.work@example.com" --type work --from 2020-01
116116+sortal add-url jsmith "https://jsmith.example.com" --label "Personal website"
117117+118118+# Remove metadata
119119+sortal remove-email jsmith "old@example.com"
120120+sortal remove-service jsmith "https://old-service.com"
121121+sortal remove-org jsmith "Old Company"
122122+sortal remove-url jsmith "https://old-url.com"
123123+124124+# Delete a contact
125125+sortal delete jsmith
126126+127127+# Synchronize data (convert thumbnails to PNG)
128128+sortal sync
129129+```
130130+131131+## Git Versioning
132132+133133+Sortal includes a `Sortal_git_store` module that provides automatic git commits
134134+for all contact modifications:
135135+136136+```ocaml
137137+open Sortal
138138+139139+(* Create a git-backed store *)
140140+let git_store = Git_store.create store env in
141141+142142+(* Initialize git repository *)
143143+let () = match Git_store.init git_store with
144144+ | Ok () -> Logs.app (fun m -> m "Git initialized")
145145+ | Error msg -> Logs.err (fun m -> m "Error: %s" msg)
146146+in
147147+148148+(* Save a contact - automatically commits with descriptive message *)
149149+let contact = Contact.make ~handle:"jsmith" ~names:["John Smith"] () in
150150+match Git_store.save git_store contact with
151151+| Ok () -> Logs.app (fun m -> m "Contact saved and committed")
152152+| Error msg -> Logs.err (fun m -> m "Error: %s" msg)
153153+```
154154+155155+**Commit Messages**: All git store operations create descriptive commit messages:
156156+- `save`: "Add contact @handle (Name)" or "Update contact @handle (Name)"
157157+- `delete`: "Delete contact @handle (Name)"
158158+- `add_email`: "Update @handle: add email address@example.com"
159159+- `remove_email`: "Update @handle: remove email address@example.com"
160160+- `add_service`: "Update @handle: add service Kind (url)"
161161+- `add_organization`: "Update @handle: add organization Org Name"
162162+- And similar for all other operations
163163+164164+## Project Status
165165+166166+Still very much just used by Anil Madhavapeddy. You're welcome to try it, but let me know...
167167+168168+## License
169169+170170+ISC License - see [LICENSE.md](LICENSE.md) for details.
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Sortal - Username to metadata mapping with XDG storage
77+88+ This library provides a system for mapping usernames to various metadata
99+ including URLs, emails, ORCID identifiers, and social media handles.
1010+ It uses XDG Base Directory Specification for storage locations and
1111+ provides temporal support for time-bounded information like historical
1212+ email addresses and employment records.
1313+1414+ {b Storage:}
1515+1616+ Contact metadata is stored as YAML files in the XDG data directory,
1717+ with one file per contact using the handle as the filename. The YAML
1818+ format uses the same Jsont codec definitions as JSON for seamless
1919+ compatibility.
2020+2121+ {b Typical Usage:}
2222+2323+ {[
2424+ let store = Sortal.create env#fs "myapp" in
2525+ let contact = Sortal.Contact.make
2626+ ~handle:"avsm"
2727+ ~names:["Anil Madhavapeddy"]
2828+ ~email:"anil@recoil.org"
2929+ ~github:"avsm"
3030+ ~orcid:"0000-0002-7890-1234"
3131+ () in
3232+ Sortal.save store contact;
3333+3434+ match Sortal.lookup store "avsm" with
3535+ | Some c -> Printf.printf "Found: %s\n" (Sortal.Contact.name c)
3636+ | None -> Printf.printf "Not found\n"
3737+ ]}
3838+*)
3939+4040+(** {1 Schema Modules}
4141+4242+ These modules define the data types and serialization formats.
4343+ They are re-exported from {!Sortal_schema} for convenience.
4444+ For version-specific access, use [Sortal_schema.V1.*]. *)
4545+4646+(** Temporal validity support for time-bounded contact fields. *)
4747+module Temporal = Sortal_schema.Temporal
4848+4949+(** Feed subscription metadata. *)
5050+module Feed = Sortal_schema.Feed
5151+5252+(** Contact metadata with temporal support. *)
5353+module Contact = Sortal_schema.Contact
5454+5555+(** {1 Core Modules} *)
5656+5757+(** Contact store with XDG-compliant storage. *)
5858+module Store = Sortal_store
5959+6060+(** Git-backed contact store with automatic version control. *)
6161+module Git_store = Sortal_git_store
6262+6363+(** Cmdliner integration for CLI applications. *)
6464+module Cmd = Sortal_cmd
6565+6666+(** {1 Convenience Re-exports}
6767+6868+ These are re-exported from {!Store} for easier top-level access. *)
6969+7070+(** The contact store type. *)
7171+type t = Store.t
7272+7373+(** [create fs app_name] creates a new contact store.
7474+ See {!Store.create} for details. *)
7575+val create : Eio.Fs.dir_ty Eio.Path.t -> string -> t
7676+7777+(** [create_from_xdg xdg] creates a contact store from an XDG context.
7878+ See {!Store.create_from_xdg} for details. *)
7979+val create_from_xdg : Xdge.t -> t
8080+8181+(** [save t contact] saves a contact to the store.
8282+ See {!Store.save} for details. *)
8383+val save : t -> Contact.t -> unit
8484+8585+(** [lookup t handle] retrieves a contact by handle.
8686+ See {!Store.lookup} for details. *)
8787+val lookup : t -> string -> Contact.t option
8888+8989+(** [delete t handle] removes a contact from the store.
9090+ See {!Store.delete} for details. *)
9191+val delete : t -> string -> unit
9292+9393+(** [list t] returns all contacts in the store.
9494+ See {!Store.list} for details. *)
9595+val list : t -> Contact.t list
9696+9797+(** [thumbnail_path t contact] returns the path to a contact's thumbnail.
9898+ See {!Store.thumbnail_path} for details. *)
9999+val thumbnail_path : t -> Contact.t -> Eio.Fs.dir_ty Eio.Path.t option
100100+101101+(** [find_by_name t name] searches for contacts by name.
102102+ See {!Store.find_by_name} for details. *)
103103+val find_by_name : t -> string -> Contact.t
104104+105105+(** [find_by_name_opt t name] searches for contacts by name.
106106+ See {!Store.find_by_name_opt} for details. *)
107107+val find_by_name_opt : t -> string -> Contact.t option
108108+109109+(** [search_all t query] searches for contacts matching a query.
110110+ See {!Store.search_all} for details. *)
111111+val search_all : t -> string -> Contact.t list
112112+113113+(** [handle_of_name name] generates a handle from a full name.
114114+ See {!Store.handle_of_name} for details. *)
115115+val handle_of_name : string -> string
116116+117117+(** [pp ppf t] pretty prints the contact store.
118118+ See {!Store.pp} for details. *)
119119+val pp : Format.formatter -> t -> unit
+464
lib/core/sortal_cmd.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+open Cmdliner
77+88+module Contact = Sortal_schema.Contact
99+module Temporal = Sortal_schema.Temporal
1010+1111+let is_png path =
1212+ let ext = String.lowercase_ascii (Filename.extension path) in
1313+ ext = ".png"
1414+1515+let convert_to_png src_path =
1616+ let base = Filename.remove_extension src_path in
1717+ let dst_path = base ^ ".png" in
1818+ let cmd = Printf.sprintf "magick %s %s" (Filename.quote src_path) (Filename.quote dst_path) in
1919+ let ret = Unix.system cmd in
2020+ match ret with
2121+ | Unix.WEXITED 0 -> Ok dst_path
2222+ | Unix.WEXITED n -> Error (Printf.sprintf "magick exited with code %d" n)
2323+ | Unix.WSIGNALED n -> Error (Printf.sprintf "magick killed by signal %d" n)
2424+ | Unix.WSTOPPED n -> Error (Printf.sprintf "magick stopped by signal %d" n)
2525+2626+let list_cmd xdg =
2727+ let store = Sortal_store.create_from_xdg xdg in
2828+ let contacts = Sortal_store.list store in
2929+ let sorted = List.sort Contact.compare contacts in
3030+ Printf.printf "Total contacts: %d\n" (List.length sorted);
3131+ List.iter (fun c ->
3232+ Printf.printf "@%s: %s\n" (Contact.handle c) (Contact.name c)
3333+ ) sorted;
3434+ 0
3535+3636+let show_cmd handle xdg =
3737+ let store = Sortal_store.create_from_xdg xdg in
3838+ match Sortal_store.lookup store handle with
3939+ | Some c ->
4040+ (* Use the pretty printer for rich temporal display *)
4141+ Fmt.pr "%a@." Contact.pp c;
4242+ 0
4343+ | None -> Logs.err (fun m -> m "Contact not found: %s" handle); 1
4444+4545+let search_cmd query xdg =
4646+ let store = Sortal_store.create_from_xdg xdg in
4747+ match Sortal_store.search_all store query with
4848+ | [] ->
4949+ Logs.warn (fun m -> m "No contacts found matching: %s" query);
5050+ 1
5151+ | matches ->
5252+ Logs.app (fun m -> m "Found %d match%s:"
5353+ (List.length matches)
5454+ (if List.length matches = 1 then "" else "es"));
5555+ List.iter (fun c ->
5656+ Logs.app (fun m -> m "@%s: %s" (Contact.handle c) (Contact.name c));
5757+ Option.iter (fun e -> Logs.app (fun m -> m " Email: %s" e)) (Contact.current_email c);
5858+ Option.iter (fun u -> Logs.app (fun m -> m " URL: %s" u)) (Contact.best_url c)
5959+ ) matches;
6060+ 0
6161+6262+let stats_cmd () xdg =
6363+ let store = Sortal_store.create_from_xdg xdg in
6464+ let contacts = Sortal_store.list store in
6565+ let total = List.length contacts in
6666+ let count pred = List.filter pred contacts |> List.length in
6767+ let with_email = count (fun c -> Contact.emails c <> []) in
6868+ let with_org = count (fun c -> Contact.organizations c <> []) in
6969+ let with_url = count (fun c -> Contact.urls c <> []) in
7070+ let with_service = count (fun c -> Contact.services c <> []) in
7171+ let with_orcid = count (fun c -> Option.is_some (Contact.orcid c)) in
7272+ let with_feeds = count (fun c -> Option.is_some (Contact.feeds c)) in
7373+ let total_feeds =
7474+ List.fold_left (fun acc c ->
7575+ acc + Option.fold ~none:0 ~some:List.length (Contact.feeds c)
7676+ ) 0 contacts
7777+ in
7878+ let total_services =
7979+ List.fold_left (fun acc c ->
8080+ acc + List.length (Contact.services c)
8181+ ) 0 contacts
8282+ in
8383+ let pct n = float_of_int n /. float_of_int total *. 100. in
8484+ Logs.app (fun m -> m "Contact Database Statistics:");
8585+ Logs.app (fun m -> m " Total contacts: %d" total);
8686+ Logs.app (fun m -> m " With email: %d (%.1f%%)" with_email (pct with_email));
8787+ Logs.app (fun m -> m " With organization: %d (%.1f%%)" with_org (pct with_org));
8888+ Logs.app (fun m -> m " With services: %d (%.1f%%), total %d services" with_service (pct with_service) total_services);
8989+ Logs.app (fun m -> m " With ORCID: %d (%.1f%%)" with_orcid (pct with_orcid));
9090+ Logs.app (fun m -> m " With URL: %d (%.1f%%)" with_url (pct with_url));
9191+ Logs.app (fun m -> m " With feeds: %d (%.1f%%), total %d feeds" with_feeds (pct with_feeds) total_feeds);
9292+ 0
9393+9494+let sync_cmd () xdg =
9595+ let store = Sortal_store.create_from_xdg xdg in
9696+ let contacts = Sortal_store.list store in
9797+ Logs.app (fun m -> m "Syncing %d contacts..." (List.length contacts));
9898+ let converted = ref 0 in
9999+ let skipped = ref 0 in
100100+ let no_thumbnail = ref 0 in
101101+ let errors = ref 0 in
102102+ List.iter (fun contact ->
103103+ let handle = Contact.handle contact in
104104+ match Sortal_store.thumbnail_path store contact with
105105+ | None ->
106106+ Logs.info (fun m -> m "@%s: no thumbnail" handle);
107107+ incr no_thumbnail
108108+ | Some eio_path ->
109109+ let path = Eio.Path.native_exn eio_path in
110110+ if is_png path then begin
111111+ Logs.info (fun m -> m "@%s: already PNG (%s)" handle (Filename.basename path));
112112+ incr skipped
113113+ end else begin
114114+ Logs.app (fun m -> m "@%s: converting %s to PNG..." handle (Filename.basename path));
115115+ match convert_to_png path with
116116+ | Ok new_path ->
117117+ Logs.app (fun m -> m " Converted: %s -> %s"
118118+ (Filename.basename path) (Filename.basename new_path));
119119+ incr converted
120120+ | Error msg ->
121121+ Logs.err (fun m -> m " Failed to convert %s: %s" path msg);
122122+ incr errors
123123+ end
124124+ ) contacts;
125125+ Logs.app (fun m -> m "Sync complete:");
126126+ Logs.app (fun m -> m " %d contacts without thumbnails" !no_thumbnail);
127127+ Logs.app (fun m -> m " %d already PNG (skipped)" !skipped);
128128+ Logs.app (fun m -> m " %d converted to PNG" !converted);
129129+ Logs.app (fun m -> m " %d errors" !errors);
130130+ if !errors > 0 then 1 else 0
131131+132132+(* Initialize git repository *)
133133+let git_init_cmd xdg env =
134134+ let store = Sortal_store.create_from_xdg xdg in
135135+ let git_store = Sortal_git_store.create store env in
136136+ match Sortal_git_store.init git_store with
137137+ | Ok () ->
138138+ if Sortal_git_store.is_initialized git_store then
139139+ Logs.app (fun m -> m "Git repository initialized in data directory")
140140+ else
141141+ Logs.app (fun m -> m "Git repository already initialized");
142142+ 0
143143+ | Error msg ->
144144+ Logs.err (fun m -> m "Failed to initialize git repository: %s" msg);
145145+ 1
146146+147147+(* Add a new contact *)
148148+let add_cmd handle names kind email github url orcid xdg env =
149149+ let store = Sortal_store.create_from_xdg xdg in
150150+ let git_store = Sortal_git_store.create store env in
151151+ (* Check if contact already exists *)
152152+ match Sortal_store.lookup store handle with
153153+ | Some _ ->
154154+ Logs.err (fun m -> m "Contact @%s already exists" handle);
155155+ 1
156156+ | None ->
157157+ let emails = match email with
158158+ | Some e -> [Contact.make_email e]
159159+ | None -> []
160160+ in
161161+ let services = match github with
162162+ | Some gh -> [Contact.make_service ~kind:Contact.Github ~handle:gh (Printf.sprintf "https://github.com/%s" gh)]
163163+ | None -> []
164164+ in
165165+ let urls = match url with
166166+ | Some u -> [Contact.make_url u]
167167+ | None -> []
168168+ in
169169+ let contact = Contact.make
170170+ ~handle
171171+ ~names
172172+ ?kind
173173+ ~emails
174174+ ~services
175175+ ~urls
176176+ ?orcid
177177+ ()
178178+ in
179179+ match Sortal_git_store.save git_store contact with
180180+ | Ok () ->
181181+ Logs.app (fun m -> m "Created contact @%s: %s" handle (Contact.name contact));
182182+ 0
183183+ | Error msg ->
184184+ Logs.err (fun m -> m "Failed to save contact: %s" msg);
185185+ 1
186186+187187+(* Delete a contact *)
188188+let delete_cmd handle xdg env =
189189+ let store = Sortal_store.create_from_xdg xdg in
190190+ let git_store = Sortal_git_store.create store env in
191191+ match Sortal_git_store.delete git_store handle with
192192+ | Ok () ->
193193+ Logs.app (fun m -> m "Deleted contact @%s" handle);
194194+ 0
195195+ | Error msg ->
196196+ Logs.err (fun m -> m "%s" msg);
197197+ 1
198198+199199+(* Convert string option to Ptime.date option *)
200200+let parse_date_opt (s_opt : string option) : Sortal_schema.Temporal.date option =
201201+ match s_opt with
202202+ | None -> None
203203+ | Some s ->
204204+ match Sortal_schema.Temporal.parse_date_string s with
205205+ | Some d -> Some d
206206+ | None ->
207207+ Logs.warn (fun m -> m "Invalid date format: %s (using ISO 8601: YYYY, YYYY-MM, or YYYY-MM-DD)" s);
208208+ None
209209+210210+(* Add email to existing contact *)
211211+let add_email_cmd handle address type_ from until note xdg env =
212212+ let store = Sortal_store.create_from_xdg xdg in
213213+ let git_store = Sortal_git_store.create store env in
214214+ let from = parse_date_opt from in
215215+ let until = parse_date_opt until in
216216+ let email = Contact.make_email ?type_ ?from ?until ?note address in
217217+ match Sortal_git_store.add_email git_store handle email with
218218+ | Ok () ->
219219+ Logs.app (fun m -> m "Added email %s to @%s" address handle);
220220+ 0
221221+ | Error msg ->
222222+ Logs.err (fun m -> m "%s" msg);
223223+ 1
224224+225225+(* Remove email from contact *)
226226+let remove_email_cmd handle address xdg env =
227227+ let store = Sortal_store.create_from_xdg xdg in
228228+ let git_store = Sortal_git_store.create store env in
229229+ match Sortal_git_store.remove_email git_store handle address with
230230+ | Ok () ->
231231+ Logs.app (fun m -> m "Removed email %s from @%s" address handle);
232232+ 0
233233+ | Error msg ->
234234+ Logs.err (fun m -> m "%s" msg);
235235+ 1
236236+237237+(* Add service to existing contact *)
238238+let add_service_cmd handle url kind service_handle label xdg env =
239239+ let store = Sortal_store.create_from_xdg xdg in
240240+ let git_store = Sortal_git_store.create store env in
241241+ let service = Contact.make_service ?kind ?handle:service_handle ?label url in
242242+ match Sortal_git_store.add_service git_store handle service with
243243+ | Ok () ->
244244+ Logs.app (fun m -> m "Added service %s to @%s" url handle);
245245+ 0
246246+ | Error msg ->
247247+ Logs.err (fun m -> m "%s" msg);
248248+ 1
249249+250250+(* Remove service from contact *)
251251+let remove_service_cmd handle url xdg env =
252252+ let store = Sortal_store.create_from_xdg xdg in
253253+ let git_store = Sortal_git_store.create store env in
254254+ match Sortal_git_store.remove_service git_store handle url with
255255+ | Ok () ->
256256+ Logs.app (fun m -> m "Removed service %s from @%s" url handle);
257257+ 0
258258+ | Error msg ->
259259+ Logs.err (fun m -> m "%s" msg);
260260+ 1
261261+262262+(* Add organization to existing contact *)
263263+let add_org_cmd handle org_name title department from until org_email org_url xdg env =
264264+ let store = Sortal_store.create_from_xdg xdg in
265265+ let git_store = Sortal_git_store.create store env in
266266+ let from = parse_date_opt from in
267267+ let until = parse_date_opt until in
268268+ let org = Contact.make_org ?title ?department ?from ?until ?email:org_email ?url:org_url org_name in
269269+ match Sortal_git_store.add_organization git_store handle org with
270270+ | Ok () ->
271271+ Logs.app (fun m -> m "Added organization %s to @%s" org_name handle);
272272+ 0
273273+ | Error msg ->
274274+ Logs.err (fun m -> m "%s" msg);
275275+ 1
276276+277277+(* Remove organization from contact *)
278278+let remove_org_cmd handle org_name xdg env =
279279+ let store = Sortal_store.create_from_xdg xdg in
280280+ let git_store = Sortal_git_store.create store env in
281281+ match Sortal_git_store.remove_organization git_store handle org_name with
282282+ | Ok () ->
283283+ Logs.app (fun m -> m "Removed organization %s from @%s" org_name handle);
284284+ 0
285285+ | Error msg ->
286286+ Logs.err (fun m -> m "%s" msg);
287287+ 1
288288+289289+(* Add URL to existing contact *)
290290+let add_url_cmd handle url label xdg env =
291291+ let store = Sortal_store.create_from_xdg xdg in
292292+ let git_store = Sortal_git_store.create store env in
293293+ let url_entry = Contact.make_url ?label url in
294294+ match Sortal_git_store.add_url git_store handle url_entry with
295295+ | Ok () ->
296296+ Logs.app (fun m -> m "Added URL %s to @%s" url handle);
297297+ 0
298298+ | Error msg ->
299299+ Logs.err (fun m -> m "%s" msg);
300300+ 1
301301+302302+(* Remove URL from contact *)
303303+let remove_url_cmd handle url xdg env =
304304+ let store = Sortal_store.create_from_xdg xdg in
305305+ let git_store = Sortal_git_store.create store env in
306306+ match Sortal_git_store.remove_url git_store handle url with
307307+ | Ok () ->
308308+ Logs.app (fun m -> m "Removed URL %s from @%s" url handle);
309309+ 0
310310+ | Error msg ->
311311+ Logs.err (fun m -> m "%s" msg);
312312+ 1
313313+314314+(* Command info and args *)
315315+let list_info = Cmd.info "list" ~doc:"List all contacts"
316316+let show_info = Cmd.info "show" ~doc:"Show detailed information about a contact"
317317+let search_info = Cmd.info "search" ~doc:"Search contacts by name"
318318+let stats_info = Cmd.info "stats" ~doc:"Show statistics about the contact database"
319319+let sync_info = Cmd.info "sync" ~doc:"Synchronize and normalize contact data"
320320+321321+let git_init_info = Cmd.info "git-init" ~doc:"Initialize git repository for contact versioning"
322322+ ~man:[
323323+ `S Manpage.s_description;
324324+ `P "Initialize a git repository in the XDG data directory to track contact changes.";
325325+ `P "Once initialized, all contact modifications will be automatically committed with descriptive messages.";
326326+ ]
327327+328328+let add_info = Cmd.info "add" ~doc:"Create a new contact"
329329+ ~man:[
330330+ `S Manpage.s_description;
331331+ `P "Create a new contact with the given handle and name.";
332332+ `P "Additional metadata can be added using options or via add-email, add-service, etc. commands.";
333333+ ]
334334+335335+let delete_info = Cmd.info "delete" ~doc:"Delete a contact"
336336+let add_email_info = Cmd.info "add-email" ~doc:"Add an email address to a contact"
337337+let remove_email_info = Cmd.info "remove-email" ~doc:"Remove an email address from a contact"
338338+let add_service_info = Cmd.info "add-service" ~doc:"Add a service (GitHub, Twitter, etc.) to a contact"
339339+let remove_service_info = Cmd.info "remove-service" ~doc:"Remove a service from a contact"
340340+let add_org_info = Cmd.info "add-org" ~doc:"Add an organization/affiliation to a contact"
341341+let remove_org_info = Cmd.info "remove-org" ~doc:"Remove an organization from a contact"
342342+let add_url_info = Cmd.info "add-url" ~doc:"Add a URL to a contact"
343343+let remove_url_info = Cmd.info "remove-url" ~doc:"Remove a URL from a contact"
344344+345345+let handle_arg =
346346+ Arg.(required & pos 0 (some string) None & info [] ~docv:"HANDLE"
347347+ ~doc:"Contact handle to display")
348348+349349+let query_arg =
350350+ Arg.(required & pos 0 (some string) None & info [] ~docv:"QUERY"
351351+ ~doc:"Name or partial name to search for")
352352+353353+(* Add command arguments *)
354354+let add_handle_arg =
355355+ Arg.(required & pos 0 (some string) None & info [] ~docv:"HANDLE"
356356+ ~doc:"Contact handle (unique identifier)")
357357+358358+let add_names_arg =
359359+ Arg.(non_empty & opt_all string [] & info ["n"; "name"] ~docv:"NAME"
360360+ ~doc:"Full name (can be specified multiple times for aliases)")
361361+362362+let add_kind_arg =
363363+ let kind_conv =
364364+ let parse s = match Contact.contact_kind_of_string s with
365365+ | Some k -> Ok k
366366+ | None -> Error (`Msg (Printf.sprintf "Invalid kind: %s" s))
367367+ in
368368+ let print ppf k = Format.pp_print_string ppf (Contact.contact_kind_to_string k) in
369369+ Arg.conv (parse, print)
370370+ in
371371+ Arg.(value & opt (some kind_conv) None & info ["k"; "kind"] ~docv:"KIND"
372372+ ~doc:"Contact kind (person, organization, group, role)")
373373+374374+let add_email_arg =
375375+ Arg.(value & opt (some string) None & info ["e"; "email"] ~docv:"EMAIL"
376376+ ~doc:"Email address")
377377+378378+let add_github_arg =
379379+ Arg.(value & opt (some string) None & info ["g"; "github"] ~docv:"HANDLE"
380380+ ~doc:"GitHub handle")
381381+382382+let add_url_arg =
383383+ Arg.(value & opt (some string) None & info ["u"; "url"] ~docv:"URL"
384384+ ~doc:"Personal/professional website URL")
385385+386386+let add_orcid_arg =
387387+ Arg.(value & opt (some string) None & info ["orcid"] ~docv:"ORCID"
388388+ ~doc:"ORCID identifier")
389389+390390+(* Add-email command arguments *)
391391+let email_address_arg =
392392+ Arg.(required & pos 1 (some string) None & info [] ~docv:"EMAIL"
393393+ ~doc:"Email address")
394394+395395+let email_type_arg =
396396+ let type_conv =
397397+ let parse s = match Contact.email_type_of_string s with
398398+ | Some t -> Ok t
399399+ | None -> Error (`Msg (Printf.sprintf "Invalid email type: %s" s))
400400+ in
401401+ let print ppf t = Format.pp_print_string ppf (Contact.email_type_to_string t) in
402402+ Arg.conv (parse, print)
403403+ in
404404+ Arg.(value & opt (some type_conv) None & info ["t"; "type"] ~docv:"TYPE"
405405+ ~doc:"Email type (work, personal, other)")
406406+407407+let date_arg name =
408408+ Arg.(value & opt (some string) None & info [name] ~docv:"DATE"
409409+ ~doc:"ISO 8601 date (e.g., 2023, 2023-01, 2023-01-15)")
410410+411411+let note_arg =
412412+ Arg.(value & opt (some string) None & info ["note"] ~docv:"NOTE"
413413+ ~doc:"Contextual note")
414414+415415+(* Add-service command arguments *)
416416+let service_url_arg =
417417+ Arg.(required & pos 1 (some string) None & info [] ~docv:"URL"
418418+ ~doc:"Service URL")
419419+420420+let service_kind_arg =
421421+ let kind_conv =
422422+ let parse s = match Contact.service_kind_of_string s with
423423+ | Some k -> Ok k
424424+ | None -> Error (`Msg (Printf.sprintf "Invalid service kind: %s" s))
425425+ in
426426+ let print ppf k = Format.pp_print_string ppf (Contact.service_kind_to_string k) in
427427+ Arg.conv (parse, print)
428428+ in
429429+ Arg.(value & opt (some kind_conv) None & info ["k"; "kind"] ~docv:"KIND"
430430+ ~doc:"Service kind (github, git, social, activitypub, photo)")
431431+432432+let service_handle_arg =
433433+ Arg.(value & opt (some string) None & info ["handle"] ~docv:"HANDLE"
434434+ ~doc:"Service handle/username")
435435+436436+let label_arg =
437437+ Arg.(value & opt (some string) None & info ["l"; "label"] ~docv:"LABEL"
438438+ ~doc:"Human-readable label")
439439+440440+(* Add-org command arguments *)
441441+let org_name_arg =
442442+ Arg.(required & pos 1 (some string) None & info [] ~docv:"ORG"
443443+ ~doc:"Organization name")
444444+445445+let org_title_arg =
446446+ Arg.(value & opt (some string) None & info ["title"] ~docv:"TITLE"
447447+ ~doc:"Job title")
448448+449449+let org_department_arg =
450450+ Arg.(value & opt (some string) None & info ["dept"; "department"] ~docv:"DEPT"
451451+ ~doc:"Department")
452452+453453+let org_email_arg =
454454+ Arg.(value & opt (some string) None & info ["email"] ~docv:"EMAIL"
455455+ ~doc:"Work email during this period")
456456+457457+let org_url_arg =
458458+ Arg.(value & opt (some string) None & info ["url"] ~docv:"URL"
459459+ ~doc:"Work homepage during this period")
460460+461461+(* URL command arguments *)
462462+let url_value_arg =
463463+ Arg.(required & pos 1 (some string) None & info [] ~docv:"URL"
464464+ ~doc:"URL")
+235
lib/core/sortal_cmd.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Cmdliner terms and commands for contact management.
77+88+ This module provides ready-to-use Cmdliner terms for building
99+ CLI applications that work with contact metadata. *)
1010+1111+module Contact = Sortal_schema.Contact
1212+module Temporal = Sortal_schema.Temporal
1313+1414+(** {1 Command Implementations} *)
1515+1616+(** [list_cmd] is a Cmdliner command that lists all contacts.
1717+1818+ Returns a function that takes an XDG context and returns an exit code. *)
1919+val list_cmd : (Xdge.t -> int)
2020+2121+(** [show_cmd handle] creates a command to show detailed contact information.
2222+2323+ @param handle The contact handle to display *)
2424+val show_cmd : string -> (Xdge.t -> int)
2525+2626+(** [search_cmd query] creates a command to search contacts by name.
2727+2828+ @param query The search query string *)
2929+val search_cmd : string -> (Xdge.t -> int)
3030+3131+(** [stats_cmd] is a command that shows database statistics. *)
3232+val stats_cmd : unit -> (Xdge.t -> int)
3333+3434+(** [sync_cmd] is a command that synchronizes and normalizes contact data.
3535+3636+ Currently performs the following operations:
3737+ - Converts non-JPG thumbnail images to PNG using ImageMagick *)
3838+val sync_cmd : unit -> (Xdge.t -> int)
3939+4040+(** [git_init_cmd xdg env] initializes a git repository in the data directory.
4141+4242+ Once initialized, all contact modifications will be automatically committed.
4343+ @param xdg XDG context
4444+ @param env Eio environment for process spawning *)
4545+val git_init_cmd : Xdge.t -> Eio_unix.Stdenv.base -> int
4646+4747+(** [add_cmd handle names kind email github url orcid xdg env] creates a new contact.
4848+4949+ @param handle Contact handle (unique identifier)
5050+ @param names List of names (first is primary)
5151+ @param kind Optional contact kind
5252+ @param email Optional email address
5353+ @param github Optional GitHub handle
5454+ @param url Optional personal/professional website
5555+ @param orcid Optional ORCID identifier
5656+ @param xdg XDG context
5757+ @param env Eio environment for git operations *)
5858+val add_cmd : string -> string list -> Contact.contact_kind option ->
5959+ string option -> string option -> string option -> string option ->
6060+ Xdge.t -> Eio_unix.Stdenv.base -> int
6161+6262+(** [delete_cmd handle xdg env] deletes a contact.
6363+6464+ @param handle The contact handle to delete
6565+ @param xdg XDG context
6666+ @param env Eio environment for git operations *)
6767+val delete_cmd : string -> Xdge.t -> Eio_unix.Stdenv.base -> int
6868+6969+(** [add_email_cmd handle address type_ from until note xdg env] adds an email to a contact.
7070+7171+ @param handle Contact handle
7272+ @param address Email address
7373+ @param type_ Email type (work, personal, other)
7474+ @param from Start date of validity
7575+ @param until End date of validity
7676+ @param note Contextual note
7777+ @param xdg XDG context
7878+ @param env Eio environment for git operations *)
7979+val add_email_cmd : string -> string -> Contact.email_type option ->
8080+ string option -> string option -> string option ->
8181+ Xdge.t -> Eio_unix.Stdenv.base -> int
8282+8383+(** [remove_email_cmd handle address xdg env] removes an email from a contact. *)
8484+val remove_email_cmd : string -> string -> Xdge.t -> Eio_unix.Stdenv.base -> int
8585+8686+(** [add_service_cmd handle url kind service_handle label xdg env] adds a service to a contact.
8787+8888+ @param handle Contact handle
8989+ @param url Service URL
9090+ @param kind Service kind
9191+ @param service_handle Service username/handle
9292+ @param label Human-readable label
9393+ @param xdg XDG context
9494+ @param env Eio environment for git operations *)
9595+val add_service_cmd : string -> string -> Contact.service_kind option ->
9696+ string option -> string option -> Xdge.t -> Eio_unix.Stdenv.base -> int
9797+9898+(** [remove_service_cmd handle url xdg env] removes a service from a contact. *)
9999+val remove_service_cmd : string -> string -> Xdge.t -> Eio_unix.Stdenv.base -> int
100100+101101+(** [add_org_cmd handle org_name title department from until org_email org_url xdg env]
102102+ adds an organization to a contact. *)
103103+val add_org_cmd : string -> string -> string option -> string option ->
104104+ string option -> string option -> string option -> string option ->
105105+ Xdge.t -> Eio_unix.Stdenv.base -> int
106106+107107+(** [remove_org_cmd handle org_name xdg env] removes an organization from a contact. *)
108108+val remove_org_cmd : string -> string -> Xdge.t -> Eio_unix.Stdenv.base -> int
109109+110110+(** [add_url_cmd handle url label xdg env] adds a URL to a contact. *)
111111+val add_url_cmd : string -> string -> string option -> Xdge.t -> Eio_unix.Stdenv.base -> int
112112+113113+(** [remove_url_cmd handle url xdg env] removes a URL from a contact. *)
114114+val remove_url_cmd : string -> string -> Xdge.t -> Eio_unix.Stdenv.base -> int
115115+116116+(** {1 Cmdliner Info Objects} *)
117117+118118+(** [list_info] is the command info for the list command. *)
119119+val list_info : Cmdliner.Cmd.info
120120+121121+(** [show_info] is the command info for the show command. *)
122122+val show_info : Cmdliner.Cmd.info
123123+124124+(** [search_info] is the command info for the search command. *)
125125+val search_info : Cmdliner.Cmd.info
126126+127127+(** [stats_info] is the command info for the stats command. *)
128128+val stats_info : Cmdliner.Cmd.info
129129+130130+(** [sync_info] is the command info for the sync command. *)
131131+val sync_info : Cmdliner.Cmd.info
132132+133133+(** [git_init_info] is the command info for the git-init command. *)
134134+val git_init_info : Cmdliner.Cmd.info
135135+136136+(** [add_info] is the command info for the add command. *)
137137+val add_info : Cmdliner.Cmd.info
138138+139139+(** [delete_info] is the command info for the delete command. *)
140140+val delete_info : Cmdliner.Cmd.info
141141+142142+(** [add_email_info] is the command info for the add-email command. *)
143143+val add_email_info : Cmdliner.Cmd.info
144144+145145+(** [remove_email_info] is the command info for the remove-email command. *)
146146+val remove_email_info : Cmdliner.Cmd.info
147147+148148+(** [add_service_info] is the command info for the add-service command. *)
149149+val add_service_info : Cmdliner.Cmd.info
150150+151151+(** [remove_service_info] is the command info for the remove-service command. *)
152152+val remove_service_info : Cmdliner.Cmd.info
153153+154154+(** [add_org_info] is the command info for the add-org command. *)
155155+val add_org_info : Cmdliner.Cmd.info
156156+157157+(** [remove_org_info] is the command info for the remove-org command. *)
158158+val remove_org_info : Cmdliner.Cmd.info
159159+160160+(** [add_url_info] is the command info for the add-url command. *)
161161+val add_url_info : Cmdliner.Cmd.info
162162+163163+(** [remove_url_info] is the command info for the remove-url command. *)
164164+val remove_url_info : Cmdliner.Cmd.info
165165+166166+(** {1 Cmdliner Argument Definitions} *)
167167+168168+(** [handle_arg] is the positional argument for a contact handle. *)
169169+val handle_arg : string Cmdliner.Term.t
170170+171171+(** [query_arg] is the positional argument for a search query. *)
172172+val query_arg : string Cmdliner.Term.t
173173+174174+(** [add_handle_arg] is the positional argument for a new contact handle. *)
175175+val add_handle_arg : string Cmdliner.Term.t
176176+177177+(** [add_names_arg] is the repeatable option for contact names. *)
178178+val add_names_arg : string list Cmdliner.Term.t
179179+180180+(** [add_kind_arg] is the optional argument for contact kind. *)
181181+val add_kind_arg : Contact.contact_kind option Cmdliner.Term.t
182182+183183+(** [add_email_arg] is the optional argument for email. *)
184184+val add_email_arg : string option Cmdliner.Term.t
185185+186186+(** [add_github_arg] is the optional argument for GitHub handle. *)
187187+val add_github_arg : string option Cmdliner.Term.t
188188+189189+(** [add_url_arg] is the optional argument for URL. *)
190190+val add_url_arg : string option Cmdliner.Term.t
191191+192192+(** [add_orcid_arg] is the optional argument for ORCID. *)
193193+val add_orcid_arg : string option Cmdliner.Term.t
194194+195195+(** [email_address_arg] is the positional argument for email address. *)
196196+val email_address_arg : string Cmdliner.Term.t
197197+198198+(** [email_type_arg] is the optional argument for email type. *)
199199+val email_type_arg : Contact.email_type option Cmdliner.Term.t
200200+201201+(** [date_arg name] creates a date argument with the given option name. *)
202202+val date_arg : string -> string option Cmdliner.Term.t
203203+204204+(** [note_arg] is the optional argument for notes. *)
205205+val note_arg : string option Cmdliner.Term.t
206206+207207+(** [service_url_arg] is the positional argument for service URL. *)
208208+val service_url_arg : string Cmdliner.Term.t
209209+210210+(** [service_kind_arg] is the optional argument for service kind. *)
211211+val service_kind_arg : Contact.service_kind option Cmdliner.Term.t
212212+213213+(** [service_handle_arg] is the optional argument for service handle. *)
214214+val service_handle_arg : string option Cmdliner.Term.t
215215+216216+(** [label_arg] is the optional argument for labels. *)
217217+val label_arg : string option Cmdliner.Term.t
218218+219219+(** [org_name_arg] is the positional argument for organization name. *)
220220+val org_name_arg : string Cmdliner.Term.t
221221+222222+(** [org_title_arg] is the optional argument for job title. *)
223223+val org_title_arg : string option Cmdliner.Term.t
224224+225225+(** [org_department_arg] is the optional argument for department. *)
226226+val org_department_arg : string option Cmdliner.Term.t
227227+228228+(** [org_email_arg] is the optional argument for work email. *)
229229+val org_email_arg : string option Cmdliner.Term.t
230230+231231+(** [org_url_arg] is the optional argument for work URL. *)
232232+val org_url_arg : string option Cmdliner.Term.t
233233+234234+(** [url_value_arg] is the positional argument for URL. *)
235235+val url_value_arg : string Cmdliner.Term.t
+233
lib/core/sortal_git_store.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+module Contact = Sortal_schema.Contact
77+88+type t = {
99+ store : Sortal_store.t;
1010+ env : Eio_unix.Stdenv.base;
1111+}
1212+1313+let create store env = { store; env }
1414+1515+let store t = t.store
1616+1717+(* Helper to check if a string contains a substring *)
1818+let contains_substring ~needle haystack =
1919+ try
2020+ let _ = Str.search_forward (Str.regexp_string needle) haystack 0 in
2121+ true
2222+ with Not_found -> false
2323+2424+(* Helper to get the data directory path as a native string *)
2525+let data_dir_path t =
2626+ (* We need to extract the data directory from the store somehow.
2727+ For now, we'll use the XDG environment to locate it. *)
2828+ let xdg = Xdge.create t.env#fs "sortal" in
2929+ let data_path = Xdge.data_dir xdg in
3030+ Eio.Path.native_exn data_path
3131+3232+(* Execute a git command in the data directory *)
3333+let run_git t args =
3434+ let data_dir = data_dir_path t in
3535+ Eio.Switch.run @@ fun sw ->
3636+ try
3737+ let mgr = t.env#process_mgr in
3838+ let cmd = ["git"; "-C"; data_dir] @ args in
3939+ let proc = Eio.Process.spawn ~sw mgr cmd in
4040+ match Eio.Process.await proc with
4141+ | `Exited 0 -> Ok ()
4242+ | `Exited n -> Error (Printf.sprintf "git %s exited with code %d" (String.concat " " args) n)
4343+ | `Signaled n -> Error (Printf.sprintf "git killed by signal %d" n)
4444+ with
4545+ | exn ->
4646+ let msg = Printexc.to_string exn in
4747+ if contains_substring ~needle:"not found" msg ||
4848+ contains_substring ~needle:"No such file" msg then
4949+ Error "git executable not found - please install git"
5050+ else
5151+ Error (Printf.sprintf "git command failed: %s" msg)
5252+5353+let is_initialized t =
5454+ let data_dir = data_dir_path t in
5555+ let git_dir = Filename.concat data_dir ".git" in
5656+ Sys.file_exists git_dir && Sys.is_directory git_dir
5757+5858+let init t =
5959+ if is_initialized t then
6060+ Ok ()
6161+ else begin
6262+ match run_git t ["init"] with
6363+ | Error _ as e -> e
6464+ | Ok () ->
6565+ (* Create initial commit *)
6666+ match run_git t ["add"; "."] with
6767+ | Error _ as e -> e
6868+ | Ok () ->
6969+ let msg = "Initialize sortal contact database" in
7070+ run_git t ["commit"; "--allow-empty"; "-m"; msg]
7171+ end
7272+7373+(* Helper to commit a file with a message *)
7474+let commit_file t filename msg =
7575+ match run_git t ["add"; filename] with
7676+ | Error _ as e -> e
7777+ | Ok () ->
7878+ run_git t ["commit"; "-m"; msg]
7979+8080+(* Helper to commit a deletion *)
8181+let commit_deletion t filename msg =
8282+ match run_git t ["rm"; filename] with
8383+ | Error _ as e -> e
8484+ | Ok () ->
8585+ run_git t ["commit"; "-m"; msg]
8686+8787+let save t contact =
8888+ let handle = Contact.handle contact in
8989+ let name = Contact.name contact in
9090+ let filename = handle ^ ".yaml" in
9191+9292+ (* Check if contact already exists *)
9393+ let is_new = match Sortal_store.lookup t.store handle with
9494+ | None -> true
9595+ | Some _ -> false
9696+ in
9797+9898+ (* Save to store *)
9999+ Sortal_store.save t.store contact;
100100+101101+ (* Commit to git *)
102102+ if not (is_initialized t) then
103103+ Ok ()
104104+ else
105105+ let msg = if is_new then
106106+ Printf.sprintf "Add contact @%s (%s)" handle name
107107+ else
108108+ Printf.sprintf "Update contact @%s (%s)" handle name
109109+ in
110110+ commit_file t filename msg
111111+112112+let delete t handle =
113113+ match Sortal_store.lookup t.store handle with
114114+ | None -> Error (Printf.sprintf "Contact not found: %s" handle)
115115+ | Some contact ->
116116+ let name = Contact.name contact in
117117+ let filename = handle ^ ".yaml" in
118118+119119+ (* Delete from store *)
120120+ Sortal_store.delete t.store handle;
121121+122122+ (* Commit deletion to git *)
123123+ if not (is_initialized t) then
124124+ Ok ()
125125+ else
126126+ let msg = Printf.sprintf "Delete contact @%s (%s)" handle name in
127127+ commit_deletion t filename msg
128128+129129+let update_contact t handle f ~msg =
130130+ match Sortal_store.update_contact t.store handle f with
131131+ | Error _ as e -> e
132132+ | Ok () ->
133133+ if not (is_initialized t) then
134134+ Ok ()
135135+ else
136136+ let filename = handle ^ ".yaml" in
137137+ commit_file t filename msg
138138+139139+let add_email t handle (email : Contact.email) =
140140+ let msg = Printf.sprintf "Update @%s: add email %s"
141141+ handle email.address in
142142+ match Sortal_store.add_email t.store handle email with
143143+ | Error _ as e -> e
144144+ | Ok () ->
145145+ if not (is_initialized t) then
146146+ Ok ()
147147+ else
148148+ let filename = handle ^ ".yaml" in
149149+ commit_file t filename msg
150150+151151+let remove_email t handle address =
152152+ let msg = Printf.sprintf "Update @%s: remove email %s" handle address in
153153+ match Sortal_store.remove_email t.store handle address with
154154+ | Error _ as e -> e
155155+ | Ok () ->
156156+ if not (is_initialized t) then
157157+ Ok ()
158158+ else
159159+ let filename = handle ^ ".yaml" in
160160+ commit_file t filename msg
161161+162162+let add_service t handle (service : Contact.service) =
163163+ let kind_str = match service.kind with
164164+ | Some k -> Contact.service_kind_to_string k
165165+ | None -> "unknown"
166166+ in
167167+ let msg = Printf.sprintf "Update @%s: add service %s (%s)"
168168+ handle kind_str service.url in
169169+ match Sortal_store.add_service t.store handle service with
170170+ | Error _ as e -> e
171171+ | Ok () ->
172172+ if not (is_initialized t) then
173173+ Ok ()
174174+ else
175175+ let filename = handle ^ ".yaml" in
176176+ commit_file t filename msg
177177+178178+let remove_service t handle url =
179179+ let msg = Printf.sprintf "Update @%s: remove service %s" handle url in
180180+ match Sortal_store.remove_service t.store handle url with
181181+ | Error _ as e -> e
182182+ | Ok () ->
183183+ if not (is_initialized t) then
184184+ Ok ()
185185+ else
186186+ let filename = handle ^ ".yaml" in
187187+ commit_file t filename msg
188188+189189+let add_organization t handle (org : Contact.organization) =
190190+ let msg = Printf.sprintf "Update @%s: add organization %s"
191191+ handle org.name in
192192+ match Sortal_store.add_organization t.store handle org with
193193+ | Error _ as e -> e
194194+ | Ok () ->
195195+ if not (is_initialized t) then
196196+ Ok ()
197197+ else
198198+ let filename = handle ^ ".yaml" in
199199+ commit_file t filename msg
200200+201201+let remove_organization t handle name =
202202+ let msg = Printf.sprintf "Update @%s: remove organization %s" handle name in
203203+ match Sortal_store.remove_organization t.store handle name with
204204+ | Error _ as e -> e
205205+ | Ok () ->
206206+ if not (is_initialized t) then
207207+ Ok ()
208208+ else
209209+ let filename = handle ^ ".yaml" in
210210+ commit_file t filename msg
211211+212212+let add_url t handle (url_entry : Contact.url_entry) =
213213+ let msg = Printf.sprintf "Update @%s: add URL %s"
214214+ handle url_entry.url in
215215+ match Sortal_store.add_url t.store handle url_entry with
216216+ | Error _ as e -> e
217217+ | Ok () ->
218218+ if not (is_initialized t) then
219219+ Ok ()
220220+ else
221221+ let filename = handle ^ ".yaml" in
222222+ commit_file t filename msg
223223+224224+let remove_url t handle url =
225225+ let msg = Printf.sprintf "Update @%s: remove URL %s" handle url in
226226+ match Sortal_store.remove_url t.store handle url with
227227+ | Error _ as e -> e
228228+ | Ok () ->
229229+ if not (is_initialized t) then
230230+ Ok ()
231231+ else
232232+ let filename = handle ^ ".yaml" in
233233+ commit_file t filename msg
+116
lib/core/sortal_git_store.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Git-backed contact store with automatic version control.
77+88+ This module wraps {!Sortal_store} to provide automatic git versioning
99+ of all contact modifications. Each change (add, update, delete) is
1010+ automatically committed to a git repository with descriptive commit
1111+ messages. *)
1212+1313+module Contact = Sortal_schema.Contact
1414+1515+type t
1616+(** A git-backed contact store. *)
1717+1818+(** {1 Creation and Initialization} *)
1919+2020+val create : Sortal_store.t -> Eio_unix.Stdenv.base -> t
2121+(** [create store env] creates a git-backed store wrapping [store].
2222+2323+ @param store The underlying contact store
2424+ @param env The Eio environment for spawning git processes *)
2525+2626+val init : t -> (unit, string) result
2727+(** [init t] initializes a git repository in the data directory.
2828+2929+ Creates a new git repository with an initial commit if one doesn't exist.
3030+ Safe to call multiple times - returns [Ok ()] if already initialized.
3131+3232+ @return [Ok ()] if initialized successfully or already initialized,
3333+ [Error msg] if git initialization fails *)
3434+3535+val is_initialized : t -> bool
3636+(** [is_initialized t] checks if the data directory is a git repository.
3737+3838+ @return [true] if a .git directory exists, [false] otherwise *)
3939+4040+(** {1 Contact Operations} *)
4141+4242+val save : t -> Contact.t -> (unit, string) result
4343+(** [save t contact] saves a contact and commits the change to git.
4444+4545+ If the contact is new, commits with message "Add contact @handle (Name)".
4646+ If updating an existing contact, commits with "Update contact @handle (Name)".
4747+4848+ @param contact The contact to save *)
4949+5050+val delete : t -> string -> (unit, string) result
5151+(** [delete t handle] deletes a contact and commits the removal to git.
5252+5353+ Commits with message "Delete contact @handle (Name)".
5454+5555+ @param handle The contact handle to delete
5656+ @return [Error msg] if contact not found *)
5757+5858+(** {1 Contact Modification} *)
5959+6060+val add_email : t -> string -> Contact.email -> (unit, string) result
6161+(** [add_email t handle email] adds an email to a contact and commits.
6262+6363+ Commits with message "Update @handle: add email address@example.com". *)
6464+6565+val remove_email : t -> string -> string -> (unit, string) result
6666+(** [remove_email t handle address] removes an email and commits.
6767+6868+ Commits with message "Update @handle: remove email address@example.com". *)
6969+7070+val add_service : t -> string -> Contact.service -> (unit, string) result
7171+(** [add_service t handle service] adds a service to a contact and commits.
7272+7373+ Commits with message "Update @handle: add service Kind (url)". *)
7474+7575+val remove_service : t -> string -> string -> (unit, string) result
7676+(** [remove_service t handle url] removes a service and commits.
7777+7878+ Commits with message "Update @handle: remove service url". *)
7979+8080+val add_organization : t -> string -> Contact.organization -> (unit, string) result
8181+(** [add_organization t handle org] adds an organization and commits.
8282+8383+ Commits with message "Update @handle: add organization Org Name". *)
8484+8585+val remove_organization : t -> string -> string -> (unit, string) result
8686+(** [remove_organization t handle name] removes an organization and commits.
8787+8888+ Commits with message "Update @handle: remove organization Org Name". *)
8989+9090+val add_url : t -> string -> Contact.url_entry -> (unit, string) result
9191+(** [add_url t handle url_entry] adds a URL and commits.
9292+9393+ Commits with message "Update @handle: add URL url". *)
9494+9595+val remove_url : t -> string -> string -> (unit, string) result
9696+(** [remove_url t handle url] removes a URL and commits.
9797+9898+ Commits with message "Update @handle: remove URL url". *)
9999+100100+(** {1 Low-level Operations} *)
101101+102102+val update_contact : t -> string -> (Contact.t -> Contact.t) ->
103103+ msg:string -> (unit, string) result
104104+(** [update_contact t handle f ~msg] updates a contact and commits with custom message.
105105+106106+ This is a low-level function that applies transformation [f] to the contact
107107+ and commits with the provided commit message.
108108+109109+ @param handle The contact handle
110110+ @param f Function to transform the contact
111111+ @param msg The git commit message *)
112112+113113+val store : t -> Sortal_store.t
114114+(** [store t] returns the underlying contact store.
115115+116116+ Use this when you need direct store access without git commits. *)
+370
lib/core/sortal_store.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+module Contact = Sortal_schema.Contact
77+module Temporal = Sortal_schema.Temporal
88+99+type t = {
1010+ xdg : Xdge.t; [@warning "-69"]
1111+ data_dir : Eio.Fs.dir_ty Eio.Path.t;
1212+}
1313+1414+let create fs app_name =
1515+ let xdg = Xdge.create fs app_name in
1616+ let data_dir = Xdge.data_dir xdg in
1717+ { xdg; data_dir }
1818+1919+let create_from_xdg xdg =
2020+ let data_dir = Xdge.data_dir xdg in
2121+ { xdg; data_dir }
2222+2323+let contact_file t handle =
2424+ Eio.Path.(t.data_dir / (handle ^ ".yaml"))
2525+2626+let save t contact =
2727+ let path = contact_file t (Contact.handle contact) in
2828+ let buf = Buffer.create 4096 in
2929+ let writer = Bytesrw.Bytes.Writer.of_buffer buf in
3030+ match Yamlt.encode Contact.json_t contact ~eod:true writer with
3131+ | Ok () -> Eio.Path.save ~create:(`Or_truncate 0o644) path (Buffer.contents buf)
3232+ | Error err -> failwith ("Failed to encode contact: " ^ err)
3333+3434+let lookup t handle =
3535+ let path = contact_file t handle in
3636+ try
3737+ let yaml_str = Eio.Path.load path in
3838+ let reader = Bytesrw.Bytes.Reader.of_string yaml_str in
3939+ match Yamlt.decode Contact.json_t reader with
4040+ | Ok contact -> Some contact
4141+ | Error msg ->
4242+ Logs.warn (fun m -> m "Failed to decode contact %s: %s" handle msg);
4343+ None
4444+ with exn ->
4545+ Logs.warn (fun m -> m "Failed to load contact %s: %s" handle (Printexc.to_string exn));
4646+ None
4747+4848+let delete t handle =
4949+ let path = contact_file t handle in
5050+ try
5151+ Eio.Path.unlink path
5252+ with
5353+ | _ -> ()
5454+5555+(* Contact modification helpers *)
5656+let update_contact t handle f =
5757+ match lookup t handle with
5858+ | None -> Error (Printf.sprintf "Contact not found: %s" handle)
5959+ | Some contact ->
6060+ let updated = f contact in
6161+ save t updated;
6262+ Ok ()
6363+6464+let add_email t handle (email : Contact.email) =
6565+ match lookup t handle with
6666+ | None -> Error (Printf.sprintf "Contact not found: %s" handle)
6767+ | Some contact ->
6868+ let emails = Contact.emails contact in
6969+ (* Check for duplicate email address *)
7070+ if List.exists (fun (e : Contact.email) -> e.address = email.address) emails then
7171+ Error (Printf.sprintf "Email %s already exists for contact @%s" email.address handle)
7272+ else
7373+ update_contact t handle (fun contact ->
7474+ let emails = Contact.emails contact in
7575+ Contact.make
7676+ ~handle:(Contact.handle contact)
7777+ ~names:(Contact.names contact)
7878+ ~kind:(Contact.kind contact)
7979+ ~emails:(emails @ [email])
8080+ ~organizations:(Contact.organizations contact)
8181+ ~urls:(Contact.urls contact)
8282+ ~services:(Contact.services contact)
8383+ ?icon:(Contact.icon contact)
8484+ ?thumbnail:(Contact.thumbnail contact)
8585+ ?orcid:(Contact.orcid contact)
8686+ ?feeds:(Contact.feeds contact)
8787+ ()
8888+ )
8989+9090+let remove_email t handle address =
9191+ update_contact t handle (fun contact ->
9292+ let emails = Contact.emails contact
9393+ |> List.filter (fun (e : Contact.email) -> e.address <> address) in
9494+ Contact.make
9595+ ~handle:(Contact.handle contact)
9696+ ~names:(Contact.names contact)
9797+ ~kind:(Contact.kind contact)
9898+ ~emails
9999+ ~organizations:(Contact.organizations contact)
100100+ ~urls:(Contact.urls contact)
101101+ ~services:(Contact.services contact)
102102+ ?icon:(Contact.icon contact)
103103+ ?thumbnail:(Contact.thumbnail contact)
104104+ ?orcid:(Contact.orcid contact)
105105+ ?feeds:(Contact.feeds contact)
106106+ ()
107107+ )
108108+109109+let add_service t handle (service : Contact.service) =
110110+ match lookup t handle with
111111+ | None -> Error (Printf.sprintf "Contact not found: %s" handle)
112112+ | Some contact ->
113113+ let services = Contact.services contact in
114114+ (* Check for duplicate service URL *)
115115+ if List.exists (fun (s : Contact.service) -> s.url = service.url) services then
116116+ Error (Printf.sprintf "Service URL %s already exists for contact @%s" service.url handle)
117117+ else
118118+ update_contact t handle (fun contact ->
119119+ let services = Contact.services contact in
120120+ Contact.make
121121+ ~handle:(Contact.handle contact)
122122+ ~names:(Contact.names contact)
123123+ ~kind:(Contact.kind contact)
124124+ ~emails:(Contact.emails contact)
125125+ ~organizations:(Contact.organizations contact)
126126+ ~urls:(Contact.urls contact)
127127+ ~services:(services @ [service])
128128+ ?icon:(Contact.icon contact)
129129+ ?thumbnail:(Contact.thumbnail contact)
130130+ ?orcid:(Contact.orcid contact)
131131+ ?feeds:(Contact.feeds contact)
132132+ ()
133133+ )
134134+135135+let remove_service t handle url =
136136+ update_contact t handle (fun contact ->
137137+ let services = Contact.services contact
138138+ |> List.filter (fun (s : Contact.service) -> s.url <> url) in
139139+ Contact.make
140140+ ~handle:(Contact.handle contact)
141141+ ~names:(Contact.names contact)
142142+ ~kind:(Contact.kind contact)
143143+ ~emails:(Contact.emails contact)
144144+ ~organizations:(Contact.organizations contact)
145145+ ~urls:(Contact.urls contact)
146146+ ~services
147147+ ?icon:(Contact.icon contact)
148148+ ?thumbnail:(Contact.thumbnail contact)
149149+ ?orcid:(Contact.orcid contact)
150150+ ?feeds:(Contact.feeds contact)
151151+ ()
152152+ )
153153+154154+let add_organization t handle (org : Contact.organization) =
155155+ match lookup t handle with
156156+ | None -> Error (Printf.sprintf "Contact not found: %s" handle)
157157+ | Some contact ->
158158+ let orgs = Contact.organizations contact in
159159+ (* Check for exact duplicate organization (same name, title, and department) *)
160160+ let is_duplicate = List.exists (fun (o : Contact.organization) ->
161161+ o.name = org.name &&
162162+ o.title = org.title &&
163163+ o.department = org.department
164164+ ) orgs in
165165+ if is_duplicate then
166166+ Error (Printf.sprintf "Organization %s with the same title/department already exists for contact @%s" org.name handle)
167167+ else
168168+ update_contact t handle (fun contact ->
169169+ let orgs = Contact.organizations contact in
170170+ Contact.make
171171+ ~handle:(Contact.handle contact)
172172+ ~names:(Contact.names contact)
173173+ ~kind:(Contact.kind contact)
174174+ ~emails:(Contact.emails contact)
175175+ ~organizations:(orgs @ [org])
176176+ ~urls:(Contact.urls contact)
177177+ ~services:(Contact.services contact)
178178+ ?icon:(Contact.icon contact)
179179+ ?thumbnail:(Contact.thumbnail contact)
180180+ ?orcid:(Contact.orcid contact)
181181+ ?feeds:(Contact.feeds contact)
182182+ ()
183183+ )
184184+185185+let remove_organization t handle name =
186186+ update_contact t handle (fun contact ->
187187+ let orgs = Contact.organizations contact
188188+ |> List.filter (fun (o : Contact.organization) -> o.name <> name) in
189189+ Contact.make
190190+ ~handle:(Contact.handle contact)
191191+ ~names:(Contact.names contact)
192192+ ~kind:(Contact.kind contact)
193193+ ~emails:(Contact.emails contact)
194194+ ~organizations:orgs
195195+ ~urls:(Contact.urls contact)
196196+ ~services:(Contact.services contact)
197197+ ?icon:(Contact.icon contact)
198198+ ?thumbnail:(Contact.thumbnail contact)
199199+ ?orcid:(Contact.orcid contact)
200200+ ?feeds:(Contact.feeds contact)
201201+ ()
202202+ )
203203+204204+let add_url t handle (url_entry : Contact.url_entry) =
205205+ match lookup t handle with
206206+ | None -> Error (Printf.sprintf "Contact not found: %s" handle)
207207+ | Some contact ->
208208+ let urls = Contact.urls contact in
209209+ (* Check for duplicate URL *)
210210+ if List.exists (fun (u : Contact.url_entry) -> u.url = url_entry.url) urls then
211211+ Error (Printf.sprintf "URL %s already exists for contact @%s" url_entry.url handle)
212212+ else
213213+ update_contact t handle (fun contact ->
214214+ let urls = Contact.urls contact in
215215+ Contact.make
216216+ ~handle:(Contact.handle contact)
217217+ ~names:(Contact.names contact)
218218+ ~kind:(Contact.kind contact)
219219+ ~emails:(Contact.emails contact)
220220+ ~organizations:(Contact.organizations contact)
221221+ ~urls:(urls @ [url_entry])
222222+ ~services:(Contact.services contact)
223223+ ?icon:(Contact.icon contact)
224224+ ?thumbnail:(Contact.thumbnail contact)
225225+ ?orcid:(Contact.orcid contact)
226226+ ?feeds:(Contact.feeds contact)
227227+ ()
228228+ )
229229+230230+let remove_url t handle url =
231231+ update_contact t handle (fun contact ->
232232+ let urls = Contact.urls contact
233233+ |> List.filter (fun (u : Contact.url_entry) -> u.url <> url) in
234234+ Contact.make
235235+ ~handle:(Contact.handle contact)
236236+ ~names:(Contact.names contact)
237237+ ~kind:(Contact.kind contact)
238238+ ~emails:(Contact.emails contact)
239239+ ~organizations:(Contact.organizations contact)
240240+ ~urls
241241+ ~services:(Contact.services contact)
242242+ ?icon:(Contact.icon contact)
243243+ ?thumbnail:(Contact.thumbnail contact)
244244+ ?orcid:(Contact.orcid contact)
245245+ ?feeds:(Contact.feeds contact)
246246+ ()
247247+ )
248248+249249+let list t =
250250+ try
251251+ let entries = Eio.Path.read_dir t.data_dir in
252252+ List.filter_map (fun entry ->
253253+ if Filename.check_suffix entry ".yaml" then
254254+ let handle = Filename.chop_suffix entry ".yaml" in
255255+ lookup t handle
256256+ else
257257+ None
258258+ ) entries
259259+ with
260260+ | _ -> []
261261+262262+let thumbnail_path t contact =
263263+ Contact.thumbnail contact
264264+ |> Option.map (fun relative_path -> Eio.Path.(t.data_dir / relative_path))
265265+266266+let png_thumbnail_path t contact =
267267+ match Contact.thumbnail contact with
268268+ | None -> None
269269+ | Some relative_path ->
270270+ let base = Filename.remove_extension relative_path in
271271+ let png_path = base ^ ".png" in
272272+ let full_path = Eio.Path.(t.data_dir / png_path) in
273273+ try
274274+ ignore (Eio.Path.load full_path);
275275+ Some full_path
276276+ with _ -> None
277277+278278+let handle_of_name name =
279279+ let name = String.lowercase_ascii name in
280280+ let words = String.split_on_char ' ' name in
281281+ let initials = String.concat "" (List.map (fun w -> String.sub w 0 1) words) in
282282+ initials ^ List.hd (List.rev words)
283283+284284+let find_by_name t name =
285285+ let name_lower = String.lowercase_ascii name in
286286+ let all_contacts = list t in
287287+ let matches = List.filter (fun c ->
288288+ List.exists (fun n -> String.lowercase_ascii n = name_lower)
289289+ (Contact.names c)
290290+ ) all_contacts in
291291+ match matches with
292292+ | [contact] -> contact
293293+ | [] -> raise Not_found
294294+ | _ -> raise (Invalid_argument ("Multiple contacts match: " ^ name))
295295+296296+let find_by_name_opt t name =
297297+ try
298298+ Some (find_by_name t name)
299299+ with
300300+ | Not_found | Invalid_argument _ -> None
301301+302302+let contains_substring ~needle haystack =
303303+ let needle_len = String.length needle in
304304+ let haystack_len = String.length haystack in
305305+ if needle_len = 0 then true
306306+ else if needle_len > haystack_len then false
307307+ else
308308+ let rec check i =
309309+ if i > haystack_len - needle_len then false
310310+ else if String.sub haystack i needle_len = needle then true
311311+ else check (i + 1)
312312+ in
313313+ check 0
314314+315315+let search_all t query =
316316+ let query_lower = String.lowercase_ascii query in
317317+ let all = list t in
318318+ let matches = List.filter (fun c ->
319319+ List.exists (fun name ->
320320+ let name_lower = String.lowercase_ascii name in
321321+ String.equal name_lower query_lower ||
322322+ String.starts_with ~prefix:query_lower name_lower ||
323323+ contains_substring ~needle:query_lower name_lower ||
324324+ (String.contains name_lower ' ' &&
325325+ String.split_on_char ' ' name_lower |> List.exists (fun word ->
326326+ String.starts_with ~prefix:query_lower word
327327+ ))
328328+ ) (Contact.names c)
329329+ ) all in
330330+ List.sort Contact.compare matches
331331+332332+let find_by_email_at t ~email ~date =
333333+ let all = list t in
334334+ List.find_opt (fun c ->
335335+ let emails_at_date = Contact.emails_at c ~date in
336336+ List.exists (fun e -> e.Contact.address = email) emails_at_date
337337+ ) all
338338+339339+let find_by_org t ~org ?from ?until () =
340340+ let org_lower = String.lowercase_ascii org in
341341+ let all = list t in
342342+ let matches = List.filter (fun c ->
343343+ let orgs : Contact.organization list = Contact.organizations c in
344344+ let filtered_orgs = match from, until with
345345+ | None, None -> orgs
346346+ | _, _ -> Temporal.filter ~get:(fun (o : Contact.organization) -> o.range)
347347+ ~from ~until orgs
348348+ in
349349+ List.exists (fun (o : Contact.organization) ->
350350+ contains_substring ~needle:org_lower
351351+ (String.lowercase_ascii o.name)
352352+ ) filtered_orgs
353353+ ) all in
354354+ List.sort Contact.compare matches
355355+356356+let list_at t ~date =
357357+ let all = list t in
358358+ List.filter (fun c ->
359359+ (* Contact is active if it has any email, org, or URL valid at date *)
360360+ let has_email = Contact.emails_at c ~date <> [] in
361361+ let has_org = Contact.organization_at c ~date <> None in
362362+ let has_url = Contact.url_at c ~date <> None in
363363+ has_email || has_org || has_url
364364+ ) all
365365+366366+let pp ppf t =
367367+ let all = list t in
368368+ Fmt.pf ppf "@[<v>%a: %d contacts stored in XDG data directory@]"
369369+ (Fmt.styled `Bold Fmt.string) "Sortal Store"
370370+ (List.length all)
+261
lib/core/sortal_store.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Contact store with XDG-compliant storage.
77+88+ The contact store manages reading and writing contact metadata
99+ using XDG-compliant storage locations. Contacts are stored as
1010+ YAML files (one per contact) using the handle as the filename. *)
1111+1212+module Contact = Sortal_schema.Contact
1313+module Temporal = Sortal_schema.Temporal
1414+1515+type t
1616+1717+(** [create fs app_name] creates a new contact store.
1818+1919+ The store will use XDG data directories for persistent storage
2020+ of contact metadata. Each contact is stored as a separate YAML
2121+ file named after its handle.
2222+2323+ @param fs Eio filesystem for file operations
2424+ @param app_name Application name for XDG directory structure *)
2525+val create : Eio.Fs.dir_ty Eio.Path.t -> string -> t
2626+2727+(** [create_from_xdg xdg] creates a contact store from an XDG context.
2828+2929+ This is a convenience function for creating a store when you already
3030+ have an XDG context (e.g., from your own XDG initialization).
3131+ The store will use the XDG data directory for the application.
3232+3333+ @param xdg An existing XDG context
3434+ @return A contact store using the XDG data directory *)
3535+val create_from_xdg : Xdge.t -> t
3636+3737+(** {1 Storage Operations} *)
3838+3939+(** [save t contact] saves a contact to the store.
4040+4141+ The contact is serialized to YAML and written to a file
4242+ named "handle.yaml" in the XDG data directory.
4343+4444+ If a contact with the same handle already exists, it is overwritten. *)
4545+val save : t -> Contact.t -> unit
4646+4747+(** [lookup t handle] retrieves a contact by handle.
4848+4949+ Searches for a file named "handle.yaml" in the XDG data directory
5050+ and deserializes it if found.
5151+5252+ @return [Some contact] if found, [None] if not found or deserialization fails *)
5353+val lookup : t -> string -> Contact.t option
5454+5555+(** [delete t handle] removes a contact from the store.
5656+5757+ Deletes the file "handle.yaml" from the XDG data directory.
5858+ Does nothing if the contact does not exist. *)
5959+val delete : t -> string -> unit
6060+6161+(** {1 Contact Modification} *)
6262+6363+(** [add_email t handle email] adds an email to an existing contact.
6464+6565+ @param t The store
6666+ @param handle The contact handle
6767+ @param email The email entry to add
6868+ @return [Ok ()] on success, [Error msg] if contact not found
6969+ @raise Failure if the contact cannot be saved *)
7070+val add_email : t -> string -> Contact.email -> (unit, string) result
7171+7272+(** [remove_email t handle address] removes an email from a contact.
7373+7474+ Removes all email entries with the given address.
7575+7676+ @param t The store
7777+ @param handle The contact handle
7878+ @param address The email address to remove
7979+ @return [Ok ()] on success, [Error msg] if contact not found *)
8080+val remove_email : t -> string -> string -> (unit, string) result
8181+8282+(** [add_service t handle service] adds a service to an existing contact.
8383+8484+ @param t The store
8585+ @param handle The contact handle
8686+ @param service The service entry to add
8787+ @return [Ok ()] on success, [Error msg] if contact not found *)
8888+val add_service : t -> string -> Contact.service -> (unit, string) result
8989+9090+(** [remove_service t handle url] removes a service from a contact.
9191+9292+ Removes all service entries with the given URL.
9393+9494+ @param t The store
9595+ @param handle The contact handle
9696+ @param url The service URL to remove
9797+ @return [Ok ()] on success, [Error msg] if contact not found *)
9898+val remove_service : t -> string -> string -> (unit, string) result
9999+100100+(** [add_organization t handle org] adds an organization to an existing contact.
101101+102102+ @param t The store
103103+ @param handle The contact handle
104104+ @param org The organization entry to add
105105+ @return [Ok ()] on success, [Error msg] if contact not found *)
106106+val add_organization : t -> string -> Contact.organization -> (unit, string) result
107107+108108+(** [remove_organization t handle name] removes an organization from a contact.
109109+110110+ Removes all organization entries with the given name.
111111+112112+ @param t The store
113113+ @param handle The contact handle
114114+ @param name The organization name to remove
115115+ @return [Ok ()] on success, [Error msg] if contact not found *)
116116+val remove_organization : t -> string -> string -> (unit, string) result
117117+118118+(** [add_url t handle url_entry] adds a URL to an existing contact.
119119+120120+ @param t The store
121121+ @param handle The contact handle
122122+ @param url_entry The URL entry to add
123123+ @return [Ok ()] on success, [Error msg] if contact not found *)
124124+val add_url : t -> string -> Contact.url_entry -> (unit, string) result
125125+126126+(** [remove_url t handle url] removes a URL from a contact.
127127+128128+ Removes all URL entries with the given URL.
129129+130130+ @param t The store
131131+ @param handle The contact handle
132132+ @param url The URL to remove
133133+ @return [Ok ()] on success, [Error msg] if contact not found *)
134134+val remove_url : t -> string -> string -> (unit, string) result
135135+136136+(** [update_contact t handle f] updates a contact by applying function [f].
137137+138138+ Looks up the contact, applies [f] to transform it, and saves the result.
139139+140140+ @param t The store
141141+ @param handle The contact handle
142142+ @param f Function to transform the contact
143143+ @return [Ok ()] on success, [Error msg] if contact not found *)
144144+val update_contact : t -> string -> (Contact.t -> Contact.t) -> (unit, string) result
145145+146146+(** [list t] returns all contacts in the store.
147147+148148+ Scans the XDG data directory for all .yaml files and attempts
149149+ to deserialize them as contacts. Files that fail to parse are
150150+ silently skipped.
151151+152152+ @return A list of all successfully loaded contacts *)
153153+val list : t -> Contact.t list
154154+155155+(** [thumbnail_path t contact] returns the absolute filesystem path to the contact's thumbnail.
156156+157157+ Returns [None] if the contact has no thumbnail set, or [Some path] with
158158+ the full path to the thumbnail file in Sortal's data directory.
159159+160160+ @param t The Sortal store
161161+ @param contact The contact whose thumbnail path to retrieve *)
162162+val thumbnail_path : t -> Contact.t -> Eio.Fs.dir_ty Eio.Path.t option
163163+164164+(** [png_thumbnail_path t contact] returns the path to the PNG version of the contact's thumbnail.
165165+166166+ Returns [None] if the contact has no thumbnail set or if no PNG version exists.
167167+ This looks for a .png file with the same base name as the contact's thumbnail.
168168+ Use this after running [sync] to get the converted PNG thumbnails.
169169+170170+ @param t The Sortal store
171171+ @param contact The contact whose PNG thumbnail path to retrieve *)
172172+val png_thumbnail_path : t -> Contact.t -> Eio.Fs.dir_ty Eio.Path.t option
173173+174174+(** {1 Searching} *)
175175+176176+(** [find_by_name t name] searches for contacts by name.
177177+178178+ Performs a case-insensitive search through all contacts,
179179+ checking if any of their names match the provided name.
180180+181181+ @param name The name to search for (case-insensitive)
182182+ @return The matching contact if exactly one match is found
183183+ @raise Not_found if no contacts match the name
184184+ @raise Invalid_argument if multiple contacts match the name *)
185185+val find_by_name : t -> string -> Contact.t
186186+187187+(** [find_by_name_opt t name] searches for contacts by name, returning an option.
188188+189189+ Like {!find_by_name} but returns [None] instead of raising exceptions
190190+ when no match or multiple matches are found.
191191+192192+ @param name The name to search for (case-insensitive)
193193+ @return [Some contact] if exactly one match is found, [None] otherwise *)
194194+val find_by_name_opt : t -> string -> Contact.t option
195195+196196+(** [search_all t query] searches for contacts matching a query string.
197197+198198+ Performs a flexible search through all contact names, looking for:
199199+ - Exact matches (case-insensitive)
200200+ - Names that start with the query
201201+ - Multi-word names where any word starts with the query
202202+203203+ This is useful for autocomplete or fuzzy search functionality.
204204+205205+ @param t The contact store
206206+ @param query The search query (case-insensitive)
207207+ @return A list of matching contacts, sorted by handle *)
208208+val search_all : t -> string -> Contact.t list
209209+210210+(** {1 Temporal Queries} *)
211211+212212+(** [find_by_email_at t ~email ~date] finds a contact by email address at a specific date.
213213+214214+ Searches for a contact that had the given email address valid at [date].
215215+216216+ @param email Email address to search for
217217+ @param date ISO 8601 date string
218218+ @return The first matching contact, or [None] if not found *)
219219+val find_by_email_at : t -> email:string -> date:Temporal.date ->
220220+ Contact.t option
221221+222222+(** [find_by_org t ~org ?from ?until ()] finds contacts who worked at an organization.
223223+224224+ Searches for contacts whose organization records overlap with the given period.
225225+ If [from] and [until] are omitted, returns all contacts who ever worked there.
226226+227227+ @param org Organization name (case-insensitive substring match)
228228+ @param from Start date of period to check (inclusive, optional)
229229+ @param until End date of period to check (exclusive, optional)
230230+ @return List of matching contacts, sorted by handle *)
231231+val find_by_org : t -> org:string -> ?from:Temporal.date ->
232232+ ?until:Temporal.date -> unit -> Contact.t list
233233+234234+(** [list_at t ~date] returns contacts that were active at a specific date.
235235+236236+ A contact is considered active at a date if it has at least one
237237+ email, organization, or URL valid at that date.
238238+239239+ @param date ISO 8601 date string
240240+ @return List of active contacts at that date *)
241241+val list_at : t -> date:Temporal.date -> Contact.t list
242242+243243+(** {1 Utilities} *)
244244+245245+(** [handle_of_name name] generates a handle from a full name.
246246+247247+ Creates a handle by concatenating the initials of all words
248248+ in the name with the full last name, all in lowercase.
249249+250250+ Examples:
251251+ - "Anil Madhavapeddy" -> "ammadhavapeddy"
252252+ - "John Smith" -> "jssmith"
253253+254254+ @param name The full name to convert
255255+ @return A suggested handle *)
256256+val handle_of_name : string -> string
257257+258258+(** {1 Pretty Printing} *)
259259+260260+(** [pp ppf t] pretty prints the contact store showing statistics. *)
261261+val pp : Format.formatter -> t -> unit
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Sortal Schema - Versioned data types and serialization
77+88+ This library provides versioned schema definitions for contact metadata
99+ with minimal I/O dependencies. It includes:
1010+ - Temporal validity support (ISO 8601 dates and ranges)
1111+ - Feed subscription types
1212+ - Contact metadata schemas (versioned)
1313+1414+ The schema library depends on jsont, yamlt, bytesrw, fmt for serialization
1515+ and formatting, plus ptime and ptime.clock.os for date/time operations. *)
1616+1717+(** {1 Schema Version 1} *)
1818+1919+module V1 : sig
2020+ (** Version 1 of the contact schema (current stable version). *)
2121+2222+ (** Temporal validity support for time-bounded fields. *)
2323+ module Temporal = Sortal_schema_temporal
2424+2525+ (** Feed subscription metadata. *)
2626+ module Feed = Sortal_schema_feed
2727+2828+ (** Contact metadata with temporal support. *)
2929+ module Contact = Sortal_schema_contact_v1
3030+end
3131+3232+(** {1 Current Version Aliases}
3333+3434+ These aliases point to the current stable schema version (V1).
3535+ When V2 is introduced, these will continue pointing to V1 for
3636+ backward compatibility. *)
3737+3838+module Temporal = V1.Temporal
3939+module Feed = V1.Feed
4040+module Contact = V1.Contact
+475
lib/schema/sortal_schema_contact_v1.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+let version = 1
77+88+type contact_kind = Person | Organization | Group | Role
99+1010+type service_kind =
1111+ | ActivityPub
1212+ | Github
1313+ | Git
1414+ | Social
1515+ | Photo
1616+ | Custom of string
1717+1818+type service = {
1919+ url: string;
2020+ kind: service_kind option;
2121+ handle: string option;
2222+ label: string option;
2323+ range: Sortal_schema_temporal.range option;
2424+ primary: bool;
2525+}
2626+2727+type email_type = Work | Personal | Other
2828+2929+type email = {
3030+ address: string;
3131+ type_: email_type option;
3232+ range: Sortal_schema_temporal.range option;
3333+ note: string option;
3434+}
3535+3636+type organization = {
3737+ name: string;
3838+ title: string option;
3939+ department: string option;
4040+ range: Sortal_schema_temporal.range option;
4141+ email: string option;
4242+ url: string option;
4343+}
4444+4545+type url_entry = {
4646+ url: string;
4747+ label: string option;
4848+ range: Sortal_schema_temporal.range option;
4949+}
5050+5151+type t = {
5252+ version: int;
5353+ kind: contact_kind;
5454+ handle: string;
5555+ names: string list;
5656+ emails: email list;
5757+ organizations: organization list;
5858+ urls: url_entry list;
5959+ services: service list;
6060+ icon: string option;
6161+ thumbnail: string option;
6262+ orcid: string option;
6363+ feeds: Sortal_schema_feed.t list option;
6464+}
6565+6666+(* Helpers *)
6767+let make_email ?type_ ?from ?until ?note address =
6868+ let range = match from, until with
6969+ | None, None -> None
7070+ | _, _ -> Some (Sortal_schema_temporal.make ?from ?until ())
7171+ in
7272+ { address; type_; range; note }
7373+7474+let email_of_string address =
7575+ { address; type_ = Some Personal; range = None; note = None }
7676+7777+let make_org ?title ?department ?from ?until ?email ?url name =
7878+ let range = match from, until with
7979+ | None, None -> None
8080+ | _, _ -> Some (Sortal_schema_temporal.make ?from ?until ())
8181+ in
8282+ { name; title; department; range; email; url }
8383+8484+let make_url ?label ?from ?until url =
8585+ let range = match from, until with
8686+ | None, None -> None
8787+ | _, _ -> Some (Sortal_schema_temporal.make ?from ?until ())
8888+ in
8989+ { url; label; range }
9090+9191+let url_of_string url =
9292+ { url; label = None; range = None }
9393+9494+let make_service ?kind ?handle ?label ?from ?until ?(primary = false) url =
9595+ let range = match from, until with
9696+ | None, None -> None
9797+ | _, _ -> Some (Sortal_schema_temporal.make ?from ?until ())
9898+ in
9999+ { url; kind; handle; label; range; primary }
100100+101101+let service_of_url url =
102102+ { url; kind = None; handle = None; label = None; range = None; primary = false }
103103+104104+let make ~handle ~names ?(kind = Person) ?(emails = []) ?(organizations = [])
105105+ ?(urls = []) ?(services = []) ?icon ?thumbnail ?orcid ?feeds () =
106106+ { version; kind; handle; names; emails; organizations; urls; services;
107107+ icon; thumbnail; orcid; feeds }
108108+109109+(* Accessors *)
110110+let version_of t = t.version
111111+let kind t = t.kind
112112+let handle t = t.handle
113113+let names t = t.names
114114+let name t = List.hd t.names
115115+let primary_name = name
116116+let emails t = t.emails
117117+let organizations t = t.organizations
118118+let urls t = t.urls
119119+let services t = t.services
120120+let icon t = t.icon
121121+let thumbnail t = t.thumbnail
122122+let orcid t = t.orcid
123123+let feeds t = t.feeds
124124+125125+(* Temporal queries *)
126126+let emails_at t ~date =
127127+ Sortal_schema_temporal.at_date ~get:(fun (e : email) -> e.range) ~date t.emails
128128+129129+let email_at t ~date =
130130+ match emails_at t ~date with
131131+ | e :: _ -> Some e.address
132132+ | [] -> None
133133+134134+let current_email t =
135135+ match Sortal_schema_temporal.current ~get:(fun (e : email) -> e.range) t.emails with
136136+ | Some e -> Some e.address
137137+ | None -> None
138138+139139+let organization_at t ~date =
140140+ match Sortal_schema_temporal.at_date ~get:(fun (o : organization) -> o.range) ~date t.organizations with
141141+ | o :: _ -> Some o
142142+ | [] -> None
143143+144144+let current_organization t =
145145+ Sortal_schema_temporal.current ~get:(fun (o : organization) -> o.range) t.organizations
146146+147147+let url_at t ~date =
148148+ match Sortal_schema_temporal.at_date ~get:(fun (u : url_entry) -> u.range) ~date t.urls with
149149+ | u :: _ -> Some u.url
150150+ | [] -> None
151151+152152+let current_url t =
153153+ match Sortal_schema_temporal.current ~get:(fun (u : url_entry) -> u.range) t.urls with
154154+ | Some u -> Some u.url
155155+ | None -> None
156156+157157+let all_email_addresses t =
158158+ List.map (fun e -> e.address) t.emails
159159+160160+(* Service queries *)
161161+let services_of_kind t (kind : service_kind) =
162162+ List.filter (fun (s : service) ->
163163+ match (s.kind : service_kind option) with
164164+ | Some k when k = kind -> true
165165+ | _ -> false
166166+ ) t.services
167167+168168+let services_at t ~date =
169169+ Sortal_schema_temporal.at_date ~get:(fun (s : service) -> s.range) ~date t.services
170170+171171+let current_services t =
172172+ List.filter (fun (s : service) -> Sortal_schema_temporal.is_current s.range) t.services
173173+174174+let primary_service t (kind : service_kind) =
175175+ List.find_opt (fun (s : service) ->
176176+ match (s.kind : service_kind option) with
177177+ | Some k when k = kind && s.primary -> true
178178+ | _ -> false
179179+ ) t.services
180180+181181+let best_url t =
182182+ current_url t
183183+ |> Option.fold ~none:(
184184+ match current_services t with
185185+ | s :: _ -> Some s.url
186186+ | [] -> current_email t |> Option.map (fun e -> "mailto:" ^ e)
187187+ ) ~some:Option.some
188188+189189+(* Modification *)
190190+let add_feed t feed =
191191+ { t with feeds = Some (feed :: Option.value t.feeds ~default:[]) }
192192+193193+let remove_feed t url =
194194+ { t with feeds = Option.map (List.filter (fun f -> Sortal_schema_feed.url f <> url)) t.feeds }
195195+196196+(* Comparison *)
197197+let compare a b = String.compare a.handle b.handle
198198+199199+(* Type conversions *)
200200+let contact_kind_to_string = function
201201+ | Person -> "person"
202202+ | Organization -> "organization"
203203+ | Group -> "group"
204204+ | Role -> "role"
205205+206206+let contact_kind_of_string = function
207207+ | "person" -> Some Person
208208+ | "organization" -> Some Organization
209209+ | "group" -> Some Group
210210+ | "role" -> Some Role
211211+ | _ -> None
212212+213213+let service_kind_to_string = function
214214+ | ActivityPub -> "activitypub"
215215+ | Github -> "github"
216216+ | Git -> "git"
217217+ | Social -> "social"
218218+ | Photo -> "photo"
219219+ | Custom s -> s
220220+221221+let service_kind_of_string s =
222222+ match String.lowercase_ascii s with
223223+ | "activitypub" -> Some ActivityPub
224224+ | "github" -> Some Github
225225+ | "git" -> Some Git
226226+ | "social" -> Some Social
227227+ | "photo" -> Some Photo
228228+ | "" | "custom" -> None
229229+ | _ -> Some (Custom s)
230230+231231+let email_type_to_string = function
232232+ | Work -> "work"
233233+ | Personal -> "personal"
234234+ | Other -> "other"
235235+236236+let email_type_of_string = function
237237+ | "work" -> Some Work
238238+ | "personal" -> Some Personal
239239+ | "other" -> Some Other
240240+ | _ -> None
241241+242242+(* JSON encoding *)
243243+244244+(* Helper: case-insensitive enum decoder *)
245245+let case_insensitive_enum ~kind:kind_name cases =
246246+ let open Jsont in
247247+ let lowercase_cases = List.map (fun (s, v) -> (String.lowercase_ascii s, v)) cases in
248248+ let dec s =
249249+ match List.assoc_opt (String.lowercase_ascii s) lowercase_cases with
250250+ | Some v -> v
251251+ | None -> failwith ("unknown " ^ kind_name ^ ": " ^ s)
252252+ in
253253+ let enc v =
254254+ match List.find_opt (fun (_, v') -> v = v') cases with
255255+ | Some (s, _) -> s
256256+ | None -> failwith ("invalid " ^ kind_name)
257257+ in
258258+ let t = map ~kind:kind_name ~dec ~enc string in
259259+ t
260260+261261+let contact_kind_json =
262262+ case_insensitive_enum ~kind:"ContactKind" [
263263+ "person", Person;
264264+ "organization", Organization;
265265+ "group", Group;
266266+ "role", Role;
267267+ ]
268268+269269+let service_json : service Jsont.t =
270270+ let open Jsont in
271271+ let open Jsont.Object in
272272+ let mem_opt f v ~enc = mem f v ~dec_absent:None ~enc_omit:Option.is_none ~enc in
273273+ (* Convert string option to/from service_kind option *)
274274+ let dec_kind_opt kind_str =
275275+ match kind_str with
276276+ | None -> None
277277+ | Some s -> service_kind_of_string s
278278+ in
279279+ let enc_kind_opt = Option.map service_kind_to_string in
280280+ let make url kind_str handle label range primary : service =
281281+ let kind = dec_kind_opt kind_str in
282282+ { url; kind; handle; label; range; primary }
283283+ in
284284+ map ~kind:"Service" make
285285+ |> mem "url" string ~enc:(fun (s : service) -> s.url)
286286+ |> mem_opt "kind" (some string) ~enc:(fun (s : service) -> enc_kind_opt s.kind)
287287+ |> mem_opt "handle" (some string) ~enc:(fun (s : service) -> s.handle)
288288+ |> mem_opt "label" (some string) ~enc:(fun (s : service) -> s.label)
289289+ |> mem_opt "range" (some Sortal_schema_temporal.json_t) ~enc:(fun (s : service) -> s.range)
290290+ |> mem "primary" bool ~dec_absent:false ~enc:(fun (s : service) -> s.primary)
291291+ |> finish
292292+293293+let email_type_json =
294294+ case_insensitive_enum ~kind:"EmailType" [
295295+ "work", Work;
296296+ "personal", Personal;
297297+ "other", Other;
298298+ ]
299299+300300+let email_json : email Jsont.t =
301301+ let open Jsont in
302302+ let open Jsont.Object in
303303+ let mem_opt f v ~enc = mem f v ~dec_absent:None ~enc_omit:Option.is_none ~enc in
304304+ let make address type_ range note : email = { address; type_; range; note } in
305305+ map ~kind:"Email" make
306306+ |> mem "address" string ~enc:(fun (e : email) -> e.address)
307307+ |> mem_opt "type" (some email_type_json) ~enc:(fun (e : email) -> e.type_)
308308+ |> mem_opt "range" (some Sortal_schema_temporal.json_t) ~enc:(fun (e : email) -> e.range)
309309+ |> mem_opt "note" (some string) ~enc:(fun (e : email) -> e.note)
310310+ |> finish
311311+312312+let organization_json : organization Jsont.t =
313313+ let open Jsont in
314314+ let open Jsont.Object in
315315+ let mem_opt f v ~enc = mem f v ~dec_absent:None ~enc_omit:Option.is_none ~enc in
316316+ let make name title department range email url : organization =
317317+ { name; title; department; range; email; url }
318318+ in
319319+ map ~kind:"Organization" make
320320+ |> mem "name" string ~enc:(fun (o : organization) -> o.name)
321321+ |> mem_opt "title" (some string) ~enc:(fun (o : organization) -> o.title)
322322+ |> mem_opt "department" (some string) ~enc:(fun (o : organization) -> o.department)
323323+ |> mem_opt "range" (some Sortal_schema_temporal.json_t) ~enc:(fun (o : organization) -> o.range)
324324+ |> mem_opt "email" (some string) ~enc:(fun (o : organization) -> o.email)
325325+ |> mem_opt "url" (some string) ~enc:(fun (o : organization) -> o.url)
326326+ |> finish
327327+328328+let url_entry_json : url_entry Jsont.t =
329329+ let open Jsont in
330330+ let open Jsont.Object in
331331+ let mem_opt f v ~enc = mem f v ~dec_absent:None ~enc_omit:Option.is_none ~enc in
332332+ let make url label range : url_entry = { url; label; range } in
333333+ map ~kind:"URL" make
334334+ |> mem "url" string ~enc:(fun (u : url_entry) -> u.url)
335335+ |> mem_opt "label" (some string) ~enc:(fun (u : url_entry) -> u.label)
336336+ |> mem_opt "range" (some Sortal_schema_temporal.json_t) ~enc:(fun (u : url_entry) -> u.range)
337337+ |> finish
338338+339339+let json_t =
340340+ let open Jsont in
341341+ let open Jsont.Object in
342342+ let mem_opt f v ~enc = mem f v ~dec_absent:None ~enc_omit:Option.is_none ~enc in
343343+ let make version kind handle names emails organizations urls services
344344+ icon thumbnail orcid feeds =
345345+ if version <> 1 then
346346+ failwith (Printf.sprintf "Unsupported contact schema version: %d" version);
347347+ { version; kind; handle; names; emails; organizations; urls; services;
348348+ icon; thumbnail; orcid; feeds }
349349+ in
350350+ map ~kind:"Contact" make
351351+ |> mem "version" int ~enc:(fun _ -> 1)
352352+ |> mem "kind" contact_kind_json ~dec_absent:Person ~enc:(fun c -> c.kind)
353353+ |> mem "handle" string ~enc:(fun c -> c.handle)
354354+ |> mem "names" (list string) ~dec_absent:[] ~enc:(fun c -> c.names)
355355+ |> mem "emails" (list email_json) ~dec_absent:[] ~enc:(fun c -> c.emails)
356356+ |> mem "organizations" (list organization_json) ~dec_absent:[] ~enc:(fun c -> c.organizations)
357357+ |> mem "urls" (list url_entry_json) ~dec_absent:[] ~enc:(fun c -> c.urls)
358358+ |> mem "services" (list service_json) ~dec_absent:[] ~enc:(fun c -> c.services)
359359+ |> mem_opt "icon" (some string) ~enc:(fun c -> c.icon)
360360+ |> mem_opt "thumbnail" (some string) ~enc:(fun c -> c.thumbnail)
361361+ |> mem_opt "orcid" (some string) ~enc:(fun c -> c.orcid)
362362+ |> mem_opt "feeds" (some (list Sortal_schema_feed.json_t)) ~enc:(fun c -> c.feeds)
363363+ |> finish
364364+365365+(* Pretty printing *)
366366+let pp ppf t =
367367+ let open Fmt in
368368+ let label = styled (`Fg `Cyan) string in
369369+ let url_style = styled (`Fg `Blue) in
370370+ let date_style = styled (`Fg `Green) in
371371+ let field lbl fmt_v = Option.iter (fun v -> pf ppf "%a: %a@," label lbl fmt_v v) in
372372+373373+ let pp_range ppf = function
374374+ | None -> ()
375375+ | Some { Sortal_schema_temporal.from; until } ->
376376+ match from, until with
377377+ | Some f, Some u ->
378378+ let fs = Sortal_schema_temporal.format_date f in
379379+ let us = Sortal_schema_temporal.format_date u in
380380+ pf ppf " %a" (date_style string) (Printf.sprintf "[%s to %s]" fs us)
381381+ | Some f, None ->
382382+ let fs = Sortal_schema_temporal.format_date f in
383383+ pf ppf " %a" (date_style string) (Printf.sprintf "[from %s]" fs)
384384+ | None, Some u ->
385385+ let us = Sortal_schema_temporal.format_date u in
386386+ pf ppf " %a" (date_style string) (Printf.sprintf "[until %s]" us)
387387+ | None, None -> ()
388388+ in
389389+390390+ pf ppf "@[<v>";
391391+ pf ppf "%a: %a@," label "Handle" (styled `Bold (fun ppf s -> pf ppf "@%s" s)) t.handle;
392392+393393+ (* Show kind if not a person *)
394394+ (match t.kind with
395395+ | Person -> ()
396396+ | k -> pf ppf "%a: %a@," label "Kind" (styled (`Fg `Magenta) string) (contact_kind_to_string k));
397397+398398+ pf ppf "%a: %a@," label "Name" (styled `Bold string) (name t);
399399+400400+ if List.length (names t) > 1 then
401401+ pf ppf "%a: @[<h>%a@]@," label "Aliases"
402402+ (list ~sep:comma string) (List.tl (names t));
403403+404404+ (* Emails with temporal info *)
405405+ if emails t <> [] then begin
406406+ pf ppf "%a:@," label "Emails";
407407+ List.iter (fun e ->
408408+ pf ppf " %a%s%s%a%a@,"
409409+ (styled (`Fg `Yellow) string) e.address
410410+ (match e.type_ with Some Work -> " (work)" | Some Personal -> " (personal)" | Some Other -> " (other)" | None -> "")
411411+ (match e.note with Some n -> " - " ^ n | None -> "")
412412+ pp_range e.range
413413+ (fun ppf current -> if current then pf ppf " %a" (styled (`Fg `Magenta) string) "[current]" else ())
414414+ (Sortal_schema_temporal.is_current e.range)
415415+ ) (emails t)
416416+ end;
417417+418418+ (* Organizations with temporal info *)
419419+ if organizations t <> [] then begin
420420+ pf ppf "%a:@," label "Organizations";
421421+ List.iter (fun o ->
422422+ pf ppf " %a" (styled `Bold string) o.name;
423423+ Option.iter (fun title -> pf ppf " - %s" title) o.title;
424424+ Option.iter (fun dept -> pf ppf " (%s)" dept) o.department;
425425+ pf ppf "%a" pp_range o.range;
426426+ if Sortal_schema_temporal.is_current o.range then
427427+ pf ppf " %a" (styled (`Fg `Magenta) string) "[current]";
428428+ pf ppf "@,";
429429+ Option.iter (fun email -> pf ppf " Email: %a@," (styled (`Fg `Yellow) string) email) o.email;
430430+ Option.iter (fun url -> pf ppf " URL: %a@," (url_style string) url) o.url;
431431+ ) (organizations t)
432432+ end;
433433+434434+ (* URLs *)
435435+ if urls t <> [] then begin
436436+ pf ppf "%a:@," label "URLs";
437437+ List.iter (fun u ->
438438+ pf ppf " %a" (url_style string) u.url;
439439+ Option.iter (fun lbl -> pf ppf " (%s)" lbl) u.label;
440440+ pf ppf "%a" pp_range u.range;
441441+ if Sortal_schema_temporal.is_current u.range then
442442+ pf ppf " %a" (styled (`Fg `Magenta) string) "[current]";
443443+ pf ppf "@,"
444444+ ) (urls t)
445445+ end;
446446+447447+ (* Services *)
448448+ if services t <> [] then begin
449449+ pf ppf "%a:@," label "Services";
450450+ List.iter (fun (s : service) ->
451451+ pf ppf " %a" (url_style string) s.url;
452452+ Option.iter (fun k -> pf ppf " (%s)" (service_kind_to_string k)) s.kind;
453453+ Option.iter (fun h -> pf ppf " [@%s]" h) s.handle;
454454+ Option.iter (fun lbl -> pf ppf " - %s" lbl) s.label;
455455+ pf ppf "%a" pp_range s.range;
456456+ if s.primary then pf ppf " %a" (styled (`Fg `Yellow) string) "[primary]";
457457+ if Sortal_schema_temporal.is_current s.range then
458458+ pf ppf " %a" (styled (`Fg `Magenta) string) "[current]";
459459+ pf ppf "@,"
460460+ ) (services t)
461461+ end;
462462+463463+ field "ORCID" (url_style (fun ppf o -> pf ppf "https://orcid.org/%s" o)) t.orcid;
464464+465465+ field "Icon" (url_style string) t.icon;
466466+ field "Thumbnail" (styled (`Fg `White) string) t.thumbnail;
467467+468468+ Option.iter (function
469469+ | [] -> ()
470470+ | feeds ->
471471+ pf ppf "%a:@," label "Feeds";
472472+ List.iter (fun feed -> pf ppf " - %a@," Sortal_schema_feed.pp feed) feeds
473473+ ) t.feeds;
474474+475475+ pf ppf "@]"
+277
lib/schema/sortal_schema_contact_v1.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Contact schema V1 with temporal support.
77+88+ This module defines the V1 contact schema with support for time-bounded
99+ information such as emails and organizations that are valid only during
1010+ specific periods.
1111+1212+ {b Schema Version Policy:}
1313+ - New optional fields can be added without bumping the version
1414+ - The version must be bumped only if the {i meaning} of an existing
1515+ field changes
1616+ - This allows forward compatibility: older readers can ignore new fields *)
1717+1818+(** {1 Schema Version} *)
1919+2020+val version : int
2121+(** The schema version number for V1. Currently [1]. *)
2222+2323+(** {1 Types} *)
2424+2525+(** Contact kind - what type of entity this represents. *)
2626+type contact_kind =
2727+ | Person (** Individual person *)
2828+ | Organization (** Company, lab, department *)
2929+ | Group (** Research group, project team *)
3030+ | Role (** Generic role email like info@, admin@ *)
3131+3232+(** Service kind - categorization of online presence. *)
3333+type service_kind =
3434+ | ActivityPub (** Mastodon, Pixelfed, PeerTube, etc *)
3535+ | Github (** GitHub *)
3636+ | Git (** GitLab, Gitea, Codeberg, etc *)
3737+ | Social (** Twitter/X, LinkedIn, etc *)
3838+ | Photo (** Immich, Flickr, Instagram, etc *)
3939+ | Custom of string (** Other service types *)
4040+4141+(** An online service/identity. *)
4242+type service = {
4343+ url: string; (** Full URL (primary identifier) *)
4444+ kind: service_kind option; (** Optional service categorization *)
4545+ handle: string option; (** Optional short handle/username *)
4646+ label: string option; (** Human description: "Cambridge GitLab", "Work account" *)
4747+ range: Sortal_schema_temporal.range option; (** Temporal validity *)
4848+ primary: bool; (** Is this the primary/preferred service of its kind? *)
4949+}
5050+5151+type email_type = Work | Personal | Other
5252+5353+type email = {
5454+ address: string;
5555+ type_: email_type option;
5656+ range: Sortal_schema_temporal.range option; (** Validity period *)
5757+ note: string option; (** Context note, e.g., "NetApp position" *)
5858+}
5959+6060+type organization = {
6161+ name: string;
6262+ title: string option;
6363+ department: string option;
6464+ range: Sortal_schema_temporal.range option; (** Employment period *)
6565+ email: string option; (** Work email during this period *)
6666+ url: string option; (** Work homepage during this period *)
6767+}
6868+6969+type url_entry = {
7070+ url: string;
7171+ label: string option; (** Human-readable label *)
7272+ range: Sortal_schema_temporal.range option; (** Validity period *)
7373+}
7474+7575+type t = {
7676+ version: int; (** Schema version (always 1 for V1) *)
7777+ kind: contact_kind; (** Type of entity (Person, Organization, etc) *)
7878+ handle: string; (** Unique identifier *)
7979+ names: string list; (** Names, first is primary *)
8080+8181+ (* Temporal fields *)
8282+ emails: email list; (** Email addresses with temporal validity *)
8383+ organizations: organization list; (** Employment/affiliation history *)
8484+ urls: url_entry list; (** URLs with optional temporal validity *)
8585+ services: service list; (** Online services/identities *)
8686+8787+ (* Simple fields - rarely change over time *)
8888+ icon: string option; (** Avatar URL *)
8989+ thumbnail: string option; (** Local thumbnail path *)
9090+ orcid: string option; (** ORCID identifier *)
9191+9292+ (* Other *)
9393+ feeds: Sortal_schema_feed.t list option; (** Feed subscriptions *)
9494+}
9595+9696+(** {1 Construction} *)
9797+9898+(** [make ~handle ~names ?kind ?emails ?organizations ?urls ?services
9999+ ?icon ?thumbnail ?orcid ?feeds ()]
100100+ creates a new V1 contact.
101101+102102+ The [version] field is automatically set to [1].
103103+ The [kind] defaults to [Person] if not specified. *)
104104+val make :
105105+ handle:string ->
106106+ names:string list ->
107107+ ?kind:contact_kind ->
108108+ ?emails:email list ->
109109+ ?organizations:organization list ->
110110+ ?urls:url_entry list ->
111111+ ?services:service list ->
112112+ ?icon:string ->
113113+ ?thumbnail:string ->
114114+ ?orcid:string ->
115115+ ?feeds:Sortal_schema_feed.t list ->
116116+ unit ->
117117+ t
118118+119119+(** {1 Email Helpers} *)
120120+121121+(** [make_email ?type_ ?from ?until ?note address] creates an email entry.
122122+123123+ @param type_ Email type (Work, Personal, Other)
124124+ @param from Start date of validity
125125+ @param until End date of validity (exclusive)
126126+ @param note Contextual note *)
127127+val make_email :
128128+ ?type_:email_type ->
129129+ ?from:Sortal_schema_temporal.date ->
130130+ ?until:Sortal_schema_temporal.date ->
131131+ ?note:string ->
132132+ string ->
133133+ email
134134+135135+(** [email_of_string s] creates a simple always-valid personal email. *)
136136+val email_of_string : string -> email
137137+138138+(** {1 Organization Helpers} *)
139139+140140+(** [make_org ?title ?department ?from ?until ?email ?url name]
141141+ creates an organization entry. *)
142142+val make_org :
143143+ ?title:string ->
144144+ ?department:string ->
145145+ ?from:Sortal_schema_temporal.date ->
146146+ ?until:Sortal_schema_temporal.date ->
147147+ ?email:string ->
148148+ ?url:string ->
149149+ string ->
150150+ organization
151151+152152+(** {1 URL Helpers} *)
153153+154154+(** [make_url ?label ?from ?until url] creates a URL entry. *)
155155+val make_url :
156156+ ?label:string ->
157157+ ?from:Sortal_schema_temporal.date ->
158158+ ?until:Sortal_schema_temporal.date ->
159159+ string ->
160160+ url_entry
161161+162162+(** [url_of_string s] creates a simple always-valid URL. *)
163163+val url_of_string : string -> url_entry
164164+165165+(** {1 Service Helpers} *)
166166+167167+(** [make_service ?kind ?handle ?label ?from ?until ?primary url]
168168+ creates a service entry.
169169+170170+ @param kind Optional service categorization
171171+ @param handle Optional short handle/username
172172+ @param label Optional description (e.g., "Work account", "Cambridge GitLab")
173173+ @param from Start date of validity
174174+ @param until End date of validity (exclusive)
175175+ @param primary Whether this is the primary service of its kind
176176+ @param url Full URL to the service (required) *)
177177+val make_service :
178178+ ?kind:service_kind ->
179179+ ?handle:string ->
180180+ ?label:string ->
181181+ ?from:Sortal_schema_temporal.date ->
182182+ ?until:Sortal_schema_temporal.date ->
183183+ ?primary:bool ->
184184+ string ->
185185+ service
186186+187187+(** [service_of_url url] creates a simple always-valid service from just a URL. *)
188188+val service_of_url : string -> service
189189+190190+(** {1 Accessors} *)
191191+192192+val version_of : t -> int
193193+val kind : t -> contact_kind
194194+val handle : t -> string
195195+val names : t -> string list
196196+val name : t -> string
197197+val primary_name : t -> string
198198+val emails : t -> email list
199199+val organizations : t -> organization list
200200+val urls : t -> url_entry list
201201+val services : t -> service list
202202+val icon : t -> string option
203203+val thumbnail : t -> string option
204204+val orcid : t -> string option
205205+val feeds : t -> Sortal_schema_feed.t list option
206206+207207+(** {1 Temporal Queries} *)
208208+209209+(** [email_at t ~date] returns the primary email valid at [date]. *)
210210+val email_at : t -> date:Sortal_schema_temporal.date -> string option
211211+212212+(** [emails_at t ~date] returns all emails valid at [date]. *)
213213+val emails_at : t -> date:Sortal_schema_temporal.date -> email list
214214+215215+(** [current_email t] returns the current primary email. *)
216216+val current_email : t -> string option
217217+218218+(** [organization_at t ~date] returns the organization at [date]. *)
219219+val organization_at : t -> date:Sortal_schema_temporal.date -> organization option
220220+221221+(** [current_organization t] returns the current organization. *)
222222+val current_organization : t -> organization option
223223+224224+(** [url_at t ~date] returns the primary URL valid at [date]. *)
225225+val url_at : t -> date:Sortal_schema_temporal.date -> string option
226226+227227+(** [current_url t] returns the current primary URL. *)
228228+val current_url : t -> string option
229229+230230+(** [all_email_addresses t] returns all email addresses (any period). *)
231231+val all_email_addresses : t -> string list
232232+233233+(** [best_url t] returns the best available URL (current URL or service fallback). *)
234234+val best_url : t -> string option
235235+236236+(** {1 Service Queries} *)
237237+238238+(** [services_of_kind t kind] returns all services matching the given kind. *)
239239+val services_of_kind : t -> service_kind -> service list
240240+241241+(** [services_at t ~date] returns all services valid at [date]. *)
242242+val services_at : t -> date:Sortal_schema_temporal.date -> service list
243243+244244+(** [current_services t] returns all currently valid services. *)
245245+val current_services : t -> service list
246246+247247+(** [primary_service t kind] returns the primary service of the given kind. *)
248248+val primary_service : t -> service_kind -> service option
249249+250250+(** {1 Modification} *)
251251+252252+val add_feed : t -> Sortal_schema_feed.t -> t
253253+val remove_feed : t -> string -> t
254254+255255+(** {1 Comparison and Display} *)
256256+257257+val compare : t -> t -> int
258258+val pp : Format.formatter -> t -> unit
259259+260260+(** {1 JSON Encoding} *)
261261+262262+(** [json_t] is the jsont encoder/decoder for V1 contacts.
263263+264264+ The schema includes a [version] field that is always encoded and
265265+ must equal [1] when decoded. *)
266266+val json_t : t Jsont.t
267267+268268+(** {1 Type Utilities} *)
269269+270270+val contact_kind_to_string : contact_kind -> string
271271+val contact_kind_of_string : string -> contact_kind option
272272+273273+val service_kind_to_string : service_kind -> string
274274+val service_kind_of_string : string -> service_kind option
275275+276276+val email_type_to_string : email_type -> string
277277+val email_type_of_string : string -> email_type option
+57
lib/schema/sortal_schema_feed.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+type feed_type =
77+ | Atom
88+ | Rss
99+ | Json
1010+1111+type t = {
1212+ feed_type : feed_type;
1313+ url : string;
1414+ name : string option;
1515+}
1616+1717+let make ~feed_type ~url ?name () =
1818+ { feed_type; url; name }
1919+2020+let feed_type t = t.feed_type
2121+let url t = t.url
2222+let name t = t.name
2323+2424+let set_name t name = { t with name = Some name }
2525+2626+let feed_type_to_string = function
2727+ | Atom -> "atom"
2828+ | Rss -> "rss"
2929+ | Json -> "json"
3030+3131+let feed_type_of_string s =
3232+ match String.lowercase_ascii s with
3333+ | "atom" -> Some Atom
3434+ | "rss" -> Some Rss
3535+ | "json" -> Some Json
3636+ | _ -> None
3737+3838+let json_t =
3939+ let open Jsont in
4040+ let open Jsont.Object in
4141+ let make feed_type url name =
4242+ match feed_type_of_string feed_type with
4343+ | Some ft -> { feed_type = ft; url; name }
4444+ | None -> failwith ("Invalid feed type: " ^ feed_type)
4545+ in
4646+ map ~kind:"Feed" make
4747+ |> mem "type" string ~enc:(fun f -> feed_type_to_string f.feed_type)
4848+ |> mem "url" string ~enc:(fun f -> f.url)
4949+ |> opt_mem "name" string ~enc:(fun f -> f.name)
5050+ |> finish
5151+5252+let pp ppf t =
5353+ let open Fmt in
5454+ pf ppf "%a: %a%a"
5555+ (styled (`Fg `Green) string) (feed_type_to_string t.feed_type)
5656+ (styled (`Fg `Blue) string) t.url
5757+ (option (fun ppf name -> pf ppf " (%a)" (styled `Faint string) name)) t.name
+48
lib/schema/sortal_schema_feed.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Feed subscription with type and URL.
77+88+ A feed represents a subscription to a content source (Atom, RSS, or JSONFeed). *)
99+1010+type t
1111+1212+(** Feed type identifier. *)
1313+type feed_type =
1414+ | Atom (** Atom feed format *)
1515+ | Rss (** RSS feed format *)
1616+ | Json (** JSON Feed format *)
1717+1818+(** [make ~feed_type ~url ?name ()] creates a new feed.
1919+2020+ @param feed_type The type of feed (Atom, RSS, or JSON)
2121+ @param url The feed URL
2222+ @param name Optional human-readable name/label for the feed *)
2323+val make : feed_type:feed_type -> url:string -> ?name:string -> unit -> t
2424+2525+(** [feed_type t] returns the feed type. *)
2626+val feed_type : t -> feed_type
2727+2828+(** [url t] returns the feed URL. *)
2929+val url : t -> string
3030+3131+(** [name t] returns the feed name if set. *)
3232+val name : t -> string option
3333+3434+(** [set_name t name] returns a new feed with the name updated. *)
3535+val set_name : t -> string -> t
3636+3737+(** [feed_type_to_string ft] converts a feed type to a string. *)
3838+val feed_type_to_string : feed_type -> string
3939+4040+(** [feed_type_of_string s] parses a feed type from a string.
4141+ Returns [None] if the string is not recognized. *)
4242+val feed_type_of_string : string -> feed_type option
4343+4444+(** [json_t] is the jsont encoder/decoder for feeds. *)
4545+val json_t : t Jsont.t
4646+4747+(** [pp ppf t] pretty prints a feed. *)
4848+val pp : Format.formatter -> t -> unit
+135
lib/schema/sortal_schema_temporal.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+type date = Ptime.date
77+88+type range = {
99+ from: date option;
1010+ until: date option;
1111+}
1212+1313+let make ?from ?until () = { from; until }
1414+1515+let always = { from = None; until = None }
1616+1717+(* Compare Ptime dates (year, month, day tuples) *)
1818+let date_compare ((y1, m1, d1) : date) ((y2, m2, d2) : date) : int =
1919+ match compare y1 y2 with
2020+ | 0 -> (
2121+ match compare m1 m2 with
2222+ | 0 -> compare d1 d2
2323+ | c -> c)
2424+ | c -> c
2525+2626+let date_gte d1 d2 = date_compare d1 d2 >= 0
2727+2828+let valid_at range_opt ~date =
2929+ match range_opt with
3030+ | None -> true (* No range = always valid *)
3131+ | Some { from; until } ->
3232+ let after_start = match from with
3333+ | None -> true
3434+ | Some f -> date_gte date f
3535+ in
3636+ let before_end = match until with
3737+ | None -> true
3838+ | Some u -> date_compare date u < 0 (* until is exclusive *)
3939+ in
4040+ after_start && before_end
4141+4242+let overlaps r1 r2 =
4343+ (* Two ranges overlap if neither ends before the other starts *)
4444+ let r1_starts_before_r2_ends = match r2.until with
4545+ | None -> true
4646+ | Some u2 -> match r1.from with
4747+ | None -> true
4848+ | Some f1 -> date_compare f1 u2 < 0
4949+ in
5050+ let r2_starts_before_r1_ends = match r1.until with
5151+ | None -> true
5252+ | Some u1 -> match r2.from with
5353+ | None -> true
5454+ | Some f2 -> date_compare f2 u1 < 0
5555+ in
5656+ r1_starts_before_r2_ends && r2_starts_before_r1_ends
5757+5858+let today () =
5959+ Ptime_clock.now () |> Ptime.to_date
6060+6161+let is_current range_opt =
6262+ valid_at range_opt ~date:(today ())
6363+6464+let current ~get list =
6565+ (* Find first currently valid item, or first item without temporal bounds *)
6666+ let current_items = List.filter (fun item -> is_current (get item)) list in
6767+ match current_items with
6868+ | x :: _ -> Some x
6969+ | [] ->
7070+ (* No current items, try to find one without temporal bounds *)
7171+ List.find_opt (fun item -> get item = None) list
7272+7373+let at_date ~get ~date list =
7474+ List.filter (fun item -> valid_at (get item) ~date) list
7575+7676+let filter ~get ~from ~until list =
7777+ let query_range = { from; until } in
7878+ List.filter (fun item ->
7979+ match get item with
8080+ | None -> true (* Items without range match all queries *)
8181+ | Some r -> overlaps r query_range
8282+ ) list
8383+8484+(* Parse ISO 8601 date string to Ptime.date, handling partial dates *)
8585+let parse_date_string (s : string) : date option =
8686+ match String.split_on_char '-' s with
8787+ | [year_s] -> (
8888+ try
8989+ let year = int_of_string year_s in
9090+ Some (year, 1, 1) (* Year only → January 1st *)
9191+ with Failure _ -> None)
9292+ | [year_s; month_s] -> (
9393+ try
9494+ let year = int_of_string year_s in
9595+ let month = int_of_string month_s in
9696+ if month >= 1 && month <= 12 then
9797+ Some (year, month, 1) (* Year-Month → 1st of month *)
9898+ else None
9999+ with Failure _ -> None)
100100+ | [year_s; month_s; day_s] -> (
101101+ try
102102+ let year = int_of_string year_s in
103103+ let month = int_of_string month_s in
104104+ let day = int_of_string day_s in
105105+ if month >= 1 && month <= 12 && day >= 1 && day <= 31 then
106106+ Some (year, month, day)
107107+ else None
108108+ with Failure _ -> None)
109109+ | _ -> None
110110+111111+(* Format Ptime.date as ISO 8601 string YYYY-MM-DD *)
112112+let format_date ((year, month, day) : date) : string =
113113+ Printf.sprintf "%04d-%02d-%02d" year month day
114114+115115+let json_t =
116116+ let open Jsont in
117117+ let open Jsont.Object in
118118+ let mem_opt f v ~enc = mem f v ~dec_absent:None ~enc_omit:Option.is_none ~enc in
119119+120120+ (* Create a jsont type for date that converts between string and Ptime.date *)
121121+ let date_jsont =
122122+ let dec meta s =
123123+ match parse_date_string s with
124124+ | Some d -> d
125125+ | None -> Error.msgf meta "TemporalDate: invalid ISO 8601 date: %S" s
126126+ in
127127+ let enc = format_date in
128128+ Base.string (Base.map ~kind:"TemporalDate" ~dec ~enc ())
129129+ in
130130+131131+ let make_range from until = { from; until } in
132132+ map ~kind:"TemporalRange" make_range
133133+ |> mem_opt "from" (some date_jsont) ~enc:(fun r -> r.from)
134134+ |> mem_opt "until" (some date_jsont) ~enc:(fun r -> r.until)
135135+ |> finish
+98
lib/schema/sortal_schema_temporal.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Temporal validity support for contact fields.
77+88+ This module provides types and functions for managing time-bounded
99+ information in contacts, such as emails valid only during certain
1010+ employment periods. *)
1111+1212+(** Date represented as a Ptime.date tuple (year, month, day).
1313+1414+ When parsing from strings, partial dates are normalized:
1515+ - Year: ["2001"] → (2001, 1, 1)
1616+ - Year-Month: ["2001-01"] → (2001, 1, 1)
1717+ - Full date: ["2001-01-15"] → (2001, 1, 15) *)
1818+type date = Ptime.date
1919+2020+(** {1 Date Conversion} *)
2121+2222+(** [parse_date_string s] parses an ISO 8601 date string.
2323+2424+ Accepts various formats with partial date support:
2525+ - "2001" (year only) → (2001, 1, 1)
2626+ - "2001-01" (year-month) → (2001, 1, 1)
2727+ - "2001-01-15" (full date) → (2001, 1, 15)
2828+2929+ Returns [None] if the string is not a valid date format. *)
3030+val parse_date_string : string -> date option
3131+3232+(** [format_date date] formats a date as ISO 8601 (YYYY-MM-DD).
3333+3434+ {b Example:} [format_date (2001, 1, 15)] returns ["2001-01-15"] *)
3535+val format_date : date -> string
3636+3737+(** {1 Temporal Ranges} *)
3838+3939+(** A temporal range indicating validity period. *)
4040+type range = {
4141+ from: date option; (** Start date (inclusive). [None] means from the beginning. *)
4242+ until: date option; (** End date (exclusive). [None] means continuing/indefinite. *)
4343+}
4444+4545+(** {1 Range Construction} *)
4646+4747+(** [make ?from ?until ()] creates a temporal range. *)
4848+val make : ?from:date -> ?until:date -> unit -> range
4949+5050+(** [always] is a range that is always valid (no from/until bounds). *)
5151+val always : range
5252+5353+(** {1 Range Queries} *)
5454+5555+(** [valid_at range ~date] checks if [range] is valid at the given [date].
5656+5757+ - [None] range means always valid
5858+ - [None] from means valid from beginning
5959+ - [None] until means valid continuing *)
6060+val valid_at : range option -> date:date -> bool
6161+6262+(** [overlaps r1 r2] checks if two ranges overlap in time. *)
6363+val overlaps : range -> range -> bool
6464+6565+(** [is_current range] checks if range is valid at the current date.
6666+ Uses today's date for the check. *)
6767+val is_current : range option -> bool
6868+6969+(** {1 List Filtering} *)
7070+7171+(** [current ~get list] returns the first current/valid item from [list].
7272+7373+ @param get Function to extract the temporal range from an item.
7474+ Returns the first item where the range is currently valid,
7575+ or the first item without temporal bounds if none are current. *)
7676+val current : get:('a -> range option) -> 'a list -> 'a option
7777+7878+(** [at_date ~get ~date list] filters [list] to items valid at [date].
7979+8080+ @param get Function to extract the temporal range from an item.
8181+ @param date The date to check validity against. *)
8282+val at_date : get:('a -> range option) -> date:date -> 'a list -> 'a list
8383+8484+(** [filter ~get ~from ~until list] filters [list] to items overlapping the period.
8585+8686+ Returns items whose temporal range overlaps with the given period. *)
8787+val filter : get:('a -> range option) -> from:date option -> until:date option ->
8888+ 'a list -> 'a list
8989+9090+(** {1 JSON Encoding} *)
9191+9292+(** [json_t] is the jsont encoder/decoder for temporal ranges.
9393+9494+ Encodes as a JSON object with optional [from] and [until] fields:
9595+ {[ { "from": "2001-01", "until": "2003-12" } ]}
9696+9797+ Empty object [\{\}] or missing field represents [always]. *)
9898+val json_t : range Jsont.t
+47
sortal.opam
···11+# This file is generated by dune, edit dune-project instead
22+opam-version: "2.0"
33+synopsis:
44+ "Contact metadata management with XDG storage and versioned schemas"
55+description: """
66+Sortal provides contact metadata management with versioned schemas,
77+ XDG-compliant storage, git versioning, and CLI tools.
88+99+ The library is split into two components:
1010+ - sortal.schema: Versioned data types with minimal dependencies
1111+ - sortal: Core library with storage, git integration, and CLI support"""
1212+maintainer: ["Anil Madhavapeddy <anil@recoil.org>"]
1313+authors: ["Anil Madhavapeddy"]
1414+license: "ISC"
1515+homepage: "https://tangled.org/@anil.recoil.org/sortal"
1616+bug-reports: "https://tangled.org/@anil.recoil.org/sortal/issues"
1717+depends: [
1818+ "dune" {>= "3.20"}
1919+ "ocaml" {>= "5.1.0"}
2020+ "eio"
2121+ "eio_main"
2222+ "xdge"
2323+ "jsont"
2424+ "ptime"
2525+ "yamlt"
2626+ "bytesrw"
2727+ "fmt"
2828+ "cmdliner"
2929+ "logs"
3030+ "odoc" {with-doc}
3131+ "alcotest" {with-test & >= "1.7.0"}
3232+]
3333+build: [
3434+ ["dune" "subst"] {dev}
3535+ [
3636+ "dune"
3737+ "build"
3838+ "-p"
3939+ name
4040+ "-j"
4141+ jobs
4242+ "@install"
4343+ "@runtest" {with-test}
4444+ "@doc" {with-doc}
4545+ ]
4646+]
4747+x-maintenance-intent: ["(latest)"]