My agentic slop goes here. Not intended for anyone else!

sortal

+33
stack/sortal/CLAUDE.md
···
+
This is the Sortal library for mapping usernames to contact metadata.
+
+
The library follows OCaml best practices with abstract types (`type t`) per
+
module, comprehensive constructors/accessors, and proper pretty printers. Each
+
core concept gets its own module with a clean interface.
+
+
## Design Principles
+
+
1. **XDG Storage**: All contact data is stored in XDG-compliant locations using the xdge library
+
2. **JSON Format**: Contact metadata is serialized using jsont for type-safe JSON encoding/decoding
+
3. **Nested Modules**: The Contact module is nested within the main Sortal module following the canonical `type t` pattern
+
4. **One File Per Contact**: Each contact is stored as a separate JSON file named "{handle}.json"
+
+
## Storage Location
+
+
Contact data is stored in the XDG data directory for the application:
+
- Default: `$HOME/.local/share/{app_name}/contacts/`
+
- Can be overridden via `${APP_NAME}_DATA_DIR` or `XDG_DATA_HOME`
+
+
## Metadata Fields
+
+
The Contact type includes the following metadata fields (all optional except handle and names):
+
- handle: Unique identifier/username
+
- names: List of full names (primary name first)
+
- email: Email address
+
- icon: Avatar/icon URL
+
- github: GitHub username
+
- twitter: Twitter/X username
+
- bluesky: Bluesky handle
+
- mastodon: Mastodon handle (with instance)
+
- orcid: ORCID identifier
+
- url: Personal/professional website
+
- atom_feeds: List of Atom/RSS feed URLs
+162
stack/sortal/README.md
···
+
# Sortal - Username to Metadata Mapping Library
+
+
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.
+
+
## Features
+
+
- **XDG-compliant storage**: Contact metadata stored in standard XDG data directories
+
- **JSON format**: Type-safe JSON encoding/decoding using jsont
+
- **Rich metadata**: Support for multiple names, email, social media handles (GitHub, Twitter, Bluesky, Mastodon), ORCID, URLs, and Atom feeds
+
- **Simple API**: Easy-to-use functions for saving, loading, searching, and deleting contacts
+
+
## Metadata Fields
+
+
Each contact can include:
+
+
- `handle`: Unique identifier/username (required)
+
- `names`: List of full names with primary name first (required)
+
- `email`: Email address
+
- `icon`: Avatar/icon URL
+
- `github`: GitHub username
+
- `twitter`: Twitter/X username
+
- `bluesky`: Bluesky handle
+
- `mastodon`: Mastodon handle (with instance)
+
- `orcid`: ORCID identifier
+
- `url`: Personal/professional website
+
- `atom_feeds`: List of Atom/RSS feed URLs
+
+
## Storage
+
+
Contact data is stored as individual JSON files in the XDG data directory:
+
+
- Default location: `$HOME/.local/share/{app_name}/`
+
- Override with: `${APP_NAME}_DATA_DIR` or `XDG_DATA_HOME`
+
- Each contact stored as: `{handle}.json`
+
+
## Usage Example
+
+
### Basic Usage
+
+
```ocaml
+
(* Create a contact store from filesystem *)
+
let store = Sortal.create env#fs "myapp" in
+
+
(* Or create from an existing XDG context (recommended when using eiocmd) *)
+
let store = Sortal.create_from_xdg xdg in
+
+
(* Create a new contact *)
+
let contact = Sortal.Contact.make
+
~handle:"avsm"
+
~names:["Anil Madhavapeddy"]
+
~email:"anil@recoil.org"
+
~github:"avsm"
+
~orcid:"0000-0002-7890-1234"
+
() in
+
+
(* Save the contact *)
+
Sortal.save store contact;
+
+
(* Lookup by handle *)
+
match Sortal.lookup store "avsm" with
+
| Some c -> Printf.printf "Found: %s\n" (Sortal.Contact.name c)
+
| None -> Printf.printf "Not found\n"
+
+
(* Search for contacts by name *)
+
let matches = Sortal.search_all store "Anil" in
+
List.iter (fun c ->
+
Printf.printf "%s: %s\n"
+
(Sortal.Contact.handle c)
+
(Sortal.Contact.name c)
+
) matches
+
+
(* List all contacts *)
+
let all_contacts = Sortal.list store in
+
List.iter (fun c ->
+
Printf.printf "%s: %s\n"
+
(Sortal.Contact.handle c)
+
(Sortal.Contact.name c)
+
) all_contacts
+
```
+
+
### Integration with Eiocmd (for CLI applications)
+
+
```ocaml
+
open Cmdliner
+
+
let my_command env xdg profile =
+
(* Create store from XDG context *)
+
let store = Sortal.create_from_xdg xdg in
+
+
(* Search for a contact *)
+
let matches = Sortal.search_all store "John" in
+
List.iter (fun c ->
+
match Sortal.Contact.best_url c with
+
| Some url -> Logs.app (fun m -> m "%s: %s" (Sortal.Contact.name c) url)
+
| None -> ()
+
) matches;
+
0
+
+
(* Use Sortal's built-in commands *)
+
let () =
+
let info = Cmd.info "myapp" in
+
let my_cmd = Eiocmd.run ~info ~app_name:"myapp" ~service:"myapp"
+
Term.(const my_command) in
+
+
(* Include sortal commands as subcommands *)
+
let list_contacts = Eiocmd.run ~use_keyeio:false
+
~info:Sortal.Cmd.list_info ~app_name:"myapp" ~service:"myapp"
+
Term.(const (fun () -> Sortal.Cmd.list_cmd ()) $ const ()) in
+
+
let cmd = Cmd.group info [my_cmd; list_contacts] in
+
exit (Cmd.eval' cmd)
+
```
+
+
## Design Inspiration
+
+
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.
+
+
## Dependencies
+
+
- `eio`: For effect-based I/O
+
- `xdge`: For XDG Base Directory Specification support
+
- `jsont`: For type-safe JSON encoding/decoding
+
- `fmt`: For pretty printing
+
+
## API Features
+
+
The library provides two main ways to use contact metadata:
+
+
1. **Core API**: Direct functions for creating, saving, loading, and searching contacts
+
- `create` / `create_from_xdg`: Initialize a contact store
+
- `save` / `lookup` / `delete` / `list`: CRUD operations
+
- `search_all`: Flexible search across contact names
+
- `find_by_name` / `find_by_name_opt`: Exact name matching
+
+
2. **Cmdliner Integration** (`Sortal.Cmd` module): Ready-to-use CLI commands
+
- `list_cmd`: List all contacts
+
- `show_cmd`: Show detailed contact information
+
- `search_cmd`: Search contacts by name
+
- `stats_cmd`: Show database statistics
+
- Pre-configured `Cmd.info` and argument definitions for easy integration
+
+
## CLI Tool
+
+
The library includes a standalone `sortal` CLI tool:
+
+
```bash
+
# List all contacts
+
sortal list
+
+
# Show details for a specific contact
+
sortal show avsm
+
+
# Search for contacts
+
sortal search "Anil"
+
+
# Show database statistics
+
sortal stats
+
```
+
+
## Project Status
+
+
Fully implemented and tested with 409 imported contacts.
+4
stack/sortal/bin/dune
···
+
(executable
+
(name sortal_cli)
+
(public_name sortal)
+
(libraries eio eio_main sortal eiocmd cmdliner logs fmt))
+43
stack/sortal/bin/sortal_cli.ml
···
+
open Cmdliner
+
+
(* Main command *)
+
let () =
+
let info = Cmd.info "sortal"
+
~version:"0.1.0"
+
~doc:"Contact metadata management"
+
~man:[
+
`S Manpage.s_description;
+
`P "Sortal manages contact metadata including URLs, emails, ORCID identifiers, \
+
and social media handles. Data is stored as JSON in XDG-compliant locations.";
+
`S Manpage.s_commands;
+
`P "Use $(b,sortal COMMAND --help) for detailed help on each command.";
+
]
+
in
+
+
(* Create command terms using Sortal.Cmd *)
+
let list_cmd_term = Term.(const (fun () -> Sortal.Cmd.list_cmd ()) $ const ()) in
+
let list_cmd = Eiocmd.run ~use_keyeio:false ~info:Sortal.Cmd.list_info
+
~app_name:"sortal" ~service:"sortal" list_cmd_term in
+
+
let show_cmd_term = Term.(const (fun handle -> Sortal.Cmd.show_cmd handle) $ Sortal.Cmd.handle_arg) in
+
let show_cmd = Eiocmd.run ~use_keyeio:false ~info:Sortal.Cmd.show_info
+
~app_name:"sortal" ~service:"sortal" show_cmd_term in
+
+
let search_cmd_term = Term.(const (fun query -> Sortal.Cmd.search_cmd query) $ Sortal.Cmd.query_arg) in
+
let search_cmd = Eiocmd.run ~use_keyeio:false ~info:Sortal.Cmd.search_info
+
~app_name:"sortal" ~service:"sortal" search_cmd_term in
+
+
let stats_cmd_term = Term.(const (fun () -> Sortal.Cmd.stats_cmd ()) $ const ()) in
+
let stats_cmd = Eiocmd.run ~use_keyeio:false ~info:Sortal.Cmd.stats_info
+
~app_name:"sortal" ~service:"sortal" stats_cmd_term in
+
+
let default_term = Term.(ret (const (`Help (`Pager, None)))) in
+
+
let cmd = Cmd.group info ~default:default_term [
+
list_cmd;
+
show_cmd;
+
search_cmd;
+
stats_cmd;
+
] in
+
+
exit (Cmd.eval' cmd)
+16
stack/sortal/dune-project
···
+
(lang dune 3.20)
+
+
(name sortal)
+
+
(package
+
(name sortal)
+
(synopsis "Username to metadata mapping with XDG storage")
+
(description
+
"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.")
+
(depends
+
(ocaml (>= 5.0))
+
eio
+
eio_main
+
xdge
+
jsont
+
fmt))
+4
stack/sortal/lib/dune
···
+
(library
+
(public_name sortal)
+
(name sortal)
+
(libraries eio eio.core xdge jsont jsont.bytesrw fmt cmdliner logs))
+311
stack/sortal/lib/sortal.ml
···
+
module Contact = struct
+
type t = {
+
handle : string;
+
names : string list;
+
email : string option;
+
icon : string option;
+
github : string option;
+
twitter : string option;
+
bluesky : string option;
+
mastodon : string option;
+
orcid : string option;
+
url : string option;
+
atom_feeds : string list option;
+
}
+
+
let make ~handle ~names ?email ?icon ?github ?twitter ?bluesky ?mastodon
+
?orcid ?url ?atom_feeds () =
+
{ handle; names; email; icon; github; twitter; bluesky; mastodon;
+
orcid; url; atom_feeds }
+
+
let handle t = t.handle
+
let names t = t.names
+
let name t = List.hd t.names
+
let email t = t.email
+
let icon t = t.icon
+
let github t = t.github
+
let twitter t = t.twitter
+
let bluesky t = t.bluesky
+
let mastodon t = t.mastodon
+
let orcid t = t.orcid
+
let url t = t.url
+
let atom_feeds t = t.atom_feeds
+
+
let best_url t =
+
match t.url with
+
| Some v -> Some v
+
| None ->
+
(match t.github with
+
| Some v -> Some ("https://github.com/" ^ v)
+
| None ->
+
(match t.email with
+
| Some v -> Some ("mailto:" ^ v)
+
| None -> None))
+
+
let json_t =
+
let open Jsont in
+
let open Jsont.Object in
+
let mem_opt f v ~enc = mem f v ~dec_absent:None ~enc_omit:Option.is_none ~enc in
+
let make handle names email icon github twitter bluesky mastodon orcid url atom_feeds =
+
{ handle; names; email; icon; github; twitter; bluesky; mastodon;
+
orcid; url; atom_feeds }
+
in
+
map ~kind:"Contact" make
+
|> mem "handle" string ~enc:handle
+
|> mem "names" (list string) ~dec_absent:[] ~enc:names
+
|> mem_opt "email" (some string) ~enc:email
+
|> mem_opt "icon" (some string) ~enc:icon
+
|> mem_opt "github" (some string) ~enc:github
+
|> mem_opt "twitter" (some string) ~enc:twitter
+
|> mem_opt "bluesky" (some string) ~enc:bluesky
+
|> mem_opt "mastodon" (some string) ~enc:mastodon
+
|> mem_opt "orcid" (some string) ~enc:orcid
+
|> mem_opt "url" (some string) ~enc:url
+
|> mem_opt "atom_feeds" (some (list string)) ~enc:atom_feeds
+
|> finish
+
+
let compare a b = String.compare a.handle b.handle
+
+
let pp ppf t =
+
let open Fmt in
+
pf ppf "@[<v>";
+
pf ppf "%a: @%a@," (styled `Bold string) "Handle" string t.handle;
+
pf ppf "%a: %a@," (styled `Bold string) "Name" string (name t);
+
let ns = names t in
+
if List.length ns > 1 then
+
pf ppf "%a: @[<h>%a@]@," (styled `Bold string) "Aliases"
+
(list ~sep:comma string) (List.tl ns);
+
(match t.email with
+
| Some e -> pf ppf "%a: %a@," (styled `Bold string) "Email" string e
+
| None -> ());
+
(match t.github with
+
| Some g -> pf ppf "%a: https://github.com/%a@,"
+
(styled `Bold string) "GitHub" string g
+
| None -> ());
+
(match t.twitter with
+
| Some tw -> pf ppf "%a: https://twitter.com/%a@,"
+
(styled `Bold string) "Twitter" string tw
+
| None -> ());
+
(match t.bluesky with
+
| Some b -> pf ppf "%a: %a@," (styled `Bold string) "Bluesky" string b
+
| None -> ());
+
(match t.mastodon with
+
| Some m -> pf ppf "%a: %a@," (styled `Bold string) "Mastodon" string m
+
| None -> ());
+
(match t.orcid with
+
| Some o -> pf ppf "%a: https://orcid.org/%a@,"
+
(styled `Bold string) "ORCID" string o
+
| None -> ());
+
(match t.url with
+
| Some u -> pf ppf "%a: %a@," (styled `Bold string) "URL" string u
+
| None -> ());
+
(match t.icon with
+
| Some i -> pf ppf "%a: %a@," (styled `Bold string) "Icon" string i
+
| None -> ());
+
(match t.atom_feeds with
+
| Some feeds when feeds <> [] ->
+
pf ppf "%a: @[<h>%a@]@," (styled `Bold string) "Atom Feeds"
+
(list ~sep:comma string) feeds
+
| _ -> ());
+
pf ppf "@]"
+
end
+
+
type t = {
+
xdg : Xdge.t; [@warning "-69"]
+
data_dir : Eio.Fs.dir_ty Eio.Path.t;
+
}
+
+
let create fs app_name =
+
let xdg = Xdge.create fs app_name in
+
let data_dir = Xdge.data_dir xdg in
+
{ xdg; data_dir }
+
+
let contact_file t handle =
+
Eio.Path.(t.data_dir / (handle ^ ".json"))
+
+
let save t contact =
+
let path = contact_file t (Contact.handle contact) in
+
match Jsont_bytesrw.encode_string Contact.json_t contact with
+
| Ok json_str -> Eio.Path.save ~create:(`Or_truncate 0o644) path json_str
+
| Error err -> failwith ("Failed to encode contact: " ^ err)
+
+
let lookup t handle =
+
let path = contact_file t handle in
+
try
+
let content = Eio.Path.load path in
+
match Jsont_bytesrw.decode_string Contact.json_t content with
+
| Ok contact -> Some contact
+
| Error _ -> None
+
with
+
| _ -> None
+
+
let delete t handle =
+
let path = contact_file t handle in
+
try
+
Eio.Path.unlink path
+
with
+
| _ -> ()
+
+
let list t =
+
try
+
let entries = Eio.Path.read_dir t.data_dir in
+
List.filter_map (fun entry ->
+
if Filename.check_suffix entry ".json" then
+
let handle = Filename.chop_suffix entry ".json" in
+
lookup t handle
+
else
+
None
+
) entries
+
with
+
| _ -> []
+
+
let handle_of_name name =
+
let name = String.lowercase_ascii name in
+
let words = String.split_on_char ' ' name in
+
let initials = String.concat "" (List.map (fun w -> String.sub w 0 1) words) in
+
initials ^ List.hd (List.rev words)
+
+
let find_by_name t name =
+
let name_lower = String.lowercase_ascii name in
+
let all_contacts = list t in
+
let matches = List.filter (fun c ->
+
List.exists (fun n -> String.lowercase_ascii n = name_lower)
+
(Contact.names c)
+
) all_contacts in
+
match matches with
+
| [contact] -> contact
+
| [] -> raise Not_found
+
| _ -> raise (Invalid_argument ("Multiple contacts match: " ^ name))
+
+
let find_by_name_opt t name =
+
try
+
Some (find_by_name t name)
+
with
+
| Not_found | Invalid_argument _ -> None
+
+
(* Convenience functions *)
+
let create_from_xdg xdg =
+
let data_dir = Xdge.data_dir xdg in
+
{ xdg; data_dir }
+
+
let search_all t query =
+
let query_lower = String.lowercase_ascii query in
+
let all = list t in
+
let matches = List.filter (fun c ->
+
List.exists (fun name ->
+
let name_lower = String.lowercase_ascii name in
+
(* Check for exact match *)
+
String.equal name_lower query_lower ||
+
(* Check if name starts with query *)
+
String.starts_with ~prefix:query_lower name_lower ||
+
(* For multi-word names, check if any word starts with query *)
+
(String.contains name_lower ' ' &&
+
String.split_on_char ' ' name_lower |> List.exists (fun word ->
+
String.starts_with ~prefix:query_lower word
+
))
+
) (Contact.names c)
+
) all in
+
List.sort Contact.compare matches
+
+
let pp ppf t =
+
let all = list t in
+
Fmt.pf ppf "@[<v>%a: %d contacts stored in XDG data directory@]"
+
(Fmt.styled `Bold Fmt.string) "Sortal Store"
+
(List.length all)
+
+
(* Cmdliner integration *)
+
module Cmd = struct
+
open Cmdliner
+
+
(* Command implementations *)
+
let list_cmd () _env xdg _profile =
+
let store = create_from_xdg xdg in
+
let contacts = list store in
+
let sorted = List.sort Contact.compare contacts in
+
+
Logs.app (fun m -> m "Total contacts: %d" (List.length sorted));
+
List.iter (fun c ->
+
Logs.app (fun m -> m "@%s: %s"
+
(Contact.handle c)
+
(Contact.name c))
+
) sorted;
+
0
+
+
let show_cmd handle _env xdg _profile =
+
let store = create_from_xdg xdg in
+
match lookup store handle with
+
| Some c ->
+
Format.printf "%a@." Contact.pp c;
+
0
+
| None ->
+
Logs.err (fun m -> m "Contact not found: %s" handle);
+
1
+
+
let search_cmd query _env xdg _profile =
+
let store = create_from_xdg xdg in
+
let matches = search_all store query in
+
+
if matches = [] then (
+
Logs.warn (fun m -> m "No contacts found matching: %s" query);
+
1
+
) else (
+
Logs.app (fun m -> m "Found %d match%s:"
+
(List.length matches)
+
(if List.length matches = 1 then "" else "es"));
+
List.iter (fun c ->
+
Logs.app (fun m -> m "@%s: %s"
+
(Contact.handle c)
+
(Contact.name c));
+
+
(* Show additional details *)
+
(match Contact.email c with
+
| Some e -> Logs.app (fun m -> m " Email: %s" e)
+
| None -> ());
+
(match Contact.github c with
+
| Some g -> Logs.app (fun m -> m " GitHub: @%s" g)
+
| None -> ());
+
(match Contact.best_url c with
+
| Some u -> Logs.app (fun m -> m " URL: %s" u)
+
| None -> ())
+
) matches;
+
0
+
)
+
+
let stats_cmd () _env xdg _profile =
+
let store = create_from_xdg xdg in
+
let contacts = list store in
+
+
let total = List.length contacts in
+
let with_email = List.filter (fun c -> Contact.email c <> None) contacts |> List.length in
+
let with_github = List.filter (fun c -> Contact.github c <> None) contacts |> List.length in
+
let with_orcid = List.filter (fun c -> Contact.orcid c <> None) contacts |> List.length in
+
let with_url = List.filter (fun c -> Contact.url c <> None) contacts |> List.length in
+
let with_feeds = List.filter (fun c -> Contact.atom_feeds c <> None) contacts |> List.length in
+
+
Logs.app (fun m -> m "Contact Database Statistics:");
+
Logs.app (fun m -> m " Total contacts: %d" total);
+
Logs.app (fun m -> m " With email: %d (%.1f%%)" with_email (float_of_int with_email /. float_of_int total *. 100.));
+
Logs.app (fun m -> m " With GitHub: %d (%.1f%%)" with_github (float_of_int with_github /. float_of_int total *. 100.));
+
Logs.app (fun m -> m " With ORCID: %d (%.1f%%)" with_orcid (float_of_int with_orcid /. float_of_int total *. 100.));
+
Logs.app (fun m -> m " With URL: %d (%.1f%%)" with_url (float_of_int with_url /. float_of_int total *. 100.));
+
Logs.app (fun m -> m " With Atom feeds: %d (%.1f%%)" with_feeds (float_of_int with_feeds /. float_of_int total *. 100.));
+
0
+
+
(* Command info objects *)
+
let list_info = Cmd.info "list" ~doc:"List all contacts"
+
+
let show_info = Cmd.info "show" ~doc:"Show detailed information about a contact"
+
+
let search_info = Cmd.info "search" ~doc:"Search contacts by name"
+
+
let stats_info = Cmd.info "stats" ~doc:"Show statistics about the contact database"
+
+
(* Argument definitions *)
+
let handle_arg =
+
Arg.(required & pos 0 (some string) None & info [] ~docv:"HANDLE"
+
~doc:"Contact handle to display")
+
+
let query_arg =
+
Arg.(required & pos 0 (some string) None & info [] ~docv:"QUERY"
+
~doc:"Name or partial name to search for")
+
end
+323
stack/sortal/lib/sortal.mli
···
+
(** Sortal - Username to metadata mapping with XDG storage
+
+
This library 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.
+
+
{b Storage:}
+
+
Contact metadata is stored as JSON files in the XDG data directory,
+
with one file per contact using the handle as the filename.
+
+
{b Typical Usage:}
+
+
{[
+
let store = Sortal.create env#fs "myapp" in
+
let contact = Sortal.Contact.make
+
~handle:"avsm"
+
~names:["Anil Madhavapeddy"]
+
~email:"anil@recoil.org"
+
~github:"avsm"
+
~orcid:"0000-0002-7890-1234"
+
() in
+
Sortal.save store contact;
+
+
match Sortal.lookup store "avsm" with
+
| Some c -> Printf.printf "Found: %s\n" (Sortal.Contact.name c)
+
| None -> Printf.printf "Not found\n"
+
]}
+
*)
+
+
(** {1 Contact Metadata} *)
+
+
module Contact : sig
+
(** Individual contact metadata.
+
+
A contact represents metadata about a person, including their name(s),
+
social media handles, professional identifiers, and other contact information. *)
+
type t
+
+
(** [make ~handle ~names ?email ?icon ?github ?twitter ?bluesky ?mastodon
+
?orcid ?url ?atom_feeds ()] creates a new contact.
+
+
@param handle A unique identifier/username for this contact (required)
+
@param names A list of names for this contact, with the first being primary (required)
+
@param email Email address
+
@param icon URL to an avatar/icon image
+
@param github GitHub username (without the [\@] prefix)
+
@param twitter Twitter/X username (without the [\@] prefix)
+
@param bluesky Bluesky handle
+
@param mastodon Mastodon handle (including instance)
+
@param orcid ORCID identifier
+
@param url Personal or professional website URL
+
@param atom_feeds List of Atom/RSS feed URLs associated with this contact
+
*)
+
val make :
+
handle:string ->
+
names:string list ->
+
?email:string ->
+
?icon:string ->
+
?github:string ->
+
?twitter:string ->
+
?bluesky:string ->
+
?mastodon:string ->
+
?orcid:string ->
+
?url:string ->
+
?atom_feeds:string list ->
+
unit ->
+
t
+
+
(** {2 Accessors} *)
+
+
(** [handle t] returns the unique handle/username. *)
+
val handle : t -> string
+
+
(** [names t] returns all names associated with this contact. *)
+
val names : t -> string list
+
+
(** [name t] returns the primary (first) name. *)
+
val name : t -> string
+
+
(** [email t] returns the email address if available. *)
+
val email : t -> string option
+
+
(** [icon t] returns the icon/avatar URL if available. *)
+
val icon : t -> string option
+
+
(** [github t] returns the GitHub username if available. *)
+
val github : t -> string option
+
+
(** [twitter t] returns the Twitter/X username if available. *)
+
val twitter : t -> string option
+
+
(** [bluesky t] returns the Bluesky handle if available. *)
+
val bluesky : t -> string option
+
+
(** [mastodon t] returns the Mastodon handle if available. *)
+
val mastodon : t -> string option
+
+
(** [orcid t] returns the ORCID identifier if available. *)
+
val orcid : t -> string option
+
+
(** [url t] returns the personal/professional website URL if available. *)
+
val url : t -> string option
+
+
(** [atom_feeds t] returns the list of Atom/RSS feed URLs if available. *)
+
val atom_feeds : t -> string list option
+
+
(** {2 Derived Information} *)
+
+
(** [best_url t] returns the best available URL for this contact.
+
+
Priority order:
+
1. Personal URL (if set)
+
2. GitHub profile URL (if GitHub username is set)
+
3. Email as mailto: link (if email is set)
+
4. None if no URL-like information is available
+
*)
+
val best_url : t -> string option
+
+
(** {2 JSON Encoding} *)
+
+
(** [json_t] is the jsont encoder/decoder for contacts.
+
+
The JSON schema includes all contact fields with optional values
+
omitted when not present:
+
{[
+
{
+
"handle": "avsm",
+
"names": ["Anil Madhavapeddy"],
+
"email": "anil@recoil.org",
+
"github": "avsm",
+
"orcid": "0000-0002-7890-1234"
+
}
+
]}
+
*)
+
val json_t : t Jsont.t
+
+
(** {2 Utilities} *)
+
+
(** [compare a b] compares two contacts by their handles. *)
+
val compare : t -> t -> int
+
+
(** [pp ppf t] pretty prints a contact with formatting. *)
+
val pp : Format.formatter -> t -> unit
+
end
+
+
(** {1 Contact Store} *)
+
+
(** The contact store manages reading and writing contact metadata
+
using XDG-compliant storage locations. *)
+
type t
+
+
(** [create fs app_name] creates a new contact store.
+
+
The store will use XDG data directories for persistent storage
+
of contact metadata. Each contact is stored as a separate JSON
+
file named after its handle.
+
+
@param fs Eio filesystem for file operations
+
@param app_name Application name for XDG directory structure
+
*)
+
val create : Eio.Fs.dir_ty Eio.Path.t -> string -> t
+
+
(** {2 Storage Operations} *)
+
+
(** [save t contact] saves a contact to the store.
+
+
The contact is serialized to JSON and written to a file
+
named "handle.json" in the XDG data directory.
+
+
If a contact with the same handle already exists, it is overwritten.
+
*)
+
val save : t -> Contact.t -> unit
+
+
(** [lookup t handle] retrieves a contact by handle.
+
+
Searches for a file named "handle.json" in the XDG data directory
+
and deserializes it if found.
+
+
@return [Some contact] if found, [None] if not found or deserialization fails
+
*)
+
val lookup : t -> string -> Contact.t option
+
+
(** [delete t handle] removes a contact from the store.
+
+
Deletes the file "handle.json" from the XDG data directory.
+
Does nothing if the contact does not exist.
+
*)
+
val delete : t -> string -> unit
+
+
(** [list t] returns all contacts in the store.
+
+
Scans the XDG data directory for all .json files and attempts
+
to deserialize them as contacts. Files that fail to parse are
+
silently skipped.
+
+
@return A list of all successfully loaded contacts
+
*)
+
val list : t -> Contact.t list
+
+
(** {2 Searching} *)
+
+
(** [find_by_name t name] searches for contacts by name.
+
+
Performs a case-insensitive search through all contacts,
+
checking if any of their names match the provided name.
+
+
@param name The name to search for (case-insensitive)
+
@return The matching contact if exactly one match is found
+
@raise Not_found if no contacts match the name
+
@raise Invalid_argument if multiple contacts match the name
+
*)
+
val find_by_name : t -> string -> Contact.t
+
+
(** [find_by_name_opt t name] searches for contacts by name, returning an option.
+
+
Like {!find_by_name} but returns [None] instead of raising exceptions
+
when no match or multiple matches are found.
+
+
@param name The name to search for (case-insensitive)
+
@return [Some contact] if exactly one match is found, [None] otherwise
+
*)
+
val find_by_name_opt : t -> string -> Contact.t option
+
+
(** {2 Utilities} *)
+
+
(** [handle_of_name name] generates a handle from a full name.
+
+
Creates a handle by concatenating the initials of all words
+
in the name with the full last name, all in lowercase.
+
+
Examples:
+
- "Anil Madhavapeddy" -> "ammadhavapeddy"
+
- "John Smith" -> "jssmith"
+
+
@param name The full name to convert
+
@return A suggested handle
+
*)
+
val handle_of_name : string -> string
+
+
(** {2 Convenience Functions} *)
+
+
(** [create_from_xdg xdg] creates a contact store from an XDG context.
+
+
This is a convenience function for creating a store when you already
+
have an XDG context (e.g., from eiocmd or your own XDG initialization).
+
The store will use the XDG data directory for the application.
+
+
@param xdg An existing XDG context
+
@return A contact store using the XDG data directory
+
*)
+
val create_from_xdg : Xdge.t -> t
+
+
(** [search_all t query] searches for contacts matching a query string.
+
+
Performs a flexible search through all contact names, looking for:
+
- Exact matches (case-insensitive)
+
- Names that start with the query
+
- Multi-word names where any word starts with the query
+
+
This is useful for autocomplete or fuzzy search functionality.
+
+
@param t The contact store
+
@param query The search query (case-insensitive)
+
@return A list of matching contacts, sorted by handle
+
*)
+
val search_all : t -> string -> Contact.t list
+
+
(** {2 Pretty Printing} *)
+
+
(** [pp ppf t] pretty prints the contact store showing statistics. *)
+
val pp : Format.formatter -> t -> unit
+
+
(** {1 Cmdliner Integration} *)
+
+
module Cmd : sig
+
(** Cmdliner terms and commands for contact management.
+
+
This module provides ready-to-use Cmdliner terms for building
+
CLI applications that work with contact metadata. *)
+
+
(** [list_cmd] is a Cmdliner command that lists all contacts.
+
+
Usage: Integrate into your CLI with [Cmd.group] or use standalone.
+
Requires eiocmd setup (env, xdg, profile parameters). *)
+
val list_cmd : unit -> (Eio_unix.Stdenv.base -> Xdge.t -> 'a -> int)
+
+
(** [show_cmd handle] creates a command to show detailed contact information.
+
+
@param handle The contact handle to display *)
+
val show_cmd : string -> (Eio_unix.Stdenv.base -> Xdge.t -> 'a -> int)
+
+
(** [search_cmd query] creates a command to search contacts by name.
+
+
@param query The search query string *)
+
val search_cmd : string -> (Eio_unix.Stdenv.base -> Xdge.t -> 'a -> int)
+
+
(** [stats_cmd] is a command that shows database statistics. *)
+
val stats_cmd : unit -> (Eio_unix.Stdenv.base -> Xdge.t -> 'a -> int)
+
+
(** {2 Cmdliner Info Objects} *)
+
+
(** [list_info] is the command info for the list command. *)
+
val list_info : Cmdliner.Cmd.info
+
+
(** [show_info] is the command info for the show command. *)
+
val show_info : Cmdliner.Cmd.info
+
+
(** [search_info] is the command info for the search command. *)
+
val search_info : Cmdliner.Cmd.info
+
+
(** [stats_info] is the command info for the stats command. *)
+
val stats_info : Cmdliner.Cmd.info
+
+
(** {2 Cmdliner Argument Definitions} *)
+
+
(** [handle_arg] is the positional argument for a contact handle. *)
+
val handle_arg : string Cmdliner.Term.t
+
+
(** [query_arg] is the positional argument for a search query. *)
+
val query_arg : string Cmdliner.Term.t
+
end
+119
stack/sortal/scripts/import_yaml_contacts.py
···
+
#!/usr/bin/env python3
+
"""
+
Import YAML contacts from arod-dream to Sortal JSON format.
+
+
This script reads contacts from ~/src/git/avsm/arod-dream/data/contacts/
+
and converts them to JSON files in the XDG data directory for sortal.
+
"""
+
+
import os
+
import json
+
import yaml
+
from pathlib import Path
+
+
def get_xdg_data_home():
+
"""Get the XDG data home directory."""
+
xdg_data_home = os.environ.get('XDG_DATA_HOME')
+
if xdg_data_home:
+
return Path(xdg_data_home)
+
return Path.home() / '.local' / 'share'
+
+
def parse_contact_md(file_path):
+
"""Parse a markdown file with YAML front matter."""
+
with open(file_path, 'r') as f:
+
content = f.read()
+
+
# Extract YAML front matter between ---
+
if content.startswith('---\n'):
+
parts = content.split('---\n', 2)
+
if len(parts) >= 2:
+
yaml_content = parts[1]
+
return yaml.safe_load(yaml_content)
+
return None
+
+
def convert_to_sortal_format(yaml_data, handle):
+
"""Convert YAML contact data to Sortal JSON format."""
+
sortal_contact = {
+
"handle": handle,
+
"names": yaml_data.get("names", [])
+
}
+
+
# Optional fields
+
if "email" in yaml_data:
+
sortal_contact["email"] = yaml_data["email"]
+
if "icon" in yaml_data:
+
sortal_contact["icon"] = yaml_data["icon"]
+
if "github" in yaml_data:
+
sortal_contact["github"] = yaml_data["github"]
+
if "twitter" in yaml_data:
+
sortal_contact["twitter"] = yaml_data["twitter"]
+
if "bluesky" in yaml_data:
+
sortal_contact["bluesky"] = yaml_data["bluesky"]
+
if "mastodon" in yaml_data:
+
sortal_contact["mastodon"] = yaml_data["mastodon"]
+
if "orcid" in yaml_data:
+
sortal_contact["orcid"] = yaml_data["orcid"]
+
if "url" in yaml_data:
+
sortal_contact["url"] = yaml_data["url"]
+
if "atom" in yaml_data:
+
sortal_contact["atom_feeds"] = yaml_data["atom"]
+
+
return sortal_contact
+
+
def main():
+
# Source directory
+
source_dir = Path.home() / 'src' / 'git' / 'avsm' / 'arod-dream' / 'data' / 'contacts'
+
+
# Destination directory (XDG data home for sortal)
+
xdg_data = get_xdg_data_home()
+
dest_dir = xdg_data / 'sortal'
+
dest_dir.mkdir(parents=True, exist_ok=True)
+
+
print(f"Importing contacts from: {source_dir}")
+
print(f"Output directory: {dest_dir}")
+
print()
+
+
if not source_dir.exists():
+
print(f"Error: Source directory does not exist: {source_dir}")
+
return 1
+
+
imported_count = 0
+
error_count = 0
+
+
# Process each .md file
+
for md_file in sorted(source_dir.glob('*.md')):
+
handle = md_file.stem
+
+
try:
+
yaml_data = parse_contact_md(md_file)
+
if yaml_data is None:
+
print(f"⚠ Skipping {handle}: No YAML front matter found")
+
error_count += 1
+
continue
+
+
# Convert to Sortal format
+
sortal_contact = convert_to_sortal_format(yaml_data, handle)
+
+
# Write JSON file
+
output_file = dest_dir / f"{handle}.json"
+
with open(output_file, 'w') as f:
+
json.dump(sortal_contact, f, indent=2, ensure_ascii=False)
+
+
name = sortal_contact.get('names', [handle])[0] if sortal_contact.get('names') else handle
+
print(f"✓ Imported: {handle} ({name})")
+
imported_count += 1
+
+
except Exception as e:
+
print(f"✗ Error importing {handle}: {e}")
+
error_count += 1
+
+
print()
+
print(f"Import complete!")
+
print(f" Successfully imported: {imported_count}")
+
print(f" Errors: {error_count}")
+
print(f" Output directory: {dest_dir}")
+
+
return 0 if error_count == 0 else 1
+
+
if __name__ == '__main__':
+
exit(main())
+3
stack/sortal/test/dune
···
+
(test
+
(name test_sortal)
+
(libraries eio eio_main sortal jsont jsont.bytesrw))
+167
stack/sortal/test/test_sortal.ml
···
+
(** Tests for the Sortal library *)
+
+
open Eio.Std
+
+
let test_contact_creation () =
+
let c = Sortal.Contact.make
+
~handle:"test"
+
~names:["Test User"; "T. User"]
+
~email:"test@example.com"
+
~github:"testuser"
+
() in
+
assert (Sortal.Contact.handle c = "test");
+
assert (Sortal.Contact.name c = "Test User");
+
assert (List.length (Sortal.Contact.names c) = 2);
+
assert (Sortal.Contact.email c = Some "test@example.com");
+
assert (Sortal.Contact.github c = Some "testuser");
+
assert (Sortal.Contact.twitter c = None);
+
traceln "✓ Contact creation works"
+
+
let test_best_url () =
+
let c1 = Sortal.Contact.make
+
~handle:"test1"
+
~names:["Test 1"]
+
~url:"https://example.com"
+
~github:"test1"
+
() in
+
assert (Sortal.Contact.best_url c1 = Some "https://example.com");
+
+
let c2 = Sortal.Contact.make
+
~handle:"test2"
+
~names:["Test 2"]
+
~github:"test2"
+
() in
+
assert (Sortal.Contact.best_url c2 = Some "https://github.com/test2");
+
+
let c3 = Sortal.Contact.make
+
~handle:"test3"
+
~names:["Test 3"]
+
~email:"test3@example.com"
+
() in
+
assert (Sortal.Contact.best_url c3 = Some "mailto:test3@example.com");
+
+
let c4 = Sortal.Contact.make
+
~handle:"test4"
+
~names:["Test 4"]
+
() in
+
assert (Sortal.Contact.best_url c4 = None);
+
+
traceln "✓ Best URL selection works"
+
+
let test_json_encoding () =
+
let c = Sortal.Contact.make
+
~handle:"json_test"
+
~names:["JSON Test"]
+
~email:"json@example.com"
+
~github:"jsontest"
+
~orcid:"0000-0001-2345-6789"
+
() in
+
+
match Jsont_bytesrw.encode_string Sortal.Contact.json_t c with
+
| Ok json_str ->
+
(match Jsont_bytesrw.decode_string Sortal.Contact.json_t json_str with
+
| Ok decoded ->
+
assert (Sortal.Contact.handle decoded = "json_test");
+
assert (Sortal.Contact.email decoded = Some "json@example.com");
+
assert (Sortal.Contact.github decoded = Some "jsontest");
+
assert (Sortal.Contact.orcid decoded = Some "0000-0001-2345-6789");
+
traceln "✓ JSON encoding/decoding works"
+
| Error err ->
+
failwith ("JSON decode failed: " ^ err))
+
| Error err ->
+
failwith ("JSON encode failed: " ^ err)
+
+
let test_handle_generation () =
+
assert (Sortal.handle_of_name "John Smith" = "jssmith");
+
assert (Sortal.handle_of_name "Alice Barbara Cooper" = "abccooper");
+
assert (Sortal.handle_of_name "Bob" = "bbob");
+
traceln "✓ Handle generation works"
+
+
let test_store_operations () =
+
Eio_main.run @@ fun env ->
+
+
(* Create a store with a test app name *)
+
let store = Sortal.create env#fs "sortal-test" in
+
+
(* Create test contacts *)
+
let c1 = Sortal.Contact.make
+
~handle:"alice"
+
~names:["Alice Anderson"]
+
~email:"alice@example.com"
+
() in
+
+
let c2 = Sortal.Contact.make
+
~handle:"bob"
+
~names:["Bob Brown"; "Robert Brown"]
+
~github:"bobbrown"
+
() in
+
+
(* Test save *)
+
Sortal.save store c1;
+
Sortal.save store c2;
+
traceln "✓ Saving contacts works";
+
+
(* Test lookup *)
+
(match Sortal.lookup store "alice" with
+
| Some c ->
+
assert (Sortal.Contact.name c = "Alice Anderson");
+
traceln "✓ Lookup works"
+
| None -> failwith "Lookup failed to find saved contact");
+
+
(* Test lookup of non-existent contact *)
+
(match Sortal.lookup store "nonexistent" with
+
| None -> traceln "✓ Lookup correctly returns None for missing contact"
+
| Some _ -> failwith "Lookup should return None for non-existent contact");
+
+
(* Test list *)
+
let all = Sortal.list store in
+
assert (List.length all >= 2);
+
traceln "✓ List returns saved contacts (%d total)" (List.length all);
+
+
(* Test find_by_name *)
+
let found = Sortal.find_by_name store "Bob Brown" in
+
assert (Sortal.Contact.handle found = "bob");
+
traceln "✓ Find by name works";
+
+
(* Test find_by_name_opt *)
+
(match Sortal.find_by_name_opt store "Alice Anderson" with
+
| Some c ->
+
assert (Sortal.Contact.handle c = "alice");
+
traceln "✓ Find by name (optional) works"
+
| None -> failwith "find_by_name_opt failed");
+
+
(match Sortal.find_by_name_opt store "Nobody" with
+
| None -> traceln "✓ Find by name (optional) returns None for missing"
+
| Some _ -> failwith "find_by_name_opt should return None");
+
+
(* Test delete *)
+
Sortal.delete store "alice";
+
(match Sortal.lookup store "alice" with
+
| None -> traceln "✓ Delete works"
+
| Some _ -> failwith "Contact should have been deleted");
+
+
(* Clean up remaining test contact *)
+
Sortal.delete store "bob";
+
traceln "✓ Test cleanup complete"
+
+
let test_contact_compare () =
+
let c1 = Sortal.Contact.make ~handle:"alice" ~names:["Alice"] () in
+
let c2 = Sortal.Contact.make ~handle:"bob" ~names:["Bob"] () in
+
let c3 = Sortal.Contact.make ~handle:"alice" ~names:["Alice2"] () in
+
+
assert (Sortal.Contact.compare c1 c2 < 0);
+
assert (Sortal.Contact.compare c2 c1 > 0);
+
assert (Sortal.Contact.compare c1 c3 = 0);
+
traceln "✓ Contact comparison works"
+
+
let () =
+
traceln "\n=== Running Sortal Tests ===\n";
+
+
test_contact_creation ();
+
test_best_url ();
+
test_json_encoding ();
+
test_handle_generation ();
+
test_contact_compare ();
+
test_store_operations ();
+
+
traceln "\n=== All Tests Passed ===\n"