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