Files
bookorbit/koreader-plugin/bookorbit.koplugin/bookorbit_sidecar.lua
T
neon dbb10b5c2f feat(koreader): add bookmark sync and exchange support
Add two-way bookmark sync support between KOReader plugin and server. Include migration 0056 for bookmark sync columns, server endpoints and services for bookmark exchange, and KOReader plugin bookmark management with comprehensive unit and E2E tests.
2026-07-28 04:34:46 +00:00

381 lines
14 KiB
Lua

--[[--
Extracts syncable data from a book's sidecar without opening the document.
The normalizers are shared with the per-book snapshot sync, which feeds them
the live in-memory annotation list and summary table instead of sidecar data
(both have the same shape), so server-side annotation keys stay stable no
matter which path uploaded them.
]]
local DocSettings = require("docsettings")
local BookList = require("ui/widget/booklist")
local lfs = require("libs/libkoreader-lfs")
local ALLOWED_DRAWERS = {
lighten = true,
underscore = true,
strikeout = true,
invert = true,
}
local ALLOWED_STATUSES = {
reading = true,
complete = true,
abandoned = true,
}
local BookOrbitSidecar = {}
function BookOrbitSidecar.sidecarMtime(file)
local sidecar_file = DocSettings:findSidecarFile(file)
if not sidecar_file then return nil end
return lfs.attributes(sidecar_file, "modification")
end
local function isDeviceDatetime(value)
return type(value) == "string" and value:match("^%d%d%d%d%-%d%d%-%d%d %d%d:%d%d:%d%d$") ~= nil
end
local function isDateOnly(value)
return type(value) == "string" and value:match("^%d%d%d%d%-%d%d%-%d%d$") ~= nil
end
local function truncate(value, max_len)
if type(value) ~= "string" then return nil end
if #value > max_len then
return value:sub(1, max_len)
end
return value
end
-- PDF positions are tables; serialize them canonically by hand so the
-- server-side annotation key (md5 of datetime|pos0) stays stable across syncs.
local function serializePos(pos)
if type(pos) == "string" then
return pos, "xpointer"
end
if type(pos) == "table" and type(pos.page) == "number" then
return string.format('{"page":%d,"x":%.2f,"y":%.2f}', pos.page, tonumber(pos.x) or 0, tonumber(pos.y) or 0), "pdf"
end
return nil
end
-- Order-independent change signal for one normalized entry. Count plus max
-- datetime cannot see a delete-plus-add that leaves both unchanged, so every
-- entry contributes a hash of its identity fields. Positions can be kilobytes
-- long, so only their length and both ends are sampled: this signal decides
-- whether an unchanged book may skip an exchange, never what gets uploaded.
local function entryHash(entry)
local pos0 = entry.pos0 or ""
local key = entry.datetime .. "|" .. #pos0 .. "|" .. pos0:sub(1, 24) .. "|" .. pos0:sub(-24)
.. "|" .. (entry.datetimeUpdated or "")
local hash = 5381
for index = 1, #key do
hash = (hash * 33 + key:byte(index)) % 4294967296
end
return hash
end
-- Returns normalized annotations, the max effective datetime and a signature
-- that changes whenever the local set changes; accepts the raw sidecar list or
-- the live ui.annotation.annotations array.
function BookOrbitSidecar.normalizeAnnotations(raw)
local annotations = {}
local max_datetime = ""
local sum, mix = 0, 0
for _, a in ipairs(raw or {}) do
-- drawer == nil marks a position-only bookmark; those are skipped in v1.
if ALLOWED_DRAWERS[a.drawer] and isDeviceDatetime(a.datetime) then
local pos0, pos_format = serializePos(a.pos0)
if pos0 then
local pos1 = serializePos(a.pos1)
local entry = {
datetime = a.datetime,
datetimeUpdated = isDeviceDatetime(a.datetime_updated) and a.datetime_updated or nil,
drawer = a.drawer,
color = truncate(a.color, 30),
text = truncate(a.text, 10000),
note = truncate(a.note, 5000),
chapter = truncate(a.chapter, 500),
pageno = type(a.pageno) == "number" and math.floor(a.pageno) or nil,
posFormat = pos_format,
pos0 = truncate(pos0, 4000),
pos1 = truncate(pos1, 4000),
}
table.insert(annotations, entry)
local effective = entry.datetimeUpdated or entry.datetime
if effective > max_datetime then
max_datetime = effective
end
local hash = entryHash(entry)
sum = (sum + hash) % 4294967296
mix = (mix + hash * (hash % 8191 + 1)) % 4294967296
end
end
end
return annotations, max_datetime,
string.format("%d:%s:%d:%d", #annotations, max_datetime, sum, mix)
end
-- Position-only bookmarks carry no text, so a rename is only visible in the
-- fields themselves. KOReader also leaves datetime_updated untouched when the
-- user edits a dogear note, which is why the note and chapter are hashed here
-- and why the exchange uploads the whole set rather than a datetime delta.
local function bookmarkHash(entry)
local pos = entry.pos or ""
local key = entry.datetime .. "|" .. #pos .. "|" .. pos:sub(1, 24) .. "|" .. pos:sub(-24)
.. "|" .. (entry.datetimeUpdated or "") .. "|" .. (entry.note or "") .. "|" .. (entry.chapter or "")
local hash = 5381
for index = 1, #key do
hash = (hash * 33 + key:byte(index)) % 4294967296
end
return hash
end
-- Returns normalized position-only bookmarks (dogears), the max effective
-- datetime and a signature that changes whenever the local set changes.
-- Paging documents store a page number instead of an xpointer; those are out of
-- scope, so only string positions are kept.
function BookOrbitSidecar.normalizeBookmarks(raw)
local bookmarks = {}
local max_datetime = ""
local sum, mix = 0, 0
for _, a in ipairs(raw or {}) do
if a.drawer == nil and isDeviceDatetime(a.datetime) and type(a.page) == "string" and a.page ~= "" then
local entry = {
datetime = a.datetime,
datetimeUpdated = isDeviceDatetime(a.datetime_updated) and a.datetime_updated or nil,
pos = truncate(a.page, 4000),
pageno = type(a.pageno) == "number" and math.floor(a.pageno) or nil,
chapter = truncate(a.chapter, 500),
note = truncate(a.note, 500),
}
table.insert(bookmarks, entry)
local effective = entry.datetimeUpdated or entry.datetime
if effective > max_datetime then
max_datetime = effective
end
local hash = bookmarkHash(entry)
sum = (sum + hash) % 4294967296
mix = (mix + hash * (hash % 8191 + 1)) % 4294967296
end
end
return bookmarks, max_datetime,
string.format("%d:%s:%d:%d", #bookmarks, max_datetime, sum, mix)
end
-- Validates a summary table (sidecar or live doc_settings reference) into a
-- fresh plain table; never returns the input reference.
function BookOrbitSidecar.normalizeSummary(summary)
if type(summary) ~= "table" then summary = {} end
local rating = nil
if type(summary.rating) == "number" and summary.rating >= 1 and summary.rating <= 5 then
rating = math.floor(summary.rating)
end
local review_note = truncate(summary.note, 10000)
return {
status = ALLOWED_STATUSES[summary.status] and summary.status or nil,
status_modified = isDateOnly(summary.modified) and summary.modified or nil,
rating = rating,
review_note = review_note,
}
end
local function dateKey(value)
if type(value) ~= "string" then return nil end
local key = value:sub(1, 10)
return isDateOnly(key) and key or nil
end
local function maxDateKey(a, b)
if not a then return b end
if not b then return a end
return a >= b and a or b
end
function BookOrbitSidecar.stateFromServerResult(result)
if type(result) ~= "table" then return nil end
local has_rating = type(result.ratingSet) == "boolean"
local has_review = type(result.reviewNoteSet) == "boolean"
if not has_rating and not has_review then return nil end
local state = {
rating_known = has_rating,
rating = has_rating and result.ratingSet and type(result.rating) == "number" and math.floor(result.rating) or nil,
review_known = has_review,
review_note = has_review and result.reviewNoteSet and truncate(result.reviewNote, 10000) or nil,
modified = maxDateKey(dateKey(result.ratingUpdatedAt), dateKey(result.reviewUpdatedAt)),
}
return state
end
function BookOrbitSidecar.rememberServerState(book, state)
if not book or not state then return end
if state.rating_known then
book.ratingSyncedKnown = true
book.ratingSynced = state.rating
end
if state.review_known then
book.reviewSyncedKnown = true
book.reviewSyncedNote = state.review_note
end
end
function BookOrbitSidecar.rememberUploadedState(book, summary, payload)
if not book or not payload then return end
if payload.rating ~= nil or payload.ratingCleared then
book.ratingSyncedKnown = true
book.ratingSynced = summary.rating
end
if payload.reviewNote ~= nil or payload.reviewCleared then
book.reviewSyncedKnown = true
book.reviewSyncedNote = summary.review_note
end
end
function BookOrbitSidecar.buildStatePayload(hash, book, summary, force_pull)
local status_changed = summary.status ~= nil
and (summary.status_modified or "") ~= (book.statusSyncedModified or "")
local rating_known = book.ratingSyncedKnown == true or book.ratingSynced ~= nil
local rating_changed = false
if summary.rating ~= nil then
rating_changed = not rating_known or summary.rating ~= book.ratingSynced
elseif rating_known and book.ratingSynced ~= nil then
rating_changed = true
end
local review_known = book.reviewSyncedKnown == true or book.reviewSyncedNote ~= nil
local review_changed = false
if summary.review_note ~= nil then
review_changed = not review_known or summary.review_note ~= book.reviewSyncedNote
elseif review_known and book.reviewSyncedNote ~= nil then
review_changed = true
end
if not status_changed and not rating_changed and not review_changed then
return force_pull and { hash = hash } or nil
end
local payload = { hash = hash }
if status_changed then
payload.status = summary.status
payload.statusModified = summary.status_modified
end
if rating_changed then
if summary.rating ~= nil then
payload.rating = summary.rating
else
payload.ratingCleared = true
end
payload.statusModified = payload.statusModified or summary.status_modified
end
if review_changed then
if summary.review_note ~= nil then
payload.reviewNote = summary.review_note
else
payload.reviewCleared = true
end
payload.reviewModified = summary.status_modified
end
return payload, status_changed, rating_changed, review_changed
end
-- A forced pull is the only way the device learns a rating or review written
-- on the web, but paying for it on every drained lifecycle entry is what made
-- an unchanged book still cost a request. Bound it by age instead: manual sync
-- and the sweep pull unconditionally, everything else once per interval.
BookOrbitSidecar.STATE_PULL_MAX_AGE = 24 * 3600
function BookOrbitSidecar.needsStatePull(book, now)
local pulled_at = book and book.statePulledAt
if type(pulled_at) ~= "number" then return true end
now = now or os.time()
if pulled_at > now then return true end
return (now - pulled_at) > BookOrbitSidecar.STATE_PULL_MAX_AGE
end
function BookOrbitSidecar.markStatePulled(book, now)
if not book then return end
book.statePulledAt = now or os.time()
end
local function applySummary(doc_settings, state)
if not doc_settings or not state then return false end
local summary = doc_settings:readSetting("summary") or {}
local touched = false
if state.rating_known and summary.rating ~= state.rating then
summary.rating = state.rating
touched = true
end
if state.review_known and summary.note ~= state.review_note then
summary.note = state.review_note
touched = true
end
if touched then
summary.modified = state.modified or os.date("%Y-%m-%d", os.time())
doc_settings:saveSetting("summary", summary)
end
return touched
end
function BookOrbitSidecar.applyServerStateLive(ui, state)
if not ui or not ui.doc_settings then return false end
local touched = applySummary(ui.doc_settings, state)
if touched and ui.document and ui.document.file and state.rating_known then
BookList.setBookInfoCacheProperty(ui.document.file, "rating", state.rating)
end
return touched
end
function BookOrbitSidecar.applyServerStateSidecar(file, state)
if not file or not state then return false end
local doc_settings = DocSettings:open(file)
local touched = applySummary(doc_settings, state)
if touched then
doc_settings:flush()
if state.rating_known then
BookList.setBookInfoCacheProperty(file, "rating", state.rating)
end
end
return touched
end
function BookOrbitSidecar.extract(file)
if not DocSettings:hasSidecarFile(file) then return nil end
local doc_settings = DocSettings:open(file)
local summary = BookOrbitSidecar.normalizeSummary(doc_settings:readSetting("summary"))
local raw_annotations = doc_settings:readSetting("annotations")
local annotations, max_datetime, signature = BookOrbitSidecar.normalizeAnnotations(raw_annotations)
local bookmarks, bm_max_datetime, bm_signature = BookOrbitSidecar.normalizeBookmarks(raw_annotations)
local last_position = doc_settings:readSetting("last_xpointer")
if not last_position then
local last_page = doc_settings:readSetting("last_page")
if last_page then
last_position = tostring(last_page)
end
end
return {
md5 = doc_settings:readSetting("partial_md5_checksum"),
percent_finished = doc_settings:readSetting("percent_finished"),
last_position = last_position,
status = summary.status,
status_modified = summary.status_modified,
rating = summary.rating,
review_note = summary.review_note,
annotations = annotations,
annotations_count = #annotations,
annotations_max_datetime = max_datetime,
annotations_signature = signature,
bookmarks = bookmarks,
bookmarks_count = #bookmarks,
bookmarks_max_datetime = bm_max_datetime,
bookmarks_signature = bm_signature,
}
end
return BookOrbitSidecar