From 7a489db4ef3cf27ea2407cb28253efd82e8ed820 Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Sun, 20 Sep 2026 00:07:30 +0000 Subject: [PATCH 1/2] Let users remove a synced book from the dashboard The Synced books page could only ever show books - once something synced (a book you no longer have, a test file, a duplicate hash), there was no way to get it off the dashboard or out of kosync. Each book now has a Remove button. It calls a new DELETE /api/v1/progress/{document}, which clears every row that book owns for that user: progress for all devices, position samples, document metadata, bookmarks, clippings, per-book stats, connector matches and any queued connector events. The device's own kosync GET goes back to returning {}, so a reader that still holds the file starts over instead of restoring the old position. Bookmarks and clippings are hard-deleted rather than tombstoned - with the book gone there is nothing left to delta-sync against, and the confirm dialog says so. Also adds progress_samples to the account-level delete, which had been leaving those rows behind when a sync account or login was deleted. --- docs/API.md | 19 +++++ src/models/document.ts | 52 +++++++++++++ src/routes/account.ts | 1 + src/routes/v1/progress.ts | 19 +++++ src/routes/web.ts | 71 +++++++++++++----- test/progress.test.ts | 152 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 297 insertions(+), 17 deletions(-) create mode 100644 src/models/document.ts diff --git a/docs/API.md b/docs/API.md index cc612bb..241eb9e 100644 --- a/docs/API.md +++ b/docs/API.md @@ -254,6 +254,25 @@ All device rows, newest first — the client decides what to apply: `position` is `null` for rows written by plain kosync clients. +#### DELETE /api/v1/progress/{document} + +Removes a synced book completely. Deletes the kosync progress for **every** device plus everything +else stored server-side for that book: position samples, bookmarks, clippings, per-book reading +stats, connector matches and any queued connector events. Document metadata goes too, so the book +disappears from `GET /api/v1/progress` and `GET /api/v1/documents`, and `GET +/syncs/progress/{document}` goes back to returning `{}`. + +```json +{"document": "a1b2c3d4e5f60718293a4b5c6d7e8f90", "deleted": true, "rows": 14} +``` + +`rows` is the number of database rows removed. Returns `404 {"code": 2003, "message": "Unknown +document"}` when the user has no data for that document. + +Bookmarks and clippings are hard-deleted rather than tombstoned — there is no book left to +delta-sync against. A device that still holds the file simply re-uploads its state on the next +sync, so this is a server-side reset, not a device-side delete. + ### Bookmarks Item ids are **client-derived**: `id = first 16 hex chars of SHA-256(xpath)`. Deterministic, so diff --git a/src/models/document.ts b/src/models/document.ts new file mode 100644 index 0000000..0ccefd9 --- /dev/null +++ b/src/models/document.ts @@ -0,0 +1,52 @@ +import { withTransaction, type DB } from '../db/db.js'; + +/** + * Every table that holds per-(user, document) reading data. Removing a book + * means clearing all of them: the dashboard lists books straight off `progress`, + * so leaving bookmarks/clippings/stats behind would strand rows no UI can reach. + * Ordered children-first; nothing here has FK dependencies, but it keeps the + * intent obvious next to the account-level delete in routes/account.ts. + */ +const DOCUMENT_TABLES = [ + 'connector_queue', + 'connector_matches', + 'stats_device_book', + 'clippings', + 'bookmarks', + 'progress_samples', + 'progress', + 'documents', +] as const; + +/** True when the user has any stored data at all for this document. */ +export function hasDocumentData(db: DB, userId: number, document: string): boolean { + for (const table of DOCUMENT_TABLES) { + const row = db + .prepare(`SELECT 1 FROM ${table} WHERE user_id = ? AND document = ? LIMIT 1`) + .get(userId, document); + if (row) return true; + } + return false; +} + +/** + * Permanently delete one book's synced data for a user: kosync progress (all + * devices), position samples, bookmarks, clippings, per-book stats, connector + * matches and any queued connector events. Returns the number of rows removed. + * + * Bookmarks and clippings are hard-deleted rather than tombstoned - the book is + * gone, so there is nothing left for a device to delta-sync against. A device + * that still holds the book simply re-uploads it on the next sync. + */ +export function deleteDocumentData(db: DB, userId: number, document: string): number { + let rows = 0; + withTransaction(db, () => { + for (const table of DOCUMENT_TABLES) { + const result = db + .prepare(`DELETE FROM ${table} WHERE user_id = ? AND document = ?`) + .run(userId, document); + rows += Number(result.changes); + } + }); + return rows; +} diff --git a/src/routes/account.ts b/src/routes/account.ts index 181780a..6109add 100644 --- a/src/routes/account.ts +++ b/src/routes/account.ts @@ -23,6 +23,7 @@ function deleteKosyncUserData(db: DB, userId: number, username: string): void { 'bookmarks', 'documents', 'progress', + 'progress_samples', ]) { db.prepare(`DELETE FROM ${table} WHERE user_id = ?`).run(userId); } diff --git a/src/routes/v1/progress.ts b/src/routes/v1/progress.ts index 47a6fdf..32a32a6 100644 --- a/src/routes/v1/progress.ts +++ b/src/routes/v1/progress.ts @@ -3,6 +3,7 @@ import { Hono } from 'hono'; import type { DB } from '../../db/db.js'; import { kosyncError, type AppEnv } from '../../auth/middleware.js'; import { isValidDocument, parseProgressBody, upsertProgress } from '../kosync.js'; +import { deleteDocumentData, hasDocumentData } from '../../models/document.js'; import { fanOutProgress } from '../../connectors/fanout.js'; export function progressRoutes(db: DB, refreshProgress: ProgressRefresh = async () => {}): Hono { @@ -116,5 +117,23 @@ export function progressRoutes(db: DB, refreshProgress: ProgressRefresh = async }); }); + // Remove a synced book entirely: kosync progress for every device plus the + // rest of that book's server-side data (samples, bookmarks, clippings, + // per-book stats, connector matches and queued connector events). Lets a user + // clear a book off their dashboard - e.g. one synced from a file they no + // longer have. Devices that still hold the book re-sync it from scratch. + app.delete('/progress/:document', (c) => { + const document = c.req.param('document'); + if (!isValidDocument(document)) { + return kosyncError(c, 403, 2004, "Field 'document' not provided."); + } + const user = c.get('user'); + if (!hasDocumentData(db, user.id, document)) { + return c.json({ code: 2003, message: 'Unknown document' }, 404); + } + const rows = deleteDocumentData(db, user.id, document); + return c.json({ document, deleted: true, rows }); + }); + return app; } diff --git a/src/routes/web.ts b/src/routes/web.ts index ff515f6..f63cf59 100644 --- a/src/routes/web.ts +++ b/src/routes/web.ts @@ -172,6 +172,8 @@ const STYLE = ` .sync-book:last-child { padding-bottom:0; } .sync-book .title { font-weight:600; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .sync-book .meta { color:var(--stone-500); font-size:12px; margin-top:3px; } + .sync-book .actions { display:flex; align-items:center; gap:10px; flex:0 0 auto; } + button.sm { padding:6px 11px; font-size:13px; } .progress-track { height:6px; border-radius:999px; background:var(--stone-100); overflow:hidden; margin-top:10px; } .progress-fill { height:100%; border-radius:inherit; background:var(--brand-500); } `; @@ -346,7 +348,7 @@ const ACCOUNT = shell(

Reading progress

-
Synced books
View titles, percentages, devices, and sync times.
+
Synced books
View titles, percentages, devices and sync times, or remove a book.

Linked services

@@ -650,31 +652,66 @@ const PROGRESS = shell( `
← Account
Reading progress

Synced books

-

All synced books with their latest progress.

+

All synced books with their latest progress. Removing a book deletes its synced progress and everything else stored here for it.

+

Loading…

` ); diff --git a/test/progress.test.ts b/test/progress.test.ts index f649996..4e2b71a 100644 --- a/test/progress.test.ts +++ b/test/progress.test.ts @@ -141,3 +141,155 @@ describe('v1 rich progress', () => { } }); }); + +describe('removing a synced book', () => { + const OTHER_DOC = 'ffeeddccbbaa99887766554433221100'; + + const BOOK_STATS = { + v: 5, + sessions: 9, + seconds: 8400, + pages: 310, + completed: false, + avg_fwd: 12, + pace_n: 250, + eta: 5400, + start_manual: false, + finish_manual: false, + start_date: 1751000000, + finished_date: 0, + tod: [0, 3000, 4000, 1400], + dow: [0, 0, 1200, 0, 2000, 3000, 2200], + }; + + /** + * Seeds one document the way a device would: kosync progress (which also + * records a position sample), metadata, a bookmark, a clipping and per-book + * reading stats - i.e. a row in every table a removal has to clear. + */ + async function seedBook( + { app, db }: ReturnType, + headers: Record, + document: string + ) { + await app.request('/syncs/progress', { + method: 'PUT', + headers, + body: JSON.stringify({ + document, + progress: POSITION.xpath, + percentage: 0.4867, + device: 'CrossPoint', + device_id: 'aaaa', + position: POSITION, + }), + }); + await app.request('/api/v1/documents', { + method: 'PUT', + headers, + body: JSON.stringify({ items: [{ document, title: 'Foundryside', author: 'RJB' }] }), + }); + await app.request(`/api/v1/bookmarks/${document}`, { + method: 'PUT', + headers, + body: JSON.stringify({ + items: [{ id: '0123456789abcdef', xpath: '/body/p[1]', percentage: 0.1, summary: 'note' }], + }), + }); + await app.request(`/api/v1/clippings/${document}`, { + method: 'PUT', + headers, + body: JSON.stringify({ items: [{ id: 'fedcba9876543210', spine: 3, text: 'a highlight' }] }), + }); + await app.request('/api/v1/stats/books', { + method: 'PUT', + headers, + body: JSON.stringify({ device_id: 'aaaa', items: [{ document, ...BOOK_STATS }] }), + }); + // Connector rows have no test-friendly HTTP path (linking needs a live + // service), so seed the two document-keyed tables directly. + const userId = (db.prepare('SELECT id FROM users WHERE username = ?').get(headers['x-auth-user']) as { id: number }).id; + db.prepare( + `INSERT INTO connector_matches (user_id, connector_id, document, external_id, confidence, source, updated_at) + VALUES (?, 'hardcover', ?, '42', 1, 'auto', 1)` + ).run(userId, document); + db.prepare( + `INSERT INTO connector_queue (user_id, connector_id, document, kind, payload, next_try_at, created_at, updated_at) + VALUES (?, 'hardcover', ?, 'progress', '{}', 0, 1, 1)` + ).run(userId, document); + } + + it('DELETE clears the kosync progress and the rest of that book, leaving others alone', async () => { + const server = makeTestApp(); + const { app, db } = server; + const { headers } = await registerUser(app); + await seedBook(server, headers, DOC); + await seedBook(server, headers, OTHER_DOC); + + const res = await app.request(`/api/v1/progress/${DOC}`, { method: 'DELETE', headers }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toMatchObject({ document: DOC, deleted: true }); + expect(body.rows).toBeGreaterThan(0); + + // The book is gone from the dashboard list and from kosync itself. + const list = await (await app.request('/api/v1/progress', { headers })).json(); + expect(list.items.map((i: { document: string }) => i.document)).toEqual([OTHER_DOC]); + const kosync = await app.request(`/syncs/progress/${DOC}`, { headers }); + expect(kosync.status).toBe(200); + expect(await kosync.json()).toEqual({}); + const devices = await (await app.request(`/api/v1/progress/${DOC}`, { headers })).json(); + expect(devices.devices).toEqual([]); + + // ...along with its metadata, highlights, bookmarks, samples and stats. + for (const table of [ + 'documents', + 'bookmarks', + 'clippings', + 'progress', + 'progress_samples', + 'stats_device_book', + 'connector_matches', + 'connector_queue', + ]) { + const left = db + .prepare(`SELECT document FROM ${table} WHERE document = ?`) + .all(DOC) as unknown[]; + expect(left, `${table} still has rows for the removed book`).toEqual([]); + const kept = db + .prepare(`SELECT document FROM ${table} WHERE document = ?`) + .all(OTHER_DOC) as unknown[]; + expect(kept.length, `${table} lost rows for the other book`).toBeGreaterThan(0); + } + + // The other book still reads back intact. + const other = await (await app.request(`/syncs/progress/${OTHER_DOC}`, { headers })).json(); + expect(other.document).toBe(OTHER_DOC); + }); + + it('DELETE only touches the caller, and 404s on a document with no data', async () => { + const server = makeTestApp(); + const { app } = server; + const a = await registerUser(app); + const b = await registerUser(app); + await seedBook(server, a.headers, DOC); + await seedBook(server, b.headers, DOC); + + // Same document hash, different user: B's copy must survive A's removal. + expect((await app.request(`/api/v1/progress/${DOC}`, { method: 'DELETE', headers: a.headers })).status).toBe(200); + const bList = await (await app.request('/api/v1/progress', { headers: b.headers })).json(); + expect(bList.items).toHaveLength(1); + + // Already removed for A - nothing left to delete. + const again = await app.request(`/api/v1/progress/${DOC}`, { method: 'DELETE', headers: a.headers }); + expect(again.status).toBe(404); + expect((await again.json()).message).toBe('Unknown document'); + }); + + it('DELETE rejects a malformed document id', async () => { + const { app } = makeTestApp(); + const { headers } = await registerUser(app); + const res = await app.request('/api/v1/progress/not%20a%20hash!', { method: 'DELETE', headers }); + expect(res.status).toBe(403); + }); +}); From a8985bc5a45bef7fc9e882a960c9251c24b35914 Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Sun, 20 Sep 2026 00:14:00 +0000 Subject: [PATCH 2/2] Keep the Remove button usable when the delete request fails outright MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fetch() rejects rather than resolving when the network is down, so the rejection escaped removeBook and left the button disabled on 'Removing…' with no error - the user had to reload the page to try again. Treat a rejection as an ordinary failed response so the existing error path re-enables the button. --- src/routes/web.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/routes/web.ts b/src/routes/web.ts index f63cf59..dfaeb72 100644 --- a/src/routes/web.ts +++ b/src/routes/web.ts @@ -696,7 +696,11 @@ async function removeBook(btn) { + 'book — opening it again starts syncing from scratch.')) return; $('err').textContent = ''; btn.disabled = true; btn.textContent = 'Removing…'; - const r = await jsend('/api/v1/progress/' + encodeURIComponent(doc), 'DELETE'); + // fetch rejects outright when the network is down; treat that as an ordinary + // failed response so the one error path below re-enables the button. + let r; + try { r = await jsend('/api/v1/progress/' + encodeURIComponent(doc), 'DELETE'); } + catch { r = { ok:false, status:0, data:{} }; } if (!r.ok && r.status !== 404) { $('err').textContent = r.data.message || 'Could not remove this book.'; btn.disabled = false; btn.textContent = 'Remove';