2026-08-29 02:06:14 +00:00
|
|
|
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
2026-08-29 01:55:54 +00:00
|
|
|
import { invoke } from "@tauri-apps/api/core";
|
|
|
|
|
import {
|
|
|
|
|
ClearIcon,
|
|
|
|
|
DownIcon,
|
|
|
|
|
FetchIcon,
|
|
|
|
|
GearIcon,
|
|
|
|
|
HistoryIcon,
|
2026-08-29 03:01:19 +00:00
|
|
|
KeyIcon,
|
2026-08-29 01:55:54 +00:00
|
|
|
LoadIcon,
|
|
|
|
|
PresetsIcon,
|
|
|
|
|
RemoveIcon,
|
|
|
|
|
UpIcon,
|
|
|
|
|
} from "./Icons";
|
|
|
|
|
|
2026-08-29 02:06:14 +00:00
|
|
|
interface Item {
|
2026-08-29 01:55:54 +00:00
|
|
|
id: string;
|
|
|
|
|
originalName: string;
|
|
|
|
|
ext: string;
|
2026-08-29 02:06:14 +00:00
|
|
|
bindings: Record<string, string> | null; // null = no data to preview with (rendered muted)
|
|
|
|
|
isSample: boolean;
|
2026-08-29 04:04:16 +00:00
|
|
|
// What kind of media Match recognized this file as - "tv"/"movie" once Match (or
|
|
|
|
|
// Fetch Data) has run, null before that or if nothing was recognized. Kept outside
|
|
|
|
|
// `bindings` so it doesn't leak into the naming-scheme field-reference list.
|
|
|
|
|
mediaKind: "tv" | "movie" | null;
|
2026-08-29 01:55:54 +00:00
|
|
|
}
|
|
|
|
|
|
2026-08-29 02:06:14 +00:00
|
|
|
interface FileEntry {
|
|
|
|
|
path: string;
|
|
|
|
|
name: string; // filename without extension
|
|
|
|
|
ext: string;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-29 02:42:31 +00:00
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-29 04:04:16 +00:00
|
|
|
// Result of the local, filename-only movie title/year matcher (renamr-core's
|
|
|
|
|
// `matching::guess_movie`, exposed via the `guess_movie` Tauri command). Tried as a
|
|
|
|
|
// fallback whenever `guess_episode` finds no season/episode pattern - a release name
|
|
|
|
|
// is either a TV episode or a movie, never both.
|
|
|
|
|
interface MovieGuess {
|
|
|
|
|
title: string;
|
|
|
|
|
year: number | null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// What runMatching resolved a file to, before it's written into the item's bindings.
|
|
|
|
|
type MatchGuess = { kind: "tv"; episode: EpisodeGuess } | { kind: "movie"; movie: MovieGuess };
|
|
|
|
|
|
2026-08-29 09:29:14 +00:00
|
|
|
// Result of the local, filename-only release-tag matcher (renamr-core's
|
|
|
|
|
// `matching::guess_release_info`, exposed via the `guess_release_info` Tauri command).
|
|
|
|
|
// Independent of the TV/movie guess above - a release name carries (or doesn't carry)
|
|
|
|
|
// these tags regardless of what kind of media it is, so this runs alongside whichever
|
|
|
|
|
// of `guess_episode`/`guess_movie` applied. Every field is optional on its own.
|
|
|
|
|
interface ReleaseInfo {
|
|
|
|
|
resolution: string | null;
|
|
|
|
|
source: string | null;
|
|
|
|
|
videoCodec: string | null;
|
|
|
|
|
audioCodec: string | null;
|
|
|
|
|
group: string | null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-29 02:42:31 +00:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-29 04:31:16 +00:00
|
|
|
// Mirrors renamr-core's `RenameAction` - what actually happens at each computed target.
|
|
|
|
|
// "move" relocates the file in place (the only action Renamr had before); "copy" and
|
|
|
|
|
// "symlink" both leave the source file exactly where it is and produce a new file
|
|
|
|
|
// elsewhere, which is why only "move" updates the loaded item's own path/name (see
|
|
|
|
|
// confirmRename below).
|
|
|
|
|
type RenameAction = "move" | "copy" | "symlink";
|
|
|
|
|
|
2026-08-29 03:01:19 +00:00
|
|
|
// Sent to `fetch_metadata`: one file's local guess (from Match) to resolve against
|
|
|
|
|
// real TMDB data. Needs a series name + season/episode to search with - exactly what
|
|
|
|
|
// Match already produces offline, which is why Fetch Data requires Match to have run
|
|
|
|
|
// first rather than doing its own from-scratch identification.
|
|
|
|
|
interface FetchQuery {
|
|
|
|
|
id: string;
|
|
|
|
|
seriesQuery: string;
|
|
|
|
|
season: number;
|
|
|
|
|
episode: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Mirrors renamr-core's `FetchOutcome` (serde-tagged on "status").
|
|
|
|
|
type FetchOutcome =
|
|
|
|
|
| { status: "found"; seriesName: string; episodeTitle: string; airDate: string | null }
|
|
|
|
|
| { status: "notFound" }
|
|
|
|
|
| { status: "failed"; error: string };
|
|
|
|
|
|
|
|
|
|
interface FetchResult {
|
|
|
|
|
id: string;
|
|
|
|
|
outcome: FetchOutcome;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-29 04:04:16 +00:00
|
|
|
// Sent to `fetch_movie_metadata`: one file's local movie-title guess (from Match) to
|
|
|
|
|
// resolve against real TMDB data. Mirrors FetchQuery, but for movies - a year is
|
|
|
|
|
// optional since not every release name carries one.
|
|
|
|
|
interface MovieFetchQuery {
|
|
|
|
|
id: string;
|
|
|
|
|
titleQuery: string;
|
|
|
|
|
year: number | null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Mirrors renamr-core's `MovieFetchOutcome` (serde-tagged on "status").
|
|
|
|
|
type MovieFetchOutcome =
|
|
|
|
|
| { status: "found"; title: string; releaseDate: string | null }
|
|
|
|
|
| { status: "notFound" }
|
|
|
|
|
| { status: "failed"; error: string };
|
|
|
|
|
|
|
|
|
|
interface MovieFetchResult {
|
|
|
|
|
id: string;
|
|
|
|
|
outcome: MovieFetchOutcome;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-29 03:44:38 +00:00
|
|
|
// Rename history / undo. Mirrors renamr-core's `history` module (serde-tagged on
|
|
|
|
|
// "status" for the outcome, same convention as RenameOutcome/FetchOutcome above).
|
|
|
|
|
interface HistoryEntry {
|
|
|
|
|
source: string;
|
|
|
|
|
newPath: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface HistoryBatch {
|
|
|
|
|
id: string;
|
|
|
|
|
/// Unix epoch milliseconds - formatted client-side with `Date`.
|
|
|
|
|
timestamp: number;
|
2026-08-29 04:31:16 +00:00
|
|
|
// What action produced every entry in this batch. Older history entries recorded
|
|
|
|
|
// before this field existed come back as "move" (the Rust side's serde default),
|
|
|
|
|
// which is also correct - that was the only action that ever existed back then.
|
|
|
|
|
action: RenameAction;
|
2026-08-29 03:44:38 +00:00
|
|
|
entries: HistoryEntry[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type RevertOutcome =
|
|
|
|
|
| { status: "reverted"; restoredPath: string }
|
|
|
|
|
| { status: "skippedConflict"; existingPath: string }
|
|
|
|
|
| { status: "failed"; error: string };
|
|
|
|
|
|
|
|
|
|
interface RevertResult {
|
|
|
|
|
source: string;
|
|
|
|
|
outcome: RevertOutcome;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-29 02:42:31 +00:00
|
|
|
function fileNameFromPath(path: string): string {
|
|
|
|
|
const parts = path.split(/[\\/]/);
|
|
|
|
|
return parts[parts.length - 1] ?? path;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-29 02:06:14 +00:00
|
|
|
// 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
|
|
|
|
|
// (see the porting roadmap) is ported — real loaded files use their own filename fields
|
|
|
|
|
// instead (see realFileBindings below) since there's no online identification yet.
|
|
|
|
|
const SAMPLE_ITEMS: Item[] = [
|
2026-08-29 01:55:54 +00:00
|
|
|
{
|
2026-08-29 02:06:14 +00:00
|
|
|
id: "sample-1",
|
2026-08-29 01:55:54 +00:00
|
|
|
originalName: "Breaking.Bad.S05E14.720p.HDTV.x264-KILLERS.mkv",
|
|
|
|
|
ext: "mkv",
|
2026-08-29 03:15:21 +00:00
|
|
|
bindings: {
|
|
|
|
|
n: "Breaking Bad",
|
|
|
|
|
s00e00: "S05E14",
|
|
|
|
|
t: "Ozymandias",
|
|
|
|
|
"show name": "Breaking Bad",
|
|
|
|
|
"season number": "5",
|
|
|
|
|
"episode number": "14",
|
|
|
|
|
"episode name": "Ozymandias",
|
2026-08-29 09:29:14 +00:00
|
|
|
vf: "720p",
|
|
|
|
|
source: "HDTV",
|
|
|
|
|
vc: "x264",
|
|
|
|
|
group: "KILLERS",
|
|
|
|
|
resolution: "720p",
|
|
|
|
|
"video codec": "x264",
|
|
|
|
|
"release group": "KILLERS",
|
2026-08-29 03:15:21 +00:00
|
|
|
},
|
2026-08-29 02:06:14 +00:00
|
|
|
isSample: true,
|
2026-08-29 04:04:16 +00:00
|
|
|
mediaKind: "tv",
|
2026-08-29 01:55:54 +00:00
|
|
|
},
|
|
|
|
|
{
|
2026-08-29 02:06:14 +00:00
|
|
|
id: "sample-2",
|
2026-08-29 01:55:54 +00:00
|
|
|
originalName: "Breaking.Bad.S05E15.720p.HDTV.x264-KILLERS.mkv",
|
|
|
|
|
ext: "mkv",
|
2026-08-29 03:15:21 +00:00
|
|
|
bindings: {
|
|
|
|
|
n: "Breaking Bad",
|
|
|
|
|
s00e00: "S05E15",
|
|
|
|
|
t: "Granite State",
|
|
|
|
|
"show name": "Breaking Bad",
|
|
|
|
|
"season number": "5",
|
|
|
|
|
"episode number": "15",
|
|
|
|
|
"episode name": "Granite State",
|
2026-08-29 09:29:14 +00:00
|
|
|
vf: "720p",
|
|
|
|
|
source: "HDTV",
|
|
|
|
|
vc: "x264",
|
|
|
|
|
group: "KILLERS",
|
|
|
|
|
resolution: "720p",
|
|
|
|
|
"video codec": "x264",
|
|
|
|
|
"release group": "KILLERS",
|
2026-08-29 03:15:21 +00:00
|
|
|
},
|
2026-08-29 02:06:14 +00:00
|
|
|
isSample: true,
|
2026-08-29 04:04:16 +00:00
|
|
|
mediaKind: "tv",
|
2026-08-29 01:55:54 +00:00
|
|
|
},
|
|
|
|
|
{
|
2026-08-29 02:06:14 +00:00
|
|
|
id: "sample-3",
|
2026-08-29 01:55:54 +00:00
|
|
|
originalName: "The.Office.US.S03E01.DVDRip.XviD-SAiNTS.avi",
|
|
|
|
|
ext: "avi",
|
2026-08-29 03:15:21 +00:00
|
|
|
bindings: {
|
|
|
|
|
n: "The Office (US)",
|
|
|
|
|
s00e00: "S03E01",
|
|
|
|
|
t: "Gay Witch Hunt",
|
|
|
|
|
"show name": "The Office (US)",
|
|
|
|
|
"season number": "3",
|
|
|
|
|
"episode number": "01",
|
|
|
|
|
"episode name": "Gay Witch Hunt",
|
2026-08-29 09:29:14 +00:00
|
|
|
source: "DVDRip",
|
|
|
|
|
vc: "XviD",
|
|
|
|
|
group: "SAiNTS",
|
|
|
|
|
"video codec": "XviD",
|
|
|
|
|
"release group": "SAiNTS",
|
2026-08-29 03:15:21 +00:00
|
|
|
},
|
2026-08-29 02:06:14 +00:00
|
|
|
isSample: true,
|
2026-08-29 04:04:16 +00:00
|
|
|
mediaKind: "tv",
|
2026-08-29 01:55:54 +00:00
|
|
|
},
|
|
|
|
|
{
|
2026-08-29 02:06:14 +00:00
|
|
|
id: "sample-4",
|
2026-08-29 01:55:54 +00:00
|
|
|
originalName: "random_video_file.mp4",
|
|
|
|
|
ext: "mp4",
|
|
|
|
|
bindings: null,
|
2026-08-29 02:06:14 +00:00
|
|
|
isSample: true,
|
2026-08-29 04:04:16 +00:00
|
|
|
mediaKind: null,
|
2026-08-29 01:55:54 +00:00
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
2026-08-29 03:15:21 +00:00
|
|
|
// The user's own preferred naming scheme (their words: "I personally like {show name} -
|
|
|
|
|
// {season number}x{episode number} - {episode name}") - this is Renamr's default now,
|
|
|
|
|
// not just an example. `{show name}`/`{season number}`/etc. are multi-word plain-field
|
|
|
|
|
// binding names, which work via renamr-core's ExpressionFormat plain-field-name
|
|
|
|
|
// fallback (ExpressionError-free even though they aren't valid Rhai identifiers).
|
|
|
|
|
const SAMPLE_EXPRESSION = "{show name} - {season number}x{episode number} - {episode name}";
|
2026-08-29 02:06:14 +00:00
|
|
|
// The matching engine isn't ported yet, so real loaded files only have their own
|
|
|
|
|
// filename-derived fields to preview with (mirrors the original's plain FileNameFormatter,
|
|
|
|
|
// used whenever a file has no online match).
|
|
|
|
|
const REAL_FILE_EXPRESSION = "{name}";
|
2026-08-29 03:15:21 +00:00
|
|
|
// What Match alone can fill in (no episode title yet - that needs Fetch Data).
|
|
|
|
|
const MATCHED_EXPRESSION = "{show name} - {season number}x{episode number}";
|
|
|
|
|
// What Match + Fetch Data together can fill in.
|
|
|
|
|
const FETCHED_EXPRESSION = "{show name} - {season number}x{episode number} - {episode name}";
|
2026-08-29 02:06:14 +00:00
|
|
|
|
2026-08-29 04:31:16 +00:00
|
|
|
// Copy for each RenameAction, used in the confirmation modal's segmented selector,
|
|
|
|
|
// subtitle, confirm-button label, and the post-confirm status message.
|
|
|
|
|
const RENAME_ACTION_LABELS: Record<
|
|
|
|
|
RenameAction,
|
|
|
|
|
{ button: string; verb: string; pastTense: string; progressive: string }
|
|
|
|
|
> = {
|
|
|
|
|
move: { button: "Rename", verb: "rename", pastTense: "Renamed", progressive: "Renaming…" },
|
|
|
|
|
copy: { button: "Copy", verb: "copy", pastTense: "Copied", progressive: "Copying…" },
|
|
|
|
|
symlink: {
|
|
|
|
|
button: "Symlink",
|
|
|
|
|
verb: "create symlinks for",
|
|
|
|
|
pastTense: "Symlinked",
|
|
|
|
|
progressive: "Creating symlinks…",
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-29 02:06:14 +00:00
|
|
|
function realFileToItem(entry: FileEntry): Item {
|
|
|
|
|
return {
|
|
|
|
|
id: entry.path,
|
|
|
|
|
originalName: entry.ext ? `${entry.name}.${entry.ext}` : entry.name,
|
|
|
|
|
ext: entry.ext,
|
|
|
|
|
bindings: { name: entry.name, ext: entry.ext },
|
|
|
|
|
isSample: false,
|
2026-08-29 04:04:16 +00:00
|
|
|
mediaKind: null, // unknown until Match runs
|
2026-08-29 02:06:14 +00:00
|
|
|
};
|
|
|
|
|
}
|
2026-08-29 01:55:54 +00:00
|
|
|
|
|
|
|
|
function ListButton({
|
|
|
|
|
icon,
|
|
|
|
|
label,
|
|
|
|
|
onClick,
|
2026-08-29 03:01:19 +00:00
|
|
|
disabled,
|
2026-08-29 01:55:54 +00:00
|
|
|
}: {
|
|
|
|
|
icon: JSX.Element;
|
|
|
|
|
label: string;
|
|
|
|
|
onClick: () => void;
|
2026-08-29 03:01:19 +00:00
|
|
|
disabled?: boolean;
|
2026-08-29 01:55:54 +00:00
|
|
|
}) {
|
|
|
|
|
return (
|
2026-08-29 03:01:19 +00:00
|
|
|
<button className="list-icon-button" title={label} onClick={onClick} disabled={disabled}>
|
2026-08-29 01:55:54 +00:00
|
|
|
{icon}
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-29 02:06:14 +00:00
|
|
|
function LoadMenuButton({ onFiles, onFolder }: { onFiles: () => void; onFolder: () => void }) {
|
|
|
|
|
const [open, setOpen] = useState(false);
|
|
|
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!open) return;
|
|
|
|
|
const onOutside = (e: MouseEvent) => {
|
|
|
|
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
|
|
|
|
};
|
|
|
|
|
document.addEventListener("mousedown", onOutside);
|
|
|
|
|
return () => document.removeEventListener("mousedown", onOutside);
|
|
|
|
|
}, [open]);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="load-menu" ref={ref}>
|
|
|
|
|
<ListButton icon={<LoadIcon />} label="Load Files" onClick={() => setOpen((v) => !v)} />
|
|
|
|
|
{open && (
|
|
|
|
|
<div className="load-menu-popup">
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setOpen(false);
|
|
|
|
|
onFiles();
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
Select Files…
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setOpen(false);
|
|
|
|
|
onFolder();
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
Select Folder…
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-29 01:55:54 +00:00
|
|
|
export default function RenamePanel() {
|
2026-08-29 02:06:14 +00:00
|
|
|
const [expression, setExpression] = useState(SAMPLE_EXPRESSION);
|
2026-08-29 01:55:54 +00:00
|
|
|
const [editingFormat, setEditingFormat] = useState(false);
|
2026-08-29 02:06:14 +00:00
|
|
|
const [items, setItems] = useState<Item[]>(SAMPLE_ITEMS);
|
2026-08-29 01:55:54 +00:00
|
|
|
const [selected, setSelected] = useState<string | null>(SAMPLE_ITEMS[0].id);
|
|
|
|
|
const [computedNames, setComputedNames] = useState<Record<string, string>>({});
|
|
|
|
|
const [computeError, setComputeError] = useState<string | null>(null);
|
|
|
|
|
const [status, setStatus] = useState<string | null>(null);
|
2026-08-29 02:42:31 +00:00
|
|
|
// 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);
|
2026-08-29 04:31:16 +00:00
|
|
|
// What to actually do at each computed target - chosen in the confirmation modal,
|
|
|
|
|
// remembered across modal opens within the session (defaults to "move", matching
|
|
|
|
|
// Renamr's original and only behavior before copy/symlink existed).
|
|
|
|
|
const [renameAction, setRenameAction] = useState<RenameAction>("move");
|
2026-08-29 03:01:19 +00:00
|
|
|
// TMDB token state for "Fetch Data". `null` = not checked yet, `false` = checked and
|
|
|
|
|
// nothing saved, `true` = a token is on file (the value itself never comes back to
|
|
|
|
|
// the frontend after saving - see Settings modal below).
|
|
|
|
|
const [hasTmdbToken, setHasTmdbToken] = useState<boolean | null>(null);
|
|
|
|
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
|
|
|
const [tmdbTokenInput, setTmdbTokenInput] = useState("");
|
|
|
|
|
const [savingSettings, setSavingSettings] = useState(false);
|
|
|
|
|
const [fetching, setFetching] = useState(false);
|
2026-08-29 03:44:38 +00:00
|
|
|
// Rename history / undo. `null` batches = not loaded yet (also used to show a
|
|
|
|
|
// loading state the first time the History panel is opened).
|
|
|
|
|
const [historyOpen, setHistoryOpen] = useState(false);
|
|
|
|
|
const [historyBatches, setHistoryBatches] = useState<HistoryBatch[] | null>(null);
|
|
|
|
|
const [undoingBatchId, setUndoingBatchId] = useState<string | null>(null);
|
2026-08-29 09:39:52 +00:00
|
|
|
// 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();
|
|
|
|
|
}, []);
|
2026-08-29 03:01:19 +00:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
invoke<string | null>("get_tmdb_token")
|
|
|
|
|
.then((token) => setHasTmdbToken(!!token))
|
|
|
|
|
.catch(() => setHasTmdbToken(false));
|
|
|
|
|
}, []);
|
2026-08-29 01:55:54 +00:00
|
|
|
|
2026-08-29 02:06:14 +00:00
|
|
|
// 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.
|
|
|
|
|
const expressionRef = useRef(expression);
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
expressionRef.current = expression;
|
|
|
|
|
}, [expression]);
|
|
|
|
|
|
2026-08-29 01:55:54 +00:00
|
|
|
useEffect(() => {
|
|
|
|
|
let cancelled = false;
|
|
|
|
|
|
|
|
|
|
Promise.all(
|
2026-08-29 02:06:14 +00:00
|
|
|
items.map(async (item) => {
|
2026-08-29 01:55:54 +00:00
|
|
|
if (!item.bindings) return [item.id, null] as const;
|
2026-08-29 02:06:14 +00:00
|
|
|
const rendered = await invoke<string>("render_expression", {
|
|
|
|
|
expression,
|
|
|
|
|
bindings: item.bindings,
|
|
|
|
|
});
|
|
|
|
|
return [item.id, `${rendered}${item.ext ? `.${item.ext}` : ""}`] as const;
|
2026-08-29 01:55:54 +00:00
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.then((entries) => {
|
|
|
|
|
if (cancelled) return;
|
|
|
|
|
const next: Record<string, string> = {};
|
|
|
|
|
for (const [id, name] of entries) {
|
|
|
|
|
if (name) next[id] = name;
|
|
|
|
|
}
|
|
|
|
|
setComputedNames(next);
|
|
|
|
|
setComputeError(null);
|
|
|
|
|
})
|
|
|
|
|
.catch((err) => {
|
|
|
|
|
if (cancelled) return;
|
|
|
|
|
setComputeError(String(err));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
cancelled = true;
|
|
|
|
|
};
|
2026-08-29 02:06:14 +00:00
|
|
|
}, [expression, items]);
|
2026-08-29 01:55:54 +00:00
|
|
|
|
|
|
|
|
const notImplemented = (feature: string) => {
|
|
|
|
|
setStatus(`${feature} isn't implemented yet — see the porting roadmap in the README.`);
|
|
|
|
|
window.clearTimeout((notImplemented as any)._t);
|
|
|
|
|
(notImplemented as any)._t = window.setTimeout(() => setStatus(null), 3200);
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-29 02:06:14 +00:00
|
|
|
const addRealFiles = (entries: FileEntry[]) => {
|
|
|
|
|
if (entries.length === 0) return;
|
|
|
|
|
|
|
|
|
|
// Only auto-switch the naming scheme the first time real files replace the demo,
|
|
|
|
|
// and only if the user hasn't already customized it.
|
|
|
|
|
if (expressionRef.current === SAMPLE_EXPRESSION) {
|
|
|
|
|
setExpression(REAL_FILE_EXPRESSION);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setItems((prev) => {
|
|
|
|
|
const real = prev.filter((i) => !i.isSample);
|
|
|
|
|
const existing = new Set(real.map((i) => i.id));
|
|
|
|
|
const added = entries.map(realFileToItem).filter((i) => !existing.has(i.id));
|
|
|
|
|
const next = [...real, ...added];
|
|
|
|
|
if (added.length > 0) setSelected(added[0].id);
|
|
|
|
|
return next;
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const loadFiles = () => {
|
|
|
|
|
invoke<FileEntry[]>("pick_files")
|
|
|
|
|
.then(addRealFiles)
|
|
|
|
|
.catch((err) => setStatus(`Couldn't open the file picker: ${err}`));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const loadFolder = () => {
|
|
|
|
|
invoke<FileEntry[]>("pick_folder")
|
|
|
|
|
.then(addRealFiles)
|
|
|
|
|
.catch((err) => setStatus(`Couldn't open the folder picker: ${err}`));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// OS drag-and-drop of files/folders onto the window.
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
let unlisten: (() => void) | undefined;
|
|
|
|
|
let cancelled = false;
|
|
|
|
|
|
|
|
|
|
import("@tauri-apps/api/webview")
|
|
|
|
|
.then(({ getCurrentWebview }) =>
|
|
|
|
|
getCurrentWebview().onDragDropEvent((event) => {
|
|
|
|
|
if (event.payload.type === "drop") {
|
|
|
|
|
invoke<FileEntry[]>("expand_dropped_paths", { paths: event.payload.paths })
|
|
|
|
|
.then(addRealFiles)
|
|
|
|
|
.catch((err) => setStatus(`Couldn't read dropped files: ${err}`));
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.then((fn) => {
|
|
|
|
|
if (cancelled) fn();
|
|
|
|
|
else unlisten = fn;
|
|
|
|
|
})
|
|
|
|
|
.catch(() => {
|
|
|
|
|
// Not running inside Tauri (e.g. a plain browser preview) - drag-and-drop
|
|
|
|
|
// just won't be available there; the Load button still works via invoke.
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
cancelled = true;
|
|
|
|
|
unlisten?.();
|
|
|
|
|
};
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-08-29 04:04:16 +00:00
|
|
|
// Local, filename-based season/episode/movie detection (renamr-core's `matching`
|
|
|
|
|
// module). Tries `guess_episode` first; when a filename carries no season/episode
|
|
|
|
|
// marker, falls back to `guess_movie` (a release name is either a TV episode or a
|
|
|
|
|
// movie, never both - see the doc comment on `guess_movie` in renamr-core). Populates
|
|
|
|
|
// TV bindings (`{n}`/`{s00e00}`/`{season}`/`{episode}`/etc.) or movie bindings
|
|
|
|
|
// (`{n}`/`{y}`/etc.) accordingly - no network calls, no metadata lookup, so it's
|
|
|
|
|
// distinct from (and much cheaper than) "Fetch Data" online matching.
|
2026-08-29 02:42:31 +00:00
|
|
|
const runMatching = async () => {
|
|
|
|
|
const targets = items.filter((i) => !i.isSample);
|
|
|
|
|
if (targets.length === 0) {
|
2026-08-29 04:04:16 +00:00
|
|
|
setStatus("Load some files first, then Match will scan their names for season/episode or movie info.");
|
2026-08-29 02:42:31 +00:00
|
|
|
window.clearTimeout((runMatching as any)._t);
|
|
|
|
|
(runMatching as any)._t = window.setTimeout(() => setStatus(null), 3200);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-29 09:29:14 +00:00
|
|
|
const results: Array<readonly [string, MatchGuess | null, ReleaseInfo | null]> = await Promise.all(
|
|
|
|
|
targets.map(async (item): Promise<readonly [string, MatchGuess | null, ReleaseInfo | null]> => {
|
|
|
|
|
// Release tags (resolution/source/codecs/group) are independent of TV-vs-movie,
|
|
|
|
|
// so kick that guess off alongside whichever of the two below applies.
|
|
|
|
|
const releasePromise = invoke<ReleaseInfo | null>("guess_release_info", {
|
|
|
|
|
filename: item.originalName,
|
|
|
|
|
}).catch(() => null);
|
|
|
|
|
|
|
|
|
|
let mediaGuess: MatchGuess | null = null;
|
2026-08-29 02:42:31 +00:00
|
|
|
try {
|
2026-08-29 04:04:16 +00:00
|
|
|
const episode = await invoke<EpisodeGuess | null>("guess_episode", {
|
2026-08-29 02:42:31 +00:00
|
|
|
filename: item.originalName,
|
|
|
|
|
});
|
2026-08-29 09:29:14 +00:00
|
|
|
if (episode) mediaGuess = { kind: "tv", episode };
|
2026-08-29 02:42:31 +00:00
|
|
|
} catch {
|
2026-08-29 04:04:16 +00:00
|
|
|
// Fall through and try a movie guess instead.
|
2026-08-29 02:42:31 +00:00
|
|
|
}
|
2026-08-29 09:29:14 +00:00
|
|
|
if (!mediaGuess) {
|
|
|
|
|
try {
|
|
|
|
|
const movie = await invoke<MovieGuess | null>("guess_movie", {
|
|
|
|
|
filename: item.originalName,
|
|
|
|
|
});
|
|
|
|
|
if (movie) mediaGuess = { kind: "movie", movie };
|
|
|
|
|
} catch {
|
|
|
|
|
// Neither guess worked - leave this item unmatched.
|
|
|
|
|
}
|
2026-08-29 04:04:16 +00:00
|
|
|
}
|
2026-08-29 09:29:14 +00:00
|
|
|
|
|
|
|
|
return [item.id, mediaGuess, await releasePromise];
|
2026-08-29 02:42:31 +00:00
|
|
|
})
|
|
|
|
|
);
|
2026-08-29 09:29:14 +00:00
|
|
|
const guesses = new Map(results.map(([id, guess]) => [id, guess] as const));
|
|
|
|
|
const releaseGuesses = new Map(results.map(([id, , release]) => [id, release] as const));
|
2026-08-29 02:42:31 +00:00
|
|
|
// 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.
|
2026-08-29 04:04:16 +00:00
|
|
|
const matchedTv = results.filter(([, guess]) => guess?.kind === "tv").length;
|
|
|
|
|
const matchedMovie = results.filter(([, guess]) => guess?.kind === "movie").length;
|
|
|
|
|
const matched = matchedTv + matchedMovie;
|
2026-08-29 09:29:14 +00:00
|
|
|
const tagged = results.filter(([, , release]) => release !== null).length;
|
2026-08-29 02:42:31 +00:00
|
|
|
|
|
|
|
|
setItems((prev) =>
|
|
|
|
|
prev.map((item) => {
|
|
|
|
|
const guess = guesses.get(item.id);
|
2026-08-29 09:29:14 +00:00
|
|
|
const release = releaseGuesses.get(item.id);
|
|
|
|
|
|
|
|
|
|
// Release-tag fields apply on top of whatever the TV/movie guess below
|
|
|
|
|
// contributes (or nothing at all, for a file with no season/episode or movie
|
|
|
|
|
// title recognized but a quality tag in its name).
|
|
|
|
|
const releaseBindings: Record<string, string> | null = release
|
|
|
|
|
? {
|
|
|
|
|
...(release.resolution ? { vf: release.resolution, resolution: release.resolution } : {}),
|
|
|
|
|
...(release.source ? { source: release.source } : {}),
|
|
|
|
|
...(release.videoCodec ? { vc: release.videoCodec, "video codec": release.videoCodec } : {}),
|
|
|
|
|
...(release.audioCodec ? { ac: release.audioCodec, "audio codec": release.audioCodec } : {}),
|
|
|
|
|
...(release.group ? { group: release.group, "release group": release.group } : {}),
|
|
|
|
|
}
|
|
|
|
|
: null;
|
|
|
|
|
|
|
|
|
|
if (!guess) {
|
|
|
|
|
return releaseBindings ? { ...item, bindings: { ...item.bindings, ...releaseBindings } } : item;
|
|
|
|
|
}
|
2026-08-29 04:04:16 +00:00
|
|
|
|
|
|
|
|
if (guess.kind === "tv") {
|
|
|
|
|
const { episode } = guess;
|
|
|
|
|
const seriesName = episode.seriesName || item.bindings?.name || item.originalName;
|
|
|
|
|
return {
|
|
|
|
|
...item,
|
|
|
|
|
mediaKind: "tv",
|
|
|
|
|
bindings: {
|
|
|
|
|
...item.bindings,
|
|
|
|
|
// FileBot-style short names, for compatibility with expressions that use them.
|
|
|
|
|
n: seriesName,
|
|
|
|
|
s00e00: `S${pad2(episode.season)}E${pad2(episode.episode)}`,
|
|
|
|
|
season: String(episode.season),
|
|
|
|
|
episode: String(episode.episode),
|
|
|
|
|
// Human-readable aliases (this is what the default naming scheme uses).
|
|
|
|
|
"show name": seriesName,
|
|
|
|
|
"season number": String(episode.season),
|
|
|
|
|
"episode number": pad2(episode.episode),
|
2026-08-29 09:29:14 +00:00
|
|
|
...releaseBindings,
|
2026-08-29 04:04:16 +00:00
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const { movie } = guess;
|
|
|
|
|
const title = movie.title || item.bindings?.name || item.originalName;
|
2026-08-29 02:42:31 +00:00
|
|
|
return {
|
|
|
|
|
...item,
|
2026-08-29 04:04:16 +00:00
|
|
|
mediaKind: "movie",
|
2026-08-29 02:42:31 +00:00
|
|
|
bindings: {
|
|
|
|
|
...item.bindings,
|
2026-08-29 04:04:16 +00:00
|
|
|
// FileBot-style short name, for compatibility (same `{n}` field TV uses).
|
|
|
|
|
n: title,
|
|
|
|
|
...(movie.year ? { y: String(movie.year) } : {}),
|
|
|
|
|
// Human-readable aliases.
|
|
|
|
|
"movie name": title,
|
|
|
|
|
...(movie.year ? { year: String(movie.year) } : {}),
|
2026-08-29 09:29:14 +00:00
|
|
|
...releaseBindings,
|
2026-08-29 02:42:31 +00:00
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 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
|
2026-08-29 04:04:16 +00:00
|
|
|
// default. Movies deliberately don't drive this auto-upgrade - a single "best
|
|
|
|
|
// expression" heuristic doesn't generalize across a batch mixing TV and movies.
|
|
|
|
|
if (expressionRef.current === REAL_FILE_EXPRESSION && matchedTv > 0) {
|
2026-08-29 03:15:21 +00:00
|
|
|
setExpression(MATCHED_EXPRESSION);
|
2026-08-29 02:42:31 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setStatus(
|
|
|
|
|
matched > 0
|
2026-08-29 09:29:14 +00:00
|
|
|
? `Matched ${matched} of ${targets.length} file${targets.length === 1 ? "" : "s"} (${matchedTv} TV, ${matchedMovie} movie${matchedMovie === 1 ? "" : "s"}${tagged > 0 ? `, ${tagged} with release tags` : ""}; filename-based, local only).`
|
2026-08-29 04:04:16 +00:00
|
|
|
: "No season/episode or movie title recognized in the loaded filenames."
|
2026-08-29 02:42:31 +00:00
|
|
|
);
|
|
|
|
|
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);
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-29 04:31:16 +00:00
|
|
|
// Step 2: the user approved the plan (and chose an action) shown in the modal -
|
|
|
|
|
// actually run it on disk via the Rust `execute_rename` command, then reconcile
|
|
|
|
|
// local state and report the outcome. Only "move" relocates the source file, so only
|
|
|
|
|
// "move" updates a loaded item's own id/name/bindings to point at the new path -
|
|
|
|
|
// "copy"/"symlink" leave the source exactly where it was and produce an additional
|
|
|
|
|
// file elsewhere that Renamr doesn't separately track as a loaded item.
|
2026-08-29 02:42:31 +00:00
|
|
|
const confirmRename = async () => {
|
2026-08-29 04:12:35 +00:00
|
|
|
if (!renamePlan || renamePlan.length === 0) return;
|
2026-08-29 02:42:31 +00:00
|
|
|
setRenaming(true);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const results = await invoke<RenameResult[]>("execute_rename", {
|
|
|
|
|
items: renamePlan.map((p) => ({ source: p.source, newName: p.newName })),
|
2026-08-29 04:31:16 +00:00
|
|
|
action: renameAction,
|
2026-08-29 02:42:31 +00:00
|
|
|
});
|
|
|
|
|
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).
|
2026-08-29 04:31:16 +00:00
|
|
|
const succeeded = results.filter((r) => r.outcome.status === "renamed").length;
|
2026-08-29 02:42:31 +00:00
|
|
|
const skipped = results.filter((r) => r.outcome.status === "skippedConflict").length;
|
|
|
|
|
const failed = results.filter((r) => r.outcome.status === "failed").length;
|
|
|
|
|
|
2026-08-29 04:31:16 +00:00
|
|
|
if (renameAction === "move") {
|
|
|
|
|
setItems((prev) =>
|
|
|
|
|
prev.map((item) => {
|
|
|
|
|
const result = bySource.get(item.id);
|
|
|
|
|
if (!result) return item;
|
2026-08-29 02:42:31 +00:00
|
|
|
|
2026-08-29 04:31:16 +00:00
|
|
|
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;
|
|
|
|
|
})
|
|
|
|
|
);
|
2026-08-29 02:42:31 +00:00
|
|
|
|
2026-08-29 04:31:16 +00:00
|
|
|
// 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;
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-08-29 02:42:31 +00:00
|
|
|
|
2026-08-29 04:31:16 +00:00
|
|
|
const parts = [`${RENAME_ACTION_LABELS[renameAction].pastTense} ${succeeded} file${succeeded === 1 ? "" : "s"}`];
|
2026-08-29 02:42:31 +00:00
|
|
|
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);
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-29 04:12:35 +00:00
|
|
|
// Per-item exclude, inside the confirmation modal itself: drops one row from the
|
|
|
|
|
// plan without closing the modal or having to cancel and re-select beforehand. Every
|
|
|
|
|
// bit of the modal's copy (file counts, the confirm button's label) reads directly
|
|
|
|
|
// from `renamePlan.length`, so removing a row here updates all of it for free.
|
|
|
|
|
const excludeFromPlan = (id: string) => {
|
|
|
|
|
if (renaming) return;
|
|
|
|
|
setRenamePlan((prev) => (prev ? prev.filter((p) => p.id !== id) : prev));
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-29 03:44:38 +00:00
|
|
|
// Rename history / undo - Renamr's only safety net beyond the confirm-before-execute
|
|
|
|
|
// step, since a filesystem rename has no OS-level undo. `execute_rename` records a
|
|
|
|
|
// batch of every successfully-renamed file after each Rename; this panel lists those
|
|
|
|
|
// batches and can revert one back to its original paths.
|
|
|
|
|
const openHistory = () => {
|
|
|
|
|
setHistoryOpen(true);
|
|
|
|
|
invoke<HistoryBatch[]>("list_rename_history")
|
|
|
|
|
.then(setHistoryBatches)
|
|
|
|
|
.catch((err) => {
|
|
|
|
|
setHistoryBatches([]);
|
|
|
|
|
setStatus(`Couldn't load rename history: ${err}`);
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const closeHistory = () => {
|
|
|
|
|
if (undoingBatchId) return;
|
|
|
|
|
setHistoryOpen(false);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const undoBatch = async (batch: HistoryBatch) => {
|
|
|
|
|
setUndoingBatchId(batch.id);
|
|
|
|
|
try {
|
|
|
|
|
const results = await invoke<RevertResult[]>("undo_rename_batch", { batchId: batch.id });
|
|
|
|
|
// Compute counts from `results` directly, not inside the setItems updater below -
|
|
|
|
|
// same rule as runMatching/confirmRename/runFetch above (React doesn't run a
|
|
|
|
|
// functional updater synchronously, so a counter mutated in there wouldn't be
|
|
|
|
|
// ready yet when building the status message right after).
|
|
|
|
|
const bySource = new Map(results.map((r) => [r.source, r] as const));
|
|
|
|
|
const reverted = results.filter((r) => r.outcome.status === "reverted").length;
|
|
|
|
|
const skipped = results.filter((r) => r.outcome.status === "skippedConflict").length;
|
|
|
|
|
const failed = results.filter((r) => r.outcome.status === "failed").length;
|
|
|
|
|
|
|
|
|
|
// Reconcile any currently-loaded item that was part of this batch back to its
|
|
|
|
|
// pre-rename path/filename - mirrors confirmRename's post-rename reconciliation.
|
|
|
|
|
setItems((prev) =>
|
|
|
|
|
prev.map((item) => {
|
|
|
|
|
const result = bySource.get(item.id);
|
|
|
|
|
if (!result || result.outcome.status !== "reverted") return item;
|
|
|
|
|
const restoredFileName = fileNameFromPath(result.outcome.restoredPath);
|
|
|
|
|
const [base, ext] = splitExt(restoredFileName);
|
|
|
|
|
return {
|
|
|
|
|
...item,
|
|
|
|
|
id: result.outcome.restoredPath,
|
|
|
|
|
originalName: restoredFileName,
|
|
|
|
|
ext,
|
|
|
|
|
bindings: item.bindings ? { ...item.bindings, name: base, ext } : item.bindings,
|
|
|
|
|
};
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
setSelected((prevSelected) => {
|
|
|
|
|
if (!prevSelected) return prevSelected;
|
|
|
|
|
const result = bySource.get(prevSelected);
|
|
|
|
|
return result?.outcome.status === "reverted" ? result.outcome.restoredPath : prevSelected;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
setHistoryBatches((prev) => (prev ? prev.filter((b) => b.id !== batch.id) : prev));
|
|
|
|
|
|
|
|
|
|
const parts = [`Reverted ${reverted} file${reverted === 1 ? "" : "s"}`];
|
|
|
|
|
if (skipped > 0) parts.push(`${skipped} skipped (something else is there now)`);
|
|
|
|
|
if (failed > 0) parts.push(`${failed} failed`);
|
|
|
|
|
setStatus(parts.join(", ") + ".");
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setStatus(`Undo failed: ${err}`);
|
|
|
|
|
} finally {
|
|
|
|
|
setUndoingBatchId(null);
|
|
|
|
|
window.clearTimeout((undoBatch as any)._t);
|
|
|
|
|
(undoBatch as any)._t = window.setTimeout(() => setStatus(null), 5000);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-29 03:01:19 +00:00
|
|
|
const openSettings = () => {
|
|
|
|
|
setTmdbTokenInput("");
|
|
|
|
|
setSettingsOpen(true);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const saveTmdbToken = async () => {
|
|
|
|
|
const trimmed = tmdbTokenInput.trim();
|
|
|
|
|
if (!trimmed) {
|
|
|
|
|
// Nothing typed - just close, leaving whatever was already saved untouched.
|
|
|
|
|
setSettingsOpen(false);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setSavingSettings(true);
|
|
|
|
|
try {
|
|
|
|
|
await invoke("save_tmdb_token", { token: trimmed });
|
|
|
|
|
setHasTmdbToken(true);
|
|
|
|
|
setTmdbTokenInput("");
|
|
|
|
|
setSettingsOpen(false);
|
|
|
|
|
setStatus("TMDB token saved.");
|
|
|
|
|
window.clearTimeout((saveTmdbToken as any)._t);
|
|
|
|
|
(saveTmdbToken as any)._t = window.setTimeout(() => setStatus(null), 3000);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setStatus(`Couldn't save the token: ${err}`);
|
|
|
|
|
} finally {
|
|
|
|
|
setSavingSettings(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const clearTmdbToken = async () => {
|
|
|
|
|
setSavingSettings(true);
|
|
|
|
|
try {
|
|
|
|
|
await invoke("save_tmdb_token", { token: "" });
|
|
|
|
|
setHasTmdbToken(false);
|
|
|
|
|
setTmdbTokenInput("");
|
|
|
|
|
setStatus("TMDB token removed.");
|
|
|
|
|
window.clearTimeout((clearTmdbToken as any)._t);
|
|
|
|
|
(clearTmdbToken as any)._t = window.setTimeout(() => setStatus(null), 3000);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setStatus(`Couldn't remove the token: ${err}`);
|
|
|
|
|
} finally {
|
|
|
|
|
setSavingSettings(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// "Fetch Data": resolve each already-Matched file's local guess against real TMDB
|
2026-08-29 04:04:16 +00:00
|
|
|
// metadata. Requires Match to have already populated either a series name + season/
|
|
|
|
|
// episode (TV) or a movie title (movie) - that's the search query - so this is
|
|
|
|
|
// deliberately layered on top of local matching, not a replacement for it. TV and
|
|
|
|
|
// movie items are resolved via separate Tauri commands, run concurrently, and merged
|
|
|
|
|
// back into a single set of counts/status message.
|
2026-08-29 03:01:19 +00:00
|
|
|
const runFetch = async () => {
|
|
|
|
|
if (hasTmdbToken === false) {
|
|
|
|
|
setStatus("Add a free TMDB API token in Settings first, then Fetch Data will work.");
|
|
|
|
|
setSettingsOpen(true);
|
|
|
|
|
window.clearTimeout((runFetch as any)._t);
|
|
|
|
|
(runFetch as any)._t = window.setTimeout(() => setStatus(null), 4000);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-29 04:04:16 +00:00
|
|
|
const tvTargets = items.filter(
|
|
|
|
|
(i) => !i.isSample && i.mediaKind === "tv" && i.bindings?.n && i.bindings?.season && i.bindings?.episode
|
|
|
|
|
);
|
|
|
|
|
const movieTargets = items.filter((i) => !i.isSample && i.mediaKind === "movie" && i.bindings?.n);
|
|
|
|
|
const targetCount = tvTargets.length + movieTargets.length;
|
|
|
|
|
|
|
|
|
|
if (targetCount === 0) {
|
|
|
|
|
setStatus("Run Match first — Fetch Data needs a guessed series/episode or movie title to look up.");
|
2026-08-29 03:01:19 +00:00
|
|
|
window.clearTimeout((runFetch as any)._t);
|
|
|
|
|
(runFetch as any)._t = window.setTimeout(() => setStatus(null), 4000);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setFetching(true);
|
|
|
|
|
try {
|
2026-08-29 04:04:16 +00:00
|
|
|
const [tvResults, movieResults] = await Promise.all([
|
|
|
|
|
tvTargets.length > 0
|
|
|
|
|
? invoke<FetchResult[]>("fetch_metadata", {
|
|
|
|
|
items: tvTargets.map(
|
|
|
|
|
(item): FetchQuery => ({
|
|
|
|
|
id: item.id,
|
|
|
|
|
seriesQuery: item.bindings!.n,
|
|
|
|
|
season: Number(item.bindings!.season),
|
|
|
|
|
episode: Number(item.bindings!.episode),
|
|
|
|
|
})
|
|
|
|
|
),
|
|
|
|
|
})
|
|
|
|
|
: Promise.resolve<FetchResult[]>([]),
|
|
|
|
|
movieTargets.length > 0
|
|
|
|
|
? invoke<MovieFetchResult[]>("fetch_movie_metadata", {
|
|
|
|
|
items: movieTargets.map(
|
|
|
|
|
(item): MovieFetchQuery => ({
|
|
|
|
|
id: item.id,
|
|
|
|
|
titleQuery: item.bindings!.n,
|
|
|
|
|
year: item.bindings!.y ? Number(item.bindings!.y) : null,
|
|
|
|
|
})
|
|
|
|
|
),
|
|
|
|
|
})
|
|
|
|
|
: Promise.resolve<MovieFetchResult[]>([]),
|
|
|
|
|
]);
|
2026-08-29 03:01:19 +00:00
|
|
|
|
2026-08-29 04:04:16 +00:00
|
|
|
const tvById = new Map(tvResults.map((r) => [r.id, r] as const));
|
|
|
|
|
const movieById = new Map(movieResults.map((r) => [r.id, r] as const));
|
|
|
|
|
|
|
|
|
|
// Compute counts from the raw results directly, not inside the setItems updater
|
|
|
|
|
// below - same rule as runMatching/confirmRename above (React doesn't run a
|
|
|
|
|
// functional updater synchronously, so a counter mutated in there wouldn't be
|
|
|
|
|
// ready yet).
|
|
|
|
|
const found =
|
|
|
|
|
tvResults.filter((r) => r.outcome.status === "found").length +
|
|
|
|
|
movieResults.filter((r) => r.outcome.status === "found").length;
|
|
|
|
|
const notFound =
|
|
|
|
|
tvResults.filter((r) => r.outcome.status === "notFound").length +
|
|
|
|
|
movieResults.filter((r) => r.outcome.status === "notFound").length;
|
|
|
|
|
const failed =
|
|
|
|
|
tvResults.filter((r) => r.outcome.status === "failed").length +
|
|
|
|
|
movieResults.filter((r) => r.outcome.status === "failed").length;
|
2026-08-29 03:01:19 +00:00
|
|
|
|
|
|
|
|
setItems((prev) =>
|
|
|
|
|
prev.map((item) => {
|
2026-08-29 04:04:16 +00:00
|
|
|
const tvResult = tvById.get(item.id);
|
|
|
|
|
if (tvResult && tvResult.outcome.status === "found") {
|
|
|
|
|
const { seriesName, episodeTitle, airDate } = tvResult.outcome;
|
|
|
|
|
return {
|
|
|
|
|
...item,
|
|
|
|
|
bindings: {
|
|
|
|
|
...item.bindings,
|
|
|
|
|
// FileBot-style short names, for compatibility.
|
|
|
|
|
n: seriesName,
|
|
|
|
|
t: episodeTitle,
|
|
|
|
|
...(airDate ? { airdate: airDate } : {}),
|
|
|
|
|
// Human-readable aliases.
|
|
|
|
|
"show name": seriesName,
|
|
|
|
|
"episode name": episodeTitle,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const movieResult = movieById.get(item.id);
|
|
|
|
|
if (movieResult && movieResult.outcome.status === "found") {
|
|
|
|
|
const { title, releaseDate } = movieResult.outcome;
|
|
|
|
|
// Only overwrite the locally-guessed year if Match didn't already find one
|
|
|
|
|
// in the filename itself - a filename-carried year is usually more precise
|
|
|
|
|
// than TMDB's release date for disambiguating remakes.
|
|
|
|
|
const guessedYear = item.bindings?.y;
|
|
|
|
|
const releaseYear = releaseDate?.slice(0, 4);
|
|
|
|
|
return {
|
|
|
|
|
...item,
|
|
|
|
|
bindings: {
|
|
|
|
|
...item.bindings,
|
|
|
|
|
// FileBot-style short name, for compatibility (same `{n}` field TV uses).
|
|
|
|
|
n: title,
|
|
|
|
|
// Reuses the same `{airdate}` field TV uses - matches FileBot's own
|
|
|
|
|
// unified binding, which covers both episode air dates and movie
|
|
|
|
|
// release dates.
|
|
|
|
|
...(releaseDate ? { airdate: releaseDate } : {}),
|
|
|
|
|
...(!guessedYear && releaseYear ? { y: releaseYear, year: releaseYear } : {}),
|
|
|
|
|
// Human-readable alias.
|
|
|
|
|
"movie name": title,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return item;
|
2026-08-29 03:01:19 +00:00
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Showcase the episode title field, but only the first time and only if the user
|
|
|
|
|
// hasn't already customized the naming scheme away from the Match-set default.
|
2026-08-29 03:15:21 +00:00
|
|
|
if (expressionRef.current === MATCHED_EXPRESSION && found > 0) {
|
|
|
|
|
setExpression(FETCHED_EXPRESSION);
|
2026-08-29 03:01:19 +00:00
|
|
|
}
|
|
|
|
|
|
2026-08-29 04:04:16 +00:00
|
|
|
const parts = [`Fetched real metadata for ${found} of ${targetCount} file${targetCount === 1 ? "" : "s"}`];
|
2026-08-29 03:01:19 +00:00
|
|
|
if (notFound > 0) parts.push(`${notFound} not found on TMDB`);
|
|
|
|
|
if (failed > 0) parts.push(`${failed} failed`);
|
|
|
|
|
setStatus(parts.join(", ") + ".");
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setStatus(`Fetch failed: ${err}`);
|
|
|
|
|
} finally {
|
|
|
|
|
setFetching(false);
|
|
|
|
|
window.clearTimeout((runFetch as any)._t);
|
|
|
|
|
(runFetch as any)._t = window.setTimeout(() => setStatus(null), 5000);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-29 02:06:14 +00:00
|
|
|
const removeSelected = () => {
|
|
|
|
|
if (!selected) return notImplemented("Excluding items");
|
|
|
|
|
setItems((prev) => {
|
|
|
|
|
const idx = prev.findIndex((i) => i.id === selected);
|
|
|
|
|
const next = prev.filter((i) => i.id !== selected);
|
|
|
|
|
const fallback = next[Math.min(idx, next.length - 1)];
|
|
|
|
|
setSelected(fallback ? fallback.id : null);
|
|
|
|
|
return next;
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const clearAll = () => {
|
|
|
|
|
setItems([]);
|
|
|
|
|
setSelected(null);
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-29 09:39:52 +00:00
|
|
|
// "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]);
|
|
|
|
|
|
2026-08-29 02:06:14 +00:00
|
|
|
const matchedCount = useMemo(() => items.filter((i) => i.bindings).length, [items]);
|
|
|
|
|
|
|
|
|
|
const renderOriginalRow = (item: Item) => (
|
|
|
|
|
<Row
|
|
|
|
|
key={item.id}
|
|
|
|
|
selected={selected === item.id}
|
|
|
|
|
muted={!item.bindings}
|
|
|
|
|
onClick={() => setSelected(item.id)}
|
|
|
|
|
text={item.originalName}
|
|
|
|
|
badge={item.ext}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const renderNameRow = (item: Item) => {
|
|
|
|
|
const name = computedNames[item.id];
|
|
|
|
|
const [base, ext] = name ? splitExt(name) : [null, item.ext];
|
|
|
|
|
return (
|
|
|
|
|
<Row
|
|
|
|
|
key={item.id}
|
|
|
|
|
selected={selected === item.id}
|
|
|
|
|
muted={!item.bindings}
|
|
|
|
|
onClick={() => setSelected(item.id)}
|
|
|
|
|
text={item.bindings ? base ?? "…" : "No data"}
|
|
|
|
|
badge={ext}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
};
|
2026-08-29 01:55:54 +00:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="rename-panel">
|
|
|
|
|
<div className="rename-lists">
|
|
|
|
|
<ListCard
|
|
|
|
|
title="Original Files"
|
2026-08-29 02:06:14 +00:00
|
|
|
items={items}
|
|
|
|
|
renderRow={renderOriginalRow}
|
|
|
|
|
emptyHint="Drop files or folders here, or click Load"
|
2026-08-29 01:55:54 +00:00
|
|
|
buttonRow={
|
|
|
|
|
<>
|
2026-08-29 09:39:52 +00:00
|
|
|
<ListButton icon={<DownIcon />} label="Align Down" onClick={() => moveSelected("down")} />
|
|
|
|
|
<ListButton icon={<UpIcon />} label="Align Up" onClick={() => moveSelected("up")} />
|
2026-08-29 02:06:14 +00:00
|
|
|
<LoadMenuButton onFiles={loadFiles} onFolder={loadFolder} />
|
2026-08-29 01:55:54 +00:00
|
|
|
<span className="list-button-gap" />
|
2026-08-29 02:06:14 +00:00
|
|
|
<ListButton icon={<RemoveIcon />} label="Exclude Selected" onClick={removeSelected} />
|
|
|
|
|
<ListButton icon={<ClearIcon />} label="Clear" onClick={clearAll} />
|
2026-08-29 03:44:38 +00:00
|
|
|
<ListButton icon={<HistoryIcon />} label="History" onClick={openHistory} />
|
2026-08-29 09:39:52 +00:00
|
|
|
<ListButton icon={<PresetsIcon />} label="Presets" onClick={openPresets} />
|
2026-08-29 01:55:54 +00:00
|
|
|
</>
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<div className="rename-actions">
|
2026-08-29 02:42:31 +00:00
|
|
|
<button className="action-button" onClick={runMatching}>
|
2026-08-29 01:55:54 +00:00
|
|
|
Match
|
|
|
|
|
</button>
|
2026-08-29 02:42:31 +00:00
|
|
|
<button className="action-button primary" onClick={prepareRename}>
|
2026-08-29 01:55:54 +00:00
|
|
|
Rename
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<ListCard
|
|
|
|
|
title="New Names"
|
2026-08-29 02:06:14 +00:00
|
|
|
items={items}
|
|
|
|
|
renderRow={renderNameRow}
|
|
|
|
|
emptyHint="Naming-scheme preview appears here"
|
2026-08-29 01:55:54 +00:00
|
|
|
buttonRow={
|
|
|
|
|
<>
|
2026-08-29 09:39:52 +00:00
|
|
|
<ListButton icon={<DownIcon />} label="Align Down" onClick={() => moveSelected("down")} />
|
|
|
|
|
<ListButton icon={<UpIcon />} label="Align Up" onClick={() => moveSelected("up")} />
|
2026-08-29 02:06:14 +00:00
|
|
|
<LoadMenuButton onFiles={loadFiles} onFolder={loadFolder} />
|
2026-08-29 01:55:54 +00:00
|
|
|
<span className="list-button-gap" />
|
2026-08-29 03:01:19 +00:00
|
|
|
<ListButton icon={<FetchIcon />} label="Fetch Data" onClick={runFetch} disabled={fetching} />
|
|
|
|
|
<ListButton icon={<KeyIcon />} label="TMDB Settings" onClick={openSettings} />
|
2026-08-29 01:55:54 +00:00
|
|
|
<ListButton icon={<GearIcon />} label="Naming Scheme" onClick={() => setEditingFormat((v) => !v)} />
|
|
|
|
|
</>
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{editingFormat && (
|
|
|
|
|
<div className="format-editor">
|
|
|
|
|
<label htmlFor="expression">Naming scheme</label>
|
|
|
|
|
<input
|
|
|
|
|
id="expression"
|
|
|
|
|
value={expression}
|
|
|
|
|
spellCheck={false}
|
|
|
|
|
onChange={(e) => setExpression(e.target.value)}
|
|
|
|
|
/>
|
|
|
|
|
{computeError && <p className="format-error">{computeError}</p>}
|
|
|
|
|
<p className="format-hint">
|
2026-08-29 03:15:21 +00:00
|
|
|
This field is fully yours to edit — type any combination of literal text and{" "}
|
|
|
|
|
<code>{"{field}"}</code> placeholders and the preview updates live against {matchedCount} of{" "}
|
|
|
|
|
{items.length} file{items.length === 1 ? "" : "s"} (the real Rust naming-expression engine, through
|
|
|
|
|
Tauri). Every field below works as a plain <code>{"{field name}"}</code>, spaces and all — no coding
|
|
|
|
|
required — plus the whole thing also accepts real{" "}
|
|
|
|
|
<a href="https://rhai.rs/" target="_blank" rel="noreferrer">
|
|
|
|
|
Rhai
|
|
|
|
|
</a>{" "}
|
|
|
|
|
script for anyone who wants it (e.g. <code>{"{show name.to_upper()}"}</code>).
|
|
|
|
|
</p>
|
|
|
|
|
<div className="format-fields">
|
|
|
|
|
<div className="format-fields-group">
|
|
|
|
|
<span className="format-fields-group-title">Always available</span>
|
|
|
|
|
<code>{"{name}"}</code>
|
|
|
|
|
<code>{"{ext}"}</code>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="format-fields-group">
|
|
|
|
|
<span className="format-fields-group-title">
|
2026-08-29 04:04:16 +00:00
|
|
|
TV, after <strong>Match</strong> (local, offline)
|
2026-08-29 03:15:21 +00:00
|
|
|
</span>
|
|
|
|
|
<code>{"{show name}"}</code>
|
|
|
|
|
<code>{"{season number}"}</code>
|
|
|
|
|
<code>{"{episode number}"}</code>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="format-fields-group">
|
|
|
|
|
<span className="format-fields-group-title">
|
2026-08-29 04:04:16 +00:00
|
|
|
TV, after <strong>Fetch Data</strong> (real TMDB metadata)
|
2026-08-29 03:15:21 +00:00
|
|
|
</span>
|
|
|
|
|
<code>{"{episode name}"}</code>
|
|
|
|
|
<code>{"{airdate}"}</code>
|
|
|
|
|
</div>
|
2026-08-29 04:04:16 +00:00
|
|
|
<div className="format-fields-group">
|
|
|
|
|
<span className="format-fields-group-title">
|
|
|
|
|
Movies, after <strong>Match</strong> or <strong>Fetch Data</strong>
|
|
|
|
|
</span>
|
|
|
|
|
<code>{"{movie name}"}</code>
|
|
|
|
|
<code>{"{year}"}</code>
|
|
|
|
|
<code>{"{airdate}"}</code>
|
|
|
|
|
</div>
|
2026-08-29 09:29:14 +00:00
|
|
|
<div className="format-fields-group">
|
|
|
|
|
<span className="format-fields-group-title">
|
|
|
|
|
Release tags, after <strong>Match</strong> (TV or movies, local, offline)
|
|
|
|
|
</span>
|
|
|
|
|
<code>{"{resolution}"}</code>
|
|
|
|
|
<code>{"{source}"}</code>
|
|
|
|
|
<code>{"{video codec}"}</code>
|
|
|
|
|
<code>{"{audio codec}"}</code>
|
|
|
|
|
<code>{"{release group}"}</code>
|
|
|
|
|
</div>
|
2026-08-29 03:15:21 +00:00
|
|
|
</div>
|
|
|
|
|
<p className="format-hint">
|
2026-08-29 09:29:14 +00:00
|
|
|
Match scans filenames locally for season/episode or movie title/year patterns, plus release-tag text
|
|
|
|
|
like resolution, source (BluRay/HDTV/WEB-DL/…), video/audio codec, and release group — none of this
|
|
|
|
|
needs a network call, since it's all parsed straight from the filename itself (Renamr has no video/audio
|
|
|
|
|
file prober, so a tag that isn't in the filename text just won't be available). Fetch Data resolves the
|
|
|
|
|
season/episode or movie guess against real TMDB metadata for the real title, episode title, and
|
|
|
|
|
air/release date — needs a free TMDB API token set in <strong>TMDB Settings</strong> first. A batch can
|
|
|
|
|
freely mix TV and movie files — each file only fills in the fields that apply to it. FileBot's own short
|
|
|
|
|
field names (<code>{"{n}"}</code>, <code>{"{s00e00}"}</code>, <code>{"{t}"}</code>, <code>{"{y}"}</code>,
|
|
|
|
|
<code>{"{vf}"}</code>, <code>{"{vc}"}</code>, <code>{"{ac}"}</code>, <code>{"{group}"}</code>, etc.) still
|
2026-08-29 04:04:16 +00:00
|
|
|
work too, side by side with the names above.
|
2026-08-29 01:55:54 +00:00
|
|
|
</p>
|
2026-08-29 03:36:06 +00:00
|
|
|
<p className="format-hint">
|
|
|
|
|
Use <code>{"/"}</code> in the scheme to organize files into subfolders as part of renaming, e.g.{" "}
|
|
|
|
|
<code>{"{show name}/{episode name}"}</code> puts each show in its own folder. Missing folders are created
|
|
|
|
|
automatically; nothing outside the file's own folder tree is ever touched.
|
|
|
|
|
</p>
|
2026-08-29 01:55:54 +00:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{status && <div className="status-bar">{status}</div>}
|
2026-08-29 02:42:31 +00:00
|
|
|
|
|
|
|
|
{renamePlan && (
|
|
|
|
|
<div className="modal-overlay" onClick={cancelRename}>
|
|
|
|
|
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
|
|
|
|
|
<h2 className="modal-title">Confirm Rename</h2>
|
2026-08-29 04:31:16 +00:00
|
|
|
<div className="action-segmented" role="radiogroup" aria-label="What to do with these files">
|
|
|
|
|
{(["move", "copy", "symlink"] as const).map((a) => (
|
|
|
|
|
<button
|
|
|
|
|
key={a}
|
|
|
|
|
type="button"
|
|
|
|
|
className={`action-segmented-option${renameAction === a ? " selected" : ""}`}
|
|
|
|
|
onClick={() => setRenameAction(a)}
|
|
|
|
|
disabled={renaming}
|
|
|
|
|
>
|
|
|
|
|
{RENAME_ACTION_LABELS[a].button}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
2026-08-29 02:42:31 +00:00
|
|
|
<p className="modal-subtitle">
|
2026-08-29 04:31:16 +00:00
|
|
|
This will {RENAME_ACTION_LABELS[renameAction].verb} {renamePlan.length} file
|
|
|
|
|
{renamePlan.length === 1 ? "" : "s"} on disk right now. Review the list below before continuing — use
|
|
|
|
|
the ✕ on any row to drop just that file from this batch, or undo the whole batch afterward from the
|
|
|
|
|
History panel.
|
|
|
|
|
{renameAction !== "move" &&
|
|
|
|
|
" The original file is left exactly where it is; only the new copy/symlink is created."}
|
2026-08-29 02:42:31 +00:00
|
|
|
{renamePlanSkipped > 0 &&
|
|
|
|
|
` (${renamePlanSkipped} other file${renamePlanSkipped === 1 ? "" : "s"} excluded: no valid preview.)`}
|
2026-08-29 03:36:06 +00:00
|
|
|
{renamePlan.some((p) => p.newName.includes("/")) &&
|
2026-08-29 04:31:16 +00:00
|
|
|
` Some of these also ${
|
|
|
|
|
renameAction === "move" ? "move" : "place"
|
|
|
|
|
} the file into a new subfolder — any missing folders are created automatically.`}
|
2026-08-29 02:42:31 +00:00
|
|
|
</p>
|
|
|
|
|
<div className="modal-plan-list">
|
2026-08-29 04:12:35 +00:00
|
|
|
{renamePlan.length === 0 ? (
|
|
|
|
|
<div className="list-empty-hint">Every file was excluded — nothing left to rename.</div>
|
|
|
|
|
) : (
|
|
|
|
|
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>
|
|
|
|
|
<button
|
|
|
|
|
className="modal-plan-remove"
|
|
|
|
|
title="Exclude this file from the rename"
|
|
|
|
|
onClick={() => excludeFromPlan(p.id)}
|
|
|
|
|
disabled={renaming}
|
|
|
|
|
>
|
|
|
|
|
<RemoveIcon />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
))
|
|
|
|
|
)}
|
2026-08-29 02:42:31 +00:00
|
|
|
</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>
|
2026-08-29 04:12:35 +00:00
|
|
|
<button
|
|
|
|
|
className="action-button primary"
|
|
|
|
|
onClick={confirmRename}
|
|
|
|
|
disabled={renaming || renamePlan.length === 0}
|
|
|
|
|
>
|
2026-08-29 04:31:16 +00:00
|
|
|
{renaming
|
|
|
|
|
? RENAME_ACTION_LABELS[renameAction].progressive
|
|
|
|
|
: `${RENAME_ACTION_LABELS[renameAction].button} ${renamePlan.length} File${
|
|
|
|
|
renamePlan.length === 1 ? "" : "s"
|
|
|
|
|
}`}
|
2026-08-29 02:42:31 +00:00
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-08-29 03:01:19 +00:00
|
|
|
|
|
|
|
|
{settingsOpen && (
|
|
|
|
|
<div className="modal-overlay" onClick={() => !savingSettings && setSettingsOpen(false)}>
|
|
|
|
|
<div className="modal-card modal-card-compact" onClick={(e) => e.stopPropagation()}>
|
|
|
|
|
<h2 className="modal-title">TMDB Settings</h2>
|
|
|
|
|
<p className="modal-subtitle">
|
|
|
|
|
Fetch Data uses{" "}
|
|
|
|
|
<a href="https://www.themoviedb.org/" target="_blank" rel="noreferrer">
|
|
|
|
|
TheMovieDB
|
|
|
|
|
</a>{" "}
|
|
|
|
|
to resolve local guesses into real episode titles and air dates. Get a free{" "}
|
|
|
|
|
<strong>API Read Access Token</strong> from your TMDB account under Settings → API, then paste it below.
|
|
|
|
|
The token is stored locally on this machine and never leaves it except to call TMDB directly.
|
|
|
|
|
</p>
|
|
|
|
|
<label htmlFor="tmdb-token" className="modal-field-label">
|
|
|
|
|
API Read Access Token
|
|
|
|
|
</label>
|
|
|
|
|
<input
|
|
|
|
|
id="tmdb-token"
|
|
|
|
|
className="modal-text-input"
|
|
|
|
|
type="password"
|
|
|
|
|
autoComplete="off"
|
|
|
|
|
spellCheck={false}
|
|
|
|
|
value={tmdbTokenInput}
|
|
|
|
|
onChange={(e) => setTmdbTokenInput(e.target.value)}
|
|
|
|
|
placeholder={hasTmdbToken ? "•••••••••• (already saved — enter a new token to replace it)" : "Paste your TMDB token here"}
|
|
|
|
|
/>
|
|
|
|
|
<div className="modal-actions modal-actions-split">
|
|
|
|
|
{hasTmdbToken ? (
|
|
|
|
|
<button className="action-button" onClick={clearTmdbToken} disabled={savingSettings}>
|
|
|
|
|
Remove Token
|
|
|
|
|
</button>
|
|
|
|
|
) : (
|
|
|
|
|
<span />
|
|
|
|
|
)}
|
|
|
|
|
<div className="modal-actions">
|
|
|
|
|
<button className="action-button" onClick={() => setSettingsOpen(false)} disabled={savingSettings}>
|
|
|
|
|
Cancel
|
|
|
|
|
</button>
|
|
|
|
|
<button className="action-button primary" onClick={saveTmdbToken} disabled={savingSettings}>
|
|
|
|
|
{savingSettings ? "Saving…" : "Save"}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-08-29 03:44:38 +00:00
|
|
|
|
|
|
|
|
{historyOpen && (
|
|
|
|
|
<div className="modal-overlay" onClick={closeHistory}>
|
|
|
|
|
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
|
|
|
|
|
<h2 className="modal-title">Rename History</h2>
|
|
|
|
|
<p className="modal-subtitle">
|
2026-08-29 04:31:16 +00:00
|
|
|
Every completed batch is logged here so it can be undone. For a Move batch, undoing moves each file
|
|
|
|
|
back to its original path — if something else now exists there, that one file is skipped rather than
|
|
|
|
|
overwritten. For a Copy or Symlink batch, undoing just removes the copy/symlink that was created; the
|
|
|
|
|
original file was never touched, so there's nothing to move back. The log keeps the most recent 50
|
|
|
|
|
operations.
|
2026-08-29 03:44:38 +00:00
|
|
|
</p>
|
|
|
|
|
{historyBatches === null ? (
|
|
|
|
|
<p className="modal-note">Loading…</p>
|
|
|
|
|
) : historyBatches.length === 0 ? (
|
|
|
|
|
<p className="modal-note">No renames yet — this fills in after you use Rename.</p>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="modal-plan-list history-batch-list">
|
|
|
|
|
{historyBatches.map((batch) => (
|
|
|
|
|
<div className="history-batch" key={batch.id}>
|
|
|
|
|
<div className="history-batch-header">
|
|
|
|
|
<span className="history-batch-when">{new Date(batch.timestamp).toLocaleString()}</span>
|
|
|
|
|
<span className="history-batch-count">
|
|
|
|
|
{batch.entries.length} file{batch.entries.length === 1 ? "" : "s"}
|
2026-08-29 04:31:16 +00:00
|
|
|
{batch.action !== "move" && ` · ${RENAME_ACTION_LABELS[batch.action].button}`}
|
2026-08-29 03:44:38 +00:00
|
|
|
</span>
|
|
|
|
|
<button
|
|
|
|
|
className="action-button history-undo-button"
|
|
|
|
|
onClick={() => undoBatch(batch)}
|
|
|
|
|
disabled={undoingBatchId !== null}
|
|
|
|
|
>
|
|
|
|
|
{undoingBatchId === batch.id ? "Undoing…" : "Undo"}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="history-batch-entries">
|
|
|
|
|
{batch.entries.map((entry, i) => (
|
|
|
|
|
<div className="modal-plan-row" key={i}>
|
|
|
|
|
<span className="modal-plan-old" title={entry.newPath}>
|
|
|
|
|
{fileNameFromPath(entry.newPath)}
|
|
|
|
|
</span>
|
|
|
|
|
<span className="modal-plan-arrow">→</span>
|
|
|
|
|
<span className="modal-plan-new" title={entry.source}>
|
|
|
|
|
{fileNameFromPath(entry.source)}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
<div className="modal-actions">
|
|
|
|
|
<button className="action-button" onClick={closeHistory} disabled={undoingBatchId !== null}>
|
|
|
|
|
Close
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-08-29 09:39:52 +00:00
|
|
|
|
|
|
|
|
{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>
|
|
|
|
|
)}
|
2026-08-29 01:55:54 +00:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ListCard({
|
|
|
|
|
title,
|
2026-08-29 02:06:14 +00:00
|
|
|
items,
|
|
|
|
|
renderRow,
|
|
|
|
|
emptyHint,
|
2026-08-29 01:55:54 +00:00
|
|
|
buttonRow,
|
|
|
|
|
}: {
|
|
|
|
|
title: string;
|
2026-08-29 02:06:14 +00:00
|
|
|
items: Item[];
|
|
|
|
|
renderRow: (item: Item) => ReactNode;
|
|
|
|
|
emptyHint: string;
|
2026-08-29 01:55:54 +00:00
|
|
|
buttonRow: ReactNode;
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="list-card">
|
|
|
|
|
<span className="list-card-title">{title}</span>
|
2026-08-29 02:06:14 +00:00
|
|
|
<div className="list-card-body">
|
|
|
|
|
{items.length === 0 ? <div className="list-empty-hint">{emptyHint}</div> : items.map(renderRow)}
|
|
|
|
|
</div>
|
2026-08-29 01:55:54 +00:00
|
|
|
<div className="list-button-row">{buttonRow}</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function Row({
|
|
|
|
|
text,
|
|
|
|
|
badge,
|
|
|
|
|
selected,
|
|
|
|
|
muted,
|
|
|
|
|
onClick,
|
|
|
|
|
}: {
|
|
|
|
|
text: string;
|
|
|
|
|
badge: string | null;
|
|
|
|
|
selected: boolean;
|
|
|
|
|
muted: boolean;
|
|
|
|
|
onClick: () => void;
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
2026-08-29 02:06:14 +00:00
|
|
|
<div className={`list-row${selected ? " selected" : ""}${muted ? " muted" : ""}`} onClick={onClick}>
|
2026-08-29 01:55:54 +00:00
|
|
|
<span className="list-row-text">{text}</span>
|
|
|
|
|
{badge && <span className="list-row-badge">{badge}</span>}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function splitExt(name: string): [string, string] {
|
|
|
|
|
const i = name.lastIndexOf(".");
|
|
|
|
|
if (i <= 0) return [name, ""];
|
|
|
|
|
return [name.slice(0, i), name.slice(i + 1)];
|
|
|
|
|
}
|