···11# Sortal - Contact Metadata Management Library
2233-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.
33+Sortal is an OCaml library that provides a comprehensive system for managing
44+contact metadata with temporal validity tracking. It stores data in
55+XDG-compliant locations using the YAML format and optionally versions all changes
66+with git.
4758## Features
69···8386) all_contacts
8487```
85888686-### 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-14789## CLI Tool
1489014991The library includes a standalone `sortal` CLI tool with full CRUD functionality:
···188130189131## Git Versioning
190132191191-Sortal includes a `Sortal_git_store` module that provides automatic git commits for all contact modifications:
133133+Sortal includes a `Sortal_git_store` module that provides automatic git commits
134134+for all contact modifications:
192135193136```ocaml
194137open Sortal
···220163221164## Project Status
222165223223-Fully implemented and tested with 420 imported contacts.
166166+Still very much just used by Anil Madhavapeddy. You're welcome to try it, but let me know...
+11-5
bin/sortal_cli.ml
···66 Fmt.set_style_renderer Fmt.stdout `Ansi_tty;
77 Fmt.set_style_renderer Fmt.stderr `Ansi_tty;
8899+ Eio_main.run @@ fun env ->
1010+1111+ let xdg_term = Xdge.Cmd.term "sortal" env#fs ~dirs:[`Data] () in
1212+913 let info = Cmd.info "sortal"
1014 ~version:"0.1.0"
1115 ~doc:"Contact metadata management"
···1721 `P "Use $(b,sortal COMMAND --help) for detailed help on each command.";
1822 ]
1923 in
2020-2121- Eio_main.run @@ fun env ->
2222-2323- let xdg_term = Xdge.Cmd.term "sortal" env#fs () in
24242525 let make_term info main_term =
2626 let term =
···214214 Cmd.v Sortal.Cmd.remove_url_info term
215215 in
216216217217- let default_term = Term.(ret (const (`Help (`Pager, None)))) in
217217+ let default_term =
218218+ let open Term.Syntax in
219219+ let+ _ = xdg_term
220220+ and+ _ = Logs_cli.level () in
221221+ `Help (`Pager, None)
222222+ in
223223+ let default_term = Term.ret default_term in
218224219225 let cmd = Cmd.group info ~default:default_term [
220226 list_cmd;
···191191 Logs.err (fun m -> m "%s" msg);
192192 1
193193194194+(* Convert string option to Ptime.date option *)
195195+let parse_date_opt (s_opt : string option) : Sortal_schema.Temporal.date option =
196196+ match s_opt with
197197+ | None -> None
198198+ | Some s ->
199199+ match Sortal_schema.Temporal.parse_date_string s with
200200+ | Some d -> Some d
201201+ | None ->
202202+ Logs.warn (fun m -> m "Invalid date format: %s (using ISO 8601: YYYY, YYYY-MM, or YYYY-MM-DD)" s);
203203+ None
204204+194205(* Add email to existing contact *)
195206let add_email_cmd handle address type_ from until note xdg env =
196207 let store = Sortal_store.create_from_xdg xdg in
197208 let git_store = Sortal_git_store.create store env in
209209+ let from = parse_date_opt from in
210210+ let until = parse_date_opt until in
198211 let email = Contact.make_email ?type_ ?from ?until ?note address in
199212 match Sortal_git_store.add_email git_store handle email with
200213 | Ok () ->
···245258let add_org_cmd handle org_name title department from until org_email org_url xdg env =
246259 let store = Sortal_store.create_from_xdg xdg in
247260 let git_store = Sortal_git_store.create store env in
261261+ let from = parse_date_opt from in
262262+ let until = parse_date_opt until in
248263 let org = Contact.make_org ?title ?department ?from ?until ?email:org_email ?url:org_url org_name in
249264 match Sortal_git_store.add_organization git_store handle org with
250265 | Ok () ->
···11(** Sortal Schema - Versioned data types and serialization
2233 This library provides versioned schema definitions for contact metadata
44- with no I/O dependencies. It includes:
44+ with minimal I/O dependencies. It includes:
55 - Temporal validity support (ISO 8601 dates and ranges)
66 - Feed subscription types
77 - Contact metadata schemas (versioned)
8899- The schema library depends only on jsont, yamlt, bytesrw, and fmt
1010- for serialization and formatting. *)
99+ The schema library depends on jsont, yamlt, bytesrw, fmt for serialization
1010+ and formatting, plus ptime and ptime.clock.os for date/time operations. *)
11111212(** {1 Schema Version 1} *)
13131414module V1 : sig
1515 (** Version 1 of the contact schema (current stable version). *)
1616-1717- (** ISO 8601 date handling. *)
1818- module Date = Sortal_schema_date
19162017 (** Temporal validity support for time-bounded fields. *)
2118 module Temporal = Sortal_schema_temporal
···3330 When V2 is introduced, these will continue pointing to V1 for
3431 backward compatibility. *)
35323636-module Date = V1.Date
3733module Temporal = V1.Temporal
3834module Feed = V1.Feed
3935module Contact = V1.Contact
+10-3
lib/schema/sortal_schema_contact_v1.ml
···369369 | None -> ()
370370 | Some { Sortal_schema_temporal.from; until } ->
371371 match from, until with
372372- | Some f, Some u -> pf ppf " %a" (date_style string) (Printf.sprintf "[%s to %s]" f u)
373373- | Some f, None -> pf ppf " %a" (date_style string) (Printf.sprintf "[from %s]" f)
374374- | None, Some u -> pf ppf " %a" (date_style string) (Printf.sprintf "[until %s]" u)
372372+ | Some f, Some u ->
373373+ let fs = Sortal_schema_temporal.format_date f in
374374+ let us = Sortal_schema_temporal.format_date u in
375375+ pf ppf " %a" (date_style string) (Printf.sprintf "[%s to %s]" fs us)
376376+ | Some f, None ->
377377+ let fs = Sortal_schema_temporal.format_date f in
378378+ pf ppf " %a" (date_style string) (Printf.sprintf "[from %s]" fs)
379379+ | None, Some u ->
380380+ let us = Sortal_schema_temporal.format_date u in
381381+ pf ppf " %a" (date_style string) (Printf.sprintf "[until %s]" us)
375382 | None, None -> ()
376383 in
377384
···11-type date = string
11+type date = Ptime.date
2233type range = {
44 from: date option;
···991010let always = { from = None; until = None }
11111212-(* Compare ISO 8601 dates lexicographically - works for YYYY, YYYY-MM, YYYY-MM-DD *)
1313-let date_compare (d1 : date) (d2 : date) : int =
1414- String.compare d1 d2
1212+(* Compare Ptime dates (year, month, day tuples) *)
1313+let date_compare ((y1, m1, d1) : date) ((y2, m2, d2) : date) : int =
1414+ match compare y1 y2 with
1515+ | 0 -> (
1616+ match compare m1 m2 with
1717+ | 0 -> compare d1 d2
1818+ | c -> c)
1919+ | c -> c
15201621let date_gte d1 d2 = date_compare d1 d2 >= 0
1722···4651 r1_starts_before_r2_ends && r2_starts_before_r1_ends
47524853let today () =
4949- let open Unix in
5050- let tm = localtime (time ()) in
5151- Printf.sprintf "%04d-%02d-%02d"
5252- (tm.tm_year + 1900)
5353- (tm.tm_mon + 1)
5454- tm.tm_mday
5454+ Ptime_clock.now () |> Ptime.to_date
55555656let is_current range_opt =
5757 valid_at range_opt ~date:(today ())
···7676 | Some r -> overlaps r query_range
7777 ) list
78787979+(* Parse ISO 8601 date string to Ptime.date, handling partial dates *)
8080+let parse_date_string (s : string) : date option =
8181+ match String.split_on_char '-' s with
8282+ | [year_s] -> (
8383+ try
8484+ let year = int_of_string year_s in
8585+ Some (year, 1, 1) (* Year only → January 1st *)
8686+ with Failure _ -> None)
8787+ | [year_s; month_s] -> (
8888+ try
8989+ let year = int_of_string year_s in
9090+ let month = int_of_string month_s in
9191+ if month >= 1 && month <= 12 then
9292+ Some (year, month, 1) (* Year-Month → 1st of month *)
9393+ else None
9494+ with Failure _ -> None)
9595+ | [year_s; month_s; day_s] -> (
9696+ try
9797+ let year = int_of_string year_s in
9898+ let month = int_of_string month_s in
9999+ let day = int_of_string day_s in
100100+ if month >= 1 && month <= 12 && day >= 1 && day <= 31 then
101101+ Some (year, month, day)
102102+ else None
103103+ with Failure _ -> None)
104104+ | _ -> None
105105+106106+(* Format Ptime.date as ISO 8601 string YYYY-MM-DD *)
107107+let format_date ((year, month, day) : date) : string =
108108+ Printf.sprintf "%04d-%02d-%02d" year month day
109109+79110let json_t =
80111 let open Jsont in
81112 let open Jsont.Object in
82113 let mem_opt f v ~enc = mem f v ~dec_absent:None ~enc_omit:Option.is_none ~enc in
114114+115115+ (* Create a jsont type for date that converts between string and Ptime.date *)
116116+ let date_jsont =
117117+ let dec meta s =
118118+ match parse_date_string s with
119119+ | Some d -> d
120120+ | None -> Error.msgf meta "TemporalDate: invalid ISO 8601 date: %S" s
121121+ in
122122+ let enc = format_date in
123123+ Base.string (Base.map ~kind:"TemporalDate" ~dec ~enc ())
124124+ in
125125+83126 let make_range from until = { from; until } in
84127 map ~kind:"TemporalRange" make_range
8585- |> mem_opt "from" (some string) ~enc:(fun r -> r.from)
8686- |> mem_opt "until" (some string) ~enc:(fun r -> r.until)
128128+ |> mem_opt "from" (some date_jsont) ~enc:(fun r -> r.from)
129129+ |> mem_opt "until" (some date_jsont) ~enc:(fun r -> r.until)
87130 |> finish
+24-7
lib/schema/sortal_schema_temporal.mli
···44 information in contacts, such as emails valid only during certain
55 employment periods. *)
6677-(** ISO 8601 date string.
77+(** Date represented as a Ptime.date tuple (year, month, day).
88+99+ When parsing from strings, partial dates are normalized:
1010+ - Year: ["2001"] → (2001, 1, 1)
1111+ - Year-Month: ["2001-01"] → (2001, 1, 1)
1212+ - Full date: ["2001-01-15"] → (2001, 1, 15) *)
1313+type date = Ptime.date
1414+1515+(** {1 Date Conversion} *)
1616+1717+(** [parse_date_string s] parses an ISO 8601 date string.
1818+1919+ Accepts various formats with partial date support:
2020+ - "2001" (year only) → (2001, 1, 1)
2121+ - "2001-01" (year-month) → (2001, 1, 1)
2222+ - "2001-01-15" (full date) → (2001, 1, 15)
2323+2424+ Returns [None] if the string is not a valid date format. *)
2525+val parse_date_string : string -> date option
82699- Supports multiple granularities:
1010- - Year: ["2001"]
1111- - Year-Month: ["2001-01"]
1212- - Full date: ["2001-01-15"]
2727+(** [format_date date] formats a date as ISO 8601 (YYYY-MM-DD).
2828+2929+ {b Example:} [format_date (2001, 1, 15)] returns ["2001-01-15"] *)
3030+val format_date : date -> string
13311414- For querying, partial dates are treated as inclusive ranges. *)
1515-type date = string
3232+(** {1 Temporal Ranges} *)
16331734(** A temporal range indicating validity period. *)
1835type range = {
+9-3
test/test_schema.ml
···11(** Schema-only tests (no I/O dependencies) *)
2233let test_temporal () =
44- let r = Sortal_schema.Temporal.make ~from:"2020-01" ~until:"2023-12" () in
55- assert (Sortal_schema.Temporal.valid_at (Some r) ~date:"2021-06");
66- assert (not (Sortal_schema.Temporal.valid_at (Some r) ~date:"2024-01"));
44+ (* Parse dates from strings *)
55+ let from_date = Sortal_schema.Temporal.parse_date_string "2020-01" |> Option.get in
66+ let until_date = Sortal_schema.Temporal.parse_date_string "2023-12" |> Option.get in
77+ let test_date_1 = Sortal_schema.Temporal.parse_date_string "2021-06" |> Option.get in
88+ let test_date_2 = Sortal_schema.Temporal.parse_date_string "2024-01" |> Option.get in
99+1010+ let r = Sortal_schema.Temporal.make ~from:from_date ~until:until_date () in
1111+ assert (Sortal_schema.Temporal.valid_at (Some r) ~date:test_date_1);
1212+ assert (not (Sortal_schema.Temporal.valid_at (Some r) ~date:test_date_2));
713 print_endline "✓ Temporal ranges work"
814915let test_feed_types () =