diff --git a/README.md b/README.md index ee15e1d..6bb3b06 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,8 @@ Press **`U` (Shift-U)** on a video to browse its creator directly, including cha When search results omit a channel handle, `s` reuses its saved mapping or looks up the handle from the channel's canonical ID before opening the editor. Esc cancels a pending lookup without saving. Failed lookups or mismatched channel metadata leave the config unchanged. +Tubular validates handles as channel addresses, not against [YouTube's current handle-creation guidelines](https://support.google.com/youtube/answer/11585688). Existing handles such as `@hontame_` keep their leading/trailing separators and round-trip through saved mappings and categories. Whitespace inside handles, control characters, slashes, query strings, and fragments remain rejected. + - **Normal:** `j` / `k` or arrow keys navigate; Space toggles a tag; `i` edits the filter; `R` renames the selected tag. - **Insert:** type a case-insensitive substring to filter live; arrow keys or Ctrl-J / Ctrl-K navigate matches. Enter or Esc returns to normal mode without saving or clearing the filter. - Filtering only hides rows: checked and unchecked staged memberships outside the filter are retained. With no matches, navigation and toggling do nothing. Reopening the dialog clears its filter. diff --git a/src/config.rs b/src/config.rs index e64fedd..33e6241 100644 --- a/src/config.rs +++ b/src/config.rs @@ -563,14 +563,13 @@ pub(crate) fn normalize_channel(input: &str) -> io::Result { .or_else(|| input.strip_prefix("https://youtube.com/")); let address = url_address.unwrap_or(input); if let Some(handle) = address.strip_prefix('@') { + // Validate address safety, not creation rules: existing handles can have edge separators. let length = handle.chars().count(); if length == 0 || length > 30 || !handle .chars() .all(|c| c.is_alphanumeric() || matches!(c, '_' | '-' | '.')) - || !handle.starts_with(char::is_alphanumeric) - || !handle.ends_with(char::is_alphanumeric) { return Err(invalid("Invalid YouTube channel handle")); } @@ -787,6 +786,34 @@ mod tests { } } + #[test] + fn existing_edge_separator_handles_can_be_saved_and_reloaded() { + let channel = "https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A"; + for handle in ["@hontame_", "@_creator", "@.creator-"] { + let directory = TestDirectory::new(); + let path = directory.0.join("config.toml"); + let tags = BTreeMap::from([("Immersion".to_owned(), true)]); + let saved = set_channel_tags(&path, &tags, handle, channel).unwrap(); + assert_eq!(saved.channels[handle], channel); + assert_eq!(saved.categories["Immersion"], [channel]); + let reloaded = load(&path).unwrap(); + assert_eq!(reloaded.channels, saved.channels); + assert_eq!(reloaded.categories, saved.categories); + let unchecked = BTreeMap::from([("Immersion".to_owned(), false)]); + set_channel_tags( + &path, + &unchecked, + &format!("https://www.youtube.com/{handle}"), + channel, + ) + .unwrap(); + let reloaded = load(&path).unwrap(); + assert!(reloaded.categories["Immersion"].is_empty()); + assert_eq!(reloaded.channels[handle], channel); + assert_eq!(reloaded.channels.len(), 1); + } + } + #[test] fn key_remaps_survive_all_saves_and_section_orders() { use crate::keys::{Action, Context}; diff --git a/src/youtube.rs b/src/youtube.rs index 8d307f2..de71604 100644 --- a/src/youtube.rs +++ b/src/youtube.rs @@ -877,7 +877,7 @@ mod tests { #[test] fn channel_handles_require_the_requested_identity_and_a_valid_handle() { let id = "UChAQQ9at2MDlXjvQX3vL3Yw"; - for handle in ["@ch.1676", "@敷嶋てとら"] { + for handle in ["@ch.1676", "@敷嶋てとら", "@hontame_"] { let metadata = serde_json::json!({"channel_id": id, "uploader_id": handle}).to_string(); assert_eq!(channel_handle(&metadata, id).unwrap(), handle); assert!(channel_handle(&metadata, "UCaYhcUwRBNscFNUKTjgPFiA").is_err());