feat(recipes): add local search (FTS5 bm25) and action handlers

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-17 15:23:00 +02:00
parent 75c96d12e9
commit 7c62c977c4
4 changed files with 419 additions and 0 deletions

View File

@@ -0,0 +1,74 @@
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
): 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 ?`
)
.all(fts, limit) 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
ORDER BY r.created_at DESC
LIMIT ?`
)
.all(limit) as SearchHit[];
}