From 7367b2fc4bd84adce1fe79203644f1f9f7dc12f3 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Fri, 9 Oct 2026 23:28:01 +0200 Subject: [PATCH] feat: edit overlapping channel tag memberships --- src/config.rs | 244 ++++++++++++++++++++++++++++++++++++++++--------- src/main.rs | 249 ++++++++++++++++++++++++++++++++++++++------------ 2 files changed, 395 insertions(+), 98 deletions(-) diff --git a/src/config.rs b/src/config.rs index 3e6b21d..c2039bd 100644 --- a/src/config.rs +++ b/src/config.rs @@ -38,8 +38,21 @@ pub fn load(path: &Path) -> io::Result { parse_categories(&document, &parse_channels(&document)?) } -pub fn add_channel(path: &Path, category: &str, handle: &str, channel: &str) -> io::Result { - let category = category_name(category)?; +pub fn set_channel_tags( + path: &Path, + tags: &BTreeMap, + handle: &str, + channel: &str, +) -> io::Result { + 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 channel = normalize_channel(channel)?; if !channel.starts_with("https://www.youtube.com/channel/") { @@ -75,33 +88,80 @@ pub fn add_channel(path: &Path, category: &str, handle: &str, channel: &str) -> let categories = document["categories"] .as_table_like_mut() .expect("categories were validated"); - // Keep the manually written key and its formatting if it has surrounding spaces. - let key = categories - .iter() - .find(|(key, _)| key.trim() == category) - .map(|(key, _)| key.to_owned()) - .unwrap_or_else(|| category.to_owned()); - if !categories.contains_key(&key) { - categories.insert(&key, value(Array::new())); - } - let channels = categories - .get_mut(&key) - .and_then(Item::as_array_mut) - .expect("category was validated"); - let mut found = false; - for entry in channels.iter_mut() { - if resolve_channel(entry.as_str().expect("channel was validated"), &mappings)? == channel { - // Replace old URLs/aliases with the current handle, preserving entry comments. - if entry.as_str() != Some(&handle) { - let decor = entry.decor().clone(); - *entry = handle.as_str().into(); - *entry.decor_mut() = decor; - } - found = true; + for (name, checked) in normalized_tags { + // Keep manually written keys and their formatting, including surrounding spaces. + let key = categories + .iter() + .find(|(key, _)| key.trim() == name) + .map(|(key, _)| key.to_owned()) + .unwrap_or_else(|| name.to_owned()); + if !categories.contains_key(&key) { + categories.insert(&key, value(Array::new())); + } + let channels = categories + .get_mut(&key) + .and_then(Item::as_array_mut) + .expect("category was validated"); + if checked { + let mut found = false; + for entry in channels.iter_mut() { + if resolve_channel(entry.as_str().expect("channel was validated"), &mappings)? + == channel + { + // Keep manually written duplicates and entry comments. + if entry.as_str() != Some(&handle) { + let decor = entry.decor().clone(); + *entry = handle.as_str().into(); + *entry.decor_mut() = decor; + } + found = true; + } + } + if !found { + 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); + } + } + } } - } - if !found { - channels.push(handle); } let config = parse_categories(&document, &mappings)?; save(path, original.as_deref(), document.to_string().as_bytes())?; @@ -368,7 +428,8 @@ mod tests { let id = "UC4QobU6STFB0P71PMvOGN5A"; assert!(load(&path).unwrap().categories.is_empty()); 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!( config.categories["Music"], [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"; 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!(config.categories["Empty"].is_empty()); let id = "UCabcdefghijklmnopqrstuv"; - let config = add_channel(&path, "Music", "@another", id).unwrap(); + let config = set_channel_tags(&path, &tags, "@another", id).unwrap(); assert_eq!( config.categories["Music"][1], 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' = '@another'\n", "[channels]\n'not-a-handle' = 'UCabcdefghijklmnopqrstuv'\n", + "[channels]\n'@example' = 'UCabcdefghijklmnopqrstuv'\n'https://youtube.com/@example' = 'UCabcdefghijklmnopqrstuv'\n", ] { fs::write(&path, malformed).unwrap(); 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); } 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!( - add_channel( - &path, - "Music", - "https://www.youtube.com/@example/videos", - id - ) - .is_err() + set_channel_tags(&path, &tags, "https://www.youtube.com/@example/videos", id).is_err() ); assert_eq!(fs::read_to_string(&path).unwrap(), manual); assert!(save(&path, Some(b"previous contents"), b"replacement").is_err()); @@ -448,23 +513,118 @@ mod tests { config.categories["Direct"], ["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["Other"], std::slice::from_ref(&url)); let saved = fs::read_to_string(&path).unwrap(); let document = saved.parse::().unwrap(); let entries = document["categories"]["Japanese"].as_array().unwrap(); 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"]["@old"].as_str(), Some(url.as_str())); 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!(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!(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::().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] fn paths_and_channel_addresses_are_strict() { let home = Some(OsString::from("/home/test")); diff --git a/src/main.rs b/src/main.rs index 7ace5cd..6283653 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,14 +11,15 @@ //! Images use native terminal graphics when supported, otherwise colored half-block characters. //! 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. -//! Channel categories 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. -//! Saving puts the creator's @handle in the category and its canonical URL in `[channels]`. +//! Channel tags live in `$XDG_CONFIG_HOME/tubular/config.toml`, or `~/.config/tubular/config.toml`. +//! `[categories]` stores flat, overlapping tags: one channel can appear in several arrays. +//! Checked tags save the creator's @handle; `[channels]` retains its canonical URL. //! 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. -//! Normal-mode c opens the category picker; n there prompts for a new name, Enter saves, Esc cancels. -//! Tab/Shift-Tab switch the independent Search/category views; r reloads config and refreshes. +//! Normal-mode c edits memberships: j/k moves, Space toggles, Enter commits, Esc cancels. +//! 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. //! 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. @@ -28,7 +29,7 @@ mod config; mod thumbnail; 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 ratatui::{ @@ -45,7 +46,7 @@ use youtube::{Job, Playback, Video}; #[command( version, 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 { /// Player executable (IINA or a player supporting '-' for standard input). @@ -90,6 +91,7 @@ struct CategoryDialog { channel: String, handle: String, label: String, + tags: BTreeMap, selection: ListState, name: Option, error: Option, @@ -302,6 +304,20 @@ impl App { } } 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)); self.description_open = false; self.description_job = None; @@ -309,6 +325,7 @@ impl App { channel, handle, label, + tags, selection, name: None, error: None, @@ -326,6 +343,22 @@ impl App { dialog.name = 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 => { name.pop(); } @@ -340,7 +373,7 @@ impl App { _ => {} } } else { - let count = self.config.categories.len(); + let count = dialog.tags.len(); match key.code { KeyCode::Esc => { self.switch_view(self.view_index); @@ -350,6 +383,16 @@ impl App { dialog.name = Some(String::new()); 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 => { dialog.selection.select(Some( dialog @@ -365,39 +408,23 @@ impl App { dialog.selection.selected().unwrap_or(0).saturating_sub(1), )); } - _ => {} - } - } - 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, - &category, - &dialog.handle, - &dialog.channel, - ) { - Ok(config) => { - self.apply_config(config); - self.switch_view(self.view_index); - self.status = format!("Saved channel to {category}."); - return; + KeyCode::Enter => { + match config::set_channel_tags( + &self.config_path, + &dialog.tags, + &dialog.handle, + &dialog.channel, + ) { + Ok(config) => { + self.apply_config(config); + self.switch_view(self.view_index); + self.status = "Saved channel tags.".into(); + 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); @@ -729,7 +756,7 @@ impl App { "NORMAL · j/k video · t/Esc close · d description · i insert · q quit" } 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); @@ -812,7 +839,7 @@ impl App { let height = area.height.saturating_sub(4).min(if dialog.name.is_some() { 7 } 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( area.x + (area.width - width) / 2, @@ -823,9 +850,9 @@ impl App { frame.render_widget(Clear, popup); let block = Block::bordered() .title(if dialog.name.is_some() { - " New category " + " New tag " } else { - " Save channel " + " Tags " }) .title_bottom(dialog.label.as_str()) .border_style(Style::new().fg(Color::Cyan)); @@ -849,16 +876,18 @@ impl App { if body.width != 0 && body.height != 0 { frame.set_cursor_position((body.x + cursor.min(body.width - 1), body.y)); } - } else if self.config.categories.is_empty() { - frame.render_widget( - Paragraph::new("No categories. Press n to create one."), - body, - ); + } else if dialog.tags.is_empty() { + frame.render_widget(Paragraph::new("No tags. Press n to create one."), body); } else { frame.render_stateful_widget( - List::new(self.config.categories.keys().map(String::as_str)) - .highlight_symbol("> ") - .highlight_style(Style::new().fg(Color::Black).bg(Color::Cyan)), + List::new(dialog.tags.iter().map(|(name, checked)| { + Line::from(vec![ + (if *checked { "[x] " } else { "[ ] " }).into(), + name.as_str().into(), + ]) + })) + .highlight_symbol("> ") + .highlight_style(Style::new().fg(Color::Black).bg(Color::Cyan)), body, &mut dialog.selection, ); @@ -873,9 +902,9 @@ impl App { } frame.render_widget( Paragraph::new(if dialog.name.is_some() { - "Enter create & save · Esc back · Ctrl-U clear" + "Enter create · Esc back · Ctrl-U clear" } else { - "j/k select · Enter save · n new · Esc cancel" + "j/k move · Space toggle · Enter save · n new · Esc cancel" }) .dim(), help, @@ -1012,20 +1041,32 @@ mod tests { app.key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); 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_eq!( app.category_dialog.as_ref().unwrap().name.as_deref(), - Some("jkqi") + Some("jkqitd ") ); assert_eq!(app.query, "rust"); assert_eq!(app.views[0].selection.selected(), Some(0)); app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::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)); assert!(app.category_dialog.is_none()); assert_eq!(app.mode, Mode::Normal); + assert!(app.config.categories.is_empty()); let rust = vec!["https://www.youtube.com/@RustVideos".into()]; app.apply_config(config::Config { @@ -1066,6 +1107,102 @@ mod tests { 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] fn thumbnails_are_normal_mode_only_and_other_popups_dismiss_them() { let mut app = app(&["tubular"]);