Add local season/episode matching and on-disk rename (skip-on-conflict, confirm-first)

This commit is contained in:
Renamr Scaffold
2026-08-29 02:42:31 +00:00
parent d0c29048be
commit bab9bfa1ec
9 changed files with 851 additions and 24 deletions
+261 -5
View File
@@ -26,6 +26,46 @@ interface FileEntry {
ext: string;
}
// Result of the local, filename-only season/episode matcher (renamr-core's
// `matching` module, exposed via the `guess_episode` Tauri command). No network calls -
// this only recognizes patterns already present in the filename text itself.
interface EpisodeGuess {
seriesName: string;
season: number;
episode: number;
}
function pad2(n: number): string {
return n.toString().padStart(2, "0");
}
// One row of the on-disk rename plan shown in the confirmation modal before anything
// touches the filesystem. `id`/`source` are the file's current full path.
interface RenamePlanEntry {
id: string;
source: string;
currentName: string;
newName: string;
}
// Mirrors renamr-core's `RenameOutcome` (serde-tagged on "status"). Conflict policy is
// fixed at skip - a colliding target is never overwritten, just reported.
type RenameOutcome =
| { status: "renamed"; newPath: string }
| { status: "unchanged" }
| { status: "skippedConflict"; existingPath: string }
| { status: "failed"; error: string };
interface RenameResult {
source: string;
outcome: RenameOutcome;
}
function fileNameFromPath(path: string): string {
const parts = path.split(/[\\/]/);
return parts[parts.length - 1] ?? path;
}
// Shown until the user loads real files, so the two-pane layout and live naming-scheme
// preview are visible immediately. Item 4 demonstrates the "no data" muted row style.
// This is what full metadata-matched renaming will look like once the matching engine
@@ -142,6 +182,10 @@ export default function RenamePanel() {
const [computedNames, setComputedNames] = useState<Record<string, string>>({});
const [computeError, setComputeError] = useState<string | null>(null);
const [status, setStatus] = useState<string | null>(null);
// Non-null while the "confirm before touching disk" modal is open.
const [renamePlan, setRenamePlan] = useState<RenamePlanEntry[] | null>(null);
const [renamePlanSkipped, setRenamePlanSkipped] = useState(0);
const [renaming, setRenaming] = useState(false);
// Refs so the drag-and-drop listener (registered once on mount) always sees the
// latest expression/items instead of the stale values from its first render.
@@ -250,6 +294,178 @@ export default function RenamePanel() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Local, filename-based season/episode detection (renamr-core's `matching` module).
// Populates `{n}`/`{s00e00}`/`{season}`/`{episode}` bindings from patterns already
// present in the filename text - no network calls, no metadata lookup, so it's
// distinct from (and much cheaper than) "Fetch Data" online matching, which still
// isn't built.
const runMatching = async () => {
const targets = items.filter((i) => !i.isSample);
if (targets.length === 0) {
setStatus("Load some files first, then Match will scan their names for season/episode info.");
window.clearTimeout((runMatching as any)._t);
(runMatching as any)._t = window.setTimeout(() => setStatus(null), 3200);
return;
}
const results = await Promise.all(
targets.map(async (item) => {
try {
const guess = await invoke<EpisodeGuess | null>("guess_episode", {
filename: item.originalName,
});
return [item.id, guess] as const;
} catch {
return [item.id, null] as const;
}
})
);
const guesses = new Map(results);
// Count matches from the results directly, not inside the setItems updater below:
// React doesn't run a functional updater synchronously, so a counter mutated in
// there wouldn't be ready yet for the status message/auto-upgrade check right after.
const matched = results.filter(([, guess]) => guess !== null).length;
setItems((prev) =>
prev.map((item) => {
const guess = guesses.get(item.id);
if (!guess) return item;
return {
...item,
bindings: {
...item.bindings,
n: guess.seriesName || item.bindings?.name || item.originalName,
s00e00: `S${pad2(guess.season)}E${pad2(guess.episode)}`,
season: String(guess.season),
episode: String(guess.episode),
},
};
})
);
// Showcase the newly-populated fields, but only the first time and only if the
// user hasn't already customized the naming scheme away from the plain-filename
// default.
if (expressionRef.current === REAL_FILE_EXPRESSION && matched > 0) {
setExpression("{n} - {s00e00}");
}
setStatus(
matched > 0
? `Matched season/episode on ${matched} of ${targets.length} file${targets.length === 1 ? "" : "s"} (filename-based, local only).`
: "No season/episode pattern recognized in the loaded filenames."
);
window.clearTimeout((runMatching as any)._t);
(runMatching as any)._t = window.setTimeout(() => setStatus(null), 4000);
};
// Step 1 of Rename: build the plan (old path -> new filename) from the live preview
// that's already on screen, and open the confirmation modal. Nothing on disk is
// touched here - only `confirmRename` below actually calls the Rust rename command,
// and only after the user explicitly approves the plan they can see.
const prepareRename = () => {
const targets = items.filter((i) => !i.isSample);
if (targets.length === 0) {
setStatus("Load some real files first — Rename needs files on disk to work with.");
window.clearTimeout((prepareRename as any)._t);
(prepareRename as any)._t = window.setTimeout(() => setStatus(null), 3200);
return;
}
const plan: RenamePlanEntry[] = [];
let skippedNoPreview = 0;
for (const item of targets) {
const newName = computedNames[item.id];
if (!newName) {
skippedNoPreview += 1;
continue;
}
if (newName === item.originalName) continue; // already correct, nothing to do
plan.push({ id: item.id, source: item.id, currentName: item.originalName, newName });
}
if (plan.length === 0) {
setStatus(
skippedNoPreview > 0
? "Nothing to rename yet — the naming scheme couldn't be computed for those files (check the naming scheme for errors)."
: "Nothing to rename — the computed names already match the current filenames."
);
window.clearTimeout((prepareRename as any)._t);
(prepareRename as any)._t = window.setTimeout(() => setStatus(null), 4000);
return;
}
setRenamePlanSkipped(skippedNoPreview);
setRenamePlan(plan);
};
// Step 2: the user approved the plan shown in the modal - actually rename the files
// on disk via the Rust `execute_rename` command, then reconcile local state (a
// renamed file's id, which is its path, changes) and report the outcome.
const confirmRename = async () => {
if (!renamePlan) return;
setRenaming(true);
try {
const results = await invoke<RenameResult[]>("execute_rename", {
items: renamePlan.map((p) => ({ source: p.source, newName: p.newName })),
});
const bySource = new Map(results.map((r) => [r.source, r] as const));
// Compute counts from `results` directly, not inside the setItems updater below:
// React doesn't run a functional updater synchronously, so counters mutated in
// there aren't ready yet when building the status message right after (this bit
// runMatching earlier - same fix applies here).
const renamed = results.filter((r) => r.outcome.status === "renamed").length;
const skipped = results.filter((r) => r.outcome.status === "skippedConflict").length;
const failed = results.filter((r) => r.outcome.status === "failed").length;
setItems((prev) =>
prev.map((item) => {
const result = bySource.get(item.id);
if (!result) return item;
if (result.outcome.status === "renamed") {
const newFileName = fileNameFromPath(result.outcome.newPath);
const [base, ext] = splitExt(newFileName);
return {
...item,
id: result.outcome.newPath,
originalName: newFileName,
ext,
bindings: item.bindings ? { ...item.bindings, name: base, ext } : item.bindings,
};
}
return item;
})
);
// Keep the current selection pointing at the same logical file if its id (path)
// changed underneath it.
setSelected((prevSelected) => {
if (!prevSelected) return prevSelected;
const result = bySource.get(prevSelected);
return result?.outcome.status === "renamed" ? result.outcome.newPath : prevSelected;
});
const parts = [`Renamed ${renamed} file${renamed === 1 ? "" : "s"}`];
if (skipped > 0) parts.push(`${skipped} skipped (name already exists)`);
if (failed > 0) parts.push(`${failed} failed`);
setStatus(parts.join(", ") + ".");
} catch (err) {
setStatus(`Rename failed: ${err}`);
} finally {
setRenaming(false);
setRenamePlan(null);
window.clearTimeout((confirmRename as any)._t);
(confirmRename as any)._t = window.setTimeout(() => setStatus(null), 5000);
}
};
const cancelRename = () => {
if (renaming) return;
setRenamePlan(null);
};
const removeSelected = () => {
if (!selected) return notImplemented("Excluding items");
setItems((prev) => {
@@ -317,10 +533,10 @@ export default function RenamePanel() {
/>
<div className="rename-actions">
<button className="action-button" onClick={() => notImplemented("Matching")}>
<button className="action-button" onClick={runMatching}>
Match
</button>
<button className="action-button primary" onClick={() => notImplemented("Renaming")}>
<button className="action-button primary" onClick={prepareRename}>
Rename
</button>
</div>
@@ -355,14 +571,54 @@ export default function RenamePanel() {
{computeError && <p className="format-error">{computeError}</p>}
<p className="format-hint">
Live preview against {matchedCount} of {items.length} file{items.length === 1 ? "" : "s"}. Runs the real
Rust naming-expression engine (Rhai-backed) through Tauri. Loaded files only expose{" "}
<code>{"{name}"}</code>/<code>{"{ext}"}</code> (their own filename) since the matching engine that would
populate <code>{"{n}"}</code>/<code>{"{s00e00}"}</code>/etc. from online metadata isn't built yet.
Rust naming-expression engine (Rhai-backed) through Tauri. Loaded files start with only{" "}
<code>{"{name}"}</code>/<code>{"{ext}"}</code> (their own filename) — click <strong>Match</strong> to scan
filenames locally for season/episode patterns and populate <code>{"{n}"}</code>/<code>{"{s00e00}"}</code>/
<code>{"{season}"}</code>/<code>{"{episode}"}</code>. That's filename parsing only, no network calls;{" "}
<strong>Fetch Data</strong> (online metadata matching) isn't built yet.
</p>
</div>
)}
{status && <div className="status-bar">{status}</div>}
{renamePlan && (
<div className="modal-overlay" onClick={cancelRename}>
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
<h2 className="modal-title">Confirm Rename</h2>
<p className="modal-subtitle">
This will rename {renamePlan.length} file{renamePlan.length === 1 ? "" : "s"} on disk right now.
There's no undo yet — review the list below before continuing.
{renamePlanSkipped > 0 &&
` (${renamePlanSkipped} other file${renamePlanSkipped === 1 ? "" : "s"} excluded: no valid preview.)`}
</p>
<div className="modal-plan-list">
{renamePlan.map((p) => (
<div className="modal-plan-row" key={p.id}>
<span className="modal-plan-old" title={p.currentName}>
{p.currentName}
</span>
<span className="modal-plan-arrow">→</span>
<span className="modal-plan-new" title={p.newName}>
{p.newName}
</span>
</div>
))}
</div>
<p className="modal-note">
If a computed name already exists on disk, that file is skipped rather than overwritten.
</p>
<div className="modal-actions">
<button className="action-button" onClick={cancelRename} disabled={renaming}>
Cancel
</button>
<button className="action-button primary" onClick={confirmRename} disabled={renaming}>
{renaming ? "Renaming…" : `Rename ${renamePlan.length} File${renamePlan.length === 1 ? "" : "s"}`}
</button>
</div>
</div>
</div>
)}
</div>
);
}