9d0c5dc524
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>
511 lines
19 KiB
Lua
511 lines
19 KiB
Lua
local Event = require("ui/event")
|
|
local InfoMessage = require("ui/widget/infomessage")
|
|
local NetworkMgr = require("ui/network/manager")
|
|
local UIManager = require("ui/uimanager")
|
|
local logger = require("logger")
|
|
local sha2 = require("ffi/sha2")
|
|
local T = require("ffi/util").template
|
|
local _ = require("readest_i18n")
|
|
|
|
local SyncAnnotations = {}
|
|
|
|
-- KOReader color name → Readest color value
|
|
local KO_TO_READEST_COLOR = {
|
|
yellow = "yellow",
|
|
red = "red",
|
|
green = "green",
|
|
blue = "blue",
|
|
purple = "violet",
|
|
orange = "#ff8800",
|
|
cyan = "#00bcd4",
|
|
olive = "#808000",
|
|
gray = "#9e9e9e",
|
|
}
|
|
|
|
-- Readest color value → KOReader color name
|
|
local READEST_TO_KO_COLOR = {
|
|
yellow = "yellow",
|
|
red = "red",
|
|
green = "green",
|
|
blue = "blue",
|
|
violet = "purple",
|
|
["#ff8800"] = "orange",
|
|
["#00bcd4"] = "cyan",
|
|
["#808000"] = "olive",
|
|
["#9e9e9e"] = "gray",
|
|
}
|
|
|
|
function SyncAnnotations:parseDatetimeToMs(dt)
|
|
if not dt then return os.time() * 1000 end
|
|
local y, m, d, h, min, s = dt:match("(%d+)-(%d+)-(%d+) (%d+):(%d+):(%d+)")
|
|
if y then
|
|
return os.time({
|
|
year = tonumber(y), month = tonumber(m), day = tonumber(d),
|
|
hour = tonumber(h), min = tonumber(min), sec = tonumber(s),
|
|
}) * 1000
|
|
end
|
|
return os.time() * 1000
|
|
end
|
|
|
|
function SyncAnnotations:parseISODatetime(dt)
|
|
if not dt then return os.time() end
|
|
local y, m, d, h, min, s = dt:match("(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+)")
|
|
if y then
|
|
return os.time({
|
|
year = tonumber(y), month = tonumber(m), day = tonumber(d),
|
|
hour = tonumber(h), min = tonumber(min), sec = tonumber(s),
|
|
})
|
|
end
|
|
return os.time()
|
|
end
|
|
|
|
function SyncAnnotations:generateNoteId(book_hash, note_type, pos0, pos1)
|
|
local raw = "ko:" .. book_hash .. ":" .. note_type .. ":" .. (pos0 or "") .. ":" .. (pos1 or "")
|
|
return sha2.md5(raw):sub(1, 7)
|
|
end
|
|
|
|
-- Build the Readest note payload for a single KOReader annotation item, or nil
|
|
-- if the item isn't a syncable highlight/bookmark. Shared by the push walk
|
|
-- (getAnnotations) and the deletion path (recordDeletion) so a note's id is
|
|
-- derived identically however it was created.
|
|
function SyncAnnotations:buildNoteDescriptor(item, book_hash, meta_hash)
|
|
local note_text = item.note
|
|
if note_text == "" then note_text = nil end
|
|
|
|
local pos0 = item.pos0
|
|
local pos1 = item.pos1
|
|
if type(pos0) == "table" then pos0 = nil end
|
|
if type(pos1) == "table" then pos1 = nil end
|
|
|
|
if item.drawer and pos0 then
|
|
-- Annotation (highlight/underline/strikeout): has drawer and pos0/pos1
|
|
local style = "highlight"
|
|
if item.drawer == "underscore" then
|
|
style = "underline"
|
|
elseif item.drawer == "strikeout" then
|
|
style = "squiggly"
|
|
end
|
|
|
|
local id = item.id or self:generateNoteId(book_hash, "annotation", tostring(pos0), pos1 and tostring(pos1))
|
|
return {
|
|
bookHash = book_hash,
|
|
metaHash = meta_hash,
|
|
id = id,
|
|
type = "annotation",
|
|
xpointer0 = tostring(pos0),
|
|
xpointer1 = pos1 and tostring(pos1) or nil,
|
|
text = item.text or "",
|
|
note = note_text,
|
|
style = style,
|
|
color = KO_TO_READEST_COLOR[item.color or "yellow"],
|
|
page = item.pageno,
|
|
createdAt = self:parseDatetimeToMs(item.datetime),
|
|
updatedAt = self:parseDatetimeToMs(item.datetime_updated or item.datetime),
|
|
}
|
|
elseif not item.drawer and type(item.page) == "string" then
|
|
-- Bookmark: no drawer, position in page field (xpointer string)
|
|
local page_xp = item.page
|
|
local id = item.id or self:generateNoteId(book_hash, "bookmark", page_xp)
|
|
return {
|
|
bookHash = book_hash,
|
|
metaHash = meta_hash,
|
|
id = id,
|
|
type = "bookmark",
|
|
xpointer0 = page_xp,
|
|
text = item.text or "",
|
|
note = note_text,
|
|
page = item.pageno,
|
|
createdAt = self:parseDatetimeToMs(item.datetime),
|
|
updatedAt = self:parseDatetimeToMs(item.datetime_updated or item.datetime),
|
|
}
|
|
end
|
|
return nil
|
|
end
|
|
|
|
function SyncAnnotations:getAnnotations(ui, settings, book_hash, meta_hash, full_sync)
|
|
local annotations = ui.annotation and ui.annotation.annotations
|
|
if not annotations then return {} end
|
|
|
|
local last_sync = full_sync and 0 or (settings.last_notes_sync_at or 0)
|
|
|
|
local notes = {}
|
|
for _, item in ipairs(annotations) do
|
|
local updated_at = self:parseDatetimeToMs(item.datetime_updated or item.datetime)
|
|
if updated_at > last_sync then
|
|
local note = self:buildNoteDescriptor(item, book_hash, meta_hash)
|
|
if note then
|
|
notes[#notes + 1] = note
|
|
end
|
|
end
|
|
end
|
|
return notes
|
|
end
|
|
|
|
-- Drop local annotations the server has tombstoned (deleted_at set), so a
|
|
-- highlight deleted on Readest also disappears in KOReader. Without this,
|
|
-- pull only skips deleted notes — the local copy lingers and any later
|
|
-- push resurrects it on the server, making it reappear (issue #4119).
|
|
function SyncAnnotations:removeDeletedAnnotations(annotation_mgr, notes, book_hash)
|
|
local annotations = annotation_mgr and annotation_mgr.annotations
|
|
if not annotations or #annotations == 0 then return 0 end
|
|
|
|
-- Map every local annotation to its index by id and by position so a
|
|
-- tombstone can be matched however it was originally created: pulled
|
|
-- notes carry a stored id, native KOReader highlights don't (their id
|
|
-- is derived from book hash + positions, matching what push uploads).
|
|
local index_by_id = {}
|
|
local index_by_anno = {}
|
|
local index_by_bookmark = {}
|
|
for i, item in ipairs(annotations) do
|
|
if item.id then
|
|
index_by_id[item.id] = i
|
|
end
|
|
if item.drawer then
|
|
local pos0 = item.pos0
|
|
local pos1 = item.pos1
|
|
if type(pos0) == "table" then pos0 = nil end
|
|
if type(pos1) == "table" then pos1 = nil end
|
|
if pos0 then
|
|
index_by_anno[tostring(pos0) .. "|" .. tostring(pos1 or "")] = i
|
|
if not item.id then
|
|
local id = self:generateNoteId(book_hash, "annotation", tostring(pos0), pos1 and tostring(pos1))
|
|
index_by_id[id] = i
|
|
end
|
|
end
|
|
elseif type(item.page) == "string" then
|
|
index_by_bookmark[item.page] = i
|
|
if not item.id then
|
|
local id = self:generateNoteId(book_hash, "bookmark", item.page)
|
|
index_by_id[id] = i
|
|
end
|
|
end
|
|
end
|
|
|
|
local to_remove = {}
|
|
for _, note in ipairs(notes) do
|
|
if note.deleted_at then
|
|
local idx
|
|
if note.id and index_by_id[note.id] then
|
|
idx = index_by_id[note.id]
|
|
elseif note.type == "bookmark" and note.xpointer0 then
|
|
idx = index_by_bookmark[note.xpointer0]
|
|
elseif note.xpointer0 then
|
|
idx = index_by_anno[note.xpointer0 .. "|" .. (note.xpointer1 or "")]
|
|
end
|
|
if idx then
|
|
to_remove[idx] = true
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Remove highest index first so earlier indexes stay valid.
|
|
local indexes = {}
|
|
for idx in pairs(to_remove) do
|
|
indexes[#indexes + 1] = idx
|
|
end
|
|
table.sort(indexes, function(a, b) return a > b end)
|
|
for _, idx in ipairs(indexes) do
|
|
table.remove(annotations, idx)
|
|
end
|
|
return #indexes
|
|
end
|
|
|
|
-- Stash a tombstone for a note deleted locally. By the time auto-sync runs the
|
|
-- deleted item is already gone from ui.annotation.annotations, so the push walk
|
|
-- can't see it; without this the deletion never reaches the server and the
|
|
-- highlight resurrects on the next pull (issue #4119, push direction). The
|
|
-- tombstone is a normal note payload with deletedAt set, persisted in the
|
|
-- per-book sidecar and folded into the next push (see push()), then cleared
|
|
-- once the server accepts it.
|
|
function SyncAnnotations:recordDeletion(doc_settings, item)
|
|
if not doc_settings or not item then return end
|
|
local book_hash = doc_settings:readSetting("partial_md5_checksum")
|
|
if not book_hash then return end
|
|
|
|
local doc_readest_sync = doc_settings:readSetting("readest_sync") or {}
|
|
local note = self:buildNoteDescriptor(item, book_hash, doc_readest_sync.meta_hash_v1)
|
|
if not note then return end
|
|
note.deletedAt = os.time() * 1000
|
|
|
|
local deleted = doc_readest_sync.deleted_notes or {}
|
|
for _, t in ipairs(deleted) do
|
|
if t.id == note.id then return end -- already recorded
|
|
end
|
|
deleted[#deleted + 1] = note
|
|
doc_readest_sync.deleted_notes = deleted
|
|
doc_settings:saveSetting("readest_sync", doc_readest_sync)
|
|
end
|
|
|
|
function SyncAnnotations:push(ui, settings, client, interactive, full_sync)
|
|
local book_hash = ui.doc_settings:readSetting("partial_md5_checksum")
|
|
local doc_readest_sync = ui.doc_settings:readSetting("readest_sync") or {}
|
|
local meta_hash = doc_readest_sync.meta_hash_v1
|
|
if not book_hash or not meta_hash then return end
|
|
|
|
local annotations = self:getAnnotations(ui, settings, book_hash, meta_hash, full_sync)
|
|
|
|
-- Fold in tombstones for notes deleted locally since the last push. These
|
|
-- are gone from ui.annotation.annotations, so getAnnotations can't see them;
|
|
-- re-stamp the current book/meta hash in case they were recorded before the
|
|
-- book was registered for sync.
|
|
local deleted_notes = doc_readest_sync.deleted_notes or {}
|
|
for _, t in ipairs(deleted_notes) do
|
|
t.bookHash = book_hash
|
|
t.metaHash = meta_hash
|
|
annotations[#annotations + 1] = t
|
|
end
|
|
|
|
if #annotations == 0 then
|
|
if interactive then
|
|
UIManager:show(InfoMessage:new{
|
|
text = _("No annotations to push"),
|
|
timeout = 2,
|
|
})
|
|
end
|
|
return
|
|
end
|
|
|
|
if interactive then
|
|
UIManager:show(InfoMessage:new{
|
|
text = _("Pushing annotations..."),
|
|
timeout = 1,
|
|
})
|
|
end
|
|
|
|
local payload = {
|
|
books = {},
|
|
notes = annotations,
|
|
configs = {},
|
|
}
|
|
logger.dbg("ReadestSync: Pushing annotations, payload:", payload)
|
|
|
|
client:pushChanges(
|
|
payload,
|
|
function(success, _response)
|
|
if interactive then
|
|
if success then
|
|
UIManager:show(InfoMessage:new{
|
|
text = T(_("%1 annotations pushed successfully"), #annotations),
|
|
timeout = 2,
|
|
})
|
|
else
|
|
UIManager:show(InfoMessage:new{
|
|
text = _("Failed to push annotations"),
|
|
timeout = 2,
|
|
})
|
|
end
|
|
end
|
|
if success then
|
|
settings.last_notes_sync_at = os.time() * 1000
|
|
G_reader_settings:saveSetting("readest_sync", settings)
|
|
if ui.doc_settings then
|
|
local synced = ui.doc_settings:readSetting("readest_sync") or {}
|
|
synced.last_synced_at_notes = os.time()
|
|
-- The server has the tombstones now; drop them so they don't
|
|
-- ride along on every future push.
|
|
synced.deleted_notes = nil
|
|
ui.doc_settings:saveSetting("readest_sync", synced)
|
|
end
|
|
end
|
|
end
|
|
)
|
|
end
|
|
|
|
function SyncAnnotations:pull(ui, settings, client, book_hash, meta_hash, dialog, interactive, full_sync)
|
|
if ui.document.info.has_pages then
|
|
if interactive then
|
|
UIManager:show(InfoMessage:new{
|
|
text = _("Annotation sync is not supported for PDF documents"),
|
|
timeout = 3,
|
|
})
|
|
end
|
|
return
|
|
end
|
|
|
|
if interactive then
|
|
UIManager:show(InfoMessage:new{
|
|
text = full_sync and _("Full sync: pulling all annotations...") or _("Pulling annotations..."),
|
|
timeout = 1,
|
|
})
|
|
end
|
|
|
|
client:pullChanges(
|
|
{
|
|
since = full_sync and 0 or (settings.last_notes_sync_at or 0),
|
|
type = "notes",
|
|
book = book_hash,
|
|
meta_hash = meta_hash,
|
|
},
|
|
function(success, response, status)
|
|
if not success then
|
|
-- Treat HTTP 401/403 as auth failure regardless of body shape
|
|
-- so a future server tweak to the error string doesn't
|
|
-- silently turn relogin into "Failed to pull annotations"
|
|
-- noise (codex round 1 finding 15).
|
|
local is_auth_fail = status == 401 or status == 403
|
|
or (response and response.error == "Not authenticated")
|
|
if interactive then
|
|
UIManager:show(InfoMessage:new{
|
|
text = is_auth_fail
|
|
and _("Authentication failed, please login again")
|
|
or _("Failed to pull annotations"),
|
|
timeout = 2,
|
|
})
|
|
end
|
|
return
|
|
end
|
|
|
|
local data = response.notes
|
|
if not data or #data == 0 then
|
|
if interactive then
|
|
UIManager:show(InfoMessage:new{
|
|
text = _("No new annotations found"),
|
|
timeout = 2,
|
|
})
|
|
end
|
|
return
|
|
end
|
|
|
|
logger.dbg("ReadestSync: Pulled annotations from sync:", #data)
|
|
local annotation_mgr = ui.annotation
|
|
if not annotation_mgr then return end
|
|
|
|
-- Honor remote deletions before adding: drop local annotations
|
|
-- the server has tombstoned so they don't reappear (issue #4119).
|
|
local removed = self:removeDeletedAnnotations(annotation_mgr, data, book_hash)
|
|
|
|
-- Build dedup sets: by ID, by pos0|pos1 for annotations, by page xpointer for bookmarks
|
|
local existing_ids = {}
|
|
local existing_annotations = {}
|
|
local existing_bookmarks = {}
|
|
for _, item in ipairs(annotation_mgr.annotations) do
|
|
-- Use stored id if available
|
|
if item.id then
|
|
existing_ids[item.id] = true
|
|
end
|
|
if item.drawer then
|
|
local pos0 = item.pos0
|
|
local pos1 = item.pos1
|
|
if type(pos0) == "table" then pos0 = nil end
|
|
if type(pos1) == "table" then pos1 = nil end
|
|
local key = tostring(pos0) .. "|" .. tostring(pos1 or "")
|
|
existing_annotations[key] = true
|
|
-- Also generate ID for annotations without id
|
|
if not item.id and pos0 then
|
|
local id = self:generateNoteId(book_hash, "annotation", tostring(pos0), pos1 and tostring(pos1))
|
|
existing_ids[id] = true
|
|
end
|
|
elseif type(item.page) == "string" then
|
|
existing_bookmarks[item.page] = true
|
|
if not item.id then
|
|
local id = self:generateNoteId(book_hash, "bookmark", item.page)
|
|
existing_ids[id] = true
|
|
end
|
|
end
|
|
end
|
|
|
|
local added = 0
|
|
for _, note in ipairs(data) do
|
|
if note.deleted_at then
|
|
goto continue
|
|
end
|
|
|
|
local xp0 = note.xpointer0
|
|
if not xp0 then goto continue end
|
|
|
|
-- Deduplicate by server-provided ID
|
|
if note.id and existing_ids[note.id] then goto continue end
|
|
|
|
local note_type = note.type
|
|
local item
|
|
|
|
local created = self:parseISODatetime(note.created_at)
|
|
local updated = self:parseISODatetime(note.updated_at) or created
|
|
local datetime_str = os.date("%Y-%m-%d %H:%M:%S", created)
|
|
local datetime_updated_str = os.date("%Y-%m-%d %H:%M:%S", updated)
|
|
|
|
-- Resolve KOReader page number from xpointer
|
|
local pageno = ui.document:getPageFromXPointer(xp0) or note.page
|
|
-- Resolve chapter title so downstream tools can group by chapter,
|
|
-- matching native KOReader highlight creation behavior.
|
|
local chapter = ui.toc and ui.toc:getTocTitleByPage(xp0) or nil
|
|
if chapter == "" then chapter = nil end
|
|
|
|
local note_text = note.note
|
|
if note_text == "" then note_text = nil end
|
|
|
|
if note_type == "bookmark" then
|
|
if existing_bookmarks[xp0] then goto continue end
|
|
|
|
item = {
|
|
id = note.id,
|
|
page = xp0,
|
|
text = note.text or "",
|
|
note = note_text,
|
|
chapter = chapter,
|
|
pageno = pageno,
|
|
datetime = datetime_str,
|
|
datetime_updated = datetime_updated_str,
|
|
}
|
|
existing_bookmarks[xp0] = true
|
|
else
|
|
local xp1 = note.xpointer1
|
|
local key = xp0 .. "|" .. (xp1 or "")
|
|
if existing_annotations[key] then goto continue end
|
|
|
|
local drawer = "lighten"
|
|
if note.style == "underline" then
|
|
drawer = "underscore"
|
|
elseif note.style == "squiggly" then
|
|
drawer = "strikeout"
|
|
end
|
|
|
|
item = {
|
|
id = note.id,
|
|
pos0 = xp0,
|
|
pos1 = xp1 or xp0,
|
|
page = xp0,
|
|
text = note.text or "",
|
|
note = note_text,
|
|
drawer = drawer,
|
|
color = READEST_TO_KO_COLOR[note.color] or "yellow",
|
|
chapter = chapter,
|
|
pageno = pageno,
|
|
datetime = datetime_str,
|
|
datetime_updated = datetime_updated_str,
|
|
}
|
|
existing_annotations[key] = true
|
|
end
|
|
|
|
local index = annotation_mgr:addItem(item)
|
|
ui:handleEvent(Event:new("AnnotationsModified", { item, index_modified = index }))
|
|
logger.dbg("ReadestSync: Added annotation from sync:", item)
|
|
added = added + 1
|
|
|
|
::continue::
|
|
end
|
|
|
|
settings.last_notes_sync_at = os.time() * 1000
|
|
G_reader_settings:saveSetting("readest_sync", settings)
|
|
if ui.doc_settings then
|
|
local doc_readest_sync = ui.doc_settings:readSetting("readest_sync") or {}
|
|
doc_readest_sync.last_synced_at_notes = os.time()
|
|
ui.doc_settings:saveSetting("readest_sync", doc_readest_sync)
|
|
end
|
|
|
|
if interactive then
|
|
UIManager:show(InfoMessage:new{
|
|
text = T(_("%1 annotations pulled"), added),
|
|
timeout = 2,
|
|
})
|
|
end
|
|
|
|
if added > 0 or removed > 0 then
|
|
UIManager:setDirty(dialog, "ui")
|
|
end
|
|
end
|
|
)
|
|
end
|
|
|
|
return SyncAnnotations
|