Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3514b4dc1c | ||
|
|
ae87c0cd6a | ||
|
|
b6f507b9e5 | ||
|
|
ce3ae31ced | ||
|
|
75c1d9bb95 | ||
|
|
905218dd4b | ||
|
|
94dad5e089 | ||
|
|
2366faccce |
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()})
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because one or more lines are too long
+186
-28
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user