A CLI and OCaml library for managing contacts

Replace bushel contacts with sortal

- Remove bushel_contact.ml, use Sortal_schema.Contact directly
- Add typed service_kind variants: ActivityPub (with Mastodon/Pixelfed/PeerTube),
Bluesky, Github, Git, Twitter, Photo, Custom
- Add convenience accessors: github_handle, twitter_handle, mastodon_handle,
bluesky_handle
- Add find_by_handle and lookup_by_name to Sortal_store
- Update bushel_loader to load contacts from Sortal XDG store
- Fix sortal sync to skip PNG conversion when PNG already exists

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+170 -17
+16 -9
lib/core/sortal_cmd.ml
··· 124 124 Logs.info (fun m -> m "@%s: already PNG (%s)" handle (Filename.basename path)); 125 125 incr skipped 126 126 end else begin 127 - Logs.app (fun m -> m "@%s: converting %s to PNG..." handle (Filename.basename path)); 128 - match convert_to_png path with 129 - | Ok new_path -> 130 - Logs.app (fun m -> m " Converted: %s -> %s" 131 - (Filename.basename path) (Filename.basename new_path)); 132 - incr converted 133 - | Error msg -> 134 - Logs.err (fun m -> m " Failed to convert %s: %s" path msg); 135 - incr errors 127 + (* Check if PNG version already exists *) 128 + let png_path = Filename.remove_extension path ^ ".png" in 129 + if Sys.file_exists png_path then begin 130 + Logs.info (fun m -> m "@%s: PNG already exists (%s)" handle (Filename.basename png_path)); 131 + incr skipped 132 + end else begin 133 + Logs.app (fun m -> m "@%s: converting %s to PNG..." handle (Filename.basename path)); 134 + match convert_to_png path with 135 + | Ok new_path -> 136 + Logs.app (fun m -> m " Converted: %s -> %s" 137 + (Filename.basename path) (Filename.basename new_path)); 138 + incr converted 139 + | Error msg -> 140 + Logs.err (fun m -> m " Failed to convert %s: %s" path msg); 141 + incr errors 142 + end 136 143 end 137 144 ) contacts; 138 145 Logs.app (fun m -> m "Sync complete:");
+15
lib/core/sortal_store.ml
··· 329 329 ) all in 330 330 List.sort Contact.compare matches 331 331 332 + let find_by_handle t handle = 333 + lookup t handle 334 + 335 + let lookup_by_name t name = 336 + let name_lower = String.lowercase_ascii name in 337 + let all_contacts = list t in 338 + let matches = List.filter (fun c -> 339 + List.exists (fun n -> String.lowercase_ascii n = name_lower) 340 + (Contact.names c) 341 + ) all_contacts in 342 + match matches with 343 + | [contact] -> contact 344 + | [] -> failwith ("Contact not found: " ^ name) 345 + | _ -> failwith ("Ambiguous contact: " ^ name) 346 + 332 347 let find_by_email_at t ~email ~date = 333 348 let all = list t in 334 349 List.find_opt (fun c ->
+18
lib/core/sortal_store.mli
··· 173 173 174 174 (** {1 Searching} *) 175 175 176 + (** [find_by_handle t handle] finds a contact by exact handle match. 177 + 178 + This is an alias for {!lookup} for API compatibility. 179 + 180 + @return [Some contact] if found, [None] if not found *) 181 + val find_by_handle : t -> string -> Contact.t option 182 + 176 183 (** [find_by_name t name] searches for contacts by name. 177 184 178 185 Performs a case-insensitive search through all contacts, ··· 183 190 @raise Not_found if no contacts match the name 184 191 @raise Invalid_argument if multiple contacts match the name *) 185 192 val find_by_name : t -> string -> Contact.t 193 + 194 + (** [lookup_by_name t name] searches for contacts by name, raising on failure. 195 + 196 + Like {!find_by_name} but raises [Failure] instead of [Not_found] 197 + or [Invalid_argument]. This matches the semantics of Bushel's 198 + original contact lookup. 199 + 200 + @param name The name to search for (case-insensitive) 201 + @return The matching contact if exactly one match is found 202 + @raise Failure if no contacts match or multiple contacts match *) 203 + val lookup_by_name : t -> string -> Contact.t 186 204 187 205 (** [find_by_name_opt t name] searches for contacts by name, returning an option. 188 206
+80 -6
lib/schema/sortal_schema_contact_v1.ml
··· 7 7 8 8 type contact_kind = Person | Organization | Group | Role 9 9 10 + type activitypub_variant = 11 + | Mastodon 12 + | Pixelfed 13 + | PeerTube 14 + | Other_activitypub of string 15 + 10 16 type service_kind = 11 - | ActivityPub 17 + | ActivityPub of activitypub_variant 18 + | Bluesky 12 19 | Github 13 20 | Git 14 - | Social 21 + | Twitter 15 22 | Photo 16 23 | Custom of string 17 24 ··· 122 129 let orcid t = t.orcid 123 130 let feeds t = t.feeds 124 131 132 + (* Service convenience accessors *) 133 + let github t = 134 + List.find_opt (fun (s : service) -> 135 + match s.kind with Some Github -> true | _ -> false 136 + ) t.services 137 + 138 + let github_handle t = 139 + match github t with 140 + | Some s -> s.handle 141 + | None -> None 142 + 143 + let twitter t = 144 + List.find_opt (fun (s : service) -> 145 + match s.kind with Some Twitter -> true | _ -> false 146 + ) t.services 147 + 148 + let twitter_handle t = 149 + match twitter t with 150 + | Some s -> s.handle 151 + | None -> None 152 + 153 + let mastodon t = 154 + List.find_opt (fun (s : service) -> 155 + match s.kind with Some (ActivityPub Mastodon) -> true | _ -> false 156 + ) t.services 157 + 158 + let mastodon_handle t = 159 + match mastodon t with 160 + | Some s -> s.handle 161 + | None -> None 162 + 163 + let bluesky t = 164 + List.find_opt (fun (s : service) -> 165 + match s.kind with Some Bluesky -> true | _ -> false 166 + ) t.services 167 + 168 + let bluesky_handle t = 169 + match bluesky t with 170 + | Some s -> s.handle 171 + | None -> None 172 + 125 173 (* Temporal queries *) 126 174 let emails_at t ~date = 127 175 Sortal_schema_temporal.at_date ~get:(fun (e : email) -> e.range) ~date t.emails ··· 210 258 | "role" -> Some Role 211 259 | _ -> None 212 260 261 + let activitypub_variant_to_string = function 262 + | Mastodon -> "mastodon" 263 + | Pixelfed -> "pixelfed" 264 + | PeerTube -> "peertube" 265 + | Other_activitypub s -> s 266 + 267 + let activitypub_variant_of_string s = 268 + match String.lowercase_ascii s with 269 + | "mastodon" -> Mastodon 270 + | "pixelfed" -> Pixelfed 271 + | "peertube" -> PeerTube 272 + | _ -> Other_activitypub s 273 + 213 274 let service_kind_to_string = function 214 - | ActivityPub -> "activitypub" 275 + | ActivityPub v -> "activitypub:" ^ activitypub_variant_to_string v 276 + | Bluesky -> "bluesky" 215 277 | Github -> "github" 216 278 | Git -> "git" 217 - | Social -> "social" 279 + | Twitter -> "twitter" 218 280 | Photo -> "photo" 219 281 | Custom s -> s 220 282 221 283 let service_kind_of_string s = 222 284 match String.lowercase_ascii s with 223 - | "activitypub" -> Some ActivityPub 285 + | "bluesky" -> Some Bluesky 224 286 | "github" -> Some Github 225 287 | "git" -> Some Git 226 - | "social" -> Some Social 288 + | "twitter" -> Some Twitter 227 289 | "photo" -> Some Photo 228 290 | "" | "custom" -> None 291 + | s when String.length s > 11 && String.sub s 0 11 = "activitypub" -> 292 + (* Handle activitypub:variant format *) 293 + let rest = String.sub s 11 (String.length s - 11) in 294 + let variant = if rest = "" then Mastodon 295 + else if String.length rest > 1 && rest.[0] = ':' then 296 + activitypub_variant_of_string (String.sub rest 1 (String.length rest - 1)) 297 + else Mastodon 298 + in 299 + Some (ActivityPub variant) 300 + | "mastodon" -> Some (ActivityPub Mastodon) 301 + | "pixelfed" -> Some (ActivityPub Pixelfed) 302 + | "peertube" -> Some (ActivityPub PeerTube) 229 303 | _ -> Some (Custom s) 230 304 231 305 let email_type_to_string = function
+41 -2
lib/schema/sortal_schema_contact_v1.mli
··· 29 29 | Group (** Research group, project team *) 30 30 | Role (** Generic role email like info@, admin@ *) 31 31 32 + (** ActivityPub service variants. *) 33 + type activitypub_variant = 34 + | Mastodon (** Mastodon instance *) 35 + | Pixelfed (** Pixelfed instance *) 36 + | PeerTube (** PeerTube instance *) 37 + | Other_activitypub of string (** Other ActivityPub-compatible service *) 38 + 32 39 (** Service kind - categorization of online presence. *) 33 40 type service_kind = 34 - | ActivityPub (** Mastodon, Pixelfed, PeerTube, etc *) 41 + | ActivityPub of activitypub_variant (** ActivityPub-compatible services *) 42 + | Bluesky (** Bluesky / AT Protocol *) 35 43 | Github (** GitHub *) 36 44 | Git (** GitLab, Gitea, Codeberg, etc *) 37 - | Social (** Twitter/X, LinkedIn, etc *) 45 + | Twitter (** Twitter/X *) 38 46 | Photo (** Immich, Flickr, Instagram, etc *) 39 47 | Custom of string (** Other service types *) 40 48 ··· 204 212 val orcid : t -> string option 205 213 val feeds : t -> Sortal_schema_feed.t list option 206 214 215 + (** {1 Service Convenience Accessors} 216 + 217 + These accessors provide easy access to common service types. *) 218 + 219 + (** [github t] returns the GitHub service entry if present. *) 220 + val github : t -> service option 221 + 222 + (** [github_handle t] returns the GitHub username if present. *) 223 + val github_handle : t -> string option 224 + 225 + (** [twitter t] returns the Twitter/X service entry if present. *) 226 + val twitter : t -> service option 227 + 228 + (** [twitter_handle t] returns the Twitter/X username if present. *) 229 + val twitter_handle : t -> string option 230 + 231 + (** [mastodon t] returns the Mastodon service entry if present. *) 232 + val mastodon : t -> service option 233 + 234 + (** [mastodon_handle t] returns the Mastodon handle if present. *) 235 + val mastodon_handle : t -> string option 236 + 237 + (** [bluesky t] returns the Bluesky service entry if present. *) 238 + val bluesky : t -> service option 239 + 240 + (** [bluesky_handle t] returns the Bluesky handle if present. *) 241 + val bluesky_handle : t -> string option 242 + 207 243 (** {1 Temporal Queries} *) 208 244 209 245 (** [email_at t ~date] returns the primary email valid at [date]. *) ··· 269 305 270 306 val contact_kind_to_string : contact_kind -> string 271 307 val contact_kind_of_string : string -> contact_kind option 308 + 309 + val activitypub_variant_to_string : activitypub_variant -> string 310 + val activitypub_variant_of_string : string -> activitypub_variant 272 311 273 312 val service_kind_to_string : service_kind -> string 274 313 val service_kind_of_string : string -> service_kind option