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 && ( + + )} +
+
+ ); + })} +
+
+ +
+
+
+ )} ); }