Commit Graph
21 Commits
Author SHA1 Message Date
john-okeefe 6e9b3528d8 fix(sync): synced highlights painted nowhere — degenerate range ends + color model mismatch
Both directions synced data but rendered nothing:

- Web reader <- devices: highlights painted no overlay. Device pushes
  resolve their start xpointer exactly (text-search anchored by the
  selection) but the end conversion carries no context and fell back
  to a document-start CFI (epubcfi .../1:0) — a garbage range end.
  When the start resolved exactly, the end is now derived from it:
  same node, character offset advanced by the selection's UTF-16
  length (extendCFIByLength). Same repair when SERVING to devices,
  where old web highlights (no end anchor) and converted range CFIs
  both collapsed pos1 onto pos0 (extendXPointerByLength on the
  xpointer form) — KOReader drew zero-width highlights.
- Colors: KOReader paints from a fixed name set (Blitbuffer
  HIGHLIGHT_COLORS), the web uses hex swatches; neither understood
  the other, so device colors fell back to defaults and web hex drew
  nothing useful on devices. Both boundaries now translate: ingest
  maps names to hex (default #ffd54f), GetMetadata maps hex to names
  (default yellow) — per-datatype edits re-push with the editing
  side's color, which LWW then propagates. SyncBookmarks endpoint
  aligned to the same mapping and default.
2026-08-19 14:08:03 -04:00
john-okeefe f6e257e497 fix(sync): web annotations never reached KOReader — bind 400s + unresolvable locators
Two blockers, diagnosed by simulating the plugin against the live
server with real library books:

1. Every KOReader progress push carrying annotations failed the JSON
   bind with 400 ('cannot unmarshal string into ... chapter/page of
   type int') — the plugin sends chapter:'', page:'30', and for CRE
   documents page:'/body/...' — so annotation sync AND progress sync
   failed together. KOReader annotation chapter/page now use FlexInt,
   which accepts numbers, numeric strings, empty strings, and
   non-numeric strings (decoding to 0). The server is deliberately
   liberal here so thin clients can send raw bookmark data.

2. GetMetadata served locators KOReader cannot place, so pulled items
   were junk: web bookmarks leaked 'cfi:epubcfi(...)' positions, web
   PDF highlights had empty pos0 (skipped by the plugin, invisible),
   and web deletions carried no pos0 so tombstones never matched.
   New koreaderPos0 resolver handles every source: device-native
   xpointers pass through untouched (round-trip identical, verified),
   web PDF JSON anchors map to their page number, EPUB CFIs convert
   to CRE xpointers (selection text passed as text-search context for
   exact anchoring), 'page:N' positions strip to the bare number.
   Unresolvable annotations are skipped with a log line instead of
   poisoning devices; tombstones get pos0 injected from the new
   locator columns.

Also: thin clients omit per-annotation percentages (paging docs still
send arithmetic page/total); the server derives them — section
midpoint from the spine char distribution for CRE documents, page/
page-count for fixed formats.
2026-08-18 19:13:38 -04:00
john-okeefe 5584bdefb5 feat(koreader): resolve pushes by SHA via BookResolver and return SHA on pull
Fixes the 'cannot push until pulling first' wall on books downloaded
via OPDS. Root cause chain: the bookhoard koreader plugin only learns
the book UUID from a successful push response, but the first push had
to match by SHA-256 alone - and that match consulted only
media_items.file_sha256, missing converted formats. When the hash
missed, no UUID was returned, so pull stayed blocked (it requires the
UUID) and the book could not sync at all.

Resolution side - route all five SHA-256 match sites through the shared
BookResolver so they are format-aware:
- resolveBookToMediaItem priority 2
- SyncBookmarks book-level lookup
- per-bookmark, per-note, and per-highlight override lookups

Exposure side - return the canonical hash so clients can learn and
cache it from a pull regardless of how the book was obtained:
- KOReaderMetadata gains sha256, populated from mediaItem.FileSha256
- KOReaderLibraryBook gains sha256, populated the same way, so the
  library list endpoint carries it for every book

Together with the plugin-side UUID bootstrap (bookhoard.koplugin),
push and pull now work in either order on any format.
2026-08-14 08:25:46 -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 75b33fdae6 feat(sync): wire annotation sync into all device and web handlers
Complete the annotation sync pipeline across all ingest and serve paths.
Previously, annotations sent inline with KOReader progress pushes were
silently discarded, and no annotations were ever served back to devices.

INGEST (device → server):

KOReader (koreader.go):
  - Add processBookAnnotations helper that processes inline highlights,
    notes, and bookmarks from every progress push (immediate + checkpoint)
  - Highlights get CRE→CFI position conversion before SaveHighlight
  - KOReader 'notes' (text + notes) stored as highlights with NoteText
    to ensure correct round-trip classification
  - Bookmarks routed through SaveBookmark with device sync data
  - Called from both updateProgressForBook and handleCheckpointSync

Kobo (kobo.go):
  - Markup handler: annotations and bookmarks route through
    AnnotationService (SaveHighlight/SaveBookmark)
  - Bookmark handler: same routing with device sync data
  - SyncFromServer handler: same routing
  - All handlers fall back to direct DB calls when annotationSvc == nil

Web reader (media.go):
  - CreateMediaHighlight → SaveHighlight (Source="web", ModifiedAt=now)
  - CreateMediaNote → SaveNote (Source="web")
  - DeleteMediaHighlight → TombstoneHighlightByID
  - DeleteMediaNote → TombstoneNoteByID (was hard delete, now tombstone)
  - All fall back to old behavior when annotationSvc == nil

SERVE (server → device):

KOReader GetMetadata (koreader.go):
  - Query and serve bookmarks from media_bookmarks table (was missing)
  - Serve deleted_highlights and deleted_bookmarks arrays containing
    device_sync_data + dedup_key for client-side deletion
  - Highlights/notes already served with reverse CFI conversion

Kobo Markup handler (kobo.go):
  - Track processed books during sync
  - Query tombstones per book, extract bookmark_id from device_sync_data
  - Return DeletedAnnotations array in KoboSyncStatus response

Conflict resolution (conflicts.go):
  - Enable annotation conflict types in ResolveConflict handler
  - Add applyAnnotationResolution dispatching to:
    applyHighlightResolution / applyBookmarkResolution / applyNoteResolution
  - Each looks up by dedup_key and applies winner's fields
  - Allow manual override of auto_resolved conflicts
    (changed check from != "unresolved" to == "user_resolved")

Infrastructure:
  - AnnotationService field + SetAnnotationService in router Config
  - Inject AnnotationService into KOReader, Kobo, Media handlers
  - Start tombstone purger goroutine in main.go (24h interval)
  - Test helpers: construct AnnotationService in test setup
2026-07-29 14:49:19 -04:00
john-okeefe 762181c123 fix(sync): treat page index as sole locator for fixed-layout pull path
The push path (koreader→server) was already updated to omit epubcfi
for paging documents, and SaveProgress derives percentage from
page/total_pages for fixed formats. However, the pull path
(server→koreader) still returned any stored epubcfi and attempted
CFI→XPointer conversion, and SaveProgress preserved stale CFI values
written by the web reader (which generates fake CFIs via
CFI.fake.fromIndex for every comic/PDF page).

These fake CFI strings lingered in the database and koreader's pull
path picked them up over progress.page, causing tonumber("epubcfi(...)")
→ nil → GotoPage(nil) → crash when the user confirmed the sync prompt.

Changes:
- GetMetadata (koreader.go): for fixed_layout/comic_archive formats,
  skip returning epubcfi and skip the CFI→CRE XPointer conversion.
  Reflowable documents are byte-for-byte unchanged.
- SaveProgress (progress.go): for fixed formats, explicitly clear
  epubcfi and character_offset on every save so stale values from
  prior web-reader sessions are cleaned up over time.
2026-06-07 00:26:53 -04:00
john-okeefe d11a623f9f fix(sync): skip CRE->CFI conversion for image-based fixed formats
The CRE->CFI converter works by text search / character-offset mapping
across the EPUB spine. Image-based fixed content (fixed-layout comic
EPUBs, PDFs, comic archives) has no extractable text, so the conversion
can never succeed and only wastes time parsing content docs while
logging a failed percentage-precision result.

Guard the conversion in the KOReader progress handler: when the matched
media item's FormatGroup is fixed_layout or comic_archive, skip
ConvertCREToStandard entirely. The incoming xpointer is left as-is so
KOReader<->KOReader restore via GotoXPointer still works; the web reader
restores by page index (the canonical locator for these formats).

This covers fixed-layout comic EPUBs in particular: KOReader routes all
EPUBs through CREngine (has_pages == false), so they send a real
xpointer that passes IsCREXPointer and would otherwise trigger the
doomed text extraction. Reflowable EPUBs (format_group == reflowable)
still run the conversion exactly as before.
2026-06-07 00:04:03 -04:00
john-okeefe 44f38803dc feat(koreader): bidirectional CFI conversion in sync pipeline
Forward (push): When KOReader pushes a CREngine XPointer, convert it
to standard epubcfi before storing. Uses CFIConverter.ConvertCREToStandard
with context_text for text search fallback.

Reverse (pull): When KOReader pulls progress, convert stored standard
epubcfi back to CREngine XPointer via CFIConverter.ConvertStandardToCRE.
Returns as koreader_xpointer field in GetMetadata response.

Other changes:
- updateProgressForBook: pass context_text to SaveProgress
- enqueueProgressForBook: pass context_text to queue
- KOReaderProgressData: add KoreaderXPointer field
- New convertCFIToXPointer helper method
- Wire libraryService in main.go for EPUB path resolution
2026-06-02 19:44:47 -04:00
john-okeefe 283b2f2ed7 feat(handlers): integrate ProgressService into media, koreader, kobo, and queue
All four progress write paths now delegate to ProgressService.SaveProgress:

- MediaHandler: UpdateMediaReadingProgress uses ProgressService for web
  saves with richer request body (reading_mode, zoom_level, scroll). GET
  now uses GetUniversalProgress query that JOINs media_items for
  format_group, total_characters, chapter_count.

- KOReaderHandler: updateProgressForBook delegates to ProgressService.
  Fixed device ID bug (was using userID, now uses deviceID). Removed
  duplicate UpdateDeviceLastSync with zero UUID. Added pgtype helper
  functions (textPtrToPgText, intPtrToPgInt4, int64PtrToPgInt8).

- KoboHandler: all four progress write points (Markup ReadingSync, Markup
  last-read-place, AnalyticsGettests, SyncFromServer) delegate to
  ProgressService. Fixed empty epubcfi string now correctly set to
  Valid: false. SyncFromServer preserves last_sync_source=bookhoard
  and Broadcast: false.

- QueueProcessor: syncProgress delegates to ProgressService.

- main.go: creates ProgressService after ConnectionManager, injects via
  SetProgressService() on all handlers and queue processor.

Handler tests cover pgtype conversion helpers (textPtrToPgText, etc.)
and device icon mapping.
2026-04-25 21:16:29 -04:00
john-okeefe a4962a87b2 fix: replace invalid new(expression) calls with proper pointer allocation
Go's new() builtin takes a type and allocates a zero value — it cannot
wrap an expression. All instances of new(someExpression) were compile
errors. Replace each with a local variable assignment and address-of
operator.

Affected files:
- handlers/koreader.go: progress field pointers (Chapter, Page, etc.)
- handlers/kobo.go: pagesRemaining pointer
- handlers/queue.go: uuidPtrToString and timestamptzPtrToString helpers
- router/reader.go: bookmark pageNumber and chapterNumber pointers
- services/media_scanner.go: validation error message pointers
- services/worker.go: StartedAt and CompletedAt timestamps
- sync/offline.go: GetDeviceStatus return pointer
- tests/device_test.go: SyncEnabled and SyncFrequencyMinutes pointers
2026-04-23 20:39:50 -04:00
john-okeefe e389df92c3 refactor: remove unnecessary type conversions and handle ignored errors across codebase
Remove redundant type conversions that Go 1.26 makes unnecessary or that
were already no-ops:

- uuid.UUID(x.Bytes) → x.Bytes (uuid.UUID is [16]byte, same as pgtype UUID Bytes)
- pgtype.UUID{Bytes: [16]byte(u), Valid: true} → pgtype.UUID{Bytes: u, Valid: true}
- (*time.Time)(&x.Time) → &x.Time
- json.RawMessage(x) → x where x is already []byte
- []byte(stringVal) → stringVal where []byte is expected
- int()/int64()/byte() casts on values already of the target type
- Decompressor(fn) → fn (type is identical)

Handle previously ignored error returns:

- collections.go: check json.Unmarshal error in GetCollection
- conversion_service.go: check fileSize.Scan() error
- app_test.go: check app.Shutdown() error in benchmark
2026-04-21 21:15:59 -04:00
john-okeefe 3e73a582ba refactor(handlers): use errors.Is() for pgx error comparison in KOReader
Replace direct equality checks (err != pgx.ErrNoRows) with the idiomatic
errors.Is(err, pgx.ErrNoRows) pattern. This is the recommended Go practice
for error comparison as it correctly handles wrapped errors from error
chains, making the code more robust against future refactoring that might
wrap errors with fmt.Errorf and %w.
2026-04-20 20:45:35 -04:00
john-okeefe 8ac6e1ac79 refactor(handlers): replace temporary variable pointer pattern with new() builtin
Simplify pointer creation across kobo, koreader, and queue handlers by
replacing the two-step pattern (assign to local, then take address) with
inline new() calls. This reduces verbosity without changing behavior:

Before:
  remaining := int(a - b)
  pagesRemaining = &remaining

After:
  pagesRemaining = new(int(a - b))

Covers page calculations, chapter/progress fields, UUID formatting,
and timestamp string conversions.
2026-04-20 08:57:42 -04:00
john-okeefe 1e05470fbb refactor(handlers): update all handlers for Echo v5 compatibility
Update all handler functions to use *echo.Context (pointer) instead of echo.Context (value) as required by Echo v5.

Changes across all handler files:
- analytics.go: Update handler signatures
- auth.go: Update authentication handler signatures
- book_matching.go: Update matching handler signatures
- collections.go: Update collection handler signatures
- collections_preview_test.go: Update test signatures
- commonhandlers.go: Update common handler signatures
- conflicts.go: Update conflict handler signatures
- context.go: Update context handler signatures
- dashboard.go: Update dashboard handler signatures
- devices.go: Update device handler signatures
- jobs.go: Update job handler signatures
- kobo.go: Update Kobo handler signatures
- koreader.go: Update Koreader handler signatures
- library.go: Update library handler signatures
- matching.go: Update matching handler signatures
- media.go: Update media handler signatures
- opds.go: Update OPDS handler signatures
- progress.go: Update progress handler signatures
- queue.go: Update queue handler signatures
- refresh_token.go: Update token handler signatures
- scanner.go: Update scanner handler signatures
- sidecar.go: Update sidecar handler signatures
- sync.go: Update sync handler signatures
- system_settings.go: Update settings handler signatures
- websocket.go: Update WebSocket handler signatures

All handlers now properly implement Echo v5's pointer-based context pattern.
This change is necessary for type safety and compatibility with Echo v5's
improved context handling and WebSocket support.
2026-03-06 14:00:28 -05:00
john-okeefe d802236874 scanner: fix library isolation, file mtime, force rescan, and deletion handling
Fix 1 - File modification time for created_at:
- Get file.ModTime() in processMediaFile and pass to CreateMediaItem
- Modified SQL INSERT to include created_at column

Fix 2 - Force rescan UPDATE instead of DELETE+INSERT:
- Changed force rescan logic to call updateMediaItem instead of delete + create
- Preserves created_at timestamp on force rescan

Fix 3 - GetMediaItemByFilePath filters by library_id:
- Added library_id to WHERE clause in SQL query
- Created GetMediaItemByFilePathAnyLibrary for cross-library lookups (KOReader)
- Added SetLibraryID method to MediaScanner
- Updated handler to call SetLibraryID for watch mode

Fix 4 - File deletion handling with persistent logging:
- Added fsnotify.Remove handler in WatchChanges
- Added orphan cleanup in ScanFolders after scan completes
- Created scanner_logger.go with daily log rotation (7 days)
- Logs to /app/logs/scanner-deletes-YYYY-MM-DD.log and scanner-errors-YYYY-MM-DD.log
- Individual deletes with enhanced safety logging

Note: Integration tests can now safely scan /app/uploads because
GetMediaItemByFilePath now filters by library_id, preventing
cross-library interference.
2026-02-26 16:39:42 -05:00
john-okeefe 00a083b60b Rename backend code references: Bookmann → Bookhoard
Backend changes:
- Update import paths: bookmann/internal → bookhoard/internal
- Rename struct fields: BookmannUUID → BookhoardUUID
- Update handler function names: mapContentIdToBookmannUUID → mapContentIdToBookhoardUUID
- Update HTTP response headers: X-Bookmann-* → X-Bookhoard-*
- Update service and middleware references
- Update main.go imports and references

This is part 2 of the project rename to Bookhoard.
2026-02-01 16:11:54 -05:00
john-okeefe d28002e4cb feat(sync): Add Kobo/Koreader sync and conflict handling
- Add Kobo markup/sync endpoints for bookshelves
- Add Koreader progress sync with SHA-256 support
- Add sync conflict detection and resolution
- Update ebook scanner for better file matching
2026-01-31 22:32:09 -05:00
john-okeefe a7f2b83bdd Integrate sync queue system and device cap API
- Start queue processor as background goroutine
- Initialize and register queue handler
- Add queue management routes (7 endpoints)
- Update KOReader handler to use checkpoint sync mode
- Add device cap management route (PUT /api/auth/users/:id/max-devices)
- Register all new endpoints with proper middleware
2026-01-31 13:06:56 -05:00
john-okeefe 2d2d643873 Add sync conflict detection and resolution system
Implement conflict detection for concurrent reading progress updates from different devices. Adds conflict management endpoints for listing, viewing, and resolving conflicts.

- Add ConflictHandler with CRUD endpoints for conflict management
- Implement automatic conflict detection in KOReader progress updates
- Add WebSocket broadcast for real-time conflict notifications
- Add database query for listing user conflicts by status
- Add integration tests and Bruno API test collection
2026-01-31 11:45:52 -05:00
john-okeefe 885cbd5d47 feat: broadcast progress updates from KOReader sync
Add WebSocket broadcast to KOReader progress sync:
- Integrate ConnectionManager into KOReaderHandler
- Broadcast progress updates on successful sync
- Include source device information (model, type)
- Real-time updates to all connected clients

When KOReader devices sync reading progress, all connected
WebSocket clients (web browsers, mobile apps, other devices)
receive instant updates.
2026-01-30 21:48:14 -05:00
john-okeefe 7a5d38b886 Phase 3 Week 7: Implement KOReader sync protocol handlers
- Create SyncProgress for bidirectional progress synchronization
- Create GetMetadata for book progress and annotation retrieval
- Create GetLibrary for user library sync
- Create SyncBookmarks for annotation management
- Support device matching by UUID, file path, or title/author
- Implement immediate and checkpoint sync modes
- Handle bookmarks, highlights, and notes synchronization
- Part of Phase 3 KOReader Integration implementation
2026-01-30 20:55:04 -05:00