mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
1c27e5dd05
Report a linked copy whose bytes differ from the library file as positionIncompatible instead of unmatched, so the plugin keeps the match, skips highlights without a failure and still syncs progress. Treat a position-less place within a page as the same place and jump to the nearest page. Suggest library books with the same title and authors for books to review, with bulk linking. See docs/KOREADER_HASH_MATCHING.md. Closes #1872 Closes #1919
363 lines
14 KiB
Lua
363 lines
14 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(account_identity)
|
|
local self = setmetatable({}, BookOrbitState)
|
|
if account_identity then
|
|
local legacy = LuaSettings:open(DataStorage:getSettingsDir() .. "/" .. STATE_FILE)
|
|
local owner = legacy:readSetting("account_identity")
|
|
if not owner and account_identity ~= "anonymous" then
|
|
legacy:saveSetting("account_identity", account_identity)
|
|
self.settings = legacy
|
|
self:flush()
|
|
owner = account_identity
|
|
end
|
|
if owner ~= account_identity then
|
|
self.filename = "bookorbit_sync_state_" .. require("ffi/sha2").md5(account_identity) .. ".lua"
|
|
end
|
|
end
|
|
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 {}),
|
|
statsRows = deepCopy(tables.statsRows 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()
|
|
self.files_by_digest = nil
|
|
local settings = LuaSettings:open(DataStorage:getSettingsDir() .. "/" .. (self.filename or 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.statsRows = settings:readSetting("statsRows", {})
|
|
self.global = settings:readSetting("global", {})
|
|
return self
|
|
end
|
|
|
|
function BookOrbitState:getBook(md5)
|
|
return self.books[md5]
|
|
end
|
|
|
|
-- A partial MD5 may identify several KOReader statistics rows. Those rows
|
|
-- need independent cursors: advancing one book's watermark must never hide
|
|
-- an older event belonging to another book with the same digest. Row ids are
|
|
-- local to statistics.sqlite3, so the persisted metadata guards against an id
|
|
-- being reused after that database is rebuilt.
|
|
function BookOrbitState:getStatsRow(id, md5, title, authors)
|
|
id = tonumber(id)
|
|
if not id or not md5 then return nil end
|
|
local key = tostring(id)
|
|
local row = self.statsRows[key]
|
|
if not row or row.md5 ~= md5 or row.title ~= title or row.authors ~= authors then
|
|
row = {
|
|
md5 = md5,
|
|
title = title,
|
|
authors = authors,
|
|
statsWatermark = 0,
|
|
}
|
|
self.statsRows[key] = row
|
|
end
|
|
row.statsWatermark = row.statsWatermark or 0
|
|
return row
|
|
end
|
|
|
|
function BookOrbitState:bindStatsRow(id, md5, title, authors, book_file_id, book_id)
|
|
local row = self:getStatsRow(id, md5, title, authors)
|
|
if not row then return nil end
|
|
if row.bookFileId ~= book_file_id or row.bookId ~= book_id then
|
|
row.statsWatermark = 0
|
|
end
|
|
row.bookFileId = book_file_id
|
|
row.bookId = book_id
|
|
return row
|
|
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. `position_compatible` is the match's positionCompatible
|
|
-- field: false marks a copy whose bytes differ from the library file, which the
|
|
-- server matches but cannot place highlights or exact positions in.
|
|
function BookOrbitState:setMatched(md5, book_file_id, book_id, file, verified_version, position_compatible)
|
|
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
|
|
book.positionIncompatible = position_compatible == false 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
|
|
|
|
-- A highlight or bookmark exchange that learns the copy is position
|
|
-- incompatible records it here, so later exchanges skip the book until the next
|
|
-- match check says otherwise.
|
|
function BookOrbitState:markPositionIncompatible(md5)
|
|
local book = self.books[md5]
|
|
if book then book.positionIncompatible = true end
|
|
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
|
|
if self.files[file] ~= md5 then self.files_by_digest = nil end
|
|
self.files[file] = md5
|
|
local book = self.books[md5]
|
|
if book and not book.file then
|
|
book.file = file
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Copies share a server rendition but have separate sidecars. An incomplete
|
|
-- local copy cannot authoritatively delete annotations belonging to another.
|
|
function BookOrbitState:hasOtherCopy(md5, file)
|
|
if not self.files_by_digest then
|
|
self.files_by_digest = {}
|
|
for path, digest in pairs(self.files) do
|
|
local paths = self.files_by_digest[digest] or {}
|
|
paths[#paths + 1] = path
|
|
self.files_by_digest[digest] = paths
|
|
end
|
|
end
|
|
for _, path in ipairs(self.files_by_digest[md5] or {}) do
|
|
if path ~= file and lfs.attributes(path, "mode") == "file" then return true end
|
|
end
|
|
return false
|
|
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_by_digest = nil
|
|
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
|