Compare commits
5 Commits
36afc157d1
...
0d69d050ee
| Author | SHA1 | Date | |
|---|---|---|---|
| 0d69d050ee | |||
| 6563a3d38a | |||
| b53b57ee17 | |||
| ff7590dfa7 | |||
| aa66ac9d7f |
@@ -70,6 +70,8 @@ These are the default bindings. Overrides in `[keys]` replace an action's bindin
|
|||||||
| `U` (Shift-U) | Open the selected video's channel |
|
| `U` (Shift-U) | Open the selected video's channel |
|
||||||
| `s` | Edit the selected channel's tags |
|
| `s` | Edit the selected channel's tags |
|
||||||
| `S` | Edit tags for all saved channels in the current category |
|
| `S` | Edit tags for all saved channels in the current category |
|
||||||
|
| `R` (Shift-R) | Rename the current category, including category-scoped collection views |
|
||||||
|
| `P` (Shift-P) | Save the current combined-category feed as a new category |
|
||||||
| `w` | Save the selected video to watch later (also works in previews) |
|
| `w` | Save the selected video to watch later (also works in previews) |
|
||||||
| `W` | Choose combined or category watch later from any video view or preview |
|
| `W` | Choose combined or category watch later from any video view or preview |
|
||||||
| `Alt-w` | Open the current category's watch later |
|
| `Alt-w` | Open the current category's watch later |
|
||||||
@@ -95,12 +97,12 @@ The full-description view scrolls with ↓ / ↑, Ctrl-J / Ctrl-K, or PageDown /
|
|||||||
|
|
||||||
### Text input
|
### Text input
|
||||||
|
|
||||||
These shortcuts work in search, category/channel/tag-filter, and new-tag fields:
|
These shortcuts work in search, category/channel/tag-filter, new-tag, and category-rename fields:
|
||||||
|
|
||||||
| Key | Action |
|
| Key | Action |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Enter | Submit the search, browse the selected category/channel, leave tag-filter mode, or stage the new tag |
|
| Enter | Submit the search, browse the selected category/channel, leave tag-filter mode, stage a new tag, or commit a rename |
|
||||||
| Esc | Leave insert mode or abandon the new-tag name |
|
| Esc | Leave insert mode or abandon the new-tag / rename prompt |
|
||||||
| Backspace | Delete one character |
|
| Backspace | Delete one character |
|
||||||
| Option/Alt-Backspace or Ctrl-W | Delete the previous whitespace-delimited word |
|
| Option/Alt-Backspace or Ctrl-W | Delete the previous whitespace-delimited word |
|
||||||
| Cmd/Super-Backspace or Ctrl-U | Clear the line |
|
| Cmd/Super-Backspace or Ctrl-U | Clear the line |
|
||||||
@@ -111,9 +113,21 @@ In terminals supporting bracketed paste, clipboard text is inserted in one opera
|
|||||||
|
|
||||||
### Category browser (`c`)
|
### Category browser (`c`)
|
||||||
|
|
||||||
- **Normal:** `j` / `k` or arrow keys navigate; `i` enters filter mode; Enter browses the selected category; `p` toggles its persisted pin; Esc closes.
|
- **Normal:** `j` / `k` or arrow keys navigate; Space checks/unchecks a category; `i` enters filter mode; Enter browses checked categories, or the hovered category if none are checked; `p` toggles its persisted pin; `R` renames it; Esc closes.
|
||||||
- **Insert:** type a case-insensitive substring to filter; arrow keys or Ctrl-J / Ctrl-K navigate matches; Enter browses; Esc returns to normal mode without clearing the filter.
|
- **Insert:** type a case-insensitive substring to filter; arrow keys or Ctrl-J / Ctrl-K navigate matches; Enter browses the chosen categories; Esc returns to normal mode without clearing the filter. Space is ordinary input here.
|
||||||
- Reopening the browser clears its filter. No matches makes browsing and pinning a no-op.
|
- Checkboxes select categories for browsing; the dimmed `p` suffix marks pins independently. Hidden checks survive filtering. Enter still browses checked categories when there are no visible matches; without checks, an empty result makes browsing a no-op. Pinning, renaming, and toggling require a visible selected row.
|
||||||
|
|
||||||
|
### Combined category feeds
|
||||||
|
|
||||||
|
Check multiple categories in `c`, then press Enter to open their combined feed. One checked category opens its ordinary view; no checks retain the usual single-category shortcut. Esc discards the selector's staged choices. Reopening `c` from a combined feed prechecks its member categories.
|
||||||
|
|
||||||
|
The combined feed uses the shared temporary tab, replacing the prior unpinned category/channel/collection view. Channels and video IDs are deduplicated, and videos use the same newest-first ordering and infinite scrolling as other feeds. Back/forward navigation restores cached rows, selection, and pagination.
|
||||||
|
|
||||||
|
Press `P` to save the viewed union of channels under a new category name. Enter commits; Esc cancels; duplicate or invalid names leave the prompt open without overwriting anything. The new flat category becomes active without automatic pinning or re-fetching matching cached pages. Its memberships are an independent copy, not links to the original categories.
|
||||||
|
|
||||||
|
Renaming a member category updates the combined view and its history. Membership changes recompute the channel union, preserving cached rows when the resolved union is unchanged; deleting a member invalidates the combined view rather than silently widening it. Explicit refresh still reloads the active view from page one.
|
||||||
|
|
||||||
|
Combined feeds are not individual categories: `S`, `R`, and `Alt-w` / `Alt-v` require a single category. Save with `P` first to use those actions. Configure multi-selection with `selector.toggle` and saving with `category.save`; name entry uses the shared `input.*` controls.
|
||||||
|
|
||||||
### Saved channels (`u`)
|
### Saved channels (`u`)
|
||||||
|
|
||||||
@@ -127,15 +141,29 @@ Press **`U` (Shift-U)** on a video to browse its creator directly, including cha
|
|||||||
|
|
||||||
### Channel tags (`s`, or `S` for bulk editing)
|
### Channel tags (`s`, or `S` for bulk editing)
|
||||||
|
|
||||||
- **Normal:** `j` / `k` or arrow keys navigate; Space toggles a tag; `i` edits the filter.
|
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.
|
- **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.
|
||||||
- `n` prompts for a new tag name. Enter stages it checked, clears the filter, and selects the new tag; Esc abandons the name.
|
- `n` prompts for a new tag name. Enter stages it checked, clears the filter, and selects the new tag; Esc abandons the name.
|
||||||
- Enter **in the normal-mode tag list** saves all staged memberships, including hidden tags. Esc cancels without writing. These controls apply to both single-channel and bulk tagging.
|
- Enter **in the normal-mode tag list** saves all staged memberships, including hidden tags. Esc cancels those staged changes; it does not undo a committed rename of an existing category. These controls apply to both single-channel and bulk tagging.
|
||||||
- Filter actions are configurable as `tags.edit`, `tags.input.next`, and `tags.input.previous`; text editing uses the shared `input.*` actions.
|
- Filter actions are configurable as `tags.edit`, `tags.input.next`, and `tags.input.previous`; text editing uses the shared `input.*` actions.
|
||||||
|
|
||||||
Bulk editing applies the exact checked tag set to **every saved channel in the current category**, not only channels with loaded videos. Tags shared by every target start checked; mixed memberships start unchecked. Review the checked set before saving: unchecked tags are removed from all targets. Other channels, saved registrations, empty categories, pins, and comments are preserved.
|
Bulk editing applies the exact checked tag set to **every saved channel in the current category**, not only channels with loaded videos. Tags shared by every target start checked; mixed memberships start unchecked. Review the checked set before saving: unchecked tags are removed from all targets. Other channels, saved registrations, empty categories, pins, and comments are preserved.
|
||||||
|
|
||||||
|
### Renaming categories (`R`)
|
||||||
|
|
||||||
|
In normal mode, press `R` (Shift-R) to rename the active category, or the hovered category in a category/collection selector or single-channel/bulk tag list. Combined collection entries and channel rows cannot be renamed.
|
||||||
|
|
||||||
|
The floating prompt starts with the current name. Edit it with the shared text controls (`Ctrl-U` clears it), then press Enter to commit or Esc to cancel. Names are trimmed and must be nonempty, free of control characters, and distinct from existing or staged category names. Errors leave the prompt open without changing the file. If the source category changed externally, close dialogs and reload config with `r` before retrying.
|
||||||
|
|
||||||
|
Renaming an existing category saves its identity immediately while preserving channel memberships, pins, comments, cached videos, selection, pagination, watch filters, and back/forward history. In tag dialogs, checkbox changes remain staged until the main tag-list Enter; cancelling them does not undo the rename. A newly staged, unsaved tag stays local when renamed and reaches disk only when the tags are saved.
|
||||||
|
|
||||||
|
Configure the shortcut with `category.rename`; the prompt uses the shared `input.*` actions. `R` remains ordinary text while editing search or filters.
|
||||||
|
|
||||||
## Subscriptions and feeds
|
## Subscriptions and feeds
|
||||||
|
|
||||||
Configuration lives at:
|
Configuration lives at:
|
||||||
@@ -167,7 +195,7 @@ Category entries accept `@handles`, HTTPS YouTube channel URLs, or canonical `UC
|
|||||||
Video tables color channel names **cyan** when the creator belongs to at least one category. Untagged registrations keep the default text color; selected rows retain their black-on-cyan highlight. Membership is matched by channel ID or handle, not the displayed name, and updates after saving tags or reloading config.
|
Video tables color channel names **cyan** when the creator belongs to at least one category. Untagged registrations keep the default text color; selected rows retain their black-on-cyan highlight. Membership is matched by channel ID or handle, not the displayed name, and updates after saving tags or reloading config.
|
||||||
|
|
||||||
- **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.
|
||||||
- Browsing an unpinned category, channel, watch-later list, or saved-video list uses one shared temporary tab; choosing another replaces it. Pinned categories keep their own tabs, marked with a dimmed `p`.
|
- Browsing an unpinned category, combined-category feed, channel, watch-later list, or saved-video list uses one shared temporary tab; choosing another replaces it. Pinned categories keep their own tabs, marked with a dimmed `p`.
|
||||||
- Removing a channel from a category does **not** unsubscribe it from Feed. To unsubscribe, remove its `[channels]` mappings and category references, then press `r`.
|
- Removing a channel from a category does **not** unsubscribe it from Feed. To unsubscribe, remove its `[channels]` mappings and category references, then press `r`.
|
||||||
- Temporary tabs do not survive restarting; saved categories and pins do. Hidden views retain loaded rows, selection, and pagination during the session.
|
- Temporary tabs do not survive restarting; saved categories and pins do. Hidden views retain loaded rows, selection, and pagination during the session.
|
||||||
|
|
||||||
@@ -177,7 +205,7 @@ Bulk editing refuses to clear all tags when a target has no `[channels]` registr
|
|||||||
|
|
||||||
Watch later is **one collection**, deduplicated by YouTube video ID. Category lists are derived from the saved videos' channel identities and the **current** subscription categories: adding or removing a channel from a category changes its watch-later list after saving tags or reloading config with `r`. No category memberships are stored on individual videos.
|
Watch later is **one collection**, deduplicated by YouTube video ID. Category lists are derived from the saved videos' channel identities and the **current** subscription categories: adding or removing a channel from a category changes its watch-later list after saving tags or reloading config with `r`. No category memberships are stored on individual videos.
|
||||||
|
|
||||||
`W` opens a searchable collection selector from any video view or preview. The bold **All categories (combined)** entry comes first, followed by configured categories; it remains available even when there are no categories. Navigate with `j` / `k` or arrows in normal mode, or press `i` to filter by name and use arrows / `Ctrl-j` / `Ctrl-k` while typing. `Enter` browses the selected match; with no matches it does nothing. `Esc` returns to normal mode, then closes the selector without navigating. Reopening clears the search. `Alt-w` still opens the current category's watch later directly.
|
`W` opens a searchable collection selector from any video view or preview. The All categories (combined) entry comes first, followed by configured categories; it remains available even when there are no categories. Navigate with `j` / `k` or arrows in normal mode, or press `i` to filter by name and use arrows / `Ctrl-j` / `Ctrl-k` while typing. `Enter` browses the selected match; with no matches it does nothing. `Esc` returns to normal mode, then closes the selector without navigating. Reopening clears the search. `Alt-w` still opens the current category's watch later directly.
|
||||||
|
|
||||||
Moving between watch-later scopes carries the current All/Unwatched/Watched filter to the destination, including cached lists; back navigation restores the originating category list. Choosing the current list does not add history. The border title shows the active filter. Lists use saved title, creator, and publication metadata without fetching video lists, so they also work offline.
|
Moving between watch-later scopes carries the current All/Unwatched/Watched filter to the destination, including cached lists; back navigation restores the originating category list. Choosing the current list does not add history. The border title shows the active filter. Lists use saved title, creator, and publication metadata without fetching video lists, so they also work offline.
|
||||||
|
|
||||||
|
|||||||
+458
-2
@@ -384,6 +384,130 @@ pub fn set_category_pinned(path: &Path, name: &str, pinned: bool) -> io::Result<
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn create_category(path: &Path, name: &str, channels: &[String]) -> io::Result<Config> {
|
||||||
|
let name = category_name(name)?;
|
||||||
|
let (original, mut document) = read_document(path)?;
|
||||||
|
let mappings = parse_channels(&document)?;
|
||||||
|
let mut categories = parse_categories(&document, &mappings)?;
|
||||||
|
let pinned_categories = parse_pins(&document)?;
|
||||||
|
let keys = keys::Bindings::from_toml(document.get("keys"))?;
|
||||||
|
if categories.contains_key(name) {
|
||||||
|
return Err(invalid(format!("Category already exists: {name:?}")));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut labels = BTreeMap::new();
|
||||||
|
// As in saved_channels, use the first mapped handle for each identity.
|
||||||
|
for (handle, identity) in &mappings {
|
||||||
|
labels.entry(identity.as_str()).or_insert(handle.as_str());
|
||||||
|
}
|
||||||
|
let identities: BTreeSet<_> = channels
|
||||||
|
.iter()
|
||||||
|
.map(|channel| resolve_channel(channel, &mappings))
|
||||||
|
.collect::<io::Result<_>>()?;
|
||||||
|
let mut entries = Array::new();
|
||||||
|
for identity in &identities {
|
||||||
|
let readable = labels.get(identity.as_str()).copied().unwrap_or_else(|| {
|
||||||
|
identity
|
||||||
|
.strip_prefix("https://www.youtube.com/")
|
||||||
|
.filter(|address| address.starts_with('@'))
|
||||||
|
.unwrap_or(identity)
|
||||||
|
});
|
||||||
|
entries.push(readable);
|
||||||
|
}
|
||||||
|
if !document.contains_key("categories") {
|
||||||
|
document.insert("categories", Item::Table(Table::new()));
|
||||||
|
}
|
||||||
|
document["categories"]
|
||||||
|
.as_table_like_mut()
|
||||||
|
.expect("categories were validated")
|
||||||
|
.insert(name, value(entries));
|
||||||
|
categories.insert(name.to_owned(), identities.into_iter().collect());
|
||||||
|
save(path, original.as_deref(), document.to_string().as_bytes())?;
|
||||||
|
Ok(Config {
|
||||||
|
channels: mappings,
|
||||||
|
categories,
|
||||||
|
pinned_categories,
|
||||||
|
keys,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rename_category(path: &Path, old: &str, new: &str) -> io::Result<Config> {
|
||||||
|
let old = category_name(old)?;
|
||||||
|
let new = category_name(new)?;
|
||||||
|
let (original, mut document) = read_document(path)?;
|
||||||
|
let channels = parse_channels(&document)?;
|
||||||
|
let categories = parse_categories(&document, &channels)?;
|
||||||
|
let pinned_categories = parse_pins(&document)?;
|
||||||
|
let keys = keys::Bindings::from_toml(document.get("keys"))?;
|
||||||
|
if !categories.contains_key(old) {
|
||||||
|
return Err(invalid(format!("Unknown source category: {old:?}")));
|
||||||
|
}
|
||||||
|
if old == new {
|
||||||
|
return Ok(Config {
|
||||||
|
channels,
|
||||||
|
categories,
|
||||||
|
pinned_categories,
|
||||||
|
keys,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if categories.contains_key(new) {
|
||||||
|
return Err(invalid(format!("Category already exists: {new:?}")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyMut only edits formatting. Move entries through formatted insertion to
|
||||||
|
// rename the key without changing its position or any membership formatting.
|
||||||
|
match &mut document["categories"] {
|
||||||
|
Item::Table(table) => {
|
||||||
|
let names: Vec<_> = table.iter().map(|(name, _)| name.to_owned()).collect();
|
||||||
|
for name in names {
|
||||||
|
let (mut key, item) = table.remove_entry(&name).expect("category was validated");
|
||||||
|
if key.get().trim() == old {
|
||||||
|
key = toml_edit::Key::new(new)
|
||||||
|
.with_leaf_decor(key.leaf_decor().clone())
|
||||||
|
.with_dotted_decor(key.dotted_decor().clone());
|
||||||
|
}
|
||||||
|
table.insert_formatted(&key, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Item::Value(toml_edit::Value::InlineTable(table)) => {
|
||||||
|
let names: Vec<_> = table.iter().map(|(name, _)| name.to_owned()).collect();
|
||||||
|
for name in names {
|
||||||
|
let (mut key, item) = table.remove_entry(&name).expect("category was validated");
|
||||||
|
if key.get().trim() == old {
|
||||||
|
key = toml_edit::Key::new(new)
|
||||||
|
.with_leaf_decor(key.leaf_decor().clone())
|
||||||
|
.with_dotted_decor(key.dotted_decor().clone());
|
||||||
|
}
|
||||||
|
table.insert_formatted(&key, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => unreachable!("categories were validated"),
|
||||||
|
}
|
||||||
|
if let Some(array) = document
|
||||||
|
.get_mut("settings")
|
||||||
|
.and_then(Item::as_table_like_mut)
|
||||||
|
.and_then(|settings| settings.get_mut("pinned_categories"))
|
||||||
|
.and_then(Item::as_array_mut)
|
||||||
|
{
|
||||||
|
for pin in array.iter_mut() {
|
||||||
|
if pin.as_str().expect("pin was validated").trim() == old {
|
||||||
|
let decor = pin.decor().clone();
|
||||||
|
*pin = new.into();
|
||||||
|
*pin.decor_mut() = decor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let categories = parse_categories(&document, &channels)?;
|
||||||
|
let pinned_categories = parse_pins(&document)?;
|
||||||
|
save(path, original.as_deref(), document.to_string().as_bytes())?;
|
||||||
|
Ok(Config {
|
||||||
|
channels,
|
||||||
|
categories,
|
||||||
|
pinned_categories,
|
||||||
|
keys,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_pins(document: &DocumentMut) -> io::Result<Vec<String>> {
|
fn parse_pins(document: &DocumentMut) -> io::Result<Vec<String>> {
|
||||||
if document.contains_key("pinned_categories") {
|
if document.contains_key("pinned_categories") {
|
||||||
return Err(invalid("Move top-level pinned_categories into [settings]"));
|
return Err(invalid("Move top-level pinned_categories into [settings]"));
|
||||||
@@ -439,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"));
|
||||||
}
|
}
|
||||||
@@ -663,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};
|
||||||
@@ -796,6 +947,9 @@ mod tests {
|
|||||||
let pin_error = set_category_pinned(&path, "B", true).unwrap_err();
|
let pin_error = set_category_pinned(&path, "B", true).unwrap_err();
|
||||||
assert_eq!(pin_error.kind(), io::ErrorKind::InvalidData);
|
assert_eq!(pin_error.kind(), io::ErrorKind::InvalidData);
|
||||||
assert_eq!(fs::read(&path).unwrap(), original.as_bytes());
|
assert_eq!(fs::read(&path).unwrap(), original.as_bytes());
|
||||||
|
let create_error = create_category(&path, "New", &expected).unwrap_err();
|
||||||
|
assert_eq!(create_error.kind(), io::ErrorKind::InvalidData);
|
||||||
|
assert_eq!(fs::read(&path).unwrap(), original.as_bytes());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -912,6 +1066,308 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn category_creation_deduplicates_aliases_and_preserves_original_config() {
|
||||||
|
let directory = TestDirectory::new();
|
||||||
|
let path = directory.0.join("config.toml");
|
||||||
|
let id = "UC4QobU6STFB0P71PMvOGN5A";
|
||||||
|
let url = format!("https://www.youtube.com/channel/{id}");
|
||||||
|
let original = "# manual preferences\n[other]\nvalue = 42\n[settings]\npinned_categories = [' Source '] # favorite\nplayer = 'mpv'\n[keys.app]\nquit = ['q'] # key note\n[channels]\n'@zeta' = 'UC4QobU6STFB0P71PMvOGN5A' # mapping note\n'@alpha' = 'UC4QobU6STFB0P71PMvOGN5A'\n[categories]\n# category note\n' Source ' = [\n '@zeta', # entry note\n '@manual',\n '@manual',\n] # array note\nEmpty = []\n";
|
||||||
|
fs::write(&path, original).unwrap();
|
||||||
|
let before = load(&path).unwrap();
|
||||||
|
let created = create_category(
|
||||||
|
&path,
|
||||||
|
" Combined ",
|
||||||
|
&[
|
||||||
|
id.to_owned(),
|
||||||
|
url.clone(),
|
||||||
|
"@zeta".to_owned(),
|
||||||
|
"https://youtube.com/@alpha".to_owned(),
|
||||||
|
"@manual".to_owned(),
|
||||||
|
"https://www.youtube.com/@manual".to_owned(),
|
||||||
|
"UCabcdefghijklmnopqrstuv".to_owned(),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
created.categories["Combined"],
|
||||||
|
[
|
||||||
|
"https://www.youtube.com/@manual".to_owned(),
|
||||||
|
url,
|
||||||
|
"https://www.youtube.com/channel/UCabcdefghijklmnopqrstuv".to_owned(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(created.channels, before.channels);
|
||||||
|
assert_eq!(created.categories["Source"], before.categories["Source"]);
|
||||||
|
assert_eq!(created.categories["Empty"], before.categories["Empty"]);
|
||||||
|
assert_eq!(created.pinned_categories, before.pinned_categories);
|
||||||
|
let contents = fs::read_to_string(&path).unwrap();
|
||||||
|
let mut document = contents.parse::<DocumentMut>().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
document["categories"]["Combined"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|entry| entry.as_str().unwrap())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
[
|
||||||
|
"@manual",
|
||||||
|
"@alpha",
|
||||||
|
"https://www.youtube.com/channel/UCabcdefghijklmnopqrstuv",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
document["categories"]
|
||||||
|
.as_table_like_mut()
|
||||||
|
.unwrap()
|
||||||
|
.remove("Combined");
|
||||||
|
assert_eq!(document.to_string(), original);
|
||||||
|
assert_eq!(load(&path).unwrap().categories, created.categories);
|
||||||
|
assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn category_creation_uses_latest_mappings_and_supports_inline_unicode_and_empty() {
|
||||||
|
let directory = TestDirectory::new();
|
||||||
|
let path = directory.0.join("config.toml");
|
||||||
|
let original = "categories = { Source = ['@first', '@first'], Empty = [] } # categories note\nsettings = { pinned_categories = ['Source'], custom = 42 }\nchannels = { '@mapped' = 'UC4QobU6STFB0P71PMvOGN5A' }\n";
|
||||||
|
fs::write(&path, original).unwrap();
|
||||||
|
let opened = load(&path).unwrap();
|
||||||
|
let latest = original.replace("UC4QobU6STFB0P71PMvOGN5A", "UCabcdefghijklmnopqrstuv");
|
||||||
|
fs::write(&path, &latest).unwrap();
|
||||||
|
let created = create_category(
|
||||||
|
&path,
|
||||||
|
" 日本語 🎵 ",
|
||||||
|
&["@mapped".to_owned(), "UCabcdefghijklmnopqrstuv".to_owned()],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_ne!(created.channels, opened.channels);
|
||||||
|
assert_eq!(
|
||||||
|
created.categories["日本語 🎵"],
|
||||||
|
["https://www.youtube.com/channel/UCabcdefghijklmnopqrstuv"]
|
||||||
|
);
|
||||||
|
assert_eq!(created.pinned_categories, ["Source"]);
|
||||||
|
let contents = fs::read_to_string(&path).unwrap();
|
||||||
|
let document = contents.parse::<DocumentMut>().unwrap();
|
||||||
|
let before_document = latest.parse::<DocumentMut>().unwrap();
|
||||||
|
assert!(document["categories"].is_inline_table());
|
||||||
|
assert_eq!(
|
||||||
|
document["categories"]["日本語 🎵"][0].as_str(),
|
||||||
|
Some("@mapped")
|
||||||
|
);
|
||||||
|
for section in ["settings", "channels"] {
|
||||||
|
assert_eq!(
|
||||||
|
document[section].to_string(),
|
||||||
|
before_document[section].to_string()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for name in ["Source", "Empty"] {
|
||||||
|
assert_eq!(
|
||||||
|
document["categories"][name].to_string(),
|
||||||
|
before_document["categories"][name].to_string()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(contents.contains("# categories note"));
|
||||||
|
let empty = create_category(&path, "New empty", &[]).unwrap();
|
||||||
|
assert!(empty.categories["New empty"].is_empty());
|
||||||
|
assert_eq!(empty.pinned_categories, ["Source"]);
|
||||||
|
let missing = directory.0.join("nested/config.toml");
|
||||||
|
let empty = create_category(&missing, "Empty", &[]).unwrap();
|
||||||
|
assert!(empty.categories["Empty"].is_empty());
|
||||||
|
assert!(empty.channels.is_empty());
|
||||||
|
assert!(empty.pinned_categories.is_empty());
|
||||||
|
assert_eq!(load(&missing).unwrap().categories, empty.categories);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn category_creation_rejects_invalid_names_channels_and_configs_without_writes() {
|
||||||
|
let directory = TestDirectory::new();
|
||||||
|
let path = directory.0.join("config.toml");
|
||||||
|
let original = "[categories]\n' Source ' = ['@manual']\nEmpty = []\n";
|
||||||
|
fs::write(&path, original).unwrap();
|
||||||
|
for name in [
|
||||||
|
"",
|
||||||
|
" ",
|
||||||
|
"Bad\nName",
|
||||||
|
"Bad\u{7f}Name",
|
||||||
|
"Source",
|
||||||
|
" Source ",
|
||||||
|
"Empty",
|
||||||
|
] {
|
||||||
|
assert!(create_category(&path, name, &[]).is_err(), "{name:?}");
|
||||||
|
assert_eq!(fs::read(&path).unwrap(), original.as_bytes());
|
||||||
|
}
|
||||||
|
// Even an identical membership snapshot must not overwrite a category.
|
||||||
|
assert!(create_category(&path, "Source", &["@manual".to_owned()]).is_err());
|
||||||
|
assert_eq!(fs::read(&path).unwrap(), original.as_bytes());
|
||||||
|
for channel in [
|
||||||
|
"",
|
||||||
|
"bad",
|
||||||
|
"@bad handle",
|
||||||
|
"@manual\n",
|
||||||
|
"https://example.com/@manual",
|
||||||
|
"https://www.youtube.com/@manual/videos",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
create_category(&path, "New", &["@valid".to_owned(), channel.to_owned()]).is_err(),
|
||||||
|
"{channel:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(fs::read(&path).unwrap(), original.as_bytes());
|
||||||
|
}
|
||||||
|
for malformed in [
|
||||||
|
"[categories\n",
|
||||||
|
"categories = 3\n",
|
||||||
|
"[categories]\nSource = [7]\n",
|
||||||
|
"[categories]\nSource = []\n' Source ' = []\n",
|
||||||
|
"[channels]\n'@mapped' = '@other'\n",
|
||||||
|
"[settings]\npinned_categories = [7]\n",
|
||||||
|
"[keys.app]\nunknown = ['q']\n",
|
||||||
|
] {
|
||||||
|
fs::write(&path, malformed).unwrap();
|
||||||
|
assert!(create_category(&path, "New", &[]).is_err());
|
||||||
|
assert_eq!(fs::read(&path).unwrap(), malformed.as_bytes());
|
||||||
|
}
|
||||||
|
let missing = directory.0.join("missing/config.toml");
|
||||||
|
assert!(create_category(&missing, "Bad\nName", &[]).is_err());
|
||||||
|
assert!(create_category(&missing, "New", &["@bad handle".to_owned()]).is_err());
|
||||||
|
assert!(!missing.parent().unwrap().exists());
|
||||||
|
assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn category_rename_preserves_memberships_pins_comments_and_section_order() {
|
||||||
|
let directory = TestDirectory::new();
|
||||||
|
let path = directory.0.join("config.toml");
|
||||||
|
let original = "# manual preferences\n[other]\nvalue = 42\n[settings]\npinned_categories = [\n ' Source ', # favorite\n 'Keep',\n] # pin note\nplayer = 'mpv'\n[keys.app]\nquit = ['q'] # key note\n[channels]\n'@mapped' = 'UC4QobU6STFB0P71PMvOGN5A' # mapping note\n[categories]\nBefore = []\n# category note\n' Source ' = [\n '@mapped', # entry note\n '@manual',\n '@manual',\n] # array note\nKeep = ['@manual']\n";
|
||||||
|
fs::write(&path, original).unwrap();
|
||||||
|
let before = load(&path).unwrap();
|
||||||
|
let before_document = original.parse::<DocumentMut>().unwrap();
|
||||||
|
let renamed = rename_category(&path, " Source ", " Renamed group ").unwrap();
|
||||||
|
assert!(!renamed.categories.contains_key("Source"));
|
||||||
|
assert_eq!(
|
||||||
|
renamed.categories["Renamed group"],
|
||||||
|
before.categories["Source"]
|
||||||
|
);
|
||||||
|
assert_eq!(renamed.categories["Keep"], before.categories["Keep"]);
|
||||||
|
assert_eq!(renamed.channels, before.channels);
|
||||||
|
assert_eq!(renamed.subscriptions(), before.subscriptions());
|
||||||
|
assert_eq!(renamed.pinned_categories, ["Renamed group", "Keep"]);
|
||||||
|
let contents = fs::read_to_string(&path).unwrap();
|
||||||
|
let document = contents.parse::<DocumentMut>().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
document.iter().map(|(name, _)| name).collect::<Vec<_>>(),
|
||||||
|
["other", "settings", "keys", "channels", "categories"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
document["categories"]
|
||||||
|
.as_table_like()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|(name, _)| name)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
["Before", "Renamed group", "Keep"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
document["categories"]["Renamed group"].to_string(),
|
||||||
|
before_document["categories"][" Source "].to_string()
|
||||||
|
);
|
||||||
|
for section in ["other", "keys", "channels"] {
|
||||||
|
assert_eq!(
|
||||||
|
document[section].to_string(),
|
||||||
|
before_document[section].to_string()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(document["settings"]["player"].as_str(), Some("mpv"));
|
||||||
|
for comment in [
|
||||||
|
"# manual preferences",
|
||||||
|
"# favorite",
|
||||||
|
"# pin note",
|
||||||
|
"# category note",
|
||||||
|
"# entry note",
|
||||||
|
"# array note",
|
||||||
|
"# key note",
|
||||||
|
"# mapping note",
|
||||||
|
] {
|
||||||
|
assert!(contents.contains(comment), "lost {comment}");
|
||||||
|
}
|
||||||
|
assert_eq!(load(&path).unwrap().categories, renamed.categories);
|
||||||
|
assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn category_rename_preserves_inline_tables_and_reads_latest_memberships() {
|
||||||
|
let directory = TestDirectory::new();
|
||||||
|
let path = directory.0.join("config.toml");
|
||||||
|
let original = "categories = { Before = [], Source = ['@first', '@first'], 'Keep' = [] } # categories note\nsettings = { pinned_categories = ['Source'], custom = 42 }\n";
|
||||||
|
fs::write(&path, original).unwrap();
|
||||||
|
let opened = load(&path).unwrap();
|
||||||
|
let latest = original.replace("'@first', '@first'", "'@first', '@late', '@first'");
|
||||||
|
fs::write(&path, &latest).unwrap();
|
||||||
|
let renamed = rename_category(&path, "Source", "Renamed").unwrap();
|
||||||
|
assert_eq!(opened.categories["Source"].len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
renamed.categories["Renamed"],
|
||||||
|
[
|
||||||
|
"https://www.youtube.com/@first",
|
||||||
|
"https://www.youtube.com/@late",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert!(!renamed.categories.contains_key("Source"));
|
||||||
|
assert_eq!(renamed.pinned_categories, ["Renamed"]);
|
||||||
|
let contents = fs::read_to_string(&path).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
contents,
|
||||||
|
latest
|
||||||
|
.replace("Source =", "Renamed =")
|
||||||
|
.replace("'Source'", "\"Renamed\"")
|
||||||
|
);
|
||||||
|
let document = contents.parse::<DocumentMut>().unwrap();
|
||||||
|
assert!(document["categories"].is_inline_table());
|
||||||
|
assert_eq!(document["settings"]["custom"].as_integer(), Some(42));
|
||||||
|
assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn category_rename_rejects_invalid_missing_or_conflicting_names_without_writes() {
|
||||||
|
let directory = TestDirectory::new();
|
||||||
|
let path = directory.0.join("config.toml");
|
||||||
|
let original = "[settings]\npinned_categories = ['Source', 'Keep']\n[categories]\n' Source ' = ['@manual']\nKeep = []\n";
|
||||||
|
fs::write(&path, original).unwrap();
|
||||||
|
for (old, new) in [
|
||||||
|
("Source", ""),
|
||||||
|
("Source", " \t "),
|
||||||
|
("Source", "Bad\nName"),
|
||||||
|
("", "New"),
|
||||||
|
("Missing", "New"),
|
||||||
|
("Missing", "Missing"),
|
||||||
|
("Source", "Keep"),
|
||||||
|
("Source", " Keep "),
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
rename_category(&path, old, new).is_err(),
|
||||||
|
"{old:?} -> {new:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(fs::read(&path).unwrap(), original.as_bytes());
|
||||||
|
}
|
||||||
|
let unchanged = rename_category(&path, " Source ", "Source").unwrap();
|
||||||
|
assert_eq!(unchanged.categories, load(&path).unwrap().categories);
|
||||||
|
assert_eq!(fs::read(&path).unwrap(), original.as_bytes());
|
||||||
|
for invalid in [
|
||||||
|
"[settings]\npinned_categories = [7]\n[categories]\nSource = []\n",
|
||||||
|
"[settings]\npinned_categories = ['Source', 'New']\n[categories]\nSource = []\n",
|
||||||
|
"[keys.global]\nunknown = ['q']\n[categories]\nSource = []\n",
|
||||||
|
"[categories]\nSource = [7]\n",
|
||||||
|
"[categories]\nSource = []\n' Source ' = []\n",
|
||||||
|
] {
|
||||||
|
fs::write(&path, invalid).unwrap();
|
||||||
|
assert!(rename_category(&path, "Source", "New").is_err());
|
||||||
|
assert_eq!(fs::read(&path).unwrap(), invalid.as_bytes());
|
||||||
|
}
|
||||||
|
let missing = directory.0.join("missing/config.toml");
|
||||||
|
assert!(rename_category(&missing, "Source", "New").is_err());
|
||||||
|
assert!(!missing.parent().unwrap().exists());
|
||||||
|
assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn category_tags_apply_exact_set_and_preserve_unrelated_entries_and_preferences() {
|
fn category_tags_apply_exact_set_and_preserve_unrelated_entries_and_preferences() {
|
||||||
let directory = TestDirectory::new();
|
let directory = TestDirectory::new();
|
||||||
|
|||||||
+39
-4
@@ -22,6 +22,7 @@ pub enum Context {
|
|||||||
TagList,
|
TagList,
|
||||||
TagFilter,
|
TagFilter,
|
||||||
TagInput,
|
TagInput,
|
||||||
|
CategoryNameInput,
|
||||||
Help,
|
Help,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@ const ALL: &[Context] = &[
|
|||||||
Context::TagList,
|
Context::TagList,
|
||||||
Context::TagFilter,
|
Context::TagFilter,
|
||||||
Context::TagInput,
|
Context::TagInput,
|
||||||
|
Context::CategoryNameInput,
|
||||||
Context::Help,
|
Context::Help,
|
||||||
];
|
];
|
||||||
const VIEW: &[Context] = &[
|
const VIEW: &[Context] = &[
|
||||||
@@ -58,6 +60,7 @@ const TEXT: &[Context] = &[
|
|||||||
Context::SelectorInput,
|
Context::SelectorInput,
|
||||||
Context::TagFilter,
|
Context::TagFilter,
|
||||||
Context::TagInput,
|
Context::TagInput,
|
||||||
|
Context::CategoryNameInput,
|
||||||
];
|
];
|
||||||
const NON_TEXT: &[Context] = &[
|
const NON_TEXT: &[Context] = &[
|
||||||
Context::Normal,
|
Context::Normal,
|
||||||
@@ -102,7 +105,7 @@ actions! {
|
|||||||
Interrupt, "app.interrupt", "Exit immediately", ALL, ["ctrl+c"];
|
Interrupt, "app.interrupt", "Exit immediately", ALL, ["ctrl+c"];
|
||||||
Quit, "app.quit", "Quit the application", &[Context::Normal, Context::Info, Context::Description, Context::CollectionNormal, Context::CollectionInfo, Context::CollectionDescription, Context::Help], ["q"];
|
Quit, "app.quit", "Quit the application", &[Context::Normal, Context::Info, Context::Description, Context::CollectionNormal, Context::CollectionInfo, Context::CollectionDescription, Context::Help], ["q"];
|
||||||
Help, "help.toggle", "Open or close keyboard help", NON_TEXT, ["?"];
|
Help, "help.toggle", "Open or close keyboard help", NON_TEXT, ["?"];
|
||||||
InputSubmit, "input.submit", "Submit search, browse selector, leave tag filter or stage new tag", TEXT, ["enter"];
|
InputSubmit, "input.submit", "Submit search, browse selector, leave tag filter, stage new tag or save category name", TEXT, ["enter"];
|
||||||
InputCancel, "input.cancel", "Leave text input without submitting", TEXT, ["escape"];
|
InputCancel, "input.cancel", "Leave text input without submitting", TEXT, ["escape"];
|
||||||
DeleteCharacter, "input.delete.character", "Delete the previous character", TEXT, ["backspace"];
|
DeleteCharacter, "input.delete.character", "Delete the previous character", TEXT, ["backspace"];
|
||||||
DeleteWord, "input.delete.word", "Delete the previous word", TEXT, ["alt+backspace", "ctrl+w"];
|
DeleteWord, "input.delete.word", "Delete the previous word", TEXT, ["alt+backspace", "ctrl+w"];
|
||||||
@@ -126,6 +129,8 @@ actions! {
|
|||||||
VideoChannel, "video.channel", "Open the selected video's channel", VIEW, ["U"];
|
VideoChannel, "video.channel", "Open the selected video's channel", VIEW, ["U"];
|
||||||
ChannelTags, "channel.tags", "Edit the selected channel's category tags", VIEW, ["s"];
|
ChannelTags, "channel.tags", "Edit the selected channel's category tags", VIEW, ["s"];
|
||||||
CategoryTags, "category.tags", "Edit category tags for all channels in this category", VIEW, ["S"];
|
CategoryTags, "category.tags", "Edit category tags for all channels in this category", VIEW, ["S"];
|
||||||
|
CategoryRename, "category.rename", "Rename the current or selected category", &[Context::Normal, Context::CollectionNormal, Context::SelectorNormal, Context::TagList], ["R"];
|
||||||
|
CategorySave, "category.save", "Save the combined category view as a new category", VIEW, ["P"];
|
||||||
WatchLaterSave, "video.watch_later.save", "Save the selected video to watch later", VIEW, ["w"];
|
WatchLaterSave, "video.watch_later.save", "Save the selected video to watch later", VIEW, ["w"];
|
||||||
WatchLaterBrowse, "view.watch_later.browse", "Choose watch later across all categories or one category", VIEW, ["W"];
|
WatchLaterBrowse, "view.watch_later.browse", "Choose watch later across all categories or one category", VIEW, ["W"];
|
||||||
WatchLaterBrowseCategory, "view.watch_later.category", "Browse the current category's watch later", VIEW, ["alt+w"];
|
WatchLaterBrowseCategory, "view.watch_later.category", "Browse the current category's watch later", VIEW, ["alt+w"];
|
||||||
@@ -142,6 +147,7 @@ actions! {
|
|||||||
SelectorEdit, "selector.edit", "Edit the category, channel, or collection filter", &[Context::SelectorNormal], ["i"];
|
SelectorEdit, "selector.edit", "Edit the category, channel, or collection filter", &[Context::SelectorNormal], ["i"];
|
||||||
SelectorOpen, "selector.open", "Open the selected category, channel, or collection", &[Context::SelectorNormal], ["enter"];
|
SelectorOpen, "selector.open", "Open the selected category, channel, or collection", &[Context::SelectorNormal], ["enter"];
|
||||||
SelectorPin, "selector.pin", "Toggle category pinning (category picker only)", &[Context::SelectorNormal], ["p"];
|
SelectorPin, "selector.pin", "Toggle category pinning (category picker only)", &[Context::SelectorNormal], ["p"];
|
||||||
|
SelectorToggle, "selector.toggle", "Check a category for combined browsing (category picker only)", &[Context::SelectorNormal], ["space"];
|
||||||
SelectorClose, "selector.close", "Close the category, channel, or collection picker", &[Context::SelectorNormal], ["escape"];
|
SelectorClose, "selector.close", "Close the category, channel, or collection picker", &[Context::SelectorNormal], ["escape"];
|
||||||
TagNext, "tags.select.next", "Select the next category tag", &[Context::TagList], ["j", "down"];
|
TagNext, "tags.select.next", "Select the next category tag", &[Context::TagList], ["j", "down"];
|
||||||
TagPrevious, "tags.select.previous", "Select the previous category tag", &[Context::TagList], ["k", "up"];
|
TagPrevious, "tags.select.previous", "Select the previous category tag", &[Context::TagList], ["k", "up"];
|
||||||
@@ -478,6 +484,14 @@ impl Bindings {
|
|||||||
(&[CategoryBrowse], "Choose a saved category"),
|
(&[CategoryBrowse], "Choose a saved category"),
|
||||||
(&[ChannelTags], "Edit the selected channel's tags"),
|
(&[ChannelTags], "Edit the selected channel's tags"),
|
||||||
(&[CategoryTags], "Edit all channels in a category"),
|
(&[CategoryTags], "Edit all channels in a category"),
|
||||||
|
(
|
||||||
|
&[CategoryRename],
|
||||||
|
"Rename the current category (including scoped collections)",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
&[CategorySave],
|
||||||
|
"Save a combined category view as a new category",
|
||||||
|
),
|
||||||
(&[ViewTabNext, ViewTabPrevious], "Cycle visible tabs"),
|
(&[ViewTabNext, ViewTabPrevious], "Cycle visible tabs"),
|
||||||
(&[ViewPrevious], "Back (preview close takes priority)"),
|
(&[ViewPrevious], "Back (preview close takes priority)"),
|
||||||
(&[ViewNext], "Forward after going back"),
|
(&[ViewNext], "Forward after going back"),
|
||||||
@@ -563,11 +577,22 @@ impl Bindings {
|
|||||||
&[SelectorInputNext, SelectorInputPrevious],
|
&[SelectorInputNext, SelectorInputPrevious],
|
||||||
"Move while filtering",
|
"Move while filtering",
|
||||||
),
|
),
|
||||||
(&[SelectorOpen, InputSubmit], "Browse the selected match"),
|
(
|
||||||
|
&[SelectorOpen, InputSubmit],
|
||||||
|
"Browse checked categories, or the selected match",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
&[SelectorToggle],
|
||||||
|
"Check categories for a combined feed (NORMAL only)",
|
||||||
|
),
|
||||||
(
|
(
|
||||||
&[SelectorPin],
|
&[SelectorPin],
|
||||||
"Pin / unpin a category (category picker only)",
|
"Pin / unpin a category (category picker only)",
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
&[CategoryRename],
|
||||||
|
"Rename the selected category, not combined / channel rows",
|
||||||
|
),
|
||||||
(&[InputCancel, SelectorClose], "INSERT → NORMAL → close"),
|
(&[InputCancel, SelectorClose], "INSERT → NORMAL → close"),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -583,6 +608,10 @@ impl Bindings {
|
|||||||
),
|
),
|
||||||
(&[TagToggle], "Toggle a tag"),
|
(&[TagToggle], "Toggle a tag"),
|
||||||
(&[TagNew], "Enter a new tag name"),
|
(&[TagNew], "Enter a new tag name"),
|
||||||
|
(
|
||||||
|
&[CategoryRename],
|
||||||
|
"Rename a tag; existing category renames save immediately",
|
||||||
|
),
|
||||||
(&[TagSave], "Save tags"),
|
(&[TagSave], "Save tags"),
|
||||||
(&[InputSubmit], "Stage a new tag name"),
|
(&[InputSubmit], "Stage a new tag name"),
|
||||||
(&[TagCancel], "Cancel tag changes"),
|
(&[TagCancel], "Cancel tag changes"),
|
||||||
@@ -592,8 +621,14 @@ impl Bindings {
|
|||||||
(
|
(
|
||||||
"TEXT INPUT",
|
"TEXT INPUT",
|
||||||
&[
|
&[
|
||||||
(&[InputSubmit], "Submit; search returns to NORMAL"),
|
(
|
||||||
(&[InputCancel], "Leave INSERT / abandon tag name"),
|
&[InputSubmit],
|
||||||
|
"Submit search / stage tag / save category name",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
&[InputCancel],
|
||||||
|
"Leave INSERT / abandon tag or category name",
|
||||||
|
),
|
||||||
(&[DeleteCharacter], "Delete one character"),
|
(&[DeleteCharacter], "Delete one character"),
|
||||||
(&[DeleteWord], "Delete previous word"),
|
(&[DeleteWord], "Delete previous word"),
|
||||||
(&[DeleteLine], "Clear the line"),
|
(&[DeleteLine], "Clear the line"),
|
||||||
|
|||||||
+1664
-70
File diff suppressed because one or more lines are too long
@@ -196,6 +196,25 @@ pub fn full_description(output: &str, expected_id: &str) -> Result<String, Strin
|
|||||||
Ok(description)
|
Ok(description)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn channel_handle(output: &str, expected_id: &str) -> Result<String, String> {
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Channel {
|
||||||
|
channel_id: String,
|
||||||
|
uploader_id: Option<String>,
|
||||||
|
}
|
||||||
|
let channel: Channel = serde_json::from_str(output)
|
||||||
|
.map_err(|error| format!("Invalid channel metadata: {error}"))?;
|
||||||
|
if channel.channel_id != expected_id {
|
||||||
|
return Err("Channel metadata did not match the requested channel".into());
|
||||||
|
}
|
||||||
|
let handle = channel
|
||||||
|
.uploader_id
|
||||||
|
.filter(|handle| handle.starts_with('@'))
|
||||||
|
.ok_or_else(|| "This channel has no available handle; no channel was saved.".to_owned())?;
|
||||||
|
crate::config::normalize_channel(&handle).map_err(|error| error.to_string())?;
|
||||||
|
Ok(handle)
|
||||||
|
}
|
||||||
|
|
||||||
fn page_end(start: usize) -> io::Result<usize> {
|
fn page_end(start: usize) -> io::Result<usize> {
|
||||||
start
|
start
|
||||||
.checked_add(PAGE_SIZE - 1)
|
.checked_add(PAGE_SIZE - 1)
|
||||||
@@ -251,6 +270,22 @@ impl Job {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn channel(id: &str) -> io::Result<Self> {
|
||||||
|
if !valid_channel_id(id) {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::InvalidInput,
|
||||||
|
"Invalid YouTube channel ID",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Self::spawn(extractor().args([
|
||||||
|
"--flat-playlist",
|
||||||
|
"--playlist-end",
|
||||||
|
"1",
|
||||||
|
"--dump-single-json",
|
||||||
|
&format!("https://www.youtube.com/channel/{id}/videos"),
|
||||||
|
]))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn description(id: &str) -> io::Result<Self> {
|
pub fn description(id: &str) -> io::Result<Self> {
|
||||||
Self::spawn(extractor().args([
|
Self::spawn(extractor().args([
|
||||||
"--no-playlist",
|
"--no-playlist",
|
||||||
@@ -839,6 +874,26 @@ mod tests {
|
|||||||
assert_eq!(exhausted.videos.len(), 2);
|
assert_eq!(exhausted.videos.len(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn channel_handles_require_the_requested_identity_and_a_valid_handle() {
|
||||||
|
let id = "UChAQQ9at2MDlXjvQX3vL3Yw";
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
for handle in [
|
||||||
|
None,
|
||||||
|
Some("ch.1676"),
|
||||||
|
Some("@bad/handle"),
|
||||||
|
Some("@bad\u{1b}"),
|
||||||
|
] {
|
||||||
|
let metadata = serde_json::json!({"channel_id": id, "uploader_id": handle}).to_string();
|
||||||
|
assert!(channel_handle(&metadata, id).is_err());
|
||||||
|
}
|
||||||
|
assert!(channel_handle("not JSON", id).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn full_descriptions_preserve_paragraphs_and_reject_mismatched_videos() {
|
fn full_descriptions_preserve_paragraphs_and_reject_mismatched_videos() {
|
||||||
let metadata = r#"{"id":"jNQXAC9IVRw","description":"First paragraph.\n\n日本語\tsecond paragraph.\u001b\nLast line."}"#;
|
let metadata = r#"{"id":"jNQXAC9IVRw","description":"First paragraph.\n\n日本語\tsecond paragraph.\u001b\nLast line."}"#;
|
||||||
|
|||||||
Reference in New Issue
Block a user