Skip to content

Commit 7a489db

Browse files
committed
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.
1 parent 062fd67 commit 7a489db

6 files changed

Lines changed: 297 additions & 17 deletions

File tree

docs/API.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,25 @@ All device rows, newest first — the client decides what to apply:
254254

255255
`position` is `null` for rows written by plain kosync clients.
256256

257+
#### DELETE /api/v1/progress/{document}
258+
259+
Removes a synced book completely. Deletes the kosync progress for **every** device plus everything
260+
else stored server-side for that book: position samples, bookmarks, clippings, per-book reading
261+
stats, connector matches and any queued connector events. Document metadata goes too, so the book
262+
disappears from `GET /api/v1/progress` and `GET /api/v1/documents`, and `GET
263+
/syncs/progress/{document}` goes back to returning `{}`.
264+
265+
```json
266+
{"document": "a1b2c3d4e5f60718293a4b5c6d7e8f90", "deleted": true, "rows": 14}
267+
```
268+
269+
`rows` is the number of database rows removed. Returns `404 {"code": 2003, "message": "Unknown
270+
document"}` when the user has no data for that document.
271+
272+
Bookmarks and clippings are hard-deleted rather than tombstoned — there is no book left to
273+
delta-sync against. A device that still holds the file simply re-uploads its state on the next
274+
sync, so this is a server-side reset, not a device-side delete.
275+
257276
### Bookmarks
258277

259278
Item ids are **client-derived**: `id = first 16 hex chars of SHA-256(xpath)`. Deterministic, so

src/models/document.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { withTransaction, type DB } from '../db/db.js';
2+
3+
/**
4+
* Every table that holds per-(user, document) reading data. Removing a book
5+
* means clearing all of them: the dashboard lists books straight off `progress`,
6+
* so leaving bookmarks/clippings/stats behind would strand rows no UI can reach.
7+
* Ordered children-first; nothing here has FK dependencies, but it keeps the
8+
* intent obvious next to the account-level delete in routes/account.ts.
9+
*/
10+
const DOCUMENT_TABLES = [
11+
'connector_queue',
12+
'connector_matches',
13+
'stats_device_book',
14+
'clippings',
15+
'bookmarks',
16+
'progress_samples',
17+
'progress',
18+
'documents',
19+
] as const;
20+
21+
/** True when the user has any stored data at all for this document. */
22+
export function hasDocumentData(db: DB, userId: number, document: string): boolean {
23+
for (const table of DOCUMENT_TABLES) {
24+
const row = db
25+
.prepare(`SELECT 1 FROM ${table} WHERE user_id = ? AND document = ? LIMIT 1`)
26+
.get(userId, document);
27+
if (row) return true;
28+
}
29+
return false;
30+
}
31+
32+
/**
33+
* Permanently delete one book's synced data for a user: kosync progress (all
34+
* devices), position samples, bookmarks, clippings, per-book stats, connector
35+
* matches and any queued connector events. Returns the number of rows removed.
36+
*
37+
* Bookmarks and clippings are hard-deleted rather than tombstoned - the book is
38+
* gone, so there is nothing left for a device to delta-sync against. A device
39+
* that still holds the book simply re-uploads it on the next sync.
40+
*/
41+
export function deleteDocumentData(db: DB, userId: number, document: string): number {
42+
let rows = 0;
43+
withTransaction(db, () => {
44+
for (const table of DOCUMENT_TABLES) {
45+
const result = db
46+
.prepare(`DELETE FROM ${table} WHERE user_id = ? AND document = ?`)
47+
.run(userId, document);
48+
rows += Number(result.changes);
49+
}
50+
});
51+
return rows;
52+
}

src/routes/account.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ function deleteKosyncUserData(db: DB, userId: number, username: string): void {
2323
'bookmarks',
2424
'documents',
2525
'progress',
26+
'progress_samples',
2627
]) {
2728
db.prepare(`DELETE FROM ${table} WHERE user_id = ?`).run(userId);
2829
}

src/routes/v1/progress.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Hono } from 'hono';
33
import type { DB } from '../../db/db.js';
44
import { kosyncError, type AppEnv } from '../../auth/middleware.js';
55
import { isValidDocument, parseProgressBody, upsertProgress } from '../kosync.js';
6+
import { deleteDocumentData, hasDocumentData } from '../../models/document.js';
67
import { fanOutProgress } from '../../connectors/fanout.js';
78

89
export function progressRoutes(db: DB, refreshProgress: ProgressRefresh = async () => {}): Hono<AppEnv> {
@@ -116,5 +117,23 @@ export function progressRoutes(db: DB, refreshProgress: ProgressRefresh = async
116117
});
117118
});
118119

120+
// Remove a synced book entirely: kosync progress for every device plus the
121+
// rest of that book's server-side data (samples, bookmarks, clippings,
122+
// per-book stats, connector matches and queued connector events). Lets a user
123+
// clear a book off their dashboard - e.g. one synced from a file they no
124+
// longer have. Devices that still hold the book re-sync it from scratch.
125+
app.delete('/progress/:document', (c) => {
126+
const document = c.req.param('document');
127+
if (!isValidDocument(document)) {
128+
return kosyncError(c, 403, 2004, "Field 'document' not provided.");
129+
}
130+
const user = c.get('user');
131+
if (!hasDocumentData(db, user.id, document)) {
132+
return c.json({ code: 2003, message: 'Unknown document' }, 404);
133+
}
134+
const rows = deleteDocumentData(db, user.id, document);
135+
return c.json({ document, deleted: true, rows });
136+
});
137+
119138
return app;
120139
}

src/routes/web.ts

Lines changed: 54 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,8 @@ const STYLE = `
172172
.sync-book:last-child { padding-bottom:0; }
173173
.sync-book .title { font-weight:600; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
174174
.sync-book .meta { color:var(--stone-500); font-size:12px; margin-top:3px; }
175+
.sync-book .actions { display:flex; align-items:center; gap:10px; flex:0 0 auto; }
176+
button.sm { padding:6px 11px; font-size:13px; }
175177
.progress-track { height:6px; border-radius:999px; background:var(--stone-100); overflow:hidden; margin-top:10px; }
176178
.progress-fill { height:100%; border-radius:inherit; background:var(--brand-500); }
177179
`;
@@ -346,7 +348,7 @@ const ACCOUNT = shell(
346348
347349
348350
<h2 style="${SECTION}">Reading progress</h2>
349-
<div class="card"><div class="row"><div><div style="font-weight:600">Synced books</div><div class="muted" style="margin-top:3px">View titles, percentages, devices, and sync times.</div></div><a href="/progress"><button class="ghost">View</button></a></div></div>
351+
<div class="card"><div class="row"><div><div style="font-weight:600">Synced books</div><div class="muted" style="margin-top:3px">View titles, percentages, devices and sync times, or remove a book.</div></div><a href="/progress"><button class="ghost">View</button></a></div></div>
350352
351353
<h2 style="${SECTION}">Linked services</h2>
352354
<div class="notice" style="margin-bottom:12px">
@@ -650,31 +652,66 @@ const PROGRESS = shell(
650652
`<div><a class="muted" href="/account">&larr; Account</a></div>
651653
<div style="margin-top:16px"><span class="eyebrow">Reading progress</span>
652654
<h1>Synced books</h1>
653-
<p class="sub">All synced books with their latest progress.</p></div>
655+
<p class="sub">All synced books with their latest progress. Removing a book deletes its synced progress and everything else stored here for it.</p></div>
656+
<div class="err" id="err"></div>
654657
<div id="list" style="margin-top:8px"><p class="muted">Loading…</p></div>
655658
656659
<script>
657660
const $ = (id) => document.getElementById(id);
658661
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));
659662
async function jget(u){ const r = await fetch(u); return { ok:r.ok, status:r.status, data:await r.json().catch(()=>({})) }; }
663+
async function jsend(u, m='POST'){ const r = await fetch(u,{method:m}); return { ok:r.ok, status:r.status, data:await r.json().catch(()=>({})) }; }
664+
665+
let BOOKS = [];
666+
667+
function bookTitle(b) { return b.title || b.filename || b.document; }
668+
669+
function card(b) {
670+
const title = bookTitle(b);
671+
const value = Math.max(0, Math.min(1, Number(b.percentage) || 0));
672+
const percent = (value * 100).toFixed(1).replace(/\\.0$/, '');
673+
const author = b.author ? '<div class="meta">' + esc(b.author) + '</div>' : '';
674+
const device = b.device || b.device_id ? 'Device: ' + esc(b.device || b.device_id) : '';
675+
const when = b.timestamp ? ' · Last synced: ' + new Date(b.timestamp * 1000).toLocaleString() : '';
676+
return '<div class="sync-book"><div class="row"><div style="min-width:0"><div class="title" title="' + esc(title) + '">' + esc(title) + '</div>'
677+
+ author + '<div class="meta">' + device + when + '</div></div>'
678+
+ '<div class="actions"><b class="mono" style="font-size:13px">' + percent + '%</b>'
679+
+ '<button class="danger sm" data-remove="' + esc(b.document) + '">Remove</button></div></div>'
680+
+ '<div class="progress-track" role="progressbar" aria-valuenow="' + (value * 100) + '" aria-valuemin="0" aria-valuemax="100"><div class="progress-fill" style="width:' + (value * 100) + '%"></div></div></div>';
681+
}
682+
683+
function render() {
684+
const el = $('list');
685+
if (!BOOKS.length) { el.innerHTML = '<div class="card"><p class="muted" style="margin:0">No synced books yet. Read something on your device first.</p></div>'; return; }
686+
el.innerHTML = BOOKS.map(card).join('');
687+
el.querySelectorAll('[data-remove]').forEach(btn => btn.onclick = () => removeBook(btn));
688+
}
689+
690+
async function removeBook(btn) {
691+
const doc = btn.dataset.remove;
692+
const book = BOOKS.find(b => b.document === doc);
693+
if (!confirm('Remove "' + (book ? bookTitle(book) : doc) + '"?\\n\\n'
694+
+ 'This permanently deletes its synced progress on every device, plus any highlights, '
695+
+ 'bookmarks, reading stats and service matches stored here for it. Your device keeps the '
696+
+ 'book — opening it again starts syncing from scratch.')) return;
697+
$('err').textContent = '';
698+
btn.disabled = true; btn.textContent = 'Removing…';
699+
const r = await jsend('/api/v1/progress/' + encodeURIComponent(doc), 'DELETE');
700+
if (!r.ok && r.status !== 404) {
701+
$('err').textContent = r.data.message || 'Could not remove this book.';
702+
btn.disabled = false; btn.textContent = 'Remove';
703+
return;
704+
}
705+
BOOKS = BOOKS.filter(b => b.document !== doc);
706+
render();
707+
}
708+
660709
(async () => {
661710
const r = await jget('/api/v1/progress?limit=500');
662-
const el = $('list');
663711
if (r.status === 409) { location.href = '/account'; return; }
664-
if (!r.ok) { el.innerHTML = '<p class="muted">Could not load synced books.</p>'; return; }
665-
const books = r.data.items || [];
666-
if (!books.length) { el.innerHTML = '<div class="card"><p class="muted" style="margin:0">No synced books yet. Read something on your device first.</p></div>'; return; }
667-
el.innerHTML = books.map(b => {
668-
const title = b.title || b.filename || b.document;
669-
const value = Math.max(0, Math.min(1, Number(b.percentage) || 0));
670-
const percent = (value * 100).toFixed(1).replace(/\\.0$/, '');
671-
const author = b.author ? '<div class="meta">' + esc(b.author) + '</div>' : '';
672-
const device = b.device || b.device_id ? 'Device: ' + esc(b.device || b.device_id) : '';
673-
const when = b.timestamp ? ' · Last synced: ' + new Date(b.timestamp * 1000).toLocaleString() : '';
674-
return '<div class="sync-book"><div class="row"><div><div class="title" title="' + esc(title) + '">' + esc(title) + '</div>'
675-
+ author + '<div class="meta">' + device + when + '</div></div><b class="mono" style="font-size:13px">' + percent + '%</b></div>'
676-
+ '<div class="progress-track" role="progressbar" aria-valuenow="' + (value * 100) + '" aria-valuemin="0" aria-valuemax="100"><div class="progress-fill" style="width:' + (value * 100) + '%"></div></div></div>';
677-
}).join('');
712+
if (!r.ok) { $('list').innerHTML = '<p class="muted">Could not load synced books.</p>'; return; }
713+
BOOKS = r.data.items || [];
714+
render();
678715
})();
679716
</script>`
680717
);

test/progress.test.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,3 +141,155 @@ describe('v1 rich progress', () => {
141141
}
142142
});
143143
});
144+
145+
describe('removing a synced book', () => {
146+
const OTHER_DOC = 'ffeeddccbbaa99887766554433221100';
147+
148+
const BOOK_STATS = {
149+
v: 5,
150+
sessions: 9,
151+
seconds: 8400,
152+
pages: 310,
153+
completed: false,
154+
avg_fwd: 12,
155+
pace_n: 250,
156+
eta: 5400,
157+
start_manual: false,
158+
finish_manual: false,
159+
start_date: 1751000000,
160+
finished_date: 0,
161+
tod: [0, 3000, 4000, 1400],
162+
dow: [0, 0, 1200, 0, 2000, 3000, 2200],
163+
};
164+
165+
/**
166+
* Seeds one document the way a device would: kosync progress (which also
167+
* records a position sample), metadata, a bookmark, a clipping and per-book
168+
* reading stats - i.e. a row in every table a removal has to clear.
169+
*/
170+
async function seedBook(
171+
{ app, db }: ReturnType<typeof makeTestApp>,
172+
headers: Record<string, string>,
173+
document: string
174+
) {
175+
await app.request('/syncs/progress', {
176+
method: 'PUT',
177+
headers,
178+
body: JSON.stringify({
179+
document,
180+
progress: POSITION.xpath,
181+
percentage: 0.4867,
182+
device: 'CrossPoint',
183+
device_id: 'aaaa',
184+
position: POSITION,
185+
}),
186+
});
187+
await app.request('/api/v1/documents', {
188+
method: 'PUT',
189+
headers,
190+
body: JSON.stringify({ items: [{ document, title: 'Foundryside', author: 'RJB' }] }),
191+
});
192+
await app.request(`/api/v1/bookmarks/${document}`, {
193+
method: 'PUT',
194+
headers,
195+
body: JSON.stringify({
196+
items: [{ id: '0123456789abcdef', xpath: '/body/p[1]', percentage: 0.1, summary: 'note' }],
197+
}),
198+
});
199+
await app.request(`/api/v1/clippings/${document}`, {
200+
method: 'PUT',
201+
headers,
202+
body: JSON.stringify({ items: [{ id: 'fedcba9876543210', spine: 3, text: 'a highlight' }] }),
203+
});
204+
await app.request('/api/v1/stats/books', {
205+
method: 'PUT',
206+
headers,
207+
body: JSON.stringify({ device_id: 'aaaa', items: [{ document, ...BOOK_STATS }] }),
208+
});
209+
// Connector rows have no test-friendly HTTP path (linking needs a live
210+
// service), so seed the two document-keyed tables directly.
211+
const userId = (db.prepare('SELECT id FROM users WHERE username = ?').get(headers['x-auth-user']) as { id: number }).id;
212+
db.prepare(
213+
`INSERT INTO connector_matches (user_id, connector_id, document, external_id, confidence, source, updated_at)
214+
VALUES (?, 'hardcover', ?, '42', 1, 'auto', 1)`
215+
).run(userId, document);
216+
db.prepare(
217+
`INSERT INTO connector_queue (user_id, connector_id, document, kind, payload, next_try_at, created_at, updated_at)
218+
VALUES (?, 'hardcover', ?, 'progress', '{}', 0, 1, 1)`
219+
).run(userId, document);
220+
}
221+
222+
it('DELETE clears the kosync progress and the rest of that book, leaving others alone', async () => {
223+
const server = makeTestApp();
224+
const { app, db } = server;
225+
const { headers } = await registerUser(app);
226+
await seedBook(server, headers, DOC);
227+
await seedBook(server, headers, OTHER_DOC);
228+
229+
const res = await app.request(`/api/v1/progress/${DOC}`, { method: 'DELETE', headers });
230+
expect(res.status).toBe(200);
231+
const body = await res.json();
232+
expect(body).toMatchObject({ document: DOC, deleted: true });
233+
expect(body.rows).toBeGreaterThan(0);
234+
235+
// The book is gone from the dashboard list and from kosync itself.
236+
const list = await (await app.request('/api/v1/progress', { headers })).json();
237+
expect(list.items.map((i: { document: string }) => i.document)).toEqual([OTHER_DOC]);
238+
const kosync = await app.request(`/syncs/progress/${DOC}`, { headers });
239+
expect(kosync.status).toBe(200);
240+
expect(await kosync.json()).toEqual({});
241+
const devices = await (await app.request(`/api/v1/progress/${DOC}`, { headers })).json();
242+
expect(devices.devices).toEqual([]);
243+
244+
// ...along with its metadata, highlights, bookmarks, samples and stats.
245+
for (const table of [
246+
'documents',
247+
'bookmarks',
248+
'clippings',
249+
'progress',
250+
'progress_samples',
251+
'stats_device_book',
252+
'connector_matches',
253+
'connector_queue',
254+
]) {
255+
const left = db
256+
.prepare(`SELECT document FROM ${table} WHERE document = ?`)
257+
.all(DOC) as unknown[];
258+
expect(left, `${table} still has rows for the removed book`).toEqual([]);
259+
const kept = db
260+
.prepare(`SELECT document FROM ${table} WHERE document = ?`)
261+
.all(OTHER_DOC) as unknown[];
262+
expect(kept.length, `${table} lost rows for the other book`).toBeGreaterThan(0);
263+
}
264+
265+
// The other book still reads back intact.
266+
const other = await (await app.request(`/syncs/progress/${OTHER_DOC}`, { headers })).json();
267+
expect(other.document).toBe(OTHER_DOC);
268+
});
269+
270+
it('DELETE only touches the caller, and 404s on a document with no data', async () => {
271+
const server = makeTestApp();
272+
const { app } = server;
273+
const a = await registerUser(app);
274+
const b = await registerUser(app);
275+
await seedBook(server, a.headers, DOC);
276+
await seedBook(server, b.headers, DOC);
277+
278+
// Same document hash, different user: B's copy must survive A's removal.
279+
expect((await app.request(`/api/v1/progress/${DOC}`, { method: 'DELETE', headers: a.headers })).status).toBe(200);
280+
const bList = await (await app.request('/api/v1/progress', { headers: b.headers })).json();
281+
expect(bList.items).toHaveLength(1);
282+
283+
// Already removed for A - nothing left to delete.
284+
const again = await app.request(`/api/v1/progress/${DOC}`, { method: 'DELETE', headers: a.headers });
285+
expect(again.status).toBe(404);
286+
expect((await again.json()).message).toBe('Unknown document');
287+
});
288+
289+
it('DELETE rejects a malformed document id', async () => {
290+
const { app } = makeTestApp();
291+
const { headers } = await registerUser(app);
292+
const res = await app.request('/api/v1/progress/not%20a%20hash!', { method: 'DELETE', headers });
293+
expect(res.status).toBe(403);
294+
});
295+
});

0 commit comments

Comments
 (0)