···6060| [**ocaml-zulip**](https://tangled.org/anil.recoil.org/ocaml-zulip.git) | zulip | OCaml bindings for the Zulip REST API with bot framework |
6161| [**odoc-xo**](https://tangled.org/anil.recoil.org/odoc-xo.git) | odoc-xo | Transform odoc HTML to use x-ocaml web components |
6262| [**poe**](https://tangled.org/anil.recoil.org/poe.git) | poe | Zulip bot for broadcasting monorepo changes with Claude integration |
6363+| [**sortal**](https://tangled.org/@anil.recoil.org/sortal.git) | sortal | Contact metadata management with XDG storage and versioned schemas |
6364| [**srcsetter**](https://tangled.org/anil.recoil.org/srcsetter.git) | srcsetter | Image srcset library for webp images |
6465| | srcsetter-cmd | Image processing tool to generate responsive images |
6566| [**xdge**](https://tangled.sh/@anil.recoil.org/xdge.git) | xdge | XDG Base Directory Specification support for Eio |
66676768---
68696969-_Generated by monopam. 59 packages from 41 repositories._
7070+_Generated by monopam. 60 packages from 42 repositories._
+367-256
monopam/bin/main.ml
···5555 | Ok config -> f config
5656 | Error msg ->
5757 Fmt.epr "Error loading config: %s@." msg;
5858- Fmt.epr "Run 'monopam verse init' first to create a workspace.@.";
5858+ Fmt.epr "Run 'monopam init' first to create a workspace.@.";
5959 `Error (false, "configuration error")
60606161(* Status command *)
···115115 let proc = Eio.Stdenv.process_mgr env in
116116 match Monopam.status ~proc ~fs ~config () with
117117 | Ok statuses ->
118118- Fmt.pr "%a" Monopam.Status.pp_summary statuses;
118118+ (* Load sources.toml for origin indicators *)
119119+ let sources =
120120+ let mono_path = Monopam.Config.Paths.monorepo config in
121121+ let sources_path = Fpath.(mono_path / "sources.toml") in
122122+ match Monopam.Sources_registry.load ~fs:(fs :> _ Eio.Path.t) sources_path with
123123+ | Ok s -> Some s
124124+ | Error _ -> None
125125+ in
126126+ Fmt.pr "%a" (Monopam.Status.pp_summary ?sources) statuses;
119127 (* Check for unregistered opam files *)
120128 (match Monopam.discover_packages ~fs ~config () with
121129 | Ok pkgs ->
···438446 let info = Cmd.info "opam" ~doc ~man in
439447 Cmd.group info [ opam_sync_cmd ]
440448441441-(* Verse commands *)
442442-443443-(* Helper to load verse config from XDG *)
444444-let with_verse_config env f =
445445- let fs = Eio.Stdenv.fs env in
446446- match Monopam.Verse_config.load ~fs () with
447447- | Ok config -> f config
448448- | Error msg ->
449449- Fmt.epr "Error loading opamverse config: %s@." msg;
450450- Fmt.epr "Run 'monopam verse init' to create a workspace.@.";
451451- `Error (false, "configuration error")
449449+(* Init command - initialize a new monopam workspace *)
452450453453-let verse_root_arg =
451451+let init_root_arg =
454452 let doc =
455453 "Path to workspace root directory. Defaults to current directory."
456454 in
···459457 & opt (some (conv (Fpath.of_string, Fpath.pp))) None
460458 & info [ "root" ] ~docv:"PATH" ~doc)
461459462462-let verse_handle_arg =
463463- let doc = "Tangled handle (e.g., alice.bsky.social)" in
460460+let init_handle_arg =
461461+ let doc = "Your handle (e.g., alice.bsky.social)" in
464462 Arg.(
465463 required & opt (some string) None & info [ "handle" ] ~docv:"HANDLE" ~doc)
466464467467-let verse_handle_opt_pos_arg =
468468- let doc =
469469- "Tangled handle. If not specified, operates on all tracked members."
470470- in
471471- Arg.(value & pos 0 (some string) None & info [] ~docv:"HANDLE" ~doc)
472472-473473-let verse_init_cmd =
474474- let doc = "Initialize a new opamverse workspace" in
465465+let init_cmd =
466466+ let doc = "Initialize a new monopam workspace" in
475467 let man =
476468 [
477469 `S Manpage.s_description;
478470 `P
479479- "Creates a new opamverse workspace for federated monorepo \
480480- collaboration. An opamverse workspace lets you browse and track other \
481481- developers' monorepos alongside your own.";
471471+ "Creates a new monopam workspace for monorepo development. The workspace \
472472+ lets you manage your own monorepo and optionally browse and track other \
473473+ developers' monorepos.";
482474 `S "WORKSPACE STRUCTURE";
483475 `P
484476 "The init command creates the following directory structure at the \
···503495 verse = \"verse\"\n\n\
504496 [identity]\n\
505497 handle = \"yourname.bsky.social\"";
506506- `S "AUTHENTICATION";
507507- `P "Before running init, you must authenticate with the tangled network:";
508508- `Pre "tangled auth login";
498498+ `S "HANDLE VALIDATION";
509499 `P
510510- "The handle you provide is validated against the AT Protocol identity \
511511- system to ensure it exists and you are authenticated.";
500500+ "The handle you provide identifies you in the community. \
501501+ It should be a valid domain name (e.g., yourname.bsky.social or \
502502+ your-domain.com).";
512503 `S "REGISTRY";
513504 `P
514514- "The opamverse registry is a git repository containing an \
515515- opamverse.toml file that lists community members and their monorepo \
516516- URLs. The default registry is at: \
517517- https://tangled.org/eeg.cl.cam.ac.uk/opamverse";
505505+ "The registry is a git repository containing an opamverse.toml file \
506506+ that lists community members and their monorepo URLs. The default \
507507+ registry is at: https://tangled.org/eeg.cl.cam.ac.uk/opamverse";
518508 `S Manpage.s_examples;
519519- `P "Initialize a workspace in ~/tangled:";
520520- `Pre "cd ~/tangled\nmonopam verse init --handle alice.bsky.social";
509509+ `P "Initialize a workspace in the current directory:";
510510+ `Pre "monopam init --handle alice.bsky.social";
521511 `P "Initialize with explicit root path:";
522522- `Pre "monopam verse init --root ~/my-workspace --handle alice.bsky.social";
512512+ `Pre "monopam init --root ~/my-workspace --handle alice.bsky.social";
523513 ]
524514 in
525515 let info = Cmd.info "init" ~doc ~man in
···539529 in
540530 match Monopam.Verse.init ~proc ~fs ~root ~handle () with
541531 | Ok () ->
542542- Fmt.pr "Monoverse workspace initialized at %a@." Fpath.pp root;
532532+ Fmt.pr "Workspace initialized at %a@." Fpath.pp root;
543533 `Ok ()
544534 | Error e ->
545535 Fmt.epr "Error: %a@." Monopam.Verse.pp_error_with_hint e;
546536 `Error (false, "init failed")
547537 in
548538 Cmd.v info
549549- Term.(ret (const run $ verse_root_arg $ verse_handle_arg $ logging_term))
539539+ Term.(ret (const run $ init_root_arg $ init_handle_arg $ logging_term))
540540+541541+(* Verse commands *)
542542+543543+(* Helper to load verse config from XDG *)
544544+let with_verse_config env f =
545545+ let fs = Eio.Stdenv.fs env in
546546+ match Monopam.Verse_config.load ~fs () with
547547+ | Ok config -> f config
548548+ | Error msg ->
549549+ Fmt.epr "Error loading opamverse config: %s@." msg;
550550+ Fmt.epr "Run 'monopam init' to create a workspace.@.";
551551+ `Error (false, "configuration error")
550552551553let verse_members_cmd =
552554 let doc = "List registry members" in
···602604 in
603605 Cmd.v info Term.(ret (const run $ logging_term))
604606605605-let verse_pull_cmd =
606606- let doc = "Sync all registry members to local workspace" in
607607- let man =
608608- [
609609- `S Manpage.s_description;
610610- `P
611611- "Clones or pulls all members from the opamverse registry. For each \
612612- member, syncs both their monorepo and opam overlay repository.";
613613- `S "WHAT IT DOES";
614614- `P "For each member in the registry:";
615615- `I ("1.", "Clones or pulls their monorepo to verse/<handle>/");
616616- `I ("2.", "Clones or pulls their opam repo to verse/<handle>-opam/");
617617- `S "SCOPE";
618618- `P "With a handle argument: syncs only that specific member.";
619619- `P "Without arguments: syncs all members in the registry.";
620620- `S "ERROR HANDLING";
621621- `P
622622- "If a sync fails for one member (e.g., network error), the error is \
623623- reported but other members are still synced.";
624624- `S Manpage.s_examples;
625625- `Pre
626626- "# Sync all registry members\n\
627627- monopam verse pull\n\n\
628628- # Sync a specific member\n\
629629- monopam verse pull alice.bsky.social\n\n\
630630- # Browse their code\n\
631631- ls verse/alice.bsky.social/";
632632- ]
633633- in
634634- let info = Cmd.info "pull" ~doc ~man in
635635- let run handle () =
636636- Eio_main.run @@ fun env ->
637637- with_verse_config env @@ fun config ->
638638- let fs = Eio.Stdenv.fs env in
639639- let proc = Eio.Stdenv.process_mgr env in
640640- match Monopam.Verse.pull ~proc ~fs ~config ?handle () with
641641- | Ok () ->
642642- Fmt.pr "Sync completed.@.";
643643- `Ok ()
644644- | Error e ->
645645- Fmt.epr "Error: %a@." Monopam.Verse.pp_error_with_hint e;
646646- `Error (false, "pull failed")
647647- in
648648- Cmd.v info Term.(ret (const run $ verse_handle_opt_pos_arg $ logging_term))
649649-650650-let verse_sync_cmd =
651651- let doc = "Sync the workspace" in
652652- let man =
653653- [
654654- `S Manpage.s_description;
655655- `P
656656- "Synchronizes your entire opamverse workspace with the latest upstream \
657657- changes. This is the command to run regularly to stay up to date.";
658658- `S "WHAT IT DOES";
659659- `P "The sync command performs two operations:";
660660- `I
661661- ( "1.",
662662- "Updates the registry: git pull in \
663663- ~/.local/share/monopam/opamverse-registry/" );
664664- `I ("2.", "Pulls all tracked members: git pull in each verse/<handle>/");
665665- `S "USE CASES";
666666- `P "Run sync when you want to:";
667667- `I ("-", "See if any new members have joined the community");
668668- `I ("-", "Get the latest code from all tracked members");
669669- `I ("-", "Catch up after being away for a while");
670670- `S "COMPARISON WITH PULL";
671671- `P
672672- "'verse sync' updates the registry AND pulls members. 'verse pull' \
673673- only pulls members (skips registry update).";
674674- `S Manpage.s_examples;
675675- `Pre
676676- "# Daily sync routine\n\
677677- cd ~/tangled\n\
678678- monopam verse sync\n\
679679- monopam verse status";
680680- ]
681681- in
682682- let info = Cmd.info "sync" ~doc ~man in
683683- let run () =
684684- Eio_main.run @@ fun env ->
685685- with_verse_config env @@ fun config ->
686686- let fs = Eio.Stdenv.fs env in
687687- let proc = Eio.Stdenv.process_mgr env in
688688- match Monopam.Verse.sync ~proc ~fs ~config () with
689689- | Ok () ->
690690- Fmt.pr "Sync completed.@.";
691691- `Ok ()
692692- | Error e ->
693693- Fmt.epr "Error: %a@." Monopam.Verse.pp_error_with_hint e;
694694- `Error (false, "sync failed")
695695- in
696696- Cmd.v info Term.(ret (const run $ logging_term))
697697-698607let verse_fork_cmd =
699608 let doc = "Fork a package from a verse member's repository" in
700609 let man =
···782691 upstream = Some result.upstream_url;
783692 branch = None;
784693 reason = Some (Fmt.str "Forked from %s" result.source_handle);
694694+ origin = Some Join; (* Forked from verse = joined *)
785695 } in
786696 let sources = Monopam.Sources_registry.add sources ~subtree:result.subtree_name entry in
787697 (match Monopam.Sources_registry.save ~fs:(fs :> _ Eio.Path.t) sources_path sources with
···804714 Cmd.v info Term.(ret (const run $ package_arg $ from_arg $ url_arg $ dry_run_arg $ logging_term))
805715806716let verse_cmd =
807807- let doc = "Federated monorepo collaboration" in
717717+ let doc = "Verse member operations" in
808718 let man =
809719 [
810720 `S Manpage.s_description;
811721 `P
812812- "The opamverse system enables federated collaboration across multiple \
813813- developers' monorepos. Each developer maintains their own monorepo \
814814- (managed by standard monopam commands), and can track other \
815815- developers' monorepos for code browsing, learning, and collaboration.";
816816- `P
817817- "Members are identified by tangled handles - decentralized identities \
818818- from the AT Protocol network (the same system used by Bluesky).";
819819- `S "QUICK START FOR NEW USERS";
820820- `P "Run these commands in order to get started:";
821821- `Pre
822822- "# Step 1: Authenticate with tangled (one-time setup)\n\
823823- tangled auth login\n\n\
824824- # Step 2: Create and initialize your workspace\n\
825825- mkdir ~/tangled && cd ~/tangled\n\
826826- monopam verse init --handle yourname.bsky.social\n\n\
827827- # Step 3: Sync all community members\n\
828828- monopam verse pull\n\n\
829829- # Step 4: Browse their code\n\
830830- ls verse/\n\
831831- cd verse/alice.bsky.social && dune build\n\n\
832832- # Step 5: Keep everything updated (run daily/weekly)\n\
833833- monopam verse sync";
834834- `S "KEY CONCEPTS";
835835- `I
836836- ( "Workspace",
837837- "A directory containing your monorepo plus all registry members' \
838838- repos" );
839839- `I
840840- ( "Registry",
841841- "A git repository listing community members and their repo URLs" );
842842- `I
843843- ( "Handle",
844844- "A tangled identity like 'alice.bsky.social' validated via AT \
845845- Protocol" );
846846- `S "WORKSPACE STRUCTURE";
847847- `P "An opamverse workspace has this layout:";
848848- `Pre
849849- "~/tangled/ # workspace root\n\
850850- ├── mono/ # YOUR monorepo\n\
851851- ├── src/ # YOUR fork checkouts\n\
852852- ├── opam-repo/ # YOUR opam overlay\n\
853853- └── verse/\n\
854854- \ ├── alice.bsky.social/ # Alice's monorepo\n\
855855- \ ├── alice.bsky.social-opam/ # Alice's opam overlay\n\
856856- \ ├── bob.example.com/ # Bob's monorepo\n\
857857- \ └── bob.example.com-opam/ # Bob's opam overlay";
858858- `P "Configuration and data are stored in XDG directories:";
859859- `Pre
860860- "~/.config/monopam/\n\
861861- └── opamverse.toml # workspace configuration\n\n\
862862- ~/.local/share/monopam/\n\
863863- └── opamverse-registry/ # cloned registry git repo";
864864- `S "COMMAND FLOW";
865865- `P "The expected sequence of commands for typical workflows:";
866866- `P "$(b,First-time setup) (once per machine):";
867867- `Pre
868868- "tangled auth login # authenticate\n\
869869- monopam verse init --handle you.bsky.social # create workspace";
870870- `P "$(b,Syncing all members):";
871871- `Pre
872872- "monopam verse pull # clone/pull all \
873873- members\n\
874874- monopam verse status # check status";
875875- `P "$(b,Daily maintenance):";
876876- `Pre
877877- "monopam verse sync # update everything\n\
878878- monopam verse status # check for changes";
879879- `P "$(b,Working in your own monorepo):";
880880- `Pre
881881- "cd ~/tangled/mono\n\
882882- monopam pull # fetch upstream \
883883- changes\n\
884884- # ... make edits ...\n\
885885- monopam push # export to checkouts";
886886- `S "INTEGRATION WITH MONOPAM";
722722+ "Commands for working with verse community members. The verse system \
723723+ enables federated collaboration across multiple developers' monorepos.";
887724 `P
888888- "The verse system complements standard monopam commands. Your mono/ \
889889- directory works exactly like a normal monopam-managed monorepo:";
890890- `Pre
891891- "# Work in your monorepo\n\
892892- cd ~/tangled/mono\n\
893893- monopam status\n\
894894- monopam pull\n\
895895- # ... make changes ...\n\
896896- monopam push";
725725+ "Members are identified by handles - typically domain names like \
726726+ 'yourname.bsky.social' or 'your-domain.com'.";
727727+ `S "NOTE";
897728 `P
898898- "The verse/ directories are for reading and learning from others' \
899899- code. You generally don't push to them (unless you're a \
900900- collaborator).";
901901- `S "REGISTRY FORMAT";
902902- `P "The registry is a git repository containing opamverse.toml:";
903903- `Pre
904904- "[registry]\n\
905905- name = \"tangled-community\"\n\n\
906906- [[members]]\n\
907907- handle = \"alice.bsky.social\"\n\
908908- monorepo = \"https://github.com/alice/mono\"";
909909- `P "Default registry: https://tangled.org/eeg.cl.cam.ac.uk/opamverse";
910910- `S "COMMANDS REFERENCE";
911911- `I ("init", "Create a new workspace with config and directories");
912912- `I ("status", "Show members and their git status");
913913- `I ("members", "List all members in the registry");
914914- `I ("pull [<handle>]", "Clone/pull all members (or specific member)");
915915- `I ("sync", "Update registry and pull all members");
729729+ "The $(b,monopam init) command creates your workspace and \
730730+ $(b,monopam sync) automatically syncs verse members. These commands \
731731+ are for additional verse-specific operations.";
732732+ `S "COMMANDS";
733733+ `I ("members", "List all members in the community registry");
916734 `I ("fork <pkg> --from <handle> --url <url>", "Fork a package from a verse member");
917917- `S "AUTHENTICATION";
918918- `P
919919- "Handle validation uses the AT Protocol identity system. The tangled \
920920- CLI stores session credentials that monopam verse commands reuse.";
921921- `P "If you see 'Not authenticated', run:";
922922- `Pre "tangled auth login";
735735+ `S Manpage.s_examples;
736736+ `P "List all community members:";
737737+ `Pre "monopam verse members";
738738+ `P "Fork a package from another member:";
739739+ `Pre "monopam verse fork cohttp --from avsm.bsky.social --url git@github.com:me/cohttp.git";
923740 ]
924741 in
925742 let info = Cmd.info "verse" ~doc ~man in
926743 Cmd.group info
927744 [
928928- verse_init_cmd;
929745 verse_members_cmd;
930930- verse_pull_cmd;
931931- verse_sync_cmd;
932746 verse_fork_cmd;
933747 ]
934748···14591273 in
14601274 Cmd.v info Term.(ret (const run $ path_arg $ url_arg $ logging_term))
1461127512761276+(* Fork command *)
12771277+12781278+let fork_cmd =
12791279+ let doc = "Fork a monorepo subtree into its own repository" in
12801280+ let man =
12811281+ [
12821282+ `S Manpage.s_description;
12831283+ `P
12841284+ "Splits a monorepo subdirectory into its own git repository. This \
12851285+ extracts the commit history for the subtree and creates a standalone \
12861286+ repository in src/<name>/.";
12871287+ `S "WHAT IT DOES";
12881288+ `P "The fork command:";
12891289+ `I ("1.", "Validates mono/<name>/ exists as a subtree");
12901290+ `I ("2.", "Uses $(b,git subtree split) to extract history");
12911291+ `I ("3.", "Creates a new git repo at src/<name>/");
12921292+ `I ("4.", "Pushes the extracted history to the new repo");
12931293+ `I ("5.", "Updates sources.toml with $(b,origin = \"fork\")");
12941294+ `I ("6.", "Auto-discovers packages from .opam files");
12951295+ `S "AFTER FORKING";
12961296+ `P "After forking, the subtree will be tracked via src/<name>/:";
12971297+ `I ("1.", "Make changes in mono/<name>/ as usual");
12981298+ `I ("2.", "Run $(b,monopam sync) to push changes to src/<name>/");
12991299+ `I ("3.", "If you provided a URL, push to remote: $(b,cd src/<name> && git push)");
13001300+ `S Manpage.s_examples;
13011301+ `P "Fork a subtree with local-only repo:";
13021302+ `Pre "monopam fork my-lib";
13031303+ `P "Fork with a remote push URL:";
13041304+ `Pre "monopam fork my-lib git@github.com:me/my-lib.git";
13051305+ `P "Preview what would be done:";
13061306+ `Pre "monopam fork my-lib --dry-run";
13071307+ ]
13081308+ in
13091309+ let info = Cmd.info "fork" ~doc ~man in
13101310+ let name_arg =
13111311+ let doc = "Name of the subtree to fork (directory name under mono/)" in
13121312+ Arg.(required & pos 0 (some string) None & info [] ~docv:"NAME" ~doc)
13131313+ in
13141314+ let url_arg =
13151315+ let doc = "Optional remote URL to add as 'origin' for pushing" in
13161316+ Arg.(value & pos 1 (some string) None & info [] ~docv:"URL" ~doc)
13171317+ in
13181318+ let dry_run_arg =
13191319+ let doc = "Show what would be done without making changes" in
13201320+ Arg.(value & flag & info [ "dry-run"; "n" ] ~doc)
13211321+ in
13221322+ let run name url dry_run () =
13231323+ Eio_main.run @@ fun env ->
13241324+ with_verse_config env @@ fun config ->
13251325+ let fs = Eio.Stdenv.fs env in
13261326+ let proc = Eio.Stdenv.process_mgr env in
13271327+ match Monopam.Fork_join.fork ~proc ~fs ~config ~name ?push_url:url ~dry_run () with
13281328+ | Ok result ->
13291329+ if dry_run then begin
13301330+ Fmt.pr "Would fork subtree '%s':@." result.name;
13311331+ Fmt.pr " Packages: %a@." Fmt.(list ~sep:(any ", ") string) result.packages_created;
13321332+ Fmt.pr " Destination: %a@." Fpath.pp result.src_path;
13331333+ match url with
13341334+ | Some u -> Fmt.pr " Push URL: %s@." u
13351335+ | None -> ()
13361336+ end else begin
13371337+ Fmt.pr "%a@." Monopam.Fork_join.pp_fork_result result;
13381338+ Fmt.pr "@.Next steps:@.";
13391339+ Fmt.pr " 1. Review the new repo: cd src/%s@." result.name;
13401340+ match url with
13411341+ | Some _ -> Fmt.pr " 2. Push to remote: git push -u origin main@."
13421342+ | None -> Fmt.pr " 2. Add a remote: git remote add origin <url>@."
13431343+ end;
13441344+ `Ok ()
13451345+ | Error e ->
13461346+ Fmt.epr "Error: %a@." Monopam.Fork_join.pp_error_with_hint e;
13471347+ `Error (false, "fork failed")
13481348+ in
13491349+ Cmd.v info Term.(ret (const run $ name_arg $ url_arg $ dry_run_arg $ logging_term))
13501350+13511351+(* Join command *)
13521352+13531353+let join_cmd =
13541354+ let doc = "Bring an external repository into the monorepo" in
13551355+ let man =
13561356+ [
13571357+ `S Manpage.s_description;
13581358+ `P
13591359+ "Clones an external git repository and adds it as a subtree in the \
13601360+ monorepo. This is the inverse of $(b,monopam fork).";
13611361+ `S "WHAT IT DOES";
13621362+ `P "The join command:";
13631363+ `I ("1.", "Derives subtree name from URL (or uses --as)");
13641364+ `I ("2.", "Validates mono/<name>/ does not exist");
13651365+ `I ("3.", "Clones the repository to src/<name>/");
13661366+ `I ("4.", "Uses $(b,git subtree add) to bring into monorepo");
13671367+ `I ("5.", "Updates sources.toml with $(b,origin = \"join\")");
13681368+ `I ("6.", "Auto-discovers packages from .opam files");
13691369+ `S "JOINING FROM VERSE";
13701370+ `P "To join a package from a verse member, use $(b,--from):";
13711371+ `Pre "monopam join --from avsm.bsky.social --url git@github.com:me/cohttp.git cohttp";
13721372+ `P "This will:";
13731373+ `I ("-", "Look up the package in their opam-repo");
13741374+ `I ("-", "Find all packages from the same git repository");
13751375+ `I ("-", "Create opam entries pointing to your fork");
13761376+ `I ("-", "Clone and add the subtree");
13771377+ `S "AFTER JOINING";
13781378+ `P "After joining, work with the subtree normally:";
13791379+ `I ("1.", "Make changes in mono/<name>/");
13801380+ `I ("2.", "Commit in mono/");
13811381+ `I ("3.", "Run $(b,monopam sync --remote) to push upstream");
13821382+ `S Manpage.s_examples;
13831383+ `P "Join a repository:";
13841384+ `Pre "monopam join https://github.com/someone/some-lib";
13851385+ `P "Join with explicit name using --url:";
13861386+ `Pre "monopam join --url https://tangled.org/handle/sortal sortal";
13871387+ `P "Join with a custom name using --as:";
13881388+ `Pre "monopam join https://github.com/someone/some-lib --as my-lib";
13891389+ `P "Join with upstream tracking (for forks):";
13901390+ `Pre "monopam join https://github.com/me/cohttp --upstream https://github.com/mirage/cohttp";
13911391+ `P "Join from a verse member:";
13921392+ `Pre "monopam join cohttp --from avsm.bsky.social --url git@github.com:me/cohttp.git";
13931393+ `P "Preview what would be done:";
13941394+ `Pre "monopam join https://github.com/someone/lib --dry-run";
13951395+ ]
13961396+ in
13971397+ let info = Cmd.info "join" ~doc ~man in
13981398+ let url_or_pkg_arg =
13991399+ let doc = "Git URL to join, or subtree name (when using --url)" in
14001400+ Arg.(required & pos 0 (some string) None & info [] ~docv:"URL|NAME" ~doc)
14011401+ in
14021402+ let as_arg =
14031403+ let doc = "Override subtree directory name" in
14041404+ Arg.(value & opt (some string) None & info [ "as" ] ~docv:"NAME" ~doc)
14051405+ in
14061406+ let upstream_arg =
14071407+ let doc = "Original upstream URL (for tracking forks)" in
14081408+ Arg.(value & opt (some string) None & info [ "upstream" ] ~docv:"URL" ~doc)
14091409+ in
14101410+ let from_arg =
14111411+ let doc = "Verse member handle to join from (requires --url)" in
14121412+ Arg.(value & opt (some string) None & info [ "from" ] ~docv:"HANDLE" ~doc)
14131413+ in
14141414+ let fork_url_arg =
14151415+ let doc = "Git URL to clone from (makes positional arg the subtree name)" in
14161416+ Arg.(value & opt (some string) None & info [ "url" ] ~docv:"URL" ~doc)
14171417+ in
14181418+ let dry_run_arg =
14191419+ let doc = "Show what would be done without making changes" in
14201420+ Arg.(value & flag & info [ "dry-run"; "n" ] ~doc)
14211421+ in
14221422+ let run url_or_pkg as_name upstream from fork_url dry_run () =
14231423+ Eio_main.run @@ fun env ->
14241424+ with_verse_config env @@ fun config ->
14251425+ let fs = Eio.Stdenv.fs env in
14261426+ let proc = Eio.Stdenv.process_mgr env in
14271427+ match from with
14281428+ | Some handle ->
14291429+ (* Join from verse member - requires --url for your fork *)
14301430+ (match fork_url with
14311431+ | None ->
14321432+ Fmt.epr "Error: --url is required when using --from@.";
14331433+ `Error (false, "--url required")
14341434+ | Some fork_url ->
14351435+ match Monopam.Fork_join.join_from_verse ~proc ~fs ~config ~verse_config:config
14361436+ ~package:url_or_pkg ~handle ~fork_url ~dry_run () with
14371437+ | Ok result ->
14381438+ if dry_run then begin
14391439+ Fmt.pr "Would join '%s' from %s:@." result.name (Option.value ~default:"verse" result.from_handle);
14401440+ Fmt.pr " Source: %s@." result.source_url;
14411441+ Option.iter (fun u -> Fmt.pr " Upstream: %s@." u) result.upstream_url;
14421442+ Fmt.pr " Packages: %a@." Fmt.(list ~sep:(any ", ") string) result.packages_added
14431443+ end else begin
14441444+ Fmt.pr "%a@." Monopam.Fork_join.pp_join_result result;
14451445+ Fmt.pr "@.Next steps:@.";
14461446+ Fmt.pr " 1. Commit the opam changes: cd opam-repo && git add -A && git commit@.";
14471447+ Fmt.pr " 2. Run $(b,monopam sync) to synchronize@."
14481448+ end;
14491449+ `Ok ()
14501450+ | Error e ->
14511451+ Fmt.epr "Error: %a@." Monopam.Fork_join.pp_error_with_hint e;
14521452+ `Error (false, "join failed"))
14531453+ | None ->
14541454+ (* Normal join from URL - use --url if provided, otherwise positional arg *)
14551455+ let url = match fork_url with Some u -> u | None -> url_or_pkg in
14561456+ let name = match fork_url with Some _ -> Some url_or_pkg | None -> as_name in
14571457+ match Monopam.Fork_join.join ~proc ~fs ~config ~url ?name ?upstream ~dry_run () with
14581458+ | Ok result ->
14591459+ if dry_run then begin
14601460+ Fmt.pr "Would join '%s':@." result.name;
14611461+ Fmt.pr " Source: %s@." result.source_url;
14621462+ Option.iter (fun u -> Fmt.pr " Upstream: %s@." u) result.upstream_url;
14631463+ Fmt.pr " Packages: %a@." Fmt.(list ~sep:(any ", ") string) result.packages_added
14641464+ end else begin
14651465+ Fmt.pr "%a@." Monopam.Fork_join.pp_join_result result;
14661466+ Fmt.pr "@.Next steps:@.";
14671467+ Fmt.pr " 1. Run $(b,monopam sync) to synchronize@."
14681468+ end;
14691469+ `Ok ()
14701470+ | Error e ->
14711471+ Fmt.epr "Error: %a@." Monopam.Fork_join.pp_error_with_hint e;
14721472+ `Error (false, "join failed")
14731473+ in
14741474+ Cmd.v info Term.(ret (const run $ url_or_pkg_arg $ as_arg $ upstream_arg $ from_arg $ fork_url_arg $ dry_run_arg $ logging_term))
14751475+14761476+(* Site command *)
14771477+14781478+let site_cmd =
14791479+ let doc = "Generate a static HTML site representing the monoverse map" in
14801480+ let man =
14811481+ [
14821482+ `S Manpage.s_description;
14831483+ `P
14841484+ "Generates a static index.html file that maps the monoverse, showing all \
14851485+ verse members, their packages, and the relationships between them.";
14861486+ `S "OUTPUT";
14871487+ `P "The generated site includes:";
14881488+ `I ("Members", "All verse members with links to their monorepo and opam repos");
14891489+ `I ("Summary", "Overview of common libraries and member-specific packages");
14901490+ `I ("Repository Details", "Each shared repo with packages and fork status");
14911491+ `S "FORK STATUS";
14921492+ `P "Use $(b,--status) to include fork relationship information:";
14931493+ `I ("+N", "You are N commits ahead of them");
14941494+ `I ("-N", "They are N commits ahead of you");
14951495+ `I ("+N/-M", "Diverged: you have N new, they have M new");
14961496+ `I ("sync", "Same commit");
14971497+ `S "DESIGN";
14981498+ `P "The HTML is designed to be:";
14991499+ `I ("-", "Simple and clean with a 10pt font");
15001500+ `I ("-", "Responsive and compact");
15011501+ `I ("-", "External links marked with icon and teal color");
15021502+ `S Manpage.s_examples;
15031503+ `P "Generate site to default location (mono/index.html):";
15041504+ `Pre "monopam site";
15051505+ `P "Generate site with fork status (slower, fetches remotes):";
15061506+ `Pre "monopam site --status";
15071507+ `P "Generate site to custom location:";
15081508+ `Pre "monopam site -o /var/www/monoverse/index.html";
15091509+ `P "Print HTML to stdout:";
15101510+ `Pre "monopam site --stdout";
15111511+ ]
15121512+ in
15131513+ let info = Cmd.info "site" ~doc ~man in
15141514+ let output_arg =
15151515+ let doc = "Output file path. Defaults to mono/index.html." in
15161516+ Arg.(value & opt (some string) None & info [ "o"; "output" ] ~docv:"FILE" ~doc)
15171517+ in
15181518+ let stdout_arg =
15191519+ let doc = "Print HTML to stdout instead of writing to file." in
15201520+ Arg.(value & flag & info [ "stdout" ] ~doc)
15211521+ in
15221522+ let status_arg =
15231523+ let doc = "Include fork status (ahead/behind) for each repository. \
15241524+ This fetches from remotes and may be slower." in
15251525+ Arg.(value & flag & info [ "status"; "s" ] ~doc)
15261526+ in
15271527+ let run output to_stdout with_status () =
15281528+ Eio_main.run @@ fun env ->
15291529+ with_config env @@ fun monopam_config ->
15301530+ with_verse_config env @@ fun verse_config ->
15311531+ let fs = Eio.Stdenv.fs env in
15321532+ let proc = Eio.Stdenv.process_mgr env in
15331533+ (* Pull/clone registry to get latest metadata *)
15341534+ Fmt.pr "Syncing registry...@.";
15351535+ let registry =
15361536+ match Monopam.Verse_registry.clone_or_pull ~proc ~fs:(fs :> _ Eio.Path.t) ~config:verse_config () with
15371537+ | Ok r -> r
15381538+ | Error msg ->
15391539+ Fmt.epr "Warning: Could not sync registry: %s@." msg;
15401540+ Monopam.Verse_registry.{ name = "opamverse"; description = None; members = [] }
15411541+ in
15421542+ (* Compute forks if --status is requested *)
15431543+ let forks =
15441544+ if with_status then begin
15451545+ Fmt.pr "Computing fork status...@.";
15461546+ Some (Monopam.Forks.compute ~proc ~fs:(fs :> _ Eio.Path.t)
15471547+ ~verse_config ~monopam_config ())
15481548+ end else None
15491549+ in
15501550+ if to_stdout then begin
15511551+ let html = Monopam.Site.generate ~fs:(fs :> _ Eio.Path.t) ~config:verse_config ?forks ~registry () in
15521552+ print_string html;
15531553+ `Ok ()
15541554+ end else begin
15551555+ let output_path =
15561556+ match output with
15571557+ | Some p -> (
15581558+ match Fpath.of_string p with
15591559+ | Ok fp -> fp
15601560+ | Error (`Msg _) -> Fpath.v p)
15611561+ | None -> Fpath.(Monopam.Verse_config.mono_path verse_config / "index.html")
15621562+ in
15631563+ match Monopam.Site.write ~fs:(fs :> _ Eio.Path.t) ~config:verse_config ?forks ~registry ~output_path () with
15641564+ | Ok () ->
15651565+ Fmt.pr "Site generated: %a@." Fpath.pp output_path;
15661566+ `Ok ()
15671567+ | Error msg ->
15681568+ Fmt.epr "Error: %s@." msg;
15691569+ `Error (false, "site generation failed")
15701570+ end
15711571+ in
15721572+ Cmd.v info Term.(ret (const run $ output_arg $ stdout_arg $ status_arg $ logging_term))
15731573+14621574(* Main command group *)
1463157514641576let main_cmd =
···14801592 `P "Inside the devcontainer, initialize your workspace:";
14811593 `Pre
14821594 "cd ~/tangled\n\
14831483- monopam verse init --handle yourname.bsky.social\n\
15951595+ monopam init --handle yourname.bsky.social\n\
14841596 cd mono";
14851597 `P "Daily workflow:";
14861598 `Pre
···15131625 `I
15141626 ( "4. monopam sync --remote",
15151627 "Sync again, including pushing to upstream git remotes" );
15161516- `P "For finer control, use $(b,push) and $(b,pull) separately:";
16281628+ `P "For finer control over the sync phases:";
15171629 `I
15181518- ( "monopam push",
15191519- "Export monorepo changes to checkouts (for manual review/push)" );
16301630+ ( "monopam sync --skip-pull",
16311631+ "Export monorepo changes to checkouts only (skip fetching remotes)" );
15201632 `I
15211521- ( "monopam pull",
15221522- "Pull remote changes into monorepo (when you know there are no local \
15231523- changes)" );
16331633+ ( "monopam sync --skip-push",
16341634+ "Pull remote changes only (skip exporting local changes)" );
15241635 `S "CHECKING STATUS";
15251636 `P "Run $(b,monopam status) to see the state of all repositories:";
15261637 `I ("local:+N", "Your monorepo is N commits ahead of the checkout");
···15291640 `I ("remote:+N", "Your checkout is N commits ahead of upstream");
15301641 `I ("remote:-N", "Upstream is N commits ahead (run $(b,monopam sync))");
15311642 `S "COMMON TASKS";
15321532- `I ("Start fresh", "monopam verse init --handle you.bsky.social");
16431643+ `I ("Start fresh", "monopam init --handle you.bsky.social");
15331644 `I ("Check status", "monopam status");
15341645 `I ("Sync everything", "monopam sync");
15351646 `I ("Sync and push upstream", "monopam sync --remote");
15361647 `I ("Sync one package", "monopam sync <package-name>");
15371648 `S "CONFIGURATION";
15381649 `P
15391539- "Run $(b,monopam verse init --handle <handle>) to create a workspace. \
16501650+ "Run $(b,monopam init --handle <handle>) to create a workspace. \
15401651 Configuration is stored in ~/.config/monopam/opamverse.toml.";
15411652 `P "Workspace structure:";
15421653 `Pre
···15611672 in
15621673 let info = Cmd.info "monopam" ~version:"%%VERSION%%" ~doc ~man in
15631674 Cmd.group info
15641564- [ status_cmd; diff_cmd; pull_cmd; cherrypick_cmd; sync_cmd; changes_cmd; opam_cmd; doctor_cmd; verse_cmd; feature_cmd; devcontainer_cmd ]
16751675+ [ init_cmd; status_cmd; diff_cmd; pull_cmd; cherrypick_cmd; sync_cmd; changes_cmd; opam_cmd; doctor_cmd; verse_cmd; feature_cmd; fork_cmd; join_cmd; devcontainer_cmd; site_cmd ]
1565167615661677let () = exit (Cmd.eval main_cmd)
+1-1
monopam/lib/doctor.ml
···436436 Buffer.add_string buf "## Current Monorepo Status\n\n";
437437 Buffer.add_string buf "Output of `monopam status`:\n```\n";
438438 (* Capture formatted pp_summary output (strip ANSI codes for prompt) *)
439439- let fmt_output = Fmt.str "%a" Status.pp_summary statuses in
439439+ let fmt_output = Fmt.str "%a" (Status.pp_summary ?sources:None) statuses in
440440 Buffer.add_string buf (strip_ansi fmt_output);
441441 Buffer.add_string buf "```\n\n";
442442 Buffer.add_string buf "Detailed status per repository:\n";
+1-1
monopam/lib/feature.ml
···1616 Some (Printf.sprintf "Run 'monopam feature remove %s' first if you want to recreate it" name)
1717 | Feature_not_found name ->
1818 Some (Printf.sprintf "Run 'monopam feature list' to see available features, or 'monopam feature add %s' to create it" name)
1919- | Config_error _ -> Some "Run 'monopam verse init' to create a workspace configuration"
1919+ | Config_error _ -> Some "Run 'monopam init' to create a workspace configuration"
20202121let pp_error_with_hint ppf e =
2222 pp_error ppf e;
+289
monopam/lib/fork_join.ml
···11+(** Fork and join operations for managing monorepo sources. *)
22+33+type error =
44+ | Config_error of string
55+ | Git_error of Git.error
66+ | Subtree_not_found of string
77+ | Src_already_exists of string
88+ | Subtree_already_exists of string
99+ | No_opam_files of string
1010+ | Verse_error of Verse.error
1111+1212+let pp_error ppf = function
1313+ | Config_error msg -> Fmt.pf ppf "Configuration error: %s" msg
1414+ | Git_error e -> Fmt.pf ppf "Git error: %a" Git.pp_error e
1515+ | Subtree_not_found name -> Fmt.pf ppf "Subtree not found in monorepo: %s" name
1616+ | Src_already_exists name -> Fmt.pf ppf "Source checkout already exists: src/%s" name
1717+ | Subtree_already_exists name -> Fmt.pf ppf "Subtree already exists in monorepo: mono/%s" name
1818+ | No_opam_files name -> Fmt.pf ppf "No .opam files found in subtree: %s" name
1919+ | Verse_error e -> Fmt.pf ppf "Verse error: %a" Verse.pp_error e
2020+2121+let error_hint = function
2222+ | Config_error _ ->
2323+ Some "Run 'monopam init --handle <your-handle>' to create a workspace."
2424+ | Git_error (Git.Dirty_worktree _) ->
2525+ Some "Commit or stash your changes first: git status"
2626+ | Git_error _ -> None
2727+ | Subtree_not_found name ->
2828+ Some (Fmt.str "Check that mono/%s exists in your monorepo" name)
2929+ | Src_already_exists name ->
3030+ Some (Fmt.str "Remove or rename src/%s first, or choose a different name" name)
3131+ | Subtree_already_exists name ->
3232+ Some (Fmt.str "Remove mono/%s first, or use a different name with --as" name)
3333+ | No_opam_files name ->
3434+ Some (Fmt.str "Add a .opam file to mono/%s before forking" name)
3535+ | Verse_error e -> Verse.error_hint e
3636+3737+let pp_error_with_hint ppf e =
3838+ pp_error ppf e;
3939+ match error_hint e with
4040+ | Some hint -> Fmt.pf ppf "@.@[<v 2>Hint: %s@]" hint
4141+ | None -> ()
4242+4343+type fork_result = {
4444+ name : string;
4545+ split_commit : string;
4646+ src_path : Fpath.t;
4747+ push_url : string option;
4848+ packages_created : string list;
4949+}
5050+5151+type join_result = {
5252+ name : string;
5353+ source_url : string;
5454+ upstream_url : string option;
5555+ packages_added : string list;
5656+ from_handle : string option;
5757+}
5858+5959+let pp_fork_result ppf (r : fork_result) =
6060+ Fmt.pf ppf "@[<v>Forked subtree '%s':@, Split commit: %s@, Local repo: %a@,"
6161+ r.name
6262+ (String.sub r.split_commit 0 (min 7 (String.length r.split_commit)))
6363+ Fpath.pp r.src_path;
6464+ (match r.push_url with
6565+ | Some url -> Fmt.pf ppf " Push URL: %s@," url
6666+ | None -> ());
6767+ if r.packages_created <> [] then
6868+ Fmt.pf ppf " Packages: %a@]" Fmt.(list ~sep:(any ", ") string) r.packages_created
6969+ else
7070+ Fmt.pf ppf "@]"
7171+7272+let pp_join_result ppf (r : join_result) =
7373+ Fmt.pf ppf "@[<v>Joined repository '%s':@, Source: %s@,"
7474+ r.name r.source_url;
7575+ (match r.upstream_url with
7676+ | Some url -> Fmt.pf ppf " Upstream: %s@," url
7777+ | None -> ());
7878+ (match r.from_handle with
7979+ | Some h -> Fmt.pf ppf " From verse: %s@," h
8080+ | None -> ());
8181+ if r.packages_added <> [] then
8282+ Fmt.pf ppf " Packages: %a@]" Fmt.(list ~sep:(any ", ") string) r.packages_added
8383+ else
8484+ Fmt.pf ppf "@]"
8585+8686+(** Helper to check if a path is a directory *)
8787+let is_directory ~fs path =
8888+ let eio_path = Eio.Path.(fs / Fpath.to_string path) in
8989+ match Eio.Path.kind ~follow:true eio_path with
9090+ | `Directory -> true
9191+ | _ -> false
9292+ | exception _ -> false
9393+9494+(** Helper to create a directory if it doesn't exist *)
9595+let ensure_dir ~fs path =
9696+ let eio_path = Eio.Path.(fs / Fpath.to_string path) in
9797+ try Eio.Path.mkdirs ~perm:0o755 eio_path with Eio.Io _ -> ()
9898+9999+(** Scan a directory for .opam files *)
100100+let find_opam_files ~fs path =
101101+ let eio_path = Eio.Path.(fs / Fpath.to_string path) in
102102+ try
103103+ Eio.Path.read_dir eio_path
104104+ |> List.filter (fun name -> String.ends_with ~suffix:".opam" name)
105105+ |> List.map (fun name ->
106106+ (* Extract package name from filename.opam *)
107107+ String.sub name 0 (String.length name - 5))
108108+ with Eio.Io _ -> []
109109+110110+(** Normalize URL to git+ format for dev-repo *)
111111+let normalize_git_url url =
112112+ if String.starts_with ~prefix:"git+" url then url
113113+ else if String.starts_with ~prefix:"git://" url then url
114114+ else if String.starts_with ~prefix:"https://" url then "git+" ^ url
115115+ else if String.starts_with ~prefix:"http://" url then "git+" ^ url
116116+ else url
117117+118118+(** Extract name from URL (last path component without .git suffix) *)
119119+let name_from_url url =
120120+ let uri = Uri.of_string url in
121121+ let path = Uri.path uri in
122122+ (* Remove leading slash and .git suffix *)
123123+ let path = if String.length path > 0 && path.[0] = '/' then
124124+ String.sub path 1 (String.length path - 1)
125125+ else path in
126126+ let path = if String.ends_with ~suffix:".git" path then
127127+ String.sub path 0 (String.length path - 4)
128128+ else path in
129129+ (* Get last component *)
130130+ match String.rindex_opt path '/' with
131131+ | Some i -> String.sub path (i + 1) (String.length path - i - 1)
132132+ | None -> path
133133+134134+let fork ~proc ~fs ~config ~name ?push_url ?(dry_run = false) () =
135135+ let monorepo = Verse_config.mono_path config in
136136+ let checkouts = Verse_config.src_path config in
137137+ let prefix = name in
138138+ let subtree_path = Fpath.(monorepo / prefix) in
139139+ let src_path = Fpath.(checkouts / name) in
140140+ (* Validate: mono/<name>/ must exist *)
141141+ if not (Git.Subtree.exists ~fs ~repo:monorepo ~prefix) then
142142+ Error (Subtree_not_found name)
143143+ (* Validate: src/<name>/ must not exist *)
144144+ else if is_directory ~fs src_path then
145145+ Error (Src_already_exists name)
146146+ else begin
147147+ (* Find .opam files in subtree *)
148148+ let packages = find_opam_files ~fs subtree_path in
149149+ if packages = [] then
150150+ Error (No_opam_files name)
151151+ else if dry_run then
152152+ Ok { name; split_commit = "(dry-run)"; src_path; push_url; packages_created = packages }
153153+ else begin
154154+ (* Split the subtree to get history *)
155155+ match Git.Subtree.split ~proc ~fs ~repo:monorepo ~prefix () with
156156+ | Error e -> Error (Git_error e)
157157+ | Ok split_commit ->
158158+ (* Ensure src/ exists *)
159159+ ensure_dir ~fs checkouts;
160160+ (* Initialize new git repo at src/<name>/ *)
161161+ match Git.init ~proc ~fs src_path with
162162+ | Error e -> Error (Git_error e)
163163+ | Ok () ->
164164+ (* Add 'origin' remote pointing to monorepo path temporarily *)
165165+ let mono_str = Fpath.to_string monorepo in
166166+ (match Git.add_remote ~proc ~fs ~name:"mono" ~url:mono_str src_path with
167167+ | Error e -> Error (Git_error e)
168168+ | Ok () ->
169169+ (* Push split commit to local repo *)
170170+ let ref_spec = split_commit ^ ":refs/heads/main" in
171171+ match Git.push_ref ~proc ~fs ~repo:monorepo ~target:(Fpath.to_string src_path) ~ref_spec () with
172172+ | Error e -> Error (Git_error e)
173173+ | Ok () ->
174174+ (* Checkout main branch *)
175175+ (match Git.checkout ~proc ~fs ~branch:"main" src_path with
176176+ | Error e -> Error (Git_error e)
177177+ | Ok () ->
178178+ (* Set push URL if provided *)
179179+ let push_result =
180180+ match push_url with
181181+ | Some url ->
182182+ (match Git.add_remote ~proc ~fs ~name:"origin" ~url src_path with
183183+ | Error e -> Error (Git_error e)
184184+ | Ok () -> Ok ())
185185+ | None -> Ok ()
186186+ in
187187+ match push_result with
188188+ | Error _ as e -> e
189189+ | Ok () ->
190190+ (* Only update sources.toml if there's a push URL *)
191191+ (match push_url with
192192+ | Some url ->
193193+ let sources_path = Fpath.(monorepo / "sources.toml") in
194194+ let sources =
195195+ match Sources_registry.load ~fs:(fs :> _ Eio.Path.t) sources_path with
196196+ | Ok s -> s
197197+ | Error _ -> Sources_registry.empty
198198+ in
199199+ let entry = Sources_registry.{
200200+ url = normalize_git_url url;
201201+ upstream = None;
202202+ branch = Some "main";
203203+ reason = None;
204204+ origin = Some Fork;
205205+ } in
206206+ let sources = Sources_registry.add sources ~subtree:name entry in
207207+ (match Sources_registry.save ~fs:(fs :> _ Eio.Path.t) sources_path sources with
208208+ | Ok () -> ()
209209+ | Error msg -> Logs.warn (fun m -> m "Failed to update sources.toml: %s" msg))
210210+ | None -> ());
211211+ Ok { name; split_commit; src_path; push_url; packages_created = packages }))
212212+ end
213213+ end
214214+215215+let join ~proc ~fs ~config ~url ?name ?upstream ?(dry_run = false) () =
216216+ let name = match name with Some n -> n | None -> name_from_url url in
217217+ let monorepo = Verse_config.mono_path config in
218218+ let checkouts = Verse_config.src_path config in
219219+ let prefix = name in
220220+ let subtree_path = Fpath.(monorepo / prefix) in
221221+ let src_path = Fpath.(checkouts / name) in
222222+ (* Validate: mono/<name>/ must not exist *)
223223+ if Git.Subtree.exists ~fs ~repo:monorepo ~prefix then
224224+ Error (Subtree_already_exists name)
225225+ else if dry_run then
226226+ Ok { name; source_url = url; upstream_url = upstream; packages_added = []; from_handle = None }
227227+ else begin
228228+ (* Ensure src/ exists *)
229229+ ensure_dir ~fs checkouts;
230230+ (* Clone to src/<name>/ *)
231231+ let branch = Verse_config.default_branch in
232232+ let uri = Uri.of_string url in
233233+ match Git.clone ~proc ~fs ~url:uri ~branch src_path with
234234+ | Error e -> Error (Git_error e)
235235+ | Ok () ->
236236+ (* Add subtree to monorepo *)
237237+ match Git.Subtree.add ~proc ~fs ~repo:monorepo ~prefix ~url:uri ~branch () with
238238+ | Error e -> Error (Git_error e)
239239+ | Ok () ->
240240+ (* Find .opam files in the new subtree *)
241241+ let packages = find_opam_files ~fs subtree_path in
242242+ (* Only update sources.toml if there's an upstream to track *)
243243+ (match upstream with
244244+ | Some _ ->
245245+ let sources_path = Fpath.(monorepo / "sources.toml") in
246246+ let sources =
247247+ match Sources_registry.load ~fs:(fs :> _ Eio.Path.t) sources_path with
248248+ | Ok s -> s
249249+ | Error _ -> Sources_registry.empty
250250+ in
251251+ let entry = Sources_registry.{
252252+ url = normalize_git_url url;
253253+ upstream = Option.map normalize_git_url upstream;
254254+ branch = Some branch;
255255+ reason = None;
256256+ origin = Some Join;
257257+ } in
258258+ let sources = Sources_registry.add sources ~subtree:name entry in
259259+ (match Sources_registry.save ~fs:(fs :> _ Eio.Path.t) sources_path sources with
260260+ | Ok () -> ()
261261+ | Error msg -> Logs.warn (fun m -> m "Failed to update sources.toml: %s" msg))
262262+ | None -> ());
263263+ Ok { name; source_url = url; upstream_url = upstream; packages_added = packages; from_handle = None }
264264+ end
265265+266266+let join_from_verse ~proc ~fs ~config ~verse_config ~package ~handle ~fork_url ?(dry_run = false) () =
267267+ (* First use verse fork to set up the opam entries *)
268268+ match Verse.fork ~proc ~fs ~config:verse_config ~handle ~package ~fork_url ~dry_run () with
269269+ | Error e -> Error (Verse_error e)
270270+ | Ok fork_result ->
271271+ if dry_run then
272272+ Ok {
273273+ name = fork_result.subtree_name;
274274+ source_url = fork_url;
275275+ upstream_url = Some fork_result.upstream_url;
276276+ packages_added = fork_result.packages_forked;
277277+ from_handle = Some handle;
278278+ }
279279+ else begin
280280+ (* Now join the repository *)
281281+ let name = fork_result.subtree_name in
282282+ match join ~proc ~fs ~config ~url:fork_url ~name ~upstream:fork_result.upstream_url ~dry_run () with
283283+ | Error e -> Error e
284284+ | Ok join_result ->
285285+ Ok { join_result with
286286+ packages_added = fork_result.packages_forked;
287287+ from_handle = Some handle;
288288+ }
289289+ end
+132
monopam/lib/fork_join.mli
···11+(** Fork and join operations for managing monorepo sources.
22+33+ This module provides operations to:
44+ - Fork: Split a monorepo subtree into its own repository in src/
55+ - Join: Bring an external repository into the monorepo as a subtree
66+77+ Both operations update sources.toml to track the origin of each source. *)
88+99+(** {1 Error Types} *)
1010+1111+type error =
1212+ | Config_error of string (** Configuration error *)
1313+ | Git_error of Git.error (** Git operation failed *)
1414+ | Subtree_not_found of string (** Subtree not found in monorepo *)
1515+ | Src_already_exists of string (** Source checkout already exists *)
1616+ | Subtree_already_exists of string (** Subtree already exists in monorepo *)
1717+ | No_opam_files of string (** No .opam files found in subtree *)
1818+ | Verse_error of Verse.error (** Error from verse operations *)
1919+2020+val pp_error : error Fmt.t
2121+(** [pp_error] formats errors. *)
2222+2323+val pp_error_with_hint : error Fmt.t
2424+(** [pp_error_with_hint] formats errors with helpful hints. *)
2525+2626+val error_hint : error -> string option
2727+(** [error_hint e] returns a hint string for the given error, if available. *)
2828+2929+(** {1 Fork Operations} *)
3030+3131+(** Result of a fork operation. *)
3232+type fork_result = {
3333+ name : string; (** Subtree/repository name *)
3434+ split_commit : string; (** Git commit SHA from subtree split *)
3535+ src_path : Fpath.t; (** Path to the new source checkout *)
3636+ push_url : string option; (** Remote push URL if provided *)
3737+ packages_created : string list; (** Package names from .opam files *)
3838+}
3939+4040+val pp_fork_result : fork_result Fmt.t
4141+(** [pp_fork_result] formats a fork result. *)
4242+4343+val fork :
4444+ proc:_ Eio.Process.mgr ->
4545+ fs:Eio.Fs.dir_ty Eio.Path.t ->
4646+ config:Verse_config.t ->
4747+ name:string ->
4848+ ?push_url:string ->
4949+ ?dry_run:bool ->
5050+ unit ->
5151+ (fork_result, error) result
5252+(** [fork ~proc ~fs ~config ~name ?push_url ?dry_run ()] splits a monorepo
5353+ subtree into its own repository.
5454+5555+ This operation:
5656+ 1. Validates mono/<name>/ exists
5757+ 2. Validates src/<name>/ does not exist
5858+ 3. Uses [git subtree split] to extract history
5959+ 4. Creates a new git repo at src/<name>/
6060+ 5. Pushes the split commit to the new repo
6161+ 6. Updates sources.toml with [origin = "fork"]
6262+ 7. Auto-discovers packages from .opam files
6363+6464+ @param name Name of the subtree to fork (directory name under mono/)
6565+ @param push_url Optional remote URL to add as origin for pushing
6666+ @param dry_run If true, validate and report what would be done *)
6767+6868+(** {1 Join Operations} *)
6969+7070+(** Result of a join operation. *)
7171+type join_result = {
7272+ name : string; (** Subtree/repository name *)
7373+ source_url : string; (** URL the repository was cloned from *)
7474+ upstream_url : string option; (** Original upstream if this is a fork *)
7575+ packages_added : string list; (** Package names from .opam files *)
7676+ from_handle : string option; (** Verse handle if joined from verse *)
7777+}
7878+7979+val pp_join_result : join_result Fmt.t
8080+(** [pp_join_result] formats a join result. *)
8181+8282+val join :
8383+ proc:_ Eio.Process.mgr ->
8484+ fs:Eio.Fs.dir_ty Eio.Path.t ->
8585+ config:Verse_config.t ->
8686+ url:string ->
8787+ ?name:string ->
8888+ ?upstream:string ->
8989+ ?dry_run:bool ->
9090+ unit ->
9191+ (join_result, error) result
9292+(** [join ~proc ~fs ~config ~url ?name ?upstream ?dry_run ()] brings an external
9393+ repository into the monorepo.
9494+9595+ This operation:
9696+ 1. Derives name from URL if not provided
9797+ 2. Validates mono/<name>/ does not exist
9898+ 3. Clones the repository to src/<name>/
9999+ 4. Uses [git subtree add] to bring into monorepo
100100+ 5. Updates sources.toml with [origin = "join"]
101101+ 6. Auto-discovers packages from .opam files
102102+103103+ @param url Git URL to clone from
104104+ @param name Override the subtree directory name (default: derived from URL)
105105+ @param upstream Original upstream URL if this is your fork of another project
106106+ @param dry_run If true, validate and report what would be done *)
107107+108108+val join_from_verse :
109109+ proc:_ Eio.Process.mgr ->
110110+ fs:Eio.Fs.dir_ty Eio.Path.t ->
111111+ config:Verse_config.t ->
112112+ verse_config:Verse_config.t ->
113113+ package:string ->
114114+ handle:string ->
115115+ fork_url:string ->
116116+ ?dry_run:bool ->
117117+ unit ->
118118+ (join_result, error) result
119119+(** [join_from_verse ~proc ~fs ~config ~verse_config ~package ~handle ~fork_url
120120+ ?dry_run ()] joins a package from a verse member's repository.
121121+122122+ This combines [Verse.fork] (to set up opam entries) with [join]:
123123+ 1. Looks up the package in verse/<handle>-opam/
124124+ 2. Finds all packages sharing the same git repository
125125+ 3. Creates opam entries pointing to your fork
126126+ 4. Clones and adds the subtree
127127+128128+ @param verse_config Verse configuration (for accessing verse/ directory)
129129+ @param package Package name to look up
130130+ @param handle Verse member handle (e.g., "avsm.bsky.social")
131131+ @param fork_url Your fork URL
132132+ @param dry_run If true, validate and report what would be done *)
···11+(** Generate a static HTML site representing the monoverse map. *)
22+33+(** Information about a package in the verse *)
44+type pkg_info = {
55+ name : string;
66+ synopsis : string option;
77+ repo_name : string;
88+ dev_repo : string; (** Upstream git URL *)
99+ owners : string list; (** List of handles that have this package *)
1010+ depends : string list; (** Package dependencies *)
1111+}
1212+1313+(** Information about a repository (group of packages) *)
1414+type repo_info = {
1515+ ri_name : string;
1616+ ri_dev_repo : string;
1717+ ri_packages : pkg_info list;
1818+ ri_owners : string list; (** All handles that have any package from this repo *)
1919+ ri_fork_status : (string * Forks.relationship) list; (** (handle, relationship) *)
2020+ ri_dep_count : int; (** Number of dependencies (for sorting) *)
2121+}
2222+2323+(** Information about a verse member *)
2424+type member_info = {
2525+ handle : string;
2626+ display_name : string; (** Name to display (from registry or handle) *)
2727+ monorepo_url : string;
2828+ opam_url : string;
2929+ package_count : int;
3030+ unique_packages : string list; (** Packages unique to this member *)
3131+}
3232+3333+(** Aggregated site data *)
3434+type site_data = {
3535+ local_handle : string;
3636+ registry_name : string;
3737+ registry_description : string option;
3838+ members : member_info list;
3939+ common_repos : repo_info list; (** Repos that exist in multiple members *)
4040+ unique_repos : repo_info list; (** Repos unique to one member *)
4141+ all_packages : pkg_info list; (** All packages *)
4242+}
4343+4444+(** Scan a member's opam repo and return package info *)
4545+let scan_member_packages ~fs opam_repo_path =
4646+ let pkgs, _errors = Opam_repo.scan_all ~fs opam_repo_path in
4747+ List.map (fun pkg ->
4848+ {
4949+ name = Package.name pkg;
5050+ synopsis = Package.synopsis pkg;
5151+ repo_name = Package.repo_name pkg;
5252+ dev_repo = Uri.to_string (Package.dev_repo pkg);
5353+ owners = [];
5454+ depends = Package.depends pkg;
5555+ }
5656+ ) pkgs
5757+5858+(** Check if a directory exists *)
5959+let dir_exists ~fs path =
6060+ let eio_path = Eio.Path.(fs / Fpath.to_string path) in
6161+ match Eio.Path.kind ~follow:true eio_path with
6262+ | `Directory -> true
6363+ | _ -> false
6464+ | exception _ -> false
6565+6666+(** Collect site data from the workspace *)
6767+let collect_data ~fs ~config ?forks ~registry () =
6868+ let local_handle = Verse_config.handle config in
6969+ let local_opam_repo = Verse_config.opam_repo_path config in
7070+ let verse_path = Verse_config.verse_path config in
7171+7272+ (* Scan local packages *)
7373+ let local_pkgs =
7474+ if dir_exists ~fs local_opam_repo then
7575+ scan_member_packages ~fs local_opam_repo
7676+ else []
7777+ in
7878+7979+ (* Build a map: package name -> list of (handle, pkg_info) *)
8080+ let pkg_map : (string, (string * pkg_info) list) Hashtbl.t = Hashtbl.create 256 in
8181+8282+ (* Add local packages *)
8383+ List.iter (fun pkg ->
8484+ let existing = try Hashtbl.find pkg_map pkg.name with Not_found -> [] in
8585+ Hashtbl.replace pkg_map pkg.name ((local_handle, pkg) :: existing)
8686+ ) local_pkgs;
8787+8888+ let registry_name = registry.Verse_registry.name in
8989+ let registry_description = registry.Verse_registry.description in
9090+9191+ (* Build handle -> display name lookup *)
9292+ let handle_to_name = Hashtbl.create 16 in
9393+ List.iter (fun (m : Verse_registry.member) ->
9494+ let display = match m.name with Some n -> n | None -> m.handle in
9595+ Hashtbl.replace handle_to_name m.handle display
9696+ ) registry.Verse_registry.members;
9797+9898+ (* Get tracked handles from verse directory, excluding local handle *)
9999+ let tracked_handles =
100100+ if dir_exists ~fs verse_path then
101101+ let eio_path = Eio.Path.(fs / Fpath.to_string verse_path) in
102102+ try
103103+ Eio.Path.read_dir eio_path
104104+ |> List.filter (fun name ->
105105+ not (String.ends_with ~suffix:"-opam" name) &&
106106+ name <> local_handle &&
107107+ dir_exists ~fs Fpath.(verse_path / name))
108108+ with Eio.Io _ -> []
109109+ else []
110110+ in
111111+112112+ (* Scan each tracked member's opam repo *)
113113+ let member_infos =
114114+ List.filter_map (fun handle ->
115115+ let opam_path = Fpath.(verse_path / (handle ^ "-opam")) in
116116+ if dir_exists ~fs opam_path then begin
117117+ let pkgs = scan_member_packages ~fs opam_path in
118118+ (* Add to package map *)
119119+ List.iter (fun pkg ->
120120+ let existing = try Hashtbl.find pkg_map pkg.name with Not_found -> [] in
121121+ Hashtbl.replace pkg_map pkg.name ((handle, pkg) :: existing)
122122+ ) pkgs;
123123+ (* Look up member in registry for URLs *)
124124+ let member = Verse_registry.find_member registry ~handle in
125125+ let display_name =
126126+ try Hashtbl.find handle_to_name handle
127127+ with Not_found -> handle
128128+ in
129129+ Some {
130130+ handle;
131131+ display_name;
132132+ monorepo_url = (match member with Some m -> m.monorepo | None -> "");
133133+ opam_url = (match member with Some m -> m.opamrepo | None -> "");
134134+ package_count = List.length pkgs;
135135+ unique_packages = []; (* Will be filled in later *)
136136+ }
137137+ end else None
138138+ ) tracked_handles
139139+ in
140140+141141+ (* Add local member info *)
142142+ let local_member =
143143+ let member = Verse_registry.find_member registry ~handle:local_handle in
144144+ let display_name =
145145+ try Hashtbl.find handle_to_name local_handle
146146+ with Not_found -> local_handle
147147+ in
148148+ {
149149+ handle = local_handle;
150150+ display_name;
151151+ monorepo_url = (match member with Some m -> m.monorepo | None -> "");
152152+ opam_url = (match member with Some m -> m.opamrepo | None -> "");
153153+ package_count = List.length local_pkgs;
154154+ unique_packages = [];
155155+ }
156156+ in
157157+158158+ (* Build final package list with owners *)
159159+ let all_packages =
160160+ Hashtbl.fold (fun _name entries acc ->
161161+ match entries with
162162+ | [] -> acc
163163+ | (_, pkg) :: _ as all ->
164164+ let owners = List.map fst all in
165165+ (* Pick the best synopsis (first non-None) *)
166166+ let synopsis =
167167+ List.find_map (fun (_, p) -> p.synopsis) all
168168+ in
169169+ (* Merge depends from all sources *)
170170+ let depends =
171171+ List.concat_map (fun (_, p) -> p.depends) all
172172+ |> List.sort_uniq String.compare
173173+ in
174174+ { pkg with owners; synopsis; depends } :: acc
175175+ ) pkg_map []
176176+ |> List.sort (fun a b -> String.compare a.name b.name)
177177+ in
178178+179179+ (* Build set of all package names for dependency counting *)
180180+ let all_pkg_names =
181181+ List.fold_left (fun s p -> Hashtbl.replace s p.name (); s)
182182+ (Hashtbl.create 256) all_packages
183183+ in
184184+185185+ (* Group packages by repo *)
186186+ let repos_map : (string, pkg_info list) Hashtbl.t = Hashtbl.create 64 in
187187+ List.iter (fun (pkg : pkg_info) ->
188188+ let existing = try Hashtbl.find repos_map pkg.repo_name with Not_found -> [] in
189189+ Hashtbl.replace repos_map pkg.repo_name (pkg :: existing)
190190+ ) all_packages;
191191+192192+ (* Build forks status lookup from forks data if provided *)
193193+ let forks_by_repo : (string, (string * Forks.relationship) list) Hashtbl.t = Hashtbl.create 64 in
194194+ (match forks with
195195+ | Some f ->
196196+ List.iter (fun (ra : Forks.repo_analysis) ->
197197+ let statuses = List.map (fun (h, _src, rel) -> (h, rel)) ra.verse_sources in
198198+ Hashtbl.replace forks_by_repo ra.repo_name statuses
199199+ ) f.Forks.repos
200200+ | None -> ());
201201+202202+ (* Build repo_info list with dependency counts *)
203203+ let all_repos =
204204+ Hashtbl.fold (fun repo_name pkgs acc ->
205205+ let dev_repo = (List.hd pkgs).dev_repo in
206206+ let owners =
207207+ List.sort_uniq String.compare (List.concat_map (fun (p : pkg_info) -> p.owners) pkgs)
208208+ in
209209+ let fork_status =
210210+ try Hashtbl.find forks_by_repo repo_name with Not_found -> []
211211+ in
212212+ (* Count dependencies that are in our package set *)
213213+ let dep_count =
214214+ List.concat_map (fun (p : pkg_info) -> p.depends) pkgs
215215+ |> List.filter (fun d -> Hashtbl.mem all_pkg_names d)
216216+ |> List.sort_uniq String.compare
217217+ |> List.length
218218+ in
219219+ { ri_name = repo_name;
220220+ ri_dev_repo = dev_repo;
221221+ ri_packages = List.sort (fun a b -> String.compare a.name b.name) pkgs;
222222+ ri_owners = owners;
223223+ ri_fork_status = fork_status;
224224+ ri_dep_count = dep_count } :: acc
225225+ ) repos_map []
226226+ (* Sort by dependency count descending (apps with most deps first), then by name *)
227227+ |> List.sort (fun a b ->
228228+ let cmp = compare b.ri_dep_count a.ri_dep_count in
229229+ if cmp <> 0 then cmp else String.compare a.ri_name b.ri_name)
230230+ in
231231+232232+ (* Separate common and unique repos *)
233233+ let common_repos = List.filter (fun r -> List.length r.ri_owners > 1) all_repos in
234234+ let unique_repos = List.filter (fun r -> List.length r.ri_owners = 1) all_repos in
235235+236236+ (* Compute unique packages per member *)
237237+ let unique_by_handle = Hashtbl.create 32 in
238238+ List.iter (fun (pkg : pkg_info) ->
239239+ if List.length pkg.owners = 1 then begin
240240+ let handle = List.hd pkg.owners in
241241+ let existing = try Hashtbl.find unique_by_handle handle with Not_found -> [] in
242242+ Hashtbl.replace unique_by_handle handle (pkg.name :: existing)
243243+ end
244244+ ) all_packages;
245245+246246+ (* Update member infos with unique packages *)
247247+ let update_member m =
248248+ let unique = try Hashtbl.find unique_by_handle m.handle with Not_found -> [] in
249249+ { m with unique_packages = List.sort String.compare unique }
250250+ in
251251+252252+ let all_members = local_member :: member_infos in
253253+ let members = List.map update_member all_members in
254254+255255+ { local_handle; registry_name; registry_description; members; common_repos; unique_repos; all_packages }
256256+257257+(** Escape HTML special characters *)
258258+let html_escape s =
259259+ let buf = Buffer.create (String.length s) in
260260+ String.iter (function
261261+ | '<' -> Buffer.add_string buf "<"
262262+ | '>' -> Buffer.add_string buf ">"
263263+ | '&' -> Buffer.add_string buf "&"
264264+ | '"' -> Buffer.add_string buf """
265265+ | c -> Buffer.add_char buf c
266266+ ) s;
267267+ Buffer.contents buf
268268+269269+(** External link SVG icon *)
270270+let external_link_icon =
271271+ {|<svg class="ext-icon" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3.5 3H9V8.5M9 3L3 9"/></svg>|}
272272+273273+(** Format fork relationship as short string *)
274274+let format_relationship = function
275275+ | Forks.Same_url -> "="
276276+ | Forks.Same_commit -> "sync"
277277+ | Forks.I_am_ahead n -> Printf.sprintf "+%d" n
278278+ | Forks.I_am_behind n -> Printf.sprintf "-%d" n
279279+ | Forks.Diverged { my_ahead; their_ahead; _ } -> Printf.sprintf "+%d/-%d" my_ahead their_ahead
280280+ | Forks.Unrelated -> "unrel"
281281+ | Forks.Not_fetched -> "?"
282282+283283+(** Generate HTML from site data *)
284284+let generate_html data =
285285+ let buf = Buffer.create 16384 in
286286+ let add = Buffer.add_string buf in
287287+288288+ (* Build member lookups *)
289289+ let member_urls = Hashtbl.create 16 in
290290+ let member_names = Hashtbl.create 16 in
291291+ List.iter (fun m ->
292292+ Hashtbl.replace member_urls m.handle (m.monorepo_url, m.opam_url);
293293+ Hashtbl.replace member_names m.handle m.display_name
294294+ ) data.members;
295295+296296+ (* Helper to get display name for handle *)
297297+ let get_name handle =
298298+ try Hashtbl.find member_names handle with Not_found -> handle
299299+ in
300300+301301+ add {|<!DOCTYPE html>
302302+<html lang="en">
303303+<head>
304304+<meta charset="UTF-8">
305305+<meta name="viewport" content="width=device-width, initial-scale=1.0">
306306+<title>|};
307307+ add (html_escape data.registry_name);
308308+ add {|</title>
309309+<style>
310310+* { margin: 0; padding: 0; box-sizing: border-box; }
311311+body { font: 10pt/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; color: #333; max-width: 900px; margin: 0 auto; padding: 12px; }
312312+h1 { font-size: 14pt; font-weight: 600; margin-bottom: 4px; }
313313+.subtitle { font-size: 9pt; color: #666; margin-bottom: 12px; border-bottom: 1px solid #ddd; padding-bottom: 8px; }
314314+h2 { font-size: 11pt; font-weight: 600; margin: 16px 0 8px; color: #444; }
315315+h3 { font-size: 10pt; font-weight: 600; margin: 12px 0 6px; color: #555; }
316316+a { color: #0066cc; text-decoration: none; }
317317+a:hover { text-decoration: underline; }
318318+a.ext { color: #0088aa; }
319319+a.ext:hover { color: #006688; }
320320+.ext-icon { width: 10px; height: 10px; margin-left: 2px; vertical-align: baseline; position: relative; top: 1px; }
321321+.members { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 8px; margin-bottom: 16px; }
322322+.member { background: #f8f8f8; padding: 8px; border-radius: 4px; border: 1px solid #e0e0e0; }
323323+.member-name { font-weight: 600; margin-bottom: 2px; }
324324+.member-handle { font-size: 8pt; color: #888; margin-bottom: 4px; }
325325+.member-stats { font-size: 9pt; color: #666; }
326326+.member-links { font-size: 9pt; margin-top: 4px; }
327327+.member-links a { margin-right: 8px; }
328328+.section { margin-bottom: 20px; }
329329+.summary { background: #fafafa; border: 1px solid #e8e8e8; border-radius: 4px; padding: 12px; margin-bottom: 16px; }
330330+.summary-title { font-weight: 600; margin-bottom: 8px; }
331331+.summary-list { font-size: 9pt; color: #555; line-height: 1.6; }
332332+.summary-item { display: inline-block; background: #fff; border: 1px solid #ddd; padding: 1px 6px; border-radius: 3px; margin: 2px 2px; }
333333+.summary-item a { color: #333; }
334334+.repo { margin-bottom: 12px; padding: 8px; background: #fafafa; border-radius: 4px; }
335335+.repo-header { display: flex; align-items: baseline; gap: 8px; margin-bottom: 4px; }
336336+.repo-name { font-weight: 600; }
337337+.repo-name a { color: #333; }
338338+.repo-packages { font-size: 9pt; color: #666; margin-bottom: 4px; }
339339+.pkg-list { list-style: none; margin: 4px 0 0 0; padding: 0; }
340340+.pkg-list li { padding: 1px 0; color: #555; font-size: 8pt; }
341341+.pkg-list li::before { content: "-"; color: #999; margin-right: 6px; }
342342+.pkg-list b { font-weight: 500; color: #444; }
343343+.repo-forks { margin-top: 6px; }
344344+.repo-forks summary { font-size: 9pt; color: #666; cursor: pointer; }
345345+.repo-forks summary:hover { color: #444; }
346346+.fork-list { margin-top: 4px; font-size: 9pt; display: flex; flex-wrap: wrap; gap: 4px 12px; }
347347+.fork-item { color: #555; }
348348+.fork-item a { margin-left: 4px; }
349349+.fork-status { font-family: monospace; font-size: 8pt; padding: 1px 4px; border-radius: 2px; margin-left: 4px; }
350350+.fork-status.ahead { background: #e6f4ea; color: #137333; }
351351+.fork-status.behind { background: #fce8e6; color: #c5221f; }
352352+.fork-status.diverged { background: #fef7e0; color: #b06000; }
353353+.fork-status.sync { background: #e8f0fe; color: #1a73e8; }
354354+.unique-section { margin-top: 12px; }
355355+.unique-member { margin-bottom: 8px; }
356356+.unique-member-name { font-weight: 500; font-size: 9pt; color: #555; }
357357+.unique-list { font-size: 9pt; color: #666; margin-top: 2px; }
358358+.intro { background: #f0f7ff; border: 1px solid #d0e3f5; border-radius: 4px; padding: 10px 12px; margin-bottom: 16px; font-size: 9pt; line-height: 1.5; color: #444; }
359359+footer { margin-top: 20px; padding-top: 8px; border-top: 1px solid #ddd; font-size: 9pt; color: #888; }
360360+</style>
361361+</head>
362362+<body>
363363+|};
364364+365365+ (* Title and description *)
366366+ add (Printf.sprintf "<h1>%s</h1>\n" (html_escape data.registry_name));
367367+ (match data.registry_description with
368368+ | Some desc -> add (Printf.sprintf "<div class=\"subtitle\">%s</div>\n" (html_escape desc))
369369+ | None -> add "<div class=\"subtitle\"></div>\n");
370370+371371+ (* Intro section *)
372372+ add {|<div class="intro">
373373+This is an experiment in large-scale agentic coding using OCaml and OxCaml, where we're building environments to exchange vibe code at scale.
374374+Managed by <a class="ext" href="https://tangled.org/anil.recoil.org/monopam">monopam|}; add external_link_icon; add {|</a>,
375375+with the central registry at <a class="ext" href="https://tangled.org/eeg.cl.cam.ac.uk/opamverse">opamverse|}; add external_link_icon; add {|</a>.
376376+</div>
377377+|};
378378+379379+ (* Members section *)
380380+ add "<div class=\"section\">\n<h2>Members</h2>\n<div class=\"members\">\n";
381381+ List.iter (fun m ->
382382+ add "<div class=\"member\">\n";
383383+ add (Printf.sprintf "<div class=\"member-name\"><a href=\"https://%s\">%s</a></div>\n"
384384+ (html_escape m.handle) (html_escape m.display_name));
385385+ if m.display_name <> m.handle then
386386+ add (Printf.sprintf "<div class=\"member-handle\">%s</div>\n" (html_escape m.handle));
387387+ add (Printf.sprintf "<div class=\"member-stats\">%d packages" m.package_count);
388388+ if m.unique_packages <> [] then
389389+ add (Printf.sprintf ", %d unique" (List.length m.unique_packages));
390390+ add "</div>\n";
391391+ if m.monorepo_url <> "" || m.opam_url <> "" then begin
392392+ add "<div class=\"member-links\">";
393393+ if m.monorepo_url <> "" then
394394+ add (Printf.sprintf "<a class=\"ext\" href=\"%s\">mono%s</a>" (html_escape m.monorepo_url) external_link_icon);
395395+ if m.opam_url <> "" then
396396+ add (Printf.sprintf "<a class=\"ext\" href=\"%s\">opam%s</a>" (html_escape m.opam_url) external_link_icon);
397397+ add "</div>\n"
398398+ end;
399399+ add "</div>\n"
400400+ ) data.members;
401401+ add "</div>\n</div>\n";
402402+403403+ (* Summary section *)
404404+ add "<div class=\"section\">\n";
405405+ add "<div class=\"summary\">\n";
406406+ add (Printf.sprintf "<div class=\"summary-title\">Common Libraries (%d repos, %d packages)</div>\n"
407407+ (List.length data.common_repos)
408408+ (List.fold_left (fun acc r -> acc + List.length r.ri_packages) 0 data.common_repos));
409409+ add "<div class=\"summary-list\">\n";
410410+ List.iter (fun r ->
411411+ add (Printf.sprintf "<span class=\"summary-item\"><a href=\"#%s\">%s</a> <span style=\"color:#888\">(%d)</span></span>\n"
412412+ (html_escape r.ri_name) (html_escape r.ri_name) (List.length r.ri_packages))
413413+ ) data.common_repos;
414414+ add "</div>\n</div>\n";
415415+416416+ (* Member-specific summary *)
417417+ let members_with_unique = List.filter (fun m -> m.unique_packages <> []) data.members in
418418+ if members_with_unique <> [] then begin
419419+ add "<div class=\"summary\">\n";
420420+ add "<div class=\"summary-title\">Member-Specific Packages</div>\n";
421421+ add "<div class=\"unique-section\">\n";
422422+ List.iter (fun m ->
423423+ add "<div class=\"unique-member\">\n";
424424+ add (Printf.sprintf "<span class=\"unique-member-name\"><a href=\"https://%s\">%s</a>:</span> "
425425+ (html_escape m.handle) (html_escape m.display_name));
426426+ add "<span class=\"unique-list\">";
427427+ add (String.concat ", " (List.map html_escape m.unique_packages));
428428+ add "</span>\n";
429429+ add "</div>\n"
430430+ ) members_with_unique;
431431+ add "</div>\n</div>\n"
432432+ end;
433433+ add "</div>\n";
434434+435435+ (* Detailed repos section *)
436436+ if data.common_repos <> [] then begin
437437+ add "<div class=\"section\">\n<h2>Repository Details</h2>\n";
438438+439439+ List.iter (fun r ->
440440+ add (Printf.sprintf "<div class=\"repo\" id=\"%s\">\n" (html_escape r.ri_name));
441441+ add "<div class=\"repo-header\">";
442442+ add (Printf.sprintf "<span class=\"repo-name\"><a class=\"ext\" href=\"%s\">%s%s</a></span>"
443443+ (html_escape r.ri_dev_repo) (html_escape r.ri_name) external_link_icon);
444444+ add "</div>\n";
445445+446446+ (* Packages list - compact with names *)
447447+ add "<div class=\"repo-packages\">";
448448+ let pkg_names = List.map (fun (p : pkg_info) -> p.name) r.ri_packages in
449449+ add (String.concat ", " (List.map html_escape pkg_names));
450450+ add "</div>\n";
451451+452452+ (* Package descriptions as bullet list *)
453453+ let pkg_descs = List.filter_map (fun (p : pkg_info) ->
454454+ match p.synopsis with
455455+ | Some s -> Some (p.name, s)
456456+ | None -> None
457457+ ) r.ri_packages in
458458+ if pkg_descs <> [] then begin
459459+ add "<ul class=\"pkg-list\">\n";
460460+ List.iter (fun (name, desc) ->
461461+ add (Printf.sprintf "<li><b>%s</b>: %s</li>\n" (html_escape name) (html_escape desc))
462462+ ) pkg_descs;
463463+ add "</ul>\n"
464464+ end;
465465+466466+ (* Forks - at repo level with names *)
467467+ if List.length r.ri_owners > 1 then begin
468468+ let owner_links = List.map (fun h ->
469469+ Printf.sprintf "<a href=\"https://%s\">%s</a>" (html_escape h) (html_escape (get_name h))
470470+ ) (List.sort String.compare r.ri_owners) in
471471+ add "<details class=\"repo-forks\">\n";
472472+ add (Printf.sprintf "<summary>%d members (%s)</summary>\n"
473473+ (List.length r.ri_owners)
474474+ (String.concat ", " owner_links));
475475+ add "<div class=\"fork-list\">\n";
476476+ List.iter (fun handle ->
477477+ let mono_url, _opam_url =
478478+ try Hashtbl.find member_urls handle
479479+ with Not_found -> ("", "")
480480+ in
481481+ add "<span class=\"fork-item\">";
482482+ add (Printf.sprintf "<a href=\"https://%s\">%s</a>" (html_escape handle) (html_escape (get_name handle)));
483483+ (* Add status if available *)
484484+ (match List.assoc_opt handle r.ri_fork_status with
485485+ | Some rel ->
486486+ let status_str = format_relationship rel in
487487+ let status_class =
488488+ match rel with
489489+ | Forks.Same_url | Forks.Same_commit -> "sync"
490490+ | Forks.I_am_ahead _ -> "ahead"
491491+ | Forks.I_am_behind _ -> "behind"
492492+ | Forks.Diverged _ -> "diverged"
493493+ | _ -> ""
494494+ in
495495+ if status_class <> "" then
496496+ add (Printf.sprintf "<span class=\"fork-status %s\">%s</span>" status_class status_str)
497497+ else
498498+ add (Printf.sprintf "<span class=\"fork-status\">%s</span>" status_str)
499499+ | None -> ());
500500+ if mono_url <> "" then
501501+ add (Printf.sprintf "<a class=\"ext\" href=\"%s/%s\">mono%s</a>"
502502+ (html_escape mono_url) (html_escape r.ri_name) external_link_icon);
503503+ add "</span>\n"
504504+ ) (List.sort String.compare r.ri_owners);
505505+ add "</div>\n</details>\n"
506506+ end;
507507+508508+ add "</div>\n"
509509+ ) data.common_repos;
510510+511511+ add "</div>\n"
512512+ end;
513513+514514+ (* Footer with generation date *)
515515+ let now = Unix.gettimeofday () in
516516+ let tm = Unix.gmtime now in
517517+ let date_str = Printf.sprintf "%04d-%02d-%02d"
518518+ (tm.Unix.tm_year + 1900) (tm.Unix.tm_mon + 1) tm.Unix.tm_mday in
519519+ add (Printf.sprintf "<footer>Generated by monopam on %s | %d members | %d repos | %d packages</footer>\n"
520520+ date_str (List.length data.members) (List.length data.common_repos + List.length data.unique_repos) (List.length data.all_packages));
521521+522522+ add "</body>\n</html>\n";
523523+ Buffer.contents buf
524524+525525+(** Generate the site and return the HTML content *)
526526+let generate ~fs ~config ?forks ~registry () =
527527+ let data = collect_data ~fs ~config ?forks ~registry () in
528528+ generate_html data
529529+530530+(** Write the site to a file *)
531531+let write ~fs ~config ?forks ~registry ~output_path () =
532532+ let html = generate ~fs ~config ?forks ~registry () in
533533+ let eio_path = Eio.Path.(fs / Fpath.to_string output_path) in
534534+ Eio.Path.save ~create:(`Or_truncate 0o644) eio_path html;
535535+ Ok ()
+82
monopam/lib/site.mli
···11+(** Generate a static HTML site representing the monoverse map.
22+33+ The site command generates an index.html that shows:
44+ - All verse members with links to their repos
55+ - Summary of common libraries and member-specific packages
66+ - Detailed repository information with fork status *)
77+88+(** {1 Types} *)
99+1010+(** Information about a package in the verse *)
1111+type pkg_info = {
1212+ name : string;
1313+ synopsis : string option;
1414+ repo_name : string;
1515+ dev_repo : string; (** Upstream git URL *)
1616+ owners : string list; (** List of handles that have this package *)
1717+ depends : string list; (** Package dependencies *)
1818+}
1919+2020+(** Information about a repository (group of packages) *)
2121+type repo_info = {
2222+ ri_name : string;
2323+ ri_dev_repo : string;
2424+ ri_packages : pkg_info list;
2525+ ri_owners : string list; (** All handles that have any package from this repo *)
2626+ ri_fork_status : (string * Forks.relationship) list; (** (handle, relationship) *)
2727+ ri_dep_count : int; (** Number of dependencies (for sorting) *)
2828+}
2929+3030+(** Information about a verse member *)
3131+type member_info = {
3232+ handle : string;
3333+ display_name : string; (** Name to display (from registry or handle) *)
3434+ monorepo_url : string;
3535+ opam_url : string;
3636+ package_count : int;
3737+ unique_packages : string list; (** Packages unique to this member *)
3838+}
3939+4040+(** Aggregated site data *)
4141+type site_data = {
4242+ local_handle : string;
4343+ registry_name : string;
4444+ registry_description : string option;
4545+ members : member_info list;
4646+ common_repos : repo_info list; (** Repos that exist in multiple members *)
4747+ unique_repos : repo_info list; (** Repos unique to one member *)
4848+ all_packages : pkg_info list; (** All packages *)
4949+}
5050+5151+(** {1 Generation} *)
5252+5353+val collect_data :
5454+ fs:Eio.Fs.dir_ty Eio.Path.t ->
5555+ config:Verse_config.t ->
5656+ ?forks:Forks.t ->
5757+ registry:Verse_registry.t ->
5858+ unit ->
5959+ site_data
6060+(** [collect_data ~fs ~config ?forks ~registry ()] scans the workspace and verse members
6161+ to collect package information for the site. If [forks] is provided,
6262+ includes fork status information for each repository. *)
6363+6464+val generate :
6565+ fs:Eio.Fs.dir_ty Eio.Path.t ->
6666+ config:Verse_config.t ->
6767+ ?forks:Forks.t ->
6868+ registry:Verse_registry.t ->
6969+ unit ->
7070+ string
7171+(** [generate ~fs ~config ?forks ~registry ()] generates the HTML content for the site. *)
7272+7373+val write :
7474+ fs:Eio.Fs.dir_ty Eio.Path.t ->
7575+ config:Verse_config.t ->
7676+ ?forks:Forks.t ->
7777+ registry:Verse_registry.t ->
7878+ output_path:Fpath.t ->
7979+ unit ->
8080+ (unit, string) result
8181+(** [write ~fs ~config ?forks ~registry ~output_path ()] generates and writes the site
8282+ to the specified output path. *)
···1515 For a subtree named "ocaml-foo", this would produce:
1616 [git+https://tangled.org/anil.recoil.org/ocaml-foo] *)
17171818+(** How a source entry was created. *)
1919+type origin =
2020+ | Fork (** Created via [monopam fork] - subtree split from monorepo *)
2121+ | Join (** Created via [monopam join] - external repo brought into monorepo *)
2222+1823(** A source entry for a subtree. *)
1924type entry = {
2025 url : string; (** Our dev-repo URL (e.g., "git+https://github.com/avsm/braid") *)
2126 upstream : string option; (** Original upstream URL if this is a fork *)
2227 branch : string option; (** Override branch (default: main) *)
2328 reason : string option; (** Why we have a custom source *)
2929+ origin : origin option; (** How this entry was created *)
2430}
25312632(** The sources registry - maps subtree names to source entries. *)
+69-14
monopam/lib/status.ml
···160160 Fmt.pf ppf "@[<h>%-20s checkout: %a subtree: %a@]" (Package.name t.package)
161161 pp_checkout_status t.checkout pp_subtree_status t.subtree
162162163163+(** Extract handle from a tangled.org URL like "git+https://tangled.org/handle/repo" *)
164164+let extract_handle_from_url url =
165165+ let url = if String.starts_with ~prefix:"git+" url then
166166+ String.sub url 4 (String.length url - 4)
167167+ else url in
168168+ let uri = Uri.of_string url in
169169+ match Uri.host uri with
170170+ | Some "tangled.org" ->
171171+ let path = Uri.path uri in
172172+ (* Path is like "/handle/repo" - extract first component *)
173173+ let path = if String.length path > 0 && path.[0] = '/' then
174174+ String.sub path 1 (String.length path - 1)
175175+ else path in
176176+ (match String.index_opt path '/' with
177177+ | Some i -> Some (String.sub path 0 i)
178178+ | None -> Some path)
179179+ | _ -> None
180180+181181+(** Format origin indicator from sources registry entry *)
182182+let pp_origin_indicator ppf entry =
183183+ match entry with
184184+ | None -> ()
185185+ | Some Sources_registry.{ origin = Some Sources_registry.Fork; _ } ->
186186+ Fmt.pf ppf " %a" Fmt.(styled `Magenta string) "^"
187187+ | Some Sources_registry.{ origin = Some Sources_registry.Join; upstream = Some url; _ } ->
188188+ (match extract_handle_from_url url with
189189+ | Some handle ->
190190+ (* Abbreviate handle - take first part before dot, max 8 chars *)
191191+ let abbrev = match String.index_opt handle '.' with
192192+ | Some i -> String.sub handle 0 i
193193+ | None -> handle
194194+ in
195195+ let abbrev = if String.length abbrev > 8 then String.sub abbrev 0 8 else abbrev in
196196+ Fmt.pf ppf " %a" Fmt.(styled `Cyan (fun ppf s -> pf ppf "v:%s" s)) abbrev
197197+ | None -> Fmt.pf ppf " %a" Fmt.(styled `Cyan string) "v:")
198198+ | Some Sources_registry.{ origin = Some Sources_registry.Join; _ } ->
199199+ Fmt.pf ppf " %a" Fmt.(styled `Cyan string) "v:"
200200+ | Some _ -> ()
201201+163202(** Compact status for actionable items with colors *)
164164-let pp_compact ppf t =
203203+let pp_compact ?sources ppf t =
165204 let name = Package.name t.package in
205205+ let subtree = Package.subtree_prefix t.package in
206206+ let entry = match sources with
207207+ | Some s -> Sources_registry.find s ~subtree
208208+ | None -> None
209209+ in
166210 (* Helper to print remote sync info *)
167211 let pp_remote ab =
168212 if ab.Git.ahead > 0 && ab.behind > 0 then
···184228 Fmt.pf ppf "%-22s %a" name
185229 Fmt.(styled `Blue (fun ppf n -> pf ppf "local:-%d" n))
186230 n;
187187- pp_remote ab
231231+ pp_remote ab;
232232+ pp_origin_indicator ppf entry
188233 | Clean ab, Present, Subtree_ahead n ->
189234 Fmt.pf ppf "%-22s %a" name
190235 Fmt.(styled `Blue (fun ppf n -> pf ppf "local:+%d" n))
191236 n;
192192- pp_remote ab
237237+ pp_remote ab;
238238+ pp_origin_indicator ppf entry
193239 (* Trees differ but can't determine count *)
194240 | Clean ab, Present, Trees_differ ->
195241 Fmt.pf ppf "%-22s %a" name Fmt.(styled `Blue string) "local:sync";
196196- pp_remote ab
242242+ pp_remote ab;
243243+ pp_origin_indicator ppf entry
197244 (* Remote sync issues only *)
198245 | Clean ab, Present, (In_sync | Unknown) when ab.ahead > 0 && ab.behind > 0 ->
199246 Fmt.pf ppf "%-22s %a" name
200247 Fmt.(styled `Yellow (fun ppf (a, b) -> pf ppf "remote:+%d/-%d" a b))
201201- (ab.ahead, ab.behind)
248248+ (ab.ahead, ab.behind);
249249+ pp_origin_indicator ppf entry
202250 | Clean ab, Present, (In_sync | Unknown) when ab.ahead > 0 ->
203251 Fmt.pf ppf "%-22s %a" name
204252 Fmt.(styled `Cyan (fun ppf n -> pf ppf "remote:+%d" n))
205205- ab.ahead
253253+ ab.ahead;
254254+ pp_origin_indicator ppf entry
206255 | Clean ab, Present, (In_sync | Unknown) when ab.behind > 0 ->
207256 Fmt.pf ppf "%-22s %a" name
208257 Fmt.(styled `Red (fun ppf n -> pf ppf "remote:-%d" n))
209209- ab.behind
258258+ ab.behind;
259259+ pp_origin_indicator ppf entry
210260 (* Other issues *)
211261 | Clean _, Not_added, _ ->
212212- Fmt.pf ppf "%-22s %a" name Fmt.(styled `Magenta string) "(no subtree)"
262262+ Fmt.pf ppf "%-22s %a" name Fmt.(styled `Magenta string) "(no subtree)";
263263+ pp_origin_indicator ppf entry
213264 | Missing, _, _ ->
214214- Fmt.pf ppf "%-22s %a" name Fmt.(styled `Red string) "(no checkout)"
265265+ Fmt.pf ppf "%-22s %a" name Fmt.(styled `Red string) "(no checkout)";
266266+ pp_origin_indicator ppf entry
215267 | Not_a_repo, _, _ ->
216216- Fmt.pf ppf "%-22s %a" name Fmt.(styled `Red string) "(not a repo)"
268268+ Fmt.pf ppf "%-22s %a" name Fmt.(styled `Red string) "(not a repo)";
269269+ pp_origin_indicator ppf entry
217270 | Dirty, _, _ ->
218218- Fmt.pf ppf "%-22s %a" name Fmt.(styled `Yellow string) "(dirty)"
271271+ Fmt.pf ppf "%-22s %a" name Fmt.(styled `Yellow string) "(dirty)";
272272+ pp_origin_indicator ppf entry
219273 | Clean _, Present, (In_sync | Unknown) ->
220220- Fmt.pf ppf "%-22s %a" name Fmt.(styled `Green string) "ok"
274274+ Fmt.pf ppf "%-22s %a" name Fmt.(styled `Green string) "ok";
275275+ pp_origin_indicator ppf entry
221276222222-let pp_summary ppf statuses =
277277+let pp_summary ?sources ppf statuses =
223278 let total = List.length statuses in
224279 let actionable = filter_actionable statuses in
225280 let synced = List.filter is_fully_synced statuses |> List.length in
···258313 "all synced";
259314 (* Only show actionable items *)
260315 if actionable <> [] then
261261- List.iter (fun t -> Fmt.pf ppf " %a\n" pp_compact t) actionable
316316+ List.iter (fun t -> Fmt.pf ppf " %a\n" (pp_compact ?sources) t) actionable
+7-2
monopam/lib/status.mli
···112112val pp : t Fmt.t
113113(** [pp] formats a single package status. *)
114114115115-val pp_summary : t list Fmt.t
116116-(** [pp_summary] formats a summary of all package statuses. *)
115115+val pp_compact : ?sources:Sources_registry.t -> t Fmt.t
116116+(** [pp_compact ?sources] formats a single package status in compact form with colors.
117117+ If [sources] is provided, displays origin indicators (^ for fork, v:handle for join). *)
118118+119119+val pp_summary : ?sources:Sources_registry.t -> t list Fmt.t
120120+(** [pp_summary ?sources] formats a summary of all package statuses.
121121+ If [sources] is provided, displays origin indicators for each package. *)
+2-2
monopam/lib/verse.ml
···2626let error_hint = function
2727 | Config_error _ ->
2828 Some
2929- "Run 'monopam verse init --handle <your-handle>' to create a workspace."
2929+ "Run 'monopam init --handle <your-handle>' to create a workspace."
3030 | Git_error (Git.Dirty_worktree _) ->
3131 Some "Commit or stash your changes first: git status"
3232 | Git_error (Git.Command_failed (cmd, _))
···4545 | Workspace_exists _ ->
4646 Some "Use a different directory, or remove the existing workspace."
4747 | Not_a_workspace _ ->
4848- Some "Run 'monopam verse init --handle <your-handle>' to create a workspace here."
4848+ Some "Run 'monopam init --handle <your-handle>' to create a workspace here."
4949 | Package_not_found (pkg, handle) ->
5050 Some (Fmt.str "Run 'monopam verse pull %s' to sync their opam repo, then check package name: %s" handle pkg)
5151 | Package_already_exists pkgs ->
+18-13
monopam/lib/verse_registry.ml
···11type member = {
22 handle : string;
33+ name : string option;
34 monorepo : string;
45 monorepo_branch : string option;
56 opamrepo : string;
67 opamrepo_branch : string option;
78}
88-type t = { name : string; members : member list }
99+type t = { name : string; description : string option; members : member list }
9101011let default_url = "https://tangled.org/eeg.cl.cam.ac.uk/opamverse"
1112···2728let pp_member ppf m =
2829 let mono_str = encode_url_with_branch m.monorepo m.monorepo_branch in
2930 let opam_str = encode_url_with_branch m.opamrepo m.opamrepo_branch in
3030- Fmt.pf ppf "@[<hov 2>%s ->@ mono:%s@ opam:%s@]" m.handle mono_str opam_str
3131+ let name_str = match m.name with Some n -> n | None -> m.handle in
3232+ Fmt.pf ppf "@[<hov 2>%s (%s) ->@ mono:%s@ opam:%s@]" name_str m.handle mono_str opam_str
31333234let pp ppf t =
3333- Fmt.pf ppf "@[<v>registry: %s@,members:@, @[<v>%a@]@]" t.name
3535+ Fmt.pf ppf "@[<v>registry: %s%a@,members:@, @[<v>%a@]@]" t.name
3636+ Fmt.(option (fun ppf s -> pf ppf "@,description: %s" s)) t.description
3437 Fmt.(list ~sep:cut pp_member)
3538 t.members
3639···4750let member_codec : member Tomlt.t =
4851 Tomlt.(
4952 Table.(
5050- obj (fun handle monorepo_raw opamrepo_raw ->
5353+ obj (fun handle name monorepo_raw opamrepo_raw ->
5154 let monorepo, monorepo_branch = parse_url_with_branch monorepo_raw in
5255 let opamrepo, opamrepo_branch = parse_url_with_branch opamrepo_raw in
5353- { handle; monorepo; monorepo_branch; opamrepo; opamrepo_branch })
5454- |> mem "handle" string ~enc:(fun m -> m.handle)
5555- |> mem "monorepo" string ~enc:(fun m -> encode_url_with_branch m.monorepo m.monorepo_branch)
5656- |> mem "opamrepo" string ~enc:(fun m -> encode_url_with_branch m.opamrepo m.opamrepo_branch)
5656+ { handle; name; monorepo; monorepo_branch; opamrepo; opamrepo_branch })
5757+ |> mem "handle" string ~enc:(fun (m : member) -> m.handle)
5858+ |> opt_mem "name" string ~enc:(fun (m : member) -> m.name)
5959+ |> mem "monorepo" string ~enc:(fun (m : member) -> encode_url_with_branch m.monorepo m.monorepo_branch)
6060+ |> mem "opamrepo" string ~enc:(fun (m : member) -> encode_url_with_branch m.opamrepo m.opamrepo_branch)
5761 |> finish))
58625959-type registry_info = { r_name : string }
6363+type registry_info = { r_name : string; r_description : string option }
60646165let registry_info_codec : registry_info Tomlt.t =
6266 Tomlt.(
6367 Table.(
6464- obj (fun r_name -> { r_name })
6868+ obj (fun r_name r_description -> { r_name; r_description })
6569 |> mem "name" string ~enc:(fun r -> r.r_name)
7070+ |> opt_mem "description" string ~enc:(fun r -> r.r_description)
6671 |> finish))
67726873let codec : t Tomlt.t =
6974 Tomlt.(
7075 Table.(
7176 obj (fun registry members ->
7272- { name = registry.r_name; members = Option.value ~default:[] members })
7373- |> mem "registry" registry_info_codec ~enc:(fun t -> { r_name = t.name })
7777+ { name = registry.r_name; description = registry.r_description; members = Option.value ~default:[] members })
7878+ |> mem "registry" registry_info_codec ~enc:(fun t -> { r_name = t.name; r_description = t.description })
7479 |> opt_mem "members" (list member_codec) ~enc:(fun t ->
7580 match t.members with [] -> None | ms -> Some ms)
7681 |> finish))
77827878-let empty_registry = { name = "opamverse"; members = [] }
8383+let empty_registry = { name = "opamverse"; description = None; members = [] }
79848085let load ~fs path =
8186 let path_str = Fpath.to_string path in
+2
monopam/lib/verse_registry.mli
···7788type member = {
99 handle : string; (** Tangled handle (e.g., "alice.bsky.social") *)
1010+ name : string option; (** Display name (e.g., "Alice Smith") *)
1011 monorepo : string; (** Git URL of the member's monorepo *)
1112 monorepo_branch : string option; (** Optional branch for monorepo (from URL#branch) *)
1213 opamrepo : string; (** Git URL of the member's opam overlay repository *)
···19202021type t = {
2122 name : string; (** Registry name *)
2323+ description : string option; (** Registry description *)
2224 members : member list; (** List of registered members *)
2325}
2426(** The parsed registry contents. *)
+17
sortal/.gitignore
···11+# OCaml build artifacts
22+_build/
33+*.install
44+*.merlin
55+66+# Third-party sources (fetch locally with opam source)
77+third_party/
88+99+# Editor and OS files
1010+.DS_Store
1111+*.swp
1212+*~
1313+.vscode/
1414+.idea/
1515+1616+# Opam local switch
1717+_opam/
···11+ISC License
22+33+Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>
44+55+Permission to use, copy, modify, and distribute this software for any
66+purpose with or without fee is hereby granted, provided that the above
77+copyright notice and this permission notice appear in all copies.
88+99+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
1010+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
1111+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
1212+ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
1313+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
1414+ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
1515+OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+170
sortal/README.md
···11+# Sortal - Contact Metadata Management Library
22+33+Sortal is an OCaml library that provides a comprehensive system for managing
44+contact metadata with temporal validity tracking. It stores data in
55+XDG-compliant locations using the YAML format and optionally versions all changes
66+with git.
77+88+## Features
99+1010+- **Temporal Support**: Track how contact information changes over time (emails, organizations, URLs)
1111+- **XDG-compliant storage**: Contact metadata stored in standard XDG data directories
1212+- **YAML format**: Human-readable YAML files with type-safe encoding/decoding using yamlt
1313+- **Rich metadata**: Support for multiple names, emails (typed), organizations, services (GitHub, social media), ORCID, URLs, and Atom feeds
1414+- **Git Versioning**: Optional automatic git commits for all changes with descriptive messages
1515+- **CLI Interface**: Full command-line interface for CRUD operations on contacts
1616+- **Simple API**: Easy-to-use functions for saving, loading, searching, and deleting contacts
1717+1818+## Metadata Fields
1919+2020+Each contact can include:
2121+2222+- `handle`: Unique identifier/username (required)
2323+- `names`: List of full names with primary name first (required)
2424+- `email`: Email address
2525+- `icon`: Avatar/icon URL
2626+- `thumbnail`: Path to a local thumbnail image file
2727+- `github`: GitHub username
2828+- `twitter`: Twitter/X username
2929+- `bluesky`: Bluesky handle
3030+- `mastodon`: Mastodon handle (with instance)
3131+- `orcid`: ORCID identifier
3232+- `url`: Personal/professional website
3333+- `atom_feeds`: List of Atom/RSS feed URLs
3434+3535+## Storage
3636+3737+Contact data is stored as individual YAML files in the XDG data directory:
3838+3939+- Default location: `$HOME/.local/share/sortal/`
4040+- Override with: `SORTAL_DATA_DIR` or `XDG_DATA_HOME`
4141+- Each contact stored as: `{handle}.yaml`
4242+- Format: Human-readable YAML with temporal data support
4343+4444+## Usage Example
4545+4646+### Basic Usage
4747+4848+```ocaml
4949+(* Create a contact store from filesystem *)
5050+let store = Sortal.create env#fs "myapp" in
5151+5252+(* Or create from an existing XDG context (recommended when using eiocmd) *)
5353+let store = Sortal.create_from_xdg xdg in
5454+5555+(* Create a new contact *)
5656+let contact = Sortal.Contact.make
5757+ ~handle:"avsm"
5858+ ~names:["Anil Madhavapeddy"]
5959+ ~email:"anil@recoil.org"
6060+ ~github:"avsm"
6161+ ~orcid:"0000-0002-7890-1234"
6262+ () in
6363+6464+(* Save the contact *)
6565+Sortal.save store contact;
6666+6767+(* Lookup by handle *)
6868+match Sortal.lookup store "avsm" with
6969+| Some c -> Printf.printf "Found: %s\n" (Sortal.Contact.name c)
7070+| None -> Printf.printf "Not found\n"
7171+7272+(* Search for contacts by name *)
7373+let matches = Sortal.search_all store "Anil" in
7474+List.iter (fun c ->
7575+ Printf.printf "%s: %s\n"
7676+ (Sortal.Contact.handle c)
7777+ (Sortal.Contact.name c)
7878+) matches
7979+8080+(* List all contacts *)
8181+let all_contacts = Sortal.list store in
8282+List.iter (fun c ->
8383+ Printf.printf "%s: %s\n"
8484+ (Sortal.Contact.handle c)
8585+ (Sortal.Contact.name c)
8686+) all_contacts
8787+```
8888+8989+## CLI Tool
9090+9191+The library includes a standalone `sortal` CLI tool with full CRUD functionality:
9292+9393+```bash
9494+# Initialize git versioning (optional)
9595+sortal git-init
9696+9797+# List all contacts
9898+sortal list
9999+100100+# Show details for a specific contact
101101+sortal show avsm
102102+103103+# Search for contacts
104104+sortal search "Anil"
105105+106106+# Show database statistics
107107+sortal stats
108108+109109+# Add a new contact
110110+sortal add jsmith --name "John Smith" --email "john@example.com" --kind person
111111+112112+# Add metadata to contacts
113113+sortal add-org jsmith "Acme Corp" --title "Software Engineer" --from 2020-01
114114+sortal add-service jsmith "https://github.com/jsmith" --kind github --handle jsmith
115115+sortal add-email jsmith "john.work@example.com" --type work --from 2020-01
116116+sortal add-url jsmith "https://jsmith.example.com" --label "Personal website"
117117+118118+# Remove metadata
119119+sortal remove-email jsmith "old@example.com"
120120+sortal remove-service jsmith "https://old-service.com"
121121+sortal remove-org jsmith "Old Company"
122122+sortal remove-url jsmith "https://old-url.com"
123123+124124+# Delete a contact
125125+sortal delete jsmith
126126+127127+# Synchronize data (convert thumbnails to PNG)
128128+sortal sync
129129+```
130130+131131+## Git Versioning
132132+133133+Sortal includes a `Sortal_git_store` module that provides automatic git commits
134134+for all contact modifications:
135135+136136+```ocaml
137137+open Sortal
138138+139139+(* Create a git-backed store *)
140140+let git_store = Git_store.create store env in
141141+142142+(* Initialize git repository *)
143143+let () = match Git_store.init git_store with
144144+ | Ok () -> Logs.app (fun m -> m "Git initialized")
145145+ | Error msg -> Logs.err (fun m -> m "Error: %s" msg)
146146+in
147147+148148+(* Save a contact - automatically commits with descriptive message *)
149149+let contact = Contact.make ~handle:"jsmith" ~names:["John Smith"] () in
150150+match Git_store.save git_store contact with
151151+| Ok () -> Logs.app (fun m -> m "Contact saved and committed")
152152+| Error msg -> Logs.err (fun m -> m "Error: %s" msg)
153153+```
154154+155155+**Commit Messages**: All git store operations create descriptive commit messages:
156156+- `save`: "Add contact @handle (Name)" or "Update contact @handle (Name)"
157157+- `delete`: "Delete contact @handle (Name)"
158158+- `add_email`: "Update @handle: add email address@example.com"
159159+- `remove_email`: "Update @handle: remove email address@example.com"
160160+- `add_service`: "Update @handle: add service Kind (url)"
161161+- `add_organization`: "Update @handle: add organization Org Name"
162162+- And similar for all other operations
163163+164164+## Project Status
165165+166166+Still very much just used by Anil Madhavapeddy. You're welcome to try it, but let me know...
167167+168168+## License
169169+170170+ISC License - see [LICENSE.md](LICENSE.md) for details.
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Sortal - Username to metadata mapping with XDG storage
77+88+ This library provides a system for mapping usernames to various metadata
99+ including URLs, emails, ORCID identifiers, and social media handles.
1010+ It uses XDG Base Directory Specification for storage locations and
1111+ provides temporal support for time-bounded information like historical
1212+ email addresses and employment records.
1313+1414+ {b Storage:}
1515+1616+ Contact metadata is stored as YAML files in the XDG data directory,
1717+ with one file per contact using the handle as the filename. The YAML
1818+ format uses the same Jsont codec definitions as JSON for seamless
1919+ compatibility.
2020+2121+ {b Typical Usage:}
2222+2323+ {[
2424+ let store = Sortal.create env#fs "myapp" in
2525+ let contact = Sortal.Contact.make
2626+ ~handle:"avsm"
2727+ ~names:["Anil Madhavapeddy"]
2828+ ~email:"anil@recoil.org"
2929+ ~github:"avsm"
3030+ ~orcid:"0000-0002-7890-1234"
3131+ () in
3232+ Sortal.save store contact;
3333+3434+ match Sortal.lookup store "avsm" with
3535+ | Some c -> Printf.printf "Found: %s\n" (Sortal.Contact.name c)
3636+ | None -> Printf.printf "Not found\n"
3737+ ]}
3838+*)
3939+4040+(** {1 Schema Modules}
4141+4242+ These modules define the data types and serialization formats.
4343+ They are re-exported from {!Sortal_schema} for convenience.
4444+ For version-specific access, use [Sortal_schema.V1.*]. *)
4545+4646+(** Temporal validity support for time-bounded contact fields. *)
4747+module Temporal = Sortal_schema.Temporal
4848+4949+(** Feed subscription metadata. *)
5050+module Feed = Sortal_schema.Feed
5151+5252+(** Contact metadata with temporal support. *)
5353+module Contact = Sortal_schema.Contact
5454+5555+(** {1 Core Modules} *)
5656+5757+(** Contact store with XDG-compliant storage. *)
5858+module Store = Sortal_store
5959+6060+(** Git-backed contact store with automatic version control. *)
6161+module Git_store = Sortal_git_store
6262+6363+(** Cmdliner integration for CLI applications. *)
6464+module Cmd = Sortal_cmd
6565+6666+(** {1 Convenience Re-exports}
6767+6868+ These are re-exported from {!Store} for easier top-level access. *)
6969+7070+(** The contact store type. *)
7171+type t = Store.t
7272+7373+(** [create fs app_name] creates a new contact store.
7474+ See {!Store.create} for details. *)
7575+val create : Eio.Fs.dir_ty Eio.Path.t -> string -> t
7676+7777+(** [create_from_xdg xdg] creates a contact store from an XDG context.
7878+ See {!Store.create_from_xdg} for details. *)
7979+val create_from_xdg : Xdge.t -> t
8080+8181+(** [save t contact] saves a contact to the store.
8282+ See {!Store.save} for details. *)
8383+val save : t -> Contact.t -> unit
8484+8585+(** [lookup t handle] retrieves a contact by handle.
8686+ See {!Store.lookup} for details. *)
8787+val lookup : t -> string -> Contact.t option
8888+8989+(** [delete t handle] removes a contact from the store.
9090+ See {!Store.delete} for details. *)
9191+val delete : t -> string -> unit
9292+9393+(** [list t] returns all contacts in the store.
9494+ See {!Store.list} for details. *)
9595+val list : t -> Contact.t list
9696+9797+(** [thumbnail_path t contact] returns the path to a contact's thumbnail.
9898+ See {!Store.thumbnail_path} for details. *)
9999+val thumbnail_path : t -> Contact.t -> Eio.Fs.dir_ty Eio.Path.t option
100100+101101+(** [find_by_name t name] searches for contacts by name.
102102+ See {!Store.find_by_name} for details. *)
103103+val find_by_name : t -> string -> Contact.t
104104+105105+(** [find_by_name_opt t name] searches for contacts by name.
106106+ See {!Store.find_by_name_opt} for details. *)
107107+val find_by_name_opt : t -> string -> Contact.t option
108108+109109+(** [search_all t query] searches for contacts matching a query.
110110+ See {!Store.search_all} for details. *)
111111+val search_all : t -> string -> Contact.t list
112112+113113+(** [handle_of_name name] generates a handle from a full name.
114114+ See {!Store.handle_of_name} for details. *)
115115+val handle_of_name : string -> string
116116+117117+(** [pp ppf t] pretty prints the contact store.
118118+ See {!Store.pp} for details. *)
119119+val pp : Format.formatter -> t -> unit
+464
sortal/lib/core/sortal_cmd.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+open Cmdliner
77+88+module Contact = Sortal_schema.Contact
99+module Temporal = Sortal_schema.Temporal
1010+1111+let is_png path =
1212+ let ext = String.lowercase_ascii (Filename.extension path) in
1313+ ext = ".png"
1414+1515+let convert_to_png src_path =
1616+ let base = Filename.remove_extension src_path in
1717+ let dst_path = base ^ ".png" in
1818+ let cmd = Printf.sprintf "magick %s %s" (Filename.quote src_path) (Filename.quote dst_path) in
1919+ let ret = Unix.system cmd in
2020+ match ret with
2121+ | Unix.WEXITED 0 -> Ok dst_path
2222+ | Unix.WEXITED n -> Error (Printf.sprintf "magick exited with code %d" n)
2323+ | Unix.WSIGNALED n -> Error (Printf.sprintf "magick killed by signal %d" n)
2424+ | Unix.WSTOPPED n -> Error (Printf.sprintf "magick stopped by signal %d" n)
2525+2626+let list_cmd xdg =
2727+ let store = Sortal_store.create_from_xdg xdg in
2828+ let contacts = Sortal_store.list store in
2929+ let sorted = List.sort Contact.compare contacts in
3030+ Printf.printf "Total contacts: %d\n" (List.length sorted);
3131+ List.iter (fun c ->
3232+ Printf.printf "@%s: %s\n" (Contact.handle c) (Contact.name c)
3333+ ) sorted;
3434+ 0
3535+3636+let show_cmd handle xdg =
3737+ let store = Sortal_store.create_from_xdg xdg in
3838+ match Sortal_store.lookup store handle with
3939+ | Some c ->
4040+ (* Use the pretty printer for rich temporal display *)
4141+ Fmt.pr "%a@." Contact.pp c;
4242+ 0
4343+ | None -> Logs.err (fun m -> m "Contact not found: %s" handle); 1
4444+4545+let search_cmd query xdg =
4646+ let store = Sortal_store.create_from_xdg xdg in
4747+ match Sortal_store.search_all store query with
4848+ | [] ->
4949+ Logs.warn (fun m -> m "No contacts found matching: %s" query);
5050+ 1
5151+ | matches ->
5252+ Logs.app (fun m -> m "Found %d match%s:"
5353+ (List.length matches)
5454+ (if List.length matches = 1 then "" else "es"));
5555+ List.iter (fun c ->
5656+ Logs.app (fun m -> m "@%s: %s" (Contact.handle c) (Contact.name c));
5757+ Option.iter (fun e -> Logs.app (fun m -> m " Email: %s" e)) (Contact.current_email c);
5858+ Option.iter (fun u -> Logs.app (fun m -> m " URL: %s" u)) (Contact.best_url c)
5959+ ) matches;
6060+ 0
6161+6262+let stats_cmd () xdg =
6363+ let store = Sortal_store.create_from_xdg xdg in
6464+ let contacts = Sortal_store.list store in
6565+ let total = List.length contacts in
6666+ let count pred = List.filter pred contacts |> List.length in
6767+ let with_email = count (fun c -> Contact.emails c <> []) in
6868+ let with_org = count (fun c -> Contact.organizations c <> []) in
6969+ let with_url = count (fun c -> Contact.urls c <> []) in
7070+ let with_service = count (fun c -> Contact.services c <> []) in
7171+ let with_orcid = count (fun c -> Option.is_some (Contact.orcid c)) in
7272+ let with_feeds = count (fun c -> Option.is_some (Contact.feeds c)) in
7373+ let total_feeds =
7474+ List.fold_left (fun acc c ->
7575+ acc + Option.fold ~none:0 ~some:List.length (Contact.feeds c)
7676+ ) 0 contacts
7777+ in
7878+ let total_services =
7979+ List.fold_left (fun acc c ->
8080+ acc + List.length (Contact.services c)
8181+ ) 0 contacts
8282+ in
8383+ let pct n = float_of_int n /. float_of_int total *. 100. in
8484+ Logs.app (fun m -> m "Contact Database Statistics:");
8585+ Logs.app (fun m -> m " Total contacts: %d" total);
8686+ Logs.app (fun m -> m " With email: %d (%.1f%%)" with_email (pct with_email));
8787+ Logs.app (fun m -> m " With organization: %d (%.1f%%)" with_org (pct with_org));
8888+ Logs.app (fun m -> m " With services: %d (%.1f%%), total %d services" with_service (pct with_service) total_services);
8989+ Logs.app (fun m -> m " With ORCID: %d (%.1f%%)" with_orcid (pct with_orcid));
9090+ Logs.app (fun m -> m " With URL: %d (%.1f%%)" with_url (pct with_url));
9191+ Logs.app (fun m -> m " With feeds: %d (%.1f%%), total %d feeds" with_feeds (pct with_feeds) total_feeds);
9292+ 0
9393+9494+let sync_cmd () xdg =
9595+ let store = Sortal_store.create_from_xdg xdg in
9696+ let contacts = Sortal_store.list store in
9797+ Logs.app (fun m -> m "Syncing %d contacts..." (List.length contacts));
9898+ let converted = ref 0 in
9999+ let skipped = ref 0 in
100100+ let no_thumbnail = ref 0 in
101101+ let errors = ref 0 in
102102+ List.iter (fun contact ->
103103+ let handle = Contact.handle contact in
104104+ match Sortal_store.thumbnail_path store contact with
105105+ | None ->
106106+ Logs.info (fun m -> m "@%s: no thumbnail" handle);
107107+ incr no_thumbnail
108108+ | Some eio_path ->
109109+ let path = Eio.Path.native_exn eio_path in
110110+ if is_png path then begin
111111+ Logs.info (fun m -> m "@%s: already PNG (%s)" handle (Filename.basename path));
112112+ incr skipped
113113+ end else begin
114114+ Logs.app (fun m -> m "@%s: converting %s to PNG..." handle (Filename.basename path));
115115+ match convert_to_png path with
116116+ | Ok new_path ->
117117+ Logs.app (fun m -> m " Converted: %s -> %s"
118118+ (Filename.basename path) (Filename.basename new_path));
119119+ incr converted
120120+ | Error msg ->
121121+ Logs.err (fun m -> m " Failed to convert %s: %s" path msg);
122122+ incr errors
123123+ end
124124+ ) contacts;
125125+ Logs.app (fun m -> m "Sync complete:");
126126+ Logs.app (fun m -> m " %d contacts without thumbnails" !no_thumbnail);
127127+ Logs.app (fun m -> m " %d already PNG (skipped)" !skipped);
128128+ Logs.app (fun m -> m " %d converted to PNG" !converted);
129129+ Logs.app (fun m -> m " %d errors" !errors);
130130+ if !errors > 0 then 1 else 0
131131+132132+(* Initialize git repository *)
133133+let git_init_cmd xdg env =
134134+ let store = Sortal_store.create_from_xdg xdg in
135135+ let git_store = Sortal_git_store.create store env in
136136+ match Sortal_git_store.init git_store with
137137+ | Ok () ->
138138+ if Sortal_git_store.is_initialized git_store then
139139+ Logs.app (fun m -> m "Git repository initialized in data directory")
140140+ else
141141+ Logs.app (fun m -> m "Git repository already initialized");
142142+ 0
143143+ | Error msg ->
144144+ Logs.err (fun m -> m "Failed to initialize git repository: %s" msg);
145145+ 1
146146+147147+(* Add a new contact *)
148148+let add_cmd handle names kind email github url orcid xdg env =
149149+ let store = Sortal_store.create_from_xdg xdg in
150150+ let git_store = Sortal_git_store.create store env in
151151+ (* Check if contact already exists *)
152152+ match Sortal_store.lookup store handle with
153153+ | Some _ ->
154154+ Logs.err (fun m -> m "Contact @%s already exists" handle);
155155+ 1
156156+ | None ->
157157+ let emails = match email with
158158+ | Some e -> [Contact.make_email e]
159159+ | None -> []
160160+ in
161161+ let services = match github with
162162+ | Some gh -> [Contact.make_service ~kind:Contact.Github ~handle:gh (Printf.sprintf "https://github.com/%s" gh)]
163163+ | None -> []
164164+ in
165165+ let urls = match url with
166166+ | Some u -> [Contact.make_url u]
167167+ | None -> []
168168+ in
169169+ let contact = Contact.make
170170+ ~handle
171171+ ~names
172172+ ?kind
173173+ ~emails
174174+ ~services
175175+ ~urls
176176+ ?orcid
177177+ ()
178178+ in
179179+ match Sortal_git_store.save git_store contact with
180180+ | Ok () ->
181181+ Logs.app (fun m -> m "Created contact @%s: %s" handle (Contact.name contact));
182182+ 0
183183+ | Error msg ->
184184+ Logs.err (fun m -> m "Failed to save contact: %s" msg);
185185+ 1
186186+187187+(* Delete a contact *)
188188+let delete_cmd handle xdg env =
189189+ let store = Sortal_store.create_from_xdg xdg in
190190+ let git_store = Sortal_git_store.create store env in
191191+ match Sortal_git_store.delete git_store handle with
192192+ | Ok () ->
193193+ Logs.app (fun m -> m "Deleted contact @%s" handle);
194194+ 0
195195+ | Error msg ->
196196+ Logs.err (fun m -> m "%s" msg);
197197+ 1
198198+199199+(* Convert string option to Ptime.date option *)
200200+let parse_date_opt (s_opt : string option) : Sortal_schema.Temporal.date option =
201201+ match s_opt with
202202+ | None -> None
203203+ | Some s ->
204204+ match Sortal_schema.Temporal.parse_date_string s with
205205+ | Some d -> Some d
206206+ | None ->
207207+ Logs.warn (fun m -> m "Invalid date format: %s (using ISO 8601: YYYY, YYYY-MM, or YYYY-MM-DD)" s);
208208+ None
209209+210210+(* Add email to existing contact *)
211211+let add_email_cmd handle address type_ from until note xdg env =
212212+ let store = Sortal_store.create_from_xdg xdg in
213213+ let git_store = Sortal_git_store.create store env in
214214+ let from = parse_date_opt from in
215215+ let until = parse_date_opt until in
216216+ let email = Contact.make_email ?type_ ?from ?until ?note address in
217217+ match Sortal_git_store.add_email git_store handle email with
218218+ | Ok () ->
219219+ Logs.app (fun m -> m "Added email %s to @%s" address handle);
220220+ 0
221221+ | Error msg ->
222222+ Logs.err (fun m -> m "%s" msg);
223223+ 1
224224+225225+(* Remove email from contact *)
226226+let remove_email_cmd handle address xdg env =
227227+ let store = Sortal_store.create_from_xdg xdg in
228228+ let git_store = Sortal_git_store.create store env in
229229+ match Sortal_git_store.remove_email git_store handle address with
230230+ | Ok () ->
231231+ Logs.app (fun m -> m "Removed email %s from @%s" address handle);
232232+ 0
233233+ | Error msg ->
234234+ Logs.err (fun m -> m "%s" msg);
235235+ 1
236236+237237+(* Add service to existing contact *)
238238+let add_service_cmd handle url kind service_handle label xdg env =
239239+ let store = Sortal_store.create_from_xdg xdg in
240240+ let git_store = Sortal_git_store.create store env in
241241+ let service = Contact.make_service ?kind ?handle:service_handle ?label url in
242242+ match Sortal_git_store.add_service git_store handle service with
243243+ | Ok () ->
244244+ Logs.app (fun m -> m "Added service %s to @%s" url handle);
245245+ 0
246246+ | Error msg ->
247247+ Logs.err (fun m -> m "%s" msg);
248248+ 1
249249+250250+(* Remove service from contact *)
251251+let remove_service_cmd handle url xdg env =
252252+ let store = Sortal_store.create_from_xdg xdg in
253253+ let git_store = Sortal_git_store.create store env in
254254+ match Sortal_git_store.remove_service git_store handle url with
255255+ | Ok () ->
256256+ Logs.app (fun m -> m "Removed service %s from @%s" url handle);
257257+ 0
258258+ | Error msg ->
259259+ Logs.err (fun m -> m "%s" msg);
260260+ 1
261261+262262+(* Add organization to existing contact *)
263263+let add_org_cmd handle org_name title department from until org_email org_url xdg env =
264264+ let store = Sortal_store.create_from_xdg xdg in
265265+ let git_store = Sortal_git_store.create store env in
266266+ let from = parse_date_opt from in
267267+ let until = parse_date_opt until in
268268+ let org = Contact.make_org ?title ?department ?from ?until ?email:org_email ?url:org_url org_name in
269269+ match Sortal_git_store.add_organization git_store handle org with
270270+ | Ok () ->
271271+ Logs.app (fun m -> m "Added organization %s to @%s" org_name handle);
272272+ 0
273273+ | Error msg ->
274274+ Logs.err (fun m -> m "%s" msg);
275275+ 1
276276+277277+(* Remove organization from contact *)
278278+let remove_org_cmd handle org_name xdg env =
279279+ let store = Sortal_store.create_from_xdg xdg in
280280+ let git_store = Sortal_git_store.create store env in
281281+ match Sortal_git_store.remove_organization git_store handle org_name with
282282+ | Ok () ->
283283+ Logs.app (fun m -> m "Removed organization %s from @%s" org_name handle);
284284+ 0
285285+ | Error msg ->
286286+ Logs.err (fun m -> m "%s" msg);
287287+ 1
288288+289289+(* Add URL to existing contact *)
290290+let add_url_cmd handle url label xdg env =
291291+ let store = Sortal_store.create_from_xdg xdg in
292292+ let git_store = Sortal_git_store.create store env in
293293+ let url_entry = Contact.make_url ?label url in
294294+ match Sortal_git_store.add_url git_store handle url_entry with
295295+ | Ok () ->
296296+ Logs.app (fun m -> m "Added URL %s to @%s" url handle);
297297+ 0
298298+ | Error msg ->
299299+ Logs.err (fun m -> m "%s" msg);
300300+ 1
301301+302302+(* Remove URL from contact *)
303303+let remove_url_cmd handle url xdg env =
304304+ let store = Sortal_store.create_from_xdg xdg in
305305+ let git_store = Sortal_git_store.create store env in
306306+ match Sortal_git_store.remove_url git_store handle url with
307307+ | Ok () ->
308308+ Logs.app (fun m -> m "Removed URL %s from @%s" url handle);
309309+ 0
310310+ | Error msg ->
311311+ Logs.err (fun m -> m "%s" msg);
312312+ 1
313313+314314+(* Command info and args *)
315315+let list_info = Cmd.info "list" ~doc:"List all contacts"
316316+let show_info = Cmd.info "show" ~doc:"Show detailed information about a contact"
317317+let search_info = Cmd.info "search" ~doc:"Search contacts by name"
318318+let stats_info = Cmd.info "stats" ~doc:"Show statistics about the contact database"
319319+let sync_info = Cmd.info "sync" ~doc:"Synchronize and normalize contact data"
320320+321321+let git_init_info = Cmd.info "git-init" ~doc:"Initialize git repository for contact versioning"
322322+ ~man:[
323323+ `S Manpage.s_description;
324324+ `P "Initialize a git repository in the XDG data directory to track contact changes.";
325325+ `P "Once initialized, all contact modifications will be automatically committed with descriptive messages.";
326326+ ]
327327+328328+let add_info = Cmd.info "add" ~doc:"Create a new contact"
329329+ ~man:[
330330+ `S Manpage.s_description;
331331+ `P "Create a new contact with the given handle and name.";
332332+ `P "Additional metadata can be added using options or via add-email, add-service, etc. commands.";
333333+ ]
334334+335335+let delete_info = Cmd.info "delete" ~doc:"Delete a contact"
336336+let add_email_info = Cmd.info "add-email" ~doc:"Add an email address to a contact"
337337+let remove_email_info = Cmd.info "remove-email" ~doc:"Remove an email address from a contact"
338338+let add_service_info = Cmd.info "add-service" ~doc:"Add a service (GitHub, Twitter, etc.) to a contact"
339339+let remove_service_info = Cmd.info "remove-service" ~doc:"Remove a service from a contact"
340340+let add_org_info = Cmd.info "add-org" ~doc:"Add an organization/affiliation to a contact"
341341+let remove_org_info = Cmd.info "remove-org" ~doc:"Remove an organization from a contact"
342342+let add_url_info = Cmd.info "add-url" ~doc:"Add a URL to a contact"
343343+let remove_url_info = Cmd.info "remove-url" ~doc:"Remove a URL from a contact"
344344+345345+let handle_arg =
346346+ Arg.(required & pos 0 (some string) None & info [] ~docv:"HANDLE"
347347+ ~doc:"Contact handle to display")
348348+349349+let query_arg =
350350+ Arg.(required & pos 0 (some string) None & info [] ~docv:"QUERY"
351351+ ~doc:"Name or partial name to search for")
352352+353353+(* Add command arguments *)
354354+let add_handle_arg =
355355+ Arg.(required & pos 0 (some string) None & info [] ~docv:"HANDLE"
356356+ ~doc:"Contact handle (unique identifier)")
357357+358358+let add_names_arg =
359359+ Arg.(non_empty & opt_all string [] & info ["n"; "name"] ~docv:"NAME"
360360+ ~doc:"Full name (can be specified multiple times for aliases)")
361361+362362+let add_kind_arg =
363363+ let kind_conv =
364364+ let parse s = match Contact.contact_kind_of_string s with
365365+ | Some k -> Ok k
366366+ | None -> Error (`Msg (Printf.sprintf "Invalid kind: %s" s))
367367+ in
368368+ let print ppf k = Format.pp_print_string ppf (Contact.contact_kind_to_string k) in
369369+ Arg.conv (parse, print)
370370+ in
371371+ Arg.(value & opt (some kind_conv) None & info ["k"; "kind"] ~docv:"KIND"
372372+ ~doc:"Contact kind (person, organization, group, role)")
373373+374374+let add_email_arg =
375375+ Arg.(value & opt (some string) None & info ["e"; "email"] ~docv:"EMAIL"
376376+ ~doc:"Email address")
377377+378378+let add_github_arg =
379379+ Arg.(value & opt (some string) None & info ["g"; "github"] ~docv:"HANDLE"
380380+ ~doc:"GitHub handle")
381381+382382+let add_url_arg =
383383+ Arg.(value & opt (some string) None & info ["u"; "url"] ~docv:"URL"
384384+ ~doc:"Personal/professional website URL")
385385+386386+let add_orcid_arg =
387387+ Arg.(value & opt (some string) None & info ["orcid"] ~docv:"ORCID"
388388+ ~doc:"ORCID identifier")
389389+390390+(* Add-email command arguments *)
391391+let email_address_arg =
392392+ Arg.(required & pos 1 (some string) None & info [] ~docv:"EMAIL"
393393+ ~doc:"Email address")
394394+395395+let email_type_arg =
396396+ let type_conv =
397397+ let parse s = match Contact.email_type_of_string s with
398398+ | Some t -> Ok t
399399+ | None -> Error (`Msg (Printf.sprintf "Invalid email type: %s" s))
400400+ in
401401+ let print ppf t = Format.pp_print_string ppf (Contact.email_type_to_string t) in
402402+ Arg.conv (parse, print)
403403+ in
404404+ Arg.(value & opt (some type_conv) None & info ["t"; "type"] ~docv:"TYPE"
405405+ ~doc:"Email type (work, personal, other)")
406406+407407+let date_arg name =
408408+ Arg.(value & opt (some string) None & info [name] ~docv:"DATE"
409409+ ~doc:"ISO 8601 date (e.g., 2023, 2023-01, 2023-01-15)")
410410+411411+let note_arg =
412412+ Arg.(value & opt (some string) None & info ["note"] ~docv:"NOTE"
413413+ ~doc:"Contextual note")
414414+415415+(* Add-service command arguments *)
416416+let service_url_arg =
417417+ Arg.(required & pos 1 (some string) None & info [] ~docv:"URL"
418418+ ~doc:"Service URL")
419419+420420+let service_kind_arg =
421421+ let kind_conv =
422422+ let parse s = match Contact.service_kind_of_string s with
423423+ | Some k -> Ok k
424424+ | None -> Error (`Msg (Printf.sprintf "Invalid service kind: %s" s))
425425+ in
426426+ let print ppf k = Format.pp_print_string ppf (Contact.service_kind_to_string k) in
427427+ Arg.conv (parse, print)
428428+ in
429429+ Arg.(value & opt (some kind_conv) None & info ["k"; "kind"] ~docv:"KIND"
430430+ ~doc:"Service kind (github, git, social, activitypub, photo)")
431431+432432+let service_handle_arg =
433433+ Arg.(value & opt (some string) None & info ["handle"] ~docv:"HANDLE"
434434+ ~doc:"Service handle/username")
435435+436436+let label_arg =
437437+ Arg.(value & opt (some string) None & info ["l"; "label"] ~docv:"LABEL"
438438+ ~doc:"Human-readable label")
439439+440440+(* Add-org command arguments *)
441441+let org_name_arg =
442442+ Arg.(required & pos 1 (some string) None & info [] ~docv:"ORG"
443443+ ~doc:"Organization name")
444444+445445+let org_title_arg =
446446+ Arg.(value & opt (some string) None & info ["title"] ~docv:"TITLE"
447447+ ~doc:"Job title")
448448+449449+let org_department_arg =
450450+ Arg.(value & opt (some string) None & info ["dept"; "department"] ~docv:"DEPT"
451451+ ~doc:"Department")
452452+453453+let org_email_arg =
454454+ Arg.(value & opt (some string) None & info ["email"] ~docv:"EMAIL"
455455+ ~doc:"Work email during this period")
456456+457457+let org_url_arg =
458458+ Arg.(value & opt (some string) None & info ["url"] ~docv:"URL"
459459+ ~doc:"Work homepage during this period")
460460+461461+(* URL command arguments *)
462462+let url_value_arg =
463463+ Arg.(required & pos 1 (some string) None & info [] ~docv:"URL"
464464+ ~doc:"URL")
+235
sortal/lib/core/sortal_cmd.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Cmdliner terms and commands for contact management.
77+88+ This module provides ready-to-use Cmdliner terms for building
99+ CLI applications that work with contact metadata. *)
1010+1111+module Contact = Sortal_schema.Contact
1212+module Temporal = Sortal_schema.Temporal
1313+1414+(** {1 Command Implementations} *)
1515+1616+(** [list_cmd] is a Cmdliner command that lists all contacts.
1717+1818+ Returns a function that takes an XDG context and returns an exit code. *)
1919+val list_cmd : (Xdge.t -> int)
2020+2121+(** [show_cmd handle] creates a command to show detailed contact information.
2222+2323+ @param handle The contact handle to display *)
2424+val show_cmd : string -> (Xdge.t -> int)
2525+2626+(** [search_cmd query] creates a command to search contacts by name.
2727+2828+ @param query The search query string *)
2929+val search_cmd : string -> (Xdge.t -> int)
3030+3131+(** [stats_cmd] is a command that shows database statistics. *)
3232+val stats_cmd : unit -> (Xdge.t -> int)
3333+3434+(** [sync_cmd] is a command that synchronizes and normalizes contact data.
3535+3636+ Currently performs the following operations:
3737+ - Converts non-JPG thumbnail images to PNG using ImageMagick *)
3838+val sync_cmd : unit -> (Xdge.t -> int)
3939+4040+(** [git_init_cmd xdg env] initializes a git repository in the data directory.
4141+4242+ Once initialized, all contact modifications will be automatically committed.
4343+ @param xdg XDG context
4444+ @param env Eio environment for process spawning *)
4545+val git_init_cmd : Xdge.t -> Eio_unix.Stdenv.base -> int
4646+4747+(** [add_cmd handle names kind email github url orcid xdg env] creates a new contact.
4848+4949+ @param handle Contact handle (unique identifier)
5050+ @param names List of names (first is primary)
5151+ @param kind Optional contact kind
5252+ @param email Optional email address
5353+ @param github Optional GitHub handle
5454+ @param url Optional personal/professional website
5555+ @param orcid Optional ORCID identifier
5656+ @param xdg XDG context
5757+ @param env Eio environment for git operations *)
5858+val add_cmd : string -> string list -> Contact.contact_kind option ->
5959+ string option -> string option -> string option -> string option ->
6060+ Xdge.t -> Eio_unix.Stdenv.base -> int
6161+6262+(** [delete_cmd handle xdg env] deletes a contact.
6363+6464+ @param handle The contact handle to delete
6565+ @param xdg XDG context
6666+ @param env Eio environment for git operations *)
6767+val delete_cmd : string -> Xdge.t -> Eio_unix.Stdenv.base -> int
6868+6969+(** [add_email_cmd handle address type_ from until note xdg env] adds an email to a contact.
7070+7171+ @param handle Contact handle
7272+ @param address Email address
7373+ @param type_ Email type (work, personal, other)
7474+ @param from Start date of validity
7575+ @param until End date of validity
7676+ @param note Contextual note
7777+ @param xdg XDG context
7878+ @param env Eio environment for git operations *)
7979+val add_email_cmd : string -> string -> Contact.email_type option ->
8080+ string option -> string option -> string option ->
8181+ Xdge.t -> Eio_unix.Stdenv.base -> int
8282+8383+(** [remove_email_cmd handle address xdg env] removes an email from a contact. *)
8484+val remove_email_cmd : string -> string -> Xdge.t -> Eio_unix.Stdenv.base -> int
8585+8686+(** [add_service_cmd handle url kind service_handle label xdg env] adds a service to a contact.
8787+8888+ @param handle Contact handle
8989+ @param url Service URL
9090+ @param kind Service kind
9191+ @param service_handle Service username/handle
9292+ @param label Human-readable label
9393+ @param xdg XDG context
9494+ @param env Eio environment for git operations *)
9595+val add_service_cmd : string -> string -> Contact.service_kind option ->
9696+ string option -> string option -> Xdge.t -> Eio_unix.Stdenv.base -> int
9797+9898+(** [remove_service_cmd handle url xdg env] removes a service from a contact. *)
9999+val remove_service_cmd : string -> string -> Xdge.t -> Eio_unix.Stdenv.base -> int
100100+101101+(** [add_org_cmd handle org_name title department from until org_email org_url xdg env]
102102+ adds an organization to a contact. *)
103103+val add_org_cmd : string -> string -> string option -> string option ->
104104+ string option -> string option -> string option -> string option ->
105105+ Xdge.t -> Eio_unix.Stdenv.base -> int
106106+107107+(** [remove_org_cmd handle org_name xdg env] removes an organization from a contact. *)
108108+val remove_org_cmd : string -> string -> Xdge.t -> Eio_unix.Stdenv.base -> int
109109+110110+(** [add_url_cmd handle url label xdg env] adds a URL to a contact. *)
111111+val add_url_cmd : string -> string -> string option -> Xdge.t -> Eio_unix.Stdenv.base -> int
112112+113113+(** [remove_url_cmd handle url xdg env] removes a URL from a contact. *)
114114+val remove_url_cmd : string -> string -> Xdge.t -> Eio_unix.Stdenv.base -> int
115115+116116+(** {1 Cmdliner Info Objects} *)
117117+118118+(** [list_info] is the command info for the list command. *)
119119+val list_info : Cmdliner.Cmd.info
120120+121121+(** [show_info] is the command info for the show command. *)
122122+val show_info : Cmdliner.Cmd.info
123123+124124+(** [search_info] is the command info for the search command. *)
125125+val search_info : Cmdliner.Cmd.info
126126+127127+(** [stats_info] is the command info for the stats command. *)
128128+val stats_info : Cmdliner.Cmd.info
129129+130130+(** [sync_info] is the command info for the sync command. *)
131131+val sync_info : Cmdliner.Cmd.info
132132+133133+(** [git_init_info] is the command info for the git-init command. *)
134134+val git_init_info : Cmdliner.Cmd.info
135135+136136+(** [add_info] is the command info for the add command. *)
137137+val add_info : Cmdliner.Cmd.info
138138+139139+(** [delete_info] is the command info for the delete command. *)
140140+val delete_info : Cmdliner.Cmd.info
141141+142142+(** [add_email_info] is the command info for the add-email command. *)
143143+val add_email_info : Cmdliner.Cmd.info
144144+145145+(** [remove_email_info] is the command info for the remove-email command. *)
146146+val remove_email_info : Cmdliner.Cmd.info
147147+148148+(** [add_service_info] is the command info for the add-service command. *)
149149+val add_service_info : Cmdliner.Cmd.info
150150+151151+(** [remove_service_info] is the command info for the remove-service command. *)
152152+val remove_service_info : Cmdliner.Cmd.info
153153+154154+(** [add_org_info] is the command info for the add-org command. *)
155155+val add_org_info : Cmdliner.Cmd.info
156156+157157+(** [remove_org_info] is the command info for the remove-org command. *)
158158+val remove_org_info : Cmdliner.Cmd.info
159159+160160+(** [add_url_info] is the command info for the add-url command. *)
161161+val add_url_info : Cmdliner.Cmd.info
162162+163163+(** [remove_url_info] is the command info for the remove-url command. *)
164164+val remove_url_info : Cmdliner.Cmd.info
165165+166166+(** {1 Cmdliner Argument Definitions} *)
167167+168168+(** [handle_arg] is the positional argument for a contact handle. *)
169169+val handle_arg : string Cmdliner.Term.t
170170+171171+(** [query_arg] is the positional argument for a search query. *)
172172+val query_arg : string Cmdliner.Term.t
173173+174174+(** [add_handle_arg] is the positional argument for a new contact handle. *)
175175+val add_handle_arg : string Cmdliner.Term.t
176176+177177+(** [add_names_arg] is the repeatable option for contact names. *)
178178+val add_names_arg : string list Cmdliner.Term.t
179179+180180+(** [add_kind_arg] is the optional argument for contact kind. *)
181181+val add_kind_arg : Contact.contact_kind option Cmdliner.Term.t
182182+183183+(** [add_email_arg] is the optional argument for email. *)
184184+val add_email_arg : string option Cmdliner.Term.t
185185+186186+(** [add_github_arg] is the optional argument for GitHub handle. *)
187187+val add_github_arg : string option Cmdliner.Term.t
188188+189189+(** [add_url_arg] is the optional argument for URL. *)
190190+val add_url_arg : string option Cmdliner.Term.t
191191+192192+(** [add_orcid_arg] is the optional argument for ORCID. *)
193193+val add_orcid_arg : string option Cmdliner.Term.t
194194+195195+(** [email_address_arg] is the positional argument for email address. *)
196196+val email_address_arg : string Cmdliner.Term.t
197197+198198+(** [email_type_arg] is the optional argument for email type. *)
199199+val email_type_arg : Contact.email_type option Cmdliner.Term.t
200200+201201+(** [date_arg name] creates a date argument with the given option name. *)
202202+val date_arg : string -> string option Cmdliner.Term.t
203203+204204+(** [note_arg] is the optional argument for notes. *)
205205+val note_arg : string option Cmdliner.Term.t
206206+207207+(** [service_url_arg] is the positional argument for service URL. *)
208208+val service_url_arg : string Cmdliner.Term.t
209209+210210+(** [service_kind_arg] is the optional argument for service kind. *)
211211+val service_kind_arg : Contact.service_kind option Cmdliner.Term.t
212212+213213+(** [service_handle_arg] is the optional argument for service handle. *)
214214+val service_handle_arg : string option Cmdliner.Term.t
215215+216216+(** [label_arg] is the optional argument for labels. *)
217217+val label_arg : string option Cmdliner.Term.t
218218+219219+(** [org_name_arg] is the positional argument for organization name. *)
220220+val org_name_arg : string Cmdliner.Term.t
221221+222222+(** [org_title_arg] is the optional argument for job title. *)
223223+val org_title_arg : string option Cmdliner.Term.t
224224+225225+(** [org_department_arg] is the optional argument for department. *)
226226+val org_department_arg : string option Cmdliner.Term.t
227227+228228+(** [org_email_arg] is the optional argument for work email. *)
229229+val org_email_arg : string option Cmdliner.Term.t
230230+231231+(** [org_url_arg] is the optional argument for work URL. *)
232232+val org_url_arg : string option Cmdliner.Term.t
233233+234234+(** [url_value_arg] is the positional argument for URL. *)
235235+val url_value_arg : string Cmdliner.Term.t
+233
sortal/lib/core/sortal_git_store.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+module Contact = Sortal_schema.Contact
77+88+type t = {
99+ store : Sortal_store.t;
1010+ env : Eio_unix.Stdenv.base;
1111+}
1212+1313+let create store env = { store; env }
1414+1515+let store t = t.store
1616+1717+(* Helper to check if a string contains a substring *)
1818+let contains_substring ~needle haystack =
1919+ try
2020+ let _ = Str.search_forward (Str.regexp_string needle) haystack 0 in
2121+ true
2222+ with Not_found -> false
2323+2424+(* Helper to get the data directory path as a native string *)
2525+let data_dir_path t =
2626+ (* We need to extract the data directory from the store somehow.
2727+ For now, we'll use the XDG environment to locate it. *)
2828+ let xdg = Xdge.create t.env#fs "sortal" in
2929+ let data_path = Xdge.data_dir xdg in
3030+ Eio.Path.native_exn data_path
3131+3232+(* Execute a git command in the data directory *)
3333+let run_git t args =
3434+ let data_dir = data_dir_path t in
3535+ Eio.Switch.run @@ fun sw ->
3636+ try
3737+ let mgr = t.env#process_mgr in
3838+ let cmd = ["git"; "-C"; data_dir] @ args in
3939+ let proc = Eio.Process.spawn ~sw mgr cmd in
4040+ match Eio.Process.await proc with
4141+ | `Exited 0 -> Ok ()
4242+ | `Exited n -> Error (Printf.sprintf "git %s exited with code %d" (String.concat " " args) n)
4343+ | `Signaled n -> Error (Printf.sprintf "git killed by signal %d" n)
4444+ with
4545+ | exn ->
4646+ let msg = Printexc.to_string exn in
4747+ if contains_substring ~needle:"not found" msg ||
4848+ contains_substring ~needle:"No such file" msg then
4949+ Error "git executable not found - please install git"
5050+ else
5151+ Error (Printf.sprintf "git command failed: %s" msg)
5252+5353+let is_initialized t =
5454+ let data_dir = data_dir_path t in
5555+ let git_dir = Filename.concat data_dir ".git" in
5656+ Sys.file_exists git_dir && Sys.is_directory git_dir
5757+5858+let init t =
5959+ if is_initialized t then
6060+ Ok ()
6161+ else begin
6262+ match run_git t ["init"] with
6363+ | Error _ as e -> e
6464+ | Ok () ->
6565+ (* Create initial commit *)
6666+ match run_git t ["add"; "."] with
6767+ | Error _ as e -> e
6868+ | Ok () ->
6969+ let msg = "Initialize sortal contact database" in
7070+ run_git t ["commit"; "--allow-empty"; "-m"; msg]
7171+ end
7272+7373+(* Helper to commit a file with a message *)
7474+let commit_file t filename msg =
7575+ match run_git t ["add"; filename] with
7676+ | Error _ as e -> e
7777+ | Ok () ->
7878+ run_git t ["commit"; "-m"; msg]
7979+8080+(* Helper to commit a deletion *)
8181+let commit_deletion t filename msg =
8282+ match run_git t ["rm"; filename] with
8383+ | Error _ as e -> e
8484+ | Ok () ->
8585+ run_git t ["commit"; "-m"; msg]
8686+8787+let save t contact =
8888+ let handle = Contact.handle contact in
8989+ let name = Contact.name contact in
9090+ let filename = handle ^ ".yaml" in
9191+9292+ (* Check if contact already exists *)
9393+ let is_new = match Sortal_store.lookup t.store handle with
9494+ | None -> true
9595+ | Some _ -> false
9696+ in
9797+9898+ (* Save to store *)
9999+ Sortal_store.save t.store contact;
100100+101101+ (* Commit to git *)
102102+ if not (is_initialized t) then
103103+ Ok ()
104104+ else
105105+ let msg = if is_new then
106106+ Printf.sprintf "Add contact @%s (%s)" handle name
107107+ else
108108+ Printf.sprintf "Update contact @%s (%s)" handle name
109109+ in
110110+ commit_file t filename msg
111111+112112+let delete t handle =
113113+ match Sortal_store.lookup t.store handle with
114114+ | None -> Error (Printf.sprintf "Contact not found: %s" handle)
115115+ | Some contact ->
116116+ let name = Contact.name contact in
117117+ let filename = handle ^ ".yaml" in
118118+119119+ (* Delete from store *)
120120+ Sortal_store.delete t.store handle;
121121+122122+ (* Commit deletion to git *)
123123+ if not (is_initialized t) then
124124+ Ok ()
125125+ else
126126+ let msg = Printf.sprintf "Delete contact @%s (%s)" handle name in
127127+ commit_deletion t filename msg
128128+129129+let update_contact t handle f ~msg =
130130+ match Sortal_store.update_contact t.store handle f with
131131+ | Error _ as e -> e
132132+ | Ok () ->
133133+ if not (is_initialized t) then
134134+ Ok ()
135135+ else
136136+ let filename = handle ^ ".yaml" in
137137+ commit_file t filename msg
138138+139139+let add_email t handle (email : Contact.email) =
140140+ let msg = Printf.sprintf "Update @%s: add email %s"
141141+ handle email.address in
142142+ match Sortal_store.add_email t.store handle email with
143143+ | Error _ as e -> e
144144+ | Ok () ->
145145+ if not (is_initialized t) then
146146+ Ok ()
147147+ else
148148+ let filename = handle ^ ".yaml" in
149149+ commit_file t filename msg
150150+151151+let remove_email t handle address =
152152+ let msg = Printf.sprintf "Update @%s: remove email %s" handle address in
153153+ match Sortal_store.remove_email t.store handle address with
154154+ | Error _ as e -> e
155155+ | Ok () ->
156156+ if not (is_initialized t) then
157157+ Ok ()
158158+ else
159159+ let filename = handle ^ ".yaml" in
160160+ commit_file t filename msg
161161+162162+let add_service t handle (service : Contact.service) =
163163+ let kind_str = match service.kind with
164164+ | Some k -> Contact.service_kind_to_string k
165165+ | None -> "unknown"
166166+ in
167167+ let msg = Printf.sprintf "Update @%s: add service %s (%s)"
168168+ handle kind_str service.url in
169169+ match Sortal_store.add_service t.store handle service with
170170+ | Error _ as e -> e
171171+ | Ok () ->
172172+ if not (is_initialized t) then
173173+ Ok ()
174174+ else
175175+ let filename = handle ^ ".yaml" in
176176+ commit_file t filename msg
177177+178178+let remove_service t handle url =
179179+ let msg = Printf.sprintf "Update @%s: remove service %s" handle url in
180180+ match Sortal_store.remove_service t.store handle url with
181181+ | Error _ as e -> e
182182+ | Ok () ->
183183+ if not (is_initialized t) then
184184+ Ok ()
185185+ else
186186+ let filename = handle ^ ".yaml" in
187187+ commit_file t filename msg
188188+189189+let add_organization t handle (org : Contact.organization) =
190190+ let msg = Printf.sprintf "Update @%s: add organization %s"
191191+ handle org.name in
192192+ match Sortal_store.add_organization t.store handle org with
193193+ | Error _ as e -> e
194194+ | Ok () ->
195195+ if not (is_initialized t) then
196196+ Ok ()
197197+ else
198198+ let filename = handle ^ ".yaml" in
199199+ commit_file t filename msg
200200+201201+let remove_organization t handle name =
202202+ let msg = Printf.sprintf "Update @%s: remove organization %s" handle name in
203203+ match Sortal_store.remove_organization t.store handle name with
204204+ | Error _ as e -> e
205205+ | Ok () ->
206206+ if not (is_initialized t) then
207207+ Ok ()
208208+ else
209209+ let filename = handle ^ ".yaml" in
210210+ commit_file t filename msg
211211+212212+let add_url t handle (url_entry : Contact.url_entry) =
213213+ let msg = Printf.sprintf "Update @%s: add URL %s"
214214+ handle url_entry.url in
215215+ match Sortal_store.add_url t.store handle url_entry with
216216+ | Error _ as e -> e
217217+ | Ok () ->
218218+ if not (is_initialized t) then
219219+ Ok ()
220220+ else
221221+ let filename = handle ^ ".yaml" in
222222+ commit_file t filename msg
223223+224224+let remove_url t handle url =
225225+ let msg = Printf.sprintf "Update @%s: remove URL %s" handle url in
226226+ match Sortal_store.remove_url t.store handle url with
227227+ | Error _ as e -> e
228228+ | Ok () ->
229229+ if not (is_initialized t) then
230230+ Ok ()
231231+ else
232232+ let filename = handle ^ ".yaml" in
233233+ commit_file t filename msg
+116
sortal/lib/core/sortal_git_store.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Git-backed contact store with automatic version control.
77+88+ This module wraps {!Sortal_store} to provide automatic git versioning
99+ of all contact modifications. Each change (add, update, delete) is
1010+ automatically committed to a git repository with descriptive commit
1111+ messages. *)
1212+1313+module Contact = Sortal_schema.Contact
1414+1515+type t
1616+(** A git-backed contact store. *)
1717+1818+(** {1 Creation and Initialization} *)
1919+2020+val create : Sortal_store.t -> Eio_unix.Stdenv.base -> t
2121+(** [create store env] creates a git-backed store wrapping [store].
2222+2323+ @param store The underlying contact store
2424+ @param env The Eio environment for spawning git processes *)
2525+2626+val init : t -> (unit, string) result
2727+(** [init t] initializes a git repository in the data directory.
2828+2929+ Creates a new git repository with an initial commit if one doesn't exist.
3030+ Safe to call multiple times - returns [Ok ()] if already initialized.
3131+3232+ @return [Ok ()] if initialized successfully or already initialized,
3333+ [Error msg] if git initialization fails *)
3434+3535+val is_initialized : t -> bool
3636+(** [is_initialized t] checks if the data directory is a git repository.
3737+3838+ @return [true] if a .git directory exists, [false] otherwise *)
3939+4040+(** {1 Contact Operations} *)
4141+4242+val save : t -> Contact.t -> (unit, string) result
4343+(** [save t contact] saves a contact and commits the change to git.
4444+4545+ If the contact is new, commits with message "Add contact @handle (Name)".
4646+ If updating an existing contact, commits with "Update contact @handle (Name)".
4747+4848+ @param contact The contact to save *)
4949+5050+val delete : t -> string -> (unit, string) result
5151+(** [delete t handle] deletes a contact and commits the removal to git.
5252+5353+ Commits with message "Delete contact @handle (Name)".
5454+5555+ @param handle The contact handle to delete
5656+ @return [Error msg] if contact not found *)
5757+5858+(** {1 Contact Modification} *)
5959+6060+val add_email : t -> string -> Contact.email -> (unit, string) result
6161+(** [add_email t handle email] adds an email to a contact and commits.
6262+6363+ Commits with message "Update @handle: add email address@example.com". *)
6464+6565+val remove_email : t -> string -> string -> (unit, string) result
6666+(** [remove_email t handle address] removes an email and commits.
6767+6868+ Commits with message "Update @handle: remove email address@example.com". *)
6969+7070+val add_service : t -> string -> Contact.service -> (unit, string) result
7171+(** [add_service t handle service] adds a service to a contact and commits.
7272+7373+ Commits with message "Update @handle: add service Kind (url)". *)
7474+7575+val remove_service : t -> string -> string -> (unit, string) result
7676+(** [remove_service t handle url] removes a service and commits.
7777+7878+ Commits with message "Update @handle: remove service url". *)
7979+8080+val add_organization : t -> string -> Contact.organization -> (unit, string) result
8181+(** [add_organization t handle org] adds an organization and commits.
8282+8383+ Commits with message "Update @handle: add organization Org Name". *)
8484+8585+val remove_organization : t -> string -> string -> (unit, string) result
8686+(** [remove_organization t handle name] removes an organization and commits.
8787+8888+ Commits with message "Update @handle: remove organization Org Name". *)
8989+9090+val add_url : t -> string -> Contact.url_entry -> (unit, string) result
9191+(** [add_url t handle url_entry] adds a URL and commits.
9292+9393+ Commits with message "Update @handle: add URL url". *)
9494+9595+val remove_url : t -> string -> string -> (unit, string) result
9696+(** [remove_url t handle url] removes a URL and commits.
9797+9898+ Commits with message "Update @handle: remove URL url". *)
9999+100100+(** {1 Low-level Operations} *)
101101+102102+val update_contact : t -> string -> (Contact.t -> Contact.t) ->
103103+ msg:string -> (unit, string) result
104104+(** [update_contact t handle f ~msg] updates a contact and commits with custom message.
105105+106106+ This is a low-level function that applies transformation [f] to the contact
107107+ and commits with the provided commit message.
108108+109109+ @param handle The contact handle
110110+ @param f Function to transform the contact
111111+ @param msg The git commit message *)
112112+113113+val store : t -> Sortal_store.t
114114+(** [store t] returns the underlying contact store.
115115+116116+ Use this when you need direct store access without git commits. *)
+370
sortal/lib/core/sortal_store.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+module Contact = Sortal_schema.Contact
77+module Temporal = Sortal_schema.Temporal
88+99+type t = {
1010+ xdg : Xdge.t; [@warning "-69"]
1111+ data_dir : Eio.Fs.dir_ty Eio.Path.t;
1212+}
1313+1414+let create fs app_name =
1515+ let xdg = Xdge.create fs app_name in
1616+ let data_dir = Xdge.data_dir xdg in
1717+ { xdg; data_dir }
1818+1919+let create_from_xdg xdg =
2020+ let data_dir = Xdge.data_dir xdg in
2121+ { xdg; data_dir }
2222+2323+let contact_file t handle =
2424+ Eio.Path.(t.data_dir / (handle ^ ".yaml"))
2525+2626+let save t contact =
2727+ let path = contact_file t (Contact.handle contact) in
2828+ let buf = Buffer.create 4096 in
2929+ let writer = Bytesrw.Bytes.Writer.of_buffer buf in
3030+ match Yamlt.encode Contact.json_t contact ~eod:true writer with
3131+ | Ok () -> Eio.Path.save ~create:(`Or_truncate 0o644) path (Buffer.contents buf)
3232+ | Error err -> failwith ("Failed to encode contact: " ^ err)
3333+3434+let lookup t handle =
3535+ let path = contact_file t handle in
3636+ try
3737+ let yaml_str = Eio.Path.load path in
3838+ let reader = Bytesrw.Bytes.Reader.of_string yaml_str in
3939+ match Yamlt.decode Contact.json_t reader with
4040+ | Ok contact -> Some contact
4141+ | Error msg ->
4242+ Logs.warn (fun m -> m "Failed to decode contact %s: %s" handle msg);
4343+ None
4444+ with exn ->
4545+ Logs.warn (fun m -> m "Failed to load contact %s: %s" handle (Printexc.to_string exn));
4646+ None
4747+4848+let delete t handle =
4949+ let path = contact_file t handle in
5050+ try
5151+ Eio.Path.unlink path
5252+ with
5353+ | _ -> ()
5454+5555+(* Contact modification helpers *)
5656+let update_contact t handle f =
5757+ match lookup t handle with
5858+ | None -> Error (Printf.sprintf "Contact not found: %s" handle)
5959+ | Some contact ->
6060+ let updated = f contact in
6161+ save t updated;
6262+ Ok ()
6363+6464+let add_email t handle (email : Contact.email) =
6565+ match lookup t handle with
6666+ | None -> Error (Printf.sprintf "Contact not found: %s" handle)
6767+ | Some contact ->
6868+ let emails = Contact.emails contact in
6969+ (* Check for duplicate email address *)
7070+ if List.exists (fun (e : Contact.email) -> e.address = email.address) emails then
7171+ Error (Printf.sprintf "Email %s already exists for contact @%s" email.address handle)
7272+ else
7373+ update_contact t handle (fun contact ->
7474+ let emails = Contact.emails contact in
7575+ Contact.make
7676+ ~handle:(Contact.handle contact)
7777+ ~names:(Contact.names contact)
7878+ ~kind:(Contact.kind contact)
7979+ ~emails:(emails @ [email])
8080+ ~organizations:(Contact.organizations contact)
8181+ ~urls:(Contact.urls contact)
8282+ ~services:(Contact.services contact)
8383+ ?icon:(Contact.icon contact)
8484+ ?thumbnail:(Contact.thumbnail contact)
8585+ ?orcid:(Contact.orcid contact)
8686+ ?feeds:(Contact.feeds contact)
8787+ ()
8888+ )
8989+9090+let remove_email t handle address =
9191+ update_contact t handle (fun contact ->
9292+ let emails = Contact.emails contact
9393+ |> List.filter (fun (e : Contact.email) -> e.address <> address) in
9494+ Contact.make
9595+ ~handle:(Contact.handle contact)
9696+ ~names:(Contact.names contact)
9797+ ~kind:(Contact.kind contact)
9898+ ~emails
9999+ ~organizations:(Contact.organizations contact)
100100+ ~urls:(Contact.urls contact)
101101+ ~services:(Contact.services contact)
102102+ ?icon:(Contact.icon contact)
103103+ ?thumbnail:(Contact.thumbnail contact)
104104+ ?orcid:(Contact.orcid contact)
105105+ ?feeds:(Contact.feeds contact)
106106+ ()
107107+ )
108108+109109+let add_service t handle (service : Contact.service) =
110110+ match lookup t handle with
111111+ | None -> Error (Printf.sprintf "Contact not found: %s" handle)
112112+ | Some contact ->
113113+ let services = Contact.services contact in
114114+ (* Check for duplicate service URL *)
115115+ if List.exists (fun (s : Contact.service) -> s.url = service.url) services then
116116+ Error (Printf.sprintf "Service URL %s already exists for contact @%s" service.url handle)
117117+ else
118118+ update_contact t handle (fun contact ->
119119+ let services = Contact.services contact in
120120+ Contact.make
121121+ ~handle:(Contact.handle contact)
122122+ ~names:(Contact.names contact)
123123+ ~kind:(Contact.kind contact)
124124+ ~emails:(Contact.emails contact)
125125+ ~organizations:(Contact.organizations contact)
126126+ ~urls:(Contact.urls contact)
127127+ ~services:(services @ [service])
128128+ ?icon:(Contact.icon contact)
129129+ ?thumbnail:(Contact.thumbnail contact)
130130+ ?orcid:(Contact.orcid contact)
131131+ ?feeds:(Contact.feeds contact)
132132+ ()
133133+ )
134134+135135+let remove_service t handle url =
136136+ update_contact t handle (fun contact ->
137137+ let services = Contact.services contact
138138+ |> List.filter (fun (s : Contact.service) -> s.url <> url) in
139139+ Contact.make
140140+ ~handle:(Contact.handle contact)
141141+ ~names:(Contact.names contact)
142142+ ~kind:(Contact.kind contact)
143143+ ~emails:(Contact.emails contact)
144144+ ~organizations:(Contact.organizations contact)
145145+ ~urls:(Contact.urls contact)
146146+ ~services
147147+ ?icon:(Contact.icon contact)
148148+ ?thumbnail:(Contact.thumbnail contact)
149149+ ?orcid:(Contact.orcid contact)
150150+ ?feeds:(Contact.feeds contact)
151151+ ()
152152+ )
153153+154154+let add_organization t handle (org : Contact.organization) =
155155+ match lookup t handle with
156156+ | None -> Error (Printf.sprintf "Contact not found: %s" handle)
157157+ | Some contact ->
158158+ let orgs = Contact.organizations contact in
159159+ (* Check for exact duplicate organization (same name, title, and department) *)
160160+ let is_duplicate = List.exists (fun (o : Contact.organization) ->
161161+ o.name = org.name &&
162162+ o.title = org.title &&
163163+ o.department = org.department
164164+ ) orgs in
165165+ if is_duplicate then
166166+ Error (Printf.sprintf "Organization %s with the same title/department already exists for contact @%s" org.name handle)
167167+ else
168168+ update_contact t handle (fun contact ->
169169+ let orgs = Contact.organizations contact in
170170+ Contact.make
171171+ ~handle:(Contact.handle contact)
172172+ ~names:(Contact.names contact)
173173+ ~kind:(Contact.kind contact)
174174+ ~emails:(Contact.emails contact)
175175+ ~organizations:(orgs @ [org])
176176+ ~urls:(Contact.urls contact)
177177+ ~services:(Contact.services contact)
178178+ ?icon:(Contact.icon contact)
179179+ ?thumbnail:(Contact.thumbnail contact)
180180+ ?orcid:(Contact.orcid contact)
181181+ ?feeds:(Contact.feeds contact)
182182+ ()
183183+ )
184184+185185+let remove_organization t handle name =
186186+ update_contact t handle (fun contact ->
187187+ let orgs = Contact.organizations contact
188188+ |> List.filter (fun (o : Contact.organization) -> o.name <> name) in
189189+ Contact.make
190190+ ~handle:(Contact.handle contact)
191191+ ~names:(Contact.names contact)
192192+ ~kind:(Contact.kind contact)
193193+ ~emails:(Contact.emails contact)
194194+ ~organizations:orgs
195195+ ~urls:(Contact.urls contact)
196196+ ~services:(Contact.services contact)
197197+ ?icon:(Contact.icon contact)
198198+ ?thumbnail:(Contact.thumbnail contact)
199199+ ?orcid:(Contact.orcid contact)
200200+ ?feeds:(Contact.feeds contact)
201201+ ()
202202+ )
203203+204204+let add_url t handle (url_entry : Contact.url_entry) =
205205+ match lookup t handle with
206206+ | None -> Error (Printf.sprintf "Contact not found: %s" handle)
207207+ | Some contact ->
208208+ let urls = Contact.urls contact in
209209+ (* Check for duplicate URL *)
210210+ if List.exists (fun (u : Contact.url_entry) -> u.url = url_entry.url) urls then
211211+ Error (Printf.sprintf "URL %s already exists for contact @%s" url_entry.url handle)
212212+ else
213213+ update_contact t handle (fun contact ->
214214+ let urls = Contact.urls contact in
215215+ Contact.make
216216+ ~handle:(Contact.handle contact)
217217+ ~names:(Contact.names contact)
218218+ ~kind:(Contact.kind contact)
219219+ ~emails:(Contact.emails contact)
220220+ ~organizations:(Contact.organizations contact)
221221+ ~urls:(urls @ [url_entry])
222222+ ~services:(Contact.services contact)
223223+ ?icon:(Contact.icon contact)
224224+ ?thumbnail:(Contact.thumbnail contact)
225225+ ?orcid:(Contact.orcid contact)
226226+ ?feeds:(Contact.feeds contact)
227227+ ()
228228+ )
229229+230230+let remove_url t handle url =
231231+ update_contact t handle (fun contact ->
232232+ let urls = Contact.urls contact
233233+ |> List.filter (fun (u : Contact.url_entry) -> u.url <> url) in
234234+ Contact.make
235235+ ~handle:(Contact.handle contact)
236236+ ~names:(Contact.names contact)
237237+ ~kind:(Contact.kind contact)
238238+ ~emails:(Contact.emails contact)
239239+ ~organizations:(Contact.organizations contact)
240240+ ~urls
241241+ ~services:(Contact.services contact)
242242+ ?icon:(Contact.icon contact)
243243+ ?thumbnail:(Contact.thumbnail contact)
244244+ ?orcid:(Contact.orcid contact)
245245+ ?feeds:(Contact.feeds contact)
246246+ ()
247247+ )
248248+249249+let list t =
250250+ try
251251+ let entries = Eio.Path.read_dir t.data_dir in
252252+ List.filter_map (fun entry ->
253253+ if Filename.check_suffix entry ".yaml" then
254254+ let handle = Filename.chop_suffix entry ".yaml" in
255255+ lookup t handle
256256+ else
257257+ None
258258+ ) entries
259259+ with
260260+ | _ -> []
261261+262262+let thumbnail_path t contact =
263263+ Contact.thumbnail contact
264264+ |> Option.map (fun relative_path -> Eio.Path.(t.data_dir / relative_path))
265265+266266+let png_thumbnail_path t contact =
267267+ match Contact.thumbnail contact with
268268+ | None -> None
269269+ | Some relative_path ->
270270+ let base = Filename.remove_extension relative_path in
271271+ let png_path = base ^ ".png" in
272272+ let full_path = Eio.Path.(t.data_dir / png_path) in
273273+ try
274274+ ignore (Eio.Path.load full_path);
275275+ Some full_path
276276+ with _ -> None
277277+278278+let handle_of_name name =
279279+ let name = String.lowercase_ascii name in
280280+ let words = String.split_on_char ' ' name in
281281+ let initials = String.concat "" (List.map (fun w -> String.sub w 0 1) words) in
282282+ initials ^ List.hd (List.rev words)
283283+284284+let find_by_name t name =
285285+ let name_lower = String.lowercase_ascii name in
286286+ let all_contacts = list t in
287287+ let matches = List.filter (fun c ->
288288+ List.exists (fun n -> String.lowercase_ascii n = name_lower)
289289+ (Contact.names c)
290290+ ) all_contacts in
291291+ match matches with
292292+ | [contact] -> contact
293293+ | [] -> raise Not_found
294294+ | _ -> raise (Invalid_argument ("Multiple contacts match: " ^ name))
295295+296296+let find_by_name_opt t name =
297297+ try
298298+ Some (find_by_name t name)
299299+ with
300300+ | Not_found | Invalid_argument _ -> None
301301+302302+let contains_substring ~needle haystack =
303303+ let needle_len = String.length needle in
304304+ let haystack_len = String.length haystack in
305305+ if needle_len = 0 then true
306306+ else if needle_len > haystack_len then false
307307+ else
308308+ let rec check i =
309309+ if i > haystack_len - needle_len then false
310310+ else if String.sub haystack i needle_len = needle then true
311311+ else check (i + 1)
312312+ in
313313+ check 0
314314+315315+let search_all t query =
316316+ let query_lower = String.lowercase_ascii query in
317317+ let all = list t in
318318+ let matches = List.filter (fun c ->
319319+ List.exists (fun name ->
320320+ let name_lower = String.lowercase_ascii name in
321321+ String.equal name_lower query_lower ||
322322+ String.starts_with ~prefix:query_lower name_lower ||
323323+ contains_substring ~needle:query_lower name_lower ||
324324+ (String.contains name_lower ' ' &&
325325+ String.split_on_char ' ' name_lower |> List.exists (fun word ->
326326+ String.starts_with ~prefix:query_lower word
327327+ ))
328328+ ) (Contact.names c)
329329+ ) all in
330330+ List.sort Contact.compare matches
331331+332332+let find_by_email_at t ~email ~date =
333333+ let all = list t in
334334+ List.find_opt (fun c ->
335335+ let emails_at_date = Contact.emails_at c ~date in
336336+ List.exists (fun e -> e.Contact.address = email) emails_at_date
337337+ ) all
338338+339339+let find_by_org t ~org ?from ?until () =
340340+ let org_lower = String.lowercase_ascii org in
341341+ let all = list t in
342342+ let matches = List.filter (fun c ->
343343+ let orgs : Contact.organization list = Contact.organizations c in
344344+ let filtered_orgs = match from, until with
345345+ | None, None -> orgs
346346+ | _, _ -> Temporal.filter ~get:(fun (o : Contact.organization) -> o.range)
347347+ ~from ~until orgs
348348+ in
349349+ List.exists (fun (o : Contact.organization) ->
350350+ contains_substring ~needle:org_lower
351351+ (String.lowercase_ascii o.name)
352352+ ) filtered_orgs
353353+ ) all in
354354+ List.sort Contact.compare matches
355355+356356+let list_at t ~date =
357357+ let all = list t in
358358+ List.filter (fun c ->
359359+ (* Contact is active if it has any email, org, or URL valid at date *)
360360+ let has_email = Contact.emails_at c ~date <> [] in
361361+ let has_org = Contact.organization_at c ~date <> None in
362362+ let has_url = Contact.url_at c ~date <> None in
363363+ has_email || has_org || has_url
364364+ ) all
365365+366366+let pp ppf t =
367367+ let all = list t in
368368+ Fmt.pf ppf "@[<v>%a: %d contacts stored in XDG data directory@]"
369369+ (Fmt.styled `Bold Fmt.string) "Sortal Store"
370370+ (List.length all)
+261
sortal/lib/core/sortal_store.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Contact store with XDG-compliant storage.
77+88+ The contact store manages reading and writing contact metadata
99+ using XDG-compliant storage locations. Contacts are stored as
1010+ YAML files (one per contact) using the handle as the filename. *)
1111+1212+module Contact = Sortal_schema.Contact
1313+module Temporal = Sortal_schema.Temporal
1414+1515+type t
1616+1717+(** [create fs app_name] creates a new contact store.
1818+1919+ The store will use XDG data directories for persistent storage
2020+ of contact metadata. Each contact is stored as a separate YAML
2121+ file named after its handle.
2222+2323+ @param fs Eio filesystem for file operations
2424+ @param app_name Application name for XDG directory structure *)
2525+val create : Eio.Fs.dir_ty Eio.Path.t -> string -> t
2626+2727+(** [create_from_xdg xdg] creates a contact store from an XDG context.
2828+2929+ This is a convenience function for creating a store when you already
3030+ have an XDG context (e.g., from your own XDG initialization).
3131+ The store will use the XDG data directory for the application.
3232+3333+ @param xdg An existing XDG context
3434+ @return A contact store using the XDG data directory *)
3535+val create_from_xdg : Xdge.t -> t
3636+3737+(** {1 Storage Operations} *)
3838+3939+(** [save t contact] saves a contact to the store.
4040+4141+ The contact is serialized to YAML and written to a file
4242+ named "handle.yaml" in the XDG data directory.
4343+4444+ If a contact with the same handle already exists, it is overwritten. *)
4545+val save : t -> Contact.t -> unit
4646+4747+(** [lookup t handle] retrieves a contact by handle.
4848+4949+ Searches for a file named "handle.yaml" in the XDG data directory
5050+ and deserializes it if found.
5151+5252+ @return [Some contact] if found, [None] if not found or deserialization fails *)
5353+val lookup : t -> string -> Contact.t option
5454+5555+(** [delete t handle] removes a contact from the store.
5656+5757+ Deletes the file "handle.yaml" from the XDG data directory.
5858+ Does nothing if the contact does not exist. *)
5959+val delete : t -> string -> unit
6060+6161+(** {1 Contact Modification} *)
6262+6363+(** [add_email t handle email] adds an email to an existing contact.
6464+6565+ @param t The store
6666+ @param handle The contact handle
6767+ @param email The email entry to add
6868+ @return [Ok ()] on success, [Error msg] if contact not found
6969+ @raise Failure if the contact cannot be saved *)
7070+val add_email : t -> string -> Contact.email -> (unit, string) result
7171+7272+(** [remove_email t handle address] removes an email from a contact.
7373+7474+ Removes all email entries with the given address.
7575+7676+ @param t The store
7777+ @param handle The contact handle
7878+ @param address The email address to remove
7979+ @return [Ok ()] on success, [Error msg] if contact not found *)
8080+val remove_email : t -> string -> string -> (unit, string) result
8181+8282+(** [add_service t handle service] adds a service to an existing contact.
8383+8484+ @param t The store
8585+ @param handle The contact handle
8686+ @param service The service entry to add
8787+ @return [Ok ()] on success, [Error msg] if contact not found *)
8888+val add_service : t -> string -> Contact.service -> (unit, string) result
8989+9090+(** [remove_service t handle url] removes a service from a contact.
9191+9292+ Removes all service entries with the given URL.
9393+9494+ @param t The store
9595+ @param handle The contact handle
9696+ @param url The service URL to remove
9797+ @return [Ok ()] on success, [Error msg] if contact not found *)
9898+val remove_service : t -> string -> string -> (unit, string) result
9999+100100+(** [add_organization t handle org] adds an organization to an existing contact.
101101+102102+ @param t The store
103103+ @param handle The contact handle
104104+ @param org The organization entry to add
105105+ @return [Ok ()] on success, [Error msg] if contact not found *)
106106+val add_organization : t -> string -> Contact.organization -> (unit, string) result
107107+108108+(** [remove_organization t handle name] removes an organization from a contact.
109109+110110+ Removes all organization entries with the given name.
111111+112112+ @param t The store
113113+ @param handle The contact handle
114114+ @param name The organization name to remove
115115+ @return [Ok ()] on success, [Error msg] if contact not found *)
116116+val remove_organization : t -> string -> string -> (unit, string) result
117117+118118+(** [add_url t handle url_entry] adds a URL to an existing contact.
119119+120120+ @param t The store
121121+ @param handle The contact handle
122122+ @param url_entry The URL entry to add
123123+ @return [Ok ()] on success, [Error msg] if contact not found *)
124124+val add_url : t -> string -> Contact.url_entry -> (unit, string) result
125125+126126+(** [remove_url t handle url] removes a URL from a contact.
127127+128128+ Removes all URL entries with the given URL.
129129+130130+ @param t The store
131131+ @param handle The contact handle
132132+ @param url The URL to remove
133133+ @return [Ok ()] on success, [Error msg] if contact not found *)
134134+val remove_url : t -> string -> string -> (unit, string) result
135135+136136+(** [update_contact t handle f] updates a contact by applying function [f].
137137+138138+ Looks up the contact, applies [f] to transform it, and saves the result.
139139+140140+ @param t The store
141141+ @param handle The contact handle
142142+ @param f Function to transform the contact
143143+ @return [Ok ()] on success, [Error msg] if contact not found *)
144144+val update_contact : t -> string -> (Contact.t -> Contact.t) -> (unit, string) result
145145+146146+(** [list t] returns all contacts in the store.
147147+148148+ Scans the XDG data directory for all .yaml files and attempts
149149+ to deserialize them as contacts. Files that fail to parse are
150150+ silently skipped.
151151+152152+ @return A list of all successfully loaded contacts *)
153153+val list : t -> Contact.t list
154154+155155+(** [thumbnail_path t contact] returns the absolute filesystem path to the contact's thumbnail.
156156+157157+ Returns [None] if the contact has no thumbnail set, or [Some path] with
158158+ the full path to the thumbnail file in Sortal's data directory.
159159+160160+ @param t The Sortal store
161161+ @param contact The contact whose thumbnail path to retrieve *)
162162+val thumbnail_path : t -> Contact.t -> Eio.Fs.dir_ty Eio.Path.t option
163163+164164+(** [png_thumbnail_path t contact] returns the path to the PNG version of the contact's thumbnail.
165165+166166+ Returns [None] if the contact has no thumbnail set or if no PNG version exists.
167167+ This looks for a .png file with the same base name as the contact's thumbnail.
168168+ Use this after running [sync] to get the converted PNG thumbnails.
169169+170170+ @param t The Sortal store
171171+ @param contact The contact whose PNG thumbnail path to retrieve *)
172172+val png_thumbnail_path : t -> Contact.t -> Eio.Fs.dir_ty Eio.Path.t option
173173+174174+(** {1 Searching} *)
175175+176176+(** [find_by_name t name] searches for contacts by name.
177177+178178+ Performs a case-insensitive search through all contacts,
179179+ checking if any of their names match the provided name.
180180+181181+ @param name The name to search for (case-insensitive)
182182+ @return The matching contact if exactly one match is found
183183+ @raise Not_found if no contacts match the name
184184+ @raise Invalid_argument if multiple contacts match the name *)
185185+val find_by_name : t -> string -> Contact.t
186186+187187+(** [find_by_name_opt t name] searches for contacts by name, returning an option.
188188+189189+ Like {!find_by_name} but returns [None] instead of raising exceptions
190190+ when no match or multiple matches are found.
191191+192192+ @param name The name to search for (case-insensitive)
193193+ @return [Some contact] if exactly one match is found, [None] otherwise *)
194194+val find_by_name_opt : t -> string -> Contact.t option
195195+196196+(** [search_all t query] searches for contacts matching a query string.
197197+198198+ Performs a flexible search through all contact names, looking for:
199199+ - Exact matches (case-insensitive)
200200+ - Names that start with the query
201201+ - Multi-word names where any word starts with the query
202202+203203+ This is useful for autocomplete or fuzzy search functionality.
204204+205205+ @param t The contact store
206206+ @param query The search query (case-insensitive)
207207+ @return A list of matching contacts, sorted by handle *)
208208+val search_all : t -> string -> Contact.t list
209209+210210+(** {1 Temporal Queries} *)
211211+212212+(** [find_by_email_at t ~email ~date] finds a contact by email address at a specific date.
213213+214214+ Searches for a contact that had the given email address valid at [date].
215215+216216+ @param email Email address to search for
217217+ @param date ISO 8601 date string
218218+ @return The first matching contact, or [None] if not found *)
219219+val find_by_email_at : t -> email:string -> date:Temporal.date ->
220220+ Contact.t option
221221+222222+(** [find_by_org t ~org ?from ?until ()] finds contacts who worked at an organization.
223223+224224+ Searches for contacts whose organization records overlap with the given period.
225225+ If [from] and [until] are omitted, returns all contacts who ever worked there.
226226+227227+ @param org Organization name (case-insensitive substring match)
228228+ @param from Start date of period to check (inclusive, optional)
229229+ @param until End date of period to check (exclusive, optional)
230230+ @return List of matching contacts, sorted by handle *)
231231+val find_by_org : t -> org:string -> ?from:Temporal.date ->
232232+ ?until:Temporal.date -> unit -> Contact.t list
233233+234234+(** [list_at t ~date] returns contacts that were active at a specific date.
235235+236236+ A contact is considered active at a date if it has at least one
237237+ email, organization, or URL valid at that date.
238238+239239+ @param date ISO 8601 date string
240240+ @return List of active contacts at that date *)
241241+val list_at : t -> date:Temporal.date -> Contact.t list
242242+243243+(** {1 Utilities} *)
244244+245245+(** [handle_of_name name] generates a handle from a full name.
246246+247247+ Creates a handle by concatenating the initials of all words
248248+ in the name with the full last name, all in lowercase.
249249+250250+ Examples:
251251+ - "Anil Madhavapeddy" -> "ammadhavapeddy"
252252+ - "John Smith" -> "jssmith"
253253+254254+ @param name The full name to convert
255255+ @return A suggested handle *)
256256+val handle_of_name : string -> string
257257+258258+(** {1 Pretty Printing} *)
259259+260260+(** [pp ppf t] pretty prints the contact store showing statistics. *)
261261+val pp : Format.formatter -> t -> unit
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Sortal Schema - Versioned data types and serialization
77+88+ This library provides versioned schema definitions for contact metadata
99+ with minimal I/O dependencies. It includes:
1010+ - Temporal validity support (ISO 8601 dates and ranges)
1111+ - Feed subscription types
1212+ - Contact metadata schemas (versioned)
1313+1414+ The schema library depends on jsont, yamlt, bytesrw, fmt for serialization
1515+ and formatting, plus ptime and ptime.clock.os for date/time operations. *)
1616+1717+(** {1 Schema Version 1} *)
1818+1919+module V1 : sig
2020+ (** Version 1 of the contact schema (current stable version). *)
2121+2222+ (** Temporal validity support for time-bounded fields. *)
2323+ module Temporal = Sortal_schema_temporal
2424+2525+ (** Feed subscription metadata. *)
2626+ module Feed = Sortal_schema_feed
2727+2828+ (** Contact metadata with temporal support. *)
2929+ module Contact = Sortal_schema_contact_v1
3030+end
3131+3232+(** {1 Current Version Aliases}
3333+3434+ These aliases point to the current stable schema version (V1).
3535+ When V2 is introduced, these will continue pointing to V1 for
3636+ backward compatibility. *)
3737+3838+module Temporal = V1.Temporal
3939+module Feed = V1.Feed
4040+module Contact = V1.Contact
+475
sortal/lib/schema/sortal_schema_contact_v1.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+let version = 1
77+88+type contact_kind = Person | Organization | Group | Role
99+1010+type service_kind =
1111+ | ActivityPub
1212+ | Github
1313+ | Git
1414+ | Social
1515+ | Photo
1616+ | Custom of string
1717+1818+type service = {
1919+ url: string;
2020+ kind: service_kind option;
2121+ handle: string option;
2222+ label: string option;
2323+ range: Sortal_schema_temporal.range option;
2424+ primary: bool;
2525+}
2626+2727+type email_type = Work | Personal | Other
2828+2929+type email = {
3030+ address: string;
3131+ type_: email_type option;
3232+ range: Sortal_schema_temporal.range option;
3333+ note: string option;
3434+}
3535+3636+type organization = {
3737+ name: string;
3838+ title: string option;
3939+ department: string option;
4040+ range: Sortal_schema_temporal.range option;
4141+ email: string option;
4242+ url: string option;
4343+}
4444+4545+type url_entry = {
4646+ url: string;
4747+ label: string option;
4848+ range: Sortal_schema_temporal.range option;
4949+}
5050+5151+type t = {
5252+ version: int;
5353+ kind: contact_kind;
5454+ handle: string;
5555+ names: string list;
5656+ emails: email list;
5757+ organizations: organization list;
5858+ urls: url_entry list;
5959+ services: service list;
6060+ icon: string option;
6161+ thumbnail: string option;
6262+ orcid: string option;
6363+ feeds: Sortal_schema_feed.t list option;
6464+}
6565+6666+(* Helpers *)
6767+let make_email ?type_ ?from ?until ?note address =
6868+ let range = match from, until with
6969+ | None, None -> None
7070+ | _, _ -> Some (Sortal_schema_temporal.make ?from ?until ())
7171+ in
7272+ { address; type_; range; note }
7373+7474+let email_of_string address =
7575+ { address; type_ = Some Personal; range = None; note = None }
7676+7777+let make_org ?title ?department ?from ?until ?email ?url name =
7878+ let range = match from, until with
7979+ | None, None -> None
8080+ | _, _ -> Some (Sortal_schema_temporal.make ?from ?until ())
8181+ in
8282+ { name; title; department; range; email; url }
8383+8484+let make_url ?label ?from ?until url =
8585+ let range = match from, until with
8686+ | None, None -> None
8787+ | _, _ -> Some (Sortal_schema_temporal.make ?from ?until ())
8888+ in
8989+ { url; label; range }
9090+9191+let url_of_string url =
9292+ { url; label = None; range = None }
9393+9494+let make_service ?kind ?handle ?label ?from ?until ?(primary = false) url =
9595+ let range = match from, until with
9696+ | None, None -> None
9797+ | _, _ -> Some (Sortal_schema_temporal.make ?from ?until ())
9898+ in
9999+ { url; kind; handle; label; range; primary }
100100+101101+let service_of_url url =
102102+ { url; kind = None; handle = None; label = None; range = None; primary = false }
103103+104104+let make ~handle ~names ?(kind = Person) ?(emails = []) ?(organizations = [])
105105+ ?(urls = []) ?(services = []) ?icon ?thumbnail ?orcid ?feeds () =
106106+ { version; kind; handle; names; emails; organizations; urls; services;
107107+ icon; thumbnail; orcid; feeds }
108108+109109+(* Accessors *)
110110+let version_of t = t.version
111111+let kind t = t.kind
112112+let handle t = t.handle
113113+let names t = t.names
114114+let name t = List.hd t.names
115115+let primary_name = name
116116+let emails t = t.emails
117117+let organizations t = t.organizations
118118+let urls t = t.urls
119119+let services t = t.services
120120+let icon t = t.icon
121121+let thumbnail t = t.thumbnail
122122+let orcid t = t.orcid
123123+let feeds t = t.feeds
124124+125125+(* Temporal queries *)
126126+let emails_at t ~date =
127127+ Sortal_schema_temporal.at_date ~get:(fun (e : email) -> e.range) ~date t.emails
128128+129129+let email_at t ~date =
130130+ match emails_at t ~date with
131131+ | e :: _ -> Some e.address
132132+ | [] -> None
133133+134134+let current_email t =
135135+ match Sortal_schema_temporal.current ~get:(fun (e : email) -> e.range) t.emails with
136136+ | Some e -> Some e.address
137137+ | None -> None
138138+139139+let organization_at t ~date =
140140+ match Sortal_schema_temporal.at_date ~get:(fun (o : organization) -> o.range) ~date t.organizations with
141141+ | o :: _ -> Some o
142142+ | [] -> None
143143+144144+let current_organization t =
145145+ Sortal_schema_temporal.current ~get:(fun (o : organization) -> o.range) t.organizations
146146+147147+let url_at t ~date =
148148+ match Sortal_schema_temporal.at_date ~get:(fun (u : url_entry) -> u.range) ~date t.urls with
149149+ | u :: _ -> Some u.url
150150+ | [] -> None
151151+152152+let current_url t =
153153+ match Sortal_schema_temporal.current ~get:(fun (u : url_entry) -> u.range) t.urls with
154154+ | Some u -> Some u.url
155155+ | None -> None
156156+157157+let all_email_addresses t =
158158+ List.map (fun e -> e.address) t.emails
159159+160160+(* Service queries *)
161161+let services_of_kind t (kind : service_kind) =
162162+ List.filter (fun (s : service) ->
163163+ match (s.kind : service_kind option) with
164164+ | Some k when k = kind -> true
165165+ | _ -> false
166166+ ) t.services
167167+168168+let services_at t ~date =
169169+ Sortal_schema_temporal.at_date ~get:(fun (s : service) -> s.range) ~date t.services
170170+171171+let current_services t =
172172+ List.filter (fun (s : service) -> Sortal_schema_temporal.is_current s.range) t.services
173173+174174+let primary_service t (kind : service_kind) =
175175+ List.find_opt (fun (s : service) ->
176176+ match (s.kind : service_kind option) with
177177+ | Some k when k = kind && s.primary -> true
178178+ | _ -> false
179179+ ) t.services
180180+181181+let best_url t =
182182+ current_url t
183183+ |> Option.fold ~none:(
184184+ match current_services t with
185185+ | s :: _ -> Some s.url
186186+ | [] -> current_email t |> Option.map (fun e -> "mailto:" ^ e)
187187+ ) ~some:Option.some
188188+189189+(* Modification *)
190190+let add_feed t feed =
191191+ { t with feeds = Some (feed :: Option.value t.feeds ~default:[]) }
192192+193193+let remove_feed t url =
194194+ { t with feeds = Option.map (List.filter (fun f -> Sortal_schema_feed.url f <> url)) t.feeds }
195195+196196+(* Comparison *)
197197+let compare a b = String.compare a.handle b.handle
198198+199199+(* Type conversions *)
200200+let contact_kind_to_string = function
201201+ | Person -> "person"
202202+ | Organization -> "organization"
203203+ | Group -> "group"
204204+ | Role -> "role"
205205+206206+let contact_kind_of_string = function
207207+ | "person" -> Some Person
208208+ | "organization" -> Some Organization
209209+ | "group" -> Some Group
210210+ | "role" -> Some Role
211211+ | _ -> None
212212+213213+let service_kind_to_string = function
214214+ | ActivityPub -> "activitypub"
215215+ | Github -> "github"
216216+ | Git -> "git"
217217+ | Social -> "social"
218218+ | Photo -> "photo"
219219+ | Custom s -> s
220220+221221+let service_kind_of_string s =
222222+ match String.lowercase_ascii s with
223223+ | "activitypub" -> Some ActivityPub
224224+ | "github" -> Some Github
225225+ | "git" -> Some Git
226226+ | "social" -> Some Social
227227+ | "photo" -> Some Photo
228228+ | "" | "custom" -> None
229229+ | _ -> Some (Custom s)
230230+231231+let email_type_to_string = function
232232+ | Work -> "work"
233233+ | Personal -> "personal"
234234+ | Other -> "other"
235235+236236+let email_type_of_string = function
237237+ | "work" -> Some Work
238238+ | "personal" -> Some Personal
239239+ | "other" -> Some Other
240240+ | _ -> None
241241+242242+(* JSON encoding *)
243243+244244+(* Helper: case-insensitive enum decoder *)
245245+let case_insensitive_enum ~kind:kind_name cases =
246246+ let open Jsont in
247247+ let lowercase_cases = List.map (fun (s, v) -> (String.lowercase_ascii s, v)) cases in
248248+ let dec s =
249249+ match List.assoc_opt (String.lowercase_ascii s) lowercase_cases with
250250+ | Some v -> v
251251+ | None -> failwith ("unknown " ^ kind_name ^ ": " ^ s)
252252+ in
253253+ let enc v =
254254+ match List.find_opt (fun (_, v') -> v = v') cases with
255255+ | Some (s, _) -> s
256256+ | None -> failwith ("invalid " ^ kind_name)
257257+ in
258258+ let t = map ~kind:kind_name ~dec ~enc string in
259259+ t
260260+261261+let contact_kind_json =
262262+ case_insensitive_enum ~kind:"ContactKind" [
263263+ "person", Person;
264264+ "organization", Organization;
265265+ "group", Group;
266266+ "role", Role;
267267+ ]
268268+269269+let service_json : service Jsont.t =
270270+ let open Jsont in
271271+ let open Jsont.Object in
272272+ let mem_opt f v ~enc = mem f v ~dec_absent:None ~enc_omit:Option.is_none ~enc in
273273+ (* Convert string option to/from service_kind option *)
274274+ let dec_kind_opt kind_str =
275275+ match kind_str with
276276+ | None -> None
277277+ | Some s -> service_kind_of_string s
278278+ in
279279+ let enc_kind_opt = Option.map service_kind_to_string in
280280+ let make url kind_str handle label range primary : service =
281281+ let kind = dec_kind_opt kind_str in
282282+ { url; kind; handle; label; range; primary }
283283+ in
284284+ map ~kind:"Service" make
285285+ |> mem "url" string ~enc:(fun (s : service) -> s.url)
286286+ |> mem_opt "kind" (some string) ~enc:(fun (s : service) -> enc_kind_opt s.kind)
287287+ |> mem_opt "handle" (some string) ~enc:(fun (s : service) -> s.handle)
288288+ |> mem_opt "label" (some string) ~enc:(fun (s : service) -> s.label)
289289+ |> mem_opt "range" (some Sortal_schema_temporal.json_t) ~enc:(fun (s : service) -> s.range)
290290+ |> mem "primary" bool ~dec_absent:false ~enc:(fun (s : service) -> s.primary)
291291+ |> finish
292292+293293+let email_type_json =
294294+ case_insensitive_enum ~kind:"EmailType" [
295295+ "work", Work;
296296+ "personal", Personal;
297297+ "other", Other;
298298+ ]
299299+300300+let email_json : email Jsont.t =
301301+ let open Jsont in
302302+ let open Jsont.Object in
303303+ let mem_opt f v ~enc = mem f v ~dec_absent:None ~enc_omit:Option.is_none ~enc in
304304+ let make address type_ range note : email = { address; type_; range; note } in
305305+ map ~kind:"Email" make
306306+ |> mem "address" string ~enc:(fun (e : email) -> e.address)
307307+ |> mem_opt "type" (some email_type_json) ~enc:(fun (e : email) -> e.type_)
308308+ |> mem_opt "range" (some Sortal_schema_temporal.json_t) ~enc:(fun (e : email) -> e.range)
309309+ |> mem_opt "note" (some string) ~enc:(fun (e : email) -> e.note)
310310+ |> finish
311311+312312+let organization_json : organization Jsont.t =
313313+ let open Jsont in
314314+ let open Jsont.Object in
315315+ let mem_opt f v ~enc = mem f v ~dec_absent:None ~enc_omit:Option.is_none ~enc in
316316+ let make name title department range email url : organization =
317317+ { name; title; department; range; email; url }
318318+ in
319319+ map ~kind:"Organization" make
320320+ |> mem "name" string ~enc:(fun (o : organization) -> o.name)
321321+ |> mem_opt "title" (some string) ~enc:(fun (o : organization) -> o.title)
322322+ |> mem_opt "department" (some string) ~enc:(fun (o : organization) -> o.department)
323323+ |> mem_opt "range" (some Sortal_schema_temporal.json_t) ~enc:(fun (o : organization) -> o.range)
324324+ |> mem_opt "email" (some string) ~enc:(fun (o : organization) -> o.email)
325325+ |> mem_opt "url" (some string) ~enc:(fun (o : organization) -> o.url)
326326+ |> finish
327327+328328+let url_entry_json : url_entry Jsont.t =
329329+ let open Jsont in
330330+ let open Jsont.Object in
331331+ let mem_opt f v ~enc = mem f v ~dec_absent:None ~enc_omit:Option.is_none ~enc in
332332+ let make url label range : url_entry = { url; label; range } in
333333+ map ~kind:"URL" make
334334+ |> mem "url" string ~enc:(fun (u : url_entry) -> u.url)
335335+ |> mem_opt "label" (some string) ~enc:(fun (u : url_entry) -> u.label)
336336+ |> mem_opt "range" (some Sortal_schema_temporal.json_t) ~enc:(fun (u : url_entry) -> u.range)
337337+ |> finish
338338+339339+let json_t =
340340+ let open Jsont in
341341+ let open Jsont.Object in
342342+ let mem_opt f v ~enc = mem f v ~dec_absent:None ~enc_omit:Option.is_none ~enc in
343343+ let make version kind handle names emails organizations urls services
344344+ icon thumbnail orcid feeds =
345345+ if version <> 1 then
346346+ failwith (Printf.sprintf "Unsupported contact schema version: %d" version);
347347+ { version; kind; handle; names; emails; organizations; urls; services;
348348+ icon; thumbnail; orcid; feeds }
349349+ in
350350+ map ~kind:"Contact" make
351351+ |> mem "version" int ~enc:(fun _ -> 1)
352352+ |> mem "kind" contact_kind_json ~dec_absent:Person ~enc:(fun c -> c.kind)
353353+ |> mem "handle" string ~enc:(fun c -> c.handle)
354354+ |> mem "names" (list string) ~dec_absent:[] ~enc:(fun c -> c.names)
355355+ |> mem "emails" (list email_json) ~dec_absent:[] ~enc:(fun c -> c.emails)
356356+ |> mem "organizations" (list organization_json) ~dec_absent:[] ~enc:(fun c -> c.organizations)
357357+ |> mem "urls" (list url_entry_json) ~dec_absent:[] ~enc:(fun c -> c.urls)
358358+ |> mem "services" (list service_json) ~dec_absent:[] ~enc:(fun c -> c.services)
359359+ |> mem_opt "icon" (some string) ~enc:(fun c -> c.icon)
360360+ |> mem_opt "thumbnail" (some string) ~enc:(fun c -> c.thumbnail)
361361+ |> mem_opt "orcid" (some string) ~enc:(fun c -> c.orcid)
362362+ |> mem_opt "feeds" (some (list Sortal_schema_feed.json_t)) ~enc:(fun c -> c.feeds)
363363+ |> finish
364364+365365+(* Pretty printing *)
366366+let pp ppf t =
367367+ let open Fmt in
368368+ let label = styled (`Fg `Cyan) string in
369369+ let url_style = styled (`Fg `Blue) in
370370+ let date_style = styled (`Fg `Green) in
371371+ let field lbl fmt_v = Option.iter (fun v -> pf ppf "%a: %a@," label lbl fmt_v v) in
372372+373373+ let pp_range ppf = function
374374+ | None -> ()
375375+ | Some { Sortal_schema_temporal.from; until } ->
376376+ match from, until with
377377+ | Some f, Some u ->
378378+ let fs = Sortal_schema_temporal.format_date f in
379379+ let us = Sortal_schema_temporal.format_date u in
380380+ pf ppf " %a" (date_style string) (Printf.sprintf "[%s to %s]" fs us)
381381+ | Some f, None ->
382382+ let fs = Sortal_schema_temporal.format_date f in
383383+ pf ppf " %a" (date_style string) (Printf.sprintf "[from %s]" fs)
384384+ | None, Some u ->
385385+ let us = Sortal_schema_temporal.format_date u in
386386+ pf ppf " %a" (date_style string) (Printf.sprintf "[until %s]" us)
387387+ | None, None -> ()
388388+ in
389389+390390+ pf ppf "@[<v>";
391391+ pf ppf "%a: %a@," label "Handle" (styled `Bold (fun ppf s -> pf ppf "@%s" s)) t.handle;
392392+393393+ (* Show kind if not a person *)
394394+ (match t.kind with
395395+ | Person -> ()
396396+ | k -> pf ppf "%a: %a@," label "Kind" (styled (`Fg `Magenta) string) (contact_kind_to_string k));
397397+398398+ pf ppf "%a: %a@," label "Name" (styled `Bold string) (name t);
399399+400400+ if List.length (names t) > 1 then
401401+ pf ppf "%a: @[<h>%a@]@," label "Aliases"
402402+ (list ~sep:comma string) (List.tl (names t));
403403+404404+ (* Emails with temporal info *)
405405+ if emails t <> [] then begin
406406+ pf ppf "%a:@," label "Emails";
407407+ List.iter (fun e ->
408408+ pf ppf " %a%s%s%a%a@,"
409409+ (styled (`Fg `Yellow) string) e.address
410410+ (match e.type_ with Some Work -> " (work)" | Some Personal -> " (personal)" | Some Other -> " (other)" | None -> "")
411411+ (match e.note with Some n -> " - " ^ n | None -> "")
412412+ pp_range e.range
413413+ (fun ppf current -> if current then pf ppf " %a" (styled (`Fg `Magenta) string) "[current]" else ())
414414+ (Sortal_schema_temporal.is_current e.range)
415415+ ) (emails t)
416416+ end;
417417+418418+ (* Organizations with temporal info *)
419419+ if organizations t <> [] then begin
420420+ pf ppf "%a:@," label "Organizations";
421421+ List.iter (fun o ->
422422+ pf ppf " %a" (styled `Bold string) o.name;
423423+ Option.iter (fun title -> pf ppf " - %s" title) o.title;
424424+ Option.iter (fun dept -> pf ppf " (%s)" dept) o.department;
425425+ pf ppf "%a" pp_range o.range;
426426+ if Sortal_schema_temporal.is_current o.range then
427427+ pf ppf " %a" (styled (`Fg `Magenta) string) "[current]";
428428+ pf ppf "@,";
429429+ Option.iter (fun email -> pf ppf " Email: %a@," (styled (`Fg `Yellow) string) email) o.email;
430430+ Option.iter (fun url -> pf ppf " URL: %a@," (url_style string) url) o.url;
431431+ ) (organizations t)
432432+ end;
433433+434434+ (* URLs *)
435435+ if urls t <> [] then begin
436436+ pf ppf "%a:@," label "URLs";
437437+ List.iter (fun u ->
438438+ pf ppf " %a" (url_style string) u.url;
439439+ Option.iter (fun lbl -> pf ppf " (%s)" lbl) u.label;
440440+ pf ppf "%a" pp_range u.range;
441441+ if Sortal_schema_temporal.is_current u.range then
442442+ pf ppf " %a" (styled (`Fg `Magenta) string) "[current]";
443443+ pf ppf "@,"
444444+ ) (urls t)
445445+ end;
446446+447447+ (* Services *)
448448+ if services t <> [] then begin
449449+ pf ppf "%a:@," label "Services";
450450+ List.iter (fun (s : service) ->
451451+ pf ppf " %a" (url_style string) s.url;
452452+ Option.iter (fun k -> pf ppf " (%s)" (service_kind_to_string k)) s.kind;
453453+ Option.iter (fun h -> pf ppf " [@%s]" h) s.handle;
454454+ Option.iter (fun lbl -> pf ppf " - %s" lbl) s.label;
455455+ pf ppf "%a" pp_range s.range;
456456+ if s.primary then pf ppf " %a" (styled (`Fg `Yellow) string) "[primary]";
457457+ if Sortal_schema_temporal.is_current s.range then
458458+ pf ppf " %a" (styled (`Fg `Magenta) string) "[current]";
459459+ pf ppf "@,"
460460+ ) (services t)
461461+ end;
462462+463463+ field "ORCID" (url_style (fun ppf o -> pf ppf "https://orcid.org/%s" o)) t.orcid;
464464+465465+ field "Icon" (url_style string) t.icon;
466466+ field "Thumbnail" (styled (`Fg `White) string) t.thumbnail;
467467+468468+ Option.iter (function
469469+ | [] -> ()
470470+ | feeds ->
471471+ pf ppf "%a:@," label "Feeds";
472472+ List.iter (fun feed -> pf ppf " - %a@," Sortal_schema_feed.pp feed) feeds
473473+ ) t.feeds;
474474+475475+ pf ppf "@]"
+277
sortal/lib/schema/sortal_schema_contact_v1.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Contact schema V1 with temporal support.
77+88+ This module defines the V1 contact schema with support for time-bounded
99+ information such as emails and organizations that are valid only during
1010+ specific periods.
1111+1212+ {b Schema Version Policy:}
1313+ - New optional fields can be added without bumping the version
1414+ - The version must be bumped only if the {i meaning} of an existing
1515+ field changes
1616+ - This allows forward compatibility: older readers can ignore new fields *)
1717+1818+(** {1 Schema Version} *)
1919+2020+val version : int
2121+(** The schema version number for V1. Currently [1]. *)
2222+2323+(** {1 Types} *)
2424+2525+(** Contact kind - what type of entity this represents. *)
2626+type contact_kind =
2727+ | Person (** Individual person *)
2828+ | Organization (** Company, lab, department *)
2929+ | Group (** Research group, project team *)
3030+ | Role (** Generic role email like info@, admin@ *)
3131+3232+(** Service kind - categorization of online presence. *)
3333+type service_kind =
3434+ | ActivityPub (** Mastodon, Pixelfed, PeerTube, etc *)
3535+ | Github (** GitHub *)
3636+ | Git (** GitLab, Gitea, Codeberg, etc *)
3737+ | Social (** Twitter/X, LinkedIn, etc *)
3838+ | Photo (** Immich, Flickr, Instagram, etc *)
3939+ | Custom of string (** Other service types *)
4040+4141+(** An online service/identity. *)
4242+type service = {
4343+ url: string; (** Full URL (primary identifier) *)
4444+ kind: service_kind option; (** Optional service categorization *)
4545+ handle: string option; (** Optional short handle/username *)
4646+ label: string option; (** Human description: "Cambridge GitLab", "Work account" *)
4747+ range: Sortal_schema_temporal.range option; (** Temporal validity *)
4848+ primary: bool; (** Is this the primary/preferred service of its kind? *)
4949+}
5050+5151+type email_type = Work | Personal | Other
5252+5353+type email = {
5454+ address: string;
5555+ type_: email_type option;
5656+ range: Sortal_schema_temporal.range option; (** Validity period *)
5757+ note: string option; (** Context note, e.g., "NetApp position" *)
5858+}
5959+6060+type organization = {
6161+ name: string;
6262+ title: string option;
6363+ department: string option;
6464+ range: Sortal_schema_temporal.range option; (** Employment period *)
6565+ email: string option; (** Work email during this period *)
6666+ url: string option; (** Work homepage during this period *)
6767+}
6868+6969+type url_entry = {
7070+ url: string;
7171+ label: string option; (** Human-readable label *)
7272+ range: Sortal_schema_temporal.range option; (** Validity period *)
7373+}
7474+7575+type t = {
7676+ version: int; (** Schema version (always 1 for V1) *)
7777+ kind: contact_kind; (** Type of entity (Person, Organization, etc) *)
7878+ handle: string; (** Unique identifier *)
7979+ names: string list; (** Names, first is primary *)
8080+8181+ (* Temporal fields *)
8282+ emails: email list; (** Email addresses with temporal validity *)
8383+ organizations: organization list; (** Employment/affiliation history *)
8484+ urls: url_entry list; (** URLs with optional temporal validity *)
8585+ services: service list; (** Online services/identities *)
8686+8787+ (* Simple fields - rarely change over time *)
8888+ icon: string option; (** Avatar URL *)
8989+ thumbnail: string option; (** Local thumbnail path *)
9090+ orcid: string option; (** ORCID identifier *)
9191+9292+ (* Other *)
9393+ feeds: Sortal_schema_feed.t list option; (** Feed subscriptions *)
9494+}
9595+9696+(** {1 Construction} *)
9797+9898+(** [make ~handle ~names ?kind ?emails ?organizations ?urls ?services
9999+ ?icon ?thumbnail ?orcid ?feeds ()]
100100+ creates a new V1 contact.
101101+102102+ The [version] field is automatically set to [1].
103103+ The [kind] defaults to [Person] if not specified. *)
104104+val make :
105105+ handle:string ->
106106+ names:string list ->
107107+ ?kind:contact_kind ->
108108+ ?emails:email list ->
109109+ ?organizations:organization list ->
110110+ ?urls:url_entry list ->
111111+ ?services:service list ->
112112+ ?icon:string ->
113113+ ?thumbnail:string ->
114114+ ?orcid:string ->
115115+ ?feeds:Sortal_schema_feed.t list ->
116116+ unit ->
117117+ t
118118+119119+(** {1 Email Helpers} *)
120120+121121+(** [make_email ?type_ ?from ?until ?note address] creates an email entry.
122122+123123+ @param type_ Email type (Work, Personal, Other)
124124+ @param from Start date of validity
125125+ @param until End date of validity (exclusive)
126126+ @param note Contextual note *)
127127+val make_email :
128128+ ?type_:email_type ->
129129+ ?from:Sortal_schema_temporal.date ->
130130+ ?until:Sortal_schema_temporal.date ->
131131+ ?note:string ->
132132+ string ->
133133+ email
134134+135135+(** [email_of_string s] creates a simple always-valid personal email. *)
136136+val email_of_string : string -> email
137137+138138+(** {1 Organization Helpers} *)
139139+140140+(** [make_org ?title ?department ?from ?until ?email ?url name]
141141+ creates an organization entry. *)
142142+val make_org :
143143+ ?title:string ->
144144+ ?department:string ->
145145+ ?from:Sortal_schema_temporal.date ->
146146+ ?until:Sortal_schema_temporal.date ->
147147+ ?email:string ->
148148+ ?url:string ->
149149+ string ->
150150+ organization
151151+152152+(** {1 URL Helpers} *)
153153+154154+(** [make_url ?label ?from ?until url] creates a URL entry. *)
155155+val make_url :
156156+ ?label:string ->
157157+ ?from:Sortal_schema_temporal.date ->
158158+ ?until:Sortal_schema_temporal.date ->
159159+ string ->
160160+ url_entry
161161+162162+(** [url_of_string s] creates a simple always-valid URL. *)
163163+val url_of_string : string -> url_entry
164164+165165+(** {1 Service Helpers} *)
166166+167167+(** [make_service ?kind ?handle ?label ?from ?until ?primary url]
168168+ creates a service entry.
169169+170170+ @param kind Optional service categorization
171171+ @param handle Optional short handle/username
172172+ @param label Optional description (e.g., "Work account", "Cambridge GitLab")
173173+ @param from Start date of validity
174174+ @param until End date of validity (exclusive)
175175+ @param primary Whether this is the primary service of its kind
176176+ @param url Full URL to the service (required) *)
177177+val make_service :
178178+ ?kind:service_kind ->
179179+ ?handle:string ->
180180+ ?label:string ->
181181+ ?from:Sortal_schema_temporal.date ->
182182+ ?until:Sortal_schema_temporal.date ->
183183+ ?primary:bool ->
184184+ string ->
185185+ service
186186+187187+(** [service_of_url url] creates a simple always-valid service from just a URL. *)
188188+val service_of_url : string -> service
189189+190190+(** {1 Accessors} *)
191191+192192+val version_of : t -> int
193193+val kind : t -> contact_kind
194194+val handle : t -> string
195195+val names : t -> string list
196196+val name : t -> string
197197+val primary_name : t -> string
198198+val emails : t -> email list
199199+val organizations : t -> organization list
200200+val urls : t -> url_entry list
201201+val services : t -> service list
202202+val icon : t -> string option
203203+val thumbnail : t -> string option
204204+val orcid : t -> string option
205205+val feeds : t -> Sortal_schema_feed.t list option
206206+207207+(** {1 Temporal Queries} *)
208208+209209+(** [email_at t ~date] returns the primary email valid at [date]. *)
210210+val email_at : t -> date:Sortal_schema_temporal.date -> string option
211211+212212+(** [emails_at t ~date] returns all emails valid at [date]. *)
213213+val emails_at : t -> date:Sortal_schema_temporal.date -> email list
214214+215215+(** [current_email t] returns the current primary email. *)
216216+val current_email : t -> string option
217217+218218+(** [organization_at t ~date] returns the organization at [date]. *)
219219+val organization_at : t -> date:Sortal_schema_temporal.date -> organization option
220220+221221+(** [current_organization t] returns the current organization. *)
222222+val current_organization : t -> organization option
223223+224224+(** [url_at t ~date] returns the primary URL valid at [date]. *)
225225+val url_at : t -> date:Sortal_schema_temporal.date -> string option
226226+227227+(** [current_url t] returns the current primary URL. *)
228228+val current_url : t -> string option
229229+230230+(** [all_email_addresses t] returns all email addresses (any period). *)
231231+val all_email_addresses : t -> string list
232232+233233+(** [best_url t] returns the best available URL (current URL or service fallback). *)
234234+val best_url : t -> string option
235235+236236+(** {1 Service Queries} *)
237237+238238+(** [services_of_kind t kind] returns all services matching the given kind. *)
239239+val services_of_kind : t -> service_kind -> service list
240240+241241+(** [services_at t ~date] returns all services valid at [date]. *)
242242+val services_at : t -> date:Sortal_schema_temporal.date -> service list
243243+244244+(** [current_services t] returns all currently valid services. *)
245245+val current_services : t -> service list
246246+247247+(** [primary_service t kind] returns the primary service of the given kind. *)
248248+val primary_service : t -> service_kind -> service option
249249+250250+(** {1 Modification} *)
251251+252252+val add_feed : t -> Sortal_schema_feed.t -> t
253253+val remove_feed : t -> string -> t
254254+255255+(** {1 Comparison and Display} *)
256256+257257+val compare : t -> t -> int
258258+val pp : Format.formatter -> t -> unit
259259+260260+(** {1 JSON Encoding} *)
261261+262262+(** [json_t] is the jsont encoder/decoder for V1 contacts.
263263+264264+ The schema includes a [version] field that is always encoded and
265265+ must equal [1] when decoded. *)
266266+val json_t : t Jsont.t
267267+268268+(** {1 Type Utilities} *)
269269+270270+val contact_kind_to_string : contact_kind -> string
271271+val contact_kind_of_string : string -> contact_kind option
272272+273273+val service_kind_to_string : service_kind -> string
274274+val service_kind_of_string : string -> service_kind option
275275+276276+val email_type_to_string : email_type -> string
277277+val email_type_of_string : string -> email_type option
+57
sortal/lib/schema/sortal_schema_feed.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+type feed_type =
77+ | Atom
88+ | Rss
99+ | Json
1010+1111+type t = {
1212+ feed_type : feed_type;
1313+ url : string;
1414+ name : string option;
1515+}
1616+1717+let make ~feed_type ~url ?name () =
1818+ { feed_type; url; name }
1919+2020+let feed_type t = t.feed_type
2121+let url t = t.url
2222+let name t = t.name
2323+2424+let set_name t name = { t with name = Some name }
2525+2626+let feed_type_to_string = function
2727+ | Atom -> "atom"
2828+ | Rss -> "rss"
2929+ | Json -> "json"
3030+3131+let feed_type_of_string s =
3232+ match String.lowercase_ascii s with
3333+ | "atom" -> Some Atom
3434+ | "rss" -> Some Rss
3535+ | "json" -> Some Json
3636+ | _ -> None
3737+3838+let json_t =
3939+ let open Jsont in
4040+ let open Jsont.Object in
4141+ let make feed_type url name =
4242+ match feed_type_of_string feed_type with
4343+ | Some ft -> { feed_type = ft; url; name }
4444+ | None -> failwith ("Invalid feed type: " ^ feed_type)
4545+ in
4646+ map ~kind:"Feed" make
4747+ |> mem "type" string ~enc:(fun f -> feed_type_to_string f.feed_type)
4848+ |> mem "url" string ~enc:(fun f -> f.url)
4949+ |> opt_mem "name" string ~enc:(fun f -> f.name)
5050+ |> finish
5151+5252+let pp ppf t =
5353+ let open Fmt in
5454+ pf ppf "%a: %a%a"
5555+ (styled (`Fg `Green) string) (feed_type_to_string t.feed_type)
5656+ (styled (`Fg `Blue) string) t.url
5757+ (option (fun ppf name -> pf ppf " (%a)" (styled `Faint string) name)) t.name
+48
sortal/lib/schema/sortal_schema_feed.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Feed subscription with type and URL.
77+88+ A feed represents a subscription to a content source (Atom, RSS, or JSONFeed). *)
99+1010+type t
1111+1212+(** Feed type identifier. *)
1313+type feed_type =
1414+ | Atom (** Atom feed format *)
1515+ | Rss (** RSS feed format *)
1616+ | Json (** JSON Feed format *)
1717+1818+(** [make ~feed_type ~url ?name ()] creates a new feed.
1919+2020+ @param feed_type The type of feed (Atom, RSS, or JSON)
2121+ @param url The feed URL
2222+ @param name Optional human-readable name/label for the feed *)
2323+val make : feed_type:feed_type -> url:string -> ?name:string -> unit -> t
2424+2525+(** [feed_type t] returns the feed type. *)
2626+val feed_type : t -> feed_type
2727+2828+(** [url t] returns the feed URL. *)
2929+val url : t -> string
3030+3131+(** [name t] returns the feed name if set. *)
3232+val name : t -> string option
3333+3434+(** [set_name t name] returns a new feed with the name updated. *)
3535+val set_name : t -> string -> t
3636+3737+(** [feed_type_to_string ft] converts a feed type to a string. *)
3838+val feed_type_to_string : feed_type -> string
3939+4040+(** [feed_type_of_string s] parses a feed type from a string.
4141+ Returns [None] if the string is not recognized. *)
4242+val feed_type_of_string : string -> feed_type option
4343+4444+(** [json_t] is the jsont encoder/decoder for feeds. *)
4545+val json_t : t Jsont.t
4646+4747+(** [pp ppf t] pretty prints a feed. *)
4848+val pp : Format.formatter -> t -> unit
+135
sortal/lib/schema/sortal_schema_temporal.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+type date = Ptime.date
77+88+type range = {
99+ from: date option;
1010+ until: date option;
1111+}
1212+1313+let make ?from ?until () = { from; until }
1414+1515+let always = { from = None; until = None }
1616+1717+(* Compare Ptime dates (year, month, day tuples) *)
1818+let date_compare ((y1, m1, d1) : date) ((y2, m2, d2) : date) : int =
1919+ match compare y1 y2 with
2020+ | 0 -> (
2121+ match compare m1 m2 with
2222+ | 0 -> compare d1 d2
2323+ | c -> c)
2424+ | c -> c
2525+2626+let date_gte d1 d2 = date_compare d1 d2 >= 0
2727+2828+let valid_at range_opt ~date =
2929+ match range_opt with
3030+ | None -> true (* No range = always valid *)
3131+ | Some { from; until } ->
3232+ let after_start = match from with
3333+ | None -> true
3434+ | Some f -> date_gte date f
3535+ in
3636+ let before_end = match until with
3737+ | None -> true
3838+ | Some u -> date_compare date u < 0 (* until is exclusive *)
3939+ in
4040+ after_start && before_end
4141+4242+let overlaps r1 r2 =
4343+ (* Two ranges overlap if neither ends before the other starts *)
4444+ let r1_starts_before_r2_ends = match r2.until with
4545+ | None -> true
4646+ | Some u2 -> match r1.from with
4747+ | None -> true
4848+ | Some f1 -> date_compare f1 u2 < 0
4949+ in
5050+ let r2_starts_before_r1_ends = match r1.until with
5151+ | None -> true
5252+ | Some u1 -> match r2.from with
5353+ | None -> true
5454+ | Some f2 -> date_compare f2 u1 < 0
5555+ in
5656+ r1_starts_before_r2_ends && r2_starts_before_r1_ends
5757+5858+let today () =
5959+ Ptime_clock.now () |> Ptime.to_date
6060+6161+let is_current range_opt =
6262+ valid_at range_opt ~date:(today ())
6363+6464+let current ~get list =
6565+ (* Find first currently valid item, or first item without temporal bounds *)
6666+ let current_items = List.filter (fun item -> is_current (get item)) list in
6767+ match current_items with
6868+ | x :: _ -> Some x
6969+ | [] ->
7070+ (* No current items, try to find one without temporal bounds *)
7171+ List.find_opt (fun item -> get item = None) list
7272+7373+let at_date ~get ~date list =
7474+ List.filter (fun item -> valid_at (get item) ~date) list
7575+7676+let filter ~get ~from ~until list =
7777+ let query_range = { from; until } in
7878+ List.filter (fun item ->
7979+ match get item with
8080+ | None -> true (* Items without range match all queries *)
8181+ | Some r -> overlaps r query_range
8282+ ) list
8383+8484+(* Parse ISO 8601 date string to Ptime.date, handling partial dates *)
8585+let parse_date_string (s : string) : date option =
8686+ match String.split_on_char '-' s with
8787+ | [year_s] -> (
8888+ try
8989+ let year = int_of_string year_s in
9090+ Some (year, 1, 1) (* Year only → January 1st *)
9191+ with Failure _ -> None)
9292+ | [year_s; month_s] -> (
9393+ try
9494+ let year = int_of_string year_s in
9595+ let month = int_of_string month_s in
9696+ if month >= 1 && month <= 12 then
9797+ Some (year, month, 1) (* Year-Month → 1st of month *)
9898+ else None
9999+ with Failure _ -> None)
100100+ | [year_s; month_s; day_s] -> (
101101+ try
102102+ let year = int_of_string year_s in
103103+ let month = int_of_string month_s in
104104+ let day = int_of_string day_s in
105105+ if month >= 1 && month <= 12 && day >= 1 && day <= 31 then
106106+ Some (year, month, day)
107107+ else None
108108+ with Failure _ -> None)
109109+ | _ -> None
110110+111111+(* Format Ptime.date as ISO 8601 string YYYY-MM-DD *)
112112+let format_date ((year, month, day) : date) : string =
113113+ Printf.sprintf "%04d-%02d-%02d" year month day
114114+115115+let json_t =
116116+ let open Jsont in
117117+ let open Jsont.Object in
118118+ let mem_opt f v ~enc = mem f v ~dec_absent:None ~enc_omit:Option.is_none ~enc in
119119+120120+ (* Create a jsont type for date that converts between string and Ptime.date *)
121121+ let date_jsont =
122122+ let dec meta s =
123123+ match parse_date_string s with
124124+ | Some d -> d
125125+ | None -> Error.msgf meta "TemporalDate: invalid ISO 8601 date: %S" s
126126+ in
127127+ let enc = format_date in
128128+ Base.string (Base.map ~kind:"TemporalDate" ~dec ~enc ())
129129+ in
130130+131131+ let make_range from until = { from; until } in
132132+ map ~kind:"TemporalRange" make_range
133133+ |> mem_opt "from" (some date_jsont) ~enc:(fun r -> r.from)
134134+ |> mem_opt "until" (some date_jsont) ~enc:(fun r -> r.until)
135135+ |> finish
+98
sortal/lib/schema/sortal_schema_temporal.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Temporal validity support for contact fields.
77+88+ This module provides types and functions for managing time-bounded
99+ information in contacts, such as emails valid only during certain
1010+ employment periods. *)
1111+1212+(** Date represented as a Ptime.date tuple (year, month, day).
1313+1414+ When parsing from strings, partial dates are normalized:
1515+ - Year: ["2001"] → (2001, 1, 1)
1616+ - Year-Month: ["2001-01"] → (2001, 1, 1)
1717+ - Full date: ["2001-01-15"] → (2001, 1, 15) *)
1818+type date = Ptime.date
1919+2020+(** {1 Date Conversion} *)
2121+2222+(** [parse_date_string s] parses an ISO 8601 date string.
2323+2424+ Accepts various formats with partial date support:
2525+ - "2001" (year only) → (2001, 1, 1)
2626+ - "2001-01" (year-month) → (2001, 1, 1)
2727+ - "2001-01-15" (full date) → (2001, 1, 15)
2828+2929+ Returns [None] if the string is not a valid date format. *)
3030+val parse_date_string : string -> date option
3131+3232+(** [format_date date] formats a date as ISO 8601 (YYYY-MM-DD).
3333+3434+ {b Example:} [format_date (2001, 1, 15)] returns ["2001-01-15"] *)
3535+val format_date : date -> string
3636+3737+(** {1 Temporal Ranges} *)
3838+3939+(** A temporal range indicating validity period. *)
4040+type range = {
4141+ from: date option; (** Start date (inclusive). [None] means from the beginning. *)
4242+ until: date option; (** End date (exclusive). [None] means continuing/indefinite. *)
4343+}
4444+4545+(** {1 Range Construction} *)
4646+4747+(** [make ?from ?until ()] creates a temporal range. *)
4848+val make : ?from:date -> ?until:date -> unit -> range
4949+5050+(** [always] is a range that is always valid (no from/until bounds). *)
5151+val always : range
5252+5353+(** {1 Range Queries} *)
5454+5555+(** [valid_at range ~date] checks if [range] is valid at the given [date].
5656+5757+ - [None] range means always valid
5858+ - [None] from means valid from beginning
5959+ - [None] until means valid continuing *)
6060+val valid_at : range option -> date:date -> bool
6161+6262+(** [overlaps r1 r2] checks if two ranges overlap in time. *)
6363+val overlaps : range -> range -> bool
6464+6565+(** [is_current range] checks if range is valid at the current date.
6666+ Uses today's date for the check. *)
6767+val is_current : range option -> bool
6868+6969+(** {1 List Filtering} *)
7070+7171+(** [current ~get list] returns the first current/valid item from [list].
7272+7373+ @param get Function to extract the temporal range from an item.
7474+ Returns the first item where the range is currently valid,
7575+ or the first item without temporal bounds if none are current. *)
7676+val current : get:('a -> range option) -> 'a list -> 'a option
7777+7878+(** [at_date ~get ~date list] filters [list] to items valid at [date].
7979+8080+ @param get Function to extract the temporal range from an item.
8181+ @param date The date to check validity against. *)
8282+val at_date : get:('a -> range option) -> date:date -> 'a list -> 'a list
8383+8484+(** [filter ~get ~from ~until list] filters [list] to items overlapping the period.
8585+8686+ Returns items whose temporal range overlaps with the given period. *)
8787+val filter : get:('a -> range option) -> from:date option -> until:date option ->
8888+ 'a list -> 'a list
8989+9090+(** {1 JSON Encoding} *)
9191+9292+(** [json_t] is the jsont encoder/decoder for temporal ranges.
9393+9494+ Encodes as a JSON object with optional [from] and [until] fields:
9595+ {[ { "from": "2001-01", "until": "2003-12" } ]}
9696+9797+ Empty object [\{\}] or missing field represents [always]. *)
9898+val json_t : range Jsont.t
+48
sortal/sortal.opam
···11+# This file is generated by dune, edit dune-project instead
22+opam-version: "2.0"
33+synopsis:
44+ "Contact metadata management with XDG storage and versioned schemas"
55+description: """
66+Sortal provides contact metadata management with versioned schemas,
77+ XDG-compliant storage, git versioning, and CLI tools.
88+99+ The library is split into two components:
1010+ - sortal.schema: Versioned data types with minimal dependencies
1111+ - sortal: Core library with storage, git integration, and CLI support"""
1212+maintainer: ["Anil Madhavapeddy <anil@recoil.org>"]
1313+authors: ["Anil Madhavapeddy"]
1414+license: "ISC"
1515+homepage: "https://tangled.org/@anil.recoil.org/sortal"
1616+bug-reports: "https://tangled.org/anil.recoil.org/sortal/issues"
1717+depends: [
1818+ "dune" {>= "3.21"}
1919+ "ocaml" {>= "5.1.0"}
2020+ "eio"
2121+ "eio_main"
2222+ "xdge"
2323+ "jsont"
2424+ "ptime"
2525+ "yamlt"
2626+ "bytesrw"
2727+ "fmt"
2828+ "cmdliner"
2929+ "logs"
3030+ "odoc" {with-doc}
3131+ "alcotest" {with-test & >= "1.7.0"}
3232+]
3333+build: [
3434+ ["dune" "subst"] {dev}
3535+ [
3636+ "dune"
3737+ "build"
3838+ "-p"
3939+ name
4040+ "-j"
4141+ jobs
4242+ "@install"
4343+ "@runtest" {with-test}
4444+ "@doc" {with-doc}
4545+ ]
4646+]
4747+dev-repo: "git+https://tangled.org/anil.recoil.org/sortal"
4848+x-maintenance-intent: ["(latest)"]