this repo has no description
1
fork

Configure Feed

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

Add thread visualization with interactive D3 force graph

Major refactoring of the links system to store links/backlinks directly in entry JSON files
instead of separate metadata. Replaced the old index command with a new threads command
that visualizes conversation threads using both terminal and web interfaces.

Key changes:
- Add links and backlinks fields to AtomEntry model
- Rewrite links command to update individual entry files with extracted URLs
- Implement bidirectional link tracking (outbound links and inbound backlinks)
- Remove old index command and reference_parser module
- Create new threads command with:
- Thread detection using connected components algorithm
- Link type categorization (self/user/external references)
- Textual-based terminal UI for browsing threads
- Flask web server with D3.js force-directed graph visualization
- Enhanced cross-user attraction in force simulation for clearer conversation threads
- Add Flask and textual as dependencies

The web visualization includes user/link filtering, interactive tooltips, drag/zoom support,
and visual emphasis on cross-user conversations through stronger attraction forces and
thicker link lines.

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

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

+1568 -1075
+3 -1
pyproject.toml
··· 39 39 "bleach>=6.0.0", 40 40 "platformdirs>=4.0.0", 41 41 "pyyaml>=6.0.0", 42 - "email_validator" 42 + "email_validator", 43 + "textual>=4.0.0", 44 + "flask>=3.1.1", 43 45 ] 44 46 45 47 [project.optional-dependencies]
+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, index_cmd, info_cmd, init, links_cmd, list_cmd, sync 4 + from . import add, duplicates, info_cmd, init, links_cmd, list_cmd, sync, threads_cmd 5 5 6 - __all__ = ["add", "duplicates", "index_cmd", "info_cmd", "init", "links_cmd", "list_cmd", "sync"] 6 + __all__ = ["add", "duplicates", "info_cmd", "init", "links_cmd", "list_cmd", "sync", "threads_cmd"]
-427
src/thicket/cli/commands/index_cmd.py
··· 1 - """CLI command for building reference index from blog entries.""" 2 - 3 - import json 4 - from pathlib import Path 5 - from typing import Optional 6 - 7 - import typer 8 - from rich.console import Console 9 - from rich.progress import ( 10 - BarColumn, 11 - Progress, 12 - SpinnerColumn, 13 - TaskProgressColumn, 14 - TextColumn, 15 - ) 16 - from rich.table import Table 17 - 18 - from ...core.git_store import GitStore 19 - from ...core.reference_parser import ReferenceIndex, ReferenceParser 20 - from ..main import app 21 - from ..utils import get_tsv_mode, load_config 22 - 23 - console = Console() 24 - 25 - 26 - @app.command() 27 - def index( 28 - config_file: Optional[Path] = typer.Option( 29 - None, 30 - "--config", 31 - "-c", 32 - help="Path to configuration file", 33 - ), 34 - output_file: Optional[Path] = typer.Option( 35 - None, 36 - "--output", 37 - "-o", 38 - help="Path to output index file (default: updates links.json in git store)", 39 - ), 40 - verbose: bool = typer.Option( 41 - False, 42 - "--verbose", 43 - "-v", 44 - help="Show detailed progress information", 45 - ), 46 - ) -> None: 47 - """Build a reference index showing which blog entries reference others. 48 - 49 - This command analyzes all blog entries to detect cross-references between 50 - different blogs, creating an index that can be used to build threaded 51 - views of related content. 52 - 53 - Updates the unified links.json file with reference data. 54 - """ 55 - try: 56 - # Load configuration 57 - config = load_config(config_file) 58 - 59 - # Initialize Git store 60 - git_store = GitStore(config.git_store) 61 - 62 - # Initialize reference parser 63 - parser = ReferenceParser() 64 - 65 - # Build user domain mapping 66 - if verbose: 67 - console.print("Building user domain mapping...") 68 - user_domains = parser.build_user_domain_mapping(git_store) 69 - 70 - if verbose: 71 - console.print(f"Found {len(user_domains)} users with {sum(len(d) for d in user_domains.values())} total domains") 72 - 73 - # Initialize reference index 74 - ref_index = ReferenceIndex() 75 - ref_index.user_domains = user_domains 76 - 77 - # Get all users 78 - index = git_store._load_index() 79 - users = list(index.users.keys()) 80 - 81 - if not users: 82 - console.print("[yellow]No users found in Git store[/yellow]") 83 - raise typer.Exit(0) 84 - 85 - # Process all entries 86 - total_entries = 0 87 - total_references = 0 88 - all_references = [] 89 - 90 - with Progress( 91 - SpinnerColumn(), 92 - TextColumn("[progress.description]{task.description}"), 93 - BarColumn(), 94 - TaskProgressColumn(), 95 - console=console, 96 - ) as progress: 97 - 98 - # Count total entries first 99 - counting_task = progress.add_task("Counting entries...", total=len(users)) 100 - entry_counts = {} 101 - for username in users: 102 - entries = git_store.list_entries(username) 103 - entry_counts[username] = len(entries) 104 - total_entries += len(entries) 105 - progress.advance(counting_task) 106 - 107 - progress.remove_task(counting_task) 108 - 109 - # Process entries - extract references 110 - processing_task = progress.add_task( 111 - f"Extracting references from {total_entries} entries...", 112 - total=total_entries 113 - ) 114 - 115 - for username in users: 116 - entries = git_store.list_entries(username) 117 - 118 - for entry in entries: 119 - # Extract references from this entry 120 - references = parser.extract_references(entry, username, user_domains) 121 - all_references.extend(references) 122 - 123 - progress.advance(processing_task) 124 - 125 - if verbose and references: 126 - console.print(f" Found {len(references)} references in {username}:{entry.title[:50]}...") 127 - 128 - progress.remove_task(processing_task) 129 - 130 - # Resolve target_entry_ids for references 131 - if all_references: 132 - resolve_task = progress.add_task( 133 - f"Resolving {len(all_references)} references...", 134 - total=len(all_references) 135 - ) 136 - 137 - if verbose: 138 - console.print(f"Resolving target entry IDs for {len(all_references)} references...") 139 - 140 - resolved_references = parser.resolve_target_entry_ids(all_references, git_store) 141 - 142 - # Count resolved references 143 - resolved_count = sum(1 for ref in resolved_references if ref.target_entry_id is not None) 144 - if verbose: 145 - console.print(f"Resolved {resolved_count} out of {len(all_references)} references") 146 - 147 - # Add resolved references to index 148 - for ref in resolved_references: 149 - ref_index.add_reference(ref) 150 - total_references += 1 151 - progress.advance(resolve_task) 152 - 153 - progress.remove_task(resolve_task) 154 - 155 - # Determine output path 156 - if output_file: 157 - output_path = output_file 158 - else: 159 - output_path = config.git_store / "links.json" 160 - 161 - # Load existing links data or create new structure 162 - if output_path.exists() and not output_file: 163 - # Load existing unified structure 164 - with open(output_path) as f: 165 - existing_data = json.load(f) 166 - else: 167 - # Create new structure 168 - existing_data = { 169 - "links": {}, 170 - "reverse_mapping": {}, 171 - "user_domains": {} 172 - } 173 - 174 - # Update with reference data 175 - existing_data["references"] = ref_index.to_dict()["references"] 176 - existing_data["user_domains"] = {k: list(v) for k, v in user_domains.items()} 177 - 178 - # Save updated structure 179 - with open(output_path, "w") as f: 180 - json.dump(existing_data, f, indent=2, default=str) 181 - 182 - # Show summary 183 - if not get_tsv_mode(): 184 - console.print("\n[green]✓ Reference index built successfully[/green]") 185 - 186 - # Create summary table or TSV output 187 - if get_tsv_mode(): 188 - print("Metric\tCount") 189 - print(f"Total Users\t{len(users)}") 190 - print(f"Total Entries\t{total_entries}") 191 - print(f"Total References\t{total_references}") 192 - print(f"Outbound Refs\t{len(ref_index.outbound_refs)}") 193 - print(f"Inbound Refs\t{len(ref_index.inbound_refs)}") 194 - print(f"Output File\t{output_path}") 195 - else: 196 - table = Table(title="Reference Index Summary") 197 - table.add_column("Metric", style="cyan") 198 - table.add_column("Count", style="green") 199 - 200 - table.add_row("Total Users", str(len(users))) 201 - table.add_row("Total Entries", str(total_entries)) 202 - table.add_row("Total References", str(total_references)) 203 - table.add_row("Outbound Refs", str(len(ref_index.outbound_refs))) 204 - table.add_row("Inbound Refs", str(len(ref_index.inbound_refs))) 205 - table.add_row("Output File", str(output_path)) 206 - 207 - console.print(table) 208 - 209 - # Show some interesting statistics 210 - if total_references > 0: 211 - if not get_tsv_mode(): 212 - console.print("\n[bold]Reference Statistics:[/bold]") 213 - 214 - # Most referenced users 215 - target_counts = {} 216 - unresolved_domains = set() 217 - 218 - for ref in ref_index.references: 219 - if ref.target_username: 220 - target_counts[ref.target_username] = target_counts.get(ref.target_username, 0) + 1 221 - else: 222 - # Track unresolved domains 223 - from urllib.parse import urlparse 224 - domain = urlparse(ref.target_url).netloc.lower() 225 - unresolved_domains.add(domain) 226 - 227 - if target_counts: 228 - if get_tsv_mode(): 229 - print("Referenced User\tReference Count") 230 - for username, count in sorted(target_counts.items(), key=lambda x: x[1], reverse=True)[:5]: 231 - print(f"{username}\t{count}") 232 - else: 233 - console.print("\nMost referenced users:") 234 - for username, count in sorted(target_counts.items(), key=lambda x: x[1], reverse=True)[:5]: 235 - console.print(f" {username}: {count} references") 236 - 237 - if unresolved_domains and verbose: 238 - if get_tsv_mode(): 239 - print("Unresolved Domain\tCount") 240 - for domain in sorted(list(unresolved_domains)[:10]): 241 - print(f"{domain}\t1") 242 - if len(unresolved_domains) > 10: 243 - print(f"... and {len(unresolved_domains) - 10} more\t...") 244 - else: 245 - console.print(f"\nUnresolved domains: {len(unresolved_domains)}") 246 - for domain in sorted(list(unresolved_domains)[:10]): 247 - console.print(f" {domain}") 248 - if len(unresolved_domains) > 10: 249 - console.print(f" ... and {len(unresolved_domains) - 10} more") 250 - 251 - except Exception as e: 252 - console.print(f"[red]Error building reference index: {e}[/red]") 253 - if verbose: 254 - console.print_exception() 255 - raise typer.Exit(1) 256 - 257 - 258 - @app.command() 259 - def threads( 260 - config_file: Optional[Path] = typer.Option( 261 - None, 262 - "--config", 263 - "-c", 264 - help="Path to configuration file", 265 - ), 266 - index_file: Optional[Path] = typer.Option( 267 - None, 268 - "--index", 269 - "-i", 270 - help="Path to reference index file (default: links.json in git store)", 271 - ), 272 - username: Optional[str] = typer.Option( 273 - None, 274 - "--username", 275 - "-u", 276 - help="Show threads for specific username only", 277 - ), 278 - entry_id: Optional[str] = typer.Option( 279 - None, 280 - "--entry", 281 - "-e", 282 - help="Show thread for specific entry ID", 283 - ), 284 - min_size: int = typer.Option( 285 - 2, 286 - "--min-size", 287 - "-m", 288 - help="Minimum thread size to display", 289 - ), 290 - ) -> None: 291 - """Show threaded view of related blog entries. 292 - 293 - This command uses the reference index to show which blog entries 294 - are connected through cross-references, creating an email-style 295 - threaded view of the conversation. 296 - 297 - Reads reference data from the unified links.json file. 298 - """ 299 - try: 300 - # Load configuration 301 - config = load_config(config_file) 302 - 303 - # Determine index file path 304 - if index_file: 305 - index_path = index_file 306 - else: 307 - index_path = config.git_store / "links.json" 308 - 309 - if not index_path.exists(): 310 - console.print(f"[red]Links file not found: {index_path}[/red]") 311 - console.print("Run 'thicket links' and 'thicket index' first to build the reference index") 312 - raise typer.Exit(1) 313 - 314 - # Load unified data 315 - with open(index_path) as f: 316 - unified_data = json.load(f) 317 - 318 - # Check if references exist in the unified structure 319 - if "references" not in unified_data: 320 - console.print(f"[red]No references found in {index_path}[/red]") 321 - console.print("Run 'thicket index' first to build the reference index") 322 - raise typer.Exit(1) 323 - 324 - # Extract reference data and reconstruct ReferenceIndex 325 - ref_index = ReferenceIndex.from_dict({ 326 - "references": unified_data["references"], 327 - "user_domains": unified_data.get("user_domains", {}) 328 - }) 329 - 330 - # Initialize Git store to get entry details 331 - git_store = GitStore(config.git_store) 332 - 333 - if entry_id and username: 334 - # Show specific thread 335 - thread_members = ref_index.get_thread_members(username, entry_id) 336 - _display_thread(thread_members, ref_index, git_store, f"Thread for {username}:{entry_id}") 337 - 338 - elif username: 339 - # Show all threads involving this user 340 - user_index = git_store._load_index() 341 - user = user_index.get_user(username) 342 - if not user: 343 - console.print(f"[red]User not found: {username}[/red]") 344 - raise typer.Exit(1) 345 - 346 - entries = git_store.list_entries(username) 347 - threads_found = set() 348 - 349 - console.print(f"[bold]Threads involving {username}:[/bold]\n") 350 - 351 - for entry in entries: 352 - thread_members = ref_index.get_thread_members(username, entry.id) 353 - if len(thread_members) >= min_size: 354 - thread_key = tuple(sorted(thread_members)) 355 - if thread_key not in threads_found: 356 - threads_found.add(thread_key) 357 - _display_thread(thread_members, ref_index, git_store, f"Thread #{len(threads_found)}") 358 - 359 - else: 360 - # Show all threads 361 - console.print("[bold]All conversation threads:[/bold]\n") 362 - 363 - all_threads = set() 364 - processed_entries = set() 365 - 366 - # Get all entries 367 - user_index = git_store._load_index() 368 - for username in user_index.users.keys(): 369 - entries = git_store.list_entries(username) 370 - for entry in entries: 371 - entry_key = (username, entry.id) 372 - if entry_key in processed_entries: 373 - continue 374 - 375 - thread_members = ref_index.get_thread_members(username, entry.id) 376 - if len(thread_members) >= min_size: 377 - thread_key = tuple(sorted(thread_members)) 378 - if thread_key not in all_threads: 379 - all_threads.add(thread_key) 380 - _display_thread(thread_members, ref_index, git_store, f"Thread #{len(all_threads)}") 381 - 382 - # Mark all members as processed 383 - for member in thread_members: 384 - processed_entries.add(member) 385 - 386 - if not all_threads: 387 - console.print("[yellow]No conversation threads found[/yellow]") 388 - console.print(f"(minimum thread size: {min_size})") 389 - 390 - except Exception as e: 391 - console.print(f"[red]Error showing threads: {e}[/red]") 392 - raise typer.Exit(1) 393 - 394 - 395 - def _display_thread(thread_members, ref_index, git_store, title): 396 - """Display a single conversation thread.""" 397 - console.print(f"[bold cyan]{title}[/bold cyan]") 398 - console.print(f"Thread size: {len(thread_members)} entries") 399 - 400 - # Get entry details for each member 401 - thread_entries = [] 402 - for username, entry_id in thread_members: 403 - entry = git_store.get_entry(username, entry_id) 404 - if entry: 405 - thread_entries.append((username, entry)) 406 - 407 - # Sort by publication date 408 - thread_entries.sort(key=lambda x: x[1].published or x[1].updated) 409 - 410 - # Display entries 411 - for i, (username, entry) in enumerate(thread_entries): 412 - prefix = "├─" if i < len(thread_entries) - 1 else "└─" 413 - 414 - # Get references for this entry 415 - outbound = ref_index.get_outbound_refs(username, entry.id) 416 - inbound = ref_index.get_inbound_refs(username, entry.id) 417 - 418 - ref_info = "" 419 - if outbound or inbound: 420 - ref_info = f" ({len(outbound)} out, {len(inbound)} in)" 421 - 422 - console.print(f" {prefix} [{username}] {entry.title[:60]}...{ref_info}") 423 - 424 - if entry.published: 425 - console.print(f" Published: {entry.published.strftime('%Y-%m-%d')}") 426 - 427 - console.print() # Empty line after each thread
+33 -52
src/thicket/cli/commands/info_cmd.py
··· 1 1 """CLI command for displaying detailed information about a specific atom entry.""" 2 2 3 - import json 4 3 from pathlib import Path 5 4 from typing import Optional 6 5 ··· 11 10 from rich.text import Text 12 11 13 12 from ...core.git_store import GitStore 14 - from ...core.reference_parser import ReferenceIndex 15 13 from ..main import app 16 14 from ..utils import load_config, get_tsv_mode 17 15 ··· 105 103 console.print(f"[red]Entry with {'URL' if is_url else 'atom ID'} '{identifier}' not found in any user's entries[/red]") 106 104 raise typer.Exit(1) 107 105 108 - # Load reference index if available 109 - links_path = config.git_store / "links.json" 110 - ref_index = None 111 - if links_path.exists(): 112 - with open(links_path) as f: 113 - unified_data = json.load(f) 114 - 115 - # Check if references exist in the unified structure 116 - if "references" in unified_data: 117 - ref_index = ReferenceIndex.from_dict({ 118 - "references": unified_data["references"], 119 - "user_domains": unified_data.get("user_domains", {}) 120 - }) 121 - 122 106 # Display information 123 107 if get_tsv_mode(): 124 - _display_entry_info_tsv(entry, found_username, ref_index, show_content) 108 + _display_entry_info_tsv(entry, found_username, show_content) 125 109 else: 126 110 _display_entry_info(entry, found_username) 127 111 128 - if ref_index: 129 - _display_link_info(entry, found_username, ref_index) 130 - else: 131 - console.print("\n[yellow]No reference index found. Run 'thicket links' and 'thicket index' to build cross-reference data.[/yellow]") 112 + # Display links and backlinks from entry fields 113 + _display_link_info(entry, found_username, git_store) 132 114 133 115 # Optionally display content 134 116 if show_content and entry.content: ··· 193 175 console.print(panel) 194 176 195 177 196 - def _display_link_info(entry, username: str, ref_index: ReferenceIndex) -> None: 178 + def _display_link_info(entry, username: str, git_store: GitStore) -> None: 197 179 """Display inbound and outbound link information.""" 198 180 199 - # Get links 200 - outbound_refs = ref_index.get_outbound_refs(username, entry.id) 201 - inbound_refs = ref_index.get_inbound_refs(username, entry.id) 181 + # Get links from entry fields 182 + outbound_links = getattr(entry, 'links', []) 183 + backlinks = getattr(entry, 'backlinks', []) 202 184 203 - if not outbound_refs and not inbound_refs: 185 + if not outbound_links and not backlinks: 204 186 console.print("\n[dim]No cross-references found for this entry.[/dim]") 205 187 return 206 188 207 189 # Create links table 208 190 links_table = Table(title="Cross-References") 209 191 links_table.add_column("Direction", style="cyan", width=10) 210 - links_table.add_column("Target/Source", style="green", width=20) 211 - links_table.add_column("URL", style="blue", width=50) 192 + links_table.add_column("Target/Source", style="green", width=30) 193 + links_table.add_column("URL/ID", style="blue", width=60) 212 194 213 - # Add outbound references 214 - for ref in outbound_refs: 215 - target_info = f"{ref.target_username}:{ref.target_entry_id}" if ref.target_username and ref.target_entry_id else "External" 216 - links_table.add_row("→ Out", target_info, ref.target_url) 195 + # Add outbound links 196 + for link in outbound_links: 197 + links_table.add_row("→ Out", "External/Other", link) 217 198 218 - # Add inbound references 219 - for ref in inbound_refs: 220 - source_info = f"{ref.source_username}:{ref.source_entry_id}" 221 - links_table.add_row("← In", source_info, ref.target_url) 199 + # Add backlinks (inbound references) 200 + for backlink_id in backlinks: 201 + # Try to find which user this entry belongs to 202 + source_info = backlink_id 203 + # Could enhance this by looking up the actual entry to get username 204 + links_table.add_row("← In", "Entry", source_info) 222 205 223 206 console.print() 224 207 console.print(links_table) 225 208 226 209 # Summary 227 - console.print(f"\n[bold]Summary:[/bold] {len(outbound_refs)} outbound, {len(inbound_refs)} inbound references") 210 + console.print(f"\n[bold]Summary:[/bold] {len(outbound_links)} outbound links, {len(backlinks)} inbound backlinks") 228 211 229 212 230 213 def _display_content(content: str) -> None: ··· 246 229 console.print(panel) 247 230 248 231 249 - def _display_entry_info_tsv(entry, username: str, ref_index: Optional[ReferenceIndex], show_content: bool) -> None: 232 + def _display_entry_info_tsv(entry, username: str, show_content: bool) -> None: 250 233 """Display entry information in TSV format.""" 251 234 252 235 # Basic info ··· 287 270 if entry.source: 288 271 print(f"Source Feed\t{entry.source}") 289 272 290 - # Add reference info if available 291 - if ref_index: 292 - outbound_refs = ref_index.get_outbound_refs(username, entry.id) 293 - inbound_refs = ref_index.get_inbound_refs(username, entry.id) 294 - 295 - print(f"Outbound References\t{len(outbound_refs)}") 296 - print(f"Inbound References\t{len(inbound_refs)}") 273 + # Add links info from entry fields 274 + outbound_links = getattr(entry, 'links', []) 275 + backlinks = getattr(entry, 'backlinks', []) 276 + 277 + if outbound_links or backlinks: 278 + print(f"Outbound Links\t{len(outbound_links)}") 279 + print(f"Backlinks\t{len(backlinks)}") 297 280 298 - # Show each reference 299 - for ref in outbound_refs: 300 - target_info = f"{ref.target_username}:{ref.target_entry_id}" if ref.target_username and ref.target_entry_id else "External" 301 - print(f"Outbound Reference\t{target_info}\t{ref.target_url}") 281 + # Show each link 282 + for link in outbound_links: 283 + print(f"→ Link\t{link}") 302 284 303 - for ref in inbound_refs: 304 - source_info = f"{ref.source_username}:{ref.source_entry_id}" 305 - print(f"Inbound Reference\t{source_info}\t{ref.target_url}") 285 + for backlink_id in backlinks: 286 + print(f"← Backlink\t{backlink_id}") 306 287 307 288 # Show content if requested 308 289 if show_content and entry.content:
+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, index_cmd, info_cmd, init, links_cmd, list_cmd, sync 50 + from .commands import add, duplicates, info_cmd, init, links_cmd, list_cmd, sync, threads_cmd 51 51 52 52 if __name__ == "__main__": 53 53 app()
-301
src/thicket/core/reference_parser.py
··· 1 - """Reference detection and parsing for blog entries.""" 2 - 3 - import re 4 - from typing import Optional 5 - from urllib.parse import urlparse 6 - 7 - from ..models import AtomEntry 8 - 9 - 10 - class BlogReference: 11 - """Represents a reference from one blog entry to another.""" 12 - 13 - def __init__( 14 - self, 15 - source_entry_id: str, 16 - source_username: str, 17 - target_url: str, 18 - target_username: Optional[str] = None, 19 - target_entry_id: Optional[str] = None, 20 - ): 21 - self.source_entry_id = source_entry_id 22 - self.source_username = source_username 23 - self.target_url = target_url 24 - self.target_username = target_username 25 - self.target_entry_id = target_entry_id 26 - 27 - def to_dict(self) -> dict: 28 - """Convert to dictionary for JSON serialization.""" 29 - result = { 30 - "source_entry_id": self.source_entry_id, 31 - "source_username": self.source_username, 32 - "target_url": self.target_url, 33 - } 34 - 35 - # Only include optional fields if they are not None 36 - if self.target_username is not None: 37 - result["target_username"] = self.target_username 38 - if self.target_entry_id is not None: 39 - result["target_entry_id"] = self.target_entry_id 40 - 41 - return result 42 - 43 - @classmethod 44 - def from_dict(cls, data: dict) -> "BlogReference": 45 - """Create from dictionary.""" 46 - return cls( 47 - source_entry_id=data["source_entry_id"], 48 - source_username=data["source_username"], 49 - target_url=data["target_url"], 50 - target_username=data.get("target_username"), 51 - target_entry_id=data.get("target_entry_id"), 52 - ) 53 - 54 - 55 - class ReferenceIndex: 56 - """Index of blog-to-blog references for creating threaded views.""" 57 - 58 - def __init__(self): 59 - self.references: list[BlogReference] = [] 60 - self.outbound_refs: dict[ 61 - str, list[BlogReference] 62 - ] = {} # entry_id -> outbound refs 63 - self.inbound_refs: dict[ 64 - str, list[BlogReference] 65 - ] = {} # entry_id -> inbound refs 66 - self.user_domains: dict[str, set[str]] = {} # username -> set of domains 67 - 68 - def add_reference(self, ref: BlogReference) -> None: 69 - """Add a reference to the index.""" 70 - self.references.append(ref) 71 - 72 - # Update outbound references 73 - source_key = f"{ref.source_username}:{ref.source_entry_id}" 74 - if source_key not in self.outbound_refs: 75 - self.outbound_refs[source_key] = [] 76 - self.outbound_refs[source_key].append(ref) 77 - 78 - # Update inbound references if we can identify the target 79 - if ref.target_username and ref.target_entry_id: 80 - target_key = f"{ref.target_username}:{ref.target_entry_id}" 81 - if target_key not in self.inbound_refs: 82 - self.inbound_refs[target_key] = [] 83 - self.inbound_refs[target_key].append(ref) 84 - 85 - def get_outbound_refs(self, username: str, entry_id: str) -> list[BlogReference]: 86 - """Get all outbound references from an entry.""" 87 - key = f"{username}:{entry_id}" 88 - return self.outbound_refs.get(key, []) 89 - 90 - def get_inbound_refs(self, username: str, entry_id: str) -> list[BlogReference]: 91 - """Get all inbound references to an entry.""" 92 - key = f"{username}:{entry_id}" 93 - return self.inbound_refs.get(key, []) 94 - 95 - def get_thread_members(self, username: str, entry_id: str) -> set[tuple[str, str]]: 96 - """Get all entries that are part of the same thread.""" 97 - visited = set() 98 - to_visit = [(username, entry_id)] 99 - thread_members = set() 100 - 101 - while to_visit: 102 - current_user, current_entry = to_visit.pop() 103 - if (current_user, current_entry) in visited: 104 - continue 105 - 106 - visited.add((current_user, current_entry)) 107 - thread_members.add((current_user, current_entry)) 108 - 109 - # Add outbound references 110 - for ref in self.get_outbound_refs(current_user, current_entry): 111 - if ref.target_username and ref.target_entry_id: 112 - to_visit.append((ref.target_username, ref.target_entry_id)) 113 - 114 - # Add inbound references 115 - for ref in self.get_inbound_refs(current_user, current_entry): 116 - to_visit.append((ref.source_username, ref.source_entry_id)) 117 - 118 - return thread_members 119 - 120 - def to_dict(self) -> dict: 121 - """Convert to dictionary for JSON serialization.""" 122 - return { 123 - "references": [ref.to_dict() for ref in self.references], 124 - "user_domains": {k: list(v) for k, v in self.user_domains.items()}, 125 - } 126 - 127 - @classmethod 128 - def from_dict(cls, data: dict) -> "ReferenceIndex": 129 - """Create from dictionary.""" 130 - index = cls() 131 - for ref_data in data.get("references", []): 132 - ref = BlogReference.from_dict(ref_data) 133 - index.add_reference(ref) 134 - 135 - for username, domains in data.get("user_domains", {}).items(): 136 - index.user_domains[username] = set(domains) 137 - 138 - return index 139 - 140 - 141 - class ReferenceParser: 142 - """Parses blog entries to detect references to other blogs.""" 143 - 144 - def __init__(self): 145 - # Common blog platforms and patterns 146 - self.blog_patterns = [ 147 - r"https?://[^/]+\.(?:org|com|net|io|dev|me|co\.uk)/.*", # Common blog domains 148 - r"https?://[^/]+\.github\.io/.*", # GitHub Pages 149 - r"https?://[^/]+\.substack\.com/.*", # Substack 150 - r"https?://medium\.com/.*", # Medium 151 - r"https?://[^/]+\.wordpress\.com/.*", # WordPress.com 152 - r"https?://[^/]+\.blogspot\.com/.*", # Blogger 153 - ] 154 - 155 - # Compile regex patterns 156 - self.link_pattern = re.compile( 157 - r'<a[^>]+href="([^"]+)"[^>]*>(.*?)</a>', re.IGNORECASE | re.DOTALL 158 - ) 159 - self.url_pattern = re.compile(r'https?://[^\s<>"]+') 160 - 161 - def extract_links_from_html(self, html_content: str) -> list[tuple[str, str]]: 162 - """Extract all links from HTML content.""" 163 - links = [] 164 - 165 - # Extract links from <a> tags 166 - for match in self.link_pattern.finditer(html_content): 167 - url = match.group(1) 168 - text = re.sub( 169 - r"<[^>]+>", "", match.group(2) 170 - ).strip() # Remove HTML tags from link text 171 - links.append((url, text)) 172 - 173 - return links 174 - 175 - def is_blog_url(self, url: str) -> bool: 176 - """Check if a URL likely points to a blog post.""" 177 - for pattern in self.blog_patterns: 178 - if re.match(pattern, url): 179 - return True 180 - return False 181 - 182 - def resolve_target_user( 183 - self, url: str, user_domains: dict[str, set[str]] 184 - ) -> Optional[str]: 185 - """Try to resolve a URL to a known user based on domain mapping.""" 186 - parsed_url = urlparse(url) 187 - domain = parsed_url.netloc.lower() 188 - 189 - for username, domains in user_domains.items(): 190 - if domain in domains: 191 - return username 192 - 193 - return None 194 - 195 - def extract_references( 196 - self, entry: AtomEntry, username: str, user_domains: dict[str, set[str]] 197 - ) -> list[BlogReference]: 198 - """Extract all blog references from an entry.""" 199 - references = [] 200 - 201 - # Combine all text content for analysis 202 - content_to_search = [] 203 - if entry.content: 204 - content_to_search.append(entry.content) 205 - if entry.summary: 206 - content_to_search.append(entry.summary) 207 - 208 - for content in content_to_search: 209 - links = self.extract_links_from_html(content) 210 - 211 - for url, _link_text in links: 212 - # Skip internal links (same domain as the entry) 213 - entry_domain = ( 214 - urlparse(str(entry.link)).netloc.lower() if entry.link else "" 215 - ) 216 - link_domain = urlparse(url).netloc.lower() 217 - 218 - if link_domain == entry_domain: 219 - continue 220 - 221 - # Check if this looks like a blog URL 222 - if not self.is_blog_url(url): 223 - continue 224 - 225 - # Try to resolve to a known user 226 - target_username = self.resolve_target_user(url, user_domains) 227 - 228 - ref = BlogReference( 229 - source_entry_id=entry.id, 230 - source_username=username, 231 - target_url=url, 232 - target_username=target_username, 233 - target_entry_id=None, # Will be resolved later if possible 234 - ) 235 - 236 - references.append(ref) 237 - 238 - return references 239 - 240 - def build_user_domain_mapping(self, git_store: "GitStore") -> dict[str, set[str]]: 241 - """Build mapping of usernames to their known domains.""" 242 - user_domains = {} 243 - index = git_store._load_index() 244 - 245 - for username, user_metadata in index.users.items(): 246 - domains = set() 247 - 248 - # Add domains from feeds 249 - for feed_url in user_metadata.feeds: 250 - domain = urlparse(feed_url).netloc.lower() 251 - if domain: 252 - domains.add(domain) 253 - 254 - # Add domain from homepage 255 - if user_metadata.homepage: 256 - domain = urlparse(str(user_metadata.homepage)).netloc.lower() 257 - if domain: 258 - domains.add(domain) 259 - 260 - user_domains[username] = domains 261 - 262 - return user_domains 263 - 264 - def resolve_target_entry_ids( 265 - self, references: list[BlogReference], git_store: "GitStore" 266 - ) -> list[BlogReference]: 267 - """Resolve target_entry_id for references that have target_username but no target_entry_id.""" 268 - resolved_refs = [] 269 - 270 - for ref in references: 271 - # If we already have a target_entry_id, keep the reference as-is 272 - if ref.target_entry_id is not None: 273 - resolved_refs.append(ref) 274 - continue 275 - 276 - # If we don't have a target_username, we can't resolve it 277 - if ref.target_username is None: 278 - resolved_refs.append(ref) 279 - continue 280 - 281 - # Try to find the entry by matching the URL 282 - entries = git_store.list_entries(ref.target_username) 283 - resolved_entry_id = None 284 - 285 - for entry in entries: 286 - # Check if the entry's link matches the target URL 287 - if entry.link and str(entry.link) == ref.target_url: 288 - resolved_entry_id = entry.id 289 - break 290 - 291 - # Create a new reference with the resolved target_entry_id 292 - resolved_ref = BlogReference( 293 - source_entry_id=ref.source_entry_id, 294 - source_username=ref.source_username, 295 - target_url=ref.target_url, 296 - target_username=ref.target_username, 297 - target_entry_id=resolved_entry_id, 298 - ) 299 - resolved_refs.append(resolved_ref) 300 - 301 - return resolved_refs
+4 -2
src/thicket/models/feed.py
··· 1 1 """Feed and entry models for thicket.""" 2 2 3 3 from datetime import datetime 4 - from typing import TYPE_CHECKING, Optional 4 + from typing import TYPE_CHECKING, Any, Optional 5 5 6 6 from pydantic import BaseModel, ConfigDict, EmailStr, HttpUrl 7 7 ··· 25 25 summary: Optional[str] = None 26 26 content: Optional[str] = None # Full body content from Atom entry 27 27 content_type: Optional[str] = "html" # text, html, xhtml 28 - author: Optional[dict] = None 28 + author: Optional[dict[str, Any]] = None 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 32 34 33 35 34 36 class FeedMetadata(BaseModel):
+210
uv.lock
··· 82 82 ] 83 83 84 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 + ] 92 + 93 + [[package]] 85 94 name = "certifi" 86 95 version = "2025.7.14" 87 96 source = { registry = "https://pypi.org/simple" } ··· 255 264 ] 256 265 257 266 [[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]] 258 286 name = "gitdb" 259 287 version = "4.0.12" 260 288 source = { registry = "https://pypi.org/simple" } ··· 325 353 ] 326 354 327 355 [[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]] 328 368 name = "iniconfig" 329 369 version = "2.1.0" 330 370 source = { registry = "https://pypi.org/simple" } ··· 334 374 ] 335 375 336 376 [[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]] 337 410 name = "markdown-it-py" 338 411 version = "3.0.0" 339 412 source = { registry = "https://pypi.org/simple" } ··· 345 418 { 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" }, 346 419 ] 347 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 + ] 508 + 348 509 [[package]] 349 510 name = "mdurl" 350 511 version = "0.1.2" ··· 867 1028 ] 868 1029 869 1030 [[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]] 870 1046 name = "thicket" 871 1047 source = { editable = "." } 872 1048 dependencies = [ 873 1049 { name = "bleach" }, 874 1050 { name = "email-validator" }, 875 1051 { name = "feedparser" }, 1052 + { name = "flask" }, 876 1053 { name = "gitpython" }, 877 1054 { name = "httpx" }, 878 1055 { name = "pendulum" }, ··· 881 1058 { name = "pydantic-settings" }, 882 1059 { name = "pyyaml" }, 883 1060 { name = "rich" }, 1061 + { name = "textual" }, 884 1062 { name = "typer" }, 885 1063 ] 886 1064 ··· 901 1079 { name = "bleach", specifier = ">=6.0.0" }, 902 1080 { name = "email-validator" }, 903 1081 { name = "feedparser", specifier = ">=6.0.11" }, 1082 + { name = "flask", specifier = ">=3.1.1" }, 904 1083 { name = "gitpython", specifier = ">=3.1.40" }, 905 1084 { name = "httpx", specifier = ">=0.28.0" }, 906 1085 { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.13.0" }, ··· 914 1093 { name = "pyyaml", specifier = ">=6.0.0" }, 915 1094 { name = "rich", specifier = ">=13.0.0" }, 916 1095 { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8.0" }, 1096 + { name = "textual", specifier = ">=4.0.0" }, 917 1097 { name = "typer", specifier = ">=0.15.0" }, 918 1098 { name = "types-pyyaml", marker = "extra == 'dev'", specifier = ">=6.0.0" }, 919 1099 ] ··· 1014 1194 ] 1015 1195 1016 1196 [[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]] 1017 1206 name = "webencodings" 1018 1207 version = "0.5.1" 1019 1208 source = { registry = "https://pypi.org/simple" } ··· 1021 1210 wheels = [ 1022 1211 { 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" }, 1023 1212 ] 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 + ]