diff --git a/docs/api.md b/docs/api.md index c551841..1468a6a 100644 --- a/docs/api.md +++ b/docs/api.md @@ -829,6 +829,14 @@ manifest 的每个条目还带 `ref`,即这个文件对应的**资源引用** → `{ "progress": {...} | null }` +### `GET /sync/progress/:bookId/history` + +`?limit=`(默认 30,最大 50)→ `{ "history": [ {...}, ... ] }`,字段同上,按 `updatedAt` 倒序。 + +阅读记录:每次有效的进度写入都会记下,**包括在「后写者胜」合并中输掉的那次**(例如离线设备的旧位置)。 +同一设备在同一章、半小时内、进度相差不到 5% 的连续写入合并成一条并向前移动;换章、跳转、换设备、乱序到达的写入各成一条。 +每本书保留最新 50 条,只返回当前账号自己的记录。 + ### `PUT /sync/progress/:bookId` 单个进度上报的便捷写法: diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 9573c1f..d3e32b5 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -237,6 +237,25 @@ CREATE TABLE IF NOT EXISTS reading_progress ( ); CREATE INDEX IF NOT EXISTS idx_progress_updated ON reading_progress(user_id, updated_at); +-- Where the reader has been in a book, so a jump can be undone (P0: 进度历史). +-- reading_progress keeps one row and last-writer-wins, which is right for "where am +-- I" and wrong for "where was I": a stale device, a mistapped 目录 entry or a device +-- whose clock runs fast all overwrite the place for good. Every valid progress write +-- lands here too — including one that lost the merge — and a device reading on +-- through one chapter updates its latest entry instead of adding rows. A new table, +-- so older builds that share this database simply do not see it. +CREATE TABLE IF NOT EXISTS reading_progress_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + book_id TEXT NOT NULL REFERENCES books(id) ON DELETE CASCADE, + locator TEXT NOT NULL DEFAULT '', + percentage REAL NOT NULL DEFAULT 0, + chapter_title TEXT NOT NULL DEFAULT '', + device TEXT NOT NULL DEFAULT '', + updated_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_progress_history ON reading_progress_history(user_id, book_id, updated_at); + -- Notes, highlights and bookmarks. deleted is a tombstone rather than a hard -- delete so that an offline client cannot resurrect a note it removed elsewhere. CREATE TABLE IF NOT EXISTS notes ( diff --git a/server/src/http/routes/sync.ts b/server/src/http/routes/sync.ts index 4aa9e42..e92c316 100644 --- a/server/src/http/routes/sync.ts +++ b/server/src/http/routes/sync.ts @@ -55,6 +55,13 @@ export function registerSyncRoutes(app: FastifyInstance, ctx: AppContext): void return { progress: ctx.sync.progressFor(user.id, bookId) }; }); + app.get('/api/v1/sync/progress/:bookId/history', { preHandler: auth }, async (request) => { + const user = currentUser(request); + const { bookId } = request.params as { bookId: string }; + const limit = Number((request.query as { limit?: string }).limit) || 30; + return { history: ctx.sync.progressHistory(user.id, bookId, limit) }; + }); + app.put('/api/v1/sync/progress/:bookId', { preHandler: auth }, async (request) => { const user = currentUser(request); const { bookId } = request.params as { bookId: string }; diff --git a/server/src/services/sync.ts b/server/src/services/sync.ts index 179c52a..b79e8ad 100644 --- a/server/src/services/sync.ts +++ b/server/src/services/sync.ts @@ -11,6 +11,11 @@ import type { BookDto, ShelfService } from './shelf.ts'; * offline for a week can push its local changes and receive only what changed * on the server since `since`. */ +/** Progress history: entries kept per book, and what still counts as reading on in one place. */ +const HISTORY_KEEP = 50; +const HISTORY_SESSION_MS = 30 * 60 * 1000; +const HISTORY_JUMP = 0.05; + export interface ProgressRecord { bookId: string; /** Opaque locator: EPUB CFI, pagination index or PDF page. Client-defined. */ @@ -123,6 +128,61 @@ export class SyncService { userId, record.bookId, record.locator ?? '', percentage, record.chapterTitle ?? '', record.device ?? '', record.updatedAt, ); + this.recordHistory(userId, record, percentage); + } + + /** + * Adds a progress write to the book's history (schema: reading_progress_history). + * + * Reading on through one chapter on one device is one entry that moves forward, + * not a row per page: the latest entry of the same device is updated when the + * write is newer, in the same chapter, within 5% of it and within half an hour. + * Anything else — another chapter, a jump, another device, a write that arrives + * out of order — is a new entry, because those are exactly the places a reader may + * want back. The newest 50 per book are kept. + */ + private recordHistory(userId: string, record: ProgressRecord, percentage: number): void { + const device = record.device ?? '', chapter = record.chapterTitle ?? ''; + const latest = this.db.get<{ id: number; chapter_title: string; percentage: number; updated_at: number }>( + `SELECT id, chapter_title, percentage, updated_at FROM reading_progress_history + WHERE user_id = ? AND book_id = ? AND device = ? ORDER BY updated_at DESC, id DESC LIMIT 1`, + userId, record.bookId, device, + ); + const continues = latest + && record.updatedAt >= latest.updated_at + && record.updatedAt - latest.updated_at <= HISTORY_SESSION_MS + && latest.chapter_title === chapter + && Math.abs(latest.percentage - percentage) < HISTORY_JUMP; + if (continues) { + this.db.run( + 'UPDATE reading_progress_history SET locator = ?, percentage = ?, updated_at = ? WHERE id = ?', + record.locator ?? '', percentage, record.updatedAt, latest.id, + ); + return; + } + this.db.run( + `INSERT INTO reading_progress_history (user_id, book_id, locator, percentage, chapter_title, device, updated_at) + VALUES (?,?,?,?,?,?,?)`, + userId, record.bookId, record.locator ?? '', percentage, chapter, device, record.updatedAt, + ); + this.db.run( + `DELETE FROM reading_progress_history WHERE user_id = ? AND book_id = ? AND id NOT IN ( + SELECT id FROM reading_progress_history WHERE user_id = ? AND book_id = ? + ORDER BY updated_at DESC, id DESC LIMIT ?)`, + userId, record.bookId, userId, record.bookId, HISTORY_KEEP, + ); + } + + /** The book's reading history, newest first. */ + progressHistory(userId: string, bookId: string, limit = 30): ProgressRecord[] { + return this.db.all<{ locator: string; percentage: number; chapter_title: string; device: string; updated_at: number }>( + `SELECT locator, percentage, chapter_title, device, updated_at FROM reading_progress_history + WHERE user_id = ? AND book_id = ? ORDER BY updated_at DESC, id DESC LIMIT ?`, + userId, bookId, Math.max(1, Math.min(HISTORY_KEEP, limit)), + ).map((row) => ({ + bookId, locator: row.locator, percentage: row.percentage, + chapterTitle: row.chapter_title, device: row.device, updatedAt: row.updated_at, + })); } private upsertNote(userId: string, record: NoteRecord): void { diff --git a/server/test/api.test.ts b/server/test/api.test.ts index b588396..efa0d0c 100644 --- a/server/test/api.test.ts +++ b/server/test/api.test.ts @@ -294,6 +294,35 @@ test('a stale offline device cannot roll back newer progress', async () => { assert.equal((view.json() as { progress: { locator: string } }).progress.locator, 'fresh'); }); +test('progress history keeps the places a write replaced, one entry per stretch of reading', async () => { + const owner = await login('owner'); + const member = await login('family'); + const book = ctx.db.get<{ id: string }>('SELECT id FROM books')!; + const push = (items: Array<{ locator: string; percentage: number; chapterTitle: string; device: string; updatedAt: number }>, token = owner.token) => + app.inject({ method: 'POST', url: '/api/v1/sync', headers: auth(token), payload: { progress: items.map(item => ({ bookId: book.id, ...item })) } }); + const minute = 60_000, base = 1_000_000_000_000; + // Reading on through chapter 3 on the phone: one entry that moves forward. + await push([{ locator: 'c3-a', percentage: 0.30, chapterTitle: '第三章', device: 'phone', updatedAt: base }]); + await push([{ locator: 'c3-b', percentage: 0.31, chapterTitle: '第三章', device: 'phone', updatedAt: base + minute }]); + // A mistapped 目录 entry jumps to chapter 40: a new entry, and chapter 3 stays restorable. + await push([{ locator: 'c40', percentage: 0.90, chapterTitle: '第四十章', device: 'phone', updatedAt: base + 2 * minute }]); + // A stale tablet loses the merge, but where it was is history too. + await push([{ locator: 'c2', percentage: 0.20, chapterTitle: '第二章', device: 'tablet', updatedAt: base - minute }]); + const read = async (token = owner.token) => (await app.inject({ method: 'GET', url: `/api/v1/sync/progress/${book.id}/history`, headers: auth(token) })).json() as { history: Array<{ locator: string; device: string }> }; + // Newest first; earlier tests in this file left older entries for the same book behind. + assert.deepEqual((await read()).history.slice(0, 3).map(item => [item.locator, item.device]), [['c40', 'phone'], ['c3-b', 'phone'], ['c2', 'tablet']]); + const current = await app.inject({ method: 'GET', url: `/api/v1/sync/progress/${book.id}`, headers: auth(owner.token) }); + assert.equal((current.json() as { progress: { locator: string } }).progress.locator, 'c40'); + // Another account sees none of it (only its own places). + assert.ok((await read(member.token)).history.every(item => !['c40', 'c3-b', 'c2'].includes(item.locator))); + // Kept bounded: the newest 50. + await push(Array.from({ length: 60 }, (_, index) => ({ locator: `x${index}`, percentage: index / 60, chapterTitle: `第${index}章`, device: 'phone', updatedAt: base + (10 + index) * minute }))); + const all = await app.inject({ method: 'GET', url: `/api/v1/sync/progress/${book.id}/history?limit=100`, headers: auth(owner.token) }); + const entries = (all.json() as { history: Array<{ locator: string }> }).history; + assert.equal(entries.length, 50); + assert.equal(entries[0]!.locator, 'x59'); +}); + test('deleting a note leaves a tombstone that a stale device cannot undo', async () => { const owner = await login('owner'); const book = ctx.db.get<{ id: string }>('SELECT id FROM books')!;