Files
Obsidian-Grocy-Bridge/main.js

511 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
const { MarkdownView, Notice, Plugin, PluginSettingTab, Setting, FuzzySuggestModal, Modal, TFile } = require('obsidian');
const DEFAULT_SETTINGS = {
grocyUrl: "",
apiKey: "",
shoppingListId: 1,
autoConfirmUnique: true,
strictMode: false,
productCacheTtlMins: 240,
personalMap: {},
noMatchFallback: "ask", // "ask" | "freeText" | "promptFreeText" | "skip"
autoMatchThreshold: 6,
autoMatchMargin: 2,
debugMatching: false,
followLinksIfNoIngredients: true,
linkFollowDepth: 1,
omitNoteWhenAnnotated: false
};
function normalizeWhitespace(s){ return s.replace(/\s+/g," ").trim(); }
function parseAmount(raw){
if(!raw) return null;
const map = {"¼":"1/4","½":"1/2","¾":"3/4","⅐":"1/7","⅑":"1/9","⅒":"1/10","⅓":"1/3","⅔":"2/3","⅕":"1/5","⅖":"2/5","⅗":"3/5","⅘":"4/5","⅙":"1/6","⅚":"5/6","⅛":"1/8","⅜":"3/8","⅝":"5/8","⅞":"7/8"};
let s = (raw.split(/\s+/)[0]||"").replace(/[¼-¾⅐-⅞]/g, m=>map[m]||m);
let m;
m = /^(\d+)\s+(\d+)\/(\d+)$/.exec(s);
if(m){ const w=+m[1], n=+m[2], d=+m[3]; return d? w + n/d : w; }
m = /^(\d+)\/(\d+)$/.exec(s);
if(m){ const n=+m[1], d=+m[2]; return d? n/d : null; }
if(/^\d+(\.\d+)?$/.test(s)) return parseFloat(s);
return null;
}
const SYNONYMS = new Map([
["broccoli crown","broccoli"],
["broccoli crowns","broccoli"],
["chicken breasts","chicken breast"],
["spring onion","green onion"],
["scallion","green onion"],
["powdered sugar","icing sugar"],
["confectioners sugar","icing sugar"],
["corn starch","cornstarch"],
["garlic clove","garlic"],
["garlic cloves","garlic"],
["clove garlic","garlic"],
["cloves garlic","garlic"],
["egg","eggs"]
]);
const SIZE_PREP_WORDS = new Set([
"small","medium","large","xl","extra","extra-large","jumbo",
"minced","chopped","diced","sliced","crushed","grated","ground","peeled","toasted",
"fresh","frozen","boneless","skinless","regular"
]);
function baseClean(s){
s = s.toLowerCase();
s = s.replace(/[^\p{L}\p{N}\s]/gu," ");
return normalizeWhitespace(s);
}
function normalizeIngredientName(s){
s = baseClean(s);
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");
s = s.split(/\s+/).filter(w=>!SIZE_PREP_WORDS.has(w)).join(" ");
if (SYNONYMS.has(s)) s = SYNONYMS.get(s);
s = s.replace(/\begg\b/g, "eggs");
return normalizeWhitespace(s);
}
function headword(s){
const tokens = s.split(/\s+/).filter(Boolean);
return tokens.length ? tokens[tokens.length-1] : "";
}
function variantsFor(needle){
const v = new Set();
v.add(needle);
if (needle.endsWith("s")) v.add(needle.replace(/s$/,""));
else v.add(needle+"s");
return Array.from(v);
}
function stripQuantityAndNotes(line){
let s = line.replace(/^\s*[-*+]\s*/, "");
const htmlAnn = /<!--\s*grocy_id\s*:\s*(\d+)\s*-->/.exec(s);
let annotId = htmlAnn ? parseInt(htmlAnn[1],10) : undefined;
let annotName;
s = s.replace(/\((grocy|grocy_id)\s*:\s*([^)]+)\)/gi, (m, key, val) => {
if (/^grocy_id$/i.test(key)) {
const n = parseInt(String(val).trim(), 10);
if (!isNaN(n)) annotId = n;
} else {
annotName = String(val).trim();
}
return "";
});
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(/\([^)]+\)/g, '');
s = s.replace(/[-,–—]\s*\b.*$/,'');
s = normalizeIngredientName(s);
s = s.replace(/\bof\b/g, "").trim();
return { amount, name: s, annotation: { id: annotId, name: annotName } };
}
function scoreCandidate(needle, product){
const p = baseClean(product.name||"");
const nTokens = needle.split(/\s+/).filter(Boolean);
let score = 0;
for(const t of nTokens){
if(t.length<2) continue;
if(p.includes(t)) score += 2;
if(p.startsWith(t)) score += 1;
}
const head = headword(needle);
for(const hv of variantsFor(head)){
if(p===hv) score += 6;
else if(p.startsWith(hv)) score += 2;
}
if(p===needle) score += 5;
return score;
}
class GrocyApi {
constructor(app, settings){
this.app = app;
this.settings = settings;
this.productCache = null;
}
url(path){ return this.settings.grocyUrl.replace(/\/+$/,'') + path; }
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), { ...init, headers });
if(!resp.ok){
throw new Error(`Grocy API error ${resp.status}: ${await resp.text()}`);
}
if (resp.status === 204) return null;
const text = await resp.text();
if (!text.trim()) return null;
return JSON.parse(text);
}
async fetchProducts(force){
const now = Date.now();
if(!force && this.productCache && (now - this.productCache.fetchedAt) < this.settings.productCacheTtlMins*60000){
return this.productCache.products;
}
const products = await this.json("/api/objects/products");
this.productCache = { fetchedAt: Date.now(), products: Array.isArray(products)?products:[] };
return this.productCache.products;
}
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);
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 };
await this.json("/api/objects/shopping_list", { method: "POST", body: JSON.stringify(body) });
}
}
class FreeTextModal extends Modal {
constructor(app, placeholder, initial, onSubmit){
super(app);
this.placeholder = placeholder;
this.initial = initial || "";
this.onSubmit = onSubmit;
}
onOpen(){
const { contentEl, titleEl } = this;
titleEl.setText("Add as free-text item");
const p = contentEl.createEl("p");
p.setText("Type what you want to appear on the shopping list:");
const input = contentEl.createEl("input", { type: "text" });
input.style.width = "100%";
input.placeholder = this.placeholder || "";
input.value = this.initial;
const btns = contentEl.createDiv();
const ok = btns.createEl("button", { text: "Add to list" });
const cancel = btns.createEl("button", { text: "Cancel" });
cancel.style.marginLeft = "0.5rem";
ok.addEventListener("click", ()=>{ this.onSubmit(input.value.trim()); this.close(); });
cancel.addEventListener("click", ()=> this.close());
input.addEventListener("keydown", (e)=>{
if(e.key==="Enter"){ this.onSubmit(input.value.trim()); this.close(); }
});
setTimeout(()=>input.focus(), 0);
}
onClose(){ this.contentEl.empty(); }
}
class ChoiceModal extends FuzzySuggestModal {
constructor(app, originalLine, items, onChoose, onFreeText){
super(app);
this.originalLine = originalLine;
this.items = [{__cancel:true, label:"Cancel"}].concat([{__free:true, label:` Add as free-text: “${originalLine}`, value: originalLine}], items);
this.onChooseCb = onChoose;
this.onFreeTextCb = onFreeText;
this.setPlaceholder(`Match product for: ${originalLine}`);
}
getItems(){ return this.items; }
getItemText(item){
if(item.__cancel) return item.label;
if(item.__free) return item.label;
return item.name || String(item.id);
}
onChooseItem(item){
if(item.__cancel){ if(app.plugins.getPlugin('grocy-shoppinglist-bridge')?.settings.debugMatching) console.debug("Chooser: cancel"); return this.onFreeTextCb(null); }
if(item.__free){
if(app.plugins.getPlugin('grocy-shoppinglist-bridge')?.settings.debugMatching) console.debug("Chooser: free-text start");
new FreeTextModal(this.app, this.originalLine, this.originalLine, (val)=>{
if(app.plugins.getPlugin('grocy-shoppinglist-bridge')?.settings.debugMatching) console.debug("Chooser: free-text chosen", val);
this.onFreeTextCb(val||this.originalLine);
}).open();
return;
}
if(app.plugins.getPlugin('grocy-shoppinglist-bridge')?.settings.debugMatching) console.debug("Chooser: product chosen", item);
this.onChooseCb(item);
}
}
module.exports = class GrocyBridgePlugin extends Plugin {
async onload(){
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
this.api = new GrocyApi(this.app, this.settings);
this.addRibbonIcon("shopping-cart", "Grocy: Add ingredients to shopping list", async () => {
try { await this.addIngredientsFromActiveNote(); }
catch(e){ console.error(e); new Notice(`Grocy error: ${e.message||e}`); }
});
this.addCommand({
id: "grocy-add-ingredients",
name: "Grocy: Add ingredients to shopping list",
callback: async () => {
try { await this.addIngredientsFromActiveNote(); }
catch(e){ console.error(e); new Notice(`Grocy error: ${e.message||e}`); }
}
});
this.addCommand({
id: "grocy-refresh-products",
name: "Grocy: Refresh product cache",
callback: async () => {
await this.api.fetchProducts(true);
new Notice("Grocy products refreshed.");
}
});
this.addSettingTab(new (class extends PluginSettingTab {
constructor(app, plugin){ super(app, plugin); this.plugin = plugin; }
display(){
const containerEl = this.containerEl;
containerEl.empty();
containerEl.createEl("h2", { text: "Grocy Shopping List Bridge" });
new Setting(containerEl).setName("Grocy URL").setDesc("e.g., https://grocy.local or https://your-domain/grocy")
.addText(t => t.setPlaceholder("https://grocy.example.com").setValue(this.plugin.settings.grocyUrl)
.onChange(async (v)=>{ this.plugin.settings.grocyUrl = v.trim(); await this.plugin.saveData(this.plugin.settings); }));
new Setting(containerEl).setName("API Key").setDesc("Create in Grocy → Settings → Manage API keys")
.addText(t => t.setPlaceholder("your-api-key").setValue(this.plugin.settings.apiKey)
.onChange(async (v)=>{ this.plugin.settings.apiKey = v.trim(); await this.plugin.saveData(this.plugin.settings); }));
new Setting(containerEl).setName("Shopping List ID").setDesc("Usually 1 unless you use multiple lists")
.addText(t => t.setPlaceholder("1").setValue(String(this.plugin.settings.shoppingListId))
.onChange(async (v)=>{ const n = parseInt(v,10); if(!isNaN(n)) this.plugin.settings.shoppingListId = n; await this.plugin.saveData(this.plugin.settings); }));
new Setting(containerEl).setName("Auto-confirm unique match").setDesc("If only one product looks right, add without asking.")
.addToggle(t => t.setValue(this.plugin.settings.autoConfirmUnique)
.onChange(async (v)=>{ this.plugin.settings.autoConfirmUnique = v; await this.plugin.saveData(this.plugin.settings); }));
containerEl.createEl("h3", { text: "Automatic matching" });
new Setting(containerEl).setName("Auto-match threshold").setDesc("Auto-add if top candidate score ≥ this (default 6).")
.addText(t => t.setPlaceholder("6").setValue(String(this.plugin.settings.autoMatchThreshold))
.onChange(async (v)=>{ const n = parseInt(v,10); if(!isNaN(n)) this.plugin.settings.autoMatchThreshold = n; await this.plugin.saveData(this.plugin.settings); }));
new Setting(containerEl).setName("Require score margin").setDesc("Top must be this many points above 2nd (default 2).")
.addText(t => t.setPlaceholder("2").setValue(String(this.plugin.settings.autoMatchMargin))
.onChange(async (v)=>{ const n = parseInt(v,10); if(!isNaN(n)) this.plugin.settings.autoMatchMargin = n; await this.plugin.saveData(this.plugin.settings); }));
new Setting(containerEl).setName("Debug matching").setDesc("Log candidate scores and decisions to Console.")
.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 => {
dd.addOption("ask","Ask (show chooser; can add free-text)");
dd.addOption("promptFreeText","Prompt me to type free-text");
dd.addOption("freeText","Create free-text automatically");
dd.addOption("skip","Skip the item");
dd.setValue(this.plugin.settings.noMatchFallback || "ask");
dd.onChange(async (v)=>{ this.plugin.settings.noMatchFallback = v; await this.plugin.saveData(this.plugin.settings); });
});
}
})(this.app, this));
}
async addIngredientsFromActiveNote(){
const view = this.app.workspace.getActiveViewOfType(MarkdownView);
if(!view){ new Notice("Open a recipe note first."); 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){
new Notice("Grocy: product list is empty — check API key / URL in settings.");
}
const added = [];
const unresolved = [];
for(const ing of ingredients){
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()];
if(mapHitId){
await this.api.addToShoppingList(mapHitId, ing.amount||null, ing.original);
added.push(`${ing.original} → #${mapHitId} (mapped)`);
continue;
}
const needle = ing.annotationProductName ? normalizeIngredientName(ing.annotationProductName) : normalizeIngredientName(ing.cleanName);
let pool = products;
const hw = headword(needle);
if(hw) pool = pool.filter(p => baseClean(p.name||"").includes(hw)) || products;
let scored = pool.map(p => ({ p, s: scoreCandidate(needle, p) }));
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.strictMode && !ing.annotationProductName){
unresolved.push({ ing, candidates: scored.slice(0,20).map(x=>x.p) });
continue;
}
const top = scored[0];
const second = scored[1];
if(top && this.settings.autoMatchThreshold>0 && top.s >= this.settings.autoMatchThreshold){
const marginOk = (!second) || (top.s - second.s >= this.settings.autoMatchMargin);
if(marginOk){
await this.api.addToShoppingList(top.p.id, ing.amount||null, ing.original);
added.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);
continue;
}
}
const candidates = scored.slice(0,20).map(x=>x.p);
if(candidates.length===1 && this.settings.autoConfirmUnique){
await this.api.addToShoppingList(candidates[0].id, ing.amount||null, ing.original);
added.push(`${ing.original}${candidates[0].name}`);
} else {
unresolved.push({ ing, candidates });
}
}
for(const u of unresolved){
const choice = await new Promise((resolve)=>{
if(this.settings.noMatchFallback==="ask"){
const modal = new ChoiceModal(
this.app,
u.ing.original,
u.candidates.length?u.candidates:products,
(p)=> resolve(p),
(txt)=> resolve(txt===null? null : { __free:true, text: txt })
);
modal.open();
} else {
resolve(null);
}
});
if(choice && choice.__free){
if (this.settings.debugMatching) console.debug("Adding as free-text (user) →", choice.text);
await this.api.addFreeTextItem(choice.text || u.ing.original);
added.push(`${u.ing.original} → free-text item`);
} else if(choice){
if (this.settings.debugMatching) console.debug("Adding selected product →", choice.id, choice.name);
await this.api.addToShoppingList(choice.id, u.ing.amount||null, u.ing.original);
added.push(`${u.ing.original}${choice.name}`);
} else {
if(this.settings.noMatchFallback==="freeText"){
if (this.settings.debugMatching) console.debug("Fallback: auto free-text →", u.ing.original);
await this.api.addFreeTextItem(u.ing.original);
added.push(`${u.ing.original} → free-text item`);
} else if(this.settings.noMatchFallback==="promptFreeText"){
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();
});
} else {
new Notice(`Skipped: ${u.ing.original}`);
}
}
}
if(added.length) new Notice(`Added ${added.length} item(s) to Grocy.`);
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 = [];
let inIngredients = false;
let startLevel = null;
for (let i=0; i<lines.length; i++){
const line = lines[i];
const hm = /^(#{2,6})\s+(.+?)\s*$/.exec(line);
if (hm){
const level = hm[1].length;
const title = hm[2].trim().toLowerCase();
if (!inIngredients){
if ((level===2 || level===3) && /^ingredients\b/.test(title)){
inIngredients = true;
startLevel = level;
continue;
}
} else {
if (level <= startLevel){
break;
}
}
continue;
}
if (inIngredients && /^\s*[-*+]\s+/.test(line)){
items.push(line);
}
}
const parsed = [];
for (const line of items){
const res = stripQuantityAndNotes(line);
parsed.push({
original: line.replace(/^\s*[-*+]\s*/, "").trim(),
cleanName: res.name,
amount: res.amount,
annotationProductId: res.annotation && res.annotation.id,
annotationProductName: res.annotation && res.annotation.name
});
}
return parsed;
}
};