Files
readest/apps/readest.koplugin/readest_syncstats.lua
T
Huang Xin 7810981417 fix(koplugin): repair reading-stats sync push/pull (#4666)
* fix(koplugin): repair reading-stats sync push/pull

Reading statistics (statistics.sqlite3 page events) never reached the
Readest sync server. Three stacked defects in the koplugin stats path:

1. push/pull called settings:readSetting/saveSetting on self.settings,
   which is the plain readest_sync data table (not a LuaSettings object),
   so auto-sync crashed on every book open
   ("attempt to call method 'readSetting' (a nil value)").
2. pushChanges declares books/notes/configs as required_params, but the
   stats push sent only statBooks/statPages, so Spore rejected the request
   client-side ("books is required for method pushChanges").
3. statBooks/statPages were listed only under the spec's payload, not as
   optional_params. Spore's expected-param set is
   required_params + optional_params, so it rejected them too
   ("statBooks is not expected for method pushChanges").

Fixes:
- Read/persist the cursor as a plain field and save the whole table via
  G_reader_settings:saveSetting, mirroring readest_syncauth.
- Send empty books/notes/configs alongside statBooks/statPages; the server
  defaults each to [] and processes the stat arrays independently.
- Declare statBooks/statPages as optional_params in readest-sync-api.json.

Also add ReadestStats debug logging across pushBookStats/pullBookStats and
push/pull (cursor, collected counts, dispatch, response status, cursor
advance), and surface the push failure status/body that was previously
swallowed on non-interactive syncs.

Tests: cover push/pull cursor handling, the pushChanges required_params
contract, and a spec-level check that every stats payload key is an
expected pushChanges param.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(koplugin): add interactive Push/Pull stats now menu items

Add "Push stats now" and "Pull stats now" entries to the Readest sync
menu, placed after "Readest library" and before "Push books now" and
gated on being signed in (access_token + user_id) like the other
account-level items. They call pushBookStats(true) / pullBookStats(true)
for a manual interactive sync of reading statistics (the whole
statistics.sqlite3 delta), complementing the automatic pull-on-open /
push-on-close.

Interactive push/pull now also confirm their result (pushed / pulled /
up to date) to match the sibling "now" items, since a silent success
would look like a no-op.

Extract the five new strings into the koplugin .po catalogs (empty
msgstr, English fallback at runtime).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(koplugin): translate reading-stats sync strings (33 locales)

Fill the previously empty msgstr entries for the reading-statistics sync
strings across all koplugin locale catalogs: the two new "Push/Pull stats
now" menu items, the pushed / pulled / up-to-date confirmations, and the
push/pull failure messages. Each locale mirrors its existing
Push/Pull/Failed-to verbs and "reading" terminology for consistency.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 15:37:32 +02:00

177 lines
7.9 KiB
Lua

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