@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 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
- ShoppingList: () => ShoppingList
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/section.ts
104
- var Section = class {
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.1.5_patch_hash=6d6bd9e233f99785a7c2187fd464edc114b76d47001dbb4eb6b5d72168de7460/node_modules/human-regex/dist/human-regex.esm.js
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("(?:\\r\\n|\\r|\\n)");
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 multiwordIngredient = d().literal("@").startNamedGroup("mIngredientModifiers").anyOf("@\\-&?").zeroOrMore().endGroup().optional().startNamedGroup("mIngredientRecipeAnchor").literal("./").endGroup().optional().startNamedGroup("mIngredientName").notAnyOf(nonWordChar).oneOrMore().startGroup().whitespace().oneOrMore().notAnyOf(nonWordChar).oneOrMore().endGroup().oneOrMore().notAnyOf("\\." + nonWordChar).endGroup().positiveLookahead("\\s*(?:\\{[^\\}]*\\}|\\([^)]*\\))").startGroup().literal("{").startNamedGroup("mIngredientQuantityModifier").literal("=").exactly(1).endGroup().optional().startNamedGroup("mIngredientQuantity").notAnyOf("}%").oneOrMore().endGroup().optional().startGroup().literal("%").startNamedGroup("mIngredientUnit").notAnyOf("}").oneOrMore().lazy().endGroup().endGroup().optional().literal("}").endGroup().optional().startGroup().literal("(").startNamedGroup("mIngredientPreparation").notAnyOf(")").oneOrMore().lazy().endGroup().literal(")").endGroup().optional().toRegExp();
332
- var singleWordIngredient = d().literal("@").startNamedGroup("sIngredientModifiers").anyOf("@\\-&?").zeroOrMore().endGroup().optional().startNamedGroup("sIngredientRecipeAnchor").literal("./").endGroup().optional().startNamedGroup("sIngredientName").notAnyOf(nonWordChar).zeroOrMore().notAnyOf("\\." + nonWordChar).endGroup().startGroup().literal("{").startNamedGroup("sIngredientQuantityModifier").literal("=").exactly(1).endGroup().optional().startNamedGroup("sIngredientQuantity").notAnyOf("}%").oneOrMore().endGroup().optional().startGroup().literal("%").startNamedGroup("sIngredientUnit").notAnyOf("}").oneOrMore().lazy().endGroup().endGroup().optional().literal("}").endGroup().optional().startGroup().literal("(").startNamedGroup("sIngredientPreparation").notAnyOf(")").oneOrMore().lazy().endGroup().literal(")").endGroup().optional().toRegExp();
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 multiwordCookware = d().literal("#").startNamedGroup("mCookwareModifiers").anyOf("\\-&?").zeroOrMore().endGroup().startNamedGroup("mCookwareName").notAnyOf(nonWordChar).oneOrMore().startGroup().whitespace().oneOrMore().notAnyOf(nonWordChar).oneOrMore().endGroup().oneOrMore().notAnyOf("\\." + nonWordChar).endGroup().positiveLookahead("\\s*(?:\\{[^\\}]*\\})").literal("{").startNamedGroup("mCookwareQuantity").anyCharacter().zeroOrMore().lazy().endGroup().literal("}").toRegExp();
335
- var singleWordCookware = d().literal("#").startNamedGroup("sCookwareModifiers").anyOf("\\-&?").zeroOrMore().endGroup().startNamedGroup("sCookwareName").notAnyOf(nonWordChar).zeroOrMore().notAnyOf("\\." + nonWordChar).endGroup().startGroup().literal("{").startNamedGroup("sCookwareQuantity").anyCharacter().zeroOrMore().lazy().endGroup().literal("}").endGroup().optional().toRegExp();
336
- var timer = 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();
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
- multiwordIngredient,
340
- singleWordIngredient,
341
- multiwordCookware,
342
- singleWordCookware,
343
- timer
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 CannotAddTextValueError = class extends Error {
486
- constructor() {
487
- super("Cannot add a quantity with a text value.");
488
- this.name = "CannotAddTextValueError";
489
- }
490
- };
491
- var IncompatibleUnitsError = class extends Error {
492
- constructor(unit1, unit2) {
493
- super(
494
- `Cannot add quantities with incompatible or unknown units: ${unit1} and ${unit2}`
495
- );
496
- this.name = "IncompatibleUnitsError";
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", value: simplifiedNum };
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 { type: "decimal", value: (0, import_big.default)(v.value).times(factor).toNumber() };
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.value;
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.value;
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", value: 0 };
548
+ return { type: "decimal", decimal: 0 };
546
549
  }
547
- if (val1.type === "fraction" && val2.type === "fraction" || val1.type === "fraction" && val2.type === "decimal" && val2.value === 0 || val2.type === "fraction" && val1.type === "decimal" && val1.value === 0) {
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
- value: (0, import_big.default)(num1).div(den1).add((0, import_big.default)(num2).div(den2)).toNumber()
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.value : v.num / v.den;
560
- return { type: "decimal", value: Math.floor(value * 100) / 100 };
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", value: 0 } };
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.value;
619
- const v2 = q2.value;
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) => ({ value: addQuantityValues(val1, val2), unit });
626
- if ((q1.unit === "" || q1.unit === void 0) && q2.unit !== void 0) {
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
- return addQuantityValuesAndSetUnit(
654
- convertedV1,
655
- convertedV2,
656
- targetUnitDef.name
657
- );
805
+ const targetUnit = { name: targetUnitDef.name };
806
+ return addQuantityValuesAndSetUnit(convertedV1, convertedV2, targetUnit);
658
807
  }
659
- throw new IncompatibleUnitsError(q1.unit, q2.unit);
808
+ throw new IncompatibleUnitsError(
809
+ q1.unit?.name,
810
+ q2.unit?.name
811
+ );
660
812
  }
661
-
662
- // src/errors.ts
663
- var ReferencedItemCannotBeRedefinedError = class extends Error {
664
- constructor(item_type, item_name, new_modifier) {
665
- super(
666
- `The referenced ${item_type} "${item_name}" cannot be redefined as ${new_modifier}.
667
- 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}`
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
- this.name = "ReferencedItemCannotBeRedefinedError";
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, note) {
675
- if (note.length > 0) {
676
- section.content.push({ type: "note", 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 note;
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, quantity, unit } = newIngredient;
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
- for (const flag of newIngredient.flags) {
702
- if (!existingIngredient.flags.includes(flag)) {
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
- flag
953
+ existingIngredient.flags[0]
707
954
  );
708
955
  }
709
- }
710
- let quantityPartIndex = void 0;
711
- if (quantity !== void 0) {
712
- const currentQuantity = {
713
- value: existingIngredient.quantity ?? getDefaultQuantityValue(),
714
- unit: existingIngredient.unit ?? ""
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
- for (const flag of newCookware.flags) {
761
- if (!existingCookware.flags.includes(flag)) {
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
- flag
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", value: input_str };
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", value: Number(s) };
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)?.[1];
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/recipe.ts
910
- var import_big2 = __toESM(require("big.js"), 1);
911
- var Recipe = class _Recipe {
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
- * Creates a new Recipe instance.
914
- * @param content - The recipe content to parse.
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
- constructor(content) {
917
- /**
918
- * The parsed recipe metadata.
919
- */
920
- __publicField(this, "metadata", {});
921
- /**
922
- * The parsed recipe ingredients.
923
- */
924
- __publicField(this, "ingredients", []);
925
- /**
926
- * The parsed recipe sections.
927
- */
928
- __publicField(this, "sections", []);
929
- /**
930
- * The parsed recipe cookware.
931
- */
932
- __publicField(this, "cookware", []);
933
- /**
934
- * The parsed recipe timers.
935
- */
936
- __publicField(this, "timers", []);
937
- /**
938
- * The parsed recipe servings. Used for scaling. Parsed from one of
939
- * {@link Metadata.servings}, {@link Metadata.yield} or {@link Metadata.serves}
940
- * metadata fields.
941
- *
942
- * @see {@link Recipe.scaleBy | scaleBy()} and {@link Recipe.scaleTo | scaleTo()} methods
943
- */
944
- __publicField(this, "servings");
945
- if (content) {
946
- this.parse(content);
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
- * Parses a recipe from a string.
951
- * @param content - The recipe content to parse.
1205
+ * Stringifies the catalog to a TOML string.
1206
+ * @returns The TOML string representation of the catalog.
952
1207
  */
953
- parse(content) {
954
- const cleanContent = content.replace(metadataRegex, "").replace(commentRegex, "").replace(blockCommentRegex, "").trim().split(/\r\n?|\n/);
955
- const { metadata, servings } = extractMetadata(content);
956
- this.metadata = metadata;
957
- this.servings = servings;
958
- let blankLineBefore = true;
959
- let section = new Section();
960
- const items = [];
961
- let note = "";
962
- let inNote = false;
963
- for (const line of cleanContent) {
964
- if (line.trim().length === 0) {
965
- flushPendingItems(section, items);
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 (line.startsWith("=")) {
972
- flushPendingItems(section, items);
973
- note = flushPendingNote(section, note);
974
- if (this.sections.length === 0 && section.isBlank()) {
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
- note = flushPendingNote(section, note);
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
- note += " " + line.substring(1).trim();
2318
+ noteText += " " + line.substring(1).trim();
997
2319
  } else {
998
- note += " " + line.trim();
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
- let name = groups.mIngredientName || groups.sIngredientName;
1013
- const scalableQuantity = (groups.mIngredientQuantityModifier || groups.sIngredientQuantityModifier) !== "=";
1014
- const quantityRaw = groups.mIngredientQuantity || groups.sIngredientQuantity;
1015
- const unit = groups.mIngredientUnit || groups.sIngredientUnit;
1016
- const preparation = groups.mIngredientPreparation || groups.sIngredientPreparation;
1017
- const modifiers = groups.mIngredientModifiers || groups.sIngredientModifiers;
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 aliasMatch = name.match(ingredientAliasRegex);
1036
- let listName, displayName;
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 (extras) {
1059
- newIngredient.extras = extras;
2352
+ if (quantity) {
2353
+ newCookware.quantity = quantity;
1060
2354
  }
1061
- const idxsInList = findAndUpsertIngredient(
1062
- this.ingredients,
1063
- newIngredient,
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: "ingredient",
1068
- index: idxsInList.ingredientIndex,
1069
- displayName
2364
+ type: "cookware",
2365
+ index: idxInList
1070
2366
  };
1071
- if (idxsInList.quantityPartIndex !== void 0) {
1072
- newItem.quantityPartIndex = idxsInList.quantityPartIndex;
2367
+ if (quantity) {
2368
+ newItem.quantity = quantity;
1073
2369
  }
1074
2370
  items.push(newItem);
1075
- } else if (groups.mCookwareName || groups.sCookwareName) {
1076
- const name = groups.mCookwareName || groups.sCookwareName;
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
- note = flushPendingNote(section, note);
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, import_big2.default)(newServings).div(originalServings);
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
- newRecipe.ingredients = newRecipe.ingredients.map((ingredient) => {
1160
- if (ingredient.quantityParts) {
1161
- ingredient.quantityParts = ingredient.quantityParts.map(
1162
- (quantityPart) => {
1163
- if (quantityPart.value.type === "fixed" && quantityPart.value.value.type === "text") {
1164
- return quantityPart;
1165
- }
1166
- return {
1167
- ...quantityPart,
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
- if (ingredient.quantityParts.length === 1) {
1176
- ingredient.quantity = ingredient.quantityParts[0].value;
1177
- ingredient.unit = ingredient.quantityParts[0].unit;
1178
- } else {
1179
- const totalQuantity = ingredient.quantityParts.reduce(
1180
- (acc, val) => addQuantities(acc, { value: val.value, unit: val.unit }),
1181
- { value: getDefaultQuantityValue() }
1182
- );
1183
- ingredient.quantity = totalQuantity.value;
1184
- ingredient.unit = totalQuantity.unit;
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
- return ingredient;
1188
- }).filter((ingredient) => ingredient.quantity !== null);
1189
- newRecipe.servings = (0, import_big2.default)(originalServings).times(factor).toNumber();
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, import_big2.default)(servingsValue).times(factor).toNumber()
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, import_big2.default)(yieldValue).times(factor).toNumber()
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, import_big2.default)(servesValue).times(factor).toNumber()
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.metadata = JSON.parse(JSON.stringify(this.metadata));
1240
- newRecipe.ingredients = JSON.parse(
1241
- JSON.stringify(this.ingredients)
1242
- );
1243
- newRecipe.sections = JSON.parse(JSON.stringify(this.sections));
1244
- newRecipe.cookware = JSON.parse(
1245
- JSON.stringify(this.cookware)
1246
- );
1247
- newRecipe.timers = JSON.parse(JSON.stringify(this.timers));
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
- for (const ingredient of scaledRecipe.ingredients) {
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
- const existingIngredient = this.ingredients.find(
1295
- (i2) => i2.name === ingredient.name
1296
- );
1297
- let addSeparate = false;
1298
- try {
1299
- if (existingIngredient && ingredient.quantity) {
1300
- if (existingIngredient.quantity) {
1301
- const newQuantity = addQuantities(
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
- existingIngredient.quantity = ingredient.quantity;
1317
- if (ingredient.unit) {
1318
- existingIngredient.unit = ingredient.unit;
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
- } catch {
1323
- addSeparate = true;
1324
- }
1325
- if (!existingIngredient || addSeparate) {
1326
- const newIngredient = { name: ingredient.name };
1327
- if (ingredient.quantity) {
1328
- newIngredient.quantity = ingredient.quantity;
1329
- }
1330
- if (ingredient.unit) {
1331
- newIngredient.unit = ingredient.unit;
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
- this.ingredients.push(newIngredient);
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
- add_recipe(recipe, scaling) {
1339
- if (typeof scaling === "number" || scaling === void 0) {
1340
- this.recipes.push({ recipe, factor: scaling ?? 1 });
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({ recipe, factor: scaling.factor });
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({ recipe, servings: scaling.servings });
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
- ShoppingList
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
- /* v8 ignore else -- expliciting error types -- @preserve */
3142
+ // v8 ignore else -- @preserve
1419
3143
  /* v8 ignore else -- expliciting error type -- @preserve */
1420
- /* v8 ignore else -- only set unit if it is given -- @preserve */
3144
+ // v8 ignore if -- @preserve
3145
+ /* v8 ignore if -- @preserve */
1421
3146
  //# sourceMappingURL=index.cjs.map