My agentic slop goes here. Not intended for anyone else!

sortal

Changed files
+41 -158
stack
+8
stack/sortal/CLAUDE.md
···
- names: List of full names (primary name first)
- email: Email address
- icon: Avatar/icon URL
+
- thumbnail: Path to a local thumbnail image file (stored in `thumbnails/` subdirectory)
- github: GitHub username
- twitter: Twitter/X username
- bluesky: Bluesky handle
···
- orcid: ORCID identifier
- url: Personal/professional website
- atom_feeds: List of Atom/RSS feed URLs
+
+
## Thumbnails
+
+
Contact thumbnails are stored locally in the XDG data directory:
+
- Location: `$HOME/.local/share/{app_name}/thumbnails/`
+
- Files are named: `{handle}.{jpg|png|gif}`
+
- The `thumbnail` field contains a relative path like `thumbnails/{handle}.jpg`
+1
stack/sortal/README.md
···
- `names`: List of full names with primary name first (required)
- `email`: Email address
- `icon`: Avatar/icon URL
+
- `thumbnail`: Path to a local thumbnail image file
- `github`: GitHub username
- `twitter`: Twitter/X username
- `bluesky`: Bluesky handle
+16 -4
stack/sortal/lib/sortal.ml
···
names : string list;
email : string option;
icon : string option;
+
thumbnail : string option;
github : string option;
twitter : string option;
bluesky : string option;
···
feeds : Feed.t list option;
}
-
let make ~handle ~names ?email ?icon ?github ?twitter ?bluesky ?mastodon
+
let make ~handle ~names ?email ?icon ?thumbnail ?github ?twitter ?bluesky ?mastodon
?orcid ?url ?feeds () =
-
{ handle; names; email; icon; github; twitter; bluesky; mastodon;
+
{ handle; names; email; icon; thumbnail; github; twitter; bluesky; mastodon;
orcid; url; feeds }
let handle t = t.handle
···
let primary_name = name
let email t = t.email
let icon t = t.icon
+
let thumbnail t = t.thumbnail
let github t = t.github
let twitter t = t.twitter
let bluesky t = t.bluesky
···
let open Jsont in
let open Jsont.Object in
let mem_opt f v ~enc = mem f v ~dec_absent:None ~enc_omit:Option.is_none ~enc in
-
let make handle names email icon github twitter bluesky mastodon orcid url feeds =
-
{ handle; names; email; icon; github; twitter; bluesky; mastodon;
+
let make handle names email icon thumbnail github twitter bluesky mastodon orcid url feeds =
+
{ handle; names; email; icon; thumbnail; github; twitter; bluesky; mastodon;
orcid; url; feeds }
in
map ~kind:"Contact" make
···
|> mem "names" (list string) ~dec_absent:[] ~enc:names
|> mem_opt "email" (some string) ~enc:email
|> mem_opt "icon" (some string) ~enc:icon
+
|> mem_opt "thumbnail" (some string) ~enc:thumbnail
|> mem_opt "github" (some string) ~enc:github
|> mem_opt "twitter" (some string) ~enc:twitter
|> mem_opt "bluesky" (some string) ~enc:bluesky
···
| None -> ());
(match t.icon with
| Some i -> pf ppf "%a: %a@," (styled `Bold string) "Icon" string i
+
| None -> ());
+
(match t.thumbnail with
+
| Some th -> pf ppf "%a: %a@," (styled `Bold string) "Thumbnail" string th
| None -> ());
(match t.feeds with
| Some feeds when feeds <> [] ->
···
) entries
with
| _ -> []
+
+
let thumbnail_path t contact =
+
match Contact.thumbnail contact with
+
| None -> None
+
| Some relative_path ->
+
Some Eio.Path.(t.data_dir / relative_path)
let handle_of_name name =
let name = String.lowercase_ascii name in
+16 -1
stack/sortal/lib/sortal.mli
···
social media handles, professional identifiers, and other contact information. *)
type t
-
(** [make ~handle ~names ?email ?icon ?github ?twitter ?bluesky ?mastodon
+
(** [make ~handle ~names ?email ?icon ?thumbnail ?github ?twitter ?bluesky ?mastodon
?orcid ?url ?feeds ()] creates a new contact.
@param handle A unique identifier/username for this contact (required)
@param names A list of names for this contact, with the first being primary (required)
@param email Email address
@param icon URL to an avatar/icon image
+
@param thumbnail Path to a local thumbnail image file
@param github GitHub username (without the [\@] prefix)
@param twitter Twitter/X username (without the [\@] prefix)
@param bluesky Bluesky handle
···
names:string list ->
?email:string ->
?icon:string ->
+
?thumbnail:string ->
?github:string ->
?twitter:string ->
?bluesky:string ->
···
(** [icon t] returns the icon/avatar URL if available. *)
val icon : t -> string option
+
+
(** [thumbnail t] returns the path to the local thumbnail image if available.
+
This is a relative path from the Sortal data directory. *)
+
val thumbnail : t -> string option
(** [github t] returns the GitHub username if available. *)
val github : t -> string option
···
@return A list of all successfully loaded contacts
*)
val list : t -> Contact.t list
+
+
(** [thumbnail_path t contact] returns the absolute filesystem path to the contact's thumbnail.
+
+
Returns [None] if the contact has no thumbnail set, or [Some path] with
+
the full path to the thumbnail file in Sortal's data directory.
+
+
@param t The Sortal store
+
@param contact The contact whose thumbnail path to retrieve *)
+
val thumbnail_path : t -> Contact.t -> Eio.Fs.dir_ty Eio.Path.t option
(** {2 Searching} *)
-153
stack/sortal/scripts/import_yaml_contacts.py
···
-
#!/usr/bin/env python3
-
"""
-
Import YAML contacts from arod-dream to Sortal JSON format.
-
-
This script reads contacts from ~/src/git/avsm/arod-dream/data/contacts/
-
and converts them to JSON files in the XDG data directory for sortal.
-
"""
-
-
import os
-
import json
-
import yaml
-
from pathlib import Path
-
-
def get_xdg_data_home():
-
"""Get the XDG data home directory."""
-
xdg_data_home = os.environ.get('XDG_DATA_HOME')
-
if xdg_data_home:
-
return Path(xdg_data_home)
-
return Path.home() / '.local' / 'share'
-
-
def parse_contact_md(file_path):
-
"""Parse a markdown file with YAML front matter."""
-
with open(file_path, 'r') as f:
-
content = f.read()
-
-
# Extract YAML front matter between ---
-
if content.startswith('---\n'):
-
parts = content.split('---\n', 2)
-
if len(parts) >= 2:
-
yaml_content = parts[1]
-
return yaml.safe_load(yaml_content)
-
return None
-
-
def detect_feed_type(url):
-
"""Detect feed type based on URL patterns."""
-
url_lower = url.lower()
-
if 'json' in url_lower or url_lower.endswith('.json'):
-
return 'json'
-
elif 'rss' in url_lower or url_lower.endswith('.rss') or url_lower.endswith('.xml'):
-
return 'rss'
-
else:
-
# Default to atom for most feeds
-
return 'atom'
-
-
def convert_to_sortal_format(yaml_data, handle):
-
"""Convert YAML contact data to Sortal JSON format."""
-
sortal_contact = {
-
"handle": handle,
-
"names": yaml_data.get("names", [])
-
}
-
-
# Optional fields
-
if "email" in yaml_data:
-
sortal_contact["email"] = yaml_data["email"]
-
if "icon" in yaml_data:
-
sortal_contact["icon"] = yaml_data["icon"]
-
if "github" in yaml_data:
-
sortal_contact["github"] = yaml_data["github"]
-
if "twitter" in yaml_data:
-
sortal_contact["twitter"] = yaml_data["twitter"]
-
if "bluesky" in yaml_data:
-
sortal_contact["bluesky"] = yaml_data["bluesky"]
-
if "mastodon" in yaml_data:
-
sortal_contact["mastodon"] = yaml_data["mastodon"]
-
if "orcid" in yaml_data:
-
sortal_contact["orcid"] = yaml_data["orcid"]
-
if "url" in yaml_data:
-
sortal_contact["url"] = yaml_data["url"]
-
-
# Convert atom feeds to new feed structure
-
if "atom" in yaml_data:
-
atom_feeds = yaml_data["atom"]
-
if atom_feeds:
-
feeds = []
-
for feed_url in atom_feeds:
-
feed_type = detect_feed_type(feed_url)
-
feed_obj = {
-
"type": feed_type,
-
"url": feed_url
-
}
-
feeds.append(feed_obj)
-
sortal_contact["feeds"] = feeds
-
-
return sortal_contact
-
-
def main():
-
# Source directory
-
source_dir = Path.home() / 'src' / 'git' / 'avsm' / 'arod-dream' / 'data' / 'contacts'
-
-
# Destination directory (XDG data home for sortal)
-
xdg_data = get_xdg_data_home()
-
dest_dir = xdg_data / 'sortal'
-
dest_dir.mkdir(parents=True, exist_ok=True)
-
-
print(f"Importing contacts from: {source_dir}")
-
print(f"Output directory: {dest_dir}")
-
print()
-
-
if not source_dir.exists():
-
print(f"Error: Source directory does not exist: {source_dir}")
-
return 1
-
-
# Delete existing contacts to avoid old schema
-
print("Clearing existing contacts...")
-
for existing_file in dest_dir.glob('*.json'):
-
existing_file.unlink()
-
-
imported_count = 0
-
error_count = 0
-
total_feeds = 0
-
-
# Process each .md file
-
for md_file in sorted(source_dir.glob('*.md')):
-
handle = md_file.stem
-
-
try:
-
yaml_data = parse_contact_md(md_file)
-
if yaml_data is None:
-
print(f"⚠ Skipping {handle}: No YAML front matter found")
-
error_count += 1
-
continue
-
-
# Convert to Sortal format
-
sortal_contact = convert_to_sortal_format(yaml_data, handle)
-
-
# Write JSON file
-
output_file = dest_dir / f"{handle}.json"
-
with open(output_file, 'w') as f:
-
json.dump(sortal_contact, f, indent=2, ensure_ascii=False)
-
-
name = sortal_contact.get('names', [handle])[0] if sortal_contact.get('names') else handle
-
feed_count = len(sortal_contact.get('feeds', []))
-
total_feeds += feed_count
-
-
feed_info = f" ({feed_count} feed{'s' if feed_count != 1 else ''})" if feed_count > 0 else ""
-
print(f"✓ Imported: {handle} ({name}){feed_info}")
-
imported_count += 1
-
-
except Exception as e:
-
print(f"✗ Error importing {handle}: {e}")
-
error_count += 1
-
-
print()
-
print(f"Import complete!")
-
print(f" Successfully imported: {imported_count}")
-
print(f" Total feeds: {total_feeds}")
-
print(f" Errors: {error_count}")
-
print(f" Output directory: {dest_dir}")
-
-
return 0 if error_count == 0 else 1
-
-
if __name__ == '__main__':
-
exit(main())