Files
neon 1c27e5dd05 fix(koreader): keep manual links to a different copy matched and suggest links
Report a linked copy whose bytes differ from the library file as positionIncompatible instead of
unmatched, so the plugin keeps the match, skips highlights without a failure and still syncs progress.
Treat a position-less place within a page as the same place and jump to the nearest page.
Suggest library books with the same title and authors for books to review, with bulk linking.
See docs/KOREADER_HASH_MATCHING.md.

Closes #1872
Closes #1919
2026-10-26 15:06:04 +00:00

464 lines
19 KiB
Lua

--[[--
Two-way exchange of position-only bookmarks (dogears) with BookOrbit.
Mirrors bookorbit_annotations in miniature. A dogear has no highlighted text,
so there is nothing to verify a repaired position against: the device checks
that the server's xpointer resolves and otherwise reports the entry as failed.
Why the whole local set is uploaded instead of a datetime delta: KOReader does
not stamp datetime_updated when the user edits a dogear note, so a watermark
would silently swallow every rename. Dogears are rare per book, the per-book
skip stamp keeps unchanged books off the wire entirely, and the server dedupes
by identity key, so re-sending the set costs one request and loses nothing.
Apply modes:
- live: the book is open; entries go through ReaderAnnotation so the dogear
indicator, the bookmark list and the footer stay coherent.
- sidecar: the book is closed; entries are merged into the sidecar annotations
table and annotations_externally_modified is set, so KOReader re-validates
and re-sorts on next open.
- skip: upload-only (suspend path); pending server changes stay queued.
]]
local DocSettings = require("docsettings")
local Event = require("ui/event")
local UIManager = require("ui/uimanager")
local T = require("ffi/util").template
local _ = require("gettext")
local logger = require("logger")
local md5 = require("ffi/sha2").md5
local BookOrbitCapabilities = require("bookorbit_capabilities")
local BookOrbitSidecar = require("bookorbit_sidecar")
local MAX_KEYS_PER_BOOK = 500
local MAX_PULL_ROUNDS = 10
local UPLOAD_CHUNK = 50
local BookOrbitBookmarks = {}
BookOrbitBookmarks.CAPABILITY = "bookmarkSync"
-- Same bound as the highlight exchange: it is the only channel that delivers
-- web-created bookmarks, so a drained lifecycle entry may not skip forever.
BookOrbitBookmarks.EXCHANGE_MAX_AGE = 6 * 3600
-- Bookmark sync rides the existing two-way toggle and only runs against a
-- server that advertises the route, so a new plugin on an old server stays
-- dormant instead of collecting 404s.
function BookOrbitBookmarks.enabled(client, annotation_sync)
if not annotation_sync or not client then return false end
return BookOrbitCapabilities.supports(client, BookOrbitBookmarks.CAPABILITY) == true
end
-- Records a confirmed 404 on the bookmark route so the rest of the session
-- stops asking, matching how the other capability-gated routes downgrade.
function BookOrbitBookmarks.markUnsupported(client)
if not client then return end
BookOrbitCapabilities.markUnsupported(client, BookOrbitBookmarks.CAPABILITY)
end
function BookOrbitBookmarks.rememberExchanged(book, signature, now)
if not book or type(signature) ~= "string" or signature == "" then return end
book.bmSignature = signature
book.bmExchangedAt = now or os.time()
end
function BookOrbitBookmarks.canSkipExchange(book, signature, now)
if not book then return false end
if type(signature) ~= "string" or signature == "" then return false end
if book.bmSignature ~= signature then return false end
local exchanged_at = book.bmExchangedAt
if type(exchanged_at) ~= "number" then return false end
now = now or os.time()
if exchanged_at > now then return false end
return (now - exchanged_at) <= BookOrbitBookmarks.EXCHANGE_MAX_AGE
end
function BookOrbitBookmarks.buildKey(datetime, pos)
return md5(datetime .. "|" .. pos)
end
function BookOrbitBookmarks.collectKeys(normalized)
local keys = {}
for _, bookmark in ipairs(normalized) do
table.insert(keys, { k = BookOrbitBookmarks.buildKey(bookmark.datetime, bookmark.pos), dt = bookmark.datetime })
end
return keys
end
local function isDogear(annotation)
return type(annotation) == "table" and annotation.drawer == nil and type(annotation.page) == "string"
end
-- Local identity of a dogear: its position is the only stable handle, and the
-- server addresses deletes by the key it was acked with.
local function findByKey(annotations, key, datetime)
for index, annotation in ipairs(annotations) do
if isDogear(annotation) and type(annotation.datetime) == "string" then
if BookOrbitBookmarks.buildKey(annotation.datetime, annotation.page) == key then
return index, annotation
end
end
end
if type(datetime) ~= "string" or datetime == "" then return nil end
for index, annotation in ipairs(annotations) do
if isDogear(annotation) and annotation.datetime == datetime then
return index, annotation
end
end
end
-- A datetime is required as well as the position: the ack has to report a local
-- identity, and md5(datetime|pos) cannot be built without one.
local function findByPos(annotations, pos)
for index, annotation in ipairs(annotations) do
if isDogear(annotation) and annotation.page == pos and type(annotation.datetime) == "string" then
return index, annotation
end
end
end
local function ackApplied(entry, opts)
opts = opts or {}
return {
serverId = entry.serverId,
status = opts.failed and "failed" or "applied",
key = opts.key,
datetime = opts.datetime,
pos = opts.pos,
}
end
local function ackExisting(entry, annotation)
return ackApplied(entry, {
key = BookOrbitBookmarks.buildKey(annotation.datetime, annotation.page),
datetime = annotation.datetime,
pos = annotation.page,
})
end
-- The server stores one title per bookmark. A dogear shows its chapter and its
-- note, so a title that already matches the chapter needs no note; anything
-- else is the user's own label and belongs in the note. Both shapes upload back
-- as the same title, so devices and the web converge either way.
local function applyTitleFields(item, title, chapter)
if chapter and chapter ~= "" then
item.chapter = chapter
-- Matches what ReaderBookmark writes for a device-created dogear.
item.text = T(_("in %1"), chapter)
item.note = (title and title ~= "" and title ~= chapter) and title or nil
else
item.note = (title and title ~= "") and title or nil
end
end
local function chapterForPos(ui, pos)
if not ui or not ui.toc or not ui.toc.getTocTitleByPage then return nil end
local ok, chapter = pcall(ui.toc.getTocTitleByPage, ui.toc, pos)
if not ok or type(chapter) ~= "string" or chapter == "" then return nil end
return chapter
end
local function refreshDogear(ui)
if not ui or not ui.bookmark then return end
local ok, page = pcall(ui.bookmark.getCurrentPageNumber, ui.bookmark)
if ok and page ~= nil then
pcall(ui.bookmark.setDogearVisibility, ui.bookmark, page)
end
end
-- Applies server changes to the OPEN book through ReaderAnnotation.
function BookOrbitBookmarks.applyLive(ui, to_apply)
local applied, deleted = {}, {}
local document = ui.document
local annotations = ui.annotation.annotations
local touched, deleted_touched = 0, 0
for _, entry in ipairs(to_apply.add or {}) do
local existing_index, existing = findByPos(annotations, entry.pos)
if existing_index then
local old_chapter, old_text, old_note, old_pageno = existing.chapter, existing.text, existing.note, existing.pageno
applyTitleFields(existing, entry.title, chapterForPos(ui, entry.pos))
if type(entry.pageno) == "number" then existing.pageno = entry.pageno end
if existing.chapter ~= old_chapter or existing.text ~= old_text
or existing.note ~= old_note or existing.pageno ~= old_pageno then
touched = touched + 1
ui:handleEvent(Event:new("AnnotationsModified", { existing, index_modified = existing_index }))
end
table.insert(applied, ackExisting(entry, existing))
else
local ok, resolves = pcall(document.isXPointerInDocument, document, entry.pos)
if not (ok and resolves) then
logger.dbg("BookOrbit: bookmark apply failed, position does not resolve:", entry.serverId)
table.insert(applied, ackApplied(entry, { failed = true }))
else
local item = { page = entry.pos }
applyTitleFields(item, entry.title, chapterForPos(ui, entry.pos))
local index = ui.annotation:addItem(item)
touched = touched + 1
ui:handleEvent(Event:new("AnnotationsModified", { item, index_modified = index }))
table.insert(applied, ackApplied(entry, {
key = BookOrbitBookmarks.buildKey(item.datetime, item.page),
datetime = item.datetime,
pos = item.page,
}))
end
end
end
for _, entry in ipairs(to_apply.delete or {}) do
local index = findByKey(annotations, entry.key, entry.datetime)
if index then
ui.bookmark:removeItemByIndex(index)
touched = touched + 1
deleted_touched = deleted_touched + 1
end
-- Missing entries were already deleted locally; ack either way.
table.insert(deleted, { serverId = entry.serverId, status = "applied" })
end
if touched > 0 then
refreshDogear(ui)
UIManager:setDirty("all", "ui")
end
return applied, deleted, touched, deleted_touched
end
-- Merges server changes into the sidecar of a CLOSED book and raises
-- annotations_externally_modified so KOReader re-validates and sorts on open.
function BookOrbitBookmarks.applySidecar(file, to_apply)
local applied, deleted = {}, {}
local doc_settings = DocSettings:open(file)
local annotations = doc_settings:readSetting("annotations") or {}
local touched, deleted_touched = 0, 0
for _, entry in ipairs(to_apply.add or {}) do
local existing_index, existing = findByPos(annotations, entry.pos)
if existing_index then
local old_chapter, old_text, old_note, old_pageno = existing.chapter, existing.text, existing.note, existing.pageno
applyTitleFields(existing, entry.title, existing.chapter)
if type(entry.pageno) == "number" then existing.pageno = entry.pageno end
if existing.chapter ~= old_chapter or existing.text ~= old_text
or existing.note ~= old_note or existing.pageno ~= old_pageno then
touched = touched + 1
end
table.insert(applied, ackExisting(entry, existing))
else
-- No document is loaded, so the position cannot be verified and no
-- chapter can be looked up; KOReader drops an unresolvable entry on
-- the next open, and the server re-pushes it after that.
local item = { page = entry.pos, datetime = os.date("%Y-%m-%d %H:%M:%S") }
if type(entry.pageno) == "number" then item.pageno = entry.pageno end
applyTitleFields(item, entry.title, nil)
table.insert(annotations, item)
touched = touched + 1
table.insert(applied, ackApplied(entry, {
key = BookOrbitBookmarks.buildKey(item.datetime, item.page),
datetime = item.datetime,
pos = item.page,
}))
end
end
for _, entry in ipairs(to_apply.delete or {}) do
local index = findByKey(annotations, entry.key, entry.datetime)
if index then
table.remove(annotations, index)
touched = touched + 1
deleted_touched = deleted_touched + 1
end
table.insert(deleted, { serverId = entry.serverId, status = "applied" })
end
if touched > 0 then
doc_settings:saveSetting("annotations", annotations)
doc_settings:makeTrue("annotations_externally_modified")
doc_settings:flush()
end
return applied, deleted, touched, deleted_touched
end
local function hasPending(to_apply)
return to_apply and (#(to_apply.add or {}) > 0 or #(to_apply.delete or {}) > 0)
end
-- The server matches a copy whose bytes differ from the library file but cannot
-- place its bookmarks, so nothing is exchanged and nothing failed.
local function incompatibleResult()
return { uploaded = 0, applied = 0, deleted = 0, failed = 0, had_errors = false, incompatible = 1 }
end
--[[--
Full bookmark exchange for one matched book. Blocking; callers run it from a
step machine or a scheduled task.
opts:
- client: BookOrbitApi instance
- state: BookOrbitState instance (book must already be matched)
- digest: partial md5 of the book file
- bookmarks: normalized bookmark list (live or sidecar source)
- bm_signature: change signal for that list, recorded on a complete exchange
- apply_mode: "live" | "sidecar" | "skip"
- ui: ReaderUI (required for apply_mode "live")
- file: book file path (required for apply_mode "sidecar")
Returns a result table { uploaded, applied, deleted, failed, had_errors } or
nil, err for auth/network level failures.
]]
function BookOrbitBookmarks.exchangeBook(opts)
local book = opts.state:getBook(opts.digest)
if not book then return nil, "unmatched" end
if book.positionIncompatible then return incompatibleResult() end
local bookmarks = opts.bookmarks or {}
local keys = BookOrbitBookmarks.collectKeys(opts.key_bookmarks or bookmarks)
local keys_complete = opts.keys_complete ~= false and #keys <= MAX_KEYS_PER_BOOK
if opts.file and opts.state.hasOtherCopy and opts.state:hasOtherCopy(opts.digest, opts.file) then
keys_complete = false
end
local sidecar_state
sidecar_state, keys_complete = BookOrbitSidecar.resolveExchangeState(opts.client, opts.sidecar_state, keys_complete)
if not keys_complete then keys = {} end
local result = { uploaded = 0, applied = 0, deleted = 0, failed = 0, had_errors = false }
local response
local first_request = true
local upload_failed = false
local cursor = 1
repeat
local chunk = {}
while cursor <= #bookmarks and #chunk < UPLOAD_CHUNK do
table.insert(chunk, bookmarks[cursor])
cursor = cursor + 1
end
local body, err = opts.client:exchangeBookmarks({
{
hash = opts.digest,
keys = first_request and keys or {},
keysComplete = first_request and keys_complete or false,
sidecarState = first_request and sidecar_state or nil,
changes = chunk,
},
})
if not body then
if err == 401 or err == 403 then return nil, "auth" end
if err == 404 then return nil, "unsupported_server" end
if type(err) ~= "number" then return nil, "network" end
logger.dbg("BookOrbit: bookmark exchange failed:", err)
result.had_errors = true
upload_failed = true
break
end
for _, hash in ipairs(body.unmatched or {}) do
if hash == opts.digest then
opts.state:setUnmatched(opts.digest)
return nil, "unmatched"
end
end
for _, hash in ipairs(body.positionIncompatible or {}) do
if hash == opts.digest then
opts.state:markPositionIncompatible(opts.digest)
return incompatibleResult()
end
end
response = body.results and body.results[1] or nil
result.uploaded = result.uploaded + #chunk
first_request = false
until cursor > #bookmarks
-- Recorded only when the complete key set went out and nothing was left
-- undelivered: the skip rule reads this stamp.
local function rememberComplete()
if upload_failed or result.had_errors or result.failed > 0 then return end
if not keys_complete then return end
BookOrbitBookmarks.rememberExchanged(book, opts.bm_signature)
end
if opts.apply_mode == "skip" then
if response and hasPending(response.toApply) then
result.remote_pending = {
to_apply = response.toApply,
more = response.more == true,
}
else
rememberComplete()
end
return result
end
local rounds = 0
local pull_complete = true
while response and hasPending(response.toApply) do
if rounds >= MAX_PULL_ROUNDS then
pull_complete = false
break
end
rounds = rounds + 1
local applied, deleted, touched, deleted_touched
if opts.apply_mode == "live" and opts.ui and opts.ui.document then
applied, deleted, touched, deleted_touched = BookOrbitBookmarks.applyLive(opts.ui, response.toApply)
elseif opts.apply_mode == "sidecar" and opts.file then
applied, deleted, touched, deleted_touched = BookOrbitBookmarks.applySidecar(opts.file, response.toApply)
else
pull_complete = false
break
end
deleted_touched = deleted_touched or 0
result.applied = result.applied + math.max(0, touched - deleted_touched)
result.deleted = result.deleted + deleted_touched
for _, entry in ipairs(applied) do
if entry.status == "failed" then result.failed = result.failed + 1 end
end
local ack_body, ack_err = opts.client:exchangeBookmarksAck({
{ hash = opts.digest, applied = applied, deleted = deleted },
})
if not ack_body then
logger.dbg("BookOrbit: bookmark exchange ack failed:", ack_err)
result.had_errors = true
break
end
if not response.more then break end
local body, err = opts.client:exchangeBookmarks({
{ hash = opts.digest, keys = {}, keysComplete = false, changes = {} },
})
if not body then
logger.dbg("BookOrbit: bookmark exchange follow-up failed:", err)
result.had_errors = true
break
end
response = body.results and body.results[1] or nil
end
if pull_complete then rememberComplete() end
return result
end
-- Convenience wrapper for the open book: collects live dogears, exchanges and
-- applies in place.
function BookOrbitBookmarks.exchangeOpenBook(opts)
local ui = opts.ui
if not ui or not ui.document then return nil, "no_document" end
-- Paging documents store page numbers, which normalizeBookmarks drops, so a
-- PDF simply produces an empty set instead of needing its own branch.
local bookmarks, _max, signature =
BookOrbitSidecar.normalizeBookmarks(ui.annotation and ui.annotation.annotations)
return BookOrbitBookmarks.exchangeBook({
client = opts.client,
state = opts.state,
digest = opts.digest,
bookmarks = bookmarks,
bm_signature = signature,
sidecar_state = opts.sidecar_state,
apply_mode = "live",
ui = ui,
file = ui.document.file,
})
end
return BookOrbitBookmarks