feat: browse categories on demand and pin favorite tabs

This commit is contained in:
2026-10-11 12:23:17 +02:00
parent e5e8799f4a
commit 4be14583b4
2 changed files with 428 additions and 18 deletions
+97
View File
@@ -13,6 +13,7 @@ use toml_edit::{Array, DocumentMut, Item, Table, value};
pub struct Config { pub struct Config {
pub channels: BTreeMap<String, String>, pub channels: BTreeMap<String, String>,
pub categories: BTreeMap<String, Vec<String>>, pub categories: BTreeMap<String, Vec<String>>,
pub pinned_categories: Vec<String>,
} }
impl Config { impl Config {
@@ -51,9 +52,11 @@ pub fn load(path: &Path) -> io::Result<Config> {
let (_, document) = read_document(path)?; let (_, document) = read_document(path)?;
let channels = parse_channels(&document)?; let channels = parse_channels(&document)?;
let categories = parse_categories(&document, &channels)?; let categories = parse_categories(&document, &channels)?;
let pinned_categories = parse_pins(&document)?;
Ok(Config { Ok(Config {
channels, channels,
categories, categories,
pinned_categories,
}) })
} }
@@ -83,6 +86,7 @@ pub fn set_channel_tags(
let mut mappings = parse_channels(&document)?; let mut mappings = parse_channels(&document)?;
// Validate the whole file before changing it or creating directories. // Validate the whole file before changing it or creating directories.
parse_categories(&document, &mappings)?; parse_categories(&document, &mappings)?;
let pinned_categories = parse_pins(&document)?;
match mappings.get(&handle) { match mappings.get(&handle) {
Some(existing) if existing != &channel => { Some(existing) if existing != &channel => {
return Err(invalid(format!( return Err(invalid(format!(
@@ -187,9 +191,62 @@ pub fn set_channel_tags(
Ok(Config { Ok(Config {
channels: mappings, channels: mappings,
categories, categories,
pinned_categories,
}) })
} }
pub fn set_category_pinned(path: &Path, name: &str, pinned: bool) -> io::Result<Config> {
let name = category_name(name)?;
let (original, mut document) = read_document(path)?;
let channels = parse_channels(&document)?;
let categories = parse_categories(&document, &channels)?;
let pins = parse_pins(&document)?;
if !categories.contains_key(name) {
return Err(invalid(format!("Unknown category: {name:?}")));
}
if !document.contains_key("pinned_categories") {
document.insert("pinned_categories", value(Array::new()));
}
let array = document["pinned_categories"]
.as_array_mut()
.expect("pins were validated");
if let Some(index) = pins.iter().position(|pin| pin == name) {
if !pinned {
array.remove(index);
}
} else if pinned {
array.push(name);
}
let pinned_categories = parse_pins(&document)?;
save(path, original.as_deref(), document.to_string().as_bytes())?;
Ok(Config {
channels,
categories,
pinned_categories,
})
}
fn parse_pins(document: &DocumentMut) -> io::Result<Vec<String>> {
let Some(item) = document.get("pinned_categories") else {
return Ok(Vec::new());
};
let array = item
.as_array()
.ok_or_else(|| invalid("pinned_categories must be an array"))?;
let mut pins = Vec::with_capacity(array.len());
for item in array.iter() {
let name = category_name(
item.as_str()
.ok_or_else(|| invalid("Pinned categories must be strings"))?,
)?;
if pins.iter().any(|pin| pin == name) {
return Err(invalid(format!("Duplicate pinned category: {name:?}")));
}
pins.push(name.to_owned());
}
Ok(pins)
}
fn invalid(message: impl Into<String>) -> io::Error { fn invalid(message: impl Into<String>) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, message.into()) io::Error::new(io::ErrorKind::InvalidData, message.into())
} }
@@ -439,6 +496,46 @@ mod tests {
} }
} }
#[test]
fn category_pins_persist_without_changing_memberships_or_manual_preferences() {
let directory = TestDirectory::new();
let path = directory.0.join("config.toml");
fs::write(&path, "# favorites\npinned_categories = ['Japanese'] # pin note\n[other]\nvalue = 42\n[categories]\nJapanese = []\nChinese = []\n").unwrap();
let pinned = set_category_pinned(&path, "Chinese", true).unwrap();
assert_eq!(pinned.pinned_categories, ["Japanese", "Chinese"]);
let tags = BTreeMap::from([("Japanese".into(), true)]);
let saved = set_channel_tags(&path, &tags, "@example", "UC4QobU6STFB0P71PMvOGN5A").unwrap();
assert_eq!(saved.pinned_categories, ["Japanese", "Chinese"]);
assert_eq!(
saved.categories["Japanese"],
["https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A"]
);
let unpinned = set_category_pinned(&path, "Japanese", false).unwrap();
assert_eq!(unpinned.pinned_categories, ["Chinese"]);
assert_eq!(load(&path).unwrap().pinned_categories, ["Chinese"]);
let content = fs::read_to_string(&path).unwrap();
assert!(content.contains("# favorites") && content.contains("# pin note"));
assert_eq!(
content.parse::<DocumentMut>().unwrap()["other"]["value"].as_integer(),
Some(42)
);
assert!(set_category_pinned(&path, "Missing", true).is_err());
assert_eq!(fs::read_to_string(&path).unwrap(), content);
for invalid in [
"pinned_categories = true",
"pinned_categories = [4]",
"pinned_categories = ['A', ' A ']",
] {
fs::write(&path, format!("{invalid}\n[categories]\nA = []\n")).unwrap();
let before = fs::read(&path).unwrap();
assert!(set_category_pinned(&path, "A", true).is_err());
assert!(
set_channel_tags(&path, &tags, "@example", "UC4QobU6STFB0P71PMvOGN5A").is_err()
);
assert_eq!(fs::read(&path).unwrap(), before);
}
}
#[test] #[test]
fn edits_preserve_manual_config_and_refuse_invalid_or_changed_files() { fn edits_preserve_manual_config_and_refuse_invalid_or_changed_files() {
let directory = TestDirectory::new(); let directory = TestDirectory::new();
+331 -18
View File
File diff suppressed because one or more lines are too long