I am new to JavaScript so I am struggling to even know where to start. Please can someone help me. I have what I have tried something as shown below but it is nothing like the desired output as I have shown below
I have this list of ingredients with the amount and val:
const Ingris = [
{
val: "onion,",
amount: "1",
},
{
val: "paprika",
amount: "½ tsp",
},
{
val: "yogurt",
amount: "1/2 Cup",
},
{
val: "fine sea salt",
amount: "½ tsp ",
},
];
I want to categories them based on these variables below:
var spices = ["paprika", "parsley", "peppermint", "poppy seed", "rosemary"];
var meats = ["steak", "ground beef", "stewing beef", "roast beef", "ribs", "chicken"];
var dairy = ["milk", "egg", "cheese", "yogurt"];
var produce = ["peppers", "radishes", "onions", "Tomato"];
This is what I am trying to obtain:
// desired output:
const ShoppingList = [
{
produceOutput: [
{
val: "garlic, minced",
amount: "8 cloves ",
},
],
spicesOutput: [
{
val: "paprika",
amount: "½ tsp ",
},
{
val: "onion",
amount: "1",
},
],
//The ingredient only goes in here if the value is not in the categories
NoCategoryOutput: [
{
val: "fine sea salt",
amount: "½ tsp",
},
],
},
];
I have made a Regex to check the value however it doesn't work and it does not recognize between Paprika
and paprika
or greek yogurt
and yogurt
please can someone help me with this
const Categorize = (term) => {
let data = []
if (term) {
const newData = Ingris.filter(({ Ingris }) => {
if (RegExp(term, "gim").exec(Ingris))
return ingridients.filter(({ amount }) => RegExp(term, "gim").exec(amount))
.length;
});
data.push(newData)
} else {
data = []
}
};
The very detailed explanation of the chosen approach can be found beneath the next provided example code.
const ingredientList = [{
"amount": "1",
"val": "packet pasta"
}, {
"val": "Chicken breast"
}, {
"val": "Ground ginger"
}, {
"amount": "8 cloves",
"val": "garlic, minced"
}, {
"amount": "1",
"val": "onion"
}, {
"amount": "½ tsp",
"val": "paprika"
}, {
"amount": "1 Chopped",
"val": "Tomato"
}, {
"amount": "1/2 Cup",
"val": "yogurt"
}, {
"amount": "1/2 teaspoon",
"val": "heavy cream"
}, {
"amount": "½ tsp",
"val": "fine sea salt"
}];
const spiceList = ["paprika", "parsley", "peppermint", "poppy seed", "rosemary"];
const meatList = ["steak", "ground beef", "stewing beef", "roast beef", "ribs", "chicken breast"];
const dairyList = ["milk", "eggs", "egg", "cheese", "yogurt", "cream"];
const produceList = ["peppers", "pepper", "radishes", "radish", "onions", "onion", "Tomatos", "Tomato", "Garlic", "Ginger"];
function groupItemByCategoryDescriptorAndSourceKey(collector, item) {
const {
descriptorList,
uncategorizableKey,
itemSourceKey,
index
} = collector;
const isEqualCategoryValues = (
((typeof collector.isEqualCategoryValues === 'function') && collector.isEqualCategoryValues) ||
((itemValue, categoryValue) => {
// this is the default implementation of how to determine equality
// of two values in case no other function was provided via the
// `collector`'s `isEqualCategoryValues` property.
itemValue = itemValue.trim().replace((/\s+/g), ' ').toLowerCase();
categoryValue = categoryValue.trim().replace((/\s+/g), ' ').toLowerCase();
return (itemValue === categoryValue);
})
);
let currentCategoryList;
function doesBoundValueEqualCategoryValue(categoryValue) {
return isEqualCategoryValues(this.value, categoryValue);
}
function doesBoundValueMatchCategoryAndWhichIsIt(descriptor) {
const isMatchingValue = descriptor.valueList.some(
doesBoundValueEqualCategoryValue, this
);
if (isMatchingValue) { // ... and which is it?
const categoryKey = descriptor.targetKey;
currentCategoryList = (
index[categoryKey] ||
(index[categoryKey] = [])
);
currentCategoryList.push(item);
}
return isMatchingValue;
}
const isCategorizable = descriptorList.some(
doesBoundValueMatchCategoryAndWhichIsIt,
{ value: item[itemSourceKey] }
);
if (!isCategorizable) {
currentCategoryList = (
index[uncategorizableKey] ||
(index[uncategorizableKey] = [])
);
currentCategoryList.push(item);
}
return collector;
}
console.log(
'Shopping List :', JSON.parse(JSON.stringify([ // in order to get rid of SO specific object reference logs.
ingredientList.reduce(groupItemByCategoryDescriptorAndSourceKey, {
descriptorList: [{
targetKey: 'spicesOutput',
valueList: spiceList
}, {
targetKey: 'meatsOutput',
valueList: meatList
}, {
targetKey: 'dairyOutput',
valueList: dairyList
}, {
targetKey: 'produceOutput',
valueList: produceList
}],
uncategorizableKey: 'noCategoryOutput',
// isEqualCategoryValues: anyCustomImplementationWhichDeterminesEqualityOfTwoCategoryValues
itemSourceKey: 'val',
index: {}
}).index]))
);
function isEqualCategoryValues(itemValue, categoryValue) {
// this is a custom implementation of how
// to determine equality of two category.
itemValue = itemValue.trim().replace((/\s+/g), ' ').toLowerCase();
categoryValue = categoryValue.trim().replace((/\s+/g), ' ').toLowerCase();
return (
(itemValue === categoryValue) ||
RegExp('\\b' + categoryValue + '\\b').test(itemValue)
);
}
console.log(
'Shopping List (custom method for equality of category values) :', JSON.parse(JSON.stringify([
ingredientList.reduce(groupItemByCategoryDescriptorAndSourceKey, {
descriptorList: [{
targetKey: 'spicesOutput',
valueList: spiceList
}, {
targetKey: 'meatsOutput',
valueList: meatList
}, {
targetKey: 'dairyOutput',
valueList: dairyList
}, {
targetKey: 'produceOutput',
valueList: produceList
}],
uncategorizableKey: 'noCategoryOutput',
isEqualCategoryValues,
itemSourceKey: 'val',
index: {}
}).index]))
);
.as-console-wrapper { min-height: 100%!important; top: 0; }
The Approach
The problem that was provided by the OP pretty much looks like a (rather complex) reduce
task from a list of ingredient items into an index/map that features different target lists for ingredient source list items.
From my point of view pushing this reduce-result additionally as sole item into an array is questionable.
const shoppingListIndex = {
produceOutput: [{
val: "garlic, minced",
amount: "8 cloves ",
}],
spicesOutput: [{
// ...
}],
NoCategoryOutput: [{
val: "fine sea salt",
amount: "½ tsp",
}]
};
// ... instead of ...
const ShoppingList = [{
produceOutput: [{
// ...
}],
spicesOutput: [{
// ...
}],
NoCategoryOutput: [{
// ...
}]
}];
Any straightforward approach somehow would stepwise pick an ingredient item, and then, for each item again, would search through each given category list until the ingredient item's val
value does match the first best category item from whichever current category list.
This task can be generalized via reduce functionality. In order to be even more generic such an implementation should not make any assumption about (or should not "know") the environment as well as the names and amount of involved lists etc.
Thus such an implementation has to be abstract and configurable. Which means that one should be clear about how to break down the OP's problem into such abstractions and configurations.
The reduce methods accumulator
can be used as config
or collector
object.
Thus, in order to not be depended on neither the amount of category lists nor their names, one does provide a list of category descriptor objects to the collector
. The implementation will know/identify this config item as descriptorList
.
Furthermore, in order to being flexible about the naming of an ingredient item's category target list, such a descriptor item does not only carry the list of possibly matching category values, but also features a property for the target list's name ...
A possible use case for a generic reduce task then might look similar to the next code example ...
ingredientList.reduce(groupItemByCategoryDescriptorAndSourceKey, {
descriptorList: [{
targetKey: 'spicesOutput',
valueList: spiceList // the OP's category list example.
}, {
targetKey: 'meatsOutput',
valueList: meatList // the OP's category list example.
}, {
targetKey: 'dairyOutput',
valueList: dairyList // the OP's category list example.
}, {
targetKey: 'produceOutput',
valueList: produceList // the OP's category list example.
}]
});
In addition, the configuration for a fully generically working reduce task has to provide the property name (key) for any source list item, in order to compare its value against any category value from any of the provided category value lists. The implementation will know/identify this config item as itemSourceKey
.
Another necessary config item is uncategorizableKey
. Its value will serve as key for the special list of source list items that could not be categorized (means no match was found amongst all the provided category lists).
There will be an optional isEqualCategoryValues
config key. If provided, this property refers to a custom function which determines the equality of two category values; with its first itemValue
argument holding the reference of the currently processed source list item, and its second categoryValue
argument holding the reference of the currently processed value of whichever currently processed category list.
Finally there is index
which always is an empty object literal and the very reference that the reduce process writes its result to.
Thus a full use case for a generic reduce task then might look similar to the next code example ...
const shoppingListIndex =
ingredientList.reduce(groupItemByCategoryDescriptorAndSourceKey, {
descriptorList: [{
targetKey: 'spicesOutput',
valueList: spiceList
}, {
targetKey: 'meatsOutput',
valueList: meatList
}, {
targetKey: 'dairyOutput',
valueList: dairyList
}, {
targetKey: 'produceOutput',
valueList: produceList
}],
uncategorizableKey: 'noCategoryOutput',
isEqualCategoryValues,
itemSourceKey: 'val',
index: {}
}).index;
The Comparison / Determining Equality
Having separated now the generic computation part from the case specific configuration, one has to focus on how one determines the equality of both values, for the given example, the val
value of an ingredient item at one hand and, at the other, the many values listed in one of the OP's category arrays.
There is for instance { ... "val": "onion" ... }
or even { ... "val": "Chicken breast" ... }
which are supposed to find their equal counterparts each in "onion"
as of produceList
and in "chicken breast"
as of meatList
.
As for "Chicken breast"
vs "chicken breast"
it is obvious that a comparison process has to convert both operants, each into a normalized variant of itself. toLowerCase
here was already sufficient enough, but in order to be on the safe side, one should take care of any whitespace sequence by first trim
ming a value and secondly replace
'ing any other remaining whitespace sequence with a single blank character.
Thus an already good enough standard comparison for equality might look like ...
function isEqualCategoryValues(itemValue, categoryValue) {
itemValue = itemValue.trim().replace((/\s+/g), ' ').toLowerCase();
categoryValue = categoryValue.trim().replace((/\s+/g), ' ').toLowerCase();
return (itemValue === categoryValue);
});
... and in fact, this is the fallback which got implemented as internal part of the reducer function in case no custom function for determining equality was provided to the reducer's collector/config object.
This naive value equality check of cause does fail immediately for any less precisely written ingredient respectively category value, like with those ones from the example code ... "Ground ginger"
vs "Ginger"
from produceList
, ... "heavy cream"
vs "cream"
from dairyList
, ... "garlic, minced"
vs "Garlic"
again from produceList
.
It is obvious that one is in need for a better, for a custom made, equality check in order to fully cover the OP's needs/requirements/acceptance criteria. But it is also nice that solving the problem now boils down to just providing a tailored function which solves just the part of how one does exactly determine value equality.
Having at hand the already normalized variants of "ground ginger"
vs "ginger"
and thinking about the occurrence of more than just 2 words within a string value separated and/or terminated by whitespace(s) and/or word boundar(y)ie(s) a valid approach could be based on Regular Expressions / (RegExp
)
console.log(
"(/\\bginger\\b/).test('ground ginger') ?",
(/\bginger\b/).test('ground ginger')
);
console.log(
"RegExp('\\\\b' + 'ginger' + '\\\\b', 'i').test('ground ginger') ?",
RegExp('\\b' + 'ginger' + '\\b').test('ground ginger')
);
.as-console-wrapper { min-height: 100%!important; top: 0; }
Thus a custom isEqualCategoryValues
function which reliably covers the OP's use case is implemented almost identically to the internally used default equality check. It in addition features a RegExp
based check which, at time, builds and tests the correct regex like it was demonstrated with the executable example code right above this paragraph.
The full custom implementation than might look like that ...
function isEqualCategoryValues(itemValue, categoryValue) {
itemValue = itemValue.trim().replace((/\s+/g), ' ').toLowerCase();
categoryValue = categoryValue.trim().replace((/\s+/g), ' ').toLowerCase();
return (
(itemValue === categoryValue) ||
RegExp('\\b' + categoryValue + '\\b').test(itemValue)
);
}
The Reduce Logic / Implementation
Having already gained an understanding why (generic reduce task but flexible in its configuration, thus being capable of processing a big variety of use cases) and how one is going to use the reduce functions collector configuration ...
const shoppingListIndex =
ingredientList.reduce(groupItemByCategoryDescriptorAndSourceKey, {
descriptorList: [{ /* ... */ }, { /* ... */ }/*, ... */],
uncategorizableKey: 'noCategoryOutput',
isEqualCategoryValues,
itemSourceKey: 'val',
index: {}
}).index;
... one now can proceed with the actual implementation of the reduce logic by just literally following the words from »The Approach« section further above.
Reading this section again, a solution might take shape which is entirely build from stacked some
tasks. The nature of some
is to leave a search task (break the iteration cycle) as soon as possible with the first found match (a boolean true
return value). This is exactly what one needs to do in order to solve the OP's problem; and the stacking is due to searching for a value that should find its match within a list of category value lists.
Since the detection functionality of the some
based approach has not just to assure the "early exit" but also needs to supply the information about the second comparison value, one has to use the callback function's this
context as data carrier.
The most outer of the some
based detection methods solves the additional task of writing / collecting the found category. Thus this method could be named doesBoundValueMatchCategoryAndWhichIsIt
and its usage might most probably look like the next code example ...
// iterate the (descriptor) list of category lists.
const isCategorizable = descriptorList.some(
doesBoundValueMatchCategoryAndWhichIsIt,
{ value: item[itemSourceKey] }
);
As one can see does the final return value of the entire some
stack tell whether a (ingredient) value could be categorized (or not).
The implementation of doesBoundValueMatchCategoryAndWhichIsIt
might look similar to this one ...
function doesBoundValueMatchCategoryAndWhichIsIt(descriptor) {
// iterate the current category list.
// boolean return value
const isMatchingValue = descriptor.valueList.some(
doesBoundValueEqualCategoryValue, this
);
// act upon the return value.
//
// - push the item of the related value- match
// into the corresponding category list (create
// the latter in case it did not yet exist).
if (isMatchingValue) { // ... and which is it?
const categoryKey = descriptor.targetKey;
currentCategoryList = (
index[categoryKey] ||
(index[categoryKey] = [])
);
currentCategoryList.push(item);
}
// forces "early exit" in case of being `true`.
return isMatchingValue;
}
With doesBoundValueEqualCategoryValue
the passage of the currently processed (ingredient) item-value almost has reached its end. This function forwards its bound current item-value and its first argument, the current category-value, to the equality function (the latter either being provided as custom variant or as internal default) ...
function doesBoundValueEqualCategoryValue(categoryValue) {
return isEqualCategoryValues(this.value, categoryValue);
}
Last, if a currently processed (ingredient) item-value could not be categorized this item gets pushed into the list which is identified by the collectors uncategorizableKey
attribute.
That's it. Thank's for reading.
Taking into account another, related, question of the OP ... How does one parse best each item of an ingredient list and does create a new object based on each parsing result? ... and one of the approaches there ... one gets something powerful like the next configurable reduce
based process chain ...
const ingredientList = [
'1 packet pasta',
'Chicken breast',
'Ground ginger',
'8 cloves garlic, minced',
'1 onion',
'½ tsp paprika',
'1 Chopped Tomato',
'1/2 Cup yogurt',
'1/2 teaspoon heavy cream',
'½ tsp fine sea salt'
];
const measuringUnitList = [
'tbsp', 'tablespoons', 'tablespoon', 'tsp', 'teaspoons', 'teaspoon', 'chopped',
'oz', 'ounces', 'ounce', 'fl. oz', 'fl. ounces', 'fl. ounce', 'fluid ounces', 'fluid ounce',
'cups', 'cup', 'qt', 'quarts', 'quart', 'pt', 'pints', 'pint', 'gal', 'gallons', 'gallon',
'ml', 'milliliter', 'l', 'liter',
'g', 'gram', 'kg', 'kilogram'
];
const spiceList = ["paprika", "parsley", "peppermint", "poppy seed", "rosemary"];
const meatList = ["steak", "ground beef", "stewing beef", "roast beef", "ribs", "chicken breast"];
const dairyList = ["milk", "eggs", "egg", "cheese", "yogurt", "cream"];
const produceList = ["peppers", "pepper", "radishes", "radish", "onions", "onion", "Tomatos", "Tomato", "Garlic", "Ginger"];
function isEqualCategoryValues(itemValue, categoryValue) {
itemValue = itemValue.trim().replace((/\s+/g), ' ').toLowerCase();
categoryValue = categoryValue.trim().replace((/\s+/g), ' ').toLowerCase();
return (
(itemValue === categoryValue) ||
RegExp('\\b' + categoryValue + '\\b').test(itemValue)
);
}
console.log('Ingredient List :', ingredientList);
console.log(
'Shopping List Index :', JSON.parse(JSON.stringify( // in order to get rid of SO specific object reference logs.
ingredientList.reduce(collectNamedCaptureGroupData, {
regXPrimary: createUnitCentricCapturingRegX(measuringUnitList),
regXSecondary: unitlessCapturingRegX,
defaultKey: 'val',
list: []
}).list.reduce(groupItemByCategoryDescriptorAndSourceKey, {
descriptorList: [{
targetKey: 'spicesOutput',
valueList: spiceList
}, {
targetKey: 'meatsOutput',
valueList: meatList
}, {
targetKey: 'dairyOutput',
valueList: dairyList
}, {
targetKey: 'produceOutput',
valueList: produceList
}],
uncategorizableKey: 'noCategoryOutput',
isEqualCategoryValues,
itemSourceKey: 'val',
index: {}
}).index))
);
.as-console-wrapper { min-height: 100%!important; top: 0; }
<script>
// [https://stackoverflow.com/questions/3115150/how-to-escape-regular-expression-special-characters-using-javascript/9310752#9310752]
function escapeRegExpSearchString(text) {
// return text.replace(/[-[\]{}()*+?.,\\^$|#\\s]/g, '\\$&');
// ... slightly changed ...
return text
.replace(/[-[\]{}()*+?.,\\^$|#]/g, '\\$&')
.replace((/\s+/), '\\s+');
}
// https://stackoverflow.com/questions/63880334/how-does-one-parse-best-each-item-of-an-ingredient-list-and-does-create-a-new-ob/63885323#63885323
function createUnitCentricCapturingRegX(unitList) {
// see: [https://regex101.com/r/7bmGXN/1/]
// e.g. (/^(?<amount>.*?)\s*\b(?<unit>tsp|...|fl\.\s*ounces|fl\.\s*ounce|cup)\b\s*(?<content>.*)$/)
const options = unitList
.map(unit => escapeRegExpSearchString(unit))
.join('|')
.replace((/\\\.\\s\+/g), '\\\.\\s*');
return RegExp('^(?<amount>.*?\\s*\\b(?:' + options + '))\\b\\s*(?<val>.*)$', 'i');
}
const unitlessCapturingRegX = (/^(?<amount>¼|½|¾|\d+\/\d+|\d+)\s*(?<val>.*)$/);
function collectNamedCaptureGroupData(collector, item) {
item = item.trim();
const { regXPrimary, regXSecondary, defaultKey, list } = collector;
const result = regXPrimary.exec(item) || regXSecondary.exec(item);
list.push(
(result && result.groups && Object.assign({}, result.groups))
|| { [defaultKey]: item }
);
return collector;
}
// https://stackoverflow.com/questions/63884077/how-does-one-categorize-a-list-of-data-items-via-many-different-category-lists-w/63907980#63907980
function groupItemByCategoryDescriptorAndSourceKey(collector, item) {
const {
descriptorList,
uncategorizableKey,
itemSourceKey,
index
} = collector;
const isEqualCategoryValues = (
((typeof collector.isEqualCategoryValues === 'function') && collector.isEqualCategoryValues) ||
((itemValue, categoryValue) => {
// this is the default implementation of how to determine equality
// of two values in case no other function was provided via the
// `collector`'s `isEqualCategoryValues` property.
itemValue = itemValue.trim().replace((/\s+/g), ' ').toLowerCase();
categoryValue = categoryValue.trim().replace((/\s+/g), ' ').toLowerCase();
return (itemValue === categoryValue);
})
);
let currentCategoryList;
function doesBoundValueEqualCategoryValue(categoryValue) {
return isEqualCategoryValues(this.value, categoryValue);
}
function doesBoundValueMatchCategoryAndWhichIsIt(descriptor) {
const isMatchingValue = descriptor.valueList.some(
doesBoundValueEqualCategoryValue, this
);
if (isMatchingValue) { // ... and which is it?
const categoryKey = descriptor.targetKey;
currentCategoryList = (
index[categoryKey] ||
(index[categoryKey] = [])
);
currentCategoryList.push(item);
}
return isMatchingValue;
}
const isCategorizable = descriptorList.some(
doesBoundValueMatchCategoryAndWhichIsIt,
{ value: item[itemSourceKey] }
);
if (!isCategorizable) {
currentCategoryList = (
index[uncategorizableKey] ||
(index[uncategorizableKey] = [])
);
currentCategoryList.push(item);
}
return collector;
}
</script>