From 21d78b8f3ad0739d0897ee5c693273c9fae89def Mon Sep 17 00:00:00 2001 From: Renamr Scaffold Date: Sat, 29 Aug 2026 09:39:52 +0000 Subject: [PATCH] Add Align Up/Down list reordering and naming-scheme Presets Align Up/Down now swaps the selected file with its neighbor in the shared item list (Original Files and New Names stay positionally paired, matching the original FileBot's "align" buttons for manually lining up mismatched lists). Naming-scheme Presets: up to 9 saved expressions, one per digit slot, persisted as presets.json (src-tauri/src/presets.rs, same load/save pattern as settings.rs/history.rs). A new Presets panel lists all 9 slots with Save Current / Apply / Clear, and a global 1-9 keydown handler applies a slot instantly from anywhere in the app (ignored while a text field is focused or a modal is open). --- README.md | 23 +++-- src-tauri/src/lib.rs | 4 + src-tauri/src/presets.rs | 105 +++++++++++++++++++ src/components/RenamePanel.css | 77 ++++++++++++++ src/components/RenamePanel.tsx | 180 ++++++++++++++++++++++++++++++++- 5 files changed, 376 insertions(+), 13 deletions(-) create mode 100644 src-tauri/src/presets.rs diff --git a/README.md b/README.md index 0738923..82b5d53 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,10 @@ video/audio codec, release group — so naming schemes can reference `{resolution}`/`{source}`/`{video codec}`/`{audio codec}`/`{release group}` (or FileBot's own `{vf}`/`{source}`/`{vc}`/`{ac}`/`{group}`) without a media prober; the rest of FileBot's full media-metadata bindings (real probed video/audio stream info) -are not yet ported. See "Porting roadmap" below. +are not yet ported. The Align Up/Down buttons reorder the (shared) file list — the two +panes always stay positionally paired — and up to 9 naming schemes can be saved as +presets, recalled with a click or a `1`-`9` keypress (persisted locally, same pattern as +Settings/History). See "Porting roadmap" below. The naming scheme is fully user-editable from the app (the "Naming Scheme" button next to the New Names list) — the default is `{show name} - {season number}x{episode @@ -136,15 +139,18 @@ src-tauri/ Tauri app shell: exposes renamr-core as commands to the f movies, TMDB metadata fetch — `metadata.rs` for TV episodes, plus a movie-fetch command alongside it — executing a rename plan, native file/folder dialogs, drag-and-drop expansion), plus - `settings.rs` (TMDB token) and `history.rs` (rename history/undo - log) — both stored as JSON in the OS app-config directory — app + `settings.rs` (TMDB token), `history.rs` (rename history/undo + log), and `presets.rs` (up to 9 saved naming schemes) — all three + stored as JSON in the OS app-config directory — app metadata/icons/window config (tauri.conf.json). src/ React + TypeScript UI (Vite). Faithful recreation of the original two-pane rename screen (Aurora theme) wired to real file loading, drag-and-drop, live naming-scheme preview, local TV/movie matching, TMDB fetch for both (behind a Settings modal for the API token), a confirm-before-touching-disk modal for the actual - rename, and a History panel that can undo a past rename. + rename, a History panel that can undo a past rename, Align Up/Down + list reordering, and a Presets panel (plus `1`-`9` hotkeys) for + saved naming schemes. scripts/gen_icons.py Regenerates src-tauri/icons/ (placeholder branding — swap the colors/glyph in the script, or replace the icons directly, to change the look). @@ -250,10 +256,11 @@ ported from, for anyone picking this up: Not yet done: the `--conflict` variants beyond skip (auto-number, override), and the CLI flag surface itself. 7. **The rest of the UI** — the Rename panel (file loading, drag-drop, live preview, - local TV/movie matching, confirmed rename with per-item exclude, rename history/undo) - is built; the other five sidebar panels (Episodes/Subtitles/SFV/Filter/List) are - still stub screens, plus Align up/down reordering and Presets on the Rename panel - itself are still no-ops. + local TV/movie matching, confirmed rename with per-item exclude, rename history/undo, + Align Up/Down reordering, naming-scheme Presets with `1`-`9` hotkeys) is built; the + other five sidebar panels (Episodes/Subtitles/SFV/Filter/List) are still stub + screens. Align Up/Down is button-based (swaps the selected file with its neighbor), + not the original's drag-and-drop-within-a-list interaction. 8. **Undo/history for renames** — done, as a flat JSON-backed undo stack rather than a full port of `net.filebot.HistoryDialog`'s session/element log (`crates/renamr-core/src/history`, `src-tauri/src/history.rs`). Every successful diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 24fcb79..03d2ecd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ mod files; mod history; mod metadata; +mod presets; mod settings; use renamr_core::format::ExpressionFormat; @@ -87,6 +88,9 @@ pub fn run() { settings::save_tmdb_token, history::list_rename_history, history::undo_rename_batch, + presets::list_presets, + presets::save_preset, + presets::delete_preset, files::pick_files, files::pick_folder, files::expand_dropped_paths, diff --git a/src-tauri/src/presets.rs b/src-tauri/src/presets.rs new file mode 100644 index 0000000..60ef162 --- /dev/null +++ b/src-tauri/src/presets.rs @@ -0,0 +1,105 @@ +//! Naming-scheme presets: up to 9 saved expressions, one per digit slot ("1".."9"), +//! recalled with a single keypress in the UI (mirrors the original FileBot's +//! `PresetEditor`/1-9 hotkey behavior in `RenamePanel.java`). Stored as plain JSON in +//! Tauri's per-OS app config directory, same load/save pattern as `settings.rs` and +//! `history.rs` - `presets.json` alongside `settings.json`/`history.json`. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; +use tauri::{AppHandle, Manager}; + +#[derive(Debug, Default, Serialize, Deserialize)] +struct StoredPresets { + #[serde(default)] + slots: HashMap, +} + +/// A slot key is exactly one ASCII digit `1`-`9` ("0" and multi-character keys are +/// rejected - there are only 9 digit keys to bind hotkeys to, matching the original's +/// 1-9 preset shortcuts). +fn is_valid_slot(slot: &str) -> bool { + matches!(slot.as_bytes(), [b] if (b'1'..=b'9').contains(b)) +} + +fn presets_path(app: &AppHandle) -> Result { + let dir = app + .path() + .app_config_dir() + .map_err(|e| format!("couldn't resolve the app config directory: {e}"))?; + fs::create_dir_all(&dir).map_err(|e| format!("couldn't create {}: {e}", dir.display()))?; + Ok(dir.join("presets.json")) +} + +fn load(app: &AppHandle) -> StoredPresets { + let Ok(path) = presets_path(app) else { + return StoredPresets::default(); + }; + fs::read_to_string(path) + .ok() + .and_then(|contents| serde_json::from_str(&contents).ok()) + .unwrap_or_default() +} + +fn save(app: &AppHandle, presets: &StoredPresets) -> Result<(), String> { + let path = presets_path(app)?; + let json = serde_json::to_string_pretty(presets).map_err(|e| e.to_string())?; + fs::write(&path, json).map_err(|e| format!("couldn't write {}: {e}", path.display())) +} + +/// Lists every saved preset slot -> naming-scheme expression. Slots with nothing saved +/// simply aren't present in the map - the frontend renders those as empty. +#[tauri::command] +pub fn list_presets(app: AppHandle) -> HashMap { + load(&app).slots +} + +/// Saves (or overwrites) the naming-scheme expression at one digit slot. +#[tauri::command] +pub fn save_preset(app: AppHandle, slot: String, expression: String) -> Result<(), String> { + if !is_valid_slot(&slot) { + return Err("Preset slots are the digits 1-9.".to_string()); + } + let trimmed = expression.trim(); + if trimmed.is_empty() { + return Err("Can't save an empty naming scheme as a preset.".to_string()); + } + + let mut presets = load(&app); + presets.slots.insert(slot, trimmed.to_string()); + save(&app, &presets) +} + +/// Clears one digit slot, if anything was saved there. +#[tauri::command] +pub fn delete_preset(app: AppHandle, slot: String) -> Result<(), String> { + if !is_valid_slot(&slot) { + return Err("Preset slots are the digits 1-9.".to_string()); + } + + let mut presets = load(&app); + presets.slots.remove(&slot); + save(&app, &presets) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_every_digit_one_through_nine() { + for d in '1'..='9' { + assert!(is_valid_slot(&d.to_string()), "slot {d} should be valid"); + } + } + + #[test] + fn rejects_zero_multi_digit_and_non_digit_slots() { + assert!(!is_valid_slot("0")); + assert!(!is_valid_slot("10")); + assert!(!is_valid_slot("")); + assert!(!is_valid_slot("a")); + assert!(!is_valid_slot("99")); + } +} diff --git a/src/components/RenamePanel.css b/src/components/RenamePanel.css index a22bffc..9a7ec86 100644 --- a/src/components/RenamePanel.css +++ b/src/components/RenamePanel.css @@ -568,6 +568,83 @@ gap: 2px; } +.preset-row { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + padding: 6px 8px; + border-radius: 8px; +} + +.preset-row:hover { + background: rgba(255, 255, 255, 0.03); +} + +.preset-slot-badge { + flex: 0 0 auto; + width: 22px; + height: 22px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 6px; + background: rgba(255, 255, 255, 0.06); + color: var(--aurora-green); + font-size: 11.5px; + font-weight: 700; +} + +.preset-expression { + all: unset; + box-sizing: border-box; + flex: 1 1 0; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding: 6px 8px; + border-radius: 6px; + font-family: ui-monospace, "SF Mono", monospace; + font-size: 12px; + color: var(--text); + cursor: pointer; +} + +.preset-expression:hover { + background: rgba(255, 255, 255, 0.06); +} + +.preset-expression:disabled { + cursor: default; + pointer-events: none; +} + +.preset-expression-empty { + color: var(--muted-text); + font-style: italic; + cursor: default; +} + +.preset-row-actions { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 6px; +} + +.preset-save-button { + width: auto; + height: 26px; + padding: 0 10px; + font-size: 11px; +} + +.preset-clear-button { + width: 26px; + height: 26px; +} + .status-bar { position: absolute; left: 18px; diff --git a/src/components/RenamePanel.tsx b/src/components/RenamePanel.tsx index 1d69667..87f29ed 100644 --- a/src/components/RenamePanel.tsx +++ b/src/components/RenamePanel.tsx @@ -385,6 +385,23 @@ export default function RenamePanel() { const [historyOpen, setHistoryOpen] = useState(false); const [historyBatches, setHistoryBatches] = useState(null); const [undoingBatchId, setUndoingBatchId] = useState(null); + // Naming-scheme presets: up to 9 saved expressions, one per digit slot, persisted on + // disk (see `src-tauri/src/presets.rs`) and recallable with a single 1-9 keypress + // (see the keydown listener below) as well as from the Presets panel. `null` = not + // loaded yet. + const [presets, setPresets] = useState | null>(null); + const [presetsOpen, setPresetsOpen] = useState(false); + const [presetsBusySlot, setPresetsBusySlot] = useState(null); + + const loadPresets = () => { + invoke>("list_presets") + .then(setPresets) + .catch(() => setPresets({})); + }; + + useEffect(() => { + loadPresets(); + }, []); useEffect(() => { invoke("get_tmdb_token") @@ -1029,6 +1046,98 @@ export default function RenamePanel() { setSelected(null); }; + // "Align Up"/"Align Down": swaps the selected file with its neighbor in `items`. + // Both list panes render the same underlying array in the same order (that's how a + // row in Original Files stays paired with its New Names preview), so this one + // handler reorders both panes at once - matching the original FileBot's "align" + // buttons, used to manually line files up when local matching gets them out of order. + const moveSelected = (direction: "up" | "down") => { + if (!selected) { + setStatus("Select a file first, then Align Up/Down moves it within the list."); + window.clearTimeout((moveSelected as any)._t); + (moveSelected as any)._t = window.setTimeout(() => setStatus(null), 3200); + return; + } + setItems((prev) => { + const idx = prev.findIndex((i) => i.id === selected); + if (idx === -1) return prev; + const swapWith = direction === "up" ? idx - 1 : idx + 1; + if (swapWith < 0 || swapWith >= prev.length) return prev; // already at that edge + const next = [...prev]; + [next[idx], next[swapWith]] = [next[swapWith], next[idx]]; + return next; + }); + }; + + // Presets panel + 1-9 hotkeys. Applying a preset just swaps the naming-scheme + // expression (same as typing a new one into the format editor) - it doesn't touch + // `items` at all, so the live preview recomputes for whatever's already loaded. + const openPresets = () => { + setPresetsOpen(true); + loadPresets(); + }; + + const closePresets = () => { + if (presetsBusySlot) return; + setPresetsOpen(false); + }; + + const applyPreset = (slot: string) => { + const value = presets?.[slot]; + if (!value) return; + setExpression(value); + setStatus(`Applied preset ${slot}.`); + window.clearTimeout((applyPreset as any)._t); + (applyPreset as any)._t = window.setTimeout(() => setStatus(null), 2500); + }; + + const saveCurrentAsPreset = async (slot: string) => { + setPresetsBusySlot(slot); + try { + await invoke("save_preset", { slot, expression }); + setPresets((prev) => ({ ...(prev ?? {}), [slot]: expression })); + } catch (err) { + setStatus(`Couldn't save preset ${slot}: ${err}`); + } finally { + setPresetsBusySlot(null); + } + }; + + const deletePreset = async (slot: string) => { + setPresetsBusySlot(slot); + try { + await invoke("delete_preset", { slot }); + setPresets((prev) => { + if (!prev) return prev; + const { [slot]: _removed, ...rest } = prev; + return rest; + }); + } catch (err) { + setStatus(`Couldn't clear preset ${slot}: ${err}`); + } finally { + setPresetsBusySlot(null); + } + }; + + // Global 1-9 hotkeys, matching the original's preset shortcuts. Ignored while any + // modal is open or while the user is typing in a text field (the naming-scheme + // input itself is the obvious case - typing "5" into it shouldn't jump presets). + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (!/^[1-9]$/.test(e.key)) return; + if (e.metaKey || e.ctrlKey || e.altKey) return; + const target = e.target as HTMLElement | null; + if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable)) { + return; + } + if (renamePlan || settingsOpen || historyOpen || presetsOpen) return; + applyPreset(e.key); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [presets, renamePlan, settingsOpen, historyOpen, presetsOpen]); + const matchedCount = useMemo(() => items.filter((i) => i.bindings).length, [items]); const renderOriginalRow = (item: Item) => ( @@ -1067,14 +1176,14 @@ export default function RenamePanel() { emptyHint="Drop files or folders here, or click Load" buttonRow={ <> - } label="Align Down" onClick={() => notImplemented("Reordering")} /> - } label="Align Up" onClick={() => notImplemented("Reordering")} /> + } label="Align Down" onClick={() => moveSelected("down")} /> + } label="Align Up" onClick={() => moveSelected("up")} /> } label="Exclude Selected" onClick={removeSelected} /> } label="Clear" onClick={clearAll} /> } label="History" onClick={openHistory} /> - } label="Presets" onClick={() => notImplemented("Presets")} /> + } label="Presets" onClick={openPresets} /> } /> @@ -1095,8 +1204,8 @@ export default function RenamePanel() { emptyHint="Naming-scheme preview appears here" buttonRow={ <> - } label="Align Down" onClick={() => notImplemented("Reordering")} /> - } label="Align Up" onClick={() => notImplemented("Reordering")} /> + } label="Align Down" onClick={() => moveSelected("down")} /> + } label="Align Up" onClick={() => moveSelected("up")} /> } label="Fetch Data" onClick={runFetch} disabled={fetching} /> @@ -1374,6 +1483,67 @@ export default function RenamePanel() { )} + + {presetsOpen && ( +
+
e.stopPropagation()}> +

Naming Scheme Presets

+

+ Save up to 9 naming schemes and jump between them instantly — click a saved one below, or just press + its number key (1-9) anywhere in the app while no text field is focused.{" "} + Save Current stores whatever naming scheme is active right now into that slot, + overwriting anything already there. +

+
+ {Array.from({ length: 9 }, (_, i) => String(i + 1)).map((slot) => { + const saved = presets?.[slot]; + const busy = presetsBusySlot === slot; + return ( +
+ {slot} + {saved ? ( + + ) : ( + Unused + )} +
+ + {saved && ( + + )} +
+
+ ); + })} +
+
+ +
+
+
+ )} ); }