33from __future__ import annotations
44
55import json
6- import os
6+ import posixpath
77import zipfile
88from dataclasses import dataclass , field
9- from datetime import date , datetime
9+ from datetime import date , datetime , timezone
1010from typing import Any , Callable , Literal , Protocol
1111
1212from models .project import ProjectDict , SessionListItemDict
4242 "tool_calls" ,
4343)
4444
45+ _VALID_SINCE : frozenset [str ] = frozenset ({"all" , "last" , "incremental" })
46+ _VALID_FMT : frozenset [str ] = frozenset ({"md" , "json" , "both" })
47+ _VALID_LAYOUT : frozenset [str ] = frozenset ({"api" , "cli" })
48+
49+
50+ def _validate_mode (name : str , value : str , allowed : frozenset [str ]) -> None :
51+ if value not in allowed :
52+ raise ValueError (f"Invalid { name } : { value !r} " )
53+
4554
4655def serialize_manifest_jsonl (manifest : list [dict [str , Any ]]) -> str :
4756 """JSONL manifest body with a trailing newline (empty string if no rows)."""
@@ -106,18 +115,19 @@ class ZipSink:
106115
107116 def __init__ (self , zf : zipfile .ZipFile ) -> None :
108117 self ._zf = zf
109- self ._manifest : list [dict [str , Any ]] = []
118+ self ._pending_files : list [tuple [str , str ]] = []
110119
111120 def add_session (
112121 self ,
113122 files : list [tuple [str , str ]],
114123 manifest_entry : dict [str , Any ],
115124 ) -> None :
116- for rel_path , content in files :
117- self ._zf .writestr (rel_path , content )
118- self ._manifest .append (manifest_entry )
125+ del manifest_entry
126+ self ._pending_files .extend (files )
119127
120128 def finalize (self , manifest : list [dict [str , Any ]]) -> None :
129+ for rel_path , content in self ._pending_files :
130+ self ._zf .writestr (rel_path , content )
121131 body = serialize_manifest_jsonl (manifest )
122132 if body :
123133 self ._zf .writestr ("manifest.jsonl" , body )
@@ -129,9 +139,9 @@ def _resolve_first_timestamp(
129139 """Return first_timestamp from metadata, or synthesise from mtime without mutating *meta*."""
130140 ts = (meta .get ("first_timestamp" ) or "" ).strip ()
131141 if not ts :
132- ts = datetime .fromtimestamp (sess_info [ "modified" ]). strftime (
133- "%Y-%m-%dT%H:%M:%S"
134- )
142+ ts = datetime .fromtimestamp (
143+ sess_info [ "modified" ], tz = timezone . utc
144+ ). strftime ( "%Y-%m-%dT%H:%M:%S" )
135145 return ts
136146
137147
@@ -159,7 +169,7 @@ def build_export_rel_path(
159169 filename = f"{ ts_file } __{ title_slug } __{ short_id } .{ ext } "
160170 if layout == "api" :
161171 return f"{ proj_slug } /{ filename } "
162- return os . path .join (date_str , proj_slug , filename )
172+ return posixpath .join (date_str , proj_slug , filename )
163173
164174
165175def build_manifest_entry (
@@ -247,11 +257,13 @@ def run_bulk_export(
247257 *since* must be one of ``all``, ``last``, or ``incremental``.
248258 Per-session failures are caught, counted, and skipped (batch continues).
249259 """
250- if since not in ("all" , "last" , "incremental" ):
251- raise ValueError (f"Invalid since mode: { since !r} " )
260+ _validate_mode ("since" , since , _VALID_SINCE )
261+ _validate_mode ("fmt" , fmt , _VALID_FMT )
262+ _validate_mode ("path_layout" , path_layout , _VALID_LAYOUT )
252263
253264 if manifest_style is None :
254265 manifest_style = path_layout
266+ _validate_mode ("manifest_style" , manifest_style , _VALID_LAYOUT )
255267
256268 result = BulkExportResult ()
257269 manifest : list [dict [str , Any ]] = []
0 commit comments