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
+51
-27
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user