A CLI and OCaml library for managing contacts

switch to yamlt

+39 -139
+1 -1
bin/dune
··· 1 1 (executable 2 2 (name sortal_cli) 3 3 (public_name sortal) 4 - (libraries eio eio_main sortal xdge cmdliner logs logs.cli logs.fmt fmt fmt.tty kgp kgp.cli)) 4 + (libraries eio eio_main sortal xdge cmdliner logs logs.cli logs.fmt fmt fmt.tty))
+3 -5
bin/sortal_cli.ml
··· 27 27 ~man:[ 28 28 `S Manpage.s_description; 29 29 `P "Sortal manages contact metadata including URLs, emails, ORCID identifiers, \ 30 - and social media handles. Data is stored as JSON in XDG-compliant locations."; 30 + and social media handles. Data is stored in XDG-compliant locations."; 31 31 `S Manpage.s_commands; 32 32 `P "Use $(b,sortal COMMAND --help) for detailed help on each command."; 33 33 ] 34 34 in 35 35 36 - let graphics_term = Kgp_cli.graphics_term in 37 - 38 - let list_cmd_term = Term.(const Sortal.Cmd.list_cmd $ graphics_term) in 36 + let list_cmd_term = Term.const Sortal.Cmd.list_cmd in 39 37 let list_cmd = run ~info:Sortal.Cmd.list_info list_cmd_term in 40 38 41 - let show_cmd_term = Term.(const Sortal.Cmd.show_cmd $ graphics_term $ Sortal.Cmd.handle_arg) in 39 + let show_cmd_term = Term.(const Sortal.Cmd.show_cmd $ Sortal.Cmd.handle_arg) in 42 40 let show_cmd = run ~info:Sortal.Cmd.show_info show_cmd_term in 43 41 44 42 let search_cmd_term = Term.(const (fun query -> Sortal.Cmd.search_cmd query) $ Sortal.Cmd.query_arg) in
+1
dune-project
··· 13 13 eio_main 14 14 xdge 15 15 jsont 16 + yamlt 16 17 fmt))
+1 -1
lib/dune
··· 1 1 (library 2 2 (public_name sortal) 3 3 (name sortal) 4 - (libraries eio eio.core xdge jsont jsont.bytesrw fmt cmdliner logs kgp)) 4 + (libraries eio eio.core xdge jsont jsont.bytesrw yamlt bytesrw fmt cmdliner logs))
+19 -118
lib/sortal_cmd.ml
··· 1 1 open Cmdliner 2 2 3 - let image_columns = 11 (* columns for 4-row thumbnail (8 cols) + 3 padding *) 3 + let is_png path = 4 + let ext = String.lowercase_ascii (Filename.extension path) in 5 + ext = ".png" 4 6 5 - (* Resolve graphics mode to determine if we should use graphics output *) 6 - let use_graphics mode = 7 - Kgp.Terminal.supports_graphics mode 7 + let convert_to_png src_path = 8 + let base = Filename.remove_extension src_path in 9 + let dst_path = base ^ ".png" in 10 + let cmd = Printf.sprintf "magick %s %s" (Filename.quote src_path) (Filename.quote dst_path) in 11 + let ret = Unix.system cmd in 12 + match ret with 13 + | Unix.WEXITED 0 -> Ok dst_path 14 + | Unix.WEXITED n -> Error (Printf.sprintf "magick exited with code %d" n) 15 + | Unix.WSIGNALED n -> Error (Printf.sprintf "magick killed by signal %d" n) 16 + | Unix.WSTOPPED n -> Error (Printf.sprintf "magick stopped by signal %d" n) 8 17 9 - let display_png_thumbnail path = 10 - let image_id = Kgp.Unicode_placeholder.next_image_id () in 11 - let png_data = Eio.Path.load path in 12 - let rows = 4 in 13 - let cols = 8 in 14 - (* Unicode placeholder mode - transmit image virtually, display via placeholders *) 15 - let placement = Kgp.Placement.make ~rows ~columns:cols ~unicode_placeholder:true () in 16 - let cmd = Kgp.transmit_and_display ~image_id ~format:`Png ~placement ~quiet:`Silent () in 17 - let buf = Buffer.create 4096 in 18 - (* Transmit image with tmux passthrough if needed *) 19 - Kgp.write_tmux buf cmd ~data:png_data; 20 - (* Write unicode placeholders - these are just text characters *) 21 - Kgp.Unicode_placeholder.write buf ~image_id ~rows ~cols (); 22 - (* Move cursor back up to top of image area and to column 0 *) 23 - Printf.bprintf buf "\x1b[%dA\r" (rows - 1); 24 - Buffer.contents buf 25 - 26 - let display_block_placeholder () = 27 - (* 4 rows of patterned block characters as fallback *) 28 - let row1 = "▓▒░▒▓▒" in 29 - let row2 = "▒░▒▓▒░" in 30 - let row3 = "░▒▓▒░▒" in 31 - let row4 = "▒▓▒░▒▓" in 32 - Printf.sprintf "%s\n%s\n%s\n%s\x1b[4A\r" row1 row2 row3 row4 (* print 4 rows, move up 4, return to col 0 *) 33 - 34 - let move_right n = Printf.sprintf "\x1b[%dC" n 35 - let move_down_and_back () = Printf.sprintf "\n\x1b[%dC" image_columns 36 - 37 - let image_rows = 4 (* height of thumbnail in rows *) 38 - 39 - (* 1-row thumbnail for listings *) 40 - let display_small_thumbnail path = 41 - let image_id = Kgp.Unicode_placeholder.next_image_id () in 42 - let png_data = Eio.Path.load path in 43 - let rows = 1 in 44 - let cols = 2 in 45 - (* Transmit image with unicode placeholder virtual placement, suppress responses *) 46 - let placement = Kgp.Placement.make ~rows ~columns:cols ~unicode_placeholder:true () in 47 - let cmd = Kgp.transmit_and_display ~image_id ~format:`Png ~placement ~quiet:`Silent () in 48 - let buf = Buffer.create 4096 in 49 - (* Use tmux-aware write that wraps for passthrough if needed *) 50 - Kgp.write_tmux buf cmd ~data:png_data; 51 - (* Write unicode placeholders (these are just text, no wrapping needed) *) 52 - Kgp.Unicode_placeholder.write buf ~image_id ~rows ~cols (); 53 - Buffer.add_char buf ' '; (* spacing after thumbnail *) 54 - Buffer.contents buf 55 - 56 - let small_placeholder () = "▓░ " (* 1-row patterned placeholder *) 57 - 58 - let list_cmd mode xdg = 18 + let list_cmd xdg = 59 19 let store = Sortal_store.create_from_xdg xdg in 60 20 let contacts = Sortal_store.list store in 61 21 let sorted = List.sort Sortal_contact.compare contacts in 62 - let graphics = use_graphics mode in 63 22 Printf.printf "Total contacts: %d\n" (List.length sorted); 64 23 List.iter (fun c -> 65 - (match Sortal_store.png_thumbnail_path store c with 66 - | Some path -> 67 - if graphics then 68 - print_string (display_small_thumbnail path) 69 - else 70 - print_string (small_placeholder ()) 71 - | None -> 72 - print_string " "); (* spacing when no thumbnail *) 73 24 Printf.printf "@%s: %s\n" (Sortal_contact.handle c) (Sortal_contact.name c) 74 25 ) sorted; 75 26 0 76 27 77 - let show_cmd mode handle xdg = 28 + let show_cmd handle xdg = 78 29 let store = Sortal_store.create_from_xdg xdg in 79 30 match Sortal_store.lookup store handle with 80 31 | Some c -> 81 - let has_thumbnail = match Sortal_store.png_thumbnail_path store c with 82 - | Some path -> 83 - if use_graphics mode then 84 - print_string (display_png_thumbnail path) 85 - else 86 - print_string (display_block_placeholder ()); 87 - print_string (move_right image_columns); 88 - true 89 - | None -> false 90 - in 91 - let lines_output = ref 0 in 92 - let indent () = 93 - incr lines_output; 94 - if has_thumbnail && !lines_output < image_rows then 95 - print_string (move_down_and_back ()) 96 - else 97 - print_newline () 98 - in 99 - let field label value = 100 - match value with 101 - | Some v -> Printf.printf "%s: %s" label v; indent () 102 - | None -> () 103 - in 104 - (* Line 1: Handle and Name *) 105 - Printf.printf "@%s: %s" (Sortal_contact.handle c) (Sortal_contact.name c); 106 - indent (); 107 - (* Line 2: Email *) 108 - field "Email" (Sortal_contact.email c); 109 - (* Line 3: GitHub *) 110 - field "GitHub" (Option.map (fun g -> "https://github.com/" ^ g) (Sortal_contact.github c)); 111 - (* Line 4: URL *) 112 - field "URL" (Sortal_contact.best_url c); 113 - (* Ensure we've output enough lines to clear the image area *) 114 - if has_thumbnail then begin 115 - while !lines_output < image_rows do 116 - indent () 117 - done 118 - end; 119 - (* Additional fields below the image *) 32 + Printf.printf "@%s: %s\n" (Sortal_contact.handle c) (Sortal_contact.name c); 33 + Option.iter (fun e -> Printf.printf "Email: %s\n" e) (Sortal_contact.email c); 34 + Option.iter (fun g -> Printf.printf "GitHub: https://github.com/%s\n" g) (Sortal_contact.github c); 35 + Option.iter (fun u -> Printf.printf "URL: %s\n" u) (Sortal_contact.best_url c); 120 36 Option.iter (fun tw -> Printf.printf "Twitter: https://twitter.com/%s\n" tw) (Sortal_contact.twitter c); 121 37 Option.iter (fun b -> Printf.printf "Bluesky: %s\n" b) (Sortal_contact.bluesky c); 122 38 Option.iter (fun m -> Printf.printf "Mastodon: %s\n" m) (Sortal_contact.mastodon c); ··· 166 82 Logs.app (fun m -> m " With URL: %d (%.1f%%)" with_url (pct with_url)); 167 83 Logs.app (fun m -> m " With feeds: %d (%.1f%%), total %d feeds" with_feeds (pct with_feeds) total_feeds); 168 84 0 169 - 170 - let is_png path = 171 - let ext = String.lowercase_ascii (Filename.extension path) in 172 - ext = ".png" 173 - 174 - let convert_to_png src_path = 175 - let base = Filename.remove_extension src_path in 176 - let dst_path = base ^ ".png" in 177 - let cmd = Printf.sprintf "magick %s %s" (Filename.quote src_path) (Filename.quote dst_path) in 178 - let ret = Unix.system cmd in 179 - match ret with 180 - | Unix.WEXITED 0 -> Ok dst_path 181 - | Unix.WEXITED n -> Error (Printf.sprintf "magick exited with code %d" n) 182 - | Unix.WSIGNALED n -> Error (Printf.sprintf "magick killed by signal %d" n) 183 - | Unix.WSTOPPED n -> Error (Printf.sprintf "magick stopped by signal %d" n) 184 85 185 86 let sync_cmd () xdg = 186 87 let store = Sortal_store.create_from_xdg xdg in
+4 -7
lib/sortal_cmd.mli
··· 5 5 6 6 (** {1 Command Implementations} *) 7 7 8 - (** [list_cmd mode] is a Cmdliner command that lists all contacts. 8 + (** [list_cmd] is a Cmdliner command that lists all contacts. 9 9 10 - @param mode The graphics mode to use for thumbnails. 11 - Usage: Integrate into your CLI with [Cmd.group] or use standalone. 12 10 Returns a function that takes an XDG context and returns an exit code. *) 13 - val list_cmd : Kgp.Terminal.graphics_mode -> (Xdge.t -> int) 11 + val list_cmd : (Xdge.t -> int) 14 12 15 - (** [show_cmd mode handle] creates a command to show detailed contact information. 13 + (** [show_cmd handle] creates a command to show detailed contact information. 16 14 17 - @param mode The graphics mode to use for thumbnails. 18 15 @param handle The contact handle to display *) 19 - val show_cmd : Kgp.Terminal.graphics_mode -> string -> (Xdge.t -> int) 16 + val show_cmd : string -> (Xdge.t -> int) 20 17 21 18 (** [search_cmd query] creates a command to search contacts by name. 22 19
+10 -7
lib/sortal_store.ml
··· 13 13 { xdg; data_dir } 14 14 15 15 let contact_file t handle = 16 - Eio.Path.(t.data_dir / (handle ^ ".json")) 16 + Eio.Path.(t.data_dir / (handle ^ ".yaml")) 17 17 18 18 let save t contact = 19 19 let path = contact_file t (Sortal_contact.handle contact) in 20 - match Jsont_bytesrw.encode_string Sortal_contact.json_t contact with 21 - | Ok json_str -> Eio.Path.save ~create:(`Or_truncate 0o644) path json_str 20 + let buf = Buffer.create 4096 in 21 + let writer = Bytesrw.Bytes.Writer.of_buffer buf in 22 + match Yamlt.encode Sortal_contact.json_t contact ~eod:true writer with 23 + | Ok () -> Eio.Path.save ~create:(`Or_truncate 0o644) path (Buffer.contents buf) 22 24 | Error err -> failwith ("Failed to encode contact: " ^ err) 23 25 24 26 let lookup t handle = 25 27 let path = contact_file t handle in 26 28 try 27 - Eio.Path.load path 28 - |> Jsont_bytesrw.decode_string Sortal_contact.json_t 29 + let yaml_str = Eio.Path.load path in 30 + let reader = Bytesrw.Bytes.Reader.of_string yaml_str in 31 + Yamlt.decode Sortal_contact.json_t reader 29 32 |> Result.to_option 30 33 with _ -> None 31 34 ··· 40 43 try 41 44 let entries = Eio.Path.read_dir t.data_dir in 42 45 List.filter_map (fun entry -> 43 - if Filename.check_suffix entry ".json" then 44 - let handle = Filename.chop_suffix entry ".json" in 46 + if Filename.check_suffix entry ".yaml" then 47 + let handle = Filename.chop_suffix entry ".yaml" in 45 48 lookup t handle 46 49 else 47 50 None