diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 77bc6eb1..0b77351d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -660,13 +660,20 @@ The tool implements a SQLite-based caching system for performance: its total at 0 until it finishes. Only the refresh writes entry rows: a run renders what its refresh committed, so lines written to a session while it renders appear on the next run. A date-filtered run refreshes the - cache as an unfiltered one would and leaves the files it wrote from its - window marked stale, so the next unfiltered run re-renders them. Its - refresh therefore loads whole files when they changed and the incremental - refresh declines, not just the window's entries, and nothing when no file - changed. A date-filtered `--all-projects` pass converts the projects with a - session in the window (or a changed file, or no combined page at the - destination) and reports the others as having no messages in it. A pass that loses a project ends with a count of the + cache as an unfiltered one would (whole files when they changed and the + incremental refresh declines, nothing when no file changed); an + `--all-projects` pass refreshes every project before converting any, + serially in the parent, to read which sessions the window shows. It + writes a view of the window (`window_session_ids` decides its session + pages, the index's links and the check below alike), into a destination + that holds none of its pages or, with `--force`, over them, removing + nothing; the vendored `assets/` and referenced images are not checked. + Written in place, it leaves the `html_cache`/`html_pages` rows of the + files it replaced marked stale, so the next unfiltered run re-renders + them; written anywhere else it touches no such row, since they are keyed + by project and file name and describe the full view. The master + `index.html`, `search.html` and the recent page have no row: every run + rewrites them. A pass that loses a project ends with a count of the failed projects, and the CLI exits 1 - **Performance**: 10-100x faster loading for large projects diff --git a/README.md b/README.md index b240c0c2..d912bf00 100644 --- a/README.md +++ b/README.md @@ -121,9 +121,9 @@ claude-code-log --all-projects # Process all projects and open in browser claude-code-log --open-browser -# Process all projects with date filtering -claude-code-log --from-date "yesterday" --to-date "today" -claude-code-log --from-date "last week" +# A view of a date window, in a directory of its own +claude-code-log --from-date "yesterday" --to-date "today" -o ~/yesterday +claude-code-log --from-date "last week" -o ~/last-week # Skip individual session files (only create combined transcripts) claude-code-log --no-individual-sessions @@ -152,10 +152,19 @@ claude-code-log /path/to/directory -o combined_transcripts.html claude-code-log /path/to/directory --open-browser # Filter by date range (supports natural language) -claude-code-log /path/to/directory --from-date "yesterday" --to-date "today" -claude-code-log /path/to/directory --from-date "3 days ago" --to-date "yesterday" +claude-code-log /path/to/directory --from-date "yesterday" --to-date "today" --combined only -o ~/yesterday.html +claude-code-log /path/to/directory --from-date "3 days ago" --to-date "yesterday" --force ``` +A date-filtered run writes a view of its window: only the projects and +sessions with messages in it, with the window's totals. It goes into a +destination that holds none of the pages it writes, so over the pages of +an earlier run it refuses to start: give it another `-o`, or `--force` to +write over them (nothing is removed). For one directory, `-o` names the +combined page alone and the session pages go into the directory, hence +`--combined only` above. See [Viewing a date +window](https://daaain.github.io/claude-code-log/date-window/). + ### Feeding Past Conversations to an LLM The combination `--depth agent --format md --compact` produces condensed Markdown suitable as context for an LLM to review or distill patterns from past work: diff --git a/claude_code_log/cache.py b/claude_code_log/cache.py index 79ae0336..2958ae0e 100644 --- a/claude_code_log/cache.py +++ b/claude_code_log/cache.py @@ -11,7 +11,7 @@ import threading import zlib from contextlib import contextmanager -from datetime import datetime, timezone +from datetime import datetime from pathlib import Path from typing import Any, Callable, Dict, Generator, List, Optional, Tuple, get_args @@ -3451,45 +3451,6 @@ def update_html_cache( ), ) - def has_sessions_in_window( - self, from_dt: Optional[datetime], to_dt: Optional[datetime] - ) -> bool: - """Does a visible session of this project overlap the window? - - Read from the session rows' first and last timestamps, so it is - only as current as those rows: a caller must not trust it for a - project whose files changed since. ``from_dt`` / ``to_dt`` are - naive UTC, as ``utils.parse_date_bounds`` returns them. A session - whose stamps do not parse counts as overlapping. - """ - from .utils import parse_timestamp - - if self._project_id is None: - return True - - def naive_utc(stamp: Optional[str]) -> Optional[datetime]: - parsed = parse_timestamp(stamp) - if parsed is None: - return None - return parsed.astimezone(timezone.utc).replace(tzinfo=None) - - with self._get_connection() as conn: - rows = conn.execute( - """SELECT first_timestamp, last_timestamp FROM sessions - WHERE project_id = ? AND hidden = 0""", - (self._project_id,), - ).fetchall() - for row in rows: - first = naive_utc(row["first_timestamp"]) - last = naive_utc(row["last_timestamp"]) - if first is None or last is None: - return True - if (to_dt is None or first <= to_dt) and ( - from_dt is None or last >= from_dt - ): - return True - return False - def mark_rendered_from_window(self, html_path: str) -> None: """Record that ``html_path`` was just written from a date window. diff --git a/claude_code_log/cli.py b/claude_code_log/cli.py index fa9ec029..100c60d4 100644 --- a/claude_code_log/cli.py +++ b/claude_code_log/cli.py @@ -23,6 +23,7 @@ ) from .converter import ( RegenerationReport, + WindowOutputExists, convert_jsonl_to, convert_jsonl_to_html, ensure_fresh_cache, @@ -1015,6 +1016,16 @@ def main() -> None: type=str, help='Filter messages up to this date/time (e.g., "1 hour ago", "today", "2025-06-08 15:00")', ) +@click.option( + "--force", + is_flag=True, + help=( + "With --from-date/--to-date: write the date window's view over the " + "files of it that already exist at the destination. Without it, such " + "a run refuses to start when any does. Nothing is removed: pages of " + "an earlier view that this one does not rewrite stay, unlinked." + ), +) @click.option( "--all-projects", is_flag=True, @@ -1214,6 +1225,7 @@ def convert( open_browser: bool, from_date: Optional[str], to_date: Optional[str], + force: bool, all_projects: bool, no_individual_sessions: bool, no_cache: bool, @@ -1260,6 +1272,11 @@ def convert( # usage error here, before any work starts. resolved_theme = _resolve_theme(theme) + # `--force` lets a date window's view overwrite the files of it already + # at its destination; there is nothing else for it to force yet. + if force and from_date is None and to_date is None: + raise click.UsageError("--force is only accepted with --from-date/--to-date.") + # Provider mode has three sub-modes: # * export — `--session-id `: render one session by id # * single-file — INPUT_PATH is a rollout FILE: render that one session @@ -1299,6 +1316,10 @@ def convert( conflicts: list[str] = [] if tui: conflicts.append("--tui") + if force: + # The provider pipeline writes its date window over a full view + # as before, so there is nothing for --force to allow. + conflicts.append("--force") if provider_wholesale: # Wholesale honors --expand-paths/--filter-path (Obsidian projection), # --combined, date range, -o/-f, --open-browser, and the cache flags @@ -1940,6 +1961,7 @@ def render_provider(destination: Path) -> Path: theme=resolved_theme, jobs=jobs, failed_projects=failed_projects, + force=force, ) # Count processed projects @@ -1979,6 +2001,11 @@ def render_provider(destination: Path) -> Path: from .providers import discover_providers detected = discover_providers().detect_provider_for_path(input_path) + if detected is not None and force: + raise click.UsageError( + f"--force does not apply to {input_path}, a {detected} " + "input: its date-filtered view is written as before." + ) if detected is not None: if input_path.is_dir(): _run_provider_wholesale( @@ -2122,6 +2149,7 @@ def render_provider(destination: Path) -> Path: render_jobs=( min(jobs, resolve_render_jobs(None)) if jobs is not None else None ), + force=force, ) # Report only work actually done this run. On a pure skip the converter # already printed its "... is current, skipping regeneration" line, so @@ -2160,7 +2188,7 @@ def render_provider(destination: Path) -> Path: if open_browser: click.launch(str(output_path)) - except FileNotFoundError as e: + except (FileNotFoundError, WindowOutputExists) as e: click.echo(f"Error: {e}", err=True) if debug: import traceback diff --git a/claude_code_log/converter.py b/claude_code_log/converter.py index 489c0bbf..e3965161 100644 --- a/claude_code_log/converter.py +++ b/claude_code_log/converter.py @@ -32,6 +32,7 @@ from .render_pool import RenderPool from .utils import ( + date_window_label, DEFAULT_THEME, output_theme, atomic_write_text, @@ -3007,6 +3008,7 @@ def convert_jsonl_to_html( silent: bool = False, page_size: int = 2000, depth: RenderingDepth = DEFAULT_DEPTH, + force: bool = False, ) -> Path: """Convert JSONL transcript(s) to HTML file(s). @@ -3025,6 +3027,7 @@ def convert_jsonl_to_html( silent, page_size=page_size, depth=depth, + force=force, ) @@ -3829,6 +3832,8 @@ def convert_jsonl_to( archive_search_link: Optional[str] = None, render_jobs: Optional[int] = None, entry_store: "Optional[ParsedEntryStore]" = None, + force: bool = False, + window_sessions: Optional[frozenset[str]] = None, ) -> Path: """Convert JSONL transcript(s) to the specified format. @@ -3877,6 +3882,16 @@ def convert_jsonl_to( single-threaded behaviour. An explicit int overrides the environment. See ``build_render_pool`` for the further conditions under which a pool is actually created. + force: With a date window, write over the files of the view that + already exist at the destination. Without it a windowed run + raises ``WindowOutputExists`` before writing anything when one + does (see ``window_session_ids`` for which session pages it + writes). Nothing is removed either way. + window_sessions: With a date window, the sessions the caller read + the window as showing (``process_projects_hierarchy`` links + these from its index and checked their pages): no other + session page is written, even for a session that gained a + message in the window since. theme: HTML theme: ``"classic"``, ``"minimal"``, or ``"default"`` (the built-in default, ``utils.DEFAULT_THEME``). Ignored for Markdown/JSON. Themes overwrite the same filenames; every @@ -4084,11 +4099,48 @@ def convert_jsonl_to( title = f"Claude Transcripts - {project_title}" # Apply date filtering + loaded = messages messages = filter_messages_by_date(messages, from_date, to_date) # Deduplicate messages (removes version stutters while preserving concurrent tool results) messages = deduplicate_messages(messages) + # A date window is a view of its own: its session pages are the + # window's sessions, it goes only where none of its files exists yet + # (or over them, forced), and it is recorded in the render cache only + # when written in place, where it replaced pages of the full view. + windowed = from_date is not None or to_date is not None + shown_sessions: Optional[set[str]] = None + window_rows: Optional[Dict[str, SessionCacheData]] = None + record_window = True + if windowed: + assert output_path is not None + if input_path.is_dir(): + window_rows = _window_session_rows(cache_manager, loaded) + shown_sessions = window_session_ids(messages, window_rows) + if window_sessions is not None: + shown_sessions &= window_sessions + record_window = ( + Path(effective_output_dir).resolve() == Path(input_path).resolve() + ) + if not force: + view_files = [output_path] if write_combined else [] + if generate_individual_sessions and shown_sessions: + view_files += [ + effective_output_dir / f"session-{sid}{suffix}.{ext}" + for sid in sorted(shown_sessions) + ] + existing = _first_existing(view_files) + if existing is not None: + raise WindowOutputExists( + existing, + project_pages=input_path.is_dir() + and generate_individual_sessions + and record_window + and bool(shown_sessions), + combined=write_combined, + ) + # Update title to include date range if specified if from_date or to_date: date_range_parts: list[str] = [] @@ -4328,6 +4380,7 @@ def convert_jsonl_to( if ( cache_manager is not None and update_cache + and record_window and _tracks_version_marker(format) ): if from_date is not None or to_date is not None: @@ -4369,6 +4422,9 @@ def convert_jsonl_to( theme=theme, render_pool=render_pool, fragment_store=fragment_store, + restrict_to_sessions=shown_sessions, + session_rows=window_rows, + record_rendered=record_window, ) finally: @@ -5080,6 +5136,161 @@ def _collect_project_sessions(messages: list[TranscriptEntry]) -> list[dict[str, ) +class WindowOutputExists(Exception): + """A date-filtered run found one of its files already at its destination. + + A window view goes into a destination that holds none of the files it + writes, or over them when forced: written over a full view, it would + leave that view's other pages beside its own, linked from nowhere, and + mixed with it. Raised before anything is written; ``path`` is the + first such file. + """ + + def __init__( + self, path: Path, *, project_pages: bool = False, combined: bool = True + ) -> None: + self.path = path + # For one project, --output names the combined page only: its + # session pages go into the project whatever it names. Without a + # combined page, nothing is left for --output to move. + if not project_pages: + way_out = ", or choose another --output" + elif combined: + way_out = ( + ", or write the combined page alone elsewhere, with " + "--combined only and --output" + ) + else: + way_out = ( + "; a project's session pages are written into it, whatever " + "--output names" + ) + super().__init__( + f"{path} already exists. A date-filtered run writes only into a " + "destination that holds none of the files it writes: pass --force " + f"to overwrite them (nothing is removed){way_out}." + ) + + +def _first_existing(paths: "Iterator[Path] | list[Path]") -> Optional[Path]: + """The first of ``paths`` that exists, if any.""" + for path in paths: + if path.exists(): + return path + return None + + +def window_session_ids( + messages: list[TranscriptEntry], session_rows: Dict[str, SessionCacheData] +) -> set[str]: + """The sessions a date window shows, given its messages. + + A trunk session with a message in the window, among the sessions the + index lists (a row with a first user message, not a warmup, not an + agent's). Its window part need not hold a user message of its own, and + first and last stamps that straddle the window do not make it one. + + The one reading of the window for the three that must agree: the + session pages a windowed run writes, the ones its index links, and + the files its destination may not already hold. + """ + shown = { + sid + for sid, row in session_rows.items() + if row.first_user_message + and row.first_user_message != "Warmup" + and not is_agent_session(sid) + } + return collect_trunk_session_ids(messages, get_warmup_session_ids(messages)) & shown + + +def _window_session_rows( + cache_manager: Optional[CacheManager], loaded: List[TranscriptEntry] +) -> Dict[str, SessionCacheData]: + """The whole-session rows a date window's sessions are chosen among. + + The cache's rows when there is a cache. Without one (``--no-cache``) + the same rows are computed from ``loaded``, the project's load before + the window's filter: a load without a cache parses the whole files, + and a session's row (its first user message, its title) is the whole + session's, wherever the window cuts it. + """ + if cache_manager is not None: + cached = cache_manager.get_cached_project_data() + return dict(cached.sessions) if cached is not None else {} + return _build_session_data_from_messages(loaded) + + +@dataclass +class _WindowView: + """What a date window shows of one project, for the master index.""" + + session_ids: frozenset[str] + # The index's session entries, without their ``file`` link. + sessions: List[Dict[str, Any]] + aggregates: Dict[str, Any] + # Whether the project has a session to show. + shown: bool + + +def _project_window_view( + project_dir: Path, + cache_manager: Optional[CacheManager], + from_date: Optional[str], + to_date: Optional[str], +) -> _WindowView: + """Read what the window shows of ``project_dir``, from a fresh cache. + + The same load and filter as the conversion's (``convert_jsonl_to``), + so its sessions are the ones the conversion writes pages for. Each + session keeps its whole-session title, with the window's message count + and time range. + """ + messages, _tree = load_directory_transcripts( + project_dir, + cache_manager, + from_date, + to_date, + silent=True, + snapshot=cache_manager is not None, + ) + rows = _window_session_rows(cache_manager, messages) + messages = deduplicate_messages( + filter_messages_by_date(messages, from_date, to_date) + ) + ids = window_session_ids(messages, rows) + window_data = compute_session_data( + messages, + include_cwd=False, + warmup_session_ids=get_warmup_session_ids(messages), + ) + sessions: List[Dict[str, Any]] = [] + for sid in ids: + row = rows[sid] + part = window_data.get(sid) + first = part.first_timestamp if part else row.first_timestamp + last = part.last_timestamp if part else row.last_timestamp + sessions.append( + { + "id": sid, + "summary": row.ai_title or row.summary, + "timestamp_range": format_timestamp_range(first, last), + "first_timestamp": first, + "last_timestamp": last, + "message_count": part.message_count if part else 0, + "first_user_message": row.first_user_message, + "team_name": row.team_name, + } + ) + sessions.sort(key=lambda s: (s["first_timestamp"] or "", s["id"])) + return _WindowView( + session_ids=frozenset(ids), + sessions=sessions, + aggregates=compute_project_aggregates(messages), + shown=bool(ids), + ) + + def build_session_title( project_title: str, session_id: str, @@ -5177,6 +5388,8 @@ def _generate_individual_session_files( render_pool: "Optional[RenderPool]" = None, fragment_store: "Optional[RenderFragmentStore]" = None, restrict_to_sessions: Optional[set[str]] = None, + session_rows: Optional[Dict[str, SessionCacheData]] = None, + record_rendered: bool = True, ) -> int: """Generate individual files for each session in the specified format. @@ -5192,7 +5405,16 @@ def _generate_individual_session_files( from another page (a source file spanning two sessions) — rendering that remnant would silently truncate the session's file. Restricted out, it renders complete when its own page's - load comes around. None (every other caller) considers all. + load comes around. A date-filtered conversion passes the + window's sessions (``window_session_ids``). None considers all. + session_rows: The session rows to use when there is no cache: a + date-filtered conversion passes the ones it chose the window's + sessions among (``_window_session_rows``). Without a cache and + without them, no session has a row, so none is written. + record_rendered: Whether to record the files written in the + render cache. A date-filtered view written outside the + project's own directory is not: the rows are keyed by file + name, and are the full view's. Returns: Number of sessions regenerated @@ -5225,6 +5447,8 @@ def _generate_individual_session_files( session_data = {s.session_id: s for s in project_cache.sessions.values()} # Get working directories for project title working_directories = cache_manager.get_working_directories() + elif session_rows is not None: + session_data = dict(session_rows) # Only generate HTML for sessions that are tracked in the sessions table # (filters out warmup-only and sessions without user messages) @@ -5364,7 +5588,11 @@ def _record_session(unit: RenderUnit) -> None: regenerated_count += 1 # Update html_cache to track this generation (marker-tracked # formats only; JSON uses its own version-field freshness). - if cache_manager is None or not _tracks_version_marker(format): + if ( + cache_manager is None + or not record_rendered + or not _tracks_version_marker(format) + ): return if from_date is not None or to_date is not None: cache_manager.mark_rendered_from_window(unit.file_name) @@ -6230,9 +6458,11 @@ class _ProjectPlan: # better than bytes: see _holdback_plans. cached_message_count: Optional[int] = None error: Optional[str] = None - # A date-filtered pass left this project alone: none of its sessions - # overlaps the window (``_plan_project``). + # A date-filtered pass left this project alone: the window shows none + # of its sessions (``window``). outside_window: bool = False + # A date-filtered pass's view of this project, read before the pool. + window: "Optional[_WindowView]" = None # How big a project has to be before holding it out of the project pool @@ -6350,6 +6580,85 @@ def cost(plan: "_ProjectPlan") -> int: return held +def _window_project_summary( + plan: _ProjectPlan, + view: _WindowView, + *, + rel_dest: str, + variant: str, + combined_ext: str, + write_combined: bool, +) -> Dict[str, Any]: + """A project's card on a window view's master index. + + The same shape as the full view's card, with the window's totals and + sessions. + """ + jsonl_files = trunk_jsonl_files(plan.project_dir) + aggregates = view.aggregates + plan.stats.sessions_total = len(view.sessions) + return { + "name": plan.project_dir.name, + "path": plan.project_dir, + "html_file": f"{rel_dest}/{plan.output_path.name}", + "jsonl_count": len(jsonl_files), + "message_count": aggregates["total_message_count"], + "last_modified": ( + max(f.stat().st_mtime for f in jsonl_files) if jsonl_files else 0.0 + ), + "total_input_tokens": aggregates["total_input_tokens"], + "total_output_tokens": aggregates["total_output_tokens"], + "total_cache_creation_tokens": aggregates["total_cache_creation_tokens"], + "total_cache_read_tokens": aggregates["total_cache_read_tokens"], + "latest_timestamp": aggregates["latest_timestamp"], + "earliest_timestamp": aggregates["earliest_timestamp"], + "working_directories": ( + plan.cache_manager.get_working_directories() if plan.cache_manager else [] + ), + "is_archived": False, + "combined_suppressed": not write_combined, + "sessions": [ + { + **{k: v for k, v in session.items() if k != "team_name"}, + "file": f"{rel_dest}/session-{session['id']}{variant}.{combined_ext}", + } + for session in view.sessions + ], + "team_names": sorted({s["team_name"] for s in view.sessions if s["team_name"]}), + } + + +def _window_view_files( + plans: List[_ProjectPlan], + *, + index_root: Path, + output_format: str, + combined_name: str, + session_suffix: str, + write_combined: bool, + generate_individual_sessions: bool, +) -> Iterator[Path]: + """Every page a date-filtered pass over ``plans`` writes, index first. + + The rendered pages only: the vendored ``assets/`` and referenced + images are the same bytes in any view, so writing over them loses + nothing. A session page is ``session-``. + """ + from .recent import RECENT_DATA, RECENT_PAGE + + yield index_root / get_index_filename(output_format) + if output_format == "html": + yield index_root / "search.html" + yield index_root / RECENT_PAGE + yield index_root / RECENT_DATA + for plan in plans: + if write_combined: + yield plan.dest_dir / combined_name + if generate_individual_sessions and plan.window is not None: + for sid in sorted(plan.window.session_ids): + yield plan.dest_dir / f"session-{sid}{session_suffix}" + + def _plan_project( project_dir: Path, *, @@ -6365,7 +6674,6 @@ def _plan_project( page_size: int, theme: str = DEFAULT_THEME, generate_individual_sessions: bool = True, - window: Optional[tuple[Optional[datetime], Optional[datetime]]] = None, ) -> Optional[_ProjectPlan]: """Resolve destination and staleness for one project (no rendering). @@ -6374,15 +6682,9 @@ def _plan_project( shared cache DB's schema/migrations and this project's row exist before concurrent workers touch the DB. - ``window`` is a date-filtered pass's ``parse_date_bounds``. Such a - pass converts a project when one of its sessions overlaps the window, - whether or not anything changed, and leaves every other one alone: - converting a project with nothing in the window would rewrite its - combined output empty and mark it as rendered from a window, which the - next plain run re-renders. A project with a changed file is converted - as before, since its session rows cannot tell what the change holds, - and so is one whose combined output is missing at the destination, - since the index links it. + A date-filtered pass decides which projects to convert afterwards, + from what the window shows (``_process_projects_hierarchy``'s window + phase), so the staleness found here does not decide it. """ from .utils import project_destination @@ -6512,20 +6814,6 @@ def _plan_project( ) ) - outside_window = False - if window is not None and cache_manager is not None: - # A combined page missing at the destination (a fresh --output) is - # written even when empty for the window: the index links it. - # A project migration 016 zeroed heals here too: a user who only - # ever runs windowed passes would otherwise keep it at zero. - needs_work = ( - bool(modified_files) - or (write_combined and not output_path.exists()) - or unrefreshed - or cache_manager.has_sessions_in_window(*window) - ) - outside_window = not needs_work - if needs_work: stats.files_updated = len(modified_files) if modified_files else 0 stats.files_loaded_from_cache = len(jsonl_files) - stats.files_updated @@ -6554,7 +6842,6 @@ def _plan_project( stats=stats, source_bytes=sum(f.stat().st_size for f in jsonl_files), cached_message_count=cached_message_count, - outside_window=outside_window, ) @@ -6618,6 +6905,7 @@ def process_projects_hierarchy( jobs: Optional[int] = None, entry_store: "Optional[ParsedEntryStore]" = None, failed_projects: Optional[List[str]] = None, + force: bool = False, ) -> Path: """Process the entire ~/.claude/projects/ hierarchy and create linked output files. @@ -6690,6 +6978,7 @@ def process_projects_hierarchy( jobs=jobs, entry_store=entry_store, failed_projects=failed_projects, + force=force, ) if use_cache: # The pass's workers have all exited: the files they rewrote @@ -6711,6 +7000,8 @@ def _write_recent_page( project_summaries: List[Dict[str, Any]], theme: str, session_pages: bool = True, + from_date: Optional[str] = None, + to_date: Optional[str] = None, ) -> None: """Write ``recent.html`` and its ``recent-data.js`` next to the index. @@ -6720,24 +7011,51 @@ def _write_recent_page( follow ``--output`` / ``--expand-paths`` / variant filenames exactly as the index does. Without ``session_pages`` (``--combined only``) there are none: the index names each session's file all the same, - but nothing writes it. A failure here must never fail the conversion: - the page is a convenience next to the index, not part of it. + but nothing writes it. Under a date window the page lists the + window's sessions, those of the index's cards, each with the window's + part of it, and names the window. A failure here must never fail the + conversion: the page is a convenience next to the index, not part of + it. """ - from .recent import RECENT_DATA, RECENT_PAGE, build_recent_payload, recent_data_js + from .recent import ( + RECENT_DATA, + RECENT_PAGE, + RecentWindow, + build_recent_payload, + recent_data_js, + ) links: Dict[str, str] = {} for summary in project_summaries if session_pages else []: for session in summary.get("sessions", []): if session.get("id") and session.get("file"): links[str(session["id"])] = str(session["file"]) + label = date_window_label(from_date, to_date) try: from .html.renderer import generate_recent_html + window = ( + RecentWindow( + label=label, + sessions={ + (str(Path(summary["path"])), str(session["id"])): session + for summary in project_summaries + for session in summary.get("sessions", []) + }, + keep=lambda entries: filter_messages_by_date( + entries, from_date, to_date + ), + ) + if label + else None + ) payload = build_recent_payload( - projects_path, href_for=lambda _project, sid: links.get(sid) + projects_path, + href_for=lambda _project, sid: links.get(sid), + window=window, ) atomic_write_text(index_dir / RECENT_DATA, recent_data_js(payload)) - atomic_write_text(index_dir / RECENT_PAGE, generate_recent_html(theme)) + atomic_write_text(index_dir / RECENT_PAGE, generate_recent_html(theme, label)) except Exception as exc: # pragma: no cover - defensive print(f"Warning: could not write {RECENT_PAGE}: {exc}") @@ -6764,6 +7082,7 @@ def _process_projects_hierarchy( jobs: Optional[int] = None, entry_store: "Optional[ParsedEntryStore]" = None, failed_projects: Optional[List[str]] = None, + force: bool = False, ) -> Path: """Process the entire ~/.claude/projects/ hierarchy and create linked output files. @@ -6797,6 +7116,16 @@ def _process_projects_hierarchy( staleness plan (``_plan_project``) as well as the conversion. failed_projects: When given, the names of the projects that failed are appended to it (see ``process_projects_hierarchy``). + force: With a date window, write over the view's files that + already exist at the destination; without it the pass raises + ``WindowOutputExists`` before writing anything when one does. + Nothing is removed either way. + + With a date window the pass writes a view of it: the master index + lists the projects with a session in the window (``window_session_ids``), + with the window's totals, and each such project's combined page and + the window's session pages. Archived projects, which have no source + to render a window from, are left out. """ import time @@ -6930,7 +7259,6 @@ def _archive_search_link(plan: "_ProjectPlan") -> Optional[str]: page_size=page_size, theme=theme, generate_individual_sessions=generate_individual_sessions, - window=window, ) except Exception as e: stats = GenerationStats() @@ -6949,9 +7277,6 @@ def _archive_search_link(plan: "_ProjectPlan") -> Optional[str]: total_archived += plan.archived_count plans.append(plan) - to_convert = [p for p in plans if p.needs_work] - projects_with_updates = sum(1 for p in to_convert if p.stats.files_updated > 0) - def _archived_suffix(plan: _ProjectPlan) -> str: return f", {plan.archived_count} archived" if plan.archived_count > 0 else "" @@ -6973,10 +7298,52 @@ def _print_project_failed(plan: _ProjectPlan, error: str) -> None: plan.stats.add_error(last_line) print(f"Warning: Failed to process {plan.project_dir}:\n{error}") + # ---- Phase 1w (window): what the window shows, before any output is + # written. The session rows describe the files only once refreshed, so + # each project's cache is refreshed here, in the parent and one project + # at a time (unfiltered, as the conversion would; the conversion then + # finds it fresh). The view's files are then checked at the + # destination, all of them, before the first is written. + if window is not None: + for plan in plans: + try: + ensure_fresh_cache_detailed( + plan.project_dir, plan.cache_manager, silent=True + ) + plan.window = _project_window_view( + plan.project_dir, plan.cache_manager, from_date, to_date + ) + except Exception: + _print_project_failed(plan, traceback.format_exc()) + continue + plan.needs_work = plan.window.shown + plan.outside_window = not plan.window.shown + # The session pages it writes: the window's, not the stale ones. + plan.stats.sessions_regenerated = ( + len(plan.window.session_ids) if generate_individual_sessions else 0 + ) + if not force: + existing = _first_existing( + _window_view_files( + [p for p in plans if p.error is None and not p.outside_window], + index_root=index_root, + output_format=output_format, + combined_name=combined_name, + session_suffix=f"{variant}.{combined_ext}", + write_combined=write_combined, + generate_individual_sessions=generate_individual_sessions, + ) + ) + if existing is not None: + raise WindowOutputExists(existing) + + to_convert = [p for p in plans if p.needs_work and p.error is None] + projects_with_updates = sum(1 for p in to_convert if p.stats.files_updated > 0) + # Cached (no-work) projects are reported first, in stable # alphabetical order; regenerated projects follow as they finish. for plan in plans: - if not plan.needs_work: + if not plan.needs_work and plan.error is None: state = "no messages in the window" if plan.outside_window else "cached" print( f" {plan.project_dir.name}: {state}{_archived_suffix(plan)} " @@ -7093,6 +7460,9 @@ def _conversion_kwargs( # budget for the render fan-out, computed by the parent so the # two levels together never exceed `--jobs`. "render_jobs": render_jobs, + # Phase 1w checked the view's files and linked these sessions. + "force": True, + "window_sessions": plan.window.session_ids if plan.window else None, } # Projects that failed only because another process held the cache's @@ -7248,6 +7618,23 @@ def _convert_plan_inline( output_path = plan.output_path stats = plan.stats + if window is not None: + # A window view lists the projects the window shows, with what + # it shows of them, and links only the pages this pass wrote. + project_stats.append((project_dir.name, stats)) + if plan.window is not None and plan.window.shown: + project_summaries.append( + _window_project_summary( + plan, + plan.window, + rel_dest=_rel_to_index(dest_dir), + variant=variant, + combined_ext=combined_ext, + write_combined=write_combined, + ) + ) + continue + try: # Get project info for index - use cached data if available # Exclude agent files (they are loaded via session references) @@ -7455,10 +7842,12 @@ def _convert_plan_inline( ) continue - # Process archived projects (projects in cache but without JSONL files) + # Process archived projects (projects in cache but without JSONL files). + # Not in a window view: with no source left, the pass renders no window + # of theirs, and their pages are the full view's. archived_project_count = 0 archived_failures: List[str] = [] - for archived_dir in sorted(archived_project_dirs): + for archived_dir in sorted(archived_project_dirs if window is None else []): try: # Initialize cache manager for archived project cache_manager = CacheManager(archived_dir, library_version) @@ -7597,7 +7986,8 @@ def _convert_plan_inline( from .html.renderer import generate_archive_search_html atomic_write_text( - index_path.parent / "search.html", generate_archive_search_html(theme) + index_path.parent / "search.html", + generate_archive_search_html(theme, date_window_label(from_date, to_date)), ) _write_recent_page( index_path.parent, @@ -7605,6 +7995,8 @@ def _convert_plan_inline( project_summaries, theme, session_pages=generate_individual_sessions, + from_date=from_date, + to_date=to_date, ) # Count total sessions from project summaries diff --git a/claude_code_log/html/renderer.py b/claude_code_log/html/renderer.py index d4bec843..fa7e667e 100644 --- a/claude_code_log/html/renderer.py +++ b/claude_code_log/html/renderer.py @@ -2146,7 +2146,9 @@ def generate_projects_index_html( return renderer.generate_projects_index(project_summaries, from_date, to_date) -def generate_archive_search_html(theme: str = DEFAULT_THEME) -> str: +def generate_archive_search_html( + theme: str = DEFAULT_THEME, window: Optional[str] = None +) -> str: """Render the archive-wide search page. Written on every HTML run, including for people who never start the @@ -2156,6 +2158,8 @@ def generate_archive_search_html(theme: str = DEFAULT_THEME) -> str: ``theme`` is the run's HTML theme (``utils.THEMES``): like the index, the page is rewritten on every run, so it always follows the run. + ``window`` is a date-filtered run's window (``date_window_label``): + the search still covers the whole archive, and the page says so. """ from ..search import SEARCH_FIELD_HINTS, SEARCH_FIELD_LABELS, SEARCH_FIELDS @@ -2164,6 +2168,7 @@ def generate_archive_search_html(theme: str = DEFAULT_THEME) -> str: env.get_template("archive_search.html").render( library_version=get_library_version(), theme=normalize_theme(theme), + window=window, # The field toggles are rendered from the canonical list, so a # new group appears in the UI without anyone remembering to add # it. Which of them start out *checked* is deliberately not @@ -2182,7 +2187,9 @@ def generate_archive_search_html(theme: str = DEFAULT_THEME) -> str: ) -def generate_recent_html(theme: str = DEFAULT_THEME) -> str: +def generate_recent_html( + theme: str = DEFAULT_THEME, window: Optional[str] = None +) -> str: """Render the recent sessions page (``recent.html``). The page holds no data: it loads the sibling ``recent-data.js`` @@ -2190,12 +2197,15 @@ def generate_recent_html(theme: str = DEFAULT_THEME) -> str: every refresh, so it is identical from run to run and no transcript text is ever inlined into it. It has one look in both themes (the minimal theme's tokens); ``theme`` is accepted for symmetry with the - other page generators and recorded in the stamp. + other page generators and recorded in the stamp. ``window`` is a + date-filtered run's window (``date_window_label``), which the page + names: its data is then the window's sessions. """ env = get_template_environment() return str( env.get_template("recent.html").render( library_version=get_library_version(), theme=normalize_theme(theme), + window=window, ) ) diff --git a/claude_code_log/html/templates/archive_search.html b/claude_code_log/html/templates/archive_search.html index 2dd0bce4..b16400f6 100644 --- a/claude_code_log/html/templates/archive_search.html +++ b/claude_code_log/html/templates/archive_search.html @@ -214,7 +214,9 @@

Search all transcripts

← All projects{% if minimal %}{% include 'components/minimal/scheme_toggle.html' %}{% endif %} - +{% if window %} +

The index beside this page shows the date window {{ window }}; this search covers the whole archive.

+{% endif %}