diff --git a/README.md b/README.md index 4b996ca..4b2a772 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,141 @@ -# Obsidian-Grocy-Bridge +# πŸ›’ Grocy Shopping List Bridge -Send ingredients from your Obsidian recipe notes straight into your grocy.info shopping list. \ No newline at end of file +Send ingredients from your Obsidian recipe notes straight into your [Grocy](https://grocy.info) shopping list. +Works seamlessly with other recipe plugins β€” supports fuzzy product matching, auto-matching with thresholds, free-text items, and manual overrides. + +--- + +## πŸš€ Quick Start +1. **Install**: Copy this plugin into `.obsidian/plugins/grocy-shoppinglist-bridge/` and enable it in Obsidian. +2. **Configure**: In plugin settings, set your **Grocy URL** and **API Key**. +3. **Use**: Open a recipe note β†’ click the πŸ›’ ribbon button β†’ ingredients appear in your Grocy shopping list. + +--- + +## ✨ Features +- Parse `## Ingredients` sections automatically (supports nested subheadings like `### Sauce`, `#### Marinade`). +- Smart ingredient normalization: + - `1 large egg` β†’ **eggs** + - `3 cloves garlic (minced)` β†’ **garlic** +- Fuzzy matching against Grocy's products with adjustable confidence. +- Multiple fallback strategies when no match is found. +- Manual overrides via inline annotations (`grocy_id`, `grocy:`). +- Ribbon button + command palette support. +- Works out-of-the-box with [Recipe Grabber](https://github.com/seethroughdev/obsidian-recipe-grabber). + Imported recipes are parsed automatically. +- Plays nicely with [Recipe View](https://obsidian-recipe-view.readthedocs.io/en/latest/). + If you use the **HTML comment syntax** (``), Recipe View will hide the annotation from the rendered card. + +--- + +## πŸ“₯ Installation +1. Download the repo. +2. Copy into your vault: + ``` + .obsidian/plugins/grocy-shoppinglist-bridge/ + ``` +3. Reload community plugins in Obsidian. +4. Enable **Grocy Shopping List Bridge**. + +--- + +## πŸ“‘ Usage + +### Recipe note example +```markdown +## Ingredients +- 1 large egg +- 3 cloves garlic (minced) +- 1 broccoli crown + +### Sauce +- 2 tbsp soy sauce +- 1 tsp sesame oil + +## Directions +Mix and cook... +``` + +- Click the **πŸ›’ ribbon button** or run: + ``` + Grocy: Add ingredients to shopping list + ``` +- All bullet points under **Ingredients** (and its subheadings) are added. +- Stops before the next top-level section (e.g. `## Directions`). + +--- + +## βš™οΈ Settings + +| Setting | Description | +|---------|-------------| +| **Grocy URL** | Base URL of your Grocy (e.g. `http://localhost/grocy` or `https://your-domain/grocy`) | +| **API Key** | Create in Grocy β†’ Settings β†’ Manage API keys | +| **Shopping List ID** | Usually `1` unless you have multiple lists | +| **Auto-confirm unique match** | If only one candidate matches, add automatically | +| **Auto-match threshold** | Default `6` β€” top candidate score required for auto-add | +| **Require score margin** | Default `2` β€” top candidate must be this many points above runner-up | +| **Debug matching** | Log candidate scores & decisions to Obsidian console | +| **No-match fallback** | What to do when no good product is found:
β€’ **Ask** (chooser + free-text option)
β€’ **Prompt free-text** (type a note)
β€’ **Free-text** (auto-add note)
β€’ **Skip** | + +--- + +## πŸ–ŠοΈ Forcing Matches (Annotations) + +If you want exact control over which Grocy product is used: + +### By Grocy ID +```markdown +- 2 tomatoes (grocy_id: 42) +``` + +Or with an HTML comment (**hidden in Recipe View**): +```markdown +- 2 tomatoes +``` +> ⚠️ Recipe View will display clean ingredients without showing the `grocy_id` comment, if using the HTML notation. + +### By Product Name +```markdown +- 1 chili pepper (grocy: jalapeΓ±o) +``` +This forces the matcher to look for β€œjalapeΓ±o” specifically. + +--- + +## πŸš€ Example Workflows + +### Fully Automated +- Auto-confirm unique = βœ… +- Threshold = `6`, Margin = `2` +- Fallback = **Free-text** + +β†’ Most items auto-add. Unmatched lines go in as notes. + +--- + +### Semi-Automated +- Auto-confirm unique = βœ… +- Fallback = **Ask** + +β†’ Confident matches auto-add, ambiguous ones ask you. + +--- + +### Manual Control +- Auto-confirm unique = ❌ +- Fallback = **Ask** + +β†’ You confirm every item via chooser. + +--- + +## πŸ” Debugging +- Enable **Debug matching** in settings. +- Open **View β†’ Toggle Developer Tools β†’ Console** in Obsidian. +- You'll see logs like: + ``` + Match: 1 large egg β†’ needle: eggs + top: {id:12, name:"Eggs", score:11} + Auto-matched β†’ Eggs + ``` diff --git a/main.js b/main.js new file mode 100644 index 0000000..85af45e --- /dev/null +++ b/main.js @@ -0,0 +1,472 @@ + +'use strict'; +const { MarkdownView, Notice, Plugin, PluginSettingTab, Setting, FuzzySuggestModal, Modal } = require('obsidian'); + +const DEFAULT_SETTINGS = { + grocyUrl: "", + apiKey: "", + shoppingListId: 1, + autoConfirmUnique: true, + strictMode: false, + productCacheTtlMins: 240, + personalMap: {}, + noMatchFallback: "ask", // "ask" | "freeText" | "promptFreeText" | "skip" + autoMatchThreshold: 6, + autoMatchMargin: 2, + debugMatching: false +}; + +function normalizeWhitespace(s){ return s.replace(/\s+/g," ").trim(); } + +function parseAmount(raw){ + if(!raw) return null; + const map = {"ΒΌ":"1/4","Β½":"1/2","ΒΎ":"3/4","⅐":"1/7","β…‘":"1/9","β…’":"1/10","β…“":"1/3","β…”":"2/3","β…•":"1/5","β…–":"2/5","β…—":"3/5","β…˜":"4/5","β…™":"1/6","β…š":"5/6","β…›":"1/8","β…œ":"3/8","⅝":"5/8","β…ž":"7/8"}; + let s = (raw.split(/\s+/)[0]||"").replace(/[ΒΌ-¾⅐-β…ž]/g, m=>map[m]||m); + let m; + m = /^(\d+)\s+(\d+)\/(\d+)$/.exec(s); + if(m){ const w=+m[1], n=+m[2], d=+m[3]; return d? w + n/d : w; } + m = /^(\d+)\/(\d+)$/.exec(s); + if(m){ const n=+m[1], d=+m[2]; return d? n/d : null; } + if(/^\d+(\.\d+)?$/.test(s)) return parseFloat(s); + return null; +} + +// Maps and lists for normalization +const SYNONYMS = new Map([ + ["broccoli crown","broccoli"], + ["broccoli crowns","broccoli"], + ["chicken breasts","chicken breast"], + ["chicken breast","chicken breast"], + ["spring onion","green onion"], + ["scallion","green onion"], + ["powdered sugar","icing sugar"], + ["confectioners sugar","icing sugar"], + ["corn starch","cornstarch"], + ["garlic clove","garlic"], + ["garlic cloves","garlic"], + ["clove garlic","garlic"], + ["cloves garlic","garlic"], + ["egg","eggs"], // treat singular as product "eggs" +]); + +const SIZE_PREP_WORDS = new Set([ + "small","medium","large","xl","extra","extra-large","jumbo", + "minced","chopped","diced","sliced","crushed","grated","ground","peeled","toasted", + "fresh","frozen","boneless","skinless","regular" +]); + +function baseClean(s){ + s = s.toLowerCase(); + s = s.replace(/[^\p{L}\p{N}\s]/gu," "); + s = normalizeWhitespace(s); + return s; +} + +function normalizeIngredientName(s){ + s = baseClean(s); + // Collapse common "clove(s) of garlic" patterns to "garlic" + s = s.replace(/\bcloves?\s+of\s+garlic\b/g, "garlic"); + s = s.replace(/\b(cloves?|clove)\s+garlic\b/g, "garlic"); + s = s.replace(/\bgarlic\s+(cloves?|clove)\b/g, "garlic"); + // Remove size/prep adjectives + s = s.split(/\s+/).filter(w=>!SIZE_PREP_WORDS.has(w)).join(" "); + s = normalizeWhitespace(s); + // Apply synonyms whole-phrase if match + if (SYNONYMS.has(s)) s = SYNONYMS.get(s); + // Singularβ†’plural simple rule for egg + s = s.replace(/\begg\b/g, "eggs"); + return s; +} + +function headword(s){ + // choose the last token as head after removing adjectives; if "of" pattern present, take rightmost token + const tokens = s.split(/\s+/).filter(Boolean); + if (tokens.length===0) return ""; + return tokens[tokens.length-1]; +} + +function variantsFor(needle){ + const v = new Set(); + v.add(needle); + // simple plural/singular flips + if (needle.endsWith("s")) v.add(needle.replace(/s$/,"")); + else v.add(needle+"s"); + // eggs special-case already handled + return Array.from(v); +} + +function stripQuantityAndNotes(line){ + let s = line.replace(/^\s*[-*+]\s*/, ""); + const htmlAnn = //.exec(s); + let annotId = htmlAnn ? parseInt(htmlAnn[1],10) : undefined; + let annotName; + s = s.replace(/\((grocy|grocy_id)\s*:\s*([^)]+)\)/gi, (m, key, val) => { + if (/^grocy_id$/i.test(key)) { + const n = parseInt(String(val).trim(), 10); + if (!isNaN(n)) annotId = n; + } else { + annotName = String(val).trim(); + } + return ""; + }); + const amountMatch = s.match(/^(\d+\s+\d+\/\d+|\d+\/\d+|\d+(\.\d+)?)/); + const amount = amountMatch ? parseAmount(amountMatch[1]) : null; + s = s.replace(/^(\d+\s+\d+\/\d+|\d+\/\d+|\d+(\.\d+)?)(\s*[A-Za-z]+\.?)?\s+/,''); + s = s.replace(/\([^)]+\)/g, ''); + s = s.replace(/[-,–—]\s*\b.*$/,''); + s = normalizeIngredientName(s); + s = s.replace(/\bof\b/g, "").trim(); + return { amount, name: s, annotation: { id: annotId, name: annotName } }; +} + +function scoreCandidate(needle, product){ + const p = baseClean(product.name||""); + const nTokens = needle.split(/\s+/).filter(Boolean); + let score = 0; + for(const t of nTokens){ + if(t.length<2) continue; + if(p.includes(t)) score += 2; + if(p.startsWith(t)) score += 1; + } + // Headword boost + const head = headword(needle); + for(const hv of variantsFor(head)){ + if(p===hv) score += 6; // exact match on headword strong + else if(p.startsWith(hv)) score += 2; + } + if(p===needle) score += 5; + return score; +} + +class GrocyApi { + constructor(app, settings){ + this.app = app; + this.settings = settings; + this.productCache = null; + } + url(path){ return this.settings.grocyUrl.replace(/\/+$/,'') + path; } + async json(path, init){ + const headers = { "GROCY-API-KEY": this.settings.apiKey }; + if (init && init.method && init.method.toUpperCase()!=="GET") headers["Content-Type"]="application/json"; + const resp = await fetch(this.url(path), Object.assign({}, init||{}, { headers: Object.assign({}, headers, (init&&init.headers)||{}) })); + if(!resp.ok){ + const text = await resp.text().catch(()=>"(no body)"); + throw new Error(`Grocy API error ${resp.status}: ${text}`); + } + if (resp.status === 204) return null; + const text = await resp.text(); + if (!text || text.trim().length === 0) return null; + try { return JSON.parse(text); } catch(e){ return text; } + } + async fetchProducts(force){ + const now = Date.now(); + if(!force && this.productCache && (now - this.productCache.fetchedAt) < this.settings.productCacheTtlMins*60000){ + return this.productCache.products; + } + const products = await this.json("/api/objects/products"); + this.productCache = { fetchedAt: Date.now(), products: Array.isArray(products)?products:[] }; + return this.productCache.products; + } + async addToShoppingList(product_id, amount, note){ + const body = { + shopping_list_id: this.settings.shoppingListId, + product_id: product_id, + note: note || "" + }; + if (amount && amount > 0) body.amount = String(amount); + if (this.settings.debugMatching) console.debug("Grocy add product payload", body); + await this.json("/api/objects/shopping_list", { method: "POST", body: JSON.stringify(body) }); + } + async addFreeTextItem(note){ + const body = { shopping_list_id: this.settings.shoppingListId, note }; + if (this.settings.debugMatching) console.debug("Grocy add free-text payload", body); + await this.json("/api/objects/shopping_list", { method: "POST", body: JSON.stringify(body) }); + } +} + +class FreeTextModal extends Modal { + constructor(app, placeholder, initial, onSubmit){ + super(app); + this.placeholder = placeholder; + this.initial = initial || ""; + this.onSubmit = onSubmit; + } + onOpen(){ + const { contentEl, titleEl } = this; + titleEl.setText("Add as free-text item"); + const p = contentEl.createEl("p"); + p.setText("Type what you want to appear on the shopping list:"); + const input = contentEl.createEl("input", { type: "text" }); + input.style.width = "100%"; + input.placeholder = this.placeholder || ""; + input.value = this.initial; + const btns = contentEl.createDiv(); + const ok = btns.createEl("button", { text: "Add to list" }); + const cancel = btns.createEl("button", { text: "Cancel" }); + cancel.style.marginLeft = "0.5rem"; + ok.addEventListener("click", ()=>{ this.onSubmit(input.value.trim()); this.close(); }); + cancel.addEventListener("click", ()=> this.close()); + input.addEventListener("keydown", (e)=>{ + if(e.key==="Enter"){ this.onSubmit(input.value.trim()); this.close(); } + }); + setTimeout(()=>input.focus(), 0); + } + onClose(){ this.contentEl.empty(); } +} + +class ChoiceModal extends FuzzySuggestModal { + constructor(app, originalLine, items, onChoose, onFreeText){ + super(app); + this.originalLine = originalLine; + this.items = [{__cancel:true, label:"Cancel"}].concat([{__free:true, label:`βž• Add as free-text: β€œ${originalLine}”`, value: originalLine}], items); + this.onChooseCb = onChoose; + this.onFreeTextCb = onFreeText; + this.setPlaceholder(`Match product for: ${originalLine}`); + } + getItems(){ return this.items; } + getItemText(item){ + if(item.__cancel) return item.label; + if(item.__free) return item.label; + return item.name || String(item.id); + } + onChooseItem(item){ + if(item.__cancel){ if(app.plugins.getPlugin('grocy-shoppinglist-bridge')?.settings.debugMatching) console.debug("Chooser: cancel"); return this.onFreeTextCb(null); } + if(item.__free){ + if(app.plugins.getPlugin('grocy-shoppinglist-bridge')?.settings.debugMatching) console.debug("Chooser: free-text start"); + new FreeTextModal(this.app, this.originalLine, this.originalLine, (val)=>{ + if(app.plugins.getPlugin('grocy-shoppinglist-bridge')?.settings.debugMatching) console.debug("Chooser: free-text chosen", val); + this.onFreeTextCb(val||this.originalLine); + }).open(); + return; + } + if(app.plugins.getPlugin('grocy-shoppinglist-bridge')?.settings.debugMatching) console.debug("Chooser: product chosen", item); + this.onChooseCb(item); + } +} + +module.exports = class GrocyBridgePlugin extends Plugin { + async onload(){ + this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); + this.api = new GrocyApi(this.app, this.settings); + + this.addRibbonIcon("shopping-cart", "Grocy: Add ingredients to shopping list", async () => { + try { await this.addIngredientsFromActiveNote(); } + catch(e){ console.error(e); new Notice(`Grocy error: ${e.message||e}`); } + }); + + this.addCommand({ + id: "grocy-add-ingredients", + name: "Grocy: Add ingredients to shopping list", + callback: async () => { + try { await this.addIngredientsFromActiveNote(); } + catch(e){ console.error(e); new Notice(`Grocy error: ${e.message||e}`); } + } + }); + + this.addCommand({ + id: "grocy-refresh-products", + name: "Grocy: Refresh product cache", + callback: async () => { + await this.api.fetchProducts(true); + new Notice("Grocy products refreshed."); + } + }); + + this.addSettingTab(new (class extends PluginSettingTab { + constructor(app, plugin){ super(app, plugin); this.plugin = plugin; } + display(){ + const containerEl = this.containerEl; + containerEl.empty(); + containerEl.createEl("h2", { text: "Grocy Shopping List Bridge" }); + new Setting(containerEl).setName("Grocy URL").setDesc("e.g., https://grocy.local or https://your-domain/grocy") + .addText(t => t.setPlaceholder("https://grocy.example.com").setValue(this.plugin.settings.grocyUrl) + .onChange(async (v)=>{ this.plugin.settings.grocyUrl = v.trim(); await this.plugin.saveData(this.plugin.settings); })); + new Setting(containerEl).setName("API Key").setDesc("Create in Grocy β†’ Settings β†’ Manage API keys") + .addText(t => t.setPlaceholder("your-api-key").setValue(this.plugin.settings.apiKey) + .onChange(async (v)=>{ this.plugin.settings.apiKey = v.trim(); await this.plugin.saveData(this.plugin.settings); })); + new Setting(containerEl).setName("Shopping List ID").setDesc("Usually 1 unless you use multiple lists") + .addText(t => t.setPlaceholder("1").setValue(String(this.plugin.settings.shoppingListId)) + .onChange(async (v)=>{ const n = parseInt(v,10); if(!isNaN(n)) this.plugin.settings.shoppingListId = n; await this.plugin.saveData(this.plugin.settings); })); + new Setting(containerEl).setName("Auto-confirm unique match").setDesc("If only one product looks right, add without asking.") + .addToggle(t => t.setValue(this.plugin.settings.autoConfirmUnique) + .onChange(async (v)=>{ this.plugin.settings.autoConfirmUnique = v; await this.plugin.saveData(this.plugin.settings); })); + + containerEl.createEl("h3", { text: "Automatic matching" }); + new Setting(containerEl).setName("Auto-match threshold").setDesc("Auto-add if top candidate score β‰₯ this (default 6).") + .addText(t => t.setPlaceholder("6").setValue(String(this.plugin.settings.autoMatchThreshold)) + .onChange(async (v)=>{ const n = parseInt(v,10); if(!isNaN(n)) this.plugin.settings.autoMatchThreshold = n; await this.plugin.saveData(this.plugin.settings); })); + new Setting(containerEl).setName("Require score margin").setDesc("Top must be this many points above 2nd (default 2).") + .addText(t => t.setPlaceholder("2").setValue(String(this.plugin.settings.autoMatchMargin)) + .onChange(async (v)=>{ const n = parseInt(v,10); if(!isNaN(n)) this.plugin.settings.autoMatchMargin = n; await this.plugin.saveData(this.plugin.settings); })); + new Setting(containerEl).setName("Debug matching").setDesc("Log candidate scores and decisions to Console.") + .addToggle(t => t.setValue(this.plugin.settings.debugMatching) + .onChange(async (v)=>{ this.plugin.settings.debugMatching = v; await this.plugin.saveData(this.plugin.settings); })); + + containerEl.createEl("h3", { text: "No-match fallback" }); + new Setting(containerEl).setName("When an item doesn't match a product").setDesc("Choose what to do if no product is found or you skip selection.") + .addDropdown(dd => { + dd.addOption("ask","Ask (show chooser; can add free-text)"); + dd.addOption("promptFreeText","Prompt me to type free-text"); + dd.addOption("freeText","Create free-text automatically"); + dd.addOption("skip","Skip the item"); + dd.setValue(this.plugin.settings.noMatchFallback || "ask"); + dd.onChange(async (v)=>{ this.plugin.settings.noMatchFallback = v; await this.plugin.saveData(this.plugin.settings); }); + }); + } + })(this.app, this)); + } + + async addIngredientsFromActiveNote(){ + const view = this.app.workspace.getActiveViewOfType(MarkdownView); + if(!view){ new Notice("Open a recipe note first."); return; } + const content = await this.app.vault.read(view.file); + const ingredients = this.extractIngredients(content); + if(ingredients.length===0){ new Notice("No ingredients found under 'Ingredients'."); return; } + + const products = await this.api.fetchProducts(false); + if(!products.length){ + new Notice("Grocy: product list is empty β€” check API key / URL in settings."); + } + const added = []; + const unresolved = []; + + for(const ing of ingredients){ + if(ing.annotationProductId){ + await this.api.addToShoppingList(ing.annotationProductId, ing.amount||null, ing.original); + added.push(`${ing.original} β†’ #${ing.annotationProductId}`); + continue; + } + const mapHitId = (this.settings.personalMap||{})[ing.cleanName.toLowerCase()]; + if(mapHitId){ + await this.api.addToShoppingList(mapHitId, ing.amount||null, ing.original); + added.push(`${ing.original} β†’ #${mapHitId} (mapped)`); + continue; + } + // Build candidate list with scores + const needle = ing.annotationProductName ? normalizeIngredientName(ing.annotationProductName) : normalizeIngredientName(ing.cleanName); + let pool = products; + const hw = headword(needle); + if(hw) pool = pool.filter(p => baseClean(p.name||"").includes(hw)) || products; + let scored = pool.map(p => ({ p, s: scoreCandidate(needle, p) })); + scored.sort((a,b)=>b.s-a.s); + if (this.settings.debugMatching) console.debug("Match:", ing.original, "needle:", needle, "top:", scored.slice(0,5).map(x=>({id:x.p.id,name:x.p.name,s:x.s}))); + + // Auto-confirm unique or high-confidence + if(this.settings.strictMode && !ing.annotationProductName){ + unresolved.push({ ing, candidates: scored.slice(0,20).map(x=>x.p) }); + continue; + } + const top = scored[0]; + const second = scored[1]; + if(top && this.settings.autoMatchThreshold>0 && top.s >= this.settings.autoMatchThreshold){ + const marginOk = (!second) || (top.s - second.s >= this.settings.autoMatchMargin); + if(marginOk){ + await this.api.addToShoppingList(top.p.id, ing.amount||null, ing.original); + added.push(`${ing.original} β†’ ${top.p.name} (auto)`); + if (this.settings.debugMatching) console.debug("Auto-matched:", ing.original, "β†’", top.p.name, "score:", top.s, "second:", second?.s); + continue; + } + } + const candidates = scored.slice(0,20).map(x=>x.p); + if(candidates.length===1 && this.settings.autoConfirmUnique){ + await this.api.addToShoppingList(candidates[0].id, ing.amount||null, ing.original); + added.push(`${ing.original} β†’ ${candidates[0].name}`); + } else { + unresolved.push({ ing, candidates }); + } + } + + for(const u of unresolved){ + const choice = await new Promise((resolve)=>{ + if(this.settings.noMatchFallback==="ask"){ + const modal = new ChoiceModal( + this.app, + u.ing.original, + u.candidates.length?u.candidates:products, + (p)=> resolve(p), // onChoose -> product + (txt)=> resolve(txt===null? null : { __free:true, text: txt }) // free-text or cancel(null) + ); + modal.open(); + } else { + resolve(null); + } + }); + + if(choice && choice.__free){ + if (this.settings.debugMatching) console.debug("Adding as free-text (user) β†’", choice.text); + await this.api.addFreeTextItem(choice.text || u.ing.original); + added.push(`${u.ing.original} β†’ free-text item`); + } else if(choice){ + if (this.settings.debugMatching) console.debug("Adding selected product β†’", choice.id, choice.name); + await this.api.addToShoppingList(choice.id, u.ing.amount||null, u.ing.original); + added.push(`${u.ing.original} β†’ ${choice.name}`); + } else { + if(this.settings.noMatchFallback==="freeText"){ + if (this.settings.debugMatching) console.debug("Fallback: auto free-text β†’", u.ing.original); + await this.api.addFreeTextItem(u.ing.original); + added.push(`${u.ing.original} β†’ free-text item`); + } else if(this.settings.noMatchFallback==="promptFreeText"){ + await new Promise((res)=>{ + new FreeTextModal(this.app, u.ing.original, u.ing.original, async (val)=>{ if(val){ if (this.settings.debugMatching) console.debug("Prompt free-text chosen β†’", val); await this.api.addFreeTextItem(val); added.push(`${u.ing.original} β†’ free-text item`);} res(null); }).open(); + }); + } else { + new Notice(`Skipped: ${u.ing.original}`); + } + } + } + + if(added.length) new Notice(`Added ${added.length} item(s) to Grocy.`); + else new Notice("No items were added."); + } + + extractIngredients(md){ + const lines = md.split(/\r?\n/); + let items = []; + let inIngredients = false; + let startLevel = null; + + for (let i=0; i