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.
482 lines
16 KiB
Lua
482 lines
16 KiB
Lua
--[[--
|
|
Single owner of the plugin's persistent sync state.
|
|
|
|
BookOrbitState reparses bookorbit_sync_state.lua through dofile() on every
|
|
open, and the catalog additionally derived its on-device maps from a fresh
|
|
open on every repaint. This manager keeps one private persistent owner and
|
|
gives workflows isolated, optionally scoped sessions. Session flushes use a
|
|
three-way merge so an older workflow cannot overwrite already-published state.
|
|
|
|
Immediate mutations join the outermost transaction and flush once. Derived
|
|
on-device maps are cached against the published generation.
|
|
]]
|
|
|
|
local BookOrbitState = require("bookorbit_state")
|
|
|
|
local StateManager = {}
|
|
|
|
local LIBRARY_VERSION_TOUCH_INTERVAL = 3600
|
|
|
|
local shared
|
|
local generation = 0
|
|
local active_mutation
|
|
local account_identity
|
|
local account_epoch = 0
|
|
local maps
|
|
local maps_generation = -1
|
|
|
|
local function deepCopy(value)
|
|
if type(value) ~= "table" then return value end
|
|
local copy = {}
|
|
for key, item in pairs(value) do
|
|
copy[deepCopy(key)] = deepCopy(item)
|
|
end
|
|
return copy
|
|
end
|
|
|
|
local function deepEqual(a, b, seen)
|
|
if a == b then return true end
|
|
if type(a) ~= "table" or type(b) ~= "table" then return false end
|
|
seen = seen or {}
|
|
if seen[a] == b then return true end
|
|
seen[a] = b
|
|
for key, value in pairs(a) do
|
|
if not deepEqual(value, b[key], seen) then return false end
|
|
end
|
|
for key in pairs(b) do
|
|
if a[key] == nil then return false end
|
|
end
|
|
return true
|
|
end
|
|
|
|
-- Three-way merge used when a long-running session flushes after another
|
|
-- scoped writer. Unchanged session fields inherit the current value; unchanged
|
|
-- current fields accept the session value. If both changed the same scalar,
|
|
-- the already-published current value wins instead of being overwritten.
|
|
local function mergeValue(base, working, current)
|
|
if deepEqual(working, current) then return deepCopy(working) end
|
|
if deepEqual(working, base) then return deepCopy(current) end
|
|
if deepEqual(current, base) then return deepCopy(working) end
|
|
if type(base) == "table" and type(working) == "table" and type(current) == "table" then
|
|
local result = {}
|
|
local keys = {}
|
|
for key in pairs(base) do keys[key] = true end
|
|
for key in pairs(working) do keys[key] = true end
|
|
for key in pairs(current) do keys[key] = true end
|
|
for key in pairs(keys) do
|
|
result[key] = mergeValue(base[key], working[key], current[key])
|
|
end
|
|
return result
|
|
end
|
|
return deepCopy(current)
|
|
end
|
|
|
|
local function replaceTable(target, source)
|
|
for key in pairs(target) do target[key] = nil end
|
|
for key, value in pairs(source or {}) do target[key] = deepCopy(value) end
|
|
end
|
|
|
|
local function mapToEntries(map)
|
|
local entries = {}
|
|
for id, file in pairs(map) do
|
|
table.insert(entries, { id = id, file = file })
|
|
end
|
|
table.sort(entries, function(a, b)
|
|
return a.id < b.id
|
|
end)
|
|
return entries
|
|
end
|
|
|
|
local function entriesToMap(entries)
|
|
if type(entries) ~= "table" then return nil end
|
|
local map = {}
|
|
for _, entry in ipairs(entries) do
|
|
if type(entry) ~= "table"
|
|
or type(entry.id) ~= "number"
|
|
or entry.id <= 0
|
|
or entry.id % 1 ~= 0
|
|
or type(entry.file) ~= "string"
|
|
or entry.file == "" then
|
|
return nil
|
|
end
|
|
map[entry.id] = entry.file
|
|
end
|
|
return map
|
|
end
|
|
|
|
local function publish()
|
|
generation = generation + 1
|
|
maps = nil
|
|
maps_generation = -1
|
|
end
|
|
|
|
local function ensure()
|
|
if not shared then
|
|
shared = BookOrbitState.open(account_identity)
|
|
shared.on_flush = publish
|
|
publish()
|
|
end
|
|
return shared
|
|
end
|
|
|
|
local function scopeTables(state, scope)
|
|
if scope.full then
|
|
return {
|
|
books = state.books,
|
|
unmatched = state.unmatched,
|
|
files = state.files,
|
|
statsRows = state.statsRows,
|
|
global = state.global,
|
|
}
|
|
end
|
|
|
|
local tables = { books = {}, unmatched = {}, files = {}, statsRows = {}, global = {} }
|
|
for digest in pairs(scope.digests) do
|
|
tables.books[digest] = state.books[digest]
|
|
tables.unmatched[digest] = state.unmatched[digest]
|
|
end
|
|
for file in pairs(scope.files) do
|
|
tables.files[file] = state.files[file]
|
|
end
|
|
for row_id in pairs(scope.statsRows) do
|
|
tables.statsRows[row_id] = state.statsRows[row_id]
|
|
end
|
|
if scope.global then tables.global = state.global end
|
|
return tables
|
|
end
|
|
|
|
local function normalizeScope(opts)
|
|
if not opts or opts.full then
|
|
return { full = true, digests = {}, files = {}, statsRows = {}, global = true }
|
|
end
|
|
local scope = { full = false, digests = {}, files = {}, statsRows = {}, global = opts.global ~= false }
|
|
for _, digest in ipairs(opts.digests or {}) do
|
|
if digest then scope.digests[digest] = true end
|
|
end
|
|
for _, file in ipairs(opts.files or {}) do
|
|
if file then scope.files[file] = true end
|
|
end
|
|
for _, row_id in ipairs(opts.statsRows or {}) do
|
|
row_id = tonumber(row_id)
|
|
if row_id then scope.statsRows[tostring(row_id)] = true end
|
|
end
|
|
return scope
|
|
end
|
|
|
|
local function refreshSession(session)
|
|
local current = scopeTables(ensure(), session.manager_scope)
|
|
session.books = deepCopy(current.books)
|
|
session.unmatched = deepCopy(current.unmatched)
|
|
session.files = deepCopy(current.files)
|
|
session.statsRows = deepCopy(current.statsRows)
|
|
session.global = deepCopy(current.global)
|
|
session.manager_base = deepCopy(current)
|
|
session.manager_generation = generation
|
|
end
|
|
|
|
local function commitSession(session)
|
|
if session.manager_account_epoch ~= account_epoch then
|
|
return nil, "stale_account"
|
|
end
|
|
local state = ensure()
|
|
local scope = session.manager_scope
|
|
local base = session.manager_base
|
|
|
|
if scope.full then
|
|
replaceTable(state.books, mergeValue(base.books, session.books, state.books))
|
|
replaceTable(state.unmatched, mergeValue(base.unmatched, session.unmatched, state.unmatched))
|
|
replaceTable(state.files, mergeValue(base.files, session.files, state.files))
|
|
replaceTable(state.statsRows, mergeValue(base.statsRows, session.statsRows, state.statsRows))
|
|
replaceTable(state.global, mergeValue(base.global, session.global, state.global))
|
|
else
|
|
for digest in pairs(scope.digests) do
|
|
state.books[digest] = mergeValue(base.books[digest], session.books[digest], state.books[digest])
|
|
state.unmatched[digest] = mergeValue(
|
|
base.unmatched[digest], session.unmatched[digest], state.unmatched[digest])
|
|
end
|
|
for file in pairs(scope.files) do
|
|
state.files[file] = mergeValue(base.files[file], session.files[file], state.files[file])
|
|
end
|
|
for row_id in pairs(scope.statsRows) do
|
|
state.statsRows[row_id] = mergeValue(base.statsRows[row_id], session.statsRows[row_id], state.statsRows[row_id])
|
|
end
|
|
if scope.global then
|
|
replaceTable(state.global, mergeValue(base.global, session.global, state.global))
|
|
end
|
|
end
|
|
|
|
local flushed, flush_err = pcall(state.flush, state)
|
|
if not flushed then
|
|
state:reload()
|
|
publish()
|
|
error(flush_err, 0)
|
|
end
|
|
refreshSession(session)
|
|
return generation
|
|
end
|
|
|
|
function StateManager.session(opts)
|
|
local scope = normalizeScope(opts)
|
|
local initial = scopeTables(ensure(), scope)
|
|
local session
|
|
session = BookOrbitState.snapshot(initial, function()
|
|
return commitSession(session)
|
|
end)
|
|
session.manager_scope = scope
|
|
session.manager_base = deepCopy(initial)
|
|
session.manager_generation = generation
|
|
session.manager_account_epoch = account_epoch
|
|
return session
|
|
end
|
|
|
|
function StateManager.setAccount(api)
|
|
local identity = "anonymous"
|
|
if api and api.server_url and api.username and api.userkey then
|
|
identity = tostring(api.server_url):gsub("/+$", "") .. "|" .. tostring(api.username)
|
|
end
|
|
if identity == account_identity then return false end
|
|
account_identity = identity
|
|
account_epoch = account_epoch + 1
|
|
shared = nil
|
|
active_mutation = nil
|
|
publish()
|
|
ensure()
|
|
return true
|
|
end
|
|
|
|
-- Compatibility alias for older callers. It returns an isolated session, not
|
|
-- the manager's mutable live state.
|
|
function StateManager.state()
|
|
return StateManager.session()
|
|
end
|
|
|
|
function StateManager.getBook(digest)
|
|
return deepCopy(ensure():getBook(digest))
|
|
end
|
|
|
|
function StateManager.summary()
|
|
local state = ensure()
|
|
local matched, unmatched = 0, 0
|
|
for _ in pairs(state.books) do matched = matched + 1 end
|
|
for _ in pairs(state.unmatched) do unmatched = unmatched + 1 end
|
|
return {
|
|
matched = matched,
|
|
unmatched = unmatched,
|
|
lastSweepAt = state.global.lastSweepAt or 0,
|
|
needsFullRecheck = state.global.needsFullRecheck == true,
|
|
}
|
|
end
|
|
|
|
function StateManager.generation()
|
|
return generation
|
|
end
|
|
|
|
-- Routes a library-version token observed on any response into the shared
|
|
-- invalidation path. Every flush rewrites the whole state file, so an already
|
|
-- known token only refreshes its observation stamp once per interval. This is
|
|
-- an observation, not the caller's work, so a failed flush is reported rather
|
|
-- than raised into a download or catalog request.
|
|
function StateManager.applyLibraryVersion(version)
|
|
if type(version) ~= "string" or version == "" then return generation end
|
|
local state = ensure()
|
|
local checked_at = state.global.libraryVersionCheckedAt
|
|
if state.global.libraryVersion == version and type(checked_at) == "number"
|
|
and os.time() - checked_at < LIBRARY_VERSION_TOUCH_INTERVAL then
|
|
return generation
|
|
end
|
|
local ok, result = pcall(StateManager.mutate, function(session)
|
|
BookOrbitState.applyLibraryVersion(session, version)
|
|
end)
|
|
if not ok then return nil, result end
|
|
return result
|
|
end
|
|
|
|
-- Re-reads the settings file in place. Use when another process may have
|
|
-- written it; holders of the shared instance observe the reloaded tables.
|
|
function StateManager.reload()
|
|
ensure():reload()
|
|
publish()
|
|
return generation
|
|
end
|
|
|
|
-- Drops only the derived caches, keeping the parsed state.
|
|
function StateManager.invalidate()
|
|
publish()
|
|
end
|
|
|
|
function StateManager.mutate(fn)
|
|
if active_mutation then
|
|
local ok, err = pcall(fn, active_mutation)
|
|
if not ok then error(err, 0) end
|
|
return generation
|
|
end
|
|
|
|
local session = StateManager.session()
|
|
active_mutation = session
|
|
local ok, err = pcall(fn, session)
|
|
active_mutation = nil
|
|
if not ok then
|
|
error(err, 0)
|
|
end
|
|
return session:flush()
|
|
end
|
|
|
|
function StateManager.mutateScoped(opts, fn)
|
|
if active_mutation then
|
|
local ok, err = pcall(fn, active_mutation)
|
|
if not ok then error(err, 0) end
|
|
return generation
|
|
end
|
|
local session = StateManager.session(opts)
|
|
active_mutation = session
|
|
local ok, err = pcall(fn, session)
|
|
active_mutation = nil
|
|
if not ok then error(err, 0) end
|
|
return session:flush()
|
|
end
|
|
|
|
-- Refuses a change prepared against an older generation, so a caller that
|
|
-- computed its update before another path published cannot clobber it.
|
|
function StateManager.commit(expected_generation, fn)
|
|
ensure()
|
|
if expected_generation ~= nil and expected_generation ~= generation then
|
|
return nil, "stale_generation"
|
|
end
|
|
return StateManager.mutate(fn)
|
|
end
|
|
|
|
function StateManager.onDeviceMaps()
|
|
if maps and maps_generation == generation then
|
|
return maps
|
|
end
|
|
local state = ensure()
|
|
local by_book_id, by_file_id = state:matchedFileMaps()
|
|
maps = { byBookId = by_book_id, byFileId = by_file_id, generation = generation }
|
|
maps_generation = generation
|
|
return maps
|
|
end
|
|
|
|
function StateManager.hasOnDeviceMaps()
|
|
return maps ~= nil and maps_generation == generation
|
|
end
|
|
|
|
-- Safe to call in a subprocess. Sparse numeric-key maps do not survive the
|
|
-- JSON subprocess envelope, so the child returns sequential records and the
|
|
-- parent rebuilds lookup maps only if the generation is still current.
|
|
function StateManager.computeOnDeviceMaps()
|
|
local state = shared or BookOrbitState.open(account_identity)
|
|
local by_book_id, by_file_id = state:matchedFileMaps()
|
|
return {
|
|
bookEntries = mapToEntries(by_book_id),
|
|
fileEntries = mapToEntries(by_file_id),
|
|
generation = generation,
|
|
}
|
|
end
|
|
|
|
function StateManager.adoptOnDeviceMaps(computed)
|
|
if type(computed) ~= "table" or computed.generation ~= generation then
|
|
return nil, "stale_generation"
|
|
end
|
|
local by_book_id = entriesToMap(computed.bookEntries)
|
|
local by_file_id = entriesToMap(computed.fileEntries)
|
|
if not by_book_id or not by_file_id then
|
|
return nil, "invalid_maps"
|
|
end
|
|
maps = {
|
|
byBookId = by_book_id,
|
|
byFileId = by_file_id,
|
|
generation = computed.generation,
|
|
}
|
|
maps_generation = generation
|
|
return maps
|
|
end
|
|
|
|
-- Links freshly downloaded files in one scoped mutation and patches stable
|
|
-- cached identities forward. Every flush rewrites the whole settings file, so
|
|
-- bulk callers batch their links instead of flushing per file. A changed
|
|
-- identity leaves the maps invalidated for asynchronous reconciliation because
|
|
-- another local format may still own the old ID.
|
|
function StateManager.linkFiles(entries)
|
|
entries = entries or {}
|
|
if #entries == 0 then return generation end
|
|
|
|
local previous = maps
|
|
local state = ensure()
|
|
local digests, files, prior = {}, {}, {}
|
|
for _, entry in ipairs(entries) do
|
|
table.insert(digests, entry.digest)
|
|
if entry.file then table.insert(files, entry.file) end
|
|
prior[entry.digest] = deepCopy(state.books[entry.digest])
|
|
end
|
|
|
|
-- The global scope is included so setMatched() can stamp each link against
|
|
-- the library version this session knows; it is read, never written here.
|
|
local gen = StateManager.mutateScoped({
|
|
digests = digests,
|
|
files = files,
|
|
}, function(session)
|
|
for _, entry in ipairs(entries) do
|
|
session:rememberFile(entry.file, entry.digest)
|
|
session:setMatched(entry.digest, entry.bookFileId, entry.bookId, entry.file)
|
|
end
|
|
end)
|
|
|
|
local ids_unchanged = true
|
|
for _, entry in ipairs(entries) do
|
|
local before = prior[entry.digest]
|
|
if before and (before.bookId ~= entry.bookId or before.fileId ~= entry.bookFileId) then
|
|
ids_unchanged = false
|
|
break
|
|
end
|
|
end
|
|
if previous and ids_unchanged then
|
|
for _, entry in ipairs(entries) do
|
|
if entry.file then
|
|
if entry.bookId then previous.byBookId[entry.bookId] = entry.file end
|
|
if entry.bookFileId then previous.byFileId[entry.bookFileId] = entry.file end
|
|
end
|
|
end
|
|
previous.generation = gen
|
|
maps = previous
|
|
maps_generation = gen
|
|
end
|
|
return gen
|
|
end
|
|
|
|
function StateManager.linkFile(digest, book_file_id, book_id, file)
|
|
return StateManager.linkFiles({
|
|
{ digest = digest, bookFileId = book_file_id, bookId = book_id, file = file },
|
|
})
|
|
end
|
|
|
|
function StateManager.repairFileIdentity(file, old_digest, new_digest)
|
|
if not file or not new_digest then return generation end
|
|
if old_digest == new_digest and ensure().files[file] == new_digest then
|
|
return generation
|
|
end
|
|
local digests = { new_digest }
|
|
if old_digest and old_digest ~= new_digest then
|
|
table.insert(digests, old_digest)
|
|
end
|
|
return StateManager.mutateScoped({
|
|
digests = digests,
|
|
files = { file },
|
|
global = false,
|
|
}, function(session)
|
|
session:repairFileIdentity(file, old_digest, new_digest)
|
|
end)
|
|
end
|
|
|
|
-- Test seam: forgets the shared instance and every derived cache.
|
|
function StateManager.reset()
|
|
shared = nil
|
|
maps = nil
|
|
maps_generation = -1
|
|
generation = 0
|
|
active_mutation = nil
|
|
account_identity = nil
|
|
account_epoch = account_epoch + 1
|
|
end
|
|
|
|
return StateManager
|