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).
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<String, String>,
|
||||
}
|
||||
|
||||
/// 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<PathBuf, String> {
|
||||
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<String, String> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -385,6 +385,23 @@ export default function RenamePanel() {
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [historyBatches, setHistoryBatches] = useState<HistoryBatch[] | null>(null);
|
||||
const [undoingBatchId, setUndoingBatchId] = useState<string | null>(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<Record<string, string> | null>(null);
|
||||
const [presetsOpen, setPresetsOpen] = useState(false);
|
||||
const [presetsBusySlot, setPresetsBusySlot] = useState<string | null>(null);
|
||||
|
||||
const loadPresets = () => {
|
||||
invoke<Record<string, string>>("list_presets")
|
||||
.then(setPresets)
|
||||
.catch(() => setPresets({}));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadPresets();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
invoke<string | null>("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={
|
||||
<>
|
||||
<ListButton icon={<DownIcon />} label="Align Down" onClick={() => notImplemented("Reordering")} />
|
||||
<ListButton icon={<UpIcon />} label="Align Up" onClick={() => notImplemented("Reordering")} />
|
||||
<ListButton icon={<DownIcon />} label="Align Down" onClick={() => moveSelected("down")} />
|
||||
<ListButton icon={<UpIcon />} label="Align Up" onClick={() => moveSelected("up")} />
|
||||
<LoadMenuButton onFiles={loadFiles} onFolder={loadFolder} />
|
||||
<span className="list-button-gap" />
|
||||
<ListButton icon={<RemoveIcon />} label="Exclude Selected" onClick={removeSelected} />
|
||||
<ListButton icon={<ClearIcon />} label="Clear" onClick={clearAll} />
|
||||
<ListButton icon={<HistoryIcon />} label="History" onClick={openHistory} />
|
||||
<ListButton icon={<PresetsIcon />} label="Presets" onClick={() => notImplemented("Presets")} />
|
||||
<ListButton icon={<PresetsIcon />} label="Presets" onClick={openPresets} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
@@ -1095,8 +1204,8 @@ export default function RenamePanel() {
|
||||
emptyHint="Naming-scheme preview appears here"
|
||||
buttonRow={
|
||||
<>
|
||||
<ListButton icon={<DownIcon />} label="Align Down" onClick={() => notImplemented("Reordering")} />
|
||||
<ListButton icon={<UpIcon />} label="Align Up" onClick={() => notImplemented("Reordering")} />
|
||||
<ListButton icon={<DownIcon />} label="Align Down" onClick={() => moveSelected("down")} />
|
||||
<ListButton icon={<UpIcon />} label="Align Up" onClick={() => moveSelected("up")} />
|
||||
<LoadMenuButton onFiles={loadFiles} onFolder={loadFolder} />
|
||||
<span className="list-button-gap" />
|
||||
<ListButton icon={<FetchIcon />} label="Fetch Data" onClick={runFetch} disabled={fetching} />
|
||||
@@ -1374,6 +1483,67 @@ export default function RenamePanel() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{presetsOpen && (
|
||||
<div className="modal-overlay" onClick={closePresets}>
|
||||
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
|
||||
<h2 className="modal-title">Naming Scheme Presets</h2>
|
||||
<p className="modal-subtitle">
|
||||
Save up to 9 naming schemes and jump between them instantly — click a saved one below, or just press
|
||||
its number key (<code>1</code>-<code>9</code>) anywhere in the app while no text field is focused.{" "}
|
||||
<strong>Save Current</strong> stores whatever naming scheme is active right now into that slot,
|
||||
overwriting anything already there.
|
||||
</p>
|
||||
<div className="modal-plan-list history-batch-list">
|
||||
{Array.from({ length: 9 }, (_, i) => String(i + 1)).map((slot) => {
|
||||
const saved = presets?.[slot];
|
||||
const busy = presetsBusySlot === slot;
|
||||
return (
|
||||
<div className="preset-row" key={slot}>
|
||||
<span className="preset-slot-badge">{slot}</span>
|
||||
{saved ? (
|
||||
<button
|
||||
className="preset-expression"
|
||||
title={`Apply: ${saved}`}
|
||||
onClick={() => applyPreset(slot)}
|
||||
disabled={busy}
|
||||
>
|
||||
{saved}
|
||||
</button>
|
||||
) : (
|
||||
<span className="preset-expression preset-expression-empty">Unused</span>
|
||||
)}
|
||||
<div className="preset-row-actions">
|
||||
<button
|
||||
className="action-button preset-save-button"
|
||||
onClick={() => saveCurrentAsPreset(slot)}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? "…" : "Save Current"}
|
||||
</button>
|
||||
{saved && (
|
||||
<button
|
||||
className="list-icon-button preset-clear-button"
|
||||
title={`Clear preset ${slot}`}
|
||||
onClick={() => deletePreset(slot)}
|
||||
disabled={busy}
|
||||
>
|
||||
<RemoveIcon />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button className="action-button" onClick={closePresets} disabled={presetsBusySlot !== null}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user