Add local season/episode matching and on-disk rename (skip-on-conflict, confirm-first)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user