@tmlmt/cooklang-parser 3.0.0-alpha.14 → 3.0.0-alpha.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -63,7 +63,7 @@ var CategoryConfig = class {
63
63
  }
64
64
  };
65
65
 
66
- // src/classes/product_catalog.ts
66
+ // src/classes/pantry.ts
67
67
  import TOML from "smol-toml";
68
68
 
69
69
  // node_modules/.pnpm/human-regex@2.2.0/node_modules/human-regex/dist/human-regex.esm.js
@@ -272,7 +272,6 @@ var nestedMetaVarRegex = (varName) => new RegExp(
272
272
  "m"
273
273
  );
274
274
  var metadataRegex = d().literal("---").newline().startCaptureGroup().anyCharacter().zeroOrMore().optional().endGroup().newline().literal("---").dotAll().toRegExp();
275
- 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();
276
275
  var nonWordChar = "\\s@#~\\[\\]{(,;:!?";
277
276
  var nonWordCharStrict = "\\s@#~\\[\\]{(,;:!?|";
278
277
  var ingredientWithAlternativeRegex = d().literal("@").startNamedGroup("ingredientModifiers").anyOf("@\\-&?").zeroOrMore().endGroup().optional().startNamedGroup("ingredientRecipeAnchor").literal("./").endGroup().optional().startGroup().startGroup().startNamedGroup("mIngredientName").notAnyOf(nonWordChar).oneOrMore().startGroup().whitespace().oneOrMore().notAnyOf(nonWordChar).oneOrMore().endGroup().oneOrMore().endGroup().positiveLookahead("\\s*(?:\\{[^\\}]*\\}|\\([^)]*\\))").endGroup().or().startNamedGroup("sIngredientName").notAnyOf(nonWordChar).zeroOrMore().notAnyOf("\\." + nonWordChar).endGroup().endGroup().startGroup().literal("{").startNamedGroup("ingredientQuantityModifier").literal("=").exactly(1).endGroup().optional().startNamedGroup("ingredientQuantity").startGroup().notAnyOf("}|%").oneOrMore().endGroup().optional().startGroup().literal("%").notAnyOf("|}").oneOrMore().lazy().endGroup().optional().startGroup().literal("|").notAnyOf("}").oneOrMore().lazy().endGroup().zeroOrMore().endGroup().literal("}").endGroup().optional().startGroup().literal("(").startNamedGroup("ingredientPreparation").notAnyOf(")").oneOrMore().lazy().endGroup().literal(")").endGroup().optional().startGroup().literal("[").startNamedGroup("ingredientNote").notAnyOf("\\]").oneOrMore().lazy().endGroup().literal("]").endGroup().optional().startNamedGroup("ingredientAlternative").startGroup().literal("|").startGroup().anyOf("@\\-&?").zeroOrMore().endGroup().optional().startGroup().literal("./").endGroup().optional().startGroup().startGroup().startGroup().notAnyOf(nonWordChar).oneOrMore().startGroup().whitespace().oneOrMore().notAnyOf(nonWordChar).oneOrMore().endGroup().oneOrMore().endGroup().positiveLookahead("\\s*(?:\\{[^\\}]*\\}|\\([^)]*\\))").endGroup().or().startGroup().notAnyOf(nonWordChar).oneOrMore().endGroup().endGroup().startGroup().literal("{").startGroup().literal("=").exactly(1).endGroup().optional().startGroup().notAnyOf("}%").oneOrMore().endGroup().optional().startGroup().literal("%").startGroup().notAnyOf("}").oneOrMore().lazy().endGroup().endGroup().optional().literal("}").endGroup().optional().startGroup().literal("(").startGroup().notAnyOf(")").oneOrMore().lazy().endGroup().literal(")").endGroup().optional().startGroup().literal("[").startGroup().notAnyOf("\\]").oneOrMore().lazy().endGroup().literal("]").endGroup().optional().endGroup().zeroOrMore().endGroup().toRegExp();
@@ -293,6 +292,13 @@ var tokensRegex = new RegExp(
293
292
  ].map((r2) => r2.source).join("|"),
294
293
  "gu"
295
294
  );
295
+ var servingsPrefixPart = (varName) => d().startAnchor().literal(varName).literal(":").anyOf(" ").zeroOrMore().startNamedGroup("servingsPrefix").nonWhitespace().startGroup().anyCharacter().zeroOrMore().lazy().nonWhitespace().endGroup().optional().endGroup().optional().anyOf(" ").zeroOrMore().toRegExp();
296
+ var servingsSuffixPart = d().anyOf(" ").zeroOrMore().startNamedGroup("servingsSuffix").nonWhitespace().startGroup().anyCharacter().zeroOrMore().nonWhitespace().endGroup().optional().endGroup().optional().anyOf(" ").zeroOrMore().endAnchor().toRegExp();
297
+ var scalingSimpleMetaValueRegex = (varName) => d().startAnchor().literal(varName).literal(":").anyOf("\\t ").zeroOrMore().startCaptureGroup().startCaptureGroup().notAnyOf(",\\n").oneOrMore().endGroup().startGroup().literal(",").anyOf("\\t ").zeroOrMore().startCaptureGroup().anyCharacter().oneOrMore().endGroup().optional().endGroup().optional().endGroup().endAnchor().multiline().toRegExp();
298
+ var scalingMetaValueWithUnitRegex = (varName) => new RegExp(
299
+ servingsPrefixPart(varName).source + arbitraryScalableRegex.source + servingsSuffixPart.source,
300
+ "m"
301
+ );
296
302
  var commentRegex = d().literal("--").anyCharacter().zeroOrMore().global().toRegExp();
297
303
  var blockCommentRegex = d().literal("[-").anyCharacter().zeroOrMore().lazy().literal("-]").whitespace().zeroOrMore().global().toRegExp();
298
304
  var shoppingListRegex = d().literal("[").startNamedGroup("name").anyCharacter().oneOrMore().endGroup().literal("]").newline().startNamedGroup("items").anyCharacter().zeroOrMore().lazy().endGroup().startGroup().newline().newline().or().endAnchor().endGroup().global().toRegExp();
@@ -960,21 +966,6 @@ function hasAlternatives(entry) {
960
966
  }
961
967
 
962
968
  // src/quantities/mutations.ts
963
- function extendAllUnits(q) {
964
- if (isAndGroup(q)) {
965
- return { and: q.and.map(extendAllUnits) };
966
- } else if (isOrGroup(q)) {
967
- return { or: q.or.map(extendAllUnits) };
968
- } else {
969
- const newQ = {
970
- quantity: q.quantity
971
- };
972
- if (q.unit) {
973
- newQ.unit = { name: q.unit };
974
- }
975
- return newQ;
976
- }
977
- }
978
969
  function normalizeAllUnits(q) {
979
970
  if (isAndGroup(q)) {
980
971
  return { and: q.and.map(normalizeAllUnits) };
@@ -1239,30 +1230,51 @@ var flattenPlainUnitGroup = (summed) => {
1239
1230
  }
1240
1231
  } else if (isAndGroup(summed)) {
1241
1232
  const andEntries = [];
1233
+ const standaloneEntries = [];
1242
1234
  const equivalentsList = [];
1243
1235
  for (const entry of summed.and) {
1244
1236
  if (isOrGroup(entry)) {
1245
1237
  const orEntries = entry.or;
1246
- andEntries.push({
1247
- quantity: orEntries[0].quantity,
1248
- ...orEntries[0].unit && { unit: orEntries[0].unit }
1249
- });
1250
- equivalentsList.push(...orEntries.slice(1));
1251
- } else if (isQuantity(entry)) {
1252
- andEntries.push({
1253
- quantity: entry.quantity,
1254
- ...entry.unit && { unit: entry.unit }
1238
+ const firstEntry = orEntries[0];
1239
+ if (isAndGroup(firstEntry)) {
1240
+ for (const nestedEntry of firstEntry.and) {
1241
+ andEntries.push({
1242
+ quantity: nestedEntry.quantity,
1243
+ ...nestedEntry.unit && { unit: nestedEntry.unit }
1244
+ });
1245
+ }
1246
+ } else {
1247
+ const primary = firstEntry;
1248
+ andEntries.push({
1249
+ quantity: primary.quantity,
1250
+ ...primary.unit && { unit: primary.unit }
1251
+ });
1252
+ }
1253
+ const equivEntries = orEntries.slice(1).filter((e2) => isQuantity(e2));
1254
+ equivalentsList.push(
1255
+ ...equivEntries.map((e2) => ({
1256
+ quantity: e2.quantity,
1257
+ ...e2.unit && { unit: e2.unit }
1258
+ }))
1259
+ );
1260
+ } else {
1261
+ const simpleQuantityEntry = entry;
1262
+ standaloneEntries.push({
1263
+ quantity: simpleQuantityEntry.quantity,
1264
+ ...simpleQuantityEntry.unit && { unit: simpleQuantityEntry.unit }
1255
1265
  });
1256
1266
  }
1257
1267
  }
1258
1268
  if (equivalentsList.length === 0) {
1259
- return andEntries;
1269
+ return [...andEntries, ...standaloneEntries];
1260
1270
  }
1261
- const result = {
1271
+ const result = [];
1272
+ result.push({
1262
1273
  and: andEntries,
1263
1274
  equivalents: equivalentsList
1264
- };
1265
- return [result];
1275
+ });
1276
+ result.push(...standaloneEntries);
1277
+ return result;
1266
1278
  } else {
1267
1279
  return [
1268
1280
  { quantity: summed.quantity, ...summed.unit && { unit: summed.unit } }
@@ -1270,17 +1282,21 @@ var flattenPlainUnitGroup = (summed) => {
1270
1282
  }
1271
1283
  };
1272
1284
  function applyBestUnit(q, system) {
1273
- if (!q.unit?.name) {
1285
+ const extended = { quantity: q.quantity };
1286
+ if (q.unit) {
1287
+ extended.unit = typeof q.unit === "string" ? { name: q.unit } : q.unit;
1288
+ }
1289
+ if (!extended.unit?.name) {
1274
1290
  return q;
1275
1291
  }
1276
- const unitDef = resolveUnit(q.unit.name);
1292
+ const unitDef = resolveUnit(extended.unit.name);
1277
1293
  if (unitDef.type === "other") {
1278
1294
  return q;
1279
1295
  }
1280
- if (q.quantity.type === "fixed" && q.quantity.value.type === "text") {
1296
+ if (extended.quantity.type === "fixed" && extended.quantity.value.type === "text") {
1281
1297
  return q;
1282
1298
  }
1283
- const avgValue = getAverageValue(q.quantity);
1299
+ const avgValue = getAverageValue(extended.quantity);
1284
1300
  const effectiveSystem = system ?? (["metric", "JP"].includes(unitDef.system) ? unitDef.system : "US");
1285
1301
  const toBase = getToBase(unitDef, effectiveSystem);
1286
1302
  const valueInBase = avgValue * toBase;
@@ -1290,22 +1306,22 @@ function applyBestUnit(q, system) {
1290
1306
  effectiveSystem,
1291
1307
  [unitDef]
1292
1308
  );
1293
- const originalCanonicalName = normalizeUnit(q.unit.name)?.name;
1309
+ const originalCanonicalName = normalizeUnit(extended.unit.name)?.name;
1294
1310
  if (bestUnit.name === originalCanonicalName) {
1295
1311
  return q;
1296
1312
  }
1297
1313
  const formattedValue = formatOutputValue(bestValue, bestUnit);
1298
- if (q.quantity.type === "range") {
1314
+ if (extended.quantity.type === "range") {
1299
1315
  const bestToBase = getToBase(bestUnit, effectiveSystem);
1300
- const minValue = getNumericValue(q.quantity.min) * toBase / bestToBase;
1301
- const maxValue = getNumericValue(q.quantity.max) * toBase / bestToBase;
1316
+ const minValue = getNumericValue(extended.quantity.min) * toBase / bestToBase;
1317
+ const maxValue = getNumericValue(extended.quantity.max) * toBase / bestToBase;
1302
1318
  return {
1303
1319
  quantity: {
1304
1320
  type: "range",
1305
1321
  min: formatOutputValue(minValue, bestUnit),
1306
1322
  max: formatOutputValue(maxValue, bestUnit)
1307
1323
  },
1308
- unit: { name: bestUnit.name }
1324
+ unit: typeof q.unit === "string" ? bestUnit.name : { name: bestUnit.name }
1309
1325
  };
1310
1326
  }
1311
1327
  return {
@@ -1313,9 +1329,27 @@ function applyBestUnit(q, system) {
1313
1329
  type: "fixed",
1314
1330
  value: formattedValue
1315
1331
  },
1316
- unit: { name: bestUnit.name }
1332
+ unit: typeof q.unit === "string" ? bestUnit.name : { name: bestUnit.name }
1317
1333
  };
1318
1334
  }
1335
+ function subtractQuantities(q1, q2, options = {}) {
1336
+ const { clampToZero = true, system } = options;
1337
+ const negatedQ2 = {
1338
+ ...q2,
1339
+ quantity: multiplyQuantityValue(q2.quantity, -1)
1340
+ };
1341
+ const result = addQuantities(q1, negatedQ2, system);
1342
+ if (clampToZero) {
1343
+ const avg = getAverageValue(result.quantity);
1344
+ if (typeof avg === "number" && avg < 0) {
1345
+ return {
1346
+ quantity: { type: "fixed", value: { type: "decimal", decimal: 0 } },
1347
+ unit: result.unit
1348
+ };
1349
+ }
1350
+ }
1351
+ return result;
1352
+ }
1319
1353
 
1320
1354
  // src/utils/parser_helpers.ts
1321
1355
  function flushPendingNote(section, noteItems) {
@@ -1456,6 +1490,97 @@ function parseQuantityInput(input_str) {
1456
1490
  }
1457
1491
  return { type: "fixed", value: parseFixedValue(clean_str) };
1458
1492
  }
1493
+ function parseQuantityWithUnit(input) {
1494
+ const trimmed = input.trim();
1495
+ const separatorIndex = trimmed.indexOf("%");
1496
+ if (separatorIndex === -1) {
1497
+ return { value: parseQuantityInput(trimmed) };
1498
+ }
1499
+ const valuePart = trimmed.slice(0, separatorIndex).trim();
1500
+ const unitPart = trimmed.slice(separatorIndex + 1).trim();
1501
+ return {
1502
+ value: parseQuantityInput(valuePart),
1503
+ unit: unitPart || void 0
1504
+ };
1505
+ }
1506
+ function parseDateFromFormat(input, format) {
1507
+ const delimiterMatch = format.match(/[^A-Za-z]/);
1508
+ if (!delimiterMatch) {
1509
+ throw new Error(`Invalid date format: ${format}. No delimiter found.`);
1510
+ }
1511
+ const delimiter = delimiterMatch[0];
1512
+ const formatParts = format.split(delimiter);
1513
+ const inputParts = input.trim().split(delimiter);
1514
+ if (formatParts.length !== 3 || inputParts.length !== 3) {
1515
+ throw new Error(
1516
+ `Invalid date input "${input}" for format "${format}". Expected 3 parts.`
1517
+ );
1518
+ }
1519
+ let day = 0, month = 0, year = 0;
1520
+ for (let i2 = 0; i2 < 3; i2++) {
1521
+ const token = formatParts[i2].toUpperCase();
1522
+ const value = parseInt(inputParts[i2], 10);
1523
+ if (isNaN(value)) {
1524
+ throw new Error(
1525
+ `Invalid date input "${input}": non-numeric part "${inputParts[i2]}".`
1526
+ );
1527
+ }
1528
+ if (token === "DD") day = value;
1529
+ else if (token === "MM") month = value;
1530
+ else if (token === "YYYY") year = value;
1531
+ else
1532
+ throw new Error(
1533
+ `Unknown token "${formatParts[i2]}" in format "${format}"`
1534
+ );
1535
+ }
1536
+ const date = new Date(year, month - 1, day);
1537
+ if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
1538
+ throw new Error(`Invalid date: "${input}" does not form a valid date.`);
1539
+ }
1540
+ return date;
1541
+ }
1542
+ function disambiguateDayMonth(first, second, year) {
1543
+ if (second > 12 && first <= 12) {
1544
+ return [second, first, year];
1545
+ }
1546
+ return [first, second, year];
1547
+ }
1548
+ function parseFuzzyDate(input) {
1549
+ const trimmed = input.trim();
1550
+ const delimiterMatch = trimmed.match(/[./-]/);
1551
+ if (!delimiterMatch) {
1552
+ throw new Error(`Cannot parse date "${input}": no delimiter found.`);
1553
+ }
1554
+ const delimiter = delimiterMatch[0];
1555
+ const parts = trimmed.split(delimiter);
1556
+ if (parts.length !== 3) {
1557
+ throw new Error(
1558
+ `Cannot parse date "${input}": expected 3 parts, got ${parts.length}.`
1559
+ );
1560
+ }
1561
+ const nums = parts.map((p) => parseInt(p, 10));
1562
+ if (nums.some((n2) => isNaN(n2))) {
1563
+ throw new Error(`Cannot parse date "${input}": non-numeric parts found.`);
1564
+ }
1565
+ let day, month, year;
1566
+ if (nums[0] >= 1e3) {
1567
+ year = nums[0];
1568
+ month = nums[1];
1569
+ day = nums[2];
1570
+ } else if (nums[2] >= 1e3) {
1571
+ [day, month, year] = disambiguateDayMonth(nums[0], nums[1], nums[2]);
1572
+ } else {
1573
+ if (nums[2] >= 100)
1574
+ throw new Error(`Invalid date: "${input}" does not form a valid date.`);
1575
+ [day, month] = disambiguateDayMonth(nums[0], nums[1], 0);
1576
+ year = 2e3 + nums[2];
1577
+ }
1578
+ const date = new Date(year, month - 1, day);
1579
+ if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
1580
+ throw new Error(`Invalid date: "${input}" does not form a valid date.`);
1581
+ }
1582
+ return date;
1583
+ }
1459
1584
  function parseMarkdownSegments(text) {
1460
1585
  const items = [];
1461
1586
  let cursor = 0;
@@ -1556,13 +1681,62 @@ function parseBlockScalarMetaVar(content, varName) {
1556
1681
  }
1557
1682
  return stripped.replace(/\n\n/g, "\0").replace(/\n/g, " ").replace(/\0/g, "\n");
1558
1683
  }
1684
+ function parseArbitraryQuantity(raw) {
1685
+ const quantityMatch = raw.trim().match(quantityAlternativeRegex);
1686
+ if (!quantityMatch?.groups) {
1687
+ throw new InvalidQuantityFormat(
1688
+ raw,
1689
+ "Arbitrary quantities must have a numerical value"
1690
+ );
1691
+ }
1692
+ const value = parseQuantityInput(quantityMatch.groups.quantity);
1693
+ const unit = quantityMatch.groups.unit;
1694
+ if (!value || value.type === "fixed" && value.value.type === "text") {
1695
+ throw new InvalidQuantityFormat(
1696
+ raw,
1697
+ "Arbitrary quantities must have a numerical value"
1698
+ );
1699
+ }
1700
+ const arbitrary = {
1701
+ quantity: value
1702
+ };
1703
+ if (unit) arbitrary.unit = unit;
1704
+ return arbitrary;
1705
+ }
1559
1706
  function parseScalingMetaVar(content, varName) {
1560
- const varMatch = content.match(scalingMetaValueRegex(varName));
1707
+ const complexMatch = content.match(scalingMetaValueWithUnitRegex(varName));
1708
+ if (complexMatch?.groups?.arbitraryQuantity) {
1709
+ const parsed = parseArbitraryQuantity(
1710
+ complexMatch.groups.arbitraryQuantity
1711
+ );
1712
+ const result2 = {
1713
+ quantity: parsed.quantity
1714
+ };
1715
+ if (parsed.unit) result2.unit = parsed.unit;
1716
+ if (complexMatch.groups.servingsPrefix) {
1717
+ result2.textBefore = complexMatch.groups.servingsPrefix;
1718
+ }
1719
+ if (complexMatch.groups.servingsSuffix) {
1720
+ result2.textAfter = complexMatch.groups.servingsSuffix;
1721
+ }
1722
+ return result2;
1723
+ }
1724
+ const varMatch = content.match(scalingSimpleMetaValueRegex(varName));
1561
1725
  if (!varMatch) return void 0;
1562
1726
  if (isNaN(Number(varMatch[2]?.trim()))) {
1563
1727
  throw new Error("Scaling variables should be numbers");
1564
1728
  }
1565
- return [Number(varMatch[2]?.trim()), varMatch[1].trim()];
1729
+ const numericValue = Number(varMatch[2]?.trim());
1730
+ const result = {
1731
+ quantity: {
1732
+ type: "fixed",
1733
+ value: { type: "decimal", decimal: numericValue }
1734
+ }
1735
+ };
1736
+ if (varMatch[3]) {
1737
+ result.text = `${varMatch[3].trim()}`;
1738
+ }
1739
+ return result;
1566
1740
  }
1567
1741
  function parseListMetaVar(content, varName) {
1568
1742
  const listMatch = content.match(
@@ -1680,6 +1854,13 @@ function parseAnyMetaVar(content, varName) {
1680
1854
  if (simple) return parseMetadataValue(simple);
1681
1855
  return void 0;
1682
1856
  }
1857
+ function getNumericValueFromMetaVar(v) {
1858
+ if (v.quantity.type === "fixed" && v.quantity.value.type !== "text") {
1859
+ return getNumericValue(v.quantity.value);
1860
+ }
1861
+ if (v.quantity.type === "range") return getNumericValue(v.quantity.min);
1862
+ return 0;
1863
+ }
1683
1864
  function extractMetadata(content) {
1684
1865
  const metadata = {};
1685
1866
  let servings = void 0;
@@ -1804,9 +1985,9 @@ function extractMetadata(content) {
1804
1985
  }
1805
1986
  for (const metaVar of ["servings", "yield", "serves"]) {
1806
1987
  const scalingMetaValue = parseScalingMetaVar(metadataContent, metaVar);
1807
- if (scalingMetaValue && scalingMetaValue[1]) {
1808
- metadata[metaVar] = scalingMetaValue[1];
1809
- servings = scalingMetaValue[0];
1988
+ if (scalingMetaValue) {
1989
+ metadata[metaVar] = scalingMetaValue;
1990
+ servings = getNumericValueFromMetaVar(scalingMetaValue);
1810
1991
  }
1811
1992
  }
1812
1993
  const tags = parseListMetaVar(metadataContent, "tags");
@@ -1836,7 +2017,227 @@ function getAlternativeSignature(alternatives) {
1836
2017
  return alternatives.map((a2) => a2.index).sort((a2, b) => a2 - b).join(",");
1837
2018
  }
1838
2019
 
2020
+ // src/classes/pantry.ts
2021
+ var Pantry = class {
2022
+ /**
2023
+ * Creates a new Pantry instance.
2024
+ * @param tomlContent - Optional TOML content to parse.
2025
+ * @param options - Optional configuration options.
2026
+ */
2027
+ constructor(tomlContent, options = {}) {
2028
+ /**
2029
+ * The parsed pantry items.
2030
+ */
2031
+ __publicField(this, "items", []);
2032
+ /**
2033
+ * Options for date parsing and other configuration.
2034
+ */
2035
+ __publicField(this, "options");
2036
+ /**
2037
+ * Optional category configuration for alias-based lookups.
2038
+ */
2039
+ __publicField(this, "categoryConfig");
2040
+ this.options = options;
2041
+ if (tomlContent) {
2042
+ this.parse(tomlContent);
2043
+ }
2044
+ }
2045
+ /**
2046
+ * Parses a TOML string into pantry items.
2047
+ * @param tomlContent - The TOML string to parse.
2048
+ * @returns The parsed list of pantry items.
2049
+ */
2050
+ parse(tomlContent) {
2051
+ const raw = TOML.parse(tomlContent);
2052
+ this.items = [];
2053
+ for (const [location, locationData] of Object.entries(raw)) {
2054
+ const locationTable = locationData;
2055
+ for (const [itemName, itemData] of Object.entries(locationTable)) {
2056
+ const item = this.parseItem(
2057
+ itemName,
2058
+ location,
2059
+ itemData
2060
+ );
2061
+ this.items.push(item);
2062
+ }
2063
+ }
2064
+ return this.items;
2065
+ }
2066
+ /**
2067
+ * Parses a single pantry item from its TOML representation.
2068
+ */
2069
+ parseItem(name, location, data) {
2070
+ const item = { name, location };
2071
+ if (typeof data === "string") {
2072
+ const parsed = parseQuantityWithUnit(data);
2073
+ item.quantity = parsed.value;
2074
+ if (parsed.unit) item.unit = parsed.unit;
2075
+ } else {
2076
+ if (data.quantity) {
2077
+ const parsed = parseQuantityWithUnit(data.quantity);
2078
+ item.quantity = parsed.value;
2079
+ if (parsed.unit) item.unit = parsed.unit;
2080
+ }
2081
+ if (data.low) {
2082
+ const parsed = parseQuantityWithUnit(data.low);
2083
+ item.low = parsed.value;
2084
+ if (parsed.unit) item.lowUnit = parsed.unit;
2085
+ }
2086
+ if (data.bought) {
2087
+ item.bought = this.parseDate(data.bought);
2088
+ }
2089
+ if (data.expire) {
2090
+ item.expire = this.parseDate(data.expire);
2091
+ }
2092
+ }
2093
+ return item;
2094
+ }
2095
+ /**
2096
+ * Parses a date string using the configured format or fuzzy detection.
2097
+ */
2098
+ parseDate(input) {
2099
+ if (this.options.dateFormat) {
2100
+ return parseDateFromFormat(input, this.options.dateFormat);
2101
+ }
2102
+ return parseFuzzyDate(input);
2103
+ }
2104
+ /**
2105
+ * Sets a category configuration for alias-based item lookups.
2106
+ * @param config - The category configuration to use.
2107
+ */
2108
+ setCategoryConfig(config) {
2109
+ this.categoryConfig = config;
2110
+ }
2111
+ /**
2112
+ * Finds a pantry item by name, using exact match first, then alias lookup
2113
+ * via the stored CategoryConfig.
2114
+ * @param name - The name to search for.
2115
+ * @returns The matching pantry item, or undefined if not found.
2116
+ */
2117
+ findItem(name) {
2118
+ const lowerName = name.toLowerCase();
2119
+ const exact = this.items.find(
2120
+ (item) => item.name.toLowerCase() === lowerName
2121
+ );
2122
+ if (exact) return exact;
2123
+ if (this.categoryConfig) {
2124
+ for (const category of this.categoryConfig.categories) {
2125
+ for (const catIngredient of category.ingredients) {
2126
+ if (catIngredient.aliases.some(
2127
+ (alias) => alias.toLowerCase() === lowerName
2128
+ )) {
2129
+ const canonicalName = catIngredient.name.toLowerCase();
2130
+ const byCanonical = this.items.find(
2131
+ (item) => item.name.toLowerCase() === canonicalName
2132
+ );
2133
+ if (byCanonical) return byCanonical;
2134
+ for (const alias of catIngredient.aliases) {
2135
+ const byAlias = this.items.find(
2136
+ (item) => item.name.toLowerCase() === alias.toLowerCase()
2137
+ );
2138
+ if (byAlias) return byAlias;
2139
+ }
2140
+ }
2141
+ }
2142
+ }
2143
+ }
2144
+ return void 0;
2145
+ }
2146
+ /**
2147
+ * Gets the numeric value of a pantry item's quantity, optionally converted to base units.
2148
+ * Returns undefined if the quantity has a text value or is not set.
2149
+ */
2150
+ getItemNumericValue(quantity, unit) {
2151
+ if (!quantity) return void 0;
2152
+ let numericValue;
2153
+ if (quantity.type === "fixed") {
2154
+ if (quantity.value.type === "text") return void 0;
2155
+ numericValue = getNumericValue(quantity.value);
2156
+ } else {
2157
+ numericValue = (getNumericValue(quantity.min) + getNumericValue(quantity.max)) / 2;
2158
+ }
2159
+ if (unit) {
2160
+ const unitDef = normalizeUnit(unit);
2161
+ if (unitDef) {
2162
+ const toBase = getToBase(unitDef);
2163
+ numericValue *= toBase;
2164
+ }
2165
+ }
2166
+ return numericValue;
2167
+ }
2168
+ /**
2169
+ * Returns all items that are depleted (quantity = 0) or below their low threshold.
2170
+ * @returns An array of depleted pantry items.
2171
+ */
2172
+ getDepletedItems() {
2173
+ return this.items.filter((item) => this.isItemLow(item));
2174
+ }
2175
+ /**
2176
+ * Returns all items whose expiration date is within `nbDays` days from today
2177
+ * (or already passed).
2178
+ * @param nbDays - Number of days ahead to check. Defaults to 0 (already expired).
2179
+ * @returns An array of expired pantry items.
2180
+ */
2181
+ getExpiredItems(nbDays = 0) {
2182
+ return this.items.filter((item) => this.isItemExpired(item, nbDays));
2183
+ }
2184
+ /**
2185
+ * Checks if a specific item is low (quantity = 0 or below `low` threshold).
2186
+ * @param itemName - The name of the item to check (supports aliases if CategoryConfig is set).
2187
+ * @returns true if the item is low, false otherwise. Returns false if item not found.
2188
+ */
2189
+ isLow(itemName) {
2190
+ const item = this.findItem(itemName);
2191
+ if (!item) return false;
2192
+ return this.isItemLow(item);
2193
+ }
2194
+ /**
2195
+ * Checks if a specific item is expired or expires within `nbDays` days.
2196
+ * @param itemName - The name of the item to check (supports aliases if CategoryConfig is set).
2197
+ * @param nbDays - Number of days ahead to check. Defaults to 0.
2198
+ * @returns true if the item is expired, false otherwise. Returns false if item not found.
2199
+ */
2200
+ isExpired(itemName, nbDays = 0) {
2201
+ const item = this.findItem(itemName);
2202
+ if (!item) return false;
2203
+ return this.isItemExpired(item, nbDays);
2204
+ }
2205
+ /**
2206
+ * Internal: checks if a pantry item is low.
2207
+ */
2208
+ isItemLow(item) {
2209
+ if (!item.quantity) return false;
2210
+ const qtyValue = this.getItemNumericValue(item.quantity, item.unit);
2211
+ if (qtyValue === void 0) return false;
2212
+ if (qtyValue === 0) return true;
2213
+ if (item.low) {
2214
+ const lowValue = this.getItemNumericValue(item.low, item.lowUnit);
2215
+ if (lowValue !== void 0 && qtyValue <= lowValue) return true;
2216
+ }
2217
+ return false;
2218
+ }
2219
+ /**
2220
+ * Internal: checks if a pantry item is expired.
2221
+ */
2222
+ isItemExpired(item, nbDays) {
2223
+ if (!item.expire) return false;
2224
+ const now = /* @__PURE__ */ new Date();
2225
+ const cutoff = new Date(
2226
+ now.getFullYear(),
2227
+ now.getMonth(),
2228
+ now.getDate() + nbDays
2229
+ );
2230
+ const expireDay = new Date(
2231
+ item.expire.getFullYear(),
2232
+ item.expire.getMonth(),
2233
+ item.expire.getDate()
2234
+ );
2235
+ return expireDay <= cutoff;
2236
+ }
2237
+ };
2238
+
1839
2239
  // src/classes/product_catalog.ts
2240
+ import TOML2 from "smol-toml";
1840
2241
  var ProductCatalog = class {
1841
2242
  constructor(tomlContent) {
1842
2243
  __publicField(this, "products", []);
@@ -1848,7 +2249,7 @@ var ProductCatalog = class {
1848
2249
  * @returns A parsed list of `ProductOption`.
1849
2250
  */
1850
2251
  parse(tomlContent) {
1851
- const catalogRaw = TOML.parse(tomlContent);
2252
+ const catalogRaw = TOML2.parse(tomlContent);
1852
2253
  this.products = [];
1853
2254
  if (!this.isValidTomlContent(catalogRaw)) {
1854
2255
  throw new InvalidProductCatalogFormat();
@@ -1921,7 +2322,7 @@ var ProductCatalog = class {
1921
2322
  size: sizeStrings.length === 1 ? sizeStrings[0] : sizeStrings
1922
2323
  };
1923
2324
  }
1924
- return TOML.stringify(grouped);
2325
+ return TOML2.stringify(grouped);
1925
2326
  }
1926
2327
  /**
1927
2328
  * Adds a product to the catalog.
@@ -2058,7 +2459,6 @@ function getEquivalentUnitsLists(...quantities) {
2058
2459
  const OrGroups = quantitiesCopy.filter(isOrGroup).filter((q) => q.or.length > 1);
2059
2460
  const unitLists = [];
2060
2461
  const normalizeOrGroup = (og) => ({
2061
- ...og,
2062
2462
  or: og.or.map((q) => ({
2063
2463
  ...q,
2064
2464
  unit: resolveUnit(q.unit?.name, q.unit?.integerProtected)
@@ -2414,27 +2814,17 @@ var _Recipe = class _Recipe {
2414
2814
  */
2415
2815
  _parseArbitraryScalable(regexMatchGroups, intoArray) {
2416
2816
  if (!regexMatchGroups || !regexMatchGroups.arbitraryQuantity) return;
2417
- const quantityMatch = regexMatchGroups.arbitraryQuantity?.trim().match(quantityAlternativeRegex);
2418
- if (quantityMatch?.groups) {
2419
- const value = parseQuantityInput(quantityMatch.groups.quantity);
2420
- const unit = quantityMatch.groups.unit;
2421
- const name = regexMatchGroups.arbitraryName || void 0;
2422
- if (!value || value.type === "fixed" && value.value.type === "text") {
2423
- throw new InvalidQuantityFormat(
2424
- regexMatchGroups.arbitraryQuantity?.trim(),
2425
- "Arbitrary quantities must have a numerical value"
2426
- );
2427
- }
2428
- const arbitrary = {
2429
- quantity: value
2430
- };
2431
- if (name) arbitrary.name = name;
2432
- if (unit) arbitrary.unit = unit;
2433
- intoArray.push({
2434
- type: "arbitrary",
2435
- index: this.arbitraries.push(arbitrary) - 1
2436
- });
2437
- }
2817
+ const parsed = parseArbitraryQuantity(regexMatchGroups.arbitraryQuantity);
2818
+ const name = regexMatchGroups.arbitraryName || void 0;
2819
+ const arbitrary = {
2820
+ quantity: parsed.quantity
2821
+ };
2822
+ if (name) arbitrary.name = name;
2823
+ if (parsed.unit) arbitrary.unit = parsed.unit;
2824
+ intoArray.push({
2825
+ type: "arbitrary",
2826
+ index: this.arbitraries.push(arbitrary) - 1
2827
+ });
2438
2828
  }
2439
2829
  /**
2440
2830
  * Parses text for arbitrary scalables and returns NoteItem array.
@@ -2739,34 +3129,10 @@ var _Recipe = class _Recipe {
2739
3129
  }
2740
3130
  }
2741
3131
  }
2742
- /**
2743
- * Gets ingredients with their quantities populated, optionally filtered by section/step
2744
- * and respecting user choices for alternatives.
2745
- *
2746
- * When no options are provided, returns all recipe ingredients with quantities
2747
- * calculated using primary alternatives (same as after parsing).
2748
- *
2749
- * @param options - Options for filtering and choice selection:
2750
- * - `section`: Filter to a specific section (Section object or 0-based index)
2751
- * - `step`: Filter to a specific step (Step object or 0-based index)
2752
- * - `choices`: Choices for alternative ingredients (defaults to primary)
2753
- * @returns Array of Ingredient objects with quantities populated
2754
- *
2755
- * @example
2756
- * ```typescript
2757
- * // Get all ingredients with primary alternatives
2758
- * const ingredients = recipe.getIngredientQuantities();
2759
- *
2760
- * // Get ingredients for a specific section
2761
- * const sectionIngredients = recipe.getIngredientQuantities({ section: 0 });
2762
- *
2763
- * // Get ingredients with specific choices applied
2764
- * const withChoices = recipe.getIngredientQuantities({
2765
- * choices: { ingredientItems: new Map([['ingredient-item-2', 1]]) }
2766
- * });
2767
- * ```
2768
- */
2769
- getIngredientQuantities(options) {
3132
+ // Type for accumulated quantities (used internally by collectQuantityGroups)
3133
+ // Defined as a static type alias for the private method's return type
3134
+ /** @internal */
3135
+ collectQuantityGroups(options) {
2770
3136
  const { section, step, choices } = options || {};
2771
3137
  const sectionsToProcess = section !== void 0 ? (() => {
2772
3138
  const idx = typeof section === "number" ? section : this.sections.indexOf(section);
@@ -2787,8 +3153,8 @@ var _Recipe = class _Recipe {
2787
3153
  const isGrouped = "group" in item && item.group !== void 0;
2788
3154
  const groupAlternatives = isGrouped ? this.choices.ingredientGroups.get(item.group) : void 0;
2789
3155
  let selectedAltIndex = 0;
2790
- let isSelected = false;
2791
- let hasExplicitChoice = false;
3156
+ let isSelected;
3157
+ let hasExplicitChoice;
2792
3158
  if (isGrouped) {
2793
3159
  const groupChoice = choices?.ingredientGroups?.get(item.group);
2794
3160
  hasExplicitChoice = groupChoice !== void 0;
@@ -2884,6 +3250,78 @@ var _Recipe = class _Recipe {
2884
3250
  }
2885
3251
  }
2886
3252
  }
3253
+ return { ingredientGroups, selectedIndices, referencedIndices };
3254
+ }
3255
+ /**
3256
+ * Gets the raw (unprocessed) quantity groups for each ingredient, before
3257
+ * any summation or equivalents simplification. This is useful for cross-recipe
3258
+ * aggregation (e.g., in {@link ShoppingList}), where quantities from multiple
3259
+ * recipes should be combined before processing.
3260
+ *
3261
+ * @param options - Options for filtering and choice selection (same as {@link getIngredientQuantities}).
3262
+ * @returns Array of {@link RawQuantityGroup} objects, one per ingredient with quantities.
3263
+ *
3264
+ * @example
3265
+ * ```typescript
3266
+ * const rawGroups = recipe.getRawQuantityGroups();
3267
+ * // Each group has: name, usedAsPrimary, flags, quantities[]
3268
+ * // quantities are the raw QuantityWithExtendedUnit or FlatOrGroup entries
3269
+ * ```
3270
+ */
3271
+ getRawQuantityGroups(options) {
3272
+ const { ingredientGroups, selectedIndices, referencedIndices } = this.collectQuantityGroups(options);
3273
+ const result = [];
3274
+ for (let index = 0; index < this.ingredients.length; index++) {
3275
+ if (!referencedIndices.has(index)) continue;
3276
+ const orig = this.ingredients[index];
3277
+ const usedAsPrimary = selectedIndices.has(index);
3278
+ const quantities = [];
3279
+ if (usedAsPrimary) {
3280
+ const groupsForIng = ingredientGroups.get(index);
3281
+ if (groupsForIng) {
3282
+ for (const [, group] of groupsForIng) {
3283
+ quantities.push(...group.quantities);
3284
+ }
3285
+ }
3286
+ }
3287
+ result.push({
3288
+ name: orig.name,
3289
+ ...usedAsPrimary && { usedAsPrimary: true },
3290
+ ...orig.flags && { flags: orig.flags },
3291
+ quantities
3292
+ });
3293
+ }
3294
+ return result;
3295
+ }
3296
+ /**
3297
+ * Gets ingredients with their quantities populated, optionally filtered by section/step
3298
+ * and respecting user choices for alternatives.
3299
+ *
3300
+ * When no options are provided, returns all recipe ingredients with quantities
3301
+ * calculated using primary alternatives (same as after parsing).
3302
+ *
3303
+ * @param options - Options for filtering and choice selection:
3304
+ * - `section`: Filter to a specific section (Section object or 0-based index)
3305
+ * - `step`: Filter to a specific step (Step object or 0-based index)
3306
+ * - `choices`: Choices for alternative ingredients (defaults to primary)
3307
+ * @returns Array of Ingredient objects with quantities populated
3308
+ *
3309
+ * @example
3310
+ * ```typescript
3311
+ * // Get all ingredients with primary alternatives
3312
+ * const ingredients = recipe.getIngredientQuantities();
3313
+ *
3314
+ * // Get ingredients for a specific section
3315
+ * const sectionIngredients = recipe.getIngredientQuantities({ section: 0 });
3316
+ *
3317
+ * // Get ingredients with specific choices applied
3318
+ * const withChoices = recipe.getIngredientQuantities({
3319
+ * choices: { ingredientItems: new Map([['ingredient-item-2', 1]]) }
3320
+ * });
3321
+ * ```
3322
+ */
3323
+ getIngredientQuantities(options) {
3324
+ const { ingredientGroups, selectedIndices, referencedIndices } = this.collectQuantityGroups(options);
2887
3325
  const result = [];
2888
3326
  for (let index = 0; index < this.ingredients.length; index++) {
2889
3327
  if (!referencedIndices.has(index)) continue;
@@ -3176,37 +3614,34 @@ var _Recipe = class _Recipe {
3176
3614
  arbitrary.quantity,
3177
3615
  factor
3178
3616
  );
3617
+ const optimized = applyBestUnit(
3618
+ { quantity: arbitrary.quantity, unit: arbitrary.unit },
3619
+ unitSystem
3620
+ );
3621
+ arbitrary.quantity = optimized.quantity;
3622
+ arbitrary.unit = optimized.unit;
3179
3623
  }
3180
3624
  newRecipe._populateIngredientQuantities();
3181
3625
  newRecipe.servings = Big4(originalServings).times(factor).toNumber();
3182
- if (newRecipe.metadata.servings && this.metadata.servings) {
3183
- if (floatRegex.test(String(this.metadata.servings).replace(",", ".").trim())) {
3184
- const servingsValue = parseFloat(
3185
- String(this.metadata.servings).replace(",", ".")
3186
- );
3187
- newRecipe.metadata.servings = String(
3188
- Big4(servingsValue).times(factor).toNumber()
3189
- );
3190
- }
3191
- }
3192
- if (newRecipe.metadata.yield && this.metadata.yield) {
3193
- if (floatRegex.test(String(this.metadata.yield).replace(",", ".").trim())) {
3194
- const yieldValue = parseFloat(
3195
- String(this.metadata.yield).replace(",", ".")
3626
+ for (const metaVar of ["servings", "yield", "serves"]) {
3627
+ if (newRecipe.metadata[metaVar] && this.metadata[metaVar]) {
3628
+ const original = this.metadata[metaVar];
3629
+ const scaledQuantity = multiplyQuantityValue(
3630
+ original.quantity,
3631
+ factor
3196
3632
  );
3197
- newRecipe.metadata.yield = String(
3198
- Big4(yieldValue).times(factor).toNumber()
3199
- );
3200
- }
3201
- }
3202
- if (newRecipe.metadata.serves && this.metadata.serves) {
3203
- if (floatRegex.test(String(this.metadata.serves).replace(",", ".").trim())) {
3204
- const servesValue = parseFloat(
3205
- String(this.metadata.serves).replace(",", ".")
3206
- );
3207
- newRecipe.metadata.serves = String(
3208
- Big4(servesValue).times(factor).toNumber()
3633
+ const optimized = applyBestUnit(
3634
+ { quantity: scaledQuantity, unit: original.unit },
3635
+ unitSystem
3209
3636
  );
3637
+ const scaled = {
3638
+ quantity: optimized.quantity
3639
+ };
3640
+ if (optimized.unit) scaled.unit = optimized.unit;
3641
+ if (original.textBefore) scaled.textBefore = original.textBefore;
3642
+ if (original.textAfter) scaled.textAfter = original.textAfter;
3643
+ if (original.text) scaled.text = original.text;
3644
+ newRecipe.metadata[metaVar] = scaled;
3210
3645
  }
3211
3646
  }
3212
3647
  return newRecipe;
@@ -3412,13 +3847,12 @@ __publicField(_Recipe, "itemCounts", /* @__PURE__ */ new WeakMap());
3412
3847
  var Recipe = _Recipe;
3413
3848
 
3414
3849
  // src/classes/shopping_list.ts
3415
- var ShoppingList = class {
3850
+ var ShoppingList = class _ShoppingList {
3416
3851
  /**
3417
3852
  * Creates a new ShoppingList instance
3418
3853
  * @param categoryConfigStr - The category configuration to parse.
3419
3854
  */
3420
3855
  constructor(categoryConfigStr) {
3421
- // TODO: backport type change
3422
3856
  /**
3423
3857
  * The ingredients in the shopping list.
3424
3858
  */
@@ -3435,38 +3869,38 @@ var ShoppingList = class {
3435
3869
  * The categorized ingredients in the shopping list.
3436
3870
  */
3437
3871
  __publicField(this, "categories");
3872
+ /**
3873
+ * The unit system to use for quantity simplification.
3874
+ * When set, overrides per-recipe unit systems.
3875
+ */
3876
+ __publicField(this, "unitSystem");
3877
+ /**
3878
+ * Per-ingredient equivalence ratio maps for recomputing equivalents
3879
+ * after pantry subtraction. Keyed by ingredient name.
3880
+ * @internal
3881
+ */
3882
+ __publicField(this, "equivalenceRatios", /* @__PURE__ */ new Map());
3883
+ /**
3884
+ * The original pantry (never mutated by recipe calculations).
3885
+ */
3886
+ __publicField(this, "pantry");
3887
+ /**
3888
+ * The pantry with quantities updated after subtracting recipe needs.
3889
+ * Recomputed on every {@link ShoppingList.calculateIngredients | calculateIngredients()} call.
3890
+ */
3891
+ __publicField(this, "resultingPantry");
3438
3892
  if (categoryConfigStr) {
3439
3893
  this.setCategoryConfig(categoryConfigStr);
3440
3894
  }
3441
3895
  }
3442
3896
  calculateIngredients() {
3443
3897
  this.ingredients = [];
3444
- const addIngredientQuantity = (name, quantityTotal) => {
3445
- const quantityTotalExtended = extendAllUnits(quantityTotal);
3446
- const newQuantities = isAndGroup(quantityTotalExtended) ? quantityTotalExtended.and : [quantityTotalExtended];
3447
- const existing = this.ingredients.find((i2) => i2.name === name);
3448
- if (existing) {
3449
- if (!existing.quantityTotal) {
3450
- existing.quantityTotal = quantityTotal;
3451
- return;
3452
- }
3453
- try {
3454
- const existingQuantityTotalExtended = extendAllUnits(
3455
- existing.quantityTotal
3456
- );
3457
- const existingQuantities = isAndGroup(existingQuantityTotalExtended) ? existingQuantityTotalExtended.and : [existingQuantityTotalExtended];
3458
- existing.quantityTotal = addEquivalentsAndSimplify([
3459
- ...existingQuantities,
3460
- ...newQuantities
3461
- ]);
3462
- return;
3463
- } catch {
3464
- }
3898
+ const rawQuantitiesMap = /* @__PURE__ */ new Map();
3899
+ const nameOrder = [];
3900
+ const trackName = (name) => {
3901
+ if (!nameOrder.includes(name)) {
3902
+ nameOrder.push(name);
3465
3903
  }
3466
- this.ingredients.push({
3467
- name,
3468
- quantityTotal
3469
- });
3470
3904
  };
3471
3905
  for (const addedRecipe of this.recipes) {
3472
3906
  let scaledRecipe;
@@ -3476,48 +3910,253 @@ var ShoppingList = class {
3476
3910
  } else {
3477
3911
  scaledRecipe = addedRecipe.recipe.scaleTo(addedRecipe.servings);
3478
3912
  }
3479
- const ingredients = scaledRecipe.getIngredientQuantities({
3913
+ const rawGroups = scaledRecipe.getRawQuantityGroups({
3480
3914
  choices: addedRecipe.choices
3481
3915
  });
3482
- for (const ingredient of ingredients) {
3483
- if (ingredient.flags && ingredient.flags.includes("hidden")) {
3916
+ for (const group of rawGroups) {
3917
+ if (group.flags?.includes("hidden") || !group.usedAsPrimary) {
3484
3918
  continue;
3485
3919
  }
3486
- if (!ingredient.usedAsPrimary) {
3487
- continue;
3920
+ trackName(group.name);
3921
+ if (group.quantities.length > 0) {
3922
+ const existing = rawQuantitiesMap.get(group.name) ?? [];
3923
+ existing.push(...group.quantities);
3924
+ rawQuantitiesMap.set(group.name, existing);
3925
+ }
3926
+ }
3927
+ }
3928
+ this.equivalenceRatios.clear();
3929
+ for (const name of nameOrder) {
3930
+ const rawQuantities = rawQuantitiesMap.get(name);
3931
+ if (!rawQuantities || rawQuantities.length === 0) {
3932
+ this.ingredients.push({ name });
3933
+ continue;
3934
+ }
3935
+ const textEntries = [];
3936
+ const numericEntries = [];
3937
+ for (const q of rawQuantities) {
3938
+ if ("quantity" in q && q.quantity.type === "fixed" && q.quantity.value.type === "text") {
3939
+ textEntries.push(q);
3940
+ } else {
3941
+ numericEntries.push(q);
3488
3942
  }
3489
- if (ingredient.quantities && ingredient.quantities.length > 0) {
3490
- const allQuantities = [];
3491
- for (const qGroup of ingredient.quantities) {
3492
- if ("and" in qGroup) {
3493
- for (const qty of qGroup.and) {
3494
- allQuantities.push(qty);
3943
+ }
3944
+ if (numericEntries.length > 1) {
3945
+ const ratioMap = _ShoppingList.buildEquivalenceRatioMap(
3946
+ getEquivalentUnitsLists(...numericEntries)
3947
+ );
3948
+ if (Object.keys(ratioMap).length > 0) {
3949
+ this.equivalenceRatios.set(name, ratioMap);
3950
+ }
3951
+ }
3952
+ const resultQuantities = [];
3953
+ for (const t2 of textEntries) {
3954
+ resultQuantities.push(toPlainUnit(t2));
3955
+ }
3956
+ if (numericEntries.length > 0) {
3957
+ resultQuantities.push(
3958
+ ...flattenPlainUnitGroup(
3959
+ addEquivalentsAndSimplify(numericEntries, this.unitSystem)
3960
+ )
3961
+ );
3962
+ }
3963
+ this.ingredients.push({
3964
+ name,
3965
+ quantities: resultQuantities
3966
+ });
3967
+ }
3968
+ this.applyPantrySubtraction();
3969
+ }
3970
+ /**
3971
+ * Subtracts pantry item quantities from calculated ingredient quantities
3972
+ * and updates the resultingPantry to reflect consumed stock.
3973
+ */
3974
+ applyPantrySubtraction() {
3975
+ if (!this.pantry) {
3976
+ this.resultingPantry = void 0;
3977
+ return;
3978
+ }
3979
+ const clonedPantry = new Pantry();
3980
+ clonedPantry.items = deepClone(this.pantry.items);
3981
+ if (this.categoryConfig) {
3982
+ clonedPantry.setCategoryConfig(this.categoryConfig);
3983
+ }
3984
+ for (const ingredient of this.ingredients) {
3985
+ if (!ingredient.quantities || ingredient.quantities.length === 0)
3986
+ continue;
3987
+ const pantryItem = clonedPantry.findItem(ingredient.name);
3988
+ if (!pantryItem || !pantryItem.quantity) continue;
3989
+ let pantryExtended = {
3990
+ quantity: pantryItem.quantity,
3991
+ ...pantryItem.unit && { unit: { name: pantryItem.unit } }
3992
+ };
3993
+ for (let i2 = 0; i2 < ingredient.quantities.length; i2++) {
3994
+ const entry = ingredient.quantities[i2];
3995
+ const leaves = "and" in entry ? entry.and : [entry];
3996
+ for (const leaf of leaves) {
3997
+ const ingredientExtended = toExtendedUnit(leaf);
3998
+ const leafHasUnit = leaf.unit !== void 0 && leaf.unit !== "";
3999
+ const pantryHasUnit = pantryExtended.unit !== void 0 && pantryExtended.unit.name !== "";
4000
+ const ratioMap = this.equivalenceRatios.get(ingredient.name);
4001
+ const unitMismatch = leafHasUnit !== pantryHasUnit && ratioMap !== void 0;
4002
+ if (unitMismatch) {
4003
+ const leafUnit = leaf.unit ?? NO_UNIT;
4004
+ const pantryUnit = pantryExtended.unit?.name ?? NO_UNIT;
4005
+ const ratioFromPantry = ratioMap[leafUnit]?.[pantryUnit];
4006
+ if (ratioFromPantry !== void 0) {
4007
+ const pantryValue = getAverageValue(pantryExtended.quantity);
4008
+ const leafValue = getAverageValue(ingredientExtended.quantity);
4009
+ if (typeof pantryValue === "number" && typeof leafValue === "number") {
4010
+ const pantryInLeafUnits = pantryValue * ratioFromPantry;
4011
+ const subtracted = Math.min(pantryInLeafUnits, leafValue);
4012
+ const remainingLeafValue = Math.max(
4013
+ leafValue - pantryInLeafUnits,
4014
+ 0
4015
+ );
4016
+ leaf.quantity = {
4017
+ type: "fixed",
4018
+ value: { type: "decimal", decimal: remainingLeafValue }
4019
+ };
4020
+ const consumedInPantryUnits = ratioFromPantry !== 0 ? subtracted / ratioFromPantry : pantryValue;
4021
+ const remainingPantryValue = Math.max(
4022
+ pantryValue - consumedInPantryUnits,
4023
+ 0
4024
+ );
4025
+ pantryExtended = {
4026
+ quantity: {
4027
+ type: "fixed",
4028
+ value: {
4029
+ type: "decimal",
4030
+ decimal: remainingPantryValue
4031
+ }
4032
+ },
4033
+ ...pantryExtended.unit && { unit: pantryExtended.unit }
4034
+ };
4035
+ continue;
3495
4036
  }
3496
- } else {
3497
- const plainQty = {
3498
- quantity: qGroup.quantity
3499
- };
3500
- if (qGroup.unit) plainQty.unit = qGroup.unit;
3501
- if (qGroup.equivalents) plainQty.equivalents = qGroup.equivalents;
3502
- allQuantities.push(plainQty);
3503
4037
  }
3504
4038
  }
3505
- if (allQuantities.length === 1) {
3506
- addIngredientQuantity(ingredient.name, allQuantities[0]);
3507
- } else {
3508
- const extendedQuantities = allQuantities.map(
3509
- (q) => extendAllUnits(q)
4039
+ try {
4040
+ const remaining = subtractQuantities(
4041
+ ingredientExtended,
4042
+ pantryExtended,
4043
+ { clampToZero: true }
3510
4044
  );
3511
- const totalQuantity = addEquivalentsAndSimplify(
3512
- extendedQuantities
4045
+ const consumed = subtractQuantities(
4046
+ pantryExtended,
4047
+ ingredientExtended,
4048
+ { clampToZero: true }
3513
4049
  );
3514
- addIngredientQuantity(ingredient.name, totalQuantity);
4050
+ pantryExtended = consumed;
4051
+ const updated = toPlainUnit(remaining);
4052
+ leaf.quantity = updated.quantity;
4053
+ leaf.unit = updated.unit;
4054
+ } catch {
3515
4055
  }
3516
- } else if (!this.ingredients.some((i2) => i2.name === ingredient.name)) {
3517
- this.ingredients.push({ name: ingredient.name });
4056
+ }
4057
+ if ("and" in entry) {
4058
+ const nonZero = entry.and.filter(
4059
+ (leaf) => leaf.quantity.type !== "fixed" || leaf.quantity.value.type !== "decimal" || leaf.quantity.value.decimal !== 0
4060
+ );
4061
+ entry.and.length = 0;
4062
+ entry.and.push(...nonZero);
4063
+ const ratioMap = this.equivalenceRatios.get(ingredient.name);
4064
+ if (entry.equivalents && ratioMap) {
4065
+ const equivUnits = entry.equivalents.map((e2) => e2.unit ?? NO_UNIT);
4066
+ entry.equivalents = _ShoppingList.recomputeEquivalents(
4067
+ entry.and,
4068
+ ratioMap,
4069
+ equivUnits
4070
+ );
4071
+ }
4072
+ if (entry.and.length === 1) {
4073
+ const single = entry.and[0];
4074
+ ingredient.quantities[i2] = {
4075
+ quantity: single.quantity,
4076
+ ...single.unit && { unit: single.unit },
4077
+ ...entry.equivalents && { equivalents: entry.equivalents },
4078
+ ...entry.alternatives && { alternatives: entry.alternatives }
4079
+ };
4080
+ }
4081
+ } else if ("equivalents" in entry && entry.equivalents) {
4082
+ const ratioMap = this.equivalenceRatios.get(ingredient.name);
4083
+ if (ratioMap) {
4084
+ const equivUnits = entry.equivalents.map(
4085
+ (e2) => e2.unit ?? NO_UNIT
4086
+ );
4087
+ const recomputed = _ShoppingList.recomputeEquivalents(
4088
+ [entry],
4089
+ ratioMap,
4090
+ equivUnits
4091
+ );
4092
+ entry.equivalents = recomputed;
4093
+ }
4094
+ }
4095
+ }
4096
+ ingredient.quantities = ingredient.quantities.filter((entry) => {
4097
+ if ("and" in entry) return entry.and.length > 0;
4098
+ return !(entry.quantity.type === "fixed" && entry.quantity.value.type === "decimal" && entry.quantity.value.decimal === 0);
4099
+ });
4100
+ if (ingredient.quantities.length === 0) {
4101
+ ingredient.quantities = void 0;
4102
+ }
4103
+ pantryItem.quantity = pantryExtended.quantity;
4104
+ if (pantryExtended.unit) {
4105
+ pantryItem.unit = pantryExtended.unit.name;
4106
+ }
4107
+ }
4108
+ this.resultingPantry = clonedPantry;
4109
+ }
4110
+ /**
4111
+ * Builds a ratio map from equivalence lists.
4112
+ * For each equivalence list, stores ratio = equiv_value / primary_value
4113
+ * for every pair of units, so equivalents can be recomputed after
4114
+ * pantry subtraction modifies primary quantities.
4115
+ */
4116
+ static buildEquivalenceRatioMap(unitsLists) {
4117
+ const ratioMap = {};
4118
+ for (const list of unitsLists) {
4119
+ for (const equiv of list) {
4120
+ const equivValue = getAverageValue(equiv.quantity);
4121
+ for (const primary of list) {
4122
+ if (primary === equiv) continue;
4123
+ const primaryValue = getAverageValue(primary.quantity);
4124
+ const equivUnit = equiv.unit.name;
4125
+ const primaryUnit = primary.unit.name;
4126
+ ratioMap[equivUnit] ?? (ratioMap[equivUnit] = {});
4127
+ ratioMap[equivUnit][primaryUnit] = equivValue / primaryValue;
3518
4128
  }
3519
4129
  }
3520
4130
  }
4131
+ return ratioMap;
4132
+ }
4133
+ /**
4134
+ * Recomputes equivalent quantities from current primary values and stored ratios.
4135
+ * For each equivalent unit in equivUnits, new_value = Σ (primary_value × ratio[equivUnit][primaryUnit]).
4136
+ * Returns undefined if all equivalents compute to zero.
4137
+ */
4138
+ static recomputeEquivalents(primaries, ratioMap, equivUnits) {
4139
+ const equivalents = [];
4140
+ for (const equivUnit of equivUnits) {
4141
+ const ratios = ratioMap[equivUnit];
4142
+ let total = 0;
4143
+ for (const primary of primaries) {
4144
+ const pUnit = primary.unit ?? NO_UNIT;
4145
+ const ratio = ratios[pUnit];
4146
+ const pValue = getAverageValue(primary.quantity);
4147
+ total += pValue * ratio;
4148
+ }
4149
+ if (total > 0) {
4150
+ equivalents.push({
4151
+ quantity: {
4152
+ type: "fixed",
4153
+ value: { type: "decimal", decimal: total }
4154
+ },
4155
+ ...equivUnit !== "" && { unit: equivUnit }
4156
+ });
4157
+ }
4158
+ }
4159
+ return equivalents.length > 0 ? equivalents : void 0;
3521
4160
  }
3522
4161
  /**
3523
4162
  * Adds a recipe to the shopping list, then automatically
@@ -3606,9 +4245,41 @@ var ShoppingList = class {
3606
4245
  this.calculateIngredients();
3607
4246
  this.categorize();
3608
4247
  }
4248
+ /**
4249
+ * Adds a pantry to the shopping list. On-hand pantry quantities will be
4250
+ * subtracted from recipe ingredient needs on each recalculation.
4251
+ * @param pantry - A Pantry instance or a TOML string to parse.
4252
+ * @param options - Options for pantry parsing (only used when providing a TOML string).
4253
+ */
4254
+ addPantry(pantry, options) {
4255
+ if (typeof pantry === "string") {
4256
+ this.pantry = new Pantry(pantry, options);
4257
+ } else if (pantry instanceof Pantry) {
4258
+ this.pantry = pantry;
4259
+ } else {
4260
+ throw new Error(
4261
+ "Invalid pantry: expected a Pantry instance or TOML string"
4262
+ );
4263
+ }
4264
+ if (this.categoryConfig) {
4265
+ this.pantry.setCategoryConfig(this.categoryConfig);
4266
+ }
4267
+ this.calculateIngredients();
4268
+ this.categorize();
4269
+ }
4270
+ /**
4271
+ * Returns the resulting pantry with quantities updated to reflect
4272
+ * what was consumed by the shopping list's recipes.
4273
+ * Returns undefined if no pantry was added.
4274
+ * @returns The resulting Pantry, or undefined.
4275
+ */
4276
+ getPantry() {
4277
+ return this.resultingPantry;
4278
+ }
3609
4279
  /**
3610
4280
  * Sets the category configuration for the shopping list
3611
4281
  * and automatically categorize current ingredients from the list.
4282
+ * Also propagates the configuration to the pantry if one is set.
3612
4283
  * @param config - The category configuration to parse.
3613
4284
  */
3614
4285
  setCategoryConfig(config) {
@@ -3616,6 +4287,9 @@ var ShoppingList = class {
3616
4287
  this.categoryConfig = new CategoryConfig(config);
3617
4288
  else if (config instanceof CategoryConfig) this.categoryConfig = config;
3618
4289
  else throw new Error("Invalid category configuration");
4290
+ if (this.pantry) {
4291
+ this.pantry.setCategoryConfig(this.categoryConfig);
4292
+ }
3619
4293
  this.categorize();
3620
4294
  }
3621
4295
  /**
@@ -3760,8 +4434,27 @@ var ShoppingCart = class {
3760
4434
  getOptimumMatch(ingredient, options) {
3761
4435
  if (options.length === 0)
3762
4436
  throw new NoProductMatchError(ingredient.name, "noProduct");
3763
- if (!ingredient.quantityTotal)
4437
+ if (!ingredient.quantities || ingredient.quantities.length === 0)
3764
4438
  throw new NoProductMatchError(ingredient.name, "noQuantity");
4439
+ const allPlainEntries = [];
4440
+ for (const q of ingredient.quantities) {
4441
+ if ("and" in q) {
4442
+ allPlainEntries.push({ and: q.and });
4443
+ } else {
4444
+ const entry = {
4445
+ quantity: q.quantity,
4446
+ ...q.unit && { unit: q.unit },
4447
+ ...q.equivalents && { equivalents: q.equivalents }
4448
+ };
4449
+ allPlainEntries.push(entry);
4450
+ }
4451
+ }
4452
+ let quantityTotal;
4453
+ if (allPlainEntries.length === 1) {
4454
+ quantityTotal = allPlainEntries[0];
4455
+ } else {
4456
+ quantityTotal = { and: allPlainEntries };
4457
+ }
3765
4458
  const normalizedOptions = options.map(
3766
4459
  (option) => ({
3767
4460
  ...option,
@@ -3777,7 +4470,7 @@ var ShoppingCart = class {
3777
4470
  })
3778
4471
  })
3779
4472
  );
3780
- const normalizedQuantityTotal = normalizeAllUnits(ingredient.quantityTotal);
4473
+ const normalizedQuantityTotal = normalizeAllUnits(quantityTotal);
3781
4474
  function getOptimumMatchForQuantityParts(normalizedQuantities, normalizedOptions2, selection = []) {
3782
4475
  if (isAndGroup(normalizedQuantities)) {
3783
4476
  for (const q of normalizedQuantities.and) {
@@ -4004,6 +4697,7 @@ export {
4004
4697
  NoProductCatalogForCartError,
4005
4698
  NoShoppingListForCartError,
4006
4699
  NoTabAsIndentError,
4700
+ Pantry,
4007
4701
  ProductCatalog,
4008
4702
  Recipe,
4009
4703
  Section,
@@ -4030,6 +4724,10 @@ export {
4030
4724
  // v8 ignore else -- @preserve
4031
4725
  // v8 ignore if -- @preserve
4032
4726
  /* v8 ignore else -- expliciting error type -- @preserve */
4727
+ /* v8 ignore next 4 -- @preserve: defensive guard; regex always matches */
4728
+ // v8 ignore if -- @preserve: defensive type guard
4033
4729
  /* v8 ignore if -- @preserve */
4034
4730
  // v8 ignore next -- @preserve
4731
+ // v8 ignore else --@preserve: defensive type guard
4732
+ // v8 ignore else -- @preserve: detection if
4035
4733
  //# sourceMappingURL=index.js.map