feat: implement bidirectional annotation sync and delete propagation

Bring the plugin to feature parity with the server's annotation sync
system. Previously annotations were push-only (and even that was broken
because the server ignored inline annotations in progress pushes).

ANNOTATION PULL (server → device):
  - Add applyServerAnnotations() method called from _doGetProgress
    after progress handling
  - Parses annotations.highlights, annotations.notes, and
    annotations.bookmarks from GetMetadata response
  - Matches incoming annotations against local bookmarks by pos0
    (start position) to detect existing entries
  - New entries are inserted with correct KOReader bookmark format:
    page, pos0, pos1, datetime, text, notes, color, chapter
  - Existing entries with changed content are updated (LWW: server
    version is authoritative since the server already resolved conflicts)
  - Respects sync_highlights, sync_notes, sync_bookmarks toggles for
    inbound sync, not just outbound
  - Triggers onSortBookmarks + saveSettings on change for persistence

DELETE PROPAGATION:
  - Parses annotations.deleted_highlights and annotations.deleted_bookmarks
    from GetMetadata response
  - Each entry contains device_sync_data (pos0, datetime, page) from the
    original device push
  - Matches local bookmarks by pos0 and removes them
  - Deleted highlights only remove entries that have text (to avoid
    removing plain bookmarks at the same position)

CLEANUP:
  - Fix BookhoardAPI.lua: rename pcall return _ to err for clarity
    (was functional but misleading variable name)
  - Remove dead code: unused getLibrary() and syncBookmarks() API methods
  - Remove sync_endpoints field (stored from registration response but
    never read by any code)
This commit is contained in:
2026-07-29 14:49:41 -04:00
parent 138347c821
commit 9d7b0f689f
2 changed files with 121 additions and 14 deletions
+5 -11
View File
@@ -60,23 +60,23 @@ function BookhoardAPI:_request(method, path, body, timeouts)
socketutil:set_timeout(timeouts[1], timeouts[2]) socketutil:set_timeout(timeouts[1], timeouts[2])
local ok, _, code local ok, err, code
if url:match("^https://") then if url:match("^https://") then
if ssl_ok then if ssl_ok then
ok, _, code = pcall(ssl_https.request, request) ok, err, code = pcall(ssl_https.request, request)
else else
socketutil:reset_timeout() socketutil:reset_timeout()
return false, { error = "HTTPS not supported on this device" } return false, { error = "HTTPS not supported on this device" }
end end
else else
ok, _, code = pcall(http.request, request) ok, err, code = pcall(http.request, request)
end end
socketutil:reset_timeout() socketutil:reset_timeout()
if not ok then if not ok then
logger.warn("BookhoardAPI: request failed:", _) logger.warn("BookhoardAPI: request failed:", err)
return false, { error = tostring(_) } return false, { error = tostring(err) }
end end
local response_body = table.concat(sink) local response_body = table.concat(sink)
@@ -130,12 +130,6 @@ function BookhoardAPI:getMetadata(uuid)
return self:_request("GET", "/api/sync/koreader/metadata/" .. uuid, nil, PROGRESS_TIMEOUTS) return self:_request("GET", "/api/sync/koreader/metadata/" .. uuid, nil, PROGRESS_TIMEOUTS)
end end
function BookhoardAPI:getLibrary()
return self:_request("GET", "/api/sync/koreader/library", nil, PROGRESS_TIMEOUTS)
end
function BookhoardAPI:syncBookmarks(data)
return self:_request("POST", "/api/sync/koreader/bookmarks", data, PROGRESS_TIMEOUTS)
end
return BookhoardAPI return BookhoardAPI
+116 -3
View File
@@ -68,7 +68,6 @@ Bookhoard.default_settings = {
sync_highlights = true, sync_highlights = true,
sync_notes = true, sync_notes = true,
sync_mode = SYNC_MODE.IMMEDIATE, sync_mode = SYNC_MODE.IMMEDIATE,
sync_endpoints = nil,
} }
function Bookhoard:init() function Bookhoard:init()
@@ -212,7 +211,6 @@ function Bookhoard:buildMainMenu()
ok_callback = function() ok_callback = function()
self.settings.auth_token = nil self.settings.auth_token = nil
self.settings.device_id = nil self.settings.device_id = nil
self.settings.sync_endpoints = nil
self.settings.auto_sync = false self.settings.auto_sync = false
G_reader_settings:saveSetting(self.settings_key, self.settings) G_reader_settings:saveSetting(self.settings_key, self.settings)
self:registerEvents() self:registerEvents()
@@ -623,7 +621,6 @@ function Bookhoard:startRegistrationPoll()
self.registration_id = nil self.registration_id = nil
self.settings.auth_token = result.auth_token self.settings.auth_token = result.auth_token
self.settings.device_id = result.device_id and tostring(result.device_id) or nil self.settings.device_id = result.device_id and tostring(result.device_id) or nil
self.settings.sync_endpoints = result.sync_endpoints
G_reader_settings:saveSetting(self.settings_key, self.settings) G_reader_settings:saveSetting(self.settings_key, self.settings)
self:registerEvents() self:registerEvents()
self:setupOPDS() self:setupOPDS()
@@ -1039,6 +1036,10 @@ function Bookhoard:_doGetProgress(interactive)
return return
end end
if result.annotations then
self:applyServerAnnotations(result.annotations)
end
local progress = result.progress local progress = result.progress
local percentage = self:getLastPercent() local percentage = self:getLastPercent()
local server_percentage = progress.percentage or 0 local server_percentage = progress.percentage or 0
@@ -1100,6 +1101,118 @@ function Bookhoard:_doGetProgress(interactive)
end) end)
end end
function Bookhoard:applyServerAnnotations(annotations)
if not self.ui.bookmark or not self.ui.bookmark.bookmarks then
return
end
if not annotations then return end
local local_bms = self.ui.bookmark.bookmarks
local changed = false
local has_pages = self.ui.document.info.has_pages
local function findLocalByPos0(pos0)
for i, bm in ipairs(local_bms) do
if bm.pos0 == pos0 then
return i
end
end
return nil
end
local function makePageFromPos(pos0)
if has_pages then
local page = tonumber(pos0:match("(%d+)$"))
return page or pos0
end
return pos0
end
local function addOrUpdate(server_entry, has_text)
local pos0 = server_entry.pos0 or ""
if 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_notes = server_entry.notes or ""
if (bm.text or "") ~= srv_text or (bm.notes or "") ~= srv_notes then
bm.text = srv_text
bm.notes = srv_notes
if server_entry.color then bm.color = server_entry.color end
changed = true
end
else
local entry = {
page = makePageFromPos(pos0),
pos0 = pos0,
pos1 = server_entry.pos1 or pos0,
datetime = server_entry.datetime or "",
text = server_entry.text or "",
notes = server_entry.notes or "",
}
if server_entry.color then entry.color = server_entry.color end
if server_entry.chapter then entry.chapter = server_entry.chapter end
table.insert(local_bms, entry)
changed = true
end
end
if self.settings.sync_highlights and annotations.highlights then
for _, hl in ipairs(annotations.highlights) do
addOrUpdate(hl, true)
end
end
if self.settings.sync_notes and annotations.notes then
for _, note in ipairs(annotations.notes) do
addOrUpdate(note, true)
end
end
if self.settings.sync_bookmarks and annotations.bookmarks then
for _, bm in ipairs(annotations.bookmarks) do
addOrUpdate(bm, false)
end
end
if annotations.deleted_highlights then
for _, del in ipairs(annotations.deleted_highlights) do
local pos0 = del.pos0 or ""
if pos0 ~= "" then
local idx = findLocalByPos0(pos0)
if idx and (local_bms[idx].text or "") ~= "" then
table.remove(local_bms, idx)
changed = true
end
end
end
end
if annotations.deleted_bookmarks then
for _, del in ipairs(annotations.deleted_bookmarks) do
local pos0 = del.pos0 or ""
if pos0 ~= "" then
local idx = findLocalByPos0(pos0)
if idx then
table.remove(local_bms, idx)
changed = true
end
end
end
end
if changed then
if self.ui.bookmark.onSortBookmarks then
self.ui.bookmark:onSortBookmarks()
end
if self.ui.saveSettings then
self.ui:saveSettings()
end
logger.dbg("Bookhoard: annotations updated from server")
end
end
function Bookhoard:_showSyncedMessage() function Bookhoard:_showSyncedMessage()
UIManager:show(InfoMessage:new{ UIManager:show(InfoMessage:new{
text = _("Progress has been synchronized."), text = _("Progress has been synchronized."),