local DataStorage = require("datastorage") local InfoMessage = require("ui/widget/infomessage") local UIManager = require("ui/uimanager") local SQ3 = require("lua-ljsqlite3/init") local logger = require("logger") 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) -- `settings` is the plain readest_sync data table (see main.lua:init), so -- the cursor is a field; persist by saving the whole table back to -- G_reader_settings, mirroring readest_syncauth. local cursor = settings.stats_push_cursor or 0 local books, pages = self:collectSince(cursor) logger.dbg("ReadestStats push: cursor=" .. tostring(cursor) .. " collected books=" .. #books .. " pages=" .. #pages .. " interactive=" .. tostring(interactive)) if #pages == 0 then logger.dbg("ReadestStats push: nothing to push (no page events past cursor)") if interactive then UIManager:show(InfoMessage:new{ text = _("Reading statistics are up to date"), timeout = 2 }) end 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 logger.dbg("ReadestStats push: dispatching " .. #pages .. " page(s); new cursor would be " .. tostring(max_start)) -- pushChanges declares books/notes/configs as required_params (shared /sync -- POST contract, readest-sync-api.json); include them empty so Spore sends -- the request — the server defaults each to [] and processes statBooks/ -- statPages independently (apps/readest-app/src/pages/api/sync.ts). client:pushChanges( { books = {}, notes = {}, configs = {}, statBooks = books, statPages = pages }, function(success, body, status) logger.dbg("ReadestStats push: response success=" .. tostring(success) .. " status=" .. tostring(status)) if success then settings.stats_push_cursor = max_start G_reader_settings:saveSetting("readest_sync", settings) logger.dbg("ReadestStats push: cursor advanced to " .. tostring(max_start)) if interactive then UIManager:show(InfoMessage:new{ text = _("Reading statistics pushed"), timeout = 2 }) end else logger.dbg("ReadestStats push: failed, cursor unchanged; body=" .. tostring(body)) if interactive then UIManager:show(InfoMessage:new{ text = _("Failed to push reading statistics"), timeout = 2 }) end end end) end function SyncStats:pull(settings, client, interactive, logout_fn) local since = settings.stats_pull_cursor or 0 logger.dbg("ReadestStats pull: since=" .. tostring(since) .. " interactive=" .. tostring(interactive)) -- 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) logger.dbg("ReadestStats pull: response success=" .. tostring(success) .. " status=" .. tostring(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 local nbooks = response and response.statBooks and #response.statBooks or 0 local npages = response and response.statPages and #response.statPages or 0 logger.dbg("ReadestStats pull: applying statBooks=" .. nbooks .. " statPages=" .. npages) 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.stats_pull_cursor = newest G_reader_settings:saveSetting("readest_sync", settings) logger.dbg("ReadestStats pull: cursor advanced to " .. tostring(newest)) else logger.dbg("ReadestStats pull: cursor unchanged (no newer rows)") end if interactive then local text = npages > 0 and _("Reading statistics pulled") or _("Reading statistics are up to date") UIManager:show(InfoMessage:new{ text = text, timeout = 2 }) end end) end return SyncStats