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

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
@@ -1017,21 +1018,6 @@ function hasAlternatives(entry) {
1017
1018
  }
1018
1019
 
1019
1020
  // 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
1021
  function normalizeAllUnits(q) {
1036
1022
  if (isAndGroup(q)) {
1037
1023
  return { and: q.and.map(normalizeAllUnits) };
@@ -1296,30 +1282,51 @@ var flattenPlainUnitGroup = (summed) => {
1296
1282
  }
1297
1283
  } else if (isAndGroup(summed)) {
1298
1284
  const andEntries = [];
1285
+ const standaloneEntries = [];
1299
1286
  const equivalentsList = [];
1300
1287
  for (const entry of summed.and) {
1301
1288
  if (isOrGroup(entry)) {
1302
1289
  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 }
1290
+ const firstEntry = orEntries[0];
1291
+ if (isAndGroup(firstEntry)) {
1292
+ for (const nestedEntry of firstEntry.and) {
1293
+ andEntries.push({
1294
+ quantity: nestedEntry.quantity,
1295
+ ...nestedEntry.unit && { unit: nestedEntry.unit }
1296
+ });
1297
+ }
1298
+ } else {
1299
+ const primary = firstEntry;
1300
+ andEntries.push({
1301
+ quantity: primary.quantity,
1302
+ ...primary.unit && { unit: primary.unit }
1303
+ });
1304
+ }
1305
+ const equivEntries = orEntries.slice(1).filter((e2) => isQuantity(e2));
1306
+ equivalentsList.push(
1307
+ ...equivEntries.map((e2) => ({
1308
+ quantity: e2.quantity,
1309
+ ...e2.unit && { unit: e2.unit }
1310
+ }))
1311
+ );
1312
+ } else {
1313
+ const simpleQuantityEntry = entry;
1314
+ standaloneEntries.push({
1315
+ quantity: simpleQuantityEntry.quantity,
1316
+ ...simpleQuantityEntry.unit && { unit: simpleQuantityEntry.unit }
1312
1317
  });
1313
1318
  }
1314
1319
  }
1315
1320
  if (equivalentsList.length === 0) {
1316
- return andEntries;
1321
+ return [...andEntries, ...standaloneEntries];
1317
1322
  }
1318
- const result = {
1323
+ const result = [];
1324
+ result.push({
1319
1325
  and: andEntries,
1320
1326
  equivalents: equivalentsList
1321
- };
1322
- return [result];
1327
+ });
1328
+ result.push(...standaloneEntries);
1329
+ return result;
1323
1330
  } else {
1324
1331
  return [
1325
1332
  { quantity: summed.quantity, ...summed.unit && { unit: summed.unit } }
@@ -1373,6 +1380,24 @@ function applyBestUnit(q, system) {
1373
1380
  unit: { name: bestUnit.name }
1374
1381
  };
1375
1382
  }
1383
+ function subtractQuantities(q1, q2, options = {}) {
1384
+ const { clampToZero = true, system } = options;
1385
+ const negatedQ2 = {
1386
+ ...q2,
1387
+ quantity: multiplyQuantityValue(q2.quantity, -1)
1388
+ };
1389
+ const result = addQuantities(q1, negatedQ2, system);
1390
+ if (clampToZero) {
1391
+ const avg = getAverageValue(result.quantity);
1392
+ if (typeof avg === "number" && avg < 0) {
1393
+ return {
1394
+ quantity: { type: "fixed", value: { type: "decimal", decimal: 0 } },
1395
+ unit: result.unit
1396
+ };
1397
+ }
1398
+ }
1399
+ return result;
1400
+ }
1376
1401
 
1377
1402
  // src/utils/parser_helpers.ts
1378
1403
  function flushPendingNote(section, noteItems) {
@@ -1513,6 +1538,97 @@ function parseQuantityInput(input_str) {
1513
1538
  }
1514
1539
  return { type: "fixed", value: parseFixedValue(clean_str) };
1515
1540
  }
1541
+ function parseQuantityWithUnit(input) {
1542
+ const trimmed = input.trim();
1543
+ const separatorIndex = trimmed.indexOf("%");
1544
+ if (separatorIndex === -1) {
1545
+ return { value: parseQuantityInput(trimmed) };
1546
+ }
1547
+ const valuePart = trimmed.slice(0, separatorIndex).trim();
1548
+ const unitPart = trimmed.slice(separatorIndex + 1).trim();
1549
+ return {
1550
+ value: parseQuantityInput(valuePart),
1551
+ unit: unitPart || void 0
1552
+ };
1553
+ }
1554
+ function parseDateFromFormat(input, format) {
1555
+ const delimiterMatch = format.match(/[^A-Za-z]/);
1556
+ if (!delimiterMatch) {
1557
+ throw new Error(`Invalid date format: ${format}. No delimiter found.`);
1558
+ }
1559
+ const delimiter = delimiterMatch[0];
1560
+ const formatParts = format.split(delimiter);
1561
+ const inputParts = input.trim().split(delimiter);
1562
+ if (formatParts.length !== 3 || inputParts.length !== 3) {
1563
+ throw new Error(
1564
+ `Invalid date input "${input}" for format "${format}". Expected 3 parts.`
1565
+ );
1566
+ }
1567
+ let day = 0, month = 0, year = 0;
1568
+ for (let i2 = 0; i2 < 3; i2++) {
1569
+ const token = formatParts[i2].toUpperCase();
1570
+ const value = parseInt(inputParts[i2], 10);
1571
+ if (isNaN(value)) {
1572
+ throw new Error(
1573
+ `Invalid date input "${input}": non-numeric part "${inputParts[i2]}".`
1574
+ );
1575
+ }
1576
+ if (token === "DD") day = value;
1577
+ else if (token === "MM") month = value;
1578
+ else if (token === "YYYY") year = value;
1579
+ else
1580
+ throw new Error(
1581
+ `Unknown token "${formatParts[i2]}" in format "${format}"`
1582
+ );
1583
+ }
1584
+ const date = new Date(year, month - 1, day);
1585
+ if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
1586
+ throw new Error(`Invalid date: "${input}" does not form a valid date.`);
1587
+ }
1588
+ return date;
1589
+ }
1590
+ function disambiguateDayMonth(first, second, year) {
1591
+ if (second > 12 && first <= 12) {
1592
+ return [second, first, year];
1593
+ }
1594
+ return [first, second, year];
1595
+ }
1596
+ function parseFuzzyDate(input) {
1597
+ const trimmed = input.trim();
1598
+ const delimiterMatch = trimmed.match(/[./-]/);
1599
+ if (!delimiterMatch) {
1600
+ throw new Error(`Cannot parse date "${input}": no delimiter found.`);
1601
+ }
1602
+ const delimiter = delimiterMatch[0];
1603
+ const parts = trimmed.split(delimiter);
1604
+ if (parts.length !== 3) {
1605
+ throw new Error(
1606
+ `Cannot parse date "${input}": expected 3 parts, got ${parts.length}.`
1607
+ );
1608
+ }
1609
+ const nums = parts.map((p) => parseInt(p, 10));
1610
+ if (nums.some((n2) => isNaN(n2))) {
1611
+ throw new Error(`Cannot parse date "${input}": non-numeric parts found.`);
1612
+ }
1613
+ let day, month, year;
1614
+ if (nums[0] >= 1e3) {
1615
+ year = nums[0];
1616
+ month = nums[1];
1617
+ day = nums[2];
1618
+ } else if (nums[2] >= 1e3) {
1619
+ [day, month, year] = disambiguateDayMonth(nums[0], nums[1], nums[2]);
1620
+ } else {
1621
+ if (nums[2] >= 100)
1622
+ throw new Error(`Invalid date: "${input}" does not form a valid date.`);
1623
+ [day, month] = disambiguateDayMonth(nums[0], nums[1], 0);
1624
+ year = 2e3 + nums[2];
1625
+ }
1626
+ const date = new Date(year, month - 1, day);
1627
+ if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
1628
+ throw new Error(`Invalid date: "${input}" does not form a valid date.`);
1629
+ }
1630
+ return date;
1631
+ }
1516
1632
  function parseMarkdownSegments(text) {
1517
1633
  const items = [];
1518
1634
  let cursor = 0;
@@ -1893,7 +2009,227 @@ function getAlternativeSignature(alternatives) {
1893
2009
  return alternatives.map((a2) => a2.index).sort((a2, b) => a2 - b).join(",");
1894
2010
  }
1895
2011
 
2012
+ // src/classes/pantry.ts
2013
+ var Pantry = class {
2014
+ /**
2015
+ * Creates a new Pantry instance.
2016
+ * @param tomlContent - Optional TOML content to parse.
2017
+ * @param options - Optional configuration options.
2018
+ */
2019
+ constructor(tomlContent, options = {}) {
2020
+ /**
2021
+ * The parsed pantry items.
2022
+ */
2023
+ __publicField(this, "items", []);
2024
+ /**
2025
+ * Options for date parsing and other configuration.
2026
+ */
2027
+ __publicField(this, "options");
2028
+ /**
2029
+ * Optional category configuration for alias-based lookups.
2030
+ */
2031
+ __publicField(this, "categoryConfig");
2032
+ this.options = options;
2033
+ if (tomlContent) {
2034
+ this.parse(tomlContent);
2035
+ }
2036
+ }
2037
+ /**
2038
+ * Parses a TOML string into pantry items.
2039
+ * @param tomlContent - The TOML string to parse.
2040
+ * @returns The parsed list of pantry items.
2041
+ */
2042
+ parse(tomlContent) {
2043
+ const raw = import_smol_toml.default.parse(tomlContent);
2044
+ this.items = [];
2045
+ for (const [location, locationData] of Object.entries(raw)) {
2046
+ const locationTable = locationData;
2047
+ for (const [itemName, itemData] of Object.entries(locationTable)) {
2048
+ const item = this.parseItem(
2049
+ itemName,
2050
+ location,
2051
+ itemData
2052
+ );
2053
+ this.items.push(item);
2054
+ }
2055
+ }
2056
+ return this.items;
2057
+ }
2058
+ /**
2059
+ * Parses a single pantry item from its TOML representation.
2060
+ */
2061
+ parseItem(name, location, data) {
2062
+ const item = { name, location };
2063
+ if (typeof data === "string") {
2064
+ const parsed = parseQuantityWithUnit(data);
2065
+ item.quantity = parsed.value;
2066
+ if (parsed.unit) item.unit = parsed.unit;
2067
+ } else {
2068
+ if (data.quantity) {
2069
+ const parsed = parseQuantityWithUnit(data.quantity);
2070
+ item.quantity = parsed.value;
2071
+ if (parsed.unit) item.unit = parsed.unit;
2072
+ }
2073
+ if (data.low) {
2074
+ const parsed = parseQuantityWithUnit(data.low);
2075
+ item.low = parsed.value;
2076
+ if (parsed.unit) item.lowUnit = parsed.unit;
2077
+ }
2078
+ if (data.bought) {
2079
+ item.bought = this.parseDate(data.bought);
2080
+ }
2081
+ if (data.expire) {
2082
+ item.expire = this.parseDate(data.expire);
2083
+ }
2084
+ }
2085
+ return item;
2086
+ }
2087
+ /**
2088
+ * Parses a date string using the configured format or fuzzy detection.
2089
+ */
2090
+ parseDate(input) {
2091
+ if (this.options.dateFormat) {
2092
+ return parseDateFromFormat(input, this.options.dateFormat);
2093
+ }
2094
+ return parseFuzzyDate(input);
2095
+ }
2096
+ /**
2097
+ * Sets a category configuration for alias-based item lookups.
2098
+ * @param config - The category configuration to use.
2099
+ */
2100
+ setCategoryConfig(config) {
2101
+ this.categoryConfig = config;
2102
+ }
2103
+ /**
2104
+ * Finds a pantry item by name, using exact match first, then alias lookup
2105
+ * via the stored CategoryConfig.
2106
+ * @param name - The name to search for.
2107
+ * @returns The matching pantry item, or undefined if not found.
2108
+ */
2109
+ findItem(name) {
2110
+ const lowerName = name.toLowerCase();
2111
+ const exact = this.items.find(
2112
+ (item) => item.name.toLowerCase() === lowerName
2113
+ );
2114
+ if (exact) return exact;
2115
+ if (this.categoryConfig) {
2116
+ for (const category of this.categoryConfig.categories) {
2117
+ for (const catIngredient of category.ingredients) {
2118
+ if (catIngredient.aliases.some(
2119
+ (alias) => alias.toLowerCase() === lowerName
2120
+ )) {
2121
+ const canonicalName = catIngredient.name.toLowerCase();
2122
+ const byCanonical = this.items.find(
2123
+ (item) => item.name.toLowerCase() === canonicalName
2124
+ );
2125
+ if (byCanonical) return byCanonical;
2126
+ for (const alias of catIngredient.aliases) {
2127
+ const byAlias = this.items.find(
2128
+ (item) => item.name.toLowerCase() === alias.toLowerCase()
2129
+ );
2130
+ if (byAlias) return byAlias;
2131
+ }
2132
+ }
2133
+ }
2134
+ }
2135
+ }
2136
+ return void 0;
2137
+ }
2138
+ /**
2139
+ * Gets the numeric value of a pantry item's quantity, optionally converted to base units.
2140
+ * Returns undefined if the quantity has a text value or is not set.
2141
+ */
2142
+ getItemNumericValue(quantity, unit) {
2143
+ if (!quantity) return void 0;
2144
+ let numericValue;
2145
+ if (quantity.type === "fixed") {
2146
+ if (quantity.value.type === "text") return void 0;
2147
+ numericValue = getNumericValue(quantity.value);
2148
+ } else {
2149
+ numericValue = (getNumericValue(quantity.min) + getNumericValue(quantity.max)) / 2;
2150
+ }
2151
+ if (unit) {
2152
+ const unitDef = normalizeUnit(unit);
2153
+ if (unitDef) {
2154
+ const toBase = getToBase(unitDef);
2155
+ numericValue *= toBase;
2156
+ }
2157
+ }
2158
+ return numericValue;
2159
+ }
2160
+ /**
2161
+ * Returns all items that are depleted (quantity = 0) or below their low threshold.
2162
+ * @returns An array of depleted pantry items.
2163
+ */
2164
+ getDepletedItems() {
2165
+ return this.items.filter((item) => this.isItemLow(item));
2166
+ }
2167
+ /**
2168
+ * Returns all items whose expiration date is within `nbDays` days from today
2169
+ * (or already passed).
2170
+ * @param nbDays - Number of days ahead to check. Defaults to 0 (already expired).
2171
+ * @returns An array of expired pantry items.
2172
+ */
2173
+ getExpiredItems(nbDays = 0) {
2174
+ return this.items.filter((item) => this.isItemExpired(item, nbDays));
2175
+ }
2176
+ /**
2177
+ * Checks if a specific item is low (quantity = 0 or below `low` threshold).
2178
+ * @param itemName - The name of the item to check (supports aliases if CategoryConfig is set).
2179
+ * @returns true if the item is low, false otherwise. Returns false if item not found.
2180
+ */
2181
+ isLow(itemName) {
2182
+ const item = this.findItem(itemName);
2183
+ if (!item) return false;
2184
+ return this.isItemLow(item);
2185
+ }
2186
+ /**
2187
+ * Checks if a specific item is expired or expires within `nbDays` days.
2188
+ * @param itemName - The name of the item to check (supports aliases if CategoryConfig is set).
2189
+ * @param nbDays - Number of days ahead to check. Defaults to 0.
2190
+ * @returns true if the item is expired, false otherwise. Returns false if item not found.
2191
+ */
2192
+ isExpired(itemName, nbDays = 0) {
2193
+ const item = this.findItem(itemName);
2194
+ if (!item) return false;
2195
+ return this.isItemExpired(item, nbDays);
2196
+ }
2197
+ /**
2198
+ * Internal: checks if a pantry item is low.
2199
+ */
2200
+ isItemLow(item) {
2201
+ if (!item.quantity) return false;
2202
+ const qtyValue = this.getItemNumericValue(item.quantity, item.unit);
2203
+ if (qtyValue === void 0) return false;
2204
+ if (qtyValue === 0) return true;
2205
+ if (item.low) {
2206
+ const lowValue = this.getItemNumericValue(item.low, item.lowUnit);
2207
+ if (lowValue !== void 0 && qtyValue <= lowValue) return true;
2208
+ }
2209
+ return false;
2210
+ }
2211
+ /**
2212
+ * Internal: checks if a pantry item is expired.
2213
+ */
2214
+ isItemExpired(item, nbDays) {
2215
+ if (!item.expire) return false;
2216
+ const now = /* @__PURE__ */ new Date();
2217
+ const cutoff = new Date(
2218
+ now.getFullYear(),
2219
+ now.getMonth(),
2220
+ now.getDate() + nbDays
2221
+ );
2222
+ const expireDay = new Date(
2223
+ item.expire.getFullYear(),
2224
+ item.expire.getMonth(),
2225
+ item.expire.getDate()
2226
+ );
2227
+ return expireDay <= cutoff;
2228
+ }
2229
+ };
2230
+
1896
2231
  // src/classes/product_catalog.ts
2232
+ var import_smol_toml2 = __toESM(require("smol-toml"), 1);
1897
2233
  var ProductCatalog = class {
1898
2234
  constructor(tomlContent) {
1899
2235
  __publicField(this, "products", []);
@@ -1905,7 +2241,7 @@ var ProductCatalog = class {
1905
2241
  * @returns A parsed list of `ProductOption`.
1906
2242
  */
1907
2243
  parse(tomlContent) {
1908
- const catalogRaw = import_smol_toml.default.parse(tomlContent);
2244
+ const catalogRaw = import_smol_toml2.default.parse(tomlContent);
1909
2245
  this.products = [];
1910
2246
  if (!this.isValidTomlContent(catalogRaw)) {
1911
2247
  throw new InvalidProductCatalogFormat();
@@ -1978,7 +2314,7 @@ var ProductCatalog = class {
1978
2314
  size: sizeStrings.length === 1 ? sizeStrings[0] : sizeStrings
1979
2315
  };
1980
2316
  }
1981
- return import_smol_toml.default.stringify(grouped);
2317
+ return import_smol_toml2.default.stringify(grouped);
1982
2318
  }
1983
2319
  /**
1984
2320
  * Adds a product to the catalog.
@@ -2115,7 +2451,6 @@ function getEquivalentUnitsLists(...quantities) {
2115
2451
  const OrGroups = quantitiesCopy.filter(isOrGroup).filter((q) => q.or.length > 1);
2116
2452
  const unitLists = [];
2117
2453
  const normalizeOrGroup = (og) => ({
2118
- ...og,
2119
2454
  or: og.or.map((q) => ({
2120
2455
  ...q,
2121
2456
  unit: resolveUnit(q.unit?.name, q.unit?.integerProtected)
@@ -2796,34 +3131,10 @@ var _Recipe = class _Recipe {
2796
3131
  }
2797
3132
  }
2798
3133
  }
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) {
3134
+ // Type for accumulated quantities (used internally by collectQuantityGroups)
3135
+ // Defined as a static type alias for the private method's return type
3136
+ /** @internal */
3137
+ collectQuantityGroups(options) {
2827
3138
  const { section, step, choices } = options || {};
2828
3139
  const sectionsToProcess = section !== void 0 ? (() => {
2829
3140
  const idx = typeof section === "number" ? section : this.sections.indexOf(section);
@@ -2941,6 +3252,78 @@ var _Recipe = class _Recipe {
2941
3252
  }
2942
3253
  }
2943
3254
  }
3255
+ return { ingredientGroups, selectedIndices, referencedIndices };
3256
+ }
3257
+ /**
3258
+ * Gets the raw (unprocessed) quantity groups for each ingredient, before
3259
+ * any summation or equivalents simplification. This is useful for cross-recipe
3260
+ * aggregation (e.g., in {@link ShoppingList}), where quantities from multiple
3261
+ * recipes should be combined before processing.
3262
+ *
3263
+ * @param options - Options for filtering and choice selection (same as {@link getIngredientQuantities}).
3264
+ * @returns Array of {@link RawQuantityGroup} objects, one per ingredient with quantities.
3265
+ *
3266
+ * @example
3267
+ * ```typescript
3268
+ * const rawGroups = recipe.getRawQuantityGroups();
3269
+ * // Each group has: name, usedAsPrimary, flags, quantities[]
3270
+ * // quantities are the raw QuantityWithExtendedUnit or FlatOrGroup entries
3271
+ * ```
3272
+ */
3273
+ getRawQuantityGroups(options) {
3274
+ const { ingredientGroups, selectedIndices, referencedIndices } = this.collectQuantityGroups(options);
3275
+ const result = [];
3276
+ for (let index = 0; index < this.ingredients.length; index++) {
3277
+ if (!referencedIndices.has(index)) continue;
3278
+ const orig = this.ingredients[index];
3279
+ const usedAsPrimary = selectedIndices.has(index);
3280
+ const quantities = [];
3281
+ if (usedAsPrimary) {
3282
+ const groupsForIng = ingredientGroups.get(index);
3283
+ if (groupsForIng) {
3284
+ for (const [, group] of groupsForIng) {
3285
+ quantities.push(...group.quantities);
3286
+ }
3287
+ }
3288
+ }
3289
+ result.push({
3290
+ name: orig.name,
3291
+ ...usedAsPrimary && { usedAsPrimary: true },
3292
+ ...orig.flags && { flags: orig.flags },
3293
+ quantities
3294
+ });
3295
+ }
3296
+ return result;
3297
+ }
3298
+ /**
3299
+ * Gets ingredients with their quantities populated, optionally filtered by section/step
3300
+ * and respecting user choices for alternatives.
3301
+ *
3302
+ * When no options are provided, returns all recipe ingredients with quantities
3303
+ * calculated using primary alternatives (same as after parsing).
3304
+ *
3305
+ * @param options - Options for filtering and choice selection:
3306
+ * - `section`: Filter to a specific section (Section object or 0-based index)
3307
+ * - `step`: Filter to a specific step (Step object or 0-based index)
3308
+ * - `choices`: Choices for alternative ingredients (defaults to primary)
3309
+ * @returns Array of Ingredient objects with quantities populated
3310
+ *
3311
+ * @example
3312
+ * ```typescript
3313
+ * // Get all ingredients with primary alternatives
3314
+ * const ingredients = recipe.getIngredientQuantities();
3315
+ *
3316
+ * // Get ingredients for a specific section
3317
+ * const sectionIngredients = recipe.getIngredientQuantities({ section: 0 });
3318
+ *
3319
+ * // Get ingredients with specific choices applied
3320
+ * const withChoices = recipe.getIngredientQuantities({
3321
+ * choices: { ingredientItems: new Map([['ingredient-item-2', 1]]) }
3322
+ * });
3323
+ * ```
3324
+ */
3325
+ getIngredientQuantities(options) {
3326
+ const { ingredientGroups, selectedIndices, referencedIndices } = this.collectQuantityGroups(options);
2944
3327
  const result = [];
2945
3328
  for (let index = 0; index < this.ingredients.length; index++) {
2946
3329
  if (!referencedIndices.has(index)) continue;
@@ -3469,13 +3852,12 @@ __publicField(_Recipe, "itemCounts", /* @__PURE__ */ new WeakMap());
3469
3852
  var Recipe = _Recipe;
3470
3853
 
3471
3854
  // src/classes/shopping_list.ts
3472
- var ShoppingList = class {
3855
+ var ShoppingList = class _ShoppingList {
3473
3856
  /**
3474
3857
  * Creates a new ShoppingList instance
3475
3858
  * @param categoryConfigStr - The category configuration to parse.
3476
3859
  */
3477
3860
  constructor(categoryConfigStr) {
3478
- // TODO: backport type change
3479
3861
  /**
3480
3862
  * The ingredients in the shopping list.
3481
3863
  */
@@ -3492,38 +3874,38 @@ var ShoppingList = class {
3492
3874
  * The categorized ingredients in the shopping list.
3493
3875
  */
3494
3876
  __publicField(this, "categories");
3877
+ /**
3878
+ * The unit system to use for quantity simplification.
3879
+ * When set, overrides per-recipe unit systems.
3880
+ */
3881
+ __publicField(this, "unitSystem");
3882
+ /**
3883
+ * Per-ingredient equivalence ratio maps for recomputing equivalents
3884
+ * after pantry subtraction. Keyed by ingredient name.
3885
+ * @internal
3886
+ */
3887
+ __publicField(this, "equivalenceRatios", /* @__PURE__ */ new Map());
3888
+ /**
3889
+ * The original pantry (never mutated by recipe calculations).
3890
+ */
3891
+ __publicField(this, "pantry");
3892
+ /**
3893
+ * The pantry with quantities updated after subtracting recipe needs.
3894
+ * Recomputed on every {@link ShoppingList.calculateIngredients | calculateIngredients()} call.
3895
+ */
3896
+ __publicField(this, "resultingPantry");
3495
3897
  if (categoryConfigStr) {
3496
3898
  this.setCategoryConfig(categoryConfigStr);
3497
3899
  }
3498
3900
  }
3499
3901
  calculateIngredients() {
3500
3902
  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
- }
3903
+ const rawQuantitiesMap = /* @__PURE__ */ new Map();
3904
+ const nameOrder = [];
3905
+ const trackName = (name) => {
3906
+ if (!nameOrder.includes(name)) {
3907
+ nameOrder.push(name);
3522
3908
  }
3523
- this.ingredients.push({
3524
- name,
3525
- quantityTotal
3526
- });
3527
3909
  };
3528
3910
  for (const addedRecipe of this.recipes) {
3529
3911
  let scaledRecipe;
@@ -3533,48 +3915,253 @@ var ShoppingList = class {
3533
3915
  } else {
3534
3916
  scaledRecipe = addedRecipe.recipe.scaleTo(addedRecipe.servings);
3535
3917
  }
3536
- const ingredients = scaledRecipe.getIngredientQuantities({
3918
+ const rawGroups = scaledRecipe.getRawQuantityGroups({
3537
3919
  choices: addedRecipe.choices
3538
3920
  });
3539
- for (const ingredient of ingredients) {
3540
- if (ingredient.flags && ingredient.flags.includes("hidden")) {
3921
+ for (const group of rawGroups) {
3922
+ if (group.flags?.includes("hidden") || !group.usedAsPrimary) {
3541
3923
  continue;
3542
3924
  }
3543
- if (!ingredient.usedAsPrimary) {
3544
- continue;
3925
+ trackName(group.name);
3926
+ if (group.quantities.length > 0) {
3927
+ const existing = rawQuantitiesMap.get(group.name) ?? [];
3928
+ existing.push(...group.quantities);
3929
+ rawQuantitiesMap.set(group.name, existing);
3545
3930
  }
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);
3931
+ }
3932
+ }
3933
+ this.equivalenceRatios.clear();
3934
+ for (const name of nameOrder) {
3935
+ const rawQuantities = rawQuantitiesMap.get(name);
3936
+ if (!rawQuantities || rawQuantities.length === 0) {
3937
+ this.ingredients.push({ name });
3938
+ continue;
3939
+ }
3940
+ const textEntries = [];
3941
+ const numericEntries = [];
3942
+ for (const q of rawQuantities) {
3943
+ if ("quantity" in q && q.quantity.type === "fixed" && q.quantity.value.type === "text") {
3944
+ textEntries.push(q);
3945
+ } else {
3946
+ numericEntries.push(q);
3947
+ }
3948
+ }
3949
+ if (numericEntries.length > 1) {
3950
+ const ratioMap = _ShoppingList.buildEquivalenceRatioMap(
3951
+ getEquivalentUnitsLists(...numericEntries)
3952
+ );
3953
+ if (Object.keys(ratioMap).length > 0) {
3954
+ this.equivalenceRatios.set(name, ratioMap);
3955
+ }
3956
+ }
3957
+ const resultQuantities = [];
3958
+ for (const t2 of textEntries) {
3959
+ resultQuantities.push(toPlainUnit(t2));
3960
+ }
3961
+ if (numericEntries.length > 0) {
3962
+ resultQuantities.push(
3963
+ ...flattenPlainUnitGroup(
3964
+ addEquivalentsAndSimplify(numericEntries, this.unitSystem)
3965
+ )
3966
+ );
3967
+ }
3968
+ this.ingredients.push({
3969
+ name,
3970
+ quantities: resultQuantities
3971
+ });
3972
+ }
3973
+ this.applyPantrySubtraction();
3974
+ }
3975
+ /**
3976
+ * Subtracts pantry item quantities from calculated ingredient quantities
3977
+ * and updates the resultingPantry to reflect consumed stock.
3978
+ */
3979
+ applyPantrySubtraction() {
3980
+ if (!this.pantry) {
3981
+ this.resultingPantry = void 0;
3982
+ return;
3983
+ }
3984
+ const clonedPantry = new Pantry();
3985
+ clonedPantry.items = deepClone(this.pantry.items);
3986
+ if (this.categoryConfig) {
3987
+ clonedPantry.setCategoryConfig(this.categoryConfig);
3988
+ }
3989
+ for (const ingredient of this.ingredients) {
3990
+ if (!ingredient.quantities || ingredient.quantities.length === 0)
3991
+ continue;
3992
+ const pantryItem = clonedPantry.findItem(ingredient.name);
3993
+ if (!pantryItem || !pantryItem.quantity) continue;
3994
+ let pantryExtended = {
3995
+ quantity: pantryItem.quantity,
3996
+ ...pantryItem.unit && { unit: { name: pantryItem.unit } }
3997
+ };
3998
+ for (let i2 = 0; i2 < ingredient.quantities.length; i2++) {
3999
+ const entry = ingredient.quantities[i2];
4000
+ const leaves = "and" in entry ? entry.and : [entry];
4001
+ for (const leaf of leaves) {
4002
+ const ingredientExtended = toExtendedUnit(leaf);
4003
+ const leafHasUnit = leaf.unit !== void 0 && leaf.unit !== "";
4004
+ const pantryHasUnit = pantryExtended.unit !== void 0 && pantryExtended.unit.name !== "";
4005
+ const ratioMap = this.equivalenceRatios.get(ingredient.name);
4006
+ const unitMismatch = leafHasUnit !== pantryHasUnit && ratioMap !== void 0;
4007
+ if (unitMismatch) {
4008
+ const leafUnit = leaf.unit ?? NO_UNIT;
4009
+ const pantryUnit = pantryExtended.unit?.name ?? NO_UNIT;
4010
+ const ratioFromPantry = ratioMap[leafUnit]?.[pantryUnit];
4011
+ if (ratioFromPantry !== void 0) {
4012
+ const pantryValue = getAverageValue(pantryExtended.quantity);
4013
+ const leafValue = getAverageValue(ingredientExtended.quantity);
4014
+ if (typeof pantryValue === "number" && typeof leafValue === "number") {
4015
+ const pantryInLeafUnits = pantryValue * ratioFromPantry;
4016
+ const subtracted = Math.min(pantryInLeafUnits, leafValue);
4017
+ const remainingLeafValue = Math.max(
4018
+ leafValue - pantryInLeafUnits,
4019
+ 0
4020
+ );
4021
+ leaf.quantity = {
4022
+ type: "fixed",
4023
+ value: { type: "decimal", decimal: remainingLeafValue }
4024
+ };
4025
+ const consumedInPantryUnits = ratioFromPantry !== 0 ? subtracted / ratioFromPantry : pantryValue;
4026
+ const remainingPantryValue = Math.max(
4027
+ pantryValue - consumedInPantryUnits,
4028
+ 0
4029
+ );
4030
+ pantryExtended = {
4031
+ quantity: {
4032
+ type: "fixed",
4033
+ value: {
4034
+ type: "decimal",
4035
+ decimal: remainingPantryValue
4036
+ }
4037
+ },
4038
+ ...pantryExtended.unit && { unit: pantryExtended.unit }
4039
+ };
4040
+ continue;
3552
4041
  }
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
4042
  }
3561
4043
  }
3562
- if (allQuantities.length === 1) {
3563
- addIngredientQuantity(ingredient.name, allQuantities[0]);
3564
- } else {
3565
- const extendedQuantities = allQuantities.map(
3566
- (q) => extendAllUnits(q)
4044
+ try {
4045
+ const remaining = subtractQuantities(
4046
+ ingredientExtended,
4047
+ pantryExtended,
4048
+ { clampToZero: true }
4049
+ );
4050
+ const consumed = subtractQuantities(
4051
+ pantryExtended,
4052
+ ingredientExtended,
4053
+ { clampToZero: true }
4054
+ );
4055
+ pantryExtended = consumed;
4056
+ const updated = toPlainUnit(remaining);
4057
+ leaf.quantity = updated.quantity;
4058
+ leaf.unit = updated.unit;
4059
+ } catch {
4060
+ }
4061
+ }
4062
+ if ("and" in entry) {
4063
+ const nonZero = entry.and.filter(
4064
+ (leaf) => leaf.quantity.type !== "fixed" || leaf.quantity.value.type !== "decimal" || leaf.quantity.value.decimal !== 0
4065
+ );
4066
+ entry.and.length = 0;
4067
+ entry.and.push(...nonZero);
4068
+ const ratioMap = this.equivalenceRatios.get(ingredient.name);
4069
+ if (entry.equivalents && ratioMap) {
4070
+ const equivUnits = entry.equivalents.map((e2) => e2.unit ?? NO_UNIT);
4071
+ entry.equivalents = _ShoppingList.recomputeEquivalents(
4072
+ entry.and,
4073
+ ratioMap,
4074
+ equivUnits
4075
+ );
4076
+ }
4077
+ if (entry.and.length === 1) {
4078
+ const single = entry.and[0];
4079
+ ingredient.quantities[i2] = {
4080
+ quantity: single.quantity,
4081
+ ...single.unit && { unit: single.unit },
4082
+ ...entry.equivalents && { equivalents: entry.equivalents },
4083
+ ...entry.alternatives && { alternatives: entry.alternatives }
4084
+ };
4085
+ }
4086
+ } else if ("equivalents" in entry && entry.equivalents) {
4087
+ const ratioMap = this.equivalenceRatios.get(ingredient.name);
4088
+ if (ratioMap) {
4089
+ const equivUnits = entry.equivalents.map(
4090
+ (e2) => e2.unit ?? NO_UNIT
3567
4091
  );
3568
- const totalQuantity = addEquivalentsAndSimplify(
3569
- extendedQuantities
4092
+ const recomputed = _ShoppingList.recomputeEquivalents(
4093
+ [entry],
4094
+ ratioMap,
4095
+ equivUnits
3570
4096
  );
3571
- addIngredientQuantity(ingredient.name, totalQuantity);
4097
+ entry.equivalents = recomputed;
3572
4098
  }
3573
- } else if (!this.ingredients.some((i2) => i2.name === ingredient.name)) {
3574
- this.ingredients.push({ name: ingredient.name });
3575
4099
  }
3576
4100
  }
4101
+ ingredient.quantities = ingredient.quantities.filter((entry) => {
4102
+ if ("and" in entry) return entry.and.length > 0;
4103
+ return !(entry.quantity.type === "fixed" && entry.quantity.value.type === "decimal" && entry.quantity.value.decimal === 0);
4104
+ });
4105
+ if (ingredient.quantities.length === 0) {
4106
+ ingredient.quantities = void 0;
4107
+ }
4108
+ pantryItem.quantity = pantryExtended.quantity;
4109
+ if (pantryExtended.unit) {
4110
+ pantryItem.unit = pantryExtended.unit.name;
4111
+ }
3577
4112
  }
4113
+ this.resultingPantry = clonedPantry;
4114
+ }
4115
+ /**
4116
+ * Builds a ratio map from equivalence lists.
4117
+ * For each equivalence list, stores ratio = equiv_value / primary_value
4118
+ * for every pair of units, so equivalents can be recomputed after
4119
+ * pantry subtraction modifies primary quantities.
4120
+ */
4121
+ static buildEquivalenceRatioMap(unitsLists) {
4122
+ const ratioMap = {};
4123
+ for (const list of unitsLists) {
4124
+ for (const equiv of list) {
4125
+ const equivValue = getAverageValue(equiv.quantity);
4126
+ for (const primary of list) {
4127
+ if (primary === equiv) continue;
4128
+ const primaryValue = getAverageValue(primary.quantity);
4129
+ const equivUnit = equiv.unit.name;
4130
+ const primaryUnit = primary.unit.name;
4131
+ ratioMap[equivUnit] ?? (ratioMap[equivUnit] = {});
4132
+ ratioMap[equivUnit][primaryUnit] = equivValue / primaryValue;
4133
+ }
4134
+ }
4135
+ }
4136
+ return ratioMap;
4137
+ }
4138
+ /**
4139
+ * Recomputes equivalent quantities from current primary values and stored ratios.
4140
+ * For each equivalent unit in equivUnits, new_value = Σ (primary_value × ratio[equivUnit][primaryUnit]).
4141
+ * Returns undefined if all equivalents compute to zero.
4142
+ */
4143
+ static recomputeEquivalents(primaries, ratioMap, equivUnits) {
4144
+ const equivalents = [];
4145
+ for (const equivUnit of equivUnits) {
4146
+ const ratios = ratioMap[equivUnit];
4147
+ let total = 0;
4148
+ for (const primary of primaries) {
4149
+ const pUnit = primary.unit ?? NO_UNIT;
4150
+ const ratio = ratios[pUnit];
4151
+ const pValue = getAverageValue(primary.quantity);
4152
+ total += pValue * ratio;
4153
+ }
4154
+ if (total > 0) {
4155
+ equivalents.push({
4156
+ quantity: {
4157
+ type: "fixed",
4158
+ value: { type: "decimal", decimal: total }
4159
+ },
4160
+ ...equivUnit !== "" && { unit: equivUnit }
4161
+ });
4162
+ }
4163
+ }
4164
+ return equivalents.length > 0 ? equivalents : void 0;
3578
4165
  }
3579
4166
  /**
3580
4167
  * Adds a recipe to the shopping list, then automatically
@@ -3663,9 +4250,41 @@ var ShoppingList = class {
3663
4250
  this.calculateIngredients();
3664
4251
  this.categorize();
3665
4252
  }
4253
+ /**
4254
+ * Adds a pantry to the shopping list. On-hand pantry quantities will be
4255
+ * subtracted from recipe ingredient needs on each recalculation.
4256
+ * @param pantry - A Pantry instance or a TOML string to parse.
4257
+ * @param options - Options for pantry parsing (only used when providing a TOML string).
4258
+ */
4259
+ addPantry(pantry, options) {
4260
+ if (typeof pantry === "string") {
4261
+ this.pantry = new Pantry(pantry, options);
4262
+ } else if (pantry instanceof Pantry) {
4263
+ this.pantry = pantry;
4264
+ } else {
4265
+ throw new Error(
4266
+ "Invalid pantry: expected a Pantry instance or TOML string"
4267
+ );
4268
+ }
4269
+ if (this.categoryConfig) {
4270
+ this.pantry.setCategoryConfig(this.categoryConfig);
4271
+ }
4272
+ this.calculateIngredients();
4273
+ this.categorize();
4274
+ }
4275
+ /**
4276
+ * Returns the resulting pantry with quantities updated to reflect
4277
+ * what was consumed by the shopping list's recipes.
4278
+ * Returns undefined if no pantry was added.
4279
+ * @returns The resulting Pantry, or undefined.
4280
+ */
4281
+ getPantry() {
4282
+ return this.resultingPantry;
4283
+ }
3666
4284
  /**
3667
4285
  * Sets the category configuration for the shopping list
3668
4286
  * and automatically categorize current ingredients from the list.
4287
+ * Also propagates the configuration to the pantry if one is set.
3669
4288
  * @param config - The category configuration to parse.
3670
4289
  */
3671
4290
  setCategoryConfig(config) {
@@ -3673,6 +4292,9 @@ var ShoppingList = class {
3673
4292
  this.categoryConfig = new CategoryConfig(config);
3674
4293
  else if (config instanceof CategoryConfig) this.categoryConfig = config;
3675
4294
  else throw new Error("Invalid category configuration");
4295
+ if (this.pantry) {
4296
+ this.pantry.setCategoryConfig(this.categoryConfig);
4297
+ }
3676
4298
  this.categorize();
3677
4299
  }
3678
4300
  /**
@@ -3817,8 +4439,27 @@ var ShoppingCart = class {
3817
4439
  getOptimumMatch(ingredient, options) {
3818
4440
  if (options.length === 0)
3819
4441
  throw new NoProductMatchError(ingredient.name, "noProduct");
3820
- if (!ingredient.quantityTotal)
4442
+ if (!ingredient.quantities || ingredient.quantities.length === 0)
3821
4443
  throw new NoProductMatchError(ingredient.name, "noQuantity");
4444
+ const allPlainEntries = [];
4445
+ for (const q of ingredient.quantities) {
4446
+ if ("and" in q) {
4447
+ allPlainEntries.push({ and: q.and });
4448
+ } else {
4449
+ const entry = {
4450
+ quantity: q.quantity,
4451
+ ...q.unit && { unit: q.unit },
4452
+ ...q.equivalents && { equivalents: q.equivalents }
4453
+ };
4454
+ allPlainEntries.push(entry);
4455
+ }
4456
+ }
4457
+ let quantityTotal;
4458
+ if (allPlainEntries.length === 1) {
4459
+ quantityTotal = allPlainEntries[0];
4460
+ } else {
4461
+ quantityTotal = { and: allPlainEntries };
4462
+ }
3822
4463
  const normalizedOptions = options.map(
3823
4464
  (option) => ({
3824
4465
  ...option,
@@ -3834,7 +4475,7 @@ var ShoppingCart = class {
3834
4475
  })
3835
4476
  })
3836
4477
  );
3837
- const normalizedQuantityTotal = normalizeAllUnits(ingredient.quantityTotal);
4478
+ const normalizedQuantityTotal = normalizeAllUnits(quantityTotal);
3838
4479
  function getOptimumMatchForQuantityParts(normalizedQuantities, normalizedOptions2, selection = []) {
3839
4480
  if (isAndGroup(normalizedQuantities)) {
3840
4481
  for (const q of normalizedQuantities.and) {
@@ -4062,6 +4703,7 @@ function isAlternativeSelected(recipe, choices, item, alternativeIndex) {
4062
4703
  NoProductCatalogForCartError,
4063
4704
  NoShoppingListForCartError,
4064
4705
  NoTabAsIndentError,
4706
+ Pantry,
4065
4707
  ProductCatalog,
4066
4708
  Recipe,
4067
4709
  Section,
@@ -4088,6 +4730,9 @@ function isAlternativeSelected(recipe, choices, item, alternativeIndex) {
4088
4730
  // v8 ignore else -- @preserve
4089
4731
  // v8 ignore if -- @preserve
4090
4732
  /* v8 ignore else -- expliciting error type -- @preserve */
4733
+ // v8 ignore if -- @preserve: defensive type guard
4091
4734
  /* v8 ignore if -- @preserve */
4092
4735
  // v8 ignore next -- @preserve
4736
+ // v8 ignore else --@preserve: defensive type guard
4737
+ // v8 ignore else -- @preserve: detection if
4093
4738
  //# sourceMappingURL=index.cjs.map