feat: browse and save combined category feeds

This commit is contained in:
2026-10-11 23:30:11 +02:00
parent ff7590dfa7
commit b53b57ee17
4 changed files with 1143 additions and 172 deletions
+217
View File
@@ -384,6 +384,53 @@ pub fn set_category_pinned(path: &Path, name: &str, pinned: bool) -> io::Result<
})
}
pub fn create_category(path: &Path, name: &str, channels: &[String]) -> io::Result<Config> {
let name = category_name(name)?;
let (original, mut document) = read_document(path)?;
let mappings = parse_channels(&document)?;
let mut categories = parse_categories(&document, &mappings)?;
let pinned_categories = parse_pins(&document)?;
let keys = keys::Bindings::from_toml(document.get("keys"))?;
if categories.contains_key(name) {
return Err(invalid(format!("Category already exists: {name:?}")));
}
let mut labels = BTreeMap::new();
// As in saved_channels, use the first mapped handle for each identity.
for (handle, identity) in &mappings {
labels.entry(identity.as_str()).or_insert(handle.as_str());
}
let identities: BTreeSet<_> = channels
.iter()
.map(|channel| resolve_channel(channel, &mappings))
.collect::<io::Result<_>>()?;
let mut entries = Array::new();
for identity in &identities {
let readable = labels.get(identity.as_str()).copied().unwrap_or_else(|| {
identity
.strip_prefix("https://www.youtube.com/")
.filter(|address| address.starts_with('@'))
.unwrap_or(identity)
});
entries.push(readable);
}
if !document.contains_key("categories") {
document.insert("categories", Item::Table(Table::new()));
}
document["categories"]
.as_table_like_mut()
.expect("categories were validated")
.insert(name, value(entries));
categories.insert(name.to_owned(), identities.into_iter().collect());
save(path, original.as_deref(), document.to_string().as_bytes())?;
Ok(Config {
channels: mappings,
categories,
pinned_categories,
keys,
})
}
pub fn rename_category(path: &Path, old: &str, new: &str) -> io::Result<Config> {
let old = category_name(old)?;
let new = category_name(new)?;
@@ -873,6 +920,9 @@ mod tests {
let pin_error = set_category_pinned(&path, "B", true).unwrap_err();
assert_eq!(pin_error.kind(), io::ErrorKind::InvalidData);
assert_eq!(fs::read(&path).unwrap(), original.as_bytes());
let create_error = create_category(&path, "New", &expected).unwrap_err();
assert_eq!(create_error.kind(), io::ErrorKind::InvalidData);
assert_eq!(fs::read(&path).unwrap(), original.as_bytes());
}
}
@@ -989,6 +1039,173 @@ mod tests {
}
}
#[test]
fn category_creation_deduplicates_aliases_and_preserves_original_config() {
let directory = TestDirectory::new();
let path = directory.0.join("config.toml");
let id = "UC4QobU6STFB0P71PMvOGN5A";
let url = format!("https://www.youtube.com/channel/{id}");
let original = "# manual preferences\n[other]\nvalue = 42\n[settings]\npinned_categories = [' Source '] # favorite\nplayer = 'mpv'\n[keys.app]\nquit = ['q'] # key note\n[channels]\n'@zeta' = 'UC4QobU6STFB0P71PMvOGN5A' # mapping note\n'@alpha' = 'UC4QobU6STFB0P71PMvOGN5A'\n[categories]\n# category note\n' Source ' = [\n '@zeta', # entry note\n '@manual',\n '@manual',\n] # array note\nEmpty = []\n";
fs::write(&path, original).unwrap();
let before = load(&path).unwrap();
let created = create_category(
&path,
" Combined ",
&[
id.to_owned(),
url.clone(),
"@zeta".to_owned(),
"https://youtube.com/@alpha".to_owned(),
"@manual".to_owned(),
"https://www.youtube.com/@manual".to_owned(),
"UCabcdefghijklmnopqrstuv".to_owned(),
],
)
.unwrap();
assert_eq!(
created.categories["Combined"],
[
"https://www.youtube.com/@manual".to_owned(),
url,
"https://www.youtube.com/channel/UCabcdefghijklmnopqrstuv".to_owned(),
]
);
assert_eq!(created.channels, before.channels);
assert_eq!(created.categories["Source"], before.categories["Source"]);
assert_eq!(created.categories["Empty"], before.categories["Empty"]);
assert_eq!(created.pinned_categories, before.pinned_categories);
let contents = fs::read_to_string(&path).unwrap();
let mut document = contents.parse::<DocumentMut>().unwrap();
assert_eq!(
document["categories"]["Combined"]
.as_array()
.unwrap()
.iter()
.map(|entry| entry.as_str().unwrap())
.collect::<Vec<_>>(),
[
"@manual",
"@alpha",
"https://www.youtube.com/channel/UCabcdefghijklmnopqrstuv",
]
);
document["categories"]
.as_table_like_mut()
.unwrap()
.remove("Combined");
assert_eq!(document.to_string(), original);
assert_eq!(load(&path).unwrap().categories, created.categories);
assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 1);
}
#[test]
fn category_creation_uses_latest_mappings_and_supports_inline_unicode_and_empty() {
let directory = TestDirectory::new();
let path = directory.0.join("config.toml");
let original = "categories = { Source = ['@first', '@first'], Empty = [] } # categories note\nsettings = { pinned_categories = ['Source'], custom = 42 }\nchannels = { '@mapped' = 'UC4QobU6STFB0P71PMvOGN5A' }\n";
fs::write(&path, original).unwrap();
let opened = load(&path).unwrap();
let latest = original.replace("UC4QobU6STFB0P71PMvOGN5A", "UCabcdefghijklmnopqrstuv");
fs::write(&path, &latest).unwrap();
let created = create_category(
&path,
" 日本語 🎵 ",
&["@mapped".to_owned(), "UCabcdefghijklmnopqrstuv".to_owned()],
)
.unwrap();
assert_ne!(created.channels, opened.channels);
assert_eq!(
created.categories["日本語 🎵"],
["https://www.youtube.com/channel/UCabcdefghijklmnopqrstuv"]
);
assert_eq!(created.pinned_categories, ["Source"]);
let contents = fs::read_to_string(&path).unwrap();
let document = contents.parse::<DocumentMut>().unwrap();
let before_document = latest.parse::<DocumentMut>().unwrap();
assert!(document["categories"].is_inline_table());
assert_eq!(
document["categories"]["日本語 🎵"][0].as_str(),
Some("@mapped")
);
for section in ["settings", "channels"] {
assert_eq!(
document[section].to_string(),
before_document[section].to_string()
);
}
for name in ["Source", "Empty"] {
assert_eq!(
document["categories"][name].to_string(),
before_document["categories"][name].to_string()
);
}
assert!(contents.contains("# categories note"));
let empty = create_category(&path, "New empty", &[]).unwrap();
assert!(empty.categories["New empty"].is_empty());
assert_eq!(empty.pinned_categories, ["Source"]);
let missing = directory.0.join("nested/config.toml");
let empty = create_category(&missing, "Empty", &[]).unwrap();
assert!(empty.categories["Empty"].is_empty());
assert!(empty.channels.is_empty());
assert!(empty.pinned_categories.is_empty());
assert_eq!(load(&missing).unwrap().categories, empty.categories);
}
#[test]
fn category_creation_rejects_invalid_names_channels_and_configs_without_writes() {
let directory = TestDirectory::new();
let path = directory.0.join("config.toml");
let original = "[categories]\n' Source ' = ['@manual']\nEmpty = []\n";
fs::write(&path, original).unwrap();
for name in [
"",
" ",
"Bad\nName",
"Bad\u{7f}Name",
"Source",
" Source ",
"Empty",
] {
assert!(create_category(&path, name, &[]).is_err(), "{name:?}");
assert_eq!(fs::read(&path).unwrap(), original.as_bytes());
}
// Even an identical membership snapshot must not overwrite a category.
assert!(create_category(&path, "Source", &["@manual".to_owned()]).is_err());
assert_eq!(fs::read(&path).unwrap(), original.as_bytes());
for channel in [
"",
"bad",
"@bad handle",
"@manual\n",
"https://example.com/@manual",
"https://www.youtube.com/@manual/videos",
] {
assert!(
create_category(&path, "New", &["@valid".to_owned(), channel.to_owned()]).is_err(),
"{channel:?}"
);
assert_eq!(fs::read(&path).unwrap(), original.as_bytes());
}
for malformed in [
"[categories\n",
"categories = 3\n",
"[categories]\nSource = [7]\n",
"[categories]\nSource = []\n' Source ' = []\n",
"[channels]\n'@mapped' = '@other'\n",
"[settings]\npinned_categories = [7]\n",
"[keys.app]\nunknown = ['q']\n",
] {
fs::write(&path, malformed).unwrap();
assert!(create_category(&path, "New", &[]).is_err());
assert_eq!(fs::read(&path).unwrap(), malformed.as_bytes());
}
let missing = directory.0.join("missing/config.toml");
assert!(create_category(&missing, "Bad\nName", &[]).is_err());
assert!(create_category(&missing, "New", &["@bad handle".to_owned()]).is_err());
assert!(!missing.parent().unwrap().exists());
assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 1);
}
#[test]
fn category_rename_preserves_memberships_pins_comments_and_section_order() {
let directory = TestDirectory::new();