From 03768c398d167c8cb82dcee022ca4f8240574b85 Mon Sep 17 00:00:00 2001 From: Renamr Scaffold Date: Sat, 29 Aug 2026 17:28:38 +0000 Subject: [PATCH] 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. --- README.md | 63 ++++++++- src-tauri/src/lib.rs | 2 + src-tauri/src/list.rs | 61 ++++++++ src/App.tsx | 3 + src/components/ListPanel.css | 139 ++++++++++++++++++ src/components/ListPanel.tsx | 263 +++++++++++++++++++++++++++++++++++ src/main.tsx | 1 + 7 files changed, 526 insertions(+), 6 deletions(-) create mode 100644 src-tauri/src/list.rs create mode 100644 src/components/ListPanel.css create mode 100644 src/components/ListPanel.tsx diff --git a/README.md b/README.md index 0d36afd..81d7eb1 100644 --- a/README.md +++ b/README.md @@ -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 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 -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 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), `history.rs` (rename history/undo 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 plan against `aurora-curator-core::cli`'s parsed arguments, 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` exactly as the Rename panel's own "Match" does, just applied to 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/logo/icon-source.png`, cropped from the supplied 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 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) 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 credentials of its own, see "Episodes" above), the SFV panel (compute/verify 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 - surface at all, see "Filter" above) — the first four of the other five sidebar - panels to become real screens. The remaining one (List) is still a stub screen. + surface at all, see "Filter" above), and the List panel (a naming-scheme-style + 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 (swaps the selected file with its neighbor), not the original's drag-and-drop-within-a-list interaction. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index eea8bbe..14742d8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ mod cli; mod episodes; mod files; mod history; +mod list; mod metadata; mod presets; mod settings; @@ -121,6 +122,7 @@ pub fn run() { sfv::compute_checksums, sfv::save_checksum_file, sfv::verify_checksum_file, + list::export_text_list, history::list_rename_history, history::undo_rename_batch, presets::list_presets, diff --git a/src-tauri/src/list.rs b/src-tauri/src/list.rs new file mode 100644 index 0000000..bc0a002 --- /dev/null +++ b/src-tauri/src/list.rs @@ -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 `.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, 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"); + } +} diff --git a/src/App.tsx b/src/App.tsx index 1fb3746..5008f41 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,7 @@ import EpisodesPanel from "./components/EpisodesPanel"; import SubtitlesPanel from "./components/SubtitlesPanel"; import SfvPanel from "./components/SfvPanel"; import FilterPanel from "./components/FilterPanel"; +import ListPanel from "./components/ListPanel"; import PanelStub from "./components/PanelStub"; export default function App() { @@ -28,6 +29,8 @@ export default function App() { ) : selected === "filter" ? ( + ) : selected === "list" ? ( + ) : ( )} diff --git a/src/components/ListPanel.css b/src/components/ListPanel.css new file mode 100644 index 0000000..dd422dc --- /dev/null +++ b/src/components/ListPanel.css @@ -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); +} diff --git a/src/components/ListPanel.tsx b/src/components/ListPanel.tsx new file mode 100644 index 0000000..b2b5a7a --- /dev/null +++ b/src/components/ListPanel.tsx @@ -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([]); + const [bindings, setBindings] = useState>>({}); + const [scanning, setScanning] = useState(false); + const [expression, setExpression] = useState(DEFAULT_EXPRESSION); + const [lines, setLines] = useState>({}); + const [computeError, setComputeError] = useState(null); + const [exporting, setExporting] = useState(false); + const [status, setStatus] = useState(null); + + async function loadFiles() { + const picked = await invoke("pick_files"); + if (picked.length === 0) return; + setFiles(picked); + const base: Record> = {}; + 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>) { + setScanning(true); + try { + const results = await Promise.all( + targets.map(async (file): Promise]> => { + const releasePromise = invoke("guess_release_info", { + filename: displayName(file), + }).catch(() => null); + + let extra: Record = {}; + let matched = false; + try { + const episode = await invoke("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("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 = 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> = {}; + 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 => { + const b = bindings[file.path]; + if (!b) return [file.path, ""] as const; + try { + const rendered = await invoke("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 = {}; + 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("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 ( +
+
+ + + {files.length > 0 ? `${files.length} file${files.length === 1 ? "" : "s"} loaded` : "No files loaded"} + +
+ +
+ Format + setExpression(e.target.value)} + spellCheck={false} + /> +
+
+ Same {"{ }"} syntax as the naming scheme. {"{name}"}/{"{ext}"} always work;{" "} + {"{show name}"}/{"{season number}"}/{"{episode number}"},{" "} + {"{movie name}"}/{"{year}"}, and release tags fill in once a file is recognized - + local matching only, no Fetch Data step. +
+ +
+ {files.length === 0 &&
Load files to build a formatted list from them.
} + + {files.length > 0 && ( +
+ + {scanning ? "Scanning…" : `${nonEmptyCount} of ${files.length} lines rendered`} + +
+ + +
+
+ )} + + {computeError &&
{computeError}
} + + {files.map((file) => ( +
+ {lines[file.path] || (empty)} + {displayName(file)} +
+ ))} +
+ + {status &&
{status}
} +
+ ); +} diff --git a/src/main.tsx b/src/main.tsx index b2a57e2..7a773f0 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -10,6 +10,7 @@ import "./components/EpisodesPanel.css"; import "./components/SubtitlesPanel.css"; import "./components/SfvPanel.css"; import "./components/FilterPanel.css"; +import "./components/ListPanel.css"; import "./components/PanelStub.css"; ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(