@tmlmt/cooklang-parser 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +94 -0
- package/dist/index.d.ts +369 -0
- package/dist/index.js +822 -0
- package/dist/index.js.map +1 -0
- package/package.json +58 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,822 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
|
|
3
|
+
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
|
|
4
|
+
|
|
5
|
+
// src/classes/aisle_config.ts
|
|
6
|
+
var AisleConfig = class {
|
|
7
|
+
/**
|
|
8
|
+
* Creates a new AisleConfig instance.
|
|
9
|
+
* @param config - The aisle configuration to parse.
|
|
10
|
+
*/
|
|
11
|
+
constructor(config) {
|
|
12
|
+
/**
|
|
13
|
+
* The categories of aisles.
|
|
14
|
+
* @see {@link AisleCategory}
|
|
15
|
+
*/
|
|
16
|
+
__publicField(this, "categories", []);
|
|
17
|
+
if (config) {
|
|
18
|
+
this.parse(config);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Parses an aisle configuration from a string.
|
|
23
|
+
* @param config - The aisle configuration to parse.
|
|
24
|
+
*/
|
|
25
|
+
parse(config) {
|
|
26
|
+
let currentCategory = null;
|
|
27
|
+
const categoryNames = /* @__PURE__ */ new Set();
|
|
28
|
+
const ingredientNames = /* @__PURE__ */ new Set();
|
|
29
|
+
for (const line of config.split("\n")) {
|
|
30
|
+
const trimmedLine = line.trim();
|
|
31
|
+
if (trimmedLine.length === 0) {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (trimmedLine.startsWith("[") && trimmedLine.endsWith("]")) {
|
|
35
|
+
const categoryName = trimmedLine.substring(1, trimmedLine.length - 1).trim();
|
|
36
|
+
if (categoryNames.has(categoryName)) {
|
|
37
|
+
throw new Error(`Duplicate category found: ${categoryName}`);
|
|
38
|
+
}
|
|
39
|
+
categoryNames.add(categoryName);
|
|
40
|
+
currentCategory = { name: categoryName, ingredients: [] };
|
|
41
|
+
this.categories.push(currentCategory);
|
|
42
|
+
} else {
|
|
43
|
+
if (currentCategory === null) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
`Ingredient found without a category: ${trimmedLine}`
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
const aliases = trimmedLine.split("|").map((s) => s.trim());
|
|
49
|
+
for (const alias of aliases) {
|
|
50
|
+
if (ingredientNames.has(alias)) {
|
|
51
|
+
throw new Error(`Duplicate ingredient/alias found: ${alias}`);
|
|
52
|
+
}
|
|
53
|
+
ingredientNames.add(alias);
|
|
54
|
+
}
|
|
55
|
+
const ingredient = {
|
|
56
|
+
name: aliases[0],
|
|
57
|
+
// We know this exists because trimmedLine is not empty
|
|
58
|
+
aliases
|
|
59
|
+
};
|
|
60
|
+
currentCategory.ingredients.push(ingredient);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// src/classes/section.ts
|
|
67
|
+
var Section = class {
|
|
68
|
+
constructor(name = "") {
|
|
69
|
+
__publicField(this, "name");
|
|
70
|
+
__publicField(this, "content", []);
|
|
71
|
+
this.name = name;
|
|
72
|
+
}
|
|
73
|
+
isBlank() {
|
|
74
|
+
return this.name === "" && this.content.length === 0;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// src/regex.ts
|
|
79
|
+
var metadataRegex = /---\n(.*?)\n---/s;
|
|
80
|
+
var multiwordIngredient = /@(?<mIngredientModifier>[@\-&+*!?])?(?<mIngredientName>(?:[^\s@#~\[\]{(.,;:!?]+(?:\s+[^\s@#~\[\]{(.,;:!?]+)+))(?=\s*(?:\{|\}|\(\s*[^)]*\s*\)))(?:\{(?<mIngredientQuantity>\p{No}|(?:\p{Nd}+(?:[.,\/][\p{Nd}]+)?))?(?:%(?<mIngredientUnits>[^}]+?))?\})?(?:\((?<mIngredientPreparation>[^)]*?)\))?/gu;
|
|
81
|
+
var singleWordIngredient = /@(?<sIngredientModifier>[@\-&+*!?])?(?<sIngredientName>[^\s@#~\[\]{(.,;:!?]+)(?:\{(?<sIngredientQuantity>\p{No}|(?:\p{Nd}+(?:[.,\/][\p{Nd}]+)?))(?:%(?<sIngredientUnits>[^}]+?))?\})?(?:\((?<sIngredientPreparation>[^)]*?)\))?/gu;
|
|
82
|
+
var multiwordCookware = /#(?<mCookwareModifier>[\-&+*!?])?(?<mCookwareName>[^#~[]+?)\{(?<mCookwareQuantity>.*?)\}/;
|
|
83
|
+
var singleWordCookware = /#(?<sCookwareModifier>[\-&+*!?])?(?<sCookwareName>[^\s\t\s\p{P}]+)/u;
|
|
84
|
+
var timer = /~(?<timerName>.*?)(?:\{(?<timerQuantity>.*?)(?:%(?<timerUnits>.+?))?\})/;
|
|
85
|
+
var tokensRegex = new RegExp(
|
|
86
|
+
[
|
|
87
|
+
multiwordIngredient,
|
|
88
|
+
singleWordIngredient,
|
|
89
|
+
multiwordCookware,
|
|
90
|
+
singleWordCookware,
|
|
91
|
+
timer
|
|
92
|
+
].map((r) => r.source).join("|"),
|
|
93
|
+
"gu"
|
|
94
|
+
);
|
|
95
|
+
var commentRegex = /--.*/g;
|
|
96
|
+
var blockCommentRegex = /\s*\[\-.*?\-\]\s*/g;
|
|
97
|
+
|
|
98
|
+
// src/units.ts
|
|
99
|
+
var units = [
|
|
100
|
+
// Mass (Metric)
|
|
101
|
+
{
|
|
102
|
+
name: "g",
|
|
103
|
+
type: "mass",
|
|
104
|
+
system: "metric",
|
|
105
|
+
aliases: ["gram", "grams"],
|
|
106
|
+
toBase: 1
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
name: "kg",
|
|
110
|
+
type: "mass",
|
|
111
|
+
system: "metric",
|
|
112
|
+
aliases: ["kilogram", "kilograms"],
|
|
113
|
+
toBase: 1e3
|
|
114
|
+
},
|
|
115
|
+
// Mass (Imperial)
|
|
116
|
+
{
|
|
117
|
+
name: "oz",
|
|
118
|
+
type: "mass",
|
|
119
|
+
system: "imperial",
|
|
120
|
+
aliases: ["ounce", "ounces"],
|
|
121
|
+
toBase: 28.3495
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
name: "lb",
|
|
125
|
+
type: "mass",
|
|
126
|
+
system: "imperial",
|
|
127
|
+
aliases: ["pound", "pounds"],
|
|
128
|
+
toBase: 453.592
|
|
129
|
+
},
|
|
130
|
+
// Volume (Metric)
|
|
131
|
+
{
|
|
132
|
+
name: "ml",
|
|
133
|
+
type: "volume",
|
|
134
|
+
system: "metric",
|
|
135
|
+
aliases: ["milliliter", "milliliters", "millilitre", "millilitres"],
|
|
136
|
+
toBase: 1
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
name: "l",
|
|
140
|
+
type: "volume",
|
|
141
|
+
system: "metric",
|
|
142
|
+
aliases: ["liter", "liters", "litre", "litres"],
|
|
143
|
+
toBase: 1e3
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
name: "tsp",
|
|
147
|
+
type: "volume",
|
|
148
|
+
system: "metric",
|
|
149
|
+
aliases: ["teaspoon", "teaspoons"],
|
|
150
|
+
toBase: 5
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
name: "tbsp",
|
|
154
|
+
type: "volume",
|
|
155
|
+
system: "metric",
|
|
156
|
+
aliases: ["tablespoon", "tablespoons"],
|
|
157
|
+
toBase: 15
|
|
158
|
+
},
|
|
159
|
+
// Volume (Imperial)
|
|
160
|
+
{
|
|
161
|
+
name: "fl-oz",
|
|
162
|
+
type: "volume",
|
|
163
|
+
system: "imperial",
|
|
164
|
+
aliases: ["fluid ounce", "fluid ounces"],
|
|
165
|
+
toBase: 29.5735
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
name: "cup",
|
|
169
|
+
type: "volume",
|
|
170
|
+
system: "imperial",
|
|
171
|
+
aliases: ["cups"],
|
|
172
|
+
toBase: 236.588
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
name: "pint",
|
|
176
|
+
type: "volume",
|
|
177
|
+
system: "imperial",
|
|
178
|
+
aliases: ["pints"],
|
|
179
|
+
toBase: 473.176
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
name: "quart",
|
|
183
|
+
type: "volume",
|
|
184
|
+
system: "imperial",
|
|
185
|
+
aliases: ["quarts"],
|
|
186
|
+
toBase: 946.353
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
name: "gallon",
|
|
190
|
+
type: "volume",
|
|
191
|
+
system: "imperial",
|
|
192
|
+
aliases: ["gallons"],
|
|
193
|
+
toBase: 3785.41
|
|
194
|
+
},
|
|
195
|
+
// Count units (no conversion, but recognized as a type)
|
|
196
|
+
{
|
|
197
|
+
name: "piece",
|
|
198
|
+
type: "count",
|
|
199
|
+
system: "metric",
|
|
200
|
+
aliases: ["pieces"],
|
|
201
|
+
toBase: 1
|
|
202
|
+
}
|
|
203
|
+
];
|
|
204
|
+
var unitMap = /* @__PURE__ */ new Map();
|
|
205
|
+
for (const unit of units) {
|
|
206
|
+
unitMap.set(unit.name.toLowerCase(), unit);
|
|
207
|
+
for (const alias of unit.aliases) {
|
|
208
|
+
unitMap.set(alias.toLowerCase(), unit);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
function normalizeUnit(unit) {
|
|
212
|
+
return unitMap.get(unit.toLowerCase().trim());
|
|
213
|
+
}
|
|
214
|
+
function addQuantities(q1, q2) {
|
|
215
|
+
const unit1Def = normalizeUnit(q1.unit);
|
|
216
|
+
const unit2Def = normalizeUnit(q2.unit);
|
|
217
|
+
if (isNaN(Number(q1.value))) {
|
|
218
|
+
throw new Error(
|
|
219
|
+
`Cannot add quantity to string-quantified value: ${q1.value}`
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
if (isNaN(Number(q2.value))) {
|
|
223
|
+
throw new Error(
|
|
224
|
+
`Cannot add quantity to string-quantified value: ${q2.value}`
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
if (q1.unit === "" && unit2Def) {
|
|
228
|
+
return {
|
|
229
|
+
value: Math.round((q1.value + q2.value) * 100) / 100,
|
|
230
|
+
unit: q2.unit
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
if (q2.unit === "" && unit1Def) {
|
|
234
|
+
return {
|
|
235
|
+
value: Math.round((q1.value + q2.value) * 100) / 100,
|
|
236
|
+
unit: q1.unit
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
if (q1.unit.toLowerCase() === q2.unit.toLowerCase()) {
|
|
240
|
+
return {
|
|
241
|
+
value: Math.round((q1.value + q2.value) * 100) / 100,
|
|
242
|
+
unit: q1.unit
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
if (unit1Def && unit2Def) {
|
|
246
|
+
if (unit1Def.type !== unit2Def.type) {
|
|
247
|
+
throw new Error(
|
|
248
|
+
`Cannot add quantities of different types: ${unit1Def.type} (${q1.unit}) and ${unit2Def.type} (${q2.unit})`
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
const baseValue1 = q1.value * unit1Def.toBase;
|
|
252
|
+
const baseValue2 = q2.value * unit2Def.toBase;
|
|
253
|
+
const totalBaseValue = baseValue1 + baseValue2;
|
|
254
|
+
let targetUnitDef;
|
|
255
|
+
if (unit1Def.system !== unit2Def.system) {
|
|
256
|
+
const metricUnitDef = unit1Def.system === "metric" ? unit1Def : unit2Def;
|
|
257
|
+
targetUnitDef = units.filter((u) => u.type === metricUnitDef.type && u.system === "metric").reduce(
|
|
258
|
+
(prev, current) => prev.toBase > current.toBase ? prev : current
|
|
259
|
+
);
|
|
260
|
+
} else {
|
|
261
|
+
targetUnitDef = unit1Def.toBase >= unit2Def.toBase ? unit1Def : unit2Def;
|
|
262
|
+
}
|
|
263
|
+
const finalValue = totalBaseValue / targetUnitDef.toBase;
|
|
264
|
+
return {
|
|
265
|
+
value: Math.round(finalValue * 100) / 100,
|
|
266
|
+
unit: targetUnitDef.name
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
throw new Error(
|
|
270
|
+
`Cannot add quantities with incompatible or unknown units: ${q1.unit} and ${q2.unit}`
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// src/parser_helpers.ts
|
|
275
|
+
function findOrPush(list, finder, creator) {
|
|
276
|
+
let index = list.findIndex(finder);
|
|
277
|
+
if (index === -1) {
|
|
278
|
+
index = list.push(creator()) - 1;
|
|
279
|
+
}
|
|
280
|
+
return index;
|
|
281
|
+
}
|
|
282
|
+
function flushPendingNote(section, note) {
|
|
283
|
+
if (note.length > 0) {
|
|
284
|
+
section.content.push({ note });
|
|
285
|
+
return "";
|
|
286
|
+
}
|
|
287
|
+
return note;
|
|
288
|
+
}
|
|
289
|
+
function flushPendingItems(section, items) {
|
|
290
|
+
if (items.length > 0) {
|
|
291
|
+
section.content.push({ items: [...items] });
|
|
292
|
+
items.length = 0;
|
|
293
|
+
return true;
|
|
294
|
+
}
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
function findAndUpsertIngredient(ingredients, newIngredient, isReference) {
|
|
298
|
+
const { name, quantity, unit } = newIngredient;
|
|
299
|
+
if (isReference) {
|
|
300
|
+
const index = ingredients.findIndex(
|
|
301
|
+
(i) => i.name.toLowerCase() === name.toLowerCase()
|
|
302
|
+
);
|
|
303
|
+
if (index === -1) {
|
|
304
|
+
throw new Error(
|
|
305
|
+
`Referenced ingredient "${name}" not found. A referenced ingredient must be declared before being referenced with '&'.`
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
const existingIngredient = ingredients[index];
|
|
309
|
+
if (quantity !== void 0) {
|
|
310
|
+
const currentQuantity = {
|
|
311
|
+
value: existingIngredient.quantity ?? 0,
|
|
312
|
+
unit: existingIngredient.unit ?? ""
|
|
313
|
+
};
|
|
314
|
+
const newQuantity = { value: quantity, unit: unit ?? "" };
|
|
315
|
+
const total = addQuantities(currentQuantity, newQuantity);
|
|
316
|
+
existingIngredient.quantity = total.value;
|
|
317
|
+
existingIngredient.unit = total.unit || void 0;
|
|
318
|
+
}
|
|
319
|
+
return index;
|
|
320
|
+
}
|
|
321
|
+
return ingredients.push(newIngredient) - 1;
|
|
322
|
+
}
|
|
323
|
+
function findAndUpsertCookware(cookware, newCookware, isReference) {
|
|
324
|
+
const { name } = newCookware;
|
|
325
|
+
if (isReference) {
|
|
326
|
+
const index = cookware.findIndex(
|
|
327
|
+
(i) => i.name.toLowerCase() === name.toLowerCase()
|
|
328
|
+
);
|
|
329
|
+
if (index === -1) {
|
|
330
|
+
throw new Error(
|
|
331
|
+
`Referenced cookware "${name}" not found. A referenced cookware must be declared before being referenced with '&'.`
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
return index;
|
|
335
|
+
}
|
|
336
|
+
return cookware.push(newCookware) - 1;
|
|
337
|
+
}
|
|
338
|
+
function parseNumber(input_str) {
|
|
339
|
+
const clean_str = String(input_str).replace(",", ".");
|
|
340
|
+
if (!clean_str.startsWith("/") && clean_str.includes("/")) {
|
|
341
|
+
const [num, den] = clean_str.split("/").map(Number);
|
|
342
|
+
return num / den;
|
|
343
|
+
}
|
|
344
|
+
return Number(clean_str);
|
|
345
|
+
}
|
|
346
|
+
function parseSimpleMetaVar(content, varName) {
|
|
347
|
+
const varMatch = content.match(
|
|
348
|
+
new RegExp(`^${varName}:\\s*(.*(?:\\r?\\n\\s+.*)*)+`, "m")
|
|
349
|
+
);
|
|
350
|
+
return varMatch ? varMatch[1]?.trim().replace(/\s*\r?\n\s+/g, " ") : void 0;
|
|
351
|
+
}
|
|
352
|
+
function parseScalingMetaVar(content, varName) {
|
|
353
|
+
const varMatch = content.match(
|
|
354
|
+
new RegExp(`^${varName}:[\\t ]*(([^,\\n]*),? ?(?:.*)?)`, "m")
|
|
355
|
+
);
|
|
356
|
+
if (!varMatch) return void 0;
|
|
357
|
+
if (isNaN(Number(varMatch[2]?.trim()))) {
|
|
358
|
+
throw new Error("Scaling variables should be numbers");
|
|
359
|
+
}
|
|
360
|
+
return [Number(varMatch[2]?.trim()), varMatch[1]?.trim()];
|
|
361
|
+
}
|
|
362
|
+
function parseListMetaVar(content, varName) {
|
|
363
|
+
const listMatch = content.match(
|
|
364
|
+
new RegExp(
|
|
365
|
+
`^${varName}:\\s*(?:\\[([^\\]]*)\\]|((?:\\r?\\n\\s*-\\s*.+)+))`,
|
|
366
|
+
"m"
|
|
367
|
+
)
|
|
368
|
+
);
|
|
369
|
+
if (!listMatch) return void 0;
|
|
370
|
+
if (listMatch[1] !== void 0) {
|
|
371
|
+
return listMatch[1].split(",").map((tag) => tag.trim());
|
|
372
|
+
} else if (listMatch[2]) {
|
|
373
|
+
return listMatch[2].split("\n").filter((line) => line.trim() !== "").map((line) => line.replace(/^\s*-\s*/, "").trim());
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
function extractMetadata(content) {
|
|
377
|
+
const metadata = {};
|
|
378
|
+
let servings = void 0;
|
|
379
|
+
const metadataContent = content.match(metadataRegex)?.[1];
|
|
380
|
+
if (!metadataContent) {
|
|
381
|
+
return { metadata };
|
|
382
|
+
}
|
|
383
|
+
for (const metaVar of [
|
|
384
|
+
"title",
|
|
385
|
+
"source",
|
|
386
|
+
"source.name",
|
|
387
|
+
"source.url",
|
|
388
|
+
"author",
|
|
389
|
+
"source.author",
|
|
390
|
+
"prep time",
|
|
391
|
+
"time.prep",
|
|
392
|
+
"cook time",
|
|
393
|
+
"time.cook",
|
|
394
|
+
"time required",
|
|
395
|
+
"time",
|
|
396
|
+
"duration",
|
|
397
|
+
"locale",
|
|
398
|
+
"introduction",
|
|
399
|
+
"description",
|
|
400
|
+
"course",
|
|
401
|
+
"category",
|
|
402
|
+
"diet",
|
|
403
|
+
"cuisine",
|
|
404
|
+
"difficulty",
|
|
405
|
+
"image",
|
|
406
|
+
"picture"
|
|
407
|
+
]) {
|
|
408
|
+
const stringMetaValue = parseSimpleMetaVar(metadataContent, metaVar);
|
|
409
|
+
if (stringMetaValue) metadata[metaVar] = stringMetaValue;
|
|
410
|
+
}
|
|
411
|
+
for (const metaVar of ["servings", "yield", "serves"]) {
|
|
412
|
+
const scalingMetaValue = parseScalingMetaVar(metadataContent, metaVar);
|
|
413
|
+
if (scalingMetaValue && scalingMetaValue[1]) {
|
|
414
|
+
metadata[metaVar] = scalingMetaValue[1];
|
|
415
|
+
servings = scalingMetaValue[0];
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
for (const metaVar of ["tags", "images", "pictures"]) {
|
|
419
|
+
const listMetaValue = parseListMetaVar(metadataContent, metaVar);
|
|
420
|
+
if (listMetaValue) metadata[metaVar] = listMetaValue;
|
|
421
|
+
}
|
|
422
|
+
return { metadata, servings };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// src/classes/recipe.ts
|
|
426
|
+
var Recipe = class _Recipe {
|
|
427
|
+
/**
|
|
428
|
+
* Creates a new Recipe instance.
|
|
429
|
+
* @param content - The recipe content to parse.
|
|
430
|
+
*/
|
|
431
|
+
constructor(content) {
|
|
432
|
+
/**
|
|
433
|
+
* The recipe's metadata.
|
|
434
|
+
* @see {@link Metadata}
|
|
435
|
+
*/
|
|
436
|
+
__publicField(this, "metadata", {});
|
|
437
|
+
/**
|
|
438
|
+
* The recipe's ingredients.
|
|
439
|
+
* @see {@link Ingredient}
|
|
440
|
+
*/
|
|
441
|
+
__publicField(this, "ingredients", []);
|
|
442
|
+
/**
|
|
443
|
+
* The recipe's sections.
|
|
444
|
+
* @see {@link Section}
|
|
445
|
+
*/
|
|
446
|
+
__publicField(this, "sections", []);
|
|
447
|
+
/**
|
|
448
|
+
* The recipe's cookware.
|
|
449
|
+
* @see {@link Cookware}
|
|
450
|
+
*/
|
|
451
|
+
__publicField(this, "cookware", []);
|
|
452
|
+
/**
|
|
453
|
+
* The recipe's timers.
|
|
454
|
+
* @see {@link Timer}
|
|
455
|
+
*/
|
|
456
|
+
__publicField(this, "timers", []);
|
|
457
|
+
/**
|
|
458
|
+
* The recipe's servings. Used for scaling
|
|
459
|
+
*/
|
|
460
|
+
__publicField(this, "servings");
|
|
461
|
+
if (content) {
|
|
462
|
+
this.parse(content);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Parses a recipe from a string.
|
|
467
|
+
* @param content - The recipe content to parse.
|
|
468
|
+
*/
|
|
469
|
+
parse(content) {
|
|
470
|
+
const cleanContent = content.replace(metadataRegex, "").replace(commentRegex, "").replace(blockCommentRegex, "").trim().split(/\r\n?|\n/);
|
|
471
|
+
const { metadata, servings } = extractMetadata(content);
|
|
472
|
+
this.metadata = metadata;
|
|
473
|
+
this.servings = servings;
|
|
474
|
+
let blankLineBefore = true;
|
|
475
|
+
let section = new Section();
|
|
476
|
+
const items = [];
|
|
477
|
+
let note = "";
|
|
478
|
+
let inNote = false;
|
|
479
|
+
for (const line of cleanContent) {
|
|
480
|
+
if (line.trim().length === 0) {
|
|
481
|
+
flushPendingItems(section, items);
|
|
482
|
+
note = flushPendingNote(section, note);
|
|
483
|
+
blankLineBefore = true;
|
|
484
|
+
inNote = false;
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
if (line.startsWith("=")) {
|
|
488
|
+
flushPendingItems(section, items);
|
|
489
|
+
note = flushPendingNote(section, note);
|
|
490
|
+
if (this.sections.length === 0 && section.isBlank()) {
|
|
491
|
+
section.name = line.substring(1).trim();
|
|
492
|
+
} else {
|
|
493
|
+
if (!section.isBlank()) {
|
|
494
|
+
this.sections.push(section);
|
|
495
|
+
}
|
|
496
|
+
section = new Section(line.substring(1).trim());
|
|
497
|
+
}
|
|
498
|
+
blankLineBefore = true;
|
|
499
|
+
inNote = false;
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
if (blankLineBefore && line.startsWith(">")) {
|
|
503
|
+
flushPendingItems(section, items);
|
|
504
|
+
note = flushPendingNote(section, note);
|
|
505
|
+
note += line.substring(1).trim();
|
|
506
|
+
inNote = true;
|
|
507
|
+
blankLineBefore = false;
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
if (inNote) {
|
|
511
|
+
if (line.startsWith(">")) {
|
|
512
|
+
note += " " + line.substring(1).trim();
|
|
513
|
+
} else {
|
|
514
|
+
note += " " + line.trim();
|
|
515
|
+
}
|
|
516
|
+
blankLineBefore = false;
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
note = flushPendingNote(section, note);
|
|
520
|
+
let cursor = 0;
|
|
521
|
+
for (const match of line.matchAll(tokensRegex)) {
|
|
522
|
+
const idx = match.index;
|
|
523
|
+
if (idx > cursor) {
|
|
524
|
+
items.push({ type: "text", value: line.slice(cursor, idx) });
|
|
525
|
+
}
|
|
526
|
+
const groups = match.groups;
|
|
527
|
+
if (groups.mIngredientName || groups.sIngredientName) {
|
|
528
|
+
const name = groups.mIngredientName || groups.sIngredientName;
|
|
529
|
+
const quantityRaw = groups.mIngredientQuantity || groups.sIngredientQuantity;
|
|
530
|
+
const units2 = groups.mIngredientUnits || groups.sIngredientUnits;
|
|
531
|
+
const preparation = groups.mIngredientPreparation || groups.sIngredientPreparation;
|
|
532
|
+
const modifier = groups.mIngredientModifier || groups.sIngredientModifier;
|
|
533
|
+
const optional = modifier === "?";
|
|
534
|
+
const hidden = modifier === "-";
|
|
535
|
+
const reference = modifier === "&";
|
|
536
|
+
const isRecipe = modifier === "@";
|
|
537
|
+
const quantity = quantityRaw ? parseNumber(quantityRaw) : void 0;
|
|
538
|
+
const idxInList = findAndUpsertIngredient(
|
|
539
|
+
this.ingredients,
|
|
540
|
+
{
|
|
541
|
+
name,
|
|
542
|
+
quantity,
|
|
543
|
+
unit: units2,
|
|
544
|
+
optional,
|
|
545
|
+
hidden,
|
|
546
|
+
preparation,
|
|
547
|
+
isRecipe
|
|
548
|
+
},
|
|
549
|
+
reference
|
|
550
|
+
);
|
|
551
|
+
items.push({ type: "ingredient", value: idxInList });
|
|
552
|
+
} else if (groups.mCookwareName || groups.sCookwareName) {
|
|
553
|
+
const name = groups.mCookwareName || groups.sCookwareName;
|
|
554
|
+
const modifier = groups.mCookwareModifier || groups.sCookwareModifier;
|
|
555
|
+
const optional = modifier === "?";
|
|
556
|
+
const hidden = modifier === "-";
|
|
557
|
+
const reference = modifier === "&";
|
|
558
|
+
const idxInList = findAndUpsertCookware(
|
|
559
|
+
this.cookware,
|
|
560
|
+
{ name, optional, hidden },
|
|
561
|
+
reference
|
|
562
|
+
);
|
|
563
|
+
items.push({ type: "cookware", value: idxInList });
|
|
564
|
+
} else if (groups.timerQuantity !== void 0) {
|
|
565
|
+
const durationStr = groups.timerQuantity.trim();
|
|
566
|
+
const unit = (groups.timerUnits || "").trim();
|
|
567
|
+
if (!unit) {
|
|
568
|
+
throw new Error("Timer missing units");
|
|
569
|
+
}
|
|
570
|
+
const name = groups.timerName || void 0;
|
|
571
|
+
const duration = parseNumber(durationStr);
|
|
572
|
+
const timerObj = {
|
|
573
|
+
name,
|
|
574
|
+
duration,
|
|
575
|
+
unit
|
|
576
|
+
};
|
|
577
|
+
const idxInList = findOrPush(
|
|
578
|
+
this.timers,
|
|
579
|
+
(t) => t.name === timerObj.name && t.duration === timerObj.duration && t.unit === timerObj.unit,
|
|
580
|
+
() => timerObj
|
|
581
|
+
);
|
|
582
|
+
items.push({ type: "timer", value: idxInList });
|
|
583
|
+
}
|
|
584
|
+
cursor = idx + match[0].length;
|
|
585
|
+
}
|
|
586
|
+
if (cursor < line.length) {
|
|
587
|
+
items.push({ type: "text", value: line.slice(cursor) });
|
|
588
|
+
}
|
|
589
|
+
blankLineBefore = false;
|
|
590
|
+
}
|
|
591
|
+
flushPendingItems(section, items);
|
|
592
|
+
note = flushPendingNote(section, note);
|
|
593
|
+
if (!section.isBlank()) {
|
|
594
|
+
this.sections.push(section);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* Scales the recipe to a new number of servings.
|
|
599
|
+
* @param newServings - The new number of servings.
|
|
600
|
+
* @returns A new Recipe instance with the scaled ingredients.
|
|
601
|
+
*/
|
|
602
|
+
scaleTo(newServings) {
|
|
603
|
+
const originalServings = this.getServings();
|
|
604
|
+
if (originalServings === void 0 || originalServings === 0) {
|
|
605
|
+
throw new Error("Error scaling recipe: no initial servings value set");
|
|
606
|
+
}
|
|
607
|
+
const factor = newServings / originalServings;
|
|
608
|
+
return this.scaleBy(factor);
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Scales the recipe by a factor.
|
|
612
|
+
* @param factor - The factor to scale the recipe by.
|
|
613
|
+
* @returns A new Recipe instance with the scaled ingredients.
|
|
614
|
+
*/
|
|
615
|
+
scaleBy(factor) {
|
|
616
|
+
const newRecipe = this.clone();
|
|
617
|
+
const originalServings = newRecipe.getServings();
|
|
618
|
+
if (originalServings === void 0 || originalServings === 0) {
|
|
619
|
+
throw new Error("Error scaling recipe: no initial servings value set");
|
|
620
|
+
}
|
|
621
|
+
newRecipe.ingredients = newRecipe.ingredients.map((ingredient) => {
|
|
622
|
+
if (ingredient.quantity && !isNaN(Number(ingredient.quantity))) {
|
|
623
|
+
ingredient.quantity *= factor;
|
|
624
|
+
}
|
|
625
|
+
return ingredient;
|
|
626
|
+
}).filter((ingredient) => ingredient.quantity !== null);
|
|
627
|
+
newRecipe.servings = originalServings * factor;
|
|
628
|
+
if (newRecipe.metadata.servings && this.metadata.servings) {
|
|
629
|
+
const servingsValue = parseFloat(this.metadata.servings);
|
|
630
|
+
if (!isNaN(servingsValue)) {
|
|
631
|
+
newRecipe.metadata.servings = String(servingsValue * factor);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
if (newRecipe.metadata.yield && this.metadata.yield) {
|
|
635
|
+
const yieldValue = parseFloat(this.metadata.yield);
|
|
636
|
+
if (!isNaN(yieldValue)) {
|
|
637
|
+
newRecipe.metadata.yield = String(yieldValue * factor);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
if (newRecipe.metadata.serves && this.metadata.serves) {
|
|
641
|
+
const servesValue = parseFloat(this.metadata.serves);
|
|
642
|
+
if (!isNaN(servesValue)) {
|
|
643
|
+
newRecipe.metadata.serves = String(servesValue * factor);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
return newRecipe;
|
|
647
|
+
}
|
|
648
|
+
getServings() {
|
|
649
|
+
if (this.servings) {
|
|
650
|
+
return this.servings;
|
|
651
|
+
}
|
|
652
|
+
return void 0;
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* Clones the recipe.
|
|
656
|
+
* @returns A new Recipe instance with the same properties.
|
|
657
|
+
*/
|
|
658
|
+
clone() {
|
|
659
|
+
const newRecipe = new _Recipe();
|
|
660
|
+
newRecipe.metadata = JSON.parse(JSON.stringify(this.metadata));
|
|
661
|
+
newRecipe.ingredients = JSON.parse(JSON.stringify(this.ingredients));
|
|
662
|
+
newRecipe.sections = JSON.parse(JSON.stringify(this.sections));
|
|
663
|
+
newRecipe.cookware = JSON.parse(JSON.stringify(this.cookware));
|
|
664
|
+
newRecipe.timers = JSON.parse(JSON.stringify(this.timers));
|
|
665
|
+
newRecipe.servings = this.servings;
|
|
666
|
+
return newRecipe;
|
|
667
|
+
}
|
|
668
|
+
};
|
|
669
|
+
|
|
670
|
+
// src/classes/shopping_list.ts
|
|
671
|
+
var ShoppingList = class {
|
|
672
|
+
/**
|
|
673
|
+
* Creates a new ShoppingList instance.
|
|
674
|
+
* @param aisle_config_str - The aisle configuration to parse.
|
|
675
|
+
*/
|
|
676
|
+
constructor(aisle_config_str) {
|
|
677
|
+
/**
|
|
678
|
+
* The ingredients in the shopping list.
|
|
679
|
+
* @see {@link Ingredient}
|
|
680
|
+
*/
|
|
681
|
+
__publicField(this, "ingredients", []);
|
|
682
|
+
/**
|
|
683
|
+
* The recipes in the shopping list.
|
|
684
|
+
* @see {@link AddedRecipe}
|
|
685
|
+
*/
|
|
686
|
+
__publicField(this, "recipes", []);
|
|
687
|
+
/**
|
|
688
|
+
* The aisle configuration for the shopping list.
|
|
689
|
+
* @see {@link AisleConfig}
|
|
690
|
+
*/
|
|
691
|
+
__publicField(this, "aisle_config");
|
|
692
|
+
/**
|
|
693
|
+
* The categorized ingredients in the shopping list.
|
|
694
|
+
* @see {@link CategorizedIngredients}
|
|
695
|
+
*/
|
|
696
|
+
__publicField(this, "categories");
|
|
697
|
+
if (aisle_config_str) {
|
|
698
|
+
this.set_aisle_config(aisle_config_str);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
calculate_ingredients() {
|
|
702
|
+
this.ingredients = [];
|
|
703
|
+
for (const { recipe, factor } of this.recipes) {
|
|
704
|
+
const scaledRecipe = factor === 1 ? recipe : recipe.scaleBy(factor);
|
|
705
|
+
for (const ingredient of scaledRecipe.ingredients) {
|
|
706
|
+
if (ingredient.hidden) {
|
|
707
|
+
continue;
|
|
708
|
+
}
|
|
709
|
+
const existingIngredient = this.ingredients.find(
|
|
710
|
+
(i) => i.name === ingredient.name
|
|
711
|
+
);
|
|
712
|
+
let addSeparate = false;
|
|
713
|
+
try {
|
|
714
|
+
if (existingIngredient) {
|
|
715
|
+
if (existingIngredient.quantity && ingredient.quantity) {
|
|
716
|
+
const newQuantity = addQuantities(
|
|
717
|
+
{
|
|
718
|
+
value: existingIngredient.quantity,
|
|
719
|
+
unit: existingIngredient.unit ?? ""
|
|
720
|
+
},
|
|
721
|
+
{
|
|
722
|
+
value: ingredient.quantity,
|
|
723
|
+
unit: ingredient.unit ?? ""
|
|
724
|
+
}
|
|
725
|
+
);
|
|
726
|
+
existingIngredient.quantity = newQuantity.value;
|
|
727
|
+
if (newQuantity.unit) {
|
|
728
|
+
existingIngredient.unit = newQuantity.unit;
|
|
729
|
+
}
|
|
730
|
+
} else if (ingredient.quantity) {
|
|
731
|
+
existingIngredient.quantity = ingredient.quantity;
|
|
732
|
+
if (ingredient.unit) {
|
|
733
|
+
existingIngredient.unit = ingredient.unit;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
} catch {
|
|
738
|
+
addSeparate = true;
|
|
739
|
+
}
|
|
740
|
+
if (!existingIngredient || addSeparate) {
|
|
741
|
+
const newIngredient = { name: ingredient.name };
|
|
742
|
+
if (ingredient.quantity) {
|
|
743
|
+
newIngredient.quantity = ingredient.quantity;
|
|
744
|
+
}
|
|
745
|
+
if (ingredient.unit) {
|
|
746
|
+
newIngredient.unit = ingredient.unit;
|
|
747
|
+
}
|
|
748
|
+
this.ingredients.push(newIngredient);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
/**
|
|
754
|
+
* Adds a recipe to the shopping list.
|
|
755
|
+
* @param recipe - The recipe to add.
|
|
756
|
+
* @param factor - The factor to scale the recipe by.
|
|
757
|
+
*/
|
|
758
|
+
add_recipe(recipe, factor = 1) {
|
|
759
|
+
this.recipes.push({ recipe, factor });
|
|
760
|
+
this.calculate_ingredients();
|
|
761
|
+
this.categorize();
|
|
762
|
+
}
|
|
763
|
+
/**
|
|
764
|
+
* Removes a recipe from the shopping list.
|
|
765
|
+
* @param index - The index of the recipe to remove.
|
|
766
|
+
*/
|
|
767
|
+
remove_recipe(index) {
|
|
768
|
+
if (index < 0 || index >= this.recipes.length) {
|
|
769
|
+
throw new Error("Index out of bounds");
|
|
770
|
+
}
|
|
771
|
+
this.recipes.splice(index, 1);
|
|
772
|
+
this.calculate_ingredients();
|
|
773
|
+
this.categorize();
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* Sets the aisle configuration for the shopping list.
|
|
777
|
+
* @param config - The aisle configuration to parse.
|
|
778
|
+
*/
|
|
779
|
+
set_aisle_config(config) {
|
|
780
|
+
this.aisle_config = new AisleConfig(config);
|
|
781
|
+
this.categorize();
|
|
782
|
+
}
|
|
783
|
+
/**
|
|
784
|
+
* Categorizes the ingredients in the shopping list
|
|
785
|
+
* Will use the aisle config if any, otherwise all ingredients will be placed in the "other" category
|
|
786
|
+
*/
|
|
787
|
+
categorize() {
|
|
788
|
+
if (!this.aisle_config) {
|
|
789
|
+
this.categories = { other: this.ingredients };
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
const categories = { other: [] };
|
|
793
|
+
for (const category of this.aisle_config.categories) {
|
|
794
|
+
categories[category.name] = [];
|
|
795
|
+
}
|
|
796
|
+
for (const ingredient of this.ingredients) {
|
|
797
|
+
let found = false;
|
|
798
|
+
for (const category of this.aisle_config.categories) {
|
|
799
|
+
for (const aisleIngredient of category.ingredients) {
|
|
800
|
+
if (aisleIngredient.aliases.includes(ingredient.name)) {
|
|
801
|
+
categories[category.name].push(ingredient);
|
|
802
|
+
found = true;
|
|
803
|
+
break;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
if (found) {
|
|
807
|
+
break;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
if (!found) {
|
|
811
|
+
categories.other.push(ingredient);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
this.categories = categories;
|
|
815
|
+
}
|
|
816
|
+
};
|
|
817
|
+
export {
|
|
818
|
+
AisleConfig,
|
|
819
|
+
Recipe,
|
|
820
|
+
ShoppingList
|
|
821
|
+
};
|
|
822
|
+
//# sourceMappingURL=index.js.map
|