feat(koreader): rebuild the plugin interface and fix four-way annotation sync

Squash of feat/koreader-widget-board. KOReader plugin 2.0.0 gets the widget-board dashboard, redesigned
book details, menus, lists and dialogs, Decor page and reading companions. Highlights, notes and bookmarks
now stay in step across web, Kobo, KOReader and the phones (defects D-00 to D-20, migration 0121), and
reading progress and narration hand off cleanly between devices.
This commit is contained in:
neon
2026-10-19 21:04:23 +00:00
parent 7202a59afc
commit 80244ea4e2
349 changed files with 66312 additions and 8386 deletions
@@ -302,6 +302,144 @@ function Session:readingSummary()
}
end
-- Seconds read per local day since `since`, in one grouped query. Each event
-- is capped at a day so a reader left open overnight does not count as hours.
function Session:dailySeconds(since)
local by_day = {}
local res = self:select(
"SELECT date(start_time, 'unixepoch', 'localtime'), COALESCE(SUM(MIN(duration, 86400)), 0)"
.. " FROM page_stat_data WHERE start_time >= ? GROUP BY 1;",
{ since })
if res then
for i = 1, #res[1] do
by_day[res[1][i]] = tonumber(res[2][i]) or 0
end
end
return by_day
end
-- Minutes read in each local hour of the day since `since`, as 24 values for
-- hours 0 to 23. Each event is capped at ten minutes, as for pace.
function Session:hourMinutes(since)
local minutes = {}
for i = 1, 24 do minutes[i] = 0 end
local res = self:select(
"SELECT CAST(strftime('%H', start_time, 'unixepoch', 'localtime') AS INTEGER), COALESCE(SUM(MIN(duration, 600)), 0)"
.. " FROM page_stat_data WHERE start_time >= ? GROUP BY 1;",
{ since })
if res then
for i = 1, #res[1] do
local hour = tonumber(res[1][i])
if hour and hour >= 0 and hour < 24 then
minutes[hour + 1] = math.floor((tonumber(res[2][i]) or 0) / 60 + 0.5)
end
end
end
return minutes
end
-- Every page turn this device has recorded: the total, the turns since
-- `today_start`, and when the first was. One pass over the table.
function Session:pageTurns(today_start)
local res = self:select(
"SELECT COUNT(*), COALESCE(SUM(CASE WHEN start_time >= ? THEN 1 ELSE 0 END), 0), MIN(start_time) FROM page_stat_data;",
{ today_start })
if not res then return { total = 0, today = 0 } end
return {
total = tonumber(res[1][1]) or 0,
today = tonumber(res[2][1]) or 0,
first = tonumber(res[3][1]),
}
end
-- Reading sessions since `since`: page events grouped into a session until a
-- gap of more than ten minutes. Returns the session count, the longest and
-- average session in seconds and the pages turned per hour, or nil when
-- there is no reading. One pass over the period's events.
function Session:sessionRecords(since)
local res = self:select([[
WITH ev AS (
SELECT start_time AS s, MIN(duration, 600) AS d,
CASE WHEN start_time - LAG(start_time + MIN(duration, 600)) OVER (ORDER BY start_time) <= 600 THEN 0 ELSE 1 END AS brk
FROM page_stat_data WHERE start_time >= ?
), grouped AS (
SELECT d, SUM(brk) OVER (ORDER BY s ROWS UNBOUNDED PRECEDING) AS g FROM ev
), sessions AS (
SELECT SUM(d) AS secs, COUNT(*) AS pages FROM grouped GROUP BY g
)
SELECT COUNT(*), COALESCE(MAX(secs), 0), COALESCE(AVG(secs), 0), COALESCE(SUM(pages), 0), COALESCE(SUM(secs), 0)
FROM sessions WHERE secs >= 60;]], { since })
if not res then return nil end
local count = tonumber(res[1][1]) or 0
if count == 0 then return nil end
local total = tonumber(res[5][1]) or 0
return {
sessions = count,
longest_seconds = tonumber(res[2][1]) or 0,
average_seconds = tonumber(res[3][1]) or 0,
pages_per_hour = total > 0 and (tonumber(res[4][1]) or 0) * 3600 / total or 0,
}
end
-- How fast one book is being read: seconds per page from its own events, the
-- page count it was last read at, and seconds read over the last `days` days.
function Session:bookPace(ids, days)
if not ids or #ids == 0 then return nil end
local marks = {}
for _ = 1, #ids do table.insert(marks, "?") end
local id_list = table.concat(marks, ",")
local totals = self:select(
"SELECT COALESCE(SUM(MIN(duration, 600)), 0), COUNT(DISTINCT page), MAX(total_pages)"
.. " FROM page_stat_data WHERE id_book IN (" .. id_list .. ");", ids)
if not totals then return nil end
local seconds = tonumber(totals[1][1]) or 0
local pages = tonumber(totals[2][1]) or 0
local total_pages = tonumber(totals[3][1]) or 0
local params = {}
for _, id in ipairs(ids) do table.insert(params, id) end
table.insert(params, os.time() - (days or 14) * 86400)
local recent = self:select(
"SELECT COALESCE(SUM(MIN(duration, 600)), 0) FROM page_stat_data WHERE id_book IN ("
.. id_list .. ") AND start_time >= ?;", params)
return {
seconds_per_page = pages > 0 and seconds / pages or nil,
total_pages = total_pages,
recent_seconds = recent and tonumber(recent[1][1]) or 0,
}
end
-- One book's reading on this device: when it started and was last read, the
-- seconds and distinct days spent on it, and the furthest page reached on
-- each day (with the page count then), oldest first, for the reading path.
-- Events are capped at ten minutes, as for pace.
function Session:bookJournal(ids)
if not ids or #ids == 0 then return nil end
local marks = {}
for _ = 1, #ids do table.insert(marks, "?") end
local id_list = table.concat(marks, ",")
local totals = self:select(
"SELECT MIN(start_time), MAX(start_time), COALESCE(SUM(MIN(duration, 600)), 0),"
.. " COUNT(DISTINCT date(start_time, 'unixepoch', 'localtime'))"
.. " FROM page_stat_data WHERE id_book IN (" .. id_list .. ");", ids)
if not totals or not tonumber(totals[1][1]) then return nil end
local days = self:select(
"SELECT date(start_time, 'unixepoch', 'localtime'), MAX(page), MAX(total_pages)"
.. " FROM page_stat_data WHERE id_book IN (" .. id_list .. ") GROUP BY 1 ORDER BY 1;", ids)
local path = {}
if days then
for i = 1, #days[1] do
table.insert(path, { day = days[1][i], page = tonumber(days[2][i]) or 0, total = tonumber(days[3][i]) or 0 })
end
end
return {
first_read = tonumber(totals[1][1]),
last_read = tonumber(totals[2][1]),
seconds = tonumber(totals[3][1]) or 0,
days = tonumber(totals[4][1]) or 0,
path = path,
}
end
local function withSession(fn)
local session = BookOrbitStatsReader.openSession()
if not session then return nil, "open_failed" end
@@ -394,6 +532,42 @@ function BookOrbitStatsReader.getReadingSummary()
end)
end
function BookOrbitStatsReader.getDailySeconds(since)
return withSession(function(session)
return session:dailySeconds(since)
end)
end
function BookOrbitStatsReader.getHourMinutes(since)
return withSession(function(session)
return session:hourMinutes(since)
end)
end
function BookOrbitStatsReader.getPageTurns(today_start)
return withSession(function(session)
return session:pageTurns(today_start)
end)
end
function BookOrbitStatsReader.getSessionRecords(since)
return withSession(function(session)
return session:sessionRecords(since)
end)
end
function BookOrbitStatsReader.getBookPace(md5, days)
return withSession(function(session)
return session:bookPace(session:bookIds(md5), days)
end)
end
function BookOrbitStatsReader.getBookJournal(md5)
return withSession(function(session)
return session:bookJournal(session:bookIds(md5))
end)
end
function BookOrbitStatsReader.getEventsAfter(ids, watermark, limit)
if not ids or #ids == 0 then return {} end
return withSession(function(session)