Show scan warnings as structured cards instead of long sentences
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
1d31999793
commit
9e848cd765
13 files changed
+305
-92
No files matched your search
@@ -331,11 +331,11 @@ func (s *Server) handleValidatePair(w http.ResponseWriter, r *http.Request) {
|
|||||||
in.apply(&p)
|
in.apply(&p)
|
||||||
warns, err := s.eng.NormalizePair(&p)
|
warns, err := s.eng.NormalizePair(&p)
|
||||||
if err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
if warns == nil {
|
if warns == nil {
|
||||||
warns = []string{}
|
warns = []store.Warning{}
|
||||||
}
|
}
|
||||||
writeJSON(w, 200, map[string]any{"ok": true, "warnings": warns, "pair": p})
|
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)
|
p, _ = s.st.Pair(r.Context(), p.ID)
|
||||||
if warns == nil {
|
if warns == nil {
|
||||||
warns = []string{}
|
warns = []store.Warning{}
|
||||||
}
|
}
|
||||||
writeJSON(w, 200, map[string]any{"pair": s.view(r, p), "warnings": warns})
|
writeJSON(w, 200, map[string]any{"pair": s.view(r, p), "warnings": warns})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -255,7 +255,7 @@ func (e *Engine) open(p store.Pair) (*libs, error) {
|
|||||||
|
|
||||||
// NormalizePair fills defaults and validates a pair. It returns non-fatal
|
// NormalizePair fills defaults and validates a pair. It returns non-fatal
|
||||||
// warnings (such as layout mismatches) for the UI.
|
// 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)
|
p.Name = strings.TrimSpace(p.Name)
|
||||||
if p.Name == "" {
|
if p.Name == "" {
|
||||||
return nil, errors.New("name is required")
|
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
|
// 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
|
// 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.
|
// 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
|
empty := true
|
||||||
if ents, _ := os.ReadDir(l.slave.Path); len(ents) > 0 {
|
if ents, _ := os.ReadDir(l.slave.Path); len(ents) > 0 {
|
||||||
for _, en := range ents {
|
for _, en := range ents {
|
||||||
@@ -312,10 +312,11 @@ func layoutWarnings(l *libs) []string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if empty {
|
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 != "" {
|
if w := renamedWarning(renamedFolders(l)); w != nil {
|
||||||
return []string{w}
|
return []store.Warning{*w}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -502,8 +502,8 @@ func TestAnyLayoutMatchesWithoutWarnings(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
for _, w := range p.LastScanWarnings {
|
for _, w := range p.LastScanWarnings {
|
||||||
if strings.Contains(w, "layout") || strings.Contains(w, "folder") || strings.Contains(w, "depth") {
|
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 %q", w)
|
t.Fatalf("matching layouts must not produce layout warnings, got %+v", w)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+51
-27
@@ -35,6 +35,15 @@ func folderKey(name string) string {
|
|||||||
return b.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 {
|
func subdirs(dir string) []string {
|
||||||
ents, _ := os.ReadDir(dir)
|
ents, _ := os.ReadDir(dir)
|
||||||
var out []string
|
var out []string
|
||||||
@@ -84,32 +93,32 @@ func renamedFolders(l *libs) [][2]string {
|
|||||||
return out
|
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 {
|
if len(pairs) == 0 {
|
||||||
return ""
|
return nil
|
||||||
}
|
}
|
||||||
var ex []string
|
w := &store.Warning{Total: len(pairs),
|
||||||
for _, p := range pairs[:min(3, len(pairs))] {
|
Detail: "Songs inside these folders can only match by path once the names agree. Rename one side so they match."}
|
||||||
ex = append(ex, fmt.Sprintf("%q (slave) vs %q (master)", p[0], p[1]))
|
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 := ""
|
for _, p := range pairs[:min(maxWarningItems, len(pairs))] {
|
||||||
if len(pairs) > 3 {
|
w.Items = append(w.Items, store.WarningItem{Slave: p[0], Master: p[1]})
|
||||||
more = fmt.Sprintf(" and %d more", len(pairs)-3)
|
|
||||||
}
|
}
|
||||||
noun := "folder is"
|
return w
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// layoutStats watches, during a scan, where songs found in both libraries
|
// layoutStats watches, during a scan, where songs found in both libraries
|
||||||
// live. Songs only in the slave library are ignored.
|
// live. Songs only in the slave library are ignored.
|
||||||
type layoutStats struct {
|
type layoutStats struct {
|
||||||
shared int // slave files with a master counterpart
|
shared int // slave files with a master counterpart
|
||||||
moved int // ...that live in a different folder in the master
|
moved int // ...that live in a different folder in the master
|
||||||
example [][2]string // a few moved examples
|
pairs map[[2]string]int // (slave album folder, master album folder) -> songs
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ls *layoutStats) add(relSlave, relMaster, kind, reason string) {
|
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) {
|
if albumKey(relSlave) == albumKey(relMaster) {
|
||||||
return // only a disc folder differs
|
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
|
// A single, EP or compilation matched to its album version is the
|
||||||
// intended behaviour, not a layout problem.
|
// intended behaviour, not a layout problem.
|
||||||
if st := parseTitle(relSlave); st.single || st.compilation || strings.Contains(reason, "album version preferred") {
|
if st := parseTitle(relSlave); st.single || st.compilation || strings.Contains(reason, "album version preferred") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ls.moved++
|
ls.moved++
|
||||||
if len(ls.example) < 2 {
|
if ls.pairs == nil {
|
||||||
ls.example = append(ls.example, [2]string{relSlave, relMaster})
|
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 {
|
if ls.shared < 20 || ls.moved*4 <= ls.shared {
|
||||||
return ""
|
return nil
|
||||||
}
|
}
|
||||||
var ex []string
|
items := make([]store.WarningItem, 0, len(ls.pairs))
|
||||||
for _, e := range ls.example {
|
for p, n := range ls.pairs {
|
||||||
ex = append(ex, fmt.Sprintf("%q is at %q", e[0], e[1]))
|
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). "+
|
sort.Slice(items, func(i, j int) bool {
|
||||||
"The libraries seem to use different folder layouts, so these songs only match through the fallbacks.",
|
if items[i].Count != items[j].Count {
|
||||||
ls.shared, ls.moved, strings.Join(ex, "; "))
|
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
|
||||||
}
|
}
|
||||||
@@ -4,18 +4,20 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"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()
|
t.Helper()
|
||||||
ev.scan()
|
ev.scan()
|
||||||
p, err := ev.st.Pair(ctx, ev.pair.ID)
|
p, err := ev.st.Pair(ctx, ev.pair.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
var out []string
|
var out []store.Warning
|
||||||
for _, w := range p.LastScanWarnings {
|
for _, w := range p.LastScanWarnings {
|
||||||
if !strings.Contains(w, "ffprobe") {
|
if !strings.Contains(w.Title, "ffprobe") {
|
||||||
out = append(out, w)
|
out = append(out, w)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -36,7 +38,7 @@ func TestSlaveOnlyContentIsNotALayoutProblem(t *testing.T) {
|
|||||||
}
|
}
|
||||||
ev.write(ev.master, "Master Only/Album/01 X.flac", "m")
|
ev.write(ev.master, "Master Only/Album/01 X.flac", "m")
|
||||||
if w := scanWarnings(t, ev); len(w) != 0 {
|
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.slave, "Beyoncé/Lemonade/Formation.mp3", "b")
|
||||||
ev.write(ev.master, "Beyonce/Lemonade/Formation.flac", "a")
|
ev.write(ev.master, "Beyonce/Lemonade/Formation.flac", "a")
|
||||||
w := scanWarnings(t, ev)
|
w := scanWarnings(t, ev)
|
||||||
if len(w) != 1 {
|
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 %q", w)
|
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"} {
|
want := map[store.WarningItem]bool{
|
||||||
if !strings.Contains(w[0], want) {
|
{Slave: "AC-DC", Master: "AC_DC"}: true,
|
||||||
t.Errorf("warning %q should contain %q", w[0], want)
|
{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")
|
ev.write(ev.slave, fmt.Sprintf("Band/Song Number %d.mp3", i), "s")
|
||||||
}
|
}
|
||||||
w := scanWarnings(t, ev)
|
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") {
|
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 %q", w)
|
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")
|
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 {
|
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")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
// Layout warnings based on what was actually found.
|
||||||
if w := layout.warning(); w != "" {
|
if w := layout.warning(); w != nil {
|
||||||
warns = append(warns, w)
|
warns = append(warns, *w)
|
||||||
}
|
}
|
||||||
if ms.symlinks+ss.symlinks > 0 {
|
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 == "" {
|
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 {
|
if err := e.st.FinishScan(ctx, pairID, time.Now().Unix(), "", warns); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
+55
-22
@@ -91,25 +91,61 @@ func (s *Store) PruneSessions(ctx context.Context) {
|
|||||||
_, _ = s.DB.ExecContext(ctx, `DELETE FROM sessions WHERE expires_at <= ?`, now())
|
_, _ = 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 ----
|
// ---- pairs ----
|
||||||
|
|
||||||
type Pair struct {
|
type Pair struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
MasterRoot string `json:"master_root"`
|
MasterRoot string `json:"master_root"`
|
||||||
SlaveRoot string `json:"slave_root"`
|
SlaveRoot string `json:"slave_root"`
|
||||||
QuarantineDir string `json:"quarantine_dir"`
|
QuarantineDir string `json:"quarantine_dir"`
|
||||||
Extensions []string `json:"extensions"`
|
Extensions []string `json:"extensions"`
|
||||||
AutoLinkIdentical bool `json:"auto_link_identical"`
|
AutoLinkIdentical bool `json:"auto_link_identical"`
|
||||||
ScanIntervalMinutes int `json:"scan_interval_minutes"`
|
ScanIntervalMinutes int `json:"scan_interval_minutes"`
|
||||||
RetentionDays int `json:"retention_days"`
|
RetentionDays int `json:"retention_days"`
|
||||||
SettleMinutes int `json:"settle_minutes"`
|
SettleMinutes int `json:"settle_minutes"`
|
||||||
ApproxMatching bool `json:"approx_matching"`
|
ApproxMatching bool `json:"approx_matching"`
|
||||||
ApproxThreshold int `json:"approx_threshold"` // percent
|
ApproxThreshold int `json:"approx_threshold"` // percent
|
||||||
LastScanAt *int64 `json:"last_scan_at"`
|
LastScanAt *int64 `json:"last_scan_at"`
|
||||||
LastScanError string `json:"last_scan_error"`
|
LastScanError string `json:"last_scan_error"`
|
||||||
LastScanWarnings []string `json:"last_scan_warnings"`
|
LastScanWarnings []Warning `json:"last_scan_warnings"`
|
||||||
CreatedAt int64 `json:"created_at"`
|
CreatedAt int64 `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
const pairCols = `id, name, master_root, slave_root, quarantine_dir, extensions, auto_link_identical,
|
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.LastScanAt = &last.Int64
|
||||||
}
|
}
|
||||||
p.Extensions = strings.Split(exts, ",")
|
p.Extensions = strings.Split(exts, ",")
|
||||||
_ = json.Unmarshal([]byte(warns), &p.LastScanWarnings)
|
p.LastScanWarnings = decodeWarnings(warns)
|
||||||
if p.LastScanWarnings == nil {
|
|
||||||
p.LastScanWarnings = []string{}
|
|
||||||
}
|
|
||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,9 +229,9 @@ func (s *Store) ClearMatches(ctx context.Context, pairID int64) error {
|
|||||||
return err
|
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 {
|
if warnings == nil {
|
||||||
warnings = []string{}
|
warnings = []Warning{}
|
||||||
}
|
}
|
||||||
w, _ := json.Marshal(warnings)
|
w, _ := json.Marshal(warnings)
|
||||||
_, err := s.DB.ExecContext(ctx, `UPDATE pairs SET last_scan_at = ?, last_scan_error = ?, last_scan_warnings = ? WHERE id = ?`,
|
_, err := s.DB.ExecContext(ctx, `UPDATE pairs SET last_scan_at = ?, last_scan_error = ?, last_scan_warnings = ? WHERE id = ?`,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-1
@@ -75,7 +75,7 @@ export type Pair = {
|
|||||||
approx_threshold: number;
|
approx_threshold: number;
|
||||||
last_scan_at: number | null;
|
last_scan_at: number | null;
|
||||||
last_scan_error: string;
|
last_scan_error: string;
|
||||||
last_scan_warnings: string[];
|
last_scan_warnings: ScanWarning[];
|
||||||
summary: Summary;
|
summary: Summary;
|
||||||
job: Job | null;
|
job: Job | null;
|
||||||
};
|
};
|
||||||
@@ -199,3 +199,10 @@ export type BulkPreview = {
|
|||||||
bytes_freed: number;
|
bytes_freed: number;
|
||||||
examples: Record<string, string[] | undefined>;
|
examples: Record<string, string[] | undefined>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ScanWarning = {
|
||||||
|
title: string;
|
||||||
|
detail?: string;
|
||||||
|
items?: { slave: string; master: string; count?: number }[];
|
||||||
|
total?: number;
|
||||||
|
};
|
||||||
@@ -105,7 +105,7 @@ function PairCard({ pair }: { pair: Pair }) {
|
|||||||
)}
|
)}
|
||||||
{pair.last_scan_warnings.length > 0 && (
|
{pair.last_scan_warnings.length > 0 && (
|
||||||
<div className="mt-3 flex gap-2 text-xs text-amber-300">
|
<div className="mt-3 flex gap-2 text-xs text-amber-300">
|
||||||
<AlertTriangle className="size-3.5 shrink-0" /> {pair.last_scan_warnings.length} layout warning{pair.last_scan_warnings.length > 1 && "s"}
|
<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`}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<a href={`#/pair/${pair.id}`} className="mt-4 flex items-center gap-1 self-end text-sm text-accent hover:underline">
|
<a href={`#/pair/${pair.id}`} className="mt-4 flex items-center gap-1 self-end text-sm text-accent hover:underline">
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { AlertTriangle, ChevronLeft, Folder, FolderOpen } from "lucide-react";
|
import { ChevronLeft, Folder, FolderOpen } from "lucide-react";
|
||||||
import { api, type Pair, type PairInput } from "../api";
|
import { api, type Pair, type PairInput, type ScanWarning } from "../api";
|
||||||
|
import { WarningList } from "./Warnings";
|
||||||
import { useInfo } from "../App";
|
import { useInfo } from "../App";
|
||||||
import { Alert, Button, Field, Modal, Toggle, inputCls } from "../ui";
|
import { Alert, Button, Field, Modal, Toggle, inputCls } from "../ui";
|
||||||
import { useToast } from "../toast";
|
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 }) {
|
export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean; onClose: () => void }) {
|
||||||
const info = useInfo();
|
const info = useInfo();
|
||||||
@@ -63,7 +64,7 @@ export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean;
|
|||||||
onSuccess: setValidation,
|
onSuccess: setValidation,
|
||||||
});
|
});
|
||||||
const save = useMutation({
|
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: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ["pairs"] });
|
qc.invalidateQueries({ queryKey: ["pairs"] });
|
||||||
qc.invalidateQueries({ queryKey: ["pair"] });
|
qc.invalidateQueries({ queryKey: ["pair"] });
|
||||||
@@ -188,11 +189,7 @@ export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean;
|
|||||||
|
|
||||||
{validation && !validation.ok && <Alert tone="red">{validation.error}</Alert>}
|
{validation && !validation.ok && <Alert tone="red">{validation.error}</Alert>}
|
||||||
{validation?.ok && validation.warnings.length === 0 && <Alert tone="green">Looks good. Both folders are valid and on the same filesystem.</Alert>}
|
{validation?.ok && validation.warnings.length === 0 && <Alert tone="green">Looks good. Both folders are valid and on the same filesystem.</Alert>}
|
||||||
{validation?.warnings.map((w) => (
|
{validation?.ok && <WarningList warnings={validation.warnings} />}
|
||||||
<Alert key={w} tone="amber" icon={<AlertTriangle className="size-4 shrink-0 mt-0.5" />}>
|
|
||||||
{w}
|
|
||||||
</Alert>
|
|
||||||
))}
|
|
||||||
{save.error && <Alert tone="red">{save.error.message}</Alert>}
|
{save.error && <Alert tone="red">{save.error.message}</Alert>}
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
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 { api, type Job, type Pair } from "../api";
|
||||||
import { Alert, Badge, Button, Card, Modal, Spinner, cx, formatAgo } from "../ui";
|
import { Alert, Badge, Button, Card, Modal, Spinner, cx, formatAgo } from "../ui";
|
||||||
import { useToast } from "../toast";
|
import { useToast } from "../toast";
|
||||||
@@ -9,6 +9,7 @@ import { PairForm } from "./PairForm";
|
|||||||
import { Review } from "./Review";
|
import { Review } from "./Review";
|
||||||
import { Build } from "./Build";
|
import { Build } from "./Build";
|
||||||
import { OpsTable } from "./Activity";
|
import { OpsTable } from "./Activity";
|
||||||
|
import { WarningList } from "./Warnings";
|
||||||
|
|
||||||
export function usePair(id: number) {
|
export function usePair(id: number) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@@ -174,11 +175,7 @@ export function PairPage({ id, tab }: { id: number; tab: string }) {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
{pair.last_scan_error && <Alert tone="red">Last scan failed: {pair.last_scan_error}</Alert>}
|
{pair.last_scan_error && <Alert tone="red">Last scan failed: {pair.last_scan_error}</Alert>}
|
||||||
{pair.last_scan_warnings.map((w) => (
|
<WarningList warnings={pair.last_scan_warnings} />
|
||||||
<Alert key={w} tone="amber" icon={<AlertTriangle className="mt-0.5 size-4 shrink-0" />}>
|
|
||||||
{w}
|
|
||||||
</Alert>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<div className="flex gap-1 border-b border-line">
|
<div className="flex gap-1 border-b border-line">
|
||||||
{tabs.map((t) => (
|
{tabs.map((t) => (
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{warnings.map((w) => (
|
||||||
|
<WarningCard key={w.title} w={w} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<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>
|
||||||
|
{w.detail && <div className="mt-0.5 text-xs text-amber-200/70">{w.detail}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{items.length > 0 && (
|
||||||
|
<div className="px-4 pb-3 pl-10.5">
|
||||||
|
<div className="overflow-x-auto rounded-md border border-amber-500/15 bg-bg/40">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-[11px] uppercase tracking-wide text-amber-200/50">
|
||||||
|
<th className="px-3 py-1.5 font-medium">Slave</th>
|
||||||
|
<th className="w-4" aria-hidden />
|
||||||
|
<th className="px-3 py-1.5 font-medium">Master</th>
|
||||||
|
{grouped && <th className="px-3 py-1.5 text-right font-medium">Songs</th>}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{shown.map((it) => (
|
||||||
|
<tr key={it.slave + "\u0000" + it.master} className="border-t border-amber-500/10 align-top">
|
||||||
|
<td className="px-3 py-1.5 mono break-all text-gray-200">{grouped ? it.slave || "(library root)" : <Diff a={it.slave} b={it.master} />}</td>
|
||||||
|
<td className="py-1.5 text-amber-200/40">
|
||||||
|
<ArrowRight className="size-3 mt-0.5" />
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 mono break-all text-gray-200">{grouped ? it.master || "(library root)" : <Diff a={it.master} b={it.slave} />}</td>
|
||||||
|
{grouped && <td className="px-3 py-1.5 text-right tabular-nums text-amber-100">{it.count}</td>}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{(items.length > PREVIEW_ROWS || hidden > 0) && (
|
||||||
|
<div className="mt-1.5 flex items-center gap-3 text-xs">
|
||||||
|
{items.length > PREVIEW_ROWS && (
|
||||||
|
<button className="flex items-center gap-1 text-amber-200 hover:text-white cursor-pointer" onClick={() => setExpanded(!expanded)}>
|
||||||
|
{expanded ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />}
|
||||||
|
{expanded ? "Show fewer" : `Show all ${items.length}`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{expanded && total > items.length && <span className="text-amber-200/60">and {total - items.length} more not listed</span>}
|
||||||
|
{!expanded && items.length <= PREVIEW_ROWS && hidden > 0 && <span className="text-amber-200/60">and {hidden} more</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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("")}
|
||||||
|
<mark title={tip} className={cx("rounded-sm bg-amber-400/25 px-px text-amber-50", tip && "underline decoration-dotted")}>
|
||||||
|
{mid.join("")}
|
||||||
|
</mark>
|
||||||
|
{ca.slice(ca.length - suf).join("")}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user