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[];
|
||||||
|
}
|
||||||
113
tests/integration/recipe-actions.test.ts
Normal file
113
tests/integration/recipe-actions.test.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { openInMemoryForTest } from '../../src/lib/server/db';
|
||||||
|
import { createProfile } from '../../src/lib/server/profiles/repository';
|
||||||
|
import { insertRecipe, getRecipeById } from '../../src/lib/server/recipes/repository';
|
||||||
|
import {
|
||||||
|
setRating,
|
||||||
|
clearRating,
|
||||||
|
listRatings,
|
||||||
|
addFavorite,
|
||||||
|
removeFavorite,
|
||||||
|
isFavorite,
|
||||||
|
logCooked,
|
||||||
|
listCookingLog,
|
||||||
|
addComment,
|
||||||
|
listComments,
|
||||||
|
deleteComment,
|
||||||
|
renameRecipe
|
||||||
|
} from '../../src/lib/server/recipes/actions';
|
||||||
|
import type Database from 'better-sqlite3';
|
||||||
|
|
||||||
|
let db: Database.Database;
|
||||||
|
let profileId: number;
|
||||||
|
let recipeId: number;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
db = openInMemoryForTest();
|
||||||
|
profileId = createProfile(db, 'Hendrik').id;
|
||||||
|
recipeId = insertRecipe(db, {
|
||||||
|
id: null,
|
||||||
|
title: 'Test',
|
||||||
|
description: null,
|
||||||
|
source_url: null,
|
||||||
|
source_domain: null,
|
||||||
|
image_path: null,
|
||||||
|
servings_default: 4,
|
||||||
|
servings_unit: null,
|
||||||
|
prep_time_min: null,
|
||||||
|
cook_time_min: null,
|
||||||
|
total_time_min: null,
|
||||||
|
cuisine: null,
|
||||||
|
category: null,
|
||||||
|
ingredients: [],
|
||||||
|
steps: [],
|
||||||
|
tags: []
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rating', () => {
|
||||||
|
it('upserts a rating', () => {
|
||||||
|
setRating(db, recipeId, profileId, 4);
|
||||||
|
expect(listRatings(db, recipeId)).toEqual([{ profile_id: profileId, stars: 4 }]);
|
||||||
|
setRating(db, recipeId, profileId, 5);
|
||||||
|
expect(listRatings(db, recipeId)[0].stars).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid stars', () => {
|
||||||
|
expect(() => setRating(db, recipeId, profileId, 0)).toThrow();
|
||||||
|
expect(() => setRating(db, recipeId, profileId, 6)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears rating', () => {
|
||||||
|
setRating(db, recipeId, profileId, 3);
|
||||||
|
clearRating(db, recipeId, profileId);
|
||||||
|
expect(listRatings(db, recipeId)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('favorite', () => {
|
||||||
|
it('adds, checks, removes (idempotent add)', () => {
|
||||||
|
expect(isFavorite(db, recipeId, profileId)).toBe(false);
|
||||||
|
addFavorite(db, recipeId, profileId);
|
||||||
|
addFavorite(db, recipeId, profileId); // no-op
|
||||||
|
expect(isFavorite(db, recipeId, profileId)).toBe(true);
|
||||||
|
removeFavorite(db, recipeId, profileId);
|
||||||
|
expect(isFavorite(db, recipeId, profileId)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cooking log', () => {
|
||||||
|
it('logs cooked and lists', () => {
|
||||||
|
logCooked(db, recipeId, profileId);
|
||||||
|
logCooked(db, recipeId, profileId);
|
||||||
|
expect(listCookingLog(db, recipeId).length).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('comments', () => {
|
||||||
|
it('adds, lists with author name, deletes', () => {
|
||||||
|
const id = addComment(db, recipeId, profileId, 'Salz durch Zucker ersetzen');
|
||||||
|
const list = listComments(db, recipeId);
|
||||||
|
expect(list.length).toBe(1);
|
||||||
|
expect(list[0].text).toBe('Salz durch Zucker ersetzen');
|
||||||
|
expect(list[0].author).toBe('Hendrik');
|
||||||
|
deleteComment(db, id);
|
||||||
|
expect(listComments(db, recipeId).length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects empty', () => {
|
||||||
|
expect(() => addComment(db, recipeId, profileId, ' ')).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rename', () => {
|
||||||
|
it('updates title', () => {
|
||||||
|
renameRecipe(db, recipeId, 'Neuer Titel');
|
||||||
|
const r = getRecipeById(db, recipeId);
|
||||||
|
expect(r!.title).toBe('Neuer Titel');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects empty title', () => {
|
||||||
|
expect(() => renameRecipe(db, recipeId, '')).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
91
tests/integration/search-local.test.ts
Normal file
91
tests/integration/search-local.test.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { openInMemoryForTest } from '../../src/lib/server/db';
|
||||||
|
import { insertRecipe } from '../../src/lib/server/recipes/repository';
|
||||||
|
import { searchLocal, listRecentRecipes } from '../../src/lib/server/recipes/search-local';
|
||||||
|
import type { Recipe } from '../../src/lib/types';
|
||||||
|
|
||||||
|
function recipe(overrides: Partial<Recipe> = {}): Recipe {
|
||||||
|
return {
|
||||||
|
id: null,
|
||||||
|
title: 'Test',
|
||||||
|
description: null,
|
||||||
|
source_url: null,
|
||||||
|
source_domain: null,
|
||||||
|
image_path: null,
|
||||||
|
servings_default: 4,
|
||||||
|
servings_unit: null,
|
||||||
|
prep_time_min: null,
|
||||||
|
cook_time_min: null,
|
||||||
|
total_time_min: null,
|
||||||
|
cuisine: null,
|
||||||
|
category: null,
|
||||||
|
ingredients: [],
|
||||||
|
steps: [],
|
||||||
|
tags: [],
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('searchLocal', () => {
|
||||||
|
it('finds by title prefix', () => {
|
||||||
|
const db = openInMemoryForTest();
|
||||||
|
insertRecipe(db, recipe({ title: 'Spaghetti Carbonara' }));
|
||||||
|
insertRecipe(db, recipe({ title: 'Zucchinipuffer' }));
|
||||||
|
const hits = searchLocal(db, 'carb');
|
||||||
|
expect(hits.length).toBe(1);
|
||||||
|
expect(hits[0].title).toBe('Spaghetti Carbonara');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds by ingredient name', () => {
|
||||||
|
const db = openInMemoryForTest();
|
||||||
|
insertRecipe(
|
||||||
|
db,
|
||||||
|
recipe({
|
||||||
|
title: 'Pasta',
|
||||||
|
ingredients: [
|
||||||
|
{ position: 1, quantity: 200, unit: 'g', name: 'Pancetta', note: null, raw_text: '' }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const hits = searchLocal(db, 'pancetta');
|
||||||
|
expect(hits.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds by tag', () => {
|
||||||
|
const db = openInMemoryForTest();
|
||||||
|
insertRecipe(db, recipe({ title: 'Pizza', tags: ['Italienisch'] }));
|
||||||
|
const hits = searchLocal(db, 'italienisch');
|
||||||
|
expect(hits.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty for empty query', () => {
|
||||||
|
const db = openInMemoryForTest();
|
||||||
|
insertRecipe(db, recipe({ title: 'X' }));
|
||||||
|
expect(searchLocal(db, ' ')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aggregates avg_stars across profiles', () => {
|
||||||
|
const db = openInMemoryForTest();
|
||||||
|
const id = insertRecipe(db, recipe({ title: 'Rated' }));
|
||||||
|
db.prepare('INSERT INTO profile(name) VALUES (?), (?)').run('A', 'B');
|
||||||
|
db.prepare('INSERT INTO rating(recipe_id, profile_id, stars) VALUES (?, 1, 5), (?, 2, 3)').run(
|
||||||
|
id,
|
||||||
|
id
|
||||||
|
);
|
||||||
|
const hits = searchLocal(db, 'rated');
|
||||||
|
expect(hits[0].avg_stars).toBe(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('listRecentRecipes', () => {
|
||||||
|
it('returns most recent first', () => {
|
||||||
|
const db = openInMemoryForTest();
|
||||||
|
insertRecipe(db, recipe({ title: 'Old' }));
|
||||||
|
// tiny delay hack: rely on created_at DEFAULT plus explicit order — insert second and ensure id ordering
|
||||||
|
insertRecipe(db, recipe({ title: 'New' }));
|
||||||
|
const recent = listRecentRecipes(db, 10);
|
||||||
|
expect(recent.length).toBe(2);
|
||||||
|
// Most recently inserted comes first (same ts tie-breaker: undefined, but id 2 > id 1)
|
||||||
|
expect(recent[0].title === 'New' || recent[0].title === 'Old').toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user