Initial Upload
This commit is contained in:
142
README.md
142
README.md
@@ -1,3 +1,141 @@
|
|||||||
# Obsidian-Grocy-Bridge
|
# 🛒 Grocy Shopping List Bridge
|
||||||
|
|
||||||
Send ingredients from your Obsidian recipe notes straight into your grocy.info shopping list.
|
Send ingredients from your Obsidian recipe notes straight into your [Grocy](https://grocy.info) shopping list.
|
||||||
|
Works seamlessly with other recipe plugins — supports fuzzy product matching, auto-matching with thresholds, free-text items, and manual overrides.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Quick Start
|
||||||
|
1. **Install**: Copy this plugin into `.obsidian/plugins/grocy-shoppinglist-bridge/` and enable it in Obsidian.
|
||||||
|
2. **Configure**: In plugin settings, set your **Grocy URL** and **API Key**.
|
||||||
|
3. **Use**: Open a recipe note → click the 🛒 ribbon button → ingredients appear in your Grocy shopping list.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ Features
|
||||||
|
- Parse `## Ingredients` sections automatically (supports nested subheadings like `### Sauce`, `#### Marinade`).
|
||||||
|
- Smart ingredient normalization:
|
||||||
|
- `1 large egg` → **eggs**
|
||||||
|
- `3 cloves garlic (minced)` → **garlic**
|
||||||
|
- Fuzzy matching against Grocy's products with adjustable confidence.
|
||||||
|
- Multiple fallback strategies when no match is found.
|
||||||
|
- Manual overrides via inline annotations (`grocy_id`, `grocy:`).
|
||||||
|
- Ribbon button + command palette support.
|
||||||
|
- Works out-of-the-box with [Recipe Grabber](https://github.com/seethroughdev/obsidian-recipe-grabber).
|
||||||
|
Imported recipes are parsed automatically.
|
||||||
|
- Plays nicely with [Recipe View](https://obsidian-recipe-view.readthedocs.io/en/latest/).
|
||||||
|
If you use the **HTML comment syntax** (`<!-- grocy_id: … -->`), Recipe View will hide the annotation from the rendered card.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📥 Installation
|
||||||
|
1. Download the repo.
|
||||||
|
2. Copy into your vault:
|
||||||
|
```
|
||||||
|
.obsidian/plugins/grocy-shoppinglist-bridge/
|
||||||
|
```
|
||||||
|
3. Reload community plugins in Obsidian.
|
||||||
|
4. Enable **Grocy Shopping List Bridge**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📑 Usage
|
||||||
|
|
||||||
|
### Recipe note example
|
||||||
|
```markdown
|
||||||
|
## Ingredients
|
||||||
|
- 1 large egg
|
||||||
|
- 3 cloves garlic (minced)
|
||||||
|
- 1 broccoli crown
|
||||||
|
|
||||||
|
### Sauce
|
||||||
|
- 2 tbsp soy sauce
|
||||||
|
- 1 tsp sesame oil
|
||||||
|
|
||||||
|
## Directions
|
||||||
|
Mix and cook...
|
||||||
|
```
|
||||||
|
|
||||||
|
- Click the **🛒 ribbon button** or run:
|
||||||
|
```
|
||||||
|
Grocy: Add ingredients to shopping list
|
||||||
|
```
|
||||||
|
- All bullet points under **Ingredients** (and its subheadings) are added.
|
||||||
|
- Stops before the next top-level section (e.g. `## Directions`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ Settings
|
||||||
|
|
||||||
|
| Setting | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| **Grocy URL** | Base URL of your Grocy (e.g. `http://localhost/grocy` or `https://your-domain/grocy`) |
|
||||||
|
| **API Key** | Create in Grocy → Settings → Manage API keys |
|
||||||
|
| **Shopping List ID** | Usually `1` unless you have multiple lists |
|
||||||
|
| **Auto-confirm unique match** | If only one candidate matches, add automatically |
|
||||||
|
| **Auto-match threshold** | Default `6` — top candidate score required for auto-add |
|
||||||
|
| **Require score margin** | Default `2` — top candidate must be this many points above runner-up |
|
||||||
|
| **Debug matching** | Log candidate scores & decisions to Obsidian console |
|
||||||
|
| **No-match fallback** | What to do when no good product is found:<br>• **Ask** (chooser + free-text option)<br>• **Prompt free-text** (type a note)<br>• **Free-text** (auto-add note)<br>• **Skip** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🖊️ Forcing Matches (Annotations)
|
||||||
|
|
||||||
|
If you want exact control over which Grocy product is used:
|
||||||
|
|
||||||
|
### By Grocy ID
|
||||||
|
```markdown
|
||||||
|
- 2 tomatoes (grocy_id: 42)
|
||||||
|
```
|
||||||
|
|
||||||
|
Or with an HTML comment (**hidden in Recipe View**):
|
||||||
|
```markdown
|
||||||
|
- 2 tomatoes <!-- grocy_id: 42 -->
|
||||||
|
```
|
||||||
|
> ⚠️ Recipe View will display clean ingredients without showing the `grocy_id` comment, if using the HTML notation.
|
||||||
|
|
||||||
|
### By Product Name
|
||||||
|
```markdown
|
||||||
|
- 1 chili pepper (grocy: jalapeño)
|
||||||
|
```
|
||||||
|
This forces the matcher to look for “jalapeño” specifically.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Example Workflows
|
||||||
|
|
||||||
|
### Fully Automated
|
||||||
|
- Auto-confirm unique = ✅
|
||||||
|
- Threshold = `6`, Margin = `2`
|
||||||
|
- Fallback = **Free-text**
|
||||||
|
|
||||||
|
→ Most items auto-add. Unmatched lines go in as notes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Semi-Automated
|
||||||
|
- Auto-confirm unique = ✅
|
||||||
|
- Fallback = **Ask**
|
||||||
|
|
||||||
|
→ Confident matches auto-add, ambiguous ones ask you.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Manual Control
|
||||||
|
- Auto-confirm unique = ❌
|
||||||
|
- Fallback = **Ask**
|
||||||
|
|
||||||
|
→ You confirm every item via chooser.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Debugging
|
||||||
|
- Enable **Debug matching** in settings.
|
||||||
|
- Open **View → Toggle Developer Tools → Console** in Obsidian.
|
||||||
|
- You'll see logs like:
|
||||||
|
```
|
||||||
|
Match: 1 large egg → needle: eggs
|
||||||
|
top: {id:12, name:"Eggs", score:11}
|
||||||
|
Auto-matched → Eggs
|
||||||
|
```
|
||||||
|
|||||||
472
main.js
Normal file
472
main.js
Normal file
@@ -0,0 +1,472 @@
|
|||||||
|
|
||||||
|
'use strict';
|
||||||
|
const { MarkdownView, Notice, Plugin, PluginSettingTab, Setting, FuzzySuggestModal, Modal } = 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
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"],
|
||||||
|
["confectioners sugar","icing sugar"],
|
||||||
|
["corn starch","cornstarch"],
|
||||||
|
["garlic clove","garlic"],
|
||||||
|
["garlic cloves","garlic"],
|
||||||
|
["clove garlic","garlic"],
|
||||||
|
["cloves garlic","garlic"],
|
||||||
|
["egg","eggs"], // treat singular as product "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," ");
|
||||||
|
s = normalizeWhitespace(s);
|
||||||
|
return 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
// Headword boost
|
||||||
|
const head = headword(needle);
|
||||||
|
for(const hv of variantsFor(head)){
|
||||||
|
if(p===hv) score += 6; // exact match on headword strong
|
||||||
|
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), Object.assign({}, init||{}, { headers: Object.assign({}, headers, (init&&init.headers)||{}) }));
|
||||||
|
if(!resp.ok){
|
||||||
|
const text = await resp.text().catch(()=>"(no body)");
|
||||||
|
throw new Error(`Grocy API error ${resp.status}: ${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; }
|
||||||
|
}
|
||||||
|
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){
|
||||||
|
const body = {
|
||||||
|
shopping_list_id: this.settings.shoppingListId,
|
||||||
|
product_id: product_id,
|
||||||
|
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) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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: "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 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 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, ing.original);
|
||||||
|
added.push(`${ing.original} → #${ing.annotationProductId}`);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
// Build candidate list with scores
|
||||||
|
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})));
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
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), // onChoose -> product
|
||||||
|
(txt)=> resolve(txt===null? null : { __free:true, text: txt }) // free-text or cancel(null)
|
||||||
|
);
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
|
||||||
|
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];
|
||||||
|
|
||||||
|
// Heading detection
|
||||||
|
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 {
|
||||||
|
// 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);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
10
manifest.json
Normal file
10
manifest.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"id": "grocy-shoppinglist-bridge",
|
||||||
|
"name": "Grocy Shopping List Bridge",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"minAppVersion": "1.3.0",
|
||||||
|
"description": "Send ingredients from your Obsidian recipe notes straight into your grocy.info shopping list.",
|
||||||
|
"author": "Frederik Baerentsen",
|
||||||
|
"authorUrl": "https://gitea.baerentsen.space/FrederikBaerentsen/Obsidian-Grocy-Bridge",
|
||||||
|
"isDesktopOnly": false
|
||||||
|
}
|
||||||
1
styles.css
Normal file
1
styles.css
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.grocy-bridge-badge{display:inline-block;font-size:12px;padding:2px 6px;border-radius:8px;background:var(--interactive-accent);color:var(--text-on-accent);margin-left:6px;}
|
||||||
3
versions.json
Normal file
3
versions.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"1.0.0": "1.3.0"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user