618 lines
25 KiB
JavaScript
618 lines
25 KiB
JavaScript
'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 no Ingredients here, optionally follow links
|
||
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.");
|
||
}
|
||
|
||
// TRACKING
|
||
const succeeded = [];
|
||
const skipped = [];
|
||
const failed = [];
|
||
const unresolved = [];
|
||
|
||
// PROCESS EACH INGREDIENT
|
||
for (const ing of ingredients) {
|
||
// 1) If annotation present
|
||
if (typeof ing.annotationProductId !== "undefined") {
|
||
// Explicit skip if grocy_id: 0
|
||
if (ing.annotationProductId === 0) {
|
||
if (this.settings.debugMatching) console.debug("Skipping item due to grocy_id:0:", ing.original);
|
||
skipped.push(`${ing.original} -> skipped (grocy_id:0)`);
|
||
continue;
|
||
}
|
||
|
||
// Annotated product, attempt to add (respect omitNoteWhenAnnotated)
|
||
try {
|
||
await this.api.addToShoppingList(
|
||
ing.annotationProductId,
|
||
ing.amount || null,
|
||
this.settings.omitNoteWhenAnnotated ? null : ing.original,
|
||
{ omitNote: this.settings.omitNoteWhenAnnotated }
|
||
);
|
||
succeeded.push(
|
||
`${ing.original} → #${ing.annotationProductId}${this.settings.omitNoteWhenAnnotated ? " (no note)" : ""}`
|
||
);
|
||
} catch (err) {
|
||
failed.push(`${ing.original} → FAILED: ${err.message || err}`);
|
||
console.error("Grocy add failed for annotated product", err);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// 2) If personal map hit
|
||
const mapHitId = (this.settings.personalMap || {})[ing.cleanName.toLowerCase()];
|
||
if (mapHitId) {
|
||
try {
|
||
await this.api.addToShoppingList(mapHitId, ing.amount || null, ing.original);
|
||
succeeded.push(`${ing.original} → #${mapHitId} (mapped)`);
|
||
} catch (err) {
|
||
failed.push(`${ing.original} → FAILED: ${err.message || err}`);
|
||
console.error("Grocy add failed for mapped product", err);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// 3) Fuzzy matching
|
||
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 })));
|
||
}
|
||
|
||
// Strict mode handling
|
||
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];
|
||
|
||
// Auto-match if confident
|
||
if (top && this.settings.autoMatchThreshold > 0 && top.s >= this.settings.autoMatchThreshold) {
|
||
const marginOk = (!second) || (top.s - second.s >= this.settings.autoMatchMargin);
|
||
if (marginOk) {
|
||
try {
|
||
await this.api.addToShoppingList(top.p.id, ing.amount || null, ing.original);
|
||
succeeded.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);
|
||
} catch (err) {
|
||
failed.push(`${ing.original} → FAILED: ${err.message || err}`);
|
||
console.error("Grocy add failed for auto-match", err);
|
||
}
|
||
continue;
|
||
}
|
||
}
|
||
|
||
// If only one candidate and autoConfirmUnique
|
||
const candidates = scored.slice(0, 20).map(x => x.p);
|
||
if (candidates.length === 1 && this.settings.autoConfirmUnique) {
|
||
try {
|
||
await this.api.addToShoppingList(candidates[0].id, ing.amount || null, ing.original);
|
||
succeeded.push(`${ing.original} → ${candidates[0].name}`);
|
||
} catch (err) {
|
||
failed.push(`${ing.original} → FAILED: ${err.message || err}`);
|
||
console.error("Grocy add failed for unique candidate", err);
|
||
}
|
||
} else {
|
||
// ambiguous -> push to unresolved for chooser/fallback
|
||
unresolved.push({ ing, candidates });
|
||
}
|
||
} // end for each ingredient
|
||
|
||
// HANDLE UNRESOLVED (chooser / fallback)
|
||
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 {
|
||
// fallback mode: resolve null so we handle fallback below
|
||
resolve(null);
|
||
}
|
||
});
|
||
|
||
if (choice && choice.__free) {
|
||
// user supplied free-text
|
||
try {
|
||
await this.api.addFreeTextItem(choice.text || u.ing.original);
|
||
succeeded.push(`${u.ing.original} → free-text item`);
|
||
} catch (err) {
|
||
failed.push(`${u.ing.original} → FAILED: ${err.message || err}`);
|
||
console.error("Grocy add failed for free-text from chooser", err);
|
||
}
|
||
} else if (choice) {
|
||
// product chosen
|
||
try {
|
||
await this.api.addToShoppingList(choice.id, u.ing.amount || null, u.ing.original);
|
||
succeeded.push(`${u.ing.original} → ${choice.name}`);
|
||
} catch (err) {
|
||
failed.push(`${u.ing.original} → FAILED: ${err.message || err}`);
|
||
console.error("Grocy add failed for chosen product", err);
|
||
}
|
||
} else {
|
||
// No choice made (modal cancelled) OR noMatchFallback != ask
|
||
if (this.settings.noMatchFallback === "freeText") {
|
||
try {
|
||
await this.api.addFreeTextItem(u.ing.original);
|
||
succeeded.push(`${u.ing.original} → free-text item`);
|
||
} catch (err) {
|
||
failed.push(`${u.ing.original} → FAILED: ${err.message || err}`);
|
||
console.error("Grocy add failed for fallback free-text", err);
|
||
}
|
||
} else if (this.settings.noMatchFallback === "promptFreeText") {
|
||
// prompt user with modal (blocking promise)
|
||
await new Promise((res) => {
|
||
new FreeTextModal(this.app, u.ing.original, u.ing.original, async (val) => {
|
||
if (val) {
|
||
try {
|
||
await this.api.addFreeTextItem(val);
|
||
succeeded.push(`${u.ing.original} → free-text item`);
|
||
} catch (err) {
|
||
failed.push(`${u.ing.original} → FAILED: ${err.message || err}`);
|
||
console.error("Grocy add failed for prompted free-text", err);
|
||
}
|
||
} else {
|
||
// user canceled the prompt - treat as skipped or nothing
|
||
skipped.push(`${u.ing.original} -> skipped (prompt canceled)`);
|
||
}
|
||
res();
|
||
}).open();
|
||
});
|
||
} else {
|
||
// skip the item
|
||
skipped.push(`${u.ing.original} -> skipped (no-match fallback)`);
|
||
}
|
||
}
|
||
} // end unresolved loop
|
||
|
||
// FINAL SUMMARY
|
||
const totalAdded = succeeded.length;
|
||
const totalSkipped = skipped.length;
|
||
const totalFailed = failed.length;
|
||
|
||
let msg = `Added ${totalAdded} item(s) to Grocy.`;
|
||
if (totalSkipped) msg += ` Skipped ${totalSkipped} item(s).`;
|
||
if (totalFailed) msg += ` ${totalFailed} failed (see console).`;
|
||
|
||
if (totalAdded || totalSkipped || totalFailed) {
|
||
new Notice(msg);
|
||
} else {
|
||
new Notice("No items were added or skipped.");
|
||
}
|
||
|
||
if (this.settings.debugMatching) {
|
||
console.debug("Grocy summary - succeeded:", succeeded);
|
||
console.debug("Grocy summary - skipped:", skipped);
|
||
console.debug("Grocy summary - failed:", failed);
|
||
}
|
||
}
|
||
|
||
|
||
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;
|
||
}
|
||
}; |