fix(koplugin): sync note deletions to Readest via tombstones (#4632)

Deleting a highlight or bookmark in the koplugin never reached the
server. SyncAnnotations:push builds its payload from getAnnotations,
which walks only the live ui.annotation.annotations list, so a deleted
note can never appear in a push — the server kept the row and the next
pull resurrected it. The pull direction (server deletions → koplugin)
was already handled by removeDeletedAnnotations (#4119); this is the
missing push direction.

Capture a tombstone at deletion time instead. onAnnotationsModified
detects a removal (negative index_modified, with the deleted item at
items[1]) and records a deletedAt-stamped descriptor in the per-book
sidecar (readest_sync.deleted_notes). push folds those tombstones into
the payload and clears them only once the server accepts them, so a
failed push retries. Extracted buildNoteDescriptor so the deletion path
derives a note's id identically to the push walk.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-18 00:05:04 +08:00
committed by GitHub
parent 5e5564ef3f
commit 9d0c5dc524
3 changed files with 261 additions and 60 deletions
@@ -172,4 +172,148 @@ describe("readest_syncannotations", function()
assert.are.equal(0, removed)
end)
end)
-- A note deleted in KOReader is removed from ui.annotation.annotations, so
-- the push walk (getAnnotations) can never see it. recordDeletion stashes a
-- deletedAt-stamped tombstone in the per-book sidecar so the next push tells
-- the server it's gone (issue #4119, push direction). Without this, the
-- deletion stays local and a later pull resurrects the highlight.
local function makeDocSettings(initial)
local store = initial or {}
return {
readSetting = function(_, k) return store[k] end,
saveSetting = function(_, k, v) store[k] = v end,
}
end
describe("recordDeletion", function()
it("records a tombstone for a deleted highlight", function()
local doc_settings = makeDocSettings({
partial_md5_checksum = "book-hash-1",
readest_sync = { meta_hash_v1 = "meta-1" },
})
SyncAnnotations:recordDeletion(doc_settings, {
id = "n1", drawer = "lighten", pos0 = "/a/1", pos1 = "/a/2",
text = "hello", datetime = "2026-05-17 10:00:00",
})
local deleted = doc_settings:readSetting("readest_sync").deleted_notes
assert.are.equal(1, #deleted)
assert.are.equal("n1", deleted[1].id)
assert.are.equal("annotation", deleted[1].type)
assert.are.equal("/a/1", deleted[1].xpointer0)
assert.are.equal("/a/2", deleted[1].xpointer1)
assert.is_truthy(deleted[1].deletedAt)
end)
it("derives the id for a native highlight (no stored id) from positions", function()
local doc_settings = makeDocSettings({
partial_md5_checksum = "book-hash-1",
readest_sync = { meta_hash_v1 = "meta-1" },
})
SyncAnnotations:recordDeletion(doc_settings, {
drawer = "lighten", pos0 = "/x/1", pos1 = "/x/2",
})
local expected = SyncAnnotations:generateNoteId("book-hash-1", "annotation", "/x/1", "/x/2")
local deleted = doc_settings:readSetting("readest_sync").deleted_notes
assert.are.equal(expected, deleted[1].id)
end)
it("records a tombstone for a deleted bookmark", function()
local doc_settings = makeDocSettings({
partial_md5_checksum = "book-hash-1",
readest_sync = { meta_hash_v1 = "meta-1" },
})
SyncAnnotations:recordDeletion(doc_settings, { id = "bm1", page = "/doc/bm" })
local deleted = doc_settings:readSetting("readest_sync").deleted_notes
assert.are.equal(1, #deleted)
assert.are.equal("bm1", deleted[1].id)
assert.are.equal("bookmark", deleted[1].type)
assert.are.equal("/doc/bm", deleted[1].xpointer0)
end)
it("dedupes by id so re-deleting the same note doesn't pile up", function()
local doc_settings = makeDocSettings({
partial_md5_checksum = "book-hash-1",
readest_sync = { meta_hash_v1 = "meta-1" },
})
local item = { id = "n1", drawer = "lighten", pos0 = "/a/1" }
SyncAnnotations:recordDeletion(doc_settings, item)
SyncAnnotations:recordDeletion(doc_settings, item)
local deleted = doc_settings:readSetting("readest_sync").deleted_notes
assert.are.equal(1, #deleted)
end)
it("ignores items that aren't syncable notes", function()
local doc_settings = makeDocSettings({
partial_md5_checksum = "book-hash-1",
readest_sync = { meta_hash_v1 = "meta-1" },
})
-- No drawer and a non-string page → neither highlight nor bookmark.
SyncAnnotations:recordDeletion(doc_settings, { page = 12 })
local readest_sync = doc_settings:readSetting("readest_sync")
assert.is_nil(readest_sync.deleted_notes)
end)
end)
describe("push folds tombstones", function()
local function makeUi(doc_settings, annotations)
return {
doc_settings = doc_settings,
annotation = { annotations = annotations or {} },
}
end
it("includes recorded tombstones in the payload and clears them on success", function()
local doc_settings = makeDocSettings({
partial_md5_checksum = "book-hash-1",
readest_sync = {
meta_hash_v1 = "meta-1",
deleted_notes = {
{ id = "gone1", type = "annotation", xpointer0 = "/b/1", deletedAt = 111 },
},
},
})
local captured
local client = {
pushChanges = function(_, payload, cb)
captured = payload
cb(true, {})
end,
}
SyncAnnotations:push(makeUi(doc_settings), {}, client, false, false)
assert.is_truthy(captured)
assert.are.equal(1, #captured.notes)
assert.are.equal("gone1", captured.notes[1].id)
assert.are.equal(111, captured.notes[1].deletedAt)
assert.are.equal("book-hash-1", captured.notes[1].bookHash)
assert.are.equal("meta-1", captured.notes[1].metaHash)
assert.is_nil(doc_settings:readSetting("readest_sync").deleted_notes)
end)
it("keeps tombstones when the push fails so a later push retries", function()
local doc_settings = makeDocSettings({
partial_md5_checksum = "book-hash-1",
readest_sync = {
meta_hash_v1 = "meta-1",
deleted_notes = {
{ id = "gone1", type = "annotation", xpointer0 = "/b/1", deletedAt = 111 },
},
},
})
local client = {
pushChanges = function(_, _payload, cb) cb(false, nil) end,
}
SyncAnnotations:push(makeUi(doc_settings), {}, client, false, false)
local deleted = doc_settings:readSetting("readest_sync").deleted_notes
assert.are.equal(1, #deleted)
assert.are.equal("gone1", deleted[1].id)
end)
end)
end)