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:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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