mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
1c27e5dd05
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
464 lines
19 KiB
Lua
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
|