Files
bookorbit/koreader-plugin/bookorbit.koplugin/bookorbit_bulk_checkpoint.lua
T
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

136 lines
4.5 KiB
Lua

--[[--
Resumable checkpoint for a bulk download run.
One compact record, published with an atomic rename so an interruption can
never leave a half-written checkpoint. It holds the source identity, the
manifest snapshot it was enumerated against, the last fully committed cursor
and bounded per-page completion and failure summaries. It deliberately does not
retain manifest rows: a resumed run re-enumerates from the cursor and validates
already-published files instead.
]]
local DataStorage = require("datastorage")
local dump = require("dump")
local lfs = require("libs/libkoreader-lfs")
local util = require("util")
local CHECKPOINT_DIR = "bookorbit-bulk-download"
local CHECKPOINT_FILE = "checkpoint.lua"
local VERSION = 2
local MAX_COMPLETED = 250
local MAX_FAILURES = 50
local Checkpoint = {}
Checkpoint.__index = Checkpoint
Checkpoint.VERSION = VERSION
Checkpoint.MAX_COMPLETED = MAX_COMPLETED
Checkpoint.MAX_FAILURES = MAX_FAILURES
local function safeLoad(path)
local chunk = loadfile(path)
if not chunk then return nil end
setfenv(chunk, {})
local ok, value = pcall(chunk)
if not ok or type(value) ~= "table" then return nil end
return value
end
function Checkpoint.open(opts)
opts = opts or {}
local settings_dir = opts.settings_dir or DataStorage:getSettingsDir()
local dir = settings_dir .. "/" .. CHECKPOINT_DIR
return setmetatable({
dir = dir,
path = dir .. "/" .. CHECKPOINT_FILE,
now = opts.now or os.time,
rename = opts.rename or os.rename,
remove = opts.remove or os.remove,
write_file = opts.write_file or function(content, path)
return util.writeToFile(content, path, true, false, true)
end,
make_path = opts.make_path or util.makePath,
attributes = opts.attributes or lfs.attributes,
load_record = opts.load_record or safeLoad,
serialize = opts.serialize or function(record)
return "return " .. dump(record) .. "\n"
end,
}, Checkpoint)
end
function Checkpoint:ensureDirectory()
if self.attributes(self.dir, "mode") == "directory" then return true end
self.make_path(self.dir)
return self.attributes(self.dir, "mode") == "directory"
end
function Checkpoint:load()
local record = self.load_record(self.path)
if type(record) ~= "table" or record.version ~= VERSION then return nil end
if type(record.source_key) ~= "string" or record.source_key == "" then return nil end
record.completed = type(record.completed) == "table" and record.completed or {}
record.linked_paths = type(record.linked_paths) == "table" and record.linked_paths or {}
record.counts = type(record.counts) == "table" and record.counts or {}
record.failures = type(record.failures) == "table" and record.failures or {}
return record
end
local function boundedCompleted(completed)
local bounded, kept = {}, 0
for book_id, path in pairs(completed or {}) do
if kept >= MAX_COMPLETED then break end
bounded[book_id] = path
kept = kept + 1
end
return bounded
end
local function boundedFailures(failures)
local bounded = {}
for index, failure in ipairs(failures or {}) do
if index > MAX_FAILURES then break end
table.insert(bounded, { id = failure.id, title = failure.title })
end
return bounded
end
function Checkpoint:save(record)
if not self:ensureDirectory() then return nil, "directory_failed" end
local stored = {
version = VERSION,
source_key = record.source_key,
label = record.label,
manifest_version = record.manifest_version,
cursor = record.cursor,
chunk_index = record.chunk_index,
page_number = record.page_number,
processed = record.processed,
completed = boundedCompleted(record.completed),
linked_paths = boundedCompleted(record.linked_paths),
counts = record.counts,
failures = boundedFailures(record.failures),
updated_at = self.now(),
}
local temporary = self.path .. ".tmp"
self.remove(temporary)
local written, write_err = self.write_file(self.serialize(stored), temporary)
if not written then
self.remove(temporary)
return nil, write_err or "write_failed"
end
local renamed, rename_err = self.rename(temporary, self.path)
if not renamed then
self.remove(temporary)
return nil, rename_err or "rename_failed"
end
return stored
end
function Checkpoint:clear()
self.remove(self.path .. ".tmp")
self.remove(self.path)
end
return Checkpoint