fix(highlights): web edits update the targeted row instead of minting duplicates

PUT /highlights/:id parsed the row id from the URL and then dropped it:
the sync-aware path routed through SaveHighlight's content-derived dedup
key, on the assumption that the same text + CFI always resolves to the
same key. That assumption breaks in practice — the stored epubcfi_start
drifts from foliate's range shape to the converter's point shape after a
device echo rewrites the row (bucketPosition cuts at the last colon, so
'…/6,/1:367,…' and '…/6/1:367' bucket differently), and the user can
edit the selection text. The recomputed key then misses the row being
edited and createHighlight mints a second one: the edited row (with
note, no device pos0) beside the original — served to KOReader as two
highlights, one noted and one not. Editing a selection's text would hit
the same trap.

SaveHighlightRequest gains an optional HighlightID. When set, the save
resolves the row by id (ownership-checked), LWWs against it under its
stored dedup key, and never re-derives identity from content. The PUT
handler passes the already-parsed id. Device pushes, Kobo, and the sync
queue send no id and keep the identity-based flow untouched.

applyLWW also stops wiping stored locators on web edits: the web reader
sends empty start/end positions (it never had a CRE xpointer), so a
note/color edit now keeps the device-native positions and CFIs instead
of blanking them — round-trip serve-back for device-created highlights
survives web-side edits.
This commit is contained in:
2026-09-09 14:12:18 -04:00
parent 905218dd4b
commit 75c1d9bb95
2 changed files with 81 additions and 19 deletions
+5
View File
@@ -1656,6 +1656,11 @@ func (mh *MediaHandler) UpdateMediaHighlight(c *echo.Context) error {
ChapterReference: req.ChapterReference,
Source: "web",
ModifiedAt: time.Now(),
// The PUT targets this exact row (from the URL): identity must
// not be re-derived from content — a device echo has usually
// rewritten the stored CFI to point shape, so the computed key
// would miss and mint a duplicate beside the edited row.
HighlightID: pgtype.UUID{Bytes: highlightUUID, Valid: true},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})