feat(sync): propagate annotation deletions to the server

Deleting a highlight or bookmark in KOReader previously never reached
Bookhoard: the push is upsert-only by design (absence must not mean
delete — a 'What to sync' category toggled off would otherwise wipe the
server), and the next pull re-added the annotation from the server.

Deletions are now reported explicitly and safely:

- applyServerAnnotations remembers the dedup key of every annotation it
  places or confirms locally (sidecar 'bookhoard_known_keys', mapped to
  highlight/bookmark). Keys are learned ONLY from server-served entries,
  so a device-native annotation can never be mis-flagged, and entries
  with unplaceable locators are never tracked (their absence is not a
  deletion). Legacy pos0-matched entries get the key stamped on first
  contact so identity matching and tracking work from then on.
- Pushes diff the remembered keys against the local store; vanished keys
  go out as deleted_highlights/deleted_bookmarks arrays. Gated per
  category like the annotation arrays themselves: a disabled category
  sends neither its annotations nor its deletions.
- After a successful push the sent keys are pruned; server tombstones
  received on pull clear their keys too, so a deletion is reported at
  most once per direction.

The server tombstones the rows (restorable from the web book page's
deleted-annotation history) and echoes tombstones to other devices.
This commit is contained in:
2026-08-22 13:17:03 -04:00
parent 957ae80836
commit 3d1c0fba4e
+149
View File
@@ -849,6 +849,118 @@ function Bookhoard:getAnnotationStore()
return nil, nil
end
-- ============================================================================
-- Deletion propagation
--
-- The push is upsert-only: absence from the annotation arrays must never be
-- read as a delete (a category toggled off in "What to sync" would otherwise
-- wipe the server). Instead the sidecar remembers the dedup keys of every
-- annotation the server has served us (bookhoard_known_keys). When one of
-- those keys is no longer present in the local store, the user deleted it on
-- this device, and the next push reports it via deleted_highlights /
-- deleted_bookmarks so the server tombstones it (restorable from the web
-- history). Keys are only ever learned from server pulls, so a device-native
-- annotation can never be mis-flagged.
-- ============================================================================
function Bookhoard:getKnownAnnotationKeys()
if not self.ui.doc_settings then return nil end
local known = self.ui.doc_settings:readSetting("bookhoard_known_keys")
if type(known) ~= "table" then return nil end
return known
end
-- Record one dedup key of an annotation that exists locally (applied now or
-- already present), so a later local absence can be reported as a deliberate
-- deletion. kind: "highlight" or "bookmark" (device notes are highlights-
-- with-notes server-side). Returns true when the sidecar changed.
function Bookhoard:rememberAnnotationKey(kind, key)
if not key or key == "" or not self.ui.doc_settings then return false end
local known = self:getKnownAnnotationKeys() or {}
if known[key] == kind then return false end
known[key] = kind
self.ui.doc_settings:saveSetting("bookhoard_known_keys", known)
return true
end
-- Drop keys the server tombstoned: the deletion already happened server-side,
-- so this device must never report it again.
function Bookhoard:forgetAnnotationKeys(tombstones)
if not tombstones or not self.ui.doc_settings then return false end
local known = self:getKnownAnnotationKeys()
if not known then return false end
local changed = false
for _, del in ipairs(tombstones) do
local key = del.dedup_key
if key and key ~= "" and known[key] ~= nil then
known[key] = nil
changed = true
end
end
if changed then
self.ui.doc_settings:saveSetting("bookhoard_known_keys", known)
end
return changed
end
-- Diff the remembered keys against the local annotation store. Returns
-- arrays of { dedup_key = key } entries for highlights (incl. notes) and
-- bookmarks whose annotations vanished locally, or nil when there is nothing
-- to report. Requires a readable local store: with none, local absence is
-- meaningless and nothing may be flagged.
function Bookhoard:collectDeletions()
local model, entries = self:getAnnotationStore()
if not model then return nil, nil end
local known = self:getKnownAnnotationKeys()
if not known or next(known) == nil then return nil, nil end
local present = {}
for _, bm in ipairs(entries) do
if bm.bookhoard_dedup_key then
present[bm.bookhoard_dedup_key] = true
end
end
local deleted_highlights, deleted_bookmarks
for key, kind in pairs(known) do
if not present[key] then
if kind == "bookmark" then
deleted_bookmarks = deleted_bookmarks or {}
table.insert(deleted_bookmarks, { dedup_key = key })
else
deleted_highlights = deleted_highlights or {}
table.insert(deleted_highlights, { dedup_key = key })
end
end
end
return deleted_highlights, deleted_bookmarks
end
-- After a successful push the flagged deletions are now tombstones on the
-- server; drop their keys so they are not re-reported forever (the tombstone
-- echo on the next pull would clear them too, this just avoids the wait).
function Bookhoard:pruneKnownAnnotationKeys(deleted_highlights, deleted_bookmarks)
if not self.ui.doc_settings then return end
if not deleted_highlights and not deleted_bookmarks then return end
local known = self:getKnownAnnotationKeys()
if not known then return end
local changed = false
for _, list in ipairs({ deleted_highlights, deleted_bookmarks }) do
if list then
for _, entry in ipairs(list) do
if known[entry.dedup_key] ~= nil then
known[entry.dedup_key] = nil
changed = true
end
end
end
end
if changed then
self.ui.doc_settings:saveSetting("bookhoard_known_keys", known)
self.ui.doc_settings:flush()
end
end
function Bookhoard:collectAnnotations()
local bookmarks = json_util.InitArray({})
local highlights = json_util.InitArray({})
@@ -1052,6 +1164,25 @@ function Bookhoard:_doUpdateProgress(interactive)
book_data.notes = {}
end
-- Report annotations deleted locally since the last pull. Gated per
-- category exactly like the arrays above: a disabled category sends
-- neither its annotations nor its deletions.
local deleted_hl, deleted_bm = self:collectDeletions()
if deleted_hl then
if self.settings.sync_highlights then
book_data.deleted_highlights = deleted_hl
else
deleted_hl = nil -- not sent: must not be pruned below
end
end
if deleted_bm then
if self.settings.sync_bookmarks then
book_data.deleted_bookmarks = deleted_bm
else
deleted_bm = nil
end
end
local api = self:getAPI()
local ok, result = api:syncProgress(book_data, self.settings.sync_mode)
@@ -1067,6 +1198,7 @@ function Bookhoard:_doUpdateProgress(interactive)
end
end
end
self:pruneKnownAnnotationKeys(deleted_hl, deleted_bm)
if interactive then
UIManager:show(InfoMessage:new{
text = _("Progress has been pushed."),
@@ -1435,9 +1567,22 @@ function Bookhoard:applyServerAnnotations(annotations)
if not isValidPos0(pos0) then return end
local srv_text = server_entry.text or ""
local srv_notes = server_entry.notes or ""
local srv_key = server_entry.dedup_key
-- Annotations that exist locally (after this call) get their key
-- remembered so a later deletion can be reported. Unplaceable
-- locators return above and are never tracked — absence of an
-- annotation this device never held is not a deletion.
local kind = has_text and "highlight" or "bookmark"
local idx = findLocal(server_entry)
if idx then
local bm = entries[idx]
-- Legacy pos0 match on a device-native entry: stamp the key so
-- identity matching and deletion tracking work from now on.
if srv_key and srv_key ~= "" and not bm.bookhoard_dedup_key then
bm.bookhoard_dedup_key = srv_key
changed = true
end
changed = self:rememberAnnotationKey(kind, srv_key) or changed
local cur_text, cur_note = getFields(bm)
if cur_text ~= srv_text or cur_note ~= srv_notes then
setFields(bm, srv_text, srv_notes)
@@ -1483,6 +1628,7 @@ function Bookhoard:applyServerAnnotations(annotations)
end
local index = self.ui.annotation:addItem(entry)
notify({ entry, index_modified = index })
changed = self:rememberAnnotationKey(kind, srv_key) or changed
changed = true
else
local entry = {
@@ -1504,6 +1650,7 @@ function Bookhoard:applyServerAnnotations(annotations)
end
if server_entry.chapter and server_entry.chapter ~= "" then entry.chapter = server_entry.chapter end
table.insert(entries, entry)
changed = self:rememberAnnotationKey(kind, srv_key) or changed
changed = true
end
end
@@ -1552,6 +1699,7 @@ function Bookhoard:applyServerAnnotations(annotations)
end
if annotations.deleted_highlights then
changed = self:forgetAnnotationKeys(annotations.deleted_highlights) or changed
for _, del in ipairs(annotations.deleted_highlights) do
local idx = findLocalByTombstone(del, true)
local bm = idx and entries[idx]
@@ -1565,6 +1713,7 @@ function Bookhoard:applyServerAnnotations(annotations)
end
if annotations.deleted_bookmarks then
changed = self:forgetAnnotationKeys(annotations.deleted_bookmarks) or changed
for _, del in ipairs(annotations.deleted_bookmarks) do
local idx = findLocalByTombstone(del, false)
if idx then