feat: browse and save combined category feeds
This commit is contained in:
+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"),
|
||||
|
||||
+886
-161
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user