Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 52 additions & 0 deletions src/models/document.ts
Original file line number Diff line number Diff line change
@@ -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;
}
1 change: 1 addition & 0 deletions src/routes/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
19 changes: 19 additions & 0 deletions src/routes/v1/progress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AppEnv> {
Expand Down Expand Up @@ -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;
}
75 changes: 58 additions & 17 deletions src/routes/web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
`;
Expand Down Expand Up @@ -346,7 +348,7 @@ const ACCOUNT = shell(


<h2 style="${SECTION}">Reading progress</h2>
<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>
<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>

<h2 style="${SECTION}">Linked services</h2>
<div class="notice" style="margin-bottom:12px">
Expand Down Expand Up @@ -650,31 +652,70 @@ const PROGRESS = shell(
`<div><a class="muted" href="/account">&larr; Account</a></div>
<div style="margin-top:16px"><span class="eyebrow">Reading progress</span>
<h1>Synced books</h1>
<p class="sub">All synced books with their latest progress.</p></div>
<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>
<div class="err" id="err"></div>
<div id="list" style="margin-top:8px"><p class="muted">Loading…</p></div>

<script>
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));
async function jget(u){ const r = await fetch(u); return { ok:r.ok, status:r.status, data:await r.json().catch(()=>({})) }; }
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(()=>({})) }; }

let BOOKS = [];

function bookTitle(b) { return b.title || b.filename || b.document; }

function card(b) {
const title = bookTitle(b);
const value = Math.max(0, Math.min(1, Number(b.percentage) || 0));
const percent = (value * 100).toFixed(1).replace(/\\.0$/, '');
const author = b.author ? '<div class="meta">' + esc(b.author) + '</div>' : '';
const device = b.device || b.device_id ? 'Device: ' + esc(b.device || b.device_id) : '';
const when = b.timestamp ? ' · Last synced: ' + new Date(b.timestamp * 1000).toLocaleString() : '';
return '<div class="sync-book"><div class="row"><div style="min-width:0"><div class="title" title="' + esc(title) + '">' + esc(title) + '</div>'
+ author + '<div class="meta">' + device + when + '</div></div>'
+ '<div class="actions"><b class="mono" style="font-size:13px">' + percent + '%</b>'
+ '<button class="danger sm" data-remove="' + esc(b.document) + '">Remove</button></div></div>'
+ '<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>';
}

function render() {
const el = $('list');
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; }
el.innerHTML = BOOKS.map(card).join('');
el.querySelectorAll('[data-remove]').forEach(btn => btn.onclick = () => removeBook(btn));
}

async function removeBook(btn) {
const doc = btn.dataset.remove;
const book = BOOKS.find(b => b.document === doc);
if (!confirm('Remove "' + (book ? bookTitle(book) : doc) + '"?\\n\\n'
+ 'This permanently deletes its synced progress on every device, plus any highlights, '
+ 'bookmarks, reading stats and service matches stored here for it. Your device keeps the '
+ 'book — opening it again starts syncing from scratch.')) return;
$('err').textContent = '';
btn.disabled = true; btn.textContent = 'Removing…';
// 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';
return;
}
BOOKS = BOOKS.filter(b => b.document !== doc);
render();
}

(async () => {
const r = await jget('/api/v1/progress?limit=500');
const el = $('list');
if (r.status === 409) { location.href = '/account'; return; }
if (!r.ok) { el.innerHTML = '<p class="muted">Could not load synced books.</p>'; return; }
const books = r.data.items || [];
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; }
el.innerHTML = books.map(b => {
const title = b.title || b.filename || b.document;
const value = Math.max(0, Math.min(1, Number(b.percentage) || 0));
const percent = (value * 100).toFixed(1).replace(/\\.0$/, '');
const author = b.author ? '<div class="meta">' + esc(b.author) + '</div>' : '';
const device = b.device || b.device_id ? 'Device: ' + esc(b.device || b.device_id) : '';
const when = b.timestamp ? ' · Last synced: ' + new Date(b.timestamp * 1000).toLocaleString() : '';
return '<div class="sync-book"><div class="row"><div><div class="title" title="' + esc(title) + '">' + esc(title) + '</div>'
+ author + '<div class="meta">' + device + when + '</div></div><b class="mono" style="font-size:13px">' + percent + '%</b></div>'
+ '<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>';
}).join('');
if (!r.ok) { $('list').innerHTML = '<p class="muted">Could not load synced books.</p>'; return; }
BOOKS = r.data.items || [];
render();
})();
</script>`
);
Expand Down
152 changes: 152 additions & 0 deletions test/progress.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof makeTestApp>,
headers: Record<string, string>,
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);
});
});
Loading