Files
readest/apps/readest.koplugin/library/librarystore.lua
T
Huang Xin 0b180da6a6 fix(koplugin): key library pull cursor on synced_at to stop stale library (#4934) (#4944)
The KOReader plugin's incremental books pull went permanently stale: after
a while it stopped receiving any updates made from other devices, and only
deleting readest_library.sqlite3 + "Pull books now" recovered it (until it
re-broke). The iOS/web library was unaffected.

Root cause: since #4678 the server keys the books pull on the server-stamped
synced_at column, and the web client (computeMaxTimestamp) advances its
cursor from synced_at. The koplugin was left on updated_at: pullBooks
advanced last_books_pulled_at from max(updated_at, deleted_at). updated_at
is client-supplied, and the koplugin bumps it from the device clock
(touchBook = os.time()*1000). An e-reader clock ahead of the server (or any
row anywhere carrying a future updated_at) drove the cursor past server-now,
so the server's synced_at > since filter returned nothing forever.

The cursor was also shared between the pull side (compared vs server
synced_at) and push-delta detection (getChangedBooks vs local updated_at),
so it could not simply be retargeted.

Fix:
- parseSyncRow reads synced_at; new row_pull_cursor() prefers it and falls
  back to max(updated_at, deleted_at) for a pre-synced_at server, mirroring
  computeMaxTimestamp.
- Split the cursor: last_books_pulled_at now tracks server synced_at (pull
  only); new last_books_pushed_at tracks local updated_at for getChangedBooks
  and is advanced on both pull and push, preserving push dedup.
- v2 -> v3 migration seeds the push watermark from the old shared value and
  resets the pull cursor to 0, auto-healing already-stale installs with one
  full re-pull (no manual sqlite deletion) and no re-push storm.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 17:52:01 +02:00

861 lines
34 KiB
Lua

-- librarystore.lua
-- SQLite-backed book index for the Library view. Merges Readest cloud books
-- (from /sync) with KOReader local books (from sidecar walks + ReadHistory)
-- via the partial-md5 hash that both sides already use.
--
-- All queries are scoped by user_id (composite PK with hash) so signing into
-- a different Readest account doesn't surface the previous user's books.
--
-- See apps/readest.koplugin/docs/library-design.md for the full schema and
-- contract notes; spec/library/librarystore_spec.lua is the canonical
-- behavioral spec.
local SQ3 = require("lua-ljsqlite3/init")
local json = require("json")
local SCHEMA_VERSION = 3
local SCHEMA_SQL = [[
CREATE TABLE IF NOT EXISTS books (
user_id TEXT NOT NULL,
hash TEXT NOT NULL,
meta_hash TEXT,
title TEXT NOT NULL,
source_title TEXT,
author TEXT,
format TEXT,
metadata_json TEXT,
series TEXT,
series_index REAL,
group_id TEXT,
group_name TEXT,
cover_path TEXT,
file_path TEXT,
cloud_present INTEGER NOT NULL DEFAULT 0,
local_present INTEGER NOT NULL DEFAULT 0,
uploaded_at INTEGER,
progress_lib TEXT,
reading_status TEXT,
reading_status_updated_at INTEGER,
last_read_at INTEGER,
created_at INTEGER,
updated_at INTEGER,
deleted_at INTEGER,
PRIMARY KEY (user_id, hash)
);
CREATE INDEX IF NOT EXISTS books_user_updated ON books(user_id, updated_at DESC);
CREATE INDEX IF NOT EXISTS books_user_lastread ON books(user_id, last_read_at DESC);
CREATE INDEX IF NOT EXISTS books_user_meta ON books(user_id, meta_hash);
CREATE INDEX IF NOT EXISTS books_user_group ON books(user_id, group_name);
CREATE INDEX IF NOT EXISTS books_user_author ON books(user_id, author);
CREATE TABLE IF NOT EXISTS sync_state (
user_id TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT,
PRIMARY KEY (user_id, key)
);
]]
-- All columns we round-trip in the books row, in insert order.
local BOOK_COLS = {
"user_id", "hash", "meta_hash", "title", "source_title", "author",
"format", "metadata_json", "series", "series_index", "group_id",
"group_name", "cover_path", "file_path", "cloud_present",
"local_present", "uploaded_at", "progress_lib", "reading_status",
"reading_status_updated_at", "last_read_at", "created_at", "updated_at", "deleted_at",
}
local BOOK_COL_INDEX = {}
for i, c in ipairs(BOOK_COLS) do BOOK_COL_INDEX[c] = i end
-- Integer/real columns that lua-ljsqlite3 returns as int64_t / double cdata.
-- We tonumber() these on row read so consumers can do arithmetic and
-- string concat without worrying about cdata. Unix-ms timestamps fit
-- well within Lua's 53-bit double mantissa.
local NUMERIC_COLS = {
series_index = true, cloud_present = true, local_present = true,
uploaded_at = true, reading_status_updated_at = true, last_read_at = true,
created_at = true, updated_at = true, deleted_at = true,
}
local function row_to_table(raw)
local out = {}
for i, col in ipairs(BOOK_COLS) do
local v = raw[i]
if v ~= nil and NUMERIC_COLS[col] then
v = tonumber(v)
end
out[col] = v
end
return out
end
-- Allowed sort columns. listBooks accepts only these to keep SQL safe from
-- injection via filters.sort_by.
local SORT_WHITELIST = {
updated_at = true,
last_read_at = true,
title = true,
author = true,
created_at = true,
series = true,
format = true,
}
-- Allowed group_by columns. Must match a real column name.
local GROUP_WHITELIST = {
author = true,
series = true,
group_name = true,
}
-- A book is shown in the Library only when its file is actually reachable:
-- either uploaded to Readest cloud (uploaded_at set, so the file + its cover
-- can be downloaded) or present on this device (local_present = 1).
--
-- A bare cloud *record* with no uploaded file (cloud_present = 1 but
-- uploaded_at NULL) has no cover and cannot be opened, so showing it is
-- meaningless. This mirrors Readest, which only adds a synced book to the
-- library when uploadedAt is set, and keeps locally-imported books that
-- carry a downloadedAt — see useBooksSync.updateLibrary at
-- apps/readest-app/src/app/library/hooks/useBooksSync.ts:136-139.
local VISIBLE_BOOK_SQL = "(uploaded_at IS NOT NULL OR local_present = 1)"
local M = {}
M.__index = M
-- ---------------------------------------------------------------------------
-- Construction
-- ---------------------------------------------------------------------------
-- opts:
-- user_id (required, string) — currently-authenticated Readest user.id
-- db_path (optional, string) — defaults to ":memory:" for tests
function M.new(opts)
assert(opts and type(opts.user_id) == "string" and opts.user_id ~= "",
"LibraryStore.new requires opts.user_id")
local self = setmetatable({}, M)
self.user_id = opts.user_id
self.db_path = opts.db_path or ":memory:"
self.db = SQ3.open(self.db_path)
-- Read version before creating tables; getUserVersion uses rowexec which
-- may leave an open iterator in some SQLite bindings, so use prepare/step.
local prev_stmt = self.db:prepare("PRAGMA user_version;")
local prev_row = prev_stmt:reset():step()
prev_stmt:close()
local prev = prev_row and tonumber(prev_row[1]) or 0
self.db:exec(SCHEMA_SQL)
-- v1 -> v2: add reading_status_updated_at to existing DBs. CREATE TABLE
-- IF NOT EXISTS won't add a column, so ALTER it in (pcall guards a DB that
-- somehow already has the column).
if prev >= 1 and prev < 2 then
pcall(function()
self.db:exec("ALTER TABLE books ADD COLUMN reading_status_updated_at INTEGER;")
end)
end
-- v2 -> v3: split the shared books cursor into a pull cursor (server
-- synced_at) and a push watermark (local updated_at). The old shared
-- `last_books_pulled_at` was advanced from client updated_at, which a
-- device with a fast clock could push into the future, starving every
-- later synced_at-keyed pull so the library went stale and never
-- recovered (issue #4934). Seed the new push watermark from the old value
-- (local-change detection is unbroken), then zero the pull cursor so the
-- next sync re-establishes it on the server's synced_at clock.
if prev >= 1 and prev < 3 then
self.db:exec([[
INSERT INTO sync_state (user_id, key, value)
SELECT user_id, 'last_books_pushed_at', value
FROM sync_state WHERE key = 'last_books_pulled_at'
ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value;
UPDATE sync_state SET value = '0' WHERE key = 'last_books_pulled_at';
]])
end
if prev < SCHEMA_VERSION then
self.db:exec(string.format("PRAGMA user_version = %d;", SCHEMA_VERSION))
end
self._groups_cache = {}
return self
end
function M:close()
if self.db then self.db:close(); self.db = nil end
end
function M:getUserVersion()
local v = self.db:rowexec("PRAGMA user_version;")
return tonumber(v)
end
-- ---------------------------------------------------------------------------
-- Sync state per (user_id, key)
-- ---------------------------------------------------------------------------
function M:getLastPulledAt()
local stmt = self.db:prepare(
"SELECT value FROM sync_state WHERE user_id = ? AND key = ?")
local row = stmt:reset():bind(self.user_id, "last_books_pulled_at"):step()
stmt:close()
if not row then return nil end
return tonumber(row[1])
end
function M:setLastPulledAt(ts)
local stmt = self.db:prepare([[
INSERT INTO sync_state (user_id, key, value) VALUES (?, ?, ?)
ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value
]])
stmt:reset():bind(self.user_id, "last_books_pulled_at", tostring(ts)):step()
stmt:close()
end
-- The push watermark: max local updated_at | deleted_at we've already
-- accounted for (pushed to the server or pulled from it). getChangedBooks
-- keys on this so a book already on the server isn't re-pushed. It is kept
-- SEPARATE from the pull cursor (last_books_pulled_at, server synced_at)
-- because the two live on different clocks — mixing them starved pulls in
-- issue #4934.
function M:getLastPushedAt()
local stmt = self.db:prepare(
"SELECT value FROM sync_state WHERE user_id = ? AND key = ?")
local row = stmt:reset():bind(self.user_id, "last_books_pushed_at"):step()
stmt:close()
if not row then return nil end
return tonumber(row[1])
end
function M:setLastPushedAt(ts)
local stmt = self.db:prepare([[
INSERT INTO sync_state (user_id, key, value) VALUES (?, ?, ?)
ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value
]])
stmt:reset():bind(self.user_id, "last_books_pushed_at", tostring(ts)):step()
stmt:close()
end
-- ---------------------------------------------------------------------------
-- upsertBook
-- ---------------------------------------------------------------------------
-- Merges a row by (user_id, hash). Flags `cloud_present` and `local_present`
-- are OR-merged with the existing row UNLESS the caller passes the
-- `_force_cloud_present` sentinel, in which case the supplied value is
-- written verbatim (used for cloud-tombstone updates that must clear the
-- flag).
--
-- Sentinels:
-- _force_cloud_present = true → caller's cloud_present overrides OR-merge.
-- _clear_fields = { "deleted_at", ... } → after the preserve-existing pass,
-- these columns are explicitly nulled. Lets a caller un-tombstone a row
-- by passing nil (which would otherwise be indistinguishable from "not
-- provided" since Lua tables drop nil values).
function M:upsertBook(row)
assert(row and type(row.hash) == "string" and row.hash ~= "",
"upsertBook requires row.hash")
assert(row.title, "upsertBook requires row.title")
local existing = self:_getRowRaw(row.hash)
local merged = {}
for k in pairs(BOOK_COL_INDEX) do
merged[k] = row[k]
end
merged.user_id = self.user_id
merged.hash = row.hash
if existing then
-- OR-merge cloud_present unless explicit override
if not row._force_cloud_present then
merged.cloud_present = math.max(
tonumber(existing.cloud_present) or 0,
tonumber(merged.cloud_present) or 0)
else
merged.cloud_present = tonumber(row.cloud_present) or 0
end
-- OR-merge local_present always (no use case for force-clearing yet)
merged.local_present = math.max(
tonumber(existing.local_present) or 0,
tonumber(merged.local_present) or 0)
-- Preserve fields the caller didn't provide
for k in pairs(BOOK_COL_INDEX) do
if merged[k] == nil and existing[k] ~= nil then
merged[k] = existing[k]
end
end
-- Explicit clears: applied after preserve so they win.
if row._clear_fields then
for _, col in ipairs(row._clear_fields) do
merged[col] = nil
end
end
else
merged.cloud_present = tonumber(merged.cloud_present) or 0
merged.local_present = tonumber(merged.local_present) or 0
end
-- Build INSERT … ON CONFLICT … DO UPDATE … with positional params.
local placeholders = {}
local update_setters = {}
for i, col in ipairs(BOOK_COLS) do
placeholders[i] = "?"
if col ~= "user_id" and col ~= "hash" then
update_setters[#update_setters + 1] = col .. " = excluded." .. col
end
end
local sql = string.format([[
INSERT INTO books (%s) VALUES (%s)
ON CONFLICT(user_id, hash) DO UPDATE SET %s
]], table.concat(BOOK_COLS, ", "),
table.concat(placeholders, ", "),
table.concat(update_setters, ", "))
local stmt = self.db:prepare(sql)
stmt:reset()
for i, col in ipairs(BOOK_COLS) do
stmt:bind1(i, merged[col])
end
stmt:step()
stmt:close()
-- Cached groupings stale after any insert/update.
self._groups_cache = {}
end
-- ---------------------------------------------------------------------------
-- getChangedBooks(since) — returns every row whose updated_at OR deleted_at
-- exceeds the watermark. Mirrors useBooksSync.getNewBooks at
-- apps/readest-app/src/app/library/hooks/useBooksSync.ts:22-35.
-- Used by the auto-sync push path on book close to send deltas.
-- ---------------------------------------------------------------------------
function M:getChangedBooks(since)
since = tonumber(since) or 0
local stmt = self.db:prepare(string.format([[
SELECT %s FROM books
WHERE user_id = ?
AND (updated_at > ? OR (deleted_at IS NOT NULL AND deleted_at > ?))
ORDER BY updated_at ASC
]], table.concat(BOOK_COLS, ", ")))
stmt:reset():bind(self.user_id, since, since)
local rows = {}
while true do
local r = stmt:step()
if not r then break end
rows[#rows + 1] = row_to_table(r)
end
stmt:close()
return rows
end
-- ---------------------------------------------------------------------------
-- touchBook(hash, fields) — update updated_at + last_read_at to "now",
-- merge in any other fields the caller passes (commonly progress_lib),
-- return the resulting row (or nil if the book isn't in the index).
--
-- This is the local-write half of "after open/close, sync to server"; the
-- caller composes touchBook + syncbooks.pushBook to mirror what
-- Readest web does in updateBookProgress + the books-table sync push
-- (apps/readest-app/src/store/libraryStore.ts:105-122).
-- ---------------------------------------------------------------------------
function M:touchBook(hash, fields)
if not hash or hash == "" then return nil end
local existing = self:_getRowRaw(hash)
if not existing then return nil end
local now = os.time() * 1000
local merge = {
hash = hash,
title = existing.title,
updated_at = now,
last_read_at = now,
}
if fields then for k, v in pairs(fields) do merge[k] = v end end
self:upsertBook(merge)
return self:_getRowRaw(hash)
end
-- Internal: fetch a row by hash for the current user, returned as a table
-- keyed by column name. Exposed (with leading underscore) for spec checks.
function M:_getRowRaw(hash)
local stmt = self.db:prepare(string.format(
"SELECT %s FROM books WHERE user_id = ? AND hash = ?",
table.concat(BOOK_COLS, ", ")))
local row = stmt:reset():bind(self.user_id, hash):step()
stmt:close()
if not row then return nil end
return row_to_table(row)
end
-- ---------------------------------------------------------------------------
-- listBooks
-- ---------------------------------------------------------------------------
-- filters: { search, sort_by, sort_asc, group_by, group_filter }
function M:listBooks(filters)
filters = filters or {}
local where = {
"user_id = ?",
"deleted_at IS NULL",
VISIBLE_BOOK_SQL,
}
local args = { self.user_id }
if filters.search and filters.search ~= "" then
where[#where + 1] = "(LOWER(COALESCE(title, '')) LIKE ? OR LOWER(COALESCE(author, '')) LIKE ?)"
local needle = "%" .. string.lower(filters.search) .. "%"
args[#args + 1] = needle
args[#args + 1] = needle
end
if filters.group_by and filters.group_filter then
local col = GROUP_WHITELIST[filters.group_by] and filters.group_by
if col then
where[#where + 1] = col .. " = ?"
args[#args + 1] = filters.group_filter
end
end
-- "Books at this shelf level with no group value" — used by the
-- bookshelf composer for the root view of group_by=author/series/group_name.
if filters.ungrouped_col and GROUP_WHITELIST[filters.ungrouped_col] then
local col = filters.ungrouped_col
where[#where + 1] = "(" .. col .. " IS NULL OR " .. col .. " = '')"
end
local sort_by = SORT_WHITELIST[filters.sort_by] and filters.sort_by or "last_read_at"
local sort_dir = filters.sort_asc and "ASC" or "DESC"
-- "Date Read" semantics in this plugin = "any recent activity" (the
-- web's Updated + Date Read concepts merged earlier). Prefer
-- updated_at when present so a metadata bump (e.g. "Add to Readest"
-- dedupe re-stamping updated_at) floats the row to the top even
-- when last_read_at is older. Falls back to last_read_at for the
-- rare row that has only the read timestamp (no updated_at).
local sort_expr = (sort_by == "last_read_at")
and "COALESCE(updated_at, last_read_at)"
or sort_by
local sql = string.format(
"SELECT %s FROM books WHERE %s ORDER BY %s %s, hash ASC",
table.concat(BOOK_COLS, ", "),
table.concat(where, " AND "),
sort_expr, sort_dir)
local stmt = self.db:prepare(sql)
stmt:reset()
for i, v in ipairs(args) do stmt:bind1(i, v) end
local rows = {}
while true do
local r = stmt:step()
if not r then break end
rows[#rows + 1] = row_to_table(r)
end
stmt:close()
return rows
end
-- ---------------------------------------------------------------------------
-- listCloudOnlyBooks — the bulk-download candidate set (#4751)
-- ---------------------------------------------------------------------------
-- Books that are in the cloud with a downloadable file but not yet on this
-- device: cloud_present = 1, local_present = 0, not deleted, and with an
-- uploaded_at (a phantom record without an uploaded file is unreachable, so
-- it's excluded just like listBooks excludes it). Returns full rows so the
-- caller can hand each straight to syncbooks.downloadBook. Ordered newest
-- first for a sensible progress sequence; hash ASC tiebreak for determinism.
function M:listCloudOnlyBooks()
local sql = string.format([[
SELECT %s FROM books
WHERE user_id = ?
AND deleted_at IS NULL
AND cloud_present = 1
AND local_present = 0
AND uploaded_at IS NOT NULL
ORDER BY COALESCE(updated_at, created_at) DESC, hash ASC
]], table.concat(BOOK_COLS, ", "))
local stmt = self.db:prepare(sql)
stmt:reset()
stmt:bind1(1, self.user_id)
local rows = {}
while true do
local r = stmt:step()
if not r then break end
rows[#rows + 1] = row_to_table(r)
end
stmt:close()
return rows
end
-- ---------------------------------------------------------------------------
-- getGroups
-- ---------------------------------------------------------------------------
-- Returns array of { name, count, latest_updated_at, latest_last_read_at,
-- latest_created_at } sorted by name. The per-sort aggregates let the
-- caller interleave groups and books in the merged shelf list using each
-- group's "most recent child" value (parity with Readest's
-- getGroupSortValue at apps/readest-app/src/app/library/utils/libraryUtils.ts:381-387).
-- Memoized per (user_id, group_by); invalidated by upsertBook.
function M:getGroups(group_by)
if not GROUP_WHITELIST[group_by] then return {} end
local cached = self._groups_cache[group_by]
if cached then return cached end
local sql = string.format([[
SELECT %s AS name,
COUNT(*) AS cnt,
MAX(updated_at) AS latest_updated,
MAX(COALESCE(updated_at, last_read_at)) AS latest_last_read,
MAX(created_at) AS latest_created
FROM books
WHERE user_id = ? AND deleted_at IS NULL
AND %s
AND %s IS NOT NULL AND %s != ''
GROUP BY %s
ORDER BY name ASC
]], group_by, VISIBLE_BOOK_SQL, group_by, group_by, group_by)
local stmt = self.db:prepare(sql)
stmt:reset():bind1(1, self.user_id)
local out = {}
while true do
local r = stmt:step()
if not r then break end
out[#out + 1] = {
name = r[1],
count = tonumber(r[2]),
latest_updated_at = tonumber(r[3]),
latest_last_read_at = tonumber(r[4]),
latest_created_at = tonumber(r[5]),
}
end
stmt:close()
self._groups_cache[group_by] = out
return out
end
-- ---------------------------------------------------------------------------
-- listBookshelfGroups(group_by, parent_path)
-- ---------------------------------------------------------------------------
-- Returns the group entries shown at the current shelf level, mirroring
-- Readest's library nav model:
-- author/series — flat groups; parent_path is ignored (only meaningful
-- at root). Each entry is the existing getGroups output with display_name
-- set to the group name.
--
-- group_name — nested folders. parent_path is the folder we're inside
-- (nil = root). We emit one entry per immediate-child segment, with the
-- `name` field carrying the full slash-delimited path so the caller can
-- pass it back as parent_path for drill-in.
--
-- Each returned entry is { _group=true, name, display_name, count,
-- latest_updated_at }, sorted by display_name ASC.
function M:listBookshelfGroups(group_by, parent_path)
if not GROUP_WHITELIST[group_by] then return {} end
if group_by ~= "group_name" then
if parent_path then return {} end
local out = {}
for _i, g in ipairs(self:getGroups(group_by)) do
out[#out + 1] = {
_group = true,
name = g.name,
display_name = g.name,
count = g.count,
latest_updated_at = g.latest_updated_at,
latest_last_read_at = g.latest_last_read_at,
latest_created_at = g.latest_created_at,
}
end
return out
end
-- group_name: walk distinct group_name values and bucket by immediate
-- child segment relative to parent_path. SQLite doesn't have great
-- string-slicing primitives, but the distinct-group_name set is small
-- (one row per unique path), so a Lua-side bucket is cheap.
-- Per-sort aggregates mirror getGroups so the merged-shelf sort can
-- use a folder's "most recent child" timestamp under any sort_by.
local stmt = self.db:prepare(string.format([[
SELECT group_name,
COUNT(*) AS cnt,
MAX(updated_at) AS latest_updated,
MAX(COALESCE(updated_at, last_read_at)) AS latest_last_read,
MAX(created_at) AS latest_created
FROM books
WHERE user_id = ? AND deleted_at IS NULL
AND %s
AND group_name IS NOT NULL AND group_name != ''
GROUP BY group_name
]], VISIBLE_BOOK_SQL))
stmt:reset():bind1(1, self.user_id)
local prefix = parent_path and (parent_path .. "/") or nil
local prefix_len = prefix and #prefix or 0
local children = {} -- segment → aggregate accumulator
while true do
local r = stmt:step()
if not r then break end
local group_name = r[1]
local cnt = tonumber(r[2]) or 0
local latest_updated = tonumber(r[3]) or 0
local latest_lastread = tonumber(r[4]) or 0
local latest_created = tonumber(r[5]) or 0
local rest
if parent_path then
-- "Fantasy" with parent="Fantasy" is a direct-child book, not
-- a folder; skip from the folder list (caller picks it up via
-- listBookshelfBooks).
if group_name ~= parent_path
and group_name:sub(1, prefix_len) == prefix then
rest = group_name:sub(prefix_len + 1)
end
else
rest = group_name
end
if rest and rest ~= "" then
-- Match Readest's slashIndex > 0 semantics
-- (apps/readest-app/src/app/library/components/BookshelfItem.tsx:43-44):
-- a leading slash keeps the whole rest as the immediate-child name
-- instead of producing an empty segment.
local slash_pos = rest:find("/", 1, true)
local segment
if slash_pos and slash_pos > 1 then
segment = rest:sub(1, slash_pos - 1)
else
segment = rest
end
local entry = children[segment]
if entry then
entry.count = entry.count + cnt
entry.latest_updated = math.max(entry.latest_updated, latest_updated)
entry.latest_lastread = math.max(entry.latest_lastread, latest_lastread)
entry.latest_created = math.max(entry.latest_created, latest_created)
else
children[segment] = {
count = cnt,
latest_updated = latest_updated,
latest_lastread = latest_lastread,
latest_created = latest_created,
}
end
end
end
stmt:close()
local out = {}
for segment, data in pairs(children) do
out[#out + 1] = {
_group = true,
name = parent_path and (parent_path .. "/" .. segment) or segment,
display_name = segment,
count = data.count,
latest_updated_at = data.latest_updated,
latest_last_read_at = data.latest_lastread,
latest_created_at = data.latest_created,
}
end
table.sort(out, function(a, b) return a.display_name < b.display_name end)
return out
end
-- ---------------------------------------------------------------------------
-- listBooksInGroup(group_by, group_value, limit, opts)
-- ---------------------------------------------------------------------------
-- Returns up to `limit` books in the group. opts.sort_by + opts.sort_asc
-- mirror M:listBooks, so the cover composer picks the same first-N
-- books the user would see when drilling in. Default sort:
-- COALESCE(updated_at, last_read_at) DESC.
--
-- For group_name, matches the path itself AND any descendant path
-- (so a top-level "Fantasy" preview pulls in books from Fantasy/Tolkien
-- /LOTR even when nothing lives at the root level).
function M:listBooksInGroup(group_by, group_value, limit, opts)
if not GROUP_WHITELIST[group_by] then return {} end
opts = opts or {}
local where_extra, args
if group_by == "group_name" then
where_extra = "(group_name = ? OR group_name LIKE ?)"
args = { self.user_id, group_value, group_value .. "/%", limit }
else
where_extra = group_by .. " = ?"
args = { self.user_id, group_value, limit }
end
-- Honor the caller's current sort so the cover-preview composite
-- picks the same first-N books the user would see when drilling in.
-- Mirrors the sort_expr logic in M:listBooks above.
local sort_by = SORT_WHITELIST[opts.sort_by] and opts.sort_by or "last_read_at"
local sort_dir = opts.sort_asc and "ASC" or "DESC"
local sort_expr = (sort_by == "last_read_at")
and "COALESCE(updated_at, last_read_at)"
or sort_by
local sql = string.format([[
SELECT %s FROM books
WHERE user_id = ? AND deleted_at IS NULL
AND %s
AND %s
ORDER BY %s %s, hash ASC
LIMIT ?
]], table.concat(BOOK_COLS, ", "), VISIBLE_BOOK_SQL, where_extra, sort_expr, sort_dir)
local stmt = self.db:prepare(sql)
stmt:reset()
for i, v in ipairs(args) do stmt:bind1(i, v) end
local rows = {}
while true do
local r = stmt:step()
if not r then break end
rows[#rows + 1] = row_to_table(r)
end
stmt:close()
return rows
end
-- ---------------------------------------------------------------------------
-- listBookshelfBooks(filters, group_by, parent_path)
-- ---------------------------------------------------------------------------
-- Returns the book rows that appear directly at the current shelf level
-- (siblings of the listBookshelfGroups output, NOT recursively).
-- group_by=nil/"none" — all books matching filters
-- group_by=author/series, root — books whose author/series is null/empty
-- group_by=author/series, drill — books with col = parent_path
-- group_by=group_name, root — books with null/empty group_name
-- group_by=group_name, drill — books with group_name = parent_path
function M:listBookshelfBooks(filters, group_by, parent_path)
local sub = {}
for k, v in pairs(filters or {}) do sub[k] = v end
if not GROUP_WHITELIST[group_by] then
sub.group_by = nil
sub.group_filter = nil
return self:listBooks(sub)
end
if parent_path then
sub.group_by = group_by
sub.group_filter = parent_path
return self:listBooks(sub)
end
sub.group_by = nil
sub.group_filter = nil
sub.ungrouped_col = group_by
return self:listBooks(sub)
end
-- ---------------------------------------------------------------------------
-- parseSyncRow (pure helper, no DB access)
-- ---------------------------------------------------------------------------
-- Maps a raw /sync DB row (snake_case, ISO timestamps, JSON-string metadata)
-- to our internal row shape (fields ready for upsertBook). Returns nil for
-- the dummy initial-sync hash.
local DUMMY_HASH = "00000000000000000000000000000000"
-- ISO-8601 → unix ms. Accepts:
-- 2026-02-01T00:00:00Z
-- 2026-02-01T00:00:00+00:00 (Supabase / Postgres native; the common case)
-- 2026-02-01T00:00:00+0000
-- 2026-02-01T00:00:00.123456+00:00 (with fractional seconds)
-- 2026-02-01 00:00:00+00:00 (Postgres without the T separator)
local function iso_to_ms(s)
if not s then return nil end
if type(s) == "number" then return s end
if type(s) ~= "string" then return nil end
local y, mo, d, h, mi, sec, frac, tz = s:match(
"^(%d%d%d%d)%-(%d%d)%-(%d%d)[T ](%d%d):(%d%d):(%d%d)([%.%d]*)(.*)$")
if not y then return nil end
local t = os.time({
year = tonumber(y), month = tonumber(mo), day = tonumber(d),
hour = tonumber(h), min = tonumber(mi), sec = tonumber(sec),
isdst = false,
})
-- os.time interprets the struct as LOCAL time; convert to UTC by
-- subtracting the local TZ offset.
local utc_offset = os.difftime(t, os.time(os.date("!*t", t)))
t = t + utc_offset
-- Apply the input's own offset (Z = +00:00; "+05:30" subtracts 5.5h to
-- get UTC). Default to UTC if no offset present (server contract).
if tz and tz ~= "" and tz ~= "Z" then
local sign, oh, om = tz:match("^([%+%-])(%d%d):?(%d%d)$")
if sign then
local off = (tonumber(oh) * 3600) + (tonumber(om or 0) * 60)
if sign == "+" then t = t - off else t = t + off end
end
end
local ms = t * 1000
if frac and frac:sub(1, 1) == "." then
-- Fractional seconds: take only the first 3 digits (ms precision)
local f = frac:sub(2, 4)
if #f > 0 then
ms = ms + tonumber(f .. string.rep("0", 3 - #f))
end
end
return ms
end
function M.parseSyncRow(dbRow)
if not dbRow then return nil end
local hash = dbRow.book_hash or dbRow.hash
if not hash or hash == DUMMY_HASH then return nil end
local out = {
hash = hash,
meta_hash = dbRow.meta_hash,
title = dbRow.title or "Untitled",
source_title = dbRow.source_title,
author = dbRow.author,
format = dbRow.format,
group_id = dbRow.group_id,
group_name = dbRow.group_name,
uploaded_at = iso_to_ms(dbRow.uploaded_at),
updated_at = iso_to_ms(dbRow.updated_at),
created_at = iso_to_ms(dbRow.created_at),
deleted_at = iso_to_ms(dbRow.deleted_at),
-- Server-stamped pull cursor (issue #4678); transient — used only to
-- advance last_books_pulled_at, not persisted as a books column.
synced_at = iso_to_ms(dbRow.synced_at),
}
-- Metadata: parse JSON string OR accept an already-parsed table; extract
-- series/series_index into denormalized columns; round-trip the raw JSON
-- so callers can read other fields lazily later.
if dbRow.metadata ~= nil then
local meta
if type(dbRow.metadata) == "string" then
local ok, parsed = pcall(json.decode, dbRow.metadata)
if ok and type(parsed) == "table" then meta = parsed end
elseif type(dbRow.metadata) == "table" then
meta = dbRow.metadata
end
if meta then
out.series = meta.series
out.series_index = meta.seriesIndex
local ok, encoded = pcall(json.encode, meta)
if ok then out.metadata_json = encoded end
end
end
-- Progress: snake-case web shape is `progress = [cur, total]`.
if dbRow.progress and type(dbRow.progress) == "table" then
local ok, encoded = pcall(json.encode, dbRow.progress)
if ok then out.progress_lib = encoded end
end
-- Reading status passthrough (web side has 'unread'/'reading'/'finished')
out.reading_status = dbRow.readingStatus or dbRow.reading_status
-- ms; server sends it as a timestamptz ISO string (iso_to_ms also passes
-- through a raw number when a caller already supplied ms).
out.reading_status_updated_at = iso_to_ms(dbRow.reading_status_updated_at)
or iso_to_ms(dbRow.readingStatusUpdatedAt)
-- Cloud-presence flag: tombstones from the cloud arrive with deleted_at
-- set; the row is still useful for tracking that the cloud copy is gone,
-- but it doesn't count as cloud-present anymore. Force the flag through
-- upsertBook's OR-merge with the sentinel.
if out.deleted_at then
out.cloud_present = 0
out._force_cloud_present = true
else
out.cloud_present = 1
end
return out
end
return M