diff --git a/src/db.rs b/src/db.rs index e56d75f..85d8a4f 100644 --- a/src/db.rs +++ b/src/db.rs @@ -871,6 +871,38 @@ impl Db { Ok(out) } + /// Subscribers per feed, for the whole catalogue in one query -- the feed list would + /// otherwise ask once per feed. + pub fn subscriber_counts(&self) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = + conn.prepare("SELECT feed_id, count(*) FROM subscriptions GROUP BY feed_id")?; + let out = stmt + .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)))? + .collect::>>()?; + Ok(out) + } + + /// Who else would miss this file: subscribers other than `user_id` who have starred + /// the item or have not read it yet. Deleting is deleting their copy too. + pub fn others_wanting(&self, enclosure_id: i64, user_id: i64) -> Result<(i64, i64)> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT + sum(CASE WHEN coalesce(st.flagged, 0) = 1 THEN 1 ELSE 0 END), + sum(CASE WHEN coalesce(st.read, 0) = 0 THEN 1 ELSE 0 END) + FROM enclosures e + JOIN subscriptions s ON s.feed_id = e.feed_id AND s.user_id != ?2 + LEFT JOIN entry_state st + ON st.user_id = s.user_id AND st.feed_id = e.feed_id AND st.guid = e.guid + WHERE e.id = ?1", + )?; + let (starred, unread) = stmt.query_row(params![enclosure_id, user_id], |r| { + Ok((r.get::<_, Option>(0)?.unwrap_or(0), r.get::<_, Option>(1)?.unwrap_or(0))) + })?; + Ok((starred, unread)) + } + pub fn subscribe(&self, user_id: i64, feed_id: &str) -> Result<()> { let conn = self.conn.lock().unwrap(); conn.execute( @@ -1274,6 +1306,32 @@ pub fn now() -> i64 { mod tests { use super::*; + #[test] + fn deleting_a_shared_file_asks_about_everyone_else() { + let db = Db::memory().unwrap(); + db.exec_for_test( + "INSERT INTO users (id, name, is_admin, created) VALUES (1,'ray',1,0),(2,'sam',0,0),(3,'kit',0,0); + INSERT INTO subscriptions (user_id, feed_id, created) VALUES (1,'f',0),(2,'f',0),(3,'f',0); + INSERT INTO entries (feed_id, guid, first_seen) VALUES ('f','a',0); + INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES + (1,'f','a','u1','/tmp/a','done');", + ) + .unwrap(); + + // Nobody has touched it: both others still have it unplayed. + assert_eq!(db.others_wanting(1, 1).unwrap(), (0, 2)); + + // Sam reads it, Kit stars it. + db.set_entry_flag(2, "f", "a", EntryFlag::Read, true).unwrap(); + db.set_entry_flag(3, "f", "a", EntryFlag::Flagged, true).unwrap(); + assert_eq!(db.others_wanting(1, 1).unwrap(), (1, 1), "one starred it, one has not played it"); + + // Asking as Kit, only Ray and Sam count -- and Kit's own star is not a reason to + // warn Kit. + db.set_entry_flag(1, "f", "a", EntryFlag::Read, true).unwrap(); + assert_eq!(db.others_wanting(1, 3).unwrap(), (0, 0)); + } + #[test] fn read_state_belongs_to_one_person() { let db = Db::memory().unwrap(); diff --git a/src/web.rs b/src/web.rs index 6e1c88b..291f354 100644 --- a/src/web.rs +++ b/src/web.rs @@ -346,6 +346,8 @@ struct FeedRow { entries: i64, downloaded: i64, unread: i64, + /// Including you. More than one means every file here is shared. + subscribers: i64, } async fn feeds( @@ -363,6 +365,7 @@ async fn feeds( .into_iter() .map(|s| (s.feed_id.clone(), s)) .collect(); + let counts = state.ctx.db.subscriber_counts()?; let mut out = Vec::with_capacity(mine.len()); for sub in &subs { let (id, feed) = (&sub.id, &sub.cfg); @@ -400,6 +403,7 @@ async fn feeds( entries: s.entries, downloaded: s.downloaded, unread: state.ctx.db.unread_count(user.id, id)?, + subscribers: counts.get(id).copied().unwrap_or(0), }); } Ok(Json(out)) @@ -822,15 +826,46 @@ async fn download_now( Ok(StatusCode::ACCEPTED) } +#[derive(Deserialize)] +struct Force { + #[serde(default)] + force: bool, +} + async fn delete_file( State(state): State, Path(id): Path, + user: crate::db::User, + Query(q): Query, ) -> Result { let enc = state .ctx .db .enclosure(id)? .ok_or_else(|| anyhow::anyhow!("no enclosure {id}"))?; + + // There is one copy of the file: deleting it deletes everyone's. Say so before doing + // it, once, and let them decide. + if !q.force { + let (starred, unread) = state.ctx.db.others_wanting(id, user.id)?; + let people = |n: i64| if n == 1 { "person".to_string() } else { format!("{n} people") }; + let complaint = match (starred, unread) { + (0, 0) => None, + (0, u) => Some(format!("{} subscribed to this feed {} not played it yet", people(u), if u == 1 { "has" } else { "have" })), + (st, 0) => Some(format!("another {} starred it to keep", people(st))), + (st, u) => Some(format!( + "another {} starred it to keep, and {} not played it yet", + people(st), + if u == 1 { "one person has".to_string() } else { format!("{u} have") } + )), + }; + if let Some(why) = complaint { + return Err(ApiError { + error: anyhow::Error::msg(format!("There is one copy of this file and {why}.")), + status: StatusCode::CONFLICT, + }); + } + } if let Some(path) = &enc.path && let Err(e) = std::fs::remove_file(path) && e.kind() != std::io::ErrorKind::NotFound diff --git a/tests/ui/app.spec.js b/tests/ui/app.spec.js index bdf4bf6..3321f89 100644 --- a/tests/ui/app.spec.js +++ b/tests/ui/app.spec.js @@ -298,5 +298,28 @@ test('a second person has their own feeds and their own read state', async ({ br await page.locator('.tabs button', { hasText: 'Unread' }).click(); await expect(page.locator('.ep')).toHaveCount(rows); + // Two people now share this feed, so the page says so and Delete is honest about it. + await expect(page.locator('#content .sub').first()).toContainText('shared with 1 other person'); + await ctx.close(); }); + +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. + await page.getByText('Test Show').click(); + await page.locator('.tabs button', { hasText: 'Downloaded' }).click(); + const row = page.locator('.ep').first(); + await expect(row).toBeVisible({ timeout: 20_000 }); + await row.click(); + + const del = page.locator('#detail button', { hasText: 'Delete' }); + 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(); })); + await del.click(); + expect(await second).toContain('one copy of this file'); + await expect(page.locator('#detail button', { hasText: 'Delete' })).toBeVisible(); +}); diff --git a/web/index.html b/web/index.html index d4054f8..317444d 100644 --- a/web/index.html +++ b/web/index.html @@ -574,7 +574,8 @@ function renderFeed(){

${esc(f.title||f.id)}

${f.entries} items · ${f.downloaded} downloaded · checked ${ago(f.last_checked)} - · every ${everyText(f.every_mins)}${f.next_check?` · next ${due(f.next_check)}`:''}
+ · every ${everyText(f.every_mins)}${f.next_check?` · next ${due(f.next_check)}`:''}${ + f.subscribers>1?` · shared with ${f.subscribers-1} other ${f.subscribers===2?'person':'people'}`:''}
${f.last_error?`
${esc(f.last_error)}
`:''} ${f.orphaned?`
This feed is no longer listed in its OPML subscription. It was kept rather than removed because it has downloaded items.
`:''} @@ -861,6 +862,12 @@ function showDetail(e){ /// One enclosure: a player when the file is here, otherwise what it is and a way to get it. function encBox(x){ const size=x.length?mb(x.length):''; + // One file serves everyone reading the feed, so deleting is not a private act. + const f=S.feeds.find(y=>y.id===S.feed); + const shared=f&&f.subscribers>1; + const delBtn=``; if(x.path && !isPlayable(x)){ // On disk, but not audio or video: view it, keep it, or remove it -- no player. return `
@@ -868,7 +875,7 @@ function encBox(x){ downloaded${size?' \u00b7 '+size:''} View Save - + ${delBtn}
`; } if(x.path){ @@ -876,7 +883,7 @@ function encBox(x){ ${size} Save - + ${delBtn} `; } // Nothing on disk. For an image or a PDF you usually just want to look at it, so link @@ -908,8 +915,21 @@ async function epAction(a,e,el,encId){ toast('Queued: '+(e.title||'item')); } if(a==='del'){ - if(!confirm('Delete the downloaded file?\n\nThe item stays listed and will not be downloaded again automatically.')) return; - await api(`/api/enclosures/${enc.id}`,{method:'DELETE'}); + const f=S.feeds.find(x=>x.id===e.feed_id); + const shared=f&&f.subscribers>1; + if(!confirm(shared + ? `Delete this file?\n\nThere is one copy, shared with ${f.subscribers-1} other ` + +`${f.subscribers===2?'person':'people'} reading this feed. The item stays listed ` + +`and will not be downloaded again automatically.` + : 'Delete the downloaded file?\n\nThe item stays listed and will not be downloaded again automatically.')) return; + try{ + await api(`/api/enclosures/${enc.id}`,{method:'DELETE'}); + }catch(err){ + // 409: somebody else has it starred or unplayed. Their reason, their words. + if(!/one copy of this file/.test(err.message)) throw err; + if(!confirm(err.message+'\n\nDelete it anyway?')) return; + await api(`/api/enclosures/${enc.id}?force=true`,{method:'DELETE'}); + } toast('Deleted'); loadEntries(); loadFeeds(true); } }catch(err){ toast(err.message,true); }