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.
- 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 -->`
`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

223
main.js
View File

@@ -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]);

View File

@@ -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",

View File

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