From aea07c5eb2bae05e614652fe5584b38d40edb554 Mon Sep 17 00:00:00 2001 From: Hendrik Date: Fri, 17 Apr 2026 15:11:23 +0200 Subject: [PATCH] feat(recipes): add recipe repository (insert/get/delete with FTS refresh) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/server/recipes/repository.ts | 157 ++++++++++++++++++++ tests/integration/recipe-repository.test.ts | 100 +++++++++++++ 2 files changed, 257 insertions(+) create mode 100644 src/lib/server/recipes/repository.ts create mode 100644 tests/integration/recipe-repository.test.ts diff --git a/src/lib/server/recipes/repository.ts b/src/lib/server/recipes/repository.ts new file mode 100644 index 0000000..b029015 --- /dev/null +++ b/src/lib/server/recipes/repository.ts @@ -0,0 +1,157 @@ +import type Database from 'better-sqlite3'; +import type { Ingredient, Recipe, Step } from '$lib/types'; + +type RecipeRow = { + id: number; + title: string; + description: string | null; + source_url: string | null; + source_domain: string | null; + image_path: string | null; + servings_default: number | null; + servings_unit: string | null; + prep_time_min: number | null; + cook_time_min: number | null; + total_time_min: number | null; + cuisine: string | null; + category: string | null; +}; + +function ensureTagIds(db: Database.Database, names: string[]): number[] { + const insert = db.prepare('INSERT OR IGNORE INTO tag(name) VALUES (?)'); + const select = db.prepare('SELECT id FROM tag WHERE name = ?'); + const ids: number[] = []; + for (const name of names) { + const trimmed = name.trim(); + if (!trimmed) continue; + insert.run(trimmed); + const row = select.get(trimmed) as { id: number }; + ids.push(row.id); + } + return ids; +} + +function refreshFts(db: Database.Database, recipeId: number): void { + // Trigger the AFTER UPDATE trigger which rebuilds the FTS row with current ingredients + tags. + db.prepare('UPDATE recipe SET title = title WHERE id = ?').run(recipeId); +} + +export function insertRecipe(db: Database.Database, recipe: Recipe): number { + const tx = db.transaction((): number => { + const info = db + .prepare( + `INSERT INTO recipe + (title, description, source_url, source_domain, image_path, + servings_default, servings_unit, + prep_time_min, cook_time_min, total_time_min, + cuisine, category) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) + .run( + recipe.title, + recipe.description, + recipe.source_url, + recipe.source_domain, + recipe.image_path, + recipe.servings_default, + recipe.servings_unit, + recipe.prep_time_min, + recipe.cook_time_min, + recipe.total_time_min, + recipe.cuisine, + recipe.category + ); + const id = Number(info.lastInsertRowid); + + const insIng = db.prepare( + `INSERT INTO ingredient(recipe_id, position, quantity, unit, name, note, raw_text) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ); + for (const ing of recipe.ingredients) { + insIng.run(id, ing.position, ing.quantity, ing.unit, ing.name, ing.note, ing.raw_text); + } + + const insStep = db.prepare( + 'INSERT INTO step(recipe_id, position, text) VALUES (?, ?, ?)' + ); + for (const step of recipe.steps) { + insStep.run(id, step.position, step.text); + } + + const tagIds = ensureTagIds(db, recipe.tags); + const linkTag = db.prepare( + 'INSERT OR IGNORE INTO recipe_tag(recipe_id, tag_id) VALUES (?, ?)' + ); + for (const tid of tagIds) linkTag.run(id, tid); + + refreshFts(db, id); + return id; + }); + return tx(); +} + +export function getRecipeById(db: Database.Database, id: number): Recipe | null { + const row = db + .prepare( + `SELECT id, title, description, source_url, source_domain, image_path, + servings_default, servings_unit, + prep_time_min, cook_time_min, total_time_min, + cuisine, category + FROM recipe WHERE id = ?` + ) + .get(id) as RecipeRow | undefined; + if (!row) return null; + + const ingredients = db + .prepare( + `SELECT position, quantity, unit, name, note, raw_text + FROM ingredient WHERE recipe_id = ? ORDER BY position` + ) + .all(id) as Ingredient[]; + + const steps = db + .prepare('SELECT position, text FROM step WHERE recipe_id = ? ORDER BY position') + .all(id) as Step[]; + + const tagRows = db + .prepare( + `SELECT t.name FROM tag t + JOIN recipe_tag rt ON rt.tag_id = t.id + WHERE rt.recipe_id = ? + ORDER BY t.name` + ) + .all(id) as { name: string }[]; + + return { + id: row.id, + title: row.title, + description: row.description, + source_url: row.source_url, + source_domain: row.source_domain, + image_path: row.image_path, + servings_default: row.servings_default, + servings_unit: row.servings_unit, + prep_time_min: row.prep_time_min, + cook_time_min: row.cook_time_min, + total_time_min: row.total_time_min, + cuisine: row.cuisine, + category: row.category, + ingredients, + steps, + tags: tagRows.map((t) => t.name) + }; +} + +export function getRecipeIdBySourceUrl( + db: Database.Database, + url: string +): number | null { + const row = db.prepare('SELECT id FROM recipe WHERE source_url = ?').get(url) as + | { id: number } + | undefined; + return row?.id ?? null; +} + +export function deleteRecipe(db: Database.Database, id: number): void { + db.prepare('DELETE FROM recipe WHERE id = ?').run(id); +} diff --git a/tests/integration/recipe-repository.test.ts b/tests/integration/recipe-repository.test.ts new file mode 100644 index 0000000..620da32 --- /dev/null +++ b/tests/integration/recipe-repository.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { openInMemoryForTest } from '../../src/lib/server/db'; +import { + insertRecipe, + getRecipeById, + getRecipeIdBySourceUrl, + deleteRecipe +} from '../../src/lib/server/recipes/repository'; +import { extractRecipeFromHtml } from '../../src/lib/server/parsers/json-ld-recipe'; +import type { Recipe } from '../../src/lib/types'; + +const here = dirname(fileURLToPath(import.meta.url)); +const fixture = (name: string): string => + readFileSync(join(here, '../fixtures', name), 'utf8'); + +function baseRecipe(overrides: Partial = {}): 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('recipe repository', () => { + it('round-trips an imported chefkoch recipe', () => { + const db = openInMemoryForTest(); + const parsed = extractRecipeFromHtml(fixture('chefkoch-schupfnudeln.html')); + expect(parsed).not.toBeNull(); + parsed!.source_url = 'https://www.chefkoch.de/rezepte/4094871643016343/x.html'; + parsed!.source_domain = 'chefkoch.de'; + const id = insertRecipe(db, parsed!); + const loaded = getRecipeById(db, id); + expect(loaded).not.toBeNull(); + expect(loaded!.title).toBe(parsed!.title); + expect(loaded!.ingredients.length).toBe(parsed!.ingredients.length); + expect(loaded!.steps.length).toBe(parsed!.steps.length); + expect(loaded!.ingredients[0].name).toBe(parsed!.ingredients[0].name); + }); + + it('FTS finds recipe by ingredient after refresh', () => { + const db = openInMemoryForTest(); + insertRecipe( + db, + baseRecipe({ + title: 'Spaghetti Carbonara', + ingredients: [ + { + position: 1, + quantity: 200, + unit: 'g', + name: 'Pancetta', + note: null, + raw_text: '200 g Pancetta' + } + ], + tags: ['Italienisch'] + }) + ); + const byIngredient = db + .prepare("SELECT rowid FROM recipe_fts WHERE recipe_fts MATCH 'pancetta'") + .all(); + expect(byIngredient.length).toBe(1); + const byTag = db + .prepare("SELECT rowid FROM recipe_fts WHERE recipe_fts MATCH 'italienisch'") + .all(); + expect(byTag.length).toBe(1); + }); + + it('looks up by source_url and deletes cascading', () => { + const db = openInMemoryForTest(); + const id = insertRecipe( + db, + baseRecipe({ + title: 'Foo', + source_url: 'https://example.com/foo', + source_domain: 'example.com' + }) + ); + expect(getRecipeIdBySourceUrl(db, 'https://example.com/foo')).toBe(id); + deleteRecipe(db, id); + expect(getRecipeById(db, id)).toBeNull(); + }); +});