this repo has no description
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

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>

+189 -3
+23 -3
src/thicket/cli/commands/sync.py
··· 5 5 from typing import Optional 6 6 7 7 import typer 8 + from pydantic import HttpUrl 8 9 from rich.progress import track 9 10 10 11 from ...core.feed_parser import FeedParser 11 12 from ...core.git_store import GitStore 13 + from ...core.opml_generator import OPMLGenerator 12 14 from ..main import app 13 15 from ..utils import ( 14 16 load_config, ··· 97 99 git_store.commit_changes(commit_message) 98 100 print_success(f"Committed changes: {commit_message}") 99 101 102 + # Generate OPML file with all feeds 103 + if not dry_run: 104 + try: 105 + opml_generator = OPMLGenerator() 106 + index = git_store._load_index() 107 + opml_path = config.git_store / "index.opml" 108 + 109 + opml_generator.generate_opml( 110 + users=index.users, 111 + title="Thicket Feed Collection", 112 + output_path=opml_path, 113 + ) 114 + print_info(f"Generated OPML file: {opml_path}") 115 + 116 + except Exception as e: 117 + print_error(f"Failed to generate OPML file: {e}") 118 + 100 119 # Summary 101 120 if dry_run: 102 121 print_info( ··· 109 128 110 129 111 130 async def sync_feed( 112 - git_store: GitStore, username: str, feed_url, dry_run: bool 131 + git_store: GitStore, username: str, feed_url: str, dry_run: bool 113 132 ) -> tuple[int, int]: 114 133 """Sync a single feed for a user.""" 115 134 ··· 117 136 118 137 try: 119 138 # Fetch and parse feed 120 - content = await parser.fetch_feed(feed_url) 121 - metadata, entries = parser.parse_feed(content, feed_url) 139 + validated_feed_url = HttpUrl(feed_url) 140 + content = await parser.fetch_feed(validated_feed_url) 141 + metadata, entries = parser.parse_feed(content, validated_feed_url) 122 142 123 143 new_entries = 0 124 144 updated_entries = 0
+166
src/thicket/core/opml_generator.py
··· 1 + """OPML generation for thicket.""" 2 + 3 + import xml.etree.ElementTree as ET 4 + from datetime import datetime 5 + from pathlib import Path 6 + from typing import Optional 7 + from xml.dom import minidom 8 + 9 + from ..models import UserMetadata 10 + 11 + 12 + class OPMLGenerator: 13 + """Generates OPML files from feed collections.""" 14 + 15 + def __init__(self) -> None: 16 + """Initialize the OPML generator.""" 17 + pass 18 + 19 + def generate_opml( 20 + self, 21 + users: dict[str, UserMetadata], 22 + title: str = "Thicket Feeds", 23 + output_path: Optional[Path] = None, 24 + ) -> str: 25 + """Generate OPML XML content from user metadata. 26 + 27 + Args: 28 + users: Dictionary of username -> UserMetadata 29 + title: Title for the OPML file 30 + output_path: Optional path to write the OPML file 31 + 32 + Returns: 33 + OPML XML content as string 34 + """ 35 + # Create root OPML element 36 + opml = ET.Element("opml", version="2.0") 37 + 38 + # Create head section 39 + head = ET.SubElement(opml, "head") 40 + title_elem = ET.SubElement(head, "title") 41 + title_elem.text = title 42 + 43 + date_created = ET.SubElement(head, "dateCreated") 44 + date_created.text = datetime.now().strftime("%a, %d %b %Y %H:%M:%S %z") 45 + 46 + date_modified = ET.SubElement(head, "dateModified") 47 + date_modified.text = datetime.now().strftime("%a, %d %b %Y %H:%M:%S %z") 48 + 49 + # Create body section 50 + body = ET.SubElement(opml, "body") 51 + 52 + # Add each user as an outline with their feeds as sub-outlines 53 + for username, user_metadata in sorted(users.items()): 54 + user_outline = ET.SubElement(body, "outline") 55 + user_outline.set("text", user_metadata.display_name or username) 56 + user_outline.set("title", user_metadata.display_name or username) 57 + 58 + # Add user metadata as attributes if available 59 + if user_metadata.homepage: 60 + user_outline.set("htmlUrl", user_metadata.homepage) 61 + if user_metadata.email: 62 + user_outline.set("email", user_metadata.email) 63 + 64 + # Add each feed as a sub-outline 65 + for feed_url in sorted(user_metadata.feeds): 66 + feed_outline = ET.SubElement(user_outline, "outline") 67 + feed_outline.set("type", "rss") 68 + feed_outline.set("text", feed_url) 69 + feed_outline.set("title", feed_url) 70 + feed_outline.set("xmlUrl", feed_url) 71 + feed_outline.set("htmlUrl", feed_url) 72 + 73 + # Convert to pretty-printed XML string 74 + xml_str = self._prettify_xml(opml) 75 + 76 + # Write to file if path provided 77 + if output_path: 78 + output_path.write_text(xml_str, encoding="utf-8") 79 + 80 + return xml_str 81 + 82 + def _prettify_xml(self, elem: ET.Element) -> str: 83 + """Return a pretty-printed XML string for the Element.""" 84 + rough_string = ET.tostring(elem, encoding="unicode") 85 + reparsed = minidom.parseString(rough_string) 86 + return reparsed.toprettyxml(indent=" ") 87 + 88 + def generate_flat_opml( 89 + self, 90 + users: dict[str, UserMetadata], 91 + title: str = "Thicket Feeds (Flat)", 92 + output_path: Optional[Path] = None, 93 + ) -> str: 94 + """Generate a flat OPML file with all feeds at the top level. 95 + 96 + This format may be more compatible with some feed readers. 97 + 98 + Args: 99 + users: Dictionary of username -> UserMetadata 100 + title: Title for the OPML file 101 + output_path: Optional path to write the OPML file 102 + 103 + Returns: 104 + OPML XML content as string 105 + """ 106 + # Create root OPML element 107 + opml = ET.Element("opml", version="2.0") 108 + 109 + # Create head section 110 + head = ET.SubElement(opml, "head") 111 + title_elem = ET.SubElement(head, "title") 112 + title_elem.text = title 113 + 114 + date_created = ET.SubElement(head, "dateCreated") 115 + date_created.text = datetime.now().strftime("%a, %d %b %Y %H:%M:%S %z") 116 + 117 + date_modified = ET.SubElement(head, "dateModified") 118 + date_modified.text = datetime.now().strftime("%a, %d %b %Y %H:%M:%S %z") 119 + 120 + # Create body section 121 + body = ET.SubElement(opml, "body") 122 + 123 + # Collect all feeds with their associated user info 124 + all_feeds = [] 125 + for username, user_metadata in users.items(): 126 + for feed_url in user_metadata.feeds: 127 + all_feeds.append( 128 + { 129 + "url": feed_url, 130 + "username": username, 131 + "display_name": user_metadata.display_name or username, 132 + "homepage": user_metadata.homepage, 133 + } 134 + ) 135 + 136 + # Sort feeds by URL for consistency 137 + all_feeds.sort(key=lambda f: f["url"] or "") 138 + 139 + # Add each feed as a top-level outline 140 + for feed_info in all_feeds: 141 + feed_outline = ET.SubElement(body, "outline") 142 + feed_outline.set("type", "rss") 143 + 144 + # Create a descriptive title that includes the user 145 + title_text = f"{feed_info['display_name']}: {feed_info['url']}" 146 + feed_outline.set("text", title_text) 147 + feed_outline.set("title", title_text) 148 + url = feed_info["url"] or "" 149 + feed_outline.set("xmlUrl", url) 150 + homepage_url = feed_info.get("homepage") or url 151 + feed_outline.set("htmlUrl", homepage_url or "") 152 + 153 + # Add custom attributes for user info 154 + feed_outline.set("thicketUser", feed_info["username"] or "") 155 + homepage = feed_info.get("homepage") 156 + if homepage: 157 + feed_outline.set("thicketHomepage", homepage) 158 + 159 + # Convert to pretty-printed XML string 160 + xml_str = self._prettify_xml(opml) 161 + 162 + # Write to file if path provided 163 + if output_path: 164 + output_path.write_text(xml_str, encoding="utf-8") 165 + 166 + return xml_str