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 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
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user