feat: expand ingredient-alias seed list to ~137 entries, ignore accents in name matching (v0.86.0)

Ingredient-alias matching (pantry, can-cook, auto-deduct, shopping-list generation) now covers common bilingual EN/FR ingredients across all 8 grocery categories, up from ~10 staples.

Also: matching now ignores accents as well as case (NFD-normalize + strip combining marks) in both the alias resolver (lib/ingredient-match.ts) and the older name-fallback matcher (pantry-shopping-match.ts) — "café"/"Café"/"cafe" and "Épinard"/"epinard" all recognize as the same ingredient without needing every accent variant manually listed as an alias.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-24 19:07:57 +02:00
parent 4ae0bd580e
commit b243cc3b8b
9 changed files with 206 additions and 39 deletions
+8
View File
@@ -2,6 +2,14 @@
All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together. All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together.
## 0.86.0 — 2026-07-24 21:30
### Added
- Ingredient-alias matching now covers ~137 common bilingual EN/FR ingredients across all grocery categories (was ~10) — produce, dairy/eggs, meat/seafood, bakery, frozen, pantry staples, spices/condiments, and beverages.
### Fixed
- Ingredient name matching (pantry, can-cook, auto-deduct, shopping-list generation) now ignores accents as well as case — "café"/"Café"/"cafe" and "Épinard"/"epinard" all recognized as the same ingredient.
## 0.85.0 — 2026-07-24 21:00 ## 0.85.0 — 2026-07-24 21:00
### Added ### Added
+1 -1
View File
@@ -71,7 +71,7 @@ Status legend: **Exists** (fully working) · **Partial** (works but with a real
| Grocery delivery integration | **Partial / stub** | Generic export payload works (integrator-shaped — no visible in-app UI consumer besides the Instacart adapter); Instacart adapter is an explicit stub (requires a partnership agreement Epicure doesn't have — returns 501 if unconfigured, throws if "configured") | `apps/web/lib/grocery-providers/instacart.ts` | | Grocery delivery integration | **Partial / stub** | Generic export payload works (integrator-shaped — no visible in-app UI consumer besides the Instacart adapter); Instacart adapter is an explicit stub (requires a partnership agreement Epicure doesn't have — returns 501 if unconfigured, throws if "configured") | `apps/web/lib/grocery-providers/instacart.ts` |
| Other delivery/price integrations (DoorDash, Kroger, Walmart, live pricing) | **Missing** | Confirmed absent by repo-wide search | — | | Other delivery/price integrations (DoorDash, Kroger, Walmart, live pricing) | **Missing** | Confirmed absent by repo-wide search | — |
| Pantry manual CRUD | Exists (extended 2026-07-24, twice) | Full edit dialog added (previously add+delete only, despite the API already supporting `PUT`) — name/quantity/unit/expiry plus **notes** (free text) and **category** (same `GROCERY_CATEGORIES` slugs shopping lists use; edit dialog's category dropdown initially displayed the raw slug/`__other__` instead of the translated label — fixed by passing a value→label render function to `SelectValue`, same pattern already used in `shopping-list-view.tsx`'s sort dropdown). List always renders all 9 category sections (8 `GROCERY_CATEGORIES` + Other) as collapsible groups, even empty ones — not just categories that currently have items — and items can be dragged between sections (dnd-kit `useDraggable`/`useDroppable`, cross-category drop only; no within-category manual reorder since pantry items have no `sortOrder` column to persist one). An "Auto-categorize" action (same `guessAisle` heuristic as the shopping list) fills in categories for uncategorized items in one click. Includes a bulk case-insensitive name+unit merge endpoint (used by the scan-confirm flow). | `apps/web/app/api/v1/pantry/**`, `apps/web/components/meal-plan/pantry-manager.tsx`, `apps/web/components/pantry/pantry-item-dialog.tsx` | | Pantry manual CRUD | Exists (extended 2026-07-24, twice) | Full edit dialog added (previously add+delete only, despite the API already supporting `PUT`) — name/quantity/unit/expiry plus **notes** (free text) and **category** (same `GROCERY_CATEGORIES` slugs shopping lists use; edit dialog's category dropdown initially displayed the raw slug/`__other__` instead of the translated label — fixed by passing a value→label render function to `SelectValue`, same pattern already used in `shopping-list-view.tsx`'s sort dropdown). List always renders all 9 category sections (8 `GROCERY_CATEGORIES` + Other) as collapsible groups, even empty ones — not just categories that currently have items — and items can be dragged between sections (dnd-kit `useDraggable`/`useDroppable`, cross-category drop only; no within-category manual reorder since pantry items have no `sortOrder` column to persist one). An "Auto-categorize" action (same `guessAisle` heuristic as the shopping list) fills in categories for uncategorized items in one click. Includes a bulk case-insensitive name+unit merge endpoint (used by the scan-confirm flow). | `apps/web/app/api/v1/pantry/**`, `apps/web/components/meal-plan/pantry-manager.tsx`, `apps/web/components/pantry/pantry-item-dialog.tsx` |
| Ingredient alias matching (new 2026-07-24) | Exists | The `ingredients` table (canonical name + `aliases[]`) existed but was never populated or queried anywhere. Seeded with ~10 bilingual EN/FR staples (salt, sugar, pepper, flour, butter, milk, egg, onion, garlic, olive oil`packages/db/src/seed.ts`) and wired into every place that compares ingredient names by raw text: pantry add/edit (sets `ingredientId` when a name/alias matches), the can-cook / "use it up soon" scorer, auto-deduct-on-cook, and shopping-list pantry-awareness on generation. Resolution happens at compare-time from free text (`resolveIngredientKey`), not from a stored FK on both sides — recipe ingredients still don't carry `ingredientId`. | `apps/web/lib/ingredient-match.ts`, `packages/db/src/seed.ts` | | Ingredient alias matching (new 2026-07-24, expanded same day) | Exists | The `ingredients` table (canonical name + `aliases[]`) existed but was never populated or queried anywhere. Seeded with ~137 bilingual EN/FR entries across all 8 grocery categories (produce, dairy/eggs, meat/seafood, bakery, frozen, pantry, spices/condiments, beverages`packages/db/src/seed.ts`) and wired into every place that compares ingredient names by raw text: pantry add/edit (sets `ingredientId` when a name/alias matches), the can-cook / "use it up soon" scorer, auto-deduct-on-cook, and shopping-list pantry-awareness on generation. Comparison is case- **and accent-insensitive** (`café`/`Café`/`cafe` all equal — NFD-normalize + strip combining marks) in both the alias resolver and the older plain-name fallback matcher (`pantry-shopping-match.ts`'s `normalizeName`, used for the reliable-ingredientId-or-name-fallback path). Resolution happens at compare-time from free text (`resolveIngredientKey`), not from a stored FK on both sides — recipe ingredients still don't carry `ingredientId`. Growing the list: Admin → Ingredients (UI) or editing `packages/db/src/seed.ts` + `pnpm db:seed` (idempotent). | `apps/web/lib/ingredient-match.ts`, `packages/db/src/seed.ts` |
| Admin: Ingredients CRUD (new 2026-07-24) | Exists | Admin-only page to manage canonical ingredients + aliases without a code change/redeploy — previously the seed file was the only place to add them. Create/edit/delete; name is unique (409 on collision); deleting an ingredient just unlinks any pantry item/recipe ingredient that pointed at it (`ingredientId` is `onDelete: "set null"` on both), nothing else is deleted. | `apps/web/app/admin/ingredients/page.tsx`, `apps/web/app/api/v1/admin/ingredients/**`, `apps/web/components/admin/ingredients-manager.tsx` | | Admin: Ingredients CRUD (new 2026-07-24) | Exists | Admin-only page to manage canonical ingredients + aliases without a code change/redeploy — previously the seed file was the only place to add them. Create/edit/delete; name is unique (409 on collision); deleting an ingredient just unlinks any pantry item/recipe ingredient that pointed at it (`ingredientId` is `onDelete: "set null"` on both), nothing else is deleted. | `apps/web/app/admin/ingredients/page.tsx`, `apps/web/app/api/v1/admin/ingredients/**`, `apps/web/components/admin/ingredients-manager.tsx` |
| Merge duplicate pantry items (new 2026-07-24, relaxed same day) | Exists | One-shot cleanup for pre-existing pantry rows that are the same ingredient under different names (added before alias matching existed) — groups by resolved ingredient key **alone** (unit is deliberately not part of the grouping key, so two rows merge even with mismatched, missing, or differently-unit'd quantities); sums quantities only when every row in a group has a parseable quantity **and** the same unit, otherwise keeps the first known (quantity, unit) pair rather than guessing. Concatenates notes, keeps the soonest expiry. Manual trigger (a button in the pantry toolbar), not automatic — manual single-item add still always inserts a new row rather than silently merging, since two batches of the same ingredient can have different expiry dates worth tracking separately. | `apps/web/app/api/v1/pantry/merge-duplicates/route.ts` | | Merge duplicate pantry items (new 2026-07-24, relaxed same day) | Exists | One-shot cleanup for pre-existing pantry rows that are the same ingredient under different names (added before alias matching existed) — groups by resolved ingredient key **alone** (unit is deliberately not part of the grouping key, so two rows merge even with mismatched, missing, or differently-unit'd quantities); sums quantities only when every row in a group has a parseable quantity **and** the same unit, otherwise keeps the first known (quantity, unit) pair rather than guessing. Concatenates notes, keeps the soonest expiry. Manual trigger (a button in the pantry toolbar), not automatic — manual single-item add still always inserts a new row rather than silently merging, since two batches of the same ingredient can have different expiry dates worth tracking separately. | `apps/web/app/api/v1/pantry/merge-duplicates/route.ts` |
| Auto-deduct pantry on cook | Exists (closed 2026-07-24) | Both UI callers that previously hardcoded `deductFromPantry: false` (`batch-cook-dishes.tsx`, `meal-planner.tsx`) now pass `true`. The new general "Mark cooked" feature (see below) additionally exposes it as a per-cook checkbox, default checked, rather than a silent always-on. Matching now goes through the ingredient-alias resolver, not a raw name string compare. | `apps/web/app/api/v1/recipes/[id]/cooked/route.ts`, `apps/web/components/recipe/batch-cook-dishes.tsx`, `apps/web/components/meal-plan/meal-planner.tsx` | | Auto-deduct pantry on cook | Exists (closed 2026-07-24) | Both UI callers that previously hardcoded `deductFromPantry: false` (`batch-cook-dishes.tsx`, `meal-planner.tsx`) now pass `true`. The new general "Mark cooked" feature (see below) additionally exposes it as a per-cook checkbox, default checked, rather than a silent always-on. Matching now goes through the ingredient-alias resolver, not a raw name string compare. | `apps/web/app/api/v1/recipes/[id]/cooked/route.ts`, `apps/web/components/recipe/batch-cook-dishes.tsx`, `apps/web/components/meal-plan/meal-planner.tsx` |
+11 -1
View File
@@ -1,5 +1,5 @@
// Mirrors CHANGELOG.md at the repo root — update both together. // Mirrors CHANGELOG.md at the repo root — update both together.
export const APP_VERSION = "0.85.0"; export const APP_VERSION = "0.86.0";
export type ChangelogEntry = { export type ChangelogEntry = {
version: string; version: string;
@@ -11,6 +11,16 @@ export type ChangelogEntry = {
}; };
export const CHANGELOG: ChangelogEntry[] = [ export const CHANGELOG: ChangelogEntry[] = [
{
version: "0.86.0",
date: "2026-07-24 21:30",
added: [
"Ingredient-alias matching now covers ~137 common bilingual EN/FR ingredients across all grocery categories (was ~10) — produce, dairy/eggs, meat/seafood, bakery, frozen, pantry staples, spices/condiments, and beverages.",
],
fixed: [
"Ingredient name matching (pantry, can-cook, auto-deduct, shopping-list generation) now ignores accents as well as case — \"café\"/\"Café\"/\"cafe\" and \"Épinard\"/\"epinard\" all recognized as the same ingredient.",
],
},
{ {
version: "0.85.0", version: "0.85.0",
date: "2026-07-24 21:00", date: "2026-07-24 21:00",
+18 -16
View File
@@ -1,14 +1,17 @@
import { db, ingredients, sql } from "@epicure/db"; import { db, ingredients } from "@epicure/db";
export type IngredientAliasIndex = Map<string, string>; export type IngredientAliasIndex = Map<string, string>;
// Case- and accent-insensitive so "café"/"cafe" and "Épinard"/"epinard"
// compare equal — NFD splits an accented letter into base letter +
// combining mark, then the combining marks (U+0300-U+036F) are stripped.
function normalize(name: string): string { function normalize(name: string): string {
return name.trim().toLowerCase(); return name.trim().toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "");
} }
/** /**
* Loads every canonical ingredient's name + aliases into a flat * Loads every canonical ingredient's name + aliases into a flat
* lowercased-string -> canonical-ingredient-id map, once per request. Used * normalized-string -> canonical-ingredient-id map, once per request. Used
* to recognize that "sel", "sel fin", and "table salt" are all the same * to recognize that "sel", "sel fin", and "table salt" are all the same
* ingredient, without requiring every recipe/pantry row to carry a stored * ingredient, without requiring every recipe/pantry row to carry a stored
* ingredientId (they don't — this resolves purely from the free-text name * ingredientId (they don't — this resolves purely from the free-text name
@@ -27,25 +30,24 @@ export async function loadIngredientAliasIndex(): Promise<IngredientAliasIndex>
} }
/** Canonical ingredient id if `rawName` matches a known name/alias exactly /** Canonical ingredient id if `rawName` matches a known name/alias exactly
* (case/whitespace-insensitive); otherwise the normalized rawName itself, * (case/accent/whitespace-insensitive); otherwise the normalized rawName
* so unmatched items still compare equal to other unmatched items with the * itself, so unmatched items still compare equal to other unmatched items
* exact same text (today's behavior, unchanged for anything not seeded). */ * with the same text (today's behavior, unchanged for anything not seeded). */
export function resolveIngredientKey(rawName: string, index: IngredientAliasIndex): string { export function resolveIngredientKey(rawName: string, index: IngredientAliasIndex): string {
const normalized = normalize(rawName); const normalized = normalize(rawName);
return index.get(normalized) ?? normalized; return index.get(normalized) ?? normalized;
} }
/** Single-name lookup (pantry add/edit) — a direct query rather than /** Single-name lookup (pantry add/edit). Loads the same small table as
* loading the whole table, since this runs once per add/rename rather than * loadIngredientAliasIndex and compares in JS rather than in SQL — accent
* in a loop. Returns null when there's no canonical match, meaning the item * stripping via NFD has no simple SQL equivalent without the `unaccent`
* stays a plain freeform pantry entry. */ * extension, which isn't guaranteed to be installed. The ingredients table
* is small (tens to low hundreds of rows), so this is cheap. Returns null
* when there's no canonical match, meaning the item stays a plain freeform
* pantry entry. */
export async function findIngredientIdByName(rawName: string): Promise<string | null> { export async function findIngredientIdByName(rawName: string): Promise<string | null> {
const normalized = normalize(rawName); const normalized = normalize(rawName);
if (!normalized) return null; if (!normalized) return null;
const [match] = await db const index = await loadIngredientAliasIndex();
.select({ id: ingredients.id }) return index.get(normalized) ?? null;
.from(ingredients)
.where(sql`lower(${ingredients.name}) = ${normalized} or exists (select 1 from unnest(${ingredients.aliases}) a where lower(a) = ${normalized})`)
.limit(1);
return match?.id ?? null;
} }
+4 -3
View File
@@ -15,9 +15,10 @@ export function scoreRecipesAgainstPantry<T>(
pantry: { rawName: string; expiresAt: Date | null }[], pantry: { rawName: string; expiresAt: Date | null }[],
aliasIndex?: IngredientAliasIndex aliasIndex?: IngredientAliasIndex
) { ) {
// With no alias index, this resolves to a plain lowercase compare — // With no alias index, this resolves to a plain case/accent-insensitive
// same behavior as before aliases existed. // compare — same behavior as before aliases existed, just accent-aware.
const keyOf = (name: string) => (aliasIndex ? resolveIngredientKey(name, aliasIndex) : name.trim().toLowerCase()); const keyOf = (name: string) =>
aliasIndex ? resolveIngredientKey(name, aliasIndex) : name.trim().toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "");
const pantryKeys = new Set(pantry.map((p) => keyOf(p.rawName))); const pantryKeys = new Set(pantry.map((p) => keyOf(p.rawName)));
const expiringSoonKeys = new Set( const expiringSoonKeys = new Set(
+4 -1
View File
@@ -37,7 +37,10 @@ export type PantryAdjustedItem = {
}; };
export function normalizeName(name: string): string { export function normalizeName(name: string): string {
const trimmed = name.toLowerCase().trim().replace(/\s+/g, " "); // Accent-insensitive too ("café"/"cafe") — NFD splits an accented letter
// into base letter + combining mark, then the marks (U+0300-U+036F) are
// stripped.
const trimmed = name.toLowerCase().trim().normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/\s+/g, " ");
// Simple plural trim — strip a single trailing "s" for words longer than 3 chars. // Simple plural trim — strip a single trailing "s" for words longer than 3 chars.
return trimmed.length > 3 && trimmed.endsWith("s") ? trimmed.slice(0, -1) : trimmed; return trimmed.length > 3 && trimmed.endsWith("s") ? trimmed.slice(0, -1) : trimmed;
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@epicure/web", "name": "@epicure/web",
"version": "0.85.0", "version": "0.86.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "epicure", "name": "epicure",
"version": "0.85.0", "version": "0.86.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "pnpm --filter web dev", "dev": "pnpm --filter web dev",
+158 -15
View File
@@ -1,22 +1,165 @@
import { db } from "./client"; import { db } from "./client";
import { tierDefinitions, ingredients } from "./schema"; import { tierDefinitions, ingredients } from "./schema";
// Starter set of canonical ingredients + common EN/FR synonyms, so pantry // Canonical ingredients + common EN/FR synonyms, so pantry items and recipe
// items and recipe ingredients written differently ("sel", "sel fin", "sel // ingredients written differently ("sel", "sel fin", "sel de table", "table
// de table", "table salt") can still be recognized as the same thing (see // salt") can still be recognized as the same thing (see
// lib/ingredient-match.ts). Deliberately small — grows over time as gaps // lib/ingredient-match.ts). Broad everyday-cooking coverage, not exhaustive —
// are found, not meant to be exhaustive on day one. // grows over time via Admin -> Ingredients or by editing this list (safe to
// add more and re-run; onConflictDoNothing on name).
const STAPLE_INGREDIENTS: { name: string; aliases: string[]; category: string }[] = [ const STAPLE_INGREDIENTS: { name: string; aliases: string[]; category: string }[] = [
{ name: "salt", aliases: ["sel", "sel fin", "sel de table", "table salt", "fine salt", "sea salt", "sel de mer"], category: "spicesCondiments" }, // Produce
{ name: "sugar", aliases: ["sucre", "sucre blanc", "white sugar", "granulated sugar", "sucre en poudre"], category: "pantry" }, { name: "onion", aliases: ["onions", "yellow onion", "oignon", "oignons"], category: "produce" },
{ name: "black pepper", aliases: ["pepper", "poivre", "poivre noir", "ground pepper", "poivre moulu"], category: "spicesCondiments" }, { name: "red onion", aliases: ["red onions", "oignon rouge", "oignons rouges"], category: "produce" },
{ name: "flour", aliases: ["farine", "all-purpose flour", "farine de blé", "plain flour", "wheat flour"], category: "pantry" }, { name: "shallot", aliases: ["shallots", "échalote", "échalotes", "echalote", "echalotes"], category: "produce" },
{ name: "butter", aliases: ["beurre", "unsalted butter", "beurre doux", "salted butter", "beurre demi-sel"], category: "dairyEggs" }, { name: "scallion", aliases: ["scallions", "green onion", "green onions", "spring onion", "oignon vert", "oignons verts", "ciboule"], category: "produce" },
{ name: "milk", aliases: ["lait", "whole milk", "lait entier", "lait demi-écrémé"], category: "dairyEggs" }, { name: "garlic", aliases: ["garlic clove", "garlic cloves", "ail", "gousse d'ail", "gousses d'ail"], category: "produce" },
{ name: "egg", aliases: ["eggs", "œuf", "oeuf", "œufs", "oeufs"], category: "dairyEggs" }, { name: "ginger", aliases: ["fresh ginger", "gingembre"], category: "produce" },
{ name: "onion", aliases: ["oignon", "oignons", "yellow onion", "onions"], category: "produce" }, { name: "carrot", aliases: ["carrots", "carotte", "carottes"], category: "produce" },
{ name: "garlic", aliases: ["ail", "garlic clove", "gousse d'ail", "garlic cloves"], category: "produce" }, { name: "celery", aliases: ["celery stalk", "celery stalks", "céleri", "celeri", "branche de céleri"], category: "produce" },
{ name: "olive oil", aliases: ["huile d'olive", "extra virgin olive oil", "huile d'olive vierge extra"], category: "pantry" }, { name: "potato", aliases: ["potatoes", "pomme de terre", "pommes de terre"], category: "produce" },
{ name: "sweet potato", aliases: ["sweet potatoes", "patate douce", "patates douces"], category: "produce" },
{ name: "tomato", aliases: ["tomatoes", "tomate", "tomates"], category: "produce" },
{ name: "cherry tomato", aliases: ["cherry tomatoes", "tomate cerise", "tomates cerises"], category: "produce" },
{ name: "cucumber", aliases: ["cucumbers", "concombre", "concombres"], category: "produce" },
{ name: "zucchini", aliases: ["zucchinis", "courgette", "courgettes"], category: "produce" },
{ name: "bell pepper", aliases: ["bell peppers", "poivron", "poivrons", "poivron rouge", "poivron vert"], category: "produce" },
{ name: "chili pepper", aliases: ["chili", "chile", "chilli", "piment", "piments"], category: "produce" },
{ name: "broccoli", aliases: ["brocoli"], category: "produce" },
{ name: "cauliflower", aliases: ["chou-fleur", "chou fleur"], category: "produce" },
{ name: "spinach", aliases: ["épinard", "épinards", "epinard", "epinards"], category: "produce" },
{ name: "lettuce", aliases: ["laitue"], category: "produce" },
{ name: "kale", aliases: ["chou kale"], category: "produce" },
{ name: "cabbage", aliases: ["chou"], category: "produce" },
{ name: "mushroom", aliases: ["mushrooms", "champignon", "champignons"], category: "produce" },
{ name: "avocado", aliases: ["avocados", "avocat", "avocats"], category: "produce" },
{ name: "lemon", aliases: ["lemons", "citron", "citrons"], category: "produce" },
{ name: "lime", aliases: ["limes", "citron vert", "citrons verts"], category: "produce" },
{ name: "orange", aliases: ["oranges", "orange fruit"], category: "produce" },
{ name: "apple", aliases: ["apples", "pomme", "pommes"], category: "produce" },
{ name: "banana", aliases: ["bananas", "banane", "bananes"], category: "produce" },
{ name: "leek", aliases: ["leeks", "poireau", "poireaux"], category: "produce" },
{ name: "fresh basil", aliases: ["basil", "basilic"], category: "produce" },
{ name: "fresh parsley", aliases: ["parsley", "persil"], category: "produce" },
{ name: "fresh cilantro", aliases: ["cilantro", "coriander leaves", "coriandre fraîche", "coriandre"], category: "produce" },
{ name: "fresh mint", aliases: ["mint", "menthe"], category: "produce" },
{ name: "fresh thyme", aliases: ["thyme", "thym"], category: "produce" },
{ name: "fresh rosemary", aliases: ["rosemary", "romarin"], category: "produce" },
// Dairy & eggs
{ name: "butter", aliases: ["unsalted butter", "salted butter", "beurre", "beurre doux", "beurre demi-sel"], category: "dairyEggs" },
{ name: "milk", aliases: ["whole milk", "lait", "lait entier", "lait demi-écrémé"], category: "dairyEggs" },
{ name: "egg", aliases: ["eggs", "œuf", "œufs", "oeuf", "oeufs"], category: "dairyEggs" },
{ name: "heavy cream", aliases: ["whipping cream", "crème fraîche épaisse", "crème entière", "creme fraiche"], category: "dairyEggs" },
{ name: "sour cream", aliases: ["crème aigre"], category: "dairyEggs" },
{ name: "plain yogurt", aliases: ["yogurt", "yoghurt", "yaourt", "yaourt nature"], category: "dairyEggs" },
{ name: "cheddar cheese", aliases: ["cheddar", "cheddar cheese grated"], category: "dairyEggs" },
{ name: "parmesan cheese", aliases: ["parmesan", "parmigiano"], category: "dairyEggs" },
{ name: "mozzarella cheese", aliases: ["mozzarella"], category: "dairyEggs" },
{ name: "feta cheese", aliases: ["feta"], category: "dairyEggs" },
{ name: "goat cheese", aliases: ["fromage de chèvre", "chevre"], category: "dairyEggs" },
{ name: "cream cheese", aliases: ["fromage à tartiner", "philadelphia"], category: "dairyEggs" },
{ name: "ricotta cheese", aliases: ["ricotta"], category: "dairyEggs" },
// Meat & seafood
{ name: "chicken breast", aliases: ["chicken breasts", "blanc de poulet", "escalope de poulet"], category: "meatSeafood" },
{ name: "chicken thigh", aliases: ["chicken thighs", "cuisse de poulet", "cuisses de poulet"], category: "meatSeafood" },
{ name: "whole chicken", aliases: ["poulet entier", "poulet"], category: "meatSeafood" },
{ name: "ground beef", aliases: ["minced beef", "bœuf haché", "boeuf hache", "viande hachée"], category: "meatSeafood" },
{ name: "beef steak", aliases: ["steak", "steak de bœuf", "bifteck"], category: "meatSeafood" },
{ name: "ground pork", aliases: ["minced pork", "porc haché"], category: "meatSeafood" },
{ name: "pork chop", aliases: ["pork chops", "côte de porc", "côtes de porc"], category: "meatSeafood" },
{ name: "bacon", aliases: ["lardons", "lardon"], category: "meatSeafood" },
{ name: "sausage", aliases: ["sausages", "saucisse", "saucisses"], category: "meatSeafood" },
{ name: "ham", aliases: ["jambon"], category: "meatSeafood" },
{ name: "salmon", aliases: ["saumon"], category: "meatSeafood" },
{ name: "tuna", aliases: ["thon"], category: "meatSeafood" },
{ name: "shrimp", aliases: ["prawns", "crevette", "crevettes"], category: "meatSeafood" },
{ name: "cod", aliases: ["morue", "cabillaud"], category: "meatSeafood" },
{ name: "mussels", aliases: ["moule", "moules"], category: "meatSeafood" },
{ name: "tofu", aliases: ["tofu firme"], category: "meatSeafood" },
// Bakery
{ name: "bread", aliases: ["pain"], category: "bakery" },
{ name: "baguette", aliases: ["french baguette"], category: "bakery" },
{ name: "tortilla", aliases: ["tortillas", "wrap", "wraps"], category: "bakery" },
{ name: "pita bread", aliases: ["pita", "pain pita"], category: "bakery" },
{ name: "breadcrumbs", aliases: ["bread crumbs", "chapelure", "panko"], category: "bakery" },
{ name: "puff pastry", aliases: ["pâte feuilletée", "pate feuilletee"], category: "bakery" },
{ name: "pie crust", aliases: ["pâte brisée", "pate brisee"], category: "bakery" },
// Frozen
{ name: "frozen peas", aliases: ["peas", "petits pois", "petits pois surgelés"], category: "frozen" },
{ name: "frozen corn", aliases: ["corn", "maïs", "mais", "maïs surgelé"], category: "frozen" },
{ name: "ice cream", aliases: ["glace"], category: "frozen" },
// Pantry
{ name: "sugar", aliases: ["white sugar", "granulated sugar", "sucre", "sucre blanc", "sucre en poudre"], category: "pantry" },
{ name: "brown sugar", aliases: ["sucre roux", "sucre brun", "cassonade"], category: "pantry" },
{ name: "flour", aliases: ["all-purpose flour", "plain flour", "wheat flour", "farine", "farine de blé"], category: "pantry" },
{ name: "olive oil", aliases: ["extra virgin olive oil", "huile d'olive", "huile d'olive vierge extra"], category: "pantry" },
{ name: "vegetable oil", aliases: ["cooking oil", "sunflower oil", "huile végétale", "huile de tournesol"], category: "pantry" },
{ name: "rice", aliases: ["white rice", "riz", "riz blanc"], category: "pantry" },
{ name: "pasta", aliases: ["spaghetti", "penne", "pâtes", "pates"], category: "pantry" },
{ name: "couscous", aliases: ["couscous grains"], category: "pantry" },
{ name: "quinoa", aliases: ["quinoa grains"], category: "pantry" },
{ name: "lentils", aliases: ["lentil", "lentilles", "lentille"], category: "pantry" },
{ name: "chickpeas", aliases: ["chickpea", "garbanzo beans", "pois chiches", "pois chiche"], category: "pantry" },
{ name: "black beans", aliases: ["haricots noirs"], category: "pantry" },
{ name: "kidney beans", aliases: ["red beans", "haricots rouges"], category: "pantry" },
{ name: "canned tomatoes", aliases: ["diced tomatoes", "tomates en conserve", "tomates concassées"], category: "pantry" },
{ name: "tomato paste", aliases: ["concentré de tomate", "concentre de tomate"], category: "pantry" },
{ name: "tomato sauce", aliases: ["sauce tomate", "coulis de tomate"], category: "pantry" },
{ name: "vegetable stock", aliases: ["vegetable broth", "bouillon de légumes", "bouillon legumes"], category: "pantry" },
{ name: "chicken stock", aliases: ["chicken broth", "bouillon de poulet"], category: "pantry" },
{ name: "beef stock", aliases: ["beef broth", "bouillon de bœuf", "bouillon de boeuf"], category: "pantry" },
{ name: "baking powder", aliases: ["levure chimique"], category: "pantry" },
{ name: "baking soda", aliases: ["bicarbonate of soda", "bicarbonate de soude", "bicarbonate"], category: "pantry" },
{ name: "yeast", aliases: ["active dry yeast", "levure de boulanger", "levure boulangère"], category: "pantry" },
{ name: "vanilla extract", aliases: ["vanilla", "extrait de vanille", "vanille"], category: "pantry" },
{ name: "honey", aliases: ["miel"], category: "pantry" },
{ name: "maple syrup", aliases: ["sirop d'érable", "sirop derable"], category: "pantry" },
{ name: "peanut butter", aliases: ["beurre de cacahuète", "beurre de cacahuete"], category: "pantry" },
{ name: "coconut milk", aliases: ["lait de coco"], category: "pantry" },
{ name: "chocolate chips", aliases: ["pépites de chocolat", "pepites de chocolat"], category: "pantry" },
{ name: "dark chocolate", aliases: ["chocolat noir"], category: "pantry" },
{ name: "cornstarch", aliases: ["corn starch", "cornflour", "maïzena", "maizena", "fécule de maïs"], category: "pantry" },
{ name: "soy sauce", aliases: ["sauce soja", "sauce soya"], category: "pantry" },
{ name: "vinegar", aliases: ["white vinegar", "vinaigre", "vinaigre blanc"], category: "pantry" },
{ name: "balsamic vinegar", aliases: ["vinaigre balsamique"], category: "pantry" },
{ name: "almonds", aliases: ["almond", "amande", "amandes"], category: "pantry" },
{ name: "walnuts", aliases: ["walnut", "noix"], category: "pantry" },
{ name: "raisins", aliases: ["raisin", "raisins secs"], category: "pantry" },
// Spices & condiments
{ name: "salt", aliases: ["table salt", "fine salt", "sea salt", "sel", "sel fin", "sel de table", "sel de mer"], category: "spicesCondiments" },
{ name: "black pepper", aliases: ["pepper", "ground pepper", "poivre", "poivre noir", "poivre moulu"], category: "spicesCondiments" },
{ name: "paprika", aliases: ["paprika doux"], category: "spicesCondiments" },
{ name: "cumin", aliases: ["ground cumin", "cumin moulu", "cumin poudre"], category: "spicesCondiments" },
{ name: "cinnamon", aliases: ["ground cinnamon", "cannelle", "cannelle moulue"], category: "spicesCondiments" },
{ name: "nutmeg", aliases: ["muscade", "noix de muscade"], category: "spicesCondiments" },
{ name: "chili powder", aliases: ["piment en poudre", "poudre de piment"], category: "spicesCondiments" },
{ name: "cayenne pepper", aliases: ["cayenne", "poivre de cayenne"], category: "spicesCondiments" },
{ name: "turmeric", aliases: ["curcuma"], category: "spicesCondiments" },
{ name: "curry powder", aliases: ["curry", "poudre de curry"], category: "spicesCondiments" },
{ name: "bay leaf", aliases: ["bay leaves", "laurier", "feuille de laurier"], category: "spicesCondiments" },
{ name: "oregano", aliases: ["origan"], category: "spicesCondiments" },
{ name: "dried thyme", aliases: ["thym séché", "thym sec"], category: "spicesCondiments" },
{ name: "dried basil", aliases: ["basilic séché"], category: "spicesCondiments" },
{ name: "mustard", aliases: ["dijon mustard", "moutarde", "moutarde de dijon"], category: "spicesCondiments" },
{ name: "ketchup", aliases: ["tomato ketchup", "ketchup tomate"], category: "spicesCondiments" },
{ name: "mayonnaise", aliases: ["mayo", "mayonnaise sauce"], category: "spicesCondiments" },
{ name: "hot sauce", aliases: ["sauce piquante"], category: "spicesCondiments" },
{ name: "garlic powder", aliases: ["ail en poudre", "poudre d'ail"], category: "spicesCondiments" },
{ name: "onion powder", aliases: ["oignon en poudre", "poudre d'oignon"], category: "spicesCondiments" },
// Beverages
{ name: "coffee", aliases: ["ground coffee", "café", "cafe"], category: "beverages" },
{ name: "tea", aliases: ["thé", "the"], category: "beverages" },
{ name: "orange juice", aliases: ["jus d'orange", "jus dorange"], category: "beverages" },
{ name: "red wine", aliases: ["vin rouge"], category: "beverages" },
{ name: "white wine", aliases: ["vin blanc"], category: "beverages" },
{ name: "sparkling water", aliases: ["eau gazeuse", "eau pétillante"], category: "beverages" },
]; ];
async function seed() { async function seed() {