···11+This is the Sortal library for mapping usernames to contact metadata.
22+33+The library follows OCaml best practices with abstract types (`type t`) per
44+module, comprehensive constructors/accessors, and proper pretty printers. Each
55+core concept gets its own module with a clean interface.
66+77+## Design Principles
88+99+1. **XDG Storage**: All contact data is stored in XDG-compliant locations using the xdge library
1010+2. **JSON Format**: Contact metadata is serialized using jsont for type-safe JSON encoding/decoding
1111+3. **Nested Modules**: The Contact module is nested within the main Sortal module following the canonical `type t` pattern
1212+4. **One File Per Contact**: Each contact is stored as a separate JSON file named "{handle}.json"
1313+1414+## Storage Location
1515+1616+Contact data is stored in the XDG data directory for the application:
1717+- Default: `$HOME/.local/share/{app_name}/contacts/`
1818+- Can be overridden via `${APP_NAME}_DATA_DIR` or `XDG_DATA_HOME`
1919+2020+## Metadata Fields
2121+2222+The Contact type includes the following metadata fields (all optional except handle and names):
2323+- handle: Unique identifier/username
2424+- names: List of full names (primary name first)
2525+- email: Email address
2626+- icon: Avatar/icon URL
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
+162
stack/sortal/README.md
···11+# Sortal - Username to Metadata Mapping Library
22+33+Sortal is an OCaml library that provides a system for mapping usernames to various metadata including URLs, emails, ORCID identifiers, and social media handles. It uses the XDG Base Directory Specification for storage locations and jsont for JSON encoding/decoding.
44+55+## Features
66+77+- **XDG-compliant storage**: Contact metadata stored in standard XDG data directories
88+- **JSON format**: Type-safe JSON encoding/decoding using jsont
99+- **Rich metadata**: Support for multiple names, email, social media handles (GitHub, Twitter, Bluesky, Mastodon), ORCID, URLs, and Atom feeds
1010+- **Simple API**: Easy-to-use functions for saving, loading, searching, and deleting contacts
1111+1212+## Metadata Fields
1313+1414+Each contact can include:
1515+1616+- `handle`: Unique identifier/username (required)
1717+- `names`: List of full names with primary name first (required)
1818+- `email`: Email address
1919+- `icon`: Avatar/icon URL
2020+- `github`: GitHub username
2121+- `twitter`: Twitter/X username
2222+- `bluesky`: Bluesky handle
2323+- `mastodon`: Mastodon handle (with instance)
2424+- `orcid`: ORCID identifier
2525+- `url`: Personal/professional website
2626+- `atom_feeds`: List of Atom/RSS feed URLs
2727+2828+## Storage
2929+3030+Contact data is stored as individual JSON files in the XDG data directory:
3131+3232+- Default location: `$HOME/.local/share/{app_name}/`
3333+- Override with: `${APP_NAME}_DATA_DIR` or `XDG_DATA_HOME`
3434+- Each contact stored as: `{handle}.json`
3535+3636+## Usage Example
3737+3838+### Basic Usage
3939+4040+```ocaml
4141+(* Create a contact store from filesystem *)
4242+let store = Sortal.create env#fs "myapp" in
4343+4444+(* Or create from an existing XDG context (recommended when using eiocmd) *)
4545+let store = Sortal.create_from_xdg xdg in
4646+4747+(* Create a new contact *)
4848+let contact = Sortal.Contact.make
4949+ ~handle:"avsm"
5050+ ~names:["Anil Madhavapeddy"]
5151+ ~email:"anil@recoil.org"
5252+ ~github:"avsm"
5353+ ~orcid:"0000-0002-7890-1234"
5454+ () in
5555+5656+(* Save the contact *)
5757+Sortal.save store contact;
5858+5959+(* Lookup by handle *)
6060+match Sortal.lookup store "avsm" with
6161+| Some c -> Printf.printf "Found: %s\n" (Sortal.Contact.name c)
6262+| None -> Printf.printf "Not found\n"
6363+6464+(* Search for contacts by name *)
6565+let matches = Sortal.search_all store "Anil" in
6666+List.iter (fun c ->
6767+ Printf.printf "%s: %s\n"
6868+ (Sortal.Contact.handle c)
6969+ (Sortal.Contact.name c)
7070+) matches
7171+7272+(* List all contacts *)
7373+let all_contacts = Sortal.list store in
7474+List.iter (fun c ->
7575+ Printf.printf "%s: %s\n"
7676+ (Sortal.Contact.handle c)
7777+ (Sortal.Contact.name c)
7878+) all_contacts
7979+```
8080+8181+### Integration with Eiocmd (for CLI applications)
8282+8383+```ocaml
8484+open Cmdliner
8585+8686+let my_command env xdg profile =
8787+ (* Create store from XDG context *)
8888+ let store = Sortal.create_from_xdg xdg in
8989+9090+ (* Search for a contact *)
9191+ let matches = Sortal.search_all store "John" in
9292+ List.iter (fun c ->
9393+ match Sortal.Contact.best_url c with
9494+ | Some url -> Logs.app (fun m -> m "%s: %s" (Sortal.Contact.name c) url)
9595+ | None -> ()
9696+ ) matches;
9797+ 0
9898+9999+(* Use Sortal's built-in commands *)
100100+let () =
101101+ let info = Cmd.info "myapp" in
102102+ let my_cmd = Eiocmd.run ~info ~app_name:"myapp" ~service:"myapp"
103103+ Term.(const my_command) in
104104+105105+ (* Include sortal commands as subcommands *)
106106+ let list_contacts = Eiocmd.run ~use_keyeio:false
107107+ ~info:Sortal.Cmd.list_info ~app_name:"myapp" ~service:"myapp"
108108+ Term.(const (fun () -> Sortal.Cmd.list_cmd ()) $ const ()) in
109109+110110+ let cmd = Cmd.group info [my_cmd; list_contacts] in
111111+ exit (Cmd.eval' cmd)
112112+```
113113+114114+## Design Inspiration
115115+116116+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.
117117+118118+## Dependencies
119119+120120+- `eio`: For effect-based I/O
121121+- `xdge`: For XDG Base Directory Specification support
122122+- `jsont`: For type-safe JSON encoding/decoding
123123+- `fmt`: For pretty printing
124124+125125+## API Features
126126+127127+The library provides two main ways to use contact metadata:
128128+129129+1. **Core API**: Direct functions for creating, saving, loading, and searching contacts
130130+ - `create` / `create_from_xdg`: Initialize a contact store
131131+ - `save` / `lookup` / `delete` / `list`: CRUD operations
132132+ - `search_all`: Flexible search across contact names
133133+ - `find_by_name` / `find_by_name_opt`: Exact name matching
134134+135135+2. **Cmdliner Integration** (`Sortal.Cmd` module): Ready-to-use CLI commands
136136+ - `list_cmd`: List all contacts
137137+ - `show_cmd`: Show detailed contact information
138138+ - `search_cmd`: Search contacts by name
139139+ - `stats_cmd`: Show database statistics
140140+ - Pre-configured `Cmd.info` and argument definitions for easy integration
141141+142142+## CLI Tool
143143+144144+The library includes a standalone `sortal` CLI tool:
145145+146146+```bash
147147+# List all contacts
148148+sortal list
149149+150150+# Show details for a specific contact
151151+sortal show avsm
152152+153153+# Search for contacts
154154+sortal search "Anil"
155155+156156+# Show database statistics
157157+sortal stats
158158+```
159159+160160+## Project Status
161161+162162+Fully implemented and tested with 409 imported contacts.
···11+open Cmdliner
22+33+(* Main command *)
44+let () =
55+ let info = Cmd.info "sortal"
66+ ~version:"0.1.0"
77+ ~doc:"Contact metadata management"
88+ ~man:[
99+ `S Manpage.s_description;
1010+ `P "Sortal manages contact metadata including URLs, emails, ORCID identifiers, \
1111+ and social media handles. Data is stored as JSON in XDG-compliant locations.";
1212+ `S Manpage.s_commands;
1313+ `P "Use $(b,sortal COMMAND --help) for detailed help on each command.";
1414+ ]
1515+ in
1616+1717+ (* Create command terms using Sortal.Cmd *)
1818+ let list_cmd_term = Term.(const (fun () -> Sortal.Cmd.list_cmd ()) $ const ()) in
1919+ let list_cmd = Eiocmd.run ~use_keyeio:false ~info:Sortal.Cmd.list_info
2020+ ~app_name:"sortal" ~service:"sortal" list_cmd_term in
2121+2222+ let show_cmd_term = Term.(const (fun handle -> Sortal.Cmd.show_cmd handle) $ Sortal.Cmd.handle_arg) in
2323+ let show_cmd = Eiocmd.run ~use_keyeio:false ~info:Sortal.Cmd.show_info
2424+ ~app_name:"sortal" ~service:"sortal" show_cmd_term in
2525+2626+ let search_cmd_term = Term.(const (fun query -> Sortal.Cmd.search_cmd query) $ Sortal.Cmd.query_arg) in
2727+ let search_cmd = Eiocmd.run ~use_keyeio:false ~info:Sortal.Cmd.search_info
2828+ ~app_name:"sortal" ~service:"sortal" search_cmd_term in
2929+3030+ let stats_cmd_term = Term.(const (fun () -> Sortal.Cmd.stats_cmd ()) $ const ()) in
3131+ let stats_cmd = Eiocmd.run ~use_keyeio:false ~info:Sortal.Cmd.stats_info
3232+ ~app_name:"sortal" ~service:"sortal" stats_cmd_term in
3333+3434+ let default_term = Term.(ret (const (`Help (`Pager, None)))) in
3535+3636+ let cmd = Cmd.group info ~default:default_term [
3737+ list_cmd;
3838+ show_cmd;
3939+ search_cmd;
4040+ stats_cmd;
4141+ ] in
4242+4343+ exit (Cmd.eval' cmd)
+16
stack/sortal/dune-project
···11+(lang dune 3.20)
22+33+(name sortal)
44+55+(package
66+ (name sortal)
77+ (synopsis "Username to metadata mapping with XDG storage")
88+ (description
99+ "Sortal provides a system for mapping usernames to various metadata including URLs, emails, ORCID identifiers, and social media handles. It uses XDG Base Directory Specification for storage locations and jsont for JSON encoding/decoding.")
1010+ (depends
1111+ (ocaml (>= 5.0))
1212+ eio
1313+ eio_main
1414+ xdge
1515+ jsont
1616+ fmt))
···11+module Contact = struct
22+ type t = {
33+ handle : string;
44+ names : string list;
55+ email : string option;
66+ icon : string option;
77+ github : string option;
88+ twitter : string option;
99+ bluesky : string option;
1010+ mastodon : string option;
1111+ orcid : string option;
1212+ url : string option;
1313+ atom_feeds : string list option;
1414+ }
1515+1616+ let make ~handle ~names ?email ?icon ?github ?twitter ?bluesky ?mastodon
1717+ ?orcid ?url ?atom_feeds () =
1818+ { handle; names; email; icon; github; twitter; bluesky; mastodon;
1919+ orcid; url; atom_feeds }
2020+2121+ let handle t = t.handle
2222+ let names t = t.names
2323+ let name t = List.hd t.names
2424+ let email t = t.email
2525+ let icon t = t.icon
2626+ let github t = t.github
2727+ let twitter t = t.twitter
2828+ let bluesky t = t.bluesky
2929+ let mastodon t = t.mastodon
3030+ let orcid t = t.orcid
3131+ let url t = t.url
3232+ let atom_feeds t = t.atom_feeds
3333+3434+ let best_url t =
3535+ match t.url with
3636+ | Some v -> Some v
3737+ | None ->
3838+ (match t.github with
3939+ | Some v -> Some ("https://github.com/" ^ v)
4040+ | None ->
4141+ (match t.email with
4242+ | Some v -> Some ("mailto:" ^ v)
4343+ | None -> None))
4444+4545+ let json_t =
4646+ let open Jsont in
4747+ let open Jsont.Object in
4848+ let mem_opt f v ~enc = mem f v ~dec_absent:None ~enc_omit:Option.is_none ~enc in
4949+ let make handle names email icon github twitter bluesky mastodon orcid url atom_feeds =
5050+ { handle; names; email; icon; github; twitter; bluesky; mastodon;
5151+ orcid; url; atom_feeds }
5252+ in
5353+ map ~kind:"Contact" make
5454+ |> mem "handle" string ~enc:handle
5555+ |> mem "names" (list string) ~dec_absent:[] ~enc:names
5656+ |> mem_opt "email" (some string) ~enc:email
5757+ |> mem_opt "icon" (some string) ~enc:icon
5858+ |> mem_opt "github" (some string) ~enc:github
5959+ |> mem_opt "twitter" (some string) ~enc:twitter
6060+ |> mem_opt "bluesky" (some string) ~enc:bluesky
6161+ |> mem_opt "mastodon" (some string) ~enc:mastodon
6262+ |> mem_opt "orcid" (some string) ~enc:orcid
6363+ |> mem_opt "url" (some string) ~enc:url
6464+ |> mem_opt "atom_feeds" (some (list string)) ~enc:atom_feeds
6565+ |> finish
6666+6767+ let compare a b = String.compare a.handle b.handle
6868+6969+ let pp ppf t =
7070+ let open Fmt in
7171+ pf ppf "@[<v>";
7272+ pf ppf "%a: @%a@," (styled `Bold string) "Handle" string t.handle;
7373+ pf ppf "%a: %a@," (styled `Bold string) "Name" string (name t);
7474+ let ns = names t in
7575+ if List.length ns > 1 then
7676+ pf ppf "%a: @[<h>%a@]@," (styled `Bold string) "Aliases"
7777+ (list ~sep:comma string) (List.tl ns);
7878+ (match t.email with
7979+ | Some e -> pf ppf "%a: %a@," (styled `Bold string) "Email" string e
8080+ | None -> ());
8181+ (match t.github with
8282+ | Some g -> pf ppf "%a: https://github.com/%a@,"
8383+ (styled `Bold string) "GitHub" string g
8484+ | None -> ());
8585+ (match t.twitter with
8686+ | Some tw -> pf ppf "%a: https://twitter.com/%a@,"
8787+ (styled `Bold string) "Twitter" string tw
8888+ | None -> ());
8989+ (match t.bluesky with
9090+ | Some b -> pf ppf "%a: %a@," (styled `Bold string) "Bluesky" string b
9191+ | None -> ());
9292+ (match t.mastodon with
9393+ | Some m -> pf ppf "%a: %a@," (styled `Bold string) "Mastodon" string m
9494+ | None -> ());
9595+ (match t.orcid with
9696+ | Some o -> pf ppf "%a: https://orcid.org/%a@,"
9797+ (styled `Bold string) "ORCID" string o
9898+ | None -> ());
9999+ (match t.url with
100100+ | Some u -> pf ppf "%a: %a@," (styled `Bold string) "URL" string u
101101+ | None -> ());
102102+ (match t.icon with
103103+ | Some i -> pf ppf "%a: %a@," (styled `Bold string) "Icon" string i
104104+ | None -> ());
105105+ (match t.atom_feeds with
106106+ | Some feeds when feeds <> [] ->
107107+ pf ppf "%a: @[<h>%a@]@," (styled `Bold string) "Atom Feeds"
108108+ (list ~sep:comma string) feeds
109109+ | _ -> ());
110110+ pf ppf "@]"
111111+end
112112+113113+type t = {
114114+ xdg : Xdge.t; [@warning "-69"]
115115+ data_dir : Eio.Fs.dir_ty Eio.Path.t;
116116+}
117117+118118+let create fs app_name =
119119+ let xdg = Xdge.create fs app_name in
120120+ let data_dir = Xdge.data_dir xdg in
121121+ { xdg; data_dir }
122122+123123+let contact_file t handle =
124124+ Eio.Path.(t.data_dir / (handle ^ ".json"))
125125+126126+let save t contact =
127127+ let path = contact_file t (Contact.handle contact) in
128128+ match Jsont_bytesrw.encode_string Contact.json_t contact with
129129+ | Ok json_str -> Eio.Path.save ~create:(`Or_truncate 0o644) path json_str
130130+ | Error err -> failwith ("Failed to encode contact: " ^ err)
131131+132132+let lookup t handle =
133133+ let path = contact_file t handle in
134134+ try
135135+ let content = Eio.Path.load path in
136136+ match Jsont_bytesrw.decode_string Contact.json_t content with
137137+ | Ok contact -> Some contact
138138+ | Error _ -> None
139139+ with
140140+ | _ -> None
141141+142142+let delete t handle =
143143+ let path = contact_file t handle in
144144+ try
145145+ Eio.Path.unlink path
146146+ with
147147+ | _ -> ()
148148+149149+let list t =
150150+ try
151151+ let entries = Eio.Path.read_dir t.data_dir in
152152+ List.filter_map (fun entry ->
153153+ if Filename.check_suffix entry ".json" then
154154+ let handle = Filename.chop_suffix entry ".json" in
155155+ lookup t handle
156156+ else
157157+ None
158158+ ) entries
159159+ with
160160+ | _ -> []
161161+162162+let handle_of_name name =
163163+ let name = String.lowercase_ascii name in
164164+ let words = String.split_on_char ' ' name in
165165+ let initials = String.concat "" (List.map (fun w -> String.sub w 0 1) words) in
166166+ initials ^ List.hd (List.rev words)
167167+168168+let find_by_name t name =
169169+ let name_lower = String.lowercase_ascii name in
170170+ let all_contacts = list t in
171171+ let matches = List.filter (fun c ->
172172+ List.exists (fun n -> String.lowercase_ascii n = name_lower)
173173+ (Contact.names c)
174174+ ) all_contacts in
175175+ match matches with
176176+ | [contact] -> contact
177177+ | [] -> raise Not_found
178178+ | _ -> raise (Invalid_argument ("Multiple contacts match: " ^ name))
179179+180180+let find_by_name_opt t name =
181181+ try
182182+ Some (find_by_name t name)
183183+ with
184184+ | Not_found | Invalid_argument _ -> None
185185+186186+(* Convenience functions *)
187187+let create_from_xdg xdg =
188188+ let data_dir = Xdge.data_dir xdg in
189189+ { xdg; data_dir }
190190+191191+let search_all t query =
192192+ let query_lower = String.lowercase_ascii query in
193193+ let all = list t in
194194+ let matches = List.filter (fun c ->
195195+ List.exists (fun name ->
196196+ let name_lower = String.lowercase_ascii name in
197197+ (* Check for exact match *)
198198+ String.equal name_lower query_lower ||
199199+ (* Check if name starts with query *)
200200+ String.starts_with ~prefix:query_lower name_lower ||
201201+ (* For multi-word names, check if any word starts with query *)
202202+ (String.contains name_lower ' ' &&
203203+ String.split_on_char ' ' name_lower |> List.exists (fun word ->
204204+ String.starts_with ~prefix:query_lower word
205205+ ))
206206+ ) (Contact.names c)
207207+ ) all in
208208+ List.sort Contact.compare matches
209209+210210+let pp ppf t =
211211+ let all = list t in
212212+ Fmt.pf ppf "@[<v>%a: %d contacts stored in XDG data directory@]"
213213+ (Fmt.styled `Bold Fmt.string) "Sortal Store"
214214+ (List.length all)
215215+216216+(* Cmdliner integration *)
217217+module Cmd = struct
218218+ open Cmdliner
219219+220220+ (* Command implementations *)
221221+ let list_cmd () _env xdg _profile =
222222+ let store = create_from_xdg xdg in
223223+ let contacts = list store in
224224+ let sorted = List.sort Contact.compare contacts in
225225+226226+ Logs.app (fun m -> m "Total contacts: %d" (List.length sorted));
227227+ List.iter (fun c ->
228228+ Logs.app (fun m -> m "@%s: %s"
229229+ (Contact.handle c)
230230+ (Contact.name c))
231231+ ) sorted;
232232+ 0
233233+234234+ let show_cmd handle _env xdg _profile =
235235+ let store = create_from_xdg xdg in
236236+ match lookup store handle with
237237+ | Some c ->
238238+ Format.printf "%a@." Contact.pp c;
239239+ 0
240240+ | None ->
241241+ Logs.err (fun m -> m "Contact not found: %s" handle);
242242+ 1
243243+244244+ let search_cmd query _env xdg _profile =
245245+ let store = create_from_xdg xdg in
246246+ let matches = search_all store query in
247247+248248+ if matches = [] then (
249249+ Logs.warn (fun m -> m "No contacts found matching: %s" query);
250250+ 1
251251+ ) else (
252252+ Logs.app (fun m -> m "Found %d match%s:"
253253+ (List.length matches)
254254+ (if List.length matches = 1 then "" else "es"));
255255+ List.iter (fun c ->
256256+ Logs.app (fun m -> m "@%s: %s"
257257+ (Contact.handle c)
258258+ (Contact.name c));
259259+260260+ (* Show additional details *)
261261+ (match Contact.email c with
262262+ | Some e -> Logs.app (fun m -> m " Email: %s" e)
263263+ | None -> ());
264264+ (match Contact.github c with
265265+ | Some g -> Logs.app (fun m -> m " GitHub: @%s" g)
266266+ | None -> ());
267267+ (match Contact.best_url c with
268268+ | Some u -> Logs.app (fun m -> m " URL: %s" u)
269269+ | None -> ())
270270+ ) matches;
271271+ 0
272272+ )
273273+274274+ let stats_cmd () _env xdg _profile =
275275+ let store = create_from_xdg xdg in
276276+ let contacts = list store in
277277+278278+ let total = List.length contacts in
279279+ let with_email = List.filter (fun c -> Contact.email c <> None) contacts |> List.length in
280280+ let with_github = List.filter (fun c -> Contact.github c <> None) contacts |> List.length in
281281+ let with_orcid = List.filter (fun c -> Contact.orcid c <> None) contacts |> List.length in
282282+ let with_url = List.filter (fun c -> Contact.url c <> None) contacts |> List.length in
283283+ let with_feeds = List.filter (fun c -> Contact.atom_feeds c <> None) contacts |> List.length in
284284+285285+ Logs.app (fun m -> m "Contact Database Statistics:");
286286+ Logs.app (fun m -> m " Total contacts: %d" total);
287287+ Logs.app (fun m -> m " With email: %d (%.1f%%)" with_email (float_of_int with_email /. float_of_int total *. 100.));
288288+ Logs.app (fun m -> m " With GitHub: %d (%.1f%%)" with_github (float_of_int with_github /. float_of_int total *. 100.));
289289+ Logs.app (fun m -> m " With ORCID: %d (%.1f%%)" with_orcid (float_of_int with_orcid /. float_of_int total *. 100.));
290290+ Logs.app (fun m -> m " With URL: %d (%.1f%%)" with_url (float_of_int with_url /. float_of_int total *. 100.));
291291+ Logs.app (fun m -> m " With Atom feeds: %d (%.1f%%)" with_feeds (float_of_int with_feeds /. float_of_int total *. 100.));
292292+ 0
293293+294294+ (* Command info objects *)
295295+ let list_info = Cmd.info "list" ~doc:"List all contacts"
296296+297297+ let show_info = Cmd.info "show" ~doc:"Show detailed information about a contact"
298298+299299+ let search_info = Cmd.info "search" ~doc:"Search contacts by name"
300300+301301+ let stats_info = Cmd.info "stats" ~doc:"Show statistics about the contact database"
302302+303303+ (* Argument definitions *)
304304+ let handle_arg =
305305+ Arg.(required & pos 0 (some string) None & info [] ~docv:"HANDLE"
306306+ ~doc:"Contact handle to display")
307307+308308+ let query_arg =
309309+ Arg.(required & pos 0 (some string) None & info [] ~docv:"QUERY"
310310+ ~doc:"Name or partial name to search for")
311311+end
+323
stack/sortal/lib/sortal.mli
···11+(** Sortal - Username to metadata mapping with XDG storage
22+33+ This library provides a system for mapping usernames to various metadata
44+ including URLs, emails, ORCID identifiers, and social media handles.
55+ It uses XDG Base Directory Specification for storage locations and
66+ jsont for JSON encoding/decoding.
77+88+ {b Storage:}
99+1010+ Contact metadata is stored as JSON files in the XDG data directory,
1111+ with one file per contact using the handle as the filename.
1212+1313+ {b Typical Usage:}
1414+1515+ {[
1616+ let store = Sortal.create env#fs "myapp" in
1717+ let contact = Sortal.Contact.make
1818+ ~handle:"avsm"
1919+ ~names:["Anil Madhavapeddy"]
2020+ ~email:"anil@recoil.org"
2121+ ~github:"avsm"
2222+ ~orcid:"0000-0002-7890-1234"
2323+ () in
2424+ Sortal.save store contact;
2525+2626+ match Sortal.lookup store "avsm" with
2727+ | Some c -> Printf.printf "Found: %s\n" (Sortal.Contact.name c)
2828+ | None -> Printf.printf "Not found\n"
2929+ ]}
3030+*)
3131+3232+(** {1 Contact Metadata} *)
3333+3434+module Contact : sig
3535+ (** Individual contact metadata.
3636+3737+ A contact represents metadata about a person, including their name(s),
3838+ social media handles, professional identifiers, and other contact information. *)
3939+ type t
4040+4141+ (** [make ~handle ~names ?email ?icon ?github ?twitter ?bluesky ?mastodon
4242+ ?orcid ?url ?atom_feeds ()] creates a new contact.
4343+4444+ @param handle A unique identifier/username for this contact (required)
4545+ @param names A list of names for this contact, with the first being primary (required)
4646+ @param email Email address
4747+ @param icon URL to an avatar/icon image
4848+ @param github GitHub username (without the [\@] prefix)
4949+ @param twitter Twitter/X username (without the [\@] prefix)
5050+ @param bluesky Bluesky handle
5151+ @param mastodon Mastodon handle (including instance)
5252+ @param orcid ORCID identifier
5353+ @param url Personal or professional website URL
5454+ @param atom_feeds List of Atom/RSS feed URLs associated with this contact
5555+ *)
5656+ val make :
5757+ handle:string ->
5858+ names:string list ->
5959+ ?email:string ->
6060+ ?icon:string ->
6161+ ?github:string ->
6262+ ?twitter:string ->
6363+ ?bluesky:string ->
6464+ ?mastodon:string ->
6565+ ?orcid:string ->
6666+ ?url:string ->
6767+ ?atom_feeds:string list ->
6868+ unit ->
6969+ t
7070+7171+ (** {2 Accessors} *)
7272+7373+ (** [handle t] returns the unique handle/username. *)
7474+ val handle : t -> string
7575+7676+ (** [names t] returns all names associated with this contact. *)
7777+ val names : t -> string list
7878+7979+ (** [name t] returns the primary (first) name. *)
8080+ val name : t -> string
8181+8282+ (** [email t] returns the email address if available. *)
8383+ val email : t -> string option
8484+8585+ (** [icon t] returns the icon/avatar URL if available. *)
8686+ val icon : t -> string option
8787+8888+ (** [github t] returns the GitHub username if available. *)
8989+ val github : t -> string option
9090+9191+ (** [twitter t] returns the Twitter/X username if available. *)
9292+ val twitter : t -> string option
9393+9494+ (** [bluesky t] returns the Bluesky handle if available. *)
9595+ val bluesky : t -> string option
9696+9797+ (** [mastodon t] returns the Mastodon handle if available. *)
9898+ val mastodon : t -> string option
9999+100100+ (** [orcid t] returns the ORCID identifier if available. *)
101101+ val orcid : t -> string option
102102+103103+ (** [url t] returns the personal/professional website URL if available. *)
104104+ val url : t -> string option
105105+106106+ (** [atom_feeds t] returns the list of Atom/RSS feed URLs if available. *)
107107+ val atom_feeds : t -> string list option
108108+109109+ (** {2 Derived Information} *)
110110+111111+ (** [best_url t] returns the best available URL for this contact.
112112+113113+ Priority order:
114114+ 1. Personal URL (if set)
115115+ 2. GitHub profile URL (if GitHub username is set)
116116+ 3. Email as mailto: link (if email is set)
117117+ 4. None if no URL-like information is available
118118+ *)
119119+ val best_url : t -> string option
120120+121121+ (** {2 JSON Encoding} *)
122122+123123+ (** [json_t] is the jsont encoder/decoder for contacts.
124124+125125+ The JSON schema includes all contact fields with optional values
126126+ omitted when not present:
127127+ {[
128128+ {
129129+ "handle": "avsm",
130130+ "names": ["Anil Madhavapeddy"],
131131+ "email": "anil@recoil.org",
132132+ "github": "avsm",
133133+ "orcid": "0000-0002-7890-1234"
134134+ }
135135+ ]}
136136+ *)
137137+ val json_t : t Jsont.t
138138+139139+ (** {2 Utilities} *)
140140+141141+ (** [compare a b] compares two contacts by their handles. *)
142142+ val compare : t -> t -> int
143143+144144+ (** [pp ppf t] pretty prints a contact with formatting. *)
145145+ val pp : Format.formatter -> t -> unit
146146+end
147147+148148+(** {1 Contact Store} *)
149149+150150+(** The contact store manages reading and writing contact metadata
151151+ using XDG-compliant storage locations. *)
152152+type t
153153+154154+(** [create fs app_name] creates a new contact store.
155155+156156+ The store will use XDG data directories for persistent storage
157157+ of contact metadata. Each contact is stored as a separate JSON
158158+ file named after its handle.
159159+160160+ @param fs Eio filesystem for file operations
161161+ @param app_name Application name for XDG directory structure
162162+ *)
163163+val create : Eio.Fs.dir_ty Eio.Path.t -> string -> t
164164+165165+(** {2 Storage Operations} *)
166166+167167+(** [save t contact] saves a contact to the store.
168168+169169+ The contact is serialized to JSON and written to a file
170170+ named "handle.json" in the XDG data directory.
171171+172172+ If a contact with the same handle already exists, it is overwritten.
173173+ *)
174174+val save : t -> Contact.t -> unit
175175+176176+(** [lookup t handle] retrieves a contact by handle.
177177+178178+ Searches for a file named "handle.json" in the XDG data directory
179179+ and deserializes it if found.
180180+181181+ @return [Some contact] if found, [None] if not found or deserialization fails
182182+ *)
183183+val lookup : t -> string -> Contact.t option
184184+185185+(** [delete t handle] removes a contact from the store.
186186+187187+ Deletes the file "handle.json" from the XDG data directory.
188188+ Does nothing if the contact does not exist.
189189+ *)
190190+val delete : t -> string -> unit
191191+192192+(** [list t] returns all contacts in the store.
193193+194194+ Scans the XDG data directory for all .json files and attempts
195195+ to deserialize them as contacts. Files that fail to parse are
196196+ silently skipped.
197197+198198+ @return A list of all successfully loaded contacts
199199+ *)
200200+val list : t -> Contact.t list
201201+202202+(** {2 Searching} *)
203203+204204+(** [find_by_name t name] searches for contacts by name.
205205+206206+ Performs a case-insensitive search through all contacts,
207207+ checking if any of their names match the provided name.
208208+209209+ @param name The name to search for (case-insensitive)
210210+ @return The matching contact if exactly one match is found
211211+ @raise Not_found if no contacts match the name
212212+ @raise Invalid_argument if multiple contacts match the name
213213+ *)
214214+val find_by_name : t -> string -> Contact.t
215215+216216+(** [find_by_name_opt t name] searches for contacts by name, returning an option.
217217+218218+ Like {!find_by_name} but returns [None] instead of raising exceptions
219219+ when no match or multiple matches are found.
220220+221221+ @param name The name to search for (case-insensitive)
222222+ @return [Some contact] if exactly one match is found, [None] otherwise
223223+ *)
224224+val find_by_name_opt : t -> string -> Contact.t option
225225+226226+(** {2 Utilities} *)
227227+228228+(** [handle_of_name name] generates a handle from a full name.
229229+230230+ Creates a handle by concatenating the initials of all words
231231+ in the name with the full last name, all in lowercase.
232232+233233+ Examples:
234234+ - "Anil Madhavapeddy" -> "ammadhavapeddy"
235235+ - "John Smith" -> "jssmith"
236236+237237+ @param name The full name to convert
238238+ @return A suggested handle
239239+ *)
240240+val handle_of_name : string -> string
241241+242242+(** {2 Convenience Functions} *)
243243+244244+(** [create_from_xdg xdg] creates a contact store from an XDG context.
245245+246246+ This is a convenience function for creating a store when you already
247247+ have an XDG context (e.g., from eiocmd or your own XDG initialization).
248248+ The store will use the XDG data directory for the application.
249249+250250+ @param xdg An existing XDG context
251251+ @return A contact store using the XDG data directory
252252+ *)
253253+val create_from_xdg : Xdge.t -> t
254254+255255+(** [search_all t query] searches for contacts matching a query string.
256256+257257+ Performs a flexible search through all contact names, looking for:
258258+ - Exact matches (case-insensitive)
259259+ - Names that start with the query
260260+ - Multi-word names where any word starts with the query
261261+262262+ This is useful for autocomplete or fuzzy search functionality.
263263+264264+ @param t The contact store
265265+ @param query The search query (case-insensitive)
266266+ @return A list of matching contacts, sorted by handle
267267+ *)
268268+val search_all : t -> string -> Contact.t list
269269+270270+(** {2 Pretty Printing} *)
271271+272272+(** [pp ppf t] pretty prints the contact store showing statistics. *)
273273+val pp : Format.formatter -> t -> unit
274274+275275+(** {1 Cmdliner Integration} *)
276276+277277+module Cmd : sig
278278+ (** Cmdliner terms and commands for contact management.
279279+280280+ This module provides ready-to-use Cmdliner terms for building
281281+ CLI applications that work with contact metadata. *)
282282+283283+ (** [list_cmd] is a Cmdliner command that lists all contacts.
284284+285285+ Usage: Integrate into your CLI with [Cmd.group] or use standalone.
286286+ Requires eiocmd setup (env, xdg, profile parameters). *)
287287+ val list_cmd : unit -> (Eio_unix.Stdenv.base -> Xdge.t -> 'a -> int)
288288+289289+ (** [show_cmd handle] creates a command to show detailed contact information.
290290+291291+ @param handle The contact handle to display *)
292292+ val show_cmd : string -> (Eio_unix.Stdenv.base -> Xdge.t -> 'a -> int)
293293+294294+ (** [search_cmd query] creates a command to search contacts by name.
295295+296296+ @param query The search query string *)
297297+ val search_cmd : string -> (Eio_unix.Stdenv.base -> Xdge.t -> 'a -> int)
298298+299299+ (** [stats_cmd] is a command that shows database statistics. *)
300300+ val stats_cmd : unit -> (Eio_unix.Stdenv.base -> Xdge.t -> 'a -> int)
301301+302302+ (** {2 Cmdliner Info Objects} *)
303303+304304+ (** [list_info] is the command info for the list command. *)
305305+ val list_info : Cmdliner.Cmd.info
306306+307307+ (** [show_info] is the command info for the show command. *)
308308+ val show_info : Cmdliner.Cmd.info
309309+310310+ (** [search_info] is the command info for the search command. *)
311311+ val search_info : Cmdliner.Cmd.info
312312+313313+ (** [stats_info] is the command info for the stats command. *)
314314+ val stats_info : Cmdliner.Cmd.info
315315+316316+ (** {2 Cmdliner Argument Definitions} *)
317317+318318+ (** [handle_arg] is the positional argument for a contact handle. *)
319319+ val handle_arg : string Cmdliner.Term.t
320320+321321+ (** [query_arg] is the positional argument for a search query. *)
322322+ val query_arg : string Cmdliner.Term.t
323323+end
+119
stack/sortal/scripts/import_yaml_contacts.py
···11+#!/usr/bin/env python3
22+"""
33+Import YAML contacts from arod-dream to Sortal JSON format.
44+55+This script reads contacts from ~/src/git/avsm/arod-dream/data/contacts/
66+and converts them to JSON files in the XDG data directory for sortal.
77+"""
88+99+import os
1010+import json
1111+import yaml
1212+from pathlib import Path
1313+1414+def get_xdg_data_home():
1515+ """Get the XDG data home directory."""
1616+ xdg_data_home = os.environ.get('XDG_DATA_HOME')
1717+ if xdg_data_home:
1818+ return Path(xdg_data_home)
1919+ return Path.home() / '.local' / 'share'
2020+2121+def parse_contact_md(file_path):
2222+ """Parse a markdown file with YAML front matter."""
2323+ with open(file_path, 'r') as f:
2424+ content = f.read()
2525+2626+ # Extract YAML front matter between ---
2727+ if content.startswith('---\n'):
2828+ parts = content.split('---\n', 2)
2929+ if len(parts) >= 2:
3030+ yaml_content = parts[1]
3131+ return yaml.safe_load(yaml_content)
3232+ return None
3333+3434+def convert_to_sortal_format(yaml_data, handle):
3535+ """Convert YAML contact data to Sortal JSON format."""
3636+ sortal_contact = {
3737+ "handle": handle,
3838+ "names": yaml_data.get("names", [])
3939+ }
4040+4141+ # Optional fields
4242+ if "email" in yaml_data:
4343+ sortal_contact["email"] = yaml_data["email"]
4444+ if "icon" in yaml_data:
4545+ sortal_contact["icon"] = yaml_data["icon"]
4646+ if "github" in yaml_data:
4747+ sortal_contact["github"] = yaml_data["github"]
4848+ if "twitter" in yaml_data:
4949+ sortal_contact["twitter"] = yaml_data["twitter"]
5050+ if "bluesky" in yaml_data:
5151+ sortal_contact["bluesky"] = yaml_data["bluesky"]
5252+ if "mastodon" in yaml_data:
5353+ sortal_contact["mastodon"] = yaml_data["mastodon"]
5454+ if "orcid" in yaml_data:
5555+ sortal_contact["orcid"] = yaml_data["orcid"]
5656+ if "url" in yaml_data:
5757+ sortal_contact["url"] = yaml_data["url"]
5858+ if "atom" in yaml_data:
5959+ sortal_contact["atom_feeds"] = yaml_data["atom"]
6060+6161+ return sortal_contact
6262+6363+def main():
6464+ # Source directory
6565+ source_dir = Path.home() / 'src' / 'git' / 'avsm' / 'arod-dream' / 'data' / 'contacts'
6666+6767+ # Destination directory (XDG data home for sortal)
6868+ xdg_data = get_xdg_data_home()
6969+ dest_dir = xdg_data / 'sortal'
7070+ dest_dir.mkdir(parents=True, exist_ok=True)
7171+7272+ print(f"Importing contacts from: {source_dir}")
7373+ print(f"Output directory: {dest_dir}")
7474+ print()
7575+7676+ if not source_dir.exists():
7777+ print(f"Error: Source directory does not exist: {source_dir}")
7878+ return 1
7979+8080+ imported_count = 0
8181+ error_count = 0
8282+8383+ # Process each .md file
8484+ for md_file in sorted(source_dir.glob('*.md')):
8585+ handle = md_file.stem
8686+8787+ try:
8888+ yaml_data = parse_contact_md(md_file)
8989+ if yaml_data is None:
9090+ print(f"⚠ Skipping {handle}: No YAML front matter found")
9191+ error_count += 1
9292+ continue
9393+9494+ # Convert to Sortal format
9595+ sortal_contact = convert_to_sortal_format(yaml_data, handle)
9696+9797+ # Write JSON file
9898+ output_file = dest_dir / f"{handle}.json"
9999+ with open(output_file, 'w') as f:
100100+ json.dump(sortal_contact, f, indent=2, ensure_ascii=False)
101101+102102+ name = sortal_contact.get('names', [handle])[0] if sortal_contact.get('names') else handle
103103+ print(f"✓ Imported: {handle} ({name})")
104104+ imported_count += 1
105105+106106+ except Exception as e:
107107+ print(f"✗ Error importing {handle}: {e}")
108108+ error_count += 1
109109+110110+ print()
111111+ print(f"Import complete!")
112112+ print(f" Successfully imported: {imported_count}")
113113+ print(f" Errors: {error_count}")
114114+ print(f" Output directory: {dest_dir}")
115115+116116+ return 0 if error_count == 0 else 1
117117+118118+if __name__ == '__main__':
119119+ exit(main())