Files
neon 80244ea4e2 feat(koreader): rebuild the plugin interface and fix four-way annotation sync
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.
2026-10-20 16:05:27 -06:00

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