diff --git a/main.lua b/main.lua index b4f6d07..e78fda9 100644 --- a/main.lua +++ b/main.lua @@ -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