mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
dbb10b5c2f
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.
246 lines
9.3 KiB
Lua
246 lines
9.3 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")
|
|
|
|
print("bookorbit_bookmark_exchange_test.lua: ok")
|