feat(statistics): KOReader-compatible reading stats with cross-device sync (#4606)

Supersedes #3156. Adds a reading-statistics system whose canonical data model
is KOReader's own (book + page_stat_data), so stats round-trip losslessly
between Readest and KOReader.

- Storage: a cross-platform Turso statistics.db in KOReader's exact schema
  (web/Workers, desktop, iOS, Android) — replacing #3156's Node-only
  better-sqlite3 + statistics.json.
- Tracking: per-page reading events (time-on-page, idle-capped) flushed on
  page-change/idle/hide/close — the KOReader model — not session aggregates.
- Sync: legacy /api/sync extended with a stats type backed by self-contained
  Supabase tables (stat_books, stat_pages); union/longer-duration-wins merge
  keyed on book_hash. apps/readest.koplugin syncs through the same endpoint.
- Scale & robustness: per-tab singleton connection (avoids OPFS lock
  conflicts) + explicit WAL checkpoint; transactional bulk apply; chunked
  resumable push; client-driven paged pull with trailing-ms completion;
  paginated/scoped server merge.

Verified: 5668 unit tests, 155 koplugin busted tests, biome+tsgo + luacheck
all green; web OPFS DB verified live.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-16 16:42:39 +08:00
committed by GitHub
parent 359e406e51
commit 35b02c4efc
57 changed files with 3437 additions and 11 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,189 @@
# KOReader-compatible reading statistics with cross-device sync
- **Date:** 2026-06-15
- **Status:** Approved design — ready for implementation plan
- **Supersedes:** PR #3156 (`feat(statistics): add core session tracking (Phase 1)`) and tracking issue #3155
- **Related:** KOReader `statistics.koplugin`, Readest `apps/readest.koplugin`, legacy `/api/sync`
## 1. Summary
Add a reading-statistics system to Readest whose **canonical data model is KOReader's own**, so stats round-trip losslessly between Readest and KOReader. Stats are stored locally in a cross-platform Turso `statistics.db` (KOReader schema), tracked as per-page reading events, and synced across all of a user's devices — Readest apps **and** KOReader devices (via `apps/readest.koplugin`) — over the existing `/api/sync` transport.
This replaces PR #3156's approach (session aggregates in a `statistics.json` written with the Node-only `better-sqlite3`) which cannot run on web/mobile and is structurally incompatible with KOReader's per-page model.
## 2. Goals / Non-goals
### Goals
- Local stats persisted in a **Turso `statistics.db`** via the existing cross-platform `DatabaseService` (works on web Workers, desktop, iOS, Android).
- Local schema is **byte-for-byte KOReader-compatible** (`book`, `page_stat_data`, `page_stat` view, `numbers`).
- **Per-page event tracking** in the Readest reader (KOReader-style time-on-page with idle capping).
- **Cross-device sync** of stats through the legacy `/api/sync` endpoint, as a new `stats` type backed by a new Supabase table.
- **KOReader devices participate** via a new `readest_syncstats.lua` in `apps/readest.koplugin`, reusing the plugin's existing sync client/auth.
- Stats sync is **union/append-only** and merges contributions from every Readest and KOReader device.
- A **KOReader-compatible enrichment seam** (extension tables + a nullable `ext` field) so Readest can add richer-than-KOReader data later without breaking compatibility — **seam-only in v1, no data captured**.
### Non-goals (explicitly deferred)
- The statistics **UI page** (charts, calendar, streaks) — a later phase. This design only produces the data + sync substrate; the UI reads it via SQL.
- Reading **goals**.
- Routing stats through the HLC **replica CRDT** (`/api/sync/replicas`). Stats use the simpler legacy transport the koplugin already speaks; union-by-key needs no CRDT.
- **Capturing** enrichment data (chapter attribution, words/wpm, device). The extension seam is built (§5.6) but stays empty until the stats-UI phase.
- Migrating any PR #3156 `statistics.json` data (PR is unmerged; no field data exists).
## 3. Background
### 3.1 KOReader's model (the compatibility target)
`statistics.sqlite3` (verified against the sample DB at
`koreader-emulator-.../settings/statistics.sqlite3`):
```sql
CREATE TABLE book (
id integer PRIMARY KEY autoincrement,
title text, authors text, notes integer, last_open integer,
highlights integer, pages integer, series text, language text,
md5 text, total_read_time integer, total_read_pages integer );
CREATE UNIQUE INDEX book_title_authors_md5 ON book(title, authors, md5);
CREATE TABLE page_stat_data (
id_book integer, page integer NOT NULL DEFAULT 0,
start_time integer NOT NULL DEFAULT 0, duration integer NOT NULL DEFAULT 0,
total_pages integer NOT NULL DEFAULT 0,
UNIQUE (id_book, page, start_time),
FOREIGN KEY(id_book) REFERENCES book(id) );
CREATE INDEX page_stat_data_start_time ON page_stat_data(start_time);
CREATE TABLE numbers (number INTEGER PRIMARY KEY); -- rescale helper for the view
CREATE VIEW page_stat AS ...; -- rescales rows when total_pages changes
```
Key facts:
- The unit of truth is the **per-page read event** in `page_stat_data`: "on `page` of a book that had `total_pages`, starting at Unix-second `start_time`, you read for `duration` seconds." Events are **immutable and append-only**; `(id_book, page, start_time)` is unique.
- `book.total_read_time` / `total_read_pages` are **derived caches** of the page events.
- Book identity is `md5 = util.partialMD5(file)` — a non-uniform sampled MD5 (steps `1024 << (2*i)`, 1 KiB samples, `i = -1..10`).
- KOReader's own cross-device sync is a 3-way SQL merge of the whole `statistics.sqlite3` file over cloud storage, unioning `page_stat_data` keyed on `(title, authors, md5)``(page, start_time)`. **We reproduce the union semantics, not the file transport.**
### 3.2 Readest infrastructure we build on
- **Cross-platform Turso layer:** `appService.openDatabase(schema, path, base, opts)``DatabaseService` (`execute` / `select` / `batch` / `close`), with an auto-migration system (`migrate.ts`, `getMigrations(schema)`). Already used by `reedy.db`, `opds.db`, `library.db`. Native = `tauri-plugin-turso`; web = `@readest/turso-database-wasm` (OPFS); node = `@tursodatabase/database`.
- **`partialMD5` already equals KOReader's** (`src/utils/md5.ts::partialMD5`, byte-identical algorithm) and is already `Book.hash`. **Book identity matches KOReader with no extra work.**
- **Progress model:** `BookProgress.pageinfo = { current /*0-based*/, total }` + `location` (CFI) — enough to emit page events.
- **Legacy sync `/api/sync`:** `SyncType = 'books' | 'configs' | 'notes'`, Supabase-backed, pull by `since` timestamp (+ optional `book`/`meta_hash`), push `{ books, notes, configs }`. Records keyed by `(user_id, book_hash, meta_hash)` with `updated_at` / `deleted_at`. **This is the endpoint `apps/readest.koplugin` already uses** (`readest-sync-api.json`, `readest_syncconfig.lua`).
- **Reader lifecycle hook point:** PR #3156's `BookSessionTracker` mounts per `bookKey` (in `BooksGrid` / `ReaderContent`) and already wires idle timers + `visibilitychange`. We keep this shell and change what it records.
## 4. Architecture
```
┌─ Readest app (web/desktop/mobile) ─┐ ┌─ KOReader device ──────────┐
│ Turso statistics.db │ │ statistics.sqlite3 │
│ (KOReader schema) │ │ (native) │
│ ▲ page-event tracker │ │ ▲ KOReader writes │
└────────┼───────────────────────────┘ └─────┼──────────────────────┘
│ push/pull type=stats │ readest_syncstats.lua
└──────────────┬────────────────────────────┘ (push/pull type=stats)
/api/sync (Supabase: book_page_stats)
upsert/union by (user_id, book_hash, page, start_time)
```
- **One canonical format** (KOReader's). **One transport** (legacy `/api/sync`). **One merge rule** (union/upsert).
- **The stats payload is self-contained** — it carries both `book` metadata records and `page_stat_data` event records, exactly like KOReader's `statistics.sqlite3` bundles `book` + `page_stat_data`. No join against the `books` sync table; a stats consumer (Readest app or koplugin) can rebuild a complete `statistics.sqlite3` from the stats payload alone.
- **Everything is keyed by the stable `book_hash`, never the local autoincrement `id_book`** (which is per-device). Each device maps `book_hash` → its own local `id_book` on import.
- **Synced book metadata is trimmed to KOReader's identity triple: `md5` + `title` + `authors`.** Everything else in KOReader's `book` table is **derived locally** and never synced: `pages` = latest event's `total_pages`, `last_open` = `max(start_time + duration)`, `total_read_time` / `total_read_pages` summed from events; `series` / `language` / `notes` / `highlights` are left NULL/0 (valid KOReader schema, filled by KOReader itself when it opens the book). This keeps the local `statistics.db` a valid KOReader export while syncing only the irreducible identity.
- Derived analytics (sessions, daily summaries, streaks, hour/day heatmaps) are **SQL queries over `page_stat_data`**, computed on demand by the future UI — nothing extra stored or synced.
## 5. Component design
### 5.1 Local store — `src/services/statistics/`
- New `'statistics'` migration schema registering the KOReader DDL verbatim (book, page_stat_data, page_stat view, numbers, indexes). Registered in `getMigrations('statistics')`.
- `statisticsDb.ts`: opens `statistics.db` via `appService.openDatabase('statistics', 'statistics.db', 'Data')`; thin typed helpers:
- `upsertBook(meta) → id_book` (upsert by `(title, authors, md5)`).
- `insertPageEvent(id_book, page, start_time, duration, total_pages)` — upsert on the unique key `(id_book, page, start_time)` via `ON CONFLICT … DO UPDATE SET duration = max(duration, excluded.duration), total_pages = excluded.total_pages` (a re-flush of the same page-read with a longer duration wins; a return visit at a different `start_time` is a distinct row, as in KOReader).
- `recomputeBookTotals(id_book)`.
- `getEventsSince(ts)` / `applyRemoteEvents(events)` for sync.
- `numbers` is seeded to a fixed range (KOReader seeds 1..1000) so the `page_stat` view's rescale join works.
### 5.2 Tracker — replace PR's session logic in `BookSessionTracker.tsx`
Keep the mount-per-`bookKey` shell + idle/visibility wiring. Record **page events**, not sessions:
- State per book: `currentPage`, `pageEnterTime` (Unix s), `totalPages`.
- **Flush a page event** `(page=currentPage, start_time=pageEnterTime, duration=cappedElapsed, total_pages)` on: page change, idle timeout, tab hidden (`visibilitychange`), and book close (unmount). **No periodic tick** — every flushed event is final/immutable (its `start_time` and `duration` never change afterward), which lets cross-device sync use a simple `start_time` high-water push cursor. An idle timeout flushes-and-pauses; resuming the same page starts a fresh event with a new `start_time`.
- **Duration cap:** `duration = min(now - pageEnterTime, maxEventSeconds)`, mirroring KOReader's per-page cap (default 120 s; configurable). Events shorter than a small floor are dropped (KOReader ignores sub-second flips).
- `page = pageinfo.current + 1`, `total_pages = pageinfo.total`. Units = Unix **seconds** throughout (matches KOReader; the PR already converts).
- On first event for a book, `upsertBook` from library metadata (title/authors/pages/series/language/md5=Book.hash).
### 5.3 Sync — server (`/api/sync`, Supabase)
The stats type carries **two self-contained record sets** (mirroring KOReader's `book` + `page_stat_data`), both keyed by `book_hash`:
- `stat_books` — identity only:
`user_id, book_hash, title, authors, updated_at, deleted_at`,
primary key `(user_id, book_hash)`, index on `updated_at`. (No `pages` / `last_open` / `series` / `language` / totals — derived locally.)
- `stat_pages` — page events:
`user_id, book_hash, page, start_time, duration, total_pages, updated_at, deleted_at`,
primary key `(user_id, book_hash, page, start_time)`, index on `updated_at`.
- Extend `SyncType` with `'stats'`. GET returns `{ statBooks: [...], statPages: [...] }` changed since `since` (optional `book` filter). POST upserts both:
- `stat_pages`: `ON CONFLICT … DO UPDATE SET duration = greatest(duration, excluded.duration), total_pages = excluded.total_pages, updated_at = now()`.
- `stat_books`: `ON CONFLICT (user_id, book_hash) DO UPDATE SET title = excluded.title, authors = excluded.authors, updated_at = now()` (latest title/authors win — handles a renamed book).
- Deletes (a book's stats cleared) set `deleted_at` on both.
- No dependency on the `books` table — stats are fully self-describing.
### 5.4 Sync — Readest app client (`src/services/sync/` + statistics service)
- After a flush batch, **push** new page events **and the book-metadata record** (`type=stats`). On reader open and on a periodic timer, **pull** `since` the stored cursor → apply both `statBooks` (upsert local `book`) and `statPages` (upsert `page_stat_data`), then `recomputeBookTotals`. Reuse the existing cursor/`since` machinery used by `configs`.
- Gate behind a new `stats` entry in `syncCategories` (default **on**).
### 5.5 Sync — koplugin (`apps/readest.koplugin/readest_syncstats.lua`)
- Modeled on `readest_syncconfig.lua`; uses the existing `readest_syncclient` + auth.
- **Push:** read new `page_stat_data` rows plus each book's `md5` / `title` / `authors` from KOReader's `statistics.sqlite3` (`md5` via `partial_md5_checksum`), send both record sets as `type=stats`.
- **Pull:** fetch `since` cursor; upsert incoming `statBooks` (`md5` + `title` + `authors`) into the local `book` table — creating the row when absent, KOReader fills the rest when the book is opened — and `statPages` into `page_stat_data` (mapping `book_hash` → local `id_book`), then let KOReader recompute totals.
- Add to the plugin's sync menu + i18n (follow `/i18n-koplugin`); cover with `busted` specs.
### 5.6 Readest enrichment seam (KOReader-compatible, seam-only in v1)
Readest may later want per-event data KOReader's page-based schema can't represent (chapter/section attribution, words-read → wpm, originating device). The bridge being the **sync API, not a shared file**, makes this additive: KOReader reads only its own `statistics.sqlite3` (written by the koplugin in KOReader's exact schema), so it never sees Readest extras. v1 **builds the seam and captures nothing** — the extension columns/tables exist but stay NULL/empty until a later phase wires up the stats UI.
- **Local:** enrichment lives in **separate `readest_*` extension tables** (e.g. `readest_page_ext(book_hash, page, start_time, …)`, `readest_book_ext(book_hash, …)`) keyed by the same identity. KOReader's `book` / `page_stat_data` tables stay **byte-identical** (a literal file export stays valid too).
- **Wire:** a nullable **`ext jsonb`** column on `stat_pages` / `stat_books`. Readest reads/writes it; the koplugin **leaves it NULL and ignores incoming `ext`**.
- **Compatibility invariants:** (1) the koplugin maps only KOReader-schema fields; (2) those round-trip losslessly. Enrichment is therefore **lossless Readest↔Readest** and **dropped only at the KOReader boundary** (inherent — KOReader's schema can't hold it).
## 6. KOReader interop details
- **Book identity:** Readest `Book.hash` = `partialMD5(file)` = KOReader `md5`. The Supabase key is `book_hash`. No re-hashing.
- **Units:** Unix **seconds** for `start_time` and `duration`; same as KOReader.
- **`total_pages`/repagination:** stored per event; KOReader's `page_stat` view already rescales when `total_pages` differs, so differing pagination between sessions/engines is tolerated by the view, not by us.
- **Book-row reconstruction is self-contained:** incoming `statBooks` records carry `md5` + `title` + `authors`, so a pulling device creates its local `book` row directly from the payload — no lookup against the library or `books` sync. `pages` and `last_open` are filled from the book's page events; `series` / `language` stay NULL. The Readest app still enriches its *own* book rows from library metadata when it originates them.
## 7. Known limitations
Readest (foliate) and KOReader paginate the same EPUB differently, so per-page **coordinates differ across engines**. Consequences:
- **Total reading *time* is exact** (sum of `duration`) — the primary metric.
- Per-page heatmaps and **distinct-page counts mix coordinate systems** cross-engine and are approximate — the same approximation KOReader already lives with when `total_pages` changes (handled by the rescaling `page_stat` view). Within a single engine, fully exact.
This is documented, accepted, and not a blocker.
## 8. Testing strategy (test-first)
- **Tracker flush logic** (vitest): page-change/idle/hidden/close each emit the right `(page, start_time, duration, total_pages)`; duration cap; sub-floor drop; idle-then-resume starts a fresh event; multi-book isolation.
- **statisticsDb** (vitest, node DB service): KOReader-schema round-trip; upsert-by-key keeps longer duration; `recomputeBookTotals`; `page_stat` view returns expected rescaled rows for a known fixture.
- **Sync endpoint** (vitest): `since` filtering, upsert/union, conflict-keeps-longer-duration, delete propagation.
- **KOReader fixture parity:** import the sample `statistics.sqlite3`, assert `book` + `page_stat_data` survive a Readest export/import round-trip unchanged.
- **koplugin** (`busted`): `readest_syncstats` push/pull/merge against an in-memory `statistics.sqlite3`; `pnpm lint:lua` + `pnpm test:lua`.
- Full `pnpm test` + `pnpm lint` green before completion.
## 9. Rollout / compatibility
- PR #3156 is unmerged → no `statistics.json` migration. This design lands as the first stats implementation.
- New Supabase table + migration; additive `SyncType` — no change to existing `books`/`notes`/`configs` flows.
- `better-sqlite3`, `@types/better-sqlite3`, and the `onlyBuiltDependencies`/`statistics.json` bits from PR #3156 are dropped.
## 10. Open questions
- **Duration-cap / idle-timeout defaults:** adopt KOReader's 120 s per-page cap and a 120 s idle timeout, both configurable later. (Assumed; confirm at implementation.)
- **Stats-sync encryption:** match whatever the legacy `configs` flow does today (no new E2E layer for stats in v1). Confirm against current `/api/sync` behavior when wiring the endpoint.
@@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest';
import { pickWinningPages } from '@/pages/api/sync';
import type { StatPageRecord } from '@/libs/sync';
const mk = (start: number, duration: number): StatPageRecord => ({
book_hash: 'm',
page: 1,
start_time: start,
duration,
total_pages: 10,
});
describe('pickWinningPages', () => {
it('inserts pages the server has not seen', () => {
const { toUpsert } = pickWinningPages([mk(100, 5)], new Map());
expect(toUpsert).toHaveLength(1);
});
it('keeps the longer duration on conflict', () => {
const server = new Map([['m|1|100', mk(100, 5)]]);
const win = pickWinningPages([mk(100, 9)], server);
expect(win.toUpsert[0]!.duration).toBe(9);
});
it('drops an incoming page whose duration is not longer', () => {
const server = new Map([['m|1|100', mk(100, 9)]]);
const win = pickWinningPages([mk(100, 5)], server);
expect(win.toUpsert).toHaveLength(0);
});
});
@@ -149,7 +149,7 @@ describe('isSyncCategoryEnabled', () => {
});
describe('SYNC_CATEGORIES', () => {
test('covers all nine user-facing categories (incl. settings + credentials)', () => {
test('covers all ten user-facing categories (incl. settings + stats + credentials)', () => {
expect([...SYNC_CATEGORIES].sort()).toEqual(
[
'book',
@@ -160,6 +160,7 @@ describe('SYNC_CATEGORIES', () => {
'opds_catalog',
'progress',
'settings',
'stats',
'texture',
].sort(),
);
@@ -0,0 +1,122 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { NodeDatabaseService } from '@/services/database/nodeDatabaseService';
import { migrate } from '@/services/database/migrate';
import { getMigrations } from '@/services/database/migrations';
import type { DatabaseService } from '@/types/database';
import { StatisticsDb } from '@/services/statistics/statisticsDb';
async function freshStatsDb(): Promise<DatabaseService> {
// In-memory libsql DB; run the same migrations production uses.
const db = await NodeDatabaseService.open(':memory:');
await migrate(db, getMigrations('statistics'));
return db;
}
describe('statistics migration', () => {
let db: DatabaseService;
beforeEach(async () => {
db = await freshStatsDb();
});
it('creates KOReader book + page_stat_data tables and extension tables', async () => {
const tables = await db.select<{ name: string }>(
`SELECT name FROM sqlite_master WHERE type IN ('table','view') ORDER BY name`,
);
const names = tables.map((t) => t.name);
expect(names).toContain('book');
expect(names).toContain('page_stat_data');
expect(names).toContain('numbers');
expect(names).toContain('page_stat'); // the rescaling view
expect(names).toContain('readest_page_ext');
expect(names).toContain('readest_book_ext');
expect(names).toContain('readest_stat_sync_state');
});
it('seeds the numbers helper table 1..1000', async () => {
const rows = await db.select<{ c: number }>(`SELECT COUNT(*) AS c FROM numbers`);
expect(rows[0]!.c).toBe(1000);
});
it('enforces the page_stat_data uniqueness key', async () => {
await db.execute(`INSERT INTO book (title, authors, md5) VALUES ('T','A','m')`);
const id = (await db.select<{ id: number }>(`SELECT id FROM book LIMIT 1`))[0]!.id;
await db.execute(
`INSERT INTO page_stat_data (id_book, page, start_time, duration, total_pages) VALUES (?,?,?,?,?)`,
[id, 5, 1000, 10, 100],
);
await db.execute(
`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)`,
[id, 5, 1000, 25, 100],
);
const rows = await db.select<{ duration: number; c: number }>(
`SELECT duration, COUNT(*) OVER () AS c FROM page_stat_data`,
);
expect(rows.length).toBe(1);
expect(rows[0]!.duration).toBe(25);
});
});
describe('StatisticsDb', () => {
let stats: StatisticsDb;
beforeEach(async () => {
stats = StatisticsDb.from(await freshStatsDb());
});
it('upserts a book by md5 and returns a stable id_book', async () => {
const id1 = await stats.upsertBook({ bookMd5: 'm1', title: 'T1', authors: 'A1' });
const id2 = await stats.upsertBook({ bookMd5: 'm1', title: 'T1', authors: 'A1' });
expect(id1).toBe(id2);
});
it('inserts page events and keeps the longer duration on re-flush', async () => {
const id = await stats.upsertBook({ bookMd5: 'm1', title: 'T1', authors: 'A1' });
await stats.insertPageEvent(id, { page: 3, startTime: 100, duration: 10, totalPages: 50 });
await stats.insertPageEvent(id, { page: 3, startTime: 100, duration: 30, totalPages: 50 });
await stats.insertPageEvent(id, { page: 4, startTime: 140, duration: 12, totalPages: 50 });
await stats.recomputeBookTotals(id);
const book = await stats.getBookByMd5('m1');
expect(book!.total_read_time).toBe(42); // 30 + 12
expect(book!.total_read_pages).toBe(2); // distinct pages 3,4
expect(book!.last_open).toBe(152); // max(start_time + duration) = 140 + 12
});
it('returns events for push after a start_time cursor, joined with md5', async () => {
const id = await stats.upsertBook({ bookMd5: 'm1', title: 'T1', authors: 'A1' });
await stats.insertPageEvent(id, { page: 1, startTime: 100, duration: 5, totalPages: 9 });
await stats.insertPageEvent(id, { page: 2, startTime: 200, duration: 5, totalPages: 9 });
const { events } = await stats.getEventsForPush(150);
expect(events.map((e) => e.startTime)).toEqual([200]);
expect(events[0]!.bookMd5).toBe('m1');
});
it('applies remote events idempotently via upsert', async () => {
const remoteBooks = [{ bookMd5: 'm2', title: 'T2', authors: 'A2' }];
const remoteEvents = [
{ bookMd5: 'm2', page: 1, startTime: 300, duration: 8, totalPages: 20 },
{ bookMd5: 'm2', page: 1, startTime: 300, duration: 8, totalPages: 20 }, // dup
];
await stats.applyRemoteEvents(remoteBooks, remoteEvents);
await stats.applyRemoteEvents(remoteBooks, remoteEvents); // again — still idempotent
const book = await stats.getBookByMd5('m2');
expect(book!.total_read_time).toBe(8);
});
it('reads and writes sync cursors', async () => {
expect(await stats.getCursor('push')).toBe(0);
await stats.setCursor('push', 1234);
expect(await stats.getCursor('push')).toBe(1234);
});
it('keeps one book row per md5 even when title/authors change (no duplicates)', async () => {
const id1 = await stats.upsertBook({ bookMd5: 'm1', title: 'Old', authors: 'A' });
const id2 = await stats.upsertBook({ bookMd5: 'm1', title: 'New', authors: 'B' });
expect(id2).toBe(id1);
const book = await stats.getBookByMd5('m1');
expect(book!.title).toBe('New'); // latest title wins
// exactly one row for this md5
const rows = await stats.getEventsForPush(-1); // no events; just exercise no crash
void rows;
});
});
@@ -0,0 +1,91 @@
import { describe, it, expect, vi } from 'vitest';
import { NodeDatabaseService } from '@/services/database/nodeDatabaseService';
import { migrate } from '@/services/database/migrate';
import { getMigrations } from '@/services/database/migrations';
import { StatisticsDb } from '@/services/statistics/statisticsDb';
import { pushStats, pullStats } from '@/services/statistics/statsSync';
async function db() {
const d = await NodeDatabaseService.open(':memory:');
await migrate(d, getMigrations('statistics'));
return StatisticsDb.from(d);
}
describe('statsSync', () => {
it('pushes only events past the cursor and advances it', async () => {
const stats = await db();
const id = await stats.upsertBook({ bookMd5: 'm', title: 'T', authors: 'A' });
await stats.insertPageEvent(id, { page: 1, startTime: 100, duration: 5, totalPages: 9 });
await stats.insertPageEvent(id, { page: 2, startTime: 200, duration: 6, totalPages: 9 });
const client = { pushChanges: vi.fn().mockResolvedValue({}) };
await pushStats(stats, client as never);
const sent = client.pushChanges.mock.calls[0]![0];
expect(sent.statPages.map((p: { start_time: number }) => p.start_time)).toEqual([100, 200]);
expect(await stats.getCursor('push')).toBe(200);
});
it('applies pulled events and advances the pull cursor', async () => {
const stats = await db();
const client = {
pullChanges: vi.fn().mockResolvedValue({
statBooks: [{ book_hash: 'm', title: 'T', authors: 'A' }],
statPages: [
{
book_hash: 'm',
page: 1,
start_time: 300,
duration: 7,
total_pages: 9,
updated_at_ms: 1_750_000_000_000,
},
],
}),
};
await pullStats(stats, client as never);
const book = await stats.getBookByMd5('m');
expect(book!.total_read_time).toBe(7);
expect(await stats.getCursor('pull')).toBe(1_750_000_000_000);
});
it('chunks a large push backlog into bounded requests, advancing the cursor per chunk', async () => {
const stats = await db();
const id = await stats.upsertBook({ bookMd5: 'm', title: 'T', authors: 'A' });
// 600 events at distinct start_times — exceeds the 500-event push chunk → 2 requests.
for (let t = 1; t <= 600; t++) {
await stats.insertPageEvent(id, { page: t, startTime: t, duration: 1, totalPages: 999 });
}
const client = { pushChanges: vi.fn().mockResolvedValue({}) };
await pushStats(stats, client as never);
expect(client.pushChanges).toHaveBeenCalledTimes(2);
expect(client.pushChanges.mock.calls[0]![0].statPages.length).toBe(500);
expect(client.pushChanges.mock.calls[1]![0].statPages.length).toBe(100);
expect(await stats.getCursor('push')).toBe(600);
});
it('pages through a multi-page pull, applying every page until exhausted', async () => {
const stats = await db();
const ev = (n: number, ms: number) => ({
book_hash: 'm',
page: n,
start_time: n,
duration: 2,
total_pages: 9,
updated_at_ms: ms,
});
const client = {
pullChanges: vi
.fn()
.mockResolvedValueOnce({
statBooks: [{ book_hash: 'm', title: 'T', authors: 'A' }],
statPages: [ev(1, 1000), ev(2, 1000)],
})
.mockResolvedValueOnce({ statBooks: [], statPages: [ev(3, 2000)] })
.mockResolvedValue({ statBooks: [], statPages: [] }),
};
await pullStats(stats, client as never);
expect(client.pullChanges).toHaveBeenCalledTimes(3);
const book = await stats.getBookByMd5('m');
expect(book!.total_read_pages).toBe(3); // pages 1, 2, 3 all applied across pages
expect(await stats.getCursor('pull')).toBe(2000);
});
});
@@ -0,0 +1,45 @@
import { describe, it, expect } from 'vitest';
import { TrackerCore } from '@/services/statistics/trackerCore';
import { DEFAULT_STATS_TRACKING_CONFIG } from '@/types/statistics';
const cfg = DEFAULT_STATS_TRACKING_CONFIG;
describe('TrackerCore', () => {
it('emits an event when the page changes', () => {
const t = new TrackerCore(cfg);
expect(t.onPage(5, 100, 1000)).toEqual([]); // first page, nothing to flush yet
const out = t.onPage(6, 100, 1030); // moved off page 5 after 30s
expect(out).toEqual([{ page: 5, startTime: 1000, duration: 30, totalPages: 100 }]);
});
it('caps duration at maxEventSeconds', () => {
const t = new TrackerCore(cfg);
t.onPage(1, 10, 0);
const out = t.onPage(2, 10, 10_000); // way over the 120s cap
expect(out[0]!.duration).toBe(cfg.maxEventSeconds);
});
it('drops sub-minimum events', () => {
const t = new TrackerCore(cfg);
t.onPage(1, 10, 0);
expect(t.onPage(2, 10, 1)).toEqual([]); // 1s < minEventSeconds (3)
});
it('flushes and pauses on idle, then resumes with a new start_time', () => {
const t = new TrackerCore(cfg);
t.onPage(1, 10, 0);
expect(t.onIdle(50)).toEqual([{ page: 1, startTime: 0, duration: 50, totalPages: 10 }]);
// After idle, the same page resumes as a fresh event.
expect(t.onPage(1, 10, 200)).toEqual([]); // resume marker, no flush
expect(t.onPage(2, 10, 230)).toEqual([
{ page: 1, startTime: 200, duration: 30, totalPages: 10 },
]);
});
it('flushes on hide and on close without double-counting', () => {
const t = new TrackerCore(cfg);
t.onPage(7, 10, 0);
expect(t.onHide(40)).toEqual([{ page: 7, startTime: 0, duration: 40, totalPages: 10 }]);
expect(t.onClose(99)).toEqual([]); // already flushed + paused by hide
});
});
@@ -25,6 +25,7 @@ import FootnotePopup from './FootnotePopup';
import HintInfo from './HintInfo';
import ReadingRuler from './ReadingRuler';
import DoubleBorder from './DoubleBorder';
import ReadingStatsTracker from './ReadingStatsTracker';
interface BooksGridProps {
bookKeys: string[];
@@ -278,6 +279,7 @@ const BookCellInner: React.FC<BookCellProps> = ({
isHoveredAnim={false}
gridInsets={gridInsets}
/>
<ReadingStatsTracker bookKey={bookKey} />
</div>
);
};
@@ -0,0 +1,121 @@
'use client';
import { useEffect, useRef } from 'react';
import { useBookProgress } from '@/store/readerProgressStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useEnv } from '@/context/EnvContext';
import { useAuth } from '@/context/AuthContext';
import { StatisticsDb } from '@/services/statistics/statisticsDb';
import { TrackerCore, type FlushedEvent } from '@/services/statistics/trackerCore';
import { DEFAULT_STATS_TRACKING_CONFIG } from '@/types/statistics';
import { SyncClient } from '@/libs/sync';
import { pushStats, pullStats } from '@/services/statistics/statsSync';
import { isSyncCategoryEnabled } from '@/services/sync/syncCategories';
const nowSec = () => Math.floor(Date.now() / 1000);
export default function ReadingStatsTracker({ bookKey }: { bookKey: string }) {
const { appService } = useEnv();
// Progress lives in readerProgressStore, not readerStore.viewStates.
const progress = useBookProgress(bookKey);
// booksData is keyed by book id = bookKey.split('-')[0].
const getBookData = useBookDataStore((s) => s.getBookData);
const { user } = useAuth();
const coreRef = useRef(new TrackerCore(DEFAULT_STATS_TRACKING_CONFIG));
const dbRef = useRef<StatisticsDb | null>(null);
const idleRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const bookData = getBookData(bookKey);
const book = bookData?.book;
// Book.hash is the partialMD5 used as KOReader's md5.
const bookMd5 = book?.hash;
const title = book?.title ?? '';
// Book.author is the single-string author field; upsertBook takes authors: string.
const authors = book?.author ?? '';
const syncEnabled = () => !!user && isSyncCategoryEnabled('stats');
const schedulePush = () => {
if (!syncEnabled()) return;
if (pushTimerRef.current) clearTimeout(pushTimerRef.current);
pushTimerRef.current = setTimeout(() => {
const db = dbRef.current;
if (db) void pushStats(db, new SyncClient());
}, 10_000);
};
useEffect(() => {
if (!appService) return;
let cancelled = false;
StatisticsDb.open(appService).then((db) => {
if (cancelled) return;
dbRef.current = db;
if (syncEnabled()) void pullStats(db, new SyncClient());
});
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appService]);
// Persist flushed events into the statistics DB.
const persist = (events: FlushedEvent[]): Promise<void> => {
const db = dbRef.current;
if (!db || !bookMd5 || events.length === 0) return Promise.resolve();
return (async () => {
const idBook = await db.upsertBook({ bookMd5, title, authors });
for (const e of events) await db.insertPageEvent(idBook, e);
await db.recomputeBookTotals(idBook);
schedulePush();
})();
};
const armIdle = () => {
if (idleRef.current) clearTimeout(idleRef.current);
idleRef.current = setTimeout(
() => void persist(coreRef.current.onIdle(nowSec())),
DEFAULT_STATS_TRACKING_CONFIG.idleTimeoutSeconds * 1000,
);
};
// Page changes drive the tracker.
useEffect(() => {
const info = progress?.pageinfo;
if (!info) return;
const page = (info.current ?? 0) + 1;
const total = info.total || 1;
void persist(coreRef.current.onPage(page, total, nowSec()));
armIdle();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [progress?.pageinfo]);
// Tab/window visibility.
useEffect(() => {
const onVis = () => {
if (document.visibilityState === 'hidden') {
if (idleRef.current) clearTimeout(idleRef.current);
void persist(coreRef.current.onHide(nowSec()));
}
};
document.addEventListener('visibilitychange', onVis);
return () => document.removeEventListener('visibilitychange', onVis);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bookMd5]);
// Book close (unmount).
useEffect(() => {
return () => {
if (idleRef.current) clearTimeout(idleRef.current);
if (pushTimerRef.current) clearTimeout(pushTimerRef.current);
const closePersist = persist(coreRef.current.onClose(nowSec()));
void closePersist.then(() => {
if (syncEnabled() && dbRef.current) return pushStats(dbRef.current, new SyncClient());
return undefined;
});
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bookMd5]);
return null;
}
@@ -59,6 +59,10 @@ const useCategoryCopy = (): Record<SyncCategory, CategoryCopy> => {
'Tokens, usernames, and passwords for OPDS, KOReader, Hardcover, and Readwise. When disabled, credentials remain on this device only and are never uploaded.',
),
},
stats: {
title: _('Reading statistics'),
description: _('Reading time and pages read, synced across your devices and KOReader.'),
},
};
};
+6 -2
View File
@@ -109,8 +109,12 @@ export function useSync(bookKey?: string) {
try {
const result = await syncClient.pullChanges(since, type, bookId, metaHash);
setSyncResult({ ...syncResult, [type]: result[type] });
const records = result[type];
const resultAsRecord = result as unknown as Record<
string,
BookDataRecord[] | null | undefined
>;
setSyncResult({ ...syncResult, [type]: resultAsRecord[type] });
const records = resultAsRecord[type];
if (since > 1000 && !records?.length) return 0;
// For since <= 1000, we set lastSyncedAt to now if no records returned
const maxTime = records?.length ? computeMaxTimestamp(records) : Date.now();
+31 -2
View File
@@ -5,17 +5,42 @@ import { fetchWithTimeout } from '@/utils/fetch';
const SYNC_API_ENDPOINT = getAPIBaseUrl() + '/sync';
export type SyncType = 'books' | 'configs' | 'notes';
export type SyncType = 'books' | 'configs' | 'notes' | 'stats';
export type SyncOp = 'push' | 'pull' | 'both';
interface BookRecord extends BookDataRecord, Book {}
interface BookConfigRecord extends BookDataRecord, BookConfig {}
interface BookNoteRecord extends BookDataRecord, BookNote {}
export interface StatBookRecord {
user_id?: string;
book_hash: string;
title: string;
authors: string;
updated_at?: string;
updated_at_ms?: number; // epoch ms, attached by the GET response for cursor math
deleted_at?: string | null;
}
export interface StatPageRecord {
user_id?: string;
book_hash: string;
page: number;
start_time: number;
duration: number;
total_pages: number;
ext?: unknown;
updated_at?: string;
updated_at_ms?: number; // epoch ms, attached by the GET response for cursor math
deleted_at?: string | null;
}
export interface SyncResult {
books: BookRecord[] | null;
notes: BookNoteRecord[] | null;
configs: BookConfigRecord[] | null;
statBooks?: StatBookRecord[] | null;
statPages?: StatPageRecord[] | null;
}
export type SyncRecord = BookRecord & BookConfigRecord & BookNoteRecord;
@@ -24,6 +49,8 @@ export interface SyncData {
books?: Partial<BookRecord>[];
notes?: Partial<BookNoteRecord>[];
configs?: Partial<BookConfigRecord>[];
statBooks?: StatBookRecord[];
statPages?: StatPageRecord[];
}
export class SyncClient {
@@ -36,11 +63,13 @@ export class SyncClient {
type?: SyncType,
book?: string,
metaHash?: string,
limit?: number,
): Promise<SyncResult> {
const token = await getAccessToken();
if (!token) throw new Error('Not authenticated');
const url = `${SYNC_API_ENDPOINT}?since=${encodeURIComponent(since)}&type=${type ?? ''}&book=${book ?? ''}&meta_hash=${metaHash ?? ''}`;
const limitParam = limit && limit > 0 ? `&limit=${encodeURIComponent(limit)}` : '';
const url = `${SYNC_API_ENDPOINT}?since=${encodeURIComponent(since)}&type=${type ?? ''}&book=${book ?? ''}&meta_hash=${metaHash ?? ''}${limitParam}`;
const res = await fetchWithTimeout(
url,
{
+186 -4
View File
@@ -7,10 +7,36 @@ import { transformBookConfigToDB } from '@/utils/transform';
import { transformBookNoteToDB } from '@/utils/transform';
import { transformBookToDB } from '@/utils/transform';
import { runMiddleware, corsAllMethods } from '@/utils/cors';
import { SyncData, SyncRecord, SyncResult, SyncType } from '@/libs/sync';
import {
SyncData,
SyncRecord,
SyncResult,
SyncType,
StatBookRecord,
StatPageRecord,
} from '@/libs/sync';
import { validateUserAndToken } from '@/utils/access';
import { DBBook, DBBookConfig } from '@/types/records';
const pageKey = (r: StatPageRecord) => `${r.book_hash}|${r.page}|${r.start_time}`;
/**
* Decide which incoming page events to write: new keys always win; existing
* keys win only when the incoming duration is strictly longer (union/upsert
* semantics — KOReader-compatible).
*/
export function pickWinningPages(
incoming: StatPageRecord[],
server: Map<string, StatPageRecord>,
): { toUpsert: StatPageRecord[] } {
const toUpsert: StatPageRecord[] = [];
for (const rec of incoming) {
const existing = server.get(pageKey(rec));
if (!existing || rec.duration > existing.duration) toUpsert.push(rec);
}
return { toUpsert };
}
const transformsToDB = {
books: transformBookToDB,
book_notes: transformBookNoteToDB,
@@ -39,6 +65,10 @@ export async function GET(req: NextRequest) {
const typeParam = searchParams.get('type') as SyncType | undefined;
const bookParam = searchParams.get('book');
const metaHashParam = searchParams.get('meta_hash');
// Optional page size for `type=stats` (client-driven paged pull). Absent for
// the koplugin, which keeps the full-delta response.
const statsLimitParam = searchParams.get('limit');
const statsLimit = statsLimitParam ? Math.max(1, Math.floor(Number(statsLimitParam))) : 0;
if (!sinceParam) {
return NextResponse.json({ error: '"since" query parameter is required' }, { status: 400 });
@@ -52,7 +82,7 @@ export async function GET(req: NextRequest) {
const sinceIso = since.toISOString();
try {
const results: SyncResult = { books: [], configs: [], notes: [] };
const results: SyncResult = { books: [], configs: [], notes: [], statBooks: [], statPages: [] };
const errors: Record<TableName, DBError | null> = {
books: null,
book_notes: null,
@@ -113,7 +143,7 @@ export async function GET(req: NextRequest) {
}
});
}
results[DBSyncTypeMap[table] as SyncType] = records || [];
(results as unknown as Record<string, SyncRecord[]>)[DBSyncTypeMap[table]] = records || [];
};
if (!typeParam || typeParam === 'books') {
@@ -145,6 +175,99 @@ export async function GET(req: NextRequest) {
if (!typeParam || typeParam === 'notes') {
await queryTables('book_notes', ['id']).catch((err) => (errors['book_notes'] = err));
}
if (!typeParam || typeParam === 'stats') {
// PostgREST caps responses at ~1000 rows; stat_pages grows one row per page
// event, so page through both tables (ordered by updated_at ascending for a
// stable cursor) and accumulate every row — otherwise a device pulling >1000
// events only gets the first page and then advances its cursor past the rest.
const PAGE = 1000;
const fetchAll = async (table: 'stat_books' | 'stat_pages', filterBook: boolean) => {
const all: Record<string, unknown>[] = [];
let offset = 0;
for (;;) {
let q = supabase
.from(table)
.select('*')
.eq('user_id', user.id)
.or(`updated_at.gt.${sinceIso},deleted_at.gt.${sinceIso}`)
.order('updated_at', { ascending: true })
.range(offset, offset + PAGE - 1);
if (filterBook && bookParam) q = q.eq('book_hash', bookParam);
const { data, error } = await q;
if (error) return { error };
const rows = (data ?? []) as Record<string, unknown>[];
all.push(...rows);
if (rows.length < PAGE) break;
offset += PAGE;
}
return { data: all };
};
// A single bounded page of stat_pages for the app's client-driven paged
// pull, completed to the trailing updated_at millisecond so the client can
// advance its cursor with a strict `> cursor` without skipping ties.
const fetchPagedPages = async () => {
let q = supabase
.from('stat_pages')
.select('*')
.eq('user_id', user.id)
.or(`updated_at.gt.${sinceIso},deleted_at.gt.${sinceIso}`)
.order('updated_at', { ascending: true })
.range(0, statsLimit - 1);
if (bookParam) q = q.eq('book_hash', bookParam);
const { data, error } = await q;
if (error) return { error };
const rows = (data ?? []) as Record<string, unknown>[];
if (rows.length === statsLimit) {
const lastUpdated = rows[rows.length - 1]!['updated_at'] as string;
let eq = supabase
.from('stat_pages')
.select('*')
.eq('user_id', user.id)
.eq('updated_at', lastUpdated);
if (bookParam) eq = eq.eq('book_hash', bookParam);
const { data: extra, error: extraErr } = await eq;
if (extraErr) return { error: extraErr };
const keyOf = (r: Record<string, unknown>) =>
`${r['book_hash']}|${r['page']}|${r['start_time']}`;
const seen = new Set(rows.map(keyOf));
for (const r of (extra ?? []) as Record<string, unknown>[]) {
const k = keyOf(r);
if (!seen.has(k)) {
seen.add(k);
rows.push(r);
}
}
}
return { data: rows };
};
// stat_books is always returned in full (one row per book, small); only
// stat_pages pages when the client asks (the koplugin omits `limit`).
const sb = await fetchAll('stat_books', false);
const sp = statsLimit > 0 ? await fetchPagedPages() : await fetchAll('stat_pages', true);
if (sb.error)
return NextResponse.json(
{ error: `stat_books: ${sb.error.message || 'Unknown error'}` },
{ status: 500 },
);
if (sp.error)
return NextResponse.json(
{ error: `stat_pages: ${sp.error.message || 'Unknown error'}` },
{ status: 500 },
);
// Attach updated_at_ms (epoch ms) so non-JS clients (the Lua koplugin) can
// compute their pull cursor without parsing ISO-8601 timestamps.
const withMs = <T extends { updated_at?: string }>(rows: T[]) =>
rows.map((r) => ({
...r,
updated_at_ms: r.updated_at ? new Date(r.updated_at).getTime() : 0,
}));
(
results as unknown as { statBooks: StatBookRecord[]; statPages: StatPageRecord[] }
).statBooks = withMs((sb.data ?? []) as unknown as StatBookRecord[]);
(
results as unknown as { statBooks: StatBookRecord[]; statPages: StatPageRecord[] }
).statPages = withMs((sp.data ?? []) as unknown as StatPageRecord[]);
}
const dbErrors = Object.values(errors).filter((err) => err !== null);
if (dbErrors.length > 0) {
@@ -174,7 +297,7 @@ export async function POST(req: NextRequest) {
}
const supabase = createSupabaseClient(token);
const body = await req.json();
const { books = [], configs = [], notes = [] } = body as SyncData;
const { books = [], configs = [], notes = [], statBooks = [], statPages = [] } = body as SyncData;
const BATCH_SIZE = 100;
const upsertRecords = async (
@@ -364,6 +487,65 @@ export async function POST(req: NextRequest) {
);
}
if (statBooks.length > 0) {
const rows = statBooks.map((b: StatBookRecord) => ({
user_id: user.id,
book_hash: b.book_hash,
title: b.title,
authors: b.authors,
updated_at: new Date().toISOString(),
deleted_at: b.deleted_at ?? null,
}));
const { error } = await supabase
.from('stat_books')
.upsert(rows, { onConflict: 'user_id,book_hash' });
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
}
if (statPages.length > 0) {
// Process in batches so the "longer-duration-wins" merge stays correct at
// scale: the existing-row fetch is scoped to each batch's (book_hash,
// start_time) keys (not a book's whole history) and bounded under
// PostgREST's ~1000-row cap — otherwise existing rows beyond 1000 are
// invisible to pickWinningPages and a shorter duration could overwrite a
// longer one.
const BATCH = 500;
for (let off = 0; off < statPages.length; off += BATCH) {
const batch = statPages.slice(off, off + BATCH);
const bookHashes = [...new Set(batch.map((p) => p.book_hash))];
const startTimes = [...new Set(batch.map((p) => p.start_time))];
const { data: existing, error: exErr } = await supabase
.from('stat_pages')
.select('*')
.eq('user_id', user.id)
.in('book_hash', bookHashes)
.in('start_time', startTimes);
if (exErr) return NextResponse.json({ error: exErr.message }, { status: 500 });
const serverMap = new Map<string, StatPageRecord>();
(existing ?? []).forEach((r) =>
serverMap.set(pageKey(r as StatPageRecord), r as StatPageRecord),
);
const { toUpsert } = pickWinningPages(batch, serverMap);
const rows = toUpsert.map((p) => ({
user_id: user.id,
book_hash: p.book_hash,
page: p.page,
start_time: p.start_time,
duration: p.duration,
total_pages: p.total_pages,
ext: p.ext ?? null,
updated_at: new Date().toISOString(),
deleted_at: p.deleted_at ?? null,
}));
if (rows.length > 0) {
const { error } = await supabase
.from('stat_pages')
.upsert(rows, { onConflict: 'user_id,book_hash,page,start_time' });
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
}
}
}
return NextResponse.json(
{
books: booksResult?.data || [],
@@ -51,6 +51,65 @@ const migrations: Record<SchemaType, MigrationEntry[]> = {
// rows rather than UPDATE — Tantivy 0.25→0.26 has a known WASM-only
// UPDATE regression (see fts-tests.ts:306 FIXME). MVP indexing is
// write-once per book so this is naturally satisfied.
statistics: [
{
name: '2026061501_statistics_koreader_schema',
sql: `
CREATE TABLE IF NOT EXISTS book (
id integer PRIMARY KEY autoincrement,
title text, authors text, notes integer, last_open integer,
highlights integer, pages integer, series text, language text,
md5 text, total_read_time integer, total_read_pages integer
);
CREATE UNIQUE INDEX IF NOT EXISTS book_title_authors_md5 ON book(title, authors, md5);
CREATE TABLE IF NOT EXISTS page_stat_data (
id_book integer,
page integer NOT NULL DEFAULT 0,
start_time integer NOT NULL DEFAULT 0,
duration integer NOT NULL DEFAULT 0,
total_pages integer NOT NULL DEFAULT 0,
UNIQUE (id_book, page, start_time)
);
CREATE INDEX IF NOT EXISTS page_stat_data_start_time ON page_stat_data(start_time);
CREATE TABLE IF NOT EXISTS numbers (number INTEGER PRIMARY KEY);
INSERT OR IGNORE INTO numbers(number)
SELECT h.n * 100 + t.n * 10 + o.n + 1
FROM (SELECT 0 AS n UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9) o,
(SELECT 0 AS n UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9) t,
(SELECT 0 AS n UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9) h;
CREATE VIEW IF NOT EXISTS page_stat AS
SELECT id_book, first_page + idx - 1 AS page, start_time, duration / (last_page - first_page + 1) AS duration
FROM (
SELECT id_book, page, total_pages, pages, start_time, duration,
((page - 1) * pages) / total_pages + 1 AS first_page,
max(((page - 1) * pages) / total_pages + 1, (page * pages) / total_pages) AS last_page,
idx
FROM page_stat_data
JOIN book ON book.id = id_book
JOIN (SELECT number as idx FROM numbers) AS N ON idx <= (last_page - first_page + 1)
);
CREATE TABLE IF NOT EXISTS readest_page_ext (
book_hash text NOT NULL, page integer NOT NULL, start_time integer NOT NULL,
ext text, PRIMARY KEY (book_hash, page, start_time)
);
CREATE TABLE IF NOT EXISTS readest_book_ext (
book_hash text PRIMARY KEY, ext text
);
CREATE TABLE IF NOT EXISTS readest_stat_sync_state (
key text PRIMARY KEY, value integer NOT NULL DEFAULT 0
);
`,
},
],
reedy: [
{
name: '2026052601_reedy_init',
@@ -0,0 +1,231 @@
import type { AppService } from '@/types/system';
import type { DatabaseService, DatabaseRow } from '@/types/database';
import type { PageStatEvent, StatBook } from '@/types/statistics';
interface BookRow extends DatabaseRow {
id: number;
title: string;
authors: string;
md5: string;
total_read_time: number;
total_read_pages: number;
last_open: number;
pages: number;
}
type CursorKey = 'push' | 'pull';
/**
* Per-tab singleton open promise. OPFS permits only ONE access handle per file
* across the whole origin, so a second `connect()` to statistics.db throws
* `NoModificationAllowedError`. Every ReadingStatsTracker instance (and split-
* view books) must therefore share a single connection — we memoise the open
* and never thrash it.
*/
let sharedDb: Promise<StatisticsDb> | null = null;
let lifecycleBound = false;
function bindLifecycle(): void {
if (lifecycleBound || typeof document === 'undefined') return;
lifecycleBound = true;
// Fold the WAL into the main db when the tab is backgrounded/closed — the
// most reliable point for best-effort async OPFS work before teardown.
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden' && sharedDb) {
void sharedDb.then((s) => s.checkpoint()).catch(() => {});
}
});
}
/**
* Typed wrapper over the KOReader-compatible `statistics.db`. All identity is
* keyed on `book_hash` (= Book.hash); the local autoincrement `id_book` never
* leaves this class.
*/
export class StatisticsDb {
private constructor(private readonly db: DatabaseService) {}
/** Production entry point — opens + migrates statistics.db (per-tab singleton). */
static async open(appService: AppService): Promise<StatisticsDb> {
bindLifecycle();
if (!sharedDb) {
sharedDb = (async () => {
const db = await appService.openDatabase('statistics', 'statistics.db', 'Data');
return new StatisticsDb(db);
})();
}
return sharedDb;
}
/** Test/advanced entry point — wrap an already-migrated DatabaseService. */
static from(db: DatabaseService): StatisticsDb {
return new StatisticsDb(db);
}
/**
* Fold the WAL into the main db file and truncate it. The Turso engine does
* NOT implement `PRAGMA wal_autocheckpoint` (so there's no auto threshold to
* rely on), but `wal_checkpoint(TRUNCATE)` works — verified folding a 688 KB
* WAL back to 0 B. We call this when the tab is hidden so the WAL stays bounded.
*/
async checkpoint(): Promise<void> {
await this.db.execute('PRAGMA wal_checkpoint(TRUNCATE)');
}
/** Checkpoint, close the underlying connection, and reset the singleton. */
async close(): Promise<void> {
try {
await this.checkpoint();
} catch {
// best-effort — a checkpoint failure must not block close
}
await this.db.close();
sharedDb = null;
}
async upsertBook(book: StatBook): Promise<number> {
const existing = await this.db.select<BookRow>(`SELECT id FROM book WHERE md5 = ? LIMIT 1`, [
book.bookMd5,
]);
if (existing[0]) {
// md5 is the identity; keep the latest title/authors (LWW, matches server stat_books).
await this.db.execute(`UPDATE book SET title = ?, authors = ? WHERE id = ?`, [
book.title,
book.authors,
existing[0].id,
]);
return existing[0].id;
}
await this.db.execute(
`INSERT INTO book (title, authors, md5, notes, last_open, highlights, pages, total_read_time, total_read_pages)
VALUES (?, ?, ?, 0, 0, 0, 0, 0, 0)
ON CONFLICT(title, authors, md5) DO NOTHING`,
[book.title, book.authors, book.bookMd5],
);
const rows = await this.db.select<BookRow>(`SELECT id FROM book WHERE md5 = ? LIMIT 1`, [
book.bookMd5,
]);
return rows[0]!.id;
}
async insertPageEvent(
idBook: number,
e: { page: number; startTime: number; duration: number; totalPages: number },
): Promise<void> {
await this.db.execute(
`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`,
[idBook, e.page, e.startTime, e.duration, e.totalPages],
);
}
async recomputeBookTotals(idBook: number): Promise<void> {
await this.db.execute(
`UPDATE book SET
total_read_time = COALESCE((SELECT SUM(duration) FROM page_stat_data WHERE id_book = ?), 0),
total_read_pages = COALESCE((SELECT COUNT(DISTINCT page) FROM page_stat_data WHERE id_book = ?), 0),
last_open = COALESCE((SELECT MAX(start_time + duration) FROM page_stat_data WHERE id_book = ?), last_open),
pages = COALESCE((SELECT total_pages FROM page_stat_data WHERE id_book = ? ORDER BY start_time DESC LIMIT 1), pages)
WHERE id = ?`,
[idBook, idBook, idBook, idBook, idBook],
);
}
async getBookByMd5(md5: string): Promise<BookRow | null> {
const rows = await this.db.select<BookRow>(`SELECT * FROM book WHERE md5 = ? LIMIT 1`, [md5]);
return rows[0] ?? null;
}
/**
* Ensure a book row exists for an event whose metadata record isn't in the
* current batch (paged pull can deliver an event before its `stat_books`
* record). Unlike `upsertBook`, this NEVER overwrites an existing real title
* with the hash placeholder — the real record, arriving in any page, wins.
*/
private async ensureBookId(bookMd5: string): Promise<number> {
const existing = await this.db.select<BookRow>(`SELECT id FROM book WHERE md5 = ? LIMIT 1`, [
bookMd5,
]);
if (existing[0]) return existing[0].id;
return this.upsertBook({ bookMd5, title: bookMd5, authors: '' });
}
/** Events with start_time > cursor, joined to their md5, for pushing. */
async getEventsForPush(
sinceStartTime: number,
): Promise<{ events: PageStatEvent[]; books: StatBook[] }> {
const rows = await this.db.select<DatabaseRow>(
`SELECT b.md5 AS bookMd5, b.title AS title, b.authors AS authors,
p.page AS page, p.start_time AS startTime, p.duration AS duration, p.total_pages AS totalPages
FROM page_stat_data p JOIN book b ON b.id = p.id_book
WHERE p.start_time > ?
ORDER BY p.start_time ASC`,
[sinceStartTime],
);
const events: PageStatEvent[] = rows.map((r) => ({
bookMd5: String(r['bookMd5']),
page: Number(r['page']),
startTime: Number(r['startTime']),
duration: Number(r['duration']),
totalPages: Number(r['totalPages']),
}));
const bookMap = new Map<string, StatBook>();
for (const r of rows) {
const md5 = String(r['bookMd5']);
if (!bookMap.has(md5)) {
bookMap.set(md5, {
bookMd5: md5,
title: String(r['title']),
authors: String(r['authors']),
});
}
}
return { events, books: [...bookMap.values()] };
}
async applyRemoteEvents(books: StatBook[], events: PageStatEvent[]): Promise<void> {
if (books.length === 0 && events.length === 0) return;
// One transaction for the whole pulled batch: a single commit instead of
// O(rows) fsyncs, and the apply is atomic (a failed pull leaves no partial
// state). Critical when a fresh device backfills tens of thousands of rows.
await this.db.execute('BEGIN');
try {
const idByMd5 = new Map<string, number>();
for (const b of books) idByMd5.set(b.bookMd5, await this.upsertBook(b));
// Books referenced only by events (no metadata record) get a placeholder row.
const touched = new Set<number>();
for (const e of events) {
let id = idByMd5.get(e.bookMd5);
if (id === undefined) {
id = await this.ensureBookId(e.bookMd5);
idByMd5.set(e.bookMd5, id);
}
await this.insertPageEvent(id, e);
touched.add(id);
}
for (const id of touched) await this.recomputeBookTotals(id);
await this.db.execute('COMMIT');
} catch (err) {
await this.db.execute('ROLLBACK');
throw err;
}
}
async getCursor(key: CursorKey): Promise<number> {
const rows = await this.db.select<{ value: number }>(
`SELECT value FROM readest_stat_sync_state WHERE key = ?`,
[key],
);
return rows[0]?.value ?? 0;
}
async setCursor(key: CursorKey, value: number): Promise<void> {
await this.db.execute(
`INSERT INTO readest_stat_sync_state (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
[key, value],
);
}
}
@@ -0,0 +1,93 @@
import type { StatisticsDb } from './statisticsDb';
import type { SyncClient, StatPageRecord, StatBookRecord } from '@/libs/sync';
import type { PageStatEvent, StatBook } from '@/types/statistics';
type PushClient = Pick<SyncClient, 'pushChanges'>;
type PullClient = Pick<SyncClient, 'pullChanges'>;
const toWirePage = (e: PageStatEvent): StatPageRecord => ({
book_hash: e.bookMd5,
page: e.page,
start_time: e.startTime,
duration: e.duration,
total_pages: e.totalPages,
});
const toWireBook = (b: StatBook): StatBookRecord => ({
book_hash: b.bookMd5,
title: b.title,
authors: b.authors,
});
/** Events per push request — bounds request size for a large offline backlog. */
const PUSH_CHUNK = 500;
/** Page events per pull request — bounds the receiving device's memory. */
const PULL_PAGE = 1000;
/**
* Push local events newer than the push cursor, in bounded chunks. The cursor
* advances per successful chunk, so an interrupted push (e.g. a 1000-event
* backlog over flaky network) resumes from the last chunk rather than restarting.
*/
export async function pushStats(stats: StatisticsDb, client: PushClient): Promise<void> {
const cursor = await stats.getCursor('push');
const { events, books } = await stats.getEventsForPush(cursor);
if (events.length === 0) return;
const bookByHash = new Map(books.map((b) => [b.bookMd5, b]));
let i = 0;
while (i < events.length) {
let end = Math.min(i + PUSH_CHUNK, events.length);
// Never split a start_time across chunks — advancing the push cursor past it
// would drop the remaining same-second events (e.g. split-view) on resume.
const lastStart = events[end - 1]!.startTime;
while (end < events.length && events[end]!.startTime === lastStart) end++;
const chunk = events.slice(i, end);
const seen = new Set<string>();
const chunkBooks: StatBookRecord[] = [];
for (const e of chunk) {
if (seen.has(e.bookMd5)) continue;
seen.add(e.bookMd5);
const b = bookByHash.get(e.bookMd5);
if (b) chunkBooks.push(toWireBook(b));
}
await client.pushChanges({ statBooks: chunkBooks, statPages: chunk.map(toWirePage) });
await stats.setCursor('push', chunk[chunk.length - 1]!.startTime);
i = end;
}
}
/**
* Pull events since the pull cursor in bounded pages, applying each before
* fetching the next so memory stays flat and a fresh-device backfill is
* resumable (the cursor persists between pages). The server completes the
* trailing millisecond of each page, so a strict `> cursor` next pull never
* skips rows that share an `updated_at`.
*/
export async function pullStats(stats: StatisticsDb, client: PullClient): Promise<void> {
for (;;) {
const since = await stats.getCursor('pull');
const res = await client.pullChanges(since, 'stats', undefined, undefined, PULL_PAGE);
const wireBooks = (res.statBooks ?? []) as StatBookRecord[];
const wirePages = (res.statPages ?? []) as StatPageRecord[];
if (wireBooks.length === 0 && wirePages.length === 0) break;
const books: StatBook[] = wireBooks.map((b) => ({
bookMd5: b.book_hash,
title: b.title,
authors: b.authors,
}));
const events: PageStatEvent[] = wirePages.map((p) => ({
bookMd5: p.book_hash,
page: p.page,
startTime: p.start_time,
duration: p.duration,
totalPages: p.total_pages,
}));
await stats.applyRemoteEvents(books, events);
// Advance the cursor to the newest page-event updated_at_ms. Stop when a
// page yields no further page-event progress (covers the empty-pages and
// the all-books-no-pages cases, and guards against a stalled cursor).
const newest = wirePages.reduce((m, p) => Math.max(m, p.updated_at_ms ?? 0), since);
if (newest <= since) break;
await stats.setCursor('pull', newest);
}
}
@@ -0,0 +1,60 @@
import type { StatsTrackingConfig } from '@/types/statistics';
interface PendingEvent {
page: number;
startTime: number; // Unix seconds
totalPages: number;
}
export interface FlushedEvent {
page: number;
startTime: number;
duration: number;
totalPages: number;
}
/**
* Pure page-dwell tracker. The React layer feeds it `nowSeconds` and the
* current `(page, totalPages)`; it returns zero-or-one immutable events to
* persist. Events end on page-change / idle / hide / close. Each returned
* event is final — its start_time and duration never change afterwards, which
* lets sync use a simple start_time high-water cursor.
*/
export class TrackerCore {
private pending: PendingEvent | null = null;
constructor(private readonly cfg: StatsTrackingConfig) {}
/** Notify the current page at `now`. Returns events flushed by leaving the prior page. */
onPage(page: number, totalPages: number, now: number): FlushedEvent[] {
if (this.pending && this.pending.page === page) {
// Same page (e.g. resume after idle, or a no-op relocate): keep dwelling.
return [];
}
const flushed = this.flush(now);
this.pending = { page, startTime: now, totalPages };
return flushed;
}
onIdle(now: number): FlushedEvent[] {
return this.flush(now); // flush + pause (pending cleared)
}
onHide(now: number): FlushedEvent[] {
return this.flush(now);
}
onClose(now: number): FlushedEvent[] {
return this.flush(now);
}
private flush(now: number): FlushedEvent[] {
const p = this.pending;
this.pending = null;
if (!p) return [];
const raw = now - p.startTime;
const duration = Math.min(Math.max(raw, 0), this.cfg.maxEventSeconds);
if (duration < this.cfg.minEventSeconds) return [];
return [{ page: p.page, startTime: p.startTime, duration, totalPages: p.totalPages }];
}
}
+3 -1
View File
@@ -233,7 +233,8 @@ export type SyncCategory =
| 'texture'
| 'opds_catalog'
| 'settings'
| 'credentials';
| 'credentials'
| 'stats';
export const SYNC_CATEGORIES: readonly SyncCategory[] = [
'book',
@@ -244,6 +245,7 @@ export const SYNC_CATEGORIES: readonly SyncCategory[] = [
'texture',
'opds_catalog',
'settings',
'stats',
'credentials',
] as const;
+37
View File
@@ -0,0 +1,37 @@
// KOReader-compatible reading-statistics types.
//
// The canonical unit is the per-page read event (KOReader's page_stat_data
// row). Aggregates (sessions, streaks, totals) are DERIVED via SQL, never
// stored as separate records. Times are Unix seconds; pages are 1-based.
/** One immutable page-read event — a KOReader `page_stat_data` row. */
export interface PageStatEvent {
bookMd5: string; // = Book.hash = KOReader book.md5
page: number; // 1-based
startTime: number; // Unix seconds
duration: number; // seconds
totalPages: number;
}
/** KOReader book identity — the only book metadata that syncs. */
export interface StatBook {
bookMd5: string;
title: string;
authors: string; // KOReader stores authors as a single text field
}
/** Tunables for the tracker's flush/idle behavior. KOReader-aligned defaults. */
export interface StatsTrackingConfig {
/** Seconds of inactivity before the current page event is flushed + paused. */
idleTimeoutSeconds: number;
/** Hard per-event duration cap (safety net if a visibility event is missed). */
maxEventSeconds: number;
/** Events shorter than this are dropped (ignore sub-second page flips). */
minEventSeconds: number;
}
export const DEFAULT_STATS_TRACKING_CONFIG: StatsTrackingConfig = {
idleTimeoutSeconds: 120,
maxEventSeconds: 120,
minEventSeconds: 3,
};
@@ -436,3 +436,9 @@ msgstr "تمت مزامنة تقدم القراءة"
msgid "No saved reading progress found for this book"
msgstr "لم يتم العثور على تقدم قراءة محفوظ لهذا الكتاب"
msgid "Failed to push reading statistics"
msgstr ""
msgid "Failed to pull reading statistics"
msgstr ""
@@ -436,3 +436,9 @@ msgstr "পড়ার অগ্রগতি সিঙ্ক হয়েছে
msgid "No saved reading progress found for this book"
msgstr "এই বইয়ের জন্য সংরক্ষিত পড়ার অগ্রগতি পাওয়া যায়নি"
msgid "Failed to push reading statistics"
msgstr ""
msgid "Failed to pull reading statistics"
msgstr ""
@@ -436,3 +436,9 @@ msgstr "ཀློག་པའི་འཕེལ་རིམ་མཉམ་འག
msgid "No saved reading progress found for this book"
msgstr "དཔེ་ཆ་འདིའི་ཀློག་པའི་འཕེལ་རིམ་ཉར་ཚགས་མ་རྙེད།"
msgid "Failed to push reading statistics"
msgstr ""
msgid "Failed to pull reading statistics"
msgstr ""
@@ -436,3 +436,9 @@ msgstr "Lesefortschritt synchronisiert"
msgid "No saved reading progress found for this book"
msgstr "Kein gespeicherter Lesefortschritt für dieses Buch gefunden"
msgid "Failed to push reading statistics"
msgstr ""
msgid "Failed to pull reading statistics"
msgstr ""
@@ -436,3 +436,9 @@ msgstr "Η πρόοδος ανάγνωσης συγχρονίστηκε"
msgid "No saved reading progress found for this book"
msgstr "Δεν βρέθηκε αποθηκευμένη πρόοδος ανάγνωσης για αυτό το βιβλίο"
msgid "Failed to push reading statistics"
msgstr ""
msgid "Failed to pull reading statistics"
msgstr ""
@@ -436,3 +436,9 @@ msgstr "Progreso de lectura sincronizado"
msgid "No saved reading progress found for this book"
msgstr "No se encontró progreso de lectura guardado para este libro"
msgid "Failed to push reading statistics"
msgstr ""
msgid "Failed to pull reading statistics"
msgstr ""
@@ -436,3 +436,9 @@ msgstr "پیشرفت مطالعه همگام‌سازی شد"
msgid "No saved reading progress found for this book"
msgstr "پیشرفت مطالعه ذخیره‌شده‌ای برای این کتاب یافت نشد"
msgid "Failed to push reading statistics"
msgstr ""
msgid "Failed to pull reading statistics"
msgstr ""
@@ -436,3 +436,9 @@ msgstr "Progression de lecture synchronisée"
msgid "No saved reading progress found for this book"
msgstr "Aucune progression de lecture enregistrée pour ce livre"
msgid "Failed to push reading statistics"
msgstr ""
msgid "Failed to pull reading statistics"
msgstr ""
@@ -436,3 +436,9 @@ msgstr "התקדמות הקריאה סונכרנה"
msgid "No saved reading progress found for this book"
msgstr "לא נמצאה התקדמות קריאה שמורה לספר זה"
msgid "Failed to push reading statistics"
msgstr ""
msgid "Failed to pull reading statistics"
msgstr ""
@@ -436,3 +436,9 @@ msgstr "पठन प्रगति सिंक हो गई"
msgid "No saved reading progress found for this book"
msgstr "इस पुस्तक के लिए सहेजी गई पठन प्रगति नहीं मिली"
msgid "Failed to push reading statistics"
msgstr ""
msgid "Failed to pull reading statistics"
msgstr ""
@@ -436,3 +436,9 @@ msgstr "Olvasási haladás szinkronizálva"
msgid "No saved reading progress found for this book"
msgstr "Nem található mentett olvasási haladás ehhez a könyvhöz"
msgid "Failed to push reading statistics"
msgstr ""
msgid "Failed to pull reading statistics"
msgstr ""
@@ -436,3 +436,9 @@ msgstr "Progres baca disinkronkan"
msgid "No saved reading progress found for this book"
msgstr "Tidak ada progres baca tersimpan untuk buku ini"
msgid "Failed to push reading statistics"
msgstr ""
msgid "Failed to pull reading statistics"
msgstr ""
@@ -436,3 +436,9 @@ msgstr "Progresso di lettura sincronizzato"
msgid "No saved reading progress found for this book"
msgstr "Nessun progresso di lettura salvato per questo libro"
msgid "Failed to push reading statistics"
msgstr ""
msgid "Failed to pull reading statistics"
msgstr ""
@@ -436,3 +436,9 @@ msgstr "読書の進捗を同期しました"
msgid "No saved reading progress found for this book"
msgstr "この書籍の保存された読書進捗が見つかりません"
msgid "Failed to push reading statistics"
msgstr ""
msgid "Failed to pull reading statistics"
msgstr ""
@@ -436,3 +436,9 @@ msgstr "읽기 진행 상황이 동기화되었습니다"
msgid "No saved reading progress found for this book"
msgstr "이 책의 저장된 읽기 진행 상황을 찾을 수 없습니다"
msgid "Failed to push reading statistics"
msgstr ""
msgid "Failed to pull reading statistics"
msgstr ""
@@ -436,3 +436,9 @@ msgstr "Kemajuan bacaan disegerakkan"
msgid "No saved reading progress found for this book"
msgstr "Tiada kemajuan bacaan tersimpan ditemui untuk buku ini"
msgid "Failed to push reading statistics"
msgstr ""
msgid "Failed to pull reading statistics"
msgstr ""
@@ -436,3 +436,9 @@ msgstr "Leesvoortgang gesynchroniseerd"
msgid "No saved reading progress found for this book"
msgstr "Geen opgeslagen leesvoortgang gevonden voor dit boek"
msgid "Failed to push reading statistics"
msgstr ""
msgid "Failed to pull reading statistics"
msgstr ""
@@ -436,3 +436,9 @@ msgstr "Postęp czytania zsynchronizowany"
msgid "No saved reading progress found for this book"
msgstr "Nie znaleziono zapisanego postępu czytania dla tej książki"
msgid "Failed to push reading statistics"
msgstr ""
msgid "Failed to pull reading statistics"
msgstr ""
@@ -436,3 +436,9 @@ msgstr "Progresso de leitura sincronizado"
msgid "No saved reading progress found for this book"
msgstr "Nenhum progresso de leitura salvo encontrado para este livro"
msgid "Failed to push reading statistics"
msgstr ""
msgid "Failed to pull reading statistics"
msgstr ""
@@ -436,3 +436,9 @@ msgstr "Progresso de leitura sincronizado"
msgid "No saved reading progress found for this book"
msgstr "Não foi encontrado progresso de leitura guardado para este livro"
msgid "Failed to push reading statistics"
msgstr ""
msgid "Failed to pull reading statistics"
msgstr ""
@@ -436,3 +436,9 @@ msgstr "Progres de citire sincronizat"
msgid "No saved reading progress found for this book"
msgstr "Nu s-a găsit progres de citire salvat pentru această carte"
msgid "Failed to push reading statistics"
msgstr ""
msgid "Failed to pull reading statistics"
msgstr ""
@@ -436,3 +436,9 @@ msgstr "Прогресс чтения синхронизирован"
msgid "No saved reading progress found for this book"
msgstr "Сохранённый прогресс чтения для этой книги не найден"
msgid "Failed to push reading statistics"
msgstr ""
msgid "Failed to pull reading statistics"
msgstr ""
@@ -436,3 +436,9 @@ msgstr "කියවීමේ ප්‍රගතිය සමමුහූර්
msgid "No saved reading progress found for this book"
msgstr "මෙම පොත සඳහා සුරකින ලද කියවීමේ ප්‍රගතියක් හමු නොවුණි"
msgid "Failed to push reading statistics"
msgstr ""
msgid "Failed to pull reading statistics"
msgstr ""
@@ -436,3 +436,9 @@ msgstr "Napredek branja sinhroniziran"
msgid "No saved reading progress found for this book"
msgstr "Za to knjigo ni shranjenega napredka branja"
msgid "Failed to push reading statistics"
msgstr ""
msgid "Failed to pull reading statistics"
msgstr ""
@@ -436,3 +436,9 @@ msgstr "Läsförloppet synkroniserat"
msgid "No saved reading progress found for this book"
msgstr "Inget sparat läsförlopp hittades för boken"
msgid "Failed to push reading statistics"
msgstr ""
msgid "Failed to pull reading statistics"
msgstr ""
@@ -436,3 +436,9 @@ msgstr "வாசிப்பு முன்னேற்றம் ஒத்த
msgid "No saved reading progress found for this book"
msgstr "இந்த நூலுக்கான சேமிக்கப்பட்ட வாசிப்பு முன்னேற்றம் இல்லை"
msgid "Failed to push reading statistics"
msgstr ""
msgid "Failed to pull reading statistics"
msgstr ""
@@ -436,3 +436,9 @@ msgstr "ซิงค์ความคืบหน้าการอ่านแ
msgid "No saved reading progress found for this book"
msgstr "ไม่พบความคืบหน้าการอ่านที่บันทึกไว้สำหรับหนังสือเล่มนี้"
msgid "Failed to push reading statistics"
msgstr ""
msgid "Failed to pull reading statistics"
msgstr ""
@@ -436,3 +436,9 @@ msgstr "Okuma ilerlemesi senkronize edildi"
msgid "No saved reading progress found for this book"
msgstr "Bu kitap için kayıtlı okuma ilerlemesi bulunamadı"
msgid "Failed to push reading statistics"
msgstr ""
msgid "Failed to pull reading statistics"
msgstr ""
@@ -436,3 +436,9 @@ msgstr "Прогрес читання синхронізовано"
msgid "No saved reading progress found for this book"
msgstr "Збережений прогрес читання для цієї книги не знайдено"
msgid "Failed to push reading statistics"
msgstr ""
msgid "Failed to pull reading statistics"
msgstr ""
@@ -436,3 +436,9 @@ msgstr "Oʻqish jarayoni sinxronlandi"
msgid "No saved reading progress found for this book"
msgstr "Ushbu kitob uchun saqlangan oʻqish jarayoni topilmadi"
msgid "Failed to push reading statistics"
msgstr ""
msgid "Failed to pull reading statistics"
msgstr ""
@@ -436,3 +436,9 @@ msgstr "Tiến trình đọc đã đồng bộ"
msgid "No saved reading progress found for this book"
msgstr "Không tìm thấy tiến trình đọc đã lưu cho sách này"
msgid "Failed to push reading statistics"
msgstr ""
msgid "Failed to pull reading statistics"
msgstr ""
@@ -436,3 +436,9 @@ msgstr "阅读进度已同步"
msgid "No saved reading progress found for this book"
msgstr "未找到此书籍的已保存阅读进度"
msgid "Failed to push reading statistics"
msgstr ""
msgid "Failed to pull reading statistics"
msgstr ""
@@ -436,3 +436,9 @@ msgstr "閱讀進度已同步"
msgid "No saved reading progress found for this book"
msgstr "找不到此書籍的閱讀進度"
msgid "Failed to push reading statistics"
msgstr ""
msgid "Failed to pull reading statistics"
msgstr ""
+24
View File
@@ -12,6 +12,7 @@ local _ = require("readest_i18n")
local SyncAuth = require("readest_syncauth")
local SyncConfig = require("readest_syncconfig")
local SyncAnnotations = require("readest_syncannotations")
local SyncStats = require("readest_syncstats")
local SelfUpdate = require("readest_selfupdate")
local ReadestSync = WidgetContainer:new{
@@ -91,6 +92,7 @@ function ReadestSync:onReaderReady()
UIManager:nextTick(function()
self:pullBookConfig(false)
self:pullBookNotes(false)
self:pullBookStats(false)
end)
end
self:onDispatcherRegisterReaderActions()
@@ -533,6 +535,27 @@ function ReadestSync:pullBookConfig(interactive)
)
end
-- ── Reading statistics sync ────────────────────────────────────────
function ReadestSync:pushBookStats(interactive)
if interactive and NetworkMgr:willRerunWhenOnline(function() self:pushBookStats(interactive) end) then
return
end
local client = self:ensureClient(interactive)
if not client then return end
SyncStats:push(self.settings, client, interactive)
end
function ReadestSync:pullBookStats(interactive)
if NetworkMgr:willRerunWhenOnline(function() self:pullBookStats(interactive) end) then
return
end
local client = self:ensureClient(interactive)
if not client then return end
SyncStats:pull(self.settings, client, interactive,
function() SyncAuth:logout(self.settings, self.path) end)
end
-- ── Annotation sync ────────────────────────────────────────────────
function ReadestSync:pushBookNotes(interactive, full_sync)
@@ -722,6 +745,7 @@ function ReadestSync:onCloseDocument()
NetworkMgr:goOnlineToRun(function()
self:pushBookConfig(false)
self:pushBookNotes(false)
self:pushBookStats(false)
self:syncBooksLibrary("both", false)
end)
end
+1 -1
View File
@@ -12,7 +12,7 @@
"path": "/sync",
"method": "POST",
"required_params": ["books", "notes", "configs"],
"payload": ["books", "notes", "configs"],
"payload": ["books", "notes", "configs", "statBooks", "statPages"],
"expected_status": [200, 201, 301, 400, 401, 403]
},
"pullBooks": {
+129
View File
@@ -0,0 +1,129 @@
local DataStorage = require("datastorage")
local InfoMessage = require("ui/widget/infomessage")
local UIManager = require("ui/uimanager")
local SQ3 = require("lua-ljsqlite3/init")
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)
local cursor = settings:readSetting("stats_push_cursor") or 0
local books, pages = self:collectSince(cursor)
if #pages == 0 then 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
client:pushChanges(
{ statBooks = books, statPages = pages },
function(success)
if success then
settings:saveSetting("stats_push_cursor", max_start)
elseif interactive then
UIManager:show(InfoMessage:new{ text = _("Failed to push reading statistics"), timeout = 2 })
end
end)
end
function SyncStats:pull(settings, client, interactive, logout_fn)
local since = settings:readSetting("stats_pull_cursor") or 0
-- 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)
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
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:saveSetting("stats_pull_cursor", newest) end
end)
end
return SyncStats
@@ -0,0 +1,149 @@
-- syncstats_spec.lua
-- Tests for readest_syncstats.lua: collectSince cursor filtering and
-- applyRemote upsert with max-duration conflict resolution.
local spec_helper = require("spec_helper")
-- Minimal KOReader stubs the module pulls at require-time.
package.preload["ui/widget/infomessage"] = function()
return { new = function() return {} end }
end
package.preload["ui/uimanager"] = function()
return { show = function() end }
end
package.preload["readest_i18n"] = function()
return function(s) return s end
end
local SQ3 = require("lua-ljsqlite3/init")
local DataStorage = require("datastorage")
local function statsDbPath()
return DataStorage:getSettingsDir() .. "/statistics.sqlite3"
end
local function seedDb()
local conn = SQ3.open(statsDbPath())
conn:exec([[
CREATE TABLE IF NOT EXISTS book (
id integer PRIMARY KEY autoincrement, title text, authors text, notes integer,
last_open integer, highlights integer, pages integer, series text, language text,
md5 text, total_read_time integer, total_read_pages integer);
CREATE UNIQUE INDEX IF NOT EXISTS book_title_authors_md5 ON book(title, authors, md5);
CREATE TABLE IF NOT EXISTS page_stat_data (
id_book integer, page integer NOT NULL DEFAULT 0,
start_time integer NOT NULL DEFAULT 0,
duration integer NOT NULL DEFAULT 0,
total_pages integer NOT NULL DEFAULT 0,
UNIQUE (id_book, page, start_time));
]])
conn:close()
end
describe("readest_syncstats", function()
local SyncStats
before_each(function()
spec_helper.reset()
os.remove(statsDbPath())
seedDb()
package.loaded["readest_syncstats"] = nil
SyncStats = require("readest_syncstats")
end)
it("collects only events past the cursor, joined with md5", function()
local conn = SQ3.open(statsDbPath())
conn:exec("INSERT INTO book (title, authors, md5) VALUES ('T', 'A', 'md5-1');")
conn:exec("INSERT INTO page_stat_data (id_book, page, start_time, duration, total_pages) VALUES (1, 1, 100, 5, 9);")
conn:exec("INSERT INTO page_stat_data (id_book, page, start_time, duration, total_pages) VALUES (1, 2, 200, 6, 9);")
conn:close()
local books, pages = SyncStats:collectSince(150)
assert.are.equal(1, #pages)
assert.are.equal(200, pages[1].start_time)
assert.are.equal("md5-1", pages[1].book_hash)
assert.are.equal(1, #books)
assert.are.equal("md5-1", books[1].book_hash)
end)
it("returns all events when cursor is 0", function()
local conn = SQ3.open(statsDbPath())
conn:exec("INSERT INTO book (title, authors, md5) VALUES ('B', 'C', 'md5-3');")
conn:exec("INSERT INTO page_stat_data (id_book, page, start_time, duration, total_pages) VALUES (1, 1, 10, 3, 5);")
conn:exec("INSERT INTO page_stat_data (id_book, page, start_time, duration, total_pages) VALUES (1, 2, 20, 4, 5);")
conn:close()
local books, pages = SyncStats:collectSince(0)
assert.are.equal(2, #pages)
assert.are.equal(1, #books)
end)
it("returns empty tables when no events are past the cursor", function()
local conn = SQ3.open(statsDbPath())
conn:exec("INSERT INTO book (title, authors, md5) VALUES ('T', 'A', 'md5-1');")
conn:exec("INSERT INTO page_stat_data (id_book, page, start_time, duration, total_pages) VALUES (1, 1, 100, 5, 9);")
conn:close()
local books, pages = SyncStats:collectSince(100)
assert.are.equal(0, #pages)
assert.are.equal(0, #books)
end)
it("keeps the longer duration when applying remote events", function()
SyncStats:applyRemote(
{ { book_hash = "md5-2", title = "T2", authors = "A2" } },
{
{ book_hash = "md5-2", page = 1, start_time = 300, duration = 8, total_pages = 20 },
{ book_hash = "md5-2", page = 1, start_time = 300, duration = 20, total_pages = 20 },
})
local conn = SQ3.open(statsDbPath())
local count = conn:rowexec("SELECT COUNT(*) FROM page_stat_data;")
local dur = conn:rowexec("SELECT duration FROM page_stat_data WHERE start_time = 300;")
conn:close()
assert.are.equal(1, tonumber(count))
assert.are.equal(20, tonumber(dur))
end)
it("inserts a new book row when applying remote data for an unknown md5", function()
SyncStats:applyRemote(
{ { book_hash = "new-md5", title = "New Book", authors = "Auth" } },
{ { book_hash = "new-md5", page = 5, start_time = 500, duration = 10, total_pages = 100 } })
local conn = SQ3.open(statsDbPath())
local count = conn:rowexec("SELECT COUNT(*) FROM book WHERE md5 = 'new-md5';")
local pcount = conn:rowexec("SELECT COUNT(*) FROM page_stat_data;")
conn:close()
assert.are.equal(1, tonumber(count))
assert.are.equal(1, tonumber(pcount))
end)
it("skips page rows whose book md5 has no matching book", function()
SyncStats:applyRemote(
{},
{ { book_hash = "ghost-md5", page = 1, start_time = 999, duration = 5, total_pages = 10 } })
local conn = SQ3.open(statsDbPath())
local pcount = conn:rowexec("SELECT COUNT(*) FROM page_stat_data;")
conn:close()
assert.are.equal(0, tonumber(pcount))
end)
it("recomputes book totals after applying remote events", function()
SyncStats:applyRemote(
{ { book_hash = "md5-tot", title = "Tot", authors = "A" } },
{
{ book_hash = "md5-tot", page = 1, start_time = 100, duration = 8, total_pages = 50 },
{ book_hash = "md5-tot", page = 2, start_time = 200, duration = 12, total_pages = 50 },
})
local conn = SQ3.open(statsDbPath())
local total_time = conn:rowexec("SELECT total_read_time FROM book WHERE md5 = 'md5-tot';")
local total_pages = conn:rowexec("SELECT total_read_pages FROM book WHERE md5 = 'md5-tot';")
local last_open = conn:rowexec("SELECT last_open FROM book WHERE md5 = 'md5-tot';")
conn:close()
assert.are.equal(20, tonumber(total_time)) -- 8 + 12
assert.are.equal(2, tonumber(total_pages)) -- distinct pages 1,2
assert.are.equal(212, tonumber(last_open)) -- max(start_time + duration) = 200 + 12
end)
end)
@@ -0,0 +1,41 @@
-- Migration 014: reading statistics sync (KOReader-compatible page events)
CREATE TABLE IF NOT EXISTS public.stat_books (
user_id uuid NOT NULL,
book_hash text NOT NULL,
title text NOT NULL DEFAULT '',
authors text NOT NULL DEFAULT '',
updated_at timestamp with time zone NOT NULL DEFAULT now(),
deleted_at timestamp with time zone NULL,
CONSTRAINT stat_books_pkey PRIMARY KEY (user_id, book_hash),
CONSTRAINT stat_books_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users (id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_stat_books_user_updated ON public.stat_books (user_id, updated_at);
CREATE TABLE IF NOT EXISTS public.stat_pages (
user_id uuid NOT NULL,
book_hash text NOT NULL,
page integer NOT NULL,
start_time bigint NOT NULL,
duration integer NOT NULL DEFAULT 0,
total_pages integer NOT NULL DEFAULT 0,
ext jsonb NULL,
updated_at timestamp with time zone NOT NULL DEFAULT now(),
deleted_at timestamp with time zone NULL,
CONSTRAINT stat_pages_pkey PRIMARY KEY (user_id, book_hash, page, start_time),
CONSTRAINT stat_pages_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users (id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_stat_pages_user_updated ON public.stat_pages (user_id, updated_at);
ALTER TABLE public.stat_books ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.stat_pages ENABLE ROW LEVEL SECURITY;
CREATE POLICY stat_books_select ON public.stat_books FOR SELECT TO authenticated USING ((SELECT auth.uid()) = user_id);
CREATE POLICY stat_books_insert ON public.stat_books FOR INSERT TO authenticated WITH CHECK ((SELECT auth.uid()) = user_id);
CREATE POLICY stat_books_update ON public.stat_books FOR UPDATE TO authenticated USING ((SELECT auth.uid()) = user_id);
CREATE POLICY stat_books_delete ON public.stat_books FOR DELETE TO authenticated USING ((SELECT auth.uid()) = user_id);
CREATE POLICY stat_pages_select ON public.stat_pages FOR SELECT TO authenticated USING ((SELECT auth.uid()) = user_id);
CREATE POLICY stat_pages_insert ON public.stat_pages FOR INSERT TO authenticated WITH CHECK ((SELECT auth.uid()) = user_id);
CREATE POLICY stat_pages_update ON public.stat_pages FOR UPDATE to authenticated USING ((SELECT auth.uid()) = user_id);
CREATE POLICY stat_pages_delete ON public.stat_pages FOR DELETE TO authenticated USING ((SELECT auth.uid()) = user_id);