feat: browse and save combined category feeds
This commit is contained in:
@@ -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 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` | Choose combined or category watch later from any video view or preview |
|
||||
| `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`)
|
||||
|
||||
- **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.
|
||||
- **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.
|
||||
- Reopening the browser clears its filter. No matches makes browsing, pinning, and renaming a no-op.
|
||||
- **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 the chosen categories; Esc returns to normal mode without clearing the filter. Space is ordinary input here.
|
||||
- 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`)
|
||||
|
||||
@@ -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.
|
||||
|
||||
- **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`.
|
||||
- Temporary tabs do not survive restarting; saved categories and pins do. Hidden views retain loaded rows, selection, and pagination during the session.
|
||||
|
||||
|
||||
+217
@@ -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> {
|
||||
let old = category_name(old)?;
|
||||
let new = category_name(new)?;
|
||||
@@ -873,6 +920,9 @@ mod tests {
|
||||
let pin_error = set_category_pinned(&path, "B", true).unwrap_err();
|
||||
assert_eq!(pin_error.kind(), io::ErrorKind::InvalidData);
|
||||
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]
|
||||
fn category_rename_preserves_memberships_pins_comments_and_section_order() {
|
||||
let directory = TestDirectory::new();
|
||||
|
||||
+23
-7
@@ -22,7 +22,7 @@ pub enum Context {
|
||||
TagList,
|
||||
TagFilter,
|
||||
TagInput,
|
||||
RenameInput,
|
||||
CategoryNameInput,
|
||||
Help,
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ const ALL: &[Context] = &[
|
||||
Context::TagList,
|
||||
Context::TagFilter,
|
||||
Context::TagInput,
|
||||
Context::RenameInput,
|
||||
Context::CategoryNameInput,
|
||||
Context::Help,
|
||||
];
|
||||
const VIEW: &[Context] = &[
|
||||
@@ -60,7 +60,7 @@ const TEXT: &[Context] = &[
|
||||
Context::SelectorInput,
|
||||
Context::TagFilter,
|
||||
Context::TagInput,
|
||||
Context::RenameInput,
|
||||
Context::CategoryNameInput,
|
||||
];
|
||||
const NON_TEXT: &[Context] = &[
|
||||
Context::Normal,
|
||||
@@ -105,7 +105,7 @@ actions! {
|
||||
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"];
|
||||
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"];
|
||||
DeleteCharacter, "input.delete.character", "Delete the previous character", TEXT, ["backspace"];
|
||||
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"];
|
||||
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"];
|
||||
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"];
|
||||
@@ -146,6 +147,7 @@ actions! {
|
||||
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"];
|
||||
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"];
|
||||
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"];
|
||||
@@ -486,6 +488,10 @@ impl Bindings {
|
||||
&[CategoryRename],
|
||||
"Rename the current category (including scoped collections)",
|
||||
),
|
||||
(
|
||||
&[CategorySave],
|
||||
"Save a combined category view as a new category",
|
||||
),
|
||||
(&[ViewTabNext, ViewTabPrevious], "Cycle visible tabs"),
|
||||
(&[ViewPrevious], "Back (preview close takes priority)"),
|
||||
(&[ViewNext], "Forward after going back"),
|
||||
@@ -571,7 +577,14 @@ impl Bindings {
|
||||
&[SelectorInputNext, SelectorInputPrevious],
|
||||
"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],
|
||||
"Pin / unpin a category (category picker only)",
|
||||
@@ -610,9 +623,12 @@ impl Bindings {
|
||||
&[
|
||||
(
|
||||
&[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"),
|
||||
(&[DeleteWord], "Delete previous word"),
|
||||
(&[DeleteLine], "Clear the line"),
|
||||
|
||||
+867
-142
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user