From 1850c7e785dd16828272c173e6e53b02b7954e03 Mon Sep 17 00:00:00 2001 From: Frederik Baerentsen Date: Tue, 16 Sep 2025 17:10:24 +0200 Subject: [PATCH] V1.0.2: Added option to skip item if --- README.md | 24 +++++- main.js | 223 +++++++++++++++++++++++++++++++++++++------------- manifest.json | 2 +- versions.json | 2 +- 4 files changed, 190 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index 4c89fff..7c570d5 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Works seamlessly with other recipe plugins β€” supports fuzzy product matching, If you use the **HTML comment syntax** (``), Recipe View will hide the annotation from the rendered card. - Omit notes when `grocy_id` is used (configurable) -> Grocy increments cleanly by product/amount only. - Follow links(e.g. meal plan note) when the active note has no Ingredients section. Traverses linked notes up to a configurable depth. - +- Skip items with `grocy_id: 0` -> lets you ignore things like salt, water, or spices entirely. --- ## πŸ“₯ Installation @@ -109,6 +109,18 @@ Or with an HTML comment (**hidden in Recipe View**): ``` This forces the matcher to look for β€œjalapeΓ±o” specifically. +### Skipping items with grocy_id: 0 + +If you want to mark an ingredient so the plugin does not add it to Grocy (for example: salt, water, or spices), annotate it with: + +- Visible form: + `- pinch of salt (grocy_id: 0)` + +- Hidden form (works with Recipe View): + `- pinch of salt ` + +`grocy_id: 0` tells the plugin to skip that item entirely. + --- ## πŸš€ Example Workflows @@ -147,3 +159,13 @@ This forces the matcher to look for β€œjalapeΓ±o” specifically. top: {id:12, name:"Eggs", score:11} Auto-matched β†’ Eggs ``` + +--- + +## Changelog + +- 1.0.2: Skip products with `grocy_id: 0`. + +- 1.0.1: Follow links if `Ingredients` headline isn't present. + +- 1.0.0: Initial release \ No newline at end of file diff --git a/main.js b/main.js index 2e1a968..04c5aaa 100644 --- a/main.js +++ b/main.js @@ -317,125 +317,232 @@ module.exports = class GrocyBridgePlugin extends Plugin { })(this.app, this)); } - async addIngredientsFromActiveNote(){ + async addIngredientsFromActiveNote() { const view = this.app.workspace.getActiveViewOfType(MarkdownView); - if(!view){ new Notice("Open a recipe note first."); return; } + 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(ingredients.length===0 && this.settings.followLinksIfNoIngredients){ + // 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){ + 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){ + } else if (ingredients.length === 0) { new Notice("No ingredients found under 'Ingredients'."); return; } const products = await this.api.fetchProducts(false); - if(!products.length){ + if (!products.length) { new Notice("Grocy: product list is empty β€” check API key / URL in settings."); } - const added = []; + + // TRACKING + const succeeded = []; + const skipped = []; + const failed = []; const unresolved = []; - for(const ing of ingredients){ - if (ing.annotationProductId) { - await this.api.addToShoppingList( - ing.annotationProductId, - ing.amount||null, - this.settings.omitNoteWhenAnnotated ? null : ing.original, - { omitNote: this.settings.omitNoteWhenAnnotated } - ); + // 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; } - 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)`); + + // 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; + 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}))); + scored.sort((a, b) => b.s - a.s); - if(this.settings.strictMode && !ing.annotationProductName){ - unresolved.push({ ing, candidates: scored.slice(0,20).map(x=>x.p) }); + 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]; - if(top && this.settings.autoMatchThreshold>0 && top.s >= this.settings.autoMatchThreshold){ + + // 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){ - 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); + 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; } } - 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}`); + + // 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 - for(const u of unresolved){ - const choice = await new Promise((resolve)=>{ - if(this.settings.noMatchFallback==="ask"){ + // 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 }) + 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){ - 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}`); + 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 { - 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(); + // 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 { - new Notice(`Skipped: ${u.ing.original}`); + // 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(added.length) new Notice(`Added ${added.length} item(s) to Grocy.`); - else new Notice("No items were added."); + 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]); diff --git a/manifest.json b/manifest.json index 4567e56..8499e7c 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "grocy-shoppinglist-bridge", "name": "Grocy Shopping List Bridge", - "version": "1.0.0", + "version": "1.0.2", "minAppVersion": "1.3.0", "description": "Send ingredients from your Obsidian recipe notes straight into your grocy.info shopping list.", "author": "Frederik Baerentsen", diff --git a/versions.json b/versions.json index 5fc8e5a..cafb697 100644 --- a/versions.json +++ b/versions.json @@ -1,3 +1,3 @@ { - "1.0.1": "1.3.0" + "1.0.2": "1.3.0" } \ No newline at end of file