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
@@ -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