diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index 2e63b6d..2300065 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -6852,7 +6852,11 @@ SELECT mh.dedup_key, 'highlight' as annotation_type, mh.device_sync_data, - mh.deleted_at + mh.deleted_at, + mh.start_position, + mh.end_position, + mh.epubcfi_start, + mh.epubcfi_end FROM media_highlights mh WHERE mh.media_item_id = $1 AND mh.user_id = $2 AND mh.deleted = TRUE AND mh.deleted_at > $3 UNION ALL @@ -6861,7 +6865,11 @@ SELECT mn.dedup_key, 'note' as annotation_type, mn.device_sync_data, - mn.deleted_at + mn.deleted_at, + mn.position as start_position, + NULL as end_position, + mn.epubcfi_location as epubcfi_start, + NULL as epubcfi_end FROM media_notes mn WHERE mn.media_item_id = $1 AND mn.user_id = $2 AND mn.deleted = TRUE AND mn.deleted_at > $3 UNION ALL @@ -6870,7 +6878,11 @@ SELECT mb.dedup_key, 'bookmark' as annotation_type, mb.device_sync_data, - mb.deleted_at + mb.deleted_at, + mb.position as start_position, + NULL as end_position, + mb.cfi_position as epubcfi_start, + NULL as epubcfi_end FROM media_bookmarks mb WHERE mb.media_item_id = $1 AND mb.user_id = $2 AND mb.deleted = TRUE AND mb.deleted_at > $3 ORDER BY deleted_at DESC @@ -6888,6 +6900,10 @@ type GetTombstonedAnnotationsForBookRow struct { AnnotationType string `db:"annotation_type" json:"annotation_type"` DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"` DeletedAt pgtype.Timestamptz `db:"deleted_at" json:"deleted_at"` + StartPosition pgtype.Text `db:"start_position" json:"start_position"` + EndPosition pgtype.Text `db:"end_position" json:"end_position"` + EpubcfiStart pgtype.Text `db:"epubcfi_start" json:"epubcfi_start"` + EpubcfiEnd pgtype.Text `db:"epubcfi_end" json:"epubcfi_end"` } func (q *Queries) GetTombstonedAnnotationsForBook(ctx context.Context, arg GetTombstonedAnnotationsForBookParams) ([]GetTombstonedAnnotationsForBookRow, error) { @@ -6905,6 +6921,10 @@ func (q *Queries) GetTombstonedAnnotationsForBook(ctx context.Context, arg GetTo &i.AnnotationType, &i.DeviceSyncData, &i.DeletedAt, + &i.StartPosition, + &i.EndPosition, + &i.EpubcfiStart, + &i.EpubcfiEnd, ); err != nil { return nil, err } diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index 3e4cd3b..9430858 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -992,7 +992,11 @@ SELECT mh.dedup_key, 'highlight' as annotation_type, mh.device_sync_data, - mh.deleted_at + mh.deleted_at, + mh.start_position, + mh.end_position, + mh.epubcfi_start, + mh.epubcfi_end FROM media_highlights mh WHERE mh.media_item_id = $1 AND mh.user_id = $2 AND mh.deleted = TRUE AND mh.deleted_at > $3 UNION ALL @@ -1001,7 +1005,11 @@ SELECT mn.dedup_key, 'note' as annotation_type, mn.device_sync_data, - mn.deleted_at + mn.deleted_at, + mn.position as start_position, + NULL as end_position, + mn.epubcfi_location as epubcfi_start, + NULL as epubcfi_end FROM media_notes mn WHERE mn.media_item_id = $1 AND mn.user_id = $2 AND mn.deleted = TRUE AND mn.deleted_at > $3 UNION ALL @@ -1010,7 +1018,11 @@ SELECT mb.dedup_key, 'bookmark' as annotation_type, mb.device_sync_data, - mb.deleted_at + mb.deleted_at, + mb.position as start_position, + NULL as end_position, + mb.cfi_position as epubcfi_start, + NULL as epubcfi_end FROM media_bookmarks mb WHERE mb.media_item_id = $1 AND mb.user_id = $2 AND mb.deleted = TRUE AND mb.deleted_at > $3 ORDER BY deleted_at DESC; diff --git a/internal/handlers/koreader.go b/internal/handlers/koreader.go index d02180f..9771a66 100644 --- a/internal/handlers/koreader.go +++ b/internal/handlers/koreader.go @@ -9,7 +9,10 @@ import ( "fmt" "log" "net/http" + "strconv" + "strings" "time" + "unicode/utf8" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" @@ -47,7 +50,7 @@ func (h *KOReaderHandler) SetAnnotationService(svc *wsync.AnnotationService) { h.annotationSvc = svc } -func (h *KOReaderHandler) convertHighlightPositions(ctx context.Context, mediaItemID pgtype.UUID, pos0, pos1 string) (string, string) { +func (h *KOReaderHandler) convertHighlightPositions(ctx context.Context, mediaItemID pgtype.UUID, pos0, pos1, contextText string) (string, string) { if pos0 == "" || h.libraryService == nil { return "", "" } @@ -59,9 +62,89 @@ func (h *KOReaderHandler) convertHighlightPositions(ctx context.Context, mediaIt if err != nil || epubPath == "" { return "", "" } - startLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos0, 0, "", mediaItem.FormatGroup, epubPath, "") + // 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, "") - return startLoc.CFI, endLoc.CFI + endCFI := endLoc.CFI + // The end conversion carries no context text, so unless it resolved + // exactly it degenerates to a percentage fallback anchored at the + // document start — useless as a range end. When the START resolved + // exactly, derive the end from it: same node, character offset + // advanced by the selection's UTF-16 length (the CFI offset unit). + if endLoc.Precision != "exact" && startLoc.Precision == "exact" && contextText != "" { + endCFI = extendCFIByLength(startLoc.CFI, contextText) + } + return startLoc.CFI, endCFI +} + +// 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). +// Selections spanning multiple text nodes produce an out-of-range offset — +// harmless: resolution clamps or fails, and consumers fall back to the start. +func extendCFIByLength(cfi, text string) string { + if cfi == "" || text == "" { + return cfi + } + i := strings.LastIndex(cfi, ":") + if i < 0 || !strings.HasSuffix(cfi, ")") { + return cfi + } + off, err := strconv.Atoi(cfi[i+1 : len(cfi)-1]) + if err != nil { + return cfi + } + utf16len := 0 + for _, r := range text { + if r > 0xFFFF { + utf16len += 2 + } else { + utf16len++ + } + } + return cfi[:i+1] + strconv.Itoa(off+utf16len) + ")" +} + +// existingHighlightColor returns the stored color of the highlight matching +// the dedup key ("" when none) so device echoes that carry no color never +// clobber the web color. +func (h *KOReaderHandler) existingHighlightColor(ctx context.Context, mediaItemID, userID pgtype.UUID, dedupKey string) string { + if dedupKey == "" { + return "" + } + existing, err := h.db.GetMediaHighlightByDedupKey(ctx, database.GetMediaHighlightByDedupKeyParams{ + UserID: userID, + MediaItemID: mediaItemID, + DedupKey: pgtype.Text{String: dedupKey, Valid: true}, + }) + if err != nil { + return "" + } + return existing.Color.String +} + +// deriveAnnotationPercentage computes a percentage for device-pushed +// annotations when the client didn't send one (thin clients skip their own +// per-annotation page lookups; arithmetic is only free on paging documents). +func (h *KOReaderHandler) deriveAnnotationPercentage(ctx context.Context, mediaItemID pgtype.UUID, pos0 string, page int) float64 { + mediaItem, err := h.db.GetMediaItem(ctx, mediaItemID) + if err != nil { + return 0 + } + formatGroup := wsync.FormatGroup(mediaItem.FormatGroup) + if formatGroup == wsync.FormatGroupFixedLayout || formatGroup == wsync.FormatGroupComicArchive { + if page > 0 && mediaItem.PageCount.Valid && mediaItem.PageCount.Int32 > 0 { + return float64(page) / float64(mediaItem.PageCount.Int32) + } + return 0 + } + if wsync.IsCREXPointer(pos0) && h.libraryService != nil { + if epubPath, err := h.libraryService.ResolveMediaPath(ctx, mediaItem.LibraryID, mediaItem.FilePath); err == nil && epubPath != "" { + return wsync.NewCFIConverter(epubPath).SectionPercentage(pos0) + } + } + return 0 } func (h *KOReaderHandler) SetLibraryService(svc LibraryPathResolver) { @@ -75,24 +158,24 @@ type KOReaderProgressRequest struct { } type KOReaderBookProgress struct { - UUID string `json:"uuid,omitempty"` - SHA256 string `json:"sha256,omitempty"` - Title string `json:"title,omitempty"` - Authors []string `json:"authors,omitempty"` - Progress float64 `json:"progress"` - Percentage float64 `json:"percentage"` - LastRead string `json:"last_read,omitempty"` - FilePath string `json:"file_path,omitempty"` - DeviceInfo KOReaderDeviceInfo `json:"device_info,omitempty"` - Bookmarks []KOReaderBookmark `json:"bookmarks,omitempty"` - Highlights []KOReaderHighlight `json:"highlights,omitempty"` - Notes []KOReaderNote `json:"notes,omitempty"` - Chapter *int `json:"chapter,omitempty"` - Character *int64 `json:"character,omitempty"` - Epubcfi *string `json:"epubcfi,omitempty"` - ContextText *string `json:"context_text,omitempty"` - Page *int `json:"page,omitempty"` - TotalPages *int `json:"total_pages,omitempty"` + UUID string `json:"uuid,omitempty"` + SHA256 string `json:"sha256,omitempty"` + Title string `json:"title,omitempty"` + Authors []string `json:"authors,omitempty"` + Progress float64 `json:"progress"` + Percentage float64 `json:"percentage"` + LastRead string `json:"last_read,omitempty"` + FilePath string `json:"file_path,omitempty"` + DeviceInfo KOReaderDeviceInfo `json:"device_info,omitempty"` + Bookmarks []KOReaderBookmark `json:"bookmarks,omitempty"` + Highlights []KOReaderHighlight `json:"highlights,omitempty"` + Notes []KOReaderNote `json:"notes,omitempty"` + Chapter *int `json:"chapter,omitempty"` + Character *int64 `json:"character,omitempty"` + Epubcfi *string `json:"epubcfi,omitempty"` + ContextText *string `json:"context_text,omitempty"` + Page *int `json:"page,omitempty"` + TotalPages *int `json:"total_pages,omitempty"` } type KOReaderDeviceInfo struct { @@ -100,53 +183,91 @@ type KOReaderDeviceInfo struct { DeviceModel string `json:"device_model,omitempty"` } +// FlexInt tolerates the loose types KOReader clients send for optional +// numeric fields: JSON numbers, numeric strings ("30"), empty strings +// (""), or non-numeric strings ("/body/..." xpointers in `page` for CRE +// documents) — the latter decode to 0. Without this, a single annotation +// carrying chapter:"" or page:"/body/..." failed the whole request bind +// with a 400. +type FlexInt int + +func (f *FlexInt) UnmarshalJSON(b []byte) error { + s := strings.TrimSpace(string(b)) + if s == "null" || s == `""` { + *f = 0 + return nil + } + if n, err := strconv.Atoi(s); err == nil { + *f = FlexInt(n) + return nil + } + if strings.HasPrefix(s, `"`) && strings.HasSuffix(s, `"`) { + inner := s[1 : len(s)-1] + if n, err := strconv.Atoi(inner); err == nil { + *f = FlexInt(n) + return nil + } + *f = 0 + return nil + } + if fl, err := strconv.ParseFloat(s, 64); err == nil { + *f = FlexInt(int(fl)) + return nil + } + *f = 0 + return nil +} + type KOReaderBookmark struct { - Chapter int `json:"chapter,omitempty"` + Chapter FlexInt `json:"chapter,omitempty"` Datetime string `json:"datetime,omitempty"` Notes string `json:"notes,omitempty"` Pos0 string `json:"pos0,omitempty"` Pos1 string `json:"pos1,omitempty"` - Page int `json:"page,omitempty"` + Page FlexInt `json:"page,omitempty"` Text string `json:"text,omitempty"` Type string `json:"type,omitempty"` Percentage *float64 `json:"percentage,omitempty"` BookSHA256 string `json:"book_sha256,omitempty"` + DedupKey string `json:"dedup_key,omitempty"` } type KOReaderHighlight struct { - Chapter int `json:"chapter,omitempty"` + Chapter FlexInt `json:"chapter,omitempty"` Datetime string `json:"datetime,omitempty"` Notes string `json:"notes,omitempty"` Pos0 string `json:"pos0,omitempty"` Pos1 string `json:"pos1,omitempty"` - Page int `json:"page,omitempty"` + Page FlexInt `json:"page,omitempty"` Text string `json:"text,omitempty"` Type string `json:"type,omitempty"` Color string `json:"color,omitempty"` Percentage *float64 `json:"percentage,omitempty"` BookSHA256 string `json:"book_sha256,omitempty"` + DedupKey string `json:"dedup_key,omitempty"` } type KOReaderNote struct { - Chapter int `json:"chapter,omitempty"` + Chapter FlexInt `json:"chapter,omitempty"` Datetime string `json:"datetime,omitempty"` Notes string `json:"notes,omitempty"` Pos0 string `json:"pos0,omitempty"` Pos1 string `json:"pos1,omitempty"` - Page int `json:"page,omitempty"` + Page FlexInt `json:"page,omitempty"` Text string `json:"text,omitempty"` Type string `json:"type,omitempty"` Percentage *float64 `json:"percentage,omitempty"` BookSHA256 string `json:"book_sha256,omitempty"` + DedupKey string `json:"dedup_key,omitempty"` } type KOReaderSyncResponse struct { - SyncStatus string `json:"sync_status"` - BooksSynced int `json:"books_synced"` + SyncStatus string `json:"sync_status"` + BooksSynced int `json:"books_synced"` BookResults []KOReaderBookSyncResult `json:"book_results,omitempty"` - Conflicts []KOReaderConflict `json:"conflicts,omitempty"` - Timestamp string `json:"timestamp"` - DeviceUpdated bool `json:"device_updated"` + Conflicts []KOReaderConflict `json:"conflicts,omitempty"` + Timestamp string `json:"timestamp"` + DeviceUpdated bool `json:"device_updated"` } type KOReaderBookSyncResult struct { @@ -174,20 +295,20 @@ type KOReaderMetadata struct { } type KOReaderProgressData struct { - Percentage float64 `json:"percentage"` - Character *int64 `json:"character,omitempty"` - Epubcfi *string `json:"epubcfi,omitempty"` - KoreaderXPointer *string `json:"koreader_xpointer,omitempty"` - Chapter *int `json:"chapter,omitempty"` - ChapterProgress *float64 `json:"chapter_progress,omitempty"` - Page *int `json:"page,omitempty"` - TotalPages *int `json:"total_pages,omitempty"` + Percentage float64 `json:"percentage"` + Character *int64 `json:"character,omitempty"` + Epubcfi *string `json:"epubcfi,omitempty"` + KoreaderXPointer *string `json:"koreader_xpointer,omitempty"` + Chapter *int `json:"chapter,omitempty"` + ChapterProgress *float64 `json:"chapter_progress,omitempty"` + Page *int `json:"page,omitempty"` + TotalPages *int `json:"total_pages,omitempty"` } type KOReaderAnnotations struct { - Highlights []KOReaderHighlight `json:"highlights,omitempty"` - Notes []KOReaderNote `json:"notes,omitempty"` - Bookmarks []KOReaderBookmark `json:"bookmarks,omitempty"` + Highlights []KOReaderHighlight `json:"highlights,omitempty"` + Notes []KOReaderNote `json:"notes,omitempty"` + Bookmarks []KOReaderBookmark `json:"bookmarks,omitempty"` DeletedHighlights []map[string]interface{} `json:"deleted_highlights,omitempty"` DeletedBookmarks []map[string]interface{} `json:"deleted_bookmarks,omitempty"` } @@ -486,12 +607,16 @@ func (h *KOReaderHandler) processBookAnnotations(ctx context.Context, deviceID, for _, hl := range book.Highlights { startPos := hl.Pos0 endPos := hl.Pos1 - epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, startPos, endPos) + // The highlight's own text anchors the conversion exactly. + epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, startPos, endPos, hl.Text) pctStart := 0.0 if hl.Percentage != nil { pctStart = *hl.Percentage } + if pctStart == 0 { + pctStart = h.deriveAnnotationPercentage(ctx, mediaItemID, startPos, int(hl.Page)) + } deviceData, _ := json.Marshal(map[string]interface{}{ "datetime": hl.Datetime, @@ -500,31 +625,55 @@ func (h *KOReaderHandler) processBookAnnotations(ctx context.Context, deviceID, "page": hl.Page, }) + // Color semantics: devices render their own default and cannot + // round-trip web colors. An echo carries NO color — preserve the + // stored (web) color so round-trips never change it. A non-empty + // color means the user edited the highlight on the device: map the + // device color name and let it win. + color := "" + if hl.Color != "" { + color = mapColorFromKOReader(hl.Color) + } + dedupKey := hl.DedupKey + if dedupKey == "" { + dedupKey = wsync.ComputeDedupKey(hl.Text, epubcfiStart, startPos) + } + if color == "" { + color = h.existingHighlightColor(ctx, mediaItemID, userID, dedupKey) + } + if color == "" { + color = "#ffd54f" + } + h.annotationSvc.SaveHighlight(ctx, wsync.SaveHighlightRequest{ - MediaItemID: mediaItemID, - UserID: userID, - SelectionText: hl.Text, - StartPosition: startPos, - EndPosition: endPos, - Color: hl.Color, - NoteText: hl.Notes, - PercentageStart: pctStart, - EpubcfiStart: epubcfiStart, - EpubcfiEnd: epubcfiEnd, - Source: "koreader", - DeviceSyncData: deviceData, + MediaItemID: mediaItemID, + UserID: userID, + SelectionText: hl.Text, + StartPosition: startPos, + EndPosition: endPos, + Color: color, + NoteText: hl.Notes, + PercentageStart: pctStart, + EpubcfiStart: epubcfiStart, + EpubcfiEnd: epubcfiEnd, + Source: "koreader", + DeviceSyncData: deviceData, + DedupKey: dedupKey, }) } for _, note := range book.Notes { startPos := note.Pos0 endPos := note.Pos1 - epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, startPos, endPos) + epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, startPos, endPos, note.Text) pctStart := 0.0 if note.Percentage != nil { pctStart = *note.Percentage } + if pctStart == 0 { + pctStart = h.deriveAnnotationPercentage(ctx, mediaItemID, startPos, int(note.Page)) + } deviceData, _ := json.Marshal(map[string]interface{}{ "datetime": note.Datetime, @@ -533,18 +682,25 @@ func (h *KOReaderHandler) processBookAnnotations(ctx context.Context, deviceID, "page": note.Page, }) + dedupKey := note.DedupKey + if dedupKey == "" { + dedupKey = wsync.ComputeDedupKey(note.Text, epubcfiStart, startPos) + } + h.annotationSvc.SaveHighlight(ctx, wsync.SaveHighlightRequest{ - MediaItemID: mediaItemID, - UserID: userID, - SelectionText: note.Text, - StartPosition: startPos, - EndPosition: endPos, - NoteText: note.Notes, - PercentageStart: pctStart, - EpubcfiStart: epubcfiStart, - EpubcfiEnd: epubcfiEnd, - Source: "koreader", - DeviceSyncData: deviceData, + MediaItemID: mediaItemID, + UserID: userID, + SelectionText: note.Text, + StartPosition: startPos, + EndPosition: endPos, + Color: h.existingHighlightColor(ctx, mediaItemID, userID, dedupKey), + NoteText: note.Notes, + PercentageStart: pctStart, + EpubcfiStart: epubcfiStart, + EpubcfiEnd: epubcfiEnd, + Source: "koreader", + DeviceSyncData: deviceData, + DedupKey: dedupKey, }) } @@ -562,6 +718,11 @@ func (h *KOReaderHandler) processBookAnnotations(ctx context.Context, deviceID, "page": bookmark.Page, }) + dedupKey := bookmark.DedupKey + if dedupKey == "" { + dedupKey = wsync.ComputeDedupKey(bookmark.Text, "", position) + } + h.annotationSvc.SaveBookmark(ctx, wsync.SaveBookmarkRequest{ MediaItemID: mediaItemID, UserID: userID, @@ -570,6 +731,7 @@ func (h *KOReaderHandler) processBookAnnotations(ctx context.Context, deviceID, ChapterNumber: int32(bookmark.Chapter), Source: "koreader", DeviceSyncData: deviceData, + DedupKey: dedupKey, }) } } @@ -809,34 +971,52 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error { for _, ann := range annotations { if ann.AnnotationType == "highlight" { - pos0 := ann.StartPosition.String - pos1 := ann.EndPosition.String - if ann.EpubcfiStart.Valid && ann.EpubcfiStart.String != "" { - if converted := h.reverseConvertCFI(c, mediaItem, ann.EpubcfiStart.String); converted != "" { - pos0 = converted - } + // Selection text doubles as the converter's text-search context. + pos0 := h.koreaderPos0(c, mediaItem, ann.StartPosition.String, ann.EpubcfiStart.String, ann.SelectionText) + pos1 := h.koreaderPos0(c, mediaItem, ann.EndPosition.String, ann.EpubcfiEnd.String, ann.SelectionText) + if pos0 == "" { + // Nothing the device could place — serving a locator it can't + // resolve would create junk bookmarks that re-push as + // duplicates, so skip instead. + log.Printf("Bookhoard: GetMetadata skip highlight %s (no resolvable pos0)", ann.ID) + continue } - if ann.EpubcfiEnd.Valid && ann.EpubcfiEnd.String != "" { - if converted := h.reverseConvertCFI(c, mediaItem, ann.EpubcfiEnd.String); converted != "" { - pos1 = converted - } + // Old web highlights carry no end anchor, and converted range + // CFIs resolve to their start — either way pos1 collapses onto + // pos0 and the device paints a zero-width highlight. Derive the + // end by advancing the start's character offset by the length + // of the selected text. + if pos1 == "" || pos1 == pos0 { + pos1 = extendXPointerByLength(pos0, ann.SelectionText) } highlight := KOReaderHighlight{ - Text: ann.SelectionText, - Pos0: pos0, - Pos1: pos1, - Color: ann.Color.String, + Text: ann.SelectionText, + Pos0: pos0, + Pos1: pos1, + // Web colors flow to the device, mapped to KOReader's named + // palette. Round-trip safety: the device suppresses the color + // when echoing un-edited applied entries (a pink→purple + // palette mismatch must not rewrite the stored hex), and an + // actual device edit pushes its color, which wins. + Color: mapColorToKOReader(ann.Color.String), Datetime: ann.CreatedAt.Time.Format(time.RFC3339), + DedupKey: ann.DedupKey.String, } if ann.NoteText.Valid && ann.NoteText.String != "" { highlight.Notes = ann.NoteText.String } annotationsResponse.Highlights = append(annotationsResponse.Highlights, highlight) } else if ann.AnnotationType == "note" { + pos0 := h.koreaderPos0(c, mediaItem, ann.StartPosition.String, ann.EpubcfiStart.String, "") + if pos0 == "" { + log.Printf("Bookhoard: GetMetadata skip note %s (no resolvable pos0)", ann.ID) + continue + } annotationsResponse.Notes = append(annotationsResponse.Notes, KOReaderNote{ Text: ann.SelectionText, - Pos0: ann.StartPosition.String, + Pos0: pos0, Datetime: ann.CreatedAt.Time.Format(time.RFC3339), + DedupKey: ann.DedupKey.String, }) } } @@ -846,21 +1026,23 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error { UserID: pgUserID, }) for _, bm := range bookmarks { - pos0 := bm.Position.String - if pos0 == "" && bm.CfiPosition.Valid { - pos0 = bm.CfiPosition.String + pos0 := h.koreaderPos0(c, mediaItem, bm.Position.String, bm.CfiPosition.String, "") + if pos0 == "" { + log.Printf("Bookhoard: GetMetadata skip bookmark %s (no resolvable pos0)", bm.ID) + continue } koreaderBookmark := KOReaderBookmark{ Text: bm.Title, Pos0: pos0, Pos1: pos0, Datetime: bm.CreatedAt.Time.Format(time.RFC3339), + DedupKey: bm.DedupKey.String, } if bm.Notes.Valid && bm.Notes.String != "" { koreaderBookmark.Notes = bm.Notes.String } if bm.ChapterNumber.Valid { - koreaderBookmark.Chapter = int(bm.ChapterNumber.Int32) + koreaderBookmark.Chapter = FlexInt(bm.ChapterNumber.Int32) } annotationsResponse.Bookmarks = append(annotationsResponse.Bookmarks, koreaderBookmark) } @@ -880,6 +1062,14 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error { dd = map[string]interface{}{} } dd["dedup_key"] = ts.DedupKey.String + // KOReader deletes by matching pos0. Device-pushed annotations carry + // it in device_sync_data; web-created ones don't (their locator is + // converted at serve time), so resolve it from the stored columns. + if dd["pos0"] == nil || dd["pos0"] == "" { + if pos0 := h.koreaderPos0(c, mediaItem, ts.StartPosition.String, ts.EpubcfiStart.String, ""); pos0 != "" { + dd["pos0"] = pos0 + } + } if ts.AnnotationType == "highlight" { annotationsResponse.DeletedHighlights = append(annotationsResponse.DeletedHighlights, dd) } else if ts.AnnotationType == "bookmark" { @@ -939,7 +1129,7 @@ func (h *KOReaderHandler) convertCFIToXPointer(c *echo.Context, mediaItem databa } } -func (h *KOReaderHandler) reverseConvertCFI(c *echo.Context, mediaItem database.MediaItems, epubcfi string) string { +func (h *KOReaderHandler) reverseConvertCFI(c *echo.Context, mediaItem database.MediaItems, epubcfi string, contextText string) string { if h.libraryService == nil || epubcfi == "" { return "" } @@ -947,13 +1137,138 @@ func (h *KOReaderHandler) reverseConvertCFI(c *echo.Context, mediaItem database. if err != nil || epubPath == "" { return "" } - loc := wsync.ConvertFromCanonical(wsync.LocatorSourceKOReader, epubcfi, 0, "", mediaItem.FormatGroup, epubPath, "") + loc := wsync.ConvertFromCanonical(wsync.LocatorSourceKOReader, epubcfi, 0, contextText, mediaItem.FormatGroup, epubPath, "") if loc.Position != "" && loc.Position != epubcfi { return loc.Position } return "" } +// pdfRectAnchor is the JSON locator the web reader stores in epubcfi_start +// for PDF text highlights (page-fraction rects; page index is 0-based). +type pdfRectAnchor struct { + V int `json:"v"` + Page int `json:"page"` + Rects [][]float64 `json:"rects"` +} + +// koreaderPos0 resolves a device-native KOReader pos0 from an annotation's +// stored locators, whatever the source. Resolution order: +// +// extendXPointerByLength advances a CRE xpointer's trailing text-node +// character offset by the rune length of text, so a highlight with only a +// start anchor still gets a plausible (non-collapsed) end for drawing. +// Overshooting the node just clamps on the device. +func extendXPointerByLength(xp, text string) string { + if xp == "" || text == "" { + return xp + } + i := strings.LastIndex(xp, ".") + if i < 0 { + return xp + } + off, err := strconv.Atoi(xp[i+1:]) + if err != nil { + return xp + } + return xp[:i+1] + strconv.Itoa(off+utf8.RuneCountInString(text)) +} + +// KOReader paints highlight colors from a fixed set of names +// (Blitbuffer.HIGHLIGHT_COLORS); the web reader uses hex swatches. Map at +// the boundary so each side always receives something it can render; +// unmappable values fall back to each side's default (yellow). +var koreaderColorFromName = map[string]string{ + "yellow": "#ffd54f", + "orange": "#ffd54f", + "green": "#a5d6a7", + "olive": "#a5d6a7", + "cyan": "#90caf9", + "blue": "#90caf9", + "purple": "#ce93d8", + "red": "#f48fb1", +} + +// mapColorFromKOReader normalizes a device color name to a web hex +// swatch (default yellow) when ingesting device pushes. +func mapColorFromKOReader(name string) string { + if hex, ok := koreaderColorFromName[strings.ToLower(strings.TrimSpace(name))]; ok { + return hex + } + return "#ffd54f" +} + +var koreaderColorFromHex = map[string]string{ + "#ffd54f": "yellow", + "#a5d6a7": "green", + "#90caf9": "blue", + "#ce93d8": "purple", + "#f48fb1": "purple", +} + +// mapColorToKOReader normalizes a web hex swatch to the nearest KOReader +// color name (default yellow) when serving to devices. Pink maps to purple +// (the palette's closest); round-trip drift is prevented on the device by +// suppressing echo colors for un-edited applied entries. +func mapColorToKOReader(hex string) string { + if name, ok := koreaderColorFromHex[strings.ToLower(strings.TrimSpace(hex))]; ok { + return name + } + return "yellow" +} + +// 1. A device-native CRE xpointer ("/body/...") in startPosition wins — +// round-trip identical for KOReader-pushed annotations (converting the +// stored CFI instead could drift and duplicate on the device). +// 2. The web reader's PDF JSON anchor → bare page number (KOReader paging +// documents use the page number as pos0). +// 3. A stored EPUB CFI (epubcfi_start, or startPosition without the +// reader's "cfi:" prefix) → converted to a CRE xpointer, with +// contextText (the selection text) enabling the text-search fallback. +// 4. A "page:N" or bare-numeric position → the bare number. +// +// Returns "" when nothing usable exists; callers skip such annotations so +// devices never receive locators they cannot place. +func (h *KOReaderHandler) koreaderPos0(c *echo.Context, mediaItem database.MediaItems, startPosition, epubcfi, contextText string) string { + if wsync.IsCREXPointer(startPosition) { + return startPosition + } + if strings.HasPrefix(epubcfi, "{") { + var anchor pdfRectAnchor + if json.Unmarshal([]byte(epubcfi), &anchor) == nil && anchor.Page >= 0 { + return strconv.Itoa(anchor.Page) + } + } + cfi := epubcfi + if cfi == "" && strings.HasPrefix(startPosition, "cfi:") { + cfi = strings.TrimPrefix(startPosition, "cfi:") + } + if cfi != "" && wsync.IsStandardEPUBCFI(cfi) { + if converted := h.reverseConvertCFI(c, mediaItem, cfi, contextText); converted != "" { + return converted + } + // Conversion failed; fall through so numeric positions still work. + if wsync.IsCREXPointer(cfi) { + return cfi + } + } + if p := strings.TrimPrefix(startPosition, "page:"); p != "" && parsePageInt(p) >= 0 { + return p + } + return "" +} + +func parsePageInt(s string) int64 { + var n int64 + for _, r := range s { + if r < '0' || r > '9' { + return -1 + } + n = n*10 + int64(r-'0') + } + return n +} + func (h *KOReaderHandler) GetLibrary(c *echo.Context) error { device := c.Get("device").(database.Devices) userID := device.UserID.Bytes @@ -1195,13 +1510,13 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error { endPos = startPos } - color := "#ffff00" + color := "#ffd54f" if highlight.Color != "" { - color = highlight.Color + color = mapColorFromKOReader(highlight.Color) } if h.annotationSvc != nil { - epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, highlight.Pos0, highlight.Pos1) + epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, highlight.Pos0, highlight.Pos1, highlight.Text) pctStart := 0.0 if highlight.Percentage != nil { diff --git a/internal/sync/annotations.go b/internal/sync/annotations.go index bfd0bf9..4f55373 100644 --- a/internal/sync/annotations.go +++ b/internal/sync/annotations.go @@ -23,8 +23,8 @@ import ( const TombstoneTTL = 30 * 24 * time.Hour type AnnotationService struct { - db *database.Queries - connMgr *ConnectionManager + db *database.Queries + connMgr *ConnectionManager settings *database.SettingsRegistry } @@ -75,6 +75,11 @@ type SaveHighlightRequest struct { Source string ModifiedAt time.Time DeviceSyncData json.RawMessage + // DedupKey overrides the computed key when the client echoes back an + // annotation it received from us (device echoes carry device-native + // locators, so the computed key would never match the original row and + // every pull→push cycle would mint a duplicate). + DedupKey string } type SaveHighlightResult struct { @@ -84,7 +89,10 @@ type SaveHighlightResult struct { } func (s *AnnotationService) SaveHighlight(ctx context.Context, req SaveHighlightRequest) (*SaveHighlightResult, error) { - dedupKey := ComputeDedupKey(req.SelectionText, req.EpubcfiStart, req.StartPosition) + dedupKey := req.DedupKey + if dedupKey == "" { + dedupKey = ComputeDedupKey(req.SelectionText, req.EpubcfiStart, req.StartPosition) + } existing, err := s.db.GetMediaHighlightByDedupKey(ctx, database.GetMediaHighlightByDedupKeyParams{ UserID: req.UserID, @@ -124,22 +132,22 @@ func (s *AnnotationService) createHighlight( deviceData := mergeDeviceSyncData(nil, req.Source, req.DeviceSyncData) highlight, err := s.db.CreateMediaHighlightFull(ctx, database.CreateMediaHighlightFullParams{ - MediaItemID: req.MediaItemID, - UserID: req.UserID, - SelectionText: req.SelectionText, - StartPosition: pgText(req.StartPosition), - EndPosition: pgText(req.EndPosition), - Color: pgText(req.Color), - NoteText: pgText(req.NoteText), - PercentageStart: pgFloat8(req.PercentageStart), - PercentageEnd: pgFloat8(req.PercentageEnd), - EpubcfiStart: pgText(req.EpubcfiStart), - EpubcfiEnd: pgText(req.EpubcfiEnd), - ChapterReference: pgInt4(req.ChapterReference), - DedupKey: pgtype.Text{String: dedupKey, Valid: true}, - LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true}, + MediaItemID: req.MediaItemID, + UserID: req.UserID, + SelectionText: req.SelectionText, + StartPosition: pgText(req.StartPosition), + EndPosition: pgText(req.EndPosition), + Color: pgText(req.Color), + NoteText: pgText(req.NoteText), + PercentageStart: pgFloat8(req.PercentageStart), + PercentageEnd: pgFloat8(req.PercentageEnd), + EpubcfiStart: pgText(req.EpubcfiStart), + EpubcfiEnd: pgText(req.EpubcfiEnd), + ChapterReference: pgInt4(req.ChapterReference), + DedupKey: pgtype.Text{String: dedupKey, Valid: true}, + LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true}, LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""}, - DeviceSyncData: deviceData, + DeviceSyncData: deviceData, }) if err != nil { return nil, fmt.Errorf("create highlight: %w", err) @@ -179,20 +187,20 @@ func (s *AnnotationService) applyLWW( deviceData := mergeDeviceSyncData(existing.DeviceSyncData, req.Source, req.DeviceSyncData) highlight, err := s.db.UpdateMediaHighlightForSync(ctx, database.UpdateMediaHighlightForSyncParams{ - ID: existing.ID, - SelectionText: req.SelectionText, - StartPosition: pgText(req.StartPosition), - EndPosition: pgText(req.EndPosition), - Color: pgText(req.Color), - NoteText: pgText(req.NoteText), - PercentageStart: pgFloat8(req.PercentageStart), - PercentageEnd: pgFloat8(req.PercentageEnd), - EpubcfiStart: pgText(req.EpubcfiStart), - EpubcfiEnd: pgText(req.EpubcfiEnd), - ChapterReference: pgInt4(req.ChapterReference), - LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true}, + ID: existing.ID, + SelectionText: req.SelectionText, + StartPosition: pgText(req.StartPosition), + EndPosition: pgText(req.EndPosition), + Color: pgText(req.Color), + NoteText: pgText(req.NoteText), + PercentageStart: pgFloat8(req.PercentageStart), + PercentageEnd: pgFloat8(req.PercentageEnd), + EpubcfiStart: pgText(req.EpubcfiStart), + EpubcfiEnd: pgText(req.EpubcfiEnd), + ChapterReference: pgInt4(req.ChapterReference), + LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true}, LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""}, - DeviceSyncData: deviceData, + DeviceSyncData: deviceData, }) if err != nil { return nil, fmt.Errorf("update highlight: %w", err) @@ -335,6 +343,7 @@ type SaveNoteRequest struct { Source string ModifiedAt time.Time DeviceSyncData []byte + DedupKey string // overrides the computed key for device echoes } type SaveNoteResult struct { @@ -348,7 +357,10 @@ func (s *AnnotationService) SaveNote(ctx context.Context, req SaveNoteRequest) ( return nil, errors.New("invalid user_id or media_item_id") } - dedupKey := ComputeDedupKey(req.Content, req.EpubcfiLocation, req.Position) + dedupKey := req.DedupKey + if dedupKey == "" { + dedupKey = ComputeDedupKey(req.Content, req.EpubcfiLocation, req.Position) + } existing, err := s.db.GetMediaNoteByDedupKey(ctx, database.GetMediaNoteByDedupKeyParams{ UserID: req.UserID, @@ -485,6 +497,9 @@ type SaveBookmarkRequest struct { Source string ModifiedAt time.Time DeviceSyncData json.RawMessage + // DedupKey overrides the computed key for device echoes (see + // SaveHighlightRequest). + DedupKey string } type SaveBookmarkResult struct { @@ -494,7 +509,10 @@ type SaveBookmarkResult struct { } func (s *AnnotationService) SaveBookmark(ctx context.Context, req SaveBookmarkRequest) (*SaveBookmarkResult, error) { - dedupKey := ComputeDedupKey(req.Title, req.EpubcfiLocation, req.Position) + dedupKey := req.DedupKey + if dedupKey == "" { + dedupKey = ComputeDedupKey(req.Title, req.EpubcfiLocation, req.Position) + } existing, err := s.db.GetMediaBookmarkByDedupKey(ctx, database.GetMediaBookmarkByDedupKeyParams{ UserID: req.UserID, @@ -530,21 +548,21 @@ func (s *AnnotationService) createBookmark(ctx context.Context, req SaveBookmark deviceData := mergeDeviceSyncData(nil, req.Source, req.DeviceSyncData) bm, err := s.db.CreateMediaBookmarkFull(ctx, database.CreateMediaBookmarkFullParams{ - MediaItemID: req.MediaItemID, - UserID: req.UserID, - PageNumber: pgInt4(req.PageNumber), - ChapterNumber: pgInt4(req.ChapterNumber), - CfiPosition: pgText(req.CFIPosition), - Title: req.Title, - Position: pgText(req.Position), - Notes: pgText(req.Notes), + MediaItemID: req.MediaItemID, + UserID: req.UserID, + PageNumber: pgInt4(req.PageNumber), + ChapterNumber: pgInt4(req.ChapterNumber), + CfiPosition: pgText(req.CFIPosition), + Title: req.Title, + Position: pgText(req.Position), + Notes: pgText(req.Notes), PercentageLocation: pgFloat8(req.PercentageLoc), - EpubcfiLocation: pgText(req.EpubcfiLocation), - ChapterReference: pgInt4(req.ChapterReference), - DedupKey: pgtype.Text{String: dedupKey, Valid: true}, - LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true}, + EpubcfiLocation: pgText(req.EpubcfiLocation), + ChapterReference: pgInt4(req.ChapterReference), + DedupKey: pgtype.Text{String: dedupKey, Valid: true}, + LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true}, LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""}, - DeviceSyncData: deviceData, + DeviceSyncData: deviceData, }) if err != nil { return nil, fmt.Errorf("create bookmark: %w", err) @@ -573,19 +591,19 @@ func (s *AnnotationService) applyBookmarkLWW(ctx context.Context, req SaveBookma deviceData := mergeDeviceSyncData(existing.DeviceSyncData, req.Source, req.DeviceSyncData) bm, err := s.db.UpdateMediaBookmarkForSync(ctx, database.UpdateMediaBookmarkForSyncParams{ - ID: existing.ID, - PageNumber: pgInt4(req.PageNumber), - ChapterNumber: pgInt4(req.ChapterNumber), - CfiPosition: pgText(req.CFIPosition), - Title: req.Title, - Position: pgText(req.Position), - Notes: pgText(req.Notes), + ID: existing.ID, + PageNumber: pgInt4(req.PageNumber), + ChapterNumber: pgInt4(req.ChapterNumber), + CfiPosition: pgText(req.CFIPosition), + Title: req.Title, + Position: pgText(req.Position), + Notes: pgText(req.Notes), PercentageLocation: pgFloat8(req.PercentageLoc), - EpubcfiLocation: pgText(req.EpubcfiLocation), - ChapterReference: pgInt4(req.ChapterReference), - LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true}, + EpubcfiLocation: pgText(req.EpubcfiLocation), + ChapterReference: pgInt4(req.ChapterReference), + LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true}, LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""}, - DeviceSyncData: deviceData, + DeviceSyncData: deviceData, }) if err != nil { return nil, fmt.Errorf("update bookmark: %w", err) diff --git a/internal/sync/cfi_converter.go b/internal/sync/cfi_converter.go index cc37237..cf42213 100644 --- a/internal/sync/cfi_converter.go +++ b/internal/sync/cfi_converter.go @@ -11,6 +11,7 @@ import ( "regexp" "strconv" "strings" + "sync" "unicode/utf8" "golang.org/x/net/html" @@ -19,6 +20,9 @@ import ( type CFIConverter struct { epubPath string cache *spineCache + // mu guards the lazily-built spine/doc caches: converter instances are + // shared across concurrent requests via the package cache in locators.go. + mu sync.Mutex } type spineItem struct { @@ -37,6 +41,8 @@ func NewCFIConverter(epubPath string) *CFIConverter { } func (c *CFIConverter) loadSpine() (*spineCache, error) { + c.mu.Lock() + defer c.mu.Unlock() if c.cache != nil { return c.cache, nil } @@ -94,6 +100,8 @@ func (c *CFIConverter) getContentDoc(fragmentIndex int) (*html.Node, string, err item := spine.items[spineIndex] href := item.href + c.mu.Lock() + defer c.mu.Unlock() if cached, ok := spine.docCache[href]; ok { return cached, href, nil } @@ -243,6 +251,46 @@ type ConversionResult struct { Precision string } +// SectionPercentage derives an approximate book-wide percentage for a CRE +// xpointer from the char distribution across the spine: the midpoint of the +// document it points into. Precision is per-section, which is what +// percentage_start is used for (ordering/filtering) — and it lets thin +// clients skip their own per-annotation page lookups entirely. +func (c *CFIConverter) SectionPercentage(xpointer string) float64 { + xp, err := ParseCREXPointer(xpointer) + if err != nil { + return 0 + } + spine, err := c.loadSpine() + if err != nil { + return 0 + } + total := 0 + charCounts := make([]int, len(spine.items)) + for i := range spine.items { + doc, _, docErr := c.getContentDoc(i + 1) + if docErr != nil { + continue + } + if b := findBody(doc); b != nil { + charCounts[i] = countTextChars(b) + total += charCounts[i] + } + } + if total <= 0 { + return 0 + } + idx := xp.FragmentIndex - 1 + if idx < 0 || idx >= len(spine.items) { + return 0 + } + before := 0 + for i := 0; i < idx; i++ { + before += charCounts[i] + } + return (float64(before) + float64(charCounts[idx])/2) / float64(total) +} + func (c *CFIConverter) ConvertCREToStandard(xpointer string, storedPercentage float64, contextText string) (*ConversionResult, error) { if IsCREFragmentID(xpointer) { return c.convertFragmentID(xpointer, storedPercentage) @@ -884,8 +932,8 @@ func readZipFile(zr *zip.Reader, name string) ([]byte, error) { } type opfContainer struct { - XMLName xml.Name `xml:"container"` - RootFiles []opfRoot `xml:"rootfiles>rootfile"` + XMLName xml.Name `xml:"container"` + RootFiles []opfRoot `xml:"rootfiles>rootfile"` } type opfRoot struct { @@ -906,8 +954,8 @@ func extractOPFPath(data []byte) (string, error) { } type xmlPackage struct { - XMLName xml.Name `xml:"package"` - Spine xmlSpine `xml:"spine"` + XMLName xml.Name `xml:"package"` + Spine xmlSpine `xml:"spine"` Manifest xmlManifest `xml:"manifest"` } @@ -1036,9 +1084,9 @@ func preprocessXHTML(input string) string { } type cfiStep struct { - Index int - ID string - Offset int + Index int + ID string + Offset int HasOffset bool } diff --git a/internal/sync/locators.go b/internal/sync/locators.go index 2a800ea..28acdda 100644 --- a/internal/sync/locators.go +++ b/internal/sync/locators.go @@ -1,6 +1,9 @@ package sync -import "log" +import ( + "log" + "sync" +) type LocatorSource string @@ -26,6 +29,35 @@ func isConvertible(formatGroup string) bool { return formatGroup == string(FormatGroupReflowable) } +// Converters parse and cache the whole EPUB (spine + content docs), so +// creating one per annotation re-reads the book for every entry. A small +// bounded cache lets one request — or several — share a single parse. +// Servers are the right place for this work: clients stay thin. +var ( + converterMu sync.Mutex + converterCache = map[string]*CFIConverter{} + converterOrder []string // insertion order for eviction +) + +const maxCachedConverters = 8 + +func cachedConverter(epubPath string) *CFIConverter { + converterMu.Lock() + defer converterMu.Unlock() + if c, ok := converterCache[epubPath]; ok { + return c + } + c := NewCFIConverter(epubPath) + converterCache[epubPath] = c + converterOrder = append(converterOrder, epubPath) + for len(converterOrder) > maxCachedConverters { + oldest := converterOrder[0] + converterOrder = converterOrder[1:] + delete(converterCache, oldest) + } + return c +} + func ConvertToCanonical( source LocatorSource, devicePos string, @@ -48,7 +80,7 @@ func ConvertToCanonical( if !IsCREXPointer(devicePos) { return CanonicalLocator{CFI: devicePos, Precision: "already-standard", Percentage: percentage} } - converter := NewCFIConverter(epubPath) + converter := cachedConverter(epubPath) result, err := converter.ConvertCREToStandard(devicePos, percentage, contextText) if err != nil || result == nil { log.Printf("Bookhoard: locator CRE→CFI conversion failed: %v", err) @@ -101,7 +133,7 @@ func ConvertFromCanonical( switch source { case LocatorSourceKOReader: - converter := NewCFIConverter(epubPath) + converter := cachedConverter(epubPath) result, err := converter.ConvertStandardToCRE(canonicalCFI, percentage, contextText) if err != nil || result == nil { log.Printf("Bookhoard: locator CFI→CRE conversion failed: %v", err) diff --git a/web/src/reader/reader.ts b/web/src/reader/reader.ts index 6eedafd..bcec0e7 100644 --- a/web/src/reader/reader.ts +++ b/web/src/reader/reader.ts @@ -412,6 +412,8 @@ document.addEventListener("alpine:init", () => { note: string; color: string; cfi: string; + cfiEnd: string; + renderCfi: string; percentage: number; pdfPage: number; pdfRects: number[][]; @@ -443,6 +445,7 @@ document.addEventListener("alpine:init", () => { y: 0, text: "", cfi: "", + cfiEnd: "", id: "", color: "#ffd54f", note: "", @@ -701,8 +704,15 @@ document.addEventListener("alpine:init", () => { const text = sel.toString().replace(/\s+/g, " ").trim(); if (!text) return; let cfi: string; + let cfiEnd: string; try { cfi = this.view.getCFI(index, range); + // Collapse to the end point for a distinct end anchor — + // KOReader sync renders the highlight box from pos0/pos1, and + // pos1 == pos0 would be a degenerate (zero-length) range. + const endRange = range.cloneRange(); + endRange.collapse(false); + cfiEnd = this.view.getCFI(index, endRange); } catch { return; } @@ -715,6 +725,7 @@ document.addEventListener("alpine:init", () => { y: (iframeRect?.top ?? 0) + rect.top, text, cfi, + cfiEnd, }); }; doc.addEventListener( @@ -827,6 +838,7 @@ document.addEventListener("alpine:init", () => { y: (iframeRect?.top ?? 0) + rect.top, text: h.text, cfi: h.cfi, + cfiEnd: h.cfiEnd, id: h.id, color: h.color, note: h.note, @@ -1070,6 +1082,7 @@ document.addEventListener("alpine:init", () => { y: number; text: string; cfi: string; + cfiEnd?: string; id?: string; color?: string; note?: string; @@ -1080,6 +1093,7 @@ document.addEventListener("alpine:init", () => { p.mode = opts.mode; p.text = opts.text; p.cfi = opts.cfi; + p.cfiEnd = opts.cfiEnd ?? ""; p.id = opts.id ?? ""; p.color = opts.color || "#ffd54f"; p.note = opts.note ?? ""; @@ -1109,7 +1123,7 @@ document.addEventListener("alpine:init", () => { } else { this.view ?.addAnnotation({ - value: hl.cfi, + value: hl.renderCfi || hl.cfi, color: hl.color, note: hl.note, id: hl.id, @@ -1141,17 +1155,68 @@ document.addEventListener("alpine:init", () => { /* not ours; leave as-is */ } } + const cfiEnd = r.epubcfi_end ?? ""; return { id: r.id, text: r.selection_text ?? "", note: r.note_text ?? "", color: r.color ?? "#ffff00", cfi, + cfiEnd, + // Rendering/navigating anchor: device-synced highlights store + // POINT CFIs (epubcfi(/6/N!/4/2[id]/8/1:1)), which resolve to a + // collapsed range and paint nothing. Foliate's overlayer needs a + // RANGE CFI — same shape getCFI() produces natively + // (epubcfi(/6/N!/4/2[id],/8/1:1,/8/1:67)) — synthesized here from + // the stored start and end points when both share a base path. + renderCfi: this.toRangeCfi(cfi, cfiEnd, r.selection_text ?? ""), percentage: r.percentage_start ?? 0, pdfPage, pdfRects, }; }, + // Build a foliate-renderable RANGE CFI from stored (possibly point) + // CFIs. Repairs two stale shapes using the selection text: a missing + // end (old web highlights), and a degenerate end — the device-push + // converter used to fall back to a document-start CFI when the end + // xpointer didn't resolve exactly. In both cases the end is derived + // from the start offset advanced by the text's UTF-16 length (EPUB + // CFI offsets are UTF-16 code units); multi-node selections just fail + // resolution harmlessly and fall back to the point CFI. + toRangeCfi(start: string, end: string, text: string): string { + if (!start) return end || start; + if (start.includes(",")) return start; // already a range CFI + const re = + /^(epubcfi\(\/\d+\/\d+!\/\d+\/\d+(?:\[[^\]]*\])?)(\/(?:[^:)]+)?(?::(\d+))?)\)$/; + const ms = re.exec(start); + if (!ms) return start; + const base = ms[1]; + const startLocal = ms[2]; + const startOff = ms[3] ? parseInt(ms[3], 10) : -1; + const utf16len = [...(text ?? "")].reduce( + (n, c) => n + (c.codePointAt(0)! > 0xffff ? 2 : 1), + 0, + ); + let endLocal = ""; + if (end && !end.includes(",")) { + const me = re.exec(end); + if (me && me[1] === base) { + const endOff = me[3] ? parseInt(me[3], 10) : -1; + // Degenerate: end resolves to the document start (the old + // converter fallback) or sits before the start offset. + const degenerate = + endOff === 0 || + (startOff >= 0 && endOff >= 0 && endOff < startOff); + if (!degenerate) endLocal = me[2]; + } + } + if (!endLocal) { + if (startOff < 0 || utf16len <= 0) return start; // point CFI + const cut = startLocal.lastIndexOf(":"); + endLocal = `${startLocal.slice(0, cut)}:${startOff + utf16len}`; + } + return `${base},${startLocal},${endLocal})`; + }, async refreshAnnotations() { const token = getToken(); if (!token || !this.mediaItemId) return; @@ -1205,6 +1270,7 @@ document.addEventListener("alpine:init", () => { start_position: "", end_position: "", epubcfi_start: p.pdfPage >= 0 ? pdfAnchor : p.cfi, + epubcfi_end: p.pdfPage >= 0 ? pdfAnchor : p.cfiEnd, color, note_text: "", percentage_start: this.lastRelocateDetail?.fraction ?? 0, @@ -1230,7 +1296,10 @@ document.addEventListener("alpine:init", () => { } } else { this.view?.addAnnotation({ - value: p.cfi, + value: + p.pdfPage >= 0 + ? "" + : this.toRangeCfi(p.cfi, p.cfiEnd, p.text) || p.cfi, color, note: "", id: row.id, @@ -1263,6 +1332,7 @@ document.addEventListener("alpine:init", () => { start_position: "", end_position: "", epubcfi_start: anchor, + epubcfi_end: p.pdfPage >= 0 ? anchor : p.cfiEnd, color: p.color, note_text: p.note, }), @@ -1282,7 +1352,7 @@ document.addEventListener("alpine:init", () => { }); } else { this.view?.addAnnotation({ - value: p.cfi, + value: this.toRangeCfi(p.cfi, p.cfiEnd, p.text) || p.cfi, color: p.color, note: p.note, id: p.id, @@ -1324,6 +1394,7 @@ document.addEventListener("alpine:init", () => { }, goToHighlight(hl: { cfi: string; + renderCfi: string; pdfPage: number; }) { if (hl.pdfPage >= 0) { @@ -1331,9 +1402,11 @@ document.addEventListener("alpine:init", () => { this.pushBackStack(); this.view?.goTo?.(hl.pdfPage); this.closeDrawers(); - } else if (hl.cfi) { + } else if (hl.renderCfi || hl.cfi) { this.pushBackStack(); - this.view?.showAnnotation({ value: hl.cfi })?.catch?.(() => {}); + this.view + ?.showAnnotation({ value: hl.renderCfi || hl.cfi }) + ?.catch?.(() => {}); this.closeDrawers(); } },