diff --git a/PROGRESS.md b/PROGRESS.md index ae6cc80..248ad0b 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -56,6 +56,15 @@ and until now nothing set them. --- +## 2026-09-10 — Marking a subscription read + +An OPML subscription's page now has **Mark all read**, sitting where every other feed keeps it -- +before Unsubscribe. The fix is in the handler rather than the button: `read-all` resolves the feeds +whose group is the given id (through `subscriptions()`, so a child promoted to config counts too) +and marks those, since the subscription's own row holds no entries and marking it did nothing. + +--- + ## 2026-09-10 — A folder counts what it holds An OPML subscription has no entries of its own, so its row always read `0 unread` no matter how much diff --git a/src/db.rs b/src/db.rs index f420478..6a88f5a 100644 --- a/src/db.rs +++ b/src/db.rs @@ -681,9 +681,15 @@ impl Db { } /// Marks every entry in a feed read, for the "mark all read" button. - pub fn mark_all_read(&self, feed_id: &str) -> Result { + /// Marks every entry of the given feeds read. Takes a list because an OPML subscription + /// holds no entries itself -- marking it read means the feeds inside it. + pub fn mark_all_read(&self, feed_ids: &[String]) -> Result { let conn = self.conn.lock().unwrap(); - Ok(conn.execute("UPDATE entries SET read = 1 WHERE feed_id = ?1 AND read = 0", [feed_id])?) + let mut n = 0; + for id in feed_ids { + n += conn.execute("UPDATE entries SET read = 1 WHERE feed_id = ?1 AND read = 0", [id])?; + } + Ok(n) } /// The next N enclosures with no file, newest entry first -- what "download latest" diff --git a/src/web.rs b/src/web.rs index be31a8e..2c40318 100644 --- a/src/web.rs +++ b/src/web.rs @@ -660,7 +660,15 @@ async fn read_all( State(state): State, Path(id): Path, ) -> Result, ApiError> { - let n = state.ctx.db.mark_all_read(&id)?; + // A subscription's own row has no entries, so marking it read means everything under it. + let mut ids = vec![id.clone()]; + ids.extend( + crate::subscriptions(&state.ctx)? + .into_iter() + .filter(|s| s.cfg.group.as_deref() == Some(id.as_str())) + .map(|s| s.id), + ); + let n = state.ctx.db.mark_all_read(&ids)?; Ok(Json(serde_json::json!({ "marked": n }))) } diff --git a/tests/ui/app.spec.js b/tests/ui/app.spec.js index 1a05c5c..a63f8dd 100644 --- a/tests/ui/app.spec.js +++ b/tests/ui/app.spec.js @@ -189,3 +189,21 @@ test('an OPML subscription is a collapsible folder', async ({ page }) => { await page.locator('.feed', { hasText: 'Test Subscriptions' }).first().click(); await expect(page.locator('.childrow')).toHaveCount(1); }); + +test('marking an OPML subscription read covers the feeds inside it', async ({ page }) => { + await page.evaluate(() => + fetch('/api/fetch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ feed: 'test-subscriptions', force: true }), + })); + + // The folder's own row has no entries, so anything it marks read came from its child. + const folder = page.locator('.feed', { hasText: 'Test Subscriptions' }).first(); + await expect(folder).toBeVisible({ timeout: 20_000 }); + await expect(folder.locator('.badge')).not.toHaveText('0'); + await folder.click(); + + await page.locator('#content .acts button', { hasText: 'Mark all read' }).click(); + await expect(folder.locator('.badge')).toHaveText('0'); +}); diff --git a/web/index.html b/web/index.html index e5310aa..0b0abf3 100644 --- a/web/index.html +++ b/web/index.html @@ -556,6 +556,7 @@ function renderGroup(f,kids){
+
@@ -590,7 +591,7 @@ function renderGroup(f,kids){ async function feedAction(a,f){ if(a==='scan'){ toast('Scanning '+(f.title||f.id)+'…'); await api('/api/fetch',{method:'POST',body:JSON.stringify({feed:f.id,force:true})}); } - if(a==='read'){ const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/read-all`,{method:'POST'}); toast(`Marked ${r.marked} read`); await loadFeeds(true); loadEntries(); } + if(a==='read'){ const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/read-all`,{method:'POST'}); toast(`Marked ${r.marked} read`); await loadFeeds(true); renderFeed(); loadEntries(); } if(a==='rm') removeFeed(f); if(a==='settings') settingsModal(f); if(a==='dl') downloadLatestModal(f);