@@ -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 ;
}
} ;