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
+55
-22
@@ -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 = ?`,
|
||||
|
||||
Reference in new issue
Block a user