···11+# Sortal - Contact Metadata Management Library
22+33+Sortal is an OCaml library that provides a comprehensive system for managing contact metadata with temporal validity tracking. It stores data in XDG-compliant locations using YAML format and optionally versions all changes with git.
44+55+## Features
66+77+- **Temporal Support**: Track how contact information changes over time (emails, organizations, URLs)
88+- **XDG-compliant storage**: Contact metadata stored in standard XDG data directories
99+- **YAML format**: Human-readable YAML files with type-safe encoding/decoding using yamlt
1010+- **Rich metadata**: Support for multiple names, emails (typed), organizations, services (GitHub, social media), ORCID, URLs, and Atom feeds
1111+- **Git Versioning**: Optional automatic git commits for all changes with descriptive messages
1212+- **CLI Interface**: Full command-line interface for CRUD operations on contacts
1313+- **Simple API**: Easy-to-use functions for saving, loading, searching, and deleting contacts
1414+1515+## Metadata Fields
1616+1717+Each contact can include:
1818+1919+- `handle`: Unique identifier/username (required)
2020+- `names`: List of full names with primary name first (required)
2121+- `email`: Email address
2222+- `icon`: Avatar/icon URL
2323+- `thumbnail`: Path to a local thumbnail image file
2424+- `github`: GitHub username
2525+- `twitter`: Twitter/X username
2626+- `bluesky`: Bluesky handle
2727+- `mastodon`: Mastodon handle (with instance)
2828+- `orcid`: ORCID identifier
2929+- `url`: Personal/professional website
3030+- `atom_feeds`: List of Atom/RSS feed URLs
3131+3232+## Storage
3333+3434+Contact data is stored as individual YAML files in the XDG data directory:
3535+3636+- Default location: `$HOME/.local/share/sortal/`
3737+- Override with: `SORTAL_DATA_DIR` or `XDG_DATA_HOME`
3838+- Each contact stored as: `{handle}.yaml`
3939+- Format: Human-readable YAML with temporal data support
4040+4141+## Usage Example
4242+4343+### Basic Usage
4444+4545+```ocaml
4646+(* Create a contact store from filesystem *)
4747+let store = Sortal.create env#fs "myapp" in
4848+4949+(* Or create from an existing XDG context (recommended when using eiocmd) *)
5050+let store = Sortal.create_from_xdg xdg in
5151+5252+(* Create a new contact *)
5353+let contact = Sortal.Contact.make
5454+ ~handle:"avsm"
5555+ ~names:["Anil Madhavapeddy"]
5656+ ~email:"anil@recoil.org"
5757+ ~github:"avsm"
5858+ ~orcid:"0000-0002-7890-1234"
5959+ () in
6060+6161+(* Save the contact *)
6262+Sortal.save store contact;
6363+6464+(* Lookup by handle *)
6565+match Sortal.lookup store "avsm" with
6666+| Some c -> Printf.printf "Found: %s\n" (Sortal.Contact.name c)
6767+| None -> Printf.printf "Not found\n"
6868+6969+(* Search for contacts by name *)
7070+let matches = Sortal.search_all store "Anil" in
7171+List.iter (fun c ->
7272+ Printf.printf "%s: %s\n"
7373+ (Sortal.Contact.handle c)
7474+ (Sortal.Contact.name c)
7575+) matches
7676+7777+(* List all contacts *)
7878+let all_contacts = Sortal.list store in
7979+List.iter (fun c ->
8080+ Printf.printf "%s: %s\n"
8181+ (Sortal.Contact.handle c)
8282+ (Sortal.Contact.name c)
8383+) all_contacts
8484+```
8585+8686+### Integration with Eiocmd (for CLI applications)
8787+8888+```ocaml
8989+open Cmdliner
9090+9191+let my_command env xdg profile =
9292+ (* Create store from XDG context *)
9393+ let store = Sortal.create_from_xdg xdg in
9494+9595+ (* Search for a contact *)
9696+ let matches = Sortal.search_all store "John" in
9797+ List.iter (fun c ->
9898+ match Sortal.Contact.best_url c with
9999+ | Some url -> Logs.app (fun m -> m "%s: %s" (Sortal.Contact.name c) url)
100100+ | None -> ()
101101+ ) matches;
102102+ 0
103103+104104+(* Use Sortal's built-in commands *)
105105+let () =
106106+ let info = Cmd.info "myapp" in
107107+ let my_cmd = Eiocmd.run ~info ~app_name:"myapp" ~service:"myapp"
108108+ Term.(const my_command) in
109109+110110+ (* Include sortal commands as subcommands *)
111111+ let list_contacts = Eiocmd.run ~use_keyeio:false
112112+ ~info:Sortal.Cmd.list_info ~app_name:"myapp" ~service:"myapp"
113113+ Term.(const (fun () -> Sortal.Cmd.list_cmd ()) $ const ()) in
114114+115115+ let cmd = Cmd.group info [my_cmd; list_contacts] in
116116+ exit (Cmd.eval' cmd)
117117+```
118118+119119+## Design Inspiration
120120+121121+The contact metadata structure is inspired by the Contact module from [Bushel](https://github.com/avsm/bushel), adapted to use JSON instead of YAML and stored in XDG-compliant locations.
122122+123123+## Dependencies
124124+125125+- `eio`: For effect-based I/O
126126+- `xdge`: For XDG Base Directory Specification support
127127+- `jsont`: For type-safe JSON encoding/decoding
128128+- `fmt`: For pretty printing
129129+130130+## API Features
131131+132132+The library provides two main ways to use contact metadata:
133133+134134+1. **Core API**: Direct functions for creating, saving, loading, and searching contacts
135135+ - `create` / `create_from_xdg`: Initialize a contact store
136136+ - `save` / `lookup` / `delete` / `list`: CRUD operations
137137+ - `search_all`: Flexible search across contact names
138138+ - `find_by_name` / `find_by_name_opt`: Exact name matching
139139+140140+2. **Cmdliner Integration** (`Sortal.Cmd` module): Ready-to-use CLI commands
141141+ - `list_cmd`: List all contacts
142142+ - `show_cmd`: Show detailed contact information
143143+ - `search_cmd`: Search contacts by name
144144+ - `stats_cmd`: Show database statistics
145145+ - Pre-configured `Cmd.info` and argument definitions for easy integration
146146+147147+## CLI Tool
148148+149149+The library includes a standalone `sortal` CLI tool with full CRUD functionality:
150150+151151+```bash
152152+# Initialize git versioning (optional)
153153+sortal git-init
154154+155155+# List all contacts
156156+sortal list
157157+158158+# Show details for a specific contact
159159+sortal show avsm
160160+161161+# Search for contacts
162162+sortal search "Anil"
163163+164164+# Show database statistics
165165+sortal stats
166166+167167+# Add a new contact
168168+sortal add jsmith --name "John Smith" --email "john@example.com" --kind person
169169+170170+# Add metadata to contacts
171171+sortal add-org jsmith "Acme Corp" --title "Software Engineer" --from 2020-01
172172+sortal add-service jsmith "https://github.com/jsmith" --kind github --handle jsmith
173173+sortal add-email jsmith "john.work@example.com" --type work --from 2020-01
174174+sortal add-url jsmith "https://jsmith.example.com" --label "Personal website"
175175+176176+# Remove metadata
177177+sortal remove-email jsmith "old@example.com"
178178+sortal remove-service jsmith "https://old-service.com"
179179+sortal remove-org jsmith "Old Company"
180180+sortal remove-url jsmith "https://old-url.com"
181181+182182+# Delete a contact
183183+sortal delete jsmith
184184+185185+# Synchronize data (convert thumbnails to PNG)
186186+sortal sync
187187+```
188188+189189+## Git Versioning
190190+191191+Sortal includes a `Sortal_git_store` module that provides automatic git commits for all contact modifications:
192192+193193+```ocaml
194194+open Sortal
195195+196196+(* Create a git-backed store *)
197197+let git_store = Git_store.create store env in
198198+199199+(* Initialize git repository *)
200200+let () = match Git_store.init git_store with
201201+ | Ok () -> Logs.app (fun m -> m "Git initialized")
202202+ | Error msg -> Logs.err (fun m -> m "Error: %s" msg)
203203+in
204204+205205+(* Save a contact - automatically commits with descriptive message *)
206206+let contact = Contact.make ~handle:"jsmith" ~names:["John Smith"] () in
207207+match Git_store.save git_store contact with
208208+| Ok () -> Logs.app (fun m -> m "Contact saved and committed")
209209+| Error msg -> Logs.err (fun m -> m "Error: %s" msg)
210210+```
211211+212212+**Commit Messages**: All git store operations create descriptive commit messages:
213213+- `save`: "Add contact @handle (Name)" or "Update contact @handle (Name)"
214214+- `delete`: "Delete contact @handle (Name)"
215215+- `add_email`: "Update @handle: add email address@example.com"
216216+- `remove_email`: "Update @handle: remove email address@example.com"
217217+- `add_service`: "Update @handle: add service Kind (url)"
218218+- `add_organization`: "Update @handle: add organization Org Name"
219219+- And similar for all other operations
220220+221221+## Project Status
222222+223223+Fully implemented and tested with 420 imported contacts.
···4455(package
66 (name sortal)
77- (synopsis "Keep track of users and their metadata in a collective web")
77+ (synopsis "Contact metadata management with XDG storage and versioned schemas")
88 (description
99- "Sortal provides a system for mapping usernames to various metadata including URLs, emails, ORCID identifiers, and social media handles.")
99+ "Sortal provides contact metadata management with versioned schemas,
1010+ XDG-compliant storage, git versioning, and CLI tools.
1111+1212+ The library is split into two components:
1313+ - sortal.schema: Versioned data types with minimal dependencies
1414+ - sortal: Core library with storage, git integration, and CLI support")
1015 (depends
1116 (ocaml (>= 5.1.0))
1217 eio
···1419 xdge
1520 jsont
1621 yamlt
1717- fmt))
2222+ bytesrw
2323+ fmt
2424+ cmdliner
2525+ logs))
···11+(** Sortal Schema - Versioned data types and serialization
22+33+ This library provides versioned schema definitions for contact metadata
44+ with no I/O dependencies. It includes:
55+ - Temporal validity support (ISO 8601 dates and ranges)
66+ - Feed subscription types
77+ - Contact metadata schemas (versioned)
88+99+ The schema library depends only on jsont, yamlt, bytesrw, and fmt
1010+ for serialization and formatting. *)
1111+1212+(** {1 Schema Version 1} *)
1313+1414+module V1 : sig
1515+ (** Version 1 of the contact schema (current stable version). *)
1616+1717+ (** Temporal validity support for time-bounded fields. *)
1818+ module Temporal = Sortal_schema_temporal
1919+2020+ (** Feed subscription metadata. *)
2121+ module Feed = Sortal_schema_feed
2222+2323+ (** Contact metadata with temporal support. *)
2424+ module Contact = Sortal_schema_contact_v1
2525+end
2626+2727+(** {1 Current Version Aliases}
2828+2929+ These aliases point to the current stable schema version (V1).
3030+ When V2 is introduced, these will continue pointing to V1 for
3131+ backward compatibility. *)
3232+3333+module Temporal = V1.Temporal
3434+module Feed = V1.Feed
3535+module Contact = V1.Contact
···3232 ]}
3333*)
34343535-(** {1 Core Modules} *)
3535+(** {1 Schema Modules}
3636+3737+ These modules define the data types and serialization formats.
3838+ They are re-exported from {!Sortal_schema} for convenience.
3939+ For version-specific access, use [Sortal_schema.V1.*]. *)
36403741(** Temporal validity support for time-bounded contact fields. *)
3838-module Temporal = Sortal_temporal
4242+module Temporal = Sortal_schema.Temporal
39434044(** Feed subscription metadata. *)
4141-module Feed = Sortal_feed
4545+module Feed = Sortal_schema.Feed
42464347(** Contact metadata with temporal support. *)
4444-module Contact = Sortal_contact
4848+module Contact = Sortal_schema.Contact
4949+5050+(** {1 Core Modules} *)
45514652(** Contact store with XDG-compliant storage. *)
4753module Store = Sortal_store
5454+5555+(** Git-backed contact store with automatic version control. *)
5656+module Git_store = Sortal_git_store
48574958(** Cmdliner integration for CLI applications. *)
5059module Cmd = Sortal_cmd
+33-30
lib/sortal_cmd.ml
lib/core/sortal_cmd.ml
···11open Cmdliner
2233+module Contact = Sortal_schema.Contact
44+module Temporal = Sortal_schema.Temporal
55+36let is_png path =
47 let ext = String.lowercase_ascii (Filename.extension path) in
58 ext = ".png"
···1821let list_cmd xdg =
1922 let store = Sortal_store.create_from_xdg xdg in
2023 let contacts = Sortal_store.list store in
2121- let sorted = List.sort Sortal_contact.compare contacts in
2424+ let sorted = List.sort Contact.compare contacts in
2225 Printf.printf "Total contacts: %d\n" (List.length sorted);
2326 List.iter (fun c ->
2424- Printf.printf "@%s: %s\n" (Sortal_contact.handle c) (Sortal_contact.name c)
2727+ Printf.printf "@%s: %s\n" (Contact.handle c) (Contact.name c)
2528 ) sorted;
2629 0
2730···3033 match Sortal_store.lookup store handle with
3134 | Some c ->
3235 (* Use the pretty printer for rich temporal display *)
3333- Fmt.pr "%a@." Sortal_contact.pp c;
3636+ Fmt.pr "%a@." Contact.pp c;
3437 0
3538 | None -> Logs.err (fun m -> m "Contact not found: %s" handle); 1
3639···4548 (List.length matches)
4649 (if List.length matches = 1 then "" else "es"));
4750 List.iter (fun c ->
4848- Logs.app (fun m -> m "@%s: %s" (Sortal_contact.handle c) (Sortal_contact.name c));
4949- Option.iter (fun e -> Logs.app (fun m -> m " Email: %s" e)) (Sortal_contact.current_email c);
5050- Option.iter (fun u -> Logs.app (fun m -> m " URL: %s" u)) (Sortal_contact.best_url c)
5151+ Logs.app (fun m -> m "@%s: %s" (Contact.handle c) (Contact.name c));
5252+ Option.iter (fun e -> Logs.app (fun m -> m " Email: %s" e)) (Contact.current_email c);
5353+ Option.iter (fun u -> Logs.app (fun m -> m " URL: %s" u)) (Contact.best_url c)
5154 ) matches;
5255 0
5356···5659 let contacts = Sortal_store.list store in
5760 let total = List.length contacts in
5861 let count pred = List.filter pred contacts |> List.length in
5959- let with_email = count (fun c -> Sortal_contact.emails c <> []) in
6060- let with_org = count (fun c -> Sortal_contact.organizations c <> []) in
6161- let with_url = count (fun c -> Sortal_contact.urls c <> []) in
6262- let with_service = count (fun c -> Sortal_contact.services c <> []) in
6363- let with_orcid = count (fun c -> Option.is_some (Sortal_contact.orcid c)) in
6464- let with_feeds = count (fun c -> Option.is_some (Sortal_contact.feeds c)) in
6262+ let with_email = count (fun c -> Contact.emails c <> []) in
6363+ let with_org = count (fun c -> Contact.organizations c <> []) in
6464+ let with_url = count (fun c -> Contact.urls c <> []) in
6565+ let with_service = count (fun c -> Contact.services c <> []) in
6666+ let with_orcid = count (fun c -> Option.is_some (Contact.orcid c)) in
6767+ let with_feeds = count (fun c -> Option.is_some (Contact.feeds c)) in
6568 let total_feeds =
6669 List.fold_left (fun acc c ->
6767- acc + Option.fold ~none:0 ~some:List.length (Sortal_contact.feeds c)
7070+ acc + Option.fold ~none:0 ~some:List.length (Contact.feeds c)
6871 ) 0 contacts
6972 in
7073 let total_services =
7174 List.fold_left (fun acc c ->
7272- acc + List.length (Sortal_contact.services c)
7575+ acc + List.length (Contact.services c)
7376 ) 0 contacts
7477 in
7578 let pct n = float_of_int n /. float_of_int total *. 100. in
···9295 let no_thumbnail = ref 0 in
9396 let errors = ref 0 in
9497 List.iter (fun contact ->
9595- let handle = Sortal_contact.handle contact in
9898+ let handle = Contact.handle contact in
9699 match Sortal_store.thumbnail_path store contact with
97100 | None ->
98101 Logs.info (fun m -> m "@%s: no thumbnail" handle);
···147150 1
148151 | None ->
149152 let emails = match email with
150150- | Some e -> [Sortal_contact.make_email e]
153153+ | Some e -> [Contact.make_email e]
151154 | None -> []
152155 in
153156 let services = match github with
154154- | Some gh -> [Sortal_contact.make_service ~kind:Sortal_contact.Github ~handle:gh (Printf.sprintf "https://github.com/%s" gh)]
157157+ | Some gh -> [Contact.make_service ~kind:Contact.Github ~handle:gh (Printf.sprintf "https://github.com/%s" gh)]
155158 | None -> []
156159 in
157160 let urls = match url with
158158- | Some u -> [Sortal_contact.make_url u]
161161+ | Some u -> [Contact.make_url u]
159162 | None -> []
160163 in
161161- let contact = Sortal_contact.make
164164+ let contact = Contact.make
162165 ~handle
163166 ~names
164167 ?kind
···170173 in
171174 match Sortal_git_store.save git_store contact with
172175 | Ok () ->
173173- Logs.app (fun m -> m "Created contact @%s: %s" handle (Sortal_contact.name contact));
176176+ Logs.app (fun m -> m "Created contact @%s: %s" handle (Contact.name contact));
174177 0
175178 | Error msg ->
176179 Logs.err (fun m -> m "Failed to save contact: %s" msg);
···192195let add_email_cmd handle address type_ from until note xdg env =
193196 let store = Sortal_store.create_from_xdg xdg in
194197 let git_store = Sortal_git_store.create store env in
195195- let email = Sortal_contact.make_email ?type_ ?from ?until ?note address in
198198+ let email = Contact.make_email ?type_ ?from ?until ?note address in
196199 match Sortal_git_store.add_email git_store handle email with
197200 | Ok () ->
198201 Logs.app (fun m -> m "Added email %s to @%s" address handle);
···217220let add_service_cmd handle url kind service_handle label xdg env =
218221 let store = Sortal_store.create_from_xdg xdg in
219222 let git_store = Sortal_git_store.create store env in
220220- let service = Sortal_contact.make_service ?kind ?handle:service_handle ?label url in
223223+ let service = Contact.make_service ?kind ?handle:service_handle ?label url in
221224 match Sortal_git_store.add_service git_store handle service with
222225 | Ok () ->
223226 Logs.app (fun m -> m "Added service %s to @%s" url handle);
···242245let add_org_cmd handle org_name title department from until org_email org_url xdg env =
243246 let store = Sortal_store.create_from_xdg xdg in
244247 let git_store = Sortal_git_store.create store env in
245245- let org = Sortal_contact.make_org ?title ?department ?from ?until ?email:org_email ?url:org_url org_name in
248248+ let org = Contact.make_org ?title ?department ?from ?until ?email:org_email ?url:org_url org_name in
246249 match Sortal_git_store.add_organization git_store handle org with
247250 | Ok () ->
248251 Logs.app (fun m -> m "Added organization %s to @%s" org_name handle);
···267270let add_url_cmd handle url label xdg env =
268271 let store = Sortal_store.create_from_xdg xdg in
269272 let git_store = Sortal_git_store.create store env in
270270- let url_entry = Sortal_contact.make_url ?label url in
273273+ let url_entry = Contact.make_url ?label url in
271274 match Sortal_git_store.add_url git_store handle url_entry with
272275 | Ok () ->
273276 Logs.app (fun m -> m "Added URL %s to @%s" url handle);
···338341339342let add_kind_arg =
340343 let kind_conv =
341341- let parse s = match Sortal_contact.contact_kind_of_string s with
344344+ let parse s = match Contact.contact_kind_of_string s with
342345 | Some k -> Ok k
343346 | None -> Error (`Msg (Printf.sprintf "Invalid kind: %s" s))
344347 in
345345- let print ppf k = Format.pp_print_string ppf (Sortal_contact.contact_kind_to_string k) in
348348+ let print ppf k = Format.pp_print_string ppf (Contact.contact_kind_to_string k) in
346349 Arg.conv (parse, print)
347350 in
348351 Arg.(value & opt (some kind_conv) None & info ["k"; "kind"] ~docv:"KIND"
···371374372375let email_type_arg =
373376 let type_conv =
374374- let parse s = match Sortal_contact.email_type_of_string s with
377377+ let parse s = match Contact.email_type_of_string s with
375378 | Some t -> Ok t
376379 | None -> Error (`Msg (Printf.sprintf "Invalid email type: %s" s))
377380 in
378378- let print ppf t = Format.pp_print_string ppf (Sortal_contact.email_type_to_string t) in
381381+ let print ppf t = Format.pp_print_string ppf (Contact.email_type_to_string t) in
379382 Arg.conv (parse, print)
380383 in
381384 Arg.(value & opt (some type_conv) None & info ["t"; "type"] ~docv:"TYPE"
···396399397400let service_kind_arg =
398401 let kind_conv =
399399- let parse s = match Sortal_contact.service_kind_of_string s with
402402+ let parse s = match Contact.service_kind_of_string s with
400403 | Some k -> Ok k
401404 | None -> Error (`Msg (Printf.sprintf "Invalid service kind: %s" s))
402405 in
403403- let print ppf k = Format.pp_print_string ppf (Sortal_contact.service_kind_to_string k) in
406406+ let print ppf k = Format.pp_print_string ppf (Contact.service_kind_to_string k) in
404407 Arg.conv (parse, print)
405408 in
406409 Arg.(value & opt (some kind_conv) None & info ["k"; "kind"] ~docv:"KIND"
+9-6
lib/sortal_cmd.mli
lib/core/sortal_cmd.mli
···33 This module provides ready-to-use Cmdliner terms for building
44 CLI applications that work with contact metadata. *)
5566+module Contact = Sortal_schema.Contact
77+module Temporal = Sortal_schema.Temporal
88+69(** {1 Command Implementations} *)
710811(** [list_cmd] is a Cmdliner command that lists all contacts.
···4750 @param orcid Optional ORCID identifier
4851 @param xdg XDG context
4952 @param env Eio environment for git operations *)
5050-val add_cmd : string -> string list -> Sortal_contact.contact_kind option ->
5353+val add_cmd : string -> string list -> Contact.contact_kind option ->
5154 string option -> string option -> string option -> string option ->
5255 Xdge.t -> Eio_unix.Stdenv.base -> int
5356···6871 @param note Contextual note
6972 @param xdg XDG context
7073 @param env Eio environment for git operations *)
7171-val add_email_cmd : string -> string -> Sortal_contact.email_type option ->
7474+val add_email_cmd : string -> string -> Contact.email_type option ->
7275 string option -> string option -> string option ->
7376 Xdge.t -> Eio_unix.Stdenv.base -> int
7477···8487 @param label Human-readable label
8588 @param xdg XDG context
8689 @param env Eio environment for git operations *)
8787-val add_service_cmd : string -> string -> Sortal_contact.service_kind option ->
9090+val add_service_cmd : string -> string -> Contact.service_kind option ->
8891 string option -> string option -> Xdge.t -> Eio_unix.Stdenv.base -> int
89929093(** [remove_service_cmd handle url xdg env] removes a service from a contact. *)
···170173val add_names_arg : string list Cmdliner.Term.t
171174172175(** [add_kind_arg] is the optional argument for contact kind. *)
173173-val add_kind_arg : Sortal_contact.contact_kind option Cmdliner.Term.t
176176+val add_kind_arg : Contact.contact_kind option Cmdliner.Term.t
174177175178(** [add_email_arg] is the optional argument for email. *)
176179val add_email_arg : string option Cmdliner.Term.t
···188191val email_address_arg : string Cmdliner.Term.t
189192190193(** [email_type_arg] is the optional argument for email type. *)
191191-val email_type_arg : Sortal_contact.email_type option Cmdliner.Term.t
194194+val email_type_arg : Contact.email_type option Cmdliner.Term.t
192195193196(** [date_arg name] creates a date argument with the given option name. *)
194197val date_arg : string -> string option Cmdliner.Term.t
···200203val service_url_arg : string Cmdliner.Term.t
201204202205(** [service_kind_arg] is the optional argument for service kind. *)
203203-val service_kind_arg : Sortal_contact.service_kind option Cmdliner.Term.t
206206+val service_kind_arg : Contact.service_kind option Cmdliner.Term.t
204207205208(** [service_handle_arg] is the optional argument for service handle. *)
206209val service_handle_arg : string option Cmdliner.Term.t
···11-(** Individual contact metadata.
22-33- This module re-exports the current contact schema version (V1).
44- See {!Sortal_contact_v1} for the full API documentation. *)
55-66-(** {1 Current Schema Version} *)
77-88-module V1 = Sortal_contact_v1
99-(** Current schema version. All functions below are aliases to V1. *)
1010-1111-include module type of Sortal_contact_v1
1212-(** @inline *)
···11+module Contact = Sortal_schema.Contact
22+13type t = {
24 store : Sortal_store.t;
35 env : Eio_unix.Stdenv.base;
···7880 run_git t ["commit"; "-m"; msg]
79818082let save t contact =
8181- let handle = Sortal_contact.handle contact in
8282- let name = Sortal_contact.name contact in
8383+ let handle = Contact.handle contact in
8484+ let name = Contact.name contact in
8385 let filename = handle ^ ".yaml" in
84868587 (* Check if contact already exists *)
···106108 match Sortal_store.lookup t.store handle with
107109 | None -> Error (Printf.sprintf "Contact not found: %s" handle)
108110 | Some contact ->
109109- let name = Sortal_contact.name contact in
111111+ let name = Contact.name contact in
110112 let filename = handle ^ ".yaml" in
111113112114 (* Delete from store *)
···129131 let filename = handle ^ ".yaml" in
130132 commit_file t filename msg
131133132132-let add_email t handle (email : Sortal_contact_v1.email) =
134134+let add_email t handle (email : Contact.email) =
133135 let msg = Printf.sprintf "Update @%s: add email %s"
134136 handle email.address in
135137 match Sortal_store.add_email t.store handle email with
···152154 let filename = handle ^ ".yaml" in
153155 commit_file t filename msg
154156155155-let add_service t handle (service : Sortal_contact_v1.service) =
157157+let add_service t handle (service : Contact.service) =
156158 let kind_str = match service.kind with
157157- | Some k -> Sortal_contact.service_kind_to_string k
159159+ | Some k -> Contact.service_kind_to_string k
158160 | None -> "unknown"
159161 in
160162 let msg = Printf.sprintf "Update @%s: add service %s (%s)"
···179181 let filename = handle ^ ".yaml" in
180182 commit_file t filename msg
181183182182-let add_organization t handle (org : Sortal_contact_v1.organization) =
184184+let add_organization t handle (org : Contact.organization) =
183185 let msg = Printf.sprintf "Update @%s: add organization %s"
184186 handle org.name in
185187 match Sortal_store.add_organization t.store handle org with
···202204 let filename = handle ^ ".yaml" in
203205 commit_file t filename msg
204206205205-let add_url t handle (url_entry : Sortal_contact_v1.url_entry) =
207207+let add_url t handle (url_entry : Contact.url_entry) =
206208 let msg = Printf.sprintf "Update @%s: add URL %s"
207209 handle url_entry.url in
208210 match Sortal_store.add_url t.store handle url_entry with
···55 automatically committed to a git repository with descriptive commit
66 messages. *)
7788+module Contact = Sortal_schema.Contact
99+810type t
911(** A git-backed contact store. *)
1012···32343335(** {1 Contact Operations} *)
34363535-val save : t -> Sortal_contact.t -> (unit, string) result
3737+val save : t -> Contact.t -> (unit, string) result
3638(** [save t contact] saves a contact and commits the change to git.
37393840 If the contact is new, commits with message "Add contact @handle (Name)".
···50525153(** {1 Contact Modification} *)
52545353-val add_email : t -> string -> Sortal_contact.email -> (unit, string) result
5555+val add_email : t -> string -> Contact.email -> (unit, string) result
5456(** [add_email t handle email] adds an email to a contact and commits.
55575658 Commits with message "Update @handle: add email address@example.com". *)
···60626163 Commits with message "Update @handle: remove email address@example.com". *)
62646363-val add_service : t -> string -> Sortal_contact.service -> (unit, string) result
6565+val add_service : t -> string -> Contact.service -> (unit, string) result
6466(** [add_service t handle service] adds a service to a contact and commits.
65676668 Commits with message "Update @handle: add service Kind (url)". *)
···70727173 Commits with message "Update @handle: remove service url". *)
72747373-val add_organization : t -> string -> Sortal_contact.organization -> (unit, string) result
7575+val add_organization : t -> string -> Contact.organization -> (unit, string) result
7476(** [add_organization t handle org] adds an organization and commits.
75777678 Commits with message "Update @handle: add organization Org Name". *)
···80828183 Commits with message "Update @handle: remove organization Org Name". *)
82848383-val add_url : t -> string -> Sortal_contact.url_entry -> (unit, string) result
8585+val add_url : t -> string -> Contact.url_entry -> (unit, string) result
8486(** [add_url t handle url_entry] adds a URL and commits.
85878688 Commits with message "Update @handle: add URL url". *)
···92949395(** {1 Low-level Operations} *)
94969595-val update_contact : t -> string -> (Sortal_contact.t -> Sortal_contact.t) ->
9797+val update_contact : t -> string -> (Contact.t -> Contact.t) ->
9698 msg:string -> (unit, string) result
9799(** [update_contact t handle f ~msg] updates a contact and commits with custom message.
98100
-362
lib/sortal_store.ml
···11-type t = {
22- xdg : Xdge.t; [@warning "-69"]
33- data_dir : Eio.Fs.dir_ty Eio.Path.t;
44-}
55-66-let create fs app_name =
77- let xdg = Xdge.create fs app_name in
88- let data_dir = Xdge.data_dir xdg in
99- { xdg; data_dir }
1010-1111-let create_from_xdg xdg =
1212- let data_dir = Xdge.data_dir xdg in
1313- { xdg; data_dir }
1414-1515-let contact_file t handle =
1616- Eio.Path.(t.data_dir / (handle ^ ".yaml"))
1717-1818-let save t contact =
1919- let path = contact_file t (Sortal_contact.handle contact) in
2020- let buf = Buffer.create 4096 in
2121- let writer = Bytesrw.Bytes.Writer.of_buffer buf in
2222- match Yamlt.encode Sortal_contact.json_t contact ~eod:true writer with
2323- | Ok () -> Eio.Path.save ~create:(`Or_truncate 0o644) path (Buffer.contents buf)
2424- | Error err -> failwith ("Failed to encode contact: " ^ err)
2525-2626-let lookup t handle =
2727- let path = contact_file t handle in
2828- try
2929- let yaml_str = Eio.Path.load path in
3030- let reader = Bytesrw.Bytes.Reader.of_string yaml_str in
3131- match Yamlt.decode Sortal_contact.json_t reader with
3232- | Ok contact -> Some contact
3333- | Error msg ->
3434- Logs.warn (fun m -> m "Failed to decode contact %s: %s" handle msg);
3535- None
3636- with exn ->
3737- Logs.warn (fun m -> m "Failed to load contact %s: %s" handle (Printexc.to_string exn));
3838- None
3939-4040-let delete t handle =
4141- let path = contact_file t handle in
4242- try
4343- Eio.Path.unlink path
4444- with
4545- | _ -> ()
4646-4747-(* Contact modification helpers *)
4848-let update_contact t handle f =
4949- match lookup t handle with
5050- | None -> Error (Printf.sprintf "Contact not found: %s" handle)
5151- | Some contact ->
5252- let updated = f contact in
5353- save t updated;
5454- Ok ()
5555-5656-let add_email t handle (email : Sortal_contact_v1.email) =
5757- match lookup t handle with
5858- | None -> Error (Printf.sprintf "Contact not found: %s" handle)
5959- | Some contact ->
6060- let emails = Sortal_contact.emails contact in
6161- (* Check for duplicate email address *)
6262- if List.exists (fun (e : Sortal_contact_v1.email) -> e.address = email.address) emails then
6363- Error (Printf.sprintf "Email %s already exists for contact @%s" email.address handle)
6464- else
6565- update_contact t handle (fun contact ->
6666- let emails = Sortal_contact.emails contact in
6767- Sortal_contact.make
6868- ~handle:(Sortal_contact.handle contact)
6969- ~names:(Sortal_contact.names contact)
7070- ~kind:(Sortal_contact.kind contact)
7171- ~emails:(emails @ [email])
7272- ~organizations:(Sortal_contact.organizations contact)
7373- ~urls:(Sortal_contact.urls contact)
7474- ~services:(Sortal_contact.services contact)
7575- ?icon:(Sortal_contact.icon contact)
7676- ?thumbnail:(Sortal_contact.thumbnail contact)
7777- ?orcid:(Sortal_contact.orcid contact)
7878- ?feeds:(Sortal_contact.feeds contact)
7979- ()
8080- )
8181-8282-let remove_email t handle address =
8383- update_contact t handle (fun contact ->
8484- let emails = Sortal_contact.emails contact
8585- |> List.filter (fun (e : Sortal_contact.email) -> e.address <> address) in
8686- Sortal_contact.make
8787- ~handle:(Sortal_contact.handle contact)
8888- ~names:(Sortal_contact.names contact)
8989- ~kind:(Sortal_contact.kind contact)
9090- ~emails
9191- ~organizations:(Sortal_contact.organizations contact)
9292- ~urls:(Sortal_contact.urls contact)
9393- ~services:(Sortal_contact.services contact)
9494- ?icon:(Sortal_contact.icon contact)
9595- ?thumbnail:(Sortal_contact.thumbnail contact)
9696- ?orcid:(Sortal_contact.orcid contact)
9797- ?feeds:(Sortal_contact.feeds contact)
9898- ()
9999- )
100100-101101-let add_service t handle (service : Sortal_contact_v1.service) =
102102- match lookup t handle with
103103- | None -> Error (Printf.sprintf "Contact not found: %s" handle)
104104- | Some contact ->
105105- let services = Sortal_contact.services contact in
106106- (* Check for duplicate service URL *)
107107- if List.exists (fun (s : Sortal_contact_v1.service) -> s.url = service.url) services then
108108- Error (Printf.sprintf "Service URL %s already exists for contact @%s" service.url handle)
109109- else
110110- update_contact t handle (fun contact ->
111111- let services = Sortal_contact.services contact in
112112- Sortal_contact.make
113113- ~handle:(Sortal_contact.handle contact)
114114- ~names:(Sortal_contact.names contact)
115115- ~kind:(Sortal_contact.kind contact)
116116- ~emails:(Sortal_contact.emails contact)
117117- ~organizations:(Sortal_contact.organizations contact)
118118- ~urls:(Sortal_contact.urls contact)
119119- ~services:(services @ [service])
120120- ?icon:(Sortal_contact.icon contact)
121121- ?thumbnail:(Sortal_contact.thumbnail contact)
122122- ?orcid:(Sortal_contact.orcid contact)
123123- ?feeds:(Sortal_contact.feeds contact)
124124- ()
125125- )
126126-127127-let remove_service t handle url =
128128- update_contact t handle (fun contact ->
129129- let services = Sortal_contact.services contact
130130- |> List.filter (fun (s : Sortal_contact.service) -> s.url <> url) in
131131- Sortal_contact.make
132132- ~handle:(Sortal_contact.handle contact)
133133- ~names:(Sortal_contact.names contact)
134134- ~kind:(Sortal_contact.kind contact)
135135- ~emails:(Sortal_contact.emails contact)
136136- ~organizations:(Sortal_contact.organizations contact)
137137- ~urls:(Sortal_contact.urls contact)
138138- ~services
139139- ?icon:(Sortal_contact.icon contact)
140140- ?thumbnail:(Sortal_contact.thumbnail contact)
141141- ?orcid:(Sortal_contact.orcid contact)
142142- ?feeds:(Sortal_contact.feeds contact)
143143- ()
144144- )
145145-146146-let add_organization t handle (org : Sortal_contact_v1.organization) =
147147- match lookup t handle with
148148- | None -> Error (Printf.sprintf "Contact not found: %s" handle)
149149- | Some contact ->
150150- let orgs = Sortal_contact.organizations contact in
151151- (* Check for exact duplicate organization (same name, title, and department) *)
152152- let is_duplicate = List.exists (fun (o : Sortal_contact_v1.organization) ->
153153- o.name = org.name &&
154154- o.title = org.title &&
155155- o.department = org.department
156156- ) orgs in
157157- if is_duplicate then
158158- Error (Printf.sprintf "Organization %s with the same title/department already exists for contact @%s" org.name handle)
159159- else
160160- update_contact t handle (fun contact ->
161161- let orgs = Sortal_contact.organizations contact in
162162- Sortal_contact.make
163163- ~handle:(Sortal_contact.handle contact)
164164- ~names:(Sortal_contact.names contact)
165165- ~kind:(Sortal_contact.kind contact)
166166- ~emails:(Sortal_contact.emails contact)
167167- ~organizations:(orgs @ [org])
168168- ~urls:(Sortal_contact.urls contact)
169169- ~services:(Sortal_contact.services contact)
170170- ?icon:(Sortal_contact.icon contact)
171171- ?thumbnail:(Sortal_contact.thumbnail contact)
172172- ?orcid:(Sortal_contact.orcid contact)
173173- ?feeds:(Sortal_contact.feeds contact)
174174- ()
175175- )
176176-177177-let remove_organization t handle name =
178178- update_contact t handle (fun contact ->
179179- let orgs = Sortal_contact.organizations contact
180180- |> List.filter (fun (o : Sortal_contact.organization) -> o.name <> name) in
181181- Sortal_contact.make
182182- ~handle:(Sortal_contact.handle contact)
183183- ~names:(Sortal_contact.names contact)
184184- ~kind:(Sortal_contact.kind contact)
185185- ~emails:(Sortal_contact.emails contact)
186186- ~organizations:orgs
187187- ~urls:(Sortal_contact.urls contact)
188188- ~services:(Sortal_contact.services contact)
189189- ?icon:(Sortal_contact.icon contact)
190190- ?thumbnail:(Sortal_contact.thumbnail contact)
191191- ?orcid:(Sortal_contact.orcid contact)
192192- ?feeds:(Sortal_contact.feeds contact)
193193- ()
194194- )
195195-196196-let add_url t handle (url_entry : Sortal_contact_v1.url_entry) =
197197- match lookup t handle with
198198- | None -> Error (Printf.sprintf "Contact not found: %s" handle)
199199- | Some contact ->
200200- let urls = Sortal_contact.urls contact in
201201- (* Check for duplicate URL *)
202202- if List.exists (fun (u : Sortal_contact_v1.url_entry) -> u.url = url_entry.url) urls then
203203- Error (Printf.sprintf "URL %s already exists for contact @%s" url_entry.url handle)
204204- else
205205- update_contact t handle (fun contact ->
206206- let urls = Sortal_contact.urls contact in
207207- Sortal_contact.make
208208- ~handle:(Sortal_contact.handle contact)
209209- ~names:(Sortal_contact.names contact)
210210- ~kind:(Sortal_contact.kind contact)
211211- ~emails:(Sortal_contact.emails contact)
212212- ~organizations:(Sortal_contact.organizations contact)
213213- ~urls:(urls @ [url_entry])
214214- ~services:(Sortal_contact.services contact)
215215- ?icon:(Sortal_contact.icon contact)
216216- ?thumbnail:(Sortal_contact.thumbnail contact)
217217- ?orcid:(Sortal_contact.orcid contact)
218218- ?feeds:(Sortal_contact.feeds contact)
219219- ()
220220- )
221221-222222-let remove_url t handle url =
223223- update_contact t handle (fun contact ->
224224- let urls = Sortal_contact.urls contact
225225- |> List.filter (fun (u : Sortal_contact.url_entry) -> u.url <> url) in
226226- Sortal_contact.make
227227- ~handle:(Sortal_contact.handle contact)
228228- ~names:(Sortal_contact.names contact)
229229- ~kind:(Sortal_contact.kind contact)
230230- ~emails:(Sortal_contact.emails contact)
231231- ~organizations:(Sortal_contact.organizations contact)
232232- ~urls
233233- ~services:(Sortal_contact.services contact)
234234- ?icon:(Sortal_contact.icon contact)
235235- ?thumbnail:(Sortal_contact.thumbnail contact)
236236- ?orcid:(Sortal_contact.orcid contact)
237237- ?feeds:(Sortal_contact.feeds contact)
238238- ()
239239- )
240240-241241-let list t =
242242- try
243243- let entries = Eio.Path.read_dir t.data_dir in
244244- List.filter_map (fun entry ->
245245- if Filename.check_suffix entry ".yaml" then
246246- let handle = Filename.chop_suffix entry ".yaml" in
247247- lookup t handle
248248- else
249249- None
250250- ) entries
251251- with
252252- | _ -> []
253253-254254-let thumbnail_path t contact =
255255- Sortal_contact.thumbnail contact
256256- |> Option.map (fun relative_path -> Eio.Path.(t.data_dir / relative_path))
257257-258258-let png_thumbnail_path t contact =
259259- match Sortal_contact.thumbnail contact with
260260- | None -> None
261261- | Some relative_path ->
262262- let base = Filename.remove_extension relative_path in
263263- let png_path = base ^ ".png" in
264264- let full_path = Eio.Path.(t.data_dir / png_path) in
265265- try
266266- ignore (Eio.Path.load full_path);
267267- Some full_path
268268- with _ -> None
269269-270270-let handle_of_name name =
271271- let name = String.lowercase_ascii name in
272272- let words = String.split_on_char ' ' name in
273273- let initials = String.concat "" (List.map (fun w -> String.sub w 0 1) words) in
274274- initials ^ List.hd (List.rev words)
275275-276276-let find_by_name t name =
277277- let name_lower = String.lowercase_ascii name in
278278- let all_contacts = list t in
279279- let matches = List.filter (fun c ->
280280- List.exists (fun n -> String.lowercase_ascii n = name_lower)
281281- (Sortal_contact.names c)
282282- ) all_contacts in
283283- match matches with
284284- | [contact] -> contact
285285- | [] -> raise Not_found
286286- | _ -> raise (Invalid_argument ("Multiple contacts match: " ^ name))
287287-288288-let find_by_name_opt t name =
289289- try
290290- Some (find_by_name t name)
291291- with
292292- | Not_found | Invalid_argument _ -> None
293293-294294-let contains_substring ~needle haystack =
295295- let needle_len = String.length needle in
296296- let haystack_len = String.length haystack in
297297- if needle_len = 0 then true
298298- else if needle_len > haystack_len then false
299299- else
300300- let rec check i =
301301- if i > haystack_len - needle_len then false
302302- else if String.sub haystack i needle_len = needle then true
303303- else check (i + 1)
304304- in
305305- check 0
306306-307307-let search_all t query =
308308- let query_lower = String.lowercase_ascii query in
309309- let all = list t in
310310- let matches = List.filter (fun c ->
311311- List.exists (fun name ->
312312- let name_lower = String.lowercase_ascii name in
313313- String.equal name_lower query_lower ||
314314- String.starts_with ~prefix:query_lower name_lower ||
315315- contains_substring ~needle:query_lower name_lower ||
316316- (String.contains name_lower ' ' &&
317317- String.split_on_char ' ' name_lower |> List.exists (fun word ->
318318- String.starts_with ~prefix:query_lower word
319319- ))
320320- ) (Sortal_contact.names c)
321321- ) all in
322322- List.sort Sortal_contact.compare matches
323323-324324-let find_by_email_at t ~email ~date =
325325- let all = list t in
326326- List.find_opt (fun c ->
327327- let emails_at_date = Sortal_contact.emails_at c ~date in
328328- List.exists (fun e -> e.Sortal_contact_v1.address = email) emails_at_date
329329- ) all
330330-331331-let find_by_org t ~org ?from ?until () =
332332- let org_lower = String.lowercase_ascii org in
333333- let all = list t in
334334- let matches = List.filter (fun c ->
335335- let orgs : Sortal_contact_v1.organization list = Sortal_contact.organizations c in
336336- let filtered_orgs = match from, until with
337337- | None, None -> orgs
338338- | _, _ -> Sortal_temporal.filter ~get:(fun (o : Sortal_contact_v1.organization) -> o.range)
339339- ~from ~until orgs
340340- in
341341- List.exists (fun (o : Sortal_contact_v1.organization) ->
342342- contains_substring ~needle:org_lower
343343- (String.lowercase_ascii o.name)
344344- ) filtered_orgs
345345- ) all in
346346- List.sort Sortal_contact.compare matches
347347-348348-let list_at t ~date =
349349- let all = list t in
350350- List.filter (fun c ->
351351- (* Contact is active if it has any email, org, or URL valid at date *)
352352- let has_email = Sortal_contact.emails_at c ~date <> [] in
353353- let has_org = Sortal_contact.organization_at c ~date <> None in
354354- let has_url = Sortal_contact.url_at c ~date <> None in
355355- has_email || has_org || has_url
356356- ) all
357357-358358-let pp ppf t =
359359- let all = list t in
360360- Fmt.pf ppf "@[<v>%a: %d contacts stored in XDG data directory@]"
361361- (Fmt.styled `Bold Fmt.string) "Sortal Store"
362362- (List.length all)
+21-18
lib/sortal_store.mli
lib/core/sortal_store.mli
···44 using XDG-compliant storage locations. Contacts are stored as
55 YAML files (one per contact) using the handle as the filename. *)
6677+module Contact = Sortal_schema.Contact
88+module Temporal = Sortal_schema.Temporal
99+710type t
811912(** [create fs app_name] creates a new contact store.
···3437 named "handle.yaml" in the XDG data directory.
35383639 If a contact with the same handle already exists, it is overwritten. *)
3737-val save : t -> Sortal_contact.t -> unit
4040+val save : t -> Contact.t -> unit
38413942(** [lookup t handle] retrieves a contact by handle.
4043···4245 and deserializes it if found.
43464447 @return [Some contact] if found, [None] if not found or deserialization fails *)
4545-val lookup : t -> string -> Sortal_contact.t option
4848+val lookup : t -> string -> Contact.t option
46494750(** [delete t handle] removes a contact from the store.
4851···5962 @param email The email entry to add
6063 @return [Ok ()] on success, [Error msg] if contact not found
6164 @raise Failure if the contact cannot be saved *)
6262-val add_email : t -> string -> Sortal_contact.email -> (unit, string) result
6565+val add_email : t -> string -> Contact.email -> (unit, string) result
63666467(** [remove_email t handle address] removes an email from a contact.
6568···7780 @param handle The contact handle
7881 @param service The service entry to add
7982 @return [Ok ()] on success, [Error msg] if contact not found *)
8080-val add_service : t -> string -> Sortal_contact.service -> (unit, string) result
8383+val add_service : t -> string -> Contact.service -> (unit, string) result
81848285(** [remove_service t handle url] removes a service from a contact.
8386···9598 @param handle The contact handle
9699 @param org The organization entry to add
97100 @return [Ok ()] on success, [Error msg] if contact not found *)
9898-val add_organization : t -> string -> Sortal_contact.organization -> (unit, string) result
101101+val add_organization : t -> string -> Contact.organization -> (unit, string) result
99102100103(** [remove_organization t handle name] removes an organization from a contact.
101104···113116 @param handle The contact handle
114117 @param url_entry The URL entry to add
115118 @return [Ok ()] on success, [Error msg] if contact not found *)
116116-val add_url : t -> string -> Sortal_contact.url_entry -> (unit, string) result
119119+val add_url : t -> string -> Contact.url_entry -> (unit, string) result
117120118121(** [remove_url t handle url] removes a URL from a contact.
119122···133136 @param handle The contact handle
134137 @param f Function to transform the contact
135138 @return [Ok ()] on success, [Error msg] if contact not found *)
136136-val update_contact : t -> string -> (Sortal_contact.t -> Sortal_contact.t) -> (unit, string) result
139139+val update_contact : t -> string -> (Contact.t -> Contact.t) -> (unit, string) result
137140138141(** [list t] returns all contacts in the store.
139142···142145 silently skipped.
143146144147 @return A list of all successfully loaded contacts *)
145145-val list : t -> Sortal_contact.t list
148148+val list : t -> Contact.t list
146149147150(** [thumbnail_path t contact] returns the absolute filesystem path to the contact's thumbnail.
148151···151154152155 @param t The Sortal store
153156 @param contact The contact whose thumbnail path to retrieve *)
154154-val thumbnail_path : t -> Sortal_contact.t -> Eio.Fs.dir_ty Eio.Path.t option
157157+val thumbnail_path : t -> Contact.t -> Eio.Fs.dir_ty Eio.Path.t option
155158156159(** [png_thumbnail_path t contact] returns the path to the PNG version of the contact's thumbnail.
157160···161164162165 @param t The Sortal store
163166 @param contact The contact whose PNG thumbnail path to retrieve *)
164164-val png_thumbnail_path : t -> Sortal_contact.t -> Eio.Fs.dir_ty Eio.Path.t option
167167+val png_thumbnail_path : t -> Contact.t -> Eio.Fs.dir_ty Eio.Path.t option
165168166169(** {1 Searching} *)
167170···174177 @return The matching contact if exactly one match is found
175178 @raise Not_found if no contacts match the name
176179 @raise Invalid_argument if multiple contacts match the name *)
177177-val find_by_name : t -> string -> Sortal_contact.t
180180+val find_by_name : t -> string -> Contact.t
178181179182(** [find_by_name_opt t name] searches for contacts by name, returning an option.
180183···183186184187 @param name The name to search for (case-insensitive)
185188 @return [Some contact] if exactly one match is found, [None] otherwise *)
186186-val find_by_name_opt : t -> string -> Sortal_contact.t option
189189+val find_by_name_opt : t -> string -> Contact.t option
187190188191(** [search_all t query] searches for contacts matching a query string.
189192···197200 @param t The contact store
198201 @param query The search query (case-insensitive)
199202 @return A list of matching contacts, sorted by handle *)
200200-val search_all : t -> string -> Sortal_contact.t list
203203+val search_all : t -> string -> Contact.t list
201204202205(** {1 Temporal Queries} *)
203206···208211 @param email Email address to search for
209212 @param date ISO 8601 date string
210213 @return The first matching contact, or [None] if not found *)
211211-val find_by_email_at : t -> email:string -> date:Sortal_temporal.date ->
212212- Sortal_contact.t option
214214+val find_by_email_at : t -> email:string -> date:Temporal.date ->
215215+ Contact.t option
213216214217(** [find_by_org t ~org ?from ?until ()] finds contacts who worked at an organization.
215218···220223 @param from Start date of period to check (inclusive, optional)
221224 @param until End date of period to check (exclusive, optional)
222225 @return List of matching contacts, sorted by handle *)
223223-val find_by_org : t -> org:string -> ?from:Sortal_temporal.date ->
224224- ?until:Sortal_temporal.date -> unit -> Sortal_contact.t list
226226+val find_by_org : t -> org:string -> ?from:Temporal.date ->
227227+ ?until:Temporal.date -> unit -> Contact.t list
225228226229(** [list_at t ~date] returns contacts that were active at a specific date.
227230···230233231234 @param date ISO 8601 date string
232235 @return List of active contacts at that date *)
233233-val list_at : t -> date:Sortal_temporal.date -> Sortal_contact.t list
236236+val list_at : t -> date:Temporal.date -> Contact.t list
234237235238(** {1 Utilities} *)
236239