feat: browse categories on demand and pin favorite tabs
This commit is contained in:
@@ -13,6 +13,7 @@ use toml_edit::{Array, DocumentMut, Item, Table, value};
|
||||
pub struct Config {
|
||||
pub channels: BTreeMap<String, String>,
|
||||
pub categories: BTreeMap<String, Vec<String>>,
|
||||
pub pinned_categories: Vec<String>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -51,9 +52,11 @@ pub fn load(path: &Path) -> io::Result<Config> {
|
||||
let (_, document) = read_document(path)?;
|
||||
let channels = parse_channels(&document)?;
|
||||
let categories = parse_categories(&document, &channels)?;
|
||||
let pinned_categories = parse_pins(&document)?;
|
||||
Ok(Config {
|
||||
channels,
|
||||
categories,
|
||||
pinned_categories,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -83,6 +86,7 @@ pub fn set_channel_tags(
|
||||
let mut mappings = parse_channels(&document)?;
|
||||
// Validate the whole file before changing it or creating directories.
|
||||
parse_categories(&document, &mappings)?;
|
||||
let pinned_categories = parse_pins(&document)?;
|
||||
match mappings.get(&handle) {
|
||||
Some(existing) if existing != &channel => {
|
||||
return Err(invalid(format!(
|
||||
@@ -187,9 +191,62 @@ pub fn set_channel_tags(
|
||||
Ok(Config {
|
||||
channels: mappings,
|
||||
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 {
|
||||
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]
|
||||
fn edits_preserve_manual_config_and_refuse_invalid_or_changed_files() {
|
||||
let directory = TestDirectory::new();
|
||||
|
||||
+331
-18
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user