mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
80244ea4e2
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.
579 lines
20 KiB
Lua
579 lines
20 KiB
Lua
--[[--
|
|
Read-only access to KOReader's statistics.sqlite3.
|
|
|
|
One-shot helpers open a short-lived connection; the sweep instead holds a
|
|
session for its whole run through openSession(), reusing one connection and its
|
|
prepared statements. Every query runs in autocommit and no transaction is ever
|
|
opened, so a chunked phase can yield between queries without blocking the
|
|
statistics plugin's writes or WAL checkpointing. The statistics plugin owns all
|
|
writes to this database.
|
|
]]
|
|
|
|
local DataStorage = require("datastorage")
|
|
local SQ3 = require("lua-ljsqlite3/init")
|
|
local logger = require("logger")
|
|
|
|
local unpack = table.unpack or unpack
|
|
|
|
local BookOrbitStatsReader = {}
|
|
|
|
local BUSY_TIMEOUT_MS = 1000
|
|
-- One cached statement per shape. The event query's shape varies with the
|
|
-- number of statistics rows a book has, so the cache is bounded and anything
|
|
-- beyond it runs as a transient statement.
|
|
local MAX_CACHED_STATEMENTS = 16
|
|
|
|
local BOOK_ROWS_SQL = "SELECT id, md5, title, authors, last_open FROM book"
|
|
.. " WHERE md5 IS NOT NULL AND md5 != '' AND id > ? ORDER BY id LIMIT ?;"
|
|
local BOOK_BY_MD5_SQL = "SELECT id, title, authors, last_open FROM book WHERE md5 = ?;"
|
|
local BOOK_IDS_SQL = "SELECT id FROM book WHERE md5 = ?;"
|
|
local LATEST_EVENTS_SQL = "SELECT id_book, MAX(start_time) FROM page_stat_data"
|
|
.. " WHERE total_pages > 0 GROUP BY id_book;"
|
|
|
|
local function metadataKey(title, authors)
|
|
return (title or "") .. "\0" .. (authors or "")
|
|
end
|
|
|
|
local function applyRow(entry, id, title, authors, last_open)
|
|
if title == "" then title = nil end
|
|
if authors == "" then authors = nil end
|
|
table.insert(entry.ids, id)
|
|
table.insert(entry.rows, {
|
|
id = id,
|
|
title = title,
|
|
authors = authors,
|
|
last_open = last_open,
|
|
})
|
|
if not entry._variant_seen[metadataKey(title, authors)] then
|
|
entry._variant_seen[metadataKey(title, authors)] = true
|
|
entry._variant_count = entry._variant_count + 1
|
|
end
|
|
if last_open > (entry.last_open or 0) then
|
|
entry.last_open = last_open
|
|
if title then entry.title = title end
|
|
if authors then entry.authors = authors end
|
|
end
|
|
if not entry.title and title then entry.title = title end
|
|
if not entry.authors and authors then entry.authors = authors end
|
|
end
|
|
|
|
local function finalizeEntry(entry)
|
|
entry.metadata_ambiguous = (entry._variant_count or 0) > 1
|
|
entry.stats_ambiguous = #entry.ids > 1
|
|
entry._variant_seen = nil
|
|
entry._variant_count = nil
|
|
return entry
|
|
end
|
|
|
|
local function dbPath()
|
|
return DataStorage:getSettingsDir() .. "/statistics.sqlite3"
|
|
end
|
|
|
|
local function openConn()
|
|
local ok, conn = pcall(SQ3.open, dbPath(), "ro")
|
|
if not ok or not conn then
|
|
logger.dbg("BookOrbit: cannot open statistics.sqlite3:", conn)
|
|
return nil
|
|
end
|
|
pcall(conn.set_busy_timeout, conn, BUSY_TIMEOUT_MS)
|
|
return conn
|
|
end
|
|
|
|
local Session = {}
|
|
Session.__index = Session
|
|
|
|
-- Opens the shared read-only connection. A busy database is the expected
|
|
-- transient failure here, so one reopen is attempted before giving up.
|
|
function BookOrbitStatsReader.openSession()
|
|
local conn = openConn() or openConn()
|
|
if not conn then return nil, "open_failed" end
|
|
return setmetatable({ conn = conn, statements = {}, statement_count = 0 }, Session)
|
|
end
|
|
|
|
function Session:close()
|
|
for _, stmt in pairs(self.statements) do
|
|
pcall(stmt.close, stmt)
|
|
end
|
|
self.statements = {}
|
|
self.statement_count = 0
|
|
if self.conn then
|
|
pcall(self.conn.close, self.conn)
|
|
self.conn = nil
|
|
end
|
|
end
|
|
|
|
function Session:_reopen()
|
|
self:close()
|
|
self.conn = openConn()
|
|
return self.conn ~= nil
|
|
end
|
|
|
|
function Session:_select(sql, params)
|
|
local stmt = self.statements[sql]
|
|
local transient = false
|
|
if stmt then
|
|
stmt:reset()
|
|
else
|
|
stmt = self.conn:prepare(sql)
|
|
if self.statement_count < MAX_CACHED_STATEMENTS then
|
|
self.statements[sql] = stmt
|
|
self.statement_count = self.statement_count + 1
|
|
else
|
|
transient = true
|
|
end
|
|
end
|
|
if params then
|
|
stmt:bind(unpack(params))
|
|
end
|
|
local res = stmt:resultset("i")
|
|
if transient then pcall(stmt.close, stmt) end
|
|
return res
|
|
end
|
|
|
|
-- Returns the column-major result set, nil when the query matched no rows, or
|
|
-- nil plus an error when it failed after one reopen and retry.
|
|
function Session:select(sql, params)
|
|
if not self.conn then return nil, "closed" end
|
|
local ok, res = pcall(self._select, self, sql, params)
|
|
if ok then return res end
|
|
logger.dbg("BookOrbit: statistics query failed, reopening:", res)
|
|
if not self:_reopen() then return nil, "open_failed" end
|
|
local retried, retry_res = pcall(self._select, self, sql, params)
|
|
if retried then return retry_res end
|
|
logger.dbg("BookOrbit: statistics query failed:", retry_res)
|
|
return nil, "query_failed"
|
|
end
|
|
|
|
-- Keyset pagination over the primary key, so a full-library enumeration can be
|
|
-- split into bounded chunks without an offset rescan. Returns the rows and the
|
|
-- id to continue from.
|
|
function Session:bookRowsAfter(after_id, limit)
|
|
after_id = after_id or 0
|
|
local rows = {}
|
|
local res = self:select(BOOK_ROWS_SQL, { after_id, limit })
|
|
if not res then return rows, after_id end
|
|
for i = 1, #res[1] do
|
|
local id = tonumber(res[1][i]) or 0
|
|
table.insert(rows, {
|
|
id = id,
|
|
md5 = res[2][i],
|
|
title = res[3][i],
|
|
authors = res[4][i],
|
|
last_open = tonumber(res[5][i]) or 0,
|
|
})
|
|
if id > after_id then after_id = id end
|
|
end
|
|
return rows, after_id
|
|
end
|
|
|
|
function Session:book(md5)
|
|
if type(md5) ~= "string" or not md5:match("^%x+$") then return nil end
|
|
local res = self:select(BOOK_BY_MD5_SQL, { md5 })
|
|
if not res then return nil end
|
|
local book = { ids = {}, rows = {}, last_open = 0, _variant_seen = {}, _variant_count = 0 }
|
|
for i = 1, #res[1] do
|
|
applyRow(book, tonumber(res[1][i]), res[2][i], res[3][i], tonumber(res[4][i]) or 0)
|
|
end
|
|
if #book.ids == 0 then return nil end
|
|
return finalizeEntry(book)
|
|
end
|
|
|
|
function Session:bookIds(md5)
|
|
if type(md5) ~= "string" or not md5:match("^%x+$") then return {} end
|
|
local ids = {}
|
|
local res = self:select(BOOK_IDS_SQL, { md5 })
|
|
if res then
|
|
for i = 1, #res[1] do
|
|
table.insert(ids, tonumber(res[1][i]))
|
|
end
|
|
end
|
|
return ids
|
|
end
|
|
|
|
-- Latest event time per statistics row, in one query. Books whose watermark
|
|
-- already covers their latest event can then be skipped without a per-book
|
|
-- event query. Returns nil when the query failed, so callers fall back to
|
|
-- asking per book rather than silently skipping every book.
|
|
function Session:latestEventTimes()
|
|
local res, err = self:select(LATEST_EVENTS_SQL)
|
|
if err then return nil end
|
|
local latest = {}
|
|
if res then
|
|
for i = 1, #res[1] do
|
|
latest[tonumber(res[1][i]) or 0] = tonumber(res[2][i]) or 0
|
|
end
|
|
end
|
|
return latest
|
|
end
|
|
|
|
local function eventsSql(count)
|
|
local marks = {}
|
|
for _ = 1, count do
|
|
table.insert(marks, "?")
|
|
end
|
|
return "SELECT page, start_time, duration, total_pages FROM page_stat_data"
|
|
.. " WHERE id_book IN (" .. table.concat(marks, ",") .. ")"
|
|
.. " AND start_time > ? AND total_pages > 0 ORDER BY start_time, page LIMIT ?;"
|
|
end
|
|
|
|
-- Events newer than the watermark across all statistics rows of a book,
|
|
-- ordered by start_time. The caller handles batching via the limit.
|
|
function Session:eventsAfter(ids, watermark, limit)
|
|
if not ids or #ids == 0 then return {} end
|
|
local params = {}
|
|
for _, id in ipairs(ids) do
|
|
table.insert(params, id)
|
|
end
|
|
table.insert(params, watermark or 0)
|
|
table.insert(params, limit)
|
|
|
|
local events = {}
|
|
local res = self:select(eventsSql(#ids), params)
|
|
if res then
|
|
for i = 1, #res[1] do
|
|
table.insert(events, {
|
|
page = tonumber(res[1][i]) or 0,
|
|
startTime = tonumber(res[2][i]) or 0,
|
|
durationSeconds = math.min(tonumber(res[3][i]) or 0, 86400),
|
|
totalPages = tonumber(res[4][i]) or 1,
|
|
})
|
|
end
|
|
end
|
|
return events
|
|
end
|
|
|
|
-- Aggregated local reading activity for the dashboard stats strip: seconds
|
|
-- read today, over the past 7 days and per day of that week, plus the current
|
|
-- daily reading streak (consecutive days with any reading, still counting if
|
|
-- today has none yet).
|
|
function Session:readingSummary()
|
|
local today = os.date("*t")
|
|
today.hour, today.min, today.sec = 0, 0, 0
|
|
local day_start = os.time(today)
|
|
local function sumSince(start_time)
|
|
local res = self:select(
|
|
"SELECT COALESCE(SUM(MIN(duration, 86400)), 0) FROM page_stat_data WHERE start_time >= ?;",
|
|
{ start_time })
|
|
return res and tonumber(res[1][1]) or 0
|
|
end
|
|
|
|
-- Per-day totals for the strip's activity bars, oldest first, today last.
|
|
local day_seconds = { 0, 0, 0, 0, 0, 0, 0 }
|
|
local week_start = day_start - 6 * 86400
|
|
local day_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;",
|
|
{ week_start })
|
|
if day_res then
|
|
local index_by_date = {}
|
|
for day = 1, 7 do
|
|
index_by_date[os.date("%Y-%m-%d", week_start + (day - 1) * 86400)] = day
|
|
end
|
|
for i = 1, #day_res[1] do
|
|
local day = index_by_date[day_res[1][i]]
|
|
if day then day_seconds[day] = tonumber(day_res[2][i]) or 0 end
|
|
end
|
|
end
|
|
|
|
local streak = 0
|
|
local res = self:select(
|
|
"SELECT DISTINCT date(start_time, 'unixepoch', 'localtime') FROM page_stat_data WHERE start_time >= ?;",
|
|
{ day_start - 366 * 86400 })
|
|
if res then
|
|
local days = {}
|
|
for i = 1, #res[1] do
|
|
days[res[1][i]] = true
|
|
end
|
|
local cursor = day_start
|
|
if not days[os.date("%Y-%m-%d", cursor)] then
|
|
cursor = cursor - 86400
|
|
end
|
|
while days[os.date("%Y-%m-%d", cursor)] do
|
|
streak = streak + 1
|
|
cursor = cursor - 86400
|
|
end
|
|
end
|
|
|
|
return {
|
|
today_seconds = sumSince(day_start),
|
|
week_seconds = sumSince(day_start - 6 * 86400),
|
|
day_seconds = day_seconds,
|
|
streak_days = streak,
|
|
}
|
|
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
|
|
local ok, result = pcall(fn, session)
|
|
session:close()
|
|
if not ok then
|
|
logger.dbg("BookOrbit: statistics query failed:", result)
|
|
return nil, "query_failed"
|
|
end
|
|
return result
|
|
end
|
|
|
|
-- Multiple statistics rows can share one md5 (the unique key is
|
|
-- title+authors+md5), so rows are grouped per md5. The sweep enumerates in
|
|
-- chunks and therefore drives the collector itself; a group is only complete
|
|
-- once every page has been added.
|
|
function BookOrbitStatsReader.newBookCollector()
|
|
return { by_md5 = {}, list = {} }
|
|
end
|
|
|
|
function BookOrbitStatsReader.collectBookRow(collector, row)
|
|
local entry = collector.by_md5[row.md5]
|
|
if not entry then
|
|
entry = { md5 = row.md5, ids = {}, rows = {}, last_open = 0, _variant_seen = {}, _variant_count = 0 }
|
|
collector.by_md5[row.md5] = entry
|
|
table.insert(collector.list, entry)
|
|
end
|
|
applyRow(entry, row.id, row.title, row.authors, row.last_open)
|
|
return entry
|
|
end
|
|
|
|
function BookOrbitStatsReader.collectedEntries(collector)
|
|
return collector.list
|
|
end
|
|
|
|
function BookOrbitStatsReader.finalizeBookEntry(entry)
|
|
return finalizeEntry(entry)
|
|
end
|
|
|
|
-- Returns { ids, rows, last_open, title, authors, metadata_ambiguous, stats_ambiguous } for one md5.
|
|
-- Used by live single-book sync before match-check.
|
|
function BookOrbitStatsReader.getBook(md5)
|
|
return withSession(function(session)
|
|
return session:book(md5)
|
|
end)
|
|
end
|
|
|
|
-- Identity cache for the currently open book. The close and suspend handlers
|
|
-- run on a hard latency budget, so they must not open statistics.sqlite3 to
|
|
-- learn the row ids; the cache is primed once the reader is ready instead.
|
|
-- Only stable identity fields live here. A miss is normal (a first open can
|
|
-- capture before the statistics row exists) and resolves through getBookIds().
|
|
local identity_cache
|
|
|
|
function BookOrbitStatsReader.primeIdentity(md5)
|
|
if type(md5) ~= "string" or md5 == "" then return nil end
|
|
if identity_cache and identity_cache.md5 == md5 then
|
|
return identity_cache.book
|
|
end
|
|
local book = BookOrbitStatsReader.getBook(md5)
|
|
identity_cache = { md5 = md5, book = book }
|
|
return book
|
|
end
|
|
|
|
-- Returns the cached entry plus whether the digest was cached at all, so a
|
|
-- caller can tell "no statistics row" from "never looked up".
|
|
function BookOrbitStatsReader.cachedIdentity(md5)
|
|
if identity_cache and identity_cache.md5 == md5 then
|
|
return identity_cache.book, true
|
|
end
|
|
return nil, false
|
|
end
|
|
|
|
function BookOrbitStatsReader.forgetIdentity()
|
|
identity_cache = nil
|
|
end
|
|
|
|
-- Returns the stat book row ids sharing one md5 (the stats unique key is
|
|
-- title+authors+md5, so a single file can map to several rows).
|
|
function BookOrbitStatsReader.getBookIds(md5)
|
|
local result = withSession(function(session)
|
|
return session:bookIds(md5)
|
|
end)
|
|
return result or {}
|
|
end
|
|
|
|
function BookOrbitStatsReader.getReadingSummary()
|
|
return withSession(function(session)
|
|
return session:readingSummary()
|
|
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)
|
|
return session:eventsAfter(ids, watermark, limit)
|
|
end)
|
|
end
|
|
|
|
return BookOrbitStatsReader
|