Add renaming slave folders to the master's spelling
The "N folders are named differently" warning now offers to rename the slave folders to match the master, marked as the recommended first step after a scan (also linked from the dashboard card). - A preview lists each rename with the differing characters highlighted; renames are selectable, and conflicts (the slave already has a folder with the master's name, symlinks, the quarantine) are skipped with a reason. - Only renames the server computes itself are applied; a request can only narrow them down, never name arbitrary paths. Only slave folders are renamed. - Case-only renames go through a temporary name so they also work on case-insensitive filesystems; a rename interrupted by a crash is completed on startup (the folder is never removed). - Renaming everything repeats until folders revealed inside renamed ones (an album under a renamed artist) are fixed too, then rescans. - Each rename is an operation in History that can be undone (which also rescans). Dry run records renames as planned. Also fixes the Review list not refreshing after a job that started and finished between two status polls, and logs the new path of operations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
176a527ec7
commit
42161e4287
14 files changed
+684
-21
No files matched your search
+5
-1
@@ -138,7 +138,7 @@ export type Match = {
|
||||
export type Operation = {
|
||||
id: number;
|
||||
pair_id: number;
|
||||
action: "link" | "promote" | "build" | "remove";
|
||||
action: "link" | "promote" | "build" | "remove" | "rename";
|
||||
rel_master: string;
|
||||
rel_slave: string;
|
||||
rel_new: string;
|
||||
@@ -201,8 +201,12 @@ export type BulkPreview = {
|
||||
};
|
||||
|
||||
export type ScanWarning = {
|
||||
code?: "renamed_folders";
|
||||
title: string;
|
||||
detail?: string;
|
||||
items?: { slave: string; master: string; count?: number }[];
|
||||
total?: number;
|
||||
fixable?: number;
|
||||
};
|
||||
|
||||
export type FolderRename = { slave: string; master: string; ready: boolean; reason?: string };
|
||||
@@ -36,6 +36,7 @@ const actionLabel: Record<Operation["action"], string> = {
|
||||
promote: "Promoted slave → master",
|
||||
build: "Built into slave",
|
||||
remove: "Removed from slave",
|
||||
rename: "Renamed slave folder",
|
||||
};
|
||||
|
||||
const statusTone: Record<Operation["status"], "green" | "red" | "blue" | "amber" | "gray"> = {
|
||||
@@ -177,5 +178,7 @@ 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 "rename":
|
||||
return `The slave folder is renamed back from "${o.rel_new}" to "${o.rel_slave}", and the library is rescanned.`;
|
||||
}
|
||||
}
|
||||
@@ -104,8 +104,13 @@ function PairCard({ pair }: { pair: Pair }) {
|
||||
</div>
|
||||
)}
|
||||
{pair.last_scan_warnings.length > 0 && (
|
||||
<div className="mt-3 flex gap-2 text-xs text-amber-300">
|
||||
<div className="mt-3 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-amber-300">
|
||||
<AlertTriangle className="size-3.5 shrink-0" /> {pair.last_scan_warnings.length === 1 ? pair.last_scan_warnings[0].title : `${pair.last_scan_warnings.length} warnings`}
|
||||
{pair.last_scan_warnings.some((w) => w.code === "renamed_folders" && (w.fixable ?? 0) > 0) && (
|
||||
<a href={`#/pair/${pair.id}/review?fix=renames`} className="rounded bg-amber-400/15 px-1.5 py-0.5 font-medium text-amber-200 hover:bg-amber-400/25">
|
||||
Rename folders to match (recommended first)
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<a href={`#/pair/${pair.id}`} className="mt-4 flex items-center gap-1 self-end text-sm text-accent hover:underline">
|
||||
|
||||
@@ -23,20 +23,26 @@ export function usePair(id: number) {
|
||||
function useJobFinished(pair: Pair | undefined) {
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const prev = useRef<string | undefined>(undefined);
|
||||
// Keyed by job id as well as state: a fast job can start and finish between
|
||||
// two polls, so "running" is never observed.
|
||||
// null = pair not loaded yet; "none" = loaded, no job so far.
|
||||
const prev = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
const st = pair?.job?.state;
|
||||
if (prev.current === "running" && st && st !== "running") {
|
||||
if (!pair) return;
|
||||
const st = pair.job?.state;
|
||||
const key = pair.job ? `${pair.job.id}:${st}` : "none";
|
||||
const seen = prev.current;
|
||||
prev.current = key;
|
||||
if (seen !== null && key !== "none" && key !== seen && st !== "running") {
|
||||
qc.invalidateQueries({ queryKey: ["matches"] });
|
||||
qc.invalidateQueries({ queryKey: ["tree"] });
|
||||
qc.invalidateQueries({ queryKey: ["ops"] });
|
||||
qc.invalidateQueries({ queryKey: ["pairs"] });
|
||||
const j = pair!.job!;
|
||||
const j = pair.job!;
|
||||
if (st === "done") toast("ok", `${j.kind} finished${j.message ? `: ${j.message}` : ""}`);
|
||||
else toast("err", `${j.kind} ${st}: ${j.error}`);
|
||||
}
|
||||
prev.current = st;
|
||||
}, [pair?.job?.state]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [pair, pair?.job?.id, pair?.job?.state]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}
|
||||
|
||||
export function ScanButton({ pair }: { pair: Pair }) {
|
||||
@@ -175,7 +181,7 @@ export function PairPage({ id, tab }: { id: number; tab: string }) {
|
||||
</Card>
|
||||
)}
|
||||
{pair.last_scan_error && <Alert tone="red">Last scan failed: {pair.last_scan_error}</Alert>}
|
||||
<WarningList warnings={pair.last_scan_warnings} />
|
||||
<WarningList warnings={pair.last_scan_warnings} pair={pair} />
|
||||
|
||||
<div className="flex gap-1 border-b border-line">
|
||||
{tabs.map((t) => (
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertTriangle, ArrowRight, FolderPen } from "lucide-react";
|
||||
import { api, type FolderRename, type Pair } from "../api";
|
||||
import { Alert, Button, Modal, Spinner, cx } from "../ui";
|
||||
import { useToast } from "../toast";
|
||||
import { Diff } from "./Warnings";
|
||||
|
||||
function wantsFix() {
|
||||
const q = window.location.hash.split("?")[1];
|
||||
return new URLSearchParams(q ?? "").get("fix") === "renames";
|
||||
}
|
||||
|
||||
export function RenameButton({ pair, count }: { pair: Pair; count: number }) {
|
||||
// The dashboard's "Rename folders to match" link opens the dialog directly.
|
||||
const [open, setOpen] = useState(wantsFix);
|
||||
const running = pair.job?.state === "running";
|
||||
return (
|
||||
<>
|
||||
{count > 0 ? (
|
||||
<Button size="sm" variant="primary" onClick={() => setOpen(true)} disabled={running} className="shrink-0">
|
||||
<FolderPen className="size-3.5" /> Rename {count} slave folder{count !== 1 && "s"} to match…
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" onClick={() => setOpen(true)} className="shrink-0">
|
||||
See why these can't be renamed…
|
||||
</Button>
|
||||
)}
|
||||
{open && <RenameDialog pair={pair} onClose={() => setOpen(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RenameDialog({ pair, onClose }: { pair: Pair; onClose: () => void }) {
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const preview = useQuery({
|
||||
queryKey: ["renames", pair.id],
|
||||
queryFn: () => api.get<{ items: FolderRename[]; dry_run: boolean }>(`/pairs/${pair.id}/renames`),
|
||||
gcTime: 0,
|
||||
});
|
||||
const items = useMemo(() => preview.data?.items ?? [], [preview.data]);
|
||||
const ready = items.filter((r) => r.ready);
|
||||
const conflicts = items.filter((r) => !r.ready);
|
||||
const [picked, setPicked] = useState<Set<string>>(new Set());
|
||||
useEffect(() => setPicked(new Set(ready.map((r) => r.slave))), [items]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
const all = ready.length > 0 && picked.size === ready.length;
|
||||
const dry = preview.data?.dry_run ?? true;
|
||||
|
||||
const run = useMutation({
|
||||
// Renaming everything sends no list, so folders revealed inside renamed
|
||||
// ones (album folders under a renamed artist) are renamed too.
|
||||
mutationFn: () => api.post(`/pairs/${pair.id}/renames`, { only: all ? [] : [...picked] }),
|
||||
onSuccess: () => {
|
||||
toast("ok", dry ? "Started (dry run): renames are recorded as planned only" : "Renaming folders. The library is rescanned afterwards.");
|
||||
qc.invalidateQueries({ queryKey: ["pair", pair.id] });
|
||||
qc.invalidateQueries({ queryKey: ["pairs"] });
|
||||
clearFix();
|
||||
onClose();
|
||||
},
|
||||
onError: (e) => toast("err", e.message),
|
||||
});
|
||||
const close = () => {
|
||||
clearFix();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open onClose={close} title="Rename slave folders to match the master" wide>
|
||||
{preview.isLoading && <Spinner />}
|
||||
{preview.error && <Alert tone="red">{preview.error.message}</Alert>}
|
||||
{preview.data && (
|
||||
<div className="space-y-3 text-sm">
|
||||
<p className="text-gray-300">
|
||||
These slave folders are the same as a master folder with a different spelling. Renaming them to the master's spelling lets their songs
|
||||
match by path, so it is best done <b>first, right after a scan</b>, before reviewing matches.
|
||||
</p>
|
||||
|
||||
{items.length === 0 && <Alert tone="green">Nothing to rename. The folder names already agree.</Alert>}
|
||||
|
||||
{ready.length > 0 && (
|
||||
<div className="overflow-hidden rounded-lg border border-line">
|
||||
<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={all}
|
||||
onChange={(e) => setPicked(e.target.checked ? new Set(ready.map((r) => r.slave)) : new Set())}
|
||||
/>
|
||||
{picked.size} of {ready.length} selected
|
||||
</label>
|
||||
<div className="max-h-[45vh] overflow-y-auto">
|
||||
{ready.map((r) => (
|
||||
<label key={r.slave} className={cx("flex items-start gap-3 border-b border-line px-3 py-2 last:border-0 cursor-pointer", !picked.has(r.slave) && "opacity-50")}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5 accent-violet-500"
|
||||
checked={picked.has(r.slave)}
|
||||
onChange={(e) =>
|
||||
setPicked((s) => {
|
||||
const n = new Set(s);
|
||||
if (e.target.checked) n.add(r.slave);
|
||||
else n.delete(r.slave);
|
||||
return n;
|
||||
})
|
||||
}
|
||||
/>
|
||||
<div className="grid min-w-0 flex-1 grid-cols-[1fr_auto_1fr] items-start gap-2 text-xs mono">
|
||||
<span className="break-all text-gray-300">
|
||||
<Diff a={r.slave} b={r.master} />
|
||||
</span>
|
||||
<ArrowRight className="mt-0.5 size-3 text-muted" />
|
||||
<span className="break-all text-white">
|
||||
<Diff a={r.master} b={r.slave} />
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{conflicts.length > 0 && (
|
||||
<Alert tone="amber" icon={<AlertTriangle className="mt-0.5 size-4 shrink-0" />}>
|
||||
<div className="font-medium">
|
||||
{conflicts.length} can't be renamed automatically
|
||||
</div>
|
||||
<ul className="mt-1 space-y-0.5 text-xs">
|
||||
{conflicts.map((r) => (
|
||||
<li key={r.slave}>
|
||||
<span className="mono">{r.slave}</span>: {r.reason}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{ready.length > 0 && (
|
||||
<ul className="list-disc space-y-0.5 pl-5 text-xs text-muted">
|
||||
<li>Only folders in the slave library are renamed. The master library is not touched, and no files are moved or changed.</li>
|
||||
<li>
|
||||
Paths inside these folders change. <b className="text-gray-300">Playlists or apps that point at the old paths will need updating.</b>
|
||||
</li>
|
||||
{all && <li>Folders that turn out to differ inside a renamed folder (for example an album inside a renamed artist) are renamed too.</li>}
|
||||
<li>Every rename is listed in History and can be undone. The library is rescanned when it finishes.</li>
|
||||
</ul>
|
||||
)}
|
||||
{dry && ready.length > 0 && <Alert tone="blue">Dry run is on, so these are recorded as planned in Activity. No folders change.</Alert>}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={close}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" disabled={picked.size === 0} loading={run.isPending} onClick={() => run.mutate()}>
|
||||
<FolderPen className="size-4" /> Rename {picked.size} folder{picked.size !== 1 && "s"}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/** Drops ?fix=renames from the URL so the dialog doesn't reopen. */
|
||||
function clearFix() {
|
||||
if (wantsFix()) window.history.replaceState(null, "", window.location.hash.split("?")[0]);
|
||||
}
|
||||
@@ -1,23 +1,26 @@
|
||||
import { useState } from "react";
|
||||
import { AlertTriangle, ArrowRight, ChevronDown, ChevronRight } from "lucide-react";
|
||||
import type { ScanWarning } from "../api";
|
||||
import { cx } from "../ui";
|
||||
import type { Pair, ScanWarning } from "../api";
|
||||
import { Badge, cx } from "../ui";
|
||||
import { RenameButton } from "./Renames";
|
||||
|
||||
const PREVIEW_ROWS = 5;
|
||||
|
||||
/** Scan and validation warnings: a heading, an explanation and a folder table. */
|
||||
export function WarningList({ warnings }: { warnings: ScanWarning[] }) {
|
||||
export function WarningList({ warnings, pair }: { warnings: ScanWarning[]; pair?: Pair }) {
|
||||
if (warnings.length === 0) return null;
|
||||
// Fixable warnings first: renaming folders is the recommended first step.
|
||||
const sorted = [...warnings].sort((a, b) => Number(!!b.code) - Number(!!a.code));
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{warnings.map((w) => (
|
||||
<WarningCard key={w.title} w={w} />
|
||||
{sorted.map((w) => (
|
||||
<WarningCard key={w.title} w={w} pair={pair} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WarningCard({ w }: { w: ScanWarning }) {
|
||||
function WarningCard({ w, pair }: { w: ScanWarning; pair?: Pair }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const items = w.items ?? [];
|
||||
const grouped = items.some((it) => it.count);
|
||||
@@ -29,10 +32,14 @@ function WarningCard({ w }: { w: ScanWarning }) {
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/[0.07] text-sm">
|
||||
<div className="flex gap-2.5 px-4 pt-3 pb-2">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-300" />
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-amber-100">{w.title}</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2 font-medium text-amber-100">
|
||||
{w.title}
|
||||
{pair && w.code === "renamed_folders" && (w.fixable ?? 0) > 0 && <Badge tone="green">Recommended first step</Badge>}
|
||||
</div>
|
||||
{w.detail && <div className="mt-0.5 text-xs text-amber-200/70">{w.detail}</div>}
|
||||
</div>
|
||||
{pair && w.code === "renamed_folders" && <RenameButton pair={pair} count={w.fixable ?? 0} />}
|
||||
</div>
|
||||
|
||||
{items.length > 0 && (
|
||||
@@ -85,7 +92,7 @@ function WarningCard({ w }: { w: ScanWarning }) {
|
||||
* in a tooltip, so look-alikes such as "-" (U+002D) and "‐" (U+2010) can be
|
||||
* told apart.
|
||||
*/
|
||||
function Diff({ a, b }: { a: string; b: string }) {
|
||||
export function Diff({ a, b }: { a: string; b: string }) {
|
||||
const ca = Array.from(a);
|
||||
const cb = Array.from(b);
|
||||
let pre = 0;
|
||||
|
||||
Reference in new issue
Block a user