Block lists: words that hide items and keep them from downloading (#47)

Each person has a list for every feed they read and one per feed. An item whose title or text
holds one of the words, matched as whole words so "ai" does not hide everything that "said"
anything, is hidden from them and, since the scanner now keeps each subscriber's filters
separate, is fetched only if someone else still wants it.

Whole-word matching is not something LIKE can do on both SQLite and Postgres, so the matches are
worked out in Rust into a `hidden` table whenever a list changes, someone subscribes, or a scan
brings in new items, and the queries only look that table up. Both new tables are tables rather
than columns because create_missing adds tables but never columns. Hidden counts as read for
the reaper and for "others still want this file", since whoever it is hidden from is as done
with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 13:13:03 +00:00
parent 868dd673f3
commit bdda9b3d2e
8 changed files with 383 additions and 55 deletions

View File

@@ -1344,3 +1344,20 @@ test('"check every feed" checks only the feeds of the person asking', async ({ b
for (const id of started) expect(mine, `${id} is not one of piper's feeds`).toContain(id);
await ctx.close();
});
test('words in Settings hide the items that mention them', async ({ page }) => {
await page.locator('.feed', { hasText: 'Test Show' }).click();
await page.locator('.tabs button', { hasText: 'All' }).first().click();
await expect(page.locator('.ep', { hasText: 'Second Episode' })).toBeVisible({ timeout: 20_000 });
await page.locator('#prefs').click();
await page.locator('#sblock').fill('notes for the second'); // a phrase, in the show notes
const saved = page.waitForResponse(r => r.url().endsWith('/api/me') && r.request().method() === 'PATCH');
await page.locator('#sblock').press('Tab');
expect((await saved).status()).toBe(204);
await page.locator('#modal .cardx').click();
await expect(page.locator('.ep', { hasText: 'Second Episode' })).toHaveCount(0);
await expect(page.locator('.ep', { hasText: 'First Episode' })).toBeVisible();
await page.evaluate(() => api('/api/me', { method: 'PATCH', body: JSON.stringify({ blocked: [] }) }));
});