V1.0.2: Added option to skip item if
This commit is contained in:
24
README.md
24
README.md
@@ -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
223
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]);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"1.0.1": "1.3.0"
|
||||
"1.0.2": "1.3.0"
|
||||
}
|
||||
Reference in New Issue
Block a user