diff --git a/.gitignore b/.gitignore index 13d907f..cdf08d7 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ Thumbs.db # Local application databases, backups, logs, and SQLite sidecars /var/ +file.json diff --git a/scripts/backup.mjs b/scripts/backup.mjs index 3513348..8002be6 100644 --- a/scripts/backup.mjs +++ b/scripts/backup.mjs @@ -48,11 +48,10 @@ async function main() { } const db = new DatabaseSync(databasePath); try { - console.log("Exporting full Formulation database..."); const bundle = exportDatabase(db); const defaultName = `formulation-backup-${new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19)}.json`; const outputPath = args[1] ? path.resolve(process.cwd(), args[1]) : path.join(process.cwd(), defaultName); - + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); fs.writeFileSync(outputPath, JSON.stringify(bundle, null, 2), "utf8"); console.log(`\n✅ Backup exported successfully to: ${outputPath}`); console.log(` - Recipes: ${bundle.summary.recipes_count}`); diff --git a/scripts/ingest-docling-book.mjs b/scripts/ingest-docling-book.mjs new file mode 100644 index 0000000..14a0599 --- /dev/null +++ b/scripts/ingest-docling-book.mjs @@ -0,0 +1,610 @@ +#!/usr/bin/env node +/** + * Batch Docling Recipe Ingestor for "The Pastry Chef's Little Black Book, Vol. I" + * + * Usage: + * node scripts/ingest-docling-book.mjs --dry-run + * node scripts/ingest-docling-book.mjs --save + */ +import fs from "node:fs"; +import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { titleCase } from "../src/lib/format.ts"; + +const root = path.resolve(import.meta.dirname, ".."); +const databasePath = path.join(root, "var", "recipe-book.sqlite"); +const doclingPath = path.join(root, "file.json"); + +// --------------------------------------------------------------------------- +// Table of Contents Chapter Ranges +// --------------------------------------------------------------------------- +export const CHAPTER_PAGE_RANGES = [ + { name: "Doughs", startPage: 11, endPage: 38, category: "doughs" }, + { name: "Tart, Pie & Strudel Fillings", startPage: 39, endPage: 64, category: "tart_pie_fillings" }, + { name: "Cakes & Souffles", startPage: 65, endPage: 122, category: "cakes_souffles" }, + { name: "Sheet Cakes", startPage: 123, endPage: 166, category: "sheet_cakes" }, + { name: "Buttercreams, Frostings & Glazes", startPage: 167, endPage: 190, category: "frostings_glazes" }, + { name: "Custards, Creams & Fillings", startPage: 191, endPage: 234, category: "custards_creams" }, + { name: "Mousses & Bavarian Creams", startPage: 235, endPage: 296, category: "mousses_bavarians" }, + { name: "Cookies & Tuiles", startPage: 297, endPage: 350, category: "cookies_tuiles" }, + { name: "Sauces & Poaching Liquids", startPage: 351, endPage: 380, category: "sauces_liquids" }, + { name: "Chocolates & Confections", startPage: 381, endPage: 424, category: "confections" }, + { name: "Frozen Desserts", startPage: 425, endPage: 472, category: "frozen_desserts" }, + { name: "Breakfast", startPage: 473, endPage: 516, category: "breakfast" }, + { name: "Breads", startPage: 517, endPage: 537, category: "breads" }, +]; + +export function getChapterForPage(pageNo) { + for (const ch of CHAPTER_PAGE_RANGES) { + if (pageNo >= ch.startPage && pageNo <= ch.endPage) return ch; + } + return { name: "General Pastry", category: "pastry" }; +} + +// --------------------------------------------------------------------------- +// Extended Ingredient Normalization Map +// --------------------------------------------------------------------------- +const KNOWN_INGREDIENT_MAP = { + "butter": "butter", + "unsalted butter": "butter", + "salted butter": "butter_salted", + "clarified butter": "clarified_butter", + "brown butter": "brown_butter", + "beurre noisette": "brown_butter", + "granulated sugar": "sugar", + "sugar": "sugar", + "powdered sugar": "confectioners_sugar", + "confectioners sugar": "confectioners_sugar", + "icing sugar": "confectioners_sugar", + "brown sugar": "brown_sugar", + "light brown sugar": "brown_sugar", + "dark brown sugar": "brown_sugar", + "all-purpose flour": "flour_all_purpose", + "all purpose flour": "flour_all_purpose", + "ap flour": "flour_all_purpose", + "pastry flour": "flour_pastry", + "cake flour": "flour_cake", + "bread flour": "flour_bread", + "fine whole wheat flour": "flour_whole_wheat", + "whole wheat flour": "flour_whole_wheat", + "almond flour": "almond_flour", + "hazelnut flour": "hazelnut_flour", + "whole eggs": "egg_whole", + "eggs": "egg_whole", + "whole egg": "egg_whole", + "egg yolks": "egg_yolk", + "egg yolk": "egg_yolk", + "egg whites": "egg_whites", + "egg white": "egg_whites", + "whole milk": "milk_whole", + "milk": "milk_whole", + "milk powder": "milk_powder", + "nonfat dry milk": "milk_powder", + "heavy cream": "heavy_cream", + "cream": "heavy_cream", + "heavy cream 36%": "heavy_cream", + "heavy cream 40%": "heavy_cream", + "sour cream": "sour_cream", + "creme fraiche": "sour_cream", + "mascarpone": "mascarpone", + "cream cheese": "cream_cheese", + "buttermilk": "buttermilk", + "salt": "salt", + "fine salt": "salt", + "kosher salt": "salt", + "sea salt": "salt", + "baking powder": "baking_powder", + "baking soda": "baking_soda", + "cream of tartar": "cream_of_tartar", + "vanilla extract": "vanilla_extract", + "vanilla bean": "vanilla_bean", + "vanilla beans": "vanilla_bean", + "vanilla paste": "vanilla_extract", + "cinnamon (ground)": "cinnamon", + "cinnamon": "cinnamon", + "ground cinnamon": "cinnamon", + "nutmeg": "nutmeg", + "ground nutmeg": "nutmeg", + "black pepper": "black_pepper", + "water": "water", + "water (cold)": "water", + "water (warm)": "water", + "water (hot)": "water", + "cocoa powder": "cocoa_powder", + "dutch-process cocoa powder": "cocoa_powder", + "cocoa butter": "cocoa_butter", + "dark chocolate": "chocolate_dark", + "chocolate": "chocolate_dark", + "dark chocolate 64%": "chocolate_dark", + "dark chocolate 70%": "chocolate_dark", + "semisweet chocolate": "chocolate_dark", + "bittersweet chocolate": "chocolate_dark", + "milk chocolate": "chocolate_milk", + "white chocolate": "chocolate_white", + "cornstarch": "cornstarch", + "gelatin (sheet)": "gelatin_sheet", + "gelatin (powder)": "gelatin_powder", + "gelatin sheets": "gelatin_sheet", + "sheet gelatin": "gelatin_sheet", + "powdered gelatin": "gelatin_powder", + "honey": "honey", + "glucose syrup": "glucose_syrup", + "glucose": "glucose_syrup", + "powdered glucose": "powdered_glucose", + "corn syrup": "corn_syrup", + "trimoline": "invert_sugar", + "invert sugar": "invert_sugar", + "canola oil": "canola_oil", + "vegetable oil": "canola_oil", + "olive oil": "olive_oil", + "lemon juice": "lemon_juice", + "lemon zest": "lemon_zest", + "orange juice": "orange_juice", + "orange zest": "orange_zest", + "lime juice": "lime_juice", + "lime zest": "lime_zest", + "passion fruit puree": "passion_fruit_puree", + "raspberry puree": "raspberry_puree", + "strawberry puree": "strawberry_puree", + "mango puree": "mango_puree", + "almond paste": "almond_paste", + "marzipan": "marzipan", + "praline paste": "praline_paste", + "hazelnut paste": "hazelnut_paste", + "pistachio paste": "pistachio_paste", + "walnuts": "walnut", + "walnut": "walnut", + "pecans": "pecan", + "almonds": "almond", + "hazelnuts": "hazelnut", + "pistachios": "pistachio", + "fresh yeast": "yeast_fresh", + "yeast (fresh)": "yeast_fresh", + "instant yeast": "yeast_instant", + "yeast (instant)": "yeast_instant", + "active dry yeast": "yeast_active_dry", + "ice cream stabilizer": "ice_cream_stabilizer", + "sorbet stabilizer": "sorbet_stabilizer", + "pectin nh": "pectin_nh", + "pectin yellow": "pectin_yellow", + "pectin": "pectin", +}; + +function slugify(text) { + return text + .toLowerCase() + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, "") || "recipe"; +} + +function parseMetricAmount(str) { + if (!str) return null; + const s = str.trim().toLowerCase(); + + const kgMatch = s.match(/^([\d.,]+)\s*kg$/i); + if (kgMatch) { + return { quantity: Math.round(parseFloat(kgMatch[1].replace(/,/g, "")) * 1000 * 100) / 100, unit_id: "gram" }; + } + + const gMatch = s.match(/^([\d.,]+)\s*g$/i); + if (gMatch) { + return { quantity: Math.round(parseFloat(gMatch[1].replace(/,/g, "")) * 100) / 100, unit_id: "gram" }; + } + + const mlMatch = s.match(/^([\d.,]+)\s*ml$/i); + if (mlMatch) { + return { quantity: Math.round(parseFloat(mlMatch[1].replace(/,/g, "")) * 100) / 100, unit_id: "milliliter" }; + } + + const lMatch = s.match(/^([\d.,]+)\s*l$/i); + if (lMatch) { + return { quantity: Math.round(parseFloat(lMatch[1].replace(/,/g, "")) * 1000 * 100) / 100, unit_id: "milliliter" }; + } + + const numMatch = s.match(/^([\d.,]+)$/); + if (numMatch) { + return { quantity: Math.round(parseFloat(numMatch[1].replace(/,/g, "")) * 100) / 100, unit_id: "gram" }; + } + + return null; +} + +function parseUsFallback(usText, name) { + if (!usText) { + if (/zest/i.test(name)) return { quantity: 1, unit_id: "each", notes: "Zest of 1" }; + if (/vanilla bean/i.test(name)) return { quantity: 1, unit_id: "each", notes: "1 bean" }; + return { quantity: 1, unit_id: "gram", notes: "To taste / as needed" }; + } + const s = usText.replace(/[\r\n\s]+/g, " ").trim().toLowerCase(); + if (s.includes("1/8") || s.includes("⅛")) return { quantity: 0.6, unit_id: "gram", notes: "⅛ tsp" }; + if (s.includes("1/4") || s.includes("¼")) return { quantity: 1.25, unit_id: "gram", notes: "¼ tsp" }; + if (s.includes("1/2") || s.includes("½")) return { quantity: 2.5, unit_id: "gram", notes: "½ tsp" }; + if (s.includes("3/4") || s.includes("¾")) return { quantity: 3.75, unit_id: "gram", notes: "¾ tsp" }; + if (s.includes("1 1/4") || s.includes("1¼")) return { quantity: 6.25, unit_id: "gram", notes: "1¼ tsp" }; + if (s.includes("1 1/2") || s.includes("1½")) return { quantity: 7.5, unit_id: "gram", notes: "1½ tsp" }; + if (s.includes("2 t")) return { quantity: 10, unit_id: "gram", notes: "2 tsp" }; + if (s.includes("1 t") && !s.includes("tbsp")) return { quantity: 5, unit_id: "gram", notes: "1 tsp" }; + if (s.includes("tbsp") || s.includes("1 t") || s.includes("2 t")) return { quantity: 15, unit_id: "gram", notes: "1 Tbsp" }; + + const eachMatch = s.match(/^([\d.]+)\s*(?:each|pc|ea)?$/); + if (eachMatch && parseFloat(eachMatch[1]) > 0) return { quantity: parseFloat(eachMatch[1]), unit_id: "each", notes: null }; + + const ozMatch = s.match(/^([\d.]+)\s*oz$/); + if (ozMatch && parseFloat(ozMatch[1]) > 0) { + return { quantity: Math.round(parseFloat(ozMatch[1]) * 28.3495 * 100) / 100, unit_id: "gram", notes: usText }; + } + + return { quantity: 1, unit_id: "gram", notes: usText }; +} + +function cleanIngredientName(raw) { + let name = raw.trim().replace(/^[\s•\-\*]+/, ""); + let notes = null; + + const parenMatch = name.match(/^([^(]+)\s*\(([^)]+)\)$/); + if (parenMatch) { + name = parenMatch[1].trim(); + notes = parenMatch[2].trim(); + } + + return { name, notes }; +} + +function inferIngredientId(name) { + const norm = name.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim(); + if (KNOWN_INGREDIENT_MAP[norm]) return KNOWN_INGREDIENT_MAP[norm]; + return slugify(norm); +} + +function isFlourBasis(ingredientId, name) { + const n = (ingredientId + " " + name).toLowerCase(); + return ( + n.includes("flour") && + !n.includes("almond") && + !n.includes("hazelnut") && + !n.includes("cornstarch") + ); +} + +function inferEquipment(instruction) { + const text = instruction.toLowerCase(); + const eq = new Set(); + if (text.includes("mixer") || text.includes("paddle") || text.includes("whip") || text.includes("dough hook")) eq.add("stand_mixer"); + if (text.includes("whisk")) eq.add("whisk"); + if (text.includes("bowl")) eq.add("mixing_bowl"); + if (text.includes("scale") || text.includes("weigh")) eq.add("kitchen_scale"); + if (text.includes("bake") || text.includes("oven") || text.includes("375°f") || text.includes("350°f") || text.includes("325°f")) eq.add("oven"); + if (text.includes("sheet pan") || text.includes("parchment") || text.includes("silpat")) eq.add("sheet_pan"); + if (text.includes("saucepan") || text.includes("simmer") || text.includes("boil") || text.includes("pot")) eq.add("saucepan"); + if (text.includes("food processor") || text.includes("process") || text.includes("robot coupe")) eq.add("food_processor"); + if (text.includes("blender") || text.includes("blend") || text.includes("immersion blender")) eq.add("blender"); + if (text.includes("thermometer") || text.includes("degrees") || text.includes("°c") || text.includes("°f")) eq.add("thermometer"); + return [...eq]; +} + +function parseShelfLife(notesList) { + for (const note of notesList) { + const text = note.toLowerCase(); + const dayMatch = text.match(/refrigerat\w*\s+for\s+(\d+)\s+days?/i); + if (dayMatch) { + return { + quantity: parseInt(dayMatch[1], 10), + unit: "day", + storage_condition: "refrigerated", + }; + } + const monthMatch = text.match(/frozen\s+(?:up\s+to\s+)?(\d+)\s+months?/i); + if (monthMatch) { + return { + quantity: parseInt(monthMatch[1], 10) * 30, + unit: "day", + storage_condition: "frozen", + }; + } + } + return null; +} + +// --------------------------------------------------------------------------- +// Page & Recipe Extractor +// --------------------------------------------------------------------------- +export function parseDoclingBook(doclingJson, targetPages = null) { + const pagesMap = new Map(); + + for (const textNode of doclingJson.texts || []) { + const pageNo = textNode.prov?.[0]?.page_no; + if (!pageNo) continue; + if (targetPages && !targetPages.includes(pageNo)) continue; + + if (!pagesMap.has(pageNo)) pagesMap.set(pageNo, { pageNo, texts: [], tables: [] }); + pagesMap.get(pageNo).texts.push(textNode); + } + + for (const tableNode of doclingJson.tables || []) { + const pageNo = tableNode.prov?.[0]?.page_no; + if (!pageNo) continue; + if (targetPages && !targetPages.includes(pageNo)) continue; + + if (!pagesMap.has(pageNo)) pagesMap.set(pageNo, { pageNo, texts: [], tables: [] }); + pagesMap.get(pageNo).tables.push(tableNode); + } + + const recipes = []; + const usedSlugs = new Map(); + const sortedPages = [...pagesMap.keys()].sort((a, b) => a - b); + + for (const pageNo of sortedPages) { + const page = pagesMap.get(pageNo); + const chapterInfo = getChapterForPage(pageNo); + + if (page.tables.length === 0) continue; + + for (const table of page.tables) { + const cells = table.data?.table_cells || []; + if (cells.length < 4) continue; + + const grid = new Map(); + let maxRow = 0; + let maxCol = 0; + for (const cell of cells) { + const r = cell.start_row_offset_idx; + const c = cell.start_col_offset_idx; + if (!grid.has(r)) grid.set(r, new Map()); + grid.get(r).set(c, cell.text?.trim() || ""); + if (r > maxRow) maxRow = r; + if (c > maxCol) maxCol = c; + } + + const headerRow = grid.get(0); + const isIngredientTable = headerRow && [...headerRow.values()].some((v) => /ingredients/i.test(v)); + if (!isIngredientTable) continue; + + const titleNode = page.texts.find( + (t) => t.label === "section_header" && !/procedure|chef's notes|notes|table of contents|scaling|baking/i.test(t.text) + ); + const title = titleNode ? titleNode.text.trim() : `Recipe Page ${pageNo}`; + let slugId = slugify(title); + if (usedSlugs.has(slugId)) { + const count = usedSlugs.get(slugId) + 1; + usedSlugs.set(slugId, count); + slugId = `${slugId}_p${pageNo}`; + } else { + usedSlugs.set(slugId, 1); + } + + const items = []; + let totalYieldGrams = null; + + for (let r = 1; r <= maxRow; r++) { + const row = grid.get(r); + if (!row) continue; + + const ingText = row.get(0) || ""; + const metricText = row.get(1) || ""; + const usText = row.get(2) || ""; + + if (/total weight/i.test(ingText)) { + const parsedTotal = parseMetricAmount(metricText); + if (parsedTotal) totalYieldGrams = parsedTotal.quantity; + continue; + } + + if (!ingText) continue; + if (ingText.endsWith(":") && !metricText && !usText) continue; + + const { name, notes: parenNotes } = cleanIngredientName(ingText); + const ingredientId = inferIngredientId(name); + const parsedMetric = parseMetricAmount(metricText); + + let notes = parenNotes; + let quantity = parsedMetric ? parsedMetric.quantity : 0; + let unitId = parsedMetric ? parsedMetric.unit_id : "gram"; + + if (quantity <= 0) { + const fallback = parseUsFallback(usText, name); + quantity = fallback.quantity; + unitId = fallback.unit_id; + if (fallback.notes) { + notes = notes ? `${notes} (${fallback.notes})` : fallback.notes; + } + } else if (usText && !notes) { + if (/[½¼¾t]/i.test(usText)) { + notes = usText; + } + } + + items.push({ + raw_name: ingText, + clean_name: name, + ingredient_id: ingredientId, + quantity, + unit_id: unitId, + us_measure: usText, + notes: notes || null, + basis_member: isFlourBasis(ingredientId, name), + }); + } + + if (items.length === 0) continue; + + const flourBasisWeight = items + .filter((i) => i.basis_member) + .reduce((sum, i) => sum + i.quantity, 0); + + const computedItems = items.map((item, idx) => { + let pct = null; + if (flourBasisWeight > 0 && item.quantity > 0) { + pct = Number(((item.quantity / flourBasisWeight) * 100).toFixed(2)); + } + return { + id: `line_${String(idx + 1).padStart(2, "0")}_${item.ingredient_id}`, + ingredient_id: item.ingredient_id, + name: titleCase(item.clean_name), + quantity: item.quantity, + unit_id: item.unit_id, + percentage: pct, + basis_member: item.basis_member, + notes: item.notes, + }; + }); + + const steps = []; + let inProcedure = false; + let inChefNotes = false; + const chefNotesList = []; + const narrativeTexts = []; + + for (const textNode of page.texts) { + const text = textNode.text.trim(); + if (/^procedure:?$/i.test(text)) { + inProcedure = true; + inChefNotes = false; + continue; + } + if (/^chef's notes:?$/i.test(text)) { + inProcedure = false; + inChefNotes = true; + continue; + } + + if (inProcedure) { + if (textNode.label === "list_item" || textNode.label === "text") { + steps.push({ + id: `step_${steps.length + 1}`, + order: steps.length + 1, + instruction: text, + equipment_ids: inferEquipment(text), + }); + } + } else if (inChefNotes) { + if (textNode.label === "list_item") { + chefNotesList.push(text); + } else if (textNode.label === "text" && !/^\d+$/.test(text)) { + narrativeTexts.push(text); + } + } + } + + const sumWeight = computedItems.reduce((s, i) => s + (i.unit_id === "gram" ? i.quantity : 0), 0); + const yieldQuantity = totalYieldGrams || (sumWeight > 0 ? sumWeight : 1000); + + recipes.push({ + id: slugId, + title, + page_no: pageNo, + chapter: chapterInfo.name, + summary: narrativeTexts.length > 0 ? narrativeTexts.join(" ") : null, + categories: [chapterInfo.category], + tags: ["pastry_chefs_little_black_book", chapterInfo.category, "classic"], + yield_quantity: Math.round(yieldQuantity * 100) / 100, + yield_unit_id: "gram", + yield_servings: null, + yield_basis: "theoretical", + yield: { + quantity: Math.round(yieldQuantity * 100) / 100, + unit_id: "gram", + basis: "theoretical", + }, + components: [ + { + id: "main", + name: "Main", + items: computedItems, + }, + ], + steps: steps.length > 0 ? steps : [{ id: "step_1", order: 1, instruction: "Prepare formulation according to standard pastry method.", equipment_ids: [] }], + notes: chefNotesList, + shelf_life: parseShelfLife(chefNotesList), + }); + } + } + + return recipes; +} + +// --------------------------------------------------------------------------- +// Batch Ingestion Runner +// --------------------------------------------------------------------------- +async function main() { + const args = process.argv.slice(2); + const isSave = args.includes("--save"); + + console.log("Loading Docling JSON from file.json..."); + const rawData = fs.readFileSync(doclingPath, "utf8"); + const doc = JSON.parse(rawData); + console.log(`Document loaded: ${doc.texts?.length || 0} texts, ${doc.tables?.length || 0} tables.`); + + const recipes = parseDoclingBook(doc); + console.log(`\nFound ${recipes.length} formulation(s) across 13 chapters.`); + + if (!isSave) { + console.log("Run with '--save' to commit to database."); + return; + } + + console.log(`\n💾 Ingesting ${recipes.length} recipes into Formulation database...`); + const { createMcpTools } = await import("../src/mcp/tools.ts"); + const { openDatabase, refreshSiteProjection } = await import("../src/lib/database.ts"); + const db = openDatabase({ readOnly: false }); + const tools = createMcpTools(() => db); + + try { + let newIngredientsCount = 0; + const recipeIds = []; + + for (let i = 0; i < recipes.length; i++) { + const recipe = recipes[i]; + + // Auto-provision lightweight ingredient records + for (const item of recipe.components[0].items) { + const exists = db.prepare("SELECT 1 FROM ingredients WHERE id = ?").get(item.ingredient_id); + if (!exists) { + tools.saveIngredient({ + id: item.ingredient_id, + name: item.name, + categories: ["pantry", "baking", "imported_stub"], + }); + newIngredientsCount++; + } + } + + const res = tools.saveRecipe(recipe); + recipeIds.push(res.recipe_id); + + if ((i + 1) % 50 === 0 || i + 1 === recipes.length) { + console.log(` [${i + 1}/${recipes.length}] Processed: ${recipe.title} (${res.recipe_id})`); + } + } + + // Create or update collection: "The Pastry Chef's Little Black Book (Vol. I)" + const collectionId = "the_pastry_chefs_little_black_book_vol_1"; + tools.saveRecipeBook({ + id: collectionId, + name: "The Pastry Chef's Little Black Book (Vol. I)", + description: "Classic culinary pastry reference by Michael Zebrowski & Michael Mignano (477 formulations across 13 chapters).", + recipe_ids: recipeIds, + }); + + refreshSiteProjection(db); + + console.log(`\n================================================================`); + console.log(`🎉 INGESTION COMPLETE!`); + console.log(`================================================================`); + console.log(` • Recipes Ingested: ${recipes.length}`); + console.log(` • New Ingredients Stubbed: ${newIngredientsCount}`); + console.log(` • Collection Created: The Pastry Chef's Little Black Book (Vol. I) (${collectionId})`); + console.log(` • Site Projection: Refreshed successfully`); + } catch (error) { + console.error("Ingestion failed:", error); + process.exit(1); + } finally { + db.close(); + } +} + +main().catch((err) => { + console.error("Ingestion error:", err); + process.exit(1); +}); diff --git a/src/lib/data.ts b/src/lib/data.ts index 48be68c..b7366e1 100644 --- a/src/lib/data.ts +++ b/src/lib/data.ts @@ -6,8 +6,8 @@ import type { Recipe, Unit, SourceMapping, -} from "./types"; -import { databaseProjection, openDatabase } from "./database"; +} from "./types.ts"; +import { databaseProjection, openDatabase } from "./database.ts"; export function loadCatalogs() { const database = openDatabase(); diff --git a/src/lib/database.ts b/src/lib/database.ts index 65df486..d440b27 100644 --- a/src/lib/database.ts +++ b/src/lib/database.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import { DatabaseSync } from "node:sqlite"; import { createSiteProjection, writeSiteProjection } from "../../scripts/lib/site-projection.mjs"; -import { readOnlyMode } from "./runtime"; +import { readOnlyMode } from "./runtime.ts"; export const databasePath = path.resolve(process.cwd(), "var/recipe-book.sqlite"); export const refreshSiteProjection = (database: DatabaseSync) => writeSiteProjection(database); diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index f6a926b..a86c3d6 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -192,18 +192,22 @@ export function createMcpTools(getDb: () => DatabaseSync) { const db = getDb(); let recipeId = args.id; - const isNew = !recipeId; + const existing = recipeId ? (db.prepare("SELECT save_version FROM recipes WHERE id = ?").get(recipeId) as any) : null; + const isNew = !existing; - if (!recipeId) { - const base = args.title - .toLowerCase() - .normalize("NFKD") - .replace(/[^a-z0-9]+/g, "_") - .replace(/^_+|_+$/g, "") || "recipe"; - recipeId = base; - let suffix = 2; - while (db.prepare("SELECT 1 FROM recipes WHERE id = ?").get(recipeId)) { - recipeId = `${base}_${suffix++}`; + if (isNew) { + if (!recipeId) { + const base = args.title + .toLowerCase() + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, "") || "recipe"; + recipeId = base; + let suffix = 2; + while (db.prepare("SELECT 1 FROM recipes WHERE id = ?").get(recipeId)) { + recipeId = `${base}_${suffix++}`; + } } db.prepare( @@ -230,7 +234,7 @@ export function createMcpTools(getDb: () => DatabaseSync) { } const structure = { - save_version: isNew ? 0 : (db.prepare("SELECT save_version FROM recipes WHERE id = ?").get(recipeId) as any).save_version, + save_version: isNew ? 0 : existing.save_version, metadata: { title: args.title, yield_quantity: args.yield_quantity, @@ -264,6 +268,26 @@ export function createMcpTools(getDb: () => DatabaseSync) { saveRecipeStructure(db, recipeId, structure as any); + // Update recipe-level attributes if provided + const updates: string[] = []; + const params: any[] = []; + if (args.summary !== undefined) { + updates.push("summary = ?"); + params.push(args.summary); + } + if (args.categories) { + updates.push("categories_json = ?"); + params.push(JSON.stringify(args.categories)); + } + if (args.notes) { + updates.push("notes_json = ?"); + params.push(JSON.stringify(args.notes)); + } + if (updates.length > 0) { + params.push(recipeId); + db.prepare(`UPDATE recipes SET ${updates.join(", ")} WHERE id = ?`).run(...params); + } + return { success: true, recipe_id: recipeId,