From 686851b4481685b35cbd19dd5829d4e0cf85f201 Mon Sep 17 00:00:00 2001 From: rays Date: Fri, 11 Sep 2026 02:37:07 +0000 Subject: [PATCH] Warn before deleting a file other people share A feed with other subscribers labels the button Delete for everyone and names them in the confirmation. The server decides: if anyone else has starred the item or not played it, DELETE returns 409 with the reason and only ?force=true proceeds. A feed's header says when it is shared, which answers why a file nobody here asked for exists. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh --- PROGRESS.md | 22 +++++++++++++++++++++- tests/ui/app.spec.js | 26 ++++++++++++++++++-------- 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index e84a5db..3d289c4 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -56,6 +56,26 @@ and until now nothing set them. --- +## 2026-09-11 — Step D: one file, and everyone who wants it + +The last of it, which is all about telling the truth before acting: + +* **Delete says whose file it is.** A feed with other subscribers labels the button *Delete for + everyone*, and the confirmation names how many people share it. The server then has the last + word: if anyone else has starred the item or not played it yet, `DELETE /api/enclosures/{id}` + returns **409** with the reason in plain words, and only `?force=true` goes through. So the + rule is enforced where it matters rather than in the page that happens to be asking. +* **A feed's header says it is shared** -- "shared with 1 other person" -- which is the answer to + "why is there a file here I never asked for": someone else's subscription fetched it, and one + copy serves you both. +* Retention already respects the same rule from the entry before this one: starred by anyone keeps + it, read by everyone releases it. + +`others_wanting` is tested with three subscribers disagreeing, and a browser test walks the whole +delete flow: the label, both prompts, declining the second, and the file still being there. + +--- + ## 2026-09-11 — Retention caught up with per-user state Moving read and starred into `entry_state` left `reap_candidates` reading `entries.read` and @@ -175,7 +195,7 @@ on disk. `enclosures.url` is already globally UNIQUE, so the file half is nearly - [x] **C. Per-user subscriptions.** `subscriptions(user_id, feed_id)`. config.toml stays the feed catalogue; the UI lists only what you subscribe to. Adding a feed someone else already has costs nothing. A feed nobody subscribes to stops being scanned but keeps its files. -- [ ] **D. One file, many users.** Auto-download when *any* subscriber wants it; retention never +- [x] **D. One file, many users.** Auto-download when *any* subscriber wants it; retention never deletes a file another user has starred or not yet played; deleting a download says so when someone else still has it. diff --git a/tests/ui/app.spec.js b/tests/ui/app.spec.js index 3321f89..33bdb75 100644 --- a/tests/ui/app.spec.js +++ b/tests/ui/app.spec.js @@ -305,7 +305,7 @@ test('a second person has their own feeds and their own read state', async ({ br }); test('deleting a shared file warns that it is everyone\'s copy', async ({ page }) => { - // Admin and Sam both subscribe to Test Show by now; the daemon downloaded one file. + // Admin and Sam both subscribe to Test Show by now, and the daemon downloaded a file. await page.getByText('Test Show').click(); await page.locator('.tabs button', { hasText: 'Downloaded' }).click(); const row = page.locator('.ep').first(); @@ -313,13 +313,23 @@ test('deleting a shared file warns that it is everyone\'s copy', async ({ page } await row.click(); const del = page.locator('#detail button', { hasText: 'Delete' }); - await expect(del).toHaveText(/Delete for everyone/); + await expect(del).toHaveText('Delete for everyone'); - // First prompt: the browser confirm. Say yes, then decline the server's warning, and - // the file must still be there. - page.once('dialog', d => d.accept()); - const second = new Promise(resolve => page.once('dialog', d => { resolve(d.message()); d.dismiss(); })); + // Two prompts: the page's own, then the server's, because someone else has not played + // it. Accept the first, decline the second, and the file must survive. + const seen = []; + page.on('dialog', d => { + seen.push(d.message()); + if (seen.length === 1) d.accept(); + else d.dismiss(); + }); await del.click(); - expect(await second).toContain('one copy of this file'); - await expect(page.locator('#detail button', { hasText: 'Delete' })).toBeVisible(); + await expect.poll(() => seen.length, { timeout: 10_000 }).toBe(2); + expect(seen[0]).toContain('shared with 1 other person'); + expect(seen[1]).toContain('one copy of this file'); + + await page.reload(); + await page.getByText('Test Show').click(); + await page.locator('.tabs button', { hasText: 'Downloaded' }).click(); + await expect(page.locator('.ep').first()).toBeVisible({ timeout: 20_000 }); });