feat: edit category memberships for all channels in a category

This commit is contained in:
2026-10-11 14:14:57 +02:00
parent 16c57ea101
commit e2fffa3afa
2 changed files with 571 additions and 72 deletions
+376 -23
View File
@@ -1,5 +1,5 @@
use std::{
collections::BTreeMap,
collections::{BTreeMap, BTreeSet},
ffi::OsString,
fs::{self, OpenOptions},
io::{self, Write},
@@ -66,15 +66,7 @@ pub fn set_channel_tags(
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 normalized_tags = normalize_tags(tags)?;
let handle = normalize_handle(handle)?;
let channel = normalize_channel(channel)?;
if !channel.starts_with("https://www.youtube.com/channel/") {
@@ -144,16 +136,155 @@ pub fn set_channel_tags(
channels.push(handle.as_str());
}
} else {
remove_category_channels(channels, &mappings, |identity| identity == channel)?;
}
}
let categories = parse_categories(&document, &mappings)?;
save(path, original.as_deref(), document.to_string().as_bytes())?;
Ok(Config {
channels: mappings,
categories,
pinned_categories,
})
}
pub fn set_category_tags(
path: &Path,
source: &str,
expected_channels: &[String],
tags: &BTreeMap<String, bool>,
) -> io::Result<Config> {
let source = category_name(source)?;
let tags = normalize_tags(tags)?;
let (original, mut document) = read_document(path)?;
let mappings = parse_channels(&document)?;
let parsed_categories = parse_categories(&document, &mappings)?;
let pinned_categories = parse_pins(&document)?;
let targets = parsed_categories
.get(source)
.ok_or_else(|| invalid(format!("Unknown source category: {source:?}")))?;
if targets.is_empty() {
return Err(invalid("Cannot edit tags for an empty source category"));
}
let expected: BTreeSet<_> = expected_channels.iter().map(String::as_str).collect();
if targets.len() != expected.len()
|| targets
.iter()
.any(|channel| !expected.contains(channel.as_str()))
{
return Err(invalid(
"Source category changed since opening the editor; please reopen it",
));
}
if !tags.values().any(|checked| *checked)
&& targets
.iter()
.any(|channel| !mappings.values().any(|mapped| mapped == channel))
{
return Err(invalid(
"Cannot clear every category: a source channel is not registered in [channels]",
));
}
let categories = document["categories"]
.as_table_like_mut()
.expect("source category was validated");
let source_entries = categories
.iter()
.find(|(name, _)| name.trim() == source)
.and_then(|(_, item)| item.as_array())
.expect("source category was validated");
let mut additions = BTreeMap::new();
for entry in source_entries.iter() {
let raw = entry.as_str().expect("channel was validated").trim();
let identity = resolve_channel(raw, &mappings)?;
let readable = raw
.strip_prefix("https://www.youtube.com/")
.or_else(|| raw.strip_prefix("https://youtube.com/"))
.filter(|address| address.starts_with('@'))
.unwrap_or(raw);
let addition = additions
.entry(identity)
.or_insert_with(|| readable.to_owned());
if readable.starts_with('@') && !addition.starts_with('@') {
addition.clear();
addition.push_str(readable);
}
}
for (handle, identity) in &mappings {
if let Some(addition) = additions.get_mut(identity)
&& !addition.starts_with('@')
{
addition.clear();
addition.push_str(handle);
}
}
// The checked set is exact, including categories added by another editor.
for (name, item) in categories.iter_mut() {
let entries = item.as_array_mut().expect("category was validated");
if tags.get(name.trim()).copied().unwrap_or(false) {
let present: BTreeSet<_> = entries
.iter()
.map(|entry| {
resolve_channel(entry.as_str().expect("channel was validated"), &mappings)
})
.collect::<io::Result<_>>()?;
for (identity, readable) in &additions {
if !present.contains(identity) {
entries.push(readable.as_str());
}
}
} else {
remove_category_channels(entries, &mappings, |identity| {
additions.contains_key(identity)
})?;
}
}
for (name, checked) in tags {
if checked && !parsed_categories.contains_key(name) {
let mut entries = Array::new();
for readable in additions.values() {
entries.push(readable.as_str());
}
categories.insert(name, value(entries));
}
}
let categories = parse_categories(&document, &mappings)?;
save(path, original.as_deref(), document.to_string().as_bytes())?;
Ok(Config {
channels: mappings,
categories,
pinned_categories,
})
}
fn normalize_tags(tags: &BTreeMap<String, bool>) -> io::Result<BTreeMap<&str, bool>> {
let mut normalized = BTreeMap::new();
for (name, checked) in tags {
let name = category_name(name)?;
if normalized.insert(name, *checked).is_some() {
return Err(invalid(format!(
"Duplicate category name after trimming: {name:?}"
)));
}
}
Ok(normalized)
}
fn remove_category_channels(
channels: &mut Array,
mappings: &BTreeMap<String, String>,
remove: impl Fn(&str) -> bool,
) -> io::Result<()> {
for index in (0..channels.len()).rev() {
if resolve_channel(
if !remove(&resolve_channel(
channels
.get(index)
.unwrap()
.as_str()
.expect("channel was validated"),
&mappings,
)? != channel
{
mappings,
)?) {
continue;
}
let removed = channels.remove(index);
@@ -184,15 +315,7 @@ pub fn set_channel_tags(
}
}
}
}
}
let categories = parse_categories(&document, &mappings)?;
save(path, original.as_deref(), document.to_string().as_bytes())?;
Ok(Config {
channels: mappings,
categories,
pinned_categories,
})
Ok(())
}
pub fn set_category_pinned(path: &Path, name: &str, pinned: bool) -> io::Result<Config> {
@@ -536,6 +659,236 @@ mod tests {
}
}
#[test]
fn category_tags_apply_exact_set_and_preserve_unrelated_entries_and_preferences() {
let directory = TestDirectory::new();
let path = directory.0.join("config.toml");
let first = "UC4QobU6STFB0P71PMvOGN5A";
let second = "UCabcdefghijklmnopqrstuv";
let direct = "UC0123456789012345678901";
let first_url = format!("https://www.youtube.com/channel/{first}");
let second_url = format!("https://www.youtube.com/channel/{second}");
let direct_url = format!("https://www.youtube.com/channel/{direct}");
let manual_url = "https://www.youtube.com/@manual".to_owned();
let original = format!(
"# manual preferences\npinned_categories = ['Source'] # pin note\nplayer = 'mpv'\n[channels]\n'@alias' = '{first}' # alias mapping\n'@second' = '{second_url}'\n[other]\nvalue = 42\n[categories]\n' Source ' = [\n '@alias', # source channel\n '{first_url}',\n '{second}',\n 'https://youtube.com/@manual',\n '@manual',\n '{direct}', # direct channel\n] # source note\nKeep = ['@alias', '@unrelated'] # keep note\nDrop = [\n '@manual', # removed channel note\n 'https://youtube.com/@alias',\n '@unrelated', # unrelated note\n]\nUntouched = ['@unrelated']\n"
);
fs::write(&path, &original).unwrap();
let opened = load(&path).unwrap();
let before = original.parse::<DocumentMut>().unwrap();
// This category was not present in the dialog's tags.
fs::write(
&path,
format!("{original}Late = ['@alias', '@unrelated'] # late note\n"),
)
.unwrap();
let tags = BTreeMap::from([
("Source".to_owned(), false),
("Keep".to_owned(), true),
("Drop".to_owned(), false),
(" New ".to_owned(), true),
]);
let saved =
set_category_tags(&path, " Source ", &opened.categories["Source"], &tags).unwrap();
assert!(saved.categories["Source"].is_empty());
for name in ["Drop", "Late", "Untouched"] {
assert_eq!(
saved.categories[name],
["https://www.youtube.com/@unrelated"]
);
}
assert_eq!(
saved.categories["Keep"],
[
first_url.clone(),
"https://www.youtube.com/@unrelated".to_owned(),
manual_url.clone(),
direct_url.clone(),
second_url.clone(),
]
);
assert_eq!(
saved.categories["New"],
[manual_url, direct_url, first_url, second_url]
);
assert_eq!(saved.channels, opened.channels);
assert_eq!(saved.pinned_categories, ["Source"]);
let content = fs::read_to_string(&path).unwrap();
let document = content.parse::<DocumentMut>().unwrap();
assert_eq!(
document["channels"].to_string(),
before["channels"].to_string()
);
assert_eq!(
document["pinned_categories"].to_string(),
before["pinned_categories"].to_string()
);
assert_eq!(document["other"]["value"].as_integer(), Some(42));
assert_eq!(document["player"].as_str(), Some("mpv"));
let new = document["categories"]["New"].as_array().unwrap();
let raw: Vec<_> = new.iter().map(|entry| entry.as_str().unwrap()).collect();
assert_eq!(raw, ["@manual", direct, "@alias", "@second"]);
let keep = document["categories"]["Keep"].as_array().unwrap();
assert_eq!(keep.get(0).unwrap().as_str(), Some("@alias"));
assert_eq!(keep.get(1).unwrap().as_str(), Some("@unrelated"));
for comment in [
"# manual preferences",
"# pin note",
"# alias mapping",
"# source channel",
"# direct channel",
"# source note",
"# keep note",
"# removed channel note",
"# unrelated note",
"# late note",
] {
assert!(content.contains(comment), "lost {comment}");
}
let reloaded = load(&path).unwrap();
assert_eq!(reloaded.categories, saved.categories);
assert_eq!(reloaded.subscriptions(), saved.subscriptions());
assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 1);
}
#[test]
fn category_tags_compare_identity_sets_not_source_order_or_alias_spelling() {
let directory = TestDirectory::new();
let path = directory.0.join("config.toml");
let id = "UC4QobU6STFB0P71PMvOGN5A";
let url = format!("https://www.youtube.com/channel/{id}");
let registry = format!("[channels]\n'@old' = '{id}'\n'@alias' = '{url}'\n");
fs::write(
&path,
format!("{registry}[categories]\nSource = ['@old', '@manual']\n"),
)
.unwrap();
let expected = load(&path).unwrap().categories.remove("Source").unwrap();
fs::write(
&path,
format!(
"{registry}[categories]\nSource = ['https://youtube.com/@manual', '{url}', '@alias'] # alias note\n"
),
)
.unwrap();
let tags = BTreeMap::from([("Source".to_owned(), true), ("New".to_owned(), true)]);
let saved = set_category_tags(&path, "Source", &expected, &tags).unwrap();
assert_eq!(
saved.categories["Source"],
["https://www.youtube.com/@manual".to_owned(), url.clone()]
);
assert_eq!(
saved.categories["New"],
["https://www.youtube.com/@manual".to_owned(), url]
);
let content = fs::read_to_string(&path).unwrap();
let document = content.parse::<DocumentMut>().unwrap();
let source = document["categories"]["Source"].as_array().unwrap();
assert_eq!(source.len(), 3);
assert_eq!(source.get(2).unwrap().as_str(), Some("@alias"));
let new = document["categories"]["New"].as_array().unwrap();
assert_eq!(new.get(1).unwrap().as_str(), Some("@alias"));
assert!(content.contains("# alias note"));
set_category_tags(&path, "Source", &expected, &tags).unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), content);
}
#[test]
fn category_tags_reject_stale_sources_invalid_files_and_tags_without_writing() {
let directory = TestDirectory::new();
let path = directory.0.join("config.toml");
let tags = BTreeMap::from([("New".to_owned(), true)]);
let expected = vec!["https://www.youtube.com/@manual".to_owned()];
for contents in [
"[categories]\nOther = ['@manual']\n",
"[categories]\nSource = []\n",
"[categories]\nSource = ['@different']\n",
"[categories]\nSource = ['@manual', '@different']\n",
"[channels]\n'@manual' = 'UC4QobU6STFB0P71PMvOGN5A'\n[categories]\nSource = ['@manual']\n",
"[categories\n",
"categories = true\n",
"[categories]\nSource = ['@manual']\nInvalid = [7]\n",
"[categories]\nSource = ['@manual']\n' Source ' = []\n",
"channels = true\n[categories]\nSource = ['@manual']\n",
"[channels]\n'@manual' = '@different'\n[categories]\nSource = ['@manual']\n",
"pinned_categories = [7]\n[categories]\nSource = ['@manual']\n",
] {
fs::write(&path, contents).unwrap();
assert!(set_category_tags(&path, "Source", &expected, &tags).is_err());
assert_eq!(fs::read_to_string(&path).unwrap(), contents);
assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 1);
}
fs::write(&path, [0xff]).unwrap();
assert!(set_category_tags(&path, "Source", &expected, &tags).is_err());
assert_eq!(fs::read(&path).unwrap(), [0xff]);
let valid = "[categories]\nSource = ['@manual']\n";
fs::write(&path, valid).unwrap();
for invalid_tags in [
BTreeMap::from([(" ".to_owned(), true)]),
BTreeMap::from([("bad\nname".to_owned(), true)]),
BTreeMap::from([("New".to_owned(), true), (" New ".to_owned(), false)]),
] {
assert!(set_category_tags(&path, "Source", &expected, &invalid_tags).is_err());
assert_eq!(fs::read_to_string(&path).unwrap(), valid);
}
assert!(set_category_tags(&path, "bad\nname", &expected, &tags).is_err());
assert_eq!(fs::read_to_string(&path).unwrap(), valid);
let missing = directory.0.join("missing/config.toml");
assert!(set_category_tags(&missing, "Source", &expected, &tags).is_err());
assert!(!missing.parent().unwrap().exists());
}
#[test]
fn category_tags_only_clear_all_tags_when_every_target_remains_registered() {
let directory = TestDirectory::new();
let path = directory.0.join("config.toml");
let id = "UC4QobU6STFB0P71PMvOGN5A";
let registry = format!(
"pinned_categories = ['Source']\n[channels]\n'@mapped' = '{id}'\n[categories]\n"
);
for source in ["'@mapped', '@manual'", "'UCabcdefghijklmnopqrstuv'"] {
let original = format!("{registry}Source = [{source}]\n");
fs::write(&path, &original).unwrap();
let opened = load(&path).unwrap();
for tags in [
BTreeMap::new(),
BTreeMap::from([("Source".to_owned(), false)]),
] {
let error = set_category_tags(&path, "Source", &opened.categories["Source"], &tags)
.unwrap_err();
assert!(error.to_string().contains("not registered in [channels]"));
assert_eq!(fs::read_to_string(&path).unwrap(), original);
}
}
let original = format!(
"{registry}Source = ['@mapped', '{id}'] # source note\nOther = ['https://youtube.com/@mapped', '@unrelated']\n"
);
fs::write(&path, &original).unwrap();
let opened = load(&path).unwrap();
let saved = set_category_tags(
&path,
"Source",
&opened.categories["Source"],
&BTreeMap::new(),
)
.unwrap();
assert!(saved.categories["Source"].is_empty());
assert_eq!(
saved.categories["Other"],
["https://www.youtube.com/@unrelated"]
);
assert_eq!(saved.channels, opened.channels);
assert_eq!(saved.pinned_categories, ["Source"]);
assert_eq!(
saved.subscriptions(),
[
"https://www.youtube.com/@unrelated",
"https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A",
]
);
assert!(fs::read_to_string(&path).unwrap().contains("# source note"));
}
#[test]
fn edits_preserve_manual_config_and_refuse_invalid_or_changed_files() {
let directory = TestDirectory::new();
+161 -15
View File
File diff suppressed because one or more lines are too long