1β{
2β "parentUuid": "69b34896-3549-48f8-ad9f-64d4be1cac95",
3β "isSidechain": false,
4β "userType": "external",
5β "cwd": "/Users/dain/workspace/claude-code-log",
6β "sessionId": "1b6cf74c-6350-40c0-91c1-d75c257c0729",
7β "version": "1.0.24",
8β "type": "user",
9β "message": {
10β "role": "user",
11β "content": [
12β {
13β "tool_use_id": "toolu_01LRCsYX5dNHNaZLfAF2RLDh",
14β "type": "tool_result",
15β "content": " 1β#!/usr/bin/env python3\n 2β\"\"\"Convert Claude transcript JSONL files to HTML.\"\"\"\n 3β\n 4βimport json\n 5βfrom pathlib import Path\n 6βfrom typing import Any, Dict, List, Optional\n 7βfrom datetime import datetime\n 8βimport dateparser\n 9βimport html\n 10β\n 11β\n 12βdef extract_text_content(content: Any) -> str:\n 13β \"\"\"Extract text content from Claude message content structure.\"\"\"\n 14β if isinstance(content, list):\n 15β text_parts = []\n 16β for item in content:\n 17β if isinstance(item, dict):\n 18β if item.get(\"type\") == \"text\":\n 19β text_parts.append(item.get(\"text\", \"\"))\n 20β # Skip tool_use and tool_result items since we only want user messages\n 21β return \"\\n\".join(text_parts)\n 22β elif isinstance(content, dict):\n 23β if content.get(\"type\") == \"text\":\n 24β return content.get(\"text\", \"\")\n 25β return str(content) if content else \"\"\n 26β\n 27β\n 28βdef format_timestamp(timestamp_str: str) -> str:\n 29β \"\"\"Format ISO timestamp for display.\"\"\"\n 30β try:\n 31β dt = datetime.fromisoformat(timestamp_str.replace(\"Z\", \"+00:00\"))\n 32β return dt.strftime(\"%Y-%m-%d %H:%M:%S\")\n 33β except (ValueError, AttributeError):\n 34β return timestamp_str\n 35β\n 36β\n 37βdef parse_timestamp(timestamp_str: str) -> Optional[datetime]:\n 38β \"\"\"Parse ISO timestamp to datetime object.\"\"\"\n 39β try:\n 40β return datetime.fromisoformat(timestamp_str.replace(\"Z\", \"+00:00\"))\n 41β except (ValueError, AttributeError):\n 42β return None\n 43β\n 44β\n 45βdef filter_messages_by_date(\n 46β messages: List[Dict[str, Any]], from_date: Optional[str], to_date: Optional[str]\n 47β) -> List[Dict[str, Any]]:\n 48β \"\"\"Filter messages based on date range.\"\"\"\n 49β if not from_date and not to_date:\n 50β return messages\n 51β\n 52β # Parse the date strings using dateparser\n 53β from_dt = None\n 54β to_dt = None\n 55β\n 56β if from_date:\n 57β from_dt = dateparser.parse(from_date)\n 58β if not from_dt:\n 59β raise ValueError(f\"Could not parse from-date: {from_date}\")\n 60β # If parsing relative dates like \"today\", start from beginning of day\n 61β if from_date in [\"today\", \"yesterday\"] or \"days ago\" in from_date:\n 62β from_dt = from_dt.replace(hour=0, minute=0, second=0, microsecond=0)\n 63β\n 64β if to_date:\n 65β to_dt = dateparser.parse(to_date)\n 66β if not to_dt:\n 67β raise ValueError(f\"Could not parse to-date: {to_date}\")\n 68β # If parsing relative dates like \"today\", end at end of day\n 69β if to_date in [\"today\", \"yesterday\"] or \"days ago\" in to_date:\n 70β to_dt = to_dt.replace(hour=23, minute=59, second=59, microsecond=999999)\n 71β\n 72β filtered_messages = []\n 73β for message in messages:\n 74β timestamp_str = message.get(\"timestamp\", \"\")\n 75β if not timestamp_str:\n 76β continue\n 77β\n 78β message_dt = parse_timestamp(timestamp_str)\n 79β if not message_dt:\n 80β continue\n 81β\n 82β # Convert to naive datetime for comparison (dateparser returns naive datetimes)\n 83β if message_dt.tzinfo:\n 84β message_dt = message_dt.replace(tzinfo=None)\n 85β\n 86β # Check if message falls within date range\n 87β if from_dt and message_dt < from_dt:\n 88β continue\n 89β if to_dt and message_dt > to_dt:\n 90β continue\n 91β\n 92β filtered_messages.append(message)\n 93β\n 94β return filtered_messages\n 95β\n 96β\n 97βdef load_transcript(jsonl_path: Path) -> List[Dict[str, Any]]:\n 98β \"\"\"Load and parse JSONL transcript file.\"\"\"\n 99β messages = []\n 100β with open(jsonl_path, \"r\", encoding=\"utf-8\") as f:\n 101β for line in f:\n 102β line = line.strip()\n 103β if line:\n 104β try:\n 105β entry = json.loads(line)\n 106β if entry.get(\"type\") in [\"user\", \"assistant\", \"summary\"]:\n 107β # Add source file info for session tracking\n 108β entry[\"_source_file\"] = jsonl_path.stem\n 109β messages.append(entry)\n 110β except json.JSONDecodeError:\n 111β continue\n 112β return messages\n 113β\n 114β\n 115βdef load_directory_transcripts(directory_path: Path) -> List[Dict[str, Any]]:\n 116β \"\"\"Load all JSONL transcript files from a directory and combine them.\"\"\"\n 117β all_messages = []\n 118β\n 119β # Find all .jsonl files\n 120β jsonl_files = list(directory_path.glob(\"*.jsonl\"))\n 121β\n 122β for jsonl_file in jsonl_files:\n 123β messages = load_transcript(jsonl_file)\n 124β all_messages.extend(messages)\n 125β\n 126β # Sort all messages chronologically\n 127β all_messages.sort(key=lambda x: x.get(\"timestamp\", \"\"))\n 128β\n 129β return all_messages\n 130β\n 131β\n 132βdef escape_html(text: str) -> str:\n 133β \"\"\"Escape HTML special characters in text.\"\"\"\n 134β return html.escape(text)\n 135β\n 136β\n 137βdef extract_command_name(text_content: str) -> str:\n 138β \"\"\"Extract command name from system message with command tags.\"\"\"\n 139β import re\n 140β\n 141β command_name_match = re.search(\n 142β r\"<command-name>([^<]+)</command-name>\", text_content\n 143β )\n 144β if command_name_match:\n 145β return command_name_match.group(1).strip()\n 146β return \"system\"\n 147β\n 148β\n 149βdef is_system_message(text_content: str, message_content: dict) -> bool:\n 150β \"\"\"Check if a message is a system message that should be filtered out.\"\"\"\n 151β system_message_patterns = [\n 152β \"Caveat: The messages below were generated by the user while running local commands. DO NOT respond to these messages or otherwise consider them in your response unless the user explicitly asks you to.\",\n 153β \"[Request interrupted by user for tool use]\",\n 154β ]\n 155β\n 156β # Check for command output messages with <local-command-stdout> tags\n 157β if \"<local-command-stdout>\" in text_content:\n 158β return True\n 159β\n 160β # Check for tool result messages - these have tool_use_id and type=\"tool_result\"\n 161β if isinstance(message_content, dict) and \"content\" in message_content:\n 162β content = message_content[\"content\"]\n 163β if isinstance(content, list):\n 164β for item in content:\n 165β if isinstance(item, dict) and item.get(\"type\") == \"tool_result\":\n 166β return True\n 167β\n 168β return any(pattern in text_content for pattern in system_message_patterns)\n 169β\n 170β\n 171βdef generate_html(messages: List[Dict[str, Any]], title: Optional[str] = None) -> str:\n 172β \"\"\"Generate HTML from transcript messages.\"\"\"\n 173β if not title:\n 174β title = \"Claude Transcript\"\n 175β\n 176β html_parts = [\n 177β \"<!DOCTYPE html>\",\n 178β \"<html lang='en'>\",\n 179β \"<head>\",\n 180β \" <meta charset='UTF-8'>\",\n 181β \" <meta name='viewport' content='width=device-width, initial-scale=1.0'>\",\n 182β f\" <title>{title}</title>\",\n 183β ' <script type=\"module\">',\n 184β \" import { marked } from 'https://cdn.jsdelivr.net/npm/marked/lib/marked.esm.js';\",\n 185β \" \",\n 186β \" // Configure marked options\",\n 187β \" marked.setOptions({\",\n 188β \" breaks: true,\",\n 189β \" gfm: true\",\n 190β \" });\",\n 191β \" \",\n 192β \" document.addEventListener('DOMContentLoaded', function() {\",\n 193β \" // Find all content divs and render markdown\",\n 194β \" const contentDivs = document.querySelectorAll('.content');\",\n 195β \" contentDivs.forEach(div => {\",\n 196β \" // Skip if it's already HTML (contains tags)\",\n 197β \" if (div.innerHTML.includes('<') && div.innerHTML.includes('>')) {\",\n 198β \" return;\",\n 199β \" }\",\n 200β \" \",\n 201β \" const markdownText = div.textContent;\",\n 202β \" if (markdownText.trim()) {\",\n 203β \" div.innerHTML = marked.parse(markdownText);\",\n 204β \" }\",\n 205β \" });\",\n 206β \" });\",\n 207β \" </script>\",\n 208β \" <style>\",\n 209β \" body {\",\n 210β \" font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Droid Sans Mono', 'Source Code Pro', 'Ubuntu Mono', 'Cascadia Code', 'Menlo', 'Consolas', monospace;\",\n 211β \" line-height: 1.5;\",\n 212β \" max-width: 1200px;\",\n 213β \" margin: 0 auto;\",\n 214β \" padding: 10px;\",\n 215β \" background-color: #fafafa;\",\n 216β \" color: #333;\",\n 217β \" }\",\n 218β \" .message {\",\n 219β \" margin-bottom: 12px;\",\n 220β \" padding: 12px;\",\n 221β \" border-radius: 6px;\",\n 222β \" border-left: 3px solid;\",\n 223β \" }\",\n 224β \" .session-divider {\",\n 225β \" margin: 30px 0;\",\n 226β \" padding: 8px 0;\",\n 227β \" border-top: 2px solid #ddd;\",\n 228β \" text-align: center;\",\n 229β \" font-weight: 600;\",\n 230β \" color: #666;\",\n 231β \" font-size: 0.9em;\",\n 232β \" }\",\n 233β \" .user {\",\n 234β \" background-color: #e3f2fd;\",\n 235β \" border-left-color: #2196f3;\",\n 236β \" }\",\n 237β \" .assistant {\",\n 238β \" background-color: #f3e5f5;\",\n 239β \" border-left-color: #9c27b0;\",\n 240β \" }\",\n 241β \" .summary {\",\n 242β \" background-color: #e8f5e8;\",\n 243β \" border-left-color: #4caf50;\",\n 244β \" }\",\n 245β \" .system {\",\n 246β \" background-color: #fff8e1;\",\n 247β \" border-left-color: #ff9800;\",\n 248β \" }\",\n 249β \" details {\",\n 250β \" margin-top: 8px;\",\n 251β \" }\",\n 252β \" details summary {\",\n 253β \" font-weight: 600;\",\n 254β \" cursor: pointer;\",\n 255β \" padding: 4px 0;\",\n 256β \" color: #666;\",\n 257β \" }\",\n 258β \" details[open] summary {\",\n 259β \" margin-bottom: 8px;\",\n 260β \" }\",\n 261β \" .duplicate-collapsed {\",\n 262β \" background-color: #fff3cd;\",\n 263β \" border-left-color: #ffc107;\",\n 264β \" opacity: 0.7;\",\n 265β \" }\",\n 266β \" .header {\",\n 267β \" font-weight: 600;\",\n 268β \" margin-bottom: 8px;\",\n 269β \" display: flex;\",\n 270β \" justify-content: space-between;\",\n 271β \" align-items: center;\",\n 272β \" }\",\n 273β \" .timestamp {\",\n 274β \" font-size: 0.85em;\",\n 275β \" color: #666;\",\n 276β \" font-weight: normal;\",\n 277β \" }\",\n 278β \" .content {\",\n 279β \" white-space: pre-wrap;\",\n 280β \" word-wrap: break-word;\",\n 281β \" }\",\n 282β \" .duplicate-note {\",\n 283β \" font-style: italic;\",\n 284β \" color: #856404;\",\n 285β \" font-size: 0.9em;\",\n 286β \" margin-top: 8px;\",\n 287β \" }\",\n 288β \" h1 {\",\n 289β \" text-align: center;\",\n 290β \" color: #2c3e50;\",\n 291β \" margin-bottom: 20px;\",\n 292β \" font-size: 1.8em;\",\n 293β \" }\",\n 294β \" code {\",\n 295β \" background-color: #f5f5f5;\",\n 296β \" padding: 2px 4px;\",\n 297β \" border-radius: 3px;\",\n 298β \" font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;\",\n 299β \" }\",\n 300β \" pre {\",\n 301β \" background-color: #f5f5f5;\",\n 302β \" padding: 10px;\",\n 303β \" border-radius: 5px;\",\n 304β \" overflow-x: auto;\",\n 305β \" }\",\n 306β \" </style>\",\n 307β \"</head>\",\n 308β \"<body>\",\n 309β f\" <h1>{title}</h1>\",\n 310β ]\n 311β\n 312β current_session = None\n 313β last_message_content = None\n 314β\n 315β for message in messages:\n 316β message_type = message.get(\"type\", \"unknown\")\n 317β\n 318β # Extract message content first to check for duplicates\n 319β message_content = message.get(\"message\", {})\n 320β if isinstance(message_content, dict):\n 321β content = message_content.get(\"content\", \"\")\n 322β else:\n 323β content = message_content\n 324β\n 325β text_content = extract_text_content(content)\n 326β if not text_content.strip():\n 327β continue\n 328β\n 329β # Handle system messages with command names\n 330β is_system = is_system_message(text_content, message_content)\n 331β is_command_message = (\n 332β \"<command-name>\" in text_content and \"<command-message>\" in text_content\n 333β )\n 334β\n 335β if is_system and not is_command_message:\n 336β continue\n 337β\n 338β # Check if we're in a new session\n 339β source_file = message.get(\"_source_file\", \"unknown\")\n 340β is_new_session = current_session != source_file\n 341β\n 342β # Check for duplicate message at session boundary\n 343β is_duplicate = (\n 344β is_new_session\n 345β and last_message_content is not None\n 346β and text_content.strip() == last_message_content.strip()\n 347β )\n 348β\n 349β if is_new_session:\n 350β if current_session is not None: # Don't add divider before first session\n 351β html_parts.append(\n 352β f\" <div class='session-divider'>Session: {source_file}</div>\"\n 353β )\n 354β else:\n 355β html_parts.append(\n 356β f\" <div class='session-divider'>Session: {source_file}</div>\"\n 357β )\n 358β current_session = source_file\n 359β\n 360β timestamp = message.get(\"timestamp\", \"\")\n 361β formatted_timestamp = format_timestamp(timestamp) if timestamp else \"\"\n 362β\n 363β # Determine CSS class and content based on message type and duplicate status\n 364β if message_type == \"summary\":\n 365β css_class = \"summary\"\n 366β summary_text = message.get(\"summary\", \"Summary\")\n 367β content_html = f\"<strong>Summary:</strong> {escape_html(str(summary_text))}\"\n 368β elif is_command_message:\n 369β css_class = \"system\"\n 370β command_name = extract_command_name(text_content)\n 371β escaped_command_name = escape_html(command_name)\n 372β escaped_content = escape_html(text_content)\n 373β content_html = f\"<details><summary>Command: {escaped_command_name}</summary><div class='content'>{escaped_content}</div></details>\"\n 374β message_type = \"system\"\n 375β else:\n 376β css_class = f\"{message_type}\"\n 377β content_html = escape_html(text_content)\n 378β\n 379β if is_duplicate:\n 380β css_class += \" duplicate-collapsed\"\n 381β content_html = \"<div class='duplicate-note'>(Duplicate from previous session - collapsed)</div>\"\n 382β\n 383β html_parts.extend(\n 384β [\n 385β f\" <div class='message {css_class}'>\",\n 386β \" <div class='header'>\",\n 387β f\" <span>{message_type.title()}</span>\",\n 388β f\" <span class='timestamp'>{formatted_timestamp}</span>\",\n 389β \" </div>\",\n 390β f\" <div class='content'>{content_html}</div>\",\n 391β \" </div>\",\n 392β ]\n 393β )\n 394β\n 395β # Update last message content for duplicate detection\n 396β last_message_content = text_content\n 397β\n 398β html_parts.extend([\"</body>\", \"</html>\"])\n 399β\n 400β return \"\\n\".join(html_parts)\n 401β\n 402β\n 403βdef convert_jsonl_to_html(\n 404β input_path: Path,\n 405β output_path: Optional[Path] = None,\n 406β from_date: Optional[str] = None,\n 407β to_date: Optional[str] = None,\n 408β) -> Path:\n 409β \"\"\"Convert JSONL transcript(s) to HTML file.\"\"\"\n 410β if not input_path.exists():\n 411β raise FileNotFoundError(f\"Input path not found: {input_path}\")\n 412β\n 413β if input_path.is_file():\n 414β # Single file mode\n 415β if output_path is None:\n 416β output_path = input_path.with_suffix(\".html\")\n 417β messages = load_transcript(input_path)\n 418β title = f\"Claude Transcript - {input_path.stem}\"\n 419β else:\n 420β # Directory mode\n 421β if output_path is None:\n 422β output_path = input_path / \"combined_transcripts.html\"\n 423β messages = load_directory_transcripts(input_path)\n 424β title = f\"Claude Transcripts - {input_path.name}\"\n 425β\n 426β # Apply date filtering\n 427β messages = filter_messages_by_date(messages, from_date, to_date)\n 428β\n 429β # Update title to include date range if specified\n 430β if from_date or to_date:\n 431β date_range_parts = []\n 432β if from_date:\n 433β date_range_parts.append(f\"from {from_date}\")\n 434β if to_date:\n 435β date_range_parts.append(f\"to {to_date}\")\n 436β date_range_str = \" \".join(date_range_parts)\n 437β title += f\" ({date_range_str})\"\n 438β\n 439β html_content = generate_html(messages, title)\n 440β output_path.write_text(html_content, encoding=\"utf-8\")\n 441β return output_path\n 442β"
16β }
17β ]
18β },
19β "uuid": "2ddd3087-623f-483e-956f-51b5d35084db",
20β "timestamp": "2025-06-14T11:42:31.733Z",
21β "toolUseResult": {
22β "type": "text",
23β "file": {
24β "filePath": "/Users/dain/workspace/claude-code-log/claude_code_log/converter.py",
25β "content": "#!/usr/bin/env python3\n\"\"\"Convert Claude transcript JSONL files to HTML.\"\"\"\n\nimport json\nfrom pathlib import Path\nfrom typing import Any, Dict, List, Optional\nfrom datetime import datetime\nimport dateparser\nimport html\n\n\ndef extract_text_content(content: Any) -> str:\n \"\"\"Extract text content from Claude message content structure.\"\"\"\n if isinstance(content, list):\n text_parts = []\n for item in content:\n if isinstance(item, dict):\n if item.get(\"type\") == \"text\":\n text_parts.append(item.get(\"text\", \"\"))\n # Skip tool_use and tool_result items since we only want user messages\n return \"\\n\".join(text_parts)\n elif isinstance(content, dict):\n if content.get(\"type\") == \"text\":\n return content.get(\"text\", \"\")\n return str(content) if content else \"\"\n\n\ndef format_timestamp(timestamp_str: str) -> str:\n \"\"\"Format ISO timestamp for display.\"\"\"\n try:\n dt = datetime.fromisoformat(timestamp_str.replace(\"Z\", \"+00:00\"))\n return dt.strftime(\"%Y-%m-%d %H:%M:%S\")\n except (ValueError, AttributeError):\n return timestamp_str\n\n\ndef parse_timestamp(timestamp_str: str) -> Optional[datetime]:\n \"\"\"Parse ISO timestamp to datetime object.\"\"\"\n try:\n return datetime.fromisoformat(timestamp_str.replace(\"Z\", \"+00:00\"))\n except (ValueError, AttributeError):\n return None\n\n\ndef filter_messages_by_date(\n messages: List[Dict[str, Any]], from_date: Optional[str], to_date: Optional[str]\n) -> List[Dict[str, Any]]:\n \"\"\"Filter messages based on date range.\"\"\"\n if not from_date and not to_date:\n return messages\n\n # Parse the date strings using dateparser\n from_dt = None\n to_dt = None\n\n if from_date:\n from_dt = dateparser.parse(from_date)\n if not from_dt:\n raise ValueError(f\"Could not parse from-date: {from_date}\")\n # If parsing relative dates like \"today\", start from beginning of day\n if from_date in [\"today\", \"yesterday\"] or \"days ago\" in from_date:\n from_dt = from_dt.replace(hour=0, minute=0, second=0, microsecond=0)\n\n if to_date:\n to_dt = dateparser.parse(to_date)\n if not to_dt:\n raise ValueError(f\"Could not parse to-date: {to_date}\")\n # If parsing relative dates like \"today\", end at end of day\n if to_date in [\"today\", \"yesterday\"] or \"days ago\" in to_date:\n to_dt = to_dt.replace(hour=23, minute=59, second=59, microsecond=999999)\n\n filtered_messages = []\n for message in messages:\n timestamp_str = message.get(\"timestamp\", \"\")\n if not timestamp_str:\n continue\n\n message_dt = parse_timestamp(timestamp_str)\n if not message_dt:\n continue\n\n # Convert to naive datetime for comparison (dateparser returns naive datetimes)\n if message_dt.tzinfo:\n message_dt = message_dt.replace(tzinfo=None)\n\n # Check if message falls within date range\n if from_dt and message_dt < from_dt:\n continue\n if to_dt and message_dt > to_dt:\n continue\n\n filtered_messages.append(message)\n\n return filtered_messages\n\n\ndef load_transcript(jsonl_path: Path) -> List[Dict[str, Any]]:\n \"\"\"Load and parse JSONL transcript file.\"\"\"\n messages = []\n with open(jsonl_path, \"r\", encoding=\"utf-8\") as f:\n for line in f:\n line = line.strip()\n if line:\n try:\n entry = json.loads(line)\n if entry.get(\"type\") in [\"user\", \"assistant\", \"summary\"]:\n # Add source file info for session tracking\n entry[\"_source_file\"] = jsonl_path.stem\n messages.append(entry)\n except json.JSONDecodeError:\n continue\n return messages\n\n\ndef load_directory_transcripts(directory_path: Path) -> List[Dict[str, Any]]:\n \"\"\"Load all JSONL transcript files from a directory and combine them.\"\"\"\n all_messages = []\n\n # Find all .jsonl files\n jsonl_files = list(directory_path.glob(\"*.jsonl\"))\n\n for jsonl_file in jsonl_files:\n messages = load_transcript(jsonl_file)\n all_messages.extend(messages)\n\n # Sort all messages chronologically\n all_messages.sort(key=lambda x: x.get(\"timestamp\", \"\"))\n\n return all_messages\n\n\ndef escape_html(text: str) -> str:\n \"\"\"Escape HTML special characters in text.\"\"\"\n return html.escape(text)\n\n\ndef extract_command_name(text_content: str) -> str:\n \"\"\"Extract command name from system message with command tags.\"\"\"\n import re\n\n command_name_match = re.search(\n r\"<command-name>([^<]+)</command-name>\", text_content\n )\n if command_name_match:\n return command_name_match.group(1).strip()\n return \"system\"\n\n\ndef is_system_message(text_content: str, message_content: dict) -> bool:\n \"\"\"Check if a message is a system message that should be filtered out.\"\"\"\n system_message_patterns = [\n \"Caveat: The messages below were generated by the user while running local commands. DO NOT respond to these messages or otherwise consider them in your response unless the user explicitly asks you to.\",\n \"[Request interrupted by user for tool use]\",\n ]\n\n # Check for command output messages with <local-command-stdout> tags\n if \"<local-command-stdout>\" in text_content:\n return True\n\n # Check for tool result messages - these have tool_use_id and type=\"tool_result\"\n if isinstance(message_content, dict) and \"content\" in message_content:\n content = message_content[\"content\"]\n if isinstance(content, list):\n for item in content:\n if isinstance(item, dict) and item.get(\"type\") == \"tool_result\":\n return True\n\n return any(pattern in text_content for pattern in system_message_patterns)\n\n\ndef generate_html(messages: List[Dict[str, Any]], title: Optional[str] = None) -> str:\n \"\"\"Generate HTML from transcript messages.\"\"\"\n if not title:\n title = \"Claude Transcript\"\n\n html_parts = [\n \"<!DOCTYPE html>\",\n \"<html lang='en'>\",\n \"<head>\",\n \" <meta charset='UTF-8'>\",\n \" <meta name='viewport' content='width=device-width, initial-scale=1.0'>\",\n f\" <title>{title}</title>\",\n ' <script type=\"module\">',\n \" import { marked } from 'https://cdn.jsdelivr.net/npm/marked/lib/marked.esm.js';\",\n \" \",\n \" // Configure marked options\",\n \" marked.setOptions({\",\n \" breaks: true,\",\n \" gfm: true\",\n \" });\",\n \" \",\n \" document.addEventListener('DOMContentLoaded', function() {\",\n \" // Find all content divs and render markdown\",\n \" const contentDivs = document.querySelectorAll('.content');\",\n \" contentDivs.forEach(div => {\",\n \" // Skip if it's already HTML (contains tags)\",\n \" if (div.innerHTML.includes('<') && div.innerHTML.includes('>')) {\",\n \" return;\",\n \" }\",\n \" \",\n \" const markdownText = div.textContent;\",\n \" if (markdownText.trim()) {\",\n \" div.innerHTML = marked.parse(markdownText);\",\n \" }\",\n \" });\",\n \" });\",\n \" </script>\",\n \" <style>\",\n \" body {\",\n \" font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Droid Sans Mono', 'Source Code Pro', 'Ubuntu Mono', 'Cascadia Code', 'Menlo', 'Consolas', monospace;\",\n \" line-height: 1.5;\",\n \" max-width: 1200px;\",\n \" margin: 0 auto;\",\n \" padding: 10px;\",\n \" background-color: #fafafa;\",\n \" color: #333;\",\n \" }\",\n \" .message {\",\n \" margin-bottom: 12px;\",\n \" padding: 12px;\",\n \" border-radius: 6px;\",\n \" border-left: 3px solid;\",\n \" }\",\n \" .session-divider {\",\n \" margin: 30px 0;\",\n \" padding: 8px 0;\",\n \" border-top: 2px solid #ddd;\",\n \" text-align: center;\",\n \" font-weight: 600;\",\n \" color: #666;\",\n \" font-size: 0.9em;\",\n \" }\",\n \" .user {\",\n \" background-color: #e3f2fd;\",\n \" border-left-color: #2196f3;\",\n \" }\",\n \" .assistant {\",\n \" background-color: #f3e5f5;\",\n \" border-left-color: #9c27b0;\",\n \" }\",\n \" .summary {\",\n \" background-color: #e8f5e8;\",\n \" border-left-color: #4caf50;\",\n \" }\",\n \" .system {\",\n \" background-color: #fff8e1;\",\n \" border-left-color: #ff9800;\",\n \" }\",\n \" details {\",\n \" margin-top: 8px;\",\n \" }\",\n \" details summary {\",\n \" font-weight: 600;\",\n \" cursor: pointer;\",\n \" padding: 4px 0;\",\n \" color: #666;\",\n \" }\",\n \" details[open] summary {\",\n \" margin-bottom: 8px;\",\n \" }\",\n \" .duplicate-collapsed {\",\n \" background-color: #fff3cd;\",\n \" border-left-color: #ffc107;\",\n \" opacity: 0.7;\",\n \" }\",\n \" .header {\",\n \" font-weight: 600;\",\n \" margin-bottom: 8px;\",\n \" display: flex;\",\n \" justify-content: space-between;\",\n \" align-items: center;\",\n \" }\",\n \" .timestamp {\",\n \" font-size: 0.85em;\",\n \" color: #666;\",\n \" font-weight: normal;\",\n \" }\",\n \" .content {\",\n \" white-space: pre-wrap;\",\n \" word-wrap: break-word;\",\n \" }\",\n \" .duplicate-note {\",\n \" font-style: italic;\",\n \" color: #856404;\",\n \" font-size: 0.9em;\",\n \" margin-top: 8px;\",\n \" }\",\n \" h1 {\",\n \" text-align: center;\",\n \" color: #2c3e50;\",\n \" margin-bottom: 20px;\",\n \" font-size: 1.8em;\",\n \" }\",\n \" code {\",\n \" background-color: #f5f5f5;\",\n \" padding: 2px 4px;\",\n \" border-radius: 3px;\",\n \" font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;\",\n \" }\",\n \" pre {\",\n \" background-color: #f5f5f5;\",\n \" padding: 10px;\",\n \" border-radius: 5px;\",\n \" overflow-x: auto;\",\n \" }\",\n \" </style>\",\n \"</head>\",\n \"<body>\",\n f\" <h1>{title}</h1>\",\n ]\n\n current_session = None\n last_message_content = None\n\n for message in messages:\n message_type = message.get(\"type\", \"unknown\")\n\n # Extract message content first to check for duplicates\n message_content = message.get(\"message\", {})\n if isinstance(message_content, dict):\n content = message_content.get(\"content\", \"\")\n else:\n content = message_content\n\n text_content = extract_text_content(content)\n if not text_content.strip():\n continue\n\n # Handle system messages with command names\n is_system = is_system_message(text_content, message_content)\n is_command_message = (\n \"<command-name>\" in text_content and \"<command-message>\" in text_content\n )\n\n if is_system and not is_command_message:\n continue\n\n # Check if we're in a new session\n source_file = message.get(\"_source_file\", \"unknown\")\n is_new_session = current_session != source_file\n\n # Check for duplicate message at session boundary\n is_duplicate = (\n is_new_session\n and last_message_content is not None\n and text_content.strip() == last_message_content.strip()\n )\n\n if is_new_session:\n if current_session is not None: # Don't add divider before first session\n html_parts.append(\n f\" <div class='session-divider'>Session: {source_file}</div>\"\n )\n else:\n html_parts.append(\n f\" <div class='session-divider'>Session: {source_file}</div>\"\n )\n current_session = source_file\n\n timestamp = message.get(\"timestamp\", \"\")\n formatted_timestamp = format_timestamp(timestamp) if timestamp else \"\"\n\n # Determine CSS class and content based on message type and duplicate status\n if message_type == \"summary\":\n css_class = \"summary\"\n summary_text = message.get(\"summary\", \"Summary\")\n content_html = f\"<strong>Summary:</strong> {escape_html(str(summary_text))}\"\n elif is_command_message:\n css_class = \"system\"\n command_name = extract_command_name(text_content)\n escaped_command_name = escape_html(command_name)\n escaped_content = escape_html(text_content)\n content_html = f\"<details><summary>Command: {escaped_command_name}</summary><div class='content'>{escaped_content}</div></details>\"\n message_type = \"system\"\n else:\n css_class = f\"{message_type}\"\n content_html = escape_html(text_content)\n\n if is_duplicate:\n css_class += \" duplicate-collapsed\"\n content_html = \"<div class='duplicate-note'>(Duplicate from previous session - collapsed)</div>\"\n\n html_parts.extend(\n [\n f\" <div class='message {css_class}'>\",\n \" <div class='header'>\",\n f\" <span>{message_type.title()}</span>\",\n f\" <span class='timestamp'>{formatted_timestamp}</span>\",\n \" </div>\",\n f\" <div class='content'>{content_html}</div>\",\n \" </div>\",\n ]\n )\n\n # Update last message content for duplicate detection\n last_message_content = text_content\n\n html_parts.extend([\"</body>\", \"</html>\"])\n\n return \"\\n\".join(html_parts)\n\n\ndef convert_jsonl_to_html(\n input_path: Path,\n output_path: Optional[Path] = None,\n from_date: Optional[str] = None,\n to_date: Optional[str] = None,\n) -> Path:\n \"\"\"Convert JSONL transcript(s) to HTML file.\"\"\"\n if not input_path.exists():\n raise FileNotFoundError(f\"Input path not found: {input_path}\")\n\n if input_path.is_file():\n # Single file mode\n if output_path is None:\n output_path = input_path.with_suffix(\".html\")\n messages = load_transcript(input_path)\n title = f\"Claude Transcript - {input_path.stem}\"\n else:\n # Directory mode\n if output_path is None:\n output_path = input_path / \"combined_transcripts.html\"\n messages = load_directory_transcripts(input_path)\n title = f\"Claude Transcripts - {input_path.name}\"\n\n # Apply date filtering\n messages = filter_messages_by_date(messages, from_date, to_date)\n\n # Update title to include date range if specified\n if from_date or to_date:\n date_range_parts = []\n if from_date:\n date_range_parts.append(f\"from {from_date}\")\n if to_date:\n date_range_parts.append(f\"to {to_date}\")\n date_range_str = \" \".join(date_range_parts)\n title += f\" ({date_range_str})\"\n\n html_content = generate_html(messages, title)\n output_path.write_text(html_content, encoding=\"utf-8\")\n return output_path\n",
26β "numLines": 442,
27β "startLine": 1,
28β "totalLines": 442
29β }
30β }
31β}