|
3 | 3 | import io |
4 | 4 | import json |
5 | 5 | import os |
6 | | -import tempfile |
7 | | -import threading |
8 | 6 | import zipfile |
9 | | -from contextlib import contextmanager |
10 | 7 | from datetime import datetime |
11 | 8 |
|
12 | 9 | from flask import Blueprint, current_app, jsonify, request, send_file |
13 | 10 |
|
| 11 | +from utils.export_state_store import ( |
| 12 | + EXPORT_STATE_FILE, |
| 13 | + atomic_write_export_state, |
| 14 | + export_state_lock, |
| 15 | + load_export_state_from_disk, |
| 16 | +) |
14 | 17 | from utils.session_path import ( |
15 | 18 | get_claude_projects_dir, |
16 | 19 | list_projects, |
|
24 | 27 | from utils.slugify import slugify |
25 | 28 | from utils.export_day_filter import collect_sessions_for_latest_activity_day |
26 | 29 |
|
27 | | -try: |
28 | | - import fcntl |
29 | | -except ImportError: |
30 | | - fcntl = None # Windows: fall back to threading lock (same process only) |
31 | | - |
32 | 30 | export_bp = Blueprint("export", __name__) |
33 | 31 |
|
34 | | -_STATE_FILE = os.path.join( |
35 | | - os.path.expanduser("~"), ".claude-code-chat-browser", "export_state.json" |
36 | | -) |
37 | | - |
38 | | -_fallback_lock = threading.Lock() |
| 32 | +# Tests monkeypatch this path; keep in sync with utils.export_state_store. |
| 33 | +_STATE_FILE = EXPORT_STATE_FILE |
39 | 34 |
|
40 | 35 |
|
41 | | -@contextmanager |
42 | 36 | def _state_lock(): |
43 | | - """Serialize export_state.json reads/writes (POSIX: flock; else threading).""" |
44 | | - if fcntl is not None: |
45 | | - lock_path = _STATE_FILE + ".lock" |
46 | | - os.makedirs(os.path.dirname(lock_path), exist_ok=True) |
47 | | - lock_fp = open(lock_path, "a+") |
48 | | - try: |
49 | | - fcntl.flock(lock_fp.fileno(), fcntl.LOCK_EX) |
50 | | - yield |
51 | | - finally: |
52 | | - fcntl.flock(lock_fp.fileno(), fcntl.LOCK_UN) |
53 | | - lock_fp.close() |
54 | | - else: |
55 | | - with _fallback_lock: |
56 | | - yield |
| 37 | + return export_state_lock(_STATE_FILE) |
57 | 38 |
|
58 | 39 |
|
59 | 40 | def _load_state_from_disk() -> dict: |
60 | | - if os.path.exists(_STATE_FILE): |
61 | | - try: |
62 | | - with open(_STATE_FILE, encoding="utf-8") as f: |
63 | | - return json.load(f) |
64 | | - except Exception: |
65 | | - pass |
66 | | - return {} |
| 41 | + return load_export_state_from_disk(_STATE_FILE) |
67 | 42 |
|
68 | 43 |
|
69 | 44 | def _atomic_write_state(state: dict) -> None: |
70 | | - """Write state atomically (temp file + replace) under _state_lock.""" |
71 | | - dir_name = os.path.dirname(_STATE_FILE) or "." |
72 | | - os.makedirs(dir_name, exist_ok=True) |
73 | | - fd, tmp_path = tempfile.mkstemp(dir=dir_name, suffix=".tmp") |
74 | | - try: |
75 | | - with os.fdopen(fd, "w", encoding="utf-8") as f: |
76 | | - json.dump(state, f, indent=2) |
77 | | - os.replace(tmp_path, _STATE_FILE) |
78 | | - except BaseException: |
79 | | - try: |
80 | | - if os.path.exists(tmp_path): |
81 | | - os.unlink(tmp_path) |
82 | | - except OSError: |
83 | | - pass |
84 | | - raise |
| 45 | + atomic_write_export_state(state, _STATE_FILE) |
85 | 46 |
|
86 | 47 |
|
87 | 48 | def _read_state() -> dict: |
@@ -115,10 +76,15 @@ def get_export_state(): |
115 | 76 |
|
116 | 77 | @export_bp.route("/api/export", methods=["POST"]) |
117 | 78 | def bulk_export(): |
118 | | - body = request.get_json(silent=True) or {} |
| 79 | + body = request.get_json(silent=True) |
| 80 | + if body is None: |
| 81 | + body = {} |
| 82 | + if not isinstance(body, dict): |
| 83 | + return jsonify({"error": "Invalid request body"}), 400 |
| 84 | + |
119 | 85 | since = body.get("since", "all") |
120 | 86 | if since not in ("all", "last", "incremental"): |
121 | | - since = "all" |
| 87 | + return jsonify({"error": "Invalid since mode", "since": since}), 400 |
122 | 88 |
|
123 | 89 | base = ( |
124 | 90 | current_app.config.get("CLAUDE_PROJECTS_DIR") |
|
0 commit comments