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 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 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 ?` ) .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 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[]; }