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