Add List panel (session 9, fifteenth feature)

Renders a naming-scheme-style expression per loaded file via the same
render_expression engine the Rename panel's live preview uses, fed by
the same local guess_episode/guess_movie/guess_release_info matching
Filter already uses. Copy List and Export to File... (a new
export_text_list Tauri command - the only new backend surface this
feature needed) round it out. Closes out all six original sidebar
panels as real screens.
This commit is contained in:
Renamr Scaffold
2026-08-29 17:28:38 +00:00
parent bfcec9135a
commit 03768c398d
7 changed files with 526 additions and 6 deletions
+57 -6
View File
@@ -51,7 +51,13 @@ CRC32/MD5/SHA1 checksums for a loaded set of files, and reads and writes the cla
credentials of any kind; see "SFV" below. A new Filter panel loads a batch of files and credentials of any kind; see "SFV" below. A new Filter panel loads a batch of files and
narrows it down by extension, TV/Movie/Unmatched kind, filename text, and (for TV) season narrows it down by extension, TV/Movie/Unmatched kind, filename text, and (for TV) season
— all computed from the same local, filename-only matching "Match" uses, with a Copy File — all computed from the same local, filename-only matching "Match" uses, with a Copy File
Paths export for whatever's left; see "Filter" below. See "Porting roadmap" below. Paths export for whatever's left; see "Filter" below. A new List panel renders a
user-editable naming-scheme-style expression per loaded file (the same `{ }` syntax and
`render_expression` engine the Rename panel's live preview uses, fed by the same local
matching "Match"/Filter use), with a live "N of M lines rendered" count, a Copy List
clipboard export, and an Export to File… button that writes the whole list to a `.txt`
file via a native Save dialog — this closes out the full original panel set (Rename/
Episodes/Subtitles/SFV/Filter/List); see "List" below. See "Porting roadmap" below.
The naming scheme is fully user-editable from the app (the "Naming Scheme" button next The naming scheme is fully user-editable from the app (the "Naming Scheme" button next
to the New Names list) — the default is `{show name} - {season number}x{episode to the New Names list) — the default is `{show name} - {season number}x{episode
@@ -217,7 +223,13 @@ src-tauri/ Tauri app shell: exposes aurora-curator-core as commands
actual algorithm/format work), actual algorithm/format work),
`history.rs` (rename history/undo `history.rs` (rename history/undo
log), `presets.rs` (up to 9 saved naming schemes) — all three log), `presets.rs` (up to 9 saved naming schemes) — all three
stored as JSON in the OS app-config directory — `cli.rs` (the stored as JSON in the OS app-config directory — `list.rs` (a
single `export_text_list` command backing the List panel's Export
to File… button: opens a native "Save File" dialog and writes
whatever already-rendered, newline-joined text the frontend hands
it to a `.txt` file — the only new Tauri surface List needed, since
matching each file and rendering its line both reuse existing
commands, see below) — `cli.rs` (the
`-rename` command-line mode's orchestration: builds and executes a `-rename` command-line mode's orchestration: builds and executes a
rename plan against `aurora-curator-core::cli`'s parsed arguments, rename plan against `aurora-curator-core::cli`'s parsed arguments,
with no Tauri `App`/window ever created for it — `main.rs` branches with no Tauri `App`/window ever created for it — `main.rs` branches
@@ -254,7 +266,14 @@ src/ React + TypeScript UI (Vite). Faithful recreation of the
`pick_files`/`guess_episode`/`guess_movie`/`guess_release_info` `pick_files`/`guess_episode`/`guess_movie`/`guess_release_info`
exactly as the Rename panel's own "Match" does, just applied to exactly as the Rename panel's own "Match" does, just applied to
a second, independently-loaded file list and rendered as a second, independently-loaded file list and rendered as
criteria instead of a rename preview. criteria instead of a rename preview. `ListPanel.tsx` is the
fifth and last: load a set of files, edit a naming-scheme-style
expression (rendered per file via the same `render_expression`
command the Rename panel's live preview calls, fed by the same
local matching Filter uses), see a live "N of M lines rendered"
list, Copy List to the clipboard, or Export to File… via the one
new `export_text_list` command above — closing out the full
original panel set.
scripts/gen_icons.py Regenerates src-tauri/icons/ from the real logo mark scripts/gen_icons.py Regenerates src-tauri/icons/ from the real logo mark
(`scripts/logo/icon-source.png`, cropped from the supplied (`scripts/logo/icon-source.png`, cropped from the supplied
lockup) — replace that source image to change the look. lockup) — replace that source image to change the look.
@@ -471,6 +490,35 @@ that season. The results list updates live with an "N of M files match" count, a
**Copy File Paths** writes every currently-matching file's full path, one per line, to the **Copy File Paths** writes every currently-matching file's full path, one per line, to the
clipboard. clipboard.
## List
The List sidebar panel loads a batch of files (independent of whatever's loaded
elsewhere) and renders a naming-scheme-style expression against each one, so the result
can be copied or exported as a plain text list — a playlist, a batch-rename preview to
check by eye, a checklist for another tool, whatever the expression is written for.
It's the last of the six original sidebar panels (Rename/Episodes/Subtitles/SFV/Filter/
List) and, like Filter, adds no new local-matching logic — it reuses the exact same
`guess_episode`/`guess_movie`/`guess_release_info` calls "Match" and Filter already use.
The one genuinely new piece is a single Tauri command, `export_text_list`, which opens a
native Save dialog and writes whatever text the frontend hands it to a `.txt` file —
everything else (matching, and rendering each file's line) reuses commands that already
existed.
**Load Files…** loads a batch and scans it locally, the same way Filter does. The
**Format** field holds the expression rendered against each file — the exact same `{ }`
syntax the naming scheme uses, run through the same `render_expression` command the
Rename panel's live preview calls. `{name}`/`{ext}` always work (they're the file's own
name/extension); `{show name}`/`{season number}`/`{episode number}`, `{movie name}`/
`{year}`, and the release-tag fields (`{resolution}`/`{source}`/`{video codec}`/
`{audio codec}`/`{release group}`) fill in once the local scan recognizes a file — there's
no Fetch Data step here, List stays local-only like Filter. A file whose rendered line
comes out empty (e.g. an expression that only references a field this particular file
never matched) shows an "(empty)" placeholder rather than blocking the rest of the list,
and the results header tracks "N of M lines rendered" live as the expression changes.
**Copy List** copies every line, in file order, newline-joined, to the clipboard; **Export
to File…** opens a native Save dialog (defaulted to `list.txt`) and writes the same text
to disk.
## Porting roadmap (not yet done) ## Porting roadmap (not yet done)
Roughly in priority order — each references the original FileBot source file it's Roughly in priority order — each references the original FileBot source file it's
@@ -574,10 +622,13 @@ ported from, for anyone picking this up:
the Episodes panel (series search + season browse with a Copy list export, no the Episodes panel (series search + season browse with a Copy list export, no
credentials of its own, see "Episodes" above), the SFV panel (compute/verify credentials of its own, see "Episodes" above), the SFV panel (compute/verify
CRC32/MD5/SHA1 checksums, read/write `.sfv`/`.md5`/`.sha1` sidecar files, entirely CRC32/MD5/SHA1 checksums, read/write `.sfv`/`.md5`/`.sha1` sidecar files, entirely
local with no credentials at all, see "SFV" above), and the Filter panel (extension/ local with no credentials at all, see "SFV" above), the Filter panel (extension/
kind/filename/season narrowing over a locally-scanned batch, no new Rust/Tauri kind/filename/season narrowing over a locally-scanned batch, no new Rust/Tauri
surface at all, see "Filter" above) — the first four of the other five sidebar surface at all, see "Filter" above), and the List panel (a naming-scheme-style
panels to become real screens. The remaining one (List) is still a stub screen. expression rendered per file via the same `render_expression` engine, with a Copy
List clipboard export and an Export to File… `.txt` writer behind one new
`export_text_list` command, see "List" above) — **all six of the original sidebar
panels are now real screens**, closing out the panel set FileBot itself shipped.
Align Up/Down is button-based Align Up/Down is button-based
(swaps the selected file with its neighbor), not the original's (swaps the selected file with its neighbor), not the original's
drag-and-drop-within-a-list interaction. drag-and-drop-within-a-list interaction.
+2
View File
@@ -2,6 +2,7 @@ mod cli;
mod episodes; mod episodes;
mod files; mod files;
mod history; mod history;
mod list;
mod metadata; mod metadata;
mod presets; mod presets;
mod settings; mod settings;
@@ -121,6 +122,7 @@ pub fn run() {
sfv::compute_checksums, sfv::compute_checksums,
sfv::save_checksum_file, sfv::save_checksum_file,
sfv::verify_checksum_file, sfv::verify_checksum_file,
list::export_text_list,
history::list_rename_history, history::list_rename_history,
history::undo_rename_batch, history::undo_rename_batch,
presets::list_presets, presets::list_presets,
+61
View File
@@ -0,0 +1,61 @@
//! The Tauri-facing side of the List panel: exports a set of already-rendered text
//! lines (one per loaded file) to a plain text file the user picks via a native
//! "Save File" dialog. This is the *only* new Tauri surface List needs - matching
//! each file against local metadata and rendering a naming-scheme-style expression
//! per file both reuse commands that already exist: `guess_episode`/`guess_movie`/
//! `guess_release_info` (the same local guessers the Rename panel's "Match" and the
//! Filter panel's scan already call) and `render_expression` (the same engine the
//! Rename panel's live preview already calls). The frontend builds the final,
//! newline-joined text; this module's only job is writing it to a file the user
//! chooses, the same async native-dialog pattern `sfv::save_checksum_file` uses.
use tauri::AppHandle;
use tauri_plugin_dialog::DialogExt;
/// The `.txt`-suffixed file name to default the Save dialog to.
fn default_export_filename(default_name: &str) -> String {
format!("{default_name}.txt")
}
/// Opens a native "Save File" dialog defaulted to `<default_name>.txt`, and if the
/// user doesn't cancel, writes `content` there verbatim (already newline-joined by
/// the frontend, one rendered line per loaded file). Returns the saved path, or
/// `None` if the user cancelled the dialog (not an error) - same shape as
/// `sfv::save_checksum_file`.
#[tauri::command]
pub async fn export_text_list(app: AppHandle, default_name: String, content: String) -> Result<Option<String>, String> {
let (tx, rx) = tokio::sync::oneshot::channel();
app.dialog()
.file()
.set_file_name(default_export_filename(&default_name))
.save_file(move |result| {
let _ = tx.send(result);
});
let chosen = rx.await.map_err(|e| e.to_string())?;
let Some(file_path) = chosen else {
return Ok(None);
};
let path = file_path.into_path().map_err(|e| e.to_string())?;
tauri::async_runtime::spawn_blocking({
let path = path.clone();
move || std::fs::write(&path, content)
})
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?;
Ok(Some(path.to_string_lossy().to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_export_filename_appends_txt_extension() {
assert_eq!(default_export_filename("my-list"), "my-list.txt");
assert_eq!(default_export_filename(""), ".txt");
}
}
+3
View File
@@ -6,6 +6,7 @@ import EpisodesPanel from "./components/EpisodesPanel";
import SubtitlesPanel from "./components/SubtitlesPanel"; import SubtitlesPanel from "./components/SubtitlesPanel";
import SfvPanel from "./components/SfvPanel"; import SfvPanel from "./components/SfvPanel";
import FilterPanel from "./components/FilterPanel"; import FilterPanel from "./components/FilterPanel";
import ListPanel from "./components/ListPanel";
import PanelStub from "./components/PanelStub"; import PanelStub from "./components/PanelStub";
export default function App() { export default function App() {
@@ -28,6 +29,8 @@ export default function App() {
<SfvPanel /> <SfvPanel />
) : selected === "filter" ? ( ) : selected === "filter" ? (
<FilterPanel /> <FilterPanel />
) : selected === "list" ? (
<ListPanel />
) : ( ) : (
<PanelStub name={current.name} /> <PanelStub name={current.name} />
)} )}
+139
View File
@@ -0,0 +1,139 @@
.list-panel {
height: 100%;
min-width: 0;
display: flex;
flex-direction: column;
gap: 10px;
padding: 18px;
overflow-y: auto;
}
.list-toolbar {
display: flex;
align-items: center;
gap: 14px;
background: var(--card);
border: 1px solid var(--surface-deep);
border-radius: 12px;
padding: 14px;
}
.list-loaded-count {
font-size: 12px;
color: var(--muted-text);
}
.list-format {
display: flex;
align-items: center;
gap: 12px;
background: var(--card);
border: 1px solid var(--surface-deep);
border-radius: 12px;
padding: 12px 14px;
}
.list-format-label {
flex: 0 0 auto;
font-size: 11.5px;
color: var(--muted-text);
}
.list-format-input {
flex: 1;
min-width: 0;
font-family: "SF Mono", Menlo, monospace;
}
.list-format-hint {
font-size: 11.5px;
color: var(--muted-text);
padding: 0 2px;
line-height: 1.5;
}
.list-format-hint code {
background: var(--surface-deep);
border-radius: 4px;
padding: 1px 4px;
font-size: 11px;
}
.list-results {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.list-results-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 2px 2px 4px;
}
.list-results-actions {
display: flex;
gap: 8px;
}
.list-match-count {
font-size: 13px;
font-weight: 600;
}
.list-error {
color: #ff8a8a;
font-size: 12px;
padding: 4px 2px;
}
.list-row {
display: flex;
align-items: center;
gap: 12px;
background: var(--card);
border: 1px solid var(--surface-deep);
border-radius: 10px;
padding: 9px 12px;
}
.list-row-line {
flex: 1;
min-width: 0;
font-size: 12.5px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.list-row-empty {
color: var(--muted-text);
font-weight: 400;
font-style: italic;
}
.list-row-name {
flex: 0 0 auto;
max-width: 40%;
font-size: 11.5px;
color: var(--muted-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.list-empty-note {
color: var(--muted-text);
font-size: 12.5px;
padding: 8px 2px;
}
.list-status {
font-size: 12px;
color: var(--aurora-cyan);
}
+263
View File
@@ -0,0 +1,263 @@
import { useEffect, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
interface FileEntry {
path: string;
name: string; // filename without extension
ext: string;
}
// Same local, filename-only guessers "Match" (Rename) and the Filter panel's scan
// already use (aurora-curator-core's `matching` module) - no network calls, so a
// loaded batch can be scanned and formatted instantly.
interface EpisodeGuess {
seriesName: string;
season: number;
episode: number;
}
interface MovieGuess {
title: string;
year: number | null;
}
interface ReleaseInfo {
resolution: string | null;
source: string | null;
videoCodec: string | null;
audioCodec: string | null;
group: string | null;
}
// The naming-scheme-style expression List renders per file, reusing exactly the same
// `render_expression` command (and therefore the same `{ }` syntax) the Rename panel's
// live preview uses. `{name}`/`{ext}` always work; every other field only fills in once
// the local scan below recognizes it (no Fetch Data step here - List stays local-only,
// same choice Filter made, since neither panel touches the filesystem or the network).
const DEFAULT_EXPRESSION = "{name}";
function pad2(n: number): string {
return n.toString().padStart(2, "0");
}
function displayName(file: FileEntry): string {
return file.ext ? `${file.name}.${file.ext}` : file.name;
}
export default function ListPanel() {
const [files, setFiles] = useState<FileEntry[]>([]);
const [bindings, setBindings] = useState<Record<string, Record<string, string>>>({});
const [scanning, setScanning] = useState(false);
const [expression, setExpression] = useState(DEFAULT_EXPRESSION);
const [lines, setLines] = useState<Record<string, string>>({});
const [computeError, setComputeError] = useState<string | null>(null);
const [exporting, setExporting] = useState(false);
const [status, setStatus] = useState<string | null>(null);
async function loadFiles() {
const picked = await invoke<FileEntry[]>("pick_files");
if (picked.length === 0) return;
setFiles(picked);
const base: Record<string, Record<string, string>> = {};
for (const file of picked) base[file.path] = { name: file.name, ext: file.ext };
setBindings(base);
setStatus(null);
await scan(picked, base);
}
async function scan(targets: FileEntry[], base: Record<string, Record<string, string>>) {
setScanning(true);
try {
const results = await Promise.all(
targets.map(async (file): Promise<readonly [string, Record<string, string>]> => {
const releasePromise = invoke<ReleaseInfo | null>("guess_release_info", {
filename: displayName(file),
}).catch(() => null);
let extra: Record<string, string> = {};
let matched = false;
try {
const episode = await invoke<EpisodeGuess | null>("guess_episode", { filename: displayName(file) });
if (episode) {
matched = true;
extra = {
n: episode.seriesName,
s00e00: `S${pad2(episode.season)}E${pad2(episode.episode)}`,
season: String(episode.season),
episode: String(episode.episode),
"show name": episode.seriesName,
"season number": String(episode.season),
"episode number": pad2(episode.episode),
};
}
} catch {
// Fall through and try a movie guess instead.
}
if (!matched) {
try {
const movie = await invoke<MovieGuess | null>("guess_movie", { filename: displayName(file) });
if (movie) {
extra = {
n: movie.title,
...(movie.year ? { y: String(movie.year) } : {}),
"movie name": movie.title,
...(movie.year ? { year: String(movie.year) } : {}),
};
}
} catch {
// Neither guess worked - the file just keeps its base {name}/{ext} bindings.
}
}
const release = await releasePromise;
const releaseBindings: Record<string, string> = 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 } : {}),
}
: {};
return [file.path, { ...base[file.path], ...extra, ...releaseBindings }] as const;
})
);
const next: Record<string, Record<string, string>> = {};
let matchedCount = 0;
for (const [path, b] of results) {
next[path] = b;
if (b.n) matchedCount += 1;
}
setBindings(next);
setStatus(
`Scanned ${targets.length} file${targets.length === 1 ? "" : "s"} locally (${matchedCount} matched; no network calls).`
);
} finally {
setScanning(false);
}
}
// Re-render every file's line whenever the expression or the underlying bindings
// change - same live-preview pattern the Rename panel's naming-scheme editor uses,
// via the same `render_expression` command. A single file whose rendered output
// ends up empty (e.g. an expression referencing a field this batch never matched)
// just shows a blank line rather than blocking the rest of the list.
useEffect(() => {
let cancelled = false;
let firstError: string | null = null;
Promise.all(
files.map(async (file): Promise<readonly [string, string]> => {
const b = bindings[file.path];
if (!b) return [file.path, ""] as const;
try {
const rendered = await invoke<string>("render_expression", { expression, bindings: b });
return [file.path, rendered] as const;
} catch (err) {
if (!firstError) firstError = String(err);
return [file.path, ""] as const;
}
})
).then((entries) => {
if (cancelled) return;
const next: Record<string, string> = {};
for (const [path, line] of entries) next[path] = line;
setLines(next);
setComputeError(firstError);
});
return () => {
cancelled = true;
};
}, [expression, files, bindings]);
const orderedLines = files.map((f) => lines[f.path] ?? "");
const nonEmptyCount = orderedLines.filter((l) => l.trim() !== "").length;
function copyList() {
if (files.length === 0) return;
navigator.clipboard
.writeText(orderedLines.join("\n"))
.then(() => setStatus(`Copied ${files.length} line${files.length === 1 ? "" : "s"} to the clipboard.`))
.catch(() => setStatus("Couldn't copy to the clipboard."));
}
async function exportList() {
if (files.length === 0) return;
setExporting(true);
try {
const savedPath = await invoke<string | null>("export_text_list", {
defaultName: "list",
content: orderedLines.join("\n"),
});
setStatus(savedPath ? `Saved to ${savedPath}` : "Export cancelled.");
} catch (err) {
setStatus(String(err));
} finally {
setExporting(false);
}
}
return (
<div className="list-panel">
<div className="list-toolbar">
<button className="action-button" onClick={loadFiles}>
Load Files…
</button>
<span className="list-loaded-count">
{files.length > 0 ? `${files.length} file${files.length === 1 ? "" : "s"} loaded` : "No files loaded"}
</span>
</div>
<div className="list-format">
<span className="list-format-label">Format</span>
<input
className="modal-text-input list-format-input"
type="text"
value={expression}
onChange={(e) => setExpression(e.target.value)}
spellCheck={false}
/>
</div>
<div className="list-format-hint">
Same <code>{"{ }"}</code> syntax as the naming scheme. <code>{"{name}"}</code>/<code>{"{ext}"}</code> always work;{" "}
<code>{"{show name}"}</code>/<code>{"{season number}"}</code>/<code>{"{episode number}"}</code>,{" "}
<code>{"{movie name}"}</code>/<code>{"{year}"}</code>, and release tags fill in once a file is recognized -
local matching only, no Fetch Data step.
</div>
<div className="list-results">
{files.length === 0 && <div className="list-empty-note">Load files to build a formatted list from them.</div>}
{files.length > 0 && (
<div className="list-results-header">
<span className="list-match-count">
{scanning ? "Scanning…" : `${nonEmptyCount} of ${files.length} lines rendered`}
</span>
<div className="list-results-actions">
<button className="action-button" onClick={copyList} disabled={files.length === 0}>
Copy List
</button>
<button className="action-button" onClick={exportList} disabled={exporting || files.length === 0}>
{exporting ? "Exporting…" : "Export to File…"}
</button>
</div>
</div>
)}
{computeError && <div className="list-error">{computeError}</div>}
{files.map((file) => (
<div className="list-row" key={file.path}>
<span className="list-row-line">{lines[file.path] || <span className="list-row-empty">(empty)</span>}</span>
<span className="list-row-name">{displayName(file)}</span>
</div>
))}
</div>
{status && <div className="list-status">{status}</div>}
</div>
);
}
+1
View File
@@ -10,6 +10,7 @@ import "./components/EpisodesPanel.css";
import "./components/SubtitlesPanel.css"; import "./components/SubtitlesPanel.css";
import "./components/SfvPanel.css"; import "./components/SfvPanel.css";
import "./components/FilterPanel.css"; import "./components/FilterPanel.css";
import "./components/ListPanel.css";
import "./components/PanelStub.css"; import "./components/PanelStub.css";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(