Files
bookorbit/koreader-plugin/bookorbit.koplugin/bookorbit_stats_reader.lua
T
neon a0dad23087 feat(kobo): add manual KOReader book hash linking and unmatched tracking
When KOReader syncs progress or annotations, it identifies books by a document
hash. BookOrbit normally resolves this automatically, but some books cannot be
matched. This adds a tracking table for unmatched hashes and a UI to manually
link them to existing library books.

Closes #458
2026-07-02 00:34:16 +00:00

163 lines
5.7 KiB
Lua

--[[--
Read-only access to KOReader's statistics.sqlite3.
Connections are short-lived and opened in read-only mode; the statistics
plugin owns all writes to this database.
]]
local DataStorage = require("datastorage")
local SQ3 = require("lua-ljsqlite3/init")
local logger = require("logger")
local BookOrbitStatsReader = {}
local function metadataKey(title, authors)
return (title or "") .. "\0" .. (authors or "")
end
local function applyRow(entry, id, title, authors, last_open)
if title == "" then title = nil end
if authors == "" then authors = nil end
table.insert(entry.ids, id)
if not entry._variant_seen[metadataKey(title, authors)] then
entry._variant_seen[metadataKey(title, authors)] = true
entry._variant_count = entry._variant_count + 1
end
if last_open > (entry.last_open or 0) then
entry.last_open = last_open
if title then entry.title = title end
if authors then entry.authors = authors end
end
if not entry.title and title then entry.title = title end
if not entry.authors and authors then entry.authors = authors end
end
local function finalizeEntry(entry)
entry.metadata_ambiguous = (entry._variant_count or 0) > 1
entry._variant_seen = nil
entry._variant_count = nil
return entry
end
local function dbPath()
return DataStorage:getSettingsDir() .. "/statistics.sqlite3"
end
local function withConn(fn)
local ok_open, conn = pcall(SQ3.open, dbPath(), "ro")
if not ok_open or not conn then
logger.dbg("BookOrbit: cannot open statistics.sqlite3:", conn)
return nil, "open_failed"
end
local ok, result = pcall(fn, conn)
pcall(conn.close, conn)
if not ok then
logger.dbg("BookOrbit: statistics query failed:", result)
return nil, "query_failed"
end
return result
end
-- Returns an array of { md5, ids = {stat book row ids}, last_open, title, authors }.
-- Multiple stats rows can share one md5 (the stats unique key is
-- title+authors+md5), so rows are grouped per md5 here.
function BookOrbitStatsReader.getBooks()
return withConn(function(conn)
local res = conn:exec("SELECT id, md5, title, authors, last_open FROM book WHERE md5 IS NOT NULL AND md5 != '';")
local by_md5 = {}
local list = {}
if res then
for i = 1, #res[1] do
local md5 = res[2][i]
local title = res[3][i]
local authors = res[4][i]
local entry = by_md5[md5]
if not entry then
entry = { md5 = md5, ids = {}, last_open = 0, _variant_seen = {}, _variant_count = 0 }
by_md5[md5] = entry
table.insert(list, entry)
end
applyRow(entry, tonumber(res[1][i]), title, authors, tonumber(res[5][i]) or 0)
end
end
for _, entry in ipairs(list) do
finalizeEntry(entry)
end
return list
end)
end
-- Returns { last_open, title, authors, metadata_ambiguous } for one md5,
-- merged the same way as getBooks(). Used by live single-book sync before
-- match-check.
function BookOrbitStatsReader.getBook(md5)
if type(md5) ~= "string" or not md5:match("^%x+$") then return nil end
return withConn(function(conn)
local res = conn:exec(string.format("SELECT title, authors, last_open FROM book WHERE md5 = '%s';", md5))
if not res then return nil end
local book = { ids = {}, last_open = 0, _variant_seen = {}, _variant_count = 0 }
for i = 1, #res[1] do
local title = res[1][i]
local authors = res[2][i]
applyRow(book, i, title, authors, tonumber(res[3][i]) or 0)
end
if not book.title and not book.authors and book.last_open == 0 then return nil end
finalizeEntry(book)
book.ids = nil
return book
end)
end
-- Returns the stat book row ids sharing one md5 (the stats unique key is
-- title+authors+md5, so a single file can map to several rows).
function BookOrbitStatsReader.getBookIds(md5)
if type(md5) ~= "string" or not md5:match("^%x+$") then return {} end
local result = withConn(function(conn)
local res = conn:exec(string.format("SELECT id FROM book WHERE md5 = '%s';", md5))
local ids = {}
if res then
for i = 1, #res[1] do
table.insert(ids, tonumber(res[1][i]))
end
end
return ids
end)
return result or {}
end
-- Returns events newer than the watermark across all stat row ids of a book,
-- ordered by start_time. The caller handles batching via the limit.
function BookOrbitStatsReader.getEventsAfter(ids, watermark, limit)
if not ids or #ids == 0 then return {} end
return withConn(function(conn)
local id_list = {}
for _, id in ipairs(ids) do
table.insert(id_list, string.format("%d", id))
end
local sql = string.format(
"SELECT page, start_time, duration, total_pages FROM page_stat_data"
.. " WHERE id_book IN (%s) AND start_time > %d AND total_pages > 0"
.. " ORDER BY start_time, page LIMIT %d;",
table.concat(id_list, ","),
watermark or 0,
limit
)
local res = conn:exec(sql)
local events = {}
if res then
for i = 1, #res[1] do
table.insert(events, {
page = tonumber(res[1][i]) or 0,
startTime = tonumber(res[2][i]) or 0,
durationSeconds = math.min(tonumber(res[3][i]) or 0, 86400),
totalPages = tonumber(res[4][i]) or 1,
})
end
end
return events
end)
end
return BookOrbitStatsReader