'use strict'; const { MarkdownView, Notice, Plugin, PluginSettingTab, Setting, FuzzySuggestModal, Modal, TFile } = 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, followLinksIfNoIngredients: true, linkFollowDepth: 1, omitNoteWhenAnnotated: 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; } const SYNONYMS = new Map([ ["broccoli crown","broccoli"], ["broccoli crowns","broccoli"], ["chicken breasts","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"] ]); 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," "); return normalizeWhitespace(s); } function normalizeIngredientName(s){ s = baseClean(s); 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"); s = s.split(/\s+/).filter(w=>!SIZE_PREP_WORDS.has(w)).join(" "); if (SYNONYMS.has(s)) s = SYNONYMS.get(s); s = s.replace(/\begg\b/g, "eggs"); return normalizeWhitespace(s); } function headword(s){ const tokens = s.split(/\s+/).filter(Boolean); return tokens.length ? tokens[tokens.length-1] : ""; } function variantsFor(needle){ const v = new Set(); v.add(needle); if (needle.endsWith("s")) v.add(needle.replace(/s$/,"")); else v.add(needle+"s"); 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; } const head = headword(needle); for(const hv of variantsFor(head)){ if(p===hv) score += 6; 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), { ...init, headers }); if(!resp.ok){ throw new Error(`Grocy API error ${resp.status}: ${await resp.text()}`); } if (resp.status === 204) return null; const text = await resp.text(); if (!text.trim()) return null; return JSON.parse(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, opts){ const body = { shopping_list_id: this.settings.shoppingListId, product_id }; if (!(opts && opts.omitNote)) body.note = note || ""; if (amount && amount > 0) body.amount = String(amount); 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 }; 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: "Link following (if no Ingredients)" }); new Setting(containerEl).setName("Follow links when note has no Ingredients").setDesc("If enabled, the plugin will traverse linked notes and collect their Ingredients.") .addToggle(t => t.setValue(this.plugin.settings.followLinksIfNoIngredients) .onChange(async (v)=>{ this.plugin.settings.followLinksIfNoIngredients = v; await this.plugin.saveData(this.plugin.settings); })); new Setting(containerEl).setName("Link follow depth").setDesc("How many levels of links to traverse (default 1).") .addText(t => t.setPlaceholder("1").setValue(String(this.plugin.settings.linkFollowDepth)) .onChange(async (v)=>{ const n = parseInt(v,10); if(!isNaN(n) && n>=0 && n<=5) this.plugin.settings.linkFollowDepth = n; await this.plugin.saveData(this.plugin.settings); })); new Setting(containerEl) .setName("Omit note for annotated items") .setDesc("If enabled, items with grocy_id annotations will be added without a note.") .addToggle(t => t .setValue(this.plugin.settings.omitNoteWhenAnnotated) .onChange(async (v)=>{ this.plugin.settings.omitNoteWhenAnnotated = 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 file = view.file; const content = await this.app.vault.read(file); let ingredients = this.extractIngredients(content); // If no Ingredients here, optionally follow links if (ingredients.length === 0 && this.settings.followLinksIfNoIngredients) { const collected = await this.collectFromLinkedNotes(file, this.settings.linkFollowDepth); ingredients = collected; if (ingredients.length === 0) { new Notice("No ingredients found in this note or its linked notes."); return; } else { new Notice(`Collected ${ingredients.length} ingredient item(s) from linked notes.`); } } else 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."); } // TRACKING const succeeded = []; const skipped = []; const failed = []; const unresolved = []; // PROCESS EACH INGREDIENT for (const ing of ingredients) { // 1) If annotation present if (typeof ing.annotationProductId !== "undefined") { // Explicit skip if grocy_id: 0 if (ing.annotationProductId === 0) { if (this.settings.debugMatching) console.debug("Skipping item due to grocy_id:0:", ing.original); skipped.push(`${ing.original} -> skipped (grocy_id:0)`); continue; } // Annotated product, attempt to add (respect omitNoteWhenAnnotated) try { await this.api.addToShoppingList( ing.annotationProductId, ing.amount || null, this.settings.omitNoteWhenAnnotated ? null : ing.original, { omitNote: this.settings.omitNoteWhenAnnotated } ); succeeded.push( `${ing.original} → #${ing.annotationProductId}${this.settings.omitNoteWhenAnnotated ? " (no note)" : ""}` ); } catch (err) { failed.push(`${ing.original} → FAILED: ${err.message || err}`); console.error("Grocy add failed for annotated product", err); } continue; } // 2) If personal map hit const mapHitId = (this.settings.personalMap || {})[ing.cleanName.toLowerCase()]; if (mapHitId) { try { await this.api.addToShoppingList(mapHitId, ing.amount || null, ing.original); succeeded.push(`${ing.original} → #${mapHitId} (mapped)`); } catch (err) { failed.push(`${ing.original} → FAILED: ${err.message || err}`); console.error("Grocy add failed for mapped product", err); } continue; } // 3) Fuzzy matching 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 }))); } // Strict mode handling 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]; // Auto-match if confident if (top && this.settings.autoMatchThreshold > 0 && top.s >= this.settings.autoMatchThreshold) { const marginOk = (!second) || (top.s - second.s >= this.settings.autoMatchMargin); if (marginOk) { try { await this.api.addToShoppingList(top.p.id, ing.amount || null, ing.original); succeeded.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); } catch (err) { failed.push(`${ing.original} → FAILED: ${err.message || err}`); console.error("Grocy add failed for auto-match", err); } continue; } } // If only one candidate and autoConfirmUnique const candidates = scored.slice(0, 20).map(x => x.p); if (candidates.length === 1 && this.settings.autoConfirmUnique) { try { await this.api.addToShoppingList(candidates[0].id, ing.amount || null, ing.original); succeeded.push(`${ing.original} → ${candidates[0].name}`); } catch (err) { failed.push(`${ing.original} → FAILED: ${err.message || err}`); console.error("Grocy add failed for unique candidate", err); } } else { // ambiguous -> push to unresolved for chooser/fallback unresolved.push({ ing, candidates }); } } // end for each ingredient // HANDLE UNRESOLVED (chooser / fallback) 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), (txt) => resolve(txt === null ? null : { __free: true, text: txt }) ); modal.open(); } else { // fallback mode: resolve null so we handle fallback below resolve(null); } }); if (choice && choice.__free) { // user supplied free-text try { await this.api.addFreeTextItem(choice.text || u.ing.original); succeeded.push(`${u.ing.original} → free-text item`); } catch (err) { failed.push(`${u.ing.original} → FAILED: ${err.message || err}`); console.error("Grocy add failed for free-text from chooser", err); } } else if (choice) { // product chosen try { await this.api.addToShoppingList(choice.id, u.ing.amount || null, u.ing.original); succeeded.push(`${u.ing.original} → ${choice.name}`); } catch (err) { failed.push(`${u.ing.original} → FAILED: ${err.message || err}`); console.error("Grocy add failed for chosen product", err); } } else { // No choice made (modal cancelled) OR noMatchFallback != ask if (this.settings.noMatchFallback === "freeText") { try { await this.api.addFreeTextItem(u.ing.original); succeeded.push(`${u.ing.original} → free-text item`); } catch (err) { failed.push(`${u.ing.original} → FAILED: ${err.message || err}`); console.error("Grocy add failed for fallback free-text", err); } } else if (this.settings.noMatchFallback === "promptFreeText") { // prompt user with modal (blocking promise) await new Promise((res) => { new FreeTextModal(this.app, u.ing.original, u.ing.original, async (val) => { if (val) { try { await this.api.addFreeTextItem(val); succeeded.push(`${u.ing.original} → free-text item`); } catch (err) { failed.push(`${u.ing.original} → FAILED: ${err.message || err}`); console.error("Grocy add failed for prompted free-text", err); } } else { // user canceled the prompt - treat as skipped or nothing skipped.push(`${u.ing.original} -> skipped (prompt canceled)`); } res(); }).open(); }); } else { // skip the item skipped.push(`${u.ing.original} -> skipped (no-match fallback)`); } } } // end unresolved loop // FINAL SUMMARY const totalAdded = succeeded.length; const totalSkipped = skipped.length; const totalFailed = failed.length; let msg = `Added ${totalAdded} item(s) to Grocy.`; if (totalSkipped) msg += ` Skipped ${totalSkipped} item(s).`; if (totalFailed) msg += ` ${totalFailed} failed (see console).`; if (totalAdded || totalSkipped || totalFailed) { new Notice(msg); } else { new Notice("No items were added or skipped."); } if (this.settings.debugMatching) { console.debug("Grocy summary - succeeded:", succeeded); console.debug("Grocy summary - skipped:", skipped); console.debug("Grocy summary - failed:", failed); } } async collectFromLinkedNotes(srcFile, maxDepth){ const app = this.app; const visited = new Set([srcFile.path]); const queue = [{ file: srcFile, depth: 0 }]; const allIngredients = []; while(queue.length){ const { file, depth } = queue.shift(); if(depth>maxDepth) continue; const cache = app.metadataCache.getFileCache(file) || {}; const links = (cache.links||[]).concat(cache.embeds||[]); for(const l of links){ const linkText = l.link || l.original || l; const target = app.metadataCache.getFirstLinkpathDest(linkText, file.path); if(target && target instanceof TFile && target.extension==="md" && !visited.has(target.path)){ visited.add(target.path); const content = await app.vault.read(target); const ings = this.extractIngredients(content); if(ings.length) allIngredients.push(...ings); if(depth < maxDepth){ queue.push({ file: target, depth: depth+1 }); } } } } return allIngredients; } extractIngredients(md){ const lines = md.split(/\r?\n/); let items = []; let inIngredients = false; let startLevel = null; for (let i=0; i