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:
cdnutterandClaude Opus 5.5 committed 2026-09-23 21:49:22 -07:00
1 parent 1d31999793
commit 9e848cd765
13 files changed
+305 -92

No files matched your search

+55 -22
View File
@@ -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 = ?`,