Files
bookorbit/koreader-plugin/bookorbit.koplugin/bookorbit_state.lua
T
neon 4a4b62bbef fix(stats): repair stale KOReader file identities
Recompute the open file digest and recover previously verified matches without guessing between duplicate files. Preserve stranded statistics across lifecycle retries and advertise plugin version 1.5.3 so installed devices can update.

Fixes #1497
2026-09-23 01:15:20 +00:00

283 lines
11 KiB
Lua

--[[--
Persistent local sync state for the BookOrbit plugin.
This LuaSettings file is the plugin's only local database. Matched books carry
full watermark records, unmatched hashes only a last-checked timestamp, so
thousands of unmatched books stay cheap. statistics.sqlite3 is never written.
]]
local DataStorage = require("datastorage")
local LuaSettings = require("luasettings")
local dump = require("dump")
local ffiutil = require("ffi/util")
local lfs = require("libs/libkoreader-lfs")
local STATE_FILE = "bookorbit_sync_state.lua"
local BookOrbitState = {}
BookOrbitState.__index = BookOrbitState
-- Conservative first value: a completely unchanged book still has to learn
-- once a day that its server-side file was deleted or re-imported. Tighten or
-- widen only against measured daily versus multi-day sync intervals.
BookOrbitState.MATCH_MAX_AGE = 24 * 3600
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
function BookOrbitState.open()
local self = setmetatable({}, BookOrbitState)
self:reload()
return self
end
function BookOrbitState.snapshot(tables, flush_handler)
return setmetatable({
books = deepCopy(tables.books or {}),
unmatched = deepCopy(tables.unmatched or {}),
files = deepCopy(tables.files or {}),
global = deepCopy(tables.global or {}),
flush_handler = flush_handler,
}, BookOrbitState)
end
-- Re-reads the settings file into this instance. Holders keep a valid
-- reference, so a reload cannot strand a caller on an orphaned snapshot it
-- would later flush over newer state.
function BookOrbitState:reload()
local settings = LuaSettings:open(DataStorage:getSettingsDir() .. "/" .. STATE_FILE)
self.settings = settings
-- readSetting with a default persists the returned live table on flush.
self.books = settings:readSetting("books", {})
self.unmatched = settings:readSetting("unmatched", {})
self.files = settings:readSetting("files", {})
self.global = settings:readSetting("global", {})
return self
end
function BookOrbitState:getBook(md5)
return self.books[md5]
end
-- Maps BookOrbit bookId -> local file path and bookFileId -> local file path
-- for every matched book whose file is still present on disk. Both maps come
-- from one scan with one existence check per stored file; bookorbit_state_manager
-- caches the result so a catalog repaint never rescans the matched library.
function BookOrbitState:matchedFileMaps()
local by_book_id, by_file_id = {}, {}
for _, book in pairs(self.books) do
local file = book.file
if file and (book.bookId or book.fileId) and lfs.attributes(file, "mode") == "file" then
if book.bookId then by_book_id[book.bookId] = file end
if book.fileId then by_file_id[book.fileId] = file end
end
end
return by_book_id, by_file_id
end
-- Every match-producing path funnels through here (lifecycle drain, open-book
-- match, sweep and the bulk manifest link). A different server book or file is
-- a fresh sync target even when the local digest is unchanged, so only its
-- local path may survive that transition. Both ids are checked because the
-- cursors below are split across them: reading statistics hang off the file
-- row, while highlights, status, rating and review hang off the book row, and a
-- folder merge re-parents a file to another book without changing its id. The
-- token the match was verified against is stored alongside the time so one
-- library change does not force a request on every later book sync until the
-- user runs a full sweep.
function BookOrbitState:setMatched(md5, book_file_id, book_id, file, verified_version)
local previous = self.books[md5]
local identity_changed = previous ~= nil
and (previous.fileId ~= book_file_id or previous.bookId ~= book_id)
local book = identity_changed and { file = file or previous.file } or (previous or {})
book.fileId = book_file_id
book.bookId = book_id
book.file = file or book.file
book.statsWatermark = book.statsWatermark or 0
book.annWatermark = book.annWatermark or ""
book.annCount = book.annCount or 0
book.matchVerifiedAt = os.time()
book.matchVerifiedVersion = verified_version or (self.global and self.global.libraryVersion) or nil
self.books[md5] = book
self.unmatched[md5] = nil
end
-- Drops the freshness stamp so the next sync rechecks, used when the user
-- explicitly asks for a rematch.
function BookOrbitState.expireMatch(book)
if not book then return end
book.matchVerifiedAt = nil
book.matchVerifiedVersion = nil
end
-- A match may be trusted without a request only inside a bounded age and, once
-- a library change has been observed, only when this book was verified against
-- the currently known token. A missing stamp is expired, so state written
-- before this existed rechecks once rather than being trusted forever.
function BookOrbitState.isMatchFresh(book, global, now)
if not book then return false end
local verified_at = book.matchVerifiedAt
if type(verified_at) ~= "number" then return false end
now = now or os.time()
if verified_at > now or (now - verified_at) > BookOrbitState.MATCH_MAX_AGE then
return false
end
global = global or {}
if global.needsFullRecheck == true then
local token = global.libraryVersion
if token == nil or book.matchVerifiedVersion ~= token then return false end
end
return true
end
-- Single invalidation path for the library-version token. matchCheck and
-- sweepComplete call it `libraryVersion` and the bulk manifest calls it
-- `manifestVersion`, but both come from the same server-side computation, so
-- every carrier feeds this one function. Returns true when the token moved.
function BookOrbitState.applyLibraryVersion(state, version, now)
if type(version) ~= "string" or version == "" then return false end
local global = state.global
local known = global.libraryVersion
local changed = known ~= nil and version ~= known
if changed then global.needsFullRecheck = true end
global.libraryVersion = version
global.libraryVersionCheckedAt = now or os.time()
return changed
end
function BookOrbitState:setUnmatched(md5)
self.books[md5] = nil
self.unmatched[md5] = os.time()
end
function BookOrbitState:rememberFile(file, md5)
if file and md5 then
self.files[file] = md5
local book = self.books[md5]
if book and not book.file then
book.file = file
end
end
end
-- Repairs a path whose cached digest no longer matches the file on disk. A
-- server identity is never copied between digests: only an identity already
-- verified for the recomputed digest may be used to recover an ambiguous
-- server match.
function BookOrbitState:repairFileIdentity(file, old_md5, new_md5)
if not file or not new_md5 then return nil end
self.files[file] = new_md5
if old_md5 and old_md5 ~= new_md5 then
local old_book = self.books[old_md5]
if old_book and old_book.file == file then
old_book.file = nil
BookOrbitState.expireMatch(old_book)
end
self.unmatched[new_md5] = nil
end
local new_book = self.books[new_md5]
if new_book then
new_book.file = file
if old_md5 ~= new_md5 then
BookOrbitState.expireMatch(new_book)
new_book.statsWatermark = 0
end
end
return new_book
end
function BookOrbitState:flush()
if self.flush_handler then
return self.flush_handler(self)
end
if self.settings.atomicFlush then
local ok, err = self.settings:atomicFlush()
if not ok then error(tostring(err or "state_flush_failed"), 0) end
else
local file = self.settings.file
local temp = file .. ".bookorbit.tmp"
local backup = file .. ".old"
os.remove(temp)
local serialized = "-- " .. temp .. "\nreturn "
.. dump(self.settings.data, nil, true) .. "\n"
local out, open_err = io.open(temp, "wb")
if not out then error(tostring(open_err or "state_temp_open_failed"), 0) end
local written, write_err = out:write(serialized)
if not written then
out:close()
os.remove(temp)
error(tostring(write_err or "state_temp_write_failed"), 0)
end
local flushed, flush_err = out:flush()
if not flushed then
out:close()
os.remove(temp)
error(tostring(flush_err or "state_temp_flush_failed"), 0)
end
local synced, sync_err = pcall(ffiutil.fsyncOpenedFile, out)
local closed, close_err = out:close()
if not synced or not closed then
os.remove(temp)
error(tostring(sync_err or close_err or "state_temp_sync_failed"), 0)
end
local had_current = lfs.attributes(file, "mode") == "file"
if had_current then
local backed_up, backup_err = os.rename(file, backup)
if not backed_up then
os.remove(temp)
error(tostring(backup_err or "state_backup_failed"), 0)
end
end
local published, publish_err = os.rename(temp, file)
if not published then
if had_current then os.rename(backup, file) end
os.remove(temp)
error(tostring(publish_err or "state_publish_failed"), 0)
end
pcall(ffiutil.fsyncDirectory, file)
end
-- bookorbit_state_manager installs this to publish a new generation, so
-- derived caches invalidate no matter which plugin path wrote.
if self.on_flush then self.on_flush(self) end
end
-- Shared ack-gated stats watermark advance used by the sweep and the per-book
-- sync. Returns true when a full batch went out and more events may remain.
function BookOrbitState.applyStatsAck(book, events, body, md5, batch_size, old_watermark)
local server_watermark = old_watermark
for _, result in ipairs(body.results or {}) do
if result.hash == md5 and result.watermark then
server_watermark = result.watermark
end
end
if #events == batch_size then
-- A full batch may have been cut inside a one-second group of events;
-- back off by one second so the remainder is fetched next round
-- (duplicates are server-side no-ops).
local max_start = events[#events].startTime
local next_watermark = max_start - 1
if next_watermark <= old_watermark then
next_watermark = server_watermark
end
book.statsWatermark = next_watermark
return true
end
book.statsWatermark = server_watermark
return false
end
return BookOrbitState