diff --git a/src/lib/client/search-filter.svelte.ts b/src/lib/client/search-filter.svelte.ts new file mode 100644 index 0000000..90e680b --- /dev/null +++ b/src/lib/client/search-filter.svelte.ts @@ -0,0 +1,83 @@ +import type { AllowedDomain } from '$lib/types'; + +const STORAGE_KEY = 'kochwas.filter.domains'; + +// Leere Menge = kein Filter aktiv (alle Domains werden gesucht). Damit fügt sich +// eine neu vom Admin freigeschaltete Domain automatisch ein, ohne dass der User +// sie extra aktivieren muss. Wenn der User aktiv auswählt, speichern wir die +// Auswahl als explizite Menge — und genau die wird dann gesucht. +class SearchFilterStore { + domains = $state([]); + active = $state>(new Set()); + loaded = $state(false); + + async load(): Promise { + if (typeof window !== 'undefined') { + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (raw) { + const arr = JSON.parse(raw) as string[]; + if (Array.isArray(arr)) this.active = new Set(arr); + } + } catch { + // ignore corrupted state + } + } + try { + const res = await fetch('/api/domains'); + if (res.ok) { + this.domains = (await res.json()) as AllowedDomain[]; + } + } catch { + // offline / server error — leave domains empty, UI falls back to "no filter" + } + this.loaded = true; + } + + persist(): void { + if (typeof window === 'undefined') return; + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify([...this.active])); + } catch { + // ignore quota / disabled storage + } + } + + toggle(domain: string): void { + const next = new Set(this.active); + if (next.has(domain)) next.delete(domain); + else next.add(domain); + this.active = next; + this.persist(); + } + + selectAll(): void { + // "Alle" == leere Menge, damit neue Domains automatisch dabei sind. + this.active = new Set(); + this.persist(); + } + + selectOnly(domain: string): void { + this.active = new Set([domain]); + this.persist(); + } + + // True wenn der User die Suche eingeschränkt hat (mindestens eine aber nicht alle). + get isFiltered(): boolean { + return this.active.size > 0 && this.active.size < this.domains.length; + } + + // Als Query-Param-String. Leer = kein Filter. + get queryParam(): string { + if (this.active.size === 0) return ''; + return [...this.active].join(','); + } + + // Badge-Text: "2/5" wenn gefiltert, sonst "Alle". + get label(): string { + if (!this.isFiltered) return 'Alle'; + return `${this.active.size}/${this.domains.length}`; + } +} + +export const searchFilterStore = new SearchFilterStore(); diff --git a/src/lib/components/SearchFilter.svelte b/src/lib/components/SearchFilter.svelte new file mode 100644 index 0000000..fc769d6 --- /dev/null +++ b/src/lib/components/SearchFilter.svelte @@ -0,0 +1,246 @@ + + +
+ + + {#if open} + + {/if} +
+ + diff --git a/src/lib/server/recipes/search-local.ts b/src/lib/server/recipes/search-local.ts index b006103..d531811 100644 --- a/src/lib/server/recipes/search-local.ts +++ b/src/lib/server/recipes/search-local.ts @@ -30,15 +30,16 @@ export function searchLocal( db: Database.Database, query: string, limit = 30, - offset = 0 + offset = 0, + domains: string[] = [] ): SearchHit[] { const fts = buildFtsQuery(query); if (!fts) return []; // bm25: lower is better. Use weights: title > tags > ingredients > description - return db - .prepare( - `SELECT r.id, + const hasFilter = domains.length > 0; + const placeholders = hasFilter ? domains.map(() => '?').join(',') : ''; + const sql = `SELECT r.id, r.title, r.description, r.image_path, @@ -48,10 +49,13 @@ 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 ?` - ) - .all(fts, limit, offset) as SearchHit[]; + LIMIT ? OFFSET ?`; + const params = hasFilter + ? [fts, ...domains, limit, offset] + : [fts, limit, offset]; + return db.prepare(sql).all(...params) as SearchHit[]; } export function listRecentRecipes( diff --git a/src/lib/server/search/searxng.ts b/src/lib/server/search/searxng.ts index 64d6c74..fad6aa8 100644 --- a/src/lib/server/search/searxng.ts +++ b/src/lib/server/search/searxng.ts @@ -287,12 +287,18 @@ export async function searchWeb( limit?: number; enrichThumbnails?: boolean; pageno?: number; + domains?: string[]; } = {} ): Promise { const trimmed = query.trim(); if (!trimmed) return []; - const domains = listDomains(db).map((d) => d.domain); - if (domains.length === 0) return []; + const allDomains = listDomains(db).map((d) => d.domain); + if (allDomains.length === 0) return []; + // Optionaler Domain-Filter: Intersection mit der Whitelist, damit der + // Filter nie außerhalb der erlaubten Domains sucht. + const whitelist = new Set(allDomains); + const filtered = opts.domains?.filter((d) => whitelist.has(d)) ?? []; + const domains = filtered.length > 0 ? filtered : allDomains; const searxngUrl = opts.searxngUrl ?? process.env.SEARXNG_URL ?? 'http://localhost:8888'; const limit = opts.limit ?? 20; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 0f94187..b1d2be0 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -6,6 +6,7 @@ import { profileStore } from '$lib/client/profile.svelte'; import { wishlistStore } from '$lib/client/wishlist.svelte'; import { pwaStore } from '$lib/client/pwa.svelte'; + import { searchFilterStore } from '$lib/client/search-filter.svelte'; import ProfileSwitcher from '$lib/components/ProfileSwitcher.svelte'; import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; import SearchLoader from '$lib/components/SearchLoader.svelte'; @@ -37,6 +38,11 @@ $page.url.pathname.startsWith('/recipes/') || $page.url.pathname === '/preview' ); + function filterParam(): string { + const p = searchFilterStore.queryParam; + return p ? `&domains=${encodeURIComponent(p)}` : ''; + } + $effect(() => { const q = navQuery.trim(); if (debounceTimer) clearTimeout(debounceTimer); @@ -63,7 +69,7 @@ debounceTimer = setTimeout(async () => { try { const res = await fetch( - `/api/recipes/search?q=${encodeURIComponent(q)}&limit=${NAV_PAGE_SIZE}` + `/api/recipes/search?q=${encodeURIComponent(q)}&limit=${NAV_PAGE_SIZE}${filterParam()}` ); const body = await res.json(); if (navQuery.trim() !== q) return; @@ -73,7 +79,7 @@ navWebSearching = true; try { const wres = await fetch( - `/api/recipes/search/web?q=${encodeURIComponent(q)}&pageno=1` + `/api/recipes/search/web?q=${encodeURIComponent(q)}&pageno=1${filterParam()}` ); if (navQuery.trim() !== q) return; if (!wres.ok) { @@ -104,7 +110,7 @@ try { if (!navLocalExhausted) { const res = await fetch( - `/api/recipes/search?q=${encodeURIComponent(q)}&limit=${NAV_PAGE_SIZE}&offset=${navHits.length}` + `/api/recipes/search?q=${encodeURIComponent(q)}&limit=${NAV_PAGE_SIZE}&offset=${navHits.length}${filterParam()}` ); const body = await res.json(); if (navQuery.trim() !== q) return; @@ -118,7 +124,7 @@ navWebSearching = navWebHits.length === 0; try { const wres = await fetch( - `/api/recipes/search/web?q=${encodeURIComponent(q)}&pageno=${nextPage}` + `/api/recipes/search/web?q=${encodeURIComponent(q)}&pageno=${nextPage}${filterParam()}` ); if (navQuery.trim() !== q) return; if (!wres.ok) { @@ -193,6 +199,7 @@ onMount(() => { profileStore.load(); void wishlistStore.refresh(); + void searchFilterStore.load(); void pwaStore.init(); document.addEventListener('click', handleClickOutside); document.addEventListener('keydown', handleKey); diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 49680a5..3824005 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -7,7 +7,9 @@ import type { WebHit } from '$lib/server/search/searxng'; import { randomQuote } from '$lib/quotes'; import SearchLoader from '$lib/components/SearchLoader.svelte'; + import SearchFilter from '$lib/components/SearchFilter.svelte'; import { profileStore } from '$lib/client/profile.svelte'; + import { searchFilterStore } from '$lib/client/search-filter.svelte'; const LOCAL_PAGE = 30; @@ -26,6 +28,7 @@ let webExhausted = $state(false); let loadingMore = $state(false); let skipNextSearch = false; + let debounceTimer: ReturnType | null = null; type SearchSnapshot = { query: string; @@ -85,6 +88,22 @@ const urlQ = ($page.url.searchParams.get('q') ?? '').trim(); if (urlQ) query = urlQ; void loadRecent(); + void searchFilterStore.load(); + }); + + // Bei Änderung der Domain-Auswahl: laufende Suche neu ausführen, + // damit der User nicht manuell re-tippen muss. + $effect(() => { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + searchFilterStore.active; + const q = query.trim(); + if (!q || q.length <= 3) return; + if (debounceTimer) clearTimeout(debounceTimer); + searching = true; + webHits = []; + webSearching = false; + webError = null; + debounceTimer = setTimeout(() => void runSearch(q), 150); }); // Sync current query back into the URL as ?q=... via replaceState, @@ -110,7 +129,10 @@ void loadFavorites(active.id); }); - let debounceTimer: ReturnType | null = null; + function filterParam(): string { + const p = searchFilterStore.queryParam; + return p ? `&domains=${encodeURIComponent(p)}` : ''; + } async function runSearch(q: string) { localExhausted = false; @@ -118,7 +140,7 @@ webExhausted = false; try { const res = await fetch( - `/api/recipes/search?q=${encodeURIComponent(q)}&limit=${LOCAL_PAGE}` + `/api/recipes/search?q=${encodeURIComponent(q)}&limit=${LOCAL_PAGE}${filterParam()}` ); const body = await res.json(); if (query.trim() !== q) return; @@ -130,7 +152,9 @@ // damit der User nicht extra auf „+ weitere" klicken muss. webSearching = true; try { - const wres = await fetch(`/api/recipes/search/web?q=${encodeURIComponent(q)}&pageno=1`); + const wres = await fetch( + `/api/recipes/search/web?q=${encodeURIComponent(q)}&pageno=1${filterParam()}` + ); if (query.trim() !== q) return; if (!wres.ok) { const err = await wres.json().catch(() => ({})); @@ -160,7 +184,7 @@ if (!localExhausted) { // Noch mehr lokale Treffer holen. const res = await fetch( - `/api/recipes/search?q=${encodeURIComponent(q)}&limit=${LOCAL_PAGE}&offset=${hits.length}` + `/api/recipes/search?q=${encodeURIComponent(q)}&limit=${LOCAL_PAGE}&offset=${hits.length}${filterParam()}` ); const body = await res.json(); if (query.trim() !== q) return; @@ -175,7 +199,7 @@ webSearching = webHits.length === 0; try { const wres = await fetch( - `/api/recipes/search/web?q=${encodeURIComponent(q)}&pageno=${nextPage}` + `/api/recipes/search/web?q=${encodeURIComponent(q)}&pageno=${nextPage}${filterParam()}` ); if (query.trim() !== q) return; if (!wres.ok) { @@ -256,7 +280,8 @@

Kochwas

{quote || '\u00a0'}

-
+ + { 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) + ? searchLocal(getDb(), q, limit, offset, domains) : offset === 0 ? listRecentRecipes(getDb(), limit) : []; diff --git a/src/routes/api/recipes/search/web/+server.ts b/src/routes/api/recipes/search/web/+server.ts index 813553e..354364c 100644 --- a/src/routes/api/recipes/search/web/+server.ts +++ b/src/routes/api/recipes/search/web/+server.ts @@ -7,8 +7,12 @@ export const GET: RequestHandler = async ({ url }) => { const q = url.searchParams.get('q')?.trim() ?? ''; if (!q) error(400, { message: 'Missing ?q=' }); const pageno = Math.max(1, Math.min(10, Number(url.searchParams.get('pageno') ?? 1))); + const domains = (url.searchParams.get('domains') ?? '') + .split(',') + .map((d) => d.trim()) + .filter(Boolean); try { - const hits = await searchWeb(getDb(), q, { pageno }); + const hits = await searchWeb(getDb(), q, { pageno, domains }); return json({ query: q, pageno, hits }); } catch (e) { error(502, { message: `Web search unavailable: ${(e as Error).message}` }); diff --git a/tests/integration/search-local.test.ts b/tests/integration/search-local.test.ts index 65f2c51..ac232fd 100644 --- a/tests/integration/search-local.test.ts +++ b/tests/integration/search-local.test.ts @@ -68,6 +68,23 @@ describe('searchLocal', () => { expect(searchLocal(db, ' ')).toEqual([]); }); + it('filters by domain when supplied', () => { + 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, []); + expect(hits.length).toBe(2); + }); + it('paginates via limit + offset', () => { const db = openInMemoryForTest(); for (let i = 0; i < 5; i++) { diff --git a/tests/integration/searxng.test.ts b/tests/integration/searxng.test.ts index f8bc071..ad27f0d 100644 --- a/tests/integration/searxng.test.ts +++ b/tests/integration/searxng.test.ts @@ -72,6 +72,46 @@ describe('searchWeb', () => { expect(hits).toEqual([]); }); + it('domain filter restricts site:-query to supplied subset', async () => { + const db = openInMemoryForTest(); + addDomain(db, 'chefkoch.de'); + addDomain(db, 'rezeptwelt.de'); + let receivedQ: string | null = null; + server.on('request', (req, res) => { + const u = new URL(req.url ?? '/', 'http://localhost'); + receivedQ = u.searchParams.get('q'); + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ results: [] })); + }); + await searchWeb(db, 'apfel', { + searxngUrl: baseUrl, + enrichThumbnails: false, + domains: ['rezeptwelt.de'] + }); + expect(receivedQ).toMatch(/site:rezeptwelt\.de/); + expect(receivedQ).not.toMatch(/site:chefkoch\.de/); + }); + + it('ignores domain filter entries that are not in whitelist', async () => { + const db = openInMemoryForTest(); + addDomain(db, 'chefkoch.de'); + let receivedQ: string | null = null; + server.on('request', (req, res) => { + const u = new URL(req.url ?? '/', 'http://localhost'); + receivedQ = u.searchParams.get('q'); + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ results: [] })); + }); + // Only "evil.com" requested — not in whitelist → fall back to full whitelist. + await searchWeb(db, 'x', { + searxngUrl: baseUrl, + enrichThumbnails: false, + domains: ['evil.com'] + }); + expect(receivedQ).toMatch(/site:chefkoch\.de/); + expect(receivedQ).not.toMatch(/site:evil\.com/); + }); + it('passes pageno to SearXNG when > 1', async () => { const db = openInMemoryForTest(); addDomain(db, 'chefkoch.de');