8 Commits
Author SHA1 Message Date
john-okeefe ce3ae31ced fix(reader): read position from the API at open; never write a restored position
The reader page embedded a snapshot of reading state (position,
bookmarks) server-side at render time. Browsers may reuse that HTML
(heuristic caching, bfcache), so opening a book could restore a stale
position — and worse, the restore's relocate auto-saved it back,
overwriting a newer device push minutes later. A KOReader sync followed
by opening the web reader would silently revert the row to the old web
position; the row's source and the rendered page disagreed.

The web reader is intrinsically tied to the server, so it has no business
preserving reading state client-side:

- The rendered page now carries only immutable book metadata. The reader
  fetches progress fresh (cache: no-store) from the existing progress
  API at open and restores with the same priority as before (page for
  fixed-layout, CFI, percentage, fresh start); a failed fetch opens at
  the start and writes nothing. Initial bookmarks likewise come from
  their endpoint instead of the embed; annotations already did.
- Progress saves are gated on deliberate navigation only (page turns,
  keys, slider, search/TOC/bookmark/back-stack jumps, tap zones — each
  marks the session as user-moved). Restores and section-load
  relocations never write, so displaying a position can no longer
  clobber a newer one. A bfcache-resurrected page resets the flag and
  cannot write its frozen position either. This replaces the old
  five-second post-init suppression, which a stale page bypassed.
- The server-rendered initial progress badges render a neutral
  placeholder until the first relocate fills them (sub-second).

No API, schema, or sync-engine changes. Normal reading saves exactly as
before — the first save now simply waits for the first real page turn.
2026-09-09 14:52:30 -04:00
john-okeefe 75c1d9bb95 fix(highlights): web edits update the targeted row instead of minting duplicates
PUT /highlights/:id parsed the row id from the URL and then dropped it:
the sync-aware path routed through SaveHighlight's content-derived dedup
key, on the assumption that the same text + CFI always resolves to the
same key. That assumption breaks in practice — the stored epubcfi_start
drifts from foliate's range shape to the converter's point shape after a
device echo rewrites the row (bucketPosition cuts at the last colon, so
'…/6,/1:367,…' and '…/6/1:367' bucket differently), and the user can
edit the selection text. The recomputed key then misses the row being
edited and createHighlight mints a second one: the edited row (with
note, no device pos0) beside the original — served to KOReader as two
highlights, one noted and one not. Editing a selection's text would hit
the same trap.

SaveHighlightRequest gains an optional HighlightID. When set, the save
resolves the row by id (ownership-checked), LWWs against it under its
stored dedup key, and never re-derives identity from content. The PUT
handler passes the already-parsed id. Device pushes, Kobo, and the sync
queue send no id and keep the identity-based flow untouched.

applyLWW also stops wiping stored locators on web edits: the web reader
sends empty start/end positions (it never had a CRE xpointer), so a
note/color edit now keeps the device-native positions and CFIs instead
of blanking them — round-trip serve-back for device-created highlights
survives web-side edits.
2026-09-09 14:12:18 -04:00
john-okeefe 905218dd4b fix(reader): stop tap zones from paging during long-press text selection
Selecting text near the left edge on a touch browser could page back
instead: a long-press released just inside the 500ms tap window (Android
selection engages at ~400-500ms, right at the guard boundary) or landing
on a margin resolved as a tap, armed the 280ms debounce, and nothing
ever cancelled it. Four hardenings in the tap-zone pipeline:

- contextmenu (Android's long-press-engaged signal) suppresses the
  matching pointerup from counting as a tap, closing the duration race
- pointercancel (the browser taking over the gesture) now resets the
  tracked pointer so stale state can never match a later touch
- selectionchange on the host document and every content iframe cancels
  an armed tap action: a selection appearing right after finger-lift
  means the 'tap' was a long-press selection engaging
- the host-viewport pointerup honors the tracked any-selection flag,
  closing the blind spot where selections in iframes or the host's own
  fixed-layout text layer were invisible to the host surface (the
  per-surface check only ran for iframe docs)

Purely touch-path (coarse pointer) changes; desktop behavior untouched.
2026-09-09 13:00:39 -04:00
john-okeefe 94dad5e089 fix(reader): stop page-turn and zoom shortcuts while typing in form fields
handleKeydown computed a 'typing' guard for the event target but only
applied it to the drawer-shortcut block (t/s/b/?//). The vi-style page
turns (h/l), arrows, and zoom keys (+/−/0) fired regardless, so typing
into the note textarea hijacked the keys: 'Wh' turned back a page on the
h, arrows moved pages instead of the caret, and digits/minus zoomed.

Return early for INPUT/SELECT/TEXTAREA/contentEditable targets, keeping
Escape live so popovers and drawers stay dismissable from the keyboard
mid-note. Covers the selection-popover note field, the notes drawer
textarea, bookmark rename, and the search box. The now-unreachable
'!typing' condition on the shortcut block is dropped.
2026-09-09 12:59:37 -04:00
john-okeefe 2366faccce fix(reader): make device-synced highlights editable on the web
Device-synced highlights paint with a synthesized range CFI (renderCfi,
built by toRangeCfi from the stored point CFIs and selection text) while
the stored locator stays a point CFI. Three follow-ons from that split:

- show-annotation (click-to-edit) matched the clicked value against the
  stored point cfi only, so clicking a device-created highlight never
  opened the edit popover — it listed in the drawer but was uneditable.
  Match either the stored cfi or the renderCfi the overlay was added by.

- deleteHighlightById removed the overlay with the stored point cfi,
  which never matched the painted value; the highlight box lingered
  until reload. Delete with the value it was added by.

- saveHighlightChanges re-added the overlay without removing the old
  value; an edit that changes the synthesized range (note/text edits
  change the UTF-16 length it derives from) would ghost the old paint
  beside the new one. Remove the previous overlay value first when the
  edit changed it.

Web-created highlights are unaffected: their stored CFI is already a
native range, so renderCfi === cfi for them.
2026-09-09 12:38:24 -04:00
john-okeefe e40530824e feat(koreader): one conversion route for every feature; bookmarks get web CFIs
Release / build-and-push (push) Successful in 2m14s
Progress, highlights, notes, and bookmarks entered position conversion
through three different doors: progress converted inline with an
uncached converter, annotations through the facade, bookmarks not at
all (the raw xpointer was stored verbatim, cfi_position stayed empty,
and the web drawer's goToBookmark silently no-ops on cfi-less entries).

Unify on the facade (ConvertToCanonical/ConvertFromCanonical):

- annotationEpub context resolved once per push: media item + EPUB path
  shared by every annotation instead of re-fetched per entry
- progress forward: the inline block becomes one facade call;
  non-reflowable formats pass through unchanged, and the cached
  converter stops re-parsing the book on every sync
- progress reverse: convertCFIToXPointer delegates to reverseConvertCFI,
  keeping the stored percentage in play for the fallback ladder
- bookmarks (bulk progress and /sync-bookmarks): pos0 resolves
  structural-only — bookmark text is a display label, never book text,
  so no context is supplied; webUsableCFI stores the result only for
  structural/exact epubcfi landings, discarding href/percentage results
  rather than storing dead drawer links. Also records percentage_location
  and origin_source on the legacy endpoint.
- percentages thread through: highlights/notes/bookmarks pass the device
  percentage or the derived section percentage instead of a hardcoded 0,
  so the last-resort fallback lands near the true position instead of
  the document start
- extendCFIByLength end-derivation now also fires on structural starts
  (it had silently stopped matching when the structural rung began
  landing starts with precision 'structural' rather than 'exact')

Tests: the drop-cap xpointer through the facade with empty context (the
bookmark scenario) must land structurally, not doc-start; webUsableCFI
table covers the store/discard gate.
2026-09-09 09:04:23 -04:00
john-okeefe 5a6c361c11 perf(sync): share the bounded converter cache for section percentages
Per-annotation percentage derivation (deriveAnnotationPercentage) built a
fresh CFIConverter for every highlight/note/bookmark, re-reading and
re-parsing the whole EPUB each time. Export SectionPercentageCached so
handlers reach the same bounded cache ConvertToCanonical already uses
(8 books, insertion-order eviction): one parse per book per push instead
of one per annotation.
2026-09-09 09:04:10 -04:00
john-okeefe 7b1c809ae3 feat(bookmarks): location identity, origin provenance, KOReader-style labels
- Drop UNIQUE(media_item_id,user_id,title): titles are display labels
  shared verbatim across clients; same-title bookmarks on different pages
  now coexist instead of 500ing (deleting over a tombstone no longer
  blocks future creates with that title)
- Add origin_source column recording the creating client (android/web/
  koreader), set once at insert, exposed in API responses
- Web reader auto-title mirrors KOReader's 'in <chapter>' convention,
  falling back to 'Bookmark'; adds bookmark rename in the drawer
2026-09-09 08:17:43 -04:00
14 changed files with 646 additions and 423 deletions
+12 -2
View File
@@ -1344,8 +1344,10 @@ CREATE TABLE IF NOT EXISTS media_bookmarks (
title VARCHAR(255) NOT NULL, title VARCHAR(255) NOT NULL,
position VARCHAR(100), -- 'pdf:page:45', 'comic:page:12', 'chapter:3' for consistency position VARCHAR(100), -- 'pdf:page:45', 'comic:page:12', 'chapter:3' for consistency
notes TEXT, notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(), created_at TIMESTAMPTZ DEFAULT NOW()
UNIQUE(media_item_id, user_id, title) -- No UNIQUE(media_item_id, user_id, title): bookmarks are identified by
-- their location (dedup_key), titles are display labels shared verbatim
-- across clients (KOReader auto-labels repeat within a chapter).
); );
CREATE INDEX IF NOT EXISTS idx_media_bookmarks_media ON media_bookmarks(media_item_id); CREATE INDEX IF NOT EXISTS idx_media_bookmarks_media ON media_bookmarks(media_item_id);
@@ -1386,6 +1388,14 @@ ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS epubcfi_location TEXT;
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS chapter_reference INTEGER; ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS chapter_reference INTEGER;
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS deleted BOOLEAN DEFAULT FALSE; ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS deleted BOOLEAN DEFAULT FALSE;
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
-- Provenance: the client that CREATED the bookmark (unlike
-- last_modified_source, which tracks the last writer). Set once at insert.
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS origin_source VARCHAR(30);
-- Identity is the dedup_key (location), not the title; drop the legacy
-- unique-title constraint so same-title bookmarks on different pages can
-- coexist (re-creating over a tombstone with a changed position also
-- relied on this). Catalog-only change, safe to re-run.
ALTER TABLE media_bookmarks DROP CONSTRAINT IF EXISTS media_bookmarks_media_item_id_user_id_title_key;
CREATE UNIQUE INDEX IF NOT EXISTS idx_media_highlights_dedup CREATE UNIQUE INDEX IF NOT EXISTS idx_media_highlights_dedup
ON media_highlights (user_id, media_item_id, dedup_key) ON media_highlights (user_id, media_item_id, dedup_key)
+1
View File
@@ -185,6 +185,7 @@ type MediaBookmarks struct {
ChapterReference pgtype.Int4 `db:"chapter_reference" json:"chapter_reference"` ChapterReference pgtype.Int4 `db:"chapter_reference" json:"chapter_reference"`
Deleted pgtype.Bool `db:"deleted" json:"deleted"` Deleted pgtype.Bool `db:"deleted" json:"deleted"`
DeletedAt pgtype.Timestamptz `db:"deleted_at" json:"deleted_at"` DeletedAt pgtype.Timestamptz `db:"deleted_at" json:"deleted_at"`
OriginSource pgtype.Text `db:"origin_source" json:"origin_source"`
} }
type MediaHighlights struct { type MediaHighlights struct {
+18 -9
View File
@@ -624,7 +624,7 @@ func (q *Queries) CreateLibrary(ctx context.Context, arg CreateLibraryParams) (L
const CreateMediaBookmark = `-- name: CreateMediaBookmark :one const CreateMediaBookmark = `-- name: CreateMediaBookmark :one
INSERT INTO media_bookmarks (media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes) INSERT INTO media_bookmarks (media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes, created_at, dedup_key, last_modified_at, last_modified_source, device_sync_data, percentage_location, epubcfi_location, chapter_reference, deleted, deleted_at RETURNING id, media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes, created_at, dedup_key, last_modified_at, last_modified_source, device_sync_data, percentage_location, epubcfi_location, chapter_reference, deleted, deleted_at, origin_source
` `
type CreateMediaBookmarkParams struct { type CreateMediaBookmarkParams struct {
@@ -670,6 +670,7 @@ func (q *Queries) CreateMediaBookmark(ctx context.Context, arg CreateMediaBookma
&i.ChapterReference, &i.ChapterReference,
&i.Deleted, &i.Deleted,
&i.DeletedAt, &i.DeletedAt,
&i.OriginSource,
) )
return i, err return i, err
} }
@@ -680,10 +681,10 @@ INSERT INTO media_bookmarks (
cfi_position, title, position, notes, cfi_position, title, position, notes,
percentage_location, epubcfi_location, chapter_reference, percentage_location, epubcfi_location, chapter_reference,
dedup_key, last_modified_at, last_modified_source, dedup_key, last_modified_at, last_modified_source,
device_sync_data device_sync_data, origin_source
) VALUES ( ) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15 $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16
) RETURNING id, media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes, created_at, dedup_key, last_modified_at, last_modified_source, device_sync_data, percentage_location, epubcfi_location, chapter_reference, deleted, deleted_at ) RETURNING id, media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes, created_at, dedup_key, last_modified_at, last_modified_source, device_sync_data, percentage_location, epubcfi_location, chapter_reference, deleted, deleted_at, origin_source
` `
type CreateMediaBookmarkFullParams struct { type CreateMediaBookmarkFullParams struct {
@@ -702,6 +703,7 @@ type CreateMediaBookmarkFullParams struct {
LastModifiedAt pgtype.Timestamptz `db:"last_modified_at" json:"last_modified_at"` LastModifiedAt pgtype.Timestamptz `db:"last_modified_at" json:"last_modified_at"`
LastModifiedSource pgtype.Text `db:"last_modified_source" json:"last_modified_source"` LastModifiedSource pgtype.Text `db:"last_modified_source" json:"last_modified_source"`
DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"` DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"`
OriginSource pgtype.Text `db:"origin_source" json:"origin_source"`
} }
func (q *Queries) CreateMediaBookmarkFull(ctx context.Context, arg CreateMediaBookmarkFullParams) (MediaBookmarks, error) { func (q *Queries) CreateMediaBookmarkFull(ctx context.Context, arg CreateMediaBookmarkFullParams) (MediaBookmarks, error) {
@@ -721,6 +723,7 @@ func (q *Queries) CreateMediaBookmarkFull(ctx context.Context, arg CreateMediaBo
arg.LastModifiedAt, arg.LastModifiedAt,
arg.LastModifiedSource, arg.LastModifiedSource,
arg.DeviceSyncData, arg.DeviceSyncData,
arg.OriginSource,
) )
var i MediaBookmarks var i MediaBookmarks
err := row.Scan( err := row.Scan(
@@ -743,6 +746,7 @@ func (q *Queries) CreateMediaBookmarkFull(ctx context.Context, arg CreateMediaBo
&i.ChapterReference, &i.ChapterReference,
&i.Deleted, &i.Deleted,
&i.DeletedAt, &i.DeletedAt,
&i.OriginSource,
) )
return i, err return i, err
} }
@@ -4524,7 +4528,7 @@ func (q *Queries) GetLibraryWithType(ctx context.Context, id pgtype.UUID) (GetLi
} }
const GetMediaBookmark = `-- name: GetMediaBookmark :one const GetMediaBookmark = `-- name: GetMediaBookmark :one
SELECT id, media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes, created_at, dedup_key, last_modified_at, last_modified_source, device_sync_data, percentage_location, epubcfi_location, chapter_reference, deleted, deleted_at FROM media_bookmarks WHERE id = $1 SELECT id, media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes, created_at, dedup_key, last_modified_at, last_modified_source, device_sync_data, percentage_location, epubcfi_location, chapter_reference, deleted, deleted_at, origin_source FROM media_bookmarks WHERE id = $1
` `
func (q *Queries) GetMediaBookmark(ctx context.Context, id pgtype.UUID) (MediaBookmarks, error) { func (q *Queries) GetMediaBookmark(ctx context.Context, id pgtype.UUID) (MediaBookmarks, error) {
@@ -4550,13 +4554,14 @@ func (q *Queries) GetMediaBookmark(ctx context.Context, id pgtype.UUID) (MediaBo
&i.ChapterReference, &i.ChapterReference,
&i.Deleted, &i.Deleted,
&i.DeletedAt, &i.DeletedAt,
&i.OriginSource,
) )
return i, err return i, err
} }
const GetMediaBookmarkByDedupKey = `-- name: GetMediaBookmarkByDedupKey :one const GetMediaBookmarkByDedupKey = `-- name: GetMediaBookmarkByDedupKey :one
SELECT id, media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes, created_at, dedup_key, last_modified_at, last_modified_source, device_sync_data, percentage_location, epubcfi_location, chapter_reference, deleted, deleted_at FROM media_bookmarks SELECT id, media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes, created_at, dedup_key, last_modified_at, last_modified_source, device_sync_data, percentage_location, epubcfi_location, chapter_reference, deleted, deleted_at, origin_source FROM media_bookmarks
WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3 WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3
ORDER BY deleted ASC, deleted_at DESC NULLS LAST ORDER BY deleted ASC, deleted_at DESC NULLS LAST
LIMIT 1 LIMIT 1
@@ -4594,12 +4599,13 @@ func (q *Queries) GetMediaBookmarkByDedupKey(ctx context.Context, arg GetMediaBo
&i.ChapterReference, &i.ChapterReference,
&i.Deleted, &i.Deleted,
&i.DeletedAt, &i.DeletedAt,
&i.OriginSource,
) )
return i, err return i, err
} }
const GetMediaBookmarks = `-- name: GetMediaBookmarks :many const GetMediaBookmarks = `-- name: GetMediaBookmarks :many
SELECT id, media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes, created_at, dedup_key, last_modified_at, last_modified_source, device_sync_data, percentage_location, epubcfi_location, chapter_reference, deleted, deleted_at FROM media_bookmarks SELECT id, media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes, created_at, dedup_key, last_modified_at, last_modified_source, device_sync_data, percentage_location, epubcfi_location, chapter_reference, deleted, deleted_at, origin_source FROM media_bookmarks
WHERE media_item_id = $1 AND user_id = $2 AND COALESCE(deleted, FALSE) = FALSE WHERE media_item_id = $1 AND user_id = $2 AND COALESCE(deleted, FALSE) = FALSE
ORDER BY created_at DESC ORDER BY created_at DESC
` `
@@ -4638,6 +4644,7 @@ func (q *Queries) GetMediaBookmarks(ctx context.Context, arg GetMediaBookmarksPa
&i.ChapterReference, &i.ChapterReference,
&i.Deleted, &i.Deleted,
&i.DeletedAt, &i.DeletedAt,
&i.OriginSource,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -11513,7 +11520,7 @@ SET
position = $4, position = $4,
last_modified_at = NOW() last_modified_at = NOW()
WHERE id = $1 AND user_id = $5 WHERE id = $1 AND user_id = $5
RETURNING id, media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes, created_at, dedup_key, last_modified_at, last_modified_source, device_sync_data, percentage_location, epubcfi_location, chapter_reference, deleted, deleted_at RETURNING id, media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes, created_at, dedup_key, last_modified_at, last_modified_source, device_sync_data, percentage_location, epubcfi_location, chapter_reference, deleted, deleted_at, origin_source
` `
type UpdateMediaBookmarkParams struct { type UpdateMediaBookmarkParams struct {
@@ -11553,6 +11560,7 @@ func (q *Queries) UpdateMediaBookmark(ctx context.Context, arg UpdateMediaBookma
&i.ChapterReference, &i.ChapterReference,
&i.Deleted, &i.Deleted,
&i.DeletedAt, &i.DeletedAt,
&i.OriginSource,
) )
return i, err return i, err
} }
@@ -11575,7 +11583,7 @@ UPDATE media_bookmarks SET
deleted = FALSE, deleted = FALSE,
deleted_at = NULL deleted_at = NULL
WHERE id = $1 WHERE id = $1
RETURNING id, media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes, created_at, dedup_key, last_modified_at, last_modified_source, device_sync_data, percentage_location, epubcfi_location, chapter_reference, deleted, deleted_at RETURNING id, media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes, created_at, dedup_key, last_modified_at, last_modified_source, device_sync_data, percentage_location, epubcfi_location, chapter_reference, deleted, deleted_at, origin_source
` `
type UpdateMediaBookmarkForSyncParams struct { type UpdateMediaBookmarkForSyncParams struct {
@@ -11631,6 +11639,7 @@ func (q *Queries) UpdateMediaBookmarkForSync(ctx context.Context, arg UpdateMedi
&i.ChapterReference, &i.ChapterReference,
&i.Deleted, &i.Deleted,
&i.DeletedAt, &i.DeletedAt,
&i.OriginSource,
) )
return i, err return i, err
} }
+2 -2
View File
@@ -898,9 +898,9 @@ INSERT INTO media_bookmarks (
cfi_position, title, position, notes, cfi_position, title, position, notes,
percentage_location, epubcfi_location, chapter_reference, percentage_location, epubcfi_location, chapter_reference,
dedup_key, last_modified_at, last_modified_source, dedup_key, last_modified_at, last_modified_source,
device_sync_data device_sync_data, origin_source
) VALUES ( ) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15 $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16
) RETURNING *; ) RETURNING *;
-- name: UpdateMediaBookmarkForSync :one -- name: UpdateMediaBookmarkForSync :one
+135 -99
View File
@@ -50,35 +50,88 @@ func (h *KOReaderHandler) SetAnnotationService(svc *wsync.AnnotationService) {
h.annotationSvc = svc h.annotationSvc = svc
} }
func (h *KOReaderHandler) convertHighlightPositions(ctx context.Context, mediaItemID pgtype.UUID, pos0, pos1, contextText string) (string, string) { // annotationEpub carries the per-book context every locator conversion
if pos0 == "" || h.libraryService == nil { // needs: the media item (format gating) and the resolved EPUB path. It is
return "", "" // resolved once per request so all annotations in a push share one
// converter-cache entry instead of re-resolving (and re-parsing the book)
// per annotation.
type annotationEpub struct {
mediaItem *database.MediaItems
epubPath string
} }
func (ec annotationEpub) convertible() bool {
return ec.mediaItem != nil && ec.epubPath != ""
}
func (h *KOReaderHandler) loadAnnotationEpub(ctx context.Context, mediaItemID pgtype.UUID) annotationEpub {
var ec annotationEpub
mediaItem, err := h.db.GetMediaItem(ctx, mediaItemID) mediaItem, err := h.db.GetMediaItem(ctx, mediaItemID)
if err != nil { if err != nil {
return ec
}
ec.mediaItem = &mediaItem
if h.libraryService != nil {
if epubPath, err := h.libraryService.ResolveMediaPath(ctx, mediaItem.LibraryID, mediaItem.FilePath); err == nil && epubPath != "" {
ec.epubPath = epubPath
}
}
return ec
}
// convertHighlightPositions resolves a device annotation's pos0/pos1
// locators to canonical CFIs through the shared facade. contextText is the
// selection's own text — the ideal anchor for the converter's verification
// and text-search rungs. percentage anchors the last-resort fallback so a
// failed conversion degrades to the neighborhood of the true position
// rather than the document start.
func (h *KOReaderHandler) convertHighlightPositions(ec annotationEpub, pos0, pos1, contextText string, percentage float64) (string, string) {
if pos0 == "" || !ec.convertible() {
return "", "" return "", ""
} }
epubPath, err := h.libraryService.ResolveMediaPath(ctx, mediaItem.LibraryID, mediaItem.FilePath) startLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos0, percentage, contextText, ec.mediaItem.FormatGroup, ec.epubPath, "")
if err != nil || epubPath == "" { endLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos1, percentage, "", ec.mediaItem.FormatGroup, ec.epubPath, "")
return "", ""
}
// The annotation's own text is the ideal anchor for the converter's
// text-search path: clients (thin, underpowered) send only raw
// locators, the server resolves them against the actual book.
startLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos0, 0, contextText, mediaItem.FormatGroup, epubPath, "")
endLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos1, 0, "", mediaItem.FormatGroup, epubPath, "")
endCFI := endLoc.CFI endCFI := endLoc.CFI
// The end conversion carries no context text, so unless it resolved // The end conversion carries no context text, so unless it resolved
// exactly it degenerates to a percentage fallback anchored at the // exactly it degenerates to a percentage fallback anchored at the
// document start — useless as a range end. When the START resolved // document start — useless as a range end. When the START resolved
// exactly, derive the end from it: same node, character offset // structurally/exactly, derive the end from it: same node, character
// advanced by the selection's UTF-16 length (the CFI offset unit). // offset advanced by the selection's UTF-16 length (the CFI offset
if endLoc.Precision != "exact" && startLoc.Precision == "exact" && contextText != "" { // unit).
if endLoc.Precision != "exact" && endLoc.Precision != "structural" &&
(startLoc.Precision == "exact" || startLoc.Precision == "structural") && contextText != "" {
endCFI = extendCFIByLength(startLoc.CFI, contextText) endCFI = extendCFIByLength(startLoc.CFI, contextText)
} }
return startLoc.CFI, endCFI return startLoc.CFI, endCFI
} }
// convertBookmarkPosition resolves a device bookmark's locator to the
// canonical CFI for the web reader. Structural-only by design: bookmark
// text is a display label ("in <chapter>" auto-fill or a user note), never
// book text, so no context is supplied and only a structural/exact landing
// is trusted — lower rungs would store a confident-looking guess the web
// drawer would present as a real destination.
func (h *KOReaderHandler) convertBookmarkPosition(ec annotationEpub, position string, percentage float64) string {
if position == "" || !ec.convertible() || !wsync.IsCREXPointer(position) {
return ""
}
loc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, position, percentage, "", ec.mediaItem.FormatGroup, ec.epubPath, "")
return webUsableCFI(loc)
}
// webUsableCFI keeps only high-confidence conversions: the web reader
// navigates bookmarks by CFI, so href/percentage/fallback results are
// discarded instead of stored as dead links.
func webUsableCFI(loc wsync.CanonicalLocator) string {
if loc.Precision != "structural" && loc.Precision != "exact" {
return ""
}
if !strings.HasPrefix(loc.CFI, "epubcfi(") || !strings.HasSuffix(loc.CFI, ")") {
return ""
}
return loc.CFI
}
// extendCFIByLength advances a point CFI's trailing character offset by the // extendCFIByLength advances a point CFI's trailing character offset by the
// UTF-16 length of text (EPUB CFI character offsets are UTF-16 code units). // UTF-16 length of text (EPUB CFI character offsets are UTF-16 code units).
// Selections spanning multiple text nodes produce an out-of-range offset — // Selections spanning multiple text nodes produce an out-of-range offset —
@@ -127,22 +180,19 @@ func (h *KOReaderHandler) existingHighlightColor(ctx context.Context, mediaItemI
// deriveAnnotationPercentage computes a percentage for device-pushed // deriveAnnotationPercentage computes a percentage for device-pushed
// annotations when the client didn't send one (thin clients skip their own // annotations when the client didn't send one (thin clients skip their own
// per-annotation page lookups; arithmetic is only free on paging documents). // per-annotation page lookups; arithmetic is only free on paging documents).
func (h *KOReaderHandler) deriveAnnotationPercentage(ctx context.Context, mediaItemID pgtype.UUID, pos0 string, page int) float64 { func (h *KOReaderHandler) deriveAnnotationPercentage(ec annotationEpub, pos0 string, page int) float64 {
mediaItem, err := h.db.GetMediaItem(ctx, mediaItemID) if ec.mediaItem == nil {
if err != nil {
return 0 return 0
} }
formatGroup := wsync.FormatGroup(mediaItem.FormatGroup) formatGroup := wsync.FormatGroup(ec.mediaItem.FormatGroup)
if formatGroup == wsync.FormatGroupFixedLayout || formatGroup == wsync.FormatGroupComicArchive { if formatGroup == wsync.FormatGroupFixedLayout || formatGroup == wsync.FormatGroupComicArchive {
if page > 0 && mediaItem.PageCount.Valid && mediaItem.PageCount.Int32 > 0 { if page > 0 && ec.mediaItem.PageCount.Valid && ec.mediaItem.PageCount.Int32 > 0 {
return float64(page) / float64(mediaItem.PageCount.Int32) return float64(page) / float64(ec.mediaItem.PageCount.Int32)
} }
return 0 return 0
} }
if wsync.IsCREXPointer(pos0) && h.libraryService != nil { if wsync.IsCREXPointer(pos0) && ec.epubPath != "" {
if epubPath, err := h.libraryService.ResolveMediaPath(ctx, mediaItem.LibraryID, mediaItem.FilePath); err == nil && epubPath != "" { return wsync.SectionPercentageCached(ec.epubPath, pos0)
return wsync.NewCFIConverter(epubPath).SectionPercentage(pos0)
}
} }
return 0 return 0
} }
@@ -614,21 +664,23 @@ func (h *KOReaderHandler) processBookAnnotations(ctx context.Context, deviceID,
if h.annotationSvc == nil { if h.annotationSvc == nil {
return return
} }
ec := h.loadAnnotationEpub(ctx, mediaItemID)
for _, hl := range book.Highlights { for _, hl := range book.Highlights {
startPos := hl.Pos0 startPos := hl.Pos0
endPos := hl.Pos1 endPos := hl.Pos1
// The highlight's own text anchors the conversion exactly.
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, startPos, endPos, hl.Text)
pctStart := 0.0 pctStart := 0.0
if hl.Percentage != nil { if hl.Percentage != nil {
pctStart = *hl.Percentage pctStart = *hl.Percentage
} }
if pctStart == 0 { if pctStart == 0 {
pctStart = h.deriveAnnotationPercentage(ctx, mediaItemID, startPos, int(hl.Page)) pctStart = h.deriveAnnotationPercentage(ec, startPos, int(hl.Page))
} }
// The highlight's own text anchors the conversion exactly.
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ec, startPos, endPos, hl.Text, pctStart)
deviceData, _ := json.Marshal(map[string]interface{}{ deviceData, _ := json.Marshal(map[string]interface{}{
"datetime": hl.Datetime, "datetime": hl.Datetime,
"pos0": hl.Pos0, "pos0": hl.Pos0,
@@ -676,16 +728,17 @@ func (h *KOReaderHandler) processBookAnnotations(ctx context.Context, deviceID,
for _, note := range book.Notes { for _, note := range book.Notes {
startPos := note.Pos0 startPos := note.Pos0
endPos := note.Pos1 endPos := note.Pos1
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, startPos, endPos, note.Text)
pctStart := 0.0 pctStart := 0.0
if note.Percentage != nil { if note.Percentage != nil {
pctStart = *note.Percentage pctStart = *note.Percentage
} }
if pctStart == 0 { if pctStart == 0 {
pctStart = h.deriveAnnotationPercentage(ctx, mediaItemID, startPos, int(note.Page)) pctStart = h.deriveAnnotationPercentage(ec, startPos, int(note.Page))
} }
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ec, startPos, endPos, note.Text, pctStart)
deviceData, _ := json.Marshal(map[string]interface{}{ deviceData, _ := json.Marshal(map[string]interface{}{
"datetime": note.Datetime, "datetime": note.Datetime,
"pos0": note.Pos0, "pos0": note.Pos0,
@@ -723,6 +776,19 @@ func (h *KOReaderHandler) processBookAnnotations(ctx context.Context, deviceID,
position = fmt.Sprintf("page:%d", bookmark.Page) position = fmt.Sprintf("page:%d", bookmark.Page)
} }
pctLoc := 0.0
if bookmark.Percentage != nil {
pctLoc = *bookmark.Percentage
}
if pctLoc == 0 {
pctLoc = h.deriveAnnotationPercentage(ec, position, int(bookmark.Page))
}
// Device-native xpointer → canonical CFI so the web drawer can
// navigate KOReader-created bookmarks (page-only positions have no
// convertible locator; the drawer's page fallback covers those).
cfiPosition := h.convertBookmarkPosition(ec, position, pctLoc)
deviceData, _ := json.Marshal(map[string]interface{}{ deviceData, _ := json.Marshal(map[string]interface{}{
"datetime": bookmark.Datetime, "datetime": bookmark.Datetime,
"pos0": bookmark.Pos0, "pos0": bookmark.Pos0,
@@ -739,8 +805,11 @@ func (h *KOReaderHandler) processBookAnnotations(ctx context.Context, deviceID,
UserID: userID, UserID: userID,
Title: bookmark.Text, Title: bookmark.Text,
Position: position, Position: position,
CFIPosition: cfiPosition,
PercentageLoc: pctLoc,
ChapterNumber: int32(bookmark.Chapter), ChapterNumber: int32(bookmark.Chapter),
Source: "koreader", Source: "koreader",
OriginSource: "koreader",
DeviceSyncData: deviceData, DeviceSyncData: deviceData,
DedupKey: dedupKey, DedupKey: dedupKey,
}) })
@@ -783,29 +852,11 @@ func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, deviceID pgtype
if h.progressSvc != nil { if h.progressSvc != nil {
epubcfi := book.Epubcfi epubcfi := book.Epubcfi
if epubcfi != nil && wsync.IsCREXPointer(*epubcfi) { if epubcfi != nil && wsync.IsCREXPointer(*epubcfi) {
log.Printf("Bookhoard: CRE→CFI attempting conversion for %s", *epubcfi) // Same facade every annotation uses: cached converter,
mediaItem, err := h.db.GetMediaItem(ctx, mediaItemID) // structural-first resolution, guarded fallbacks. The facade
if err != nil { // passes non-reflowable formats through untouched.
log.Printf("Bookhoard: CRE→CFI failed to get media item: %v", err) ec := h.loadAnnotationEpub(ctx, mediaItemID)
} else if mediaItem.FormatGroup == string(wsync.FormatGroupFixedLayout) || if ec.convertible() {
mediaItem.FormatGroup == string(wsync.FormatGroupComicArchive) {
// Image-based fixed content (fixed-layout comic EPUBs, PDF,
// comic archives) has no extractable text, so CRE→CFI conversion
// cannot succeed. The page index (page/total_pages) is the
// canonical locator. Keep the incoming xpointer for device-native
// restore; the web reader restores by page.
log.Printf("Bookhoard: CRE→CFI skipped for %s format", mediaItem.FormatGroup)
} else if h.libraryService == nil {
log.Printf("Bookhoard: CRE→CFI libraryService is nil, skipping conversion")
} else {
epubPath, resolveErr := h.libraryService.ResolveMediaPath(ctx, mediaItem.LibraryID, mediaItem.FilePath)
if resolveErr != nil {
log.Printf("Bookhoard: CRE→CFI failed to resolve media path: %v", resolveErr)
} else if epubPath == "" {
log.Printf("Bookhoard: CRE→CFI resolved empty epub path for %s", mediaItem.FilePath)
} else {
log.Printf("Bookhoard: CRE→CFI resolved epub path: %s", epubPath)
converter := wsync.NewCFIConverter(epubPath)
pct := 0.0 pct := 0.0
if book.Percentage >= 0 { if book.Percentage >= 0 {
pct = book.Percentage pct = book.Percentage
@@ -814,22 +865,11 @@ func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, deviceID pgtype
if book.ContextText != nil { if book.ContextText != nil {
contextText = *book.ContextText contextText = *book.ContextText
} }
result, convErr := converter.ConvertCREToStandard(*epubcfi, pct, contextText) loc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, *epubcfi, pct, contextText, ec.mediaItem.FormatGroup, ec.epubPath, "")
if convErr != nil { if loc.CFI != "" && loc.CFI != *epubcfi {
log.Printf("Bookhoard: CRE→CFI conversion error: %v", convErr) converted := loc.CFI
} else if result != nil { epubcfi = &converted
if result.EPUBCFI != "" { log.Printf("Bookhoard: CRE→CFI converted progress (%s) to %s", loc.Precision, converted)
convertedCFI := result.EPUBCFI
epubcfi = &convertedCFI
log.Printf("Bookhoard: CRE→CFI converted to epubcfi: %s", convertedCFI)
} else if result.Href != "" {
convertedHref := result.Href
epubcfi = &convertedHref
log.Printf("Bookhoard: CRE→CFI converted to href: %s", convertedHref)
} else {
log.Printf("Bookhoard: CRE→CFI conversion: %s precision for %s", result.Precision, *epubcfi)
}
}
} }
} }
} }
@@ -1168,40 +1208,20 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
} }
func (h *KOReaderHandler) convertCFIToXPointer(c *echo.Context, mediaItem database.MediaItems, progress database.GetUniversalProgressRow, progressData *KOReaderProgressData) { func (h *KOReaderHandler) convertCFIToXPointer(c *echo.Context, mediaItem database.MediaItems, progress database.GetUniversalProgressRow, progressData *KOReaderProgressData) {
if h.libraryService == nil {
log.Printf("Bookhoard: CFI→CRE libraryService is nil, skipping reverse conversion")
return
}
epubPath, err := h.libraryService.ResolveMediaPath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath)
if err != nil {
log.Printf("Bookhoard: CFI→CRE failed to resolve media path: %v", err)
return
}
if epubPath == "" {
log.Printf("Bookhoard: CFI→CRE resolved empty epub path for %s", mediaItem.FilePath)
return
}
converter := wsync.NewCFIConverter(epubPath)
contextText := "" contextText := ""
if progress.ContextText.Valid { if progress.ContextText.Valid {
contextText = progress.ContextText.String contextText = progress.ContextText.String
} }
pct := progress.Percentage.Float64 // Same facade path annotations use on serve: structural resolution
// first, guarded text search only as fallback, cached converter. The
result, err := converter.ConvertStandardToCRE(progress.Epubcfi.String, pct, contextText) // stored percentage anchors the reverse fallback ladder.
if err != nil { if xp := h.reverseConvertCFI(c, mediaItem, progress.Epubcfi.String, contextText, progress.Percentage.Float64); xp != "" {
log.Printf("Bookhoard: CFI→CRE conversion error: %v", err) progressData.KoreaderXPointer = &xp
return log.Printf("Bookhoard: CFI→CRE converted to XPointer: %s", xp)
}
if result != nil && result.XPointer != "" {
progressData.KoreaderXPointer = &result.XPointer
log.Printf("Bookhoard: CFI→CRE converted to XPointer: %s", result.XPointer)
} }
} }
func (h *KOReaderHandler) reverseConvertCFI(c *echo.Context, mediaItem database.MediaItems, epubcfi string, contextText string) string { func (h *KOReaderHandler) reverseConvertCFI(c *echo.Context, mediaItem database.MediaItems, epubcfi string, contextText string, percentage float64) string {
if h.libraryService == nil || epubcfi == "" { if h.libraryService == nil || epubcfi == "" {
return "" return ""
} }
@@ -1209,7 +1229,7 @@ func (h *KOReaderHandler) reverseConvertCFI(c *echo.Context, mediaItem database.
if err != nil || epubPath == "" { if err != nil || epubPath == "" {
return "" return ""
} }
loc := wsync.ConvertFromCanonical(wsync.LocatorSourceKOReader, epubcfi, 0, contextText, mediaItem.FormatGroup, epubPath, "") loc := wsync.ConvertFromCanonical(wsync.LocatorSourceKOReader, epubcfi, percentage, contextText, mediaItem.FormatGroup, epubPath, "")
if loc.Position != "" && loc.Position != epubcfi { if loc.Position != "" && loc.Position != epubcfi {
return loc.Position return loc.Position
} }
@@ -1316,7 +1336,7 @@ func (h *KOReaderHandler) koreaderPos0(c *echo.Context, mediaItem database.Media
cfi = strings.TrimPrefix(startPosition, "cfi:") cfi = strings.TrimPrefix(startPosition, "cfi:")
} }
if cfi != "" && wsync.IsStandardEPUBCFI(cfi) { if cfi != "" && wsync.IsStandardEPUBCFI(cfi) {
if converted := h.reverseConvertCFI(c, mediaItem, cfi, contextText); converted != "" { if converted := h.reverseConvertCFI(c, mediaItem, cfi, contextText, 0); converted != "" {
return converted return converted
} }
// Conversion failed; fall through so numeric positions still work. // Conversion failed; fall through so numeric positions still work.
@@ -1482,6 +1502,17 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
} }
if h.annotationSvc != nil { if h.annotationSvc != nil {
ec := h.loadAnnotationEpub(ctx, mediaItemID)
pctLoc := 0.0
if bookmark.Percentage != nil {
pctLoc = *bookmark.Percentage
}
if pctLoc == 0 {
pctLoc = h.deriveAnnotationPercentage(ec, position, int(bookmark.Page))
}
cfiPosition := h.convertBookmarkPosition(ec, position, pctLoc)
deviceData, _ := json.Marshal(map[string]interface{}{ deviceData, _ := json.Marshal(map[string]interface{}{
"datetime": bookmark.Datetime, "datetime": bookmark.Datetime,
"pos0": bookmark.Pos0, "pos0": bookmark.Pos0,
@@ -1493,8 +1524,11 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
UserID: pgUserID, UserID: pgUserID,
Title: bookmark.Text, Title: bookmark.Text,
Position: position, Position: position,
CFIPosition: cfiPosition,
PercentageLoc: pctLoc,
ChapterNumber: int32(bookmark.Chapter), ChapterNumber: int32(bookmark.Chapter),
Source: "koreader", Source: "koreader",
OriginSource: "koreader",
DeviceSyncData: deviceData, DeviceSyncData: deviceData,
}) })
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted { if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
@@ -1588,13 +1622,15 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
} }
if h.annotationSvc != nil { if h.annotationSvc != nil {
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, highlight.Pos0, highlight.Pos1, highlight.Text) ec := h.loadAnnotationEpub(ctx, mediaItemID)
pctStart := 0.0 pctStart := 0.0
if highlight.Percentage != nil { if highlight.Percentage != nil {
pctStart = *highlight.Percentage pctStart = *highlight.Percentage
} }
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ec, highlight.Pos0, highlight.Pos1, highlight.Text, pctStart)
deviceData, _ := json.Marshal(map[string]interface{}{ deviceData, _ := json.Marshal(map[string]interface{}{
"datetime": highlight.Datetime, "datetime": highlight.Datetime,
"pos0": highlight.Pos0, "pos0": highlight.Pos0,
+52
View File
@@ -4,6 +4,8 @@ import (
"encoding/json" "encoding/json"
"testing" "testing"
wsync "bookhoard/internal/sync"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@@ -50,3 +52,53 @@ func TestKOReaderProgressRequest_DeletedAnnotationsOmitted(t *testing.T) {
assert.Empty(t, req.Books[0].DeletedHighlights) assert.Empty(t, req.Books[0].DeletedHighlights)
assert.Empty(t, req.Books[0].DeletedBookmarks) assert.Empty(t, req.Books[0].DeletedBookmarks)
} }
// The web drawer navigates bookmarks by CFI, so only high-confidence
// conversions may be stored: href/percentage/fallback results would
// become dead links. This is the sole gate for KOReader→web bookmark
// positions (bookmark text is a label, never book text, so the
// conversion runs structural-only with empty context).
func TestWebUsableCFI(t *testing.T) {
cases := []struct {
name string
loc wsync.CanonicalLocator
expected string
}{
{
name: "structural landing stored",
loc: wsync.CanonicalLocator{CFI: "epubcfi(/6/52!/4/28/2/1:0)", Precision: "structural", Percentage: 0.52},
expected: "epubcfi(/6/52!/4/28/2/1:0)",
},
{
name: "exact text-search landing stored",
loc: wsync.CanonicalLocator{CFI: "epubcfi(/6/52!/4/28/2/1:0)", Precision: "exact", Percentage: 0.52},
expected: "epubcfi(/6/52!/4/28/2/1:0)",
},
{
name: "percentage guess discarded",
loc: wsync.CanonicalLocator{CFI: "epubcfi(/6/52!/4/2/1:0)", Precision: "percentage", Percentage: 0.52},
expected: "",
},
{
name: "fallback passthrough (raw xpointer) discarded",
loc: wsync.CanonicalLocator{CFI: "/body/DocFragment[2]/body/p[3]", Precision: "fallback", Percentage: 0.52},
expected: "",
},
{
name: "section href discarded (not a CFI)",
loc: wsync.CanonicalLocator{CFI: "ch10.xhtml", Precision: "section", Percentage: 0.52},
expected: "",
},
{
name: "structural but non-CFI value discarded",
loc: wsync.CanonicalLocator{CFI: "ch10.xhtml#h1", Precision: "structural", Percentage: 0.52},
expected: "",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.expected, webUsableCFI(tc.loc))
})
}
}
+13
View File
@@ -152,6 +152,9 @@ type CreateMediaBookmarkRequest struct {
ChapterNumber int32 `json:"chapter_number"` ChapterNumber int32 `json:"chapter_number"`
Percentage float64 `json:"percentage"` Percentage float64 `json:"percentage"`
ChapterReference int32 `json:"chapter_reference"` ChapterReference int32 `json:"chapter_reference"`
// Origin labels the creating client for display ("android", "web").
// Optional: defaults to "web" for browser callers.
Origin string `json:"origin" validate:"omitempty,max=30"`
} }
// UpdateMediaBookmarkRequest represents the request for updating a media bookmark // UpdateMediaBookmarkRequest represents the request for updating a media bookmark
@@ -1653,6 +1656,11 @@ func (mh *MediaHandler) UpdateMediaHighlight(c *echo.Context) error {
ChapterReference: req.ChapterReference, ChapterReference: req.ChapterReference,
Source: "web", Source: "web",
ModifiedAt: time.Now(), ModifiedAt: time.Now(),
// The PUT targets this exact row (from the URL): identity must
// not be re-derived from content — a device echo has usually
// rewritten the stored CFI to point shape, so the computed key
// would miss and mint a duplicate beside the edited row.
HighlightID: pgtype.UUID{Bytes: highlightUUID, Valid: true},
}) })
if err != nil { if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
@@ -1750,6 +1758,10 @@ func (mh *MediaHandler) CreateMediaBookmark(c *echo.Context) error {
// The sync-aware path (dedup + LWW + tombstones) is preferred; fall back // The sync-aware path (dedup + LWW + tombstones) is preferred; fall back
// to the plain query when the service isn't wired (e.g. some tests). // to the plain query when the service isn't wired (e.g. some tests).
if mh.annotationSvc != nil { if mh.annotationSvc != nil {
origin := req.Origin
if origin == "" {
origin = "web"
}
result, err := mh.annotationSvc.SaveBookmark(c.Request().Context(), wsync.SaveBookmarkRequest{ result, err := mh.annotationSvc.SaveBookmark(c.Request().Context(), wsync.SaveBookmarkRequest{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
@@ -1762,6 +1774,7 @@ func (mh *MediaHandler) CreateMediaBookmark(c *echo.Context) error {
PercentageLoc: req.Percentage, PercentageLoc: req.Percentage,
ChapterReference: req.ChapterReference, ChapterReference: req.ChapterReference,
Source: "web", Source: "web",
OriginSource: origin,
ModifiedAt: time.Now(), ModifiedAt: time.Now(),
}) })
if err != nil { if err != nil {
+6 -69
View File
@@ -1,18 +1,15 @@
package router package router
import ( import (
"bookhoard/internal/database"
"bookhoard/internal/handlers" "bookhoard/internal/handlers"
"bookhoard/internal/services" "bookhoard/internal/services"
"bookhoard/internal/sync" "bookhoard/internal/sync"
"bookhoard/internal/utils" "bookhoard/internal/utils"
"bookhoard/templates" "bookhoard/templates"
"bytes" "bytes"
"errors"
"net/http" "net/http"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v5" "github.com/labstack/echo/v5"
) )
@@ -69,21 +66,10 @@ func registerReaderRoutes(cfg *Config) {
if !visible { if !visible {
return renderErrorPage(c, "Access denied", "access_denied") return renderErrorPage(c, "Access denied", "access_denied")
} }
// Get reading progress // Convert to template types. Reading state is deliberately NOT
var progress database.ReadingProgress // fetched or embedded: the reader pulls position, bookmarks, and
progress, err = cfg.Queries.GetReadingProgress(c.Request().Context(), database.GetReadingProgressParams{ // annotations from the APIs at open time so the page can never
MediaItemID: pgtype.UUID{Bytes: parsedUUID, Valid: true}, // carry (nor write back) a stale snapshot.
UserID: uuidToPGType(userUUID),
})
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
progress = database.ReadingProgress{}
}
// Get bookmarks
bookmarks, _ := cfg.Queries.GetMediaBookmarks(c.Request().Context(), database.GetMediaBookmarksParams{
MediaItemID: pgtype.UUID{Bytes: parsedUUID, Valid: true},
UserID: uuidToPGType(userUUID),
})
// Convert to template types
mediaUUID, _ := uuid.FromBytes(mediaItem.ID.Bytes[0:16]) mediaUUID, _ := uuid.FromBytes(mediaItem.ID.Bytes[0:16])
libUUID, _ := uuid.FromBytes(mediaItem.LibraryID.Bytes[0:16]) libUUID, _ := uuid.FromBytes(mediaItem.LibraryID.Bytes[0:16])
metadata := templates.ReaderMetadata{ metadata := templates.ReaderMetadata{
@@ -104,58 +90,9 @@ func registerReaderRoutes(cfg *Config) {
TotalCharacters: mediaItem.TotalCharacters.Int64, TotalCharacters: mediaItem.TotalCharacters.Int64,
EstimatedPages: sync.EstimatedPages(mediaItem.TotalCharacters.Int64), EstimatedPages: sync.EstimatedPages(mediaItem.TotalCharacters.Int64),
} }
// Progress conversion (inline) // Render template
progressUUID, _ := uuid.FromBytes(progress.ID.Bytes[0:16])
progressMediaUUID, _ := uuid.FromBytes(progress.MediaItemID.Bytes[0:16])
progressUserUUID, _ := uuid.FromBytes(progress.UserID.Bytes[0:16])
templateProgress := templates.ReadingProgress{
ID: progressUUID.String(),
MediaItemID: progressMediaUUID.String(),
UserID: progressUserUUID.String(),
CurrentPage: int(progress.CurrentPage.Int32),
TotalPages: int(progress.TotalPages.Int32),
Percentage: progress.Percentage.Float64 * 100,
EpubCfi: textToString(progress.Epubcfi),
LastReadAt: progress.LastReadAt.Time,
Chapter: int(progress.Chapter.Int32),
ChapterProgress: progress.ChapterProgress.Float64 * 100,
FormatGroup: mediaItem.FormatGroup,
}
// Bookmarks conversion (inline, with loop)
templateBookmarks := make([]templates.Bookmark, len(bookmarks))
for i, b := range bookmarks {
bookmarkUUID, _ := uuid.FromBytes(b.ID.Bytes[0:16])
bookmarkMediaUUID, _ := uuid.FromBytes(b.MediaItemID.Bytes[0:16])
bookmarkUserUUID, _ := uuid.FromBytes(b.UserID.Bytes[0:16])
var pageNumber *int
if b.PageNumber.Valid {
val := int(b.PageNumber.Int32)
pageNumber = &val
}
var chapterNumber *int
if b.ChapterNumber.Valid {
val := int(b.ChapterNumber.Int32)
chapterNumber = &val
}
templateBookmarks[i] = templates.Bookmark{
ID: bookmarkUUID.String(),
MediaItemID: bookmarkMediaUUID.String(),
UserID: bookmarkUserUUID.String(),
PageNumber: pageNumber,
ChapterNumber: chapterNumber,
CfiPosition: textToString(b.CfiPosition),
Title: b.Title,
Position: textToString(b.Position),
Notes: textToString(b.Notes),
CreatedAt: b.CreatedAt.Time,
}
}
// 8. Render template
var buf bytes.Buffer var buf bytes.Buffer
err = templates.Reader(user, metadata, templateProgress, templateBookmarks).Render(c.Request().Context(), &buf) err = templates.Reader(user, metadata).Render(c.Request().Context(), &buf)
if err != nil { if err != nil {
return renderErrorPage(c, "Error rendering reader", "render_error") return renderErrorPage(c, "Error rendering reader", "render_error")
} }
+68 -7
View File
@@ -67,6 +67,13 @@ type SaveHighlightRequest struct {
EndPosition string EndPosition string
Color string Color string
NoteText string NoteText string
// HighlightID, when valid, targets that exact row (web PUTs edit by
// id): the save LWWs against it directly under its stored dedup key.
// The computed key depends on fields that legitimately change — the
// stored CFI drifts range→point shape after device echoes, and the
// user can edit the selection text — so a key-based upsert would mint
// a duplicate beside the very row being edited.
HighlightID pgtype.UUID
PercentageStart float64 PercentageStart float64
PercentageEnd float64 PercentageEnd float64
EpubcfiStart string EpubcfiStart string
@@ -90,6 +97,35 @@ type SaveHighlightResult struct {
func (s *AnnotationService) SaveHighlight(ctx context.Context, req SaveHighlightRequest) (*SaveHighlightResult, error) { func (s *AnnotationService) SaveHighlight(ctx context.Context, req SaveHighlightRequest) (*SaveHighlightResult, error) {
dedupKey := req.DedupKey dedupKey := req.DedupKey
// Web edits arrive with the row id from the URL: resolve by id first
// and LWW against that row under its stored key. Content-derived keys
// are for lookups by identity (device pushes carry no row ids); an
// edit must never re-derive identity from (possibly edited) content.
if req.HighlightID.Valid {
byID, idErr := s.db.GetMediaHighlight(ctx, req.HighlightID)
if idErr != nil && !errors.Is(idErr, pgx.ErrNoRows) {
return nil, fmt.Errorf("query highlight by id: %w", idErr)
}
if idErr == nil {
if byID.UserID != req.UserID || byID.MediaItemID != req.MediaItemID {
return nil, fmt.Errorf("highlight %s belongs to another user or media item", req.HighlightID)
}
if dedupKey == "" {
dedupKey = byID.DedupKey.String
}
if byID.Deleted.Bool {
if !incomingNewerThanTombstone(req.ModifiedAt, byID.DeletedAt, byID.LastModifiedAt) {
return &SaveHighlightResult{Highlight: byID, Outcome: SaveOutcomeDeleted}, nil
}
// Newer than the tombstone: a deliberate re-create. Resurrect
// via the LWW update (which clears deleted/deleted_at).
}
return s.applyLWW(ctx, req, byID, dedupKey)
}
// No row with that id: fall through to identity-based resolution.
}
if dedupKey == "" { if dedupKey == "" {
dedupKey = ComputeDedupKey(req.SelectionText, req.EpubcfiStart, req.StartPosition) dedupKey = ComputeDedupKey(req.SelectionText, req.EpubcfiStart, req.StartPosition)
} }
@@ -186,17 +222,38 @@ func (s *AnnotationService) applyLWW(
deviceData := mergeDeviceSyncData(existing.DeviceSyncData, req.Source, req.DeviceSyncData) deviceData := mergeDeviceSyncData(existing.DeviceSyncData, req.Source, req.DeviceSyncData)
// Web edits carry no device locators (the web reader never had a CRE
// xpointer) and may carry no CFI either: keep the stored ones so
// device-native serve-back and round-trip identity survive a web-side
// note/color edit instead of being wiped to empty.
startPosition := req.StartPosition
if startPosition == "" {
startPosition = existing.StartPosition.String
}
endPosition := req.EndPosition
if endPosition == "" {
endPosition = existing.EndPosition.String
}
epubcfiStart := req.EpubcfiStart
if epubcfiStart == "" {
epubcfiStart = existing.EpubcfiStart.String
}
epubcfiEnd := req.EpubcfiEnd
if epubcfiEnd == "" {
epubcfiEnd = existing.EpubcfiEnd.String
}
highlight, err := s.db.UpdateMediaHighlightForSync(ctx, database.UpdateMediaHighlightForSyncParams{ highlight, err := s.db.UpdateMediaHighlightForSync(ctx, database.UpdateMediaHighlightForSyncParams{
ID: existing.ID, ID: existing.ID,
SelectionText: req.SelectionText, SelectionText: req.SelectionText,
StartPosition: pgText(req.StartPosition), StartPosition: pgText(startPosition),
EndPosition: pgText(req.EndPosition), EndPosition: pgText(endPosition),
Color: pgText(req.Color), Color: pgText(req.Color),
NoteText: pgText(req.NoteText), NoteText: pgText(req.NoteText),
PercentageStart: pgFloat8(req.PercentageStart), PercentageStart: pgFloat8(req.PercentageStart),
PercentageEnd: pgFloat8(req.PercentageEnd), PercentageEnd: pgFloat8(req.PercentageEnd),
EpubcfiStart: pgText(req.EpubcfiStart), EpubcfiStart: pgText(epubcfiStart),
EpubcfiEnd: pgText(req.EpubcfiEnd), EpubcfiEnd: pgText(epubcfiEnd),
ChapterReference: pgInt4(req.ChapterReference), ChapterReference: pgInt4(req.ChapterReference),
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true}, LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""}, LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
@@ -590,6 +647,9 @@ type SaveBookmarkRequest struct {
Source string Source string
ModifiedAt time.Time ModifiedAt time.Time
DeviceSyncData json.RawMessage DeviceSyncData json.RawMessage
// OriginSource records the client that created the bookmark (set once
// at insert; unlike Source it is not updated by later writers).
OriginSource string
// DedupKey overrides the computed key for device echoes (see // DedupKey overrides the computed key for device echoes (see
// SaveHighlightRequest). // SaveHighlightRequest).
DedupKey string DedupKey string
@@ -624,9 +684,9 @@ func (s *AnnotationService) SaveBookmark(ctx context.Context, req SaveBookmarkRe
if !incomingNewerThanTombstone(req.ModifiedAt, existing.DeletedAt, existing.LastModifiedAt) { if !incomingNewerThanTombstone(req.ModifiedAt, existing.DeletedAt, existing.LastModifiedAt) {
return &SaveBookmarkResult{Bookmark: existing, Outcome: SaveOutcomeDeleted}, nil return &SaveBookmarkResult{Bookmark: existing, Outcome: SaveOutcomeDeleted}, nil
} }
// Newer than the tombstone: a deliberate re-create. Resurrect via the // Newer than the tombstone: a deliberate re-create at the same
// LWW update instead of INSERT (the tombstoned row still holds the // location. Resurrect via the LWW update so the row keeps its id
// UNIQUE(media_item_id, user_id, title) slot). // and origin.
return s.applyBookmarkLWW(ctx, req, existing, dedupKey) return s.applyBookmarkLWW(ctx, req, existing, dedupKey)
} }
@@ -656,6 +716,7 @@ func (s *AnnotationService) createBookmark(ctx context.Context, req SaveBookmark
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true}, LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""}, LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
DeviceSyncData: deviceData, DeviceSyncData: deviceData,
OriginSource: pgText(req.OriginSource),
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("create bookmark: %w", err) return nil, fmt.Errorf("create bookmark: %w", err)
+21
View File
@@ -666,3 +666,24 @@ func TestReverseIgnoresSingleCharContext(t *testing.T) {
t.Errorf("single-char reverse context must fall back to percentage, got %s", reverse.Precision) t.Errorf("single-char reverse context must fall back to percentage, got %s", reverse.Precision)
} }
} }
// The bookmark route supplies no context (bookmark text is a display
// label, never book text), so the facade must still resolve the drop-cap
// xpointer structurally instead of collapsing to the document start.
func TestConvertToCanonical_DropCapEmptyContext(t *testing.T) {
epubPath := writeDropCapEPUB(t)
xp := "/body/DocFragment[2]/body/p[3]/span[1]/text().0"
loc := ConvertToCanonical(LocatorSourceKOReader, xp, 0.52, "", string(FormatGroupReflowable), epubPath, "")
t.Logf("facade drop-cap (no context) → %s (%s)", loc.CFI, loc.Precision)
if loc.Precision != "structural" && loc.Precision != "exact" {
t.Fatalf("expected structural/exact precision, got %s (%s)", loc.Precision, loc.CFI)
}
if !strings.HasPrefix(loc.CFI, "epubcfi(") {
t.Fatalf("expected standard epubcfi, got %s", loc.CFI)
}
if strings.HasSuffix(loc.CFI, "/4/2/1:0)") {
t.Errorf("empty-context conversion collapsed to doc start: %s", loc.CFI)
}
}
+7
View File
@@ -58,6 +58,13 @@ func cachedConverter(epubPath string) *CFIConverter {
return c return c
} }
// SectionPercentageCached resolves the spine-section percentage of a CRE
// xpointer through the shared bounded converter cache, so per-annotation
// lookups parse the EPUB once per book instead of once per annotation.
func SectionPercentageCached(epubPath, xpointer string) float64 {
return cachedConverter(epubPath).SectionPercentage(xpointer)
}
func ConvertToCanonical( func ConvertToCanonical(
source LocatorSource, source LocatorSource,
devicePos string, devicePos string,
+34 -54
View File
@@ -5,7 +5,11 @@ import (
"fmt" "fmt"
) )
func readerInitExpr(metadata ReaderMetadata, progress ReadingProgress, bookmarks []Bookmark) string { // The init config carries only immutable book metadata. Reading state
// (position, bookmarks, annotations) is never embedded: the reader fetches
// it from the APIs at open time, so the page can never carry — nor write
// back — a stale snapshot of it.
func readerInitExpr(metadata ReaderMetadata) string {
config := map[string]interface{}{ config := map[string]interface{}{
"mediaItemId": metadata.MediaItemID, "mediaItemId": metadata.MediaItemID,
"fileUrl": metadata.FileURL, "fileUrl": metadata.FileURL,
@@ -13,42 +17,11 @@ func readerInitExpr(metadata ReaderMetadata, progress ReadingProgress, bookmarks
"readingDirection": metadata.ReadingDirection, "readingDirection": metadata.ReadingDirection,
"mangaType": metadata.MangaType, "mangaType": metadata.MangaType,
} }
if progress.Percentage > 0 {
config["savedPercentage"] = progress.Percentage / 100
}
if progress.EpubCfi != "" {
config["savedCfi"] = progress.EpubCfi
}
// Fixed-layout & comic formats: the page index is the canonical, exact
// locator (pages are fixed images). Pass it so the reader restores by page.
if (metadata.FormatGroup == "fixed_layout" || metadata.FormatGroup == "comic_archive") && progress.CurrentPage > 0 {
config["savedPage"] = progress.CurrentPage
if progress.TotalPages > 0 {
config["savedTotalPages"] = progress.TotalPages
}
}
if len(bookmarks) > 0 {
items := make([]map[string]interface{}, 0, len(bookmarks))
for _, b := range bookmarks {
var page any
if b.PageNumber != nil {
page = *b.PageNumber
}
items = append(items, map[string]interface{}{
"id": b.ID,
"title": b.Title,
"positionLabel": b.Position,
"cfi": b.CfiPosition,
"page": page,
})
}
config["bookmarks"] = items
}
jsonBytes, _ := json.Marshal(config) jsonBytes, _ := json.Marshal(config)
return fmt.Sprintf("initReader(%s)", string(jsonBytes)) return fmt.Sprintf("initReader(%s)", string(jsonBytes))
} }
templ Reader(user User, metadata ReaderMetadata, progress ReadingProgress, bookmarks []Bookmark) { templ Reader(user User, metadata ReaderMetadata) {
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
@@ -64,7 +37,7 @@ templ Reader(user User, metadata ReaderMetadata, progress ReadingProgress, bookm
</head> </head>
<body <body
x-data="readerShell" x-data="readerShell"
x-init={ readerInitExpr(metadata, progress, bookmarks) } x-init={ readerInitExpr(metadata) }
class={ "theme-" + user.Theme + " h-screen overflow-hidden" } class={ "theme-" + user.Theme + " h-screen overflow-hidden" }
> >
<!-- Reading surface: edge-to-edge. Chrome overlays translucently; <!-- Reading surface: edge-to-edge. Chrome overlays translucently;
@@ -96,7 +69,7 @@ templ Reader(user User, metadata ReaderMetadata, progress ReadingProgress, bookm
</div> </div>
</div> </div>
@ReaderChrome(metadata, progress) @ReaderChrome(metadata)
<!-- Drawer scrim --> <!-- Drawer scrim -->
<div <div
@@ -311,7 +284,7 @@ templ Reader(user User, metadata ReaderMetadata, progress ReadingProgress, bookm
</html> </html>
} }
templ ReaderChrome(metadata ReaderMetadata, progress ReadingProgress) { templ ReaderChrome(metadata ReaderMetadata) {
<div id="reader-chrome" class="transition-opacity duration-300" :class="chromeVisible ? 'opacity-100' : 'chrome-hidden opacity-0 pointer-events-none'"> <div id="reader-chrome" class="transition-opacity duration-300" :class="chromeVisible ? 'opacity-100' : 'chrome-hidden opacity-0 pointer-events-none'">
<!-- Top bar --> <!-- Top bar -->
<div id="reader-topbar" class="fixed top-0 left-0 right-0 border-b z-40 pt-[env(safe-area-inset-top)] reader-glass"> <div id="reader-topbar" class="fixed top-0 left-0 right-0 border-b z-40 pt-[env(safe-area-inset-top)] reader-glass">
@@ -365,17 +338,7 @@ templ ReaderChrome(metadata ReaderMetadata, progress ReadingProgress) {
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<div class="w-px h-6 reader-sep"></div> <div class="w-px h-6 reader-sep"></div>
<div id="progress-display" @click="cycleProgressMode()" :title="progressTooltip()" class="text-sm min-w-[4rem] max-w-[5rem] sm:max-w-none text-center cursor-pointer truncate whitespace-nowrap overflow-hidden"> <div id="progress-display" @click="cycleProgressMode()" :title="progressTooltip()" class="text-sm min-w-[4rem] max-w-[5rem] sm:max-w-none text-center cursor-pointer truncate whitespace-nowrap overflow-hidden">
<span class="hidden sm:inline" x-text="progressLabel"></span><span x-text="progressMain"> <span class="hidden sm:inline" x-text="progressLabel"></span><span x-text="progressMain"></span>
if progress.FormatGroup == "reflowable" {
if metadata.EstimatedPages > 0 {
{ fmt.Sprintf("%.0f%% · Page %d/%d", progress.Percentage, progress.CurrentPage, metadata.EstimatedPages) }
} else {
{ fmt.Sprintf("%.0f%%", progress.Percentage) }
}
} else {
{ fmt.Sprintf("%d/%d", progress.CurrentPage, progress.TotalPages) }
}
</span>
</div> </div>
<div class="w-px h-6 reader-sep"></div> <div class="w-px h-6 reader-sep"></div>
<button @click="toggleTOC()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Table of Contents (t)">📖</button> <button @click="toggleTOC()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Table of Contents (t)">📖</button>
@@ -467,13 +430,7 @@ templ ReaderChrome(metadata ReaderMetadata, progress ReadingProgress) {
<!-- Progress + TOC --> <!-- Progress + TOC -->
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<div id="progress-display-fx" @click="cycleProgressMode()" :title="progressTooltip()" class="text-sm min-w-[3.5rem] text-center cursor-pointer truncate whitespace-nowrap overflow-hidden"> <div id="progress-display-fx" @click="cycleProgressMode()" :title="progressTooltip()" class="text-sm min-w-[3.5rem] text-center cursor-pointer truncate whitespace-nowrap overflow-hidden">
<span x-text="progressMain"> <span x-text="progressMain"></span>
if progress.FormatGroup == "reflowable" {
{ fmt.Sprintf("%.0f%%", progress.Percentage) }
} else {
{ fmt.Sprintf("%d/%d", progress.CurrentPage, progress.TotalPages) }
}
</span>
</div> </div>
<button @click="toggleTOC()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Table of Contents (t)">📖</button> <button @click="toggleTOC()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Table of Contents (t)">📖</button>
</div> </div>
@@ -730,10 +687,33 @@ templ ReaderAnnotationsDrawer() {
href="#" href="#"
@click.prevent="goToBookmark(bookmark)" @click.prevent="goToBookmark(bookmark)"
class="flex-1 min-w-0 block py-2 hover:bg-gray-700 rounded px-2" class="flex-1 min-w-0 block py-2 hover:bg-gray-700 rounded px-2"
x-show="!bookmark.renameOpen"
> >
<span class="font-medium block truncate" x-text="bookmark.title"></span> <span class="font-medium block truncate" x-text="bookmark.title"></span>
<span class="text-xs block truncate" style="color: var(--text-secondary)" x-text="bookmark.positionLabel"></span> <span class="text-xs block truncate" style="color: var(--text-secondary)" x-text="bookmark.positionLabel"></span>
</a> </a>
<div class="flex-1 min-w-0 py-2" x-show="bookmark.renameOpen" @click.stop>
<input
type="text"
class="reader-note-input"
x-model="bookmark.renameText"
@keydown.enter="renameBookmark(bookmark)"
@keydown.escape="bookmark.renameOpen = false"
placeholder="Bookmark name…"
></input>
<div class="flex gap-2 mt-1">
<button @click="renameBookmark(bookmark)" class="flex-1 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700">Save</button>
<button @click="bookmark.renameOpen = false" class="flex-1 py-1 text-xs border rounded hover:opacity-80" style="border-color: var(--border);">Cancel</button>
</div>
</div>
<button
@click="startBookmarkRename(bookmark)"
class="p-2 rounded hover:bg-gray-600 opacity-0 group-hover:opacity-100 transition-opacity"
title="Rename bookmark"
aria-label="Rename bookmark"
>
</button>
<button <button
@click="deleteBookmark(bookmark.id)" @click="deleteBookmark(bookmark.id)"
class="p-2 rounded hover:bg-red-900/60 opacity-0 group-hover:opacity-100 transition-opacity" class="p-2 rounded hover:bg-red-900/60 opacity-0 group-hover:opacity-100 transition-opacity"
+39 -128
View File
File diff suppressed because one or more lines are too long
+214 -29
View File
@@ -406,6 +406,10 @@ document.addEventListener("alpine:init", () => {
tapZonesEnabled: true as boolean, tapZonesEnabled: true as boolean,
tapZoneSize: 30 as number, tapZoneSize: 30 as number,
tapZoneTimer: null as ReturnType<typeof setTimeout> | null, tapZoneTimer: null as ReturnType<typeof setTimeout> | null,
// Any live text selection, host document or content iframe. Fed by
// selectionchange listeners (touch devices); tap zones stand down
// while one exists.
anySelection: false as boolean,
highlightItems: [] as { highlightItems: [] as {
id: string; id: string;
text: string; text: string;
@@ -472,11 +476,17 @@ document.addEventListener("alpine:init", () => {
positionLabel: string; positionLabel: string;
cfi: string; cfi: string;
page: number | null; page: number | null;
renameOpen?: boolean;
renameText?: string;
}[], }[],
tocItems: [] as any[], tocItems: [] as any[],
mediaItemId: "" as string, mediaItemId: "" as string,
saveTimeout: null as ReturnType<typeof setTimeout> | null, saveTimeout: null as ReturnType<typeof setTimeout> | null,
initTime: 0 as number, // Set only by deliberate navigation (page turns, jumps, slider). The
// restore at open time and section-load relocations never set it, so
// progress saves can only ever write a position the user actually
// moved to — never a stale restore clobbering a newer device push.
userMoved: false as boolean,
contextText: "" as string, contextText: "" as string,
readingTheme: "light" as string, readingTheme: "light" as string,
readingMode: "light" as string, readingMode: "light" as string,
@@ -558,20 +568,13 @@ document.addEventListener("alpine:init", () => {
formatGroup: string; formatGroup: string;
readingDirection: string; readingDirection: string;
mangaType: string; mangaType: string;
savedPercentage?: number;
savedCfi?: string;
savedPage?: number;
savedTotalPages?: number;
bookmarks?: {
id: string;
title: string;
positionLabel: string;
cfi: string;
page: number | null;
}[];
}) { }) {
this.mediaItemId = config.mediaItemId; this.mediaItemId = config.mediaItemId;
this.bookmarkItems = config.bookmarks ?? []; // Reading state (position, bookmarks, annotations) is never baked
// into the rendered page: the web reader is intrinsically tied to
// the server, so it reads all of it from the APIs at open time —
// a device sync between render and open can never be shadowed by a
// stale snapshot.
this.isComic = config.formatGroup === "comic_archive"; this.isComic = config.formatGroup === "comic_archive";
// Reading flow for comics is a per-book preference (a webtoon title // Reading flow for comics is a per-book preference (a webtoon title
// vs. a paged manga volume); read before the renderer is chosen. // vs. a paged manga volume); read before the renderer is chosen.
@@ -689,6 +692,14 @@ document.addEventListener("alpine:init", () => {
// out to the host document, so the viewport listeners miss them). // out to the host document, so the viewport listeners miss them).
if (window.matchMedia("(pointer: coarse)").matches) { if (window.matchMedia("(pointer: coarse)").matches) {
this.attachTapZoneListeners(doc as unknown as HTMLElement, true); this.attachTapZoneListeners(doc as unknown as HTMLElement, true);
// Same for selectionchange: a selection inside the iframe must
// cancel armed tap actions and feed the host-surface guard.
doc.addEventListener("selectionchange", () => {
const sel = doc.getSelection();
this.noteSelectionActivity(
!!sel && !sel.isCollapsed && !!sel.toString(),
);
});
} }
// Text selection → highlight popover (reflowable EPUB only; // Text selection → highlight popover (reflowable EPUB only;
// fixed-layout highlight overlays are a later milestone). // fixed-layout highlight overlays are a later milestone).
@@ -824,7 +835,13 @@ document.addEventListener("alpine:init", () => {
}); });
this.view.addEventListener("show-annotation", (e: any) => { this.view.addEventListener("show-annotation", (e: any) => {
const { value, index, range } = e.detail; const { value, index, range } = e.detail;
const h = this.highlightItems.find((x) => x.cfi === value); // Device-synced highlights are painted with a synthesized range
// CFI (renderCfi) while the stored locator stays a point CFI, so a
// click reports the render value — match either or editing
// device highlights is impossible.
const h = this.highlightItems.find(
(x) => x.cfi === value || x.renderCfi === value,
);
if (!h) return; if (!h) return;
const doc = this.renderer const doc = this.renderer
?.getContents?.() ?.getContents?.()
@@ -896,15 +913,18 @@ document.addEventListener("alpine:init", () => {
document.addEventListener("keydown", (ev: KeyboardEvent) => document.addEventListener("keydown", (ev: KeyboardEvent) =>
this.handleKeydown(ev), this.handleKeydown(ev),
); );
if (this.isFixedLayout && config.savedPage != null && config.savedPage > 0) { // Reading position comes from the database, fetched fresh at open
// (the rendered page carries no snapshot of it).
const saved = await this.fetchSavedLocation();
if (this.isFixedLayout && saved.page != null && saved.page > 0) {
// Fixed-layout & comics: a page index is the exact, universal locator. // Fixed-layout & comics: a page index is the exact, universal locator.
// A bare number navigates directly to the section index in foliate. // A bare number navigates directly to the section index in foliate.
await this.view.init({ lastLocation: config.savedPage - 1 }) await this.view.init({ lastLocation: saved.page - 1 })
} else if (config.savedCfi) { } else if (saved.cfi) {
await this.view.init({ lastLocation: config.savedCfi }) await this.view.init({ lastLocation: saved.cfi })
} else if (config.savedPercentage && config.savedPercentage > 0) { } else if (saved.percentage != null && saved.percentage > 0) {
await this.view.init({ await this.view.init({
lastLocation: { fraction: config.savedPercentage }, lastLocation: { fraction: saved.percentage },
}) })
} else { } else {
await this.view.init({}) await this.view.init({})
@@ -918,9 +938,14 @@ document.addEventListener("alpine:init", () => {
this.renderer.setAttribute("interaction-mode", this.interactionMode); this.renderer.setAttribute("interaction-mode", this.interactionMode);
} }
this.fxZoomed = this.isFixedLayout && this.renderer?.zoom != null; this.fxZoomed = this.isFixedLayout && this.renderer?.zoom != null;
this.initTime = Date.now(); // A bfcache-resurrected page is stale by definition: forbid it from
// writing its frozen position back until the user navigates again.
window.addEventListener("pageshow", (e: PageTransitionEvent) => {
if (e.persisted) this.userMoved = false;
});
this.fetchReadingSpeed(); this.fetchReadingSpeed();
this.refreshAnnotations(); this.refreshAnnotations();
this.refreshBookmarks();
this.setupChrome(); this.setupChrome();
this.setupTapZones(); this.setupTapZones();
}, },
@@ -983,6 +1008,26 @@ document.addEventListener("alpine:init", () => {
if (!window.matchMedia("(pointer: coarse)").matches) return; if (!window.matchMedia("(pointer: coarse)").matches) return;
const vp = document.getElementById("reader-viewport"); const vp = document.getElementById("reader-viewport");
if (vp) this.attachTapZoneListeners(vp as HTMLElement, false); if (vp) this.attachTapZoneListeners(vp as HTMLElement, false);
// Host-document selections (fixed-layout/PDF text layers, margins):
// selectionchange never crosses iframe boundaries, so register per
// surface.
document.addEventListener("selectionchange", () => {
const sel = document.getSelection();
this.noteSelectionActivity(
!!sel && !sel.isCollapsed && !!sel.toString(),
);
});
},
// Record selection state and abort any armed tap action: a selection
// appearing right after finger-lift means the "tap" was actually a
// long-press selection engaging, and paging away would destroy the
// gesture the user just made.
noteSelectionActivity(hasSelection: boolean) {
this.anySelection = hasSelection;
if (hasSelection && this.tapZoneTimer) {
clearTimeout(this.tapZoneTimer);
this.tapZoneTimer = null;
}
}, },
attachTapZoneListeners(surface: HTMLElement, isDoc: boolean) { attachTapZoneListeners(surface: HTMLElement, isDoc: boolean) {
let downX = 0; let downX = 0;
@@ -990,6 +1035,12 @@ document.addEventListener("alpine:init", () => {
let downT = 0; let downT = 0;
let downId = -1; let downId = -1;
let moved = false; let moved = false;
// Android fires contextmenu when a long-press engages text
// selection: that press must never resolve into a tap action, even
// when it was released inside the 500ms tap window (the selection
// engaging and the guard racing is exactly how corner selections
// used to page back instead).
let longPressed = false;
surface.addEventListener( surface.addEventListener(
"pointerdown", "pointerdown",
(e: PointerEvent) => { (e: PointerEvent) => {
@@ -999,6 +1050,21 @@ document.addEventListener("alpine:init", () => {
downT = Date.now(); downT = Date.now();
downId = e.pointerId; downId = e.pointerId;
moved = false; moved = false;
longPressed = false;
},
{ passive: true },
);
surface.addEventListener("contextmenu", () => {
longPressed = true;
}, { passive: true });
// The browser takes over the gesture (text selection, scroll) with
// pointercancel — no pointerup will follow. Drop the tracked
// pointer so stale state can never match a later touch.
surface.addEventListener(
"pointercancel",
() => {
downId = -1;
moved = false;
}, },
{ passive: true }, { passive: true },
); );
@@ -1016,7 +1082,7 @@ document.addEventListener("alpine:init", () => {
(e: PointerEvent) => { (e: PointerEvent) => {
if (e.pointerId !== downId) return; if (e.pointerId !== downId) return;
downId = -1; downId = -1;
if (moved || Date.now() - downT > 500) return; if (moved || longPressed || Date.now() - downT > 500) return;
if (!this.tapZonesEnabled) return; if (!this.tapZonesEnabled) return;
const target = e.target as HTMLElement | null; const target = e.target as HTMLElement | null;
if ( if (
@@ -1027,6 +1093,10 @@ document.addEventListener("alpine:init", () => {
return; return;
const sel = isDoc ? (surface as any).getSelection?.() : null; const sel = isDoc ? (surface as any).getSelection?.() : null;
if (sel?.toString?.()) return; if (sel?.toString?.()) return;
// Host-surface blind spot: selections living in content iframes
// (or the host's own fixed-layout text layer) never show in a
// per-surface check — the tracked flag covers them.
if (!isDoc && this.anySelection) return;
// No tap actions while a fixed-layout page is zoomed — taps then // No tap actions while a fixed-layout page is zoomed — taps then
// belong to the content (and double-tap zoom). // belong to the content (and double-tap zoom).
if (this.isFixedLayout && this.renderer?.zoom != null) return; if (this.isFixedLayout && this.renderer?.zoom != null) return;
@@ -1341,7 +1411,23 @@ document.addEventListener("alpine:init", () => {
if (!resp.ok) return; if (!resp.ok) return;
const row = await resp.json(); const row = await resp.json();
const idx = this.highlightItems.findIndex((h) => h.id === p.id); const idx = this.highlightItems.findIndex((h) => h.id === p.id);
// The overlay is keyed by the value it was added with; an edit can
// change it (note/text edits change the synthesized range), so
// remove the old paint before re-adding or it ghosts.
const oldValue =
idx !== -1
? this.highlightItems[idx].renderCfi ||
this.highlightItems[idx].cfi
: "";
if (idx !== -1) this.highlightItems[idx] = this.mapHighlightRow(row); if (idx !== -1) this.highlightItems[idx] = this.mapHighlightRow(row);
const newValue =
idx !== -1
? this.highlightItems[idx].renderCfi ||
this.highlightItems[idx].cfi
: "";
if (p.pdfPage < 0 && oldValue && oldValue !== newValue) {
this.view?.deleteAnnotation({ value: oldValue });
}
// Re-add so the overlay redraws with the new color. // Re-add so the overlay redraws with the new color.
if (p.pdfPage >= 0) { if (p.pdfPage >= 0) {
this.renderer?.addRectAnnotation?.({ this.renderer?.addRectAnnotation?.({
@@ -1377,7 +1463,11 @@ document.addEventListener("alpine:init", () => {
if (hl?.pdfPage >= 0) { if (hl?.pdfPage >= 0) {
this.renderer?.removeRectAnnotation?.(id); this.renderer?.removeRectAnnotation?.(id);
} else if (hl?.cfi) { } else if (hl?.cfi) {
this.view?.deleteAnnotation({ value: hl.cfi }); // Delete with the value the overlay was added by: device-synced
// highlights paint a synthesized range, not the stored point CFI.
this.view?.deleteAnnotation({
value: hl.renderCfi || hl.cfi,
});
} }
this.hideSelectionPopover(); this.hideSelectionPopover();
} catch (_e) { } catch (_e) {
@@ -1451,8 +1541,44 @@ document.addEventListener("alpine:init", () => {
/* ignore note errors */ /* ignore note errors */
} }
}, },
// Fresh reading position from the database — the single source of
// truth at open time. Fails soft to a fresh start: the userMoved gate
// guarantees merely opening (even at the wrong spot) can never
// overwrite the stored position.
async fetchSavedLocation(): Promise<{
cfi?: string;
page?: number;
percentage?: number;
}> {
const token = getToken();
if (!token || !this.mediaItemId) return {};
try {
const resp = await fetch(
`/api/media-items/${this.mediaItemId}/progress`,
{
headers: { Authorization: `Bearer ${token}` },
cache: "no-store",
},
);
if (!resp.ok) return {};
const row: any = await resp.json();
const cfi: string = row?.epubcfi?.String ?? row?.epubcfi ?? "";
const page: number = row?.current_page?.Int32 ?? row?.current_page ?? 0;
// The stored percentage is a 0-1 fraction.
const pct: number = row?.percentage?.Float64 ?? row?.percentage ?? 0;
return {
cfi: typeof cfi === "string" ? cfi : "",
page: typeof page === "number" ? page : 0,
percentage: typeof pct === "number" ? pct : 0,
};
} catch (_e) {
return {};
}
},
debouncedSaveProgress(fraction: number, location: any, cfi: string) { debouncedSaveProgress(fraction: number, location: any, cfi: string) {
if (Date.now() - this.initTime < 5000) return; // Only deliberate navigation writes progress: displaying a restored
// position must never overwrite a newer device push.
if (!this.userMoved) return;
if (this.saveTimeout) clearTimeout(this.saveTimeout); if (this.saveTimeout) clearTimeout(this.saveTimeout);
this.saveTimeout = setTimeout(() => { this.saveTimeout = setTimeout(() => {
this.saveProgress(fraction, location, cfi); this.saveProgress(fraction, location, cfi);
@@ -1593,18 +1719,23 @@ document.addEventListener("alpine:init", () => {
saveSettings({ double_page_spread: this.doublePageSpread }); saveSettings({ double_page_spread: this.doublePageSpread });
}, },
goLeft() { goLeft() {
this.userMoved = true;
this.view?.goLeft?.(); this.view?.goLeft?.();
}, },
goRight() { goRight() {
this.userMoved = true;
this.view?.goRight?.(); this.view?.goRight?.();
}, },
nextPage() { nextPage() {
this.userMoved = true;
this.view?.next?.(); this.view?.next?.();
}, },
previousPage() { previousPage() {
this.userMoved = true;
this.view?.prev?.(); this.view?.prev?.();
}, },
goToFraction(value: string) { goToFraction(value: string) {
this.userMoved = true;
this.view?.goToFraction?.(parseFloat(value)); this.view?.goToFraction?.(parseFloat(value));
}, },
toggleTOC() { toggleTOC() {
@@ -1783,9 +1914,11 @@ document.addEventListener("alpine:init", () => {
}, },
goToSearchResult(item: { cfi?: string; page?: number | null }) { goToSearchResult(item: { cfi?: string; page?: number | null }) {
if (item.cfi) { if (item.cfi) {
this.userMoved = true;
this.pushBackStack(); this.pushBackStack();
this.view?.goTo?.(item.cfi); this.view?.goTo?.(item.cfi);
} else if (item.page != null) { } else if (item.page != null) {
this.userMoved = true;
this.pushBackStack(); this.pushBackStack();
this.view?.goTo?.(item.page); this.view?.goTo?.(item.page);
} else return; } else return;
@@ -1814,6 +1947,7 @@ document.addEventListener("alpine:init", () => {
goBackToLocation() { goBackToLocation() {
const loc = this.backStack.pop(); const loc = this.backStack.pop();
if (!loc) return; if (!loc) return;
this.userMoved = true;
if (loc.cfi) this.view?.goTo?.(loc.cfi); if (loc.cfi) this.view?.goTo?.(loc.cfi);
else if (typeof loc.page === "number") this.view?.goTo?.(loc.page); else if (typeof loc.page === "number") this.view?.goTo?.(loc.page);
}, },
@@ -1829,6 +1963,7 @@ document.addEventListener("alpine:init", () => {
}, },
goToTOCItem(item: any) { goToTOCItem(item: any) {
if (this.view && item.href) { if (this.view && item.href) {
this.userMoved = true;
this.pushBackStack(); this.pushBackStack();
this.view.goTo(item.href); this.view.goTo(item.href);
this.tocOpen = false; this.tocOpen = false;
@@ -1956,6 +2091,7 @@ document.addEventListener("alpine:init", () => {
}, },
goToPage(index: number) { goToPage(index: number) {
if (!this.view || typeof index !== "number" || index < 0) return; if (!this.view || typeof index !== "number" || index < 0) return;
this.userMoved = true;
this.pushBackStack(); this.pushBackStack();
this.view.goTo(index); this.view.goTo(index);
this.tocOpen = false; this.tocOpen = false;
@@ -1963,9 +2099,11 @@ document.addEventListener("alpine:init", () => {
goToBookmark(item: { cfi: string; page: number | null }) { goToBookmark(item: { cfi: string; page: number | null }) {
if (!this.view) return; if (!this.view) return;
if (item.cfi) { if (item.cfi) {
this.userMoved = true;
this.pushBackStack(); this.pushBackStack();
this.view.goTo(item.cfi); this.view.goTo(item.cfi);
} else if (item.page != null && item.page > 0) { } else if (item.page != null && item.page > 0) {
this.userMoved = true;
this.pushBackStack(); this.pushBackStack();
// Fixed-layout/comic: sections are pages; foliate takes an index. // Fixed-layout/comic: sections are pages; foliate takes an index.
this.view.goTo(item.page - 1); this.view.goTo(item.page - 1);
@@ -2079,6 +2217,14 @@ document.addEventListener("alpine:init", () => {
const page = this.isFixedLayout const page = this.isFixedLayout
? (this.renderer?.index ?? 0) + 1 ? (this.renderer?.index ?? 0) + 1
: 0; : 0;
// Auto-label mirrors KOReader's convention ("in <chapter title>")
// so every client names unnamed bookmarks identically; falls back
// to a plain "Bookmark" for fixed-layout or TOC-less books.
const chapter = this.lastRelocateDetail?.tocItem?.label as
| string
| undefined;
const title =
!this.isFixedLayout && chapter ? `in ${chapter}` : "Bookmark";
try { try {
const resp = await fetch( const resp = await fetch(
@@ -2090,7 +2236,7 @@ document.addEventListener("alpine:init", () => {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
title: `Bookmark at ${this.progressText || "current position"}`, title,
position: this.isFixedLayout position: this.isFixedLayout
? `page:${page}` ? `page:${page}`
: cfi : cfi
@@ -2130,6 +2276,38 @@ document.addEventListener("alpine:init", () => {
/* ignore bookmark errors for now */ /* ignore bookmark errors for now */
} }
}, },
startBookmarkRename(bookmark: (typeof this.bookmarkItems)[number]) {
bookmark.renameOpen = true;
bookmark.renameText = bookmark.title;
},
async renameBookmark(bookmark: (typeof this.bookmarkItems)[number]) {
const token = getToken();
const title = (bookmark.renameText ?? "").trim();
if (!token || !this.mediaItemId || !title) return;
try {
const resp = await fetch(
`/api/media-items/${this.mediaItemId}/bookmarks/${bookmark.id}`,
{
method: "PUT",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
title,
notes: "",
position: bookmark.positionLabel || "",
}),
},
);
if (resp.ok) {
bookmark.title = title;
}
bookmark.renameOpen = false;
} catch (_e) {
bookmark.renameOpen = false;
}
},
chapterNumberForProgress(): number { chapterNumberForProgress(): number {
const tocItem = this.lastRelocateDetail?.tocItem; const tocItem = this.lastRelocateDetail?.tocItem;
if (!tocItem?.label) return 0; if (!tocItem?.label) return 0;
@@ -2425,11 +2603,18 @@ document.addEventListener("alpine:init", () => {
}, },
handleKeydown(event: KeyboardEvent) { handleKeydown(event: KeyboardEvent) {
const k = event.key; const k = event.key;
// Never hijack keys while the user is typing in a form control. // Never hijack keys while the user is typing in a form control: the
const tag = (event.target as HTMLElement)?.tagName; // field must receive h/l page turns, +/ zoom, and caret arrows.
// Escape stays live so popovers/drawers can still be dismissed from
// the keyboard even mid-note.
const t = event.target as HTMLElement | null;
const typing = const typing =
tag === "INPUT" || tag === "SELECT" || tag === "TEXTAREA"; t?.tagName === "INPUT" ||
t?.tagName === "SELECT" ||
t?.tagName === "TEXTAREA" ||
!!t?.isContentEditable;
this.pokeChrome(); this.pokeChrome();
if (typing && k !== "Escape") return;
if (k === "ArrowLeft" || k === "h") { if (k === "ArrowLeft" || k === "h") {
if (event.altKey) { if (event.altKey) {
event.preventDefault(); event.preventDefault();
@@ -2458,7 +2643,7 @@ document.addEventListener("alpine:init", () => {
} else if (k === "F1") { } else if (k === "F1") {
event.preventDefault(); event.preventDefault();
this.toggleHelp(); this.toggleHelp();
} else if (!typing) { } else {
if (k === "t") this.toggleTOC(); if (k === "t") this.toggleTOC();
else if (k === "s") this.toggleSettings(); else if (k === "s") this.toggleSettings();
else if (k === "b") this.addBookmark(); else if (k === "b") this.addBookmark();