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';
const { MarkdownView, Notice, Plugin, PluginSettingTab, Setting, FuzzySuggestModal, Modal } = require('obsidian');
const { MarkdownView, Notice, Plugin, PluginSettingTab, Setting, FuzzySuggestModal, Modal, TFile } = require('obsidian');
const DEFAULT_SETTINGS = {
grocyUrl: "",
@@ -13,7 +12,10 @@ const DEFAULT_SETTINGS = {
noMatchFallback: "ask", // "ask" | "freeText" | "promptFreeText" | "skip"
autoMatchThreshold: 6,
autoMatchMargin: 2,
debugMatching: false
debugMatching: false,
followLinksIfNoIngredients: true,
linkFollowDepth: 1,
omitNoteWhenAnnotated: false
};
function normalizeWhitespace(s){ return s.replace(/\s+/g," ").trim(); }
@@ -31,12 +33,10 @@ function parseAmount(raw){
return null;
}
// Maps and lists for normalization
const SYNONYMS = new Map([
["broccoli crown","broccoli"],
["broccoli crowns","broccoli"],
["chicken breasts","chicken breast"],
["chicken breast","chicken breast"],
["spring onion","green onion"],
["scallion","green onion"],
["powdered sugar","icing sugar"],
@@ -46,7 +46,7 @@ const SYNONYMS = new Map([
["garlic cloves","garlic"],
["clove garlic","garlic"],
["cloves garlic","garlic"],
["egg","eggs"], // treat singular as product "eggs"
["egg","eggs"]
]);
const SIZE_PREP_WORDS = new Set([
@@ -58,40 +58,30 @@ const SIZE_PREP_WORDS = new Set([
function baseClean(s){
s = s.toLowerCase();
s = s.replace(/[^\p{L}\p{N}\s]/gu," ");
s = normalizeWhitespace(s);
return s;
return normalizeWhitespace(s);
}
function normalizeIngredientName(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(/\b(cloves?|clove)\s+garlic\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 = normalizeWhitespace(s);
// Apply synonyms whole-phrase if match
if (SYNONYMS.has(s)) s = SYNONYMS.get(s);
// Singular→plural simple rule for egg
s = s.replace(/\begg\b/g, "eggs");
return s;
return normalizeWhitespace(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);
if (tokens.length===0) return "";
return tokens[tokens.length-1];
return tokens.length ? tokens[tokens.length-1] : "";
}
function variantsFor(needle){
const v = new Set();
v.add(needle);
// simple plural/singular flips
if (needle.endsWith("s")) v.add(needle.replace(/s$/,""));
else v.add(needle+"s");
// eggs special-case already handled
return Array.from(v);
}
@@ -111,7 +101,7 @@ function stripQuantityAndNotes(line){
});
const amountMatch = s.match(/^(\d+\s+\d+\/\d+|\d+\/\d+|\d+(\.\d+)?)/);
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(/[-,–—]\s*\b.*$/,'');
s = normalizeIngredientName(s);
@@ -128,10 +118,9 @@ function scoreCandidate(needle, product){
if(p.includes(t)) score += 2;
if(p.startsWith(t)) score += 1;
}
// Headword boost
const head = headword(needle);
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;
}
if(p===needle) score += 5;
@@ -148,15 +137,14 @@ class GrocyApi {
async json(path, init){
const headers = { "GROCY-API-KEY": this.settings.apiKey };
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){
const text = await resp.text().catch(()=>"(no body)");
throw new Error(`Grocy API error ${resp.status}: ${text}`);
throw new Error(`Grocy API error ${resp.status}: ${await resp.text()}`);
}
if (resp.status === 204) return null;
const text = await resp.text();
if (!text || text.trim().length === 0) return null;
try { return JSON.parse(text); } catch(e){ return text; }
if (!text.trim()) return null;
return JSON.parse(text);
}
async fetchProducts(force){
const now = Date.now();
@@ -167,19 +155,14 @@ class GrocyApi {
this.productCache = { fetchedAt: Date.now(), products: Array.isArray(products)?products:[] };
return this.productCache.products;
}
async addToShoppingList(product_id, amount, note){
const body = {
shopping_list_id: this.settings.shoppingListId,
product_id: product_id,
note: note || ""
};
async addToShoppingList(product_id, amount, note, opts){
const body = { shopping_list_id: this.settings.shoppingListId, product_id };
if (!(opts && opts.omitNote)) body.note = note || "";
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) });
}
async addFreeTextItem(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) });
}
}
@@ -302,6 +285,24 @@ module.exports = class GrocyBridgePlugin extends Plugin {
.addToggle(t => t.setValue(this.plugin.settings.debugMatching)
.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" });
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 => {
@@ -319,9 +320,23 @@ module.exports = class GrocyBridgePlugin extends Plugin {
async addIngredientsFromActiveNote(){
const view = this.app.workspace.getActiveViewOfType(MarkdownView);
if(!view){ new Notice("Open a recipe note first."); return; }
const content = await this.app.vault.read(view.file);
const ingredients = this.extractIngredients(content);
if(ingredients.length===0){ new Notice("No ingredients found under 'Ingredients'."); 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){
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);
if(!products.length){
@@ -331,9 +346,13 @@ module.exports = class GrocyBridgePlugin extends Plugin {
const unresolved = [];
for(const ing of ingredients){
if(ing.annotationProductId){
await this.api.addToShoppingList(ing.annotationProductId, ing.amount||null, ing.original);
added.push(`${ing.original} → #${ing.annotationProductId}`);
if (ing.annotationProductId) {
await this.api.addToShoppingList(
ing.annotationProductId,
ing.amount||null,
this.settings.omitNoteWhenAnnotated ? null : ing.original,
{ omitNote: this.settings.omitNoteWhenAnnotated }
);
continue;
}
const mapHitId = (this.settings.personalMap||{})[ing.cleanName.toLowerCase()];
@@ -342,7 +361,6 @@ module.exports = class GrocyBridgePlugin extends Plugin {
added.push(`${ing.original} → #${mapHitId} (mapped)`);
continue;
}
// Build candidate list with scores
const needle = ing.annotationProductName ? normalizeIngredientName(ing.annotationProductName) : normalizeIngredientName(ing.cleanName);
let pool = products;
const hw = headword(needle);
@@ -351,7 +369,6 @@ module.exports = class GrocyBridgePlugin extends Plugin {
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})));
// Auto-confirm unique or high-confidence
if(this.settings.strictMode && !ing.annotationProductName){
unresolved.push({ ing, candidates: scored.slice(0,20).map(x=>x.p) });
continue;
@@ -383,8 +400,8 @@ module.exports = class GrocyBridgePlugin extends Plugin {
this.app,
u.ing.original,
u.candidates.length?u.candidates:products,
(p)=> resolve(p), // onChoose -> product
(txt)=> resolve(txt===null? null : { __free:true, text: txt }) // free-text or cancel(null)
(p)=> resolve(p),
(txt)=> resolve(txt===null? null : { __free:true, text: txt })
);
modal.open();
} else {
@@ -419,6 +436,33 @@ module.exports = class GrocyBridgePlugin extends Plugin {
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){
const lines = md.split(/\r?\n/);
let items = [];
@@ -428,7 +472,6 @@ module.exports = class GrocyBridgePlugin extends Plugin {
for (let i=0; i<lines.length; i++){
const line = lines[i];
// Heading detection
const hm = /^(#{2,6})\s+(.+?)\s*$/.exec(line);
if (hm){
const level = hm[1].length;
@@ -440,22 +483,18 @@ module.exports = class GrocyBridgePlugin extends Plugin {
continue;
}
} else {
// We are inside Ingredients; stop if heading is same or higher level
if (level <= startLevel){
break;
}
// deeper subheading okay
}
continue;
}
// While inside Ingredients, collect bullet lines only
if (inIngredients && /^\s*[-*+]\s+/.test(line)){
items.push(line);
}
}
// Parse collected items
const parsed = [];
for (const line of items){
const res = stripQuantityAndNotes(line);
@@ -469,4 +508,4 @@ module.exports = class GrocyBridgePlugin extends Plugin {
}
return parsed;
}
};
};

View File

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