mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
1e5fcedb2a
Deleting a library cascades away its books, files, page stats and reading sessions, and a rescan mints new rows. The device's sync state is keyed by the file's partial md5, so an unchanged file kept its cursors across that swap: the plugin asked only for events newer than a watermark earned against rows the server no longer had, and nothing recorded before the swap was ever resent. "Recheck all book matches" did not help, because it funnels back through setMatched. Treat a changed book file id or book id as a fresh sync target and keep only the local file path. Both ids matter: statistics hang off the file row while highlights, status, rating and review hang off the book row, and an existing-folder merge re-parents a file to another book without changing its id. Resending costs nothing because the page-stat dedup key makes a replay a server-side no-op. Order both hash resolvers by book file id so content that exists as two files always resolves to the same row. They previously took whichever row Postgres returned first, so they could disagree with each other and, with the reset above, move a device's sync target under it. Closes #1049
329 lines
12 KiB
Lua
329 lines
12 KiB
Lua
--[[--
|
|
Deterministic environment for driving BookOrbitSweep from a standalone spec.
|
|
|
|
It installs the KOReader modules the sweep touches, a fake statistics database
|
|
backed by helpers/fake_sqlite, a fake scheduler so chunk callbacks advance on
|
|
demand instead of on wall time, and a recording API client. Time is frozen
|
|
(gettime returns 0), so chunk boundaries fall on the item count rather than on
|
|
how fast the machine running the spec happens to be.
|
|
|
|
Call install() before requiring bookorbit_sweep.
|
|
]]
|
|
|
|
local FakeScheduler = require("helpers/fake_scheduler")
|
|
local FakeSqlite = require("helpers/fake_sqlite")
|
|
|
|
local SweepHarness = {}
|
|
|
|
local function joinArgs(...)
|
|
local parts = {}
|
|
for index = 1, select("#", ...) do
|
|
table.insert(parts, tostring((select(index, ...))))
|
|
end
|
|
return table.concat(parts, " ")
|
|
end
|
|
|
|
local function statisticsResponder(handle)
|
|
return function(sql, params)
|
|
if sql:find("FROM book WHERE md5 IS NOT NULL", 1, true) then
|
|
local after_id, limit = params[1] or 0, params[2] or 50
|
|
local rows = {}
|
|
for _, book in ipairs(handle.books) do
|
|
if book.id > after_id and #rows < limit then
|
|
table.insert(rows, {
|
|
tostring(book.id), book.md5, book.title or "",
|
|
book.authors or "", tostring(book.last_open or 0),
|
|
})
|
|
end
|
|
end
|
|
return FakeSqlite.resultSet(rows)
|
|
end
|
|
|
|
if sql:find("MAX(start_time)", 1, true) then
|
|
handle.calls.latest_event_queries = handle.calls.latest_event_queries + 1
|
|
local rows = {}
|
|
for id, events in pairs(handle.events) do
|
|
local newest = 0
|
|
for _, event in ipairs(events) do
|
|
if event.start_time > newest then newest = event.start_time end
|
|
end
|
|
table.insert(rows, { tostring(id), tostring(newest) })
|
|
end
|
|
table.sort(rows, function(a, b) return a[1] < b[1] end)
|
|
return FakeSqlite.resultSet(rows)
|
|
end
|
|
|
|
if sql:find("FROM page_stat_data", 1, true) then
|
|
handle.calls.event_queries = handle.calls.event_queries + 1
|
|
local watermark = params[#params - 1] or 0
|
|
local rows = {}
|
|
for index = 1, #params - 2 do
|
|
for _, event in ipairs(handle.events[params[index]] or {}) do
|
|
if event.start_time > watermark then
|
|
table.insert(rows, {
|
|
tostring(event.page), tostring(event.start_time),
|
|
tostring(event.duration or 60), tostring(event.total_pages or 100),
|
|
})
|
|
end
|
|
end
|
|
end
|
|
return FakeSqlite.resultSet(rows)
|
|
end
|
|
|
|
return nil
|
|
end
|
|
end
|
|
|
|
local function installClient(handle)
|
|
local responses = handle.responses
|
|
return {
|
|
isConfigured = function() return true end,
|
|
matchCheck = function(_, hashes)
|
|
table.insert(handle.calls.match, hashes)
|
|
local matches = {}
|
|
for _, hash in ipairs(hashes) do
|
|
if responses.unmatched[hash] then
|
|
-- left out of the response on purpose
|
|
else
|
|
local configured = responses.matches[hash] or {}
|
|
local stored = handle.state and handle.state:getBook(hash) or {}
|
|
table.insert(matches, {
|
|
hash = hash,
|
|
bookId = configured.bookId or stored.bookId or 1,
|
|
bookFileId = configured.bookFileId or stored.fileId or 2,
|
|
})
|
|
end
|
|
end
|
|
return { matches = matches, libraryVersion = responses.library_version }
|
|
end,
|
|
uploadPageStats = function(_, books)
|
|
table.insert(handle.calls.page_stats, books)
|
|
if responses.page_stats then return responses.page_stats(books) end
|
|
return { results = {}, unmatched = {} }
|
|
end,
|
|
uploadBookStates = function(_, batch)
|
|
table.insert(handle.calls.book_states, batch)
|
|
return { results = {}, unmatched = {} }
|
|
end,
|
|
bulkProgress = function(_, batch)
|
|
table.insert(handle.calls.progress, batch)
|
|
return { unmatched = {} }
|
|
end,
|
|
uploadAnnotations = function(_, books)
|
|
table.insert(handle.calls.legacy_annotations, books)
|
|
return { unmatched = {} }
|
|
end,
|
|
sweepComplete = function()
|
|
handle.calls.sweep_complete = handle.calls.sweep_complete + 1
|
|
return { libraryVersion = responses.library_version }
|
|
end,
|
|
}
|
|
end
|
|
|
|
--[[--
|
|
opts:
|
|
- books: statistics rows, { md5, id, title, authors, last_open }
|
|
- events: map of statistics row id to { page, start_time, duration, total_pages }
|
|
- history: ReadHistory entries, { file, time, text }
|
|
- state: initial { books, unmatched, files, global }
|
|
- library_version: token every server response carries
|
|
- unmatched: set of hashes match-check refuses to match
|
|
- matches: map of hashes to custom { bookId, bookFileId } match results
|
|
- page_stats_response: function returning the server response for an upload
|
|
- sidecar: overrides for the BookOrbitSidecar stub
|
|
]]
|
|
function SweepHarness.install(opts)
|
|
opts = opts or {}
|
|
-- Plugin modules capture their stubs at load time, so a second harness in
|
|
-- the same process has to make them resolve again or it silently keeps
|
|
-- talking to the previous fixture.
|
|
for name in pairs(package.loaded) do
|
|
if type(name) == "string" and name:match("^bookorbit_") then
|
|
package.loaded[name] = nil
|
|
end
|
|
end
|
|
|
|
local handle = {
|
|
books = opts.books or {},
|
|
events = opts.events or {},
|
|
history = opts.history or {},
|
|
responses = {
|
|
library_version = opts.library_version,
|
|
unmatched = opts.unmatched or {},
|
|
matches = opts.matches or {},
|
|
page_stats = opts.page_stats_response,
|
|
},
|
|
calls = {
|
|
match = {},
|
|
page_stats = {},
|
|
book_states = {},
|
|
progress = {},
|
|
legacy_annotations = {},
|
|
annotation_exchanges = {},
|
|
sweep_complete = 0,
|
|
event_queries = 0,
|
|
latest_event_queries = 0,
|
|
scheduled = 0,
|
|
sidecar_extracts = {},
|
|
history_resolved = {},
|
|
},
|
|
bookmark_exchanges = {},
|
|
flushes = 0,
|
|
}
|
|
|
|
handle.scheduler = FakeScheduler.new()
|
|
handle.sqlite = FakeSqlite.install(statisticsResponder(handle), opts.sqlite)
|
|
|
|
package.loaded["datastorage"] = { getSettingsDir = function() return "/nonexistent" end }
|
|
package.loaded["luasettings"] = {
|
|
open = function()
|
|
return {
|
|
data = {},
|
|
readSetting = function(_, _, default) return default end,
|
|
saveSetting = function() end,
|
|
flush = function() end,
|
|
}
|
|
end,
|
|
}
|
|
package.loaded["dump"] = function() return "{}" end
|
|
package.loaded["logger"] = {
|
|
dbg = function() end,
|
|
info = function() end,
|
|
warn = function() end,
|
|
-- A sweep step that throws must fail the spec rather than be swallowed
|
|
-- by the step's own error handling.
|
|
err = function(...) error(joinArgs(...), 0) end,
|
|
}
|
|
package.loaded["ffi/util"] = {
|
|
template = function(text) return text end,
|
|
-- Frozen clock: chunk boundaries then depend only on the item count.
|
|
gettime = function() return 0, 0 end,
|
|
}
|
|
package.loaded["gettext"] = function(value) return value end
|
|
package.loaded["util"] = {
|
|
partialMD5 = function(file) return "md5-" .. file end,
|
|
trim = function(value) return value end,
|
|
}
|
|
package.loaded["libs/libkoreader-lfs"] = {
|
|
attributes = function(_, what)
|
|
if what == "mode" then return "file" end
|
|
return nil
|
|
end,
|
|
}
|
|
package.loaded["readhistory"] = { hist = handle.history }
|
|
package.loaded["docsettings"] = {
|
|
hasSidecarFile = function() return opts.has_sidecar_files == true end,
|
|
open = function()
|
|
return {
|
|
readSetting = function() return nil end,
|
|
saveSetting = function() end,
|
|
makeTrue = function() end,
|
|
flush = function() end,
|
|
}
|
|
end,
|
|
}
|
|
package.loaded["apps/reader/readerui"] = { instance = nil }
|
|
|
|
package.loaded["ui/uimanager"] = {
|
|
scheduleIn = function(_, delay, callback)
|
|
handle.calls.scheduled = handle.calls.scheduled + 1
|
|
handle.scheduler:scheduleIn(delay, callback)
|
|
end,
|
|
show = function() end,
|
|
close = function() end,
|
|
setDirty = function() end,
|
|
}
|
|
package.loaded["ui/trapper"] = { wrap = function(_, callback) callback() end }
|
|
package.loaded["ui/widget/infomessage"] = { new = function(_, fields) return fields end }
|
|
package.loaded["ui/widget/notification"] = { notify = function() end }
|
|
package.loaded["ui/widget/buttondialog"] = {
|
|
new = function(_, fields)
|
|
fields.setTitle = function(self, title) self.title = title end
|
|
handle.dialog = fields
|
|
return fields
|
|
end,
|
|
}
|
|
|
|
local sidecar = {
|
|
sidecarMtime = function() return nil end,
|
|
extract = function() return nil end,
|
|
buildStatePayload = function() return nil end,
|
|
stateFromServerResult = function() return nil end,
|
|
applyServerStateSidecar = function() return false end,
|
|
rememberServerState = function() end,
|
|
markStatePulled = function() end,
|
|
rememberUploadedState = function() end,
|
|
}
|
|
for key, value in pairs(opts.sidecar or {}) do
|
|
sidecar[key] = value
|
|
end
|
|
handle.sidecar = sidecar
|
|
package.loaded["bookorbit_sidecar"] = sidecar
|
|
|
|
package.loaded["bookorbit_annotations"] = {
|
|
exchangeBook = function(exchange_opts)
|
|
table.insert(handle.calls.annotation_exchanges, exchange_opts)
|
|
return { uploaded = #(exchange_opts.annotations or {}), applied = 0, deleted = 0, failed = 0, had_errors = false }
|
|
end,
|
|
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,
|
|
}
|
|
package.loaded["bookorbit_api"] = { new = function() return installClient(handle) end }
|
|
package.loaded["bookorbit_book_sync"] = { isRunning = function() return false end }
|
|
|
|
local BookOrbitState = require("bookorbit_state")
|
|
local initial = opts.state or {}
|
|
handle.state = BookOrbitState.snapshot({
|
|
books = initial.books or {},
|
|
unmatched = initial.unmatched or {},
|
|
files = initial.files or {},
|
|
global = initial.global or {},
|
|
}, function()
|
|
handle.flushes = handle.flushes + 1
|
|
end)
|
|
package.loaded["bookorbit_state_manager"] = {
|
|
session = function() return handle.state end,
|
|
summary = function() return {} end,
|
|
}
|
|
|
|
return handle
|
|
end
|
|
|
|
-- Collects the hashes a match-check batch carried, as a set, because the
|
|
-- queue is built from pairs() and its order is not defined.
|
|
function SweepHarness.hashSet(batch)
|
|
local set = {}
|
|
for _, hash in ipairs(batch or {}) do
|
|
set[hash] = true
|
|
end
|
|
return set
|
|
end
|
|
|
|
function SweepHarness.sortedKeys(map)
|
|
local keys = {}
|
|
for key in pairs(map or {}) do
|
|
table.insert(keys, key)
|
|
end
|
|
table.sort(keys)
|
|
return keys
|
|
end
|
|
|
|
return SweepHarness
|