first commit
This commit is contained in:
58
src/db.rs
58
src/db.rs
@@ -871,6 +871,38 @@ impl Db {
|
|||||||
Ok(out)
|
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<std::collections::HashMap<String, i64>> {
|
||||||
|
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::<rusqlite::Result<std::collections::HashMap<_, _>>>()?;
|
||||||
|
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<i64>>(0)?.unwrap_or(0), r.get::<_, Option<i64>>(1)?.unwrap_or(0)))
|
||||||
|
})?;
|
||||||
|
Ok((starred, unread))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn subscribe(&self, user_id: i64, feed_id: &str) -> Result<()> {
|
pub fn subscribe(&self, user_id: i64, feed_id: &str) -> Result<()> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@@ -1274,6 +1306,32 @@ pub fn now() -> i64 {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn read_state_belongs_to_one_person() {
|
fn read_state_belongs_to_one_person() {
|
||||||
let db = Db::memory().unwrap();
|
let db = Db::memory().unwrap();
|
||||||
|
|||||||
35
src/web.rs
35
src/web.rs
@@ -346,6 +346,8 @@ struct FeedRow {
|
|||||||
entries: i64,
|
entries: i64,
|
||||||
downloaded: i64,
|
downloaded: i64,
|
||||||
unread: i64,
|
unread: i64,
|
||||||
|
/// Including you. More than one means every file here is shared.
|
||||||
|
subscribers: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn feeds(
|
async fn feeds(
|
||||||
@@ -363,6 +365,7 @@ async fn feeds(
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|s| (s.feed_id.clone(), s))
|
.map(|s| (s.feed_id.clone(), s))
|
||||||
.collect();
|
.collect();
|
||||||
|
let counts = state.ctx.db.subscriber_counts()?;
|
||||||
let mut out = Vec::with_capacity(mine.len());
|
let mut out = Vec::with_capacity(mine.len());
|
||||||
for sub in &subs {
|
for sub in &subs {
|
||||||
let (id, feed) = (&sub.id, &sub.cfg);
|
let (id, feed) = (&sub.id, &sub.cfg);
|
||||||
@@ -400,6 +403,7 @@ async fn feeds(
|
|||||||
entries: s.entries,
|
entries: s.entries,
|
||||||
downloaded: s.downloaded,
|
downloaded: s.downloaded,
|
||||||
unread: state.ctx.db.unread_count(user.id, id)?,
|
unread: state.ctx.db.unread_count(user.id, id)?,
|
||||||
|
subscribers: counts.get(id).copied().unwrap_or(0),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(Json(out))
|
Ok(Json(out))
|
||||||
@@ -822,15 +826,46 @@ async fn download_now(
|
|||||||
Ok(StatusCode::ACCEPTED)
|
Ok(StatusCode::ACCEPTED)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Force {
|
||||||
|
#[serde(default)]
|
||||||
|
force: bool,
|
||||||
|
}
|
||||||
|
|
||||||
async fn delete_file(
|
async fn delete_file(
|
||||||
State(state): State<WebState>,
|
State(state): State<WebState>,
|
||||||
Path(id): Path<i64>,
|
Path(id): Path<i64>,
|
||||||
|
user: crate::db::User,
|
||||||
|
Query(q): Query<Force>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
let enc = state
|
let enc = state
|
||||||
.ctx
|
.ctx
|
||||||
.db
|
.db
|
||||||
.enclosure(id)?
|
.enclosure(id)?
|
||||||
.ok_or_else(|| anyhow::anyhow!("no 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
|
if let Some(path) = &enc.path
|
||||||
&& let Err(e) = std::fs::remove_file(path)
|
&& let Err(e) = std::fs::remove_file(path)
|
||||||
&& e.kind() != std::io::ErrorKind::NotFound
|
&& e.kind() != std::io::ErrorKind::NotFound
|
||||||
|
|||||||
@@ -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 page.locator('.tabs button', { hasText: 'Unread' }).click();
|
||||||
await expect(page.locator('.ep')).toHaveCount(rows);
|
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();
|
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();
|
||||||
|
});
|
||||||
|
|||||||
@@ -574,7 +574,8 @@ function renderFeed(){
|
|||||||
<div class="meta">
|
<div class="meta">
|
||||||
<h2>${esc(f.title||f.id)}</h2>
|
<h2>${esc(f.title||f.id)}</h2>
|
||||||
<div class="sub">${f.entries} items · ${f.downloaded} downloaded · checked ${ago(f.last_checked)}
|
<div class="sub">${f.entries} items · ${f.downloaded} downloaded · checked ${ago(f.last_checked)}
|
||||||
· every ${everyText(f.every_mins)}${f.next_check?` · next ${due(f.next_check)}`:''}</div>
|
· 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'}`:''}</div>
|
||||||
${f.last_error?`<div class="sub" style="color:var(--bad)">${esc(f.last_error)}</div>`:''}
|
${f.last_error?`<div class="sub" style="color:var(--bad)">${esc(f.last_error)}</div>`:''}
|
||||||
${f.orphaned?`<div class="sub" style="color:var(--warn)">This feed is no longer listed in its
|
${f.orphaned?`<div class="sub" style="color:var(--warn)">This feed is no longer listed in its
|
||||||
OPML subscription. It was kept rather than removed because it has downloaded items.</div>`:''}
|
OPML subscription. It was kept rather than removed because it has downloaded items.</div>`:''}
|
||||||
@@ -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.
|
/// One enclosure: a player when the file is here, otherwise what it is and a way to get it.
|
||||||
function encBox(x){
|
function encBox(x){
|
||||||
const size=x.length?mb(x.length):'';
|
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=`<button class="btn danger" data-a="del" data-enc="${x.id}"${
|
||||||
|
shared?` title="Shared with ${f.subscribers-1} other ${f.subscribers===2?'person':'people'} reading this feed"`:''
|
||||||
|
}>Delete${shared?' for everyone':' file'}</button>`;
|
||||||
if(x.path && !isPlayable(x)){
|
if(x.path && !isPlayable(x)){
|
||||||
// On disk, but not audio or video: view it, keep it, or remove it -- no player.
|
// On disk, but not audio or video: view it, keep it, or remove it -- no player.
|
||||||
return `<div class="encbox">
|
return `<div class="encbox">
|
||||||
@@ -868,7 +875,7 @@ function encBox(x){
|
|||||||
<span class="meta" style="flex:1">downloaded${size?' \u00b7 '+size:''}</span>
|
<span class="meta" style="flex:1">downloaded${size?' \u00b7 '+size:''}</span>
|
||||||
<a class="btn" href="/media/${x.id}" target="_blank" rel="noopener noreferrer">View</a>
|
<a class="btn" href="/media/${x.id}" target="_blank" rel="noopener noreferrer">View</a>
|
||||||
<a class="btn" href="/media/${x.id}" download>Save</a>
|
<a class="btn" href="/media/${x.id}" download>Save</a>
|
||||||
<button class="btn danger" data-a="del" data-enc="${x.id}">Delete file</button>
|
${delBtn}
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
if(x.path){
|
if(x.path){
|
||||||
@@ -876,7 +883,7 @@ function encBox(x){
|
|||||||
<audio id="audio-${x.id}" controls preload="none" src="/media/${x.id}"></audio>
|
<audio id="audio-${x.id}" controls preload="none" src="/media/${x.id}"></audio>
|
||||||
<span class="meta">${size}</span>
|
<span class="meta">${size}</span>
|
||||||
<a class="btn" href="/media/${x.id}" download>Save</a>
|
<a class="btn" href="/media/${x.id}" download>Save</a>
|
||||||
<button class="btn danger" data-a="del" data-enc="${x.id}">Delete file</button>
|
${delBtn}
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
// Nothing on disk. For an image or a PDF you usually just want to look at it, so link
|
// 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'));
|
toast('Queued: '+(e.title||'item'));
|
||||||
}
|
}
|
||||||
if(a==='del'){
|
if(a==='del'){
|
||||||
if(!confirm('Delete the downloaded file?\n\nThe item stays listed and will not be downloaded again automatically.')) return;
|
const f=S.feeds.find(x=>x.id===e.feed_id);
|
||||||
await api(`/api/enclosures/${enc.id}`,{method:'DELETE'});
|
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);
|
toast('Deleted'); loadEntries(); loadFeeds(true);
|
||||||
}
|
}
|
||||||
}catch(err){ toast(err.message,true); }
|
}catch(err){ toast(err.message,true); }
|
||||||
|
|||||||
Reference in New Issue
Block a user