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 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[]; }