Now follows links in a mealplan and omits notes if grocy_id is used
This commit is contained in:
141
main.js
141
main.js
@@ -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;
|
||||
}
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user