From 9e848cd76580ff0924d231c65a3de53179818b49 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 23 Sep 2026 21:49:22 -0700 Subject: [PATCH] Show scan warnings as structured cards instead of long sentences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Warnings are now stored and served as structured data (title, detail, slave/master items, total) and rendered as cards with a Slave -> Master table: the first five rows, with "Show all" for the rest. - Renamed folders highlight the characters that differ; non-ASCII characters get their code point in a tooltip, so look-alikes such as "-" (U+002D) and "‐" (U+2010) can be told apart. - Songs in a different folder are grouped by folder pair with a song count, most affected first, instead of two long examples. - Folders that only differ by name are no longer also reported as songs in a different folder. - Warnings saved as plain text by earlier scans still display. Co-Authored-By: Claude Opus 5.5 --- internal/api/server.go | 6 +- internal/engine/engine.go | 11 ++-- internal/engine/engine_test.go | 4 +- internal/engine/layout.go | 78 +++++++++++++++--------- internal/engine/layout_test.go | 51 ++++++++++++---- internal/engine/scan.go | 10 +-- internal/store/models.go | 77 ++++++++++++++++------- internal/store/store_test.go | 17 ++++++ web/src/api.ts | 9 ++- web/src/pages/Dashboard.tsx | 2 +- web/src/pages/PairForm.tsx | 15 ++--- web/src/pages/PairPage.tsx | 9 +-- web/src/pages/Warnings.tsx | 108 +++++++++++++++++++++++++++++++++ 13 files changed, 305 insertions(+), 92 deletions(-) create mode 100644 internal/store/store_test.go create mode 100644 web/src/pages/Warnings.tsx diff --git a/internal/api/server.go b/internal/api/server.go index c76937f..c354970 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -331,11 +331,11 @@ func (s *Server) handleValidatePair(w http.ResponseWriter, r *http.Request) { in.apply(&p) warns, err := s.eng.NormalizePair(&p) if err != nil { - writeJSON(w, 200, map[string]any{"ok": false, "error": err.Error(), "warnings": []string{}}) + writeJSON(w, 200, map[string]any{"ok": false, "error": err.Error(), "warnings": []store.Warning{}}) return } if warns == nil { - warns = []string{} + warns = []store.Warning{} } writeJSON(w, 200, map[string]any{"ok": true, "warnings": warns, "pair": p}) } @@ -374,7 +374,7 @@ func (s *Server) handleSavePair(w http.ResponseWriter, r *http.Request) { } p, _ = s.st.Pair(r.Context(), p.ID) if warns == nil { - warns = []string{} + warns = []store.Warning{} } writeJSON(w, 200, map[string]any{"pair": s.view(r, p), "warnings": warns}) } diff --git a/internal/engine/engine.go b/internal/engine/engine.go index e39efc0..e74d0ba 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -255,7 +255,7 @@ func (e *Engine) open(p store.Pair) (*libs, error) { // NormalizePair fills defaults and validates a pair. It returns non-fatal // warnings (such as layout mismatches) for the UI. -func (e *Engine) NormalizePair(p *store.Pair) ([]string, error) { +func (e *Engine) NormalizePair(p *store.Pair) ([]store.Warning, error) { p.Name = strings.TrimSpace(p.Name) if p.Name == "" { return nil, errors.New("name is required") @@ -301,7 +301,7 @@ func (e *Engine) NormalizePair(p *store.Pair) ([]string, error) { // layoutWarnings is the quick check run when a pair is saved and at the // start of each scan. Content that exists in only one library is normal and // never warned about; only folders that are the same but named differently are. -func layoutWarnings(l *libs) []string { +func layoutWarnings(l *libs) []store.Warning { empty := true if ents, _ := os.ReadDir(l.slave.Path); len(ents) > 0 { for _, en := range ents { @@ -312,10 +312,11 @@ func layoutWarnings(l *libs) []string { } } if empty { - return []string{"The slave library is empty. Use Build to pick songs from the master library to hardlink into it."} + return []store.Warning{{Title: "The slave library is empty", + Detail: "Use Build to pick songs from the master library to hardlink into it."}} } - if w := renamedWarning(renamedFolders(l)); w != "" { - return []string{w} + if w := renamedWarning(renamedFolders(l)); w != nil { + return []store.Warning{*w} } return nil } diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go index 8d276bc..ba13cd6 100644 --- a/internal/engine/engine_test.go +++ b/internal/engine/engine_test.go @@ -502,8 +502,8 @@ func TestAnyLayoutMatchesWithoutWarnings(t *testing.T) { t.Fatal(err) } for _, w := range p.LastScanWarnings { - if strings.Contains(w, "layout") || strings.Contains(w, "folder") || strings.Contains(w, "depth") { - t.Fatalf("matching layouts must not produce layout warnings, got %q", w) + if text := w.Title + " " + w.Detail; strings.Contains(text, "layout") || strings.Contains(text, "folder") || strings.Contains(text, "depth") { + t.Fatalf("matching layouts must not produce layout warnings, got %+v", w) } } } diff --git a/internal/engine/layout.go b/internal/engine/layout.go index 534b389..6df62d0 100644 --- a/internal/engine/layout.go +++ b/internal/engine/layout.go @@ -35,6 +35,15 @@ func folderKey(name string) string { return b.String() } +// pathKey applies folderKey to every component of a relative folder path. +func pathKey(dir string) string { + parts := strings.Split(dir, string(filepath.Separator)) + for i, p := range parts { + parts[i] = folderKey(p) + } + return strings.Join(parts, "/") +} + func subdirs(dir string) []string { ents, _ := os.ReadDir(dir) var out []string @@ -84,32 +93,32 @@ func renamedFolders(l *libs) [][2]string { return out } -func renamedWarning(pairs [][2]string) string { +// maxWarningItems caps how many rows a warning carries. +const maxWarningItems = 200 + +func renamedWarning(pairs [][2]string) *store.Warning { if len(pairs) == 0 { - return "" + return nil } - var ex []string - for _, p := range pairs[:min(3, len(pairs))] { - ex = append(ex, fmt.Sprintf("%q (slave) vs %q (master)", p[0], p[1])) + w := &store.Warning{Total: len(pairs), + Detail: "Songs inside these folders can only match by path once the names agree. Rename one side so they match."} + if len(pairs) == 1 { + w.Title = "1 folder is named differently in the two libraries" + } else { + w.Title = fmt.Sprintf("%d folders are named differently in the two libraries", len(pairs)) } - more := "" - if len(pairs) > 3 { - more = fmt.Sprintf(" and %d more", len(pairs)-3) + for _, p := range pairs[:min(maxWarningItems, len(pairs))] { + w.Items = append(w.Items, store.WarningItem{Slave: p[0], Master: p[1]}) } - noun := "folder is" - if len(pairs) > 1 { - noun = "folders are" - } - return fmt.Sprintf("%d %s named differently in the two libraries: %s%s. Songs inside them only match by path "+ - "once the names agree.", len(pairs), noun, strings.Join(ex, ", "), more) + return w } // layoutStats watches, during a scan, where songs found in both libraries // live. Songs only in the slave library are ignored. type layoutStats struct { - shared int // slave files with a master counterpart - moved int // ...that live in a different folder in the master - example [][2]string // a few moved examples + shared int // slave files with a master counterpart + moved int // ...that live in a different folder in the master + pairs map[[2]string]int // (slave album folder, master album folder) -> songs } func (ls *layoutStats) add(relSlave, relMaster, kind, reason string) { @@ -123,26 +132,41 @@ func (ls *layoutStats) add(relSlave, relMaster, kind, reason string) { if albumKey(relSlave) == albumKey(relMaster) { return // only a disc folder differs } + if pathKey(AlbumFolder(relSlave)) == pathKey(AlbumFolder(relMaster)) { + return // same folder under another name: the renamed-folders warning covers it + } // A single, EP or compilation matched to its album version is the // intended behaviour, not a layout problem. if st := parseTitle(relSlave); st.single || st.compilation || strings.Contains(reason, "album version preferred") { return } ls.moved++ - if len(ls.example) < 2 { - ls.example = append(ls.example, [2]string{relSlave, relMaster}) + if ls.pairs == nil { + ls.pairs = map[[2]string]int{} } + ls.pairs[[2]string{AlbumFolder(relSlave), AlbumFolder(relMaster)}]++ } -func (ls *layoutStats) warning() string { +func (ls *layoutStats) warning() *store.Warning { if ls.shared < 20 || ls.moved*4 <= ls.shared { - return "" + return nil } - var ex []string - for _, e := range ls.example { - ex = append(ex, fmt.Sprintf("%q is at %q", e[0], e[1])) + items := make([]store.WarningItem, 0, len(ls.pairs)) + for p, n := range ls.pairs { + items = append(items, store.WarningItem{Slave: p[0], Master: p[1], Count: n}) } - return fmt.Sprintf("Of %d songs found in both libraries, %d are in a different folder in the master library (for example %s). "+ - "The libraries seem to use different folder layouts, so these songs only match through the fallbacks.", - ls.shared, ls.moved, strings.Join(ex, "; ")) + sort.Slice(items, func(i, j int) bool { + if items[i].Count != items[j].Count { + return items[i].Count > items[j].Count + } + return items[i].Slave < items[j].Slave + }) + w := &store.Warning{ + Title: fmt.Sprintf("%d of %d songs found in both libraries are in a different folder in the master library", ls.moved, ls.shared), + Detail: "The libraries seem to use different folder layouts here, so these songs only match through the fallbacks. " + + "Folders are listed with the most affected songs first.", + Total: len(items), + Items: items[:min(maxWarningItems, len(items))], + } + return w } diff --git a/internal/engine/layout_test.go b/internal/engine/layout_test.go index 808807a..5449d5e 100644 --- a/internal/engine/layout_test.go +++ b/internal/engine/layout_test.go @@ -4,18 +4,20 @@ import ( "fmt" "strings" "testing" + + "github.com/cdnutter/playlink/internal/store" ) -func scanWarnings(t *testing.T, ev *env) []string { +func scanWarnings(t *testing.T, ev *env) []store.Warning { t.Helper() ev.scan() p, err := ev.st.Pair(ctx, ev.pair.ID) if err != nil { t.Fatal(err) } - var out []string + var out []store.Warning for _, w := range p.LastScanWarnings { - if !strings.Contains(w, "ffprobe") { + if !strings.Contains(w.Title, "ffprobe") { out = append(out, w) } } @@ -36,7 +38,7 @@ func TestSlaveOnlyContentIsNotALayoutProblem(t *testing.T) { } ev.write(ev.master, "Master Only/Album/01 X.flac", "m") if w := scanWarnings(t, ev); len(w) != 0 { - t.Fatalf("slave-only content should not produce warnings, got %q", w) + t.Fatalf("slave-only content should not produce warnings, got %+v", w) } } @@ -51,13 +53,23 @@ func TestRenamedFoldersAreReported(t *testing.T) { ev.write(ev.slave, "Beyoncé/Lemonade/Formation.mp3", "b") ev.write(ev.master, "Beyonce/Lemonade/Formation.flac", "a") w := scanWarnings(t, ev) - if len(w) != 1 { - t.Fatalf("want one renamed-folders warning, got %q", w) + if len(w) != 1 || w[0].Title != "4 folders are named differently in the two libraries" || w[0].Total != 4 { + t.Fatalf("want one renamed-folders warning, got %+v", w) } - for _, want := range []string{`"AC-DC" (slave) vs "AC_DC" (master)`, `"Avenged Sevenfold/City Of Evil" (slave) vs "Avenged Sevenfold/City of Evil" (master)`, "4 folders", "1 more"} { - if !strings.Contains(w[0], want) { - t.Errorf("warning %q should contain %q", w[0], want) + want := map[store.WarningItem]bool{ + {Slave: "AC-DC", Master: "AC_DC"}: true, + {Slave: "Beatles, The", Master: "The Beatles"}: true, + {Slave: "Beyoncé", Master: "Beyonce"}: true, + {Slave: "Avenged Sevenfold/City Of Evil", Master: "Avenged Sevenfold/City of Evil"}: true, + } + for _, it := range w[0].Items { + if !want[it] { + t.Errorf("unexpected item %+v", it) } + delete(want, it) + } + if len(want) != 0 { + t.Errorf("missing items %v", want) } } @@ -69,8 +81,11 @@ func TestDifferentLayoutIsReported(t *testing.T) { ev.write(ev.slave, fmt.Sprintf("Band/Song Number %d.mp3", i), "s") } w := scanWarnings(t, ev) - if len(w) != 1 || !strings.Contains(w[0], "Of 24 songs found in both libraries, 24 are in a different folder") { - t.Fatalf("want a different-layout warning, got %q", w) + if len(w) != 1 || w[0].Title != "24 of 24 songs found in both libraries are in a different folder in the master library" { + t.Fatalf("want a different-layout warning, got %+v", w) + } + if len(w[0].Items) != 1 || w[0].Items[0] != (store.WarningItem{Slave: "Band", Master: "Band/Album", Count: 24}) { + t.Fatalf("songs should be grouped by folder pair, got %+v", w[0].Items) } } @@ -83,7 +98,7 @@ func TestSinglesMatchedToAlbumsAreNotALayoutProblem(t *testing.T) { ev.write(ev.slave, fmt.Sprintf("Band/Song Number %d - Single/Song Number %d.mp3", i, i), "s") } if w := scanWarnings(t, ev); len(w) != 0 { - t.Fatalf("singles matched to album versions should not warn, got %q", w) + t.Fatalf("singles matched to album versions should not warn, got %+v", w) } } @@ -102,3 +117,15 @@ func TestFolderKey(t *testing.T) { t.Error("different folders must keep different keys") } } + +// A renamed folder is reported once, as a renamed folder, not again as songs +// living in a different folder. +func TestRenamedFolderNotCountedAsMoved(t *testing.T) { + var ls layoutStats + for i := 0; i < 30; i++ { + ls.add(fmt.Sprintf("ABBA/Voulez-Vous/%02d x.flac", i), fmt.Sprintf("ABBA/Voulez‐vous/%02d x.flac", i), store.KindElsewhere, "") + } + if ls.shared != 30 || ls.moved != 0 || ls.warning() != nil { + t.Fatalf("renamed folder counted as moved: shared=%d moved=%d", ls.shared, ls.moved) + } +} diff --git a/internal/engine/scan.go b/internal/engine/scan.go index 4f742e6..04cc22d 100644 --- a/internal/engine/scan.go +++ b/internal/engine/scan.go @@ -340,14 +340,16 @@ func (e *Engine) scan(ctx context.Context, j *Job, pairID int64, trigger string) } // Layout warnings based on what was actually found. - if w := layout.warning(); w != "" { - warns = append(warns, w) + if w := layout.warning(); w != nil { + warns = append(warns, *w) } if ms.symlinks+ss.symlinks > 0 { - warns = append(warns, fmt.Sprintf("Ignored %d symlinks (playlink never follows or replaces symlinks).", ms.symlinks+ss.symlinks)) + warns = append(warns, store.Warning{Title: fmt.Sprintf("Ignored %d symlinks", ms.symlinks+ss.symlinks), + Detail: "playlink never follows or replaces symlinks."}) } if e.prober.Bin == "" { - warns = append(warns, "ffprobe was not found, so codec and quality details are unavailable for manual review.") + warns = append(warns, store.Warning{Title: "ffprobe was not found", + Detail: "Codec and quality details are unavailable for manual review."}) } if err := e.st.FinishScan(ctx, pairID, time.Now().Unix(), "", warns); err != nil { return err diff --git a/internal/store/models.go b/internal/store/models.go index 497cf0c..17ca807 100644 --- a/internal/store/models.go +++ b/internal/store/models.go @@ -91,25 +91,61 @@ func (s *Store) PruneSessions(ctx context.Context) { _, _ = s.DB.ExecContext(ctx, `DELETE FROM sessions WHERE expires_at <= ?`, now()) } +// ---- warnings ---- + +// Warning is a scan or validation notice, structured so the UI can lay it +// out (a heading, an explanation and a table of affected folders) rather +// than print one long sentence. +type Warning struct { + Title string `json:"title"` + Detail string `json:"detail,omitempty"` + Items []WarningItem `json:"items,omitempty"` + // Total is the full number of items when Items was truncated. + Total int `json:"total,omitempty"` +} + +// WarningItem pairs a slave path with the master path it corresponds to. +type WarningItem struct { + Slave string `json:"slave"` + Master string `json:"master"` + Count int `json:"count,omitempty"` // songs affected, when grouped +} + +// decodeWarnings reads stored warnings; scans before structured warnings +// stored plain strings, which become title-only warnings. +func decodeWarnings(raw string) []Warning { + var ws []Warning + if json.Unmarshal([]byte(raw), &ws) == nil && ws != nil { + return ws + } + var old []string + _ = json.Unmarshal([]byte(raw), &old) + ws = make([]Warning, 0, len(old)) + for _, t := range old { + ws = append(ws, Warning{Title: t}) + } + return ws +} + // ---- pairs ---- type Pair struct { - ID int64 `json:"id"` - Name string `json:"name"` - MasterRoot string `json:"master_root"` - SlaveRoot string `json:"slave_root"` - QuarantineDir string `json:"quarantine_dir"` - Extensions []string `json:"extensions"` - AutoLinkIdentical bool `json:"auto_link_identical"` - ScanIntervalMinutes int `json:"scan_interval_minutes"` - RetentionDays int `json:"retention_days"` - SettleMinutes int `json:"settle_minutes"` - ApproxMatching bool `json:"approx_matching"` - ApproxThreshold int `json:"approx_threshold"` // percent - LastScanAt *int64 `json:"last_scan_at"` - LastScanError string `json:"last_scan_error"` - LastScanWarnings []string `json:"last_scan_warnings"` - CreatedAt int64 `json:"created_at"` + ID int64 `json:"id"` + Name string `json:"name"` + MasterRoot string `json:"master_root"` + SlaveRoot string `json:"slave_root"` + QuarantineDir string `json:"quarantine_dir"` + Extensions []string `json:"extensions"` + AutoLinkIdentical bool `json:"auto_link_identical"` + ScanIntervalMinutes int `json:"scan_interval_minutes"` + RetentionDays int `json:"retention_days"` + SettleMinutes int `json:"settle_minutes"` + ApproxMatching bool `json:"approx_matching"` + ApproxThreshold int `json:"approx_threshold"` // percent + LastScanAt *int64 `json:"last_scan_at"` + LastScanError string `json:"last_scan_error"` + LastScanWarnings []Warning `json:"last_scan_warnings"` + CreatedAt int64 `json:"created_at"` } const pairCols = `id, name, master_root, slave_root, quarantine_dir, extensions, auto_link_identical, @@ -132,10 +168,7 @@ func scanPair(r scanner) (Pair, error) { p.LastScanAt = &last.Int64 } p.Extensions = strings.Split(exts, ",") - _ = json.Unmarshal([]byte(warns), &p.LastScanWarnings) - if p.LastScanWarnings == nil { - p.LastScanWarnings = []string{} - } + p.LastScanWarnings = decodeWarnings(warns) return p, nil } @@ -196,9 +229,9 @@ func (s *Store) ClearMatches(ctx context.Context, pairID int64) error { return err } -func (s *Store) FinishScan(ctx context.Context, pairID, at int64, scanErr string, warnings []string) error { +func (s *Store) FinishScan(ctx context.Context, pairID, at int64, scanErr string, warnings []Warning) error { if warnings == nil { - warnings = []string{} + warnings = []Warning{} } w, _ := json.Marshal(warnings) _, err := s.DB.ExecContext(ctx, `UPDATE pairs SET last_scan_at = ?, last_scan_error = ?, last_scan_warnings = ? WHERE id = ?`, diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 0000000..a5e2957 --- /dev/null +++ b/internal/store/store_test.go @@ -0,0 +1,17 @@ +package store + +import "testing" + +func TestDecodeWarnings(t *testing.T) { + old := decodeWarnings(`["Only 3 of 9 folders match."]`) + if len(old) != 1 || old[0].Title != "Only 3 of 9 folders match." { + t.Fatalf("plain-string warnings from older scans should decode as titles, got %+v", old) + } + cur := decodeWarnings(`[{"title":"t","items":[{"slave":"a","master":"b","count":2}],"total":5}]`) + if len(cur) != 1 || cur[0].Total != 5 || cur[0].Items[0].Count != 2 { + t.Fatalf("structured warnings should round-trip, got %+v", cur) + } + if ws := decodeWarnings(`[]`); ws == nil || len(ws) != 0 { + t.Fatalf("empty list should stay an empty (non-nil) list, got %#v", ws) + } +} diff --git a/web/src/api.ts b/web/src/api.ts index 151e48c..7c0e85d 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -75,7 +75,7 @@ export type Pair = { approx_threshold: number; last_scan_at: number | null; last_scan_error: string; - last_scan_warnings: string[]; + last_scan_warnings: ScanWarning[]; summary: Summary; job: Job | null; }; @@ -199,3 +199,10 @@ export type BulkPreview = { bytes_freed: number; examples: Record; }; + +export type ScanWarning = { + title: string; + detail?: string; + items?: { slave: string; master: string; count?: number }[]; + total?: number; +}; diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index a36f438..9c6d2e6 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -105,7 +105,7 @@ function PairCard({ pair }: { pair: Pair }) { )} {pair.last_scan_warnings.length > 0 && (
- {pair.last_scan_warnings.length} layout warning{pair.last_scan_warnings.length > 1 && "s"} + {pair.last_scan_warnings.length === 1 ? pair.last_scan_warnings[0].title : `${pair.last_scan_warnings.length} warnings`}
)} diff --git a/web/src/pages/PairForm.tsx b/web/src/pages/PairForm.tsx index be97900..880e40b 100644 --- a/web/src/pages/PairForm.tsx +++ b/web/src/pages/PairForm.tsx @@ -1,12 +1,13 @@ import { useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { AlertTriangle, ChevronLeft, Folder, FolderOpen } from "lucide-react"; -import { api, type Pair, type PairInput } from "../api"; +import { ChevronLeft, Folder, FolderOpen } from "lucide-react"; +import { api, type Pair, type PairInput, type ScanWarning } from "../api"; +import { WarningList } from "./Warnings"; import { useInfo } from "../App"; import { Alert, Button, Field, Modal, Toggle, inputCls } from "../ui"; import { useToast } from "../toast"; -type Validation = { ok: boolean; error?: string; warnings: string[] }; +type Validation = { ok: boolean; error?: string; warnings: ScanWarning[] }; export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean; onClose: () => void }) { const info = useInfo(); @@ -63,7 +64,7 @@ export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean; onSuccess: setValidation, }); const save = useMutation({ - mutationFn: () => (pair ? api.put<{ warnings: string[] }>(`/pairs/${pair.id}`, body()) : api.post<{ warnings: string[] }>("/pairs", body())), + mutationFn: () => (pair ? api.put<{ warnings: ScanWarning[] }>(`/pairs/${pair.id}`, body()) : api.post<{ warnings: ScanWarning[] }>("/pairs", body())), onSuccess: () => { qc.invalidateQueries({ queryKey: ["pairs"] }); qc.invalidateQueries({ queryKey: ["pair"] }); @@ -188,11 +189,7 @@ export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean; {validation && !validation.ok && {validation.error}} {validation?.ok && validation.warnings.length === 0 && Looks good. Both folders are valid and on the same filesystem.} - {validation?.warnings.map((w) => ( - }> - {w} - - ))} + {validation?.ok && } {save.error && {save.error.message}}
diff --git a/web/src/pages/PairPage.tsx b/web/src/pages/PairPage.tsx index 3493e41..864ec5b 100644 --- a/web/src/pages/PairPage.tsx +++ b/web/src/pages/PairPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { AlertTriangle, ChevronLeft, Pencil, RefreshCw, Square, Trash2 } from "lucide-react"; +import { ChevronLeft, Pencil, RefreshCw, Square, Trash2 } from "lucide-react"; import { api, type Job, type Pair } from "../api"; import { Alert, Badge, Button, Card, Modal, Spinner, cx, formatAgo } from "../ui"; import { useToast } from "../toast"; @@ -9,6 +9,7 @@ import { PairForm } from "./PairForm"; import { Review } from "./Review"; import { Build } from "./Build"; import { OpsTable } from "./Activity"; +import { WarningList } from "./Warnings"; export function usePair(id: number) { return useQuery({ @@ -174,11 +175,7 @@ export function PairPage({ id, tab }: { id: number; tab: string }) { )} {pair.last_scan_error && Last scan failed: {pair.last_scan_error}} - {pair.last_scan_warnings.map((w) => ( - }> - {w} - - ))} +
{tabs.map((t) => ( diff --git a/web/src/pages/Warnings.tsx b/web/src/pages/Warnings.tsx new file mode 100644 index 0000000..a5466c0 --- /dev/null +++ b/web/src/pages/Warnings.tsx @@ -0,0 +1,108 @@ +import { useState } from "react"; +import { AlertTriangle, ArrowRight, ChevronDown, ChevronRight } from "lucide-react"; +import type { ScanWarning } from "../api"; +import { cx } from "../ui"; + +const PREVIEW_ROWS = 5; + +/** Scan and validation warnings: a heading, an explanation and a folder table. */ +export function WarningList({ warnings }: { warnings: ScanWarning[] }) { + if (warnings.length === 0) return null; + return ( +
+ {warnings.map((w) => ( + + ))} +
+ ); +} + +function WarningCard({ w }: { w: ScanWarning }) { + const [expanded, setExpanded] = useState(false); + const items = w.items ?? []; + const grouped = items.some((it) => it.count); + const shown = expanded ? items : items.slice(0, PREVIEW_ROWS); + const total = w.total ?? items.length; + const hidden = total - shown.length; + + return ( +
+
+ +
+
{w.title}
+ {w.detail &&
{w.detail}
} +
+
+ + {items.length > 0 && ( +
+
+ + + + + + {grouped && } + + + + {shown.map((it) => ( + + + + + {grouped && } + + ))} + +
Slave + MasterSongs
{grouped ? it.slave || "(library root)" : } + + {grouped ? it.master || "(library root)" : }{it.count}
+
+ {(items.length > PREVIEW_ROWS || hidden > 0) && ( +
+ {items.length > PREVIEW_ROWS && ( + + )} + {expanded && total > items.length && and {total - items.length} more not listed} + {!expanded && items.length <= PREVIEW_ROWS && hidden > 0 && and {hidden} more} +
+ )} +
+ )} +
+ ); +} + +/** + * Shows `a`, highlighting where it differs from `b` (common prefix and suffix + * are left plain). Non-ASCII characters in the highlight get their code point + * 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 }) { + const ca = Array.from(a); + const cb = Array.from(b); + let pre = 0; + while (pre < ca.length && pre < cb.length && ca[pre] === cb[pre]) pre++; + let suf = 0; + while (suf < ca.length - pre && suf < cb.length - pre && ca[ca.length - 1 - suf] === cb[cb.length - 1 - suf]) suf++; + const mid = ca.slice(pre, ca.length - suf); + if (mid.length === 0) return <>{a}; + const exotic = mid.filter((c) => c.codePointAt(0)! > 0x7e); + const tip = exotic.length ? exotic.map((c) => `"${c}" U+${c.codePointAt(0)!.toString(16).toUpperCase().padStart(4, "0")}`).join(", ") : undefined; + return ( + <> + {ca.slice(0, pre).join("")} + + {mid.join("")} + + {ca.slice(ca.length - suf).join("")} + + ); +}