Now follows links in a mealplan and omits notes if grocy_id is used

This commit is contained in:
2025-09-14 16:44:45 +02:00
parent f5c6e7ec19
commit 30e7ed00a8
2 changed files with 91 additions and 52 deletions

141
main.js
View File

@@ -1,6 +1,5 @@
'use strict'; 'use strict';
const { MarkdownView, Notice, Plugin, PluginSettingTab, Setting, FuzzySuggestModal, Modal } = require('obsidian'); const { MarkdownView, Notice, Plugin, PluginSettingTab, Setting, FuzzySuggestModal, Modal, TFile } = require('obsidian');
const DEFAULT_SETTINGS = { const DEFAULT_SETTINGS = {
grocyUrl: "", grocyUrl: "",
@@ -13,7 +12,10 @@ const DEFAULT_SETTINGS = {
noMatchFallback: "ask", // "ask" | "freeText" | "promptFreeText" | "skip" noMatchFallback: "ask", // "ask" | "freeText" | "promptFreeText" | "skip"
autoMatchThreshold: 6, autoMatchThreshold: 6,
autoMatchMargin: 2, autoMatchMargin: 2,
debugMatching: false debugMatching: false,
followLinksIfNoIngredients: true,
linkFollowDepth: 1,
omitNoteWhenAnnotated: false
}; };
function normalizeWhitespace(s){ return s.replace(/\s+/g," ").trim(); } function normalizeWhitespace(s){ return s.replace(/\s+/g," ").trim(); }
@@ -31,12 +33,10 @@ function parseAmount(raw){
return null; return null;
} }
// Maps and lists for normalization
const SYNONYMS = new Map([ const SYNONYMS = new Map([
["broccoli crown","broccoli"], ["broccoli crown","broccoli"],
["broccoli crowns","broccoli"], ["broccoli crowns","broccoli"],
["chicken breasts","chicken breast"], ["chicken breasts","chicken breast"],
["chicken breast","chicken breast"],
["spring onion","green onion"], ["spring onion","green onion"],
["scallion","green onion"], ["scallion","green onion"],
["powdered sugar","icing sugar"], ["powdered sugar","icing sugar"],
@@ -46,7 +46,7 @@ const SYNONYMS = new Map([
["garlic cloves","garlic"], ["garlic cloves","garlic"],
["clove garlic","garlic"], ["clove garlic","garlic"],
["cloves garlic","garlic"], ["cloves garlic","garlic"],
["egg","eggs"], // treat singular as product "eggs" ["egg","eggs"]
]); ]);
const SIZE_PREP_WORDS = new Set([ const SIZE_PREP_WORDS = new Set([
@@ -58,40 +58,30 @@ const SIZE_PREP_WORDS = new Set([
function baseClean(s){ function baseClean(s){
s = s.toLowerCase(); s = s.toLowerCase();
s = s.replace(/[^\p{L}\p{N}\s]/gu," "); s = s.replace(/[^\p{L}\p{N}\s]/gu," ");
s = normalizeWhitespace(s); return normalizeWhitespace(s);
return s;
} }
function normalizeIngredientName(s){ function normalizeIngredientName(s){
s = baseClean(s); s = baseClean(s);
// Collapse common "clove(s) of garlic" patterns to "garlic"
s = s.replace(/\bcloves?\s+of\s+garlic\b/g, "garlic"); s = s.replace(/\bcloves?\s+of\s+garlic\b/g, "garlic");
s = s.replace(/\b(cloves?|clove)\s+garlic\b/g, "garlic"); s = s.replace(/\b(cloves?|clove)\s+garlic\b/g, "garlic");
s = s.replace(/\bgarlic\s+(cloves?|clove)\b/g, "garlic"); s = s.replace(/\bgarlic\s+(cloves?|clove)\b/g, "garlic");
// Remove size/prep adjectives
s = s.split(/\s+/).filter(w=>!SIZE_PREP_WORDS.has(w)).join(" "); s = s.split(/\s+/).filter(w=>!SIZE_PREP_WORDS.has(w)).join(" ");
s = normalizeWhitespace(s);
// Apply synonyms whole-phrase if match
if (SYNONYMS.has(s)) s = SYNONYMS.get(s); if (SYNONYMS.has(s)) s = SYNONYMS.get(s);
// Singular→plural simple rule for egg
s = s.replace(/\begg\b/g, "eggs"); s = s.replace(/\begg\b/g, "eggs");
return s; return normalizeWhitespace(s);
} }
function headword(s){ function headword(s){
// choose the last token as head after removing adjectives; if "of" pattern present, take rightmost token
const tokens = s.split(/\s+/).filter(Boolean); const tokens = s.split(/\s+/).filter(Boolean);
if (tokens.length===0) return ""; return tokens.length ? tokens[tokens.length-1] : "";
return tokens[tokens.length-1];
} }
function variantsFor(needle){ function variantsFor(needle){
const v = new Set(); const v = new Set();
v.add(needle); v.add(needle);
// simple plural/singular flips
if (needle.endsWith("s")) v.add(needle.replace(/s$/,"")); if (needle.endsWith("s")) v.add(needle.replace(/s$/,""));
else v.add(needle+"s"); else v.add(needle+"s");
// eggs special-case already handled
return Array.from(v); return Array.from(v);
} }
@@ -111,7 +101,7 @@ function stripQuantityAndNotes(line){
}); });
const amountMatch = s.match(/^(\d+\s+\d+\/\d+|\d+\/\d+|\d+(\.\d+)?)/); const amountMatch = s.match(/^(\d+\s+\d+\/\d+|\d+\/\d+|\d+(\.\d+)?)/);
const amount = amountMatch ? parseAmount(amountMatch[1]) : null; const amount = amountMatch ? parseAmount(amountMatch[1]) : null;
s = s.replace(/^(\d+\s+\d+\/\d+|\d+\/\d+|\d+(\.\d+)?)(\s*[A-Za-z]+\.?)?\s+/,''); s = s.replace(/^(\d+\s+\d+\/\d+|\d+\/\d+|\d+(\.\d+)?)(\s*[A-Za-z]+\.?)?\s+/, '');
s = s.replace(/\([^)]+\)/g, ''); s = s.replace(/\([^)]+\)/g, '');
s = s.replace(/[-,–—]\s*\b.*$/,''); s = s.replace(/[-,–—]\s*\b.*$/,'');
s = normalizeIngredientName(s); s = normalizeIngredientName(s);
@@ -128,10 +118,9 @@ function scoreCandidate(needle, product){
if(p.includes(t)) score += 2; if(p.includes(t)) score += 2;
if(p.startsWith(t)) score += 1; if(p.startsWith(t)) score += 1;
} }
// Headword boost
const head = headword(needle); const head = headword(needle);
for(const hv of variantsFor(head)){ for(const hv of variantsFor(head)){
if(p===hv) score += 6; // exact match on headword strong if(p===hv) score += 6;
else if(p.startsWith(hv)) score += 2; else if(p.startsWith(hv)) score += 2;
} }
if(p===needle) score += 5; if(p===needle) score += 5;
@@ -148,15 +137,14 @@ class GrocyApi {
async json(path, init){ async json(path, init){
const headers = { "GROCY-API-KEY": this.settings.apiKey }; const headers = { "GROCY-API-KEY": this.settings.apiKey };
if (init && init.method && init.method.toUpperCase()!=="GET") headers["Content-Type"]="application/json"; if (init && init.method && init.method.toUpperCase()!=="GET") headers["Content-Type"]="application/json";
const resp = await fetch(this.url(path), Object.assign({}, init||{}, { headers: Object.assign({}, headers, (init&&init.headers)||{}) })); const resp = await fetch(this.url(path), { ...init, headers });
if(!resp.ok){ if(!resp.ok){
const text = await resp.text().catch(()=>"(no body)"); throw new Error(`Grocy API error ${resp.status}: ${await resp.text()}`);
throw new Error(`Grocy API error ${resp.status}: ${text}`);
} }
if (resp.status === 204) return null; if (resp.status === 204) return null;
const text = await resp.text(); const text = await resp.text();
if (!text || text.trim().length === 0) return null; if (!text.trim()) return null;
try { return JSON.parse(text); } catch(e){ return text; } return JSON.parse(text);
} }
async fetchProducts(force){ async fetchProducts(force){
const now = Date.now(); const now = Date.now();
@@ -167,19 +155,14 @@ class GrocyApi {
this.productCache = { fetchedAt: Date.now(), products: Array.isArray(products)?products:[] }; this.productCache = { fetchedAt: Date.now(), products: Array.isArray(products)?products:[] };
return this.productCache.products; return this.productCache.products;
} }
async addToShoppingList(product_id, amount, note){ async addToShoppingList(product_id, amount, note, opts){
const body = { const body = { shopping_list_id: this.settings.shoppingListId, product_id };
shopping_list_id: this.settings.shoppingListId, if (!(opts && opts.omitNote)) body.note = note || "";
product_id: product_id,
note: note || ""
};
if (amount && amount > 0) body.amount = String(amount); if (amount && amount > 0) body.amount = String(amount);
if (this.settings.debugMatching) console.debug("Grocy add product payload", body);
await this.json("/api/objects/shopping_list", { method: "POST", body: JSON.stringify(body) }); await this.json("/api/objects/shopping_list", { method: "POST", body: JSON.stringify(body) });
} }
async addFreeTextItem(note){ async addFreeTextItem(note){
const body = { shopping_list_id: this.settings.shoppingListId, note }; const body = { shopping_list_id: this.settings.shoppingListId, note };
if (this.settings.debugMatching) console.debug("Grocy add free-text payload", body);
await this.json("/api/objects/shopping_list", { method: "POST", body: JSON.stringify(body) }); await this.json("/api/objects/shopping_list", { method: "POST", body: JSON.stringify(body) });
} }
} }
@@ -302,6 +285,24 @@ module.exports = class GrocyBridgePlugin extends Plugin {
.addToggle(t => t.setValue(this.plugin.settings.debugMatching) .addToggle(t => t.setValue(this.plugin.settings.debugMatching)
.onChange(async (v)=>{ this.plugin.settings.debugMatching = v; await this.plugin.saveData(this.plugin.settings); })); .onChange(async (v)=>{ this.plugin.settings.debugMatching = v; await this.plugin.saveData(this.plugin.settings); }));
containerEl.createEl("h3", { text: "Link following (if no Ingredients)" });
new Setting(containerEl).setName("Follow links when note has no Ingredients").setDesc("If enabled, the plugin will traverse linked notes and collect their Ingredients.")
.addToggle(t => t.setValue(this.plugin.settings.followLinksIfNoIngredients)
.onChange(async (v)=>{ this.plugin.settings.followLinksIfNoIngredients = v; await this.plugin.saveData(this.plugin.settings); }));
new Setting(containerEl).setName("Link follow depth").setDesc("How many levels of links to traverse (default 1).")
.addText(t => t.setPlaceholder("1").setValue(String(this.plugin.settings.linkFollowDepth))
.onChange(async (v)=>{ const n = parseInt(v,10); if(!isNaN(n) && n>=0 && n<=5) this.plugin.settings.linkFollowDepth = n; await this.plugin.saveData(this.plugin.settings); }));
new Setting(containerEl)
.setName("Omit note for annotated items")
.setDesc("If enabled, items with grocy_id annotations will be added without a note.")
.addToggle(t => t
.setValue(this.plugin.settings.omitNoteWhenAnnotated)
.onChange(async (v)=>{
this.plugin.settings.omitNoteWhenAnnotated = v;
await this.plugin.saveData(this.plugin.settings);
}));
containerEl.createEl("h3", { text: "No-match fallback" }); containerEl.createEl("h3", { text: "No-match fallback" });
new Setting(containerEl).setName("When an item doesn't match a product").setDesc("Choose what to do if no product is found or you skip selection.") new Setting(containerEl).setName("When an item doesn't match a product").setDesc("Choose what to do if no product is found or you skip selection.")
.addDropdown(dd => { .addDropdown(dd => {
@@ -319,9 +320,23 @@ 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 content = await this.app.vault.read(view.file); const file = view.file;
const ingredients = this.extractIngredients(content); const content = await this.app.vault.read(file);
if(ingredients.length===0){ new Notice("No ingredients found under 'Ingredients'."); return; } let ingredients = this.extractIngredients(content);
if(ingredients.length===0 && this.settings.followLinksIfNoIngredients){
const collected = await this.collectFromLinkedNotes(file, this.settings.linkFollowDepth);
ingredients = collected;
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){
new Notice("No ingredients found under 'Ingredients'.");
return;
}
const products = await this.api.fetchProducts(false); const products = await this.api.fetchProducts(false);
if(!products.length){ if(!products.length){
@@ -331,9 +346,13 @@ module.exports = class GrocyBridgePlugin extends Plugin {
const unresolved = []; const unresolved = [];
for(const ing of ingredients){ for(const ing of ingredients){
if(ing.annotationProductId){ if (ing.annotationProductId) {
await this.api.addToShoppingList(ing.annotationProductId, ing.amount||null, ing.original); await this.api.addToShoppingList(
added.push(`${ing.original} → #${ing.annotationProductId}`); ing.annotationProductId,
ing.amount||null,
this.settings.omitNoteWhenAnnotated ? null : ing.original,
{ omitNote: this.settings.omitNoteWhenAnnotated }
);
continue; continue;
} }
const mapHitId = (this.settings.personalMap||{})[ing.cleanName.toLowerCase()]; const mapHitId = (this.settings.personalMap||{})[ing.cleanName.toLowerCase()];
@@ -342,7 +361,6 @@ module.exports = class GrocyBridgePlugin extends Plugin {
added.push(`${ing.original} → #${mapHitId} (mapped)`); added.push(`${ing.original} → #${mapHitId} (mapped)`);
continue; continue;
} }
// Build candidate list with scores
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);
@@ -351,7 +369,6 @@ module.exports = class GrocyBridgePlugin extends Plugin {
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})));
// Auto-confirm unique or high-confidence
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;
@@ -383,8 +400,8 @@ module.exports = class GrocyBridgePlugin extends Plugin {
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), // onChoose -> product (p)=> resolve(p),
(txt)=> resolve(txt===null? null : { __free:true, text: txt }) // free-text or cancel(null) (txt)=> resolve(txt===null? null : { __free:true, text: txt })
); );
modal.open(); modal.open();
} else { } else {
@@ -419,6 +436,33 @@ module.exports = class GrocyBridgePlugin extends Plugin {
else new Notice("No items were added."); else new Notice("No items were added.");
} }
async collectFromLinkedNotes(srcFile, maxDepth){
const app = this.app;
const visited = new Set([srcFile.path]);
const queue = [{ file: srcFile, depth: 0 }];
const allIngredients = [];
while(queue.length){
const { file, depth } = queue.shift();
if(depth>maxDepth) continue;
const cache = app.metadataCache.getFileCache(file) || {};
const links = (cache.links||[]).concat(cache.embeds||[]);
for(const l of links){
const linkText = l.link || l.original || l;
const target = app.metadataCache.getFirstLinkpathDest(linkText, file.path);
if(target && target instanceof TFile && target.extension==="md" && !visited.has(target.path)){
visited.add(target.path);
const content = await app.vault.read(target);
const ings = this.extractIngredients(content);
if(ings.length) allIngredients.push(...ings);
if(depth < maxDepth){
queue.push({ file: target, depth: depth+1 });
}
}
}
}
return allIngredients;
}
extractIngredients(md){ extractIngredients(md){
const lines = md.split(/\r?\n/); const lines = md.split(/\r?\n/);
let items = []; let items = [];
@@ -428,7 +472,6 @@ module.exports = class GrocyBridgePlugin extends Plugin {
for (let i=0; i<lines.length; i++){ for (let i=0; i<lines.length; i++){
const line = lines[i]; const line = lines[i];
// Heading detection
const hm = /^(#{2,6})\s+(.+?)\s*$/.exec(line); const hm = /^(#{2,6})\s+(.+?)\s*$/.exec(line);
if (hm){ if (hm){
const level = hm[1].length; const level = hm[1].length;
@@ -440,22 +483,18 @@ module.exports = class GrocyBridgePlugin extends Plugin {
continue; continue;
} }
} else { } else {
// We are inside Ingredients; stop if heading is same or higher level
if (level <= startLevel){ if (level <= startLevel){
break; break;
} }
// deeper subheading okay
} }
continue; continue;
} }
// While inside Ingredients, collect bullet lines only
if (inIngredients && /^\s*[-*+]\s+/.test(line)){ if (inIngredients && /^\s*[-*+]\s+/.test(line)){
items.push(line); items.push(line);
} }
} }
// Parse collected items
const parsed = []; const parsed = [];
for (const line of items){ for (const line of items){
const res = stripQuantityAndNotes(line); const res = stripQuantityAndNotes(line);
@@ -469,4 +508,4 @@ module.exports = class GrocyBridgePlugin extends Plugin {
} }
return parsed; return parsed;
} }
}; };

View File

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