Skip to content

Commit 38e548d

Browse files
Address PR #112 review: CollectedExportEntry, shared read_last_export_ms, engine tests
1 parent d3b101f commit 38e548d

6 files changed

Lines changed: 209 additions & 74 deletions

File tree

api/export_api.py

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,8 @@
1818
from flask import Blueprint, Response, request
1919

2020
from api.flask_config import exclusion_rules, json_response
21-
from services.export_engine import collect_export_entries
21+
from services.export_engine import collect_export_entries, read_last_export_ms
2222
from services.workspace_db import global_storage_db_path
23-
from utils.path_helpers import to_epoch_ms
2423
from utils.workspace_path import resolve_workspace_path
2524

2625
bp = Blueprint("export_api", __name__)
@@ -67,15 +66,6 @@ def _save_export_state(count: int) -> None:
6766
json.dump(state, f, indent=2)
6867

6968

70-
def _read_last_export_ms(since: Literal["all", "last"]) -> int:
71-
if since != "last":
72-
return 0
73-
ts = _get_export_state().get("lastExportTime")
74-
if ts:
75-
return to_epoch_ms(ts)
76-
return 0
77-
78-
7969
@bp.route("/api/export/state")
8070
def get_export_state() -> Response:
8171
"""Return the last export timestamp."""
@@ -109,7 +99,7 @@ def export_chats() -> tuple[Response, int] | Response:
10999
workspace_path=workspace_path,
110100
exclusion_rules=exclusion_rules(),
111101
since=since,
112-
last_export_ms=_read_last_export_ms(since),
102+
last_export_ms=read_last_export_ms(since, state=_get_export_state()),
113103
out_dir="",
114104
include_composer=True,
115105
include_cli=False,

models/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from models.conversation import Bubble, Composer, Conversation, WorkspaceLocalComposer
44
from models.errors import SchemaError
55
from models.parse_warnings import ParseWarningCollector
6-
from models.export import ExportEntry
6+
from models.export import CollectedExportEntry, ExportEntry
77
from models.search import ConversationSummary, SearchResult
88
from models.workspace import Workspace
99

@@ -16,6 +16,7 @@
1616
"Composer",
1717
"Conversation",
1818
"ConversationSummary",
19+
"CollectedExportEntry",
1920
"ExportEntry",
2021
"ParseWarningCollector",
2122
"SchemaError",

models/export.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,23 @@
11
from __future__ import annotations
22

33
from dataclasses import dataclass, field
4-
from typing import Any
4+
from typing import Any, TypedDict
55

66
from models.from_dict_validation import require_dict, require_non_empty_str_fields
77

88

9+
class CollectedExportEntry(TypedDict):
10+
"""One exportable conversation with rendered markdown (engine/CLI collection)."""
11+
12+
id: str
13+
rel_path: str
14+
content: str
15+
out_path: str
16+
updatedAt: int
17+
title: str
18+
workspace: str
19+
20+
921
@dataclass(frozen=True)
1022
class ExportEntry:
1123
"""One line of manifest.jsonl; log_id / title / workspace required, timestamps optional."""

scripts/export.py

Lines changed: 2 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,11 @@
3131
sys.path.insert(0, str(_project_root))
3232

3333
from models import ExportEntry, SchemaError # noqa: E402
34-
from services.export_engine import collect_export_entries # noqa: E402
34+
from services.export_engine import collect_export_entries, read_last_export_ms # noqa: E402
3535
from utils.exclusion_rules import ( # noqa: E402
3636
load_rules,
3737
resolve_exclusion_rules_path,
3838
)
39-
from utils.path_helpers import to_epoch_ms # noqa: E402
4039
from utils.workspace_path import resolve_workspace_path # noqa: E402
4140

4241
_logger = logging.getLogger(__name__)
@@ -168,23 +167,6 @@ def parse_args() -> ExportCliOptions:
168167
}
169168

170169

171-
def _read_last_export_ms(state_path: str, since: Literal["all", "last"]) -> int:
172-
if since != "last" or not os.path.isfile(state_path):
173-
return 0
174-
try:
175-
with open(state_path, "r", encoding="utf-8") as f:
176-
st = json.load(f)
177-
ts = st.get("lastExportTime")
178-
if ts:
179-
return to_epoch_ms(ts)
180-
except (json.JSONDecodeError, ValueError, OSError) as e:
181-
_logger.warning(
182-
"Could not read last export timestamp; defaulting to full export: %s",
183-
e,
184-
)
185-
return 0
186-
187-
188170
def main() -> None:
189171
configure_cli_logging()
190172
opts = parse_args()
@@ -198,7 +180,7 @@ def main() -> None:
198180

199181
state_dir = get_global_state_dir()
200182
state_path = os.path.join(state_dir, "export_state.json")
201-
last_export = _read_last_export_ms(state_path, since)
183+
last_export = read_last_export_ms(since, state_path=state_path)
202184

203185
exported = collect_export_entries(
204186
workspace_path=workspace_path,

services/export_engine.py

Lines changed: 38 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@
88
import sqlite3
99
from dataclasses import dataclass
1010
from datetime import datetime
11-
from typing import Any, Literal, TypedDict
11+
from typing import Any, Literal
1212

1313
from models import Bubble
14+
from models.export import CollectedExportEntry
1415
from services.summary_cache import fingerprint_workspace_storage, nocache_enabled
1516
from services.workspace_context import (
1617
WorkspaceContext,
@@ -49,16 +50,32 @@
4950
SinceMode = Literal["all", "last"]
5051

5152

52-
class ExportEntry(TypedDict):
53-
"""One exportable conversation with rendered markdown."""
54-
55-
id: str
56-
rel_path: str
57-
content: str
58-
out_path: str
59-
updatedAt: int
60-
title: str
61-
workspace: str
53+
def read_last_export_ms(
54+
since: SinceMode,
55+
*,
56+
state_path: str | None = None,
57+
state: dict[str, Any] | None = None,
58+
) -> int:
59+
"""Return last-export epoch ms for ``since=last``; 0 for a full export."""
60+
if since != "last":
61+
return 0
62+
ts: Any = None
63+
if state is not None:
64+
ts = state.get("lastExportTime")
65+
elif state_path is not None and os.path.isfile(state_path):
66+
try:
67+
with open(state_path, "r", encoding="utf-8") as f:
68+
st = json.load(f)
69+
if isinstance(st, dict):
70+
ts = st.get("lastExportTime")
71+
except (json.JSONDecodeError, ValueError, OSError) as e:
72+
_logger.warning(
73+
"Could not read last export timestamp; defaulting to full export: %s",
74+
e,
75+
)
76+
if ts:
77+
return to_epoch_ms(ts)
78+
return 0
6279

6380

6481
@dataclass(frozen=True)
@@ -209,9 +226,9 @@ def _collect_ide_export_entries(
209226
last_export_ms: int,
210227
today: str,
211228
out_dir: str,
212-
) -> list[ExportEntry]:
229+
) -> list[CollectedExportEntry]:
213230
ctx = orch.ctx
214-
exported: list[ExportEntry] = []
231+
exported: list[CollectedExportEntry] = []
215232
for row in db_data.ide_composer_rows:
216233
composer_id = row["key"].split(":")[1]
217234
try:
@@ -239,6 +256,8 @@ def _collect_ide_export_entries(
239256
updated_at = to_epoch_ms(cd.get("lastUpdatedAt")) or to_epoch_ms(
240257
cd.get("createdAt"),
241258
)
259+
# Intentional behavior change vs legacy CLI: fall back to createdAt when
260+
# lastUpdatedAt is absent (affects timestamps, filenames, and --since last).
242261
if since == "last" and updated_at <= last_export_ms:
243262
continue
244263

@@ -325,7 +344,7 @@ def _collect_ide_export_entries(
325344
workspace_info={"ws_slug": ws_slug, "ws_display_name": ws_display_name},
326345
)
327346

328-
rel_path = os.path.join(today, ws_slug, "chat", filename)
347+
rel_path = os.path.relpath(out_path, out_dir)
329348
exported.append({
330349
"id": composer_id,
331350
"rel_path": rel_path,
@@ -345,8 +364,8 @@ def _collect_cli_export_entries(
345364
last_export_ms: int,
346365
today: str,
347366
out_dir: str,
348-
) -> list[ExportEntry]:
349-
exported: list[ExportEntry] = []
367+
) -> list[CollectedExportEntry]:
368+
exported: list[CollectedExportEntry] = []
350369
try:
351370
cli_projects = list_cli_projects(get_cli_chats_path())
352371
except Exception as e: # noqa: BLE001 — log and skip CLI enumeration on any failure
@@ -446,7 +465,7 @@ def _collect_cli_export_entries(
446465
bubbles=bubbles,
447466
title_override=title,
448467
)
449-
rel_path = os.path.join(today, ws_slug_cli, "cli", filename)
468+
rel_path = os.path.relpath(out_path, out_dir)
450469
exported.append({
451470
"id": session_id,
452471
"rel_path": rel_path,
@@ -469,14 +488,14 @@ def collect_export_entries(
469488
include_composer: bool = True,
470489
include_cli: bool = True,
471490
nocache: bool = False,
472-
) -> list[ExportEntry]:
491+
) -> list[CollectedExportEntry]:
473492
"""Collect exportable conversations (IDE + CLI) via shared orchestration."""
474493
effective_nocache = nocache_enabled(request_nocache=nocache)
475494
orch = prepare_workspace_orchestration(
476495
workspace_path, exclusion_rules, nocache=effective_nocache,
477496
)
478497
today = datetime.now().strftime("%Y-%m-%d")
479-
exported: list[ExportEntry] = []
498+
exported: list[CollectedExportEntry] = []
480499

481500
if include_composer:
482501
db_data = load_global_db_export_data(orch)

0 commit comments

Comments
 (0)