···11+module Feed = struct
22+ type feed_type =
33+ | Atom
44+ | Rss
55+ | Json
66+77+ type t = {
88+ feed_type : feed_type;
99+ url : string;
1010+ name : string option;
1111+ }
1212+1313+ let make ~feed_type ~url ?name () =
1414+ { feed_type; url; name }
1515+1616+ let feed_type t = t.feed_type
1717+ let url t = t.url
1818+ let name t = t.name
1919+2020+ let set_name t name = { t with name = Some name }
2121+2222+ let feed_type_to_string = function
2323+ | Atom -> "atom"
2424+ | Rss -> "rss"
2525+ | Json -> "json"
2626+2727+ let feed_type_of_string = function
2828+ | "atom" -> Some Atom
2929+ | "rss" -> Some Rss
3030+ | "json" -> Some Json
3131+ | _ -> None
3232+3333+ let json_t =
3434+ let open Jsont in
3535+ let open Jsont.Object in
3636+ let make feed_type url name =
3737+ match feed_type_of_string feed_type with
3838+ | Some ft -> { feed_type = ft; url; name }
3939+ | None -> failwith ("Invalid feed type: " ^ feed_type)
4040+ in
4141+ map ~kind:"Feed" make
4242+ |> mem "type" string ~enc:(fun f -> feed_type_to_string f.feed_type)
4343+ |> mem "url" string ~enc:(fun f -> f.url)
4444+ |> opt_mem "name" string ~enc:(fun f -> f.name)
4545+ |> finish
4646+4747+ let pp ppf t =
4848+ let open Fmt in
4949+ pf ppf "%s: %s%a"
5050+ (feed_type_to_string t.feed_type)
5151+ t.url
5252+ (option (fun ppf name -> pf ppf " (%s)" name)) t.name
5353+end
5454+155module Contact = struct
256 type t = {
357 handle : string;
···1064 mastodon : string option;
1165 orcid : string option;
1266 url : string option;
1313- atom_feeds : string list option;
6767+ feeds : Feed.t list option;
1468 }
15691670 let make ~handle ~names ?email ?icon ?github ?twitter ?bluesky ?mastodon
1717- ?orcid ?url ?atom_feeds () =
7171+ ?orcid ?url ?feeds () =
1872 { handle; names; email; icon; github; twitter; bluesky; mastodon;
1919- orcid; url; atom_feeds }
7373+ orcid; url; feeds }
20742175 let handle t = t.handle
2276 let names t = t.names
2377 let name t = List.hd t.names
7878+ let primary_name = name
2479 let email t = t.email
2580 let icon t = t.icon
2681 let github t = t.github
···2984 let mastodon t = t.mastodon
3085 let orcid t = t.orcid
3186 let url t = t.url
3232- let atom_feeds t = t.atom_feeds
8787+ let feeds t = t.feeds
8888+8989+ let add_feed t feed =
9090+ let feeds = match t.feeds with
9191+ | Some fs -> Some (feed :: fs)
9292+ | None -> Some [feed]
9393+ in
9494+ { t with feeds }
9595+9696+ let remove_feed t url =
9797+ let feeds = match t.feeds with
9898+ | Some fs -> Some (List.filter (fun f -> Feed.url f <> url) fs)
9999+ | None -> None
100100+ in
101101+ { t with feeds }
3310234103 let best_url t =
35104 match t.url with
···46115 let open Jsont in
47116 let open Jsont.Object in
48117 let mem_opt f v ~enc = mem f v ~dec_absent:None ~enc_omit:Option.is_none ~enc in
4949- let make handle names email icon github twitter bluesky mastodon orcid url atom_feeds =
118118+ let make handle names email icon github twitter bluesky mastodon orcid url feeds =
50119 { handle; names; email; icon; github; twitter; bluesky; mastodon;
5151- orcid; url; atom_feeds }
120120+ orcid; url; feeds }
52121 in
53122 map ~kind:"Contact" make
54123 |> mem "handle" string ~enc:handle
···61130 |> mem_opt "mastodon" (some string) ~enc:mastodon
62131 |> mem_opt "orcid" (some string) ~enc:orcid
63132 |> mem_opt "url" (some string) ~enc:url
6464- |> mem_opt "atom_feeds" (some (list string)) ~enc:atom_feeds
133133+ |> mem_opt "feeds" (some (list Feed.json_t)) ~enc:feeds
65134 |> finish
6613567136 let compare a b = String.compare a.handle b.handle
···102171 (match t.icon with
103172 | Some i -> pf ppf "%a: %a@," (styled `Bold string) "Icon" string i
104173 | None -> ());
105105- (match t.atom_feeds with
174174+ (match t.feeds with
106175 | Some feeds when feeds <> [] ->
107107- pf ppf "%a: @[<h>%a@]@," (styled `Bold string) "Atom Feeds"
108108- (list ~sep:comma string) feeds
176176+ pf ppf "%a:@," (styled `Bold string) "Feeds";
177177+ List.iter (fun feed ->
178178+ pf ppf " - %a@," Feed.pp feed
179179+ ) feeds
109180 | _ -> ());
110181 pf ppf "@]"
111182end
···280351 let with_github = List.filter (fun c -> Contact.github c <> None) contacts |> List.length in
281352 let with_orcid = List.filter (fun c -> Contact.orcid c <> None) contacts |> List.length in
282353 let with_url = List.filter (fun c -> Contact.url c <> None) contacts |> List.length in
283283- let with_feeds = List.filter (fun c -> Contact.atom_feeds c <> None) contacts |> List.length in
354354+ let with_feeds = List.filter (fun c -> Contact.feeds c <> None) contacts |> List.length in
355355+ let total_feeds = List.fold_left (fun acc c ->
356356+ match Contact.feeds c with
357357+ | Some feeds -> acc + List.length feeds
358358+ | None -> acc
359359+ ) 0 contacts in
284360285361 Logs.app (fun m -> m "Contact Database Statistics:");
286362 Logs.app (fun m -> m " Total contacts: %d" total);
···288364 Logs.app (fun m -> m " With GitHub: %d (%.1f%%)" with_github (float_of_int with_github /. float_of_int total *. 100.));
289365 Logs.app (fun m -> m " With ORCID: %d (%.1f%%)" with_orcid (float_of_int with_orcid /. float_of_int total *. 100.));
290366 Logs.app (fun m -> m " With URL: %d (%.1f%%)" with_url (float_of_int with_url /. float_of_int total *. 100.));
291291- Logs.app (fun m -> m " With Atom feeds: %d (%.1f%%)" with_feeds (float_of_int with_feeds /. float_of_int total *. 100.));
367367+ Logs.app (fun m -> m " With feeds: %d (%.1f%%), total %d feeds" with_feeds (float_of_int with_feeds /. float_of_int total *. 100.) total_feeds);
292368 0
293369294370 (* Command info objects *)
+63-5
stack/sortal/lib/sortal.mli
···2929 ]}
3030*)
31313232+(** {1 Feed Metadata} *)
3333+3434+module Feed : sig
3535+ (** Feed subscription with type and URL.
3636+3737+ A feed represents a subscription to a content source (Atom, RSS, or JSONFeed). *)
3838+ type t
3939+4040+ (** Feed type identifier. *)
4141+ type feed_type =
4242+ | Atom (** Atom feed format *)
4343+ | Rss (** RSS feed format *)
4444+ | Json (** JSON Feed format *)
4545+4646+ (** [make ~feed_type ~url ?name ()] creates a new feed.
4747+4848+ @param feed_type The type of feed (Atom, RSS, or JSON)
4949+ @param url The feed URL
5050+ @param name Optional human-readable name/label for the feed
5151+ *)
5252+ val make : feed_type:feed_type -> url:string -> ?name:string -> unit -> t
5353+5454+ (** [feed_type t] returns the feed type. *)
5555+ val feed_type : t -> feed_type
5656+5757+ (** [url t] returns the feed URL. *)
5858+ val url : t -> string
5959+6060+ (** [name t] returns the feed name if set. *)
6161+ val name : t -> string option
6262+6363+ (** [set_name t name] returns a new feed with the name updated. *)
6464+ val set_name : t -> string -> t
6565+6666+ (** [feed_type_to_string ft] converts a feed type to a string. *)
6767+ val feed_type_to_string : feed_type -> string
6868+6969+ (** [feed_type_of_string s] parses a feed type from a string.
7070+ Returns [None] if the string is not recognized. *)
7171+ val feed_type_of_string : string -> feed_type option
7272+7373+ (** [json_t] is the jsont encoder/decoder for feeds. *)
7474+ val json_t : t Jsont.t
7575+7676+ (** [pp ppf t] pretty prints a feed. *)
7777+ val pp : Format.formatter -> t -> unit
7878+end
7979+3280(** {1 Contact Metadata} *)
33813482module Contact : sig
···3987 type t
40884189 (** [make ~handle ~names ?email ?icon ?github ?twitter ?bluesky ?mastodon
4242- ?orcid ?url ?atom_feeds ()] creates a new contact.
9090+ ?orcid ?url ?feeds ()] creates a new contact.
43914492 @param handle A unique identifier/username for this contact (required)
4593 @param names A list of names for this contact, with the first being primary (required)
···5199 @param mastodon Mastodon handle (including instance)
52100 @param orcid ORCID identifier
53101 @param url Personal or professional website URL
5454- @param atom_feeds List of Atom/RSS feed URLs associated with this contact
102102+ @param feeds List of feed subscriptions (Atom/RSS/JSON) associated with this contact
55103 *)
56104 val make :
57105 handle:string ->
···64112 ?mastodon:string ->
65113 ?orcid:string ->
66114 ?url:string ->
6767- ?atom_feeds:string list ->
115115+ ?feeds:Feed.t list ->
68116 unit ->
69117 t
70118···79127 (** [name t] returns the primary (first) name. *)
80128 val name : t -> string
81129130130+ (** [primary_name t] returns the primary (first) name.
131131+ This is an alias for {!name} for clarity. *)
132132+ val primary_name : t -> string
133133+82134 (** [email t] returns the email address if available. *)
83135 val email : t -> string option
84136···103155 (** [url t] returns the personal/professional website URL if available. *)
104156 val url : t -> string option
105157106106- (** [atom_feeds t] returns the list of Atom/RSS feed URLs if available. *)
107107- val atom_feeds : t -> string list option
158158+ (** [feeds t] returns the list of feed subscriptions if available. *)
159159+ val feeds : t -> Feed.t list option
160160+161161+ (** [add_feed t feed] returns a new contact with the feed added. *)
162162+ val add_feed : t -> Feed.t -> t
163163+164164+ (** [remove_feed t url] returns a new contact with the feed matching the URL removed. *)
165165+ val remove_feed : t -> string -> t
108166109167 (** {2 Derived Information} *)
110168
+36-2
stack/sortal/scripts/import_yaml_contacts.py
···3131 return yaml.safe_load(yaml_content)
3232 return None
33333434+def detect_feed_type(url):
3535+ """Detect feed type based on URL patterns."""
3636+ url_lower = url.lower()
3737+ if 'json' in url_lower or url_lower.endswith('.json'):
3838+ return 'json'
3939+ elif 'rss' in url_lower or url_lower.endswith('.rss') or url_lower.endswith('.xml'):
4040+ return 'rss'
4141+ else:
4242+ # Default to atom for most feeds
4343+ return 'atom'
4444+3445def convert_to_sortal_format(yaml_data, handle):
3546 """Convert YAML contact data to Sortal JSON format."""
3647 sortal_contact = {
···5566 sortal_contact["orcid"] = yaml_data["orcid"]
5667 if "url" in yaml_data:
5768 sortal_contact["url"] = yaml_data["url"]
6969+7070+ # Convert atom feeds to new feed structure
5871 if "atom" in yaml_data:
5959- sortal_contact["atom_feeds"] = yaml_data["atom"]
7272+ atom_feeds = yaml_data["atom"]
7373+ if atom_feeds:
7474+ feeds = []
7575+ for feed_url in atom_feeds:
7676+ feed_type = detect_feed_type(feed_url)
7777+ feed_obj = {
7878+ "type": feed_type,
7979+ "url": feed_url
8080+ }
8181+ feeds.append(feed_obj)
8282+ sortal_contact["feeds"] = feeds
60836184 return sortal_contact
6285···77100 print(f"Error: Source directory does not exist: {source_dir}")
78101 return 1
79102103103+ # Delete existing contacts to avoid old schema
104104+ print("Clearing existing contacts...")
105105+ for existing_file in dest_dir.glob('*.json'):
106106+ existing_file.unlink()
107107+80108 imported_count = 0
81109 error_count = 0
110110+ total_feeds = 0
8211183112 # Process each .md file
84113 for md_file in sorted(source_dir.glob('*.md')):
···100129 json.dump(sortal_contact, f, indent=2, ensure_ascii=False)
101130102131 name = sortal_contact.get('names', [handle])[0] if sortal_contact.get('names') else handle
103103- print(f"✓ Imported: {handle} ({name})")
132132+ feed_count = len(sortal_contact.get('feeds', []))
133133+ total_feeds += feed_count
134134+135135+ feed_info = f" ({feed_count} feed{'s' if feed_count != 1 else ''})" if feed_count > 0 else ""
136136+ print(f"✓ Imported: {handle} ({name}){feed_info}")
104137 imported_count += 1
105138106139 except Exception as e:
···110143 print()
111144 print(f"Import complete!")
112145 print(f" Successfully imported: {imported_count}")
146146+ print(f" Total feeds: {total_feeds}")
113147 print(f" Errors: {error_count}")
114148 print(f" Output directory: {dest_dir}")
115149