Files
kochwas/src/lib/server/recipes/search-local.ts
hsiegeln d9490c8073
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 3m11s
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) <noreply@anthropic.com>
2026-04-21 21:59:48 +02:00

148 lines
4.6 KiB
TypeScript

import type Database from 'better-sqlite3';
export type SearchHit = {
id: number;
title: string;
description: string | null;
image_path: string | null;
source_domain: string | null;
avg_stars: number | null;
last_cooked_at: string | null;
};
function escapeFtsTerm(term: string): string {
// FTS5 requires double-quoting to treat as a literal phrase; escape inner quotes by doubling
return `"${term.replace(/"/g, '""')}"`;
}
function buildFtsQuery(q: string): string | null {
const tokens = q
.trim()
.split(/\s+/)
.filter(Boolean)
.map(escapeFtsTerm);
if (tokens.length === 0) return null;
// prefix-match each token
return tokens.map((t) => `${t}*`).join(' ');
}
export function searchLocal(
db: Database.Database,
query: string,
limit = 30,
offset = 0
): SearchHit[] {
const fts = buildFtsQuery(query);
if (!fts) return [];
// bm25: lower is better. Use weights: title > tags > ingredients > description
const sql = `SELECT r.id,
r.title,
r.description,
r.image_path,
r.source_domain,
(SELECT AVG(stars) FROM rating WHERE recipe_id = r.id) AS avg_stars,
(SELECT MAX(cooked_at) FROM cooking_log WHERE recipe_id = r.id) AS last_cooked_at
FROM recipe r
JOIN recipe_fts f ON f.rowid = r.id
WHERE recipe_fts MATCH ?
ORDER BY bm25(recipe_fts, 10.0, 0.5, 2.0, 5.0)
LIMIT ? OFFSET ?`;
return db.prepare(sql).all(fts, limit, offset) as SearchHit[];
}
export function listRecentRecipes(
db: Database.Database,
limit = 12
): SearchHit[] {
return db
.prepare(
`SELECT r.id,
r.title,
r.description,
r.image_path,
r.source_domain,
(SELECT AVG(stars) FROM rating WHERE recipe_id = r.id) AS avg_stars,
(SELECT MAX(cooked_at) FROM cooking_log WHERE recipe_id = r.id) AS last_cooked_at
FROM recipe r
WHERE r.hidden_from_recent = 0
ORDER BY r.created_at DESC
LIMIT ?`
)
.all(limit) as SearchHit[];
}
export function listAllRecipes(db: Database.Database): SearchHit[] {
return db
.prepare(
`SELECT r.id,
r.title,
r.description,
r.image_path,
r.source_domain,
(SELECT AVG(stars) FROM rating WHERE recipe_id = r.id) AS avg_stars,
(SELECT MAX(cooked_at) FROM cooking_log WHERE recipe_id = r.id) AS last_cooked_at
FROM recipe r
ORDER BY r.title COLLATE NOCASE`
)
.all() as SearchHit[];
}
export type AllRecipesSort = 'name' | 'rating' | 'cooked' | 'created';
export function listAllRecipesPaginated(
db: Database.Database,
sort: AllRecipesSort,
limit: number,
offset: number
): SearchHit[] {
// NULLS-last-Emulation per CASE-Expression — SQLite unterstützt NULLS LAST
// zwar seit 3.30, aber der Pi könnte auf einer älteren Version laufen und
// CASE ist überall zuverlässig.
const orderBy: Record<AllRecipesSort, string> = {
name: 'r.title COLLATE NOCASE ASC',
rating:
'CASE WHEN (SELECT AVG(stars) FROM rating WHERE recipe_id = r.id) IS NULL THEN 1 ELSE 0 END, ' +
'(SELECT AVG(stars) FROM rating WHERE recipe_id = r.id) DESC, r.title COLLATE NOCASE ASC',
cooked:
'CASE WHEN (SELECT MAX(cooked_at) FROM cooking_log WHERE recipe_id = r.id) IS NULL THEN 1 ELSE 0 END, ' +
'(SELECT MAX(cooked_at) FROM cooking_log WHERE recipe_id = r.id) DESC, r.title COLLATE NOCASE ASC',
created: 'r.created_at DESC, r.id DESC'
};
return db
.prepare(
`SELECT r.id,
r.title,
r.description,
r.image_path,
r.source_domain,
(SELECT AVG(stars) FROM rating WHERE recipe_id = r.id) AS avg_stars,
(SELECT MAX(cooked_at) FROM cooking_log WHERE recipe_id = r.id) AS last_cooked_at
FROM recipe r
ORDER BY ${orderBy[sort]}
LIMIT ? OFFSET ?`
)
.all(limit, offset) as SearchHit[];
}
export function listFavoritesForProfile(
db: Database.Database,
profileId: number
): SearchHit[] {
return db
.prepare(
`SELECT r.id,
r.title,
r.description,
r.image_path,
r.source_domain,
(SELECT AVG(stars) FROM rating WHERE recipe_id = r.id) AS avg_stars,
(SELECT MAX(cooked_at) FROM cooking_log WHERE recipe_id = r.id) AS last_cooked_at
FROM recipe r
JOIN favorite f ON f.recipe_id = r.id
WHERE f.profile_id = ?
ORDER BY r.title COLLATE NOCASE`
)
.all(profileId) as SearchHit[];
}