diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b3e366..cf61848 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ The long form, with what was wrong before and how it was found, is in ### Added +- An admin can give a feed a Directory category in its settings (`category` in config.toml), for + the blogs and other feeds that name none of their own. A feed's own iTunes category still wins. - Keyboard shortcuts after Feedly's: j and k through items, Shift-J and Shift-K through feeds, g and a letter to go to a place, o to play, s to pin, and more. Press ? for the whole list. - Directory can be filtered to Podcasts or Blogs, and by each show's own iTunes category as a row diff --git a/docs/configuration.md b/docs/configuration.md index bb662c9..ced9093 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -97,6 +97,7 @@ url = "https://atp.fm/rss" folder = "Accidental Tech Podcast" # default: the feed title schedule = "every 6h" # overrides [general] for this feed media_types = ["audio"] # overrides [general] for this feed +category = "Technology" # the Directory's, if the feed names none username = "ray" # HTTP basic auth password_env = "IPX_ATP_PASS" # preferred over a literal `password` ``` diff --git a/src/config.rs b/src/config.rs index d587434..659d66b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -115,6 +115,10 @@ pub struct Feed { /// Download folder name; defaults to the sanitized feed title. #[serde(skip_serializing_if = "Option::is_none")] pub folder: Option, + /// The Directory's category for a feed that names none of its own, as most blogs do not. + /// The feed's own iTunes category wins where there is one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub category: Option, /// Every whitespace-separated word of a keyword must appear in the /// url/title/description/categories for an enclosure to be taken. #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -469,7 +473,7 @@ mod tests { taken.insert("the-daily".to_string(), Feed { url: "u".into(), folder: None, group: None, media_types: None, schedule: None, keywords: vec![], allow_explicit: false, auto_download: true, max_new_per_check: None, username: None, - password: None, password_env: None, + password: None, password_env: None, category: None, }); assert_eq!(unique_slug("The Daily", &taken), "the-daily-2"); } @@ -489,6 +493,7 @@ mod tests { username: Some("ray".into()), password: Some("literal".into()), password_env: None, + category: None, }; assert_eq!(f.password().as_deref(), Some("literal")); diff --git a/src/download.rs b/src/download.rs index b160f8b..ea5e9ad 100644 --- a/src/download.rs +++ b/src/download.rs @@ -387,7 +387,7 @@ mod tests { let mut f = crate::config::Feed { url: "u".into(), folder: Some("Subscriptions/Some | Show".into()), group: None, media_types: None, schedule: None, keywords: vec![], allow_explicit: false, auto_download: true, - max_new_per_check: None, username: None, password: None, password_env: None, + max_new_per_check: None, username: None, password: None, password_env: None, category: None, }; assert_eq!(folder_for(&cfg, "id", &f, None), "Subscriptions/Some - Show"); diff --git a/src/main.rs b/src/main.rs index 74c036d..fda4451 100644 --- a/src/main.rs +++ b/src/main.rs @@ -587,6 +587,7 @@ pub async fn add_one( username: None, password: None, password_env: None, + category: None, }; let title = match feed::fetch(&ctx.client, &probe, None, None).await { @@ -713,6 +714,7 @@ pub fn subscribe_opml( username: None, password: None, password_env: None, + category: None, }, ); grew = true; @@ -956,6 +958,7 @@ pub fn subscriptions(ctx: &Ctx) -> Result> { username: parent.and_then(|p| p.username.clone()), password: parent.and_then(|p| p.password.clone()), password_env: parent.and_then(|p| p.password_env.clone()), + category: None, }, managed: true, }); @@ -1658,6 +1661,7 @@ mod tests { username: None, password: None, password_env: None, + category: None, } } diff --git a/src/web.rs b/src/web.rs index 3bb251f..b9b047e 100644 --- a/src/web.rs +++ b/src/web.rs @@ -471,6 +471,9 @@ struct FeedRow { title: Option, image: Option, folder: Option, + /// The Directory category an admin gave it, and the one the feed names itself, which wins. + category: Option, + feed_category: Option, keywords: Vec, allow_explicit: bool, auto_download: bool, @@ -538,6 +541,8 @@ async fn feeds( title: s.title, image: s.image, folder: feed.folder.clone(), + category: feed.category.clone(), + feed_category: s.category, keywords: mine .keywords .clone() @@ -669,7 +674,8 @@ fn popular(state: &WebState, user_id: i64) -> Result> { image: sum.image, subscribers: n, subscribed, - category: sum.category, + // The feed's own wins; an admin's is for the feeds, mostly blogs, that name none. + category: sum.category.or_else(|| s.cfg.category.clone()), podcast: media.contains(&s.id), }); } @@ -793,6 +799,7 @@ mod tests { username: None, password: None, password_env: None, + category: None, }; assert!(!looks_private(&f("https://feeds.twit.tv/twit.xml"))); assert!(!looks_private(&f("https://example.com/rss?format=mp3"))); @@ -839,7 +846,7 @@ mod tests { crate::config::Feed { url: url.into(), folder: None, group: None, media_types: None, schedule: None, keywords: vec![], allow_explicit: false, auto_download: true, max_new_per_check: None, username: None, - password: None, password_env: None, + password: None, password_env: None, category: None, } } @@ -851,9 +858,10 @@ mod tests { assert!(absent.schedule.is_none() && absent.folder.is_none()); let cleared: FeedPatch = - serde_json::from_str(r#"{"schedule":null,"folder":null,"max_new_per_check":null}"#).unwrap(); + serde_json::from_str(r#"{"schedule":null,"folder":null,"category":null,"max_new_per_check":null}"#).unwrap(); assert_eq!(cleared.schedule, Some(None), "null must mean clear"); assert_eq!(cleared.folder, Some(None)); + assert_eq!(cleared.category, Some(None)); assert_eq!(cleared.max_new_per_check, Some(None)); let set: FeedPatch = serde_json::from_str(r#"{"schedule":"every 6h"}"#).unwrap(); @@ -1038,6 +1046,8 @@ struct FeedPatch { schedule: Option>, #[serde(default, deserialize_with = "double_option")] folder: Option>, + #[serde(default, deserialize_with = "double_option")] + category: Option>, keywords: Option>, allow_explicit: Option, auto_download: Option, @@ -1091,13 +1101,14 @@ async fn patch_feed( // The rest describes the feed itself -- where its files land, its address, when it is // polled -- and there is one of those however many people read it. - let feed_level = body.url.is_some() || body.folder.is_some() || body.schedule.is_some(); + let feed_level = + body.url.is_some() || body.folder.is_some() || body.schedule.is_some() || body.category.is_some(); if !feed_level { return Ok(StatusCode::NO_CONTENT); } if !user.is_admin { return Err(ApiError::forbidden( - "the feed's address, folder and schedule are the same for everyone, so only an admin changes them", + "the feed's address, folder, schedule and category are the same for everyone, so only an admin changes them", )); } let mut cfg = (*state.ctx.cfg()).clone(); @@ -1144,6 +1155,9 @@ async fn patch_feed( if let Some(v) = body.folder { feed.folder = v.filter(|s| !s.trim().is_empty()); } + if let Some(v) = body.category { + feed.category = v.map(|s| s.trim().to_owned()).filter(|s| !s.is_empty()); + } cfg.save(&state.config_path)?; state.ctx.reload_cfg(&state.config_path)?; if url_changed { diff --git a/tests/ui/app.spec.js b/tests/ui/app.spec.js index 1576936..22d9f0a 100644 --- a/tests/ui/app.spec.js +++ b/tests/ui/app.spec.js @@ -951,3 +951,18 @@ test('keys move through items and places, after Feedly', async ({ page }) => { await page.keyboard.press('a'); await expect(page.locator('#count')).toContainText('All Subscriptions'); }); + +test('an admin can give a blog its Directory category', async ({ page }) => { + const patch = (id, category) => page.evaluate(([id, category]) => + api(`/api/feeds/${id}`, { method: 'PATCH', body: JSON.stringify({ category }) }), [id, category]); + const listed = async id => (await page.evaluate(() => api('/api/directory'))).find(p => p.id === id); + + await patch('picture-blog', 'Visual Arts'); + expect(await listed('picture-blog')).toMatchObject({ podcast: false, category: 'Visual Arts' }); + // A feed's own iTunes category wins over one given here. + await patch('test-show', 'Comedy'); + expect((await listed('test-show')).category).toBe('Technology'); + + for (const id of ['picture-blog', 'test-show']) await patch(id, null); + expect((await listed('picture-blog')).category).toBeNull(); +}); diff --git a/web/index.html b/web/index.html index bb50001..f537150 100644 --- a/web/index.html +++ b/web/index.html @@ -2061,9 +2061,19 @@ function settingsModal(f,newUrl){ Where the files land. There is one copy however many people subscribe, so this is the same for everyone.`:''} + ${S.me&&S.me.admin?`
+ + + ${f.feed_category + ? `The feed names its own, ${esc(f.feed_category)}, and the Directory uses that.` + : `The feed names none, so the Directory files it under this. Pick one already listed where it fits.`}
`:''}
`); $('#scopy').onclick=()=>copyText($('#surl').value,$('#scopy')); + // Offer the categories the Directory already shows, so a blog about games joins Games rather + // than starting a second chip beside it. + if($('#scats')) api('/api/directory').then(rows=>{ $('#scats').innerHTML=[...new Set((rows||[]) + .map(p=>p.category).filter(Boolean))].sort().map(c=>`