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.
370 lines
14 KiB
Lua
370 lines
14 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, candidates)
|
|
table.insert(handle.calls.match, hashes)
|
|
table.insert(handle.calls.match_candidates, candidates)
|
|
if responses.match_error then return nil, responses.match_error end
|
|
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,
|
|
getPluginVersion = function()
|
|
return { capabilities = responses.capabilities or {} }
|
|
end,
|
|
annotationPendingBooks = function(_, hashes)
|
|
table.insert(handle.calls.pending, hashes)
|
|
return { pending = responses.pending or {} }
|
|
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, statsRows, 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
|
|
- match_error: HTTP or transport error returned by match-check
|
|
- page_stats_response: function returning the server response for an upload
|
|
- sidecar: overrides for the BookOrbitSidecar stub
|
|
- capabilities: what the server's plugin version answer advertises
|
|
- pending: hashes the server reports as having changes for this device
|
|
]]
|
|
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 {},
|
|
match_error = opts.match_error,
|
|
page_stats = opts.page_stats_response,
|
|
capabilities = opts.capabilities,
|
|
pending = opts.pending,
|
|
},
|
|
calls = {
|
|
match = {},
|
|
match_candidates = {},
|
|
page_stats = {},
|
|
book_states = {},
|
|
progress = {},
|
|
legacy_annotations = {},
|
|
annotation_exchanges = {},
|
|
pending = {},
|
|
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(_, widget) handle.shown = widget 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,
|
|
}
|
|
-- The sync sheet and the pills are drawn elsewhere: the run's result is
|
|
-- what they were given, its title and notes as one text.
|
|
local function resultText(result)
|
|
local lines = { result.title }
|
|
if result.sub then table.insert(lines, result.sub) end
|
|
for _i, note in ipairs(result.notes or {}) do table.insert(lines, note) end
|
|
return table.concat(lines, "\n")
|
|
end
|
|
package.loaded["bookorbit_sync_sheet"] = {
|
|
open = function()
|
|
local sheet = { progress = nil }
|
|
handle.dialog = sheet
|
|
function sheet:progressed(status, done, total) self.progress = { status = status, done = done, total = total } end
|
|
function sheet:finished(result) handle.shown = { text = resultText(result), result = result } end
|
|
return sheet
|
|
end,
|
|
}
|
|
package.loaded["bookorbit_toast"] = {
|
|
show = function(text) handle.shown = { text = text } end,
|
|
error = function(text) handle.shown = { text = text } 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,
|
|
sidecarState = function() return nil end,
|
|
markFile = function() return false 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 {},
|
|
statsRows = initial.statsRows 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
|