feat: edit overlapping channel tag memberships

This commit is contained in:
2026-10-09 23:28:01 +02:00
parent 20cdd8afb2
commit 7367b2fc4b
2 changed files with 395 additions and 98 deletions
+202 -42
View File
@@ -38,8 +38,21 @@ pub fn load(path: &Path) -> io::Result<Config> {
parse_categories(&document, &parse_channels(&document)?)
}
pub fn add_channel(path: &Path, category: &str, handle: &str, channel: &str) -> io::Result<Config> {
let category = category_name(category)?;
pub fn set_channel_tags(
path: &Path,
tags: &BTreeMap<String, bool>,
handle: &str,
channel: &str,
) -> io::Result<Config> {
let mut normalized_tags = BTreeMap::new();
for (name, checked) in tags {
let name = category_name(name)?;
if normalized_tags.insert(name, *checked).is_some() {
return Err(invalid(format!(
"Duplicate category name after trimming: {name:?}"
)));
}
}
let handle = normalize_handle(handle)?;
let channel = normalize_channel(channel)?;
if !channel.starts_with("https://www.youtube.com/channel/") {
@@ -75,33 +88,80 @@ pub fn add_channel(path: &Path, category: &str, handle: &str, channel: &str) ->
let categories = document["categories"]
.as_table_like_mut()
.expect("categories were validated");
// Keep the manually written key and its formatting if it has surrounding spaces.
let key = categories
.iter()
.find(|(key, _)| key.trim() == category)
.map(|(key, _)| key.to_owned())
.unwrap_or_else(|| category.to_owned());
if !categories.contains_key(&key) {
categories.insert(&key, value(Array::new()));
}
let channels = categories
.get_mut(&key)
.and_then(Item::as_array_mut)
.expect("category was validated");
let mut found = false;
for entry in channels.iter_mut() {
if resolve_channel(entry.as_str().expect("channel was validated"), &mappings)? == channel {
// Replace old URLs/aliases with the current handle, preserving entry comments.
if entry.as_str() != Some(&handle) {
let decor = entry.decor().clone();
*entry = handle.as_str().into();
*entry.decor_mut() = decor;
}
found = true;
for (name, checked) in normalized_tags {
// Keep manually written keys and their formatting, including surrounding spaces.
let key = categories
.iter()
.find(|(key, _)| key.trim() == name)
.map(|(key, _)| key.to_owned())
.unwrap_or_else(|| name.to_owned());
if !categories.contains_key(&key) {
categories.insert(&key, value(Array::new()));
}
let channels = categories
.get_mut(&key)
.and_then(Item::as_array_mut)
.expect("category was validated");
if checked {
let mut found = false;
for entry in channels.iter_mut() {
if resolve_channel(entry.as_str().expect("channel was validated"), &mappings)?
== channel
{
// Keep manually written duplicates and entry comments.
if entry.as_str() != Some(&handle) {
let decor = entry.decor().clone();
*entry = handle.as_str().into();
*entry.decor_mut() = decor;
}
found = true;
}
}
if !found {
channels.push(handle.as_str());
}
} else {
for index in (0..channels.len()).rev() {
if resolve_channel(
channels
.get(index)
.unwrap()
.as_str()
.expect("channel was validated"),
&mappings,
)? != channel
{
continue;
}
let removed = channels.remove(index);
// Array comments can be attached to the next entry, not their own line.
let prefix = removed
.decor()
.prefix()
.and_then(|raw| raw.as_str())
.unwrap_or("");
let suffix = removed
.decor()
.suffix()
.and_then(|raw| raw.as_str())
.unwrap_or("");
if prefix.contains('#') || suffix.contains('#') {
if let Some(next) = channels.get_mut(index) {
let next_prefix = next
.decor()
.prefix()
.and_then(|raw| raw.as_str())
.unwrap_or("");
let preserved = format!("{prefix}{suffix}{next_prefix}");
next.decor_mut().set_prefix(preserved);
} else {
let trailing = channels.trailing().as_str().unwrap_or("");
let preserved = format!("{prefix}{suffix}{trailing}");
channels.set_trailing(preserved);
}
}
}
}
}
if !found {
channels.push(handle);
}
let config = parse_categories(&document, &mappings)?;
save(path, original.as_deref(), document.to_string().as_bytes())?;
@@ -368,7 +428,8 @@ mod tests {
let id = "UC4QobU6STFB0P71PMvOGN5A";
assert!(load(&path).unwrap().categories.is_empty());
assert!(!path.parent().unwrap().exists());
let config = add_channel(&path, " Music ", "@example", id).unwrap();
let tags = BTreeMap::from([(" Music ".to_owned(), true)]);
let config = set_channel_tags(&path, &tags, "@example", id).unwrap();
assert_eq!(
config.categories["Music"],
[format!("https://www.youtube.com/channel/{id}")]
@@ -376,11 +437,12 @@ mod tests {
let manual = "# manual preferences\nplayer = 'mpv' # keep me\n[other]\nvalue = 42\n[categories]\n# favorites\n' Music ' = ['@example', 'https://www.youtube.com/@example'] # channel note\nEmpty = []\n";
fs::write(&path, manual).unwrap();
let config = add_channel(&path, "Music", "https://youtube.com/@example", id).unwrap();
let tags = BTreeMap::from([("Music".to_owned(), true)]);
let config = set_channel_tags(&path, &tags, "https://youtube.com/@example", id).unwrap();
assert_eq!(config.categories["Music"].len(), 1);
assert!(config.categories["Empty"].is_empty());
let id = "UCabcdefghijklmnopqrstuv";
let config = add_channel(&path, "Music", "@another", id).unwrap();
let config = set_channel_tags(&path, &tags, "@another", id).unwrap();
assert_eq!(
config.categories["Music"][1],
format!("https://www.youtube.com/channel/{id}")
@@ -410,21 +472,24 @@ mod tests {
"[channels]\n'@example' = 'https://example.com/channel/UCabcdefghijklmnopqrstuv'\n",
"[channels]\n'@example' = '@another'\n",
"[channels]\n'not-a-handle' = 'UCabcdefghijklmnopqrstuv'\n",
"[channels]\n'@example' = 'UCabcdefghijklmnopqrstuv'\n'https://youtube.com/@example' = 'UCabcdefghijklmnopqrstuv'\n",
] {
fs::write(&path, malformed).unwrap();
assert!(load(&path).is_err());
assert!(add_channel(&path, "Music", "@example", id).is_err());
assert!(set_channel_tags(&path, &tags, "@example", id).is_err());
assert_eq!(fs::read_to_string(&path).unwrap(), malformed);
}
fs::write(&path, manual).unwrap();
for tags in [
BTreeMap::from([(" ".to_owned(), true)]),
BTreeMap::from([("bad\nname".to_owned(), false)]),
BTreeMap::from([("Music".to_owned(), true), (" Music ".to_owned(), false)]),
] {
assert!(set_channel_tags(&path, &tags, "@example", id).is_err());
assert_eq!(fs::read_to_string(&path).unwrap(), manual);
}
assert!(
add_channel(
&path,
"Music",
"https://www.youtube.com/@example/videos",
id
)
.is_err()
set_channel_tags(&path, &tags, "https://www.youtube.com/@example/videos", id).is_err()
);
assert_eq!(fs::read_to_string(&path).unwrap(), manual);
assert!(save(&path, Some(b"previous contents"), b"replacement").is_err());
@@ -448,23 +513,118 @@ mod tests {
config.categories["Direct"],
["https://www.youtube.com/@RustVideos"]
);
let config = add_channel(&path, "Japanese", "@jawed", id).unwrap();
let tags = BTreeMap::from([("Japanese".to_owned(), true)]);
let config = set_channel_tags(&path, &tags, "@jawed", id).unwrap();
assert_eq!(config.categories["Japanese"], std::slice::from_ref(&url));
assert_eq!(config.categories["Other"], std::slice::from_ref(&url));
let saved = fs::read_to_string(&path).unwrap();
let document = saved.parse::<DocumentMut>().unwrap();
let entries = document["categories"]["Japanese"].as_array().unwrap();
assert!(entries.iter().all(|entry| entry.as_str() == Some("@jawed")));
assert_eq!(entries.len(), 2);
assert_eq!(document["channels"]["@jawed"].as_str(), Some(url.as_str()));
assert_eq!(document["channels"]["@old"].as_str(), Some(url.as_str()));
assert!(saved.contains("# keep this entry comment"));
add_channel(&path, "Japanese", "@jawed", id).unwrap();
set_channel_tags(&path, &tags, "@jawed", id).unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), saved);
assert!(add_channel(&path, "Japanese", "@jawed", "UCabcdefghijklmnopqrstuv").is_err());
assert!(set_channel_tags(&path, &tags, "@jawed", "UCabcdefghijklmnopqrstuv").is_err());
assert_eq!(fs::read_to_string(&path).unwrap(), saved);
assert_eq!(load(&path).unwrap().categories["Japanese"], [url]);
}
#[test]
fn overlapping_tags_remove_stable_aliases_without_touching_other_channels() {
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 manual = format!(
"# subscriptions\n[channels]\n'@old' = '{url}' # old alias\n'@other' = '{other_url}' # other mapping\n[categories]\n' First ' = [\n '@other', # unrelated creator\n '{id}',\n '@old',\n 'https://youtube.com/@old',\n '{url}',\n] # first tag\nSecond = ['@other']\nOmitted = ['@old'] # untouched tag\n"
);
fs::write(&path, &manual).unwrap();
let tags = BTreeMap::from([
("First".to_owned(), true),
("Second".to_owned(), true),
("New empty".to_owned(), false),
]);
let config = set_channel_tags(&path, &tags, "@current", id).unwrap();
assert_eq!(config.categories["First"], [other_url.clone(), url.clone()]);
assert_eq!(
config.categories["Second"],
[other_url.clone(), url.clone()]
);
assert!(config.categories["New empty"].is_empty());
let saved = fs::read_to_string(&path).unwrap();
let document = saved.parse::<DocumentMut>().unwrap();
let entries = document["categories"][" First "].as_array().unwrap();
assert_eq!(entries.len(), 5);
assert!(
entries
.iter()
.skip(1)
.all(|entry| entry.as_str() == Some("@current"))
);
assert_eq!(
document["categories"]["Omitted"]
.as_array()
.unwrap()
.get(0)
.unwrap()
.as_str(),
Some("@old")
);
assert_eq!(document["channels"]["@old"].as_str(), Some(url.as_str()));
assert_eq!(
document["channels"]["@other"].as_str(),
Some(other_url.as_str())
);
// Restore manually written aliases so deselection exercises every accepted spelling.
fs::write(&path, &manual).unwrap();
let tags = BTreeMap::from([
("First".to_owned(), false),
("Second".to_owned(), true),
("New empty".to_owned(), false),
]);
let config = set_channel_tags(&path, &tags, "@current", id).unwrap();
assert_eq!(config.categories["First"], std::slice::from_ref(&other_url));
assert_eq!(
config.categories["Second"],
[other_url.clone(), url.clone()]
);
assert_eq!(config.categories["Omitted"], std::slice::from_ref(&url));
assert!(config.categories["New empty"].is_empty());
let saved = fs::read_to_string(&path).unwrap();
for comment in [
"# subscriptions",
"# old alias",
"# other mapping",
"# unrelated creator",
"# first tag",
"# untouched tag",
] {
assert!(saved.contains(comment), "lost {comment}");
}
let tags = BTreeMap::from([
("First".to_owned(), false),
("Second".to_owned(), false),
("Omitted".to_owned(), false),
]);
set_channel_tags(&path, &tags, "@current", id).unwrap();
let config = load(&path).unwrap();
assert_eq!(config.categories["First"], std::slice::from_ref(&other_url));
assert_eq!(config.categories["Second"], [other_url]);
assert!(config.categories["Omitted"].is_empty());
assert!(config.categories["New empty"].is_empty());
assert!(
fs::read_to_string(&path)
.unwrap()
.contains("# unrelated creator")
);
}
#[test]
fn paths_and_channel_addresses_are_strict() {
let home = Some(OsString::from("/home/test"));