diff --git a/api/smart_key_notation_test.go b/api/smart_key_notation_test.go new file mode 100644 index 0000000..50df179 --- /dev/null +++ b/api/smart_key_notation_test.go @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package api + +import ( + "testing" + "time" + + "github.com/vynulldev/vynull/library" +) + +// An import stores keys as Camelot, but rekordbox key rules carry the names +// rekordbox showed: "key is Am" must still find a track stored as 8A, and a +// rule in either notation must find a key stored in the other. +func TestSmartKeyRulesAcrossNotations(t *testing.T) { + match := func(op int, left, key string) bool { + tr := &library.Track{ID: 1, Key: key} + if op == 0 { // Vynull's own "is compatible with", which rekordbox lacks + return (&SmartRules{Combinator: "all", Conditions: []SmartCond{ + {Field: "key", Operator: "compatible", Value: left}}}).Match(tr, nil, time.Now()) + } + rules, _, _ := smartRulesFromRekordbox(&library.RBSmartList{Logical: 1, + Conditions: []library.RBSmartCond{{Property: "key", Operator: op, Left: left}}}) + return rules.Match(tr, nil, time.Now()) + } + for _, tc := range []struct { + op int + left, key string + want bool + }{ + {1, "Am", "8A", true}, // is + {1, "8A", "Am", true}, // is, the other way round + {1, "Abm", "1A", true}, // an enharmonic spelling + {1, "Am", "8B", false}, // a different key + {2, "Am", "8A", false}, // is not + {2, "Am", "9A", true}, + {8, "m", "8A", true}, // contains, against the classic name Am + {9, "m", "8B", true}, // does not contain: C has no m in either name + {9, "m", "8A", false}, + {10, "8", "8A", true}, // starts with, in Camelot + {1, " Am", "8A", false}, // text with spaces is not a key name + {1, "Alpha", "Alpha", true}, // non-key text compares as text + {0, "Am", "9A", true}, // compatible (a neighbour), typed in classic + {0, "Am", "3B", false}, + } { + if got := match(tc.op, tc.left, tc.key); got != tc.want { + t.Errorf("op %d %q vs key %q = %v, want %v", tc.op, tc.left, tc.key, got, tc.want) + } + } +} diff --git a/api/smart_playlist.go b/api/smart_playlist.go index 70cc297..9cef37f 100644 --- a/api/smart_playlist.go +++ b/api/smart_playlist.go @@ -10,6 +10,7 @@ import ( "time" "unicode" + "github.com/vynulldev/vynull/analysis" "github.com/vynulldev/vynull/library" ) @@ -226,18 +227,69 @@ func keyMatch(op string, value any, trackKey string) bool { return false } switch op { - case "is", "eq", "=", "is_not", "ne", "!=": - // Equality compares the key's name like any other text. - return stringMatch(op, value, trackKey) + case "is", "eq", "=": + return sameKey(target, trackKey) + case "is_not", "ne", "!=": + return !sameKey(target, trackKey) case "compatible": - return camelotCompatible(trackKey, target) - case "contains", "not_contains", "starts_with", "ends_with": - // Text operators compare the key's name, as rekordbox does. - return stringMatch(op, value, trackKey) + return camelotCompatible(camelotName(trackKey), camelotName(target)) + case "contains", "starts_with", "ends_with": + // Text operators compare the key's name, as rekordbox does, in + // either notation: an import stores keys as Camelot, but the rule + // may be written against rekordbox's classic names. + for _, name := range keyNames(trackKey) { + if stringMatch(op, value, name) { + return true + } + } + return false + case "not_contains": + for _, name := range keyNames(trackKey) { + if !stringMatch(op, value, name) { + return false + } + } + return true } return false } +// sameKey reports whether a key rule's value names the track's key. It +// compares the names as text, as rekordbox does, and also as keys in either +// notation, since an import stores keys as Camelot ("8A") while rekordbox +// rules carry the names rekordbox showed ("Am"). +func sameKey(rule, trackKey string) bool { + if stringMatch("is", rule, trackKey) { + return true + } + a, _ := analysis.KeyNamesFrom(rule) + b, _ := analysis.KeyNamesFrom(trackKey) + return a != "" && a == b && rule == strings.TrimSpace(rule) +} + +// camelotName is a key's Camelot name when it names a key in either +// notation ("Am" is 8A), else the text unchanged. +func camelotName(key string) string { + if cam, _ := analysis.KeyNamesFrom(key); cam != "" { + return cam + } + return key +} + +// keyNames is a track key's name in each notation it has: the stored name +// and, for a recognised key, its Camelot and classic names. +func keyNames(key string) []string { + names := []string{key} + if cam, std := analysis.KeyNamesFrom(key); cam != "" { + for _, n := range []string{cam, std} { + if n != key { + names = append(names, n) + } + } + } + return names +} + // foldText prepares text for comparison the way rekordbox's smart playlists // compare it (link-spec conformance smart text suites): case-insensitive, a // precomposed accented letter equals its base letter ("Álpha" is "alpha"), diff --git a/api/web/index.html b/api/web/index.html index f6ac8cc..9e06c84 100644 --- a/api/web/index.html +++ b/api/web/index.html @@ -11140,13 +11140,17 @@