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