Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,14 @@ jobs:

mypy:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false

- uses: actions/setup-python@v5
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.12"
cache: pip
Expand Down
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,22 @@ Browse and export Claude Code chat history — Web GUI and CLI.
- **Smooth transitions** — staggered card/message animations, crossfade content swaps
- **Scroll-to-top button** in bottom-right corner
- **Per-model badges** in session header
- **Bulk export** — download all sessions, incremental updates, or latest-day slice as a zip; if there is nothing to export, the API returns **422** with JSON body `{"error": "Nothing to export", "since": "<mode>"}` (the `since` field echoes your request: `"all"`, `"last"`, or `"incremental"`) instead of an empty zip
- **Bulk export** — download all sessions, incremental updates, or latest-day slice as a zip; if there is nothing to export, the API returns **422** with JSON body `{"error": "Nothing to export", "code": "EXPORT_NOTHING_TO_EXPORT", "since": "<mode>"}` (the `since` field echoes your request: `"all"`, `"last"`, or `"incremental"`) instead of an empty zip

### API error codes

JSON error responses include a machine-readable `"code"` (stable `UPPER_SNAKE_CASE`) and a human-readable `"error"` message. Common codes:

| Code | Typical HTTP | Meaning |
|------|--------------|---------|
| `SEARCH_INVALID_LIMIT` | 400 | Query param `limit` is not a positive integer |
| `INVALID_PATH` | 400 | Path traversal or unsafe project/session path |
| `SESSION_NOT_FOUND` | 404 | Session file missing or excluded |
| `INVALID_REQUEST_BODY` | 400 | POST body is not a JSON object |
| `INVALID_SINCE_MODE` | 400 | Bulk export `since` is not `all`, `last`, or `incremental` |
| `EXPORT_NOTHING_TO_EXPORT` | 422 | No sessions matched the export scope |
| `PARSE_ERROR` | 500 | Session file could not be parsed |
| `INTERNAL_ERROR` | 500 | Unexpected failure (e.g. stats computation) |

### CLI Export
- Standalone script to export all sessions to Markdown with YAML frontmatter
Expand Down
32 changes: 32 additions & 0 deletions api/error_codes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""Stable machine-readable error codes for API JSON error responses."""

from __future__ import annotations

from enum import StrEnum

from flask import Response, jsonify


class ErrorCode(StrEnum):
SEARCH_INVALID_LIMIT = "SEARCH_INVALID_LIMIT"
INVALID_PATH = "INVALID_PATH"
SESSION_NOT_FOUND = "SESSION_NOT_FOUND"
INVALID_REQUEST_BODY = "INVALID_REQUEST_BODY"
INVALID_SINCE_MODE = "INVALID_SINCE_MODE"
PARSE_ERROR = "PARSE_ERROR"
EXPORT_NOTHING_TO_EXPORT = "EXPORT_NOTHING_TO_EXPORT"
INTERNAL_ERROR = "INTERNAL_ERROR"


def error_response(
code: ErrorCode,
message: str,
status: int,
**extra: object,
) -> tuple[Response, int]:
body: dict[str, object] = {"error": message, "code": code}
reserved = frozenset({"error", "code"})
for key, value in extra.items():
if key not in reserved:
body[key] = value
return jsonify(body), status
119 changes: 82 additions & 37 deletions api/export_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@

from flask import Blueprint, current_app, request, send_file

from api._flask_types import FlaskReturn, json_error, json_response
from api._flask_types import FlaskReturn, json_response
from api.error_codes import ErrorCode, error_response
from models.export import ExportStateDict

from utils.export_state_store import (
EXPORT_STATE_FILE,
atomic_write_export_state,
Expand All @@ -36,6 +36,14 @@
# Tests monkeypatch this path; keep in sync with utils.export_state_store.
_STATE_FILE = EXPORT_STATE_FILE

_EXPORT_ERRORS = (
json.JSONDecodeError,
KeyError,
ValueError,
OSError,
FileNotFoundError,
)


def _state_lock() -> Any:
return export_state_lock(_STATE_FILE)
Expand Down Expand Up @@ -84,11 +92,20 @@ def bulk_export() -> FlaskReturn:
if body is None:
body = {}
if not isinstance(body, dict):
return json_error("Invalid request body", 400)
return error_response(
ErrorCode.INVALID_REQUEST_BODY,
"Invalid request body",
400,
)

since = body.get("since", "all")
if since not in ("all", "last", "incremental"):
return json_error({"error": "Invalid since mode", "since": since}, 400)
return error_response(
ErrorCode.INVALID_SINCE_MODE,
"Invalid since mode",
400,
since=since,
)

base = (
current_app.config.get("CLAUDE_PROJECTS_DIR")
Expand Down Expand Up @@ -153,7 +170,7 @@ def bulk_export() -> FlaskReturn:
)
new_sessions_map[sid] = sess_info.get("modified", 0)
count += 1
except Exception as e:
except _EXPORT_ERRORS as e:
current_app.logger.warning(
"Failed to export %s: %s", sid[:10], e
)
Expand Down Expand Up @@ -215,7 +232,7 @@ def bulk_export() -> FlaskReturn:
)
new_sessions_map[sid] = sess_info.get("modified", 0)
count += 1
except Exception as e:
except _EXPORT_ERRORS as e:
current_app.logger.warning(
"Failed to export %s: %s", sid[:10], e
)
Expand All @@ -226,11 +243,15 @@ def bulk_export() -> FlaskReturn:
)
zf.writestr("manifest.jsonl", manifest_str)

if count > 0:
_write_state(new_sessions_map, count)

if count == 0:
return json_error({"error": "Nothing to export", "since": since}, 422)
return error_response(
ErrorCode.EXPORT_NOTHING_TO_EXPORT,
"Nothing to export",
422,
since=since,
)

_write_state(new_sessions_map, count)

buf.seek(0)
date_tag = datetime.now().strftime("%Y-%m-%d")
Expand All @@ -253,7 +274,6 @@ def bulk_export() -> FlaskReturn:

@export_bp.route("/api/export/session/<path:project_name>/<session_id>")
def export_session(project_name: str, session_id: str) -> FlaskReturn:
import os
from utils.session_path import safe_join

base = (
Expand All @@ -263,42 +283,67 @@ def export_session(project_name: str, session_id: str) -> FlaskReturn:
try:
filepath = safe_join(base, project_name, f"{session_id}.jsonl")
except ValueError:
return json_error("Invalid path", 400)
return error_response(ErrorCode.INVALID_PATH, "Invalid path", 400)

if not os.path.isfile(filepath):
return json_error("Session not found", 404)
return error_response(
ErrorCode.SESSION_NOT_FOUND,
"Session not found",
404,
)

fmt = request.args.get("format", "md")
try:
session = parse_session(filepath)
rules = current_app.config.get("EXCLUSION_RULES") or []
if is_session_excluded(rules, session, project_name):
return json_error("Session not found", 404)
except _EXPORT_ERRORS:
current_app.logger.exception(
"Failed to parse session %s for export", session_id
)
return error_response(
ErrorCode.PARSE_ERROR,
"Failed to parse session",
500,
)

rules = current_app.config.get("EXCLUSION_RULES") or []
if is_session_excluded(rules, session, project_name):
return error_response(
ErrorCode.SESSION_NOT_FOUND,
"Session not found",
404,
)

try:
stats = compute_stats(session)
title_slug = slugify(session["title"], default="session")

if fmt == "json":
content = session_to_json(session, stats)
buf = io.BytesIO(content.encode("utf-8"))
buf.seek(0)
return send_file(
buf,
mimetype="application/json",
as_attachment=True,
download_name=f"{title_slug}.json", # type: ignore[call-arg]
)
except _EXPORT_ERRORS:
current_app.logger.exception(
"Failed to compute stats for export %s", session_id
)
return error_response(
ErrorCode.INTERNAL_ERROR,
"Failed to compute session stats",
500,
)

md = session_to_markdown(session, stats)
buf = io.BytesIO(md.encode("utf-8"))
title_slug = slugify(session["title"], default="session")

if fmt == "json":
content = session_to_json(session, stats)
buf = io.BytesIO(content.encode("utf-8"))
buf.seek(0)
return send_file(
buf,
mimetype="text/markdown",
mimetype="application/json",
as_attachment=True,
download_name=f"{title_slug}.md", # type: ignore[call-arg]
download_name=f"{title_slug}.json", # type: ignore[call-arg]
)
except Exception:
current_app.logger.exception(
"Failed to export session %s/%s", project_name, session_id
)
return json_error("Internal server error exporting session", 500)

md = session_to_markdown(session, stats)
buf = io.BytesIO(md.encode("utf-8"))
buf.seek(0)
return send_file(
buf,
mimetype="text/markdown",
as_attachment=True,
download_name=f"{title_slug}.md", # type: ignore[call-arg]
)
15 changes: 10 additions & 5 deletions api/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@

import os

from flask import Blueprint, current_app, jsonify, request
from flask import Blueprint, current_app, request

from api._flask_types import FlaskReturn, json_error, json_response
from api._flask_types import FlaskReturn, json_response
from api.error_codes import ErrorCode, error_response
from models.search import SearchHitDict
from utils.session_path import get_claude_projects_dir, list_projects, list_sessions
from utils.jsonl_parser import parse_session
Expand Down Expand Up @@ -37,8 +38,13 @@ def search() -> FlaskReturn:

try:
max_results = _parse_limit(request.args.get("limit"))
except ValueError as e:
return json_error(str(e), 400)
except ValueError:
return error_response(
ErrorCode.SEARCH_INVALID_LIMIT,
"Invalid limit: must be a positive integer",
400,
)

base = current_app.config.get("CLAUDE_PROJECTS_DIR") or get_claude_projects_dir()
projects = list_projects(base)

Expand All @@ -60,7 +66,6 @@ def search() -> FlaskReturn:
for msg in session["messages"]:
text = msg.get("text", "") or msg.get("content", "")
if query in text.lower():
# Find the matching snippet
idx = text.lower().index(query)
start = max(0, idx - 80)
end = min(len(text), idx + len(query) + 80)
Expand Down
Loading
Loading