···184184 | Error e -> Printf.eprintf "Config error: %s\n" e; 1
185185 | Ok config ->
186186 let data_dir = get_data_dir config data_dir in
187187- with_entries data_dir @@ fun _env entries ->
187187+ Eio_main.run @@ fun env ->
188188+ let fs = Eio.Stdenv.fs env in
189189+ let entries = Bushel_eio.Bushel_loader.load fs data_dir in
188190 let papers = List.length (Bushel.Entry.papers entries) in
189191 let notes = List.length (Bushel.Entry.notes entries) in
190192 let projects = List.length (Bushel.Entry.projects entries) in
191193 let ideas = List.length (Bushel.Entry.ideas entries) in
192194 let videos = List.length (Bushel.Entry.videos entries) in
193195 let contacts = List.length (Bushel.Entry.contacts entries) in
196196+ let images = List.length (Bushel_eio.Bushel_loader.load_images fs
197197+ ~output_dir:config.Bushel_config.local_output_dir) in
194198 Printf.printf "Bushel Statistics\n";
195199 Printf.printf "=================\n";
196200 Printf.printf "Papers: %4d\n" papers;
···199203 Printf.printf "Ideas: %4d\n" ideas;
200204 Printf.printf "Videos: %4d\n" videos;
201205 Printf.printf "Contacts: %4d\n" contacts;
206206+ Printf.printf "Images: %4d\n" images;
202207 Printf.printf "-----------------\n";
203208 Printf.printf "Total: %4d\n" (papers + notes + projects + ideas + videos);
204209 0
···247252 let doc = "Also upload to Typesense (remote sync)." in
248253 Arg.(value & flag & info ["remote"] ~doc)
249254 in
255255+ let dry_run =
256256+ let doc = "Show what commands would be run without executing them." in
257257+ Arg.(value & flag & info ["dry-run"; "n"] ~doc)
258258+ in
250259 let only =
251260 let doc = "Only run specific step (images, srcsetter, thumbs, faces, videos, typesense)." in
252261 Arg.(value & opt (some string) None & info ["only"] ~docv:"STEP" ~doc)
253262 in
254254- let run () config_file data_dir remote only =
263263+ let run () config_file data_dir remote dry_run only =
255264 match load_config config_file with
256265 | Error e -> Printf.eprintf "Config error: %s\n" e; 1
257266 | Ok config ->
···274283 let fs = Eio.Stdenv.fs env in
275284 let entries = Bushel_eio.Bushel_loader.load fs data_dir in
276285277277- Printf.printf "Running sync pipeline...\n";
286286+ Printf.printf "%s sync pipeline...\n" (if dry_run then "Dry-run" else "Running");
278287 List.iter (fun step ->
279288 Printf.printf " - %s\n" (Bushel_sync.string_of_step step)
280289 ) steps;
281290 Printf.printf "\n";
282291283283- let results = Bushel_sync.run ~env ~config ~steps ~entries in
292292+ let results = Bushel_sync.run ~dry_run ~env ~config ~steps ~entries in
284293285294 Printf.printf "\nResults:\n";
286295 List.iter (fun r ->
···288297 Printf.printf " [%s] %s: %s\n"
289298 status
290299 (Bushel_sync.string_of_step r.step)
291291- r.message
300300+ r.message;
301301+ (* In dry-run mode, show the details (commands) *)
302302+ if dry_run && r.Bushel_sync.details <> [] then begin
303303+ List.iter (fun d -> Printf.printf " %s\n" d) r.Bushel_sync.details
304304+ end
292305 ) results;
293306294307 let failures = List.filter (fun r -> not r.Bushel_sync.success) results in
···304317 `P "4. $(b,faces) - Fetch contact face thumbnails from Immich";
305318 `P "5. $(b,videos) - Fetch video thumbnails from PeerTube";
306319 `P "6. $(b,typesense) - Upload to Typesense (with --remote)";
320320+ `P "Use $(b,--dry-run) to see what commands would be run without executing them.";
307321 ] in
308322 let info = Cmd.info "sync" ~doc ~man in
309309- Cmd.v info Term.(const run $ logging_t $ config_file $ data_dir $ remote $ only)
323323+ Cmd.v info Term.(const run $ logging_t $ config_file $ data_dir $ remote $ dry_run $ only)
310324311325(** {1 Paper Add Command} *)
312326···506520 let info = Cmd.info "video" ~doc in
507521 Cmd.v info Term.(const run $ logging_t $ config_file $ data_dir $ server $ channel)
508522523523+(** {1 Images Command} *)
524524+525525+let images_cmd =
526526+ let limit =
527527+ let doc = "Maximum number of images to show." in
528528+ Arg.(value & opt (some int) None & info ["n"; "limit"] ~docv:"N" ~doc)
529529+ in
530530+ let sort_by =
531531+ let doc = "Sort by field (slug, width, height, variants). Default: slug." in
532532+ Arg.(value & opt string "slug" & info ["s"; "sort"] ~docv:"FIELD" ~doc)
533533+ in
534534+ let run () config_file limit sort_by =
535535+ match load_config config_file with
536536+ | Error e -> Printf.eprintf "Config error: %s\n" e; 1
537537+ | Ok config ->
538538+ Eio_main.run @@ fun env ->
539539+ let fs = Eio.Stdenv.fs env in
540540+ let output_dir = config.Bushel_config.local_output_dir in
541541+ let images = Bushel_eio.Bushel_loader.load_images fs ~output_dir in
542542+ if images = [] then begin
543543+ Printf.printf "No images found.\n";
544544+ Printf.printf "Run 'bushel sync' to generate image index.\n";
545545+ 0
546546+ end else begin
547547+ (* Sort *)
548548+ let sorted = match sort_by with
549549+ | "width" ->
550550+ List.sort (fun a b ->
551551+ let (wa, _) = Srcsetter.dims a in
552552+ let (wb, _) = Srcsetter.dims b in
553553+ compare wb wa (* largest first *)
554554+ ) images
555555+ | "height" ->
556556+ List.sort (fun a b ->
557557+ let (_, ha) = Srcsetter.dims a in
558558+ let (_, hb) = Srcsetter.dims b in
559559+ compare hb ha (* largest first *)
560560+ ) images
561561+ | "variants" ->
562562+ List.sort (fun a b ->
563563+ let va = Srcsetter.MS.cardinal (Srcsetter.variants a) in
564564+ let vb = Srcsetter.MS.cardinal (Srcsetter.variants b) in
565565+ compare vb va (* most variants first *)
566566+ ) images
567567+ | _ -> (* slug, default *)
568568+ List.sort (fun a b ->
569569+ String.compare (Srcsetter.slug a) (Srcsetter.slug b)
570570+ ) images
571571+ in
572572+ (* Limit *)
573573+ let limited = match limit with
574574+ | None -> sorted
575575+ | Some n -> List.filteri (fun i _ -> i < n) sorted
576576+ in
577577+ (* Build table *)
578578+ let rows = List.map (fun img ->
579579+ let (w, h) = Srcsetter.dims img in
580580+ let num_variants = Srcsetter.MS.cardinal (Srcsetter.variants img) in
581581+ [ Srcsetter.slug img
582582+ ; Printf.sprintf "%dx%d" w h
583583+ ; string_of_int num_variants
584584+ ; Srcsetter.origin img
585585+ ]
586586+ ) limited in
587587+ let table = Table.make
588588+ ~headers:["SLUG"; "DIMS"; "VARIANTS"; "ORIGIN"]
589589+ rows
590590+ in
591591+ Table.print table;
592592+ Printf.printf "\nTotal: %d images\n" (List.length limited);
593593+ 0
594594+ end
595595+ in
596596+ let doc = "List images from the srcsetter index." in
597597+ let man = [
598598+ `S Manpage.s_description;
599599+ `P "Lists images that have been processed by srcsetter.";
600600+ `P "Images are stored separately from other entries and are referenced \
601601+ by slug in markdown content using the :slug syntax.";
602602+ `P "Run $(b,bushel sync) to process images and generate the index.";
603603+ ] in
604604+ let info = Cmd.info "images" ~doc ~man in
605605+ Cmd.v info Term.(const run $ logging_t $ config_file $ limit $ sort_by)
606606+509607(** {1 Config Command} *)
510608511609let config_cmd =
···573671 Cmd.group info [
574672 init_cmd;
575673 list_cmd;
674674+ images_cmd;
576675 stats_cmd;
577676 show_cmd;
578677 sync_cmd;
-4
ocaml-bushel/lib/bushel.ml
···12121313 {1 Entry Types}
14141515- - {!Contact} - People/researchers with social links
1615 - {!Note} - Blog posts and research notes
1716 - {!Paper} - Academic papers with BibTeX metadata
1817 - {!Project} - Research projects
···4443*)
45444645(** {1 Entry Types} *)
4747-4848-module Contact = Bushel_contact
4949-(** Contact/person entries. *)
50465147module Note = Bushel_note
5248(** Blog post and research note entries. *)
-154
ocaml-bushel/lib/bushel_contact.ml
···11-(*---------------------------------------------------------------------------
22- Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33- SPDX-License-Identifier: ISC
44- ---------------------------------------------------------------------------*)
55-66-(** Contact/person entry type for Bushel *)
77-88-type t = {
99- names : string list;
1010- handle : string;
1111- email : string option;
1212- icon : string option;
1313- github : string option;
1414- twitter : string option;
1515- bluesky : string option;
1616- mastodon : string option;
1717- orcid : string option;
1818- url : string option;
1919- atom : string list option;
2020-}
2121-2222-type ts = t list
2323-2424-(** {1 Constructors} *)
2525-2626-let v ?email ?github ?twitter ?bluesky ?mastodon ?orcid ?icon ?url ?atom handle names =
2727- { names; handle; email; github; twitter; bluesky; mastodon; orcid; url; icon; atom }
2828-2929-let make names email icon github twitter bluesky mastodon orcid url atom =
3030- v ?email ?github ?twitter ?bluesky ?mastodon ?orcid ?icon ?url ?atom "" names
3131-3232-(** {1 Accessors} *)
3333-3434-let names { names; _ } = names
3535-3636-let name c =
3737- match c.names with
3838- | n :: _ -> n
3939- | [] -> failwith (Printf.sprintf "Contact with handle '%s' has empty names list" c.handle)
4040-4141-let handle { handle; _ } = handle
4242-let email { email; _ } = email
4343-let icon { icon; _ } = icon
4444-let github { github; _ } = github
4545-let twitter { twitter; _ } = twitter
4646-let bluesky { bluesky; _ } = bluesky
4747-let mastodon { mastodon; _ } = mastodon
4848-let orcid { orcid; _ } = orcid
4949-let url { url; _ } = url
5050-let atom { atom; _ } = atom
5151-5252-(** {1 Jsont Codec} *)
5353-5454-let jsont : t Jsont.t =
5555- let open Jsont in
5656- let open Jsont.Object in
5757- let mem_opt f v ~enc = mem f v ~dec_absent:None ~enc_omit:Option.is_none ~enc in
5858- map ~kind:"Contact" make
5959- |> mem "names" (list string) ~dec_absent:[] ~enc:names
6060- |> mem_opt "email" (some string) ~enc:email
6161- |> mem_opt "icon" (some string) ~enc:icon
6262- |> mem_opt "github" (some string) ~enc:github
6363- |> mem_opt "twitter" (some string) ~enc:twitter
6464- |> mem_opt "bluesky" (some string) ~enc:bluesky
6565- |> mem_opt "mastodon" (some string) ~enc:mastodon
6666- |> mem_opt "orcid" (some string) ~enc:orcid
6767- |> mem_opt "url" (some string) ~enc:url
6868- |> mem_opt "atom" (some (list string)) ~enc:atom
6969- |> finish
7070-7171-(** {1 Parsing} *)
7272-7373-let of_frontmatter ~handle (fm : Frontmatter.t) : (t, string) result =
7474- match Frontmatter.decode jsont fm with
7575- | Ok c -> Ok { c with handle }
7676- | Error e -> Error e
7777-7878-(** {1 Lookup Functions} *)
7979-8080-let compare a b = String.compare a.handle b.handle
8181-8282-let find_by_handle ts h = List.find_opt (fun { handle; _ } -> handle = h) ts
8383-8484-let best_url c =
8585- match c.url with
8686- | Some _ as url -> url
8787- | None ->
8888- match c.github with
8989- | Some g -> Some ("https://github.com/" ^ g)
9090- | None -> Option.map (fun e -> "mailto:" ^ e) c.email
9191-9292-(** Given a name, turn it lowercase and return the concatenation of the
9393- initials of all the words in the name and the full last name. *)
9494-let handle_of_name name =
9595- let name = String.lowercase_ascii name in
9696- let words = String.split_on_char ' ' name in
9797- let initials = String.concat "" (List.map (fun w -> String.sub w 0 1) words) in
9898- initials ^ List.hd (List.rev words)
9999-100100-(** Fuzzy lookup for an author by name. *)
101101-let lookup_by_name ts a =
102102- let a = String.lowercase_ascii a in
103103- let rec aux acc = function
104104- | [] -> acc
105105- | t :: ts ->
106106- if List.exists (fun n -> String.lowercase_ascii n = a) t.names
107107- then aux (t :: acc) ts
108108- else aux acc ts
109109- in
110110- match aux [] ts with
111111- | [ a ] -> a
112112- | [] -> raise (Failure ("Contact not found: " ^ a))
113113- | _ -> raise (Failure ("Ambiguous contact: " ^ a))
114114-115115-(** {1 Pretty Printing} *)
116116-117117-let pp ppf c =
118118- let open Fmt in
119119- pf ppf "@[<v>";
120120- pf ppf "%a: %a@," (styled `Bold string) "Type" (styled `Cyan string) "Contact";
121121- pf ppf "%a: @%a@," (styled `Bold string) "Handle" string (handle c);
122122- pf ppf "%a: %a@," (styled `Bold string) "Name" string (name c);
123123- let ns = names c in
124124- if List.length ns > 1 then
125125- pf ppf "%a: @[<h>%a@]@," (styled `Bold string) "Aliases" (list ~sep:comma string) (List.tl ns);
126126- (match email c with
127127- | Some e -> pf ppf "%a: %a@," (styled `Bold string) "Email" string e
128128- | None -> ());
129129- (match github c with
130130- | Some g -> pf ppf "%a: https://github.com/%a@," (styled `Bold string) "GitHub" string g
131131- | None -> ());
132132- (match twitter c with
133133- | Some t -> pf ppf "%a: https://twitter.com/%a@," (styled `Bold string) "Twitter" string t
134134- | None -> ());
135135- (match bluesky c with
136136- | Some b -> pf ppf "%a: %a@," (styled `Bold string) "Bluesky" string b
137137- | None -> ());
138138- (match mastodon c with
139139- | Some m -> pf ppf "%a: %a@," (styled `Bold string) "Mastodon" string m
140140- | None -> ());
141141- (match orcid c with
142142- | Some o -> pf ppf "%a: https://orcid.org/%a@," (styled `Bold string) "ORCID" string o
143143- | None -> ());
144144- (match url c with
145145- | Some u -> pf ppf "%a: %a@," (styled `Bold string) "URL" string u
146146- | None -> ());
147147- (match icon c with
148148- | Some i -> pf ppf "%a: %a@," (styled `Bold string) "Icon" string i
149149- | None -> ());
150150- (match atom c with
151151- | Some atoms when atoms <> [] ->
152152- pf ppf "%a: @[<h>%a@]@," (styled `Bold string) "Atom Feeds" (list ~sep:comma string) atoms
153153- | _ -> ());
154154- pf ppf "@]"
+9-4
ocaml-bushel/lib/bushel_entry.ml
···2323 projects : Bushel_project.ts;
2424 ideas : Bushel_idea.ts;
2525 videos : Bushel_video.ts;
2626- contacts : Bushel_contact.ts;
2626+ contacts : Sortal_schema.Contact.t list;
2727 data_dir : string;
2828}
2929···153153(** {1 Contact Lookups} *)
154154155155let lookup_by_name { contacts; _ } n =
156156- match Bushel_contact.lookup_by_name contacts n with
157157- | v -> Some v
158158- | exception _ -> None
156156+ let name_lower = String.lowercase_ascii n in
157157+ let matches = List.filter (fun c ->
158158+ List.exists (fun name -> String.lowercase_ascii name = name_lower)
159159+ (Sortal_schema.Contact.names c)
160160+ ) contacts in
161161+ match matches with
162162+ | [contact] -> Some contact
163163+ | _ -> None
159164160165(** {1 Tag Functions} *)
161166
+3-3
ocaml-bushel/lib/bushel_entry.mli
···2828 projects:Bushel_project.t list ->
2929 ideas:Bushel_idea.t list ->
3030 videos:Bushel_video.t list ->
3131- contacts:Bushel_contact.t list ->
3131+ contacts:Sortal_schema.Contact.t list ->
3232 data_dir:string ->
3333 t
3434(** Create an entry collection from lists of each entry type. *)
35353636(** {1 Accessors} *)
37373838-val contacts : t -> Bushel_contact.ts
3838+val contacts : t -> Sortal_schema.Contact.t list
3939val videos : t -> Bushel_video.ts
4040val ideas : t -> Bushel_idea.ts
4141val papers : t -> Bushel_paper.ts
···111111112112(** {1 Contact Lookups} *)
113113114114-val lookup_by_name : t -> string -> Bushel_contact.t option
114114+val lookup_by_name : t -> string -> Sortal_schema.Contact.t option
115115(** [lookup_by_name entries name] finds a contact by name. *)
116116117117(** {1 Tag Functions} *)
+2-2
ocaml-bushel/lib/bushel_link_graph.ml
···144144 ) (Bushel_entry.all_entries entries) in
145145146146 let contact_nodes = List.map (fun contact ->
147147- let handle = Bushel_contact.handle contact in
148148- let name = Bushel_contact.name contact in
147147+ let handle = Sortal_schema.Contact.handle contact in
148148+ let name = Sortal_schema.Contact.name contact in
149149 `O [
150150 ("id", `String handle);
151151 ("title", `String name);
+5-5
ocaml-bushel/lib/bushel_md.ml
···137137 | Some () ->
138138 let slug = Label.key l in
139139 let s = strip_handle slug in
140140- (match Bushel_contact.find_by_handle (Bushel_entry.contacts entries) s with
140140+ (match List.find_opt (fun c -> Sortal_schema.Contact.handle c = s) (Bushel_entry.contacts entries) with
141141 | Some c ->
142142- let name = Bushel_contact.name c in
143143- (match Bushel_contact.best_url c with
142142+ let name = Sortal_schema.Contact.name c in
143143+ (match Sortal_schema.Contact.best_url c with
144144 | Some dest ->
145145 let txt = Inline.Text (name, meta) in
146146 let ld = Link_definition.make ~dest:(dest, meta) () in
···370370 | Some (url, _title) ->
371371 let s = strip_handle url in
372372 if is_contact_slug url then
373373- (match Bushel_contact.find_by_handle (Bushel_entry.contacts entries) s with
373373+ (match List.find_opt (fun c -> Sortal_schema.Contact.handle c = s) (Bushel_entry.contacts entries) with
374374 | None -> Hashtbl.replace broken_contacts url ()
375375 | Some _ -> ())
376376 else if is_bushel_slug url then
···386386 | Some () ->
387387 let slug = Label.key l in
388388 let handle = strip_handle slug in
389389- (match Bushel_contact.find_by_handle (Bushel_entry.contacts entries) handle with
389389+ (match List.find_opt (fun c -> Sortal_schema.Contact.handle c = handle) (Bushel_entry.contacts entries) with
390390 | None -> Hashtbl.replace broken_contacts slug ()
391391 | Some _ -> ());
392392 Mapper.default
+2-1
ocaml-bushel/lib/dune
···1010 re
1111 uri
1212 fmt
1313- yamlrw))
1313+ yamlrw
1414+ sortal.schema))
+25-12
ocaml-bushel/lib_eio/bushel_loader.ml
···88let src = Logs.Src.create "bushel.loader" ~doc:"Bushel loader"
99module Log = (val Logs.src_log src : Logs.LOG)
10101111+(** Load images from srcsetter index.json *)
1212+let load_images fs ~output_dir =
1313+ let index_path = Filename.concat output_dir "index.json" in
1414+ let path = Eio.Path.(fs / index_path) in
1515+ try
1616+ let content = Eio.Path.load path in
1717+ match Srcsetter.list_of_json content with
1818+ | Ok images ->
1919+ Log.info (fun m -> m "Loaded %d images from %s" (List.length images) index_path);
2020+ images
2121+ | Error e ->
2222+ Log.warn (fun m -> m "Failed to parse %s: %s" index_path e);
2323+ []
2424+ with
2525+ | Eio.Io (Eio.Fs.E (Eio.Fs.Not_found _), _) ->
2626+ Log.info (fun m -> m "No image index found at %s" index_path);
2727+ []
2828+1129(** List markdown files in a directory *)
1230let list_md_files fs dir =
1331 let path = Eio.Path.(fs / dir) in
···3856 None
3957 ) files
40584141-(** Load contacts from data/contacts/ *)
4242-let load_contacts fs base =
4343- map_category fs base "contacts" (fun fm ->
4444- let handle =
4545- match Frontmatter.fname fm with
4646- | Some fname -> Filename.basename fname |> Filename.chop_extension
4747- | None -> ""
4848- in
4949- Bushel.Contact.of_frontmatter ~handle fm
5050- )
5959+(** Load contacts from Sortal XDG store *)
6060+let load_contacts fs _base =
6161+ let store = Sortal.Store.create fs "sortal" in
6262+ Sortal.Store.list store
51635264(** Load projects from data/projects/ *)
5365let load_projects fs base =
···165177 | None -> ())
166178 else if Bushel.Md.is_contact_slug link then
167179 let handle = Bushel.Md.strip_handle link in
168168- (match Bushel.Contact.find_by_handle (Bushel.Entry.contacts entries) handle with
180180+ let contacts = Bushel.Entry.contacts entries in
181181+ (match List.find_opt (fun c -> Sortal_schema.Contact.handle c = handle) contacts with
169182 | Some c ->
170170- add_internal_link source_slug (Bushel.Contact.handle c) `Contact
183183+ add_internal_link source_slug (Sortal_schema.Contact.handle c) `Contact
171184 | None -> ())
172185 else if Bushel.Md.is_tag_slug link || Bushel.Md.is_type_filter_slug link then
173186 () (* Skip tag links *)
···7373(** {1 Contact Face Fetching} *)
74747575let fetch_face_for_contact ~proc_mgr ~endpoint ~api_key ~output_dir contact =
7676- let names = Bushel.Contact.names contact in
7777- let handle = Bushel.Contact.handle contact in
7676+ let names = Sortal_schema.Contact.names contact in
7777+ let handle = Sortal_schema.Contact.handle contact in
7878 let output_path = Filename.concat output_dir (handle ^ ".jpg") in
79798080 (* Skip if already exists *)
···113113 Unix.mkdir output_dir 0o755;
114114115115 let results = List.map (fun contact ->
116116- let handle = Bushel.Contact.handle contact in
116116+ let handle = Sortal_schema.Contact.handle contact in
117117 let result = fetch_face_for_contact ~proc_mgr ~endpoint ~api_key ~output_dir contact in
118118 (handle, result)
119119 ) contacts in
+223-120
ocaml-bushel/lib_sync/bushel_sync.ml
···5555 | "typesense" -> Some Typesense
5656 | _ -> None
57575858-let all_steps = [Images; Srcsetter; Thumbs; Faces; Videos]
5858+let all_steps = [Images; Thumbs; Faces; Srcsetter; Videos]
5959let all_steps_with_remote = all_steps @ [Typesense]
60606161(** {1 Step Results} *)
···77777878(** {1 Rsync Images} *)
79798080-let sync_images ~proc_mgr config =
8080+let sync_images ~dry_run ~fs ~proc_mgr config =
8181 Log.info (fun m -> m "Syncing images from remote...");
8282- let cmd = Bushel_config.rsync_command config in
8383- Log.debug (fun m -> m "Running: %s" cmd);
8484-8585- (* Ensure local directory exists *)
8682 let local_dir = config.Bushel_config.local_source_dir in
8787- if not (Sys.file_exists local_dir) then begin
8888- Log.info (fun m -> m "Creating directory: %s" local_dir);
8989- Unix.mkdir local_dir 0o755
9090- end;
8383+ let args = ["rsync"; "-avz";
8484+ Bushel_config.rsync_source config ^ "/";
8585+ local_dir ^ "/"] in
8686+ let cmd = String.concat " " args in
91879292- try
9393- let args = ["rsync"; "-avz";
9494- Bushel_config.rsync_source config ^ "/";
9595- local_dir ^ "/"] in
9696- Eio.Process.run proc_mgr args;
8888+ if dry_run then begin
9789 { step = Images; success = true;
9898- message = "Images synced from remote";
9999- details = [] }
100100- with e ->
101101- { step = Images; success = false;
102102- message = Printf.sprintf "Rsync failed: %s" (Printexc.to_string e);
103103- details = [] }
9090+ message = "Would run rsync";
9191+ details = [cmd] }
9292+ end else begin
9393+ Log.debug (fun m -> m "Running: %s" cmd);
9494+9595+ (* Ensure local directory exists (recursive) *)
9696+ let local_path = Eio.Path.(fs / local_dir) in
9797+ Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 local_path;
9898+9999+ try
100100+ Eio.Process.run proc_mgr args;
101101+ { step = Images; success = true;
102102+ message = "Images synced from remote";
103103+ details = [] }
104104+ with e ->
105105+ { step = Images; success = false;
106106+ message = Printf.sprintf "Rsync failed: %s" (Printexc.to_string e);
107107+ details = [] }
108108+ end
104109105110(** {1 Srcsetter} *)
106111107107-let run_srcsetter ~proc_mgr config =
112112+let run_srcsetter ~dry_run ~fs ~proc_mgr config =
108113 Log.info (fun m -> m "Running srcsetter...");
109114 let src_dir = config.Bushel_config.local_source_dir in
110115 let dst_dir = config.Bushel_config.local_output_dir in
111116112112- (* Ensure output directory exists *)
113113- if not (Sys.file_exists dst_dir) then begin
114114- Log.info (fun m -> m "Creating directory: %s" dst_dir);
115115- Unix.mkdir dst_dir 0o755
116116- end;
117117-118118- try
119119- let args = ["srcsetter"; src_dir; dst_dir] in
120120- Eio.Process.run proc_mgr args;
117117+ if dry_run then begin
121118 { step = Srcsetter; success = true;
122122- message = "Srcsetter completed";
123123- details = [] }
124124- with e ->
125125- { step = Srcsetter; success = false;
126126- message = Printf.sprintf "Srcsetter failed: %s" (Printexc.to_string e);
127127- details = [] }
119119+ message = "Would run srcsetter";
120120+ details = [Printf.sprintf "srcsetter %s %s" src_dir dst_dir] }
121121+ end else begin
122122+ (* Ensure output directory exists (recursive) *)
123123+ let src_path = Eio.Path.(fs / src_dir) in
124124+ let dst_path = Eio.Path.(fs / dst_dir) in
125125+ Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 dst_path;
126126+127127+ try
128128+ let entries = Srcsetter_cmd.run
129129+ ~proc_mgr
130130+ ~src_dir:src_path
131131+ ~dst_dir:dst_path
132132+ ~preserve:true
133133+ ()
134134+ in
135135+ { step = Srcsetter; success = true;
136136+ message = Printf.sprintf "Srcsetter completed: %d images processed"
137137+ (List.length entries);
138138+ details = [] }
139139+ with e ->
140140+ { step = Srcsetter; success = false;
141141+ message = Printf.sprintf "Srcsetter failed: %s" (Printexc.to_string e);
142142+ details = [] }
143143+ end
128144129145(** {1 Paper Thumbnails} *)
130146131131-let generate_paper_thumbnails ~proc_mgr config =
147147+let generate_paper_thumbnails ~dry_run ~fs ~proc_mgr config =
132148 Log.info (fun m -> m "Generating paper thumbnails...");
133149 let pdfs_dir = config.Bushel_config.paper_pdfs_dir in
134134- let output_dir = Bushel_config.paper_thumbs_dir config in
150150+ (* Output to local_source_dir/papers/ so srcsetter processes them *)
151151+ let output_dir = Filename.concat config.Bushel_config.local_source_dir "papers" in
135152136153 if not (Sys.file_exists pdfs_dir) then begin
137154 Log.warn (fun m -> m "PDFs directory does not exist: %s" pdfs_dir);
···139156 message = "No PDFs directory";
140157 details = [] }
141158 end else begin
142142- (* Ensure output directory exists *)
143143- if not (Sys.file_exists output_dir) then
144144- Unix.mkdir output_dir 0o755;
145145-146159 let pdfs = Sys.readdir pdfs_dir |> Array.to_list
147160 |> List.filter (fun f -> Filename.check_suffix f ".pdf") in
148161149149- let results = List.map (fun pdf_file ->
150150- let slug = Filename.chop_extension pdf_file in
151151- let pdf_path = Filename.concat pdfs_dir pdf_file in
152152- let output_path = Filename.concat output_dir (slug ^ ".webp") in
153153-154154- if Sys.file_exists output_path then begin
155155- Log.debug (fun m -> m "Skipping %s: thumbnail exists" slug);
156156- `Skipped slug
157157- end else begin
158158- Log.info (fun m -> m "Generating thumbnail for %s" slug);
159159- try
160160- (* ImageMagick command: render PDF at 600 DPI, crop top 50%, resize to 2048px *)
162162+ if dry_run then begin
163163+ let would_run = List.filter_map (fun pdf_file ->
164164+ let slug = Filename.chop_extension pdf_file in
165165+ let pdf_path = Filename.concat pdfs_dir pdf_file in
166166+ (* Output as PNG - srcsetter will convert to webp *)
167167+ let output_path = Filename.concat output_dir (slug ^ ".png") in
168168+ if Sys.file_exists output_path then None
169169+ else begin
161170 let args = [
162162- "magick";
163163- "-density"; "600";
164164- "-quality"; "100";
165165- pdf_path ^ "[0]"; (* First page only *)
166166- "-gravity"; "North";
167167- "-crop"; "100%x50%+0+0";
168168- "-resize"; "2048x";
169169- output_path
171171+ "magick"; "-density"; "600"; "-quality"; "100";
172172+ pdf_path ^ "[0]"; "-gravity"; "North";
173173+ "-crop"; "100%x50%+0+0"; "-resize"; "2048x"; output_path
170174 ] in
171171- Eio.Process.run proc_mgr args;
172172- `Ok slug
173173- with e ->
174174- Log.err (fun m -> m "Failed to generate thumbnail for %s: %s"
175175- slug (Printexc.to_string e));
176176- `Error slug
177177- end
178178- ) pdfs in
175175+ Some (String.concat " " args)
176176+ end
177177+ ) pdfs in
178178+ let skipped = List.length pdfs - List.length would_run in
179179+ { step = Thumbs; success = true;
180180+ message = Printf.sprintf "Would generate %d thumbnails (%d already exist)"
181181+ (List.length would_run) skipped;
182182+ details = would_run }
183183+ end else begin
184184+ (* Ensure output directory exists (recursive) *)
185185+ let output_path = Eio.Path.(fs / output_dir) in
186186+ Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 output_path;
187187+188188+ let results = List.map (fun pdf_file ->
189189+ let slug = Filename.chop_extension pdf_file in
190190+ let pdf_path = Filename.concat pdfs_dir pdf_file in
191191+ (* Output as PNG - srcsetter will convert to webp *)
192192+ let output_path = Filename.concat output_dir (slug ^ ".png") in
193193+194194+ if Sys.file_exists output_path then begin
195195+ Log.debug (fun m -> m "Skipping %s: thumbnail exists" slug);
196196+ `Skipped slug
197197+ end else begin
198198+ Log.info (fun m -> m "Generating thumbnail for %s" slug);
199199+ try
200200+ (* ImageMagick command: render PDF at 600 DPI, crop top 50%, resize to 2048px *)
201201+ let args = [
202202+ "magick";
203203+ "-density"; "600";
204204+ "-quality"; "100";
205205+ pdf_path ^ "[0]"; (* First page only *)
206206+ "-gravity"; "North";
207207+ "-crop"; "100%x50%+0+0";
208208+ "-resize"; "2048x";
209209+ output_path
210210+ ] in
211211+ Eio.Process.run proc_mgr args;
212212+ `Ok slug
213213+ with e ->
214214+ Log.err (fun m -> m "Failed to generate thumbnail for %s: %s"
215215+ slug (Printexc.to_string e));
216216+ `Error slug
217217+ end
218218+ ) pdfs in
179219180180- let ok_count = List.fold_left (fun acc r -> match r with `Ok _ -> acc + 1 | _ -> acc) 0 results in
181181- let skipped_count = List.fold_left (fun acc r -> match r with `Skipped _ -> acc + 1 | _ -> acc) 0 results in
182182- let error_count = List.fold_left (fun acc r -> match r with `Error _ -> acc + 1 | _ -> acc) 0 results in
220220+ let ok_count = List.fold_left (fun acc r -> match r with `Ok _ -> acc + 1 | _ -> acc) 0 results in
221221+ let skipped_count = List.fold_left (fun acc r -> match r with `Skipped _ -> acc + 1 | _ -> acc) 0 results in
222222+ let error_count = List.fold_left (fun acc r -> match r with `Error _ -> acc + 1 | _ -> acc) 0 results in
183223184184- { step = Thumbs; success = error_count = 0;
185185- message = Printf.sprintf "%d generated, %d skipped, %d errors"
186186- ok_count skipped_count error_count;
187187- details = List.filter_map (fun r -> match r with `Error s -> Some s | _ -> None) results }
224224+ { step = Thumbs; success = error_count = 0;
225225+ message = Printf.sprintf "%d generated, %d skipped, %d errors"
226226+ ok_count skipped_count error_count;
227227+ details = List.filter_map (fun r -> match r with `Error s -> Some s | _ -> None) results }
228228+ end
188229 end
189230190231(** {1 Contact Faces} *)
191232192192-let sync_faces ~proc_mgr config entries =
193193- Log.info (fun m -> m "Syncing contact faces from Immich...");
194194- let output_dir = Bushel_config.contact_faces_dir config in
233233+let sync_faces ~dry_run ~fs config entries =
234234+ Log.info (fun m -> m "Syncing contact faces from Sortal...");
235235+ (* Output to local_source_dir/faces/ so srcsetter processes them *)
236236+ let output_dir = Filename.concat config.Bushel_config.local_source_dir "faces" in
237237+ let contacts = Bushel.Entry.contacts entries in
195238196196- match Bushel_config.immich_api_key config with
197197- | Error e ->
198198- Log.warn (fun m -> m "Cannot read Immich API key: %s" e);
199199- { step = Faces; success = false;
200200- message = "Missing Immich API key";
201201- details = [e] }
202202- | Ok api_key ->
203203- let contacts = Bushel.Entry.contacts entries in
204204- let results = Bushel_immich.fetch_all_faces
205205- ~proc_mgr
206206- ~endpoint:config.immich_endpoint
207207- ~api_key
208208- ~output_dir
209209- contacts in
239239+ (* Load sortal store to get thumbnail paths *)
240240+ let sortal_store = Sortal.Store.create fs "sortal" in
210241211211- let ok_count = List.length (List.filter (fun (_, r) ->
212212- match r with Bushel_immich.Ok _ -> true | _ -> false) results) in
213213- let skipped_count = List.length (List.filter (fun (_, r) ->
214214- match r with Bushel_immich.Skipped _ -> true | _ -> false) results) in
215215- let error_count = List.length (List.filter (fun (_, r) ->
216216- match r with Bushel_immich.Error _ -> true | _ -> false) results) in
242242+ (* Find contacts with PNG thumbnails that need copying *)
243243+ let contacts_with_thumbs = List.filter_map (fun c ->
244244+ match Sortal.Store.png_thumbnail_path sortal_store c with
245245+ | Some path -> Some (c, path)
246246+ | None -> None
247247+ ) contacts in
217248249249+ if dry_run then begin
250250+ let would_copy = List.filter (fun (c, _src_path) ->
251251+ let handle = Sortal_schema.Contact.handle c in
252252+ let output_path = Filename.concat output_dir (handle ^ ".png") in
253253+ not (Sys.file_exists output_path)
254254+ ) contacts_with_thumbs in
255255+ let skipped = List.length contacts_with_thumbs - List.length would_copy in
256256+ let no_thumb = List.length contacts - List.length contacts_with_thumbs in
218257 { step = Faces; success = true;
219219- message = Printf.sprintf "%d fetched, %d skipped, %d errors"
220220- ok_count skipped_count error_count;
258258+ message = Printf.sprintf "Would copy %d faces from Sortal (%d already exist, %d without thumbnails)"
259259+ (List.length would_copy) skipped no_thumb;
260260+ details = List.map (fun (c, src_path) ->
261261+ let handle = Sortal_schema.Contact.handle c in
262262+ Printf.sprintf "cp %s %s/%s.png" (Eio.Path.native_exn src_path) output_dir handle
263263+ ) (List.filteri (fun i _ -> i < 5) would_copy) @
264264+ (if List.length would_copy > 5 then ["...and more"] else []) }
265265+ end else begin
266266+ (* Ensure output directory exists *)
267267+ let output_path = Eio.Path.(fs / output_dir) in
268268+ Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 output_path;
269269+270270+ let results = List.map (fun (c, src_path) ->
271271+ let handle = Sortal_schema.Contact.handle c in
272272+ let dst_path = Filename.concat output_dir (handle ^ ".png") in
273273+274274+ if Sys.file_exists dst_path then begin
275275+ Log.debug (fun m -> m "Skipping %s: already exists" handle);
276276+ (handle, `Skipped)
277277+ end else begin
278278+ Log.info (fun m -> m "Copying face for %s" handle);
279279+ try
280280+ let content = Eio.Path.load src_path in
281281+ let oc = open_out_bin dst_path in
282282+ output_string oc content;
283283+ close_out oc;
284284+ (handle, `Ok)
285285+ with e ->
286286+ Log.err (fun m -> m "Failed to copy face for %s: %s" handle (Printexc.to_string e));
287287+ (handle, `Error (Printexc.to_string e))
288288+ end
289289+ ) contacts_with_thumbs in
290290+291291+ let ok_count = List.length (List.filter (fun (_, r) -> r = `Ok) results) in
292292+ let skipped_count = List.length (List.filter (fun (_, r) -> r = `Skipped) results) in
293293+ let error_count = List.length (List.filter (fun (_, r) -> match r with `Error _ -> true | _ -> false) results) in
294294+ let no_thumb = List.length contacts - List.length contacts_with_thumbs in
295295+296296+ { step = Faces; success = error_count = 0;
297297+ message = Printf.sprintf "%d copied, %d skipped, %d errors, %d without thumbnails"
298298+ ok_count skipped_count error_count no_thumb;
221299 details = List.filter_map (fun (h, r) ->
222222- match r with Bushel_immich.Error e -> Some (h ^ ": " ^ e) | _ -> None
300300+ match r with `Error e -> Some (h ^ ": " ^ e) | _ -> None
223301 ) results }
302302+ end
224303225304(** {1 Video Thumbnails} *)
226305227227-let sync_video_thumbnails ~proc_mgr config =
306306+let sync_video_thumbnails ~dry_run ~proc_mgr config =
228307 Log.info (fun m -> m "Syncing video thumbnails from PeerTube...");
229308 let output_dir = Bushel_config.video_thumbs_dir config in
230309 let videos_yml = Filename.concat config.data_dir "videos.yml" in
231310232311 let index = Bushel_peertube.VideoIndex.load_file videos_yml in
233233- let count = List.length (Bushel_peertube.VideoIndex.to_list index) in
312312+ let video_list = Bushel_peertube.VideoIndex.to_list index in
313313+ let count = List.length video_list in
234314235315 if count = 0 then begin
236316 Log.info (fun m -> m "No videos in index");
237317 { step = Videos; success = true;
238318 message = "No videos in index";
239319 details = [] }
320320+ end else if dry_run then begin
321321+ let would_fetch = List.filter (fun (uuid, _server) ->
322322+ let output_path = Filename.concat output_dir (uuid ^ ".jpg") in
323323+ not (Sys.file_exists output_path)
324324+ ) video_list in
325325+ let skipped = count - List.length would_fetch in
326326+ { step = Videos; success = true;
327327+ message = Printf.sprintf "Would fetch %d video thumbnails from PeerTube (%d already exist)"
328328+ (List.length would_fetch) skipped;
329329+ details = List.map (fun (uuid, server) ->
330330+ Printf.sprintf "curl <server:%s>/api/v1/videos/%s -> %s.jpg" server uuid uuid
331331+ ) (List.filteri (fun i _ -> i < 5) would_fetch) @
332332+ (if List.length would_fetch > 5 then ["...and more"] else []) }
240333 end else begin
241334 let results = Bushel_peertube.fetch_thumbnails_from_index
242335 ~proc_mgr
···261354262355(** {1 Typesense Upload} *)
263356264264-let upload_typesense config _entries =
357357+let upload_typesense ~dry_run config _entries =
265358 Log.info (fun m -> m "Uploading to Typesense...");
266359267360 match Bushel_config.typesense_api_key config with
···270363 message = "Missing Typesense API key";
271364 details = [e] }
272365 | Ok _api_key ->
273273- (* TODO: Implement actual Typesense upload using bushel-typesense *)
274274- { step = Typesense; success = true;
275275- message = "Typesense upload (not yet implemented)";
276276- details = [] }
366366+ if dry_run then
367367+ { step = Typesense; success = true;
368368+ message = "Would upload to Typesense";
369369+ details = ["POST to Typesense API (not yet implemented)"] }
370370+ else
371371+ (* TODO: Implement actual Typesense upload using bushel-typesense *)
372372+ { step = Typesense; success = true;
373373+ message = "Typesense upload (not yet implemented)";
374374+ details = [] }
277375278376(** {1 Run Pipeline} *)
279377280280-let run ~env ~config ~steps ~entries =
378378+let run ~dry_run ~env ~config ~steps ~entries =
281379 let proc_mgr = Eio.Stdenv.process_mgr env in
380380+ let fs = Eio.Stdenv.fs env in
282381283382 let results = List.map (fun step ->
284284- Log.info (fun m -> m "Running step: %s" (string_of_step step));
383383+ Log.info (fun m -> m "%s step: %s"
384384+ (if dry_run then "Dry-run" else "Running")
385385+ (string_of_step step));
285386 match step with
286286- | Images -> sync_images ~proc_mgr config
287287- | Srcsetter -> run_srcsetter ~proc_mgr config
288288- | Thumbs -> generate_paper_thumbnails ~proc_mgr config
289289- | Faces -> sync_faces ~proc_mgr config entries
290290- | Videos -> sync_video_thumbnails ~proc_mgr config
291291- | Typesense -> upload_typesense config entries
387387+ | Images -> sync_images ~dry_run ~fs ~proc_mgr config
388388+ | Srcsetter -> run_srcsetter ~dry_run ~fs ~proc_mgr config
389389+ | Thumbs -> generate_paper_thumbnails ~dry_run ~fs ~proc_mgr config
390390+ | Faces -> sync_faces ~dry_run ~fs config entries
391391+ | Videos -> sync_video_thumbnails ~dry_run ~proc_mgr config
392392+ | Typesense -> upload_typesense ~dry_run config entries
292393 ) steps in
293394294395 (* Summary *)
295396 let success_count = List.length (List.filter (fun r -> r.success) results) in
296397 let total = List.length results in
297297- Log.info (fun m -> m "Sync complete: %d/%d steps succeeded" success_count total);
398398+ Log.info (fun m -> m "%s complete: %d/%d steps succeeded"
399399+ (if dry_run then "Dry-run" else "Sync")
400400+ success_count total);
298401299402 results
···3838 | None ->
3939 Fmt.epr "%a No server specified and not logged in.@." error_style "Error:";
4040 Fmt.epr "Use --server or login first.@.";
4141- exit 1
4141+ raise (Immich_auth.Error.Exit_code 1)
4242 in
4343 (* Create session using requests config *)
4444 let session = Requests.Cmd.create requests_config env sw in
+6
ocaml-immich/bin/main.ml
···3131 in
3232 Cmd.eval (Cmd.group info cmds)
3333 with
3434+ | Eio.Cancel.Cancelled Stdlib.Exit ->
3535+ (* Eio wraps Exit in Cancelled when a fiber is cancelled *)
3636+ 0
3737+ | Immich_auth.Error.Exit_code code ->
3838+ (* Exit code from Error.wrap - already printed error message *)
3939+ code
3440 | Openapi.Runtime.Api_error _ as exn ->
3541 (* Handle Immich API errors with nice formatting *)
3642 Immich_auth.Error.handle_exn exn
+5-4
ocaml-immich/lib/cmd.ml
···8888 error_style "Error:"
8989 profile_style profile_name
9090 Fmt.(styled `Bold string) "immich auth login";
9191- exit 1
9191+ raise (Error.Exit_code 1)
9292 | Some session -> f fs session
93939494let with_client ?requests_config ?profile f env =
···161161 | Some _, Some _ ->
162162 Fmt.epr "%a Cannot specify both --api-key and --email. Choose one authentication method.@."
163163 error_style "Error:";
164164- exit 1
164164+ raise (Error.Exit_code 1)
165165166166let login_cmd env fs =
167167 let doc = "Login to an Immich server." in
168168 let info = Cmd.info "login" ~doc in
169169 let login' (style_renderer, level) requests_config server api_key email password profile key_name =
170170 setup_logging_with_config style_renderer level requests_config;
171171- login_action ~requests_config ~server ~api_key ~email ~password ~profile ~key_name env
171171+ Error.wrap (fun () ->
172172+ login_action ~requests_config ~server ~api_key ~email ~password ~profile ~key_name env)
172173 in
173174 Cmd.v info
174175 Term.(const login' $ setup_logging $ requests_config_term fs $ server_arg $ api_key_arg $ email_arg $ password_arg $ profile_arg $ key_name_arg)
···302303 Fmt.epr "%a %a@."
303304 label_style "Available profiles:"
304305 Fmt.(list ~sep:(any ", ") profile_style) profiles;
305305- exit 1
306306+ raise (Error.Exit_code 1)
306307 end
307308308309let profile_switch_cmd env =
+16-4
ocaml-immich/lib/error.ml
···117117 try f (); 0
118118 with exn -> handle_exn exn
119119120120+(** Exception to signal desired exit code without calling [exit] directly.
121121+ This avoids issues when running inside Eio's event loop. *)
122122+exception Exit_code of int
123123+120124(** Wrap a command action to handle API errors gracefully.
121125122126 This is designed to be used in cmdliner command definitions:
···133137 ]}
134138135139 The wrapper catches API errors and prints a nice message,
136136- then exits with an appropriate code. *)
140140+ then raises [Exit_code] with an appropriate code. This exception
141141+ should be caught by the main program outside the Eio event loop. *)
137142let wrap f =
138143 try f ()
139139- with exn ->
140140- let code = handle_exn exn in
141141- exit code
144144+ with
145145+ | Stdlib.Exit ->
146146+ (* exit() was called somewhere - treat as success *)
147147+ ()
148148+ | Eio.Cancel.Cancelled Stdlib.Exit ->
149149+ (* Eio wraps Exit in Cancelled - treat as success *)
150150+ ()
151151+ | exn ->
152152+ let code = handle_exn exn in
153153+ raise (Exit_code code)
+9-2
ocaml-immich/lib/error.mli
···53535454(** {1 Exception Handling} *)
55555656+exception Exit_code of int
5757+(** Exception raised to signal a desired exit code.
5858+ This is used instead of calling [exit] directly to avoid issues
5959+ when running inside Eio's event loop. Catch this exception in
6060+ the main program outside the Eio context. *)
6161+5662val handle_exn : exn -> int
5763(** Handle an exception, printing a nice error message if it's an API error.
5864···8288(** Wrap a command action to handle API errors gracefully.
83898490 This is designed to be used in cmdliner command definitions.
8585- Catches API errors, prints a nice message, and exits with
8686- an appropriate code.
9191+ Catches API errors, prints a nice message, and raises {!Exit_code}
9292+ with an appropriate code. The calling code should catch this
9393+ exception outside the Eio event loop.
87948895 Usage:
8996 {[
+16-9
sortal/lib/core/sortal_cmd.ml
···124124 Logs.info (fun m -> m "@%s: already PNG (%s)" handle (Filename.basename path));
125125 incr skipped
126126 end else begin
127127- Logs.app (fun m -> m "@%s: converting %s to PNG..." handle (Filename.basename path));
128128- match convert_to_png path with
129129- | Ok new_path ->
130130- Logs.app (fun m -> m " Converted: %s -> %s"
131131- (Filename.basename path) (Filename.basename new_path));
132132- incr converted
133133- | Error msg ->
134134- Logs.err (fun m -> m " Failed to convert %s: %s" path msg);
135135- incr errors
127127+ (* Check if PNG version already exists *)
128128+ let png_path = Filename.remove_extension path ^ ".png" in
129129+ if Sys.file_exists png_path then begin
130130+ Logs.info (fun m -> m "@%s: PNG already exists (%s)" handle (Filename.basename png_path));
131131+ incr skipped
132132+ end else begin
133133+ Logs.app (fun m -> m "@%s: converting %s to PNG..." handle (Filename.basename path));
134134+ match convert_to_png path with
135135+ | Ok new_path ->
136136+ Logs.app (fun m -> m " Converted: %s -> %s"
137137+ (Filename.basename path) (Filename.basename new_path));
138138+ incr converted
139139+ | Error msg ->
140140+ Logs.err (fun m -> m " Failed to convert %s: %s" path msg);
141141+ incr errors
142142+ end
136143 end
137144 ) contacts;
138145 Logs.app (fun m -> m "Sync complete:");
+15
sortal/lib/core/sortal_store.ml
···329329 ) all in
330330 List.sort Contact.compare matches
331331332332+let find_by_handle t handle =
333333+ lookup t handle
334334+335335+let lookup_by_name t name =
336336+ let name_lower = String.lowercase_ascii name in
337337+ let all_contacts = list t in
338338+ let matches = List.filter (fun c ->
339339+ List.exists (fun n -> String.lowercase_ascii n = name_lower)
340340+ (Contact.names c)
341341+ ) all_contacts in
342342+ match matches with
343343+ | [contact] -> contact
344344+ | [] -> failwith ("Contact not found: " ^ name)
345345+ | _ -> failwith ("Ambiguous contact: " ^ name)
346346+332347let find_by_email_at t ~email ~date =
333348 let all = list t in
334349 List.find_opt (fun c ->
+18
sortal/lib/core/sortal_store.mli
···173173174174(** {1 Searching} *)
175175176176+(** [find_by_handle t handle] finds a contact by exact handle match.
177177+178178+ This is an alias for {!lookup} for API compatibility.
179179+180180+ @return [Some contact] if found, [None] if not found *)
181181+val find_by_handle : t -> string -> Contact.t option
182182+176183(** [find_by_name t name] searches for contacts by name.
177184178185 Performs a case-insensitive search through all contacts,
···183190 @raise Not_found if no contacts match the name
184191 @raise Invalid_argument if multiple contacts match the name *)
185192val find_by_name : t -> string -> Contact.t
193193+194194+(** [lookup_by_name t name] searches for contacts by name, raising on failure.
195195+196196+ Like {!find_by_name} but raises [Failure] instead of [Not_found]
197197+ or [Invalid_argument]. This matches the semantics of Bushel's
198198+ original contact lookup.
199199+200200+ @param name The name to search for (case-insensitive)
201201+ @return The matching contact if exactly one match is found
202202+ @raise Failure if no contacts match or multiple contacts match *)
203203+val lookup_by_name : t -> string -> Contact.t
186204187205(** [find_by_name_opt t name] searches for contacts by name, returning an option.
188206
+80-6
sortal/lib/schema/sortal_schema_contact_v1.ml
···7788type contact_kind = Person | Organization | Group | Role
991010+type activitypub_variant =
1111+ | Mastodon
1212+ | Pixelfed
1313+ | PeerTube
1414+ | Other_activitypub of string
1515+1016type service_kind =
1111- | ActivityPub
1717+ | ActivityPub of activitypub_variant
1818+ | Bluesky
1219 | Github
1320 | Git
1414- | Social
2121+ | Twitter
1522 | Photo
1623 | Custom of string
1724···122129let orcid t = t.orcid
123130let feeds t = t.feeds
124131132132+(* Service convenience accessors *)
133133+let github t =
134134+ List.find_opt (fun (s : service) ->
135135+ match s.kind with Some Github -> true | _ -> false
136136+ ) t.services
137137+138138+let github_handle t =
139139+ match github t with
140140+ | Some s -> s.handle
141141+ | None -> None
142142+143143+let twitter t =
144144+ List.find_opt (fun (s : service) ->
145145+ match s.kind with Some Twitter -> true | _ -> false
146146+ ) t.services
147147+148148+let twitter_handle t =
149149+ match twitter t with
150150+ | Some s -> s.handle
151151+ | None -> None
152152+153153+let mastodon t =
154154+ List.find_opt (fun (s : service) ->
155155+ match s.kind with Some (ActivityPub Mastodon) -> true | _ -> false
156156+ ) t.services
157157+158158+let mastodon_handle t =
159159+ match mastodon t with
160160+ | Some s -> s.handle
161161+ | None -> None
162162+163163+let bluesky t =
164164+ List.find_opt (fun (s : service) ->
165165+ match s.kind with Some Bluesky -> true | _ -> false
166166+ ) t.services
167167+168168+let bluesky_handle t =
169169+ match bluesky t with
170170+ | Some s -> s.handle
171171+ | None -> None
172172+125173(* Temporal queries *)
126174let emails_at t ~date =
127175 Sortal_schema_temporal.at_date ~get:(fun (e : email) -> e.range) ~date t.emails
···210258 | "role" -> Some Role
211259 | _ -> None
212260261261+let activitypub_variant_to_string = function
262262+ | Mastodon -> "mastodon"
263263+ | Pixelfed -> "pixelfed"
264264+ | PeerTube -> "peertube"
265265+ | Other_activitypub s -> s
266266+267267+let activitypub_variant_of_string s =
268268+ match String.lowercase_ascii s with
269269+ | "mastodon" -> Mastodon
270270+ | "pixelfed" -> Pixelfed
271271+ | "peertube" -> PeerTube
272272+ | _ -> Other_activitypub s
273273+213274let service_kind_to_string = function
214214- | ActivityPub -> "activitypub"
275275+ | ActivityPub v -> "activitypub:" ^ activitypub_variant_to_string v
276276+ | Bluesky -> "bluesky"
215277 | Github -> "github"
216278 | Git -> "git"
217217- | Social -> "social"
279279+ | Twitter -> "twitter"
218280 | Photo -> "photo"
219281 | Custom s -> s
220282221283let service_kind_of_string s =
222284 match String.lowercase_ascii s with
223223- | "activitypub" -> Some ActivityPub
285285+ | "bluesky" -> Some Bluesky
224286 | "github" -> Some Github
225287 | "git" -> Some Git
226226- | "social" -> Some Social
288288+ | "twitter" -> Some Twitter
227289 | "photo" -> Some Photo
228290 | "" | "custom" -> None
291291+ | s when String.length s > 11 && String.sub s 0 11 = "activitypub" ->
292292+ (* Handle activitypub:variant format *)
293293+ let rest = String.sub s 11 (String.length s - 11) in
294294+ let variant = if rest = "" then Mastodon
295295+ else if String.length rest > 1 && rest.[0] = ':' then
296296+ activitypub_variant_of_string (String.sub rest 1 (String.length rest - 1))
297297+ else Mastodon
298298+ in
299299+ Some (ActivityPub variant)
300300+ | "mastodon" -> Some (ActivityPub Mastodon)
301301+ | "pixelfed" -> Some (ActivityPub Pixelfed)
302302+ | "peertube" -> Some (ActivityPub PeerTube)
229303 | _ -> Some (Custom s)
230304231305let email_type_to_string = function
+41-2
sortal/lib/schema/sortal_schema_contact_v1.mli
···2929 | Group (** Research group, project team *)
3030 | Role (** Generic role email like info@, admin@ *)
31313232+(** ActivityPub service variants. *)
3333+type activitypub_variant =
3434+ | Mastodon (** Mastodon instance *)
3535+ | Pixelfed (** Pixelfed instance *)
3636+ | PeerTube (** PeerTube instance *)
3737+ | Other_activitypub of string (** Other ActivityPub-compatible service *)
3838+3239(** Service kind - categorization of online presence. *)
3340type service_kind =
3434- | ActivityPub (** Mastodon, Pixelfed, PeerTube, etc *)
4141+ | ActivityPub of activitypub_variant (** ActivityPub-compatible services *)
4242+ | Bluesky (** Bluesky / AT Protocol *)
3543 | Github (** GitHub *)
3644 | Git (** GitLab, Gitea, Codeberg, etc *)
3737- | Social (** Twitter/X, LinkedIn, etc *)
4545+ | Twitter (** Twitter/X *)
3846 | Photo (** Immich, Flickr, Instagram, etc *)
3947 | Custom of string (** Other service types *)
4048···204212val orcid : t -> string option
205213val feeds : t -> Sortal_schema_feed.t list option
206214215215+(** {1 Service Convenience Accessors}
216216+217217+ These accessors provide easy access to common service types. *)
218218+219219+(** [github t] returns the GitHub service entry if present. *)
220220+val github : t -> service option
221221+222222+(** [github_handle t] returns the GitHub username if present. *)
223223+val github_handle : t -> string option
224224+225225+(** [twitter t] returns the Twitter/X service entry if present. *)
226226+val twitter : t -> service option
227227+228228+(** [twitter_handle t] returns the Twitter/X username if present. *)
229229+val twitter_handle : t -> string option
230230+231231+(** [mastodon t] returns the Mastodon service entry if present. *)
232232+val mastodon : t -> service option
233233+234234+(** [mastodon_handle t] returns the Mastodon handle if present. *)
235235+val mastodon_handle : t -> string option
236236+237237+(** [bluesky t] returns the Bluesky service entry if present. *)
238238+val bluesky : t -> service option
239239+240240+(** [bluesky_handle t] returns the Bluesky handle if present. *)
241241+val bluesky_handle : t -> string option
242242+207243(** {1 Temporal Queries} *)
208244209245(** [email_at t ~date] returns the primary email valid at [date]. *)
···269305270306val contact_kind_to_string : contact_kind -> string
271307val contact_kind_of_string : string -> contact_kind option
308308+309309+val activitypub_variant_to_string : activitypub_variant -> string
310310+val activitypub_variant_of_string : string -> activitypub_variant
272311273312val service_kind_to_string : service_kind -> string
274313val service_kind_of_string : string -> service_kind option
+5-82
srcsetter/bin/srcsetter.ml
···1515 PERFORMANCE OF THIS SOFTWARE.
1616 *)
17171818-module SC = Srcsetter_cmd
1919-2020-let min_interval = Some (Mtime.Span.of_uint64_ns 1000L)
2121-2222-let stage1 { SC.img_exts; src_dir; _ } =
2323- let filter f = List.exists (Filename.check_suffix ("." ^ f)) img_exts in
2424- let fs = SC.file_seq ~filter src_dir in
2525- let total = Seq.length fs in
2626- Format.printf "[1/3] Scanned %d images from %a.\n%!" total Eio.Path.pp src_dir;
2727- fs
2828-2929-let stage2 ({ SC.max_fibers; dst_dir; _ } as cfg) fs =
3030- let display =
3131- Progress.Display.start
3232- ~config:(Progress.Config.v ~persistent:false ~min_interval ())
3333- (SC.main_bar_heading "[2/3] Processing images..." (Seq.length fs))
3434- in
3535- let [ _; main_rep ] = Progress.Display.reporters display in
3636- let ents = ref [] in
3737- SC.iter_seq_p ~max_fibers
3838- (fun src ->
3939- let ent = SC.process_file cfg (display, main_rep) src in
4040- ents := ent :: !ents)
4141- fs;
4242- Progress.Display.finalise display;
4343- Format.printf "[2/3] Processed %d images to %a.\n%!" (List.length !ents)
4444- Eio.Path.pp dst_dir;
4545- !ents
4646-4747-let stage3 ({ SC.dst_dir; max_fibers; _ } as cfg) ents =
4848- let ents_seq = List.to_seq ents in
4949- let oents = ref [] in
5050- let display =
5151- Progress.Display.start
5252- ~config:(Progress.Config.v ~persistent:false ~min_interval ())
5353- (SC.main_bar_heading "[3/3] Verifying images..." (List.length ents))
5454- in
5555- let [ _; rep ] = Progress.Display.reporters display in
5656- SC.iter_seq_p ~max_fibers
5757- (fun ent ->
5858- let w, h = SC.dims cfg Eio.Path.(dst_dir / Srcsetter.name ent) in
5959- let variants =
6060- Srcsetter.MS.bindings ent.variants
6161- |> List.map (fun (k, _) -> (k, SC.dims cfg Eio.Path.(dst_dir / k)))
6262- |> Srcsetter.MS.of_list
6363- in
6464- rep 1;
6565- oents := { ent with Srcsetter.dims = (w, h); variants } :: !oents)
6666- ents_seq;
6767- Progress.Display.finalise display;
6868- Printf.printf "[3/3] Verified %d generated image sizes.\n%!"
6969- (List.length ents);
7070- !oents
7171-7218let _ =
7319 (* TODO cmdliner *)
7420 Eio_main.run @@ fun env ->
7521 Eio.Switch.run @@ fun _ ->
2222+ let fs = Eio.Stdenv.fs env in
7623 let path_env p =
7777- if String.starts_with ~prefix:"/" p then Eio.(Path.(Stdenv.fs env / p))
7878- else Eio.(Path.(Stdenv.cwd env / p))
2424+ if String.starts_with ~prefix:"/" p then Eio.Path.(fs / p)
2525+ else Eio.Path.(Eio.Stdenv.cwd env / p)
7926 in
8027 let src_dir = path_env Sys.argv.(1) in
8128 let dst_dir = path_env Sys.argv.(2) in
8229 let proc_mgr = Eio.Stdenv.process_mgr env in
8383- let idx_file = "index.json" in
8484- let img_widths =
8585- [ 320; 480; 640; 768; 1024; 1280; 1440; 1600; 1920; 2560; 3840 ]
8686- in
8787- let img_exts = [ "png"; "webp"; "jpeg"; "jpg"; "bmp"; "heic"; "gif" ] in
8888- let img_widths = List.sort (fun a b -> compare b a) img_widths in
8989- let max_fibers = 8 in
9090- let cfg =
9191- {
9292- Srcsetter_cmd.dummy = false;
9393- preserve = true;
9494- proc_mgr;
9595- src_dir;
9696- dst_dir;
9797- idx_file;
9898- img_widths;
9999- img_exts;
100100- max_fibers;
101101- }
102102- in
103103- let fs = stage1 cfg in
104104- let ents = stage2 cfg fs in
105105- let oents = stage3 cfg ents in
106106- let j = Srcsetter.list_to_json oents |> Result.get_ok in
107107- let idx = Eio.Path.(dst_dir / idx_file) in
108108- Eio.Path.save ~append:false ~create:(`Or_truncate 0o644) idx j
3030+ let _entries = Srcsetter_cmd.run ~proc_mgr ~src_dir ~dst_dir () in
3131+ ()
···101101 let output = Process.parse_out proc_mgr Buf_read.take_all args in
102102 Scanf.sscanf output "%d %d" (fun w h -> (w, h))
103103104104+(** [try_dims cfg path] returns [Some (w, h)] if identify succeeds, [None] otherwise. *)
105105+let try_dims cfg path =
106106+ try Some (dims cfg path)
107107+ with _ -> None
108108+109109+(** [file_size path] returns the size of the file in bytes. *)
110110+let file_size path =
111111+ let stat = Path.stat ~follow:true path in
112112+ Optint.Int63.to_int stat.size
113113+114114+(** [is_valid_image cfg path] returns true if the file exists, has non-zero size,
115115+ and identify can read its dimensions. *)
116116+let is_valid_image cfg path =
117117+ Path.is_file path &&
118118+ file_size path > 0 &&
119119+ Option.is_some (try_dims cfg path)
120120+121121+(** [width_from_variant_name name] extracts the width from a variant filename.
122122+123123+ Variant filenames have the form "path/name.WIDTH.webp". Returns [None] for
124124+ base images (no width suffix). *)
125125+let width_from_variant_name name =
126126+ let base = Filename.chop_extension name in (* remove .webp *)
127127+ let parts = String.split_on_char '.' base in
128128+ match List.rev parts with
129129+ | last :: _ -> (
130130+ match int_of_string_opt last with
131131+ | Some w -> Some w
132132+ | None -> None)
133133+ | [] -> None
134134+104135(** [run cfg args] executes a shell command unless in dummy mode. *)
105136let run { dummy; proc_mgr; _ } args =
106137 if not dummy then Process.run proc_mgr args
···166197 let dst = Path.(dst_dir / dst_file) in
167198 (src_file, dst_file, w, needs_conversion ~preserve dst)
168199169169-(** [calc_needed cfg ~img_widths ~w src] computes which conversions are needed.
170170-171171- Returns [(base, variants)] where each is tagged with [`Exists] or [`Todo]. *)
172172-let calc_needed { src_dir; dst_dir; preserve; _ } ~img_widths ~w src =
173173- let check_dst fname tw =
174174- let dst = Path.(dst_dir / fname) in
175175- let ent = (src, dst, tw) in
176176- if preserve && Path.is_file dst then `Exists ent else `Todo ent
177177- in
178178- let file = relativize_path src_dir src in
179179- let base_name = Filename.chop_extension file in
180180- let base = check_dst (Printf.sprintf "%s.webp" base_name) w in
181181- let variants =
182182- List.filter_map
183183- (fun tw ->
184184- if tw <= w then Some (check_dst (Printf.sprintf "%s.%d.webp" base_name tw) tw)
185185- else None)
186186- img_widths
187187- in
188188- (base, variants)
189189-190200(** {1 Progress Bar Rendering} *)
191201192202(** [main_bar total] creates a progress bar for [total] items. *)
···269279 main_rep 1
270280 end;
271281 ent
282282+283283+(** {1 Pipeline Execution} *)
284284+285285+let min_interval = Some (Mtime.Span.of_uint64_ns 1000L)
286286+287287+(** [stage1 cfg] scans for images in the source directory.
288288+289289+ Returns a sequence of file paths matching the configured extensions. *)
290290+let stage1 { img_exts; src_dir; _ } =
291291+ let filter f = List.exists (Filename.check_suffix ("." ^ f)) img_exts in
292292+ let fs = file_seq ~filter src_dir in
293293+ let total = Seq.length fs in
294294+ Format.printf "[1/3] Scanned %d images from %a.\n%!" total Path.pp src_dir;
295295+ fs
296296+297297+(** [stage2 cfg fs] processes images, converting to WebP at multiple sizes.
298298+299299+ @return List of {!Srcsetter.t} entries with placeholder dimensions. *)
300300+let stage2 ({ max_fibers; dst_dir; _ } as cfg) fs =
301301+ let display =
302302+ Progress.Display.start
303303+ ~config:(Progress.Config.v ~persistent:false ~min_interval ())
304304+ (main_bar_heading "[2/3] Processing images..." (Seq.length fs))
305305+ in
306306+ let [ _; main_rep ] = Progress.Display.reporters display in
307307+ let ents = ref [] in
308308+ iter_seq_p ~max_fibers
309309+ (fun src ->
310310+ let ent = process_file cfg (display, main_rep) src in
311311+ ents := ent :: !ents)
312312+ fs;
313313+ Progress.Display.finalise display;
314314+ Format.printf "[2/3] Processed %d images to %a.\n%!" (List.length !ents)
315315+ Path.pp dst_dir;
316316+ !ents
317317+318318+(** [stage3 cfg ents] verifies generated images and records their dimensions.
319319+320320+ Regenerates any images that have zero length or fail identify validation.
321321+322322+ @return List of {!Srcsetter.t} entries with actual dimensions. *)
323323+let stage3 ({ src_dir; dst_dir; max_fibers; _ } as cfg) ents =
324324+ let ents_seq = List.to_seq ents in
325325+ let oents = ref [] in
326326+ let regenerated = ref 0 in
327327+ let display =
328328+ Progress.Display.start
329329+ ~config:(Progress.Config.v ~persistent:false ~min_interval ())
330330+ (main_bar_heading "[3/3] Verifying images..." (List.length ents))
331331+ in
332332+ let [ _; rep ] = Progress.Display.reporters display in
333333+ iter_seq_p ~max_fibers
334334+ (fun ent ->
335335+ let src_path = Path.(src_dir / Srcsetter.origin ent) in
336336+ let orig_w, _ = dims cfg src_path in
337337+ (* Verify and regenerate base image if needed *)
338338+ let base_path = Path.(dst_dir / Srcsetter.name ent) in
339339+ if not (is_valid_image cfg base_path) then begin
340340+ incr regenerated;
341341+ convert cfg (Srcsetter.origin ent, Srcsetter.name ent, orig_w)
342342+ end;
343343+ let w, h = dims cfg base_path in
344344+ (* Verify and regenerate variants if needed *)
345345+ let variants =
346346+ Srcsetter.MS.bindings ent.variants
347347+ |> List.map (fun (k, _) ->
348348+ let variant_path = Path.(dst_dir / k) in
349349+ if not (is_valid_image cfg variant_path) then begin
350350+ incr regenerated;
351351+ let target_w = Option.value (width_from_variant_name k) ~default:orig_w in
352352+ convert cfg (Srcsetter.origin ent, k, target_w)
353353+ end;
354354+ (k, dims cfg variant_path))
355355+ |> Srcsetter.MS.of_list
356356+ in
357357+ rep 1;
358358+ oents := { ent with Srcsetter.dims = (w, h); variants } :: !oents)
359359+ ents_seq;
360360+ Progress.Display.finalise display;
361361+ if !regenerated > 0 then
362362+ Printf.printf "[3/3] Verified %d images, regenerated %d invalid outputs.\n%!"
363363+ (List.length ents) !regenerated
364364+ else
365365+ Printf.printf "[3/3] Verified %d generated image sizes.\n%!"
366366+ (List.length ents);
367367+ !oents
368368+369369+(** [run ~proc_mgr ~src_dir ~dst_dir ()] runs the full srcsetter pipeline.
370370+371371+ Scans [src_dir] for images, converts them to WebP format at multiple
372372+ responsive sizes, and writes an index file to [dst_dir].
373373+374374+ @param proc_mgr Eio process manager for running ImageMagick
375375+ @param src_dir Source directory containing original images
376376+ @param dst_dir Destination directory for generated images
377377+ @param idx_file Name of the index file (default ["index.json"])
378378+ @param img_widths List of target widths (default common responsive breakpoints)
379379+ @param img_exts List of extensions to process (default common image formats)
380380+ @param max_fibers Maximum concurrent operations (default 8)
381381+ @param dummy When true, skip actual conversions (default false)
382382+ @param preserve When true, skip existing files (default true)
383383+ @return List of {!Srcsetter.t} entries describing generated images *)
384384+let run
385385+ ~proc_mgr
386386+ ~src_dir
387387+ ~dst_dir
388388+ ?(idx_file = "index.json")
389389+ ?(img_widths = [ 320; 480; 640; 768; 1024; 1280; 1440; 1600; 1920; 2560; 3840 ])
390390+ ?(img_exts = [ "png"; "webp"; "jpeg"; "jpg"; "bmp"; "heic"; "gif" ])
391391+ ?(max_fibers = 8)
392392+ ?(dummy = false)
393393+ ?(preserve = true)
394394+ ()
395395+ =
396396+ let img_widths = List.sort (fun a b -> compare b a) img_widths in
397397+ let cfg =
398398+ {
399399+ dummy;
400400+ preserve;
401401+ proc_mgr;
402402+ src_dir;
403403+ dst_dir;
404404+ idx_file;
405405+ img_widths;
406406+ img_exts;
407407+ max_fibers;
408408+ }
409409+ in
410410+ let fs = stage1 cfg in
411411+ let ents = stage2 cfg fs in
412412+ let oents = stage3 cfg ents in
413413+ let j = Srcsetter.list_to_json oents |> Result.get_ok in
414414+ let idx = Path.(dst_dir / idx_file) in
415415+ Path.save ~append:false ~create:(`Or_truncate 0o644) idx j;
416416+ oents
+122
srcsetter/lib/srcsetter_cmd.mli
···11+(* Copyright (c) 2024, Anil Madhavapeddy <anil@recoil.org>
22+33+ Permission to use, copy, modify, and/or distribute this software for
44+ any purpose with or without fee is hereby granted, provided that the
55+ above copyright notice and this permission notice appear in all
66+ copies.
77+88+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL
99+ WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED
1010+ WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE
1111+ AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL
1212+ DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA
1313+ OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
1414+ TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
1515+ PERFORMANCE OF THIS SOFTWARE. *)
1616+1717+(** Command-line image processing operations for srcsetter.
1818+1919+ This module provides the core image processing pipeline including
2020+ file discovery, image conversion, and progress reporting.
2121+2222+ {1 High-Level Pipeline}
2323+2424+ The simplest way to use this module is via {!run}, which executes
2525+ the complete pipeline:
2626+2727+ {[
2828+ Srcsetter_cmd.run
2929+ ~proc_mgr:(Eio.Stdenv.process_mgr env)
3030+ ~src_dir:Eio.Path.(fs / "images/originals")
3131+ ~dst_dir:Eio.Path.(fs / "images/output")
3232+ ()
3333+ ]}
3434+3535+ {1 Configuration} *)
3636+3737+(** Configuration for the image processing pipeline. *)
3838+type ('a, 'b) config = {
3939+ dummy : bool; (** When true, skip actual image conversion (dry run) *)
4040+ preserve : bool; (** When true, skip conversion if destination exists *)
4141+ proc_mgr : 'a Eio.Process.mgr; (** Eio process manager for running ImageMagick *)
4242+ src_dir : 'b Eio.Path.t; (** Source directory containing original images *)
4343+ dst_dir : 'b Eio.Path.t; (** Destination directory for generated images *)
4444+ img_widths : int list; (** List of target widths for responsive variants *)
4545+ img_exts : string list; (** File extensions to process (e.g., ["jpg"; "png"]) *)
4646+ idx_file : string; (** Name of the JSON index file to generate *)
4747+ max_fibers : int; (** Maximum concurrent conversion operations *)
4848+}
4949+5050+(** {1 File Operations} *)
5151+5252+val file_seq :
5353+ filter:(string -> bool) ->
5454+ ([> Eio.Fs.dir_ty ] as 'a) Eio.Path.t ->
5555+ 'a Eio.Path.t Seq.t
5656+(** [file_seq ~filter path] recursively enumerates files in [path].
5757+5858+ Returns a sequence of file paths where [filter filename] is true.
5959+ Directories are traversed depth-first. *)
6060+6161+val iter_seq_p : ?max_fibers:int -> ('a -> unit) -> 'a Seq.t -> unit
6262+(** [iter_seq_p ?max_fibers fn seq] iterates [fn] over [seq] in parallel.
6363+6464+ @param max_fibers Optional limit on concurrent fibers. Must be positive.
6565+ @raise Invalid_argument if [max_fibers] is not positive. *)
6666+6767+(** {1 Image Operations} *)
6868+6969+val dims : ('a, 'b) config -> 'b Eio.Path.t -> int * int
7070+(** [dims cfg path] returns the [(width, height)] dimensions of an image.
7171+7272+ Uses ImageMagick's [identify] command to read image metadata. *)
7373+7474+val convert : ('a, 'b) config -> string * string * int -> unit
7575+(** [convert cfg (src, dst, size)] converts an image to WebP format.
7676+7777+ Creates the destination directory if needed, then uses ImageMagick
7878+ to resize and convert the image with auto-orientation.
7979+8080+ @param src Source filename relative to [cfg.src_dir]
8181+ @param dst Destination filename relative to [cfg.dst_dir]
8282+ @param size Target width in pixels *)
8383+8484+val convert_pdf :
8585+ ('a, 'b) config ->
8686+ size:string ->
8787+ dst:'b Eio.Path.t ->
8888+ src:'b Eio.Path.t ->
8989+ unit
9090+(** [convert_pdf cfg ~size ~dst ~src] converts a PDF's first page to an image.
9191+9292+ Renders at 300 DPI, crops the top half, and resizes to the target width. *)
9393+9494+(** {1 Pipeline Execution} *)
9595+9696+val run :
9797+ proc_mgr:'a Eio.Process.mgr ->
9898+ src_dir:'b Eio.Path.t ->
9999+ dst_dir:'b Eio.Path.t ->
100100+ ?idx_file:string ->
101101+ ?img_widths:int list ->
102102+ ?img_exts:string list ->
103103+ ?max_fibers:int ->
104104+ ?dummy:bool ->
105105+ ?preserve:bool ->
106106+ unit ->
107107+ Srcsetter.t list
108108+(** [run ~proc_mgr ~src_dir ~dst_dir ()] runs the full srcsetter pipeline.
109109+110110+ Scans [src_dir] for images, converts them to WebP format at multiple
111111+ responsive sizes, and writes an index file to [dst_dir].
112112+113113+ @param proc_mgr Eio process manager for running ImageMagick
114114+ @param src_dir Source directory containing original images
115115+ @param dst_dir Destination directory for generated images
116116+ @param idx_file Name of the index file (default ["index.json"])
117117+ @param img_widths List of target widths (default common responsive breakpoints)
118118+ @param img_exts List of extensions to process (default common image formats)
119119+ @param max_fibers Maximum concurrent operations (default 8)
120120+ @param dummy When true, skip actual conversions (default false)
121121+ @param preserve When true, skip existing files (default true)
122122+ @return List of {!Srcsetter.t} entries describing generated images *)