feat: combine subscriptions in a general Feed view

This commit is contained in:
2026-10-10 00:17:23 +02:00
parent f7c8ef0f3c
commit 2de3e82597
2 changed files with 342 additions and 33 deletions
+77 -12
View File
@@ -11,9 +11,23 @@ use toml_edit::{Array, DocumentMut, Item, Table, value};
#[derive(Default, Debug)]
pub struct Config {
pub channels: BTreeMap<String, String>,
pub categories: BTreeMap<String, Vec<String>>,
}
impl Config {
pub fn subscriptions(&self) -> Vec<String> {
let mut channels: Vec<_> = self
.channels
.values()
.chain(self.categories.values().flatten())
.collect();
channels.sort_unstable();
channels.dedup();
channels.into_iter().cloned().collect()
}
}
pub fn config_path() -> io::Result<PathBuf> {
resolve_path(
std::env::var_os("XDG_CONFIG_HOME"),
@@ -35,7 +49,12 @@ fn resolve_path(xdg: Option<OsString>, home: Option<OsString>) -> io::Result<Pat
pub fn load(path: &Path) -> io::Result<Config> {
let (_, document) = read_document(path)?;
parse_categories(&document, &parse_channels(&document)?)
let channels = parse_channels(&document)?;
let categories = parse_categories(&document, &channels)?;
Ok(Config {
channels,
categories,
})
}
pub fn set_channel_tags(
@@ -163,9 +182,12 @@ pub fn set_channel_tags(
}
}
}
let config = parse_categories(&document, &mappings)?;
let categories = parse_categories(&document, &mappings)?;
save(path, original.as_deref(), document.to_string().as_bytes())?;
Ok(config)
Ok(Config {
channels: mappings,
categories,
})
}
fn invalid(message: impl Into<String>) -> io::Error {
@@ -296,10 +318,10 @@ fn read_document(path: &Path) -> io::Result<(Option<Vec<u8>>, DocumentMut)> {
fn parse_categories(
document: &DocumentMut,
mappings: &BTreeMap<String, String>,
) -> io::Result<Config> {
let mut config = Config::default();
) -> io::Result<BTreeMap<String, Vec<String>>> {
let mut parsed = BTreeMap::new();
let Some(item) = document.get("categories") else {
return Ok(config);
return Ok(parsed);
};
let categories = item
.as_table_like()
@@ -321,17 +343,13 @@ fn parse_categories(
normalized.push(channel);
}
}
if config
.categories
.insert(name.to_owned(), normalized)
.is_some()
{
if parsed.insert(name.to_owned(), normalized).is_some() {
return Err(invalid(format!(
"Duplicate category name after trimming: {name:?}"
)));
}
}
Ok(config)
Ok(parsed)
}
static NEXT_TEMP: AtomicU64 = AtomicU64::new(0);
@@ -430,6 +448,10 @@ mod tests {
assert!(!path.parent().unwrap().exists());
let tags = BTreeMap::from([(" Music ".to_owned(), true)]);
let config = set_channel_tags(&path, &tags, "@example", id).unwrap();
assert_eq!(
config.channels["@example"],
format!("https://www.youtube.com/channel/{id}")
);
assert_eq!(
config.categories["Music"],
[format!("https://www.youtube.com/channel/{id}")]
@@ -625,6 +647,49 @@ mod tests {
);
}
#[test]
fn subscriptions_include_untagged_registry_and_manual_channels_after_tag_removal() {
let directory = TestDirectory::new();
let path = directory.0.join("config.toml");
let id = "UC4QobU6STFB0P71PMvOGN5A";
let url = format!("https://www.youtube.com/channel/{id}");
let other_id = "UCabcdefghijklmnopqrstuv";
let other_url = format!("https://www.youtube.com/channel/{other_id}");
let registry =
format!("[channels]\n'https://youtube.com/@old' = '{id}'\n'@alias' = '{url}'\n");
fs::write(&path, &registry).unwrap();
let config = load(&path).unwrap();
assert!(config.categories.is_empty());
assert_eq!(config.channels["@old"], url);
assert_eq!(config.subscriptions(), std::slice::from_ref(&url));
let config = set_channel_tags(&path, &BTreeMap::new(), "@untagged", other_id).unwrap();
assert!(config.categories.is_empty());
assert_eq!(config.subscriptions(), [url.clone(), other_url.clone()]);
assert_eq!(load(&path).unwrap().subscriptions(), config.subscriptions());
fs::write(
&path,
format!(
"{registry}[categories]\nTagged = ['@old', '@alias', '{url}', '{id}']\nManual = ['@RustVideos', 'https://youtube.com/@RustVideos', '{other_id}', '{other_url}']\n"
),
)
.unwrap();
let expected = vec![
"https://www.youtube.com/@RustVideos".to_owned(),
url.clone(),
other_url,
];
let config = load(&path).unwrap();
assert_eq!(config.categories["Tagged"], std::slice::from_ref(&url));
assert_eq!(config.subscriptions(), expected);
let tags = BTreeMap::from([("Tagged".to_owned(), false)]);
let config = set_channel_tags(&path, &tags, "@current", id).unwrap();
assert!(config.categories["Tagged"].is_empty());
assert_eq!(config.subscriptions(), expected);
assert_eq!(load(&path).unwrap().subscriptions(), expected);
}
#[test]
fn paths_and_channel_addresses_are_strict() {
let home = Some(OsString::from("/home/test"));