fix: smart playlist UX polish — layout, defaults, free-form input, case-insensitive matching

Details view:
- Skip evaluation on new playlists (was returning all 25k tracks)
- Go straight to editor on auto-edit instead of awaiting loadTracks

Editor:
- Default sort to Random instead of None, remove None option
- Hide sort direction button when sort is Random
- Fixed-width field (160px) and operator (140px) columns, value fills remaining space
- Preview fills remaining vertical space (flex layout) instead of fixed 200px max-height
- Preview scrolls independently while rules/options stay pinned

Combobox:
- Accept free-form text on blur and Enter — no longer requires selection from dropdown
- Enables typing values like 'indie' that may not be exact DB entries

Backend:
- Case-insensitive text matching: COLLATE NOCASE on is/is_not/is_any_of operators
- Applies to both regular fields and genre subqueries
- LIKE operators were already case-insensitive (SQLite default)
This commit is contained in:
2026-03-21 13:25:21 -04:00
parent 477b7ff6a2
commit 6972953649
5 changed files with 69 additions and 33 deletions
+6 -6
View File
@@ -194,13 +194,13 @@ func buildGenreSubquery(rule Rule) (string, []any, error) {
switch rule.Operator {
case "is":
return subquery + "g.name = ?)", []any{rule.Value}, nil
return subquery + "g.name = ? COLLATE NOCASE)", []any{rule.Value}, nil
case "is_not":
return `af.id NOT IN (
SELECT rg_sub.recording_id FROM recording_genres rg_sub
JOIN genres g ON rg_sub.genre_id = g.id
WHERE g.name = ?)`, []any{rule.Value}, nil
WHERE g.name = ? COLLATE NOCASE)`, []any{rule.Value}, nil
case "is_any_of":
var values []string
@@ -225,7 +225,7 @@ func buildGenreSubquery(rule Rule) (string, []any, error) {
condArgs := make([]any, len(values))
for i, v := range values {
placeholders[i] = "?"
placeholders[i] = "? COLLATE NOCASE"
condArgs[i] = v
}
@@ -255,7 +255,7 @@ func buildCondition(
return col + " = ?", []any{v}, nil
}
return col + " = ?", []any{rule.Value}, nil
return col + " = ? COLLATE NOCASE", []any{rule.Value}, nil
case "is_not":
if isNumeric {
@@ -267,7 +267,7 @@ func buildCondition(
return col + " != ?", []any{v}, nil
}
return col + " != ?", []any{rule.Value}, nil
return col + " != ? COLLATE NOCASE", []any{rule.Value}, nil
case "contains":
return col + " LIKE ?",
@@ -308,7 +308,7 @@ func buildCondition(
condArgs := make([]any, len(values))
for i, v := range values {
placeholders[i] = "?"
placeholders[i] = "? COLLATE NOCASE"
condArgs[i] = v
}
+12 -12
View File
@@ -264,8 +264,8 @@ func TestBuildWhereClause_TextIs(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
if clause != "artist_name = ?" {
t.Errorf("clause = %q, want %q", clause, "artist_name = ?")
if clause != "artist_name = ? COLLATE NOCASE" {
t.Errorf("clause = %q, want %q", clause, "artist_name = ? COLLATE NOCASE")
}
if len(args) != 1 || args[0] != "Queen" {
@@ -283,9 +283,9 @@ func TestBuildWhereClause_TextIsNot(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
if clause != "artist_name != ?" {
if clause != "artist_name != ? COLLATE NOCASE" {
t.Errorf("clause = %q, want %q",
clause, "artist_name != ?")
clause, "artist_name != ? COLLATE NOCASE")
}
if len(args) != 1 || args[0] != "Queen" {
@@ -386,9 +386,9 @@ func TestBuildWhereClause_TextIsAnyOf(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
if clause != "artist_name IN (?, ?)" {
if clause != "artist_name IN (? COLLATE NOCASE, ? COLLATE NOCASE)" {
t.Errorf("clause = %q, want %q",
clause, "artist_name IN (?, ?)")
clause, "artist_name IN (? COLLATE NOCASE, ? COLLATE NOCASE)")
}
if len(args) != 2 || args[0] != "Queen" || args[1] != "AC/DC" {
@@ -541,8 +541,8 @@ func TestBuildWhereClause_GenreIsProducesSubquery(t *testing.T) {
clause)
}
if !strings.Contains(clause, "g.name = ?") {
t.Errorf("genre 'is' should have g.name = ?: %q", clause)
if !strings.Contains(clause, "g.name = ? COLLATE NOCASE") {
t.Errorf("genre 'is' should have g.name = ? COLLATE NOCASE: %q", clause)
}
if len(args) != 1 || args[0] != "Rock" {
@@ -596,9 +596,9 @@ func TestBuildWhereClause_GenreIsAnyOfProducesSubquery(t *testing.T) {
)
}
if !strings.Contains(clause, "g.name IN (?, ?)") {
if !strings.Contains(clause, "g.name IN (? COLLATE NOCASE, ? COLLATE NOCASE)") {
t.Errorf(
"genre 'is_any_of' should have g.name IN (?, ?): %q",
"genre 'is_any_of' should have g.name IN (? COLLATE NOCASE, ? COLLATE NOCASE): %q",
clause,
)
}
@@ -647,9 +647,9 @@ func TestBuildWhereClause_MultipleRulesAND(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
if clause != "artist_name = ? AND year > ?" {
if clause != "artist_name = ? COLLATE NOCASE AND year > ?" {
t.Errorf("clause = %q, want %q",
clause, "artist_name = ? AND year > ?")
clause, "artist_name = ? COLLATE NOCASE AND year > ?")
}
if len(args) != 2 || args[0] != "Queen" || args[1] != int64(1975) {