@tmlmt/cooklang-parser 2.1.8 → 3.0.0-alpha.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -33,9 +33,27 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
33
33
  var index_exports = {};
34
34
  __export(index_exports, {
35
35
  CategoryConfig: () => CategoryConfig,
36
+ NoProductCatalogForCartError: () => NoProductCatalogForCartError,
37
+ NoShoppingListForCartError: () => NoShoppingListForCartError,
38
+ ProductCatalog: () => ProductCatalog,
36
39
  Recipe: () => Recipe,
37
40
  Section: () => Section,
38
- ShoppingList: () => ShoppingList
41
+ ShoppingCart: () => ShoppingCart,
42
+ ShoppingList: () => ShoppingList,
43
+ convertQuantityToSystem: () => convertQuantityToSystem,
44
+ formatExtendedQuantity: () => formatExtendedQuantity,
45
+ formatItemQuantity: () => formatItemQuantity,
46
+ formatNumericValue: () => formatNumericValue,
47
+ formatQuantity: () => formatQuantity,
48
+ formatQuantityWithUnit: () => formatQuantityWithUnit,
49
+ formatSingleValue: () => formatSingleValue,
50
+ formatUnit: () => formatUnit,
51
+ hasAlternatives: () => hasAlternatives,
52
+ isAlternativeSelected: () => isAlternativeSelected,
53
+ isAndGroup: () => isAndGroup,
54
+ isGroupedItem: () => isGroupedItem,
55
+ isSimpleGroup: () => isSimpleGroup,
56
+ renderFractionAsVulgar: () => renderFractionAsVulgar
39
57
  });
40
58
  module.exports = __toCommonJS(index_exports);
41
59
 
@@ -100,33 +118,10 @@ var CategoryConfig = class {
100
118
  }
101
119
  };
102
120
 
103
- // src/classes/section.ts
104
- var Section = class {
105
- /**
106
- * Creates an instance of Section.
107
- * @param name - The name of the section. Defaults to an empty string.
108
- */
109
- constructor(name = "") {
110
- /**
111
- * The name of the section. Can be an empty string for the default (first) section.
112
- * @defaultValue `""`
113
- */
114
- __publicField(this, "name");
115
- /** An array of steps and notes that make up the content of the section. */
116
- __publicField(this, "content", []);
117
- this.name = name;
118
- }
119
- /**
120
- * Checks if the section is blank (has no name and no content).
121
- * Used during recipe parsing
122
- * @returns `true` if the section is blank, otherwise `false`.
123
- */
124
- isBlank() {
125
- return this.name === "" && this.content.length === 0;
126
- }
127
- };
121
+ // src/classes/product_catalog.ts
122
+ var import_smol_toml = __toESM(require("smol-toml"), 1);
128
123
 
129
- // node_modules/.pnpm/human-regex@2.1.5_patch_hash=6d6bd9e233f99785a7c2187fd464edc114b76d47001dbb4eb6b5d72168de7460/node_modules/human-regex/dist/human-regex.esm.js
124
+ // node_modules/.pnpm/human-regex@2.2.0/node_modules/human-regex/dist/human-regex.esm.js
130
125
  var t = /* @__PURE__ */ new Map();
131
126
  var r = { GLOBAL: "g", NON_SENSITIVE: "i", MULTILINE: "m", DOT_ALL: "s", UNICODE: "u", STICKY: "y" };
132
127
  var e = Object.freeze({ digit: "0-9", lowercaseLetter: "a-z", uppercaseLetter: "A-Z", letter: "a-zA-Z", alphanumeric: "a-zA-Z0-9", anyCharacter: "." });
@@ -187,7 +182,7 @@ var a = class {
187
182
  return this.add(".");
188
183
  }
189
184
  newline() {
190
- return this.add("(?:\\r\\n|\\r|\\n)");
185
+ return this.add("(\\r\\n|\\r|\\n)");
191
186
  }
192
187
  negativeLookahead(t2) {
193
188
  return this.add(`(?!${t2})`);
@@ -328,19 +323,22 @@ var i = (() => {
328
323
  var metadataRegex = d().literal("---").newline().startCaptureGroup().anyCharacter().zeroOrMore().optional().endGroup().newline().literal("---").dotAll().toRegExp();
329
324
  var scalingMetaValueRegex = (varName) => d().startAnchor().literal(varName).literal(":").anyOf("\\t ").zeroOrMore().startCaptureGroup().startCaptureGroup().notAnyOf(",\\n").oneOrMore().endGroup().startGroup().literal(",").whitespace().zeroOrMore().startCaptureGroup().anyCharacter().oneOrMore().endGroup().endGroup().optional().endGroup().endAnchor().multiline().toRegExp();
330
325
  var nonWordChar = "\\s@#~\\[\\]{(,;:!?";
331
- var multiwordIngredient = d().literal("@").startNamedGroup("mIngredientModifiers").anyOf("@\\-&?").zeroOrMore().endGroup().optional().startNamedGroup("mIngredientRecipeAnchor").literal("./").endGroup().optional().startNamedGroup("mIngredientName").notAnyOf(nonWordChar).oneOrMore().startGroup().whitespace().oneOrMore().notAnyOf(nonWordChar).oneOrMore().endGroup().oneOrMore().notAnyOf("\\." + nonWordChar).endGroup().positiveLookahead("\\s*(?:\\{[^\\}]*\\}|\\([^)]*\\))").startGroup().literal("{").startNamedGroup("mIngredientQuantityModifier").literal("=").exactly(1).endGroup().optional().startNamedGroup("mIngredientQuantity").notAnyOf("}%").oneOrMore().endGroup().optional().startGroup().literal("%").startNamedGroup("mIngredientUnit").notAnyOf("}").oneOrMore().lazy().endGroup().endGroup().optional().literal("}").endGroup().optional().startGroup().literal("(").startNamedGroup("mIngredientPreparation").notAnyOf(")").oneOrMore().lazy().endGroup().literal(")").endGroup().optional().toRegExp();
332
- var singleWordIngredient = d().literal("@").startNamedGroup("sIngredientModifiers").anyOf("@\\-&?").zeroOrMore().endGroup().optional().startNamedGroup("sIngredientRecipeAnchor").literal("./").endGroup().optional().startNamedGroup("sIngredientName").notAnyOf(nonWordChar).zeroOrMore().notAnyOf("\\." + nonWordChar).endGroup().startGroup().literal("{").startNamedGroup("sIngredientQuantityModifier").literal("=").exactly(1).endGroup().optional().startNamedGroup("sIngredientQuantity").notAnyOf("}%").oneOrMore().endGroup().optional().startGroup().literal("%").startNamedGroup("sIngredientUnit").notAnyOf("}").oneOrMore().lazy().endGroup().endGroup().optional().literal("}").endGroup().optional().startGroup().literal("(").startNamedGroup("sIngredientPreparation").notAnyOf(")").oneOrMore().lazy().endGroup().literal(")").endGroup().optional().toRegExp();
326
+ var nonWordCharStrict = "\\s@#~\\[\\]{(,;:!?|";
327
+ 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();
328
+ var inlineIngredientAlternativesRegex = new RegExp("\\|" + ingredientWithAlternativeRegex.source.slice(1));
329
+ 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();
330
+ var ingredientWithGroupKeyRegex = d().literal("@|").startNamedGroup("gIngredientGroupKey").notAnyOf(nonWordCharStrict).oneOrMore().endGroup().literal("|").startNamedGroup("gIngredientModifiers").anyOf("@\\-&?").zeroOrMore().endGroup().optional().startNamedGroup("gIngredientRecipeAnchor").literal("./").endGroup().optional().startGroup().startGroup().startNamedGroup("gmIngredientName").notAnyOf(nonWordChar).oneOrMore().startGroup().whitespace().oneOrMore().notAnyOf(nonWordChar).oneOrMore().endGroup().oneOrMore().endGroup().positiveLookahead("\\s*(?:\\{[^\\}]*\\}|\\([^)]*\\))").endGroup().or().startNamedGroup("gsIngredientName").notAnyOf(nonWordChar).zeroOrMore().notAnyOf("\\." + nonWordChar).endGroup().endGroup().startGroup().literal("{").startNamedGroup("gIngredientQuantityModifier").literal("=").exactly(1).endGroup().optional().startNamedGroup("gIngredientQuantity").startGroup().notAnyOf("}|%").oneOrMore().endGroup().optional().startGroup().literal("%").notAnyOf("|}").oneOrMore().lazy().endGroup().optional().startGroup().literal("|").notAnyOf("}").oneOrMore().lazy().endGroup().zeroOrMore().endGroup().literal("}").endGroup().optional().startGroup().literal("(").startNamedGroup("gIngredientPreparation").notAnyOf(")").oneOrMore().lazy().endGroup().literal(")").endGroup().optional().toRegExp();
333
331
  var ingredientAliasRegex = d().startAnchor().startNamedGroup("ingredientListName").notAnyOf("|").oneOrMore().endGroup().literal("|").startNamedGroup("ingredientDisplayName").notAnyOf("|").oneOrMore().endGroup().endAnchor().toRegExp();
334
- var multiwordCookware = d().literal("#").startNamedGroup("mCookwareModifiers").anyOf("\\-&?").zeroOrMore().endGroup().startNamedGroup("mCookwareName").notAnyOf(nonWordChar).oneOrMore().startGroup().whitespace().oneOrMore().notAnyOf(nonWordChar).oneOrMore().endGroup().oneOrMore().notAnyOf("\\." + nonWordChar).endGroup().positiveLookahead("\\s*(?:\\{[^\\}]*\\})").literal("{").startNamedGroup("mCookwareQuantity").anyCharacter().zeroOrMore().lazy().endGroup().literal("}").toRegExp();
335
- var singleWordCookware = d().literal("#").startNamedGroup("sCookwareModifiers").anyOf("\\-&?").zeroOrMore().endGroup().startNamedGroup("sCookwareName").notAnyOf(nonWordChar).zeroOrMore().notAnyOf("\\." + nonWordChar).endGroup().startGroup().literal("{").startNamedGroup("sCookwareQuantity").anyCharacter().zeroOrMore().lazy().endGroup().literal("}").endGroup().optional().toRegExp();
336
- var timer = d().literal("~").startNamedGroup("timerName").anyCharacter().zeroOrMore().lazy().endGroup().literal("{").startNamedGroup("timerQuantity").anyCharacter().oneOrMore().lazy().endGroup().startGroup().literal("%").startNamedGroup("timerUnit").anyCharacter().oneOrMore().lazy().endGroup().endGroup().optional().literal("}").toRegExp();
332
+ 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();
333
+ 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();
334
+ var arbitraryScalableRegex = d().literal("{{").startGroup().startNamedGroup("arbitraryName").notAnyOf("}:%").oneOrMore().endGroup().literal(":").endGroup().optional().startNamedGroup("arbitraryQuantity").startGroup().notAnyOf("}|%").oneOrMore().endGroup().optional().startGroup().literal("%").notAnyOf("|}").oneOrMore().lazy().endGroup().optional().startGroup().literal("|").notAnyOf("}").oneOrMore().lazy().endGroup().zeroOrMore().endGroup().literal("}}").toRegExp();
337
335
  var tokensRegex = new RegExp(
338
336
  [
339
- multiwordIngredient,
340
- singleWordIngredient,
341
- multiwordCookware,
342
- singleWordCookware,
343
- timer
337
+ ingredientWithGroupKeyRegex,
338
+ ingredientWithAlternativeRegex,
339
+ cookwareRegex,
340
+ timerRegex,
341
+ arbitraryScalableRegex
344
342
  ].map((r2) => r2.source).join("|"),
345
343
  "gu"
346
344
  );
@@ -351,8 +349,7 @@ var rangeRegex = d().startAnchor().digit().oneOrMore().startGroup().anyOf(".,/")
351
349
  var numberLikeRegex = d().startAnchor().digit().oneOrMore().startGroup().anyOf(".,/").exactly(1).digit().oneOrMore().endGroup().optional().endAnchor().toRegExp();
352
350
  var floatRegex = d().startAnchor().digit().oneOrMore().startGroup().anyOf(".").exactly(1).digit().oneOrMore().endGroup().optional().endAnchor().toRegExp();
353
351
 
354
- // src/units.ts
355
- var import_big = __toESM(require("big.js"), 1);
352
+ // src/units/definitions.ts
356
353
  var units = [
357
354
  // Mass (Metric)
358
355
  {
@@ -360,7 +357,8 @@ var units = [
360
357
  type: "mass",
361
358
  system: "metric",
362
359
  aliases: ["gram", "grams", "grammes"],
363
- toBase: 1
360
+ toBase: 1,
361
+ maxValue: 999
364
362
  },
365
363
  {
366
364
  name: "kg",
@@ -369,20 +367,28 @@ var units = [
369
367
  aliases: ["kilogram", "kilograms", "kilogrammes", "kilos", "kilo"],
370
368
  toBase: 1e3
371
369
  },
372
- // Mass (Imperial)
370
+ // Mass (US/UK - identical in both systems)
373
371
  {
374
372
  name: "oz",
375
373
  type: "mass",
376
- system: "imperial",
374
+ system: "ambiguous",
377
375
  aliases: ["ounce", "ounces"],
378
- toBase: 28.3495
376
+ toBase: 28.3495,
377
+ // default: US (same as UK)
378
+ toBaseBySystem: { US: 28.3495, UK: 28.3495 },
379
+ maxValue: 31,
380
+ // 16 oz = 1 lb, allow a bit more
381
+ fractions: { enabled: true, denominators: [2] }
379
382
  },
380
383
  {
381
384
  name: "lb",
382
385
  type: "mass",
383
- system: "imperial",
386
+ system: "ambiguous",
384
387
  aliases: ["pound", "pounds"],
385
- toBase: 453.592
388
+ toBase: 453.592,
389
+ // default: US (same as UK)
390
+ toBaseBySystem: { US: 453.592, UK: 453.592 },
391
+ fractions: { enabled: true, denominators: [2, 4] }
386
392
  },
387
393
  // Volume (Metric)
388
394
  {
@@ -390,21 +396,26 @@ var units = [
390
396
  type: "volume",
391
397
  system: "metric",
392
398
  aliases: ["milliliter", "milliliters", "millilitre", "millilitres", "cc"],
393
- toBase: 1
399
+ toBase: 1,
400
+ maxValue: 999
394
401
  },
395
402
  {
396
403
  name: "cl",
397
404
  type: "volume",
398
405
  system: "metric",
399
406
  aliases: ["centiliter", "centiliters", "centilitre", "centilitres"],
400
- toBase: 10
407
+ toBase: 10,
408
+ isBestUnit: false
409
+ // exists but not a "best" candidate
401
410
  },
402
411
  {
403
412
  name: "dl",
404
413
  type: "volume",
405
414
  system: "metric",
406
415
  aliases: ["deciliter", "deciliters", "decilitre", "decilitres"],
407
- toBase: 100
416
+ toBase: 100,
417
+ isBestUnit: false
418
+ // exists but not a "best" candidate
408
419
  },
409
420
  {
410
421
  name: "l",
@@ -413,55 +424,102 @@ var units = [
413
424
  aliases: ["liter", "liters", "litre", "litres"],
414
425
  toBase: 1e3
415
426
  },
427
+ // Volume (JP)
428
+ {
429
+ name: "go",
430
+ type: "volume",
431
+ system: "JP",
432
+ aliases: ["gou", "goo", "\u5408", "rice cup"],
433
+ toBase: 180,
434
+ maxValue: 10
435
+ },
436
+ // Volume (Ambiguous: metric/US/UK)
416
437
  {
417
438
  name: "tsp",
418
439
  type: "volume",
419
- system: "metric",
440
+ system: "ambiguous",
420
441
  aliases: ["teaspoon", "teaspoons"],
421
- toBase: 5
442
+ toBase: 5,
443
+ // default: metric
444
+ toBaseBySystem: { metric: 5, US: 4.929, UK: 5.919, JP: 5 },
445
+ maxValue: 5,
446
+ // 3 tsp = 1 tbsp (but allow a bit more)
447
+ fractions: { enabled: true, denominators: [2, 3, 4, 8] }
422
448
  },
423
449
  {
424
450
  name: "tbsp",
425
451
  type: "volume",
426
- system: "metric",
452
+ system: "ambiguous",
427
453
  aliases: ["tablespoon", "tablespoons"],
428
- toBase: 15
454
+ toBase: 15,
455
+ // default: metric
456
+ toBaseBySystem: { metric: 15, US: 14.787, UK: 17.758, JP: 15 },
457
+ maxValue: 4,
458
+ // ~16 tbsp = 1 cup
459
+ fractions: { enabled: true }
429
460
  },
430
- // Volume (Imperial)
461
+ // Volume (Ambiguous: US/UK only)
431
462
  {
432
463
  name: "fl-oz",
433
464
  type: "volume",
434
- system: "imperial",
465
+ system: "ambiguous",
435
466
  aliases: ["fluid ounce", "fluid ounces"],
436
- toBase: 29.5735
467
+ toBase: 29.5735,
468
+ // default: US
469
+ toBaseBySystem: { US: 29.5735, UK: 28.4131 },
470
+ maxValue: 15,
471
+ // 8 fl-oz ~ 1 cup, allow more
472
+ fractions: { enabled: true, denominators: [2] }
437
473
  },
438
474
  {
439
475
  name: "cup",
440
476
  type: "volume",
441
- system: "imperial",
477
+ system: "ambiguous",
442
478
  aliases: ["cups"],
443
- toBase: 236.588
479
+ toBase: 236.588,
480
+ // default: US
481
+ toBaseBySystem: { US: 236.588, UK: 284.131 },
482
+ maxValue: 15,
483
+ // upgrade to gallons above 15 cups
484
+ fractions: { enabled: true }
444
485
  },
445
486
  {
446
487
  name: "pint",
447
488
  type: "volume",
448
- system: "imperial",
489
+ system: "ambiguous",
449
490
  aliases: ["pints"],
450
- toBase: 473.176
491
+ toBase: 473.176,
492
+ // default: US
493
+ toBaseBySystem: { US: 473.176, UK: 568.261 },
494
+ maxValue: 3,
495
+ // 2 pints = 1 quart
496
+ fractions: { enabled: true, denominators: [2] },
497
+ isBestUnit: false
498
+ // exists but not a "best" candidate
451
499
  },
452
500
  {
453
501
  name: "quart",
454
502
  type: "volume",
455
- system: "imperial",
503
+ system: "ambiguous",
456
504
  aliases: ["quarts"],
457
- toBase: 946.353
505
+ toBase: 946.353,
506
+ // default: US
507
+ toBaseBySystem: { US: 946.353, UK: 1136.52 },
508
+ maxValue: 3,
509
+ // 4 quarts = 1 gallon
510
+ fractions: { enabled: true, denominators: [2] },
511
+ isBestUnit: false
512
+ // exists but not a "best" candidate
458
513
  },
459
514
  {
460
515
  name: "gallon",
461
516
  type: "volume",
462
- system: "imperial",
517
+ system: "ambiguous",
463
518
  aliases: ["gallons"],
464
- toBase: 3785.41
519
+ toBase: 3785.41,
520
+ // default: US
521
+ toBaseBySystem: { US: 3785.41, UK: 4546.09 },
522
+ fractions: { enabled: true, denominators: [2] }
465
523
  },
466
524
  // Count units (no conversion, but recognized as a type)
467
525
  {
@@ -469,7 +527,8 @@ var units = [
469
527
  type: "count",
470
528
  system: "metric",
471
529
  aliases: ["pieces", "pc"],
472
- toBase: 1
530
+ toBase: 1,
531
+ maxValue: 999
473
532
  }
474
533
  ];
475
534
  var unitMap = /* @__PURE__ */ new Map();
@@ -482,20 +541,25 @@ for (const unit of units) {
482
541
  function normalizeUnit(unit = "") {
483
542
  return unitMap.get(unit.toLowerCase().trim());
484
543
  }
485
- var CannotAddTextValueError = class extends Error {
486
- constructor() {
487
- super("Cannot add a quantity with a text value.");
488
- this.name = "CannotAddTextValueError";
489
- }
490
- };
491
- var IncompatibleUnitsError = class extends Error {
492
- constructor(unit1, unit2) {
493
- super(
494
- `Cannot add quantities with incompatible or unknown units: ${unit1} and ${unit2}`
495
- );
496
- this.name = "IncompatibleUnitsError";
497
- }
498
- };
544
+ var NO_UNIT = "__no-unit__";
545
+ function resolveUnit(name = NO_UNIT, integerProtected = false) {
546
+ const normalizedUnit = normalizeUnit(name);
547
+ const resolvedUnit = normalizedUnit ? { ...normalizedUnit, name } : { name, type: "other", system: "none" };
548
+ return integerProtected ? { ...resolvedUnit, integerProtected: true } : resolvedUnit;
549
+ }
550
+ function isNoUnit(unit) {
551
+ if (!unit) return true;
552
+ return resolveUnit(unit.name).name === NO_UNIT;
553
+ }
554
+
555
+ // src/units/conversion.ts
556
+ var import_big2 = __toESM(require("big.js"), 1);
557
+
558
+ // src/quantities/numeric.ts
559
+ var import_big = __toESM(require("big.js"), 1);
560
+ var DEFAULT_DENOMINATORS = [2, 3, 4];
561
+ var DEFAULT_FRACTION_ACCURACY = 0.05;
562
+ var DEFAULT_MAX_WHOLE = 4;
499
563
  function gcd(a2, b) {
500
564
  return b === 0 ? a2 : gcd(b, a2 % b);
501
565
  }
@@ -511,14 +575,58 @@ function simplifyFraction(num, den) {
511
575
  simplifiedDen = -simplifiedDen;
512
576
  }
513
577
  if (simplifiedDen === 1) {
514
- return { type: "decimal", value: simplifiedNum };
578
+ return { type: "decimal", decimal: simplifiedNum };
515
579
  } else {
516
580
  return { type: "fraction", num: simplifiedNum, den: simplifiedDen };
517
581
  }
518
582
  }
583
+ function approximateFraction(value, denominators = DEFAULT_DENOMINATORS, accuracy = DEFAULT_FRACTION_ACCURACY, maxWhole = DEFAULT_MAX_WHOLE) {
584
+ if (value <= 0 || !Number.isFinite(value)) {
585
+ return null;
586
+ }
587
+ const wholePart = Math.floor(value);
588
+ if (wholePart > maxWhole) {
589
+ return null;
590
+ }
591
+ const fractionalPart = value - wholePart;
592
+ if (fractionalPart < 1e-4) {
593
+ return null;
594
+ }
595
+ let bestFraction = null;
596
+ for (const den of denominators) {
597
+ const exactNum = value * den;
598
+ const roundedNum = Math.round(exactNum);
599
+ if (roundedNum === 0) continue;
600
+ const approximatedValue = roundedNum / den;
601
+ const relativeError = Math.abs(approximatedValue - value) / value;
602
+ if (relativeError <= accuracy) {
603
+ if (!bestFraction || relativeError < bestFraction.error) {
604
+ bestFraction = { num: roundedNum, den, error: relativeError };
605
+ }
606
+ }
607
+ }
608
+ if (!bestFraction) {
609
+ return null;
610
+ }
611
+ const commonDivisor = gcd(bestFraction.num, bestFraction.den);
612
+ return {
613
+ type: "fraction",
614
+ num: bestFraction.num / commonDivisor,
615
+ den: bestFraction.den / commonDivisor
616
+ };
617
+ }
618
+ function getNumericValue(v) {
619
+ if (v.type === "decimal") {
620
+ return v.decimal;
621
+ }
622
+ return v.num / v.den;
623
+ }
519
624
  function multiplyNumericValue(v, factor) {
520
625
  if (v.type === "decimal") {
521
- return { type: "decimal", value: (0, import_big.default)(v.value).times(factor).toNumber() };
626
+ return {
627
+ type: "decimal",
628
+ decimal: (0, import_big.default)(v.decimal).times(factor).toNumber()
629
+ };
522
630
  }
523
631
  return simplifyFraction((0, import_big.default)(v.num).times(factor).toNumber(), v.den);
524
632
  }
@@ -528,36 +636,62 @@ function addNumericValues(val1, val2) {
528
636
  let num2;
529
637
  let den2;
530
638
  if (val1.type === "decimal") {
531
- num1 = val1.value;
639
+ num1 = val1.decimal;
532
640
  den1 = 1;
533
641
  } else {
534
642
  num1 = val1.num;
535
643
  den1 = val1.den;
536
644
  }
537
645
  if (val2.type === "decimal") {
538
- num2 = val2.value;
646
+ num2 = val2.decimal;
539
647
  den2 = 1;
540
648
  } else {
541
649
  num2 = val2.num;
542
650
  den2 = val2.den;
543
651
  }
544
652
  if (num1 === 0 && num2 === 0) {
545
- return { type: "decimal", value: 0 };
653
+ return { type: "decimal", decimal: 0 };
546
654
  }
547
- if (val1.type === "fraction" && val2.type === "fraction" || val1.type === "fraction" && val2.type === "decimal" && val2.value === 0 || val2.type === "fraction" && val1.type === "decimal" && val1.value === 0) {
655
+ if (val1.type === "fraction" && val2.type === "fraction" || val1.type === "fraction" && val2.type === "decimal" && val2.decimal === 0 || val2.type === "fraction" && val1.type === "decimal" && val1.decimal === 0) {
548
656
  const commonDen = den1 * den2;
549
657
  const sumNum = num1 * den2 + num2 * den1;
550
658
  return simplifyFraction(sumNum, commonDen);
551
659
  } else {
552
660
  return {
553
661
  type: "decimal",
554
- value: (0, import_big.default)(num1).div(den1).add((0, import_big.default)(num2).div(den2)).toNumber()
662
+ decimal: (0, import_big.default)(num1).div(den1).add((0, import_big.default)(num2).div(den2)).toNumber()
555
663
  };
556
664
  }
557
665
  }
558
- var toRoundedDecimal = (v) => {
559
- const value = v.type === "decimal" ? v.value : v.num / v.den;
560
- return { type: "decimal", value: Math.floor(value * 100) / 100 };
666
+ var toRoundedDecimal = (v, precision = 3) => {
667
+ const value = v.type === "decimal" ? v.decimal : v.num / v.den;
668
+ if (value === 0) {
669
+ return { type: "decimal", decimal: 0 };
670
+ }
671
+ const absValue = Math.abs(value);
672
+ if (absValue >= 1e3) {
673
+ return { type: "decimal", decimal: Math.round(value) };
674
+ }
675
+ const magnitude = Math.floor(Math.log10(absValue));
676
+ const scale = Math.pow(10, precision - 1 - magnitude);
677
+ const rounded = Math.round(value * scale) / scale;
678
+ return { type: "decimal", decimal: rounded };
679
+ };
680
+ var formatOutputValue = (value, unitDef, precision = 3) => {
681
+ if (unitDef.fractions?.enabled) {
682
+ const denominators = unitDef.fractions.denominators ?? DEFAULT_DENOMINATORS;
683
+ const maxWhole = unitDef.fractions.maxWhole ?? DEFAULT_MAX_WHOLE;
684
+ const fraction = approximateFraction(
685
+ value,
686
+ denominators,
687
+ DEFAULT_FRACTION_ACCURACY,
688
+ maxWhole
689
+ );
690
+ if (fraction) {
691
+ return fraction;
692
+ }
693
+ }
694
+ return toRoundedDecimal({ type: "decimal", decimal: value }, precision);
561
695
  };
562
696
  function multiplyQuantityValue(value, factor) {
563
697
  if (value.type === "fixed") {
@@ -565,8 +699,8 @@ function multiplyQuantityValue(value, factor) {
565
699
  value.value,
566
700
  (0, import_big.default)(factor)
567
701
  );
568
- if (factor === parseInt(factor.toString()) || // e.g. 2 === int
569
- (0, import_big.default)(1).div(factor).toNumber() === parseInt((0, import_big.default)(1).div(factor).toString())) {
702
+ if (newValue.type === "fraction" && ((0, import_big.default)(factor).toNumber() === parseInt((0, import_big.default)(factor).toString()) || // e.g. 2 === int
703
+ (0, import_big.default)(1).div(factor).toNumber() === parseInt((0, import_big.default)(1).div(factor).toString()))) {
570
704
  return {
571
705
  type: "fixed",
572
706
  value: newValue
@@ -583,13 +717,291 @@ function multiplyQuantityValue(value, factor) {
583
717
  max: multiplyNumericValue(value.max, factor)
584
718
  };
585
719
  }
586
- var convertQuantityValue = (value, def, targetDef) => {
587
- if (def.name === targetDef.name) return value;
588
- const factor = def.toBase / targetDef.toBase;
589
- return multiplyQuantityValue(value, factor);
720
+ function getAverageValue(q) {
721
+ if (q.type === "fixed") {
722
+ return q.value.type === "text" ? q.value.text : getNumericValue(q.value);
723
+ } else {
724
+ return (getNumericValue(q.min) + getNumericValue(q.max)) / 2;
725
+ }
726
+ }
727
+
728
+ // src/units/compatibility.ts
729
+ function areUnitsGroupable(u1, u2) {
730
+ if (u1.name === u2.name) {
731
+ return true;
732
+ }
733
+ if (u1.type === "other" || u2.type === "other") {
734
+ return false;
735
+ }
736
+ if (u1.type === u2.type && u1.system === u2.system) {
737
+ return true;
738
+ }
739
+ if (u1.type === u2.type) {
740
+ if (u1.system === "ambiguous" && u2.system === "metric" && u1.toBaseBySystem?.metric !== void 0) {
741
+ return true;
742
+ }
743
+ if (u2.system === "ambiguous" && u1.system === "metric" && u2.toBaseBySystem?.metric !== void 0) {
744
+ return true;
745
+ }
746
+ }
747
+ return false;
748
+ }
749
+ function areUnitsConvertible(u1, u2) {
750
+ if (u1.name === u2.name) return true;
751
+ if (u1.type === "other" || u2.type === "other") return false;
752
+ return u1.type === u2.type;
753
+ }
754
+ function isUnitCompatibleWithSystem(unit, system) {
755
+ if (unit.system === system) return true;
756
+ if (unit.system === "ambiguous") {
757
+ if (unit.toBaseBySystem) {
758
+ return system in unit.toBaseBySystem;
759
+ }
760
+ if (system === "metric") return true;
761
+ }
762
+ if (unit.system === "metric" && system === "JP") {
763
+ return true;
764
+ }
765
+ return false;
766
+ }
767
+
768
+ // src/units/conversion.ts
769
+ var EPSILON = 0.01;
770
+ var DEFAULT_MAX_VALUE = 999;
771
+ function isCloseToInteger(value) {
772
+ return Math.abs(value - Math.round(value)) < EPSILON;
773
+ }
774
+ function getMaxValue(unit) {
775
+ return unit.maxValue ?? DEFAULT_MAX_VALUE;
776
+ }
777
+ function isValueInRange(value, unit) {
778
+ const maxValue = getMaxValue(unit);
779
+ if (value >= 1 && value <= maxValue) {
780
+ return true;
781
+ }
782
+ if (value > 0 && value < 1 && unit.fractions?.enabled) {
783
+ const denominators = unit.fractions.denominators ?? DEFAULT_DENOMINATORS;
784
+ const maxWhole = unit.fractions.maxWhole ?? DEFAULT_MAX_WHOLE;
785
+ const fraction = approximateFraction(
786
+ value,
787
+ denominators,
788
+ DEFAULT_FRACTION_ACCURACY,
789
+ maxWhole
790
+ );
791
+ return fraction !== null;
792
+ }
793
+ return false;
794
+ }
795
+ function findBestUnit(valueInBase, unitType, system, inputUnits) {
796
+ const inputUnitNames = new Set(inputUnits.map((u) => u.name));
797
+ const candidates = units.filter(
798
+ (u) => u.type === unitType && isUnitCompatibleWithSystem(u, system) && (u.isBestUnit !== false || inputUnitNames.has(u.name))
799
+ );
800
+ if (candidates.length === 0) {
801
+ const fallbackUnit = inputUnits[0];
802
+ return {
803
+ unit: fallbackUnit,
804
+ value: valueInBase / getToBase(fallbackUnit, system)
805
+ };
806
+ }
807
+ const candidatesWithValues = candidates.map((unit) => ({
808
+ unit,
809
+ value: valueInBase / getToBase(unit, system)
810
+ }));
811
+ const inRange = candidatesWithValues.filter(
812
+ (c) => isValueInRange(c.value, c.unit)
813
+ );
814
+ if (inRange.length > 0) {
815
+ const integersInInputFamily = inRange.filter(
816
+ (c) => isCloseToInteger(c.value) && inputUnitNames.has(c.unit.name)
817
+ );
818
+ if (integersInInputFamily.length > 0) {
819
+ return integersInInputFamily.sort((a2, b) => a2.value - b.value)[0];
820
+ }
821
+ const integersAny = inRange.filter((c) => isCloseToInteger(c.value));
822
+ if (integersAny.length > 0) {
823
+ return integersAny.sort((a2, b) => a2.value - b.value)[0];
824
+ }
825
+ return inRange.sort((a2, b) => {
826
+ const aInFamily = inputUnitNames.has(a2.unit.name) ? 0 : 1;
827
+ const bInFamily = inputUnitNames.has(b.unit.name) ? 0 : 1;
828
+ if (aInFamily !== bInFamily) return aInFamily - bInFamily;
829
+ return a2.value - b.value;
830
+ })[0];
831
+ }
832
+ return candidatesWithValues.sort((a2, b) => {
833
+ const aMaxValue = getMaxValue(a2.unit);
834
+ const bMaxValue = getMaxValue(b.unit);
835
+ const aDistance = a2.value < 1 ? 1 - a2.value : a2.value - aMaxValue;
836
+ const bDistance = b.value < 1 ? 1 - b.value : b.value - bMaxValue;
837
+ return aDistance - bDistance;
838
+ })[0];
839
+ }
840
+ function getUnitRatio(q1, q2) {
841
+ const q1Value = getAverageValue(q1.quantity);
842
+ const q2Value = getAverageValue(q2.quantity);
843
+ const factor = "toBase" in q1.unit && "toBase" in q2.unit ? q1.unit.toBase / q2.unit.toBase : 1;
844
+ if (typeof q1Value !== "number" || typeof q2Value !== "number") {
845
+ throw Error(
846
+ "One of both values is not a number, so a ratio cannot be computed"
847
+ );
848
+ }
849
+ return (0, import_big2.default)(q1Value).times(factor).div(q2Value);
850
+ }
851
+ function getBaseUnitRatio(q, qRef) {
852
+ if ("toBase" in q.unit && "toBase" in qRef.unit) {
853
+ return q.unit.toBase / qRef.unit.toBase;
854
+ } else {
855
+ return 1;
856
+ }
857
+ }
858
+ function getToBase(unit, system) {
859
+ if (unit.system === "ambiguous" && system && unit.toBaseBySystem) {
860
+ return unit.toBaseBySystem[system] ?? unit.toBase;
861
+ }
862
+ return unit.toBase;
863
+ }
864
+
865
+ // src/errors.ts
866
+ var ReferencedItemCannotBeRedefinedError = class extends Error {
867
+ constructor(item_type, item_name, new_modifier) {
868
+ super(
869
+ `The referenced ${item_type} "${item_name}" cannot be redefined as ${new_modifier}.
870
+ 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}`
871
+ );
872
+ this.name = "ReferencedItemCannotBeRedefinedError";
873
+ }
874
+ };
875
+ var NoProductCatalogForCartError = class extends Error {
876
+ constructor() {
877
+ super(
878
+ `Cannot build a cart without a product catalog. Please set one using setProductCatalog()`
879
+ );
880
+ this.name = "NoProductCatalogForCartError";
881
+ }
882
+ };
883
+ var NoShoppingListForCartError = class extends Error {
884
+ constructor() {
885
+ super(
886
+ `Cannot build a cart without a shopping list. Please set one using setShoppingList()`
887
+ );
888
+ this.name = "NoShoppingListForCartError";
889
+ }
890
+ };
891
+ var NoProductMatchError = class extends Error {
892
+ constructor(item_name, code) {
893
+ const messageMap = {
894
+ incompatibleUnits: `The units of the products in the catalogue are incompatible with ingredient ${item_name} in the shopping list.`,
895
+ noProduct: "No product was found linked to ingredient name ${item_name} in the shopping list",
896
+ textValue: `Ingredient ${item_name} has a text value as quantity and can therefore not be matched with any product in the catalogue.`,
897
+ noQuantity: `Ingredient ${item_name} has no quantity and can therefore not be matched with any product in the catalogue.`,
898
+ 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`
899
+ };
900
+ super(messageMap[code]);
901
+ __publicField(this, "code");
902
+ this.code = code;
903
+ this.name = "NoProductMatchError";
904
+ }
905
+ };
906
+ var InvalidProductCatalogFormat = class extends Error {
907
+ constructor() {
908
+ super("Invalid product catalog format.");
909
+ this.name = "InvalidProductCatalogFormat";
910
+ }
911
+ };
912
+ var CannotAddTextValueError = class extends Error {
913
+ constructor() {
914
+ super("Cannot add a quantity with a text value.");
915
+ this.name = "CannotAddTextValueError";
916
+ }
917
+ };
918
+ var IncompatibleUnitsError = class extends Error {
919
+ constructor(unit1, unit2) {
920
+ super(
921
+ `Cannot add quantities with incompatible or unknown units: ${unit1} and ${unit2}`
922
+ );
923
+ this.name = "IncompatibleUnitsError";
924
+ }
925
+ };
926
+ var InvalidQuantityFormat = class extends Error {
927
+ constructor(value, extra) {
928
+ super(
929
+ `Invalid quantity format found in: ${value}${extra ? ` (${extra})` : ""}`
930
+ );
931
+ this.name = "InvalidQuantityFormat";
932
+ }
590
933
  };
934
+
935
+ // src/utils/type_guards.ts
936
+ function isGroup(x) {
937
+ return "and" in x || "or" in x;
938
+ }
939
+ function isOrGroup(x) {
940
+ return isGroup(x) && "or" in x;
941
+ }
942
+ function isAndGroup(x) {
943
+ return "and" in x;
944
+ }
945
+ function isQuantity(x) {
946
+ return x && typeof x === "object" && "quantity" in x;
947
+ }
948
+ function isSimpleGroup(entry) {
949
+ return "quantity" in entry;
950
+ }
951
+ function isNumericValueIntegerLike(v) {
952
+ if (v.type === "decimal") return Number.isInteger(v.decimal);
953
+ return v.num % v.den === 0;
954
+ }
955
+ function isValueIntegerLike(q) {
956
+ if (q.type === "fixed") {
957
+ if (q.value.type === "text") return false;
958
+ return isNumericValueIntegerLike(q.value);
959
+ }
960
+ return isNumericValueIntegerLike(q.min) && isNumericValueIntegerLike(q.max);
961
+ }
962
+ function hasAlternatives(entry) {
963
+ return "alternatives" in entry && Array.isArray(entry.alternatives) && entry.alternatives.length > 0;
964
+ }
965
+
966
+ // src/quantities/mutations.ts
967
+ function extendAllUnits(q) {
968
+ if (isAndGroup(q)) {
969
+ return { and: q.and.map(extendAllUnits) };
970
+ } else if (isOrGroup(q)) {
971
+ return { or: q.or.map(extendAllUnits) };
972
+ } else {
973
+ const newQ = {
974
+ quantity: q.quantity
975
+ };
976
+ if (q.unit) {
977
+ newQ.unit = { name: q.unit };
978
+ }
979
+ return newQ;
980
+ }
981
+ }
982
+ function normalizeAllUnits(q) {
983
+ if (isAndGroup(q)) {
984
+ return { and: q.and.map(normalizeAllUnits) };
985
+ } else if (isOrGroup(q)) {
986
+ return { or: q.or.map(normalizeAllUnits) };
987
+ } else {
988
+ const newQ = {
989
+ quantity: q.quantity,
990
+ unit: resolveUnit(q.unit)
991
+ };
992
+ if (q.equivalents && q.equivalents.length > 0) {
993
+ const equivalentsNormalized = q.equivalents.map(
994
+ (eq) => normalizeAllUnits(eq)
995
+ );
996
+ return {
997
+ or: [newQ, ...equivalentsNormalized]
998
+ };
999
+ }
1000
+ return newQ;
1001
+ }
1002
+ }
591
1003
  function getDefaultQuantityValue() {
592
- return { type: "fixed", value: { type: "decimal", value: 0 } };
1004
+ return { type: "fixed", value: { type: "decimal", decimal: 0 } };
593
1005
  }
594
1006
  function addQuantityValues(v1, v2) {
595
1007
  if (v1.type === "fixed" && v1.value.type === "text" || v2.type === "fixed" && v2.value.type === "text") {
@@ -614,201 +1026,406 @@ function addQuantityValues(v1, v2) {
614
1026
  );
615
1027
  return { type: "range", min: newMin, max: newMax };
616
1028
  }
617
- function addQuantities(q1, q2) {
618
- const v1 = q1.value;
619
- const v2 = q2.value;
1029
+ function addQuantities(q1, q2, system) {
1030
+ const v1 = q1.quantity;
1031
+ const v2 = q2.quantity;
620
1032
  if (v1.type === "fixed" && v1.value.type === "text" || v2.type === "fixed" && v2.value.type === "text") {
621
1033
  throw new CannotAddTextValueError();
622
1034
  }
623
- const unit1Def = normalizeUnit(q1.unit);
624
- const unit2Def = normalizeUnit(q2.unit);
625
- const addQuantityValuesAndSetUnit = (val1, val2, unit) => ({ value: addQuantityValues(val1, val2), unit });
626
- if ((q1.unit === "" || q1.unit === void 0) && q2.unit !== void 0) {
1035
+ const unit1Def = normalizeUnit(q1.unit?.name);
1036
+ const unit2Def = normalizeUnit(q2.unit?.name);
1037
+ const addQuantityValuesAndSetUnit = (val1, val2, unit) => ({
1038
+ quantity: addQuantityValues(val1, val2),
1039
+ unit
1040
+ });
1041
+ if ((q1.unit?.name === "" || q1.unit === void 0) && q2.unit !== void 0) {
627
1042
  return addQuantityValuesAndSetUnit(v1, v2, q2.unit);
628
1043
  }
629
- if ((q2.unit === "" || q2.unit === void 0) && q1.unit !== void 0) {
1044
+ if ((q2.unit?.name === "" || q2.unit === void 0) && q1.unit !== void 0) {
630
1045
  return addQuantityValuesAndSetUnit(v1, v2, q1.unit);
631
1046
  }
632
- if (!q1.unit && !q2.unit || q1.unit && q2.unit && q1.unit.toLowerCase() === q2.unit.toLowerCase()) {
1047
+ if (!q1.unit && !q2.unit) {
633
1048
  return addQuantityValuesAndSetUnit(v1, v2, q1.unit);
634
1049
  }
635
- if (unit1Def && unit2Def) {
636
- if (unit1Def.type !== unit2Def.type) {
637
- throw new IncompatibleUnitsError(
638
- `${unit1Def.type} (${q1.unit})`,
639
- `${unit2Def.type} (${q2.unit})`
640
- );
1050
+ if (q1.unit && q2.unit && q1.unit.name.toLowerCase() === q2.unit.name.toLowerCase()) {
1051
+ if (unit1Def) {
1052
+ const effectiveSystem = system ?? (["metric", "JP"].includes(unit1Def.system) ? unit1Def.system : "US");
1053
+ return addAndFindBestUnit(v1, v2, unit1Def, unit1Def, effectiveSystem, [
1054
+ unit1Def
1055
+ ]);
641
1056
  }
642
- let targetUnitDef;
643
- if (unit1Def.system !== unit2Def.system) {
644
- const metricUnitDef = unit1Def.system === "metric" ? unit1Def : unit2Def;
645
- targetUnitDef = units.filter((u) => u.type === metricUnitDef.type && u.system === "metric").reduce(
646
- (prev, current) => prev.toBase > current.toBase ? prev : current
647
- );
648
- } else {
649
- targetUnitDef = unit1Def.toBase >= unit2Def.toBase ? unit1Def : unit2Def;
650
- }
651
- const convertedV1 = convertQuantityValue(v1, unit1Def, targetUnitDef);
652
- const convertedV2 = convertQuantityValue(v2, unit2Def, targetUnitDef);
653
- return addQuantityValuesAndSetUnit(
654
- convertedV1,
655
- convertedV2,
656
- targetUnitDef.name
657
- );
658
- }
659
- throw new IncompatibleUnitsError(q1.unit, q2.unit);
660
- }
661
-
662
- // src/errors.ts
663
- var ReferencedItemCannotBeRedefinedError = class extends Error {
664
- constructor(item_type, item_name, new_modifier) {
665
- super(
666
- `The referenced ${item_type} "${item_name}" cannot be redefined as ${new_modifier}.
667
- You can either remove the reference to create a new ${item_type} defined as ${new_modifier} or add the ${new_modifier} flag to the original definition of the ${item_type}`
668
- );
669
- this.name = "ReferencedItemCannotBeRedefinedError";
670
- }
671
- };
672
-
673
- // src/parser_helpers.ts
674
- function flushPendingNote(section, note) {
675
- if (note.length > 0) {
676
- section.content.push({ type: "note", note });
677
- return "";
678
- }
679
- return note;
680
- }
681
- function flushPendingItems(section, items) {
682
- if (items.length > 0) {
683
- section.content.push({ type: "step", items: [...items] });
684
- items.length = 0;
685
- return true;
1057
+ return addQuantityValuesAndSetUnit(v1, v2, q1.unit);
686
1058
  }
687
- return false;
688
- }
689
- function findAndUpsertIngredient(ingredients, newIngredient, isReference) {
690
- const { name, quantity, unit } = newIngredient;
691
- if (isReference) {
692
- const indexFind = ingredients.findIndex(
693
- (i2) => i2.name.toLowerCase() === name.toLowerCase()
694
- );
695
- if (indexFind === -1) {
696
- throw new Error(
697
- `Referenced ingredient "${name}" not found. A referenced ingredient must be declared before being referenced with '&'.`
1059
+ if (unit1Def && unit2Def) {
1060
+ if (!areUnitsConvertible(unit1Def, unit2Def)) {
1061
+ throw new IncompatibleUnitsError(
1062
+ `${unit1Def.type} (${q1.unit?.name})`,
1063
+ `${unit2Def.type} (${q2.unit?.name})`
698
1064
  );
699
1065
  }
700
- const existingIngredient = ingredients[indexFind];
701
- for (const flag of newIngredient.flags) {
702
- if (!existingIngredient.flags.includes(flag)) {
703
- throw new ReferencedItemCannotBeRedefinedError(
704
- "ingredient",
705
- existingIngredient.name,
706
- flag
707
- );
708
- }
709
- }
710
- let quantityPartIndex = void 0;
711
- if (quantity !== void 0) {
712
- const currentQuantity = {
713
- value: existingIngredient.quantity ?? getDefaultQuantityValue(),
714
- unit: existingIngredient.unit ?? ""
715
- };
716
- const newQuantity = { value: quantity, unit: unit ?? "" };
717
- try {
718
- const total = addQuantities(currentQuantity, newQuantity);
719
- existingIngredient.quantity = total.value;
720
- existingIngredient.unit = total.unit || void 0;
721
- if (existingIngredient.quantityParts) {
722
- existingIngredient.quantityParts.push(
723
- ...newIngredient.quantityParts
724
- );
1066
+ let effectiveSystem = system;
1067
+ if (!effectiveSystem) {
1068
+ if (unit1Def.system === "metric" || unit2Def.system === "metric") {
1069
+ effectiveSystem = "metric";
1070
+ } else {
1071
+ if (unit1Def.system === "JP" && unit2Def.system === "JP") {
1072
+ effectiveSystem = "JP";
725
1073
  } else {
726
- existingIngredient.quantityParts = newIngredient.quantityParts;
727
- }
728
- quantityPartIndex = existingIngredient.quantityParts.length - 1;
729
- } catch (e2) {
730
- if (e2 instanceof IncompatibleUnitsError || e2 instanceof CannotAddTextValueError) {
731
- return {
732
- ingredientIndex: ingredients.push(newIngredient) - 1,
733
- quantityPartIndex: 0
734
- };
1074
+ const unit1SupportsUS = unit1Def.system === "US" || unit1Def.system === "ambiguous" && unit1Def.toBaseBySystem && "US" in unit1Def.toBaseBySystem;
1075
+ const unit2SupportsUS = unit2Def.system === "US" || unit2Def.system === "ambiguous" && unit2Def.toBaseBySystem && "US" in unit2Def.toBaseBySystem;
1076
+ effectiveSystem = unit1SupportsUS && unit2SupportsUS ? "US" : "metric";
735
1077
  }
736
1078
  }
737
1079
  }
1080
+ return addAndFindBestUnit(v1, v2, unit1Def, unit2Def, effectiveSystem, [
1081
+ unit1Def,
1082
+ unit2Def
1083
+ ]);
1084
+ }
1085
+ throw new IncompatibleUnitsError(
1086
+ q1.unit?.name,
1087
+ q2.unit?.name
1088
+ );
1089
+ }
1090
+ function addAndFindBestUnit(v1, v2, unit1Def, unit2Def, system, inputUnits) {
1091
+ const toBase1 = getToBase(unit1Def, system);
1092
+ const toBase2 = getToBase(unit2Def, system);
1093
+ let sumInBase;
1094
+ if (v1.type === "fixed" && v2.type === "fixed") {
1095
+ const val1 = getNumericValue(v1.value);
1096
+ const val2 = getNumericValue(v2.value);
1097
+ sumInBase = val1 * toBase1 + val2 * toBase2;
1098
+ } else {
1099
+ const avg1 = getAverageValue(v1);
1100
+ const avg2 = getAverageValue(v2);
1101
+ sumInBase = avg1 * toBase1 + avg2 * toBase2;
1102
+ }
1103
+ const { unit: bestUnit, value: bestValue } = findBestUnit(
1104
+ sumInBase,
1105
+ unit1Def.type,
1106
+ system,
1107
+ inputUnits
1108
+ );
1109
+ const formattedValue = formatOutputValue(bestValue, bestUnit);
1110
+ if (v1.type === "range" || v2.type === "range") {
1111
+ const r1 = v1.type === "range" ? v1 : { type: "range", min: v1.value, max: v1.value };
1112
+ const r2 = v2.type === "range" ? v2 : { type: "range", min: v2.value, max: v2.value };
1113
+ const minInBase = getNumericValue(r1.min) * toBase1 + getNumericValue(r2.min) * toBase2;
1114
+ const maxInBase = getNumericValue(r1.max) * toBase1 + getNumericValue(r2.max) * toBase2;
1115
+ const bestToBase = getToBase(bestUnit, system);
1116
+ const minValue = minInBase / bestToBase;
1117
+ const maxValue = maxInBase / bestToBase;
738
1118
  return {
739
- ingredientIndex: indexFind,
740
- quantityPartIndex
1119
+ quantity: {
1120
+ type: "range",
1121
+ min: formatOutputValue(minValue, bestUnit),
1122
+ max: formatOutputValue(maxValue, bestUnit)
1123
+ },
1124
+ unit: { name: bestUnit.name }
741
1125
  };
742
1126
  }
743
1127
  return {
744
- ingredientIndex: ingredients.push(newIngredient) - 1,
745
- quantityPartIndex: newIngredient.quantity ? 0 : void 0
1128
+ quantity: { type: "fixed", value: formattedValue },
1129
+ unit: { name: bestUnit.name }
746
1130
  };
747
1131
  }
748
- function findAndUpsertCookware(cookware, newCookware, isReference) {
749
- const { name, quantity } = newCookware;
750
- if (isReference) {
751
- const index = cookware.findIndex(
752
- (i2) => i2.name.toLowerCase() === name.toLowerCase()
753
- );
754
- if (index === -1) {
755
- throw new Error(
756
- `Referenced cookware "${name}" not found. A referenced cookware must be declared before being referenced with '&'.`
757
- );
758
- }
759
- const existingCookware = cookware[index];
760
- for (const flag of newCookware.flags) {
761
- if (!existingCookware.flags.includes(flag)) {
762
- throw new ReferencedItemCannotBeRedefinedError(
1132
+ function convertQuantityToSystem(quantity, system) {
1133
+ const unitDef = resolveUnit(
1134
+ typeof quantity.unit === "string" ? quantity.unit : quantity.unit?.name
1135
+ );
1136
+ if (unitDef.type === "other" || !("toBase" in unitDef)) {
1137
+ return void 0;
1138
+ }
1139
+ const avgValue = getAverageValue(quantity.quantity);
1140
+ if (typeof avgValue !== "number") {
1141
+ return void 0;
1142
+ }
1143
+ const toBase = getToBase(unitDef, system);
1144
+ const valueInBase = avgValue * toBase;
1145
+ const { unit: bestUnit, value: bestValue } = findBestUnit(
1146
+ valueInBase,
1147
+ unitDef.type,
1148
+ system,
1149
+ [unitDef]
1150
+ );
1151
+ const formattedValue = formatOutputValue(bestValue, bestUnit);
1152
+ if (quantity.quantity.type === "range") {
1153
+ const bestToBase = getToBase(bestUnit, system);
1154
+ const minValue = getNumericValue(quantity.quantity.min) * toBase / bestToBase;
1155
+ const maxValue = getNumericValue(quantity.quantity.max) * toBase / bestToBase;
1156
+ return {
1157
+ quantity: {
1158
+ type: "range",
1159
+ min: formatOutputValue(minValue, bestUnit),
1160
+ max: formatOutputValue(maxValue, bestUnit)
1161
+ },
1162
+ unit: { name: bestUnit.name }
1163
+ };
1164
+ }
1165
+ return {
1166
+ quantity: { type: "fixed", value: formattedValue },
1167
+ unit: { name: bestUnit.name }
1168
+ };
1169
+ }
1170
+ function toPlainUnit(quantity) {
1171
+ if (isQuantity(quantity))
1172
+ return quantity.unit ? { ...quantity, unit: quantity.unit.name } : quantity;
1173
+ else if (isOrGroup(quantity)) {
1174
+ return {
1175
+ or: quantity.or.map(toPlainUnit)
1176
+ };
1177
+ } else {
1178
+ return {
1179
+ and: quantity.and.map(toPlainUnit)
1180
+ };
1181
+ }
1182
+ }
1183
+ function toExtendedUnit(q) {
1184
+ if (isQuantity(q)) {
1185
+ return q.unit ? { ...q, unit: { name: q.unit } } : q;
1186
+ } else if (isOrGroup(q)) {
1187
+ return { or: q.or.map(toExtendedUnit) };
1188
+ } else {
1189
+ return { and: q.and.map(toExtendedUnit) };
1190
+ }
1191
+ }
1192
+ function deNormalizeQuantity(q) {
1193
+ const result = {
1194
+ quantity: q.quantity
1195
+ };
1196
+ if (!isNoUnit(q.unit)) {
1197
+ result.unit = { name: q.unit.name };
1198
+ }
1199
+ return result;
1200
+ }
1201
+ var flattenPlainUnitGroup = (summed) => {
1202
+ if (isOrGroup(summed)) {
1203
+ const entries = summed.or;
1204
+ const andGroupEntry = entries.find(
1205
+ (e2) => isAndGroup(e2)
1206
+ );
1207
+ if (andGroupEntry) {
1208
+ const andEntries = [];
1209
+ const addGroupEntryContent = andGroupEntry.and;
1210
+ for (const entry of addGroupEntryContent) {
1211
+ andEntries.push({
1212
+ quantity: entry.quantity,
1213
+ ...entry.unit && { unit: entry.unit }
1214
+ });
1215
+ }
1216
+ const equivalentsList = entries.filter((e2) => isQuantity(e2)).map((e2) => ({ quantity: e2.quantity, unit: e2.unit }));
1217
+ if (equivalentsList.length > 0) {
1218
+ return [
1219
+ {
1220
+ and: andEntries,
1221
+ equivalents: equivalentsList
1222
+ }
1223
+ ];
1224
+ } else {
1225
+ return andEntries;
1226
+ }
1227
+ }
1228
+ const simpleEntries = entries.filter(
1229
+ (e2) => isQuantity(e2)
1230
+ );
1231
+ if (simpleEntries.length > 0) {
1232
+ const result = {
1233
+ quantity: simpleEntries[0].quantity,
1234
+ unit: simpleEntries[0].unit
1235
+ };
1236
+ if (simpleEntries.length > 1) {
1237
+ result.equivalents = simpleEntries.slice(1);
1238
+ }
1239
+ return [result];
1240
+ } else {
1241
+ const first = entries[0];
1242
+ return [{ quantity: first.quantity, unit: first.unit }];
1243
+ }
1244
+ } else if (isAndGroup(summed)) {
1245
+ const andEntries = [];
1246
+ const equivalentsList = [];
1247
+ for (const entry of summed.and) {
1248
+ if (isOrGroup(entry)) {
1249
+ const orEntries = entry.or;
1250
+ andEntries.push({
1251
+ quantity: orEntries[0].quantity,
1252
+ ...orEntries[0].unit && { unit: orEntries[0].unit }
1253
+ });
1254
+ equivalentsList.push(...orEntries.slice(1));
1255
+ } else if (isQuantity(entry)) {
1256
+ andEntries.push({
1257
+ quantity: entry.quantity,
1258
+ ...entry.unit && { unit: entry.unit }
1259
+ });
1260
+ }
1261
+ }
1262
+ if (equivalentsList.length === 0) {
1263
+ return andEntries;
1264
+ }
1265
+ const result = {
1266
+ and: andEntries,
1267
+ equivalents: equivalentsList
1268
+ };
1269
+ return [result];
1270
+ } else {
1271
+ return [
1272
+ { quantity: summed.quantity, ...summed.unit && { unit: summed.unit } }
1273
+ ];
1274
+ }
1275
+ };
1276
+ function applyBestUnit(q, system) {
1277
+ if (!q.unit?.name) {
1278
+ return q;
1279
+ }
1280
+ const unitDef = resolveUnit(q.unit.name);
1281
+ if (unitDef.type === "other") {
1282
+ return q;
1283
+ }
1284
+ if (q.quantity.type === "fixed" && q.quantity.value.type === "text") {
1285
+ return q;
1286
+ }
1287
+ const avgValue = getAverageValue(q.quantity);
1288
+ const effectiveSystem = system ?? (["metric", "JP"].includes(unitDef.system) ? unitDef.system : "US");
1289
+ const toBase = getToBase(unitDef, effectiveSystem);
1290
+ const valueInBase = avgValue * toBase;
1291
+ const { unit: bestUnit, value: bestValue } = findBestUnit(
1292
+ valueInBase,
1293
+ unitDef.type,
1294
+ effectiveSystem,
1295
+ [unitDef]
1296
+ );
1297
+ const originalCanonicalName = normalizeUnit(q.unit.name)?.name;
1298
+ if (bestUnit.name === originalCanonicalName) {
1299
+ return q;
1300
+ }
1301
+ const formattedValue = formatOutputValue(bestValue, bestUnit);
1302
+ if (q.quantity.type === "range") {
1303
+ const bestToBase = getToBase(bestUnit, effectiveSystem);
1304
+ const minValue = getNumericValue(q.quantity.min) * toBase / bestToBase;
1305
+ const maxValue = getNumericValue(q.quantity.max) * toBase / bestToBase;
1306
+ return {
1307
+ quantity: {
1308
+ type: "range",
1309
+ min: formatOutputValue(minValue, bestUnit),
1310
+ max: formatOutputValue(maxValue, bestUnit)
1311
+ },
1312
+ unit: { name: bestUnit.name }
1313
+ };
1314
+ }
1315
+ return {
1316
+ quantity: {
1317
+ type: "fixed",
1318
+ value: formattedValue
1319
+ },
1320
+ unit: { name: bestUnit.name }
1321
+ };
1322
+ }
1323
+
1324
+ // src/utils/parser_helpers.ts
1325
+ function flushPendingNote(section, noteItems) {
1326
+ if (noteItems.length > 0) {
1327
+ section.content.push({ type: "note", items: [...noteItems] });
1328
+ return [];
1329
+ }
1330
+ return noteItems;
1331
+ }
1332
+ function flushPendingItems(section, items) {
1333
+ if (items.length > 0) {
1334
+ section.content.push({ type: "step", items: [...items] });
1335
+ items.length = 0;
1336
+ return true;
1337
+ }
1338
+ return false;
1339
+ }
1340
+ function findAndUpsertIngredient(ingredients, newIngredient, isReference) {
1341
+ const { name } = newIngredient;
1342
+ if (isReference) {
1343
+ const indexFind = ingredients.findIndex(
1344
+ (i2) => i2.name.toLowerCase() === name.toLowerCase()
1345
+ );
1346
+ if (indexFind === -1) {
1347
+ throw new Error(
1348
+ `Referenced ingredient "${name}" not found. A referenced ingredient must be declared before being referenced with '&'.`
1349
+ );
1350
+ }
1351
+ const existingIngredient = ingredients[indexFind];
1352
+ if (!newIngredient.flags) {
1353
+ if (Array.isArray(existingIngredient.flags) && existingIngredient.flags.length > 0) {
1354
+ throw new ReferencedItemCannotBeRedefinedError(
1355
+ "ingredient",
1356
+ existingIngredient.name,
1357
+ existingIngredient.flags[0]
1358
+ );
1359
+ }
1360
+ } else {
1361
+ for (const flag of newIngredient.flags) {
1362
+ if (existingIngredient.flags === void 0 || !existingIngredient.flags.includes(flag)) {
1363
+ throw new ReferencedItemCannotBeRedefinedError(
1364
+ "ingredient",
1365
+ existingIngredient.name,
1366
+ flag
1367
+ );
1368
+ }
1369
+ }
1370
+ }
1371
+ return indexFind;
1372
+ }
1373
+ return ingredients.push(newIngredient) - 1;
1374
+ }
1375
+ function findAndUpsertCookware(cookware, newCookware, isReference) {
1376
+ const { name, quantity } = newCookware;
1377
+ if (isReference) {
1378
+ const index = cookware.findIndex(
1379
+ (i2) => i2.name.toLowerCase() === name.toLowerCase()
1380
+ );
1381
+ if (index === -1) {
1382
+ throw new Error(
1383
+ `Referenced cookware "${name}" not found. A referenced cookware must be declared before being referenced with '&'.`
1384
+ );
1385
+ }
1386
+ const existingCookware = cookware[index];
1387
+ if (!newCookware.flags) {
1388
+ if (Array.isArray(existingCookware.flags) && existingCookware.flags.length > 0) {
1389
+ throw new ReferencedItemCannotBeRedefinedError(
763
1390
  "cookware",
764
1391
  existingCookware.name,
765
- flag
1392
+ existingCookware.flags[0]
766
1393
  );
767
1394
  }
1395
+ } else {
1396
+ for (const flag of newCookware.flags) {
1397
+ if (existingCookware.flags === void 0 || !existingCookware.flags.includes(flag)) {
1398
+ throw new ReferencedItemCannotBeRedefinedError(
1399
+ "cookware",
1400
+ existingCookware.name,
1401
+ flag
1402
+ );
1403
+ }
1404
+ }
768
1405
  }
769
- let quantityPartIndex = void 0;
770
1406
  if (quantity !== void 0) {
771
1407
  if (!existingCookware.quantity) {
772
1408
  existingCookware.quantity = quantity;
773
- existingCookware.quantityParts = newCookware.quantityParts;
774
- quantityPartIndex = 0;
775
1409
  } else {
776
1410
  try {
777
1411
  existingCookware.quantity = addQuantityValues(
778
1412
  existingCookware.quantity,
779
1413
  quantity
780
1414
  );
781
- if (!existingCookware.quantityParts) {
782
- existingCookware.quantityParts = newCookware.quantityParts;
783
- quantityPartIndex = 0;
784
- } else {
785
- quantityPartIndex = existingCookware.quantityParts.push(
786
- ...newCookware.quantityParts
787
- ) - 1;
788
- }
789
1415
  } catch (e2) {
790
1416
  if (e2 instanceof CannotAddTextValueError) {
791
- return {
792
- cookwareIndex: cookware.push(newCookware) - 1,
793
- quantityPartIndex: 0
794
- };
1417
+ return cookware.push(newCookware) - 1;
795
1418
  }
796
1419
  }
797
1420
  }
798
1421
  }
799
- return {
800
- cookwareIndex: index,
801
- quantityPartIndex
802
- };
1422
+ return index;
803
1423
  }
804
- return {
805
- cookwareIndex: cookware.push(newCookware) - 1,
806
- quantityPartIndex: quantity ? 0 : void 0
807
- };
1424
+ return cookware.push(newCookware) - 1;
808
1425
  }
809
1426
  var parseFixedValue = (input_str) => {
810
1427
  if (!numberLikeRegex.test(input_str)) {
811
- return { type: "text", value: input_str };
1428
+ return { type: "text", text: input_str };
812
1429
  }
813
1430
  const s = input_str.trim().replace(",", ".");
814
1431
  if (s.includes("/")) {
@@ -817,8 +1434,22 @@ var parseFixedValue = (input_str) => {
817
1434
  const den = Number(parts[1]);
818
1435
  return { type: "fraction", num, den };
819
1436
  }
820
- return { type: "decimal", value: Number(s) };
1437
+ return { type: "decimal", decimal: Number(s) };
821
1438
  };
1439
+ function stringifyQuantityValue(quantity) {
1440
+ if (quantity.type === "fixed") {
1441
+ return stringifyFixedValue(quantity);
1442
+ } else {
1443
+ return `${stringifyFixedValue({ type: "fixed", value: quantity.min })}-${stringifyFixedValue({ type: "fixed", value: quantity.max })}`;
1444
+ }
1445
+ }
1446
+ function stringifyFixedValue(quantity) {
1447
+ if (quantity.value.type === "fraction")
1448
+ return `${quantity.value.num}/${quantity.value.den}`;
1449
+ else if (quantity.value.type === "decimal")
1450
+ return String(quantity.value.decimal);
1451
+ else return quantity.value.text;
1452
+ }
822
1453
  function parseQuantityInput(input_str) {
823
1454
  const clean_str = String(input_str).trim();
824
1455
  if (rangeRegex.test(clean_str)) {
@@ -860,7 +1491,7 @@ function parseListMetaVar(content, varName) {
860
1491
  function extractMetadata(content) {
861
1492
  const metadata = {};
862
1493
  let servings = void 0;
863
- const metadataContent = content.match(metadataRegex)?.[1];
1494
+ const metadataContent = content.match(metadataRegex)?.[2];
864
1495
  if (!metadataContent) {
865
1496
  return { metadata };
866
1497
  }
@@ -892,6 +1523,18 @@ function extractMetadata(content) {
892
1523
  const stringMetaValue = parseSimpleMetaVar(metadataContent, metaVar);
893
1524
  if (stringMetaValue) metadata[metaVar] = stringMetaValue;
894
1525
  }
1526
+ let unitSystem;
1527
+ const unitSystemRaw = parseSimpleMetaVar(metadataContent, "unit system");
1528
+ if (unitSystemRaw) {
1529
+ metadata["unit system"] = unitSystemRaw;
1530
+ const unitSystemMap = {
1531
+ metric: "metric",
1532
+ us: "US",
1533
+ uk: "UK",
1534
+ jp: "JP"
1535
+ };
1536
+ unitSystem = unitSystemMap[unitSystemRaw.toLowerCase()];
1537
+ }
895
1538
  for (const metaVar of ["serves", "yield", "servings"]) {
896
1539
  const scalingMetaValue = parseScalingMetaVar(metadataContent, metaVar);
897
1540
  if (scalingMetaValue && scalingMetaValue[1]) {
@@ -903,12 +1546,521 @@ function extractMetadata(content) {
903
1546
  const listMetaValue = parseListMetaVar(metadataContent, metaVar);
904
1547
  if (listMetaValue) metadata[metaVar] = listMetaValue;
905
1548
  }
906
- return { metadata, servings };
1549
+ return { metadata, servings, unitSystem };
1550
+ }
1551
+ function isPositiveIntegerString(str) {
1552
+ return /^\d+$/.test(str);
1553
+ }
1554
+ function unionOfSets(s1, s2) {
1555
+ const result = new Set(s1);
1556
+ for (const item of s2) {
1557
+ result.add(item);
1558
+ }
1559
+ return result;
1560
+ }
1561
+ function getAlternativeSignature(alternatives) {
1562
+ if (!alternatives || alternatives.length === 0) return null;
1563
+ return alternatives.map((a2) => a2.index).sort((a2, b) => a2 - b).join(",");
1564
+ }
1565
+
1566
+ // src/classes/product_catalog.ts
1567
+ var ProductCatalog = class {
1568
+ constructor(tomlContent) {
1569
+ __publicField(this, "products", []);
1570
+ if (tomlContent) this.parse(tomlContent);
1571
+ }
1572
+ /**
1573
+ * Parses a TOML string into a list of product options.
1574
+ * @param tomlContent - The TOML string to parse.
1575
+ * @returns A parsed list of `ProductOption`.
1576
+ */
1577
+ parse(tomlContent) {
1578
+ const catalogRaw = import_smol_toml.default.parse(tomlContent);
1579
+ this.products = [];
1580
+ if (!this.isValidTomlContent(catalogRaw)) {
1581
+ throw new InvalidProductCatalogFormat();
1582
+ }
1583
+ for (const [ingredientName, ingredientData] of Object.entries(catalogRaw)) {
1584
+ const ingredientTable = ingredientData;
1585
+ const aliases = ingredientTable.aliases;
1586
+ for (const [key, productData] of Object.entries(ingredientTable)) {
1587
+ if (key === "aliases") {
1588
+ continue;
1589
+ }
1590
+ const productId = key;
1591
+ const { name, size, price, ...rest } = productData;
1592
+ const sizeStrings = Array.isArray(size) ? size : [size];
1593
+ const sizes = sizeStrings.map((sizeStr) => {
1594
+ const sizeAndUnitRaw = sizeStr.split("%");
1595
+ const sizeParsed = parseQuantityInput(
1596
+ sizeAndUnitRaw[0]
1597
+ );
1598
+ const productSize = { size: sizeParsed };
1599
+ if (sizeAndUnitRaw.length > 1) {
1600
+ productSize.unit = sizeAndUnitRaw[1];
1601
+ }
1602
+ return productSize;
1603
+ });
1604
+ const productOption = {
1605
+ id: productId,
1606
+ productName: name,
1607
+ ingredientName,
1608
+ price,
1609
+ sizes,
1610
+ ...rest
1611
+ };
1612
+ if (aliases) {
1613
+ productOption.ingredientAliases = aliases;
1614
+ }
1615
+ this.products.push(productOption);
1616
+ }
1617
+ }
1618
+ return this.products;
1619
+ }
1620
+ /**
1621
+ * Stringifies the catalog to a TOML string.
1622
+ * @returns The TOML string representation of the catalog.
1623
+ */
1624
+ stringify() {
1625
+ const grouped = {};
1626
+ for (const product of this.products) {
1627
+ const {
1628
+ id,
1629
+ ingredientName,
1630
+ ingredientAliases,
1631
+ sizes,
1632
+ productName,
1633
+ ...rest
1634
+ } = product;
1635
+ if (!grouped[ingredientName]) {
1636
+ grouped[ingredientName] = {};
1637
+ }
1638
+ if (ingredientAliases && !grouped[ingredientName].aliases) {
1639
+ grouped[ingredientName].aliases = ingredientAliases;
1640
+ }
1641
+ const sizeStrings = sizes.map(
1642
+ (s) => s.unit ? `${stringifyQuantityValue(s.size)}%${s.unit}` : stringifyQuantityValue(s.size)
1643
+ );
1644
+ grouped[ingredientName][id] = {
1645
+ ...rest,
1646
+ name: productName,
1647
+ // Use array if multiple sizes, otherwise single string
1648
+ size: sizeStrings.length === 1 ? sizeStrings[0] : sizeStrings
1649
+ };
1650
+ }
1651
+ return import_smol_toml.default.stringify(grouped);
1652
+ }
1653
+ /**
1654
+ * Adds a product to the catalog.
1655
+ * @param productOption - The product to add.
1656
+ */
1657
+ add(productOption) {
1658
+ this.products.push(productOption);
1659
+ }
1660
+ /**
1661
+ * Removes a product from the catalog by its ID.
1662
+ * @param productId - The ID of the product to remove.
1663
+ */
1664
+ remove(productId) {
1665
+ this.products = this.products.filter((product) => product.id !== productId);
1666
+ }
1667
+ isValidTomlContent(catalog) {
1668
+ for (const productsRaw of Object.values(catalog)) {
1669
+ if (typeof productsRaw !== "object" || productsRaw === null) {
1670
+ return false;
1671
+ }
1672
+ for (const [id, obj] of Object.entries(productsRaw)) {
1673
+ if (id === "aliases") {
1674
+ if (!Array.isArray(obj)) {
1675
+ return false;
1676
+ }
1677
+ } else {
1678
+ if (!isPositiveIntegerString(id)) {
1679
+ return false;
1680
+ }
1681
+ if (typeof obj !== "object" || obj === null) {
1682
+ return false;
1683
+ }
1684
+ const record = obj;
1685
+ const keys = Object.keys(record);
1686
+ const mandatoryKeys = ["name", "size", "price"];
1687
+ if (mandatoryKeys.some((key) => !keys.includes(key))) {
1688
+ return false;
1689
+ }
1690
+ const hasProductName = typeof record.name === "string";
1691
+ const hasSize = typeof record.size === "string" || Array.isArray(record.size) && record.size.every((s) => typeof s === "string");
1692
+ const hasPrice = typeof record.price === "number";
1693
+ if (!(hasProductName && hasSize && hasPrice)) {
1694
+ return false;
1695
+ }
1696
+ }
1697
+ }
1698
+ }
1699
+ return true;
1700
+ }
1701
+ };
1702
+
1703
+ // src/classes/section.ts
1704
+ var Section = class {
1705
+ /**
1706
+ * Creates an instance of Section.
1707
+ * @param name - The name of the section. Defaults to an empty string.
1708
+ */
1709
+ constructor(name = "") {
1710
+ /**
1711
+ * The name of the section. Can be an empty string for the default (first) section.
1712
+ * @defaultValue `""`
1713
+ */
1714
+ __publicField(this, "name");
1715
+ /** An array of steps and notes that make up the content of the section. */
1716
+ __publicField(this, "content", []);
1717
+ this.name = name;
1718
+ }
1719
+ /**
1720
+ * Checks if the section is blank (has no name and no content).
1721
+ * Used during recipe parsing
1722
+ * @returns `true` if the section is blank, otherwise `false`.
1723
+ */
1724
+ isBlank() {
1725
+ return this.name === "" && this.content.length === 0;
1726
+ }
1727
+ };
1728
+
1729
+ // src/quantities/alternatives.ts
1730
+ var import_big3 = __toESM(require("big.js"), 1);
1731
+
1732
+ // src/units/lookup.ts
1733
+ function findListWithCompatibleQuantity(list, quantity) {
1734
+ const quantityWithUnitDef = {
1735
+ ...quantity,
1736
+ unit: resolveUnit(quantity.unit?.name)
1737
+ };
1738
+ return list.find(
1739
+ (l) => l.some((lq) => areUnitsGroupable(lq.unit, quantityWithUnitDef.unit))
1740
+ );
1741
+ }
1742
+ function findCompatibleQuantityWithinList(list, quantity) {
1743
+ const quantityWithUnitDef = {
1744
+ ...quantity,
1745
+ unit: resolveUnit(quantity.unit?.name)
1746
+ };
1747
+ return list.find(
1748
+ (q) => q.unit.name === quantityWithUnitDef.unit.name || q.unit.type === quantityWithUnitDef.unit.type && q.unit.type !== "other"
1749
+ );
1750
+ }
1751
+
1752
+ // src/utils/general.ts
1753
+ var legacyDeepClone = (v) => {
1754
+ if (v === null || typeof v !== "object") {
1755
+ return v;
1756
+ }
1757
+ if (v instanceof Map) {
1758
+ return new Map(
1759
+ Array.from(v.entries()).map(([k, val]) => [
1760
+ legacyDeepClone(k),
1761
+ legacyDeepClone(val)
1762
+ ])
1763
+ );
1764
+ }
1765
+ if (v instanceof Set) {
1766
+ return new Set(Array.from(v).map((val) => legacyDeepClone(val)));
1767
+ }
1768
+ if (v instanceof Date) {
1769
+ return new Date(v.getTime());
1770
+ }
1771
+ if (Array.isArray(v)) {
1772
+ return v.map((item) => legacyDeepClone(item));
1773
+ }
1774
+ const cloned = {};
1775
+ for (const key of Object.keys(v)) {
1776
+ cloned[key] = legacyDeepClone(v[key]);
1777
+ }
1778
+ return cloned;
1779
+ };
1780
+ var deepClone = (v) => typeof structuredClone === "function" ? structuredClone(v) : legacyDeepClone(v);
1781
+
1782
+ // src/quantities/alternatives.ts
1783
+ function getEquivalentUnitsLists(...quantities) {
1784
+ const quantitiesCopy = deepClone(quantities);
1785
+ const OrGroups = quantitiesCopy.filter(isOrGroup).filter((q) => q.or.length > 1);
1786
+ const unitLists = [];
1787
+ const normalizeOrGroup = (og) => ({
1788
+ ...og,
1789
+ or: og.or.map((q) => ({
1790
+ ...q,
1791
+ unit: resolveUnit(q.unit?.name, q.unit?.integerProtected)
1792
+ }))
1793
+ });
1794
+ function findLinkIndexForUnits(lists, unitsToCheck) {
1795
+ return lists.findIndex((l) => {
1796
+ const listItems = l.map((q) => resolveUnit(q.unit?.name));
1797
+ return unitsToCheck.some(
1798
+ (u) => u && listItems.some((lu) => areUnitsGroupable(lu, u))
1799
+ );
1800
+ });
1801
+ }
1802
+ function mergeOrGroupIntoList(lists, idx, og) {
1803
+ let unitRatio;
1804
+ const commonUnitList = lists[idx].reduce((acc, v) => {
1805
+ const normalizedV = {
1806
+ ...v,
1807
+ unit: resolveUnit(v.unit?.name, v.unit?.integerProtected)
1808
+ };
1809
+ const commonQuantity = og.or.find(
1810
+ (q) => isQuantity(q) && areUnitsGroupable(q.unit, normalizedV.unit)
1811
+ );
1812
+ if (commonQuantity) {
1813
+ acc.push(normalizedV);
1814
+ if (!unitRatio) {
1815
+ unitRatio = getUnitRatio(normalizedV, commonQuantity);
1816
+ }
1817
+ }
1818
+ return acc;
1819
+ }, []);
1820
+ for (const newQ of og.or) {
1821
+ if (commonUnitList.some((q) => areUnitsGroupable(q.unit, newQ.unit))) {
1822
+ continue;
1823
+ } else {
1824
+ const scaledQuantity = multiplyQuantityValue(newQ.quantity, unitRatio);
1825
+ lists[idx].push({ ...newQ, quantity: scaledQuantity });
1826
+ }
1827
+ }
1828
+ }
1829
+ for (const orGroup of OrGroups) {
1830
+ const orGroupModified = normalizeOrGroup(orGroup);
1831
+ const units2 = orGroupModified.or.map((q) => q.unit);
1832
+ const linkIndex = findLinkIndexForUnits(unitLists, units2);
1833
+ if (linkIndex === -1) {
1834
+ unitLists.push(orGroupModified.or);
1835
+ } else {
1836
+ mergeOrGroupIntoList(unitLists, linkIndex, orGroupModified);
1837
+ }
1838
+ }
1839
+ return unitLists;
1840
+ }
1841
+ function sortUnitList(list) {
1842
+ if (!list || list.length <= 1) return list;
1843
+ const priorityList = [];
1844
+ const nonPriorityList = [];
1845
+ for (const q of list) {
1846
+ if (q.unit.integerProtected || q.unit.system === "none") {
1847
+ priorityList.push(q);
1848
+ } else {
1849
+ nonPriorityList.push(q);
1850
+ }
1851
+ }
1852
+ return priorityList.sort((a2, b) => {
1853
+ const prefixA = a2.unit.integerProtected ? "___" : "";
1854
+ const prefixB = b.unit.integerProtected ? "___" : "";
1855
+ return (prefixA + a2.unit.name).localeCompare(prefixB + b.unit.name, "en");
1856
+ }).concat(nonPriorityList);
1857
+ }
1858
+ function reduceOrsToFirstEquivalent(unitList, quantities) {
1859
+ function reduceToQuantity(firstQuantity) {
1860
+ const equivalentList = sortUnitList(
1861
+ findListWithCompatibleQuantity(unitList, firstQuantity)
1862
+ );
1863
+ if (!equivalentList) return firstQuantity;
1864
+ const firstQuantityInList = findCompatibleQuantityWithinList(
1865
+ equivalentList,
1866
+ firstQuantity
1867
+ );
1868
+ const normalizedFirstQuantity = {
1869
+ ...firstQuantity,
1870
+ unit: resolveUnit(firstQuantity.unit?.name)
1871
+ };
1872
+ if (firstQuantityInList.unit.integerProtected) {
1873
+ const resultQuantity = {
1874
+ quantity: firstQuantity.quantity
1875
+ };
1876
+ if (!isNoUnit(normalizedFirstQuantity.unit)) {
1877
+ resultQuantity.unit = { name: normalizedFirstQuantity.unit.name };
1878
+ }
1879
+ return resultQuantity;
1880
+ } else {
1881
+ let nextProtected;
1882
+ const equivalentListTemp = [...equivalentList];
1883
+ while (nextProtected !== -1) {
1884
+ nextProtected = equivalentListTemp.findIndex(
1885
+ (eq) => eq.unit?.integerProtected
1886
+ );
1887
+ if (nextProtected !== -1) {
1888
+ const unitRatio2 = getUnitRatio(
1889
+ equivalentListTemp[nextProtected],
1890
+ firstQuantityInList
1891
+ );
1892
+ const nextProtectedQuantityValue = multiplyQuantityValue(
1893
+ firstQuantity.quantity,
1894
+ unitRatio2
1895
+ );
1896
+ if (isValueIntegerLike(nextProtectedQuantityValue)) {
1897
+ const nextProtectedQuantity = {
1898
+ quantity: nextProtectedQuantityValue
1899
+ };
1900
+ if (!isNoUnit(equivalentListTemp[nextProtected].unit)) {
1901
+ nextProtectedQuantity.unit = {
1902
+ name: equivalentListTemp[nextProtected].unit.name
1903
+ };
1904
+ }
1905
+ return nextProtectedQuantity;
1906
+ } else {
1907
+ equivalentListTemp.splice(nextProtected, 1);
1908
+ }
1909
+ }
1910
+ }
1911
+ const firstNonIntegerProtected = equivalentListTemp.filter(
1912
+ (q) => !q.unit.integerProtected
1913
+ )[0];
1914
+ const unitRatio = getUnitRatio(
1915
+ firstNonIntegerProtected,
1916
+ firstQuantityInList
1917
+ ).times(getBaseUnitRatio(normalizedFirstQuantity, firstQuantityInList));
1918
+ const firstEqQuantity = {
1919
+ quantity: firstNonIntegerProtected.unit.name === firstQuantity.unit.name ? firstQuantity.quantity : multiplyQuantityValue(firstQuantity.quantity, unitRatio)
1920
+ };
1921
+ if (!isNoUnit(firstNonIntegerProtected.unit)) {
1922
+ firstEqQuantity.unit = { name: firstNonIntegerProtected.unit.name };
1923
+ }
1924
+ return firstEqQuantity;
1925
+ }
1926
+ }
1927
+ return quantities.map((q) => {
1928
+ if (isQuantity(q)) return reduceToQuantity(q);
1929
+ const qListModified = sortUnitList(
1930
+ q.or.map((qq) => ({
1931
+ ...qq,
1932
+ unit: resolveUnit(qq.unit?.name, qq.unit?.integerProtected)
1933
+ }))
1934
+ );
1935
+ return reduceToQuantity(qListModified[0]);
1936
+ });
1937
+ }
1938
+ function addQuantitiesOrGroups(quantities, system) {
1939
+ if (quantities.length === 0)
1940
+ return {
1941
+ sum: {
1942
+ quantity: getDefaultQuantityValue(),
1943
+ unit: resolveUnit()
1944
+ },
1945
+ unitsLists: []
1946
+ };
1947
+ if (quantities.length === 1) {
1948
+ if (isQuantity(quantities[0]))
1949
+ return {
1950
+ sum: {
1951
+ ...quantities[0],
1952
+ unit: resolveUnit(quantities[0].unit?.name)
1953
+ },
1954
+ unitsLists: []
1955
+ };
1956
+ }
1957
+ const unitsLists = getEquivalentUnitsLists(...quantities);
1958
+ const reducedQuantities = reduceOrsToFirstEquivalent(unitsLists, quantities);
1959
+ const sum = [];
1960
+ for (const nextQ of reducedQuantities) {
1961
+ const existingQ = findCompatibleQuantityWithinList(sum, nextQ);
1962
+ if (existingQ === void 0) {
1963
+ sum.push({
1964
+ ...nextQ,
1965
+ unit: resolveUnit(nextQ.unit?.name)
1966
+ });
1967
+ } else {
1968
+ const sumQ = addQuantities(existingQ, nextQ, system);
1969
+ existingQ.quantity = sumQ.quantity;
1970
+ existingQ.unit = resolveUnit(sumQ.unit?.name);
1971
+ }
1972
+ }
1973
+ if (sum.length === 1) {
1974
+ return { sum: sum[0], unitsLists };
1975
+ }
1976
+ return { sum: { and: sum }, unitsLists };
1977
+ }
1978
+ function regroupQuantitiesAndExpandEquivalents(sum, unitsLists, system) {
1979
+ const sumQuantities = isAndGroup(sum) ? sum.and : [sum];
1980
+ const result = [];
1981
+ const processedQuantities = /* @__PURE__ */ new Set();
1982
+ for (const list of unitsLists) {
1983
+ const listCopy = deepClone(list);
1984
+ const main = [];
1985
+ const mainCandidates = sumQuantities.filter(
1986
+ (q) => !processedQuantities.has(q)
1987
+ );
1988
+ if (mainCandidates.length === 0) continue;
1989
+ mainCandidates.forEach((q) => {
1990
+ const mainInList = findCompatibleQuantityWithinList(listCopy, q);
1991
+ if (mainInList !== void 0) {
1992
+ processedQuantities.add(q);
1993
+ main.push(q);
1994
+ listCopy.splice(listCopy.indexOf(mainInList), 1);
1995
+ }
1996
+ });
1997
+ const equivalents = sortUnitList(listCopy).map((equiv) => {
1998
+ const initialValue = {
1999
+ quantity: getDefaultQuantityValue()
2000
+ };
2001
+ if (equiv.unit) {
2002
+ initialValue.unit = { name: equiv.unit.name };
2003
+ }
2004
+ return main.reduce((acc, v) => {
2005
+ const mainInList = findCompatibleQuantityWithinList(list, v);
2006
+ const conversionRatio = getBaseUnitRatio(v, mainInList);
2007
+ const valueInOriginalUnit = (0, import_big3.default)(getAverageValue(v.quantity)).times(
2008
+ conversionRatio
2009
+ );
2010
+ const newValue = {
2011
+ quantity: multiplyQuantityValue(
2012
+ {
2013
+ type: "fixed",
2014
+ value: {
2015
+ type: "decimal",
2016
+ decimal: valueInOriginalUnit.toNumber()
2017
+ }
2018
+ },
2019
+ (0, import_big3.default)(getAverageValue(equiv.quantity)).div(
2020
+ getAverageValue(mainInList.quantity)
2021
+ )
2022
+ )
2023
+ };
2024
+ if (equiv.unit && !isNoUnit(equiv.unit)) {
2025
+ newValue.unit = { name: equiv.unit.name };
2026
+ }
2027
+ return addQuantities(acc, newValue, system);
2028
+ }, initialValue);
2029
+ });
2030
+ if (main.length + equivalents.length > 1) {
2031
+ const resultMain = main.length > 1 ? {
2032
+ and: main.map(deNormalizeQuantity)
2033
+ } : deNormalizeQuantity(main[0]);
2034
+ result.push({
2035
+ or: [resultMain, ...equivalents]
2036
+ });
2037
+ } else {
2038
+ result.push(deNormalizeQuantity(main[0]));
2039
+ }
2040
+ }
2041
+ sumQuantities.filter((q) => !processedQuantities.has(q)).forEach((q) => result.push(deNormalizeQuantity(q)));
2042
+ return result;
2043
+ }
2044
+ function addEquivalentsAndSimplify(quantities, system) {
2045
+ if (quantities.length === 1) {
2046
+ return toPlainUnit(quantities[0]);
2047
+ }
2048
+ const { sum, unitsLists } = addQuantitiesOrGroups(quantities, system);
2049
+ const regrouped = regroupQuantitiesAndExpandEquivalents(
2050
+ sum,
2051
+ unitsLists,
2052
+ system
2053
+ );
2054
+ if (regrouped.length === 1) {
2055
+ return toPlainUnit(regrouped[0]);
2056
+ } else {
2057
+ return { and: regrouped.map(toPlainUnit) };
2058
+ }
907
2059
  }
908
2060
 
909
2061
  // src/classes/recipe.ts
910
- var import_big2 = __toESM(require("big.js"), 1);
911
- var Recipe = class _Recipe {
2062
+ var import_big4 = __toESM(require("big.js"), 1);
2063
+ var _Recipe = class _Recipe {
912
2064
  /**
913
2065
  * Creates a new Recipe instance.
914
2066
  * @param content - The recipe content to parse.
@@ -918,6 +2070,13 @@ var Recipe = class _Recipe {
918
2070
  * The parsed recipe metadata.
919
2071
  */
920
2072
  __publicField(this, "metadata", {});
2073
+ /**
2074
+ * The possible choices of alternative ingredients for this recipe.
2075
+ */
2076
+ __publicField(this, "choices", {
2077
+ ingredientItems: /* @__PURE__ */ new Map(),
2078
+ ingredientGroups: /* @__PURE__ */ new Map()
2079
+ });
921
2080
  /**
922
2081
  * The parsed recipe ingredients.
923
2082
  */
@@ -934,6 +2093,10 @@ var Recipe = class _Recipe {
934
2093
  * The parsed recipe timers.
935
2094
  */
936
2095
  __publicField(this, "timers", []);
2096
+ /**
2097
+ * The parsed arbitrary quantities.
2098
+ */
2099
+ __publicField(this, "arbitraries", []);
937
2100
  /**
938
2101
  * The parsed recipe servings. Used for scaling. Parsed from one of
939
2102
  * {@link Metadata.servings}, {@link Metadata.yield} or {@link Metadata.serves}
@@ -942,35 +2105,608 @@ var Recipe = class _Recipe {
942
2105
  * @see {@link Recipe.scaleBy | scaleBy()} and {@link Recipe.scaleTo | scaleTo()} methods
943
2106
  */
944
2107
  __publicField(this, "servings");
2108
+ _Recipe.itemCounts.set(this, 0);
945
2109
  if (content) {
946
2110
  this.parse(content);
947
2111
  }
948
2112
  }
2113
+ /**
2114
+ * Gets the unit system specified in the recipe metadata.
2115
+ * Used for resolving ambiguous units like tsp, tbsp, cup, etc.
2116
+ *
2117
+ * @returns The unit system if specified, or undefined to use defaults
2118
+ */
2119
+ get unitSystem() {
2120
+ return _Recipe.unitSystems.get(this);
2121
+ }
2122
+ /**
2123
+ * Gets the current item count for this recipe.
2124
+ */
2125
+ getItemCount() {
2126
+ return _Recipe.itemCounts.get(this);
2127
+ }
2128
+ /**
2129
+ * Gets the current item count and increments it.
2130
+ */
2131
+ getAndIncrementItemCount() {
2132
+ const current = this.getItemCount();
2133
+ _Recipe.itemCounts.set(this, current + 1);
2134
+ return current;
2135
+ }
2136
+ /**
2137
+ * Parses a matched arbitrary scalable quantity and adds it to the given array.
2138
+ * @private
2139
+ * @param regexMatchGroups - The regex match groups from arbitrary scalable regex.
2140
+ * @param intoArray - The array to push the parsed arbitrary scalable item into.
2141
+ */
2142
+ _parseArbitraryScalable(regexMatchGroups, intoArray) {
2143
+ if (!regexMatchGroups || !regexMatchGroups.arbitraryQuantity) return;
2144
+ const quantityMatch = regexMatchGroups.arbitraryQuantity?.trim().match(quantityAlternativeRegex);
2145
+ if (quantityMatch?.groups) {
2146
+ const value = parseQuantityInput(quantityMatch.groups.quantity);
2147
+ const unit = quantityMatch.groups.unit;
2148
+ const name = regexMatchGroups.arbitraryName || void 0;
2149
+ if (!value || value.type === "fixed" && value.value.type === "text") {
2150
+ throw new InvalidQuantityFormat(
2151
+ regexMatchGroups.arbitraryQuantity?.trim(),
2152
+ "Arbitrary quantities must have a numerical value"
2153
+ );
2154
+ }
2155
+ const arbitrary = {
2156
+ quantity: value
2157
+ };
2158
+ if (name) arbitrary.name = name;
2159
+ if (unit) arbitrary.unit = unit;
2160
+ intoArray.push({
2161
+ type: "arbitrary",
2162
+ index: this.arbitraries.push(arbitrary) - 1
2163
+ });
2164
+ }
2165
+ }
2166
+ /**
2167
+ * Parses text for arbitrary scalables and returns NoteItem array.
2168
+ * @param text - The text to parse for arbitrary scalables.
2169
+ * @returns Array of NoteItem (text and arbitrary scalable items).
2170
+ */
2171
+ _parseNoteText(text) {
2172
+ const noteItems = [];
2173
+ let cursor = 0;
2174
+ const globalRegex = new RegExp(arbitraryScalableRegex.source, "g");
2175
+ for (const match of text.matchAll(globalRegex)) {
2176
+ const idx = match.index;
2177
+ if (idx > cursor) {
2178
+ noteItems.push({ type: "text", value: text.slice(cursor, idx) });
2179
+ }
2180
+ this._parseArbitraryScalable(match.groups, noteItems);
2181
+ cursor = idx + match[0].length;
2182
+ }
2183
+ if (cursor < text.length) {
2184
+ noteItems.push({ type: "text", value: text.slice(cursor) });
2185
+ }
2186
+ return noteItems;
2187
+ }
2188
+ _parseQuantityRecursive(quantityRaw) {
2189
+ let quantityMatch = quantityRaw.match(quantityAlternativeRegex);
2190
+ const quantities = [];
2191
+ while (quantityMatch?.groups) {
2192
+ const value = quantityMatch.groups.quantity ? parseQuantityInput(quantityMatch.groups.quantity) : void 0;
2193
+ const unit = quantityMatch.groups.unit;
2194
+ if (value) {
2195
+ const newQuantity = { quantity: value };
2196
+ if (unit) {
2197
+ if (unit.startsWith("=")) {
2198
+ newQuantity.unit = {
2199
+ name: unit.substring(1),
2200
+ integerProtected: true
2201
+ };
2202
+ } else {
2203
+ newQuantity.unit = { name: unit };
2204
+ }
2205
+ }
2206
+ quantities.push(newQuantity);
2207
+ } else {
2208
+ throw new InvalidQuantityFormat(quantityRaw);
2209
+ }
2210
+ quantityMatch = quantityMatch.groups.alternative ? quantityMatch.groups.alternative.match(quantityAlternativeRegex) : null;
2211
+ }
2212
+ return quantities;
2213
+ }
2214
+ _parseIngredientWithAlternativeRecursive(ingredientMatchString, items) {
2215
+ const alternatives = [];
2216
+ let testString = ingredientMatchString;
2217
+ while (true) {
2218
+ const match = testString.match(
2219
+ alternatives.length > 0 ? inlineIngredientAlternativesRegex : ingredientWithAlternativeRegex
2220
+ );
2221
+ if (!match?.groups) break;
2222
+ const groups = match.groups;
2223
+ let name = groups.mIngredientName || groups.sIngredientName;
2224
+ const preparation = groups.ingredientPreparation;
2225
+ const modifiers = groups.ingredientModifiers;
2226
+ const reference = modifiers !== void 0 && modifiers.includes("&");
2227
+ const flags = [];
2228
+ if (modifiers !== void 0 && modifiers.includes("?")) {
2229
+ flags.push("optional");
2230
+ }
2231
+ if (modifiers !== void 0 && modifiers.includes("-")) {
2232
+ flags.push("hidden");
2233
+ }
2234
+ if (modifiers !== void 0 && modifiers.includes("@") || groups.ingredientRecipeAnchor) {
2235
+ flags.push("recipe");
2236
+ }
2237
+ let extras = void 0;
2238
+ if (flags.includes("recipe")) {
2239
+ extras = { path: `${name}.cook` };
2240
+ name = name.substring(name.lastIndexOf("/") + 1);
2241
+ }
2242
+ const aliasMatch = name.match(ingredientAliasRegex);
2243
+ let listName, displayName;
2244
+ if (aliasMatch && aliasMatch.groups.ingredientListName.trim().length > 0 && aliasMatch.groups.ingredientDisplayName.trim().length > 0) {
2245
+ listName = aliasMatch.groups.ingredientListName.trim();
2246
+ displayName = aliasMatch.groups.ingredientDisplayName.trim();
2247
+ } else {
2248
+ listName = name;
2249
+ displayName = name;
2250
+ }
2251
+ const newIngredient = {
2252
+ name: listName
2253
+ };
2254
+ if (preparation) {
2255
+ newIngredient.preparation = preparation;
2256
+ }
2257
+ if (flags.length > 0) {
2258
+ newIngredient.flags = flags;
2259
+ }
2260
+ if (extras) {
2261
+ newIngredient.extras = extras;
2262
+ }
2263
+ const idxInList = findAndUpsertIngredient(
2264
+ this.ingredients,
2265
+ newIngredient,
2266
+ reference
2267
+ );
2268
+ let itemQuantity = void 0;
2269
+ if (groups.ingredientQuantity) {
2270
+ const parsedQuantities = this._parseQuantityRecursive(
2271
+ groups.ingredientQuantity
2272
+ );
2273
+ const [primary, ...rest] = parsedQuantities;
2274
+ if (primary) {
2275
+ itemQuantity = {
2276
+ ...primary,
2277
+ scalable: groups.ingredientQuantityModifier !== "="
2278
+ };
2279
+ if (rest.length > 0) {
2280
+ itemQuantity.equivalents = rest;
2281
+ }
2282
+ }
2283
+ }
2284
+ const alternative = {
2285
+ index: idxInList,
2286
+ displayName
2287
+ };
2288
+ const note = groups.ingredientNote?.trim();
2289
+ if (note) {
2290
+ alternative.note = note;
2291
+ }
2292
+ if (itemQuantity) {
2293
+ alternative.itemQuantity = itemQuantity;
2294
+ }
2295
+ alternatives.push(alternative);
2296
+ testString = groups.ingredientAlternative || "";
2297
+ }
2298
+ if (alternatives.length > 1) {
2299
+ const alternativesIndexes = alternatives.map((alt) => alt.index);
2300
+ for (const ingredientIndex of alternativesIndexes) {
2301
+ const ingredient = this.ingredients[ingredientIndex];
2302
+ if (ingredient) {
2303
+ if (!ingredient.alternatives) {
2304
+ ingredient.alternatives = new Set(
2305
+ alternativesIndexes.filter((index) => index !== ingredientIndex)
2306
+ );
2307
+ } else {
2308
+ ingredient.alternatives = unionOfSets(
2309
+ ingredient.alternatives,
2310
+ new Set(
2311
+ alternativesIndexes.filter(
2312
+ (index) => index !== ingredientIndex
2313
+ )
2314
+ )
2315
+ );
2316
+ }
2317
+ }
2318
+ }
2319
+ }
2320
+ const id = `ingredient-item-${this.getAndIncrementItemCount()}`;
2321
+ const newItem = {
2322
+ type: "ingredient",
2323
+ id,
2324
+ alternatives
2325
+ };
2326
+ items.push(newItem);
2327
+ if (alternatives.length > 1) {
2328
+ this.choices.ingredientItems.set(id, alternatives);
2329
+ }
2330
+ }
2331
+ _parseIngredientWithGroupKey(ingredientMatchString, items) {
2332
+ const match = ingredientMatchString.match(ingredientWithGroupKeyRegex);
2333
+ if (!match?.groups) return;
2334
+ const groups = match.groups;
2335
+ const groupKey = groups.gIngredientGroupKey;
2336
+ let name = groups.gmIngredientName || groups.gsIngredientName;
2337
+ const preparation = groups.gIngredientPreparation;
2338
+ const modifiers = groups.gIngredientModifiers;
2339
+ const reference = modifiers !== void 0 && modifiers.includes("&");
2340
+ const flags = [];
2341
+ if (modifiers !== void 0 && modifiers.includes("?")) {
2342
+ flags.push("optional");
2343
+ }
2344
+ if (modifiers !== void 0 && modifiers.includes("-")) {
2345
+ flags.push("hidden");
2346
+ }
2347
+ if (modifiers !== void 0 && modifiers.includes("@") || groups.gIngredientRecipeAnchor) {
2348
+ flags.push("recipe");
2349
+ }
2350
+ let extras = void 0;
2351
+ if (flags.includes("recipe")) {
2352
+ extras = { path: `${name}.cook` };
2353
+ name = name.substring(name.lastIndexOf("/") + 1);
2354
+ }
2355
+ const aliasMatch = name.match(ingredientAliasRegex);
2356
+ let listName, displayName;
2357
+ if (aliasMatch && aliasMatch.groups.ingredientListName.trim().length > 0 && aliasMatch.groups.ingredientDisplayName.trim().length > 0) {
2358
+ listName = aliasMatch.groups.ingredientListName.trim();
2359
+ displayName = aliasMatch.groups.ingredientDisplayName.trim();
2360
+ } else {
2361
+ listName = name;
2362
+ displayName = name;
2363
+ }
2364
+ const newIngredient = {
2365
+ name: listName
2366
+ };
2367
+ if (preparation) {
2368
+ newIngredient.preparation = preparation;
2369
+ }
2370
+ if (flags.length > 0) {
2371
+ newIngredient.flags = flags;
2372
+ }
2373
+ if (extras) {
2374
+ newIngredient.extras = extras;
2375
+ }
2376
+ const idxInList = findAndUpsertIngredient(
2377
+ this.ingredients,
2378
+ newIngredient,
2379
+ reference
2380
+ );
2381
+ let itemQuantity = void 0;
2382
+ if (groups.gIngredientQuantity) {
2383
+ const parsedQuantities = this._parseQuantityRecursive(
2384
+ groups.gIngredientQuantity
2385
+ );
2386
+ const [primary, ...rest] = parsedQuantities;
2387
+ itemQuantity = {
2388
+ ...primary,
2389
+ // there's necessarily a primary quantity as the match group was detected
2390
+ scalable: groups.gIngredientQuantityModifier !== "="
2391
+ };
2392
+ if (rest.length > 0) {
2393
+ itemQuantity.equivalents = rest;
2394
+ }
2395
+ }
2396
+ const alternative = {
2397
+ index: idxInList,
2398
+ displayName
2399
+ };
2400
+ if (itemQuantity) {
2401
+ alternative.itemQuantity = itemQuantity;
2402
+ }
2403
+ const existingAlternatives = this.choices.ingredientGroups.get(groupKey);
2404
+ function upsertAlternativeToIngredient(ingredients, ingredientIdx, newAlternativeIdx) {
2405
+ const ingredient = ingredients[ingredientIdx];
2406
+ if (ingredient) {
2407
+ if (ingredient.alternatives === void 0) {
2408
+ ingredient.alternatives = /* @__PURE__ */ new Set([newAlternativeIdx]);
2409
+ } else {
2410
+ ingredient.alternatives.add(newAlternativeIdx);
2411
+ }
2412
+ }
2413
+ }
2414
+ if (existingAlternatives) {
2415
+ for (const alt of existingAlternatives) {
2416
+ upsertAlternativeToIngredient(this.ingredients, alt.index, idxInList);
2417
+ upsertAlternativeToIngredient(this.ingredients, idxInList, alt.index);
2418
+ }
2419
+ }
2420
+ const id = `ingredient-item-${this.getAndIncrementItemCount()}`;
2421
+ const newItem = {
2422
+ type: "ingredient",
2423
+ id,
2424
+ group: groupKey,
2425
+ alternatives: [alternative]
2426
+ };
2427
+ items.push(newItem);
2428
+ const choiceAlternative = deepClone(alternative);
2429
+ choiceAlternative.itemId = id;
2430
+ const existingChoice = this.choices.ingredientGroups.get(groupKey);
2431
+ if (!existingChoice) {
2432
+ this.choices.ingredientGroups.set(groupKey, [choiceAlternative]);
2433
+ } else {
2434
+ existingChoice.push(choiceAlternative);
2435
+ }
2436
+ }
2437
+ /**
2438
+ * Populates the `quantities` property for each ingredient based on
2439
+ * how they appear in the recipe preparation. Only primary ingredients
2440
+ * get quantities populated. Primary ingredients get `usedAsPrimary: true` flag.
2441
+ *
2442
+ * For inline alternatives (e.g. `\@a|b|c`), the first alternative is primary.
2443
+ * For grouped alternatives (e.g. `\@|group|a`, `\@|group|b`), the first item in the group is primary.
2444
+ *
2445
+ * Quantities are grouped by their alternative signature and summed using addEquivalentsAndSimplify.
2446
+ * @internal
2447
+ */
2448
+ _populate_ingredient_quantities() {
2449
+ for (const ing of this.ingredients) {
2450
+ delete ing.quantities;
2451
+ delete ing.usedAsPrimary;
2452
+ }
2453
+ const ingredientsWithQuantities = this.getIngredientQuantities();
2454
+ const matchedIndices = /* @__PURE__ */ new Set();
2455
+ for (const computed of ingredientsWithQuantities) {
2456
+ const idx = this.ingredients.findIndex(
2457
+ (ing2, i2) => ing2.name === computed.name && !matchedIndices.has(i2)
2458
+ );
2459
+ matchedIndices.add(idx);
2460
+ const ing = this.ingredients[idx];
2461
+ if (computed.quantities) {
2462
+ ing.quantities = computed.quantities;
2463
+ }
2464
+ if (computed.usedAsPrimary) {
2465
+ ing.usedAsPrimary = true;
2466
+ }
2467
+ }
2468
+ }
2469
+ /**
2470
+ * Gets ingredients with their quantities populated, optionally filtered by section/step
2471
+ * and respecting user choices for alternatives.
2472
+ *
2473
+ * When no options are provided, returns all recipe ingredients with quantities
2474
+ * calculated using primary alternatives (same as after parsing).
2475
+ *
2476
+ * @param options - Options for filtering and choice selection:
2477
+ * - `section`: Filter to a specific section (Section object or 0-based index)
2478
+ * - `step`: Filter to a specific step (Step object or 0-based index)
2479
+ * - `choices`: Choices for alternative ingredients (defaults to primary)
2480
+ * @returns Array of Ingredient objects with quantities populated
2481
+ *
2482
+ * @example
2483
+ * ```typescript
2484
+ * // Get all ingredients with primary alternatives
2485
+ * const ingredients = recipe.getIngredientQuantities();
2486
+ *
2487
+ * // Get ingredients for a specific section
2488
+ * const sectionIngredients = recipe.getIngredientQuantities({ section: 0 });
2489
+ *
2490
+ * // Get ingredients with specific choices applied
2491
+ * const withChoices = recipe.getIngredientQuantities({
2492
+ * choices: { ingredientItems: new Map([['ingredient-item-2', 1]]) }
2493
+ * });
2494
+ * ```
2495
+ */
2496
+ getIngredientQuantities(options) {
2497
+ const { section, step, choices } = options || {};
2498
+ const sectionsToProcess = section !== void 0 ? (() => {
2499
+ const idx = typeof section === "number" ? section : this.sections.indexOf(section);
2500
+ return idx >= 0 && idx < this.sections.length ? [this.sections[idx]] : [];
2501
+ })() : this.sections;
2502
+ const ingredientGroups = /* @__PURE__ */ new Map();
2503
+ const selectedIndices = /* @__PURE__ */ new Set();
2504
+ const referencedIndices = /* @__PURE__ */ new Set();
2505
+ for (const currentSection of sectionsToProcess) {
2506
+ const allSteps = currentSection.content.filter(
2507
+ (item) => item.type === "step"
2508
+ );
2509
+ const stepsToProcess = step === void 0 ? allSteps : typeof step === "number" ? step >= 0 && step < allSteps.length ? [allSteps[step]] : [] : allSteps.includes(step) ? [step] : [];
2510
+ for (const currentStep of stepsToProcess) {
2511
+ for (const item of currentStep.items.filter(
2512
+ (item2) => item2.type === "ingredient"
2513
+ )) {
2514
+ const isGrouped = "group" in item && item.group !== void 0;
2515
+ const groupAlternatives = isGrouped ? this.choices.ingredientGroups.get(item.group) : void 0;
2516
+ let selectedAltIndex = 0;
2517
+ let isSelected = false;
2518
+ let hasExplicitChoice = false;
2519
+ if (isGrouped) {
2520
+ const groupChoice = choices?.ingredientGroups?.get(item.group);
2521
+ hasExplicitChoice = groupChoice !== void 0;
2522
+ const targetIndex = groupChoice ?? 0;
2523
+ isSelected = groupAlternatives?.[targetIndex]?.itemId === item.id;
2524
+ } else {
2525
+ const itemChoice = choices?.ingredientItems?.get(item.id);
2526
+ hasExplicitChoice = itemChoice !== void 0;
2527
+ selectedAltIndex = itemChoice ?? 0;
2528
+ isSelected = true;
2529
+ }
2530
+ const alternative = item.alternatives[selectedAltIndex];
2531
+ if (!alternative || !isSelected) continue;
2532
+ selectedIndices.add(alternative.index);
2533
+ const allAlts = isGrouped ? groupAlternatives : item.alternatives;
2534
+ for (const alt of allAlts) {
2535
+ referencedIndices.add(alt.index);
2536
+ }
2537
+ if (!alternative.itemQuantity) continue;
2538
+ const baseQty = {
2539
+ quantity: alternative.itemQuantity.quantity,
2540
+ ...alternative.itemQuantity.unit && {
2541
+ unit: alternative.itemQuantity.unit
2542
+ }
2543
+ };
2544
+ const quantityEntry = alternative.itemQuantity.equivalents?.length ? { or: [baseQty, ...alternative.itemQuantity.equivalents] } : baseQty;
2545
+ let alternativeRefs;
2546
+ if (!hasExplicitChoice && allAlts.length > 1) {
2547
+ alternativeRefs = allAlts.filter(
2548
+ (alt) => isGrouped ? alt.itemId !== item.id : alt.index !== alternative.index
2549
+ ).map((otherAlt) => {
2550
+ const ref = { index: otherAlt.index };
2551
+ if (otherAlt.itemQuantity) {
2552
+ const altQty = {
2553
+ quantity: otherAlt.itemQuantity.quantity,
2554
+ ...otherAlt.itemQuantity.unit && {
2555
+ unit: otherAlt.itemQuantity.unit.name
2556
+ },
2557
+ ...otherAlt.itemQuantity.equivalents && {
2558
+ equivalents: otherAlt.itemQuantity.equivalents.map(
2559
+ (eq) => toPlainUnit(eq)
2560
+ )
2561
+ }
2562
+ };
2563
+ ref.quantities = [altQty];
2564
+ }
2565
+ return ref;
2566
+ });
2567
+ }
2568
+ const altIndices = getAlternativeSignature(alternativeRefs) ?? "";
2569
+ let signature;
2570
+ if (isGrouped) {
2571
+ const resolvedUnit = resolveUnit(
2572
+ alternative.itemQuantity.unit?.name
2573
+ );
2574
+ signature = `group:${item.group}|${altIndices}|${resolvedUnit.type}`;
2575
+ } else if (altIndices) {
2576
+ const resolvedUnit = resolveUnit(
2577
+ alternative.itemQuantity.unit?.name
2578
+ );
2579
+ signature = `${altIndices}|${resolvedUnit.type}}`;
2580
+ } else {
2581
+ signature = null;
2582
+ }
2583
+ if (!ingredientGroups.has(alternative.index)) {
2584
+ ingredientGroups.set(alternative.index, /* @__PURE__ */ new Map());
2585
+ }
2586
+ const groupsForIng = ingredientGroups.get(alternative.index);
2587
+ if (!groupsForIng.has(signature)) {
2588
+ groupsForIng.set(signature, {
2589
+ quantities: [],
2590
+ alternativeQuantities: /* @__PURE__ */ new Map()
2591
+ });
2592
+ }
2593
+ const group = groupsForIng.get(signature);
2594
+ group.quantities.push(quantityEntry);
2595
+ for (const ref of alternativeRefs ?? []) {
2596
+ if (!group.alternativeQuantities.has(ref.index)) {
2597
+ group.alternativeQuantities.set(ref.index, []);
2598
+ }
2599
+ for (const altQty of ref.quantities ?? []) {
2600
+ const extended = toExtendedUnit({
2601
+ quantity: altQty.quantity,
2602
+ unit: altQty.unit
2603
+ });
2604
+ if (altQty.equivalents?.length) {
2605
+ const eqEntries = [
2606
+ extended,
2607
+ ...altQty.equivalents.map((eq) => toExtendedUnit(eq))
2608
+ ];
2609
+ group.alternativeQuantities.get(ref.index).push({ or: eqEntries });
2610
+ } else {
2611
+ group.alternativeQuantities.get(ref.index).push(extended);
2612
+ }
2613
+ }
2614
+ }
2615
+ }
2616
+ }
2617
+ }
2618
+ const result = [];
2619
+ for (let index = 0; index < this.ingredients.length; index++) {
2620
+ if (!referencedIndices.has(index)) continue;
2621
+ const orig = this.ingredients[index];
2622
+ const ing = {
2623
+ name: orig.name,
2624
+ ...orig.preparation && { preparation: orig.preparation },
2625
+ ...orig.flags && { flags: orig.flags },
2626
+ ...orig.extras && { extras: orig.extras }
2627
+ };
2628
+ if (selectedIndices.has(index)) {
2629
+ ing.usedAsPrimary = true;
2630
+ const groupsForIng = ingredientGroups.get(index);
2631
+ if (groupsForIng) {
2632
+ const quantityGroups = [];
2633
+ for (const [, group] of groupsForIng) {
2634
+ const summed = addEquivalentsAndSimplify(
2635
+ group.quantities,
2636
+ this.unitSystem
2637
+ );
2638
+ const flattened = flattenPlainUnitGroup(summed);
2639
+ const alternatives = group.alternativeQuantities.size > 0 ? [...group.alternativeQuantities].map(([altIdx, altQtys]) => ({
2640
+ index: altIdx,
2641
+ ...altQtys.length > 0 && {
2642
+ quantities: flattenPlainUnitGroup(
2643
+ addEquivalentsAndSimplify(altQtys, this.unitSystem)
2644
+ ).flatMap(
2645
+ /* v8 ignore next -- item.and branch requires complex nested AND-with-equivalents structure */
2646
+ (item) => "quantity" in item ? [item] : item.and
2647
+ )
2648
+ }
2649
+ })) : void 0;
2650
+ for (const gq of flattened) {
2651
+ if ("and" in gq) {
2652
+ quantityGroups.push({
2653
+ and: gq.and,
2654
+ ...gq.equivalents?.length && {
2655
+ equivalents: gq.equivalents
2656
+ },
2657
+ ...alternatives?.length && { alternatives }
2658
+ });
2659
+ } else {
2660
+ quantityGroups.push({
2661
+ ...gq,
2662
+ ...alternatives?.length && { alternatives }
2663
+ });
2664
+ }
2665
+ }
2666
+ }
2667
+ if (quantityGroups.length > 0) {
2668
+ ing.quantities = quantityGroups;
2669
+ }
2670
+ }
2671
+ }
2672
+ result.push(ing);
2673
+ }
2674
+ return result;
2675
+ }
949
2676
  /**
950
2677
  * Parses a recipe from a string.
951
2678
  * @param content - The recipe content to parse.
952
2679
  */
953
2680
  parse(content) {
954
2681
  const cleanContent = content.replace(metadataRegex, "").replace(commentRegex, "").replace(blockCommentRegex, "").trim().split(/\r\n?|\n/);
955
- const { metadata, servings } = extractMetadata(content);
2682
+ const { metadata, servings, unitSystem } = extractMetadata(content);
956
2683
  this.metadata = metadata;
957
2684
  this.servings = servings;
2685
+ if (unitSystem) _Recipe.unitSystems.set(this, unitSystem);
958
2686
  let blankLineBefore = true;
959
2687
  let section = new Section();
960
2688
  const items = [];
961
- let note = "";
2689
+ let noteText = "";
962
2690
  let inNote = false;
963
2691
  for (const line of cleanContent) {
964
2692
  if (line.trim().length === 0) {
965
2693
  flushPendingItems(section, items);
966
- note = flushPendingNote(section, note);
2694
+ flushPendingNote(
2695
+ section,
2696
+ noteText ? this._parseNoteText(noteText) : []
2697
+ );
2698
+ noteText = "";
967
2699
  blankLineBefore = true;
968
2700
  inNote = false;
969
2701
  continue;
970
2702
  }
971
2703
  if (line.startsWith("=")) {
972
2704
  flushPendingItems(section, items);
973
- note = flushPendingNote(section, note);
2705
+ flushPendingNote(
2706
+ section,
2707
+ noteText ? this._parseNoteText(noteText) : []
2708
+ );
2709
+ noteText = "";
974
2710
  if (this.sections.length === 0 && section.isBlank()) {
975
2711
  section.name = line.replace(/^=+|=+$/g, "").trim();
976
2712
  } else {
@@ -985,22 +2721,20 @@ var Recipe = class _Recipe {
985
2721
  }
986
2722
  if (blankLineBefore && line.startsWith(">")) {
987
2723
  flushPendingItems(section, items);
988
- note = flushPendingNote(section, note);
989
- note += line.substring(1).trim();
2724
+ noteText = line.substring(1).trim();
990
2725
  inNote = true;
991
2726
  blankLineBefore = false;
992
2727
  continue;
993
2728
  }
994
2729
  if (inNote) {
995
2730
  if (line.startsWith(">")) {
996
- note += " " + line.substring(1).trim();
2731
+ noteText += " " + line.substring(1).trim();
997
2732
  } else {
998
- note += " " + line.trim();
2733
+ noteText += " " + line.trim();
999
2734
  }
1000
2735
  blankLineBefore = false;
1001
2736
  continue;
1002
2737
  }
1003
- note = flushPendingNote(section, note);
1004
2738
  let cursor = 0;
1005
2739
  for (const match of line.matchAll(tokensRegex)) {
1006
2740
  const idx = match.index;
@@ -1009,12 +2743,13 @@ var Recipe = class _Recipe {
1009
2743
  }
1010
2744
  const groups = match.groups;
1011
2745
  if (groups.mIngredientName || groups.sIngredientName) {
1012
- let name = groups.mIngredientName || groups.sIngredientName;
1013
- const scalableQuantity = (groups.mIngredientQuantityModifier || groups.sIngredientQuantityModifier) !== "=";
1014
- const quantityRaw = groups.mIngredientQuantity || groups.sIngredientQuantity;
1015
- const unit = groups.mIngredientUnit || groups.sIngredientUnit;
1016
- const preparation = groups.mIngredientPreparation || groups.sIngredientPreparation;
1017
- const modifiers = groups.mIngredientModifiers || groups.sIngredientModifiers;
2746
+ this._parseIngredientWithAlternativeRecursive(match[0], items);
2747
+ } else if (groups.gmIngredientName || groups.gsIngredientName) {
2748
+ this._parseIngredientWithGroupKey(match[0], items);
2749
+ } else if (groups.mCookwareName || groups.sCookwareName) {
2750
+ const name = groups.mCookwareName || groups.sCookwareName;
2751
+ const modifiers = groups.cookwareModifiers;
2752
+ const quantityRaw = groups.cookwareQuantity;
1018
2753
  const reference = modifiers !== void 0 && modifiers.includes("&");
1019
2754
  const flags = [];
1020
2755
  if (modifiers !== void 0 && modifiers.includes("?")) {
@@ -1023,83 +2758,31 @@ var Recipe = class _Recipe {
1023
2758
  if (modifiers !== void 0 && modifiers.includes("-")) {
1024
2759
  flags.push("hidden");
1025
2760
  }
1026
- if (modifiers !== void 0 && modifiers.includes("@") || groups.mIngredientRecipeAnchor || groups.sIngredientRecipeAnchor) {
1027
- flags.push("recipe");
1028
- }
1029
- let extras = void 0;
1030
- if (flags.includes("recipe")) {
1031
- extras = { path: `${name}.cook` };
1032
- name = name.substring(name.lastIndexOf("/") + 1);
1033
- }
1034
2761
  const quantity = quantityRaw ? parseQuantityInput(quantityRaw) : void 0;
1035
- const aliasMatch = name.match(ingredientAliasRegex);
1036
- let listName, displayName;
1037
- if (aliasMatch && aliasMatch.groups.ingredientListName.trim().length > 0 && aliasMatch.groups.ingredientDisplayName.trim().length > 0) {
1038
- listName = aliasMatch.groups.ingredientListName.trim();
1039
- displayName = aliasMatch.groups.ingredientDisplayName.trim();
1040
- } else {
1041
- listName = name;
1042
- displayName = name;
1043
- }
1044
- const newIngredient = {
1045
- name: listName,
1046
- quantity,
1047
- quantityParts: quantity ? [
1048
- {
1049
- value: quantity,
1050
- unit,
1051
- scalable: scalableQuantity
1052
- }
1053
- ] : void 0,
1054
- unit,
1055
- preparation,
1056
- flags
2762
+ const newCookware = {
2763
+ name
1057
2764
  };
1058
- if (extras) {
1059
- newIngredient.extras = extras;
2765
+ if (quantity) {
2766
+ newCookware.quantity = quantity;
2767
+ }
2768
+ if (flags.length > 0) {
2769
+ newCookware.flags = flags;
1060
2770
  }
1061
- const idxsInList = findAndUpsertIngredient(
1062
- this.ingredients,
1063
- newIngredient,
2771
+ const idxInList = findAndUpsertCookware(
2772
+ this.cookware,
2773
+ newCookware,
1064
2774
  reference
1065
2775
  );
1066
2776
  const newItem = {
1067
- type: "ingredient",
1068
- index: idxsInList.ingredientIndex,
1069
- displayName
2777
+ type: "cookware",
2778
+ index: idxInList
1070
2779
  };
1071
- if (idxsInList.quantityPartIndex !== void 0) {
1072
- newItem.quantityPartIndex = idxsInList.quantityPartIndex;
2780
+ if (quantity) {
2781
+ newItem.quantity = quantity;
1073
2782
  }
1074
2783
  items.push(newItem);
1075
- } else if (groups.mCookwareName || groups.sCookwareName) {
1076
- const name = groups.mCookwareName || groups.sCookwareName;
1077
- const modifiers = groups.mCookwareModifiers || groups.sCookwareModifiers;
1078
- const quantityRaw = groups.mCookwareQuantity || groups.sCookwareQuantity;
1079
- const reference = modifiers !== void 0 && modifiers.includes("&");
1080
- const flags = [];
1081
- if (modifiers !== void 0 && modifiers.includes("?")) {
1082
- flags.push("optional");
1083
- }
1084
- if (modifiers !== void 0 && modifiers.includes("-")) {
1085
- flags.push("hidden");
1086
- }
1087
- const quantity = quantityRaw ? parseQuantityInput(quantityRaw) : void 0;
1088
- const idxsInList = findAndUpsertCookware(
1089
- this.cookware,
1090
- {
1091
- name,
1092
- quantity,
1093
- quantityParts: quantity ? [quantity] : void 0,
1094
- flags
1095
- },
1096
- reference
1097
- );
1098
- items.push({
1099
- type: "cookware",
1100
- index: idxsInList.cookwareIndex,
1101
- quantityPartIndex: idxsInList.quantityPartIndex
1102
- });
2784
+ } else if (groups.arbitraryQuantity) {
2785
+ this._parseArbitraryScalable(groups, items);
1103
2786
  } else {
1104
2787
  const durationStr = groups.timerQuantity.trim();
1105
2788
  const unit = (groups.timerUnit || "").trim();
@@ -1123,10 +2806,11 @@ var Recipe = class _Recipe {
1123
2806
  blankLineBefore = false;
1124
2807
  }
1125
2808
  flushPendingItems(section, items);
1126
- note = flushPendingNote(section, note);
2809
+ flushPendingNote(section, noteText ? this._parseNoteText(noteText) : []);
1127
2810
  if (!section.isBlank()) {
1128
2811
  this.sections.push(section);
1129
2812
  }
2813
+ this._populate_ingredient_quantities();
1130
2814
  }
1131
2815
  /**
1132
2816
  * Scales the recipe to a new number of servings. In practice, it calls
@@ -1141,7 +2825,7 @@ var Recipe = class _Recipe {
1141
2825
  if (originalServings === void 0 || originalServings === 0) {
1142
2826
  originalServings = 1;
1143
2827
  }
1144
- const factor = (0, import_big2.default)(newServings).div(originalServings);
2828
+ const factor = (0, import_big4.default)(newServings).div(originalServings);
1145
2829
  return this.scaleBy(factor);
1146
2830
  }
1147
2831
  /**
@@ -1156,44 +2840,83 @@ var Recipe = class _Recipe {
1156
2840
  if (originalServings === void 0 || originalServings === 0) {
1157
2841
  originalServings = 1;
1158
2842
  }
1159
- newRecipe.ingredients = newRecipe.ingredients.map((ingredient) => {
1160
- if (ingredient.quantityParts) {
1161
- ingredient.quantityParts = ingredient.quantityParts.map(
1162
- (quantityPart) => {
1163
- if (quantityPart.value.type === "fixed" && quantityPart.value.value.type === "text") {
1164
- return quantityPart;
1165
- }
1166
- return {
1167
- ...quantityPart,
1168
- value: multiplyQuantityValue(
1169
- quantityPart.value,
1170
- quantityPart.scalable ? (0, import_big2.default)(factor) : 1
1171
- )
1172
- };
2843
+ const unitSystem = this.unitSystem;
2844
+ function scaleAlternativesBy(alternatives, factor2) {
2845
+ for (const alternative of alternatives) {
2846
+ if (alternative.itemQuantity) {
2847
+ const scaleFactor = alternative.itemQuantity.scalable ? (0, import_big4.default)(factor2) : 1;
2848
+ if (alternative.itemQuantity.quantity.type !== "fixed" || alternative.itemQuantity.quantity.value.type !== "text") {
2849
+ alternative.itemQuantity.quantity = multiplyQuantityValue(
2850
+ alternative.itemQuantity.quantity,
2851
+ scaleFactor
2852
+ );
1173
2853
  }
1174
- );
1175
- if (ingredient.quantityParts.length === 1) {
1176
- ingredient.quantity = ingredient.quantityParts[0].value;
1177
- ingredient.unit = ingredient.quantityParts[0].unit;
1178
- } else {
1179
- const totalQuantity = ingredient.quantityParts.reduce(
1180
- (acc, val) => addQuantities(acc, { value: val.value, unit: val.unit }),
1181
- { value: getDefaultQuantityValue() }
2854
+ if (alternative.itemQuantity.equivalents) {
2855
+ alternative.itemQuantity.equivalents = alternative.itemQuantity.equivalents.map(
2856
+ (altQuantity) => {
2857
+ if (altQuantity.quantity.type === "fixed" && altQuantity.quantity.value.type === "text") {
2858
+ return altQuantity;
2859
+ } else {
2860
+ return {
2861
+ ...altQuantity,
2862
+ quantity: multiplyQuantityValue(
2863
+ altQuantity.quantity,
2864
+ scaleFactor
2865
+ )
2866
+ };
2867
+ }
2868
+ }
2869
+ );
2870
+ }
2871
+ const optimizedPrimary = applyBestUnit(
2872
+ {
2873
+ quantity: alternative.itemQuantity.quantity,
2874
+ unit: alternative.itemQuantity.unit
2875
+ },
2876
+ unitSystem
1182
2877
  );
1183
- ingredient.quantity = totalQuantity.value;
1184
- ingredient.unit = totalQuantity.unit;
2878
+ alternative.itemQuantity.quantity = optimizedPrimary.quantity;
2879
+ alternative.itemQuantity.unit = optimizedPrimary.unit;
2880
+ if (alternative.itemQuantity.equivalents) {
2881
+ alternative.itemQuantity.equivalents = alternative.itemQuantity.equivalents.map(
2882
+ (eq) => applyBestUnit(eq, unitSystem)
2883
+ );
2884
+ }
1185
2885
  }
1186
2886
  }
1187
- return ingredient;
1188
- }).filter((ingredient) => ingredient.quantity !== null);
1189
- newRecipe.servings = (0, import_big2.default)(originalServings).times(factor).toNumber();
2887
+ }
2888
+ for (const section of newRecipe.sections) {
2889
+ for (const step of section.content.filter(
2890
+ (item) => item.type === "step"
2891
+ )) {
2892
+ for (const item of step.items.filter(
2893
+ (item2) => item2.type === "ingredient"
2894
+ )) {
2895
+ scaleAlternativesBy(item.alternatives, factor);
2896
+ }
2897
+ }
2898
+ }
2899
+ for (const alternatives of newRecipe.choices.ingredientGroups.values()) {
2900
+ scaleAlternativesBy(alternatives, factor);
2901
+ }
2902
+ for (const alternatives of newRecipe.choices.ingredientItems.values()) {
2903
+ scaleAlternativesBy(alternatives, factor);
2904
+ }
2905
+ for (const arbitrary of newRecipe.arbitraries) {
2906
+ arbitrary.quantity = multiplyQuantityValue(
2907
+ arbitrary.quantity,
2908
+ factor
2909
+ );
2910
+ }
2911
+ newRecipe._populate_ingredient_quantities();
2912
+ newRecipe.servings = (0, import_big4.default)(originalServings).times(factor).toNumber();
1190
2913
  if (newRecipe.metadata.servings && this.metadata.servings) {
1191
2914
  if (floatRegex.test(String(this.metadata.servings).replace(",", ".").trim())) {
1192
2915
  const servingsValue = parseFloat(
1193
2916
  String(this.metadata.servings).replace(",", ".")
1194
2917
  );
1195
2918
  newRecipe.metadata.servings = String(
1196
- (0, import_big2.default)(servingsValue).times(factor).toNumber()
2919
+ (0, import_big4.default)(servingsValue).times(factor).toNumber()
1197
2920
  );
1198
2921
  }
1199
2922
  }
@@ -1203,7 +2926,7 @@ var Recipe = class _Recipe {
1203
2926
  String(this.metadata.yield).replace(",", ".")
1204
2927
  );
1205
2928
  newRecipe.metadata.yield = String(
1206
- (0, import_big2.default)(yieldValue).times(factor).toNumber()
2929
+ (0, import_big4.default)(yieldValue).times(factor).toNumber()
1207
2930
  );
1208
2931
  }
1209
2932
  }
@@ -1213,10 +2936,143 @@ var Recipe = class _Recipe {
1213
2936
  String(this.metadata.serves).replace(",", ".")
1214
2937
  );
1215
2938
  newRecipe.metadata.serves = String(
1216
- (0, import_big2.default)(servesValue).times(factor).toNumber()
2939
+ (0, import_big4.default)(servesValue).times(factor).toNumber()
2940
+ );
2941
+ }
2942
+ }
2943
+ return newRecipe;
2944
+ }
2945
+ /**
2946
+ * Converts all ingredient quantities in the recipe to a target unit system.
2947
+ *
2948
+ * @param system - The target unit system to convert to (metric, US, UK, JP)
2949
+ * @param method - How to handle existing quantities:
2950
+ * - "keep": Keep all existing equivalents (swap if needed, or add converted)
2951
+ * - "replace": Replace primary with target system quantity, discard equivalent used for conversion
2952
+ * - "remove": Only keep target system quantity, delete all equivalents
2953
+ * @returns A new Recipe instance with converted quantities
2954
+ *
2955
+ * @example
2956
+ * ```typescript
2957
+ * // Convert a recipe to metric, keeping original units as equivalents
2958
+ * const metricRecipe = recipe.convertTo("metric", "keep");
2959
+ *
2960
+ * // Convert to US units, removing all other equivalents
2961
+ * const usRecipe = recipe.convertTo("US", "remove");
2962
+ * ```
2963
+ */
2964
+ convertTo(system, method) {
2965
+ const newRecipe = this.clone();
2966
+ function buildNewPrimary(convertedQty, oldPrimary, remainingEquivalents, scalable, integerProtected, source) {
2967
+ const newUnit = integerProtected && convertedQty.unit ? { name: convertedQty.unit.name, integerProtected: true } : convertedQty.unit;
2968
+ const newPrimary = {
2969
+ quantity: convertedQty.quantity,
2970
+ unit: newUnit,
2971
+ scalable
2972
+ };
2973
+ if (method === "remove") {
2974
+ return newPrimary;
2975
+ } else if (method === "replace") {
2976
+ if (remainingEquivalents.length > 0) {
2977
+ newPrimary.equivalents = remainingEquivalents;
2978
+ if (source === "converted") newPrimary.equivalents.push(oldPrimary);
2979
+ }
2980
+ } else {
2981
+ newPrimary.equivalents = [oldPrimary, ...remainingEquivalents];
2982
+ }
2983
+ return newPrimary;
2984
+ }
2985
+ function convertItemQuantity(itemQuantity) {
2986
+ const primaryUnit = resolveUnit(itemQuantity.unit?.name);
2987
+ const equivalents = itemQuantity.equivalents ?? [];
2988
+ const oldPrimary = {
2989
+ quantity: itemQuantity.quantity,
2990
+ unit: itemQuantity.unit
2991
+ };
2992
+ if (primaryUnit.type !== "other" && isUnitCompatibleWithSystem(primaryUnit, system)) {
2993
+ if (method === "remove") {
2994
+ return { ...itemQuantity, equivalents: void 0 };
2995
+ }
2996
+ return itemQuantity;
2997
+ }
2998
+ const targetEquivIndex = equivalents.findIndex((eq) => {
2999
+ const eqUnit = resolveUnit(eq.unit?.name);
3000
+ return eqUnit.type !== "other" && isUnitCompatibleWithSystem(eqUnit, system);
3001
+ });
3002
+ if (targetEquivIndex !== -1) {
3003
+ const targetEquiv = equivalents[targetEquivIndex];
3004
+ const remainingEquivalents = equivalents.filter(
3005
+ (_, i2) => i2 !== targetEquivIndex
1217
3006
  );
3007
+ return buildNewPrimary(
3008
+ targetEquiv,
3009
+ oldPrimary,
3010
+ remainingEquivalents,
3011
+ itemQuantity.scalable,
3012
+ targetEquiv.unit?.integerProtected,
3013
+ "swapped"
3014
+ );
3015
+ }
3016
+ const converted = convertQuantityToSystem(oldPrimary, system);
3017
+ if (converted && converted.unit) {
3018
+ return buildNewPrimary(
3019
+ converted,
3020
+ oldPrimary,
3021
+ equivalents,
3022
+ itemQuantity.scalable,
3023
+ itemQuantity.unit?.integerProtected,
3024
+ "swapped"
3025
+ );
3026
+ }
3027
+ for (let i2 = 0; i2 < equivalents.length; i2++) {
3028
+ const equiv = equivalents[i2];
3029
+ const convertedEquiv = convertQuantityToSystem(equiv, system);
3030
+ if (convertedEquiv && convertedEquiv.unit) {
3031
+ const remainingEquivalents = method === "keep" ? equivalents : equivalents.filter((_, idx) => idx !== i2);
3032
+ return buildNewPrimary(
3033
+ convertedEquiv,
3034
+ oldPrimary,
3035
+ remainingEquivalents,
3036
+ itemQuantity.scalable,
3037
+ equiv.unit?.integerProtected,
3038
+ "converted"
3039
+ );
3040
+ }
3041
+ }
3042
+ if (method === "remove") {
3043
+ return { ...itemQuantity, equivalents: void 0 };
3044
+ } else {
3045
+ return itemQuantity;
3046
+ }
3047
+ }
3048
+ function convertAlternatives(alternatives) {
3049
+ for (const alternative of alternatives) {
3050
+ if (alternative.itemQuantity) {
3051
+ alternative.itemQuantity = convertItemQuantity(
3052
+ alternative.itemQuantity
3053
+ );
3054
+ }
1218
3055
  }
1219
3056
  }
3057
+ for (const section of newRecipe.sections) {
3058
+ for (const step of section.content.filter(
3059
+ (item) => item.type === "step"
3060
+ )) {
3061
+ for (const item of step.items.filter(
3062
+ (item2) => item2.type === "ingredient"
3063
+ )) {
3064
+ convertAlternatives(item.alternatives);
3065
+ }
3066
+ }
3067
+ }
3068
+ for (const alternatives of newRecipe.choices.ingredientGroups.values()) {
3069
+ convertAlternatives(alternatives);
3070
+ }
3071
+ for (const alternatives of newRecipe.choices.ingredientItems.values()) {
3072
+ convertAlternatives(alternatives);
3073
+ }
3074
+ newRecipe._populate_ingredient_quantities();
3075
+ if (method !== "keep") _Recipe.unitSystems.set(newRecipe, system);
1220
3076
  return newRecipe;
1221
3077
  }
1222
3078
  /**
@@ -1236,19 +3092,33 @@ var Recipe = class _Recipe {
1236
3092
  */
1237
3093
  clone() {
1238
3094
  const newRecipe = new _Recipe();
1239
- newRecipe.metadata = JSON.parse(JSON.stringify(this.metadata));
1240
- newRecipe.ingredients = JSON.parse(
1241
- JSON.stringify(this.ingredients)
1242
- );
1243
- newRecipe.sections = JSON.parse(JSON.stringify(this.sections));
1244
- newRecipe.cookware = JSON.parse(
1245
- JSON.stringify(this.cookware)
1246
- );
1247
- newRecipe.timers = JSON.parse(JSON.stringify(this.timers));
3095
+ newRecipe.choices = deepClone(this.choices);
3096
+ _Recipe.itemCounts.set(newRecipe, this.getItemCount());
3097
+ newRecipe.metadata = deepClone(this.metadata);
3098
+ newRecipe.ingredients = deepClone(this.ingredients);
3099
+ newRecipe.sections = this.sections.map((section) => {
3100
+ const newSection = new Section(section.name);
3101
+ newSection.content = deepClone(section.content);
3102
+ return newSection;
3103
+ });
3104
+ newRecipe.cookware = deepClone(this.cookware);
3105
+ newRecipe.timers = deepClone(this.timers);
3106
+ newRecipe.arbitraries = deepClone(this.arbitraries);
1248
3107
  newRecipe.servings = this.servings;
1249
3108
  return newRecipe;
1250
3109
  }
1251
3110
  };
3111
+ /**
3112
+ * External storage for unit system (not a property on instances).
3113
+ * Used for resolving ambiguous units during quantity addition.
3114
+ */
3115
+ __publicField(_Recipe, "unitSystems", /* @__PURE__ */ new WeakMap());
3116
+ /**
3117
+ * External storage for item count (not a property on instances).
3118
+ * Used for giving ID numbers to items during parsing.
3119
+ */
3120
+ __publicField(_Recipe, "itemCounts", /* @__PURE__ */ new WeakMap());
3121
+ var Recipe = _Recipe;
1252
3122
 
1253
3123
  // src/classes/shopping_list.ts
1254
3124
  var ShoppingList = class {
@@ -1257,6 +3127,7 @@ var ShoppingList = class {
1257
3127
  * @param category_config_str - The category configuration to parse.
1258
3128
  */
1259
3129
  constructor(category_config_str) {
3130
+ // TODO: backport type change
1260
3131
  /**
1261
3132
  * The ingredients in the shopping list.
1262
3133
  */
@@ -1279,6 +3150,33 @@ var ShoppingList = class {
1279
3150
  }
1280
3151
  calculate_ingredients() {
1281
3152
  this.ingredients = [];
3153
+ const addIngredientQuantity = (name, quantityTotal) => {
3154
+ const quantityTotalExtended = extendAllUnits(quantityTotal);
3155
+ const newQuantities = isAndGroup(quantityTotalExtended) ? quantityTotalExtended.and : [quantityTotalExtended];
3156
+ const existing = this.ingredients.find((i2) => i2.name === name);
3157
+ if (existing) {
3158
+ if (!existing.quantityTotal) {
3159
+ existing.quantityTotal = quantityTotal;
3160
+ return;
3161
+ }
3162
+ try {
3163
+ const existingQuantityTotalExtended = extendAllUnits(
3164
+ existing.quantityTotal
3165
+ );
3166
+ const existingQuantities = isAndGroup(existingQuantityTotalExtended) ? existingQuantityTotalExtended.and : [existingQuantityTotalExtended];
3167
+ existing.quantityTotal = addEquivalentsAndSimplify([
3168
+ ...existingQuantities,
3169
+ ...newQuantities
3170
+ ]);
3171
+ return;
3172
+ } catch {
3173
+ }
3174
+ }
3175
+ this.ingredients.push({
3176
+ name,
3177
+ quantityTotal
3178
+ });
3179
+ };
1282
3180
  for (const addedRecipe of this.recipes) {
1283
3181
  let scaledRecipe;
1284
3182
  if ("factor" in addedRecipe) {
@@ -1287,67 +3185,123 @@ var ShoppingList = class {
1287
3185
  } else {
1288
3186
  scaledRecipe = addedRecipe.recipe.scaleTo(addedRecipe.servings);
1289
3187
  }
1290
- for (const ingredient of scaledRecipe.ingredients) {
3188
+ const ingredients = scaledRecipe.getIngredientQuantities({
3189
+ choices: addedRecipe.choices
3190
+ });
3191
+ for (const ingredient of ingredients) {
1291
3192
  if (ingredient.flags && ingredient.flags.includes("hidden")) {
1292
3193
  continue;
1293
3194
  }
1294
- const existingIngredient = this.ingredients.find(
1295
- (i2) => i2.name === ingredient.name
1296
- );
1297
- let addSeparate = false;
1298
- try {
1299
- if (existingIngredient && ingredient.quantity) {
1300
- if (existingIngredient.quantity) {
1301
- const newQuantity = addQuantities(
1302
- {
1303
- value: existingIngredient.quantity,
1304
- unit: existingIngredient.unit ?? ""
1305
- },
1306
- {
1307
- value: ingredient.quantity,
1308
- unit: ingredient.unit ?? ""
1309
- }
1310
- );
1311
- existingIngredient.quantity = newQuantity.value;
1312
- if (newQuantity.unit) {
1313
- existingIngredient.unit = newQuantity.unit;
3195
+ if (!ingredient.usedAsPrimary) {
3196
+ continue;
3197
+ }
3198
+ if (ingredient.quantities && ingredient.quantities.length > 0) {
3199
+ const allQuantities = [];
3200
+ for (const qGroup of ingredient.quantities) {
3201
+ if ("and" in qGroup) {
3202
+ for (const qty of qGroup.and) {
3203
+ allQuantities.push(qty);
1314
3204
  }
1315
3205
  } else {
1316
- existingIngredient.quantity = ingredient.quantity;
1317
- if (ingredient.unit) {
1318
- existingIngredient.unit = ingredient.unit;
1319
- }
3206
+ const plainQty = {
3207
+ quantity: qGroup.quantity
3208
+ };
3209
+ if (qGroup.unit) plainQty.unit = qGroup.unit;
3210
+ if (qGroup.equivalents) plainQty.equivalents = qGroup.equivalents;
3211
+ allQuantities.push(plainQty);
1320
3212
  }
1321
3213
  }
1322
- } catch {
1323
- addSeparate = true;
1324
- }
1325
- if (!existingIngredient || addSeparate) {
1326
- const newIngredient = { name: ingredient.name };
1327
- if (ingredient.quantity) {
1328
- newIngredient.quantity = ingredient.quantity;
1329
- }
1330
- if (ingredient.unit) {
1331
- newIngredient.unit = ingredient.unit;
3214
+ if (allQuantities.length === 1) {
3215
+ addIngredientQuantity(ingredient.name, allQuantities[0]);
3216
+ } else {
3217
+ const extendedQuantities = allQuantities.map(
3218
+ (q) => extendAllUnits(q)
3219
+ );
3220
+ const totalQuantity = addEquivalentsAndSimplify(
3221
+ extendedQuantities
3222
+ );
3223
+ addIngredientQuantity(ingredient.name, totalQuantity);
1332
3224
  }
1333
- this.ingredients.push(newIngredient);
3225
+ } else if (!this.ingredients.some((i2) => i2.name === ingredient.name)) {
3226
+ this.ingredients.push({ name: ingredient.name });
1334
3227
  }
1335
3228
  }
1336
3229
  }
1337
3230
  }
1338
- add_recipe(recipe, scaling) {
1339
- if (typeof scaling === "number" || scaling === void 0) {
1340
- this.recipes.push({ recipe, factor: scaling ?? 1 });
3231
+ /**
3232
+ * Adds a recipe to the shopping list, then automatically
3233
+ * recalculates the quantities and recategorize the ingredients.
3234
+ * @param recipe - The recipe to add.
3235
+ * @param options - Options for adding the recipe.
3236
+ * @throws Error if the recipe has alternatives without corresponding choices.
3237
+ */
3238
+ add_recipe(recipe, options = {}) {
3239
+ const errorMessage = this.getUnresolvedAlternativesError(
3240
+ recipe,
3241
+ options.choices
3242
+ );
3243
+ if (errorMessage) {
3244
+ throw new Error(errorMessage);
3245
+ }
3246
+ if (!options.scaling) {
3247
+ this.recipes.push({
3248
+ recipe,
3249
+ factor: options.scaling ?? 1,
3250
+ choices: options.choices
3251
+ });
1341
3252
  } else {
1342
- if ("factor" in scaling) {
1343
- this.recipes.push({ recipe, factor: scaling.factor });
3253
+ if ("factor" in options.scaling) {
3254
+ this.recipes.push({
3255
+ recipe,
3256
+ factor: options.scaling.factor,
3257
+ choices: options.choices
3258
+ });
1344
3259
  } else {
1345
- this.recipes.push({ recipe, servings: scaling.servings });
3260
+ this.recipes.push({
3261
+ recipe,
3262
+ servings: options.scaling.servings,
3263
+ choices: options.choices
3264
+ });
1346
3265
  }
1347
3266
  }
1348
3267
  this.calculate_ingredients();
1349
3268
  this.categorize();
1350
3269
  }
3270
+ /**
3271
+ * Checks if a recipe has unresolved alternatives (alternatives without provided choices).
3272
+ * @param recipe - The recipe to check.
3273
+ * @param choices - The choices provided for the recipe.
3274
+ * @returns An error message if there are unresolved alternatives, undefined otherwise.
3275
+ */
3276
+ getUnresolvedAlternativesError(recipe, choices) {
3277
+ const missingItems = [];
3278
+ const missingGroups = [];
3279
+ for (const itemId of recipe.choices.ingredientItems.keys()) {
3280
+ if (!choices?.ingredientItems?.has(itemId)) {
3281
+ missingItems.push(itemId);
3282
+ }
3283
+ }
3284
+ for (const groupId of recipe.choices.ingredientGroups.keys()) {
3285
+ if (!choices?.ingredientGroups?.has(groupId)) {
3286
+ missingGroups.push(groupId);
3287
+ }
3288
+ }
3289
+ if (missingItems.length === 0 && missingGroups.length === 0) {
3290
+ return void 0;
3291
+ }
3292
+ const parts = [];
3293
+ if (missingItems.length > 0) {
3294
+ parts.push(
3295
+ `ingredientItems: [${missingItems.map((i2) => `'${i2}'`).join(", ")}]`
3296
+ );
3297
+ }
3298
+ if (missingGroups.length > 0) {
3299
+ parts.push(
3300
+ `ingredientGroups: [${missingGroups.map((g) => `'${g}'`).join(", ")}]`
3301
+ );
3302
+ }
3303
+ return `Recipe has unresolved alternatives. Missing choices for: ${parts.join(", ")}`;
3304
+ }
1351
3305
  /**
1352
3306
  * Removes a recipe from the shopping list, then automatically
1353
3307
  * recalculates the quantities and recategorize the ingredients.s
@@ -1407,15 +3361,384 @@ var ShoppingList = class {
1407
3361
  this.categories = categories;
1408
3362
  }
1409
3363
  };
3364
+
3365
+ // src/classes/shopping_cart.ts
3366
+ var import_yalps = require("yalps");
3367
+ var ShoppingCart = class {
3368
+ /**
3369
+ * Creates a new ShoppingCart instance
3370
+ * @param options - {@link ShoppingCartOptions | Options} for the constructor
3371
+ */
3372
+ constructor(options) {
3373
+ /**
3374
+ * The product catalog to use for matching products
3375
+ */
3376
+ __publicField(this, "productCatalog");
3377
+ /**
3378
+ * The shopping list to build the cart from
3379
+ */
3380
+ __publicField(this, "shoppingList");
3381
+ /**
3382
+ * The content of the cart
3383
+ */
3384
+ __publicField(this, "cart", []);
3385
+ /**
3386
+ * The ingredients that were successfully matched with products
3387
+ */
3388
+ __publicField(this, "match", []);
3389
+ /**
3390
+ * The ingredients that could not be matched with products
3391
+ */
3392
+ __publicField(this, "misMatch", []);
3393
+ /**
3394
+ * Key information about the shopping cart
3395
+ */
3396
+ __publicField(this, "summary");
3397
+ if (options?.catalog) this.productCatalog = options.catalog;
3398
+ if (options?.list) this.shoppingList = options.list;
3399
+ this.summary = { totalPrice: 0, totalItems: 0 };
3400
+ }
3401
+ /**
3402
+ * Sets the product catalog to use for matching products
3403
+ * To use if a catalog was not provided at the creation of the instance
3404
+ * @param catalog - The {@link ProductCatalog} to set
3405
+ */
3406
+ setProductCatalog(catalog) {
3407
+ this.productCatalog = catalog;
3408
+ }
3409
+ // TODO: harmonize recipe name to use underscores
3410
+ /**
3411
+ * Sets the shopping list to build the cart from.
3412
+ * To use if a shopping list was not provided at the creation of the instance
3413
+ * @param list - The {@link ShoppingList} to set
3414
+ */
3415
+ setShoppingList(list) {
3416
+ this.shoppingList = list;
3417
+ }
3418
+ /**
3419
+ * Builds the cart from the shopping list and product catalog
3420
+ * @remarks
3421
+ * - 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
3422
+ * in addition to that combination being added to the {@link ShoppingCart.cart | cart}.
3423
+ * - Otherwise, the latter will be listed in the {@link ShoppingCart.misMatch | misMatch} array. Possible causes can be:
3424
+ * - No product is listed in the catalog for that ingredient
3425
+ * - The ingredient has no quantity, a text quantity
3426
+ * - The ingredient's quantity unit is incompatible with the units of the candidate products listed in the catalog
3427
+ * @throws {@link NoProductCatalogForCartError} if no product catalog is set
3428
+ * @throws {@link NoShoppingListForCartError} if no shopping list is set
3429
+ * @returns `true` if all ingredients in the shopping list have been matched to products in the catalog, or `false` otherwise
3430
+ */
3431
+ buildCart() {
3432
+ this.resetCart();
3433
+ if (this.productCatalog === void 0) {
3434
+ throw new NoProductCatalogForCartError();
3435
+ } else if (this.shoppingList === void 0) {
3436
+ throw new NoShoppingListForCartError();
3437
+ }
3438
+ for (const ingredient of this.shoppingList.ingredients) {
3439
+ const productOptions = this.getProductOptions(ingredient);
3440
+ try {
3441
+ const optimumMatch = this.getOptimumMatch(ingredient, productOptions);
3442
+ this.cart.push(...optimumMatch);
3443
+ this.match.push({ ingredient, selection: optimumMatch });
3444
+ } catch (error) {
3445
+ if (error instanceof NoProductMatchError) {
3446
+ this.misMatch.push({ ingredient, reason: error.code });
3447
+ }
3448
+ }
3449
+ }
3450
+ this.summarize();
3451
+ return this.misMatch.length > 0;
3452
+ }
3453
+ /**
3454
+ * Gets the product options for a given ingredient
3455
+ * @param ingredient - The ingredient to get the product options for
3456
+ * @returns An array of {@link ProductOption}
3457
+ */
3458
+ getProductOptions(ingredient) {
3459
+ return this.productCatalog.products.filter(
3460
+ (product) => product.ingredientName === ingredient.name || product.ingredientAliases?.includes(ingredient.name)
3461
+ );
3462
+ }
3463
+ /**
3464
+ * Gets the optimum match for a given ingredient and product option
3465
+ * @param ingredient - The ingredient to match
3466
+ * @param options - The product options to choose from
3467
+ * @returns An array of {@link ProductSelection}
3468
+ * @throws {@link NoProductMatchError} if no match can be found
3469
+ */
3470
+ getOptimumMatch(ingredient, options) {
3471
+ if (options.length === 0)
3472
+ throw new NoProductMatchError(ingredient.name, "noProduct");
3473
+ if (!ingredient.quantityTotal)
3474
+ throw new NoProductMatchError(ingredient.name, "noQuantity");
3475
+ const normalizedOptions = options.map(
3476
+ (option) => ({
3477
+ ...option,
3478
+ sizes: option.sizes.map((s) => {
3479
+ const resolvedUnit = resolveUnit(s.unit);
3480
+ return {
3481
+ size: resolvedUnit && "toBase" in resolvedUnit ? multiplyQuantityValue(
3482
+ s.size,
3483
+ resolvedUnit.toBase
3484
+ ) : s.size,
3485
+ unit: resolvedUnit
3486
+ };
3487
+ })
3488
+ })
3489
+ );
3490
+ const normalizedQuantityTotal = normalizeAllUnits(ingredient.quantityTotal);
3491
+ function getOptimumMatchForQuantityParts(normalizedQuantities, normalizedOptions2, selection = []) {
3492
+ if (isAndGroup(normalizedQuantities)) {
3493
+ for (const q of normalizedQuantities.and) {
3494
+ const result = getOptimumMatchForQuantityParts(
3495
+ q,
3496
+ normalizedOptions2,
3497
+ selection
3498
+ );
3499
+ selection.push(...result);
3500
+ }
3501
+ } else {
3502
+ const alternativeUnitsOfQuantity = isOrGroup(normalizedQuantities) ? normalizedQuantities.or : [normalizedQuantities];
3503
+ const solutions = [];
3504
+ const errors = /* @__PURE__ */ new Set();
3505
+ for (const alternative of alternativeUnitsOfQuantity) {
3506
+ if (alternative.quantity.type === "fixed" && alternative.quantity.value.type === "text") {
3507
+ errors.add("textValue");
3508
+ continue;
3509
+ }
3510
+ const scaledQuantity = multiplyQuantityValue(
3511
+ alternative.quantity,
3512
+ "toBase" in alternative.unit ? alternative.unit.toBase : 1
3513
+ );
3514
+ alternative.quantity = scaledQuantity;
3515
+ const matchOptions = normalizedOptions2.filter(
3516
+ (option) => option.sizes.some(
3517
+ (s) => areUnitsGroupable(alternative.unit, s.unit)
3518
+ )
3519
+ );
3520
+ if (matchOptions.length > 0) {
3521
+ const findCompatibleSize = (option) => option.sizes.find(
3522
+ (s) => areUnitsGroupable(alternative.unit, s.unit)
3523
+ );
3524
+ if (matchOptions.length == 1) {
3525
+ const matchedOption = matchOptions[0];
3526
+ const compatibleSize = findCompatibleSize(matchedOption);
3527
+ const product = options.find(
3528
+ (opt) => opt.id === matchedOption.id
3529
+ );
3530
+ const targetQuantity = scaledQuantity.type === "fixed" ? scaledQuantity.value : scaledQuantity.min;
3531
+ const resQuantity = Math.ceil(
3532
+ getNumericValue(targetQuantity) / getNumericValue(compatibleSize.size.value)
3533
+ );
3534
+ solutions.push([
3535
+ {
3536
+ product,
3537
+ quantity: resQuantity,
3538
+ totalPrice: resQuantity * matchedOption.price
3539
+ }
3540
+ ]);
3541
+ continue;
3542
+ }
3543
+ const model = {
3544
+ direction: "minimize",
3545
+ objective: "price",
3546
+ integers: true,
3547
+ constraints: {
3548
+ size: {
3549
+ min: scaledQuantity.type === "fixed" ? getNumericValue(scaledQuantity.value) : getNumericValue(scaledQuantity.min)
3550
+ }
3551
+ },
3552
+ variables: matchOptions.reduce(
3553
+ (acc, option) => {
3554
+ const compatibleSize = findCompatibleSize(option);
3555
+ acc[option.id] = {
3556
+ price: option.price,
3557
+ size: getNumericValue(compatibleSize.size.value)
3558
+ };
3559
+ return acc;
3560
+ },
3561
+ {}
3562
+ )
3563
+ };
3564
+ const solution = (0, import_yalps.solve)(model);
3565
+ solutions.push(
3566
+ solution.variables.map((variable) => {
3567
+ const resProductSelection = {
3568
+ product: options.find((option) => option.id === variable[0]),
3569
+ quantity: variable[1]
3570
+ };
3571
+ return {
3572
+ ...resProductSelection,
3573
+ totalPrice: resProductSelection.quantity * resProductSelection.product.price
3574
+ };
3575
+ })
3576
+ );
3577
+ } else {
3578
+ errors.add("incompatibleUnits");
3579
+ }
3580
+ }
3581
+ if (solutions.length === 0) {
3582
+ throw new NoProductMatchError(
3583
+ ingredient.name,
3584
+ errors.size === 1 ? errors.values().next().value : "textValue_incompatibleUnits"
3585
+ );
3586
+ } else {
3587
+ return solutions.sort(
3588
+ (a2, b) => a2.reduce((acc, item) => acc + item.totalPrice, 0) - b.reduce((acc, item) => acc + item.totalPrice, 0)
3589
+ )[0];
3590
+ }
3591
+ }
3592
+ return selection;
3593
+ }
3594
+ return getOptimumMatchForQuantityParts(
3595
+ normalizedQuantityTotal,
3596
+ normalizedOptions
3597
+ );
3598
+ }
3599
+ /**
3600
+ * Reset the cart's properties
3601
+ */
3602
+ resetCart() {
3603
+ this.cart = [];
3604
+ this.match = [];
3605
+ this.misMatch = [];
3606
+ this.summary = { totalPrice: 0, totalItems: 0 };
3607
+ }
3608
+ /**
3609
+ * Calculate the cart's key info and store it in the cart's {@link ShoppingCart.summary | summary} property.
3610
+ * This function is automatically invoked by {@link ShoppingCart.buildCart | buildCart() } method.
3611
+ * @returns the total price and number of items in the cart
3612
+ */
3613
+ summarize() {
3614
+ this.summary.totalPrice = this.cart.reduce(
3615
+ (acc, item) => acc + item.totalPrice,
3616
+ 0
3617
+ );
3618
+ this.summary.totalItems = this.cart.length;
3619
+ return this.summary;
3620
+ }
3621
+ };
3622
+
3623
+ // src/utils/render_helpers.ts
3624
+ var VULGAR_FRACTIONS = {
3625
+ "1/2": "\xBD",
3626
+ "1/3": "\u2153",
3627
+ "2/3": "\u2154",
3628
+ "1/4": "\xBC",
3629
+ "3/4": "\xBE",
3630
+ "1/8": "\u215B",
3631
+ "3/8": "\u215C",
3632
+ "5/8": "\u215D",
3633
+ "7/8": "\u215E"
3634
+ };
3635
+ function renderFractionAsVulgar(num, den) {
3636
+ const wholePart = Math.floor(num / den);
3637
+ const remainder = num % den;
3638
+ if (remainder === 0) {
3639
+ return String(wholePart);
3640
+ }
3641
+ const fractionKey = `${remainder}/${den}`;
3642
+ const vulgar = VULGAR_FRACTIONS[fractionKey];
3643
+ if (wholePart > 0) {
3644
+ return vulgar ? `${wholePart}${vulgar}` : `${wholePart} ${remainder}/${den}`;
3645
+ }
3646
+ return vulgar ?? `${num}/${den}`;
3647
+ }
3648
+ function formatNumericValue(value, useVulgar = true) {
3649
+ if (value.type === "decimal") {
3650
+ return String(value.decimal);
3651
+ }
3652
+ if (useVulgar) {
3653
+ return renderFractionAsVulgar(value.num, value.den);
3654
+ }
3655
+ return `${value.num}/${value.den}`;
3656
+ }
3657
+ function formatSingleValue(value) {
3658
+ if (value.type === "text") {
3659
+ return value.text;
3660
+ }
3661
+ return formatNumericValue(value);
3662
+ }
3663
+ function formatQuantity(quantity) {
3664
+ if (quantity.type === "fixed") {
3665
+ return formatSingleValue(quantity.value);
3666
+ }
3667
+ const minStr = formatNumericValue(quantity.min);
3668
+ const maxStr = formatNumericValue(quantity.max);
3669
+ return `${minStr}-${maxStr}`;
3670
+ }
3671
+ function formatUnit(unit) {
3672
+ if (!unit) return "";
3673
+ if (typeof unit === "string") return unit;
3674
+ return unit.name;
3675
+ }
3676
+ function formatQuantityWithUnit(quantity, unit) {
3677
+ if (!quantity) return "";
3678
+ const qty = formatQuantity(quantity);
3679
+ const unitStr = formatUnit(unit);
3680
+ return unitStr ? `${qty} ${unitStr}` : qty;
3681
+ }
3682
+ function formatExtendedQuantity(item) {
3683
+ return formatQuantityWithUnit(item.quantity, item.unit);
3684
+ }
3685
+ function formatItemQuantity(itemQuantity, separator = " | ") {
3686
+ const parts = [];
3687
+ parts.push(formatExtendedQuantity(itemQuantity));
3688
+ if (itemQuantity.equivalents) {
3689
+ for (const eq of itemQuantity.equivalents) {
3690
+ parts.push(formatExtendedQuantity(eq));
3691
+ }
3692
+ }
3693
+ return parts.join(separator);
3694
+ }
3695
+ function isGroupedItem(item) {
3696
+ return item.group !== void 0;
3697
+ }
3698
+ function isAlternativeSelected(recipe, choices, item, alternativeIndex) {
3699
+ if (item.group) {
3700
+ const selectedIndex2 = choices?.ingredientGroups?.get(item.group);
3701
+ const groupAlternatives = recipe.choices.ingredientGroups.get(item.group);
3702
+ if (groupAlternatives && selectedIndex2 !== void 0 && selectedIndex2 < groupAlternatives.length) {
3703
+ const selectedItemId = groupAlternatives[selectedIndex2]?.itemId;
3704
+ return selectedItemId === item.id;
3705
+ }
3706
+ return false;
3707
+ }
3708
+ const selectedIndex = choices?.ingredientItems?.get(item.id);
3709
+ return alternativeIndex === selectedIndex;
3710
+ }
1410
3711
  // Annotate the CommonJS export names for ESM import in node:
1411
3712
  0 && (module.exports = {
1412
3713
  CategoryConfig,
3714
+ NoProductCatalogForCartError,
3715
+ NoShoppingListForCartError,
3716
+ ProductCatalog,
1413
3717
  Recipe,
1414
3718
  Section,
1415
- ShoppingList
3719
+ ShoppingCart,
3720
+ ShoppingList,
3721
+ convertQuantityToSystem,
3722
+ formatExtendedQuantity,
3723
+ formatItemQuantity,
3724
+ formatNumericValue,
3725
+ formatQuantity,
3726
+ formatQuantityWithUnit,
3727
+ formatSingleValue,
3728
+ formatUnit,
3729
+ hasAlternatives,
3730
+ isAlternativeSelected,
3731
+ isAndGroup,
3732
+ isGroupedItem,
3733
+ isSimpleGroup,
3734
+ renderFractionAsVulgar
1416
3735
  });
1417
3736
  /* v8 ignore else -- @preserve */
1418
- /* v8 ignore else -- expliciting error types -- @preserve */
3737
+ /* v8 ignore next -- @preserve: defensive fallback for ambiguous units without toBaseBySystem */
3738
+ /* v8 ignore start -- @preserve: defensive fallback that shouldn't happen with valid inputs */
3739
+ // v8 ignore else -- @preserve
3740
+ // v8 ignore if -- @preserve
1419
3741
  /* v8 ignore else -- expliciting error type -- @preserve */
1420
- /* v8 ignore else -- only set unit if it is given -- @preserve */
3742
+ /* v8 ignore if -- @preserve */
3743
+ // v8 ignore next -- @preserve
1421
3744
  //# sourceMappingURL=index.cjs.map