7810981417
* 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>
177 lines
7.9 KiB
Lua
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
|