feat: rename categories from views and selectors
This commit is contained in:
+212
@@ -384,6 +384,83 @@ pub fn set_category_pinned(path: &Path, name: &str, pinned: bool) -> io::Result<
|
||||
})
|
||||
}
|
||||
|
||||
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>> {
|
||||
if document.contains_key("pinned_categories") {
|
||||
return Err(invalid("Move top-level pinned_categories into [settings]"));
|
||||
@@ -912,6 +989,141 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[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]
|
||||
fn category_tags_apply_exact_set_and_preserve_unrelated_entries_and_preferences() {
|
||||
let directory = TestDirectory::new();
|
||||
|
||||
+22
-3
@@ -22,6 +22,7 @@ pub enum Context {
|
||||
TagList,
|
||||
TagFilter,
|
||||
TagInput,
|
||||
RenameInput,
|
||||
Help,
|
||||
}
|
||||
|
||||
@@ -38,6 +39,7 @@ const ALL: &[Context] = &[
|
||||
Context::TagList,
|
||||
Context::TagFilter,
|
||||
Context::TagInput,
|
||||
Context::RenameInput,
|
||||
Context::Help,
|
||||
];
|
||||
const VIEW: &[Context] = &[
|
||||
@@ -58,6 +60,7 @@ const TEXT: &[Context] = &[
|
||||
Context::SelectorInput,
|
||||
Context::TagFilter,
|
||||
Context::TagInput,
|
||||
Context::RenameInput,
|
||||
];
|
||||
const NON_TEXT: &[Context] = &[
|
||||
Context::Normal,
|
||||
@@ -102,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 or stage new tag", TEXT, ["enter"];
|
||||
InputSubmit, "input.submit", "Submit search, browse selector, leave tag filter, stage new tag or rename category", 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"];
|
||||
@@ -126,6 +129,7 @@ actions! {
|
||||
VideoChannel, "video.channel", "Open the selected video's channel", VIEW, ["U"];
|
||||
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"];
|
||||
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"];
|
||||
@@ -478,6 +482,10 @@ impl Bindings {
|
||||
(&[CategoryBrowse], "Choose a saved category"),
|
||||
(&[ChannelTags], "Edit the selected channel's tags"),
|
||||
(&[CategoryTags], "Edit all channels in a category"),
|
||||
(
|
||||
&[CategoryRename],
|
||||
"Rename the current category (including scoped collections)",
|
||||
),
|
||||
(&[ViewTabNext, ViewTabPrevious], "Cycle visible tabs"),
|
||||
(&[ViewPrevious], "Back (preview close takes priority)"),
|
||||
(&[ViewNext], "Forward after going back"),
|
||||
@@ -568,6 +576,10 @@ impl Bindings {
|
||||
&[SelectorPin],
|
||||
"Pin / unpin a category (category picker only)",
|
||||
),
|
||||
(
|
||||
&[CategoryRename],
|
||||
"Rename the selected category, not combined / channel rows",
|
||||
),
|
||||
(&[InputCancel, SelectorClose], "INSERT → NORMAL → close"),
|
||||
],
|
||||
),
|
||||
@@ -583,6 +595,10 @@ impl Bindings {
|
||||
),
|
||||
(&[TagToggle], "Toggle a tag"),
|
||||
(&[TagNew], "Enter a new tag name"),
|
||||
(
|
||||
&[CategoryRename],
|
||||
"Rename a tag; existing category renames save immediately",
|
||||
),
|
||||
(&[TagSave], "Save tags"),
|
||||
(&[InputSubmit], "Stage a new tag name"),
|
||||
(&[TagCancel], "Cancel tag changes"),
|
||||
@@ -592,8 +608,11 @@ impl Bindings {
|
||||
(
|
||||
"TEXT INPUT",
|
||||
&[
|
||||
(&[InputSubmit], "Submit; search returns to NORMAL"),
|
||||
(&[InputCancel], "Leave INSERT / abandon tag name"),
|
||||
(
|
||||
&[InputSubmit],
|
||||
"Submit search / stage tag / commit category rename",
|
||||
),
|
||||
(&[InputCancel], "Leave INSERT / abandon tag name or rename"),
|
||||
(&[DeleteCharacter], "Delete one character"),
|
||||
(&[DeleteWord], "Delete previous word"),
|
||||
(&[DeleteLine], "Clear the line"),
|
||||
|
||||
+813
-9
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user