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

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