My agentic slop goes here. Not intended for anyone else!
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 Feed Metadata} *)
33
34module Feed : sig
35 (** Feed subscription with type and URL.
36
37 A feed represents a subscription to a content source (Atom, RSS, or JSONFeed). *)
38 type t
39
40 (** Feed type identifier. *)
41 type feed_type =
42 | Atom (** Atom feed format *)
43 | Rss (** RSS feed format *)
44 | Json (** JSON Feed format *)
45
46 (** [make ~feed_type ~url ?name ()] creates a new feed.
47
48 @param feed_type The type of feed (Atom, RSS, or JSON)
49 @param url The feed URL
50 @param name Optional human-readable name/label for the feed
51 *)
52 val make : feed_type:feed_type -> url:string -> ?name:string -> unit -> t
53
54 (** [feed_type t] returns the feed type. *)
55 val feed_type : t -> feed_type
56
57 (** [url t] returns the feed URL. *)
58 val url : t -> string
59
60 (** [name t] returns the feed name if set. *)
61 val name : t -> string option
62
63 (** [set_name t name] returns a new feed with the name updated. *)
64 val set_name : t -> string -> t
65
66 (** [feed_type_to_string ft] converts a feed type to a string. *)
67 val feed_type_to_string : feed_type -> string
68
69 (** [feed_type_of_string s] parses a feed type from a string.
70 Returns [None] if the string is not recognized. *)
71 val feed_type_of_string : string -> feed_type option
72
73 (** [json_t] is the jsont encoder/decoder for feeds. *)
74 val json_t : t Jsont.t
75
76 (** [pp ppf t] pretty prints a feed. *)
77 val pp : Format.formatter -> t -> unit
78end
79
80(** {1 Contact Metadata} *)
81
82module Contact : sig
83 (** Individual contact metadata.
84
85 A contact represents metadata about a person, including their name(s),
86 social media handles, professional identifiers, and other contact information. *)
87 type t
88
89 (** [make ~handle ~names ?email ?icon ?thumbnail ?github ?twitter ?bluesky ?mastodon
90 ?orcid ?url ?feeds ()] creates a new contact.
91
92 @param handle A unique identifier/username for this contact (required)
93 @param names A list of names for this contact, with the first being primary (required)
94 @param email Email address
95 @param icon URL to an avatar/icon image
96 @param thumbnail Path to a local thumbnail image file
97 @param github GitHub username (without the [\@] prefix)
98 @param twitter Twitter/X username (without the [\@] prefix)
99 @param bluesky Bluesky handle
100 @param mastodon Mastodon handle (including instance)
101 @param orcid ORCID identifier
102 @param url Personal or professional website URL (primary URL)
103 @param urls Additional website URLs
104 @param feeds List of feed subscriptions (Atom/RSS/JSON) associated with this contact
105 *)
106 val make :
107 handle:string ->
108 names:string list ->
109 ?email:string ->
110 ?icon:string ->
111 ?thumbnail:string ->
112 ?github:string ->
113 ?twitter:string ->
114 ?bluesky:string ->
115 ?mastodon:string ->
116 ?orcid:string ->
117 ?url:string ->
118 ?urls:string list ->
119 ?feeds:Feed.t list ->
120 unit ->
121 t
122
123 (** {2 Accessors} *)
124
125 (** [handle t] returns the unique handle/username. *)
126 val handle : t -> string
127
128 (** [names t] returns all names associated with this contact. *)
129 val names : t -> string list
130
131 (** [name t] returns the primary (first) name. *)
132 val name : t -> string
133
134 (** [primary_name t] returns the primary (first) name.
135 This is an alias for {!name} for clarity. *)
136 val primary_name : t -> string
137
138 (** [email t] returns the email address if available. *)
139 val email : t -> string option
140
141 (** [icon t] returns the icon/avatar URL if available. *)
142 val icon : t -> string option
143
144 (** [thumbnail t] returns the path to the local thumbnail image if available.
145 This is a relative path from the Sortal data directory. *)
146 val thumbnail : t -> string option
147
148 (** [github t] returns the GitHub username if available. *)
149 val github : t -> string option
150
151 (** [twitter t] returns the Twitter/X username if available. *)
152 val twitter : t -> string option
153
154 (** [bluesky t] returns the Bluesky handle if available. *)
155 val bluesky : t -> string option
156
157 (** [mastodon t] returns the Mastodon handle if available. *)
158 val mastodon : t -> string option
159
160 (** [orcid t] returns the ORCID identifier if available. *)
161 val orcid : t -> string option
162
163 (** [url t] returns the primary URL if available.
164
165 Returns the [url] field if set, otherwise returns the first element
166 of [urls] if available, or [None] if neither is set. *)
167 val url : t -> string option
168
169 (** [urls t] returns all URLs associated with this contact.
170
171 Combines the [url] field (if set) with the [urls] list (if set).
172 The primary [url] appears first if present. Returns an empty list
173 if neither [url] nor [urls] is set. *)
174 val urls : t -> string list
175
176 (** [feeds t] returns the list of feed subscriptions if available. *)
177 val feeds : t -> Feed.t list option
178
179 (** [add_feed t feed] returns a new contact with the feed added. *)
180 val add_feed : t -> Feed.t -> t
181
182 (** [remove_feed t url] returns a new contact with the feed matching the URL removed. *)
183 val remove_feed : t -> string -> t
184
185 (** {2 Derived Information} *)
186
187 (** [best_url t] returns the best available URL for this contact.
188
189 Priority order:
190 1. Personal URL (if set)
191 2. GitHub profile URL (if GitHub username is set)
192 3. Email as mailto: link (if email is set)
193 4. None if no URL-like information is available
194 *)
195 val best_url : t -> string option
196
197 (** {2 JSON Encoding} *)
198
199 (** [json_t] is the jsont encoder/decoder for contacts.
200
201 The JSON schema includes all contact fields with optional values
202 omitted when not present:
203 {[
204 {
205 "handle": "avsm",
206 "names": ["Anil Madhavapeddy"],
207 "email": "anil@recoil.org",
208 "github": "avsm",
209 "orcid": "0000-0002-7890-1234"
210 }
211 ]}
212 *)
213 val json_t : t Jsont.t
214
215 (** {2 Utilities} *)
216
217 (** [compare a b] compares two contacts by their handles. *)
218 val compare : t -> t -> int
219
220 (** [pp ppf t] pretty prints a contact with formatting. *)
221 val pp : Format.formatter -> t -> unit
222end
223
224(** {1 Contact Store} *)
225
226(** The contact store manages reading and writing contact metadata
227 using XDG-compliant storage locations. *)
228type t
229
230(** [create fs app_name] creates a new contact store.
231
232 The store will use XDG data directories for persistent storage
233 of contact metadata. Each contact is stored as a separate JSON
234 file named after its handle.
235
236 @param fs Eio filesystem for file operations
237 @param app_name Application name for XDG directory structure
238 *)
239val create : Eio.Fs.dir_ty Eio.Path.t -> string -> t
240
241(** {2 Storage Operations} *)
242
243(** [save t contact] saves a contact to the store.
244
245 The contact is serialized to JSON and written to a file
246 named "handle.json" in the XDG data directory.
247
248 If a contact with the same handle already exists, it is overwritten.
249 *)
250val save : t -> Contact.t -> unit
251
252(** [lookup t handle] retrieves a contact by handle.
253
254 Searches for a file named "handle.json" in the XDG data directory
255 and deserializes it if found.
256
257 @return [Some contact] if found, [None] if not found or deserialization fails
258 *)
259val lookup : t -> string -> Contact.t option
260
261(** [delete t handle] removes a contact from the store.
262
263 Deletes the file "handle.json" from the XDG data directory.
264 Does nothing if the contact does not exist.
265 *)
266val delete : t -> string -> unit
267
268(** [list t] returns all contacts in the store.
269
270 Scans the XDG data directory for all .json files and attempts
271 to deserialize them as contacts. Files that fail to parse are
272 silently skipped.
273
274 @return A list of all successfully loaded contacts
275 *)
276val list : t -> Contact.t list
277
278(** [thumbnail_path t contact] returns the absolute filesystem path to the contact's thumbnail.
279
280 Returns [None] if the contact has no thumbnail set, or [Some path] with
281 the full path to the thumbnail file in Sortal's data directory.
282
283 @param t The Sortal store
284 @param contact The contact whose thumbnail path to retrieve *)
285val thumbnail_path : t -> Contact.t -> Eio.Fs.dir_ty Eio.Path.t option
286
287(** {2 Searching} *)
288
289(** [find_by_name t name] searches for contacts by name.
290
291 Performs a case-insensitive search through all contacts,
292 checking if any of their names match the provided name.
293
294 @param name The name to search for (case-insensitive)
295 @return The matching contact if exactly one match is found
296 @raise Not_found if no contacts match the name
297 @raise Invalid_argument if multiple contacts match the name
298 *)
299val find_by_name : t -> string -> Contact.t
300
301(** [find_by_name_opt t name] searches for contacts by name, returning an option.
302
303 Like {!find_by_name} but returns [None] instead of raising exceptions
304 when no match or multiple matches are found.
305
306 @param name The name to search for (case-insensitive)
307 @return [Some contact] if exactly one match is found, [None] otherwise
308 *)
309val find_by_name_opt : t -> string -> Contact.t option
310
311(** {2 Utilities} *)
312
313(** [handle_of_name name] generates a handle from a full name.
314
315 Creates a handle by concatenating the initials of all words
316 in the name with the full last name, all in lowercase.
317
318 Examples:
319 - "Anil Madhavapeddy" -> "ammadhavapeddy"
320 - "John Smith" -> "jssmith"
321
322 @param name The full name to convert
323 @return A suggested handle
324 *)
325val handle_of_name : string -> string
326
327(** {2 Convenience Functions} *)
328
329(** [create_from_xdg xdg] creates a contact store from an XDG context.
330
331 This is a convenience function for creating a store when you already
332 have an XDG context (e.g., from eiocmd or your own XDG initialization).
333 The store will use the XDG data directory for the application.
334
335 @param xdg An existing XDG context
336 @return A contact store using the XDG data directory
337 *)
338val create_from_xdg : Xdge.t -> t
339
340(** [search_all t query] searches for contacts matching a query string.
341
342 Performs a flexible search through all contact names, looking for:
343 - Exact matches (case-insensitive)
344 - Names that start with the query
345 - Multi-word names where any word starts with the query
346
347 This is useful for autocomplete or fuzzy search functionality.
348
349 @param t The contact store
350 @param query The search query (case-insensitive)
351 @return A list of matching contacts, sorted by handle
352 *)
353val search_all : t -> string -> Contact.t list
354
355(** {2 Pretty Printing} *)
356
357(** [pp ppf t] pretty prints the contact store showing statistics. *)
358val pp : Format.formatter -> t -> unit
359
360(** {1 Cmdliner Integration} *)
361
362module Cmd : sig
363 (** Cmdliner terms and commands for contact management.
364
365 This module provides ready-to-use Cmdliner terms for building
366 CLI applications that work with contact metadata. *)
367
368 (** [list_cmd] is a Cmdliner command that lists all contacts.
369
370 Usage: Integrate into your CLI with [Cmd.group] or use standalone.
371 Requires eiocmd setup (env, xdg, profile parameters). *)
372 val list_cmd : unit -> (Eio_unix.Stdenv.base -> Xdge.t -> 'a -> int)
373
374 (** [show_cmd handle] creates a command to show detailed contact information.
375
376 @param handle The contact handle to display *)
377 val show_cmd : string -> (Eio_unix.Stdenv.base -> Xdge.t -> 'a -> int)
378
379 (** [search_cmd query] creates a command to search contacts by name.
380
381 @param query The search query string *)
382 val search_cmd : string -> (Eio_unix.Stdenv.base -> Xdge.t -> 'a -> int)
383
384 (** [stats_cmd] is a command that shows database statistics. *)
385 val stats_cmd : unit -> (Eio_unix.Stdenv.base -> Xdge.t -> 'a -> int)
386
387 (** {2 Cmdliner Info Objects} *)
388
389 (** [list_info] is the command info for the list command. *)
390 val list_info : Cmdliner.Cmd.info
391
392 (** [show_info] is the command info for the show command. *)
393 val show_info : Cmdliner.Cmd.info
394
395 (** [search_info] is the command info for the search command. *)
396 val search_info : Cmdliner.Cmd.info
397
398 (** [stats_info] is the command info for the stats command. *)
399 val stats_info : Cmdliner.Cmd.info
400
401 (** {2 Cmdliner Argument Definitions} *)
402
403 (** [handle_arg] is the positional argument for a contact handle. *)
404 val handle_arg : string Cmdliner.Term.t
405
406 (** [query_arg] is the positional argument for a search query. *)
407 val query_arg : string Cmdliner.Term.t
408end