Files
bookorbit/koreader-plugin/bookorbit.koplugin/bookorbit_stats_reader.lua
T
neon bf4102e8df feat(books): support reviews, rating removal, and plugin enhancements
Add support for user-scoped personal notes (reviews) and allow clearing book ratings to null,
providing full sync capabilities for these actions in the KOReader plugin. Modularize the
KOReader plugin codebase into dedicated mixin files to improve maintainability and expand
the catalog detail view with related sections (series, author, similar books).

Closes #552
2026-07-07 04:31:53 +00:00

205 lines
7.3 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
-- Aggregated local reading activity for the dashboard stats strip: seconds
-- read today and over the past 7 days, plus the current daily reading streak
-- (consecutive days with any reading, still counting if today has none yet).
function BookOrbitStatsReader.getReadingSummary()
return withConn(function(conn)
local today = os.date("*t")
today.hour, today.min, today.sec = 0, 0, 0
local day_start = os.time(today)
local function sumSince(start_time)
local res = conn:exec(string.format(
"SELECT COALESCE(SUM(MIN(duration, 86400)), 0) FROM page_stat_data WHERE start_time >= %d;",
start_time))
return res and tonumber(res[1][1]) or 0
end
local streak = 0
local res = conn:exec(string.format(
"SELECT DISTINCT date(start_time, 'unixepoch', 'localtime') FROM page_stat_data WHERE start_time >= %d;",
day_start - 366 * 86400))
if res then
local days = {}
for i = 1, #res[1] do
days[res[1][i]] = true
end
local cursor = day_start
if not days[os.date("%Y-%m-%d", cursor)] then
cursor = cursor - 86400
end
while days[os.date("%Y-%m-%d", cursor)] do
streak = streak + 1
cursor = cursor - 86400
end
end
return {
today_seconds = sumSince(day_start),
week_seconds = sumSince(day_start - 6 * 86400),
streak_days = streak,
}
end)
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