Files
Obsidian-Grocy-Bridge/main.js

618 lines
25 KiB
JavaScript
Raw Permalink 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 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;
}
};