5 Commits
Author SHA1 Message Date
john-okeefe dafcadd211 fix(sync): echo dedup + color semantics + classification for KOReader round-trips
Echo duplication: devices push their full annotation list on every
sync, and an echo of a web-created annotation computed a different
dedup key than the original (device locators differ from web locators)
— every pull→push cycle minted a duplicate row, and cleaning those up
on the web tombstoned them back to the device, deleting the
just-applied copies. That was the "web highlights never appear on
KOReader" experience. GetMetadata now serves each annotation's
dedup_key; the device stores it on the applied entry and echoes it in
pushes; SaveHighlight/SaveBookmark/SaveNote accept a DedupKey
override so echoes converge onto the original row (verified: pull →
echo push creates no rows, LWW skips identical content).

Color semantics (per user preference): devices render their own
default and cannot round-trip web colors, so GetMetadata no longer
serves colors at all — every highlight syncs regardless of its web
color and the device draws its default. An echo carries no color;
ingest then PRESERVES the stored web color (existingHighlightColor
lookup by dedup key) so round-trips never change it. A non-empty
device color means the user edited the highlight there: it maps
name→hex (green→#a5d6a7, default yellow) and wins. Verified: echo
kept #ffd54f; a simulated device edit with "green" updated the web
row to #a5d6a7.

Classification: KOReader auto-fills text="in Chapter X" on page
bookmarks (ReaderAnnotation:updateItemByXPointer), so the plugin's
text-presence classification turned every echoed bookmark into a junk
highlight on the web. v2 classification now keys off the drawer field
(present = highlight/note, absent = bookmark with its label in note).
2026-08-19 19:41:58 -04:00
john-okeefe a962342ee0 fix(sync): resurrect tombstoned annotations when a newer save re-creates them
Deleting a bookmark/highlight/note and then re-adding the same content
at the same position (same dedup key — e.g. the reader's auto-titled
'Bookmark at X%') was silently swallowed: the save hit the tombstone
branch, returned 201 with the deleted row, and the list (which filters
deleted) stayed empty. Bookmarks were further blocked by the
UNIQUE(media_item_id, user_id, title) slot the tombstoned row holds,
and notes had no TTL escape at all.

Tombstones now only block saves that predate them (stale replays from
a device that still has the annotation). A save whose modification
time is newer than max(deleted_at, last_modified_at) — a deliberate
re-create from the web or a device — resurrects the row via the LWW
update queries, which now clear deleted/deleted_at.
2026-08-14 15:42:18 -04:00
john-okeefe 1461273162 fix(sync): wire dead token cleanup queries into daily maintenance runner
CleanupExpiredRefreshTokens and CleanupExpiredOpdsTokens were generated
by sqlc but never invoked anywhere in the codebase, so expired/revoked
tokens accumulated in the database indefinitely. The refresh-token query
was parameterized in the settings-registry work specifically so its
retention window could follow the configurable session duration, but the
periodic caller was never wired up.

annotations.go:
- Rename StartTombstonePurger to StartDailyMaintenance, which now runs
  all periodic cleanup tasks from a single 24h-tick goroutine.
- Add runDailyMaintenance helper: tombstones, then OPDS tokens, then
  refresh tokens, each logging independently so one failure never skips
  the others.
- Refresh-token retention is read from the registry (SessionDuration)
  on every tick so live admin edits are honored; guarded on the registry
  being wired so unwired test paths simply skip cleanup.
- All three queries only delete rows that are already expired or
  revoked, so active sessions are never logged out.

main.go:
- Update the call site: tombstonePurgerCancel becomes maintenanceCancel
  and calls StartDailyMaintenance.

Net footprint: still one goroutine and one ticker; the cleanup adds one
DELETE per table per day.
2026-08-10 10:43:05 -04:00
john-okeefe 757398bf15 feat(sync): make annotation tombstone TTL configurable
The 30-day retention window for soft-deleted annotations was a package
const; move it behind the registry so it can be tuned live.

annotations.go:
- AnnotationService gains an optional *database.SettingsRegistry and a
  tombstoneTTL() helper. The skip-resurrect checks and the purge cutoff
  now call it instead of reading the TombstoneTTL const directly.
- Add ActiveTombstoneTTL() so callers outside the sync package can
  compute cutoffs consistently with the service.
- The package-level TombstoneTTL const is retained as the fallback for
  tests / unwired code paths.

kobo.go, koreader.go:
- The per-book tombstone sweep cutoff now uses
  h.annotationSvc.ActiveTombstoneTTL() instead of the wsync.TombstoneTTL
  const, so both the service and the handlers honor the configured TTL.
2026-08-10 08:01:45 -04:00
john-okeefe 3b15766149 feat(sync): add AnnotationService with dedup, LWW, and tombstone management
AnnotationService is the central service for cross-device annotation sync.
It provides SaveHighlight, SaveNote, and SaveBookmark methods that handle
the full sync lifecycle:

Identity (3-layer):
  1. Server UUID (primary key)
  2. Per-device native ID stored in device_sync_data JSONB
  3. Content dedup_key: sha1(normalize(selection_text) + bucket_position)
     - CFI character offsets are stripped for bucketing so the same
       highlight at slightly different offsets still deduplicates
     - Raw positions are preserved in the DB for precise restoration

Resolution policy (LWW):
  - When the incoming annotation has an explicit ModifiedAt timestamp,
    last_modified_at wins
  - When the device sends zero ModifiedAt (creation time only), field-diff
    mode compares content fields (text/color/note/percentage) — if all
    match, the save is skipped; if any differ, the save is applied with
    server-receive-time as the new last_modified_at

Conflict detection:
  - When incoming and existing annotations have different sources (e.g.
    koreader vs kobo) and content differs, an auto_resolved sync_conflict
    is recorded with both sides' data for audit trail
  - Broadcasts a WebSocket conflict notification for real-time UI updates

Tombstone management:
  - Delete-wins: tombstoned annotations block recreation from stale pushes
  - 30-day TTL before physical purge
  - PurgeExpiredTombstones method + StartTombstonePurger goroutine (24h ticker)

Add locators.go with unified bidirectional CFI conversion:
  ConvertToCanonical / ConvertFromCanonical
  - CRE XPointer <-> standard EPUB CFI (for KOReader)
  - KEPUB CFI passthrough (for Kobo)
  - Skips non-reflowable formats (PDF, CBZ, fixed-layout EPUBs)

Add 25 unit tests covering:
  - Dedup key determinism, text normalization, position sensitivity
  - Offset insensitivity (CFI char-offset bucketing)
  - Device sync data merge (preserves existing, overwrites same source)
  - Cross-source detection
  - LWW comparison (newer wins, older skipped, fallback to updated_at)
  - Field-diff mode (identical content skipped, changes applied)
  - Tombstone TTL constant
  - CRE XPointer parsing and classification
  - Standard EPUB CFI classification
2026-07-29 14:48:47 -04:00