this repo has no description
1
fork

Configure Feed

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

Refactor CLI commands and update dependencies

- Remove textual and flask dependencies from main deps
- Remove coverage options from pytest config
- Add pytest to dev dependency group
- Remove links_cmd and threads_cmd commands
- Update command imports and exports
- Apply code formatting improvements across all files
- Streamline dependency management for CLI-focused tool

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

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

+363 -1866
+5 -6
pyproject.toml
··· 40 40 "platformdirs>=4.0.0", 41 41 "pyyaml>=6.0.0", 42 42 "email_validator", 43 - "textual>=4.0.0", 44 - "flask>=3.1.1", 45 43 ] 46 44 47 45 [project.optional-dependencies] ··· 140 138 "-ra", 141 139 "--strict-markers", 142 140 "--strict-config", 143 - "--cov=src/thicket", 144 - "--cov-report=term-missing", 145 - "--cov-report=html", 146 - "--cov-report=xml", 147 141 ] 148 142 filterwarnings = [ 149 143 "error", ··· 172 166 "class .*\\bProtocol\\):", 173 167 "@(abc\\.)?abstractmethod", 174 168 ] 169 + 170 + [dependency-groups] 171 + dev = [ 172 + "pytest>=8.4.1", 173 + ]
+2 -2
src/thicket/cli/commands/__init__.py
··· 1 1 """CLI commands for thicket.""" 2 2 3 3 # Import all commands to register them with the main app 4 - from . import add, duplicates, info_cmd, init, links_cmd, list_cmd, sync, threads_cmd 4 + from . import add, duplicates, info_cmd, init, list_cmd, sync 5 5 6 - __all__ = ["add", "duplicates", "info_cmd", "init", "links_cmd", "list_cmd", "sync", "threads_cmd"] 6 + __all__ = ["add", "duplicates", "info_cmd", "init", "list_cmd", "sync"]
+44 -9
src/thicket/cli/commands/add.py
··· 23 23 def add_command( 24 24 subcommand: str = typer.Argument(..., help="Subcommand: 'user' or 'feed'"), 25 25 username: str = typer.Argument(..., help="Username"), 26 - feed_url: Optional[str] = typer.Argument(None, help="Feed URL (required for 'user' command)"), 26 + feed_url: Optional[str] = typer.Argument( 27 + None, help="Feed URL (required for 'user' command)" 28 + ), 27 29 email: Optional[str] = typer.Option(None, "--email", "-e", help="User email"), 28 - homepage: Optional[str] = typer.Option(None, "--homepage", "-h", help="User homepage"), 30 + homepage: Optional[str] = typer.Option( 31 + None, "--homepage", "-h", help="User homepage" 32 + ), 29 33 icon: Optional[str] = typer.Option(None, "--icon", "-i", help="User icon URL"), 30 - display_name: Optional[str] = typer.Option(None, "--display-name", "-d", help="User display name"), 34 + display_name: Optional[str] = typer.Option( 35 + None, "--display-name", "-d", help="User display name" 36 + ), 31 37 config_file: Optional[Path] = typer.Option( 32 38 Path("thicket.yaml"), "--config", help="Configuration file path" 33 39 ), 34 40 auto_discover: bool = typer.Option( 35 - True, "--auto-discover/--no-auto-discover", help="Auto-discover user metadata from feed" 41 + True, 42 + "--auto-discover/--no-auto-discover", 43 + help="Auto-discover user metadata from feed", 36 44 ), 37 45 ) -> None: 38 46 """Add a user or feed to thicket.""" 39 47 40 48 if subcommand == "user": 41 - add_user(username, feed_url, email, homepage, icon, display_name, config_file, auto_discover) 49 + add_user( 50 + username, 51 + feed_url, 52 + email, 53 + homepage, 54 + icon, 55 + display_name, 56 + config_file, 57 + auto_discover, 58 + ) 42 59 elif subcommand == "feed": 43 60 add_feed(username, feed_url, config_file) 44 61 else: ··· 89 106 discovered_metadata = asyncio.run(discover_feed_metadata(validated_feed_url)) 90 107 91 108 # Prepare user data with manual overrides taking precedence 92 - user_display_name = display_name or (discovered_metadata.author_name or discovered_metadata.title if discovered_metadata else None) 93 - user_email = email or (discovered_metadata.author_email if discovered_metadata else None) 94 - user_homepage = homepage or (str(discovered_metadata.author_uri or discovered_metadata.link) if discovered_metadata else None) 95 - user_icon = icon or (str(discovered_metadata.logo or discovered_metadata.icon or discovered_metadata.image_url) if discovered_metadata else None) 109 + user_display_name = display_name or ( 110 + discovered_metadata.author_name or discovered_metadata.title 111 + if discovered_metadata 112 + else None 113 + ) 114 + user_email = email or ( 115 + discovered_metadata.author_email if discovered_metadata else None 116 + ) 117 + user_homepage = homepage or ( 118 + str(discovered_metadata.author_uri or discovered_metadata.link) 119 + if discovered_metadata 120 + else None 121 + ) 122 + user_icon = icon or ( 123 + str( 124 + discovered_metadata.logo 125 + or discovered_metadata.icon 126 + or discovered_metadata.image_url 127 + ) 128 + if discovered_metadata 129 + else None 130 + ) 96 131 97 132 # Add user to Git store 98 133 git_store.add_user(
+7 -3
src/thicket/cli/commands/duplicates.py
··· 10 10 from ..main import app 11 11 from ..utils import ( 12 12 console, 13 + get_tsv_mode, 13 14 load_config, 14 15 print_error, 15 16 print_info, 16 17 print_success, 17 - get_tsv_mode, 18 18 ) 19 19 20 20 ··· 75 75 print_info(f"Total duplicates: {len(duplicates.duplicates)}") 76 76 77 77 78 - def add_duplicate(git_store: GitStore, duplicate_id: Optional[str], canonical_id: Optional[str]) -> None: 78 + def add_duplicate( 79 + git_store: GitStore, duplicate_id: Optional[str], canonical_id: Optional[str] 80 + ) -> None: 79 81 """Add a duplicate mapping.""" 80 82 if not duplicate_id: 81 83 print_error("Duplicate ID is required") ··· 124 126 # Remove the mapping 125 127 if git_store.remove_duplicate(duplicate_id): 126 128 # Commit changes 127 - git_store.commit_changes(f"Remove duplicate mapping: {duplicate_id} -> {canonical_id}") 129 + git_store.commit_changes( 130 + f"Remove duplicate mapping: {duplicate_id} -> {canonical_id}" 131 + ) 128 132 print_success(f"Removed duplicate mapping: {duplicate_id} -> {canonical_id}") 129 133 else: 130 134 print_error(f"Failed to remove duplicate mapping: {duplicate_id}")
+78 -72
src/thicket/cli/commands/info_cmd.py
··· 7 7 from rich.console import Console 8 8 from rich.panel import Panel 9 9 from rich.table import Table 10 - from rich.text import Text 11 10 12 11 from ...core.git_store import GitStore 13 12 from ..main import app 14 - from ..utils import load_config, get_tsv_mode 13 + from ..utils import get_tsv_mode, load_config 15 14 16 15 console = Console() 17 16 ··· 19 18 @app.command() 20 19 def info( 21 20 identifier: str = typer.Argument( 22 - ..., 23 - help="The atom ID or URL of the entry to display information about" 21 + ..., help="The atom ID or URL of the entry to display information about" 24 22 ), 25 23 username: Optional[str] = typer.Option( 26 24 None, 27 25 "--username", 28 26 "-u", 29 - help="Username to search for the entry (if not provided, searches all users)" 27 + help="Username to search for the entry (if not provided, searches all users)", 30 28 ), 31 29 config_file: Optional[Path] = typer.Option( 32 30 Path("thicket.yaml"), ··· 35 33 help="Path to configuration file", 36 34 ), 37 35 show_content: bool = typer.Option( 38 - False, 39 - "--content", 40 - help="Include the full content of the entry in the output" 36 + False, "--content", help="Include the full content of the entry in the output" 41 37 ), 42 38 ) -> None: 43 39 """Display detailed information about a specific atom entry. 44 - 40 + 45 41 You can specify the entry using either its atom ID or URL. 46 42 Shows all metadata for the given entry, including title, dates, categories, 47 43 and summarizes all inbound and outbound links to/from other posts. ··· 49 45 try: 50 46 # Load configuration 51 47 config = load_config(config_file) 52 - 48 + 53 49 # Initialize Git store 54 50 git_store = GitStore(config.git_store) 55 - 51 + 56 52 # Find the entry 57 53 entry = None 58 54 found_username = None 59 - 55 + 60 56 # Check if identifier looks like a URL 61 - is_url = identifier.startswith(('http://', 'https://')) 62 - 57 + is_url = identifier.startswith(("http://", "https://")) 58 + 63 59 if username: 64 60 # Search specific username 65 61 if is_url: ··· 95 91 if entry: 96 92 found_username = user 97 93 break 98 - 94 + 99 95 if not entry or not found_username: 100 96 if username: 101 - console.print(f"[red]Entry with {'URL' if is_url else 'atom ID'} '{identifier}' not found for user '{username}'[/red]") 97 + console.print( 98 + f"[red]Entry with {'URL' if is_url else 'atom ID'} '{identifier}' not found for user '{username}'[/red]" 99 + ) 102 100 else: 103 - console.print(f"[red]Entry with {'URL' if is_url else 'atom ID'} '{identifier}' not found in any user's entries[/red]") 101 + console.print( 102 + f"[red]Entry with {'URL' if is_url else 'atom ID'} '{identifier}' not found in any user's entries[/red]" 103 + ) 104 104 raise typer.Exit(1) 105 - 105 + 106 106 # Display information 107 107 if get_tsv_mode(): 108 108 _display_entry_info_tsv(entry, found_username, show_content) 109 109 else: 110 110 _display_entry_info(entry, found_username) 111 - 111 + 112 112 # Display links and backlinks from entry fields 113 113 _display_link_info(entry, found_username, git_store) 114 - 114 + 115 115 # Optionally display content 116 116 if show_content and entry.content: 117 117 _display_content(entry.content) 118 - 118 + 119 119 except Exception as e: 120 120 console.print(f"[red]Error displaying entry info: {e}[/red]") 121 121 raise typer.Exit(1) ··· 123 123 124 124 def _display_entry_info(entry, username: str) -> None: 125 125 """Display basic entry information in a structured format.""" 126 - 126 + 127 127 # Create main info panel 128 128 info_table = Table.grid(padding=(0, 2)) 129 129 info_table.add_column("Field", style="cyan bold", width=15) 130 130 info_table.add_column("Value", style="white") 131 - 131 + 132 132 info_table.add_row("User", f"[green]{username}[/green]") 133 133 info_table.add_row("Atom ID", f"[blue]{entry.id}[/blue]") 134 134 info_table.add_row("Title", entry.title) 135 135 info_table.add_row("Link", str(entry.link)) 136 - 136 + 137 137 if entry.published: 138 - info_table.add_row("Published", entry.published.strftime("%Y-%m-%d %H:%M:%S UTC")) 139 - 138 + info_table.add_row( 139 + "Published", entry.published.strftime("%Y-%m-%d %H:%M:%S UTC") 140 + ) 141 + 140 142 info_table.add_row("Updated", entry.updated.strftime("%Y-%m-%d %H:%M:%S UTC")) 141 - 143 + 142 144 if entry.summary: 143 145 # Truncate long summaries 144 - summary = entry.summary[:200] + "..." if len(entry.summary) > 200 else entry.summary 146 + summary = ( 147 + entry.summary[:200] + "..." if len(entry.summary) > 200 else entry.summary 148 + ) 145 149 info_table.add_row("Summary", summary) 146 - 150 + 147 151 if entry.categories: 148 152 categories_text = ", ".join(entry.categories) 149 153 info_table.add_row("Categories", categories_text) 150 - 154 + 151 155 if entry.author: 152 156 author_info = [] 153 157 if "name" in entry.author: ··· 156 160 author_info.append(f"<{entry.author['email']}>") 157 161 if author_info: 158 162 info_table.add_row("Author", " ".join(author_info)) 159 - 163 + 160 164 if entry.content_type: 161 165 info_table.add_row("Content Type", entry.content_type) 162 - 166 + 163 167 if entry.rights: 164 168 info_table.add_row("Rights", entry.rights) 165 - 169 + 166 170 if entry.source: 167 171 info_table.add_row("Source Feed", entry.source) 168 - 172 + 169 173 panel = Panel( 170 - info_table, 171 - title=f"[bold]Entry Information[/bold]", 172 - border_style="blue" 174 + info_table, title="[bold]Entry Information[/bold]", border_style="blue" 173 175 ) 174 - 176 + 175 177 console.print(panel) 176 178 177 179 178 180 def _display_link_info(entry, username: str, git_store: GitStore) -> None: 179 181 """Display inbound and outbound link information.""" 180 - 182 + 181 183 # Get links from entry fields 182 - outbound_links = getattr(entry, 'links', []) 183 - backlinks = getattr(entry, 'backlinks', []) 184 - 184 + outbound_links = getattr(entry, "links", []) 185 + backlinks = getattr(entry, "backlinks", []) 186 + 185 187 if not outbound_links and not backlinks: 186 188 console.print("\n[dim]No cross-references found for this entry.[/dim]") 187 189 return 188 - 190 + 189 191 # Create links table 190 192 links_table = Table(title="Cross-References") 191 193 links_table.add_column("Direction", style="cyan", width=10) 192 194 links_table.add_column("Target/Source", style="green", width=30) 193 195 links_table.add_column("URL/ID", style="blue", width=60) 194 - 196 + 195 197 # Add outbound links 196 198 for link in outbound_links: 197 199 links_table.add_row("→ Out", "External/Other", link) 198 - 200 + 199 201 # Add backlinks (inbound references) 200 202 for backlink_id in backlinks: 201 203 # Try to find which user this entry belongs to 202 204 source_info = backlink_id 203 205 # Could enhance this by looking up the actual entry to get username 204 206 links_table.add_row("← In", "Entry", source_info) 205 - 207 + 206 208 console.print() 207 209 console.print(links_table) 208 - 210 + 209 211 # Summary 210 - console.print(f"\n[bold]Summary:[/bold] {len(outbound_links)} outbound links, {len(backlinks)} inbound backlinks") 212 + console.print( 213 + f"\n[bold]Summary:[/bold] {len(outbound_links)} outbound links, {len(backlinks)} inbound backlinks" 214 + ) 211 215 212 216 213 217 def _display_content(content: str) -> None: 214 218 """Display the full content of the entry.""" 215 - 219 + 216 220 # Truncate very long content 217 221 display_content = content 218 222 if len(content) > 5000: 219 223 display_content = content[:5000] + "\n\n[... content truncated ...]" 220 - 224 + 221 225 panel = Panel( 222 226 display_content, 223 227 title="[bold]Entry Content[/bold]", 224 228 border_style="green", 225 - expand=False 229 + expand=False, 226 230 ) 227 - 231 + 228 232 console.print() 229 233 console.print(panel) 230 234 231 235 232 236 def _display_entry_info_tsv(entry, username: str, show_content: bool) -> None: 233 237 """Display entry information in TSV format.""" 234 - 238 + 235 239 # Basic info 236 240 print("Field\tValue") 237 241 print(f"User\t{username}") 238 242 print(f"Atom ID\t{entry.id}") 239 - print(f"Title\t{entry.title.replace(chr(9), ' ').replace(chr(10), ' ').replace(chr(13), ' ')}") 243 + print( 244 + f"Title\t{entry.title.replace(chr(9), ' ').replace(chr(10), ' ').replace(chr(13), ' ')}" 245 + ) 240 246 print(f"Link\t{entry.link}") 241 - 247 + 242 248 if entry.published: 243 249 print(f"Published\t{entry.published.strftime('%Y-%m-%d %H:%M:%S UTC')}") 244 - 250 + 245 251 print(f"Updated\t{entry.updated.strftime('%Y-%m-%d %H:%M:%S UTC')}") 246 - 252 + 247 253 if entry.summary: 248 254 # Escape tabs and newlines in summary 249 - summary = entry.summary.replace('\t', ' ').replace('\n', ' ').replace('\r', ' ') 255 + summary = entry.summary.replace("\t", " ").replace("\n", " ").replace("\r", " ") 250 256 print(f"Summary\t{summary}") 251 - 257 + 252 258 if entry.categories: 253 259 print(f"Categories\t{', '.join(entry.categories)}") 254 - 260 + 255 261 if entry.author: 256 262 author_info = [] 257 263 if "name" in entry.author: ··· 260 266 author_info.append(f"<{entry.author['email']}>") 261 267 if author_info: 262 268 print(f"Author\t{' '.join(author_info)}") 263 - 269 + 264 270 if entry.content_type: 265 271 print(f"Content Type\t{entry.content_type}") 266 - 272 + 267 273 if entry.rights: 268 274 print(f"Rights\t{entry.rights}") 269 - 275 + 270 276 if entry.source: 271 277 print(f"Source Feed\t{entry.source}") 272 - 278 + 273 279 # Add links info from entry fields 274 - outbound_links = getattr(entry, 'links', []) 275 - backlinks = getattr(entry, 'backlinks', []) 276 - 280 + outbound_links = getattr(entry, "links", []) 281 + backlinks = getattr(entry, "backlinks", []) 282 + 277 283 if outbound_links or backlinks: 278 284 print(f"Outbound Links\t{len(outbound_links)}") 279 285 print(f"Backlinks\t{len(backlinks)}") 280 - 286 + 281 287 # Show each link 282 288 for link in outbound_links: 283 289 print(f"→ Link\t{link}") 284 - 290 + 285 291 for backlink_id in backlinks: 286 292 print(f"← Backlink\t{backlink_id}") 287 - 293 + 288 294 # Show content if requested 289 295 if show_content and entry.content: 290 296 # Escape tabs and newlines in content 291 - content = entry.content.replace('\t', ' ').replace('\n', ' ').replace('\r', ' ') 292 - print(f"Content\t{content}") 297 + content = entry.content.replace("\t", " ").replace("\n", " ").replace("\r", " ") 298 + print(f"Content\t{content}")
+5 -6
src/thicket/cli/commands/init.py
··· 14 14 15 15 @app.command() 16 16 def init( 17 - git_store: Path = typer.Argument(..., help="Path to Git repository for storing feeds"), 17 + git_store: Path = typer.Argument( 18 + ..., help="Path to Git repository for storing feeds" 19 + ), 18 20 cache_dir: Optional[Path] = typer.Option( 19 21 None, "--cache-dir", "-c", help="Cache directory (default: ~/.cache/thicket)" 20 22 ), ··· 30 32 # Set default paths 31 33 if cache_dir is None: 32 34 from platformdirs import user_cache_dir 35 + 33 36 cache_dir = Path(user_cache_dir("thicket")) 34 37 35 38 if config_file is None: ··· 54 57 55 58 # Create configuration 56 59 try: 57 - config = ThicketConfig( 58 - git_store=git_store, 59 - cache_dir=cache_dir, 60 - users=[] 61 - ) 60 + config = ThicketConfig(git_store=git_store, cache_dir=cache_dir, users=[]) 62 61 63 62 save_config(config, config_file) 64 63 print_success(f"Created configuration file: {config_file}")
+11 -11
src/thicket/cli/commands/list_cmd.py
··· 11 11 from ..main import app 12 12 from ..utils import ( 13 13 console, 14 + get_tsv_mode, 14 15 load_config, 16 + print_entries_tsv, 15 17 print_error, 16 - print_feeds_table, 17 18 print_feeds_table_from_git, 18 19 print_info, 19 - print_users_table, 20 20 print_users_table_from_git, 21 - print_entries_tsv, 22 - get_tsv_mode, 23 21 ) 24 22 25 23 ··· 60 58 """List all users.""" 61 59 index = git_store._load_index() 62 60 users = list(index.users.values()) 63 - 61 + 64 62 if not users: 65 63 print_info("No users configured") 66 64 return ··· 83 81 print_feeds_table_from_git(git_store, username) 84 82 85 83 86 - def list_entries(git_store: GitStore, username: Optional[str] = None, limit: Optional[int] = None) -> None: 84 + def list_entries( 85 + git_store: GitStore, username: Optional[str] = None, limit: Optional[int] = None 86 + ) -> None: 87 87 """List entries, optionally filtered by user.""" 88 88 89 89 if username: ··· 123 123 """Clean HTML content for display in table.""" 124 124 if not content: 125 125 return "" 126 - 126 + 127 127 # Remove HTML tags 128 - clean_text = re.sub(r'<[^>]+>', ' ', content) 128 + clean_text = re.sub(r"<[^>]+>", " ", content) 129 129 # Replace multiple whitespace with single space 130 - clean_text = re.sub(r'\s+', ' ', clean_text) 130 + clean_text = re.sub(r"\s+", " ", clean_text) 131 131 # Strip and limit length 132 132 clean_text = clean_text.strip() 133 133 if len(clean_text) > 100: 134 134 clean_text = clean_text[:97] + "..." 135 - 135 + 136 136 return clean_text 137 137 138 138 ··· 141 141 if get_tsv_mode(): 142 142 print_entries_tsv(entries_by_user, usernames) 143 143 return 144 - 144 + 145 145 table = Table(title="Feed Entries") 146 146 table.add_column("User", style="cyan", no_wrap=True) 147 147 table.add_column("Title", style="bold")
+15 -5
src/thicket/cli/commands/sync.py
··· 71 71 user_updated_entries = 0 72 72 73 73 # Sync each feed for the user 74 - for feed_url in track(user_metadata.feeds, description=f"Syncing {user_metadata.username}'s feeds"): 74 + for feed_url in track( 75 + user_metadata.feeds, description=f"Syncing {user_metadata.username}'s feeds" 76 + ): 75 77 try: 76 78 new_entries, updated_entries = asyncio.run( 77 79 sync_feed(git_store, user_metadata.username, feed_url, dry_run) ··· 83 85 print_error(f"Failed to sync feed {feed_url}: {e}") 84 86 continue 85 87 86 - print_info(f"User {user_metadata.username}: {user_new_entries} new, {user_updated_entries} updated") 88 + print_info( 89 + f"User {user_metadata.username}: {user_new_entries} new, {user_updated_entries} updated" 90 + ) 87 91 total_new_entries += user_new_entries 88 92 total_updated_entries += user_updated_entries 89 93 ··· 95 99 96 100 # Summary 97 101 if dry_run: 98 - print_info(f"Dry run complete: would sync {total_new_entries} new entries, {total_updated_entries} updated") 102 + print_info( 103 + f"Dry run complete: would sync {total_new_entries} new entries, {total_updated_entries} updated" 104 + ) 99 105 else: 100 - print_success(f"Sync complete: {total_new_entries} new entries, {total_updated_entries} updated") 106 + print_success( 107 + f"Sync complete: {total_new_entries} new entries, {total_updated_entries} updated" 108 + ) 101 109 102 110 103 - async def sync_feed(git_store: GitStore, username: str, feed_url, dry_run: bool) -> tuple[int, int]: 111 + async def sync_feed( 112 + git_store: GitStore, username: str, feed_url, dry_run: bool 113 + ) -> tuple[int, int]: 104 114 """Sync a single feed for a user.""" 105 115 106 116 parser = FeedParser()
-1111
src/thicket/cli/commands/threads_cmd.py
··· 1 - """CLI command for displaying and browsing thread-graphs of blog posts.""" 2 - 3 - from dataclasses import dataclass, field 4 - from datetime import datetime 5 - from enum import Enum 6 - from pathlib import Path 7 - from typing import Dict, List, Optional, Set, Tuple 8 - 9 - import typer 10 - from rich.console import Console 11 - import json 12 - import webbrowser 13 - import threading 14 - import time 15 - from flask import Flask, render_template_string, jsonify 16 - from textual import events 17 - from textual.app import App, ComposeResult 18 - from textual.containers import Container, Horizontal, Vertical 19 - from textual.reactive import reactive 20 - from textual.widget import Widget 21 - from textual.widgets import Footer, Header, Label, Static 22 - 23 - from ...core.git_store import GitStore 24 - from ...models import AtomEntry 25 - from ..main import app 26 - from ..utils import get_tsv_mode, load_config 27 - 28 - console = Console() 29 - 30 - 31 - class LinkType(Enum): 32 - """Types of links between entries.""" 33 - 34 - SELF_REFERENCE = "self" # Link to same user's content 35 - USER_REFERENCE = "user" # Link to another tracked user 36 - EXTERNAL = "external" # Link to external content 37 - 38 - 39 - @dataclass 40 - class ThreadNode: 41 - """Represents a node in the thread graph.""" 42 - 43 - entry_id: str 44 - username: str 45 - entry: AtomEntry 46 - outbound_links: List[Tuple[str, LinkType]] = field( 47 - default_factory=list 48 - ) # (url, type) 49 - inbound_backlinks: List[str] = field(default_factory=list) # entry_ids 50 - 51 - @property 52 - def published_date(self) -> datetime: 53 - """Get the published or updated date for sorting.""" 54 - return self.entry.published or self.entry.updated 55 - 56 - @property 57 - def title(self) -> str: 58 - """Get the entry title.""" 59 - return self.entry.title 60 - 61 - @property 62 - def summary(self) -> str: 63 - """Get a short summary of the entry.""" 64 - if self.entry.summary: 65 - return ( 66 - self.entry.summary[:100] + "..." 67 - if len(self.entry.summary) > 100 68 - else self.entry.summary 69 - ) 70 - return "" 71 - 72 - 73 - @dataclass 74 - class ThreadGraph: 75 - """Represents the full thread graph of interconnected posts.""" 76 - 77 - nodes: Dict[str, ThreadNode] = field(default_factory=dict) # entry_id -> ThreadNode 78 - user_entries: Dict[str, List[str]] = field( 79 - default_factory=dict 80 - ) # username -> [entry_ids] 81 - url_to_entry: Dict[str, str] = field(default_factory=dict) # url -> entry_id 82 - 83 - def add_node(self, node: ThreadNode) -> None: 84 - """Add a node to the graph.""" 85 - self.nodes[node.entry_id] = node 86 - 87 - # Update user entries index 88 - if node.username not in self.user_entries: 89 - self.user_entries[node.username] = [] 90 - self.user_entries[node.username].append(node.entry_id) 91 - 92 - # Update URL mapping 93 - if node.entry.link: 94 - self.url_to_entry[str(node.entry.link)] = node.entry_id 95 - 96 - def get_connected_components(self) -> List[Set[str]]: 97 - """Find all connected components in the graph (threads).""" 98 - visited: Set[str] = set() 99 - components: List[Set[str]] = [] 100 - 101 - for entry_id in self.nodes: 102 - if entry_id not in visited: 103 - component: Set[str] = set() 104 - self._dfs(entry_id, visited, component) 105 - components.append(component) 106 - 107 - return components 108 - 109 - def _dfs(self, entry_id: str, visited: Set[str], component: Set[str]) -> None: 110 - """Depth-first search to find connected components.""" 111 - if entry_id in visited: 112 - return 113 - 114 - visited.add(entry_id) 115 - component.add(entry_id) 116 - 117 - node = self.nodes.get(entry_id) 118 - if not node: 119 - return 120 - 121 - # Follow outbound links 122 - for url, link_type in node.outbound_links: 123 - if url in self.url_to_entry: 124 - target_id = self.url_to_entry[url] 125 - self._dfs(target_id, visited, component) 126 - 127 - # Follow backlinks 128 - for backlink_id in node.inbound_backlinks: 129 - self._dfs(backlink_id, visited, component) 130 - 131 - def get_standalone_entries(self) -> List[str]: 132 - """Get entries with no connections.""" 133 - standalone = [] 134 - for entry_id, node in self.nodes.items(): 135 - if not node.outbound_links and not node.inbound_backlinks: 136 - standalone.append(entry_id) 137 - return standalone 138 - 139 - def sort_component_chronologically(self, component: Set[str]) -> List[str]: 140 - """Sort a component by published date.""" 141 - nodes = [ 142 - self.nodes[entry_id] for entry_id in component if entry_id in self.nodes 143 - ] 144 - nodes.sort(key=lambda n: n.published_date) 145 - return [n.entry_id for n in nodes] 146 - 147 - 148 - def build_thread_graph(git_store: GitStore) -> ThreadGraph: 149 - """Build the thread graph from all entries in the git store.""" 150 - graph = ThreadGraph() 151 - 152 - # Get all users from index 153 - index = git_store._load_index() 154 - user_domains = {} 155 - 156 - # Build user domain mapping 157 - for username, user_metadata in index.users.items(): 158 - domains = set() 159 - 160 - # Add domains from feeds 161 - for feed_url in user_metadata.feeds: 162 - from urllib.parse import urlparse 163 - 164 - domain = urlparse(str(feed_url)).netloc.lower() 165 - if domain: 166 - domains.add(domain) 167 - 168 - # Add domain from homepage 169 - if user_metadata.homepage: 170 - domain = urlparse(str(user_metadata.homepage)).netloc.lower() 171 - if domain: 172 - domains.add(domain) 173 - 174 - user_domains[username] = domains 175 - 176 - # Process all entries 177 - for username in index.users: 178 - entries = git_store.list_entries(username) 179 - 180 - for entry in entries: 181 - # Create node 182 - node = ThreadNode(entry_id=entry.id, username=username, entry=entry) 183 - 184 - # Process outbound links 185 - for link in getattr(entry, "links", []): 186 - link_type = categorize_link(link, username, user_domains) 187 - node.outbound_links.append((link, link_type)) 188 - 189 - # Copy backlinks 190 - node.inbound_backlinks = getattr(entry, "backlinks", []) 191 - 192 - # Add to graph 193 - graph.add_node(node) 194 - 195 - return graph 196 - 197 - 198 - def categorize_link( 199 - url: str, source_username: str, user_domains: Dict[str, Set[str]] 200 - ) -> LinkType: 201 - """Categorize a link as self-reference, user reference, or external.""" 202 - from urllib.parse import urlparse 203 - 204 - try: 205 - parsed = urlparse(url) 206 - domain = parsed.netloc.lower() 207 - 208 - # Check if it's a self-reference 209 - if domain in user_domains.get(source_username, set()): 210 - return LinkType.SELF_REFERENCE 211 - 212 - # Check if it's a reference to another tracked user 213 - for username, domains in user_domains.items(): 214 - if username != source_username and domain in domains: 215 - return LinkType.USER_REFERENCE 216 - 217 - # Otherwise it's external 218 - return LinkType.EXTERNAL 219 - 220 - except Exception: 221 - return LinkType.EXTERNAL 222 - 223 - 224 - class ThreadTreeWidget(Static): 225 - """Widget for displaying a thread as a tree.""" 226 - 227 - def __init__(self, component: Set[str], graph: ThreadGraph, **kwargs): 228 - super().__init__(**kwargs) 229 - self.component = component 230 - self.graph = graph 231 - 232 - def compose(self) -> ComposeResult: 233 - """Create the tree display.""" 234 - # Sort entries chronologically 235 - sorted_ids = self.graph.sort_component_chronologically(self.component) 236 - 237 - # Build tree structure as text 238 - content_lines = ["Thread:"] 239 - added_nodes: Set[str] = set() 240 - 241 - # Add nodes in chronological order, showing connections 242 - for entry_id in sorted_ids: 243 - if entry_id not in added_nodes: 244 - self._add_node_to_text(content_lines, entry_id, added_nodes, 0) 245 - 246 - # Join all lines into content 247 - content = "\n".join(content_lines) 248 - 249 - # Create a Static widget with the content 250 - yield Static(content, id="thread-content") 251 - 252 - def _add_node_to_text( 253 - self, content_lines: List[str], entry_id: str, added_nodes: Set[str], indent: int = 0 254 - ): 255 - """Recursively add nodes to the text display.""" 256 - if entry_id in added_nodes: 257 - # Show cycle reference 258 - node = self.graph.nodes.get(entry_id) 259 - if node: 260 - prefix = " " * indent 261 - content_lines.append(f"{prefix}↻ {node.username}: {node.title}") 262 - return 263 - 264 - added_nodes.add(entry_id) 265 - node = self.graph.nodes.get(entry_id) 266 - if not node: 267 - return 268 - 269 - # Format node display 270 - prefix = " " * indent 271 - date_str = node.published_date.strftime("%Y-%m-%d") 272 - node_label = f"{prefix}• {node.username}: {node.title} ({date_str})" 273 - content_lines.append(node_label) 274 - 275 - # Add connections info 276 - if node.outbound_links: 277 - links_by_type: Dict[LinkType, List[str]] = {} 278 - for url, link_type in node.outbound_links: 279 - if link_type not in links_by_type: 280 - links_by_type[link_type] = [] 281 - links_by_type[link_type].append(url) 282 - 283 - for link_type, urls in links_by_type.items(): 284 - type_label = f"{prefix} → {link_type.value}: {len(urls)} link(s)" 285 - content_lines.append(type_label) 286 - 287 - if node.inbound_backlinks: 288 - backlink_label = f"{prefix} ← backlinks: {len(node.inbound_backlinks)}" 289 - content_lines.append(backlink_label) 290 - 291 - 292 - class ThreadBrowserApp(App): 293 - """Terminal UI for browsing threads.""" 294 - 295 - CSS = """ 296 - ThreadBrowserApp { 297 - background: $surface; 298 - } 299 - 300 - #thread-list { 301 - width: 1fr; 302 - height: 1fr; 303 - border: solid $primary; 304 - overflow-y: scroll; 305 - } 306 - 307 - #entry-detail { 308 - width: 1fr; 309 - height: 1fr; 310 - border: solid $secondary; 311 - overflow-y: scroll; 312 - padding: 1; 313 - } 314 - """ 315 - 316 - BINDINGS = [ 317 - ("q", "quit", "Quit"), 318 - ("j", "next_thread", "Next Thread"), 319 - ("k", "prev_thread", "Previous Thread"), 320 - ("enter", "select_thread", "View Thread"), 321 - ] 322 - 323 - def __init__(self, graph: ThreadGraph): 324 - super().__init__() 325 - self.graph = graph 326 - self.threads = [] 327 - self.current_thread_index = 0 328 - self._build_thread_list() 329 - 330 - def _build_thread_list(self): 331 - """Build the list of threads to display.""" 332 - # Get connected components (actual threads) 333 - components = self.graph.get_connected_components() 334 - 335 - # Sort components by the earliest date in each 336 - sorted_components = [] 337 - for component in components: 338 - if len(component) > 1: # Only show actual threads 339 - sorted_ids = self.graph.sort_component_chronologically(component) 340 - if sorted_ids: 341 - first_node = self.graph.nodes.get(sorted_ids[0]) 342 - if first_node: 343 - sorted_components.append((first_node.published_date, component)) 344 - 345 - sorted_components.sort(key=lambda x: x[0], reverse=True) 346 - self.threads = [comp for _, comp in sorted_components] 347 - 348 - def compose(self) -> ComposeResult: 349 - """Create the UI layout.""" 350 - yield Header() 351 - 352 - with Horizontal(): 353 - with Vertical(id="thread-list"): 354 - yield Label("Threads", classes="title") 355 - for i, thread in enumerate(self.threads): 356 - # Get thread summary 357 - sorted_ids = self.graph.sort_component_chronologically(thread) 358 - if sorted_ids: 359 - first_node = self.graph.nodes.get(sorted_ids[0]) 360 - if first_node: 361 - label = f"{i + 1}. {first_node.title} ({len(thread)} posts)" 362 - yield Label(label, classes="thread-item") 363 - 364 - with Vertical(id="entry-detail"): 365 - if self.threads: 366 - yield ThreadTreeWidget(self.threads[0], self.graph) 367 - 368 - yield Footer() 369 - 370 - def action_next_thread(self) -> None: 371 - """Move to next thread.""" 372 - if self.current_thread_index < len(self.threads) - 1: 373 - self.current_thread_index += 1 374 - self.update_display() 375 - 376 - def action_prev_thread(self) -> None: 377 - """Move to previous thread.""" 378 - if self.current_thread_index > 0: 379 - self.current_thread_index -= 1 380 - self.update_display() 381 - 382 - def action_select_thread(self) -> None: 383 - """View detailed thread.""" 384 - # In a real implementation, this could show more detail 385 - pass 386 - 387 - def update_display(self) -> None: 388 - """Update the thread display.""" 389 - detail_view = self.query_one("#entry-detail") 390 - detail_view.remove_children() 391 - 392 - if self.threads and self.current_thread_index < len(self.threads): 393 - widget = ThreadTreeWidget( 394 - self.threads[self.current_thread_index], self.graph 395 - ) 396 - detail_view.mount(widget) 397 - 398 - 399 - @app.command() 400 - def threads( 401 - config_file: Optional[Path] = typer.Option( 402 - Path("thicket.yaml"), 403 - "--config", 404 - "-c", 405 - help="Path to configuration file", 406 - ), 407 - interactive: bool = typer.Option( 408 - True, 409 - "--interactive/--no-interactive", 410 - "-i/-n", 411 - help="Launch interactive terminal UI", 412 - ), 413 - web: bool = typer.Option( 414 - False, 415 - "--web", 416 - "-w", 417 - help="Launch web server with D3 force graph visualization", 418 - ), 419 - port: int = typer.Option( 420 - 8080, 421 - "--port", 422 - "-p", 423 - help="Port for web server", 424 - ), 425 - ) -> None: 426 - """Browse and visualize thread-graphs of interconnected blog posts. 427 - 428 - This command analyzes all blog entries and their links/backlinks to build 429 - a graph of conversations and references between posts. Threads are displayed 430 - as connected components in the link graph. 431 - """ 432 - try: 433 - # Load configuration 434 - config = load_config(config_file) 435 - 436 - # Initialize Git store 437 - git_store = GitStore(config.git_store) 438 - 439 - # Build thread graph 440 - console.print("Building thread graph...") 441 - graph = build_thread_graph(git_store) 442 - 443 - # Get statistics 444 - components = graph.get_connected_components() 445 - threads = [c for c in components if len(c) > 1] 446 - standalone = graph.get_standalone_entries() 447 - 448 - console.print( 449 - f"\n[green]Found {len(threads)} threads and {len(standalone)} standalone posts[/green]" 450 - ) 451 - 452 - if web: 453 - # Launch web server with D3 visualization 454 - _launch_web_server(graph, port) 455 - elif interactive and threads: 456 - # Launch terminal UI 457 - app = ThreadBrowserApp(graph) 458 - app.run() 459 - else: 460 - # Display in console 461 - if get_tsv_mode(): 462 - _display_threads_tsv(graph, threads) 463 - else: 464 - _display_threads_rich(graph, threads) 465 - 466 - except Exception as e: 467 - console.print(f"[red]Error building threads: {e}[/red]") 468 - raise typer.Exit(1) 469 - 470 - 471 - def _display_threads_rich(graph: ThreadGraph, threads: List[Set[str]]) -> None: 472 - """Display threads using rich formatting.""" 473 - for i, thread in enumerate(threads[:10]): # Show first 10 threads 474 - sorted_ids = graph.sort_component_chronologically(thread) 475 - 476 - console.print(f"\n[bold]Thread {i + 1}[/bold] ({len(thread)} posts)") 477 - 478 - for j, entry_id in enumerate(sorted_ids): 479 - node = graph.nodes.get(entry_id) 480 - if node: 481 - date_str = node.published_date.strftime("%Y-%m-%d") 482 - indent = " " * min(j, 3) # Max 3 levels of indent 483 - console.print(f"{indent}• [{node.username}] {node.title} ({date_str})") 484 - 485 - # Show link types 486 - if node.outbound_links: 487 - link_summary = {} 488 - for _, link_type in node.outbound_links: 489 - link_summary[link_type] = link_summary.get(link_type, 0) + 1 490 - 491 - link_str = ", ".join( 492 - [f"{t.value}:{c}" for t, c in link_summary.items()] 493 - ) 494 - console.print(f"{indent} → Links: {link_str}") 495 - 496 - 497 - def _display_threads_tsv(graph: ThreadGraph, threads: List[Set[str]]) -> None: 498 - """Display threads in TSV format.""" 499 - print("Thread\tSize\tFirst Post\tLast Post\tUsers") 500 - 501 - for i, thread in enumerate(threads): 502 - sorted_ids = graph.sort_component_chronologically(thread) 503 - 504 - if sorted_ids: 505 - first_node = graph.nodes.get(sorted_ids[0]) 506 - last_node = graph.nodes.get(sorted_ids[-1]) 507 - 508 - users = set() 509 - for entry_id in thread: 510 - node = graph.nodes.get(entry_id) 511 - if node: 512 - users.add(node.username) 513 - 514 - if first_node and last_node: 515 - print( 516 - f"{i + 1}\t{len(thread)}\t{first_node.published_date.strftime('%Y-%m-%d')}\t{last_node.published_date.strftime('%Y-%m-%d')}\t{','.join(users)}" 517 - ) 518 - 519 - 520 - def _build_graph_json(graph: ThreadGraph) -> dict: 521 - """Convert ThreadGraph to JSON format for D3 visualization.""" 522 - nodes = [] 523 - links = [] 524 - 525 - # Color mapping for different users 526 - user_colors = {} 527 - colors = [ 528 - "#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd", 529 - "#8c564b", "#e377c2", "#7f7f7f", "#bcbd22", "#17becf", 530 - "#aec7e8", "#ffbb78", "#98df8a", "#ff9896", "#c5b0d5" 531 - ] 532 - 533 - # Assign colors to users 534 - for i, username in enumerate(set(node.username for node in graph.nodes.values())): 535 - user_colors[username] = colors[i % len(colors)] 536 - 537 - # Create nodes 538 - for entry_id, node in graph.nodes.items(): 539 - nodes.append({ 540 - "id": entry_id, 541 - "title": node.title, 542 - "username": node.username, 543 - "date": node.published_date.strftime("%Y-%m-%d"), 544 - "summary": node.summary, 545 - "color": user_colors[node.username], 546 - "outbound_count": len(node.outbound_links), 547 - "backlink_count": len(node.inbound_backlinks), 548 - "link_types": { 549 - "self": len([l for l in node.outbound_links if l[1] == LinkType.SELF_REFERENCE]), 550 - "user": len([l for l in node.outbound_links if l[1] == LinkType.USER_REFERENCE]), 551 - "external": len([l for l in node.outbound_links if l[1] == LinkType.EXTERNAL]) 552 - } 553 - }) 554 - 555 - # Create links (only for links between tracked entries) 556 - for entry_id, node in graph.nodes.items(): 557 - for url, link_type in node.outbound_links: 558 - if url in graph.url_to_entry: 559 - target_id = graph.url_to_entry[url] 560 - if target_id in graph.nodes: 561 - links.append({ 562 - "source": entry_id, 563 - "target": target_id, 564 - "type": link_type.value, 565 - "url": url 566 - }) 567 - 568 - return { 569 - "nodes": nodes, 570 - "links": links, 571 - "stats": { 572 - "total_nodes": len(nodes), 573 - "total_links": len(links), 574 - "users": list(user_colors.keys()), 575 - "user_colors": user_colors 576 - } 577 - } 578 - 579 - 580 - def _launch_web_server(graph: ThreadGraph, port: int) -> None: 581 - """Launch Flask web server with D3 force graph visualization.""" 582 - flask_app = Flask(__name__) 583 - 584 - # Store graph data globally for the Flask app 585 - graph_data = _build_graph_json(graph) 586 - 587 - @flask_app.route('/') 588 - def index(): 589 - """Serve the main visualization page.""" 590 - return render_template_string(HTML_TEMPLATE, port=port) 591 - 592 - @flask_app.route('/api/graph') 593 - def api_graph(): 594 - """API endpoint to serve graph data as JSON.""" 595 - return jsonify(graph_data) 596 - 597 - # Disable Flask logging in development mode 598 - import logging 599 - log = logging.getLogger('werkzeug') 600 - log.setLevel(logging.ERROR) 601 - 602 - def open_browser(): 603 - """Open browser after a short delay.""" 604 - time.sleep(1.5) 605 - webbrowser.open(f'http://localhost:{port}') 606 - 607 - # Start browser in a separate thread 608 - browser_thread = threading.Thread(target=open_browser) 609 - browser_thread.daemon = True 610 - browser_thread.start() 611 - 612 - console.print(f"\n[green]Starting web server at http://localhost:{port}[/green]") 613 - console.print("[yellow]Press Ctrl+C to stop the server[/yellow]") 614 - 615 - try: 616 - flask_app.run(host='0.0.0.0', port=port, debug=False) 617 - except KeyboardInterrupt: 618 - console.print("\n[green]Server stopped[/green]") 619 - 620 - 621 - # HTML template for D3 force graph visualization 622 - HTML_TEMPLATE = """ 623 - <!DOCTYPE html> 624 - <html lang="en"> 625 - <head> 626 - <meta charset="UTF-8"> 627 - <meta name="viewport" content="width=device-width, initial-scale=1.0"> 628 - <title>Thicket Thread Graph Visualization</title> 629 - <script src="https://d3js.org/d3.v7.min.js"></script> 630 - <style> 631 - body { 632 - font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; 633 - margin: 0; 634 - padding: 20px; 635 - background-color: #f5f5f5; 636 - } 637 - 638 - .header { 639 - text-align: center; 640 - margin-bottom: 20px; 641 - } 642 - 643 - h1 { 644 - color: #333; 645 - margin-bottom: 10px; 646 - } 647 - 648 - .controls { 649 - display: flex; 650 - justify-content: center; 651 - gap: 15px; 652 - margin-bottom: 20px; 653 - flex-wrap: wrap; 654 - } 655 - 656 - .control-group { 657 - display: flex; 658 - align-items: center; 659 - gap: 5px; 660 - } 661 - 662 - select, input[type="range"] { 663 - padding: 5px; 664 - border: 1px solid #ddd; 665 - border-radius: 4px; 666 - } 667 - 668 - .stats { 669 - display: flex; 670 - justify-content: center; 671 - gap: 20px; 672 - margin-bottom: 20px; 673 - font-size: 14px; 674 - color: #666; 675 - } 676 - 677 - .stat-item { 678 - background: white; 679 - padding: 10px 15px; 680 - border-radius: 6px; 681 - box-shadow: 0 2px 4px rgba(0,0,0,0.1); 682 - } 683 - 684 - #graph-container { 685 - background: white; 686 - border-radius: 8px; 687 - box-shadow: 0 4px 6px rgba(0,0,0,0.1); 688 - overflow: hidden; 689 - } 690 - 691 - #graph { 692 - cursor: grab; 693 - } 694 - 695 - #graph:active { 696 - cursor: grabbing; 697 - } 698 - 699 - .node { 700 - stroke: #fff; 701 - stroke-width: 1.5px; 702 - cursor: pointer; 703 - } 704 - 705 - .node:hover { 706 - stroke: #333; 707 - stroke-width: 2px; 708 - } 709 - 710 - .link { 711 - stroke: #999; 712 - stroke-opacity: 0.6; 713 - stroke-width: 1px; 714 - } 715 - 716 - .link.self-link { 717 - stroke: #2ca02c; 718 - } 719 - 720 - .link.user-link { 721 - stroke: #ff7f0e; 722 - } 723 - 724 - .link.external-link { 725 - stroke: #d62728; 726 - } 727 - 728 - .tooltip { 729 - position: absolute; 730 - background: rgba(0, 0, 0, 0.9); 731 - color: white; 732 - padding: 10px; 733 - border-radius: 4px; 734 - font-size: 12px; 735 - line-height: 1.4; 736 - pointer-events: none; 737 - z-index: 1000; 738 - max-width: 300px; 739 - } 740 - 741 - .legend { 742 - position: fixed; 743 - top: 20px; 744 - right: 20px; 745 - background: white; 746 - padding: 15px; 747 - border-radius: 6px; 748 - box-shadow: 0 2px 8px rgba(0,0,0,0.15); 749 - font-size: 12px; 750 - z-index: 100; 751 - } 752 - 753 - .legend h3 { 754 - margin: 0 0 10px 0; 755 - font-size: 14px; 756 - color: #333; 757 - } 758 - 759 - .legend-item { 760 - display: flex; 761 - align-items: center; 762 - margin-bottom: 5px; 763 - } 764 - 765 - .legend-color { 766 - width: 12px; 767 - height: 12px; 768 - margin-right: 8px; 769 - border-radius: 2px; 770 - } 771 - 772 - .legend-line { 773 - width: 20px; 774 - height: 2px; 775 - margin-right: 8px; 776 - } 777 - </style> 778 - </head> 779 - <body> 780 - <div class="header"> 781 - <h1>Thicket Thread Graph Visualization</h1> 782 - <p>Interactive visualization of blog post connections and conversations</p> 783 - </div> 784 - 785 - <div class="controls"> 786 - <div class="control-group"> 787 - <label for="userFilter">Filter by user:</label> 788 - <select id="userFilter"> 789 - <option value="all">All Users</option> 790 - </select> 791 - </div> 792 - 793 - <div class="control-group"> 794 - <label for="linkFilter">Show links:</label> 795 - <select id="linkFilter"> 796 - <option value="all">All Links</option> 797 - <option value="user">User Links Only</option> 798 - <option value="self">Self Links Only</option> 799 - <option value="external">External Links Only</option> 800 - </select> 801 - </div> 802 - 803 - <div class="control-group"> 804 - <label for="forceStrength">Force Strength:</label> 805 - <input type="range" id="forceStrength" min="0.1" max="2" step="0.1" value="0.3"> 806 - </div> 807 - 808 - <div class="control-group"> 809 - <label for="nodeSize">Node Size:</label> 810 - <input type="range" id="nodeSize" min="3" max="15" step="1" value="6"> 811 - </div> 812 - </div> 813 - 814 - <div class="stats" id="stats"></div> 815 - 816 - <div id="graph-container"> 817 - <svg id="graph"></svg> 818 - </div> 819 - 820 - <div class="legend"> 821 - <h3>Link Types</h3> 822 - <div class="legend-item"> 823 - <div class="legend-line" style="background: #2ca02c;"></div> 824 - <span>Self References</span> 825 - </div> 826 - <div class="legend-item"> 827 - <div class="legend-line" style="background: #ff7f0e;"></div> 828 - <span>User References</span> 829 - </div> 830 - <div class="legend-item"> 831 - <div class="legend-line" style="background: #d62728;"></div> 832 - <span>External References</span> 833 - </div> 834 - 835 - <h3 style="margin-top: 15px;">Interactions</h3> 836 - <div style="font-size: 11px; color: #666;"> 837 - • Hover: Show details<br> 838 - • Click: Pin/unpin node<br> 839 - • Drag: Move nodes<br> 840 - • Zoom: Mouse wheel 841 - </div> 842 - </div> 843 - 844 - <div class="tooltip" id="tooltip" style="display: none;"></div> 845 - 846 - <script> 847 - let graphData; 848 - let simulation; 849 - let svg, g, link, node; 850 - let width = window.innerWidth - 40; 851 - let height = window.innerHeight - 200; 852 - 853 - // Initialize the visualization 854 - async function init() { 855 - // Fetch graph data 856 - const response = await fetch('/api/graph'); 857 - graphData = await response.json(); 858 - 859 - // Set up SVG 860 - svg = d3.select("#graph") 861 - .attr("width", width) 862 - .attr("height", height); 863 - 864 - // Add zoom behavior 865 - const zoom = d3.zoom() 866 - .scaleExtent([0.1, 4]) 867 - .on("zoom", (event) => { 868 - g.attr("transform", event.transform); 869 - }); 870 - 871 - svg.call(zoom); 872 - 873 - // Create main group for all elements 874 - g = svg.append("g"); 875 - 876 - // Set up controls 877 - setupControls(); 878 - 879 - // Initial render 880 - updateVisualization(); 881 - 882 - // Update stats 883 - updateStats(); 884 - 885 - // Handle window resize 886 - window.addEventListener('resize', () => { 887 - width = window.innerWidth - 40; 888 - height = window.innerHeight - 200; 889 - svg.attr("width", width).attr("height", height); 890 - simulation.force("center", d3.forceCenter(width / 2, height / 2)); 891 - simulation.restart(); 892 - }); 893 - } 894 - 895 - function setupControls() { 896 - // Populate user filter 897 - const userFilter = d3.select("#userFilter"); 898 - graphData.stats.users.forEach(user => { 899 - userFilter.append("option").attr("value", user).text(user); 900 - }); 901 - 902 - // Add event listeners 903 - d3.select("#userFilter").on("change", updateVisualization); 904 - d3.select("#linkFilter").on("change", updateVisualization); 905 - d3.select("#forceStrength").on("input", updateForces); 906 - d3.select("#nodeSize").on("input", updateNodeSizes); 907 - } 908 - 909 - function updateVisualization() { 910 - // Filter data based on controls 911 - const userFilter = d3.select("#userFilter").property("value"); 912 - const linkFilter = d3.select("#linkFilter").property("value"); 913 - 914 - let filteredNodes = graphData.nodes; 915 - let filteredLinks = graphData.links; 916 - 917 - if (userFilter !== "all") { 918 - filteredNodes = graphData.nodes.filter(n => n.username === userFilter); 919 - const nodeIds = new Set(filteredNodes.map(n => n.id)); 920 - filteredLinks = graphData.links.filter(l => 921 - nodeIds.has(l.source.id || l.source) && nodeIds.has(l.target.id || l.target) 922 - ); 923 - } 924 - 925 - if (linkFilter !== "all") { 926 - filteredLinks = filteredLinks.filter(l => l.type === linkFilter); 927 - } 928 - 929 - // Clear existing elements 930 - g.selectAll(".link").remove(); 931 - g.selectAll(".node").remove(); 932 - 933 - // Create force simulation 934 - simulation = d3.forceSimulation(filteredNodes) 935 - .force("link", d3.forceLink(filteredLinks).id(d => d.id) 936 - .distance(d => { 937 - // Get source and target nodes 938 - const sourceNode = filteredNodes.find(n => n.id === (d.source.id || d.source)); 939 - const targetNode = filteredNodes.find(n => n.id === (d.target.id || d.target)); 940 - 941 - // If nodes are from different users, make them attract more (shorter distance) 942 - if (sourceNode && targetNode && sourceNode.username !== targetNode.username) { 943 - return 30; // Shorter distance = stronger attraction 944 - } 945 - 946 - // Same user posts have normal distance 947 - return 60; 948 - }) 949 - .strength(d => { 950 - // Get source and target nodes 951 - const sourceNode = filteredNodes.find(n => n.id === (d.source.id || d.source)); 952 - const targetNode = filteredNodes.find(n => n.id === (d.target.id || d.target)); 953 - 954 - // If nodes are from different users, make the link stronger 955 - if (sourceNode && targetNode && sourceNode.username !== targetNode.username) { 956 - return 1.5; // Stronger link force 957 - } 958 - 959 - // Same user posts have normal strength 960 - return 1.0; 961 - })) 962 - .force("charge", d3.forceManyBody().strength(-200)) 963 - .force("center", d3.forceCenter(width / 2, height / 2)) 964 - .force("collision", d3.forceCollide().radius(15)); 965 - 966 - // Create links 967 - link = g.append("g") 968 - .selectAll(".link") 969 - .data(filteredLinks) 970 - .enter().append("line") 971 - .attr("class", d => `link ${d.type}-link`) 972 - .attr("stroke-width", d => { 973 - // Get source and target nodes 974 - const sourceNode = filteredNodes.find(n => n.id === (d.source.id || d.source)); 975 - const targetNode = filteredNodes.find(n => n.id === (d.target.id || d.target)); 976 - 977 - // If nodes are from different users, make the line thicker 978 - if (sourceNode && targetNode && sourceNode.username !== targetNode.username) { 979 - return 2.5; // Thicker line for cross-user connections 980 - } 981 - 982 - // Same user posts have normal thickness 983 - return 1; 984 - }); 985 - 986 - // Create nodes 987 - node = g.append("g") 988 - .selectAll(".node") 989 - .data(filteredNodes) 990 - .enter().append("circle") 991 - .attr("class", "node") 992 - .attr("r", d => Math.max(4, Math.log(d.outbound_count + d.backlink_count + 1) * 3)) 993 - .attr("fill", d => d.color) 994 - .call(d3.drag() 995 - .on("start", dragstarted) 996 - .on("drag", dragged) 997 - .on("end", dragended)) 998 - .on("mouseover", showTooltip) 999 - .on("mouseout", hideTooltip) 1000 - .on("click", togglePin); 1001 - 1002 - // Update force simulation 1003 - simulation.on("tick", () => { 1004 - link 1005 - .attr("x1", d => d.source.x) 1006 - .attr("y1", d => d.source.y) 1007 - .attr("x2", d => d.target.x) 1008 - .attr("y2", d => d.target.y); 1009 - 1010 - node 1011 - .attr("cx", d => d.x) 1012 - .attr("cy", d => d.y); 1013 - }); 1014 - 1015 - updateStats(filteredNodes, filteredLinks); 1016 - } 1017 - 1018 - function updateForces() { 1019 - const strength = +d3.select("#forceStrength").property("value"); 1020 - if (simulation) { 1021 - simulation.force("charge").strength(-200 * strength); 1022 - simulation.alpha(0.3).restart(); 1023 - } 1024 - } 1025 - 1026 - function updateNodeSizes() { 1027 - const size = +d3.select("#nodeSize").property("value"); 1028 - if (node) { 1029 - node.attr("r", d => Math.max(size * 0.5, Math.log(d.outbound_count + d.backlink_count + 1) * size * 0.5)); 1030 - } 1031 - } 1032 - 1033 - function dragstarted(event, d) { 1034 - if (!event.active) simulation.alphaTarget(0.3).restart(); 1035 - d.fx = d.x; 1036 - d.fy = d.y; 1037 - } 1038 - 1039 - function dragged(event, d) { 1040 - d.fx = event.x; 1041 - d.fy = event.y; 1042 - } 1043 - 1044 - function dragended(event, d) { 1045 - if (!event.active) simulation.alphaTarget(0); 1046 - if (!d.pinned) { 1047 - d.fx = null; 1048 - d.fy = null; 1049 - } 1050 - } 1051 - 1052 - function togglePin(event, d) { 1053 - d.pinned = !d.pinned; 1054 - if (d.pinned) { 1055 - d.fx = d.x; 1056 - d.fy = d.y; 1057 - } else { 1058 - d.fx = null; 1059 - d.fy = null; 1060 - } 1061 - } 1062 - 1063 - function showTooltip(event, d) { 1064 - const tooltip = d3.select("#tooltip"); 1065 - tooltip.style("display", "block") 1066 - .html(` 1067 - <strong>${d.title}</strong><br> 1068 - <strong>User:</strong> ${d.username}<br> 1069 - <strong>Date:</strong> ${d.date}<br> 1070 - <strong>Outbound Links:</strong> ${d.outbound_count}<br> 1071 - <strong>Backlinks:</strong> ${d.backlink_count}<br> 1072 - <strong>Link Types:</strong> Self: ${d.link_types.self}, User: ${d.link_types.user}, External: ${d.link_types.external} 1073 - ${d.summary ? '<br><br>' + d.summary : ''} 1074 - `) 1075 - .style("left", (event.pageX + 10) + "px") 1076 - .style("top", (event.pageY - 10) + "px"); 1077 - } 1078 - 1079 - function hideTooltip() { 1080 - d3.select("#tooltip").style("display", "none"); 1081 - } 1082 - 1083 - function updateStats(nodes = graphData.nodes, links = graphData.links) { 1084 - const stats = d3.select("#stats"); 1085 - const userCounts = {}; 1086 - nodes.forEach(n => { 1087 - userCounts[n.username] = (userCounts[n.username] || 0) + 1; 1088 - }); 1089 - 1090 - stats.html(` 1091 - <div class="stat-item"> 1092 - <strong>${nodes.length}</strong> Nodes 1093 - </div> 1094 - <div class="stat-item"> 1095 - <strong>${links.length}</strong> Links 1096 - </div> 1097 - <div class="stat-item"> 1098 - <strong>${Object.keys(userCounts).length}</strong> Users 1099 - </div> 1100 - <div class="stat-item"> 1101 - Users: ${Object.entries(userCounts).map(([user, count]) => `${user} (${count})`).join(', ')} 1102 - </div> 1103 - `); 1104 - } 1105 - 1106 - // Initialize when page loads 1107 - init(); 1108 - </script> 1109 - </body> 1110 - </html> 1111 - """
+1 -1
src/thicket/cli/main.py
··· 47 47 48 48 49 49 # Import commands to register them 50 - from .commands import add, duplicates, info_cmd, init, links_cmd, list_cmd, sync, threads_cmd 50 + from .commands import add, duplicates, info_cmd, init, list_cmd, sync # noqa: F401 51 51 52 52 if __name__ == "__main__": 53 53 app()
+32 -20
src/thicket/cli/utils.py
··· 8 8 from rich.progress import Progress, SpinnerColumn, TextColumn 9 9 from rich.table import Table 10 10 11 - from ..models import ThicketConfig, UserMetadata 12 11 from ..core.git_store import GitStore 12 + from ..models import ThicketConfig, UserMetadata 13 13 14 14 console = Console() 15 15 ··· 17 17 def get_tsv_mode() -> bool: 18 18 """Get the global TSV mode setting.""" 19 19 from .main import tsv_mode 20 + 20 21 return tsv_mode 21 22 22 23 ··· 37 38 default_config = Path("thicket.yaml") 38 39 if default_config.exists(): 39 40 import yaml 41 + 40 42 with open(default_config) as f: 41 43 config_data = yaml.safe_load(f) 42 44 return ThicketConfig(**config_data) 43 - 45 + 44 46 # Fall back to environment variables 45 47 return ThicketConfig() 46 48 except Exception as e: 47 49 console.print(f"[red]Error loading configuration: {e}[/red]") 48 - console.print("[yellow]Run 'thicket init' to create a new configuration.[/yellow]") 50 + console.print( 51 + "[yellow]Run 'thicket init' to create a new configuration.[/yellow]" 52 + ) 49 53 raise typer.Exit(1) from e 50 54 51 55 ··· 78 82 if get_tsv_mode(): 79 83 print_users_tsv(config) 80 84 return 81 - 85 + 82 86 table = Table(title="Users and Feeds") 83 87 table.add_column("Username", style="cyan", no_wrap=True) 84 88 table.add_column("Display Name", style="magenta") ··· 104 108 if get_tsv_mode(): 105 109 print_feeds_tsv(config, username) 106 110 return 107 - 111 + 108 112 table = Table(title=f"Feeds{f' for {username}' if username else ''}") 109 113 table.add_column("Username", style="cyan", no_wrap=True) 110 114 table.add_column("Feed URL", style="blue") ··· 154 158 if get_tsv_mode(): 155 159 print_users_tsv_from_git(users) 156 160 return 157 - 161 + 158 162 table = Table(title="Users and Feeds") 159 163 table.add_column("Username", style="cyan", no_wrap=True) 160 164 table.add_column("Display Name", style="magenta") ··· 175 179 console.print(table) 176 180 177 181 178 - def print_feeds_table_from_git(git_store: GitStore, username: Optional[str] = None) -> None: 182 + def print_feeds_table_from_git( 183 + git_store: GitStore, username: Optional[str] = None 184 + ) -> None: 179 185 """Print a table of feeds from git repository.""" 180 186 if get_tsv_mode(): 181 187 print_feeds_tsv_from_git(git_store, username) 182 188 return 183 - 189 + 184 190 table = Table(title=f"Feeds{f' for {username}' if username else ''}") 185 191 table.add_column("Username", style="cyan", no_wrap=True) 186 192 table.add_column("Feed URL", style="blue") ··· 209 215 print("Username\tDisplay Name\tEmail\tHomepage\tFeeds") 210 216 for user in config.users: 211 217 feeds_str = ",".join(str(feed) for feed in user.feeds) 212 - print(f"{user.username}\t{user.display_name or ''}\t{user.email or ''}\t{user.homepage or ''}\t{feeds_str}") 218 + print( 219 + f"{user.username}\t{user.display_name or ''}\t{user.email or ''}\t{user.homepage or ''}\t{feeds_str}" 220 + ) 213 221 214 222 215 223 def print_users_tsv_from_git(users: list[UserMetadata]) -> None: ··· 217 225 print("Username\tDisplay Name\tEmail\tHomepage\tFeeds") 218 226 for user in users: 219 227 feeds_str = ",".join(user.feeds) 220 - print(f"{user.username}\t{user.display_name or ''}\t{user.email or ''}\t{user.homepage or ''}\t{feeds_str}") 228 + print( 229 + f"{user.username}\t{user.display_name or ''}\t{user.email or ''}\t{user.homepage or ''}\t{feeds_str}" 230 + ) 221 231 222 232 223 233 def print_feeds_tsv(config: ThicketConfig, username: Optional[str] = None) -> None: ··· 225 235 print("Username\tFeed URL\tStatus") 226 236 users = [config.find_user(username)] if username else config.users 227 237 users = [u for u in users if u is not None] 228 - 238 + 229 239 for user in users: 230 240 for feed in user.feeds: 231 241 print(f"{user.username}\t{feed}\tActive") 232 242 233 243 234 - def print_feeds_tsv_from_git(git_store: GitStore, username: Optional[str] = None) -> None: 244 + def print_feeds_tsv_from_git( 245 + git_store: GitStore, username: Optional[str] = None 246 + ) -> None: 235 247 """Print feeds from git repository in TSV format.""" 236 248 print("Username\tFeed URL\tStatus") 237 - 249 + 238 250 if username: 239 251 user = git_store.get_user(username) 240 252 users = [user] if user else [] 241 253 else: 242 254 index = git_store._load_index() 243 255 users = list(index.users.values()) 244 - 256 + 245 257 for user in users: 246 258 for feed in user.feeds: 247 259 print(f"{user.username}\t{feed}\tActive") ··· 250 262 def print_entries_tsv(entries_by_user: list[list], usernames: list[str]) -> None: 251 263 """Print entries in TSV format.""" 252 264 print("User\tAtom ID\tTitle\tUpdated\tURL") 253 - 265 + 254 266 # Combine all entries with usernames 255 267 all_entries = [] 256 268 for entries, username in zip(entries_by_user, usernames): 257 269 for entry in entries: 258 270 all_entries.append((username, entry)) 259 - 271 + 260 272 # Sort by updated time (newest first) 261 273 all_entries.sort(key=lambda x: x[1].updated, reverse=True) 262 - 274 + 263 275 for username, entry in all_entries: 264 276 # Format updated time 265 277 updated_str = entry.updated.strftime("%Y-%m-%d %H:%M") 266 - 278 + 267 279 # Escape tabs and newlines in title to preserve TSV format 268 - title = entry.title.replace('\t', ' ').replace('\n', ' ').replace('\r', ' ') 269 - 280 + title = entry.title.replace("\t", " ").replace("\n", " ").replace("\r", " ") 281 + 270 282 print(f"{username}\t{entry.id}\t{title}\t{updated_str}\t{entry.link}")
+84 -55
src/thicket/core/feed_parser.py
··· 19 19 """Initialize the feed parser.""" 20 20 self.user_agent = user_agent 21 21 self.allowed_tags = [ 22 - "a", "abbr", "acronym", "b", "blockquote", "br", "code", "em", 23 - "i", "li", "ol", "p", "pre", "strong", "ul", "h1", "h2", "h3", 24 - "h4", "h5", "h6", "img", "div", "span", 22 + "a", 23 + "abbr", 24 + "acronym", 25 + "b", 26 + "blockquote", 27 + "br", 28 + "code", 29 + "em", 30 + "i", 31 + "li", 32 + "ol", 33 + "p", 34 + "pre", 35 + "strong", 36 + "ul", 37 + "h1", 38 + "h2", 39 + "h3", 40 + "h4", 41 + "h5", 42 + "h6", 43 + "img", 44 + "div", 45 + "span", 25 46 ] 26 47 self.allowed_attributes = { 27 48 "a": ["href", "title"], ··· 43 64 response.raise_for_status() 44 65 return response.text 45 66 46 - def parse_feed(self, content: str, source_url: Optional[HttpUrl] = None) -> tuple[FeedMetadata, list[AtomEntry]]: 67 + def parse_feed( 68 + self, content: str, source_url: Optional[HttpUrl] = None 69 + ) -> tuple[FeedMetadata, list[AtomEntry]]: 47 70 """Parse feed content and return metadata and entries.""" 48 71 parsed = feedparser.parse(content) 49 72 ··· 74 97 author_email = None 75 98 author_uri = None 76 99 77 - if hasattr(feed, 'author_detail'): 78 - author_name = feed.author_detail.get('name') 79 - author_email = feed.author_detail.get('email') 80 - author_uri = feed.author_detail.get('href') 81 - elif hasattr(feed, 'author'): 100 + if hasattr(feed, "author_detail"): 101 + author_name = feed.author_detail.get("name") 102 + author_email = feed.author_detail.get("email") 103 + author_uri = feed.author_detail.get("href") 104 + elif hasattr(feed, "author"): 82 105 author_name = feed.author 83 106 84 107 # Parse managing editor for RSS feeds 85 - if not author_email and hasattr(feed, 'managingEditor'): 108 + if not author_email and hasattr(feed, "managingEditor"): 86 109 author_email = feed.managingEditor 87 110 88 111 # Parse feed link 89 112 feed_link = None 90 - if hasattr(feed, 'link'): 113 + if hasattr(feed, "link"): 91 114 try: 92 115 feed_link = HttpUrl(feed.link) 93 116 except ValidationError: ··· 98 121 icon = None 99 122 image_url = None 100 123 101 - if hasattr(feed, 'image'): 124 + if hasattr(feed, "image"): 102 125 try: 103 - image_url = HttpUrl(feed.image.get('href', feed.image.get('url', ''))) 126 + image_url = HttpUrl(feed.image.get("href", feed.image.get("url", ""))) 104 127 except (ValidationError, AttributeError): 105 128 pass 106 129 107 - if hasattr(feed, 'icon'): 130 + if hasattr(feed, "icon"): 108 131 try: 109 132 icon = HttpUrl(feed.icon) 110 133 except ValidationError: 111 134 pass 112 135 113 - if hasattr(feed, 'logo'): 136 + if hasattr(feed, "logo"): 114 137 try: 115 138 logo = HttpUrl(feed.logo) 116 139 except ValidationError: 117 140 pass 118 141 119 142 return FeedMetadata( 120 - title=getattr(feed, 'title', None), 143 + title=getattr(feed, "title", None), 121 144 author_name=author_name, 122 145 author_email=author_email, 123 146 author_uri=HttpUrl(author_uri) if author_uri else None, ··· 125 148 logo=logo, 126 149 icon=icon, 127 150 image_url=image_url, 128 - description=getattr(feed, 'description', None), 151 + description=getattr(feed, "description", None), 129 152 ) 130 153 131 - def _normalize_entry(self, entry: feedparser.FeedParserDict, source_url: Optional[HttpUrl] = None) -> AtomEntry: 154 + def _normalize_entry( 155 + self, entry: feedparser.FeedParserDict, source_url: Optional[HttpUrl] = None 156 + ) -> AtomEntry: 132 157 """Normalize an entry to Atom format.""" 133 158 # Parse timestamps 134 - updated = self._parse_timestamp(entry.get('updated_parsed') or entry.get('published_parsed')) 135 - published = self._parse_timestamp(entry.get('published_parsed')) 159 + updated = self._parse_timestamp( 160 + entry.get("updated_parsed") or entry.get("published_parsed") 161 + ) 162 + published = self._parse_timestamp(entry.get("published_parsed")) 136 163 137 164 # Parse content 138 165 content = self._extract_content(entry) ··· 143 170 144 171 # Parse categories/tags 145 172 categories = [] 146 - if hasattr(entry, 'tags'): 147 - categories = [tag.get('term', '') for tag in entry.tags if tag.get('term')] 173 + if hasattr(entry, "tags"): 174 + categories = [tag.get("term", "") for tag in entry.tags if tag.get("term")] 148 175 149 176 # Sanitize HTML content 150 177 if content: 151 178 content = self._sanitize_html(content) 152 179 153 - summary = entry.get('summary', '') 180 + summary = entry.get("summary", "") 154 181 if summary: 155 182 summary = self._sanitize_html(summary) 156 183 157 184 return AtomEntry( 158 - id=entry.get('id', entry.get('link', '')), 159 - title=entry.get('title', ''), 160 - link=HttpUrl(entry.get('link', '')), 185 + id=entry.get("id", entry.get("link", "")), 186 + title=entry.get("title", ""), 187 + link=HttpUrl(entry.get("link", "")), 161 188 updated=updated, 162 189 published=published, 163 190 summary=summary or None, ··· 165 192 content_type=content_type, 166 193 author=author, 167 194 categories=categories, 168 - rights=entry.get('rights', None), 195 + rights=entry.get("rights", None), 169 196 source=str(source_url) if source_url else None, 170 197 ) 171 198 ··· 178 205 def _extract_content(self, entry: feedparser.FeedParserDict) -> Optional[str]: 179 206 """Extract the best content from an entry.""" 180 207 # Prefer content over summary 181 - if hasattr(entry, 'content') and entry.content: 208 + if hasattr(entry, "content") and entry.content: 182 209 # Find the best content (prefer text/html, then text/plain) 183 210 for content_item in entry.content: 184 - if content_item.get('type') in ['text/html', 'html']: 185 - return content_item.get('value', '') 186 - elif content_item.get('type') in ['text/plain', 'text']: 187 - return content_item.get('value', '') 211 + if content_item.get("type") in ["text/html", "html"]: 212 + return content_item.get("value", "") 213 + elif content_item.get("type") in ["text/plain", "text"]: 214 + return content_item.get("value", "") 188 215 # Fallback to first content item 189 - return entry.content[0].get('value', '') 216 + return entry.content[0].get("value", "") 190 217 191 218 # Fallback to summary 192 - return entry.get('summary', '') 219 + return entry.get("summary", "") 193 220 194 221 def _extract_content_type(self, entry: feedparser.FeedParserDict) -> str: 195 222 """Extract content type from entry.""" 196 - if hasattr(entry, 'content') and entry.content: 197 - content_type = entry.content[0].get('type', 'html') 223 + if hasattr(entry, "content") and entry.content: 224 + content_type = entry.content[0].get("type", "html") 198 225 # Normalize content type 199 - if content_type in ['text/html', 'html']: 200 - return 'html' 201 - elif content_type in ['text/plain', 'text']: 202 - return 'text' 203 - elif content_type == 'xhtml': 204 - return 'xhtml' 205 - return 'html' 226 + if content_type in ["text/html", "html"]: 227 + return "html" 228 + elif content_type in ["text/plain", "text"]: 229 + return "text" 230 + elif content_type == "xhtml": 231 + return "xhtml" 232 + return "html" 206 233 207 234 def _extract_author(self, entry: feedparser.FeedParserDict) -> Optional[dict]: 208 235 """Extract author information from entry.""" 209 236 author = {} 210 237 211 - if hasattr(entry, 'author_detail'): 212 - author.update({ 213 - 'name': entry.author_detail.get('name'), 214 - 'email': entry.author_detail.get('email'), 215 - 'uri': entry.author_detail.get('href'), 216 - }) 217 - elif hasattr(entry, 'author'): 218 - author['name'] = entry.author 238 + if hasattr(entry, "author_detail"): 239 + author.update( 240 + { 241 + "name": entry.author_detail.get("name"), 242 + "email": entry.author_detail.get("email"), 243 + "uri": entry.author_detail.get("href"), 244 + } 245 + ) 246 + elif hasattr(entry, "author"): 247 + author["name"] = entry.author 219 248 220 249 return author if author else None 221 250 ··· 236 265 # Start with the path component 237 266 if parsed.path: 238 267 # Remove leading slash and replace problematic characters 239 - safe_id = parsed.path.lstrip('/').replace('/', '_').replace('\\', '_') 268 + safe_id = parsed.path.lstrip("/").replace("/", "_").replace("\\", "_") 240 269 else: 241 270 # Use the entire ID as fallback 242 271 safe_id = entry_id ··· 244 273 # Replace problematic characters 245 274 safe_chars = [] 246 275 for char in safe_id: 247 - if char.isalnum() or char in '-_.': 276 + if char.isalnum() or char in "-_.": 248 277 safe_chars.append(char) 249 278 else: 250 - safe_chars.append('_') 279 + safe_chars.append("_") 251 280 252 - safe_id = ''.join(safe_chars) 281 + safe_id = "".join(safe_chars) 253 282 254 283 # Ensure it's not too long (max 200 chars) 255 284 if len(safe_id) > 200:
+45 -18
src/thicket/core/git_store.py
··· 53 53 """Save the index to index.json.""" 54 54 index_path = self.repo_path / "index.json" 55 55 with open(index_path, "w") as f: 56 - json.dump(index.model_dump(mode="json", exclude_none=True), f, indent=2, default=str) 56 + json.dump( 57 + index.model_dump(mode="json", exclude_none=True), 58 + f, 59 + indent=2, 60 + default=str, 61 + ) 57 62 58 63 def _load_index(self) -> GitStoreIndex: 59 64 """Load the index from index.json.""" ··· 86 91 87 92 return DuplicateMap(**data) 88 93 89 - def add_user(self, username: str, display_name: Optional[str] = None, 90 - email: Optional[str] = None, homepage: Optional[str] = None, 91 - icon: Optional[str] = None, feeds: Optional[list[str]] = None) -> UserMetadata: 94 + def add_user( 95 + self, 96 + username: str, 97 + display_name: Optional[str] = None, 98 + email: Optional[str] = None, 99 + homepage: Optional[str] = None, 100 + icon: Optional[str] = None, 101 + feeds: Optional[list[str]] = None, 102 + ) -> UserMetadata: 92 103 """Add a new user to the Git store.""" 93 104 index = self._load_index() 94 105 ··· 108 119 created=datetime.now(), 109 120 last_updated=datetime.now(), 110 121 ) 111 - 112 122 113 123 # Update index 114 124 index.add_user(user_metadata) ··· 136 146 137 147 user.update_timestamp() 138 148 139 - 140 149 # Update index 141 150 index.add_user(user) 142 151 self._save_index(index) ··· 151 160 152 161 # Sanitize entry ID for filename 153 162 from .feed_parser import FeedParser 163 + 154 164 parser = FeedParser() 155 165 safe_id = parser.sanitize_entry_id(entry.id) 156 166 ··· 163 173 164 174 # Save entry 165 175 with open(entry_path, "w") as f: 166 - json.dump(entry.model_dump(mode="json", exclude_none=True), f, indent=2, default=str) 176 + json.dump( 177 + entry.model_dump(mode="json", exclude_none=True), 178 + f, 179 + indent=2, 180 + default=str, 181 + ) 167 182 168 183 # Update user metadata if new entry 169 184 if not entry_exists: ··· 181 196 182 197 # Sanitize entry ID 183 198 from .feed_parser import FeedParser 199 + 184 200 parser = FeedParser() 185 201 safe_id = parser.sanitize_entry_id(entry_id) 186 202 ··· 193 209 194 210 return AtomEntry(**data) 195 211 196 - def list_entries(self, username: str, limit: Optional[int] = None) -> list[AtomEntry]: 212 + def list_entries( 213 + self, username: str, limit: Optional[int] = None 214 + ) -> list[AtomEntry]: 197 215 """List entries for a user.""" 198 216 user = self.get_user(username) 199 217 if not user: ··· 204 222 return [] 205 223 206 224 entries = [] 207 - entry_files = sorted(user_dir.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True) 208 - 225 + entry_files = sorted( 226 + user_dir.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True 227 + ) 209 228 210 229 if limit: 211 230 entry_files = entry_files[:limit] ··· 260 279 "total_entries": index.total_entries, 261 280 "total_duplicates": len(duplicates.duplicates), 262 281 "last_updated": index.last_updated, 263 - "repository_size": sum(f.stat().st_size for f in self.repo_path.rglob("*") if f.is_file()), 282 + "repository_size": sum( 283 + f.stat().st_size for f in self.repo_path.rglob("*") if f.is_file() 284 + ), 264 285 } 265 286 266 - def search_entries(self, query: str, username: Optional[str] = None, 267 - limit: Optional[int] = None) -> list[tuple[str, AtomEntry]]: 287 + def search_entries( 288 + self, query: str, username: Optional[str] = None, limit: Optional[int] = None 289 + ) -> list[tuple[str, AtomEntry]]: 268 290 """Search entries by content.""" 269 291 results = [] 270 292 ··· 288 310 entry = AtomEntry(**data) 289 311 290 312 # Simple text search in title, summary, and content 291 - searchable_text = " ".join(filter(None, [ 292 - entry.title, 293 - entry.summary or "", 294 - entry.content or "", 295 - ])).lower() 313 + searchable_text = " ".join( 314 + filter( 315 + None, 316 + [ 317 + entry.title, 318 + entry.summary or "", 319 + entry.content or "", 320 + ], 321 + ) 322 + ).lower() 296 323 297 324 if query.lower() in searchable_text: 298 325 results.append((user.username, entry))
+24
src/thicket/models/config.py
··· 31 31 git_store: Path 32 32 cache_dir: Path 33 33 users: list[UserConfig] = [] 34 + 35 + def find_user(self, username: str) -> Optional[UserConfig]: 36 + """Find a user by username.""" 37 + for user in self.users: 38 + if user.username == username: 39 + return user 40 + return None 41 + 42 + def add_user(self, user: UserConfig) -> bool: 43 + """Add a user to the configuration. Returns True if added, False if already exists.""" 44 + if self.find_user(user.username) is not None: 45 + return False 46 + self.users.append(user) 47 + return True 48 + 49 + def add_feed_to_user(self, username: str, feed_url: HttpUrl) -> bool: 50 + """Add a feed to an existing user. Returns True if added, False if user not found or feed already exists.""" 51 + user = self.find_user(username) 52 + if user is None: 53 + return False 54 + if feed_url in user.feeds: 55 + return False 56 + user.feeds.append(feed_url) 57 + return True
-2
src/thicket/models/feed.py
··· 29 29 categories: list[str] = [] 30 30 rights: Optional[str] = None # Copyright info 31 31 source: Optional[str] = None # Source feed URL 32 - links: list[str] = [] # URLs mentioned in this entry 33 - backlinks: list[str] = [] # Entry IDs that link to this entry 34 32 35 33 36 34 class FeedMetadata(BaseModel):
+1 -3
src/thicket/models/user.py
··· 38 38 class GitStoreIndex(BaseModel): 39 39 """Index of all users and their directories in the Git store.""" 40 40 41 - model_config = ConfigDict( 42 - json_encoders={datetime: lambda v: v.isoformat()} 43 - ) 41 + model_config = ConfigDict(json_encoders={datetime: lambda v: v.isoformat()}) 44 42 45 43 users: dict[str, UserMetadata] = {} # username -> UserMetadata 46 44 created: datetime
+9 -211
uv.lock
··· 1 1 version = 1 2 - revision = 2 2 + revision = 3 3 3 requires-python = ">=3.9" 4 4 resolution-markers = [ 5 5 "python_full_version >= '3.10'", ··· 79 79 sdist = { url = "https://files.pythonhosted.org/packages/76/9a/0e33f5054c54d349ea62c277191c020c2d6ef1d65ab2cb1993f91ec846d1/bleach-6.2.0.tar.gz", hash = "sha256:123e894118b8a599fd80d3ec1a6d4cc7ce4e5882b1317a7e1ba69b56e95f991f", size = 203083, upload-time = "2024-10-29T18:30:40.477Z" } 80 80 wheels = [ 81 81 { url = "https://files.pythonhosted.org/packages/fc/55/96142937f66150805c25c4d0f31ee4132fd33497753400734f9dfdcbdc66/bleach-6.2.0-py3-none-any.whl", hash = "sha256:117d9c6097a7c3d22fd578fcd8d35ff1e125df6736f554da4e432fdd63f31e5e", size = 163406, upload-time = "2024-10-29T18:30:38.186Z" }, 82 - ] 83 - 84 - [[package]] 85 - name = "blinker" 86 - version = "1.9.0" 87 - source = { registry = "https://pypi.org/simple" } 88 - sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } 89 - wheels = [ 90 - { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, 91 82 ] 92 83 93 84 [[package]] ··· 264 255 ] 265 256 266 257 [[package]] 267 - name = "flask" 268 - version = "3.1.1" 269 - source = { registry = "https://pypi.org/simple" } 270 - dependencies = [ 271 - { name = "blinker" }, 272 - { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, 273 - { name = "click", version = "8.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, 274 - { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, 275 - { name = "itsdangerous" }, 276 - { name = "jinja2" }, 277 - { name = "markupsafe" }, 278 - { name = "werkzeug" }, 279 - ] 280 - sdist = { url = "https://files.pythonhosted.org/packages/c0/de/e47735752347f4128bcf354e0da07ef311a78244eba9e3dc1d4a5ab21a98/flask-3.1.1.tar.gz", hash = "sha256:284c7b8f2f58cb737f0cf1c30fd7eaf0ccfcde196099d24ecede3fc2005aa59e", size = 753440, upload-time = "2025-05-13T15:01:17.447Z" } 281 - wheels = [ 282 - { url = "https://files.pythonhosted.org/packages/3d/68/9d4508e893976286d2ead7f8f571314af6c2037af34853a30fd769c02e9d/flask-3.1.1-py3-none-any.whl", hash = "sha256:07aae2bb5eaf77993ef57e357491839f5fd9f4dc281593a81a9e4d79a24f295c", size = 103305, upload-time = "2025-05-13T15:01:15.591Z" }, 283 - ] 284 - 285 - [[package]] 286 258 name = "gitdb" 287 259 version = "4.0.12" 288 260 source = { registry = "https://pypi.org/simple" } ··· 353 325 ] 354 326 355 327 [[package]] 356 - name = "importlib-metadata" 357 - version = "8.7.0" 358 - source = { registry = "https://pypi.org/simple" } 359 - dependencies = [ 360 - { name = "zipp", marker = "python_full_version < '3.10'" }, 361 - ] 362 - sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" } 363 - wheels = [ 364 - { url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656, upload-time = "2025-04-27T15:29:00.214Z" }, 365 - ] 366 - 367 - [[package]] 368 328 name = "iniconfig" 369 329 version = "2.1.0" 370 330 source = { registry = "https://pypi.org/simple" } ··· 374 334 ] 375 335 376 336 [[package]] 377 - name = "itsdangerous" 378 - version = "2.2.0" 379 - source = { registry = "https://pypi.org/simple" } 380 - sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } 381 - wheels = [ 382 - { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, 383 - ] 384 - 385 - [[package]] 386 - name = "jinja2" 387 - version = "3.1.6" 388 - source = { registry = "https://pypi.org/simple" } 389 - dependencies = [ 390 - { name = "markupsafe" }, 391 - ] 392 - sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } 393 - wheels = [ 394 - { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, 395 - ] 396 - 397 - [[package]] 398 - name = "linkify-it-py" 399 - version = "2.0.3" 400 - source = { registry = "https://pypi.org/simple" } 401 - dependencies = [ 402 - { name = "uc-micro-py" }, 403 - ] 404 - sdist = { url = "https://files.pythonhosted.org/packages/2a/ae/bb56c6828e4797ba5a4821eec7c43b8bf40f69cda4d4f5f8c8a2810ec96a/linkify-it-py-2.0.3.tar.gz", hash = "sha256:68cda27e162e9215c17d786649d1da0021a451bdc436ef9e0fa0ba5234b9b048", size = 27946, upload-time = "2024-02-04T14:48:04.179Z" } 405 - wheels = [ 406 - { url = "https://files.pythonhosted.org/packages/04/1e/b832de447dee8b582cac175871d2f6c3d5077cc56d5575cadba1fd1cccfa/linkify_it_py-2.0.3-py3-none-any.whl", hash = "sha256:6bcbc417b0ac14323382aef5c5192c0075bf8a9d6b41820a2b66371eac6b6d79", size = 19820, upload-time = "2024-02-04T14:48:02.496Z" }, 407 - ] 408 - 409 - [[package]] 410 337 name = "markdown-it-py" 411 338 version = "3.0.0" 412 339 source = { registry = "https://pypi.org/simple" } ··· 416 343 sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } 417 344 wheels = [ 418 345 { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, 419 - ] 420 - 421 - [package.optional-dependencies] 422 - linkify = [ 423 - { name = "linkify-it-py" }, 424 - ] 425 - plugins = [ 426 - { name = "mdit-py-plugins" }, 427 - ] 428 - 429 - [[package]] 430 - name = "markupsafe" 431 - version = "3.0.2" 432 - source = { registry = "https://pypi.org/simple" } 433 - sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" } 434 - wheels = [ 435 - { url = "https://files.pythonhosted.org/packages/04/90/d08277ce111dd22f77149fd1a5d4653eeb3b3eaacbdfcbae5afb2600eebd/MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8", size = 14357, upload-time = "2024-10-18T15:20:51.44Z" }, 436 - { url = "https://files.pythonhosted.org/packages/04/e1/6e2194baeae0bca1fae6629dc0cbbb968d4d941469cbab11a3872edff374/MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158", size = 12393, upload-time = "2024-10-18T15:20:52.426Z" }, 437 - { url = "https://files.pythonhosted.org/packages/1d/69/35fa85a8ece0a437493dc61ce0bb6d459dcba482c34197e3efc829aa357f/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579", size = 21732, upload-time = "2024-10-18T15:20:53.578Z" }, 438 - { url = "https://files.pythonhosted.org/packages/22/35/137da042dfb4720b638d2937c38a9c2df83fe32d20e8c8f3185dbfef05f7/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d", size = 20866, upload-time = "2024-10-18T15:20:55.06Z" }, 439 - { url = "https://files.pythonhosted.org/packages/29/28/6d029a903727a1b62edb51863232152fd335d602def598dade38996887f0/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb", size = 20964, upload-time = "2024-10-18T15:20:55.906Z" }, 440 - { url = "https://files.pythonhosted.org/packages/cc/cd/07438f95f83e8bc028279909d9c9bd39e24149b0d60053a97b2bc4f8aa51/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b", size = 21977, upload-time = "2024-10-18T15:20:57.189Z" }, 441 - { url = "https://files.pythonhosted.org/packages/29/01/84b57395b4cc062f9c4c55ce0df7d3108ca32397299d9df00fedd9117d3d/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c", size = 21366, upload-time = "2024-10-18T15:20:58.235Z" }, 442 - { url = "https://files.pythonhosted.org/packages/bd/6e/61ebf08d8940553afff20d1fb1ba7294b6f8d279df9fd0c0db911b4bbcfd/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171", size = 21091, upload-time = "2024-10-18T15:20:59.235Z" }, 443 - { url = "https://files.pythonhosted.org/packages/11/23/ffbf53694e8c94ebd1e7e491de185124277964344733c45481f32ede2499/MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50", size = 15065, upload-time = "2024-10-18T15:21:00.307Z" }, 444 - { url = "https://files.pythonhosted.org/packages/44/06/e7175d06dd6e9172d4a69a72592cb3f7a996a9c396eee29082826449bbc3/MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a", size = 15514, upload-time = "2024-10-18T15:21:01.122Z" }, 445 - { url = "https://files.pythonhosted.org/packages/6b/28/bbf83e3f76936960b850435576dd5e67034e200469571be53f69174a2dfd/MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d", size = 14353, upload-time = "2024-10-18T15:21:02.187Z" }, 446 - { url = "https://files.pythonhosted.org/packages/6c/30/316d194b093cde57d448a4c3209f22e3046c5bb2fb0820b118292b334be7/MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93", size = 12392, upload-time = "2024-10-18T15:21:02.941Z" }, 447 - { url = "https://files.pythonhosted.org/packages/f2/96/9cdafba8445d3a53cae530aaf83c38ec64c4d5427d975c974084af5bc5d2/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832", size = 23984, upload-time = "2024-10-18T15:21:03.953Z" }, 448 - { url = "https://files.pythonhosted.org/packages/f1/a4/aefb044a2cd8d7334c8a47d3fb2c9f328ac48cb349468cc31c20b539305f/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84", size = 23120, upload-time = "2024-10-18T15:21:06.495Z" }, 449 - { url = "https://files.pythonhosted.org/packages/8d/21/5e4851379f88f3fad1de30361db501300d4f07bcad047d3cb0449fc51f8c/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca", size = 23032, upload-time = "2024-10-18T15:21:07.295Z" }, 450 - { url = "https://files.pythonhosted.org/packages/00/7b/e92c64e079b2d0d7ddf69899c98842f3f9a60a1ae72657c89ce2655c999d/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798", size = 24057, upload-time = "2024-10-18T15:21:08.073Z" }, 451 - { url = "https://files.pythonhosted.org/packages/f9/ac/46f960ca323037caa0a10662ef97d0a4728e890334fc156b9f9e52bcc4ca/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e", size = 23359, upload-time = "2024-10-18T15:21:09.318Z" }, 452 - { url = "https://files.pythonhosted.org/packages/69/84/83439e16197337b8b14b6a5b9c2105fff81d42c2a7c5b58ac7b62ee2c3b1/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4", size = 23306, upload-time = "2024-10-18T15:21:10.185Z" }, 453 - { url = "https://files.pythonhosted.org/packages/9a/34/a15aa69f01e2181ed8d2b685c0d2f6655d5cca2c4db0ddea775e631918cd/MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d", size = 15094, upload-time = "2024-10-18T15:21:11.005Z" }, 454 - { url = "https://files.pythonhosted.org/packages/da/b8/3a3bd761922d416f3dc5d00bfbed11f66b1ab89a0c2b6e887240a30b0f6b/MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b", size = 15521, upload-time = "2024-10-18T15:21:12.911Z" }, 455 - { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274, upload-time = "2024-10-18T15:21:13.777Z" }, 456 - { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348, upload-time = "2024-10-18T15:21:14.822Z" }, 457 - { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149, upload-time = "2024-10-18T15:21:15.642Z" }, 458 - { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118, upload-time = "2024-10-18T15:21:17.133Z" }, 459 - { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993, upload-time = "2024-10-18T15:21:18.064Z" }, 460 - { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178, upload-time = "2024-10-18T15:21:18.859Z" }, 461 - { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319, upload-time = "2024-10-18T15:21:19.671Z" }, 462 - { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352, upload-time = "2024-10-18T15:21:20.971Z" }, 463 - { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097, upload-time = "2024-10-18T15:21:22.646Z" }, 464 - { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601, upload-time = "2024-10-18T15:21:23.499Z" }, 465 - { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload-time = "2024-10-18T15:21:24.577Z" }, 466 - { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload-time = "2024-10-18T15:21:25.382Z" }, 467 - { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload-time = "2024-10-18T15:21:26.199Z" }, 468 - { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload-time = "2024-10-18T15:21:27.029Z" }, 469 - { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload-time = "2024-10-18T15:21:27.846Z" }, 470 - { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload-time = "2024-10-18T15:21:28.744Z" }, 471 - { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload-time = "2024-10-18T15:21:29.545Z" }, 472 - { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload-time = "2024-10-18T15:21:30.366Z" }, 473 - { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" }, 474 - { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" }, 475 - { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload-time = "2024-10-18T15:21:33.625Z" }, 476 - { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload-time = "2024-10-18T15:21:34.611Z" }, 477 - { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload-time = "2024-10-18T15:21:35.398Z" }, 478 - { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload-time = "2024-10-18T15:21:36.231Z" }, 479 - { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload-time = "2024-10-18T15:21:37.073Z" }, 480 - { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload-time = "2024-10-18T15:21:37.932Z" }, 481 - { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload-time = "2024-10-18T15:21:39.799Z" }, 482 - { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload-time = "2024-10-18T15:21:40.813Z" }, 483 - { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" }, 484 - { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" }, 485 - { url = "https://files.pythonhosted.org/packages/a7/ea/9b1530c3fdeeca613faeb0fb5cbcf2389d816072fab72a71b45749ef6062/MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a", size = 14344, upload-time = "2024-10-18T15:21:43.721Z" }, 486 - { url = "https://files.pythonhosted.org/packages/4b/c2/fbdbfe48848e7112ab05e627e718e854d20192b674952d9042ebd8c9e5de/MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff", size = 12389, upload-time = "2024-10-18T15:21:44.666Z" }, 487 - { url = "https://files.pythonhosted.org/packages/f0/25/7a7c6e4dbd4f867d95d94ca15449e91e52856f6ed1905d58ef1de5e211d0/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13", size = 21607, upload-time = "2024-10-18T15:21:45.452Z" }, 488 - { url = "https://files.pythonhosted.org/packages/53/8f/f339c98a178f3c1e545622206b40986a4c3307fe39f70ccd3d9df9a9e425/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144", size = 20728, upload-time = "2024-10-18T15:21:46.295Z" }, 489 - { url = "https://files.pythonhosted.org/packages/1a/03/8496a1a78308456dbd50b23a385c69b41f2e9661c67ea1329849a598a8f9/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29", size = 20826, upload-time = "2024-10-18T15:21:47.134Z" }, 490 - { url = "https://files.pythonhosted.org/packages/e6/cf/0a490a4bd363048c3022f2f475c8c05582179bb179defcee4766fb3dcc18/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0", size = 21843, upload-time = "2024-10-18T15:21:48.334Z" }, 491 - { url = "https://files.pythonhosted.org/packages/19/a3/34187a78613920dfd3cdf68ef6ce5e99c4f3417f035694074beb8848cd77/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0", size = 21219, upload-time = "2024-10-18T15:21:49.587Z" }, 492 - { url = "https://files.pythonhosted.org/packages/17/d8/5811082f85bb88410ad7e452263af048d685669bbbfb7b595e8689152498/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178", size = 20946, upload-time = "2024-10-18T15:21:50.441Z" }, 493 - { url = "https://files.pythonhosted.org/packages/7c/31/bd635fb5989440d9365c5e3c47556cfea121c7803f5034ac843e8f37c2f2/MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f", size = 15063, upload-time = "2024-10-18T15:21:51.385Z" }, 494 - { url = "https://files.pythonhosted.org/packages/b3/73/085399401383ce949f727afec55ec3abd76648d04b9f22e1c0e99cb4bec3/MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a", size = 15506, upload-time = "2024-10-18T15:21:52.974Z" }, 495 - ] 496 - 497 - [[package]] 498 - name = "mdit-py-plugins" 499 - version = "0.4.2" 500 - source = { registry = "https://pypi.org/simple" } 501 - dependencies = [ 502 - { name = "markdown-it-py" }, 503 - ] 504 - sdist = { url = "https://files.pythonhosted.org/packages/19/03/a2ecab526543b152300717cf232bb4bb8605b6edb946c845016fa9c9c9fd/mdit_py_plugins-0.4.2.tar.gz", hash = "sha256:5f2cd1fdb606ddf152d37ec30e46101a60512bc0e5fa1a7002c36647b09e26b5", size = 43542, upload-time = "2024-09-09T20:27:49.564Z" } 505 - wheels = [ 506 - { url = "https://files.pythonhosted.org/packages/a7/f7/7782a043553ee469c1ff49cfa1cdace2d6bf99a1f333cf38676b3ddf30da/mdit_py_plugins-0.4.2-py3-none-any.whl", hash = "sha256:0c673c3f889399a33b95e88d2f0d111b4447bdfea7f237dab2d488f459835636", size = 55316, upload-time = "2024-09-09T20:27:48.397Z" }, 507 346 ] 508 347 509 348 [[package]] ··· 1028 867 ] 1029 868 1030 869 [[package]] 1031 - name = "textual" 1032 - version = "4.0.0" 1033 - source = { registry = "https://pypi.org/simple" } 1034 - dependencies = [ 1035 - { name = "markdown-it-py", extra = ["linkify", "plugins"] }, 1036 - { name = "platformdirs" }, 1037 - { name = "rich" }, 1038 - { name = "typing-extensions" }, 1039 - ] 1040 - sdist = { url = "https://files.pythonhosted.org/packages/f1/22/a2812ab1e5b0cb3a327a4ea79b430234c2271ba13462b989f435b40a247d/textual-4.0.0.tar.gz", hash = "sha256:1cab4ea3cfc0e47ae773405cdd6bc2a17ed76ff7b648379ac8017ea89c5ad28c", size = 1606128, upload-time = "2025-07-12T09:41:20.812Z" } 1041 - wheels = [ 1042 - { url = "https://files.pythonhosted.org/packages/d8/e4/ebe27c54d2534cc41d00ea1d78b783763f97abf3e3d6dd41e5536daa52a5/textual-4.0.0-py3-none-any.whl", hash = "sha256:214051640f890676a670aa7d29cd2a37d27cfe6b2cf866e9d5abc3b6c89c5800", size = 692382, upload-time = "2025-07-12T09:41:18.828Z" }, 1043 - ] 1044 - 1045 - [[package]] 1046 870 name = "thicket" 1047 871 source = { editable = "." } 1048 872 dependencies = [ 1049 873 { name = "bleach" }, 1050 874 { name = "email-validator" }, 1051 875 { name = "feedparser" }, 1052 - { name = "flask" }, 1053 876 { name = "gitpython" }, 1054 877 { name = "httpx" }, 1055 878 { name = "pendulum" }, ··· 1058 881 { name = "pydantic-settings" }, 1059 882 { name = "pyyaml" }, 1060 883 { name = "rich" }, 1061 - { name = "textual" }, 1062 884 { name = "typer" }, 1063 885 ] 1064 886 ··· 1073 895 { name = "types-pyyaml" }, 1074 896 ] 1075 897 898 + [package.dev-dependencies] 899 + dev = [ 900 + { name = "pytest" }, 901 + ] 902 + 1076 903 [package.metadata] 1077 904 requires-dist = [ 1078 905 { name = "black", marker = "extra == 'dev'", specifier = ">=24.0.0" }, 1079 906 { name = "bleach", specifier = ">=6.0.0" }, 1080 907 { name = "email-validator" }, 1081 908 { name = "feedparser", specifier = ">=6.0.11" }, 1082 - { name = "flask", specifier = ">=3.1.1" }, 1083 909 { name = "gitpython", specifier = ">=3.1.40" }, 1084 910 { name = "httpx", specifier = ">=0.28.0" }, 1085 911 { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.13.0" }, ··· 1093 919 { name = "pyyaml", specifier = ">=6.0.0" }, 1094 920 { name = "rich", specifier = ">=13.0.0" }, 1095 921 { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8.0" }, 1096 - { name = "textual", specifier = ">=4.0.0" }, 1097 922 { name = "typer", specifier = ">=0.15.0" }, 1098 923 { name = "types-pyyaml", marker = "extra == 'dev'", specifier = ">=6.0.0" }, 1099 924 ] 1100 925 provides-extras = ["dev"] 926 + 927 + [package.metadata.requires-dev] 928 + dev = [{ name = "pytest", specifier = ">=8.4.1" }] 1101 929 1102 930 [[package]] 1103 931 name = "tomli" ··· 1194 1022 ] 1195 1023 1196 1024 [[package]] 1197 - name = "uc-micro-py" 1198 - version = "1.0.3" 1199 - source = { registry = "https://pypi.org/simple" } 1200 - sdist = { url = "https://files.pythonhosted.org/packages/91/7a/146a99696aee0609e3712f2b44c6274566bc368dfe8375191278045186b8/uc-micro-py-1.0.3.tar.gz", hash = "sha256:d321b92cff673ec58027c04015fcaa8bb1e005478643ff4a500882eaab88c48a", size = 6043, upload-time = "2024-02-09T16:52:01.654Z" } 1201 - wheels = [ 1202 - { url = "https://files.pythonhosted.org/packages/37/87/1f677586e8ac487e29672e4b17455758fce261de06a0d086167bb760361a/uc_micro_py-1.0.3-py3-none-any.whl", hash = "sha256:db1dffff340817673d7b466ec86114a9dc0e9d4d9b5ba229d9d60e5c12600cd5", size = 6229, upload-time = "2024-02-09T16:52:00.371Z" }, 1203 - ] 1204 - 1205 - [[package]] 1206 1025 name = "webencodings" 1207 1026 version = "0.5.1" 1208 1027 source = { registry = "https://pypi.org/simple" } ··· 1210 1029 wheels = [ 1211 1030 { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" }, 1212 1031 ] 1213 - 1214 - [[package]] 1215 - name = "werkzeug" 1216 - version = "3.1.3" 1217 - source = { registry = "https://pypi.org/simple" } 1218 - dependencies = [ 1219 - { name = "markupsafe" }, 1220 - ] 1221 - sdist = { url = "https://files.pythonhosted.org/packages/9f/69/83029f1f6300c5fb2471d621ab06f6ec6b3324685a2ce0f9777fd4a8b71e/werkzeug-3.1.3.tar.gz", hash = "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746", size = 806925, upload-time = "2024-11-08T15:52:18.093Z" } 1222 - wheels = [ 1223 - { url = "https://files.pythonhosted.org/packages/52/24/ab44c871b0f07f491e5d2ad12c9bd7358e527510618cb1b803a88e986db1/werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e", size = 224498, upload-time = "2024-11-08T15:52:16.132Z" }, 1224 - ] 1225 - 1226 - [[package]] 1227 - name = "zipp" 1228 - version = "3.23.0" 1229 - source = { registry = "https://pypi.org/simple" } 1230 - sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } 1231 - wheels = [ 1232 - { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, 1233 - ]