@musodojo/music-theory-data 14.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,166 @@
1
+ import {
2
+ diatonicSeventhChords,
3
+ diatonicTriads,
4
+ harmonicMinorSeventhChords,
5
+ harmonicMinorTriads,
6
+ lowerCaseRomanNumerals,
7
+ melodicMinorSeventhChords,
8
+ melodicMinorTriads,
9
+ upperCaseRomanNumerals,
10
+ } from "../data/chords/mod.ts";
11
+ import type {
12
+ ChordDetails,
13
+ RomanSeventhChord,
14
+ RomanTriad,
15
+ SeventhChord,
16
+ Triad,
17
+ } from "../types/chords.d.ts";
18
+ import { rotateArray } from "./rotate-array.ts";
19
+ import {
20
+ type HarmonicMinorModeKey,
21
+ harmonicMinorModes,
22
+ } from "../data/note-collections/harmonic-minor-modes.ts";
23
+ import {
24
+ type MelodicMinorModeKey,
25
+ melodicMinorModes,
26
+ } from "../data/note-collections/melodic-minor-modes.ts";
27
+ import type { Interval } from "../data/labels/note-labels.ts";
28
+ import {
29
+ type DiatonicModeKey,
30
+ diatonicModes,
31
+ } from "../data/note-collections/diatonic-modes.ts";
32
+
33
+ export function getRomanTriads(triads: Triad[]): RomanTriad[] {
34
+ return triads.map((quality, i) => {
35
+ switch (quality) {
36
+ case "M":
37
+ return upperCaseRomanNumerals[i];
38
+ case "m":
39
+ return lowerCaseRomanNumerals[i];
40
+ case "°":
41
+ return lowerCaseRomanNumerals[i] + quality;
42
+ case "+":
43
+ return upperCaseRomanNumerals[i] + quality;
44
+ default:
45
+ return upperCaseRomanNumerals[i];
46
+ }
47
+ }) as RomanTriad[];
48
+ }
49
+
50
+ export function getRomanSeventhChords(
51
+ sevenths: SeventhChord[],
52
+ ): RomanSeventhChord[] {
53
+ return sevenths.map((quality, i) => {
54
+ switch (quality) {
55
+ case "M7":
56
+ return upperCaseRomanNumerals[i] + quality;
57
+ case "m7":
58
+ return lowerCaseRomanNumerals[i] + quality;
59
+ case "7":
60
+ return upperCaseRomanNumerals[i] + quality;
61
+ case "ø7":
62
+ return lowerCaseRomanNumerals[i] + quality;
63
+ case "m7♭5":
64
+ return lowerCaseRomanNumerals[i] + quality;
65
+ case "°7":
66
+ return lowerCaseRomanNumerals[i] + quality;
67
+ case "m(M7)":
68
+ return lowerCaseRomanNumerals[i] + "M7";
69
+ case "+M7":
70
+ return upperCaseRomanNumerals[i] + quality;
71
+ case "M7♯5":
72
+ return upperCaseRomanNumerals[i] + quality;
73
+ default:
74
+ return upperCaseRomanNumerals[i] + quality;
75
+ }
76
+ }) as RomanSeventhChord[];
77
+ }
78
+
79
+ export function getChordDetailsForMode(
80
+ intervals: readonly Interval[],
81
+ triads: readonly Triad[],
82
+ sevenths: readonly SeventhChord[],
83
+ romanTriads: readonly RomanTriad[],
84
+ romanSeventhChords: readonly RomanSeventhChord[],
85
+ ): ChordDetails[] {
86
+ return intervals
87
+ .filter((interval) => interval !== "8")
88
+ .map((interval, i) => ({
89
+ interval,
90
+ triad: triads[i],
91
+ seventh: sevenths[i],
92
+ romanTriad: romanTriads[i],
93
+ romanSeventhChord: romanSeventhChords[i],
94
+ }));
95
+ }
96
+
97
+ export function getChordDetailsForDiatonicMode(
98
+ diatonicModeKey: DiatonicModeKey,
99
+ ): ChordDetails[] {
100
+ const mode = diatonicModes[diatonicModeKey];
101
+ const rotation = mode.rotation as number;
102
+ const rotatedTriads = rotateArray(diatonicTriads, rotation);
103
+ const rotatedSeventhChords = rotateArray(diatonicSeventhChords, rotation);
104
+ return getChordDetailsForMode(
105
+ mode.intervals,
106
+ rotatedTriads,
107
+ rotatedSeventhChords,
108
+ getRomanTriads(rotatedTriads),
109
+ getRomanSeventhChords(rotatedSeventhChords),
110
+ );
111
+ }
112
+
113
+ export function getChordDetailsForHarmonicMinorMode(
114
+ harmonicMinorModeKey: HarmonicMinorModeKey,
115
+ ): ChordDetails[] {
116
+ const mode = harmonicMinorModes[harmonicMinorModeKey];
117
+ const rotation = mode.rotation as number;
118
+ const rotatedTriads = rotateArray(harmonicMinorTriads, rotation);
119
+ const rotatedSeventhChords = rotateArray(
120
+ harmonicMinorSeventhChords,
121
+ rotation,
122
+ );
123
+ return getChordDetailsForMode(
124
+ mode.intervals,
125
+ rotatedTriads,
126
+ rotatedSeventhChords,
127
+ getRomanTriads(rotatedTriads),
128
+ getRomanSeventhChords(rotatedSeventhChords),
129
+ );
130
+ }
131
+
132
+ export function getChordDetailsForMelodicMinorMode(
133
+ melodicMinorModeKey: MelodicMinorModeKey,
134
+ ): ChordDetails[] {
135
+ const mode = melodicMinorModes[melodicMinorModeKey];
136
+ const rotation = mode.rotation as number;
137
+ const rotatedTriads = rotateArray(melodicMinorTriads, rotation);
138
+ const rotatedSeventhChords = rotateArray(melodicMinorSeventhChords, rotation);
139
+ return getChordDetailsForMode(
140
+ mode.intervals,
141
+ rotatedTriads,
142
+ rotatedSeventhChords,
143
+ getRomanTriads(rotatedTriads),
144
+ getRomanSeventhChords(rotatedSeventhChords),
145
+ );
146
+ }
147
+
148
+ export type AnyModeKey =
149
+ | DiatonicModeKey
150
+ | HarmonicMinorModeKey
151
+ | MelodicMinorModeKey;
152
+
153
+ export function getChordDetailsForModeKey(
154
+ modeKey: AnyModeKey,
155
+ ): ChordDetails[] {
156
+ if (Object.prototype.hasOwnProperty.call(diatonicModes, modeKey)) {
157
+ return getChordDetailsForDiatonicMode(modeKey as DiatonicModeKey);
158
+ }
159
+ if (Object.prototype.hasOwnProperty.call(harmonicMinorModes, modeKey)) {
160
+ return getChordDetailsForHarmonicMinorMode(modeKey as HarmonicMinorModeKey);
161
+ }
162
+ if (Object.prototype.hasOwnProperty.call(melodicMinorModes, modeKey)) {
163
+ return getChordDetailsForMelodicMinorMode(modeKey as MelodicMinorModeKey);
164
+ }
165
+ return [];
166
+ }
@@ -0,0 +1,168 @@
1
+ import type { Interval } from "../data/labels/note-labels.ts";
2
+ import type { MidiNoteNumber, MidiNoteSequence } from "../types/midi.d.ts";
3
+ import { rootMidiAndIntervalToMidi } from "./midi.ts";
4
+
5
+ export type MidiNoteSequenceDirection =
6
+ | "ascending"
7
+ | "descending"
8
+ | "ascending-descending"
9
+ | "descending-ascending";
10
+
11
+ // TODO: add property `restsAtEnd` to add null values to array end
12
+ export interface MidiNoteSequenceOptions {
13
+ rootNoteMidi: MidiNoteNumber;
14
+ intervals: Interval[];
15
+ direction: MidiNoteSequenceDirection;
16
+ startFromIndex?: number;
17
+ filterOutOctave?: boolean;
18
+ numNotes?: number;
19
+ numOctaves?: number;
20
+ extraNotes?: number;
21
+ }
22
+
23
+ /**
24
+ * Generates a monotonic sequence of MIDI notes.
25
+ * @param rootNoteMidi The root MIDI note number.
26
+ * @param intervals The array of intervals to generate the sequence from.
27
+ * @param numNotes The number of notes to generate.
28
+ * @param startFromIndex The starting index in the intervals array.
29
+ * @param direction The direction of the sequence ('ascending' or 'descending').
30
+ * @returns An array of MIDI note numbers.
31
+ * @throws {Error} If a MIDI note cannot be calculated for an interval.
32
+ */
33
+ function getMonotonicMidiNoteSequence(
34
+ rootNoteMidi: MidiNoteNumber,
35
+ intervals: Interval[],
36
+ numNotes: number,
37
+ startFromIndex: number,
38
+ direction: "ascending" | "descending",
39
+ ): MidiNoteSequence {
40
+ const notes: MidiNoteNumber[] = [];
41
+ const intervalsLength = intervals.length;
42
+
43
+ if (intervalsLength === 0 || numNotes <= 0) return [];
44
+
45
+ for (let i = 0; i < numNotes; i++) {
46
+ let intervalIndex: number;
47
+ let octaveOffset: number;
48
+
49
+ if (direction === "ascending") {
50
+ intervalIndex = (startFromIndex + i) % intervalsLength;
51
+ octaveOffset = Math.floor((startFromIndex + i) / intervalsLength) * 12;
52
+ const interval = intervals[intervalIndex];
53
+ const note = rootMidiAndIntervalToMidi(rootNoteMidi, interval);
54
+ if (note === undefined) {
55
+ throw new Error(
56
+ `Could not calculate MIDI note for interval ${interval} at index ${intervalIndex}`,
57
+ );
58
+ }
59
+ notes.push((note + octaveOffset) as MidiNoteNumber);
60
+ } // descending...
61
+ else {
62
+ intervalIndex =
63
+ ((startFromIndex - i) % intervalsLength + intervalsLength) %
64
+ intervalsLength;
65
+ /**
66
+ * octaveOffset increases every time we've looped through all intervals
67
+ * The Math.max(0, ...) is a piece of defensive programming. It protects the function from producing illogical results if it
68
+ * ever receives invalid input.
69
+ * @example
70
+ * intervalsLength = 7
71
+ * Imagine we accidentally pass startFromIndex = 10. This is not a valid index for the intervals array,
72
+ * but a robust function should handle it gracefully.
73
+ */
74
+ octaveOffset =
75
+ Math.max(0, Math.ceil((i - startFromIndex) / intervalsLength)) * 12;
76
+ const interval = intervals[intervalIndex];
77
+ // Subtract octaveOffset from root before applying interval
78
+ const note = rootMidiAndIntervalToMidi(
79
+ (rootNoteMidi - octaveOffset) as MidiNoteNumber,
80
+ interval,
81
+ );
82
+ if (note === undefined) {
83
+ throw new Error(
84
+ `Could not calculate MIDI note for interval ${interval} at index ${intervalIndex}`,
85
+ );
86
+ }
87
+ notes.push(note);
88
+ }
89
+ }
90
+
91
+ return notes;
92
+ }
93
+
94
+ /**
95
+ * Generates a sequence of MIDI notes based on the provided options.
96
+ * The sequence can be ascending, descending, or a combination of both.
97
+ * @param options The options for generating the MIDI note sequence.
98
+ * @returns A sequence of MIDI notes.
99
+ * @throws {Error} If a MIDI note cannot be calculated for an interval.
100
+ */
101
+ export function getMidiNoteSequence(
102
+ options: MidiNoteSequenceOptions,
103
+ ): MidiNoteSequence {
104
+ const {
105
+ rootNoteMidi,
106
+ intervals,
107
+ direction,
108
+ startFromIndex = 0,
109
+ filterOutOctave = true,
110
+ numNotes,
111
+ numOctaves = 1,
112
+ extraNotes = 0,
113
+ } = options;
114
+
115
+ const fundamentalIntervals = filterOutOctave
116
+ ? intervals.filter((i) => i !== "8" && i !== "♮8")
117
+ : intervals;
118
+
119
+ if (fundamentalIntervals.length === 0) return [];
120
+
121
+ // Calculate the total number of notes for the (first) monotonic part of the sequence.
122
+ // This is simply reversed and sliced to create the ascending-descending or descending-ascending sequences.
123
+ // numOctaves = 0: fundamentalIntervals.length
124
+ // numOctaves = 1: fundamentalIntervals.length + 1
125
+ // numOctaves = 2: 2 * fundamentalIntervals.length + 1
126
+ const monotonicNoteCount = numNotes ??
127
+ (numOctaves === 0
128
+ ? fundamentalIntervals.length
129
+ : numOctaves * fundamentalIntervals.length + 1) +
130
+ extraNotes;
131
+
132
+ if (monotonicNoteCount <= 0) return [];
133
+
134
+ let sequence: MidiNoteSequence = [];
135
+ const monotonicNotes = (dir: "ascending" | "descending") =>
136
+ getMonotonicMidiNoteSequence(
137
+ rootNoteMidi,
138
+ fundamentalIntervals,
139
+ monotonicNoteCount,
140
+ startFromIndex,
141
+ dir,
142
+ );
143
+
144
+ switch (direction) {
145
+ case "ascending":
146
+ sequence = monotonicNotes("ascending");
147
+ break;
148
+ case "descending":
149
+ sequence = monotonicNotes("descending");
150
+ break;
151
+ case "ascending-descending": {
152
+ const ascendingNotes = monotonicNotes("ascending");
153
+ // Exclude the peak note to avoid duplication when reversing
154
+ const descendingPart = [...ascendingNotes].reverse().slice(1);
155
+ sequence = [...ascendingNotes, ...descendingPart];
156
+ break;
157
+ }
158
+ case "descending-ascending": {
159
+ const descendingNotes = monotonicNotes("descending");
160
+ // Exclude the lowest note to avoid duplication when reversing
161
+ const ascendingPart = [...descendingNotes].reverse().slice(1);
162
+ sequence = [...descendingNotes, ...ascendingPart];
163
+ break;
164
+ }
165
+ }
166
+
167
+ return sequence;
168
+ }
@@ -0,0 +1,74 @@
1
+ import {
2
+ compoundToSimpleIntervalMap,
3
+ extensionToSimpleIntervalMap,
4
+ type Interval,
5
+ intervalToIntegerMap,
6
+ simpleToCompoundIntervalMap,
7
+ simpleToExtensionIntervalMap,
8
+ } from "../data/labels/note-labels.ts";
9
+
10
+ export function filterOutOctaveIntervals(
11
+ intervals: readonly Interval[],
12
+ ): Interval[] {
13
+ return intervals.filter((i) => i !== "8" && i !== "♮8");
14
+ }
15
+
16
+ export function toSortedIntervals(
17
+ intervals: readonly Interval[],
18
+ ): Interval[] {
19
+ return intervals
20
+ .toSorted((a, b) => {
21
+ const intA = intervalToIntegerMap.get(a);
22
+ const intB = intervalToIntegerMap.get(b);
23
+ if (intA === undefined || intB === undefined) return 0;
24
+ return intA - intB;
25
+ });
26
+ }
27
+
28
+ export type IntervalTransformation =
29
+ | "simpleToExtension"
30
+ | "extensionToSimple"
31
+ | "simpleToCompound"
32
+ | "compoundToSimple";
33
+
34
+ export interface TransformIntervalsOptions {
35
+ intervalTransformation?: IntervalTransformation;
36
+ filterOutOctave?: boolean;
37
+ sortIntervals?: boolean;
38
+ }
39
+
40
+ export function transformIntervals(
41
+ intervals: readonly Interval[],
42
+ options: TransformIntervalsOptions = {},
43
+ ): Interval[] {
44
+ const {
45
+ intervalTransformation,
46
+ filterOutOctave = false,
47
+ sortIntervals = true,
48
+ } = options;
49
+
50
+ const intervalMap: ReadonlyMap<Interval, Interval> = (() => {
51
+ switch (intervalTransformation) {
52
+ case "simpleToExtension":
53
+ return simpleToExtensionIntervalMap;
54
+ case "extensionToSimple":
55
+ return extensionToSimpleIntervalMap;
56
+ case "simpleToCompound":
57
+ return simpleToCompoundIntervalMap;
58
+ case "compoundToSimple":
59
+ return compoundToSimpleIntervalMap;
60
+ default:
61
+ return new Map();
62
+ }
63
+ })();
64
+
65
+ const fundamentalIntervals = filterOutOctave
66
+ ? filterOutOctaveIntervals(intervals)
67
+ : intervals;
68
+
69
+ const finalIntervals = fundamentalIntervals.map((interval) =>
70
+ intervalMap.get(interval) ?? interval
71
+ );
72
+
73
+ return sortIntervals ? toSortedIntervals(finalIntervals) : finalIntervals;
74
+ }
@@ -0,0 +1,50 @@
1
+ import {
2
+ type Interval,
3
+ intervalToIntegerMap,
4
+ type NoteInteger,
5
+ type NoteName,
6
+ noteNameToIntegerMap,
7
+ } from "../data/labels/note-labels.ts";
8
+
9
+ import type { MidiNoteNumber, OctaveNumber } from "../types/midi.d.ts";
10
+
11
+ export function rootIntegerAndIntervalToMidi(
12
+ rootNoteInteger: NoteInteger,
13
+ interval: Interval,
14
+ rootNoteOctaveNumber: OctaveNumber = 4,
15
+ ): MidiNoteNumber | undefined {
16
+ const intervalValue = intervalToIntegerMap.get(interval);
17
+ if (intervalValue === undefined) return undefined;
18
+ return (rootNoteOctaveNumber + 1) * 12 + rootNoteInteger +
19
+ intervalValue as MidiNoteNumber;
20
+ }
21
+
22
+ export function rootMidiAndIntervalToMidi(
23
+ rootNoteMidi: MidiNoteNumber,
24
+ interval: Interval,
25
+ ): MidiNoteNumber | undefined {
26
+ const intervalValue = intervalToIntegerMap.get(interval);
27
+ if (intervalValue === undefined) return undefined;
28
+ return rootNoteMidi + intervalValue as MidiNoteNumber;
29
+ }
30
+
31
+ export function noteNameToMidi(
32
+ noteName: NoteName,
33
+ octaveNumber: OctaveNumber = 4,
34
+ ): MidiNoteNumber | undefined {
35
+ const noteValue = noteNameToIntegerMap.get(noteName);
36
+ if (noteValue === undefined) return undefined;
37
+ return noteValue + (octaveNumber + 1) * 12 as MidiNoteNumber;
38
+ }
39
+
40
+ export function noteNameAndIntervalToMidi(
41
+ noteName: NoteName,
42
+ interval: Interval,
43
+ octaveNumber: OctaveNumber = 4,
44
+ ): MidiNoteNumber | undefined {
45
+ const noteValue = noteNameToIntegerMap.get(noteName);
46
+ if (noteValue === undefined) return undefined;
47
+ const intervalValue = intervalToIntegerMap.get(interval);
48
+ if (intervalValue === undefined) return undefined;
49
+ return noteValue + (octaveNumber + 1) * 12 as MidiNoteNumber;
50
+ }
@@ -0,0 +1,5 @@
1
+ export * from "./get-midi-note-sequences.ts";
2
+ export * from "./get-chords.ts";
3
+ export * from "./note-names.ts";
4
+ export * from "./rotate-array.ts";
5
+ export * from "./note-collections.ts";
@@ -0,0 +1,170 @@
1
+ import {
2
+ type NoteCollectionKey,
3
+ noteCollections,
4
+ } from "../data/note-collections/mod.ts";
5
+ import type { Interval } from "../data/labels/note-labels.ts";
6
+ import type { NoteCollection } from "../types/note-collections.d.ts";
7
+
8
+ /**
9
+ * Checks if a given string is a valid `NoteCollectionKey`.
10
+ * @param key The string to check.
11
+ * @returns `true` if the key is a valid `NoteCollectionKey`, `false` otherwise.
12
+ */
13
+ export function isValidNoteCollectionKey(
14
+ key: string,
15
+ ): key is NoteCollectionKey {
16
+ return Object.prototype.hasOwnProperty.call(noteCollections, key);
17
+ }
18
+
19
+ const normalizationMap = new Map<string, string>();
20
+
21
+ const aliasSets: Record<string, string[]> = {
22
+ "♭": ["b", "flat"],
23
+ "♯": ["#", "sharp"],
24
+ "♮": ["n", "natural"],
25
+ "𝄫": ["bb", "doubleflat"],
26
+ "𝄪": ["##", "doublesharp"],
27
+
28
+ "M": ["maj", "major"],
29
+ "m": ["min", "minor"],
30
+ "°": ["dim", "diminished"],
31
+ "+": ["aug", "augmented"],
32
+ "ø": ["halfdiminished"],
33
+
34
+ "7": ["seventh"],
35
+ "dominant": ["dom"],
36
+ };
37
+
38
+ for (const [canonical, aliases] of Object.entries(aliasSets)) {
39
+ for (const alias of aliases) {
40
+ normalizationMap.set(alias, canonical);
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Normalizes a search string by converting aliases to their canonical form,
46
+ * removing non-essential characters, and collapsing whitespace.
47
+ * @param str The string to normalize.
48
+ * @returns A normalized string for searching.
49
+ */
50
+ function normalizeSearchTerm(str: string): string {
51
+ // Start with trimming, but preserve original case for now.
52
+ let normalized = str.trim();
53
+
54
+ // Iteratively replace all aliases, using a case-insensitive regex.
55
+ // This is crucial because we can't simply lowercase the entire string,
56
+ // as that would merge "M" (major) and "m" (minor) into the same character.
57
+ for (const [alias, canonical] of normalizationMap.entries()) {
58
+ // This regex looks for an alias that is either a whole word (\b)
59
+ // or is immediately followed by a digit (for cases like "b2", "maj7").
60
+ // The "i" flag makes the search case-insensitive.
61
+ const regex = new RegExp(`\\b${alias}(?=[0-9]|\\b)`, "gi");
62
+ normalized = normalized.replace(regex, canonical);
63
+ }
64
+
65
+ // Final cleanup: remove non-essential characters and collapse whitespace.
66
+ return normalized.replace(/[-()]/g, "").replace(/\s+/g, " ").trim();
67
+ }
68
+
69
+ export interface SearchOptions {
70
+ query?: string;
71
+ intervals?: Interval[];
72
+ type?: string;
73
+ }
74
+
75
+ export function searchNoteCollections(
76
+ options: SearchOptions,
77
+ ): NoteCollection[] {
78
+ const { query, intervals, type } = options;
79
+ let candidates = Object.values(noteCollections);
80
+
81
+ // 1. Apply hard filters first to narrow down the candidate pool
82
+ if (type) {
83
+ const normalizedType = normalizeSearchTerm(type);
84
+ const searchWords = normalizedType.split(" ");
85
+
86
+ candidates = candidates.filter((theme) => {
87
+ const themeTypesString = theme.type.map(normalizeSearchTerm).join(" ");
88
+ return searchWords.every((word) => {
89
+ // Use case-sensitive search for 'M' and 'm'
90
+ const isCaseSensitiveWord = word === "M" || word === "m";
91
+ const regex = new RegExp(
92
+ `\\b${word}\\b`,
93
+ isCaseSensitiveWord ? "" : "i",
94
+ );
95
+ return regex.test(themeTypesString);
96
+ });
97
+ });
98
+ }
99
+
100
+ if (intervals && intervals.length > 0) {
101
+ candidates = candidates.filter((theme) =>
102
+ intervals.every((interval) => theme.intervals.includes(interval))
103
+ );
104
+ }
105
+
106
+ // If there's no text query, the filtered list is the final result.
107
+ if (!query) {
108
+ return candidates;
109
+ }
110
+
111
+ // 2. Apply prioritized text search on the filtered candidates
112
+ const prioritizedResults = new Set<NoteCollection>();
113
+ const normalizedQuery = normalizeSearchTerm(query);
114
+
115
+ if (!normalizedQuery) {
116
+ return candidates;
117
+ }
118
+
119
+ // Use case-sensitive search for "M" and "m" to avoid incorrect matches.
120
+ const isCaseSensitiveQuery = normalizedQuery === "M" ||
121
+ normalizedQuery === "m";
122
+ const regexFlags = isCaseSensitiveQuery ? "" : "i";
123
+ const searchRegex = new RegExp(`\\b${normalizedQuery}\\b`, regexFlags);
124
+
125
+ const passes = [
126
+ // Pass 1: Exact match on primaryName
127
+ (theme: NoteCollection) =>
128
+ normalizeSearchTerm(theme.primaryName) === normalizedQuery,
129
+ // Pass 2: Exact match on any name
130
+ (theme: NoteCollection) =>
131
+ theme.names.some((name) => normalizeSearchTerm(name) === normalizedQuery),
132
+ // Pass 3: Whole word match on primaryName
133
+ (theme: NoteCollection) =>
134
+ searchRegex.test(normalizeSearchTerm(theme.primaryName)),
135
+ // Pass 4: Whole word match on any name
136
+ (theme: NoteCollection) =>
137
+ theme.names.some((name) => searchRegex.test(normalizeSearchTerm(name))),
138
+ // Pass 7: Whole word match on any type
139
+ (theme: NoteCollection) =>
140
+ theme.type.some((t) => searchRegex.test(normalizeSearchTerm(t))),
141
+ // Pass 8: Whole word match on any characteristic
142
+ (theme: NoteCollection) =>
143
+ theme.characteristics.some((c) =>
144
+ searchRegex.test(normalizeSearchTerm(c))
145
+ ),
146
+ ];
147
+
148
+ for (const pass of passes) {
149
+ for (const theme of candidates) {
150
+ if (pass(theme)) {
151
+ prioritizedResults.add(theme);
152
+ }
153
+ }
154
+ }
155
+
156
+ return Array.from(prioritizedResults);
157
+ }
158
+
159
+ /**
160
+ * Finds the single best matching `NoteCollection` based on search options.
161
+ * This is a convenience wrapper around `searchNoteCollections` that returns only the first result.
162
+ * The search is prioritized to return the most relevant match first.
163
+ * @param options The search options.
164
+ * @returns The best matching `NoteCollection` or `undefined` if no match is found.
165
+ */
166
+ export function findNoteCollection(
167
+ options: SearchOptions,
168
+ ): NoteCollection | undefined {
169
+ return searchNoteCollections(options)[0];
170
+ }