75 lines
2.0 KiB
TypeScript
75 lines
2.0 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
|
||
|
|
): 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[];
|
||
|
|
}
|