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, domains: string[] = [] ): 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, 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 ? ${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[]; } 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 = { 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[]; }