diff --git a/src/claude_code_transcripts/__init__.py b/src/claude_code_transcripts/__init__.py index f2246a2..681e5db 100644 --- a/src/claude_code_transcripts/__init__.py +++ b/src/claude_code_transcripts/__init__.py @@ -1,7 +1,7 @@ """Convert Claude Code session JSON to a clean mobile-friendly HTML page with pagination.""" -import json import html +import json import os import platform import re @@ -13,11 +13,11 @@ from pathlib import Path import click -from click_default_group import DefaultGroup import httpx -from jinja2 import Environment, PackageLoader import markdown import questionary +from click_default_group import DefaultGroup +from jinja2 import Environment, PackageLoader # Set up Jinja2 environment _jinja_env = Environment( @@ -158,6 +158,20 @@ def _get_jsonl_summary(filepath, max_length=200): return "(no summary)" +def _should_include_session(filepath, include_agents=False): + """Check if a session file should be included in listings. + + Returns (True, summary) if the session should be included, + (False, None) if it should be skipped. + """ + if not include_agents and filepath.name.startswith("agent-"): + return False, None + summary = get_session_summary(filepath) + if summary.lower() == "warmup" or summary == "(no summary)": + return False, None + return True, summary + + def find_local_sessions(folder, limit=10): """Find recent JSONL session files in the given folder. @@ -170,11 +184,8 @@ def find_local_sessions(folder, limit=10): results = [] for f in folder.glob("**/*.jsonl"): - if f.name.startswith("agent-"): - continue - summary = get_session_summary(f) - # Skip boring/empty sessions - if summary.lower() == "warmup" or summary == "(no summary)": + include, summary = _should_include_session(f) + if not include: continue results.append((f, summary)) @@ -183,6 +194,52 @@ def find_local_sessions(folder, limit=10): return results[:limit] +def find_sessions_for_project(projects_folder, project_path, limit=10): + """Find sessions for a specific project directory. + + Returns (sessions_list, project_folder_exists) where: + - sessions_list: list of (Path, summary) tuples sorted by modification time + - project_folder_exists: True if the project folder exists, False otherwise + """ + projects_folder = Path(projects_folder) + encoded = encode_path_to_folder_name(project_path) + project_folder = projects_folder / encoded + + if not project_folder.exists(): + return [], False + + results = [] + for f in project_folder.glob("*.jsonl"): + include, summary = _should_include_session(f) + if not include: + continue + results.append((f, summary)) + + results.sort(key=lambda x: x[0].stat().st_mtime, reverse=True) + return results[:limit], True + + +def find_sessions_excluding_project(projects_folder, exclude_project_path, limit=10): + """Find recent sessions from all projects except the specified one. + + Returns a list of (Path, summary) tuples sorted by modification time. + """ + projects_folder = Path(projects_folder) + exclude_encoded = encode_path_to_folder_name(exclude_project_path) + + results = [] + for f in projects_folder.glob("**/*.jsonl"): + if f.parent.name == exclude_encoded: + continue + include, summary = _should_include_session(f) + if not include: + continue + results.append((f, summary)) + + results.sort(key=lambda x: x[0].stat().st_mtime, reverse=True) + return results[:limit] + + def get_project_display_name(folder_name): """Convert encoded folder name to readable project name. @@ -242,6 +299,29 @@ def get_project_display_name(folder_name): return folder_name +def encode_path_to_folder_name(path): + """Encode a filesystem path to Claude's project folder naming convention. + + This is the inverse of how Claude Code stores project folders: + - /Users/foo/bar -> -Users-foo-bar + - /Users/foo/.hidden -> -Users-foo--hidden + + Note: This is NOT the inverse of get_project_display_name(), which is + a one-way beautifier that extracts meaningful project names for display. + """ + # Convert to absolute path but don't resolve symlinks + # (resolve() doesn't work well with non-existent paths) + p = Path(path) + if not p.is_absolute(): + p = Path.cwd() / p + path = str(p) + # Hidden directories: /. becomes -- + encoded = path.replace("/.", "--") + # All other slashes become dashes + encoded = encoded.replace("/", "-") + return encoded + + def find_all_sessions(folder, include_agents=False): """Find all sessions in a Claude projects folder, grouped by project. @@ -260,13 +340,8 @@ def find_all_sessions(folder, include_agents=False): projects = {} for session_file in folder.glob("**/*.jsonl"): - # Skip agent files unless requested - if not include_agents and session_file.name.startswith("agent-"): - continue - - # Get summary and skip boring sessions - summary = get_session_summary(session_file) - if summary.lower() == "warmup" or summary == "(no summary)": + include, summary = _should_include_session(session_file, include_agents) + if not include: continue # Get project folder @@ -1432,15 +1507,17 @@ def local_cmd(output, output_auto, repo, gist, include_json, open_browser, limit return click.echo("Loading local sessions...") - results = find_local_sessions(projects_folder, limit=limit) + cwd = os.getcwd() - if not results: - click.echo("No local sessions found.") - return + current_sessions, project_exists = find_sessions_for_project( + projects_folder, cwd, limit=limit + ) + + other_sessions = find_sessions_excluding_project(projects_folder, cwd, limit=limit) - # Build choices for questionary choices = [] - for filepath, summary in results: + + def format_session(filepath, summary, include_project=False): stat = filepath.stat() mod_time = datetime.fromtimestamp(stat.st_mtime) size_kb = stat.st_size / 1024 @@ -1449,7 +1526,31 @@ def local_cmd(output, output_auto, repo, gist, include_json, open_browser, limit if len(summary) > 50: summary = summary[:47] + "..." display = f"{date_str} {size_kb:5.0f} KB {summary}" - choices.append(questionary.Choice(title=display, value=filepath)) + if include_project: + project_name = get_project_display_name(filepath.parent.name) + display = f"{display} [{project_name}]" + return display + + if current_sessions: + choices.append(questionary.Separator("── Current Project ──")) + for filepath, summary in current_sessions: + display = format_session(filepath, summary, include_project=False) + choices.append(questionary.Choice(title=display, value=filepath)) + elif project_exists: + choices.append(questionary.Separator("── Current Project ──")) + choices.append(questionary.Separator(" (no sessions found)")) + else: + choices.append(questionary.Separator("── No sessions for this project ──")) + + if other_sessions: + choices.append(questionary.Separator("── Other Projects ──")) + for filepath, summary in other_sessions: + display = format_session(filepath, summary, include_project=True) + choices.append(questionary.Choice(title=display, value=filepath)) + + if not current_sessions and not other_sessions: + click.echo("No local sessions found.") + return selected = questionary.select( "Select a session to convert:", diff --git a/tests/test_all.py b/tests/test_all.py index 7e4e601..46775df 100644 --- a/tests/test_all.py +++ b/tests/test_all.py @@ -8,9 +8,12 @@ from claude_code_transcripts import ( cli, + encode_path_to_folder_name, find_all_sessions, - get_project_display_name, + find_sessions_excluding_project, + find_sessions_for_project, generate_batch_html, + get_project_display_name, ) @@ -88,6 +91,150 @@ def test_handles_simple_name(self): assert get_project_display_name("simple-project") == "simple-project" +class TestEncodePathToFolderName: + """Tests for encode_path_to_folder_name function.""" + + def test_encodes_simple_path(self): + """Test encoding simple filesystem path.""" + with tempfile.TemporaryDirectory() as tmpdir: + test_path = Path(tmpdir) / "foo" / "bar" + test_path.mkdir(parents=True) + encoded = encode_path_to_folder_name(str(test_path)) + assert encoded.startswith("-") + assert "foo-bar" in encoded + + def test_encodes_hidden_directory(self): + """Test encoding path with hidden directory.""" + with tempfile.TemporaryDirectory() as tmpdir: + test_path = Path(tmpdir) / ".config" + test_path.mkdir(parents=True) + encoded = encode_path_to_folder_name(str(test_path)) + assert "--config" in encoded + + def test_encodes_root_path(self): + """Test encoding root path.""" + encoded = encode_path_to_folder_name("/") + assert encoded == "-" + + def test_encodes_multiple_hidden_directories(self): + """Test encoding path with multiple hidden directories.""" + with tempfile.TemporaryDirectory() as tmpdir: + test_path = Path(tmpdir) / ".config" / ".cache" + test_path.mkdir(parents=True) + encoded = encode_path_to_folder_name(str(test_path)) + assert "--config--cache" in encoded + + +class TestFindSessionsForProject: + """Tests for find_sessions_for_project function.""" + + def test_finds_sessions_only_for_specified_project(self, mock_projects_dir): + """Test that only sessions from the specified project are returned.""" + project_a_path = "/home/user/projects/project-a" + sessions, exists = find_sessions_for_project( + mock_projects_dir, project_a_path, limit=10 + ) + + assert len(sessions) == 2 + assert exists is True + + for filepath, summary in sessions: + assert filepath.parent.name == "-home-user-projects-project-a" + + def test_respects_limit_parameter(self, mock_projects_dir): + """Test that limit parameter is respected.""" + project_a_path = "/home/user/projects/project-a" + sessions, exists = find_sessions_for_project( + mock_projects_dir, project_a_path, limit=1 + ) + + assert len(sessions) == 1 + assert exists is True + + def test_returns_empty_for_nonexistent_project(self, mock_projects_dir): + """Test handling of non-existent project.""" + nonexistent_path = "/home/user/projects/nonexistent" + sessions, exists = find_sessions_for_project( + mock_projects_dir, nonexistent_path, limit=10 + ) + + assert sessions == [] + assert exists is False + + def test_returns_empty_for_project_with_only_agent_files(self, mock_projects_dir): + """Test that project with only agent files returns empty list.""" + agent_only_project = mock_projects_dir / "-home-user-projects-agent-only" + agent_only_project.mkdir(parents=True) + agent_file = agent_only_project / "agent-test.jsonl" + agent_file.write_text( + '{"type": "user", "timestamp": "2025-01-01T10:00:00.000Z", "message": {"role": "user", "content": "test"}}\n' + ) + + agent_only_path = "/home/user/projects/agent-only" + sessions, exists = find_sessions_for_project( + mock_projects_dir, agent_only_path, limit=10 + ) + + assert sessions == [] + assert exists is True + + def test_excludes_warmup_sessions(self, mock_projects_dir): + """Test that warmup sessions are excluded.""" + project_b_path = "/home/user/projects/project-b" + sessions, exists = find_sessions_for_project( + mock_projects_dir, project_b_path, limit=10 + ) + + assert len(sessions) == 1 + assert exists is True + + +class TestFindSessionsExcludingProject: + """Tests for find_sessions_excluding_project function.""" + + def test_finds_sessions_excluding_specified_project(self, mock_projects_dir): + """Test that sessions from the excluded project are not returned.""" + exclude_path = "/home/user/projects/project-a" + sessions = find_sessions_excluding_project( + mock_projects_dir, exclude_path, limit=10 + ) + + assert len(sessions) == 1 + + for filepath, summary in sessions: + assert filepath.parent.name != "-home-user-projects-project-a" + assert filepath.parent.name == "-home-user-projects-project-b" + + def test_respects_limit_parameter(self, mock_projects_dir): + """Test that limit parameter is respected.""" + exclude_path = "/home/user/projects/project-a" + sessions = find_sessions_excluding_project( + mock_projects_dir, exclude_path, limit=1 + ) + + assert len(sessions) == 1 + + def test_excludes_agent_files(self, mock_projects_dir): + """Test that agent files are excluded.""" + exclude_path = "/home/user/projects/nonexistent" + sessions = find_sessions_excluding_project( + mock_projects_dir, exclude_path, limit=10 + ) + + for filepath, summary in sessions: + assert not filepath.name.startswith("agent-") + + def test_excludes_warmup_sessions(self, mock_projects_dir): + """Test that warmup sessions are excluded.""" + exclude_path = "/home/user/projects/nonexistent" + sessions = find_sessions_excluding_project( + mock_projects_dir, exclude_path, limit=10 + ) + + for filepath, summary in sessions: + assert summary.lower() != "warmup" + + class TestFindAllSessions: """Tests for find_all_sessions function.""" @@ -420,7 +567,7 @@ class TestJsonCommandWithUrl: def test_json_command_accepts_url(self, output_dir): """Test that json command can accept a URL starting with http:// or https://.""" - from unittest.mock import patch, MagicMock + from unittest.mock import MagicMock, patch # Sample JSONL content jsonl_content = ( @@ -458,7 +605,7 @@ def test_json_command_accepts_url(self, output_dir): def test_json_command_accepts_http_url(self, output_dir): """Test that json command can accept http:// URLs.""" - from unittest.mock import patch, MagicMock + from unittest.mock import MagicMock, patch jsonl_content = '{"type": "user", "timestamp": "2025-01-01T10:00:00.000Z", "message": {"role": "user", "content": "Hello"}}\n' @@ -486,6 +633,7 @@ def test_json_command_accepts_http_url(self, output_dir): def test_json_command_url_fetch_error(self, output_dir): """Test that json command handles URL fetch errors gracefully.""" from unittest.mock import patch + import httpx runner = CliRunner()