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:
141
src/lib/server/recipes/actions.ts
Normal file
141
src/lib/server/recipes/actions.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
export type RatingRow = { profile_id: number; stars: number };
|
||||
export type CommentRow = {
|
||||
id: number;
|
||||
profile_id: number;
|
||||
text: string;
|
||||
created_at: string;
|
||||
author: string;
|
||||
};
|
||||
export type CookedRow = { id: number; profile_id: number; cooked_at: string };
|
||||
|
||||
export function setRating(
|
||||
db: Database.Database,
|
||||
recipeId: number,
|
||||
profileId: number,
|
||||
stars: number
|
||||
): void {
|
||||
if (stars < 1 || stars > 5) throw new Error('stars must be 1..5');
|
||||
db.prepare(
|
||||
`INSERT INTO rating(recipe_id, profile_id, stars) VALUES (?, ?, ?)
|
||||
ON CONFLICT(recipe_id, profile_id) DO UPDATE SET stars = excluded.stars, updated_at = CURRENT_TIMESTAMP`
|
||||
).run(recipeId, profileId, stars);
|
||||
}
|
||||
|
||||
export function clearRating(
|
||||
db: Database.Database,
|
||||
recipeId: number,
|
||||
profileId: number
|
||||
): void {
|
||||
db.prepare('DELETE FROM rating WHERE recipe_id = ? AND profile_id = ?').run(
|
||||
recipeId,
|
||||
profileId
|
||||
);
|
||||
}
|
||||
|
||||
export function listRatings(db: Database.Database, recipeId: number): RatingRow[] {
|
||||
return db
|
||||
.prepare('SELECT profile_id, stars FROM rating WHERE recipe_id = ?')
|
||||
.all(recipeId) as RatingRow[];
|
||||
}
|
||||
|
||||
export function addFavorite(
|
||||
db: Database.Database,
|
||||
recipeId: number,
|
||||
profileId: number
|
||||
): void {
|
||||
db.prepare(
|
||||
'INSERT OR IGNORE INTO favorite(recipe_id, profile_id) VALUES (?, ?)'
|
||||
).run(recipeId, profileId);
|
||||
}
|
||||
|
||||
export function removeFavorite(
|
||||
db: Database.Database,
|
||||
recipeId: number,
|
||||
profileId: number
|
||||
): void {
|
||||
db.prepare('DELETE FROM favorite WHERE recipe_id = ? AND profile_id = ?').run(
|
||||
recipeId,
|
||||
profileId
|
||||
);
|
||||
}
|
||||
|
||||
export function isFavorite(
|
||||
db: Database.Database,
|
||||
recipeId: number,
|
||||
profileId: number
|
||||
): boolean {
|
||||
return (
|
||||
db
|
||||
.prepare('SELECT 1 AS ok FROM favorite WHERE recipe_id = ? AND profile_id = ?')
|
||||
.get(recipeId, profileId) !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
export function logCooked(
|
||||
db: Database.Database,
|
||||
recipeId: number,
|
||||
profileId: number
|
||||
): CookedRow {
|
||||
return db
|
||||
.prepare(
|
||||
`INSERT INTO cooking_log(recipe_id, profile_id) VALUES (?, ?)
|
||||
RETURNING id, profile_id, cooked_at`
|
||||
)
|
||||
.get(recipeId, profileId) as CookedRow;
|
||||
}
|
||||
|
||||
export function listCookingLog(
|
||||
db: Database.Database,
|
||||
recipeId: number
|
||||
): CookedRow[] {
|
||||
return db
|
||||
.prepare(
|
||||
'SELECT id, profile_id, cooked_at FROM cooking_log WHERE recipe_id = ? ORDER BY cooked_at DESC'
|
||||
)
|
||||
.all(recipeId) as CookedRow[];
|
||||
}
|
||||
|
||||
export function addComment(
|
||||
db: Database.Database,
|
||||
recipeId: number,
|
||||
profileId: number,
|
||||
text: string
|
||||
): number {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) throw new Error('comment text cannot be empty');
|
||||
const info = db
|
||||
.prepare('INSERT INTO comment(recipe_id, profile_id, text) VALUES (?, ?, ?)')
|
||||
.run(recipeId, profileId, trimmed);
|
||||
return Number(info.lastInsertRowid);
|
||||
}
|
||||
|
||||
export function deleteComment(db: Database.Database, id: number): void {
|
||||
db.prepare('DELETE FROM comment WHERE id = ?').run(id);
|
||||
}
|
||||
|
||||
export function listComments(db: Database.Database, recipeId: number): CommentRow[] {
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT c.id, c.profile_id, c.text, c.created_at, p.name AS author
|
||||
FROM comment c
|
||||
JOIN profile p ON p.id = c.profile_id
|
||||
WHERE c.recipe_id = ?
|
||||
ORDER BY c.created_at ASC`
|
||||
)
|
||||
.all(recipeId) as CommentRow[];
|
||||
}
|
||||
|
||||
export function renameRecipe(
|
||||
db: Database.Database,
|
||||
recipeId: number,
|
||||
newTitle: string
|
||||
): void {
|
||||
const trimmed = newTitle.trim();
|
||||
if (!trimmed) throw new Error('title cannot be empty');
|
||||
db.prepare('UPDATE recipe SET title = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(
|
||||
trimmed,
|
||||
recipeId
|
||||
);
|
||||
}
|
||||
74
src/lib/server/recipes/search-local.ts
Normal file
74
src/lib/server/recipes/search-local.ts
Normal 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[];
|
||||
}
|
||||
Reference in New Issue
Block a user