feat: edit overlapping channel tag memberships
This commit is contained in:
+182
-22
@@ -38,8 +38,21 @@ pub fn load(path: &Path) -> io::Result<Config> {
|
|||||||
parse_categories(&document, &parse_channels(&document)?)
|
parse_categories(&document, &parse_channels(&document)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn add_channel(path: &Path, category: &str, handle: &str, channel: &str) -> io::Result<Config> {
|
pub fn set_channel_tags(
|
||||||
let category = category_name(category)?;
|
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 handle = normalize_handle(handle)?;
|
||||||
let channel = normalize_channel(channel)?;
|
let channel = normalize_channel(channel)?;
|
||||||
if !channel.starts_with("https://www.youtube.com/channel/") {
|
if !channel.starts_with("https://www.youtube.com/channel/") {
|
||||||
@@ -75,12 +88,13 @@ pub fn add_channel(path: &Path, category: &str, handle: &str, channel: &str) ->
|
|||||||
let categories = document["categories"]
|
let categories = document["categories"]
|
||||||
.as_table_like_mut()
|
.as_table_like_mut()
|
||||||
.expect("categories were validated");
|
.expect("categories were validated");
|
||||||
// Keep the manually written key and its formatting if it has surrounding spaces.
|
for (name, checked) in normalized_tags {
|
||||||
|
// Keep manually written keys and their formatting, including surrounding spaces.
|
||||||
let key = categories
|
let key = categories
|
||||||
.iter()
|
.iter()
|
||||||
.find(|(key, _)| key.trim() == category)
|
.find(|(key, _)| key.trim() == name)
|
||||||
.map(|(key, _)| key.to_owned())
|
.map(|(key, _)| key.to_owned())
|
||||||
.unwrap_or_else(|| category.to_owned());
|
.unwrap_or_else(|| name.to_owned());
|
||||||
if !categories.contains_key(&key) {
|
if !categories.contains_key(&key) {
|
||||||
categories.insert(&key, value(Array::new()));
|
categories.insert(&key, value(Array::new()));
|
||||||
}
|
}
|
||||||
@@ -88,10 +102,13 @@ pub fn add_channel(path: &Path, category: &str, handle: &str, channel: &str) ->
|
|||||||
.get_mut(&key)
|
.get_mut(&key)
|
||||||
.and_then(Item::as_array_mut)
|
.and_then(Item::as_array_mut)
|
||||||
.expect("category was validated");
|
.expect("category was validated");
|
||||||
|
if checked {
|
||||||
let mut found = false;
|
let mut found = false;
|
||||||
for entry in channels.iter_mut() {
|
for entry in channels.iter_mut() {
|
||||||
if resolve_channel(entry.as_str().expect("channel was validated"), &mappings)? == channel {
|
if resolve_channel(entry.as_str().expect("channel was validated"), &mappings)?
|
||||||
// Replace old URLs/aliases with the current handle, preserving entry comments.
|
== channel
|
||||||
|
{
|
||||||
|
// Keep manually written duplicates and entry comments.
|
||||||
if entry.as_str() != Some(&handle) {
|
if entry.as_str() != Some(&handle) {
|
||||||
let decor = entry.decor().clone();
|
let decor = entry.decor().clone();
|
||||||
*entry = handle.as_str().into();
|
*entry = handle.as_str().into();
|
||||||
@@ -101,7 +118,50 @@ pub fn add_channel(path: &Path, category: &str, handle: &str, channel: &str) ->
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !found {
|
if !found {
|
||||||
channels.push(handle);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let config = parse_categories(&document, &mappings)?;
|
let config = parse_categories(&document, &mappings)?;
|
||||||
save(path, original.as_deref(), document.to_string().as_bytes())?;
|
save(path, original.as_deref(), document.to_string().as_bytes())?;
|
||||||
@@ -368,7 +428,8 @@ mod tests {
|
|||||||
let id = "UC4QobU6STFB0P71PMvOGN5A";
|
let id = "UC4QobU6STFB0P71PMvOGN5A";
|
||||||
assert!(load(&path).unwrap().categories.is_empty());
|
assert!(load(&path).unwrap().categories.is_empty());
|
||||||
assert!(!path.parent().unwrap().exists());
|
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!(
|
assert_eq!(
|
||||||
config.categories["Music"],
|
config.categories["Music"],
|
||||||
[format!("https://www.youtube.com/channel/{id}")]
|
[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";
|
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();
|
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_eq!(config.categories["Music"].len(), 1);
|
||||||
assert!(config.categories["Empty"].is_empty());
|
assert!(config.categories["Empty"].is_empty());
|
||||||
let id = "UCabcdefghijklmnopqrstuv";
|
let id = "UCabcdefghijklmnopqrstuv";
|
||||||
let config = add_channel(&path, "Music", "@another", id).unwrap();
|
let config = set_channel_tags(&path, &tags, "@another", id).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
config.categories["Music"][1],
|
config.categories["Music"][1],
|
||||||
format!("https://www.youtube.com/channel/{id}")
|
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' = 'https://example.com/channel/UCabcdefghijklmnopqrstuv'\n",
|
||||||
"[channels]\n'@example' = '@another'\n",
|
"[channels]\n'@example' = '@another'\n",
|
||||||
"[channels]\n'not-a-handle' = 'UCabcdefghijklmnopqrstuv'\n",
|
"[channels]\n'not-a-handle' = 'UCabcdefghijklmnopqrstuv'\n",
|
||||||
|
"[channels]\n'@example' = 'UCabcdefghijklmnopqrstuv'\n'https://youtube.com/@example' = 'UCabcdefghijklmnopqrstuv'\n",
|
||||||
] {
|
] {
|
||||||
fs::write(&path, malformed).unwrap();
|
fs::write(&path, malformed).unwrap();
|
||||||
assert!(load(&path).is_err());
|
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);
|
assert_eq!(fs::read_to_string(&path).unwrap(), malformed);
|
||||||
}
|
}
|
||||||
fs::write(&path, manual).unwrap();
|
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!(
|
assert!(
|
||||||
add_channel(
|
set_channel_tags(&path, &tags, "https://www.youtube.com/@example/videos", id).is_err()
|
||||||
&path,
|
|
||||||
"Music",
|
|
||||||
"https://www.youtube.com/@example/videos",
|
|
||||||
id
|
|
||||||
)
|
|
||||||
.is_err()
|
|
||||||
);
|
);
|
||||||
assert_eq!(fs::read_to_string(&path).unwrap(), manual);
|
assert_eq!(fs::read_to_string(&path).unwrap(), manual);
|
||||||
assert!(save(&path, Some(b"previous contents"), b"replacement").is_err());
|
assert!(save(&path, Some(b"previous contents"), b"replacement").is_err());
|
||||||
@@ -448,23 +513,118 @@ mod tests {
|
|||||||
config.categories["Direct"],
|
config.categories["Direct"],
|
||||||
["https://www.youtube.com/@RustVideos"]
|
["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["Japanese"], std::slice::from_ref(&url));
|
||||||
assert_eq!(config.categories["Other"], std::slice::from_ref(&url));
|
assert_eq!(config.categories["Other"], std::slice::from_ref(&url));
|
||||||
let saved = fs::read_to_string(&path).unwrap();
|
let saved = fs::read_to_string(&path).unwrap();
|
||||||
let document = saved.parse::<DocumentMut>().unwrap();
|
let document = saved.parse::<DocumentMut>().unwrap();
|
||||||
let entries = document["categories"]["Japanese"].as_array().unwrap();
|
let entries = document["categories"]["Japanese"].as_array().unwrap();
|
||||||
assert!(entries.iter().all(|entry| entry.as_str() == Some("@jawed")));
|
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"]["@jawed"].as_str(), Some(url.as_str()));
|
||||||
assert_eq!(document["channels"]["@old"].as_str(), Some(url.as_str()));
|
assert_eq!(document["channels"]["@old"].as_str(), Some(url.as_str()));
|
||||||
assert!(saved.contains("# keep this entry comment"));
|
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_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!(fs::read_to_string(&path).unwrap(), saved);
|
||||||
assert_eq!(load(&path).unwrap().categories["Japanese"], [url]);
|
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]
|
#[test]
|
||||||
fn paths_and_channel_addresses_are_strict() {
|
fn paths_and_channel_addresses_are_strict() {
|
||||||
let home = Some(OsString::from("/home/test"));
|
let home = Some(OsString::from("/home/test"));
|
||||||
|
|||||||
+182
-45
@@ -11,14 +11,15 @@
|
|||||||
//! Images use native terminal graphics when supported, otherwise colored half-block characters.
|
//! Images use native terminal graphics when supported, otherwise colored half-block characters.
|
||||||
//! Thumbnails scale up or down proportionally to fill the popup without cropping.
|
//! Thumbnails scale up or down proportionally to fill the popup without cropping.
|
||||||
//! curl fetches JPEGs lazily into memory; decoding and image resizing/encoding run off-thread.
|
//! curl fetches JPEGs lazily into memory; decoding and image resizing/encoding run off-thread.
|
||||||
//! Channel categories live in `$XDG_CONFIG_HOME/tubular/config.toml`, or `~/.config/tubular/config.toml`.
|
//! Channel tags live in `$XDG_CONFIG_HOME/tubular/config.toml`, or `~/.config/tubular/config.toml`.
|
||||||
//! Under `[categories]`, write `Rust = ["@RustVideos"]`; channel URLs and UC IDs also work.
|
//! `[categories]` stores flat, overlapping tags: one channel can appear in several arrays.
|
||||||
//! Saving puts the creator's @handle in the category and its canonical URL in `[channels]`.
|
//! Checked tags save the creator's @handle; `[channels]` retains its canonical URL.
|
||||||
//! Mapped handles resolve to stable channel IDs; unmapped handles still work directly.
|
//! Mapped handles resolve to stable channel IDs; unmapped handles still work directly.
|
||||||
//! Re-saving an existing channel replaces its opaque URL in that category and preserves comments.
|
//! Deselecting removes the channel from that tag, preserving other creators and empty tags.
|
||||||
//! Missing handle/ID metadata or a conflicting mapping prevents saving rather than guessing.
|
//! Missing handle/ID metadata or a conflicting mapping prevents saving rather than guessing.
|
||||||
//! Normal-mode c opens the category picker; n there prompts for a new name, Enter saves, Esc cancels.
|
//! Normal-mode c edits memberships: j/k moves, Space toggles, Enter commits, Esc cancels.
|
||||||
//! Tab/Shift-Tab switch the independent Search/category views; r reloads config and refreshes.
|
//! n prompts for a new tag; Enter stages it checked, Esc returns. Only list Enter writes the file.
|
||||||
|
//! Tab/Shift-Tab switch independent Search/tag views; r reloads config and refreshes.
|
||||||
//! Feeds merge up to 20 videos per channel's /videos tab, ordered by approximate upload dates.
|
//! Feeds merge up to 20 videos per channel's /videos tab, ordered by approximate upload dates.
|
||||||
//! IINA receives `--stdin`; other players must read standard input when passed `-`.
|
//! IINA receives `--stdin`; other players must read standard input when passed `-`.
|
||||||
//! Audio and video are streamed through a pipe, never saved to a video file.
|
//! Audio and video are streamed through a pipe, never saved to a video file.
|
||||||
@@ -28,7 +29,7 @@ mod config;
|
|||||||
mod thumbnail;
|
mod thumbnail;
|
||||||
mod youtube;
|
mod youtube;
|
||||||
|
|
||||||
use std::{ffi::OsString, io, path::PathBuf, time::Duration};
|
use std::{collections::BTreeMap, ffi::OsString, io, path::PathBuf, time::Duration};
|
||||||
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
@@ -45,7 +46,7 @@ use youtube::{Job, Playback, Video};
|
|||||||
#[command(
|
#[command(
|
||||||
version,
|
version,
|
||||||
about = "Search YouTube and stream videos to your chosen player",
|
about = "Search YouTube and stream videos to your chosen player",
|
||||||
after_help = "Requires yt-dlp, FFmpeg, and a player on PATH; thumbnails also use curl.\nDefaults to IINA on macOS and mpv elsewhere; --player overrides the default.\nIINA receives '--stdin'; other players receive '-' to read audio/video from standard input.\nWithout a CLI query, start in INSERT; a CLI query is searched automatically in NORMAL.\nINSERT: type to edit; Enter submits if changed and returns to NORMAL; Esc returns without submitting.\nNORMAL: j/k or arrows select; Enter streams; i edits the query; d toggles description; t toggles thumbnail; c saves a channel; q quits.\nTab/Shift-Tab switch Search/category views; r reloads config and refreshes the current view.\nSubscriptions: $XDG_CONFIG_HOME/tubular/config.toml, or ~/.config/tubular/config.toml when XDG_CONFIG_HOME is unset/empty/relative.\nExample TOML:\n[categories]\nCreators = [\"@jawed\"]\n[channels]\n\"@jawed\" = \"https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A\"\nCategory entries accept @handles, HTTPS YouTube channel URLs, or canonical UC channel IDs.\n[channels] optionally maps handles to canonical channel URLs/IDs; feeds use these stable identities.\nSaving writes a handle plus its mapping; re-saving replaces matching URLs/aliases in that category, preserving comments.\nUnmapped handles work directly. Existing URL entries still load; they are not changed until re-saved.\nSaving requires a handle and canonical ID; unavailable metadata or conflicting mappings are reported without changing the file.\nFeeds combine up to 20 videos per channel's /videos tab, newest-first using approximate upload dates.\nCATEGORY PICKER: j/k or arrows choose; Enter saves; n prompts for a new category; Esc cancels.\nNEW CATEGORY: type a name; Enter creates it and saves the channel; Esc returns to the picker; Ctrl-U clears.\nOpening the description fetches full video metadata, preserving paragraphs and caching it for these results.\nPreview windows use up to 70% width and 60% height, capped at 96 by 20 cells.\nIn descriptions, Up/Down scroll lines and PgUp/PgDn scroll pages; j/k still selects videos.\nEsc closes the description; i closes it to edit the query. Scrolling stays within the text.\nTHUMBNAIL: t or Esc closes; j/k or arrows load the selected video's image; d switches to description.\nThumbnails are fetched lazily into memory and fit proportionally inside the popup on resize.\nTerminal graphics are detected at startup, with colored half-block rendering when image protocols are unavailable.\nDownloading, JPEG decoding, and image resizing/encoding do not block navigation.\ni, c, view changes, and refresh dismiss the thumbnail; typing t in INSERT or a category name edits text.\nCtrl-U clears in INSERT; Ctrl-C quits in any mode or popup.\nSelecting another video replaces playback. Quitting stops playback.\nExample: tubular --player mpv --player-arg=--volume=50 \"rust programming\""
|
after_help = "Requires yt-dlp, FFmpeg, and a player on PATH; thumbnails also use curl.\nDefaults to IINA on macOS and mpv elsewhere; --player overrides the default.\nIINA receives '--stdin'; other players receive '-' to read audio/video from standard input.\nWithout a CLI query, start in INSERT; a CLI query is searched automatically in NORMAL.\nINSERT: type to edit; Enter submits if changed and returns to NORMAL; Esc returns without submitting.\nNORMAL: j/k or arrows select; Enter streams; i edits the query; d toggles description; t toggles thumbnail; c edits channel tags; q quits.\nTab/Shift-Tab switch Search/tag views; r reloads config and refreshes the current view.\nSubscriptions: $XDG_CONFIG_HOME/tubular/config.toml, or ~/.config/tubular/config.toml when XDG_CONFIG_HOME is unset/empty/relative.\nExample TOML:\n[categories]\nCreators = [\"@jawed\"]\nFavorites = [\"@jawed\"]\n[channels]\n\"@jawed\" = \"https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A\"\n[categories] defines flat, overlapping tags, each with its own feed; there is no hierarchy or inheritance.\nEntries accept @handles, HTTPS YouTube channel URLs, or canonical UC channel IDs.\n[channels] optionally maps handles to canonical channel URLs/IDs; feeds use these stable identities.\nChecked tags save the handle plus its mapping; existing URLs/aliases are replaced with the handle, preserving comments.\nUnchecked tags remove only that channel; other creators, omitted tags, and empty tag arrays are retained.\nUnmapped handles work directly. Existing URL entries still load.\nSaving requires a handle and canonical ID; unavailable metadata or conflicting mappings are reported without changing the file.\nFeeds combine up to 20 videos per channel's /videos tab, newest-first using approximate upload dates.\nTAGS: existing assignments are checked; j/k or arrows move; Space toggles; Enter saves all staged memberships; n prompts for a new tag; Esc cancels without writing.\nNEW TAG: type a name; Enter stages it checked and returns to the list; Esc abandons the name; Ctrl-U clears.\nNew tags and membership changes reach disk only on Enter in the main tag list.\nOpening the description fetches full video metadata, preserving paragraphs and caching it for these results.\nPreview windows use up to 70% width and 60% height, capped at 96 by 20 cells.\nIn descriptions, Up/Down scroll lines and PgUp/PgDn scroll pages; j/k still selects videos.\nEsc closes the description; i closes it to edit the query. Scrolling stays within the text.\nTHUMBNAIL: t or Esc closes; j/k or arrows load the selected video's image; d switches to description.\nThumbnails are fetched lazily into memory and fit proportionally inside the popup on resize.\nTerminal graphics are detected at startup, with colored half-block rendering when image protocols are unavailable.\nDownloading, JPEG decoding, and image resizing/encoding do not block navigation.\ni, c, view changes, and refresh dismiss the thumbnail; typing t in INSERT or a tag name edits text.\nCtrl-U clears in INSERT; Ctrl-C quits in any mode or popup.\nSelecting another video replaces playback. Quitting stops playback.\nExample: tubular --player mpv --player-arg=--volume=50 \"rust programming\""
|
||||||
)]
|
)]
|
||||||
struct Options {
|
struct Options {
|
||||||
/// Player executable (IINA or a player supporting '-' for standard input).
|
/// Player executable (IINA or a player supporting '-' for standard input).
|
||||||
@@ -90,6 +91,7 @@ struct CategoryDialog {
|
|||||||
channel: String,
|
channel: String,
|
||||||
handle: String,
|
handle: String,
|
||||||
label: String,
|
label: String,
|
||||||
|
tags: BTreeMap<String, bool>,
|
||||||
selection: ListState,
|
selection: ListState,
|
||||||
name: Option<String>,
|
name: Option<String>,
|
||||||
error: Option<String>,
|
error: Option<String>,
|
||||||
@@ -302,6 +304,20 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let mut selection = ListState::default();
|
let mut selection = ListState::default();
|
||||||
|
let handle_url = format!("https://www.youtube.com/{handle}");
|
||||||
|
let tags = self
|
||||||
|
.config
|
||||||
|
.categories
|
||||||
|
.iter()
|
||||||
|
.map(|(name, channels)| {
|
||||||
|
(
|
||||||
|
name.clone(),
|
||||||
|
channels
|
||||||
|
.iter()
|
||||||
|
.any(|url| url == &channel || url == &handle_url),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
selection.select((!self.config.categories.is_empty()).then_some(0));
|
selection.select((!self.config.categories.is_empty()).then_some(0));
|
||||||
self.description_open = false;
|
self.description_open = false;
|
||||||
self.description_job = None;
|
self.description_job = None;
|
||||||
@@ -309,6 +325,7 @@ impl App {
|
|||||||
channel,
|
channel,
|
||||||
handle,
|
handle,
|
||||||
label,
|
label,
|
||||||
|
tags,
|
||||||
selection,
|
selection,
|
||||||
name: None,
|
name: None,
|
||||||
error: None,
|
error: None,
|
||||||
@@ -326,6 +343,22 @@ impl App {
|
|||||||
dialog.name = None;
|
dialog.name = None;
|
||||||
dialog.error = None;
|
dialog.error = None;
|
||||||
}
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
let name = name.trim().to_owned();
|
||||||
|
if name.is_empty() || name.chars().any(char::is_control) {
|
||||||
|
dialog.error =
|
||||||
|
Some("Enter a nonempty tag name without control characters.".into());
|
||||||
|
} else if dialog.tags.contains_key(&name) {
|
||||||
|
dialog.error = Some("That tag already exists.".into());
|
||||||
|
} else {
|
||||||
|
dialog.tags.insert(name.clone(), true);
|
||||||
|
dialog
|
||||||
|
.selection
|
||||||
|
.select(dialog.tags.keys().position(|tag| tag == &name));
|
||||||
|
dialog.name = None;
|
||||||
|
dialog.error = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
KeyCode::Backspace => {
|
KeyCode::Backspace => {
|
||||||
name.pop();
|
name.pop();
|
||||||
}
|
}
|
||||||
@@ -340,7 +373,7 @@ impl App {
|
|||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let count = self.config.categories.len();
|
let count = dialog.tags.len();
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Esc => {
|
KeyCode::Esc => {
|
||||||
self.switch_view(self.view_index);
|
self.switch_view(self.view_index);
|
||||||
@@ -350,6 +383,16 @@ impl App {
|
|||||||
dialog.name = Some(String::new());
|
dialog.name = Some(String::new());
|
||||||
dialog.error = None;
|
dialog.error = None;
|
||||||
}
|
}
|
||||||
|
KeyCode::Char(' ') => {
|
||||||
|
if let Some((_, checked)) = dialog
|
||||||
|
.selection
|
||||||
|
.selected()
|
||||||
|
.and_then(|index| dialog.tags.iter_mut().nth(index))
|
||||||
|
{
|
||||||
|
*checked = !*checked;
|
||||||
|
dialog.error = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
KeyCode::Down | KeyCode::Char('j') if count != 0 => {
|
KeyCode::Down | KeyCode::Char('j') if count != 0 => {
|
||||||
dialog.selection.select(Some(
|
dialog.selection.select(Some(
|
||||||
dialog
|
dialog
|
||||||
@@ -365,39 +408,23 @@ impl App {
|
|||||||
dialog.selection.selected().unwrap_or(0).saturating_sub(1),
|
dialog.selection.selected().unwrap_or(0).saturating_sub(1),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
_ => {}
|
KeyCode::Enter => {
|
||||||
}
|
match config::set_channel_tags(
|
||||||
}
|
|
||||||
if key.code == KeyCode::Enter {
|
|
||||||
let category = dialog
|
|
||||||
.name
|
|
||||||
.as_deref()
|
|
||||||
.map(str::trim)
|
|
||||||
.map(str::to_owned)
|
|
||||||
.or_else(|| {
|
|
||||||
dialog
|
|
||||||
.selection
|
|
||||||
.selected()
|
|
||||||
.and_then(|index| self.config.categories.keys().nth(index))
|
|
||||||
.cloned()
|
|
||||||
});
|
|
||||||
if let Some(category) = category {
|
|
||||||
match config::add_channel(
|
|
||||||
&self.config_path,
|
&self.config_path,
|
||||||
&category,
|
&dialog.tags,
|
||||||
&dialog.handle,
|
&dialog.handle,
|
||||||
&dialog.channel,
|
&dialog.channel,
|
||||||
) {
|
) {
|
||||||
Ok(config) => {
|
Ok(config) => {
|
||||||
self.apply_config(config);
|
self.apply_config(config);
|
||||||
self.switch_view(self.view_index);
|
self.switch_view(self.view_index);
|
||||||
self.status = format!("Saved channel to {category}.");
|
self.status = "Saved channel tags.".into();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Err(error) => dialog.error = Some(error.to_string()),
|
Err(error) => dialog.error = Some(error.to_string()),
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
dialog.error = Some("Press n to create a category.".into());
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.category_dialog = Some(dialog);
|
self.category_dialog = Some(dialog);
|
||||||
@@ -729,7 +756,7 @@ impl App {
|
|||||||
"NORMAL · j/k video · t/Esc close · d description · i insert · q quit"
|
"NORMAL · j/k video · t/Esc close · d description · i insert · q quit"
|
||||||
}
|
}
|
||||||
Mode::Normal => {
|
Mode::Normal => {
|
||||||
"NORMAL · j/k · Enter play · i search · d info · t image · c save · Tab views · r refresh · q quit"
|
"NORMAL · j/k · Enter play · i search · d info · t image · c tags · Tab views · r refresh · q quit"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
frame.render_widget(Paragraph::new(help_text).dim(), help);
|
frame.render_widget(Paragraph::new(help_text).dim(), help);
|
||||||
@@ -812,7 +839,7 @@ impl App {
|
|||||||
let height = area.height.saturating_sub(4).min(if dialog.name.is_some() {
|
let height = area.height.saturating_sub(4).min(if dialog.name.is_some() {
|
||||||
7
|
7
|
||||||
} else {
|
} else {
|
||||||
self.config.categories.len().saturating_add(5).clamp(7, 16) as u16
|
dialog.tags.len().saturating_add(5).clamp(7, 16) as u16
|
||||||
});
|
});
|
||||||
let popup = Rect::new(
|
let popup = Rect::new(
|
||||||
area.x + (area.width - width) / 2,
|
area.x + (area.width - width) / 2,
|
||||||
@@ -823,9 +850,9 @@ impl App {
|
|||||||
frame.render_widget(Clear, popup);
|
frame.render_widget(Clear, popup);
|
||||||
let block = Block::bordered()
|
let block = Block::bordered()
|
||||||
.title(if dialog.name.is_some() {
|
.title(if dialog.name.is_some() {
|
||||||
" New category "
|
" New tag "
|
||||||
} else {
|
} else {
|
||||||
" Save channel "
|
" Tags "
|
||||||
})
|
})
|
||||||
.title_bottom(dialog.label.as_str())
|
.title_bottom(dialog.label.as_str())
|
||||||
.border_style(Style::new().fg(Color::Cyan));
|
.border_style(Style::new().fg(Color::Cyan));
|
||||||
@@ -849,14 +876,16 @@ impl App {
|
|||||||
if body.width != 0 && body.height != 0 {
|
if body.width != 0 && body.height != 0 {
|
||||||
frame.set_cursor_position((body.x + cursor.min(body.width - 1), body.y));
|
frame.set_cursor_position((body.x + cursor.min(body.width - 1), body.y));
|
||||||
}
|
}
|
||||||
} else if self.config.categories.is_empty() {
|
} else if dialog.tags.is_empty() {
|
||||||
frame.render_widget(
|
frame.render_widget(Paragraph::new("No tags. Press n to create one."), body);
|
||||||
Paragraph::new("No categories. Press n to create one."),
|
|
||||||
body,
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
frame.render_stateful_widget(
|
frame.render_stateful_widget(
|
||||||
List::new(self.config.categories.keys().map(String::as_str))
|
List::new(dialog.tags.iter().map(|(name, checked)| {
|
||||||
|
Line::from(vec![
|
||||||
|
(if *checked { "[x] " } else { "[ ] " }).into(),
|
||||||
|
name.as_str().into(),
|
||||||
|
])
|
||||||
|
}))
|
||||||
.highlight_symbol("> ")
|
.highlight_symbol("> ")
|
||||||
.highlight_style(Style::new().fg(Color::Black).bg(Color::Cyan)),
|
.highlight_style(Style::new().fg(Color::Black).bg(Color::Cyan)),
|
||||||
body,
|
body,
|
||||||
@@ -873,9 +902,9 @@ impl App {
|
|||||||
}
|
}
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new(if dialog.name.is_some() {
|
Paragraph::new(if dialog.name.is_some() {
|
||||||
"Enter create & save · Esc back · Ctrl-U clear"
|
"Enter create · Esc back · Ctrl-U clear"
|
||||||
} else {
|
} else {
|
||||||
"j/k select · Enter save · n new · Esc cancel"
|
"j/k move · Space toggle · Enter save · n new · Esc cancel"
|
||||||
})
|
})
|
||||||
.dim(),
|
.dim(),
|
||||||
help,
|
help,
|
||||||
@@ -1012,20 +1041,32 @@ mod tests {
|
|||||||
app.key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE));
|
app.key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE));
|
||||||
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||||
assert!(app.category_dialog.as_ref().unwrap().error.is_some());
|
assert!(app.category_dialog.as_ref().unwrap().error.is_some());
|
||||||
for c in ['j', 'k', 'q', 'i'] {
|
for c in ['j', 'k', 'q', 'i', 't', 'd', ' '] {
|
||||||
assert!(!app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)));
|
assert!(!app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)));
|
||||||
}
|
}
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
app.category_dialog.as_ref().unwrap().name.as_deref(),
|
app.category_dialog.as_ref().unwrap().name.as_deref(),
|
||||||
Some("jkqi")
|
Some("jkqitd ")
|
||||||
);
|
);
|
||||||
assert_eq!(app.query, "rust");
|
assert_eq!(app.query, "rust");
|
||||||
assert_eq!(app.views[0].selection.selected(), Some(0));
|
assert_eq!(app.views[0].selection.selected(), Some(0));
|
||||||
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||||
assert!(app.category_dialog.as_ref().unwrap().name.is_none());
|
assert!(app.category_dialog.as_ref().unwrap().name.is_none());
|
||||||
|
app.key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE));
|
||||||
|
app.key(KeyEvent::new(KeyCode::Char('A'), KeyModifiers::NONE));
|
||||||
|
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||||
|
assert!(app.category_dialog.as_ref().unwrap().name.is_none());
|
||||||
|
assert!(app.category_dialog.as_ref().unwrap().tags["A"]);
|
||||||
|
assert!(app.config.categories.is_empty());
|
||||||
|
app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
||||||
|
assert!(!app.category_dialog.as_ref().unwrap().tags["A"]);
|
||||||
|
app.key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE));
|
||||||
|
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||||
|
assert!(!app.category_dialog.as_ref().unwrap().tags["A"]);
|
||||||
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||||
assert!(app.category_dialog.is_none());
|
assert!(app.category_dialog.is_none());
|
||||||
assert_eq!(app.mode, Mode::Normal);
|
assert_eq!(app.mode, Mode::Normal);
|
||||||
|
assert!(app.config.categories.is_empty());
|
||||||
|
|
||||||
let rust = vec!["https://www.youtube.com/@RustVideos".into()];
|
let rust = vec!["https://www.youtube.com/@RustVideos".into()];
|
||||||
app.apply_config(config::Config {
|
app.apply_config(config::Config {
|
||||||
@@ -1066,6 +1107,102 @@ mod tests {
|
|||||||
assert!(app.job.is_none());
|
assert!(app.job.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn channel_tags_stage_memberships_and_commit_only_from_the_list() {
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"tubular-tag-editor-test-{}.toml",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
struct TestFile(PathBuf);
|
||||||
|
impl Drop for TestFile {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = std::fs::remove_file(&self.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::fs::OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create_new(true)
|
||||||
|
.open(&path)
|
||||||
|
.unwrap();
|
||||||
|
let _file = TestFile(path.clone());
|
||||||
|
let original = "[categories]\nAlias = [\"@RustVideos\"]\nCanonical = [\"UCaYhcUwRBNscFNUKTjgPFiA\"]\nEmpty = []\n";
|
||||||
|
std::fs::write(&path, original).unwrap();
|
||||||
|
let mut app = app(&["tubular", "rust"]);
|
||||||
|
app.config_path = path.clone();
|
||||||
|
app.views[0].videos = youtube::search_results(
|
||||||
|
r#"{"entries":[{"id":"l-GaaivVJCE","title":"Rust video","channel_id":"UCaYhcUwRBNscFNUKTjgPFiA","uploader_id":"@RustVideos"}]}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
app.views[0].loaded = true;
|
||||||
|
app.views[0].selection.select(Some(0));
|
||||||
|
app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
|
||||||
|
assert_eq!(
|
||||||
|
app.category_dialog.as_ref().unwrap().tags,
|
||||||
|
[
|
||||||
|
("Alias".into(), true),
|
||||||
|
("Canonical".into(), true),
|
||||||
|
("Empty".into(), false),
|
||||||
|
]
|
||||||
|
.into()
|
||||||
|
);
|
||||||
|
app.key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||||||
|
assert!(app.category_dialog.as_ref().unwrap().tags["Alias"]);
|
||||||
|
app.key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
|
||||||
|
app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
||||||
|
app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
|
||||||
|
app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
|
||||||
|
app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
||||||
|
app.key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE));
|
||||||
|
for c in "Empty".chars() {
|
||||||
|
app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
|
||||||
|
}
|
||||||
|
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||||
|
assert!(app.category_dialog.as_ref().unwrap().error.is_some());
|
||||||
|
app.key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL));
|
||||||
|
for c in " Added ".chars() {
|
||||||
|
app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
|
||||||
|
}
|
||||||
|
app.key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
||||||
|
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||||
|
assert_eq!(
|
||||||
|
app.category_dialog.as_ref().unwrap().selection.selected(),
|
||||||
|
Some(0)
|
||||||
|
);
|
||||||
|
assert!(app.category_dialog.as_ref().unwrap().tags["Added"]);
|
||||||
|
assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
|
||||||
|
app.category_dialog.as_mut().unwrap().error = Some("Previous save failed".into());
|
||||||
|
app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
||||||
|
assert!(app.category_dialog.as_ref().unwrap().error.is_none());
|
||||||
|
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||||
|
assert!(app.category_dialog.is_none());
|
||||||
|
let saved = config::load(&path).unwrap();
|
||||||
|
let canonical = vec!["https://www.youtube.com/channel/UCaYhcUwRBNscFNUKTjgPFiA".to_owned()];
|
||||||
|
assert!(saved.categories["Alias"].is_empty());
|
||||||
|
assert!(saved.categories["Added"].is_empty());
|
||||||
|
assert_eq!(saved.categories["Canonical"], canonical);
|
||||||
|
assert_eq!(saved.categories["Empty"], canonical);
|
||||||
|
let committed = std::fs::read_to_string(&path).unwrap();
|
||||||
|
app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
|
||||||
|
app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
||||||
|
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||||
|
assert_eq!(std::fs::read_to_string(&path).unwrap(), committed);
|
||||||
|
std::fs::write(&path, "[categories]\n").unwrap();
|
||||||
|
app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
|
||||||
|
assert!(
|
||||||
|
app.category_dialog
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.selection
|
||||||
|
.selected()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
|
||||||
|
app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
||||||
|
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||||
|
assert!(app.category_dialog.is_none());
|
||||||
|
assert!(config::load(&path).unwrap().categories.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn thumbnails_are_normal_mode_only_and_other_popups_dismiss_them() {
|
fn thumbnails_are_normal_mode_only_and_other_popups_dismiss_them() {
|
||||||
let mut app = app(&["tubular"]);
|
let mut app = app(&["tubular"]);
|
||||||
|
|||||||
Reference in New Issue
Block a user