feat: group global configuration under settings
This commit is contained in:
@@ -129,7 +129,7 @@ $XDG_CONFIG_HOME/tubular/config.toml
|
|||||||
If `XDG_CONFIG_HOME` is unset, empty, or relative, the fallback is `~/.config/tubular/config.toml`. Edit the file directly and press `r` to reload, or save memberships from the tag dialog.
|
If `XDG_CONFIG_HOME` is unset, empty, or relative, the fallback is `~/.config/tubular/config.toml`. Edit the file directly and press `r` to reload, or save memberships from the tag dialog.
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
# Top-level setting: place this before the table headers.
|
[settings]
|
||||||
pinned_categories = ["Favorites"]
|
pinned_categories = ["Favorites"]
|
||||||
|
|
||||||
[categories]
|
[categories]
|
||||||
@@ -142,6 +142,8 @@ Favorites = ["@jawed"]
|
|||||||
|
|
||||||
`[categories]` contains **flat, overlapping tags**. A channel can belong to several categories; there is no hierarchy or inheritance. `[channels]` maps readable handles to stable channel URLs or IDs. Saving a channel through the UI records its handle and mapping when metadata is available.
|
`[categories]` contains **flat, overlapping tags**. A channel can belong to several categories; there is no hierarchy or inheritance. `[channels]` maps readable handles to stable channel URLs or IDs. Saving a channel through the UI records its handle and mapping when metadata is available.
|
||||||
|
|
||||||
|
`[settings]` holds global options such as `pinned_categories`. The `[settings]`, `[channels]`, and `[categories]` sections can appear in any order; pin/unpin preserves the existing section order and other preferences. For older configs, add `[settings]` immediately above `pinned_categories`. A top-level declaration is no longer accepted.
|
||||||
|
|
||||||
Category entries accept `@handles`, HTTPS YouTube channel URLs, or canonical `UC…` channel IDs. Mapped handles use their stable identity; unmapped handles work directly.
|
Category entries accept `@handles`, HTTPS YouTube channel URLs, or canonical `UC…` channel IDs. Mapped handles use their stable identity; unmapped handles work directly.
|
||||||
|
|
||||||
- **Search** and **Feed** are permanent tabs. Feed combines all `[channels]` registrations, including untagged channels, plus channels entered directly in categories, without duplicates.
|
- **Search** and **Feed** are permanent tabs. Feed combines all `[channels]` registrations, including untagged channels, plus channels entered directly in categories, without duplicates.
|
||||||
|
|||||||
+104
-15
@@ -344,11 +344,18 @@ pub fn set_category_pinned(path: &Path, name: &str, pinned: bool) -> io::Result<
|
|||||||
if !categories.contains_key(name) {
|
if !categories.contains_key(name) {
|
||||||
return Err(invalid(format!("Unknown category: {name:?}")));
|
return Err(invalid(format!("Unknown category: {name:?}")));
|
||||||
}
|
}
|
||||||
if !document.contains_key("pinned_categories") {
|
if !document.contains_key("settings") {
|
||||||
document.insert("pinned_categories", value(Array::new()));
|
document.insert("settings", Item::Table(Table::new()));
|
||||||
}
|
}
|
||||||
let array = document["pinned_categories"]
|
let settings = document["settings"]
|
||||||
.as_array_mut()
|
.as_table_like_mut()
|
||||||
|
.expect("settings were validated");
|
||||||
|
if !settings.contains_key("pinned_categories") {
|
||||||
|
settings.insert("pinned_categories", value(Array::new()));
|
||||||
|
}
|
||||||
|
let array = settings
|
||||||
|
.get_mut("pinned_categories")
|
||||||
|
.and_then(Item::as_array_mut)
|
||||||
.expect("pins were validated");
|
.expect("pins were validated");
|
||||||
if let Some(index) = pins.iter().position(|pin| pin == name) {
|
if let Some(index) = pins.iter().position(|pin| pin == name) {
|
||||||
if !pinned {
|
if !pinned {
|
||||||
@@ -367,7 +374,16 @@ pub fn set_category_pinned(path: &Path, name: &str, pinned: bool) -> io::Result<
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn parse_pins(document: &DocumentMut) -> io::Result<Vec<String>> {
|
fn parse_pins(document: &DocumentMut) -> io::Result<Vec<String>> {
|
||||||
let Some(item) = document.get("pinned_categories") else {
|
if document.contains_key("pinned_categories") {
|
||||||
|
return Err(invalid("Move top-level pinned_categories into [settings]"));
|
||||||
|
}
|
||||||
|
let Some(settings) = document.get("settings") else {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
};
|
||||||
|
let settings = settings
|
||||||
|
.as_table_like()
|
||||||
|
.ok_or_else(|| invalid("settings must be a TOML table"))?;
|
||||||
|
let Some(item) = settings.get("pinned_categories") else {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
};
|
};
|
||||||
let array = item
|
let array = item
|
||||||
@@ -636,11 +652,82 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_can_move_between_sections_and_pin_edits_preserve_their_order() {
|
||||||
|
let directory = TestDirectory::new();
|
||||||
|
let path = directory.0.join("config.toml");
|
||||||
|
let sections = [
|
||||||
|
(
|
||||||
|
"settings",
|
||||||
|
"[settings]\npinned_categories = ['A']\ncustom = 42\n",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"channels",
|
||||||
|
"[channels]\n'@example' = 'UC4QobU6STFB0P71PMvOGN5A'\n",
|
||||||
|
),
|
||||||
|
("categories", "[categories]\nA = []\nB = ['@example']\n"),
|
||||||
|
];
|
||||||
|
for order in [
|
||||||
|
[0, 1, 2],
|
||||||
|
[0, 2, 1],
|
||||||
|
[1, 0, 2],
|
||||||
|
[1, 2, 0],
|
||||||
|
[2, 0, 1],
|
||||||
|
[2, 1, 0],
|
||||||
|
] {
|
||||||
|
let original = order
|
||||||
|
.iter()
|
||||||
|
.map(|index| sections[*index].1)
|
||||||
|
.collect::<String>();
|
||||||
|
fs::write(&path, original).unwrap();
|
||||||
|
let config = load(&path).unwrap();
|
||||||
|
assert_eq!(config.pinned_categories, ["A"]);
|
||||||
|
assert_eq!(
|
||||||
|
config.categories["B"],
|
||||||
|
["https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
set_category_pinned(&path, "B", true)
|
||||||
|
.unwrap()
|
||||||
|
.pinned_categories,
|
||||||
|
["A", "B"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
set_category_pinned(&path, "A", false)
|
||||||
|
.unwrap()
|
||||||
|
.pinned_categories,
|
||||||
|
["B"]
|
||||||
|
);
|
||||||
|
let saved = load(&path).unwrap();
|
||||||
|
assert_eq!(saved.pinned_categories, ["B"]);
|
||||||
|
assert_eq!(saved.categories, config.categories);
|
||||||
|
assert_eq!(saved.channels, config.channels);
|
||||||
|
let contents = fs::read_to_string(&path).unwrap();
|
||||||
|
let document = contents.parse::<DocumentMut>().unwrap();
|
||||||
|
assert_eq!(document["settings"]["custom"].as_integer(), Some(42));
|
||||||
|
assert_eq!(
|
||||||
|
document.iter().map(|(name, _)| name).collect::<Vec<_>>(),
|
||||||
|
order
|
||||||
|
.iter()
|
||||||
|
.map(|index| sections[*index].0)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for original in [
|
||||||
|
"[categories]\nA = []\n",
|
||||||
|
"[settings]\ncustom = 42\n[categories]\nA = []\n",
|
||||||
|
] {
|
||||||
|
fs::write(&path, original).unwrap();
|
||||||
|
set_category_pinned(&path, "A", true).unwrap();
|
||||||
|
assert_eq!(load(&path).unwrap().pinned_categories, ["A"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn category_pins_persist_without_changing_memberships_or_manual_preferences() {
|
fn category_pins_persist_without_changing_memberships_or_manual_preferences() {
|
||||||
let directory = TestDirectory::new();
|
let directory = TestDirectory::new();
|
||||||
let path = directory.0.join("config.toml");
|
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();
|
fs::write(&path, "# favorites\n[settings]\npinned_categories = ['Japanese'] # pin note\n[other]\nvalue = 42\n[categories]\nJapanese = []\nChinese = []\n").unwrap();
|
||||||
let pinned = set_category_pinned(&path, "Chinese", true).unwrap();
|
let pinned = set_category_pinned(&path, "Chinese", true).unwrap();
|
||||||
assert_eq!(pinned.pinned_categories, ["Japanese", "Chinese"]);
|
assert_eq!(pinned.pinned_categories, ["Japanese", "Chinese"]);
|
||||||
let tags = BTreeMap::from([("Japanese".into(), true)]);
|
let tags = BTreeMap::from([("Japanese".into(), true)]);
|
||||||
@@ -662,9 +749,11 @@ mod tests {
|
|||||||
assert!(set_category_pinned(&path, "Missing", true).is_err());
|
assert!(set_category_pinned(&path, "Missing", true).is_err());
|
||||||
assert_eq!(fs::read_to_string(&path).unwrap(), content);
|
assert_eq!(fs::read_to_string(&path).unwrap(), content);
|
||||||
for invalid in [
|
for invalid in [
|
||||||
"pinned_categories = true",
|
"[settings]\npinned_categories = true",
|
||||||
"pinned_categories = [4]",
|
"[settings]\npinned_categories = [4]",
|
||||||
"pinned_categories = ['A', ' A ']",
|
"[settings]\npinned_categories = ['A', ' A ']",
|
||||||
|
"settings = []",
|
||||||
|
"pinned_categories = ['A']",
|
||||||
] {
|
] {
|
||||||
fs::write(&path, format!("{invalid}\n[categories]\nA = []\n")).unwrap();
|
fs::write(&path, format!("{invalid}\n[categories]\nA = []\n")).unwrap();
|
||||||
let before = fs::read(&path).unwrap();
|
let before = fs::read(&path).unwrap();
|
||||||
@@ -688,7 +777,7 @@ mod tests {
|
|||||||
let direct_url = format!("https://www.youtube.com/channel/{direct}");
|
let direct_url = format!("https://www.youtube.com/channel/{direct}");
|
||||||
let manual_url = "https://www.youtube.com/@manual".to_owned();
|
let manual_url = "https://www.youtube.com/@manual".to_owned();
|
||||||
let original = format!(
|
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"
|
"# manual preferences\n[settings]\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();
|
fs::write(&path, &original).unwrap();
|
||||||
let opened = load(&path).unwrap();
|
let opened = load(&path).unwrap();
|
||||||
@@ -737,11 +826,11 @@ mod tests {
|
|||||||
before["channels"].to_string()
|
before["channels"].to_string()
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
document["pinned_categories"].to_string(),
|
document["settings"]["pinned_categories"].to_string(),
|
||||||
before["pinned_categories"].to_string()
|
before["settings"]["pinned_categories"].to_string()
|
||||||
);
|
);
|
||||||
assert_eq!(document["other"]["value"].as_integer(), Some(42));
|
assert_eq!(document["other"]["value"].as_integer(), Some(42));
|
||||||
assert_eq!(document["player"].as_str(), Some("mpv"));
|
assert_eq!(document["settings"]["player"].as_str(), Some("mpv"));
|
||||||
let new = document["categories"]["New"].as_array().unwrap();
|
let new = document["categories"]["New"].as_array().unwrap();
|
||||||
let raw: Vec<_> = new.iter().map(|entry| entry.as_str().unwrap()).collect();
|
let raw: Vec<_> = new.iter().map(|entry| entry.as_str().unwrap()).collect();
|
||||||
assert_eq!(raw, ["@manual", direct, "@alias", "@second"]);
|
assert_eq!(raw, ["@manual", direct, "@alias", "@second"]);
|
||||||
@@ -828,7 +917,7 @@ mod tests {
|
|||||||
"[categories]\nSource = ['@manual']\n' Source ' = []\n",
|
"[categories]\nSource = ['@manual']\n' Source ' = []\n",
|
||||||
"channels = true\n[categories]\nSource = ['@manual']\n",
|
"channels = true\n[categories]\nSource = ['@manual']\n",
|
||||||
"[channels]\n'@manual' = '@different'\n[categories]\nSource = ['@manual']\n",
|
"[channels]\n'@manual' = '@different'\n[categories]\nSource = ['@manual']\n",
|
||||||
"pinned_categories = [7]\n[categories]\nSource = ['@manual']\n",
|
"[settings]\npinned_categories = [7]\n[categories]\nSource = ['@manual']\n",
|
||||||
] {
|
] {
|
||||||
fs::write(&path, contents).unwrap();
|
fs::write(&path, contents).unwrap();
|
||||||
assert!(set_category_tags(&path, "Source", &expected, &tags).is_err());
|
assert!(set_category_tags(&path, "Source", &expected, &tags).is_err());
|
||||||
@@ -861,7 +950,7 @@ mod tests {
|
|||||||
let path = directory.0.join("config.toml");
|
let path = directory.0.join("config.toml");
|
||||||
let id = "UC4QobU6STFB0P71PMvOGN5A";
|
let id = "UC4QobU6STFB0P71PMvOGN5A";
|
||||||
let registry = format!(
|
let registry = format!(
|
||||||
"pinned_categories = ['Source']\n[channels]\n'@mapped' = '{id}'\n[categories]\n"
|
"[settings]\npinned_categories = ['Source']\n[channels]\n'@mapped' = '{id}'\n[categories]\n"
|
||||||
);
|
);
|
||||||
for source in ["'@mapped', '@manual'", "'UCabcdefghijklmnopqrstuv'"] {
|
for source in ["'@mapped', '@manual'", "'UCabcdefghijklmnopqrstuv'"] {
|
||||||
let original = format!("{registry}Source = [{source}]\n");
|
let original = format!("{registry}Source = [{source}]\n");
|
||||||
|
|||||||
+4
-3
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user