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 de4a95b..4c85f83 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", @@ -50,7 +51,7 @@ "eslint-plugin-tsdoc": "0.4.0", "execa": "9.6.0", "globals": "16.5.0", - "human-regex": "2.1.5", + "human-regex": "2.2.0", "prettier": "3.6.2", "rimraf": "6.1.0", "tsup": "8.5.0", @@ -80,6 +81,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/patches/human-regex@2.1.5.patch b/patches/human-regex@2.1.5.patch deleted file mode 100644 index cb2f62e..0000000 --- a/patches/human-regex@2.1.5.patch +++ /dev/null @@ -1,117 +0,0 @@ -diff --git a/README.md b/README.md -index f95a4dc41c1f3a8371d694e1e1913f275acb22fa..afb60390589d9bf60b0f7297f24c29ca348a1866 100644 ---- a/README.md -+++ b/README.md -@@ -93,8 +93,13 @@ Creates a new regex builder instance. - | `.anyCharacter()` | Adds a pattern for any character (`.`). | `.` | - | `.literal("text")` | Adds a literal text pattern. | `["text"]` | - | `.or()` | Adds an OR pattern. | `\|` | -+| `.newline()` | Adds a pattern for newline characters. | `(\r\n\|\r\|\n)` | - | `.range("digit")` | Adds a range pattern for digits (`0-9`). | `[0-9]` | --| `.notRange("aeiou")` | Excludes characters from the pattern. | `[^aeiou]` | -+| `.notRange("letter")` | Adds a range pattern for non-letters | `[^a-zA-Z]` | -+| `.anyOf("aeiou\\s")` | Adds list of accepted characters | `[aeiou\s]` | -+| `.notAnyOf("aeiou\\s")` | Adds list of excluded characters | `[^aeiou\s]` | -+ -+ - - ### Quantifiers - -@@ -165,6 +170,26 @@ Creates a new regex builder instance. - - `Patterns.url`: Predefined URL pattern. - - `Patterns.phoneInternational`: Predefined international phone number pattern. - -+### Predefined Ranges -+ -+For use with `range()` and `notRange()`: -+ -+ digit: "0-9", -+ lowercaseLetter: "a-z", -+ uppercaseLetter: "A-Z", -+ letter: "a-zA-Z", -+ alphanumeric: "a-zA-Z0-9", -+ anyCharacter: ".", -+ -+| RangeKey | Range value | -+| ------------------------ | --------------- | -+| `digit` | `0-9` | -+| `lowercaseLetter` | `a-z` | -+| `uppercaseLetter` | `A-Z` | -+| `letter` | `a-zA-Z` | -+| `alphanumeric` | `a-zA-Z0-9` | -+| `anyCharacter` | `.` | -+ - ## Examples - - ### Combining with Existing Regex -diff --git a/dist/human-regex.cjs.js b/dist/human-regex.cjs.js -index 97b4577ef5d72b9edf8e201312f806a5d900ac1b..505e4f2771a4145ff427540ef0d79c2833551d36 100644 ---- a/dist/human-regex.cjs.js -+++ b/dist/human-regex.cjs.js -@@ -1,2 +1,2 @@ --"use strict";const t=new Map,r={GLOBAL:"g",NON_SENSITIVE:"i",MULTILINE:"m",DOT_ALL:"s",UNICODE:"u",STICKY:"y"},e=Object.freeze({digit:"0-9",lowercaseLetter:"a-z",uppercaseLetter:"A-Z",letter:"a-zA-Z",alphanumeric:"a-zA-Z0-9",anyCharacter:"."}),a=Object.freeze({zeroOrMore:"*",oneOrMore:"+",optional:"?"});class n{constructor(){this.parts=[],this.flags=new Set}digit(){return this.add("\\d")}special(){return this.add("(?=.*[!@#$%^&*])")}word(){return this.add("\\w")}whitespace(){return this.add("\\s")}nonWhitespace(){return this.add("\\S")}literal(r){return this.add(function(r){t.has(r)||t.set(r,r.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"));return t.get(r)}(r))}or(){return this.add("|")}range(t){const r=e[t];if(!r)throw new Error(`Unknown range: ${t}`);return this.add(`[${r}]`)}notRange(t){return this.add(`[^${t}]`)}lazy(){const t=this.parts.pop();if(!t)throw new Error("No quantifier to make lazy");return this.add(`${t}?`)}letter(){return this.add("[a-zA-Z]")}anyCharacter(){return this.add(".")}negativeLookahead(t){return this.add(`(?!${t})`)}positiveLookahead(t){return this.add(`(?=${t})`)}positiveLookbehind(t){return this.add(`(?<=${t})`)}negativeLookbehind(t){return this.add(`(?`)}startGroup(){return this.add("(?:")}startCaptureGroup(){return this.add("(")}wordBoundary(){return this.add("\\b")}nonWordBoundary(){return this.add("\\B")}endGroup(){return this.add(")")}startAnchor(){return this.add("^")}endAnchor(){return this.add("$")}global(){return this.flags.add(r.GLOBAL),this}nonSensitive(){return this.flags.add(r.NON_SENSITIVE),this}multiline(){return this.flags.add(r.MULTILINE),this}dotAll(){return this.flags.add(r.DOT_ALL),this}sticky(){return this.flags.add(r.STICKY),this}unicodeChar(t){this.flags.add(r.UNICODE);const e=new Set(["u","l","t","m","o"]);if(void 0!==t&&!e.has(t))throw new Error(`Invalid Unicode letter variant: ${t}`);return this.add(`\\p{L${null!=t?t:""}}`)}unicodeDigit(){return this.flags.add(r.UNICODE),this.add("\\p{N}")}unicodePunctuation(){return this.flags.add(r.UNICODE),this.add("\\p{P}")}unicodeSymbol(){return this.flags.add(r.UNICODE),this.add("\\p{S}")}repeat(t){if(0===this.parts.length)throw new Error("No pattern to repeat");const r=this.parts.pop();return this.parts.push(`(${r}){${t}}`),this}ipv4Octet(){return this.add("(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)")}protocol(){return this.add("https?://")}www(){return this.add("(www\\.)?")}tld(){return this.add("(com|org|net)")}path(){return this.add("(/\\w+)*")}add(t){return this.parts.push(t),this}toString(){return this.parts.join("")}toRegExp(){return new RegExp(this.toString(),[...this.flags].join(""))}}const d=()=>new n,s=(()=>{const t=t=>{const r=t().toRegExp();return()=>new RegExp(r.source,r.flags)};return{email:t((()=>d().startAnchor().word().oneOrMore().literal("@").word().oneOrMore().startGroup().literal(".").word().oneOrMore().endGroup().zeroOrMore().literal(".").letter().atLeast(2).endAnchor())),url:t((()=>d().startAnchor().protocol().www().word().oneOrMore().literal(".").tld().path().endAnchor())),phoneInternational:t((()=>d().startAnchor().literal("+").digit().between(1,3).literal("-").digit().between(3,14).endAnchor()))}})();exports.Flags=r,exports.Patterns=s,exports.Quantifiers=a,exports.Ranges=e,exports.createRegex=d; -+"use strict";const t=new Map,r={GLOBAL:"g",NON_SENSITIVE:"i",MULTILINE:"m",DOT_ALL:"s",UNICODE:"u",STICKY:"y"},e=Object.freeze({digit:"0-9",lowercaseLetter:"a-z",uppercaseLetter:"A-Z",letter:"a-zA-Z",alphanumeric:"a-zA-Z0-9",anyCharacter:"."}),n=Object.freeze({zeroOrMore:"*",oneOrMore:"+",optional:"?"});class a{constructor(){this.parts=[],this.flags=new Set}digit(){return this.add("\\d")}special(){return this.add("(?=.*[!@#$%^&*])")}word(){return this.add("\\w")}whitespace(){return this.add("\\s")}nonWhitespace(){return this.add("\\S")}literal(r){return this.add(function(r){t.has(r)||t.set(r,r.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"));return t.get(r)}(r))}or(){return this.add("|")}range(t){const r=e[t];if(!r)throw new Error(`Unknown range: ${t}`);return this.add(`[${r}]`)}notRange(t){const r=e[t];if(!r)throw new Error(`Unknown range: ${t}`);return this.add(`[^${r}]`)}anyOf(t){return this.add(`[${t}]`)}notAnyOf(t){return this.add(`[^${t}]`)}lazy(){const t=this.parts.pop();if(!t)throw new Error("No quantifier to make lazy");return this.add(`${t}?`)}letter(){return this.add("[a-zA-Z]")}anyCharacter(){return this.add(".")}newline(){return this.add("(?:\\r\\n|\\r|\\n)")}negativeLookahead(t){return this.add(`(?!${t})`)}positiveLookahead(t){return this.add(`(?=${t})`)}positiveLookbehind(t){return this.add(`(?<=${t})`)}negativeLookbehind(t){return this.add(`(?`)}startGroup(){return this.add("(?:")}startCaptureGroup(){return this.add("(")}wordBoundary(){return this.add("\\b")}nonWordBoundary(){return this.add("\\B")}endGroup(){return this.add(")")}startAnchor(){return this.add("^")}endAnchor(){return this.add("$")}global(){return this.flags.add(r.GLOBAL),this}nonSensitive(){return this.flags.add(r.NON_SENSITIVE),this}multiline(){return this.flags.add(r.MULTILINE),this}dotAll(){return this.flags.add(r.DOT_ALL),this}sticky(){return this.flags.add(r.STICKY),this}unicodeChar(t){this.flags.add(r.UNICODE);const e=new Set(["u","l","t","m","o"]);if(void 0!==t&&!e.has(t))throw new Error(`Invalid Unicode letter variant: ${t}`);return this.add(`\\p{L${null!=t?t:""}}`)}unicodeDigit(){return this.flags.add(r.UNICODE),this.add("\\p{N}")}unicodePunctuation(){return this.flags.add(r.UNICODE),this.add("\\p{P}")}unicodeSymbol(){return this.flags.add(r.UNICODE),this.add("\\p{S}")}repeat(t){if(0===this.parts.length)throw new Error("No pattern to repeat");const r=this.parts.pop();return this.parts.push(`(${r}){${t}}`),this}ipv4Octet(){return this.add("(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)")}protocol(){return this.add("https?://")}www(){return this.add("(www\\.)?")}tld(){return this.add("(com|org|net)")}path(){return this.add("(/\\w+)*")}add(t){return this.parts.push(t),this}toString(){return this.parts.join("")}toRegExp(){return new RegExp(this.toString(),[...this.flags].join(""))}}const d=()=>new a,s=(()=>{const t=t=>{const r=t().toRegExp();return()=>new RegExp(r.source,r.flags)};return{email:t((()=>d().startAnchor().word().oneOrMore().literal("@").word().oneOrMore().startGroup().literal(".").word().oneOrMore().endGroup().zeroOrMore().literal(".").letter().atLeast(2).endAnchor())),url:t((()=>d().startAnchor().protocol().www().word().oneOrMore().literal(".").tld().path().endAnchor())),phoneInternational:t((()=>d().startAnchor().literal("+").digit().between(1,3).literal("-").digit().between(3,14).endAnchor()))}})();exports.Flags=r,exports.Patterns=s,exports.Quantifiers=n,exports.Ranges=e,exports.createRegex=d; - //# sourceMappingURL=human-regex.cjs.js.map -diff --git a/dist/human-regex.cjs.js.map b/dist/human-regex.cjs.js.map -index 4e1d7236724f234a49246eac7b74b185b3709517..8cd9133392d204bae0ace0b8e10b1b01e303572d 100644 ---- a/dist/human-regex.cjs.js.map -+++ b/dist/human-regex.cjs.js.map -@@ -1 +1 @@ --{"version":3,"file":"human-regex.cjs.js","sources":["../src/human-regex.ts"],"sourcesContent":["type PartialBut = Partial & Pick;\n\nconst escapeCache = new Map();\n\nconst Flags = {\n GLOBAL: \"g\",\n NON_SENSITIVE: \"i\",\n MULTILINE: \"m\",\n DOT_ALL: \"s\",\n UNICODE: \"u\",\n STICKY: \"y\",\n} as const;\n\nconst Ranges = Object.freeze({\n digit: \"0-9\",\n lowercaseLetter: \"a-z\",\n uppercaseLetter: \"A-Z\",\n letter: \"a-zA-Z\",\n alphanumeric: \"a-zA-Z0-9\",\n anyCharacter: \".\",\n});\n\ntype RangeKeys = keyof typeof Ranges;\n\nconst Quantifiers = Object.freeze({\n zeroOrMore: \"*\",\n oneOrMore: \"+\",\n optional: \"?\",\n});\n\ntype Quantifiers =\n | \"exactly\"\n | \"atLeast\"\n | \"atMost\"\n | \"between\"\n | \"oneOrMore\"\n | \"zeroOrMore\"\n | \"repeat\";\ntype QuantifierMethods = Quantifiers | \"optional\" | \"lazy\";\n\ntype WithLazy = HumanRegex;\ntype Base = Omit;\ntype AtStart = Omit;\ntype AfterAnchor = Omit;\ntype SimpleQuantifier = Omit;\ntype LazyQuantifier = Omit;\n\nclass HumanRegex {\n private parts: string[];\n private flags: Set;\n\n constructor() {\n this.parts = [];\n this.flags = new Set();\n }\n\n digit(): Base {\n return this.add(\"\\\\d\");\n }\n\n special(): Base {\n return this.add(\"(?=.*[!@#$%^&*])\");\n }\n\n word(): Base {\n return this.add(\"\\\\w\");\n }\n\n whitespace(): Base {\n return this.add(\"\\\\s\");\n }\n\n nonWhitespace(): Base {\n return this.add(\"\\\\S\");\n }\n\n literal(text: string): this {\n return this.add(escapeLiteral(text));\n }\n\n or(): AfterAnchor {\n return this.add(\"|\");\n }\n\n range(name: RangeKeys): Base {\n const range = Ranges[name];\n if (!range) throw new Error(`Unknown range: ${name}`);\n return this.add(`[${range}]`);\n }\n\n notRange(chars: string): Base {\n return this.add(`[^${chars}]`);\n }\n\n lazy(): Base {\n const lastPart = this.parts.pop();\n if (!lastPart) throw new Error(\"No quantifier to make lazy\");\n return this.add(`${lastPart}?`);\n }\n\n letter(): Base {\n return this.add(\"[a-zA-Z]\");\n }\n\n anyCharacter(): Base {\n return this.add(\".\");\n }\n\n negativeLookahead(pattern: string): Base {\n return this.add(`(?!${pattern})`);\n }\n\n positiveLookahead(pattern: string): Base {\n return this.add(`(?=${pattern})`);\n }\n\n positiveLookbehind(pattern: string): Base {\n return this.add(`(?<=${pattern})`);\n }\n\n negativeLookbehind(pattern: string): Base {\n return this.add(`(?`);\n }\n\n startGroup(): AfterAnchor {\n return this.add(\"(?:\");\n }\n\n startCaptureGroup(): AfterAnchor {\n return this.add(\"(\");\n }\n\n wordBoundary(): Base {\n return this.add(\"\\\\b\");\n }\n\n nonWordBoundary(): Base {\n return this.add(\"\\\\B\");\n }\n\n endGroup(): Base {\n return this.add(\")\");\n }\n\n startAnchor(): AfterAnchor {\n return this.add(\"^\");\n }\n\n endAnchor(): AfterAnchor {\n return this.add(\"$\");\n }\n\n global(): this {\n this.flags.add(Flags.GLOBAL);\n return this;\n }\n\n nonSensitive(): this {\n this.flags.add(Flags.NON_SENSITIVE);\n return this;\n }\n\n multiline(): this {\n this.flags.add(Flags.MULTILINE);\n return this;\n }\n\n dotAll(): this {\n this.flags.add(Flags.DOT_ALL);\n return this;\n }\n\n sticky(): this {\n this.flags.add(Flags.STICKY);\n return this;\n }\n\n unicodeChar(variant?: \"u\" | \"l\" | \"t\" | \"m\" | \"o\"): Base {\n this.flags.add(Flags.UNICODE);\n const validVariants = new Set([\"u\", \"l\", \"t\", \"m\", \"o\"] as const);\n\n if (variant !== undefined && !validVariants.has(variant)) {\n throw new Error(`Invalid Unicode letter variant: ${variant}`);\n }\n\n return this.add(`\\\\p{L${variant ?? \"\"}}`);\n }\n\n unicodeDigit(): Base {\n this.flags.add(Flags.UNICODE);\n return this.add(\"\\\\p{N}\");\n }\n\n unicodePunctuation(): Base {\n this.flags.add(Flags.UNICODE);\n return this.add(\"\\\\p{P}\");\n }\n\n unicodeSymbol(): Base {\n this.flags.add(Flags.UNICODE);\n return this.add(\"\\\\p{S}\");\n }\n\n repeat(count: number): Base {\n if (this.parts.length === 0) {\n throw new Error(\"No pattern to repeat\");\n }\n\n const lastPart = this.parts.pop();\n this.parts.push(`(${lastPart}){${count}}`);\n return this;\n }\n\n ipv4Octet(): Base {\n return this.add(\"(25[0-5]|2[0-4]\\\\d|1\\\\d\\\\d|[1-9]\\\\d|\\\\d)\");\n }\n\n protocol(): Base {\n return this.add(\"https?://\");\n }\n\n www(): Base {\n return this.add(\"(www\\\\.)?\");\n }\n\n tld(): Base {\n return this.add(\"(com|org|net)\");\n }\n\n path(): Base {\n return this.add(\"(/\\\\w+)*\");\n }\n\n private add(part: string): this {\n this.parts.push(part);\n return this;\n }\n\n toString(): string {\n return this.parts.join(\"\");\n }\n\n toRegExp(): RegExp {\n return new RegExp(this.toString(), [...this.flags].join(\"\"));\n }\n}\n\nfunction escapeLiteral(text: string): string {\n if (!escapeCache.has(text)) {\n escapeCache.set(text, text.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\"));\n }\n return escapeCache.get(text)!;\n}\n\nconst createRegex = (): AtStart => new HumanRegex();\n\nconst Patterns = (() => {\n const createCachedPattern = (\n builder: () => PartialBut\n ) => {\n const regex = builder().toRegExp();\n return () => new RegExp(regex.source, regex.flags);\n };\n\n return {\n email: createCachedPattern(() =>\n createRegex()\n .startAnchor()\n .word()\n .oneOrMore()\n .literal(\"@\")\n .word()\n .oneOrMore()\n .startGroup()\n .literal(\".\")\n .word()\n .oneOrMore()\n .endGroup()\n .zeroOrMore()\n .literal(\".\")\n .letter()\n .atLeast(2)\n .endAnchor()\n ),\n url: createCachedPattern(() =>\n createRegex()\n .startAnchor()\n .protocol()\n .www()\n .word()\n .oneOrMore()\n .literal(\".\")\n .tld()\n .path()\n .endAnchor()\n ),\n phoneInternational: createCachedPattern(() =>\n createRegex()\n .startAnchor()\n .literal(\"+\")\n .digit()\n .between(1, 3)\n .literal(\"-\")\n .digit()\n .between(3, 14)\n .endAnchor()\n ),\n };\n})();\n\nexport { createRegex, Patterns, Flags, Ranges, Quantifiers };\n"],"names":["escapeCache","Map","Flags","GLOBAL","NON_SENSITIVE","MULTILINE","DOT_ALL","UNICODE","STICKY","Ranges","Object","freeze","digit","lowercaseLetter","uppercaseLetter","letter","alphanumeric","anyCharacter","Quantifiers","zeroOrMore","oneOrMore","optional","HumanRegex","constructor","this","parts","flags","Set","add","special","word","whitespace","nonWhitespace","literal","text","has","set","replace","get","escapeLiteral","or","range","name","Error","notRange","chars","lazy","lastPart","pop","negativeLookahead","pattern","positiveLookahead","positiveLookbehind","negativeLookbehind","hasSpecialCharacter","hasDigit","hasLetter","exactly","n","atLeast","atMost","between","min","max","startNamedGroup","startGroup","startCaptureGroup","wordBoundary","nonWordBoundary","endGroup","startAnchor","endAnchor","global","nonSensitive","multiline","dotAll","sticky","unicodeChar","variant","validVariants","undefined","unicodeDigit","unicodePunctuation","unicodeSymbol","repeat","count","length","push","ipv4Octet","protocol","www","tld","path","part","toString","join","toRegExp","RegExp","createRegex","Patterns","createCachedPattern","builder","regex","source","email","url","phoneInternational"],"mappings":"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"} -\ No newline at end of file -+{"version":3,"file":"human-regex.cjs.js","sources":["../src/human-regex.ts"],"sourcesContent":["type PartialBut = Partial & Pick;\n\nconst escapeCache = new Map();\n\nconst Flags = {\n GLOBAL: \"g\",\n NON_SENSITIVE: \"i\",\n MULTILINE: \"m\",\n DOT_ALL: \"s\",\n UNICODE: \"u\",\n STICKY: \"y\",\n} as const;\n\nconst Ranges = Object.freeze({\n digit: \"0-9\",\n lowercaseLetter: \"a-z\",\n uppercaseLetter: \"A-Z\",\n letter: \"a-zA-Z\",\n alphanumeric: \"a-zA-Z0-9\",\n anyCharacter: \".\",\n});\n\ntype RangeKeys = keyof typeof Ranges;\n\nconst Quantifiers = Object.freeze({\n zeroOrMore: \"*\",\n oneOrMore: \"+\",\n optional: \"?\",\n});\n\ntype Quantifiers =\n | \"exactly\"\n | \"atLeast\"\n | \"atMost\"\n | \"between\"\n | \"oneOrMore\"\n | \"zeroOrMore\"\n | \"repeat\";\ntype QuantifierMethods = Quantifiers | \"optional\" | \"lazy\";\n\ntype WithLazy = HumanRegex;\ntype Base = Omit;\ntype AtStart = Omit;\ntype AfterAnchor = Omit;\ntype SimpleQuantifier = Omit;\ntype LazyQuantifier = Omit;\n\nclass HumanRegex {\n private parts: string[];\n private flags: Set;\n\n constructor() {\n this.parts = [];\n this.flags = new Set();\n }\n\n digit(): Base {\n return this.add(\"\\\\d\");\n }\n\n special(): Base {\n return this.add(\"(?=.*[!@#$%^&*])\");\n }\n\n word(): Base {\n return this.add(\"\\\\w\");\n }\n\n whitespace(): Base {\n return this.add(\"\\\\s\");\n }\n\n nonWhitespace(): Base {\n return this.add(\"\\\\S\");\n }\n\n literal(text: string): this {\n return this.add(escapeLiteral(text));\n }\n\n or(): AfterAnchor {\n return this.add(\"|\");\n }\n\n range(name: RangeKeys): Base {\n const range = Ranges[name];\n if (!range) throw new Error(`Unknown range: ${name}`);\n return this.add(`[${range}]`);\n }\n\n notRange(name: RangeKeys): Base {\n const range = Ranges[name];\n if (!range) throw new Error(`Unknown range: ${name}`);\n return this.add(`[^${range}]`);\n }\n\n anyOf(chars: string): Base {\n return this.add(`[${chars}]`);\n }\n\n notAnyOf(chars: string): Base {\n return this.add(`[^${chars}]`);\n }\n\n lazy(): Base {\n const lastPart = this.parts.pop();\n if (!lastPart) throw new Error(\"No quantifier to make lazy\");\n return this.add(`${lastPart}?`);\n }\n\n letter(): Base {\n return this.add(\"[a-zA-Z]\");\n }\n\n anyCharacter(): Base {\n return this.add(\".\");\n }\n\n newline(): Base {\n return this.add(\"(?:\\\\r\\\\n|\\\\r|\\\\n)\"); // Windows: \\r\\n, Unix: \\n, Old Macs: \\r\n }\n\n negativeLookahead(pattern: string): Base {\n return this.add(`(?!${pattern})`);\n }\n\n positiveLookahead(pattern: string): Base {\n return this.add(`(?=${pattern})`);\n }\n\n positiveLookbehind(pattern: string): Base {\n return this.add(`(?<=${pattern})`);\n }\n\n negativeLookbehind(pattern: string): Base {\n return this.add(`(?`);\n }\n\n startGroup(): AfterAnchor {\n return this.add(\"(?:\");\n }\n\n startCaptureGroup(): AfterAnchor {\n return this.add(\"(\");\n }\n\n wordBoundary(): Base {\n return this.add(\"\\\\b\");\n }\n\n nonWordBoundary(): Base {\n return this.add(\"\\\\B\");\n }\n\n endGroup(): Base {\n return this.add(\")\");\n }\n\n startAnchor(): AfterAnchor {\n return this.add(\"^\");\n }\n\n endAnchor(): AfterAnchor {\n return this.add(\"$\");\n }\n\n global(): this {\n this.flags.add(Flags.GLOBAL);\n return this;\n }\n\n nonSensitive(): this {\n this.flags.add(Flags.NON_SENSITIVE);\n return this;\n }\n\n multiline(): this {\n this.flags.add(Flags.MULTILINE);\n return this;\n }\n\n dotAll(): this {\n this.flags.add(Flags.DOT_ALL);\n return this;\n }\n\n sticky(): this {\n this.flags.add(Flags.STICKY);\n return this;\n }\n\n unicodeChar(variant?: \"u\" | \"l\" | \"t\" | \"m\" | \"o\"): Base {\n this.flags.add(Flags.UNICODE);\n const validVariants = new Set([\"u\", \"l\", \"t\", \"m\", \"o\"] as const);\n\n if (variant !== undefined && !validVariants.has(variant)) {\n throw new Error(`Invalid Unicode letter variant: ${variant}`);\n }\n\n return this.add(`\\\\p{L${variant ?? \"\"}}`);\n }\n\n unicodeDigit(): Base {\n this.flags.add(Flags.UNICODE);\n return this.add(\"\\\\p{N}\");\n }\n\n unicodePunctuation(): Base {\n this.flags.add(Flags.UNICODE);\n return this.add(\"\\\\p{P}\");\n }\n\n unicodeSymbol(): Base {\n this.flags.add(Flags.UNICODE);\n return this.add(\"\\\\p{S}\");\n }\n\n repeat(count: number): Base {\n if (this.parts.length === 0) {\n throw new Error(\"No pattern to repeat\");\n }\n\n const lastPart = this.parts.pop();\n this.parts.push(`(${lastPart}){${count}}`);\n return this;\n }\n\n ipv4Octet(): Base {\n return this.add(\"(25[0-5]|2[0-4]\\\\d|1\\\\d\\\\d|[1-9]\\\\d|\\\\d)\");\n }\n\n protocol(): Base {\n return this.add(\"https?://\");\n }\n\n www(): Base {\n return this.add(\"(www\\\\.)?\");\n }\n\n tld(): Base {\n return this.add(\"(com|org|net)\");\n }\n\n path(): Base {\n return this.add(\"(/\\\\w+)*\");\n }\n\n private add(part: string): this {\n this.parts.push(part);\n return this;\n }\n\n toString(): string {\n return this.parts.join(\"\");\n }\n\n toRegExp(): RegExp {\n return new RegExp(this.toString(), [...this.flags].join(\"\"));\n }\n}\n\nfunction escapeLiteral(text: string): string {\n if (!escapeCache.has(text)) {\n escapeCache.set(text, text.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\"));\n }\n return escapeCache.get(text)!;\n}\n\nconst createRegex = (): AtStart => new HumanRegex();\n\nconst Patterns = (() => {\n const createCachedPattern = (\n builder: () => PartialBut\n ) => {\n const regex = builder().toRegExp();\n return () => new RegExp(regex.source, regex.flags);\n };\n\n return {\n email: createCachedPattern(() =>\n createRegex()\n .startAnchor()\n .word()\n .oneOrMore()\n .literal(\"@\")\n .word()\n .oneOrMore()\n .startGroup()\n .literal(\".\")\n .word()\n .oneOrMore()\n .endGroup()\n .zeroOrMore()\n .literal(\".\")\n .letter()\n .atLeast(2)\n .endAnchor()\n ),\n url: createCachedPattern(() =>\n createRegex()\n .startAnchor()\n .protocol()\n .www()\n .word()\n .oneOrMore()\n .literal(\".\")\n .tld()\n .path()\n .endAnchor()\n ),\n phoneInternational: createCachedPattern(() =>\n createRegex()\n .startAnchor()\n .literal(\"+\")\n .digit()\n .between(1, 3)\n .literal(\"-\")\n .digit()\n .between(3, 14)\n .endAnchor()\n ),\n };\n})();\n\nexport { createRegex, Patterns, Flags, Ranges, Quantifiers };\n"],"names":["escapeCache","Map","Flags","GLOBAL","NON_SENSITIVE","MULTILINE","DOT_ALL","UNICODE","STICKY","Ranges","Object","freeze","digit","lowercaseLetter","uppercaseLetter","letter","alphanumeric","anyCharacter","Quantifiers","zeroOrMore","oneOrMore","optional","HumanRegex","constructor","this","parts","flags","Set","add","special","word","whitespace","nonWhitespace","literal","text","has","set","replace","get","escapeLiteral","or","range","name","Error","notRange","anyOf","chars","notAnyOf","lazy","lastPart","pop","newline","negativeLookahead","pattern","positiveLookahead","positiveLookbehind","negativeLookbehind","hasSpecialCharacter","hasDigit","hasLetter","exactly","n","atLeast","atMost","between","min","max","startNamedGroup","startGroup","startCaptureGroup","wordBoundary","nonWordBoundary","endGroup","startAnchor","endAnchor","global","nonSensitive","multiline","dotAll","sticky","unicodeChar","variant","validVariants","undefined","unicodeDigit","unicodePunctuation","unicodeSymbol","repeat","count","length","push","ipv4Octet","protocol","www","tld","path","part","toString","join","toRegExp","RegExp","createRegex","Patterns","createCachedPattern","builder","regex","source","email","url","phoneInternational"],"mappings":"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"} -\ No newline at end of file -diff --git a/dist/human-regex.esm.js b/dist/human-regex.esm.js -index dc4b9d6be5451cfb91113a83c449885ab684e3ba..be13291963f0f46f2625e84b7b63a83ef242ba4d 100644 ---- a/dist/human-regex.esm.js -+++ b/dist/human-regex.esm.js -@@ -1,2 +1,2 @@ --const t=new Map,r={GLOBAL:"g",NON_SENSITIVE:"i",MULTILINE:"m",DOT_ALL:"s",UNICODE:"u",STICKY:"y"},e=Object.freeze({digit:"0-9",lowercaseLetter:"a-z",uppercaseLetter:"A-Z",letter:"a-zA-Z",alphanumeric:"a-zA-Z0-9",anyCharacter:"."}),a=Object.freeze({zeroOrMore:"*",oneOrMore:"+",optional:"?"});class n{constructor(){this.parts=[],this.flags=new Set}digit(){return this.add("\\d")}special(){return this.add("(?=.*[!@#$%^&*])")}word(){return this.add("\\w")}whitespace(){return this.add("\\s")}nonWhitespace(){return this.add("\\S")}literal(r){return this.add(function(r){t.has(r)||t.set(r,r.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"));return t.get(r)}(r))}or(){return this.add("|")}range(t){const r=e[t];if(!r)throw new Error(`Unknown range: ${t}`);return this.add(`[${r}]`)}notRange(t){return this.add(`[^${t}]`)}lazy(){const t=this.parts.pop();if(!t)throw new Error("No quantifier to make lazy");return this.add(`${t}?`)}letter(){return this.add("[a-zA-Z]")}anyCharacter(){return this.add(".")}negativeLookahead(t){return this.add(`(?!${t})`)}positiveLookahead(t){return this.add(`(?=${t})`)}positiveLookbehind(t){return this.add(`(?<=${t})`)}negativeLookbehind(t){return this.add(`(?`)}startGroup(){return this.add("(?:")}startCaptureGroup(){return this.add("(")}wordBoundary(){return this.add("\\b")}nonWordBoundary(){return this.add("\\B")}endGroup(){return this.add(")")}startAnchor(){return this.add("^")}endAnchor(){return this.add("$")}global(){return this.flags.add(r.GLOBAL),this}nonSensitive(){return this.flags.add(r.NON_SENSITIVE),this}multiline(){return this.flags.add(r.MULTILINE),this}dotAll(){return this.flags.add(r.DOT_ALL),this}sticky(){return this.flags.add(r.STICKY),this}unicodeChar(t){this.flags.add(r.UNICODE);const e=new Set(["u","l","t","m","o"]);if(void 0!==t&&!e.has(t))throw new Error(`Invalid Unicode letter variant: ${t}`);return this.add(`\\p{L${null!=t?t:""}}`)}unicodeDigit(){return this.flags.add(r.UNICODE),this.add("\\p{N}")}unicodePunctuation(){return this.flags.add(r.UNICODE),this.add("\\p{P}")}unicodeSymbol(){return this.flags.add(r.UNICODE),this.add("\\p{S}")}repeat(t){if(0===this.parts.length)throw new Error("No pattern to repeat");const r=this.parts.pop();return this.parts.push(`(${r}){${t}}`),this}ipv4Octet(){return this.add("(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)")}protocol(){return this.add("https?://")}www(){return this.add("(www\\.)?")}tld(){return this.add("(com|org|net)")}path(){return this.add("(/\\w+)*")}add(t){return this.parts.push(t),this}toString(){return this.parts.join("")}toRegExp(){return new RegExp(this.toString(),[...this.flags].join(""))}}const d=()=>new n,i=(()=>{const t=t=>{const r=t().toRegExp();return()=>new RegExp(r.source,r.flags)};return{email:t((()=>d().startAnchor().word().oneOrMore().literal("@").word().oneOrMore().startGroup().literal(".").word().oneOrMore().endGroup().zeroOrMore().literal(".").letter().atLeast(2).endAnchor())),url:t((()=>d().startAnchor().protocol().www().word().oneOrMore().literal(".").tld().path().endAnchor())),phoneInternational:t((()=>d().startAnchor().literal("+").digit().between(1,3).literal("-").digit().between(3,14).endAnchor()))}})();export{r as Flags,i as Patterns,a as Quantifiers,e as Ranges,d as createRegex}; -+const t=new Map,r={GLOBAL:"g",NON_SENSITIVE:"i",MULTILINE:"m",DOT_ALL:"s",UNICODE:"u",STICKY:"y"},e=Object.freeze({digit:"0-9",lowercaseLetter:"a-z",uppercaseLetter:"A-Z",letter:"a-zA-Z",alphanumeric:"a-zA-Z0-9",anyCharacter:"."}),n=Object.freeze({zeroOrMore:"*",oneOrMore:"+",optional:"?"});class a{constructor(){this.parts=[],this.flags=new Set}digit(){return this.add("\\d")}special(){return this.add("(?=.*[!@#$%^&*])")}word(){return this.add("\\w")}whitespace(){return this.add("\\s")}nonWhitespace(){return this.add("\\S")}literal(r){return this.add(function(r){t.has(r)||t.set(r,r.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"));return t.get(r)}(r))}or(){return this.add("|")}range(t){const r=e[t];if(!r)throw new Error(`Unknown range: ${t}`);return this.add(`[${r}]`)}notRange(t){const r=e[t];if(!r)throw new Error(`Unknown range: ${t}`);return this.add(`[^${r}]`)}anyOf(t){return this.add(`[${t}]`)}notAnyOf(t){return this.add(`[^${t}]`)}lazy(){const t=this.parts.pop();if(!t)throw new Error("No quantifier to make lazy");return this.add(`${t}?`)}letter(){return this.add("[a-zA-Z]")}anyCharacter(){return this.add(".")}newline(){return this.add("(?:\\r\\n|\\r|\\n)")}negativeLookahead(t){return this.add(`(?!${t})`)}positiveLookahead(t){return this.add(`(?=${t})`)}positiveLookbehind(t){return this.add(`(?<=${t})`)}negativeLookbehind(t){return this.add(`(?`)}startGroup(){return this.add("(?:")}startCaptureGroup(){return this.add("(")}wordBoundary(){return this.add("\\b")}nonWordBoundary(){return this.add("\\B")}endGroup(){return this.add(")")}startAnchor(){return this.add("^")}endAnchor(){return this.add("$")}global(){return this.flags.add(r.GLOBAL),this}nonSensitive(){return this.flags.add(r.NON_SENSITIVE),this}multiline(){return this.flags.add(r.MULTILINE),this}dotAll(){return this.flags.add(r.DOT_ALL),this}sticky(){return this.flags.add(r.STICKY),this}unicodeChar(t){this.flags.add(r.UNICODE);const e=new Set(["u","l","t","m","o"]);if(void 0!==t&&!e.has(t))throw new Error(`Invalid Unicode letter variant: ${t}`);return this.add(`\\p{L${null!=t?t:""}}`)}unicodeDigit(){return this.flags.add(r.UNICODE),this.add("\\p{N}")}unicodePunctuation(){return this.flags.add(r.UNICODE),this.add("\\p{P}")}unicodeSymbol(){return this.flags.add(r.UNICODE),this.add("\\p{S}")}repeat(t){if(0===this.parts.length)throw new Error("No pattern to repeat");const r=this.parts.pop();return this.parts.push(`(${r}){${t}}`),this}ipv4Octet(){return this.add("(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)")}protocol(){return this.add("https?://")}www(){return this.add("(www\\.)?")}tld(){return this.add("(com|org|net)")}path(){return this.add("(/\\w+)*")}add(t){return this.parts.push(t),this}toString(){return this.parts.join("")}toRegExp(){return new RegExp(this.toString(),[...this.flags].join(""))}}const d=()=>new a,i=(()=>{const t=t=>{const r=t().toRegExp();return()=>new RegExp(r.source,r.flags)};return{email:t((()=>d().startAnchor().word().oneOrMore().literal("@").word().oneOrMore().startGroup().literal(".").word().oneOrMore().endGroup().zeroOrMore().literal(".").letter().atLeast(2).endAnchor())),url:t((()=>d().startAnchor().protocol().www().word().oneOrMore().literal(".").tld().path().endAnchor())),phoneInternational:t((()=>d().startAnchor().literal("+").digit().between(1,3).literal("-").digit().between(3,14).endAnchor()))}})();export{r as Flags,i as Patterns,n as Quantifiers,e as Ranges,d as createRegex}; - //# sourceMappingURL=human-regex.esm.js.map -diff --git a/dist/human-regex.esm.js.map b/dist/human-regex.esm.js.map -index 14d4247491ad8cf1e767e53d5b6623c4d8bc80fe..f1c0cb3734effb96175e811f8aa591cc87938638 100644 ---- a/dist/human-regex.esm.js.map -+++ b/dist/human-regex.esm.js.map -@@ -1 +1 @@ --{"version":3,"file":"human-regex.esm.js","sources":["../src/human-regex.ts"],"sourcesContent":["type PartialBut = Partial & Pick;\n\nconst escapeCache = new Map();\n\nconst Flags = {\n GLOBAL: \"g\",\n NON_SENSITIVE: \"i\",\n MULTILINE: \"m\",\n DOT_ALL: \"s\",\n UNICODE: \"u\",\n STICKY: \"y\",\n} as const;\n\nconst Ranges = Object.freeze({\n digit: \"0-9\",\n lowercaseLetter: \"a-z\",\n uppercaseLetter: \"A-Z\",\n letter: \"a-zA-Z\",\n alphanumeric: \"a-zA-Z0-9\",\n anyCharacter: \".\",\n});\n\ntype RangeKeys = keyof typeof Ranges;\n\nconst Quantifiers = Object.freeze({\n zeroOrMore: \"*\",\n oneOrMore: \"+\",\n optional: \"?\",\n});\n\ntype Quantifiers =\n | \"exactly\"\n | \"atLeast\"\n | \"atMost\"\n | \"between\"\n | \"oneOrMore\"\n | \"zeroOrMore\"\n | \"repeat\";\ntype QuantifierMethods = Quantifiers | \"optional\" | \"lazy\";\n\ntype WithLazy = HumanRegex;\ntype Base = Omit;\ntype AtStart = Omit;\ntype AfterAnchor = Omit;\ntype SimpleQuantifier = Omit;\ntype LazyQuantifier = Omit;\n\nclass HumanRegex {\n private parts: string[];\n private flags: Set;\n\n constructor() {\n this.parts = [];\n this.flags = new Set();\n }\n\n digit(): Base {\n return this.add(\"\\\\d\");\n }\n\n special(): Base {\n return this.add(\"(?=.*[!@#$%^&*])\");\n }\n\n word(): Base {\n return this.add(\"\\\\w\");\n }\n\n whitespace(): Base {\n return this.add(\"\\\\s\");\n }\n\n nonWhitespace(): Base {\n return this.add(\"\\\\S\");\n }\n\n literal(text: string): this {\n return this.add(escapeLiteral(text));\n }\n\n or(): AfterAnchor {\n return this.add(\"|\");\n }\n\n range(name: RangeKeys): Base {\n const range = Ranges[name];\n if (!range) throw new Error(`Unknown range: ${name}`);\n return this.add(`[${range}]`);\n }\n\n notRange(chars: string): Base {\n return this.add(`[^${chars}]`);\n }\n\n lazy(): Base {\n const lastPart = this.parts.pop();\n if (!lastPart) throw new Error(\"No quantifier to make lazy\");\n return this.add(`${lastPart}?`);\n }\n\n letter(): Base {\n return this.add(\"[a-zA-Z]\");\n }\n\n anyCharacter(): Base {\n return this.add(\".\");\n }\n\n negativeLookahead(pattern: string): Base {\n return this.add(`(?!${pattern})`);\n }\n\n positiveLookahead(pattern: string): Base {\n return this.add(`(?=${pattern})`);\n }\n\n positiveLookbehind(pattern: string): Base {\n return this.add(`(?<=${pattern})`);\n }\n\n negativeLookbehind(pattern: string): Base {\n return this.add(`(?`);\n }\n\n startGroup(): AfterAnchor {\n return this.add(\"(?:\");\n }\n\n startCaptureGroup(): AfterAnchor {\n return this.add(\"(\");\n }\n\n wordBoundary(): Base {\n return this.add(\"\\\\b\");\n }\n\n nonWordBoundary(): Base {\n return this.add(\"\\\\B\");\n }\n\n endGroup(): Base {\n return this.add(\")\");\n }\n\n startAnchor(): AfterAnchor {\n return this.add(\"^\");\n }\n\n endAnchor(): AfterAnchor {\n return this.add(\"$\");\n }\n\n global(): this {\n this.flags.add(Flags.GLOBAL);\n return this;\n }\n\n nonSensitive(): this {\n this.flags.add(Flags.NON_SENSITIVE);\n return this;\n }\n\n multiline(): this {\n this.flags.add(Flags.MULTILINE);\n return this;\n }\n\n dotAll(): this {\n this.flags.add(Flags.DOT_ALL);\n return this;\n }\n\n sticky(): this {\n this.flags.add(Flags.STICKY);\n return this;\n }\n\n unicodeChar(variant?: \"u\" | \"l\" | \"t\" | \"m\" | \"o\"): Base {\n this.flags.add(Flags.UNICODE);\n const validVariants = new Set([\"u\", \"l\", \"t\", \"m\", \"o\"] as const);\n\n if (variant !== undefined && !validVariants.has(variant)) {\n throw new Error(`Invalid Unicode letter variant: ${variant}`);\n }\n\n return this.add(`\\\\p{L${variant ?? \"\"}}`);\n }\n\n unicodeDigit(): Base {\n this.flags.add(Flags.UNICODE);\n return this.add(\"\\\\p{N}\");\n }\n\n unicodePunctuation(): Base {\n this.flags.add(Flags.UNICODE);\n return this.add(\"\\\\p{P}\");\n }\n\n unicodeSymbol(): Base {\n this.flags.add(Flags.UNICODE);\n return this.add(\"\\\\p{S}\");\n }\n\n repeat(count: number): Base {\n if (this.parts.length === 0) {\n throw new Error(\"No pattern to repeat\");\n }\n\n const lastPart = this.parts.pop();\n this.parts.push(`(${lastPart}){${count}}`);\n return this;\n }\n\n ipv4Octet(): Base {\n return this.add(\"(25[0-5]|2[0-4]\\\\d|1\\\\d\\\\d|[1-9]\\\\d|\\\\d)\");\n }\n\n protocol(): Base {\n return this.add(\"https?://\");\n }\n\n www(): Base {\n return this.add(\"(www\\\\.)?\");\n }\n\n tld(): Base {\n return this.add(\"(com|org|net)\");\n }\n\n path(): Base {\n return this.add(\"(/\\\\w+)*\");\n }\n\n private add(part: string): this {\n this.parts.push(part);\n return this;\n }\n\n toString(): string {\n return this.parts.join(\"\");\n }\n\n toRegExp(): RegExp {\n return new RegExp(this.toString(), [...this.flags].join(\"\"));\n }\n}\n\nfunction escapeLiteral(text: string): string {\n if (!escapeCache.has(text)) {\n escapeCache.set(text, text.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\"));\n }\n return escapeCache.get(text)!;\n}\n\nconst createRegex = (): AtStart => new HumanRegex();\n\nconst Patterns = (() => {\n const createCachedPattern = (\n builder: () => PartialBut\n ) => {\n const regex = builder().toRegExp();\n return () => new RegExp(regex.source, regex.flags);\n };\n\n return {\n email: createCachedPattern(() =>\n createRegex()\n .startAnchor()\n .word()\n .oneOrMore()\n .literal(\"@\")\n .word()\n .oneOrMore()\n .startGroup()\n .literal(\".\")\n .word()\n .oneOrMore()\n .endGroup()\n .zeroOrMore()\n .literal(\".\")\n .letter()\n .atLeast(2)\n .endAnchor()\n ),\n url: createCachedPattern(() =>\n createRegex()\n .startAnchor()\n .protocol()\n .www()\n .word()\n .oneOrMore()\n .literal(\".\")\n .tld()\n .path()\n .endAnchor()\n ),\n phoneInternational: createCachedPattern(() =>\n createRegex()\n .startAnchor()\n .literal(\"+\")\n .digit()\n .between(1, 3)\n .literal(\"-\")\n .digit()\n .between(3, 14)\n .endAnchor()\n ),\n };\n})();\n\nexport { createRegex, Patterns, Flags, Ranges, Quantifiers };\n"],"names":["escapeCache","Map","Flags","GLOBAL","NON_SENSITIVE","MULTILINE","DOT_ALL","UNICODE","STICKY","Ranges","Object","freeze","digit","lowercaseLetter","uppercaseLetter","letter","alphanumeric","anyCharacter","Quantifiers","zeroOrMore","oneOrMore","optional","HumanRegex","constructor","this","parts","flags","Set","add","special","word","whitespace","nonWhitespace","literal","text","has","set","replace","get","escapeLiteral","or","range","name","Error","notRange","chars","lazy","lastPart","pop","negativeLookahead","pattern","positiveLookahead","positiveLookbehind","negativeLookbehind","hasSpecialCharacter","hasDigit","hasLetter","exactly","n","atLeast","atMost","between","min","max","startNamedGroup","startGroup","startCaptureGroup","wordBoundary","nonWordBoundary","endGroup","startAnchor","endAnchor","global","nonSensitive","multiline","dotAll","sticky","unicodeChar","variant","validVariants","undefined","unicodeDigit","unicodePunctuation","unicodeSymbol","repeat","count","length","push","ipv4Octet","protocol","www","tld","path","part","toString","join","toRegExp","RegExp","createRegex","Patterns","createCachedPattern","builder","regex","source","email","url","phoneInternational"],"mappings":"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"} -\ No newline at end of file -+{"version":3,"file":"human-regex.esm.js","sources":["../src/human-regex.ts"],"sourcesContent":["type PartialBut = Partial & Pick;\n\nconst escapeCache = new Map();\n\nconst Flags = {\n GLOBAL: \"g\",\n NON_SENSITIVE: \"i\",\n MULTILINE: \"m\",\n DOT_ALL: \"s\",\n UNICODE: \"u\",\n STICKY: \"y\",\n} as const;\n\nconst Ranges = Object.freeze({\n digit: \"0-9\",\n lowercaseLetter: \"a-z\",\n uppercaseLetter: \"A-Z\",\n letter: \"a-zA-Z\",\n alphanumeric: \"a-zA-Z0-9\",\n anyCharacter: \".\",\n});\n\ntype RangeKeys = keyof typeof Ranges;\n\nconst Quantifiers = Object.freeze({\n zeroOrMore: \"*\",\n oneOrMore: \"+\",\n optional: \"?\",\n});\n\ntype Quantifiers =\n | \"exactly\"\n | \"atLeast\"\n | \"atMost\"\n | \"between\"\n | \"oneOrMore\"\n | \"zeroOrMore\"\n | \"repeat\";\ntype QuantifierMethods = Quantifiers | \"optional\" | \"lazy\";\n\ntype WithLazy = HumanRegex;\ntype Base = Omit;\ntype AtStart = Omit;\ntype AfterAnchor = Omit;\ntype SimpleQuantifier = Omit;\ntype LazyQuantifier = Omit;\n\nclass HumanRegex {\n private parts: string[];\n private flags: Set;\n\n constructor() {\n this.parts = [];\n this.flags = new Set();\n }\n\n digit(): Base {\n return this.add(\"\\\\d\");\n }\n\n special(): Base {\n return this.add(\"(?=.*[!@#$%^&*])\");\n }\n\n word(): Base {\n return this.add(\"\\\\w\");\n }\n\n whitespace(): Base {\n return this.add(\"\\\\s\");\n }\n\n nonWhitespace(): Base {\n return this.add(\"\\\\S\");\n }\n\n literal(text: string): this {\n return this.add(escapeLiteral(text));\n }\n\n or(): AfterAnchor {\n return this.add(\"|\");\n }\n\n range(name: RangeKeys): Base {\n const range = Ranges[name];\n if (!range) throw new Error(`Unknown range: ${name}`);\n return this.add(`[${range}]`);\n }\n\n notRange(name: RangeKeys): Base {\n const range = Ranges[name];\n if (!range) throw new Error(`Unknown range: ${name}`);\n return this.add(`[^${range}]`);\n }\n\n anyOf(chars: string): Base {\n return this.add(`[${chars}]`);\n }\n\n notAnyOf(chars: string): Base {\n return this.add(`[^${chars}]`);\n }\n\n lazy(): Base {\n const lastPart = this.parts.pop();\n if (!lastPart) throw new Error(\"No quantifier to make lazy\");\n return this.add(`${lastPart}?`);\n }\n\n letter(): Base {\n return this.add(\"[a-zA-Z]\");\n }\n\n anyCharacter(): Base {\n return this.add(\".\");\n }\n\n newline(): Base {\n return this.add(\"(?:\\\\r\\\\n|\\\\r|\\\\n)\"); // Windows: \\r\\n, Unix: \\n, Old Macs: \\r\n }\n\n negativeLookahead(pattern: string): Base {\n return this.add(`(?!${pattern})`);\n }\n\n positiveLookahead(pattern: string): Base {\n return this.add(`(?=${pattern})`);\n }\n\n positiveLookbehind(pattern: string): Base {\n return this.add(`(?<=${pattern})`);\n }\n\n negativeLookbehind(pattern: string): Base {\n return this.add(`(?`);\n }\n\n startGroup(): AfterAnchor {\n return this.add(\"(?:\");\n }\n\n startCaptureGroup(): AfterAnchor {\n return this.add(\"(\");\n }\n\n wordBoundary(): Base {\n return this.add(\"\\\\b\");\n }\n\n nonWordBoundary(): Base {\n return this.add(\"\\\\B\");\n }\n\n endGroup(): Base {\n return this.add(\")\");\n }\n\n startAnchor(): AfterAnchor {\n return this.add(\"^\");\n }\n\n endAnchor(): AfterAnchor {\n return this.add(\"$\");\n }\n\n global(): this {\n this.flags.add(Flags.GLOBAL);\n return this;\n }\n\n nonSensitive(): this {\n this.flags.add(Flags.NON_SENSITIVE);\n return this;\n }\n\n multiline(): this {\n this.flags.add(Flags.MULTILINE);\n return this;\n }\n\n dotAll(): this {\n this.flags.add(Flags.DOT_ALL);\n return this;\n }\n\n sticky(): this {\n this.flags.add(Flags.STICKY);\n return this;\n }\n\n unicodeChar(variant?: \"u\" | \"l\" | \"t\" | \"m\" | \"o\"): Base {\n this.flags.add(Flags.UNICODE);\n const validVariants = new Set([\"u\", \"l\", \"t\", \"m\", \"o\"] as const);\n\n if (variant !== undefined && !validVariants.has(variant)) {\n throw new Error(`Invalid Unicode letter variant: ${variant}`);\n }\n\n return this.add(`\\\\p{L${variant ?? \"\"}}`);\n }\n\n unicodeDigit(): Base {\n this.flags.add(Flags.UNICODE);\n return this.add(\"\\\\p{N}\");\n }\n\n unicodePunctuation(): Base {\n this.flags.add(Flags.UNICODE);\n return this.add(\"\\\\p{P}\");\n }\n\n unicodeSymbol(): Base {\n this.flags.add(Flags.UNICODE);\n return this.add(\"\\\\p{S}\");\n }\n\n repeat(count: number): Base {\n if (this.parts.length === 0) {\n throw new Error(\"No pattern to repeat\");\n }\n\n const lastPart = this.parts.pop();\n this.parts.push(`(${lastPart}){${count}}`);\n return this;\n }\n\n ipv4Octet(): Base {\n return this.add(\"(25[0-5]|2[0-4]\\\\d|1\\\\d\\\\d|[1-9]\\\\d|\\\\d)\");\n }\n\n protocol(): Base {\n return this.add(\"https?://\");\n }\n\n www(): Base {\n return this.add(\"(www\\\\.)?\");\n }\n\n tld(): Base {\n return this.add(\"(com|org|net)\");\n }\n\n path(): Base {\n return this.add(\"(/\\\\w+)*\");\n }\n\n private add(part: string): this {\n this.parts.push(part);\n return this;\n }\n\n toString(): string {\n return this.parts.join(\"\");\n }\n\n toRegExp(): RegExp {\n return new RegExp(this.toString(), [...this.flags].join(\"\"));\n }\n}\n\nfunction escapeLiteral(text: string): string {\n if (!escapeCache.has(text)) {\n escapeCache.set(text, text.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\"));\n }\n return escapeCache.get(text)!;\n}\n\nconst createRegex = (): AtStart => new HumanRegex();\n\nconst Patterns = (() => {\n const createCachedPattern = (\n builder: () => PartialBut\n ) => {\n const regex = builder().toRegExp();\n return () => new RegExp(regex.source, regex.flags);\n };\n\n return {\n email: createCachedPattern(() =>\n createRegex()\n .startAnchor()\n .word()\n .oneOrMore()\n .literal(\"@\")\n .word()\n .oneOrMore()\n .startGroup()\n .literal(\".\")\n .word()\n .oneOrMore()\n .endGroup()\n .zeroOrMore()\n .literal(\".\")\n .letter()\n .atLeast(2)\n .endAnchor()\n ),\n url: createCachedPattern(() =>\n createRegex()\n .startAnchor()\n .protocol()\n .www()\n .word()\n .oneOrMore()\n .literal(\".\")\n .tld()\n .path()\n .endAnchor()\n ),\n phoneInternational: createCachedPattern(() =>\n createRegex()\n .startAnchor()\n .literal(\"+\")\n .digit()\n .between(1, 3)\n .literal(\"-\")\n .digit()\n .between(3, 14)\n .endAnchor()\n ),\n };\n})();\n\nexport { createRegex, Patterns, Flags, Ranges, Quantifiers };\n"],"names":["escapeCache","Map","Flags","GLOBAL","NON_SENSITIVE","MULTILINE","DOT_ALL","UNICODE","STICKY","Ranges","Object","freeze","digit","lowercaseLetter","uppercaseLetter","letter","alphanumeric","anyCharacter","Quantifiers","zeroOrMore","oneOrMore","optional","HumanRegex","constructor","this","parts","flags","Set","add","special","word","whitespace","nonWhitespace","literal","text","has","set","replace","get","escapeLiteral","or","range","name","Error","notRange","anyOf","chars","notAnyOf","lazy","lastPart","pop","newline","negativeLookahead","pattern","positiveLookahead","positiveLookbehind","negativeLookbehind","hasSpecialCharacter","hasDigit","hasLetter","exactly","n","atLeast","atMost","between","min","max","startNamedGroup","startGroup","startCaptureGroup","wordBoundary","nonWordBoundary","endGroup","startAnchor","endAnchor","global","nonSensitive","multiline","dotAll","sticky","unicodeChar","variant","validVariants","undefined","unicodeDigit","unicodePunctuation","unicodeSymbol","repeat","count","length","push","ipv4Octet","protocol","www","tld","path","part","toString","join","toRegExp","RegExp","createRegex","Patterns","createCachedPattern","builder","regex","source","email","url","phoneInternational"],"mappings":"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"} -\ No newline at end of file -diff --git a/index.d.ts b/index.d.ts -index 87eacee3c4aad3e1c79fb06dcf60319065012b97..de4a228088e210651918cb87a697285d00f0da40 100644 ---- a/index.d.ts -+++ b/index.d.ts -@@ -63,10 +63,13 @@ export class HumanRegex { - or(): AfterAnchor; - - range(name: RangeKeys): Base; -- notRange(chars: string): Base; -+ notRange(name: RangeKeys): Base; -+ anyOf(chars: string): Base; -+ notAnyOf(chars: string): Base; - lazy(): Base; - letter(): Base; - anyCharacter(): Base; -+ newline(): Base; - - // Lookahead/behind - negativeLookahead(pattern: string): Base; -diff --git a/package.json b/package.json -index 881d37da1ce741e0b7a82da9163d2718de53151f..214261c20aca7213c4955e71179fd3d33a428810 100644 ---- a/package.json -+++ b/package.json -@@ -4,6 +4,13 @@ - "description": "Human-friendly regex builder with English-like syntax", - "main": "dist/human-regex.cjs.js", - "module": "dist/human-regex.esm.js", -+ "exports": { -+ ".": { -+ "import": "./dist/human-regex.esm.js", -+ "require": "./dist/human-regex.cjs.js", -+ "types": "./index.d.ts" -+ } -+ }, - "types": "types/index.d.ts", - "type": "module", - "scripts": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9870af4..e40c752 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,11 +4,6 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false -patchedDependencies: - human-regex@2.1.5: - hash: 6d6bd9e233f99785a7c2187fd464edc114b76d47001dbb4eb6b5d72168de7460 - path: patches/human-regex@2.1.5.patch - importers: .: @@ -16,6 +11,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 +30,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 @@ -54,8 +58,8 @@ importers: specifier: 16.5.0 version: 16.5.0 human-regex: - specifier: 2.1.5 - version: 2.1.5(patch_hash=6d6bd9e233f99785a7c2187fd464edc114b76d47001dbb4eb6b5d72168de7460) + specifier: 2.2.0 + version: 2.2.0 prettier: specifier: 3.6.2 version: 3.6.2 @@ -670,6 +674,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==} @@ -1262,6 +1269,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==} @@ -1271,8 +1281,8 @@ packages: html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} - human-regex@2.1.5: - resolution: {integrity: sha512-0IjS3kUvWMu27q4JT1t/0eA8J78iOEsn6igZ4yElBmYeyAU901+4cwQHKUyco1A2e9llO8pnIsPimPD5y+n06A==} + human-regex@2.2.0: + resolution: {integrity: sha512-lpDOVu5sVKVylCvnSG4RlhCY+9/iT0OSflkm0t6ApZ4B6wrfmHALwfifZTB7Awdjq7Xb1IR1CXgP/ecxQ7ZwrQ==} human-signals@8.0.1: resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} @@ -1366,8 +1376,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: @@ -1732,6 +1742,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'} @@ -2071,6 +2085,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'} @@ -2234,7 +2251,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: @@ -2521,6 +2538,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': {} @@ -3202,13 +3221,15 @@ snapshots: dependencies: '@types/hast': 3.0.4 + heap@0.2.7: {} + hookable@5.5.3: {} html-escaper@2.0.2: {} html-void-elements@3.0.0: {} - human-regex@2.1.5(patch_hash=6d6bd9e233f99785a7c2187fd464edc114b76d47001dbb4eb6b5d72168de7460): + human-regex@2.2.0: dependencies: tslib: 2.8.1 @@ -3286,7 +3307,7 @@ snapshots: js-tokens@9.0.1: {} - js-yaml@4.1.0: + js-yaml@4.1.1: dependencies: argparse: 2.0.1 @@ -3665,6 +3686,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: @@ -4023,6 +4046,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/pnpm-workspace.yaml b/pnpm-workspace.yaml index bc2bc56..06ac931 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,6 +3,3 @@ packages: onlyBuiltDependencies: - esbuild - -patchedDependencies: - human-regex@2.1.5: patches/human-regex@2.1.5.patch diff --git a/src/classes/product_catalog.ts b/src/classes/product_catalog.ts new file mode 100644 index 0000000..7188cdf --- /dev/null +++ b/src/classes/product_catalog.ts @@ -0,0 +1,199 @@ +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] + * 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.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); + + // Reset internal state + this.products = []; + + if (!this.isValidTomlContent(catalogRaw)) { + throw new InvalidProductCatalogFormat(); + } + + 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 { name, size, price, ...rest } = + productData as unknown as ProductOptionToml; + + const sizeAndUnitRaw = size.split("%"); + const sizeParsed = parseQuantityInput( + sizeAndUnitRaw[0]!, + ) as FixedNumericValue; + + const productOption: ProductOption = { + id: productId, + productName: name, + ingredientName: ingredientName, + price: price, + size: sizeParsed, + ...rest, + }; + if (aliases) { + productOption.ingredientAliases = aliases; + } + + 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 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 TOML.stringify(grouped); + } + + /** + * 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(catalog: TomlTable): boolean { + for (const productsRaw of Object.values(catalog)) { + if (typeof productsRaw !== "object" || productsRaw === null) { + 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 mandatoryKeys = ["name", "size", "price"]; + + if (mandatoryKeys.some((key) => !keys.includes(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..c99e88b --- /dev/null +++ b/src/classes/shopping_cart.ts @@ -0,0 +1,379 @@ +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 === undefined) { + throw new NoProductCatalogForCartError(); + } else if (this.shoppingList === undefined) { + 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 || + product.ingredientAliases?.includes(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..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( @@ -13,3 +13,45 @@ 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 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..936406a 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,24 @@ import type { CategoryIngredient, Category, QuantityPart, + ProductOption, + ProductSelection, + CartContent, + ProductMatch, + CartMatch, + ProductMisMatch, + CartMisMatch, + NoProductMatchErrorCode, } from "./types"; export { Recipe, ShoppingList, + ShoppingCart, + ShoppingCartOptions, + ShoppingCartSummary, CategoryConfig, + ProductCatalog, Metadata, Ingredient, IngredientFlag, @@ -63,4 +81,12 @@ export { Category, Section, QuantityPart, + ProductOption, + ProductSelection, + CartContent, + ProductMatch, + CartMatch, + ProductMisMatch, + CartMisMatch, + NoProductMatchErrorCode, }; diff --git a/src/parser_helpers.ts b/src/parser_helpers.ts index 370ca86..ac20460 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(); @@ -326,7 +341,7 @@ export function extractMetadata(content: string): MetadataExtract { let servings: number | undefined = undefined; // Is there front-matter at all? - const metadataContent = content.match(metadataRegex)?.[1]; + const metadataContent = content.match(metadataRegex)?.[2]; if (!metadataContent) { return { metadata }; } @@ -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..1dc883f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -170,6 +170,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 +420,98 @@ 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 aliases of the ingredient it also corresponds to */ + ingredientAliases?: string[]; + /** The size of the product. */ + size: FixedNumericValue; + /** The unit of the product size. */ + unit?: string; + /** The price of the product */ + price: number; + /** Arbitrary additional metadata */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: string]: any; +} + +/** + * 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; + /** Arbitrary additional metadata */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: string]: any; +} + +/** + * 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[]; + +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 + */ +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..1922425 --- /dev/null +++ b/test/product_catalog.test.ts @@ -0,0 +1,275 @@ +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 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("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"`, + // 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" }`, + // 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", () => { + it("should stringify a valid product catalog", () => { + const catalog = new ProductCatalog(); + catalog.products = exampleProductOptions; + 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" +`); + }); + + 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" +`); + }); + }); + + 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(exampleTomlContent); + 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 } }, + }); + }); + + 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); + }); + }); +}); 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); + }); + }); });