My agentic slop goes here. Not intended for anyone else!
1module Feed = struct 2 type feed_type = 3 | Atom 4 | Rss 5 | Json 6 7 type t = { 8 feed_type : feed_type; 9 url : string; 10 name : string option; 11 } 12 13 let make ~feed_type ~url ?name () = 14 { feed_type; url; name } 15 16 let feed_type t = t.feed_type 17 let url t = t.url 18 let name t = t.name 19 20 let set_name t name = { t with name = Some name } 21 22 let feed_type_to_string = function 23 | Atom -> "atom" 24 | Rss -> "rss" 25 | Json -> "json" 26 27 let feed_type_of_string = function 28 | "atom" -> Some Atom 29 | "rss" -> Some Rss 30 | "json" -> Some Json 31 | _ -> None 32 33 let json_t = 34 let open Jsont in 35 let open Jsont.Object in 36 let make feed_type url name = 37 match feed_type_of_string feed_type with 38 | Some ft -> { feed_type = ft; url; name } 39 | None -> failwith ("Invalid feed type: " ^ feed_type) 40 in 41 map ~kind:"Feed" make 42 |> mem "type" string ~enc:(fun f -> feed_type_to_string f.feed_type) 43 |> mem "url" string ~enc:(fun f -> f.url) 44 |> opt_mem "name" string ~enc:(fun f -> f.name) 45 |> finish 46 47 let pp ppf t = 48 let open Fmt in 49 pf ppf "%s: %s%a" 50 (feed_type_to_string t.feed_type) 51 t.url 52 (option (fun ppf name -> pf ppf " (%s)" name)) t.name 53end 54 55module Contact = struct 56 type t = { 57 handle : string; 58 names : string list; 59 email : string option; 60 icon : string option; 61 thumbnail : string option; 62 github : string option; 63 twitter : string option; 64 bluesky : string option; 65 mastodon : string option; 66 orcid : string option; 67 url : string option; 68 feeds : Feed.t list option; 69 } 70 71 let make ~handle ~names ?email ?icon ?thumbnail ?github ?twitter ?bluesky ?mastodon 72 ?orcid ?url ?feeds () = 73 { handle; names; email; icon; thumbnail; github; twitter; bluesky; mastodon; 74 orcid; url; feeds } 75 76 let handle t = t.handle 77 let names t = t.names 78 let name t = List.hd t.names 79 let primary_name = name 80 let email t = t.email 81 let icon t = t.icon 82 let thumbnail t = t.thumbnail 83 let github t = t.github 84 let twitter t = t.twitter 85 let bluesky t = t.bluesky 86 let mastodon t = t.mastodon 87 let orcid t = t.orcid 88 let url t = t.url 89 let feeds t = t.feeds 90 91 let add_feed t feed = 92 let feeds = match t.feeds with 93 | Some fs -> Some (feed :: fs) 94 | None -> Some [feed] 95 in 96 { t with feeds } 97 98 let remove_feed t url = 99 let feeds = match t.feeds with 100 | Some fs -> Some (List.filter (fun f -> Feed.url f <> url) fs) 101 | None -> None 102 in 103 { t with feeds } 104 105 let best_url t = 106 match t.url with 107 | Some v -> Some v 108 | None -> 109 (match t.github with 110 | Some v -> Some ("https://github.com/" ^ v) 111 | None -> 112 (match t.email with 113 | Some v -> Some ("mailto:" ^ v) 114 | None -> None)) 115 116 let json_t = 117 let open Jsont in 118 let open Jsont.Object in 119 let mem_opt f v ~enc = mem f v ~dec_absent:None ~enc_omit:Option.is_none ~enc in 120 let make handle names email icon thumbnail github twitter bluesky mastodon orcid url feeds = 121 { handle; names; email; icon; thumbnail; github; twitter; bluesky; mastodon; 122 orcid; url; feeds } 123 in 124 map ~kind:"Contact" make 125 |> mem "handle" string ~enc:handle 126 |> mem "names" (list string) ~dec_absent:[] ~enc:names 127 |> mem_opt "email" (some string) ~enc:email 128 |> mem_opt "icon" (some string) ~enc:icon 129 |> mem_opt "thumbnail" (some string) ~enc:thumbnail 130 |> mem_opt "github" (some string) ~enc:github 131 |> mem_opt "twitter" (some string) ~enc:twitter 132 |> mem_opt "bluesky" (some string) ~enc:bluesky 133 |> mem_opt "mastodon" (some string) ~enc:mastodon 134 |> mem_opt "orcid" (some string) ~enc:orcid 135 |> mem_opt "url" (some string) ~enc:url 136 |> mem_opt "feeds" (some (list Feed.json_t)) ~enc:feeds 137 |> finish 138 139 let compare a b = String.compare a.handle b.handle 140 141 let pp ppf t = 142 let open Fmt in 143 pf ppf "@[<v>"; 144 pf ppf "%a: @%a@," (styled `Bold string) "Handle" string t.handle; 145 pf ppf "%a: %a@," (styled `Bold string) "Name" string (name t); 146 let ns = names t in 147 if List.length ns > 1 then 148 pf ppf "%a: @[<h>%a@]@," (styled `Bold string) "Aliases" 149 (list ~sep:comma string) (List.tl ns); 150 (match t.email with 151 | Some e -> pf ppf "%a: %a@," (styled `Bold string) "Email" string e 152 | None -> ()); 153 (match t.github with 154 | Some g -> pf ppf "%a: https://github.com/%a@," 155 (styled `Bold string) "GitHub" string g 156 | None -> ()); 157 (match t.twitter with 158 | Some tw -> pf ppf "%a: https://twitter.com/%a@," 159 (styled `Bold string) "Twitter" string tw 160 | None -> ()); 161 (match t.bluesky with 162 | Some b -> pf ppf "%a: %a@," (styled `Bold string) "Bluesky" string b 163 | None -> ()); 164 (match t.mastodon with 165 | Some m -> pf ppf "%a: %a@," (styled `Bold string) "Mastodon" string m 166 | None -> ()); 167 (match t.orcid with 168 | Some o -> pf ppf "%a: https://orcid.org/%a@," 169 (styled `Bold string) "ORCID" string o 170 | None -> ()); 171 (match t.url with 172 | Some u -> pf ppf "%a: %a@," (styled `Bold string) "URL" string u 173 | None -> ()); 174 (match t.icon with 175 | Some i -> pf ppf "%a: %a@," (styled `Bold string) "Icon" string i 176 | None -> ()); 177 (match t.thumbnail with 178 | Some th -> pf ppf "%a: %a@," (styled `Bold string) "Thumbnail" string th 179 | None -> ()); 180 (match t.feeds with 181 | Some feeds when feeds <> [] -> 182 pf ppf "%a:@," (styled `Bold string) "Feeds"; 183 List.iter (fun feed -> 184 pf ppf " - %a@," Feed.pp feed 185 ) feeds 186 | _ -> ()); 187 pf ppf "@]" 188end 189 190type t = { 191 xdg : Xdge.t; [@warning "-69"] 192 data_dir : Eio.Fs.dir_ty Eio.Path.t; 193} 194 195let create fs app_name = 196 let xdg = Xdge.create fs app_name in 197 let data_dir = Xdge.data_dir xdg in 198 { xdg; data_dir } 199 200let contact_file t handle = 201 Eio.Path.(t.data_dir / (handle ^ ".json")) 202 203let save t contact = 204 let path = contact_file t (Contact.handle contact) in 205 match Jsont_bytesrw.encode_string Contact.json_t contact with 206 | Ok json_str -> Eio.Path.save ~create:(`Or_truncate 0o644) path json_str 207 | Error err -> failwith ("Failed to encode contact: " ^ err) 208 209let lookup t handle = 210 let path = contact_file t handle in 211 try 212 let content = Eio.Path.load path in 213 match Jsont_bytesrw.decode_string Contact.json_t content with 214 | Ok contact -> Some contact 215 | Error _ -> None 216 with 217 | _ -> None 218 219let delete t handle = 220 let path = contact_file t handle in 221 try 222 Eio.Path.unlink path 223 with 224 | _ -> () 225 226let list t = 227 try 228 let entries = Eio.Path.read_dir t.data_dir in 229 List.filter_map (fun entry -> 230 if Filename.check_suffix entry ".json" then 231 let handle = Filename.chop_suffix entry ".json" in 232 lookup t handle 233 else 234 None 235 ) entries 236 with 237 | _ -> [] 238 239let thumbnail_path t contact = 240 match Contact.thumbnail contact with 241 | None -> None 242 | Some relative_path -> 243 Some Eio.Path.(t.data_dir / relative_path) 244 245let handle_of_name name = 246 let name = String.lowercase_ascii name in 247 let words = String.split_on_char ' ' name in 248 let initials = String.concat "" (List.map (fun w -> String.sub w 0 1) words) in 249 initials ^ List.hd (List.rev words) 250 251let find_by_name t name = 252 let name_lower = String.lowercase_ascii name in 253 let all_contacts = list t in 254 let matches = List.filter (fun c -> 255 List.exists (fun n -> String.lowercase_ascii n = name_lower) 256 (Contact.names c) 257 ) all_contacts in 258 match matches with 259 | [contact] -> contact 260 | [] -> raise Not_found 261 | _ -> raise (Invalid_argument ("Multiple contacts match: " ^ name)) 262 263let find_by_name_opt t name = 264 try 265 Some (find_by_name t name) 266 with 267 | Not_found | Invalid_argument _ -> None 268 269(* Convenience functions *) 270let create_from_xdg xdg = 271 let data_dir = Xdge.data_dir xdg in 272 { xdg; data_dir } 273 274let search_all t query = 275 let query_lower = String.lowercase_ascii query in 276 let all = list t in 277 let matches = List.filter (fun c -> 278 List.exists (fun name -> 279 let name_lower = String.lowercase_ascii name in 280 (* Check for exact match *) 281 String.equal name_lower query_lower || 282 (* Check if name starts with query *) 283 String.starts_with ~prefix:query_lower name_lower || 284 (* For multi-word names, check if any word starts with query *) 285 (String.contains name_lower ' ' && 286 String.split_on_char ' ' name_lower |> List.exists (fun word -> 287 String.starts_with ~prefix:query_lower word 288 )) 289 ) (Contact.names c) 290 ) all in 291 List.sort Contact.compare matches 292 293let pp ppf t = 294 let all = list t in 295 Fmt.pf ppf "@[<v>%a: %d contacts stored in XDG data directory@]" 296 (Fmt.styled `Bold Fmt.string) "Sortal Store" 297 (List.length all) 298 299(* Cmdliner integration *) 300module Cmd = struct 301 open Cmdliner 302 303 (* Command implementations *) 304 let list_cmd () _env xdg _profile = 305 let store = create_from_xdg xdg in 306 let contacts = list store in 307 let sorted = List.sort Contact.compare contacts in 308 309 Logs.app (fun m -> m "Total contacts: %d" (List.length sorted)); 310 List.iter (fun c -> 311 Logs.app (fun m -> m "@%s: %s" 312 (Contact.handle c) 313 (Contact.name c)) 314 ) sorted; 315 0 316 317 let show_cmd handle _env xdg _profile = 318 let store = create_from_xdg xdg in 319 match lookup store handle with 320 | Some c -> 321 Format.printf "%a@." Contact.pp c; 322 0 323 | None -> 324 Logs.err (fun m -> m "Contact not found: %s" handle); 325 1 326 327 let search_cmd query _env xdg _profile = 328 let store = create_from_xdg xdg in 329 let matches = search_all store query in 330 331 if matches = [] then ( 332 Logs.warn (fun m -> m "No contacts found matching: %s" query); 333 1 334 ) else ( 335 Logs.app (fun m -> m "Found %d match%s:" 336 (List.length matches) 337 (if List.length matches = 1 then "" else "es")); 338 List.iter (fun c -> 339 Logs.app (fun m -> m "@%s: %s" 340 (Contact.handle c) 341 (Contact.name c)); 342 343 (* Show additional details *) 344 (match Contact.email c with 345 | Some e -> Logs.app (fun m -> m " Email: %s" e) 346 | None -> ()); 347 (match Contact.github c with 348 | Some g -> Logs.app (fun m -> m " GitHub: @%s" g) 349 | None -> ()); 350 (match Contact.best_url c with 351 | Some u -> Logs.app (fun m -> m " URL: %s" u) 352 | None -> ()) 353 ) matches; 354 0 355 ) 356 357 let stats_cmd () _env xdg _profile = 358 let store = create_from_xdg xdg in 359 let contacts = list store in 360 361 let total = List.length contacts in 362 let with_email = List.filter (fun c -> Contact.email c <> None) contacts |> List.length in 363 let with_github = List.filter (fun c -> Contact.github c <> None) contacts |> List.length in 364 let with_orcid = List.filter (fun c -> Contact.orcid c <> None) contacts |> List.length in 365 let with_url = List.filter (fun c -> Contact.url c <> None) contacts |> List.length in 366 let with_feeds = List.filter (fun c -> Contact.feeds c <> None) contacts |> List.length in 367 let total_feeds = List.fold_left (fun acc c -> 368 match Contact.feeds c with 369 | Some feeds -> acc + List.length feeds 370 | None -> acc 371 ) 0 contacts in 372 373 Logs.app (fun m -> m "Contact Database Statistics:"); 374 Logs.app (fun m -> m " Total contacts: %d" total); 375 Logs.app (fun m -> m " With email: %d (%.1f%%)" with_email (float_of_int with_email /. float_of_int total *. 100.)); 376 Logs.app (fun m -> m " With GitHub: %d (%.1f%%)" with_github (float_of_int with_github /. float_of_int total *. 100.)); 377 Logs.app (fun m -> m " With ORCID: %d (%.1f%%)" with_orcid (float_of_int with_orcid /. float_of_int total *. 100.)); 378 Logs.app (fun m -> m " With URL: %d (%.1f%%)" with_url (float_of_int with_url /. float_of_int total *. 100.)); 379 Logs.app (fun m -> m " With feeds: %d (%.1f%%), total %d feeds" with_feeds (float_of_int with_feeds /. float_of_int total *. 100.) total_feeds); 380 0 381 382 (* Command info objects *) 383 let list_info = Cmd.info "list" ~doc:"List all contacts" 384 385 let show_info = Cmd.info "show" ~doc:"Show detailed information about a contact" 386 387 let search_info = Cmd.info "search" ~doc:"Search contacts by name" 388 389 let stats_info = Cmd.info "stats" ~doc:"Show statistics about the contact database" 390 391 (* Argument definitions *) 392 let handle_arg = 393 Arg.(required & pos 0 (some string) None & info [] ~docv:"HANDLE" 394 ~doc:"Contact handle to display") 395 396 let query_arg = 397 Arg.(required & pos 0 (some string) None & info [] ~docv:"QUERY" 398 ~doc:"Name or partial name to search for") 399end