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