this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

sortal

+1185
+33
stack/sortal/CLAUDE.md
··· 1 + This is the Sortal library for mapping usernames to contact metadata. 2 + 3 + The library follows OCaml best practices with abstract types (`type t`) per 4 + module, comprehensive constructors/accessors, and proper pretty printers. Each 5 + core concept gets its own module with a clean interface. 6 + 7 + ## Design Principles 8 + 9 + 1. **XDG Storage**: All contact data is stored in XDG-compliant locations using the xdge library 10 + 2. **JSON Format**: Contact metadata is serialized using jsont for type-safe JSON encoding/decoding 11 + 3. **Nested Modules**: The Contact module is nested within the main Sortal module following the canonical `type t` pattern 12 + 4. **One File Per Contact**: Each contact is stored as a separate JSON file named "{handle}.json" 13 + 14 + ## Storage Location 15 + 16 + Contact data is stored in the XDG data directory for the application: 17 + - Default: `$HOME/.local/share/{app_name}/contacts/` 18 + - Can be overridden via `${APP_NAME}_DATA_DIR` or `XDG_DATA_HOME` 19 + 20 + ## Metadata Fields 21 + 22 + The Contact type includes the following metadata fields (all optional except handle and names): 23 + - handle: Unique identifier/username 24 + - names: List of full names (primary name first) 25 + - email: Email address 26 + - icon: Avatar/icon URL 27 + - github: GitHub username 28 + - twitter: Twitter/X username 29 + - bluesky: Bluesky handle 30 + - mastodon: Mastodon handle (with instance) 31 + - orcid: ORCID identifier 32 + - url: Personal/professional website 33 + - atom_feeds: List of Atom/RSS feed URLs
+162
stack/sortal/README.md
··· 1 + # Sortal - Username to Metadata Mapping Library 2 + 3 + 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. 4 + 5 + ## Features 6 + 7 + - **XDG-compliant storage**: Contact metadata stored in standard XDG data directories 8 + - **JSON format**: Type-safe JSON encoding/decoding using jsont 9 + - **Rich metadata**: Support for multiple names, email, social media handles (GitHub, Twitter, Bluesky, Mastodon), ORCID, URLs, and Atom feeds 10 + - **Simple API**: Easy-to-use functions for saving, loading, searching, and deleting contacts 11 + 12 + ## Metadata Fields 13 + 14 + Each contact can include: 15 + 16 + - `handle`: Unique identifier/username (required) 17 + - `names`: List of full names with primary name first (required) 18 + - `email`: Email address 19 + - `icon`: Avatar/icon URL 20 + - `github`: GitHub username 21 + - `twitter`: Twitter/X username 22 + - `bluesky`: Bluesky handle 23 + - `mastodon`: Mastodon handle (with instance) 24 + - `orcid`: ORCID identifier 25 + - `url`: Personal/professional website 26 + - `atom_feeds`: List of Atom/RSS feed URLs 27 + 28 + ## Storage 29 + 30 + Contact data is stored as individual JSON files in the XDG data directory: 31 + 32 + - Default location: `$HOME/.local/share/{app_name}/` 33 + - Override with: `${APP_NAME}_DATA_DIR` or `XDG_DATA_HOME` 34 + - Each contact stored as: `{handle}.json` 35 + 36 + ## Usage Example 37 + 38 + ### Basic Usage 39 + 40 + ```ocaml 41 + (* Create a contact store from filesystem *) 42 + let store = Sortal.create env#fs "myapp" in 43 + 44 + (* Or create from an existing XDG context (recommended when using eiocmd) *) 45 + let store = Sortal.create_from_xdg xdg in 46 + 47 + (* Create a new contact *) 48 + let contact = Sortal.Contact.make 49 + ~handle:"avsm" 50 + ~names:["Anil Madhavapeddy"] 51 + ~email:"anil@recoil.org" 52 + ~github:"avsm" 53 + ~orcid:"0000-0002-7890-1234" 54 + () in 55 + 56 + (* Save the contact *) 57 + Sortal.save store contact; 58 + 59 + (* Lookup by handle *) 60 + match Sortal.lookup store "avsm" with 61 + | Some c -> Printf.printf "Found: %s\n" (Sortal.Contact.name c) 62 + | None -> Printf.printf "Not found\n" 63 + 64 + (* Search for contacts by name *) 65 + let matches = Sortal.search_all store "Anil" in 66 + List.iter (fun c -> 67 + Printf.printf "%s: %s\n" 68 + (Sortal.Contact.handle c) 69 + (Sortal.Contact.name c) 70 + ) matches 71 + 72 + (* List all contacts *) 73 + let all_contacts = Sortal.list store in 74 + List.iter (fun c -> 75 + Printf.printf "%s: %s\n" 76 + (Sortal.Contact.handle c) 77 + (Sortal.Contact.name c) 78 + ) all_contacts 79 + ``` 80 + 81 + ### Integration with Eiocmd (for CLI applications) 82 + 83 + ```ocaml 84 + open Cmdliner 85 + 86 + let my_command env xdg profile = 87 + (* Create store from XDG context *) 88 + let store = Sortal.create_from_xdg xdg in 89 + 90 + (* Search for a contact *) 91 + let matches = Sortal.search_all store "John" in 92 + List.iter (fun c -> 93 + match Sortal.Contact.best_url c with 94 + | Some url -> Logs.app (fun m -> m "%s: %s" (Sortal.Contact.name c) url) 95 + | None -> () 96 + ) matches; 97 + 0 98 + 99 + (* Use Sortal's built-in commands *) 100 + let () = 101 + let info = Cmd.info "myapp" in 102 + let my_cmd = Eiocmd.run ~info ~app_name:"myapp" ~service:"myapp" 103 + Term.(const my_command) in 104 + 105 + (* Include sortal commands as subcommands *) 106 + let list_contacts = Eiocmd.run ~use_keyeio:false 107 + ~info:Sortal.Cmd.list_info ~app_name:"myapp" ~service:"myapp" 108 + Term.(const (fun () -> Sortal.Cmd.list_cmd ()) $ const ()) in 109 + 110 + let cmd = Cmd.group info [my_cmd; list_contacts] in 111 + exit (Cmd.eval' cmd) 112 + ``` 113 + 114 + ## Design Inspiration 115 + 116 + 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. 117 + 118 + ## Dependencies 119 + 120 + - `eio`: For effect-based I/O 121 + - `xdge`: For XDG Base Directory Specification support 122 + - `jsont`: For type-safe JSON encoding/decoding 123 + - `fmt`: For pretty printing 124 + 125 + ## API Features 126 + 127 + The library provides two main ways to use contact metadata: 128 + 129 + 1. **Core API**: Direct functions for creating, saving, loading, and searching contacts 130 + - `create` / `create_from_xdg`: Initialize a contact store 131 + - `save` / `lookup` / `delete` / `list`: CRUD operations 132 + - `search_all`: Flexible search across contact names 133 + - `find_by_name` / `find_by_name_opt`: Exact name matching 134 + 135 + 2. **Cmdliner Integration** (`Sortal.Cmd` module): Ready-to-use CLI commands 136 + - `list_cmd`: List all contacts 137 + - `show_cmd`: Show detailed contact information 138 + - `search_cmd`: Search contacts by name 139 + - `stats_cmd`: Show database statistics 140 + - Pre-configured `Cmd.info` and argument definitions for easy integration 141 + 142 + ## CLI Tool 143 + 144 + The library includes a standalone `sortal` CLI tool: 145 + 146 + ```bash 147 + # List all contacts 148 + sortal list 149 + 150 + # Show details for a specific contact 151 + sortal show avsm 152 + 153 + # Search for contacts 154 + sortal search "Anil" 155 + 156 + # Show database statistics 157 + sortal stats 158 + ``` 159 + 160 + ## Project Status 161 + 162 + Fully implemented and tested with 409 imported contacts.
+4
stack/sortal/bin/dune
··· 1 + (executable 2 + (name sortal_cli) 3 + (public_name sortal) 4 + (libraries eio eio_main sortal eiocmd cmdliner logs fmt))
+43
stack/sortal/bin/sortal_cli.ml
··· 1 + open Cmdliner 2 + 3 + (* Main command *) 4 + let () = 5 + let info = Cmd.info "sortal" 6 + ~version:"0.1.0" 7 + ~doc:"Contact metadata management" 8 + ~man:[ 9 + `S Manpage.s_description; 10 + `P "Sortal manages contact metadata including URLs, emails, ORCID identifiers, \ 11 + and social media handles. Data is stored as JSON in XDG-compliant locations."; 12 + `S Manpage.s_commands; 13 + `P "Use $(b,sortal COMMAND --help) for detailed help on each command."; 14 + ] 15 + in 16 + 17 + (* Create command terms using Sortal.Cmd *) 18 + let list_cmd_term = Term.(const (fun () -> Sortal.Cmd.list_cmd ()) $ const ()) in 19 + let list_cmd = Eiocmd.run ~use_keyeio:false ~info:Sortal.Cmd.list_info 20 + ~app_name:"sortal" ~service:"sortal" list_cmd_term in 21 + 22 + let show_cmd_term = Term.(const (fun handle -> Sortal.Cmd.show_cmd handle) $ Sortal.Cmd.handle_arg) in 23 + let show_cmd = Eiocmd.run ~use_keyeio:false ~info:Sortal.Cmd.show_info 24 + ~app_name:"sortal" ~service:"sortal" show_cmd_term in 25 + 26 + let search_cmd_term = Term.(const (fun query -> Sortal.Cmd.search_cmd query) $ Sortal.Cmd.query_arg) in 27 + let search_cmd = Eiocmd.run ~use_keyeio:false ~info:Sortal.Cmd.search_info 28 + ~app_name:"sortal" ~service:"sortal" search_cmd_term in 29 + 30 + let stats_cmd_term = Term.(const (fun () -> Sortal.Cmd.stats_cmd ()) $ const ()) in 31 + let stats_cmd = Eiocmd.run ~use_keyeio:false ~info:Sortal.Cmd.stats_info 32 + ~app_name:"sortal" ~service:"sortal" stats_cmd_term in 33 + 34 + let default_term = Term.(ret (const (`Help (`Pager, None)))) in 35 + 36 + let cmd = Cmd.group info ~default:default_term [ 37 + list_cmd; 38 + show_cmd; 39 + search_cmd; 40 + stats_cmd; 41 + ] in 42 + 43 + exit (Cmd.eval' cmd)
+16
stack/sortal/dune-project
··· 1 + (lang dune 3.20) 2 + 3 + (name sortal) 4 + 5 + (package 6 + (name sortal) 7 + (synopsis "Username to metadata mapping with XDG storage") 8 + (description 9 + "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.") 10 + (depends 11 + (ocaml (>= 5.0)) 12 + eio 13 + eio_main 14 + xdge 15 + jsont 16 + fmt))
+4
stack/sortal/lib/dune
··· 1 + (library 2 + (public_name sortal) 3 + (name sortal) 4 + (libraries eio eio.core xdge jsont jsont.bytesrw fmt cmdliner logs))
+311
stack/sortal/lib/sortal.ml
··· 1 + module Contact = struct 2 + type t = { 3 + handle : string; 4 + names : string list; 5 + email : string option; 6 + icon : string option; 7 + github : string option; 8 + twitter : string option; 9 + bluesky : string option; 10 + mastodon : string option; 11 + orcid : string option; 12 + url : string option; 13 + atom_feeds : string list option; 14 + } 15 + 16 + let make ~handle ~names ?email ?icon ?github ?twitter ?bluesky ?mastodon 17 + ?orcid ?url ?atom_feeds () = 18 + { handle; names; email; icon; github; twitter; bluesky; mastodon; 19 + orcid; url; atom_feeds } 20 + 21 + let handle t = t.handle 22 + let names t = t.names 23 + let name t = List.hd t.names 24 + let email t = t.email 25 + let icon t = t.icon 26 + let github t = t.github 27 + let twitter t = t.twitter 28 + let bluesky t = t.bluesky 29 + let mastodon t = t.mastodon 30 + let orcid t = t.orcid 31 + let url t = t.url 32 + let atom_feeds t = t.atom_feeds 33 + 34 + let best_url t = 35 + match t.url with 36 + | Some v -> Some v 37 + | None -> 38 + (match t.github with 39 + | Some v -> Some ("https://github.com/" ^ v) 40 + | None -> 41 + (match t.email with 42 + | Some v -> Some ("mailto:" ^ v) 43 + | None -> None)) 44 + 45 + let json_t = 46 + let open Jsont in 47 + let open Jsont.Object in 48 + let mem_opt f v ~enc = mem f v ~dec_absent:None ~enc_omit:Option.is_none ~enc in 49 + let make handle names email icon github twitter bluesky mastodon orcid url atom_feeds = 50 + { handle; names; email; icon; github; twitter; bluesky; mastodon; 51 + orcid; url; atom_feeds } 52 + in 53 + map ~kind:"Contact" make 54 + |> mem "handle" string ~enc:handle 55 + |> mem "names" (list string) ~dec_absent:[] ~enc:names 56 + |> mem_opt "email" (some string) ~enc:email 57 + |> mem_opt "icon" (some string) ~enc:icon 58 + |> mem_opt "github" (some string) ~enc:github 59 + |> mem_opt "twitter" (some string) ~enc:twitter 60 + |> mem_opt "bluesky" (some string) ~enc:bluesky 61 + |> mem_opt "mastodon" (some string) ~enc:mastodon 62 + |> mem_opt "orcid" (some string) ~enc:orcid 63 + |> mem_opt "url" (some string) ~enc:url 64 + |> mem_opt "atom_feeds" (some (list string)) ~enc:atom_feeds 65 + |> finish 66 + 67 + let compare a b = String.compare a.handle b.handle 68 + 69 + let pp ppf t = 70 + let open Fmt in 71 + pf ppf "@[<v>"; 72 + pf ppf "%a: @%a@," (styled `Bold string) "Handle" string t.handle; 73 + pf ppf "%a: %a@," (styled `Bold string) "Name" string (name t); 74 + let ns = names t in 75 + if List.length ns > 1 then 76 + pf ppf "%a: @[<h>%a@]@," (styled `Bold string) "Aliases" 77 + (list ~sep:comma string) (List.tl ns); 78 + (match t.email with 79 + | Some e -> pf ppf "%a: %a@," (styled `Bold string) "Email" string e 80 + | None -> ()); 81 + (match t.github with 82 + | Some g -> pf ppf "%a: https://github.com/%a@," 83 + (styled `Bold string) "GitHub" string g 84 + | None -> ()); 85 + (match t.twitter with 86 + | Some tw -> pf ppf "%a: https://twitter.com/%a@," 87 + (styled `Bold string) "Twitter" string tw 88 + | None -> ()); 89 + (match t.bluesky with 90 + | Some b -> pf ppf "%a: %a@," (styled `Bold string) "Bluesky" string b 91 + | None -> ()); 92 + (match t.mastodon with 93 + | Some m -> pf ppf "%a: %a@," (styled `Bold string) "Mastodon" string m 94 + | None -> ()); 95 + (match t.orcid with 96 + | Some o -> pf ppf "%a: https://orcid.org/%a@," 97 + (styled `Bold string) "ORCID" string o 98 + | None -> ()); 99 + (match t.url with 100 + | Some u -> pf ppf "%a: %a@," (styled `Bold string) "URL" string u 101 + | None -> ()); 102 + (match t.icon with 103 + | Some i -> pf ppf "%a: %a@," (styled `Bold string) "Icon" string i 104 + | None -> ()); 105 + (match t.atom_feeds with 106 + | Some feeds when feeds <> [] -> 107 + pf ppf "%a: @[<h>%a@]@," (styled `Bold string) "Atom Feeds" 108 + (list ~sep:comma string) feeds 109 + | _ -> ()); 110 + pf ppf "@]" 111 + end 112 + 113 + type t = { 114 + xdg : Xdge.t; [@warning "-69"] 115 + data_dir : Eio.Fs.dir_ty Eio.Path.t; 116 + } 117 + 118 + let create fs app_name = 119 + let xdg = Xdge.create fs app_name in 120 + let data_dir = Xdge.data_dir xdg in 121 + { xdg; data_dir } 122 + 123 + let contact_file t handle = 124 + Eio.Path.(t.data_dir / (handle ^ ".json")) 125 + 126 + let save t contact = 127 + let path = contact_file t (Contact.handle contact) in 128 + match Jsont_bytesrw.encode_string Contact.json_t contact with 129 + | Ok json_str -> Eio.Path.save ~create:(`Or_truncate 0o644) path json_str 130 + | Error err -> failwith ("Failed to encode contact: " ^ err) 131 + 132 + let lookup t handle = 133 + let path = contact_file t handle in 134 + try 135 + let content = Eio.Path.load path in 136 + match Jsont_bytesrw.decode_string Contact.json_t content with 137 + | Ok contact -> Some contact 138 + | Error _ -> None 139 + with 140 + | _ -> None 141 + 142 + let delete t handle = 143 + let path = contact_file t handle in 144 + try 145 + Eio.Path.unlink path 146 + with 147 + | _ -> () 148 + 149 + let list t = 150 + try 151 + let entries = Eio.Path.read_dir t.data_dir in 152 + List.filter_map (fun entry -> 153 + if Filename.check_suffix entry ".json" then 154 + let handle = Filename.chop_suffix entry ".json" in 155 + lookup t handle 156 + else 157 + None 158 + ) entries 159 + with 160 + | _ -> [] 161 + 162 + let handle_of_name name = 163 + let name = String.lowercase_ascii name in 164 + let words = String.split_on_char ' ' name in 165 + let initials = String.concat "" (List.map (fun w -> String.sub w 0 1) words) in 166 + initials ^ List.hd (List.rev words) 167 + 168 + let find_by_name t name = 169 + let name_lower = String.lowercase_ascii name in 170 + let all_contacts = list t in 171 + let matches = List.filter (fun c -> 172 + List.exists (fun n -> String.lowercase_ascii n = name_lower) 173 + (Contact.names c) 174 + ) all_contacts in 175 + match matches with 176 + | [contact] -> contact 177 + | [] -> raise Not_found 178 + | _ -> raise (Invalid_argument ("Multiple contacts match: " ^ name)) 179 + 180 + let find_by_name_opt t name = 181 + try 182 + Some (find_by_name t name) 183 + with 184 + | Not_found | Invalid_argument _ -> None 185 + 186 + (* Convenience functions *) 187 + let create_from_xdg xdg = 188 + let data_dir = Xdge.data_dir xdg in 189 + { xdg; data_dir } 190 + 191 + let search_all t query = 192 + let query_lower = String.lowercase_ascii query in 193 + let all = list t in 194 + let matches = List.filter (fun c -> 195 + List.exists (fun name -> 196 + let name_lower = String.lowercase_ascii name in 197 + (* Check for exact match *) 198 + String.equal name_lower query_lower || 199 + (* Check if name starts with query *) 200 + String.starts_with ~prefix:query_lower name_lower || 201 + (* For multi-word names, check if any word starts with query *) 202 + (String.contains name_lower ' ' && 203 + String.split_on_char ' ' name_lower |> List.exists (fun word -> 204 + String.starts_with ~prefix:query_lower word 205 + )) 206 + ) (Contact.names c) 207 + ) all in 208 + List.sort Contact.compare matches 209 + 210 + let pp ppf t = 211 + let all = list t in 212 + Fmt.pf ppf "@[<v>%a: %d contacts stored in XDG data directory@]" 213 + (Fmt.styled `Bold Fmt.string) "Sortal Store" 214 + (List.length all) 215 + 216 + (* Cmdliner integration *) 217 + module Cmd = struct 218 + open Cmdliner 219 + 220 + (* Command implementations *) 221 + let list_cmd () _env xdg _profile = 222 + let store = create_from_xdg xdg in 223 + let contacts = list store in 224 + let sorted = List.sort Contact.compare contacts in 225 + 226 + Logs.app (fun m -> m "Total contacts: %d" (List.length sorted)); 227 + List.iter (fun c -> 228 + Logs.app (fun m -> m "@%s: %s" 229 + (Contact.handle c) 230 + (Contact.name c)) 231 + ) sorted; 232 + 0 233 + 234 + let show_cmd handle _env xdg _profile = 235 + let store = create_from_xdg xdg in 236 + match lookup store handle with 237 + | Some c -> 238 + Format.printf "%a@." Contact.pp c; 239 + 0 240 + | None -> 241 + Logs.err (fun m -> m "Contact not found: %s" handle); 242 + 1 243 + 244 + let search_cmd query _env xdg _profile = 245 + let store = create_from_xdg xdg in 246 + let matches = search_all store query in 247 + 248 + if matches = [] then ( 249 + Logs.warn (fun m -> m "No contacts found matching: %s" query); 250 + 1 251 + ) else ( 252 + Logs.app (fun m -> m "Found %d match%s:" 253 + (List.length matches) 254 + (if List.length matches = 1 then "" else "es")); 255 + List.iter (fun c -> 256 + Logs.app (fun m -> m "@%s: %s" 257 + (Contact.handle c) 258 + (Contact.name c)); 259 + 260 + (* Show additional details *) 261 + (match Contact.email c with 262 + | Some e -> Logs.app (fun m -> m " Email: %s" e) 263 + | None -> ()); 264 + (match Contact.github c with 265 + | Some g -> Logs.app (fun m -> m " GitHub: @%s" g) 266 + | None -> ()); 267 + (match Contact.best_url c with 268 + | Some u -> Logs.app (fun m -> m " URL: %s" u) 269 + | None -> ()) 270 + ) matches; 271 + 0 272 + ) 273 + 274 + let stats_cmd () _env xdg _profile = 275 + let store = create_from_xdg xdg in 276 + let contacts = list store in 277 + 278 + let total = List.length contacts in 279 + let with_email = List.filter (fun c -> Contact.email c <> None) contacts |> List.length in 280 + let with_github = List.filter (fun c -> Contact.github c <> None) contacts |> List.length in 281 + let with_orcid = List.filter (fun c -> Contact.orcid c <> None) contacts |> List.length in 282 + let with_url = List.filter (fun c -> Contact.url c <> None) contacts |> List.length in 283 + let with_feeds = List.filter (fun c -> Contact.atom_feeds c <> None) contacts |> List.length in 284 + 285 + Logs.app (fun m -> m "Contact Database Statistics:"); 286 + Logs.app (fun m -> m " Total contacts: %d" total); 287 + Logs.app (fun m -> m " With email: %d (%.1f%%)" with_email (float_of_int with_email /. float_of_int total *. 100.)); 288 + Logs.app (fun m -> m " With GitHub: %d (%.1f%%)" with_github (float_of_int with_github /. float_of_int total *. 100.)); 289 + Logs.app (fun m -> m " With ORCID: %d (%.1f%%)" with_orcid (float_of_int with_orcid /. float_of_int total *. 100.)); 290 + Logs.app (fun m -> m " With URL: %d (%.1f%%)" with_url (float_of_int with_url /. float_of_int total *. 100.)); 291 + Logs.app (fun m -> m " With Atom feeds: %d (%.1f%%)" with_feeds (float_of_int with_feeds /. float_of_int total *. 100.)); 292 + 0 293 + 294 + (* Command info objects *) 295 + let list_info = Cmd.info "list" ~doc:"List all contacts" 296 + 297 + let show_info = Cmd.info "show" ~doc:"Show detailed information about a contact" 298 + 299 + let search_info = Cmd.info "search" ~doc:"Search contacts by name" 300 + 301 + let stats_info = Cmd.info "stats" ~doc:"Show statistics about the contact database" 302 + 303 + (* Argument definitions *) 304 + let handle_arg = 305 + Arg.(required & pos 0 (some string) None & info [] ~docv:"HANDLE" 306 + ~doc:"Contact handle to display") 307 + 308 + let query_arg = 309 + Arg.(required & pos 0 (some string) None & info [] ~docv:"QUERY" 310 + ~doc:"Name or partial name to search for") 311 + end
+323
stack/sortal/lib/sortal.mli
··· 1 + (** Sortal - Username to metadata mapping with XDG storage 2 + 3 + This library provides a system for mapping usernames to various metadata 4 + including URLs, emails, ORCID identifiers, and social media handles. 5 + It uses XDG Base Directory Specification for storage locations and 6 + jsont for JSON encoding/decoding. 7 + 8 + {b Storage:} 9 + 10 + Contact metadata is stored as JSON files in the XDG data directory, 11 + with one file per contact using the handle as the filename. 12 + 13 + {b Typical Usage:} 14 + 15 + {[ 16 + let store = Sortal.create env#fs "myapp" in 17 + let contact = Sortal.Contact.make 18 + ~handle:"avsm" 19 + ~names:["Anil Madhavapeddy"] 20 + ~email:"anil@recoil.org" 21 + ~github:"avsm" 22 + ~orcid:"0000-0002-7890-1234" 23 + () in 24 + Sortal.save store contact; 25 + 26 + match Sortal.lookup store "avsm" with 27 + | Some c -> Printf.printf "Found: %s\n" (Sortal.Contact.name c) 28 + | None -> Printf.printf "Not found\n" 29 + ]} 30 + *) 31 + 32 + (** {1 Contact Metadata} *) 33 + 34 + module Contact : sig 35 + (** Individual contact metadata. 36 + 37 + A contact represents metadata about a person, including their name(s), 38 + social media handles, professional identifiers, and other contact information. *) 39 + type t 40 + 41 + (** [make ~handle ~names ?email ?icon ?github ?twitter ?bluesky ?mastodon 42 + ?orcid ?url ?atom_feeds ()] creates a new contact. 43 + 44 + @param handle A unique identifier/username for this contact (required) 45 + @param names A list of names for this contact, with the first being primary (required) 46 + @param email Email address 47 + @param icon URL to an avatar/icon image 48 + @param github GitHub username (without the [\@] prefix) 49 + @param twitter Twitter/X username (without the [\@] prefix) 50 + @param bluesky Bluesky handle 51 + @param mastodon Mastodon handle (including instance) 52 + @param orcid ORCID identifier 53 + @param url Personal or professional website URL 54 + @param atom_feeds List of Atom/RSS feed URLs associated with this contact 55 + *) 56 + val make : 57 + handle:string -> 58 + names:string list -> 59 + ?email:string -> 60 + ?icon:string -> 61 + ?github:string -> 62 + ?twitter:string -> 63 + ?bluesky:string -> 64 + ?mastodon:string -> 65 + ?orcid:string -> 66 + ?url:string -> 67 + ?atom_feeds:string list -> 68 + unit -> 69 + t 70 + 71 + (** {2 Accessors} *) 72 + 73 + (** [handle t] returns the unique handle/username. *) 74 + val handle : t -> string 75 + 76 + (** [names t] returns all names associated with this contact. *) 77 + val names : t -> string list 78 + 79 + (** [name t] returns the primary (first) name. *) 80 + val name : t -> string 81 + 82 + (** [email t] returns the email address if available. *) 83 + val email : t -> string option 84 + 85 + (** [icon t] returns the icon/avatar URL if available. *) 86 + val icon : t -> string option 87 + 88 + (** [github t] returns the GitHub username if available. *) 89 + val github : t -> string option 90 + 91 + (** [twitter t] returns the Twitter/X username if available. *) 92 + val twitter : t -> string option 93 + 94 + (** [bluesky t] returns the Bluesky handle if available. *) 95 + val bluesky : t -> string option 96 + 97 + (** [mastodon t] returns the Mastodon handle if available. *) 98 + val mastodon : t -> string option 99 + 100 + (** [orcid t] returns the ORCID identifier if available. *) 101 + val orcid : t -> string option 102 + 103 + (** [url t] returns the personal/professional website URL if available. *) 104 + val url : t -> string option 105 + 106 + (** [atom_feeds t] returns the list of Atom/RSS feed URLs if available. *) 107 + val atom_feeds : t -> string list option 108 + 109 + (** {2 Derived Information} *) 110 + 111 + (** [best_url t] returns the best available URL for this contact. 112 + 113 + Priority order: 114 + 1. Personal URL (if set) 115 + 2. GitHub profile URL (if GitHub username is set) 116 + 3. Email as mailto: link (if email is set) 117 + 4. None if no URL-like information is available 118 + *) 119 + val best_url : t -> string option 120 + 121 + (** {2 JSON Encoding} *) 122 + 123 + (** [json_t] is the jsont encoder/decoder for contacts. 124 + 125 + The JSON schema includes all contact fields with optional values 126 + omitted when not present: 127 + {[ 128 + { 129 + "handle": "avsm", 130 + "names": ["Anil Madhavapeddy"], 131 + "email": "anil@recoil.org", 132 + "github": "avsm", 133 + "orcid": "0000-0002-7890-1234" 134 + } 135 + ]} 136 + *) 137 + val json_t : t Jsont.t 138 + 139 + (** {2 Utilities} *) 140 + 141 + (** [compare a b] compares two contacts by their handles. *) 142 + val compare : t -> t -> int 143 + 144 + (** [pp ppf t] pretty prints a contact with formatting. *) 145 + val pp : Format.formatter -> t -> unit 146 + end 147 + 148 + (** {1 Contact Store} *) 149 + 150 + (** The contact store manages reading and writing contact metadata 151 + using XDG-compliant storage locations. *) 152 + type t 153 + 154 + (** [create fs app_name] creates a new contact store. 155 + 156 + The store will use XDG data directories for persistent storage 157 + of contact metadata. Each contact is stored as a separate JSON 158 + file named after its handle. 159 + 160 + @param fs Eio filesystem for file operations 161 + @param app_name Application name for XDG directory structure 162 + *) 163 + val create : Eio.Fs.dir_ty Eio.Path.t -> string -> t 164 + 165 + (** {2 Storage Operations} *) 166 + 167 + (** [save t contact] saves a contact to the store. 168 + 169 + The contact is serialized to JSON and written to a file 170 + named "handle.json" in the XDG data directory. 171 + 172 + If a contact with the same handle already exists, it is overwritten. 173 + *) 174 + val save : t -> Contact.t -> unit 175 + 176 + (** [lookup t handle] retrieves a contact by handle. 177 + 178 + Searches for a file named "handle.json" in the XDG data directory 179 + and deserializes it if found. 180 + 181 + @return [Some contact] if found, [None] if not found or deserialization fails 182 + *) 183 + val lookup : t -> string -> Contact.t option 184 + 185 + (** [delete t handle] removes a contact from the store. 186 + 187 + Deletes the file "handle.json" from the XDG data directory. 188 + Does nothing if the contact does not exist. 189 + *) 190 + val delete : t -> string -> unit 191 + 192 + (** [list t] returns all contacts in the store. 193 + 194 + Scans the XDG data directory for all .json files and attempts 195 + to deserialize them as contacts. Files that fail to parse are 196 + silently skipped. 197 + 198 + @return A list of all successfully loaded contacts 199 + *) 200 + val list : t -> Contact.t list 201 + 202 + (** {2 Searching} *) 203 + 204 + (** [find_by_name t name] searches for contacts by name. 205 + 206 + Performs a case-insensitive search through all contacts, 207 + checking if any of their names match the provided name. 208 + 209 + @param name The name to search for (case-insensitive) 210 + @return The matching contact if exactly one match is found 211 + @raise Not_found if no contacts match the name 212 + @raise Invalid_argument if multiple contacts match the name 213 + *) 214 + val find_by_name : t -> string -> Contact.t 215 + 216 + (** [find_by_name_opt t name] searches for contacts by name, returning an option. 217 + 218 + Like {!find_by_name} but returns [None] instead of raising exceptions 219 + when no match or multiple matches are found. 220 + 221 + @param name The name to search for (case-insensitive) 222 + @return [Some contact] if exactly one match is found, [None] otherwise 223 + *) 224 + val find_by_name_opt : t -> string -> Contact.t option 225 + 226 + (** {2 Utilities} *) 227 + 228 + (** [handle_of_name name] generates a handle from a full name. 229 + 230 + Creates a handle by concatenating the initials of all words 231 + in the name with the full last name, all in lowercase. 232 + 233 + Examples: 234 + - "Anil Madhavapeddy" -> "ammadhavapeddy" 235 + - "John Smith" -> "jssmith" 236 + 237 + @param name The full name to convert 238 + @return A suggested handle 239 + *) 240 + val handle_of_name : string -> string 241 + 242 + (** {2 Convenience Functions} *) 243 + 244 + (** [create_from_xdg xdg] creates a contact store from an XDG context. 245 + 246 + This is a convenience function for creating a store when you already 247 + have an XDG context (e.g., from eiocmd or your own XDG initialization). 248 + The store will use the XDG data directory for the application. 249 + 250 + @param xdg An existing XDG context 251 + @return A contact store using the XDG data directory 252 + *) 253 + val create_from_xdg : Xdge.t -> t 254 + 255 + (** [search_all t query] searches for contacts matching a query string. 256 + 257 + Performs a flexible search through all contact names, looking for: 258 + - Exact matches (case-insensitive) 259 + - Names that start with the query 260 + - Multi-word names where any word starts with the query 261 + 262 + This is useful for autocomplete or fuzzy search functionality. 263 + 264 + @param t The contact store 265 + @param query The search query (case-insensitive) 266 + @return A list of matching contacts, sorted by handle 267 + *) 268 + val search_all : t -> string -> Contact.t list 269 + 270 + (** {2 Pretty Printing} *) 271 + 272 + (** [pp ppf t] pretty prints the contact store showing statistics. *) 273 + val pp : Format.formatter -> t -> unit 274 + 275 + (** {1 Cmdliner Integration} *) 276 + 277 + module Cmd : sig 278 + (** Cmdliner terms and commands for contact management. 279 + 280 + This module provides ready-to-use Cmdliner terms for building 281 + CLI applications that work with contact metadata. *) 282 + 283 + (** [list_cmd] is a Cmdliner command that lists all contacts. 284 + 285 + Usage: Integrate into your CLI with [Cmd.group] or use standalone. 286 + Requires eiocmd setup (env, xdg, profile parameters). *) 287 + val list_cmd : unit -> (Eio_unix.Stdenv.base -> Xdge.t -> 'a -> int) 288 + 289 + (** [show_cmd handle] creates a command to show detailed contact information. 290 + 291 + @param handle The contact handle to display *) 292 + val show_cmd : string -> (Eio_unix.Stdenv.base -> Xdge.t -> 'a -> int) 293 + 294 + (** [search_cmd query] creates a command to search contacts by name. 295 + 296 + @param query The search query string *) 297 + val search_cmd : string -> (Eio_unix.Stdenv.base -> Xdge.t -> 'a -> int) 298 + 299 + (** [stats_cmd] is a command that shows database statistics. *) 300 + val stats_cmd : unit -> (Eio_unix.Stdenv.base -> Xdge.t -> 'a -> int) 301 + 302 + (** {2 Cmdliner Info Objects} *) 303 + 304 + (** [list_info] is the command info for the list command. *) 305 + val list_info : Cmdliner.Cmd.info 306 + 307 + (** [show_info] is the command info for the show command. *) 308 + val show_info : Cmdliner.Cmd.info 309 + 310 + (** [search_info] is the command info for the search command. *) 311 + val search_info : Cmdliner.Cmd.info 312 + 313 + (** [stats_info] is the command info for the stats command. *) 314 + val stats_info : Cmdliner.Cmd.info 315 + 316 + (** {2 Cmdliner Argument Definitions} *) 317 + 318 + (** [handle_arg] is the positional argument for a contact handle. *) 319 + val handle_arg : string Cmdliner.Term.t 320 + 321 + (** [query_arg] is the positional argument for a search query. *) 322 + val query_arg : string Cmdliner.Term.t 323 + end
+119
stack/sortal/scripts/import_yaml_contacts.py
··· 1 + #!/usr/bin/env python3 2 + """ 3 + Import YAML contacts from arod-dream to Sortal JSON format. 4 + 5 + This script reads contacts from ~/src/git/avsm/arod-dream/data/contacts/ 6 + and converts them to JSON files in the XDG data directory for sortal. 7 + """ 8 + 9 + import os 10 + import json 11 + import yaml 12 + from pathlib import Path 13 + 14 + def get_xdg_data_home(): 15 + """Get the XDG data home directory.""" 16 + xdg_data_home = os.environ.get('XDG_DATA_HOME') 17 + if xdg_data_home: 18 + return Path(xdg_data_home) 19 + return Path.home() / '.local' / 'share' 20 + 21 + def parse_contact_md(file_path): 22 + """Parse a markdown file with YAML front matter.""" 23 + with open(file_path, 'r') as f: 24 + content = f.read() 25 + 26 + # Extract YAML front matter between --- 27 + if content.startswith('---\n'): 28 + parts = content.split('---\n', 2) 29 + if len(parts) >= 2: 30 + yaml_content = parts[1] 31 + return yaml.safe_load(yaml_content) 32 + return None 33 + 34 + def convert_to_sortal_format(yaml_data, handle): 35 + """Convert YAML contact data to Sortal JSON format.""" 36 + sortal_contact = { 37 + "handle": handle, 38 + "names": yaml_data.get("names", []) 39 + } 40 + 41 + # Optional fields 42 + if "email" in yaml_data: 43 + sortal_contact["email"] = yaml_data["email"] 44 + if "icon" in yaml_data: 45 + sortal_contact["icon"] = yaml_data["icon"] 46 + if "github" in yaml_data: 47 + sortal_contact["github"] = yaml_data["github"] 48 + if "twitter" in yaml_data: 49 + sortal_contact["twitter"] = yaml_data["twitter"] 50 + if "bluesky" in yaml_data: 51 + sortal_contact["bluesky"] = yaml_data["bluesky"] 52 + if "mastodon" in yaml_data: 53 + sortal_contact["mastodon"] = yaml_data["mastodon"] 54 + if "orcid" in yaml_data: 55 + sortal_contact["orcid"] = yaml_data["orcid"] 56 + if "url" in yaml_data: 57 + sortal_contact["url"] = yaml_data["url"] 58 + if "atom" in yaml_data: 59 + sortal_contact["atom_feeds"] = yaml_data["atom"] 60 + 61 + return sortal_contact 62 + 63 + def main(): 64 + # Source directory 65 + source_dir = Path.home() / 'src' / 'git' / 'avsm' / 'arod-dream' / 'data' / 'contacts' 66 + 67 + # Destination directory (XDG data home for sortal) 68 + xdg_data = get_xdg_data_home() 69 + dest_dir = xdg_data / 'sortal' 70 + dest_dir.mkdir(parents=True, exist_ok=True) 71 + 72 + print(f"Importing contacts from: {source_dir}") 73 + print(f"Output directory: {dest_dir}") 74 + print() 75 + 76 + if not source_dir.exists(): 77 + print(f"Error: Source directory does not exist: {source_dir}") 78 + return 1 79 + 80 + imported_count = 0 81 + error_count = 0 82 + 83 + # Process each .md file 84 + for md_file in sorted(source_dir.glob('*.md')): 85 + handle = md_file.stem 86 + 87 + try: 88 + yaml_data = parse_contact_md(md_file) 89 + if yaml_data is None: 90 + print(f"⚠ Skipping {handle}: No YAML front matter found") 91 + error_count += 1 92 + continue 93 + 94 + # Convert to Sortal format 95 + sortal_contact = convert_to_sortal_format(yaml_data, handle) 96 + 97 + # Write JSON file 98 + output_file = dest_dir / f"{handle}.json" 99 + with open(output_file, 'w') as f: 100 + json.dump(sortal_contact, f, indent=2, ensure_ascii=False) 101 + 102 + name = sortal_contact.get('names', [handle])[0] if sortal_contact.get('names') else handle 103 + print(f"✓ Imported: {handle} ({name})") 104 + imported_count += 1 105 + 106 + except Exception as e: 107 + print(f"✗ Error importing {handle}: {e}") 108 + error_count += 1 109 + 110 + print() 111 + print(f"Import complete!") 112 + print(f" Successfully imported: {imported_count}") 113 + print(f" Errors: {error_count}") 114 + print(f" Output directory: {dest_dir}") 115 + 116 + return 0 if error_count == 0 else 1 117 + 118 + if __name__ == '__main__': 119 + exit(main())
+3
stack/sortal/test/dune
··· 1 + (test 2 + (name test_sortal) 3 + (libraries eio eio_main sortal jsont jsont.bytesrw))
+167
stack/sortal/test/test_sortal.ml
··· 1 + (** Tests for the Sortal library *) 2 + 3 + open Eio.Std 4 + 5 + let test_contact_creation () = 6 + let c = Sortal.Contact.make 7 + ~handle:"test" 8 + ~names:["Test User"; "T. User"] 9 + ~email:"test@example.com" 10 + ~github:"testuser" 11 + () in 12 + assert (Sortal.Contact.handle c = "test"); 13 + assert (Sortal.Contact.name c = "Test User"); 14 + assert (List.length (Sortal.Contact.names c) = 2); 15 + assert (Sortal.Contact.email c = Some "test@example.com"); 16 + assert (Sortal.Contact.github c = Some "testuser"); 17 + assert (Sortal.Contact.twitter c = None); 18 + traceln "✓ Contact creation works" 19 + 20 + let test_best_url () = 21 + let c1 = Sortal.Contact.make 22 + ~handle:"test1" 23 + ~names:["Test 1"] 24 + ~url:"https://example.com" 25 + ~github:"test1" 26 + () in 27 + assert (Sortal.Contact.best_url c1 = Some "https://example.com"); 28 + 29 + let c2 = Sortal.Contact.make 30 + ~handle:"test2" 31 + ~names:["Test 2"] 32 + ~github:"test2" 33 + () in 34 + assert (Sortal.Contact.best_url c2 = Some "https://github.com/test2"); 35 + 36 + let c3 = Sortal.Contact.make 37 + ~handle:"test3" 38 + ~names:["Test 3"] 39 + ~email:"test3@example.com" 40 + () in 41 + assert (Sortal.Contact.best_url c3 = Some "mailto:test3@example.com"); 42 + 43 + let c4 = Sortal.Contact.make 44 + ~handle:"test4" 45 + ~names:["Test 4"] 46 + () in 47 + assert (Sortal.Contact.best_url c4 = None); 48 + 49 + traceln "✓ Best URL selection works" 50 + 51 + let test_json_encoding () = 52 + let c = Sortal.Contact.make 53 + ~handle:"json_test" 54 + ~names:["JSON Test"] 55 + ~email:"json@example.com" 56 + ~github:"jsontest" 57 + ~orcid:"0000-0001-2345-6789" 58 + () in 59 + 60 + match Jsont_bytesrw.encode_string Sortal.Contact.json_t c with 61 + | Ok json_str -> 62 + (match Jsont_bytesrw.decode_string Sortal.Contact.json_t json_str with 63 + | Ok decoded -> 64 + assert (Sortal.Contact.handle decoded = "json_test"); 65 + assert (Sortal.Contact.email decoded = Some "json@example.com"); 66 + assert (Sortal.Contact.github decoded = Some "jsontest"); 67 + assert (Sortal.Contact.orcid decoded = Some "0000-0001-2345-6789"); 68 + traceln "✓ JSON encoding/decoding works" 69 + | Error err -> 70 + failwith ("JSON decode failed: " ^ err)) 71 + | Error err -> 72 + failwith ("JSON encode failed: " ^ err) 73 + 74 + let test_handle_generation () = 75 + assert (Sortal.handle_of_name "John Smith" = "jssmith"); 76 + assert (Sortal.handle_of_name "Alice Barbara Cooper" = "abccooper"); 77 + assert (Sortal.handle_of_name "Bob" = "bbob"); 78 + traceln "✓ Handle generation works" 79 + 80 + let test_store_operations () = 81 + Eio_main.run @@ fun env -> 82 + 83 + (* Create a store with a test app name *) 84 + let store = Sortal.create env#fs "sortal-test" in 85 + 86 + (* Create test contacts *) 87 + let c1 = Sortal.Contact.make 88 + ~handle:"alice" 89 + ~names:["Alice Anderson"] 90 + ~email:"alice@example.com" 91 + () in 92 + 93 + let c2 = Sortal.Contact.make 94 + ~handle:"bob" 95 + ~names:["Bob Brown"; "Robert Brown"] 96 + ~github:"bobbrown" 97 + () in 98 + 99 + (* Test save *) 100 + Sortal.save store c1; 101 + Sortal.save store c2; 102 + traceln "✓ Saving contacts works"; 103 + 104 + (* Test lookup *) 105 + (match Sortal.lookup store "alice" with 106 + | Some c -> 107 + assert (Sortal.Contact.name c = "Alice Anderson"); 108 + traceln "✓ Lookup works" 109 + | None -> failwith "Lookup failed to find saved contact"); 110 + 111 + (* Test lookup of non-existent contact *) 112 + (match Sortal.lookup store "nonexistent" with 113 + | None -> traceln "✓ Lookup correctly returns None for missing contact" 114 + | Some _ -> failwith "Lookup should return None for non-existent contact"); 115 + 116 + (* Test list *) 117 + let all = Sortal.list store in 118 + assert (List.length all >= 2); 119 + traceln "✓ List returns saved contacts (%d total)" (List.length all); 120 + 121 + (* Test find_by_name *) 122 + let found = Sortal.find_by_name store "Bob Brown" in 123 + assert (Sortal.Contact.handle found = "bob"); 124 + traceln "✓ Find by name works"; 125 + 126 + (* Test find_by_name_opt *) 127 + (match Sortal.find_by_name_opt store "Alice Anderson" with 128 + | Some c -> 129 + assert (Sortal.Contact.handle c = "alice"); 130 + traceln "✓ Find by name (optional) works" 131 + | None -> failwith "find_by_name_opt failed"); 132 + 133 + (match Sortal.find_by_name_opt store "Nobody" with 134 + | None -> traceln "✓ Find by name (optional) returns None for missing" 135 + | Some _ -> failwith "find_by_name_opt should return None"); 136 + 137 + (* Test delete *) 138 + Sortal.delete store "alice"; 139 + (match Sortal.lookup store "alice" with 140 + | None -> traceln "✓ Delete works" 141 + | Some _ -> failwith "Contact should have been deleted"); 142 + 143 + (* Clean up remaining test contact *) 144 + Sortal.delete store "bob"; 145 + traceln "✓ Test cleanup complete" 146 + 147 + let test_contact_compare () = 148 + let c1 = Sortal.Contact.make ~handle:"alice" ~names:["Alice"] () in 149 + let c2 = Sortal.Contact.make ~handle:"bob" ~names:["Bob"] () in 150 + let c3 = Sortal.Contact.make ~handle:"alice" ~names:["Alice2"] () in 151 + 152 + assert (Sortal.Contact.compare c1 c2 < 0); 153 + assert (Sortal.Contact.compare c2 c1 > 0); 154 + assert (Sortal.Contact.compare c1 c3 = 0); 155 + traceln "✓ Contact comparison works" 156 + 157 + let () = 158 + traceln "\n=== Running Sortal Tests ===\n"; 159 + 160 + test_contact_creation (); 161 + test_best_url (); 162 + test_json_encoding (); 163 + test_handle_generation (); 164 + test_contact_compare (); 165 + test_store_operations (); 166 + 167 + traceln "\n=== All Tests Passed ===\n"