From 0670d904a0f4ad9af8c0532b7c5920357bbf043d Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Tue, 18 Aug 2026 19:13:23 -0400 Subject: [PATCH 1/8] feat(db): locator columns for tombstoned annotations GetTombstonedAnnotationsForBook now also returns each tombstone's start_position/end_position and epubcfi_start/end (note: position/ epubcfi_location, bookmark: position/cfi_position), so serving code can resolve a device-native locator for deletions of web-created annotations, whose device_sync_data carries no pos0. --- internal/database/queries.sql.go | 26 +++++++++++++++++++++++--- internal/database/queries/queries.sql | 18 +++++++++++++++--- 2 files changed, 38 insertions(+), 6 deletions(-) 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; From f6e257e4975b493d7730462bfd9ea7ec3d1d1403 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Tue, 18 Aug 2026 19:13:38 -0400 Subject: [PATCH 2/8] =?UTF-8?q?fix(sync):=20web=20annotations=20never=20re?= =?UTF-8?q?ached=20KOReader=20=E2=80=94=20bind=20400s=20+=20unresolvable?= =?UTF-8?q?=20locators?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two blockers, diagnosed by simulating the plugin against the live server with real library books: 1. Every KOReader progress push carrying annotations failed the JSON bind with 400 ('cannot unmarshal string into ... chapter/page of type int') — the plugin sends chapter:'', page:'30', and for CRE documents page:'/body/...' — so annotation sync AND progress sync failed together. KOReader annotation chapter/page now use FlexInt, which accepts numbers, numeric strings, empty strings, and non-numeric strings (decoding to 0). The server is deliberately liberal here so thin clients can send raw bookmark data. 2. GetMetadata served locators KOReader cannot place, so pulled items were junk: web bookmarks leaked 'cfi:epubcfi(...)' positions, web PDF highlights had empty pos0 (skipped by the plugin, invisible), and web deletions carried no pos0 so tombstones never matched. New koreaderPos0 resolver handles every source: device-native xpointers pass through untouched (round-trip identical, verified), web PDF JSON anchors map to their page number, EPUB CFIs convert to CRE xpointers (selection text passed as text-search context for exact anchoring), 'page:N' positions strip to the bare number. Unresolvable annotations are skipped with a log line instead of poisoning devices; tombstones get pos0 injected from the new locator columns. Also: thin clients omit per-annotation percentages (paging docs still send arithmetic page/total); the server derives them — section midpoint from the spine char distribution for CRE documents, page/ page-count for fixed formats. --- internal/handlers/koreader.go | 203 +++++++++++++++++++++++++++++----- 1 file changed, 174 insertions(+), 29 deletions(-) diff --git a/internal/handlers/koreader.go b/internal/handlers/koreader.go index d02180f..2ab6b6b 100644 --- a/internal/handlers/koreader.go +++ b/internal/handlers/koreader.go @@ -9,6 +9,8 @@ import ( "fmt" "log" "net/http" + "strconv" + "strings" "time" "github.com/google/uuid" @@ -47,7 +49,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,11 +61,37 @@ 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 } +// 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) { h.libraryService = svc } @@ -100,13 +128,48 @@ 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"` @@ -114,12 +177,12 @@ type KOReaderBookmark struct { } 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"` @@ -128,12 +191,12 @@ type KOReaderHighlight struct { } 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"` @@ -486,12 +549,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, @@ -519,12 +586,15 @@ func (h *KOReaderHandler) processBookAnnotations(ctx context.Context, deviceID, for _, note := range book.Notes { startPos := note.Pos0 endPos := note.Pos1 - epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, startPos, endPos) + 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, @@ -809,17 +879,15 @@ 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 - } - } - if ann.EpubcfiEnd.Valid && ann.EpubcfiEnd.String != "" { - if converted := h.reverseConvertCFI(c, mediaItem, ann.EpubcfiEnd.String); converted != "" { - pos1 = 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 } highlight := KOReaderHighlight{ Text: ann.SelectionText, @@ -833,9 +901,14 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error { } 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), }) } @@ -846,9 +919,10 @@ 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, @@ -860,7 +934,7 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error { 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 +954,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 +1021,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 +1029,76 @@ 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: +// +// 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 @@ -1201,7 +1346,7 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error { } 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 { From 1585aa1073752c234231370fd18e506bf8b06fb4 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Tue, 18 Aug 2026 19:13:51 -0400 Subject: [PATCH 3/8] perf(sync): share parsed EPUBs across conversions, make converters concurrency-safe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ConvertToCanonical/ConvertFromCanonical built a fresh CFIConverter per call, and each annotation converts twice (pos0+pos1) — a book with 200 highlights re-opened and re-parsed the EPUB 400+ times per sync, and again per metadata pull. A bounded 8-entry cache keyed by path now shares converters (the parsing work belongs on the server; clients stay thin). CFIConverter gained a mutex around its lazily built spine/doc caches since instances are now shared between concurrent requests. Adds CFIConverter.SectionPercentage: book-wide percentage for a CRE xpointer from the spine char distribution (midpoint of its document) — the server-side counterpart to dropping per-annotation getPageFromXPointer lookups from the plugin. --- internal/sync/cfi_converter.go | 62 ++++++++++++++++++++++++++++++---- internal/sync/locators.go | 38 +++++++++++++++++++-- 2 files changed, 90 insertions(+), 10 deletions(-) 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) From 97e546b2a4cd35e0d1ed8978fc602bc162264f58 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Tue, 18 Aug 2026 19:13:51 -0400 Subject: [PATCH 4/8] feat(reader): send end-anchor CFI for EPUB highlights Web highlights stored only epubcfi_start, so devices received degenerate pos0 == pos1 (zero-length) highlight ranges. The reader now collapses the selection range to its end point for a second CFI and stores it as epubcfi_end (PDF rect anchors reuse the JSON anchor for both ends). --- web/src/reader/reader.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/web/src/reader/reader.ts b/web/src/reader/reader.ts index 6eedafd..4c94e29 100644 --- a/web/src/reader/reader.ts +++ b/web/src/reader/reader.ts @@ -412,6 +412,7 @@ document.addEventListener("alpine:init", () => { note: string; color: string; cfi: string; + cfiEnd: string; percentage: number; pdfPage: number; pdfRects: number[][]; @@ -443,6 +444,7 @@ document.addEventListener("alpine:init", () => { y: 0, text: "", cfi: "", + cfiEnd: "", id: "", color: "#ffd54f", note: "", @@ -701,8 +703,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 +724,7 @@ document.addEventListener("alpine:init", () => { y: (iframeRect?.top ?? 0) + rect.top, text, cfi, + cfiEnd, }); }; doc.addEventListener( @@ -827,6 +837,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 +1081,7 @@ document.addEventListener("alpine:init", () => { y: number; text: string; cfi: string; + cfiEnd?: string; id?: string; color?: string; note?: string; @@ -1080,6 +1092,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 ?? ""; @@ -1147,6 +1160,7 @@ document.addEventListener("alpine:init", () => { note: r.note_text ?? "", color: r.color ?? "#ffff00", cfi, + cfiEnd: r.epubcfi_end ?? "", percentage: r.percentage_start ?? 0, pdfPage, pdfRects, @@ -1205,6 +1219,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, @@ -1263,6 +1278,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, }), From 6e9b3528d85756a7de62b4e83b046f45ed5c3342 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Wed, 19 Aug 2026 14:08:03 -0400 Subject: [PATCH 5/8] =?UTF-8?q?fix(sync):=20synced=20highlights=20painted?= =?UTF-8?q?=20nowhere=20=E2=80=94=20degenerate=20range=20ends=20+=20color?= =?UTF-8?q?=20model=20mismatch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both directions synced data but rendered nothing: - Web reader <- devices: highlights painted no overlay. Device pushes resolve their start xpointer exactly (text-search anchored by the selection) but the end conversion carries no context and fell back to a document-start CFI (epubcfi .../1:0) — a garbage range end. When the start resolved exactly, the end is now derived from it: same node, character offset advanced by the selection's UTF-16 length (extendCFIByLength). Same repair when SERVING to devices, where old web highlights (no end anchor) and converted range CFIs both collapsed pos1 onto pos0 (extendXPointerByLength on the xpointer form) — KOReader drew zero-width highlights. - Colors: KOReader paints from a fixed name set (Blitbuffer HIGHLIGHT_COLORS), the web uses hex swatches; neither understood the other, so device colors fell back to defaults and web hex drew nothing useful on devices. Both boundaries now translate: ingest maps names to hex (default #ffd54f), GetMetadata maps hex to names (default yellow) — per-datatype edits re-push with the editing side's color, which LWW then propagates. SyncBookmarks endpoint aligned to the same mapping and default. --- internal/handlers/koreader.go | 231 ++++++++++++++++++++++++---------- 1 file changed, 168 insertions(+), 63 deletions(-) diff --git a/internal/handlers/koreader.go b/internal/handlers/koreader.go index 2ab6b6b..b41d0e5 100644 --- a/internal/handlers/koreader.go +++ b/internal/handlers/koreader.go @@ -12,6 +12,7 @@ import ( "strconv" "strings" "time" + "unicode/utf8" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" @@ -66,7 +67,43 @@ func (h *KOReaderHandler) convertHighlightPositions(ctx context.Context, mediaIt // 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) + ")" } // deriveAnnotationPercentage computes a percentage for device-pushed @@ -103,24 +140,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 { @@ -204,12 +241,12 @@ type KOReaderNote struct { } 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 { @@ -237,20 +274,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"` } @@ -568,18 +605,18 @@ func (h *KOReaderHandler) processBookAnnotations(ctx context.Context, deviceID, }) 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: mapColorFromKOReader(hl.Color), + NoteText: hl.Notes, + PercentageStart: pctStart, + EpubcfiStart: epubcfiStart, + EpubcfiEnd: epubcfiEnd, + Source: "koreader", + DeviceSyncData: deviceData, }) } @@ -604,17 +641,17 @@ func (h *KOReaderHandler) processBookAnnotations(ctx context.Context, deviceID, }) 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, + NoteText: note.Notes, + PercentageStart: pctStart, + EpubcfiStart: epubcfiStart, + EpubcfiEnd: epubcfiEnd, + Source: "koreader", + DeviceSyncData: deviceData, }) } @@ -889,11 +926,19 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error { log.Printf("Bookhoard: GetMetadata skip highlight %s (no resolvable pos0)", ann.ID) continue } + // 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, + Color: mapColorToKOReader(ann.Color.String), Datetime: ann.CreatedAt.Time.Format(time.RFC3339), } if ann.NoteText.Valid && ann.NoteText.String != "" { @@ -1039,14 +1084,74 @@ func (h *KOReaderHandler) reverseConvertCFI(c *echo.Context, mediaItem database. // 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"` + 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", +} + +var koreaderColorFromHex = map[string]string{ + "#ffd54f": "yellow", + "#a5d6a7": "green", + "#90caf9": "blue", + "#ce93d8": "purple", + "#f48fb1": "purple", +} + +// 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" +} + +// mapColorToKOReader normalizes a web hex swatch to a KOReader color +// name (default yellow) when serving to devices. +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). @@ -1340,9 +1445,9 @@ 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 { From 50ec2bebf205d6e3288335e23704ae12a2b7ea92 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Wed, 19 Aug 2026 14:08:03 -0400 Subject: [PATCH 6/8] =?UTF-8?q?fix(reader):=20render=20device-synced=20hig?= =?UTF-8?q?hlights=20=E2=80=94=20synthesize=20range=20CFIs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Device-synced highlights stored POINT CFIs (epubcfi(.../8/1:1)); the overlayer resolves those to a collapsed range and paints nothing, so KOReader-made highlights were listed in the drawer but invisible on the page. mapHighlightRow now builds a renderCfi: a proper RANGE CFI (epubcfi(base,/start,/end)) synthesized from the stored start/end points. It also repairs stale rows: missing ends (old web highlights) and degenerate document-start ends (the old converter fallback) are derived from the start offset plus the selection text's UTF-16 length. All overlay drawing, navigation (showAnnotation), and the post-create/post-edit re-adds use renderCfi. Verified in-browser against live device-synced rows: the paginator's overlayer paints the highlight rects after the fix. --- web/src/reader/reader.ts | 69 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 63 insertions(+), 6 deletions(-) diff --git a/web/src/reader/reader.ts b/web/src/reader/reader.ts index 4c94e29..bcec0e7 100644 --- a/web/src/reader/reader.ts +++ b/web/src/reader/reader.ts @@ -413,6 +413,7 @@ document.addEventListener("alpine:init", () => { color: string; cfi: string; cfiEnd: string; + renderCfi: string; percentage: number; pdfPage: number; pdfRects: number[][]; @@ -1122,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, @@ -1154,18 +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: r.epubcfi_end ?? "", + 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; @@ -1245,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, @@ -1298,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, @@ -1340,6 +1394,7 @@ document.addEventListener("alpine:init", () => { }, goToHighlight(hl: { cfi: string; + renderCfi: string; pdfPage: number; }) { if (hl.pdfPage >= 0) { @@ -1347,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(); } }, From dafcadd211399c634a05fcbf614a045ce15e9eb6 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Wed, 19 Aug 2026 19:41:39 -0400 Subject: [PATCH 7/8] fix(sync): echo dedup + color semantics + classification for KOReader round-trips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Echo duplication: devices push their full annotation list on every sync, and an echo of a web-created annotation computed a different dedup key than the original (device locators differ from web locators) — every pull→push cycle minted a duplicate row, and cleaning those up on the web tombstoned them back to the device, deleting the just-applied copies. That was the "web highlights never appear on KOReader" experience. GetMetadata now serves each annotation's dedup_key; the device stores it on the applied entry and echoes it in pushes; SaveHighlight/SaveBookmark/SaveNote accept a DedupKey override so echoes converge onto the original row (verified: pull → echo push creates no rows, LWW skips identical content). Color semantics (per user preference): devices render their own default and cannot round-trip web colors, so GetMetadata no longer serves colors at all — every highlight syncs regardless of its web color and the device draws its default. An echo carries no color; ingest then PRESERVES the stored web color (existingHighlightColor lookup by dedup key) so round-trips never change it. A non-empty device color means the user edited the highlight there: it maps name→hex (green→#a5d6a7, default yellow) and wins. Verified: echo kept #ffd54f; a simulated device edit with "green" updated the web row to #a5d6a7. Classification: KOReader auto-fills text="in Chapter X" on page bookmarks (ReaderAnnotation:updateItemByXPointer), so the plugin's text-presence classification turned every echoed bookmark into a junk highlight on the web. v2 classification now keys off the drawer field (present = highlight/note, absent = bookmark with its label in note). --- internal/handlers/koreader.go | 88 +++++++++++++++++------ internal/sync/annotations.go | 132 +++++++++++++++++++--------------- 2 files changed, 141 insertions(+), 79 deletions(-) diff --git a/internal/handlers/koreader.go b/internal/handlers/koreader.go index b41d0e5..cb16c72 100644 --- a/internal/handlers/koreader.go +++ b/internal/handlers/koreader.go @@ -106,6 +106,24 @@ func extendCFIByLength(cfi, text string) string { 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). @@ -211,6 +229,7 @@ type KOReaderBookmark struct { 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 { @@ -225,6 +244,7 @@ type KOReaderHighlight struct { 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 { @@ -238,6 +258,7 @@ type KOReaderNote struct { 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 { @@ -604,19 +625,40 @@ 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: mapColorFromKOReader(hl.Color), + Color: color, NoteText: hl.Notes, PercentageStart: pctStart, EpubcfiStart: epubcfiStart, EpubcfiEnd: epubcfiEnd, Source: "koreader", DeviceSyncData: deviceData, + DedupKey: dedupKey, }) } @@ -640,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, + Color: h.existingHighlightColor(ctx, mediaItemID, userID, dedupKey), NoteText: note.Notes, PercentageStart: pctStart, EpubcfiStart: epubcfiStart, EpubcfiEnd: epubcfiEnd, Source: "koreader", DeviceSyncData: deviceData, + DedupKey: dedupKey, }) } @@ -669,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, @@ -677,6 +731,7 @@ func (h *KOReaderHandler) processBookAnnotations(ctx context.Context, deviceID, ChapterNumber: int32(bookmark.Chapter), Source: "koreader", DeviceSyncData: deviceData, + DedupKey: dedupKey, }) } } @@ -935,11 +990,15 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error { pos1 = extendXPointerByLength(pos0, ann.SelectionText) } highlight := KOReaderHighlight{ - Text: ann.SelectionText, - Pos0: pos0, - Pos1: pos1, - Color: mapColorToKOReader(ann.Color.String), + Text: ann.SelectionText, + Pos0: pos0, + Pos1: pos1, + // No color served: devices render their own default and + // cannot round-trip web colors — the web color only changes + // when the highlight is edited on the device (push carries + // the device color, ingested with the name→hex map). Datetime: ann.CreatedAt.Time.Format(time.RFC3339), + DedupKey: ann.DedupKey.String, } if ann.NoteText.Valid && ann.NoteText.String != "" { highlight.Notes = ann.NoteText.String @@ -955,6 +1014,7 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error { Text: ann.SelectionText, Pos0: pos0, Datetime: ann.CreatedAt.Time.Format(time.RFC3339), + DedupKey: ann.DedupKey.String, }) } } @@ -974,6 +1034,7 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error { 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 @@ -1126,14 +1187,6 @@ var koreaderColorFromName = map[string]string{ "red": "#f48fb1", } -var koreaderColorFromHex = map[string]string{ - "#ffd54f": "yellow", - "#a5d6a7": "green", - "#90caf9": "blue", - "#ce93d8": "purple", - "#f48fb1": "purple", -} - // mapColorFromKOReader normalizes a device color name to a web hex // swatch (default yellow) when ingesting device pushes. func mapColorFromKOReader(name string) string { @@ -1143,15 +1196,6 @@ func mapColorFromKOReader(name string) string { return "#ffd54f" } -// mapColorToKOReader normalizes a web hex swatch to a KOReader color -// name (default yellow) when serving to devices. -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). 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) From 178fb2eb37da51d39003af3df59f6c7e0c272ce7 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Thu, 20 Aug 2026 08:43:21 -0400 Subject: [PATCH 8/8] feat(sync): serve web highlight colors to KOReader (mapped to its palette) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverses the earlier "no colors to the device" decision now that the echo machinery makes it safe: GetMetadata maps the stored web hex to KOReader's fixed color names (#ce93d8→purple, #90caf9→blue, #a5d6a7→green, #ffd54f→yellow; pink maps to purple as the closest — round-trip drift is prevented on the device by echo suppression, and a device edit still wins). mapColorToKOReader restored for serving; ingest (name→hex, preserve-on-echo) unchanged. --- internal/handlers/koreader.go | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/internal/handlers/koreader.go b/internal/handlers/koreader.go index cb16c72..9771a66 100644 --- a/internal/handlers/koreader.go +++ b/internal/handlers/koreader.go @@ -993,10 +993,12 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error { Text: ann.SelectionText, Pos0: pos0, Pos1: pos1, - // No color served: devices render their own default and - // cannot round-trip web colors — the web color only changes - // when the highlight is edited on the device (push carries - // the device color, ingested with the name→hex map). + // 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, } @@ -1196,6 +1198,25 @@ func mapColorFromKOReader(name string) string { 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).