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:
2026-09-15 17:19:59 +00:00
parent 8ae5c332c3
commit b83523fc92
8 changed files with 59 additions and 7 deletions

View File

@@ -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<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
/// 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"));

View File

@@ -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");

View File

@@ -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<Vec<Sub>> {
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,
}
}

View File

@@ -471,6 +471,9 @@ struct FeedRow {
title: Option<String>,
image: 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>,
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<Vec<PopularRow>> {
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<Option<String>>,
#[serde(default, deserialize_with = "double_option")]
folder: Option<Option<String>>,
#[serde(default, deserialize_with = "double_option")]
category: Option<Option<String>>,
keywords: Option<Vec<String>>,
allow_explicit: 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
// 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 {