fix: accept existing handles with trailing underscores
This commit is contained in:
@@ -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.
|
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.
|
- **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.
|
- **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.
|
- 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.
|
||||||
|
|||||||
+29
-2
@@ -563,14 +563,13 @@ pub(crate) fn normalize_channel(input: &str) -> io::Result<String> {
|
|||||||
.or_else(|| input.strip_prefix("https://youtube.com/"));
|
.or_else(|| input.strip_prefix("https://youtube.com/"));
|
||||||
let address = url_address.unwrap_or(input);
|
let address = url_address.unwrap_or(input);
|
||||||
if let Some(handle) = address.strip_prefix('@') {
|
if let Some(handle) = address.strip_prefix('@') {
|
||||||
|
// Validate address safety, not creation rules: existing handles can have edge separators.
|
||||||
let length = handle.chars().count();
|
let length = handle.chars().count();
|
||||||
if length == 0
|
if length == 0
|
||||||
|| length > 30
|
|| length > 30
|
||||||
|| !handle
|
|| !handle
|
||||||
.chars()
|
.chars()
|
||||||
.all(|c| c.is_alphanumeric() || matches!(c, '_' | '-' | '.'))
|
.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"));
|
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]
|
#[test]
|
||||||
fn key_remaps_survive_all_saves_and_section_orders() {
|
fn key_remaps_survive_all_saves_and_section_orders() {
|
||||||
use crate::keys::{Action, Context};
|
use crate::keys::{Action, Context};
|
||||||
|
|||||||
+1
-1
@@ -877,7 +877,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn channel_handles_require_the_requested_identity_and_a_valid_handle() {
|
fn channel_handles_require_the_requested_identity_and_a_valid_handle() {
|
||||||
let id = "UChAQQ9at2MDlXjvQX3vL3Yw";
|
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();
|
let metadata = serde_json::json!({"channel_id": id, "uploader_id": handle}).to_string();
|
||||||
assert_eq!(channel_handle(&metadata, id).unwrap(), handle);
|
assert_eq!(channel_handle(&metadata, id).unwrap(), handle);
|
||||||
assert!(channel_handle(&metadata, "UCaYhcUwRBNscFNUKTjgPFiA").is_err());
|
assert!(channel_handle(&metadata, "UCaYhcUwRBNscFNUKTjgPFiA").is_err());
|
||||||
|
|||||||
Reference in New Issue
Block a user