@tmlmt/cooklang-parser 3.0.0-alpha.9 → 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.cjs +2520 -420
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +868 -129
- package/dist/index.d.ts +868 -129
- package/dist/index.js +2502 -419
- package/dist/index.js.map +1 -1
- package/package.json +17 -17
package/dist/index.js
CHANGED
|
@@ -63,7 +63,7 @@ var CategoryConfig = class {
|
|
|
63
63
|
}
|
|
64
64
|
};
|
|
65
65
|
|
|
66
|
-
// src/classes/
|
|
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 (
|
|
366
|
+
// Mass (US/UK - identical in both systems)
|
|
315
367
|
{
|
|
316
368
|
name: "oz",
|
|
317
369
|
type: "mass",
|
|
318
|
-
system: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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 (
|
|
457
|
+
// Volume (Ambiguous: US/UK only)
|
|
373
458
|
{
|
|
374
459
|
name: "fl-oz",
|
|
375
460
|
type: "volume",
|
|
376
|
-
system: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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
|
-
|
|
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:
|
|
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,6 +927,20 @@ 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) {
|
|
@@ -619,11 +950,14 @@ function isOrGroup(x) {
|
|
|
619
950
|
return isGroup(x) && "or" in x;
|
|
620
951
|
}
|
|
621
952
|
function isAndGroup(x) {
|
|
622
|
-
return
|
|
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,23 +969,11 @@ 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 (isAndGroup(q)) {
|
|
642
|
-
return { and: q.and.map(extendAllUnits) };
|
|
643
|
-
} else if (isOrGroup(q)) {
|
|
644
|
-
return { or: q.or.map(extendAllUnits) };
|
|
645
|
-
} else {
|
|
646
|
-
const newQ = {
|
|
647
|
-
quantity: q.quantity
|
|
648
|
-
};
|
|
649
|
-
if (q.unit) {
|
|
650
|
-
newQ.unit = { name: q.unit };
|
|
651
|
-
}
|
|
652
|
-
return newQ;
|
|
653
|
-
}
|
|
654
|
-
}
|
|
655
977
|
function normalizeAllUnits(q) {
|
|
656
978
|
if (isAndGroup(q)) {
|
|
657
979
|
return { and: q.and.map(normalizeAllUnits) };
|
|
@@ -673,11 +995,6 @@ function normalizeAllUnits(q) {
|
|
|
673
995
|
return newQ;
|
|
674
996
|
}
|
|
675
997
|
}
|
|
676
|
-
var convertQuantityValue = (value, def, targetDef) => {
|
|
677
|
-
if (def.name === targetDef.name) return value;
|
|
678
|
-
const factor = def.toBase / targetDef.toBase;
|
|
679
|
-
return multiplyQuantityValue(value, factor);
|
|
680
|
-
};
|
|
681
998
|
function getDefaultQuantityValue() {
|
|
682
999
|
return { type: "fixed", value: { type: "decimal", decimal: 0 } };
|
|
683
1000
|
}
|
|
@@ -704,7 +1021,7 @@ function addQuantityValues(v1, v2) {
|
|
|
704
1021
|
);
|
|
705
1022
|
return { type: "range", min: newMin, max: newMax };
|
|
706
1023
|
}
|
|
707
|
-
function addQuantities(q1, q2) {
|
|
1024
|
+
function addQuantities(q1, q2, system) {
|
|
708
1025
|
const v1 = q1.quantity;
|
|
709
1026
|
const v2 = q2.quantity;
|
|
710
1027
|
if (v1.type === "fixed" && v1.value.type === "text" || v2.type === "fixed" && v2.value.type === "text") {
|
|
@@ -722,35 +1039,129 @@ function addQuantities(q1, q2) {
|
|
|
722
1039
|
if ((q2.unit?.name === "" || q2.unit === void 0) && q1.unit !== void 0) {
|
|
723
1040
|
return addQuantityValuesAndSetUnit(v1, v2, q1.unit);
|
|
724
1041
|
}
|
|
725
|
-
if (!q1.unit && !q2.unit
|
|
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
|
+
}
|
|
726
1052
|
return addQuantityValuesAndSetUnit(v1, v2, q1.unit);
|
|
727
1053
|
}
|
|
728
1054
|
if (unit1Def && unit2Def) {
|
|
729
|
-
if (unit1Def
|
|
1055
|
+
if (!areUnitsConvertible(unit1Def, unit2Def)) {
|
|
730
1056
|
throw new IncompatibleUnitsError(
|
|
731
1057
|
`${unit1Def.type} (${q1.unit?.name})`,
|
|
732
1058
|
`${unit2Def.type} (${q2.unit?.name})`
|
|
733
1059
|
);
|
|
734
1060
|
}
|
|
735
|
-
let
|
|
736
|
-
if (
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
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
|
+
}
|
|
743
1074
|
}
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
1075
|
+
return addAndFindBestUnit(v1, v2, unit1Def, unit2Def, effectiveSystem, [
|
|
1076
|
+
unit1Def,
|
|
1077
|
+
unit2Def
|
|
1078
|
+
]);
|
|
748
1079
|
}
|
|
749
1080
|
throw new IncompatibleUnitsError(
|
|
750
1081
|
q1.unit?.name,
|
|
751
1082
|
q2.unit?.name
|
|
752
1083
|
);
|
|
753
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
|
+
}
|
|
754
1165
|
function toPlainUnit(quantity) {
|
|
755
1166
|
if (isQuantity(quantity))
|
|
756
1167
|
return quantity.unit ? { ...quantity, unit: quantity.unit.name } : quantity;
|
|
@@ -827,36 +1238,126 @@ var flattenPlainUnitGroup = (summed) => {
|
|
|
827
1238
|
}
|
|
828
1239
|
} else if (isAndGroup(summed)) {
|
|
829
1240
|
const andEntries = [];
|
|
1241
|
+
const standaloneEntries = [];
|
|
830
1242
|
const equivalentsList = [];
|
|
831
1243
|
for (const entry of summed.and) {
|
|
832
1244
|
if (isOrGroup(entry)) {
|
|
833
1245
|
const orEntries = entry.or;
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
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;
|
|
1256
|
+
andEntries.push({
|
|
1257
|
+
quantity: primary.quantity,
|
|
1258
|
+
...primary.unit && { unit: primary.unit }
|
|
1259
|
+
});
|
|
1260
|
+
}
|
|
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 }
|
|
843
1273
|
});
|
|
844
1274
|
}
|
|
845
1275
|
}
|
|
846
1276
|
if (equivalentsList.length === 0) {
|
|
847
|
-
return andEntries;
|
|
1277
|
+
return [...andEntries, ...standaloneEntries];
|
|
848
1278
|
}
|
|
849
|
-
const result =
|
|
1279
|
+
const result = [];
|
|
1280
|
+
result.push({
|
|
850
1281
|
and: andEntries,
|
|
851
1282
|
equivalents: equivalentsList
|
|
852
|
-
};
|
|
853
|
-
|
|
1283
|
+
});
|
|
1284
|
+
result.push(...standaloneEntries);
|
|
1285
|
+
return result;
|
|
854
1286
|
} else {
|
|
855
1287
|
return [
|
|
856
1288
|
{ quantity: summed.quantity, ...summed.unit && { unit: summed.unit } }
|
|
857
1289
|
];
|
|
858
1290
|
}
|
|
859
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
|
+
}
|
|
860
1361
|
|
|
861
1362
|
// src/utils/parser_helpers.ts
|
|
862
1363
|
function flushPendingNote(section, noteItems) {
|
|
@@ -866,9 +1367,12 @@ function flushPendingNote(section, noteItems) {
|
|
|
866
1367
|
}
|
|
867
1368
|
return noteItems;
|
|
868
1369
|
}
|
|
869
|
-
function flushPendingItems(section, items) {
|
|
1370
|
+
function flushPendingItems(section, items, stepVariants, stepOptional) {
|
|
870
1371
|
if (items.length > 0) {
|
|
871
|
-
|
|
1372
|
+
const step = { type: "step", items: [...items] };
|
|
1373
|
+
if (stepVariants) step.variants = stepVariants;
|
|
1374
|
+
if (stepOptional) step.optional = true;
|
|
1375
|
+
section.content.push(step);
|
|
872
1376
|
items.length = 0;
|
|
873
1377
|
return true;
|
|
874
1378
|
}
|
|
@@ -987,7 +1491,7 @@ function stringifyFixedValue(quantity) {
|
|
|
987
1491
|
return String(quantity.value.decimal);
|
|
988
1492
|
else return quantity.value.text;
|
|
989
1493
|
}
|
|
990
|
-
function
|
|
1494
|
+
function parseQuantityValue(input_str) {
|
|
991
1495
|
const clean_str = String(input_str).trim();
|
|
992
1496
|
if (rangeRegex.test(clean_str)) {
|
|
993
1497
|
const range_parts = clean_str.split("-");
|
|
@@ -997,19 +1501,253 @@ function parseQuantityInput(input_str) {
|
|
|
997
1501
|
}
|
|
998
1502
|
return { type: "fixed", value: parseFixedValue(clean_str) };
|
|
999
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
|
+
}
|
|
1000
1669
|
function parseSimpleMetaVar(content, varName) {
|
|
1001
1670
|
const varMatch = content.match(
|
|
1002
1671
|
new RegExp(`^${varName}:\\s*(.*(?:\\r?\\n\\s+.*)*)+`, "m")
|
|
1003
1672
|
);
|
|
1004
1673
|
return varMatch ? varMatch[1]?.trim().replace(/\s*\r?\n\s+/g, " ") : void 0;
|
|
1005
1674
|
}
|
|
1006
|
-
function
|
|
1007
|
-
const
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
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;
|
|
1011
1749
|
}
|
|
1012
|
-
return
|
|
1750
|
+
return void 0;
|
|
1013
1751
|
}
|
|
1014
1752
|
function parseListMetaVar(content, varName) {
|
|
1015
1753
|
const listMatch = content.match(
|
|
@@ -1025,6 +1763,115 @@ function parseListMetaVar(content, varName) {
|
|
|
1025
1763
|
return listMatch[2].split("\n").filter((line) => line.trim() !== "").map((line) => line.replace(/^\s*-\s*/, "").trim());
|
|
1026
1764
|
}
|
|
1027
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
|
+
}
|
|
1028
1875
|
function extractMetadata(content) {
|
|
1029
1876
|
const metadata = {};
|
|
1030
1877
|
let servings = void 0;
|
|
@@ -1032,20 +1879,49 @@ function extractMetadata(content) {
|
|
|
1032
1879
|
if (!metadataContent) {
|
|
1033
1880
|
return { metadata };
|
|
1034
1881
|
}
|
|
1035
|
-
|
|
1882
|
+
const handledKeys = /* @__PURE__ */ new Set([
|
|
1883
|
+
// Simple string fields
|
|
1036
1884
|
"title",
|
|
1037
|
-
"source",
|
|
1038
|
-
"source.name",
|
|
1039
|
-
"source.url",
|
|
1040
1885
|
"author",
|
|
1041
|
-
"
|
|
1042
|
-
"
|
|
1043
|
-
"
|
|
1886
|
+
"locale",
|
|
1887
|
+
"introduction",
|
|
1888
|
+
"description",
|
|
1889
|
+
"course",
|
|
1890
|
+
"category",
|
|
1891
|
+
"diet",
|
|
1892
|
+
"cuisine",
|
|
1893
|
+
"difficulty",
|
|
1894
|
+
// Source fields
|
|
1895
|
+
"source",
|
|
1896
|
+
"source.name",
|
|
1897
|
+
"source.url",
|
|
1898
|
+
"source.author",
|
|
1899
|
+
// Time fields
|
|
1900
|
+
"prep time",
|
|
1901
|
+
"time.prep",
|
|
1044
1902
|
"cook time",
|
|
1045
1903
|
"time.cook",
|
|
1046
1904
|
"time required",
|
|
1047
1905
|
"time",
|
|
1048
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",
|
|
1049
1925
|
"locale",
|
|
1050
1926
|
"introduction",
|
|
1051
1927
|
"description",
|
|
@@ -1053,25 +1929,97 @@ function extractMetadata(content) {
|
|
|
1053
1929
|
"category",
|
|
1054
1930
|
"diet",
|
|
1055
1931
|
"cuisine",
|
|
1056
|
-
"difficulty"
|
|
1057
|
-
"image",
|
|
1058
|
-
"picture"
|
|
1932
|
+
"difficulty"
|
|
1059
1933
|
]) {
|
|
1934
|
+
if (metaVar === "description" || metaVar === "introduction") {
|
|
1935
|
+
const blockValue = parseBlockScalarMetaVar(metadataContent, metaVar);
|
|
1936
|
+
if (blockValue) {
|
|
1937
|
+
metadata[metaVar] = blockValue;
|
|
1938
|
+
continue;
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1060
1941
|
const stringMetaValue = parseSimpleMetaVar(metadataContent, metaVar);
|
|
1061
1942
|
if (stringMetaValue) metadata[metaVar] = stringMetaValue;
|
|
1062
1943
|
}
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
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;
|
|
1068
2008
|
}
|
|
1069
2009
|
}
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
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
|
+
}
|
|
1073
2021
|
}
|
|
1074
|
-
return { metadata, servings };
|
|
2022
|
+
return { metadata, servings, unitSystem };
|
|
1075
2023
|
}
|
|
1076
2024
|
function isPositiveIntegerString(str) {
|
|
1077
2025
|
return /^\d+$/.test(str);
|
|
@@ -1085,10 +2033,230 @@ function unionOfSets(s1, s2) {
|
|
|
1085
2033
|
}
|
|
1086
2034
|
function getAlternativeSignature(alternatives) {
|
|
1087
2035
|
if (!alternatives || alternatives.length === 0) return null;
|
|
1088
|
-
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(",");
|
|
1089
2037
|
}
|
|
1090
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
|
+
|
|
1091
2258
|
// src/classes/product_catalog.ts
|
|
2259
|
+
import TOML2 from "smol-toml";
|
|
1092
2260
|
var ProductCatalog = class {
|
|
1093
2261
|
constructor(tomlContent) {
|
|
1094
2262
|
__publicField(this, "products", []);
|
|
@@ -1100,7 +2268,7 @@ var ProductCatalog = class {
|
|
|
1100
2268
|
* @returns A parsed list of `ProductOption`.
|
|
1101
2269
|
*/
|
|
1102
2270
|
parse(tomlContent) {
|
|
1103
|
-
const catalogRaw =
|
|
2271
|
+
const catalogRaw = TOML2.parse(tomlContent);
|
|
1104
2272
|
this.products = [];
|
|
1105
2273
|
if (!this.isValidTomlContent(catalogRaw)) {
|
|
1106
2274
|
throw new InvalidProductCatalogFormat();
|
|
@@ -1117,7 +2285,7 @@ var ProductCatalog = class {
|
|
|
1117
2285
|
const sizeStrings = Array.isArray(size) ? size : [size];
|
|
1118
2286
|
const sizes = sizeStrings.map((sizeStr) => {
|
|
1119
2287
|
const sizeAndUnitRaw = sizeStr.split("%");
|
|
1120
|
-
const sizeParsed =
|
|
2288
|
+
const sizeParsed = parseQuantityValue(
|
|
1121
2289
|
sizeAndUnitRaw[0]
|
|
1122
2290
|
);
|
|
1123
2291
|
const productSize = { size: sizeParsed };
|
|
@@ -1173,7 +2341,7 @@ var ProductCatalog = class {
|
|
|
1173
2341
|
size: sizeStrings.length === 1 ? sizeStrings[0] : sizeStrings
|
|
1174
2342
|
};
|
|
1175
2343
|
}
|
|
1176
|
-
return
|
|
2344
|
+
return TOML2.stringify(grouped);
|
|
1177
2345
|
}
|
|
1178
2346
|
/**
|
|
1179
2347
|
* Adds a product to the catalog.
|
|
@@ -1230,8 +2398,10 @@ var Section = class {
|
|
|
1230
2398
|
/**
|
|
1231
2399
|
* Creates an instance of Section.
|
|
1232
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.
|
|
1233
2403
|
*/
|
|
1234
|
-
constructor(name = "") {
|
|
2404
|
+
constructor(name = "", variants, optional) {
|
|
1235
2405
|
/**
|
|
1236
2406
|
* The name of the section. Can be an empty string for the default (first) section.
|
|
1237
2407
|
* @defaultValue `""`
|
|
@@ -1239,7 +2409,13 @@ var Section = class {
|
|
|
1239
2409
|
__publicField(this, "name");
|
|
1240
2410
|
/** An array of steps and notes that make up the content of the section. */
|
|
1241
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");
|
|
1242
2416
|
this.name = name;
|
|
2417
|
+
if (variants) this.variants = variants;
|
|
2418
|
+
if (optional) this.optional = true;
|
|
1243
2419
|
}
|
|
1244
2420
|
/**
|
|
1245
2421
|
* Checks if the section is blank (has no name and no content).
|
|
@@ -1252,46 +2428,16 @@ var Section = class {
|
|
|
1252
2428
|
};
|
|
1253
2429
|
|
|
1254
2430
|
// src/quantities/alternatives.ts
|
|
1255
|
-
import
|
|
1256
|
-
|
|
1257
|
-
// src/units/conversion.ts
|
|
1258
|
-
import Big2 from "big.js";
|
|
1259
|
-
function getUnitRatio(q1, q2) {
|
|
1260
|
-
const q1Value = getAverageValue(q1.quantity);
|
|
1261
|
-
const q2Value = getAverageValue(q2.quantity);
|
|
1262
|
-
const factor = "toBase" in q1.unit && "toBase" in q2.unit ? q1.unit.toBase / q2.unit.toBase : 1;
|
|
1263
|
-
if (typeof q1Value !== "number" || typeof q2Value !== "number") {
|
|
1264
|
-
throw Error(
|
|
1265
|
-
"One of both values is not a number, so a ratio cannot be computed"
|
|
1266
|
-
);
|
|
1267
|
-
}
|
|
1268
|
-
return Big2(q1Value).times(factor).div(q2Value);
|
|
1269
|
-
}
|
|
1270
|
-
function getBaseUnitRatio(q, qRef) {
|
|
1271
|
-
if ("toBase" in q.unit && "toBase" in qRef.unit) {
|
|
1272
|
-
return q.unit.toBase / qRef.unit.toBase;
|
|
1273
|
-
} else {
|
|
1274
|
-
return 1;
|
|
1275
|
-
}
|
|
1276
|
-
}
|
|
2431
|
+
import Big4 from "big.js";
|
|
1277
2432
|
|
|
1278
2433
|
// src/units/lookup.ts
|
|
1279
|
-
function areUnitsCompatible(u1, u2) {
|
|
1280
|
-
if (u1.name === u2.name) {
|
|
1281
|
-
return true;
|
|
1282
|
-
}
|
|
1283
|
-
if (u1.type !== "other" && u1.type === u2.type && u1.system === u2.system) {
|
|
1284
|
-
return true;
|
|
1285
|
-
}
|
|
1286
|
-
return false;
|
|
1287
|
-
}
|
|
1288
2434
|
function findListWithCompatibleQuantity(list, quantity) {
|
|
1289
2435
|
const quantityWithUnitDef = {
|
|
1290
2436
|
...quantity,
|
|
1291
2437
|
unit: resolveUnit(quantity.unit?.name)
|
|
1292
2438
|
};
|
|
1293
2439
|
return list.find(
|
|
1294
|
-
(l) => l.some((lq) =>
|
|
2440
|
+
(l) => l.some((lq) => areUnitsGroupable(lq.unit, quantityWithUnitDef.unit))
|
|
1295
2441
|
);
|
|
1296
2442
|
}
|
|
1297
2443
|
function findCompatibleQuantityWithinList(list, quantity) {
|
|
@@ -1305,10 +2451,14 @@ function findCompatibleQuantityWithinList(list, quantity) {
|
|
|
1305
2451
|
}
|
|
1306
2452
|
|
|
1307
2453
|
// src/utils/general.ts
|
|
2454
|
+
import Big3 from "big.js";
|
|
1308
2455
|
var legacyDeepClone = (v) => {
|
|
1309
2456
|
if (v === null || typeof v !== "object") {
|
|
1310
2457
|
return v;
|
|
1311
2458
|
}
|
|
2459
|
+
if (v instanceof Big3) {
|
|
2460
|
+
return new Big3(v);
|
|
2461
|
+
}
|
|
1312
2462
|
if (v instanceof Map) {
|
|
1313
2463
|
return new Map(
|
|
1314
2464
|
Array.from(v.entries()).map(([k, val]) => [
|
|
@@ -1318,7 +2468,9 @@ var legacyDeepClone = (v) => {
|
|
|
1318
2468
|
);
|
|
1319
2469
|
}
|
|
1320
2470
|
if (v instanceof Set) {
|
|
1321
|
-
return new Set(
|
|
2471
|
+
return new Set(
|
|
2472
|
+
Array.from(v).map((val) => legacyDeepClone(val))
|
|
2473
|
+
);
|
|
1322
2474
|
}
|
|
1323
2475
|
if (v instanceof Date) {
|
|
1324
2476
|
return new Date(v.getTime());
|
|
@@ -1332,7 +2484,7 @@ var legacyDeepClone = (v) => {
|
|
|
1332
2484
|
}
|
|
1333
2485
|
return cloned;
|
|
1334
2486
|
};
|
|
1335
|
-
var deepClone = (v) =>
|
|
2487
|
+
var deepClone = (v) => legacyDeepClone(v);
|
|
1336
2488
|
|
|
1337
2489
|
// src/quantities/alternatives.ts
|
|
1338
2490
|
function getEquivalentUnitsLists(...quantities) {
|
|
@@ -1340,7 +2492,6 @@ function getEquivalentUnitsLists(...quantities) {
|
|
|
1340
2492
|
const OrGroups = quantitiesCopy.filter(isOrGroup).filter((q) => q.or.length > 1);
|
|
1341
2493
|
const unitLists = [];
|
|
1342
2494
|
const normalizeOrGroup = (og) => ({
|
|
1343
|
-
...og,
|
|
1344
2495
|
or: og.or.map((q) => ({
|
|
1345
2496
|
...q,
|
|
1346
2497
|
unit: resolveUnit(q.unit?.name, q.unit?.integerProtected)
|
|
@@ -1348,11 +2499,9 @@ function getEquivalentUnitsLists(...quantities) {
|
|
|
1348
2499
|
});
|
|
1349
2500
|
function findLinkIndexForUnits(lists, unitsToCheck) {
|
|
1350
2501
|
return lists.findIndex((l) => {
|
|
1351
|
-
const
|
|
2502
|
+
const listItems = l.map((q) => resolveUnit(q.unit?.name));
|
|
1352
2503
|
return unitsToCheck.some(
|
|
1353
|
-
(u) =>
|
|
1354
|
-
(lu) => lu.name === u?.name || lu.system === u?.system && lu.type === u?.type && lu.type !== "other"
|
|
1355
|
-
)
|
|
2504
|
+
(u) => u && listItems.some((lu) => areUnitsGroupable(lu, u))
|
|
1356
2505
|
);
|
|
1357
2506
|
});
|
|
1358
2507
|
}
|
|
@@ -1364,16 +2513,18 @@ function getEquivalentUnitsLists(...quantities) {
|
|
|
1364
2513
|
unit: resolveUnit(v.unit?.name, v.unit?.integerProtected)
|
|
1365
2514
|
};
|
|
1366
2515
|
const commonQuantity = og.or.find(
|
|
1367
|
-
(q) => isQuantity(q) &&
|
|
2516
|
+
(q) => isQuantity(q) && areUnitsGroupable(q.unit, normalizedV.unit)
|
|
1368
2517
|
);
|
|
1369
2518
|
if (commonQuantity) {
|
|
1370
2519
|
acc.push(normalizedV);
|
|
1371
|
-
|
|
2520
|
+
if (!unitRatio) {
|
|
2521
|
+
unitRatio = getUnitRatio(normalizedV, commonQuantity);
|
|
2522
|
+
}
|
|
1372
2523
|
}
|
|
1373
2524
|
return acc;
|
|
1374
2525
|
}, []);
|
|
1375
2526
|
for (const newQ of og.or) {
|
|
1376
|
-
if (commonUnitList.some((q) =>
|
|
2527
|
+
if (commonUnitList.some((q) => areUnitsGroupable(q.unit, newQ.unit))) {
|
|
1377
2528
|
continue;
|
|
1378
2529
|
} else {
|
|
1379
2530
|
const scaledQuantity = multiplyQuantityValue(newQ.quantity, unitRatio);
|
|
@@ -1490,7 +2641,7 @@ function reduceOrsToFirstEquivalent(unitList, quantities) {
|
|
|
1490
2641
|
return reduceToQuantity(qListModified[0]);
|
|
1491
2642
|
});
|
|
1492
2643
|
}
|
|
1493
|
-
function addQuantitiesOrGroups(
|
|
2644
|
+
function addQuantitiesOrGroups(quantities, system) {
|
|
1494
2645
|
if (quantities.length === 0)
|
|
1495
2646
|
return {
|
|
1496
2647
|
sum: {
|
|
@@ -1520,7 +2671,7 @@ function addQuantitiesOrGroups(...quantities) {
|
|
|
1520
2671
|
unit: resolveUnit(nextQ.unit?.name)
|
|
1521
2672
|
});
|
|
1522
2673
|
} else {
|
|
1523
|
-
const sumQ = addQuantities(existingQ, nextQ);
|
|
2674
|
+
const sumQ = addQuantities(existingQ, nextQ, system);
|
|
1524
2675
|
existingQ.quantity = sumQ.quantity;
|
|
1525
2676
|
existingQ.unit = resolveUnit(sumQ.unit?.name);
|
|
1526
2677
|
}
|
|
@@ -1530,7 +2681,7 @@ function addQuantitiesOrGroups(...quantities) {
|
|
|
1530
2681
|
}
|
|
1531
2682
|
return { sum: { and: sum }, unitsLists };
|
|
1532
2683
|
}
|
|
1533
|
-
function regroupQuantitiesAndExpandEquivalents(sum, unitsLists) {
|
|
2684
|
+
function regroupQuantitiesAndExpandEquivalents(sum, unitsLists, system) {
|
|
1534
2685
|
const sumQuantities = isAndGroup(sum) ? sum.and : [sum];
|
|
1535
2686
|
const result = [];
|
|
1536
2687
|
const processedQuantities = /* @__PURE__ */ new Set();
|
|
@@ -1558,10 +2709,20 @@ function regroupQuantitiesAndExpandEquivalents(sum, unitsLists) {
|
|
|
1558
2709
|
}
|
|
1559
2710
|
return main.reduce((acc, v) => {
|
|
1560
2711
|
const mainInList = findCompatibleQuantityWithinList(list, v);
|
|
2712
|
+
const conversionRatio = getBaseUnitRatio(v, mainInList);
|
|
2713
|
+
const valueInOriginalUnit = Big4(getAverageValue(v.quantity)).times(
|
|
2714
|
+
conversionRatio
|
|
2715
|
+
);
|
|
1561
2716
|
const newValue = {
|
|
1562
2717
|
quantity: multiplyQuantityValue(
|
|
1563
|
-
|
|
1564
|
-
|
|
2718
|
+
{
|
|
2719
|
+
type: "fixed",
|
|
2720
|
+
value: {
|
|
2721
|
+
type: "decimal",
|
|
2722
|
+
decimal: valueInOriginalUnit.toNumber()
|
|
2723
|
+
}
|
|
2724
|
+
},
|
|
2725
|
+
Big4(getAverageValue(equiv.quantity)).div(
|
|
1565
2726
|
getAverageValue(mainInList.quantity)
|
|
1566
2727
|
)
|
|
1567
2728
|
)
|
|
@@ -1569,7 +2730,7 @@ function regroupQuantitiesAndExpandEquivalents(sum, unitsLists) {
|
|
|
1569
2730
|
if (equiv.unit && !isNoUnit(equiv.unit)) {
|
|
1570
2731
|
newValue.unit = { name: equiv.unit.name };
|
|
1571
2732
|
}
|
|
1572
|
-
return addQuantities(acc, newValue);
|
|
2733
|
+
return addQuantities(acc, newValue, system);
|
|
1573
2734
|
}, initialValue);
|
|
1574
2735
|
});
|
|
1575
2736
|
if (main.length + equivalents.length > 1) {
|
|
@@ -1586,21 +2747,66 @@ function regroupQuantitiesAndExpandEquivalents(sum, unitsLists) {
|
|
|
1586
2747
|
sumQuantities.filter((q) => !processedQuantities.has(q)).forEach((q) => result.push(deNormalizeQuantity(q)));
|
|
1587
2748
|
return result;
|
|
1588
2749
|
}
|
|
1589
|
-
function addEquivalentsAndSimplify(
|
|
2750
|
+
function addEquivalentsAndSimplify(quantities, system) {
|
|
1590
2751
|
if (quantities.length === 1) {
|
|
1591
2752
|
return toPlainUnit(quantities[0]);
|
|
1592
2753
|
}
|
|
1593
|
-
const { sum, unitsLists } = addQuantitiesOrGroups(
|
|
1594
|
-
const regrouped = regroupQuantitiesAndExpandEquivalents(
|
|
2754
|
+
const { sum, unitsLists } = addQuantitiesOrGroups(quantities, system);
|
|
2755
|
+
const regrouped = regroupQuantitiesAndExpandEquivalents(
|
|
2756
|
+
sum,
|
|
2757
|
+
unitsLists,
|
|
2758
|
+
system
|
|
2759
|
+
);
|
|
1595
2760
|
if (regrouped.length === 1) {
|
|
1596
2761
|
return toPlainUnit(regrouped[0]);
|
|
1597
2762
|
} else {
|
|
1598
2763
|
return { and: regrouped.map(toPlainUnit) };
|
|
1599
2764
|
}
|
|
1600
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
|
+
}
|
|
2804
|
+
}
|
|
2805
|
+
return equivalents.length > 0 ? equivalents : void 0;
|
|
2806
|
+
}
|
|
1601
2807
|
|
|
1602
2808
|
// src/classes/recipe.ts
|
|
1603
|
-
import
|
|
2809
|
+
import Big5 from "big.js";
|
|
1604
2810
|
var _Recipe = class _Recipe {
|
|
1605
2811
|
/**
|
|
1606
2812
|
* Creates a new Recipe instance.
|
|
@@ -1616,7 +2822,8 @@ var _Recipe = class _Recipe {
|
|
|
1616
2822
|
*/
|
|
1617
2823
|
__publicField(this, "choices", {
|
|
1618
2824
|
ingredientItems: /* @__PURE__ */ new Map(),
|
|
1619
|
-
ingredientGroups: /* @__PURE__ */ new Map()
|
|
2825
|
+
ingredientGroups: /* @__PURE__ */ new Map(),
|
|
2826
|
+
variants: []
|
|
1620
2827
|
});
|
|
1621
2828
|
/**
|
|
1622
2829
|
* The parsed recipe ingredients.
|
|
@@ -1647,10 +2854,20 @@ var _Recipe = class _Recipe {
|
|
|
1647
2854
|
*/
|
|
1648
2855
|
__publicField(this, "servings");
|
|
1649
2856
|
_Recipe.itemCounts.set(this, 0);
|
|
2857
|
+
_Recipe.subgroupIndices.set(this, /* @__PURE__ */ new Map());
|
|
1650
2858
|
if (content) {
|
|
1651
2859
|
this.parse(content);
|
|
1652
2860
|
}
|
|
1653
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
|
+
}
|
|
1654
2871
|
/**
|
|
1655
2872
|
* Gets the current item count for this recipe.
|
|
1656
2873
|
*/
|
|
@@ -1673,27 +2890,17 @@ var _Recipe = class _Recipe {
|
|
|
1673
2890
|
*/
|
|
1674
2891
|
_parseArbitraryScalable(regexMatchGroups, intoArray) {
|
|
1675
2892
|
if (!regexMatchGroups || !regexMatchGroups.arbitraryQuantity) return;
|
|
1676
|
-
const
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
const arbitrary = {
|
|
1688
|
-
quantity: value
|
|
1689
|
-
};
|
|
1690
|
-
if (name) arbitrary.name = name;
|
|
1691
|
-
if (unit) arbitrary.unit = unit;
|
|
1692
|
-
intoArray.push({
|
|
1693
|
-
type: "arbitrary",
|
|
1694
|
-
index: this.arbitraries.push(arbitrary) - 1
|
|
1695
|
-
});
|
|
1696
|
-
}
|
|
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
|
+
});
|
|
1697
2904
|
}
|
|
1698
2905
|
/**
|
|
1699
2906
|
* Parses text for arbitrary scalables and returns NoteItem array.
|
|
@@ -1707,13 +2914,13 @@ var _Recipe = class _Recipe {
|
|
|
1707
2914
|
for (const match of text.matchAll(globalRegex)) {
|
|
1708
2915
|
const idx = match.index;
|
|
1709
2916
|
if (idx > cursor) {
|
|
1710
|
-
noteItems.push(
|
|
2917
|
+
noteItems.push(...parseMarkdownSegments(text.slice(cursor, idx)));
|
|
1711
2918
|
}
|
|
1712
2919
|
this._parseArbitraryScalable(match.groups, noteItems);
|
|
1713
2920
|
cursor = idx + match[0].length;
|
|
1714
2921
|
}
|
|
1715
2922
|
if (cursor < text.length) {
|
|
1716
|
-
noteItems.push(
|
|
2923
|
+
noteItems.push(...parseMarkdownSegments(text.slice(cursor)));
|
|
1717
2924
|
}
|
|
1718
2925
|
return noteItems;
|
|
1719
2926
|
}
|
|
@@ -1721,7 +2928,7 @@ var _Recipe = class _Recipe {
|
|
|
1721
2928
|
let quantityMatch = quantityRaw.match(quantityAlternativeRegex);
|
|
1722
2929
|
const quantities = [];
|
|
1723
2930
|
while (quantityMatch?.groups) {
|
|
1724
|
-
const value = quantityMatch.groups.quantity ?
|
|
2931
|
+
const value = quantityMatch.groups.quantity ? parseQuantityValue(quantityMatch.groups.quantity) : void 0;
|
|
1725
2932
|
const unit = quantityMatch.groups.unit;
|
|
1726
2933
|
if (value) {
|
|
1727
2934
|
const newQuantity = { quantity: value };
|
|
@@ -1822,7 +3029,7 @@ var _Recipe = class _Recipe {
|
|
|
1822
3029
|
alternative.note = note;
|
|
1823
3030
|
}
|
|
1824
3031
|
if (itemQuantity) {
|
|
1825
|
-
alternative
|
|
3032
|
+
Object.assign(alternative, itemQuantity);
|
|
1826
3033
|
}
|
|
1827
3034
|
alternatives.push(alternative);
|
|
1828
3035
|
testString = groups.ingredientAlternative || "";
|
|
@@ -1865,6 +3072,7 @@ var _Recipe = class _Recipe {
|
|
|
1865
3072
|
if (!match?.groups) return;
|
|
1866
3073
|
const groups = match.groups;
|
|
1867
3074
|
const groupKey = groups.gIngredientGroupKey;
|
|
3075
|
+
const subgroupKey = groups.gIngredientSubgroupKey;
|
|
1868
3076
|
let name = groups.gmIngredientName || groups.gsIngredientName;
|
|
1869
3077
|
const preparation = groups.gIngredientPreparation;
|
|
1870
3078
|
const modifiers = groups.gIngredientModifiers;
|
|
@@ -1930,9 +3138,14 @@ var _Recipe = class _Recipe {
|
|
|
1930
3138
|
displayName
|
|
1931
3139
|
};
|
|
1932
3140
|
if (itemQuantity) {
|
|
1933
|
-
alternative
|
|
3141
|
+
Object.assign(alternative, itemQuantity);
|
|
3142
|
+
}
|
|
3143
|
+
const note = groups.gIngredientNote?.trim();
|
|
3144
|
+
if (note) {
|
|
3145
|
+
alternative.note = note;
|
|
1934
3146
|
}
|
|
1935
|
-
const
|
|
3147
|
+
const existingSubgroups = this.choices.ingredientGroups.get(groupKey);
|
|
3148
|
+
const existingAlternativesFlat = existingSubgroups?.flat();
|
|
1936
3149
|
function upsertAlternativeToIngredient(ingredients, ingredientIdx, newAlternativeIdx) {
|
|
1937
3150
|
const ingredient = ingredients[ingredientIdx];
|
|
1938
3151
|
if (ingredient) {
|
|
@@ -1943,8 +3156,8 @@ var _Recipe = class _Recipe {
|
|
|
1943
3156
|
}
|
|
1944
3157
|
}
|
|
1945
3158
|
}
|
|
1946
|
-
if (
|
|
1947
|
-
for (const alt of
|
|
3159
|
+
if (existingAlternativesFlat) {
|
|
3160
|
+
for (const alt of existingAlternativesFlat) {
|
|
1948
3161
|
upsertAlternativeToIngredient(this.ingredients, alt.index, idxInList);
|
|
1949
3162
|
upsertAlternativeToIngredient(this.ingredients, idxInList, alt.index);
|
|
1950
3163
|
}
|
|
@@ -1956,14 +3169,35 @@ var _Recipe = class _Recipe {
|
|
|
1956
3169
|
group: groupKey,
|
|
1957
3170
|
alternatives: [alternative]
|
|
1958
3171
|
};
|
|
3172
|
+
if (subgroupKey !== void 0) {
|
|
3173
|
+
newItem.subgroup = subgroupKey;
|
|
3174
|
+
}
|
|
1959
3175
|
items.push(newItem);
|
|
1960
3176
|
const choiceAlternative = deepClone(alternative);
|
|
1961
3177
|
choiceAlternative.itemId = id;
|
|
1962
3178
|
const existingChoice = this.choices.ingredientGroups.get(groupKey);
|
|
3179
|
+
const sgMap = _Recipe.subgroupIndices.get(this);
|
|
1963
3180
|
if (!existingChoice) {
|
|
1964
|
-
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
|
+
}
|
|
1965
3199
|
} else {
|
|
1966
|
-
existingChoice.push(choiceAlternative);
|
|
3200
|
+
existingChoice.push([choiceAlternative]);
|
|
1967
3201
|
}
|
|
1968
3202
|
}
|
|
1969
3203
|
/**
|
|
@@ -1977,7 +3211,7 @@ var _Recipe = class _Recipe {
|
|
|
1977
3211
|
* Quantities are grouped by their alternative signature and summed using addEquivalentsAndSimplify.
|
|
1978
3212
|
* @internal
|
|
1979
3213
|
*/
|
|
1980
|
-
|
|
3214
|
+
_populateIngredientQuantities() {
|
|
1981
3215
|
for (const ing of this.ingredients) {
|
|
1982
3216
|
delete ing.quantities;
|
|
1983
3217
|
delete ing.usedAsPrimary;
|
|
@@ -1998,35 +3232,13 @@ var _Recipe = class _Recipe {
|
|
|
1998
3232
|
}
|
|
1999
3233
|
}
|
|
2000
3234
|
}
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
* When no options are provided, returns all recipe ingredients with quantities
|
|
2006
|
-
* calculated using primary alternatives (same as after parsing).
|
|
2007
|
-
*
|
|
2008
|
-
* @param options - Options for filtering and choice selection:
|
|
2009
|
-
* - `section`: Filter to a specific section (Section object or 0-based index)
|
|
2010
|
-
* - `step`: Filter to a specific step (Step object or 0-based index)
|
|
2011
|
-
* - `choices`: Choices for alternative ingredients (defaults to primary)
|
|
2012
|
-
* @returns Array of Ingredient objects with quantities populated
|
|
2013
|
-
*
|
|
2014
|
-
* @example
|
|
2015
|
-
* ```typescript
|
|
2016
|
-
* // Get all ingredients with primary alternatives
|
|
2017
|
-
* const ingredients = recipe.getIngredientQuantities();
|
|
2018
|
-
*
|
|
2019
|
-
* // Get ingredients for a specific section
|
|
2020
|
-
* const sectionIngredients = recipe.getIngredientQuantities({ section: 0 });
|
|
2021
|
-
*
|
|
2022
|
-
* // Get ingredients with specific choices applied
|
|
2023
|
-
* const withChoices = recipe.getIngredientQuantities({
|
|
2024
|
-
* choices: { ingredientItems: new Map([['ingredient-item-2', 1]]) }
|
|
2025
|
-
* });
|
|
2026
|
-
* ```
|
|
2027
|
-
*/
|
|
2028
|
-
getIngredientQuantities(options) {
|
|
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) {
|
|
2029
3239
|
const { section, step, choices } = options || {};
|
|
3240
|
+
const activeVariant = choices?.variant;
|
|
3241
|
+
const isDefaultVariant = activeVariant === void 0 || activeVariant === "*";
|
|
2030
3242
|
const sectionsToProcess = section !== void 0 ? (() => {
|
|
2031
3243
|
const idx = typeof section === "number" ? section : this.sections.indexOf(section);
|
|
2032
3244
|
return idx >= 0 && idx < this.sections.length ? [this.sections[idx]] : [];
|
|
@@ -2034,80 +3246,155 @@ var _Recipe = class _Recipe {
|
|
|
2034
3246
|
const ingredientGroups = /* @__PURE__ */ new Map();
|
|
2035
3247
|
const selectedIndices = /* @__PURE__ */ new Set();
|
|
2036
3248
|
const referencedIndices = /* @__PURE__ */ new Set();
|
|
3249
|
+
const dynamicOptionalIndices = /* @__PURE__ */ new Set();
|
|
2037
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
|
+
}
|
|
2038
3258
|
const allSteps = currentSection.content.filter(
|
|
2039
3259
|
(item) => item.type === "step"
|
|
2040
3260
|
);
|
|
3261
|
+
const isOptionalSection = currentSection.optional === true;
|
|
2041
3262
|
const stepsToProcess = step === void 0 ? allSteps : typeof step === "number" ? step >= 0 && step < allSteps.length ? [allSteps[step]] : [] : allSteps.includes(step) ? [step] : [];
|
|
2042
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;
|
|
2043
3272
|
for (const item of currentStep.items.filter(
|
|
2044
3273
|
(item2) => item2.type === "ingredient"
|
|
2045
3274
|
)) {
|
|
2046
3275
|
const isGrouped = "group" in item && item.group !== void 0;
|
|
2047
|
-
const
|
|
3276
|
+
const groupSubgroups = isGrouped ? this.choices.ingredientGroups.get(item.group) : void 0;
|
|
2048
3277
|
let selectedAltIndex = 0;
|
|
2049
|
-
let isSelected
|
|
2050
|
-
let hasExplicitChoice
|
|
3278
|
+
let isSelected;
|
|
3279
|
+
let hasExplicitChoice;
|
|
2051
3280
|
if (isGrouped) {
|
|
2052
3281
|
const groupChoice = choices?.ingredientGroups?.get(item.group);
|
|
2053
3282
|
hasExplicitChoice = groupChoice !== void 0;
|
|
2054
|
-
|
|
2055
|
-
|
|
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;
|
|
3307
|
+
}
|
|
2056
3308
|
} else {
|
|
2057
3309
|
const itemChoice = choices?.ingredientItems?.get(item.id);
|
|
2058
3310
|
hasExplicitChoice = itemChoice !== void 0;
|
|
2059
|
-
|
|
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;
|
|
3323
|
+
}
|
|
2060
3324
|
isSelected = true;
|
|
2061
3325
|
}
|
|
2062
3326
|
const alternative = item.alternatives[selectedAltIndex];
|
|
2063
3327
|
if (!alternative || !isSelected) continue;
|
|
2064
3328
|
selectedIndices.add(alternative.index);
|
|
2065
|
-
|
|
2066
|
-
|
|
3329
|
+
if (isOptionalStep) {
|
|
3330
|
+
dynamicOptionalIndices.add(alternative.index);
|
|
3331
|
+
}
|
|
3332
|
+
const allAltsFlat = isGrouped ? groupSubgroups.flat() : item.alternatives;
|
|
3333
|
+
for (const alt of allAltsFlat) {
|
|
2067
3334
|
referencedIndices.add(alt.index);
|
|
2068
3335
|
}
|
|
2069
|
-
if (!alternative.
|
|
3336
|
+
if (!alternative.quantity) continue;
|
|
2070
3337
|
const baseQty = {
|
|
2071
|
-
quantity: alternative.
|
|
2072
|
-
...alternative.
|
|
2073
|
-
unit: alternative.
|
|
3338
|
+
quantity: alternative.quantity,
|
|
3339
|
+
...alternative.unit && {
|
|
3340
|
+
unit: alternative.unit
|
|
2074
3341
|
}
|
|
2075
3342
|
};
|
|
2076
|
-
const quantityEntry = alternative.
|
|
3343
|
+
const quantityEntry = alternative.equivalents?.length ? { or: [baseQty, ...alternative.equivalents] } : baseQty;
|
|
2077
3344
|
let alternativeRefs;
|
|
2078
|
-
if (!hasExplicitChoice &&
|
|
2079
|
-
|
|
2080
|
-
(
|
|
2081
|
-
)
|
|
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
|
|
3353
|
+
};
|
|
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];
|
|
3367
|
+
}
|
|
3368
|
+
return ref;
|
|
3369
|
+
})
|
|
3370
|
+
);
|
|
3371
|
+
} else if (!hasExplicitChoice && !isGrouped && allAltsFlat.length > 1) {
|
|
3372
|
+
alternativeRefs = allAltsFlat.filter((alt) => alt.index !== alternative.index).map((otherAlt) => {
|
|
2082
3373
|
const ref = { index: otherAlt.index };
|
|
2083
|
-
if (otherAlt.
|
|
3374
|
+
if (otherAlt.quantity) {
|
|
2084
3375
|
const altQty = {
|
|
2085
|
-
quantity: otherAlt.
|
|
2086
|
-
...otherAlt.
|
|
2087
|
-
unit: otherAlt.
|
|
3376
|
+
quantity: otherAlt.quantity,
|
|
3377
|
+
...otherAlt.unit && {
|
|
3378
|
+
unit: otherAlt.unit.name
|
|
2088
3379
|
},
|
|
2089
|
-
...otherAlt.
|
|
2090
|
-
equivalents: otherAlt.
|
|
3380
|
+
...otherAlt.equivalents && {
|
|
3381
|
+
equivalents: otherAlt.equivalents.map(
|
|
2091
3382
|
(eq) => toPlainUnit(eq)
|
|
2092
3383
|
)
|
|
2093
3384
|
}
|
|
2094
3385
|
};
|
|
2095
3386
|
ref.quantities = [altQty];
|
|
2096
3387
|
}
|
|
2097
|
-
return ref;
|
|
3388
|
+
return [ref];
|
|
2098
3389
|
});
|
|
2099
3390
|
}
|
|
2100
3391
|
const altIndices = getAlternativeSignature(alternativeRefs) ?? "";
|
|
2101
3392
|
let signature;
|
|
2102
3393
|
if (isGrouped) {
|
|
2103
|
-
const resolvedUnit = resolveUnit(
|
|
2104
|
-
alternative.itemQuantity.unit?.name
|
|
2105
|
-
);
|
|
3394
|
+
const resolvedUnit = resolveUnit(alternative.unit?.name);
|
|
2106
3395
|
signature = `group:${item.group}|${altIndices}|${resolvedUnit.type}`;
|
|
2107
3396
|
} else if (altIndices) {
|
|
2108
|
-
const resolvedUnit = resolveUnit(
|
|
2109
|
-
alternative.itemQuantity.unit?.name
|
|
2110
|
-
);
|
|
3397
|
+
const resolvedUnit = resolveUnit(alternative.unit?.name);
|
|
2111
3398
|
signature = `${altIndices}|${resolvedUnit.type}}`;
|
|
2112
3399
|
} else {
|
|
2113
3400
|
signature = null;
|
|
@@ -2119,42 +3406,145 @@ var _Recipe = class _Recipe {
|
|
|
2119
3406
|
if (!groupsForIng.has(signature)) {
|
|
2120
3407
|
groupsForIng.set(signature, {
|
|
2121
3408
|
quantities: [],
|
|
2122
|
-
alternativeQuantities: /* @__PURE__ */ new Map()
|
|
3409
|
+
alternativeQuantities: /* @__PURE__ */ new Map(),
|
|
3410
|
+
alternativeSubgroups: []
|
|
2123
3411
|
});
|
|
2124
3412
|
}
|
|
2125
3413
|
const group = groupsForIng.get(signature);
|
|
2126
3414
|
group.quantities.push(quantityEntry);
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
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) {
|
|
3422
|
+
if (!group.alternativeQuantities.has(ref.index)) {
|
|
3423
|
+
group.alternativeQuantities.set(ref.index, []);
|
|
3424
|
+
}
|
|
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);
|
|
3438
|
+
}
|
|
2144
3439
|
}
|
|
2145
3440
|
}
|
|
2146
3441
|
}
|
|
2147
3442
|
}
|
|
2148
3443
|
}
|
|
2149
3444
|
}
|
|
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);
|
|
2150
3475
|
const result = [];
|
|
2151
3476
|
for (let index = 0; index < this.ingredients.length; index++) {
|
|
2152
3477
|
if (!referencedIndices.has(index)) continue;
|
|
2153
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);
|
|
3490
|
+
}
|
|
3491
|
+
}
|
|
3492
|
+
}
|
|
3493
|
+
result.push({
|
|
3494
|
+
name: orig.name,
|
|
3495
|
+
...usedAsPrimary && { usedAsPrimary: true },
|
|
3496
|
+
...flags && { flags },
|
|
3497
|
+
quantities
|
|
3498
|
+
});
|
|
3499
|
+
}
|
|
3500
|
+
return result;
|
|
3501
|
+
}
|
|
3502
|
+
/**
|
|
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();
|
|
3519
|
+
*
|
|
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
|
+
* ```
|
|
3528
|
+
*/
|
|
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
|
+
}
|
|
2154
3544
|
const ing = {
|
|
2155
3545
|
name: orig.name,
|
|
2156
3546
|
...orig.preparation && { preparation: orig.preparation },
|
|
2157
|
-
...
|
|
3547
|
+
...flags && { flags },
|
|
2158
3548
|
...orig.extras && { extras: orig.extras }
|
|
2159
3549
|
};
|
|
2160
3550
|
if (selectedIndices.has(index)) {
|
|
@@ -2163,19 +3553,30 @@ var _Recipe = class _Recipe {
|
|
|
2163
3553
|
if (groupsForIng) {
|
|
2164
3554
|
const quantityGroups = [];
|
|
2165
3555
|
for (const [, group] of groupsForIng) {
|
|
2166
|
-
const summed = addEquivalentsAndSimplify(
|
|
3556
|
+
const summed = addEquivalentsAndSimplify(
|
|
3557
|
+
group.quantities,
|
|
3558
|
+
this.unitSystem
|
|
3559
|
+
);
|
|
2167
3560
|
const flattened = flattenPlainUnitGroup(summed);
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
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
|
+
}
|
|
2179
3580
|
for (const gq of flattened) {
|
|
2180
3581
|
if ("and" in gq) {
|
|
2181
3582
|
quantityGroups.push({
|
|
@@ -2202,23 +3603,82 @@ var _Recipe = class _Recipe {
|
|
|
2202
3603
|
}
|
|
2203
3604
|
return result;
|
|
2204
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
|
+
}
|
|
3636
|
+
}
|
|
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
|
+
}
|
|
3653
|
+
}
|
|
3654
|
+
}
|
|
3655
|
+
return this.cookware.filter(
|
|
3656
|
+
(cw, idx) => cookwareIndices.has(idx) && !cw.flags?.includes("hidden")
|
|
3657
|
+
);
|
|
3658
|
+
}
|
|
2205
3659
|
/**
|
|
2206
3660
|
* Parses a recipe from a string.
|
|
2207
3661
|
* @param content - The recipe content to parse.
|
|
2208
3662
|
*/
|
|
2209
3663
|
parse(content) {
|
|
2210
3664
|
const cleanContent = content.replace(metadataRegex, "").replace(commentRegex, "").replace(blockCommentRegex, "").trim().split(/\r\n?|\n/);
|
|
2211
|
-
const { metadata, servings } = extractMetadata(content);
|
|
3665
|
+
const { metadata, servings, unitSystem } = extractMetadata(content);
|
|
2212
3666
|
this.metadata = metadata;
|
|
2213
3667
|
this.servings = servings;
|
|
3668
|
+
if (unitSystem) _Recipe.unitSystems.set(this, unitSystem);
|
|
2214
3669
|
let blankLineBefore = true;
|
|
2215
3670
|
let section = new Section();
|
|
2216
3671
|
const items = [];
|
|
2217
3672
|
let noteText = "";
|
|
2218
3673
|
let inNote = false;
|
|
3674
|
+
let stepVariants;
|
|
3675
|
+
let stepOptional;
|
|
3676
|
+
const discoveredVariants = /* @__PURE__ */ new Set();
|
|
2219
3677
|
for (const line of cleanContent) {
|
|
2220
3678
|
if (line.trim().length === 0) {
|
|
2221
|
-
flushPendingItems(section, items);
|
|
3679
|
+
flushPendingItems(section, items, stepVariants, stepOptional);
|
|
3680
|
+
stepVariants = void 0;
|
|
3681
|
+
stepOptional = void 0;
|
|
2222
3682
|
flushPendingNote(
|
|
2223
3683
|
section,
|
|
2224
3684
|
noteText ? this._parseNoteText(noteText) : []
|
|
@@ -2229,26 +3689,42 @@ var _Recipe = class _Recipe {
|
|
|
2229
3689
|
continue;
|
|
2230
3690
|
}
|
|
2231
3691
|
if (line.startsWith("=")) {
|
|
2232
|
-
flushPendingItems(section, items);
|
|
3692
|
+
flushPendingItems(section, items, stepVariants, stepOptional);
|
|
3693
|
+
stepVariants = void 0;
|
|
3694
|
+
stepOptional = void 0;
|
|
2233
3695
|
flushPendingNote(
|
|
2234
3696
|
section,
|
|
2235
3697
|
noteText ? this._parseNoteText(noteText) : []
|
|
2236
3698
|
);
|
|
2237
3699
|
noteText = "";
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
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;
|
|
2243
3713
|
}
|
|
2244
|
-
|
|
3714
|
+
sectionName = sectionName.slice(sectionVarMatch[0].length).trim();
|
|
2245
3715
|
}
|
|
3716
|
+
if (!section.isBlank()) {
|
|
3717
|
+
this.sections.push(section);
|
|
3718
|
+
}
|
|
3719
|
+
section = new Section(sectionName, sectionVariants, sectionOptional);
|
|
2246
3720
|
blankLineBefore = true;
|
|
2247
3721
|
inNote = false;
|
|
2248
3722
|
continue;
|
|
2249
3723
|
}
|
|
2250
3724
|
if (blankLineBefore && line.startsWith(">")) {
|
|
2251
|
-
flushPendingItems(section, items);
|
|
3725
|
+
flushPendingItems(section, items, stepVariants, stepOptional);
|
|
3726
|
+
stepVariants = void 0;
|
|
3727
|
+
stepOptional = void 0;
|
|
2252
3728
|
noteText = line.substring(1).trim();
|
|
2253
3729
|
inNote = true;
|
|
2254
3730
|
blankLineBefore = false;
|
|
@@ -2263,11 +3739,31 @@ var _Recipe = class _Recipe {
|
|
|
2263
3739
|
blankLineBefore = false;
|
|
2264
3740
|
continue;
|
|
2265
3741
|
}
|
|
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
|
+
}
|
|
2266
3762
|
let cursor = 0;
|
|
2267
|
-
for (const match of
|
|
3763
|
+
for (const match of currentLine.matchAll(tokensRegex)) {
|
|
2268
3764
|
const idx = match.index;
|
|
2269
3765
|
if (idx > cursor) {
|
|
2270
|
-
items.push(
|
|
3766
|
+
items.push(...parseMarkdownSegments(currentLine.slice(cursor, idx)));
|
|
2271
3767
|
}
|
|
2272
3768
|
const groups = match.groups;
|
|
2273
3769
|
if (groups.mIngredientName || groups.sIngredientName) {
|
|
@@ -2286,7 +3782,7 @@ var _Recipe = class _Recipe {
|
|
|
2286
3782
|
if (modifiers !== void 0 && modifiers.includes("-")) {
|
|
2287
3783
|
flags.push("hidden");
|
|
2288
3784
|
}
|
|
2289
|
-
const quantity = quantityRaw ?
|
|
3785
|
+
const quantity = quantityRaw ? parseQuantityValue(quantityRaw) : void 0;
|
|
2290
3786
|
const newCookware = {
|
|
2291
3787
|
name
|
|
2292
3788
|
};
|
|
@@ -2318,7 +3814,7 @@ var _Recipe = class _Recipe {
|
|
|
2318
3814
|
throw new Error("Timer missing unit");
|
|
2319
3815
|
}
|
|
2320
3816
|
const name = groups.timerName || void 0;
|
|
2321
|
-
const duration =
|
|
3817
|
+
const duration = parseQuantityValue(durationStr);
|
|
2322
3818
|
const timerObj = {
|
|
2323
3819
|
name,
|
|
2324
3820
|
duration,
|
|
@@ -2328,17 +3824,22 @@ var _Recipe = class _Recipe {
|
|
|
2328
3824
|
}
|
|
2329
3825
|
cursor = idx + match[0].length;
|
|
2330
3826
|
}
|
|
2331
|
-
if (cursor <
|
|
2332
|
-
items.push(
|
|
3827
|
+
if (cursor < currentLine.length) {
|
|
3828
|
+
items.push(...parseMarkdownSegments(currentLine.slice(cursor)));
|
|
2333
3829
|
}
|
|
2334
3830
|
blankLineBefore = false;
|
|
2335
3831
|
}
|
|
2336
|
-
flushPendingItems(section, items);
|
|
3832
|
+
flushPendingItems(section, items, stepVariants, stepOptional);
|
|
2337
3833
|
flushPendingNote(section, noteText ? this._parseNoteText(noteText) : []);
|
|
2338
3834
|
if (!section.isBlank()) {
|
|
2339
3835
|
this.sections.push(section);
|
|
2340
3836
|
}
|
|
2341
|
-
this.
|
|
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();
|
|
2342
3843
|
}
|
|
2343
3844
|
/**
|
|
2344
3845
|
* Scales the recipe to a new number of servings. In practice, it calls
|
|
@@ -2353,7 +3854,7 @@ var _Recipe = class _Recipe {
|
|
|
2353
3854
|
if (originalServings === void 0 || originalServings === 0) {
|
|
2354
3855
|
originalServings = 1;
|
|
2355
3856
|
}
|
|
2356
|
-
const factor =
|
|
3857
|
+
const factor = Big5(newServings).div(originalServings);
|
|
2357
3858
|
return this.scaleBy(factor);
|
|
2358
3859
|
}
|
|
2359
3860
|
/**
|
|
@@ -2368,18 +3869,19 @@ var _Recipe = class _Recipe {
|
|
|
2368
3869
|
if (originalServings === void 0 || originalServings === 0) {
|
|
2369
3870
|
originalServings = 1;
|
|
2370
3871
|
}
|
|
3872
|
+
const unitSystem = this.unitSystem;
|
|
2371
3873
|
function scaleAlternativesBy(alternatives, factor2) {
|
|
2372
3874
|
for (const alternative of alternatives) {
|
|
2373
|
-
if (alternative.
|
|
2374
|
-
const scaleFactor = alternative.
|
|
2375
|
-
if (alternative.
|
|
2376
|
-
alternative.
|
|
2377
|
-
alternative.
|
|
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,
|
|
2378
3880
|
scaleFactor
|
|
2379
3881
|
);
|
|
2380
3882
|
}
|
|
2381
|
-
if (alternative.
|
|
2382
|
-
alternative.
|
|
3883
|
+
if (alternative.equivalents) {
|
|
3884
|
+
alternative.equivalents = alternative.equivalents.map(
|
|
2383
3885
|
(altQuantity) => {
|
|
2384
3886
|
if (altQuantity.quantity.type === "fixed" && altQuantity.quantity.value.type === "text") {
|
|
2385
3887
|
return altQuantity;
|
|
@@ -2395,6 +3897,20 @@ var _Recipe = class _Recipe {
|
|
|
2395
3897
|
}
|
|
2396
3898
|
);
|
|
2397
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
|
+
}
|
|
2398
3914
|
}
|
|
2399
3915
|
}
|
|
2400
3916
|
}
|
|
@@ -2409,8 +3925,10 @@ var _Recipe = class _Recipe {
|
|
|
2409
3925
|
}
|
|
2410
3926
|
}
|
|
2411
3927
|
}
|
|
2412
|
-
for (const
|
|
2413
|
-
|
|
3928
|
+
for (const subgroups of newRecipe.choices.ingredientGroups.values()) {
|
|
3929
|
+
for (const subgroup of subgroups) {
|
|
3930
|
+
scaleAlternativesBy(subgroup, factor);
|
|
3931
|
+
}
|
|
2414
3932
|
}
|
|
2415
3933
|
for (const alternatives of newRecipe.choices.ingredientItems.values()) {
|
|
2416
3934
|
scaleAlternativesBy(alternatives, factor);
|
|
@@ -2420,39 +3938,198 @@ var _Recipe = class _Recipe {
|
|
|
2420
3938
|
arbitrary.quantity,
|
|
2421
3939
|
factor
|
|
2422
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;
|
|
2423
3947
|
}
|
|
2424
|
-
newRecipe.
|
|
2425
|
-
newRecipe.servings =
|
|
2426
|
-
|
|
2427
|
-
if (
|
|
2428
|
-
|
|
2429
|
-
String(this.metadata.servings).replace(",", ".")
|
|
2430
|
-
);
|
|
2431
|
-
newRecipe.metadata.servings = String(
|
|
2432
|
-
Big4(servingsValue).times(factor).toNumber()
|
|
2433
|
-
);
|
|
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();
|
|
2434
3953
|
}
|
|
2435
3954
|
}
|
|
2436
3955
|
if (newRecipe.metadata.yield && this.metadata.yield) {
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
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
|
|
2440
3962
|
);
|
|
2441
|
-
|
|
2442
|
-
|
|
3963
|
+
const optimized = applyBestUnit(
|
|
3964
|
+
{ quantity: scaledQuantity, unit: original.unit },
|
|
3965
|
+
unitSystem
|
|
2443
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;
|
|
2444
3974
|
}
|
|
2445
3975
|
}
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
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];
|
|
4015
|
+
}
|
|
4016
|
+
return newPrimary;
|
|
4017
|
+
}
|
|
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
|
|
4048
|
+
);
|
|
4049
|
+
return buildNewPrimary(
|
|
4050
|
+
targetEquiv,
|
|
4051
|
+
oldPrimary,
|
|
4052
|
+
remainingEquivalents,
|
|
4053
|
+
alternative.scalable,
|
|
4054
|
+
targetEquiv.unit?.integerProtected,
|
|
4055
|
+
"swapped"
|
|
2450
4056
|
);
|
|
2451
|
-
|
|
2452
|
-
|
|
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"
|
|
2453
4067
|
);
|
|
2454
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
|
+
}
|
|
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
|
+
}
|
|
2455
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);
|
|
2456
4133
|
return newRecipe;
|
|
2457
4134
|
}
|
|
2458
4135
|
/**
|
|
@@ -2477,7 +4154,11 @@ var _Recipe = class _Recipe {
|
|
|
2477
4154
|
newRecipe.metadata = deepClone(this.metadata);
|
|
2478
4155
|
newRecipe.ingredients = deepClone(this.ingredients);
|
|
2479
4156
|
newRecipe.sections = this.sections.map((section) => {
|
|
2480
|
-
const newSection = new Section(
|
|
4157
|
+
const newSection = new Section(
|
|
4158
|
+
section.name,
|
|
4159
|
+
section.variants,
|
|
4160
|
+
section.optional
|
|
4161
|
+
);
|
|
2481
4162
|
newSection.content = deepClone(section.content);
|
|
2482
4163
|
return newSection;
|
|
2483
4164
|
});
|
|
@@ -2488,21 +4169,30 @@ var _Recipe = class _Recipe {
|
|
|
2488
4169
|
return newRecipe;
|
|
2489
4170
|
}
|
|
2490
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());
|
|
2491
4177
|
/**
|
|
2492
4178
|
* External storage for item count (not a property on instances).
|
|
2493
4179
|
* Used for giving ID numbers to items during parsing.
|
|
2494
4180
|
*/
|
|
2495
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());
|
|
2496
4187
|
var Recipe = _Recipe;
|
|
2497
4188
|
|
|
2498
4189
|
// src/classes/shopping_list.ts
|
|
2499
4190
|
var ShoppingList = class {
|
|
2500
4191
|
/**
|
|
2501
4192
|
* Creates a new ShoppingList instance
|
|
2502
|
-
* @param
|
|
4193
|
+
* @param categoryConfigStr - The category configuration to parse.
|
|
2503
4194
|
*/
|
|
2504
|
-
constructor(
|
|
2505
|
-
// TODO: backport type change
|
|
4195
|
+
constructor(categoryConfigStr) {
|
|
2506
4196
|
/**
|
|
2507
4197
|
* The ingredients in the shopping list.
|
|
2508
4198
|
*/
|
|
@@ -2514,43 +4204,43 @@ var ShoppingList = class {
|
|
|
2514
4204
|
/**
|
|
2515
4205
|
* The category configuration for the shopping list.
|
|
2516
4206
|
*/
|
|
2517
|
-
__publicField(this, "
|
|
4207
|
+
__publicField(this, "categoryConfig");
|
|
2518
4208
|
/**
|
|
2519
4209
|
* The categorized ingredients in the shopping list.
|
|
2520
4210
|
*/
|
|
2521
4211
|
__publicField(this, "categories");
|
|
2522
|
-
|
|
2523
|
-
|
|
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);
|
|
2524
4234
|
}
|
|
2525
4235
|
}
|
|
2526
|
-
|
|
4236
|
+
calculateIngredients() {
|
|
2527
4237
|
this.ingredients = [];
|
|
2528
|
-
const
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
if (!existing.quantityTotal) {
|
|
2534
|
-
existing.quantityTotal = quantityTotal;
|
|
2535
|
-
return;
|
|
2536
|
-
}
|
|
2537
|
-
try {
|
|
2538
|
-
const existingQuantityTotalExtended = extendAllUnits(
|
|
2539
|
-
existing.quantityTotal
|
|
2540
|
-
);
|
|
2541
|
-
const existingQuantities = isAndGroup(existingQuantityTotalExtended) ? existingQuantityTotalExtended.and : [existingQuantityTotalExtended];
|
|
2542
|
-
existing.quantityTotal = addEquivalentsAndSimplify(
|
|
2543
|
-
...existingQuantities,
|
|
2544
|
-
...newQuantities
|
|
2545
|
-
);
|
|
2546
|
-
return;
|
|
2547
|
-
} catch {
|
|
2548
|
-
}
|
|
4238
|
+
const rawQuantitiesMap = /* @__PURE__ */ new Map();
|
|
4239
|
+
const nameOrder = [];
|
|
4240
|
+
const trackName = (name) => {
|
|
4241
|
+
if (!nameOrder.includes(name)) {
|
|
4242
|
+
nameOrder.push(name);
|
|
2549
4243
|
}
|
|
2550
|
-
this.ingredients.push({
|
|
2551
|
-
name,
|
|
2552
|
-
quantityTotal
|
|
2553
|
-
});
|
|
2554
4244
|
};
|
|
2555
4245
|
for (const addedRecipe of this.recipes) {
|
|
2556
4246
|
let scaledRecipe;
|
|
@@ -2560,48 +4250,245 @@ var ShoppingList = class {
|
|
|
2560
4250
|
} else {
|
|
2561
4251
|
scaledRecipe = addedRecipe.recipe.scaleTo(addedRecipe.servings);
|
|
2562
4252
|
}
|
|
2563
|
-
const
|
|
4253
|
+
const rawGroups = scaledRecipe.getRawQuantityGroups({
|
|
2564
4254
|
choices: addedRecipe.choices
|
|
2565
4255
|
});
|
|
2566
|
-
for (const
|
|
2567
|
-
if (
|
|
4256
|
+
for (const group of rawGroups) {
|
|
4257
|
+
if (group.flags?.includes("hidden") || !group.usedAsPrimary) {
|
|
2568
4258
|
continue;
|
|
2569
4259
|
}
|
|
2570
|
-
|
|
2571
|
-
|
|
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);
|
|
2572
4290
|
}
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
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
|
+
});
|
|
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;
|
|
2579
4378
|
}
|
|
2580
4379
|
} else {
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
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
|
+
}
|
|
2587
4437
|
}
|
|
2588
4438
|
}
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
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
|
|
2594
4469
|
);
|
|
2595
|
-
const
|
|
2596
|
-
|
|
4470
|
+
const recomputed = recomputeEquivalents(
|
|
4471
|
+
[entry],
|
|
4472
|
+
ratioMap,
|
|
4473
|
+
equivUnits
|
|
2597
4474
|
);
|
|
2598
|
-
|
|
4475
|
+
entry.equivalents = recomputed;
|
|
2599
4476
|
}
|
|
2600
|
-
} else if (!this.ingredients.some((i2) => i2.name === ingredient.name)) {
|
|
2601
|
-
this.ingredients.push({ name: ingredient.name });
|
|
2602
4477
|
}
|
|
2603
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
|
+
}
|
|
2604
4490
|
}
|
|
4491
|
+
this.resultingPantry = clonedPantry;
|
|
2605
4492
|
}
|
|
2606
4493
|
/**
|
|
2607
4494
|
* Adds a recipe to the shopping list, then automatically
|
|
@@ -2610,7 +4497,7 @@ var ShoppingList = class {
|
|
|
2610
4497
|
* @param options - Options for adding the recipe.
|
|
2611
4498
|
* @throws Error if the recipe has alternatives without corresponding choices.
|
|
2612
4499
|
*/
|
|
2613
|
-
|
|
4500
|
+
addRecipe(recipe, options = {}) {
|
|
2614
4501
|
const errorMessage = this.getUnresolvedAlternativesError(
|
|
2615
4502
|
recipe,
|
|
2616
4503
|
options.choices
|
|
@@ -2639,7 +4526,7 @@ var ShoppingList = class {
|
|
|
2639
4526
|
});
|
|
2640
4527
|
}
|
|
2641
4528
|
}
|
|
2642
|
-
this.
|
|
4529
|
+
this.calculateIngredients();
|
|
2643
4530
|
this.categorize();
|
|
2644
4531
|
}
|
|
2645
4532
|
/**
|
|
@@ -2679,27 +4566,62 @@ var ShoppingList = class {
|
|
|
2679
4566
|
}
|
|
2680
4567
|
/**
|
|
2681
4568
|
* Removes a recipe from the shopping list, then automatically
|
|
2682
|
-
* recalculates the quantities and recategorize the ingredients.
|
|
4569
|
+
* recalculates the quantities and recategorize the ingredients.
|
|
2683
4570
|
* @param index - The index of the recipe to remove.
|
|
2684
4571
|
*/
|
|
2685
|
-
|
|
4572
|
+
removeRecipe(index) {
|
|
2686
4573
|
if (index < 0 || index >= this.recipes.length) {
|
|
2687
4574
|
throw new Error("Index out of bounds");
|
|
2688
4575
|
}
|
|
2689
4576
|
this.recipes.splice(index, 1);
|
|
2690
|
-
this.
|
|
4577
|
+
this.calculateIngredients();
|
|
4578
|
+
this.categorize();
|
|
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();
|
|
2691
4600
|
this.categorize();
|
|
2692
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
|
+
}
|
|
2693
4611
|
/**
|
|
2694
4612
|
* Sets the category configuration for the shopping list
|
|
2695
4613
|
* and automatically categorize current ingredients from the list.
|
|
4614
|
+
* Also propagates the configuration to the pantry if one is set.
|
|
2696
4615
|
* @param config - The category configuration to parse.
|
|
2697
4616
|
*/
|
|
2698
|
-
|
|
4617
|
+
setCategoryConfig(config) {
|
|
2699
4618
|
if (typeof config === "string")
|
|
2700
|
-
this.
|
|
2701
|
-
else if (config instanceof CategoryConfig) this.
|
|
4619
|
+
this.categoryConfig = new CategoryConfig(config);
|
|
4620
|
+
else if (config instanceof CategoryConfig) this.categoryConfig = config;
|
|
2702
4621
|
else throw new Error("Invalid category configuration");
|
|
4622
|
+
if (this.pantry) {
|
|
4623
|
+
this.pantry.setCategoryConfig(this.categoryConfig);
|
|
4624
|
+
}
|
|
2703
4625
|
this.categorize();
|
|
2704
4626
|
}
|
|
2705
4627
|
/**
|
|
@@ -2707,17 +4629,17 @@ var ShoppingList = class {
|
|
|
2707
4629
|
* Will use the category config if any, otherwise all ingredients will be placed in the "other" category
|
|
2708
4630
|
*/
|
|
2709
4631
|
categorize() {
|
|
2710
|
-
if (!this.
|
|
4632
|
+
if (!this.categoryConfig) {
|
|
2711
4633
|
this.categories = { other: this.ingredients };
|
|
2712
4634
|
return;
|
|
2713
4635
|
}
|
|
2714
4636
|
const categories = { other: [] };
|
|
2715
|
-
for (const category of this.
|
|
4637
|
+
for (const category of this.categoryConfig.categories) {
|
|
2716
4638
|
categories[category.name] = [];
|
|
2717
4639
|
}
|
|
2718
4640
|
for (const ingredient of this.ingredients) {
|
|
2719
4641
|
let found = false;
|
|
2720
|
-
for (const category of this.
|
|
4642
|
+
for (const category of this.categoryConfig.categories) {
|
|
2721
4643
|
for (const categoryIngredient of category.ingredients) {
|
|
2722
4644
|
if (categoryIngredient.aliases.includes(ingredient.name)) {
|
|
2723
4645
|
categories[category.name].push(ingredient);
|
|
@@ -2781,7 +4703,6 @@ var ShoppingCart = class {
|
|
|
2781
4703
|
setProductCatalog(catalog) {
|
|
2782
4704
|
this.productCatalog = catalog;
|
|
2783
4705
|
}
|
|
2784
|
-
// TODO: harmonize recipe name to use underscores
|
|
2785
4706
|
/**
|
|
2786
4707
|
* Sets the shopping list to build the cart from.
|
|
2787
4708
|
* To use if a shopping list was not provided at the creation of the instance
|
|
@@ -2845,8 +4766,27 @@ var ShoppingCart = class {
|
|
|
2845
4766
|
getOptimumMatch(ingredient, options) {
|
|
2846
4767
|
if (options.length === 0)
|
|
2847
4768
|
throw new NoProductMatchError(ingredient.name, "noProduct");
|
|
2848
|
-
if (!ingredient.
|
|
4769
|
+
if (!ingredient.quantities || ingredient.quantities.length === 0)
|
|
2849
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
|
+
}
|
|
2850
4790
|
const normalizedOptions = options.map(
|
|
2851
4791
|
(option) => ({
|
|
2852
4792
|
...option,
|
|
@@ -2862,7 +4802,7 @@ var ShoppingCart = class {
|
|
|
2862
4802
|
})
|
|
2863
4803
|
})
|
|
2864
4804
|
);
|
|
2865
|
-
const normalizedQuantityTotal = normalizeAllUnits(
|
|
4805
|
+
const normalizedQuantityTotal = normalizeAllUnits(quantityTotal);
|
|
2866
4806
|
function getOptimumMatchForQuantityParts(normalizedQuantities, normalizedOptions2, selection = []) {
|
|
2867
4807
|
if (isAndGroup(normalizedQuantities)) {
|
|
2868
4808
|
for (const q of normalizedQuantities.and) {
|
|
@@ -2889,12 +4829,12 @@ var ShoppingCart = class {
|
|
|
2889
4829
|
alternative.quantity = scaledQuantity;
|
|
2890
4830
|
const matchOptions = normalizedOptions2.filter(
|
|
2891
4831
|
(option) => option.sizes.some(
|
|
2892
|
-
(s) =>
|
|
4832
|
+
(s) => areUnitsGroupable(alternative.unit, s.unit)
|
|
2893
4833
|
)
|
|
2894
4834
|
);
|
|
2895
4835
|
if (matchOptions.length > 0) {
|
|
2896
4836
|
const findCompatibleSize = (option) => option.sizes.find(
|
|
2897
|
-
(s) =>
|
|
4837
|
+
(s) => areUnitsGroupable(alternative.unit, s.unit)
|
|
2898
4838
|
);
|
|
2899
4839
|
if (matchOptions.length == 1) {
|
|
2900
4840
|
const matchedOption = matchOptions[0];
|
|
@@ -2996,33 +4936,176 @@ var ShoppingCart = class {
|
|
|
2996
4936
|
};
|
|
2997
4937
|
|
|
2998
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
|
+
}
|
|
2999
5013
|
function isAlternativeSelected(recipe, choices, item, alternativeIndex) {
|
|
3000
5014
|
if (item.group) {
|
|
3001
5015
|
const selectedIndex2 = choices?.ingredientGroups?.get(item.group);
|
|
3002
|
-
const
|
|
3003
|
-
if (
|
|
3004
|
-
const
|
|
3005
|
-
return
|
|
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);
|
|
3006
5020
|
}
|
|
3007
5021
|
return false;
|
|
3008
5022
|
}
|
|
3009
5023
|
const selectedIndex = choices?.ingredientItems?.get(item.id);
|
|
3010
5024
|
return alternativeIndex === selectedIndex;
|
|
3011
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
|
+
}
|
|
3012
5068
|
export {
|
|
3013
5069
|
CategoryConfig,
|
|
3014
5070
|
NoProductCatalogForCartError,
|
|
3015
5071
|
NoShoppingListForCartError,
|
|
5072
|
+
Pantry,
|
|
3016
5073
|
ProductCatalog,
|
|
3017
5074
|
Recipe,
|
|
3018
5075
|
Section,
|
|
3019
5076
|
ShoppingCart,
|
|
3020
5077
|
ShoppingList,
|
|
3021
|
-
|
|
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
|
|
3022
5095
|
};
|
|
3023
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 */
|
|
3024
5099
|
// v8 ignore else -- @preserve
|
|
3025
|
-
/* v8 ignore else -- expliciting error type -- @preserve */
|
|
3026
5100
|
// v8 ignore if -- @preserve
|
|
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
|
|
3027
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 */
|
|
3028
5111
|
//# sourceMappingURL=index.js.map
|