refactor(search): local search ignores domain filter
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 3m11s
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:
@@ -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) {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)}` : '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)}` : '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
: [];
|
: [];
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user