From 0ae64faf08f80e89eee40c65f76270377c207268 Mon Sep 17 00:00:00 2001 From: Thomas Lamant Date: Thu, 20 Nov 2025 23:59:15 +0100 Subject: [PATCH 1/7] feat: new class `ShoppingCart` to assing ingredients in a shopping list to products from a catalog (#64) Resolves: #38 * feat: new class ShoppingCart to match products from a catalogue with a shopping list's ingredients * feat(ShoppingCart): add total price per selected product * feat: new class `ProductCatalog` to manage product catalogs * feat(ShoppingCart): add possibility to provide shopping list and/or catalog in the constructor * docs: added additional types to the API section * test: maximize coverage (100%) * docs: improve docstrings, add examples and remarks --- docs/index.md | 6 +- package.json | 5 +- pnpm-lock.yaml | 40 +++- src/classes/product_catalog.ts | 165 +++++++++++++++ src/classes/shopping_cart.ts | 377 +++++++++++++++++++++++++++++++++ src/errors.ts | 48 +++++ src/index.ts | 24 +++ src/parser_helpers.ts | 19 ++ src/types.ts | 87 ++++++++ src/units.ts | 8 + test/parser_helpers.test.ts | 28 +++ test/product_catalog.test.ts | 159 ++++++++++++++ test/shopping_cart.test.ts | 290 +++++++++++++++++++++++++ test/units.test.ts | 11 + 14 files changed, 1260 insertions(+), 7 deletions(-) create mode 100644 src/classes/product_catalog.ts create mode 100644 src/classes/shopping_cart.ts create mode 100644 test/product_catalog.test.ts create mode 100644 test/shopping_cart.test.ts diff --git a/docs/index.md b/docs/index.md index 369d6ea..8140bde 100644 --- a/docs/index.md +++ b/docs/index.md @@ -24,6 +24,8 @@ features: details: Fully-typed, compliant with the Cooklang Specifications - title: Useful extensions details: Additional features to the original specs - - title: Parsing, scaling and shopping - details: Classes to parse and scale recipes, as well as parse category configuration and create shopping lists + - title: Parsing and scaling + details: Classes to parse and scale recipes + - title: Shopping + details: Classes to parse category configurations, create shopping lists, and fill in a virtual shopping cart based on a product catalog --- diff --git a/package.json b/package.json index 232b7fe..3e4ab03 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@eslint/js": "9.39.1", "@iconify-json/material-symbols": "1.2.44", "@types/big.js": "6.2.2", + "@types/js-yaml": "^4.0.9", "@types/node": "22.19.0", "@vitest/coverage-v8": "4.0.8", "@vitest/ui": "4.0.8", @@ -81,6 +82,8 @@ ], "license": "MIT", "dependencies": { - "big.js": "7.0.1" + "big.js": "7.0.1", + "smol-toml": "^1.5.2", + "yalps": "^0.6.3" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7c4c664..fb12809 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,6 +16,12 @@ importers: big.js: specifier: 7.0.1 version: 7.0.1 + smol-toml: + specifier: ^1.5.2 + version: 1.5.2 + yalps: + specifier: ^0.6.3 + version: 0.6.3 devDependencies: '@eslint/compat': specifier: 1.4.1 @@ -29,6 +35,9 @@ importers: '@types/big.js': specifier: 6.2.2 version: 6.2.2 + '@types/js-yaml': + specifier: ^4.0.9 + version: 4.0.9 '@types/node': specifier: 22.19.0 version: 22.19.0 @@ -959,6 +968,9 @@ packages: '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/js-yaml@4.0.9': + resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -1578,6 +1590,9 @@ packages: hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + heap@0.2.7: + resolution: {integrity: sha512-2bsegYkkHO+h/9MGbn6KWcE45cHZgPANo5LXF7EvWdT0yT2EguSVO1nDgU5c8+ZOPwp2vMNa7YFsJhVcDR9Sdg==} + hookable@5.5.3: resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} @@ -1685,8 +1700,8 @@ packages: js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} - js-yaml@4.1.0: - resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true json-buffer@3.0.1: @@ -2063,6 +2078,10 @@ packages: resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} engines: {node: '>=18'} + smol-toml@1.5.2: + resolution: {integrity: sha512-QlaZEqcAH3/RtNyet1IPIYPsEWAaYyXXv1Krsi+1L/QHppjX4Ifm8MQsBISz9vE8cHicIq3clogsheili5vhaQ==} + engines: {node: '>= 18'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -2456,6 +2475,9 @@ packages: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} + yalps@0.6.3: + resolution: {integrity: sha512-XS1Sb3uejyNMu/Bl+3ZdI4VZGJKT6JSoSDYcLFlapOUy0wJPN9h+XDXAY8xeFk0bQFdEMIOGNAN2/jht4XSJKA==} + yaml@2.8.1: resolution: {integrity: sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==} engines: {node: '>= 14.6'} @@ -2852,7 +2874,7 @@ snapshots: globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.1.0 + js-yaml: 4.1.1 minimatch: 3.1.2 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -3159,6 +3181,8 @@ snapshots: dependencies: '@types/unist': 3.0.3 + '@types/js-yaml@4.0.9': {} + '@types/json-schema@7.0.15': {} '@types/linkify-it@5.0.0': {} @@ -3900,6 +3924,8 @@ snapshots: dependencies: '@types/hast': 3.0.4 + heap@0.2.7: {} + hookable@5.5.3: {} htm@3.1.1: {} @@ -3986,7 +4012,7 @@ snapshots: js-tokens@9.0.1: {} - js-yaml@4.1.0: + js-yaml@4.1.1: dependencies: argparse: 2.0.1 @@ -4369,6 +4395,8 @@ snapshots: mrmime: 2.0.1 totalist: 3.0.1 + smol-toml@1.5.2: {} + source-map-js@1.2.1: {} source-map@0.8.0-beta.0: @@ -4757,6 +4785,10 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.1.0 + yalps@0.6.3: + dependencies: + heap: 0.2.7 + yaml@2.8.1: {} yocto-queue@0.1.0: {} diff --git a/src/classes/product_catalog.ts b/src/classes/product_catalog.ts new file mode 100644 index 0000000..8a84f7b --- /dev/null +++ b/src/classes/product_catalog.ts @@ -0,0 +1,165 @@ +import TOML from "smol-toml"; +import type { + FixedNumericValue, + ProductOption, + ProductOptionToml, +} from "../types"; +import type { TomlTable } from "smol-toml"; +import { + isPositiveIntegerString, + parseQuantityInput, + stringifyQuantityValue, +} from "../parser_helpers"; +import { InvalidProductCatalogFormat } from "../errors"; + +/** + * Product Catalog Manager + * + * Used in conjunction with {@link ShoppingCart} + * + * ## Usage + * + * You can either directly populate the products by feeding the {@link ProductCatalog.products | products} property. Alternatively, + * you can provide a catalog in TOML format to either the constructor itself or to the {@link ProductCatalog.parse | parse()} method. + * + * @category Classes + * + * @example + * ```typescript + * import { ProductCatalog } from "@tmlmt/cooklang-parser"; + * + * const catalog = `[eggs] +01123 = { name = "Single Egg", size = "1", price = 2 } +11244 = { name = "Pack of 6 eggs", size = "6", price = 10 } + +[flour] +01124 = { name = "Small pack", size = "100%g", price = 1.5 } +14141 = { name = "Big pack", size = "6%kg", price = 10 } + * ` + * const catalog = new ProductCatalog(catalog); + * ``` + */ +export class ProductCatalog { + public products: ProductOption[] = []; + + constructor(tomlContent?: string) { + if (tomlContent) this.products = this.parse(tomlContent); + } + + /** + * Parses a TOML string into a list of product options. + * @param tomlContent - The TOML string to parse. + * @returns A parsed list of `ProductOption`. + */ + public parse(tomlContent: string): ProductOption[] { + const catalogRaw = TOML.parse(tomlContent); + + if (!this.isValidTomlContent(catalogRaw)) { + throw new InvalidProductCatalogFormat(); + } + + for (const [ingredientName, productsRaw] of Object.entries(catalogRaw)) { + for (const [productId, productRaw] of Object.entries(productsRaw)) { + const sizeAndUnitRaw = (productRaw as ProductOptionToml).size.split( + "%", + ); + const size = parseQuantityInput( + sizeAndUnitRaw[0]!, + ) as FixedNumericValue; + + const productOption: ProductOption = { + id: productId, + productName: (productRaw as ProductOptionToml).name, + ingredientName: ingredientName, + price: (productRaw as ProductOptionToml).price, + size, + }; + + if (sizeAndUnitRaw.length > 1) { + productOption.unit = sizeAndUnitRaw[1]!; + } + + this.products.push(productOption); + } + } + + return this.products; + } + + /** + * Stringifies the catalog to a TOML string. + * @returns The TOML string representation of the catalog. + */ + public stringify(): string { + const groupedProducts = this.products.reduce( + (acc, item) => { + const { id, ingredientName, size, unit, productName, ...product } = + item; + + if (!acc[ingredientName]) { + acc[ingredientName] = {}; + } + + acc[ingredientName][id] = { + ...product, + name: productName, + size: unit + ? `${stringifyQuantityValue(size)}%${unit}` + : stringifyQuantityValue(size), + }; + + return acc; + }, + {} as Record>, + ); + return TOML.stringify(groupedProducts); + } + + /** + * Adds a product to the catalog. + * @param productOption - The product to add. + */ + public add(productOption: ProductOption): void { + this.products.push(productOption); + } + + /** + * Removes a product from the catalog by its ID. + * @param productId - The ID of the product to remove. + */ + public remove(productId: string): void { + this.products = this.products.filter((product) => product.id !== productId); + } + + private isValidTomlContent(products: TomlTable): boolean { + for (const productsRaw of Object.values(products)) { + for (const [id, obj] of Object.entries(productsRaw)) { + if (!isPositiveIntegerString(id)) { + return false; + } + if (typeof obj !== "object" || obj === null) { + return false; + } + + const record = obj as Record; + const keys = Object.keys(record); + + const allowedKeys = new Set(["name", "size", "price"]); + + if (keys.some((key) => !allowedKeys.has(key))) { + return false; + } + + const hasProductName = typeof record.name === "string"; + const hasSize = typeof record.size === "string"; + const hasPrice = typeof record.price === "number"; + + if (!(hasProductName && hasSize && hasPrice)) { + return false; + } + } + } + + return true; + } +} diff --git a/src/classes/shopping_cart.ts b/src/classes/shopping_cart.ts new file mode 100644 index 0000000..4cf70d1 --- /dev/null +++ b/src/classes/shopping_cart.ts @@ -0,0 +1,377 @@ +import type { + ProductOption, + ProductSelection, + Ingredient, + CartContent, + CartMatch, + CartMisMatch, + FixedNumericValue, + Range, +} from "../types"; +import { ProductCatalog } from "./product_catalog"; +import { ShoppingList } from "./shopping_list"; +import { + NoProductCatalogForCartError, + NoShoppingListForCartError, + NoProductMatchError, +} from "../errors"; +import { + multiplyQuantityValue, + normalizeUnit, + getNumericValue, +} from "../units"; +import { solve, type Model } from "yalps"; + +/** + * Options for the {@link ShoppingCart} constructor + * @category Types + */ +export interface ShoppingCartOptions { + /** + * A product catalog to connect to the cart + */ + catalog?: ProductCatalog; + /** + * A shopping list to connect to the cart + */ + list?: ShoppingList; +} + +/** + * Key information about the {@link ShoppingCart} + * @category Types + */ +export interface ShoppingCartSummary { + /** + * The total price of the cart + */ + totalPrice: number; + /** + * The total number of items in the cart + */ + totalItems: number; +} + +/** + * Shopping Cart Manager: a tool to find the best combination of products to buy to satisfy a shopping list. + * + * @example + * ```ts + * const shoppingList = new ShoppingList(); + * const recipe = new Recipe("@flour{600%g}"); + * shoppingList.add_recipe(recipe); + * + * const catalog = new ProductCatalog(); + * catalog.products = [ + * { + * id: "flour-1kg", + * productName: "Flour (1kg)", + * ingredientName: "flour", + * price: 10, + * size: { type: "fixed", value: { type: "decimal", value: 1000 } }, + * unit: "g", + * }, + * { + * id: "flour-500g", + * productName: "Flour (500g)", + * ingredientName: "flour", + * price: 6, + * size: { type: "fixed", value: { type: "decimal", value: 500 } }, + * unit: "g", + * }, + * ]; + * + * const shoppingCart = new ShoppingCart({list: shoppingList, catalog})) + * shoppingCart.buildCart(); + * ``` + * + * @category Classes + */ +export class ShoppingCart { + /** + * The product catalog to use for matching products + */ + productCatalog?: ProductCatalog; + /** + * The shopping list to build the cart from + */ + shoppingList?: ShoppingList; + /** + * The content of the cart + */ + cart: CartContent = []; + /** + * The ingredients that were successfully matched with products + */ + match: CartMatch = []; + /** + * The ingredients that could not be matched with products + */ + misMatch: CartMisMatch = []; + /** + * Key information about the shopping cart + */ + summary: ShoppingCartSummary; + + /** + * Creates a new ShoppingCart instance + * @param options - {@link ShoppingCartOptions | Options} for the constructor + */ + constructor(options?: ShoppingCartOptions) { + if (options?.catalog) this.productCatalog = options.catalog; + if (options?.list) this.shoppingList = options.list; + this.summary = { totalPrice: 0, totalItems: 0 }; + } + + /** + * Sets the product catalog to use for matching products + * To use if a catalog was not provided at the creation of the instance + * @param catalog - The {@link ProductCatalog} to set + */ + setProductCatalog(catalog: ProductCatalog) { + this.productCatalog = catalog; + } + + /** + * Sets the shopping list to build the cart from. + * To use if a shopping list was not provided at the creation of the instance + * @param list - The {@link ShoppingList} to set + */ + setShoppingList(list: ShoppingList) { + this.shoppingList = list; + } + + /** + * Builds the cart from the shopping list and product catalog + * @remarks + * - If a combination of product(s) is successfully found for a given ingredient, the latter will be listed in the {@link ShoppingCart.match | match} array + * in addition to that combination being added to the {@link ShoppingCart.cart | cart}. + * - Otherwise, the latter will be listed in the {@link ShoppingCart.misMatch | misMatch} array. Possible causes can be: + * - No product is listed in the catalog for that ingredient + * - The ingredient has no quantity, a text quantity + * - The ingredient's quantity unit is incompatible with the units of the candidate products listed in the catalog + * @throws {@link NoProductCatalogForCartError} if no product catalog is set + * @throws {@link NoShoppingListForCartError} if no shopping list is set + * @returns `true` if all ingredients in the shopping list have been matched to products in the catalog, or `false` otherwise + */ + buildCart(): boolean { + this.resetCart(); + + if (!this.productCatalog) { + throw new NoProductCatalogForCartError(); + } else if (!this.shoppingList) { + throw new NoShoppingListForCartError(); + } + + for (const ingredient of this.shoppingList.ingredients) { + const productOptions = this.getProductOptions(ingredient); + try { + const optimumMatch = this.getOptimumMatch(ingredient, productOptions); + this.cart.push(...optimumMatch); + this.match.push({ ingredient, selection: optimumMatch }); + } catch (error) { + /* v8 ignore else -- @preserve */ + if (error instanceof NoProductMatchError) { + this.misMatch.push({ ingredient, reason: error.code }); + } + } + } + + this.summarize(); + + return this.misMatch.length > 0; + } + + /** + * Gets the product options for a given ingredient + * @param ingredient - The ingredient to get the product options for + * @returns An array of {@link ProductOption} + */ + private getProductOptions(ingredient: Ingredient): ProductOption[] { + // this function is only called in buildCart() which starts by checking that a product catalog is present + return this.productCatalog!.products.filter( + (product) => product.ingredientName === ingredient.name, + ); + } + + /** + * Gets the optimum match for a given ingredient and product option + * @param ingredient - The ingredient to match + * @param options - The product options to choose from + * @returns An array of {@link ProductSelection} + * @throws {@link NoProductMatchError} if no match can be found + */ + private getOptimumMatch( + ingredient: Ingredient, + options: ProductOption[], + ): ProductSelection[] { + // If there's no product option, return an empty match + if (options.length === 0) + throw new NoProductMatchError(ingredient.name, "noProduct"); + // If the ingredient has no quantity, we can't match any product + if (!ingredient.quantity) + throw new NoProductMatchError(ingredient.name, "noQuantity"); + // If the ingredient has a text quantity, we can't match any product + if ( + ingredient.quantity.type === "fixed" && + ingredient.quantity.value.type === "text" + ) + throw new NoProductMatchError(ingredient.name, "textValue"); + // Convert quantities to base + if (!this.checkUnitCompatibility(ingredient, options)) { + throw new NoProductMatchError(ingredient.name, "incompatibleUnits"); + } + + const normalizedOptions = options + .map((option) => { + return { ...option, unit: normalizeUnit(option.unit) }; + }) + .map((option) => { + return { + ...option, + size: option.unit + ? (multiplyQuantityValue( + option.size, + option.unit.toBase, + ) as FixedNumericValue) + : option.size, + }; + }); + const normalizedIngredient = { + ...ingredient, + quantity: ingredient.quantity as FixedNumericValue | Range, + unit: normalizeUnit(ingredient.unit), + }; + if (normalizedIngredient.unit && normalizedIngredient.quantity) + normalizedIngredient.quantity = multiplyQuantityValue( + normalizedIngredient.quantity, + normalizedIngredient.unit.toBase, + ) as FixedNumericValue | Range; + + // Simple minimization exercise if only one product option + if (normalizedOptions.length == 1) { + // FixedValue + if (normalizedIngredient.quantity.type === "fixed") { + const resQuantity = Math.ceil( + getNumericValue(normalizedIngredient.quantity.value) / + getNumericValue(normalizedOptions[0]!.size.value), + ); + return [ + { + product: options[0]!, + quantity: resQuantity, + totalPrice: resQuantity * options[0]!.price, + }, + ]; + } + // Range + else { + const targetQuantity = normalizedIngredient.quantity.min; + const resQuantity = Math.ceil( + getNumericValue(targetQuantity) / + getNumericValue(normalizedOptions[0]!.size.value), + ); + return [ + { + product: options[0]!, + quantity: resQuantity, + totalPrice: resQuantity * options[0]!.price, + }, + ]; + } + } + + // More complex problem if there are several options + const model: Model = { + direction: "minimize", + objective: "price", + integers: true, + constraints: { + size: { + min: + normalizedIngredient.quantity.type === "fixed" + ? getNumericValue(normalizedIngredient.quantity.value) + : getNumericValue(normalizedIngredient.quantity.min), + }, + }, + variables: normalizedOptions.reduce( + (acc, option) => { + acc[option.id] = { + price: option.price, + size: getNumericValue(option.size.value), + }; + return acc; + }, + {} as Record, + ), + }; + + const solution = solve(model); + return solution.variables.map((variable) => { + const resProductSelection = { + product: options.find((option) => option.id === variable[0])!, + quantity: variable[1], + }; + return { + ...resProductSelection, + totalPrice: + resProductSelection.quantity * resProductSelection.product.price, + }; + }); + } + + /** + * Checks if the units of an ingredient and its product options are compatible + * @param ingredient - The ingredient to check + * @param options - The product options to check + * @returns `true` if the units are compatible, `false` otherwise + */ + private checkUnitCompatibility( + ingredient: Ingredient, + options: ProductOption[], + ): boolean { + if (options.every((option) => option.unit === ingredient.unit)) { + return true; + } + if (!ingredient.unit && options.some((option) => option.unit)) { + return false; + } + if (ingredient.unit && options.some((option) => !option.unit)) { + return false; + } + + const optionsUnits = options.map((options) => normalizeUnit(options.unit)); + const normalizedUnit = normalizeUnit(ingredient.unit); + if (!normalizedUnit) { + return false; + } + if (optionsUnits.some((unit) => unit?.type !== normalizedUnit?.type)) { + return false; + } + return true; + } + + /** + * Reset the cart's properties + */ + private resetCart() { + this.cart = []; + this.match = []; + this.misMatch = []; + this.summary = { totalPrice: 0, totalItems: 0 }; + } + + /** + * Calculate the cart's key info and store it in the cart's {@link ShoppingCart.summary | summary} property. + * This function is automatically invoked by {@link ShoppingCart.buildCart | buildCart() } method. + * @returns the total price and number of items in the cart + */ + summarize(): ShoppingCartSummary { + this.summary.totalPrice = this.cart.reduce( + (acc, item) => acc + item.totalPrice, + 0, + ); + this.summary.totalItems = this.cart.length; + return this.summary; + } +} diff --git a/src/errors.ts b/src/errors.ts index b9fd515..a02c5cc 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -13,3 +13,51 @@ You can either remove the reference to create a new ${item_type} defined as ${ne this.name = "ReferencedItemCannotBeRedefinedError"; } } + +export class NoProductCatalogForCartError extends Error { + constructor() { + super( + `Cannot build a cart without a product catalog. Please set one using setProductCatalog()`, + ); + this.name = "NoProductCatalogForCartError"; + } +} + +export class NoShoppingListForCartError extends Error { + constructor() { + super( + `Cannot build a cart without a shopping list. Please set one using setShoppingList()`, + ); + this.name = "NoShoppingListForCartError"; + } +} + +export type NoProductMatchErrorCode = + | "incompatibleUnits" + | "noProduct" + | "textValue" + | "noQuantity"; + +export class NoProductMatchError extends Error { + code: NoProductMatchErrorCode; + + constructor(item_name: string, code: NoProductMatchErrorCode) { + const messageMap: Record = { + incompatibleUnits: `The units of the products in the catalogue are incompatible with ingredient ${item_name} in the shopping list.`, + noProduct: + "No product was found linked to ingredient name ${item_name} in the shopping list", + textValue: `Ingredient ${item_name} has a text value as quantity and can therefore not be matched with any product in the catalogue.`, + noQuantity: `Ingredient ${item_name} has no quantity and can therefore not be matched with any product in the catalogue.`, + }; + super(messageMap[code]); + this.code = code; + this.name = "NoProductMatchError"; + } +} + +export class InvalidProductCatalogFormat extends Error { + constructor() { + super("Invalid product catalog format."); + this.name = "InvalidProductCatalogFormat"; + } +} diff --git a/src/index.ts b/src/index.ts index b4a9dc6..ea44fd4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,12 @@ import { CategoryConfig } from "./classes/category_config"; +import { ProductCatalog } from "./classes/product_catalog"; import { Recipe } from "./classes/recipe"; import { ShoppingList } from "./classes/shopping_list"; +import { + ShoppingCart, + type ShoppingCartOptions, + type ShoppingCartSummary, +} from "./classes/shopping_cart"; import { Section } from "./classes/section"; import type { @@ -30,12 +36,23 @@ import type { CategoryIngredient, Category, QuantityPart, + ProductOption, + ProductSelection, + CartContent, + ProductMatch, + CartMatch, + ProductMisMatch, + CartMisMatch, } from "./types"; export { Recipe, ShoppingList, + ShoppingCart, + ShoppingCartOptions, + ShoppingCartSummary, CategoryConfig, + ProductCatalog, Metadata, Ingredient, IngredientFlag, @@ -63,4 +80,11 @@ export { Category, Section, QuantityPart, + ProductOption, + ProductSelection, + CartContent, + ProductMatch, + CartMatch, + ProductMisMatch, + CartMisMatch, }; diff --git a/src/parser_helpers.ts b/src/parser_helpers.ts index 370ca86..777624c 100644 --- a/src/parser_helpers.ts +++ b/src/parser_helpers.ts @@ -256,6 +256,21 @@ export const parseFixedValue = ( return { type: "decimal", value: Number(s) }; }; +export function stringifyQuantityValue(quantity: FixedValue | Range): string { + if (quantity.type === "fixed") { + return stringifyFixedValue(quantity); + } else { + return `${stringifyFixedValue({ type: "fixed", value: quantity.min })}-${stringifyFixedValue({ type: "fixed", value: quantity.max })}`; + } +} + +function stringifyFixedValue(quantity: FixedValue): string { + if (quantity.value.type === "fraction") + return `${quantity.value.num}/${quantity.value.den}`; + else return String(quantity.value.value); +} + +// TODO: rename to parseQuantityValue export function parseQuantityInput(input_str: string): FixedValue | Range { const clean_str = String(input_str).trim(); @@ -409,3 +424,7 @@ export function extractMetadata(content: string): MetadataExtract { return { metadata, servings }; } + +export function isPositiveIntegerString(str: string): boolean { + return /^\d+$/.test(str); +} diff --git a/src/types.ts b/src/types.ts index 90798a4..eabd8c9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,5 @@ import type { Recipe } from "./classes/recipe"; +import { NoProductMatchErrorCode } from "./errors"; import type { Quantity } from "./units"; /** @@ -170,6 +171,11 @@ export interface FixedValue { value: TextValue | DecimalValue | FractionValue; } +export interface FixedNumericValue { + type: "fixed"; + value: DecimalValue | FractionValue; +} + /** * Represents a range of quantities, e.g. "1-2" * @category Types @@ -415,3 +421,84 @@ export interface Category { /** The ingredients in the category. */ ingredients: CategoryIngredient[]; } + +/** + * Represents a product option in a {@link ProductCatalog} + * @category Types + */ +export interface ProductOption { + /** The ID of the product */ + id: string; + /** The name of the product */ + productName: string; + /** The name of the ingredient it corresponds to */ + ingredientName: string; + /** The size of the product. */ + size: FixedNumericValue; + /** The unit of the product size. */ + unit?: string; + /** The price of the product */ + price: number; +} + +/** + * Represents a product option as described in a catalog TOML file + * @category Types + */ +export interface ProductOptionToml { + /** The name of the product */ + name: string; + /** The size and unit of the product separated by % */ + size: string; + /** The price of the product */ + price: number; +} + +/** + * Represents a product selection in a {@link ShoppingCart} + * @category Types + */ +export interface ProductSelection { + /** The selected product */ + product: ProductOption; + /** The quantity of the selected product */ + quantity: number; + /** The total price for this selected product */ + totalPrice: number; +} + +/** + * Represents the content of the actual cart of the {@link ShoppingCart} + * @category Types + */ +export type CartContent = ProductSelection[]; + +/** + * Represents a successful match between a ingredient and product(s) in the product catalog, in a {@link ShoppingCart} + * @category Types + */ +export interface ProductMatch { + ingredient: Ingredient; + selection: ProductSelection[]; +} + +/** + * Represents all successful matches between ingredients and the product catalog, in a {@link ShoppingCart} + * @category Types + */ +export type CartMatch = ProductMatch[]; + +/** + * Represents an ingredient which didn't match with any product in the product catalog, in a {@link ShoppingCart} + * @category Types + */ +export interface ProductMisMatch { + ingredient: Ingredient; + reason: NoProductMatchErrorCode; +} + +/** + * Represents all ingredients which didn't match with any product in the product catalog, in a {@link ShoppingCart} + * @category Types + */ +export type CartMisMatch = ProductMisMatch[]; diff --git a/src/units.ts b/src/units.ts index 45f8e2c..94e3584 100644 --- a/src/units.ts +++ b/src/units.ts @@ -195,6 +195,14 @@ export function simplifyFraction( } } +export function getNumericValue(v: DecimalValue | FractionValue): number { + // TODO: rename NumericValue to NumericalValue for all relevant functions + if (v.type === "decimal") { + return v.value; + } + return v.num / v.den; +} + export function multiplyNumericValue( v: DecimalValue | FractionValue, factor: number | Big, diff --git a/test/parser_helpers.test.ts b/test/parser_helpers.test.ts index 1a6eadd..5be19e1 100644 --- a/test/parser_helpers.test.ts +++ b/test/parser_helpers.test.ts @@ -12,6 +12,7 @@ import { extractMetadata, findAndUpsertCookware, findAndUpsertIngredient, + stringifyQuantityValue, } from "../src/parser_helpers"; describe("parseSimpleMetaVar", () => { @@ -595,3 +596,30 @@ describe("parseQuantityInput", () => { }); }); }); + +describe("stringifyQuantityValue", () => { + it("correctly stringify fixed values", () => { + expect( + stringifyQuantityValue({ + type: "fixed", + value: { type: "decimal", value: 1.5 }, + }), + ).toEqual("1.5"); + expect( + stringifyQuantityValue({ + type: "fixed", + value: { type: "fraction", num: 2, den: 3 }, + }), + ).toEqual("2/3"); + }); + + it("correctly stringify ranges", () => { + expect( + stringifyQuantityValue({ + type: "range", + min: { type: "decimal", value: 1 }, + max: { type: "decimal", value: 2 }, + }), + ).toEqual("1-2"); + }); +}); diff --git a/test/product_catalog.test.ts b/test/product_catalog.test.ts new file mode 100644 index 0000000..1d1599a --- /dev/null +++ b/test/product_catalog.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect } from "vitest"; +import { ProductCatalog } from "../src/classes/product_catalog"; +import { InvalidProductCatalogFormat } from "../src/errors"; +import { ProductOption } from "../src"; + +describe("ProductCatalog", () => { + const exampleTomlContent = `[eggs] +01123 = { name = "Single Egg", size = "1", price = 2 } +11244 = { name = "Pack of 6 eggs", size = "6", price = 10 } + +[flour] +01124 = { name = "Small pack", size = "100%g", price = 1.5 } +14141 = { name = "Big pack", size = "6%kg", price = 10 }`; + + const exampleTomlContentAlt = `[eggs.11244] +price = 10 +name = "Pack of 6 eggs" +size = "6" + +[eggs.01123] +price = 2 +name = "Single Egg" +size = "1" + +[flour.14141] +price = 10 +name = "Big pack" +size = "6%kg" + +[flour.01124] +price = 1.5 +name = "Small pack" +size = "100%g" +`; + + const exampleProductOptions: ProductOption[] = [ + { + id: "11244", + productName: "Pack of 6 eggs", + ingredientName: "eggs", + price: 10, + size: { type: "fixed", value: { type: "decimal", value: 6 } }, + }, + { + id: "01123", + productName: "Single Egg", + ingredientName: "eggs", + price: 2, + size: { type: "fixed", value: { type: "decimal", value: 1 } }, + }, + { + id: "14141", + productName: "Big pack", + ingredientName: "flour", + price: 10, + size: { type: "fixed", value: { type: "decimal", value: 6 } }, + unit: "kg", + }, + { + id: "01124", + productName: "Small pack", + ingredientName: "flour", + price: 1.5, + size: { type: "fixed", value: { type: "decimal", value: 100 } }, + unit: "g", + }, + ]; + + describe("parsing", () => { + it("should parse a valid product catalog", () => { + const catalog = new ProductCatalog(); + const products = catalog.parse(exampleTomlContent); + expect(products.length).toBe(4); + expect(products).toEqual(exampleProductOptions); + }); + + it("should parse the same valid product catalog presented in dotted format", () => { + const catalog = new ProductCatalog(); + const products = catalog.parse(exampleTomlContentAlt); + expect(products.length).toBe(4); + expect(products).toEqual(exampleProductOptions); + }); + + it("should throw an error for an invalid product catalog", () => { + const tomlContentArray = [ + // Ingredient value is not a table + `eggs = "not a table"`, + // Product is not an object + `[eggs] +01123 = "not an object"`, + // No price + `[eggs] +01123 = { name = "Single Egg", size = "1" }`, + // No size + `[eggs] +Text = { name = "Single Egg", size = "1", price = 2 }`, + // No ingredient name + ` +01234 = { name = "Single Egg", size = "1", price = 2 }`, + // No product name + `[eggs] +01234 = { size = "1", price = 2 }`, + // Non numerical price + `[flour] +01234 = { name = "Single Pack", size = "100%g", price = "2" }`, + // Non authorized key + `[flour] +01234 = { name = "Single Pack", size = "100%g", price = 2, some-other-key = "yo" }`, + ]; + for (const tomlContent of tomlContentArray) { + expect(() => new ProductCatalog(tomlContent)).toThrow( + InvalidProductCatalogFormat, + ); + } + }); + }); + + describe("stringifying", () => { + it("should stringify a valid product catalog", () => { + const catalog = new ProductCatalog(); + catalog.products = exampleProductOptions; + const stringified = catalog.stringify(); + expect(stringified).toBe(exampleTomlContentAlt); + }); + }); + + describe("adding", () => { + it("should add a product to the catalog", () => { + const catalog = new ProductCatalog(); + const newProduct: ProductOption = { + id: "12345", + productName: "New Product", + ingredientName: "new-ingredient", + size: { type: "fixed", value: { type: "decimal", value: 1 } }, + unit: "kg", + price: 10, + }; + catalog.add(newProduct); + expect(catalog.products.length).toBe(1); + expect(catalog.products[0]).toEqual(newProduct); + }); + }); + + describe("removing", () => { + it("should remove a product from the catalog", () => { + const catalog = new ProductCatalog(); + catalog.products = exampleProductOptions; + catalog.remove("11244"); + expect(catalog.products.length).toBe(3); + expect(catalog.products).not.toContainEqual({ + id: "11244", + productName: "Pack of 6 eggs", + ingredientName: "eggs", + price: 10, + size: { type: "fixed", value: { type: "decimal", value: 6 } }, + }); + }); + }); +}); diff --git a/test/shopping_cart.test.ts b/test/shopping_cart.test.ts new file mode 100644 index 0000000..d5de399 --- /dev/null +++ b/test/shopping_cart.test.ts @@ -0,0 +1,290 @@ +import { describe, it, expect } from "vitest"; +import { ShoppingCart } from "../src/classes/shopping_cart"; +import { ShoppingList } from "../src/classes/shopping_list"; +import { Recipe } from "../src/classes/recipe"; +import { ProductCatalog } from "../src/classes/product_catalog"; +import { + NoProductCatalogForCartError, + NoShoppingListForCartError, +} from "../src/errors"; +import { + recipeForShoppingList1, + recipeForShoppingList2, +} from "./fixtures/recipes"; + +const productCatalog: ProductCatalog = new ProductCatalog(); +productCatalog.products = [ + { + id: "flour-80g", + productName: "Flour (80g)", + ingredientName: "flour", + price: 25, + size: { type: "fixed", value: { type: "decimal", value: 80 } }, + unit: "g", + }, + { + id: "flour-40g", + productName: "Flour (40g)", + ingredientName: "flour", + price: 15, + size: { type: "fixed", value: { type: "decimal", value: 40 } }, + unit: "g", + }, + { + id: "eggs-1", + productName: "Single Egg", + ingredientName: "eggs", + price: 20, + size: { type: "fixed", value: { type: "decimal", value: 1 } }, + }, + { + id: "milk-1L", + productName: "Milk (1L)", + ingredientName: "milk", + price: 30, + size: { type: "fixed", value: { type: "decimal", value: 1 } }, + unit: "l", + }, +]; + +describe("initialisation", () => { + it("should be initialized directly with the class constructor", () => { + const shoppingList = new ShoppingList(); + shoppingList.add_recipe(new Recipe(recipeForShoppingList1)); + const shoppingCart = new ShoppingCart({ + catalog: productCatalog, + list: shoppingList, + }); + expect(shoppingCart.productCatalog).toBe(productCatalog); + expect(shoppingCart.shoppingList).toBe(shoppingList); + }); + + it("should throw an error if no shopping list is set", () => { + const shoppingCart = new ShoppingCart(); + shoppingCart.setProductCatalog(productCatalog); + expect(() => shoppingCart.buildCart()).toThrow(NoShoppingListForCartError); + }); + + it("should throw an error if no product catalog is set", () => { + const shoppingList = new ShoppingList(); + shoppingList.add_recipe(new Recipe(recipeForShoppingList1)); + const shoppingCart = new ShoppingCart(); + shoppingCart.setShoppingList(shoppingList); + expect(() => shoppingCart.buildCart()).toThrow( + NoProductCatalogForCartError, + ); + }); +}); + +describe("buildCart", () => { + it("should handle ingredients with no matching products", () => { + const shoppingCart = new ShoppingCart(); + const shoppingList = new ShoppingList(); + const recipe = new Recipe("@unknown-ingredient{1}"); + shoppingList.add_recipe(recipe); + shoppingCart.setShoppingList(shoppingList); + shoppingCart.setProductCatalog(productCatalog); + shoppingCart.buildCart(); + + expect(shoppingCart.cart).toEqual([]); + }); + + it("should handle ingredients with no quantity", () => { + const shoppingCart = new ShoppingCart(); + const shoppingList = new ShoppingList(); + const recipe = new Recipe("@flour"); + shoppingList.add_recipe(recipe); + shoppingCart.setShoppingList(shoppingList); + shoppingCart.setProductCatalog(productCatalog); + shoppingCart.buildCart(); + + expect(shoppingCart.cart).toEqual([]); + }); + + it("should handle ingredients with text quantity", () => { + const shoppingCart = new ShoppingCart(); + const shoppingList = new ShoppingList(); + const recipe = new Recipe("@flour{a bit}"); + shoppingList.add_recipe(recipe); + shoppingCart.setShoppingList(shoppingList); + shoppingCart.setProductCatalog(productCatalog); + shoppingCart.buildCart(); + + expect(shoppingCart.cart).toEqual([]); + }); + + it("should handle gracefully ingredient/products with for incompatible units", () => { + const shoppingCart = new ShoppingCart(); + const shoppingList = new ShoppingList(); + const recipe = new Recipe("@flour{1%l}"); + shoppingList.add_recipe(recipe); + shoppingCart.setShoppingList(shoppingList); + shoppingCart.setProductCatalog(productCatalog); + shoppingCart.buildCart(); + expect(shoppingCart.match.length).toBe(0); + expect(shoppingCart.misMatch.length).toBe(1); + expect(shoppingCart.misMatch[0]!.ingredient.name).toBe("flour"); + expect(shoppingCart.misMatch[0]!.reason).toBe("incompatibleUnits"); + + const shoppingCart2 = new ShoppingCart(); + const shoppingList2 = new ShoppingList(); + const recipe2 = new Recipe("@eggs{2}"); + const productCatalog2: ProductCatalog = new ProductCatalog(); + productCatalog2.add({ + id: "eggs-1", + productName: "Pack of 12 eggs", + ingredientName: "eggs", + price: 20, + size: { type: "fixed", value: { type: "decimal", value: 1 } }, + unit: "dozen", + }); + shoppingList2.add_recipe(recipe2); + shoppingCart2.setShoppingList(shoppingList2); + shoppingCart2.setProductCatalog(productCatalog2); + shoppingCart2.buildCart(); + expect(shoppingCart2.match.length).toBe(0); + expect(shoppingCart2.misMatch.length).toBe(1); + expect(shoppingCart2.misMatch[0]!.ingredient.name).toBe("eggs"); + expect(shoppingCart2.misMatch[0]!.reason).toBe("incompatibleUnits"); + + const shoppingCart3 = new ShoppingCart(); + const shoppingList3 = new ShoppingList(); + const recipe3 = new Recipe("@eggs{1%dozen}"); + const productCatalog3: ProductCatalog = new ProductCatalog(); + productCatalog3.add({ + id: "eggs-1", + productName: "Single Egg", + ingredientName: "eggs", + price: 20, + size: { type: "fixed", value: { type: "decimal", value: 1 } }, + }); + shoppingList3.add_recipe(recipe3); + shoppingCart3.setShoppingList(shoppingList3); + shoppingCart3.setProductCatalog(productCatalog3); + shoppingCart3.buildCart(); + expect(shoppingCart3.match.length).toBe(0); + expect(shoppingCart3.misMatch.length).toBe(1); + expect(shoppingCart3.misMatch[0]!.ingredient.name).toBe("eggs"); + expect(shoppingCart3.misMatch[0]!.reason).toBe("incompatibleUnits"); + + const shoppingCart4 = new ShoppingCart(); + const shoppingList4 = new ShoppingList(); + const recipe4 = new Recipe("@peeled tomatoes{2%cans}"); + const productCatalog4: ProductCatalog = new ProductCatalog(); + productCatalog4.add({ + id: "0123", + productName: "Peeled Tomatoes", + ingredientName: "peeled tomatoes", + price: 20, + size: { type: "fixed", value: { type: "decimal", value: 400 } }, + unit: "g", + }); + shoppingList4.add_recipe(recipe4); + shoppingCart4.setShoppingList(shoppingList4); + shoppingCart4.setProductCatalog(productCatalog4); + shoppingCart4.buildCart(); + expect(shoppingCart4.match.length).toBe(0); + expect(shoppingCart4.misMatch.length).toBe(1); + expect(shoppingCart4.misMatch[0]!.ingredient.name).toBe("peeled tomatoes"); + expect(shoppingCart4.misMatch[0]!.reason).toBe("incompatibleUnits"); + }); + + it("should choose the cheapest option", () => { + const shoppingCart = new ShoppingCart(); + const shoppingList = new ShoppingList(); + const recipe = new Recipe("@flour{600%g}"); + shoppingList.add_recipe(recipe); + shoppingCart.setShoppingList(shoppingList); + const catalog = new ProductCatalog(); + catalog.products = [ + { + id: "flour-1kg", + productName: "Flour (1kg)", + ingredientName: "flour", + price: 10, + size: { type: "fixed", value: { type: "decimal", value: 1000 } }, + unit: "g", + }, + { + id: "flour-500g", + productName: "Flour (500g)", + ingredientName: "flour", + price: 6, + size: { type: "fixed", value: { type: "decimal", value: 500 } }, + unit: "g", + }, + ]; + shoppingCart.setProductCatalog(catalog); + shoppingCart.buildCart(); + + // It should choose 1x 1kg pack (price 1) over 2x 500g packs (price 1.2) + expect(shoppingCart.cart).toEqual([ + { product: catalog.products[0], quantity: 1, totalPrice: 10 }, // 1x 1kg + ]); + }); + + it("should handle range quantities", () => { + const shoppingCart = new ShoppingCart(); + const shoppingList = new ShoppingList(); + const recipe = new Recipe("Mix @flour{30-90%g} with @milk{80-120%cL}"); + shoppingList.add_recipe(recipe); + shoppingCart.setShoppingList(shoppingList); + shoppingCart.setProductCatalog(productCatalog); + shoppingCart.buildCart(); + + expect(shoppingCart.cart).toEqual([ + // Needs at least 30g of flour. 1 x 40g should be the solution + { product: productCatalog.products[1], quantity: 1, totalPrice: 15 }, + // Needs at least 80cL of milk, 1 x 1L should be the solution + { product: productCatalog.products[3], quantity: 1, totalPrice: 30 }, + ]); + }); + + it("should build a cart with one recipe", () => { + const shoppingList = new ShoppingList(); + const shoppingCart = new ShoppingCart(); + shoppingList.add_recipe(new Recipe(recipeForShoppingList1)); + shoppingCart.setShoppingList(shoppingList); + shoppingCart.setProductCatalog(productCatalog); + shoppingCart.buildCart(); + + expect(shoppingCart.cart).toEqual([ + { product: productCatalog.products[0], quantity: 1, totalPrice: 25 }, // 1x + { product: productCatalog.products[1], quantity: 1, totalPrice: 15 }, // 1x + { product: productCatalog.products[2], quantity: 2, totalPrice: 40 }, // 1x + { product: productCatalog.products[3], quantity: 1, totalPrice: 30 }, // 1x + ]); + expect(shoppingCart.match.length).toBe(3); + expect(shoppingCart.misMatch.length).toBe(3); + }); + + it("should build a cart with multiple recipes", () => { + const shoppingCart = new ShoppingCart(); + const shoppingList = new ShoppingList(); + shoppingList.add_recipe(new Recipe(recipeForShoppingList1)); + shoppingList.add_recipe(new Recipe(recipeForShoppingList2)); + shoppingCart.setShoppingList(shoppingList); + shoppingCart.setProductCatalog(productCatalog); + shoppingCart.buildCart(); + + expect(shoppingCart.cart).toEqual([ + { product: productCatalog.products[0], quantity: 2, totalPrice: 50 }, // 1x + { product: productCatalog.products[2], quantity: 3, totalPrice: 60 }, // 1x + { product: productCatalog.products[3], quantity: 1, totalPrice: 30 }, // 1x + ]); + expect(shoppingCart.match.length).toBe(3); + expect( + shoppingCart.misMatch.map((mismatch) => [ + mismatch.ingredient.name, + mismatch.reason, + ]), + ).toEqual([ + // there's more reasons, but the first one that matches is captured + ["sugar", "noProduct"], + ["pepper", "noProduct"], + ["spices", "noProduct"], + ["butter", "noProduct"], + ["pepper", "noProduct"], + ]); + }); +}); diff --git a/test/units.test.ts b/test/units.test.ts index 443b2eb..eba0598 100644 --- a/test/units.test.ts +++ b/test/units.test.ts @@ -5,6 +5,7 @@ import { normalizeUnit, simplifyFraction, addNumericValues, + getNumericValue, CannotAddTextValueError, IncompatibleUnitsError, addQuantityValues, @@ -586,4 +587,14 @@ describe("multiplyQuantityValue", () => { value: { type: "decimal", value: 3.6 }, }); }); + + describe("getNumericValue", () => { + it("should get the numerical value of a DecimalValue", () => { + expect(getNumericValue({ type: "decimal", value: 1.2 })).toBe(1.2); + }); + + it("should get the numerical value of a FractionValue", () => { + expect(getNumericValue({ type: "fraction", num: 2, den: 3 })).toBe(2 / 3); + }); + }); }); From fcf910ad9fffc870dcba20e3097698aad486e719 Mon Sep 17 00:00:00 2001 From: Thomas Lamant Date: Tue, 25 Nov 2025 00:12:35 +0100 Subject: [PATCH 2/7] fix(errors): export NoProductMatchErrorCode --- src/classes/shopping_cart.ts | 4 ++-- src/errors.ts | 8 +------- src/index.ts | 2 ++ src/types.ts | 7 ++++++- 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/classes/shopping_cart.ts b/src/classes/shopping_cart.ts index 4cf70d1..7b7ccb9 100644 --- a/src/classes/shopping_cart.ts +++ b/src/classes/shopping_cart.ts @@ -157,9 +157,9 @@ export class ShoppingCart { buildCart(): boolean { this.resetCart(); - if (!this.productCatalog) { + if (this.productCatalog === undefined) { throw new NoProductCatalogForCartError(); - } else if (!this.shoppingList) { + } else if (this.shoppingList === undefined) { throw new NoShoppingListForCartError(); } diff --git a/src/errors.ts b/src/errors.ts index a02c5cc..43976bf 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,4 +1,4 @@ -import { IngredientFlag, CookwareFlag } from "./types"; +import { IngredientFlag, CookwareFlag, NoProductMatchErrorCode } from "./types"; export class ReferencedItemCannotBeRedefinedError extends Error { constructor( @@ -32,12 +32,6 @@ export class NoShoppingListForCartError extends Error { } } -export type NoProductMatchErrorCode = - | "incompatibleUnits" - | "noProduct" - | "textValue" - | "noQuantity"; - export class NoProductMatchError extends Error { code: NoProductMatchErrorCode; diff --git a/src/index.ts b/src/index.ts index ea44fd4..936406a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -43,6 +43,7 @@ import type { CartMatch, ProductMisMatch, CartMisMatch, + NoProductMatchErrorCode, } from "./types"; export { @@ -87,4 +88,5 @@ export { CartMatch, ProductMisMatch, CartMisMatch, + NoProductMatchErrorCode, }; diff --git a/src/types.ts b/src/types.ts index eabd8c9..64205bb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,4 @@ import type { Recipe } from "./classes/recipe"; -import { NoProductMatchErrorCode } from "./errors"; import type { Quantity } from "./units"; /** @@ -488,6 +487,12 @@ export interface ProductMatch { */ export type CartMatch = ProductMatch[]; +export type NoProductMatchErrorCode = + | "incompatibleUnits" + | "noProduct" + | "textValue" + | "noQuantity"; + /** * Represents an ingredient which didn't match with any product in the product catalog, in a {@link ShoppingCart} * @category Types From b3f2c5a934ecd76c61455f49106b4a58a9a50e4c Mon Sep 17 00:00:00 2001 From: Thomas Lamant Date: Tue, 25 Nov 2025 01:00:54 +0100 Subject: [PATCH 3/7] docs(ProductCatalog): fix Class docstring --- src/classes/product_catalog.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/classes/product_catalog.ts b/src/classes/product_catalog.ts index 8a84f7b..3bef06c 100644 --- a/src/classes/product_catalog.ts +++ b/src/classes/product_catalog.ts @@ -16,25 +16,25 @@ import { InvalidProductCatalogFormat } from "../errors"; * Product Catalog Manager * * Used in conjunction with {@link ShoppingCart} - * + * * ## Usage * * You can either directly populate the products by feeding the {@link ProductCatalog.products | products} property. Alternatively, - * you can provide a catalog in TOML format to either the constructor itself or to the {@link ProductCatalog.parse | parse()} method. + * you can provide a catalog in TOML format to either the constructor itself or to the {@link ProductCatalog.parse | parse()} method. * * @category Classes - * + * * @example * ```typescript * import { ProductCatalog } from "@tmlmt/cooklang-parser"; * * const catalog = `[eggs] -01123 = { name = "Single Egg", size = "1", price = 2 } -11244 = { name = "Pack of 6 eggs", size = "6", price = 10 } - -[flour] -01124 = { name = "Small pack", size = "100%g", price = 1.5 } -14141 = { name = "Big pack", size = "6%kg", price = 10 } + * 01123 = { name = "Single Egg", size = "1", price = 2 } + * 11244 = { name = "Pack of 6 eggs", size = "6", price = 10 } + * + * [flour] + * 01124 = { name = "Small pack", size = "100%g", price = 1.5 } + * 14141 = { name = "Big pack", size = "6%kg", price = 10 } * ` * const catalog = new ProductCatalog(catalog); * ``` From 555823a463cb56e3928c677153d7f19f441da402 Mon Sep 17 00:00:00 2001 From: Thomas Lamant Date: Tue, 25 Nov 2025 02:25:30 +0100 Subject: [PATCH 4/7] feat(ProductCatalog): allow for ingredient aliases --- src/classes/product_catalog.ts | 138 ++++++++++++++++++++------------- src/classes/shopping_cart.ts | 4 +- src/types.ts | 2 + test/product_catalog.test.ts | 129 ++++++++++++++++++++++++------ 4 files changed, 194 insertions(+), 79 deletions(-) diff --git a/src/classes/product_catalog.ts b/src/classes/product_catalog.ts index 3bef06c..28eb00b 100644 --- a/src/classes/product_catalog.ts +++ b/src/classes/product_catalog.ts @@ -29,21 +29,23 @@ import { InvalidProductCatalogFormat } from "../errors"; * import { ProductCatalog } from "@tmlmt/cooklang-parser"; * * const catalog = `[eggs] + * aliases = ["oeuf", "huevo"] * 01123 = { name = "Single Egg", size = "1", price = 2 } * 11244 = { name = "Pack of 6 eggs", size = "6", price = 10 } - * * [flour] + * aliases = ["farine", "Mehl"] * 01124 = { name = "Small pack", size = "100%g", price = 1.5 } * 14141 = { name = "Big pack", size = "6%kg", price = 10 } * ` * const catalog = new ProductCatalog(catalog); + * const eggs = catalog.find("oeuf"); * ``` */ export class ProductCatalog { public products: ProductOption[] = []; constructor(tomlContent?: string) { - if (tomlContent) this.products = this.parse(tomlContent); + if (tomlContent) this.parse(tomlContent); } /** @@ -54,25 +56,37 @@ export class ProductCatalog { public parse(tomlContent: string): ProductOption[] { const catalogRaw = TOML.parse(tomlContent); + // Reset internal state + this.products = []; + if (!this.isValidTomlContent(catalogRaw)) { throw new InvalidProductCatalogFormat(); } - for (const [ingredientName, productsRaw] of Object.entries(catalogRaw)) { - for (const [productId, productRaw] of Object.entries(productsRaw)) { - const sizeAndUnitRaw = (productRaw as ProductOptionToml).size.split( - "%", - ); + for (const [ingredientName, ingredientData] of Object.entries(catalogRaw)) { + const ingredientTable = ingredientData as TomlTable; + const aliases = ingredientTable.aliases as string[] | undefined; + + for (const [key, productData] of Object.entries(ingredientTable)) { + if (key === "aliases") { + continue; + } + + const productId = key; + const productRaw = productData as unknown as ProductOptionToml; + + const sizeAndUnitRaw = productRaw.size.split("%"); const size = parseQuantityInput( sizeAndUnitRaw[0]!, ) as FixedNumericValue; const productOption: ProductOption = { id: productId, - productName: (productRaw as ProductOptionToml).name, + productName: productRaw.name, ingredientName: ingredientName, - price: (productRaw as ProductOptionToml).price, + price: productRaw.price, size, + ingredientAliases: aliases, }; if (sizeAndUnitRaw.length > 1) { @@ -91,28 +105,34 @@ export class ProductCatalog { * @returns The TOML string representation of the catalog. */ public stringify(): string { - const groupedProducts = this.products.reduce( - (acc, item) => { - const { id, ingredientName, size, unit, productName, ...product } = - item; - - if (!acc[ingredientName]) { - acc[ingredientName] = {}; - } - - acc[ingredientName][id] = { - ...product, - name: productName, - size: unit - ? `${stringifyQuantityValue(size)}%${unit}` - : stringifyQuantityValue(size), - }; + const grouped: Record = {}; + + for (const product of this.products) { + const { + id, + ingredientName, + ingredientAliases, + size, + unit, + productName, + ...rest + } = product; + if (!grouped[ingredientName]) { + grouped[ingredientName] = {}; + } + if (ingredientAliases && !grouped[ingredientName].aliases) { + grouped[ingredientName].aliases = ingredientAliases; + } + grouped[ingredientName][id] = { + ...rest, + name: productName, + size: unit + ? `${stringifyQuantityValue(size)}%${unit}` + : stringifyQuantityValue(size), + }; + } - return acc; - }, - {} as Record>, - ); - return TOML.stringify(groupedProducts); + return TOML.stringify(grouped); } /** @@ -131,31 +151,41 @@ export class ProductCatalog { this.products = this.products.filter((product) => product.id !== productId); } - private isValidTomlContent(products: TomlTable): boolean { - for (const productsRaw of Object.values(products)) { - for (const [id, obj] of Object.entries(productsRaw)) { - if (!isPositiveIntegerString(id)) { - return false; - } - if (typeof obj !== "object" || obj === null) { - return false; - } - - const record = obj as Record; - const keys = Object.keys(record); - - const allowedKeys = new Set(["name", "size", "price"]); - - if (keys.some((key) => !allowedKeys.has(key))) { - return false; - } - - const hasProductName = typeof record.name === "string"; - const hasSize = typeof record.size === "string"; - const hasPrice = typeof record.price === "number"; + private isValidTomlContent(catalog: TomlTable): boolean { + for (const productsRaw of Object.values(catalog)) { + if (typeof productsRaw !== "object" || productsRaw === null) { + return false; + } - if (!(hasProductName && hasSize && hasPrice)) { - return false; + for (const [id, obj] of Object.entries(productsRaw)) { + if (id === "aliases") { + if (!Array.isArray(obj)) { + return false; + } + } else { + if (!isPositiveIntegerString(id)) { + return false; + } + if (typeof obj !== "object" || obj === null) { + return false; + } + + const record = obj as Record; + const keys = Object.keys(record); + + const allowedKeys = new Set(["name", "size", "price"]); + + if (keys.some((key) => !allowedKeys.has(key))) { + return false; + } + + const hasProductName = typeof record.name === "string"; + const hasSize = typeof record.size === "string"; + const hasPrice = typeof record.price === "number"; + + if (!(hasProductName && hasSize && hasPrice)) { + return false; + } } } } diff --git a/src/classes/shopping_cart.ts b/src/classes/shopping_cart.ts index 7b7ccb9..c99e88b 100644 --- a/src/classes/shopping_cart.ts +++ b/src/classes/shopping_cart.ts @@ -190,7 +190,9 @@ export class ShoppingCart { private getProductOptions(ingredient: Ingredient): ProductOption[] { // this function is only called in buildCart() which starts by checking that a product catalog is present return this.productCatalog!.products.filter( - (product) => product.ingredientName === ingredient.name, + (product) => + product.ingredientName === ingredient.name || + product.ingredientAliases?.includes(ingredient.name), ); } diff --git a/src/types.ts b/src/types.ts index 64205bb..f52765b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -432,6 +432,8 @@ export interface ProductOption { productName: string; /** The name of the ingredient it corresponds to */ ingredientName: string; + /** The aliases of the ingredient it also corresponds to */ + ingredientAliases?: string[]; /** The size of the product. */ size: FixedNumericValue; /** The unit of the product size. */ diff --git a/test/product_catalog.test.ts b/test/product_catalog.test.ts index 1d1599a..e0d39c3 100644 --- a/test/product_catalog.test.ts +++ b/test/product_catalog.test.ts @@ -81,38 +81,59 @@ size = "100%g" expect(products).toEqual(exampleProductOptions); }); - it("should throw an error for an invalid product catalog", () => { - const tomlContentArray = [ - // Ingredient value is not a table - `eggs = "not a table"`, - // Product is not an object - `[eggs] + it("should parse a product catalog with valid aliases", () => { + const catalog = new ProductCatalog(); + const products = catalog.parse(`[eggs] +aliases = ["oeuf", "huevo"] +01123 = { name = "Single Egg", size = "1", price = 2 }`); + expect(products.length).toBe(1); + expect(products).toEqual([ + { + id: "01123", + productName: "Single Egg", + ingredientName: "eggs", + ingredientAliases: ["oeuf", "huevo"], + price: 2, + size: { type: "fixed", value: { type: "decimal", value: 1 } }, + }, + ]); + }); + + it.each([ + // Ingredient value is not a table + `eggs = "not a table"`, + // Product is not an object + `[eggs] 01123 = "not an object"`, - // No price - `[eggs] + // No price + `[eggs] 01123 = { name = "Single Egg", size = "1" }`, - // No size - `[eggs] + // No size + `[eggs] Text = { name = "Single Egg", size = "1", price = 2 }`, - // No ingredient name - ` + // No ingredient name + ` 01234 = { name = "Single Egg", size = "1", price = 2 }`, - // No product name - `[eggs] + // No product name + `[eggs] 01234 = { size = "1", price = 2 }`, - // Non numerical price - `[flour] + // Non numerical price + `[flour] 01234 = { name = "Single Pack", size = "100%g", price = "2" }`, - // Non authorized key - `[flour] + // Non authorized key + `[flour] 01234 = { name = "Single Pack", size = "100%g", price = 2, some-other-key = "yo" }`, - ]; - for (const tomlContent of tomlContentArray) { + // Invalid aliases definition + `[eggs] +aliases = "not an array"`, + ])( + "should throw an error for an invalid product catalog", + (tomlContent) => { expect(() => new ProductCatalog(tomlContent)).toThrow( InvalidProductCatalogFormat, ); - } - }); + }, + ); }); describe("stringifying", () => { @@ -122,6 +143,42 @@ Text = { name = "Single Egg", size = "1", price = 2 }`, const stringified = catalog.stringify(); expect(stringified).toBe(exampleTomlContentAlt); }); + + it("should handle products with aliases", () => { + const catalog = new ProductCatalog(); + catalog.products = [ + { + id: "11244", + productName: "Pack of 6 eggs", + ingredientName: "eggs", + ingredientAliases: ["oeuf", "huevo"], + price: 10, + size: { type: "fixed", value: { type: "decimal", value: 6 } }, + }, + { + id: "01123", + productName: "Single Egg", + ingredientName: "eggs", + ingredientAliases: ["oeuf", "huevo"], + price: 2, + size: { type: "fixed", value: { type: "decimal", value: 1 } }, + }, + ]; + const stringified = catalog.stringify(); + expect(stringified).toBe(`[eggs] +aliases = [ "oeuf", "huevo" ] + +[eggs.11244] +price = 10 +name = "Pack of 6 eggs" +size = "6" + +[eggs.01123] +price = 2 +name = "Single Egg" +size = "1" +`); + }); }); describe("adding", () => { @@ -143,8 +200,7 @@ Text = { name = "Single Egg", size = "1", price = 2 }`, describe("removing", () => { it("should remove a product from the catalog", () => { - const catalog = new ProductCatalog(); - catalog.products = exampleProductOptions; + const catalog = new ProductCatalog(exampleTomlContent); catalog.remove("11244"); expect(catalog.products.length).toBe(3); expect(catalog.products).not.toContainEqual({ @@ -155,5 +211,30 @@ Text = { name = "Single Egg", size = "1", price = 2 }`, size: { type: "fixed", value: { type: "decimal", value: 6 } }, }); }); + + it("should do nothing if a product do not exist", () => { + const catalog = new ProductCatalog(exampleTomlContent); + catalog.remove("00000"); + expect(catalog.products.length).toBe(4); + expect(catalog.products).toEqual(exampleProductOptions); + }); + }); + + describe("adding and removing with aliases", () => { + it("should add and remove products with aliases", () => { + const catalog = new ProductCatalog(); + const newProduct: ProductOption = { + id: "12345", + productName: "New Product", + ingredientName: "new-ingredient", + ingredientAliases: ["alias-1"], + size: { type: "fixed", value: { type: "decimal", value: 1 } }, + unit: "kg", + price: 10, + }; + catalog.add(newProduct); + catalog.remove("12345"); + expect(catalog.products.length).toBe(0); + }); }); }); From 3a5b59720a94de4bdadc7ba7c4925e310abb155a Mon Sep 17 00:00:00 2001 From: Thomas Lamant Date: Tue, 25 Nov 2025 02:46:00 +0100 Subject: [PATCH 5/7] feat(ProductCatalog): allow for additional arbitrary metadata --- src/classes/product_catalog.ts | 22 ++++++++++-------- src/types.ts | 6 +++++ test/product_catalog.test.ts | 41 +++++++++++++++++++++++++++++++--- 3 files changed, 57 insertions(+), 12 deletions(-) diff --git a/src/classes/product_catalog.ts b/src/classes/product_catalog.ts index 28eb00b..7188cdf 100644 --- a/src/classes/product_catalog.ts +++ b/src/classes/product_catalog.ts @@ -73,21 +73,25 @@ export class ProductCatalog { } const productId = key; - const productRaw = productData as unknown as ProductOptionToml; + const { name, size, price, ...rest } = + productData as unknown as ProductOptionToml; - const sizeAndUnitRaw = productRaw.size.split("%"); - const size = parseQuantityInput( + const sizeAndUnitRaw = size.split("%"); + const sizeParsed = parseQuantityInput( sizeAndUnitRaw[0]!, ) as FixedNumericValue; const productOption: ProductOption = { id: productId, - productName: productRaw.name, + productName: name, ingredientName: ingredientName, - price: productRaw.price, - size, - ingredientAliases: aliases, + price: price, + size: sizeParsed, + ...rest, }; + if (aliases) { + productOption.ingredientAliases = aliases; + } if (sizeAndUnitRaw.length > 1) { productOption.unit = sizeAndUnitRaw[1]!; @@ -173,9 +177,9 @@ export class ProductCatalog { const record = obj as Record; const keys = Object.keys(record); - const allowedKeys = new Set(["name", "size", "price"]); + const mandatoryKeys = ["name", "size", "price"]; - if (keys.some((key) => !allowedKeys.has(key))) { + if (mandatoryKeys.some((key) => !keys.includes(key))) { return false; } diff --git a/src/types.ts b/src/types.ts index f52765b..1dc883f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -440,6 +440,9 @@ export interface ProductOption { unit?: string; /** The price of the product */ price: number; + /** Arbitrary additional metadata */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: string]: any; } /** @@ -453,6 +456,9 @@ export interface ProductOptionToml { size: string; /** The price of the product */ price: number; + /** Arbitrary additional metadata */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: string]: any; } /** diff --git a/test/product_catalog.test.ts b/test/product_catalog.test.ts index e0d39c3..1922425 100644 --- a/test/product_catalog.test.ts +++ b/test/product_catalog.test.ts @@ -99,6 +99,23 @@ aliases = ["oeuf", "huevo"] ]); }); + it("should parse a product catalog with additional metadata", () => { + const catalog = new ProductCatalog(); + const products = catalog.parse(`[eggs] +01123 = { name = "Single Egg", size = "1", price = 2, image = "egg.png" }`); + expect(products.length).toBe(1); + expect(products).toEqual([ + { + id: "01123", + image: "egg.png", + productName: "Single Egg", + ingredientName: "eggs", + price: 2, + size: { type: "fixed", value: { type: "decimal", value: 1 } }, + }, + ]); + }); + it.each([ // Ingredient value is not a table `eggs = "not a table"`, @@ -120,9 +137,6 @@ Text = { name = "Single Egg", size = "1", price = 2 }`, // Non numerical price `[flour] 01234 = { name = "Single Pack", size = "100%g", price = "2" }`, - // Non authorized key - `[flour] -01234 = { name = "Single Pack", size = "100%g", price = 2, some-other-key = "yo" }`, // Invalid aliases definition `[eggs] aliases = "not an array"`, @@ -177,6 +191,27 @@ size = "6" price = 2 name = "Single Egg" size = "1" +`); + }); + + it("should handle products with arbitrary metadata", () => { + const catalog = new ProductCatalog(); + catalog.products = [ + { + id: "11244", + productName: "Pack of 6 eggs", + ingredientName: "eggs", + price: 10, + size: { type: "fixed", value: { type: "decimal", value: 6 } }, + image: "egg.png", + }, + ]; + const stringified = catalog.stringify(); + expect(stringified).toBe(`[eggs.11244] +price = 10 +image = "egg.png" +name = "Pack of 6 eggs" +size = "6" `); }); }); From 462b8fd1cc3329c0162360586c9a4759dda4ca2e Mon Sep 17 00:00:00 2001 From: Thomas Lamant Date: Tue, 25 Nov 2025 08:50:28 +0100 Subject: [PATCH 6/7] chore(deps): pin deps --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 3e4ab03..66cf901 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "@eslint/js": "9.39.1", "@iconify-json/material-symbols": "1.2.44", "@types/big.js": "6.2.2", - "@types/js-yaml": "^4.0.9", + "@types/js-yaml": "4.0.9", "@types/node": "22.19.0", "@vitest/coverage-v8": "4.0.8", "@vitest/ui": "4.0.8", @@ -83,7 +83,7 @@ "license": "MIT", "dependencies": { "big.js": "7.0.1", - "smol-toml": "^1.5.2", - "yalps": "^0.6.3" + "smol-toml": "1.5.2", + "yalps": "0.6.3" } } From d361b7d436e257e6f803acf2756b590e61ef7fee Mon Sep 17 00:00:00 2001 From: Thomas Lamant Date: Sun, 11 Jan 2026 02:21:49 +0100 Subject: [PATCH 7/7] docs: add missing types, errors and refine docstrings for ShoppingCart and ProductCatalog --- src/classes/product_catalog.ts | 8 ++++---- src/classes/shopping_cart.ts | 2 +- src/errors.ts | 8 ++++++++ src/index.ts | 24 ++++++++++++++++++------ src/types.ts | 9 +++++++++ 5 files changed, 40 insertions(+), 11 deletions(-) diff --git a/src/classes/product_catalog.ts b/src/classes/product_catalog.ts index 7188cdf..2bd1007 100644 --- a/src/classes/product_catalog.ts +++ b/src/classes/product_catalog.ts @@ -13,9 +13,7 @@ import { import { InvalidProductCatalogFormat } from "../errors"; /** - * Product Catalog Manager - * - * Used in conjunction with {@link ShoppingCart} + * Product Catalog Manager: used in conjunction with {@link ShoppingCart} * * ## Usage * @@ -28,10 +26,12 @@ import { InvalidProductCatalogFormat } from "../errors"; * ```typescript * import { ProductCatalog } from "@tmlmt/cooklang-parser"; * - * const catalog = `[eggs] + * const catalog = ` + * [eggs] * aliases = ["oeuf", "huevo"] * 01123 = { name = "Single Egg", size = "1", price = 2 } * 11244 = { name = "Pack of 6 eggs", size = "6", price = 10 } + * * [flour] * aliases = ["farine", "Mehl"] * 01124 = { name = "Small pack", size = "100%g", price = 1.5 } diff --git a/src/classes/shopping_cart.ts b/src/classes/shopping_cart.ts index c99e88b..66685f6 100644 --- a/src/classes/shopping_cart.ts +++ b/src/classes/shopping_cart.ts @@ -53,7 +53,7 @@ export interface ShoppingCartSummary { } /** - * Shopping Cart Manager: a tool to find the best combination of products to buy to satisfy a shopping list. + * Shopping Cart Manager: a tool to find the best combination of products to buy (defined in a {@link ProductCatalog}) to satisfy a {@link ShoppingList}. * * @example * ```ts diff --git a/src/errors.ts b/src/errors.ts index 43976bf..e114279 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -14,6 +14,10 @@ You can either remove the reference to create a new ${item_type} defined as ${ne } } +/** + * Error thrown when trying to build a shopping cart without a product catalog + * @category Errors + */ export class NoProductCatalogForCartError extends Error { constructor() { super( @@ -23,6 +27,10 @@ export class NoProductCatalogForCartError extends Error { } } +/** + * Error thrown when trying to build a shopping cart without a shopping list + * @category Errors + */ export class NoShoppingListForCartError extends Error { constructor() { super( diff --git a/src/index.ts b/src/index.ts index 936406a..e06f92f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,15 @@ import { } from "./classes/shopping_cart"; import { Section } from "./classes/section"; +export { + CategoryConfig, + ProductCatalog, + Recipe, + ShoppingList, + ShoppingCart, + Section, +}; + import type { Metadata, Ingredient, @@ -19,6 +28,7 @@ import type { DecimalValue, FractionValue, TextValue, + FixedNumericValue, Timer, TextItem, IngredientItem, @@ -47,13 +57,8 @@ import type { } from "./types"; export { - Recipe, - ShoppingList, - ShoppingCart, ShoppingCartOptions, ShoppingCartSummary, - CategoryConfig, - ProductCatalog, Metadata, Ingredient, IngredientFlag, @@ -63,6 +68,7 @@ export { DecimalValue, FractionValue, TextValue, + FixedNumericValue, Timer, TextItem, IngredientItem, @@ -79,7 +85,6 @@ export { RecipeWithServings, CategoryIngredient, Category, - Section, QuantityPart, ProductOption, ProductSelection, @@ -90,3 +95,10 @@ export { CartMisMatch, NoProductMatchErrorCode, }; + +import { + NoProductCatalogForCartError, + NoShoppingListForCartError, +} from "./errors"; + +export { NoProductCatalogForCartError, NoShoppingListForCartError }; diff --git a/src/types.ts b/src/types.ts index 1dc883f..a523d1c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -170,6 +170,11 @@ export interface FixedValue { value: TextValue | DecimalValue | FractionValue; } +/** + * Represents a single, fixed numeric quantity. + * This can be a decimal or fraction. + * @category Types + */ export interface FixedNumericValue { type: "fixed"; value: DecimalValue | FractionValue; @@ -495,6 +500,10 @@ export interface ProductMatch { */ export type CartMatch = ProductMatch[]; +/** + * Represents the error codes for an ingredient which didn't match with any product in the product catalog, in a {@link ShoppingCart} + * @category Types + */ export type NoProductMatchErrorCode = | "incompatibleUnits" | "noProduct"