mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
80244ea4e2
Squash of feat/koreader-widget-board. KOReader plugin 2.0.0 gets the widget-board dashboard, redesigned book details, menus, lists and dialogs, Decor page and reading companions. Highlights, notes and bookmarks now stay in step across web, Kobo, KOReader and the phones (defects D-00 to D-20, migration 0121), and reading progress and narration hand off cleanly between devices.
255 lines
9.7 KiB
Lua
255 lines
9.7 KiB
Lua
-- 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")
|
|
|
|
requests = {}
|
|
local copy_state = makeState({})
|
|
copy_state.hasOtherCopy = function() return true end
|
|
BookOrbitBookmarks.exchangeBook({
|
|
client = client, state = copy_state, digest = "digest", bookmarks = {}, bm_signature = "empty-copy",
|
|
apply_mode = "sidecar", file = "/books/copy.epub",
|
|
})
|
|
assertEqual(requests[1].keysComplete, false, "one duplicate sidecar cannot delete another copy's dogears")
|
|
|
|
print("bookorbit_bookmark_exchange_test.lua: ok")
|