recipe-ingredient-parser-v3
Version:
Natural language parser for recipes and ingredient lists, incl. combining ingredients
295 lines (267 loc) โข 9.61 kB
text/typescript
import * as convert from "./convert";
import { unitsMap } from "./units";
import { repeatingFractions } from "./repeatingFractions";
import { toTasteMap } from "./numbers";
//import * as Natural from 'natural';
//const nounInflector = new Natural.NounInflector();
export interface Ingredient {
ingredient: string;
quantity: string | null;
unit: string | null;
minQty: string | null;
maxQty: string | null;
}
export function toTasteRecognize(input: string, language: string) {
const toTaste = toTasteMap[language];
const firstLetter = toTaste.match(/\b(\w)/g);
//componing first two word
//const word = firstWord.concat(' ').concat(secondWord)
if (firstLetter) {
//checking the extended version
let regEx = new RegExp("\\b" + toTaste + "\\b", "gi");
if (input.match(regEx)) {
return [
(firstLetter.join(".") + ".").toLocaleLowerCase(),
convert.getFirstMatch(input, regEx),
true,
] as [string, string, boolean];
}
const regExString = firstLetter.join("[.]?") + "[.]?";
regEx = new RegExp("\\b" + regExString + "\\b", "gi");
//const a = input.toString().split(/[\s-]+/);
if (input.match(regEx)) {
return [
(firstLetter.join(".") + ".").toLocaleLowerCase(),
convert.getFirstMatch(input, regEx),
false,
] as [string, string, boolean];
}
}
return ["", "", false] as [string, string, boolean];
}
function getUnit(input: string, language: string) {
// const word = input.concat(' ').concat(secondWord)
let unit = unitsMap.get(language);
let units = unit[0];
let pluralUnits = unit[1];
let symbolUnits = unit[3];
let response = [] as string[];
const [toTaste, match, extFlag] = toTasteRecognize(input, language);
// If there is already a unit found for the ingredient, don't add toTaste. Fixes t.t. bug from words with two t's
if (toTaste && !unit) {
if (extFlag) {
response = [toTaste, toTaste, match];
} else {
response = [toTaste, toTaste, match];
}
} else {
if (units[input] || pluralUnits[input]) {
response = [input, pluralUnits[input], input];
}
for (const unit of Object.keys(units)) {
for (const shorthand of units[unit]) {
const regex = new RegExp("(?=\\b" + shorthand + "\\b)", "gi");
if (input.match(regex)) {
response = [unit, pluralUnits[unit], shorthand];
}
}
}
for (const pluralUnit of Object.keys(pluralUnits)) {
const regex = new RegExp(
"(?=\\b" + pluralUnits[pluralUnit] + "\\b)",
"gi"
);
if (input.match(regex)) {
response = [
pluralUnit,
pluralUnits[pluralUnit],
pluralUnits[pluralUnit],
];
}
}
}
let symbol = symbolUnits[response[0]];
response.splice(2, 0, symbol);
return response;
}
/* return the proposition if it's used before of the name of
the ingredient */
function getPreposition(input: string, language: string) {
let prepositionMap = unitsMap.get(language);
let prepositions = prepositionMap[2];
for (const preposition of prepositions) {
let regex = new RegExp("^" + preposition);
if (convert.getFirstMatch(input, regex)) return preposition;
}
return null;
}
export function parse(recipeString: string, language: string) {
let ingredientLine = recipeString.trim().replace(/^(-)/, "").toLowerCase(); // removes leading and trailing whitespace
/* restOfIngredient represents rest of ingredient line.
For example: "1 pinch salt" --> quantity: 1, restOfIngredient: pinch salt */
let [quantity, restOfIngredient] = convert.findQuantityAndConvertIfUnicode(
ingredientLine,
language
) as string[];
quantity = convert.convertFromFraction(quantity);
/* extraInfo will be any info in parantheses. We'll place it at the end of the ingredient.
For example: "sugar (or other sweetener)" --> extraInfo: "(or other sweetener)" */
let extraInfo;
if (convert.getFirstMatch(restOfIngredient, /\(([^\)]+)\)/)) {
extraInfo = convert.getFirstMatch(restOfIngredient, /\(([^\)]+)\)/);
restOfIngredient = restOfIngredient.replace(extraInfo, "").trim();
}
// grab unit and turn it into non-plural version, for ex: "Tablespoons" OR "Tsbp." --> "tablespoon"
let [unit, unitPlural, symbol, originalUnit] = getUnit(
restOfIngredient,
language
) as string[];
// remove unit from the ingredient if one was found and trim leading and trailing whitespace
let regex_originalunit = RegExp("\\b" + originalUnit + "\\b", "gi");
let regex_unit = RegExp("\\b" + unit + "\\b", "gi");
let ingredient = !!originalUnit
? restOfIngredient.replace(regex_originalunit, "").trim()
: restOfIngredient.replace(regex_unit, "").trim();
ingredient = ingredient.split(".").join("").trim();
let preposition = getPreposition(ingredient.split(" ")[0], language);
if (preposition) {
let regex = new RegExp("^" + preposition);
ingredient = ingredient.replace(regex, "").trim();
}
let minQty = quantity; // default to quantity
let maxQty = quantity; // default to quantity
// if quantity is non-nil and is a range, for ex: "1-2", we want to get minQty and maxQty
if (quantity && quantity.includes("-")) {
[minQty, maxQty] = quantity.split("-");
}
if ((!quantity || quantity == "0") && !unit) {
unit = "q.b.";
unitPlural = "q.b.";
}
return {
quantity: +quantity,
unit: !!unit ? unit : null,
unitPlural: !!unitPlural ? unitPlural : null,
symbol: !!symbol ? symbol : null,
ingredient: extraInfo
? `${ingredient} ${extraInfo}`
: ingredient.replace(/( )*\.( )*/g, ""),
minQty: +minQty,
maxQty: +maxQty,
};
}
export function multiLineParse(recipeString: string, language: string) {
let ingredients = recipeString.split(/,|๐๐ป|๐|\r|\n|-|;/g);
ingredients = ingredients.filter((line) => {
// Verifica se la riga contiene una qualsiasi delle varianti della parola "ingredienti"
if (/ingredient[ei]/i.test(line)) {
return false;
}
// Verifica se la riga contiene solo numeri
if (/^\d+$/.test(line)) {
return false;
}
// Verifica se la riga contiene solo spazi bianchi o รจ vuota
if (/^\s*$/.test(line)) {
return false;
}
return true;
});
let result = [];
let i;
for (var ingredient of ingredients) {
i = parse(ingredient, language);
if (i["ingredient"]) {
result.push(i);
}
}
return result;
}
export function combine(ingredientArray: Ingredient[]) {
const combinedIngredients = ingredientArray.reduce((acc, ingredient) => {
const key = ingredient.ingredient + ingredient.unit; // when combining different units, remove this from the key and just use the name
const existingIngredient = acc[key];
if (existingIngredient) {
return Object.assign(acc, {
[key]: combineTwoIngredients(existingIngredient, ingredient),
});
} else {
return Object.assign(acc, { [key]: ingredient });
}
}, {} as { [key: string]: Ingredient });
return Object.keys(combinedIngredients)
.reduce((acc, key) => {
const ingredient = combinedIngredients[key];
return acc.concat(ingredient);
}, [] as Ingredient[])
.sort(compareIngredients);
}
export function prettyPrintingPress(ingredient: Ingredient) {
let quantity = "";
let unit = ingredient.unit;
if (ingredient.quantity) {
const [whole, remainder] = ingredient.quantity.split(".");
if (+whole !== 0 && typeof whole !== "undefined") {
quantity = whole;
}
if (+remainder !== 0 && typeof remainder !== "undefined") {
let fractional;
if (repeatingFractions[remainder]) {
fractional = repeatingFractions[remainder];
} else {
const fraction = "0." + remainder;
const len = fraction.length - 2;
let denominator = Math.pow(10, len);
let numerator = +fraction * denominator;
const divisor = gcd(numerator, denominator);
numerator /= divisor;
denominator /= divisor;
fractional = Math.floor(numerator) + "/" + Math.floor(denominator);
}
quantity += quantity ? " " + fractional : fractional;
}
/* if (((+whole !== 0 && typeof remainder !== 'undefined') || +whole > 1) && unit) {
unit = nounInflector.pluralize(unit);
}*/
} else {
return ingredient.ingredient;
}
return `${quantity}${unit ? " " + unit : ""} ${ingredient.ingredient}`;
}
function gcd(a: number, b: number): number {
if (b < 0.0000001) {
return a;
}
return gcd(b, Math.floor(a % b));
}
// TODO: Maybe change this to existingIngredients: Ingredient | Ingredient[]
function combineTwoIngredients(
existingIngredients: Ingredient,
ingredient: Ingredient
): Ingredient {
const quantity =
existingIngredients.quantity && ingredient.quantity
? (
Number(existingIngredients.quantity) + Number(ingredient.quantity)
).toString()
: null;
const minQty =
existingIngredients.minQty && ingredient.minQty
? (
Number(existingIngredients.minQty) + Number(ingredient.minQty)
).toString()
: null;
const maxQty =
existingIngredients.maxQty && ingredient.maxQty
? (
Number(existingIngredients.maxQty) + Number(ingredient.maxQty)
).toString()
: null;
return Object.assign({}, existingIngredients, { quantity, minQty, maxQty });
}
function compareIngredients(a: Ingredient, b: Ingredient) {
if (a.ingredient === b.ingredient) {
return 0;
}
return a.ingredient < b.ingredient ? -1 : 1;
}