@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.cjs CHANGED
@@ -37,6 +37,7 @@ __export(index_exports, {
37
37
  NoProductCatalogForCartError: () => NoProductCatalogForCartError,
38
38
  NoShoppingListForCartError: () => NoShoppingListForCartError,
39
39
  NoTabAsIndentError: () => NoTabAsIndentError,
40
+ Pantry: () => Pantry,
40
41
  ProductCatalog: () => ProductCatalog,
41
42
  Recipe: () => Recipe,
42
43
  Section: () => Section,
@@ -120,7 +121,7 @@ var CategoryConfig = class {
120
121
  }
121
122
  };
122
123
 
123
- // src/classes/product_catalog.ts
124
+ // src/classes/pantry.ts
124
125
  var import_smol_toml = __toESM(require("smol-toml"), 1);
125
126
 
126
127
  // node_modules/.pnpm/human-regex@2.2.0/node_modules/human-regex/dist/human-regex.esm.js
@@ -329,7 +330,6 @@ var nestedMetaVarRegex = (varName) => new RegExp(
329
330
  "m"
330
331
  );
331
332
  var metadataRegex = d().literal("---").newline().startCaptureGroup().anyCharacter().zeroOrMore().optional().endGroup().newline().literal("---").dotAll().toRegExp();
332
- 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();
333
333
  var nonWordChar = "\\s@#~\\[\\]{(,;:!?";
334
334
  var nonWordCharStrict = "\\s@#~\\[\\]{(,;:!?|";
335
335
  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();
@@ -350,6 +350,13 @@ var tokensRegex = new RegExp(
350
350
  ].map((r2) => r2.source).join("|"),
351
351
  "gu"
352
352
  );
353
+ 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();
354
+ var servingsSuffixPart = d().anyOf(" ").zeroOrMore().startNamedGroup("servingsSuffix").nonWhitespace().startGroup().anyCharacter().zeroOrMore().nonWhitespace().endGroup().optional().endGroup().optional().anyOf(" ").zeroOrMore().endAnchor().toRegExp();
355
+ 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();
356
+ var scalingMetaValueWithUnitRegex = (varName) => new RegExp(
357
+ servingsPrefixPart(varName).source + arbitraryScalableRegex.source + servingsSuffixPart.source,
358
+ "m"
359
+ );
353
360
  var commentRegex = d().literal("--").anyCharacter().zeroOrMore().global().toRegExp();
354
361
  var blockCommentRegex = d().literal("[-").anyCharacter().zeroOrMore().lazy().literal("-]").whitespace().zeroOrMore().global().toRegExp();
355
362
  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();
@@ -1017,21 +1024,6 @@ function hasAlternatives(entry) {
1017
1024
  }
1018
1025
 
1019
1026
  // src/quantities/mutations.ts
1020
- function extendAllUnits(q) {
1021
- if (isAndGroup(q)) {
1022
- return { and: q.and.map(extendAllUnits) };
1023
- } else if (isOrGroup(q)) {
1024
- return { or: q.or.map(extendAllUnits) };
1025
- } else {
1026
- const newQ = {
1027
- quantity: q.quantity
1028
- };
1029
- if (q.unit) {
1030
- newQ.unit = { name: q.unit };
1031
- }
1032
- return newQ;
1033
- }
1034
- }
1035
1027
  function normalizeAllUnits(q) {
1036
1028
  if (isAndGroup(q)) {
1037
1029
  return { and: q.and.map(normalizeAllUnits) };
@@ -1296,30 +1288,51 @@ var flattenPlainUnitGroup = (summed) => {
1296
1288
  }
1297
1289
  } else if (isAndGroup(summed)) {
1298
1290
  const andEntries = [];
1291
+ const standaloneEntries = [];
1299
1292
  const equivalentsList = [];
1300
1293
  for (const entry of summed.and) {
1301
1294
  if (isOrGroup(entry)) {
1302
1295
  const orEntries = entry.or;
1303
- andEntries.push({
1304
- quantity: orEntries[0].quantity,
1305
- ...orEntries[0].unit && { unit: orEntries[0].unit }
1306
- });
1307
- equivalentsList.push(...orEntries.slice(1));
1308
- } else if (isQuantity(entry)) {
1309
- andEntries.push({
1310
- quantity: entry.quantity,
1311
- ...entry.unit && { unit: entry.unit }
1296
+ const firstEntry = orEntries[0];
1297
+ if (isAndGroup(firstEntry)) {
1298
+ for (const nestedEntry of firstEntry.and) {
1299
+ andEntries.push({
1300
+ quantity: nestedEntry.quantity,
1301
+ ...nestedEntry.unit && { unit: nestedEntry.unit }
1302
+ });
1303
+ }
1304
+ } else {
1305
+ const primary = firstEntry;
1306
+ andEntries.push({
1307
+ quantity: primary.quantity,
1308
+ ...primary.unit && { unit: primary.unit }
1309
+ });
1310
+ }
1311
+ const equivEntries = orEntries.slice(1).filter((e2) => isQuantity(e2));
1312
+ equivalentsList.push(
1313
+ ...equivEntries.map((e2) => ({
1314
+ quantity: e2.quantity,
1315
+ ...e2.unit && { unit: e2.unit }
1316
+ }))
1317
+ );
1318
+ } else {
1319
+ const simpleQuantityEntry = entry;
1320
+ standaloneEntries.push({
1321
+ quantity: simpleQuantityEntry.quantity,
1322
+ ...simpleQuantityEntry.unit && { unit: simpleQuantityEntry.unit }
1312
1323
  });
1313
1324
  }
1314
1325
  }
1315
1326
  if (equivalentsList.length === 0) {
1316
- return andEntries;
1327
+ return [...andEntries, ...standaloneEntries];
1317
1328
  }
1318
- const result = {
1329
+ const result = [];
1330
+ result.push({
1319
1331
  and: andEntries,
1320
1332
  equivalents: equivalentsList
1321
- };
1322
- return [result];
1333
+ });
1334
+ result.push(...standaloneEntries);
1335
+ return result;
1323
1336
  } else {
1324
1337
  return [
1325
1338
  { quantity: summed.quantity, ...summed.unit && { unit: summed.unit } }
@@ -1327,17 +1340,21 @@ var flattenPlainUnitGroup = (summed) => {
1327
1340
  }
1328
1341
  };
1329
1342
  function applyBestUnit(q, system) {
1330
- if (!q.unit?.name) {
1343
+ const extended = { quantity: q.quantity };
1344
+ if (q.unit) {
1345
+ extended.unit = typeof q.unit === "string" ? { name: q.unit } : q.unit;
1346
+ }
1347
+ if (!extended.unit?.name) {
1331
1348
  return q;
1332
1349
  }
1333
- const unitDef = resolveUnit(q.unit.name);
1350
+ const unitDef = resolveUnit(extended.unit.name);
1334
1351
  if (unitDef.type === "other") {
1335
1352
  return q;
1336
1353
  }
1337
- if (q.quantity.type === "fixed" && q.quantity.value.type === "text") {
1354
+ if (extended.quantity.type === "fixed" && extended.quantity.value.type === "text") {
1338
1355
  return q;
1339
1356
  }
1340
- const avgValue = getAverageValue(q.quantity);
1357
+ const avgValue = getAverageValue(extended.quantity);
1341
1358
  const effectiveSystem = system ?? (["metric", "JP"].includes(unitDef.system) ? unitDef.system : "US");
1342
1359
  const toBase = getToBase(unitDef, effectiveSystem);
1343
1360
  const valueInBase = avgValue * toBase;
@@ -1347,22 +1364,22 @@ function applyBestUnit(q, system) {
1347
1364
  effectiveSystem,
1348
1365
  [unitDef]
1349
1366
  );
1350
- const originalCanonicalName = normalizeUnit(q.unit.name)?.name;
1367
+ const originalCanonicalName = normalizeUnit(extended.unit.name)?.name;
1351
1368
  if (bestUnit.name === originalCanonicalName) {
1352
1369
  return q;
1353
1370
  }
1354
1371
  const formattedValue = formatOutputValue(bestValue, bestUnit);
1355
- if (q.quantity.type === "range") {
1372
+ if (extended.quantity.type === "range") {
1356
1373
  const bestToBase = getToBase(bestUnit, effectiveSystem);
1357
- const minValue = getNumericValue(q.quantity.min) * toBase / bestToBase;
1358
- const maxValue = getNumericValue(q.quantity.max) * toBase / bestToBase;
1374
+ const minValue = getNumericValue(extended.quantity.min) * toBase / bestToBase;
1375
+ const maxValue = getNumericValue(extended.quantity.max) * toBase / bestToBase;
1359
1376
  return {
1360
1377
  quantity: {
1361
1378
  type: "range",
1362
1379
  min: formatOutputValue(minValue, bestUnit),
1363
1380
  max: formatOutputValue(maxValue, bestUnit)
1364
1381
  },
1365
- unit: { name: bestUnit.name }
1382
+ unit: typeof q.unit === "string" ? bestUnit.name : { name: bestUnit.name }
1366
1383
  };
1367
1384
  }
1368
1385
  return {
@@ -1370,9 +1387,27 @@ function applyBestUnit(q, system) {
1370
1387
  type: "fixed",
1371
1388
  value: formattedValue
1372
1389
  },
1373
- unit: { name: bestUnit.name }
1390
+ unit: typeof q.unit === "string" ? bestUnit.name : { name: bestUnit.name }
1374
1391
  };
1375
1392
  }
1393
+ function subtractQuantities(q1, q2, options = {}) {
1394
+ const { clampToZero = true, system } = options;
1395
+ const negatedQ2 = {
1396
+ ...q2,
1397
+ quantity: multiplyQuantityValue(q2.quantity, -1)
1398
+ };
1399
+ const result = addQuantities(q1, negatedQ2, system);
1400
+ if (clampToZero) {
1401
+ const avg = getAverageValue(result.quantity);
1402
+ if (typeof avg === "number" && avg < 0) {
1403
+ return {
1404
+ quantity: { type: "fixed", value: { type: "decimal", decimal: 0 } },
1405
+ unit: result.unit
1406
+ };
1407
+ }
1408
+ }
1409
+ return result;
1410
+ }
1376
1411
 
1377
1412
  // src/utils/parser_helpers.ts
1378
1413
  function flushPendingNote(section, noteItems) {
@@ -1513,6 +1548,97 @@ function parseQuantityInput(input_str) {
1513
1548
  }
1514
1549
  return { type: "fixed", value: parseFixedValue(clean_str) };
1515
1550
  }
1551
+ function parseQuantityWithUnit(input) {
1552
+ const trimmed = input.trim();
1553
+ const separatorIndex = trimmed.indexOf("%");
1554
+ if (separatorIndex === -1) {
1555
+ return { value: parseQuantityInput(trimmed) };
1556
+ }
1557
+ const valuePart = trimmed.slice(0, separatorIndex).trim();
1558
+ const unitPart = trimmed.slice(separatorIndex + 1).trim();
1559
+ return {
1560
+ value: parseQuantityInput(valuePart),
1561
+ unit: unitPart || void 0
1562
+ };
1563
+ }
1564
+ function parseDateFromFormat(input, format) {
1565
+ const delimiterMatch = format.match(/[^A-Za-z]/);
1566
+ if (!delimiterMatch) {
1567
+ throw new Error(`Invalid date format: ${format}. No delimiter found.`);
1568
+ }
1569
+ const delimiter = delimiterMatch[0];
1570
+ const formatParts = format.split(delimiter);
1571
+ const inputParts = input.trim().split(delimiter);
1572
+ if (formatParts.length !== 3 || inputParts.length !== 3) {
1573
+ throw new Error(
1574
+ `Invalid date input "${input}" for format "${format}". Expected 3 parts.`
1575
+ );
1576
+ }
1577
+ let day = 0, month = 0, year = 0;
1578
+ for (let i2 = 0; i2 < 3; i2++) {
1579
+ const token = formatParts[i2].toUpperCase();
1580
+ const value = parseInt(inputParts[i2], 10);
1581
+ if (isNaN(value)) {
1582
+ throw new Error(
1583
+ `Invalid date input "${input}": non-numeric part "${inputParts[i2]}".`
1584
+ );
1585
+ }
1586
+ if (token === "DD") day = value;
1587
+ else if (token === "MM") month = value;
1588
+ else if (token === "YYYY") year = value;
1589
+ else
1590
+ throw new Error(
1591
+ `Unknown token "${formatParts[i2]}" in format "${format}"`
1592
+ );
1593
+ }
1594
+ const date = new Date(year, month - 1, day);
1595
+ if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
1596
+ throw new Error(`Invalid date: "${input}" does not form a valid date.`);
1597
+ }
1598
+ return date;
1599
+ }
1600
+ function disambiguateDayMonth(first, second, year) {
1601
+ if (second > 12 && first <= 12) {
1602
+ return [second, first, year];
1603
+ }
1604
+ return [first, second, year];
1605
+ }
1606
+ function parseFuzzyDate(input) {
1607
+ const trimmed = input.trim();
1608
+ const delimiterMatch = trimmed.match(/[./-]/);
1609
+ if (!delimiterMatch) {
1610
+ throw new Error(`Cannot parse date "${input}": no delimiter found.`);
1611
+ }
1612
+ const delimiter = delimiterMatch[0];
1613
+ const parts = trimmed.split(delimiter);
1614
+ if (parts.length !== 3) {
1615
+ throw new Error(
1616
+ `Cannot parse date "${input}": expected 3 parts, got ${parts.length}.`
1617
+ );
1618
+ }
1619
+ const nums = parts.map((p) => parseInt(p, 10));
1620
+ if (nums.some((n2) => isNaN(n2))) {
1621
+ throw new Error(`Cannot parse date "${input}": non-numeric parts found.`);
1622
+ }
1623
+ let day, month, year;
1624
+ if (nums[0] >= 1e3) {
1625
+ year = nums[0];
1626
+ month = nums[1];
1627
+ day = nums[2];
1628
+ } else if (nums[2] >= 1e3) {
1629
+ [day, month, year] = disambiguateDayMonth(nums[0], nums[1], nums[2]);
1630
+ } else {
1631
+ if (nums[2] >= 100)
1632
+ throw new Error(`Invalid date: "${input}" does not form a valid date.`);
1633
+ [day, month] = disambiguateDayMonth(nums[0], nums[1], 0);
1634
+ year = 2e3 + nums[2];
1635
+ }
1636
+ const date = new Date(year, month - 1, day);
1637
+ if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
1638
+ throw new Error(`Invalid date: "${input}" does not form a valid date.`);
1639
+ }
1640
+ return date;
1641
+ }
1516
1642
  function parseMarkdownSegments(text) {
1517
1643
  const items = [];
1518
1644
  let cursor = 0;
@@ -1613,13 +1739,62 @@ function parseBlockScalarMetaVar(content, varName) {
1613
1739
  }
1614
1740
  return stripped.replace(/\n\n/g, "\0").replace(/\n/g, " ").replace(/\0/g, "\n");
1615
1741
  }
1742
+ function parseArbitraryQuantity(raw) {
1743
+ const quantityMatch = raw.trim().match(quantityAlternativeRegex);
1744
+ if (!quantityMatch?.groups) {
1745
+ throw new InvalidQuantityFormat(
1746
+ raw,
1747
+ "Arbitrary quantities must have a numerical value"
1748
+ );
1749
+ }
1750
+ const value = parseQuantityInput(quantityMatch.groups.quantity);
1751
+ const unit = quantityMatch.groups.unit;
1752
+ if (!value || value.type === "fixed" && value.value.type === "text") {
1753
+ throw new InvalidQuantityFormat(
1754
+ raw,
1755
+ "Arbitrary quantities must have a numerical value"
1756
+ );
1757
+ }
1758
+ const arbitrary = {
1759
+ quantity: value
1760
+ };
1761
+ if (unit) arbitrary.unit = unit;
1762
+ return arbitrary;
1763
+ }
1616
1764
  function parseScalingMetaVar(content, varName) {
1617
- const varMatch = content.match(scalingMetaValueRegex(varName));
1765
+ const complexMatch = content.match(scalingMetaValueWithUnitRegex(varName));
1766
+ if (complexMatch?.groups?.arbitraryQuantity) {
1767
+ const parsed = parseArbitraryQuantity(
1768
+ complexMatch.groups.arbitraryQuantity
1769
+ );
1770
+ const result2 = {
1771
+ quantity: parsed.quantity
1772
+ };
1773
+ if (parsed.unit) result2.unit = parsed.unit;
1774
+ if (complexMatch.groups.servingsPrefix) {
1775
+ result2.textBefore = complexMatch.groups.servingsPrefix;
1776
+ }
1777
+ if (complexMatch.groups.servingsSuffix) {
1778
+ result2.textAfter = complexMatch.groups.servingsSuffix;
1779
+ }
1780
+ return result2;
1781
+ }
1782
+ const varMatch = content.match(scalingSimpleMetaValueRegex(varName));
1618
1783
  if (!varMatch) return void 0;
1619
1784
  if (isNaN(Number(varMatch[2]?.trim()))) {
1620
1785
  throw new Error("Scaling variables should be numbers");
1621
1786
  }
1622
- return [Number(varMatch[2]?.trim()), varMatch[1].trim()];
1787
+ const numericValue = Number(varMatch[2]?.trim());
1788
+ const result = {
1789
+ quantity: {
1790
+ type: "fixed",
1791
+ value: { type: "decimal", decimal: numericValue }
1792
+ }
1793
+ };
1794
+ if (varMatch[3]) {
1795
+ result.text = `${varMatch[3].trim()}`;
1796
+ }
1797
+ return result;
1623
1798
  }
1624
1799
  function parseListMetaVar(content, varName) {
1625
1800
  const listMatch = content.match(
@@ -1737,6 +1912,13 @@ function parseAnyMetaVar(content, varName) {
1737
1912
  if (simple) return parseMetadataValue(simple);
1738
1913
  return void 0;
1739
1914
  }
1915
+ function getNumericValueFromMetaVar(v) {
1916
+ if (v.quantity.type === "fixed" && v.quantity.value.type !== "text") {
1917
+ return getNumericValue(v.quantity.value);
1918
+ }
1919
+ if (v.quantity.type === "range") return getNumericValue(v.quantity.min);
1920
+ return 0;
1921
+ }
1740
1922
  function extractMetadata(content) {
1741
1923
  const metadata = {};
1742
1924
  let servings = void 0;
@@ -1861,9 +2043,9 @@ function extractMetadata(content) {
1861
2043
  }
1862
2044
  for (const metaVar of ["servings", "yield", "serves"]) {
1863
2045
  const scalingMetaValue = parseScalingMetaVar(metadataContent, metaVar);
1864
- if (scalingMetaValue && scalingMetaValue[1]) {
1865
- metadata[metaVar] = scalingMetaValue[1];
1866
- servings = scalingMetaValue[0];
2046
+ if (scalingMetaValue) {
2047
+ metadata[metaVar] = scalingMetaValue;
2048
+ servings = getNumericValueFromMetaVar(scalingMetaValue);
1867
2049
  }
1868
2050
  }
1869
2051
  const tags = parseListMetaVar(metadataContent, "tags");
@@ -1893,7 +2075,227 @@ function getAlternativeSignature(alternatives) {
1893
2075
  return alternatives.map((a2) => a2.index).sort((a2, b) => a2 - b).join(",");
1894
2076
  }
1895
2077
 
2078
+ // src/classes/pantry.ts
2079
+ var Pantry = class {
2080
+ /**
2081
+ * Creates a new Pantry instance.
2082
+ * @param tomlContent - Optional TOML content to parse.
2083
+ * @param options - Optional configuration options.
2084
+ */
2085
+ constructor(tomlContent, options = {}) {
2086
+ /**
2087
+ * The parsed pantry items.
2088
+ */
2089
+ __publicField(this, "items", []);
2090
+ /**
2091
+ * Options for date parsing and other configuration.
2092
+ */
2093
+ __publicField(this, "options");
2094
+ /**
2095
+ * Optional category configuration for alias-based lookups.
2096
+ */
2097
+ __publicField(this, "categoryConfig");
2098
+ this.options = options;
2099
+ if (tomlContent) {
2100
+ this.parse(tomlContent);
2101
+ }
2102
+ }
2103
+ /**
2104
+ * Parses a TOML string into pantry items.
2105
+ * @param tomlContent - The TOML string to parse.
2106
+ * @returns The parsed list of pantry items.
2107
+ */
2108
+ parse(tomlContent) {
2109
+ const raw = import_smol_toml.default.parse(tomlContent);
2110
+ this.items = [];
2111
+ for (const [location, locationData] of Object.entries(raw)) {
2112
+ const locationTable = locationData;
2113
+ for (const [itemName, itemData] of Object.entries(locationTable)) {
2114
+ const item = this.parseItem(
2115
+ itemName,
2116
+ location,
2117
+ itemData
2118
+ );
2119
+ this.items.push(item);
2120
+ }
2121
+ }
2122
+ return this.items;
2123
+ }
2124
+ /**
2125
+ * Parses a single pantry item from its TOML representation.
2126
+ */
2127
+ parseItem(name, location, data) {
2128
+ const item = { name, location };
2129
+ if (typeof data === "string") {
2130
+ const parsed = parseQuantityWithUnit(data);
2131
+ item.quantity = parsed.value;
2132
+ if (parsed.unit) item.unit = parsed.unit;
2133
+ } else {
2134
+ if (data.quantity) {
2135
+ const parsed = parseQuantityWithUnit(data.quantity);
2136
+ item.quantity = parsed.value;
2137
+ if (parsed.unit) item.unit = parsed.unit;
2138
+ }
2139
+ if (data.low) {
2140
+ const parsed = parseQuantityWithUnit(data.low);
2141
+ item.low = parsed.value;
2142
+ if (parsed.unit) item.lowUnit = parsed.unit;
2143
+ }
2144
+ if (data.bought) {
2145
+ item.bought = this.parseDate(data.bought);
2146
+ }
2147
+ if (data.expire) {
2148
+ item.expire = this.parseDate(data.expire);
2149
+ }
2150
+ }
2151
+ return item;
2152
+ }
2153
+ /**
2154
+ * Parses a date string using the configured format or fuzzy detection.
2155
+ */
2156
+ parseDate(input) {
2157
+ if (this.options.dateFormat) {
2158
+ return parseDateFromFormat(input, this.options.dateFormat);
2159
+ }
2160
+ return parseFuzzyDate(input);
2161
+ }
2162
+ /**
2163
+ * Sets a category configuration for alias-based item lookups.
2164
+ * @param config - The category configuration to use.
2165
+ */
2166
+ setCategoryConfig(config) {
2167
+ this.categoryConfig = config;
2168
+ }
2169
+ /**
2170
+ * Finds a pantry item by name, using exact match first, then alias lookup
2171
+ * via the stored CategoryConfig.
2172
+ * @param name - The name to search for.
2173
+ * @returns The matching pantry item, or undefined if not found.
2174
+ */
2175
+ findItem(name) {
2176
+ const lowerName = name.toLowerCase();
2177
+ const exact = this.items.find(
2178
+ (item) => item.name.toLowerCase() === lowerName
2179
+ );
2180
+ if (exact) return exact;
2181
+ if (this.categoryConfig) {
2182
+ for (const category of this.categoryConfig.categories) {
2183
+ for (const catIngredient of category.ingredients) {
2184
+ if (catIngredient.aliases.some(
2185
+ (alias) => alias.toLowerCase() === lowerName
2186
+ )) {
2187
+ const canonicalName = catIngredient.name.toLowerCase();
2188
+ const byCanonical = this.items.find(
2189
+ (item) => item.name.toLowerCase() === canonicalName
2190
+ );
2191
+ if (byCanonical) return byCanonical;
2192
+ for (const alias of catIngredient.aliases) {
2193
+ const byAlias = this.items.find(
2194
+ (item) => item.name.toLowerCase() === alias.toLowerCase()
2195
+ );
2196
+ if (byAlias) return byAlias;
2197
+ }
2198
+ }
2199
+ }
2200
+ }
2201
+ }
2202
+ return void 0;
2203
+ }
2204
+ /**
2205
+ * Gets the numeric value of a pantry item's quantity, optionally converted to base units.
2206
+ * Returns undefined if the quantity has a text value or is not set.
2207
+ */
2208
+ getItemNumericValue(quantity, unit) {
2209
+ if (!quantity) return void 0;
2210
+ let numericValue;
2211
+ if (quantity.type === "fixed") {
2212
+ if (quantity.value.type === "text") return void 0;
2213
+ numericValue = getNumericValue(quantity.value);
2214
+ } else {
2215
+ numericValue = (getNumericValue(quantity.min) + getNumericValue(quantity.max)) / 2;
2216
+ }
2217
+ if (unit) {
2218
+ const unitDef = normalizeUnit(unit);
2219
+ if (unitDef) {
2220
+ const toBase = getToBase(unitDef);
2221
+ numericValue *= toBase;
2222
+ }
2223
+ }
2224
+ return numericValue;
2225
+ }
2226
+ /**
2227
+ * Returns all items that are depleted (quantity = 0) or below their low threshold.
2228
+ * @returns An array of depleted pantry items.
2229
+ */
2230
+ getDepletedItems() {
2231
+ return this.items.filter((item) => this.isItemLow(item));
2232
+ }
2233
+ /**
2234
+ * Returns all items whose expiration date is within `nbDays` days from today
2235
+ * (or already passed).
2236
+ * @param nbDays - Number of days ahead to check. Defaults to 0 (already expired).
2237
+ * @returns An array of expired pantry items.
2238
+ */
2239
+ getExpiredItems(nbDays = 0) {
2240
+ return this.items.filter((item) => this.isItemExpired(item, nbDays));
2241
+ }
2242
+ /**
2243
+ * Checks if a specific item is low (quantity = 0 or below `low` threshold).
2244
+ * @param itemName - The name of the item to check (supports aliases if CategoryConfig is set).
2245
+ * @returns true if the item is low, false otherwise. Returns false if item not found.
2246
+ */
2247
+ isLow(itemName) {
2248
+ const item = this.findItem(itemName);
2249
+ if (!item) return false;
2250
+ return this.isItemLow(item);
2251
+ }
2252
+ /**
2253
+ * Checks if a specific item is expired or expires within `nbDays` days.
2254
+ * @param itemName - The name of the item to check (supports aliases if CategoryConfig is set).
2255
+ * @param nbDays - Number of days ahead to check. Defaults to 0.
2256
+ * @returns true if the item is expired, false otherwise. Returns false if item not found.
2257
+ */
2258
+ isExpired(itemName, nbDays = 0) {
2259
+ const item = this.findItem(itemName);
2260
+ if (!item) return false;
2261
+ return this.isItemExpired(item, nbDays);
2262
+ }
2263
+ /**
2264
+ * Internal: checks if a pantry item is low.
2265
+ */
2266
+ isItemLow(item) {
2267
+ if (!item.quantity) return false;
2268
+ const qtyValue = this.getItemNumericValue(item.quantity, item.unit);
2269
+ if (qtyValue === void 0) return false;
2270
+ if (qtyValue === 0) return true;
2271
+ if (item.low) {
2272
+ const lowValue = this.getItemNumericValue(item.low, item.lowUnit);
2273
+ if (lowValue !== void 0 && qtyValue <= lowValue) return true;
2274
+ }
2275
+ return false;
2276
+ }
2277
+ /**
2278
+ * Internal: checks if a pantry item is expired.
2279
+ */
2280
+ isItemExpired(item, nbDays) {
2281
+ if (!item.expire) return false;
2282
+ const now = /* @__PURE__ */ new Date();
2283
+ const cutoff = new Date(
2284
+ now.getFullYear(),
2285
+ now.getMonth(),
2286
+ now.getDate() + nbDays
2287
+ );
2288
+ const expireDay = new Date(
2289
+ item.expire.getFullYear(),
2290
+ item.expire.getMonth(),
2291
+ item.expire.getDate()
2292
+ );
2293
+ return expireDay <= cutoff;
2294
+ }
2295
+ };
2296
+
1896
2297
  // src/classes/product_catalog.ts
2298
+ var import_smol_toml2 = __toESM(require("smol-toml"), 1);
1897
2299
  var ProductCatalog = class {
1898
2300
  constructor(tomlContent) {
1899
2301
  __publicField(this, "products", []);
@@ -1905,7 +2307,7 @@ var ProductCatalog = class {
1905
2307
  * @returns A parsed list of `ProductOption`.
1906
2308
  */
1907
2309
  parse(tomlContent) {
1908
- const catalogRaw = import_smol_toml.default.parse(tomlContent);
2310
+ const catalogRaw = import_smol_toml2.default.parse(tomlContent);
1909
2311
  this.products = [];
1910
2312
  if (!this.isValidTomlContent(catalogRaw)) {
1911
2313
  throw new InvalidProductCatalogFormat();
@@ -1978,7 +2380,7 @@ var ProductCatalog = class {
1978
2380
  size: sizeStrings.length === 1 ? sizeStrings[0] : sizeStrings
1979
2381
  };
1980
2382
  }
1981
- return import_smol_toml.default.stringify(grouped);
2383
+ return import_smol_toml2.default.stringify(grouped);
1982
2384
  }
1983
2385
  /**
1984
2386
  * Adds a product to the catalog.
@@ -2115,7 +2517,6 @@ function getEquivalentUnitsLists(...quantities) {
2115
2517
  const OrGroups = quantitiesCopy.filter(isOrGroup).filter((q) => q.or.length > 1);
2116
2518
  const unitLists = [];
2117
2519
  const normalizeOrGroup = (og) => ({
2118
- ...og,
2119
2520
  or: og.or.map((q) => ({
2120
2521
  ...q,
2121
2522
  unit: resolveUnit(q.unit?.name, q.unit?.integerProtected)
@@ -2471,27 +2872,17 @@ var _Recipe = class _Recipe {
2471
2872
  */
2472
2873
  _parseArbitraryScalable(regexMatchGroups, intoArray) {
2473
2874
  if (!regexMatchGroups || !regexMatchGroups.arbitraryQuantity) return;
2474
- const quantityMatch = regexMatchGroups.arbitraryQuantity?.trim().match(quantityAlternativeRegex);
2475
- if (quantityMatch?.groups) {
2476
- const value = parseQuantityInput(quantityMatch.groups.quantity);
2477
- const unit = quantityMatch.groups.unit;
2478
- const name = regexMatchGroups.arbitraryName || void 0;
2479
- if (!value || value.type === "fixed" && value.value.type === "text") {
2480
- throw new InvalidQuantityFormat(
2481
- regexMatchGroups.arbitraryQuantity?.trim(),
2482
- "Arbitrary quantities must have a numerical value"
2483
- );
2484
- }
2485
- const arbitrary = {
2486
- quantity: value
2487
- };
2488
- if (name) arbitrary.name = name;
2489
- if (unit) arbitrary.unit = unit;
2490
- intoArray.push({
2491
- type: "arbitrary",
2492
- index: this.arbitraries.push(arbitrary) - 1
2493
- });
2494
- }
2875
+ const parsed = parseArbitraryQuantity(regexMatchGroups.arbitraryQuantity);
2876
+ const name = regexMatchGroups.arbitraryName || void 0;
2877
+ const arbitrary = {
2878
+ quantity: parsed.quantity
2879
+ };
2880
+ if (name) arbitrary.name = name;
2881
+ if (parsed.unit) arbitrary.unit = parsed.unit;
2882
+ intoArray.push({
2883
+ type: "arbitrary",
2884
+ index: this.arbitraries.push(arbitrary) - 1
2885
+ });
2495
2886
  }
2496
2887
  /**
2497
2888
  * Parses text for arbitrary scalables and returns NoteItem array.
@@ -2796,34 +3187,10 @@ var _Recipe = class _Recipe {
2796
3187
  }
2797
3188
  }
2798
3189
  }
2799
- /**
2800
- * Gets ingredients with their quantities populated, optionally filtered by section/step
2801
- * and respecting user choices for alternatives.
2802
- *
2803
- * When no options are provided, returns all recipe ingredients with quantities
2804
- * calculated using primary alternatives (same as after parsing).
2805
- *
2806
- * @param options - Options for filtering and choice selection:
2807
- * - `section`: Filter to a specific section (Section object or 0-based index)
2808
- * - `step`: Filter to a specific step (Step object or 0-based index)
2809
- * - `choices`: Choices for alternative ingredients (defaults to primary)
2810
- * @returns Array of Ingredient objects with quantities populated
2811
- *
2812
- * @example
2813
- * ```typescript
2814
- * // Get all ingredients with primary alternatives
2815
- * const ingredients = recipe.getIngredientQuantities();
2816
- *
2817
- * // Get ingredients for a specific section
2818
- * const sectionIngredients = recipe.getIngredientQuantities({ section: 0 });
2819
- *
2820
- * // Get ingredients with specific choices applied
2821
- * const withChoices = recipe.getIngredientQuantities({
2822
- * choices: { ingredientItems: new Map([['ingredient-item-2', 1]]) }
2823
- * });
2824
- * ```
2825
- */
2826
- getIngredientQuantities(options) {
3190
+ // Type for accumulated quantities (used internally by collectQuantityGroups)
3191
+ // Defined as a static type alias for the private method's return type
3192
+ /** @internal */
3193
+ collectQuantityGroups(options) {
2827
3194
  const { section, step, choices } = options || {};
2828
3195
  const sectionsToProcess = section !== void 0 ? (() => {
2829
3196
  const idx = typeof section === "number" ? section : this.sections.indexOf(section);
@@ -2844,8 +3211,8 @@ var _Recipe = class _Recipe {
2844
3211
  const isGrouped = "group" in item && item.group !== void 0;
2845
3212
  const groupAlternatives = isGrouped ? this.choices.ingredientGroups.get(item.group) : void 0;
2846
3213
  let selectedAltIndex = 0;
2847
- let isSelected = false;
2848
- let hasExplicitChoice = false;
3214
+ let isSelected;
3215
+ let hasExplicitChoice;
2849
3216
  if (isGrouped) {
2850
3217
  const groupChoice = choices?.ingredientGroups?.get(item.group);
2851
3218
  hasExplicitChoice = groupChoice !== void 0;
@@ -2941,6 +3308,78 @@ var _Recipe = class _Recipe {
2941
3308
  }
2942
3309
  }
2943
3310
  }
3311
+ return { ingredientGroups, selectedIndices, referencedIndices };
3312
+ }
3313
+ /**
3314
+ * Gets the raw (unprocessed) quantity groups for each ingredient, before
3315
+ * any summation or equivalents simplification. This is useful for cross-recipe
3316
+ * aggregation (e.g., in {@link ShoppingList}), where quantities from multiple
3317
+ * recipes should be combined before processing.
3318
+ *
3319
+ * @param options - Options for filtering and choice selection (same as {@link getIngredientQuantities}).
3320
+ * @returns Array of {@link RawQuantityGroup} objects, one per ingredient with quantities.
3321
+ *
3322
+ * @example
3323
+ * ```typescript
3324
+ * const rawGroups = recipe.getRawQuantityGroups();
3325
+ * // Each group has: name, usedAsPrimary, flags, quantities[]
3326
+ * // quantities are the raw QuantityWithExtendedUnit or FlatOrGroup entries
3327
+ * ```
3328
+ */
3329
+ getRawQuantityGroups(options) {
3330
+ const { ingredientGroups, selectedIndices, referencedIndices } = this.collectQuantityGroups(options);
3331
+ const result = [];
3332
+ for (let index = 0; index < this.ingredients.length; index++) {
3333
+ if (!referencedIndices.has(index)) continue;
3334
+ const orig = this.ingredients[index];
3335
+ const usedAsPrimary = selectedIndices.has(index);
3336
+ const quantities = [];
3337
+ if (usedAsPrimary) {
3338
+ const groupsForIng = ingredientGroups.get(index);
3339
+ if (groupsForIng) {
3340
+ for (const [, group] of groupsForIng) {
3341
+ quantities.push(...group.quantities);
3342
+ }
3343
+ }
3344
+ }
3345
+ result.push({
3346
+ name: orig.name,
3347
+ ...usedAsPrimary && { usedAsPrimary: true },
3348
+ ...orig.flags && { flags: orig.flags },
3349
+ quantities
3350
+ });
3351
+ }
3352
+ return result;
3353
+ }
3354
+ /**
3355
+ * Gets ingredients with their quantities populated, optionally filtered by section/step
3356
+ * and respecting user choices for alternatives.
3357
+ *
3358
+ * When no options are provided, returns all recipe ingredients with quantities
3359
+ * calculated using primary alternatives (same as after parsing).
3360
+ *
3361
+ * @param options - Options for filtering and choice selection:
3362
+ * - `section`: Filter to a specific section (Section object or 0-based index)
3363
+ * - `step`: Filter to a specific step (Step object or 0-based index)
3364
+ * - `choices`: Choices for alternative ingredients (defaults to primary)
3365
+ * @returns Array of Ingredient objects with quantities populated
3366
+ *
3367
+ * @example
3368
+ * ```typescript
3369
+ * // Get all ingredients with primary alternatives
3370
+ * const ingredients = recipe.getIngredientQuantities();
3371
+ *
3372
+ * // Get ingredients for a specific section
3373
+ * const sectionIngredients = recipe.getIngredientQuantities({ section: 0 });
3374
+ *
3375
+ * // Get ingredients with specific choices applied
3376
+ * const withChoices = recipe.getIngredientQuantities({
3377
+ * choices: { ingredientItems: new Map([['ingredient-item-2', 1]]) }
3378
+ * });
3379
+ * ```
3380
+ */
3381
+ getIngredientQuantities(options) {
3382
+ const { ingredientGroups, selectedIndices, referencedIndices } = this.collectQuantityGroups(options);
2944
3383
  const result = [];
2945
3384
  for (let index = 0; index < this.ingredients.length; index++) {
2946
3385
  if (!referencedIndices.has(index)) continue;
@@ -3233,37 +3672,34 @@ var _Recipe = class _Recipe {
3233
3672
  arbitrary.quantity,
3234
3673
  factor
3235
3674
  );
3675
+ const optimized = applyBestUnit(
3676
+ { quantity: arbitrary.quantity, unit: arbitrary.unit },
3677
+ unitSystem
3678
+ );
3679
+ arbitrary.quantity = optimized.quantity;
3680
+ arbitrary.unit = optimized.unit;
3236
3681
  }
3237
3682
  newRecipe._populateIngredientQuantities();
3238
3683
  newRecipe.servings = (0, import_big4.default)(originalServings).times(factor).toNumber();
3239
- if (newRecipe.metadata.servings && this.metadata.servings) {
3240
- if (floatRegex.test(String(this.metadata.servings).replace(",", ".").trim())) {
3241
- const servingsValue = parseFloat(
3242
- String(this.metadata.servings).replace(",", ".")
3243
- );
3244
- newRecipe.metadata.servings = String(
3245
- (0, import_big4.default)(servingsValue).times(factor).toNumber()
3246
- );
3247
- }
3248
- }
3249
- if (newRecipe.metadata.yield && this.metadata.yield) {
3250
- if (floatRegex.test(String(this.metadata.yield).replace(",", ".").trim())) {
3251
- const yieldValue = parseFloat(
3252
- String(this.metadata.yield).replace(",", ".")
3684
+ for (const metaVar of ["servings", "yield", "serves"]) {
3685
+ if (newRecipe.metadata[metaVar] && this.metadata[metaVar]) {
3686
+ const original = this.metadata[metaVar];
3687
+ const scaledQuantity = multiplyQuantityValue(
3688
+ original.quantity,
3689
+ factor
3253
3690
  );
3254
- newRecipe.metadata.yield = String(
3255
- (0, import_big4.default)(yieldValue).times(factor).toNumber()
3256
- );
3257
- }
3258
- }
3259
- if (newRecipe.metadata.serves && this.metadata.serves) {
3260
- if (floatRegex.test(String(this.metadata.serves).replace(",", ".").trim())) {
3261
- const servesValue = parseFloat(
3262
- String(this.metadata.serves).replace(",", ".")
3263
- );
3264
- newRecipe.metadata.serves = String(
3265
- (0, import_big4.default)(servesValue).times(factor).toNumber()
3691
+ const optimized = applyBestUnit(
3692
+ { quantity: scaledQuantity, unit: original.unit },
3693
+ unitSystem
3266
3694
  );
3695
+ const scaled = {
3696
+ quantity: optimized.quantity
3697
+ };
3698
+ if (optimized.unit) scaled.unit = optimized.unit;
3699
+ if (original.textBefore) scaled.textBefore = original.textBefore;
3700
+ if (original.textAfter) scaled.textAfter = original.textAfter;
3701
+ if (original.text) scaled.text = original.text;
3702
+ newRecipe.metadata[metaVar] = scaled;
3267
3703
  }
3268
3704
  }
3269
3705
  return newRecipe;
@@ -3469,13 +3905,12 @@ __publicField(_Recipe, "itemCounts", /* @__PURE__ */ new WeakMap());
3469
3905
  var Recipe = _Recipe;
3470
3906
 
3471
3907
  // src/classes/shopping_list.ts
3472
- var ShoppingList = class {
3908
+ var ShoppingList = class _ShoppingList {
3473
3909
  /**
3474
3910
  * Creates a new ShoppingList instance
3475
3911
  * @param categoryConfigStr - The category configuration to parse.
3476
3912
  */
3477
3913
  constructor(categoryConfigStr) {
3478
- // TODO: backport type change
3479
3914
  /**
3480
3915
  * The ingredients in the shopping list.
3481
3916
  */
@@ -3492,38 +3927,38 @@ var ShoppingList = class {
3492
3927
  * The categorized ingredients in the shopping list.
3493
3928
  */
3494
3929
  __publicField(this, "categories");
3930
+ /**
3931
+ * The unit system to use for quantity simplification.
3932
+ * When set, overrides per-recipe unit systems.
3933
+ */
3934
+ __publicField(this, "unitSystem");
3935
+ /**
3936
+ * Per-ingredient equivalence ratio maps for recomputing equivalents
3937
+ * after pantry subtraction. Keyed by ingredient name.
3938
+ * @internal
3939
+ */
3940
+ __publicField(this, "equivalenceRatios", /* @__PURE__ */ new Map());
3941
+ /**
3942
+ * The original pantry (never mutated by recipe calculations).
3943
+ */
3944
+ __publicField(this, "pantry");
3945
+ /**
3946
+ * The pantry with quantities updated after subtracting recipe needs.
3947
+ * Recomputed on every {@link ShoppingList.calculateIngredients | calculateIngredients()} call.
3948
+ */
3949
+ __publicField(this, "resultingPantry");
3495
3950
  if (categoryConfigStr) {
3496
3951
  this.setCategoryConfig(categoryConfigStr);
3497
3952
  }
3498
3953
  }
3499
3954
  calculateIngredients() {
3500
3955
  this.ingredients = [];
3501
- const addIngredientQuantity = (name, quantityTotal) => {
3502
- const quantityTotalExtended = extendAllUnits(quantityTotal);
3503
- const newQuantities = isAndGroup(quantityTotalExtended) ? quantityTotalExtended.and : [quantityTotalExtended];
3504
- const existing = this.ingredients.find((i2) => i2.name === name);
3505
- if (existing) {
3506
- if (!existing.quantityTotal) {
3507
- existing.quantityTotal = quantityTotal;
3508
- return;
3509
- }
3510
- try {
3511
- const existingQuantityTotalExtended = extendAllUnits(
3512
- existing.quantityTotal
3513
- );
3514
- const existingQuantities = isAndGroup(existingQuantityTotalExtended) ? existingQuantityTotalExtended.and : [existingQuantityTotalExtended];
3515
- existing.quantityTotal = addEquivalentsAndSimplify([
3516
- ...existingQuantities,
3517
- ...newQuantities
3518
- ]);
3519
- return;
3520
- } catch {
3521
- }
3956
+ const rawQuantitiesMap = /* @__PURE__ */ new Map();
3957
+ const nameOrder = [];
3958
+ const trackName = (name) => {
3959
+ if (!nameOrder.includes(name)) {
3960
+ nameOrder.push(name);
3522
3961
  }
3523
- this.ingredients.push({
3524
- name,
3525
- quantityTotal
3526
- });
3527
3962
  };
3528
3963
  for (const addedRecipe of this.recipes) {
3529
3964
  let scaledRecipe;
@@ -3533,48 +3968,253 @@ var ShoppingList = class {
3533
3968
  } else {
3534
3969
  scaledRecipe = addedRecipe.recipe.scaleTo(addedRecipe.servings);
3535
3970
  }
3536
- const ingredients = scaledRecipe.getIngredientQuantities({
3971
+ const rawGroups = scaledRecipe.getRawQuantityGroups({
3537
3972
  choices: addedRecipe.choices
3538
3973
  });
3539
- for (const ingredient of ingredients) {
3540
- if (ingredient.flags && ingredient.flags.includes("hidden")) {
3974
+ for (const group of rawGroups) {
3975
+ if (group.flags?.includes("hidden") || !group.usedAsPrimary) {
3541
3976
  continue;
3542
3977
  }
3543
- if (!ingredient.usedAsPrimary) {
3544
- continue;
3978
+ trackName(group.name);
3979
+ if (group.quantities.length > 0) {
3980
+ const existing = rawQuantitiesMap.get(group.name) ?? [];
3981
+ existing.push(...group.quantities);
3982
+ rawQuantitiesMap.set(group.name, existing);
3983
+ }
3984
+ }
3985
+ }
3986
+ this.equivalenceRatios.clear();
3987
+ for (const name of nameOrder) {
3988
+ const rawQuantities = rawQuantitiesMap.get(name);
3989
+ if (!rawQuantities || rawQuantities.length === 0) {
3990
+ this.ingredients.push({ name });
3991
+ continue;
3992
+ }
3993
+ const textEntries = [];
3994
+ const numericEntries = [];
3995
+ for (const q of rawQuantities) {
3996
+ if ("quantity" in q && q.quantity.type === "fixed" && q.quantity.value.type === "text") {
3997
+ textEntries.push(q);
3998
+ } else {
3999
+ numericEntries.push(q);
3545
4000
  }
3546
- if (ingredient.quantities && ingredient.quantities.length > 0) {
3547
- const allQuantities = [];
3548
- for (const qGroup of ingredient.quantities) {
3549
- if ("and" in qGroup) {
3550
- for (const qty of qGroup.and) {
3551
- allQuantities.push(qty);
4001
+ }
4002
+ if (numericEntries.length > 1) {
4003
+ const ratioMap = _ShoppingList.buildEquivalenceRatioMap(
4004
+ getEquivalentUnitsLists(...numericEntries)
4005
+ );
4006
+ if (Object.keys(ratioMap).length > 0) {
4007
+ this.equivalenceRatios.set(name, ratioMap);
4008
+ }
4009
+ }
4010
+ const resultQuantities = [];
4011
+ for (const t2 of textEntries) {
4012
+ resultQuantities.push(toPlainUnit(t2));
4013
+ }
4014
+ if (numericEntries.length > 0) {
4015
+ resultQuantities.push(
4016
+ ...flattenPlainUnitGroup(
4017
+ addEquivalentsAndSimplify(numericEntries, this.unitSystem)
4018
+ )
4019
+ );
4020
+ }
4021
+ this.ingredients.push({
4022
+ name,
4023
+ quantities: resultQuantities
4024
+ });
4025
+ }
4026
+ this.applyPantrySubtraction();
4027
+ }
4028
+ /**
4029
+ * Subtracts pantry item quantities from calculated ingredient quantities
4030
+ * and updates the resultingPantry to reflect consumed stock.
4031
+ */
4032
+ applyPantrySubtraction() {
4033
+ if (!this.pantry) {
4034
+ this.resultingPantry = void 0;
4035
+ return;
4036
+ }
4037
+ const clonedPantry = new Pantry();
4038
+ clonedPantry.items = deepClone(this.pantry.items);
4039
+ if (this.categoryConfig) {
4040
+ clonedPantry.setCategoryConfig(this.categoryConfig);
4041
+ }
4042
+ for (const ingredient of this.ingredients) {
4043
+ if (!ingredient.quantities || ingredient.quantities.length === 0)
4044
+ continue;
4045
+ const pantryItem = clonedPantry.findItem(ingredient.name);
4046
+ if (!pantryItem || !pantryItem.quantity) continue;
4047
+ let pantryExtended = {
4048
+ quantity: pantryItem.quantity,
4049
+ ...pantryItem.unit && { unit: { name: pantryItem.unit } }
4050
+ };
4051
+ for (let i2 = 0; i2 < ingredient.quantities.length; i2++) {
4052
+ const entry = ingredient.quantities[i2];
4053
+ const leaves = "and" in entry ? entry.and : [entry];
4054
+ for (const leaf of leaves) {
4055
+ const ingredientExtended = toExtendedUnit(leaf);
4056
+ const leafHasUnit = leaf.unit !== void 0 && leaf.unit !== "";
4057
+ const pantryHasUnit = pantryExtended.unit !== void 0 && pantryExtended.unit.name !== "";
4058
+ const ratioMap = this.equivalenceRatios.get(ingredient.name);
4059
+ const unitMismatch = leafHasUnit !== pantryHasUnit && ratioMap !== void 0;
4060
+ if (unitMismatch) {
4061
+ const leafUnit = leaf.unit ?? NO_UNIT;
4062
+ const pantryUnit = pantryExtended.unit?.name ?? NO_UNIT;
4063
+ const ratioFromPantry = ratioMap[leafUnit]?.[pantryUnit];
4064
+ if (ratioFromPantry !== void 0) {
4065
+ const pantryValue = getAverageValue(pantryExtended.quantity);
4066
+ const leafValue = getAverageValue(ingredientExtended.quantity);
4067
+ if (typeof pantryValue === "number" && typeof leafValue === "number") {
4068
+ const pantryInLeafUnits = pantryValue * ratioFromPantry;
4069
+ const subtracted = Math.min(pantryInLeafUnits, leafValue);
4070
+ const remainingLeafValue = Math.max(
4071
+ leafValue - pantryInLeafUnits,
4072
+ 0
4073
+ );
4074
+ leaf.quantity = {
4075
+ type: "fixed",
4076
+ value: { type: "decimal", decimal: remainingLeafValue }
4077
+ };
4078
+ const consumedInPantryUnits = ratioFromPantry !== 0 ? subtracted / ratioFromPantry : pantryValue;
4079
+ const remainingPantryValue = Math.max(
4080
+ pantryValue - consumedInPantryUnits,
4081
+ 0
4082
+ );
4083
+ pantryExtended = {
4084
+ quantity: {
4085
+ type: "fixed",
4086
+ value: {
4087
+ type: "decimal",
4088
+ decimal: remainingPantryValue
4089
+ }
4090
+ },
4091
+ ...pantryExtended.unit && { unit: pantryExtended.unit }
4092
+ };
4093
+ continue;
3552
4094
  }
3553
- } else {
3554
- const plainQty = {
3555
- quantity: qGroup.quantity
3556
- };
3557
- if (qGroup.unit) plainQty.unit = qGroup.unit;
3558
- if (qGroup.equivalents) plainQty.equivalents = qGroup.equivalents;
3559
- allQuantities.push(plainQty);
3560
4095
  }
3561
4096
  }
3562
- if (allQuantities.length === 1) {
3563
- addIngredientQuantity(ingredient.name, allQuantities[0]);
3564
- } else {
3565
- const extendedQuantities = allQuantities.map(
3566
- (q) => extendAllUnits(q)
4097
+ try {
4098
+ const remaining = subtractQuantities(
4099
+ ingredientExtended,
4100
+ pantryExtended,
4101
+ { clampToZero: true }
3567
4102
  );
3568
- const totalQuantity = addEquivalentsAndSimplify(
3569
- extendedQuantities
4103
+ const consumed = subtractQuantities(
4104
+ pantryExtended,
4105
+ ingredientExtended,
4106
+ { clampToZero: true }
3570
4107
  );
3571
- addIngredientQuantity(ingredient.name, totalQuantity);
4108
+ pantryExtended = consumed;
4109
+ const updated = toPlainUnit(remaining);
4110
+ leaf.quantity = updated.quantity;
4111
+ leaf.unit = updated.unit;
4112
+ } catch {
3572
4113
  }
3573
- } else if (!this.ingredients.some((i2) => i2.name === ingredient.name)) {
3574
- this.ingredients.push({ name: ingredient.name });
4114
+ }
4115
+ if ("and" in entry) {
4116
+ const nonZero = entry.and.filter(
4117
+ (leaf) => leaf.quantity.type !== "fixed" || leaf.quantity.value.type !== "decimal" || leaf.quantity.value.decimal !== 0
4118
+ );
4119
+ entry.and.length = 0;
4120
+ entry.and.push(...nonZero);
4121
+ const ratioMap = this.equivalenceRatios.get(ingredient.name);
4122
+ if (entry.equivalents && ratioMap) {
4123
+ const equivUnits = entry.equivalents.map((e2) => e2.unit ?? NO_UNIT);
4124
+ entry.equivalents = _ShoppingList.recomputeEquivalents(
4125
+ entry.and,
4126
+ ratioMap,
4127
+ equivUnits
4128
+ );
4129
+ }
4130
+ if (entry.and.length === 1) {
4131
+ const single = entry.and[0];
4132
+ ingredient.quantities[i2] = {
4133
+ quantity: single.quantity,
4134
+ ...single.unit && { unit: single.unit },
4135
+ ...entry.equivalents && { equivalents: entry.equivalents },
4136
+ ...entry.alternatives && { alternatives: entry.alternatives }
4137
+ };
4138
+ }
4139
+ } else if ("equivalents" in entry && entry.equivalents) {
4140
+ const ratioMap = this.equivalenceRatios.get(ingredient.name);
4141
+ if (ratioMap) {
4142
+ const equivUnits = entry.equivalents.map(
4143
+ (e2) => e2.unit ?? NO_UNIT
4144
+ );
4145
+ const recomputed = _ShoppingList.recomputeEquivalents(
4146
+ [entry],
4147
+ ratioMap,
4148
+ equivUnits
4149
+ );
4150
+ entry.equivalents = recomputed;
4151
+ }
4152
+ }
4153
+ }
4154
+ ingredient.quantities = ingredient.quantities.filter((entry) => {
4155
+ if ("and" in entry) return entry.and.length > 0;
4156
+ return !(entry.quantity.type === "fixed" && entry.quantity.value.type === "decimal" && entry.quantity.value.decimal === 0);
4157
+ });
4158
+ if (ingredient.quantities.length === 0) {
4159
+ ingredient.quantities = void 0;
4160
+ }
4161
+ pantryItem.quantity = pantryExtended.quantity;
4162
+ if (pantryExtended.unit) {
4163
+ pantryItem.unit = pantryExtended.unit.name;
4164
+ }
4165
+ }
4166
+ this.resultingPantry = clonedPantry;
4167
+ }
4168
+ /**
4169
+ * Builds a ratio map from equivalence lists.
4170
+ * For each equivalence list, stores ratio = equiv_value / primary_value
4171
+ * for every pair of units, so equivalents can be recomputed after
4172
+ * pantry subtraction modifies primary quantities.
4173
+ */
4174
+ static buildEquivalenceRatioMap(unitsLists) {
4175
+ const ratioMap = {};
4176
+ for (const list of unitsLists) {
4177
+ for (const equiv of list) {
4178
+ const equivValue = getAverageValue(equiv.quantity);
4179
+ for (const primary of list) {
4180
+ if (primary === equiv) continue;
4181
+ const primaryValue = getAverageValue(primary.quantity);
4182
+ const equivUnit = equiv.unit.name;
4183
+ const primaryUnit = primary.unit.name;
4184
+ ratioMap[equivUnit] ?? (ratioMap[equivUnit] = {});
4185
+ ratioMap[equivUnit][primaryUnit] = equivValue / primaryValue;
3575
4186
  }
3576
4187
  }
3577
4188
  }
4189
+ return ratioMap;
4190
+ }
4191
+ /**
4192
+ * Recomputes equivalent quantities from current primary values and stored ratios.
4193
+ * For each equivalent unit in equivUnits, new_value = Σ (primary_value × ratio[equivUnit][primaryUnit]).
4194
+ * Returns undefined if all equivalents compute to zero.
4195
+ */
4196
+ static recomputeEquivalents(primaries, ratioMap, equivUnits) {
4197
+ const equivalents = [];
4198
+ for (const equivUnit of equivUnits) {
4199
+ const ratios = ratioMap[equivUnit];
4200
+ let total = 0;
4201
+ for (const primary of primaries) {
4202
+ const pUnit = primary.unit ?? NO_UNIT;
4203
+ const ratio = ratios[pUnit];
4204
+ const pValue = getAverageValue(primary.quantity);
4205
+ total += pValue * ratio;
4206
+ }
4207
+ if (total > 0) {
4208
+ equivalents.push({
4209
+ quantity: {
4210
+ type: "fixed",
4211
+ value: { type: "decimal", decimal: total }
4212
+ },
4213
+ ...equivUnit !== "" && { unit: equivUnit }
4214
+ });
4215
+ }
4216
+ }
4217
+ return equivalents.length > 0 ? equivalents : void 0;
3578
4218
  }
3579
4219
  /**
3580
4220
  * Adds a recipe to the shopping list, then automatically
@@ -3663,9 +4303,41 @@ var ShoppingList = class {
3663
4303
  this.calculateIngredients();
3664
4304
  this.categorize();
3665
4305
  }
4306
+ /**
4307
+ * Adds a pantry to the shopping list. On-hand pantry quantities will be
4308
+ * subtracted from recipe ingredient needs on each recalculation.
4309
+ * @param pantry - A Pantry instance or a TOML string to parse.
4310
+ * @param options - Options for pantry parsing (only used when providing a TOML string).
4311
+ */
4312
+ addPantry(pantry, options) {
4313
+ if (typeof pantry === "string") {
4314
+ this.pantry = new Pantry(pantry, options);
4315
+ } else if (pantry instanceof Pantry) {
4316
+ this.pantry = pantry;
4317
+ } else {
4318
+ throw new Error(
4319
+ "Invalid pantry: expected a Pantry instance or TOML string"
4320
+ );
4321
+ }
4322
+ if (this.categoryConfig) {
4323
+ this.pantry.setCategoryConfig(this.categoryConfig);
4324
+ }
4325
+ this.calculateIngredients();
4326
+ this.categorize();
4327
+ }
4328
+ /**
4329
+ * Returns the resulting pantry with quantities updated to reflect
4330
+ * what was consumed by the shopping list's recipes.
4331
+ * Returns undefined if no pantry was added.
4332
+ * @returns The resulting Pantry, or undefined.
4333
+ */
4334
+ getPantry() {
4335
+ return this.resultingPantry;
4336
+ }
3666
4337
  /**
3667
4338
  * Sets the category configuration for the shopping list
3668
4339
  * and automatically categorize current ingredients from the list.
4340
+ * Also propagates the configuration to the pantry if one is set.
3669
4341
  * @param config - The category configuration to parse.
3670
4342
  */
3671
4343
  setCategoryConfig(config) {
@@ -3673,6 +4345,9 @@ var ShoppingList = class {
3673
4345
  this.categoryConfig = new CategoryConfig(config);
3674
4346
  else if (config instanceof CategoryConfig) this.categoryConfig = config;
3675
4347
  else throw new Error("Invalid category configuration");
4348
+ if (this.pantry) {
4349
+ this.pantry.setCategoryConfig(this.categoryConfig);
4350
+ }
3676
4351
  this.categorize();
3677
4352
  }
3678
4353
  /**
@@ -3817,8 +4492,27 @@ var ShoppingCart = class {
3817
4492
  getOptimumMatch(ingredient, options) {
3818
4493
  if (options.length === 0)
3819
4494
  throw new NoProductMatchError(ingredient.name, "noProduct");
3820
- if (!ingredient.quantityTotal)
4495
+ if (!ingredient.quantities || ingredient.quantities.length === 0)
3821
4496
  throw new NoProductMatchError(ingredient.name, "noQuantity");
4497
+ const allPlainEntries = [];
4498
+ for (const q of ingredient.quantities) {
4499
+ if ("and" in q) {
4500
+ allPlainEntries.push({ and: q.and });
4501
+ } else {
4502
+ const entry = {
4503
+ quantity: q.quantity,
4504
+ ...q.unit && { unit: q.unit },
4505
+ ...q.equivalents && { equivalents: q.equivalents }
4506
+ };
4507
+ allPlainEntries.push(entry);
4508
+ }
4509
+ }
4510
+ let quantityTotal;
4511
+ if (allPlainEntries.length === 1) {
4512
+ quantityTotal = allPlainEntries[0];
4513
+ } else {
4514
+ quantityTotal = { and: allPlainEntries };
4515
+ }
3822
4516
  const normalizedOptions = options.map(
3823
4517
  (option) => ({
3824
4518
  ...option,
@@ -3834,7 +4528,7 @@ var ShoppingCart = class {
3834
4528
  })
3835
4529
  })
3836
4530
  );
3837
- const normalizedQuantityTotal = normalizeAllUnits(ingredient.quantityTotal);
4531
+ const normalizedQuantityTotal = normalizeAllUnits(quantityTotal);
3838
4532
  function getOptimumMatchForQuantityParts(normalizedQuantities, normalizedOptions2, selection = []) {
3839
4533
  if (isAndGroup(normalizedQuantities)) {
3840
4534
  for (const q of normalizedQuantities.and) {
@@ -4062,6 +4756,7 @@ function isAlternativeSelected(recipe, choices, item, alternativeIndex) {
4062
4756
  NoProductCatalogForCartError,
4063
4757
  NoShoppingListForCartError,
4064
4758
  NoTabAsIndentError,
4759
+ Pantry,
4065
4760
  ProductCatalog,
4066
4761
  Recipe,
4067
4762
  Section,
@@ -4088,6 +4783,10 @@ function isAlternativeSelected(recipe, choices, item, alternativeIndex) {
4088
4783
  // v8 ignore else -- @preserve
4089
4784
  // v8 ignore if -- @preserve
4090
4785
  /* v8 ignore else -- expliciting error type -- @preserve */
4786
+ /* v8 ignore next 4 -- @preserve: defensive guard; regex always matches */
4787
+ // v8 ignore if -- @preserve: defensive type guard
4091
4788
  /* v8 ignore if -- @preserve */
4092
4789
  // v8 ignore next -- @preserve
4790
+ // v8 ignore else --@preserve: defensive type guard
4791
+ // v8 ignore else -- @preserve: detection if
4093
4792
  //# sourceMappingURL=index.cjs.map