mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
bf4102e8df
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
205 lines
7.3 KiB
Lua
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
|