Files
kochwas/src/lib/server/recipes/repository.ts
hsiegeln a1baf7f30a feat(db): section_heading roundtrip in recipe-repository
INSERT/SELECT in insertRecipe, replaceIngredients und getRecipeById
um section_heading ergänzt. IngredientSchema im PATCH-Endpoint sowie
Ingredient-Fixtures in search-local-, scaler- und repository-Tests
auf das neue Pflichtfeld aktualisiert.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 14:55:46 +02:00

245 lines
6.9 KiB
TypeScript

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, section_heading)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
);
for (const ing of recipe.ingredients) {
insIng.run(id, ing.position, ing.quantity, ing.unit, ing.name, ing.note, ing.raw_text, ing.section_heading);
}
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, section_heading
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);
}
export type RecipeMetaPatch = {
title?: string;
description?: 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;
};
export function updateRecipeMeta(
db: Database.Database,
id: number,
patch: RecipeMetaPatch
): void {
const fields: string[] = [];
const values: unknown[] = [];
for (const key of [
'title',
'description',
'servings_default',
'servings_unit',
'prep_time_min',
'cook_time_min',
'total_time_min',
'cuisine',
'category'
] as const) {
if (patch[key] !== undefined) {
fields.push(`${key} = ?`);
values.push(patch[key]);
}
}
if (fields.length === 0) return;
fields.push('updated_at = CURRENT_TIMESTAMP');
db.prepare(`UPDATE recipe SET ${fields.join(', ')} WHERE id = ?`).run(...values, id);
}
export function updateImagePath(
db: Database.Database,
id: number,
filename: string | null
): void {
db.prepare('UPDATE recipe SET image_path = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(
filename,
id
);
}
export function replaceIngredients(
db: Database.Database,
recipeId: number,
ingredients: Ingredient[]
): void {
const tx = db.transaction(() => {
db.prepare('DELETE FROM ingredient WHERE recipe_id = ?').run(recipeId);
const ins = db.prepare(
`INSERT INTO ingredient(recipe_id, position, quantity, unit, name, note, raw_text, section_heading)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
);
for (const ing of ingredients) {
ins.run(recipeId, ing.position, ing.quantity, ing.unit, ing.name, ing.note, ing.raw_text, ing.section_heading);
}
refreshFts(db, recipeId);
});
tx();
}
export function replaceSteps(
db: Database.Database,
recipeId: number,
steps: Step[]
): void {
const tx = db.transaction(() => {
db.prepare('DELETE FROM step WHERE recipe_id = ?').run(recipeId);
const ins = db.prepare(
'INSERT INTO step(recipe_id, position, text) VALUES (?, ?, ?)'
);
for (const step of steps) {
ins.run(recipeId, step.position, step.text);
}
});
tx();
}