fix(koreader): keep manual links to a different copy matched and suggest links

Report a linked copy whose bytes differ from the library file as positionIncompatible instead of
unmatched, so the plugin keeps the match, skips highlights without a failure and still syncs progress.
Treat a position-less place within a page as the same place and jump to the nearest page.
Suggest library books with the same title and authors for books to review, with bulk linking.
See docs/KOREADER_HASH_MATCHING.md.

Closes #1872
Closes #1919
This commit is contained in:
neon
2026-10-26 15:06:04 +00:00
parent 834da8c954
commit 1c27e5dd05
36 changed files with 1634 additions and 63 deletions
@@ -717,6 +717,12 @@ local function hasPending(to_apply)
return to_apply and (#(to_apply.add or {}) > 0 or #(to_apply.edit or {}) > 0 or #(to_apply.delete or {}) > 0)
end
-- The server matches a copy whose bytes differ from the library file but cannot
-- place its highlights, so nothing is exchanged and nothing failed.
local function incompatibleResult()
return { uploaded = 0, applied = 0, deleted = 0, failed = 0, unverified = 0, had_errors = false, incompatible = 1 }
end
--[[--
Full annotation exchange for one matched book. Blocking; callers run it from a
step machine or a scheduled task.
@@ -735,11 +741,13 @@ opts:
- 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.
nil, err for auth/network level failures. A position-incompatible copy returns
a result with `incompatible = 1` and exchanges nothing.
]]
function BookOrbitAnnotations.exchangeBook(opts)
local book = opts.state:getBook(opts.digest)
if not book then return nil, "unmatched" end
if book.positionIncompatible then return incompatibleResult() end
local keys = BookOrbitAnnotations.collectKeys(opts.key_annotations or opts.annotations)
local keys_complete = opts.keys_complete ~= false and #keys <= MAX_KEYS_PER_BOOK
@@ -806,6 +814,12 @@ function BookOrbitAnnotations.exchangeBook(opts)
return nil, "unmatched"
end
end
for _, hash in ipairs(body.positionIncompatible or {}) do
if hash == opts.digest then
opts.state:markPositionIncompatible(opts.digest)
return incompatibleResult()
end
end
response = body.results and body.results[1] or nil
result.uploaded = result.uploaded + #chunk
first_request = false
@@ -68,6 +68,7 @@ function BookSheet.data(plugin)
author = props.authors or "",
digest = digest,
matched = book ~= nil,
incompatible = book ~= nil and book.positionIncompatible == true,
book_id = book and book.bookId or nil,
synced = mark and MenuUI.ago(mark) or nil,
local_pct = local_pct,
@@ -171,7 +172,8 @@ local function promptBody(page, w)
table.insert(items, space(nil, px(10)))
table.insert(items, hstack({ w = w, gap = px(8), valign = "top" }, {
icon("info", px(16), { sw = 2.25 }),
text(_("Highlights, bookmarks and reading time sync either way."), { s = 13, col = INK.g3, w = w - px(24), lines = 2 }),
text(d.incompatible and _("Reading time syncs either way. Highlights and bookmarks don't sync for this copy.")
or _("Highlights, bookmarks and reading time sync either way."), { s = 13, col = INK.g3, w = w - px(24), lines = 2 }),
}))
end
table.insert(items, space(nil, px(8)))
@@ -224,6 +226,17 @@ local function body(page, w)
})
end
-- A matched copy whose bytes differ from the library file syncs progress as a
-- percentage and nothing that needs an exact position.
local copy_note
if d.incompatible then
copy_note = hstack({ w = w, gap = px(6), valign = "top" }, {
icon("info", px(16), { sw = 2 }),
text(_("This copy is a different file from the one in BookOrbit. Progress syncs as a percentage. Highlights and bookmarks don't sync. Download the book from BookOrbit to sync everything."),
{ s = 13, col = INK.g3, w = w - px(16) - px(6), lines = 4 }),
})
end
local rows = {
MenuUI.row(w, { icon = "refresh-cw", label = _("Auto-sync"), sub = _("For every book"), toggle = d.auto, action = { kind = "auto" } }),
}
@@ -249,6 +262,8 @@ local function body(page, w)
buttons,
outcome and space(nil, px(10)) or false,
outcome or false,
copy_note and space(nil, px(10)) or false,
copy_note or false,
space(nil, px(14)),
vstack({ w = w }, list),
}
@@ -340,7 +340,7 @@ stepMatch = function(ctx)
for _, match in ipairs(body.matches or {}) do
if match.hash == ctx.snap.digest then
ctx.state:setMatched(match.hash, match.bookFileId, match.bookId, ctx.snap.file)
ctx.state:setMatched(match.hash, match.bookFileId, match.bookId, ctx.snap.file, nil, match.positionCompatible)
bindStatsRow(ctx, ctx.state:getBook(ctx.snap.digest))
if not acknowledge(ctx, "match") then return end
return step(ctx, stepStats)
@@ -561,7 +561,8 @@ stepAnnotations = function(ctx)
closed_book = apply_mode == "sidecar",
})
if not result.had_errors then
ctx.sidecar_ann_done = true
-- An incompatible copy exchanged nothing, so its sidecar is not marked as known.
if not result.incompatible then ctx.sidecar_ann_done = true end
book.annCount = ctx.snap.ann_count
-- Only park remote changes when the user actually wants them applied;
-- with two-way highlights off there is nothing to come back for.
@@ -716,7 +717,11 @@ stepBookmarks = function(ctx)
return step(ctx, stepState)
end
if result.had_errors then ctx.had_errors = true else ctx.sidecar_bm_done = true end
if result.had_errors then
ctx.had_errors = true
elseif not result.incompatible then
ctx.sidecar_bm_done = 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
@@ -283,6 +283,12 @@ local function hasPending(to_apply)
return to_apply and (#(to_apply.add or {}) > 0 or #(to_apply.delete or {}) > 0)
end
-- The server matches a copy whose bytes differ from the library file but cannot
-- place its bookmarks, so nothing is exchanged and nothing failed.
local function incompatibleResult()
return { uploaded = 0, applied = 0, deleted = 0, failed = 0, had_errors = false, incompatible = 1 }
end
--[[--
Full bookmark exchange for one matched book. Blocking; callers run it from a
step machine or a scheduled task.
@@ -303,6 +309,7 @@ nil, err for auth/network level failures.
function BookOrbitBookmarks.exchangeBook(opts)
local book = opts.state:getBook(opts.digest)
if not book then return nil, "unmatched" end
if book.positionIncompatible then return incompatibleResult() end
local bookmarks = opts.bookmarks or {}
local keys = BookOrbitBookmarks.collectKeys(opts.key_bookmarks or bookmarks)
@@ -350,6 +357,12 @@ function BookOrbitBookmarks.exchangeBook(opts)
return nil, "unmatched"
end
end
for _, hash in ipairs(body.positionIncompatible or {}) do
if hash == opts.digest then
opts.state:markPositionIncompatible(opts.digest)
return incompatibleResult()
end
end
response = body.results and body.results[1] or nil
result.uploaded = result.uploaded + #chunk
first_request = false
@@ -18,6 +18,9 @@ function HighlightDiagnostics.lastSyncText(summary, format_time)
local text = T(_("%1 uploaded, %2 applied, %3 deleted, %4 failed, %5 skipped"),
count(summary.uploaded), count(summary.applied), count(summary.deleted),
count(summary.failed), count(summary.skipped))
if count(summary.incompatible) > 0 then
text = T(_("%1, %2 not synced (different copy)"), text, count(summary.incompatible))
end
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)
@@ -60,6 +63,9 @@ function HighlightDiagnostics.openHighlightsText(ctx)
local pending = ctx.scheduler_status
local matched = ctx.matched == true
if matched and ctx.position_incompatible == true then
return _("not synced: this copy differs from BookOrbit's file")
end
if type(pending) == "table" then
if matched then
return _("matched, sync scheduled")
@@ -17,6 +17,9 @@ local COUNT_FIELDS = {
"failed",
"skipped",
"touched_books",
-- Books whose device copy differs from the library file: matched, but
-- highlights and bookmarks cannot be placed in it, so none were exchanged.
"incompatible",
}
-- Bookmarks are counted separately from highlights: they are a different user
@@ -142,6 +145,9 @@ function HighlightSummary.message(summary)
if summary.touched_books > 0 then
text = text .. "\n" .. T(_("%1 closed book(s) updated."), summary.touched_books)
end
if summary.incompatible > 0 then
text = text .. "\n" .. T(_("%1 book(s) skipped: the copy on this device is a different file from the one in BookOrbit."), summary.incompatible)
end
return text
end
@@ -153,6 +153,7 @@ function MainMenu:diagnosticsSections()
annotation_sync = self.settings.annotation_sync,
has_open_book = has_open_book,
matched = self.isOpenBookMatched and self:isOpenBookMatched(digest) or false,
position_incompatible = self.isOpenBookPositionIncompatible and self:isOpenBookPositionIncompatible(digest) or false,
scheduler_status = scheduler,
last_highlight_sync = self.settings.last_highlight_sync,
} },
@@ -113,9 +113,19 @@ function ProgressSync:syncToProgress(progress, percentage_fallback)
logger.dbg("BookOrbit: syncing to progress", progress, "percent", percentage_fallback)
local has_position = progress ~= nil and progress ~= ""
if not has_position and percentage_fallback then
-- No exact position (push came from web/audio sync); jump by percentage.
-- kosync percentage is 0-1; GotoPercent expects 0-100.
self.ui:handleEvent(Event:new("GotoPercent", percentage_fallback * 100))
-- No exact position (web or audio sync, or a different copy of the
-- book); jump by percentage. In page mode KOReader reports a place as
-- page / pages but GotoPercent floors percent * pages, which lands one
-- page short of the page another device sent, so go to the nearest page.
local rolling = self.ui.rolling
local pages = self.ui.document.info and self.ui.document.info.number_of_pages
if rolling and rolling.view and rolling.view.view_mode == "page" and type(pages) == "number" and pages > 0 then
local page = math.max(1, math.min(pages, math.floor(percentage_fallback * pages + 0.5)))
self.ui:handleEvent(Event:new("GotoPage", page))
else
-- kosync percentage is 0-1; GotoPercent expects 0-100.
self.ui:handleEvent(Event:new("GotoPercent", percentage_fallback * 100))
end
if self.book_sheet then self.book_sheet:refreshProgress() end
return
end
@@ -127,6 +137,23 @@ function ProgressSync:syncToProgress(progress, percentage_fallback)
if self.book_sheet then self.book_sheet:refreshProgress() end
end
-- A place BookOrbit holds without a position (a different copy of the book, or
-- a web or audio place it could not convert) is reachable only by percentage,
-- which lands on a page boundary. Within one page of it is the same place; an
-- exact comparison would offer a jump to where the reader already is on every
-- sync.
function ProgressSync:isSamePlace(body, local_percentage, local_progress)
if local_percentage == body.percentage or body.progress == local_progress then return true end
if body.progress ~= nil and body.progress ~= "" then return false end
local doc = self.ui and self.ui.document
local pages = doc and doc.getPageCount and doc:getPageCount()
if type(pages) ~= "number" or pages <= 0 or type(local_percentage) ~= "number" or type(body.percentage) ~= "number" then
return false
end
-- Plus the 4-decimal rounding both percentages go through on the way.
return math.abs(body.percentage - local_percentage) <= 1 / pages + 0.0001
end
function ProgressSync:remoteProgressIsNewer(body, local_percentage)
local local_timestamp = self.last_page_turn_timestamp or 0
if body.timestamp ~= nil then
@@ -206,7 +233,7 @@ function ProgressSync:reconcileProgressBeforeBookSync(digest, on_done)
local local_progress = self:getLastProgress()
local local_percentage = self:getLastPercent()
if local_percentage == body.percentage or body.progress == local_progress then
if self:isSamePlace(body, local_percentage, local_progress) then
self:noteProgressCompared(digest)
on_done(false)
return true
@@ -416,7 +443,7 @@ function ProgressSync:getProgress(ensure_networking, interactive)
local progress = self:getLastProgress()
local percentage = self:getLastPercent()
if percentage == body.percentage or body.progress == progress then
if self:isSamePlace(body, percentage, progress) then
self:noteProgressCompared(digest)
if interactive then
Toast.show(_("Already in sync"))
@@ -140,8 +140,10 @@ end
-- folder merge re-parents a file to another book without changing its id. The
-- token the match was verified against is stored alongside the time so one
-- library change does not force a request on every later book sync until the
-- user runs a full sweep.
function BookOrbitState:setMatched(md5, book_file_id, book_id, file, verified_version)
-- user runs a full sweep. `position_compatible` is the match's positionCompatible
-- field: false marks a copy whose bytes differ from the library file, which the
-- server matches but cannot place highlights or exact positions in.
function BookOrbitState:setMatched(md5, book_file_id, book_id, file, verified_version, position_compatible)
local previous = self.books[md5]
local identity_changed = previous ~= nil
and (previous.fileId ~= book_file_id or previous.bookId ~= book_id)
@@ -154,6 +156,7 @@ function BookOrbitState:setMatched(md5, book_file_id, book_id, file, verified_ve
book.annCount = book.annCount or 0
book.matchVerifiedAt = os.time()
book.matchVerifiedVersion = verified_version or (self.global and self.global.libraryVersion) or nil
book.positionIncompatible = position_compatible == false or nil
self.books[md5] = book
self.unmatched[md5] = nil
end
@@ -201,6 +204,14 @@ function BookOrbitState.applyLibraryVersion(state, version, now)
return changed
end
-- A highlight or bookmark exchange that learns the copy is position
-- incompatible records it here, so later exchanges skip the book until the next
-- match check says otherwise.
function BookOrbitState:markPositionIncompatible(md5)
local book = self.books[md5]
if book then book.positionIncompatible = true end
end
function BookOrbitState:setUnmatched(md5)
self.books[md5] = nil
self.unmatched[md5] = os.time()
@@ -690,7 +690,7 @@ local function stepMatchNext(ctx)
-- stored, so these books stay on the fast path after the sweep records
-- a changed library version.
ctx.state:setMatched(match.hash, match.bookFileId, match.bookId,
cand and cand.file or nil, body.libraryVersion)
cand and cand.file or nil, body.libraryVersion, match.positionCompatible)
end
for _, md5 in ipairs(batch) do
if not matched[md5] then
@@ -1063,7 +1063,8 @@ local function stepAnnotationsNext(ctx)
local bookmarks_done = exchangeBookmarksForEntry(ctx, entry, apply_mode, pending)
if aborted(ctx) then return end
if bookmarks_done and not result.had_errors and apply_mode == "sidecar" and entry.sidecar_marked == false
if bookmarks_done and not result.had_errors and not result.incompatible
and apply_mode == "sidecar" and entry.sidecar_marked == false
and BookOrbitSidecar.markFile(entry.file) and pending then
-- The marker is this sweep's own write; without this the next sweep would
-- see a changed sidecar and exchange the book again for nothing.
@@ -1283,7 +1284,11 @@ local function stepDone(ctx)
if ann_done and state_done and progress_done then
book.sidecarMtime = pending.mtime
book.annCount = pending.ann_count
BookOrbitAnnotations.advanceWatermark(book, pending.ann_max_datetime, device_now)
-- A copy the server cannot place highlights in sent none, so its
-- watermark stays behind them for a later compatible match.
if not book.positionIncompatible then
BookOrbitAnnotations.advanceWatermark(book, pending.ann_max_datetime, device_now)
end
if state_done and pending.need_state then
if not pending.state_used_server then
book.statusSyncedModified = pending.status_modified or book.statusSyncedModified
+8 -3
View File
@@ -585,6 +585,11 @@ function BookOrbit:isOpenBookMatched(digest)
return BookOrbitStateManager.getBook(digest) ~= nil
end
function BookOrbit:isOpenBookPositionIncompatible(digest)
local book = digest and BookOrbitStateManager.getBook(digest) or nil
return book ~= nil and book.positionIncompatible == true
end
function BookOrbit:recordOpenAnnotationUnmatched(reason)
self:recordHighlightSync({
event = "open_book",
@@ -1072,7 +1077,7 @@ function BookOrbit:matchOpenBookForAutoSync(on_done)
local matched = false
for _, match in ipairs(body.matches or {}) do
if match.hash == digest then
state:setMatched(match.hash, match.bookFileId, match.bookId, self.ui.document.file)
state:setMatched(match.hash, match.bookFileId, match.bookId, self.ui.document.file, nil, match.positionCompatible)
matched = true
break
end
@@ -1143,10 +1148,10 @@ function BookOrbit:exchangeAnnotationsForOpenBook(reason, retry_count)
if bm_ok and bm_err == "unsupported_server" then
BookOrbitBookmarks.markUnsupported(client)
else
bm_done = bm_ok and bm_result ~= nil and not bm_result.had_errors
bm_done = bm_ok and bm_result ~= nil and not bm_result.had_errors and not bm_result.incompatible
end
end
if ok and result and not result.had_errors and bm_done then
if ok and result and not result.had_errors and not result.incompatible and bm_done then
BookOrbitSidecar.markLive(self.ui.doc_settings)
end
state:flush()
@@ -0,0 +1,200 @@
-- A matched copy whose bytes differ from the library file (a manual link to a
-- calibre-sent copy, say) keeps its match: highlight and bookmark exchanges skip
-- it without counting a failure, and the server's answer for it never reads as
-- "not in your library".
package.path = "koreader-plugin/bookorbit.koplugin/?.lua;koreader-plugin/spec/?.lua;" .. package.path
package.loaded["docsettings"] = {
open = function()
return {
readSetting = function() return nil end,
saveSetting = function() end,
makeTrue = function() end,
flush = function() end,
}
end,
hasSidecarFile = function() return false end,
findSidecarFile = function() return nil end,
}
package.loaded["datastorage"] = { getSettingsDir = function() return "/tmp" end }
package.loaded["luasettings"] = { open = function() return { readSetting = function(_, _, default) return default end } end }
package.loaded["dump"] = function() return "" end
package.loaded["ui/event"] = { new = function(_, name, payload) return { name = name, payload = payload } end }
package.loaded["ui/uimanager"] = { setDirty = function() end }
package.loaded["ui/widget/booklist"] = { setBookInfoCacheProperty = function() end }
package.loaded["logger"] = { dbg = function() end }
package.loaded["ffi/sha2"] = { md5 = function(value) return value end }
package.loaded["ffi/util"] = {
template = function(text, ...)
local values = { ... }
return (text:gsub("%%(%d+)", function(index) return tostring(values[tonumber(index)]) end))
end,
}
package.loaded["gettext"] = function(value) return value end
package.loaded["util"] = { trim = function(value) return tostring(value or ""):match("^%s*(.-)%s*$") end }
package.loaded["libs/libkoreader-lfs"] = { attributes = function() return nil end }
package.loaded["bookorbit_capabilities"] = {
supports = function() return true end,
markUnsupported = function() end,
}
local BookOrbitState = require("bookorbit_state")
local BookOrbitAnnotations = require("bookorbit_annotations")
local BookOrbitBookmarks = require("bookorbit_bookmarks")
local HighlightSummary = require("bookorbit_highlight_summary")
local Diagnostics = require("bookorbit_highlight_diagnostics")
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 newState()
return BookOrbitState.snapshot({ books = {}, unmatched = {}, files = {}, statsRows = {}, global = {} })
end
-- The match records the flag, a later compatible match clears it, and a new
-- server identity starts from a clean record.
local state = newState()
state:setMatched("digest", 10, 20, "/books/copy.epub", "v1", false)
assertEqual(state:getBook("digest").positionIncompatible, true, "an incompatible match is flagged")
state:setMatched("digest", 10, 20, "/books/copy.epub", "v1")
assertEqual(state:getBook("digest").positionIncompatible, nil, "a match without the field (older server, compatible copy) clears it")
state:setMatched("digest", 10, 20, "/books/copy.epub", "v1", false)
state:setMatched("digest", 11, 21, "/books/copy.epub", "v1", true)
assertEqual(state:getBook("digest").positionIncompatible, nil, "a different book or file is a fresh record")
state:markPositionIncompatible("digest")
assertEqual(state:getBook("digest").positionIncompatible, true, "an exchange can record what it learned")
state:markPositionIncompatible("unknown")
assertEqual(state:getBook("unknown"), nil, "marking an unmatched digest does not invent a match")
local function highlight(datetime, pos0)
return { drawer = "lighten", datetime = datetime, pos0 = pos0, pos1 = pos0 .. ".9", text = "text" }
end
-- A flagged book never reaches the server and reports nothing as failed.
local flagged = newState()
flagged:setMatched("digest", 10, 20, "/books/copy.epub", "v1", false)
local requests = 0
local silent_client = {
exchangeAnnotations = function() requests = requests + 1 end,
exchangeAck = function() requests = requests + 1 end,
exchangeBookmarks = function() requests = requests + 1 end,
exchangeBookmarksAck = function() requests = requests + 1 end,
}
local ann = BookOrbitAnnotations.exchangeBook({
client = silent_client,
state = flagged,
digest = "digest",
annotations = { highlight("2026-10-26 08:00:00", "/body/p[1]") },
ann_max_datetime = "2026-10-26 08:00:00",
ann_signature = "sig",
apply_mode = "sidecar",
file = "/books/copy.epub",
})
assertEqual(ann.incompatible, 1, "the highlight exchange reports the skip")
assertEqual(ann.had_errors, false, "a skip is not a failure")
assertEqual(ann.uploaded, 0, "nothing is claimed as uploaded")
local bm = BookOrbitBookmarks.exchangeBook({
client = silent_client,
state = flagged,
digest = "digest",
bookmarks = { { datetime = "2026-10-26 08:00:00", pos = "/body/p[2]", pageno = 2 } },
bm_signature = "sig",
apply_mode = "sidecar",
file = "/books/copy.epub",
})
assertEqual(bm.incompatible, 1, "the bookmark exchange reports the skip")
assertEqual(bm.had_errors, false, "a bookmark skip is not a failure")
assertEqual(requests, 0, "a flagged book sends no request")
assertEqual(flagged:getBook("digest").annWatermark, "", "the highlight watermark stays put for a later compatible exchange")
-- A book the local state still thinks is compatible learns it from the reply,
-- keeps its match and is not counted as failed.
local learning = newState()
learning:setMatched("digest", 10, 20, "/books/copy.epub", "v1")
local ann_reply = BookOrbitAnnotations.exchangeBook({
client = {
exchangeAnnotations = function() return { results = {}, unmatched = {}, positionIncompatible = { "digest" } } end,
exchangeAck = function() error("no ack for a skipped book") end,
},
state = learning,
digest = "digest",
annotations = { highlight("2026-10-26 08:00:00", "/body/p[1]") },
ann_max_datetime = "2026-10-26 08:00:00",
ann_signature = "sig",
apply_mode = "sidecar",
file = "/books/copy.epub",
})
assertEqual(ann_reply.incompatible, 1, "the reply turns into a skip")
assertEqual(ann_reply.uploaded, 0, "the rejected chunk is not counted as uploaded")
assertEqual(learning:getBook("digest") ~= nil, true, "the match survives")
assertEqual(learning.unmatched["digest"], nil, "the book is not recorded as unmatched")
assertEqual(learning:getBook("digest").positionIncompatible, true, "the flag is recorded for later exchanges")
assertEqual(learning:getBook("digest").annWatermark, "", "the watermark does not advance past highlights that never synced")
local bm_learning = newState()
bm_learning:setMatched("digest", 10, 20, "/books/copy.epub", "v1")
local bm_reply = BookOrbitBookmarks.exchangeBook({
client = {
exchangeBookmarks = function() return { results = {}, unmatched = {}, positionIncompatible = { "digest" } } end,
exchangeBookmarksAck = function() error("no ack for a skipped book") end,
},
state = bm_learning,
digest = "digest",
bookmarks = { { datetime = "2026-10-26 08:00:00", pos = "/body/p[2]", pageno = 2 } },
bm_signature = "sig",
apply_mode = "sidecar",
file = "/books/copy.epub",
})
assertEqual(bm_reply.incompatible, 1, "the bookmark reply turns into a skip")
assertEqual(bm_learning.unmatched["digest"], nil, "the bookmark reply keeps the match")
assertEqual(bm_learning:getBook("digest").positionIncompatible, true, "the bookmark reply records the flag")
-- A truly unmatched answer still drops the match, as before.
local dropping = newState()
dropping:setMatched("digest", 10, 20, "/books/copy.epub", "v1")
local dropped, dropped_err = BookOrbitAnnotations.exchangeBook({
client = { exchangeAnnotations = function() return { results = {}, unmatched = { "digest" } } end },
state = dropping,
digest = "digest",
annotations = {},
ann_signature = "sig",
apply_mode = "skip",
})
assertEqual(dropped, nil, "an unmatched answer has no result")
assertEqual(dropped_err, "unmatched", "and reports unmatched")
assertEqual(dropping:getBook("digest"), nil, "and drops the local match")
-- Summaries say what happened without calling it a failure.
local summary = HighlightSummary.add(nil, ann)
summary = HighlightSummary.addBookmarks(summary, bm)
assertEqual(summary.incompatible, 1, "one skipped book is counted once, not per exchange")
assertEqual(summary.failed, 0, "no highlight failure")
assertEqual(summary.bm_failed, 0, "no bookmark failure")
assertEqual(HighlightSummary.actionableError(summary, nil), nil, "no partial failure is reported")
assert(HighlightSummary.message(summary):find("different file from the one in BookOrbit", 1, true),
"the message names the reason")
assertEqual(HighlightSummary.normalize({}).incompatible, 0, "older persisted summaries read as zero")
assertEqual(Diagnostics.openHighlightsText{
annotation_sync = true,
has_open_book = true,
matched = true,
position_incompatible = true,
}, "not synced: this copy differs from BookOrbit's file", "diagnostics name the state")
assertEqual(Diagnostics.openHighlightsText{
annotation_sync = true,
has_open_book = true,
matched = true,
}, "matched, idle", "a compatible match reads as before")
assertEqual(Diagnostics.lastSyncText({ incompatible = 2, at = 0 }),
"0 uploaded, 0 applied, 0 deleted, 0 failed, 0 skipped, 2 not synced (different copy)",
"the last-sync row counts different copies apart from skips")
assertEqual(Diagnostics.lastSyncText({ uploaded = 1, at = 0 }),
"1 uploaded, 0 applied, 0 deleted, 0 failed, 0 skipped", "a compatible sync reads as before")
print("bookorbit_position_incompatible_test: ok")
@@ -0,0 +1,87 @@
-- A place BookOrbit holds without a position can only be reached by percentage,
-- which lands on a page boundary, so a jump there must not leave a prompt that
-- offers the same place again on the next sync.
local function read(path)
local file = assert(io.open(path))
local source = file:read("*a")
file:close()
return source
end
local source = read("koreader-plugin/bookorbit.koplugin/bookorbit_progress_sync.lua")
local same = assert(source:match("(function ProgressSync:isSamePlace.-\nend)\n"))
local chunk = assert(loadstring("local ProgressSync = {}\n" .. same .. "\nreturn ProgressSync"))
local ProgressSync = chunk()
local function plugin(pages)
local p = setmetatable({}, { __index = ProgressSync })
p.ui = { document = { getPageCount = function() return pages end } }
return p
end
local p = plugin(167)
-- 60% of 167 pages is page 100.2: the jump lands on page 100, 59.88%.
assert(p:isSamePlace({ percentage = 0.6, progress = nil }, 100 / 167, "/body/p[3]"),
"a percentage-only place within a page is where the reader already is")
assert(p:isSamePlace({ percentage = 0.6, progress = "" }, 100 / 167, "/body/p[3]"),
"an empty position counts as none")
-- Another device on page 135 (0.8083) reaches this copy on page 134 (0.8024).
assert(p:isSamePlace({ percentage = 0.8083, progress = nil }, 134 / 167, "/body/p[3]"),
"a percentage jump that rounds down to the page start is still the same place")
assert(not p:isSamePlace({ percentage = 0.62, progress = nil }, 100 / 167, "/body/p[3]"),
"more than a page away is a different place")
assert(not p:isSamePlace({ percentage = 0.6, progress = "/body/p[9]" }, 100 / 167, "/body/p[3]"),
"an exact position elsewhere is never rounded into this one")
assert(p:isSamePlace({ percentage = 0.4, progress = "/body/p[3]" }, 0.6, "/body/p[3]"),
"the same exact position is the same place whatever the percentages say")
assert(p:isSamePlace({ percentage = 0.5, progress = nil }, 0.5, nil),
"equal percentages are the same place, as before")
local unknown = plugin(nil)
assert(not unknown:isSamePlace({ percentage = 0.6, progress = nil }, 0.5988, "/body/p[3]"),
"without a page count only the exact comparison applies")
local closed = setmetatable({}, { __index = ProgressSync })
assert(not closed:isSamePlace({ percentage = 0.6, progress = nil }, 0.5988, "/body/p[3]"),
"without an open document only the exact comparison applies")
-- The jump itself: page mode goes to the page the other device reported.
local sync_to = assert(source:match("(function ProgressSync:syncToProgress.-\nend)\n"))
local function jumper(view_mode, pages, has_pages)
local events = {}
local env = setmetatable({
Event = { new = function(_, name, value) return { name = name, value = value } end },
logger = { dbg = function() end },
}, { __index = _G })
local loaded = assert(loadstring("local ProgressSync = {}\n" .. sync_to .. "\nreturn ProgressSync"))
setfenv(loaded, env)
local jp = setmetatable({}, { __index = loaded() })
jp.ui = {
handleEvent = function(_, event) table.insert(events, event) end,
document = { info = { number_of_pages = pages, has_pages = has_pages } },
rolling = not has_pages and { view = { view_mode = view_mode } } or nil,
}
return jp, events
end
local paged, paged_events = jumper("page", 167, false)
paged:syncToProgress(nil, 135 / 167)
assert(paged_events[1].name == "GotoPage" and paged_events[1].value == 135, "page mode lands on the reported page, not one short")
paged:syncToProgress("", 0)
assert(paged_events[2].value == 1, "the start of the book is page 1")
paged:syncToProgress(nil, 1)
assert(paged_events[3].value == 167, "the end of the book is the last page")
local scrolled, scrolled_events = jumper("scroll", 167, false)
scrolled:syncToProgress(nil, 0.6)
assert(scrolled_events[1].name == "GotoPercent" and math.abs(scrolled_events[1].value - 60) < 1e-9, "scroll mode keeps the offset jump")
local pdf, pdf_events = jumper(nil, 40, true)
pdf:syncToProgress(nil, 0.5)
assert(pdf_events[1].name == "GotoPercent", "a paged document keeps the percentage jump")
local exact, exact_events = jumper("page", 167, false)
exact:syncToProgress("/body/p[3]", 0.6)
assert(exact_events[1].name == "GotoXPointer", "an exact position still wins")
print("bookorbit_same_place_test: ok")