2026-04-17 15:11:23 +02:00
|
|
|
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(
|
2026-04-19 14:55:46 +02:00
|
|
|
`INSERT INTO ingredient(recipe_id, position, quantity, unit, name, note, raw_text, section_heading)
|
|
|
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
2026-04-17 15:11:23 +02:00
|
|
|
);
|
|
|
|
|
for (const ing of recipe.ingredients) {
|
2026-04-19 14:55:46 +02:00
|
|
|
insIng.run(id, ing.position, ing.quantity, ing.unit, ing.name, ing.note, ing.raw_text, ing.section_heading);
|
2026-04-17 15:11:23 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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(
|
2026-04-19 14:55:46 +02:00
|
|
|
`SELECT position, quantity, unit, name, note, raw_text, section_heading
|
2026-04-17 15:11:23 +02:00
|
|
|
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);
|
|
|
|
|
}
|
feat(recipe): Edit-Modus für Zutaten, Schritte und Meta
Auf der Rezept-Detail-Seite ein neuer „Bearbeiten"-Button (Pencil-Icon)
in der Action-Bar. Klick schaltet RecipeView auf RecipeEditor um.
Im Editor:
- Titel, Beschreibung, Portionen, Vorbereitungs-/Koch-/Gesamtzeit als
inline-Inputs.
- Zutaten: pro Zeile Menge, Einheit, Name, Notiz + Trash-Icon zum
Entfernen. „+ Zutat hinzufügen"-Dashed-Button am Listenende.
- Schritte: nummerierte Textareas, Trash-Icon, „+ Schritt hinzufügen".
- Mengen akzeptieren Komma- oder Punkt-Dezimalen.
- Empty-Items werden beim Speichern automatisch aussortiert.
Backend:
- Neue Repo-Funktionen updateRecipeMeta(id, patch), replaceIngredients,
replaceSteps — letztere in einer Transaction mit delete+insert und
FTS-Refresh.
- PATCH /api/recipes/[id] akzeptiert jetzt zusätzlich description,
servings_default, servings_unit, prep_time_min, cook_time_min,
total_time_min, cuisine, category, ingredients[], steps[]. Vorher
nur title/hidden_from_recent; diese beiden bleiben als
Kurz-Fall erhalten, damit bestehende Aufrufer unverändert laufen.
- Zod-Schema mit expliziten Grenzen (max-Länge, positive Mengen).
Tests: 3 neue Cases für updateRecipeMeta, replaceIngredients (inkl.
FTS-Update), replaceSteps.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 12:41:10 +02:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-18 21:39:54 +02:00
|
|
|
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
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
feat(recipe): Edit-Modus für Zutaten, Schritte und Meta
Auf der Rezept-Detail-Seite ein neuer „Bearbeiten"-Button (Pencil-Icon)
in der Action-Bar. Klick schaltet RecipeView auf RecipeEditor um.
Im Editor:
- Titel, Beschreibung, Portionen, Vorbereitungs-/Koch-/Gesamtzeit als
inline-Inputs.
- Zutaten: pro Zeile Menge, Einheit, Name, Notiz + Trash-Icon zum
Entfernen. „+ Zutat hinzufügen"-Dashed-Button am Listenende.
- Schritte: nummerierte Textareas, Trash-Icon, „+ Schritt hinzufügen".
- Mengen akzeptieren Komma- oder Punkt-Dezimalen.
- Empty-Items werden beim Speichern automatisch aussortiert.
Backend:
- Neue Repo-Funktionen updateRecipeMeta(id, patch), replaceIngredients,
replaceSteps — letztere in einer Transaction mit delete+insert und
FTS-Refresh.
- PATCH /api/recipes/[id] akzeptiert jetzt zusätzlich description,
servings_default, servings_unit, prep_time_min, cook_time_min,
total_time_min, cuisine, category, ingredients[], steps[]. Vorher
nur title/hidden_from_recent; diese beiden bleiben als
Kurz-Fall erhalten, damit bestehende Aufrufer unverändert laufen.
- Zod-Schema mit expliziten Grenzen (max-Länge, positive Mengen).
Tests: 3 neue Cases für updateRecipeMeta, replaceIngredients (inkl.
FTS-Update), replaceSteps.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 12:41:10 +02:00
|
|
|
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(
|
2026-04-19 14:55:46 +02:00
|
|
|
`INSERT INTO ingredient(recipe_id, position, quantity, unit, name, note, raw_text, section_heading)
|
|
|
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
feat(recipe): Edit-Modus für Zutaten, Schritte und Meta
Auf der Rezept-Detail-Seite ein neuer „Bearbeiten"-Button (Pencil-Icon)
in der Action-Bar. Klick schaltet RecipeView auf RecipeEditor um.
Im Editor:
- Titel, Beschreibung, Portionen, Vorbereitungs-/Koch-/Gesamtzeit als
inline-Inputs.
- Zutaten: pro Zeile Menge, Einheit, Name, Notiz + Trash-Icon zum
Entfernen. „+ Zutat hinzufügen"-Dashed-Button am Listenende.
- Schritte: nummerierte Textareas, Trash-Icon, „+ Schritt hinzufügen".
- Mengen akzeptieren Komma- oder Punkt-Dezimalen.
- Empty-Items werden beim Speichern automatisch aussortiert.
Backend:
- Neue Repo-Funktionen updateRecipeMeta(id, patch), replaceIngredients,
replaceSteps — letztere in einer Transaction mit delete+insert und
FTS-Refresh.
- PATCH /api/recipes/[id] akzeptiert jetzt zusätzlich description,
servings_default, servings_unit, prep_time_min, cook_time_min,
total_time_min, cuisine, category, ingredients[], steps[]. Vorher
nur title/hidden_from_recent; diese beiden bleiben als
Kurz-Fall erhalten, damit bestehende Aufrufer unverändert laufen.
- Zod-Schema mit expliziten Grenzen (max-Länge, positive Mengen).
Tests: 3 neue Cases für updateRecipeMeta, replaceIngredients (inkl.
FTS-Update), replaceSteps.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 12:41:10 +02:00
|
|
|
);
|
|
|
|
|
for (const ing of ingredients) {
|
2026-04-19 14:55:46 +02:00
|
|
|
ins.run(recipeId, ing.position, ing.quantity, ing.unit, ing.name, ing.note, ing.raw_text, ing.section_heading);
|
feat(recipe): Edit-Modus für Zutaten, Schritte und Meta
Auf der Rezept-Detail-Seite ein neuer „Bearbeiten"-Button (Pencil-Icon)
in der Action-Bar. Klick schaltet RecipeView auf RecipeEditor um.
Im Editor:
- Titel, Beschreibung, Portionen, Vorbereitungs-/Koch-/Gesamtzeit als
inline-Inputs.
- Zutaten: pro Zeile Menge, Einheit, Name, Notiz + Trash-Icon zum
Entfernen. „+ Zutat hinzufügen"-Dashed-Button am Listenende.
- Schritte: nummerierte Textareas, Trash-Icon, „+ Schritt hinzufügen".
- Mengen akzeptieren Komma- oder Punkt-Dezimalen.
- Empty-Items werden beim Speichern automatisch aussortiert.
Backend:
- Neue Repo-Funktionen updateRecipeMeta(id, patch), replaceIngredients,
replaceSteps — letztere in einer Transaction mit delete+insert und
FTS-Refresh.
- PATCH /api/recipes/[id] akzeptiert jetzt zusätzlich description,
servings_default, servings_unit, prep_time_min, cook_time_min,
total_time_min, cuisine, category, ingredients[], steps[]. Vorher
nur title/hidden_from_recent; diese beiden bleiben als
Kurz-Fall erhalten, damit bestehende Aufrufer unverändert laufen.
- Zod-Schema mit expliziten Grenzen (max-Länge, positive Mengen).
Tests: 3 neue Cases für updateRecipeMeta, replaceIngredients (inkl.
FTS-Update), replaceSteps.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 12:41:10 +02:00
|
|
|
}
|
|
|
|
|
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();
|
|
|
|
|
}
|