···2424- names: List of full names (primary name first)
2525- email: Email address
2626- icon: Avatar/icon URL
2727+- thumbnail: Path to a local thumbnail image file (stored in `thumbnails/` subdirectory)
2728- github: GitHub username
2829- twitter: Twitter/X username
2930- bluesky: Bluesky handle
···3132- orcid: ORCID identifier
3233- url: Personal/professional website
3334- atom_feeds: List of Atom/RSS feed URLs
3535+3636+## Thumbnails
3737+3838+Contact thumbnails are stored locally in the XDG data directory:
3939+- Location: `$HOME/.local/share/{app_name}/thumbnails/`
4040+- Files are named: `{handle}.{jpg|png|gif}`
4141+- The `thumbnail` field contains a relative path like `thumbnails/{handle}.jpg`
+1
stack/sortal/README.md
···1717- `names`: List of full names with primary name first (required)
1818- `email`: Email address
1919- `icon`: Avatar/icon URL
2020+- `thumbnail`: Path to a local thumbnail image file
2021- `github`: GitHub username
2122- `twitter`: Twitter/X username
2223- `bluesky`: Bluesky handle
+16-4
stack/sortal/lib/sortal.ml
···5858 names : string list;
5959 email : string option;
6060 icon : string option;
6161+ thumbnail : string option;
6162 github : string option;
6263 twitter : string option;
6364 bluesky : string option;
···6768 feeds : Feed.t list option;
6869 }
69707070- let make ~handle ~names ?email ?icon ?github ?twitter ?bluesky ?mastodon
7171+ let make ~handle ~names ?email ?icon ?thumbnail ?github ?twitter ?bluesky ?mastodon
7172 ?orcid ?url ?feeds () =
7272- { handle; names; email; icon; github; twitter; bluesky; mastodon;
7373+ { handle; names; email; icon; thumbnail; github; twitter; bluesky; mastodon;
7374 orcid; url; feeds }
74757576 let handle t = t.handle
···7879 let primary_name = name
7980 let email t = t.email
8081 let icon t = t.icon
8282+ let thumbnail t = t.thumbnail
8183 let github t = t.github
8284 let twitter t = t.twitter
8385 let bluesky t = t.bluesky
···115117 let open Jsont in
116118 let open Jsont.Object in
117119 let mem_opt f v ~enc = mem f v ~dec_absent:None ~enc_omit:Option.is_none ~enc in
118118- let make handle names email icon github twitter bluesky mastodon orcid url feeds =
119119- { handle; names; email; icon; github; twitter; bluesky; mastodon;
120120+ let make handle names email icon thumbnail github twitter bluesky mastodon orcid url feeds =
121121+ { handle; names; email; icon; thumbnail; github; twitter; bluesky; mastodon;
120122 orcid; url; feeds }
121123 in
122124 map ~kind:"Contact" make
···124126 |> mem "names" (list string) ~dec_absent:[] ~enc:names
125127 |> mem_opt "email" (some string) ~enc:email
126128 |> mem_opt "icon" (some string) ~enc:icon
129129+ |> mem_opt "thumbnail" (some string) ~enc:thumbnail
127130 |> mem_opt "github" (some string) ~enc:github
128131 |> mem_opt "twitter" (some string) ~enc:twitter
129132 |> mem_opt "bluesky" (some string) ~enc:bluesky
···170173 | None -> ());
171174 (match t.icon with
172175 | Some i -> pf ppf "%a: %a@," (styled `Bold string) "Icon" string i
176176+ | None -> ());
177177+ (match t.thumbnail with
178178+ | Some th -> pf ppf "%a: %a@," (styled `Bold string) "Thumbnail" string th
173179 | None -> ());
174180 (match t.feeds with
175181 | Some feeds when feeds <> [] ->
···229235 ) entries
230236 with
231237 | _ -> []
238238+239239+let thumbnail_path t contact =
240240+ match Contact.thumbnail contact with
241241+ | None -> None
242242+ | Some relative_path ->
243243+ Some Eio.Path.(t.data_dir / relative_path)
232244233245let handle_of_name name =
234246 let name = String.lowercase_ascii name in
+16-1
stack/sortal/lib/sortal.mli
···8686 social media handles, professional identifiers, and other contact information. *)
8787 type t
88888989- (** [make ~handle ~names ?email ?icon ?github ?twitter ?bluesky ?mastodon
8989+ (** [make ~handle ~names ?email ?icon ?thumbnail ?github ?twitter ?bluesky ?mastodon
9090 ?orcid ?url ?feeds ()] creates a new contact.
91919292 @param handle A unique identifier/username for this contact (required)
9393 @param names A list of names for this contact, with the first being primary (required)
9494 @param email Email address
9595 @param icon URL to an avatar/icon image
9696+ @param thumbnail Path to a local thumbnail image file
9697 @param github GitHub username (without the [\@] prefix)
9798 @param twitter Twitter/X username (without the [\@] prefix)
9899 @param bluesky Bluesky handle
···106107 names:string list ->
107108 ?email:string ->
108109 ?icon:string ->
110110+ ?thumbnail:string ->
109111 ?github:string ->
110112 ?twitter:string ->
111113 ?bluesky:string ->
···136138137139 (** [icon t] returns the icon/avatar URL if available. *)
138140 val icon : t -> string option
141141+142142+ (** [thumbnail t] returns the path to the local thumbnail image if available.
143143+ This is a relative path from the Sortal data directory. *)
144144+ val thumbnail : t -> string option
139145140146 (** [github t] returns the GitHub username if available. *)
141147 val github : t -> string option
···256262 @return A list of all successfully loaded contacts
257263 *)
258264val list : t -> Contact.t list
265265+266266+(** [thumbnail_path t contact] returns the absolute filesystem path to the contact's thumbnail.
267267+268268+ Returns [None] if the contact has no thumbnail set, or [Some path] with
269269+ the full path to the thumbnail file in Sortal's data directory.
270270+271271+ @param t The Sortal store
272272+ @param contact The contact whose thumbnail path to retrieve *)
273273+val thumbnail_path : t -> Contact.t -> Eio.Fs.dir_ty Eio.Path.t option
259274260275(** {2 Searching} *)
261276
-153
stack/sortal/scripts/import_yaml_contacts.py
···11-#!/usr/bin/env python3
22-"""
33-Import YAML contacts from arod-dream to Sortal JSON format.
44-55-This script reads contacts from ~/src/git/avsm/arod-dream/data/contacts/
66-and converts them to JSON files in the XDG data directory for sortal.
77-"""
88-99-import os
1010-import json
1111-import yaml
1212-from pathlib import Path
1313-1414-def get_xdg_data_home():
1515- """Get the XDG data home directory."""
1616- xdg_data_home = os.environ.get('XDG_DATA_HOME')
1717- if xdg_data_home:
1818- return Path(xdg_data_home)
1919- return Path.home() / '.local' / 'share'
2020-2121-def parse_contact_md(file_path):
2222- """Parse a markdown file with YAML front matter."""
2323- with open(file_path, 'r') as f:
2424- content = f.read()
2525-2626- # Extract YAML front matter between ---
2727- if content.startswith('---\n'):
2828- parts = content.split('---\n', 2)
2929- if len(parts) >= 2:
3030- yaml_content = parts[1]
3131- return yaml.safe_load(yaml_content)
3232- return None
3333-3434-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-4545-def convert_to_sortal_format(yaml_data, handle):
4646- """Convert YAML contact data to Sortal JSON format."""
4747- sortal_contact = {
4848- "handle": handle,
4949- "names": yaml_data.get("names", [])
5050- }
5151-5252- # Optional fields
5353- if "email" in yaml_data:
5454- sortal_contact["email"] = yaml_data["email"]
5555- if "icon" in yaml_data:
5656- sortal_contact["icon"] = yaml_data["icon"]
5757- if "github" in yaml_data:
5858- sortal_contact["github"] = yaml_data["github"]
5959- if "twitter" in yaml_data:
6060- sortal_contact["twitter"] = yaml_data["twitter"]
6161- if "bluesky" in yaml_data:
6262- sortal_contact["bluesky"] = yaml_data["bluesky"]
6363- if "mastodon" in yaml_data:
6464- sortal_contact["mastodon"] = yaml_data["mastodon"]
6565- if "orcid" in yaml_data:
6666- sortal_contact["orcid"] = yaml_data["orcid"]
6767- if "url" in yaml_data:
6868- sortal_contact["url"] = yaml_data["url"]
6969-7070- # Convert atom feeds to new feed structure
7171- if "atom" in yaml_data:
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
8383-8484- return sortal_contact
8585-8686-def main():
8787- # Source directory
8888- source_dir = Path.home() / 'src' / 'git' / 'avsm' / 'arod-dream' / 'data' / 'contacts'
8989-9090- # Destination directory (XDG data home for sortal)
9191- xdg_data = get_xdg_data_home()
9292- dest_dir = xdg_data / 'sortal'
9393- dest_dir.mkdir(parents=True, exist_ok=True)
9494-9595- print(f"Importing contacts from: {source_dir}")
9696- print(f"Output directory: {dest_dir}")
9797- print()
9898-9999- if not source_dir.exists():
100100- print(f"Error: Source directory does not exist: {source_dir}")
101101- return 1
102102-103103- # 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-108108- imported_count = 0
109109- error_count = 0
110110- total_feeds = 0
111111-112112- # Process each .md file
113113- for md_file in sorted(source_dir.glob('*.md')):
114114- handle = md_file.stem
115115-116116- try:
117117- yaml_data = parse_contact_md(md_file)
118118- if yaml_data is None:
119119- print(f"⚠ Skipping {handle}: No YAML front matter found")
120120- error_count += 1
121121- continue
122122-123123- # Convert to Sortal format
124124- sortal_contact = convert_to_sortal_format(yaml_data, handle)
125125-126126- # Write JSON file
127127- output_file = dest_dir / f"{handle}.json"
128128- with open(output_file, 'w') as f:
129129- json.dump(sortal_contact, f, indent=2, ensure_ascii=False)
130130-131131- name = sortal_contact.get('names', [handle])[0] if sortal_contact.get('names') else handle
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}")
137137- imported_count += 1
138138-139139- except Exception as e:
140140- print(f"✗ Error importing {handle}: {e}")
141141- error_count += 1
142142-143143- print()
144144- print(f"Import complete!")
145145- print(f" Successfully imported: {imported_count}")
146146- print(f" Total feeds: {total_feeds}")
147147- print(f" Errors: {error_count}")
148148- print(f" Output directory: {dest_dir}")
149149-150150- return 0 if error_count == 0 else 1
151151-152152-if __name__ == '__main__':
153153- exit(main())