Fix search: allow spaces and match multi-word queries as separate words
Space was hard-coded as the global play/pause toggle, so it never reached the search-typing logic - typing "geolino azte" would toggle playback instead of adding the space. Now a space is appended to an in-progress search instead. Search also required the whole query to be one contiguous substring of title + artist, which misses queries like "conni rad" for "Conni lernt Rad fahren" or "geolino azte" for a podcast episode titled "Azteken" by "GEOlino Spezial" (title comes before artist). Each word is now matched independently, in any order. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -76,6 +76,12 @@ describe("search", () => {
|
|||||||
it("matches title and artist together", () => {
|
it("matches title and artist together", () => {
|
||||||
expect(resultsFor({ ...initialUiState, search: "rolf" }).albums.map((a) => a.id)).toEqual(["c"]);
|
expect(resultsFor({ ...initialUiState, search: "rolf" }).albums.map((a) => a.id)).toEqual(["c"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("matches multi-word queries as separate words, not one contiguous phrase", () => {
|
||||||
|
// "conni horbuch" is the reverse of "Hörbuch Conni" - a contiguous-substring search
|
||||||
|
// would miss it, but each word does show up somewhere in title + artist.
|
||||||
|
expect(resultsFor({ ...initialUiState, search: "conni horbuch" }).albums.map((a) => a.id)).toEqual(["b"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("keyboard", () => {
|
describe("keyboard", () => {
|
||||||
@@ -257,6 +263,17 @@ describe("keyboard", () => {
|
|||||||
expect(press(" ", inRoom)).toEqual([{ type: "pop", freq: 340 }, { type: "toggle" }]);
|
expect(press(" ", inRoom)).toEqual([{ type: "pop", freq: 340 }, { type: "toggle" }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("types a space into an in-progress search instead of toggling playback", () => {
|
||||||
|
const searching: UiState = { ...initialUiState, search: "geolino" };
|
||||||
|
expect(press(" ", searching)).toEqual([
|
||||||
|
{ type: "ui", patch: { search: "geolino ", selIndex: 0 } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still toggles playback on space when there is no search yet", () => {
|
||||||
|
expect(press(" ")).toEqual([{ type: "pop", freq: 340 }, { type: "toggle" }]);
|
||||||
|
});
|
||||||
|
|
||||||
it("does not swallow backspace when there is nothing to delete", () => {
|
it("does not swallow backspace when there is nothing to delete", () => {
|
||||||
expect(press("Backspace")).toEqual([]);
|
expect(press("Backspace")).toEqual([]);
|
||||||
expect(press("Backspace", { ...initialUiState, search: "ab" })).toEqual([
|
expect(press("Backspace", { ...initialUiState, search: "ab" })).toEqual([
|
||||||
|
|||||||
@@ -208,8 +208,12 @@ export function handleKey(
|
|||||||
if (media) return media;
|
if (media) return media;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Space is the one other transport key reachable from anywhere, same as Shift+media.
|
// Space is the one other transport key reachable from anywhere, same as Shift+media -
|
||||||
|
// except mid-search, where a space is punctuation the query needs (e.g. "geolino azte").
|
||||||
if (key === " ") {
|
if (key === " ") {
|
||||||
|
if (state.page === "music" && state.view === "browse" && state.openAlbumId === null && !state.showHelp && state.search) {
|
||||||
|
return [{ type: "ui", patch: { search: state.search + " ", selIndex: 0 } }];
|
||||||
|
}
|
||||||
return [{ type: "pop", freq: 340 }, { type: "toggle" }];
|
return [{ type: "pop", freq: 340 }, { type: "toggle" }];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,22 @@ export function normalize(value: string): string {
|
|||||||
.replace(/[^a-z0-9]/g, "");
|
.replace(/[^a-z0-9]/g, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Words of a query, normalized individually. A space-separated query like "conni rad"
|
||||||
|
* should find "Conni lernt Rad fahren" even though "rad" isn't right after "conni" -
|
||||||
|
* requiring the whole phrase to be one contiguous substring is too strict for that. */
|
||||||
|
function searchWords(value: string): string[] {
|
||||||
|
return (value ?? "")
|
||||||
|
.trim()
|
||||||
|
.split(/\s+/)
|
||||||
|
.map(normalize)
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every word has to show up somewhere in the haystack, in any order. */
|
||||||
|
function matchesWords(haystack: string, words: string[]): boolean {
|
||||||
|
return words.every((word) => haystack.includes(word));
|
||||||
|
}
|
||||||
|
|
||||||
/** Which of the three shelves an album belongs on. A podcast is exactly a
|
/** Which of the three shelves an album belongs on. A podcast is exactly a
|
||||||
* `Kinderpodcasts`-section album; everything else buckets by `kind`, so a Figuren
|
* `Kinderpodcasts`-section album; everything else buckets by `kind`, so a Figuren
|
||||||
* album joins whichever of music/audiobooks matches what it actually holds. */
|
* album joins whichever of music/audiobooks matches what it actually holds. */
|
||||||
@@ -81,12 +97,12 @@ export function categoryMatches(query: BrowseQuery): Category[] {
|
|||||||
|
|
||||||
export function albumMatches(query: BrowseQuery): Album[] {
|
export function albumMatches(query: BrowseQuery): Album[] {
|
||||||
if (query.mode === "tracks") return [];
|
if (query.mode === "tracks") return [];
|
||||||
const needle = normalize(query.search);
|
const words = searchWords(query.search);
|
||||||
let candidates = pool(query.albums, query.group);
|
let candidates = pool(query.albums, query.group);
|
||||||
if (!needle && !query.category) return [];
|
if (!words.length && !query.category) return [];
|
||||||
if (query.category) candidates = candidates.filter((a) => a.category === query.category);
|
if (query.category) candidates = candidates.filter((a) => a.category === query.category);
|
||||||
if (!needle) return candidates;
|
if (!words.length) return candidates;
|
||||||
return candidates.filter((a) => normalize(a.title + a.artist).includes(needle));
|
return candidates.filter((a) => matchesWords(normalize(a.title + a.artist), words));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Capped, because an empty query over 900 podcast episodes is not a useful screen. */
|
/** Capped, because an empty query over 900 podcast episodes is not a useful screen. */
|
||||||
@@ -94,11 +110,11 @@ export const MAX_SONG_HITS = 40;
|
|||||||
|
|
||||||
export function songMatches(query: BrowseQuery): SongHit[] {
|
export function songMatches(query: BrowseQuery): SongHit[] {
|
||||||
if (query.mode !== "tracks") return [];
|
if (query.mode !== "tracks") return [];
|
||||||
const needle = normalize(query.search);
|
const words = searchWords(query.search);
|
||||||
const hits: SongHit[] = [];
|
const hits: SongHit[] = [];
|
||||||
for (const album of pool(query.albums, query.group)) {
|
for (const album of pool(query.albums, query.group)) {
|
||||||
album.tracks.forEach((track, index) => {
|
album.tracks.forEach((track, index) => {
|
||||||
if (!needle || normalize(track.title).includes(needle)) {
|
if (!words.length || matchesWords(normalize(track.title), words)) {
|
||||||
hits.push({ album, index, title: track.title, duration: track.duration });
|
hits.push({ album, index, title: track.title, duration: track.duration });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user