batchCooking/apps/api/src/db/catalog-en-keys.ts
Nicolas 4b85531601 fix(api): les uids du catalogue sont en anglais, pas des slugs français
reference-seed-data.ts reste rédigé en français (c'est juste le libellé
d'autoring, jamais stocké/exposé), mais la clé stable (`Diet.key`/
`Category.key`/`Ingredient.key`) qu'on en dérive doit elle-même être un
identifiant anglais, indépendant de la langue d'autoring — pas juste le
même texte français passé à slugify().

- apps/api/src/db/catalog-en-keys.ts : dictionnaire écrit à la main
  (label français -> clé anglaise) pour les 5 régimes, 14 allergènes et
  437 ingrédients ; getEnglishKey() lève une erreur explicite si un
  nouvel élément n'a pas encore d'entrée plutôt que de retomber sur un
  slug français silencieux.
- scripts/validate-catalog-en-keys.ts : vérifie que chaque diet/allergène/
  ingrédient de reference-seed-data.ts a une entrée, et que les clés
  anglaises résultantes sont uniques (437/437, 14/14, 5/5 — zéro manquant,
  zéro collision).
- reference-seed-data.ts et scripts/generate-catalog-i18n.ts utilisent
  désormais getEnglishKey() au lieu de slugify(nom français).
- Nouvelle migration (20260818193000_catalog_keys_to_english) qui
  remappe les lignes déjà seedées avec un slug français (par la migration
  précédente) vers leur clé anglaise définitive.
- apps/web/src/locales/fr/translation.json régénéré : catalog.* est
  maintenant indexé par clé anglaise ("vegetarian", "eggs",
  "ground_beef"...), toujours avec le libellé français en valeur.
- Tests/step-definitions mis à jour (getEnglishKey() au lieu de
  slugify()) ; 102 tests Mocha + 32 scénarios Cucumber passent contre la
  base migrée. Vérifié aussi en direct via GET /reference/diets et
  /reference/allergies.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 20:03:55 +02:00

557 lines
17 KiB
TypeScript

/**
* English key for every catalog reference label — `Diet`/`Category`
* (allergens)/`Ingredient` rows must carry a stable, storage-safe `key`
* that is itself English, independent of whatever language the *seed's*
* authoring label (`DIETS`/`ALLERGENS`/`INGREDIENT_GROUPS` in
* `reference-seed-data.ts`, currently French) happens to be in — a French
* `key` would tie the identifier to the one language it's meant to be
* decoupled from (see `utils/slugify.ts`'s doc comment and `apps/web`'s
* `locales/fr/translation.json` `catalog.*` namespace, which resolves the
* *display* label from this same key).
*
* Hand-assigned (not machine-translated) — an English label is chosen once
* and never changes, exactly like the key it produces (via {@link
* slugify}). Keyed by the exact French authoring label so
* `reference-seed-data.ts` and `scripts/generate-catalog-i18n.ts` can look
* a row's key up by the same string they already have in hand.
*
* `getEnglishKey` throws on a missing entry rather than falling back to
* slugifying the French label — a silently-French key defeats the point,
* so a newly-added diet/allergen/ingredient must get an entry here before
* it can seed.
*/
import { slugify } from "../utils/slugify.js";
const DIET_KEYS: Record<string, string> = {
Omnivore: "omnivore",
Végétarien: "vegetarian",
Végan: "vegan",
Pescétarien: "pescatarian",
"Sans gluten": "gluten_free",
};
const ALLERGEN_KEYS: Record<string, string> = {
Gluten: "gluten",
Crustacés: "crustaceans",
Œufs: "eggs",
Poissons: "fish",
Arachides: "peanuts",
Soja: "soy",
Lait: "milk",
"Fruits à coque": "tree_nuts",
Céleri: "celery",
Moutarde: "mustard",
"Graines de sésame": "sesame_seeds",
Sulfites: "sulfites",
Lupin: "lupin",
Mollusques: "molluscs",
};
// Grouped by (category, subcategory), mirroring `INGREDIENT_GROUPS` in
// reference-seed-data.ts, purely so a translator can find/check an entry
// against its source group — this is one flat lookup table at runtime.
const INGREDIENT_KEYS: Record<string, string> = {
// --- Produits frais / Légumes ---------------------------------------
Tomate: "tomato",
Oignon: "onion",
Échalote: "shallot",
Ail: "garlic",
Carotte: "carrot",
Courgette: "zucchini",
Concombre: "cucumber",
Cornichons: "gherkins",
Poivron: "bell_pepper",
Champignon: "mushroom",
Cèpes: "porcini",
Aubergine: "eggplant",
Brocoli: "broccoli",
"Chou-fleur": "cauliflower",
"Chou blanc": "white_cabbage",
"Chou rouge": "red_cabbage",
"Chou de Bruxelles": "brussels_sprouts",
Épinard: "spinach",
Blette: "swiss_chard",
Salade: "lettuce",
Roquette: "arugula",
Cresson: "watercress",
Poireau: "leek",
Radis: "radish",
Betterave: "beetroot",
Navet: "turnip",
Panais: "parsnip",
"Haricot vert": "green_bean",
"Petit pois": "pea",
Maïs: "corn",
Artichaut: "artichoke",
Fenouil: "fennel",
Endive: "endive",
Potiron: "pumpkin",
Butternut: "butternut_squash",
Asperge: "asparagus",
Avocat: "avocado",
"Pomme de terre": "potato",
"Patate douce": "sweet_potato",
"Tomates cerises": "cherry_tomato",
"Pak-choï": "bok_choy",
"Germes de soja": "soybean_sprouts",
Shiitake: "shiitake",
Daikon: "daikon",
"Piment vert frais": "fresh_green_chili",
// --- Produits frais / Fruits -----------------------------------------
Citron: "lemon",
"Citron vert": "lime",
Pomme: "apple",
Poire: "pear",
Banane: "banana",
Orange: "orange",
Clémentine: "clementine",
Pamplemousse: "grapefruit",
Fraise: "strawberry",
Framboise: "raspberry",
Myrtille: "blueberry",
Mûre: "blackberry",
Cerise: "cherry",
Abricot: "apricot",
Pêche: "peach",
Prune: "plum",
Raisin: "grape",
Melon: "melon",
Pastèque: "watermelon",
Ananas: "pineapple",
Mangue: "mango",
Kiwi: "kiwi",
Figue: "fig",
Datte: "date",
Litchi: "lychee",
Grenade: "pomegranate",
Rhubarbe: "rhubarb",
Coing: "quince",
// --- Produits frais / Herbes fraîches ---------------------------------
Basilic: "basil",
Persil: "parsley",
Thym: "thyme",
Romarin: "rosemary",
Laurier: "bay_leaf",
Ciboulette: "chives",
"Coriandre fraîche": "fresh_cilantro",
Menthe: "mint",
Origan: "oregano",
Aneth: "dill",
Estragon: "tarragon",
Sarriette: "savory",
Marjolaine: "marjoram",
Sauge: "sage",
Cerfeuil: "chervil",
Gingembre: "ginger",
Citronnelle: "lemongrass",
Combava: "kaffir_lime",
// --- Boucherie & poissonnerie / Viandes -------------------------------
Lapin: "rabbit",
"Bœuf haché": "ground_beef",
"Steak de bœuf": "beef_steak",
"Rôti de bœuf": "beef_roast",
"Escalope de veau": "veal_cutlet",
"Filet mignon de porc": "pork_tenderloin",
"Côte de porc": "pork_chop",
Agneau: "lamb",
"Gigot d'agneau": "leg_of_lamb",
Lardons: "bacon_lardons",
Bacon: "bacon",
"Jambon blanc": "ham",
"Jambon cru": "cured_ham",
Saucisse: "sausage",
Chorizo: "chorizo",
Merguez: "merguez",
Prosciutto: "prosciutto",
Pancetta: "pancetta",
Mortadelle: "mortadella",
Salami: "salami",
// --- Boucherie & poissonnerie / Volailles -----------------------------
Poulet: "chicken",
Dinde: "turkey",
Canard: "duck",
"Magret de canard": "duck_breast",
// --- Boucherie & poissonnerie / Poissons -------------------------------
Saumon: "salmon",
Thon: "tuna",
Cabillaud: "cod",
Truite: "trout",
Sardine: "sardine",
Anchois: "anchovy",
Merlan: "whiting",
Surimi: "surimi",
"Bar (loup de mer)": "sea_bass",
Dorade: "sea_bream",
Sole: "sole",
Turbot: "turbot",
Merlu: "hake",
Colin: "pollock",
"Lieu noir": "saithe",
Églefin: "haddock",
Maquereau: "mackerel",
Hareng: "herring",
Rouget: "red_mullet",
Raie: "skate",
Lotte: "monkfish",
Flétan: "halibut",
Espadon: "swordfish",
Carpe: "carp",
Brochet: "pike",
Perche: "perch",
Tilapia: "tilapia",
Panga: "pangasius",
"Saumon fumé": "smoked_salmon",
"Poisson séché": "dried_fish",
// --- Boucherie & poissonnerie / Crustacés & fruits de mer -------------
Crevettes: "shrimp",
Langoustines: "langoustine",
Homard: "lobster",
Crabe: "crab",
Langouste: "spiny_lobster",
Moules: "mussels",
Huîtres: "oysters",
"Saint-Jacques": "scallops",
Calamar: "squid",
Poulpe: "octopus",
Palourdes: "clams",
Bulots: "whelks",
// --- Épicerie sèche / Féculents ----------------------------------------
Semoule: "semolina",
Couscous: "couscous",
Boulgour: "bulgur",
Polenta: "polenta",
Quinoa: "quinoa",
Pâtes: "pasta",
"Pâtes complètes": "whole_wheat_pasta",
Riz: "rice",
"Riz basmati": "basmati_rice",
"Riz complet": "brown_rice",
"Flocons d'avoine": "oats",
Spaghetti: "spaghetti",
Penne: "penne",
Tagliatelles: "tagliatelle",
"Lasagnes (feuilles)": "lasagna_sheets",
Gnocchi: "gnocchi",
"Riz arborio": "arborio_rice",
"Nouilles de riz": "rice_noodles",
"Nouilles udon": "udon_noodles",
"Nouilles soba": "soba_noodles",
"Nouilles chinoises": "chinese_noodles",
"Vermicelles de riz": "rice_vermicelli",
"Vermicelles de soja": "soy_vermicelli",
"Riz gluant": "sticky_rice",
"Riz à sushi": "sushi_rice",
"Riz jasmin": "jasmine_rice",
// --- Épicerie sèche / Légumineuses --------------------------------------
"Lentilles vertes": "green_lentils",
"Lentilles corail": "red_lentils",
"Pois chiches": "chickpeas",
"Haricots blancs": "white_beans",
"Haricots rouges": "kidney_beans",
"Haricots noirs": "black_beans",
"Pois cassés": "split_peas",
Fèves: "fava_beans",
Edamame: "edamame",
"Haricots pinto": "pinto_beans",
// --- Épicerie sèche / Graines & fruits secs ----------------------------
Cacahuètes: "peanuts_shelled",
Amandes: "almonds",
Noix: "walnuts",
Noisettes: "hazelnuts",
"Noix de cajou": "cashews",
Pistaches: "pistachios",
"Noix de pécan": "pecans",
"Poudre d'amande": "almond_powder",
"Pignons de pin": "pine_nuts",
"Graines de tournesol": "sunflower_seeds",
"Graines de courge": "pumpkin_seeds",
"Noix de coco râpée": "shredded_coconut",
"Raisins secs": "raisins",
Pruneaux: "prunes",
"Abricots secs": "dried_apricots",
// --- Épicerie sèche / Autres --------------------------------------------
"Champignons noirs": "black_mushrooms",
"Algue nori": "nori_seaweed",
"Algue wakamé": "wakame_seaweed",
"Algue kombu": "kombu_seaweed",
"Pousses de bambou": "bamboo_shoots",
"Châtaignes d'eau": "water_chestnuts",
// --- Boulangerie / Pains -------------------------------------------------
Pain: "bread",
"Pain de mie": "sandwich_bread",
"Pain complet": "whole_wheat_bread",
Baguette: "baguette",
"Pain de seigle": "rye_bread",
Chapelure: "breadcrumbs",
"Pain à burger": "burger_bun",
"Pain brioché": "brioche_bun",
"Pain à hot-dog": "hot_dog_bun",
"Pain pita": "pita_bread",
"Pain bagel": "bagel",
Naan: "naan",
"Pain wrap": "wrap_bread",
"Pain viennois": "viennese_bread",
"Pain de campagne": "country_bread",
"Pain aux céréales": "multigrain_bread",
"Petit pain": "bread_roll",
"Pain suédois": "swedish_bread",
"Pain sans gluten": "gluten_free_bread",
Biscotte: "rusk",
Croûtons: "croutons",
Focaccia: "focaccia",
Ciabatta: "ciabatta",
"Tortilla de maïs": "corn_tortilla",
"Tortilla de blé": "wheat_tortilla",
// --- Boulangerie / Pâtes à cuire -----------------------------------------
"Pâte feuilletée": "puff_pastry",
"Pâte brisée": "shortcrust_pastry",
"Pâte à pizza": "pizza_dough",
"Pâte à tarte sablée": "sweet_shortcrust_pastry",
// --- Crémerie & fromage / Produits laitiers ------------------------------
Lait: "milk",
Beurre: "butter",
"Crème fraîche": "creme_fraiche",
"Crème liquide": "liquid_cream",
Fromage: "cheese",
Emmental: "emmental",
Gruyère: "gruyere",
Parmesan: "parmesan",
Mozzarella: "mozzarella",
"Chèvre (fromage)": "goat_cheese",
Feta: "feta",
Comté: "comte",
"Fromage blanc": "fromage_blanc",
Mascarpone: "mascarpone",
Yaourt: "yogurt",
Burrata: "burrata",
Ricotta: "ricotta",
Pecorino: "pecorino",
Gorgonzola: "gorgonzola",
Cheddar: "cheddar",
// --- Crémerie & fromage / Œufs -------------------------------------------
Œuf: "egg",
// --- Crémerie & fromage / Alternatives ------------------------------------
"Lait de coco": "coconut_milk",
"Crème de coco": "coconut_cream",
"Lait d'amande": "almond_milk",
"Lait d'avoine": "oat_milk",
Tofu: "tofu",
"Tofu soyeux": "silken_tofu",
// --- Condiments & épices / Épices -----------------------------------------
"Herbes de Provence": "herbes_de_provence",
"Poivre noir": "black_pepper",
Paprika: "paprika",
"Piment d'Espelette": "espelette_pepper",
"Piment de Cayenne": "cayenne_pepper",
Cumin: "cumin",
"Curry (poudre)": "curry_powder",
Curcuma: "turmeric",
Cannelle: "cinnamon",
Muscade: "nutmeg",
Safran: "saffron",
"Clou de girofle": "clove",
"Vanille (gousse)": "vanilla_bean",
"Poivre blanc": "white_pepper",
"Poivre rose": "pink_pepper",
"Poivre du Sichuan": "sichuan_pepper",
"Paprika fumé": "smoked_paprika",
"Piment oiseau": "bird_eye_chili",
"Baies de genièvre": "juniper_berries",
"Anis étoilé (badiane)": "star_anise",
"Anis vert": "green_anise",
"Graines de fenouil": "fennel_seeds",
Sumac: "sumac",
Nigelle: "nigella",
"Quatre épices": "allspice",
"Colombo (poudre)": "colombo_powder",
Baharat: "baharat",
Raifort: "horseradish",
"Sel aux herbes": "herb_salt",
"Sel de céleri": "celery_salt",
"Fleur de sel": "fleur_de_sel",
Sel: "salt",
"Cinq épices": "five_spice",
"Garam masala": "garam_masala",
"Graines de coriandre": "coriander_seeds",
Cardamome: "cardamom",
Fenugrec: "fenugreek",
"Piment jalapeño": "jalapeno",
"Piment chipotle": "chipotle",
"Piment poblano": "poblano_pepper",
"Piment habanero": "habanero",
"Ras el hanout": "ras_el_hanout",
"Za'atar": "zaatar",
// --- Condiments & épices / Sauces -------------------------------------------
"Sauce soja": "soy_sauce",
Moutarde: "mustard",
Mayonnaise: "mayonnaise",
Ketchup: "ketchup",
Tabasco: "tabasco",
"Sauce Worcestershire": "worcestershire_sauce",
"Sauce nuoc-mâm": "fish_sauce",
Wasabi: "wasabi",
Harissa: "harissa",
"Pâte de curry": "curry_paste",
"Beurre de cacahuète": "peanut_butter",
"Moutarde de Dijon": "dijon_mustard",
"Moutarde à l'ancienne": "wholegrain_mustard",
"Sauce barbecue": "barbecue_sauce",
"Sauce tartare": "tartar_sauce",
"Sauce cocktail": "cocktail_sauce",
"Sauce béarnaise": "bearnaise_sauce",
"Sauce hollandaise": "hollandaise_sauce",
"Sauce béchamel": "bechamel_sauce",
"Sauce teriyaki": "teriyaki_sauce",
"Sauce ponzu": "ponzu_sauce",
Chimichurri: "chimichurri",
"Pesto rouge (tomates séchées)": "red_pesto",
Pesto: "pesto",
"Sauce huître": "oyster_sauce",
"Sauce hoisin": "hoisin_sauce",
"Sauce sriracha": "sriracha",
"Sauce sweet chili": "sweet_chili_sauce",
Miso: "miso",
"Pâte de crevettes": "shrimp_paste",
"Pâte de curry rouge (thaï)": "red_curry_paste",
"Pâte de curry vert (thaï)": "green_curry_paste",
Tahini: "tahini",
// --- Condiments & épices / Assaisonnements -----------------------------------
"Huile d'olive": "olive_oil",
"Huile de tournesol": "sunflower_oil",
"Huile de colza": "rapeseed_oil",
"Huile de coco": "coconut_oil",
"Huile de sésame": "sesame_oil",
"Vinaigre de cidre": "cider_vinegar",
"Vinaigre blanc": "white_vinegar",
"Vinaigre balsamique": "balsamic_vinegar",
Câpres: "capers",
Olives: "olives",
"Vin blanc (cuisine)": "white_wine",
"Vin rouge (cuisine)": "red_wine",
"Vinaigre de vin rouge": "red_wine_vinegar",
"Vinaigre de vin blanc": "white_wine_vinegar",
"Vinaigre de xérès": "sherry_vinegar",
"Huile de noix": "walnut_oil",
"Huile de noisette": "hazelnut_oil",
"Huile d'arachide": "peanut_oil",
"Huile pimentée": "chili_oil",
"Vinaigre de riz": "rice_vinegar",
Mirin: "mirin",
"Saké (cuisine)": "sake",
"Jus de citron": "lemon_juice",
"Jus de citron vert": "lime_juice",
"Jus d'orange": "orange_juice",
"Jus de pomme": "apple_juice",
"Jus de raisin": "grape_juice",
"Jus de tomate": "tomato_juice",
"Jus de cranberry": "cranberry_juice",
Café: "coffee",
Thé: "tea",
"Bière (cuisine)": "beer",
"Cidre (cuisine)": "cider",
"Champagne / vin pétillant (cuisine)": "champagne",
"Porto (cuisine)": "port_wine",
"Vin jaune (cuisine)": "vin_jaune",
Cognac: "cognac",
Rhum: "rum",
Whisky: "whisky",
Vodka: "vodka",
// --- Aides culinaires / Bases -------------------------------------------
"Farine de blé": "wheat_flour",
"Farine complète": "whole_wheat_flour",
"Farine de maïs": "corn_flour",
"Farine de sarrasin": "buckwheat_flour",
"Farine de riz": "rice_flour",
"Bouillon cube légumes": "vegetable_stock_cube",
"Bouillon cube volaille": "chicken_stock_cube",
"Concentré de tomate": "tomato_paste",
"Coulis de tomate": "tomato_coulis",
"Tomates pelées (conserve)": "canned_peeled_tomatoes",
"Tomates séchées": "sun_dried_tomatoes",
"Fond de veau": "veal_stock",
"Fond de volaille": "chicken_stock",
"Bouillon cube bœuf": "beef_stock_cube",
"Bouillon cube poisson": "fish_stock_cube",
"Bouillon de légumes": "vegetable_broth",
"Bouillon de volaille": "chicken_broth",
"Bouillon de bœuf": "beef_broth",
"Court-bouillon": "court_bouillon",
"Dashi (bouillon japonais)": "dashi",
"Bisque de crustacés": "shellfish_bisque",
"Farine de tapioca": "tapioca_flour",
"Masa harina": "masa_harina",
Eau: "water",
"Eau gazeuse": "sparkling_water",
"Eau de fleur d'oranger": "orange_blossom_water",
"Eau de rose": "rose_water",
"Fumet de poisson": "fish_fumet",
// --- Aides culinaires / Épaississants -------------------------------------
"Levure boulangère": "bakers_yeast",
"Levure chimique": "baking_powder",
Maïzena: "cornstarch",
"Farine de lupin": "lupin_flour",
Gélatine: "gelatin",
"Bicarbonate de soude": "baking_soda",
"Fécule de pomme de terre": "potato_starch",
// --- Aides culinaires / Sucres ---------------------------------------------
Sucre: "sugar",
Miel: "honey",
"Sirop d'érable": "maple_syrup",
"Sucre roux": "brown_sugar",
"Sucre glace": "powdered_sugar",
Cassonade: "demerara_sugar",
"Chocolat noir": "dark_chocolate",
"Chocolat au lait": "milk_chocolate",
"Chocolat blanc": "white_chocolate",
"Pépites de chocolat": "chocolate_chips",
"Cacao en poudre": "cocoa_powder",
"Extrait de vanille": "vanilla_extract",
"Sucre de palme": "palm_sugar",
"Sirop de sucre de canne": "cane_syrup",
};
const ALL_KEYS: Record<string, string> = {
...DIET_KEYS,
...ALLERGEN_KEYS,
...INGREDIENT_KEYS,
};
/**
* Resolves a seed-time French authoring label to its English `key` —
* throws if it's missing an entry above rather than silently falling back
* to a French slug, since a new diet/allergen/ingredient needs a
* deliberately-chosen English key before it can seed at all.
* `slugify` still runs over the result so a stray character/casing slip in
* the table above can't produce a key that doesn't match the
* `[a-z0-9_]`-only shape every other key has.
*/
export function getEnglishKey(frenchLabel: string): string {
const english = ALL_KEYS[frenchLabel];
if (english === undefined) {
throw new Error(
`No English key registered for "${frenchLabel}" — add one to catalog-en-keys.ts`,
);
}
return slugify(english);
}