- Ajoute une expression régulière anglaise à chacun des 26 TechStepMapping
du catalogue (locale "en"), en plus du "fr" existant — les recettes en
anglais (TheMealDB, etc.) peuvent désormais matcher leurs étapes.
- Nouveau apps/api/src/lib/ingredient-matcher.ts : moteur de matching pur
(nom d'ingrédient, unité, quantité) contre les catalogues Ingredient/Unit,
à partir de labels anglais écrits à la main (packages/shared/src/data/
catalog-labels-en.ts — 546 INGREDIENT_LABELS_EN + 17 UNIT_LABELS_EN avec
synonymes/abréviations). Tokenise et stem naïvement les deux côtés pour
tolérer pluriels et mots descriptifs superflus ; la correspondance la
plus spécifique (le plus de mots) l'emporte en cas de recoupement.
- extractQuantity() : lit un nombre en tête de texte libre (entier,
décimal, fraction simple ou nombre mixte) pour déduire la quantité et
l'unité quand la source ne les fournit pas séparément.
- Étend recipe-translation.ts : translateRecipe(recipe, locale) résout
aussi ingredientId/unitId/quantity de chaque ligne d'ingrédient — mais
uniquement pour locale "en" (seules langue avec des labels), pour ne pas
interroger la base inutilement ni halluciner un match dans une autre
langue.
- Ajoute cup/ounce/pound au catalogue Unit (toBaseFactor réel), absents
jusqu'ici alors que très fréquents dans les recettes anglaises.
- Vérifié en conditions réelles contre TheMealDB (Teriyaki Chicken
Casserole) : 8/9 ingrédients résolus avec la bonne quantité/unité, le
seul raté ("stir-fry vegetables") étant un mélange sans entrée dédiée au
catalogue — dégradation gracieuse (unitId/ingredientId: null) comme prévu.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1526 lines
61 KiB
TypeScript
1526 lines
61 KiB
TypeScript
import type {
|
|
AllergenKind,
|
|
IngredientCategory,
|
|
IngredientIcon,
|
|
IngredientSubcategory,
|
|
PrismaClient,
|
|
UnitType,
|
|
} from "@prisma/client";
|
|
|
|
// Short, optional-to-pick regime list — `UserProfile.dietId` stays
|
|
// nullable, this is not meant to be exhaustive. Authored directly as
|
|
// English camelCase `uid`s (the exact value stored as `Diet.key`) — no
|
|
// French label, no separate lookup table (see the module doc comment on
|
|
// `seedReferenceData` below). The French display text for each lives
|
|
// solely in `apps/web`'s `locales/fr/translation.json` (`catalog.diets`),
|
|
// maintained independently, tied together only by this same uid string.
|
|
export const DIETS = ["omnivore", "vegetarian", "vegan", "pescatarian", "glutenFree"];
|
|
|
|
// Recipe ingredient units — a closed, normalized set replacing what used to
|
|
// be free text (see `Unit`/`RecipeIngredient.unitId` in schema.prisma for
|
|
// why). `toBaseFactor` is how many of the type's base unit (gram for MASS,
|
|
// milliliter for VOLUME, itself for COUNT) one of this unit equals — MASS
|
|
// and VOLUME units convert against each other within their own type, COUNT
|
|
// units don't convert to one another at all (a "pincée" isn't a fixed
|
|
// fraction of a "gousse"), so each just gets `1`. Same "English camelCase
|
|
// uid, no French label" authoring as `DIETS`/`ALLERGENS` — the display
|
|
// label lives in `apps/web`'s `locales/fr/translation.json` under
|
|
// `catalog.units.<key>`.
|
|
export const UNITS: Array<{ uid: string; type: UnitType; toBaseFactor: number }> = [
|
|
{ uid: "gram", type: "MASS", toBaseFactor: 1 },
|
|
{ uid: "kilogram", type: "MASS", toBaseFactor: 1000 },
|
|
{ uid: "milliliter", type: "VOLUME", toBaseFactor: 1 },
|
|
{ uid: "centiliter", type: "VOLUME", toBaseFactor: 10 },
|
|
{ uid: "liter", type: "VOLUME", toBaseFactor: 1000 },
|
|
{ uid: "tablespoon", type: "VOLUME", toBaseFactor: 15 },
|
|
{ uid: "teaspoon", type: "VOLUME", toBaseFactor: 5 },
|
|
{ uid: "piece", type: "COUNT", toBaseFactor: 1 },
|
|
{ uid: "pinch", type: "COUNT", toBaseFactor: 1 },
|
|
{ uid: "slice", type: "COUNT", toBaseFactor: 1 },
|
|
{ uid: "clove", type: "COUNT", toBaseFactor: 1 },
|
|
{ uid: "bunch", type: "COUNT", toBaseFactor: 1 },
|
|
{ uid: "sachet", type: "COUNT", toBaseFactor: 1 },
|
|
{ uid: "sprig", type: "COUNT", toBaseFactor: 1 },
|
|
// US customary units — added for English-language sources (TheMealDB and
|
|
// similar routinely state quantities in cups/oz/lb, not metric), so
|
|
// `ingredient-matcher.ts` has something to resolve them to instead of
|
|
// always falling back to `unitId: null`. Convertible against their
|
|
// metric siblings like any other MASS/VOLUME unit.
|
|
{ uid: "cup", type: "VOLUME", toBaseFactor: 236.5882 },
|
|
{ uid: "ounce", type: "MASS", toBaseFactor: 28.3495 },
|
|
{ uid: "pound", type: "MASS", toBaseFactor: 453.5924 },
|
|
];
|
|
|
|
// Cooking-technique catalog (French recipe-step normalization) — a static
|
|
// list of common instructions, each carrying one or more text-matching
|
|
// rules used by `tech-step-matcher.ts` to auto-detect which technique(s) a
|
|
// free-text `Step.description` corresponds to (a step can mention several,
|
|
// e.g. "faire chauffer une poêle puis y faire fondre le beurre" is both
|
|
// `preheat` and `melt` — see `Step.techSteps`/`StepTechStep` in
|
|
// schema.prisma). Same "English camelCase uid, no French label" authoring
|
|
// as DIETS/UNITS — the label lives in apps/web's
|
|
// locales/fr/translation.json under `catalog.techSteps.<key>`.
|
|
// `expression` is a regex source matched (case/accent-insensitive, via
|
|
// `normalizeText`) against the step description; `weight` breaks ties when
|
|
// two *different* techniques' expressions match the same span of text
|
|
// (highest weight wins) — see `tech-step-matcher.ts`'s `matchTechSteps`.
|
|
// Specific, multi-word phrases ("cuire au four", "faire revenir") are
|
|
// weighted higher than the generic single-verb forms they overlap with
|
|
// ("cuire", "sauter") so the more specific technique wins when both match
|
|
// the same words. `locale` lets the same technique carry one matching rule
|
|
// set per language — `"fr"` and `"en"` today (the latter mainly for
|
|
// English-language sources like TheMealDB), more can be added later
|
|
// without a schema change. The two locales are independent rule sets, not
|
|
// translations of each other — an English recipe is matched only against
|
|
// the `"en"` mappings, never a mix of both.
|
|
export const TECH_STEPS: Array<{
|
|
uid: string;
|
|
mappings: Array<{ locale: string; expression: string; weight: number }>;
|
|
}> = [
|
|
{
|
|
uid: "cook",
|
|
mappings: [
|
|
{ locale: "fr", expression: "\\bcui(re|sez|sant|sson)\\b|\\bcuit(e|es|s)?\\b", weight: 10 },
|
|
{ locale: "en", expression: "\\bcook(s|ed|ing)?\\b", weight: 10 },
|
|
],
|
|
},
|
|
{
|
|
uid: "fry",
|
|
mappings: [
|
|
{ locale: "fr", expression: "\\bfri(re|t|te|ts|tes|ture)\\b", weight: 15 },
|
|
{ locale: "en", expression: "\\bfr(y|ies|ied|ying)\\b", weight: 15 },
|
|
],
|
|
},
|
|
{
|
|
uid: "melt",
|
|
mappings: [
|
|
{
|
|
locale: "fr",
|
|
expression:
|
|
"\\bfondre\\b|\\bfondu(e|es|s)?\\b|\\bfaire fondre\\b|\\bfaites fondre\\b|\\bfaire chauffer\\b|\\bfaites chauffer\\b",
|
|
weight: 15,
|
|
},
|
|
{ locale: "en", expression: "\\bmelt(s|ed|ing)?\\b", weight: 15 },
|
|
],
|
|
},
|
|
{
|
|
uid: "deglaze",
|
|
mappings: [
|
|
{ locale: "fr", expression: "\\bd[ée]glac(er|ez|é|ée|age)\\b", weight: 20 },
|
|
{ locale: "en", expression: "\\bdeglaz(e|es|ed|ing)\\b", weight: 20 },
|
|
],
|
|
},
|
|
{
|
|
uid: "simmer",
|
|
mappings: [
|
|
{ locale: "fr", expression: "\\bmijot(er|ez|e|ant|é)\\b", weight: 15 },
|
|
{ locale: "en", expression: "\\bsimmer(s|ed|ing)?\\b", weight: 15 },
|
|
],
|
|
},
|
|
{
|
|
uid: "boil",
|
|
mappings: [
|
|
{ locale: "fr", expression: "\\bbouill(ir|ant|ie|ies)\\b|\\b[ée]bullition\\b", weight: 12 },
|
|
{ locale: "en", expression: "\\bboil(s|ed|ing)?\\b", weight: 12 },
|
|
],
|
|
},
|
|
{
|
|
uid: "roast",
|
|
mappings: [
|
|
{ locale: "fr", expression: "\\br[ôo]tir\\b|\\br[ôo]ti(e|es|s)?\\b", weight: 15 },
|
|
{ locale: "en", expression: "\\broast(s|ed|ing)?\\b", weight: 15 },
|
|
],
|
|
},
|
|
{
|
|
uid: "grill",
|
|
mappings: [
|
|
{ locale: "fr", expression: "\\bgrill(er|ez|é|ée|ées|ade)\\b", weight: 15 },
|
|
{ locale: "en", expression: "\\bgrill(s|ed|ing)?\\b", weight: 15 },
|
|
],
|
|
},
|
|
{
|
|
uid: "panFry",
|
|
mappings: [
|
|
{ locale: "fr", expression: "\\bsaut(er|ez|é|ée|ées|ant)\\b", weight: 12 },
|
|
{
|
|
locale: "en",
|
|
expression: "\\bsaut[ée](s|ed|ing)?\\b|\\bpan[- ]?fr(y|ies|ied|ying)\\b",
|
|
weight: 12,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
uid: "blanch",
|
|
mappings: [
|
|
{ locale: "fr", expression: "\\bblanch(ir|issez|i|ie|ies|iment)\\b", weight: 18 },
|
|
{ locale: "en", expression: "\\bblanch(es|ed|ing)?\\b", weight: 18 },
|
|
],
|
|
},
|
|
{
|
|
uid: "marinate",
|
|
mappings: [
|
|
{ locale: "fr", expression: "\\bmarin(er|ez|é|ée|ées|ade)\\b", weight: 18 },
|
|
{ locale: "en", expression: "\\bmarinat(e|es|ed|ing)\\b|\\bmarinad(e|es)\\b", weight: 18 },
|
|
],
|
|
},
|
|
{
|
|
uid: "chop",
|
|
mappings: [
|
|
{ locale: "fr", expression: "\\bhach(er|ez|é|ée|ées|is)\\b", weight: 15 },
|
|
{ locale: "en", expression: "\\bchop(s|ped|ping)?\\b", weight: 15 },
|
|
],
|
|
},
|
|
{
|
|
uid: "peel",
|
|
mappings: [
|
|
{ locale: "fr", expression: "\\b[ée]pluch(er|ez|é|ée|ées|age)\\b", weight: 15 },
|
|
{ locale: "en", expression: "\\bpeel(s|ed|ing)?\\b", weight: 15 },
|
|
],
|
|
},
|
|
{
|
|
uid: "mince",
|
|
mappings: [
|
|
{ locale: "fr", expression: "\\b[ée]minc(er|ez|é|ée|ées)\\b", weight: 18 },
|
|
{ locale: "en", expression: "\\bminc(e|es|ed|ing)\\b", weight: 18 },
|
|
],
|
|
},
|
|
{
|
|
uid: "mix",
|
|
mappings: [
|
|
{ locale: "fr", expression: "\\bm[ée]lang(er|ez|é|ée|ées|e|es)\\b", weight: 10 },
|
|
{ locale: "en", expression: "\\bmix(es|ed|ing)?\\b|\\bcombine(s|d)?\\b", weight: 10 },
|
|
],
|
|
},
|
|
{
|
|
uid: "whisk",
|
|
mappings: [
|
|
{ locale: "fr", expression: "\\bfouett(er|ez|é|ée|ées)\\b|\\bau fouet\\b", weight: 15 },
|
|
{ locale: "en", expression: "\\bwhisk(s|ed|ing)?\\b", weight: 15 },
|
|
],
|
|
},
|
|
{
|
|
uid: "foldIn",
|
|
mappings: [
|
|
{ locale: "fr", expression: "\\bincorpor(er|ez|é|ée|ées|ant)\\b", weight: 15 },
|
|
{ locale: "en", expression: "\\bfold(s|ed|ing)? in\\b", weight: 15 },
|
|
],
|
|
},
|
|
{
|
|
uid: "setAside",
|
|
mappings: [
|
|
{ locale: "fr", expression: "\\br[ée]serv(er|ez|é|ée|ées)\\b", weight: 15 },
|
|
{ locale: "en", expression: "\\bset(s)? aside\\b|\\bsetting aside\\b", weight: 15 },
|
|
],
|
|
},
|
|
{
|
|
uid: "season",
|
|
mappings: [
|
|
{ locale: "fr", expression: "\\bassaisonn(er|ez|é|ée|ées|ement)\\b", weight: 15 },
|
|
{ locale: "en", expression: "\\bseason(s|ed|ing)?\\b", weight: 15 },
|
|
],
|
|
},
|
|
{
|
|
uid: "drain",
|
|
mappings: [
|
|
{ locale: "fr", expression: "\\b[ée]goutt(er|ez|é|ée|ées)\\b", weight: 15 },
|
|
{ locale: "en", expression: "\\bdrain(s|ed|ing)?\\b", weight: 15 },
|
|
],
|
|
},
|
|
{
|
|
uid: "brown",
|
|
mappings: [
|
|
{
|
|
locale: "fr",
|
|
expression:
|
|
"\\bfaire revenir\\b|\\bfaites revenir\\b|\\bfais revenir\\b|\\bfaire dorer\\b|\\bfaites dorer\\b",
|
|
weight: 25,
|
|
},
|
|
// Verb forms only (not bare "brown"), which would false-positive on
|
|
// ingredient descriptions like "brown sugar"/"brown rice".
|
|
{ locale: "en", expression: "\\bbrown(ed|ing)\\b", weight: 25 },
|
|
],
|
|
},
|
|
{
|
|
uid: "rest",
|
|
mappings: [
|
|
{ locale: "fr", expression: "\\blaiss(er|ez|e) reposer\\b|\\breposer\\b", weight: 20 },
|
|
// Anchored to "let ... rest"/"rest for" rather than bare "rest",
|
|
// which would false-positive on phrases like "the rest of the".
|
|
{
|
|
locale: "en",
|
|
expression: "\\blet (it |them )?rest\\b|\\brest(s|ed|ing)? for\\b",
|
|
weight: 20,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
uid: "preheat",
|
|
mappings: [
|
|
{ locale: "fr", expression: "\\bpr[ée]chauff(er|ez|é|ée)\\b", weight: 20 },
|
|
{ locale: "en", expression: "\\bpreheat(s|ed|ing)?\\b", weight: 20 },
|
|
],
|
|
},
|
|
{
|
|
uid: "bake",
|
|
mappings: [
|
|
{
|
|
locale: "fr",
|
|
expression:
|
|
"\\bcuire au four\\b|\\bcuisson au four\\b|\\benfourn(er|ez|é|ée|ées)\\b|\\bau four\\b",
|
|
weight: 25,
|
|
},
|
|
{
|
|
locale: "en",
|
|
expression: "\\bbak(e|es|ed|ing)\\b|\\bin (a|the) (preheated )?oven\\b",
|
|
weight: 25,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
uid: "plate",
|
|
mappings: [
|
|
{ locale: "fr", expression: "\\bdress(er|ez|age)\\b", weight: 15 },
|
|
{ locale: "en", expression: "\\bplat(e|es|ed|ing)\\b", weight: 15 },
|
|
],
|
|
},
|
|
{
|
|
uid: "coat",
|
|
mappings: [
|
|
{ locale: "fr", expression: "\\bnapp(er|ez|é|ée|ées|age)\\b", weight: 15 },
|
|
{ locale: "en", expression: "\\bcoat(s|ed|ing)?\\b", weight: 15 },
|
|
],
|
|
},
|
|
];
|
|
|
|
// The 14 allergens EU Regulation 1169/2011 (Annex II) requires food
|
|
// businesses to declare — a standard, defensible reference list rather than
|
|
// an invented one. Split into ALLERGY (classic IgE-mediated immune
|
|
// reaction) vs INTOLERANCE (non-immune — gluten sensitivity, sulfite
|
|
// sensitivity) per the product decision discussed in chat: only Gluten and
|
|
// Sulfites are commonly-recognized intolerances among the 14; the rest are
|
|
// true allergens.
|
|
export const ALLERGENS: Array<{ uid: string; kind: AllergenKind }> = [
|
|
{ uid: "gluten", kind: "INTOLERANCE" },
|
|
{ uid: "crustaceans", kind: "ALLERGY" },
|
|
{ uid: "eggs", kind: "ALLERGY" },
|
|
{ uid: "fish", kind: "ALLERGY" },
|
|
{ uid: "peanuts", kind: "ALLERGY" },
|
|
{ uid: "soy", kind: "ALLERGY" },
|
|
{ uid: "milk", kind: "ALLERGY" },
|
|
{ uid: "treeNuts", kind: "ALLERGY" },
|
|
{ uid: "celery", kind: "ALLERGY" },
|
|
{ uid: "mustard", kind: "ALLERGY" },
|
|
{ uid: "sesameSeeds", kind: "ALLERGY" },
|
|
{ uid: "sulfites", kind: "INTOLERANCE" },
|
|
{ uid: "lupin", kind: "ALLERGY" },
|
|
{ uid: "molluscs", kind: "ALLERGY" },
|
|
];
|
|
|
|
interface IngredientSeed {
|
|
/** English camelCase identifier, authored directly — also the DB `key` value (no French label, no separate lookup table). */
|
|
uid: string;
|
|
/**
|
|
* Generic pictogram type, overriding its group's `defaultIcon` below —
|
|
* only needed for the exceptions within a subcategory (a wedge of cheese
|
|
* inside an otherwise-milk "produits laitiers" group, a stockpot inside
|
|
* an otherwise-flour "bases" group…). See `IngredientIcon` in
|
|
* schema.prisma and `apps/web`'s `features/recipes/ingredient-icons.tsx`
|
|
* for the actual pictograms — this was a free-text emoji field until the
|
|
* product decision recorded in chat replaced it with this small, shared
|
|
* vocabulary.
|
|
*/
|
|
icon?: IngredientIcon;
|
|
allergenUids: string[];
|
|
/**
|
|
* Diet regimes this ingredient is compatible with, overriding its group's
|
|
* `defaultDiets` below — only needed for the exceptions within a
|
|
* subcategory (a fish-based stock inside an otherwise-vegan "bases"
|
|
* group, a butter-based dough inside an otherwise-vegan "pâtes à
|
|
* cuire"…). References `DIETS` by uid, same as `allergenUids`
|
|
* references `ALLERGENS`. Deliberately never includes `"omnivore"`
|
|
* (trivial, every ingredient qualifies) or `"glutenFree"` (derived from
|
|
* `allergenUids` instead — see `IngredientDiet` in schema.prisma for
|
|
* why).
|
|
*/
|
|
dietUids?: string[];
|
|
/**
|
|
* Whether this ingredient is reasonably makeable at home (a burger bun, a
|
|
* béchamel) rather than something you'd only ever buy (a raw vegetable, a
|
|
* specific cut of meat) — see `Ingredient.reproducible` in schema.prisma.
|
|
* Omitted (falsy) by default; only set `true` on the curated subset this
|
|
* is actually true for. Never group-level (unlike `defaultDiets`/
|
|
* `defaultIcon`) — even a homogeneous-looking group like "Pains" mixes
|
|
* genuinely home-bakeable items (`Pain`, `Naan`) with ones nobody
|
|
* realistically bakes from scratch (`Pain de seigle`, `Biscotte`), so this
|
|
* needs a per-item judgment call, not a group default.
|
|
*/
|
|
reproducible?: boolean;
|
|
}
|
|
|
|
// A broad pantry list — the goal is to cover the large majority of what a
|
|
// home cook reaches for (viandes, poissons, légumes, fruits, féculents,
|
|
// condiments, épices...), not just enough to exercise the recipe catalog in
|
|
// tests. Ingredients are reference data (see `Ingredient` in schema.prisma:
|
|
// `key` is `@unique`, there's no create/update/delete endpoint), so this is
|
|
// meant to already be comprehensive at first deploy rather than grown
|
|
// piecemeal as recipes need more of it. `allergenUids` reference
|
|
// `ALLERGENS` above by uid — every one of the 14 EU-regulated allergens is
|
|
// covered by at least one ingredient here.
|
|
//
|
|
// Grouped by (`category`, `subcategory`) — mirrors `IngredientCategory`/
|
|
// `IngredientSubcategory` in schema.prisma, a supermarket-aisle taxonomy
|
|
// ("rayons") reworked from an earlier, less intuitive scheme that mixed
|
|
// cuisine-of-origin buckets ("cuisine italienne") in with aisle-style ones
|
|
// ("légumes") — an ingredient's category used to depend on which angle you
|
|
// thought of first. The picker UI (`apps/web`'s `IngredientPicker`) uses
|
|
// this to offer category browsing, not just free-text search — with 400+
|
|
// ingredients, search alone doesn't scale to actually *finding* something,
|
|
// and a single flat list of 7 aisles alone wouldn't either (some aisles
|
|
// would be 100+ items deep). Each group's key is the single source of
|
|
// truth for that mapping; `INGREDIENTS` below just flattens it back to one
|
|
// array for the seeding loop.
|
|
//
|
|
// `defaultDiets`/`defaultIcon` are what every item in the group shares
|
|
// unless it sets its own `dietUids`/`icon` — most groups are homogeneous
|
|
// on both counts (a vegetable is always vegan and always looks like a
|
|
// vegetable; a cut of meat never is and never does), so this avoids
|
|
// repeating the same values on hundreds of items; only a group's
|
|
// exceptions (a fish-based stock inside "bases", a cheese inside "produits
|
|
// laitiers"…) need a per-item override.
|
|
export const INGREDIENT_GROUPS: Array<{
|
|
category: IngredientCategory;
|
|
subcategory: IngredientSubcategory;
|
|
defaultDiets: string[];
|
|
defaultIcon: IngredientIcon;
|
|
items: IngredientSeed[];
|
|
}> = [
|
|
// =========================================================================
|
|
// Produits frais
|
|
// =========================================================================
|
|
{
|
|
category: "freshProduce",
|
|
subcategory: "vegetables",
|
|
defaultDiets: ["vegetarian", "vegan", "pescatarian"],
|
|
defaultIcon: "VEGETABLE",
|
|
items: [
|
|
{ uid: "tomato", allergenUids: [] },
|
|
{ uid: "onion", allergenUids: [] },
|
|
{ uid: "shallot", allergenUids: [] },
|
|
{ uid: "garlic", allergenUids: [] },
|
|
{ uid: "carrot", allergenUids: [] },
|
|
{ uid: "zucchini", allergenUids: [] },
|
|
{ uid: "cucumber", allergenUids: [] },
|
|
{ uid: "gherkins", allergenUids: [] },
|
|
{ uid: "bellPepper", allergenUids: [] },
|
|
{ uid: "mushroom", allergenUids: [] },
|
|
{ uid: "porcini", allergenUids: [] },
|
|
{ uid: "eggplant", allergenUids: [] },
|
|
{ uid: "broccoli", allergenUids: [] },
|
|
{ uid: "cauliflower", allergenUids: [] },
|
|
{ uid: "whiteCabbage", allergenUids: [] },
|
|
{ uid: "redCabbage", allergenUids: [] },
|
|
{ uid: "brusselsSprouts", allergenUids: [] },
|
|
{ uid: "spinach", allergenUids: [] },
|
|
{ uid: "swissChard", allergenUids: [] },
|
|
{ uid: "lettuce", allergenUids: [] },
|
|
{ uid: "arugula", allergenUids: [] },
|
|
{ uid: "watercress", allergenUids: [] },
|
|
{ uid: "leek", allergenUids: [] },
|
|
{ uid: "celery", allergenUids: ["celery"] },
|
|
{ uid: "radish", allergenUids: [] },
|
|
{ uid: "beetroot", allergenUids: [] },
|
|
{ uid: "turnip", allergenUids: [] },
|
|
{ uid: "parsnip", allergenUids: [] },
|
|
{ uid: "greenBean", allergenUids: [] },
|
|
{ uid: "pea", allergenUids: [] },
|
|
{ uid: "corn", allergenUids: [] },
|
|
{ uid: "artichoke", allergenUids: [] },
|
|
{ uid: "fennel", allergenUids: [] },
|
|
{ uid: "endive", allergenUids: [] },
|
|
{ uid: "pumpkin", allergenUids: [] },
|
|
{ uid: "butternutSquash", allergenUids: [] },
|
|
{ uid: "asparagus", allergenUids: [] },
|
|
{ uid: "avocado", allergenUids: [] },
|
|
{ uid: "potato", allergenUids: [] },
|
|
{ uid: "sweetPotato", allergenUids: [] },
|
|
{ uid: "cherryTomato", allergenUids: [] },
|
|
{ uid: "bokChoy", allergenUids: [] },
|
|
{ uid: "soybeanSprouts", allergenUids: ["soy"] },
|
|
{ uid: "shiitake", allergenUids: [] },
|
|
{ uid: "daikon", allergenUids: [] },
|
|
{ uid: "freshGreenChili", allergenUids: [] },
|
|
{ uid: "cardoon", allergenUids: [] },
|
|
// Second Ciqual pass — see the PR description.
|
|
{ uid: "radicchio", allergenUids: [] },
|
|
{ uid: "romanesco", allergenUids: [] },
|
|
{ uid: "kohlrabi", allergenUids: [] },
|
|
{ uid: "napaCabbage", allergenUids: [] },
|
|
{ uid: "celeriac", allergenUids: ["celery"] },
|
|
{ uid: "okra", allergenUids: [] },
|
|
{ uid: "springOnion", allergenUids: [] },
|
|
{ uid: "redKuriSquash", allergenUids: [] },
|
|
{ uid: "rutabaga", allergenUids: [] },
|
|
{ uid: "samphire", allergenUids: [] },
|
|
{ uid: "salsify", allergenUids: [] },
|
|
{ uid: "lambsLettuce", allergenUids: [] },
|
|
{ uid: "escarole", allergenUids: [] },
|
|
],
|
|
},
|
|
{
|
|
category: "freshProduce",
|
|
subcategory: "fruits",
|
|
defaultDiets: ["vegetarian", "vegan", "pescatarian"],
|
|
defaultIcon: "FRUIT",
|
|
items: [
|
|
{ uid: "lemon", allergenUids: [] },
|
|
{ uid: "lime", allergenUids: [] },
|
|
{ uid: "apple", allergenUids: [] },
|
|
{ uid: "pear", allergenUids: [] },
|
|
{ uid: "banana", allergenUids: [] },
|
|
{ uid: "orange", allergenUids: [] },
|
|
{ uid: "clementine", allergenUids: [] },
|
|
{ uid: "grapefruit", allergenUids: [] },
|
|
{ uid: "strawberry", allergenUids: [] },
|
|
{ uid: "raspberry", allergenUids: [] },
|
|
{ uid: "blueberry", allergenUids: [] },
|
|
{ uid: "blackberry", allergenUids: [] },
|
|
{ uid: "cherry", allergenUids: [] },
|
|
{ uid: "apricot", allergenUids: [] },
|
|
{ uid: "peach", allergenUids: [] },
|
|
{ uid: "plum", allergenUids: [] },
|
|
{ uid: "grape", allergenUids: [] },
|
|
{ uid: "melon", allergenUids: [] },
|
|
{ uid: "watermelon", allergenUids: [] },
|
|
{ uid: "pineapple", allergenUids: [] },
|
|
{ uid: "mango", allergenUids: [] },
|
|
{ uid: "kiwi", allergenUids: [] },
|
|
{ uid: "fig", allergenUids: [] },
|
|
{ uid: "date", allergenUids: [] },
|
|
{ uid: "lychee", allergenUids: [] },
|
|
{ uid: "pomegranate", allergenUids: [] },
|
|
{ uid: "rhubarb", allergenUids: [] },
|
|
{ uid: "quince", allergenUids: [] },
|
|
{ uid: "blackcurrant", allergenUids: [] },
|
|
{ uid: "cranberry", allergenUids: [] },
|
|
// Second Ciqual pass.
|
|
{ uid: "redcurrant", allergenUids: [] },
|
|
{ uid: "persimmon", allergenUids: [] },
|
|
{ uid: "nectarine", allergenUids: [] },
|
|
{ uid: "tamarind", allergenUids: [] },
|
|
],
|
|
},
|
|
{
|
|
category: "freshProduce",
|
|
subcategory: "freshHerbs",
|
|
defaultDiets: ["vegetarian", "vegan", "pescatarian"],
|
|
defaultIcon: "HERB",
|
|
items: [
|
|
{ uid: "basil", allergenUids: [] },
|
|
{ uid: "parsley", allergenUids: [] },
|
|
{ uid: "thyme", allergenUids: [] },
|
|
{ uid: "rosemary", allergenUids: [] },
|
|
{ uid: "bayLeaf", allergenUids: [] },
|
|
{ uid: "chives", allergenUids: [] },
|
|
{ uid: "freshCilantro", allergenUids: [] },
|
|
{ uid: "mint", allergenUids: [] },
|
|
{ uid: "oregano", allergenUids: [] },
|
|
{ uid: "dill", allergenUids: [] },
|
|
{ uid: "tarragon", allergenUids: [] },
|
|
{ uid: "savory", allergenUids: [] },
|
|
{ uid: "marjoram", allergenUids: [] },
|
|
{ uid: "sage", allergenUids: [] },
|
|
{ uid: "chervil", allergenUids: [] },
|
|
{ uid: "ginger", allergenUids: [] },
|
|
{ uid: "lemongrass", allergenUids: [] },
|
|
{ uid: "kaffirLime", allergenUids: [] },
|
|
],
|
|
},
|
|
// =========================================================================
|
|
// Boucherie & poissonnerie
|
|
// =========================================================================
|
|
{
|
|
category: "meatAndSeafood",
|
|
subcategory: "meats",
|
|
defaultDiets: [],
|
|
defaultIcon: "MEAT",
|
|
items: [
|
|
{ uid: "rabbit", allergenUids: [] },
|
|
{ uid: "groundBeef", allergenUids: [] },
|
|
{ uid: "beefSteak", allergenUids: [] },
|
|
{ uid: "beefRoast", allergenUids: [] },
|
|
{ uid: "vealCutlet", allergenUids: [] },
|
|
{ uid: "porkTenderloin", allergenUids: [] },
|
|
{ uid: "porkChop", allergenUids: [] },
|
|
{ uid: "lamb", allergenUids: [] },
|
|
{ uid: "legOfLamb", allergenUids: [] },
|
|
{ uid: "baconLardons", allergenUids: [] },
|
|
{ uid: "bacon", allergenUids: [] },
|
|
{ uid: "ham", allergenUids: [] },
|
|
{ uid: "curedHam", allergenUids: [] },
|
|
{ uid: "sausage", allergenUids: [] },
|
|
{ uid: "chorizo", allergenUids: [] },
|
|
{ uid: "merguez", allergenUids: [] },
|
|
{ uid: "prosciutto", allergenUids: [] },
|
|
{ uid: "pancetta", allergenUids: [] },
|
|
{ uid: "mortadella", allergenUids: [] },
|
|
{ uid: "salami", allergenUids: [] },
|
|
// Added from the Ciqual 2025 "aliments moyens" table — French
|
|
// charcuterie/offal/game staples the pantry list was missing (see
|
|
// the PR description for how this batch was sourced/curated).
|
|
{ uid: "andouille", allergenUids: [] },
|
|
{ uid: "andouillette", allergenUids: [] },
|
|
{ uid: "whitePudding", allergenUids: [] },
|
|
{ uid: "blackPudding", allergenUids: [] },
|
|
{ uid: "cervelat", allergenUids: [] },
|
|
{ uid: "rillettes", allergenUids: [] },
|
|
{ uid: "dryCuredSausage", allergenUids: [] },
|
|
{ uid: "bayonneHam", allergenUids: [] },
|
|
{ uid: "coppa", allergenUids: [] },
|
|
{ uid: "rosetteSausage", allergenUids: [] },
|
|
{ uid: "vealLiver", allergenUids: [] },
|
|
{ uid: "vealKidneys", allergenUids: [] },
|
|
{ uid: "vealBrain", allergenUids: [] },
|
|
{ uid: "vealSweetbread", allergenUids: [] },
|
|
{ uid: "beefTongue", allergenUids: [] },
|
|
{ uid: "tripe", allergenUids: [] },
|
|
{ uid: "venison", allergenUids: [] },
|
|
{ uid: "roeDeer", allergenUids: [] },
|
|
{ uid: "wildBoar", allergenUids: [] },
|
|
// Second Ciqual pass.
|
|
{ uid: "horseMeat", allergenUids: [] },
|
|
{ uid: "beefHeart", allergenUids: [] },
|
|
{ uid: "foieGras", allergenUids: [] },
|
|
{ uid: "beefMuzzle", allergenUids: [] },
|
|
{ uid: "grisonsDriedBeef", allergenUids: [] },
|
|
],
|
|
},
|
|
{
|
|
category: "meatAndSeafood",
|
|
subcategory: "poultry",
|
|
defaultDiets: [],
|
|
defaultIcon: "POULTRY",
|
|
items: [
|
|
{ uid: "chicken", allergenUids: [] },
|
|
{ uid: "turkey", allergenUids: [] },
|
|
{ uid: "duck", allergenUids: [] },
|
|
{ uid: "duckBreast", allergenUids: [] },
|
|
// Ciqual 2025 additions — see the VIANDES group above.
|
|
{ uid: "quail", allergenUids: [] },
|
|
{ uid: "guineaFowl", allergenUids: [] },
|
|
{ uid: "goose", allergenUids: [] },
|
|
{ uid: "poultryLiver", allergenUids: [] },
|
|
// Second Ciqual pass.
|
|
{ uid: "capon", allergenUids: [] },
|
|
{ uid: "pigeon", allergenUids: [] },
|
|
{ uid: "pheasant", allergenUids: [] },
|
|
],
|
|
},
|
|
{
|
|
category: "meatAndSeafood",
|
|
subcategory: "fish",
|
|
defaultDiets: ["pescatarian"],
|
|
defaultIcon: "FISH",
|
|
items: [
|
|
{ uid: "salmon", allergenUids: ["fish"] },
|
|
{ uid: "tuna", allergenUids: ["fish"] },
|
|
{ uid: "cod", allergenUids: ["fish"] },
|
|
{ uid: "trout", allergenUids: ["fish"] },
|
|
{ uid: "sardine", allergenUids: ["fish"] },
|
|
{ uid: "anchovy", allergenUids: ["fish"] },
|
|
{ uid: "whiting", allergenUids: ["fish"] },
|
|
{ uid: "surimi", allergenUids: ["fish"] },
|
|
{ uid: "seaBass", allergenUids: ["fish"] },
|
|
{ uid: "seaBream", allergenUids: ["fish"] },
|
|
{ uid: "sole", allergenUids: ["fish"] },
|
|
{ uid: "turbot", allergenUids: ["fish"] },
|
|
{ uid: "hake", allergenUids: ["fish"] },
|
|
{ uid: "pollock", allergenUids: ["fish"] },
|
|
{ uid: "saithe", allergenUids: ["fish"] },
|
|
{ uid: "haddock", allergenUids: ["fish"] },
|
|
{ uid: "mackerel", allergenUids: ["fish"] },
|
|
{ uid: "herring", allergenUids: ["fish"] },
|
|
{ uid: "redMullet", allergenUids: ["fish"] },
|
|
{ uid: "skate", allergenUids: ["fish"] },
|
|
{ uid: "monkfish", allergenUids: ["fish"] },
|
|
{ uid: "halibut", allergenUids: ["fish"] },
|
|
{ uid: "swordfish", allergenUids: ["fish"] },
|
|
{ uid: "carp", allergenUids: ["fish"] },
|
|
{ uid: "pike", allergenUids: ["fish"] },
|
|
{ uid: "perch", allergenUids: ["fish"] },
|
|
{ uid: "tilapia", allergenUids: ["fish"] },
|
|
{ uid: "pangasius", allergenUids: ["fish"] },
|
|
{ uid: "smokedSalmon", allergenUids: ["fish"] },
|
|
{ uid: "driedFish", allergenUids: ["fish"] },
|
|
// Ciqual 2025 additions.
|
|
{ uid: "eel", allergenUids: ["fish"] },
|
|
{ uid: "plaice", allergenUids: ["fish"] },
|
|
// Second Ciqual pass.
|
|
{ uid: "saltCod", allergenUids: ["fish"] },
|
|
{ uid: "lemonSole", allergenUids: ["fish"] },
|
|
{ uid: "scorpionfish", allergenUids: ["fish"] },
|
|
],
|
|
},
|
|
{
|
|
category: "meatAndSeafood",
|
|
subcategory: "shellfish",
|
|
defaultDiets: ["pescatarian"],
|
|
defaultIcon: "SHELLFISH",
|
|
items: [
|
|
{ uid: "shrimp", allergenUids: ["crustaceans"] },
|
|
{ uid: "langoustine", allergenUids: ["crustaceans"] },
|
|
{ uid: "lobster", allergenUids: ["crustaceans"] },
|
|
{ uid: "crab", allergenUids: ["crustaceans"] },
|
|
{ uid: "spinyLobster", allergenUids: ["crustaceans"] },
|
|
{ uid: "mussels", allergenUids: ["molluscs"] },
|
|
{ uid: "oysters", allergenUids: ["molluscs"] },
|
|
{ uid: "scallops", allergenUids: ["molluscs"] },
|
|
{ uid: "squid", allergenUids: ["molluscs"] },
|
|
{ uid: "octopus", allergenUids: ["molluscs"] },
|
|
{ uid: "clams", allergenUids: ["molluscs"] },
|
|
{ uid: "whelks", allergenUids: ["molluscs"] },
|
|
// Ciqual 2025 additions.
|
|
{ uid: "spiderCrab", allergenUids: ["crustaceans"] },
|
|
{ uid: "periwinkle", allergenUids: ["molluscs"] },
|
|
// Second Ciqual pass.
|
|
{ uid: "crayfish", allergenUids: ["crustaceans"] },
|
|
{ uid: "greyShrimp", allergenUids: ["crustaceans"] },
|
|
{ uid: "cockle", allergenUids: ["molluscs"] },
|
|
{ uid: "snail", allergenUids: ["molluscs"] },
|
|
{ uid: "cuttlefish", allergenUids: ["molluscs"] },
|
|
],
|
|
},
|
|
// =========================================================================
|
|
// Épicerie sèche
|
|
// =========================================================================
|
|
{
|
|
category: "dryGoods",
|
|
subcategory: "starches",
|
|
defaultDiets: ["vegetarian", "vegan", "pescatarian"],
|
|
defaultIcon: "GRAIN",
|
|
items: [
|
|
{ uid: "semolina", allergenUids: ["gluten"] },
|
|
{ uid: "couscous", allergenUids: ["gluten"] },
|
|
{ uid: "bulgur", allergenUids: ["gluten"] },
|
|
{ uid: "polenta", allergenUids: [] },
|
|
{ uid: "quinoa", allergenUids: [] },
|
|
{ uid: "pasta", allergenUids: ["gluten"] },
|
|
{ uid: "wholeWheatPasta", allergenUids: ["gluten"] },
|
|
{ uid: "rice", allergenUids: [] },
|
|
{ uid: "basmatiRice", allergenUids: [] },
|
|
{ uid: "brownRice", allergenUids: [] },
|
|
{ uid: "oats", allergenUids: ["gluten"] },
|
|
{ uid: "spaghetti", allergenUids: ["gluten"] },
|
|
{ uid: "penne", allergenUids: ["gluten"] },
|
|
{ uid: "tagliatelle", allergenUids: ["gluten"] },
|
|
{ uid: "lasagnaSheets", allergenUids: ["gluten"] },
|
|
{ uid: "gnocchi", allergenUids: ["gluten"] },
|
|
{ uid: "arborioRice", allergenUids: [] },
|
|
{ uid: "riceNoodles", allergenUids: [] },
|
|
{ uid: "udonNoodles", allergenUids: ["gluten"] },
|
|
{ uid: "sobaNoodles", allergenUids: ["gluten"] },
|
|
{ uid: "chineseNoodles", allergenUids: ["gluten"] },
|
|
{ uid: "riceVermicelli", allergenUids: [] },
|
|
{ uid: "soyVermicelli", allergenUids: [] },
|
|
{ uid: "stickyRice", allergenUids: [] },
|
|
{ uid: "sushiRice", allergenUids: [] },
|
|
{ uid: "jasmineRice", allergenUids: [] },
|
|
],
|
|
},
|
|
{
|
|
category: "dryGoods",
|
|
subcategory: "legumes",
|
|
defaultDiets: ["vegetarian", "vegan", "pescatarian"],
|
|
defaultIcon: "LEGUME",
|
|
items: [
|
|
{ uid: "greenLentils", allergenUids: [] },
|
|
{ uid: "redLentils", allergenUids: [] },
|
|
{ uid: "chickpeas", allergenUids: [] },
|
|
{ uid: "whiteBeans", allergenUids: [] },
|
|
{ uid: "kidneyBeans", allergenUids: [] },
|
|
{ uid: "blackBeans", allergenUids: [] },
|
|
{ uid: "splitPeas", allergenUids: [] },
|
|
{ uid: "favaBeans", allergenUids: [] },
|
|
{ uid: "edamame", allergenUids: ["soy"] },
|
|
{ uid: "pintoBeans", allergenUids: [] },
|
|
// Second Ciqual pass.
|
|
{ uid: "flageoletBeans", allergenUids: [] },
|
|
{ uid: "goldenLentils", allergenUids: [] },
|
|
],
|
|
},
|
|
{
|
|
category: "dryGoods",
|
|
subcategory: "nutsAndSeeds",
|
|
defaultDiets: ["vegetarian", "vegan", "pescatarian"],
|
|
defaultIcon: "NUT_SEED",
|
|
items: [
|
|
{ uid: "peanutsShelled", allergenUids: ["peanuts"] },
|
|
{ uid: "almonds", allergenUids: ["treeNuts"] },
|
|
{ uid: "walnuts", allergenUids: ["treeNuts"] },
|
|
{ uid: "hazelnuts", allergenUids: ["treeNuts"] },
|
|
{ uid: "cashews", allergenUids: ["treeNuts"] },
|
|
{ uid: "pistachios", allergenUids: ["treeNuts"] },
|
|
{ uid: "pecans", allergenUids: ["treeNuts"] },
|
|
{ uid: "almondPowder", allergenUids: ["treeNuts"] },
|
|
{ uid: "pineNuts", allergenUids: [] },
|
|
{ uid: "sunflowerSeeds", allergenUids: [] },
|
|
{ uid: "pumpkinSeeds", allergenUids: [] },
|
|
{ uid: "shreddedCoconut", allergenUids: [] },
|
|
{ uid: "raisins", allergenUids: ["sulfites"] },
|
|
{ uid: "prunes", allergenUids: ["sulfites"] },
|
|
{ uid: "driedApricots", allergenUids: ["sulfites"] },
|
|
{ uid: "sesameSeeds", allergenUids: ["sesameSeeds"] },
|
|
],
|
|
},
|
|
{
|
|
category: "dryGoods",
|
|
subcategory: "other",
|
|
defaultDiets: ["vegetarian", "vegan", "pescatarian"],
|
|
defaultIcon: "JAR",
|
|
items: [
|
|
{ uid: "blackMushrooms", allergenUids: [] },
|
|
{ uid: "noriSeaweed", allergenUids: [] },
|
|
{ uid: "wakameSeaweed", allergenUids: [] },
|
|
{ uid: "kombuSeaweed", allergenUids: [] },
|
|
{ uid: "bambooShoots", allergenUids: [] },
|
|
{ uid: "waterChestnuts", allergenUids: [] },
|
|
],
|
|
},
|
|
// =========================================================================
|
|
// Boulangerie
|
|
// =========================================================================
|
|
{
|
|
category: "bakery",
|
|
subcategory: "breads",
|
|
defaultDiets: ["vegetarian", "vegan", "pescatarian"],
|
|
defaultIcon: "BREAD",
|
|
items: [
|
|
{ uid: "bread", reproducible: true, allergenUids: ["gluten"] },
|
|
{ uid: "sandwichBread", reproducible: true, allergenUids: ["gluten"] },
|
|
{ uid: "wholeWheatBread", allergenUids: ["gluten"] },
|
|
{ uid: "baguette", allergenUids: ["gluten"] },
|
|
{ uid: "ryeBread", allergenUids: ["gluten"] },
|
|
{ uid: "breadcrumbs", reproducible: true, allergenUids: ["gluten"] },
|
|
{
|
|
uid: "burgerBun",
|
|
reproducible: true,
|
|
allergenUids: ["gluten", "milk", "eggs"],
|
|
dietUids: ["vegetarian", "pescatarian"],
|
|
},
|
|
{
|
|
uid: "briocheBun",
|
|
allergenUids: ["gluten", "milk", "eggs"],
|
|
dietUids: ["vegetarian", "pescatarian"],
|
|
},
|
|
{ uid: "hotDogBun", reproducible: true, allergenUids: ["gluten"] },
|
|
{ uid: "pitaBread", reproducible: true, allergenUids: ["gluten"] },
|
|
{ uid: "bagel", reproducible: true, allergenUids: ["gluten"] },
|
|
{ uid: "naan", reproducible: true, allergenUids: ["gluten"] },
|
|
{ uid: "wrapBread", allergenUids: ["gluten"] },
|
|
{
|
|
uid: "vienneseBread",
|
|
allergenUids: ["gluten", "milk"],
|
|
dietUids: ["vegetarian", "pescatarian"],
|
|
},
|
|
{ uid: "countryBread", allergenUids: ["gluten"] },
|
|
{ uid: "multigrainBread", allergenUids: ["gluten"] },
|
|
{ uid: "breadRoll", allergenUids: ["gluten"] },
|
|
{ uid: "swedishBread", allergenUids: ["gluten"] },
|
|
{ uid: "glutenFreeBread", allergenUids: [] },
|
|
{ uid: "rusk", allergenUids: ["gluten"] },
|
|
{ uid: "croutons", reproducible: true, allergenUids: ["gluten"] },
|
|
{ uid: "focaccia", reproducible: true, allergenUids: ["gluten"] },
|
|
{ uid: "ciabatta", reproducible: true, allergenUids: ["gluten"] },
|
|
{ uid: "cornTortilla", allergenUids: [] },
|
|
{ uid: "wheatTortilla", allergenUids: ["gluten"] },
|
|
// Second Ciqual pass.
|
|
{ uid: "breadstick", reproducible: true, allergenUids: ["gluten"] },
|
|
],
|
|
},
|
|
{
|
|
category: "bakery",
|
|
subcategory: "rawDough",
|
|
defaultDiets: ["vegetarian", "vegan", "pescatarian"],
|
|
defaultIcon: "DOUGH",
|
|
items: [
|
|
{
|
|
uid: "puffPastry",
|
|
reproducible: true,
|
|
allergenUids: ["gluten", "milk"],
|
|
dietUids: ["vegetarian", "pescatarian"],
|
|
},
|
|
{
|
|
uid: "shortcrustPastry",
|
|
reproducible: true,
|
|
allergenUids: ["gluten", "milk"],
|
|
dietUids: ["vegetarian", "pescatarian"],
|
|
},
|
|
{ uid: "pizzaDough", reproducible: true, allergenUids: ["gluten"] },
|
|
{
|
|
uid: "sweetShortcrustPastry",
|
|
reproducible: true,
|
|
allergenUids: ["gluten", "milk"],
|
|
dietUids: ["vegetarian", "pescatarian"],
|
|
},
|
|
],
|
|
},
|
|
// =========================================================================
|
|
// Crémerie & fromage
|
|
// =========================================================================
|
|
{
|
|
category: "dairyAndCheese",
|
|
subcategory: "dairy",
|
|
defaultDiets: ["vegetarian", "pescatarian"],
|
|
defaultIcon: "MILK",
|
|
items: [
|
|
{ uid: "milk", allergenUids: ["milk"] },
|
|
{ uid: "butter", allergenUids: ["milk"] },
|
|
{ uid: "cremeFraiche", allergenUids: ["milk"] },
|
|
{ uid: "liquidCream", allergenUids: ["milk"] },
|
|
{ uid: "cheese", icon: "CHEESE", allergenUids: ["milk"] },
|
|
{ uid: "emmental", icon: "CHEESE", allergenUids: ["milk"] },
|
|
{ uid: "gruyere", icon: "CHEESE", allergenUids: ["milk"] },
|
|
{ uid: "parmesan", icon: "CHEESE", allergenUids: ["milk"] },
|
|
{ uid: "mozzarella", icon: "CHEESE", allergenUids: ["milk"] },
|
|
{ uid: "goatCheese", icon: "CHEESE", allergenUids: ["milk"] },
|
|
{ uid: "feta", icon: "CHEESE", allergenUids: ["milk"] },
|
|
{ uid: "comte", icon: "CHEESE", allergenUids: ["milk"] },
|
|
{ uid: "fromageBlanc", allergenUids: ["milk"] },
|
|
{ uid: "mascarpone", icon: "CHEESE", allergenUids: ["milk"] },
|
|
{ uid: "yogurt", allergenUids: ["milk"] },
|
|
{ uid: "burrata", icon: "CHEESE", allergenUids: ["milk"] },
|
|
{ uid: "ricotta", icon: "CHEESE", allergenUids: ["milk"] },
|
|
{ uid: "pecorino", icon: "CHEESE", allergenUids: ["milk"] },
|
|
{ uid: "gorgonzola", icon: "CHEESE", allergenUids: ["milk"] },
|
|
{ uid: "cheddar", icon: "CHEESE", allergenUids: ["milk"] },
|
|
// Ciqual 2025 additions — classic French regional cheeses the
|
|
// catalog only had a handful of internationally-known ones for
|
|
// (Emmental, Parmesan, Mozzarella…), missing the actual French
|
|
// aisle staples Ciqual's "aliments moyens" table surfaced.
|
|
{ uid: "brie", icon: "CHEESE", allergenUids: ["milk"] },
|
|
{ uid: "camembert", icon: "CHEESE", allergenUids: ["milk"] },
|
|
{ uid: "roquefort", icon: "CHEESE", allergenUids: ["milk"] },
|
|
{ uid: "munster", icon: "CHEESE", allergenUids: ["milk"] },
|
|
{ uid: "reblochon", icon: "CHEESE", allergenUids: ["milk"] },
|
|
{ uid: "cantal", icon: "CHEESE", allergenUids: ["milk"] },
|
|
{ uid: "beaufort", icon: "CHEESE", allergenUids: ["milk"] },
|
|
{ uid: "saintNectaire", icon: "CHEESE", allergenUids: ["milk"] },
|
|
{ uid: "blueCheese", icon: "CHEESE", allergenUids: ["milk"] },
|
|
{ uid: "cancoillotte", icon: "CHEESE", allergenUids: ["milk"] },
|
|
{ uid: "tomme", icon: "CHEESE", allergenUids: ["milk"] },
|
|
{ uid: "epoisses", icon: "CHEESE", allergenUids: ["milk"] },
|
|
{ uid: "chaource", icon: "CHEESE", allergenUids: ["milk"] },
|
|
{ uid: "livarot", icon: "CHEESE", allergenUids: ["milk"] },
|
|
{ uid: "pontLeveque", icon: "CHEESE", allergenUids: ["milk"] },
|
|
{ uid: "morbier", icon: "CHEESE", allergenUids: ["milk"] },
|
|
{ uid: "racletteCheese", icon: "CHEESE", allergenUids: ["milk"] },
|
|
{ uid: "fourmeDAmbert", icon: "CHEESE", allergenUids: ["milk"] },
|
|
{ uid: "salers", icon: "CHEESE", allergenUids: ["milk"] },
|
|
{ uid: "ossauIraty", icon: "CHEESE", allergenUids: ["milk"] },
|
|
{ uid: "vacherin", icon: "CHEESE", allergenUids: ["milk"] },
|
|
{ uid: "saintMarcellin", icon: "CHEESE", allergenUids: ["milk"] },
|
|
{ uid: "neufchatel", icon: "CHEESE", allergenUids: ["milk"] },
|
|
{ uid: "crottinDeChavignol", icon: "CHEESE", allergenUids: ["milk"] },
|
|
{ uid: "abondanceCheese", icon: "CHEESE", allergenUids: ["milk"] },
|
|
{ uid: "carreDeLEst", icon: "CHEESE", allergenUids: ["milk"] },
|
|
// Second Ciqual pass.
|
|
{ uid: "edam", icon: "CHEESE", allergenUids: ["milk"] },
|
|
{ uid: "gouda", icon: "CHEESE", allergenUids: ["milk"] },
|
|
{ uid: "mimolette", icon: "CHEESE", allergenUids: ["milk"] },
|
|
{ uid: "maroilles", icon: "CHEESE", allergenUids: ["milk"] },
|
|
{ uid: "montDor", icon: "CHEESE", allergenUids: ["milk"] },
|
|
{ uid: "kefir", allergenUids: ["milk"] },
|
|
{ uid: "greekYogurt", allergenUids: ["milk"] },
|
|
],
|
|
},
|
|
{
|
|
category: "dairyAndCheese",
|
|
subcategory: "eggs",
|
|
defaultDiets: ["vegetarian", "pescatarian"],
|
|
defaultIcon: "EGG",
|
|
items: [{ uid: "egg", allergenUids: ["eggs"] }],
|
|
},
|
|
{
|
|
category: "dairyAndCheese",
|
|
subcategory: "plantBasedAlternatives",
|
|
defaultDiets: ["vegetarian", "vegan", "pescatarian"],
|
|
defaultIcon: "SPROUT",
|
|
items: [
|
|
{ uid: "coconutMilk", icon: "MILK", allergenUids: [] },
|
|
{ uid: "coconutCream", icon: "MILK", allergenUids: [] },
|
|
{ uid: "almondMilk", icon: "MILK", allergenUids: ["treeNuts"] },
|
|
{ uid: "oatMilk", icon: "MILK", allergenUids: ["gluten"] },
|
|
{ uid: "tofu", allergenUids: ["soy"] },
|
|
{ uid: "silkenTofu", allergenUids: ["soy"] },
|
|
],
|
|
},
|
|
// =========================================================================
|
|
// Condiments & épices
|
|
// =========================================================================
|
|
{
|
|
category: "condimentsAndSpices",
|
|
subcategory: "spices",
|
|
defaultDiets: ["vegetarian", "vegan", "pescatarian"],
|
|
defaultIcon: "SPICE",
|
|
items: [
|
|
{ uid: "herbesDeProvence", allergenUids: [] },
|
|
{ uid: "blackPepper", allergenUids: [] },
|
|
{ uid: "paprika", allergenUids: [] },
|
|
{ uid: "espelettePepper", allergenUids: [] },
|
|
{ uid: "cayennePepper", allergenUids: [] },
|
|
{ uid: "cumin", allergenUids: [] },
|
|
{ uid: "curryPowder", allergenUids: [] },
|
|
{ uid: "turmeric", allergenUids: [] },
|
|
{ uid: "cinnamon", allergenUids: [] },
|
|
{ uid: "nutmeg", allergenUids: [] },
|
|
{ uid: "saffron", allergenUids: [] },
|
|
{ uid: "clove", allergenUids: [] },
|
|
{ uid: "vanillaBean", allergenUids: [] },
|
|
{ uid: "whitePepper", allergenUids: [] },
|
|
{ uid: "pinkPepper", allergenUids: [] },
|
|
{ uid: "sichuanPepper", allergenUids: [] },
|
|
{ uid: "smokedPaprika", allergenUids: [] },
|
|
{ uid: "birdEyeChili", allergenUids: [] },
|
|
{ uid: "juniperBerries", allergenUids: [] },
|
|
{ uid: "starAnise", allergenUids: [] },
|
|
{ uid: "greenAnise", allergenUids: [] },
|
|
{ uid: "fennelSeeds", allergenUids: [] },
|
|
{ uid: "sumac", allergenUids: [] },
|
|
{ uid: "nigella", allergenUids: [] },
|
|
{ uid: "allspice", allergenUids: [] },
|
|
{ uid: "colomboPowder", allergenUids: [] },
|
|
{ uid: "baharat", allergenUids: [] },
|
|
{ uid: "horseradish", allergenUids: [] },
|
|
{ uid: "herbSalt", allergenUids: [] },
|
|
{ uid: "celerySalt", allergenUids: ["celery"] },
|
|
{ uid: "fleurDeSel", allergenUids: [] },
|
|
{ uid: "salt", allergenUids: [] },
|
|
{ uid: "fiveSpice", allergenUids: [] },
|
|
{ uid: "garamMasala", allergenUids: [] },
|
|
{ uid: "corianderSeeds", allergenUids: [] },
|
|
{ uid: "cardamom", allergenUids: [] },
|
|
{ uid: "fenugreek", allergenUids: [] },
|
|
{ uid: "jalapeno", allergenUids: [] },
|
|
{ uid: "chipotle", allergenUids: [] },
|
|
{ uid: "poblanoPepper", allergenUids: [] },
|
|
{ uid: "habanero", allergenUids: [] },
|
|
{ uid: "rasElHanout", allergenUids: [] },
|
|
{ uid: "zaatar", allergenUids: ["sesameSeeds"] },
|
|
],
|
|
},
|
|
{
|
|
category: "condimentsAndSpices",
|
|
subcategory: "sauces",
|
|
defaultDiets: ["vegetarian", "vegan", "pescatarian"],
|
|
defaultIcon: "JAR",
|
|
items: [
|
|
{ uid: "soySauce", allergenUids: ["soy"] },
|
|
{ uid: "mustard", allergenUids: ["mustard"] },
|
|
{
|
|
uid: "mayonnaise",
|
|
reproducible: true,
|
|
allergenUids: ["eggs"],
|
|
dietUids: ["vegetarian", "pescatarian"],
|
|
},
|
|
{ uid: "ketchup", reproducible: true, allergenUids: [] },
|
|
{ uid: "tabasco", allergenUids: [] },
|
|
{
|
|
uid: "worcestershireSauce",
|
|
allergenUids: ["fish"],
|
|
dietUids: ["pescatarian"],
|
|
},
|
|
{
|
|
uid: "fishSauce",
|
|
allergenUids: ["fish"],
|
|
dietUids: ["pescatarian"],
|
|
},
|
|
{ uid: "wasabi", allergenUids: [] },
|
|
{ uid: "harissa", allergenUids: [] },
|
|
{ uid: "curryPaste", allergenUids: [] },
|
|
{ uid: "peanutButter", allergenUids: ["peanuts"] },
|
|
{ uid: "dijonMustard", allergenUids: ["mustard"] },
|
|
{ uid: "wholegrainMustard", allergenUids: ["mustard"] },
|
|
{ uid: "barbecueSauce", reproducible: true, allergenUids: [] },
|
|
{
|
|
uid: "tartarSauce",
|
|
allergenUids: ["eggs"],
|
|
dietUids: ["vegetarian", "pescatarian"],
|
|
},
|
|
{
|
|
uid: "cocktailSauce",
|
|
allergenUids: ["eggs"],
|
|
dietUids: ["vegetarian", "pescatarian"],
|
|
},
|
|
{
|
|
uid: "bearnaiseSauce",
|
|
allergenUids: ["eggs", "milk"],
|
|
dietUids: ["vegetarian", "pescatarian"],
|
|
},
|
|
{
|
|
uid: "hollandaiseSauce",
|
|
allergenUids: ["eggs", "milk"],
|
|
dietUids: ["vegetarian", "pescatarian"],
|
|
},
|
|
{
|
|
uid: "bechamelSauce",
|
|
reproducible: true,
|
|
allergenUids: ["milk", "gluten"],
|
|
dietUids: ["vegetarian", "pescatarian"],
|
|
},
|
|
{ uid: "teriyakiSauce", allergenUids: ["soy"] },
|
|
{
|
|
uid: "ponzuSauce",
|
|
allergenUids: ["soy", "fish"],
|
|
dietUids: ["pescatarian"],
|
|
},
|
|
{ uid: "chimichurri", allergenUids: [] },
|
|
{
|
|
uid: "redPesto",
|
|
allergenUids: ["milk", "treeNuts"],
|
|
dietUids: ["vegetarian", "pescatarian"],
|
|
},
|
|
{
|
|
uid: "pesto",
|
|
reproducible: true,
|
|
allergenUids: ["milk", "treeNuts"],
|
|
dietUids: ["vegetarian", "pescatarian"],
|
|
},
|
|
{
|
|
uid: "oysterSauce",
|
|
allergenUids: ["molluscs"],
|
|
dietUids: ["pescatarian"],
|
|
},
|
|
{ uid: "hoisinSauce", allergenUids: ["soy"] },
|
|
{ uid: "sriracha", allergenUids: [] },
|
|
{ uid: "sweetChiliSauce", allergenUids: [] },
|
|
{ uid: "miso", allergenUids: ["soy"] },
|
|
{
|
|
uid: "shrimpPaste",
|
|
allergenUids: ["crustaceans"],
|
|
dietUids: ["pescatarian"],
|
|
},
|
|
{ uid: "redCurryPaste", allergenUids: [] },
|
|
{ uid: "greenCurryPaste", allergenUids: [] },
|
|
{ uid: "tahini", reproducible: true, allergenUids: ["sesameSeeds"] },
|
|
// Second Ciqual pass.
|
|
{
|
|
uid: "aioli",
|
|
reproducible: true,
|
|
allergenUids: ["eggs"],
|
|
dietUids: ["vegetarian", "pescatarian"],
|
|
},
|
|
{ uid: "vinaigrette", reproducible: true, allergenUids: [] },
|
|
{ uid: "hummus", reproducible: true, allergenUids: ["sesameSeeds"] },
|
|
],
|
|
},
|
|
{
|
|
category: "condimentsAndSpices",
|
|
subcategory: "seasonings",
|
|
defaultDiets: ["vegetarian", "vegan", "pescatarian"],
|
|
defaultIcon: "BOTTLE",
|
|
items: [
|
|
{ uid: "oliveOil", allergenUids: [] },
|
|
{ uid: "sunflowerOil", allergenUids: [] },
|
|
{ uid: "rapeseedOil", allergenUids: [] },
|
|
{ uid: "coconutOil", allergenUids: [] },
|
|
{ uid: "sesameOil", allergenUids: ["sesameSeeds"] },
|
|
{ uid: "ciderVinegar", allergenUids: [] },
|
|
{ uid: "whiteVinegar", allergenUids: [] },
|
|
{ uid: "balsamicVinegar", allergenUids: ["sulfites"] },
|
|
{ uid: "capers", icon: "JAR", allergenUids: [] },
|
|
{ uid: "olives", icon: "JAR", allergenUids: [] },
|
|
{ uid: "blackOlives", icon: "JAR", allergenUids: [] },
|
|
{ uid: "greenOlives", icon: "JAR", allergenUids: [] },
|
|
{ uid: "whiteWine", icon: "DRINK", allergenUids: ["sulfites"] },
|
|
{ uid: "redWine", icon: "DRINK", allergenUids: ["sulfites"] },
|
|
{ uid: "roseWine", icon: "DRINK", allergenUids: ["sulfites"] },
|
|
{ uid: "redWineVinegar", allergenUids: ["sulfites"] },
|
|
{ uid: "whiteWineVinegar", allergenUids: ["sulfites"] },
|
|
{ uid: "sherryVinegar", allergenUids: ["sulfites"] },
|
|
{ uid: "walnutOil", allergenUids: ["treeNuts"] },
|
|
{ uid: "hazelnutOil", allergenUids: ["treeNuts"] },
|
|
{ uid: "peanutOil", allergenUids: ["peanuts"] },
|
|
{ uid: "chiliOil", allergenUids: [] },
|
|
{ uid: "riceVinegar", allergenUids: [] },
|
|
// Second Ciqual pass.
|
|
{ uid: "cornOil", allergenUids: [] },
|
|
{ uid: "grapeseedOil", allergenUids: [] },
|
|
{ uid: "soybeanOil", allergenUids: ["soy"] },
|
|
{ uid: "palmOil", allergenUids: [] },
|
|
{ uid: "mirin", icon: "DRINK", allergenUids: [] },
|
|
{ uid: "sake", icon: "DRINK", allergenUids: [] },
|
|
{ uid: "lemonJuice", icon: "DRINK", allergenUids: [] },
|
|
{ uid: "limeJuice", icon: "DRINK", allergenUids: [] },
|
|
{ uid: "orangeJuice", icon: "DRINK", allergenUids: [] },
|
|
{ uid: "appleJuice", icon: "DRINK", allergenUids: [] },
|
|
{ uid: "grapeJuice", icon: "DRINK", allergenUids: [] },
|
|
{ uid: "tomatoJuice", icon: "DRINK", allergenUids: [] },
|
|
{ uid: "cranberryJuice", icon: "DRINK", allergenUids: [] },
|
|
{ uid: "coffee", icon: "DRINK", allergenUids: [] },
|
|
{ uid: "tea", icon: "DRINK", allergenUids: [] },
|
|
{ uid: "beer", icon: "DRINK", allergenUids: ["gluten"] },
|
|
{ uid: "cider", icon: "DRINK", allergenUids: ["sulfites"] },
|
|
{
|
|
uid: "champagne",
|
|
icon: "DRINK",
|
|
allergenUids: ["sulfites"],
|
|
},
|
|
{ uid: "portWine", icon: "DRINK", allergenUids: ["sulfites"] },
|
|
{ uid: "vinJaune", icon: "DRINK", allergenUids: ["sulfites"] },
|
|
{ uid: "cognac", icon: "DRINK", allergenUids: [] },
|
|
{ uid: "rum", icon: "DRINK", allergenUids: [] },
|
|
{ uid: "whisky", icon: "DRINK", allergenUids: [] },
|
|
{ uid: "vodka", icon: "DRINK", allergenUids: [] },
|
|
],
|
|
},
|
|
// =========================================================================
|
|
// Aides culinaires
|
|
// =========================================================================
|
|
{
|
|
category: "cookingEssentials",
|
|
subcategory: "bases",
|
|
defaultDiets: ["vegetarian", "vegan", "pescatarian"],
|
|
defaultIcon: "GRAIN",
|
|
items: [
|
|
{ uid: "wheatFlour", allergenUids: ["gluten"] },
|
|
{ uid: "wholeWheatFlour", allergenUids: ["gluten"] },
|
|
{ uid: "cornFlour", allergenUids: [] },
|
|
{ uid: "buckwheatFlour", allergenUids: [] },
|
|
{ uid: "riceFlour", allergenUids: [] },
|
|
{ uid: "vegetableStockCube", icon: "STOCK_POT", allergenUids: ["celery"] },
|
|
{
|
|
uid: "chickenStockCube",
|
|
icon: "STOCK_POT",
|
|
allergenUids: ["celery"],
|
|
dietUids: [],
|
|
},
|
|
{ uid: "tomatoPaste", icon: "JAR", allergenUids: [] },
|
|
{ uid: "tomatoCoulis", icon: "JAR", allergenUids: [] },
|
|
{ uid: "cannedPeeledTomatoes", icon: "JAR", allergenUids: [] },
|
|
{ uid: "sunDriedTomatoes", icon: "JAR", allergenUids: [] },
|
|
{
|
|
uid: "vealStock",
|
|
icon: "STOCK_POT",
|
|
reproducible: true,
|
|
allergenUids: [],
|
|
dietUids: [],
|
|
},
|
|
{
|
|
uid: "chickenStock",
|
|
icon: "STOCK_POT",
|
|
reproducible: true,
|
|
allergenUids: [],
|
|
dietUids: [],
|
|
},
|
|
{
|
|
uid: "beefStockCube",
|
|
icon: "STOCK_POT",
|
|
allergenUids: ["celery"],
|
|
dietUids: [],
|
|
},
|
|
{
|
|
uid: "fishStockCube",
|
|
icon: "STOCK_POT",
|
|
allergenUids: ["fish", "celery"],
|
|
dietUids: ["pescatarian"],
|
|
},
|
|
{
|
|
uid: "vegetableBroth",
|
|
icon: "STOCK_POT",
|
|
reproducible: true,
|
|
allergenUids: ["celery"],
|
|
},
|
|
{
|
|
uid: "chickenBroth",
|
|
icon: "STOCK_POT",
|
|
reproducible: true,
|
|
allergenUids: ["celery"],
|
|
dietUids: [],
|
|
},
|
|
{
|
|
uid: "beefBroth",
|
|
icon: "STOCK_POT",
|
|
reproducible: true,
|
|
allergenUids: ["celery"],
|
|
dietUids: [],
|
|
},
|
|
{ uid: "courtBouillon", icon: "STOCK_POT", allergenUids: [] },
|
|
{
|
|
uid: "dashi",
|
|
icon: "STOCK_POT",
|
|
allergenUids: ["fish"],
|
|
dietUids: ["pescatarian"],
|
|
},
|
|
{
|
|
uid: "shellfishBisque",
|
|
icon: "STOCK_POT",
|
|
allergenUids: ["crustaceans"],
|
|
dietUids: ["pescatarian"],
|
|
},
|
|
{ uid: "tapiocaFlour", allergenUids: [] },
|
|
{ uid: "masaHarina", allergenUids: [] },
|
|
{ uid: "water", icon: "DRINK", allergenUids: [] },
|
|
{ uid: "sparklingWater", icon: "DRINK", allergenUids: [] },
|
|
{ uid: "orangeBlossomWater", icon: "DRINK", allergenUids: [] },
|
|
{ uid: "roseWater", icon: "DRINK", allergenUids: [] },
|
|
{
|
|
uid: "fishFumet",
|
|
icon: "STOCK_POT",
|
|
reproducible: true,
|
|
allergenUids: ["fish"],
|
|
dietUids: ["pescatarian"],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
category: "cookingEssentials",
|
|
subcategory: "thickeners",
|
|
defaultDiets: ["vegetarian", "vegan", "pescatarian"],
|
|
defaultIcon: "JAR",
|
|
items: [
|
|
{ uid: "bakersYeast", allergenUids: [] },
|
|
{ uid: "bakingPowder", allergenUids: [] },
|
|
{ uid: "cornstarch", allergenUids: [] },
|
|
{ uid: "lupinFlour", allergenUids: ["lupin"] },
|
|
// Animal collagen (bones/skin, usually pork or beef) — not
|
|
// vegetarian/vegan, and not reliably fish-derived either, so no
|
|
// pescetarian flag.
|
|
{ uid: "gelatin", allergenUids: [], dietUids: [] },
|
|
{ uid: "bakingSoda", allergenUids: [] },
|
|
{ uid: "potatoStarch", allergenUids: [] },
|
|
],
|
|
},
|
|
{
|
|
category: "cookingEssentials",
|
|
subcategory: "sugars",
|
|
defaultDiets: ["vegetarian", "vegan", "pescatarian"],
|
|
defaultIcon: "SUGAR",
|
|
items: [
|
|
{ uid: "sugar", allergenUids: [] },
|
|
{ uid: "honey", allergenUids: [], dietUids: ["vegetarian", "pescatarian"] },
|
|
{ uid: "mapleSyrup", allergenUids: [] },
|
|
{ uid: "brownSugar", allergenUids: [] },
|
|
{ uid: "powderedSugar", allergenUids: [] },
|
|
{ uid: "demeraraSugar", allergenUids: [] },
|
|
{ uid: "darkChocolate", allergenUids: [] },
|
|
{
|
|
uid: "milkChocolate",
|
|
allergenUids: ["milk"],
|
|
dietUids: ["vegetarian", "pescatarian"],
|
|
},
|
|
{
|
|
uid: "whiteChocolate",
|
|
allergenUids: ["milk"],
|
|
dietUids: ["vegetarian", "pescatarian"],
|
|
},
|
|
{ uid: "chocolateChips", allergenUids: [] },
|
|
{ uid: "cocoaPowder", allergenUids: [] },
|
|
{ uid: "vanillaExtract", allergenUids: [] },
|
|
{ uid: "palmSugar", allergenUids: [] },
|
|
{ uid: "caneSyrup", allergenUids: [] },
|
|
],
|
|
},
|
|
];
|
|
|
|
const INGREDIENTS: Array<
|
|
Omit<IngredientSeed, "icon" | "dietUids" | "reproducible"> & {
|
|
category: IngredientCategory;
|
|
subcategory: IngredientSubcategory;
|
|
icon: IngredientIcon;
|
|
dietUids: string[];
|
|
reproducible: boolean;
|
|
}
|
|
> = INGREDIENT_GROUPS.flatMap(({ category, subcategory, defaultDiets, defaultIcon, items }) =>
|
|
items.map((item) => ({
|
|
...item,
|
|
category,
|
|
subcategory,
|
|
icon: item.icon ?? defaultIcon,
|
|
dietUids: item.dietUids ?? defaultDiets,
|
|
reproducible: item.reproducible ?? false,
|
|
})),
|
|
);
|
|
|
|
/**
|
|
* Populates the `Diet`/`Category`/`Allergy` reference tables. Idempotent
|
|
* (safe to call against a database that already has this data — upserts by
|
|
* `key`, all `@unique`) — used both by `prisma/seed.ts` (the CLI entry
|
|
* point, `prisma db seed`) and by `test-support/reset-db.ts` (so every
|
|
* test starts from the same realistic reference data the real app seeds,
|
|
* not an empty table).
|
|
*
|
|
* Every `uid` below (`DIETS`, `ALLERGENS`, `INGREDIENT_GROUPS`) *is* the
|
|
* database `key`, authored directly — no French label, no separate
|
|
* lookup/translation table (that used to be `catalog-en-keys.ts` +
|
|
* `getEnglishKey()`, removed: product decision recorded in chat, this file
|
|
* carries zero natural-language text now). The French display label for
|
|
* each uid lives solely in `apps/web`'s `locales/fr/translation.json`
|
|
* (`catalog.*` namespace), maintained independently — adding a new
|
|
* diet/allergen/ingredient here means also adding its translation there by
|
|
* hand, tied together only by the matching uid string.
|
|
*/
|
|
export async function seedReferenceData(prisma: PrismaClient): Promise<void> {
|
|
for (const key of DIETS) {
|
|
await prisma.diet.upsert({ where: { key }, update: {}, create: { key } });
|
|
}
|
|
|
|
// `update: { type, toBaseFactor }` (not `{}`) — same reasoning as
|
|
// `ALLERGENS`' `kind` below: a reseed must correct a unit's
|
|
// type/toBaseFactor if it's ever edited above, not just skip existing rows.
|
|
for (const { uid: key, type, toBaseFactor } of UNITS) {
|
|
await prisma.unit.upsert({
|
|
where: { key },
|
|
update: { type, toBaseFactor },
|
|
create: { key, type, toBaseFactor },
|
|
});
|
|
}
|
|
|
|
// TechStep: upsert by key (same idempotent-seed reasoning as everything
|
|
// above), then fully replace its mappings on every reseed. Mappings carry
|
|
// no natural per-row identity to upsert against, and expressions/weights
|
|
// are expected to be tuned over time — a straight "delete all, recreate
|
|
// from source" keeps the table an exact mirror of `TECH_STEPS` rather
|
|
// than accumulating stale/duplicate rows from earlier edits. Nothing else
|
|
// references `TechStepMapping.id` (`Step` only points at `TechStep`, not
|
|
// at a specific mapping), so this replace is safe.
|
|
for (const { uid: key } of TECH_STEPS) {
|
|
await prisma.techStep.upsert({ where: { key }, update: {}, create: { key } });
|
|
}
|
|
const techSteps = await prisma.techStep.findMany({
|
|
where: { key: { in: TECH_STEPS.map((t) => t.uid) } },
|
|
});
|
|
const techStepIdByKey = new Map(techSteps.map((t) => [t.key, t.id]));
|
|
|
|
await prisma.techStepMapping.deleteMany({
|
|
where: { techStepId: { in: [...techStepIdByKey.values()] } },
|
|
});
|
|
const techStepMappingRows = TECH_STEPS.flatMap(({ uid, mappings }) => {
|
|
const techStepId = techStepIdByKey.get(uid);
|
|
if (techStepId === undefined) return [];
|
|
return mappings.map(({ locale, expression, weight }) => ({
|
|
techStepId,
|
|
locale,
|
|
expression,
|
|
weight,
|
|
}));
|
|
});
|
|
if (techStepMappingRows.length > 0) {
|
|
await prisma.techStepMapping.createMany({ data: techStepMappingRows });
|
|
}
|
|
|
|
// `Allergy` itself carries no `key` — it's the selectable instance of a
|
|
// keyed `Category` (see schema.prisma) — so seeding an allergen means one
|
|
// Category (upserted by key) plus exactly one Allergy row under it,
|
|
// created only the first time. `update: { kind }` (not `{}`) — a reseed
|
|
// must correct `kind` on an already-existing category if the
|
|
// classification above ever changes, not just skip it.
|
|
for (const { uid: key, kind } of ALLERGENS) {
|
|
const category = await prisma.category.upsert({
|
|
where: { key },
|
|
update: { kind },
|
|
create: { key, kind },
|
|
});
|
|
const existing = await prisma.allergy.findFirst({ where: { categoryId: category.id } });
|
|
if (!existing) {
|
|
await prisma.allergy.create({ data: { categoryId: category.id } });
|
|
}
|
|
}
|
|
|
|
// Ingredients: bulk, not one upsert per row (`INGREDIENTS` is a few
|
|
// hundred entries long, and `seedReferenceData` re-runs on every single
|
|
// test's `resetDatabase()` — a per-row round trip made the whole suite
|
|
// measurably slower). Bulk-create whatever's missing in one query, then
|
|
// reconcile `icon`/`category`/`subcategory` only for the rows where any
|
|
// of them actually changed — on a freshly-truncated table (the common
|
|
// test-suite case) that's zero updates, on a real re-deploy it's however
|
|
// many rows were edited in code since the last deploy, never the full
|
|
// list.
|
|
const ingredientKeys = INGREDIENTS.map((i) => i.uid);
|
|
const existingIngredients = await prisma.ingredient.findMany({
|
|
where: { key: { in: ingredientKeys } },
|
|
select: {
|
|
id: true,
|
|
key: true,
|
|
icon: true,
|
|
category: true,
|
|
subcategory: true,
|
|
reproducible: true,
|
|
},
|
|
});
|
|
const existingByKey = new Map(existingIngredients.map((i) => [i.key, i]));
|
|
|
|
const missingIngredients = INGREDIENTS.filter((i) => !existingByKey.has(i.uid));
|
|
if (missingIngredients.length > 0) {
|
|
await prisma.ingredient.createMany({
|
|
data: missingIngredients.map(({ uid, icon, category, subcategory, reproducible }) => ({
|
|
key: uid,
|
|
icon,
|
|
category,
|
|
subcategory,
|
|
reproducible,
|
|
})),
|
|
});
|
|
}
|
|
|
|
const changed = INGREDIENTS.filter((i) => {
|
|
const existing = existingByKey.get(i.uid);
|
|
return (
|
|
existing &&
|
|
(existing.icon !== i.icon ||
|
|
existing.category !== i.category ||
|
|
existing.subcategory !== i.subcategory ||
|
|
existing.reproducible !== i.reproducible)
|
|
);
|
|
});
|
|
for (const { uid, icon, category, subcategory, reproducible } of changed) {
|
|
await prisma.ingredient.update({
|
|
where: { key: uid },
|
|
data: { icon, category, subcategory, reproducible },
|
|
});
|
|
}
|
|
|
|
// Re-resolve every ingredient's id (existing + just-created) and every
|
|
// allergy's id (by its category key) once, then link them in a single
|
|
// bulk insert — same "re-derived every time, not upserted per link"
|
|
// reasoning as before for `IngredientAllergy` (it has no natural per-row
|
|
// identity to upsert against), just batched instead of looped.
|
|
const allIngredients = await prisma.ingredient.findMany({
|
|
where: { key: { in: ingredientKeys } },
|
|
select: { id: true, key: true },
|
|
});
|
|
const ingredientIdByKey = new Map(allIngredients.map((i) => [i.key, i.id]));
|
|
|
|
const allergies = await prisma.allergy.findMany({ include: { category: true } });
|
|
const allergyIdByCategoryKey = new Map(allergies.map((a) => [a.category.key, a.id]));
|
|
|
|
const links: Array<{ ingredientId: number; allergyId: number }> = [];
|
|
for (const { uid, allergenUids } of INGREDIENTS) {
|
|
const ingredientId = ingredientIdByKey.get(uid);
|
|
if (ingredientId === undefined) continue;
|
|
for (const allergenUid of allergenUids) {
|
|
const allergyId = allergyIdByCategoryKey.get(allergenUid);
|
|
if (allergyId !== undefined) links.push({ ingredientId, allergyId });
|
|
}
|
|
}
|
|
if (links.length > 0) {
|
|
await prisma.ingredientAllergy.createMany({ data: links, skipDuplicates: true });
|
|
}
|
|
|
|
// Same bulk-insert approach as the allergy links above, resolved against
|
|
// `dietUids` (item override, falling back to its group's `defaultDiets`
|
|
// in the `INGREDIENTS` flatten step) instead of `allergenUids`.
|
|
const diets = await prisma.diet.findMany();
|
|
const dietIdByKey = new Map(diets.map((d) => [d.key, d.id]));
|
|
|
|
const dietLinks: Array<{ ingredientId: number; dietId: number }> = [];
|
|
for (const { uid, dietUids } of INGREDIENTS) {
|
|
const ingredientId = ingredientIdByKey.get(uid);
|
|
if (ingredientId === undefined) continue;
|
|
for (const dietUid of dietUids) {
|
|
const dietId = dietIdByKey.get(dietUid);
|
|
if (dietId !== undefined) dietLinks.push({ ingredientId, dietId });
|
|
}
|
|
}
|
|
if (dietLinks.length > 0) {
|
|
await prisma.ingredientDiet.createMany({ data: dietLinks, skipDuplicates: true });
|
|
}
|
|
}
|