···2727 ~man:[
2828 `S Manpage.s_description;
2929 `P "Sortal manages contact metadata including URLs, emails, ORCID identifiers, \
3030- and social media handles. Data is stored as JSON in XDG-compliant locations.";
3030+ and social media handles. Data is stored in XDG-compliant locations.";
3131 `S Manpage.s_commands;
3232 `P "Use $(b,sortal COMMAND --help) for detailed help on each command.";
3333 ]
3434 in
35353636- let graphics_term = Kgp_cli.graphics_term in
3737-3838- let list_cmd_term = Term.(const Sortal.Cmd.list_cmd $ graphics_term) in
3636+ let list_cmd_term = Term.const Sortal.Cmd.list_cmd in
3937 let list_cmd = run ~info:Sortal.Cmd.list_info list_cmd_term in
40384141- let show_cmd_term = Term.(const Sortal.Cmd.show_cmd $ graphics_term $ Sortal.Cmd.handle_arg) in
3939+ let show_cmd_term = Term.(const Sortal.Cmd.show_cmd $ Sortal.Cmd.handle_arg) in
4240 let show_cmd = run ~info:Sortal.Cmd.show_info show_cmd_term in
43414442 let search_cmd_term = Term.(const (fun query -> Sortal.Cmd.search_cmd query) $ Sortal.Cmd.query_arg) in
···11open Cmdliner
2233-let image_columns = 11 (* columns for 4-row thumbnail (8 cols) + 3 padding *)
33+let is_png path =
44+ let ext = String.lowercase_ascii (Filename.extension path) in
55+ ext = ".png"
4655-(* Resolve graphics mode to determine if we should use graphics output *)
66-let use_graphics mode =
77- Kgp.Terminal.supports_graphics mode
77+let convert_to_png src_path =
88+ let base = Filename.remove_extension src_path in
99+ let dst_path = base ^ ".png" in
1010+ let cmd = Printf.sprintf "magick %s %s" (Filename.quote src_path) (Filename.quote dst_path) in
1111+ let ret = Unix.system cmd in
1212+ match ret with
1313+ | Unix.WEXITED 0 -> Ok dst_path
1414+ | Unix.WEXITED n -> Error (Printf.sprintf "magick exited with code %d" n)
1515+ | Unix.WSIGNALED n -> Error (Printf.sprintf "magick killed by signal %d" n)
1616+ | Unix.WSTOPPED n -> Error (Printf.sprintf "magick stopped by signal %d" n)
81799-let display_png_thumbnail path =
1010- let image_id = Kgp.Unicode_placeholder.next_image_id () in
1111- let png_data = Eio.Path.load path in
1212- let rows = 4 in
1313- let cols = 8 in
1414- (* Unicode placeholder mode - transmit image virtually, display via placeholders *)
1515- let placement = Kgp.Placement.make ~rows ~columns:cols ~unicode_placeholder:true () in
1616- let cmd = Kgp.transmit_and_display ~image_id ~format:`Png ~placement ~quiet:`Silent () in
1717- let buf = Buffer.create 4096 in
1818- (* Transmit image with tmux passthrough if needed *)
1919- Kgp.write_tmux buf cmd ~data:png_data;
2020- (* Write unicode placeholders - these are just text characters *)
2121- Kgp.Unicode_placeholder.write buf ~image_id ~rows ~cols ();
2222- (* Move cursor back up to top of image area and to column 0 *)
2323- Printf.bprintf buf "\x1b[%dA\r" (rows - 1);
2424- Buffer.contents buf
2525-2626-let display_block_placeholder () =
2727- (* 4 rows of patterned block characters as fallback *)
2828- let row1 = "▓▒░▒▓▒" in
2929- let row2 = "▒░▒▓▒░" in
3030- let row3 = "░▒▓▒░▒" in
3131- let row4 = "▒▓▒░▒▓" in
3232- 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 *)
3333-3434-let move_right n = Printf.sprintf "\x1b[%dC" n
3535-let move_down_and_back () = Printf.sprintf "\n\x1b[%dC" image_columns
3636-3737-let image_rows = 4 (* height of thumbnail in rows *)
3838-3939-(* 1-row thumbnail for listings *)
4040-let display_small_thumbnail path =
4141- let image_id = Kgp.Unicode_placeholder.next_image_id () in
4242- let png_data = Eio.Path.load path in
4343- let rows = 1 in
4444- let cols = 2 in
4545- (* Transmit image with unicode placeholder virtual placement, suppress responses *)
4646- let placement = Kgp.Placement.make ~rows ~columns:cols ~unicode_placeholder:true () in
4747- let cmd = Kgp.transmit_and_display ~image_id ~format:`Png ~placement ~quiet:`Silent () in
4848- let buf = Buffer.create 4096 in
4949- (* Use tmux-aware write that wraps for passthrough if needed *)
5050- Kgp.write_tmux buf cmd ~data:png_data;
5151- (* Write unicode placeholders (these are just text, no wrapping needed) *)
5252- Kgp.Unicode_placeholder.write buf ~image_id ~rows ~cols ();
5353- Buffer.add_char buf ' '; (* spacing after thumbnail *)
5454- Buffer.contents buf
5555-5656-let small_placeholder () = "▓░ " (* 1-row patterned placeholder *)
5757-5858-let list_cmd mode xdg =
1818+let list_cmd xdg =
5919 let store = Sortal_store.create_from_xdg xdg in
6020 let contacts = Sortal_store.list store in
6121 let sorted = List.sort Sortal_contact.compare contacts in
6262- let graphics = use_graphics mode in
6322 Printf.printf "Total contacts: %d\n" (List.length sorted);
6423 List.iter (fun c ->
6565- (match Sortal_store.png_thumbnail_path store c with
6666- | Some path ->
6767- if graphics then
6868- print_string (display_small_thumbnail path)
6969- else
7070- print_string (small_placeholder ())
7171- | None ->
7272- print_string " "); (* spacing when no thumbnail *)
7324 Printf.printf "@%s: %s\n" (Sortal_contact.handle c) (Sortal_contact.name c)
7425 ) sorted;
7526 0
76277777-let show_cmd mode handle xdg =
2828+let show_cmd handle xdg =
7829 let store = Sortal_store.create_from_xdg xdg in
7930 match Sortal_store.lookup store handle with
8031 | Some c ->
8181- let has_thumbnail = match Sortal_store.png_thumbnail_path store c with
8282- | Some path ->
8383- if use_graphics mode then
8484- print_string (display_png_thumbnail path)
8585- else
8686- print_string (display_block_placeholder ());
8787- print_string (move_right image_columns);
8888- true
8989- | None -> false
9090- in
9191- let lines_output = ref 0 in
9292- let indent () =
9393- incr lines_output;
9494- if has_thumbnail && !lines_output < image_rows then
9595- print_string (move_down_and_back ())
9696- else
9797- print_newline ()
9898- in
9999- let field label value =
100100- match value with
101101- | Some v -> Printf.printf "%s: %s" label v; indent ()
102102- | None -> ()
103103- in
104104- (* Line 1: Handle and Name *)
105105- Printf.printf "@%s: %s" (Sortal_contact.handle c) (Sortal_contact.name c);
106106- indent ();
107107- (* Line 2: Email *)
108108- field "Email" (Sortal_contact.email c);
109109- (* Line 3: GitHub *)
110110- field "GitHub" (Option.map (fun g -> "https://github.com/" ^ g) (Sortal_contact.github c));
111111- (* Line 4: URL *)
112112- field "URL" (Sortal_contact.best_url c);
113113- (* Ensure we've output enough lines to clear the image area *)
114114- if has_thumbnail then begin
115115- while !lines_output < image_rows do
116116- indent ()
117117- done
118118- end;
119119- (* Additional fields below the image *)
3232+ Printf.printf "@%s: %s\n" (Sortal_contact.handle c) (Sortal_contact.name c);
3333+ Option.iter (fun e -> Printf.printf "Email: %s\n" e) (Sortal_contact.email c);
3434+ Option.iter (fun g -> Printf.printf "GitHub: https://github.com/%s\n" g) (Sortal_contact.github c);
3535+ Option.iter (fun u -> Printf.printf "URL: %s\n" u) (Sortal_contact.best_url c);
12036 Option.iter (fun tw -> Printf.printf "Twitter: https://twitter.com/%s\n" tw) (Sortal_contact.twitter c);
12137 Option.iter (fun b -> Printf.printf "Bluesky: %s\n" b) (Sortal_contact.bluesky c);
12238 Option.iter (fun m -> Printf.printf "Mastodon: %s\n" m) (Sortal_contact.mastodon c);
···16682 Logs.app (fun m -> m " With URL: %d (%.1f%%)" with_url (pct with_url));
16783 Logs.app (fun m -> m " With feeds: %d (%.1f%%), total %d feeds" with_feeds (pct with_feeds) total_feeds);
16884 0
169169-170170-let is_png path =
171171- let ext = String.lowercase_ascii (Filename.extension path) in
172172- ext = ".png"
173173-174174-let convert_to_png src_path =
175175- let base = Filename.remove_extension src_path in
176176- let dst_path = base ^ ".png" in
177177- let cmd = Printf.sprintf "magick %s %s" (Filename.quote src_path) (Filename.quote dst_path) in
178178- let ret = Unix.system cmd in
179179- match ret with
180180- | Unix.WEXITED 0 -> Ok dst_path
181181- | Unix.WEXITED n -> Error (Printf.sprintf "magick exited with code %d" n)
182182- | Unix.WSIGNALED n -> Error (Printf.sprintf "magick killed by signal %d" n)
183183- | Unix.WSTOPPED n -> Error (Printf.sprintf "magick stopped by signal %d" n)
1848518586let sync_cmd () xdg =
18687 let store = Sortal_store.create_from_xdg xdg in
+4-7
lib/sortal_cmd.mli
···5566(** {1 Command Implementations} *)
7788-(** [list_cmd mode] is a Cmdliner command that lists all contacts.
88+(** [list_cmd] is a Cmdliner command that lists all contacts.
991010- @param mode The graphics mode to use for thumbnails.
1111- Usage: Integrate into your CLI with [Cmd.group] or use standalone.
1210 Returns a function that takes an XDG context and returns an exit code. *)
1313-val list_cmd : Kgp.Terminal.graphics_mode -> (Xdge.t -> int)
1111+val list_cmd : (Xdge.t -> int)
14121515-(** [show_cmd mode handle] creates a command to show detailed contact information.
1313+(** [show_cmd handle] creates a command to show detailed contact information.
16141717- @param mode The graphics mode to use for thumbnails.
1815 @param handle The contact handle to display *)
1919-val show_cmd : Kgp.Terminal.graphics_mode -> string -> (Xdge.t -> int)
1616+val show_cmd : string -> (Xdge.t -> int)
20172118(** [search_cmd query] creates a command to search contacts by name.
2219
+10-7
lib/sortal_store.ml
···1313 { xdg; data_dir }
14141515let contact_file t handle =
1616- Eio.Path.(t.data_dir / (handle ^ ".json"))
1616+ Eio.Path.(t.data_dir / (handle ^ ".yaml"))
17171818let save t contact =
1919 let path = contact_file t (Sortal_contact.handle contact) in
2020- match Jsont_bytesrw.encode_string Sortal_contact.json_t contact with
2121- | Ok json_str -> Eio.Path.save ~create:(`Or_truncate 0o644) path json_str
2020+ let buf = Buffer.create 4096 in
2121+ let writer = Bytesrw.Bytes.Writer.of_buffer buf in
2222+ match Yamlt.encode Sortal_contact.json_t contact ~eod:true writer with
2323+ | Ok () -> Eio.Path.save ~create:(`Or_truncate 0o644) path (Buffer.contents buf)
2224 | Error err -> failwith ("Failed to encode contact: " ^ err)
23252426let lookup t handle =
2527 let path = contact_file t handle in
2628 try
2727- Eio.Path.load path
2828- |> Jsont_bytesrw.decode_string Sortal_contact.json_t
2929+ let yaml_str = Eio.Path.load path in
3030+ let reader = Bytesrw.Bytes.Reader.of_string yaml_str in
3131+ Yamlt.decode Sortal_contact.json_t reader
2932 |> Result.to_option
3033 with _ -> None
3134···4043 try
4144 let entries = Eio.Path.read_dir t.data_dir in
4245 List.filter_map (fun entry ->
4343- if Filename.check_suffix entry ".json" then
4444- let handle = Filename.chop_suffix entry ".json" in
4646+ if Filename.check_suffix entry ".yaml" then
4747+ let handle = Filename.chop_suffix entry ".yaml" in
4548 lookup t handle
4649 else
4750 None