fix: force re-render campaign tracking, cache dir loading, Joplin link preservation. Co-Authored-By: Fable 5

This commit is contained in:
JesseMarkowitz
2026-06-12 22:39:12 -04:00
parent 456975ad50
commit 3cf0b1eaa8
5 changed files with 131 additions and 32 deletions
+41 -14
View File
@@ -107,7 +107,11 @@ class Cache:
self._save()
def get_new_or_updated(
self, provider: str, conversations: list[dict], force: bool = False
self,
provider: str,
conversations: list[dict],
force: bool = False,
campaign_at: str | None = None,
) -> list[dict]:
"""Filter a conversation list to only new or updated conversations.
@@ -115,19 +119,23 @@ class Cache:
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).
force: When True, return conversations to re-render regardless of
cache freshness — used by ``export --force``.
campaign_at: In force mode, exclude conversations already re-exported
at/after this ISO timestamp (the re-render campaign start), so
each run advances and finished providers do nothing.
Returns:
Subset that needs to be exported.
Subset that needs to be exported, ordered oldest-export-first in
force mode.
"""
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.
# Re-export for a re-render campaign, ordered least-recently-exported
# first (never-exported sorts first). When ``campaign_at`` is given,
# conversations already re-exported during this campaign
# (exported_at >= campaign_at) are excluded — so finished providers
# do no work and the remaining count shrinks to zero. Each capped run
# re-renders the next-oldest batch. Conversations without an id drop.
entries = self._data.get(provider, {})
def _exported_at(conv: dict) -> str:
@@ -135,10 +143,10 @@ class Cache:
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,
)
candidates = [c for c in conversations if c.get("id") or c.get("uuid")]
if campaign_at is not None:
candidates = [c for c in candidates if _exported_at(c) < campaign_at]
return sorted(candidates, key=_exported_at)
result = []
for conv in conversations:
@@ -283,6 +291,25 @@ class Cache:
"""Return the ISO8601 timestamp of the last export run, or None."""
return self._data.get("last_run")
def exported_at(self, provider: str, conv_id: str) -> str:
"""Return the recorded ``exported_at`` for a conversation, or '' if absent."""
entry = self._data.get(provider, {}).get(conv_id)
return entry.get("exported_at", "") if isinstance(entry, dict) else ""
# Force re-render campaign marker (top-level scalar; skipped by stats/clear,
# which only act on dict-valued provider keys).
def get_force_campaign(self) -> str | None:
"""Return the active force-re-render campaign start timestamp, or None."""
return self._data.get("force_campaign_at")
def set_force_campaign(self, started_at: str) -> None:
self._data["force_campaign_at"] = started_at
self._save()
def clear_force_campaign(self) -> None:
if self._data.pop("force_campaign_at", None) is not None:
self._save()
# ------------------------------------------------------------------
# Private helpers
# ------------------------------------------------------------------