Skip to content

Commit b926d73

Browse files
authored
feat: new class ShoppingCart to assing ingredients in a shopping list to products from a catalog (#83)
# Description Introducing ShoppingCart and ProductCatalog to enhance shopping functionality. This PR adds: - `ProductCatalog` class to manage product options from TOML files - `ShoppingCart` class to find optimal product combinations for shopping lists - Support for matching ingredients to products with compatible units - Optimization algorithm to minimize shopping costs - Virtual shopping cart functionality with price calculation The documentation has been updated to split "Shopping" into its own feature section, highlighting the new ability to parse category configurations, create shopping lists, and fill in a virtual shopping cart based on a product catalog. ## Type of change - [x] New feature (non-breaking change which adds functionality) - [x] This change requires a documentation update
1 parent b11e4bb commit b926d73

File tree

14 files changed

+1455
-12
lines changed

14 files changed

+1455
-12
lines changed

docs/index.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ features:
2424
details: Fully-typed, compliant with the Cooklang Specifications
2525
- title: Useful extensions
2626
details: Additional features to the original specs
27-
- title: Parsing, scaling and shopping
28-
details: Classes to parse and scale recipes, as well as parse category configuration and create shopping lists
27+
- title: Parsing and scaling
28+
details: Classes to parse and scale recipes
29+
- title: Shopping
30+
details: Classes to parse category configurations, create shopping lists, and fill in a virtual shopping cart based on a product catalog
2931
---

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
"@eslint/js": "9.39.1",
4343
"@iconify-json/material-symbols": "1.2.44",
4444
"@types/big.js": "6.2.2",
45+
"@types/js-yaml": "4.0.9",
4546
"@types/node": "22.19.0",
4647
"@vitest/coverage-v8": "4.0.8",
4748
"@vitest/ui": "4.0.8",
@@ -81,6 +82,8 @@
8182
],
8283
"license": "MIT",
8384
"dependencies": {
84-
"big.js": "7.0.1"
85+
"big.js": "7.0.1",
86+
"smol-toml": "1.5.2",
87+
"yalps": "0.6.3"
8588
}
8689
}

pnpm-lock.yaml

Lines changed: 36 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/classes/product_catalog.ts

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
import TOML from "smol-toml";
2+
import type {
3+
FixedNumericValue,
4+
ProductOption,
5+
ProductOptionToml,
6+
} from "../types";
7+
import type { TomlTable } from "smol-toml";
8+
import {
9+
isPositiveIntegerString,
10+
parseQuantityInput,
11+
stringifyQuantityValue,
12+
} from "../parser_helpers";
13+
import { InvalidProductCatalogFormat } from "../errors";
14+
15+
/**
16+
* Product Catalog Manager: used in conjunction with {@link ShoppingCart}
17+
*
18+
* ## Usage
19+
*
20+
* You can either directly populate the products by feeding the {@link ProductCatalog.products | products} property. Alternatively,
21+
* you can provide a catalog in TOML format to either the constructor itself or to the {@link ProductCatalog.parse | parse()} method.
22+
*
23+
* @category Classes
24+
*
25+
* @example
26+
* ```typescript
27+
* import { ProductCatalog } from "@tmlmt/cooklang-parser";
28+
*
29+
* const catalog = `
30+
* [eggs]
31+
* aliases = ["oeuf", "huevo"]
32+
* 01123 = { name = "Single Egg", size = "1", price = 2 }
33+
* 11244 = { name = "Pack of 6 eggs", size = "6", price = 10 }
34+
*
35+
* [flour]
36+
* aliases = ["farine", "Mehl"]
37+
* 01124 = { name = "Small pack", size = "100%g", price = 1.5 }
38+
* 14141 = { name = "Big pack", size = "6%kg", price = 10 }
39+
* `
40+
* const catalog = new ProductCatalog(catalog);
41+
* const eggs = catalog.find("oeuf");
42+
* ```
43+
*/
44+
export class ProductCatalog {
45+
public products: ProductOption[] = [];
46+
47+
constructor(tomlContent?: string) {
48+
if (tomlContent) this.parse(tomlContent);
49+
}
50+
51+
/**
52+
* Parses a TOML string into a list of product options.
53+
* @param tomlContent - The TOML string to parse.
54+
* @returns A parsed list of `ProductOption`.
55+
*/
56+
public parse(tomlContent: string): ProductOption[] {
57+
const catalogRaw = TOML.parse(tomlContent);
58+
59+
// Reset internal state
60+
this.products = [];
61+
62+
if (!this.isValidTomlContent(catalogRaw)) {
63+
throw new InvalidProductCatalogFormat();
64+
}
65+
66+
for (const [ingredientName, ingredientData] of Object.entries(catalogRaw)) {
67+
const ingredientTable = ingredientData as TomlTable;
68+
const aliases = ingredientTable.aliases as string[] | undefined;
69+
70+
for (const [key, productData] of Object.entries(ingredientTable)) {
71+
if (key === "aliases") {
72+
continue;
73+
}
74+
75+
const productId = key;
76+
const { name, size, price, ...rest } =
77+
productData as unknown as ProductOptionToml;
78+
79+
const sizeAndUnitRaw = size.split("%");
80+
const sizeParsed = parseQuantityInput(
81+
sizeAndUnitRaw[0]!,
82+
) as FixedNumericValue;
83+
84+
const productOption: ProductOption = {
85+
id: productId,
86+
productName: name,
87+
ingredientName: ingredientName,
88+
price: price,
89+
size: sizeParsed,
90+
...rest,
91+
};
92+
if (aliases) {
93+
productOption.ingredientAliases = aliases;
94+
}
95+
96+
if (sizeAndUnitRaw.length > 1) {
97+
productOption.unit = sizeAndUnitRaw[1]!;
98+
}
99+
100+
this.products.push(productOption);
101+
}
102+
}
103+
104+
return this.products;
105+
}
106+
107+
/**
108+
* Stringifies the catalog to a TOML string.
109+
* @returns The TOML string representation of the catalog.
110+
*/
111+
public stringify(): string {
112+
const grouped: Record<string, TomlTable> = {};
113+
114+
for (const product of this.products) {
115+
const {
116+
id,
117+
ingredientName,
118+
ingredientAliases,
119+
size,
120+
unit,
121+
productName,
122+
...rest
123+
} = product;
124+
if (!grouped[ingredientName]) {
125+
grouped[ingredientName] = {};
126+
}
127+
if (ingredientAliases && !grouped[ingredientName].aliases) {
128+
grouped[ingredientName].aliases = ingredientAliases;
129+
}
130+
grouped[ingredientName][id] = {
131+
...rest,
132+
name: productName,
133+
size: unit
134+
? `${stringifyQuantityValue(size)}%${unit}`
135+
: stringifyQuantityValue(size),
136+
};
137+
}
138+
139+
return TOML.stringify(grouped);
140+
}
141+
142+
/**
143+
* Adds a product to the catalog.
144+
* @param productOption - The product to add.
145+
*/
146+
public add(productOption: ProductOption): void {
147+
this.products.push(productOption);
148+
}
149+
150+
/**
151+
* Removes a product from the catalog by its ID.
152+
* @param productId - The ID of the product to remove.
153+
*/
154+
public remove(productId: string): void {
155+
this.products = this.products.filter((product) => product.id !== productId);
156+
}
157+
158+
private isValidTomlContent(catalog: TomlTable): boolean {
159+
for (const productsRaw of Object.values(catalog)) {
160+
if (typeof productsRaw !== "object" || productsRaw === null) {
161+
return false;
162+
}
163+
164+
for (const [id, obj] of Object.entries(productsRaw)) {
165+
if (id === "aliases") {
166+
if (!Array.isArray(obj)) {
167+
return false;
168+
}
169+
} else {
170+
if (!isPositiveIntegerString(id)) {
171+
return false;
172+
}
173+
if (typeof obj !== "object" || obj === null) {
174+
return false;
175+
}
176+
177+
const record = obj as Record<string, unknown>;
178+
const keys = Object.keys(record);
179+
180+
const mandatoryKeys = ["name", "size", "price"];
181+
182+
if (mandatoryKeys.some((key) => !keys.includes(key))) {
183+
return false;
184+
}
185+
186+
const hasProductName = typeof record.name === "string";
187+
const hasSize = typeof record.size === "string";
188+
const hasPrice = typeof record.price === "number";
189+
190+
if (!(hasProductName && hasSize && hasPrice)) {
191+
return false;
192+
}
193+
}
194+
}
195+
}
196+
197+
return true;
198+
}
199+
}

0 commit comments

Comments
 (0)