feat: browse and save combined category feeds

This commit is contained in:
2026-10-11 23:30:11 +02:00
parent ff7590dfa7
commit b53b57ee17
4 changed files with 1143 additions and 172 deletions
+17 -4
View File
@@ -71,6 +71,7 @@ These are the default bindings. Overrides in `[keys]` replace an action's bindin
| `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 | | `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 |
@@ -112,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; `R` renames it; 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, pinning, and renaming 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`)
@@ -178,7 +191,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.
+217
View File
@@ -384,6 +384,53 @@ 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> { pub fn rename_category(path: &Path, old: &str, new: &str) -> io::Result<Config> {
let old = category_name(old)?; let old = category_name(old)?;
let new = category_name(new)?; let new = category_name(new)?;
@@ -873,6 +920,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());
} }
} }
@@ -989,6 +1039,173 @@ 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] #[test]
fn category_rename_preserves_memberships_pins_comments_and_section_order() { fn category_rename_preserves_memberships_pins_comments_and_section_order() {
let directory = TestDirectory::new(); let directory = TestDirectory::new();
+23 -7
View File
@@ -22,7 +22,7 @@ pub enum Context {
TagList, TagList,
TagFilter, TagFilter,
TagInput, TagInput,
RenameInput, CategoryNameInput,
Help, Help,
} }
@@ -39,7 +39,7 @@ const ALL: &[Context] = &[
Context::TagList, Context::TagList,
Context::TagFilter, Context::TagFilter,
Context::TagInput, Context::TagInput,
Context::RenameInput, Context::CategoryNameInput,
Context::Help, Context::Help,
]; ];
const VIEW: &[Context] = &[ const VIEW: &[Context] = &[
@@ -60,7 +60,7 @@ const TEXT: &[Context] = &[
Context::SelectorInput, Context::SelectorInput,
Context::TagFilter, Context::TagFilter,
Context::TagInput, Context::TagInput,
Context::RenameInput, Context::CategoryNameInput,
]; ];
const NON_TEXT: &[Context] = &[ const NON_TEXT: &[Context] = &[
Context::Normal, Context::Normal,
@@ -105,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, stage new tag or rename category", 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"];
@@ -130,6 +130,7 @@ actions! {
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"]; 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"];
@@ -146,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"];
@@ -486,6 +488,10 @@ impl Bindings {
&[CategoryRename], &[CategoryRename],
"Rename the current category (including scoped collections)", "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"),
@@ -571,7 +577,14 @@ 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)",
@@ -610,9 +623,12 @@ impl Bindings {
&[ &[
( (
&[InputSubmit], &[InputSubmit],
"Submit search / stage tag / commit category rename", "Submit search / stage tag / save category name",
),
(
&[InputCancel],
"Leave INSERT / abandon tag or category name",
), ),
(&[InputCancel], "Leave INSERT / abandon tag name or rename"),
(&[DeleteCharacter], "Delete one character"), (&[DeleteCharacter], "Delete one character"),
(&[DeleteWord], "Delete previous word"), (&[DeleteWord], "Delete previous word"),
(&[DeleteLine], "Clear the line"), (&[DeleteLine], "Clear the line"),
+886 -161
View File
File diff suppressed because one or more lines are too long