@tmlmt/cooklang-parser 2.1.7 → 3.0.0-alpha.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -63,33 +63,10 @@ var CategoryConfig = class {
63
63
  }
64
64
  };
65
65
 
66
- // src/classes/section.ts
67
- var Section = class {
68
- /**
69
- * Creates an instance of Section.
70
- * @param name - The name of the section. Defaults to an empty string.
71
- */
72
- constructor(name = "") {
73
- /**
74
- * The name of the section. Can be an empty string for the default (first) section.
75
- * @defaultValue `""`
76
- */
77
- __publicField(this, "name");
78
- /** An array of steps and notes that make up the content of the section. */
79
- __publicField(this, "content", []);
80
- this.name = name;
81
- }
82
- /**
83
- * Checks if the section is blank (has no name and no content).
84
- * Used during recipe parsing
85
- * @returns `true` if the section is blank, otherwise `false`.
86
- */
87
- isBlank() {
88
- return this.name === "" && this.content.length === 0;
89
- }
90
- };
66
+ // src/classes/product_catalog.ts
67
+ import TOML from "smol-toml";
91
68
 
92
- // node_modules/.pnpm/human-regex@2.1.5_patch_hash=6d6bd9e233f99785a7c2187fd464edc114b76d47001dbb4eb6b5d72168de7460/node_modules/human-regex/dist/human-regex.esm.js
69
+ // node_modules/.pnpm/human-regex@2.2.0/node_modules/human-regex/dist/human-regex.esm.js
93
70
  var t = /* @__PURE__ */ new Map();
94
71
  var r = { GLOBAL: "g", NON_SENSITIVE: "i", MULTILINE: "m", DOT_ALL: "s", UNICODE: "u", STICKY: "y" };
95
72
  var e = Object.freeze({ digit: "0-9", lowercaseLetter: "a-z", uppercaseLetter: "A-Z", letter: "a-zA-Z", alphanumeric: "a-zA-Z0-9", anyCharacter: "." });
@@ -150,7 +127,7 @@ var a = class {
150
127
  return this.add(".");
151
128
  }
152
129
  newline() {
153
- return this.add("(?:\\r\\n|\\r|\\n)");
130
+ return this.add("(\\r\\n|\\r|\\n)");
154
131
  }
155
132
  negativeLookahead(t2) {
156
133
  return this.add(`(?!${t2})`);
@@ -291,19 +268,20 @@ var i = (() => {
291
268
  var metadataRegex = d().literal("---").newline().startCaptureGroup().anyCharacter().zeroOrMore().optional().endGroup().newline().literal("---").dotAll().toRegExp();
292
269
  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();
293
270
  var nonWordChar = "\\s@#~\\[\\]{(,;:!?";
294
- 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();
295
- 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();
271
+ var nonWordCharStrict = "\\s@#~\\[\\]{(,;:!?|";
272
+ 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();
273
+ var inlineIngredientAlternativesRegex = new RegExp("\\|" + ingredientWithAlternativeRegex.source.slice(1));
274
+ var quantityAlternativeRegex = d().startNamedGroup("ingredientQuantityValue").notAnyOf("}|%").oneOrMore().endGroup().optional().startGroup().literal("%").startNamedGroup("ingredientUnit").notAnyOf("|}").oneOrMore().endGroup().endGroup().optional().startGroup().literal("|").startNamedGroup("ingredientAltQuantity").startGroup().notAnyOf("}").oneOrMore().endGroup().zeroOrMore().endGroup().endGroup().optional().toRegExp();
275
+ 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();
296
276
  var ingredientAliasRegex = d().startAnchor().startNamedGroup("ingredientListName").notAnyOf("|").oneOrMore().endGroup().literal("|").startNamedGroup("ingredientDisplayName").notAnyOf("|").oneOrMore().endGroup().endAnchor().toRegExp();
297
- 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();
298
- 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();
299
- 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();
277
+ 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();
278
+ 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();
300
279
  var tokensRegex = new RegExp(
301
280
  [
302
- multiwordIngredient,
303
- singleWordIngredient,
304
- multiwordCookware,
305
- singleWordCookware,
306
- timer
281
+ ingredientWithGroupKeyRegex,
282
+ ingredientWithAlternativeRegex,
283
+ cookwareRegex,
284
+ timerRegex
307
285
  ].map((r2) => r2.source).join("|"),
308
286
  "gu"
309
287
  );
@@ -314,8 +292,7 @@ var rangeRegex = d().startAnchor().digit().oneOrMore().startGroup().anyOf(".,/")
314
292
  var numberLikeRegex = d().startAnchor().digit().oneOrMore().startGroup().anyOf(".,/").exactly(1).digit().oneOrMore().endGroup().optional().endAnchor().toRegExp();
315
293
  var floatRegex = d().startAnchor().digit().oneOrMore().startGroup().anyOf(".").exactly(1).digit().oneOrMore().endGroup().optional().endAnchor().toRegExp();
316
294
 
317
- // src/units.ts
318
- import Big from "big.js";
295
+ // src/units/definitions.ts
319
296
  var units = [
320
297
  // Mass (Metric)
321
298
  {
@@ -445,20 +422,19 @@ for (const unit of units) {
445
422
  function normalizeUnit(unit = "") {
446
423
  return unitMap.get(unit.toLowerCase().trim());
447
424
  }
448
- var CannotAddTextValueError = class extends Error {
449
- constructor() {
450
- super("Cannot add a quantity with a text value.");
451
- this.name = "CannotAddTextValueError";
452
- }
453
- };
454
- var IncompatibleUnitsError = class extends Error {
455
- constructor(unit1, unit2) {
456
- super(
457
- `Cannot add quantities with incompatible or unknown units: ${unit1} and ${unit2}`
458
- );
459
- this.name = "IncompatibleUnitsError";
460
- }
461
- };
425
+ var NO_UNIT = "__no-unit__";
426
+ function resolveUnit(name = NO_UNIT, integerProtected = false) {
427
+ const normalizedUnit = normalizeUnit(name);
428
+ const resolvedUnit = normalizedUnit ? { ...normalizedUnit, name } : { name, type: "other", system: "none" };
429
+ return integerProtected ? { ...resolvedUnit, integerProtected: true } : resolvedUnit;
430
+ }
431
+ function isNoUnit(unit) {
432
+ if (!unit) return true;
433
+ return resolveUnit(unit.name).name === NO_UNIT;
434
+ }
435
+
436
+ // src/quantities/numeric.ts
437
+ import Big from "big.js";
462
438
  function gcd(a2, b) {
463
439
  return b === 0 ? a2 : gcd(b, a2 % b);
464
440
  }
@@ -474,14 +450,23 @@ function simplifyFraction(num, den) {
474
450
  simplifiedDen = -simplifiedDen;
475
451
  }
476
452
  if (simplifiedDen === 1) {
477
- return { type: "decimal", value: simplifiedNum };
453
+ return { type: "decimal", decimal: simplifiedNum };
478
454
  } else {
479
455
  return { type: "fraction", num: simplifiedNum, den: simplifiedDen };
480
456
  }
481
457
  }
458
+ function getNumericValue(v) {
459
+ if (v.type === "decimal") {
460
+ return v.decimal;
461
+ }
462
+ return v.num / v.den;
463
+ }
482
464
  function multiplyNumericValue(v, factor) {
483
465
  if (v.type === "decimal") {
484
- return { type: "decimal", value: Big(v.value).times(factor).toNumber() };
466
+ return {
467
+ type: "decimal",
468
+ decimal: Big(v.decimal).times(factor).toNumber()
469
+ };
485
470
  }
486
471
  return simplifyFraction(Big(v.num).times(factor).toNumber(), v.den);
487
472
  }
@@ -491,36 +476,36 @@ function addNumericValues(val1, val2) {
491
476
  let num2;
492
477
  let den2;
493
478
  if (val1.type === "decimal") {
494
- num1 = val1.value;
479
+ num1 = val1.decimal;
495
480
  den1 = 1;
496
481
  } else {
497
482
  num1 = val1.num;
498
483
  den1 = val1.den;
499
484
  }
500
485
  if (val2.type === "decimal") {
501
- num2 = val2.value;
486
+ num2 = val2.decimal;
502
487
  den2 = 1;
503
488
  } else {
504
489
  num2 = val2.num;
505
490
  den2 = val2.den;
506
491
  }
507
492
  if (num1 === 0 && num2 === 0) {
508
- return { type: "decimal", value: 0 };
493
+ return { type: "decimal", decimal: 0 };
509
494
  }
510
- 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) {
495
+ 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) {
511
496
  const commonDen = den1 * den2;
512
497
  const sumNum = num1 * den2 + num2 * den1;
513
498
  return simplifyFraction(sumNum, commonDen);
514
499
  } else {
515
500
  return {
516
501
  type: "decimal",
517
- value: Big(num1).div(den1).add(Big(num2).div(den2)).toNumber()
502
+ decimal: Big(num1).div(den1).add(Big(num2).div(den2)).toNumber()
518
503
  };
519
504
  }
520
505
  }
521
506
  var toRoundedDecimal = (v) => {
522
- const value = v.type === "decimal" ? v.value : v.num / v.den;
523
- return { type: "decimal", value: Math.floor(value * 100) / 100 };
507
+ const value = v.type === "decimal" ? v.decimal : v.num / v.den;
508
+ return { type: "decimal", decimal: Math.round(value * 1e3) / 1e3 };
524
509
  };
525
510
  function multiplyQuantityValue(value, factor) {
526
511
  if (value.type === "fixed") {
@@ -546,13 +531,139 @@ function multiplyQuantityValue(value, factor) {
546
531
  max: multiplyNumericValue(value.max, factor)
547
532
  };
548
533
  }
534
+ function getAverageValue(q) {
535
+ if (q.type === "fixed") {
536
+ return q.value.type === "text" ? q.value.text : getNumericValue(q.value);
537
+ } else {
538
+ return (getNumericValue(q.min) + getNumericValue(q.max)) / 2;
539
+ }
540
+ }
541
+
542
+ // src/errors.ts
543
+ var ReferencedItemCannotBeRedefinedError = class extends Error {
544
+ constructor(item_type, item_name, new_modifier) {
545
+ super(
546
+ `The referenced ${item_type} "${item_name}" cannot be redefined as ${new_modifier}.
547
+ 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}`
548
+ );
549
+ this.name = "ReferencedItemCannotBeRedefinedError";
550
+ }
551
+ };
552
+ var NoProductCatalogForCartError = class extends Error {
553
+ constructor() {
554
+ super(
555
+ `Cannot build a cart without a product catalog. Please set one using setProductCatalog()`
556
+ );
557
+ this.name = "NoProductCatalogForCartError";
558
+ }
559
+ };
560
+ var NoShoppingListForCartError = class extends Error {
561
+ constructor() {
562
+ super(
563
+ `Cannot build a cart without a shopping list. Please set one using setShoppingList()`
564
+ );
565
+ this.name = "NoShoppingListForCartError";
566
+ }
567
+ };
568
+ var NoProductMatchError = class extends Error {
569
+ constructor(item_name, code) {
570
+ const messageMap = {
571
+ incompatibleUnits: `The units of the products in the catalogue are incompatible with ingredient ${item_name} in the shopping list.`,
572
+ noProduct: "No product was found linked to ingredient name ${item_name} in the shopping list",
573
+ textValue: `Ingredient ${item_name} has a text value as quantity and can therefore not be matched with any product in the catalogue.`,
574
+ noQuantity: `Ingredient ${item_name} has no quantity and can therefore not be matched with any product in the catalogue.`,
575
+ 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`
576
+ };
577
+ super(messageMap[code]);
578
+ __publicField(this, "code");
579
+ this.code = code;
580
+ this.name = "NoProductMatchError";
581
+ }
582
+ };
583
+ var InvalidProductCatalogFormat = class extends Error {
584
+ constructor() {
585
+ super("Invalid product catalog format.");
586
+ this.name = "InvalidProductCatalogFormat";
587
+ }
588
+ };
589
+ var CannotAddTextValueError = class extends Error {
590
+ constructor() {
591
+ super("Cannot add a quantity with a text value.");
592
+ this.name = "CannotAddTextValueError";
593
+ }
594
+ };
595
+ var IncompatibleUnitsError = class extends Error {
596
+ constructor(unit1, unit2) {
597
+ super(
598
+ `Cannot add quantities with incompatible or unknown units: ${unit1} and ${unit2}`
599
+ );
600
+ this.name = "IncompatibleUnitsError";
601
+ }
602
+ };
603
+ var InvalidQuantityFormat = class extends Error {
604
+ constructor(value) {
605
+ super(`Invalid quantity format found in: ${value}`);
606
+ this.name = "InvalidQuantityFormat";
607
+ }
608
+ };
609
+
610
+ // src/utils/type_guards.ts
611
+ function isGroup(x) {
612
+ return x && "type" in x;
613
+ }
614
+ function isOrGroup(x) {
615
+ return isGroup(x) && x.type === "or";
616
+ }
617
+ function isAndGroup(x) {
618
+ return isGroup(x) && x.type === "and";
619
+ }
620
+ function isQuantity(x) {
621
+ return x && typeof x === "object" && "quantity" in x;
622
+ }
623
+ function isNumericValueIntegerLike(v) {
624
+ if (v.type === "decimal") return Number.isInteger(v.decimal);
625
+ return v.num % v.den === 0;
626
+ }
627
+ function isValueIntegerLike(q) {
628
+ if (q.type === "fixed") {
629
+ if (q.value.type === "text") return false;
630
+ return isNumericValueIntegerLike(q.value);
631
+ }
632
+ return isNumericValueIntegerLike(q.min) && isNumericValueIntegerLike(q.max);
633
+ }
634
+
635
+ // src/quantities/mutations.ts
636
+ function extendAllUnits(q) {
637
+ if (isGroup(q)) {
638
+ return { ...q, entries: q.entries.map(extendAllUnits) };
639
+ } else {
640
+ const newQ = {
641
+ quantity: q.quantity
642
+ };
643
+ if (q.unit) {
644
+ newQ.unit = { name: q.unit };
645
+ }
646
+ return newQ;
647
+ }
648
+ }
649
+ function normalizeAllUnits(q) {
650
+ if (isGroup(q)) {
651
+ return { ...q, entries: q.entries.map(normalizeAllUnits) };
652
+ } else {
653
+ const newQ = {
654
+ quantity: q.quantity,
655
+ unit: resolveUnit(q.unit)
656
+ };
657
+ return newQ;
658
+ }
659
+ }
549
660
  var convertQuantityValue = (value, def, targetDef) => {
550
661
  if (def.name === targetDef.name) return value;
551
662
  const factor = def.toBase / targetDef.toBase;
552
663
  return multiplyQuantityValue(value, factor);
553
664
  };
554
665
  function getDefaultQuantityValue() {
555
- return { type: "fixed", value: { type: "decimal", value: 0 } };
666
+ return { type: "fixed", value: { type: "decimal", decimal: 0 } };
556
667
  }
557
668
  function addQuantityValues(v1, v2) {
558
669
  if (v1.type === "fixed" && v1.value.type === "text" || v2.type === "fixed" && v2.value.type === "text") {
@@ -578,28 +689,31 @@ function addQuantityValues(v1, v2) {
578
689
  return { type: "range", min: newMin, max: newMax };
579
690
  }
580
691
  function addQuantities(q1, q2) {
581
- const v1 = q1.value;
582
- const v2 = q2.value;
692
+ const v1 = q1.quantity;
693
+ const v2 = q2.quantity;
583
694
  if (v1.type === "fixed" && v1.value.type === "text" || v2.type === "fixed" && v2.value.type === "text") {
584
695
  throw new CannotAddTextValueError();
585
696
  }
586
- const unit1Def = normalizeUnit(q1.unit);
587
- const unit2Def = normalizeUnit(q2.unit);
588
- const addQuantityValuesAndSetUnit = (val1, val2, unit) => ({ value: addQuantityValues(val1, val2), unit });
589
- if ((q1.unit === "" || q1.unit === void 0) && q2.unit !== void 0) {
697
+ const unit1Def = normalizeUnit(q1.unit?.name);
698
+ const unit2Def = normalizeUnit(q2.unit?.name);
699
+ const addQuantityValuesAndSetUnit = (val1, val2, unit) => ({
700
+ quantity: addQuantityValues(val1, val2),
701
+ unit
702
+ });
703
+ if ((q1.unit?.name === "" || q1.unit === void 0) && q2.unit !== void 0) {
590
704
  return addQuantityValuesAndSetUnit(v1, v2, q2.unit);
591
705
  }
592
- if ((q2.unit === "" || q2.unit === void 0) && q1.unit !== void 0) {
706
+ if ((q2.unit?.name === "" || q2.unit === void 0) && q1.unit !== void 0) {
593
707
  return addQuantityValuesAndSetUnit(v1, v2, q1.unit);
594
708
  }
595
- if (!q1.unit && !q2.unit || q1.unit && q2.unit && q1.unit.toLowerCase() === q2.unit.toLowerCase()) {
709
+ if (!q1.unit && !q2.unit || q1.unit && q2.unit && q1.unit.name.toLowerCase() === q2.unit.name.toLowerCase()) {
596
710
  return addQuantityValuesAndSetUnit(v1, v2, q1.unit);
597
711
  }
598
712
  if (unit1Def && unit2Def) {
599
713
  if (unit1Def.type !== unit2Def.type) {
600
714
  throw new IncompatibleUnitsError(
601
- `${unit1Def.type} (${q1.unit})`,
602
- `${unit2Def.type} (${q2.unit})`
715
+ `${unit1Def.type} (${q1.unit?.name})`,
716
+ `${unit2Def.type} (${q2.unit?.name})`
603
717
  );
604
718
  }
605
719
  let targetUnitDef;
@@ -613,27 +727,131 @@ function addQuantities(q1, q2) {
613
727
  }
614
728
  const convertedV1 = convertQuantityValue(v1, unit1Def, targetUnitDef);
615
729
  const convertedV2 = convertQuantityValue(v2, unit2Def, targetUnitDef);
616
- return addQuantityValuesAndSetUnit(
617
- convertedV1,
618
- convertedV2,
619
- targetUnitDef.name
620
- );
730
+ const targetUnit = { name: targetUnitDef.name };
731
+ return addQuantityValuesAndSetUnit(convertedV1, convertedV2, targetUnit);
621
732
  }
622
- throw new IncompatibleUnitsError(q1.unit, q2.unit);
733
+ throw new IncompatibleUnitsError(
734
+ q1.unit?.name,
735
+ q2.unit?.name
736
+ );
623
737
  }
624
-
625
- // src/errors.ts
626
- var ReferencedItemCannotBeRedefinedError = class extends Error {
627
- constructor(item_type, item_name, new_modifier) {
628
- super(
629
- `The referenced ${item_type} "${item_name}" cannot be redefined as ${new_modifier}.
630
- 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}`
738
+ function toPlainUnit(quantity) {
739
+ if (isQuantity(quantity))
740
+ return quantity.unit ? { ...quantity, unit: quantity.unit.name } : quantity;
741
+ else {
742
+ return {
743
+ ...quantity,
744
+ entries: quantity.entries.map(toPlainUnit)
745
+ };
746
+ }
747
+ }
748
+ function toExtendedUnit(q) {
749
+ if (isQuantity(q)) {
750
+ return q.unit ? { ...q, unit: { name: q.unit } } : q;
751
+ } else {
752
+ return {
753
+ ...q,
754
+ entries: q.entries.map(
755
+ (entry) => isQuantity(entry) ? toExtendedUnit(entry) : toExtendedUnit(entry)
756
+ )
757
+ };
758
+ }
759
+ }
760
+ function deNormalizeQuantity(q) {
761
+ const result = {
762
+ quantity: q.quantity
763
+ };
764
+ if (!isNoUnit(q.unit)) {
765
+ result.unit = { name: q.unit.name };
766
+ }
767
+ return result;
768
+ }
769
+ var flattenPlainUnitGroup = (summed) => {
770
+ if (isOrGroup(summed)) {
771
+ const entries = summed.entries;
772
+ const andGroupEntry = entries.find(
773
+ (e2) => isGroup(e2) && e2.type === "and"
631
774
  );
632
- this.name = "ReferencedItemCannotBeRedefinedError";
775
+ if (andGroupEntry) {
776
+ const andEntries = [];
777
+ for (const entry of andGroupEntry.entries) {
778
+ if (isQuantity(entry)) {
779
+ andEntries.push({
780
+ quantity: entry.quantity,
781
+ unit: entry.unit
782
+ });
783
+ }
784
+ }
785
+ const equivalentsList = entries.filter((e2) => isQuantity(e2)).map((e2) => ({ quantity: e2.quantity, unit: e2.unit }));
786
+ if (equivalentsList.length > 0) {
787
+ return [
788
+ {
789
+ type: "and",
790
+ entries: andEntries,
791
+ equivalents: equivalentsList
792
+ }
793
+ ];
794
+ } else {
795
+ return andEntries.map((entry) => ({ groupQuantity: entry }));
796
+ }
797
+ }
798
+ const simpleEntries = entries.filter(
799
+ (e2) => isQuantity(e2)
800
+ );
801
+ if (simpleEntries.length > 0) {
802
+ const result = {
803
+ quantity: simpleEntries[0].quantity,
804
+ unit: simpleEntries[0].unit
805
+ };
806
+ if (simpleEntries.length > 1) {
807
+ result.equivalents = simpleEntries.slice(1);
808
+ }
809
+ return [{ groupQuantity: result }];
810
+ } else {
811
+ const first = entries[0];
812
+ return [
813
+ { groupQuantity: { quantity: first.quantity, unit: first.unit } }
814
+ ];
815
+ }
816
+ } else if (isGroup(summed)) {
817
+ const andEntries = [];
818
+ const equivalentsList = [];
819
+ for (const entry of summed.entries) {
820
+ if (isOrGroup(entry)) {
821
+ const orEntries = entry.entries.filter(
822
+ (e2) => isQuantity(e2)
823
+ );
824
+ if (orEntries.length > 0) {
825
+ andEntries.push({
826
+ quantity: orEntries[0].quantity,
827
+ unit: orEntries[0].unit
828
+ });
829
+ equivalentsList.push(...orEntries.slice(1));
830
+ }
831
+ } else if (isQuantity(entry)) {
832
+ andEntries.push({
833
+ quantity: entry.quantity,
834
+ unit: entry.unit
835
+ });
836
+ }
837
+ }
838
+ if (equivalentsList.length === 0) {
839
+ return andEntries.map((entry) => ({ groupQuantity: entry }));
840
+ }
841
+ const result = {
842
+ type: "and",
843
+ entries: andEntries,
844
+ equivalents: equivalentsList
845
+ };
846
+ return [result];
847
+ } else {
848
+ return [
849
+ { groupQuantity: { quantity: summed.quantity, unit: summed.unit } }
850
+ ];
633
851
  }
634
852
  };
635
853
 
636
- // src/parser_helpers.ts
854
+ // src/utils/parser_helpers.ts
637
855
  function flushPendingNote(section, note) {
638
856
  if (note.length > 0) {
639
857
  section.content.push({ type: "note", note });
@@ -650,7 +868,7 @@ function flushPendingItems(section, items) {
650
868
  return false;
651
869
  }
652
870
  function findAndUpsertIngredient(ingredients, newIngredient, isReference) {
653
- const { name, quantity, unit } = newIngredient;
871
+ const { name } = newIngredient;
654
872
  if (isReference) {
655
873
  const indexFind = ingredients.findIndex(
656
874
  (i2) => i2.name.toLowerCase() === name.toLowerCase()
@@ -661,52 +879,28 @@ function findAndUpsertIngredient(ingredients, newIngredient, isReference) {
661
879
  );
662
880
  }
663
881
  const existingIngredient = ingredients[indexFind];
664
- for (const flag of newIngredient.flags) {
665
- if (!existingIngredient.flags.includes(flag)) {
882
+ if (!newIngredient.flags) {
883
+ if (Array.isArray(existingIngredient.flags) && existingIngredient.flags.length > 0) {
666
884
  throw new ReferencedItemCannotBeRedefinedError(
667
885
  "ingredient",
668
886
  existingIngredient.name,
669
- flag
887
+ existingIngredient.flags[0]
670
888
  );
671
889
  }
672
- }
673
- let quantityPartIndex = void 0;
674
- if (quantity !== void 0) {
675
- const currentQuantity = {
676
- value: existingIngredient.quantity ?? getDefaultQuantityValue(),
677
- unit: existingIngredient.unit ?? ""
678
- };
679
- const newQuantity = { value: quantity, unit: unit ?? "" };
680
- try {
681
- const total = addQuantities(currentQuantity, newQuantity);
682
- existingIngredient.quantity = total.value;
683
- existingIngredient.unit = total.unit || void 0;
684
- if (existingIngredient.quantityParts) {
685
- existingIngredient.quantityParts.push(
686
- ...newIngredient.quantityParts
890
+ } else {
891
+ for (const flag of newIngredient.flags) {
892
+ if (existingIngredient.flags === void 0 || !existingIngredient.flags.includes(flag)) {
893
+ throw new ReferencedItemCannotBeRedefinedError(
894
+ "ingredient",
895
+ existingIngredient.name,
896
+ flag
687
897
  );
688
- } else {
689
- existingIngredient.quantityParts = newIngredient.quantityParts;
690
- }
691
- quantityPartIndex = existingIngredient.quantityParts.length - 1;
692
- } catch (e2) {
693
- if (e2 instanceof IncompatibleUnitsError || e2 instanceof CannotAddTextValueError) {
694
- return {
695
- ingredientIndex: ingredients.push(newIngredient) - 1,
696
- quantityPartIndex: 0
697
- };
698
898
  }
699
899
  }
700
900
  }
701
- return {
702
- ingredientIndex: indexFind,
703
- quantityPartIndex
704
- };
901
+ return indexFind;
705
902
  }
706
- return {
707
- ingredientIndex: ingredients.push(newIngredient) - 1,
708
- quantityPartIndex: newIngredient.quantity ? 0 : void 0
709
- };
903
+ return ingredients.push(newIngredient) - 1;
710
904
  }
711
905
  function findAndUpsertCookware(cookware, newCookware, isReference) {
712
906
  const { name, quantity } = newCookware;
@@ -720,58 +914,48 @@ function findAndUpsertCookware(cookware, newCookware, isReference) {
720
914
  );
721
915
  }
722
916
  const existingCookware = cookware[index];
723
- for (const flag of newCookware.flags) {
724
- if (!existingCookware.flags.includes(flag)) {
917
+ if (!newCookware.flags) {
918
+ if (Array.isArray(existingCookware.flags) && existingCookware.flags.length > 0) {
725
919
  throw new ReferencedItemCannotBeRedefinedError(
726
920
  "cookware",
727
921
  existingCookware.name,
728
- flag
922
+ existingCookware.flags[0]
729
923
  );
730
924
  }
925
+ } else {
926
+ for (const flag of newCookware.flags) {
927
+ if (existingCookware.flags === void 0 || !existingCookware.flags.includes(flag)) {
928
+ throw new ReferencedItemCannotBeRedefinedError(
929
+ "cookware",
930
+ existingCookware.name,
931
+ flag
932
+ );
933
+ }
934
+ }
731
935
  }
732
- let quantityPartIndex = void 0;
733
936
  if (quantity !== void 0) {
734
937
  if (!existingCookware.quantity) {
735
938
  existingCookware.quantity = quantity;
736
- existingCookware.quantityParts = newCookware.quantityParts;
737
- quantityPartIndex = 0;
738
939
  } else {
739
940
  try {
740
941
  existingCookware.quantity = addQuantityValues(
741
942
  existingCookware.quantity,
742
943
  quantity
743
944
  );
744
- if (!existingCookware.quantityParts) {
745
- existingCookware.quantityParts = newCookware.quantityParts;
746
- quantityPartIndex = 0;
747
- } else {
748
- quantityPartIndex = existingCookware.quantityParts.push(
749
- ...newCookware.quantityParts
750
- ) - 1;
751
- }
752
945
  } catch (e2) {
753
946
  if (e2 instanceof CannotAddTextValueError) {
754
- return {
755
- cookwareIndex: cookware.push(newCookware) - 1,
756
- quantityPartIndex: 0
757
- };
947
+ return cookware.push(newCookware) - 1;
758
948
  }
759
949
  }
760
950
  }
761
951
  }
762
- return {
763
- cookwareIndex: index,
764
- quantityPartIndex
765
- };
952
+ return index;
766
953
  }
767
- return {
768
- cookwareIndex: cookware.push(newCookware) - 1,
769
- quantityPartIndex: quantity ? 0 : void 0
770
- };
954
+ return cookware.push(newCookware) - 1;
771
955
  }
772
956
  var parseFixedValue = (input_str) => {
773
957
  if (!numberLikeRegex.test(input_str)) {
774
- return { type: "text", value: input_str };
958
+ return { type: "text", text: input_str };
775
959
  }
776
960
  const s = input_str.trim().replace(",", ".");
777
961
  if (s.includes("/")) {
@@ -780,8 +964,22 @@ var parseFixedValue = (input_str) => {
780
964
  const den = Number(parts[1]);
781
965
  return { type: "fraction", num, den };
782
966
  }
783
- return { type: "decimal", value: Number(s) };
967
+ return { type: "decimal", decimal: Number(s) };
784
968
  };
969
+ function stringifyQuantityValue(quantity) {
970
+ if (quantity.type === "fixed") {
971
+ return stringifyFixedValue(quantity);
972
+ } else {
973
+ return `${stringifyFixedValue({ type: "fixed", value: quantity.min })}-${stringifyFixedValue({ type: "fixed", value: quantity.max })}`;
974
+ }
975
+ }
976
+ function stringifyFixedValue(quantity) {
977
+ if (quantity.value.type === "fraction")
978
+ return `${quantity.value.num}/${quantity.value.den}`;
979
+ else if (quantity.value.type === "decimal")
980
+ return String(quantity.value.decimal);
981
+ else return quantity.value.text;
982
+ }
785
983
  function parseQuantityInput(input_str) {
786
984
  const clean_str = String(input_str).trim();
787
985
  if (rangeRegex.test(clean_str)) {
@@ -823,7 +1021,7 @@ function parseListMetaVar(content, varName) {
823
1021
  function extractMetadata(content) {
824
1022
  const metadata = {};
825
1023
  let servings = void 0;
826
- const metadataContent = content.match(metadataRegex)?.[1];
1024
+ const metadataContent = content.match(metadataRegex)?.[2];
827
1025
  if (!metadataContent) {
828
1026
  return { metadata };
829
1027
  }
@@ -868,74 +1066,1168 @@ function extractMetadata(content) {
868
1066
  }
869
1067
  return { metadata, servings };
870
1068
  }
1069
+ function isPositiveIntegerString(str) {
1070
+ return /^\d+$/.test(str);
1071
+ }
1072
+ function unionOfSets(s1, s2) {
1073
+ const result = new Set(s1);
1074
+ for (const item of s2) {
1075
+ result.add(item);
1076
+ }
1077
+ return result;
1078
+ }
1079
+ function getAlternativeSignature(alternatives) {
1080
+ if (!alternatives || alternatives.length === 0) return null;
1081
+ return alternatives.map((a2) => a2.index).sort((a2, b) => a2 - b).join(",");
1082
+ }
871
1083
 
872
- // src/classes/recipe.ts
873
- import Big2 from "big.js";
874
- var Recipe = class _Recipe {
1084
+ // src/classes/product_catalog.ts
1085
+ var ProductCatalog = class {
1086
+ constructor(tomlContent) {
1087
+ __publicField(this, "products", []);
1088
+ if (tomlContent) this.parse(tomlContent);
1089
+ }
875
1090
  /**
876
- * Creates a new Recipe instance.
877
- * @param content - The recipe content to parse.
1091
+ * Parses a TOML string into a list of product options.
1092
+ * @param tomlContent - The TOML string to parse.
1093
+ * @returns A parsed list of `ProductOption`.
878
1094
  */
879
- constructor(content) {
880
- /**
881
- * The parsed recipe metadata.
882
- */
883
- __publicField(this, "metadata", {});
884
- /**
885
- * The parsed recipe ingredients.
886
- */
887
- __publicField(this, "ingredients", []);
888
- /**
889
- * The parsed recipe sections.
890
- */
891
- __publicField(this, "sections", []);
892
- /**
893
- * The parsed recipe cookware.
894
- */
895
- __publicField(this, "cookware", []);
896
- /**
897
- * The parsed recipe timers.
898
- */
899
- __publicField(this, "timers", []);
900
- /**
901
- * The parsed recipe servings. Used for scaling. Parsed from one of
902
- * {@link Metadata.servings}, {@link Metadata.yield} or {@link Metadata.serves}
903
- * metadata fields.
904
- *
905
- * @see {@link Recipe.scaleBy | scaleBy()} and {@link Recipe.scaleTo | scaleTo()} methods
906
- */
907
- __publicField(this, "servings");
908
- if (content) {
909
- this.parse(content);
1095
+ parse(tomlContent) {
1096
+ const catalogRaw = TOML.parse(tomlContent);
1097
+ this.products = [];
1098
+ if (!this.isValidTomlContent(catalogRaw)) {
1099
+ throw new InvalidProductCatalogFormat();
910
1100
  }
1101
+ for (const [ingredientName, ingredientData] of Object.entries(catalogRaw)) {
1102
+ const ingredientTable = ingredientData;
1103
+ const aliases = ingredientTable.aliases;
1104
+ for (const [key, productData] of Object.entries(ingredientTable)) {
1105
+ if (key === "aliases") {
1106
+ continue;
1107
+ }
1108
+ const productId = key;
1109
+ const { name, size, price, ...rest } = productData;
1110
+ const sizeStrings = Array.isArray(size) ? size : [size];
1111
+ const sizes = sizeStrings.map((sizeStr) => {
1112
+ const sizeAndUnitRaw = sizeStr.split("%");
1113
+ const sizeParsed = parseQuantityInput(
1114
+ sizeAndUnitRaw[0]
1115
+ );
1116
+ const productSize = { size: sizeParsed };
1117
+ if (sizeAndUnitRaw.length > 1) {
1118
+ productSize.unit = sizeAndUnitRaw[1];
1119
+ }
1120
+ return productSize;
1121
+ });
1122
+ const productOption = {
1123
+ id: productId,
1124
+ productName: name,
1125
+ ingredientName,
1126
+ price,
1127
+ sizes,
1128
+ ...rest
1129
+ };
1130
+ if (aliases) {
1131
+ productOption.ingredientAliases = aliases;
1132
+ }
1133
+ this.products.push(productOption);
1134
+ }
1135
+ }
1136
+ return this.products;
911
1137
  }
912
1138
  /**
913
- * Parses a recipe from a string.
914
- * @param content - The recipe content to parse.
1139
+ * Stringifies the catalog to a TOML string.
1140
+ * @returns The TOML string representation of the catalog.
915
1141
  */
916
- parse(content) {
917
- const cleanContent = content.replace(metadataRegex, "").replace(commentRegex, "").replace(blockCommentRegex, "").trim().split(/\r\n?|\n/);
918
- const { metadata, servings } = extractMetadata(content);
919
- this.metadata = metadata;
920
- this.servings = servings;
921
- let blankLineBefore = true;
922
- let section = new Section();
923
- const items = [];
924
- let note = "";
925
- let inNote = false;
926
- for (const line of cleanContent) {
927
- if (line.trim().length === 0) {
928
- flushPendingItems(section, items);
929
- note = flushPendingNote(section, note);
930
- blankLineBefore = true;
931
- inNote = false;
932
- continue;
1142
+ stringify() {
1143
+ const grouped = {};
1144
+ for (const product of this.products) {
1145
+ const {
1146
+ id,
1147
+ ingredientName,
1148
+ ingredientAliases,
1149
+ sizes,
1150
+ productName,
1151
+ ...rest
1152
+ } = product;
1153
+ if (!grouped[ingredientName]) {
1154
+ grouped[ingredientName] = {};
933
1155
  }
934
- if (line.startsWith("=")) {
935
- flushPendingItems(section, items);
936
- note = flushPendingNote(section, note);
937
- if (this.sections.length === 0 && section.isBlank()) {
938
- section.name = line.replace(/^=+|=+$/g, "").trim();
1156
+ if (ingredientAliases && !grouped[ingredientName].aliases) {
1157
+ grouped[ingredientName].aliases = ingredientAliases;
1158
+ }
1159
+ const sizeStrings = sizes.map(
1160
+ (s) => s.unit ? `${stringifyQuantityValue(s.size)}%${s.unit}` : stringifyQuantityValue(s.size)
1161
+ );
1162
+ grouped[ingredientName][id] = {
1163
+ ...rest,
1164
+ name: productName,
1165
+ // Use array if multiple sizes, otherwise single string
1166
+ size: sizeStrings.length === 1 ? sizeStrings[0] : sizeStrings
1167
+ };
1168
+ }
1169
+ return TOML.stringify(grouped);
1170
+ }
1171
+ /**
1172
+ * Adds a product to the catalog.
1173
+ * @param productOption - The product to add.
1174
+ */
1175
+ add(productOption) {
1176
+ this.products.push(productOption);
1177
+ }
1178
+ /**
1179
+ * Removes a product from the catalog by its ID.
1180
+ * @param productId - The ID of the product to remove.
1181
+ */
1182
+ remove(productId) {
1183
+ this.products = this.products.filter((product) => product.id !== productId);
1184
+ }
1185
+ isValidTomlContent(catalog) {
1186
+ for (const productsRaw of Object.values(catalog)) {
1187
+ if (typeof productsRaw !== "object" || productsRaw === null) {
1188
+ return false;
1189
+ }
1190
+ for (const [id, obj] of Object.entries(productsRaw)) {
1191
+ if (id === "aliases") {
1192
+ if (!Array.isArray(obj)) {
1193
+ return false;
1194
+ }
1195
+ } else {
1196
+ if (!isPositiveIntegerString(id)) {
1197
+ return false;
1198
+ }
1199
+ if (typeof obj !== "object" || obj === null) {
1200
+ return false;
1201
+ }
1202
+ const record = obj;
1203
+ const keys = Object.keys(record);
1204
+ const mandatoryKeys = ["name", "size", "price"];
1205
+ if (mandatoryKeys.some((key) => !keys.includes(key))) {
1206
+ return false;
1207
+ }
1208
+ const hasProductName = typeof record.name === "string";
1209
+ const hasSize = typeof record.size === "string" || Array.isArray(record.size) && record.size.every((s) => typeof s === "string");
1210
+ const hasPrice = typeof record.price === "number";
1211
+ if (!(hasProductName && hasSize && hasPrice)) {
1212
+ return false;
1213
+ }
1214
+ }
1215
+ }
1216
+ }
1217
+ return true;
1218
+ }
1219
+ };
1220
+
1221
+ // src/classes/section.ts
1222
+ var Section = class {
1223
+ /**
1224
+ * Creates an instance of Section.
1225
+ * @param name - The name of the section. Defaults to an empty string.
1226
+ */
1227
+ constructor(name = "") {
1228
+ /**
1229
+ * The name of the section. Can be an empty string for the default (first) section.
1230
+ * @defaultValue `""`
1231
+ */
1232
+ __publicField(this, "name");
1233
+ /** An array of steps and notes that make up the content of the section. */
1234
+ __publicField(this, "content", []);
1235
+ this.name = name;
1236
+ }
1237
+ /**
1238
+ * Checks if the section is blank (has no name and no content).
1239
+ * Used during recipe parsing
1240
+ * @returns `true` if the section is blank, otherwise `false`.
1241
+ */
1242
+ isBlank() {
1243
+ return this.name === "" && this.content.length === 0;
1244
+ }
1245
+ };
1246
+
1247
+ // src/quantities/alternatives.ts
1248
+ import Big3 from "big.js";
1249
+
1250
+ // src/units/conversion.ts
1251
+ import Big2 from "big.js";
1252
+ function getUnitRatio(q1, q2) {
1253
+ const q1Value = getAverageValue(q1.quantity);
1254
+ const q2Value = getAverageValue(q2.quantity);
1255
+ const factor = "toBase" in q1.unit && "toBase" in q2.unit ? q1.unit.toBase / q2.unit.toBase : 1;
1256
+ if (typeof q1Value !== "number" || typeof q2Value !== "number") {
1257
+ throw Error(
1258
+ "One of both values is not a number, so a ratio cannot be computed"
1259
+ );
1260
+ }
1261
+ return Big2(q1Value).times(factor).div(q2Value);
1262
+ }
1263
+ function getBaseUnitRatio(q, qRef) {
1264
+ if ("toBase" in q.unit && "toBase" in qRef.unit) {
1265
+ return q.unit.toBase / qRef.unit.toBase;
1266
+ } else {
1267
+ return 1;
1268
+ }
1269
+ }
1270
+
1271
+ // src/units/lookup.ts
1272
+ function areUnitsCompatible(u1, u2) {
1273
+ if (u1.name === u2.name) {
1274
+ return true;
1275
+ }
1276
+ if (u1.type !== "other" && u1.type === u2.type && u1.system === u2.system) {
1277
+ return true;
1278
+ }
1279
+ return false;
1280
+ }
1281
+ function findListWithCompatibleQuantity(list, quantity) {
1282
+ const quantityWithUnitDef = {
1283
+ ...quantity,
1284
+ unit: resolveUnit(quantity.unit?.name)
1285
+ };
1286
+ return list.find(
1287
+ (l) => l.some((lq) => areUnitsCompatible(lq.unit, quantityWithUnitDef.unit))
1288
+ );
1289
+ }
1290
+ function findCompatibleQuantityWithinList(list, quantity) {
1291
+ const quantityWithUnitDef = {
1292
+ ...quantity,
1293
+ unit: resolveUnit(quantity.unit?.name)
1294
+ };
1295
+ return list.find(
1296
+ (q) => q.unit.name === quantityWithUnitDef.unit.name || q.unit.type === quantityWithUnitDef.unit.type && q.unit.type !== "other"
1297
+ );
1298
+ }
1299
+
1300
+ // src/utils/general.ts
1301
+ var legacyDeepClone = (v) => {
1302
+ if (v === null || typeof v !== "object") {
1303
+ return v;
1304
+ }
1305
+ if (v instanceof Map) {
1306
+ return new Map(
1307
+ Array.from(v.entries()).map(([k, val]) => [
1308
+ legacyDeepClone(k),
1309
+ legacyDeepClone(val)
1310
+ ])
1311
+ );
1312
+ }
1313
+ if (v instanceof Set) {
1314
+ return new Set(Array.from(v).map((val) => legacyDeepClone(val)));
1315
+ }
1316
+ if (v instanceof Date) {
1317
+ return new Date(v.getTime());
1318
+ }
1319
+ if (Array.isArray(v)) {
1320
+ return v.map((item) => legacyDeepClone(item));
1321
+ }
1322
+ const cloned = {};
1323
+ for (const key of Object.keys(v)) {
1324
+ cloned[key] = legacyDeepClone(v[key]);
1325
+ }
1326
+ return cloned;
1327
+ };
1328
+ var deepClone = (v) => typeof structuredClone === "function" ? structuredClone(v) : legacyDeepClone(v);
1329
+
1330
+ // src/quantities/alternatives.ts
1331
+ function getEquivalentUnitsLists(...quantities) {
1332
+ const quantitiesCopy = deepClone(quantities);
1333
+ const OrGroups = quantitiesCopy.filter(isOrGroup).filter((q) => q.entries.length > 1);
1334
+ const unitLists = [];
1335
+ const normalizeOrGroup = (og) => ({
1336
+ ...og,
1337
+ entries: og.entries.map((q) => ({
1338
+ ...q,
1339
+ unit: resolveUnit(q.unit?.name, q.unit?.integerProtected)
1340
+ }))
1341
+ });
1342
+ function findLinkIndexForUnits(lists, unitsToCheck) {
1343
+ return lists.findIndex((l) => {
1344
+ const listItem = l.map((q) => resolveUnit(q.unit?.name));
1345
+ return unitsToCheck.some(
1346
+ (u) => listItem.some(
1347
+ (lu) => lu.name === u?.name || lu.system === u?.system && lu.type === u?.type && lu.type !== "other"
1348
+ )
1349
+ );
1350
+ });
1351
+ }
1352
+ function mergeOrGroupIntoList(lists, idx, og) {
1353
+ let unitRatio;
1354
+ const commonUnitList = lists[idx].reduce((acc, v) => {
1355
+ const normalizedV = {
1356
+ ...v,
1357
+ unit: resolveUnit(v.unit?.name, v.unit?.integerProtected)
1358
+ };
1359
+ const commonQuantity = og.entries.find(
1360
+ (q) => isQuantity(q) && areUnitsCompatible(q.unit, normalizedV.unit)
1361
+ );
1362
+ if (commonQuantity) {
1363
+ acc.push(normalizedV);
1364
+ unitRatio = getUnitRatio(normalizedV, commonQuantity);
1365
+ }
1366
+ return acc;
1367
+ }, []);
1368
+ for (const newQ of og.entries) {
1369
+ if (commonUnitList.some((q) => areUnitsCompatible(q.unit, newQ.unit))) {
1370
+ continue;
1371
+ } else {
1372
+ const scaledQuantity = multiplyQuantityValue(newQ.quantity, unitRatio);
1373
+ lists[idx].push({ ...newQ, quantity: scaledQuantity });
1374
+ }
1375
+ }
1376
+ }
1377
+ for (const orGroup of OrGroups) {
1378
+ const orGroupModified = normalizeOrGroup(orGroup);
1379
+ const units2 = orGroupModified.entries.map((q) => q.unit);
1380
+ const linkIndex = findLinkIndexForUnits(unitLists, units2);
1381
+ if (linkIndex === -1) {
1382
+ unitLists.push(orGroupModified.entries);
1383
+ } else {
1384
+ mergeOrGroupIntoList(unitLists, linkIndex, orGroupModified);
1385
+ }
1386
+ }
1387
+ return unitLists;
1388
+ }
1389
+ function sortUnitList(list) {
1390
+ if (!list || list.length <= 1) return list;
1391
+ const priorityList = [];
1392
+ const nonPriorityList = [];
1393
+ for (const q of list) {
1394
+ if (q.unit.integerProtected || q.unit.system === "none") {
1395
+ priorityList.push(q);
1396
+ } else {
1397
+ nonPriorityList.push(q);
1398
+ }
1399
+ }
1400
+ return priorityList.sort((a2, b) => {
1401
+ const prefixA = a2.unit.integerProtected ? "___" : "";
1402
+ const prefixB = b.unit.integerProtected ? "___" : "";
1403
+ return (prefixA + a2.unit.name).localeCompare(prefixB + b.unit.name, "en");
1404
+ }).concat(nonPriorityList);
1405
+ }
1406
+ function reduceOrsToFirstEquivalent(unitList, quantities) {
1407
+ function reduceToQuantity(firstQuantity) {
1408
+ const equivalentList = sortUnitList(
1409
+ findListWithCompatibleQuantity(unitList, firstQuantity)
1410
+ );
1411
+ if (!equivalentList) return firstQuantity;
1412
+ const firstQuantityInList = findCompatibleQuantityWithinList(
1413
+ equivalentList,
1414
+ firstQuantity
1415
+ );
1416
+ const normalizedFirstQuantity = {
1417
+ ...firstQuantity,
1418
+ unit: resolveUnit(firstQuantity.unit?.name)
1419
+ };
1420
+ if (firstQuantityInList.unit.integerProtected) {
1421
+ const resultQuantity = {
1422
+ quantity: firstQuantity.quantity
1423
+ };
1424
+ if (!isNoUnit(normalizedFirstQuantity.unit)) {
1425
+ resultQuantity.unit = { name: normalizedFirstQuantity.unit.name };
1426
+ }
1427
+ return resultQuantity;
1428
+ } else {
1429
+ let nextProtected;
1430
+ const equivalentListTemp = [...equivalentList];
1431
+ while (nextProtected !== -1) {
1432
+ nextProtected = equivalentListTemp.findIndex(
1433
+ (eq) => eq.unit?.integerProtected
1434
+ );
1435
+ if (nextProtected !== -1) {
1436
+ const unitRatio2 = getUnitRatio(
1437
+ equivalentListTemp[nextProtected],
1438
+ firstQuantityInList
1439
+ );
1440
+ const nextProtectedQuantityValue = multiplyQuantityValue(
1441
+ firstQuantity.quantity,
1442
+ unitRatio2
1443
+ );
1444
+ if (isValueIntegerLike(nextProtectedQuantityValue)) {
1445
+ const nextProtectedQuantity = {
1446
+ quantity: nextProtectedQuantityValue
1447
+ };
1448
+ if (!isNoUnit(equivalentListTemp[nextProtected].unit)) {
1449
+ nextProtectedQuantity.unit = {
1450
+ name: equivalentListTemp[nextProtected].unit.name
1451
+ };
1452
+ }
1453
+ return nextProtectedQuantity;
1454
+ } else {
1455
+ equivalentListTemp.splice(nextProtected, 1);
1456
+ }
1457
+ }
1458
+ }
1459
+ const firstNonIntegerProtected = equivalentListTemp.filter(
1460
+ (q) => !q.unit.integerProtected
1461
+ )[0];
1462
+ const unitRatio = getUnitRatio(
1463
+ firstNonIntegerProtected,
1464
+ firstQuantityInList
1465
+ ).times(getBaseUnitRatio(normalizedFirstQuantity, firstQuantityInList));
1466
+ const firstEqQuantity = {
1467
+ quantity: firstNonIntegerProtected.unit.name === firstQuantity.unit.name ? firstQuantity.quantity : multiplyQuantityValue(firstQuantity.quantity, unitRatio)
1468
+ };
1469
+ if (!isNoUnit(firstNonIntegerProtected.unit)) {
1470
+ firstEqQuantity.unit = { name: firstNonIntegerProtected.unit.name };
1471
+ }
1472
+ return firstEqQuantity;
1473
+ }
1474
+ }
1475
+ return quantities.map((q) => {
1476
+ if (isQuantity(q)) return reduceToQuantity(q);
1477
+ const qListModified = sortUnitList(
1478
+ q.entries.map((qq) => ({
1479
+ ...qq,
1480
+ unit: resolveUnit(qq.unit?.name, qq.unit?.integerProtected)
1481
+ }))
1482
+ );
1483
+ return reduceToQuantity(qListModified[0]);
1484
+ });
1485
+ }
1486
+ function addQuantitiesOrGroups(...quantities) {
1487
+ if (quantities.length === 0)
1488
+ return {
1489
+ sum: {
1490
+ quantity: getDefaultQuantityValue(),
1491
+ unit: resolveUnit()
1492
+ },
1493
+ unitsLists: []
1494
+ };
1495
+ if (quantities.length === 1) {
1496
+ if (isQuantity(quantities[0]))
1497
+ return {
1498
+ sum: {
1499
+ ...quantities[0],
1500
+ unit: resolveUnit(quantities[0].unit?.name)
1501
+ },
1502
+ unitsLists: []
1503
+ };
1504
+ }
1505
+ const unitsLists = getEquivalentUnitsLists(...quantities);
1506
+ const reducedQuantities = reduceOrsToFirstEquivalent(unitsLists, quantities);
1507
+ const sum = [];
1508
+ for (const nextQ of reducedQuantities) {
1509
+ const existingQ = findCompatibleQuantityWithinList(sum, nextQ);
1510
+ if (existingQ === void 0) {
1511
+ sum.push({
1512
+ ...nextQ,
1513
+ unit: resolveUnit(nextQ.unit?.name)
1514
+ });
1515
+ } else {
1516
+ const sumQ = addQuantities(existingQ, nextQ);
1517
+ existingQ.quantity = sumQ.quantity;
1518
+ existingQ.unit = resolveUnit(sumQ.unit?.name);
1519
+ }
1520
+ }
1521
+ if (sum.length === 1) {
1522
+ return { sum: sum[0], unitsLists };
1523
+ }
1524
+ return { sum: { type: "and", entries: sum }, unitsLists };
1525
+ }
1526
+ function regroupQuantitiesAndExpandEquivalents(sum, unitsLists) {
1527
+ const sumQuantities = isGroup(sum) ? sum.entries : [sum];
1528
+ const result = [];
1529
+ const processedQuantities = /* @__PURE__ */ new Set();
1530
+ for (const list of unitsLists) {
1531
+ const listCopy = deepClone(list);
1532
+ const main = [];
1533
+ const mainCandidates = sumQuantities.filter(
1534
+ (q) => !processedQuantities.has(q)
1535
+ );
1536
+ if (mainCandidates.length === 0) continue;
1537
+ mainCandidates.forEach((q) => {
1538
+ const mainInList = findCompatibleQuantityWithinList(listCopy, q);
1539
+ if (mainInList !== void 0) {
1540
+ processedQuantities.add(q);
1541
+ main.push(q);
1542
+ listCopy.splice(listCopy.indexOf(mainInList), 1);
1543
+ }
1544
+ });
1545
+ const equivalents = sortUnitList(listCopy).map((equiv) => {
1546
+ const initialValue = {
1547
+ quantity: getDefaultQuantityValue()
1548
+ };
1549
+ if (equiv.unit) {
1550
+ initialValue.unit = { name: equiv.unit.name };
1551
+ }
1552
+ return main.reduce((acc, v) => {
1553
+ const mainInList = findCompatibleQuantityWithinList(list, v);
1554
+ const newValue = {
1555
+ quantity: multiplyQuantityValue(
1556
+ v.quantity,
1557
+ Big3(getAverageValue(equiv.quantity)).div(
1558
+ getAverageValue(mainInList.quantity)
1559
+ )
1560
+ )
1561
+ };
1562
+ if (equiv.unit && !isNoUnit(equiv.unit)) {
1563
+ newValue.unit = { name: equiv.unit.name };
1564
+ }
1565
+ return addQuantities(acc, newValue);
1566
+ }, initialValue);
1567
+ });
1568
+ if (main.length + equivalents.length > 1) {
1569
+ const resultMain = main.length > 1 ? {
1570
+ type: "and",
1571
+ entries: main.map(deNormalizeQuantity)
1572
+ } : deNormalizeQuantity(main[0]);
1573
+ result.push({
1574
+ type: "or",
1575
+ entries: [resultMain, ...equivalents]
1576
+ });
1577
+ } else {
1578
+ result.push(deNormalizeQuantity(main[0]));
1579
+ }
1580
+ }
1581
+ sumQuantities.filter((q) => !processedQuantities.has(q)).forEach((q) => result.push(deNormalizeQuantity(q)));
1582
+ return result;
1583
+ }
1584
+ function addEquivalentsAndSimplify(...quantities) {
1585
+ if (quantities.length === 1) {
1586
+ return toPlainUnit(quantities[0]);
1587
+ }
1588
+ const { sum, unitsLists } = addQuantitiesOrGroups(...quantities);
1589
+ const regrouped = regroupQuantitiesAndExpandEquivalents(sum, unitsLists);
1590
+ if (regrouped.length === 1) {
1591
+ return toPlainUnit(regrouped[0]);
1592
+ } else {
1593
+ return { type: "and", entries: regrouped.map(toPlainUnit) };
1594
+ }
1595
+ }
1596
+
1597
+ // src/classes/recipe.ts
1598
+ import Big4 from "big.js";
1599
+ var _Recipe = class _Recipe {
1600
+ /**
1601
+ * Creates a new Recipe instance.
1602
+ * @param content - The recipe content to parse.
1603
+ */
1604
+ constructor(content) {
1605
+ /**
1606
+ * The parsed recipe metadata.
1607
+ */
1608
+ __publicField(this, "metadata", {});
1609
+ /**
1610
+ * The default or manual choice of alternative ingredients.
1611
+ * Contains the full context including alternatives list and active selection index.
1612
+ */
1613
+ __publicField(this, "choices", {
1614
+ ingredientItems: /* @__PURE__ */ new Map(),
1615
+ ingredientGroups: /* @__PURE__ */ new Map()
1616
+ });
1617
+ /**
1618
+ * The parsed recipe ingredients.
1619
+ */
1620
+ __publicField(this, "ingredients", []);
1621
+ /**
1622
+ * The parsed recipe sections.
1623
+ */
1624
+ __publicField(this, "sections", []);
1625
+ /**
1626
+ * The parsed recipe cookware.
1627
+ */
1628
+ __publicField(this, "cookware", []);
1629
+ /**
1630
+ * The parsed recipe timers.
1631
+ */
1632
+ __publicField(this, "timers", []);
1633
+ /**
1634
+ * The parsed recipe servings. Used for scaling. Parsed from one of
1635
+ * {@link Metadata.servings}, {@link Metadata.yield} or {@link Metadata.serves}
1636
+ * metadata fields.
1637
+ *
1638
+ * @see {@link Recipe.scaleBy | scaleBy()} and {@link Recipe.scaleTo | scaleTo()} methods
1639
+ */
1640
+ __publicField(this, "servings");
1641
+ _Recipe.itemCounts.set(this, 0);
1642
+ if (content) {
1643
+ this.parse(content);
1644
+ }
1645
+ }
1646
+ /**
1647
+ * Gets the current item count for this recipe.
1648
+ */
1649
+ getItemCount() {
1650
+ return _Recipe.itemCounts.get(this);
1651
+ }
1652
+ /**
1653
+ * Gets the current item count and increments it.
1654
+ */
1655
+ getAndIncrementItemCount() {
1656
+ const current = this.getItemCount();
1657
+ _Recipe.itemCounts.set(this, current + 1);
1658
+ return current;
1659
+ }
1660
+ _parseQuantityRecursive(quantityRaw) {
1661
+ let quantityMatch = quantityRaw.match(quantityAlternativeRegex);
1662
+ const quantities = [];
1663
+ while (quantityMatch?.groups) {
1664
+ const value = quantityMatch.groups.ingredientQuantityValue ? parseQuantityInput(quantityMatch.groups.ingredientQuantityValue) : void 0;
1665
+ const unit = quantityMatch.groups.ingredientUnit;
1666
+ if (value) {
1667
+ const newQuantity = { quantity: value };
1668
+ if (unit) {
1669
+ if (unit.startsWith("=")) {
1670
+ newQuantity.unit = {
1671
+ name: unit.substring(1),
1672
+ integerProtected: true
1673
+ };
1674
+ } else {
1675
+ newQuantity.unit = { name: unit };
1676
+ }
1677
+ }
1678
+ quantities.push(newQuantity);
1679
+ } else {
1680
+ throw new InvalidQuantityFormat(quantityRaw);
1681
+ }
1682
+ quantityMatch = quantityMatch.groups.ingredientAltQuantity ? quantityMatch.groups.ingredientAltQuantity.match(
1683
+ quantityAlternativeRegex
1684
+ ) : null;
1685
+ }
1686
+ return quantities;
1687
+ }
1688
+ _parseIngredientWithAlternativeRecursive(ingredientMatchString, items) {
1689
+ const alternatives = [];
1690
+ let testString = ingredientMatchString;
1691
+ while (true) {
1692
+ const match = testString.match(
1693
+ alternatives.length > 0 ? inlineIngredientAlternativesRegex : ingredientWithAlternativeRegex
1694
+ );
1695
+ if (!match?.groups) break;
1696
+ const groups = match.groups;
1697
+ let name = groups.mIngredientName || groups.sIngredientName;
1698
+ const preparation = groups.ingredientPreparation;
1699
+ const modifiers = groups.ingredientModifiers;
1700
+ const reference = modifiers !== void 0 && modifiers.includes("&");
1701
+ const flags = [];
1702
+ if (modifiers !== void 0 && modifiers.includes("?")) {
1703
+ flags.push("optional");
1704
+ }
1705
+ if (modifiers !== void 0 && modifiers.includes("-")) {
1706
+ flags.push("hidden");
1707
+ }
1708
+ if (modifiers !== void 0 && modifiers.includes("@") || groups.ingredientRecipeAnchor) {
1709
+ flags.push("recipe");
1710
+ }
1711
+ let extras = void 0;
1712
+ if (flags.includes("recipe")) {
1713
+ extras = { path: `${name}.cook` };
1714
+ name = name.substring(name.lastIndexOf("/") + 1);
1715
+ }
1716
+ const aliasMatch = name.match(ingredientAliasRegex);
1717
+ let listName, displayName;
1718
+ if (aliasMatch && aliasMatch.groups.ingredientListName.trim().length > 0 && aliasMatch.groups.ingredientDisplayName.trim().length > 0) {
1719
+ listName = aliasMatch.groups.ingredientListName.trim();
1720
+ displayName = aliasMatch.groups.ingredientDisplayName.trim();
1721
+ } else {
1722
+ listName = name;
1723
+ displayName = name;
1724
+ }
1725
+ const newIngredient = {
1726
+ name: listName
1727
+ };
1728
+ if (preparation) {
1729
+ newIngredient.preparation = preparation;
1730
+ }
1731
+ if (flags.length > 0) {
1732
+ newIngredient.flags = flags;
1733
+ }
1734
+ if (extras) {
1735
+ newIngredient.extras = extras;
1736
+ }
1737
+ const idxInList = findAndUpsertIngredient(
1738
+ this.ingredients,
1739
+ newIngredient,
1740
+ reference
1741
+ );
1742
+ let itemQuantity = void 0;
1743
+ if (groups.ingredientQuantity) {
1744
+ const parsedQuantities = this._parseQuantityRecursive(
1745
+ groups.ingredientQuantity
1746
+ );
1747
+ const [primary, ...rest] = parsedQuantities;
1748
+ if (primary) {
1749
+ itemQuantity = {
1750
+ ...primary,
1751
+ scalable: groups.ingredientQuantityModifier !== "="
1752
+ };
1753
+ if (rest.length > 0) {
1754
+ itemQuantity.equivalents = rest;
1755
+ }
1756
+ }
1757
+ }
1758
+ const alternative = {
1759
+ index: idxInList,
1760
+ displayName
1761
+ };
1762
+ const note = groups.ingredientNote?.trim();
1763
+ if (note) {
1764
+ alternative.note = note;
1765
+ }
1766
+ if (itemQuantity) {
1767
+ alternative.itemQuantity = itemQuantity;
1768
+ }
1769
+ alternatives.push(alternative);
1770
+ testString = groups.ingredientAlternative || "";
1771
+ }
1772
+ if (alternatives.length > 1) {
1773
+ const alternativesIndexes = alternatives.map((alt) => alt.index);
1774
+ for (const ingredientIndex of alternativesIndexes) {
1775
+ const ingredient = this.ingredients[ingredientIndex];
1776
+ if (ingredient) {
1777
+ if (!ingredient.alternatives) {
1778
+ ingredient.alternatives = new Set(
1779
+ alternativesIndexes.filter((index) => index !== ingredientIndex)
1780
+ );
1781
+ } else {
1782
+ ingredient.alternatives = unionOfSets(
1783
+ ingredient.alternatives,
1784
+ new Set(
1785
+ alternativesIndexes.filter(
1786
+ (index) => index !== ingredientIndex
1787
+ )
1788
+ )
1789
+ );
1790
+ }
1791
+ }
1792
+ }
1793
+ }
1794
+ const id = `ingredient-item-${this.getAndIncrementItemCount()}`;
1795
+ const newItem = {
1796
+ type: "ingredient",
1797
+ id,
1798
+ alternatives
1799
+ };
1800
+ items.push(newItem);
1801
+ if (alternatives.length > 1) {
1802
+ this.choices.ingredientItems.set(id, alternatives);
1803
+ }
1804
+ }
1805
+ _parseIngredientWithGroupKey(ingredientMatchString, items) {
1806
+ const match = ingredientMatchString.match(ingredientWithGroupKeyRegex);
1807
+ if (!match?.groups) return;
1808
+ const groups = match.groups;
1809
+ const groupKey = groups.gIngredientGroupKey;
1810
+ let name = groups.gmIngredientName || groups.gsIngredientName;
1811
+ const preparation = groups.gIngredientPreparation;
1812
+ const modifiers = groups.gIngredientModifiers;
1813
+ const reference = modifiers !== void 0 && modifiers.includes("&");
1814
+ const flags = [];
1815
+ if (modifiers !== void 0 && modifiers.includes("?")) {
1816
+ flags.push("optional");
1817
+ }
1818
+ if (modifiers !== void 0 && modifiers.includes("-")) {
1819
+ flags.push("hidden");
1820
+ }
1821
+ if (modifiers !== void 0 && modifiers.includes("@") || groups.gIngredientRecipeAnchor) {
1822
+ flags.push("recipe");
1823
+ }
1824
+ let extras = void 0;
1825
+ if (flags.includes("recipe")) {
1826
+ extras = { path: `${name}.cook` };
1827
+ name = name.substring(name.lastIndexOf("/") + 1);
1828
+ }
1829
+ const aliasMatch = name.match(ingredientAliasRegex);
1830
+ let listName, displayName;
1831
+ if (aliasMatch && aliasMatch.groups.ingredientListName.trim().length > 0 && aliasMatch.groups.ingredientDisplayName.trim().length > 0) {
1832
+ listName = aliasMatch.groups.ingredientListName.trim();
1833
+ displayName = aliasMatch.groups.ingredientDisplayName.trim();
1834
+ } else {
1835
+ listName = name;
1836
+ displayName = name;
1837
+ }
1838
+ const newIngredient = {
1839
+ name: listName
1840
+ };
1841
+ if (preparation) {
1842
+ newIngredient.preparation = preparation;
1843
+ }
1844
+ if (flags.length > 0) {
1845
+ newIngredient.flags = flags;
1846
+ }
1847
+ if (extras) {
1848
+ newIngredient.extras = extras;
1849
+ }
1850
+ const idxInList = findAndUpsertIngredient(
1851
+ this.ingredients,
1852
+ newIngredient,
1853
+ reference
1854
+ );
1855
+ let itemQuantity = void 0;
1856
+ if (groups.gIngredientQuantity) {
1857
+ const parsedQuantities = this._parseQuantityRecursive(
1858
+ groups.gIngredientQuantity
1859
+ );
1860
+ const [primary, ...rest] = parsedQuantities;
1861
+ itemQuantity = {
1862
+ ...primary,
1863
+ // there's necessarily a primary quantity as the match group was detected
1864
+ scalable: groups.gIngredientQuantityModifier !== "="
1865
+ };
1866
+ if (rest.length > 0) {
1867
+ itemQuantity.equivalents = rest;
1868
+ }
1869
+ }
1870
+ const alternative = {
1871
+ index: idxInList,
1872
+ displayName
1873
+ };
1874
+ if (itemQuantity) {
1875
+ alternative.itemQuantity = itemQuantity;
1876
+ }
1877
+ const existingAlternatives = this.choices.ingredientGroups.get(groupKey);
1878
+ function upsertAlternativeToIngredient(ingredients, ingredientIdx, newAlternativeIdx) {
1879
+ const ingredient = ingredients[ingredientIdx];
1880
+ if (ingredient) {
1881
+ if (ingredient.alternatives === void 0) {
1882
+ ingredient.alternatives = /* @__PURE__ */ new Set([newAlternativeIdx]);
1883
+ } else {
1884
+ ingredient.alternatives.add(newAlternativeIdx);
1885
+ }
1886
+ }
1887
+ }
1888
+ if (existingAlternatives) {
1889
+ for (const alt of existingAlternatives) {
1890
+ upsertAlternativeToIngredient(this.ingredients, alt.index, idxInList);
1891
+ upsertAlternativeToIngredient(this.ingredients, idxInList, alt.index);
1892
+ }
1893
+ }
1894
+ const id = `ingredient-item-${this.getAndIncrementItemCount()}`;
1895
+ const newItem = {
1896
+ type: "ingredient",
1897
+ id,
1898
+ group: groupKey,
1899
+ alternatives: [alternative]
1900
+ };
1901
+ items.push(newItem);
1902
+ const choiceAlternative = deepClone(alternative);
1903
+ choiceAlternative.itemId = id;
1904
+ const existingChoice = this.choices.ingredientGroups.get(groupKey);
1905
+ if (!existingChoice) {
1906
+ this.choices.ingredientGroups.set(groupKey, [choiceAlternative]);
1907
+ } else {
1908
+ existingChoice.push(choiceAlternative);
1909
+ }
1910
+ }
1911
+ /**
1912
+ * Populates the `quantities` property for each ingredient based on
1913
+ * how they appear in the recipe preparation. Only primary ingredients
1914
+ * get quantities populated. Primary ingredients get `usedAsPrimary: true` flag.
1915
+ *
1916
+ * For inline alternatives (e.g. `\@a|b|c`), the first alternative is primary.
1917
+ * For grouped alternatives (e.g. `\@|group|a`, `\@|group|b`), the first item in the group is primary.
1918
+ *
1919
+ * Quantities are grouped by their alternative signature and summed using addEquivalentsAndSimplify.
1920
+ * @internal
1921
+ */
1922
+ _populate_ingredient_quantities() {
1923
+ this.ingredients = this.ingredients.map((ing) => {
1924
+ if (ing.quantities) {
1925
+ delete ing.quantities;
1926
+ }
1927
+ if (ing.usedAsPrimary) {
1928
+ delete ing.usedAsPrimary;
1929
+ }
1930
+ return ing;
1931
+ });
1932
+ const seenGroups = /* @__PURE__ */ new Set();
1933
+ const ingredientGroups = /* @__PURE__ */ new Map();
1934
+ for (const section of this.sections) {
1935
+ for (const step of section.content.filter(
1936
+ (item) => item.type === "step"
1937
+ )) {
1938
+ for (const item of step.items.filter(
1939
+ (item2) => item2.type === "ingredient"
1940
+ )) {
1941
+ const isGroupedItem = "group" in item && item.group !== void 0;
1942
+ const isFirstInGroup = isGroupedItem && !seenGroups.has(item.group);
1943
+ if (isGroupedItem) {
1944
+ seenGroups.add(item.group);
1945
+ }
1946
+ const isPrimary = !isGroupedItem || isFirstInGroup;
1947
+ const alternative = item.alternatives[0];
1948
+ if (isPrimary) {
1949
+ const primaryIngredient = this.ingredients[alternative.index];
1950
+ if (primaryIngredient) {
1951
+ primaryIngredient.usedAsPrimary = true;
1952
+ }
1953
+ }
1954
+ if (!isPrimary || !alternative.itemQuantity) continue;
1955
+ const allQuantities = [
1956
+ {
1957
+ quantity: alternative.itemQuantity.quantity,
1958
+ unit: alternative.itemQuantity.unit
1959
+ }
1960
+ ];
1961
+ if (alternative.itemQuantity.equivalents) {
1962
+ allQuantities.push(...alternative.itemQuantity.equivalents);
1963
+ }
1964
+ const quantityEntry = allQuantities.length === 1 ? allQuantities[0] : { type: "or", entries: allQuantities };
1965
+ const hasInlineAlternatives = item.alternatives.length > 1;
1966
+ const hasGroupedAlternatives = isGroupedItem && this.choices.ingredientGroups.has(item.group);
1967
+ let alternativeRefs;
1968
+ if (hasInlineAlternatives) {
1969
+ alternativeRefs = [];
1970
+ for (let j = 1; j < item.alternatives.length; j++) {
1971
+ const otherAlt = item.alternatives[j];
1972
+ const newRef = {
1973
+ index: otherAlt.index
1974
+ };
1975
+ if (otherAlt.itemQuantity) {
1976
+ const altQty = {
1977
+ quantity: otherAlt.itemQuantity.quantity
1978
+ };
1979
+ if (otherAlt.itemQuantity.unit) {
1980
+ altQty.unit = otherAlt.itemQuantity.unit.name;
1981
+ }
1982
+ if (otherAlt.itemQuantity.equivalents) {
1983
+ altQty.equivalents = otherAlt.itemQuantity.equivalents.map(
1984
+ (eq) => toPlainUnit(eq)
1985
+ );
1986
+ }
1987
+ newRef.alternativeQuantities = [altQty];
1988
+ }
1989
+ alternativeRefs.push(newRef);
1990
+ }
1991
+ } else if (hasGroupedAlternatives) {
1992
+ const groupAlternatives = this.choices.ingredientGroups.get(
1993
+ item.group
1994
+ );
1995
+ alternativeRefs = [];
1996
+ for (let j = 1; j < groupAlternatives.length; j++) {
1997
+ const otherAlt = groupAlternatives[j];
1998
+ if (otherAlt.itemQuantity) {
1999
+ const altQty = {
2000
+ quantity: otherAlt.itemQuantity.quantity
2001
+ };
2002
+ if (otherAlt.itemQuantity.unit) {
2003
+ altQty.unit = otherAlt.itemQuantity.unit.name;
2004
+ }
2005
+ if (otherAlt.itemQuantity.equivalents) {
2006
+ altQty.equivalents = otherAlt.itemQuantity.equivalents.map(
2007
+ (eq) => toPlainUnit(eq)
2008
+ );
2009
+ }
2010
+ alternativeRefs.push({
2011
+ index: otherAlt.index,
2012
+ alternativeQuantities: [altQty]
2013
+ });
2014
+ }
2015
+ }
2016
+ if (alternativeRefs.length === 0) {
2017
+ alternativeRefs = void 0;
2018
+ }
2019
+ }
2020
+ if (!ingredientGroups.has(alternative.index)) {
2021
+ ingredientGroups.set(alternative.index, /* @__PURE__ */ new Map());
2022
+ }
2023
+ const groupsForIngredient = ingredientGroups.get(alternative.index);
2024
+ const baseSignature = getAlternativeSignature(alternativeRefs);
2025
+ const signature = isGroupedItem ? `group:${item.group}|${baseSignature ?? ""}` : baseSignature;
2026
+ if (!groupsForIngredient.has(signature)) {
2027
+ groupsForIngredient.set(signature, {
2028
+ alternativeQuantities: /* @__PURE__ */ new Map(),
2029
+ quantities: []
2030
+ });
2031
+ }
2032
+ const group = groupsForIngredient.get(signature);
2033
+ group.quantities.push(quantityEntry);
2034
+ if (alternativeRefs) {
2035
+ for (const ref of alternativeRefs) {
2036
+ if (!group.alternativeQuantities.has(ref.index)) {
2037
+ group.alternativeQuantities.set(ref.index, []);
2038
+ }
2039
+ if (ref.alternativeQuantities && ref.alternativeQuantities.length > 0) {
2040
+ for (const altQty of ref.alternativeQuantities) {
2041
+ if (altQty.equivalents && altQty.equivalents.length > 0) {
2042
+ const entries = [
2043
+ toExtendedUnit({
2044
+ quantity: altQty.quantity,
2045
+ unit: altQty.unit
2046
+ }),
2047
+ ...altQty.equivalents.map((eq) => toExtendedUnit(eq))
2048
+ ];
2049
+ group.alternativeQuantities.get(ref.index).push({ type: "or", entries });
2050
+ } else {
2051
+ group.alternativeQuantities.get(ref.index).push(
2052
+ toExtendedUnit({
2053
+ quantity: altQty.quantity,
2054
+ unit: altQty.unit
2055
+ })
2056
+ );
2057
+ }
2058
+ }
2059
+ }
2060
+ }
2061
+ }
2062
+ }
2063
+ }
2064
+ }
2065
+ for (const [index, groupsForIngredient] of ingredientGroups) {
2066
+ const ingredient = this.ingredients[index];
2067
+ const quantityGroups = [];
2068
+ for (const [, group] of groupsForIngredient) {
2069
+ const summedGroupQuantity = addEquivalentsAndSimplify(
2070
+ ...group.quantities
2071
+ );
2072
+ const groupQuantities = flattenPlainUnitGroup(summedGroupQuantity);
2073
+ let alternatives;
2074
+ if (group.alternativeQuantities.size > 0) {
2075
+ alternatives = [];
2076
+ for (const [altIndex, altQuantities] of group.alternativeQuantities) {
2077
+ const ref = { index: altIndex };
2078
+ if (altQuantities.length > 0) {
2079
+ const summedAltQuantity = addEquivalentsAndSimplify(
2080
+ ...altQuantities
2081
+ );
2082
+ const flattenedAlt = flattenPlainUnitGroup(summedAltQuantity);
2083
+ ref.alternativeQuantities = flattenedAlt.flatMap((item) => {
2084
+ if ("groupQuantity" in item) {
2085
+ return [item.groupQuantity];
2086
+ } else {
2087
+ return item.entries;
2088
+ }
2089
+ });
2090
+ }
2091
+ alternatives.push(ref);
2092
+ }
2093
+ }
2094
+ for (const gq of groupQuantities) {
2095
+ if ("type" in gq && gq.type === "and") {
2096
+ const andGroup = {
2097
+ type: "and",
2098
+ entries: gq.entries
2099
+ };
2100
+ if (gq.equivalents && gq.equivalents.length > 0) {
2101
+ andGroup.equivalents = gq.equivalents;
2102
+ }
2103
+ if (alternatives && alternatives.length > 0) {
2104
+ andGroup.alternatives = alternatives;
2105
+ }
2106
+ quantityGroups.push(andGroup);
2107
+ } else {
2108
+ const quantityGroup = gq;
2109
+ if (alternatives && alternatives.length > 0) {
2110
+ quantityGroup.alternatives = alternatives;
2111
+ }
2112
+ quantityGroups.push(quantityGroup);
2113
+ }
2114
+ }
2115
+ }
2116
+ if (quantityGroups.length > 0) {
2117
+ ingredient.quantities = quantityGroups;
2118
+ }
2119
+ }
2120
+ }
2121
+ /**
2122
+ * Calculates ingredient quantities based on the provided choices.
2123
+ * Returns a list of computed ingredients with their total quantities.
2124
+ *
2125
+ * @param choices - The recipe choices to apply when computing quantities.
2126
+ * If not provided, uses the default choices (first alternative for each item).
2127
+ * @returns An array of ComputedIngredient with quantityTotal calculated based on choices.
2128
+ */
2129
+ calc_ingredient_quantities(choices) {
2130
+ const effectiveChoices = choices || {
2131
+ ingredientItems: new Map(
2132
+ Array.from(this.choices.ingredientItems.keys()).map((k) => [k, 0])
2133
+ ),
2134
+ ingredientGroups: new Map(
2135
+ Array.from(this.choices.ingredientGroups.keys()).map((k) => [k, 0])
2136
+ )
2137
+ };
2138
+ const ingredientQuantities = /* @__PURE__ */ new Map();
2139
+ const selectedIngredientIndices = /* @__PURE__ */ new Set();
2140
+ for (const section of this.sections) {
2141
+ for (const step of section.content.filter(
2142
+ (item) => item.type === "step"
2143
+ )) {
2144
+ for (const item of step.items.filter(
2145
+ (item2) => item2.type === "ingredient"
2146
+ )) {
2147
+ for (let i2 = 0; i2 < item.alternatives.length; i2++) {
2148
+ const alternative = item.alternatives[i2];
2149
+ const isAlternativeChoiceItem = effectiveChoices.ingredientItems?.get(item.id) === i2;
2150
+ const alternativeChoiceGroupIdx = item.group ? effectiveChoices.ingredientGroups?.get(item.group) : void 0;
2151
+ const alternativeChoiceGroup = item.group ? this.choices.ingredientGroups.get(item.group) : void 0;
2152
+ const isAlternativeChoiceGroup = alternativeChoiceGroup && alternativeChoiceGroupIdx !== void 0 ? alternativeChoiceGroup[alternativeChoiceGroupIdx]?.itemId === item.id : false;
2153
+ const isSelected = !("group" in item) && (item.alternatives.length === 1 || isAlternativeChoiceItem) || isAlternativeChoiceGroup;
2154
+ if (isSelected) {
2155
+ selectedIngredientIndices.add(alternative.index);
2156
+ if (alternative.itemQuantity) {
2157
+ const allQuantities = [
2158
+ {
2159
+ quantity: alternative.itemQuantity.quantity,
2160
+ unit: alternative.itemQuantity.unit
2161
+ }
2162
+ ];
2163
+ if (alternative.itemQuantity.equivalents) {
2164
+ allQuantities.push(...alternative.itemQuantity.equivalents);
2165
+ }
2166
+ const equivalents = allQuantities.length === 1 ? allQuantities[0] : {
2167
+ type: "or",
2168
+ entries: allQuantities
2169
+ };
2170
+ ingredientQuantities.set(alternative.index, [
2171
+ ...ingredientQuantities.get(alternative.index) || [],
2172
+ equivalents
2173
+ ]);
2174
+ }
2175
+ }
2176
+ }
2177
+ }
2178
+ }
2179
+ }
2180
+ const computedIngredients = [];
2181
+ for (let index = 0; index < this.ingredients.length; index++) {
2182
+ if (!selectedIngredientIndices.has(index)) continue;
2183
+ const ing = this.ingredients[index];
2184
+ const computed = {
2185
+ name: ing.name
2186
+ };
2187
+ if (ing.preparation) {
2188
+ computed.preparation = ing.preparation;
2189
+ }
2190
+ if (ing.flags) {
2191
+ computed.flags = ing.flags;
2192
+ }
2193
+ if (ing.extras) {
2194
+ computed.extras = ing.extras;
2195
+ }
2196
+ const quantities = ingredientQuantities.get(index);
2197
+ if (quantities && quantities.length > 0) {
2198
+ computed.quantityTotal = addEquivalentsAndSimplify(...quantities);
2199
+ }
2200
+ computedIngredients.push(computed);
2201
+ }
2202
+ return computedIngredients;
2203
+ }
2204
+ /**
2205
+ * Parses a recipe from a string.
2206
+ * @param content - The recipe content to parse.
2207
+ */
2208
+ parse(content) {
2209
+ const cleanContent = content.replace(metadataRegex, "").replace(commentRegex, "").replace(blockCommentRegex, "").trim().split(/\r\n?|\n/);
2210
+ const { metadata, servings } = extractMetadata(content);
2211
+ this.metadata = metadata;
2212
+ this.servings = servings;
2213
+ let blankLineBefore = true;
2214
+ let section = new Section();
2215
+ const items = [];
2216
+ let note = "";
2217
+ let inNote = false;
2218
+ for (const line of cleanContent) {
2219
+ if (line.trim().length === 0) {
2220
+ flushPendingItems(section, items);
2221
+ note = flushPendingNote(section, note);
2222
+ blankLineBefore = true;
2223
+ inNote = false;
2224
+ continue;
2225
+ }
2226
+ if (line.startsWith("=")) {
2227
+ flushPendingItems(section, items);
2228
+ note = flushPendingNote(section, note);
2229
+ if (this.sections.length === 0 && section.isBlank()) {
2230
+ section.name = line.replace(/^=+|=+$/g, "").trim();
939
2231
  } else {
940
2232
  if (!section.isBlank()) {
941
2233
  this.sections.push(section);
@@ -972,12 +2264,13 @@ var Recipe = class _Recipe {
972
2264
  }
973
2265
  const groups = match.groups;
974
2266
  if (groups.mIngredientName || groups.sIngredientName) {
975
- let name = groups.mIngredientName || groups.sIngredientName;
976
- const scalableQuantity = (groups.mIngredientQuantityModifier || groups.sIngredientQuantityModifier) !== "=";
977
- const quantityRaw = groups.mIngredientQuantity || groups.sIngredientQuantity;
978
- const unit = groups.mIngredientUnit || groups.sIngredientUnit;
979
- const preparation = groups.mIngredientPreparation || groups.sIngredientPreparation;
980
- const modifiers = groups.mIngredientModifiers || groups.sIngredientModifiers;
2267
+ this._parseIngredientWithAlternativeRecursive(match[0], items);
2268
+ } else if (groups.gmIngredientName || groups.gsIngredientName) {
2269
+ this._parseIngredientWithGroupKey(match[0], items);
2270
+ } else if (groups.mCookwareName || groups.sCookwareName) {
2271
+ const name = groups.mCookwareName || groups.sCookwareName;
2272
+ const modifiers = groups.cookwareModifiers;
2273
+ const quantityRaw = groups.cookwareQuantity;
981
2274
  const reference = modifiers !== void 0 && modifiers.includes("&");
982
2275
  const flags = [];
983
2276
  if (modifiers !== void 0 && modifiers.includes("?")) {
@@ -986,83 +2279,29 @@ var Recipe = class _Recipe {
986
2279
  if (modifiers !== void 0 && modifiers.includes("-")) {
987
2280
  flags.push("hidden");
988
2281
  }
989
- if (modifiers !== void 0 && modifiers.includes("@") || groups.mIngredientRecipeAnchor || groups.sIngredientRecipeAnchor) {
990
- flags.push("recipe");
991
- }
992
- let extras = void 0;
993
- if (flags.includes("recipe")) {
994
- extras = { path: `${name}.cook` };
995
- name = name.substring(name.lastIndexOf("/") + 1);
996
- }
997
2282
  const quantity = quantityRaw ? parseQuantityInput(quantityRaw) : void 0;
998
- const aliasMatch = name.match(ingredientAliasRegex);
999
- let listName, displayName;
1000
- if (aliasMatch && aliasMatch.groups.ingredientListName.trim().length > 0 && aliasMatch.groups.ingredientDisplayName.trim().length > 0) {
1001
- listName = aliasMatch.groups.ingredientListName.trim();
1002
- displayName = aliasMatch.groups.ingredientDisplayName.trim();
1003
- } else {
1004
- listName = name;
1005
- displayName = name;
1006
- }
1007
- const newIngredient = {
1008
- name: listName,
1009
- quantity,
1010
- quantityParts: quantity ? [
1011
- {
1012
- value: quantity,
1013
- unit,
1014
- scalable: scalableQuantity
1015
- }
1016
- ] : void 0,
1017
- unit,
1018
- preparation,
1019
- flags
2283
+ const newCookware = {
2284
+ name
1020
2285
  };
1021
- if (extras) {
1022
- newIngredient.extras = extras;
2286
+ if (quantity) {
2287
+ newCookware.quantity = quantity;
2288
+ }
2289
+ if (flags.length > 0) {
2290
+ newCookware.flags = flags;
1023
2291
  }
1024
- const idxsInList = findAndUpsertIngredient(
1025
- this.ingredients,
1026
- newIngredient,
2292
+ const idxInList = findAndUpsertCookware(
2293
+ this.cookware,
2294
+ newCookware,
1027
2295
  reference
1028
2296
  );
1029
2297
  const newItem = {
1030
- type: "ingredient",
1031
- index: idxsInList.ingredientIndex,
1032
- displayName
2298
+ type: "cookware",
2299
+ index: idxInList
1033
2300
  };
1034
- if (idxsInList.quantityPartIndex !== void 0) {
1035
- newItem.quantityPartIndex = idxsInList.quantityPartIndex;
2301
+ if (quantity) {
2302
+ newItem.quantity = quantity;
1036
2303
  }
1037
2304
  items.push(newItem);
1038
- } else if (groups.mCookwareName || groups.sCookwareName) {
1039
- const name = groups.mCookwareName || groups.sCookwareName;
1040
- const modifiers = groups.mCookwareModifiers || groups.sCookwareModifiers;
1041
- const quantityRaw = groups.mCookwareQuantity || groups.sCookwareQuantity;
1042
- const reference = modifiers !== void 0 && modifiers.includes("&");
1043
- const flags = [];
1044
- if (modifiers !== void 0 && modifiers.includes("?")) {
1045
- flags.push("optional");
1046
- }
1047
- if (modifiers !== void 0 && modifiers.includes("-")) {
1048
- flags.push("hidden");
1049
- }
1050
- const quantity = quantityRaw ? parseQuantityInput(quantityRaw) : void 0;
1051
- const idxsInList = findAndUpsertCookware(
1052
- this.cookware,
1053
- {
1054
- name,
1055
- quantity,
1056
- quantityParts: quantity ? [quantity] : void 0,
1057
- flags
1058
- },
1059
- reference
1060
- );
1061
- items.push({
1062
- type: "cookware",
1063
- index: idxsInList.cookwareIndex,
1064
- quantityPartIndex: idxsInList.quantityPartIndex
1065
- });
1066
2305
  } else {
1067
2306
  const durationStr = groups.timerQuantity.trim();
1068
2307
  const unit = (groups.timerUnit || "").trim();
@@ -1090,6 +2329,7 @@ var Recipe = class _Recipe {
1090
2329
  if (!section.isBlank()) {
1091
2330
  this.sections.push(section);
1092
2331
  }
2332
+ this._populate_ingredient_quantities();
1093
2333
  }
1094
2334
  /**
1095
2335
  * Scales the recipe to a new number of servings. In practice, it calls
@@ -1104,7 +2344,7 @@ var Recipe = class _Recipe {
1104
2344
  if (originalServings === void 0 || originalServings === 0) {
1105
2345
  throw new Error("Error scaling recipe: no initial servings value set");
1106
2346
  }
1107
- const factor = Big2(newServings).div(originalServings);
2347
+ const factor = Big4(newServings).div(originalServings);
1108
2348
  return this.scaleBy(factor);
1109
2349
  }
1110
2350
  /**
@@ -1119,44 +2359,62 @@ var Recipe = class _Recipe {
1119
2359
  if (originalServings === void 0 || originalServings === 0) {
1120
2360
  throw new Error("Error scaling recipe: no initial servings value set");
1121
2361
  }
1122
- newRecipe.ingredients = newRecipe.ingredients.map((ingredient) => {
1123
- if (ingredient.quantityParts) {
1124
- ingredient.quantityParts = ingredient.quantityParts.map(
1125
- (quantityPart) => {
1126
- if (quantityPart.value.type === "fixed" && quantityPart.value.value.type === "text") {
1127
- return quantityPart;
1128
- }
1129
- return {
1130
- ...quantityPart,
1131
- value: multiplyQuantityValue(
1132
- quantityPart.value,
1133
- quantityPart.scalable ? Big2(factor) : 1
1134
- )
1135
- };
2362
+ function scaleAlternativesBy(alternatives, factor2) {
2363
+ for (const alternative of alternatives) {
2364
+ if (alternative.itemQuantity) {
2365
+ const scaleFactor = alternative.itemQuantity.scalable ? Big4(factor2) : 1;
2366
+ if (alternative.itemQuantity.quantity.type !== "fixed" || alternative.itemQuantity.quantity.value.type !== "text") {
2367
+ alternative.itemQuantity.quantity = multiplyQuantityValue(
2368
+ alternative.itemQuantity.quantity,
2369
+ scaleFactor
2370
+ );
2371
+ }
2372
+ if (alternative.itemQuantity.equivalents) {
2373
+ alternative.itemQuantity.equivalents = alternative.itemQuantity.equivalents.map(
2374
+ (altQuantity) => {
2375
+ if (altQuantity.quantity.type === "fixed" && altQuantity.quantity.value.type === "text") {
2376
+ return altQuantity;
2377
+ } else {
2378
+ return {
2379
+ ...altQuantity,
2380
+ quantity: multiplyQuantityValue(
2381
+ altQuantity.quantity,
2382
+ scaleFactor
2383
+ )
2384
+ };
2385
+ }
2386
+ }
2387
+ );
1136
2388
  }
1137
- );
1138
- if (ingredient.quantityParts.length === 1) {
1139
- ingredient.quantity = ingredient.quantityParts[0].value;
1140
- ingredient.unit = ingredient.quantityParts[0].unit;
1141
- } else {
1142
- const totalQuantity = ingredient.quantityParts.reduce(
1143
- (acc, val) => addQuantities(acc, { value: val.value, unit: val.unit }),
1144
- { value: getDefaultQuantityValue() }
1145
- );
1146
- ingredient.quantity = totalQuantity.value;
1147
- ingredient.unit = totalQuantity.unit;
1148
2389
  }
1149
2390
  }
1150
- return ingredient;
1151
- }).filter((ingredient) => ingredient.quantity !== null);
1152
- newRecipe.servings = Big2(originalServings).times(factor).toNumber();
2391
+ }
2392
+ for (const section of newRecipe.sections) {
2393
+ for (const step of section.content.filter(
2394
+ (item) => item.type === "step"
2395
+ )) {
2396
+ for (const item of step.items.filter(
2397
+ (item2) => item2.type === "ingredient"
2398
+ )) {
2399
+ scaleAlternativesBy(item.alternatives, factor);
2400
+ }
2401
+ }
2402
+ }
2403
+ for (const alternatives of newRecipe.choices.ingredientGroups.values()) {
2404
+ scaleAlternativesBy(alternatives, factor);
2405
+ }
2406
+ for (const alternatives of newRecipe.choices.ingredientItems.values()) {
2407
+ scaleAlternativesBy(alternatives, factor);
2408
+ }
2409
+ newRecipe._populate_ingredient_quantities();
2410
+ newRecipe.servings = Big4(originalServings).times(factor).toNumber();
1153
2411
  if (newRecipe.metadata.servings && this.metadata.servings) {
1154
2412
  if (floatRegex.test(String(this.metadata.servings).replace(",", ".").trim())) {
1155
2413
  const servingsValue = parseFloat(
1156
2414
  String(this.metadata.servings).replace(",", ".")
1157
2415
  );
1158
2416
  newRecipe.metadata.servings = String(
1159
- Big2(servingsValue).times(factor).toNumber()
2417
+ Big4(servingsValue).times(factor).toNumber()
1160
2418
  );
1161
2419
  }
1162
2420
  }
@@ -1166,7 +2424,7 @@ var Recipe = class _Recipe {
1166
2424
  String(this.metadata.yield).replace(",", ".")
1167
2425
  );
1168
2426
  newRecipe.metadata.yield = String(
1169
- Big2(yieldValue).times(factor).toNumber()
2427
+ Big4(yieldValue).times(factor).toNumber()
1170
2428
  );
1171
2429
  }
1172
2430
  }
@@ -1176,7 +2434,7 @@ var Recipe = class _Recipe {
1176
2434
  String(this.metadata.serves).replace(",", ".")
1177
2435
  );
1178
2436
  newRecipe.metadata.serves = String(
1179
- Big2(servesValue).times(factor).toNumber()
2437
+ Big4(servesValue).times(factor).toNumber()
1180
2438
  );
1181
2439
  }
1182
2440
  }
@@ -1199,19 +2457,27 @@ var Recipe = class _Recipe {
1199
2457
  */
1200
2458
  clone() {
1201
2459
  const newRecipe = new _Recipe();
1202
- newRecipe.metadata = JSON.parse(JSON.stringify(this.metadata));
1203
- newRecipe.ingredients = JSON.parse(
1204
- JSON.stringify(this.ingredients)
1205
- );
1206
- newRecipe.sections = JSON.parse(JSON.stringify(this.sections));
1207
- newRecipe.cookware = JSON.parse(
1208
- JSON.stringify(this.cookware)
1209
- );
1210
- newRecipe.timers = JSON.parse(JSON.stringify(this.timers));
2460
+ newRecipe.choices = deepClone(this.choices);
2461
+ _Recipe.itemCounts.set(newRecipe, this.getItemCount());
2462
+ newRecipe.metadata = deepClone(this.metadata);
2463
+ newRecipe.ingredients = deepClone(this.ingredients);
2464
+ newRecipe.sections = this.sections.map((section) => {
2465
+ const newSection = new Section(section.name);
2466
+ newSection.content = deepClone(section.content);
2467
+ return newSection;
2468
+ });
2469
+ newRecipe.cookware = deepClone(this.cookware);
2470
+ newRecipe.timers = deepClone(this.timers);
1211
2471
  newRecipe.servings = this.servings;
1212
2472
  return newRecipe;
1213
2473
  }
1214
2474
  };
2475
+ /**
2476
+ * External storage for item count (not a property on instances).
2477
+ * Used for giving ID numbers to items during parsing.
2478
+ */
2479
+ __publicField(_Recipe, "itemCounts", /* @__PURE__ */ new WeakMap());
2480
+ var Recipe = _Recipe;
1215
2481
 
1216
2482
  // src/classes/shopping_list.ts
1217
2483
  var ShoppingList = class {
@@ -1220,6 +2486,7 @@ var ShoppingList = class {
1220
2486
  * @param category_config_str - The category configuration to parse.
1221
2487
  */
1222
2488
  constructor(category_config_str) {
2489
+ // TODO: backport type change
1223
2490
  /**
1224
2491
  * The ingredients in the shopping list.
1225
2492
  */
@@ -1242,6 +2509,33 @@ var ShoppingList = class {
1242
2509
  }
1243
2510
  calculate_ingredients() {
1244
2511
  this.ingredients = [];
2512
+ const addIngredientQuantity = (name, quantityTotal) => {
2513
+ const quantityTotalExtended = extendAllUnits(quantityTotal);
2514
+ const newQuantities = isAndGroup(quantityTotalExtended) ? quantityTotalExtended.entries : [quantityTotalExtended];
2515
+ const existing = this.ingredients.find((i2) => i2.name === name);
2516
+ if (existing) {
2517
+ if (!existing.quantityTotal) {
2518
+ existing.quantityTotal = quantityTotal;
2519
+ return;
2520
+ }
2521
+ try {
2522
+ const existingQuantityTotalExtended = extendAllUnits(
2523
+ existing.quantityTotal
2524
+ );
2525
+ const existingQuantities = isAndGroup(existingQuantityTotalExtended) ? existingQuantityTotalExtended.entries : [existingQuantityTotalExtended];
2526
+ existing.quantityTotal = addEquivalentsAndSimplify(
2527
+ ...existingQuantities,
2528
+ ...newQuantities
2529
+ );
2530
+ return;
2531
+ } catch {
2532
+ }
2533
+ }
2534
+ this.ingredients.push({
2535
+ name,
2536
+ quantityTotal
2537
+ });
2538
+ };
1245
2539
  for (const addedRecipe of this.recipes) {
1246
2540
  let scaledRecipe;
1247
2541
  if ("factor" in addedRecipe) {
@@ -1250,62 +2544,47 @@ var ShoppingList = class {
1250
2544
  } else {
1251
2545
  scaledRecipe = addedRecipe.recipe.scaleTo(addedRecipe.servings);
1252
2546
  }
1253
- for (const ingredient of scaledRecipe.ingredients) {
2547
+ const computedIngredients = scaledRecipe.calc_ingredient_quantities(
2548
+ addedRecipe.choices
2549
+ );
2550
+ for (const ingredient of computedIngredients) {
1254
2551
  if (ingredient.flags && ingredient.flags.includes("hidden")) {
1255
2552
  continue;
1256
2553
  }
1257
- const existingIngredient = this.ingredients.find(
1258
- (i2) => i2.name === ingredient.name
1259
- );
1260
- let addSeparate = false;
1261
- try {
1262
- if (existingIngredient && ingredient.quantity) {
1263
- if (existingIngredient.quantity) {
1264
- const newQuantity = addQuantities(
1265
- {
1266
- value: existingIngredient.quantity,
1267
- unit: existingIngredient.unit ?? ""
1268
- },
1269
- {
1270
- value: ingredient.quantity,
1271
- unit: ingredient.unit ?? ""
1272
- }
1273
- );
1274
- existingIngredient.quantity = newQuantity.value;
1275
- if (newQuantity.unit) {
1276
- existingIngredient.unit = newQuantity.unit;
1277
- }
1278
- } else {
1279
- existingIngredient.quantity = ingredient.quantity;
1280
- if (ingredient.unit) {
1281
- existingIngredient.unit = ingredient.unit;
1282
- }
1283
- }
1284
- }
1285
- } catch {
1286
- addSeparate = true;
1287
- }
1288
- if (!existingIngredient || addSeparate) {
1289
- const newIngredient = { name: ingredient.name };
1290
- if (ingredient.quantity) {
1291
- newIngredient.quantity = ingredient.quantity;
1292
- }
1293
- if (ingredient.unit) {
1294
- newIngredient.unit = ingredient.unit;
1295
- }
1296
- this.ingredients.push(newIngredient);
2554
+ if (ingredient.quantityTotal) {
2555
+ addIngredientQuantity(ingredient.name, ingredient.quantityTotal);
2556
+ } else if (!this.ingredients.some((i2) => i2.name === ingredient.name)) {
2557
+ this.ingredients.push({ name: ingredient.name });
1297
2558
  }
1298
2559
  }
1299
2560
  }
1300
2561
  }
1301
- add_recipe(recipe, scaling) {
1302
- if (typeof scaling === "number" || scaling === void 0) {
1303
- this.recipes.push({ recipe, factor: scaling ?? 1 });
2562
+ /**
2563
+ * Adds a recipe to the shopping list, then automatically
2564
+ * recalculates the quantities and recategorize the ingredients.
2565
+ * @param recipe - The recipe to add.
2566
+ * @param options - Options for adding the recipe.
2567
+ */
2568
+ add_recipe(recipe, options = {}) {
2569
+ if (!options.scaling) {
2570
+ this.recipes.push({
2571
+ recipe,
2572
+ factor: options.scaling ?? 1,
2573
+ choices: options.choices
2574
+ });
1304
2575
  } else {
1305
- if ("factor" in scaling) {
1306
- this.recipes.push({ recipe, factor: scaling.factor });
2576
+ if ("factor" in options.scaling) {
2577
+ this.recipes.push({
2578
+ recipe,
2579
+ factor: options.scaling.factor,
2580
+ choices: options.choices
2581
+ });
1307
2582
  } else {
1308
- this.recipes.push({ recipe, servings: scaling.servings });
2583
+ this.recipes.push({
2584
+ recipe,
2585
+ servings: options.scaling.servings,
2586
+ choices: options.choices
2587
+ });
1309
2588
  }
1310
2589
  }
1311
2590
  this.calculate_ingredients();
@@ -1370,14 +2649,275 @@ var ShoppingList = class {
1370
2649
  this.categories = categories;
1371
2650
  }
1372
2651
  };
2652
+
2653
+ // src/classes/shopping_cart.ts
2654
+ import { solve } from "yalps";
2655
+ var ShoppingCart = class {
2656
+ /**
2657
+ * Creates a new ShoppingCart instance
2658
+ * @param options - {@link ShoppingCartOptions | Options} for the constructor
2659
+ */
2660
+ constructor(options) {
2661
+ /**
2662
+ * The product catalog to use for matching products
2663
+ */
2664
+ __publicField(this, "productCatalog");
2665
+ /**
2666
+ * The shopping list to build the cart from
2667
+ */
2668
+ __publicField(this, "shoppingList");
2669
+ /**
2670
+ * The content of the cart
2671
+ */
2672
+ __publicField(this, "cart", []);
2673
+ /**
2674
+ * The ingredients that were successfully matched with products
2675
+ */
2676
+ __publicField(this, "match", []);
2677
+ /**
2678
+ * The ingredients that could not be matched with products
2679
+ */
2680
+ __publicField(this, "misMatch", []);
2681
+ /**
2682
+ * Key information about the shopping cart
2683
+ */
2684
+ __publicField(this, "summary");
2685
+ if (options?.catalog) this.productCatalog = options.catalog;
2686
+ if (options?.list) this.shoppingList = options.list;
2687
+ this.summary = { totalPrice: 0, totalItems: 0 };
2688
+ }
2689
+ /**
2690
+ * Sets the product catalog to use for matching products
2691
+ * To use if a catalog was not provided at the creation of the instance
2692
+ * @param catalog - The {@link ProductCatalog} to set
2693
+ */
2694
+ setProductCatalog(catalog) {
2695
+ this.productCatalog = catalog;
2696
+ }
2697
+ // TODO: harmonize recipe name to use underscores
2698
+ /**
2699
+ * Sets the shopping list to build the cart from.
2700
+ * To use if a shopping list was not provided at the creation of the instance
2701
+ * @param list - The {@link ShoppingList} to set
2702
+ */
2703
+ setShoppingList(list) {
2704
+ this.shoppingList = list;
2705
+ }
2706
+ /**
2707
+ * Builds the cart from the shopping list and product catalog
2708
+ * @remarks
2709
+ * - 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
2710
+ * in addition to that combination being added to the {@link ShoppingCart.cart | cart}.
2711
+ * - Otherwise, the latter will be listed in the {@link ShoppingCart.misMatch | misMatch} array. Possible causes can be:
2712
+ * - No product is listed in the catalog for that ingredient
2713
+ * - The ingredient has no quantity, a text quantity
2714
+ * - The ingredient's quantity unit is incompatible with the units of the candidate products listed in the catalog
2715
+ * @throws {@link NoProductCatalogForCartError} if no product catalog is set
2716
+ * @throws {@link NoShoppingListForCartError} if no shopping list is set
2717
+ * @returns `true` if all ingredients in the shopping list have been matched to products in the catalog, or `false` otherwise
2718
+ */
2719
+ buildCart() {
2720
+ this.resetCart();
2721
+ if (this.productCatalog === void 0) {
2722
+ throw new NoProductCatalogForCartError();
2723
+ } else if (this.shoppingList === void 0) {
2724
+ throw new NoShoppingListForCartError();
2725
+ }
2726
+ for (const ingredient of this.shoppingList.ingredients) {
2727
+ const productOptions = this.getProductOptions(ingredient);
2728
+ try {
2729
+ const optimumMatch = this.getOptimumMatch(ingredient, productOptions);
2730
+ this.cart.push(...optimumMatch);
2731
+ this.match.push({ ingredient, selection: optimumMatch });
2732
+ } catch (error) {
2733
+ if (error instanceof NoProductMatchError) {
2734
+ this.misMatch.push({ ingredient, reason: error.code });
2735
+ }
2736
+ }
2737
+ }
2738
+ this.summarize();
2739
+ return this.misMatch.length > 0;
2740
+ }
2741
+ /**
2742
+ * Gets the product options for a given ingredient
2743
+ * @param ingredient - The ingredient to get the product options for
2744
+ * @returns An array of {@link ProductOption}
2745
+ */
2746
+ getProductOptions(ingredient) {
2747
+ return this.productCatalog.products.filter(
2748
+ (product) => product.ingredientName === ingredient.name || product.ingredientAliases?.includes(ingredient.name)
2749
+ );
2750
+ }
2751
+ /**
2752
+ * Gets the optimum match for a given ingredient and product option
2753
+ * @param ingredient - The ingredient to match
2754
+ * @param options - The product options to choose from
2755
+ * @returns An array of {@link ProductSelection}
2756
+ * @throws {@link NoProductMatchError} if no match can be found
2757
+ */
2758
+ getOptimumMatch(ingredient, options) {
2759
+ if (options.length === 0)
2760
+ throw new NoProductMatchError(ingredient.name, "noProduct");
2761
+ if (!ingredient.quantityTotal)
2762
+ throw new NoProductMatchError(ingredient.name, "noQuantity");
2763
+ const normalizedOptions = options.map(
2764
+ (option) => ({
2765
+ ...option,
2766
+ sizes: option.sizes.map((s) => {
2767
+ const resolvedUnit = resolveUnit(s.unit);
2768
+ return {
2769
+ size: resolvedUnit && "toBase" in resolvedUnit ? multiplyQuantityValue(
2770
+ s.size,
2771
+ resolvedUnit.toBase
2772
+ ) : s.size,
2773
+ unit: resolvedUnit
2774
+ };
2775
+ })
2776
+ })
2777
+ );
2778
+ const normalizedQuantityTotal = normalizeAllUnits(ingredient.quantityTotal);
2779
+ function getOptimumMatchForQuantityParts(normalizedQuantities, normalizedOptions2, selection = []) {
2780
+ if (isAndGroup(normalizedQuantities)) {
2781
+ for (const q of normalizedQuantities.entries) {
2782
+ const result = getOptimumMatchForQuantityParts(
2783
+ q,
2784
+ normalizedOptions2,
2785
+ selection
2786
+ );
2787
+ selection.push(...result);
2788
+ }
2789
+ } else {
2790
+ const alternativeUnitsOfQuantity = isOrGroup(normalizedQuantities) ? normalizedQuantities.entries : [normalizedQuantities];
2791
+ const solutions = [];
2792
+ const errors = /* @__PURE__ */ new Set();
2793
+ for (const alternative of alternativeUnitsOfQuantity) {
2794
+ if (alternative.quantity.type === "fixed" && alternative.quantity.value.type === "text") {
2795
+ errors.add("textValue");
2796
+ continue;
2797
+ }
2798
+ const scaledQuantity = multiplyQuantityValue(
2799
+ alternative.quantity,
2800
+ "toBase" in alternative.unit ? alternative.unit.toBase : 1
2801
+ );
2802
+ alternative.quantity = scaledQuantity;
2803
+ const matchOptions = normalizedOptions2.filter(
2804
+ (option) => option.sizes.some(
2805
+ (s) => areUnitsCompatible(alternative.unit, s.unit)
2806
+ )
2807
+ );
2808
+ if (matchOptions.length > 0) {
2809
+ const findCompatibleSize = (option) => option.sizes.find(
2810
+ (s) => areUnitsCompatible(alternative.unit, s.unit)
2811
+ );
2812
+ if (matchOptions.length == 1) {
2813
+ const matchedOption = matchOptions[0];
2814
+ const compatibleSize = findCompatibleSize(matchedOption);
2815
+ const product = options.find(
2816
+ (opt) => opt.id === matchedOption.id
2817
+ );
2818
+ const targetQuantity = scaledQuantity.type === "fixed" ? scaledQuantity.value : scaledQuantity.min;
2819
+ const resQuantity = Math.ceil(
2820
+ getNumericValue(targetQuantity) / getNumericValue(compatibleSize.size.value)
2821
+ );
2822
+ solutions.push([
2823
+ {
2824
+ product,
2825
+ quantity: resQuantity,
2826
+ totalPrice: resQuantity * matchedOption.price
2827
+ }
2828
+ ]);
2829
+ continue;
2830
+ }
2831
+ const model = {
2832
+ direction: "minimize",
2833
+ objective: "price",
2834
+ integers: true,
2835
+ constraints: {
2836
+ size: {
2837
+ min: scaledQuantity.type === "fixed" ? getNumericValue(scaledQuantity.value) : getNumericValue(scaledQuantity.min)
2838
+ }
2839
+ },
2840
+ variables: matchOptions.reduce(
2841
+ (acc, option) => {
2842
+ const compatibleSize = findCompatibleSize(option);
2843
+ acc[option.id] = {
2844
+ price: option.price,
2845
+ size: getNumericValue(compatibleSize.size.value)
2846
+ };
2847
+ return acc;
2848
+ },
2849
+ {}
2850
+ )
2851
+ };
2852
+ const solution = solve(model);
2853
+ solutions.push(
2854
+ solution.variables.map((variable) => {
2855
+ const resProductSelection = {
2856
+ product: options.find((option) => option.id === variable[0]),
2857
+ quantity: variable[1]
2858
+ };
2859
+ return {
2860
+ ...resProductSelection,
2861
+ totalPrice: resProductSelection.quantity * resProductSelection.product.price
2862
+ };
2863
+ })
2864
+ );
2865
+ } else {
2866
+ errors.add("incompatibleUnits");
2867
+ }
2868
+ }
2869
+ if (solutions.length === 0) {
2870
+ throw new NoProductMatchError(
2871
+ ingredient.name,
2872
+ errors.size === 1 ? errors.values().next().value : "textValue_incompatibleUnits"
2873
+ );
2874
+ } else {
2875
+ return solutions.sort(
2876
+ (a2, b) => a2.reduce((acc, item) => acc + item.totalPrice, 0) - b.reduce((acc, item) => acc + item.totalPrice, 0)
2877
+ )[0];
2878
+ }
2879
+ }
2880
+ return selection;
2881
+ }
2882
+ return getOptimumMatchForQuantityParts(
2883
+ normalizedQuantityTotal,
2884
+ normalizedOptions
2885
+ );
2886
+ }
2887
+ /**
2888
+ * Reset the cart's properties
2889
+ */
2890
+ resetCart() {
2891
+ this.cart = [];
2892
+ this.match = [];
2893
+ this.misMatch = [];
2894
+ this.summary = { totalPrice: 0, totalItems: 0 };
2895
+ }
2896
+ /**
2897
+ * Calculate the cart's key info and store it in the cart's {@link ShoppingCart.summary | summary} property.
2898
+ * This function is automatically invoked by {@link ShoppingCart.buildCart | buildCart() } method.
2899
+ * @returns the total price and number of items in the cart
2900
+ */
2901
+ summarize() {
2902
+ this.summary.totalPrice = this.cart.reduce(
2903
+ (acc, item) => acc + item.totalPrice,
2904
+ 0
2905
+ );
2906
+ this.summary.totalItems = this.cart.length;
2907
+ return this.summary;
2908
+ }
2909
+ };
1373
2910
  export {
1374
2911
  CategoryConfig,
2912
+ NoProductCatalogForCartError,
2913
+ NoShoppingListForCartError,
2914
+ ProductCatalog,
1375
2915
  Recipe,
1376
2916
  Section,
2917
+ ShoppingCart,
1377
2918
  ShoppingList
1378
2919
  };
1379
2920
  /* v8 ignore else -- @preserve */
1380
- /* v8 ignore else -- expliciting error types -- @preserve */
1381
2921
  /* v8 ignore else -- expliciting error type -- @preserve */
1382
- /* v8 ignore else -- only set unit if it is given -- @preserve */
2922
+ /* v8 ignore if -- @preserve */
1383
2923
  //# sourceMappingURL=index.js.map