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:
@@ -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() {
|
||||
<SfvPanel />
|
||||
) : selected === "filter" ? (
|
||||
<FilterPanel />
|
||||
) : selected === "list" ? (
|
||||
<ListPanel />
|
||||
) : (
|
||||
<PanelStub name={current.name} />
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user