@tmlmt/cooklang-parser 2.1.8 → 3.0.0-alpha.10
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/dist/index.cjs +2112 -387
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +972 -70
- package/dist/index.d.ts +972 -70
- package/dist/index.js +2095 -386
- package/dist/index.js.map +1 -1
- package/package.json +22 -19
package/dist/index.cjs
CHANGED
|
@@ -33,9 +33,25 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
|
|
|
33
33
|
var index_exports = {};
|
|
34
34
|
__export(index_exports, {
|
|
35
35
|
CategoryConfig: () => CategoryConfig,
|
|
36
|
+
NoProductCatalogForCartError: () => NoProductCatalogForCartError,
|
|
37
|
+
NoShoppingListForCartError: () => NoShoppingListForCartError,
|
|
38
|
+
ProductCatalog: () => ProductCatalog,
|
|
36
39
|
Recipe: () => Recipe,
|
|
37
40
|
Section: () => Section,
|
|
38
|
-
|
|
41
|
+
ShoppingCart: () => ShoppingCart,
|
|
42
|
+
ShoppingList: () => ShoppingList,
|
|
43
|
+
formatExtendedQuantity: () => formatExtendedQuantity,
|
|
44
|
+
formatItemQuantity: () => formatItemQuantity,
|
|
45
|
+
formatNumericValue: () => formatNumericValue,
|
|
46
|
+
formatQuantity: () => formatQuantity,
|
|
47
|
+
formatQuantityWithUnit: () => formatQuantityWithUnit,
|
|
48
|
+
formatSingleValue: () => formatSingleValue,
|
|
49
|
+
formatUnit: () => formatUnit,
|
|
50
|
+
hasAlternatives: () => hasAlternatives,
|
|
51
|
+
isAlternativeSelected: () => isAlternativeSelected,
|
|
52
|
+
isAndGroup: () => isAndGroup,
|
|
53
|
+
isGroupedItem: () => isGroupedItem,
|
|
54
|
+
isSimpleGroup: () => isSimpleGroup
|
|
39
55
|
});
|
|
40
56
|
module.exports = __toCommonJS(index_exports);
|
|
41
57
|
|
|
@@ -100,33 +116,10 @@ var CategoryConfig = class {
|
|
|
100
116
|
}
|
|
101
117
|
};
|
|
102
118
|
|
|
103
|
-
// src/classes/
|
|
104
|
-
var
|
|
105
|
-
/**
|
|
106
|
-
* Creates an instance of Section.
|
|
107
|
-
* @param name - The name of the section. Defaults to an empty string.
|
|
108
|
-
*/
|
|
109
|
-
constructor(name = "") {
|
|
110
|
-
/**
|
|
111
|
-
* The name of the section. Can be an empty string for the default (first) section.
|
|
112
|
-
* @defaultValue `""`
|
|
113
|
-
*/
|
|
114
|
-
__publicField(this, "name");
|
|
115
|
-
/** An array of steps and notes that make up the content of the section. */
|
|
116
|
-
__publicField(this, "content", []);
|
|
117
|
-
this.name = name;
|
|
118
|
-
}
|
|
119
|
-
/**
|
|
120
|
-
* Checks if the section is blank (has no name and no content).
|
|
121
|
-
* Used during recipe parsing
|
|
122
|
-
* @returns `true` if the section is blank, otherwise `false`.
|
|
123
|
-
*/
|
|
124
|
-
isBlank() {
|
|
125
|
-
return this.name === "" && this.content.length === 0;
|
|
126
|
-
}
|
|
127
|
-
};
|
|
119
|
+
// src/classes/product_catalog.ts
|
|
120
|
+
var import_smol_toml = __toESM(require("smol-toml"), 1);
|
|
128
121
|
|
|
129
|
-
// node_modules/.pnpm/human-regex@2.
|
|
122
|
+
// node_modules/.pnpm/human-regex@2.2.0/node_modules/human-regex/dist/human-regex.esm.js
|
|
130
123
|
var t = /* @__PURE__ */ new Map();
|
|
131
124
|
var r = { GLOBAL: "g", NON_SENSITIVE: "i", MULTILINE: "m", DOT_ALL: "s", UNICODE: "u", STICKY: "y" };
|
|
132
125
|
var e = Object.freeze({ digit: "0-9", lowercaseLetter: "a-z", uppercaseLetter: "A-Z", letter: "a-zA-Z", alphanumeric: "a-zA-Z0-9", anyCharacter: "." });
|
|
@@ -187,7 +180,7 @@ var a = class {
|
|
|
187
180
|
return this.add(".");
|
|
188
181
|
}
|
|
189
182
|
newline() {
|
|
190
|
-
return this.add("(
|
|
183
|
+
return this.add("(\\r\\n|\\r|\\n)");
|
|
191
184
|
}
|
|
192
185
|
negativeLookahead(t2) {
|
|
193
186
|
return this.add(`(?!${t2})`);
|
|
@@ -328,19 +321,22 @@ var i = (() => {
|
|
|
328
321
|
var metadataRegex = d().literal("---").newline().startCaptureGroup().anyCharacter().zeroOrMore().optional().endGroup().newline().literal("---").dotAll().toRegExp();
|
|
329
322
|
var scalingMetaValueRegex = (varName) => d().startAnchor().literal(varName).literal(":").anyOf("\\t ").zeroOrMore().startCaptureGroup().startCaptureGroup().notAnyOf(",\\n").oneOrMore().endGroup().startGroup().literal(",").whitespace().zeroOrMore().startCaptureGroup().anyCharacter().oneOrMore().endGroup().endGroup().optional().endGroup().endAnchor().multiline().toRegExp();
|
|
330
323
|
var nonWordChar = "\\s@#~\\[\\]{(,;:!?";
|
|
331
|
-
var
|
|
332
|
-
var
|
|
324
|
+
var nonWordCharStrict = "\\s@#~\\[\\]{(,;:!?|";
|
|
325
|
+
var ingredientWithAlternativeRegex = d().literal("@").startNamedGroup("ingredientModifiers").anyOf("@\\-&?").zeroOrMore().endGroup().optional().startNamedGroup("ingredientRecipeAnchor").literal("./").endGroup().optional().startGroup().startGroup().startNamedGroup("mIngredientName").notAnyOf(nonWordChar).oneOrMore().startGroup().whitespace().oneOrMore().notAnyOf(nonWordChar).oneOrMore().endGroup().oneOrMore().endGroup().positiveLookahead("\\s*(?:\\{[^\\}]*\\}|\\([^)]*\\))").endGroup().or().startNamedGroup("sIngredientName").notAnyOf(nonWordChar).zeroOrMore().notAnyOf("\\." + nonWordChar).endGroup().endGroup().startGroup().literal("{").startNamedGroup("ingredientQuantityModifier").literal("=").exactly(1).endGroup().optional().startNamedGroup("ingredientQuantity").startGroup().notAnyOf("}|%").oneOrMore().endGroup().optional().startGroup().literal("%").notAnyOf("|}").oneOrMore().lazy().endGroup().optional().startGroup().literal("|").notAnyOf("}").oneOrMore().lazy().endGroup().zeroOrMore().endGroup().literal("}").endGroup().optional().startGroup().literal("(").startNamedGroup("ingredientPreparation").notAnyOf(")").oneOrMore().lazy().endGroup().literal(")").endGroup().optional().startGroup().literal("[").startNamedGroup("ingredientNote").notAnyOf("\\]").oneOrMore().lazy().endGroup().literal("]").endGroup().optional().startNamedGroup("ingredientAlternative").startGroup().literal("|").startGroup().anyOf("@\\-&?").zeroOrMore().endGroup().optional().startGroup().literal("./").endGroup().optional().startGroup().startGroup().startGroup().notAnyOf(nonWordChar).oneOrMore().startGroup().whitespace().oneOrMore().notAnyOf(nonWordChar).oneOrMore().endGroup().oneOrMore().endGroup().positiveLookahead("\\s*(?:\\{[^\\}]*\\}|\\([^)]*\\))").endGroup().or().startGroup().notAnyOf(nonWordChar).oneOrMore().endGroup().endGroup().startGroup().literal("{").startGroup().literal("=").exactly(1).endGroup().optional().startGroup().notAnyOf("}%").oneOrMore().endGroup().optional().startGroup().literal("%").startGroup().notAnyOf("}").oneOrMore().lazy().endGroup().endGroup().optional().literal("}").endGroup().optional().startGroup().literal("(").startGroup().notAnyOf(")").oneOrMore().lazy().endGroup().literal(")").endGroup().optional().startGroup().literal("[").startGroup().notAnyOf("\\]").oneOrMore().lazy().endGroup().literal("]").endGroup().optional().endGroup().zeroOrMore().endGroup().toRegExp();
|
|
326
|
+
var inlineIngredientAlternativesRegex = new RegExp("\\|" + ingredientWithAlternativeRegex.source.slice(1));
|
|
327
|
+
var quantityAlternativeRegex = d().startNamedGroup("quantity").notAnyOf("}|%").oneOrMore().endGroup().optional().startGroup().literal("%").startNamedGroup("unit").notAnyOf("|}").oneOrMore().endGroup().endGroup().optional().startGroup().literal("|").startNamedGroup("alternative").startGroup().notAnyOf("}").oneOrMore().endGroup().zeroOrMore().endGroup().endGroup().optional().toRegExp();
|
|
328
|
+
var ingredientWithGroupKeyRegex = d().literal("@|").startNamedGroup("gIngredientGroupKey").notAnyOf(nonWordCharStrict).oneOrMore().endGroup().literal("|").startNamedGroup("gIngredientModifiers").anyOf("@\\-&?").zeroOrMore().endGroup().optional().startNamedGroup("gIngredientRecipeAnchor").literal("./").endGroup().optional().startGroup().startGroup().startNamedGroup("gmIngredientName").notAnyOf(nonWordChar).oneOrMore().startGroup().whitespace().oneOrMore().notAnyOf(nonWordChar).oneOrMore().endGroup().oneOrMore().endGroup().positiveLookahead("\\s*(?:\\{[^\\}]*\\}|\\([^)]*\\))").endGroup().or().startNamedGroup("gsIngredientName").notAnyOf(nonWordChar).zeroOrMore().notAnyOf("\\." + nonWordChar).endGroup().endGroup().startGroup().literal("{").startNamedGroup("gIngredientQuantityModifier").literal("=").exactly(1).endGroup().optional().startNamedGroup("gIngredientQuantity").startGroup().notAnyOf("}|%").oneOrMore().endGroup().optional().startGroup().literal("%").notAnyOf("|}").oneOrMore().lazy().endGroup().optional().startGroup().literal("|").notAnyOf("}").oneOrMore().lazy().endGroup().zeroOrMore().endGroup().literal("}").endGroup().optional().startGroup().literal("(").startNamedGroup("gIngredientPreparation").notAnyOf(")").oneOrMore().lazy().endGroup().literal(")").endGroup().optional().toRegExp();
|
|
333
329
|
var ingredientAliasRegex = d().startAnchor().startNamedGroup("ingredientListName").notAnyOf("|").oneOrMore().endGroup().literal("|").startNamedGroup("ingredientDisplayName").notAnyOf("|").oneOrMore().endGroup().endAnchor().toRegExp();
|
|
334
|
-
var
|
|
335
|
-
var
|
|
336
|
-
var
|
|
330
|
+
var cookwareRegex = d().literal("#").startNamedGroup("cookwareModifiers").anyOf("\\-&?").zeroOrMore().endGroup().startGroup().startGroup().startNamedGroup("mCookwareName").notAnyOf(nonWordChar).oneOrMore().startGroup().whitespace().oneOrMore().notAnyOf(nonWordChar).oneOrMore().endGroup().oneOrMore().endGroup().positiveLookahead("\\s*(?:\\{[^\\}]*\\})").endGroup().or().startNamedGroup("sCookwareName").notAnyOf(nonWordChar).zeroOrMore().notAnyOf("\\." + nonWordChar).endGroup().endGroup().startGroup().literal("{").startNamedGroup("cookwareQuantity").anyCharacter().zeroOrMore().lazy().endGroup().literal("}").endGroup().optional().toRegExp();
|
|
331
|
+
var timerRegex = d().literal("~").startNamedGroup("timerName").anyCharacter().zeroOrMore().lazy().endGroup().literal("{").startNamedGroup("timerQuantity").anyCharacter().oneOrMore().lazy().endGroup().startGroup().literal("%").startNamedGroup("timerUnit").anyCharacter().oneOrMore().lazy().endGroup().endGroup().optional().literal("}").toRegExp();
|
|
332
|
+
var arbitraryScalableRegex = d().literal("{{").startGroup().startNamedGroup("arbitraryName").notAnyOf("}:%").oneOrMore().endGroup().literal(":").endGroup().optional().startNamedGroup("arbitraryQuantity").startGroup().notAnyOf("}|%").oneOrMore().endGroup().optional().startGroup().literal("%").notAnyOf("|}").oneOrMore().lazy().endGroup().optional().startGroup().literal("|").notAnyOf("}").oneOrMore().lazy().endGroup().zeroOrMore().endGroup().literal("}}").toRegExp();
|
|
337
333
|
var tokensRegex = new RegExp(
|
|
338
334
|
[
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
335
|
+
ingredientWithGroupKeyRegex,
|
|
336
|
+
ingredientWithAlternativeRegex,
|
|
337
|
+
cookwareRegex,
|
|
338
|
+
timerRegex,
|
|
339
|
+
arbitraryScalableRegex
|
|
344
340
|
].map((r2) => r2.source).join("|"),
|
|
345
341
|
"gu"
|
|
346
342
|
);
|
|
@@ -351,8 +347,7 @@ var rangeRegex = d().startAnchor().digit().oneOrMore().startGroup().anyOf(".,/")
|
|
|
351
347
|
var numberLikeRegex = d().startAnchor().digit().oneOrMore().startGroup().anyOf(".,/").exactly(1).digit().oneOrMore().endGroup().optional().endAnchor().toRegExp();
|
|
352
348
|
var floatRegex = d().startAnchor().digit().oneOrMore().startGroup().anyOf(".").exactly(1).digit().oneOrMore().endGroup().optional().endAnchor().toRegExp();
|
|
353
349
|
|
|
354
|
-
// src/units.ts
|
|
355
|
-
var import_big = __toESM(require("big.js"), 1);
|
|
350
|
+
// src/units/definitions.ts
|
|
356
351
|
var units = [
|
|
357
352
|
// Mass (Metric)
|
|
358
353
|
{
|
|
@@ -482,20 +477,19 @@ for (const unit of units) {
|
|
|
482
477
|
function normalizeUnit(unit = "") {
|
|
483
478
|
return unitMap.get(unit.toLowerCase().trim());
|
|
484
479
|
}
|
|
485
|
-
var
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
}
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
};
|
|
480
|
+
var NO_UNIT = "__no-unit__";
|
|
481
|
+
function resolveUnit(name = NO_UNIT, integerProtected = false) {
|
|
482
|
+
const normalizedUnit = normalizeUnit(name);
|
|
483
|
+
const resolvedUnit = normalizedUnit ? { ...normalizedUnit, name } : { name, type: "other", system: "none" };
|
|
484
|
+
return integerProtected ? { ...resolvedUnit, integerProtected: true } : resolvedUnit;
|
|
485
|
+
}
|
|
486
|
+
function isNoUnit(unit) {
|
|
487
|
+
if (!unit) return true;
|
|
488
|
+
return resolveUnit(unit.name).name === NO_UNIT;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// src/quantities/numeric.ts
|
|
492
|
+
var import_big = __toESM(require("big.js"), 1);
|
|
499
493
|
function gcd(a2, b) {
|
|
500
494
|
return b === 0 ? a2 : gcd(b, a2 % b);
|
|
501
495
|
}
|
|
@@ -511,14 +505,23 @@ function simplifyFraction(num, den) {
|
|
|
511
505
|
simplifiedDen = -simplifiedDen;
|
|
512
506
|
}
|
|
513
507
|
if (simplifiedDen === 1) {
|
|
514
|
-
return { type: "decimal",
|
|
508
|
+
return { type: "decimal", decimal: simplifiedNum };
|
|
515
509
|
} else {
|
|
516
510
|
return { type: "fraction", num: simplifiedNum, den: simplifiedDen };
|
|
517
511
|
}
|
|
518
512
|
}
|
|
513
|
+
function getNumericValue(v) {
|
|
514
|
+
if (v.type === "decimal") {
|
|
515
|
+
return v.decimal;
|
|
516
|
+
}
|
|
517
|
+
return v.num / v.den;
|
|
518
|
+
}
|
|
519
519
|
function multiplyNumericValue(v, factor) {
|
|
520
520
|
if (v.type === "decimal") {
|
|
521
|
-
return {
|
|
521
|
+
return {
|
|
522
|
+
type: "decimal",
|
|
523
|
+
decimal: (0, import_big.default)(v.decimal).times(factor).toNumber()
|
|
524
|
+
};
|
|
522
525
|
}
|
|
523
526
|
return simplifyFraction((0, import_big.default)(v.num).times(factor).toNumber(), v.den);
|
|
524
527
|
}
|
|
@@ -528,36 +531,36 @@ function addNumericValues(val1, val2) {
|
|
|
528
531
|
let num2;
|
|
529
532
|
let den2;
|
|
530
533
|
if (val1.type === "decimal") {
|
|
531
|
-
num1 = val1.
|
|
534
|
+
num1 = val1.decimal;
|
|
532
535
|
den1 = 1;
|
|
533
536
|
} else {
|
|
534
537
|
num1 = val1.num;
|
|
535
538
|
den1 = val1.den;
|
|
536
539
|
}
|
|
537
540
|
if (val2.type === "decimal") {
|
|
538
|
-
num2 = val2.
|
|
541
|
+
num2 = val2.decimal;
|
|
539
542
|
den2 = 1;
|
|
540
543
|
} else {
|
|
541
544
|
num2 = val2.num;
|
|
542
545
|
den2 = val2.den;
|
|
543
546
|
}
|
|
544
547
|
if (num1 === 0 && num2 === 0) {
|
|
545
|
-
return { type: "decimal",
|
|
548
|
+
return { type: "decimal", decimal: 0 };
|
|
546
549
|
}
|
|
547
|
-
if (val1.type === "fraction" && val2.type === "fraction" || val1.type === "fraction" && val2.type === "decimal" && val2.
|
|
550
|
+
if (val1.type === "fraction" && val2.type === "fraction" || val1.type === "fraction" && val2.type === "decimal" && val2.decimal === 0 || val2.type === "fraction" && val1.type === "decimal" && val1.decimal === 0) {
|
|
548
551
|
const commonDen = den1 * den2;
|
|
549
552
|
const sumNum = num1 * den2 + num2 * den1;
|
|
550
553
|
return simplifyFraction(sumNum, commonDen);
|
|
551
554
|
} else {
|
|
552
555
|
return {
|
|
553
556
|
type: "decimal",
|
|
554
|
-
|
|
557
|
+
decimal: (0, import_big.default)(num1).div(den1).add((0, import_big.default)(num2).div(den2)).toNumber()
|
|
555
558
|
};
|
|
556
559
|
}
|
|
557
560
|
}
|
|
558
561
|
var toRoundedDecimal = (v) => {
|
|
559
|
-
const value = v.type === "decimal" ? v.
|
|
560
|
-
return { type: "decimal",
|
|
562
|
+
const value = v.type === "decimal" ? v.decimal : v.num / v.den;
|
|
563
|
+
return { type: "decimal", decimal: Math.round(value * 1e3) / 1e3 };
|
|
561
564
|
};
|
|
562
565
|
function multiplyQuantityValue(value, factor) {
|
|
563
566
|
if (value.type === "fixed") {
|
|
@@ -565,8 +568,8 @@ function multiplyQuantityValue(value, factor) {
|
|
|
565
568
|
value.value,
|
|
566
569
|
(0, import_big.default)(factor)
|
|
567
570
|
);
|
|
568
|
-
if (factor === parseInt(factor.toString()) || // e.g. 2 === int
|
|
569
|
-
(0, import_big.default)(1).div(factor).toNumber() === parseInt((0, import_big.default)(1).div(factor).toString())) {
|
|
571
|
+
if (newValue.type === "fraction" && ((0, import_big.default)(factor).toNumber() === parseInt((0, import_big.default)(factor).toString()) || // e.g. 2 === int
|
|
572
|
+
(0, import_big.default)(1).div(factor).toNumber() === parseInt((0, import_big.default)(1).div(factor).toString()))) {
|
|
570
573
|
return {
|
|
571
574
|
type: "fixed",
|
|
572
575
|
value: newValue
|
|
@@ -583,13 +586,159 @@ function multiplyQuantityValue(value, factor) {
|
|
|
583
586
|
max: multiplyNumericValue(value.max, factor)
|
|
584
587
|
};
|
|
585
588
|
}
|
|
589
|
+
function getAverageValue(q) {
|
|
590
|
+
if (q.type === "fixed") {
|
|
591
|
+
return q.value.type === "text" ? q.value.text : getNumericValue(q.value);
|
|
592
|
+
} else {
|
|
593
|
+
return (getNumericValue(q.min) + getNumericValue(q.max)) / 2;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// src/errors.ts
|
|
598
|
+
var ReferencedItemCannotBeRedefinedError = class extends Error {
|
|
599
|
+
constructor(item_type, item_name, new_modifier) {
|
|
600
|
+
super(
|
|
601
|
+
`The referenced ${item_type} "${item_name}" cannot be redefined as ${new_modifier}.
|
|
602
|
+
You can either remove the reference to create a new ${item_type} defined as ${new_modifier} or add the ${new_modifier} flag to the original definition of the ${item_type}`
|
|
603
|
+
);
|
|
604
|
+
this.name = "ReferencedItemCannotBeRedefinedError";
|
|
605
|
+
}
|
|
606
|
+
};
|
|
607
|
+
var NoProductCatalogForCartError = class extends Error {
|
|
608
|
+
constructor() {
|
|
609
|
+
super(
|
|
610
|
+
`Cannot build a cart without a product catalog. Please set one using setProductCatalog()`
|
|
611
|
+
);
|
|
612
|
+
this.name = "NoProductCatalogForCartError";
|
|
613
|
+
}
|
|
614
|
+
};
|
|
615
|
+
var NoShoppingListForCartError = class extends Error {
|
|
616
|
+
constructor() {
|
|
617
|
+
super(
|
|
618
|
+
`Cannot build a cart without a shopping list. Please set one using setShoppingList()`
|
|
619
|
+
);
|
|
620
|
+
this.name = "NoShoppingListForCartError";
|
|
621
|
+
}
|
|
622
|
+
};
|
|
623
|
+
var NoProductMatchError = class extends Error {
|
|
624
|
+
constructor(item_name, code) {
|
|
625
|
+
const messageMap = {
|
|
626
|
+
incompatibleUnits: `The units of the products in the catalogue are incompatible with ingredient ${item_name} in the shopping list.`,
|
|
627
|
+
noProduct: "No product was found linked to ingredient name ${item_name} in the shopping list",
|
|
628
|
+
textValue: `Ingredient ${item_name} has a text value as quantity and can therefore not be matched with any product in the catalogue.`,
|
|
629
|
+
noQuantity: `Ingredient ${item_name} has no quantity and can therefore not be matched with any product in the catalogue.`,
|
|
630
|
+
textValue_incompatibleUnits: `Multiple alternative quantities were provided for ingredient ${item_name} in the shopping list but they were either text values or no product in catalog were found to have compatible units`
|
|
631
|
+
};
|
|
632
|
+
super(messageMap[code]);
|
|
633
|
+
__publicField(this, "code");
|
|
634
|
+
this.code = code;
|
|
635
|
+
this.name = "NoProductMatchError";
|
|
636
|
+
}
|
|
637
|
+
};
|
|
638
|
+
var InvalidProductCatalogFormat = class extends Error {
|
|
639
|
+
constructor() {
|
|
640
|
+
super("Invalid product catalog format.");
|
|
641
|
+
this.name = "InvalidProductCatalogFormat";
|
|
642
|
+
}
|
|
643
|
+
};
|
|
644
|
+
var CannotAddTextValueError = class extends Error {
|
|
645
|
+
constructor() {
|
|
646
|
+
super("Cannot add a quantity with a text value.");
|
|
647
|
+
this.name = "CannotAddTextValueError";
|
|
648
|
+
}
|
|
649
|
+
};
|
|
650
|
+
var IncompatibleUnitsError = class extends Error {
|
|
651
|
+
constructor(unit1, unit2) {
|
|
652
|
+
super(
|
|
653
|
+
`Cannot add quantities with incompatible or unknown units: ${unit1} and ${unit2}`
|
|
654
|
+
);
|
|
655
|
+
this.name = "IncompatibleUnitsError";
|
|
656
|
+
}
|
|
657
|
+
};
|
|
658
|
+
var InvalidQuantityFormat = class extends Error {
|
|
659
|
+
constructor(value, extra) {
|
|
660
|
+
super(
|
|
661
|
+
`Invalid quantity format found in: ${value}${extra ? ` (${extra})` : ""}`
|
|
662
|
+
);
|
|
663
|
+
this.name = "InvalidQuantityFormat";
|
|
664
|
+
}
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
// src/utils/type_guards.ts
|
|
668
|
+
function isGroup(x) {
|
|
669
|
+
return "and" in x || "or" in x;
|
|
670
|
+
}
|
|
671
|
+
function isOrGroup(x) {
|
|
672
|
+
return isGroup(x) && "or" in x;
|
|
673
|
+
}
|
|
674
|
+
function isAndGroup(x) {
|
|
675
|
+
return "and" in x;
|
|
676
|
+
}
|
|
677
|
+
function isQuantity(x) {
|
|
678
|
+
return x && typeof x === "object" && "quantity" in x;
|
|
679
|
+
}
|
|
680
|
+
function isSimpleGroup(entry) {
|
|
681
|
+
return "quantity" in entry;
|
|
682
|
+
}
|
|
683
|
+
function isNumericValueIntegerLike(v) {
|
|
684
|
+
if (v.type === "decimal") return Number.isInteger(v.decimal);
|
|
685
|
+
return v.num % v.den === 0;
|
|
686
|
+
}
|
|
687
|
+
function isValueIntegerLike(q) {
|
|
688
|
+
if (q.type === "fixed") {
|
|
689
|
+
if (q.value.type === "text") return false;
|
|
690
|
+
return isNumericValueIntegerLike(q.value);
|
|
691
|
+
}
|
|
692
|
+
return isNumericValueIntegerLike(q.min) && isNumericValueIntegerLike(q.max);
|
|
693
|
+
}
|
|
694
|
+
function hasAlternatives(entry) {
|
|
695
|
+
return "alternatives" in entry && Array.isArray(entry.alternatives) && entry.alternatives.length > 0;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// src/quantities/mutations.ts
|
|
699
|
+
function extendAllUnits(q) {
|
|
700
|
+
if (isAndGroup(q)) {
|
|
701
|
+
return { and: q.and.map(extendAllUnits) };
|
|
702
|
+
} else if (isOrGroup(q)) {
|
|
703
|
+
return { or: q.or.map(extendAllUnits) };
|
|
704
|
+
} else {
|
|
705
|
+
const newQ = {
|
|
706
|
+
quantity: q.quantity
|
|
707
|
+
};
|
|
708
|
+
if (q.unit) {
|
|
709
|
+
newQ.unit = { name: q.unit };
|
|
710
|
+
}
|
|
711
|
+
return newQ;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
function normalizeAllUnits(q) {
|
|
715
|
+
if (isAndGroup(q)) {
|
|
716
|
+
return { and: q.and.map(normalizeAllUnits) };
|
|
717
|
+
} else if (isOrGroup(q)) {
|
|
718
|
+
return { or: q.or.map(normalizeAllUnits) };
|
|
719
|
+
} else {
|
|
720
|
+
const newQ = {
|
|
721
|
+
quantity: q.quantity,
|
|
722
|
+
unit: resolveUnit(q.unit)
|
|
723
|
+
};
|
|
724
|
+
if (q.equivalents && q.equivalents.length > 0) {
|
|
725
|
+
const equivalentsNormalized = q.equivalents.map(
|
|
726
|
+
(eq) => normalizeAllUnits(eq)
|
|
727
|
+
);
|
|
728
|
+
return {
|
|
729
|
+
or: [newQ, ...equivalentsNormalized]
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
return newQ;
|
|
733
|
+
}
|
|
734
|
+
}
|
|
586
735
|
var convertQuantityValue = (value, def, targetDef) => {
|
|
587
736
|
if (def.name === targetDef.name) return value;
|
|
588
737
|
const factor = def.toBase / targetDef.toBase;
|
|
589
738
|
return multiplyQuantityValue(value, factor);
|
|
590
739
|
};
|
|
591
740
|
function getDefaultQuantityValue() {
|
|
592
|
-
return { type: "fixed", value: { type: "decimal",
|
|
741
|
+
return { type: "fixed", value: { type: "decimal", decimal: 0 } };
|
|
593
742
|
}
|
|
594
743
|
function addQuantityValues(v1, v2) {
|
|
595
744
|
if (v1.type === "fixed" && v1.value.type === "text" || v2.type === "fixed" && v2.value.type === "text") {
|
|
@@ -615,28 +764,31 @@ function addQuantityValues(v1, v2) {
|
|
|
615
764
|
return { type: "range", min: newMin, max: newMax };
|
|
616
765
|
}
|
|
617
766
|
function addQuantities(q1, q2) {
|
|
618
|
-
const v1 = q1.
|
|
619
|
-
const v2 = q2.
|
|
767
|
+
const v1 = q1.quantity;
|
|
768
|
+
const v2 = q2.quantity;
|
|
620
769
|
if (v1.type === "fixed" && v1.value.type === "text" || v2.type === "fixed" && v2.value.type === "text") {
|
|
621
770
|
throw new CannotAddTextValueError();
|
|
622
771
|
}
|
|
623
|
-
const unit1Def = normalizeUnit(q1.unit);
|
|
624
|
-
const unit2Def = normalizeUnit(q2.unit);
|
|
625
|
-
const addQuantityValuesAndSetUnit = (val1, val2, unit) => ({
|
|
626
|
-
|
|
772
|
+
const unit1Def = normalizeUnit(q1.unit?.name);
|
|
773
|
+
const unit2Def = normalizeUnit(q2.unit?.name);
|
|
774
|
+
const addQuantityValuesAndSetUnit = (val1, val2, unit) => ({
|
|
775
|
+
quantity: addQuantityValues(val1, val2),
|
|
776
|
+
unit
|
|
777
|
+
});
|
|
778
|
+
if ((q1.unit?.name === "" || q1.unit === void 0) && q2.unit !== void 0) {
|
|
627
779
|
return addQuantityValuesAndSetUnit(v1, v2, q2.unit);
|
|
628
780
|
}
|
|
629
|
-
if ((q2.unit === "" || q2.unit === void 0) && q1.unit !== void 0) {
|
|
781
|
+
if ((q2.unit?.name === "" || q2.unit === void 0) && q1.unit !== void 0) {
|
|
630
782
|
return addQuantityValuesAndSetUnit(v1, v2, q1.unit);
|
|
631
783
|
}
|
|
632
|
-
if (!q1.unit && !q2.unit || q1.unit && q2.unit && q1.unit.toLowerCase() === q2.unit.toLowerCase()) {
|
|
784
|
+
if (!q1.unit && !q2.unit || q1.unit && q2.unit && q1.unit.name.toLowerCase() === q2.unit.name.toLowerCase()) {
|
|
633
785
|
return addQuantityValuesAndSetUnit(v1, v2, q1.unit);
|
|
634
786
|
}
|
|
635
787
|
if (unit1Def && unit2Def) {
|
|
636
788
|
if (unit1Def.type !== unit2Def.type) {
|
|
637
789
|
throw new IncompatibleUnitsError(
|
|
638
|
-
`${unit1Def.type} (${q1.unit})`,
|
|
639
|
-
`${unit2Def.type} (${q2.unit})`
|
|
790
|
+
`${unit1Def.type} (${q1.unit?.name})`,
|
|
791
|
+
`${unit2Def.type} (${q2.unit?.name})`
|
|
640
792
|
);
|
|
641
793
|
}
|
|
642
794
|
let targetUnitDef;
|
|
@@ -650,33 +802,128 @@ function addQuantities(q1, q2) {
|
|
|
650
802
|
}
|
|
651
803
|
const convertedV1 = convertQuantityValue(v1, unit1Def, targetUnitDef);
|
|
652
804
|
const convertedV2 = convertQuantityValue(v2, unit2Def, targetUnitDef);
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
convertedV2,
|
|
656
|
-
targetUnitDef.name
|
|
657
|
-
);
|
|
805
|
+
const targetUnit = { name: targetUnitDef.name };
|
|
806
|
+
return addQuantityValuesAndSetUnit(convertedV1, convertedV2, targetUnit);
|
|
658
807
|
}
|
|
659
|
-
throw new IncompatibleUnitsError(
|
|
808
|
+
throw new IncompatibleUnitsError(
|
|
809
|
+
q1.unit?.name,
|
|
810
|
+
q2.unit?.name
|
|
811
|
+
);
|
|
660
812
|
}
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
813
|
+
function toPlainUnit(quantity) {
|
|
814
|
+
if (isQuantity(quantity))
|
|
815
|
+
return quantity.unit ? { ...quantity, unit: quantity.unit.name } : quantity;
|
|
816
|
+
else if (isOrGroup(quantity)) {
|
|
817
|
+
return {
|
|
818
|
+
or: quantity.or.map(toPlainUnit)
|
|
819
|
+
};
|
|
820
|
+
} else {
|
|
821
|
+
return {
|
|
822
|
+
and: quantity.and.map(toPlainUnit)
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
function toExtendedUnit(q) {
|
|
827
|
+
if (isQuantity(q)) {
|
|
828
|
+
return q.unit ? { ...q, unit: { name: q.unit } } : q;
|
|
829
|
+
} else if (isOrGroup(q)) {
|
|
830
|
+
return { or: q.or.map(toExtendedUnit) };
|
|
831
|
+
} else {
|
|
832
|
+
return { and: q.and.map(toExtendedUnit) };
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
function deNormalizeQuantity(q) {
|
|
836
|
+
const result = {
|
|
837
|
+
quantity: q.quantity
|
|
838
|
+
};
|
|
839
|
+
if (!isNoUnit(q.unit)) {
|
|
840
|
+
result.unit = { name: q.unit.name };
|
|
841
|
+
}
|
|
842
|
+
return result;
|
|
843
|
+
}
|
|
844
|
+
var flattenPlainUnitGroup = (summed) => {
|
|
845
|
+
if (isOrGroup(summed)) {
|
|
846
|
+
const entries = summed.or;
|
|
847
|
+
const andGroupEntry = entries.find(
|
|
848
|
+
(e2) => isAndGroup(e2)
|
|
668
849
|
);
|
|
669
|
-
|
|
850
|
+
if (andGroupEntry) {
|
|
851
|
+
const andEntries = [];
|
|
852
|
+
const addGroupEntryContent = andGroupEntry.and;
|
|
853
|
+
for (const entry of addGroupEntryContent) {
|
|
854
|
+
andEntries.push({
|
|
855
|
+
quantity: entry.quantity,
|
|
856
|
+
...entry.unit && { unit: entry.unit }
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
const equivalentsList = entries.filter((e2) => isQuantity(e2)).map((e2) => ({ quantity: e2.quantity, unit: e2.unit }));
|
|
860
|
+
if (equivalentsList.length > 0) {
|
|
861
|
+
return [
|
|
862
|
+
{
|
|
863
|
+
and: andEntries,
|
|
864
|
+
equivalents: equivalentsList
|
|
865
|
+
}
|
|
866
|
+
];
|
|
867
|
+
} else {
|
|
868
|
+
return andEntries;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
const simpleEntries = entries.filter(
|
|
872
|
+
(e2) => isQuantity(e2)
|
|
873
|
+
);
|
|
874
|
+
if (simpleEntries.length > 0) {
|
|
875
|
+
const result = {
|
|
876
|
+
quantity: simpleEntries[0].quantity,
|
|
877
|
+
unit: simpleEntries[0].unit
|
|
878
|
+
};
|
|
879
|
+
if (simpleEntries.length > 1) {
|
|
880
|
+
result.equivalents = simpleEntries.slice(1);
|
|
881
|
+
}
|
|
882
|
+
return [result];
|
|
883
|
+
} else {
|
|
884
|
+
const first = entries[0];
|
|
885
|
+
return [{ quantity: first.quantity, unit: first.unit }];
|
|
886
|
+
}
|
|
887
|
+
} else if (isAndGroup(summed)) {
|
|
888
|
+
const andEntries = [];
|
|
889
|
+
const equivalentsList = [];
|
|
890
|
+
for (const entry of summed.and) {
|
|
891
|
+
if (isOrGroup(entry)) {
|
|
892
|
+
const orEntries = entry.or;
|
|
893
|
+
andEntries.push({
|
|
894
|
+
quantity: orEntries[0].quantity,
|
|
895
|
+
...orEntries[0].unit && { unit: orEntries[0].unit }
|
|
896
|
+
});
|
|
897
|
+
equivalentsList.push(...orEntries.slice(1));
|
|
898
|
+
} else if (isQuantity(entry)) {
|
|
899
|
+
andEntries.push({
|
|
900
|
+
quantity: entry.quantity,
|
|
901
|
+
...entry.unit && { unit: entry.unit }
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
if (equivalentsList.length === 0) {
|
|
906
|
+
return andEntries;
|
|
907
|
+
}
|
|
908
|
+
const result = {
|
|
909
|
+
and: andEntries,
|
|
910
|
+
equivalents: equivalentsList
|
|
911
|
+
};
|
|
912
|
+
return [result];
|
|
913
|
+
} else {
|
|
914
|
+
return [
|
|
915
|
+
{ quantity: summed.quantity, ...summed.unit && { unit: summed.unit } }
|
|
916
|
+
];
|
|
670
917
|
}
|
|
671
918
|
};
|
|
672
919
|
|
|
673
|
-
// src/parser_helpers.ts
|
|
674
|
-
function flushPendingNote(section,
|
|
675
|
-
if (
|
|
676
|
-
section.content.push({ type: "note",
|
|
677
|
-
return
|
|
920
|
+
// src/utils/parser_helpers.ts
|
|
921
|
+
function flushPendingNote(section, noteItems) {
|
|
922
|
+
if (noteItems.length > 0) {
|
|
923
|
+
section.content.push({ type: "note", items: [...noteItems] });
|
|
924
|
+
return [];
|
|
678
925
|
}
|
|
679
|
-
return
|
|
926
|
+
return noteItems;
|
|
680
927
|
}
|
|
681
928
|
function flushPendingItems(section, items) {
|
|
682
929
|
if (items.length > 0) {
|
|
@@ -687,7 +934,7 @@ function flushPendingItems(section, items) {
|
|
|
687
934
|
return false;
|
|
688
935
|
}
|
|
689
936
|
function findAndUpsertIngredient(ingredients, newIngredient, isReference) {
|
|
690
|
-
const { name
|
|
937
|
+
const { name } = newIngredient;
|
|
691
938
|
if (isReference) {
|
|
692
939
|
const indexFind = ingredients.findIndex(
|
|
693
940
|
(i2) => i2.name.toLowerCase() === name.toLowerCase()
|
|
@@ -698,52 +945,28 @@ function findAndUpsertIngredient(ingredients, newIngredient, isReference) {
|
|
|
698
945
|
);
|
|
699
946
|
}
|
|
700
947
|
const existingIngredient = ingredients[indexFind];
|
|
701
|
-
|
|
702
|
-
if (
|
|
948
|
+
if (!newIngredient.flags) {
|
|
949
|
+
if (Array.isArray(existingIngredient.flags) && existingIngredient.flags.length > 0) {
|
|
703
950
|
throw new ReferencedItemCannotBeRedefinedError(
|
|
704
951
|
"ingredient",
|
|
705
952
|
existingIngredient.name,
|
|
706
|
-
|
|
953
|
+
existingIngredient.flags[0]
|
|
707
954
|
);
|
|
708
955
|
}
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
const newQuantity = { value: quantity, unit: unit ?? "" };
|
|
717
|
-
try {
|
|
718
|
-
const total = addQuantities(currentQuantity, newQuantity);
|
|
719
|
-
existingIngredient.quantity = total.value;
|
|
720
|
-
existingIngredient.unit = total.unit || void 0;
|
|
721
|
-
if (existingIngredient.quantityParts) {
|
|
722
|
-
existingIngredient.quantityParts.push(
|
|
723
|
-
...newIngredient.quantityParts
|
|
956
|
+
} else {
|
|
957
|
+
for (const flag of newIngredient.flags) {
|
|
958
|
+
if (existingIngredient.flags === void 0 || !existingIngredient.flags.includes(flag)) {
|
|
959
|
+
throw new ReferencedItemCannotBeRedefinedError(
|
|
960
|
+
"ingredient",
|
|
961
|
+
existingIngredient.name,
|
|
962
|
+
flag
|
|
724
963
|
);
|
|
725
|
-
} else {
|
|
726
|
-
existingIngredient.quantityParts = newIngredient.quantityParts;
|
|
727
|
-
}
|
|
728
|
-
quantityPartIndex = existingIngredient.quantityParts.length - 1;
|
|
729
|
-
} catch (e2) {
|
|
730
|
-
if (e2 instanceof IncompatibleUnitsError || e2 instanceof CannotAddTextValueError) {
|
|
731
|
-
return {
|
|
732
|
-
ingredientIndex: ingredients.push(newIngredient) - 1,
|
|
733
|
-
quantityPartIndex: 0
|
|
734
|
-
};
|
|
735
964
|
}
|
|
736
965
|
}
|
|
737
966
|
}
|
|
738
|
-
return
|
|
739
|
-
ingredientIndex: indexFind,
|
|
740
|
-
quantityPartIndex
|
|
741
|
-
};
|
|
967
|
+
return indexFind;
|
|
742
968
|
}
|
|
743
|
-
return
|
|
744
|
-
ingredientIndex: ingredients.push(newIngredient) - 1,
|
|
745
|
-
quantityPartIndex: newIngredient.quantity ? 0 : void 0
|
|
746
|
-
};
|
|
969
|
+
return ingredients.push(newIngredient) - 1;
|
|
747
970
|
}
|
|
748
971
|
function findAndUpsertCookware(cookware, newCookware, isReference) {
|
|
749
972
|
const { name, quantity } = newCookware;
|
|
@@ -757,58 +980,48 @@ function findAndUpsertCookware(cookware, newCookware, isReference) {
|
|
|
757
980
|
);
|
|
758
981
|
}
|
|
759
982
|
const existingCookware = cookware[index];
|
|
760
|
-
|
|
761
|
-
if (
|
|
983
|
+
if (!newCookware.flags) {
|
|
984
|
+
if (Array.isArray(existingCookware.flags) && existingCookware.flags.length > 0) {
|
|
762
985
|
throw new ReferencedItemCannotBeRedefinedError(
|
|
763
986
|
"cookware",
|
|
764
987
|
existingCookware.name,
|
|
765
|
-
|
|
988
|
+
existingCookware.flags[0]
|
|
766
989
|
);
|
|
767
990
|
}
|
|
991
|
+
} else {
|
|
992
|
+
for (const flag of newCookware.flags) {
|
|
993
|
+
if (existingCookware.flags === void 0 || !existingCookware.flags.includes(flag)) {
|
|
994
|
+
throw new ReferencedItemCannotBeRedefinedError(
|
|
995
|
+
"cookware",
|
|
996
|
+
existingCookware.name,
|
|
997
|
+
flag
|
|
998
|
+
);
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
768
1001
|
}
|
|
769
|
-
let quantityPartIndex = void 0;
|
|
770
1002
|
if (quantity !== void 0) {
|
|
771
1003
|
if (!existingCookware.quantity) {
|
|
772
1004
|
existingCookware.quantity = quantity;
|
|
773
|
-
existingCookware.quantityParts = newCookware.quantityParts;
|
|
774
|
-
quantityPartIndex = 0;
|
|
775
1005
|
} else {
|
|
776
1006
|
try {
|
|
777
1007
|
existingCookware.quantity = addQuantityValues(
|
|
778
1008
|
existingCookware.quantity,
|
|
779
1009
|
quantity
|
|
780
1010
|
);
|
|
781
|
-
if (!existingCookware.quantityParts) {
|
|
782
|
-
existingCookware.quantityParts = newCookware.quantityParts;
|
|
783
|
-
quantityPartIndex = 0;
|
|
784
|
-
} else {
|
|
785
|
-
quantityPartIndex = existingCookware.quantityParts.push(
|
|
786
|
-
...newCookware.quantityParts
|
|
787
|
-
) - 1;
|
|
788
|
-
}
|
|
789
1011
|
} catch (e2) {
|
|
790
1012
|
if (e2 instanceof CannotAddTextValueError) {
|
|
791
|
-
return
|
|
792
|
-
cookwareIndex: cookware.push(newCookware) - 1,
|
|
793
|
-
quantityPartIndex: 0
|
|
794
|
-
};
|
|
1013
|
+
return cookware.push(newCookware) - 1;
|
|
795
1014
|
}
|
|
796
1015
|
}
|
|
797
1016
|
}
|
|
798
1017
|
}
|
|
799
|
-
return
|
|
800
|
-
cookwareIndex: index,
|
|
801
|
-
quantityPartIndex
|
|
802
|
-
};
|
|
1018
|
+
return index;
|
|
803
1019
|
}
|
|
804
|
-
return
|
|
805
|
-
cookwareIndex: cookware.push(newCookware) - 1,
|
|
806
|
-
quantityPartIndex: quantity ? 0 : void 0
|
|
807
|
-
};
|
|
1020
|
+
return cookware.push(newCookware) - 1;
|
|
808
1021
|
}
|
|
809
1022
|
var parseFixedValue = (input_str) => {
|
|
810
1023
|
if (!numberLikeRegex.test(input_str)) {
|
|
811
|
-
return { type: "text",
|
|
1024
|
+
return { type: "text", text: input_str };
|
|
812
1025
|
}
|
|
813
1026
|
const s = input_str.trim().replace(",", ".");
|
|
814
1027
|
if (s.includes("/")) {
|
|
@@ -817,8 +1030,22 @@ var parseFixedValue = (input_str) => {
|
|
|
817
1030
|
const den = Number(parts[1]);
|
|
818
1031
|
return { type: "fraction", num, den };
|
|
819
1032
|
}
|
|
820
|
-
return { type: "decimal",
|
|
1033
|
+
return { type: "decimal", decimal: Number(s) };
|
|
821
1034
|
};
|
|
1035
|
+
function stringifyQuantityValue(quantity) {
|
|
1036
|
+
if (quantity.type === "fixed") {
|
|
1037
|
+
return stringifyFixedValue(quantity);
|
|
1038
|
+
} else {
|
|
1039
|
+
return `${stringifyFixedValue({ type: "fixed", value: quantity.min })}-${stringifyFixedValue({ type: "fixed", value: quantity.max })}`;
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
function stringifyFixedValue(quantity) {
|
|
1043
|
+
if (quantity.value.type === "fraction")
|
|
1044
|
+
return `${quantity.value.num}/${quantity.value.den}`;
|
|
1045
|
+
else if (quantity.value.type === "decimal")
|
|
1046
|
+
return String(quantity.value.decimal);
|
|
1047
|
+
else return quantity.value.text;
|
|
1048
|
+
}
|
|
822
1049
|
function parseQuantityInput(input_str) {
|
|
823
1050
|
const clean_str = String(input_str).trim();
|
|
824
1051
|
if (rangeRegex.test(clean_str)) {
|
|
@@ -860,7 +1087,7 @@ function parseListMetaVar(content, varName) {
|
|
|
860
1087
|
function extractMetadata(content) {
|
|
861
1088
|
const metadata = {};
|
|
862
1089
|
let servings = void 0;
|
|
863
|
-
const metadataContent = content.match(metadataRegex)?.[
|
|
1090
|
+
const metadataContent = content.match(metadataRegex)?.[2];
|
|
864
1091
|
if (!metadataContent) {
|
|
865
1092
|
return { metadata };
|
|
866
1093
|
}
|
|
@@ -905,73 +1132,1169 @@ function extractMetadata(content) {
|
|
|
905
1132
|
}
|
|
906
1133
|
return { metadata, servings };
|
|
907
1134
|
}
|
|
1135
|
+
function isPositiveIntegerString(str) {
|
|
1136
|
+
return /^\d+$/.test(str);
|
|
1137
|
+
}
|
|
1138
|
+
function unionOfSets(s1, s2) {
|
|
1139
|
+
const result = new Set(s1);
|
|
1140
|
+
for (const item of s2) {
|
|
1141
|
+
result.add(item);
|
|
1142
|
+
}
|
|
1143
|
+
return result;
|
|
1144
|
+
}
|
|
1145
|
+
function getAlternativeSignature(alternatives) {
|
|
1146
|
+
if (!alternatives || alternatives.length === 0) return null;
|
|
1147
|
+
return alternatives.map((a2) => a2.index).sort((a2, b) => a2 - b).join(",");
|
|
1148
|
+
}
|
|
908
1149
|
|
|
909
|
-
// src/classes/
|
|
910
|
-
var
|
|
911
|
-
|
|
1150
|
+
// src/classes/product_catalog.ts
|
|
1151
|
+
var ProductCatalog = class {
|
|
1152
|
+
constructor(tomlContent) {
|
|
1153
|
+
__publicField(this, "products", []);
|
|
1154
|
+
if (tomlContent) this.parse(tomlContent);
|
|
1155
|
+
}
|
|
912
1156
|
/**
|
|
913
|
-
*
|
|
914
|
-
* @param
|
|
1157
|
+
* Parses a TOML string into a list of product options.
|
|
1158
|
+
* @param tomlContent - The TOML string to parse.
|
|
1159
|
+
* @returns A parsed list of `ProductOption`.
|
|
915
1160
|
*/
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
1161
|
+
parse(tomlContent) {
|
|
1162
|
+
const catalogRaw = import_smol_toml.default.parse(tomlContent);
|
|
1163
|
+
this.products = [];
|
|
1164
|
+
if (!this.isValidTomlContent(catalogRaw)) {
|
|
1165
|
+
throw new InvalidProductCatalogFormat();
|
|
1166
|
+
}
|
|
1167
|
+
for (const [ingredientName, ingredientData] of Object.entries(catalogRaw)) {
|
|
1168
|
+
const ingredientTable = ingredientData;
|
|
1169
|
+
const aliases = ingredientTable.aliases;
|
|
1170
|
+
for (const [key, productData] of Object.entries(ingredientTable)) {
|
|
1171
|
+
if (key === "aliases") {
|
|
1172
|
+
continue;
|
|
1173
|
+
}
|
|
1174
|
+
const productId = key;
|
|
1175
|
+
const { name, size, price, ...rest } = productData;
|
|
1176
|
+
const sizeStrings = Array.isArray(size) ? size : [size];
|
|
1177
|
+
const sizes = sizeStrings.map((sizeStr) => {
|
|
1178
|
+
const sizeAndUnitRaw = sizeStr.split("%");
|
|
1179
|
+
const sizeParsed = parseQuantityInput(
|
|
1180
|
+
sizeAndUnitRaw[0]
|
|
1181
|
+
);
|
|
1182
|
+
const productSize = { size: sizeParsed };
|
|
1183
|
+
if (sizeAndUnitRaw.length > 1) {
|
|
1184
|
+
productSize.unit = sizeAndUnitRaw[1];
|
|
1185
|
+
}
|
|
1186
|
+
return productSize;
|
|
1187
|
+
});
|
|
1188
|
+
const productOption = {
|
|
1189
|
+
id: productId,
|
|
1190
|
+
productName: name,
|
|
1191
|
+
ingredientName,
|
|
1192
|
+
price,
|
|
1193
|
+
sizes,
|
|
1194
|
+
...rest
|
|
1195
|
+
};
|
|
1196
|
+
if (aliases) {
|
|
1197
|
+
productOption.ingredientAliases = aliases;
|
|
1198
|
+
}
|
|
1199
|
+
this.products.push(productOption);
|
|
1200
|
+
}
|
|
947
1201
|
}
|
|
1202
|
+
return this.products;
|
|
948
1203
|
}
|
|
949
1204
|
/**
|
|
950
|
-
*
|
|
951
|
-
* @
|
|
1205
|
+
* Stringifies the catalog to a TOML string.
|
|
1206
|
+
* @returns The TOML string representation of the catalog.
|
|
952
1207
|
*/
|
|
953
|
-
|
|
954
|
-
const
|
|
955
|
-
const
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
if (
|
|
965
|
-
|
|
966
|
-
note = flushPendingNote(section, note);
|
|
967
|
-
blankLineBefore = true;
|
|
968
|
-
inNote = false;
|
|
969
|
-
continue;
|
|
1208
|
+
stringify() {
|
|
1209
|
+
const grouped = {};
|
|
1210
|
+
for (const product of this.products) {
|
|
1211
|
+
const {
|
|
1212
|
+
id,
|
|
1213
|
+
ingredientName,
|
|
1214
|
+
ingredientAliases,
|
|
1215
|
+
sizes,
|
|
1216
|
+
productName,
|
|
1217
|
+
...rest
|
|
1218
|
+
} = product;
|
|
1219
|
+
if (!grouped[ingredientName]) {
|
|
1220
|
+
grouped[ingredientName] = {};
|
|
970
1221
|
}
|
|
971
|
-
if (
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
1222
|
+
if (ingredientAliases && !grouped[ingredientName].aliases) {
|
|
1223
|
+
grouped[ingredientName].aliases = ingredientAliases;
|
|
1224
|
+
}
|
|
1225
|
+
const sizeStrings = sizes.map(
|
|
1226
|
+
(s) => s.unit ? `${stringifyQuantityValue(s.size)}%${s.unit}` : stringifyQuantityValue(s.size)
|
|
1227
|
+
);
|
|
1228
|
+
grouped[ingredientName][id] = {
|
|
1229
|
+
...rest,
|
|
1230
|
+
name: productName,
|
|
1231
|
+
// Use array if multiple sizes, otherwise single string
|
|
1232
|
+
size: sizeStrings.length === 1 ? sizeStrings[0] : sizeStrings
|
|
1233
|
+
};
|
|
1234
|
+
}
|
|
1235
|
+
return import_smol_toml.default.stringify(grouped);
|
|
1236
|
+
}
|
|
1237
|
+
/**
|
|
1238
|
+
* Adds a product to the catalog.
|
|
1239
|
+
* @param productOption - The product to add.
|
|
1240
|
+
*/
|
|
1241
|
+
add(productOption) {
|
|
1242
|
+
this.products.push(productOption);
|
|
1243
|
+
}
|
|
1244
|
+
/**
|
|
1245
|
+
* Removes a product from the catalog by its ID.
|
|
1246
|
+
* @param productId - The ID of the product to remove.
|
|
1247
|
+
*/
|
|
1248
|
+
remove(productId) {
|
|
1249
|
+
this.products = this.products.filter((product) => product.id !== productId);
|
|
1250
|
+
}
|
|
1251
|
+
isValidTomlContent(catalog) {
|
|
1252
|
+
for (const productsRaw of Object.values(catalog)) {
|
|
1253
|
+
if (typeof productsRaw !== "object" || productsRaw === null) {
|
|
1254
|
+
return false;
|
|
1255
|
+
}
|
|
1256
|
+
for (const [id, obj] of Object.entries(productsRaw)) {
|
|
1257
|
+
if (id === "aliases") {
|
|
1258
|
+
if (!Array.isArray(obj)) {
|
|
1259
|
+
return false;
|
|
1260
|
+
}
|
|
1261
|
+
} else {
|
|
1262
|
+
if (!isPositiveIntegerString(id)) {
|
|
1263
|
+
return false;
|
|
1264
|
+
}
|
|
1265
|
+
if (typeof obj !== "object" || obj === null) {
|
|
1266
|
+
return false;
|
|
1267
|
+
}
|
|
1268
|
+
const record = obj;
|
|
1269
|
+
const keys = Object.keys(record);
|
|
1270
|
+
const mandatoryKeys = ["name", "size", "price"];
|
|
1271
|
+
if (mandatoryKeys.some((key) => !keys.includes(key))) {
|
|
1272
|
+
return false;
|
|
1273
|
+
}
|
|
1274
|
+
const hasProductName = typeof record.name === "string";
|
|
1275
|
+
const hasSize = typeof record.size === "string" || Array.isArray(record.size) && record.size.every((s) => typeof s === "string");
|
|
1276
|
+
const hasPrice = typeof record.price === "number";
|
|
1277
|
+
if (!(hasProductName && hasSize && hasPrice)) {
|
|
1278
|
+
return false;
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
return true;
|
|
1284
|
+
}
|
|
1285
|
+
};
|
|
1286
|
+
|
|
1287
|
+
// src/classes/section.ts
|
|
1288
|
+
var Section = class {
|
|
1289
|
+
/**
|
|
1290
|
+
* Creates an instance of Section.
|
|
1291
|
+
* @param name - The name of the section. Defaults to an empty string.
|
|
1292
|
+
*/
|
|
1293
|
+
constructor(name = "") {
|
|
1294
|
+
/**
|
|
1295
|
+
* The name of the section. Can be an empty string for the default (first) section.
|
|
1296
|
+
* @defaultValue `""`
|
|
1297
|
+
*/
|
|
1298
|
+
__publicField(this, "name");
|
|
1299
|
+
/** An array of steps and notes that make up the content of the section. */
|
|
1300
|
+
__publicField(this, "content", []);
|
|
1301
|
+
this.name = name;
|
|
1302
|
+
}
|
|
1303
|
+
/**
|
|
1304
|
+
* Checks if the section is blank (has no name and no content).
|
|
1305
|
+
* Used during recipe parsing
|
|
1306
|
+
* @returns `true` if the section is blank, otherwise `false`.
|
|
1307
|
+
*/
|
|
1308
|
+
isBlank() {
|
|
1309
|
+
return this.name === "" && this.content.length === 0;
|
|
1310
|
+
}
|
|
1311
|
+
};
|
|
1312
|
+
|
|
1313
|
+
// src/quantities/alternatives.ts
|
|
1314
|
+
var import_big3 = __toESM(require("big.js"), 1);
|
|
1315
|
+
|
|
1316
|
+
// src/units/conversion.ts
|
|
1317
|
+
var import_big2 = __toESM(require("big.js"), 1);
|
|
1318
|
+
function getUnitRatio(q1, q2) {
|
|
1319
|
+
const q1Value = getAverageValue(q1.quantity);
|
|
1320
|
+
const q2Value = getAverageValue(q2.quantity);
|
|
1321
|
+
const factor = "toBase" in q1.unit && "toBase" in q2.unit ? q1.unit.toBase / q2.unit.toBase : 1;
|
|
1322
|
+
if (typeof q1Value !== "number" || typeof q2Value !== "number") {
|
|
1323
|
+
throw Error(
|
|
1324
|
+
"One of both values is not a number, so a ratio cannot be computed"
|
|
1325
|
+
);
|
|
1326
|
+
}
|
|
1327
|
+
return (0, import_big2.default)(q1Value).times(factor).div(q2Value);
|
|
1328
|
+
}
|
|
1329
|
+
function getBaseUnitRatio(q, qRef) {
|
|
1330
|
+
if ("toBase" in q.unit && "toBase" in qRef.unit) {
|
|
1331
|
+
return q.unit.toBase / qRef.unit.toBase;
|
|
1332
|
+
} else {
|
|
1333
|
+
return 1;
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
// src/units/lookup.ts
|
|
1338
|
+
function areUnitsCompatible(u1, u2) {
|
|
1339
|
+
if (u1.name === u2.name) {
|
|
1340
|
+
return true;
|
|
1341
|
+
}
|
|
1342
|
+
if (u1.type !== "other" && u1.type === u2.type && u1.system === u2.system) {
|
|
1343
|
+
return true;
|
|
1344
|
+
}
|
|
1345
|
+
return false;
|
|
1346
|
+
}
|
|
1347
|
+
function findListWithCompatibleQuantity(list, quantity) {
|
|
1348
|
+
const quantityWithUnitDef = {
|
|
1349
|
+
...quantity,
|
|
1350
|
+
unit: resolveUnit(quantity.unit?.name)
|
|
1351
|
+
};
|
|
1352
|
+
return list.find(
|
|
1353
|
+
(l) => l.some((lq) => areUnitsCompatible(lq.unit, quantityWithUnitDef.unit))
|
|
1354
|
+
);
|
|
1355
|
+
}
|
|
1356
|
+
function findCompatibleQuantityWithinList(list, quantity) {
|
|
1357
|
+
const quantityWithUnitDef = {
|
|
1358
|
+
...quantity,
|
|
1359
|
+
unit: resolveUnit(quantity.unit?.name)
|
|
1360
|
+
};
|
|
1361
|
+
return list.find(
|
|
1362
|
+
(q) => q.unit.name === quantityWithUnitDef.unit.name || q.unit.type === quantityWithUnitDef.unit.type && q.unit.type !== "other"
|
|
1363
|
+
);
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
// src/utils/general.ts
|
|
1367
|
+
var legacyDeepClone = (v) => {
|
|
1368
|
+
if (v === null || typeof v !== "object") {
|
|
1369
|
+
return v;
|
|
1370
|
+
}
|
|
1371
|
+
if (v instanceof Map) {
|
|
1372
|
+
return new Map(
|
|
1373
|
+
Array.from(v.entries()).map(([k, val]) => [
|
|
1374
|
+
legacyDeepClone(k),
|
|
1375
|
+
legacyDeepClone(val)
|
|
1376
|
+
])
|
|
1377
|
+
);
|
|
1378
|
+
}
|
|
1379
|
+
if (v instanceof Set) {
|
|
1380
|
+
return new Set(Array.from(v).map((val) => legacyDeepClone(val)));
|
|
1381
|
+
}
|
|
1382
|
+
if (v instanceof Date) {
|
|
1383
|
+
return new Date(v.getTime());
|
|
1384
|
+
}
|
|
1385
|
+
if (Array.isArray(v)) {
|
|
1386
|
+
return v.map((item) => legacyDeepClone(item));
|
|
1387
|
+
}
|
|
1388
|
+
const cloned = {};
|
|
1389
|
+
for (const key of Object.keys(v)) {
|
|
1390
|
+
cloned[key] = legacyDeepClone(v[key]);
|
|
1391
|
+
}
|
|
1392
|
+
return cloned;
|
|
1393
|
+
};
|
|
1394
|
+
var deepClone = (v) => typeof structuredClone === "function" ? structuredClone(v) : legacyDeepClone(v);
|
|
1395
|
+
|
|
1396
|
+
// src/quantities/alternatives.ts
|
|
1397
|
+
function getEquivalentUnitsLists(...quantities) {
|
|
1398
|
+
const quantitiesCopy = deepClone(quantities);
|
|
1399
|
+
const OrGroups = quantitiesCopy.filter(isOrGroup).filter((q) => q.or.length > 1);
|
|
1400
|
+
const unitLists = [];
|
|
1401
|
+
const normalizeOrGroup = (og) => ({
|
|
1402
|
+
...og,
|
|
1403
|
+
or: og.or.map((q) => ({
|
|
1404
|
+
...q,
|
|
1405
|
+
unit: resolveUnit(q.unit?.name, q.unit?.integerProtected)
|
|
1406
|
+
}))
|
|
1407
|
+
});
|
|
1408
|
+
function findLinkIndexForUnits(lists, unitsToCheck) {
|
|
1409
|
+
return lists.findIndex((l) => {
|
|
1410
|
+
const listItem = l.map((q) => resolveUnit(q.unit?.name));
|
|
1411
|
+
return unitsToCheck.some(
|
|
1412
|
+
(u) => listItem.some(
|
|
1413
|
+
(lu) => lu.name === u?.name || lu.system === u?.system && lu.type === u?.type && lu.type !== "other"
|
|
1414
|
+
)
|
|
1415
|
+
);
|
|
1416
|
+
});
|
|
1417
|
+
}
|
|
1418
|
+
function mergeOrGroupIntoList(lists, idx, og) {
|
|
1419
|
+
let unitRatio;
|
|
1420
|
+
const commonUnitList = lists[idx].reduce((acc, v) => {
|
|
1421
|
+
const normalizedV = {
|
|
1422
|
+
...v,
|
|
1423
|
+
unit: resolveUnit(v.unit?.name, v.unit?.integerProtected)
|
|
1424
|
+
};
|
|
1425
|
+
const commonQuantity = og.or.find(
|
|
1426
|
+
(q) => isQuantity(q) && areUnitsCompatible(q.unit, normalizedV.unit)
|
|
1427
|
+
);
|
|
1428
|
+
if (commonQuantity) {
|
|
1429
|
+
acc.push(normalizedV);
|
|
1430
|
+
unitRatio = getUnitRatio(normalizedV, commonQuantity);
|
|
1431
|
+
}
|
|
1432
|
+
return acc;
|
|
1433
|
+
}, []);
|
|
1434
|
+
for (const newQ of og.or) {
|
|
1435
|
+
if (commonUnitList.some((q) => areUnitsCompatible(q.unit, newQ.unit))) {
|
|
1436
|
+
continue;
|
|
1437
|
+
} else {
|
|
1438
|
+
const scaledQuantity = multiplyQuantityValue(newQ.quantity, unitRatio);
|
|
1439
|
+
lists[idx].push({ ...newQ, quantity: scaledQuantity });
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
for (const orGroup of OrGroups) {
|
|
1444
|
+
const orGroupModified = normalizeOrGroup(orGroup);
|
|
1445
|
+
const units2 = orGroupModified.or.map((q) => q.unit);
|
|
1446
|
+
const linkIndex = findLinkIndexForUnits(unitLists, units2);
|
|
1447
|
+
if (linkIndex === -1) {
|
|
1448
|
+
unitLists.push(orGroupModified.or);
|
|
1449
|
+
} else {
|
|
1450
|
+
mergeOrGroupIntoList(unitLists, linkIndex, orGroupModified);
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
return unitLists;
|
|
1454
|
+
}
|
|
1455
|
+
function sortUnitList(list) {
|
|
1456
|
+
if (!list || list.length <= 1) return list;
|
|
1457
|
+
const priorityList = [];
|
|
1458
|
+
const nonPriorityList = [];
|
|
1459
|
+
for (const q of list) {
|
|
1460
|
+
if (q.unit.integerProtected || q.unit.system === "none") {
|
|
1461
|
+
priorityList.push(q);
|
|
1462
|
+
} else {
|
|
1463
|
+
nonPriorityList.push(q);
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
return priorityList.sort((a2, b) => {
|
|
1467
|
+
const prefixA = a2.unit.integerProtected ? "___" : "";
|
|
1468
|
+
const prefixB = b.unit.integerProtected ? "___" : "";
|
|
1469
|
+
return (prefixA + a2.unit.name).localeCompare(prefixB + b.unit.name, "en");
|
|
1470
|
+
}).concat(nonPriorityList);
|
|
1471
|
+
}
|
|
1472
|
+
function reduceOrsToFirstEquivalent(unitList, quantities) {
|
|
1473
|
+
function reduceToQuantity(firstQuantity) {
|
|
1474
|
+
const equivalentList = sortUnitList(
|
|
1475
|
+
findListWithCompatibleQuantity(unitList, firstQuantity)
|
|
1476
|
+
);
|
|
1477
|
+
if (!equivalentList) return firstQuantity;
|
|
1478
|
+
const firstQuantityInList = findCompatibleQuantityWithinList(
|
|
1479
|
+
equivalentList,
|
|
1480
|
+
firstQuantity
|
|
1481
|
+
);
|
|
1482
|
+
const normalizedFirstQuantity = {
|
|
1483
|
+
...firstQuantity,
|
|
1484
|
+
unit: resolveUnit(firstQuantity.unit?.name)
|
|
1485
|
+
};
|
|
1486
|
+
if (firstQuantityInList.unit.integerProtected) {
|
|
1487
|
+
const resultQuantity = {
|
|
1488
|
+
quantity: firstQuantity.quantity
|
|
1489
|
+
};
|
|
1490
|
+
if (!isNoUnit(normalizedFirstQuantity.unit)) {
|
|
1491
|
+
resultQuantity.unit = { name: normalizedFirstQuantity.unit.name };
|
|
1492
|
+
}
|
|
1493
|
+
return resultQuantity;
|
|
1494
|
+
} else {
|
|
1495
|
+
let nextProtected;
|
|
1496
|
+
const equivalentListTemp = [...equivalentList];
|
|
1497
|
+
while (nextProtected !== -1) {
|
|
1498
|
+
nextProtected = equivalentListTemp.findIndex(
|
|
1499
|
+
(eq) => eq.unit?.integerProtected
|
|
1500
|
+
);
|
|
1501
|
+
if (nextProtected !== -1) {
|
|
1502
|
+
const unitRatio2 = getUnitRatio(
|
|
1503
|
+
equivalentListTemp[nextProtected],
|
|
1504
|
+
firstQuantityInList
|
|
1505
|
+
);
|
|
1506
|
+
const nextProtectedQuantityValue = multiplyQuantityValue(
|
|
1507
|
+
firstQuantity.quantity,
|
|
1508
|
+
unitRatio2
|
|
1509
|
+
);
|
|
1510
|
+
if (isValueIntegerLike(nextProtectedQuantityValue)) {
|
|
1511
|
+
const nextProtectedQuantity = {
|
|
1512
|
+
quantity: nextProtectedQuantityValue
|
|
1513
|
+
};
|
|
1514
|
+
if (!isNoUnit(equivalentListTemp[nextProtected].unit)) {
|
|
1515
|
+
nextProtectedQuantity.unit = {
|
|
1516
|
+
name: equivalentListTemp[nextProtected].unit.name
|
|
1517
|
+
};
|
|
1518
|
+
}
|
|
1519
|
+
return nextProtectedQuantity;
|
|
1520
|
+
} else {
|
|
1521
|
+
equivalentListTemp.splice(nextProtected, 1);
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
const firstNonIntegerProtected = equivalentListTemp.filter(
|
|
1526
|
+
(q) => !q.unit.integerProtected
|
|
1527
|
+
)[0];
|
|
1528
|
+
const unitRatio = getUnitRatio(
|
|
1529
|
+
firstNonIntegerProtected,
|
|
1530
|
+
firstQuantityInList
|
|
1531
|
+
).times(getBaseUnitRatio(normalizedFirstQuantity, firstQuantityInList));
|
|
1532
|
+
const firstEqQuantity = {
|
|
1533
|
+
quantity: firstNonIntegerProtected.unit.name === firstQuantity.unit.name ? firstQuantity.quantity : multiplyQuantityValue(firstQuantity.quantity, unitRatio)
|
|
1534
|
+
};
|
|
1535
|
+
if (!isNoUnit(firstNonIntegerProtected.unit)) {
|
|
1536
|
+
firstEqQuantity.unit = { name: firstNonIntegerProtected.unit.name };
|
|
1537
|
+
}
|
|
1538
|
+
return firstEqQuantity;
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
return quantities.map((q) => {
|
|
1542
|
+
if (isQuantity(q)) return reduceToQuantity(q);
|
|
1543
|
+
const qListModified = sortUnitList(
|
|
1544
|
+
q.or.map((qq) => ({
|
|
1545
|
+
...qq,
|
|
1546
|
+
unit: resolveUnit(qq.unit?.name, qq.unit?.integerProtected)
|
|
1547
|
+
}))
|
|
1548
|
+
);
|
|
1549
|
+
return reduceToQuantity(qListModified[0]);
|
|
1550
|
+
});
|
|
1551
|
+
}
|
|
1552
|
+
function addQuantitiesOrGroups(...quantities) {
|
|
1553
|
+
if (quantities.length === 0)
|
|
1554
|
+
return {
|
|
1555
|
+
sum: {
|
|
1556
|
+
quantity: getDefaultQuantityValue(),
|
|
1557
|
+
unit: resolveUnit()
|
|
1558
|
+
},
|
|
1559
|
+
unitsLists: []
|
|
1560
|
+
};
|
|
1561
|
+
if (quantities.length === 1) {
|
|
1562
|
+
if (isQuantity(quantities[0]))
|
|
1563
|
+
return {
|
|
1564
|
+
sum: {
|
|
1565
|
+
...quantities[0],
|
|
1566
|
+
unit: resolveUnit(quantities[0].unit?.name)
|
|
1567
|
+
},
|
|
1568
|
+
unitsLists: []
|
|
1569
|
+
};
|
|
1570
|
+
}
|
|
1571
|
+
const unitsLists = getEquivalentUnitsLists(...quantities);
|
|
1572
|
+
const reducedQuantities = reduceOrsToFirstEquivalent(unitsLists, quantities);
|
|
1573
|
+
const sum = [];
|
|
1574
|
+
for (const nextQ of reducedQuantities) {
|
|
1575
|
+
const existingQ = findCompatibleQuantityWithinList(sum, nextQ);
|
|
1576
|
+
if (existingQ === void 0) {
|
|
1577
|
+
sum.push({
|
|
1578
|
+
...nextQ,
|
|
1579
|
+
unit: resolveUnit(nextQ.unit?.name)
|
|
1580
|
+
});
|
|
1581
|
+
} else {
|
|
1582
|
+
const sumQ = addQuantities(existingQ, nextQ);
|
|
1583
|
+
existingQ.quantity = sumQ.quantity;
|
|
1584
|
+
existingQ.unit = resolveUnit(sumQ.unit?.name);
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
if (sum.length === 1) {
|
|
1588
|
+
return { sum: sum[0], unitsLists };
|
|
1589
|
+
}
|
|
1590
|
+
return { sum: { and: sum }, unitsLists };
|
|
1591
|
+
}
|
|
1592
|
+
function regroupQuantitiesAndExpandEquivalents(sum, unitsLists) {
|
|
1593
|
+
const sumQuantities = isAndGroup(sum) ? sum.and : [sum];
|
|
1594
|
+
const result = [];
|
|
1595
|
+
const processedQuantities = /* @__PURE__ */ new Set();
|
|
1596
|
+
for (const list of unitsLists) {
|
|
1597
|
+
const listCopy = deepClone(list);
|
|
1598
|
+
const main = [];
|
|
1599
|
+
const mainCandidates = sumQuantities.filter(
|
|
1600
|
+
(q) => !processedQuantities.has(q)
|
|
1601
|
+
);
|
|
1602
|
+
if (mainCandidates.length === 0) continue;
|
|
1603
|
+
mainCandidates.forEach((q) => {
|
|
1604
|
+
const mainInList = findCompatibleQuantityWithinList(listCopy, q);
|
|
1605
|
+
if (mainInList !== void 0) {
|
|
1606
|
+
processedQuantities.add(q);
|
|
1607
|
+
main.push(q);
|
|
1608
|
+
listCopy.splice(listCopy.indexOf(mainInList), 1);
|
|
1609
|
+
}
|
|
1610
|
+
});
|
|
1611
|
+
const equivalents = sortUnitList(listCopy).map((equiv) => {
|
|
1612
|
+
const initialValue = {
|
|
1613
|
+
quantity: getDefaultQuantityValue()
|
|
1614
|
+
};
|
|
1615
|
+
if (equiv.unit) {
|
|
1616
|
+
initialValue.unit = { name: equiv.unit.name };
|
|
1617
|
+
}
|
|
1618
|
+
return main.reduce((acc, v) => {
|
|
1619
|
+
const mainInList = findCompatibleQuantityWithinList(list, v);
|
|
1620
|
+
const newValue = {
|
|
1621
|
+
quantity: multiplyQuantityValue(
|
|
1622
|
+
v.quantity,
|
|
1623
|
+
(0, import_big3.default)(getAverageValue(equiv.quantity)).div(
|
|
1624
|
+
getAverageValue(mainInList.quantity)
|
|
1625
|
+
)
|
|
1626
|
+
)
|
|
1627
|
+
};
|
|
1628
|
+
if (equiv.unit && !isNoUnit(equiv.unit)) {
|
|
1629
|
+
newValue.unit = { name: equiv.unit.name };
|
|
1630
|
+
}
|
|
1631
|
+
return addQuantities(acc, newValue);
|
|
1632
|
+
}, initialValue);
|
|
1633
|
+
});
|
|
1634
|
+
if (main.length + equivalents.length > 1) {
|
|
1635
|
+
const resultMain = main.length > 1 ? {
|
|
1636
|
+
and: main.map(deNormalizeQuantity)
|
|
1637
|
+
} : deNormalizeQuantity(main[0]);
|
|
1638
|
+
result.push({
|
|
1639
|
+
or: [resultMain, ...equivalents]
|
|
1640
|
+
});
|
|
1641
|
+
} else {
|
|
1642
|
+
result.push(deNormalizeQuantity(main[0]));
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
sumQuantities.filter((q) => !processedQuantities.has(q)).forEach((q) => result.push(deNormalizeQuantity(q)));
|
|
1646
|
+
return result;
|
|
1647
|
+
}
|
|
1648
|
+
function addEquivalentsAndSimplify(...quantities) {
|
|
1649
|
+
if (quantities.length === 1) {
|
|
1650
|
+
return toPlainUnit(quantities[0]);
|
|
1651
|
+
}
|
|
1652
|
+
const { sum, unitsLists } = addQuantitiesOrGroups(...quantities);
|
|
1653
|
+
const regrouped = regroupQuantitiesAndExpandEquivalents(sum, unitsLists);
|
|
1654
|
+
if (regrouped.length === 1) {
|
|
1655
|
+
return toPlainUnit(regrouped[0]);
|
|
1656
|
+
} else {
|
|
1657
|
+
return { and: regrouped.map(toPlainUnit) };
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
// src/classes/recipe.ts
|
|
1662
|
+
var import_big4 = __toESM(require("big.js"), 1);
|
|
1663
|
+
var _Recipe = class _Recipe {
|
|
1664
|
+
/**
|
|
1665
|
+
* Creates a new Recipe instance.
|
|
1666
|
+
* @param content - The recipe content to parse.
|
|
1667
|
+
*/
|
|
1668
|
+
constructor(content) {
|
|
1669
|
+
/**
|
|
1670
|
+
* The parsed recipe metadata.
|
|
1671
|
+
*/
|
|
1672
|
+
__publicField(this, "metadata", {});
|
|
1673
|
+
/**
|
|
1674
|
+
* The possible choices of alternative ingredients for this recipe.
|
|
1675
|
+
*/
|
|
1676
|
+
__publicField(this, "choices", {
|
|
1677
|
+
ingredientItems: /* @__PURE__ */ new Map(),
|
|
1678
|
+
ingredientGroups: /* @__PURE__ */ new Map()
|
|
1679
|
+
});
|
|
1680
|
+
/**
|
|
1681
|
+
* The parsed recipe ingredients.
|
|
1682
|
+
*/
|
|
1683
|
+
__publicField(this, "ingredients", []);
|
|
1684
|
+
/**
|
|
1685
|
+
* The parsed recipe sections.
|
|
1686
|
+
*/
|
|
1687
|
+
__publicField(this, "sections", []);
|
|
1688
|
+
/**
|
|
1689
|
+
* The parsed recipe cookware.
|
|
1690
|
+
*/
|
|
1691
|
+
__publicField(this, "cookware", []);
|
|
1692
|
+
/**
|
|
1693
|
+
* The parsed recipe timers.
|
|
1694
|
+
*/
|
|
1695
|
+
__publicField(this, "timers", []);
|
|
1696
|
+
/**
|
|
1697
|
+
* The parsed arbitrary quantities.
|
|
1698
|
+
*/
|
|
1699
|
+
__publicField(this, "arbitraries", []);
|
|
1700
|
+
/**
|
|
1701
|
+
* The parsed recipe servings. Used for scaling. Parsed from one of
|
|
1702
|
+
* {@link Metadata.servings}, {@link Metadata.yield} or {@link Metadata.serves}
|
|
1703
|
+
* metadata fields.
|
|
1704
|
+
*
|
|
1705
|
+
* @see {@link Recipe.scaleBy | scaleBy()} and {@link Recipe.scaleTo | scaleTo()} methods
|
|
1706
|
+
*/
|
|
1707
|
+
__publicField(this, "servings");
|
|
1708
|
+
_Recipe.itemCounts.set(this, 0);
|
|
1709
|
+
if (content) {
|
|
1710
|
+
this.parse(content);
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
/**
|
|
1714
|
+
* Gets the current item count for this recipe.
|
|
1715
|
+
*/
|
|
1716
|
+
getItemCount() {
|
|
1717
|
+
return _Recipe.itemCounts.get(this);
|
|
1718
|
+
}
|
|
1719
|
+
/**
|
|
1720
|
+
* Gets the current item count and increments it.
|
|
1721
|
+
*/
|
|
1722
|
+
getAndIncrementItemCount() {
|
|
1723
|
+
const current = this.getItemCount();
|
|
1724
|
+
_Recipe.itemCounts.set(this, current + 1);
|
|
1725
|
+
return current;
|
|
1726
|
+
}
|
|
1727
|
+
/**
|
|
1728
|
+
* Parses a matched arbitrary scalable quantity and adds it to the given array.
|
|
1729
|
+
* @private
|
|
1730
|
+
* @param regexMatchGroups - The regex match groups from arbitrary scalable regex.
|
|
1731
|
+
* @param intoArray - The array to push the parsed arbitrary scalable item into.
|
|
1732
|
+
*/
|
|
1733
|
+
_parseArbitraryScalable(regexMatchGroups, intoArray) {
|
|
1734
|
+
if (!regexMatchGroups || !regexMatchGroups.arbitraryQuantity) return;
|
|
1735
|
+
const quantityMatch = regexMatchGroups.arbitraryQuantity?.trim().match(quantityAlternativeRegex);
|
|
1736
|
+
if (quantityMatch?.groups) {
|
|
1737
|
+
const value = parseQuantityInput(quantityMatch.groups.quantity);
|
|
1738
|
+
const unit = quantityMatch.groups.unit;
|
|
1739
|
+
const name = regexMatchGroups.arbitraryName || void 0;
|
|
1740
|
+
if (!value || value.type === "fixed" && value.value.type === "text") {
|
|
1741
|
+
throw new InvalidQuantityFormat(
|
|
1742
|
+
regexMatchGroups.arbitraryQuantity?.trim(),
|
|
1743
|
+
"Arbitrary quantities must have a numerical value"
|
|
1744
|
+
);
|
|
1745
|
+
}
|
|
1746
|
+
const arbitrary = {
|
|
1747
|
+
quantity: value
|
|
1748
|
+
};
|
|
1749
|
+
if (name) arbitrary.name = name;
|
|
1750
|
+
if (unit) arbitrary.unit = unit;
|
|
1751
|
+
intoArray.push({
|
|
1752
|
+
type: "arbitrary",
|
|
1753
|
+
index: this.arbitraries.push(arbitrary) - 1
|
|
1754
|
+
});
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
/**
|
|
1758
|
+
* Parses text for arbitrary scalables and returns NoteItem array.
|
|
1759
|
+
* @param text - The text to parse for arbitrary scalables.
|
|
1760
|
+
* @returns Array of NoteItem (text and arbitrary scalable items).
|
|
1761
|
+
*/
|
|
1762
|
+
_parseNoteText(text) {
|
|
1763
|
+
const noteItems = [];
|
|
1764
|
+
let cursor = 0;
|
|
1765
|
+
const globalRegex = new RegExp(arbitraryScalableRegex.source, "g");
|
|
1766
|
+
for (const match of text.matchAll(globalRegex)) {
|
|
1767
|
+
const idx = match.index;
|
|
1768
|
+
if (idx > cursor) {
|
|
1769
|
+
noteItems.push({ type: "text", value: text.slice(cursor, idx) });
|
|
1770
|
+
}
|
|
1771
|
+
this._parseArbitraryScalable(match.groups, noteItems);
|
|
1772
|
+
cursor = idx + match[0].length;
|
|
1773
|
+
}
|
|
1774
|
+
if (cursor < text.length) {
|
|
1775
|
+
noteItems.push({ type: "text", value: text.slice(cursor) });
|
|
1776
|
+
}
|
|
1777
|
+
return noteItems;
|
|
1778
|
+
}
|
|
1779
|
+
_parseQuantityRecursive(quantityRaw) {
|
|
1780
|
+
let quantityMatch = quantityRaw.match(quantityAlternativeRegex);
|
|
1781
|
+
const quantities = [];
|
|
1782
|
+
while (quantityMatch?.groups) {
|
|
1783
|
+
const value = quantityMatch.groups.quantity ? parseQuantityInput(quantityMatch.groups.quantity) : void 0;
|
|
1784
|
+
const unit = quantityMatch.groups.unit;
|
|
1785
|
+
if (value) {
|
|
1786
|
+
const newQuantity = { quantity: value };
|
|
1787
|
+
if (unit) {
|
|
1788
|
+
if (unit.startsWith("=")) {
|
|
1789
|
+
newQuantity.unit = {
|
|
1790
|
+
name: unit.substring(1),
|
|
1791
|
+
integerProtected: true
|
|
1792
|
+
};
|
|
1793
|
+
} else {
|
|
1794
|
+
newQuantity.unit = { name: unit };
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
quantities.push(newQuantity);
|
|
1798
|
+
} else {
|
|
1799
|
+
throw new InvalidQuantityFormat(quantityRaw);
|
|
1800
|
+
}
|
|
1801
|
+
quantityMatch = quantityMatch.groups.alternative ? quantityMatch.groups.alternative.match(quantityAlternativeRegex) : null;
|
|
1802
|
+
}
|
|
1803
|
+
return quantities;
|
|
1804
|
+
}
|
|
1805
|
+
_parseIngredientWithAlternativeRecursive(ingredientMatchString, items) {
|
|
1806
|
+
const alternatives = [];
|
|
1807
|
+
let testString = ingredientMatchString;
|
|
1808
|
+
while (true) {
|
|
1809
|
+
const match = testString.match(
|
|
1810
|
+
alternatives.length > 0 ? inlineIngredientAlternativesRegex : ingredientWithAlternativeRegex
|
|
1811
|
+
);
|
|
1812
|
+
if (!match?.groups) break;
|
|
1813
|
+
const groups = match.groups;
|
|
1814
|
+
let name = groups.mIngredientName || groups.sIngredientName;
|
|
1815
|
+
const preparation = groups.ingredientPreparation;
|
|
1816
|
+
const modifiers = groups.ingredientModifiers;
|
|
1817
|
+
const reference = modifiers !== void 0 && modifiers.includes("&");
|
|
1818
|
+
const flags = [];
|
|
1819
|
+
if (modifiers !== void 0 && modifiers.includes("?")) {
|
|
1820
|
+
flags.push("optional");
|
|
1821
|
+
}
|
|
1822
|
+
if (modifiers !== void 0 && modifiers.includes("-")) {
|
|
1823
|
+
flags.push("hidden");
|
|
1824
|
+
}
|
|
1825
|
+
if (modifiers !== void 0 && modifiers.includes("@") || groups.ingredientRecipeAnchor) {
|
|
1826
|
+
flags.push("recipe");
|
|
1827
|
+
}
|
|
1828
|
+
let extras = void 0;
|
|
1829
|
+
if (flags.includes("recipe")) {
|
|
1830
|
+
extras = { path: `${name}.cook` };
|
|
1831
|
+
name = name.substring(name.lastIndexOf("/") + 1);
|
|
1832
|
+
}
|
|
1833
|
+
const aliasMatch = name.match(ingredientAliasRegex);
|
|
1834
|
+
let listName, displayName;
|
|
1835
|
+
if (aliasMatch && aliasMatch.groups.ingredientListName.trim().length > 0 && aliasMatch.groups.ingredientDisplayName.trim().length > 0) {
|
|
1836
|
+
listName = aliasMatch.groups.ingredientListName.trim();
|
|
1837
|
+
displayName = aliasMatch.groups.ingredientDisplayName.trim();
|
|
1838
|
+
} else {
|
|
1839
|
+
listName = name;
|
|
1840
|
+
displayName = name;
|
|
1841
|
+
}
|
|
1842
|
+
const newIngredient = {
|
|
1843
|
+
name: listName
|
|
1844
|
+
};
|
|
1845
|
+
if (preparation) {
|
|
1846
|
+
newIngredient.preparation = preparation;
|
|
1847
|
+
}
|
|
1848
|
+
if (flags.length > 0) {
|
|
1849
|
+
newIngredient.flags = flags;
|
|
1850
|
+
}
|
|
1851
|
+
if (extras) {
|
|
1852
|
+
newIngredient.extras = extras;
|
|
1853
|
+
}
|
|
1854
|
+
const idxInList = findAndUpsertIngredient(
|
|
1855
|
+
this.ingredients,
|
|
1856
|
+
newIngredient,
|
|
1857
|
+
reference
|
|
1858
|
+
);
|
|
1859
|
+
let itemQuantity = void 0;
|
|
1860
|
+
if (groups.ingredientQuantity) {
|
|
1861
|
+
const parsedQuantities = this._parseQuantityRecursive(
|
|
1862
|
+
groups.ingredientQuantity
|
|
1863
|
+
);
|
|
1864
|
+
const [primary, ...rest] = parsedQuantities;
|
|
1865
|
+
if (primary) {
|
|
1866
|
+
itemQuantity = {
|
|
1867
|
+
...primary,
|
|
1868
|
+
scalable: groups.ingredientQuantityModifier !== "="
|
|
1869
|
+
};
|
|
1870
|
+
if (rest.length > 0) {
|
|
1871
|
+
itemQuantity.equivalents = rest;
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
const alternative = {
|
|
1876
|
+
index: idxInList,
|
|
1877
|
+
displayName
|
|
1878
|
+
};
|
|
1879
|
+
const note = groups.ingredientNote?.trim();
|
|
1880
|
+
if (note) {
|
|
1881
|
+
alternative.note = note;
|
|
1882
|
+
}
|
|
1883
|
+
if (itemQuantity) {
|
|
1884
|
+
alternative.itemQuantity = itemQuantity;
|
|
1885
|
+
}
|
|
1886
|
+
alternatives.push(alternative);
|
|
1887
|
+
testString = groups.ingredientAlternative || "";
|
|
1888
|
+
}
|
|
1889
|
+
if (alternatives.length > 1) {
|
|
1890
|
+
const alternativesIndexes = alternatives.map((alt) => alt.index);
|
|
1891
|
+
for (const ingredientIndex of alternativesIndexes) {
|
|
1892
|
+
const ingredient = this.ingredients[ingredientIndex];
|
|
1893
|
+
if (ingredient) {
|
|
1894
|
+
if (!ingredient.alternatives) {
|
|
1895
|
+
ingredient.alternatives = new Set(
|
|
1896
|
+
alternativesIndexes.filter((index) => index !== ingredientIndex)
|
|
1897
|
+
);
|
|
1898
|
+
} else {
|
|
1899
|
+
ingredient.alternatives = unionOfSets(
|
|
1900
|
+
ingredient.alternatives,
|
|
1901
|
+
new Set(
|
|
1902
|
+
alternativesIndexes.filter(
|
|
1903
|
+
(index) => index !== ingredientIndex
|
|
1904
|
+
)
|
|
1905
|
+
)
|
|
1906
|
+
);
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
const id = `ingredient-item-${this.getAndIncrementItemCount()}`;
|
|
1912
|
+
const newItem = {
|
|
1913
|
+
type: "ingredient",
|
|
1914
|
+
id,
|
|
1915
|
+
alternatives
|
|
1916
|
+
};
|
|
1917
|
+
items.push(newItem);
|
|
1918
|
+
if (alternatives.length > 1) {
|
|
1919
|
+
this.choices.ingredientItems.set(id, alternatives);
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
_parseIngredientWithGroupKey(ingredientMatchString, items) {
|
|
1923
|
+
const match = ingredientMatchString.match(ingredientWithGroupKeyRegex);
|
|
1924
|
+
if (!match?.groups) return;
|
|
1925
|
+
const groups = match.groups;
|
|
1926
|
+
const groupKey = groups.gIngredientGroupKey;
|
|
1927
|
+
let name = groups.gmIngredientName || groups.gsIngredientName;
|
|
1928
|
+
const preparation = groups.gIngredientPreparation;
|
|
1929
|
+
const modifiers = groups.gIngredientModifiers;
|
|
1930
|
+
const reference = modifiers !== void 0 && modifiers.includes("&");
|
|
1931
|
+
const flags = [];
|
|
1932
|
+
if (modifiers !== void 0 && modifiers.includes("?")) {
|
|
1933
|
+
flags.push("optional");
|
|
1934
|
+
}
|
|
1935
|
+
if (modifiers !== void 0 && modifiers.includes("-")) {
|
|
1936
|
+
flags.push("hidden");
|
|
1937
|
+
}
|
|
1938
|
+
if (modifiers !== void 0 && modifiers.includes("@") || groups.gIngredientRecipeAnchor) {
|
|
1939
|
+
flags.push("recipe");
|
|
1940
|
+
}
|
|
1941
|
+
let extras = void 0;
|
|
1942
|
+
if (flags.includes("recipe")) {
|
|
1943
|
+
extras = { path: `${name}.cook` };
|
|
1944
|
+
name = name.substring(name.lastIndexOf("/") + 1);
|
|
1945
|
+
}
|
|
1946
|
+
const aliasMatch = name.match(ingredientAliasRegex);
|
|
1947
|
+
let listName, displayName;
|
|
1948
|
+
if (aliasMatch && aliasMatch.groups.ingredientListName.trim().length > 0 && aliasMatch.groups.ingredientDisplayName.trim().length > 0) {
|
|
1949
|
+
listName = aliasMatch.groups.ingredientListName.trim();
|
|
1950
|
+
displayName = aliasMatch.groups.ingredientDisplayName.trim();
|
|
1951
|
+
} else {
|
|
1952
|
+
listName = name;
|
|
1953
|
+
displayName = name;
|
|
1954
|
+
}
|
|
1955
|
+
const newIngredient = {
|
|
1956
|
+
name: listName
|
|
1957
|
+
};
|
|
1958
|
+
if (preparation) {
|
|
1959
|
+
newIngredient.preparation = preparation;
|
|
1960
|
+
}
|
|
1961
|
+
if (flags.length > 0) {
|
|
1962
|
+
newIngredient.flags = flags;
|
|
1963
|
+
}
|
|
1964
|
+
if (extras) {
|
|
1965
|
+
newIngredient.extras = extras;
|
|
1966
|
+
}
|
|
1967
|
+
const idxInList = findAndUpsertIngredient(
|
|
1968
|
+
this.ingredients,
|
|
1969
|
+
newIngredient,
|
|
1970
|
+
reference
|
|
1971
|
+
);
|
|
1972
|
+
let itemQuantity = void 0;
|
|
1973
|
+
if (groups.gIngredientQuantity) {
|
|
1974
|
+
const parsedQuantities = this._parseQuantityRecursive(
|
|
1975
|
+
groups.gIngredientQuantity
|
|
1976
|
+
);
|
|
1977
|
+
const [primary, ...rest] = parsedQuantities;
|
|
1978
|
+
itemQuantity = {
|
|
1979
|
+
...primary,
|
|
1980
|
+
// there's necessarily a primary quantity as the match group was detected
|
|
1981
|
+
scalable: groups.gIngredientQuantityModifier !== "="
|
|
1982
|
+
};
|
|
1983
|
+
if (rest.length > 0) {
|
|
1984
|
+
itemQuantity.equivalents = rest;
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
const alternative = {
|
|
1988
|
+
index: idxInList,
|
|
1989
|
+
displayName
|
|
1990
|
+
};
|
|
1991
|
+
if (itemQuantity) {
|
|
1992
|
+
alternative.itemQuantity = itemQuantity;
|
|
1993
|
+
}
|
|
1994
|
+
const existingAlternatives = this.choices.ingredientGroups.get(groupKey);
|
|
1995
|
+
function upsertAlternativeToIngredient(ingredients, ingredientIdx, newAlternativeIdx) {
|
|
1996
|
+
const ingredient = ingredients[ingredientIdx];
|
|
1997
|
+
if (ingredient) {
|
|
1998
|
+
if (ingredient.alternatives === void 0) {
|
|
1999
|
+
ingredient.alternatives = /* @__PURE__ */ new Set([newAlternativeIdx]);
|
|
2000
|
+
} else {
|
|
2001
|
+
ingredient.alternatives.add(newAlternativeIdx);
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
}
|
|
2005
|
+
if (existingAlternatives) {
|
|
2006
|
+
for (const alt of existingAlternatives) {
|
|
2007
|
+
upsertAlternativeToIngredient(this.ingredients, alt.index, idxInList);
|
|
2008
|
+
upsertAlternativeToIngredient(this.ingredients, idxInList, alt.index);
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
const id = `ingredient-item-${this.getAndIncrementItemCount()}`;
|
|
2012
|
+
const newItem = {
|
|
2013
|
+
type: "ingredient",
|
|
2014
|
+
id,
|
|
2015
|
+
group: groupKey,
|
|
2016
|
+
alternatives: [alternative]
|
|
2017
|
+
};
|
|
2018
|
+
items.push(newItem);
|
|
2019
|
+
const choiceAlternative = deepClone(alternative);
|
|
2020
|
+
choiceAlternative.itemId = id;
|
|
2021
|
+
const existingChoice = this.choices.ingredientGroups.get(groupKey);
|
|
2022
|
+
if (!existingChoice) {
|
|
2023
|
+
this.choices.ingredientGroups.set(groupKey, [choiceAlternative]);
|
|
2024
|
+
} else {
|
|
2025
|
+
existingChoice.push(choiceAlternative);
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
/**
|
|
2029
|
+
* Populates the `quantities` property for each ingredient based on
|
|
2030
|
+
* how they appear in the recipe preparation. Only primary ingredients
|
|
2031
|
+
* get quantities populated. Primary ingredients get `usedAsPrimary: true` flag.
|
|
2032
|
+
*
|
|
2033
|
+
* For inline alternatives (e.g. `\@a|b|c`), the first alternative is primary.
|
|
2034
|
+
* For grouped alternatives (e.g. `\@|group|a`, `\@|group|b`), the first item in the group is primary.
|
|
2035
|
+
*
|
|
2036
|
+
* Quantities are grouped by their alternative signature and summed using addEquivalentsAndSimplify.
|
|
2037
|
+
* @internal
|
|
2038
|
+
*/
|
|
2039
|
+
_populate_ingredient_quantities() {
|
|
2040
|
+
for (const ing of this.ingredients) {
|
|
2041
|
+
delete ing.quantities;
|
|
2042
|
+
delete ing.usedAsPrimary;
|
|
2043
|
+
}
|
|
2044
|
+
const ingredientsWithQuantities = this.getIngredientQuantities();
|
|
2045
|
+
const matchedIndices = /* @__PURE__ */ new Set();
|
|
2046
|
+
for (const computed of ingredientsWithQuantities) {
|
|
2047
|
+
const idx = this.ingredients.findIndex(
|
|
2048
|
+
(ing2, i2) => ing2.name === computed.name && !matchedIndices.has(i2)
|
|
2049
|
+
);
|
|
2050
|
+
matchedIndices.add(idx);
|
|
2051
|
+
const ing = this.ingredients[idx];
|
|
2052
|
+
if (computed.quantities) {
|
|
2053
|
+
ing.quantities = computed.quantities;
|
|
2054
|
+
}
|
|
2055
|
+
if (computed.usedAsPrimary) {
|
|
2056
|
+
ing.usedAsPrimary = true;
|
|
2057
|
+
}
|
|
2058
|
+
}
|
|
2059
|
+
}
|
|
2060
|
+
/**
|
|
2061
|
+
* Gets ingredients with their quantities populated, optionally filtered by section/step
|
|
2062
|
+
* and respecting user choices for alternatives.
|
|
2063
|
+
*
|
|
2064
|
+
* When no options are provided, returns all recipe ingredients with quantities
|
|
2065
|
+
* calculated using primary alternatives (same as after parsing).
|
|
2066
|
+
*
|
|
2067
|
+
* @param options - Options for filtering and choice selection:
|
|
2068
|
+
* - `section`: Filter to a specific section (Section object or 0-based index)
|
|
2069
|
+
* - `step`: Filter to a specific step (Step object or 0-based index)
|
|
2070
|
+
* - `choices`: Choices for alternative ingredients (defaults to primary)
|
|
2071
|
+
* @returns Array of Ingredient objects with quantities populated
|
|
2072
|
+
*
|
|
2073
|
+
* @example
|
|
2074
|
+
* ```typescript
|
|
2075
|
+
* // Get all ingredients with primary alternatives
|
|
2076
|
+
* const ingredients = recipe.getIngredientQuantities();
|
|
2077
|
+
*
|
|
2078
|
+
* // Get ingredients for a specific section
|
|
2079
|
+
* const sectionIngredients = recipe.getIngredientQuantities({ section: 0 });
|
|
2080
|
+
*
|
|
2081
|
+
* // Get ingredients with specific choices applied
|
|
2082
|
+
* const withChoices = recipe.getIngredientQuantities({
|
|
2083
|
+
* choices: { ingredientItems: new Map([['ingredient-item-2', 1]]) }
|
|
2084
|
+
* });
|
|
2085
|
+
* ```
|
|
2086
|
+
*/
|
|
2087
|
+
getIngredientQuantities(options) {
|
|
2088
|
+
const { section, step, choices } = options || {};
|
|
2089
|
+
const sectionsToProcess = section !== void 0 ? (() => {
|
|
2090
|
+
const idx = typeof section === "number" ? section : this.sections.indexOf(section);
|
|
2091
|
+
return idx >= 0 && idx < this.sections.length ? [this.sections[idx]] : [];
|
|
2092
|
+
})() : this.sections;
|
|
2093
|
+
const ingredientGroups = /* @__PURE__ */ new Map();
|
|
2094
|
+
const selectedIndices = /* @__PURE__ */ new Set();
|
|
2095
|
+
const referencedIndices = /* @__PURE__ */ new Set();
|
|
2096
|
+
for (const currentSection of sectionsToProcess) {
|
|
2097
|
+
const allSteps = currentSection.content.filter(
|
|
2098
|
+
(item) => item.type === "step"
|
|
2099
|
+
);
|
|
2100
|
+
const stepsToProcess = step === void 0 ? allSteps : typeof step === "number" ? step >= 0 && step < allSteps.length ? [allSteps[step]] : [] : allSteps.includes(step) ? [step] : [];
|
|
2101
|
+
for (const currentStep of stepsToProcess) {
|
|
2102
|
+
for (const item of currentStep.items.filter(
|
|
2103
|
+
(item2) => item2.type === "ingredient"
|
|
2104
|
+
)) {
|
|
2105
|
+
const isGrouped = "group" in item && item.group !== void 0;
|
|
2106
|
+
const groupAlternatives = isGrouped ? this.choices.ingredientGroups.get(item.group) : void 0;
|
|
2107
|
+
let selectedAltIndex = 0;
|
|
2108
|
+
let isSelected = false;
|
|
2109
|
+
let hasExplicitChoice = false;
|
|
2110
|
+
if (isGrouped) {
|
|
2111
|
+
const groupChoice = choices?.ingredientGroups?.get(item.group);
|
|
2112
|
+
hasExplicitChoice = groupChoice !== void 0;
|
|
2113
|
+
const targetIndex = groupChoice ?? 0;
|
|
2114
|
+
isSelected = groupAlternatives?.[targetIndex]?.itemId === item.id;
|
|
2115
|
+
} else {
|
|
2116
|
+
const itemChoice = choices?.ingredientItems?.get(item.id);
|
|
2117
|
+
hasExplicitChoice = itemChoice !== void 0;
|
|
2118
|
+
selectedAltIndex = itemChoice ?? 0;
|
|
2119
|
+
isSelected = true;
|
|
2120
|
+
}
|
|
2121
|
+
const alternative = item.alternatives[selectedAltIndex];
|
|
2122
|
+
if (!alternative || !isSelected) continue;
|
|
2123
|
+
selectedIndices.add(alternative.index);
|
|
2124
|
+
const allAlts = isGrouped ? groupAlternatives : item.alternatives;
|
|
2125
|
+
for (const alt of allAlts) {
|
|
2126
|
+
referencedIndices.add(alt.index);
|
|
2127
|
+
}
|
|
2128
|
+
if (!alternative.itemQuantity) continue;
|
|
2129
|
+
const baseQty = {
|
|
2130
|
+
quantity: alternative.itemQuantity.quantity,
|
|
2131
|
+
...alternative.itemQuantity.unit && {
|
|
2132
|
+
unit: alternative.itemQuantity.unit
|
|
2133
|
+
}
|
|
2134
|
+
};
|
|
2135
|
+
const quantityEntry = alternative.itemQuantity.equivalents?.length ? { or: [baseQty, ...alternative.itemQuantity.equivalents] } : baseQty;
|
|
2136
|
+
let alternativeRefs;
|
|
2137
|
+
if (!hasExplicitChoice && allAlts.length > 1) {
|
|
2138
|
+
alternativeRefs = allAlts.filter(
|
|
2139
|
+
(alt) => isGrouped ? alt.itemId !== item.id : alt.index !== alternative.index
|
|
2140
|
+
).map((otherAlt) => {
|
|
2141
|
+
const ref = { index: otherAlt.index };
|
|
2142
|
+
if (otherAlt.itemQuantity) {
|
|
2143
|
+
const altQty = {
|
|
2144
|
+
quantity: otherAlt.itemQuantity.quantity,
|
|
2145
|
+
...otherAlt.itemQuantity.unit && {
|
|
2146
|
+
unit: otherAlt.itemQuantity.unit.name
|
|
2147
|
+
},
|
|
2148
|
+
...otherAlt.itemQuantity.equivalents && {
|
|
2149
|
+
equivalents: otherAlt.itemQuantity.equivalents.map(
|
|
2150
|
+
(eq) => toPlainUnit(eq)
|
|
2151
|
+
)
|
|
2152
|
+
}
|
|
2153
|
+
};
|
|
2154
|
+
ref.quantities = [altQty];
|
|
2155
|
+
}
|
|
2156
|
+
return ref;
|
|
2157
|
+
});
|
|
2158
|
+
}
|
|
2159
|
+
const altIndices = getAlternativeSignature(alternativeRefs) ?? "";
|
|
2160
|
+
let signature;
|
|
2161
|
+
if (isGrouped) {
|
|
2162
|
+
const resolvedUnit = resolveUnit(
|
|
2163
|
+
alternative.itemQuantity.unit?.name
|
|
2164
|
+
);
|
|
2165
|
+
signature = `group:${item.group}|${altIndices}|${resolvedUnit.type}`;
|
|
2166
|
+
} else if (altIndices) {
|
|
2167
|
+
const resolvedUnit = resolveUnit(
|
|
2168
|
+
alternative.itemQuantity.unit?.name
|
|
2169
|
+
);
|
|
2170
|
+
signature = `${altIndices}|${resolvedUnit.type}}`;
|
|
2171
|
+
} else {
|
|
2172
|
+
signature = null;
|
|
2173
|
+
}
|
|
2174
|
+
if (!ingredientGroups.has(alternative.index)) {
|
|
2175
|
+
ingredientGroups.set(alternative.index, /* @__PURE__ */ new Map());
|
|
2176
|
+
}
|
|
2177
|
+
const groupsForIng = ingredientGroups.get(alternative.index);
|
|
2178
|
+
if (!groupsForIng.has(signature)) {
|
|
2179
|
+
groupsForIng.set(signature, {
|
|
2180
|
+
quantities: [],
|
|
2181
|
+
alternativeQuantities: /* @__PURE__ */ new Map()
|
|
2182
|
+
});
|
|
2183
|
+
}
|
|
2184
|
+
const group = groupsForIng.get(signature);
|
|
2185
|
+
group.quantities.push(quantityEntry);
|
|
2186
|
+
for (const ref of alternativeRefs ?? []) {
|
|
2187
|
+
if (!group.alternativeQuantities.has(ref.index)) {
|
|
2188
|
+
group.alternativeQuantities.set(ref.index, []);
|
|
2189
|
+
}
|
|
2190
|
+
for (const altQty of ref.quantities ?? []) {
|
|
2191
|
+
const extended = toExtendedUnit({
|
|
2192
|
+
quantity: altQty.quantity,
|
|
2193
|
+
unit: altQty.unit
|
|
2194
|
+
});
|
|
2195
|
+
if (altQty.equivalents?.length) {
|
|
2196
|
+
const eqEntries = [
|
|
2197
|
+
extended,
|
|
2198
|
+
...altQty.equivalents.map((eq) => toExtendedUnit(eq))
|
|
2199
|
+
];
|
|
2200
|
+
group.alternativeQuantities.get(ref.index).push({ or: eqEntries });
|
|
2201
|
+
} else {
|
|
2202
|
+
group.alternativeQuantities.get(ref.index).push(extended);
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2205
|
+
}
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
2208
|
+
}
|
|
2209
|
+
const result = [];
|
|
2210
|
+
for (let index = 0; index < this.ingredients.length; index++) {
|
|
2211
|
+
if (!referencedIndices.has(index)) continue;
|
|
2212
|
+
const orig = this.ingredients[index];
|
|
2213
|
+
const ing = {
|
|
2214
|
+
name: orig.name,
|
|
2215
|
+
...orig.preparation && { preparation: orig.preparation },
|
|
2216
|
+
...orig.flags && { flags: orig.flags },
|
|
2217
|
+
...orig.extras && { extras: orig.extras }
|
|
2218
|
+
};
|
|
2219
|
+
if (selectedIndices.has(index)) {
|
|
2220
|
+
ing.usedAsPrimary = true;
|
|
2221
|
+
const groupsForIng = ingredientGroups.get(index);
|
|
2222
|
+
if (groupsForIng) {
|
|
2223
|
+
const quantityGroups = [];
|
|
2224
|
+
for (const [, group] of groupsForIng) {
|
|
2225
|
+
const summed = addEquivalentsAndSimplify(...group.quantities);
|
|
2226
|
+
const flattened = flattenPlainUnitGroup(summed);
|
|
2227
|
+
const alternatives = group.alternativeQuantities.size > 0 ? [...group.alternativeQuantities].map(([altIdx, altQtys]) => ({
|
|
2228
|
+
index: altIdx,
|
|
2229
|
+
...altQtys.length > 0 && {
|
|
2230
|
+
quantities: flattenPlainUnitGroup(
|
|
2231
|
+
addEquivalentsAndSimplify(...altQtys)
|
|
2232
|
+
).flatMap(
|
|
2233
|
+
/* v8 ignore next -- item.and branch requires complex nested AND-with-equivalents structure */
|
|
2234
|
+
(item) => "quantity" in item ? [item] : item.and
|
|
2235
|
+
)
|
|
2236
|
+
}
|
|
2237
|
+
})) : void 0;
|
|
2238
|
+
for (const gq of flattened) {
|
|
2239
|
+
if ("and" in gq) {
|
|
2240
|
+
quantityGroups.push({
|
|
2241
|
+
and: gq.and,
|
|
2242
|
+
...gq.equivalents?.length && {
|
|
2243
|
+
equivalents: gq.equivalents
|
|
2244
|
+
},
|
|
2245
|
+
...alternatives?.length && { alternatives }
|
|
2246
|
+
});
|
|
2247
|
+
} else {
|
|
2248
|
+
quantityGroups.push({
|
|
2249
|
+
...gq,
|
|
2250
|
+
...alternatives?.length && { alternatives }
|
|
2251
|
+
});
|
|
2252
|
+
}
|
|
2253
|
+
}
|
|
2254
|
+
}
|
|
2255
|
+
if (quantityGroups.length > 0) {
|
|
2256
|
+
ing.quantities = quantityGroups;
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
2260
|
+
result.push(ing);
|
|
2261
|
+
}
|
|
2262
|
+
return result;
|
|
2263
|
+
}
|
|
2264
|
+
/**
|
|
2265
|
+
* Parses a recipe from a string.
|
|
2266
|
+
* @param content - The recipe content to parse.
|
|
2267
|
+
*/
|
|
2268
|
+
parse(content) {
|
|
2269
|
+
const cleanContent = content.replace(metadataRegex, "").replace(commentRegex, "").replace(blockCommentRegex, "").trim().split(/\r\n?|\n/);
|
|
2270
|
+
const { metadata, servings } = extractMetadata(content);
|
|
2271
|
+
this.metadata = metadata;
|
|
2272
|
+
this.servings = servings;
|
|
2273
|
+
let blankLineBefore = true;
|
|
2274
|
+
let section = new Section();
|
|
2275
|
+
const items = [];
|
|
2276
|
+
let noteText = "";
|
|
2277
|
+
let inNote = false;
|
|
2278
|
+
for (const line of cleanContent) {
|
|
2279
|
+
if (line.trim().length === 0) {
|
|
2280
|
+
flushPendingItems(section, items);
|
|
2281
|
+
flushPendingNote(
|
|
2282
|
+
section,
|
|
2283
|
+
noteText ? this._parseNoteText(noteText) : []
|
|
2284
|
+
);
|
|
2285
|
+
noteText = "";
|
|
2286
|
+
blankLineBefore = true;
|
|
2287
|
+
inNote = false;
|
|
2288
|
+
continue;
|
|
2289
|
+
}
|
|
2290
|
+
if (line.startsWith("=")) {
|
|
2291
|
+
flushPendingItems(section, items);
|
|
2292
|
+
flushPendingNote(
|
|
2293
|
+
section,
|
|
2294
|
+
noteText ? this._parseNoteText(noteText) : []
|
|
2295
|
+
);
|
|
2296
|
+
noteText = "";
|
|
2297
|
+
if (this.sections.length === 0 && section.isBlank()) {
|
|
975
2298
|
section.name = line.replace(/^=+|=+$/g, "").trim();
|
|
976
2299
|
} else {
|
|
977
2300
|
if (!section.isBlank()) {
|
|
@@ -985,22 +2308,20 @@ var Recipe = class _Recipe {
|
|
|
985
2308
|
}
|
|
986
2309
|
if (blankLineBefore && line.startsWith(">")) {
|
|
987
2310
|
flushPendingItems(section, items);
|
|
988
|
-
|
|
989
|
-
note += line.substring(1).trim();
|
|
2311
|
+
noteText = line.substring(1).trim();
|
|
990
2312
|
inNote = true;
|
|
991
2313
|
blankLineBefore = false;
|
|
992
2314
|
continue;
|
|
993
2315
|
}
|
|
994
2316
|
if (inNote) {
|
|
995
2317
|
if (line.startsWith(">")) {
|
|
996
|
-
|
|
2318
|
+
noteText += " " + line.substring(1).trim();
|
|
997
2319
|
} else {
|
|
998
|
-
|
|
2320
|
+
noteText += " " + line.trim();
|
|
999
2321
|
}
|
|
1000
2322
|
blankLineBefore = false;
|
|
1001
2323
|
continue;
|
|
1002
2324
|
}
|
|
1003
|
-
note = flushPendingNote(section, note);
|
|
1004
2325
|
let cursor = 0;
|
|
1005
2326
|
for (const match of line.matchAll(tokensRegex)) {
|
|
1006
2327
|
const idx = match.index;
|
|
@@ -1009,12 +2330,13 @@ var Recipe = class _Recipe {
|
|
|
1009
2330
|
}
|
|
1010
2331
|
const groups = match.groups;
|
|
1011
2332
|
if (groups.mIngredientName || groups.sIngredientName) {
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
const
|
|
1017
|
-
const modifiers = groups.
|
|
2333
|
+
this._parseIngredientWithAlternativeRecursive(match[0], items);
|
|
2334
|
+
} else if (groups.gmIngredientName || groups.gsIngredientName) {
|
|
2335
|
+
this._parseIngredientWithGroupKey(match[0], items);
|
|
2336
|
+
} else if (groups.mCookwareName || groups.sCookwareName) {
|
|
2337
|
+
const name = groups.mCookwareName || groups.sCookwareName;
|
|
2338
|
+
const modifiers = groups.cookwareModifiers;
|
|
2339
|
+
const quantityRaw = groups.cookwareQuantity;
|
|
1018
2340
|
const reference = modifiers !== void 0 && modifiers.includes("&");
|
|
1019
2341
|
const flags = [];
|
|
1020
2342
|
if (modifiers !== void 0 && modifiers.includes("?")) {
|
|
@@ -1023,83 +2345,31 @@ var Recipe = class _Recipe {
|
|
|
1023
2345
|
if (modifiers !== void 0 && modifiers.includes("-")) {
|
|
1024
2346
|
flags.push("hidden");
|
|
1025
2347
|
}
|
|
1026
|
-
if (modifiers !== void 0 && modifiers.includes("@") || groups.mIngredientRecipeAnchor || groups.sIngredientRecipeAnchor) {
|
|
1027
|
-
flags.push("recipe");
|
|
1028
|
-
}
|
|
1029
|
-
let extras = void 0;
|
|
1030
|
-
if (flags.includes("recipe")) {
|
|
1031
|
-
extras = { path: `${name}.cook` };
|
|
1032
|
-
name = name.substring(name.lastIndexOf("/") + 1);
|
|
1033
|
-
}
|
|
1034
2348
|
const quantity = quantityRaw ? parseQuantityInput(quantityRaw) : void 0;
|
|
1035
|
-
const
|
|
1036
|
-
|
|
1037
|
-
if (aliasMatch && aliasMatch.groups.ingredientListName.trim().length > 0 && aliasMatch.groups.ingredientDisplayName.trim().length > 0) {
|
|
1038
|
-
listName = aliasMatch.groups.ingredientListName.trim();
|
|
1039
|
-
displayName = aliasMatch.groups.ingredientDisplayName.trim();
|
|
1040
|
-
} else {
|
|
1041
|
-
listName = name;
|
|
1042
|
-
displayName = name;
|
|
1043
|
-
}
|
|
1044
|
-
const newIngredient = {
|
|
1045
|
-
name: listName,
|
|
1046
|
-
quantity,
|
|
1047
|
-
quantityParts: quantity ? [
|
|
1048
|
-
{
|
|
1049
|
-
value: quantity,
|
|
1050
|
-
unit,
|
|
1051
|
-
scalable: scalableQuantity
|
|
1052
|
-
}
|
|
1053
|
-
] : void 0,
|
|
1054
|
-
unit,
|
|
1055
|
-
preparation,
|
|
1056
|
-
flags
|
|
2349
|
+
const newCookware = {
|
|
2350
|
+
name
|
|
1057
2351
|
};
|
|
1058
|
-
if (
|
|
1059
|
-
|
|
2352
|
+
if (quantity) {
|
|
2353
|
+
newCookware.quantity = quantity;
|
|
1060
2354
|
}
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
2355
|
+
if (flags.length > 0) {
|
|
2356
|
+
newCookware.flags = flags;
|
|
2357
|
+
}
|
|
2358
|
+
const idxInList = findAndUpsertCookware(
|
|
2359
|
+
this.cookware,
|
|
2360
|
+
newCookware,
|
|
1064
2361
|
reference
|
|
1065
2362
|
);
|
|
1066
2363
|
const newItem = {
|
|
1067
|
-
type: "
|
|
1068
|
-
index:
|
|
1069
|
-
displayName
|
|
2364
|
+
type: "cookware",
|
|
2365
|
+
index: idxInList
|
|
1070
2366
|
};
|
|
1071
|
-
if (
|
|
1072
|
-
newItem.
|
|
2367
|
+
if (quantity) {
|
|
2368
|
+
newItem.quantity = quantity;
|
|
1073
2369
|
}
|
|
1074
2370
|
items.push(newItem);
|
|
1075
|
-
} else if (groups.
|
|
1076
|
-
|
|
1077
|
-
const modifiers = groups.mCookwareModifiers || groups.sCookwareModifiers;
|
|
1078
|
-
const quantityRaw = groups.mCookwareQuantity || groups.sCookwareQuantity;
|
|
1079
|
-
const reference = modifiers !== void 0 && modifiers.includes("&");
|
|
1080
|
-
const flags = [];
|
|
1081
|
-
if (modifiers !== void 0 && modifiers.includes("?")) {
|
|
1082
|
-
flags.push("optional");
|
|
1083
|
-
}
|
|
1084
|
-
if (modifiers !== void 0 && modifiers.includes("-")) {
|
|
1085
|
-
flags.push("hidden");
|
|
1086
|
-
}
|
|
1087
|
-
const quantity = quantityRaw ? parseQuantityInput(quantityRaw) : void 0;
|
|
1088
|
-
const idxsInList = findAndUpsertCookware(
|
|
1089
|
-
this.cookware,
|
|
1090
|
-
{
|
|
1091
|
-
name,
|
|
1092
|
-
quantity,
|
|
1093
|
-
quantityParts: quantity ? [quantity] : void 0,
|
|
1094
|
-
flags
|
|
1095
|
-
},
|
|
1096
|
-
reference
|
|
1097
|
-
);
|
|
1098
|
-
items.push({
|
|
1099
|
-
type: "cookware",
|
|
1100
|
-
index: idxsInList.cookwareIndex,
|
|
1101
|
-
quantityPartIndex: idxsInList.quantityPartIndex
|
|
1102
|
-
});
|
|
2371
|
+
} else if (groups.arbitraryQuantity) {
|
|
2372
|
+
this._parseArbitraryScalable(groups, items);
|
|
1103
2373
|
} else {
|
|
1104
2374
|
const durationStr = groups.timerQuantity.trim();
|
|
1105
2375
|
const unit = (groups.timerUnit || "").trim();
|
|
@@ -1123,10 +2393,11 @@ var Recipe = class _Recipe {
|
|
|
1123
2393
|
blankLineBefore = false;
|
|
1124
2394
|
}
|
|
1125
2395
|
flushPendingItems(section, items);
|
|
1126
|
-
|
|
2396
|
+
flushPendingNote(section, noteText ? this._parseNoteText(noteText) : []);
|
|
1127
2397
|
if (!section.isBlank()) {
|
|
1128
2398
|
this.sections.push(section);
|
|
1129
2399
|
}
|
|
2400
|
+
this._populate_ingredient_quantities();
|
|
1130
2401
|
}
|
|
1131
2402
|
/**
|
|
1132
2403
|
* Scales the recipe to a new number of servings. In practice, it calls
|
|
@@ -1141,7 +2412,7 @@ var Recipe = class _Recipe {
|
|
|
1141
2412
|
if (originalServings === void 0 || originalServings === 0) {
|
|
1142
2413
|
originalServings = 1;
|
|
1143
2414
|
}
|
|
1144
|
-
const factor = (0,
|
|
2415
|
+
const factor = (0, import_big4.default)(newServings).div(originalServings);
|
|
1145
2416
|
return this.scaleBy(factor);
|
|
1146
2417
|
}
|
|
1147
2418
|
/**
|
|
@@ -1156,44 +2427,68 @@ var Recipe = class _Recipe {
|
|
|
1156
2427
|
if (originalServings === void 0 || originalServings === 0) {
|
|
1157
2428
|
originalServings = 1;
|
|
1158
2429
|
}
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
(
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
value: multiplyQuantityValue(
|
|
1169
|
-
quantityPart.value,
|
|
1170
|
-
quantityPart.scalable ? (0, import_big2.default)(factor) : 1
|
|
1171
|
-
)
|
|
1172
|
-
};
|
|
2430
|
+
function scaleAlternativesBy(alternatives, factor2) {
|
|
2431
|
+
for (const alternative of alternatives) {
|
|
2432
|
+
if (alternative.itemQuantity) {
|
|
2433
|
+
const scaleFactor = alternative.itemQuantity.scalable ? (0, import_big4.default)(factor2) : 1;
|
|
2434
|
+
if (alternative.itemQuantity.quantity.type !== "fixed" || alternative.itemQuantity.quantity.value.type !== "text") {
|
|
2435
|
+
alternative.itemQuantity.quantity = multiplyQuantityValue(
|
|
2436
|
+
alternative.itemQuantity.quantity,
|
|
2437
|
+
scaleFactor
|
|
2438
|
+
);
|
|
1173
2439
|
}
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
2440
|
+
if (alternative.itemQuantity.equivalents) {
|
|
2441
|
+
alternative.itemQuantity.equivalents = alternative.itemQuantity.equivalents.map(
|
|
2442
|
+
(altQuantity) => {
|
|
2443
|
+
if (altQuantity.quantity.type === "fixed" && altQuantity.quantity.value.type === "text") {
|
|
2444
|
+
return altQuantity;
|
|
2445
|
+
} else {
|
|
2446
|
+
return {
|
|
2447
|
+
...altQuantity,
|
|
2448
|
+
quantity: multiplyQuantityValue(
|
|
2449
|
+
altQuantity.quantity,
|
|
2450
|
+
scaleFactor
|
|
2451
|
+
)
|
|
2452
|
+
};
|
|
2453
|
+
}
|
|
2454
|
+
}
|
|
2455
|
+
);
|
|
2456
|
+
}
|
|
2457
|
+
}
|
|
2458
|
+
}
|
|
2459
|
+
}
|
|
2460
|
+
for (const section of newRecipe.sections) {
|
|
2461
|
+
for (const step of section.content.filter(
|
|
2462
|
+
(item) => item.type === "step"
|
|
2463
|
+
)) {
|
|
2464
|
+
for (const item of step.items.filter(
|
|
2465
|
+
(item2) => item2.type === "ingredient"
|
|
2466
|
+
)) {
|
|
2467
|
+
scaleAlternativesBy(item.alternatives, factor);
|
|
1185
2468
|
}
|
|
1186
2469
|
}
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
2470
|
+
}
|
|
2471
|
+
for (const alternatives of newRecipe.choices.ingredientGroups.values()) {
|
|
2472
|
+
scaleAlternativesBy(alternatives, factor);
|
|
2473
|
+
}
|
|
2474
|
+
for (const alternatives of newRecipe.choices.ingredientItems.values()) {
|
|
2475
|
+
scaleAlternativesBy(alternatives, factor);
|
|
2476
|
+
}
|
|
2477
|
+
for (const arbitrary of newRecipe.arbitraries) {
|
|
2478
|
+
arbitrary.quantity = multiplyQuantityValue(
|
|
2479
|
+
arbitrary.quantity,
|
|
2480
|
+
factor
|
|
2481
|
+
);
|
|
2482
|
+
}
|
|
2483
|
+
newRecipe._populate_ingredient_quantities();
|
|
2484
|
+
newRecipe.servings = (0, import_big4.default)(originalServings).times(factor).toNumber();
|
|
1190
2485
|
if (newRecipe.metadata.servings && this.metadata.servings) {
|
|
1191
2486
|
if (floatRegex.test(String(this.metadata.servings).replace(",", ".").trim())) {
|
|
1192
2487
|
const servingsValue = parseFloat(
|
|
1193
2488
|
String(this.metadata.servings).replace(",", ".")
|
|
1194
2489
|
);
|
|
1195
2490
|
newRecipe.metadata.servings = String(
|
|
1196
|
-
(0,
|
|
2491
|
+
(0, import_big4.default)(servingsValue).times(factor).toNumber()
|
|
1197
2492
|
);
|
|
1198
2493
|
}
|
|
1199
2494
|
}
|
|
@@ -1203,7 +2498,7 @@ var Recipe = class _Recipe {
|
|
|
1203
2498
|
String(this.metadata.yield).replace(",", ".")
|
|
1204
2499
|
);
|
|
1205
2500
|
newRecipe.metadata.yield = String(
|
|
1206
|
-
(0,
|
|
2501
|
+
(0, import_big4.default)(yieldValue).times(factor).toNumber()
|
|
1207
2502
|
);
|
|
1208
2503
|
}
|
|
1209
2504
|
}
|
|
@@ -1213,7 +2508,7 @@ var Recipe = class _Recipe {
|
|
|
1213
2508
|
String(this.metadata.serves).replace(",", ".")
|
|
1214
2509
|
);
|
|
1215
2510
|
newRecipe.metadata.serves = String(
|
|
1216
|
-
(0,
|
|
2511
|
+
(0, import_big4.default)(servesValue).times(factor).toNumber()
|
|
1217
2512
|
);
|
|
1218
2513
|
}
|
|
1219
2514
|
}
|
|
@@ -1236,19 +2531,28 @@ var Recipe = class _Recipe {
|
|
|
1236
2531
|
*/
|
|
1237
2532
|
clone() {
|
|
1238
2533
|
const newRecipe = new _Recipe();
|
|
1239
|
-
newRecipe.
|
|
1240
|
-
newRecipe
|
|
1241
|
-
|
|
1242
|
-
);
|
|
1243
|
-
newRecipe.sections =
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
2534
|
+
newRecipe.choices = deepClone(this.choices);
|
|
2535
|
+
_Recipe.itemCounts.set(newRecipe, this.getItemCount());
|
|
2536
|
+
newRecipe.metadata = deepClone(this.metadata);
|
|
2537
|
+
newRecipe.ingredients = deepClone(this.ingredients);
|
|
2538
|
+
newRecipe.sections = this.sections.map((section) => {
|
|
2539
|
+
const newSection = new Section(section.name);
|
|
2540
|
+
newSection.content = deepClone(section.content);
|
|
2541
|
+
return newSection;
|
|
2542
|
+
});
|
|
2543
|
+
newRecipe.cookware = deepClone(this.cookware);
|
|
2544
|
+
newRecipe.timers = deepClone(this.timers);
|
|
2545
|
+
newRecipe.arbitraries = deepClone(this.arbitraries);
|
|
1248
2546
|
newRecipe.servings = this.servings;
|
|
1249
2547
|
return newRecipe;
|
|
1250
2548
|
}
|
|
1251
2549
|
};
|
|
2550
|
+
/**
|
|
2551
|
+
* External storage for item count (not a property on instances).
|
|
2552
|
+
* Used for giving ID numbers to items during parsing.
|
|
2553
|
+
*/
|
|
2554
|
+
__publicField(_Recipe, "itemCounts", /* @__PURE__ */ new WeakMap());
|
|
2555
|
+
var Recipe = _Recipe;
|
|
1252
2556
|
|
|
1253
2557
|
// src/classes/shopping_list.ts
|
|
1254
2558
|
var ShoppingList = class {
|
|
@@ -1257,6 +2561,7 @@ var ShoppingList = class {
|
|
|
1257
2561
|
* @param category_config_str - The category configuration to parse.
|
|
1258
2562
|
*/
|
|
1259
2563
|
constructor(category_config_str) {
|
|
2564
|
+
// TODO: backport type change
|
|
1260
2565
|
/**
|
|
1261
2566
|
* The ingredients in the shopping list.
|
|
1262
2567
|
*/
|
|
@@ -1279,6 +2584,33 @@ var ShoppingList = class {
|
|
|
1279
2584
|
}
|
|
1280
2585
|
calculate_ingredients() {
|
|
1281
2586
|
this.ingredients = [];
|
|
2587
|
+
const addIngredientQuantity = (name, quantityTotal) => {
|
|
2588
|
+
const quantityTotalExtended = extendAllUnits(quantityTotal);
|
|
2589
|
+
const newQuantities = isAndGroup(quantityTotalExtended) ? quantityTotalExtended.and : [quantityTotalExtended];
|
|
2590
|
+
const existing = this.ingredients.find((i2) => i2.name === name);
|
|
2591
|
+
if (existing) {
|
|
2592
|
+
if (!existing.quantityTotal) {
|
|
2593
|
+
existing.quantityTotal = quantityTotal;
|
|
2594
|
+
return;
|
|
2595
|
+
}
|
|
2596
|
+
try {
|
|
2597
|
+
const existingQuantityTotalExtended = extendAllUnits(
|
|
2598
|
+
existing.quantityTotal
|
|
2599
|
+
);
|
|
2600
|
+
const existingQuantities = isAndGroup(existingQuantityTotalExtended) ? existingQuantityTotalExtended.and : [existingQuantityTotalExtended];
|
|
2601
|
+
existing.quantityTotal = addEquivalentsAndSimplify(
|
|
2602
|
+
...existingQuantities,
|
|
2603
|
+
...newQuantities
|
|
2604
|
+
);
|
|
2605
|
+
return;
|
|
2606
|
+
} catch {
|
|
2607
|
+
}
|
|
2608
|
+
}
|
|
2609
|
+
this.ingredients.push({
|
|
2610
|
+
name,
|
|
2611
|
+
quantityTotal
|
|
2612
|
+
});
|
|
2613
|
+
};
|
|
1282
2614
|
for (const addedRecipe of this.recipes) {
|
|
1283
2615
|
let scaledRecipe;
|
|
1284
2616
|
if ("factor" in addedRecipe) {
|
|
@@ -1287,67 +2619,123 @@ var ShoppingList = class {
|
|
|
1287
2619
|
} else {
|
|
1288
2620
|
scaledRecipe = addedRecipe.recipe.scaleTo(addedRecipe.servings);
|
|
1289
2621
|
}
|
|
1290
|
-
|
|
2622
|
+
const ingredients = scaledRecipe.getIngredientQuantities({
|
|
2623
|
+
choices: addedRecipe.choices
|
|
2624
|
+
});
|
|
2625
|
+
for (const ingredient of ingredients) {
|
|
1291
2626
|
if (ingredient.flags && ingredient.flags.includes("hidden")) {
|
|
1292
2627
|
continue;
|
|
1293
2628
|
}
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
if (
|
|
1301
|
-
const
|
|
1302
|
-
|
|
1303
|
-
value: existingIngredient.quantity,
|
|
1304
|
-
unit: existingIngredient.unit ?? ""
|
|
1305
|
-
},
|
|
1306
|
-
{
|
|
1307
|
-
value: ingredient.quantity,
|
|
1308
|
-
unit: ingredient.unit ?? ""
|
|
1309
|
-
}
|
|
1310
|
-
);
|
|
1311
|
-
existingIngredient.quantity = newQuantity.value;
|
|
1312
|
-
if (newQuantity.unit) {
|
|
1313
|
-
existingIngredient.unit = newQuantity.unit;
|
|
2629
|
+
if (!ingredient.usedAsPrimary) {
|
|
2630
|
+
continue;
|
|
2631
|
+
}
|
|
2632
|
+
if (ingredient.quantities && ingredient.quantities.length > 0) {
|
|
2633
|
+
const allQuantities = [];
|
|
2634
|
+
for (const qGroup of ingredient.quantities) {
|
|
2635
|
+
if ("and" in qGroup) {
|
|
2636
|
+
for (const qty of qGroup.and) {
|
|
2637
|
+
allQuantities.push(qty);
|
|
1314
2638
|
}
|
|
1315
2639
|
} else {
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
2640
|
+
const plainQty = {
|
|
2641
|
+
quantity: qGroup.quantity
|
|
2642
|
+
};
|
|
2643
|
+
if (qGroup.unit) plainQty.unit = qGroup.unit;
|
|
2644
|
+
if (qGroup.equivalents) plainQty.equivalents = qGroup.equivalents;
|
|
2645
|
+
allQuantities.push(plainQty);
|
|
1320
2646
|
}
|
|
1321
2647
|
}
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
2648
|
+
if (allQuantities.length === 1) {
|
|
2649
|
+
addIngredientQuantity(ingredient.name, allQuantities[0]);
|
|
2650
|
+
} else {
|
|
2651
|
+
const extendedQuantities = allQuantities.map(
|
|
2652
|
+
(q) => extendAllUnits(q)
|
|
2653
|
+
);
|
|
2654
|
+
const totalQuantity = addEquivalentsAndSimplify(
|
|
2655
|
+
...extendedQuantities
|
|
2656
|
+
);
|
|
2657
|
+
addIngredientQuantity(ingredient.name, totalQuantity);
|
|
1332
2658
|
}
|
|
1333
|
-
|
|
2659
|
+
} else if (!this.ingredients.some((i2) => i2.name === ingredient.name)) {
|
|
2660
|
+
this.ingredients.push({ name: ingredient.name });
|
|
1334
2661
|
}
|
|
1335
2662
|
}
|
|
1336
2663
|
}
|
|
1337
2664
|
}
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
2665
|
+
/**
|
|
2666
|
+
* Adds a recipe to the shopping list, then automatically
|
|
2667
|
+
* recalculates the quantities and recategorize the ingredients.
|
|
2668
|
+
* @param recipe - The recipe to add.
|
|
2669
|
+
* @param options - Options for adding the recipe.
|
|
2670
|
+
* @throws Error if the recipe has alternatives without corresponding choices.
|
|
2671
|
+
*/
|
|
2672
|
+
add_recipe(recipe, options = {}) {
|
|
2673
|
+
const errorMessage = this.getUnresolvedAlternativesError(
|
|
2674
|
+
recipe,
|
|
2675
|
+
options.choices
|
|
2676
|
+
);
|
|
2677
|
+
if (errorMessage) {
|
|
2678
|
+
throw new Error(errorMessage);
|
|
2679
|
+
}
|
|
2680
|
+
if (!options.scaling) {
|
|
2681
|
+
this.recipes.push({
|
|
2682
|
+
recipe,
|
|
2683
|
+
factor: options.scaling ?? 1,
|
|
2684
|
+
choices: options.choices
|
|
2685
|
+
});
|
|
1341
2686
|
} else {
|
|
1342
|
-
if ("factor" in scaling) {
|
|
1343
|
-
this.recipes.push({
|
|
2687
|
+
if ("factor" in options.scaling) {
|
|
2688
|
+
this.recipes.push({
|
|
2689
|
+
recipe,
|
|
2690
|
+
factor: options.scaling.factor,
|
|
2691
|
+
choices: options.choices
|
|
2692
|
+
});
|
|
1344
2693
|
} else {
|
|
1345
|
-
this.recipes.push({
|
|
2694
|
+
this.recipes.push({
|
|
2695
|
+
recipe,
|
|
2696
|
+
servings: options.scaling.servings,
|
|
2697
|
+
choices: options.choices
|
|
2698
|
+
});
|
|
1346
2699
|
}
|
|
1347
2700
|
}
|
|
1348
2701
|
this.calculate_ingredients();
|
|
1349
2702
|
this.categorize();
|
|
1350
2703
|
}
|
|
2704
|
+
/**
|
|
2705
|
+
* Checks if a recipe has unresolved alternatives (alternatives without provided choices).
|
|
2706
|
+
* @param recipe - The recipe to check.
|
|
2707
|
+
* @param choices - The choices provided for the recipe.
|
|
2708
|
+
* @returns An error message if there are unresolved alternatives, undefined otherwise.
|
|
2709
|
+
*/
|
|
2710
|
+
getUnresolvedAlternativesError(recipe, choices) {
|
|
2711
|
+
const missingItems = [];
|
|
2712
|
+
const missingGroups = [];
|
|
2713
|
+
for (const itemId of recipe.choices.ingredientItems.keys()) {
|
|
2714
|
+
if (!choices?.ingredientItems?.has(itemId)) {
|
|
2715
|
+
missingItems.push(itemId);
|
|
2716
|
+
}
|
|
2717
|
+
}
|
|
2718
|
+
for (const groupId of recipe.choices.ingredientGroups.keys()) {
|
|
2719
|
+
if (!choices?.ingredientGroups?.has(groupId)) {
|
|
2720
|
+
missingGroups.push(groupId);
|
|
2721
|
+
}
|
|
2722
|
+
}
|
|
2723
|
+
if (missingItems.length === 0 && missingGroups.length === 0) {
|
|
2724
|
+
return void 0;
|
|
2725
|
+
}
|
|
2726
|
+
const parts = [];
|
|
2727
|
+
if (missingItems.length > 0) {
|
|
2728
|
+
parts.push(
|
|
2729
|
+
`ingredientItems: [${missingItems.map((i2) => `'${i2}'`).join(", ")}]`
|
|
2730
|
+
);
|
|
2731
|
+
}
|
|
2732
|
+
if (missingGroups.length > 0) {
|
|
2733
|
+
parts.push(
|
|
2734
|
+
`ingredientGroups: [${missingGroups.map((g) => `'${g}'`).join(", ")}]`
|
|
2735
|
+
);
|
|
2736
|
+
}
|
|
2737
|
+
return `Recipe has unresolved alternatives. Missing choices for: ${parts.join(", ")}`;
|
|
2738
|
+
}
|
|
1351
2739
|
/**
|
|
1352
2740
|
* Removes a recipe from the shopping list, then automatically
|
|
1353
2741
|
* recalculates the quantities and recategorize the ingredients.s
|
|
@@ -1407,15 +2795,352 @@ var ShoppingList = class {
|
|
|
1407
2795
|
this.categories = categories;
|
|
1408
2796
|
}
|
|
1409
2797
|
};
|
|
2798
|
+
|
|
2799
|
+
// src/classes/shopping_cart.ts
|
|
2800
|
+
var import_yalps = require("yalps");
|
|
2801
|
+
var ShoppingCart = class {
|
|
2802
|
+
/**
|
|
2803
|
+
* Creates a new ShoppingCart instance
|
|
2804
|
+
* @param options - {@link ShoppingCartOptions | Options} for the constructor
|
|
2805
|
+
*/
|
|
2806
|
+
constructor(options) {
|
|
2807
|
+
/**
|
|
2808
|
+
* The product catalog to use for matching products
|
|
2809
|
+
*/
|
|
2810
|
+
__publicField(this, "productCatalog");
|
|
2811
|
+
/**
|
|
2812
|
+
* The shopping list to build the cart from
|
|
2813
|
+
*/
|
|
2814
|
+
__publicField(this, "shoppingList");
|
|
2815
|
+
/**
|
|
2816
|
+
* The content of the cart
|
|
2817
|
+
*/
|
|
2818
|
+
__publicField(this, "cart", []);
|
|
2819
|
+
/**
|
|
2820
|
+
* The ingredients that were successfully matched with products
|
|
2821
|
+
*/
|
|
2822
|
+
__publicField(this, "match", []);
|
|
2823
|
+
/**
|
|
2824
|
+
* The ingredients that could not be matched with products
|
|
2825
|
+
*/
|
|
2826
|
+
__publicField(this, "misMatch", []);
|
|
2827
|
+
/**
|
|
2828
|
+
* Key information about the shopping cart
|
|
2829
|
+
*/
|
|
2830
|
+
__publicField(this, "summary");
|
|
2831
|
+
if (options?.catalog) this.productCatalog = options.catalog;
|
|
2832
|
+
if (options?.list) this.shoppingList = options.list;
|
|
2833
|
+
this.summary = { totalPrice: 0, totalItems: 0 };
|
|
2834
|
+
}
|
|
2835
|
+
/**
|
|
2836
|
+
* Sets the product catalog to use for matching products
|
|
2837
|
+
* To use if a catalog was not provided at the creation of the instance
|
|
2838
|
+
* @param catalog - The {@link ProductCatalog} to set
|
|
2839
|
+
*/
|
|
2840
|
+
setProductCatalog(catalog) {
|
|
2841
|
+
this.productCatalog = catalog;
|
|
2842
|
+
}
|
|
2843
|
+
// TODO: harmonize recipe name to use underscores
|
|
2844
|
+
/**
|
|
2845
|
+
* Sets the shopping list to build the cart from.
|
|
2846
|
+
* To use if a shopping list was not provided at the creation of the instance
|
|
2847
|
+
* @param list - The {@link ShoppingList} to set
|
|
2848
|
+
*/
|
|
2849
|
+
setShoppingList(list) {
|
|
2850
|
+
this.shoppingList = list;
|
|
2851
|
+
}
|
|
2852
|
+
/**
|
|
2853
|
+
* Builds the cart from the shopping list and product catalog
|
|
2854
|
+
* @remarks
|
|
2855
|
+
* - If a combination of product(s) is successfully found for a given ingredient, the latter will be listed in the {@link ShoppingCart.match | match} array
|
|
2856
|
+
* in addition to that combination being added to the {@link ShoppingCart.cart | cart}.
|
|
2857
|
+
* - Otherwise, the latter will be listed in the {@link ShoppingCart.misMatch | misMatch} array. Possible causes can be:
|
|
2858
|
+
* - No product is listed in the catalog for that ingredient
|
|
2859
|
+
* - The ingredient has no quantity, a text quantity
|
|
2860
|
+
* - The ingredient's quantity unit is incompatible with the units of the candidate products listed in the catalog
|
|
2861
|
+
* @throws {@link NoProductCatalogForCartError} if no product catalog is set
|
|
2862
|
+
* @throws {@link NoShoppingListForCartError} if no shopping list is set
|
|
2863
|
+
* @returns `true` if all ingredients in the shopping list have been matched to products in the catalog, or `false` otherwise
|
|
2864
|
+
*/
|
|
2865
|
+
buildCart() {
|
|
2866
|
+
this.resetCart();
|
|
2867
|
+
if (this.productCatalog === void 0) {
|
|
2868
|
+
throw new NoProductCatalogForCartError();
|
|
2869
|
+
} else if (this.shoppingList === void 0) {
|
|
2870
|
+
throw new NoShoppingListForCartError();
|
|
2871
|
+
}
|
|
2872
|
+
for (const ingredient of this.shoppingList.ingredients) {
|
|
2873
|
+
const productOptions = this.getProductOptions(ingredient);
|
|
2874
|
+
try {
|
|
2875
|
+
const optimumMatch = this.getOptimumMatch(ingredient, productOptions);
|
|
2876
|
+
this.cart.push(...optimumMatch);
|
|
2877
|
+
this.match.push({ ingredient, selection: optimumMatch });
|
|
2878
|
+
} catch (error) {
|
|
2879
|
+
if (error instanceof NoProductMatchError) {
|
|
2880
|
+
this.misMatch.push({ ingredient, reason: error.code });
|
|
2881
|
+
}
|
|
2882
|
+
}
|
|
2883
|
+
}
|
|
2884
|
+
this.summarize();
|
|
2885
|
+
return this.misMatch.length > 0;
|
|
2886
|
+
}
|
|
2887
|
+
/**
|
|
2888
|
+
* Gets the product options for a given ingredient
|
|
2889
|
+
* @param ingredient - The ingredient to get the product options for
|
|
2890
|
+
* @returns An array of {@link ProductOption}
|
|
2891
|
+
*/
|
|
2892
|
+
getProductOptions(ingredient) {
|
|
2893
|
+
return this.productCatalog.products.filter(
|
|
2894
|
+
(product) => product.ingredientName === ingredient.name || product.ingredientAliases?.includes(ingredient.name)
|
|
2895
|
+
);
|
|
2896
|
+
}
|
|
2897
|
+
/**
|
|
2898
|
+
* Gets the optimum match for a given ingredient and product option
|
|
2899
|
+
* @param ingredient - The ingredient to match
|
|
2900
|
+
* @param options - The product options to choose from
|
|
2901
|
+
* @returns An array of {@link ProductSelection}
|
|
2902
|
+
* @throws {@link NoProductMatchError} if no match can be found
|
|
2903
|
+
*/
|
|
2904
|
+
getOptimumMatch(ingredient, options) {
|
|
2905
|
+
if (options.length === 0)
|
|
2906
|
+
throw new NoProductMatchError(ingredient.name, "noProduct");
|
|
2907
|
+
if (!ingredient.quantityTotal)
|
|
2908
|
+
throw new NoProductMatchError(ingredient.name, "noQuantity");
|
|
2909
|
+
const normalizedOptions = options.map(
|
|
2910
|
+
(option) => ({
|
|
2911
|
+
...option,
|
|
2912
|
+
sizes: option.sizes.map((s) => {
|
|
2913
|
+
const resolvedUnit = resolveUnit(s.unit);
|
|
2914
|
+
return {
|
|
2915
|
+
size: resolvedUnit && "toBase" in resolvedUnit ? multiplyQuantityValue(
|
|
2916
|
+
s.size,
|
|
2917
|
+
resolvedUnit.toBase
|
|
2918
|
+
) : s.size,
|
|
2919
|
+
unit: resolvedUnit
|
|
2920
|
+
};
|
|
2921
|
+
})
|
|
2922
|
+
})
|
|
2923
|
+
);
|
|
2924
|
+
const normalizedQuantityTotal = normalizeAllUnits(ingredient.quantityTotal);
|
|
2925
|
+
function getOptimumMatchForQuantityParts(normalizedQuantities, normalizedOptions2, selection = []) {
|
|
2926
|
+
if (isAndGroup(normalizedQuantities)) {
|
|
2927
|
+
for (const q of normalizedQuantities.and) {
|
|
2928
|
+
const result = getOptimumMatchForQuantityParts(
|
|
2929
|
+
q,
|
|
2930
|
+
normalizedOptions2,
|
|
2931
|
+
selection
|
|
2932
|
+
);
|
|
2933
|
+
selection.push(...result);
|
|
2934
|
+
}
|
|
2935
|
+
} else {
|
|
2936
|
+
const alternativeUnitsOfQuantity = isOrGroup(normalizedQuantities) ? normalizedQuantities.or : [normalizedQuantities];
|
|
2937
|
+
const solutions = [];
|
|
2938
|
+
const errors = /* @__PURE__ */ new Set();
|
|
2939
|
+
for (const alternative of alternativeUnitsOfQuantity) {
|
|
2940
|
+
if (alternative.quantity.type === "fixed" && alternative.quantity.value.type === "text") {
|
|
2941
|
+
errors.add("textValue");
|
|
2942
|
+
continue;
|
|
2943
|
+
}
|
|
2944
|
+
const scaledQuantity = multiplyQuantityValue(
|
|
2945
|
+
alternative.quantity,
|
|
2946
|
+
"toBase" in alternative.unit ? alternative.unit.toBase : 1
|
|
2947
|
+
);
|
|
2948
|
+
alternative.quantity = scaledQuantity;
|
|
2949
|
+
const matchOptions = normalizedOptions2.filter(
|
|
2950
|
+
(option) => option.sizes.some(
|
|
2951
|
+
(s) => areUnitsCompatible(alternative.unit, s.unit)
|
|
2952
|
+
)
|
|
2953
|
+
);
|
|
2954
|
+
if (matchOptions.length > 0) {
|
|
2955
|
+
const findCompatibleSize = (option) => option.sizes.find(
|
|
2956
|
+
(s) => areUnitsCompatible(alternative.unit, s.unit)
|
|
2957
|
+
);
|
|
2958
|
+
if (matchOptions.length == 1) {
|
|
2959
|
+
const matchedOption = matchOptions[0];
|
|
2960
|
+
const compatibleSize = findCompatibleSize(matchedOption);
|
|
2961
|
+
const product = options.find(
|
|
2962
|
+
(opt) => opt.id === matchedOption.id
|
|
2963
|
+
);
|
|
2964
|
+
const targetQuantity = scaledQuantity.type === "fixed" ? scaledQuantity.value : scaledQuantity.min;
|
|
2965
|
+
const resQuantity = Math.ceil(
|
|
2966
|
+
getNumericValue(targetQuantity) / getNumericValue(compatibleSize.size.value)
|
|
2967
|
+
);
|
|
2968
|
+
solutions.push([
|
|
2969
|
+
{
|
|
2970
|
+
product,
|
|
2971
|
+
quantity: resQuantity,
|
|
2972
|
+
totalPrice: resQuantity * matchedOption.price
|
|
2973
|
+
}
|
|
2974
|
+
]);
|
|
2975
|
+
continue;
|
|
2976
|
+
}
|
|
2977
|
+
const model = {
|
|
2978
|
+
direction: "minimize",
|
|
2979
|
+
objective: "price",
|
|
2980
|
+
integers: true,
|
|
2981
|
+
constraints: {
|
|
2982
|
+
size: {
|
|
2983
|
+
min: scaledQuantity.type === "fixed" ? getNumericValue(scaledQuantity.value) : getNumericValue(scaledQuantity.min)
|
|
2984
|
+
}
|
|
2985
|
+
},
|
|
2986
|
+
variables: matchOptions.reduce(
|
|
2987
|
+
(acc, option) => {
|
|
2988
|
+
const compatibleSize = findCompatibleSize(option);
|
|
2989
|
+
acc[option.id] = {
|
|
2990
|
+
price: option.price,
|
|
2991
|
+
size: getNumericValue(compatibleSize.size.value)
|
|
2992
|
+
};
|
|
2993
|
+
return acc;
|
|
2994
|
+
},
|
|
2995
|
+
{}
|
|
2996
|
+
)
|
|
2997
|
+
};
|
|
2998
|
+
const solution = (0, import_yalps.solve)(model);
|
|
2999
|
+
solutions.push(
|
|
3000
|
+
solution.variables.map((variable) => {
|
|
3001
|
+
const resProductSelection = {
|
|
3002
|
+
product: options.find((option) => option.id === variable[0]),
|
|
3003
|
+
quantity: variable[1]
|
|
3004
|
+
};
|
|
3005
|
+
return {
|
|
3006
|
+
...resProductSelection,
|
|
3007
|
+
totalPrice: resProductSelection.quantity * resProductSelection.product.price
|
|
3008
|
+
};
|
|
3009
|
+
})
|
|
3010
|
+
);
|
|
3011
|
+
} else {
|
|
3012
|
+
errors.add("incompatibleUnits");
|
|
3013
|
+
}
|
|
3014
|
+
}
|
|
3015
|
+
if (solutions.length === 0) {
|
|
3016
|
+
throw new NoProductMatchError(
|
|
3017
|
+
ingredient.name,
|
|
3018
|
+
errors.size === 1 ? errors.values().next().value : "textValue_incompatibleUnits"
|
|
3019
|
+
);
|
|
3020
|
+
} else {
|
|
3021
|
+
return solutions.sort(
|
|
3022
|
+
(a2, b) => a2.reduce((acc, item) => acc + item.totalPrice, 0) - b.reduce((acc, item) => acc + item.totalPrice, 0)
|
|
3023
|
+
)[0];
|
|
3024
|
+
}
|
|
3025
|
+
}
|
|
3026
|
+
return selection;
|
|
3027
|
+
}
|
|
3028
|
+
return getOptimumMatchForQuantityParts(
|
|
3029
|
+
normalizedQuantityTotal,
|
|
3030
|
+
normalizedOptions
|
|
3031
|
+
);
|
|
3032
|
+
}
|
|
3033
|
+
/**
|
|
3034
|
+
* Reset the cart's properties
|
|
3035
|
+
*/
|
|
3036
|
+
resetCart() {
|
|
3037
|
+
this.cart = [];
|
|
3038
|
+
this.match = [];
|
|
3039
|
+
this.misMatch = [];
|
|
3040
|
+
this.summary = { totalPrice: 0, totalItems: 0 };
|
|
3041
|
+
}
|
|
3042
|
+
/**
|
|
3043
|
+
* Calculate the cart's key info and store it in the cart's {@link ShoppingCart.summary | summary} property.
|
|
3044
|
+
* This function is automatically invoked by {@link ShoppingCart.buildCart | buildCart() } method.
|
|
3045
|
+
* @returns the total price and number of items in the cart
|
|
3046
|
+
*/
|
|
3047
|
+
summarize() {
|
|
3048
|
+
this.summary.totalPrice = this.cart.reduce(
|
|
3049
|
+
(acc, item) => acc + item.totalPrice,
|
|
3050
|
+
0
|
|
3051
|
+
);
|
|
3052
|
+
this.summary.totalItems = this.cart.length;
|
|
3053
|
+
return this.summary;
|
|
3054
|
+
}
|
|
3055
|
+
};
|
|
3056
|
+
|
|
3057
|
+
// src/utils/render_helpers.ts
|
|
3058
|
+
function formatNumericValue(value) {
|
|
3059
|
+
if (value.type === "decimal") {
|
|
3060
|
+
return String(value.decimal);
|
|
3061
|
+
}
|
|
3062
|
+
return `${value.num}/${value.den}`;
|
|
3063
|
+
}
|
|
3064
|
+
function formatSingleValue(value) {
|
|
3065
|
+
if (value.type === "text") {
|
|
3066
|
+
return value.text;
|
|
3067
|
+
}
|
|
3068
|
+
return formatNumericValue(value);
|
|
3069
|
+
}
|
|
3070
|
+
function formatQuantity(quantity) {
|
|
3071
|
+
if (quantity.type === "fixed") {
|
|
3072
|
+
return formatSingleValue(quantity.value);
|
|
3073
|
+
}
|
|
3074
|
+
const minStr = formatNumericValue(quantity.min);
|
|
3075
|
+
const maxStr = formatNumericValue(quantity.max);
|
|
3076
|
+
return `${minStr}-${maxStr}`;
|
|
3077
|
+
}
|
|
3078
|
+
function formatUnit(unit) {
|
|
3079
|
+
if (!unit) return "";
|
|
3080
|
+
if (typeof unit === "string") return unit;
|
|
3081
|
+
return unit.name;
|
|
3082
|
+
}
|
|
3083
|
+
function formatQuantityWithUnit(quantity, unit) {
|
|
3084
|
+
if (!quantity) return "";
|
|
3085
|
+
const qty = formatQuantity(quantity);
|
|
3086
|
+
const unitStr = formatUnit(unit);
|
|
3087
|
+
return unitStr ? `${qty} ${unitStr}` : qty;
|
|
3088
|
+
}
|
|
3089
|
+
function formatExtendedQuantity(item) {
|
|
3090
|
+
return formatQuantityWithUnit(item.quantity, item.unit);
|
|
3091
|
+
}
|
|
3092
|
+
function formatItemQuantity(itemQuantity, separator = " | ") {
|
|
3093
|
+
const parts = [];
|
|
3094
|
+
parts.push(formatExtendedQuantity(itemQuantity));
|
|
3095
|
+
if (itemQuantity.equivalents) {
|
|
3096
|
+
for (const eq of itemQuantity.equivalents) {
|
|
3097
|
+
parts.push(formatExtendedQuantity(eq));
|
|
3098
|
+
}
|
|
3099
|
+
}
|
|
3100
|
+
return parts.join(separator);
|
|
3101
|
+
}
|
|
3102
|
+
function isGroupedItem(item) {
|
|
3103
|
+
return item.group !== void 0;
|
|
3104
|
+
}
|
|
3105
|
+
function isAlternativeSelected(recipe, choices, item, alternativeIndex) {
|
|
3106
|
+
if (item.group) {
|
|
3107
|
+
const selectedIndex2 = choices?.ingredientGroups?.get(item.group);
|
|
3108
|
+
const groupAlternatives = recipe.choices.ingredientGroups.get(item.group);
|
|
3109
|
+
if (groupAlternatives && selectedIndex2 !== void 0 && selectedIndex2 < groupAlternatives.length) {
|
|
3110
|
+
const selectedItemId = groupAlternatives[selectedIndex2]?.itemId;
|
|
3111
|
+
return selectedItemId === item.id;
|
|
3112
|
+
}
|
|
3113
|
+
return false;
|
|
3114
|
+
}
|
|
3115
|
+
const selectedIndex = choices?.ingredientItems?.get(item.id);
|
|
3116
|
+
return alternativeIndex === selectedIndex;
|
|
3117
|
+
}
|
|
1410
3118
|
// Annotate the CommonJS export names for ESM import in node:
|
|
1411
3119
|
0 && (module.exports = {
|
|
1412
3120
|
CategoryConfig,
|
|
3121
|
+
NoProductCatalogForCartError,
|
|
3122
|
+
NoShoppingListForCartError,
|
|
3123
|
+
ProductCatalog,
|
|
1413
3124
|
Recipe,
|
|
1414
3125
|
Section,
|
|
1415
|
-
|
|
3126
|
+
ShoppingCart,
|
|
3127
|
+
ShoppingList,
|
|
3128
|
+
formatExtendedQuantity,
|
|
3129
|
+
formatItemQuantity,
|
|
3130
|
+
formatNumericValue,
|
|
3131
|
+
formatQuantity,
|
|
3132
|
+
formatQuantityWithUnit,
|
|
3133
|
+
formatSingleValue,
|
|
3134
|
+
formatUnit,
|
|
3135
|
+
hasAlternatives,
|
|
3136
|
+
isAlternativeSelected,
|
|
3137
|
+
isAndGroup,
|
|
3138
|
+
isGroupedItem,
|
|
3139
|
+
isSimpleGroup
|
|
1416
3140
|
});
|
|
1417
3141
|
/* v8 ignore else -- @preserve */
|
|
1418
|
-
|
|
3142
|
+
// v8 ignore else -- @preserve
|
|
1419
3143
|
/* v8 ignore else -- expliciting error type -- @preserve */
|
|
1420
|
-
|
|
3144
|
+
// v8 ignore if -- @preserve
|
|
3145
|
+
/* v8 ignore if -- @preserve */
|
|
1421
3146
|
//# sourceMappingURL=index.cjs.map
|