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.
|
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
|
||||||
145
main.js
145
main.js
@@ -319,11 +319,15 @@ module.exports = class GrocyBridgePlugin extends Plugin {
|
|||||||
|
|
||||||
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 no Ingredients here, optionally follow links
|
||||||
if (ingredients.length === 0 && this.settings.followLinksIfNoIngredients) {
|
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;
|
||||||
@@ -342,57 +346,109 @@ module.exports = class GrocyBridgePlugin extends Plugin {
|
|||||||
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 = [];
|
||||||
|
|
||||||
|
// PROCESS EACH INGREDIENT
|
||||||
for (const ing of ingredients) {
|
for (const ing of ingredients) {
|
||||||
if (ing.annotationProductId) {
|
// 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(
|
await this.api.addToShoppingList(
|
||||||
ing.annotationProductId,
|
ing.annotationProductId,
|
||||||
ing.amount || null,
|
ing.amount || null,
|
||||||
this.settings.omitNoteWhenAnnotated ? null : ing.original,
|
this.settings.omitNoteWhenAnnotated ? null : ing.original,
|
||||||
{ omitNote: this.settings.omitNoteWhenAnnotated }
|
{ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2) If personal map hit
|
||||||
const mapHitId = (this.settings.personalMap || {})[ing.cleanName.toLowerCase()];
|
const mapHitId = (this.settings.personalMap || {})[ing.cleanName.toLowerCase()];
|
||||||
if (mapHitId) {
|
if (mapHitId) {
|
||||||
|
try {
|
||||||
await this.api.addToShoppingList(mapHitId, ing.amount || null, ing.original);
|
await this.api.addToShoppingList(mapHitId, ing.amount || null, ing.original);
|
||||||
added.push(`${ing.original} → #${mapHitId} (mapped)`);
|
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.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) {
|
if (this.settings.strictMode && !ing.annotationProductName) {
|
||||||
unresolved.push({ ing, candidates: scored.slice(0, 20).map(x => x.p) });
|
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];
|
||||||
|
|
||||||
|
// Auto-match if confident
|
||||||
if (top && this.settings.autoMatchThreshold > 0 && top.s >= this.settings.autoMatchThreshold) {
|
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) {
|
||||||
|
try {
|
||||||
await this.api.addToShoppingList(top.p.id, ing.amount || null, ing.original);
|
await this.api.addToShoppingList(top.p.id, ing.amount || null, ing.original);
|
||||||
added.push(`${ing.original} → ${top.p.name} (auto)`);
|
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);
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If only one candidate and autoConfirmUnique
|
||||||
const candidates = scored.slice(0, 20).map(x => x.p);
|
const candidates = scored.slice(0, 20).map(x => x.p);
|
||||||
if (candidates.length === 1 && this.settings.autoConfirmUnique) {
|
if (candidates.length === 1 && this.settings.autoConfirmUnique) {
|
||||||
|
try {
|
||||||
await this.api.addToShoppingList(candidates[0].id, ing.amount || null, ing.original);
|
await this.api.addToShoppingList(candidates[0].id, ing.amount || null, ing.original);
|
||||||
added.push(`${ing.original} → ${candidates[0].name}`);
|
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
|
||||||
|
|
||||||
|
// HANDLE UNRESOLVED (chooser / fallback)
|
||||||
for (const u of unresolved) {
|
for (const u of unresolved) {
|
||||||
const choice = await new Promise((resolve) => {
|
const choice = await new Promise((resolve) => {
|
||||||
if (this.settings.noMatchFallback === "ask") {
|
if (this.settings.noMatchFallback === "ask") {
|
||||||
@@ -405,36 +461,87 @@ module.exports = class GrocyBridgePlugin extends Plugin {
|
|||||||
);
|
);
|
||||||
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
|
||||||
|
try {
|
||||||
await this.api.addFreeTextItem(choice.text || u.ing.original);
|
await this.api.addFreeTextItem(choice.text || u.ing.original);
|
||||||
added.push(`${u.ing.original} → free-text item`);
|
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) {
|
} else if (choice) {
|
||||||
if (this.settings.debugMatching) console.debug("Adding selected product →", choice.id, choice.name);
|
// product chosen
|
||||||
|
try {
|
||||||
await this.api.addToShoppingList(choice.id, u.ing.amount || null, u.ing.original);
|
await this.api.addToShoppingList(choice.id, u.ing.amount || null, u.ing.original);
|
||||||
added.push(`${u.ing.original} → ${choice.name}`);
|
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 {
|
||||||
|
// No choice made (modal cancelled) OR noMatchFallback != ask
|
||||||
if (this.settings.noMatchFallback === "freeText") {
|
if (this.settings.noMatchFallback === "freeText") {
|
||||||
if (this.settings.debugMatching) console.debug("Fallback: auto free-text →", u.ing.original);
|
try {
|
||||||
await this.api.addFreeTextItem(u.ing.original);
|
await this.api.addFreeTextItem(u.ing.original);
|
||||||
added.push(`${u.ing.original} → free-text item`);
|
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") {
|
} else if (this.settings.noMatchFallback === "promptFreeText") {
|
||||||
|
// prompt user with modal (blocking promise)
|
||||||
await new Promise((res) => {
|
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();
|
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 (this.settings.debugMatching) {
|
||||||
|
console.debug("Grocy summary - succeeded:", succeeded);
|
||||||
|
console.debug("Grocy summary - skipped:", skipped);
|
||||||
|
console.debug("Grocy summary - failed:", failed);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if(added.length) new Notice(`Added ${added.length} item(s) to Grocy.`);
|
|
||||||
else new Notice("No items were added.");
|
|
||||||
}
|
|
||||||
|
|
||||||
async collectFromLinkedNotes(srcFile, maxDepth){
|
async collectFromLinkedNotes(srcFile, maxDepth){
|
||||||
const app = this.app;
|
const app = this.app;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"1.0.1": "1.3.0"
|
"1.0.2": "1.3.0"
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user