Mark all read on an OPML subscription

Marking the subscription read did nothing: its own row holds no entries.
read-all now resolves the feeds grouped under the id -- via
subscriptions(), so a child promoted to config is included -- and marks
those. Button sits before Unsubscribe, where every other feed keeps it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
This commit is contained in:
2026-09-10 18:45:46 +00:00
parent 8f5e2749ff
commit 7473d5b7fb
5 changed files with 46 additions and 4 deletions

View File

@@ -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 ## 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 An OPML subscription has no entries of its own, so its row always read `0 unread` no matter how much

View File

@@ -681,9 +681,15 @@ impl Db {
} }
/// Marks every entry in a feed read, for the "mark all read" button. /// Marks every entry in a feed read, for the "mark all read" button.
pub fn mark_all_read(&self, feed_id: &str) -> Result<usize> { /// 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<usize> {
let conn = self.conn.lock().unwrap(); 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" /// The next N enclosures with no file, newest entry first -- what "download latest"

View File

@@ -660,7 +660,15 @@ async fn read_all(
State(state): State<WebState>, State(state): State<WebState>,
Path(id): Path<String>, Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, ApiError> { ) -> Result<Json<serde_json::Value>, 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 }))) Ok(Json(serde_json::json!({ "marked": n })))
} }

View File

@@ -189,3 +189,21 @@ test('an OPML subscription is a collapsible folder', async ({ page }) => {
await page.locator('.feed', { hasText: 'Test Subscriptions' }).first().click(); await page.locator('.feed', { hasText: 'Test Subscriptions' }).first().click();
await expect(page.locator('.childrow')).toHaveCount(1); 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');
});

View File

@@ -556,6 +556,7 @@ function renderGroup(f,kids){
<div class="acts"> <div class="acts">
<button class="btn primary" data-a="scan">Re-read OPML</button> <button class="btn primary" data-a="scan">Re-read OPML</button>
<button class="btn" data-a="settings">Settings</button> <button class="btn" data-a="settings">Settings</button>
<button class="btn" data-a="read">Mark all read</button>
<button class="btn danger" data-a="rm">Unsubscribe</button> <button class="btn danger" data-a="rm">Unsubscribe</button>
</div> </div>
</div> </div>
@@ -590,7 +591,7 @@ function renderGroup(f,kids){
async function feedAction(a,f){ 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==='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==='rm') removeFeed(f);
if(a==='settings') settingsModal(f); if(a==='settings') settingsModal(f);
if(a==='dl') downloadLatestModal(f); if(a==='dl') downloadLatestModal(f);