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.
This commit is contained in:
neon
2026-07-28 04:34:46 +00:00
parent e0ca660f2e
commit dbb10b5c2f
47 changed files with 18917 additions and 106 deletions
+11 -4
View File
@@ -48,23 +48,30 @@ From the dashboard menu, the plugin update check is top-level; from the Tools me
A first sync on a device with years of history uploads in batches of 500 events (roughly 400 requests per 200k events, a few minutes of background work), and is safe to interrupt.
## Two-way highlight sync
## Two-way highlight and bookmark sync
Requires server 0.4+; against an older server the plugin falls back to upload-only automatically.
Requires server 0.4+; against an older server the plugin falls back to upload-only automatically. Bookmark sync additionally requires a server that advertises the `bookmarkSync` capability; on an older one the bookmark paths stay dormant and highlights sync exactly as before.
- Device to web: highlight positions (crengine xpointers) convert to reader CFIs, verified against the highlighted text, so they render as native, editable highlights on the web.
- Web to device: applied on book open (with auto sync on), on manual sync, and during the full sweep for closed books, with positions re-verified/re-anchored against the text.
- Deletions go both ways through a trash/restore flow, so nothing is lost outright.
- Identity is the highlight's creation datetime plus position, so extending a highlight on the device is recognized as a move, not a delete-and-recreate.
- Styles map across formats (e.g. squiggly <-> underline, invert, named colors <-> hex).
- Toggle via Settings > Sync > "Two-way highlight sync" (uploads keep working either way).
- Toggle via Settings > Sync > "Two-way highlights & bookmarks" (uploads keep working either way).
Position-only bookmarks (dogears) sync the same way:
- Device to web: the dogear xpointer converts to a reader CFI and shows up in the web reader's bookmark list. Its title is the note you attached, else the chapter, else the page.
- Web to device: applied on book open, on manual sync, and during the full sweep for closed books. The position is only placed when it resolves; an entry that does not is retried on the next exchange.
- Deletions and device-side renames propagate; the web has no rename surface yet, so edits flow device to web only.
- A dogear has no highlighted text to re-anchor against, so a converted position can sit slightly off where a highlight would have been repaired exactly.
## Limitations
- Web highlight changes reach a closed book only via a manual sweep or its next open.
- Web-created PDF highlights aren't supported (device PDF highlights sync up but aren't drawn over the PDF).
- Books with reading stats but no sidecar path sync reading time only, until the full sweep covers them.
- Position-only bookmarks (no highlighted text) don't sync.
- PDF bookmarks don't sync in either direction (the web has no PDF bookmark surface).
- A device clock far in the past can delay edit detection for web-modified highlights.
- The full sweep is manual-only; books you never reopen only sync when you run it.
- A cloned device with a different KOReader `device_id` double-counts reading time; clones keeping the same `device_id` deduplicate naturally.
@@ -517,6 +517,14 @@ function BookOrbitApi:exchangeAck(books)
return self:request("POST", "/koreader/plugin/annotations/exchange-ack", self:withDevice({ books = books }))
end
function BookOrbitApi:exchangeBookmarks(books)
return self:request("POST", "/koreader/plugin/bookmarks/exchange", self:withDevice({ books = books }))
end
function BookOrbitApi:exchangeBookmarksAck(books)
return self:request("POST", "/koreader/plugin/bookmarks/exchange-ack", self:withDevice({ books = books }))
end
function BookOrbitApi:uploadBookStates(books)
return self:request("POST", "/koreader/plugin/book-states", self:withDevice({ books = books }))
end
@@ -22,6 +22,7 @@ local _ = require("gettext")
local BookOrbitAnnotations = require("bookorbit_annotations")
local BookOrbitApi = require("bookorbit_api")
local BookOrbitBookmarks = require("bookorbit_bookmarks")
local BookOrbitHighlightSummary = require("bookorbit_highlight_summary")
local BookOrbitSidecar = require("bookorbit_sidecar")
local BookOrbitState = require("bookorbit_state")
@@ -73,8 +74,9 @@ function BookOrbitBookSync.capture(plugin)
if not digest then return nil end
local started_ms = nowMs()
local annotations, ann_max, ann_signature =
BookOrbitSidecar.normalizeAnnotations(ui.annotation and ui.annotation.annotations)
local live_annotations = ui.annotation and ui.annotation.annotations
local annotations, ann_max, ann_signature = BookOrbitSidecar.normalizeAnnotations(live_annotations)
local bookmarks, _bm_max, bm_signature = BookOrbitSidecar.normalizeBookmarks(live_annotations)
local summary = BookOrbitSidecar.normalizeSummary(ui.doc_settings:readSetting("summary"))
local stats = ui.statistics
@@ -119,6 +121,8 @@ function BookOrbitBookSync.capture(plugin)
ann_count = #annotations,
ann_max_datetime = ann_max,
ann_signature = ann_signature,
bookmarks = bookmarks,
bm_signature = bm_signature,
mtime_at_capture = file and BookOrbitSidecar.sidecarMtime(file) or nil,
ts = ts,
}
@@ -216,7 +220,7 @@ local function finish(ctx, err)
text = text .. "\n" .. _("BookOrbit server needs an update for two-way highlights.")
end
if ctx.highlight_disabled_reported then
text = text .. "\n" .. _("Two-way highlight sync is disabled.")
text = text .. "\n" .. _("Two-way highlight and bookmark sync is disabled.")
end
if ctx.skip_progress then
text = text .. "\n" .. _("Progress was not changed.")
@@ -266,7 +270,7 @@ local function step(ctx, fn)
end)
end
local stepMatch, stepStats, stepAnnotations, stepAnnotationsLegacy, stepState, stepProgress
local stepMatch, stepStats, stepAnnotations, stepAnnotationsLegacy, stepBookmarks, stepState, stepProgress
stepMatch = function(ctx)
if ctx.acknowledged.match then
@@ -374,7 +378,7 @@ end
-- while the book is open, sidecar on the close path) and acks them.
stepAnnotations = function(ctx)
if ctx.acknowledged.annotations then
return step(ctx, stepState)
return step(ctx, stepBookmarks)
end
local book = ctx.state:getBook(ctx.snap.digest)
if not book then return finish(ctx, "unmatched") end
@@ -401,7 +405,7 @@ stepAnnotations = function(ctx)
and BookOrbitAnnotations.canSkipExchange(book, ctx.snap.ann_signature) then
logger.dbg("BookOrbit: book sync annotation exchange skipped, local set unchanged")
if not acknowledge(ctx, "annotations", false) then return end
return step(ctx, stepState)
return step(ctx, stepBookmarks)
end
if pending and apply_mode == "sidecar" then
@@ -445,7 +449,7 @@ stepAnnotations = function(ctx)
end
ctx.had_errors = true
ctx.highlight_summary = BookOrbitHighlightSummary.add(ctx.highlight_summary, { had_errors = true })
return step(ctx, stepState)
return step(ctx, stepBookmarks)
end
if result.had_errors then ctx.had_errors = true end
@@ -469,7 +473,7 @@ stepAnnotations = function(ctx)
return
end
end
return step(ctx, stepState)
return step(ctx, stepBookmarks)
end
-- One-way upload for servers without the 0.4 exchange endpoint.
@@ -498,7 +502,7 @@ stepAnnotationsLegacy = function(ctx)
book.annCount = ctx.snap.ann_count
if not acknowledge(ctx, "annotations") then return end
end
return step(ctx, stepState)
return step(ctx, stepBookmarks)
end
-- Consumed through a head cursor: removing from the front of a Lua array
@@ -518,7 +522,7 @@ stepAnnotationsLegacy = function(ctx)
ctx.highlight_summary = BookOrbitHighlightSummary.add(ctx.highlight_summary, { had_errors = true })
if isTransportError(err) then return finish(ctx, "network") end
logger.dbg("BookOrbit: book sync annotations upload failed:", err)
return step(ctx, stepState)
return step(ctx, stepBookmarks)
end
if isUnmatched(body, ctx.snap.digest) then
ctx.state:setUnmatched(ctx.snap.digest)
@@ -530,6 +534,86 @@ stepAnnotationsLegacy = function(ctx)
return step(ctx, stepAnnotationsLegacy)
end
-- Two-way dogear exchange. It shares the annotations phase acknowledgement, so
-- an outbox entry queued before bookmark sync existed still drains: only the
-- free-form remote_pending kind is new.
stepBookmarks = function(ctx)
if not ctx.annotation_sync or not BookOrbitBookmarks.enabled(ctx.client, true) then
return step(ctx, stepState)
end
local book = ctx.state:getBook(ctx.snap.digest)
if not book then return finish(ctx, "unmatched") end
local apply_mode = "skip"
if (ctx.reason == "close" or ctx.reason == "suspend" or ctx.reason == "recovery")
and ctx.snap.file and lfs.attributes(ctx.snap.file, "mode") == "file" then
apply_mode = "sidecar"
elseif ctx.reason == "manual" and ctx.plugin and ctx.plugin.ui and ctx.plugin.ui.document then
apply_mode = "live"
end
local pending = ctx.remote_pending and ctx.remote_pending.bookmarks
if not pending and not ctx.interactive
and BookOrbitBookmarks.canSkipExchange(book, ctx.snap.bm_signature) then
logger.dbg("BookOrbit: book sync bookmark exchange skipped, local set unchanged")
return step(ctx, stepState)
end
if pending and apply_mode == "sidecar" then
local applied, deleted = BookOrbitBookmarks.applySidecar(ctx.snap.file, pending.to_apply or {})
local ack_body, ack_err = ctx.client:exchangeBookmarksAck({
{ hash = ctx.snap.digest, applied = applied, deleted = deleted },
})
if not ack_body then
if isAuthError(ack_err) then return finish(ctx, "auth") end
return finish(ctx, "network")
end
if ctx.on_remote_applied then
local ok, cleared = pcall(ctx.on_remote_applied, "bookmarks")
if not ok or cleared == false then return finish(ctx, "outbox") end
end
ctx.remote_pending.bookmarks = nil
end
local result, err = BookOrbitBookmarks.exchangeBook({
client = ctx.client,
state = ctx.state,
digest = ctx.snap.digest,
bookmarks = ctx.snap.bookmarks,
bm_signature = ctx.snap.bm_signature,
apply_mode = apply_mode,
ui = apply_mode == "live" and ctx.plugin.ui or nil,
file = ctx.snap.file,
})
if not result then
if err == "auth" then return finish(ctx, "auth") end
if err == "network" then
ctx.had_errors = true
return finish(ctx, "network")
end
if err == "unmatched" then return finish(ctx, "unmatched") end
if err == "unsupported_server" then
BookOrbitBookmarks.markUnsupported(ctx.client)
return step(ctx, stepState)
end
ctx.had_errors = true
ctx.highlight_summary = BookOrbitHighlightSummary.addBookmarks(ctx.highlight_summary, { had_errors = true })
return step(ctx, stepState)
end
if result.had_errors then ctx.had_errors = true end
ctx.counts.bookmarks = (ctx.counts.bookmarks or 0) + result.uploaded
ctx.highlight_summary = BookOrbitHighlightSummary.addBookmarks(ctx.highlight_summary, result)
if not result.had_errors and result.remote_pending and ctx.on_remote_pending then
local ok, recorded = pcall(ctx.on_remote_pending, "bookmarks", result.remote_pending)
if not ok or recorded == false then
ctx.had_errors = true
return finish(ctx, "outbox")
end
end
return step(ctx, stepState)
end
stepState = function(ctx)
if ctx.acknowledged.state then
return step(ctx, stepProgress)
@@ -0,0 +1,427 @@
--[[--
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
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
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
--[[--
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
local bookmarks = opts.bookmarks or {}
local keys = BookOrbitBookmarks.collectKeys(bookmarks)
local keys_complete = #keys <= MAX_KEYS_PER_BOOK
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,
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
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,
apply_mode = "live",
ui = ui,
})
end
return BookOrbitBookmarks
@@ -26,6 +26,22 @@ function HighlightDiagnostics.lastSyncText(summary, format_time)
return text
end
function HighlightDiagnostics.lastBookmarkSyncText(summary, format_time)
if type(summary) ~= "table" then return _("never") end
local uploaded = count(summary.bm_uploaded)
local applied = count(summary.bm_applied)
local deleted = count(summary.bm_deleted)
local failed = count(summary.bm_failed)
if uploaded + applied + deleted + failed == 0 then return _("none") end
local text = T(_("%1 uploaded, %2 applied, %3 deleted, %4 failed"), uploaded, applied, deleted, failed)
local at = tonumber(summary.at)
if at and at > 0 then
local when = format_time and format_time(at) or os.date("%H:%M", at)
text = T(_("%1 at %2"), text, when)
end
return text
end
function HighlightDiagnostics.retryText(status)
if type(status) == "table" and status.pending then
return _("pending")
@@ -19,6 +19,15 @@ local COUNT_FIELDS = {
"touched_books",
}
-- Bookmarks are counted separately from highlights: they are a different user
-- surface, and folding them together would make "3 applied" unreadable.
local BOOKMARK_COUNT_FIELDS = {
"bm_uploaded",
"bm_applied",
"bm_deleted",
"bm_failed",
}
local ACTIONABLE_ERRORS = {
auth = true,
network = true,
@@ -43,6 +52,9 @@ function HighlightSummary.normalize(summary)
for _, field in ipairs(COUNT_FIELDS) do
normalized[field] = numberValue(summary[field])
end
for _, field in ipairs(BOOKMARK_COUNT_FIELDS) do
normalized[field] = numberValue(summary[field])
end
return normalized
end
@@ -66,12 +78,35 @@ function HighlightSummary.add(summary, result, opts)
return summary
end
-- Bookmark exchange results share the highlight result shape, so they are
-- folded into their own counters rather than the highlight ones.
function HighlightSummary.addBookmarks(summary, result)
summary = HighlightSummary.normalize(summary)
result = result or {}
summary.bm_uploaded = summary.bm_uploaded + numberValue(result.uploaded)
summary.bm_applied = summary.bm_applied + numberValue(result.applied)
summary.bm_deleted = summary.bm_deleted + numberValue(result.deleted)
summary.bm_failed = summary.bm_failed + numberValue(result.failed)
if result.had_errors and numberValue(result.failed) == 0 then
summary.bm_failed = summary.bm_failed + 1
end
return summary
end
function HighlightSummary.hasBookmarkCounts(summary)
summary = HighlightSummary.normalize(summary)
for _, field in ipairs(BOOKMARK_COUNT_FIELDS) do
if summary[field] > 0 then return true end
end
return false
end
function HighlightSummary.hasCounts(summary)
summary = HighlightSummary.normalize(summary)
for _, field in ipairs(COUNT_FIELDS) do
if summary[field] > 0 then return true end
end
return false
return HighlightSummary.hasBookmarkCounts(summary)
end
function HighlightSummary.hasRemoteChanges(summary)
@@ -79,10 +114,15 @@ function HighlightSummary.hasRemoteChanges(summary)
return summary.applied + summary.deleted > 0
end
function HighlightSummary.hasRemoteBookmarkChanges(summary)
summary = HighlightSummary.normalize(summary)
return summary.bm_applied + summary.bm_deleted > 0
end
function HighlightSummary.actionableError(summary, err)
if ACTIONABLE_ERRORS[err] then return err end
summary = HighlightSummary.normalize(summary)
if summary.failed > 0 then return "partial_failure" end
if summary.failed > 0 or summary.bm_failed > 0 then return "partial_failure" end
end
function HighlightSummary.message(summary)
@@ -92,6 +132,13 @@ function HighlightSummary.message(summary)
if summary.failed > 0 or summary.skipped > 0 then
text = text .. "\n" .. T(_("Failed: %1. Skipped: %2."), summary.failed, summary.skipped)
end
if HighlightSummary.hasBookmarkCounts(summary) then
text = text .. "\n" .. T(_("Bookmarks: %1 uploaded, %2 applied, %3 deleted."),
summary.bm_uploaded, summary.bm_applied, summary.bm_deleted)
if summary.bm_failed > 0 then
text = text .. "\n" .. T(_("Bookmarks failed: %1."), summary.bm_failed)
end
end
if summary.touched_books > 0 then
text = text .. "\n" .. T(_("%1 closed book(s) updated."), summary.touched_books)
end
@@ -189,7 +189,7 @@ function MainMenu:diagnosticsRows()
table.insert(rows, "----------------------------")
table.insert(rows, { _("Auto sync"), formatBool(self.settings.auto_sync) })
table.insert(rows, { _("Two-way highlights"), formatBool(self.settings.annotation_sync) })
table.insert(rows, { _("Two-way highlights & bookmarks"), formatBool(self.settings.annotation_sync) })
table.insert(rows, { _("Open highlights"), open_highlight_text })
table.insert(rows, { _("Highlight apply retry"), BookOrbitHighlightDiagnostics.retryText(self.open_highlight_retry_status) })
table.insert(rows, { _("Skip offline auto-sync"), formatBool(self.settings.skip_sync_when_offline) })
@@ -206,6 +206,8 @@ function MainMenu:diagnosticsRows()
table.insert(rows, "----------------------------")
table.insert(rows, { _("Last sync"), recordText(self.settings.last_sync) })
table.insert(rows, { _("Last highlight sync"), highlightRecordText(self.settings.last_highlight_sync) })
table.insert(rows, { _("Last bookmark sync"),
BookOrbitHighlightDiagnostics.lastBookmarkSyncText(self.settings.last_highlight_sync, formatShortTime) })
table.insert(rows, { _("Last error"), recordText(self.settings.last_error) })
table.insert(rows, "----------------------------")
if has_open_book then
@@ -452,9 +454,9 @@ If set to 0, updating progress based on page turns will be disabled.]]),
end,
})
table.insert(items, {
text = _("Two-way highlight sync"),
text = _("Two-way highlights & bookmarks"),
checked_func = function() return self.settings.annotation_sync end,
help_text = _([[Also applies highlights, notes and deletions made in BookOrbit to this device: on book open, after the manual book sync, and during the full sweep for closed books. Turning this off keeps uploads only.]]),
help_text = _([[Also applies highlights, notes, bookmarks (dogears) and deletions made in BookOrbit to this device: on book open, after the manual book sync, and during the full sweep for closed books. Turning this off keeps uploads only. Bookmark sync needs a server that supports it, and PDF bookmarks never sync.]]),
callback = function()
self.settings.annotation_sync = not self.settings.annotation_sync
end,
@@ -117,6 +117,53 @@ function BookOrbitSidecar.normalizeAnnotations(raw)
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)
@@ -299,8 +346,9 @@ function BookOrbitSidecar.extract(file)
local doc_settings = DocSettings:open(file)
local summary = BookOrbitSidecar.normalizeSummary(doc_settings:readSetting("summary"))
local annotations, max_datetime, signature =
BookOrbitSidecar.normalizeAnnotations(doc_settings:readSetting("annotations"))
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
@@ -322,6 +370,10 @@ function BookOrbitSidecar.extract(file)
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
@@ -31,6 +31,7 @@ local T = ffiutil.template
local _ = require("gettext")
local BookOrbitAnnotations = require("bookorbit_annotations")
local BookOrbitBookmarks = require("bookorbit_bookmarks")
local BookOrbitApi = require("bookorbit_api")
local BookOrbitHighlightSummary = require("bookorbit_highlight_summary")
local BookOrbitQueue = require("bookorbit_queue")
@@ -253,7 +254,7 @@ local function finish(ctx, err)
text = text .. "\n" .. _("BookOrbit server needs an update for two-way highlights.")
end
if ctx.highlight_disabled_reported then
text = text .. "\n" .. _("Two-way highlight sync is disabled.")
text = text .. "\n" .. _("Two-way highlight and bookmark sync is disabled.")
end
if ctx.had_errors then
text = text .. "\n" .. _("Some books failed and will retry on the next sync.")
@@ -745,6 +746,8 @@ local function sidecarEntry(ctx, md5)
annotations = extract.annotations,
ann_max_datetime = extract.annotations_max_datetime,
ann_signature = extract.annotations_signature,
bookmarks = extract.bookmarks,
bm_signature = extract.bookmarks_signature,
})
end
@@ -815,6 +818,48 @@ local function currentlyOpenFile()
end
end
-- Dogears share the sidecar and the apply mode with highlights, so the sweep
-- exchanges both for the same book in one pass. A server without the route is
-- recorded once and skipped for the rest of the run.
local function exchangeBookmarksForEntry(ctx, entry, apply_mode, pending)
if not ctx.annotation_sync or ctx.bookmarks_unsupported then return end
if not BookOrbitBookmarks.enabled(ctx.client, true) then
ctx.bookmarks_unsupported = true
return
end
local book = ctx.state:getBook(entry.md5)
if not book then return end
if not ctx.interactive and BookOrbitBookmarks.canSkipExchange(book, entry.bm_signature) then return end
local result, err = BookOrbitBookmarks.exchangeBook({
client = ctx.client,
state = ctx.state,
digest = entry.md5,
bookmarks = entry.bookmarks,
bm_signature = entry.bm_signature,
apply_mode = apply_mode,
file = entry.file,
})
if not result then
if err == "unsupported_server" then
BookOrbitBookmarks.markUnsupported(ctx.client)
ctx.bookmarks_unsupported = true
return
end
ctx.had_errors = true
if pending then pending.failed = true end
ctx.highlight_summary = BookOrbitHighlightSummary.addBookmarks(ctx.highlight_summary, { had_errors = true })
logger.dbg("BookOrbit: bookmark exchange failed for", entry.md5, err)
return
end
if result.had_errors then
ctx.had_errors = true
if pending then pending.failed = true end
end
ctx.counts.bookmarks = (ctx.counts.bookmarks or 0) + result.uploaded
ctx.highlight_summary = BookOrbitHighlightSummary.addBookmarks(ctx.highlight_summary, result)
end
-- Phase 5: per-book annotation exchange. Uploads the local delta, reports the
-- key set for deletion detection and applies server-side changes into the
-- sidecar of closed books. Falls back to the legacy one-way upload when the
@@ -885,6 +930,9 @@ local function stepAnnotationsNext(ctx)
ctx.highlight_summary = BookOrbitHighlightSummary.add(ctx.highlight_summary, result, {
closed_book = apply_mode == "sidecar",
})
exchangeBookmarksForEntry(ctx, entry, apply_mode, pending)
if aborted(ctx) then return end
return step(ctx, ctx.steps.annotationsNext)
end
+49 -24
View File
@@ -30,6 +30,7 @@ local T = require("ffi/util").template
local _ = require("gettext")
local BookOrbitAnnotations = require("bookorbit_annotations")
local BookOrbitBookmarks = require("bookorbit_bookmarks")
local BookOrbitApi = require("bookorbit_api")
local BookOrbitBookSync = require("bookorbit_book_sync")
local BookOrbitCatalog = require("bookorbit_catalog")
@@ -1013,47 +1014,71 @@ function BookOrbit:exchangeAnnotationsForOpenBook(reason, retry_count)
end
self.annotation_exchange_running = true
local client = self:newClient()
local ok, result, err = pcall(BookOrbitAnnotations.exchangeOpenBook, {
client = self:newClient(),
client = client,
state = state,
digest = digest,
ui = self.ui,
})
local bm_ok, bm_result, bm_err
if BookOrbitBookmarks.enabled(client, self.settings.annotation_sync) then
bm_ok, bm_result, bm_err = pcall(BookOrbitBookmarks.exchangeOpenBook, {
client = client,
state = state,
digest = digest,
ui = self.ui,
})
if bm_ok and bm_err == "unsupported_server" then
BookOrbitBookmarks.markUnsupported(client)
end
end
state:flush()
self.annotation_exchange_running = false
local summary = { event = "open_book", reason = reason or "auto" }
local error_code
if not ok then
logger.err("BookOrbit: annotation exchange error:", result)
self:recordHighlightSync({
event = "open_book",
reason = reason or "auto",
failed = 1,
}, "partial_failure")
summary.failed = 1
error_code = "partial_failure"
elseif result then
local summary = BookOrbitHighlightSummary.add({
event = "open_book",
reason = reason or "auto",
}, result)
self:recordHighlightSync(summary)
if BookOrbitHighlightSummary.hasRemoteChanges(summary) then
Notification:notify(T(_("BookOrbit: %1 highlight(s) updated"),
summary.applied + summary.deleted))
summary = BookOrbitHighlightSummary.add(summary, result)
elseif err then
summary.skipped = err == "unmatched" and 1 or 0
summary.failed = (err == "network" or err == "unsupported_server") and 1 or 0
error_code = err
if err ~= "unmatched" and err ~= "unsupported_server" and err ~= "network" then
logger.dbg("BookOrbit: annotation exchange skipped:", err)
end
end
if bm_ok == false then
logger.err("BookOrbit: bookmark exchange error:", bm_result)
summary = BookOrbitHighlightSummary.addBookmarks(summary, { had_errors = true })
error_code = error_code or "partial_failure"
elseif bm_result then
summary = BookOrbitHighlightSummary.addBookmarks(summary, bm_result)
elseif bm_err and bm_err ~= "unmatched" and bm_err ~= "unsupported_server" then
summary = BookOrbitHighlightSummary.addBookmarks(summary, { had_errors = true })
error_code = error_code or bm_err
end
self:recordHighlightSync(summary, error_code)
if BookOrbitHighlightSummary.hasRemoteChanges(summary) then
Notification:notify(T(_("BookOrbit: %1 highlight(s) updated"),
summary.applied + summary.deleted))
end
if BookOrbitHighlightSummary.hasRemoteBookmarkChanges(summary) then
Notification:notify(T(_("BookOrbit: %1 bookmark(s) updated"),
summary.bm_applied + summary.bm_deleted))
end
if ok and result then
if reason == "annotation_open" and (summary.failed or 0) > 0 then
self:scheduleOpenHighlightRetry(reason, retry_count or 0)
elseif (summary.failed or 0) == 0 then
self.open_highlight_retry_status = nil
end
elseif err then
self:recordHighlightSync({
event = "open_book",
reason = reason or "auto",
skipped = err == "unmatched" and 1 or 0,
failed = (err == "network" or err == "unsupported_server") and 1 or 0,
}, err)
if err ~= "unmatched" and err ~= "unsupported_server" and err ~= "network" then
logger.dbg("BookOrbit: annotation exchange skipped:", err)
end
end
end
@@ -30,6 +30,13 @@ package.loaded["libs/libkoreader-lfs"] = {
package.loaded["ffi/util"] = { template = function(value) return value end }
package.loaded["gettext"] = function(value) return value end
package.loaded["bookorbit_bookmarks"] = {
enabled = function() return false end,
markUnsupported = function() end,
canSkipExchange = function() return false end,
rememberExchanged = function() end,
exchangeBook = function() error("bookmark sync is off in this fixture") end,
}
package.loaded["bookorbit_annotations"] = {
exchangeBook = function()
table.insert(calls, "annotations")
@@ -37,6 +37,13 @@ package.loaded["ffi/util"] = {
}
package.loaded["gettext"] = function(value) return value end
package.loaded["bookorbit_bookmarks"] = {
enabled = function() return false end,
markUnsupported = function() end,
canSkipExchange = function() return false end,
rememberExchanged = function() end,
exchangeBook = function() error("bookmark sync is off in this fixture") end,
}
package.loaded["bookorbit_annotations"] = {
exchangeBook = function(opts)
table.insert(calls, "annotations")
@@ -0,0 +1,245 @@
-- The bookmark exchange round trip: full-set upload with chunking, the pull
-- and ack loop, the skip stamp and the error classification the call sites
-- branch on.
package.path = "koreader-plugin/bookorbit.koplugin/?.lua;koreader-plugin/spec/?.lua;" .. package.path
local sidecar_annotations = {}
package.loaded["docsettings"] = {
open = function()
return {
readSetting = function(_, key)
if key == "annotations" then return sidecar_annotations end
end,
saveSetting = function(_, key, value)
if key == "annotations" then sidecar_annotations = value end
end,
makeTrue = function() end,
flush = function() end,
}
end,
hasSidecarFile = function() return false end,
findSidecarFile = function() return nil end,
}
package.loaded["ui/event"] = { new = function(_, name, payload) return { name = name, payload = payload } end }
package.loaded["ui/uimanager"] = { setDirty = function() end }
package.loaded["logger"] = { dbg = function() end }
package.loaded["ffi/sha2"] = { md5 = function(value) return value end }
package.loaded["ffi/util"] = { template = function(pattern, a) return (pattern:gsub("%%1", tostring(a))) end }
package.loaded["gettext"] = function(value) return value end
package.loaded["libs/libkoreader-lfs"] = { attributes = function() return nil end }
package.loaded["ui/widget/booklist"] = { setBookInfoCacheProperty = function() end }
package.loaded["bookorbit_capabilities"] = {
supports = function() return true end,
markUnsupported = function() end,
}
local BookOrbitBookmarks = require("bookorbit_bookmarks")
local function assertEqual(actual, expected, label)
if actual ~= expected then
error(string.format("%s: expected %s, got %s", label, tostring(expected), tostring(actual)), 2)
end
end
local function assertTrue(value, label)
if not value then error(label .. ": expected truthy", 2) end
end
local function makeState(book)
return {
getBook = function() return book end,
setUnmatched = function(self) self.unmatched = true end,
}
end
local function bookmark(datetime, pos)
return { datetime = datetime, pos = pos, pageno = 1 }
end
-- Upload: the whole local set goes out, chunked, with the key list only on the
-- first request. A watermark delta would drop renames, because KOReader leaves
-- datetime_updated alone when a dogear note is edited.
local requests = {}
local client = {
exchangeBookmarks = function(_, books)
table.insert(requests, books[1])
return { results = { { hash = "digest", toApply = { add = {}, delete = {} } } }, unmatched = {} }
end,
exchangeBookmarksAck = function() return { results = {} } end,
}
local many = {}
for index = 1, 120 do
table.insert(many, bookmark(string.format("2026-06-01 10:%02d:00", index % 60), "/body/p[" .. index .. "]"))
end
local book = {}
local result = BookOrbitBookmarks.exchangeBook({
client = client,
state = makeState(book),
digest = "digest",
bookmarks = many,
bm_signature = "sig-120",
apply_mode = "sidecar",
file = "/books/one.epub",
})
assertEqual(result.uploaded, 120, "every bookmark is uploaded")
assertEqual(#requests, 3, "120 bookmarks chunk into three requests")
assertEqual(#requests[1].keys, 120, "the full key set rides the first request")
assertEqual(requests[1].keysComplete, true, "the key set is complete")
assertEqual(#requests[2].keys, 0, "follow-up chunks carry no keys")
assertEqual(requests[2].keysComplete, false, "only the first request may drive deletion detection")
assertEqual(book.bmSignature, "sig-120", "a clean exchange records the skip stamp")
-- An empty local set still exchanges once, so the server can push web bookmarks
-- at a book the device has never bookmarked.
requests = {}
local empty_book = {}
local empty_result = BookOrbitBookmarks.exchangeBook({
client = client,
state = makeState(empty_book),
digest = "digest",
bookmarks = {},
bm_signature = "sig-empty",
apply_mode = "sidecar",
file = "/books/one.epub",
})
assertEqual(#requests, 1, "an empty set still asks the server")
assertEqual(empty_result.uploaded, 0, "nothing was uploaded")
-- Pull, apply and ack, then a follow-up round while the server reports more.
local rounds = 0
local acks = {}
sidecar_annotations = {}
local pull_client = {
exchangeBookmarks = function()
rounds = rounds + 1
if rounds == 1 then
return {
results = { {
hash = "digest",
toApply = { add = { { serverId = 1, pos = "/body/p[1]", title = "First" } }, delete = {} },
more = true,
} },
}
end
return {
results = { {
hash = "digest",
toApply = { add = { { serverId = 2, pos = "/body/p[2]", title = "Second" } }, delete = {} },
more = false,
} },
}
end,
exchangeBookmarksAck = function(_, books)
table.insert(acks, books[1])
return { results = {} }
end,
}
local pull_book = {}
local pull_result = BookOrbitBookmarks.exchangeBook({
client = pull_client,
state = makeState(pull_book),
digest = "digest",
bookmarks = {},
bm_signature = "sig-pull",
apply_mode = "sidecar",
file = "/books/one.epub",
})
assertEqual(pull_result.applied, 2, "both pushed bookmarks landed")
assertEqual(#acks, 2, "each round is acknowledged before the next pull")
assertEqual(acks[1].applied[1].serverId, 1, "the ack names the server id")
assertEqual(acks[1].applied[1].key ~= nil, true, "the ack carries the device identity")
assertEqual(#sidecar_annotations, 2, "both dogears are in the sidecar")
assertEqual(pull_book.bmSignature, "sig-pull", "a fully drained pull records the skip stamp")
-- Skip mode parks server changes instead of applying them, and must not record
-- the stamp: the next exchange has to come back for them.
local skip_book = {}
local skip_result = BookOrbitBookmarks.exchangeBook({
client = {
exchangeBookmarks = function()
return { results = { { hash = "digest", toApply = { add = { { serverId = 9, pos = "/body/p[9]", title = "Later" } } } } } }
end,
exchangeBookmarksAck = function() error("skip mode must not ack") end,
},
state = makeState(skip_book),
digest = "digest",
bookmarks = {},
bm_signature = "sig-skip",
apply_mode = "skip",
})
assertTrue(skip_result.remote_pending, "server changes are parked")
assertEqual(skip_result.remote_pending.to_apply.add[1].serverId, 9, "the parked payload is preserved")
assertEqual(skip_book.bmSignature, nil, "parked changes keep the next exchange mandatory")
-- Error classification the call sites branch on.
local function exchangeWithError(err)
return select(2, BookOrbitBookmarks.exchangeBook({
client = {
exchangeBookmarks = function() return nil, err end,
exchangeBookmarksAck = function() return { results = {} } end,
},
state = makeState({}),
digest = "digest",
bookmarks = {},
bm_signature = "sig-error",
apply_mode = "sidecar",
file = "/books/one.epub",
}))
end
assertEqual(exchangeWithError(401), "auth", "401 is an auth failure")
assertEqual(exchangeWithError(403), "auth", "403 is an auth failure")
assertEqual(exchangeWithError(404), "unsupported_server", "404 downgrades the capability")
assertEqual(exchangeWithError("timeout"), "network", "a transport failure is a network error")
-- An unmatched book stops the exchange and is remembered as unmatched.
local unmatched_state = makeState({})
local unmatched_result, unmatched_err = BookOrbitBookmarks.exchangeBook({
client = {
exchangeBookmarks = function() return { results = {}, unmatched = { "digest" } } end,
exchangeBookmarksAck = function() return { results = {} } end,
},
state = unmatched_state,
digest = "digest",
bookmarks = {},
bm_signature = "sig-unmatched",
apply_mode = "sidecar",
file = "/books/one.epub",
})
assertEqual(unmatched_result, nil, "an unmatched book produces no result")
assertEqual(unmatched_err, "unmatched", "the caller learns the book is unmatched")
assertTrue(unmatched_state.unmatched, "the unmatched digest is recorded")
-- A 500 mid-upload leaves the stamp unset so the next sync retries.
local failing_book = {}
local flaky_calls = 0
local two_chunks = {}
for index = 1, 60 do
table.insert(two_chunks, bookmark(string.format("2026-06-01 10:%02d:00", index % 60), "/body/p[" .. index .. "]"))
end
local flaky_result = BookOrbitBookmarks.exchangeBook({
client = {
exchangeBookmarks = function()
flaky_calls = flaky_calls + 1
if flaky_calls == 1 then
return { results = { { hash = "digest", toApply = { add = {}, delete = {} } } } }
end
return nil, 500
end,
exchangeBookmarksAck = function() return { results = {} } end,
},
state = makeState(failing_book),
digest = "digest",
bookmarks = two_chunks,
bm_signature = "sig-flaky",
apply_mode = "sidecar",
file = "/books/one.epub",
})
assertTrue(flaky_result.had_errors, "a 5xx is reported as an error")
assertEqual(failing_book.bmSignature, nil, "a failed upload does not record the skip stamp")
print("bookorbit_bookmark_exchange_test.lua: ok")
@@ -0,0 +1,234 @@
-- Dogear sync: normalization, skip logic, live apply, sidecar apply,
-- deletion detection and the ack identity the server links devices by.
package.path = "koreader-plugin/bookorbit.koplugin/?.lua;koreader-plugin/spec/?.lua;" .. package.path
local sidecar_store
local sidecar_flags
package.loaded["docsettings"] = {
open = function()
return {
readSetting = function(_, key)
if key == "annotations" then return sidecar_store end
end,
saveSetting = function(_, key, value)
if key == "annotations" then sidecar_store = value end
end,
makeTrue = function(_, key) sidecar_flags[key] = true end,
flush = function() sidecar_flags.flushed = true end,
}
end,
hasSidecarFile = function() return false end,
findSidecarFile = function() return nil end,
}
local handled_events = {}
package.loaded["ui/event"] = {
new = function(_, name, payload)
return { name = name, payload = payload }
end,
}
local dirty_calls = 0
package.loaded["ui/uimanager"] = {
setDirty = function() dirty_calls = dirty_calls + 1 end,
}
package.loaded["logger"] = { dbg = function() end }
-- Identity md5 is replaced by the input itself so assertions stay readable.
package.loaded["ffi/sha2"] = { md5 = function(value) return value end }
package.loaded["ffi/util"] = {
template = function(pattern, a) return (pattern:gsub("%%1", tostring(a))) end,
}
package.loaded["gettext"] = function(value) return value end
package.loaded["libs/libkoreader-lfs"] = { attributes = function() return nil end }
package.loaded["ui/widget/booklist"] = { setBookInfoCacheProperty = function() end }
local capability_answer = true
local marked_unsupported = false
package.loaded["bookorbit_capabilities"] = {
supports = function(_, feature)
if feature ~= "bookmarkSync" then return false end
return capability_answer
end,
markUnsupported = function() marked_unsupported = true end,
}
local BookOrbitSidecar = require("bookorbit_sidecar")
local BookOrbitBookmarks = require("bookorbit_bookmarks")
local function assertEqual(actual, expected, label)
if actual ~= expected then
error(string.format("%s: expected %s, got %s", label, tostring(expected), tostring(actual)), 2)
end
end
local function assertTrue(value, label)
if not value then error(label .. ": expected truthy", 2) end
end
-- normalizeBookmarks
local raw = {
{ page = "/body/DocFragment[2]/body/p[1]/text().0", datetime = "2026-06-01 10:00:00", chapter = "One", pageno = 4 },
{ page = "/body/DocFragment[3]", datetime = "2026-06-02 11:00:00", note = "Look", pageno = 9 },
-- A highlight: has a drawer, belongs to the annotation exchange.
{ page = "/body/DocFragment[4]", pos0 = "/body/DocFragment[4]", drawer = "lighten", datetime = "2026-06-03 12:00:00" },
-- A paging document dogear: the position is a page number, out of scope.
{ page = 12, datetime = "2026-06-04 13:00:00" },
-- Unusable datetime.
{ page = "/body/DocFragment[5]", datetime = "nonsense" },
}
local bookmarks, max_datetime, signature = BookOrbitSidecar.normalizeBookmarks(raw)
assertEqual(#bookmarks, 2, "only rolling dogears are normalized")
assertEqual(bookmarks[1].pos, "/body/DocFragment[2]/body/p[1]/text().0", "position comes from page")
assertEqual(bookmarks[1].chapter, "One", "chapter is carried")
assertEqual(bookmarks[1].pageno, 4, "pageno is carried")
assertEqual(bookmarks[2].note, "Look", "note is carried")
assertEqual(max_datetime, "2026-06-02 11:00:00", "max datetime ignores highlights")
local renamed = {
{ page = "/body/DocFragment[2]/body/p[1]/text().0", datetime = "2026-06-01 10:00:00", chapter = "One", pageno = 4 },
{ page = "/body/DocFragment[3]", datetime = "2026-06-02 11:00:00", note = "Renamed", pageno = 9 },
}
local _renamed, _renamed_max, renamed_signature = BookOrbitSidecar.normalizeBookmarks(renamed)
assertTrue(renamed_signature ~= signature,
"a note edit changes the signature even though KOReader leaves datetime_updated alone")
-- The highlight signature must not move, or every book re-exchanges after an update.
local annotations_before = select(3, BookOrbitSidecar.normalizeAnnotations(raw))
local annotations_after = select(3, BookOrbitSidecar.normalizeAnnotations(raw))
assertEqual(annotations_before, annotations_after, "highlight signature is stable")
-- keys and skip logic
local keys = BookOrbitBookmarks.collectKeys(bookmarks)
assertEqual(#keys, 2, "one key per bookmark")
assertEqual(keys[1].k, "2026-06-01 10:00:00|/body/DocFragment[2]/body/p[1]/text().0", "key is datetime|pos")
assertEqual(keys[1].dt, "2026-06-01 10:00:00", "key carries its datetime")
local book = {}
assertEqual(BookOrbitBookmarks.canSkipExchange(book, signature, 1000), false, "never exchanged, cannot skip")
BookOrbitBookmarks.rememberExchanged(book, signature, 1000)
assertEqual(BookOrbitBookmarks.canSkipExchange(book, signature, 1100), true, "unchanged set may skip")
assertEqual(BookOrbitBookmarks.canSkipExchange(book, renamed_signature, 1100), false, "changed set may not skip")
assertEqual(BookOrbitBookmarks.canSkipExchange(book, signature, 1000 + BookOrbitBookmarks.EXCHANGE_MAX_AGE + 1), false,
"a stale stamp may not skip")
assertEqual(BookOrbitBookmarks.canSkipExchange(book, signature, 500), false, "a stamp in the future may not skip")
-- capability gate
assertEqual(BookOrbitBookmarks.enabled({}, false), false, "the two-way toggle gates bookmark sync")
assertEqual(BookOrbitBookmarks.enabled({}, true), true, "an advertising server enables bookmark sync")
capability_answer = false
assertEqual(BookOrbitBookmarks.enabled({}, true), false, "a server without the capability stays dormant")
capability_answer = true
BookOrbitBookmarks.markUnsupported({})
assertTrue(marked_unsupported, "a confirmed 404 downgrades the capability")
-- live apply
local resolvable = {}
local annotations = {}
local added_items = {}
local removed_indexes = {}
local ui = {
document = {
isXPointerInDocument = function(_, pos) return resolvable[pos] == true end,
},
annotation = {
annotations = annotations,
addItem = function(self, item)
item.datetime = item.datetime or "2026-07-01 09:00:00"
table.insert(self.annotations, item)
table.insert(added_items, item)
return #self.annotations
end,
},
bookmark = {
getCurrentPageNumber = function() return "/body/DocFragment[1]" end,
setDogearVisibility = function() end,
removeItemByIndex = function(_, index)
table.insert(removed_indexes, index)
table.remove(annotations, index)
end,
},
toc = {
getTocTitleByPage = function(_, pos)
if pos == "/body/DocFragment[7]" then return "Chapter Seven" end
return ""
end,
},
handleEvent = function(_, event) table.insert(handled_events, event) end,
}
resolvable["/body/DocFragment[7]"] = true
resolvable["/body/DocFragment[8]"] = true
local applied, deleted, touched, deleted_touched = BookOrbitBookmarks.applyLive(ui, {
add = {
{ serverId = 1, pos = "/body/DocFragment[7]", title = "Chapter Seven" },
{ serverId = 2, pos = "/body/DocFragment[8]", title = "My own label" },
{ serverId = 3, pos = "/body/DocFragment[9]", title = "Broken" },
},
})
assertEqual(touched, 2, "two resolvable positions were added")
assertEqual(deleted_touched, 0, "nothing was deleted")
assertEqual(#deleted, 0, "no delete acks")
assertEqual(applied[1].status, "applied", "resolvable entry applied")
assertEqual(applied[1].key, "2026-07-01 09:00:00|/body/DocFragment[7]", "ack carries the device identity")
assertEqual(applied[1].pos, "/body/DocFragment[7]", "ack carries the device position")
assertEqual(added_items[1].chapter, "Chapter Seven", "the device fills the chapter from its own toc")
assertEqual(added_items[1].note, nil, "a title matching the chapter needs no note")
assertEqual(added_items[2].note, "My own label", "a title that is not the chapter becomes the note")
assertEqual(applied[3].status, "failed", "an unresolvable position fails instead of landing")
assertTrue(dirty_calls > 0, "the screen is refreshed after a change")
-- An entry already present locally is acked with the local identity, not re-added.
local before_count = #annotations
local applied_again = BookOrbitBookmarks.applyLive(ui, {
add = { { serverId = 1, pos = "/body/DocFragment[7]", title = "Chapter Seven" } },
})
assertEqual(#annotations, before_count, "an already present position is not duplicated")
assertEqual(applied_again[1].key, "2026-07-01 09:00:00|/body/DocFragment[7]", "the existing identity is acked")
-- Delete by the identity the server was acked with.
local _a, deletes, _t, delete_touched = BookOrbitBookmarks.applyLive(ui, {
delete = {
{ serverId = 1, key = "2026-07-01 09:00:00|/body/DocFragment[7]", datetime = "2026-07-01 09:00:00" },
{ serverId = 99, key = "never|seen", datetime = "2020-01-01 00:00:00" },
},
})
assertEqual(delete_touched, 1, "only the known entry was removed")
assertEqual(#deletes, 2, "an already absent entry is still acked")
assertEqual(deletes[2].status, "applied", "a missing local entry counts as applied")
-- sidecar apply
sidecar_store = {
{ page = "/body/DocFragment[2]", datetime = "2026-06-01 10:00:00" },
{ page = "/body/DocFragment[4]", drawer = "lighten", pos0 = "/body/DocFragment[4]", datetime = "2026-06-03 12:00:00" },
}
sidecar_flags = {}
local s_applied, s_deleted, s_touched, s_deleted_touched = BookOrbitBookmarks.applySidecar("/books/one.epub", {
add = { { serverId = 5, pos = "/body/DocFragment[6]", title = "From the web", pageno = 12 } },
delete = { { serverId = 6, key = "2026-06-01 10:00:00|/body/DocFragment[2]", datetime = "2026-06-01 10:00:00" } },
})
assertEqual(s_touched, 2, "one add and one delete touched the sidecar")
assertEqual(s_deleted_touched, 1, "one delete landed")
assertEqual(#s_applied, 1, "one apply ack")
assertEqual(#s_deleted, 1, "one delete ack")
assertEqual(#sidecar_store, 2, "the highlight stayed and the new dogear was appended")
assertEqual(sidecar_store[2].note, "From the web", "a closed book has no toc, so the title lands in the note")
assertEqual(sidecar_store[2].pageno, 12, "the server pageno is kept")
assertTrue(sidecar_flags.annotations_externally_modified, "KOReader is asked to re-validate on open")
assertTrue(sidecar_flags.flushed, "the sidecar was flushed")
-- A highlight must never be touched by the bookmark exchange.
assertEqual(sidecar_store[1].drawer, "lighten", "the highlight survived the dogear delete")
print("bookorbit_bookmarks_test.lua: ok")
@@ -48,6 +48,13 @@ package.loaded["libs/libkoreader-lfs"] = { attributes = function() return nil en
package.loaded["ffi/util"] = { template = function(value) return value end }
package.loaded["gettext"] = function(value) return value end
package.loaded["bookorbit_bookmarks"] = {
enabled = function() return false end,
markUnsupported = function() end,
canSkipExchange = function() return false end,
rememberExchanged = function() end,
exchangeBook = function() error("bookmark sync is off in this fixture") end,
}
package.loaded["bookorbit_annotations"] = {
canSkipExchange = function() return false end,
rememberExchanged = function() end,
@@ -32,6 +32,13 @@ package.loaded["ffi/util"] = {
}
package.loaded["gettext"] = function(value) return value end
package.loaded["bookorbit_bookmarks"] = {
enabled = function() return false end,
markUnsupported = function() end,
canSkipExchange = function() return false end,
rememberExchanged = function() end,
exchangeBook = function() error("bookmark sync is off in this fixture") end,
}
package.loaded["bookorbit_annotations"] = {
exchangeBook = function() error("every phase is already acknowledged") end,
canSkipExchange = function() return false end,
@@ -128,7 +128,7 @@ assertEqual(hasItem(items, "Current book"), false, "file manager menu hides curr
assertEqual(hasItem(items, "Sync current book now"), false, "file manager menu hides current book sync")
assertEqual(hasItem(items, "Auto sync current book"), false, "file manager menu hides current book auto sync")
assertEqual(hasItem(items, "Sync all books now"), true, "logged-in menu shows sync all")
assertEqual(hasItem(items, "Two-way highlight sync"), false, "logged-in top level hides highlight toggle")
assertEqual(hasItem(items, "Two-way highlights & bookmarks"), false, "logged-in top level hides the two-way toggle")
assertEqual(hasItem(items, "Skip auto-sync when offline"), false, "logged-in top level hides offline skip toggle")
assertEqual(hasItem(items, "Sync settings"), false, "logged-in menu replaces sync settings with settings")
assertEqual(hasItem(items, "Settings"), true, "logged-in menu shows settings")
@@ -142,7 +142,7 @@ assertEqual(hasItem(file_manager_dashboard_settings.sub_item_table, "Open dashbo
assertEqual(hasItem(file_manager_dashboard_settings.sub_item_table, "Show below Continue reading (Discover)"), true, "file manager dashboard settings has the section picker")
local file_manager_sync_settings = findItem(file_manager_settings.sub_item_table, "Sync")
assertEqual(hasItem(file_manager_sync_settings.sub_item_table, "Auto sync current book"), false, "file manager sync settings hides reader auto sync")
assertEqual(hasItem(file_manager_sync_settings.sub_item_table, "Two-way highlight sync"), true, "file manager sync settings has highlight toggle")
assertEqual(hasItem(file_manager_sync_settings.sub_item_table, "Two-way highlights & bookmarks"), true, "file manager sync settings has the two-way toggle")
assertEqual(hasItem(file_manager_sync_settings.sub_item_table, "Skip auto-sync when offline"), true, "file manager sync settings has offline skip toggle")
assertEqual(hasItem(file_manager_sync_settings.sub_item_table, "Open dashboard on startup (Off)"), false, "file manager sync settings excludes dashboard startup")
local file_manager_plugin_settings = findItem(file_manager_settings.sub_item_table, "Plugin")
@@ -167,7 +167,7 @@ assertEqual(hasItem(dashboard_dashboard_settings.sub_item_table, "Open dashboard
assertEqual(hasItem(dashboard_dashboard_settings.sub_item_table, "Show below Continue reading (Discover)"), true, "dashboard settings has the section picker")
assertEqual(hasItem(dashboard_items, "Show below Continue reading (Discover)"), false, "the section picker is not promoted to the dashboard menu top level")
local dashboard_sync_settings = findItem(dashboard_settings.sub_item_table, "Sync")
assertEqual(hasItem(dashboard_sync_settings.sub_item_table, "Two-way highlight sync"), true, "dashboard sync settings has highlight toggle")
assertEqual(hasItem(dashboard_sync_settings.sub_item_table, "Two-way highlights & bookmarks"), true, "dashboard sync settings has the two-way toggle")
assertEqual(hasItem(dashboard_sync_settings.sub_item_table, "Skip auto-sync when offline"), true, "dashboard sync settings has offline skip toggle")
assertEqual(hasItem(dashboard_sync_settings.sub_item_table, "Open dashboard on startup (Off)"), false, "dashboard sync settings excludes dashboard startup")
assertEqual(hasItem(dashboard_settings.sub_item_table, "Plugin"), false, "dashboard settings omits duplicate plugin group")
@@ -203,10 +203,10 @@ local reader_dashboard_settings = findItem(settings.sub_item_table, "Dashboard")
assertEqual(hasItem(reader_dashboard_settings.sub_item_table, "Open dashboard on startup (Off)"), true, "reader dashboard settings has startup option")
local sync_settings = findItem(settings.sub_item_table, "Sync")
assertEqual(hasItem(sync_settings.sub_item_table, "Auto sync current book"), false, "reader sync settings excludes top-level auto sync")
assertEqual(hasItem(sync_settings.sub_item_table, "Two-way highlight sync"), true, "sync settings has highlight toggle")
assertEqual(hasItem(sync_settings.sub_item_table, "Two-way highlights & bookmarks"), true, "sync settings has the two-way toggle")
assertEqual(hasItem(sync_settings.sub_item_table, "Skip auto-sync when offline"), true, "sync settings has offline toggle")
assertEqual(hasItem(sync_settings.sub_item_table, "Open dashboard on startup (Off)"), false, "sync settings excludes dashboard startup")
assertEqual(hasItem(items, "Two-way highlight sync"), false, "top level hides highlight toggle")
assertEqual(hasItem(items, "Two-way highlights & bookmarks"), false, "top level hides the two-way toggle")
assertEqual(hasItem(items, "Skip auto-sync when offline"), false, "top level hides offline toggle")
assertEqual(findItem(sync_settings.sub_item_table, "Skip auto-sync when offline").enabled_func(), true, "offline toggle is enabled when auto sync is on")
local reader_plugin_settings = findItem(settings.sub_item_table, "Plugin")
@@ -155,6 +155,7 @@ function SweepHarness.install(opts)
sidecar_extracts = {},
history_resolved = {},
},
bookmark_exchanges = {},
flushes = 0,
}
@@ -254,9 +255,20 @@ function SweepHarness.install(opts)
readWatermark = function() return "" end,
advanceWatermark = function() end,
}
package.loaded["bookorbit_bookmarks"] = {
enabled = function() return opts.bookmark_sync == true end,
markUnsupported = function() end,
canSkipExchange = function() return false end,
rememberExchanged = function() end,
exchangeBook = function()
table.insert(handle.bookmark_exchanges, true)
return { uploaded = 0, applied = 0, deleted = 0, failed = 0, had_errors = false }
end,
}
package.loaded["bookorbit_highlight_summary"] = {
normalize = function(value) return value end,
add = function(value) return value end,
addBookmarks = function(value) return value end,
hasCounts = function() return false end,
hasRemoteChanges = function() return false end,
}
+1 -1
View File
@@ -359,7 +359,7 @@ export interface KoreaderCatalogManifestPage {
restartRequired: boolean;
}
export type KoreaderPluginCapability = "catalogBulkManifest" | "catalogDashboardSections";
export type KoreaderPluginCapability = "catalogBulkManifest" | "catalogDashboardSections" | "bookmarkSync";
export interface KoreaderPluginVersionInfo {
pluginVersion: string;
+19
View File
@@ -438,6 +438,25 @@ export const E2E_SUITES = Object.freeze({
...SHARED_DB_AND_HELPER_PATHS,
],
},
"koreader-bookmark-exchange": {
id: "koreader-bookmark-exchange",
name: "KOReader Bookmark Exchange",
timeout: 45,
lane: "full",
description: "KOReader two-way bookmark (dogear) exchange suite",
vitestTarget: "test/koreader-bookmark-exchange.e2e-spec.ts",
junitOutput: `${TEST_RESULTS_DIR}/koreader-bookmark-exchange-e2e-junit.xml`,
prepareDedicatedDatabase: true,
useDedicatedDatabase: true,
changedPaths: [
"server/src/modules/bookmark/**",
"server/src/modules/koreader/**",
"server/src/modules/position-converter/**",
"server/test/koreader-bookmark-exchange.e2e-spec.ts",
"server/test/e2e/reader-state-isolation/**",
...SHARED_DB_AND_HELPER_PATHS,
],
},
"koreader-bulk-manifest": {
id: "koreader-bulk-manifest",
name: "KOReader Bulk Manifest Scale",
@@ -0,0 +1,23 @@
CREATE TABLE "koreader_bookmark_links" (
"id" serial PRIMARY KEY NOT NULL,
"bookmark_id" integer NOT NULL,
"user_id" integer NOT NULL,
"device_id" varchar(100) NOT NULL,
"koreader_key" varchar(32) NOT NULL,
"device_datetime" varchar(19),
"applied_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "bookmarks" ADD COLUMN "origin" varchar(10) DEFAULT 'web' NOT NULL;--> statement-breakpoint
ALTER TABLE "bookmarks" ADD COLUMN "device_pos" varchar(4000);--> statement-breakpoint
ALTER TABLE "bookmarks" ADD COLUMN "pageno" integer;--> statement-breakpoint
ALTER TABLE "bookmarks" ADD COLUMN "updated_at" timestamp with time zone DEFAULT now() NOT NULL;--> statement-breakpoint
ALTER TABLE "bookmarks" ADD COLUMN "deleted_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "koreader_bookmark_links" ADD CONSTRAINT "koreader_bookmark_links_bookmark_id_bookmarks_id_fk" FOREIGN KEY ("bookmark_id") REFERENCES "public"."bookmarks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "koreader_bookmark_links" ADD CONSTRAINT "koreader_bookmark_links_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "koreader_bookmark_links_bookmark_device_uidx" ON "koreader_bookmark_links" USING btree ("bookmark_id","device_id");--> statement-breakpoint
CREATE INDEX "koreader_bookmark_links_user_device_key_idx" ON "koreader_bookmark_links" USING btree ("user_id","device_id","koreader_key");--> statement-breakpoint
CREATE INDEX "koreader_bookmark_links_user_device_idx" ON "koreader_bookmark_links" USING btree ("user_id","device_id");--> statement-breakpoint
CREATE INDEX "koreader_bookmark_links_bookmark_id_idx" ON "koreader_bookmark_links" USING btree ("bookmark_id");--> statement-breakpoint
CREATE INDEX "bookmarks_deleted_at_idx" ON "bookmarks" USING btree ("deleted_at") WHERE "bookmarks"."deleted_at" is not null;--> statement-breakpoint
ALTER TABLE "bookmarks" ADD CONSTRAINT "bookmarks_origin_chk" CHECK ("bookmarks"."origin" in ('web', 'koreader'));
File diff suppressed because it is too large Load Diff
@@ -393,6 +393,13 @@
"when": 1785128574339,
"tag": "0055_add_audit_search_indexes",
"breakpoints": true
},
{
"idx": 56,
"version": "7",
"when": 1785208780116,
"tag": "0056_add_bookmark_sync_columns",
"breakpoints": true
}
]
}
+37
View File
@@ -17,6 +17,7 @@ import {
} from 'drizzle-orm/pg-core';
import { bookFiles } from './books';
import { bookmarks } from './reader';
import { users } from './auth';
export const koreaderUsers = pgTable(
@@ -297,3 +298,39 @@ export const koreaderDeviceSweeps = pgTable(
export type KoreaderDeviceSweep = typeof koreaderDeviceSweeps.$inferSelect;
export type NewKoreaderDeviceSweep = typeof koreaderDeviceSweeps.$inferInsert;
/**
* One row per (bookmark, device): what a KOReader device holds locally, and under
* which local identity. It is what lets a bookmark absent from a device's key set
* be recognised as a device-side deletion, and what stops an already-delivered
* bookmark from being pushed again.
*/
export const koreaderBookmarkLinks = pgTable(
'koreader_bookmark_links',
{
id: serial('id').primaryKey(),
bookmarkId: integer('bookmark_id')
.notNull()
.references(() => bookmarks.id, { onDelete: 'cascade' }),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
deviceId: varchar('device_id', { length: 100 }).notNull(),
// md5 hex of `${deviceDatetime}|${devicePos}` as the device computed it.
koreaderKey: varchar('koreader_key', { length: 32 }).notNull(),
deviceDatetime: varchar('device_datetime', { length: 19 }),
appliedAt: timestamp('applied_at', { withTimezone: true }).defaultNow().notNull(),
},
(t) => [
uniqueIndex('koreader_bookmark_links_bookmark_device_uidx').on(t.bookmarkId, t.deviceId),
// Non-unique for the same reason annotation_sync_state's key index is: the same
// device key can legitimately exist for two books (one file scanned into two
// libraries), and lookups are book-scoped anyway.
index('koreader_bookmark_links_user_device_key_idx').on(t.userId, t.deviceId, t.koreaderKey),
index('koreader_bookmark_links_user_device_idx').on(t.userId, t.deviceId),
index('koreader_bookmark_links_bookmark_id_idx').on(t.bookmarkId),
],
);
export type KoreaderBookmarkLink = typeof koreaderBookmarkLinks.$inferSelect;
export type NewKoreaderBookmarkLink = typeof koreaderBookmarkLinks.$inferInsert;
+18
View File
@@ -292,6 +292,8 @@ export const audiobookProgress = pgTable(
export type AudiobookProgress = typeof audiobookProgress.$inferSelect;
export type NewAudiobookProgress = typeof audiobookProgress.$inferInsert;
export type BookmarkOrigin = 'web' | 'koreader';
export const bookmarks = pgTable(
'bookmarks',
{
@@ -307,17 +309,33 @@ export const bookmarks = pgTable(
title: varchar('title', { length: 500 }).notNull(),
// Audio: absolute book position in seconds (sum of preceding file durations + offset).
positionSeconds: real('position_seconds'),
origin: varchar('origin', { length: 10 }).$type<BookmarkOrigin>().notNull().default('web'),
// Canonical KOReader xpointer for a device-created bookmark, kept so it returns
// to devices at its original position instead of surviving a double conversion.
devicePos: varchar('device_pos', { length: 4000 }),
pageno: integer('pageno'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdateFn(() => new Date()),
// Tombstone. Web reads filter it out; device exchanges need it to propagate
// a deletion to every other linked device before the row can be purged.
deletedAt: timestamp('deleted_at', { withTimezone: true }),
},
(t) => [
index('bookmarks_user_book_idx').on(t.userId, t.bookId),
index('bookmarks_book_id_idx').on(t.bookId),
index('bookmarks_deleted_at_idx')
.on(t.deletedAt)
.where(sql`${t.deletedAt} is not null`),
uniqueIndex('bookmarks_user_book_cfi_uidx')
.on(t.userId, t.bookId, t.cfi)
.where(sql`${t.cfi} is not null`),
uniqueIndex('bookmarks_user_book_pos_uidx')
.on(t.userId, t.bookId, t.positionSeconds)
.where(sql`${t.positionSeconds} is not null and ${t.cfi} is null`),
check('bookmarks_origin_chk', sql`${t.origin} in ('web', 'koreader')`),
],
);
@@ -0,0 +1,101 @@
import { BookmarkSyncService } from './bookmark-sync.service';
function makeRow(overrides?: Record<string, unknown>) {
return {
id: 1,
userId: 10,
bookId: 5,
cfi: 'epubcfi(/6/2)',
title: 'Chapter 1',
positionSeconds: null,
origin: 'koreader',
devicePos: '/body/DocFragment[2]',
pageno: 12,
createdAt: new Date('2026-01-01T00:00:00Z'),
updatedAt: new Date('2026-01-01T00:00:00Z'),
deletedAt: null,
...overrides,
};
}
function makeService() {
const repo = {
listForSync: vi.fn(),
createFromDevice: vi.fn().mockResolvedValue(null),
restoreAtLocation: vi.fn().mockResolvedValue(null),
findLiveByLocation: vi.fn().mockResolvedValue(null),
updateFromDevice: vi.fn(),
tombstone: vi.fn(),
listPurgeableTombstones: vi.fn(),
purge: vi.fn(),
};
return { service: new BookmarkSyncService(repo as never), repo };
}
const fields = { cfi: 'epubcfi(/6/2)', title: 'Chapter 1', devicePos: '/body/DocFragment[2]', pageno: 12 };
describe('BookmarkSyncService', () => {
describe('upsertFromDevice', () => {
it('reports a fresh insert as created', async () => {
const { service, repo } = makeService();
const row = makeRow();
repo.createFromDevice.mockResolvedValue(row);
const result = await service.upsertFromDevice(10, 5, fields);
expect(result).toEqual({ row, outcome: 'created' });
expect(repo.restoreAtLocation).not.toHaveBeenCalled();
});
it('restores the tombstone that still owns the location', async () => {
const { service, repo } = makeService();
const row = makeRow({ id: 2 });
repo.restoreAtLocation.mockResolvedValue(row);
const result = await service.upsertFromDevice(10, 5, fields);
expect(result).toEqual({ row, outcome: 'restored' });
expect(repo.restoreAtLocation).toHaveBeenCalledWith(
10,
5,
{ cfi: 'epubcfi(/6/2)', positionSeconds: null },
{ title: 'Chapter 1', origin: 'koreader', devicePos: '/body/DocFragment[2]', pageno: 12 },
);
expect(repo.findLiveByLocation).not.toHaveBeenCalled();
});
it('matches the live bookmark another device already created there', async () => {
const { service, repo } = makeService();
const row = makeRow({ id: 3, origin: 'web', devicePos: null });
repo.findLiveByLocation.mockResolvedValue(row);
const result = await service.upsertFromDevice(10, 5, fields);
expect(result).toEqual({ row, outcome: 'matched' });
});
it('returns null when nothing could be landed', async () => {
const { service } = makeService();
expect(await service.upsertFromDevice(10, 5, fields)).toBeNull();
});
});
it('delegates the remaining operations to the repository', async () => {
const { service, repo } = makeService();
const deletedBefore = new Date('2026-01-01T00:00:00Z');
repo.tombstone.mockResolvedValue(2);
repo.listPurgeableTombstones.mockResolvedValue([7]);
repo.purge.mockResolvedValue(1);
repo.listForSync.mockResolvedValue([]);
await service.listForSync(10, 5, 500);
await service.applyDeviceEdit(10, 3, { title: 'Renamed' });
expect(repo.listForSync).toHaveBeenCalledWith(10, 5, 500);
expect(repo.updateFromDevice).toHaveBeenCalledWith(10, 3, { title: 'Renamed' });
expect(await service.tombstone(10, [1, 2])).toBe(2);
expect(await service.listPurgeableTombstones(10, deletedBefore, 50)).toEqual([7]);
expect(await service.purge(10, [7])).toBe(1);
});
});
@@ -0,0 +1,64 @@
import { Injectable } from '@nestjs/common';
import type { BookmarkRow } from '../../db/schema';
import { BookmarkRepository, type DeviceBookmarkFields } from './bookmark.repository';
export type DeviceBookmarkOutcome = 'created' | 'restored' | 'matched';
export interface DeviceBookmarkUpsertResult {
row: BookmarkRow;
outcome: DeviceBookmarkOutcome;
}
/**
* The bookmarks table stays owned by this module; device sync drives it through
* these operations instead of reaching into the repository from another module.
*/
@Injectable()
export class BookmarkSyncService {
constructor(private readonly bookmarkRepo: BookmarkRepository) {}
/** Every row of the book, tombstones included, capped so one huge book cannot unbound a request. */
listForSync(userId: number, bookId: number, limit: number): Promise<BookmarkRow[]> {
return this.bookmarkRepo.listForSync(userId, bookId, limit);
}
/**
* Lands one device bookmark at its CFI. A live row at that location is reused, a
* tombstoned one is restored, and only an unoccupied location inserts.
*/
async upsertFromDevice(userId: number, bookId: number, fields: DeviceBookmarkFields): Promise<DeviceBookmarkUpsertResult | null> {
const location = { cfi: fields.cfi, positionSeconds: null };
const created = await this.bookmarkRepo.createFromDevice(userId, bookId, fields);
if (created) return { row: created, outcome: 'created' };
const restored = await this.bookmarkRepo.restoreAtLocation(userId, bookId, location, {
title: fields.title,
origin: 'koreader',
devicePos: fields.devicePos,
pageno: fields.pageno,
});
if (restored) return { row: restored, outcome: 'restored' };
const existing = await this.bookmarkRepo.findLiveByLocation(userId, bookId, location);
return existing ? { row: existing, outcome: 'matched' } : null;
}
/** Device-side rename. The web has no rename surface, so the device always wins. */
applyDeviceEdit(userId: number, bookmarkId: number, values: { title?: string; devicePos?: string; pageno?: number | null }) {
return this.bookmarkRepo.updateFromDevice(userId, bookmarkId, values);
}
tombstone(userId: number, bookmarkIds: number[]): Promise<number> {
return this.bookmarkRepo.tombstone(userId, bookmarkIds);
}
listPurgeableTombstones(userId: number, deletedBefore: Date, limit: number): Promise<number[]> {
return this.bookmarkRepo.listPurgeableTombstones(userId, deletedBefore, limit);
}
purge(userId: number, bookmarkIds: number[]): Promise<number> {
return this.bookmarkRepo.purge(userId, bookmarkIds);
}
}
@@ -8,6 +8,7 @@ import { BookmarkController } from './bookmark.controller';
import { BookmarkModule } from './bookmark.module';
import { BookmarkRepository } from './bookmark.repository';
import { BookmarkService } from './bookmark.service';
import { BookmarkSyncService } from './bookmark-sync.service';
describe('BookmarkModule', () => {
it('registers expected controller and providers', () => {
@@ -15,6 +16,7 @@ describe('BookmarkModule', () => {
const providers = Reflect.getMetadata(MODULE_METADATA.PROVIDERS, BookmarkModule) as Array<unknown>;
expect(controllers).toEqual([BookmarkController]);
expect(providers).toEqual(expect.arrayContaining([BookmarkService, BookmarkRepository]));
expect(providers).toEqual(expect.arrayContaining([BookmarkService, BookmarkSyncService, BookmarkRepository]));
expect(Reflect.getMetadata(MODULE_METADATA.EXPORTS, BookmarkModule)).toEqual([BookmarkSyncService]);
});
});
@@ -4,10 +4,12 @@ import { BookModule } from '../book/book.module';
import { BookmarkController } from './bookmark.controller';
import { BookmarkRepository } from './bookmark.repository';
import { BookmarkService } from './bookmark.service';
import { BookmarkSyncService } from './bookmark-sync.service';
@Module({
imports: [BookModule],
controllers: [BookmarkController],
providers: [BookmarkService, BookmarkRepository],
providers: [BookmarkService, BookmarkSyncService, BookmarkRepository],
exports: [BookmarkSyncService],
})
export class BookmarkModule {}
@@ -8,7 +8,12 @@ function makeRow(overrides?: Record<string, unknown>) {
cfi: 'epubcfi(/6/2)',
title: 'Chapter 1',
positionSeconds: null,
origin: 'web',
devicePos: null,
pageno: null,
createdAt: new Date('2026-01-01T00:00:00Z'),
updatedAt: new Date('2026-01-01T00:00:00Z'),
deletedAt: null,
...overrides,
};
}
@@ -31,6 +36,13 @@ function makeDb() {
valuesResult.onConflictDoNothing.mockReturnValue(conflictResult);
conflictResult.returning.mockResolvedValue([]);
const updateResult = { set: vi.fn() };
const setResult = { where: vi.fn() };
const updateWhereResult = { returning: vi.fn() };
updateResult.set.mockReturnValue(setResult);
setResult.where.mockReturnValue(updateWhereResult);
updateWhereResult.returning.mockResolvedValue([]);
const deleteResult = { where: vi.fn() };
const deleteWhereResult = { returning: vi.fn() };
deleteResult.where.mockReturnValue(deleteWhereResult);
@@ -39,12 +51,15 @@ function makeDb() {
const db = {
select: vi.fn().mockReturnValue(selectResult),
insert: vi.fn().mockReturnValue(insertResult),
update: vi.fn().mockReturnValue(updateResult),
delete: vi.fn().mockReturnValue(deleteResult),
_where: whereResult,
_orderBy: orderByResult,
_insert: insertResult,
_values: valuesResult,
_conflict: conflictResult,
_update: updateResult,
_updateWhere: updateWhereResult,
_deleteWhere: deleteWhereResult,
};
return db;
@@ -70,14 +85,14 @@ describe('BookmarkRepository', () => {
});
});
describe('findExistingByLocation', () => {
describe('findLiveByLocation', () => {
it('returns first bookmark for duplicate CFI location', async () => {
const { repo, db } = makeRepository();
const row = makeRow();
db._where.orderBy.mockReturnValue(db._orderBy);
db._orderBy.limit.mockResolvedValue([row]);
const result = await repo.findExistingByLocation(10, 5, { cfi: 'epubcfi(/6/2)', positionSeconds: null });
const result = await repo.findLiveByLocation(10, 5, { cfi: 'epubcfi(/6/2)', positionSeconds: null });
expect(result).toEqual(row);
expect(db._orderBy.limit).toHaveBeenCalledWith(1);
@@ -89,7 +104,7 @@ describe('BookmarkRepository', () => {
db._where.orderBy.mockReturnValue(db._orderBy);
db._orderBy.limit.mockResolvedValue([row]);
const result = await repo.findExistingByLocation(10, 5, { cfi: null, positionSeconds: 93.5 });
const result = await repo.findLiveByLocation(10, 5, { cfi: null, positionSeconds: 93.5 });
expect(result).toEqual(row);
expect(db._orderBy.limit).toHaveBeenCalledWith(1);
@@ -98,7 +113,7 @@ describe('BookmarkRepository', () => {
it('returns null when no location fields are provided', async () => {
const { repo, db } = makeRepository();
const result = await repo.findExistingByLocation(10, 5, { cfi: null, positionSeconds: null });
const result = await repo.findLiveByLocation(10, 5, { cfi: null, positionSeconds: null });
expect(result).toBeNull();
expect(db.select).not.toHaveBeenCalled();
@@ -109,7 +124,7 @@ describe('BookmarkRepository', () => {
db._where.orderBy.mockReturnValue(db._orderBy);
db._orderBy.limit.mockResolvedValue([]);
const result = await repo.findExistingByLocation(10, 5, { cfi: 'epubcfi(/6/2)', positionSeconds: null });
const result = await repo.findLiveByLocation(10, 5, { cfi: 'epubcfi(/6/2)', positionSeconds: null });
expect(result).toBeNull();
});
@@ -138,23 +153,139 @@ describe('BookmarkRepository', () => {
});
});
describe('delete', () => {
it('returns true when delete query returns at least one row', async () => {
describe('restoreAtLocation', () => {
it('clears the tombstone and rewrites the row fields', async () => {
const { repo, db } = makeRepository();
db._deleteWhere.returning.mockResolvedValue([{ id: 1 }]);
const row = makeRow({ deletedAt: null, origin: 'koreader', devicePos: '/body/DocFragment[2]' });
db._updateWhere.returning.mockResolvedValue([row]);
const result = await repo.delete(5, 1, 10);
const result = await repo.restoreAtLocation(
10,
5,
{ cfi: 'epubcfi(/6/2)', positionSeconds: null },
{ title: 'Chapter 1', origin: 'koreader', devicePos: '/body/DocFragment[2]', pageno: 12 },
);
expect(result).toBe(true);
expect(db._update.set).toHaveBeenCalledWith({
title: 'Chapter 1',
origin: 'koreader',
devicePos: '/body/DocFragment[2]',
pageno: 12,
deletedAt: null,
});
expect(result).toEqual(row);
});
it('returns false when no rows are deleted', async () => {
it('returns null without querying when the location is empty', async () => {
const { repo, db } = makeRepository();
db._deleteWhere.returning.mockResolvedValue([]);
const result = await repo.delete(5, 999, 10);
const result = await repo.restoreAtLocation(
10,
5,
{ cfi: null, positionSeconds: null },
{ title: 'x', origin: 'web', devicePos: null, pageno: null },
);
expect(result).toBeNull();
expect(db.update).not.toHaveBeenCalled();
});
});
describe('softDelete', () => {
it('returns true when a live row was tombstoned', async () => {
const { repo, db } = makeRepository();
db._updateWhere.returning.mockResolvedValue([{ id: 1 }]);
const result = await repo.softDelete(5, 1, 10);
expect(result).toBe(true);
expect(db.delete).not.toHaveBeenCalled();
});
it('returns false when nothing matched', async () => {
const { repo, db } = makeRepository();
db._updateWhere.returning.mockResolvedValue([]);
const result = await repo.softDelete(5, 999, 10);
expect(result).toBe(false);
});
});
describe('sync operations', () => {
it('lists a bounded, tombstone-inclusive working set', async () => {
const { repo, db } = makeRepository();
const rows = [makeRow(), makeRow({ id: 2, deletedAt: new Date('2026-02-01T00:00:00Z') })];
db._where.orderBy.mockReturnValue(db._orderBy);
db._orderBy.limit.mockResolvedValue(rows);
const result = await repo.listForSync(10, 5, 500);
expect(result).toEqual(rows);
expect(db._orderBy.limit).toHaveBeenCalledWith(500);
});
it('inserts a device bookmark with its canonical device position', async () => {
const { repo, db } = makeRepository();
const row = makeRow({ origin: 'koreader' });
db._conflict.returning.mockResolvedValue([row]);
const result = await repo.createFromDevice(10, 5, {
cfi: 'epubcfi(/6/2)',
title: 'Chapter 1',
devicePos: '/body/DocFragment[2]/body/p[3]/text().0',
pageno: 12,
});
expect(db._insert.values).toHaveBeenCalledWith({
userId: 10,
bookId: 5,
cfi: 'epubcfi(/6/2)',
title: 'Chapter 1',
origin: 'koreader',
devicePos: '/body/DocFragment[2]/body/p[3]/text().0',
pageno: 12,
});
expect(result).toEqual(row);
});
it('skips the tombstone update when no ids are given', async () => {
const { repo, db } = makeRepository();
expect(await repo.tombstone(10, [])).toBe(0);
expect(db.update).not.toHaveBeenCalled();
});
it('counts tombstoned rows', async () => {
const { repo, db } = makeRepository();
db._updateWhere.returning.mockResolvedValue([{ id: 3 }, { id: 4 }]);
expect(await repo.tombstone(10, [3, 4])).toBe(2);
});
it('skips the purge delete when no ids are given', async () => {
const { repo, db } = makeRepository();
expect(await repo.purge(10, [])).toBe(0);
expect(db.delete).not.toHaveBeenCalled();
});
it('hard deletes purgeable tombstones', async () => {
const { repo, db } = makeRepository();
db._deleteWhere.returning.mockResolvedValue([{ id: 7 }]);
expect(await repo.purge(10, [7])).toBe(1);
});
it('returns purge candidate ids oldest first', async () => {
const { repo, db } = makeRepository();
db._where.orderBy.mockReturnValue(db._orderBy);
db._orderBy.limit.mockResolvedValue([{ id: 3 }, { id: 9 }]);
const result = await repo.listPurgeableTombstones(10, new Date('2026-01-01T00:00:00Z'), 50);
expect(result).toEqual([3, 9]);
expect(db._orderBy.limit).toHaveBeenCalledWith(50);
});
});
});
@@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
import { and, asc, eq, isNull } from 'drizzle-orm';
import { and, asc, eq, inArray, isNotNull, isNull, lt } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../db';
@@ -8,6 +8,14 @@ import { bookmarks, type BookmarkRow, type NewBookmark } from '../../db/schema';
type Db = NodePgDatabase<typeof schema>;
/** What a device-originated bookmark carries beyond the shared location/title pair. */
export interface DeviceBookmarkFields {
cfi: string;
title: string;
devicePos: string;
pageno: number | null;
}
@Injectable()
export class BookmarkRepository {
constructor(@Inject(DB) private readonly db: Db) {}
@@ -16,16 +24,16 @@ export class BookmarkRepository {
return this.db
.select()
.from(bookmarks)
.where(and(eq(bookmarks.bookId, bookId), eq(bookmarks.userId, userId)))
.where(and(eq(bookmarks.bookId, bookId), eq(bookmarks.userId, userId), isNull(bookmarks.deletedAt)))
.orderBy(asc(bookmarks.createdAt), asc(bookmarks.id));
}
async findExistingByLocation(userId: number, bookId: number, data: Pick<NewBookmark, 'cfi' | 'positionSeconds'>): Promise<BookmarkRow | null> {
async findLiveByLocation(userId: number, bookId: number, data: Pick<NewBookmark, 'cfi' | 'positionSeconds'>): Promise<BookmarkRow | null> {
if (data.cfi != null) {
const [row] = await this.db
.select()
.from(bookmarks)
.where(and(eq(bookmarks.userId, userId), eq(bookmarks.bookId, bookId), eq(bookmarks.cfi, data.cfi)))
.where(and(eq(bookmarks.userId, userId), eq(bookmarks.bookId, bookId), eq(bookmarks.cfi, data.cfi), isNull(bookmarks.deletedAt)))
.orderBy(asc(bookmarks.createdAt), asc(bookmarks.id))
.limit(1);
return row ?? null;
@@ -36,7 +44,13 @@ export class BookmarkRepository {
.select()
.from(bookmarks)
.where(
and(eq(bookmarks.userId, userId), eq(bookmarks.bookId, bookId), eq(bookmarks.positionSeconds, data.positionSeconds), isNull(bookmarks.cfi)),
and(
eq(bookmarks.userId, userId),
eq(bookmarks.bookId, bookId),
eq(bookmarks.positionSeconds, data.positionSeconds),
isNull(bookmarks.cfi),
isNull(bookmarks.deletedAt),
),
)
.orderBy(asc(bookmarks.createdAt), asc(bookmarks.id))
.limit(1);
@@ -55,11 +69,118 @@ export class BookmarkRepository {
return row ?? null;
}
async delete(bookId: number, bookmarkId: number, userId: number) {
/**
* Re-activates the tombstoned row that owns this location. The unique location
* indexes still cover tombstoned rows, so inserting at a deleted bookmark's CFI
* would conflict instead of recreating it.
*/
async restoreAtLocation(
userId: number,
bookId: number,
data: Pick<NewBookmark, 'cfi' | 'positionSeconds'>,
values: Pick<NewBookmark, 'title' | 'origin' | 'devicePos' | 'pageno'>,
): Promise<BookmarkRow | null> {
const location =
data.cfi != null
? eq(bookmarks.cfi, data.cfi)
: data.positionSeconds != null
? and(eq(bookmarks.positionSeconds, data.positionSeconds), isNull(bookmarks.cfi))
: null;
if (!location) return null;
const [row] = await this.db
.update(bookmarks)
.set({
title: values.title,
origin: values.origin ?? 'web',
devicePos: values.devicePos ?? null,
pageno: values.pageno ?? null,
deletedAt: null,
})
.where(and(eq(bookmarks.userId, userId), eq(bookmarks.bookId, bookId), location, isNotNull(bookmarks.deletedAt)))
.returning();
return row ?? null;
}
/** Soft delete: devices still holding the bookmark learn about it on their next exchange. */
async softDelete(bookId: number, bookmarkId: number, userId: number) {
const result = await this.db
.delete(bookmarks)
.where(and(eq(bookmarks.id, bookmarkId), eq(bookmarks.bookId, bookId), eq(bookmarks.userId, userId)))
.update(bookmarks)
.set({ deletedAt: new Date() })
.where(and(eq(bookmarks.id, bookmarkId), eq(bookmarks.bookId, bookId), eq(bookmarks.userId, userId), isNull(bookmarks.deletedAt)))
.returning({ id: bookmarks.id });
return result.length > 0;
}
// Sync-facing operations. The bookmarks table stays owned here; the KOReader
// module drives these through BookmarkSyncService.
/** Every row of one book, tombstones included, so a sync can see deletions too. */
async listForSync(userId: number, bookId: number, limit: number): Promise<BookmarkRow[]> {
return this.db
.select()
.from(bookmarks)
.where(and(eq(bookmarks.userId, userId), eq(bookmarks.bookId, bookId)))
.orderBy(asc(bookmarks.id))
.limit(limit);
}
async createFromDevice(userId: number, bookId: number, fields: DeviceBookmarkFields): Promise<BookmarkRow | null> {
const [row] = await this.db
.insert(bookmarks)
.values({
userId,
bookId,
cfi: fields.cfi,
title: fields.title,
origin: 'koreader',
devicePos: fields.devicePos,
pageno: fields.pageno,
})
.onConflictDoNothing()
.returning();
return row ?? null;
}
async updateFromDevice(
userId: number,
bookmarkId: number,
values: Partial<Pick<NewBookmark, 'title' | 'devicePos' | 'pageno'>>,
): Promise<BookmarkRow | null> {
const [row] = await this.db
.update(bookmarks)
.set(values)
.where(and(eq(bookmarks.id, bookmarkId), eq(bookmarks.userId, userId), isNull(bookmarks.deletedAt)))
.returning();
return row ?? null;
}
async tombstone(userId: number, bookmarkIds: number[]): Promise<number> {
if (bookmarkIds.length === 0) return 0;
const rows = await this.db
.update(bookmarks)
.set({ deletedAt: new Date() })
.where(and(eq(bookmarks.userId, userId), inArray(bookmarks.id, bookmarkIds), isNull(bookmarks.deletedAt)))
.returning({ id: bookmarks.id });
return rows.length;
}
async listPurgeableTombstones(userId: number, deletedBefore: Date, limit: number): Promise<number[]> {
const rows = await this.db
.select({ id: bookmarks.id })
.from(bookmarks)
.where(and(eq(bookmarks.userId, userId), isNotNull(bookmarks.deletedAt), lt(bookmarks.deletedAt, deletedBefore)))
.orderBy(asc(bookmarks.deletedAt))
.limit(limit);
return rows.map((row) => row.id);
}
async purge(userId: number, bookmarkIds: number[]): Promise<number> {
if (bookmarkIds.length === 0) return 0;
const rows = await this.db
.delete(bookmarks)
.where(and(eq(bookmarks.userId, userId), inArray(bookmarks.id, bookmarkIds), isNotNull(bookmarks.deletedAt)))
.returning({ id: bookmarks.id });
return rows.length;
}
}
@@ -42,9 +42,10 @@ function makeBookmarkRow(overrides?: Record<string, unknown>) {
function makeService() {
const bookmarkRepo = {
findByBookId: vi.fn(),
findExistingByLocation: vi.fn(),
findLiveByLocation: vi.fn(),
create: vi.fn(),
delete: vi.fn(),
restoreAtLocation: vi.fn().mockResolvedValue(null),
softDelete: vi.fn(),
};
const bookService = {
verifyBookAccess: vi.fn().mockResolvedValue(undefined),
@@ -83,13 +84,13 @@ describe('BookmarkService', () => {
const { service, bookmarkRepo, bookService } = makeService();
const user = makeUser();
const existing = makeBookmarkRow();
bookmarkRepo.findExistingByLocation.mockResolvedValue(existing);
bookmarkRepo.findLiveByLocation.mockResolvedValue(existing);
const dto: CreateBookmarkDto = { cfi: 'epubcfi(/6/4!/4/2/1:0)', title: 'Chapter 1' };
const result = await service.createBookmark(5, user, dto);
expect(bookService.verifyBookAccess).toHaveBeenCalledWith(5, user);
expect(bookmarkRepo.findExistingByLocation).toHaveBeenCalledWith(1, 5, {
expect(bookmarkRepo.findLiveByLocation).toHaveBeenCalledWith(1, 5, {
cfi: 'epubcfi(/6/4!/4/2/1:0)',
positionSeconds: null,
});
@@ -100,11 +101,11 @@ describe('BookmarkService', () => {
it('returns an existing bookmark for duplicate audio position requests', async () => {
const { service, bookmarkRepo } = makeService();
const existing = makeBookmarkRow({ id: 11, cfi: null, positionSeconds: 93.5, title: '00:01:33' });
bookmarkRepo.findExistingByLocation.mockResolvedValue(existing);
bookmarkRepo.findLiveByLocation.mockResolvedValue(existing);
const result = await service.createBookmark(5, makeUser(), { title: '00:01:33', positionSeconds: 93.5 });
expect(bookmarkRepo.findExistingByLocation).toHaveBeenCalledWith(1, 5, { cfi: null, positionSeconds: 93.5 });
expect(bookmarkRepo.findLiveByLocation).toHaveBeenCalledWith(1, 5, { cfi: null, positionSeconds: 93.5 });
expect(bookmarkRepo.create).not.toHaveBeenCalled();
expect(result.id).toBe(11);
expect(result.positionSeconds).toBe(93.5);
@@ -113,7 +114,7 @@ describe('BookmarkService', () => {
it('creates and maps a bookmark when no duplicate exists', async () => {
const { service, bookmarkRepo } = makeService();
const createdRow = makeBookmarkRow({ id: 12, cfi: null, positionSeconds: 42, title: '00:00:42' });
bookmarkRepo.findExistingByLocation.mockResolvedValue(null);
bookmarkRepo.findLiveByLocation.mockResolvedValue(null);
bookmarkRepo.create.mockResolvedValue(createdRow);
const result = await service.createBookmark(5, makeUser(), { title: '00:00:42', positionSeconds: 42 });
@@ -128,40 +129,58 @@ describe('BookmarkService', () => {
it('re-reads and returns the existing bookmark when a concurrent duplicate insert is ignored', async () => {
const { service, bookmarkRepo } = makeService();
const existing = makeBookmarkRow({ id: 13, cfi: null, positionSeconds: 42, title: '00:00:42' });
bookmarkRepo.findExistingByLocation.mockResolvedValueOnce(null).mockResolvedValueOnce(existing);
bookmarkRepo.findLiveByLocation.mockResolvedValueOnce(null).mockResolvedValueOnce(existing);
bookmarkRepo.create.mockResolvedValue(null);
const result = await service.createBookmark(5, makeUser(), { title: '00:00:42', positionSeconds: 42 });
expect(bookmarkRepo.findExistingByLocation).toHaveBeenNthCalledWith(2, 1, 5, { cfi: null, positionSeconds: 42 });
expect(bookmarkRepo.findLiveByLocation).toHaveBeenNthCalledWith(2, 1, 5, { cfi: null, positionSeconds: 42 });
expect(result.id).toBe(13);
});
it('restores the tombstone that still owns the location instead of failing the insert', async () => {
const { service, bookmarkRepo } = makeService();
const restored = makeBookmarkRow({ id: 14, deletedAt: null });
bookmarkRepo.findLiveByLocation.mockResolvedValue(null);
bookmarkRepo.create.mockResolvedValue(null);
bookmarkRepo.restoreAtLocation.mockResolvedValue(restored);
const result = await service.createBookmark(5, makeUser(), { title: 'Chapter 1', cfi: 'epubcfi(/6/4!/4/2/1:0)' });
expect(bookmarkRepo.restoreAtLocation).toHaveBeenCalledWith(
1,
5,
{ cfi: 'epubcfi(/6/4!/4/2/1:0)', positionSeconds: null },
{ title: 'Chapter 1', origin: 'web', devicePos: null, pageno: null },
);
expect(result.id).toBe(14);
});
it('propagates access errors and does not query repository', async () => {
const { service, bookService, bookmarkRepo } = makeService();
bookService.verifyBookAccess.mockRejectedValue(new NotFoundException('Book 5 not found'));
await expect(service.createBookmark(5, makeUser(), { title: 'x', cfi: 'epubcfi(/6/2)' })).rejects.toThrow(NotFoundException);
expect(bookmarkRepo.findExistingByLocation).not.toHaveBeenCalled();
expect(bookmarkRepo.findLiveByLocation).not.toHaveBeenCalled();
expect(bookmarkRepo.create).not.toHaveBeenCalled();
});
});
describe('deleteBookmark', () => {
it('verifies access and deletes bookmark', async () => {
it('verifies access and soft deletes the bookmark', async () => {
const { service, bookmarkRepo, bookService } = makeService();
const user = makeUser();
bookmarkRepo.delete.mockResolvedValue(true);
bookmarkRepo.softDelete.mockResolvedValue(true);
await expect(service.deleteBookmark(5, 10, user)).resolves.toBeUndefined();
expect(bookService.verifyBookAccess).toHaveBeenCalledWith(5, user);
expect(bookmarkRepo.delete).toHaveBeenCalledWith(5, 10, 1);
expect(bookmarkRepo.softDelete).toHaveBeenCalledWith(5, 10, 1);
});
it('throws NotFoundException with stable message when bookmark is missing', async () => {
const { service, bookmarkRepo } = makeService();
bookmarkRepo.delete.mockResolvedValue(false);
bookmarkRepo.softDelete.mockResolvedValue(false);
await expect(service.deleteBookmark(5, 99, makeUser())).rejects.toThrow(NotFoundException);
await expect(service.deleteBookmark(5, 99, makeUser())).rejects.toThrow('Bookmark 99 not found for book 5');
@@ -172,7 +191,7 @@ describe('BookmarkService', () => {
bookService.verifyBookAccess.mockRejectedValue(new ForbiddenException());
await expect(service.deleteBookmark(5, 10, makeUser())).rejects.toThrow(ForbiddenException);
expect(bookmarkRepo.delete).not.toHaveBeenCalled();
expect(bookmarkRepo.softDelete).not.toHaveBeenCalled();
});
});
});
+19 -14
View File
@@ -25,27 +25,32 @@ export class BookmarkService {
async createBookmark(bookId: number, user: RequestUser, dto: CreateBookmarkDto): Promise<BookmarkResponseDto> {
await this.bookService.verifyBookAccess(bookId, user);
const createData = this.buildCreateData(dto);
const existing = await this.bookmarkRepo.findExistingByLocation(user.id, bookId, {
cfi: createData.cfi,
positionSeconds: createData.positionSeconds,
});
const location = { cfi: createData.cfi, positionSeconds: createData.positionSeconds };
const existing = await this.bookmarkRepo.findLiveByLocation(user.id, bookId, location);
if (existing) return BookmarkResponseDto.from(existing);
const row = await this.bookmarkRepo.create(user.id, bookId, createData);
if (!row) {
const concurrent = await this.bookmarkRepo.findExistingByLocation(user.id, bookId, {
cfi: createData.cfi,
positionSeconds: createData.positionSeconds,
});
if (concurrent) return BookmarkResponseDto.from(concurrent);
throw new ConflictException(BOOKMARK_CONFLICT_MESSAGE);
}
return BookmarkResponseDto.from(row);
if (row) return BookmarkResponseDto.from(row);
// The insert conflicted: either a tombstone still owns this location and has to
// come back to life, or another request created the same bookmark first.
const restored = await this.bookmarkRepo.restoreAtLocation(user.id, bookId, location, {
title: createData.title,
origin: 'web',
devicePos: null,
pageno: null,
});
if (restored) return BookmarkResponseDto.from(restored);
const concurrent = await this.bookmarkRepo.findLiveByLocation(user.id, bookId, location);
if (concurrent) return BookmarkResponseDto.from(concurrent);
throw new ConflictException(BOOKMARK_CONFLICT_MESSAGE);
}
async deleteBookmark(bookId: number, bookmarkId: number, user: RequestUser): Promise<void> {
await this.bookService.verifyBookAccess(bookId, user);
const deleted = await this.bookmarkRepo.delete(bookId, bookmarkId, user.id);
const deleted = await this.bookmarkRepo.softDelete(bookId, bookmarkId, user.id);
if (!deleted) throw new NotFoundException(this.notFoundMessage(bookId, bookmarkId));
}
+1
View File
@@ -1,3 +1,4 @@
export * from './koreader-bookmark-exchange.dto';
export * from './koreader-catalog-query.dto';
export * from './koreader-device-param.dto';
export * from './koreader-exchange.dto';
@@ -0,0 +1,157 @@
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
ArrayMinSize,
IsArray,
IsBoolean,
IsIn,
IsInt,
IsOptional,
IsString,
Matches,
MaxLength,
Min,
ValidateNested,
} from 'class-validator';
import { PluginDeviceDto } from './koreader-plugin.dto';
const MD5_HEX = /^[0-9a-f]{32}$/i;
const DEVICE_DATETIME = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/;
export const BOOKMARK_EXCHANGE_MAX_BOOKS = 20;
export const BOOKMARK_EXCHANGE_MAX_KEYS = 500;
export const BOOKMARK_EXCHANGE_MAX_CHANGES = 50;
export class BookmarkExchangeKeyDto {
@IsString()
@Matches(MD5_HEX)
k!: string;
@IsString()
@Matches(DEVICE_DATETIME)
dt!: string;
}
/** One position-only bookmark (dogear) as KOReader stores it on a rolling document. */
export class KoreaderBookmarkDto {
@IsString()
@Matches(DEVICE_DATETIME)
datetime!: string;
@IsOptional()
@IsString()
@Matches(DEVICE_DATETIME)
datetimeUpdated?: string;
@IsString()
@MaxLength(4000)
pos!: string;
@IsOptional()
@IsInt()
@Min(0)
pageno?: number;
@IsOptional()
@IsString()
@MaxLength(500)
chapter?: string;
@IsOptional()
@IsString()
@MaxLength(500)
note?: string;
}
export class BookmarkExchangeBookDto {
@IsString()
@Matches(MD5_HEX)
hash!: string;
@IsArray()
@ArrayMaxSize(BOOKMARK_EXCHANGE_MAX_KEYS)
@ValidateNested({ each: true })
@Type(() => BookmarkExchangeKeyDto)
keys!: BookmarkExchangeKeyDto[];
/** False when the device chunk-capped the key list; deletion detection is skipped then. */
@IsBoolean()
keysComplete!: boolean;
@IsArray()
@ArrayMaxSize(BOOKMARK_EXCHANGE_MAX_CHANGES)
@ValidateNested({ each: true })
@Type(() => KoreaderBookmarkDto)
changes!: KoreaderBookmarkDto[];
}
export class BookmarkExchangeDto extends PluginDeviceDto {
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(BOOKMARK_EXCHANGE_MAX_BOOKS)
@ValidateNested({ each: true })
@Type(() => BookmarkExchangeBookDto)
books!: BookmarkExchangeBookDto[];
}
export class BookmarkAckAppliedDto {
@IsInt()
@Min(1)
serverId!: number;
@IsIn(['applied', 'failed'])
status!: 'applied' | 'failed';
/** Local identity the device gave the applied bookmark; without it nothing links. */
@IsOptional()
@IsString()
@Matches(MD5_HEX)
key?: string;
@IsOptional()
@IsString()
@Matches(DEVICE_DATETIME)
datetime?: string;
@IsOptional()
@IsString()
@MaxLength(4000)
pos?: string;
}
export class BookmarkAckDeletedDto {
@IsInt()
@Min(1)
serverId!: number;
@IsIn(['applied', 'failed'])
status!: 'applied' | 'failed';
}
export class BookmarkAckBookDto {
@IsString()
@Matches(MD5_HEX)
hash!: string;
@IsArray()
@ArrayMaxSize(200)
@ValidateNested({ each: true })
@Type(() => BookmarkAckAppliedDto)
applied!: BookmarkAckAppliedDto[];
@IsArray()
@ArrayMaxSize(200)
@ValidateNested({ each: true })
@Type(() => BookmarkAckDeletedDto)
deleted!: BookmarkAckDeletedDto[];
}
export class BookmarkExchangeAckDto extends PluginDeviceDto {
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(BOOKMARK_EXCHANGE_MAX_BOOKS)
@ValidateNested({ each: true })
@Type(() => BookmarkAckBookDto)
books!: BookmarkAckBookDto[];
}
@@ -0,0 +1,385 @@
import { Logger } from '@nestjs/common';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { RequestUser } from '../../common/types/request-user';
import type { BookmarkExchangeAckDto, BookmarkExchangeDto } from './dto';
import { KoreaderBookmarkExchangeService, buildBookmarkKey } from './koreader-bookmark-exchange.service';
const DEVICE_ID = 'device-1234';
const HASH_A = 'a'.repeat(32);
const HASH_B = 'b'.repeat(32);
const POS = '/body/DocFragment[3]/body/div/p[7]/text().0';
const DATETIME = '2026-06-08 10:11:12';
function makeUser(): RequestUser {
return { id: 7, settings: {} } as unknown as RequestUser;
}
function makeBookmarkRow(overrides: Record<string, unknown> = {}) {
return {
id: 100,
userId: 7,
bookId: 20,
cfi: 'epubcfi(/6/8!/4/2/14/1:0)',
title: 'Chapter 3',
positionSeconds: null,
origin: 'web',
devicePos: null,
pageno: null,
createdAt: new Date('2026-06-01T10:00:00Z'),
updatedAt: new Date('2026-06-01T10:00:00Z'),
deletedAt: null,
...overrides,
};
}
function makeLinkRow(overrides: Record<string, unknown> = {}) {
return {
id: 900,
bookmarkId: 100,
userId: 7,
deviceId: DEVICE_ID,
koreaderKey: buildBookmarkKey(DATETIME, POS),
deviceDatetime: DATETIME,
appliedAt: new Date('2026-06-02T10:00:00Z'),
...overrides,
};
}
function makeExchangeDto(books: BookmarkExchangeDto['books']): BookmarkExchangeDto {
return { deviceId: DEVICE_ID, deviceModel: 'Kobo', pluginVersion: '0.5.0', books } as BookmarkExchangeDto;
}
function makeBook(overrides: Partial<BookmarkExchangeDto['books'][number]> = {}): BookmarkExchangeDto['books'][number] {
return { hash: HASH_A, keys: [], keysComplete: false, changes: [], ...overrides } as BookmarkExchangeDto['books'][number];
}
describe('KoreaderBookmarkExchangeService', () => {
let koreaderRepo: { getAccessibleLibraryIds: ReturnType<typeof vi.fn>; resolveBookFilesByHashes: ReturnType<typeof vi.fn> };
let bookmarkRepo: Record<string, ReturnType<typeof vi.fn>>;
let bookmarkSync: Record<string, ReturnType<typeof vi.fn>>;
let positionConverter: Record<string, ReturnType<typeof vi.fn>>;
let service: KoreaderBookmarkExchangeService;
beforeEach(() => {
vi.restoreAllMocks();
vi.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
koreaderRepo = {
getAccessibleLibraryIds: vi.fn().mockResolvedValue([1]),
resolveBookFilesByHashes: vi.fn().mockResolvedValue(new Map([[HASH_A, { bookFileId: 10, bookId: 20, libraryId: 1 }]])),
};
bookmarkRepo = {
findLinksForBookmarks: vi.fn().mockResolvedValue([]),
findBookmarkIdsWithLinks: vi.fn().mockResolvedValue(new Set()),
upsertLink: vi.fn().mockResolvedValue(undefined),
deleteLinks: vi.fn().mockResolvedValue(0),
};
bookmarkSync = {
listForSync: vi.fn().mockResolvedValue([]),
upsertFromDevice: vi.fn(),
applyDeviceEdit: vi.fn().mockResolvedValue(null),
tombstone: vi.fn().mockResolvedValue(0),
listPurgeableTombstones: vi.fn().mockResolvedValue([]),
purge: vi.fn().mockResolvedValue(0),
};
positionConverter = {
xpointerPointToCfi: vi.fn().mockResolvedValue({ status: 'exact', cfi: 'epubcfi(/6/8!/4/2/14/1:0)' }),
cfiPointToXpointer: vi.fn().mockResolvedValue({ status: 'exact', pos0: POS }),
};
service = new KoreaderBookmarkExchangeService(koreaderRepo as never, bookmarkRepo as never, bookmarkSync as never, positionConverter as never);
});
describe('exchange', () => {
it('reports books the server cannot resolve as unmatched without touching them', async () => {
const result = await service.exchange(makeUser(), makeExchangeDto([makeBook({ hash: HASH_B })]));
expect(result.unmatched).toEqual([HASH_B]);
expect(result.results).toEqual([]);
expect(bookmarkSync.listForSync).not.toHaveBeenCalled();
});
it('creates a bookmark from an unknown device key and links it', async () => {
const created = makeBookmarkRow({ id: 101, origin: 'koreader', devicePos: POS, pageno: 42 });
bookmarkSync.upsertFromDevice.mockResolvedValue({ row: created, outcome: 'created' });
bookmarkSync.listForSync.mockResolvedValueOnce([]).mockResolvedValueOnce([created]);
bookmarkRepo.findLinksForBookmarks.mockResolvedValueOnce([]).mockResolvedValueOnce([makeLinkRow({ bookmarkId: 101 })]);
const result = await service.exchange(
makeUser(),
makeExchangeDto([makeBook({ changes: [{ datetime: DATETIME, pos: POS, pageno: 42, chapter: 'Chapter 3' }] })]),
);
expect(positionConverter.xpointerPointToCfi).toHaveBeenCalledWith({ bookFileId: 10, pos: POS });
expect(bookmarkSync.upsertFromDevice).toHaveBeenCalledWith(7, 20, {
cfi: 'epubcfi(/6/8!/4/2/14/1:0)',
title: 'Chapter 3',
devicePos: POS,
pageno: 42,
});
expect(bookmarkRepo.upsertLink).toHaveBeenCalledWith({
bookmarkId: 101,
userId: 7,
deviceId: DEVICE_ID,
koreaderKey: buildBookmarkKey(DATETIME, POS),
deviceDatetime: DATETIME,
});
expect(result.results[0]).toMatchObject({ accepted: 1, duplicates: 0, rejected: 0 });
// Already linked by the ingest, so nothing is pushed back at it.
expect(result.results[0].toApply.add).toEqual([]);
});
it('prefers the device note over the chapter title, then the page label', async () => {
bookmarkSync.upsertFromDevice.mockResolvedValue({ row: makeBookmarkRow({ id: 101 }), outcome: 'created' });
await service.exchange(
makeUser(),
makeExchangeDto([makeBook({ changes: [{ datetime: DATETIME, pos: POS, pageno: 42, chapter: 'Chapter 3', note: 'Look here' }] })]),
);
expect(bookmarkSync.upsertFromDevice).toHaveBeenLastCalledWith(7, 20, expect.objectContaining({ title: 'Look here' }));
await service.exchange(makeUser(), makeExchangeDto([makeBook({ changes: [{ datetime: DATETIME, pos: POS, pageno: 42 }] })]));
expect(bookmarkSync.upsertFromDevice).toHaveBeenLastCalledWith(7, 20, expect.objectContaining({ title: 'p. 42' }));
await service.exchange(makeUser(), makeExchangeDto([makeBook({ changes: [{ datetime: DATETIME, pos: POS }] })]));
expect(bookmarkSync.upsertFromDevice).toHaveBeenLastCalledWith(7, 20, expect.objectContaining({ title: 'Bookmark' }));
});
it('rejects a change whose position cannot be converted, leaving no CFI-less row behind', async () => {
positionConverter.xpointerPointToCfi.mockResolvedValue({ status: 'failed', reason: 'chapter_unavailable' });
const result = await service.exchange(makeUser(), makeExchangeDto([makeBook({ changes: [{ datetime: DATETIME, pos: POS }] })]));
expect(bookmarkSync.upsertFromDevice).not.toHaveBeenCalled();
expect(result.results[0]).toMatchObject({ accepted: 0, rejected: 1 });
});
it('counts a bookmark another device already created as a duplicate', async () => {
bookmarkSync.upsertFromDevice.mockResolvedValue({ row: makeBookmarkRow(), outcome: 'matched' });
const result = await service.exchange(makeUser(), makeExchangeDto([makeBook({ changes: [{ datetime: DATETIME, pos: POS }] })]));
expect(result.results[0]).toMatchObject({ accepted: 0, duplicates: 1 });
expect(bookmarkRepo.upsertLink).toHaveBeenCalledTimes(1);
});
it('applies a device rename to a known key without creating anything', async () => {
const bookmark = makeBookmarkRow({ title: 'Chapter 3' });
bookmarkSync.listForSync.mockResolvedValue([bookmark]);
bookmarkRepo.findLinksForBookmarks.mockResolvedValue([makeLinkRow()]);
const result = await service.exchange(
makeUser(),
makeExchangeDto([makeBook({ changes: [{ datetime: DATETIME, pos: POS, pageno: 42, note: 'Renamed' }] })]),
);
expect(bookmarkSync.applyDeviceEdit).toHaveBeenCalledWith(7, 100, { title: 'Renamed', pageno: 42 });
expect(bookmarkSync.upsertFromDevice).not.toHaveBeenCalled();
expect(result.results[0]).toMatchObject({ accepted: 0, duplicates: 1 });
});
it('leaves an unchanged known key alone', async () => {
bookmarkSync.listForSync.mockResolvedValue([makeBookmarkRow({ title: 'Chapter 3' })]);
bookmarkRepo.findLinksForBookmarks.mockResolvedValue([makeLinkRow()]);
await service.exchange(makeUser(), makeExchangeDto([makeBook({ changes: [{ datetime: DATETIME, pos: POS, chapter: 'Chapter 3' }] })]));
expect(bookmarkSync.applyDeviceEdit).not.toHaveBeenCalled();
});
it('pushes a live web bookmark down with a converted position', async () => {
bookmarkSync.listForSync.mockResolvedValue([makeBookmarkRow({ pageno: 7 })]);
const result = await service.exchange(makeUser(), makeExchangeDto([makeBook()]));
expect(positionConverter.cfiPointToXpointer).toHaveBeenCalledWith({ bookFileId: 10, cfi: 'epubcfi(/6/8!/4/2/14/1:0)' });
expect(result.results[0].toApply.add).toEqual([{ serverId: 100, pos: POS, pageno: 7, title: 'Chapter 3' }]);
expect(result.results[0].skippedConversion).toBe(0);
});
it('returns a device-originated bookmark at its stored device position without reconverting', async () => {
bookmarkSync.listForSync.mockResolvedValue([makeBookmarkRow({ origin: 'koreader', devicePos: POS, pageno: 3 })]);
const result = await service.exchange(makeUser(), makeExchangeDto([makeBook()]));
expect(positionConverter.cfiPointToXpointer).not.toHaveBeenCalled();
expect(result.results[0].toApply.add[0]).toMatchObject({ serverId: 100, pos: POS, pageno: 3 });
});
it('counts an unconvertible web bookmark as skipped instead of pushing it', async () => {
bookmarkSync.listForSync.mockResolvedValue([makeBookmarkRow()]);
positionConverter.cfiPointToXpointer.mockResolvedValue({ status: 'failed', reason: 'unresolvable_structure' });
const result = await service.exchange(makeUser(), makeExchangeDto([makeBook()]));
expect(result.results[0].toApply.add).toEqual([]);
expect(result.results[0].skippedConversion).toBe(1);
});
it('never pushes an audio bookmark or a tombstone as an add', async () => {
bookmarkSync.listForSync.mockResolvedValue([
makeBookmarkRow({ id: 101, cfi: null, positionSeconds: 90 }),
makeBookmarkRow({ id: 102, deletedAt: new Date('2026-06-05T10:00:00Z') }),
]);
const result = await service.exchange(makeUser(), makeExchangeDto([makeBook()]));
expect(result.results[0].toApply.add).toEqual([]);
});
it('pushes a delete for a tombstone this device still holds', async () => {
bookmarkSync.listForSync.mockResolvedValue([makeBookmarkRow({ deletedAt: new Date('2026-06-05T10:00:00Z') })]);
bookmarkRepo.findLinksForBookmarks.mockResolvedValue([makeLinkRow()]);
const result = await service.exchange(makeUser(), makeExchangeDto([makeBook()]));
expect(result.results[0].toApply.delete).toEqual([{ serverId: 100, key: buildBookmarkKey(DATETIME, POS), datetime: DATETIME }]);
});
it('tombstones a bookmark whose key the device no longer reports', async () => {
bookmarkSync.listForSync.mockResolvedValue([makeBookmarkRow()]);
bookmarkRepo.findLinksForBookmarks.mockResolvedValue([makeLinkRow()]);
bookmarkSync.tombstone.mockResolvedValue(1);
const result = await service.exchange(makeUser(), makeExchangeDto([makeBook({ keys: [], keysComplete: true })]));
expect(bookmarkSync.tombstone).toHaveBeenCalledWith(7, [100]);
expect(bookmarkRepo.deleteLinks).toHaveBeenCalledWith(7, DEVICE_ID, [100]);
expect(result.results[0].deviceDeleted).toBe(1);
});
it('skips deletion detection when the device could not report a complete key set', async () => {
bookmarkSync.listForSync.mockResolvedValue([makeBookmarkRow()]);
bookmarkRepo.findLinksForBookmarks.mockResolvedValue([makeLinkRow()]);
const result = await service.exchange(makeUser(), makeExchangeDto([makeBook({ keys: [], keysComplete: false })]));
expect(bookmarkSync.tombstone).not.toHaveBeenCalled();
expect(result.results[0].deviceDeleted).toBe(0);
});
it('keeps a bookmark whose key is still present', async () => {
bookmarkSync.listForSync.mockResolvedValue([makeBookmarkRow()]);
bookmarkRepo.findLinksForBookmarks.mockResolvedValue([makeLinkRow()]);
await service.exchange(
makeUser(),
makeExchangeDto([makeBook({ keys: [{ k: buildBookmarkKey(DATETIME, POS), dt: DATETIME }], keysComplete: true })]),
);
expect(bookmarkSync.tombstone).not.toHaveBeenCalled();
});
it('purges only expired tombstones no device still holds', async () => {
bookmarkSync.listPurgeableTombstones.mockResolvedValue([201, 202]);
bookmarkRepo.findBookmarkIdsWithLinks.mockResolvedValue(new Set([202]));
bookmarkSync.purge.mockResolvedValue(1);
await service.exchange(makeUser(), makeExchangeDto([makeBook()]));
expect(bookmarkSync.purge).toHaveBeenCalledWith(7, [201]);
});
it('does not purge when every expired tombstone is still linked', async () => {
bookmarkSync.listPurgeableTombstones.mockResolvedValue([201]);
bookmarkRepo.findBookmarkIdsWithLinks.mockResolvedValue(new Set([201]));
await service.exchange(makeUser(), makeExchangeDto([makeBook()]));
expect(bookmarkSync.purge).not.toHaveBeenCalled();
});
});
describe('exchangeAck', () => {
function makeAckDto(books: BookmarkExchangeAckDto['books']): BookmarkExchangeAckDto {
return { deviceId: DEVICE_ID, deviceModel: 'Kobo', pluginVersion: '0.5.0', books } as BookmarkExchangeAckDto;
}
it('links an applied bookmark under the identity the device reported', async () => {
bookmarkSync.listForSync.mockResolvedValue([makeBookmarkRow()]);
const result = await service.exchangeAck(
makeUser(),
makeAckDto([{ hash: HASH_A, applied: [{ serverId: 100, status: 'applied', key: 'f'.repeat(32), datetime: DATETIME }], deleted: [] }]),
);
expect(bookmarkRepo.upsertLink).toHaveBeenCalledWith({
bookmarkId: 100,
userId: 7,
deviceId: DEVICE_ID,
koreaderKey: 'f'.repeat(32),
deviceDatetime: DATETIME,
});
expect(result.results).toEqual([{ hash: HASH_A, acked: 1 }]);
});
it('derives the identity from datetime and position when the device omits the key', async () => {
bookmarkSync.listForSync.mockResolvedValue([makeBookmarkRow()]);
await service.exchangeAck(
makeUser(),
makeAckDto([{ hash: HASH_A, applied: [{ serverId: 100, status: 'applied', datetime: DATETIME, pos: POS }], deleted: [] }]),
);
expect(bookmarkRepo.upsertLink).toHaveBeenCalledWith(expect.objectContaining({ koreaderKey: buildBookmarkKey(DATETIME, POS) }));
});
it('ignores a failed apply and an apply with no usable identity', async () => {
bookmarkSync.listForSync.mockResolvedValue([makeBookmarkRow()]);
await service.exchangeAck(
makeUser(),
makeAckDto([
{
hash: HASH_A,
applied: [
{ serverId: 100, status: 'failed', key: 'f'.repeat(32) },
{ serverId: 100, status: 'applied' },
],
deleted: [],
},
]),
);
expect(bookmarkRepo.upsertLink).not.toHaveBeenCalled();
});
it('ignores a serverId that does not belong to this user and book', async () => {
bookmarkSync.listForSync.mockResolvedValue([makeBookmarkRow()]);
await service.exchangeAck(
makeUser(),
makeAckDto([{ hash: HASH_A, applied: [{ serverId: 999, status: 'applied', key: 'f'.repeat(32) }], deleted: [] }]),
);
expect(bookmarkRepo.upsertLink).not.toHaveBeenCalled();
});
it('drops the link once the device confirms the delete', async () => {
bookmarkSync.listForSync.mockResolvedValue([makeBookmarkRow({ deletedAt: new Date('2026-06-05T10:00:00Z') })]);
bookmarkRepo.deleteLinks.mockResolvedValue(1);
const result = await service.exchangeAck(
makeUser(),
makeAckDto([{ hash: HASH_A, applied: [], deleted: [{ serverId: 100, status: 'applied' }] }]),
);
expect(bookmarkRepo.deleteLinks).toHaveBeenCalledWith(7, DEVICE_ID, [100]);
expect(result.results).toEqual([{ hash: HASH_A, acked: 1 }]);
});
it('reports unresolved hashes as unmatched', async () => {
const result = await service.exchangeAck(makeUser(), makeAckDto([{ hash: HASH_B, applied: [], deleted: [] }]));
expect(result.unmatched).toEqual([HASH_B]);
expect(result.results).toEqual([]);
});
});
it('builds the same identity key the plugin computes', () => {
expect(buildBookmarkKey('2026-06-08 10:11:12', 'pos')).toHaveLength(32);
expect(buildBookmarkKey('2026-06-08 10:11:12', 'pos')).not.toBe(buildBookmarkKey('2026-06-08 10:11:13', 'pos'));
});
});
@@ -0,0 +1,393 @@
import { Injectable, Logger } from '@nestjs/common';
import { createHash } from 'crypto';
import type { RequestUser } from '../../common/types/request-user';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import type { BookmarkRow, KoreaderBookmarkLink } from '../../db/schema';
import { BookmarkSyncService } from '../bookmark/bookmark-sync.service';
import { PositionConverterService } from '../position-converter/position-converter.service';
import type { BookmarkAckBookDto, BookmarkExchangeAckDto, BookmarkExchangeBookDto, BookmarkExchangeDto, KoreaderBookmarkDto } from './dto';
import { KoreaderBookmarkRepository } from './koreader-bookmark.repository';
import { KoreaderRepository } from './koreader.repository';
const EXCHANGE_EVENT = 'koreader.bookmark_exchange';
const ACK_EVENT = 'koreader.bookmark_exchange_ack';
/** Per-book working set. Dogears are rare, so one bounded page covers a whole book. */
const BOOKMARKS_PER_BOOK_LIMIT = 500;
const PUSH_DOWN_PAGE = 100;
/** Converting a CFI opens a chapter document; keep one request's worth bounded. */
const CONVERSION_BUDGET_PER_REQUEST = 20;
const TOMBSTONE_RETENTION_DAYS = 90;
const PURGE_CANDIDATES_PER_REQUEST = 50;
const TITLE_MAX_LENGTH = 500;
export interface BookmarkAddEntry {
serverId: number;
pos: string;
pageno: number | null;
title: string;
}
export interface BookmarkDeleteEntry {
serverId: number;
/** The device's own identity for the row; it has no other way to find it locally. */
key: string;
datetime: string | null;
}
export interface BookmarkExchangeBookResult {
hash: string;
bookId: number;
accepted: number;
duplicates: number;
rejected: number;
deviceDeleted: number;
toApply: { add: BookmarkAddEntry[]; delete: BookmarkDeleteEntry[] };
more: boolean;
skippedConversion: number;
}
export interface BookmarkExchangeResponse {
results: BookmarkExchangeBookResult[];
unmatched: string[];
}
export interface BookmarkExchangeAckResponse {
results: { hash: string; acked: number }[];
unmatched: string[];
}
/** The device identity of a bookmark: md5 of `${datetime}|${pos}`, matching the plugin. */
export function buildBookmarkKey(datetime: string, pos: string): string {
return createHash('md5').update(`${datetime}|${pos}`).digest('hex');
}
@Injectable()
export class KoreaderBookmarkExchangeService {
private readonly logger = new Logger(KoreaderBookmarkExchangeService.name);
constructor(
private readonly koreaderRepo: KoreaderRepository,
private readonly bookmarkRepo: KoreaderBookmarkRepository,
private readonly bookmarkSync: BookmarkSyncService,
private readonly positionConverter: PositionConverterService,
) {}
async exchange(user: RequestUser, dto: BookmarkExchangeDto): Promise<BookmarkExchangeResponse> {
const startedAtMs = Date.now();
const totalChanges = dto.books.reduce((sum, book) => sum + book.changes.length, 0);
this.logger.log(
`[${EXCHANGE_EVENT}] [start] userId=${user.id} deviceId=${dto.deviceId.slice(0, 8)} books=${dto.books.length} changes=${totalChanges} - bookmark exchange started`,
);
try {
const matches = await this.resolveBooks(user.id, dto.books);
const results: BookmarkExchangeBookResult[] = [];
const unmatched: string[] = [];
let pushedTotal = 0;
for (const book of dto.books) {
const hash = book.hash.toLowerCase();
const match = matches.get(hash);
if (!match) {
unmatched.push(hash);
continue;
}
const result = await this.exchangeBook(user.id, dto.deviceId, hash, match.bookId, match.bookFileId, book);
pushedTotal += result.toApply.add.length + result.toApply.delete.length;
results.push(result);
}
await this.purgeExpiredTombstones(user.id);
this.logger.log(
`[${EXCHANGE_EVENT}] [end] userId=${user.id} deviceId=${dto.deviceId.slice(0, 8)} durationMs=${Date.now() - startedAtMs} books=${results.length} pushed=${pushedTotal} unmatched=${unmatched.length} - bookmark exchange completed`,
);
return { results, unmatched };
} catch (error) {
this.logFailure(EXCHANGE_EVENT, user.id, dto.deviceId, startedAtMs, error);
throw error;
}
}
async exchangeAck(user: RequestUser, dto: BookmarkExchangeAckDto): Promise<BookmarkExchangeAckResponse> {
const startedAtMs = Date.now();
try {
const matches = await this.resolveBooks(user.id, dto.books);
const results: { hash: string; acked: number }[] = [];
const unmatched: string[] = [];
for (const book of dto.books) {
const hash = book.hash.toLowerCase();
const match = matches.get(hash);
if (!match) {
unmatched.push(hash);
continue;
}
const acked = await this.ackBook(user.id, dto.deviceId, match.bookId, book);
results.push({ hash, acked });
}
this.logger.log(
`[${ACK_EVENT}] [end] userId=${user.id} deviceId=${dto.deviceId.slice(0, 8)} durationMs=${Date.now() - startedAtMs} books=${results.length} unmatched=${unmatched.length} - bookmark exchange ack applied`,
);
return { results, unmatched };
} catch (error) {
this.logFailure(ACK_EVENT, user.id, dto.deviceId, startedAtMs, error);
throw error;
}
}
private async resolveBooks(userId: number, books: { hash: string }[]) {
const accessibleLibraryIds = await this.koreaderRepo.getAccessibleLibraryIds(userId);
const hashes = [...new Set(books.map((book) => book.hash.toLowerCase()))];
return this.koreaderRepo.resolveBookFilesByHashes(hashes, accessibleLibraryIds, userId);
}
private logFailure(event: string, userId: number, deviceId: string, startedAtMs: number, error: unknown): void {
const errorClass = error instanceof Error ? error.constructor.name : 'UnknownError';
this.logger.warn(
`[${event}] [fail] userId=${userId} deviceId=${deviceId.slice(0, 8)} durationMs=${Date.now() - startedAtMs} errorClass=${errorClass} error="${sanitizeLogValue(error instanceof Error ? error.message : 'unknown error')}" - bookmark exchange failed`,
);
}
private async exchangeBook(
userId: number,
deviceId: string,
hash: string,
bookId: number,
bookFileId: number,
book: BookmarkExchangeBookDto,
): Promise<BookmarkExchangeBookResult> {
const before = await this.loadWorkingSet(userId, deviceId, bookId);
const ingest = await this.ingestDeviceChanges(userId, deviceId, bookId, bookFileId, book.changes, before);
const deviceDeleted = book.keysComplete ? await this.applyDeviceDeletions(userId, deviceId, book, before) : 0;
const after = ingest.mutated || deviceDeleted > 0 ? await this.loadWorkingSet(userId, deviceId, bookId) : before;
const push = await this.buildPushDown(userId, bookFileId, after);
return {
hash,
bookId,
accepted: ingest.accepted,
duplicates: ingest.duplicates,
rejected: ingest.rejected,
deviceDeleted,
toApply: { add: push.add, delete: push.delete },
more: push.more,
skippedConversion: push.skippedConversion,
};
}
private async loadWorkingSet(userId: number, deviceId: string, bookId: number) {
const bookmarks = await this.bookmarkSync.listForSync(userId, bookId, BOOKMARKS_PER_BOOK_LIMIT);
if (bookmarks.length === BOOKMARKS_PER_BOOK_LIMIT) {
this.logger.warn(
`[${EXCHANGE_EVENT}] userId=${userId} bookId=${bookId} limit=${BOOKMARKS_PER_BOOK_LIMIT} - bookmark working set capped; rows past the cap neither sync nor delete this round`,
);
}
const links = await this.bookmarkRepo.findLinksForBookmarks(
userId,
deviceId,
bookmarks.map((bookmark) => bookmark.id),
);
return { bookmarks, links, byId: new Map(bookmarks.map((bookmark) => [bookmark.id, bookmark])) };
}
/**
* Device adds and renames. A known key is a rename at most; an unknown key resolves
* to a CFI and then either links an existing bookmark, restores a tombstone or inserts.
*/
private async ingestDeviceChanges(
userId: number,
deviceId: string,
bookId: number,
bookFileId: number,
changes: KoreaderBookmarkDto[],
working: { links: KoreaderBookmarkLink[]; byId: Map<number, BookmarkRow> },
): Promise<{ accepted: number; duplicates: number; rejected: number; mutated: boolean }> {
let accepted = 0;
let duplicates = 0;
let rejected = 0;
let mutated = false;
if (changes.length === 0) return { accepted, duplicates, rejected, mutated };
const linkByKey = new Map(working.links.map((link) => [link.koreaderKey, link]));
const seenKeys = new Set<string>();
for (const change of changes) {
const key = buildBookmarkKey(change.datetime, change.pos);
if (seenKeys.has(key)) {
duplicates += 1;
continue;
}
seenKeys.add(key);
const title = this.titleFor(change);
const known = linkByKey.get(key);
if (known) {
const bookmark = working.byId.get(known.bookmarkId);
// Only the device can rename a dogear, so a differing title is always its edit.
if (bookmark && !bookmark.deletedAt && bookmark.title !== title) {
await this.bookmarkSync.applyDeviceEdit(userId, known.bookmarkId, { title, pageno: change.pageno ?? null });
mutated = true;
}
duplicates += 1;
continue;
}
const outcome = await this.positionConverter.xpointerPointToCfi({ bookFileId, pos: change.pos });
if (outcome.status === 'failed' || !outcome.cfi) {
rejected += 1;
continue;
}
const landed = await this.bookmarkSync.upsertFromDevice(userId, bookId, {
cfi: outcome.cfi,
title,
devicePos: change.pos,
pageno: change.pageno ?? null,
});
if (!landed) {
rejected += 1;
continue;
}
await this.bookmarkRepo.upsertLink({
bookmarkId: landed.row.id,
userId,
deviceId,
koreaderKey: key,
deviceDatetime: change.datetime,
});
mutated = true;
if (landed.outcome === 'matched') duplicates += 1;
else accepted += 1;
}
return { accepted, duplicates, rejected, mutated };
}
/**
* A link whose key the device no longer reports was deleted there. The bookmark is
* tombstoned globally and this device's link dropped; the other devices' links stay
* so they still receive the delete.
*/
private async applyDeviceDeletions(
userId: number,
deviceId: string,
book: BookmarkExchangeBookDto,
working: { links: KoreaderBookmarkLink[]; byId: Map<number, BookmarkRow> },
): Promise<number> {
const presentKeys = new Set(book.keys.map((entry) => entry.k));
const goneIds = working.links
.filter((link) => !presentKeys.has(link.koreaderKey))
.map((link) => link.bookmarkId)
.filter((bookmarkId) => working.byId.get(bookmarkId)?.deletedAt == null);
if (goneIds.length === 0) return 0;
const tombstoned = await this.bookmarkSync.tombstone(userId, goneIds);
await this.bookmarkRepo.deleteLinks(userId, deviceId, goneIds);
return tombstoned;
}
/** Live bookmarks this device has never seen, plus the tombstones it still holds. */
private async buildPushDown(
userId: number,
bookFileId: number,
working: { bookmarks: BookmarkRow[]; links: KoreaderBookmarkLink[] },
): Promise<{ add: BookmarkAddEntry[]; delete: BookmarkDeleteEntry[]; more: boolean; skippedConversion: number }> {
const linkedIds = new Set(working.links.map((link) => link.bookmarkId));
const byId = new Map(working.bookmarks.map((bookmark) => [bookmark.id, bookmark]));
const deleteEntries: BookmarkDeleteEntry[] = working.links
.filter((link) => byId.get(link.bookmarkId)?.deletedAt != null)
.map((link) => ({ serverId: link.bookmarkId, key: link.koreaderKey, datetime: link.deviceDatetime }));
// Audio bookmarks have no device counterpart, and a CFI-less row cannot be placed.
const candidates = working.bookmarks.filter((bookmark) => bookmark.deletedAt == null && bookmark.cfi != null && !linkedIds.has(bookmark.id));
const add: BookmarkAddEntry[] = [];
let skippedConversion = 0;
let conversionBudget = CONVERSION_BUDGET_PER_REQUEST;
for (const bookmark of candidates) {
if (add.length >= PUSH_DOWN_PAGE) break;
let pos = bookmark.devicePos;
if (!pos) {
if (conversionBudget <= 0) {
skippedConversion += 1;
continue;
}
conversionBudget -= 1;
const outcome = await this.positionConverter.cfiPointToXpointer({ bookFileId, cfi: bookmark.cfi! });
if (outcome.status === 'failed' || !outcome.pos0) {
skippedConversion += 1;
continue;
}
pos = outcome.pos0;
}
add.push({ serverId: bookmark.id, pos, pageno: bookmark.pageno, title: bookmark.title });
}
// Only a page-capped add set is worth another round: entries whose conversion was
// budgeted out this request are retried by the next exchange, not by a follow-up pull.
const more = candidates.length > add.length + skippedConversion;
return { add, delete: deleteEntries, more, skippedConversion };
}
private async ackBook(userId: number, deviceId: string, bookId: number, book: BookmarkAckBookDto): Promise<number> {
const bookmarks = await this.bookmarkSync.listForSync(userId, bookId, BOOKMARKS_PER_BOOK_LIMIT);
const byId = new Map(bookmarks.map((bookmark) => [bookmark.id, bookmark]));
let acked = 0;
for (const entry of book.applied) {
if (entry.status !== 'applied' || !byId.has(entry.serverId)) continue;
const key = entry.key ?? (entry.datetime && entry.pos ? buildBookmarkKey(entry.datetime, entry.pos) : null);
// Without a device identity the bookmark could never be recognised as deleted there.
if (!key) continue;
await this.bookmarkRepo.upsertLink({
bookmarkId: entry.serverId,
userId,
deviceId,
koreaderKey: key,
deviceDatetime: entry.datetime ?? null,
});
acked += 1;
}
const deletedIds = book.deleted.filter((entry) => entry.status === 'applied' && byId.has(entry.serverId)).map((entry) => entry.serverId);
acked += await this.bookmarkRepo.deleteLinks(userId, deviceId, deletedIds);
return acked;
}
/** Opportunistic: a tombstone no device still holds has nothing left to tell anyone. */
private async purgeExpiredTombstones(userId: number): Promise<void> {
const deletedBefore = new Date(Date.now() - TOMBSTONE_RETENTION_DAYS * 24 * 60 * 60 * 1000);
const candidates = await this.bookmarkSync.listPurgeableTombstones(userId, deletedBefore, PURGE_CANDIDATES_PER_REQUEST);
if (candidates.length === 0) return;
const stillLinked = await this.bookmarkRepo.findBookmarkIdsWithLinks(userId, candidates);
const purgeable = candidates.filter((id) => !stillLinked.has(id));
if (purgeable.length === 0) return;
const purged = await this.bookmarkSync.purge(userId, purgeable);
if (purged > 0) {
this.logger.log(`[${EXCHANGE_EVENT}] userId=${userId} purged=${purged} - expired bookmark tombstones removed`);
}
}
/**
* A dogear has no text of its own. The device note is the user's own label; a chapter
* title matches what the web reader stores. Both are user data, so the last-resort
* page label stays a neutral, documented fallback rather than localized copy.
*/
private titleFor(change: KoreaderBookmarkDto): string {
const note = change.note?.trim();
if (note) return note.slice(0, TITLE_MAX_LENGTH);
const chapter = change.chapter?.trim();
if (chapter) return chapter.slice(0, TITLE_MAX_LENGTH);
if (change.pageno != null) return `p. ${change.pageno}`;
return 'Bookmark';
}
}
@@ -0,0 +1,72 @@
import { Inject, Injectable } from '@nestjs/common';
import { and, eq, inArray } from 'drizzle-orm';
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../db';
import * as schema from '../../db/schema';
import { koreaderBookmarkLinks, type KoreaderBookmarkLink } from '../../db/schema';
type Db = NodePgDatabase<typeof schema>;
export interface BookmarkLinkUpsert {
bookmarkId: number;
userId: number;
deviceId: string;
koreaderKey: string;
deviceDatetime: string | null;
}
/** Owns koreader_bookmark_links: which device holds which bookmark, under which local key. */
@Injectable()
export class KoreaderBookmarkRepository {
constructor(@Inject(DB) private readonly db: Db) {}
async findLinksForBookmarks(userId: number, deviceId: string, bookmarkIds: number[]): Promise<KoreaderBookmarkLink[]> {
if (bookmarkIds.length === 0) return [];
return this.db
.select()
.from(koreaderBookmarkLinks)
.where(
and(
eq(koreaderBookmarkLinks.userId, userId),
eq(koreaderBookmarkLinks.deviceId, deviceId),
inArray(koreaderBookmarkLinks.bookmarkId, bookmarkIds),
),
);
}
/** Every device's links for these bookmarks; used to decide whether a tombstone can be purged. */
async findBookmarkIdsWithLinks(userId: number, bookmarkIds: number[]): Promise<Set<number>> {
if (bookmarkIds.length === 0) return new Set();
const rows = await this.db
.select({ bookmarkId: koreaderBookmarkLinks.bookmarkId })
.from(koreaderBookmarkLinks)
.where(and(eq(koreaderBookmarkLinks.userId, userId), inArray(koreaderBookmarkLinks.bookmarkId, bookmarkIds)));
return new Set(rows.map((row) => row.bookmarkId));
}
async upsertLink(link: BookmarkLinkUpsert): Promise<void> {
await this.db
.insert(koreaderBookmarkLinks)
.values(link)
.onConflictDoUpdate({
target: [koreaderBookmarkLinks.bookmarkId, koreaderBookmarkLinks.deviceId],
set: { koreaderKey: link.koreaderKey, deviceDatetime: link.deviceDatetime, appliedAt: new Date() },
});
}
async deleteLinks(userId: number, deviceId: string, bookmarkIds: number[]): Promise<number> {
if (bookmarkIds.length === 0) return 0;
const rows = await this.db
.delete(koreaderBookmarkLinks)
.where(
and(
eq(koreaderBookmarkLinks.userId, userId),
eq(koreaderBookmarkLinks.deviceId, deviceId),
inArray(koreaderBookmarkLinks.bookmarkId, bookmarkIds),
),
)
.returning({ id: koreaderBookmarkLinks.id });
return rows.length;
}
}
@@ -163,6 +163,7 @@ describe('KoreaderPackageService', () => {
const result = await service.getVersionInfo();
expect(result.capabilities).toContain('catalogBulkManifest');
expect(result.capabilities).toContain('bookmarkSync');
});
it('returns pluginVersion=unknown when the plugin source dir does not exist', async () => {
@@ -17,7 +17,7 @@ const PROVISION_FILE = 'bookorbit_provision.lua';
// Wire features this server advertises. The plugin selects a new route only
// when its name appears here, so a downgraded server transparently returns the
// plugin to its legacy path.
const SERVER_CAPABILITIES: readonly KoreaderPluginCapability[] = ['catalogBulkManifest', 'catalogDashboardSections'];
const SERVER_CAPABILITIES: readonly KoreaderPluginCapability[] = ['catalogBulkManifest', 'catalogDashboardSections', 'bookmarkSync'];
@Injectable()
export class KoreaderPackageService {
@@ -51,7 +51,7 @@ describe('KOReader plugin update source wiring', () => {
expect(topLevelActionBlock).toContain('self:onBookOrbitToggleAutoSync(nil, true)');
expect(dashboardSettingsBlock).toContain('return T(_("Open dashboard on startup (%1)"), self:catalogAutoOpenLabel())');
expect(syncSettingsBlock).not.toContain('text = _("Auto sync current book")');
expect(syncSettingsBlock).toContain('text = _("Two-way highlight sync")');
expect(syncSettingsBlock).toContain('text = _("Two-way highlights & bookmarks")');
expect(syncSettingsBlock).toContain('text = _("Skip auto-sync when offline")');
expect(syncSettingsBlock).not.toContain('return T(_("Open dashboard on startup (%1)"), self:catalogAutoOpenLabel())');
expect(syncSettingsBlock).toContain('return T(_("Periodically sync every # pages (%1)")');
@@ -22,6 +22,10 @@ function makeController() {
exchange: vi.fn().mockResolvedValue({ books: [] }),
exchangeAck: vi.fn().mockResolvedValue({ ok: true }),
};
const bookmarkExchangeService = {
exchange: vi.fn().mockResolvedValue({ results: [], unmatched: [] }),
exchangeAck: vi.fn().mockResolvedValue({ results: [], unmatched: [] }),
};
const packageService = {
getVersionInfo: vi.fn().mockResolvedValue({ pluginVersion: '0.4.0', serverVersion: '1.0.0' }),
buildPluginPackage: vi.fn().mockResolvedValue(Buffer.from('fake-zip-content')),
@@ -34,10 +38,12 @@ function makeController() {
statsService as never,
annotationService as never,
annotationExchangeService as never,
bookmarkExchangeService as never,
packageService as never,
),
pluginService,
statsService,
bookmarkExchangeService,
annotationService,
annotationExchangeService,
packageService,
@@ -100,6 +106,26 @@ describe('KoreaderPluginController', () => {
expect(annotationExchangeService.exchangeAck).toHaveBeenCalledWith(user, dto);
});
it('exchangeBookmarks delegates to bookmark exchange service', async () => {
const { controller, bookmarkExchangeService } = makeController();
const user = { id: 7 } as never;
const dto = {} as never;
await controller.exchangeBookmarks(user, dto);
expect(bookmarkExchangeService.exchange).toHaveBeenCalledWith(user, dto);
});
it('exchangeBookmarksAck delegates to bookmark exchange service', async () => {
const { controller, bookmarkExchangeService } = makeController();
const user = { id: 7 } as never;
const dto = {} as never;
await controller.exchangeBookmarksAck(user, dto);
expect(bookmarkExchangeService.exchangeAck).toHaveBeenCalledWith(user, dto);
});
it('uploadBookStates delegates to plugin service', async () => {
const { controller, pluginService } = makeController();
const user = { id: 7 } as never;
@@ -6,6 +6,7 @@ import { Public } from '../../common/decorators/public.decorator';
import type { RequestUser } from '../../common/types/request-user';
import { KoreaderAuthGuard } from './koreader-auth.guard';
import { KoreaderAnnotationExchangeService } from './koreader-annotation-exchange.service';
import { KoreaderBookmarkExchangeService } from './koreader-bookmark-exchange.service';
import { KoreaderPackageService } from './koreader-package.service';
import { KoreaderPluginAnnotationService } from './koreader-plugin-annotation.service';
import { KoreaderPluginService } from './koreader-plugin.service';
@@ -15,6 +16,8 @@ import {
AnnotationExchangeDto,
AnnotationsUploadDto,
BookStatesUploadDto,
BookmarkExchangeAckDto,
BookmarkExchangeDto,
BulkProgressDto,
MatchCheckDto,
PageStatsUploadDto,
@@ -30,6 +33,7 @@ export class KoreaderPluginController {
private readonly statsService: KoreaderStatsService,
private readonly annotationService: KoreaderPluginAnnotationService,
private readonly annotationExchangeService: KoreaderAnnotationExchangeService,
private readonly bookmarkExchangeService: KoreaderBookmarkExchangeService,
private readonly packageService: KoreaderPackageService,
) {}
@@ -59,6 +63,16 @@ export class KoreaderPluginController {
return this.annotationExchangeService.exchangeAck(user, dto);
}
@Post('bookmarks/exchange')
exchangeBookmarks(@CurrentUser() user: RequestUser, @Body() dto: BookmarkExchangeDto) {
return this.bookmarkExchangeService.exchange(user, dto);
}
@Post('bookmarks/exchange-ack')
exchangeBookmarksAck(@CurrentUser() user: RequestUser, @Body() dto: BookmarkExchangeAckDto) {
return this.bookmarkExchangeService.exchangeAck(user, dto);
}
@Post('book-states')
uploadBookStates(@CurrentUser() user: RequestUser, @Body() dto: BookStatesUploadDto) {
return this.pluginService.uploadBookStates(user, dto);
@@ -1,6 +1,8 @@
import { describe, expect, it } from 'vitest';
import { KoreaderAnnotationExchangeService } from './koreader-annotation-exchange.service';
import { KoreaderBookmarkExchangeService } from './koreader-bookmark-exchange.service';
import { KoreaderBookmarkRepository } from './koreader-bookmark.repository';
import { KoreaderAuthGuard } from './koreader-auth.guard';
import { KoreaderCatalogController } from './koreader-catalog.controller';
import { KoreaderCatalogService } from './koreader-catalog.service';
@@ -34,6 +36,8 @@ describe('KoreaderModule', () => {
KoreaderPluginRepository,
KoreaderPluginAnnotationService,
KoreaderAnnotationExchangeService,
KoreaderBookmarkExchangeService,
KoreaderBookmarkRepository,
KoreaderStatsService,
]);
expect(Reflect.getMetadata('exports', KoreaderModule)).toEqual([KoreaderService, KoreaderRepository]);
@@ -4,6 +4,7 @@ import { CommonModule } from '../../common/common.module';
import { AchievementModule } from '../achievement/achievement.module';
import { AppSettingsModule } from '../app-settings/app-settings.module';
import { AnnotationModule } from '../annotation/annotation.module';
import { BookmarkModule } from '../bookmark/bookmark.module';
import { BookModule } from '../book/book.module';
import { DashboardModule } from '../dashboard/dashboard.module';
import { OpdsModule } from '../opds/opds.module';
@@ -14,6 +15,8 @@ import { UserBookNoteModule } from '../user-book-note/user-book-note.module';
import { UserBookStatusModule } from '../user-book-status/user-book-status.module';
import { KoreaderAnnotationExchangeService } from './koreader-annotation-exchange.service';
import { KoreaderAuthGuard } from './koreader-auth.guard';
import { KoreaderBookmarkExchangeService } from './koreader-bookmark-exchange.service';
import { KoreaderBookmarkRepository } from './koreader-bookmark.repository';
import { KoreaderCatalogController } from './koreader-catalog.controller';
import { KoreaderCatalogService } from './koreader-catalog.service';
import { KoreaderPackageService } from './koreader-package.service';
@@ -39,6 +42,7 @@ import { KoreaderStatsService } from './koreader-stats.service';
AppSettingsModule,
AnnotationModule,
BookModule,
BookmarkModule,
DashboardModule,
OpdsModule,
PositionConverterModule,
@@ -58,6 +62,8 @@ import { KoreaderStatsService } from './koreader-stats.service';
KoreaderPluginRepository,
KoreaderPluginAnnotationService,
KoreaderAnnotationExchangeService,
KoreaderBookmarkExchangeService,
KoreaderBookmarkRepository,
KoreaderStatsService,
],
exports: [KoreaderService, KoreaderRepository],
@@ -6,8 +6,10 @@ import {
CfiToXPointerResult,
ConversionResult,
CONVERTER_VERSION,
cfiPointToCollapsedCp,
cfiRangeToXPointer,
collapsedPointToCfi,
collapsedPointToXPointer,
xpointerPointToCollapsed,
xpointerRangeToCfi,
} from './position-converter.core';
@@ -78,6 +80,26 @@ export class PositionConverterService {
return { status: 'exact', cfi, chapterIndex };
}
/**
* Converts a collapsed point CFI to a single xpointer. Dogears have no highlighted
* text to repair against, so this is structural only: it resolves or it fails.
*/
async cfiPointToXpointer(params: { bookFileId: number; cfi: string }): Promise<CfiToXPointerOutcome> {
const parsed = parseCfi(params.cfi);
if (!parsed) return { status: 'failed', reason: 'unparsable_cfi' };
const chapterIndex = chapterIndexFromSpineStep(parsed.spineStep);
if (chapterIndex == null) return { status: 'failed', reason: 'missing_spine_step' };
const doc = await this.epubDom.getChapter(params.bookFileId, chapterIndex);
if (!doc) return { status: 'failed', reason: 'chapter_unavailable', chapterIndex };
const cp = cfiPointToCollapsedCp(doc, params.cfi);
if (cp == null) return { status: 'failed', reason: 'unresolvable_structure', chapterIndex };
const pos = collapsedPointToXPointer(doc, chapterIndex, cp);
if (!pos) return { status: 'failed', reason: 'xpointer_generation_failed', chapterIndex };
return { status: 'exact', pos0: pos, chapterIndex };
}
async cfiToXpointer(params: CfiToXPointerParams): Promise<CfiToXPointerOutcome> {
const parsed = parseCfi(params.cfi);
if (!parsed) return { status: 'failed', reason: 'unparsable_cfi' };
@@ -0,0 +1,399 @@
import { randomUUID } from 'crypto';
import { eq } from 'drizzle-orm';
import * as schema from '../src/db/schema';
import { createEpubFixture } from './e2e/reader-state-isolation/reader-state-isolation-fixture-builder';
import {
authHeader,
closeReaderStateIsolationE2EContext,
createLibraryWithFolder,
createReaderStateIsolationE2EContext,
locateBookByAbsolutePath,
triggerAndWaitForLibraryScan,
type CreatedLibrary,
type LocatedBookFile,
type ReaderStateIsolationE2EContext,
} from './e2e/reader-state-isolation/reader-state-isolation-harness';
const KOREADER_USERNAME = `bookmark-device-${randomUUID().slice(0, 8)}`;
const KOREADER_PASSWORD = 'BookmarkDevicePass123';
const DEVICE_ID = 'e2e-bm-device-0001';
const OTHER_DEVICE_ID = 'e2e-bm-device-0002';
// The fixture chapter body is exactly <p>fixture</p>.
const FIXTURE_XPOINTER = '/body/DocFragment[1]/body/p/text().0';
const WEB_POINT_CFI = 'epubcfi(/6/2!/4/2/1:3)';
interface BookmarkBookPayload {
hash: string;
keys: { k: string; dt: string }[];
keysComplete: boolean;
changes: Record<string, unknown>[];
}
interface BookmarkExchangeResult {
hash: string;
bookId: number;
accepted: number;
duplicates: number;
rejected: number;
deviceDeleted: number;
toApply: {
add: { serverId: number; pos: string; pageno: number | null; title: string }[];
delete: { serverId: number; key: string; datetime: string | null }[];
};
more: boolean;
skippedConversion: number;
}
interface WebBookmark {
id: number;
cfi: string | null;
title: string;
}
describe('KOReader bookmark exchange (e2e)', { timeout: 120_000 }, () => {
let ctx!: ReaderStateIsolationE2EContext;
let library!: CreatedLibrary;
let epub!: LocatedBookFile;
let fileHash!: string;
let deviceBookmarkId!: number;
let deviceBookmarkKey!: string;
let webBookmarkId!: number;
function deviceHeaders(): Record<string, string> {
return { 'x-auth-user': KOREADER_USERNAME, 'x-auth-key': KOREADER_PASSWORD };
}
async function exchange(books: BookmarkBookPayload[], deviceId = DEVICE_ID) {
const response = await ctx.app.inject({
method: 'POST',
url: '/api/v1/koreader/plugin/bookmarks/exchange',
headers: deviceHeaders(),
payload: { deviceId, deviceModel: 'E2E', pluginVersion: '0.5.0', books },
});
expect(response.statusCode).toBe(201);
return response.json() as { results: BookmarkExchangeResult[]; unmatched: string[] };
}
async function exchangeAck(books: Record<string, unknown>[], deviceId = DEVICE_ID) {
const response = await ctx.app.inject({
method: 'POST',
url: '/api/v1/koreader/plugin/bookmarks/exchange-ack',
headers: deviceHeaders(),
payload: { deviceId, deviceModel: 'E2E', pluginVersion: '0.5.0', books },
});
expect(response.statusCode).toBe(201);
return response.json() as { results: { hash: string; acked: number }[]; unmatched: string[] };
}
function pull(deviceId = DEVICE_ID) {
return exchange([{ hash: fileHash, keys: [], keysComplete: false, changes: [] }], deviceId);
}
async function listWebBookmarks(): Promise<WebBookmark[]> {
const response = await ctx.app.inject({
method: 'GET',
url: `/api/v1/books/${epub.bookId}/bookmarks`,
headers: authHeader(ctx.adminToken),
});
expect(response.statusCode).toBe(200);
return response.json() as WebBookmark[];
}
beforeAll(async () => {
ctx = await createReaderStateIsolationE2EContext();
library = await createLibraryWithFolder(ctx, { name: `koreader-bookmark-${randomUUID()}` });
const epubPath = await createEpubFixture(library.folderPath, 'bookmark-book.epub', {
title: `Bookmark Book ${randomUUID()}`,
uid: `urn:uuid:${randomUUID()}`,
});
await triggerAndWaitForLibraryScan(ctx, library.libraryId);
epub = await locateBookByAbsolutePath(ctx, epubPath);
const [fileRow] = await ctx.db
.select({ fileHash: schema.bookFiles.fileHash })
.from(schema.bookFiles)
.where(eq(schema.bookFiles.id, epub.bookFileId));
expect(fileRow?.fileHash).toBeTruthy();
fileHash = fileRow!.fileHash!;
const credentials = await ctx.app.inject({
method: 'POST',
url: '/api/v1/koreader/credentials',
headers: authHeader(ctx.adminToken),
payload: { username: KOREADER_USERNAME, password: KOREADER_PASSWORD },
});
if (credentials.statusCode === 409) {
const updated = await ctx.app.inject({
method: 'PATCH',
url: '/api/v1/koreader/credentials',
headers: authHeader(ctx.adminToken),
payload: { username: KOREADER_USERNAME, password: KOREADER_PASSWORD },
});
expect(updated.statusCode).toBe(200);
} else {
expect([200, 201]).toContain(credentials.statusCode);
}
}, 120_000);
afterAll(async () => {
if (ctx) await closeReaderStateIsolationE2EContext(ctx);
});
it('advertises the bookmarkSync capability the plugin gates on', async () => {
const response = await ctx.app.inject({
method: 'GET',
url: '/api/v1/koreader/plugin/version',
headers: deviceHeaders(),
});
expect(response.statusCode).toBe(200);
expect((response.json() as { capabilities: string[] }).capabilities).toContain('bookmarkSync');
});
it('converts a device dogear into a web bookmark and pushes nothing back at that device', async () => {
const first = await exchange([
{
hash: fileHash,
keys: [],
keysComplete: false,
changes: [{ datetime: '2026-06-01 10:00:00', pos: FIXTURE_XPOINTER, pageno: 1, chapter: 'Chapter One' }],
},
]);
expect(first.results[0]).toMatchObject({ accepted: 1, duplicates: 0, rejected: 0 });
expect(first.results[0].toApply.add).toEqual([]);
expect(first.results[0].toApply.delete).toEqual([]);
const bookmarks = await listWebBookmarks();
expect(bookmarks).toHaveLength(1);
expect(bookmarks[0].title).toBe('Chapter One');
expect(bookmarks[0].cfi).toMatch(/^epubcfi\(/);
deviceBookmarkId = bookmarks[0].id;
const [row] = await ctx.db.select().from(schema.bookmarks).where(eq(schema.bookmarks.id, deviceBookmarkId));
expect(row.origin).toBe('koreader');
expect(row.devicePos).toBe(FIXTURE_XPOINTER);
expect(row.pageno).toBe(1);
const retry = await pull();
expect(retry.results[0]).toMatchObject({ accepted: 0, duplicates: 0 });
expect(retry.results[0].toApply.add).toEqual([]);
});
it('rejects a device position that cannot be converted instead of storing a CFI-less row', async () => {
const result = await exchange([
{
hash: fileHash,
keys: [],
keysComplete: false,
changes: [{ datetime: '2026-06-01 10:05:00', pos: '/body/DocFragment[99]/body/p/text().0' }],
},
]);
expect(result.results[0]).toMatchObject({ accepted: 0, rejected: 1 });
expect(await listWebBookmarks()).toHaveLength(1);
});
it('delivers the device bookmark to a second device at its original position, ack-gated', async () => {
const pulled = await pull(OTHER_DEVICE_ID);
const add = pulled.results[0].toApply.add.find((entry) => entry.serverId === deviceBookmarkId);
expect(add).toBeDefined();
// Device-originated: the stored xpointer is returned verbatim, not round-tripped through the CFI.
expect(add).toMatchObject({ pos: FIXTURE_XPOINTER, pageno: 1, title: 'Chapter One' });
// Idempotent before the ack.
const retry = await pull(OTHER_DEVICE_ID);
expect(retry.results[0].toApply.add.find((entry) => entry.serverId === deviceBookmarkId)).toBeDefined();
const ack = await exchangeAck(
[
{
hash: fileHash,
applied: [{ serverId: deviceBookmarkId, status: 'applied', datetime: '2026-06-02 08:00:00', pos: FIXTURE_XPOINTER }],
deleted: [],
},
],
OTHER_DEVICE_ID,
);
expect(ack.results[0].acked).toBe(1);
const after = await pull(OTHER_DEVICE_ID);
expect(after.results[0].toApply.add).toEqual([]);
});
it('applies a device-side rename without creating a second bookmark', async () => {
const renamed = await exchange([
{
hash: fileHash,
keys: [],
keysComplete: false,
changes: [{ datetime: '2026-06-01 10:00:00', pos: FIXTURE_XPOINTER, pageno: 1, chapter: 'Chapter One', note: 'Remember this' }],
},
]);
expect(renamed.results[0]).toMatchObject({ accepted: 0, duplicates: 1 });
const bookmarks = await listWebBookmarks();
expect(bookmarks).toHaveLength(1);
expect(bookmarks[0].title).toBe('Remember this');
});
it('converts a web bookmark and delivers it to the device, ack-gated', async () => {
const created = await ctx.app.inject({
method: 'POST',
url: `/api/v1/books/${epub.bookId}/bookmarks`,
headers: authHeader(ctx.adminToken),
payload: { cfi: WEB_POINT_CFI, title: 'Chapter One' },
});
expect(created.statusCode).toBe(201);
webBookmarkId = (created.json() as { id: number }).id;
const pulled = await pull();
const add = pulled.results[0].toApply.add.find((entry) => entry.serverId === webBookmarkId);
expect(add).toBeDefined();
expect(add!.title).toBe('Chapter One');
expect(add!.pos).toMatch(/^\/body\/DocFragment\[1\]/);
expect(pulled.results[0].skippedConversion).toBe(0);
const ack = await exchangeAck([
{
hash: fileHash,
applied: [{ serverId: webBookmarkId, status: 'applied', datetime: '2026-06-03 09:00:00', pos: add!.pos }],
deleted: [],
},
]);
expect(ack.results[0].acked).toBe(1);
const after = await pull();
expect(after.results[0].toApply.add.find((entry) => entry.serverId === webBookmarkId)).toBeUndefined();
});
it('turns a web delete into a tombstone and propagates it to every linked device', async () => {
const deleted = await ctx.app.inject({
method: 'DELETE',
url: `/api/v1/books/${epub.bookId}/bookmarks/${deviceBookmarkId}`,
headers: authHeader(ctx.adminToken),
});
expect(deleted.statusCode).toBe(204);
// The web no longer lists it, but the row survives so devices can be told.
expect((await listWebBookmarks()).map((bookmark) => bookmark.id)).not.toContain(deviceBookmarkId);
const [row] = await ctx.db.select().from(schema.bookmarks).where(eq(schema.bookmarks.id, deviceBookmarkId));
expect(row.deletedAt).not.toBeNull();
const pulled = await pull();
const entry = pulled.results[0].toApply.delete.find((item) => item.serverId === deviceBookmarkId);
expect(entry).toBeDefined();
// The device is addressed by the identity it acked with, its only local handle.
expect(entry!.key).toMatch(/^[0-9a-f]{32}$/);
expect(entry!.datetime).toBe('2026-06-01 10:00:00');
deviceBookmarkKey = entry!.key;
await exchangeAck([{ hash: fileHash, applied: [], deleted: [{ serverId: deviceBookmarkId, status: 'applied' }] }]);
const afterAck = await pull();
expect(afterAck.results[0].toApply.delete).toEqual([]);
// The second device still holds it and learns about the delete on its own schedule.
const other = await pull(OTHER_DEVICE_ID);
expect(other.results[0].toApply.delete.find((item) => item.serverId === deviceBookmarkId)).toBeDefined();
await exchangeAck([{ hash: fileHash, applied: [], deleted: [{ serverId: deviceBookmarkId, status: 'applied' }] }], OTHER_DEVICE_ID);
});
it('restores the tombstone when a device re-adds a bookmark at the same position', async () => {
const restored = await exchange([
{
hash: fileHash,
keys: [],
keysComplete: false,
changes: [{ datetime: '2026-06-04 12:00:00', pos: FIXTURE_XPOINTER, pageno: 1, chapter: 'Chapter One' }],
},
]);
expect(restored.results[0]).toMatchObject({ accepted: 1, rejected: 0 });
const bookmarks = await listWebBookmarks();
// The same row came back to life: the unique location index would have refused an insert.
expect(bookmarks.map((bookmark) => bookmark.id)).toContain(deviceBookmarkId);
expect(bookmarks.find((bookmark) => bookmark.id === deviceBookmarkId)!.title).toBe('Chapter One');
expect(deviceBookmarkKey).toMatch(/^[0-9a-f]{32}$/);
// The second device dropped its link when it acked the earlier delete, so the
// restored row reaches it as a fresh add. Re-link it, so the next test can
// prove a deletion on one device still propagates to the other.
const other = await pull(OTHER_DEVICE_ID);
const readds = other.results[0].toApply.add;
expect(readds.map((entry) => entry.serverId)).toEqual(expect.arrayContaining([deviceBookmarkId, webBookmarkId]));
await exchangeAck(
[
{
hash: fileHash,
applied: readds.map((entry, index) => ({
serverId: entry.serverId,
status: 'applied',
datetime: `2026-06-05 09:0${index}:00`,
pos: entry.pos,
})),
deleted: [],
},
],
OTHER_DEVICE_ID,
);
});
it('skips deletion detection when the device could not report a complete key set', async () => {
const result = await exchange([{ hash: fileHash, keys: [], keysComplete: false, changes: [] }]);
expect(result.results[0].deviceDeleted).toBe(0);
expect(await listWebBookmarks()).toHaveLength(2);
});
it('tombstones bookmarks the device no longer reports once the key set is complete', async () => {
const result = await exchange([{ hash: fileHash, keys: [], keysComplete: true, changes: [] }]);
// Both this device's links are gone from its key set, so both bookmarks are deleted.
expect(result.results[0].deviceDeleted).toBe(2);
expect(await listWebBookmarks()).toHaveLength(0);
// A deletion on one device reaches every other device that still holds the row.
const other = await pull(OTHER_DEVICE_ID);
expect(other.results[0].toApply.delete.map((entry) => entry.serverId)).toEqual(expect.arrayContaining([deviceBookmarkId, webBookmarkId]));
expect(other.results[0].toApply.add).toEqual([]);
});
it('reports a hash it cannot resolve as unmatched', async () => {
const unknownHash = 'f'.repeat(32);
const result = await exchange([{ hash: unknownHash, keys: [], keysComplete: true, changes: [] }]);
expect(result.unmatched).toEqual([unknownHash]);
expect(result.results).toEqual([]);
});
it('rejects a malformed payload at the validation boundary', async () => {
const response = await ctx.app.inject({
method: 'POST',
url: '/api/v1/koreader/plugin/bookmarks/exchange',
headers: deviceHeaders(),
payload: {
deviceId: DEVICE_ID,
deviceModel: 'E2E',
pluginVersion: '0.5.0',
books: [{ hash: fileHash, keys: [], keysComplete: true, changes: [{ datetime: 'not-a-datetime', pos: FIXTURE_XPOINTER }] }],
},
});
expect(response.statusCode).toBe(400);
});
it('refuses an unauthenticated device', async () => {
const response = await ctx.app.inject({
method: 'POST',
url: '/api/v1/koreader/plugin/bookmarks/exchange',
payload: {
deviceId: DEVICE_ID,
deviceModel: 'E2E',
pluginVersion: '0.5.0',
books: [{ hash: fileHash, keys: [], keysComplete: false, changes: [] }],
},
});
expect(response.statusCode).toBe(401);
});
});