refactor(search): local search ignores domain filter
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 3m11s

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) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-21 21:59:48 +02:00
parent 0373dc32da
commit d9490c8073
7 changed files with 25 additions and 40 deletions

View File

@@ -17,7 +17,7 @@ export type SearchStoreOptions = {
debounceMs?: number; debounceMs?: number;
filterDebounceMs?: number; filterDebounceMs?: number;
minQueryLength?: number; minQueryLength?: number;
filterParam?: () => string; webFilterParam?: () => string;
fetchImpl?: typeof fetch; fetchImpl?: typeof fetch;
}; };
@@ -38,7 +38,7 @@ export class SearchStore {
private readonly debounceMs: number; private readonly debounceMs: number;
private readonly filterDebounceMs: number; private readonly filterDebounceMs: number;
private readonly minQueryLength: number; private readonly minQueryLength: number;
private readonly filterParam: () => string; private readonly webFilterParam: () => string;
private readonly fetchImpl: typeof fetch; private readonly fetchImpl: typeof fetch;
private debounceTimer: ReturnType<typeof setTimeout> | null = null; private debounceTimer: ReturnType<typeof setTimeout> | null = null;
private skipNextDebounce = false; private skipNextDebounce = false;
@@ -48,7 +48,7 @@ export class SearchStore {
this.debounceMs = opts.debounceMs ?? 300; this.debounceMs = opts.debounceMs ?? 300;
this.filterDebounceMs = opts.filterDebounceMs ?? 150; this.filterDebounceMs = opts.filterDebounceMs ?? 150;
this.minQueryLength = opts.minQueryLength ?? 4; this.minQueryLength = opts.minQueryLength ?? 4;
this.filterParam = opts.filterParam ?? (() => ''); this.webFilterParam = opts.webFilterParam ?? (() => '');
this.fetchImpl = opts.fetchImpl ?? ((...a) => fetch(...a)); this.fetchImpl = opts.fetchImpl ?? ((...a) => fetch(...a));
} }
@@ -80,7 +80,7 @@ export class SearchStore {
this.webExhausted = false; this.webExhausted = false;
try { try {
const res = await this.fetchImpl( 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[] }; const body = (await res.json()) as { hits: SearchHit[] };
if (this.query.trim() !== q) return; if (this.query.trim() !== q) return;
@@ -99,7 +99,7 @@ export class SearchStore {
this.webSearching = true; this.webSearching = true;
try { try {
const res = await this.fetchImpl( 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 (this.query.trim() !== q) return;
if (!res.ok) { if (!res.ok) {
@@ -125,7 +125,7 @@ export class SearchStore {
try { try {
if (!this.localExhausted) { if (!this.localExhausted) {
const res = await this.fetchImpl( 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[] }; const body = (await res.json()) as { hits: SearchHit[] };
if (this.query.trim() !== q) return; if (this.query.trim() !== q) return;
@@ -140,7 +140,7 @@ export class SearchStore {
if (wasEmpty) this.webSearching = true; if (wasEmpty) this.webSearching = true;
try { try {
const res = await this.fetchImpl( 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 (this.query.trim() !== q) return;
if (!res.ok) { if (!res.ok) {

View File

@@ -30,15 +30,12 @@ export function searchLocal(
db: Database.Database, db: Database.Database,
query: string, query: string,
limit = 30, limit = 30,
offset = 0, offset = 0
domains: string[] = []
): SearchHit[] { ): SearchHit[] {
const fts = buildFtsQuery(query); const fts = buildFtsQuery(query);
if (!fts) return []; if (!fts) return [];
// bm25: lower is better. Use weights: title > tags > ingredients > description // 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, const sql = `SELECT r.id,
r.title, r.title,
r.description, r.description,
@@ -49,13 +46,9 @@ export function searchLocal(
FROM recipe r FROM recipe r
JOIN recipe_fts f ON f.rowid = r.id JOIN recipe_fts f ON f.rowid = r.id
WHERE recipe_fts MATCH ? WHERE recipe_fts MATCH ?
${hasFilter ? `AND r.source_domain IN (${placeholders})` : ''}
ORDER BY bm25(recipe_fts, 10.0, 0.5, 2.0, 5.0) ORDER BY bm25(recipe_fts, 10.0, 0.5, 2.0, 5.0)
LIMIT ? OFFSET ?`; LIMIT ? OFFSET ?`;
const params = hasFilter return db.prepare(sql).all(fts, limit, offset) as SearchHit[];
? [fts, ...domains, limit, offset]
: [fts, limit, offset];
return db.prepare(sql).all(...params) as SearchHit[];
} }
export function listRecentRecipes( export function listRecentRecipes(

View File

@@ -31,7 +31,7 @@
const navStore = new SearchStore({ const navStore = new SearchStore({
pageSize: 30, pageSize: 30,
filterParam: () => { webFilterParam: () => {
const p = searchFilterStore.queryParam; const p = searchFilterStore.queryParam;
return p ? `&domains=${encodeURIComponent(p)}` : ''; return p ? `&domains=${encodeURIComponent(p)}` : '';
} }

View File

@@ -16,7 +16,7 @@
const store = new SearchStore({ const store = new SearchStore({
pageSize: LOCAL_PAGE, pageSize: LOCAL_PAGE,
filterParam: () => { webFilterParam: () => {
const p = searchFilterStore.queryParam; const p = searchFilterStore.queryParam;
return p ? `&domains=${encodeURIComponent(p)}` : ''; return p ? `&domains=${encodeURIComponent(p)}` : '';
} }

View File

@@ -7,13 +7,9 @@ export const GET: RequestHandler = async ({ url }) => {
const q = url.searchParams.get('q')?.trim() ?? ''; const q = url.searchParams.get('q')?.trim() ?? '';
const limit = Math.min(Number(url.searchParams.get('limit') ?? 30), 100); const limit = Math.min(Number(url.searchParams.get('limit') ?? 30), 100);
const offset = Math.max(0, Number(url.searchParams.get('offset') ?? 0)); 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 = const hits =
q.length >= 1 q.length >= 1
? searchLocal(getDb(), q, limit, offset, domains) ? searchLocal(getDb(), q, limit, offset)
: offset === 0 : offset === 0
? listRecentRecipes(getDb(), limit) ? listRecentRecipes(getDb(), limit)
: []; : [];

View File

@@ -69,20 +69,11 @@ describe('searchLocal', () => {
expect(searchLocal(db, ' ')).toEqual([]); expect(searchLocal(db, ' ')).toEqual([]);
}); });
it('filters by domain when supplied', () => { it('ignores source_domain — local search is domain-agnostic', () => {
const db = openInMemoryForTest(); const db = openInMemoryForTest();
insertRecipe(db, recipe({ title: 'Apfelstrudel', source_domain: 'chefkoch.de' })); insertRecipe(db, recipe({ title: 'Apfelstrudel', source_domain: 'chefkoch.de' }));
insertRecipe(db, recipe({ title: 'Apfeltraum', source_domain: 'rezeptwelt.de' })); insertRecipe(db, recipe({ title: 'Apfeltraum', source_domain: 'rezeptwelt.de' }));
const hits = searchLocal(db, 'apfel', 10, 0, ['chefkoch.de']); const hits = searchLocal(db, 'apfel');
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, []);
expect(hits.length).toBe(2); expect(hits.length).toBe(2);
}); });

View File

@@ -202,7 +202,7 @@ describe('SearchStore', () => {
expect(round).toEqual(snap); 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(); vi.useFakeTimers();
const fetchImpl = mockFetch([ const fetchImpl = mockFetch([
{ body: { hits: [] } }, { body: { hits: [] } },
@@ -211,13 +211,15 @@ describe('SearchStore', () => {
const store = new SearchStore({ const store = new SearchStore({
fetchImpl, fetchImpl,
debounceMs: 10, debounceMs: 10,
filterParam: () => '&domains=chefkoch.de' webFilterParam: () => '&domains=chefkoch.de'
}); });
store.query = 'curry'; store.query = 'curry';
store.runDebounced(); store.runDebounced();
await vi.advanceTimersByTimeAsync(15); await vi.advanceTimersByTimeAsync(15);
await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(2)); 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/); expect(fetchImpl.mock.calls[1][0]).toMatch(/&domains=chefkoch\.de/);
}); });
@@ -243,22 +245,25 @@ describe('SearchStore', () => {
const fetchImpl = mockFetch([ const fetchImpl = mockFetch([
{ body: { hits: [] } }, { body: { hits: [] } },
{ 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({ const store = new SearchStore({
fetchImpl, fetchImpl,
debounceMs: 10, debounceMs: 10,
filterDebounceMs: 5, filterDebounceMs: 5,
filterParam: () => filter webFilterParam: () => filter
}); });
store.query = 'broth'; store.query = 'broth';
store.runDebounced(); store.runDebounced();
await vi.advanceTimersByTimeAsync(15); await vi.advanceTimersByTimeAsync(15);
await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(2));
filter = '&domains=chefkoch.de'; filter = '&domains=chefkoch.de';
store.reSearch(); store.reSearch();
await vi.advanceTimersByTimeAsync(10); 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; const last = fetchImpl.mock.calls.at(-1)?.[0] as string;
expect(last).toMatch(/\/api\/recipes\/search\/web\?/);
expect(last).toMatch(/&domains=chefkoch\.de/); expect(last).toMatch(/&domains=chefkoch\.de/);
}); });
}); });