···
+
"""CLI command for displaying and browsing thread-graphs of blog posts."""
+
from dataclasses import dataclass, field
+
from datetime import datetime
+
from pathlib import Path
+
from typing import Dict, List, Optional, Set, Tuple
+
from rich.console import Console
+
from flask import Flask, render_template_string, jsonify
+
from textual import events
+
from textual.app import App, ComposeResult
+
from textual.containers import Container, Horizontal, Vertical
+
from textual.reactive import reactive
+
from textual.widget import Widget
+
from textual.widgets import Footer, Header, Label, Static
+
from ...core.git_store import GitStore
+
from ...models import AtomEntry
+
from ..utils import get_tsv_mode, load_config
+
"""Types of links between entries."""
+
SELF_REFERENCE = "self" # Link to same user's content
+
USER_REFERENCE = "user" # Link to another tracked user
+
EXTERNAL = "external" # Link to external content
+
"""Represents a node in the thread graph."""
+
outbound_links: List[Tuple[str, LinkType]] = field(
+
inbound_backlinks: List[str] = field(default_factory=list) # entry_ids
+
def published_date(self) -> datetime:
+
"""Get the published or updated date for sorting."""
+
return self.entry.published or self.entry.updated
+
def title(self) -> str:
+
"""Get the entry title."""
+
return self.entry.title
+
def summary(self) -> str:
+
"""Get a short summary of the entry."""
+
self.entry.summary[:100] + "..."
+
if len(self.entry.summary) > 100
+
else self.entry.summary
+
"""Represents the full thread graph of interconnected posts."""
+
nodes: Dict[str, ThreadNode] = field(default_factory=dict) # entry_id -> ThreadNode
+
user_entries: Dict[str, List[str]] = field(
+
) # username -> [entry_ids]
+
url_to_entry: Dict[str, str] = field(default_factory=dict) # url -> entry_id
+
def add_node(self, node: ThreadNode) -> None:
+
"""Add a node to the graph."""
+
self.nodes[node.entry_id] = node
+
# Update user entries index
+
if node.username not in self.user_entries:
+
self.user_entries[node.username] = []
+
self.user_entries[node.username].append(node.entry_id)
+
self.url_to_entry[str(node.entry.link)] = node.entry_id
+
def get_connected_components(self) -> List[Set[str]]:
+
"""Find all connected components in the graph (threads)."""
+
visited: Set[str] = set()
+
components: List[Set[str]] = []
+
for entry_id in self.nodes:
+
if entry_id not in visited:
+
component: Set[str] = set()
+
self._dfs(entry_id, visited, component)
+
components.append(component)
+
def _dfs(self, entry_id: str, visited: Set[str], component: Set[str]) -> None:
+
"""Depth-first search to find connected components."""
+
if entry_id in visited:
+
component.add(entry_id)
+
node = self.nodes.get(entry_id)
+
# Follow outbound links
+
for url, link_type in node.outbound_links:
+
if url in self.url_to_entry:
+
target_id = self.url_to_entry[url]
+
self._dfs(target_id, visited, component)
+
for backlink_id in node.inbound_backlinks:
+
self._dfs(backlink_id, visited, component)
+
def get_standalone_entries(self) -> List[str]:
+
"""Get entries with no connections."""
+
for entry_id, node in self.nodes.items():
+
if not node.outbound_links and not node.inbound_backlinks:
+
standalone.append(entry_id)
+
def sort_component_chronologically(self, component: Set[str]) -> List[str]:
+
"""Sort a component by published date."""
+
self.nodes[entry_id] for entry_id in component if entry_id in self.nodes
+
nodes.sort(key=lambda n: n.published_date)
+
return [n.entry_id for n in nodes]
+
def build_thread_graph(git_store: GitStore) -> ThreadGraph:
+
"""Build the thread graph from all entries in the git store."""
+
# Get all users from index
+
index = git_store._load_index()
+
# Build user domain mapping
+
for username, user_metadata in index.users.items():
+
# Add domains from feeds
+
for feed_url in user_metadata.feeds:
+
from urllib.parse import urlparse
+
domain = urlparse(str(feed_url)).netloc.lower()
+
# Add domain from homepage
+
if user_metadata.homepage:
+
domain = urlparse(str(user_metadata.homepage)).netloc.lower()
+
user_domains[username] = domains
+
for username in index.users:
+
entries = git_store.list_entries(username)
+
node = ThreadNode(entry_id=entry.id, username=username, entry=entry)
+
# Process outbound links
+
for link in getattr(entry, "links", []):
+
link_type = categorize_link(link, username, user_domains)
+
node.outbound_links.append((link, link_type))
+
node.inbound_backlinks = getattr(entry, "backlinks", [])
+
url: str, source_username: str, user_domains: Dict[str, Set[str]]
+
"""Categorize a link as self-reference, user reference, or external."""
+
from urllib.parse import urlparse
+
domain = parsed.netloc.lower()
+
# Check if it's a self-reference
+
if domain in user_domains.get(source_username, set()):
+
return LinkType.SELF_REFERENCE
+
# Check if it's a reference to another tracked user
+
for username, domains in user_domains.items():
+
if username != source_username and domain in domains:
+
return LinkType.USER_REFERENCE
+
# Otherwise it's external
+
return LinkType.EXTERNAL
+
return LinkType.EXTERNAL
+
class ThreadTreeWidget(Static):
+
"""Widget for displaying a thread as a tree."""
+
def __init__(self, component: Set[str], graph: ThreadGraph, **kwargs):
+
super().__init__(**kwargs)
+
self.component = component
+
def compose(self) -> ComposeResult:
+
"""Create the tree display."""
+
# Sort entries chronologically
+
sorted_ids = self.graph.sort_component_chronologically(self.component)
+
# Build tree structure as text
+
content_lines = ["Thread:"]
+
added_nodes: Set[str] = set()
+
# Add nodes in chronological order, showing connections
+
for entry_id in sorted_ids:
+
if entry_id not in added_nodes:
+
self._add_node_to_text(content_lines, entry_id, added_nodes, 0)
+
# Join all lines into content
+
content = "\n".join(content_lines)
+
# Create a Static widget with the content
+
yield Static(content, id="thread-content")
+
self, content_lines: List[str], entry_id: str, added_nodes: Set[str], indent: int = 0
+
"""Recursively add nodes to the text display."""
+
if entry_id in added_nodes:
+
node = self.graph.nodes.get(entry_id)
+
content_lines.append(f"{prefix}↻ {node.username}: {node.title}")
+
added_nodes.add(entry_id)
+
node = self.graph.nodes.get(entry_id)
+
date_str = node.published_date.strftime("%Y-%m-%d")
+
node_label = f"{prefix}• {node.username}: {node.title} ({date_str})"
+
content_lines.append(node_label)
+
if node.outbound_links:
+
links_by_type: Dict[LinkType, List[str]] = {}
+
for url, link_type in node.outbound_links:
+
if link_type not in links_by_type:
+
links_by_type[link_type] = []
+
links_by_type[link_type].append(url)
+
for link_type, urls in links_by_type.items():
+
type_label = f"{prefix} → {link_type.value}: {len(urls)} link(s)"
+
content_lines.append(type_label)
+
if node.inbound_backlinks:
+
backlink_label = f"{prefix} ← backlinks: {len(node.inbound_backlinks)}"
+
content_lines.append(backlink_label)
+
class ThreadBrowserApp(App):
+
"""Terminal UI for browsing threads."""
+
border: solid $primary;
+
border: solid $secondary;
+
("j", "next_thread", "Next Thread"),
+
("k", "prev_thread", "Previous Thread"),
+
("enter", "select_thread", "View Thread"),
+
def __init__(self, graph: ThreadGraph):
+
self.current_thread_index = 0
+
self._build_thread_list()
+
def _build_thread_list(self):
+
"""Build the list of threads to display."""
+
# Get connected components (actual threads)
+
components = self.graph.get_connected_components()
+
# Sort components by the earliest date in each
+
for component in components:
+
if len(component) > 1: # Only show actual threads
+
sorted_ids = self.graph.sort_component_chronologically(component)
+
first_node = self.graph.nodes.get(sorted_ids[0])
+
sorted_components.append((first_node.published_date, component))
+
sorted_components.sort(key=lambda x: x[0], reverse=True)
+
self.threads = [comp for _, comp in sorted_components]
+
def compose(self) -> ComposeResult:
+
"""Create the UI layout."""
+
with Vertical(id="thread-list"):
+
yield Label("Threads", classes="title")
+
for i, thread in enumerate(self.threads):
+
sorted_ids = self.graph.sort_component_chronologically(thread)
+
first_node = self.graph.nodes.get(sorted_ids[0])
+
label = f"{i + 1}. {first_node.title} ({len(thread)} posts)"
+
yield Label(label, classes="thread-item")
+
with Vertical(id="entry-detail"):
+
yield ThreadTreeWidget(self.threads[0], self.graph)
+
def action_next_thread(self) -> None:
+
"""Move to next thread."""
+
if self.current_thread_index < len(self.threads) - 1:
+
self.current_thread_index += 1
+
def action_prev_thread(self) -> None:
+
"""Move to previous thread."""
+
if self.current_thread_index > 0:
+
self.current_thread_index -= 1
+
def action_select_thread(self) -> None:
+
"""View detailed thread."""
+
# In a real implementation, this could show more detail
+
def update_display(self) -> None:
+
"""Update the thread display."""
+
detail_view = self.query_one("#entry-detail")
+
detail_view.remove_children()
+
if self.threads and self.current_thread_index < len(self.threads):
+
widget = ThreadTreeWidget(
+
self.threads[self.current_thread_index], self.graph
+
detail_view.mount(widget)
+
config_file: Optional[Path] = typer.Option(
+
help="Path to configuration file",
+
interactive: bool = typer.Option(
+
"--interactive/--no-interactive",
+
help="Launch interactive terminal UI",
+
web: bool = typer.Option(
+
help="Launch web server with D3 force graph visualization",
+
port: int = typer.Option(
+
help="Port for web server",
+
"""Browse and visualize thread-graphs of interconnected blog posts.
+
This command analyzes all blog entries and their links/backlinks to build
+
a graph of conversations and references between posts. Threads are displayed
+
as connected components in the link graph.
+
config = load_config(config_file)
+
git_store = GitStore(config.git_store)
+
console.print("Building thread graph...")
+
graph = build_thread_graph(git_store)
+
components = graph.get_connected_components()
+
threads = [c for c in components if len(c) > 1]
+
standalone = graph.get_standalone_entries()
+
f"\n[green]Found {len(threads)} threads and {len(standalone)} standalone posts[/green]"
+
# Launch web server with D3 visualization
+
_launch_web_server(graph, port)
+
elif interactive and threads:
+
app = ThreadBrowserApp(graph)
+
_display_threads_tsv(graph, threads)
+
_display_threads_rich(graph, threads)
+
console.print(f"[red]Error building threads: {e}[/red]")
+
def _display_threads_rich(graph: ThreadGraph, threads: List[Set[str]]) -> None:
+
"""Display threads using rich formatting."""
+
for i, thread in enumerate(threads[:10]): # Show first 10 threads
+
sorted_ids = graph.sort_component_chronologically(thread)
+
console.print(f"\n[bold]Thread {i + 1}[/bold] ({len(thread)} posts)")
+
for j, entry_id in enumerate(sorted_ids):
+
node = graph.nodes.get(entry_id)
+
date_str = node.published_date.strftime("%Y-%m-%d")
+
indent = " " * min(j, 3) # Max 3 levels of indent
+
console.print(f"{indent}• [{node.username}] {node.title} ({date_str})")
+
if node.outbound_links:
+
for _, link_type in node.outbound_links:
+
link_summary[link_type] = link_summary.get(link_type, 0) + 1
+
[f"{t.value}:{c}" for t, c in link_summary.items()]
+
console.print(f"{indent} → Links: {link_str}")
+
def _display_threads_tsv(graph: ThreadGraph, threads: List[Set[str]]) -> None:
+
"""Display threads in TSV format."""
+
print("Thread\tSize\tFirst Post\tLast Post\tUsers")
+
for i, thread in enumerate(threads):
+
sorted_ids = graph.sort_component_chronologically(thread)
+
first_node = graph.nodes.get(sorted_ids[0])
+
last_node = graph.nodes.get(sorted_ids[-1])
+
for entry_id in thread:
+
node = graph.nodes.get(entry_id)
+
users.add(node.username)
+
if first_node and last_node:
+
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)}"
+
def _build_graph_json(graph: ThreadGraph) -> dict:
+
"""Convert ThreadGraph to JSON format for D3 visualization."""
+
# Color mapping for different users
+
"#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd",
+
"#8c564b", "#e377c2", "#7f7f7f", "#bcbd22", "#17becf",
+
"#aec7e8", "#ffbb78", "#98df8a", "#ff9896", "#c5b0d5"
+
# Assign colors to users
+
for i, username in enumerate(set(node.username for node in graph.nodes.values())):
+
user_colors[username] = colors[i % len(colors)]
+
for entry_id, node in graph.nodes.items():
+
"username": node.username,
+
"date": node.published_date.strftime("%Y-%m-%d"),
+
"summary": node.summary,
+
"color": user_colors[node.username],
+
"outbound_count": len(node.outbound_links),
+
"backlink_count": len(node.inbound_backlinks),
+
"self": len([l for l in node.outbound_links if l[1] == LinkType.SELF_REFERENCE]),
+
"user": len([l for l in node.outbound_links if l[1] == LinkType.USER_REFERENCE]),
+
"external": len([l for l in node.outbound_links if l[1] == LinkType.EXTERNAL])
+
# Create links (only for links between tracked entries)
+
for entry_id, node in graph.nodes.items():
+
for url, link_type in node.outbound_links:
+
if url in graph.url_to_entry:
+
target_id = graph.url_to_entry[url]
+
if target_id in graph.nodes:
+
"type": link_type.value,
+
"total_nodes": len(nodes),
+
"total_links": len(links),
+
"users": list(user_colors.keys()),
+
"user_colors": user_colors
+
def _launch_web_server(graph: ThreadGraph, port: int) -> None:
+
"""Launch Flask web server with D3 force graph visualization."""
+
flask_app = Flask(__name__)
+
# Store graph data globally for the Flask app
+
graph_data = _build_graph_json(graph)
+
"""Serve the main visualization page."""
+
return render_template_string(HTML_TEMPLATE, port=port)
+
@flask_app.route('/api/graph')
+
"""API endpoint to serve graph data as JSON."""
+
return jsonify(graph_data)
+
# Disable Flask logging in development mode
+
log = logging.getLogger('werkzeug')
+
log.setLevel(logging.ERROR)
+
"""Open browser after a short delay."""
+
webbrowser.open(f'http://localhost:{port}')
+
# Start browser in a separate thread
+
browser_thread = threading.Thread(target=open_browser)
+
browser_thread.daemon = True
+
console.print(f"\n[green]Starting web server at http://localhost:{port}[/green]")
+
console.print("[yellow]Press Ctrl+C to stop the server[/yellow]")
+
flask_app.run(host='0.0.0.0', port=port, debug=False)
+
except KeyboardInterrupt:
+
console.print("\n[green]Server stopped[/green]")
+
# HTML template for D3 force graph visualization
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
+
<title>Thicket Thread Graph Visualization</title>
+
<script src="https://d3js.org/d3.v7.min.js"></script>
+
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
+
background-color: #f5f5f5;
+
justify-content: center;
+
select, input[type="range"] {
+
border: 1px solid #ddd;
+
justify-content: center;
+
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
+
background: rgba(0, 0, 0, 0.9);
+
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
+
<h1>Thicket Thread Graph Visualization</h1>
+
<p>Interactive visualization of blog post connections and conversations</p>
+
<div class="control-group">
+
<label for="userFilter">Filter by user:</label>
+
<select id="userFilter">
+
<option value="all">All Users</option>
+
<div class="control-group">
+
<label for="linkFilter">Show links:</label>
+
<select id="linkFilter">
+
<option value="all">All Links</option>
+
<option value="user">User Links Only</option>
+
<option value="self">Self Links Only</option>
+
<option value="external">External Links Only</option>
+
<div class="control-group">
+
<label for="forceStrength">Force Strength:</label>
+
<input type="range" id="forceStrength" min="0.1" max="2" step="0.1" value="0.3">
+
<div class="control-group">
+
<label for="nodeSize">Node Size:</label>
+
<input type="range" id="nodeSize" min="3" max="15" step="1" value="6">
+
<div class="stats" id="stats"></div>
+
<div id="graph-container">
+
<div class="legend-item">
+
<div class="legend-line" style="background: #2ca02c;"></div>
+
<span>Self References</span>
+
<div class="legend-item">
+
<div class="legend-line" style="background: #ff7f0e;"></div>
+
<span>User References</span>
+
<div class="legend-item">
+
<div class="legend-line" style="background: #d62728;"></div>
+
<span>External References</span>
+
<h3 style="margin-top: 15px;">Interactions</h3>
+
<div style="font-size: 11px; color: #666;">
+
• Hover: Show details<br>
+
• Click: Pin/unpin node<br>
+
<div class="tooltip" id="tooltip" style="display: none;"></div>
+
let svg, g, link, node;
+
let width = window.innerWidth - 40;
+
let height = window.innerHeight - 200;
+
// Initialize the visualization
+
async function init() {
+
const response = await fetch('/api/graph');
+
graphData = await response.json();
+
svg = d3.select("#graph")
+
.attr("height", height);
+
.on("zoom", (event) => {
+
g.attr("transform", event.transform);
+
// Create main group for all elements
+
// Handle window resize
+
window.addEventListener('resize', () => {
+
width = window.innerWidth - 40;
+
height = window.innerHeight - 200;
+
svg.attr("width", width).attr("height", height);
+
simulation.force("center", d3.forceCenter(width / 2, height / 2));
+
function setupControls() {
+
// Populate user filter
+
const userFilter = d3.select("#userFilter");
+
graphData.stats.users.forEach(user => {
+
userFilter.append("option").attr("value", user).text(user);
+
d3.select("#userFilter").on("change", updateVisualization);
+
d3.select("#linkFilter").on("change", updateVisualization);
+
d3.select("#forceStrength").on("input", updateForces);
+
d3.select("#nodeSize").on("input", updateNodeSizes);
+
function updateVisualization() {
+
// Filter data based on controls
+
const userFilter = d3.select("#userFilter").property("value");
+
const linkFilter = d3.select("#linkFilter").property("value");
+
let filteredNodes = graphData.nodes;
+
let filteredLinks = graphData.links;
+
if (userFilter !== "all") {
+
filteredNodes = graphData.nodes.filter(n => n.username === userFilter);
+
const nodeIds = new Set(filteredNodes.map(n => n.id));
+
filteredLinks = graphData.links.filter(l =>
+
nodeIds.has(l.source.id || l.source) && nodeIds.has(l.target.id || l.target)
+
if (linkFilter !== "all") {
+
filteredLinks = filteredLinks.filter(l => l.type === linkFilter);
+
// Clear existing elements
+
g.selectAll(".link").remove();
+
g.selectAll(".node").remove();
+
// Create force simulation
+
simulation = d3.forceSimulation(filteredNodes)
+
.force("link", d3.forceLink(filteredLinks).id(d => d.id)
+
// Get source and target nodes
+
const sourceNode = filteredNodes.find(n => n.id === (d.source.id || d.source));
+
const targetNode = filteredNodes.find(n => n.id === (d.target.id || d.target));
+
// If nodes are from different users, make them attract more (shorter distance)
+
if (sourceNode && targetNode && sourceNode.username !== targetNode.username) {
+
return 30; // Shorter distance = stronger attraction
+
// Same user posts have normal distance
+
// Get source and target nodes
+
const sourceNode = filteredNodes.find(n => n.id === (d.source.id || d.source));
+
const targetNode = filteredNodes.find(n => n.id === (d.target.id || d.target));
+
// If nodes are from different users, make the link stronger
+
if (sourceNode && targetNode && sourceNode.username !== targetNode.username) {
+
return 1.5; // Stronger link force
+
// Same user posts have normal strength
+
.force("charge", d3.forceManyBody().strength(-200))
+
.force("center", d3.forceCenter(width / 2, height / 2))
+
.force("collision", d3.forceCollide().radius(15));
+
.enter().append("line")
+
.attr("class", d => `link ${d.type}-link`)
+
.attr("stroke-width", d => {
+
// Get source and target nodes
+
const sourceNode = filteredNodes.find(n => n.id === (d.source.id || d.source));
+
const targetNode = filteredNodes.find(n => n.id === (d.target.id || d.target));
+
// If nodes are from different users, make the line thicker
+
if (sourceNode && targetNode && sourceNode.username !== targetNode.username) {
+
return 2.5; // Thicker line for cross-user connections
+
// Same user posts have normal thickness
+
.enter().append("circle")
+
.attr("r", d => Math.max(4, Math.log(d.outbound_count + d.backlink_count + 1) * 3))
+
.attr("fill", d => d.color)
+
.on("start", dragstarted)
+
.on("mouseover", showTooltip)
+
.on("mouseout", hideTooltip)
+
.on("click", togglePin);
+
// Update force simulation
+
simulation.on("tick", () => {
+
.attr("x1", d => d.source.x)
+
.attr("y1", d => d.source.y)
+
.attr("x2", d => d.target.x)
+
.attr("y2", d => d.target.y);
+
updateStats(filteredNodes, filteredLinks);
+
function updateForces() {
+
const strength = +d3.select("#forceStrength").property("value");
+
simulation.force("charge").strength(-200 * strength);
+
simulation.alpha(0.3).restart();
+
function updateNodeSizes() {
+
const size = +d3.select("#nodeSize").property("value");
+
node.attr("r", d => Math.max(size * 0.5, Math.log(d.outbound_count + d.backlink_count + 1) * size * 0.5));
+
function dragstarted(event, d) {
+
if (!event.active) simulation.alphaTarget(0.3).restart();
+
function dragged(event, d) {
+
function dragended(event, d) {
+
if (!event.active) simulation.alphaTarget(0);
+
function togglePin(event, d) {
+
function showTooltip(event, d) {
+
const tooltip = d3.select("#tooltip");
+
tooltip.style("display", "block")
+
<strong>${d.title}</strong><br>
+
<strong>User:</strong> ${d.username}<br>
+
<strong>Date:</strong> ${d.date}<br>
+
<strong>Outbound Links:</strong> ${d.outbound_count}<br>
+
<strong>Backlinks:</strong> ${d.backlink_count}<br>
+
<strong>Link Types:</strong> Self: ${d.link_types.self}, User: ${d.link_types.user}, External: ${d.link_types.external}
+
${d.summary ? '<br><br>' + d.summary : ''}
+
.style("left", (event.pageX + 10) + "px")
+
.style("top", (event.pageY - 10) + "px");
+
function hideTooltip() {
+
d3.select("#tooltip").style("display", "none");
+
function updateStats(nodes = graphData.nodes, links = graphData.links) {
+
const stats = d3.select("#stats");
+
userCounts[n.username] = (userCounts[n.username] || 0) + 1;
+
<div class="stat-item">
+
<strong>${nodes.length}</strong> Nodes
+
<div class="stat-item">
+
<strong>${links.length}</strong> Links
+
<div class="stat-item">
+
<strong>${Object.keys(userCounts).length}</strong> Users
+
<div class="stat-item">
+
Users: ${Object.entries(userCounts).map(([user, count]) => `${user} (${count})`).join(', ')}
+
// Initialize when page loads