8 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
11 changed files with 731 additions and 345 deletions
+44 -17
View File
@@ -81,28 +81,39 @@ func (h *KOReaderHandler) loadAnnotationEpub(ctx context.Context, mediaItemID pg
// convertHighlightPositions resolves a device annotation's pos0/pos1
// locators to canonical CFIs through the shared facade. contextText is the
// selection's own text — the ideal anchor for the converter's verification
// and text-search rungs. percentage anchors the last-resort fallback so a
// failed conversion degrades to the neighborhood of the true position
// rather than the document start.
// 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 "", ""
}
startLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos0, percentage, contextText, ec.mediaItem.FormatGroup, ec.epubPath, "")
endLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos1, percentage, "", ec.mediaItem.FormatGroup, ec.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
// structurally/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" && endLoc.Precision != "structural" &&
(startLoc.Precision == "exact" || startLoc.Precision == "structural") && contextText != "" {
endCFI = extendCFIByLength(startLoc.CFI, contextText)
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
}
return startLoc.CFI, endCFI
if endCFI == "" && pos1 != "" {
endLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos1, percentage, "", ec.mediaItem.FormatGroup, ec.epubPath, "")
endCFI = webUsableCFI(endLoc)
}
// 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
@@ -866,10 +877,26 @@ func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, deviceID pgtype
contextText = *book.ContextText
}
loc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, *epubcfi, pct, contextText, ec.mediaItem.FormatGroup, ec.epubPath, "")
if loc.CFI != "" && loc.CFI != *epubcfi {
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
}
}
}
+5
View File
@@ -1656,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()})
+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")
}
+112 -23
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,
@@ -687,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),
@@ -718,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
}
}
+90
View File
@@ -667,6 +667,96 @@ func TestReverseIgnoresSingleCharContext(t *testing.T) {
}
}
// 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.
+6 -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
}
@@ -94,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}
+11 -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>
+39 -128
View File
File diff suppressed because one or more lines are too long
+186 -28
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;
@@ -478,7 +482,11 @@ document.addEventListener("alpine:init", () => {
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,
@@ -560,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.
@@ -691,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).
@@ -735,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) => {
@@ -826,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?.()
@@ -898,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({})
@@ -920,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();
},
@@ -985,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;
@@ -992,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) => {
@@ -1001,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 },
);
@@ -1018,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 (
@@ -1029,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;
@@ -1343,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?.({
@@ -1379,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) {
@@ -1453,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);
@@ -1595,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() {
@@ -1785,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;
@@ -1816,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);
},
@@ -1831,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;
@@ -1958,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;
@@ -1965,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);
@@ -2467,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();
@@ -2500,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();