From d9490c8073664ca1c9c31bab3c22ff4a35bcb00c Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Tue, 21 Apr 2026 21:59:48 +0200 Subject: [PATCH] refactor(search): local search ignores domain filter Der Domain-Filter im Header-Dropdown wirkt ab jetzt ausschliesslich auf die Web-Suche (SearXNG). Die Suche in gespeicherten Rezepten liefert immer alle Treffer, unabhaengig von der Quelldomain -- wer ein Rezept gespeichert hat, will es finden, selbst wenn er die Domain aus dem Filter ausgeschlossen hat. - SearchStore: filterParam -> webFilterParam, nur noch an Web-Calls - /api/recipes/search: domains-Query-Param wird nicht mehr gelesen - searchLocal(): domains-Parameter + SQL-Branch entfernt - Tests entsprechend angepasst Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/client/search.svelte.ts | 14 +++++++------- src/lib/server/recipes/search-local.ts | 11 ++--------- src/routes/+layout.svelte | 2 +- src/routes/+page.svelte | 2 +- src/routes/api/recipes/search/+server.ts | 6 +----- tests/integration/search-local.test.ts | 13 ++----------- tests/unit/search-store.test.ts | 17 +++++++++++------ 7 files changed, 25 insertions(+), 40 deletions(-) diff --git a/src/lib/client/search.svelte.ts b/src/lib/client/search.svelte.ts index 3b4ceaf..dec200d 100644 --- a/src/lib/client/search.svelte.ts +++ b/src/lib/client/search.svelte.ts @@ -17,7 +17,7 @@ export type SearchStoreOptions = { debounceMs?: number; filterDebounceMs?: number; minQueryLength?: number; - filterParam?: () => string; + webFilterParam?: () => string; fetchImpl?: typeof fetch; }; @@ -38,7 +38,7 @@ export class SearchStore { private readonly debounceMs: number; private readonly filterDebounceMs: number; private readonly minQueryLength: number; - private readonly filterParam: () => string; + private readonly webFilterParam: () => string; private readonly fetchImpl: typeof fetch; private debounceTimer: ReturnType | null = null; private skipNextDebounce = false; @@ -48,7 +48,7 @@ export class SearchStore { this.debounceMs = opts.debounceMs ?? 300; this.filterDebounceMs = opts.filterDebounceMs ?? 150; this.minQueryLength = opts.minQueryLength ?? 4; - this.filterParam = opts.filterParam ?? (() => ''); + this.webFilterParam = opts.webFilterParam ?? (() => ''); this.fetchImpl = opts.fetchImpl ?? ((...a) => fetch(...a)); } @@ -80,7 +80,7 @@ export class SearchStore { this.webExhausted = false; try { const res = await this.fetchImpl( - `/api/recipes/search?q=${encodeURIComponent(q)}&limit=${this.pageSize}${this.filterParam()}` + `/api/recipes/search?q=${encodeURIComponent(q)}&limit=${this.pageSize}` ); const body = (await res.json()) as { hits: SearchHit[] }; if (this.query.trim() !== q) return; @@ -99,7 +99,7 @@ export class SearchStore { this.webSearching = true; try { const res = await this.fetchImpl( - `/api/recipes/search/web?q=${encodeURIComponent(q)}&pageno=${pageno}${this.filterParam()}` + `/api/recipes/search/web?q=${encodeURIComponent(q)}&pageno=${pageno}${this.webFilterParam()}` ); if (this.query.trim() !== q) return; if (!res.ok) { @@ -125,7 +125,7 @@ export class SearchStore { try { if (!this.localExhausted) { const res = await this.fetchImpl( - `/api/recipes/search?q=${encodeURIComponent(q)}&limit=${this.pageSize}&offset=${this.hits.length}${this.filterParam()}` + `/api/recipes/search?q=${encodeURIComponent(q)}&limit=${this.pageSize}&offset=${this.hits.length}` ); const body = (await res.json()) as { hits: SearchHit[] }; if (this.query.trim() !== q) return; @@ -140,7 +140,7 @@ export class SearchStore { if (wasEmpty) this.webSearching = true; try { const res = await this.fetchImpl( - `/api/recipes/search/web?q=${encodeURIComponent(q)}&pageno=${nextPage}${this.filterParam()}` + `/api/recipes/search/web?q=${encodeURIComponent(q)}&pageno=${nextPage}${this.webFilterParam()}` ); if (this.query.trim() !== q) return; if (!res.ok) { diff --git a/src/lib/server/recipes/search-local.ts b/src/lib/server/recipes/search-local.ts index bfe0f7f..6e8e832 100644 --- a/src/lib/server/recipes/search-local.ts +++ b/src/lib/server/recipes/search-local.ts @@ -30,15 +30,12 @@ export function searchLocal( db: Database.Database, query: string, limit = 30, - offset = 0, - domains: string[] = [] + offset = 0 ): SearchHit[] { const fts = buildFtsQuery(query); if (!fts) return []; // bm25: lower is better. Use weights: title > tags > ingredients > description - const hasFilter = domains.length > 0; - const placeholders = hasFilter ? domains.map(() => '?').join(',') : ''; const sql = `SELECT r.id, r.title, r.description, @@ -49,13 +46,9 @@ export function searchLocal( FROM recipe r JOIN recipe_fts f ON f.rowid = r.id WHERE recipe_fts MATCH ? - ${hasFilter ? `AND r.source_domain IN (${placeholders})` : ''} ORDER BY bm25(recipe_fts, 10.0, 0.5, 2.0, 5.0) LIMIT ? OFFSET ?`; - const params = hasFilter - ? [fts, ...domains, limit, offset] - : [fts, limit, offset]; - return db.prepare(sql).all(...params) as SearchHit[]; + return db.prepare(sql).all(fts, limit, offset) as SearchHit[]; } export function listRecentRecipes( diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 835d269..c4a08a3 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -31,7 +31,7 @@ const navStore = new SearchStore({ pageSize: 30, - filterParam: () => { + webFilterParam: () => { const p = searchFilterStore.queryParam; return p ? `&domains=${encodeURIComponent(p)}` : ''; } diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 73a0003..3aedf90 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -16,7 +16,7 @@ const store = new SearchStore({ pageSize: LOCAL_PAGE, - filterParam: () => { + webFilterParam: () => { const p = searchFilterStore.queryParam; return p ? `&domains=${encodeURIComponent(p)}` : ''; } diff --git a/src/routes/api/recipes/search/+server.ts b/src/routes/api/recipes/search/+server.ts index f290aeb..ffdea32 100644 --- a/src/routes/api/recipes/search/+server.ts +++ b/src/routes/api/recipes/search/+server.ts @@ -7,13 +7,9 @@ export const GET: RequestHandler = async ({ url }) => { const q = url.searchParams.get('q')?.trim() ?? ''; const limit = Math.min(Number(url.searchParams.get('limit') ?? 30), 100); const offset = Math.max(0, Number(url.searchParams.get('offset') ?? 0)); - const domains = (url.searchParams.get('domains') ?? '') - .split(',') - .map((d) => d.trim()) - .filter(Boolean); const hits = q.length >= 1 - ? searchLocal(getDb(), q, limit, offset, domains) + ? searchLocal(getDb(), q, limit, offset) : offset === 0 ? listRecentRecipes(getDb(), limit) : []; diff --git a/tests/integration/search-local.test.ts b/tests/integration/search-local.test.ts index 81b0299..74e37f4 100644 --- a/tests/integration/search-local.test.ts +++ b/tests/integration/search-local.test.ts @@ -69,20 +69,11 @@ describe('searchLocal', () => { expect(searchLocal(db, ' ')).toEqual([]); }); - it('filters by domain when supplied', () => { + it('ignores source_domain — local search is domain-agnostic', () => { const db = openInMemoryForTest(); insertRecipe(db, recipe({ title: 'Apfelstrudel', source_domain: 'chefkoch.de' })); insertRecipe(db, recipe({ title: 'Apfeltraum', source_domain: 'rezeptwelt.de' })); - const hits = searchLocal(db, 'apfel', 10, 0, ['chefkoch.de']); - expect(hits.length).toBe(1); - expect(hits[0].source_domain).toBe('chefkoch.de'); - }); - - it('no domain filter when array is empty', () => { - const db = openInMemoryForTest(); - insertRecipe(db, recipe({ title: 'Apfelstrudel', source_domain: 'chefkoch.de' })); - insertRecipe(db, recipe({ title: 'Apfeltraum', source_domain: 'rezeptwelt.de' })); - const hits = searchLocal(db, 'apfel', 10, 0, []); + const hits = searchLocal(db, 'apfel'); expect(hits.length).toBe(2); }); diff --git a/tests/unit/search-store.test.ts b/tests/unit/search-store.test.ts index c73c5bd..bedcb3a 100644 --- a/tests/unit/search-store.test.ts +++ b/tests/unit/search-store.test.ts @@ -202,7 +202,7 @@ describe('SearchStore', () => { expect(round).toEqual(snap); }); - it('filterParam option: gets appended to both local and web requests', async () => { + it('webFilterParam option: only appended to web requests, never to local', async () => { vi.useFakeTimers(); const fetchImpl = mockFetch([ { body: { hits: [] } }, @@ -211,13 +211,15 @@ describe('SearchStore', () => { const store = new SearchStore({ fetchImpl, debounceMs: 10, - filterParam: () => '&domains=chefkoch.de' + webFilterParam: () => '&domains=chefkoch.de' }); store.query = 'curry'; store.runDebounced(); await vi.advanceTimersByTimeAsync(15); await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(2)); - expect(fetchImpl.mock.calls[0][0]).toMatch(/&domains=chefkoch\.de/); + expect(fetchImpl.mock.calls[0][0]).not.toMatch(/domains=/); + expect(fetchImpl.mock.calls[0][0]).toMatch(/\/api\/recipes\/search\?/); + expect(fetchImpl.mock.calls[1][0]).toMatch(/\/api\/recipes\/search\/web\?/); expect(fetchImpl.mock.calls[1][0]).toMatch(/&domains=chefkoch\.de/); }); @@ -243,22 +245,25 @@ describe('SearchStore', () => { const fetchImpl = mockFetch([ { body: { hits: [] } }, { body: { hits: [] } }, - { body: { hits: [{ id: 1, title: 'filtered', description: null, image_path: null, source_domain: null, avg_stars: null, last_cooked_at: null }] } } + { body: { hits: [] } }, + { body: { hits: [{ url: 'https://chefkoch.de/x', title: 'filtered', domain: 'chefkoch.de', snippet: null, thumbnail: null }] } } ]); const store = new SearchStore({ fetchImpl, debounceMs: 10, filterDebounceMs: 5, - filterParam: () => filter + webFilterParam: () => filter }); store.query = 'broth'; store.runDebounced(); await vi.advanceTimersByTimeAsync(15); + await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(2)); filter = '&domains=chefkoch.de'; store.reSearch(); await vi.advanceTimersByTimeAsync(10); - await vi.waitFor(() => expect(store.hits).toHaveLength(1)); + await vi.waitFor(() => expect(store.webHits).toHaveLength(1)); const last = fetchImpl.mock.calls.at(-1)?.[0] as string; + expect(last).toMatch(/\/api\/recipes\/search\/web\?/); expect(last).toMatch(/&domains=chefkoch\.de/); }); });