Manage Atom feeds in a persistent git repository

Add OPML generation to sync command

- Create new OPMLGenerator class for exporting feed collections
- Integrate OPML generation into sync command workflow
- Add proper URL validation with pydantic HttpUrl
- Generate index.opml file in git store after successful sync

🤖 Generated with [Claude Code](https://claude.ai/code)

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

Changed files
+189 -3
src
thicket
cli
commands
core
+23 -3
src/thicket/cli/commands/sync.py
···
from typing import Optional
import typer
from rich.progress import track
from ...core.feed_parser import FeedParser
from ...core.git_store import GitStore
from ..main import app
from ..utils import (
load_config,
···
git_store.commit_changes(commit_message)
print_success(f"Committed changes: {commit_message}")
# Summary
if dry_run:
print_info(
···
async def sync_feed(
-
git_store: GitStore, username: str, feed_url, dry_run: bool
) -> tuple[int, int]:
"""Sync a single feed for a user."""
···
try:
# Fetch and parse feed
-
content = await parser.fetch_feed(feed_url)
-
metadata, entries = parser.parse_feed(content, feed_url)
new_entries = 0
updated_entries = 0
···
from typing import Optional
import typer
+
from pydantic import HttpUrl
from rich.progress import track
from ...core.feed_parser import FeedParser
from ...core.git_store import GitStore
+
from ...core.opml_generator import OPMLGenerator
from ..main import app
from ..utils import (
load_config,
···
git_store.commit_changes(commit_message)
print_success(f"Committed changes: {commit_message}")
+
# Generate OPML file with all feeds
+
if not dry_run:
+
try:
+
opml_generator = OPMLGenerator()
+
index = git_store._load_index()
+
opml_path = config.git_store / "index.opml"
+
+
opml_generator.generate_opml(
+
users=index.users,
+
title="Thicket Feed Collection",
+
output_path=opml_path,
+
)
+
print_info(f"Generated OPML file: {opml_path}")
+
+
except Exception as e:
+
print_error(f"Failed to generate OPML file: {e}")
+
# Summary
if dry_run:
print_info(
···
async def sync_feed(
+
git_store: GitStore, username: str, feed_url: str, dry_run: bool
) -> tuple[int, int]:
"""Sync a single feed for a user."""
···
try:
# Fetch and parse feed
+
validated_feed_url = HttpUrl(feed_url)
+
content = await parser.fetch_feed(validated_feed_url)
+
metadata, entries = parser.parse_feed(content, validated_feed_url)
new_entries = 0
updated_entries = 0
+166
src/thicket/core/opml_generator.py
···
···
+
"""OPML generation for thicket."""
+
+
import xml.etree.ElementTree as ET
+
from datetime import datetime
+
from pathlib import Path
+
from typing import Optional
+
from xml.dom import minidom
+
+
from ..models import UserMetadata
+
+
+
class OPMLGenerator:
+
"""Generates OPML files from feed collections."""
+
+
def __init__(self) -> None:
+
"""Initialize the OPML generator."""
+
pass
+
+
def generate_opml(
+
self,
+
users: dict[str, UserMetadata],
+
title: str = "Thicket Feeds",
+
output_path: Optional[Path] = None,
+
) -> str:
+
"""Generate OPML XML content from user metadata.
+
+
Args:
+
users: Dictionary of username -> UserMetadata
+
title: Title for the OPML file
+
output_path: Optional path to write the OPML file
+
+
Returns:
+
OPML XML content as string
+
"""
+
# Create root OPML element
+
opml = ET.Element("opml", version="2.0")
+
+
# Create head section
+
head = ET.SubElement(opml, "head")
+
title_elem = ET.SubElement(head, "title")
+
title_elem.text = title
+
+
date_created = ET.SubElement(head, "dateCreated")
+
date_created.text = datetime.now().strftime("%a, %d %b %Y %H:%M:%S %z")
+
+
date_modified = ET.SubElement(head, "dateModified")
+
date_modified.text = datetime.now().strftime("%a, %d %b %Y %H:%M:%S %z")
+
+
# Create body section
+
body = ET.SubElement(opml, "body")
+
+
# Add each user as an outline with their feeds as sub-outlines
+
for username, user_metadata in sorted(users.items()):
+
user_outline = ET.SubElement(body, "outline")
+
user_outline.set("text", user_metadata.display_name or username)
+
user_outline.set("title", user_metadata.display_name or username)
+
+
# Add user metadata as attributes if available
+
if user_metadata.homepage:
+
user_outline.set("htmlUrl", user_metadata.homepage)
+
if user_metadata.email:
+
user_outline.set("email", user_metadata.email)
+
+
# Add each feed as a sub-outline
+
for feed_url in sorted(user_metadata.feeds):
+
feed_outline = ET.SubElement(user_outline, "outline")
+
feed_outline.set("type", "rss")
+
feed_outline.set("text", feed_url)
+
feed_outline.set("title", feed_url)
+
feed_outline.set("xmlUrl", feed_url)
+
feed_outline.set("htmlUrl", feed_url)
+
+
# Convert to pretty-printed XML string
+
xml_str = self._prettify_xml(opml)
+
+
# Write to file if path provided
+
if output_path:
+
output_path.write_text(xml_str, encoding="utf-8")
+
+
return xml_str
+
+
def _prettify_xml(self, elem: ET.Element) -> str:
+
"""Return a pretty-printed XML string for the Element."""
+
rough_string = ET.tostring(elem, encoding="unicode")
+
reparsed = minidom.parseString(rough_string)
+
return reparsed.toprettyxml(indent=" ")
+
+
def generate_flat_opml(
+
self,
+
users: dict[str, UserMetadata],
+
title: str = "Thicket Feeds (Flat)",
+
output_path: Optional[Path] = None,
+
) -> str:
+
"""Generate a flat OPML file with all feeds at the top level.
+
+
This format may be more compatible with some feed readers.
+
+
Args:
+
users: Dictionary of username -> UserMetadata
+
title: Title for the OPML file
+
output_path: Optional path to write the OPML file
+
+
Returns:
+
OPML XML content as string
+
"""
+
# Create root OPML element
+
opml = ET.Element("opml", version="2.0")
+
+
# Create head section
+
head = ET.SubElement(opml, "head")
+
title_elem = ET.SubElement(head, "title")
+
title_elem.text = title
+
+
date_created = ET.SubElement(head, "dateCreated")
+
date_created.text = datetime.now().strftime("%a, %d %b %Y %H:%M:%S %z")
+
+
date_modified = ET.SubElement(head, "dateModified")
+
date_modified.text = datetime.now().strftime("%a, %d %b %Y %H:%M:%S %z")
+
+
# Create body section
+
body = ET.SubElement(opml, "body")
+
+
# Collect all feeds with their associated user info
+
all_feeds = []
+
for username, user_metadata in users.items():
+
for feed_url in user_metadata.feeds:
+
all_feeds.append(
+
{
+
"url": feed_url,
+
"username": username,
+
"display_name": user_metadata.display_name or username,
+
"homepage": user_metadata.homepage,
+
}
+
)
+
+
# Sort feeds by URL for consistency
+
all_feeds.sort(key=lambda f: f["url"] or "")
+
+
# Add each feed as a top-level outline
+
for feed_info in all_feeds:
+
feed_outline = ET.SubElement(body, "outline")
+
feed_outline.set("type", "rss")
+
+
# Create a descriptive title that includes the user
+
title_text = f"{feed_info['display_name']}: {feed_info['url']}"
+
feed_outline.set("text", title_text)
+
feed_outline.set("title", title_text)
+
url = feed_info["url"] or ""
+
feed_outline.set("xmlUrl", url)
+
homepage_url = feed_info.get("homepage") or url
+
feed_outline.set("htmlUrl", homepage_url or "")
+
+
# Add custom attributes for user info
+
feed_outline.set("thicketUser", feed_info["username"] or "")
+
homepage = feed_info.get("homepage")
+
if homepage:
+
feed_outline.set("thicketHomepage", homepage)
+
+
# Convert to pretty-printed XML string
+
xml_str = self._prettify_xml(opml)
+
+
# Write to file if path provided
+
if output_path:
+
output_path.write_text(xml_str, encoding="utf-8")
+
+
return xml_str