Support KOReader's v2 annotation model (2024.07+) — sync was collecting nothing

KOReader replaced the bookmark store: since 2024.07, highlights/notes/
bookmarks live in ui.annotation.annotations (sidecar key 'annotations')
with new field semantics — text = highlighted text, note = user note,
drawer/color styling, pos0/pos1 as xpointers (CRE) or {page=N} tables
(pdf). ui.bookmark.bookmarks no longer exists, so collectAnnotations
returned empty and applyServerAnnotations bailed on its first guard:
annotations NEVER crossed in either direction on current KOReader
(reproduced: device log showed highlights:[] with a highlight present,
and the book's sidecar showed annotations={} with no bookmarks key).

Both functions are now dual-model via getAnnotationStore():
- collect: v2 entries normalized to the wire format (text/note from
  the new fields, pdf table positions stringified to their page,
  classification text+note=note / text=highlight / else=bookmark with
  the label traveling in the entry text, as before); v1 mapping
  corrected to KOReader's own migration semantics (v1 notes held the
  highlighted text, text held the note).
- apply: v2 entries are built with drawer='lighten' for highlights
  (its absence marks a page bookmark), pdf positions as {page=N},
  and inserted via ui.annotation:addItem() — the module's supported
  API (sorted insertion, datetime/pageno defaults). v1 path preserved
  for older devices. Tombstone removal works on either array; the
  v1-only onSortBookmarks call is now guarded.

Verified both models in the stub harness against the live server:
v2 push collects the device highlight; v2 pull lands server
highlights (with notes), and bookmarks with correct field shapes;
v1 roundtrip unchanged.
This commit is contained in:
2026-08-19 08:24:09 -04:00
parent 884c570424
commit e79857fade
+124 -39
View File
@@ -835,24 +835,57 @@ function Bookhoard:collectBookData()
return book_data return book_data
end end
-- KOReader stores annotations in one of two models depending on version:
-- v2 (2024.07+): self.ui.annotation.annotations; entries carry
-- text = highlighted text, note = user note, drawer/color style,
-- pos0/pos1 = xpointer (CRE) or {page=N} table (pdf), page = xpointer
-- (CRE) or number (pdf). Sidecar key "annotations".
-- v1 (older): self.ui.bookmark.bookmarks; entries carry text = note
-- label / bookmark title, notes = highlighted text, pos0/pos1 strings.
-- Returns "v2"/"v1"/nil and the entries array.
function Bookhoard:getAnnotationStore()
if self.ui.annotation and type(self.ui.annotation.annotations) == "table" then
return "v2", self.ui.annotation.annotations
end
if self.ui.bookmark and type(self.ui.bookmark.bookmarks) == "table" then
return "v1", self.ui.bookmark.bookmarks
end
return nil, nil
end
function Bookhoard:collectAnnotations() function Bookhoard:collectAnnotations()
local bookmarks = json_util.InitArray({}) local bookmarks = json_util.InitArray({})
local highlights = json_util.InitArray({}) local highlights = json_util.InitArray({})
local notes = json_util.InitArray({}) local notes = json_util.InitArray({})
if not self.ui.bookmark then local model, entries = self:getAnnotationStore()
if not model then
return bookmarks, highlights, notes return bookmarks, highlights, notes
end end
local all_bookmarks = self.ui.bookmark.bookmarks
if not all_bookmarks then
return bookmarks, highlights, notes
end
local file_sha256 = self:getFileSHA256() local file_sha256 = self:getFileSHA256()
local total_pages = self.ui.document:getPageCount() local total_pages = self.ui.document:getPageCount()
local has_pages = self.ui.document.info.has_pages local has_pages = self.ui.document.info.has_pages
for _, bm in ipairs(all_bookmarks) do local function wirePos(p, fallback_page)
-- v2 pdf positions are tables {page=N,x,y}; the wire format is a
-- string — the page number is the durable locator there.
if type(p) == "table" then
return tostring(p.page or fallback_page or "")
end
return p or ""
end
for _, bm in ipairs(entries) do
local sel_text, user_note
if model == "v2" then
sel_text = bm.text or "" -- highlighted text
user_note = bm.note or "" -- user note / bookmark label
else
sel_text = bm.notes or "" -- v1: notes held the highlighted text
user_note = bm.text or "" -- v1: text held the note/label
end
-- Thin-client policy: no per-annotation CRE lookups here (a -- Thin-client policy: no per-annotation CRE lookups here (a
-- getPageFromXPointer call each is the most expensive thing this -- getPageFromXPointer call each is the most expensive thing this
-- loop can do on weak hardware). Percentages are arithmetic for -- loop can do on weak hardware). Percentages are arithmetic for
@@ -867,19 +900,19 @@ function Bookhoard:collectAnnotations()
local entry = { local entry = {
chapter = chapter, chapter = chapter,
datetime = bm.datetime or "", datetime = bm.datetime or "",
notes = bm.notes or "", notes = user_note,
pos0 = bm.pos0 or "", pos0 = wirePos(bm.pos0, bm.page),
pos1 = bm.pos1 or "", pos1 = wirePos(bm.pos1, bm.page),
page = tostring(bm.page or ""), page = tostring(bm.page or ""),
text = bm.text or "", text = sel_text,
book_sha256 = file_sha256, book_sha256 = file_sha256,
} }
if percentage then if percentage then
entry.percentage = Math.roundPercent(percentage) entry.percentage = Math.roundPercent(percentage)
end end
local has_text = bm.text and bm.text ~= "" local has_text = sel_text ~= ""
local has_notes = bm.notes and bm.notes ~= "" local has_notes = user_note ~= ""
if has_text and has_notes then if has_text and has_notes then
entry.type = "note" entry.type = "note"
@@ -892,6 +925,7 @@ function Bookhoard:collectAnnotations()
table.insert(highlights, entry) table.insert(highlights, entry)
else else
entry.type = "bookmark" entry.type = "bookmark"
entry.text = user_note -- bookmark label travels in `text`
table.insert(bookmarks, entry) table.insert(bookmarks, entry)
end end
end end
@@ -1161,30 +1195,40 @@ function Bookhoard:_doGetProgress(interactive)
end end
function Bookhoard:applyServerAnnotations(annotations) function Bookhoard:applyServerAnnotations(annotations)
if not self.ui.bookmark or not self.ui.bookmark.bookmarks then
return
end
if not annotations then return end if not annotations then return end
local model, entries = self:getAnnotationStore()
if not model then return end
local local_bms = self.ui.bookmark.bookmarks
local changed = false local changed = false
local has_pages = self.ui.document.info.has_pages local has_pages = self.ui.document.info.has_pages
local function findLocalByPos0(pos0) local function findLocalByPos0(pos0)
for i, bm in ipairs(local_bms) do for i, bm in ipairs(entries) do
if bm.pos0 == pos0 then local p = bm.pos0
if type(p) == "table" then p = tostring(p.page or "") end
if p == pos0 then
return i return i
end end
end end
return nil return nil
end end
local function makePageFromPos(pos0) -- Get/set the highlighted text and the user note with model-appropriate
if has_pages then -- field names (v2: text/note; v1: notes/text).
local page = tonumber(pos0:match("(%d+)$")) local function getFields(bm)
return page or pos0 if model == "v2" then
return bm.text or "", bm.note or ""
end
return bm.notes or "", bm.text or ""
end
local function setFields(bm, sel_text, user_note)
if model == "v2" then
bm.text = sel_text ~= "" and sel_text or nil
bm.note = user_note ~= "" and user_note or nil
else
bm.notes = sel_text
bm.text = user_note
end end
return pos0
end end
local function isValidPos0(pos0) local function isValidPos0(pos0)
@@ -1201,32 +1245,69 @@ function Bookhoard:applyServerAnnotations(annotations)
return pos0:sub(1, 6) == "/body/" return pos0:sub(1, 6) == "/body/"
end end
local function makePageFromPos(pos0)
if has_pages then
local page = tonumber(pos0:match("(%d+)$"))
return page or pos0
end
return pos0
end
-- v2 pdf positions are {page=N} tables; CRE keeps xpointer strings.
local function makeLocalPos(pos)
if has_pages then
local page = tonumber(pos)
return page and { page = page } or nil
end
return pos
end
local function addOrUpdate(server_entry, has_text) local function addOrUpdate(server_entry, has_text)
local pos0 = server_entry.pos0 or "" local pos0 = server_entry.pos0 or ""
if not isValidPos0(pos0) then return end if not isValidPos0(pos0) then return end
local idx = findLocalByPos0(pos0)
if idx then
local bm = local_bms[idx]
local srv_text = server_entry.text or "" local srv_text = server_entry.text or ""
local srv_notes = server_entry.notes or "" local srv_notes = server_entry.notes or ""
if (bm.text or "") ~= srv_text or (bm.notes or "") ~= srv_notes then local idx = findLocalByPos0(pos0)
bm.text = srv_text if idx then
bm.notes = srv_notes local bm = entries[idx]
if server_entry.color then bm.color = server_entry.color end local cur_text, cur_note = getFields(bm)
if cur_text ~= srv_text or cur_note ~= srv_notes then
setFields(bm, srv_text, srv_notes)
if server_entry.color and server_entry.color ~= "" then bm.color = server_entry.color end
changed = true changed = true
end end
elseif model == "v2" then
-- `drawer` marks a renderable highlight in v2; its absence
-- makes the entry a page bookmark (label goes in `note`).
local entry = {
datetime = server_entry.datetime ~= "" and server_entry.datetime or nil,
chapter = server_entry.chapter ~= "" and server_entry.chapter or nil,
pos0 = makeLocalPos(pos0),
pos1 = makeLocalPos(server_entry.pos1 ~= "" and server_entry.pos1 or pos0),
page = makePageFromPos(pos0),
}
if has_text then
entry.drawer = "lighten"
entry.text = srv_text
if srv_notes ~= "" then entry.note = srv_notes end
if server_entry.color and server_entry.color ~= "" then entry.color = server_entry.color end
else
if srv_text ~= "" then entry.note = srv_text end -- bookmark label
end
self.ui.annotation:addItem(entry)
changed = true
else else
local entry = { local entry = {
page = makePageFromPos(pos0), page = makePageFromPos(pos0),
pos0 = pos0, pos0 = pos0,
pos1 = server_entry.pos1 or pos0, pos1 = server_entry.pos1 or pos0,
datetime = server_entry.datetime or "", datetime = server_entry.datetime or "",
text = server_entry.text or "", notes = srv_text,
notes = server_entry.notes or "", text = srv_notes,
} }
if server_entry.color then entry.color = server_entry.color end if server_entry.color and server_entry.color ~= "" then entry.color = server_entry.color end
if server_entry.chapter then entry.chapter = server_entry.chapter end if server_entry.chapter and server_entry.chapter ~= "" then entry.chapter = server_entry.chapter end
table.insert(local_bms, entry) table.insert(entries, entry)
changed = true changed = true
end end
end end
@@ -1254,8 +1335,10 @@ function Bookhoard:applyServerAnnotations(annotations)
local pos0 = del.pos0 or "" local pos0 = del.pos0 or ""
if pos0 ~= "" then if pos0 ~= "" then
local idx = findLocalByPos0(pos0) local idx = findLocalByPos0(pos0)
if idx and (local_bms[idx].text or "") ~= "" then local bm = idx and entries[idx]
table.remove(local_bms, idx) local cur_text = bm and (getFields(bm)) or ""
if idx and cur_text ~= "" then
table.remove(entries, idx)
changed = true changed = true
end end
end end
@@ -1268,7 +1351,7 @@ function Bookhoard:applyServerAnnotations(annotations)
if pos0 ~= "" then if pos0 ~= "" then
local idx = findLocalByPos0(pos0) local idx = findLocalByPos0(pos0)
if idx then if idx then
table.remove(local_bms, idx) table.remove(entries, idx)
changed = true changed = true
end end
end end
@@ -1276,7 +1359,9 @@ function Bookhoard:applyServerAnnotations(annotations)
end end
if changed then if changed then
if self.ui.bookmark.onSortBookmarks then -- addItem() inserts v2 entries at their sorted position already;
-- explicit re-sorting is a v1-model need only.
if model == "v1" and self.ui.bookmark and self.ui.bookmark.onSortBookmarks then
self.ui.bookmark:onSortBookmarks() self.ui.bookmark:onSortBookmarks()
end end
if self.ui.saveSettings then if self.ui.saveSettings then