fix: --force re-render progression, cache dir loading, and Joplin link preservation. Co-Authored-By: Fable 5 <noreply@anthropic.com>
This commit is contained in:
+36
-2
@@ -84,7 +84,13 @@ class Cache:
|
||||
if provider not in self._data:
|
||||
self._data[provider] = {}
|
||||
|
||||
self._data[provider][conv_id] = {
|
||||
# Preserve the Joplin link across re-exports. Without this, re-rendering
|
||||
# a conversation (e.g. a forced re-export to pick up new formatting)
|
||||
# would drop joplin_note_id, and the next sync would create a duplicate
|
||||
# note instead of updating the existing one. exported_at is refreshed,
|
||||
# so get_joplin_pending() still flags the note for an update.
|
||||
prev = self._data[provider].get(conv_id) or {}
|
||||
entry = {
|
||||
"title": metadata.get("title", ""),
|
||||
"project": metadata.get("project"),
|
||||
"created_at": metadata.get("created_at", ""),
|
||||
@@ -92,20 +98,48 @@ class Cache:
|
||||
"exported_at": datetime.now(tz=timezone.utc).isoformat(),
|
||||
"file_path": metadata.get("file_path", ""),
|
||||
}
|
||||
for carried in ("joplin_note_id", "joplin_synced_at", "joplin_resources"):
|
||||
if carried in prev:
|
||||
entry[carried] = prev[carried]
|
||||
|
||||
self._data[provider][conv_id] = entry
|
||||
self._data["last_run"] = datetime.now(tz=timezone.utc).isoformat()
|
||||
self._save()
|
||||
|
||||
def get_new_or_updated(self, provider: str, conversations: list[dict]) -> list[dict]:
|
||||
def get_new_or_updated(
|
||||
self, provider: str, conversations: list[dict], force: bool = False
|
||||
) -> list[dict]:
|
||||
"""Filter a conversation list to only new or updated conversations.
|
||||
|
||||
Args:
|
||||
provider: "chatgpt" or "claude"
|
||||
conversations: List of raw conversation dicts from the provider.
|
||||
Each must have an ``id``/``uuid`` and ``updated_at``/``update_time``.
|
||||
force: When True, return every conversation regardless of cache
|
||||
state — used by ``export --force`` to re-render everything
|
||||
without clearing the manifest (which would drop Joplin links).
|
||||
|
||||
Returns:
|
||||
Subset that needs to be exported.
|
||||
"""
|
||||
if force:
|
||||
# Re-export everything, but order by least-recently-exported first
|
||||
# (never-exported sorts first). With a per-run cap, the batch just
|
||||
# re-rendered gets a fresh exported_at and sinks to the back, so
|
||||
# each subsequent run advances through the archive instead of
|
||||
# repeating the same head. Conversations without an id are dropped.
|
||||
entries = self._data.get(provider, {})
|
||||
|
||||
def _exported_at(conv: dict) -> str:
|
||||
conv_id = conv.get("id") or conv.get("uuid", "")
|
||||
entry = entries.get(conv_id) or {}
|
||||
return entry.get("exported_at") or ""
|
||||
|
||||
return sorted(
|
||||
(c for c in conversations if c.get("id") or c.get("uuid")),
|
||||
key=_exported_at,
|
||||
)
|
||||
|
||||
result = []
|
||||
for conv in conversations:
|
||||
conv_id = conv.get("id") or conv.get("uuid", "")
|
||||
|
||||
+36
-6
@@ -62,6 +62,14 @@ def cli(ctx: click.Context, verbose: bool, quiet: bool, debug: bool, no_log_file
|
||||
"""Export ChatGPT and Claude conversations to Markdown for personal archival."""
|
||||
ctx.ensure_object(dict)
|
||||
|
||||
# Load .env BEFORE reading any env var (LOG_FILE, CACHE_DIR). Without this
|
||||
# the cache/log paths fell back to defaults regardless of .env — the cache
|
||||
# in particular silently lived at ./cache even when CACHE_DIR was set.
|
||||
# override=False so a real shell env var still wins over .env.
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv(override=False)
|
||||
|
||||
# Determine console log level
|
||||
if debug or verbose:
|
||||
level = logging.DEBUG
|
||||
@@ -71,7 +79,6 @@ def cli(ctx: click.Context, verbose: bool, quiet: bool, debug: bool, no_log_file
|
||||
level = logging.INFO
|
||||
|
||||
# Determine log file path from env (setup_logging handles "none")
|
||||
import os
|
||||
log_file = os.getenv("LOG_FILE", "./cache/logs/exporter.log")
|
||||
|
||||
setup_logging(level=level, log_file=log_file, no_log_file=no_log_file)
|
||||
@@ -634,6 +641,16 @@ def _print_doctor_table(checks: list[dict]) -> None:
|
||||
"(default: images)."
|
||||
),
|
||||
)
|
||||
@click.option(
|
||||
"--force",
|
||||
is_flag=True,
|
||||
help=(
|
||||
"Re-export every conversation even if already cached and unchanged. "
|
||||
"Use to re-render the whole archive after a formatting/feature change "
|
||||
"without 'cache --clear' (which would drop Joplin note links). Combine "
|
||||
"with --max-conversations to spread the work over several runs."
|
||||
),
|
||||
)
|
||||
@click.option("--dry-run", is_flag=True, help="Show what would be exported without writing anything.")
|
||||
@click.pass_context
|
||||
def export(
|
||||
@@ -646,6 +663,7 @@ def export(
|
||||
hidden_content: str | None,
|
||||
max_conversations: int | None,
|
||||
download_media: str | None,
|
||||
force: bool,
|
||||
dry_run: bool,
|
||||
) -> None:
|
||||
"""Export new and updated conversations to Markdown or JSON.
|
||||
@@ -736,7 +754,7 @@ def export(
|
||||
f" [dim]--project filter '{project_filter}': {len(all_convs)} matching conversations.[/dim]"
|
||||
)
|
||||
|
||||
to_export = cache.get_new_or_updated(prov_name, all_convs)
|
||||
to_export = cache.get_new_or_updated(prov_name, all_convs, force=force)
|
||||
skipped = len(all_convs) - len(to_export)
|
||||
summary[prov_name]["skipped"] = skipped
|
||||
|
||||
@@ -841,10 +859,22 @@ def export(
|
||||
_print_export_summary(summary)
|
||||
total_deferred = sum(s.get("deferred", 0) for s in summary.values())
|
||||
if total_deferred:
|
||||
console.print(
|
||||
f"[yellow]{total_deferred} conversation(s) deferred by the session cap "
|
||||
f"({session_cap} per provider per run). Re-run the same command to continue.[/yellow]"
|
||||
)
|
||||
if force:
|
||||
# In force mode every conversation is "to_export", so the
|
||||
# deferred count is a rolling remainder, not a shrinking
|
||||
# backlog: each run re-renders the oldest batch and advances.
|
||||
exported_now = sum(s.get("exported", 0) for s in summary.values())
|
||||
console.print(
|
||||
f"[yellow]Force re-render: did the {exported_now} least-recently-exported "
|
||||
f"this run; {total_deferred} still awaiting a refresh. Re-run to continue "
|
||||
f"(the count stays high until the last pass — each run advances through "
|
||||
f"the archive).[/yellow]"
|
||||
)
|
||||
else:
|
||||
console.print(
|
||||
f"[yellow]{total_deferred} conversation(s) deferred by the session cap "
|
||||
f"({session_cap} per provider per run). Re-run the same command to continue.[/yellow]"
|
||||
)
|
||||
# Emit the data-loss summary at INFO level so it lands in the log file
|
||||
# AND the operator's console (default level is INFO).
|
||||
for line in loss_report.format_summary().split("\n"):
|
||||
|
||||
Reference in New Issue
Block a user