V1.0.2: Added option to skip item if

This commit is contained in:
2025-09-16 17:10:24 +02:00
parent 1f5873eeda
commit 1850c7e785
4 changed files with 190 additions and 61 deletions

View File

@@ -29,7 +29,7 @@ Works seamlessly with other recipe plugins — supports fuzzy product matching,
If you use the **HTML comment syntax** (`<!-- grocy_id: … -->`), Recipe View will hide the annotation from the rendered card. If you use the **HTML comment syntax** (`<!-- grocy_id: … -->`), 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. - 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. - 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 ## 📥 Installation
@@ -109,6 +109,18 @@ Or with an HTML comment (**hidden in Recipe View**):
``` ```
This forces the matcher to look for “jalapeño” specifically. 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 -->`
`grocy_id: 0` tells the plugin to skip that item entirely.
--- ---
## 🚀 Example Workflows ## 🚀 Example Workflows
@@ -147,3 +159,13 @@ This forces the matcher to look for “jalapeño” specifically.
top: {id:12, name:"Eggs", score:11} top: {id:12, name:"Eggs", score:11}
Auto-matched → Eggs 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

223
main.js
View File

@@ -317,125 +317,232 @@ module.exports = class GrocyBridgePlugin extends Plugin {
})(this.app, this)); })(this.app, this));
} }
async addIngredientsFromActiveNote(){ async addIngredientsFromActiveNote() {
const view = this.app.workspace.getActiveViewOfType(MarkdownView); 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 file = view.file;
const content = await this.app.vault.read(file); const content = await this.app.vault.read(file);
let ingredients = this.extractIngredients(content); 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); const collected = await this.collectFromLinkedNotes(file, this.settings.linkFollowDepth);
ingredients = collected; ingredients = collected;
if(ingredients.length===0){ if (ingredients.length === 0) {
new Notice("No ingredients found in this note or its linked notes."); new Notice("No ingredients found in this note or its linked notes.");
return; return;
} else { } else {
new Notice(`Collected ${ingredients.length} ingredient item(s) from linked notes.`); 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'."); new Notice("No ingredients found under 'Ingredients'.");
return; return;
} }
const products = await this.api.fetchProducts(false); 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."); new Notice("Grocy: product list is empty — check API key / URL in settings.");
} }
const added = [];
// TRACKING
const succeeded = [];
const skipped = [];
const failed = [];
const unresolved = []; const unresolved = [];
for(const ing of ingredients){ // PROCESS EACH INGREDIENT
if (ing.annotationProductId) { for (const ing of ingredients) {
await this.api.addToShoppingList( // 1) If annotation present
ing.annotationProductId, if (typeof ing.annotationProductId !== "undefined") {
ing.amount||null, // Explicit skip if grocy_id: 0
this.settings.omitNoteWhenAnnotated ? null : ing.original, if (ing.annotationProductId === 0) {
{ omitNote: this.settings.omitNoteWhenAnnotated } 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; continue;
} }
const mapHitId = (this.settings.personalMap||{})[ing.cleanName.toLowerCase()];
if(mapHitId){ // 2) If personal map hit
await this.api.addToShoppingList(mapHitId, ing.amount||null, ing.original); const mapHitId = (this.settings.personalMap || {})[ing.cleanName.toLowerCase()];
added.push(`${ing.original} → #${mapHitId} (mapped)`); 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; continue;
} }
// 3) Fuzzy matching
const needle = ing.annotationProductName ? normalizeIngredientName(ing.annotationProductName) : normalizeIngredientName(ing.cleanName); const needle = ing.annotationProductName ? normalizeIngredientName(ing.annotationProductName) : normalizeIngredientName(ing.cleanName);
let pool = products; let pool = products;
const hw = headword(needle); 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) })); let scored = pool.map(p => ({ p, s: scoreCandidate(needle, p) }));
scored.sort((a,b)=>b.s-a.s); 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})));
if(this.settings.strictMode && !ing.annotationProductName){ if (this.settings.debugMatching) {
unresolved.push({ ing, candidates: scored.slice(0,20).map(x=>x.p) }); 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; continue;
} }
const top = scored[0]; const top = scored[0];
const second = scored[1]; 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); const marginOk = (!second) || (top.s - second.s >= this.settings.autoMatchMargin);
if(marginOk){ if (marginOk) {
await this.api.addToShoppingList(top.p.id, ing.amount||null, ing.original); try {
added.push(`${ing.original}${top.p.name} (auto)`); await this.api.addToShoppingList(top.p.id, ing.amount || null, ing.original);
if (this.settings.debugMatching) console.debug("Auto-matched:", ing.original, "→", top.p.name, "score:", top.s, "second:", second?.s); 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; continue;
} }
} }
const candidates = scored.slice(0,20).map(x=>x.p);
if(candidates.length===1 && this.settings.autoConfirmUnique){ // If only one candidate and autoConfirmUnique
await this.api.addToShoppingList(candidates[0].id, ing.amount||null, ing.original); const candidates = scored.slice(0, 20).map(x => x.p);
added.push(`${ing.original}${candidates[0].name}`); 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 { } else {
// ambiguous -> push to unresolved for chooser/fallback
unresolved.push({ ing, candidates }); unresolved.push({ ing, candidates });
} }
} } // end for each ingredient
for(const u of unresolved){ // HANDLE UNRESOLVED (chooser / fallback)
const choice = await new Promise((resolve)=>{ for (const u of unresolved) {
if(this.settings.noMatchFallback==="ask"){ const choice = await new Promise((resolve) => {
if (this.settings.noMatchFallback === "ask") {
const modal = new ChoiceModal( const modal = new ChoiceModal(
this.app, this.app,
u.ing.original, u.ing.original,
u.candidates.length?u.candidates:products, u.candidates.length ? u.candidates : products,
(p)=> resolve(p), (p) => resolve(p),
(txt)=> resolve(txt===null? null : { __free:true, text: txt }) (txt) => resolve(txt === null ? null : { __free: true, text: txt })
); );
modal.open(); modal.open();
} else { } else {
// fallback mode: resolve null so we handle fallback below
resolve(null); resolve(null);
} }
}); });
if(choice && choice.__free){ if (choice && choice.__free) {
if (this.settings.debugMatching) console.debug("Adding as free-text (user) →", choice.text); // user supplied free-text
await this.api.addFreeTextItem(choice.text || u.ing.original); try {
added.push(`${u.ing.original} → free-text item`); await this.api.addFreeTextItem(choice.text || u.ing.original);
} else if(choice){ succeeded.push(`${u.ing.original} → free-text item`);
if (this.settings.debugMatching) console.debug("Adding selected product →", choice.id, choice.name); } catch (err) {
await this.api.addToShoppingList(choice.id, u.ing.amount||null, u.ing.original); failed.push(`${u.ing.original} → FAILED: ${err.message || err}`);
added.push(`${u.ing.original}${choice.name}`); 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 { } else {
if(this.settings.noMatchFallback==="freeText"){ // No choice made (modal cancelled) OR noMatchFallback != ask
if (this.settings.debugMatching) console.debug("Fallback: auto free-text", u.ing.original); if (this.settings.noMatchFallback === "freeText") {
await this.api.addFreeTextItem(u.ing.original); try {
added.push(`${u.ing.original} → free-text item`); await this.api.addFreeTextItem(u.ing.original);
} else if(this.settings.noMatchFallback==="promptFreeText"){ succeeded.push(`${u.ing.original} → free-text item`);
await new Promise((res)=>{ } catch (err) {
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(); 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 { } 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.`); if (this.settings.debugMatching) {
else new Notice("No items were added."); console.debug("Grocy summary - succeeded:", succeeded);
console.debug("Grocy summary - skipped:", skipped);
console.debug("Grocy summary - failed:", failed);
}
} }
async collectFromLinkedNotes(srcFile, maxDepth){ async collectFromLinkedNotes(srcFile, maxDepth){
const app = this.app; const app = this.app;
const visited = new Set([srcFile.path]); const visited = new Set([srcFile.path]);

View File

@@ -1,7 +1,7 @@
{ {
"id": "grocy-shoppinglist-bridge", "id": "grocy-shoppinglist-bridge",
"name": "Grocy Shopping List Bridge", "name": "Grocy Shopping List Bridge",
"version": "1.0.0", "version": "1.0.2",
"minAppVersion": "1.3.0", "minAppVersion": "1.3.0",
"description": "Send ingredients from your Obsidian recipe notes straight into your grocy.info shopping list.", "description": "Send ingredients from your Obsidian recipe notes straight into your grocy.info shopping list.",
"author": "Frederik Baerentsen", "author": "Frederik Baerentsen",

View File

@@ -1,3 +1,3 @@
{ {
"1.0.1": "1.3.0" "1.0.2": "1.3.0"
} }