forked from akai/readest
35b02c4efc
Supersedes #3156. Adds a reading-statistics system whose canonical data model is KOReader's own (book + page_stat_data), so stats round-trip losslessly between Readest and KOReader. - Storage: a cross-platform Turso statistics.db in KOReader's exact schema (web/Workers, desktop, iOS, Android) — replacing #3156's Node-only better-sqlite3 + statistics.json. - Tracking: per-page reading events (time-on-page, idle-capped) flushed on page-change/idle/hide/close — the KOReader model — not session aggregates. - Sync: legacy /api/sync extended with a stats type backed by self-contained Supabase tables (stat_books, stat_pages); union/longer-duration-wins merge keyed on book_hash. apps/readest.koplugin syncs through the same endpoint. - Scale & robustness: per-tab singleton connection (avoids OPFS lock conflicts) + explicit WAL checkpoint; transactional bulk apply; chunked resumable push; client-driven paged pull with trailing-ms completion; paginated/scoped server merge. Verified: 5668 unit tests, 155 koplugin busted tests, biome+tsgo + luacheck all green; web OPFS DB verified live. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
130 lines
5.2 KiB
Lua
130 lines
5.2 KiB
Lua
local DataStorage = require("datastorage")
|
|
local InfoMessage = require("ui/widget/infomessage")
|
|
local UIManager = require("ui/uimanager")
|
|
local SQ3 = require("lua-ljsqlite3/init")
|
|
local _ = require("readest_i18n")
|
|
|
|
local SyncStats = {}
|
|
|
|
local function db_path()
|
|
return DataStorage:getSettingsDir() .. "/statistics.sqlite3"
|
|
end
|
|
|
|
-- Read book md5/title/authors + page events with start_time > cursor.
|
|
function SyncStats:collectSince(cursor)
|
|
local conn = SQ3.open(db_path())
|
|
local books, pages, seen = {}, {}, {}
|
|
local stmt = conn:prepare([[
|
|
SELECT b.md5, b.title, b.authors, p.page, p.start_time, p.duration, p.total_pages
|
|
FROM page_stat_data p JOIN book b ON b.id = p.id_book
|
|
WHERE p.start_time > ? ORDER BY p.start_time ASC]])
|
|
stmt:reset():bind(tonumber(cursor) or 0)
|
|
local row = stmt:step()
|
|
while row ~= nil do
|
|
local md5 = row[1]
|
|
if md5 and not seen[md5] then
|
|
seen[md5] = true
|
|
table.insert(books, { book_hash = md5, title = row[2] or "", authors = row[3] or "" })
|
|
end
|
|
table.insert(pages, {
|
|
book_hash = md5,
|
|
page = tonumber(row[4]),
|
|
start_time = tonumber(row[5]),
|
|
duration = tonumber(row[6]),
|
|
total_pages = tonumber(row[7]),
|
|
})
|
|
row = stmt:step()
|
|
end
|
|
stmt:close()
|
|
conn:close()
|
|
return books, pages
|
|
end
|
|
|
|
-- Upsert pulled rows into the local statistics.sqlite3 (union / longer-duration).
|
|
function SyncStats:applyRemote(books, pages)
|
|
local conn = SQ3.open(db_path())
|
|
conn:exec("BEGIN;")
|
|
local insert_book = conn:prepare("INSERT OR IGNORE INTO book (title, authors, md5) VALUES (?, ?, ?);")
|
|
for _, b in ipairs(books or {}) do
|
|
insert_book:reset():bind(b.title or "", b.authors or "", b.book_hash):step()
|
|
end
|
|
insert_book:close()
|
|
local find_id = conn:prepare("SELECT id FROM book WHERE md5 = ? LIMIT 1;")
|
|
local insert_page = conn:prepare([[
|
|
INSERT INTO page_stat_data (id_book, page, start_time, duration, total_pages)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT(id_book, page, start_time)
|
|
DO UPDATE SET duration = max(duration, excluded.duration), total_pages = excluded.total_pages;]])
|
|
local id_cache = {}
|
|
local touched = {}
|
|
for _, p in ipairs(pages or {}) do
|
|
local id = id_cache[p.book_hash]
|
|
if not id then
|
|
local r = find_id:reset():bind(p.book_hash):step()
|
|
if r ~= nil then id = tonumber(r[1]); id_cache[p.book_hash] = id end
|
|
end
|
|
if id then
|
|
insert_page:reset():bind(id, p.page, p.start_time, p.duration, p.total_pages):step()
|
|
touched[id] = true
|
|
end
|
|
end
|
|
find_id:close()
|
|
insert_page:close()
|
|
-- Mirror the Readest app's recomputeBookTotals so a KOReader device shows
|
|
-- fresh totals right after a pull (id is a trusted integer from the DB).
|
|
for id in pairs(touched) do
|
|
conn:exec(string.format([[
|
|
UPDATE book SET
|
|
total_read_time = COALESCE((SELECT SUM(duration) FROM page_stat_data WHERE id_book = %d), 0),
|
|
total_read_pages = COALESCE((SELECT COUNT(DISTINCT page) FROM page_stat_data WHERE id_book = %d), 0),
|
|
last_open = COALESCE((SELECT MAX(start_time + duration) FROM page_stat_data WHERE id_book = %d), last_open)
|
|
WHERE id = %d;]], id, id, id, id))
|
|
end
|
|
conn:exec("COMMIT;")
|
|
conn:close()
|
|
end
|
|
|
|
function SyncStats:push(settings, client, interactive)
|
|
local cursor = settings:readSetting("stats_push_cursor") or 0
|
|
local books, pages = self:collectSince(cursor)
|
|
if #pages == 0 then return end
|
|
local max_start = cursor
|
|
for _, p in ipairs(pages) do if p.start_time > max_start then max_start = p.start_time end end
|
|
client:pushChanges(
|
|
{ statBooks = books, statPages = pages },
|
|
function(success)
|
|
if success then
|
|
settings:saveSetting("stats_push_cursor", max_start)
|
|
elseif interactive then
|
|
UIManager:show(InfoMessage:new{ text = _("Failed to push reading statistics"), timeout = 2 })
|
|
end
|
|
end)
|
|
end
|
|
|
|
function SyncStats:pull(settings, client, interactive, logout_fn)
|
|
local since = settings:readSetting("stats_pull_cursor") or 0
|
|
-- pullChanges requires since/type/book/meta_hash params (readest-sync-api.json).
|
|
client:pullChanges(
|
|
{ since = since, type = "stats", book = "", meta_hash = "" },
|
|
function(success, response, status)
|
|
if not success then
|
|
if status == 401 or status == 403 then
|
|
if logout_fn then logout_fn() end
|
|
end
|
|
if interactive then
|
|
UIManager:show(InfoMessage:new{ text = _("Failed to pull reading statistics"), timeout = 2 })
|
|
end
|
|
return
|
|
end
|
|
self:applyRemote(response.statBooks, response.statPages)
|
|
local newest = since
|
|
for _, p in ipairs(response.statPages or {}) do
|
|
local u = tonumber(p.updated_at_ms) or 0
|
|
if u > newest then newest = u end
|
|
end
|
|
if newest > since then settings:saveSetting("stats_pull_cursor", newest) end
|
|
end)
|
|
end
|
|
|
|
return SyncStats
|