@tmlmt/cooklang-parser 3.0.0-alpha.8 → 3.0.0-elpha.22

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,7 +63,7 @@ var CategoryConfig = class {
63
63
  }
64
64
  };
65
65
 
66
- // src/classes/product_catalog.ts
66
+ // src/classes/pantry.ts
67
67
  import TOML from "smol-toml";
68
68
 
69
69
  // node_modules/.pnpm/human-regex@2.2.0/node_modules/human-regex/dist/human-regex.esm.js
@@ -265,14 +265,19 @@ var i = (() => {
265
265
  })();
266
266
 
267
267
  // src/regex.ts
268
+ var metadataKeyRegex = /^([^:\n]+?):/gm;
269
+ var numericValueRegex = /^-?\d+(\.\d+)?$/;
270
+ var nestedMetaVarRegex = (varName) => new RegExp(
271
+ `^${varName}:\\s*\\r?\\n((?:[ ]+.+(?:\\r?\\n|$))+)`,
272
+ "m"
273
+ );
268
274
  var metadataRegex = d().literal("---").newline().startCaptureGroup().anyCharacter().zeroOrMore().optional().endGroup().newline().literal("---").dotAll().toRegExp();
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();
270
275
  var nonWordChar = "\\s@#~\\[\\]{(,;:!?";
271
276
  var nonWordCharStrict = "\\s@#~\\[\\]{(,;:!?|";
272
277
  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
278
  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();
279
+ 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();
280
+ var ingredientWithGroupKeyRegex = d().literal("@|").startNamedGroup("gIngredientGroupKey").notAnyOf(nonWordCharStrict + "/").oneOrMore().endGroup().startGroup().literal("/").startNamedGroup("gIngredientSubgroupKey").notAnyOf(nonWordCharStrict).oneOrMore().endGroup().endGroup().optional().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().startGroup().literal("[").startNamedGroup("gIngredientNote").notAnyOf("\\]").oneOrMore().lazy().endGroup().literal("]").endGroup().optional().toRegExp();
276
281
  var ingredientAliasRegex = d().startAnchor().startNamedGroup("ingredientListName").notAnyOf("|").oneOrMore().endGroup().literal("|").startNamedGroup("ingredientDisplayName").notAnyOf("|").oneOrMore().endGroup().endAnchor().toRegExp();
277
282
  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
283
  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();
@@ -287,12 +292,58 @@ var tokensRegex = new RegExp(
287
292
  ].map((r2) => r2.source).join("|"),
288
293
  "gu"
289
294
  );
295
+ var yieldPrefixPart = d().startAnchor().literal("yield").literal(":").anyOf(" ").zeroOrMore().startNamedGroup("servingsPrefix").nonWhitespace().startGroup().anyCharacter().zeroOrMore().lazy().nonWhitespace().endGroup().optional().endGroup().optional().anyOf(" ").zeroOrMore().toRegExp();
296
+ var yieldSuffixPart = d().anyOf(" ").zeroOrMore().startNamedGroup("servingsSuffix").nonWhitespace().startGroup().anyCharacter().zeroOrMore().nonWhitespace().endGroup().optional().endGroup().optional().anyOf(" ").zeroOrMore().endAnchor().toRegExp();
297
+ var yieldMetaValueWithUnitRegex = new RegExp(
298
+ yieldPrefixPart.source + arbitraryScalableRegex.source + yieldSuffixPart.source,
299
+ "m"
300
+ );
301
+ var yieldMetaValueAsQuantityRegex = d().startAnchor().literal("yield:").anyOf(" ").zeroOrMore().startNamedGroup("quantity").notAnyOf("{}|%\\n\\r").oneOrMore().endGroup().optional().startGroup().literal("%").startNamedGroup("unit").notAnyOf("\\n\\r|}").oneOrMore().endGroup().endGroup().optional().anyOf(" ").zeroOrMore().endAnchor().toRegExp();
302
+ var yieldMetaValueRegex = new RegExp(
303
+ [
304
+ yieldMetaValueWithUnitRegex.source,
305
+ yieldMetaValueAsQuantityRegex.source
306
+ ].join("|"),
307
+ "m"
308
+ );
290
309
  var commentRegex = d().literal("--").anyCharacter().zeroOrMore().global().toRegExp();
291
310
  var blockCommentRegex = d().literal("[-").anyCharacter().zeroOrMore().lazy().literal("-]").whitespace().zeroOrMore().global().toRegExp();
292
311
  var shoppingListRegex = d().literal("[").startNamedGroup("name").anyCharacter().oneOrMore().endGroup().literal("]").newline().startNamedGroup("items").anyCharacter().zeroOrMore().lazy().endGroup().startGroup().newline().newline().or().endAnchor().endGroup().global().toRegExp();
293
312
  var rangeRegex = d().startAnchor().digit().oneOrMore().startGroup().anyOf(".,/").exactly(1).digit().oneOrMore().endGroup().optional().literal("-").digit().oneOrMore().startGroup().anyOf(".,/").exactly(1).digit().oneOrMore().endGroup().optional().endAnchor().toRegExp();
294
313
  var numberLikeRegex = d().startAnchor().digit().oneOrMore().startGroup().anyOf(".,/").exactly(1).digit().oneOrMore().endGroup().optional().endAnchor().toRegExp();
295
314
  var floatRegex = d().startAnchor().digit().oneOrMore().startGroup().anyOf(".").exactly(1).digit().oneOrMore().endGroup().optional().endAnchor().toRegExp();
315
+ var variantTagRegex = d().startAnchor().literal("[").startNamedGroup("variantOptionalPrefix").literal("?").endGroup().optional().startNamedGroup("variantNames").notAnyOf("\\]").oneOrMore().endGroup().optional().literal("]").whitespace().zeroOrMore().toRegExp();
316
+ var mdEscaped = d().literal("\\").startCaptureGroup().anyOf("*_`").endGroup();
317
+ var mdInlineCode = d().literal("`").startCaptureGroup().notAnyOf("`").oneOrMore().lazy().endGroup().literal("`");
318
+ var mdLink = d().literal("[").startCaptureGroup().notAnyOf("\\]").oneOrMore().lazy().endGroup().literal("](").startCaptureGroup().notAnyOf(")").oneOrMore().lazy().endGroup().literal(")");
319
+ var mdTripleAsterisk = d().literal("***").startCaptureGroup().anyCharacter().oneOrMore().lazy().endGroup().literal("***");
320
+ var mdTripleUnderscore = d().literal("___").startCaptureGroup().anyCharacter().oneOrMore().lazy().endGroup().literal("___");
321
+ var mdBoldAstItalicUnd = d().literal("**_").startCaptureGroup().anyCharacter().oneOrMore().lazy().endGroup().literal("_**");
322
+ var mdBoldUndItalicAst = d().literal("__*").startCaptureGroup().anyCharacter().oneOrMore().lazy().endGroup().literal("*__");
323
+ var mdItalicAstBoldUnd = d().literal("*__").startCaptureGroup().anyCharacter().oneOrMore().lazy().endGroup().literal("__*");
324
+ var mdItalicUndBoldAst = d().literal("_**").startCaptureGroup().anyCharacter().oneOrMore().lazy().endGroup().literal("**_");
325
+ var mdBoldAsterisk = d().literal("**").startCaptureGroup().anyCharacter().oneOrMore().lazy().endGroup().literal("**");
326
+ var mdBoldUnderscore = d().wordBoundary().literal("__").startCaptureGroup().anyCharacter().oneOrMore().lazy().endGroup().literal("__").wordBoundary();
327
+ var mdItalicAsterisk = d().literal("*").startCaptureGroup().anyCharacter().oneOrMore().lazy().endGroup().literal("*");
328
+ var mdItalicUnderscore = d().wordBoundary().literal("_").startCaptureGroup().anyCharacter().oneOrMore().lazy().endGroup().literal("_").wordBoundary();
329
+ var markdownRegex = new RegExp(
330
+ [
331
+ mdEscaped,
332
+ mdInlineCode,
333
+ mdLink,
334
+ mdTripleAsterisk,
335
+ mdTripleUnderscore,
336
+ mdBoldAstItalicUnd,
337
+ mdBoldUndItalicAst,
338
+ mdItalicAstBoldUnd,
339
+ mdItalicUndBoldAst,
340
+ mdBoldAsterisk,
341
+ mdBoldUnderscore,
342
+ mdItalicAsterisk,
343
+ mdItalicUnderscore
344
+ ].map((r2) => r2.toRegExp().source).join("|"),
345
+ "g"
346
+ );
296
347
 
297
348
  // src/units/definitions.ts
298
349
  var units = [
@@ -302,7 +353,8 @@ var units = [
302
353
  type: "mass",
303
354
  system: "metric",
304
355
  aliases: ["gram", "grams", "grammes"],
305
- toBase: 1
356
+ toBase: 1,
357
+ maxValue: 999
306
358
  },
307
359
  {
308
360
  name: "kg",
@@ -311,20 +363,28 @@ var units = [
311
363
  aliases: ["kilogram", "kilograms", "kilogrammes", "kilos", "kilo"],
312
364
  toBase: 1e3
313
365
  },
314
- // Mass (Imperial)
366
+ // Mass (US/UK - identical in both systems)
315
367
  {
316
368
  name: "oz",
317
369
  type: "mass",
318
- system: "imperial",
370
+ system: "ambiguous",
319
371
  aliases: ["ounce", "ounces"],
320
- toBase: 28.3495
372
+ toBase: 28.3495,
373
+ // default: US (same as UK)
374
+ toBaseBySystem: { US: 28.3495, UK: 28.3495 },
375
+ maxValue: 31,
376
+ // 16 oz = 1 lb, allow a bit more
377
+ fractions: { enabled: true, denominators: [2] }
321
378
  },
322
379
  {
323
380
  name: "lb",
324
381
  type: "mass",
325
- system: "imperial",
382
+ system: "ambiguous",
326
383
  aliases: ["pound", "pounds"],
327
- toBase: 453.592
384
+ toBase: 453.592,
385
+ // default: US (same as UK)
386
+ toBaseBySystem: { US: 453.592, UK: 453.592 },
387
+ fractions: { enabled: true, denominators: [2, 4] }
328
388
  },
329
389
  // Volume (Metric)
330
390
  {
@@ -332,21 +392,26 @@ var units = [
332
392
  type: "volume",
333
393
  system: "metric",
334
394
  aliases: ["milliliter", "milliliters", "millilitre", "millilitres", "cc"],
335
- toBase: 1
395
+ toBase: 1,
396
+ maxValue: 999
336
397
  },
337
398
  {
338
399
  name: "cl",
339
400
  type: "volume",
340
401
  system: "metric",
341
402
  aliases: ["centiliter", "centiliters", "centilitre", "centilitres"],
342
- toBase: 10
403
+ toBase: 10,
404
+ isBestUnit: false
405
+ // exists but not a "best" candidate
343
406
  },
344
407
  {
345
408
  name: "dl",
346
409
  type: "volume",
347
410
  system: "metric",
348
411
  aliases: ["deciliter", "deciliters", "decilitre", "decilitres"],
349
- toBase: 100
412
+ toBase: 100,
413
+ isBestUnit: false
414
+ // exists but not a "best" candidate
350
415
  },
351
416
  {
352
417
  name: "l",
@@ -355,55 +420,102 @@ var units = [
355
420
  aliases: ["liter", "liters", "litre", "litres"],
356
421
  toBase: 1e3
357
422
  },
423
+ // Volume (JP)
424
+ {
425
+ name: "go",
426
+ type: "volume",
427
+ system: "JP",
428
+ aliases: ["gou", "goo", "\u5408", "rice cup"],
429
+ toBase: 180,
430
+ maxValue: 10
431
+ },
432
+ // Volume (Ambiguous: metric/US/UK)
358
433
  {
359
434
  name: "tsp",
360
435
  type: "volume",
361
- system: "metric",
436
+ system: "ambiguous",
362
437
  aliases: ["teaspoon", "teaspoons"],
363
- toBase: 5
438
+ toBase: 5,
439
+ // default: metric
440
+ toBaseBySystem: { metric: 5, US: 4.929, UK: 5.919, JP: 5 },
441
+ maxValue: 5,
442
+ // 3 tsp = 1 tbsp (but allow a bit more)
443
+ fractions: { enabled: true, denominators: [2, 3, 4, 8] }
364
444
  },
365
445
  {
366
446
  name: "tbsp",
367
447
  type: "volume",
368
- system: "metric",
448
+ system: "ambiguous",
369
449
  aliases: ["tablespoon", "tablespoons"],
370
- toBase: 15
450
+ toBase: 15,
451
+ // default: metric
452
+ toBaseBySystem: { metric: 15, US: 14.787, UK: 17.758, JP: 15 },
453
+ maxValue: 4,
454
+ // ~16 tbsp = 1 cup
455
+ fractions: { enabled: true }
371
456
  },
372
- // Volume (Imperial)
457
+ // Volume (Ambiguous: US/UK only)
373
458
  {
374
459
  name: "fl-oz",
375
460
  type: "volume",
376
- system: "imperial",
461
+ system: "ambiguous",
377
462
  aliases: ["fluid ounce", "fluid ounces"],
378
- toBase: 29.5735
463
+ toBase: 29.5735,
464
+ // default: US
465
+ toBaseBySystem: { US: 29.5735, UK: 28.4131 },
466
+ maxValue: 15,
467
+ // 8 fl-oz ~ 1 cup, allow more
468
+ fractions: { enabled: true, denominators: [2] }
379
469
  },
380
470
  {
381
471
  name: "cup",
382
472
  type: "volume",
383
- system: "imperial",
473
+ system: "ambiguous",
384
474
  aliases: ["cups"],
385
- toBase: 236.588
475
+ toBase: 236.588,
476
+ // default: US
477
+ toBaseBySystem: { US: 236.588, UK: 284.131 },
478
+ maxValue: 15,
479
+ // upgrade to gallons above 15 cups
480
+ fractions: { enabled: true }
386
481
  },
387
482
  {
388
483
  name: "pint",
389
484
  type: "volume",
390
- system: "imperial",
485
+ system: "ambiguous",
391
486
  aliases: ["pints"],
392
- toBase: 473.176
487
+ toBase: 473.176,
488
+ // default: US
489
+ toBaseBySystem: { US: 473.176, UK: 568.261 },
490
+ maxValue: 3,
491
+ // 2 pints = 1 quart
492
+ fractions: { enabled: true, denominators: [2] },
493
+ isBestUnit: false
494
+ // exists but not a "best" candidate
393
495
  },
394
496
  {
395
497
  name: "quart",
396
498
  type: "volume",
397
- system: "imperial",
499
+ system: "ambiguous",
398
500
  aliases: ["quarts"],
399
- toBase: 946.353
501
+ toBase: 946.353,
502
+ // default: US
503
+ toBaseBySystem: { US: 946.353, UK: 1136.52 },
504
+ maxValue: 3,
505
+ // 4 quarts = 1 gallon
506
+ fractions: { enabled: true, denominators: [2] },
507
+ isBestUnit: false
508
+ // exists but not a "best" candidate
400
509
  },
401
510
  {
402
511
  name: "gallon",
403
512
  type: "volume",
404
- system: "imperial",
513
+ system: "ambiguous",
405
514
  aliases: ["gallons"],
406
- toBase: 3785.41
515
+ toBase: 3785.41,
516
+ // default: US
517
+ toBaseBySystem: { US: 3785.41, UK: 4546.09 },
518
+ fractions: { enabled: true, denominators: [2] }
407
519
  },
408
520
  // Count units (no conversion, but recognized as a type)
409
521
  {
@@ -411,7 +523,8 @@ var units = [
411
523
  type: "count",
412
524
  system: "metric",
413
525
  aliases: ["pieces", "pc"],
414
- toBase: 1
526
+ toBase: 1,
527
+ maxValue: 999
415
528
  }
416
529
  ];
417
530
  var unitMap = /* @__PURE__ */ new Map();
@@ -435,8 +548,14 @@ function isNoUnit(unit) {
435
548
  return resolveUnit(unit.name).name === NO_UNIT;
436
549
  }
437
550
 
551
+ // src/units/conversion.ts
552
+ import Big2 from "big.js";
553
+
438
554
  // src/quantities/numeric.ts
439
555
  import Big from "big.js";
556
+ var DEFAULT_DENOMINATORS = [2, 3, 4];
557
+ var DEFAULT_FRACTION_ACCURACY = 0.05;
558
+ var DEFAULT_MAX_WHOLE = 4;
440
559
  function gcd(a2, b) {
441
560
  return b === 0 ? a2 : gcd(b, a2 % b);
442
561
  }
@@ -457,6 +576,41 @@ function simplifyFraction(num, den) {
457
576
  return { type: "fraction", num: simplifiedNum, den: simplifiedDen };
458
577
  }
459
578
  }
579
+ function approximateFraction(value, denominators = DEFAULT_DENOMINATORS, accuracy = DEFAULT_FRACTION_ACCURACY, maxWhole = DEFAULT_MAX_WHOLE) {
580
+ if (value <= 0 || !Number.isFinite(value)) {
581
+ return null;
582
+ }
583
+ const wholePart = Math.floor(value);
584
+ if (wholePart > maxWhole) {
585
+ return null;
586
+ }
587
+ const fractionalPart = value - wholePart;
588
+ if (fractionalPart < 1e-4) {
589
+ return null;
590
+ }
591
+ let bestFraction = null;
592
+ for (const den of denominators) {
593
+ const exactNum = value * den;
594
+ const roundedNum = Math.round(exactNum);
595
+ if (roundedNum === 0) continue;
596
+ const approximatedValue = roundedNum / den;
597
+ const relativeError = Math.abs(approximatedValue - value) / value;
598
+ if (relativeError <= accuracy) {
599
+ if (!bestFraction || relativeError < bestFraction.error) {
600
+ bestFraction = { num: roundedNum, den, error: relativeError };
601
+ }
602
+ }
603
+ }
604
+ if (!bestFraction) {
605
+ return null;
606
+ }
607
+ const commonDivisor = gcd(bestFraction.num, bestFraction.den);
608
+ return {
609
+ type: "fraction",
610
+ num: bestFraction.num / commonDivisor,
611
+ den: bestFraction.den / commonDivisor
612
+ };
613
+ }
460
614
  function getNumericValue(v) {
461
615
  if (v.type === "decimal") {
462
616
  return v.decimal;
@@ -505,9 +659,35 @@ function addNumericValues(val1, val2) {
505
659
  };
506
660
  }
507
661
  }
508
- var toRoundedDecimal = (v) => {
662
+ var toRoundedDecimal = (v, precision = 3) => {
509
663
  const value = v.type === "decimal" ? v.decimal : v.num / v.den;
510
- return { type: "decimal", decimal: Math.round(value * 1e3) / 1e3 };
664
+ if (value === 0) {
665
+ return { type: "decimal", decimal: 0 };
666
+ }
667
+ const absValue = Math.abs(value);
668
+ if (absValue >= 1e3) {
669
+ return { type: "decimal", decimal: Math.round(value) };
670
+ }
671
+ const magnitude = Math.floor(Math.log10(absValue));
672
+ const scale = Math.pow(10, precision - 1 - magnitude);
673
+ const rounded = Math.round(value * scale) / scale;
674
+ return { type: "decimal", decimal: rounded };
675
+ };
676
+ var formatOutputValue = (value, unitDef, precision = 3) => {
677
+ if (unitDef.fractions?.enabled) {
678
+ const denominators = unitDef.fractions.denominators ?? DEFAULT_DENOMINATORS;
679
+ const maxWhole = unitDef.fractions.maxWhole ?? DEFAULT_MAX_WHOLE;
680
+ const fraction = approximateFraction(
681
+ value,
682
+ denominators,
683
+ DEFAULT_FRACTION_ACCURACY,
684
+ maxWhole
685
+ );
686
+ if (fraction) {
687
+ return fraction;
688
+ }
689
+ }
690
+ return toRoundedDecimal({ type: "decimal", decimal: value }, precision);
511
691
  };
512
692
  function multiplyQuantityValue(value, factor) {
513
693
  if (value.type === "fixed") {
@@ -541,6 +721,143 @@ function getAverageValue(q) {
541
721
  }
542
722
  }
543
723
 
724
+ // src/units/compatibility.ts
725
+ function areUnitsGroupable(u1, u2) {
726
+ if (u1.name === u2.name) {
727
+ return true;
728
+ }
729
+ if (u1.type === "other" || u2.type === "other") {
730
+ return false;
731
+ }
732
+ if (u1.type === u2.type && u1.system === u2.system) {
733
+ return true;
734
+ }
735
+ if (u1.type === u2.type) {
736
+ if (u1.system === "ambiguous" && u2.system === "metric" && u1.toBaseBySystem?.metric !== void 0) {
737
+ return true;
738
+ }
739
+ if (u2.system === "ambiguous" && u1.system === "metric" && u2.toBaseBySystem?.metric !== void 0) {
740
+ return true;
741
+ }
742
+ }
743
+ return false;
744
+ }
745
+ function areUnitsConvertible(u1, u2) {
746
+ if (u1.name === u2.name) return true;
747
+ if (u1.type === "other" || u2.type === "other") return false;
748
+ return u1.type === u2.type;
749
+ }
750
+ function isUnitCompatibleWithSystem(unit, system) {
751
+ if (unit.system === system) return true;
752
+ if (unit.system === "ambiguous") {
753
+ if (unit.toBaseBySystem) {
754
+ return system in unit.toBaseBySystem;
755
+ }
756
+ if (system === "metric") return true;
757
+ }
758
+ if (unit.system === "metric" && system === "JP") {
759
+ return true;
760
+ }
761
+ return false;
762
+ }
763
+
764
+ // src/units/conversion.ts
765
+ var EPSILON = 0.01;
766
+ var DEFAULT_MAX_VALUE = 999;
767
+ function isCloseToInteger(value) {
768
+ return Math.abs(value - Math.round(value)) < EPSILON;
769
+ }
770
+ function getMaxValue(unit) {
771
+ return unit.maxValue ?? DEFAULT_MAX_VALUE;
772
+ }
773
+ function isValueInRange(value, unit) {
774
+ const maxValue = getMaxValue(unit);
775
+ if (value >= 1 && value <= maxValue) {
776
+ return true;
777
+ }
778
+ if (value > 0 && value < 1 && unit.fractions?.enabled) {
779
+ const denominators = unit.fractions.denominators ?? DEFAULT_DENOMINATORS;
780
+ const maxWhole = unit.fractions.maxWhole ?? DEFAULT_MAX_WHOLE;
781
+ const fraction = approximateFraction(
782
+ value,
783
+ denominators,
784
+ DEFAULT_FRACTION_ACCURACY,
785
+ maxWhole
786
+ );
787
+ return fraction !== null;
788
+ }
789
+ return false;
790
+ }
791
+ function findBestUnit(valueInBase, unitType, system, inputUnits) {
792
+ const inputUnitNames = new Set(inputUnits.map((u) => u.name));
793
+ const candidates = units.filter(
794
+ (u) => u.type === unitType && isUnitCompatibleWithSystem(u, system) && (u.isBestUnit !== false || inputUnitNames.has(u.name))
795
+ );
796
+ if (candidates.length === 0) {
797
+ const fallbackUnit = inputUnits[0];
798
+ return {
799
+ unit: fallbackUnit,
800
+ value: valueInBase / getToBase(fallbackUnit, system)
801
+ };
802
+ }
803
+ const candidatesWithValues = candidates.map((unit) => ({
804
+ unit,
805
+ value: valueInBase / getToBase(unit, system)
806
+ }));
807
+ const inRange = candidatesWithValues.filter(
808
+ (c) => isValueInRange(c.value, c.unit)
809
+ );
810
+ if (inRange.length > 0) {
811
+ const integersInInputFamily = inRange.filter(
812
+ (c) => isCloseToInteger(c.value) && inputUnitNames.has(c.unit.name)
813
+ );
814
+ if (integersInInputFamily.length > 0) {
815
+ return integersInInputFamily.sort((a2, b) => a2.value - b.value)[0];
816
+ }
817
+ const integersAny = inRange.filter((c) => isCloseToInteger(c.value));
818
+ if (integersAny.length > 0) {
819
+ return integersAny.sort((a2, b) => a2.value - b.value)[0];
820
+ }
821
+ return inRange.sort((a2, b) => {
822
+ const aInFamily = inputUnitNames.has(a2.unit.name) ? 0 : 1;
823
+ const bInFamily = inputUnitNames.has(b.unit.name) ? 0 : 1;
824
+ if (aInFamily !== bInFamily) return aInFamily - bInFamily;
825
+ return a2.value - b.value;
826
+ })[0];
827
+ }
828
+ return candidatesWithValues.sort((a2, b) => {
829
+ const aMaxValue = getMaxValue(a2.unit);
830
+ const bMaxValue = getMaxValue(b.unit);
831
+ const aDistance = a2.value < 1 ? 1 - a2.value : a2.value - aMaxValue;
832
+ const bDistance = b.value < 1 ? 1 - b.value : b.value - bMaxValue;
833
+ return aDistance - bDistance;
834
+ })[0];
835
+ }
836
+ function getUnitRatio(q1, q2) {
837
+ const q1Value = getAverageValue(q1.quantity);
838
+ const q2Value = getAverageValue(q2.quantity);
839
+ const factor = "toBase" in q1.unit && "toBase" in q2.unit ? q1.unit.toBase / q2.unit.toBase : 1;
840
+ if (typeof q1Value !== "number" || typeof q2Value !== "number") {
841
+ throw Error(
842
+ "One of both values is not a number, so a ratio cannot be computed"
843
+ );
844
+ }
845
+ return Big2(q1Value).times(factor).div(q2Value);
846
+ }
847
+ function getBaseUnitRatio(q, qRef) {
848
+ if ("toBase" in q.unit && "toBase" in qRef.unit) {
849
+ return q.unit.toBase / qRef.unit.toBase;
850
+ } else {
851
+ return 1;
852
+ }
853
+ }
854
+ function getToBase(unit, system) {
855
+ if (unit.system === "ambiguous" && system && unit.toBaseBySystem) {
856
+ return unit.toBaseBySystem[system] ?? unit.toBase;
857
+ }
858
+ return unit.toBase;
859
+ }
860
+
544
861
  // src/errors.ts
545
862
  var ReferencedItemCannotBeRedefinedError = class extends Error {
546
863
  constructor(item_type, item_name, new_modifier) {
@@ -571,7 +888,7 @@ var NoProductMatchError = class extends Error {
571
888
  constructor(item_name, code) {
572
889
  const messageMap = {
573
890
  incompatibleUnits: `The units of the products in the catalogue are incompatible with ingredient ${item_name} in the shopping list.`,
574
- noProduct: "No product was found linked to ingredient name ${item_name} in the shopping list",
891
+ noProduct: `No product was found linked to ingredient name ${item_name} in the shopping list`,
575
892
  textValue: `Ingredient ${item_name} has a text value as quantity and can therefore not be matched with any product in the catalogue.`,
576
893
  noQuantity: `Ingredient ${item_name} has no quantity and can therefore not be matched with any product in the catalogue.`,
577
894
  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`
@@ -610,20 +927,37 @@ var InvalidQuantityFormat = class extends Error {
610
927
  this.name = "InvalidQuantityFormat";
611
928
  }
612
929
  };
930
+ var NoTabAsIndentError = class extends Error {
931
+ constructor() {
932
+ super(
933
+ `Tabs are not allowed for indentation in metadata blocks. Please use spaces only.`
934
+ );
935
+ this.name = "NoTabAsIndentError";
936
+ }
937
+ };
938
+ var BadIndentationError = class extends Error {
939
+ constructor() {
940
+ super(`Bad identation of a nested block. Please use spaces only.`);
941
+ this.name = "BadIndentationError";
942
+ }
943
+ };
613
944
 
614
945
  // src/utils/type_guards.ts
615
946
  function isGroup(x) {
616
- return x && "type" in x;
947
+ return "and" in x || "or" in x;
617
948
  }
618
949
  function isOrGroup(x) {
619
- return isGroup(x) && x.type === "or";
950
+ return isGroup(x) && "or" in x;
620
951
  }
621
952
  function isAndGroup(x) {
622
- return isGroup(x) && x.type === "and";
953
+ return "and" in x;
623
954
  }
624
955
  function isQuantity(x) {
625
956
  return x && typeof x === "object" && "quantity" in x;
626
957
  }
958
+ function isSimpleGroup(entry) {
959
+ return "quantity" in entry;
960
+ }
627
961
  function isNumericValueIntegerLike(v) {
628
962
  if (v.type === "decimal") return Number.isInteger(v.decimal);
629
963
  return v.num % v.den === 0;
@@ -635,37 +969,32 @@ function isValueIntegerLike(q) {
635
969
  }
636
970
  return isNumericValueIntegerLike(q.min) && isNumericValueIntegerLike(q.max);
637
971
  }
972
+ function hasAlternatives(entry) {
973
+ return "alternatives" in entry && Array.isArray(entry.alternatives) && entry.alternatives.length > 0;
974
+ }
638
975
 
639
976
  // src/quantities/mutations.ts
640
- function extendAllUnits(q) {
641
- if (isGroup(q)) {
642
- return { ...q, entries: q.entries.map(extendAllUnits) };
643
- } else {
644
- const newQ = {
645
- quantity: q.quantity
646
- };
647
- if (q.unit) {
648
- newQ.unit = { name: q.unit };
649
- }
650
- return newQ;
651
- }
652
- }
653
977
  function normalizeAllUnits(q) {
654
- if (isGroup(q)) {
655
- return { ...q, entries: q.entries.map(normalizeAllUnits) };
978
+ if (isAndGroup(q)) {
979
+ return { and: q.and.map(normalizeAllUnits) };
980
+ } else if (isOrGroup(q)) {
981
+ return { or: q.or.map(normalizeAllUnits) };
656
982
  } else {
657
983
  const newQ = {
658
984
  quantity: q.quantity,
659
985
  unit: resolveUnit(q.unit)
660
986
  };
987
+ if (q.equivalents && q.equivalents.length > 0) {
988
+ const equivalentsNormalized = q.equivalents.map(
989
+ (eq) => normalizeAllUnits(eq)
990
+ );
991
+ return {
992
+ or: [newQ, ...equivalentsNormalized]
993
+ };
994
+ }
661
995
  return newQ;
662
996
  }
663
997
  }
664
- var convertQuantityValue = (value, def, targetDef) => {
665
- if (def.name === targetDef.name) return value;
666
- const factor = def.toBase / targetDef.toBase;
667
- return multiplyQuantityValue(value, factor);
668
- };
669
998
  function getDefaultQuantityValue() {
670
999
  return { type: "fixed", value: { type: "decimal", decimal: 0 } };
671
1000
  }
@@ -692,7 +1021,7 @@ function addQuantityValues(v1, v2) {
692
1021
  );
693
1022
  return { type: "range", min: newMin, max: newMax };
694
1023
  }
695
- function addQuantities(q1, q2) {
1024
+ function addQuantities(q1, q2, system) {
696
1025
  const v1 = q1.quantity;
697
1026
  const v2 = q2.quantity;
698
1027
  if (v1.type === "fixed" && v1.value.type === "text" || v2.type === "fixed" && v2.value.type === "text") {
@@ -710,55 +1039,149 @@ function addQuantities(q1, q2) {
710
1039
  if ((q2.unit?.name === "" || q2.unit === void 0) && q1.unit !== void 0) {
711
1040
  return addQuantityValuesAndSetUnit(v1, v2, q1.unit);
712
1041
  }
713
- if (!q1.unit && !q2.unit || q1.unit && q2.unit && q1.unit.name.toLowerCase() === q2.unit.name.toLowerCase()) {
1042
+ if (!q1.unit && !q2.unit) {
1043
+ return addQuantityValuesAndSetUnit(v1, v2, q1.unit);
1044
+ }
1045
+ if (q1.unit && q2.unit && q1.unit.name.toLowerCase() === q2.unit.name.toLowerCase()) {
1046
+ if (unit1Def) {
1047
+ const effectiveSystem = system ?? (["metric", "JP"].includes(unit1Def.system) ? unit1Def.system : "US");
1048
+ return addAndFindBestUnit(v1, v2, unit1Def, unit1Def, effectiveSystem, [
1049
+ unit1Def
1050
+ ]);
1051
+ }
714
1052
  return addQuantityValuesAndSetUnit(v1, v2, q1.unit);
715
1053
  }
716
1054
  if (unit1Def && unit2Def) {
717
- if (unit1Def.type !== unit2Def.type) {
1055
+ if (!areUnitsConvertible(unit1Def, unit2Def)) {
718
1056
  throw new IncompatibleUnitsError(
719
1057
  `${unit1Def.type} (${q1.unit?.name})`,
720
1058
  `${unit2Def.type} (${q2.unit?.name})`
721
1059
  );
722
1060
  }
723
- let targetUnitDef;
724
- if (unit1Def.system !== unit2Def.system) {
725
- const metricUnitDef = unit1Def.system === "metric" ? unit1Def : unit2Def;
726
- targetUnitDef = units.filter((u) => u.type === metricUnitDef.type && u.system === "metric").reduce(
727
- (prev, current) => prev.toBase > current.toBase ? prev : current
728
- );
729
- } else {
730
- targetUnitDef = unit1Def.toBase >= unit2Def.toBase ? unit1Def : unit2Def;
1061
+ let effectiveSystem = system;
1062
+ if (!effectiveSystem) {
1063
+ if (unit1Def.system === "metric" || unit2Def.system === "metric") {
1064
+ effectiveSystem = "metric";
1065
+ } else {
1066
+ if (unit1Def.system === "JP" && unit2Def.system === "JP") {
1067
+ effectiveSystem = "JP";
1068
+ } else {
1069
+ const unit1SupportsUS = unit1Def.system === "US" || unit1Def.system === "ambiguous" && unit1Def.toBaseBySystem && "US" in unit1Def.toBaseBySystem;
1070
+ const unit2SupportsUS = unit2Def.system === "US" || unit2Def.system === "ambiguous" && unit2Def.toBaseBySystem && "US" in unit2Def.toBaseBySystem;
1071
+ effectiveSystem = unit1SupportsUS && unit2SupportsUS ? "US" : "metric";
1072
+ }
1073
+ }
731
1074
  }
732
- const convertedV1 = convertQuantityValue(v1, unit1Def, targetUnitDef);
733
- const convertedV2 = convertQuantityValue(v2, unit2Def, targetUnitDef);
734
- const targetUnit = { name: targetUnitDef.name };
735
- return addQuantityValuesAndSetUnit(convertedV1, convertedV2, targetUnit);
1075
+ return addAndFindBestUnit(v1, v2, unit1Def, unit2Def, effectiveSystem, [
1076
+ unit1Def,
1077
+ unit2Def
1078
+ ]);
736
1079
  }
737
1080
  throw new IncompatibleUnitsError(
738
1081
  q1.unit?.name,
739
1082
  q2.unit?.name
740
1083
  );
741
1084
  }
1085
+ function addAndFindBestUnit(v1, v2, unit1Def, unit2Def, system, inputUnits) {
1086
+ const toBase1 = getToBase(unit1Def, system);
1087
+ const toBase2 = getToBase(unit2Def, system);
1088
+ let sumInBase;
1089
+ if (v1.type === "fixed" && v2.type === "fixed") {
1090
+ const val1 = getNumericValue(v1.value);
1091
+ const val2 = getNumericValue(v2.value);
1092
+ sumInBase = val1 * toBase1 + val2 * toBase2;
1093
+ } else {
1094
+ const avg1 = getAverageValue(v1);
1095
+ const avg2 = getAverageValue(v2);
1096
+ sumInBase = avg1 * toBase1 + avg2 * toBase2;
1097
+ }
1098
+ const { unit: bestUnit, value: bestValue } = findBestUnit(
1099
+ sumInBase,
1100
+ unit1Def.type,
1101
+ system,
1102
+ inputUnits
1103
+ );
1104
+ const formattedValue = formatOutputValue(bestValue, bestUnit);
1105
+ if (v1.type === "range" || v2.type === "range") {
1106
+ const r1 = v1.type === "range" ? v1 : { type: "range", min: v1.value, max: v1.value };
1107
+ const r2 = v2.type === "range" ? v2 : { type: "range", min: v2.value, max: v2.value };
1108
+ const minInBase = getNumericValue(r1.min) * toBase1 + getNumericValue(r2.min) * toBase2;
1109
+ const maxInBase = getNumericValue(r1.max) * toBase1 + getNumericValue(r2.max) * toBase2;
1110
+ const bestToBase = getToBase(bestUnit, system);
1111
+ const minValue = minInBase / bestToBase;
1112
+ const maxValue = maxInBase / bestToBase;
1113
+ return {
1114
+ quantity: {
1115
+ type: "range",
1116
+ min: formatOutputValue(minValue, bestUnit),
1117
+ max: formatOutputValue(maxValue, bestUnit)
1118
+ },
1119
+ unit: { name: bestUnit.name }
1120
+ };
1121
+ }
1122
+ return {
1123
+ quantity: { type: "fixed", value: formattedValue },
1124
+ unit: { name: bestUnit.name }
1125
+ };
1126
+ }
1127
+ function convertQuantityToSystem(quantity, system) {
1128
+ const unitDef = resolveUnit(
1129
+ typeof quantity.unit === "string" ? quantity.unit : quantity.unit?.name
1130
+ );
1131
+ if (unitDef.type === "other" || !("toBase" in unitDef)) {
1132
+ return void 0;
1133
+ }
1134
+ const avgValue = getAverageValue(quantity.quantity);
1135
+ if (typeof avgValue !== "number") {
1136
+ return void 0;
1137
+ }
1138
+ const toBase = getToBase(unitDef, system);
1139
+ const valueInBase = avgValue * toBase;
1140
+ const { unit: bestUnit, value: bestValue } = findBestUnit(
1141
+ valueInBase,
1142
+ unitDef.type,
1143
+ system,
1144
+ [unitDef]
1145
+ );
1146
+ const formattedValue = formatOutputValue(bestValue, bestUnit);
1147
+ if (quantity.quantity.type === "range") {
1148
+ const bestToBase = getToBase(bestUnit, system);
1149
+ const minValue = getNumericValue(quantity.quantity.min) * toBase / bestToBase;
1150
+ const maxValue = getNumericValue(quantity.quantity.max) * toBase / bestToBase;
1151
+ return {
1152
+ quantity: {
1153
+ type: "range",
1154
+ min: formatOutputValue(minValue, bestUnit),
1155
+ max: formatOutputValue(maxValue, bestUnit)
1156
+ },
1157
+ unit: { name: bestUnit.name }
1158
+ };
1159
+ }
1160
+ return {
1161
+ quantity: { type: "fixed", value: formattedValue },
1162
+ unit: { name: bestUnit.name }
1163
+ };
1164
+ }
742
1165
  function toPlainUnit(quantity) {
743
1166
  if (isQuantity(quantity))
744
1167
  return quantity.unit ? { ...quantity, unit: quantity.unit.name } : quantity;
745
- else {
1168
+ else if (isOrGroup(quantity)) {
1169
+ return {
1170
+ or: quantity.or.map(toPlainUnit)
1171
+ };
1172
+ } else {
746
1173
  return {
747
- ...quantity,
748
- entries: quantity.entries.map(toPlainUnit)
1174
+ and: quantity.and.map(toPlainUnit)
749
1175
  };
750
1176
  }
751
1177
  }
752
1178
  function toExtendedUnit(q) {
753
1179
  if (isQuantity(q)) {
754
1180
  return q.unit ? { ...q, unit: { name: q.unit } } : q;
1181
+ } else if (isOrGroup(q)) {
1182
+ return { or: q.or.map(toExtendedUnit) };
755
1183
  } else {
756
- return {
757
- ...q,
758
- entries: q.entries.map(
759
- (entry) => isQuantity(entry) ? toExtendedUnit(entry) : toExtendedUnit(entry)
760
- )
761
- };
1184
+ return { and: q.and.map(toExtendedUnit) };
762
1185
  }
763
1186
  }
764
1187
  function deNormalizeQuantity(q) {
@@ -772,26 +1195,24 @@ function deNormalizeQuantity(q) {
772
1195
  }
773
1196
  var flattenPlainUnitGroup = (summed) => {
774
1197
  if (isOrGroup(summed)) {
775
- const entries = summed.entries;
1198
+ const entries = summed.or;
776
1199
  const andGroupEntry = entries.find(
777
- (e2) => isGroup(e2) && e2.type === "and"
1200
+ (e2) => isAndGroup(e2)
778
1201
  );
779
1202
  if (andGroupEntry) {
780
1203
  const andEntries = [];
781
- for (const entry of andGroupEntry.entries) {
782
- if (isQuantity(entry)) {
783
- andEntries.push({
784
- quantity: entry.quantity,
785
- unit: entry.unit
786
- });
787
- }
1204
+ const addGroupEntryContent = andGroupEntry.and;
1205
+ for (const entry of addGroupEntryContent) {
1206
+ andEntries.push({
1207
+ quantity: entry.quantity,
1208
+ ...entry.unit && { unit: entry.unit }
1209
+ });
788
1210
  }
789
1211
  const equivalentsList = entries.filter((e2) => isQuantity(e2)).map((e2) => ({ quantity: e2.quantity, unit: e2.unit }));
790
1212
  if (equivalentsList.length > 0) {
791
1213
  return [
792
1214
  {
793
- type: "and",
794
- entries: andEntries,
1215
+ and: andEntries,
795
1216
  equivalents: equivalentsList
796
1217
  }
797
1218
  ];
@@ -815,41 +1236,128 @@ var flattenPlainUnitGroup = (summed) => {
815
1236
  const first = entries[0];
816
1237
  return [{ quantity: first.quantity, unit: first.unit }];
817
1238
  }
818
- } else if (isGroup(summed)) {
1239
+ } else if (isAndGroup(summed)) {
819
1240
  const andEntries = [];
1241
+ const standaloneEntries = [];
820
1242
  const equivalentsList = [];
821
- for (const entry of summed.entries) {
1243
+ for (const entry of summed.and) {
822
1244
  if (isOrGroup(entry)) {
823
- const orEntries = entry.entries.filter(
824
- (e2) => isQuantity(e2)
825
- );
826
- if (orEntries.length > 0) {
1245
+ const orEntries = entry.or;
1246
+ const firstEntry = orEntries[0];
1247
+ if (isAndGroup(firstEntry)) {
1248
+ for (const nestedEntry of firstEntry.and) {
1249
+ andEntries.push({
1250
+ quantity: nestedEntry.quantity,
1251
+ ...nestedEntry.unit && { unit: nestedEntry.unit }
1252
+ });
1253
+ }
1254
+ } else {
1255
+ const primary = firstEntry;
827
1256
  andEntries.push({
828
- quantity: orEntries[0].quantity,
829
- unit: orEntries[0].unit
1257
+ quantity: primary.quantity,
1258
+ ...primary.unit && { unit: primary.unit }
830
1259
  });
831
- equivalentsList.push(...orEntries.slice(1));
832
1260
  }
833
- } else if (isQuantity(entry)) {
834
- andEntries.push({
835
- quantity: entry.quantity,
836
- unit: entry.unit
1261
+ const equivEntries = orEntries.slice(1).filter((e2) => isQuantity(e2));
1262
+ equivalentsList.push(
1263
+ ...equivEntries.map((e2) => ({
1264
+ quantity: e2.quantity,
1265
+ ...e2.unit && { unit: e2.unit }
1266
+ }))
1267
+ );
1268
+ } else {
1269
+ const simpleQuantityEntry = entry;
1270
+ standaloneEntries.push({
1271
+ quantity: simpleQuantityEntry.quantity,
1272
+ ...simpleQuantityEntry.unit && { unit: simpleQuantityEntry.unit }
837
1273
  });
838
1274
  }
839
1275
  }
840
1276
  if (equivalentsList.length === 0) {
841
- return andEntries;
1277
+ return [...andEntries, ...standaloneEntries];
842
1278
  }
843
- const result = {
844
- type: "and",
845
- entries: andEntries,
1279
+ const result = [];
1280
+ result.push({
1281
+ and: andEntries,
846
1282
  equivalents: equivalentsList
847
- };
848
- return [result];
1283
+ });
1284
+ result.push(...standaloneEntries);
1285
+ return result;
849
1286
  } else {
850
- return [{ quantity: summed.quantity, unit: summed.unit }];
1287
+ return [
1288
+ { quantity: summed.quantity, ...summed.unit && { unit: summed.unit } }
1289
+ ];
851
1290
  }
852
1291
  };
1292
+ function applyBestUnit(q, system) {
1293
+ const extended = { quantity: q.quantity };
1294
+ if (q.unit) {
1295
+ extended.unit = typeof q.unit === "string" ? { name: q.unit } : q.unit;
1296
+ }
1297
+ if (!extended.unit?.name) {
1298
+ return q;
1299
+ }
1300
+ const unitDef = resolveUnit(extended.unit.name);
1301
+ if (unitDef.type === "other") {
1302
+ return q;
1303
+ }
1304
+ if (extended.quantity.type === "fixed" && extended.quantity.value.type === "text") {
1305
+ return q;
1306
+ }
1307
+ const avgValue = getAverageValue(extended.quantity);
1308
+ const effectiveSystem = system ?? (["metric", "JP"].includes(unitDef.system) ? unitDef.system : "US");
1309
+ const toBase = getToBase(unitDef, effectiveSystem);
1310
+ const valueInBase = avgValue * toBase;
1311
+ const { unit: bestUnit, value: bestValue } = findBestUnit(
1312
+ valueInBase,
1313
+ unitDef.type,
1314
+ effectiveSystem,
1315
+ [unitDef]
1316
+ );
1317
+ const originalCanonicalName = normalizeUnit(extended.unit.name)?.name;
1318
+ if (bestUnit.name === originalCanonicalName) {
1319
+ return q;
1320
+ }
1321
+ const formattedValue = formatOutputValue(bestValue, bestUnit);
1322
+ if (extended.quantity.type === "range") {
1323
+ const bestToBase = getToBase(bestUnit, effectiveSystem);
1324
+ const minValue = getNumericValue(extended.quantity.min) * toBase / bestToBase;
1325
+ const maxValue = getNumericValue(extended.quantity.max) * toBase / bestToBase;
1326
+ return {
1327
+ quantity: {
1328
+ type: "range",
1329
+ min: formatOutputValue(minValue, bestUnit),
1330
+ max: formatOutputValue(maxValue, bestUnit)
1331
+ },
1332
+ unit: typeof q.unit === "string" ? bestUnit.name : { name: bestUnit.name }
1333
+ };
1334
+ }
1335
+ return {
1336
+ quantity: {
1337
+ type: "fixed",
1338
+ value: formattedValue
1339
+ },
1340
+ unit: typeof q.unit === "string" ? bestUnit.name : { name: bestUnit.name }
1341
+ };
1342
+ }
1343
+ function subtractQuantities(q1, q2, options = {}) {
1344
+ const { clampToZero = true, system } = options;
1345
+ const negatedQ2 = {
1346
+ ...q2,
1347
+ quantity: multiplyQuantityValue(q2.quantity, -1)
1348
+ };
1349
+ const result = addQuantities(q1, negatedQ2, system);
1350
+ if (clampToZero) {
1351
+ const avg = getAverageValue(result.quantity);
1352
+ if (typeof avg === "number" && avg < 0) {
1353
+ return {
1354
+ quantity: { type: "fixed", value: { type: "decimal", decimal: 0 } },
1355
+ unit: result.unit
1356
+ };
1357
+ }
1358
+ }
1359
+ return result;
1360
+ }
853
1361
 
854
1362
  // src/utils/parser_helpers.ts
855
1363
  function flushPendingNote(section, noteItems) {
@@ -859,9 +1367,12 @@ function flushPendingNote(section, noteItems) {
859
1367
  }
860
1368
  return noteItems;
861
1369
  }
862
- function flushPendingItems(section, items) {
1370
+ function flushPendingItems(section, items, stepVariants, stepOptional) {
863
1371
  if (items.length > 0) {
864
- section.content.push({ type: "step", items: [...items] });
1372
+ const step = { type: "step", items: [...items] };
1373
+ if (stepVariants) step.variants = stepVariants;
1374
+ if (stepOptional) step.optional = true;
1375
+ section.content.push(step);
865
1376
  items.length = 0;
866
1377
  return true;
867
1378
  }
@@ -980,7 +1491,7 @@ function stringifyFixedValue(quantity) {
980
1491
  return String(quantity.value.decimal);
981
1492
  else return quantity.value.text;
982
1493
  }
983
- function parseQuantityInput(input_str) {
1494
+ function parseQuantityValue(input_str) {
984
1495
  const clean_str = String(input_str).trim();
985
1496
  if (rangeRegex.test(clean_str)) {
986
1497
  const range_parts = clean_str.split("-");
@@ -990,19 +1501,253 @@ function parseQuantityInput(input_str) {
990
1501
  }
991
1502
  return { type: "fixed", value: parseFixedValue(clean_str) };
992
1503
  }
1504
+ function parseQuantityWithUnit(input) {
1505
+ const trimmed = input.trim();
1506
+ const separatorIndex = trimmed.indexOf("%");
1507
+ if (separatorIndex === -1) {
1508
+ return { value: parseQuantityValue(trimmed) };
1509
+ }
1510
+ const valuePart = trimmed.slice(0, separatorIndex).trim();
1511
+ const unitPart = trimmed.slice(separatorIndex + 1).trim();
1512
+ return {
1513
+ value: parseQuantityValue(valuePart),
1514
+ unit: unitPart || void 0
1515
+ };
1516
+ }
1517
+ function parseDateFromFormat(input, format) {
1518
+ const delimiterMatch = format.match(/[^A-Za-z]/);
1519
+ if (!delimiterMatch) {
1520
+ throw new Error(`Invalid date format: ${format}. No delimiter found.`);
1521
+ }
1522
+ const delimiter = delimiterMatch[0];
1523
+ const formatParts = format.split(delimiter);
1524
+ const inputParts = input.trim().split(delimiter);
1525
+ if (formatParts.length !== 3 || inputParts.length !== 3) {
1526
+ throw new Error(
1527
+ `Invalid date input "${input}" for format "${format}". Expected 3 parts.`
1528
+ );
1529
+ }
1530
+ let day = 0, month = 0, year = 0;
1531
+ for (let i2 = 0; i2 < 3; i2++) {
1532
+ const token = formatParts[i2].toUpperCase();
1533
+ const value = parseInt(inputParts[i2], 10);
1534
+ if (isNaN(value)) {
1535
+ throw new Error(
1536
+ `Invalid date input "${input}": non-numeric part "${inputParts[i2]}".`
1537
+ );
1538
+ }
1539
+ if (token === "DD") day = value;
1540
+ else if (token === "MM") month = value;
1541
+ else if (token === "YYYY") year = value;
1542
+ else
1543
+ throw new Error(
1544
+ `Unknown token "${formatParts[i2]}" in format "${format}"`
1545
+ );
1546
+ }
1547
+ const date = new Date(year, month - 1, day);
1548
+ if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
1549
+ throw new Error(`Invalid date: "${input}" does not form a valid date.`);
1550
+ }
1551
+ return date;
1552
+ }
1553
+ function disambiguateDayMonth(first, second, year) {
1554
+ if (second > 12 && first <= 12) {
1555
+ return [second, first, year];
1556
+ }
1557
+ return [first, second, year];
1558
+ }
1559
+ function parseFuzzyDate(input) {
1560
+ const trimmed = input.trim();
1561
+ const delimiterMatch = trimmed.match(/[./-]/);
1562
+ if (!delimiterMatch) {
1563
+ throw new Error(`Cannot parse date "${input}": no delimiter found.`);
1564
+ }
1565
+ const delimiter = delimiterMatch[0];
1566
+ const parts = trimmed.split(delimiter);
1567
+ if (parts.length !== 3) {
1568
+ throw new Error(
1569
+ `Cannot parse date "${input}": expected 3 parts, got ${parts.length}.`
1570
+ );
1571
+ }
1572
+ const nums = parts.map((p) => parseInt(p, 10));
1573
+ if (nums.some((n2) => isNaN(n2))) {
1574
+ throw new Error(`Cannot parse date "${input}": non-numeric parts found.`);
1575
+ }
1576
+ let day, month, year;
1577
+ if (nums[0] >= 1e3) {
1578
+ year = nums[0];
1579
+ month = nums[1];
1580
+ day = nums[2];
1581
+ } else if (nums[2] >= 1e3) {
1582
+ [day, month, year] = disambiguateDayMonth(nums[0], nums[1], nums[2]);
1583
+ } else {
1584
+ if (nums[2] >= 100)
1585
+ throw new Error(`Invalid date: "${input}" does not form a valid date.`);
1586
+ [day, month] = disambiguateDayMonth(nums[0], nums[1], 0);
1587
+ year = 2e3 + nums[2];
1588
+ }
1589
+ const date = new Date(year, month - 1, day);
1590
+ if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
1591
+ throw new Error(`Invalid date: "${input}" does not form a valid date.`);
1592
+ }
1593
+ return date;
1594
+ }
1595
+ function parseMarkdownSegments(text) {
1596
+ const items = [];
1597
+ let cursor = 0;
1598
+ for (const match of text.matchAll(markdownRegex)) {
1599
+ const idx = match.index;
1600
+ if (idx > cursor) {
1601
+ items.push({ type: "text", value: text.slice(cursor, idx) });
1602
+ }
1603
+ const [
1604
+ ,
1605
+ escaped,
1606
+ // group 1: escaped character
1607
+ code,
1608
+ // group 2: inline code
1609
+ linkText,
1610
+ // group 3: link text
1611
+ linkUrl,
1612
+ // group 4: link url
1613
+ tripleAst,
1614
+ // group 5: ***bold+italic***
1615
+ tripleUnd,
1616
+ // group 6: ___bold+italic___
1617
+ astUnd,
1618
+ // group 7: **_bold+italic_**
1619
+ undAst,
1620
+ // group 8: __*bold+italic*__
1621
+ astUndUnd,
1622
+ // group 9: *__bold+italic__*
1623
+ undAstAst,
1624
+ // group 10: _**bold+italic**_
1625
+ boldAst,
1626
+ // group 11: **bold**
1627
+ boldUnd,
1628
+ // group 12: __bold__
1629
+ italicAst,
1630
+ // group 13: *italic*
1631
+ italicUnd
1632
+ // group 14: _italic_
1633
+ ] = match;
1634
+ let value;
1635
+ let attribute;
1636
+ let href;
1637
+ if (escaped !== void 0) {
1638
+ items.push({ type: "text", value: escaped });
1639
+ cursor = idx + match[0].length;
1640
+ continue;
1641
+ } else if (code !== void 0) {
1642
+ value = code;
1643
+ attribute = "code";
1644
+ } else if (linkText !== void 0) {
1645
+ value = linkText;
1646
+ attribute = "link";
1647
+ href = linkUrl;
1648
+ } else if (tripleAst !== void 0 || tripleUnd !== void 0 || astUnd !== void 0 || undAst !== void 0 || astUndUnd !== void 0 || undAstAst !== void 0) {
1649
+ value = tripleAst ?? tripleUnd ?? astUnd ?? undAst ?? astUndUnd ?? undAstAst;
1650
+ attribute = "bold+italic";
1651
+ } else if (boldAst !== void 0 || boldUnd !== void 0) {
1652
+ value = boldAst ?? boldUnd;
1653
+ attribute = "bold";
1654
+ } else {
1655
+ value = italicAst ?? italicUnd;
1656
+ attribute = "italic";
1657
+ }
1658
+ const item = { type: "text", value };
1659
+ if (attribute) item.attribute = attribute;
1660
+ if (href) item.href = href;
1661
+ items.push(item);
1662
+ cursor = idx + match[0].length;
1663
+ }
1664
+ if (cursor < text.length) {
1665
+ items.push({ type: "text", value: text.slice(cursor) });
1666
+ }
1667
+ return items;
1668
+ }
993
1669
  function parseSimpleMetaVar(content, varName) {
994
1670
  const varMatch = content.match(
995
1671
  new RegExp(`^${varName}:\\s*(.*(?:\\r?\\n\\s+.*)*)+`, "m")
996
1672
  );
997
1673
  return varMatch ? varMatch[1]?.trim().replace(/\s*\r?\n\s+/g, " ") : void 0;
998
1674
  }
999
- function parseScalingMetaVar(content, varName) {
1000
- const varMatch = content.match(scalingMetaValueRegex(varName));
1001
- if (!varMatch) return void 0;
1002
- if (isNaN(Number(varMatch[2]?.trim()))) {
1003
- throw new Error("Scaling variables should be numbers");
1675
+ function parseBlockScalarMetaVar(content, varName) {
1676
+ const match = content.match(
1677
+ new RegExp(
1678
+ `^${varName}:\\s*([|>])\\s*\\r?\\n((?:(?:[ ]+.*|\\s*)(?:\\r?\\n|$))+)`,
1679
+ "m"
1680
+ )
1681
+ );
1682
+ if (!match) return void 0;
1683
+ const style = match[1];
1684
+ const rawBlock = match[2];
1685
+ const lines = rawBlock.split(/\r?\n/);
1686
+ const firstNonEmpty = lines.find((l) => l.trim() !== "");
1687
+ if (!firstNonEmpty) return void 0;
1688
+ const baseIndent = firstNonEmpty.match(/^([ ]*)/)[1].length;
1689
+ const stripped = lines.map((line) => line.trim() === "" ? "" : line.slice(baseIndent)).join("\n").replace(/\n+$/, "");
1690
+ if (style === "|") {
1691
+ return stripped;
1692
+ }
1693
+ return stripped.replace(/\n\n/g, "\0").replace(/\n/g, " ").replace(/\0/g, "\n");
1694
+ }
1695
+ function parseArbitraryQuantity(raw) {
1696
+ const quantityMatch = raw.trim().match(quantityAlternativeRegex);
1697
+ if (!quantityMatch?.groups) {
1698
+ throw new InvalidQuantityFormat(
1699
+ raw,
1700
+ "Arbitrary quantities must have a numerical value"
1701
+ );
1702
+ }
1703
+ const value = parseQuantityValue(quantityMatch.groups.quantity);
1704
+ const unit = quantityMatch.groups.unit;
1705
+ if (!value || value.type === "fixed" && value.value.type === "text") {
1706
+ throw new InvalidQuantityFormat(
1707
+ raw,
1708
+ "Arbitrary quantities must have a numerical value"
1709
+ );
1710
+ }
1711
+ const arbitrary = {
1712
+ quantity: value
1713
+ };
1714
+ if (unit) arbitrary.unit = unit;
1715
+ return arbitrary;
1716
+ }
1717
+ function parseServingsMetaVar(content, varName) {
1718
+ const raw = parseSimpleMetaVar(content, varName);
1719
+ if (raw === void 0) return void 0;
1720
+ const num = Number(raw);
1721
+ if (isNaN(num)) {
1722
+ return { numericValue: 1, rawValue: raw };
1723
+ }
1724
+ return { numericValue: num, rawValue: num };
1725
+ }
1726
+ function parseYieldMetaVar(content) {
1727
+ const match = content.match(yieldMetaValueRegex);
1728
+ if (!match) return void 0;
1729
+ if (match.groups?.arbitraryQuantity) {
1730
+ const parsed = parseArbitraryQuantity(match.groups.arbitraryQuantity);
1731
+ const result = {
1732
+ quantity: parsed.quantity
1733
+ };
1734
+ if (parsed.unit) result.unit = parsed.unit;
1735
+ if (match.groups.servingsPrefix) {
1736
+ result.textBefore = match.groups.servingsPrefix;
1737
+ }
1738
+ if (match.groups.servingsSuffix) {
1739
+ result.textAfter = match.groups.servingsSuffix;
1740
+ }
1741
+ return result;
1742
+ }
1743
+ if (match.groups?.quantity) {
1744
+ const result = {
1745
+ quantity: parseQuantityValue(match.groups.quantity)
1746
+ };
1747
+ if (match.groups.unit) result.unit = match.groups.unit;
1748
+ return result;
1004
1749
  }
1005
- return [Number(varMatch[2]?.trim()), varMatch[1].trim()];
1750
+ return void 0;
1006
1751
  }
1007
1752
  function parseListMetaVar(content, varName) {
1008
1753
  const listMatch = content.match(
@@ -1018,6 +1763,115 @@ function parseListMetaVar(content, varName) {
1018
1763
  return listMatch[2].split("\n").filter((line) => line.trim() !== "").map((line) => line.replace(/^\s*-\s*/, "").trim());
1019
1764
  }
1020
1765
  }
1766
+ function extractAllMetadataKeys(content) {
1767
+ const keys = [];
1768
+ for (const match of content.matchAll(metadataKeyRegex)) {
1769
+ keys.push(match[1].trim());
1770
+ }
1771
+ return [...new Set(keys)];
1772
+ }
1773
+ function parseNestedMetaVar(content, varName) {
1774
+ const match = content.match(nestedMetaVarRegex(varName));
1775
+ if (!match) return void 0;
1776
+ const nestedContent = match[1];
1777
+ return parseNestedBlock(nestedContent);
1778
+ }
1779
+ function parseNestedBlock(content) {
1780
+ const lines = content.split(/\r?\n/).filter((line) => line.trim() !== "");
1781
+ if (lines.length === 0) return void 0;
1782
+ const baseIndentMatch = lines[0].match(/^(\s*)/);
1783
+ if (baseIndentMatch?.[0]?.includes(" ")) {
1784
+ throw new NoTabAsIndentError();
1785
+ }
1786
+ const baseIndent = baseIndentMatch?.[1]?.length;
1787
+ if (lines[0].trim().startsWith("- ")) return void 0;
1788
+ const result = {};
1789
+ let i2 = 0;
1790
+ while (i2 < lines.length) {
1791
+ const line = lines[i2];
1792
+ const leadingWhitespace = line.match(/^(\s*)/)?.[1];
1793
+ if (leadingWhitespace && leadingWhitespace.includes(" ")) {
1794
+ throw new NoTabAsIndentError();
1795
+ }
1796
+ const currentIndent = leadingWhitespace.length;
1797
+ if (currentIndent < baseIndent) {
1798
+ break;
1799
+ }
1800
+ if (currentIndent !== baseIndent) {
1801
+ throw new BadIndentationError();
1802
+ }
1803
+ const keyValueMatch = line.match(/^[ ]*([^:\n]+?):\s*(.*)$/);
1804
+ if (!keyValueMatch) {
1805
+ i2++;
1806
+ continue;
1807
+ }
1808
+ const key = keyValueMatch[1].trim();
1809
+ const rawValue = keyValueMatch[2].trim();
1810
+ if (rawValue === "") {
1811
+ const childLines = [];
1812
+ let j = i2 + 1;
1813
+ while (j < lines.length) {
1814
+ const childLine = lines[j];
1815
+ const childIndent = childLine.match(/^([ ]*)/)?.[1]?.length;
1816
+ if (childIndent && childIndent > baseIndent) {
1817
+ childLines.push(childLine);
1818
+ j++;
1819
+ } else {
1820
+ break;
1821
+ }
1822
+ }
1823
+ if (childLines.length > 0) {
1824
+ const firstChildTrimmed = childLines[0].trim();
1825
+ if (firstChildTrimmed.startsWith("- ")) {
1826
+ const reconstructedContent = `${key}:
1827
+ ${childLines.join("\n")}`;
1828
+ const listResult = parseListMetaVar(reconstructedContent, key);
1829
+ if (listResult) {
1830
+ result[key] = listResult.map(
1831
+ (item) => parseMetadataValue(item)
1832
+ );
1833
+ }
1834
+ } else {
1835
+ const childContent = childLines.join("\n");
1836
+ const nested = parseNestedBlock(childContent);
1837
+ if (nested) {
1838
+ result[key] = nested;
1839
+ }
1840
+ }
1841
+ }
1842
+ i2 = j;
1843
+ } else {
1844
+ result[key] = parseMetadataValue(rawValue);
1845
+ i2++;
1846
+ }
1847
+ }
1848
+ return result;
1849
+ }
1850
+ function parseMetadataValue(rawValue) {
1851
+ if (rawValue.startsWith("[") && rawValue.endsWith("]")) {
1852
+ return rawValue.slice(1, -1).split(",").map((item) => item.trim());
1853
+ }
1854
+ if (numericValueRegex.test(rawValue)) {
1855
+ return Number(rawValue);
1856
+ }
1857
+ return rawValue;
1858
+ }
1859
+ function parseAnyMetaVar(content, varName) {
1860
+ const nested = parseNestedMetaVar(content, varName);
1861
+ if (nested) return nested;
1862
+ const list = parseListMetaVar(content, varName);
1863
+ if (list) return list;
1864
+ const simple = parseSimpleMetaVar(content, varName);
1865
+ if (simple) return parseMetadataValue(simple);
1866
+ return void 0;
1867
+ }
1868
+ function getNumericValueFromYield(v) {
1869
+ if (v.quantity.type === "fixed" && v.quantity.value.type !== "text") {
1870
+ return getNumericValue(v.quantity.value);
1871
+ }
1872
+ if (v.quantity.type === "range") return getNumericValue(v.quantity.min);
1873
+ return 1;
1874
+ }
1021
1875
  function extractMetadata(content) {
1022
1876
  const metadata = {};
1023
1877
  let servings = void 0;
@@ -1025,13 +1879,24 @@ function extractMetadata(content) {
1025
1879
  if (!metadataContent) {
1026
1880
  return { metadata };
1027
1881
  }
1028
- for (const metaVar of [
1882
+ const handledKeys = /* @__PURE__ */ new Set([
1883
+ // Simple string fields
1029
1884
  "title",
1885
+ "author",
1886
+ "locale",
1887
+ "introduction",
1888
+ "description",
1889
+ "course",
1890
+ "category",
1891
+ "diet",
1892
+ "cuisine",
1893
+ "difficulty",
1894
+ // Source fields
1030
1895
  "source",
1031
1896
  "source.name",
1032
1897
  "source.url",
1033
- "author",
1034
1898
  "source.author",
1899
+ // Time fields
1035
1900
  "prep time",
1036
1901
  "time.prep",
1037
1902
  "cook time",
@@ -1039,6 +1904,24 @@ function extractMetadata(content) {
1039
1904
  "time required",
1040
1905
  "time",
1041
1906
  "duration",
1907
+ // Image fields
1908
+ "image",
1909
+ "picture",
1910
+ "images",
1911
+ "pictures",
1912
+ // Unit system
1913
+ "unit system",
1914
+ // Scaling fields
1915
+ "servings",
1916
+ "yield",
1917
+ "serves",
1918
+ // List fields
1919
+ "tags",
1920
+ "variants"
1921
+ ]);
1922
+ for (const metaVar of [
1923
+ "title",
1924
+ "author",
1042
1925
  "locale",
1043
1926
  "introduction",
1044
1927
  "description",
@@ -1046,25 +1929,97 @@ function extractMetadata(content) {
1046
1929
  "category",
1047
1930
  "diet",
1048
1931
  "cuisine",
1049
- "difficulty",
1050
- "image",
1051
- "picture"
1932
+ "difficulty"
1052
1933
  ]) {
1934
+ if (metaVar === "description" || metaVar === "introduction") {
1935
+ const blockValue = parseBlockScalarMetaVar(metadataContent, metaVar);
1936
+ if (blockValue) {
1937
+ metadata[metaVar] = blockValue;
1938
+ continue;
1939
+ }
1940
+ }
1053
1941
  const stringMetaValue = parseSimpleMetaVar(metadataContent, metaVar);
1054
1942
  if (stringMetaValue) metadata[metaVar] = stringMetaValue;
1055
1943
  }
1056
- for (const metaVar of ["serves", "yield", "servings"]) {
1057
- const scalingMetaValue = parseScalingMetaVar(metadataContent, metaVar);
1058
- if (scalingMetaValue && scalingMetaValue[1]) {
1059
- metadata[metaVar] = scalingMetaValue[1];
1060
- servings = scalingMetaValue[0];
1944
+ const sourceNested = parseNestedMetaVar(metadataContent, "source");
1945
+ const sourceTxt = parseSimpleMetaVar(metadataContent, "source");
1946
+ const sourceName = parseSimpleMetaVar(metadataContent, "source.name");
1947
+ const sourceUrl = parseSimpleMetaVar(metadataContent, "source.url");
1948
+ const sourceAuthor = parseSimpleMetaVar(metadataContent, "source.author");
1949
+ if (sourceNested) {
1950
+ const source = {};
1951
+ if (typeof sourceNested.name === "string") source.name = sourceNested.name;
1952
+ if (typeof sourceNested.url === "string") source.url = sourceNested.url;
1953
+ if (typeof sourceNested.author === "string")
1954
+ source.author = sourceNested.author;
1955
+ if (Object.keys(source).length > 0) metadata.source = source;
1956
+ } else if (sourceName || sourceAuthor || sourceUrl) {
1957
+ const source = {};
1958
+ if (sourceName) source.name = sourceName;
1959
+ if (sourceUrl) source.url = sourceUrl;
1960
+ if (sourceAuthor) source.author = sourceAuthor;
1961
+ metadata.source = source;
1962
+ } else if (sourceTxt) {
1963
+ metadata.source = sourceTxt;
1964
+ }
1965
+ const timeNested = parseNestedMetaVar(metadataContent, "time");
1966
+ const prepTime = parseSimpleMetaVar(metadataContent, "prep time") ?? parseSimpleMetaVar(metadataContent, "time.prep");
1967
+ const cookTime = parseSimpleMetaVar(metadataContent, "cook time") ?? parseSimpleMetaVar(metadataContent, "time.cook");
1968
+ const totalTime = parseSimpleMetaVar(metadataContent, "time required") ?? parseSimpleMetaVar(metadataContent, "time") ?? parseSimpleMetaVar(metadataContent, "duration");
1969
+ if (timeNested) {
1970
+ const time = {};
1971
+ if (typeof timeNested.prep === "string") time.prep = timeNested.prep;
1972
+ if (typeof timeNested.cook === "string") time.cook = timeNested.cook;
1973
+ if (typeof timeNested.total === "string") time.total = timeNested.total;
1974
+ if (Object.keys(time).length > 0) metadata.time = time;
1975
+ } else if (prepTime || cookTime || totalTime) {
1976
+ const time = {};
1977
+ if (prepTime) time.prep = prepTime;
1978
+ if (cookTime) time.cook = cookTime;
1979
+ if (totalTime) time.total = totalTime;
1980
+ metadata.time = time;
1981
+ }
1982
+ const image = parseSimpleMetaVar(metadataContent, "image") ?? parseSimpleMetaVar(metadataContent, "picture");
1983
+ if (image) metadata.image = image;
1984
+ const images = parseListMetaVar(metadataContent, "images") ?? parseListMetaVar(metadataContent, "pictures");
1985
+ if (images) metadata.images = images;
1986
+ let unitSystem;
1987
+ const unitSystemRaw = parseSimpleMetaVar(metadataContent, "unit system");
1988
+ if (unitSystemRaw) {
1989
+ metadata.unitSystem = unitSystemRaw;
1990
+ const unitSystemMap = {
1991
+ metric: "metric",
1992
+ us: "US",
1993
+ uk: "UK",
1994
+ jp: "JP"
1995
+ };
1996
+ unitSystem = unitSystemMap[unitSystemRaw.toLowerCase()];
1997
+ }
1998
+ const yieldValue = parseYieldMetaVar(metadataContent);
1999
+ if (yieldValue) {
2000
+ metadata.yield = yieldValue;
2001
+ servings = getNumericValueFromYield(yieldValue);
2002
+ }
2003
+ for (const metaVar of ["serves", "servings"]) {
2004
+ const result = parseServingsMetaVar(metadataContent, metaVar);
2005
+ if (result !== void 0) {
2006
+ metadata[metaVar] = result.rawValue;
2007
+ servings = result.numericValue;
1061
2008
  }
1062
2009
  }
1063
- for (const metaVar of ["tags", "images", "pictures"]) {
1064
- const listMetaValue = parseListMetaVar(metadataContent, metaVar);
1065
- if (listMetaValue) metadata[metaVar] = listMetaValue;
2010
+ const tags = parseListMetaVar(metadataContent, "tags");
2011
+ if (tags) metadata.tags = tags;
2012
+ const variants = parseListMetaVar(metadataContent, "variants");
2013
+ if (variants) metadata.variants = variants;
2014
+ const allKeys = extractAllMetadataKeys(metadataContent);
2015
+ for (const key of allKeys) {
2016
+ if (handledKeys.has(key)) continue;
2017
+ const value = parseAnyMetaVar(metadataContent, key);
2018
+ if (value !== void 0) {
2019
+ metadata[key] = value;
2020
+ }
1066
2021
  }
1067
- return { metadata, servings };
2022
+ return { metadata, servings, unitSystem };
1068
2023
  }
1069
2024
  function isPositiveIntegerString(str) {
1070
2025
  return /^\d+$/.test(str);
@@ -1078,10 +2033,230 @@ function unionOfSets(s1, s2) {
1078
2033
  }
1079
2034
  function getAlternativeSignature(alternatives) {
1080
2035
  if (!alternatives || alternatives.length === 0) return null;
1081
- return alternatives.map((a2) => a2.index).sort((a2, b) => a2 - b).join(",");
2036
+ return alternatives.flat().map((a2) => a2.index).sort((a2, b) => a2 - b).join(",");
1082
2037
  }
1083
2038
 
2039
+ // src/classes/pantry.ts
2040
+ var Pantry = class {
2041
+ /**
2042
+ * Creates a new Pantry instance.
2043
+ * @param tomlContent - Optional TOML content to parse.
2044
+ * @param options - Optional configuration options.
2045
+ */
2046
+ constructor(tomlContent, options = {}) {
2047
+ /**
2048
+ * The parsed pantry items.
2049
+ */
2050
+ __publicField(this, "items", []);
2051
+ /**
2052
+ * Options for date parsing and other configuration.
2053
+ */
2054
+ __publicField(this, "options");
2055
+ /**
2056
+ * Optional category configuration for alias-based lookups.
2057
+ */
2058
+ __publicField(this, "categoryConfig");
2059
+ this.options = options;
2060
+ if (tomlContent) {
2061
+ this.parse(tomlContent);
2062
+ }
2063
+ }
2064
+ /**
2065
+ * Parses a TOML string into pantry items.
2066
+ * @param tomlContent - The TOML string to parse.
2067
+ * @returns The parsed list of pantry items.
2068
+ */
2069
+ parse(tomlContent) {
2070
+ const raw = TOML.parse(tomlContent);
2071
+ this.items = [];
2072
+ for (const [location, locationData] of Object.entries(raw)) {
2073
+ const locationTable = locationData;
2074
+ for (const [itemName, itemData] of Object.entries(locationTable)) {
2075
+ const item = this.parseItem(
2076
+ itemName,
2077
+ location,
2078
+ itemData
2079
+ );
2080
+ this.items.push(item);
2081
+ }
2082
+ }
2083
+ return this.items;
2084
+ }
2085
+ /**
2086
+ * Parses a single pantry item from its TOML representation.
2087
+ */
2088
+ parseItem(name, location, data) {
2089
+ const item = { name, location };
2090
+ if (typeof data === "string") {
2091
+ const parsed = parseQuantityWithUnit(data);
2092
+ item.quantity = parsed.value;
2093
+ if (parsed.unit) item.unit = parsed.unit;
2094
+ } else {
2095
+ if (data.quantity) {
2096
+ const parsed = parseQuantityWithUnit(data.quantity);
2097
+ item.quantity = parsed.value;
2098
+ if (parsed.unit) item.unit = parsed.unit;
2099
+ }
2100
+ if (data.low) {
2101
+ const parsed = parseQuantityWithUnit(data.low);
2102
+ item.low = parsed.value;
2103
+ if (parsed.unit) item.lowUnit = parsed.unit;
2104
+ }
2105
+ if (data.bought) {
2106
+ item.bought = this.parseDate(data.bought);
2107
+ }
2108
+ if (data.expire) {
2109
+ item.expire = this.parseDate(data.expire);
2110
+ }
2111
+ }
2112
+ return item;
2113
+ }
2114
+ /**
2115
+ * Parses a date string using the configured format or fuzzy detection.
2116
+ */
2117
+ parseDate(input) {
2118
+ if (this.options.dateFormat) {
2119
+ return parseDateFromFormat(input, this.options.dateFormat);
2120
+ }
2121
+ return parseFuzzyDate(input);
2122
+ }
2123
+ /**
2124
+ * Sets a category configuration for alias-based item lookups.
2125
+ * @param config - The category configuration to use.
2126
+ */
2127
+ setCategoryConfig(config) {
2128
+ this.categoryConfig = config;
2129
+ }
2130
+ /**
2131
+ * Finds a pantry item by name, using exact match first, then alias lookup
2132
+ * via the stored CategoryConfig.
2133
+ * @param name - The name to search for.
2134
+ * @returns The matching pantry item, or undefined if not found.
2135
+ */
2136
+ findItem(name) {
2137
+ const lowerName = name.toLowerCase();
2138
+ const exact = this.items.find(
2139
+ (item) => item.name.toLowerCase() === lowerName
2140
+ );
2141
+ if (exact) return exact;
2142
+ if (this.categoryConfig) {
2143
+ for (const category of this.categoryConfig.categories) {
2144
+ for (const catIngredient of category.ingredients) {
2145
+ if (catIngredient.aliases.some(
2146
+ (alias) => alias.toLowerCase() === lowerName
2147
+ )) {
2148
+ const canonicalName = catIngredient.name.toLowerCase();
2149
+ const byCanonical = this.items.find(
2150
+ (item) => item.name.toLowerCase() === canonicalName
2151
+ );
2152
+ if (byCanonical) return byCanonical;
2153
+ for (const alias of catIngredient.aliases) {
2154
+ const byAlias = this.items.find(
2155
+ (item) => item.name.toLowerCase() === alias.toLowerCase()
2156
+ );
2157
+ if (byAlias) return byAlias;
2158
+ }
2159
+ }
2160
+ }
2161
+ }
2162
+ }
2163
+ return void 0;
2164
+ }
2165
+ /**
2166
+ * Gets the numeric value of a pantry item's quantity, optionally converted to base units.
2167
+ * Returns undefined if the quantity has a text value or is not set.
2168
+ */
2169
+ getItemNumericValue(quantity, unit) {
2170
+ if (!quantity) return void 0;
2171
+ let numericValue;
2172
+ if (quantity.type === "fixed") {
2173
+ if (quantity.value.type === "text") return void 0;
2174
+ numericValue = getNumericValue(quantity.value);
2175
+ } else {
2176
+ numericValue = (getNumericValue(quantity.min) + getNumericValue(quantity.max)) / 2;
2177
+ }
2178
+ if (unit) {
2179
+ const unitDef = normalizeUnit(unit);
2180
+ if (unitDef) {
2181
+ const toBase = getToBase(unitDef);
2182
+ numericValue *= toBase;
2183
+ }
2184
+ }
2185
+ return numericValue;
2186
+ }
2187
+ /**
2188
+ * Returns all items that are depleted (quantity = 0) or below their low threshold.
2189
+ * @returns An array of depleted pantry items.
2190
+ */
2191
+ getDepletedItems() {
2192
+ return this.items.filter((item) => this.isItemLow(item));
2193
+ }
2194
+ /**
2195
+ * Returns all items whose expiration date is within `nbDays` days from today
2196
+ * (or already passed).
2197
+ * @param nbDays - Number of days ahead to check. Defaults to 0 (already expired).
2198
+ * @returns An array of expired pantry items.
2199
+ */
2200
+ getExpiredItems(nbDays = 0) {
2201
+ return this.items.filter((item) => this.isItemExpired(item, nbDays));
2202
+ }
2203
+ /**
2204
+ * Checks if a specific item is low (quantity = 0 or below `low` threshold).
2205
+ * @param itemName - The name of the item to check (supports aliases if CategoryConfig is set).
2206
+ * @returns true if the item is low, false otherwise. Returns false if item not found.
2207
+ */
2208
+ isLow(itemName) {
2209
+ const item = this.findItem(itemName);
2210
+ if (!item) return false;
2211
+ return this.isItemLow(item);
2212
+ }
2213
+ /**
2214
+ * Checks if a specific item is expired or expires within `nbDays` days.
2215
+ * @param itemName - The name of the item to check (supports aliases if CategoryConfig is set).
2216
+ * @param nbDays - Number of days ahead to check. Defaults to 0.
2217
+ * @returns true if the item is expired, false otherwise. Returns false if item not found.
2218
+ */
2219
+ isExpired(itemName, nbDays = 0) {
2220
+ const item = this.findItem(itemName);
2221
+ if (!item) return false;
2222
+ return this.isItemExpired(item, nbDays);
2223
+ }
2224
+ /**
2225
+ * Internal: checks if a pantry item is low.
2226
+ */
2227
+ isItemLow(item) {
2228
+ if (!item.quantity) return false;
2229
+ const qtyValue = this.getItemNumericValue(item.quantity, item.unit);
2230
+ if (qtyValue === void 0) return false;
2231
+ if (qtyValue === 0) return true;
2232
+ if (item.low) {
2233
+ const lowValue = this.getItemNumericValue(item.low, item.lowUnit);
2234
+ if (lowValue !== void 0 && qtyValue <= lowValue) return true;
2235
+ }
2236
+ return false;
2237
+ }
2238
+ /**
2239
+ * Internal: checks if a pantry item is expired.
2240
+ */
2241
+ isItemExpired(item, nbDays) {
2242
+ if (!item.expire) return false;
2243
+ const now = /* @__PURE__ */ new Date();
2244
+ const cutoff = new Date(
2245
+ now.getFullYear(),
2246
+ now.getMonth(),
2247
+ now.getDate() + nbDays
2248
+ );
2249
+ const expireDay = new Date(
2250
+ item.expire.getFullYear(),
2251
+ item.expire.getMonth(),
2252
+ item.expire.getDate()
2253
+ );
2254
+ return expireDay <= cutoff;
2255
+ }
2256
+ };
2257
+
1084
2258
  // src/classes/product_catalog.ts
2259
+ import TOML2 from "smol-toml";
1085
2260
  var ProductCatalog = class {
1086
2261
  constructor(tomlContent) {
1087
2262
  __publicField(this, "products", []);
@@ -1093,7 +2268,7 @@ var ProductCatalog = class {
1093
2268
  * @returns A parsed list of `ProductOption`.
1094
2269
  */
1095
2270
  parse(tomlContent) {
1096
- const catalogRaw = TOML.parse(tomlContent);
2271
+ const catalogRaw = TOML2.parse(tomlContent);
1097
2272
  this.products = [];
1098
2273
  if (!this.isValidTomlContent(catalogRaw)) {
1099
2274
  throw new InvalidProductCatalogFormat();
@@ -1110,7 +2285,7 @@ var ProductCatalog = class {
1110
2285
  const sizeStrings = Array.isArray(size) ? size : [size];
1111
2286
  const sizes = sizeStrings.map((sizeStr) => {
1112
2287
  const sizeAndUnitRaw = sizeStr.split("%");
1113
- const sizeParsed = parseQuantityInput(
2288
+ const sizeParsed = parseQuantityValue(
1114
2289
  sizeAndUnitRaw[0]
1115
2290
  );
1116
2291
  const productSize = { size: sizeParsed };
@@ -1166,7 +2341,7 @@ var ProductCatalog = class {
1166
2341
  size: sizeStrings.length === 1 ? sizeStrings[0] : sizeStrings
1167
2342
  };
1168
2343
  }
1169
- return TOML.stringify(grouped);
2344
+ return TOML2.stringify(grouped);
1170
2345
  }
1171
2346
  /**
1172
2347
  * Adds a product to the catalog.
@@ -1223,8 +2398,10 @@ var Section = class {
1223
2398
  /**
1224
2399
  * Creates an instance of Section.
1225
2400
  * @param name - The name of the section. Defaults to an empty string.
2401
+ * @param variants - Optional variant names for this section.
2402
+ * @param optional - Whether the section is optional.
1226
2403
  */
1227
- constructor(name = "") {
2404
+ constructor(name = "", variants, optional) {
1228
2405
  /**
1229
2406
  * The name of the section. Can be an empty string for the default (first) section.
1230
2407
  * @defaultValue `""`
@@ -1232,7 +2409,13 @@ var Section = class {
1232
2409
  __publicField(this, "name");
1233
2410
  /** An array of steps and notes that make up the content of the section. */
1234
2411
  __publicField(this, "content", []);
2412
+ /** Optional list of variant names this section belongs to. */
2413
+ __publicField(this, "variants");
2414
+ /** Whether the section has been marked as optional ([?]) */
2415
+ __publicField(this, "optional");
1235
2416
  this.name = name;
2417
+ if (variants) this.variants = variants;
2418
+ if (optional) this.optional = true;
1236
2419
  }
1237
2420
  /**
1238
2421
  * Checks if the section is blank (has no name and no content).
@@ -1245,46 +2428,16 @@ var Section = class {
1245
2428
  };
1246
2429
 
1247
2430
  // src/quantities/alternatives.ts
1248
- import Big3 from "big.js";
1249
-
1250
- // src/units/conversion.ts
1251
- import Big2 from "big.js";
1252
- function getUnitRatio(q1, q2) {
1253
- const q1Value = getAverageValue(q1.quantity);
1254
- const q2Value = getAverageValue(q2.quantity);
1255
- const factor = "toBase" in q1.unit && "toBase" in q2.unit ? q1.unit.toBase / q2.unit.toBase : 1;
1256
- if (typeof q1Value !== "number" || typeof q2Value !== "number") {
1257
- throw Error(
1258
- "One of both values is not a number, so a ratio cannot be computed"
1259
- );
1260
- }
1261
- return Big2(q1Value).times(factor).div(q2Value);
1262
- }
1263
- function getBaseUnitRatio(q, qRef) {
1264
- if ("toBase" in q.unit && "toBase" in qRef.unit) {
1265
- return q.unit.toBase / qRef.unit.toBase;
1266
- } else {
1267
- return 1;
1268
- }
1269
- }
2431
+ import Big4 from "big.js";
1270
2432
 
1271
2433
  // src/units/lookup.ts
1272
- function areUnitsCompatible(u1, u2) {
1273
- if (u1.name === u2.name) {
1274
- return true;
1275
- }
1276
- if (u1.type !== "other" && u1.type === u2.type && u1.system === u2.system) {
1277
- return true;
1278
- }
1279
- return false;
1280
- }
1281
2434
  function findListWithCompatibleQuantity(list, quantity) {
1282
2435
  const quantityWithUnitDef = {
1283
2436
  ...quantity,
1284
2437
  unit: resolveUnit(quantity.unit?.name)
1285
2438
  };
1286
2439
  return list.find(
1287
- (l) => l.some((lq) => areUnitsCompatible(lq.unit, quantityWithUnitDef.unit))
2440
+ (l) => l.some((lq) => areUnitsGroupable(lq.unit, quantityWithUnitDef.unit))
1288
2441
  );
1289
2442
  }
1290
2443
  function findCompatibleQuantityWithinList(list, quantity) {
@@ -1298,10 +2451,14 @@ function findCompatibleQuantityWithinList(list, quantity) {
1298
2451
  }
1299
2452
 
1300
2453
  // src/utils/general.ts
2454
+ import Big3 from "big.js";
1301
2455
  var legacyDeepClone = (v) => {
1302
2456
  if (v === null || typeof v !== "object") {
1303
2457
  return v;
1304
2458
  }
2459
+ if (v instanceof Big3) {
2460
+ return new Big3(v);
2461
+ }
1305
2462
  if (v instanceof Map) {
1306
2463
  return new Map(
1307
2464
  Array.from(v.entries()).map(([k, val]) => [
@@ -1311,7 +2468,9 @@ var legacyDeepClone = (v) => {
1311
2468
  );
1312
2469
  }
1313
2470
  if (v instanceof Set) {
1314
- return new Set(Array.from(v).map((val) => legacyDeepClone(val)));
2471
+ return new Set(
2472
+ Array.from(v).map((val) => legacyDeepClone(val))
2473
+ );
1315
2474
  }
1316
2475
  if (v instanceof Date) {
1317
2476
  return new Date(v.getTime());
@@ -1325,27 +2484,24 @@ var legacyDeepClone = (v) => {
1325
2484
  }
1326
2485
  return cloned;
1327
2486
  };
1328
- var deepClone = (v) => typeof structuredClone === "function" ? structuredClone(v) : legacyDeepClone(v);
2487
+ var deepClone = (v) => legacyDeepClone(v);
1329
2488
 
1330
2489
  // src/quantities/alternatives.ts
1331
2490
  function getEquivalentUnitsLists(...quantities) {
1332
2491
  const quantitiesCopy = deepClone(quantities);
1333
- const OrGroups = quantitiesCopy.filter(isOrGroup).filter((q) => q.entries.length > 1);
2492
+ const OrGroups = quantitiesCopy.filter(isOrGroup).filter((q) => q.or.length > 1);
1334
2493
  const unitLists = [];
1335
2494
  const normalizeOrGroup = (og) => ({
1336
- ...og,
1337
- entries: og.entries.map((q) => ({
2495
+ or: og.or.map((q) => ({
1338
2496
  ...q,
1339
2497
  unit: resolveUnit(q.unit?.name, q.unit?.integerProtected)
1340
2498
  }))
1341
2499
  });
1342
2500
  function findLinkIndexForUnits(lists, unitsToCheck) {
1343
2501
  return lists.findIndex((l) => {
1344
- const listItem = l.map((q) => resolveUnit(q.unit?.name));
2502
+ const listItems = l.map((q) => resolveUnit(q.unit?.name));
1345
2503
  return unitsToCheck.some(
1346
- (u) => listItem.some(
1347
- (lu) => lu.name === u?.name || lu.system === u?.system && lu.type === u?.type && lu.type !== "other"
1348
- )
2504
+ (u) => u && listItems.some((lu) => areUnitsGroupable(lu, u))
1349
2505
  );
1350
2506
  });
1351
2507
  }
@@ -1356,17 +2512,19 @@ function getEquivalentUnitsLists(...quantities) {
1356
2512
  ...v,
1357
2513
  unit: resolveUnit(v.unit?.name, v.unit?.integerProtected)
1358
2514
  };
1359
- const commonQuantity = og.entries.find(
1360
- (q) => isQuantity(q) && areUnitsCompatible(q.unit, normalizedV.unit)
2515
+ const commonQuantity = og.or.find(
2516
+ (q) => isQuantity(q) && areUnitsGroupable(q.unit, normalizedV.unit)
1361
2517
  );
1362
2518
  if (commonQuantity) {
1363
2519
  acc.push(normalizedV);
1364
- unitRatio = getUnitRatio(normalizedV, commonQuantity);
2520
+ if (!unitRatio) {
2521
+ unitRatio = getUnitRatio(normalizedV, commonQuantity);
2522
+ }
1365
2523
  }
1366
2524
  return acc;
1367
2525
  }, []);
1368
- for (const newQ of og.entries) {
1369
- if (commonUnitList.some((q) => areUnitsCompatible(q.unit, newQ.unit))) {
2526
+ for (const newQ of og.or) {
2527
+ if (commonUnitList.some((q) => areUnitsGroupable(q.unit, newQ.unit))) {
1370
2528
  continue;
1371
2529
  } else {
1372
2530
  const scaledQuantity = multiplyQuantityValue(newQ.quantity, unitRatio);
@@ -1376,10 +2534,10 @@ function getEquivalentUnitsLists(...quantities) {
1376
2534
  }
1377
2535
  for (const orGroup of OrGroups) {
1378
2536
  const orGroupModified = normalizeOrGroup(orGroup);
1379
- const units2 = orGroupModified.entries.map((q) => q.unit);
2537
+ const units2 = orGroupModified.or.map((q) => q.unit);
1380
2538
  const linkIndex = findLinkIndexForUnits(unitLists, units2);
1381
2539
  if (linkIndex === -1) {
1382
- unitLists.push(orGroupModified.entries);
2540
+ unitLists.push(orGroupModified.or);
1383
2541
  } else {
1384
2542
  mergeOrGroupIntoList(unitLists, linkIndex, orGroupModified);
1385
2543
  }
@@ -1475,7 +2633,7 @@ function reduceOrsToFirstEquivalent(unitList, quantities) {
1475
2633
  return quantities.map((q) => {
1476
2634
  if (isQuantity(q)) return reduceToQuantity(q);
1477
2635
  const qListModified = sortUnitList(
1478
- q.entries.map((qq) => ({
2636
+ q.or.map((qq) => ({
1479
2637
  ...qq,
1480
2638
  unit: resolveUnit(qq.unit?.name, qq.unit?.integerProtected)
1481
2639
  }))
@@ -1483,7 +2641,7 @@ function reduceOrsToFirstEquivalent(unitList, quantities) {
1483
2641
  return reduceToQuantity(qListModified[0]);
1484
2642
  });
1485
2643
  }
1486
- function addQuantitiesOrGroups(...quantities) {
2644
+ function addQuantitiesOrGroups(quantities, system) {
1487
2645
  if (quantities.length === 0)
1488
2646
  return {
1489
2647
  sum: {
@@ -1513,7 +2671,7 @@ function addQuantitiesOrGroups(...quantities) {
1513
2671
  unit: resolveUnit(nextQ.unit?.name)
1514
2672
  });
1515
2673
  } else {
1516
- const sumQ = addQuantities(existingQ, nextQ);
2674
+ const sumQ = addQuantities(existingQ, nextQ, system);
1517
2675
  existingQ.quantity = sumQ.quantity;
1518
2676
  existingQ.unit = resolveUnit(sumQ.unit?.name);
1519
2677
  }
@@ -1521,10 +2679,10 @@ function addQuantitiesOrGroups(...quantities) {
1521
2679
  if (sum.length === 1) {
1522
2680
  return { sum: sum[0], unitsLists };
1523
2681
  }
1524
- return { sum: { type: "and", entries: sum }, unitsLists };
2682
+ return { sum: { and: sum }, unitsLists };
1525
2683
  }
1526
- function regroupQuantitiesAndExpandEquivalents(sum, unitsLists) {
1527
- const sumQuantities = isGroup(sum) ? sum.entries : [sum];
2684
+ function regroupQuantitiesAndExpandEquivalents(sum, unitsLists, system) {
2685
+ const sumQuantities = isAndGroup(sum) ? sum.and : [sum];
1528
2686
  const result = [];
1529
2687
  const processedQuantities = /* @__PURE__ */ new Set();
1530
2688
  for (const list of unitsLists) {
@@ -1551,10 +2709,20 @@ function regroupQuantitiesAndExpandEquivalents(sum, unitsLists) {
1551
2709
  }
1552
2710
  return main.reduce((acc, v) => {
1553
2711
  const mainInList = findCompatibleQuantityWithinList(list, v);
2712
+ const conversionRatio = getBaseUnitRatio(v, mainInList);
2713
+ const valueInOriginalUnit = Big4(getAverageValue(v.quantity)).times(
2714
+ conversionRatio
2715
+ );
1554
2716
  const newValue = {
1555
2717
  quantity: multiplyQuantityValue(
1556
- v.quantity,
1557
- Big3(getAverageValue(equiv.quantity)).div(
2718
+ {
2719
+ type: "fixed",
2720
+ value: {
2721
+ type: "decimal",
2722
+ decimal: valueInOriginalUnit.toNumber()
2723
+ }
2724
+ },
2725
+ Big4(getAverageValue(equiv.quantity)).div(
1558
2726
  getAverageValue(mainInList.quantity)
1559
2727
  )
1560
2728
  )
@@ -1562,17 +2730,15 @@ function regroupQuantitiesAndExpandEquivalents(sum, unitsLists) {
1562
2730
  if (equiv.unit && !isNoUnit(equiv.unit)) {
1563
2731
  newValue.unit = { name: equiv.unit.name };
1564
2732
  }
1565
- return addQuantities(acc, newValue);
2733
+ return addQuantities(acc, newValue, system);
1566
2734
  }, initialValue);
1567
2735
  });
1568
2736
  if (main.length + equivalents.length > 1) {
1569
2737
  const resultMain = main.length > 1 ? {
1570
- type: "and",
1571
- entries: main.map(deNormalizeQuantity)
2738
+ and: main.map(deNormalizeQuantity)
1572
2739
  } : deNormalizeQuantity(main[0]);
1573
2740
  result.push({
1574
- type: "or",
1575
- entries: [resultMain, ...equivalents]
2741
+ or: [resultMain, ...equivalents]
1576
2742
  });
1577
2743
  } else {
1578
2744
  result.push(deNormalizeQuantity(main[0]));
@@ -1581,21 +2747,66 @@ function regroupQuantitiesAndExpandEquivalents(sum, unitsLists) {
1581
2747
  sumQuantities.filter((q) => !processedQuantities.has(q)).forEach((q) => result.push(deNormalizeQuantity(q)));
1582
2748
  return result;
1583
2749
  }
1584
- function addEquivalentsAndSimplify(...quantities) {
2750
+ function addEquivalentsAndSimplify(quantities, system) {
1585
2751
  if (quantities.length === 1) {
1586
2752
  return toPlainUnit(quantities[0]);
1587
2753
  }
1588
- const { sum, unitsLists } = addQuantitiesOrGroups(...quantities);
1589
- const regrouped = regroupQuantitiesAndExpandEquivalents(sum, unitsLists);
2754
+ const { sum, unitsLists } = addQuantitiesOrGroups(quantities, system);
2755
+ const regrouped = regroupQuantitiesAndExpandEquivalents(
2756
+ sum,
2757
+ unitsLists,
2758
+ system
2759
+ );
1590
2760
  if (regrouped.length === 1) {
1591
2761
  return toPlainUnit(regrouped[0]);
1592
2762
  } else {
1593
- return { type: "and", entries: regrouped.map(toPlainUnit) };
2763
+ return { and: regrouped.map(toPlainUnit) };
2764
+ }
2765
+ }
2766
+ function buildEquivalenceRatioMap(unitsLists) {
2767
+ const ratioMap = {};
2768
+ for (const list of unitsLists) {
2769
+ for (const equiv of list) {
2770
+ const equivValue = getAverageValue(equiv.quantity);
2771
+ for (const primary of list) {
2772
+ if (primary === equiv) continue;
2773
+ const primaryValue = getAverageValue(primary.quantity);
2774
+ const equivUnit = normalizeUnit(equiv.unit.name)?.name ?? equiv.unit.name;
2775
+ const primaryUnit = normalizeUnit(primary.unit.name)?.name ?? primary.unit.name;
2776
+ ratioMap[equivUnit] ?? (ratioMap[equivUnit] = {});
2777
+ ratioMap[equivUnit][primaryUnit] = equivValue / primaryValue;
2778
+ }
2779
+ }
2780
+ }
2781
+ return ratioMap;
2782
+ }
2783
+ function recomputeEquivalents(primaries, ratioMap, equivUnits) {
2784
+ const equivalents = [];
2785
+ for (const equivUnit of equivUnits) {
2786
+ const ratios = ratioMap[normalizeUnit(equivUnit)?.name ?? equivUnit];
2787
+ let total = 0;
2788
+ for (const primary of primaries) {
2789
+ const pUnit = normalizeUnit(primary.unit ?? NO_UNIT)?.name ?? primary.unit ?? NO_UNIT;
2790
+ const ratio = ratios[pUnit];
2791
+ if (ratio === void 0) continue;
2792
+ const pValue = getAverageValue(primary.quantity);
2793
+ total += pValue * ratio;
2794
+ }
2795
+ if (total > 0) {
2796
+ equivalents.push({
2797
+ quantity: {
2798
+ type: "fixed",
2799
+ value: { type: "decimal", decimal: total }
2800
+ },
2801
+ ...equivUnit !== "" && { unit: equivUnit }
2802
+ });
2803
+ }
1594
2804
  }
2805
+ return equivalents.length > 0 ? equivalents : void 0;
1595
2806
  }
1596
2807
 
1597
2808
  // src/classes/recipe.ts
1598
- import Big4 from "big.js";
2809
+ import Big5 from "big.js";
1599
2810
  var _Recipe = class _Recipe {
1600
2811
  /**
1601
2812
  * Creates a new Recipe instance.
@@ -1607,12 +2818,12 @@ var _Recipe = class _Recipe {
1607
2818
  */
1608
2819
  __publicField(this, "metadata", {});
1609
2820
  /**
1610
- * The default or manual choice of alternative ingredients.
1611
- * Contains the full context including alternatives list and active selection index.
2821
+ * The possible choices of alternative ingredients for this recipe.
1612
2822
  */
1613
2823
  __publicField(this, "choices", {
1614
2824
  ingredientItems: /* @__PURE__ */ new Map(),
1615
- ingredientGroups: /* @__PURE__ */ new Map()
2825
+ ingredientGroups: /* @__PURE__ */ new Map(),
2826
+ variants: []
1616
2827
  });
1617
2828
  /**
1618
2829
  * The parsed recipe ingredients.
@@ -1643,10 +2854,20 @@ var _Recipe = class _Recipe {
1643
2854
  */
1644
2855
  __publicField(this, "servings");
1645
2856
  _Recipe.itemCounts.set(this, 0);
2857
+ _Recipe.subgroupIndices.set(this, /* @__PURE__ */ new Map());
1646
2858
  if (content) {
1647
2859
  this.parse(content);
1648
2860
  }
1649
2861
  }
2862
+ /**
2863
+ * Gets the unit system specified in the recipe metadata.
2864
+ * Used for resolving ambiguous units like tsp, tbsp, cup, etc.
2865
+ *
2866
+ * @returns The unit system if specified, or undefined to use defaults
2867
+ */
2868
+ get unitSystem() {
2869
+ return _Recipe.unitSystems.get(this);
2870
+ }
1650
2871
  /**
1651
2872
  * Gets the current item count for this recipe.
1652
2873
  */
@@ -1669,27 +2890,17 @@ var _Recipe = class _Recipe {
1669
2890
  */
1670
2891
  _parseArbitraryScalable(regexMatchGroups, intoArray) {
1671
2892
  if (!regexMatchGroups || !regexMatchGroups.arbitraryQuantity) return;
1672
- const quantityMatch = regexMatchGroups.arbitraryQuantity?.trim().match(quantityAlternativeRegex);
1673
- if (quantityMatch?.groups) {
1674
- const value = quantityMatch.groups.quantity ? parseQuantityInput(quantityMatch.groups.quantity) : void 0;
1675
- const unit = quantityMatch.groups.unit;
1676
- const name = regexMatchGroups.arbitraryName || void 0;
1677
- if (!value || value.type === "fixed" && value.value.type === "text") {
1678
- throw new InvalidQuantityFormat(
1679
- regexMatchGroups.arbitraryQuantity?.trim(),
1680
- "Arbitrary quantities must have a numerical value"
1681
- );
1682
- }
1683
- const arbitrary = {
1684
- quantity: value
1685
- };
1686
- if (name) arbitrary.name = name;
1687
- if (unit) arbitrary.unit = unit;
1688
- intoArray.push({
1689
- type: "arbitrary",
1690
- index: this.arbitraries.push(arbitrary) - 1
1691
- });
1692
- }
2893
+ const parsed = parseArbitraryQuantity(regexMatchGroups.arbitraryQuantity);
2894
+ const name = regexMatchGroups.arbitraryName || void 0;
2895
+ const arbitrary = {
2896
+ quantity: parsed.quantity
2897
+ };
2898
+ if (name) arbitrary.name = name;
2899
+ if (parsed.unit) arbitrary.unit = parsed.unit;
2900
+ intoArray.push({
2901
+ type: "arbitrary",
2902
+ index: this.arbitraries.push(arbitrary) - 1
2903
+ });
1693
2904
  }
1694
2905
  /**
1695
2906
  * Parses text for arbitrary scalables and returns NoteItem array.
@@ -1703,13 +2914,13 @@ var _Recipe = class _Recipe {
1703
2914
  for (const match of text.matchAll(globalRegex)) {
1704
2915
  const idx = match.index;
1705
2916
  if (idx > cursor) {
1706
- noteItems.push({ type: "text", value: text.slice(cursor, idx) });
2917
+ noteItems.push(...parseMarkdownSegments(text.slice(cursor, idx)));
1707
2918
  }
1708
2919
  this._parseArbitraryScalable(match.groups, noteItems);
1709
2920
  cursor = idx + match[0].length;
1710
2921
  }
1711
2922
  if (cursor < text.length) {
1712
- noteItems.push({ type: "text", value: text.slice(cursor) });
2923
+ noteItems.push(...parseMarkdownSegments(text.slice(cursor)));
1713
2924
  }
1714
2925
  return noteItems;
1715
2926
  }
@@ -1717,7 +2928,7 @@ var _Recipe = class _Recipe {
1717
2928
  let quantityMatch = quantityRaw.match(quantityAlternativeRegex);
1718
2929
  const quantities = [];
1719
2930
  while (quantityMatch?.groups) {
1720
- const value = quantityMatch.groups.quantity ? parseQuantityInput(quantityMatch.groups.quantity) : void 0;
2931
+ const value = quantityMatch.groups.quantity ? parseQuantityValue(quantityMatch.groups.quantity) : void 0;
1721
2932
  const unit = quantityMatch.groups.unit;
1722
2933
  if (value) {
1723
2934
  const newQuantity = { quantity: value };
@@ -1818,7 +3029,7 @@ var _Recipe = class _Recipe {
1818
3029
  alternative.note = note;
1819
3030
  }
1820
3031
  if (itemQuantity) {
1821
- alternative.itemQuantity = itemQuantity;
3032
+ Object.assign(alternative, itemQuantity);
1822
3033
  }
1823
3034
  alternatives.push(alternative);
1824
3035
  testString = groups.ingredientAlternative || "";
@@ -1861,6 +3072,7 @@ var _Recipe = class _Recipe {
1861
3072
  if (!match?.groups) return;
1862
3073
  const groups = match.groups;
1863
3074
  const groupKey = groups.gIngredientGroupKey;
3075
+ const subgroupKey = groups.gIngredientSubgroupKey;
1864
3076
  let name = groups.gmIngredientName || groups.gsIngredientName;
1865
3077
  const preparation = groups.gIngredientPreparation;
1866
3078
  const modifiers = groups.gIngredientModifiers;
@@ -1926,9 +3138,14 @@ var _Recipe = class _Recipe {
1926
3138
  displayName
1927
3139
  };
1928
3140
  if (itemQuantity) {
1929
- alternative.itemQuantity = itemQuantity;
3141
+ Object.assign(alternative, itemQuantity);
1930
3142
  }
1931
- const existingAlternatives = this.choices.ingredientGroups.get(groupKey);
3143
+ const note = groups.gIngredientNote?.trim();
3144
+ if (note) {
3145
+ alternative.note = note;
3146
+ }
3147
+ const existingSubgroups = this.choices.ingredientGroups.get(groupKey);
3148
+ const existingAlternativesFlat = existingSubgroups?.flat();
1932
3149
  function upsertAlternativeToIngredient(ingredients, ingredientIdx, newAlternativeIdx) {
1933
3150
  const ingredient = ingredients[ingredientIdx];
1934
3151
  if (ingredient) {
@@ -1939,8 +3156,8 @@ var _Recipe = class _Recipe {
1939
3156
  }
1940
3157
  }
1941
3158
  }
1942
- if (existingAlternatives) {
1943
- for (const alt of existingAlternatives) {
3159
+ if (existingAlternativesFlat) {
3160
+ for (const alt of existingAlternativesFlat) {
1944
3161
  upsertAlternativeToIngredient(this.ingredients, alt.index, idxInList);
1945
3162
  upsertAlternativeToIngredient(this.ingredients, idxInList, alt.index);
1946
3163
  }
@@ -1952,14 +3169,35 @@ var _Recipe = class _Recipe {
1952
3169
  group: groupKey,
1953
3170
  alternatives: [alternative]
1954
3171
  };
3172
+ if (subgroupKey !== void 0) {
3173
+ newItem.subgroup = subgroupKey;
3174
+ }
1955
3175
  items.push(newItem);
1956
3176
  const choiceAlternative = deepClone(alternative);
1957
3177
  choiceAlternative.itemId = id;
1958
3178
  const existingChoice = this.choices.ingredientGroups.get(groupKey);
3179
+ const sgMap = _Recipe.subgroupIndices.get(this);
1959
3180
  if (!existingChoice) {
1960
- this.choices.ingredientGroups.set(groupKey, [choiceAlternative]);
3181
+ this.choices.ingredientGroups.set(groupKey, [[choiceAlternative]]);
3182
+ if (subgroupKey !== void 0) {
3183
+ sgMap.set(groupKey, /* @__PURE__ */ new Map([[subgroupKey, 0]]));
3184
+ }
3185
+ } else if (subgroupKey !== void 0) {
3186
+ const groupSgMap = sgMap.get(groupKey);
3187
+ const existingIdx = groupSgMap?.get(subgroupKey);
3188
+ if (existingIdx !== void 0) {
3189
+ existingChoice[existingIdx].push(choiceAlternative);
3190
+ } else {
3191
+ const newIdx = existingChoice.length;
3192
+ existingChoice.push([choiceAlternative]);
3193
+ if (!groupSgMap) {
3194
+ sgMap.set(groupKey, /* @__PURE__ */ new Map([[subgroupKey, newIdx]]));
3195
+ } else {
3196
+ groupSgMap.set(subgroupKey, newIdx);
3197
+ }
3198
+ }
1961
3199
  } else {
1962
- existingChoice.push(choiceAlternative);
3200
+ existingChoice.push([choiceAlternative]);
1963
3201
  }
1964
3202
  }
1965
3203
  /**
@@ -1973,142 +3211,230 @@ var _Recipe = class _Recipe {
1973
3211
  * Quantities are grouped by their alternative signature and summed using addEquivalentsAndSimplify.
1974
3212
  * @internal
1975
3213
  */
1976
- _populate_ingredient_quantities() {
1977
- this.ingredients = this.ingredients.map((ing) => {
1978
- if (ing.quantities) {
1979
- delete ing.quantities;
3214
+ _populateIngredientQuantities() {
3215
+ for (const ing of this.ingredients) {
3216
+ delete ing.quantities;
3217
+ delete ing.usedAsPrimary;
3218
+ }
3219
+ const ingredientsWithQuantities = this.getIngredientQuantities();
3220
+ const matchedIndices = /* @__PURE__ */ new Set();
3221
+ for (const computed of ingredientsWithQuantities) {
3222
+ const idx = this.ingredients.findIndex(
3223
+ (ing2, i2) => ing2.name === computed.name && !matchedIndices.has(i2)
3224
+ );
3225
+ matchedIndices.add(idx);
3226
+ const ing = this.ingredients[idx];
3227
+ if (computed.quantities) {
3228
+ ing.quantities = computed.quantities;
1980
3229
  }
1981
- if (ing.usedAsPrimary) {
1982
- delete ing.usedAsPrimary;
3230
+ if (computed.usedAsPrimary) {
3231
+ ing.usedAsPrimary = true;
1983
3232
  }
1984
- return ing;
1985
- });
1986
- const seenGroups = /* @__PURE__ */ new Set();
3233
+ }
3234
+ }
3235
+ // Type for accumulated quantities (used internally by collectQuantityGroups)
3236
+ // Defined as a static type alias for the private method's return type
3237
+ /** @internal */
3238
+ collectQuantityGroups(options) {
3239
+ const { section, step, choices } = options || {};
3240
+ const activeVariant = choices?.variant;
3241
+ const isDefaultVariant = activeVariant === void 0 || activeVariant === "*";
3242
+ const sectionsToProcess = section !== void 0 ? (() => {
3243
+ const idx = typeof section === "number" ? section : this.sections.indexOf(section);
3244
+ return idx >= 0 && idx < this.sections.length ? [this.sections[idx]] : [];
3245
+ })() : this.sections;
1987
3246
  const ingredientGroups = /* @__PURE__ */ new Map();
1988
- for (const section of this.sections) {
1989
- for (const step of section.content.filter(
3247
+ const selectedIndices = /* @__PURE__ */ new Set();
3248
+ const referencedIndices = /* @__PURE__ */ new Set();
3249
+ const dynamicOptionalIndices = /* @__PURE__ */ new Set();
3250
+ for (const currentSection of sectionsToProcess) {
3251
+ if (currentSection.variants) {
3252
+ if (isDefaultVariant) {
3253
+ if (!currentSection.variants.includes("*")) continue;
3254
+ } else {
3255
+ if (!currentSection.variants.includes(activeVariant)) continue;
3256
+ }
3257
+ }
3258
+ const allSteps = currentSection.content.filter(
1990
3259
  (item) => item.type === "step"
1991
- )) {
1992
- for (const item of step.items.filter(
3260
+ );
3261
+ const isOptionalSection = currentSection.optional === true;
3262
+ const stepsToProcess = step === void 0 ? allSteps : typeof step === "number" ? step >= 0 && step < allSteps.length ? [allSteps[step]] : [] : allSteps.includes(step) ? [step] : [];
3263
+ for (const currentStep of stepsToProcess) {
3264
+ if (currentStep.variants) {
3265
+ if (isDefaultVariant) {
3266
+ if (!currentStep.variants.includes("*")) continue;
3267
+ } else {
3268
+ if (!currentStep.variants.includes(activeVariant)) continue;
3269
+ }
3270
+ }
3271
+ const isOptionalStep = currentStep.optional === true || isOptionalSection;
3272
+ for (const item of currentStep.items.filter(
1993
3273
  (item2) => item2.type === "ingredient"
1994
3274
  )) {
1995
- const isGroupedItem = "group" in item && item.group !== void 0;
1996
- const isFirstInGroup = isGroupedItem && !seenGroups.has(item.group);
1997
- if (isGroupedItem) {
1998
- seenGroups.add(item.group);
1999
- }
2000
- const isPrimary = !isGroupedItem || isFirstInGroup;
2001
- const alternative = item.alternatives[0];
2002
- if (isPrimary) {
2003
- const primaryIngredient = this.ingredients[alternative.index];
2004
- if (primaryIngredient) {
2005
- primaryIngredient.usedAsPrimary = true;
3275
+ const isGrouped = "group" in item && item.group !== void 0;
3276
+ const groupSubgroups = isGrouped ? this.choices.ingredientGroups.get(item.group) : void 0;
3277
+ let selectedAltIndex = 0;
3278
+ let isSelected;
3279
+ let hasExplicitChoice;
3280
+ if (isGrouped) {
3281
+ const groupChoice = choices?.ingredientGroups?.get(item.group);
3282
+ hasExplicitChoice = groupChoice !== void 0;
3283
+ if (!hasExplicitChoice && !isDefaultVariant) {
3284
+ const matchingSubgroupIdx = groupSubgroups?.findIndex(
3285
+ (sg) => sg.some(
3286
+ (alt) => alt.note && alt.note.toLowerCase().includes(activeVariant.toLowerCase())
3287
+ )
3288
+ );
3289
+ if (matchingSubgroupIdx !== void 0 && matchingSubgroupIdx >= 0) {
3290
+ const matchedSubgroup = groupSubgroups[matchingSubgroupIdx];
3291
+ isSelected = matchedSubgroup.some(
3292
+ (alt) => alt.itemId === item.id
3293
+ );
3294
+ hasExplicitChoice = true;
3295
+ selectedAltIndex = 0;
3296
+ } else {
3297
+ const targetSubgroupIndex = 0;
3298
+ const selectedSubgroup = groupSubgroups?.[targetSubgroupIndex];
3299
+ isSelected = selectedSubgroup.some(
3300
+ (alt) => alt.itemId === item.id
3301
+ );
3302
+ }
3303
+ } else {
3304
+ const targetSubgroupIndex = groupChoice ?? 0;
3305
+ const selectedSubgroup = groupSubgroups?.[targetSubgroupIndex];
3306
+ isSelected = selectedSubgroup?.some((alt) => alt.itemId === item.id) ?? false;
2006
3307
  }
2007
- }
2008
- if (!isPrimary || !alternative.itemQuantity) continue;
2009
- const allQuantities = [
2010
- {
2011
- quantity: alternative.itemQuantity.quantity,
2012
- unit: alternative.itemQuantity.unit
3308
+ } else {
3309
+ const itemChoice = choices?.ingredientItems?.get(item.id);
3310
+ hasExplicitChoice = itemChoice !== void 0;
3311
+ if (!hasExplicitChoice && !isDefaultVariant) {
3312
+ const matchingIndices = item.alternatives.map((alt, idx) => ({ alt, idx })).filter(
3313
+ ({ alt }) => alt.note && alt.note.toLowerCase().includes(activeVariant.toLowerCase())
3314
+ ).map(({ idx }) => idx);
3315
+ if (matchingIndices.length > 0) {
3316
+ selectedAltIndex = matchingIndices[0];
3317
+ hasExplicitChoice = true;
3318
+ } else {
3319
+ selectedAltIndex = itemChoice ?? 0;
3320
+ }
3321
+ } else {
3322
+ selectedAltIndex = itemChoice ?? 0;
2013
3323
  }
2014
- ];
2015
- if (alternative.itemQuantity.equivalents) {
2016
- allQuantities.push(...alternative.itemQuantity.equivalents);
3324
+ isSelected = true;
3325
+ }
3326
+ const alternative = item.alternatives[selectedAltIndex];
3327
+ if (!alternative || !isSelected) continue;
3328
+ selectedIndices.add(alternative.index);
3329
+ if (isOptionalStep) {
3330
+ dynamicOptionalIndices.add(alternative.index);
2017
3331
  }
2018
- const quantityEntry = allQuantities.length === 1 ? allQuantities[0] : { type: "or", entries: allQuantities };
2019
- const hasInlineAlternatives = item.alternatives.length > 1;
2020
- const hasGroupedAlternatives = isGroupedItem && this.choices.ingredientGroups.has(item.group);
3332
+ const allAltsFlat = isGrouped ? groupSubgroups.flat() : item.alternatives;
3333
+ for (const alt of allAltsFlat) {
3334
+ referencedIndices.add(alt.index);
3335
+ }
3336
+ if (!alternative.quantity) continue;
3337
+ const baseQty = {
3338
+ quantity: alternative.quantity,
3339
+ ...alternative.unit && {
3340
+ unit: alternative.unit
3341
+ }
3342
+ };
3343
+ const quantityEntry = alternative.equivalents?.length ? { or: [baseQty, ...alternative.equivalents] } : baseQty;
2021
3344
  let alternativeRefs;
2022
- if (hasInlineAlternatives) {
2023
- alternativeRefs = [];
2024
- for (let j = 1; j < item.alternatives.length; j++) {
2025
- const otherAlt = item.alternatives[j];
2026
- const newRef = {
2027
- index: otherAlt.index
2028
- };
2029
- if (otherAlt.itemQuantity) {
2030
- const altQty = {
2031
- quantity: otherAlt.itemQuantity.quantity
3345
+ if (!hasExplicitChoice && groupSubgroups && groupSubgroups.length > 1) {
3346
+ const currentSubgroupIdx = groupSubgroups.findIndex(
3347
+ (sg) => sg.some((alt) => alt.itemId === item.id)
3348
+ );
3349
+ alternativeRefs = groupSubgroups.filter((_, idx) => idx !== currentSubgroupIdx).map(
3350
+ (subgroup) => subgroup.map((otherAlt) => {
3351
+ const ref = {
3352
+ index: otherAlt.index
2032
3353
  };
2033
- if (otherAlt.itemQuantity.unit) {
2034
- altQty.unit = otherAlt.itemQuantity.unit.name;
2035
- }
2036
- if (otherAlt.itemQuantity.equivalents) {
2037
- altQty.equivalents = otherAlt.itemQuantity.equivalents.map(
2038
- (eq) => toPlainUnit(eq)
2039
- );
3354
+ if (otherAlt.quantity) {
3355
+ const altQty = {
3356
+ quantity: otherAlt.quantity,
3357
+ ...otherAlt.unit && {
3358
+ unit: otherAlt.unit.name
3359
+ },
3360
+ ...otherAlt.equivalents && {
3361
+ equivalents: otherAlt.equivalents.map(
3362
+ (eq) => toPlainUnit(eq)
3363
+ )
3364
+ }
3365
+ };
3366
+ ref.quantities = [altQty];
2040
3367
  }
2041
- newRef.quantities = [altQty];
2042
- }
2043
- alternativeRefs.push(newRef);
2044
- }
2045
- } else if (hasGroupedAlternatives) {
2046
- const groupAlternatives = this.choices.ingredientGroups.get(
2047
- item.group
3368
+ return ref;
3369
+ })
2048
3370
  );
2049
- alternativeRefs = [];
2050
- for (let j = 1; j < groupAlternatives.length; j++) {
2051
- const otherAlt = groupAlternatives[j];
2052
- if (otherAlt.itemQuantity) {
3371
+ } else if (!hasExplicitChoice && !isGrouped && allAltsFlat.length > 1) {
3372
+ alternativeRefs = allAltsFlat.filter((alt) => alt.index !== alternative.index).map((otherAlt) => {
3373
+ const ref = { index: otherAlt.index };
3374
+ if (otherAlt.quantity) {
2053
3375
  const altQty = {
2054
- quantity: otherAlt.itemQuantity.quantity
3376
+ quantity: otherAlt.quantity,
3377
+ ...otherAlt.unit && {
3378
+ unit: otherAlt.unit.name
3379
+ },
3380
+ ...otherAlt.equivalents && {
3381
+ equivalents: otherAlt.equivalents.map(
3382
+ (eq) => toPlainUnit(eq)
3383
+ )
3384
+ }
2055
3385
  };
2056
- if (otherAlt.itemQuantity.unit) {
2057
- altQty.unit = otherAlt.itemQuantity.unit.name;
2058
- }
2059
- if (otherAlt.itemQuantity.equivalents) {
2060
- altQty.equivalents = otherAlt.itemQuantity.equivalents.map(
2061
- (eq) => toPlainUnit(eq)
2062
- );
2063
- }
2064
- alternativeRefs.push({
2065
- index: otherAlt.index,
2066
- quantities: [altQty]
2067
- });
3386
+ ref.quantities = [altQty];
2068
3387
  }
2069
- }
2070
- if (alternativeRefs.length === 0) {
2071
- alternativeRefs = void 0;
2072
- }
3388
+ return [ref];
3389
+ });
3390
+ }
3391
+ const altIndices = getAlternativeSignature(alternativeRefs) ?? "";
3392
+ let signature;
3393
+ if (isGrouped) {
3394
+ const resolvedUnit = resolveUnit(alternative.unit?.name);
3395
+ signature = `group:${item.group}|${altIndices}|${resolvedUnit.type}`;
3396
+ } else if (altIndices) {
3397
+ const resolvedUnit = resolveUnit(alternative.unit?.name);
3398
+ signature = `${altIndices}|${resolvedUnit.type}}`;
3399
+ } else {
3400
+ signature = null;
2073
3401
  }
2074
3402
  if (!ingredientGroups.has(alternative.index)) {
2075
3403
  ingredientGroups.set(alternative.index, /* @__PURE__ */ new Map());
2076
3404
  }
2077
- const groupsForIngredient = ingredientGroups.get(alternative.index);
2078
- const baseSignature = getAlternativeSignature(alternativeRefs);
2079
- const signature = isGroupedItem ? `group:${item.group}|${baseSignature ?? ""}` : baseSignature;
2080
- if (!groupsForIngredient.has(signature)) {
2081
- groupsForIngredient.set(signature, {
3405
+ const groupsForIng = ingredientGroups.get(alternative.index);
3406
+ if (!groupsForIng.has(signature)) {
3407
+ groupsForIng.set(signature, {
3408
+ quantities: [],
2082
3409
  alternativeQuantities: /* @__PURE__ */ new Map(),
2083
- quantities: []
3410
+ alternativeSubgroups: []
2084
3411
  });
2085
3412
  }
2086
- const group = groupsForIngredient.get(signature);
3413
+ const group = groupsForIng.get(signature);
2087
3414
  group.quantities.push(quantityEntry);
2088
- if (alternativeRefs) {
2089
- for (const ref of alternativeRefs) {
3415
+ if (alternativeRefs && alternativeRefs.length > 0 && group.alternativeSubgroups.length === 0) {
3416
+ group.alternativeSubgroups = alternativeRefs.map(
3417
+ (subgroup) => subgroup.map((ref) => ref.index)
3418
+ );
3419
+ }
3420
+ for (const subgroup of alternativeRefs ?? []) {
3421
+ for (const ref of subgroup) {
2090
3422
  if (!group.alternativeQuantities.has(ref.index)) {
2091
3423
  group.alternativeQuantities.set(ref.index, []);
2092
3424
  }
2093
- if (ref.quantities && ref.quantities.length > 0) {
2094
- for (const altQty of ref.quantities) {
2095
- if (altQty.equivalents && altQty.equivalents.length > 0) {
2096
- const entries = [
2097
- toExtendedUnit({
2098
- quantity: altQty.quantity,
2099
- unit: altQty.unit
2100
- }),
2101
- ...altQty.equivalents.map((eq) => toExtendedUnit(eq))
2102
- ];
2103
- group.alternativeQuantities.get(ref.index).push({ type: "or", entries });
2104
- } else {
2105
- group.alternativeQuantities.get(ref.index).push(
2106
- toExtendedUnit({
2107
- quantity: altQty.quantity,
2108
- unit: altQty.unit
2109
- })
2110
- );
2111
- }
3425
+ for (const altQty of ref.quantities ?? []) {
3426
+ const extended = toExtendedUnit({
3427
+ quantity: altQty.quantity,
3428
+ unit: altQty.unit
3429
+ });
3430
+ if (altQty.equivalents?.length) {
3431
+ const eqEntries = [
3432
+ extended,
3433
+ ...altQty.equivalents.map((eq) => toExtendedUnit(eq))
3434
+ ];
3435
+ group.alternativeQuantities.get(ref.index).push({ or: eqEntries });
3436
+ } else {
3437
+ group.alternativeQuantities.get(ref.index).push(extended);
2112
3438
  }
2113
3439
  }
2114
3440
  }
@@ -2116,144 +3442,219 @@ var _Recipe = class _Recipe {
2116
3442
  }
2117
3443
  }
2118
3444
  }
2119
- for (const [index, groupsForIngredient] of ingredientGroups) {
2120
- const ingredient = this.ingredients[index];
2121
- const quantityGroups = [];
2122
- for (const [, group] of groupsForIngredient) {
2123
- const summedGroupQuantity = addEquivalentsAndSimplify(
2124
- ...group.quantities
2125
- );
2126
- const groupQuantities = flattenPlainUnitGroup(summedGroupQuantity);
2127
- let alternatives;
2128
- if (group.alternativeQuantities.size > 0) {
2129
- alternatives = [];
2130
- for (const [altIndex, altQuantities] of group.alternativeQuantities) {
2131
- const ref = { index: altIndex };
2132
- if (altQuantities.length > 0) {
2133
- const summedAltQuantity = addEquivalentsAndSimplify(
2134
- ...altQuantities
2135
- );
2136
- const flattenedAlt = flattenPlainUnitGroup(summedAltQuantity);
2137
- ref.quantities = flattenedAlt.flatMap((item) => {
2138
- if ("quantity" in item) {
2139
- return [item];
2140
- } else {
2141
- return item.entries;
2142
- }
2143
- });
2144
- }
2145
- alternatives.push(ref);
2146
- }
2147
- }
2148
- for (const gq of groupQuantities) {
2149
- if ("type" in gq && gq.type === "and") {
2150
- const andGroup = {
2151
- type: "and",
2152
- entries: gq.entries
2153
- };
2154
- if (gq.equivalents && gq.equivalents.length > 0) {
2155
- andGroup.equivalents = gq.equivalents;
2156
- }
2157
- if (alternatives && alternatives.length > 0) {
2158
- andGroup.alternatives = alternatives;
2159
- }
2160
- quantityGroups.push(andGroup);
2161
- } else {
2162
- const quantityGroup = gq;
2163
- if (alternatives && alternatives.length > 0) {
2164
- quantityGroup.alternatives = alternatives;
2165
- }
2166
- quantityGroups.push(quantityGroup);
3445
+ return {
3446
+ ingredientGroups,
3447
+ selectedIndices,
3448
+ referencedIndices,
3449
+ dynamicOptionalIndices
3450
+ };
3451
+ }
3452
+ /**
3453
+ * Gets the raw (unprocessed) quantity groups for each ingredient, before
3454
+ * any summation or equivalents simplification. This is useful for cross-recipe
3455
+ * aggregation (e.g., in {@link ShoppingList}), where quantities from multiple
3456
+ * recipes should be combined before processing.
3457
+ *
3458
+ * @param options - Options for filtering and choice selection (same as {@link getIngredientQuantities}).
3459
+ * @returns Array of {@link RawQuantityGroup} objects, one per ingredient with quantities.
3460
+ *
3461
+ * @example
3462
+ * ```typescript
3463
+ * const rawGroups = recipe.getRawQuantityGroups();
3464
+ * // Each group has: name, usedAsPrimary, flags, quantities[]
3465
+ * // quantities are the raw QuantityWithExtendedUnit or FlatOrGroup entries
3466
+ * ```
3467
+ */
3468
+ getRawQuantityGroups(options) {
3469
+ const {
3470
+ ingredientGroups,
3471
+ selectedIndices,
3472
+ referencedIndices,
3473
+ dynamicOptionalIndices
3474
+ } = this.collectQuantityGroups(options);
3475
+ const result = [];
3476
+ for (let index = 0; index < this.ingredients.length; index++) {
3477
+ if (!referencedIndices.has(index)) continue;
3478
+ const orig = this.ingredients[index];
3479
+ const usedAsPrimary = selectedIndices.has(index);
3480
+ let flags = orig.flags;
3481
+ if (dynamicOptionalIndices.has(index) && !flags?.includes("optional")) {
3482
+ flags = [...flags ?? [], "optional"];
3483
+ }
3484
+ const quantities = [];
3485
+ if (usedAsPrimary) {
3486
+ const groupsForIng = ingredientGroups.get(index);
3487
+ if (groupsForIng) {
3488
+ for (const [, group] of groupsForIng) {
3489
+ quantities.push(...group.quantities);
2167
3490
  }
2168
3491
  }
2169
3492
  }
2170
- if (quantityGroups.length > 0) {
2171
- ingredient.quantities = quantityGroups;
2172
- }
3493
+ result.push({
3494
+ name: orig.name,
3495
+ ...usedAsPrimary && { usedAsPrimary: true },
3496
+ ...flags && { flags },
3497
+ quantities
3498
+ });
2173
3499
  }
3500
+ return result;
2174
3501
  }
2175
3502
  /**
2176
- * Calculates ingredient quantities based on the provided choices.
2177
- * Returns a list of computed ingredients with their total quantities.
3503
+ * Gets ingredients with their quantities populated, optionally filtered by section/step
3504
+ * and respecting user choices for alternatives.
3505
+ *
3506
+ * When no options are provided, returns all recipe ingredients with quantities
3507
+ * calculated using primary alternatives (same as after parsing).
3508
+ *
3509
+ * @param options - Options for filtering and choice selection:
3510
+ * - `section`: Filter to a specific section (Section object or 0-based index)
3511
+ * - `step`: Filter to a specific step (Step object or 0-based index)
3512
+ * - `choices`: Choices for alternative ingredients (defaults to primary)
3513
+ * @returns Array of Ingredient objects with quantities populated
3514
+ *
3515
+ * @example
3516
+ * ```typescript
3517
+ * // Get all ingredients with primary alternatives
3518
+ * const ingredients = recipe.getIngredientQuantities();
2178
3519
  *
2179
- * @param choices - The recipe choices to apply when computing quantities.
2180
- * If not provided, uses the default choices (first alternative for each item).
2181
- * @returns An array of ComputedIngredient with quantityTotal calculated based on choices.
3520
+ * // Get ingredients for a specific section
3521
+ * const sectionIngredients = recipe.getIngredientQuantities({ section: 0 });
3522
+ *
3523
+ * // Get ingredients with specific choices applied
3524
+ * const withChoices = recipe.getIngredientQuantities({
3525
+ * choices: { ingredientItems: new Map([['ingredient-item-2', 1]]) }
3526
+ * });
3527
+ * ```
2182
3528
  */
2183
- calc_ingredient_quantities(choices) {
2184
- const effectiveChoices = choices || {
2185
- ingredientItems: new Map(
2186
- Array.from(this.choices.ingredientItems.keys()).map((k) => [k, 0])
2187
- ),
2188
- ingredientGroups: new Map(
2189
- Array.from(this.choices.ingredientGroups.keys()).map((k) => [k, 0])
2190
- )
2191
- };
2192
- const ingredientQuantities = /* @__PURE__ */ new Map();
2193
- const selectedIngredientIndices = /* @__PURE__ */ new Set();
2194
- for (const section of this.sections) {
2195
- for (const step of section.content.filter(
2196
- (item) => item.type === "step"
2197
- )) {
2198
- for (const item of step.items.filter(
2199
- (item2) => item2.type === "ingredient"
2200
- )) {
2201
- for (let i2 = 0; i2 < item.alternatives.length; i2++) {
2202
- const alternative = item.alternatives[i2];
2203
- const isAlternativeChoiceItem = effectiveChoices.ingredientItems?.get(item.id) === i2;
2204
- const alternativeChoiceGroupIdx = item.group ? effectiveChoices.ingredientGroups?.get(item.group) : void 0;
2205
- const alternativeChoiceGroup = item.group ? this.choices.ingredientGroups.get(item.group) : void 0;
2206
- const isAlternativeChoiceGroup = alternativeChoiceGroup && alternativeChoiceGroupIdx !== void 0 ? alternativeChoiceGroup[alternativeChoiceGroupIdx]?.itemId === item.id : false;
2207
- const isSelected = !("group" in item) && (item.alternatives.length === 1 || isAlternativeChoiceItem) || isAlternativeChoiceGroup;
2208
- if (isSelected) {
2209
- selectedIngredientIndices.add(alternative.index);
2210
- if (alternative.itemQuantity) {
2211
- const allQuantities = [
2212
- {
2213
- quantity: alternative.itemQuantity.quantity,
2214
- unit: alternative.itemQuantity.unit
2215
- }
2216
- ];
2217
- if (alternative.itemQuantity.equivalents) {
2218
- allQuantities.push(...alternative.itemQuantity.equivalents);
2219
- }
2220
- const equivalents = allQuantities.length === 1 ? allQuantities[0] : {
2221
- type: "or",
2222
- entries: allQuantities
2223
- };
2224
- ingredientQuantities.set(alternative.index, [
2225
- ...ingredientQuantities.get(alternative.index) || [],
2226
- equivalents
2227
- ]);
3529
+ getIngredientQuantities(options) {
3530
+ const {
3531
+ ingredientGroups,
3532
+ selectedIndices,
3533
+ referencedIndices,
3534
+ dynamicOptionalIndices
3535
+ } = this.collectQuantityGroups(options);
3536
+ const result = [];
3537
+ for (let index = 0; index < this.ingredients.length; index++) {
3538
+ if (!referencedIndices.has(index)) continue;
3539
+ const orig = this.ingredients[index];
3540
+ let flags = orig.flags;
3541
+ if (dynamicOptionalIndices.has(index) && !flags?.includes("optional")) {
3542
+ flags = [...flags ?? [], "optional"];
3543
+ }
3544
+ const ing = {
3545
+ name: orig.name,
3546
+ ...orig.preparation && { preparation: orig.preparation },
3547
+ ...flags && { flags },
3548
+ ...orig.extras && { extras: orig.extras }
3549
+ };
3550
+ if (selectedIndices.has(index)) {
3551
+ ing.usedAsPrimary = true;
3552
+ const groupsForIng = ingredientGroups.get(index);
3553
+ if (groupsForIng) {
3554
+ const quantityGroups = [];
3555
+ for (const [, group] of groupsForIng) {
3556
+ const summed = addEquivalentsAndSimplify(
3557
+ group.quantities,
3558
+ this.unitSystem
3559
+ );
3560
+ const flattened = flattenPlainUnitGroup(summed);
3561
+ let alternatives;
3562
+ if (group.alternativeSubgroups.length > 0) {
3563
+ alternatives = group.alternativeSubgroups.map(
3564
+ (subgroupIndices) => subgroupIndices.map((altIdx) => {
3565
+ const altQtys = group.alternativeQuantities.get(altIdx);
3566
+ return {
3567
+ index: altIdx,
3568
+ ...altQtys.length > 0 && {
3569
+ quantities: flattenPlainUnitGroup(
3570
+ addEquivalentsAndSimplify(altQtys, this.unitSystem)
3571
+ ).flatMap(
3572
+ /* v8 ignore next -- item.and branch requires complex nested AND-with-equivalents structure */
3573
+ (item) => "quantity" in item ? [item] : item.and
3574
+ )
3575
+ }
3576
+ };
3577
+ })
3578
+ );
3579
+ }
3580
+ for (const gq of flattened) {
3581
+ if ("and" in gq) {
3582
+ quantityGroups.push({
3583
+ and: gq.and,
3584
+ ...gq.equivalents?.length && {
3585
+ equivalents: gq.equivalents
3586
+ },
3587
+ ...alternatives?.length && { alternatives }
3588
+ });
3589
+ } else {
3590
+ quantityGroups.push({
3591
+ ...gq,
3592
+ ...alternatives?.length && { alternatives }
3593
+ });
2228
3594
  }
2229
3595
  }
2230
3596
  }
3597
+ if (quantityGroups.length > 0) {
3598
+ ing.quantities = quantityGroups;
3599
+ }
2231
3600
  }
2232
3601
  }
3602
+ result.push(ing);
2233
3603
  }
2234
- const computedIngredients = [];
2235
- for (let index = 0; index < this.ingredients.length; index++) {
2236
- if (!selectedIngredientIndices.has(index)) continue;
2237
- const ing = this.ingredients[index];
2238
- const computed = {
2239
- name: ing.name
2240
- };
2241
- if (ing.preparation) {
2242
- computed.preparation = ing.preparation;
2243
- }
2244
- if (ing.flags) {
2245
- computed.flags = ing.flags;
2246
- }
2247
- if (ing.extras) {
2248
- computed.extras = ing.extras;
3604
+ return result;
3605
+ }
3606
+ /**
3607
+ * Returns the list of cookware items that are used in the active variant.
3608
+ * Cookware in steps/sections not matching the active variant are excluded.
3609
+ * Hidden cookware is always excluded.
3610
+ *
3611
+ * @param options - Options for filtering:
3612
+ * - `choices`: The choices to apply (only `variant` is used)
3613
+ * @returns Array of Cookware objects referenced by active steps
3614
+ *
3615
+ * @example
3616
+ * ```typescript
3617
+ * // Get all cookware for the default variant
3618
+ * const cookware = recipe.getCookwareForVariant();
3619
+ *
3620
+ * // Get cookware for a specific variant
3621
+ * const veganCookware = recipe.getCookwareForVariant({ choices: { variant: 'vegan' } });
3622
+ * ```
3623
+ */
3624
+ getCookwareForVariant(options) {
3625
+ const { choices } = options || {};
3626
+ const activeVariant = choices?.variant;
3627
+ const isDefaultVariant = activeVariant === void 0 || activeVariant === "*";
3628
+ const cookwareIndices = /* @__PURE__ */ new Set();
3629
+ for (const currentSection of this.sections) {
3630
+ if (currentSection.variants) {
3631
+ if (isDefaultVariant) {
3632
+ if (!currentSection.variants.includes("*")) continue;
3633
+ } else {
3634
+ if (!currentSection.variants.includes(activeVariant)) continue;
3635
+ }
2249
3636
  }
2250
- const quantities = ingredientQuantities.get(index);
2251
- if (quantities && quantities.length > 0) {
2252
- computed.quantityTotal = addEquivalentsAndSimplify(...quantities);
3637
+ const allSteps = currentSection.content.filter(
3638
+ (item) => item.type === "step"
3639
+ );
3640
+ for (const currentStep of allSteps) {
3641
+ if (currentStep.variants) {
3642
+ if (isDefaultVariant) {
3643
+ if (!currentStep.variants.includes("*")) continue;
3644
+ } else {
3645
+ if (!currentStep.variants.includes(activeVariant)) continue;
3646
+ }
3647
+ }
3648
+ for (const item of currentStep.items) {
3649
+ if (item.type === "cookware") {
3650
+ cookwareIndices.add(item.index);
3651
+ }
3652
+ }
2253
3653
  }
2254
- computedIngredients.push(computed);
2255
3654
  }
2256
- return computedIngredients;
3655
+ return this.cookware.filter(
3656
+ (cw, idx) => cookwareIndices.has(idx) && !cw.flags?.includes("hidden")
3657
+ );
2257
3658
  }
2258
3659
  /**
2259
3660
  * Parses a recipe from a string.
@@ -2261,17 +3662,23 @@ var _Recipe = class _Recipe {
2261
3662
  */
2262
3663
  parse(content) {
2263
3664
  const cleanContent = content.replace(metadataRegex, "").replace(commentRegex, "").replace(blockCommentRegex, "").trim().split(/\r\n?|\n/);
2264
- const { metadata, servings } = extractMetadata(content);
3665
+ const { metadata, servings, unitSystem } = extractMetadata(content);
2265
3666
  this.metadata = metadata;
2266
3667
  this.servings = servings;
3668
+ if (unitSystem) _Recipe.unitSystems.set(this, unitSystem);
2267
3669
  let blankLineBefore = true;
2268
3670
  let section = new Section();
2269
3671
  const items = [];
2270
3672
  let noteText = "";
2271
3673
  let inNote = false;
3674
+ let stepVariants;
3675
+ let stepOptional;
3676
+ const discoveredVariants = /* @__PURE__ */ new Set();
2272
3677
  for (const line of cleanContent) {
2273
3678
  if (line.trim().length === 0) {
2274
- flushPendingItems(section, items);
3679
+ flushPendingItems(section, items, stepVariants, stepOptional);
3680
+ stepVariants = void 0;
3681
+ stepOptional = void 0;
2275
3682
  flushPendingNote(
2276
3683
  section,
2277
3684
  noteText ? this._parseNoteText(noteText) : []
@@ -2282,30 +3689,42 @@ var _Recipe = class _Recipe {
2282
3689
  continue;
2283
3690
  }
2284
3691
  if (line.startsWith("=")) {
2285
- flushPendingItems(section, items);
3692
+ flushPendingItems(section, items, stepVariants, stepOptional);
3693
+ stepVariants = void 0;
3694
+ stepOptional = void 0;
2286
3695
  flushPendingNote(
2287
3696
  section,
2288
3697
  noteText ? this._parseNoteText(noteText) : []
2289
3698
  );
2290
3699
  noteText = "";
2291
- if (this.sections.length === 0 && section.isBlank()) {
2292
- section.name = line.replace(/^=+|=+$/g, "").trim();
2293
- } else {
2294
- if (!section.isBlank()) {
2295
- this.sections.push(section);
3700
+ let sectionName = line.replace(/^=+|=+$/g, "").trim();
3701
+ let sectionVariants;
3702
+ let sectionOptional;
3703
+ const sectionVarMatch = sectionName.match(variantTagRegex);
3704
+ if (sectionVarMatch?.groups) {
3705
+ const isOptionalPrefix = sectionVarMatch.groups.variantOptionalPrefix === "?";
3706
+ const names = (sectionVarMatch.groups.variantNames ?? "").split(",").map((n2) => n2.trim()).filter((n2) => n2.length > 0);
3707
+ if (names.length > 0) {
3708
+ sectionVariants = names;
3709
+ for (const v of names) discoveredVariants.add(v);
3710
+ }
3711
+ if (isOptionalPrefix) {
3712
+ sectionOptional = true;
2296
3713
  }
2297
- section = new Section(line.replace(/^=+|=+$/g, "").trim());
3714
+ sectionName = sectionName.slice(sectionVarMatch[0].length).trim();
2298
3715
  }
3716
+ if (!section.isBlank()) {
3717
+ this.sections.push(section);
3718
+ }
3719
+ section = new Section(sectionName, sectionVariants, sectionOptional);
2299
3720
  blankLineBefore = true;
2300
3721
  inNote = false;
2301
3722
  continue;
2302
3723
  }
2303
3724
  if (blankLineBefore && line.startsWith(">")) {
2304
- flushPendingItems(section, items);
2305
- flushPendingNote(
2306
- section,
2307
- noteText ? this._parseNoteText(noteText) : []
2308
- );
3725
+ flushPendingItems(section, items, stepVariants, stepOptional);
3726
+ stepVariants = void 0;
3727
+ stepOptional = void 0;
2309
3728
  noteText = line.substring(1).trim();
2310
3729
  inNote = true;
2311
3730
  blankLineBefore = false;
@@ -2320,13 +3739,31 @@ var _Recipe = class _Recipe {
2320
3739
  blankLineBefore = false;
2321
3740
  continue;
2322
3741
  }
2323
- flushPendingNote(section, noteText ? this._parseNoteText(noteText) : []);
2324
- noteText = "";
3742
+ let currentLine = line;
3743
+ if (items.length === 0) {
3744
+ const varMatch = currentLine.match(variantTagRegex);
3745
+ if (varMatch?.groups) {
3746
+ const isOptionalPrefix = varMatch.groups.variantOptionalPrefix === "?";
3747
+ const names = (varMatch.groups.variantNames ?? "").split(",").map((n2) => n2.trim()).filter((n2) => n2.length > 0);
3748
+ if (names.length > 0) {
3749
+ stepVariants = names;
3750
+ for (const v of names) discoveredVariants.add(v);
3751
+ }
3752
+ if (isOptionalPrefix) {
3753
+ stepOptional = true;
3754
+ }
3755
+ currentLine = currentLine.slice(varMatch[0].length);
3756
+ if (currentLine.trim().length === 0) {
3757
+ blankLineBefore = false;
3758
+ continue;
3759
+ }
3760
+ }
3761
+ }
2325
3762
  let cursor = 0;
2326
- for (const match of line.matchAll(tokensRegex)) {
3763
+ for (const match of currentLine.matchAll(tokensRegex)) {
2327
3764
  const idx = match.index;
2328
3765
  if (idx > cursor) {
2329
- items.push({ type: "text", value: line.slice(cursor, idx) });
3766
+ items.push(...parseMarkdownSegments(currentLine.slice(cursor, idx)));
2330
3767
  }
2331
3768
  const groups = match.groups;
2332
3769
  if (groups.mIngredientName || groups.sIngredientName) {
@@ -2345,7 +3782,7 @@ var _Recipe = class _Recipe {
2345
3782
  if (modifiers !== void 0 && modifiers.includes("-")) {
2346
3783
  flags.push("hidden");
2347
3784
  }
2348
- const quantity = quantityRaw ? parseQuantityInput(quantityRaw) : void 0;
3785
+ const quantity = quantityRaw ? parseQuantityValue(quantityRaw) : void 0;
2349
3786
  const newCookware = {
2350
3787
  name
2351
3788
  };
@@ -2377,7 +3814,7 @@ var _Recipe = class _Recipe {
2377
3814
  throw new Error("Timer missing unit");
2378
3815
  }
2379
3816
  const name = groups.timerName || void 0;
2380
- const duration = parseQuantityInput(durationStr);
3817
+ const duration = parseQuantityValue(durationStr);
2381
3818
  const timerObj = {
2382
3819
  name,
2383
3820
  duration,
@@ -2387,17 +3824,22 @@ var _Recipe = class _Recipe {
2387
3824
  }
2388
3825
  cursor = idx + match[0].length;
2389
3826
  }
2390
- if (cursor < line.length) {
2391
- items.push({ type: "text", value: line.slice(cursor) });
3827
+ if (cursor < currentLine.length) {
3828
+ items.push(...parseMarkdownSegments(currentLine.slice(cursor)));
2392
3829
  }
2393
3830
  blankLineBefore = false;
2394
3831
  }
2395
- flushPendingItems(section, items);
3832
+ flushPendingItems(section, items, stepVariants, stepOptional);
2396
3833
  flushPendingNote(section, noteText ? this._parseNoteText(noteText) : []);
2397
3834
  if (!section.isBlank()) {
2398
3835
  this.sections.push(section);
2399
3836
  }
2400
- this._populate_ingredient_quantities();
3837
+ const metaVariants = this.metadata.variants ?? [];
3838
+ const allVariants = /* @__PURE__ */ new Set([...metaVariants, ...discoveredVariants]);
3839
+ if (allVariants.size > 0) {
3840
+ this.choices.variants = [...allVariants];
3841
+ }
3842
+ this._populateIngredientQuantities();
2401
3843
  }
2402
3844
  /**
2403
3845
  * Scales the recipe to a new number of servings. In practice, it calls
@@ -2412,7 +3854,7 @@ var _Recipe = class _Recipe {
2412
3854
  if (originalServings === void 0 || originalServings === 0) {
2413
3855
  originalServings = 1;
2414
3856
  }
2415
- const factor = Big4(newServings).div(originalServings);
3857
+ const factor = Big5(newServings).div(originalServings);
2416
3858
  return this.scaleBy(factor);
2417
3859
  }
2418
3860
  /**
@@ -2427,18 +3869,19 @@ var _Recipe = class _Recipe {
2427
3869
  if (originalServings === void 0 || originalServings === 0) {
2428
3870
  originalServings = 1;
2429
3871
  }
3872
+ const unitSystem = this.unitSystem;
2430
3873
  function scaleAlternativesBy(alternatives, factor2) {
2431
3874
  for (const alternative of alternatives) {
2432
- if (alternative.itemQuantity) {
2433
- const scaleFactor = alternative.itemQuantity.scalable ? Big4(factor2) : 1;
2434
- if (alternative.itemQuantity.quantity.type !== "fixed" || alternative.itemQuantity.quantity.value.type !== "text") {
2435
- alternative.itemQuantity.quantity = multiplyQuantityValue(
2436
- alternative.itemQuantity.quantity,
3875
+ if (alternative.quantity) {
3876
+ const scaleFactor = alternative.scalable ? Big5(factor2) : 1;
3877
+ if (alternative.quantity.type !== "fixed" || alternative.quantity.value.type !== "text") {
3878
+ alternative.quantity = multiplyQuantityValue(
3879
+ alternative.quantity,
2437
3880
  scaleFactor
2438
3881
  );
2439
3882
  }
2440
- if (alternative.itemQuantity.equivalents) {
2441
- alternative.itemQuantity.equivalents = alternative.itemQuantity.equivalents.map(
3883
+ if (alternative.equivalents) {
3884
+ alternative.equivalents = alternative.equivalents.map(
2442
3885
  (altQuantity) => {
2443
3886
  if (altQuantity.quantity.type === "fixed" && altQuantity.quantity.value.type === "text") {
2444
3887
  return altQuantity;
@@ -2454,6 +3897,20 @@ var _Recipe = class _Recipe {
2454
3897
  }
2455
3898
  );
2456
3899
  }
3900
+ const optimizedPrimary = applyBestUnit(
3901
+ {
3902
+ quantity: alternative.quantity,
3903
+ unit: alternative.unit
3904
+ },
3905
+ unitSystem
3906
+ );
3907
+ alternative.quantity = optimizedPrimary.quantity;
3908
+ alternative.unit = optimizedPrimary.unit;
3909
+ if (alternative.equivalents) {
3910
+ alternative.equivalents = alternative.equivalents.map(
3911
+ (eq) => applyBestUnit(eq, unitSystem)
3912
+ );
3913
+ }
2457
3914
  }
2458
3915
  }
2459
3916
  }
@@ -2468,8 +3925,10 @@ var _Recipe = class _Recipe {
2468
3925
  }
2469
3926
  }
2470
3927
  }
2471
- for (const alternatives of newRecipe.choices.ingredientGroups.values()) {
2472
- scaleAlternativesBy(alternatives, factor);
3928
+ for (const subgroups of newRecipe.choices.ingredientGroups.values()) {
3929
+ for (const subgroup of subgroups) {
3930
+ scaleAlternativesBy(subgroup, factor);
3931
+ }
2473
3932
  }
2474
3933
  for (const alternatives of newRecipe.choices.ingredientItems.values()) {
2475
3934
  scaleAlternativesBy(alternatives, factor);
@@ -2479,39 +3938,198 @@ var _Recipe = class _Recipe {
2479
3938
  arbitrary.quantity,
2480
3939
  factor
2481
3940
  );
3941
+ const optimized = applyBestUnit(
3942
+ { quantity: arbitrary.quantity, unit: arbitrary.unit },
3943
+ unitSystem
3944
+ );
3945
+ arbitrary.quantity = optimized.quantity;
3946
+ arbitrary.unit = optimized.unit;
2482
3947
  }
2483
- newRecipe._populate_ingredient_quantities();
2484
- newRecipe.servings = Big4(originalServings).times(factor).toNumber();
2485
- if (newRecipe.metadata.servings && this.metadata.servings) {
2486
- if (floatRegex.test(String(this.metadata.servings).replace(",", ".").trim())) {
2487
- const servingsValue = parseFloat(
2488
- String(this.metadata.servings).replace(",", ".")
2489
- );
2490
- newRecipe.metadata.servings = String(
2491
- Big4(servingsValue).times(factor).toNumber()
2492
- );
3948
+ newRecipe._populateIngredientQuantities();
3949
+ newRecipe.servings = Big5(originalServings).times(factor).toNumber();
3950
+ for (const metaVar of ["servings", "serves"]) {
3951
+ if (typeof newRecipe.metadata[metaVar] === "number") {
3952
+ newRecipe.metadata[metaVar] = Big5(newRecipe.metadata[metaVar]).times(factor).toNumber();
2493
3953
  }
2494
3954
  }
2495
3955
  if (newRecipe.metadata.yield && this.metadata.yield) {
2496
- if (floatRegex.test(String(this.metadata.yield).replace(",", ".").trim())) {
2497
- const yieldValue = parseFloat(
2498
- String(this.metadata.yield).replace(",", ".")
3956
+ const original = this.metadata.yield;
3957
+ if (original.quantity.type === "fixed" && original.quantity.value.type === "text") {
3958
+ } else {
3959
+ const scaledQuantity = multiplyQuantityValue(
3960
+ original.quantity,
3961
+ factor
2499
3962
  );
2500
- newRecipe.metadata.yield = String(
2501
- Big4(yieldValue).times(factor).toNumber()
3963
+ const optimized = applyBestUnit(
3964
+ { quantity: scaledQuantity, unit: original.unit },
3965
+ unitSystem
2502
3966
  );
3967
+ const scaled = {
3968
+ quantity: optimized.quantity
3969
+ };
3970
+ if (optimized.unit) scaled.unit = optimized.unit;
3971
+ if (original.textBefore) scaled.textBefore = original.textBefore;
3972
+ if (original.textAfter) scaled.textAfter = original.textAfter;
3973
+ newRecipe.metadata.yield = scaled;
3974
+ }
3975
+ }
3976
+ return newRecipe;
3977
+ }
3978
+ /**
3979
+ * Converts all ingredient quantities in the recipe to a target unit system.
3980
+ *
3981
+ * @param system - The target unit system to convert to (metric, US, UK, JP)
3982
+ * @param method - How to handle existing quantities:
3983
+ * - "keep": Keep all existing equivalents (swap if needed, or add converted)
3984
+ * - "replace": Replace primary with target system quantity, discard equivalent used for conversion
3985
+ * - "remove": Only keep target system quantity, delete all equivalents
3986
+ * @returns A new Recipe instance with converted quantities
3987
+ *
3988
+ * @example
3989
+ * ```typescript
3990
+ * // Convert a recipe to metric, keeping original units as equivalents
3991
+ * const metricRecipe = recipe.convertTo("metric", "keep");
3992
+ *
3993
+ * // Convert to US units, removing all other equivalents
3994
+ * const usRecipe = recipe.convertTo("US", "remove");
3995
+ * ```
3996
+ */
3997
+ convertTo(system, method) {
3998
+ const newRecipe = this.clone();
3999
+ function buildNewPrimary(convertedQty, oldPrimary, remainingEquivalents, scalable, integerProtected, source) {
4000
+ const newUnit = integerProtected && convertedQty.unit ? { name: convertedQty.unit.name, integerProtected: true } : convertedQty.unit;
4001
+ const newPrimary = {
4002
+ quantity: convertedQty.quantity,
4003
+ unit: newUnit,
4004
+ scalable
4005
+ };
4006
+ if (method === "remove") {
4007
+ return newPrimary;
4008
+ } else if (method === "replace") {
4009
+ if (source === "converted") remainingEquivalents.push(oldPrimary);
4010
+ if (remainingEquivalents.length > 0) {
4011
+ newPrimary.equivalents = remainingEquivalents;
4012
+ }
4013
+ } else {
4014
+ newPrimary.equivalents = [oldPrimary, ...remainingEquivalents];
2503
4015
  }
4016
+ return newPrimary;
2504
4017
  }
2505
- if (newRecipe.metadata.serves && this.metadata.serves) {
2506
- if (floatRegex.test(String(this.metadata.serves).replace(",", ".").trim())) {
2507
- const servesValue = parseFloat(
2508
- String(this.metadata.serves).replace(",", ".")
4018
+ function convertAlternativeQuantity(alternative) {
4019
+ const primaryUnit = resolveUnit(alternative.unit?.name);
4020
+ const equivalents = alternative.equivalents ?? [];
4021
+ const oldPrimary = {
4022
+ quantity: alternative.quantity,
4023
+ unit: alternative.unit
4024
+ };
4025
+ if (primaryUnit.type !== "other" && isUnitCompatibleWithSystem(primaryUnit, system)) {
4026
+ if (method === "remove") {
4027
+ return {
4028
+ quantity: alternative.quantity,
4029
+ unit: alternative.unit,
4030
+ scalable: alternative.scalable
4031
+ };
4032
+ }
4033
+ return {
4034
+ quantity: alternative.quantity,
4035
+ unit: alternative.unit,
4036
+ scalable: alternative.scalable,
4037
+ equivalents
4038
+ };
4039
+ }
4040
+ const targetEquivIndex = equivalents.findIndex((eq) => {
4041
+ const eqUnit = resolveUnit(eq.unit?.name);
4042
+ return eqUnit.type !== "other" && isUnitCompatibleWithSystem(eqUnit, system);
4043
+ });
4044
+ if (targetEquivIndex !== -1) {
4045
+ const targetEquiv = equivalents[targetEquivIndex];
4046
+ const remainingEquivalents = equivalents.filter(
4047
+ (_, i2) => i2 !== targetEquivIndex
2509
4048
  );
2510
- newRecipe.metadata.serves = String(
2511
- Big4(servesValue).times(factor).toNumber()
4049
+ return buildNewPrimary(
4050
+ targetEquiv,
4051
+ oldPrimary,
4052
+ remainingEquivalents,
4053
+ alternative.scalable,
4054
+ targetEquiv.unit?.integerProtected,
4055
+ "swapped"
4056
+ );
4057
+ }
4058
+ const converted = convertQuantityToSystem(oldPrimary, system);
4059
+ if (converted && converted.unit) {
4060
+ return buildNewPrimary(
4061
+ converted,
4062
+ oldPrimary,
4063
+ equivalents,
4064
+ alternative.scalable,
4065
+ alternative.unit?.integerProtected,
4066
+ "swapped"
2512
4067
  );
2513
4068
  }
4069
+ for (let i2 = 0; i2 < equivalents.length; i2++) {
4070
+ const equiv = equivalents[i2];
4071
+ const convertedEquiv = convertQuantityToSystem(equiv, system);
4072
+ if (convertedEquiv && convertedEquiv.unit) {
4073
+ const remainingEquivalents = method === "keep" ? equivalents : equivalents.filter((_, idx) => idx !== i2);
4074
+ return buildNewPrimary(
4075
+ convertedEquiv,
4076
+ oldPrimary,
4077
+ remainingEquivalents,
4078
+ alternative.scalable,
4079
+ equiv.unit?.integerProtected,
4080
+ "converted"
4081
+ );
4082
+ }
4083
+ }
4084
+ if (method === "remove") {
4085
+ return {
4086
+ quantity: alternative.quantity,
4087
+ unit: alternative.unit,
4088
+ scalable: alternative.scalable
4089
+ };
4090
+ } else {
4091
+ return {
4092
+ quantity: alternative.quantity,
4093
+ unit: alternative.unit,
4094
+ scalable: alternative.scalable,
4095
+ equivalents
4096
+ };
4097
+ }
4098
+ }
4099
+ function convertAlternatives(alternatives) {
4100
+ for (const alternative of alternatives) {
4101
+ if (alternative.quantity) {
4102
+ const converted = convertAlternativeQuantity(
4103
+ alternative
4104
+ );
4105
+ alternative.quantity = converted.quantity;
4106
+ alternative.unit = converted.unit;
4107
+ alternative.scalable = converted.scalable;
4108
+ alternative.equivalents = converted.equivalents;
4109
+ }
4110
+ }
2514
4111
  }
4112
+ for (const section of newRecipe.sections) {
4113
+ for (const step of section.content.filter(
4114
+ (item) => item.type === "step"
4115
+ )) {
4116
+ for (const item of step.items.filter(
4117
+ (item2) => item2.type === "ingredient"
4118
+ )) {
4119
+ convertAlternatives(item.alternatives);
4120
+ }
4121
+ }
4122
+ }
4123
+ for (const subgroups of newRecipe.choices.ingredientGroups.values()) {
4124
+ for (const subgroup of subgroups) {
4125
+ convertAlternatives(subgroup);
4126
+ }
4127
+ }
4128
+ for (const alternatives of newRecipe.choices.ingredientItems.values()) {
4129
+ convertAlternatives(alternatives);
4130
+ }
4131
+ newRecipe._populateIngredientQuantities();
4132
+ if (method !== "keep") _Recipe.unitSystems.set(newRecipe, system);
2515
4133
  return newRecipe;
2516
4134
  }
2517
4135
  /**
@@ -2536,7 +4154,11 @@ var _Recipe = class _Recipe {
2536
4154
  newRecipe.metadata = deepClone(this.metadata);
2537
4155
  newRecipe.ingredients = deepClone(this.ingredients);
2538
4156
  newRecipe.sections = this.sections.map((section) => {
2539
- const newSection = new Section(section.name);
4157
+ const newSection = new Section(
4158
+ section.name,
4159
+ section.variants,
4160
+ section.optional
4161
+ );
2540
4162
  newSection.content = deepClone(section.content);
2541
4163
  return newSection;
2542
4164
  });
@@ -2547,21 +4169,30 @@ var _Recipe = class _Recipe {
2547
4169
  return newRecipe;
2548
4170
  }
2549
4171
  };
4172
+ /**
4173
+ * External storage for unit system (not a property on instances).
4174
+ * Used for resolving ambiguous units during quantity addition.
4175
+ */
4176
+ __publicField(_Recipe, "unitSystems", /* @__PURE__ */ new WeakMap());
2550
4177
  /**
2551
4178
  * External storage for item count (not a property on instances).
2552
4179
  * Used for giving ID numbers to items during parsing.
2553
4180
  */
2554
4181
  __publicField(_Recipe, "itemCounts", /* @__PURE__ */ new WeakMap());
4182
+ /**
4183
+ * External storage for subgroup index tracking during parsing.
4184
+ * Maps groupKey → subgroupKey → index within the subgroups array.
4185
+ */
4186
+ __publicField(_Recipe, "subgroupIndices", /* @__PURE__ */ new WeakMap());
2555
4187
  var Recipe = _Recipe;
2556
4188
 
2557
4189
  // src/classes/shopping_list.ts
2558
4190
  var ShoppingList = class {
2559
4191
  /**
2560
4192
  * Creates a new ShoppingList instance
2561
- * @param category_config_str - The category configuration to parse.
4193
+ * @param categoryConfigStr - The category configuration to parse.
2562
4194
  */
2563
- constructor(category_config_str) {
2564
- // TODO: backport type change
4195
+ constructor(categoryConfigStr) {
2565
4196
  /**
2566
4197
  * The ingredients in the shopping list.
2567
4198
  */
@@ -2573,43 +4204,43 @@ var ShoppingList = class {
2573
4204
  /**
2574
4205
  * The category configuration for the shopping list.
2575
4206
  */
2576
- __publicField(this, "category_config");
4207
+ __publicField(this, "categoryConfig");
2577
4208
  /**
2578
4209
  * The categorized ingredients in the shopping list.
2579
4210
  */
2580
4211
  __publicField(this, "categories");
2581
- if (category_config_str) {
2582
- this.set_category_config(category_config_str);
4212
+ /**
4213
+ * The unit system to use for quantity simplification.
4214
+ * When set, overrides per-recipe unit systems.
4215
+ */
4216
+ __publicField(this, "unitSystem");
4217
+ /**
4218
+ * Per-ingredient equivalence ratio maps for recomputing equivalents
4219
+ * after pantry subtraction. Keyed by ingredient name.
4220
+ * @internal
4221
+ */
4222
+ __publicField(this, "equivalenceRatios", /* @__PURE__ */ new Map());
4223
+ /**
4224
+ * The original pantry (never mutated by recipe calculations).
4225
+ */
4226
+ __publicField(this, "pantry");
4227
+ /**
4228
+ * The pantry with quantities updated after subtracting recipe needs.
4229
+ * Recomputed on every {@link ShoppingList.calculateIngredients | calculateIngredients()} call.
4230
+ */
4231
+ __publicField(this, "resultingPantry");
4232
+ if (categoryConfigStr) {
4233
+ this.setCategoryConfig(categoryConfigStr);
2583
4234
  }
2584
4235
  }
2585
- calculate_ingredients() {
4236
+ calculateIngredients() {
2586
4237
  this.ingredients = [];
2587
- const addIngredientQuantity = (name, quantityTotal) => {
2588
- const quantityTotalExtended = extendAllUnits(quantityTotal);
2589
- const newQuantities = isAndGroup(quantityTotalExtended) ? quantityTotalExtended.entries : [quantityTotalExtended];
2590
- const existing = this.ingredients.find((i2) => i2.name === name);
2591
- if (existing) {
2592
- if (!existing.quantityTotal) {
2593
- existing.quantityTotal = quantityTotal;
2594
- return;
2595
- }
2596
- try {
2597
- const existingQuantityTotalExtended = extendAllUnits(
2598
- existing.quantityTotal
2599
- );
2600
- const existingQuantities = isAndGroup(existingQuantityTotalExtended) ? existingQuantityTotalExtended.entries : [existingQuantityTotalExtended];
2601
- existing.quantityTotal = addEquivalentsAndSimplify(
2602
- ...existingQuantities,
2603
- ...newQuantities
2604
- );
2605
- return;
2606
- } catch {
2607
- }
4238
+ const rawQuantitiesMap = /* @__PURE__ */ new Map();
4239
+ const nameOrder = [];
4240
+ const trackName = (name) => {
4241
+ if (!nameOrder.includes(name)) {
4242
+ nameOrder.push(name);
2608
4243
  }
2609
- this.ingredients.push({
2610
- name,
2611
- quantityTotal
2612
- });
2613
4244
  };
2614
4245
  for (const addedRecipe of this.recipes) {
2615
4246
  let scaledRecipe;
@@ -2619,28 +4250,261 @@ var ShoppingList = class {
2619
4250
  } else {
2620
4251
  scaledRecipe = addedRecipe.recipe.scaleTo(addedRecipe.servings);
2621
4252
  }
2622
- const computedIngredients = scaledRecipe.calc_ingredient_quantities(
2623
- addedRecipe.choices
2624
- );
2625
- for (const ingredient of computedIngredients) {
2626
- if (ingredient.flags && ingredient.flags.includes("hidden")) {
4253
+ const rawGroups = scaledRecipe.getRawQuantityGroups({
4254
+ choices: addedRecipe.choices
4255
+ });
4256
+ for (const group of rawGroups) {
4257
+ if (group.flags?.includes("hidden") || !group.usedAsPrimary) {
2627
4258
  continue;
2628
4259
  }
2629
- if (ingredient.quantityTotal) {
2630
- addIngredientQuantity(ingredient.name, ingredient.quantityTotal);
2631
- } else if (!this.ingredients.some((i2) => i2.name === ingredient.name)) {
2632
- this.ingredients.push({ name: ingredient.name });
4260
+ trackName(group.name);
4261
+ if (group.quantities.length > 0) {
4262
+ const existing = rawQuantitiesMap.get(group.name) ?? [];
4263
+ existing.push(...group.quantities);
4264
+ rawQuantitiesMap.set(group.name, existing);
4265
+ }
4266
+ }
4267
+ }
4268
+ this.equivalenceRatios.clear();
4269
+ for (const name of nameOrder) {
4270
+ const rawQuantities = rawQuantitiesMap.get(name);
4271
+ if (!rawQuantities || rawQuantities.length === 0) {
4272
+ this.ingredients.push({ name });
4273
+ continue;
4274
+ }
4275
+ const textEntries = [];
4276
+ const numericEntries = [];
4277
+ for (const q of rawQuantities) {
4278
+ if ("quantity" in q && q.quantity.type === "fixed" && q.quantity.value.type === "text") {
4279
+ textEntries.push(q);
4280
+ } else {
4281
+ numericEntries.push(q);
4282
+ }
4283
+ }
4284
+ if (numericEntries.length > 1) {
4285
+ const ratioMap = buildEquivalenceRatioMap(
4286
+ getEquivalentUnitsLists(...numericEntries)
4287
+ );
4288
+ if (Object.keys(ratioMap).length > 0) {
4289
+ this.equivalenceRatios.set(name, ratioMap);
2633
4290
  }
2634
4291
  }
4292
+ const resultQuantities = [];
4293
+ for (const t2 of textEntries) {
4294
+ resultQuantities.push(toPlainUnit(t2));
4295
+ }
4296
+ if (numericEntries.length > 0) {
4297
+ resultQuantities.push(
4298
+ ...flattenPlainUnitGroup(
4299
+ addEquivalentsAndSimplify(numericEntries, this.unitSystem)
4300
+ )
4301
+ );
4302
+ }
4303
+ this.ingredients.push({
4304
+ name,
4305
+ quantities: resultQuantities
4306
+ });
2635
4307
  }
4308
+ this.applyPantrySubtraction();
4309
+ }
4310
+ /**
4311
+ * Subtracts pantry item quantities from calculated ingredient quantities
4312
+ * and updates the resultingPantry to reflect consumed stock.
4313
+ */
4314
+ applyPantrySubtraction() {
4315
+ if (!this.pantry) {
4316
+ this.resultingPantry = void 0;
4317
+ return;
4318
+ }
4319
+ const clonedPantry = new Pantry();
4320
+ clonedPantry.items = deepClone(this.pantry.items);
4321
+ if (this.categoryConfig) {
4322
+ clonedPantry.setCategoryConfig(this.categoryConfig);
4323
+ }
4324
+ for (const ingredient of this.ingredients) {
4325
+ if (!ingredient.quantities || ingredient.quantities.length === 0)
4326
+ continue;
4327
+ const pantryItem = clonedPantry.findItem(ingredient.name);
4328
+ if (!pantryItem || !pantryItem.quantity) continue;
4329
+ let pantryExtended = {
4330
+ quantity: pantryItem.quantity,
4331
+ ...pantryItem.unit && { unit: { name: pantryItem.unit } }
4332
+ };
4333
+ for (let i2 = 0; i2 < ingredient.quantities.length; i2++) {
4334
+ const entry = ingredient.quantities[i2];
4335
+ const leaves = "and" in entry ? entry.and : [entry];
4336
+ for (const leaf of leaves) {
4337
+ const ingredientExtended = toExtendedUnit(leaf);
4338
+ const leafHasUnit = leaf.unit !== void 0 && leaf.unit !== "";
4339
+ const pantryHasUnit = pantryExtended.unit !== void 0 && pantryExtended.unit.name !== "";
4340
+ const ratioMap = this.equivalenceRatios.get(ingredient.name);
4341
+ const unitMismatch = leafHasUnit !== pantryHasUnit && ratioMap !== void 0;
4342
+ const leafDef = normalizeUnit(leaf.unit);
4343
+ const pantryDef = normalizeUnit(pantryExtended.unit?.name);
4344
+ if (unitMismatch) {
4345
+ const leafUnit = leaf.unit ?? NO_UNIT;
4346
+ const pantryUnit = pantryExtended.unit?.name ?? NO_UNIT;
4347
+ const ratioFromPantry = ratioMap[normalizeUnit(leafUnit)?.name ?? leafUnit]?.[normalizeUnit(pantryUnit)?.name ?? pantryUnit];
4348
+ if (ratioFromPantry !== void 0) {
4349
+ const pantryValue = getAverageValue(pantryExtended.quantity);
4350
+ const leafValue = getAverageValue(ingredientExtended.quantity);
4351
+ if (typeof pantryValue === "number" && typeof leafValue === "number") {
4352
+ const pantryInLeafUnits = pantryValue * ratioFromPantry;
4353
+ const subtracted = Math.min(pantryInLeafUnits, leafValue);
4354
+ const remainingLeafValue = Math.max(
4355
+ leafValue - pantryInLeafUnits,
4356
+ 0
4357
+ );
4358
+ leaf.quantity = {
4359
+ type: "fixed",
4360
+ value: { type: "decimal", decimal: remainingLeafValue }
4361
+ };
4362
+ const consumedInPantryUnits = subtracted / ratioFromPantry;
4363
+ const remainingPantryValue = Math.max(
4364
+ pantryValue - consumedInPantryUnits,
4365
+ 0
4366
+ );
4367
+ pantryExtended = {
4368
+ quantity: {
4369
+ type: "fixed",
4370
+ value: {
4371
+ type: "decimal",
4372
+ decimal: remainingPantryValue
4373
+ }
4374
+ },
4375
+ ...pantryExtended.unit && { unit: pantryExtended.unit }
4376
+ };
4377
+ continue;
4378
+ }
4379
+ } else {
4380
+ continue;
4381
+ }
4382
+ } else if (leafDef && pantryDef && areUnitsConvertible(leafDef, pantryDef) || (leaf.unit ?? "").toLowerCase() === (pantryExtended.unit?.name ?? "").toLowerCase()) {
4383
+ const remaining = subtractQuantities(
4384
+ ingredientExtended,
4385
+ pantryExtended,
4386
+ { clampToZero: true }
4387
+ );
4388
+ const consumed = subtractQuantities(
4389
+ pantryExtended,
4390
+ ingredientExtended,
4391
+ { clampToZero: true }
4392
+ );
4393
+ pantryExtended = consumed;
4394
+ const updated = toPlainUnit(remaining);
4395
+ leaf.quantity = updated.quantity;
4396
+ leaf.unit = updated.unit;
4397
+ } else if (ratioMap) {
4398
+ const canonicalLeaf = normalizeUnit(leaf.unit)?.name ?? leaf.unit;
4399
+ const leafValue = getAverageValue(ingredientExtended.quantity);
4400
+ const pantryValue = getAverageValue(pantryExtended.quantity);
4401
+ if (typeof leafValue === "number" && typeof pantryValue === "number" && pantryDef) {
4402
+ for (const [equivUnit, ratios] of Object.entries(ratioMap)) {
4403
+ const ratio = ratios[canonicalLeaf];
4404
+ if (ratio === void 0) continue;
4405
+ const equivDef = normalizeUnit(equivUnit);
4406
+ if (!equivDef || !areUnitsConvertible(equivDef, pantryDef))
4407
+ continue;
4408
+ const pantryInEquiv = pantryValue * getToBase(pantryDef) / getToBase(equivDef);
4409
+ const pantryInLeafUnits = pantryInEquiv / ratio;
4410
+ const subtracted = Math.min(pantryInLeafUnits, leafValue);
4411
+ const remainingLeafValue = Math.max(
4412
+ leafValue - pantryInLeafUnits,
4413
+ 0
4414
+ );
4415
+ leaf.quantity = {
4416
+ type: "fixed",
4417
+ value: { type: "decimal", decimal: remainingLeafValue }
4418
+ };
4419
+ const consumedInEquiv = subtracted * ratio;
4420
+ const consumedInPantryUnits = consumedInEquiv * getToBase(equivDef) / getToBase(pantryDef);
4421
+ const remainingPantryValue = Math.max(
4422
+ pantryValue - consumedInPantryUnits,
4423
+ 0
4424
+ );
4425
+ pantryExtended = {
4426
+ quantity: {
4427
+ type: "fixed",
4428
+ value: {
4429
+ type: "decimal",
4430
+ decimal: remainingPantryValue
4431
+ }
4432
+ },
4433
+ ...pantryExtended.unit && { unit: pantryExtended.unit }
4434
+ };
4435
+ break;
4436
+ }
4437
+ }
4438
+ }
4439
+ }
4440
+ if ("and" in entry) {
4441
+ const nonZero = entry.and.filter(
4442
+ (leaf) => leaf.quantity.type !== "fixed" || leaf.quantity.value.type !== "decimal" || leaf.quantity.value.decimal !== 0
4443
+ );
4444
+ entry.and.length = 0;
4445
+ entry.and.push(...nonZero);
4446
+ const ratioMap = this.equivalenceRatios.get(ingredient.name);
4447
+ if (entry.equivalents && ratioMap) {
4448
+ const equivUnits = entry.equivalents.map((e2) => e2.unit);
4449
+ entry.equivalents = recomputeEquivalents(
4450
+ entry.and,
4451
+ ratioMap,
4452
+ equivUnits
4453
+ );
4454
+ }
4455
+ if (entry.and.length === 1) {
4456
+ const single = entry.and[0];
4457
+ ingredient.quantities[i2] = {
4458
+ quantity: single.quantity,
4459
+ ...single.unit && { unit: single.unit },
4460
+ ...entry.equivalents && { equivalents: entry.equivalents }
4461
+ };
4462
+ }
4463
+ } else if ("equivalents" in entry && entry.equivalents) {
4464
+ const ratioMap = this.equivalenceRatios.get(ingredient.name);
4465
+ if (ratioMap) {
4466
+ const equivUnits = entry.equivalents.map(
4467
+ (e2) => e2.unit
4468
+ // equivalents always have units
4469
+ );
4470
+ const recomputed = recomputeEquivalents(
4471
+ [entry],
4472
+ ratioMap,
4473
+ equivUnits
4474
+ );
4475
+ entry.equivalents = recomputed;
4476
+ }
4477
+ }
4478
+ }
4479
+ ingredient.quantities = ingredient.quantities.filter((entry) => {
4480
+ if ("and" in entry) return entry.and.length > 0;
4481
+ return !(entry.quantity.type === "fixed" && entry.quantity.value.type === "decimal" && entry.quantity.value.decimal === 0);
4482
+ });
4483
+ if (ingredient.quantities.length === 0) {
4484
+ ingredient.quantities = void 0;
4485
+ }
4486
+ pantryItem.quantity = pantryExtended.quantity;
4487
+ if (pantryExtended.unit) {
4488
+ pantryItem.unit = pantryExtended.unit.name;
4489
+ }
4490
+ }
4491
+ this.resultingPantry = clonedPantry;
2636
4492
  }
2637
4493
  /**
2638
4494
  * Adds a recipe to the shopping list, then automatically
2639
4495
  * recalculates the quantities and recategorize the ingredients.
2640
4496
  * @param recipe - The recipe to add.
2641
4497
  * @param options - Options for adding the recipe.
4498
+ * @throws Error if the recipe has alternatives without corresponding choices.
2642
4499
  */
2643
- add_recipe(recipe, options = {}) {
4500
+ addRecipe(recipe, options = {}) {
4501
+ const errorMessage = this.getUnresolvedAlternativesError(
4502
+ recipe,
4503
+ options.choices
4504
+ );
4505
+ if (errorMessage) {
4506
+ throw new Error(errorMessage);
4507
+ }
2644
4508
  if (!options.scaling) {
2645
4509
  this.recipes.push({
2646
4510
  recipe,
@@ -2662,32 +4526,102 @@ var ShoppingList = class {
2662
4526
  });
2663
4527
  }
2664
4528
  }
2665
- this.calculate_ingredients();
4529
+ this.calculateIngredients();
2666
4530
  this.categorize();
2667
4531
  }
4532
+ /**
4533
+ * Checks if a recipe has unresolved alternatives (alternatives without provided choices).
4534
+ * @param recipe - The recipe to check.
4535
+ * @param choices - The choices provided for the recipe.
4536
+ * @returns An error message if there are unresolved alternatives, undefined otherwise.
4537
+ */
4538
+ getUnresolvedAlternativesError(recipe, choices) {
4539
+ const missingItems = [];
4540
+ const missingGroups = [];
4541
+ for (const itemId of recipe.choices.ingredientItems.keys()) {
4542
+ if (!choices?.ingredientItems?.has(itemId)) {
4543
+ missingItems.push(itemId);
4544
+ }
4545
+ }
4546
+ for (const groupId of recipe.choices.ingredientGroups.keys()) {
4547
+ if (!choices?.ingredientGroups?.has(groupId)) {
4548
+ missingGroups.push(groupId);
4549
+ }
4550
+ }
4551
+ if (missingItems.length === 0 && missingGroups.length === 0) {
4552
+ return void 0;
4553
+ }
4554
+ const parts = [];
4555
+ if (missingItems.length > 0) {
4556
+ parts.push(
4557
+ `ingredientItems: [${missingItems.map((i2) => `'${i2}'`).join(", ")}]`
4558
+ );
4559
+ }
4560
+ if (missingGroups.length > 0) {
4561
+ parts.push(
4562
+ `ingredientGroups: [${missingGroups.map((g) => `'${g}'`).join(", ")}]`
4563
+ );
4564
+ }
4565
+ return `Recipe has unresolved alternatives. Missing choices for: ${parts.join(", ")}`;
4566
+ }
2668
4567
  /**
2669
4568
  * Removes a recipe from the shopping list, then automatically
2670
- * recalculates the quantities and recategorize the ingredients.s
4569
+ * recalculates the quantities and recategorize the ingredients.
2671
4570
  * @param index - The index of the recipe to remove.
2672
4571
  */
2673
- remove_recipe(index) {
4572
+ removeRecipe(index) {
2674
4573
  if (index < 0 || index >= this.recipes.length) {
2675
4574
  throw new Error("Index out of bounds");
2676
4575
  }
2677
4576
  this.recipes.splice(index, 1);
2678
- this.calculate_ingredients();
4577
+ this.calculateIngredients();
2679
4578
  this.categorize();
2680
4579
  }
4580
+ /**
4581
+ * Adds a pantry to the shopping list. On-hand pantry quantities will be
4582
+ * subtracted from recipe ingredient needs on each recalculation.
4583
+ * @param pantry - A Pantry instance or a TOML string to parse.
4584
+ * @param options - Options for pantry parsing (only used when providing a TOML string).
4585
+ */
4586
+ addPantry(pantry, options) {
4587
+ if (typeof pantry === "string") {
4588
+ this.pantry = new Pantry(pantry, options);
4589
+ } else if (pantry instanceof Pantry) {
4590
+ this.pantry = pantry;
4591
+ } else {
4592
+ throw new Error(
4593
+ "Invalid pantry: expected a Pantry instance or TOML string"
4594
+ );
4595
+ }
4596
+ if (this.categoryConfig) {
4597
+ this.pantry.setCategoryConfig(this.categoryConfig);
4598
+ }
4599
+ this.calculateIngredients();
4600
+ this.categorize();
4601
+ }
4602
+ /**
4603
+ * Returns the resulting pantry with quantities updated to reflect
4604
+ * what was consumed by the shopping list's recipes.
4605
+ * Returns undefined if no pantry was added.
4606
+ * @returns The resulting Pantry, or undefined.
4607
+ */
4608
+ getPantry() {
4609
+ return this.resultingPantry;
4610
+ }
2681
4611
  /**
2682
4612
  * Sets the category configuration for the shopping list
2683
4613
  * and automatically categorize current ingredients from the list.
4614
+ * Also propagates the configuration to the pantry if one is set.
2684
4615
  * @param config - The category configuration to parse.
2685
4616
  */
2686
- set_category_config(config) {
4617
+ setCategoryConfig(config) {
2687
4618
  if (typeof config === "string")
2688
- this.category_config = new CategoryConfig(config);
2689
- else if (config instanceof CategoryConfig) this.category_config = config;
4619
+ this.categoryConfig = new CategoryConfig(config);
4620
+ else if (config instanceof CategoryConfig) this.categoryConfig = config;
2690
4621
  else throw new Error("Invalid category configuration");
4622
+ if (this.pantry) {
4623
+ this.pantry.setCategoryConfig(this.categoryConfig);
4624
+ }
2691
4625
  this.categorize();
2692
4626
  }
2693
4627
  /**
@@ -2695,17 +4629,17 @@ var ShoppingList = class {
2695
4629
  * Will use the category config if any, otherwise all ingredients will be placed in the "other" category
2696
4630
  */
2697
4631
  categorize() {
2698
- if (!this.category_config) {
4632
+ if (!this.categoryConfig) {
2699
4633
  this.categories = { other: this.ingredients };
2700
4634
  return;
2701
4635
  }
2702
4636
  const categories = { other: [] };
2703
- for (const category of this.category_config.categories) {
4637
+ for (const category of this.categoryConfig.categories) {
2704
4638
  categories[category.name] = [];
2705
4639
  }
2706
4640
  for (const ingredient of this.ingredients) {
2707
4641
  let found = false;
2708
- for (const category of this.category_config.categories) {
4642
+ for (const category of this.categoryConfig.categories) {
2709
4643
  for (const categoryIngredient of category.ingredients) {
2710
4644
  if (categoryIngredient.aliases.includes(ingredient.name)) {
2711
4645
  categories[category.name].push(ingredient);
@@ -2769,7 +4703,6 @@ var ShoppingCart = class {
2769
4703
  setProductCatalog(catalog) {
2770
4704
  this.productCatalog = catalog;
2771
4705
  }
2772
- // TODO: harmonize recipe name to use underscores
2773
4706
  /**
2774
4707
  * Sets the shopping list to build the cart from.
2775
4708
  * To use if a shopping list was not provided at the creation of the instance
@@ -2833,8 +4766,27 @@ var ShoppingCart = class {
2833
4766
  getOptimumMatch(ingredient, options) {
2834
4767
  if (options.length === 0)
2835
4768
  throw new NoProductMatchError(ingredient.name, "noProduct");
2836
- if (!ingredient.quantityTotal)
4769
+ if (!ingredient.quantities || ingredient.quantities.length === 0)
2837
4770
  throw new NoProductMatchError(ingredient.name, "noQuantity");
4771
+ const allPlainEntries = [];
4772
+ for (const q of ingredient.quantities) {
4773
+ if ("and" in q) {
4774
+ allPlainEntries.push({ and: q.and });
4775
+ } else {
4776
+ const entry = {
4777
+ quantity: q.quantity,
4778
+ ...q.unit && { unit: q.unit },
4779
+ ...q.equivalents && { equivalents: q.equivalents }
4780
+ };
4781
+ allPlainEntries.push(entry);
4782
+ }
4783
+ }
4784
+ let quantityTotal;
4785
+ if (allPlainEntries.length === 1) {
4786
+ quantityTotal = allPlainEntries[0];
4787
+ } else {
4788
+ quantityTotal = { and: allPlainEntries };
4789
+ }
2838
4790
  const normalizedOptions = options.map(
2839
4791
  (option) => ({
2840
4792
  ...option,
@@ -2850,10 +4802,10 @@ var ShoppingCart = class {
2850
4802
  })
2851
4803
  })
2852
4804
  );
2853
- const normalizedQuantityTotal = normalizeAllUnits(ingredient.quantityTotal);
4805
+ const normalizedQuantityTotal = normalizeAllUnits(quantityTotal);
2854
4806
  function getOptimumMatchForQuantityParts(normalizedQuantities, normalizedOptions2, selection = []) {
2855
4807
  if (isAndGroup(normalizedQuantities)) {
2856
- for (const q of normalizedQuantities.entries) {
4808
+ for (const q of normalizedQuantities.and) {
2857
4809
  const result = getOptimumMatchForQuantityParts(
2858
4810
  q,
2859
4811
  normalizedOptions2,
@@ -2862,7 +4814,7 @@ var ShoppingCart = class {
2862
4814
  selection.push(...result);
2863
4815
  }
2864
4816
  } else {
2865
- const alternativeUnitsOfQuantity = isOrGroup(normalizedQuantities) ? normalizedQuantities.entries : [normalizedQuantities];
4817
+ const alternativeUnitsOfQuantity = isOrGroup(normalizedQuantities) ? normalizedQuantities.or : [normalizedQuantities];
2866
4818
  const solutions = [];
2867
4819
  const errors = /* @__PURE__ */ new Set();
2868
4820
  for (const alternative of alternativeUnitsOfQuantity) {
@@ -2877,12 +4829,12 @@ var ShoppingCart = class {
2877
4829
  alternative.quantity = scaledQuantity;
2878
4830
  const matchOptions = normalizedOptions2.filter(
2879
4831
  (option) => option.sizes.some(
2880
- (s) => areUnitsCompatible(alternative.unit, s.unit)
4832
+ (s) => areUnitsGroupable(alternative.unit, s.unit)
2881
4833
  )
2882
4834
  );
2883
4835
  if (matchOptions.length > 0) {
2884
4836
  const findCompatibleSize = (option) => option.sizes.find(
2885
- (s) => areUnitsCompatible(alternative.unit, s.unit)
4837
+ (s) => areUnitsGroupable(alternative.unit, s.unit)
2886
4838
  );
2887
4839
  if (matchOptions.length == 1) {
2888
4840
  const matchedOption = matchOptions[0];
@@ -2982,17 +4934,178 @@ var ShoppingCart = class {
2982
4934
  return this.summary;
2983
4935
  }
2984
4936
  };
4937
+
4938
+ // src/utils/render_helpers.ts
4939
+ var VULGAR_FRACTIONS = {
4940
+ "1/2": "\xBD",
4941
+ "1/3": "\u2153",
4942
+ "2/3": "\u2154",
4943
+ "1/4": "\xBC",
4944
+ "3/4": "\xBE",
4945
+ "1/8": "\u215B",
4946
+ "3/8": "\u215C",
4947
+ "5/8": "\u215D",
4948
+ "7/8": "\u215E"
4949
+ };
4950
+ function renderFractionAsVulgar(num, den) {
4951
+ const wholePart = Math.floor(num / den);
4952
+ const remainder = num % den;
4953
+ if (remainder === 0) {
4954
+ return String(wholePart);
4955
+ }
4956
+ const fractionKey = `${remainder}/${den}`;
4957
+ const vulgar = VULGAR_FRACTIONS[fractionKey];
4958
+ if (wholePart > 0) {
4959
+ return vulgar ? `${wholePart}${vulgar}` : `${wholePart} ${remainder}/${den}`;
4960
+ }
4961
+ return vulgar ?? `${num}/${den}`;
4962
+ }
4963
+ function formatNumericValue(value, useVulgar = true) {
4964
+ if (value.type === "decimal") {
4965
+ return String(value.decimal);
4966
+ }
4967
+ if (useVulgar) {
4968
+ return renderFractionAsVulgar(value.num, value.den);
4969
+ }
4970
+ return `${value.num}/${value.den}`;
4971
+ }
4972
+ function formatSingleValue(value) {
4973
+ if (value.type === "text") {
4974
+ return value.text;
4975
+ }
4976
+ return formatNumericValue(value);
4977
+ }
4978
+ function formatQuantity(quantity) {
4979
+ if (quantity.type === "fixed") {
4980
+ return formatSingleValue(quantity.value);
4981
+ }
4982
+ const minStr = formatNumericValue(quantity.min);
4983
+ const maxStr = formatNumericValue(quantity.max);
4984
+ return `${minStr}-${maxStr}`;
4985
+ }
4986
+ function formatUnit(unit) {
4987
+ if (!unit) return "";
4988
+ if (typeof unit === "string") return unit;
4989
+ return unit.name;
4990
+ }
4991
+ function formatQuantityWithUnit(quantity, unit) {
4992
+ if (!quantity) return "";
4993
+ const qty = formatQuantity(quantity);
4994
+ const unitStr = formatUnit(unit);
4995
+ return unitStr ? `${qty} ${unitStr}` : qty;
4996
+ }
4997
+ function formatExtendedQuantity(item) {
4998
+ return formatQuantityWithUnit(item.quantity, item.unit);
4999
+ }
5000
+ function formatItemQuantity(itemQuantity, separator = " | ") {
5001
+ const parts = [];
5002
+ parts.push(formatExtendedQuantity(itemQuantity));
5003
+ if (itemQuantity.equivalents) {
5004
+ for (const eq of itemQuantity.equivalents) {
5005
+ parts.push(formatExtendedQuantity(eq));
5006
+ }
5007
+ }
5008
+ return parts.join(separator);
5009
+ }
5010
+ function isGroupedItem(item) {
5011
+ return item.group !== void 0;
5012
+ }
5013
+ function isAlternativeSelected(recipe, choices, item, alternativeIndex) {
5014
+ if (item.group) {
5015
+ const selectedIndex2 = choices?.ingredientGroups?.get(item.group);
5016
+ const groupSubgroups = recipe.choices.ingredientGroups.get(item.group);
5017
+ if (groupSubgroups && selectedIndex2 !== void 0 && selectedIndex2 < groupSubgroups.length) {
5018
+ const selectedSubgroup = groupSubgroups[selectedIndex2];
5019
+ return selectedSubgroup?.some((alt) => alt.itemId === item.id);
5020
+ }
5021
+ return false;
5022
+ }
5023
+ const selectedIndex = choices?.ingredientItems?.get(item.id);
5024
+ return alternativeIndex === selectedIndex;
5025
+ }
5026
+ function isSectionActive(section, variant) {
5027
+ if (!section.variants) return true;
5028
+ const isDefault = variant === void 0 || variant === "*";
5029
+ if (isDefault) {
5030
+ return section.variants.includes("*");
5031
+ }
5032
+ return section.variants.includes(variant);
5033
+ }
5034
+ function isStepActive(step, variant) {
5035
+ if (!step.variants) return true;
5036
+ const isDefault = variant === void 0 || variant === "*";
5037
+ if (isDefault) {
5038
+ return step.variants.includes("*");
5039
+ }
5040
+ return step.variants.includes(variant);
5041
+ }
5042
+ function getEffectiveChoices(recipe, variant) {
5043
+ const choices = { variant };
5044
+ if (variant === void 0 || variant === "*") return choices;
5045
+ const variantLower = variant.toLowerCase();
5046
+ for (const [itemId, alternatives] of recipe.choices.ingredientItems) {
5047
+ const matchIdx = alternatives.findIndex(
5048
+ (alt) => alt.note && alt.note.toLowerCase().includes(variantLower)
5049
+ );
5050
+ if (matchIdx >= 0) {
5051
+ if (!choices.ingredientItems) choices.ingredientItems = /* @__PURE__ */ new Map();
5052
+ choices.ingredientItems.set(itemId, matchIdx);
5053
+ }
5054
+ }
5055
+ for (const [groupId, subgroups] of recipe.choices.ingredientGroups) {
5056
+ const matchIdx = subgroups.findIndex(
5057
+ (sg) => sg.some(
5058
+ (alt) => alt.note && alt.note.toLowerCase().includes(variantLower)
5059
+ )
5060
+ );
5061
+ if (matchIdx >= 0) {
5062
+ if (!choices.ingredientGroups) choices.ingredientGroups = /* @__PURE__ */ new Map();
5063
+ choices.ingredientGroups.set(groupId, matchIdx);
5064
+ }
5065
+ }
5066
+ return choices;
5067
+ }
2985
5068
  export {
2986
5069
  CategoryConfig,
2987
5070
  NoProductCatalogForCartError,
2988
5071
  NoShoppingListForCartError,
5072
+ Pantry,
2989
5073
  ProductCatalog,
2990
5074
  Recipe,
2991
5075
  Section,
2992
5076
  ShoppingCart,
2993
- ShoppingList
5077
+ ShoppingList,
5078
+ convertQuantityToSystem,
5079
+ formatExtendedQuantity,
5080
+ formatItemQuantity,
5081
+ formatNumericValue,
5082
+ formatQuantity,
5083
+ formatQuantityWithUnit,
5084
+ formatSingleValue,
5085
+ formatUnit,
5086
+ getEffectiveChoices,
5087
+ hasAlternatives,
5088
+ isAlternativeSelected,
5089
+ isAndGroup,
5090
+ isGroupedItem,
5091
+ isSectionActive,
5092
+ isSimpleGroup,
5093
+ isStepActive,
5094
+ renderFractionAsVulgar
2994
5095
  };
2995
5096
  /* v8 ignore else -- @preserve */
5097
+ /* v8 ignore next -- @preserve: defensive fallback for ambiguous units without toBaseBySystem */
5098
+ /* v8 ignore start -- @preserve: defensive fallback that shouldn't happen with valid inputs */
5099
+ // v8 ignore else -- @preserve
5100
+ // v8 ignore if -- @preserve
2996
5101
  /* v8 ignore else -- expliciting error type -- @preserve */
5102
+ /* v8 ignore next 4 -- @preserve: defensive guard; regex always matches */
5103
+ // v8 ignore if -- @preserve: defensive type guard
2997
5104
  /* v8 ignore if -- @preserve */
5105
+ // v8 ignore next -- @preserve
5106
+ // v8 ignore else -- @preserve: text quantities never reach the equivalence path
5107
+ // v8 ignore else --@preserve: defensive type guard
5108
+ // v8 ignore else -- @preserve: detection if
5109
+ /* v8 ignore else -- @preserve: only act when there are matches */
5110
+ /* v8 ignore else -- @preserve: initialization pattern */
2998
5111
  //# sourceMappingURL=index.js.map