From 829ea89386b98b230a2c6a470438f49dadb63780 Mon Sep 17 00:00:00 2001 From: Martin Bauer Date: Fri, 11 Sep 2026 12:11:36 +0200 Subject: [PATCH] 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 --- web/src/lib/__tests__/keyboard.test.ts | 17 ++++++++++++++++ web/src/lib/keyboard.ts | 6 +++++- web/src/lib/search.ts | 28 ++++++++++++++++++++------ 3 files changed, 44 insertions(+), 7 deletions(-) diff --git a/web/src/lib/__tests__/keyboard.test.ts b/web/src/lib/__tests__/keyboard.test.ts index 9e8066c..6cd444f 100644 --- a/web/src/lib/__tests__/keyboard.test.ts +++ b/web/src/lib/__tests__/keyboard.test.ts @@ -76,6 +76,12 @@ describe("search", () => { it("matches title and artist together", () => { 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", () => { @@ -257,6 +263,17 @@ describe("keyboard", () => { 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", () => { expect(press("Backspace")).toEqual([]); expect(press("Backspace", { ...initialUiState, search: "ab" })).toEqual([ diff --git a/web/src/lib/keyboard.ts b/web/src/lib/keyboard.ts index 5c6fa28..170c7bc 100644 --- a/web/src/lib/keyboard.ts +++ b/web/src/lib/keyboard.ts @@ -208,8 +208,12 @@ export function handleKey( 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 (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" }]; } diff --git a/web/src/lib/search.ts b/web/src/lib/search.ts index 16e0a72..37a6aa3 100644 --- a/web/src/lib/search.ts +++ b/web/src/lib/search.ts @@ -30,6 +30,22 @@ export function normalize(value: string): string { .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 * `Kinderpodcasts`-section album; everything else buckets by `kind`, so a Figuren * 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[] { if (query.mode === "tracks") return []; - const needle = normalize(query.search); + const words = searchWords(query.search); 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 (!needle) return candidates; - return candidates.filter((a) => normalize(a.title + a.artist).includes(needle)); + if (!words.length) return candidates; + 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. */ @@ -94,11 +110,11 @@ export const MAX_SONG_HITS = 40; export function songMatches(query: BrowseQuery): SongHit[] { if (query.mode !== "tracks") return []; - const needle = normalize(query.search); + const words = searchWords(query.search); const hits: SongHit[] = []; for (const album of pool(query.albums, query.group)) { 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 }); } });