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 @@

RELATED

number: [['eq','equals'], ['ne','is not'], ['lt','<'], ['lte','≤'], ['gt','>'], ['gte','≥'], ['between','between']], string: [['is','equals'], ['is_not','is not'], ['contains','contains'], ['not_contains','doesn’t contain'], ['starts_with','starts with'], ['ends_with','ends with'], ['is_empty','is empty'], ['not_empty','not empty']], - key: [['is','equals'], ['is_not','is not'], ['compatible','is compatible with']], + // rekordbox's key operators, plus Vynull's harmonic-mixing match. + key: [['is','equals'], ['is_not','is not'], ['contains','contains'], ['not_contains','doesn’t contain'], + ['starts_with','starts with'], ['ends_with','ends with'], ['compatible','is compatible with']], tag: [['has','has tag'], ['not_has','doesn’t have tag'], ['has_any','has any tag'], ['none','has no tags']], date: [['in_last_days','in the last (days)'], ['not_in_last_days','not in the last (days)'], ['in_last_months','in the last (months)'], ['not_in_last_months','not in the last (months)'], ['on','is'], ['not_on','is not'], ['before','before'], ['after','after'], ['between','between']], color: [['eq','is'], ['ne','is not']], }; +// Key operators that test the key's name as text, so take a typed value. +const SMART_KEY_TEXT_OPS = ['contains', 'not_contains', 'starts_with', 'ends_with']; // Date operators whose value is a count of days or months, not a date. const SMART_DATE_COUNT_OPS = ['in_last_days', 'not_in_last_days', 'in_last_months', 'not_in_last_months']; // Camelot key list for key field dropdowns. @@ -11311,10 +11315,23 @@

RELATED

} return ``; } + if (meta.type === 'key' && SMART_KEY_TEXT_OPS.includes(op)) { + return ``; + } if (meta.type === 'key') { - return ``; + // An imported rekordbox rule may name its key in classic notation + // ("Am"); keep that value as its own option so opening the editor never + // swaps the rule's key for the first one listed. + const v = c.value == null ? '' : String(c.value); + // Each key is stored in Camelot and labelled in both notations, led by + // the user's key display setting ("8A · Am" or "Am · 8A"). + const opts = SMART_KEYS.map(k => + ``); + if (v && !SMART_KEYS.includes(v)) { + const p = KEY_PAIRS[v.trim().toUpperCase()]; + opts.unshift(``); + } + return ``; } if (meta.type === 'tag') { const tags = (allTagsCache || []); @@ -11601,10 +11618,24 @@

RELATED

return false; } if (meta.type === 'key') { - const trackKey = (t.key || '').toUpperCase(); - const target = (v || '').toString().toUpperCase(); + // Compare keys in either notation, as the server does. + const cam = x => { const k = String(x || '').toUpperCase(); return (KEY_PAIRS[k] || [k])[0]; }; + const trackKey = cam(t.key); + const target = cam(v); if (op === 'is') return trackKey === target; if (op === 'is_not') return trackKey !== target; + if (SMART_KEY_TEXT_OPS.includes(op)) { + // The key's name in each notation, as the server tests it. + const raw = String(t.key || ''); + const p = KEY_PAIRS[raw.trim().toUpperCase()]; + const names = (p ? [raw, p[0], p[1]] : [raw]).map(n => n.toLowerCase()); + const q = String(v || '').toLowerCase(); + const hit = f => names.some(f); + if (op === 'contains') return hit(n => n.includes(q)); + if (op === 'not_contains') return raw !== '' && !hit(n => n.includes(q)); + if (op === 'starts_with') return hit(n => n.startsWith(q)); + if (op === 'ends_with') return hit(n => n.endsWith(q)); + } // compatible — quick approximation; server does the authoritative check return trackKey === target; }