feat: edit overlapping channel tag memberships
This commit is contained in:
+202
-42
@@ -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"));
|
||||
|
||||
Reference in New Issue
Block a user