Add organizing files into Artist/Album/01 Title from their tags
A new Organize tab renames audio files from their tags into a per-pair
naming pattern, by default {artist}/{album}/{track} {title} (album
artist, falling back to artist). Multi-disc albums get a disc prefix
(2-04) or a Disc N folder; names are made safe for Windows/SMB shares.
- Preview first: every rename, files already organized, and files
skipped with the reason (missing tags, with a button into the tag
editor; two files resolving to the same name; an existing file).
- Applying re-checks each file against the preview, moves it within its
library (hardlinks and contents untouched, case-only renames via a
temporary name, completed on startup after a crash), carries .lrc
lyrics and folder cover art along, and removes emptied folders. Each
move is an operation that can be undone; the library is rescanned.
- Optional automatic organizing of the slave, master or both during
scans (off by default). Dry run applies.
- Scans now recognise a slave file that is a hardlink of a master file
at a different path as linked ("same file, other path") instead of
unmatched.
Migration 7 adds the per-pair naming settings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3eb02e2c8d
commit
dbcf562f7e
16 files changed
+1480
-11
No files matched your search
+28
-3
@@ -76,6 +76,9 @@ export type Pair = {
|
||||
settle_minutes: number;
|
||||
approx_matching: boolean;
|
||||
approx_threshold: number;
|
||||
organize_template: string;
|
||||
organize_multidisc: "prefix" | "folder" | "none";
|
||||
auto_organize: "off" | "slave" | "master" | "both";
|
||||
last_scan_at: number | null;
|
||||
last_scan_error: string;
|
||||
last_scan_warnings: ScanWarning[];
|
||||
@@ -96,6 +99,9 @@ export type PairInput = Pick<
|
||||
| "settle_minutes"
|
||||
| "approx_matching"
|
||||
| "approx_threshold"
|
||||
| "organize_template"
|
||||
| "organize_multidisc"
|
||||
| "auto_organize"
|
||||
>;
|
||||
|
||||
export type ProbeInfo = {
|
||||
@@ -123,7 +129,7 @@ export type Match = {
|
||||
pair_id: number;
|
||||
rel_slave: string;
|
||||
rel_master: string;
|
||||
kind: "path" | "path_case" | "extension" | "fuzzy" | "elsewhere" | "approximate" | "none";
|
||||
kind: "path" | "path_case" | "extension" | "fuzzy" | "elsewhere" | "approximate" | "inode" | "none";
|
||||
status: Status;
|
||||
master_size: number;
|
||||
master_mtime: number;
|
||||
@@ -141,7 +147,7 @@ export type Match = {
|
||||
export type Operation = {
|
||||
id: number;
|
||||
pair_id: number;
|
||||
action: "link" | "promote" | "build" | "remove" | "rename" | "tags";
|
||||
action: "link" | "promote" | "build" | "remove" | "rename" | "tags" | "move";
|
||||
rel_master: string;
|
||||
rel_slave: string;
|
||||
rel_new: string;
|
||||
@@ -156,7 +162,13 @@ export type Operation = {
|
||||
finished_at: number | null;
|
||||
undone_at: number | null;
|
||||
purged_at: number | null;
|
||||
detail?: { side?: "master" | "slave"; mode?: "replace" | "inplace"; changes?: Record<string, [string, string]> };
|
||||
detail?: {
|
||||
side?: "master" | "slave";
|
||||
mode?: "replace" | "inplace";
|
||||
changes?: Record<string, [string, string]>;
|
||||
sidecars?: [string, string][];
|
||||
art?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type TreeEntry = {
|
||||
@@ -228,3 +240,16 @@ export type TagFile = {
|
||||
links: number;
|
||||
size: number;
|
||||
};
|
||||
|
||||
export type OrganizeItem = { id: number; side: Side; from: string; to?: string; move: boolean; reason?: string; sidecars?: string[] };
|
||||
|
||||
export type OrganizePlan = {
|
||||
pair_id: number;
|
||||
sides: Side[];
|
||||
template: string;
|
||||
created_at: number;
|
||||
scanned: number;
|
||||
conforming: number;
|
||||
moves: number;
|
||||
skips: Record<string, number>;
|
||||
};
|
||||
@@ -38,6 +38,7 @@ const actionLabel: Record<Operation["action"], string> = {
|
||||
remove: "Removed from slave",
|
||||
rename: "Renamed slave folder",
|
||||
tags: "Edited tags",
|
||||
move: "Renamed file",
|
||||
};
|
||||
|
||||
const statusTone: Record<Operation["status"], "green" | "red" | "blue" | "amber" | "gray"> = {
|
||||
@@ -97,7 +98,7 @@ export function OpsTable({ pairId, quarantine }: { pairId?: number; quarantine?:
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate mono text-[13px]" title={o.rel_slave || o.rel_master}>
|
||||
{o.action === "tags" && <span className="mr-1 font-sans text-[11px] text-muted">{o.detail?.side}</span>}
|
||||
{(o.action === "tags" || o.action === "move") && <span className="mr-1 font-sans text-[11px] text-muted">{o.detail?.side}</span>}
|
||||
{o.rel_slave || o.rel_master}
|
||||
</div>
|
||||
{o.detail?.changes && (
|
||||
@@ -189,6 +190,8 @@ function undoText(o: Operation) {
|
||||
return "The hardlink is removed from the slave library. The master file is not affected.";
|
||||
case "remove":
|
||||
return "The hardlink is recreated in the slave library.";
|
||||
case "move":
|
||||
return `The file is moved back from "${o.rel_new}" to "${o.rel_slave || o.rel_master}"${o.detail?.sidecars?.length ? ", with its lyrics file" : ""}, and the library is rescanned.`;
|
||||
case "tags":
|
||||
return o.detail?.mode === "inplace"
|
||||
? "The previous version of the file is written back from quarantine, restoring the old tags everywhere the file is linked."
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertTriangle, ArrowRight, FolderTree, Tags as TagsIcon } from "lucide-react";
|
||||
import { api, type OrganizeItem, type OrganizePlan, type Pair, type Side } from "../api";
|
||||
import { useInfo } from "../App";
|
||||
import { Alert, Badge, Button, Card, Empty, Field, Modal, Spinner, cx, formatAgo, inputCls } from "../ui";
|
||||
import { useToast } from "../toast";
|
||||
import { TagEditor } from "./Tags";
|
||||
|
||||
const TOKENS: [string, string][] = [
|
||||
["artist", "album artist, or artist"],
|
||||
["album", "album"],
|
||||
["track", "track number, 01"],
|
||||
["title", "title"],
|
||||
["year", "year"],
|
||||
["disc", "disc number"],
|
||||
["track_artist", "the song's artist"],
|
||||
["genre", "genre"],
|
||||
];
|
||||
|
||||
const SAMPLE: Record<string, string> = {
|
||||
artist: "Avenged Sevenfold",
|
||||
album: "Nightmare",
|
||||
track: "01",
|
||||
title: "Nightmare",
|
||||
year: "2010",
|
||||
disc: "1",
|
||||
track_artist: "Avenged Sevenfold",
|
||||
genre: "Metal",
|
||||
};
|
||||
|
||||
/** Renders the pattern for a sample song; disc 2 shows the multi-disc style. */
|
||||
function example(tpl: string, multi?: Pair["organize_multidisc"]) {
|
||||
const values: Record<string, string> = multi
|
||||
? { ...SAMPLE, track: multi === "prefix" ? "2-04" : "04", title: "Victim", disc: "2" }
|
||||
: SAMPLE;
|
||||
let parts = tpl.split("/").map((part) => part.replace(/\{([a-z_]+)\}/g, (_, k: string) => values[k] ?? `{${k}}`));
|
||||
if (multi === "folder") parts = [...parts.slice(0, -1), "Disc 2", parts[parts.length - 1]];
|
||||
return parts.join("/") + ".flac";
|
||||
}
|
||||
|
||||
type Scope = Side | "both";
|
||||
|
||||
export function OrganizeTab({ pair }: { pair: Pair }) {
|
||||
const info = useInfo();
|
||||
if (info.data && !info.data.tag_editing)
|
||||
return <Alert tone="amber">Organizing reads tags with ffmpeg and ffprobe, which were not found. The Docker image includes both.</Alert>;
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<NamingSettings pair={pair} />
|
||||
<Preview pair={pair} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NamingSettings({ pair }: { pair: Pair }) {
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const [tpl, setTpl] = useState(pair.organize_template);
|
||||
const [multi, setMulti] = useState(pair.organize_multidisc);
|
||||
const [auto, setAuto] = useState(pair.auto_organize);
|
||||
useEffect(() => {
|
||||
setTpl(pair.organize_template);
|
||||
setMulti(pair.organize_multidisc);
|
||||
setAuto(pair.auto_organize);
|
||||
}, [pair.id, pair.organize_template, pair.organize_multidisc, pair.auto_organize]);
|
||||
const dirty = tpl !== pair.organize_template || multi !== pair.organize_multidisc || auto !== pair.auto_organize;
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
api.put(`/pairs/${pair.id}`, {
|
||||
name: pair.name,
|
||||
master_root: pair.master_root,
|
||||
slave_root: pair.slave_root,
|
||||
quarantine_dir: pair.quarantine_dir,
|
||||
extensions: pair.extensions,
|
||||
auto_link_identical: pair.auto_link_identical,
|
||||
scan_interval_minutes: pair.scan_interval_minutes,
|
||||
retention_days: pair.retention_days,
|
||||
settle_minutes: pair.settle_minutes,
|
||||
approx_matching: pair.approx_matching,
|
||||
approx_threshold: pair.approx_threshold,
|
||||
organize_template: tpl,
|
||||
organize_multidisc: multi,
|
||||
auto_organize: auto,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast("ok", "Naming settings saved. Preview again to see the effect.");
|
||||
qc.invalidateQueries({ queryKey: ["pair", pair.id] });
|
||||
qc.invalidateQueries({ queryKey: ["pairs"] });
|
||||
},
|
||||
onError: (e) => toast("err", e.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<Card className="space-y-4 p-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="flex items-center gap-2 font-semibold">
|
||||
<FolderTree className="size-4 text-accent" /> File naming
|
||||
</h2>
|
||||
<p className="text-sm text-muted">Files are renamed from their tags into this layout. Nothing else about a file changes.</p>
|
||||
</div>
|
||||
<Button variant="primary" size="sm" disabled={!dirty} loading={save.isPending} onClick={() => save.mutate()}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
<Field label="Naming pattern" hint={<>Folders are separated by <code className="mono">/</code>. The file extension is added automatically.</>}>
|
||||
<input className={inputCls + " mono"} value={tpl} onChange={(e) => setTpl(e.target.value)} spellCheck={false} />
|
||||
</Field>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{TOKENS.map(([t, help]) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
title={help}
|
||||
className="rounded-md border border-line bg-panel-2 px-2 py-0.5 text-xs mono text-gray-300 hover:border-accent cursor-pointer"
|
||||
onClick={() => setTpl((v) => v + `{${t}}`)}
|
||||
>
|
||||
{`{${t}}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="rounded-lg border border-line bg-bg/60 p-3 text-xs">
|
||||
<div className="text-muted">Example</div>
|
||||
<div className="mono break-all text-gray-100">{example(tpl)}</div>
|
||||
{multi !== "none" && (
|
||||
<div className="mono break-all text-gray-400">
|
||||
{example(tpl, multi)} <span className="font-sans text-muted">(disc 2 of a multi-disc album)</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field label="Multi-disc albums" hint="Keeps disc 1 and disc 2 tracks from getting the same name.">
|
||||
<select className={inputCls} value={multi} onChange={(e) => setMulti(e.target.value as Pair["organize_multidisc"])}>
|
||||
<option value="prefix">Disc number before the track (2-04 Title)</option>
|
||||
<option value="folder">A folder per disc (Disc 2/04 Title)</option>
|
||||
<option value="none">Nothing (clashing names are skipped)</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Organize automatically" hint="During every scan, clean renames are applied without a preview.">
|
||||
<select className={inputCls} value={auto} onChange={(e) => setAuto(e.target.value as Pair["auto_organize"])}>
|
||||
<option value="off">Off: preview and apply by hand</option>
|
||||
<option value="slave">Slave library</option>
|
||||
<option value="master">Master library</option>
|
||||
<option value="both">Both libraries</option>
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
{auto !== "off" && (
|
||||
<Alert tone="amber" icon={<AlertTriangle className="mt-0.5 size-4 shrink-0" />}>
|
||||
Files are renamed during each scan without asking. Every rename is listed in History and can be undone, and files with missing tags or
|
||||
clashing names are left alone. Dry run still applies.
|
||||
{auto !== "both" && " Organizing only one library can make paths differ from the other; hardlinked songs stay paired either way."}
|
||||
</Alert>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Preview({ pair }: { pair: Pair }) {
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const dry = useInfo().data?.dry_run ?? true;
|
||||
const [scope, setScope] = useState<Scope>("both");
|
||||
const [view, setView] = useState<"move" | "skip">("move");
|
||||
const [sel, setSel] = useState<Set<number> | null>(null); // null = all
|
||||
const [confirm, setConfirm] = useState(false);
|
||||
const [editTags, setEditTags] = useState<OrganizeItem | null>(null);
|
||||
const running = pair.job?.state === "running";
|
||||
|
||||
const plan = useQuery({
|
||||
queryKey: ["organize", pair.id, view],
|
||||
queryFn: () =>
|
||||
api.get<{ plan: OrganizePlan | null; items?: OrganizeItem[]; total?: number }>(`/pairs/${pair.id}/organize/plan?filter=${view}&limit=500`),
|
||||
});
|
||||
useEffect(() => setSel(null), [plan.data?.plan?.created_at]);
|
||||
|
||||
const preview = useMutation({
|
||||
mutationFn: () => api.post(`/pairs/${pair.id}/organize/preview`, { side: scope }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["pair", pair.id] }),
|
||||
onError: (e) => toast("err", e.message),
|
||||
});
|
||||
const apply = useMutation({
|
||||
mutationFn: () => api.post(`/pairs/${pair.id}/organize/apply`, { ids: sel ? [...sel] : [] }),
|
||||
onSuccess: () => {
|
||||
setConfirm(false);
|
||||
toast("ok", dry ? "Started (dry run): renames are recorded as planned only" : "Renaming files. The library is rescanned afterwards.");
|
||||
qc.invalidateQueries({ queryKey: ["pair", pair.id] });
|
||||
},
|
||||
onError: (e) => toast("err", e.message),
|
||||
});
|
||||
|
||||
const p = plan.data?.plan;
|
||||
const items = plan.data?.items ?? [];
|
||||
const skipped = p ? Object.values(p.skips).reduce((a, b) => a + b, 0) : 0;
|
||||
const chosen = sel ? sel.size : (p?.moves ?? 0);
|
||||
const both = (p?.sides.length ?? 0) > 1;
|
||||
|
||||
return (
|
||||
<Card className="space-y-4 p-5">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-semibold">Preview and apply</h2>
|
||||
<p className="text-sm text-muted">See what would be renamed before anything changes.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select className={inputCls + " h-8 w-auto text-xs"} value={scope} onChange={(e) => setScope(e.target.value as Scope)}>
|
||||
<option value="both">Both libraries (recommended)</option>
|
||||
<option value="slave">Slave library</option>
|
||||
<option value="master">Master library</option>
|
||||
</select>
|
||||
<Button size="sm" onClick={() => preview.mutate()} loading={preview.isPending} disabled={running}>
|
||||
Preview renames
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{running && pair.job?.kind.startsWith("organize") && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted">
|
||||
<Spinner className="size-4" /> {pair.job.phase || "Working"}… {pair.job.total > 0 && `${pair.job.done} / ${pair.job.total}`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!p && !running && (
|
||||
<Empty icon={<FolderTree className="size-8" />} title="No preview yet">
|
||||
Choose which library to organize and click <b>Preview renames</b>. Organizing both keeps their paths in step, so songs keep matching.
|
||||
</Empty>
|
||||
)}
|
||||
|
||||
{p && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
<Stat label="checked" value={p.scanned} />
|
||||
<Stat label="already organized" value={p.conforming} tone="text-emerald-300" />
|
||||
<Stat label="to rename" value={p.moves} tone="text-accent" />
|
||||
<Stat label="skipped" value={skipped} tone={skipped ? "text-amber-300" : undefined} />
|
||||
</div>
|
||||
<div className="text-xs text-muted">
|
||||
Preview of the {p.sides.join(" and ")} {both ? "libraries" : "library"} · {formatAgo(p.created_at)} · pattern{" "}
|
||||
<code className="mono">{p.template}</code>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 border-b border-line">
|
||||
{(["move", "skip"] as const).map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
onClick={() => setView(v)}
|
||||
className={cx("-mb-px border-b-2 px-3 py-1.5 text-sm cursor-pointer", view === v ? "border-accent text-white" : "border-transparent text-muted hover:text-white")}
|
||||
>
|
||||
{v === "move" ? `To rename (${p.moves})` : `Skipped (${skipped})`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{view === "skip" && skipped > 0 && (
|
||||
<ul className="space-y-0.5 text-xs text-muted">
|
||||
{Object.entries(p.skips).map(([why, n]) => (
|
||||
<li key={why}>
|
||||
<b className="tabular-nums text-gray-300">{n}</b> {why}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-line">
|
||||
{view === "move" && items.length > 0 && (
|
||||
<label className="flex items-center gap-3 border-b border-line bg-panel-2 px-3 py-2 text-xs text-muted cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-violet-500"
|
||||
checked={sel === null}
|
||||
onChange={(e) => setSel(e.target.checked ? null : new Set())}
|
||||
/>
|
||||
{sel === null ? `All ${p.moves} selected` : `${sel.size} selected`}
|
||||
</label>
|
||||
)}
|
||||
<div className="max-h-[55vh] overflow-y-auto">
|
||||
{items.length === 0 && <div className="p-6 text-center text-sm text-muted">Nothing here.</div>}
|
||||
{items.map((it) => (
|
||||
<div key={it.id} className="flex items-start gap-3 border-b border-line px-3 py-2 last:border-0">
|
||||
{it.move && (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5 accent-violet-500"
|
||||
checked={sel === null || sel.has(it.id)}
|
||||
onChange={(e) =>
|
||||
setSel((s) => {
|
||||
const n = new Set(s ?? items.filter((x) => x.move).map((x) => x.id));
|
||||
if (e.target.checked) n.add(it.id);
|
||||
else n.delete(it.id);
|
||||
return n;
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{both && <Badge tone={it.side === "master" ? "violet" : "gray"}>{it.side}</Badge>}
|
||||
<div className="min-w-0 flex-1 text-xs mono">
|
||||
{it.move ? (
|
||||
<div className="grid grid-cols-[1fr_auto_1fr] items-start gap-2">
|
||||
<span className="break-all text-gray-400">{it.from}</span>
|
||||
<ArrowRight className="mt-0.5 size-3 text-muted" />
|
||||
<span className="break-all text-gray-100">{it.to}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="break-all text-gray-300">{it.from}</div>
|
||||
)}
|
||||
{it.sidecars?.length ? <div className="mt-0.5 font-sans text-[11px] text-muted">+ lyrics file moves along</div> : null}
|
||||
{!it.move && <div className="font-sans text-[11px] text-amber-300">{it.reason}</div>}
|
||||
</div>
|
||||
{!it.move && it.reason?.startsWith("missing") && (
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditTags(it)}>
|
||||
<TagsIcon className="size-3.5" /> Fix tags
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{(plan.data?.total ?? 0) > items.length && (
|
||||
<p className="text-xs text-muted">Showing the first {items.length} of {plan.data?.total}. Applying all includes every file.</p>
|
||||
)}
|
||||
|
||||
{p.moves > 0 && (
|
||||
<div className="flex justify-end">
|
||||
<Button variant="primary" disabled={chosen === 0 || running} onClick={() => setConfirm(true)}>
|
||||
<FolderTree className="size-4" /> Rename {chosen} file{chosen !== 1 && "s"}…
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Modal open={confirm} onClose={() => setConfirm(false)} title={`Rename ${chosen} file${chosen !== 1 ? "s" : ""}?`}>
|
||||
<ul className="list-disc space-y-1 pl-5 text-sm text-gray-300">
|
||||
<li>Files are moved to their new names inside the same library. Their contents and hardlinks don't change.</li>
|
||||
<li>Lyrics files with the same name move along; cover art follows when a whole folder moves. Emptied folders are removed.</li>
|
||||
<li>
|
||||
<b>Playlists or apps that point at the old paths will need updating.</b>
|
||||
</li>
|
||||
{p && !both && <li>Only the {p.sides[0]} library is renamed. Hardlinked songs stay paired, but paths may differ from the other library.</li>}
|
||||
<li>Each rename is listed in History and can be undone. The library is rescanned when it finishes.</li>
|
||||
</ul>
|
||||
{dry && (
|
||||
<div className="mt-3">
|
||||
<Alert tone="blue">Dry run is on, so the renames are recorded as planned in Activity. No files move.</Alert>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={() => setConfirm(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" loading={apply.isPending} onClick={() => apply.mutate()}>
|
||||
Rename {chosen} file{chosen !== 1 && "s"}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
{editTags && <TagEditor pairId={pair.id} side={editTags.side} paths={[editTags.from]} onClose={() => setEditTags(null)} />}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value, tone }: { label: string; value: number; tone?: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-line bg-panel-2 px-3 py-2">
|
||||
<div className={cx("text-xl font-semibold tabular-nums", tone)}>{value}</div>
|
||||
<div className="text-xs text-muted">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,9 @@ export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean;
|
||||
settle_minutes: 10,
|
||||
approx_matching: false,
|
||||
approx_threshold: 85,
|
||||
organize_template: "",
|
||||
organize_multidisc: "prefix",
|
||||
auto_organize: "off",
|
||||
};
|
||||
const [f, setF] = useState<PairInput>(pair ?? blank);
|
||||
const [exts, setExts] = useState((pair?.extensions ?? []).join(", "));
|
||||
@@ -52,6 +55,10 @@ export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean;
|
||||
settle_minutes: f.settle_minutes,
|
||||
approx_matching: f.approx_matching,
|
||||
approx_threshold: f.approx_threshold,
|
||||
// Edited on the Organize tab; sent unchanged so saving here keeps them.
|
||||
organize_template: f.organize_template,
|
||||
organize_multidisc: f.organize_multidisc,
|
||||
auto_organize: f.auto_organize,
|
||||
extensions: exts.split(",").map((s) => s.trim()).filter(Boolean),
|
||||
});
|
||||
const set = <K extends keyof PairInput>(k: K, v: PairInput[K]) => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Build } from "./Build";
|
||||
import { OpsTable } from "./Activity";
|
||||
import { WarningList } from "./Warnings";
|
||||
import { TagsTab } from "./Tags";
|
||||
import { OrganizeTab } from "./Organize";
|
||||
|
||||
export function usePair(id: number) {
|
||||
return useQuery({
|
||||
@@ -39,6 +40,8 @@ function useJobFinished(pair: Pair | undefined) {
|
||||
qc.invalidateQueries({ queryKey: ["tree"] });
|
||||
qc.invalidateQueries({ queryKey: ["ops"] });
|
||||
qc.invalidateQueries({ queryKey: ["pairs"] });
|
||||
qc.invalidateQueries({ queryKey: ["organize"] });
|
||||
qc.invalidateQueries({ queryKey: ["files"] });
|
||||
const j = pair.job!;
|
||||
if (st === "done") toast("ok", `${j.kind} finished${j.message ? `: ${j.message}` : ""}`);
|
||||
else toast("err", `${j.kind} ${st}: ${j.error}`);
|
||||
@@ -124,6 +127,7 @@ const tabs = [
|
||||
{ id: "review", label: "Review" },
|
||||
{ id: "build", label: "Build" },
|
||||
{ id: "tags", label: "Tags" },
|
||||
{ id: "organize", label: "Organize" },
|
||||
{ id: "history", label: "History" },
|
||||
];
|
||||
|
||||
@@ -205,6 +209,7 @@ export function PairPage({ id, tab }: { id: number; tab: string }) {
|
||||
{tab === "review" && <Review pair={pair} />}
|
||||
{tab === "build" && <Build pair={pair} />}
|
||||
{tab === "tags" && <TagsTab pair={pair} />}
|
||||
{tab === "organize" && <OrganizeTab pair={pair} />}
|
||||
{tab === "history" && <OpsTable pairId={pair.id} />}
|
||||
|
||||
<PairForm pair={pair} open={editing} onClose={() => setEditing(false)} />
|
||||
|
||||
@@ -25,6 +25,7 @@ const kindLabel: Record<Match["kind"], string> = {
|
||||
fuzzy: "similar title",
|
||||
elsewhere: "same top folder, other subfolder",
|
||||
approximate: "approximate match",
|
||||
inode: "same file, other path",
|
||||
none: "no match",
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user