Directory: let an admin give a blog its category
Almost no blog names a category the Directory can use, so a feed can carry one of its own in config.toml, set by an admin in the feed's settings and used when the feed names none. The feed's own iTunes category still wins. The field offers the categories the Directory already shows, so a blog about games joins Games rather than starting a second chip. Setting it on a feed from an OPML promotes it to config, as any other shared setting does. Closes #10. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,8 @@ The long form, with what was wrong before and how it was found, is in
|
|||||||
|
|
||||||
### Added
|
### 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,
|
- 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.
|
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
|
- Directory can be filtered to Podcasts or Blogs, and by each show's own iTunes category as a row
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ url = "https://atp.fm/rss"
|
|||||||
folder = "Accidental Tech Podcast" # default: the feed title
|
folder = "Accidental Tech Podcast" # default: the feed title
|
||||||
schedule = "every 6h" # overrides [general] for this feed
|
schedule = "every 6h" # overrides [general] for this feed
|
||||||
media_types = ["audio"] # 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
|
username = "ray" # HTTP basic auth
|
||||||
password_env = "IPX_ATP_PASS" # preferred over a literal `password`
|
password_env = "IPX_ATP_PASS" # preferred over a literal `password`
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -115,6 +115,10 @@ pub struct Feed {
|
|||||||
/// Download folder name; defaults to the sanitized feed title.
|
/// Download folder name; defaults to the sanitized feed title.
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub folder: Option<String>,
|
pub folder: Option<String>,
|
||||||
|
/// 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<String>,
|
||||||
/// Every whitespace-separated word of a keyword must appear in the
|
/// Every whitespace-separated word of a keyword must appear in the
|
||||||
/// url/title/description/categories for an enclosure to be taken.
|
/// url/title/description/categories for an enclosure to be taken.
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
@@ -469,7 +473,7 @@ mod tests {
|
|||||||
taken.insert("the-daily".to_string(), Feed {
|
taken.insert("the-daily".to_string(), Feed {
|
||||||
url: "u".into(), folder: None, group: None, media_types: None, schedule: None, keywords: vec![], allow_explicit: false,
|
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,
|
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");
|
assert_eq!(unique_slug("The Daily", &taken), "the-daily-2");
|
||||||
}
|
}
|
||||||
@@ -489,6 +493,7 @@ mod tests {
|
|||||||
username: Some("ray".into()),
|
username: Some("ray".into()),
|
||||||
password: Some("literal".into()),
|
password: Some("literal".into()),
|
||||||
password_env: None,
|
password_env: None,
|
||||||
|
category: None,
|
||||||
};
|
};
|
||||||
assert_eq!(f.password().as_deref(), Some("literal"));
|
assert_eq!(f.password().as_deref(), Some("literal"));
|
||||||
|
|
||||||
|
|||||||
@@ -387,7 +387,7 @@ mod tests {
|
|||||||
let mut f = crate::config::Feed {
|
let mut f = crate::config::Feed {
|
||||||
url: "u".into(), folder: Some("Subscriptions/Some | Show".into()), group: None, media_types: None,
|
url: "u".into(), folder: Some("Subscriptions/Some | Show".into()), group: None, media_types: None,
|
||||||
schedule: None, keywords: vec![], allow_explicit: false, auto_download: true,
|
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");
|
assert_eq!(folder_for(&cfg, "id", &f, None), "Subscriptions/Some - Show");
|
||||||
|
|
||||||
|
|||||||
@@ -587,6 +587,7 @@ pub async fn add_one(
|
|||||||
username: None,
|
username: None,
|
||||||
password: None,
|
password: None,
|
||||||
password_env: None,
|
password_env: None,
|
||||||
|
category: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let title = match feed::fetch(&ctx.client, &probe, None, None).await {
|
let title = match feed::fetch(&ctx.client, &probe, None, None).await {
|
||||||
@@ -713,6 +714,7 @@ pub fn subscribe_opml(
|
|||||||
username: None,
|
username: None,
|
||||||
password: None,
|
password: None,
|
||||||
password_env: None,
|
password_env: None,
|
||||||
|
category: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
grew = true;
|
grew = true;
|
||||||
@@ -956,6 +958,7 @@ pub fn subscriptions(ctx: &Ctx) -> Result<Vec<Sub>> {
|
|||||||
username: parent.and_then(|p| p.username.clone()),
|
username: parent.and_then(|p| p.username.clone()),
|
||||||
password: parent.and_then(|p| p.password.clone()),
|
password: parent.and_then(|p| p.password.clone()),
|
||||||
password_env: parent.and_then(|p| p.password_env.clone()),
|
password_env: parent.and_then(|p| p.password_env.clone()),
|
||||||
|
category: None,
|
||||||
},
|
},
|
||||||
managed: true,
|
managed: true,
|
||||||
});
|
});
|
||||||
@@ -1658,6 +1661,7 @@ mod tests {
|
|||||||
username: None,
|
username: None,
|
||||||
password: None,
|
password: None,
|
||||||
password_env: None,
|
password_env: None,
|
||||||
|
category: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
24
src/web.rs
24
src/web.rs
@@ -471,6 +471,9 @@ struct FeedRow {
|
|||||||
title: Option<String>,
|
title: Option<String>,
|
||||||
image: Option<String>,
|
image: Option<String>,
|
||||||
folder: Option<String>,
|
folder: Option<String>,
|
||||||
|
/// The Directory category an admin gave it, and the one the feed names itself, which wins.
|
||||||
|
category: Option<String>,
|
||||||
|
feed_category: Option<String>,
|
||||||
keywords: Vec<String>,
|
keywords: Vec<String>,
|
||||||
allow_explicit: bool,
|
allow_explicit: bool,
|
||||||
auto_download: bool,
|
auto_download: bool,
|
||||||
@@ -538,6 +541,8 @@ async fn feeds(
|
|||||||
title: s.title,
|
title: s.title,
|
||||||
image: s.image,
|
image: s.image,
|
||||||
folder: feed.folder.clone(),
|
folder: feed.folder.clone(),
|
||||||
|
category: feed.category.clone(),
|
||||||
|
feed_category: s.category,
|
||||||
keywords: mine
|
keywords: mine
|
||||||
.keywords
|
.keywords
|
||||||
.clone()
|
.clone()
|
||||||
@@ -669,7 +674,8 @@ fn popular(state: &WebState, user_id: i64) -> Result<Vec<PopularRow>> {
|
|||||||
image: sum.image,
|
image: sum.image,
|
||||||
subscribers: n,
|
subscribers: n,
|
||||||
subscribed,
|
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),
|
podcast: media.contains(&s.id),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -793,6 +799,7 @@ mod tests {
|
|||||||
username: None,
|
username: None,
|
||||||
password: None,
|
password: None,
|
||||||
password_env: None,
|
password_env: None,
|
||||||
|
category: None,
|
||||||
};
|
};
|
||||||
assert!(!looks_private(&f("https://feeds.twit.tv/twit.xml")));
|
assert!(!looks_private(&f("https://feeds.twit.tv/twit.xml")));
|
||||||
assert!(!looks_private(&f("https://example.com/rss?format=mp3")));
|
assert!(!looks_private(&f("https://example.com/rss?format=mp3")));
|
||||||
@@ -839,7 +846,7 @@ mod tests {
|
|||||||
crate::config::Feed {
|
crate::config::Feed {
|
||||||
url: url.into(), folder: None, group: None, media_types: None, schedule: None, keywords: vec![], allow_explicit: false,
|
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,
|
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());
|
assert!(absent.schedule.is_none() && absent.folder.is_none());
|
||||||
|
|
||||||
let cleared: FeedPatch =
|
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.schedule, Some(None), "null must mean clear");
|
||||||
assert_eq!(cleared.folder, Some(None));
|
assert_eq!(cleared.folder, Some(None));
|
||||||
|
assert_eq!(cleared.category, Some(None));
|
||||||
assert_eq!(cleared.max_new_per_check, Some(None));
|
assert_eq!(cleared.max_new_per_check, Some(None));
|
||||||
|
|
||||||
let set: FeedPatch = serde_json::from_str(r#"{"schedule":"every 6h"}"#).unwrap();
|
let set: FeedPatch = serde_json::from_str(r#"{"schedule":"every 6h"}"#).unwrap();
|
||||||
@@ -1038,6 +1046,8 @@ struct FeedPatch {
|
|||||||
schedule: Option<Option<String>>,
|
schedule: Option<Option<String>>,
|
||||||
#[serde(default, deserialize_with = "double_option")]
|
#[serde(default, deserialize_with = "double_option")]
|
||||||
folder: Option<Option<String>>,
|
folder: Option<Option<String>>,
|
||||||
|
#[serde(default, deserialize_with = "double_option")]
|
||||||
|
category: Option<Option<String>>,
|
||||||
keywords: Option<Vec<String>>,
|
keywords: Option<Vec<String>>,
|
||||||
allow_explicit: Option<bool>,
|
allow_explicit: Option<bool>,
|
||||||
auto_download: Option<bool>,
|
auto_download: Option<bool>,
|
||||||
@@ -1091,13 +1101,14 @@ async fn patch_feed(
|
|||||||
|
|
||||||
// The rest describes the feed itself -- where its files land, its address, when it is
|
// 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.
|
// 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 {
|
if !feed_level {
|
||||||
return Ok(StatusCode::NO_CONTENT);
|
return Ok(StatusCode::NO_CONTENT);
|
||||||
}
|
}
|
||||||
if !user.is_admin {
|
if !user.is_admin {
|
||||||
return Err(ApiError::forbidden(
|
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();
|
let mut cfg = (*state.ctx.cfg()).clone();
|
||||||
@@ -1144,6 +1155,9 @@ async fn patch_feed(
|
|||||||
if let Some(v) = body.folder {
|
if let Some(v) = body.folder {
|
||||||
feed.folder = v.filter(|s| !s.trim().is_empty());
|
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)?;
|
cfg.save(&state.config_path)?;
|
||||||
state.ctx.reload_cfg(&state.config_path)?;
|
state.ctx.reload_cfg(&state.config_path)?;
|
||||||
if url_changed {
|
if url_changed {
|
||||||
|
|||||||
@@ -951,3 +951,18 @@ test('keys move through items and places, after Feedly', async ({ page }) => {
|
|||||||
await page.keyboard.press('a');
|
await page.keyboard.press('a');
|
||||||
await expect(page.locator('#count')).toContainText('All Subscriptions');
|
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();
|
||||||
|
});
|
||||||
|
|||||||
@@ -2061,9 +2061,19 @@ function settingsModal(f,newUrl){
|
|||||||
<input type="text" id="sfolder" value="${esc(f.folder||'')}" placeholder="${esc(f.title||f.id)}">
|
<input type="text" id="sfolder" value="${esc(f.folder||'')}" placeholder="${esc(f.title||f.id)}">
|
||||||
<span class="hint">Where the files land. There is one copy however many people
|
<span class="hint">Where the files land. There is one copy however many people
|
||||||
subscribe, so this is the same for everyone.</span></div>`:''}
|
subscribe, so this is the same for everyone.</span></div>`:''}
|
||||||
|
${S.me&&S.me.admin?`<div class="field"><label>Directory category (shared)</label>
|
||||||
|
<input type="text" id="scat" list="scats" value="${esc(f.category||'')}" placeholder="${esc(f.feed_category||'None')}">
|
||||||
|
<datalist id="scats"></datalist>
|
||||||
|
<span class="hint">${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.`}</span></div>`:''}
|
||||||
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
|
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
|
||||||
<button class="btn ico primary" id="ssave" title="Save" aria-label="Save">${ICON.check}</button></div>`);
|
<button class="btn ico primary" id="ssave" title="Save" aria-label="Save">${ICON.check}</button></div>`);
|
||||||
$('#scopy').onclick=()=>copyText($('#surl').value,$('#scopy'));
|
$('#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=>`<option value="${esc(c)}">`).join(''); }).catch(()=>{});
|
||||||
$('#ssave').onclick=async()=>{
|
$('#ssave').onclick=async()=>{
|
||||||
const max=$('#smax').value;
|
const max=$('#smax').value;
|
||||||
try{
|
try{
|
||||||
@@ -2075,6 +2085,7 @@ function settingsModal(f,newUrl){
|
|||||||
if(S.me&&S.me.admin){
|
if(S.me&&S.me.admin){
|
||||||
patch.url=$('#surl').value.trim();
|
patch.url=$('#surl').value.trim();
|
||||||
patch.folder=$('#sfolder').value.trim()||null;
|
patch.folder=$('#sfolder').value.trim()||null;
|
||||||
|
patch.category=$('#scat').value.trim()||null;
|
||||||
}
|
}
|
||||||
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify(patch)});
|
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify(patch)});
|
||||||
closeModal(); toast('Saved — applies on the next scan');
|
closeModal(); toast('Saved — applies on the next scan');
|
||||||
|
|||||||
Reference in New Issue
Block a user