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