11 Commits
Author SHA1 Message Date
john-okeefe 3514b4dc1c fix(reader): close the highlight popover on outside clicks in the book
Release / build-and-push (push) Successful in 2m24s
Clicks on reader chrome already dismissed the selection popover via the
host-document outside-click handler, but clicks inside the book happen
in content iframes whose events never bubble to the host document — the
host handler never sees them. The only iframe-side dismissal ran through
the selection tracker's collapsed check, which hides the popover solely
in create mode: once the popover was open in EDIT mode (clicked a
highlight, writing a note), clicking anywhere in the book did nothing
and Esc was the only way out.

Each content iframe now gets a pointerdown listener that dismisses the
popover in any mode. Clicking a painted highlight still opens the edit
popover: this hides first, then foliate's show-annotation re-opens it.
2026-09-10 08:27:16 -04:00
john-okeefe ae87c0cd6a fix(sync): let content-equal echoes refresh drifted locators
The LWW skip path compared only annotation content (text, color, note,
percentages) — locator columns were not part of 'changed'. A device echo
with identical content therefore resolved to skip, and the freshly
re-derived canonical CFIs were discarded in the same request that
computed them: a highlight whose stored start anchor had been corrupted
by the old percentage-exact bug could never heal, because every
subsequent echo carried the same text and was skipped before the
locator columns were written. Observed live: a push converted the
cross-block highlight's anchors correctly (structural, heading to
paragraph) yet the row kept its garbage start CFI and the highlight
stayed unpaintable on the web.

Echo saves now treat a non-empty incoming locator that differs from the
stored one as a change (locatorRefresher): empty locators still coalesce
(no drift), and once healed the echo produces identical CFIs, so the
steady state remains skip — no write churn. Highlights check
epubcfi_start/end; bookmarks check cfi_position/position.

applyBookmarkLWW also gains the empty-locator coalescing the highlight
path already had: web bookmark edits carry no device locators, and a
title/note edit must not wipe the stored device-native position.
2026-09-10 08:10:42 -04:00
john-okeefe b6f507b9e5 fix(sync): understand cross-block text; never store a guessed locator
Tonight's failures all traced to one blind spot: the converter could
only reason about text within a single block. A position at a chapter
heading sends walk-up context (heading + the paragraphs below, joined by
the plugin's block capture); a selection can span several paragraphs.
Neither shape could be verified (containment compared one block against
a multi-block quote, so the CORRECT structural landing at the heading
was rejected) nor matched by text search (it never crossed block
boundaries). The ladder then fell to the percentage rung — which labeled
its char-count guess Precision "exact" — and that confidently-wrong CFI
was stored: reading positions reopened paragraphs away from the true
spot, and a highlight echo overwrote the row's good web CFIs with a
garbage start anchor that made the highlight unpaintable ("disappeared").

Four changes, all in the forward converter and its consumers:

- Quote verification: after the structural walk lands, read the
  whitespace-normalized document text forward from the landing point
  (crossing block boundaries; inline spans join directly so drop-cap
  splits still read as one word). A usable context must be a prefix of
  that stream — which is exactly what device captures are: the text from
  the position onward, or the selection between two anchors. The old
  single-block containment checks remain as secondary acceptance.
- Cross-block text search: the search rung matches against the whole
  document flattened in reading order, with every rune mapped back to
  its source node and offset. A context spanning blocks now matches, and
  the matched extent yields a true range end (EndEPUBCFI) that
  highlights use as their end anchor, threaded through the facade as
  CanonicalLocator.EndCFI.
- Honest labels: the percentage rung returns Precision "percentage" —
  a char-count estimate must never masquerade as an exact anchor.
- Confident-only storage: progress adopts a converted locator solely at
  structural/exact precision (section hrefs keep their legacy handling;
  anything lower stores percentage only), and highlight conversion
  returns CFIs only at structural/exact precision — a low-confidence
  echo yields empty, which applyLWW coalescing turns into preservation
  of the row's existing web CFIs instead of clobbering them.

Tests: walk-up context at a heading verifies structurally and lands in
the heading; a block-spanning context is found by search with a range
end landing in the following paragraph; the percentage rung is honestly
labeled; all drop-cap guards stay green.
2026-09-09 20:15:12 -04:00
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
16 changed files with 1057 additions and 458 deletions
+12 -2
View File
@@ -1344,8 +1344,10 @@ CREATE TABLE IF NOT EXISTS media_bookmarks (
title VARCHAR(255) NOT NULL,
position VARCHAR(100), -- 'pdf:page:45', 'comic:page:12', 'chapter:3' for consistency
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(media_item_id, user_id, title)
created_at TIMESTAMPTZ DEFAULT NOW()
-- 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);
@@ -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 deleted BOOLEAN DEFAULT FALSE;
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
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"`
Deleted pgtype.Bool `db:"deleted" json:"deleted"`
DeletedAt pgtype.Timestamptz `db:"deleted_at" json:"deleted_at"`
OriginSource pgtype.Text `db:"origin_source" json:"origin_source"`
}
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
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)
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 {
@@ -670,6 +670,7 @@ func (q *Queries) CreateMediaBookmark(ctx context.Context, arg CreateMediaBookma
&i.ChapterReference,
&i.Deleted,
&i.DeletedAt,
&i.OriginSource,
)
return i, err
}
@@ -680,10 +681,10 @@ INSERT INTO media_bookmarks (
cfi_position, title, position, notes,
percentage_location, epubcfi_location, chapter_reference,
dedup_key, last_modified_at, last_modified_source,
device_sync_data
device_sync_data, origin_source
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15
) 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
$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, origin_source
`
type CreateMediaBookmarkFullParams struct {
@@ -702,6 +703,7 @@ type CreateMediaBookmarkFullParams struct {
LastModifiedAt pgtype.Timestamptz `db:"last_modified_at" json:"last_modified_at"`
LastModifiedSource pgtype.Text `db:"last_modified_source" json:"last_modified_source"`
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) {
@@ -721,6 +723,7 @@ func (q *Queries) CreateMediaBookmarkFull(ctx context.Context, arg CreateMediaBo
arg.LastModifiedAt,
arg.LastModifiedSource,
arg.DeviceSyncData,
arg.OriginSource,
)
var i MediaBookmarks
err := row.Scan(
@@ -743,6 +746,7 @@ func (q *Queries) CreateMediaBookmarkFull(ctx context.Context, arg CreateMediaBo
&i.ChapterReference,
&i.Deleted,
&i.DeletedAt,
&i.OriginSource,
)
return i, err
}
@@ -4524,7 +4528,7 @@ func (q *Queries) GetLibraryWithType(ctx context.Context, id pgtype.UUID) (GetLi
}
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) {
@@ -4550,13 +4554,14 @@ func (q *Queries) GetMediaBookmark(ctx context.Context, id pgtype.UUID) (MediaBo
&i.ChapterReference,
&i.Deleted,
&i.DeletedAt,
&i.OriginSource,
)
return i, err
}
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
ORDER BY deleted ASC, deleted_at DESC NULLS LAST
LIMIT 1
@@ -4594,12 +4599,13 @@ func (q *Queries) GetMediaBookmarkByDedupKey(ctx context.Context, arg GetMediaBo
&i.ChapterReference,
&i.Deleted,
&i.DeletedAt,
&i.OriginSource,
)
return i, err
}
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
ORDER BY created_at DESC
`
@@ -4638,6 +4644,7 @@ func (q *Queries) GetMediaBookmarks(ctx context.Context, arg GetMediaBookmarksPa
&i.ChapterReference,
&i.Deleted,
&i.DeletedAt,
&i.OriginSource,
); err != nil {
return nil, err
}
@@ -11513,7 +11520,7 @@ SET
position = $4,
last_modified_at = NOW()
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 {
@@ -11553,6 +11560,7 @@ func (q *Queries) UpdateMediaBookmark(ctx context.Context, arg UpdateMediaBookma
&i.ChapterReference,
&i.Deleted,
&i.DeletedAt,
&i.OriginSource,
)
return i, err
}
@@ -11575,7 +11583,7 @@ UPDATE media_bookmarks SET
deleted = FALSE,
deleted_at = NULL
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 {
@@ -11631,6 +11639,7 @@ func (q *Queries) UpdateMediaBookmarkForSync(ctx context.Context, arg UpdateMedi
&i.ChapterReference,
&i.Deleted,
&i.DeletedAt,
&i.OriginSource,
)
return i, err
}
+2 -2
View File
@@ -898,9 +898,9 @@ INSERT INTO media_bookmarks (
cfi_position, title, position, notes,
percentage_location, epubcfi_location, chapter_reference,
dedup_key, last_modified_at, last_modified_source,
device_sync_data
device_sync_data, origin_source
) 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 *;
-- name: UpdateMediaBookmarkForSync :one
+176 -113
View File
@@ -50,33 +50,97 @@ func (h *KOReaderHandler) SetAnnotationService(svc *wsync.AnnotationService) {
h.annotationSvc = svc
}
func (h *KOReaderHandler) convertHighlightPositions(ctx context.Context, mediaItemID pgtype.UUID, pos0, pos1, contextText string) (string, string) {
if pos0 == "" || h.libraryService == nil {
return "", ""
}
// annotationEpub carries the per-book context every locator conversion
// needs: the media item (format gating) and the resolved EPUB path. It is
// 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)
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 — a quote of the document, so the converter can
// verify structural landings against it and, when it must search, anchor a
// range end that spans block boundaries. percentage anchors the
// last-resort fallback. Only structural/exact landings are returned: a
// low-confidence conversion yields "" so an echo preserves the row's
// existing web CFIs (applyLWW coalesces empty) instead of clobbering them
// with a guess, and a new device highlight paints nowhere rather than in
// the wrong place.
func (h *KOReaderHandler) convertHighlightPositions(ec annotationEpub, pos0, pos1, contextText string, percentage float64) (string, string) {
if pos0 == "" || !ec.convertible() {
return "", ""
}
epubPath, err := h.libraryService.ResolveMediaPath(ctx, mediaItem.LibraryID, mediaItem.FilePath)
if err != nil || epubPath == "" {
return "", ""
startLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos0, percentage, contextText, ec.mediaItem.FormatGroup, ec.epubPath, "")
startCFI := webUsableCFI(startLoc)
endCFI := ""
// A text-search start matched the selection text itself: its extent
// is the selection's true end, even across blocks.
if startCFI != "" && startLoc.EndCFI != "" {
endCFI = startLoc.EndCFI
}
// 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
// The end conversion carries no context text, so unless it resolved
// exactly it degenerates to a percentage fallback anchored at the
// document start — useless as a range end. When the START resolved
// exactly, derive the end from it: same node, character offset
// advanced by the selection's UTF-16 length (the CFI offset unit).
if endLoc.Precision != "exact" && startLoc.Precision == "exact" && contextText != "" {
endCFI = extendCFIByLength(startLoc.CFI, contextText)
if endCFI == "" && pos1 != "" {
endLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos1, percentage, "", ec.mediaItem.FormatGroup, ec.epubPath, "")
endCFI = webUsableCFI(endLoc)
}
return startLoc.CFI, endCFI
// The end conversion carries no context text; when neither resolved
// confidently, derive the end from the start advanced by the
// selection's UTF-16 length (the CFI offset unit). Multi-node
// selections produce an out-of-range offset — harmless: resolution
// clamps or fails, and consumers fall back to the start.
if endCFI == "" && startCFI != "" && contextText != "" {
endCFI = extendCFIByLength(startCFI, contextText)
}
return startCFI, 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
@@ -127,22 +191,19 @@ func (h *KOReaderHandler) existingHighlightColor(ctx context.Context, mediaItemI
// deriveAnnotationPercentage computes a percentage for device-pushed
// annotations when the client didn't send one (thin clients skip their own
// 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 {
mediaItem, err := h.db.GetMediaItem(ctx, mediaItemID)
if err != nil {
func (h *KOReaderHandler) deriveAnnotationPercentage(ec annotationEpub, pos0 string, page int) float64 {
if ec.mediaItem == nil {
return 0
}
formatGroup := wsync.FormatGroup(mediaItem.FormatGroup)
formatGroup := wsync.FormatGroup(ec.mediaItem.FormatGroup)
if formatGroup == wsync.FormatGroupFixedLayout || formatGroup == wsync.FormatGroupComicArchive {
if page > 0 && mediaItem.PageCount.Valid && mediaItem.PageCount.Int32 > 0 {
return float64(page) / float64(mediaItem.PageCount.Int32)
if page > 0 && ec.mediaItem.PageCount.Valid && ec.mediaItem.PageCount.Int32 > 0 {
return float64(page) / float64(ec.mediaItem.PageCount.Int32)
}
return 0
}
if wsync.IsCREXPointer(pos0) && h.libraryService != nil {
if epubPath, err := h.libraryService.ResolveMediaPath(ctx, mediaItem.LibraryID, mediaItem.FilePath); err == nil && epubPath != "" {
return wsync.NewCFIConverter(epubPath).SectionPercentage(pos0)
}
if wsync.IsCREXPointer(pos0) && ec.epubPath != "" {
return wsync.SectionPercentageCached(ec.epubPath, pos0)
}
return 0
}
@@ -614,21 +675,23 @@ func (h *KOReaderHandler) processBookAnnotations(ctx context.Context, deviceID,
if h.annotationSvc == nil {
return
}
ec := h.loadAnnotationEpub(ctx, mediaItemID)
for _, hl := range book.Highlights {
startPos := hl.Pos0
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
if hl.Percentage != nil {
pctStart = *hl.Percentage
}
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{}{
"datetime": hl.Datetime,
"pos0": hl.Pos0,
@@ -676,16 +739,17 @@ func (h *KOReaderHandler) processBookAnnotations(ctx context.Context, deviceID,
for _, note := range book.Notes {
startPos := note.Pos0
endPos := note.Pos1
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, startPos, endPos, note.Text)
pctStart := 0.0
if note.Percentage != nil {
pctStart = *note.Percentage
}
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{}{
"datetime": note.Datetime,
"pos0": note.Pos0,
@@ -723,6 +787,19 @@ func (h *KOReaderHandler) processBookAnnotations(ctx context.Context, deviceID,
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{}{
"datetime": bookmark.Datetime,
"pos0": bookmark.Pos0,
@@ -739,8 +816,11 @@ func (h *KOReaderHandler) processBookAnnotations(ctx context.Context, deviceID,
UserID: userID,
Title: bookmark.Text,
Position: position,
CFIPosition: cfiPosition,
PercentageLoc: pctLoc,
ChapterNumber: int32(bookmark.Chapter),
Source: "koreader",
OriginSource: "koreader",
DeviceSyncData: deviceData,
DedupKey: dedupKey,
})
@@ -783,53 +863,40 @@ func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, deviceID pgtype
if h.progressSvc != nil {
epubcfi := book.Epubcfi
if epubcfi != nil && wsync.IsCREXPointer(*epubcfi) {
log.Printf("Bookhoard: CRE→CFI attempting conversion for %s", *epubcfi)
mediaItem, err := h.db.GetMediaItem(ctx, mediaItemID)
if err != nil {
log.Printf("Bookhoard: CRE→CFI failed to get media item: %v", err)
} else if mediaItem.FormatGroup == string(wsync.FormatGroupFixedLayout) ||
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
if book.Percentage >= 0 {
pct = book.Percentage
}
contextText := ""
if book.ContextText != nil {
contextText = *book.ContextText
}
result, convErr := converter.ConvertCREToStandard(*epubcfi, pct, contextText)
if convErr != nil {
log.Printf("Bookhoard: CRE→CFI conversion error: %v", convErr)
} else if result != nil {
if result.EPUBCFI != "" {
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)
}
}
// Same facade every annotation uses: cached converter,
// structural-first resolution, guarded fallbacks. The facade
// passes non-reflowable formats through untouched.
ec := h.loadAnnotationEpub(ctx, mediaItemID)
if ec.convertible() {
pct := 0.0
if book.Percentage >= 0 {
pct = book.Percentage
}
contextText := ""
if book.ContextText != nil {
contextText = *book.ContextText
}
loc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, *epubcfi, pct, contextText, ec.mediaItem.FormatGroup, ec.epubPath, "")
switch {
case strings.HasPrefix(loc.CFI, "epubcfi(") &&
(loc.Precision == "structural" || loc.Precision == "exact"):
converted := loc.CFI
epubcfi = &converted
log.Printf("Bookhoard: CRE→CFI converted progress (%s) to %s", loc.Precision, converted)
case loc.CFI != "" && loc.CFI != *epubcfi &&
(loc.Precision == "element" || loc.Precision == "section"):
// Fragment-ID positions resolve to a section href: keep
// serving it (legacy behavior).
converted := loc.CFI
epubcfi = &converted
log.Printf("Bookhoard: CRE→CFI converted progress (%s) to href %s", loc.Precision, converted)
default:
// Low-confidence (percentage/fallback): store no
// canonical locator — the row's percentage restores
// approximately instead of a confidently-wrong CFI,
// and the device keeps its own native position.
log.Printf("Bookhoard: CRE→CFI conversion low-confidence (%s) for %s; storing percentage only", loc.Precision, *epubcfi)
epubcfi = nil
}
}
}
@@ -1168,40 +1235,20 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
}
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 := ""
if progress.ContextText.Valid {
contextText = progress.ContextText.String
}
pct := progress.Percentage.Float64
result, err := converter.ConvertStandardToCRE(progress.Epubcfi.String, pct, contextText)
if err != nil {
log.Printf("Bookhoard: CFI→CRE conversion error: %v", err)
return
}
if result != nil && result.XPointer != "" {
progressData.KoreaderXPointer = &result.XPointer
log.Printf("Bookhoard: CFI→CRE converted to XPointer: %s", result.XPointer)
// Same facade path annotations use on serve: structural resolution
// first, guarded text search only as fallback, cached converter. The
// stored percentage anchors the reverse fallback ladder.
if xp := h.reverseConvertCFI(c, mediaItem, progress.Epubcfi.String, contextText, progress.Percentage.Float64); xp != "" {
progressData.KoreaderXPointer = &xp
log.Printf("Bookhoard: CFI→CRE converted to XPointer: %s", xp)
}
}
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 == "" {
return ""
}
@@ -1209,7 +1256,7 @@ func (h *KOReaderHandler) reverseConvertCFI(c *echo.Context, mediaItem database.
if err != nil || epubPath == "" {
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 {
return loc.Position
}
@@ -1316,7 +1363,7 @@ func (h *KOReaderHandler) koreaderPos0(c *echo.Context, mediaItem database.Media
cfi = strings.TrimPrefix(startPosition, "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
}
// Conversion failed; fall through so numeric positions still work.
@@ -1482,6 +1529,17 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
}
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{}{
"datetime": bookmark.Datetime,
"pos0": bookmark.Pos0,
@@ -1493,8 +1551,11 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
UserID: pgUserID,
Title: bookmark.Text,
Position: position,
CFIPosition: cfiPosition,
PercentageLoc: pctLoc,
ChapterNumber: int32(bookmark.Chapter),
Source: "koreader",
OriginSource: "koreader",
DeviceSyncData: deviceData,
})
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
@@ -1588,13 +1649,15 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
}
if h.annotationSvc != nil {
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, highlight.Pos0, highlight.Pos1, highlight.Text)
ec := h.loadAnnotationEpub(ctx, mediaItemID)
pctStart := 0.0
if highlight.Percentage != nil {
pctStart = *highlight.Percentage
}
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ec, highlight.Pos0, highlight.Pos1, highlight.Text, pctStart)
deviceData, _ := json.Marshal(map[string]interface{}{
"datetime": highlight.Datetime,
"pos0": highlight.Pos0,
+52
View File
@@ -4,6 +4,8 @@ import (
"encoding/json"
"testing"
wsync "bookhoard/internal/sync"
"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].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"`
Percentage float64 `json:"percentage"`
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
@@ -1653,6 +1656,11 @@ func (mh *MediaHandler) UpdateMediaHighlight(c *echo.Context) error {
ChapterReference: req.ChapterReference,
Source: "web",
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 {
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
// to the plain query when the service isn't wired (e.g. some tests).
if mh.annotationSvc != nil {
origin := req.Origin
if origin == "" {
origin = "web"
}
result, err := mh.annotationSvc.SaveBookmark(c.Request().Context(), wsync.SaveBookmarkRequest{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
@@ -1762,6 +1774,7 @@ func (mh *MediaHandler) CreateMediaBookmark(c *echo.Context) error {
PercentageLoc: req.Percentage,
ChapterReference: req.ChapterReference,
Source: "web",
OriginSource: origin,
ModifiedAt: time.Now(),
})
if err != nil {
+6 -69
View File
@@ -1,18 +1,15 @@
package router
import (
"bookhoard/internal/database"
"bookhoard/internal/handlers"
"bookhoard/internal/services"
"bookhoard/internal/sync"
"bookhoard/internal/utils"
"bookhoard/templates"
"bytes"
"errors"
"net/http"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v5"
)
@@ -69,21 +66,10 @@ func registerReaderRoutes(cfg *Config) {
if !visible {
return renderErrorPage(c, "Access denied", "access_denied")
}
// Get reading progress
var progress database.ReadingProgress
progress, err = cfg.Queries.GetReadingProgress(c.Request().Context(), database.GetReadingProgressParams{
MediaItemID: pgtype.UUID{Bytes: parsedUUID, Valid: true},
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
// Convert to template types. Reading state is deliberately NOT
// fetched or embedded: the reader pulls position, bookmarks, and
// annotations from the APIs at open time so the page can never
// carry (nor write back) a stale snapshot.
mediaUUID, _ := uuid.FromBytes(mediaItem.ID.Bytes[0:16])
libUUID, _ := uuid.FromBytes(mediaItem.LibraryID.Bytes[0:16])
metadata := templates.ReaderMetadata{
@@ -104,58 +90,9 @@ func registerReaderRoutes(cfg *Config) {
TotalCharacters: mediaItem.TotalCharacters.Int64,
EstimatedPages: sync.EstimatedPages(mediaItem.TotalCharacters.Int64),
}
// Progress conversion (inline)
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
// Render template
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 {
return renderErrorPage(c, "Error rendering reader", "render_error")
}
+119 -26
View File
@@ -60,21 +60,28 @@ const (
)
type SaveHighlightRequest struct {
MediaItemID pgtype.UUID
UserID pgtype.UUID
SelectionText string
StartPosition string
EndPosition string
Color string
NoteText string
PercentageStart float64
PercentageEnd float64
EpubcfiStart string
EpubcfiEnd string
ChapterReference int32
Source string
ModifiedAt time.Time
DeviceSyncData json.RawMessage
MediaItemID pgtype.UUID
UserID pgtype.UUID
SelectionText string
StartPosition string
EndPosition string
Color 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
PercentageEnd float64
EpubcfiStart string
EpubcfiEnd string
ChapterReference int32
Source string
ModifiedAt time.Time
DeviceSyncData json.RawMessage
// DedupKey overrides the computed key when the client echoes back an
// annotation it received from us (device echoes carry device-native
// locators, so the computed key would never match the original row and
@@ -90,6 +97,35 @@ type SaveHighlightResult struct {
func (s *AnnotationService) SaveHighlight(ctx context.Context, req SaveHighlightRequest) (*SaveHighlightResult, error) {
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 == "" {
dedupKey = ComputeDedupKey(req.SelectionText, req.EpubcfiStart, req.StartPosition)
}
@@ -186,17 +222,38 @@ func (s *AnnotationService) applyLWW(
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{
ID: existing.ID,
SelectionText: req.SelectionText,
StartPosition: pgText(req.StartPosition),
EndPosition: pgText(req.EndPosition),
StartPosition: pgText(startPosition),
EndPosition: pgText(endPosition),
Color: pgText(req.Color),
NoteText: pgText(req.NoteText),
PercentageStart: pgFloat8(req.PercentageStart),
PercentageEnd: pgFloat8(req.PercentageEnd),
EpubcfiStart: pgText(req.EpubcfiStart),
EpubcfiEnd: pgText(req.EpubcfiEnd),
EpubcfiStart: pgText(epubcfiStart),
EpubcfiEnd: pgText(epubcfiEnd),
ChapterReference: pgInt4(req.ChapterReference),
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
@@ -222,7 +279,9 @@ func (s *AnnotationService) compareIncoming(req SaveHighlightRequest, existing d
textEq(req.Color, existing.Color) &&
textEq(req.NoteText, existing.NoteText) &&
floatEq(req.PercentageStart, existing.PercentageStart) &&
floatEq(req.PercentageEnd, existing.PercentageEnd)
floatEq(req.PercentageEnd, existing.PercentageEnd) &&
locatorRefresher(req.EpubcfiStart, existing.EpubcfiStart) &&
locatorRefresher(req.EpubcfiEnd, existing.EpubcfiEnd)
return !contentSame, !contentSame
}
@@ -233,6 +292,22 @@ func (s *AnnotationService) compareIncoming(req SaveHighlightRequest, existing d
return req.ModifiedAt.After(existingMod.Time), true
}
// locatorRefresher reports whether an incoming locator leaves the stored
// one untouched: either the push carried none (empty coalesces to the
// stored value in applyLWW) or it matches what is stored. A non-empty
// incoming locator that DIFFERS is a deliberate refresh: device echoes
// re-derive their canonical CFIs on every push, and an improvement (a
// converter fix re-landing a corrupted anchor, drift repair) must reach
// the row even when the annotation content is otherwise identical —
// without this, content-equal echoes resolve to "skip" and a bad stored
// locator can never heal.
func locatorRefresher(incoming string, existing pgtype.Text) bool {
if incoming == "" {
return true
}
return textEq(incoming, existing)
}
func (s *AnnotationService) TombstoneHighlight(
ctx context.Context,
userID, mediaItemID pgtype.UUID,
@@ -590,6 +665,9 @@ type SaveBookmarkRequest struct {
Source string
ModifiedAt time.Time
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
// SaveHighlightRequest).
DedupKey string
@@ -624,9 +702,9 @@ func (s *AnnotationService) SaveBookmark(ctx context.Context, req SaveBookmarkRe
if !incomingNewerThanTombstone(req.ModifiedAt, existing.DeletedAt, existing.LastModifiedAt) {
return &SaveBookmarkResult{Bookmark: existing, Outcome: SaveOutcomeDeleted}, nil
}
// Newer than the tombstone: a deliberate re-create. Resurrect via the
// LWW update instead of INSERT (the tombstoned row still holds the
// UNIQUE(media_item_id, user_id, title) slot).
// Newer than the tombstone: a deliberate re-create at the same
// location. Resurrect via the LWW update so the row keeps its id
// and origin.
return s.applyBookmarkLWW(ctx, req, existing, dedupKey)
}
@@ -656,6 +734,7 @@ func (s *AnnotationService) createBookmark(ctx context.Context, req SaveBookmark
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
DeviceSyncData: deviceData,
OriginSource: pgText(req.OriginSource),
})
if err != nil {
return nil, fmt.Errorf("create bookmark: %w", err)
@@ -683,13 +762,25 @@ func (s *AnnotationService) applyBookmarkLWW(ctx context.Context, req SaveBookma
}
deviceData := mergeDeviceSyncData(existing.DeviceSyncData, req.Source, req.DeviceSyncData)
// Web saves carry no device locators: keep the stored ones so a web
// title/note edit never wipes the device-native position (mirrors the
// highlight path's coalescing).
cfiPosition := req.CFIPosition
if cfiPosition == "" {
cfiPosition = existing.CfiPosition.String
}
position := req.Position
if position == "" {
position = existing.Position.String
}
bm, err := s.db.UpdateMediaBookmarkForSync(ctx, database.UpdateMediaBookmarkForSyncParams{
ID: existing.ID,
PageNumber: pgInt4(req.PageNumber),
ChapterNumber: pgInt4(req.ChapterNumber),
CfiPosition: pgText(req.CFIPosition),
CfiPosition: pgText(cfiPosition),
Title: req.Title,
Position: pgText(req.Position),
Position: pgText(position),
Notes: pgText(req.Notes),
PercentageLocation: pgFloat8(req.PercentageLoc),
EpubcfiLocation: pgText(req.EpubcfiLocation),
@@ -714,7 +805,9 @@ func (s *AnnotationService) applyBookmarkLWW(ctx context.Context, req SaveBookma
func (s *AnnotationService) compareIncomingBookmark(req SaveBookmarkRequest, existing database.MediaBookmarks) (incomingNewer bool, contentChanged bool) {
if req.ModifiedAt.IsZero() {
contentSame := strings.EqualFold(req.Title, existing.Title) &&
textEq(req.Notes, existing.Notes)
textEq(req.Notes, existing.Notes) &&
locatorRefresher(req.CFIPosition, existing.CfiPosition) &&
locatorRefresher(req.Position, existing.Position)
return !contentSame, !contentSame
}
existingMod := existing.LastModifiedAt
+79
View File
@@ -369,3 +369,82 @@ func TestIncomingNewerThanTombstone(t *testing.T) {
})
}
}
// Content-equal device echoes must still count as changed when they carry
// a locator that differs from the stored one: echoes re-derive canonical
// CFIs on every push, and a better conversion (or a repair of a corrupted
// anchor) has to reach the row — otherwise the skip path discards it and
// the bad locator can never heal.
func TestCompareIncomingLocatorDriftRefreshes(t *testing.T) {
svc := &AnnotationService{}
existing := database.MediaHighlights{
SelectionText: "CHAPTER IV. “What a pity it is, Elinor,”",
Color: pgtype.Text{String: "#90caf9", Valid: true},
EpubcfiStart: pgtype.Text{String: "epubcfi(/6/12!/4/2[x]/32/3:576)", Valid: true},
EpubcfiEnd: pgtype.Text{String: "epubcfi(/6/12!/4/2[x]/4/1:93)", Valid: true},
}
base := SaveHighlightRequest{
SelectionText: existing.SelectionText,
Color: "#90caf9",
}
t.Run("identical content and locators skip", func(t *testing.T) {
req := base
req.EpubcfiStart = existing.EpubcfiStart.String
req.EpubcfiEnd = existing.EpubcfiEnd.String
_, changed := svc.compareIncoming(req, existing)
if changed {
t.Error("identical echo must not rewrite the row")
}
})
t.Run("better start locator refreshes despite identical content", func(t *testing.T) {
req := base
req.EpubcfiStart = "epubcfi(/6/12!/4/2[x]/2/3:0)"
req.EpubcfiEnd = existing.EpubcfiEnd.String
_, changed := svc.compareIncoming(req, existing)
if !changed {
t.Error("locator drift on a content-equal echo must trigger an update")
}
})
t.Run("empty incoming locators leave the row alone", func(t *testing.T) {
req := base // no CFIs: applyLWW coalesces empty to stored
_, changed := svc.compareIncoming(req, existing)
if changed {
t.Error("empty locators coalesce; must not count as drift")
}
})
}
// Bookmarks share the echo-refresh rule for their locators.
func TestCompareIncomingBookmarkLocatorDrift(t *testing.T) {
svc := &AnnotationService{}
existing := database.MediaBookmarks{
Title: "in CHAPTER IV.",
CfiPosition: pgtype.Text{String: "epubcfi(/6/12!/4/2[x]/2/3:0)", Valid: true},
Position: pgtype.Text{String: "/body/DocFragment[6]/body/div[1]/h2[1]/text().0", Valid: true},
}
t.Run("same locators skip", func(t *testing.T) {
req := SaveBookmarkRequest{
Title: existing.Title,
CFIPosition: existing.CfiPosition.String,
Position: existing.Position.String,
}
if _, changed := svc.compareIncomingBookmark(req, existing); changed {
t.Error("identical bookmark echo must not rewrite the row")
}
})
t.Run("new CFI refreshes despite same title", func(t *testing.T) {
req := SaveBookmarkRequest{
Title: existing.Title,
CFIPosition: "epubcfi(/6/12!/4/2[x]/2/3:5)",
Position: existing.Position.String,
}
if _, changed := svc.compareIncomingBookmark(req, existing); !changed {
t.Error("bookmark locator drift must trigger an update")
}
})
}
+153 -24
View File
@@ -12,6 +12,7 @@ import (
"strconv"
"strings"
"sync"
"unicode"
"unicode/utf8"
"golang.org/x/net/html"
@@ -245,7 +246,12 @@ func parseElementPart(part string) (string, int) {
}
type ConversionResult struct {
EPUBCFI string
EPUBCFI string
// EndEPUBCFI is set when the conversion matched a context that is a
// quote of the document (text search): the range end anchor of the
// quoted text, valid across block boundaries. Selections use it as
// the highlight end; point positions ignore it.
EndEPUBCFI string
Href string
Percentage float64
Precision string
@@ -510,23 +516,22 @@ func (c *CFIConverter) convertByStructuralPath(body *html.Node, xp *CREXPointer,
return nil
}
// Text is the final verification: when the device sent usable words and
// they disagree with this structural landing, reject it and let text
// search / percentage decide rather than storing a confident-but-wrong CFI.
// Text is the final verification, in quote form: a usable context must
// be a prefix of the document as read forward from this landing point.
// Device captures are exactly that shape — a position's context is the
// text from the position onward (the plugin's walk-up may concatenate
// the heading with the paragraphs below), and a selection is the
// document text between its two anchors. The single-block containment
// checks stay as secondary acceptance for reverse shapes.
if usable, normalized := usableContextText(contextText); usable {
doc := documentTextFrom(body, textNode, localOffset, utf8.RuneCountInString(normalized)+64)
flat := blockFlattenedText(textNode)
if flat != "" && !strings.Contains(flat, normalized) && !strings.Contains(normalized, flat) {
// Compare a prefix too: device sends ~100 chars from the reader
// position while the block may be longer.
prefix := normalized
if utf8.RuneCountInString(prefix) > 40 {
runes := []rune(prefix)
prefix = string(runes[:40])
}
if !strings.Contains(flat, prefix) {
log.Printf("Bookhoard: structural landing disagrees with context in %s (block %q vs ctx %q)", href, truncateForLog(flat, 80), truncateForLog(normalized, 80))
return nil
}
if !(strings.HasPrefix(doc, normalized) ||
strings.Contains(flat, normalized) ||
strings.Contains(normalized, flat)) {
log.Printf("Bookhoard: structural landing disagrees with context in %s (reads %q vs ctx %q)",
href, truncateForLog(doc, 80), truncateForLog(normalized, 80))
return nil
}
}
@@ -592,28 +597,149 @@ func (c *CFIConverter) convertFragmentID(s string, storedPercentage float64) (*C
}, nil
}
// docRunePos records which text node (and rune offset within it) produced
// each rune of the flattened document text.
type docRunePos struct {
node *html.Node
off int
}
// flattenDocumentForSearch renders the document's text as a
// whitespace-normalized rune stream in document order, crossing block
// boundaries: a single space separates blocks, while inline spans join
// directly (drop-cap splits like <span>C</span>onvergence read as one
// word). Every emitted rune carries its source (node, rune offset) so
// matches map back to CFIs.
func flattenDocumentForSearch(body *html.Node) ([]rune, []docRunePos) {
var runes []rune
var poss []docRunePos
pendingSpace := false
var lastBlock *html.Node
appendNode := func(n *html.Node) {
block := findBlockParent(n)
if lastBlock != nil && block != lastBlock {
pendingSpace = true
}
lastBlock = block
off := 0
for _, r := range n.Data {
if unicode.IsSpace(r) {
pendingSpace = true
} else {
if pendingSpace && len(runes) > 0 {
runes = append(runes, ' ')
poss = append(poss, docRunePos{node: n, off: off})
}
pendingSpace = false
runes = append(runes, r)
poss = append(poss, docRunePos{node: n, off: off})
}
off++
}
}
var walk func(n *html.Node)
walk = func(n *html.Node) {
if n.Type == html.TextNode {
if strings.TrimSpace(n.Data) != "" {
appendNode(n)
}
return
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
walk(body)
return runes, poss
}
// documentTextFrom reads the whitespace-normalized document text starting
// at rune `offset` in `start` (typically a structural landing point),
// crossing block boundaries — the document "as read" from that position.
// Capped at maxRunes.
func documentTextFrom(body *html.Node, start *html.Node, offset, maxRunes int) string {
runes, poss := flattenDocumentForSearch(body)
begin := -1
for i := range poss {
if poss[i].node == start && poss[i].off >= offset {
begin = i
break
}
}
if begin < 0 {
return ""
}
// The separator space before a block's first content rune shares its
// (node, offset) — skip it so the read starts on real text.
if runes[begin] == ' ' && begin+1 < len(runes) {
begin++
}
end := len(runes)
if begin+maxRunes < end {
end = begin + maxRunes
}
return string(runes[begin:end])
}
func (c *CFIConverter) convertByTextSearch(body *html.Node, xp *CREXPointer, spine *spineCache, href string, storedPercentage float64, contextText string) *ConversionResult {
normalizedCtx := normalizeWhitespace(contextText)
if normalizedCtx == "" {
return nil
}
match, matchOffset := findTextInNode(body, normalizedCtx)
if match == nil {
log.Printf("Bookhoard: text search no match for %q in %s", normalizedCtx, href)
// Match against the whole document flattened in reading order — the
// context may span block boundaries (a selection covering several
// paragraphs, a walk-up capture joining a heading with what follows).
runes, poss := flattenDocumentForSearch(body)
text := string(runes)
words := strings.Fields(normalizedCtx)
quoted := make([]string, len(words))
for i, w := range words {
quoted[i] = regexp.QuoteMeta(w)
}
pattern := strings.Join(quoted, `\s+`)
re, err := regexp.Compile(pattern)
if err != nil {
return nil
}
loc := re.FindStringIndex(text)
if loc == nil {
log.Printf("Bookhoard: text search no match for %q in %s", truncateForLog(normalizedCtx, 60), href)
return nil
}
startRune := utf8.RuneCountInString(text[:loc[0]])
endRune := utf8.RuneCountInString(text[:loc[1]]) // exclusive
spineIndex := xp.FragmentIndex - 1
cfi, err := buildCFI(spineIndex, match, matchOffset)
if err != nil || cfi == "" {
startPos := poss[startRune]
startCFI, err := buildCFI(spineIndex, startPos.node, startPos.off)
if err != nil || startCFI == "" {
log.Printf("Bookhoard: text search found match but buildCFI failed: %v", err)
return nil
}
log.Printf("Bookhoard: text search matched %q → %s (precision: exact)", normalizedCtx, cfi)
// The matched context is a quote of the document: its extent gives
// selections a true range end, across block boundaries.
endCFI := ""
if endRune > startRune && endRune <= len(poss) {
endPos := poss[endRune-1]
endOff := endPos.off + 1
if nRunes := utf8.RuneCountInString(endPos.node.Data); endOff > nRunes {
endOff = nRunes
}
if ec, err := buildCFI(spineIndex, endPos.node, endOff); err == nil && ec != "" {
endCFI = ec
}
}
log.Printf("Bookhoard: text search matched %q → %s (precision: exact)", truncateForLog(normalizedCtx, 60), startCFI)
return &ConversionResult{
EPUBCFI: cfi,
EPUBCFI: startCFI,
EndEPUBCFI: endCFI,
Href: href,
Percentage: storedPercentage,
Precision: "exact",
@@ -780,7 +906,10 @@ func (c *CFIConverter) convertByPercentageOffset(body *html.Node, xp *CREXPointe
EPUBCFI: cfi,
Href: href,
Percentage: storedPercentage,
Precision: "exact",
// This is a char-count estimate, not an exact landing: say
// so. Callers gate storage on precision, and a guess must
// never masquerade as an exact anchor.
Precision: "percentage",
}, nil
}
}
+111
View File
@@ -666,3 +666,114 @@ func TestReverseIgnoresSingleCharContext(t *testing.T) {
t.Errorf("single-char reverse context must fall back to percentage, got %s", reverse.Precision)
}
}
// A position at a chapter heading sends walk-up context: the heading text
// concatenated with the paragraphs below (block walk-up in the plugin).
// The structural landing at the heading is correct and must verify — the
// context is a prefix of the document as read from the landing.
func TestWalkUpContextVerifiesAtHeading(t *testing.T) {
c := NewCFIConverter(writeDropCapEPUB(t))
// ch10: h1[1] "Chapter 10", h1[2] "The Three C's of the New Covenant",
// then paragraphs. Position at h1[2]'s text start; the device captured
// the heading plus the following paragraph.
xp := "/body/DocFragment[2]/body/h1[2]/text().0"
ctx := "The Three C's of the New Covenant The Cleansing Life of Christ"
result, err := c.ConvertCREToStandard(xp, 0.52, ctx)
if err != nil {
t.Fatalf("ConvertCREToStandard error: %v", err)
}
t.Logf("walk-up heading → %s (%s)", result.EPUBCFI, result.Precision)
if result.Precision != "structural" {
t.Fatalf("expected structural precision (quote verification), got %s (%s)", result.Precision, result.EPUBCFI)
}
if strings.HasSuffix(result.EPUBCFI, "/4/2/1:0)") {
t.Errorf("collapsed to doc start: %s", result.EPUBCFI)
}
// The landing must be the heading, not a paragraph below it.
reverse, rerr := c.ConvertStandardToCRE(result.EPUBCFI, result.Percentage, "")
if rerr != nil {
t.Fatalf("reverse conversion error: %v", rerr)
}
if !strings.Contains(reverse.XPointer, "h1[2]") {
t.Errorf("expected landing in h1[2], got %s", reverse.XPointer)
}
}
// A selection spanning blocks (heading tail into the next paragraph) must
// be findable by text search across block boundaries, and the matched
// quote's extent gives a true range end.
func TestCrossBlockSearchSpansBlocks(t *testing.T) {
c := NewCFIConverter(writeDropCapEPUB(t))
// No element path → structural rung skipped, text search runs.
xp := "/body/DocFragment[2]/body"
ctx := "New Covenant The Cleansing Life of Christ"
result, err := c.ConvertCREToStandard(xp, 0.52, ctx)
if err != nil {
t.Fatalf("ConvertCREToStandard error: %v", err)
}
t.Logf("cross-block search → %s … %s (%s)", result.EPUBCFI, result.EndEPUBCFI, result.Precision)
if result.Precision != "exact" {
t.Fatalf("expected exact text-search precision, got %s", result.Precision)
}
if result.EPUBCFI == "" || result.EndEPUBCFI == "" {
t.Fatalf("expected range anchors, got %q…%q", result.EPUBCFI, result.EndEPUBCFI)
}
if result.EPUBCFI == result.EndEPUBCFI {
t.Fatalf("range collapsed: %s", result.EPUBCFI)
}
// The start lands in the heading, the end in the following paragraph.
startRev, err1 := c.ConvertStandardToCRE(result.EPUBCFI, result.Percentage, "")
endRev, err2 := c.ConvertStandardToCRE(result.EndEPUBCFI, result.Percentage, "")
if err1 != nil || err2 != nil {
t.Fatalf("reverse conversions failed: %v %v", err1, err2)
}
if !strings.Contains(startRev.XPointer, "h1[2]") {
t.Errorf("expected start in h1[2], got %s", startRev.XPointer)
}
if !strings.Contains(endRev.XPointer, "p[1]") {
t.Errorf("expected end in p[1] (following paragraph), got %s", endRev.XPointer)
}
}
// The percentage rung is a char-count estimate: it must never label its
// landing "exact". Feed it an unmatchable context so the ladder falls all
// the way through.
func TestPercentageFallbackIsHonestlyLabeled(t *testing.T) {
c := NewCFIConverter(writeDropCapEPUB(t))
xp := "/body/DocFragment[2]/body/p[3]/span[1]/text().0"
result, err := c.ConvertCREToStandard(xp, 0.52, "zzz qqq vvv uuu www")
if err != nil {
t.Fatalf("ConvertCREToStandard error: %v", err)
}
t.Logf("unmatchable context → %s (%s)", result.EPUBCFI, result.Precision)
if result.Precision != "percentage" {
t.Errorf("percentage rung must not claim exact, got %s", result.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)
}
}
+13 -2
View File
@@ -14,7 +14,11 @@ const (
)
type CanonicalLocator struct {
CFI string
CFI string
// EndCFI carries the matched context's range end (text-search
// conversions of selections) so callers can anchor a true highlight
// range across block boundaries.
EndCFI string
Precision string
Percentage float64
}
@@ -58,6 +62,13 @@ func cachedConverter(epubPath string) *CFIConverter {
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(
source LocatorSource,
devicePos string,
@@ -87,7 +98,7 @@ func ConvertToCanonical(
return CanonicalLocator{CFI: devicePos, Precision: "fallback", Percentage: percentage}
}
if result.EPUBCFI != "" {
return CanonicalLocator{CFI: result.EPUBCFI, Precision: result.Precision, Percentage: result.Percentage}
return CanonicalLocator{CFI: result.EPUBCFI, EndCFI: result.EndEPUBCFI, Precision: result.Precision, Percentage: result.Percentage}
}
if result.Href != "" {
return CanonicalLocator{CFI: result.Href, Precision: result.Precision, Percentage: result.Percentage}
+34 -54
View File
@@ -5,7 +5,11 @@ import (
"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{}{
"mediaItemId": metadata.MediaItemID,
"fileUrl": metadata.FileURL,
@@ -13,42 +17,11 @@ func readerInitExpr(metadata ReaderMetadata, progress ReadingProgress, bookmarks
"readingDirection": metadata.ReadingDirection,
"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)
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>
<html lang="en">
<head>
@@ -64,7 +37,7 @@ templ Reader(user User, metadata ReaderMetadata, progress ReadingProgress, bookm
</head>
<body
x-data="readerShell"
x-init={ readerInitExpr(metadata, progress, bookmarks) }
x-init={ readerInitExpr(metadata) }
class={ "theme-" + user.Theme + " h-screen overflow-hidden" }
>
<!-- Reading surface: edge-to-edge. Chrome overlays translucently;
@@ -96,7 +69,7 @@ templ Reader(user User, metadata ReaderMetadata, progress ReadingProgress, bookm
</div>
</div>
@ReaderChrome(metadata, progress)
@ReaderChrome(metadata)
<!-- Drawer scrim -->
<div
@@ -311,7 +284,7 @@ templ Reader(user User, metadata ReaderMetadata, progress ReadingProgress, bookm
</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'">
<!-- 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">
@@ -365,17 +338,7 @@ templ ReaderChrome(metadata ReaderMetadata, progress ReadingProgress) {
<div class="flex items-center gap-1">
<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">
<span class="hidden sm:inline" x-text="progressLabel"></span><span x-text="progressMain">
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>
<span class="hidden sm:inline" x-text="progressLabel"></span><span x-text="progressMain"></span>
</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>
@@ -467,13 +430,7 @@ templ ReaderChrome(metadata ReaderMetadata, progress ReadingProgress) {
<!-- Progress + TOC -->
<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">
<span x-text="progressMain">
if progress.FormatGroup == "reflowable" {
{ fmt.Sprintf("%.0f%%", progress.Percentage) }
} else {
{ fmt.Sprintf("%d/%d", progress.CurrentPage, progress.TotalPages) }
}
</span>
<span x-text="progressMain"></span>
</div>
<button @click="toggleTOC()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Table of Contents (t)">📖</button>
</div>
@@ -730,10 +687,33 @@ templ ReaderAnnotationsDrawer() {
href="#"
@click.prevent="goToBookmark(bookmark)"
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="text-xs block truncate" style="color: var(--text-secondary)" x-text="bookmark.positionLabel"></span>
</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
@click="deleteBookmark(bookmark.id)"
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
+229 -29
View File
@@ -406,6 +406,10 @@ document.addEventListener("alpine:init", () => {
tapZonesEnabled: true as boolean,
tapZoneSize: 30 as number,
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 {
id: string;
text: string;
@@ -472,11 +476,17 @@ document.addEventListener("alpine:init", () => {
positionLabel: string;
cfi: string;
page: number | null;
renameOpen?: boolean;
renameText?: string;
}[],
tocItems: [] as any[],
mediaItemId: "" as string,
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,
readingTheme: "light" as string,
readingMode: "light" as string,
@@ -558,20 +568,13 @@ document.addEventListener("alpine:init", () => {
formatGroup: string;
readingDirection: 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.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";
// Reading flow for comics is a per-book preference (a webtoon title
// 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).
if (window.matchMedia("(pointer: coarse)").matches) {
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;
// fixed-layout highlight overlays are a later milestone).
@@ -733,6 +744,21 @@ document.addEventListener("alpine:init", () => {
() => setTimeout(checkSelection, 0),
{ passive: true },
);
// Clicks in the book dismiss the popover in ANY mode: iframe
// events never bubble to the host document (so the host
// outside-click dismiss never sees them), and the collapsed
// check above only covers create mode — edit mode had no
// outside-click path at all, leaving Esc as the only way out.
// Clicking a painted highlight still works: this hides, then
// foliate's show-annotation re-opens it in edit mode.
doc.addEventListener(
"pointerdown",
() => {
if (this.selectionPopover.open)
this.hideSelectionPopover();
},
{ passive: true },
);
doc.addEventListener(
"keyup",
(ev: KeyboardEvent) => {
@@ -824,7 +850,13 @@ document.addEventListener("alpine:init", () => {
});
this.view.addEventListener("show-annotation", (e: any) => {
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;
const doc = this.renderer
?.getContents?.()
@@ -896,15 +928,18 @@ document.addEventListener("alpine:init", () => {
document.addEventListener("keydown", (ev: KeyboardEvent) =>
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.
// A bare number navigates directly to the section index in foliate.
await this.view.init({ lastLocation: config.savedPage - 1 })
} else if (config.savedCfi) {
await this.view.init({ lastLocation: config.savedCfi })
} else if (config.savedPercentage && config.savedPercentage > 0) {
await this.view.init({ lastLocation: saved.page - 1 })
} else if (saved.cfi) {
await this.view.init({ lastLocation: saved.cfi })
} else if (saved.percentage != null && saved.percentage > 0) {
await this.view.init({
lastLocation: { fraction: config.savedPercentage },
lastLocation: { fraction: saved.percentage },
})
} else {
await this.view.init({})
@@ -918,9 +953,14 @@ document.addEventListener("alpine:init", () => {
this.renderer.setAttribute("interaction-mode", this.interactionMode);
}
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.refreshAnnotations();
this.refreshBookmarks();
this.setupChrome();
this.setupTapZones();
},
@@ -983,6 +1023,26 @@ document.addEventListener("alpine:init", () => {
if (!window.matchMedia("(pointer: coarse)").matches) return;
const vp = document.getElementById("reader-viewport");
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) {
let downX = 0;
@@ -990,6 +1050,12 @@ document.addEventListener("alpine:init", () => {
let downT = 0;
let downId = -1;
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(
"pointerdown",
(e: PointerEvent) => {
@@ -999,6 +1065,21 @@ document.addEventListener("alpine:init", () => {
downT = Date.now();
downId = e.pointerId;
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 },
);
@@ -1016,7 +1097,7 @@ document.addEventListener("alpine:init", () => {
(e: PointerEvent) => {
if (e.pointerId !== downId) return;
downId = -1;
if (moved || Date.now() - downT > 500) return;
if (moved || longPressed || Date.now() - downT > 500) return;
if (!this.tapZonesEnabled) return;
const target = e.target as HTMLElement | null;
if (
@@ -1027,6 +1108,10 @@ document.addEventListener("alpine:init", () => {
return;
const sel = isDoc ? (surface as any).getSelection?.() : null;
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
// belong to the content (and double-tap zoom).
if (this.isFixedLayout && this.renderer?.zoom != null) return;
@@ -1341,7 +1426,23 @@ document.addEventListener("alpine:init", () => {
if (!resp.ok) return;
const row = await resp.json();
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);
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.
if (p.pdfPage >= 0) {
this.renderer?.addRectAnnotation?.({
@@ -1377,7 +1478,11 @@ document.addEventListener("alpine:init", () => {
if (hl?.pdfPage >= 0) {
this.renderer?.removeRectAnnotation?.(id);
} 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();
} catch (_e) {
@@ -1451,8 +1556,44 @@ document.addEventListener("alpine:init", () => {
/* 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) {
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);
this.saveTimeout = setTimeout(() => {
this.saveProgress(fraction, location, cfi);
@@ -1593,18 +1734,23 @@ document.addEventListener("alpine:init", () => {
saveSettings({ double_page_spread: this.doublePageSpread });
},
goLeft() {
this.userMoved = true;
this.view?.goLeft?.();
},
goRight() {
this.userMoved = true;
this.view?.goRight?.();
},
nextPage() {
this.userMoved = true;
this.view?.next?.();
},
previousPage() {
this.userMoved = true;
this.view?.prev?.();
},
goToFraction(value: string) {
this.userMoved = true;
this.view?.goToFraction?.(parseFloat(value));
},
toggleTOC() {
@@ -1783,9 +1929,11 @@ document.addEventListener("alpine:init", () => {
},
goToSearchResult(item: { cfi?: string; page?: number | null }) {
if (item.cfi) {
this.userMoved = true;
this.pushBackStack();
this.view?.goTo?.(item.cfi);
} else if (item.page != null) {
this.userMoved = true;
this.pushBackStack();
this.view?.goTo?.(item.page);
} else return;
@@ -1814,6 +1962,7 @@ document.addEventListener("alpine:init", () => {
goBackToLocation() {
const loc = this.backStack.pop();
if (!loc) return;
this.userMoved = true;
if (loc.cfi) this.view?.goTo?.(loc.cfi);
else if (typeof loc.page === "number") this.view?.goTo?.(loc.page);
},
@@ -1829,6 +1978,7 @@ document.addEventListener("alpine:init", () => {
},
goToTOCItem(item: any) {
if (this.view && item.href) {
this.userMoved = true;
this.pushBackStack();
this.view.goTo(item.href);
this.tocOpen = false;
@@ -1956,6 +2106,7 @@ document.addEventListener("alpine:init", () => {
},
goToPage(index: number) {
if (!this.view || typeof index !== "number" || index < 0) return;
this.userMoved = true;
this.pushBackStack();
this.view.goTo(index);
this.tocOpen = false;
@@ -1963,9 +2114,11 @@ document.addEventListener("alpine:init", () => {
goToBookmark(item: { cfi: string; page: number | null }) {
if (!this.view) return;
if (item.cfi) {
this.userMoved = true;
this.pushBackStack();
this.view.goTo(item.cfi);
} else if (item.page != null && item.page > 0) {
this.userMoved = true;
this.pushBackStack();
// Fixed-layout/comic: sections are pages; foliate takes an index.
this.view.goTo(item.page - 1);
@@ -2079,6 +2232,14 @@ document.addEventListener("alpine:init", () => {
const page = this.isFixedLayout
? (this.renderer?.index ?? 0) + 1
: 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 {
const resp = await fetch(
@@ -2090,7 +2251,7 @@ document.addEventListener("alpine:init", () => {
"Content-Type": "application/json",
},
body: JSON.stringify({
title: `Bookmark at ${this.progressText || "current position"}`,
title,
position: this.isFixedLayout
? `page:${page}`
: cfi
@@ -2130,6 +2291,38 @@ document.addEventListener("alpine:init", () => {
/* 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 {
const tocItem = this.lastRelocateDetail?.tocItem;
if (!tocItem?.label) return 0;
@@ -2425,11 +2618,18 @@ document.addEventListener("alpine:init", () => {
},
handleKeydown(event: KeyboardEvent) {
const k = event.key;
// Never hijack keys while the user is typing in a form control.
const tag = (event.target as HTMLElement)?.tagName;
// Never hijack keys while the user is typing in a form control: the
// 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 =
tag === "INPUT" || tag === "SELECT" || tag === "TEXTAREA";
t?.tagName === "INPUT" ||
t?.tagName === "SELECT" ||
t?.tagName === "TEXTAREA" ||
!!t?.isContentEditable;
this.pokeChrome();
if (typing && k !== "Escape") return;
if (k === "ArrowLeft" || k === "h") {
if (event.altKey) {
event.preventDefault();
@@ -2458,7 +2658,7 @@ document.addEventListener("alpine:init", () => {
} else if (k === "F1") {
event.preventDefault();
this.toggleHelp();
} else if (!typing) {
} else {
if (k === "t") this.toggleTOC();
else if (k === "s") this.toggleSettings();
else if (k === "b") this.addBookmark();