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';
|
'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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"1.0.0": "1.3.0"
|
"1.0.1": "1.3.0"
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user