From c34eaa89d0b7441109b4eb8cae7fee06a87e52e6 Mon Sep 17 00:00:00 2001 From: kyuno053 <31762247+kyuno053@users.noreply.github.com> Date: Tue, 18 Aug 2026 20:52:12 +0200 Subject: [PATCH] =?UTF-8?q?chore(web):=20session=20de=20polish=20global=20?= =?UTF-8?q?=E2=80=94=20version,=20checkbox,=20danger=20zone,=20ic=C3=B4nes?= =?UTF-8?q?=20(#20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(web): session de polish global — version, checkbox, danger zone, icônes - Affiche le numéro de version (package.json, injecté via Vite) en bas de la sidebar, masqué en mode collapse et en mobile. - Factorise les checkbox/radio dupliqués (AllergySelect, DietTagSelect, IngredientPicker, UserPreferencesPage) en composants partagés CheckboxOption/RadioOption (components/ui/), et inverse le layout pour que la case soit à gauche du label. - Teinte la "zone de danger" de suppression de compte en rouge (fond + bordure), pas seulement le bouton. - Migre les icônes de navigation générale vers lucide-react (nav-icons.tsx devient un fichier de ré-export) ; les pictogrammes d'ingrédients métier restent en SVG custom (pas d'équivalents fins côté lucide). Vérifié : pnpm build, pnpm lint, pnpm --filter web e2e (43/43), et vérification visuelle manuelle (sidebar desktop/collapsed/mobile, light/dark). * feat(web): icônes d'ingrédients depuis foodiconpack.com + page de crédits - Remplace 19 des 22 pictogrammes génériques d'ingrédients par des icônes curées du pack gratuit "Common ingredient icons"/"Common Utensils" de foodiconpack.com (CC BY 4.0) : carotte, pomme, basilic, bœuf, poulet, saumon, crevette, riz, pois chiches, amandes, lait, cheddar, œufs, cannelle, miel, huile d'olive, bière, marmite, sucre. - BREAD/DOUGH/SPROUT restent en SVG custom : pas d'équivalent net dans le pack (packs "ingrédients"/"ustensiles"/"plats"/"boissons" vérifiés). Architecture inchangée : `icon` reste un enum de 22 valeurs partagées en base (pas de migration, pas de mapping par ingrédient — cf. le commentaire du fichier sur l'historique emoji→enum générique). - Nouveau wrapper FilledIcon (fill="currentColor", viewBox 2048) à côté du wrapper Icon existant (stroke) — les deux stylent au même endroit via CSS, donc le mélange des 19+3 icônes reste visuellement homogène. - Ajoute /parametres/credits (CreditsPage) créditant foodiconpack.com et liant la licence CC BY 4.0, requis par la licence des icônes utilisées ; nouvelle entrée de nav "Crédits" (icône lucide Info). Vérifié : pnpm build, pnpm lint, pnpm --filter web e2e (43/43), et vérification visuelle (grille des 22 icônes dans le picker, page crédits). * feat(web,api): zone dangereuse rouge, préférences élargies, onglet favoris par défaut, e2e recettes, catalogue en uid+i18n - Zone dangereuse (compte) : le bouton "Supprimer mon compte" est rouge. - Pages préférences/paramétrage : contenu centré et élargi (32rem -> 56rem) au lieu de coller à gauche sur un écran large. - Page recettes : l'onglet "Favoris" est sélectionné par défaut. - Ajout de apps/web/cypress/e2e/recipes.cy.ts (onglets, recherche, sélection master-detail, favori, suppression, lien nouvelle recette). - Catalogue de référence (ingrédients/régimes/allergènes) : la colonne `name` (le libellé français, utilisé comme clé unique) devient `key`, un slug stable et opaque au sens produit (ex. "vegetarien", "boeuf_hache"). Le libellé lui-même déménage entièrement côté client, dans apps/web/src/locales/fr/translation.json sous le namespace `catalog.*`, résolu via `t(\`catalog.ingredients.${key}\`)` etc. — même schéma que IngredientCategory/IngredientSubcategory. Migration Prisma (rename + backfill des ~456 lignes déjà seedées), seed/service/tests API et composants web mis à jour en conséquence. - apps/api/src/utils/slugify.ts + scripts/generate-catalog-i18n.ts (regénère le fichier de traduction depuis reference-seed-data.ts). - 102 tests Mocha + 32 scénarios Cucumber passent contre la base migrée. Note : cypress run plante dans cet environnement (le processus GPU Chromium/Electron crash même headless, indépendamment des flags) — les recipes.cy.ts n'ont pas pu être exécutés ici ; vérifiés par lecture du code source des composants visés et par un passage manuel dans le navigateur de prévisualisation. Co-Authored-By: Claude Sonnet 5 * 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 * fix(web): crypto.randomUUID plante hors contexte sécurisé, empêchant d'associer un ingrédient Écran noir + "TypeError: crypto.randomUUID is not a function" au clic sur une carte d'ingrédient dans le formulaire de recette. crypto.randomUUID() n'est défini que dans un "contexte sécurisé" (https, ou littéralement le host "localhost") — il est absent sur une IP locale (test sur un vrai appareil), dans une WebView Capacitor (l'enrobage mobile prévu pour cette app), ou en http sur un vrai domaine. RecipeFormPage/StepListEditor s'en servaient pour générer l'identité React (`key`) de chaque ligne d'ingrédient/étape en brouillon. - apps/web/src/lib/client-key.ts : remplace par un générateur qui ne touche jamais `crypto` — un compteur + Math.random suffit, cette valeur n'a besoin d'être unique que le temps de la session de rendu, jamais envoyée au serveur. - apps/web/cypress/e2e/recipe-form.cy.ts : couvre l'association d'un ingrédient (recherche, sélection, exclusion du picker une fois sélectionné, retrait), la création et l'édition d'une recette, et un test de non-régression dédié qui supprime crypto.randomUUID avant le chargement de la page (comme le ferait un vrai contexte non sécurisé) pour vérifier que l'ajout de plusieurs ingrédients/étapes ne plante plus. Vérifié en direct dans le navigateur de prévisualisation en supprimant crypto.randomUUID à la main (reproduit le crash), puis en confirmant que l'ajout d'ingrédient fonctionne à nouveau après le correctif. cypress run ne peut toujours pas s'exécuter dans cet environnement (voir le commit précédent) — non exécutés avec Cypress lui-même, mais vérifiés par lecture des sélecteurs réels et rejoués à la main dans le navigateur. Co-Authored-By: Claude Sonnet 5 * fix(ci): corrige les specs Cypress cassées par le refactor uid+i18n, applique biome - onboarding.cy.ts / preferences.cy.ts / recipes.cy.ts mockaient encore GET /reference/diets|allergies avec l'ancienne forme {id, name}. Depuis les deux derniers commits l'API renvoie {id, key} (uid anglais) et le composant résout le libellé via i18n (t(`catalog.diets.${key}`)) — avec key manquant, ça affichait littéralement "catalog.diets.undefined" au lieu de "Végétarien"/"Omnivore"/etc., faisant échouer cy.select()/ cy.contains() dans ces 3 specs. Corrigé pour mocker {key: "vegetarian"}, {key: "peanuts"}, etc. - recipes.cy.ts : le test "shows a not-found message" utilisait le mauvais code d'erreur (4041 au lieu de ErrorCode.RECIPE_NOT_FOUND = 4045), donc RecipeDetailPanel tombait dans son état d'erreur générique au lieu du message "Cette recette n'existe pas." — bug dans mon propre test, sans rapport avec le refactor. - pnpm lint (biome) : les fichiers touchés par le refactor précédent avaient quelques soucis de formatage/tri d'imports (des sed multi- fichiers, pas d'édition via l'outil habituel) — corrigés par `biome check --write`. Vérifié : ces 3 specs + recipe-form.cy.ts passent maintenant dans le job CI GitHub Actions (Linux, Cypress s'y exécute réellement — contrairement à cet environnement Windows sandboxé, voir les commits précédents) ; 102 tests Mocha + 32 scénarios Cucumber toujours au vert en local. Co-Authored-By: Claude Sonnet 5 --------- Co-authored-by: Claude Sonnet 5 --- .gitignore | 2 +- .../step-definitions/profile.steps.ts | 16 +- .../features/step-definitions/recipe.steps.ts | 17 +- .../step-definitions/reference.steps.ts | 13 +- .../migration.sql | 480 +++++++++++++++ .../migration.sql | 465 +++++++++++++++ apps/api/prisma/schema.prisma | 32 +- apps/api/scripts/generate-catalog-i18n.ts | 49 ++ apps/api/scripts/validate-catalog-en-keys.ts | 46 ++ apps/api/src/db/catalog-en-keys.ts | 557 ++++++++++++++++++ apps/api/src/db/reference-seed-data.ts | 75 ++- apps/api/src/modules/recipe/recipe.service.ts | 10 +- .../modules/reference/reference.service.ts | 44 +- apps/api/src/utils/slugify.ts | 26 + apps/api/test/profile.test.ts | 31 +- apps/api/test/recipe.test.ts | 23 +- apps/api/test/reference.test.ts | 33 +- apps/web/cypress/e2e/onboarding.cy.ts | 8 +- apps/web/cypress/e2e/preferences.cy.ts | 8 +- apps/web/cypress/e2e/recipe-form.cy.ts | 178 ++++++ apps/web/cypress/e2e/recipes.cy.ts | 241 ++++++++ apps/web/package.json | 3 +- apps/web/src/App.tsx | 6 +- apps/web/src/components/ui/Checkbox.tsx | 41 ++ apps/web/src/components/ui/Radio.tsx | 38 ++ .../src/features/profile/AllergySelect.tsx | 21 +- apps/web/src/features/profile/DietSelect.tsx | 2 +- .../profile/DislikedIngredientsField.tsx | 2 +- .../src/features/recipes/AllergenBadges.tsx | 4 +- apps/web/src/features/recipes/DietBadges.tsx | 4 +- .../src/features/recipes/DietTagSelect.tsx | 9 +- .../features/recipes/FavoriteStarButton.tsx | 2 +- .../src/features/recipes/IngredientPicker.tsx | 35 +- .../src/features/recipes/IngredientRow.tsx | 2 +- .../features/recipes/RecipeDetailPanel.tsx | 2 +- apps/web/src/features/recipes/RecipeTabs.tsx | 5 +- .../src/features/recipes/StepListEditor.tsx | 3 +- .../src/features/recipes/ingredient-icons.tsx | 278 +++++---- apps/web/src/layouts/AppLayout.scss | 22 + apps/web/src/layouts/AppLayout.tsx | 13 +- apps/web/src/layouts/nav-icons.tsx | 158 +---- apps/web/src/lib/client-key.ts | 24 + apps/web/src/locales/fr/translation.json | 475 ++++++++++++++- apps/web/src/pages/RecipeFormPage.tsx | 7 +- apps/web/src/pages/RecipesPage.tsx | 2 +- apps/web/src/pages/settings/CreditsPage.tsx | 37 ++ .../pages/settings/UserPreferencesPage.tsx | 22 +- .../src/pages/settings/settings-pages.scss | 25 +- apps/web/src/styles/global.scss | 25 +- apps/web/src/vite-env.d.ts | 5 + apps/web/vite.config.ts | 11 + packages/shared/src/types/reference.ts | 26 +- pnpm-lock.yaml | 12 + 53 files changed, 3221 insertions(+), 454 deletions(-) create mode 100644 apps/api/prisma/migrations/20260818190000_catalog_labels_to_keys/migration.sql create mode 100644 apps/api/prisma/migrations/20260818193000_catalog_keys_to_english/migration.sql create mode 100644 apps/api/scripts/generate-catalog-i18n.ts create mode 100644 apps/api/scripts/validate-catalog-en-keys.ts create mode 100644 apps/api/src/db/catalog-en-keys.ts create mode 100644 apps/api/src/utils/slugify.ts create mode 100644 apps/web/cypress/e2e/recipe-form.cy.ts create mode 100644 apps/web/cypress/e2e/recipes.cy.ts create mode 100644 apps/web/src/components/ui/Checkbox.tsx create mode 100644 apps/web/src/components/ui/Radio.tsx create mode 100644 apps/web/src/lib/client-key.ts create mode 100644 apps/web/src/pages/settings/CreditsPage.tsx create mode 100644 apps/web/src/vite-env.d.ts diff --git a/.gitignore b/.gitignore index a49f9c9..bf8131d 100644 --- a/.gitignore +++ b/.gitignore @@ -149,4 +149,4 @@ Projet batch cooking.pdf tmp-mockups/ # IA -.claude/ \ No newline at end of file +.claude/ diff --git a/apps/api/features/step-definitions/profile.steps.ts b/apps/api/features/step-definitions/profile.steps.ts index f9e432f..1503d92 100644 --- a/apps/api/features/step-definitions/profile.steps.ts +++ b/apps/api/features/step-definitions/profile.steps.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import { Then, When } from "@cucumber/cucumber"; +import { getEnglishKey } from "../../src/db/catalog-en-keys.js"; import { prisma } from "../../src/db/prisma.js"; import type { CustomWorld } from "../support/world.js"; @@ -11,25 +12,32 @@ function splitNames(names: string): string[] { .filter(Boolean); } -/** Resolves allergen names (Category.name) to their Allergy id — see reference.service.ts for why the name lives on Category, not Allergy. */ +/** + * Resolves allergen names (as written in a `.feature` file, e.g. + * "Arachides") to their Allergy id — scenarios still name allergens by their + * French label for readability, so this slugifies before matching against + * `Category.key` (see reference.service.ts for why the key lives on + * Category, not Allergy). + */ async function allergyIdsFor(names: string[]): Promise { const allergies = await prisma.allergy.findMany({ include: { category: true } }); return names.map((name) => { - const match = allergies.find((allergy) => allergy.category.name === name); + const key = getEnglishKey(name); + const match = allergies.find((allergy) => allergy.category.key === key); if (!match) throw new Error(`No seeded allergen named "${name}"`); return match.id; }); } When("I set my regime to {string}", async function (this: CustomWorld, dietName: string) { - const diet = await prisma.diet.findFirstOrThrow({ where: { name: dietName } }); + const diet = await prisma.diet.findFirstOrThrow({ where: { key: getEnglishKey(dietName) } }); this.response = await this.agent.patch("/profile/diet").send({ dietId: diet.id }); }); Then( "my profile's regime should be {string}", async function (this: CustomWorld, dietName: string) { - const diet = await prisma.diet.findFirstOrThrow({ where: { name: dietName } }); + const diet = await prisma.diet.findFirstOrThrow({ where: { key: getEnglishKey(dietName) } }); assert.equal(this.response.body.dietId, diet.id); }, ); diff --git a/apps/api/features/step-definitions/recipe.steps.ts b/apps/api/features/step-definitions/recipe.steps.ts index 020f99f..b18ccbc 100644 --- a/apps/api/features/step-definitions/recipe.steps.ts +++ b/apps/api/features/step-definitions/recipe.steps.ts @@ -1,12 +1,20 @@ import assert from "node:assert/strict"; import { Given, Then, When } from "@cucumber/cucumber"; +import { getEnglishKey } from "../../src/db/catalog-en-keys.js"; import { prisma } from "../../src/db/prisma.js"; import { TEST_REFERENCE_DATE } from "../../test-support/reference-date.js"; import type { CustomWorld } from "../support/world.js"; -/** Resolves a reference ingredient by its seeded name — every scenario below names an ingredient by its `reference-seed-data.ts` name, never a raw id. */ +/** + * Resolves a reference ingredient by its seeded French name — every + * scenario below names an ingredient by its `reference-seed-data.ts` name, + * never a raw id or its slug `key` directly, so this slugifies before + * matching. + */ async function findIngredientId(name: string): Promise { - const ingredient = await prisma.ingredient.findFirstOrThrow({ where: { name } }); + const ingredient = await prisma.ingredient.findFirstOrThrow({ + where: { key: getEnglishKey(name) }, + }); return ingredient.id; } @@ -55,10 +63,11 @@ Then( "the created recipe should have ingredient {string} and step {string}", function (this: CustomWorld, ingredientName: string, step: string) { const body = this.response.body as { - ingredients: Array<{ ingredient: { name: string } }>; + ingredients: Array<{ ingredient: { key: string } }>; steps: Array<{ description: string }>; }; - assert.ok(body.ingredients.some((line) => line.ingredient.name === ingredientName)); + const expectedKey = getEnglishKey(ingredientName); + assert.ok(body.ingredients.some((line) => line.ingredient.key === expectedKey)); assert.ok(body.steps.some((s) => s.description === step)); }, ); diff --git a/apps/api/features/step-definitions/reference.steps.ts b/apps/api/features/step-definitions/reference.steps.ts index e4e7c1b..82fb3c7 100644 --- a/apps/api/features/step-definitions/reference.steps.ts +++ b/apps/api/features/step-definitions/reference.steps.ts @@ -1,11 +1,20 @@ import assert from "node:assert/strict"; import { Then } from "@cucumber/cucumber"; +import { getEnglishKey } from "../../src/db/catalog-en-keys.js"; import type { CustomWorld } from "../support/world.js"; +// The feature file still names an item by its French label, for +// readability — the response itself carries only the slug `key` (see +// reference.service.ts), so this slugifies the expected label before +// comparing. Then( "the reference list response should include {string}", function (this: CustomWorld, name: string) { - const names = (this.response.body as Array<{ name: string }>).map((item) => item.name); - assert.ok(names.includes(name), `expected ${JSON.stringify(names)} to include "${name}"`); + const keys = (this.response.body as Array<{ key: string }>).map((item) => item.key); + const expectedKey = getEnglishKey(name); + assert.ok( + keys.includes(expectedKey), + `expected ${JSON.stringify(keys)} to include "${expectedKey}"`, + ); }, ); diff --git a/apps/api/prisma/migrations/20260818190000_catalog_labels_to_keys/migration.sql b/apps/api/prisma/migrations/20260818190000_catalog_labels_to_keys/migration.sql new file mode 100644 index 0000000..af2b0b3 --- /dev/null +++ b/apps/api/prisma/migrations/20260818190000_catalog_labels_to_keys/migration.sql @@ -0,0 +1,480 @@ +-- Renames the reference-data label column to a stable slug key on Diet, +-- Category (allergens), and Ingredient — see `utils/slugify.ts` and +-- `scripts/generate-catalog-i18n.ts`. The display label these columns used +-- to hold moves to `apps/web`'s `locales/fr/translation.json` (`catalog.*` +-- namespace) instead. + +ALTER TABLE "diet" RENAME COLUMN "name" TO "key"; +ALTER INDEX "diet_name_key" RENAME TO "diet_key_key"; + +ALTER TABLE "category" RENAME COLUMN "name" TO "key"; +ALTER INDEX "category_name_key" RENAME TO "category_key_key"; + +ALTER TABLE "ingredients" RENAME COLUMN "name" TO "key"; +ALTER INDEX "ingredients_name_key" RENAME TO "ingredients_key_key"; + +-- Auto-generated by scripts/generate-catalog-i18n.ts — do not hand-edit. +-- Backfills the "key" column (just renamed from "name" by this migration's +-- preceding statement, so it still holds the old French label) to its slug +-- value, for every row already seeded in a database this migration runs +-- against. A fresh database has none of these rows yet (the seed script +-- inserts by "key" from the start), so this is a no-op there. + +UPDATE "diet" SET "key" = 'omnivore' WHERE "key" = 'Omnivore'; +UPDATE "diet" SET "key" = 'vegetarien' WHERE "key" = 'Végétarien'; +UPDATE "diet" SET "key" = 'vegan' WHERE "key" = 'Végan'; +UPDATE "diet" SET "key" = 'pescetarien' WHERE "key" = 'Pescétarien'; +UPDATE "diet" SET "key" = 'sans_gluten' WHERE "key" = 'Sans gluten'; + +UPDATE "category" SET "key" = 'gluten' WHERE "key" = 'Gluten'; +UPDATE "category" SET "key" = 'crustaces' WHERE "key" = 'Crustacés'; +UPDATE "category" SET "key" = 'oeufs' WHERE "key" = 'Œufs'; +UPDATE "category" SET "key" = 'poissons' WHERE "key" = 'Poissons'; +UPDATE "category" SET "key" = 'arachides' WHERE "key" = 'Arachides'; +UPDATE "category" SET "key" = 'soja' WHERE "key" = 'Soja'; +UPDATE "category" SET "key" = 'lait' WHERE "key" = 'Lait'; +UPDATE "category" SET "key" = 'fruits_a_coque' WHERE "key" = 'Fruits à coque'; +UPDATE "category" SET "key" = 'celeri' WHERE "key" = 'Céleri'; +UPDATE "category" SET "key" = 'moutarde' WHERE "key" = 'Moutarde'; +UPDATE "category" SET "key" = 'graines_de_sesame' WHERE "key" = 'Graines de sésame'; +UPDATE "category" SET "key" = 'sulfites' WHERE "key" = 'Sulfites'; +UPDATE "category" SET "key" = 'lupin' WHERE "key" = 'Lupin'; +UPDATE "category" SET "key" = 'mollusques' WHERE "key" = 'Mollusques'; + +UPDATE "ingredients" SET "key" = 'tomate' WHERE "key" = 'Tomate'; +UPDATE "ingredients" SET "key" = 'oignon' WHERE "key" = 'Oignon'; +UPDATE "ingredients" SET "key" = 'echalote' WHERE "key" = 'Échalote'; +UPDATE "ingredients" SET "key" = 'ail' WHERE "key" = 'Ail'; +UPDATE "ingredients" SET "key" = 'carotte' WHERE "key" = 'Carotte'; +UPDATE "ingredients" SET "key" = 'courgette' WHERE "key" = 'Courgette'; +UPDATE "ingredients" SET "key" = 'concombre' WHERE "key" = 'Concombre'; +UPDATE "ingredients" SET "key" = 'cornichons' WHERE "key" = 'Cornichons'; +UPDATE "ingredients" SET "key" = 'poivron' WHERE "key" = 'Poivron'; +UPDATE "ingredients" SET "key" = 'champignon' WHERE "key" = 'Champignon'; +UPDATE "ingredients" SET "key" = 'cepes' WHERE "key" = 'Cèpes'; +UPDATE "ingredients" SET "key" = 'aubergine' WHERE "key" = 'Aubergine'; +UPDATE "ingredients" SET "key" = 'brocoli' WHERE "key" = 'Brocoli'; +UPDATE "ingredients" SET "key" = 'chou_fleur' WHERE "key" = 'Chou-fleur'; +UPDATE "ingredients" SET "key" = 'chou_blanc' WHERE "key" = 'Chou blanc'; +UPDATE "ingredients" SET "key" = 'chou_rouge' WHERE "key" = 'Chou rouge'; +UPDATE "ingredients" SET "key" = 'chou_de_bruxelles' WHERE "key" = 'Chou de Bruxelles'; +UPDATE "ingredients" SET "key" = 'epinard' WHERE "key" = 'Épinard'; +UPDATE "ingredients" SET "key" = 'blette' WHERE "key" = 'Blette'; +UPDATE "ingredients" SET "key" = 'salade' WHERE "key" = 'Salade'; +UPDATE "ingredients" SET "key" = 'roquette' WHERE "key" = 'Roquette'; +UPDATE "ingredients" SET "key" = 'cresson' WHERE "key" = 'Cresson'; +UPDATE "ingredients" SET "key" = 'poireau' WHERE "key" = 'Poireau'; +UPDATE "ingredients" SET "key" = 'celeri' WHERE "key" = 'Céleri'; +UPDATE "ingredients" SET "key" = 'radis' WHERE "key" = 'Radis'; +UPDATE "ingredients" SET "key" = 'betterave' WHERE "key" = 'Betterave'; +UPDATE "ingredients" SET "key" = 'navet' WHERE "key" = 'Navet'; +UPDATE "ingredients" SET "key" = 'panais' WHERE "key" = 'Panais'; +UPDATE "ingredients" SET "key" = 'haricot_vert' WHERE "key" = 'Haricot vert'; +UPDATE "ingredients" SET "key" = 'petit_pois' WHERE "key" = 'Petit pois'; +UPDATE "ingredients" SET "key" = 'mais' WHERE "key" = 'Maïs'; +UPDATE "ingredients" SET "key" = 'artichaut' WHERE "key" = 'Artichaut'; +UPDATE "ingredients" SET "key" = 'fenouil' WHERE "key" = 'Fenouil'; +UPDATE "ingredients" SET "key" = 'endive' WHERE "key" = 'Endive'; +UPDATE "ingredients" SET "key" = 'potiron' WHERE "key" = 'Potiron'; +UPDATE "ingredients" SET "key" = 'butternut' WHERE "key" = 'Butternut'; +UPDATE "ingredients" SET "key" = 'asperge' WHERE "key" = 'Asperge'; +UPDATE "ingredients" SET "key" = 'avocat' WHERE "key" = 'Avocat'; +UPDATE "ingredients" SET "key" = 'pomme_de_terre' WHERE "key" = 'Pomme de terre'; +UPDATE "ingredients" SET "key" = 'patate_douce' WHERE "key" = 'Patate douce'; +UPDATE "ingredients" SET "key" = 'tomates_cerises' WHERE "key" = 'Tomates cerises'; +UPDATE "ingredients" SET "key" = 'pak_choi' WHERE "key" = 'Pak-choï'; +UPDATE "ingredients" SET "key" = 'germes_de_soja' WHERE "key" = 'Germes de soja'; +UPDATE "ingredients" SET "key" = 'shiitake' WHERE "key" = 'Shiitake'; +UPDATE "ingredients" SET "key" = 'daikon' WHERE "key" = 'Daikon'; +UPDATE "ingredients" SET "key" = 'piment_vert_frais' WHERE "key" = 'Piment vert frais'; +UPDATE "ingredients" SET "key" = 'citron' WHERE "key" = 'Citron'; +UPDATE "ingredients" SET "key" = 'citron_vert' WHERE "key" = 'Citron vert'; +UPDATE "ingredients" SET "key" = 'pomme' WHERE "key" = 'Pomme'; +UPDATE "ingredients" SET "key" = 'poire' WHERE "key" = 'Poire'; +UPDATE "ingredients" SET "key" = 'banane' WHERE "key" = 'Banane'; +UPDATE "ingredients" SET "key" = 'orange' WHERE "key" = 'Orange'; +UPDATE "ingredients" SET "key" = 'clementine' WHERE "key" = 'Clémentine'; +UPDATE "ingredients" SET "key" = 'pamplemousse' WHERE "key" = 'Pamplemousse'; +UPDATE "ingredients" SET "key" = 'fraise' WHERE "key" = 'Fraise'; +UPDATE "ingredients" SET "key" = 'framboise' WHERE "key" = 'Framboise'; +UPDATE "ingredients" SET "key" = 'myrtille' WHERE "key" = 'Myrtille'; +UPDATE "ingredients" SET "key" = 'mure' WHERE "key" = 'Mûre'; +UPDATE "ingredients" SET "key" = 'cerise' WHERE "key" = 'Cerise'; +UPDATE "ingredients" SET "key" = 'abricot' WHERE "key" = 'Abricot'; +UPDATE "ingredients" SET "key" = 'peche' WHERE "key" = 'Pêche'; +UPDATE "ingredients" SET "key" = 'prune' WHERE "key" = 'Prune'; +UPDATE "ingredients" SET "key" = 'raisin' WHERE "key" = 'Raisin'; +UPDATE "ingredients" SET "key" = 'melon' WHERE "key" = 'Melon'; +UPDATE "ingredients" SET "key" = 'pasteque' WHERE "key" = 'Pastèque'; +UPDATE "ingredients" SET "key" = 'ananas' WHERE "key" = 'Ananas'; +UPDATE "ingredients" SET "key" = 'mangue' WHERE "key" = 'Mangue'; +UPDATE "ingredients" SET "key" = 'kiwi' WHERE "key" = 'Kiwi'; +UPDATE "ingredients" SET "key" = 'figue' WHERE "key" = 'Figue'; +UPDATE "ingredients" SET "key" = 'datte' WHERE "key" = 'Datte'; +UPDATE "ingredients" SET "key" = 'litchi' WHERE "key" = 'Litchi'; +UPDATE "ingredients" SET "key" = 'grenade' WHERE "key" = 'Grenade'; +UPDATE "ingredients" SET "key" = 'rhubarbe' WHERE "key" = 'Rhubarbe'; +UPDATE "ingredients" SET "key" = 'coing' WHERE "key" = 'Coing'; +UPDATE "ingredients" SET "key" = 'basilic' WHERE "key" = 'Basilic'; +UPDATE "ingredients" SET "key" = 'persil' WHERE "key" = 'Persil'; +UPDATE "ingredients" SET "key" = 'thym' WHERE "key" = 'Thym'; +UPDATE "ingredients" SET "key" = 'romarin' WHERE "key" = 'Romarin'; +UPDATE "ingredients" SET "key" = 'laurier' WHERE "key" = 'Laurier'; +UPDATE "ingredients" SET "key" = 'ciboulette' WHERE "key" = 'Ciboulette'; +UPDATE "ingredients" SET "key" = 'coriandre_fraiche' WHERE "key" = 'Coriandre fraîche'; +UPDATE "ingredients" SET "key" = 'menthe' WHERE "key" = 'Menthe'; +UPDATE "ingredients" SET "key" = 'origan' WHERE "key" = 'Origan'; +UPDATE "ingredients" SET "key" = 'aneth' WHERE "key" = 'Aneth'; +UPDATE "ingredients" SET "key" = 'estragon' WHERE "key" = 'Estragon'; +UPDATE "ingredients" SET "key" = 'sarriette' WHERE "key" = 'Sarriette'; +UPDATE "ingredients" SET "key" = 'marjolaine' WHERE "key" = 'Marjolaine'; +UPDATE "ingredients" SET "key" = 'sauge' WHERE "key" = 'Sauge'; +UPDATE "ingredients" SET "key" = 'cerfeuil' WHERE "key" = 'Cerfeuil'; +UPDATE "ingredients" SET "key" = 'gingembre' WHERE "key" = 'Gingembre'; +UPDATE "ingredients" SET "key" = 'citronnelle' WHERE "key" = 'Citronnelle'; +UPDATE "ingredients" SET "key" = 'combava' WHERE "key" = 'Combava'; +UPDATE "ingredients" SET "key" = 'lapin' WHERE "key" = 'Lapin'; +UPDATE "ingredients" SET "key" = 'boeuf_hache' WHERE "key" = 'Bœuf haché'; +UPDATE "ingredients" SET "key" = 'steak_de_boeuf' WHERE "key" = 'Steak de bœuf'; +UPDATE "ingredients" SET "key" = 'roti_de_boeuf' WHERE "key" = 'Rôti de bœuf'; +UPDATE "ingredients" SET "key" = 'escalope_de_veau' WHERE "key" = 'Escalope de veau'; +UPDATE "ingredients" SET "key" = 'filet_mignon_de_porc' WHERE "key" = 'Filet mignon de porc'; +UPDATE "ingredients" SET "key" = 'cote_de_porc' WHERE "key" = 'Côte de porc'; +UPDATE "ingredients" SET "key" = 'agneau' WHERE "key" = 'Agneau'; +UPDATE "ingredients" SET "key" = 'gigot_d_agneau' WHERE "key" = 'Gigot d''agneau'; +UPDATE "ingredients" SET "key" = 'lardons' WHERE "key" = 'Lardons'; +UPDATE "ingredients" SET "key" = 'bacon' WHERE "key" = 'Bacon'; +UPDATE "ingredients" SET "key" = 'jambon_blanc' WHERE "key" = 'Jambon blanc'; +UPDATE "ingredients" SET "key" = 'jambon_cru' WHERE "key" = 'Jambon cru'; +UPDATE "ingredients" SET "key" = 'saucisse' WHERE "key" = 'Saucisse'; +UPDATE "ingredients" SET "key" = 'chorizo' WHERE "key" = 'Chorizo'; +UPDATE "ingredients" SET "key" = 'merguez' WHERE "key" = 'Merguez'; +UPDATE "ingredients" SET "key" = 'prosciutto' WHERE "key" = 'Prosciutto'; +UPDATE "ingredients" SET "key" = 'pancetta' WHERE "key" = 'Pancetta'; +UPDATE "ingredients" SET "key" = 'mortadelle' WHERE "key" = 'Mortadelle'; +UPDATE "ingredients" SET "key" = 'salami' WHERE "key" = 'Salami'; +UPDATE "ingredients" SET "key" = 'poulet' WHERE "key" = 'Poulet'; +UPDATE "ingredients" SET "key" = 'dinde' WHERE "key" = 'Dinde'; +UPDATE "ingredients" SET "key" = 'canard' WHERE "key" = 'Canard'; +UPDATE "ingredients" SET "key" = 'magret_de_canard' WHERE "key" = 'Magret de canard'; +UPDATE "ingredients" SET "key" = 'saumon' WHERE "key" = 'Saumon'; +UPDATE "ingredients" SET "key" = 'thon' WHERE "key" = 'Thon'; +UPDATE "ingredients" SET "key" = 'cabillaud' WHERE "key" = 'Cabillaud'; +UPDATE "ingredients" SET "key" = 'truite' WHERE "key" = 'Truite'; +UPDATE "ingredients" SET "key" = 'sardine' WHERE "key" = 'Sardine'; +UPDATE "ingredients" SET "key" = 'anchois' WHERE "key" = 'Anchois'; +UPDATE "ingredients" SET "key" = 'merlan' WHERE "key" = 'Merlan'; +UPDATE "ingredients" SET "key" = 'surimi' WHERE "key" = 'Surimi'; +UPDATE "ingredients" SET "key" = 'bar_loup_de_mer' WHERE "key" = 'Bar (loup de mer)'; +UPDATE "ingredients" SET "key" = 'dorade' WHERE "key" = 'Dorade'; +UPDATE "ingredients" SET "key" = 'sole' WHERE "key" = 'Sole'; +UPDATE "ingredients" SET "key" = 'turbot' WHERE "key" = 'Turbot'; +UPDATE "ingredients" SET "key" = 'merlu' WHERE "key" = 'Merlu'; +UPDATE "ingredients" SET "key" = 'colin' WHERE "key" = 'Colin'; +UPDATE "ingredients" SET "key" = 'lieu_noir' WHERE "key" = 'Lieu noir'; +UPDATE "ingredients" SET "key" = 'eglefin' WHERE "key" = 'Églefin'; +UPDATE "ingredients" SET "key" = 'maquereau' WHERE "key" = 'Maquereau'; +UPDATE "ingredients" SET "key" = 'hareng' WHERE "key" = 'Hareng'; +UPDATE "ingredients" SET "key" = 'rouget' WHERE "key" = 'Rouget'; +UPDATE "ingredients" SET "key" = 'raie' WHERE "key" = 'Raie'; +UPDATE "ingredients" SET "key" = 'lotte' WHERE "key" = 'Lotte'; +UPDATE "ingredients" SET "key" = 'fletan' WHERE "key" = 'Flétan'; +UPDATE "ingredients" SET "key" = 'espadon' WHERE "key" = 'Espadon'; +UPDATE "ingredients" SET "key" = 'carpe' WHERE "key" = 'Carpe'; +UPDATE "ingredients" SET "key" = 'brochet' WHERE "key" = 'Brochet'; +UPDATE "ingredients" SET "key" = 'perche' WHERE "key" = 'Perche'; +UPDATE "ingredients" SET "key" = 'tilapia' WHERE "key" = 'Tilapia'; +UPDATE "ingredients" SET "key" = 'panga' WHERE "key" = 'Panga'; +UPDATE "ingredients" SET "key" = 'saumon_fume' WHERE "key" = 'Saumon fumé'; +UPDATE "ingredients" SET "key" = 'poisson_seche' WHERE "key" = 'Poisson séché'; +UPDATE "ingredients" SET "key" = 'crevettes' WHERE "key" = 'Crevettes'; +UPDATE "ingredients" SET "key" = 'langoustines' WHERE "key" = 'Langoustines'; +UPDATE "ingredients" SET "key" = 'homard' WHERE "key" = 'Homard'; +UPDATE "ingredients" SET "key" = 'crabe' WHERE "key" = 'Crabe'; +UPDATE "ingredients" SET "key" = 'langouste' WHERE "key" = 'Langouste'; +UPDATE "ingredients" SET "key" = 'moules' WHERE "key" = 'Moules'; +UPDATE "ingredients" SET "key" = 'huitres' WHERE "key" = 'Huîtres'; +UPDATE "ingredients" SET "key" = 'saint_jacques' WHERE "key" = 'Saint-Jacques'; +UPDATE "ingredients" SET "key" = 'calamar' WHERE "key" = 'Calamar'; +UPDATE "ingredients" SET "key" = 'poulpe' WHERE "key" = 'Poulpe'; +UPDATE "ingredients" SET "key" = 'palourdes' WHERE "key" = 'Palourdes'; +UPDATE "ingredients" SET "key" = 'bulots' WHERE "key" = 'Bulots'; +UPDATE "ingredients" SET "key" = 'semoule' WHERE "key" = 'Semoule'; +UPDATE "ingredients" SET "key" = 'couscous' WHERE "key" = 'Couscous'; +UPDATE "ingredients" SET "key" = 'boulgour' WHERE "key" = 'Boulgour'; +UPDATE "ingredients" SET "key" = 'polenta' WHERE "key" = 'Polenta'; +UPDATE "ingredients" SET "key" = 'quinoa' WHERE "key" = 'Quinoa'; +UPDATE "ingredients" SET "key" = 'pates' WHERE "key" = 'Pâtes'; +UPDATE "ingredients" SET "key" = 'pates_completes' WHERE "key" = 'Pâtes complètes'; +UPDATE "ingredients" SET "key" = 'riz' WHERE "key" = 'Riz'; +UPDATE "ingredients" SET "key" = 'riz_basmati' WHERE "key" = 'Riz basmati'; +UPDATE "ingredients" SET "key" = 'riz_complet' WHERE "key" = 'Riz complet'; +UPDATE "ingredients" SET "key" = 'flocons_d_avoine' WHERE "key" = 'Flocons d''avoine'; +UPDATE "ingredients" SET "key" = 'spaghetti' WHERE "key" = 'Spaghetti'; +UPDATE "ingredients" SET "key" = 'penne' WHERE "key" = 'Penne'; +UPDATE "ingredients" SET "key" = 'tagliatelles' WHERE "key" = 'Tagliatelles'; +UPDATE "ingredients" SET "key" = 'lasagnes_feuilles' WHERE "key" = 'Lasagnes (feuilles)'; +UPDATE "ingredients" SET "key" = 'gnocchi' WHERE "key" = 'Gnocchi'; +UPDATE "ingredients" SET "key" = 'riz_arborio' WHERE "key" = 'Riz arborio'; +UPDATE "ingredients" SET "key" = 'nouilles_de_riz' WHERE "key" = 'Nouilles de riz'; +UPDATE "ingredients" SET "key" = 'nouilles_udon' WHERE "key" = 'Nouilles udon'; +UPDATE "ingredients" SET "key" = 'nouilles_soba' WHERE "key" = 'Nouilles soba'; +UPDATE "ingredients" SET "key" = 'nouilles_chinoises' WHERE "key" = 'Nouilles chinoises'; +UPDATE "ingredients" SET "key" = 'vermicelles_de_riz' WHERE "key" = 'Vermicelles de riz'; +UPDATE "ingredients" SET "key" = 'vermicelles_de_soja' WHERE "key" = 'Vermicelles de soja'; +UPDATE "ingredients" SET "key" = 'riz_gluant' WHERE "key" = 'Riz gluant'; +UPDATE "ingredients" SET "key" = 'riz_a_sushi' WHERE "key" = 'Riz à sushi'; +UPDATE "ingredients" SET "key" = 'riz_jasmin' WHERE "key" = 'Riz jasmin'; +UPDATE "ingredients" SET "key" = 'lentilles_vertes' WHERE "key" = 'Lentilles vertes'; +UPDATE "ingredients" SET "key" = 'lentilles_corail' WHERE "key" = 'Lentilles corail'; +UPDATE "ingredients" SET "key" = 'pois_chiches' WHERE "key" = 'Pois chiches'; +UPDATE "ingredients" SET "key" = 'haricots_blancs' WHERE "key" = 'Haricots blancs'; +UPDATE "ingredients" SET "key" = 'haricots_rouges' WHERE "key" = 'Haricots rouges'; +UPDATE "ingredients" SET "key" = 'haricots_noirs' WHERE "key" = 'Haricots noirs'; +UPDATE "ingredients" SET "key" = 'pois_casses' WHERE "key" = 'Pois cassés'; +UPDATE "ingredients" SET "key" = 'feves' WHERE "key" = 'Fèves'; +UPDATE "ingredients" SET "key" = 'edamame' WHERE "key" = 'Edamame'; +UPDATE "ingredients" SET "key" = 'haricots_pinto' WHERE "key" = 'Haricots pinto'; +UPDATE "ingredients" SET "key" = 'cacahuetes' WHERE "key" = 'Cacahuètes'; +UPDATE "ingredients" SET "key" = 'amandes' WHERE "key" = 'Amandes'; +UPDATE "ingredients" SET "key" = 'noix' WHERE "key" = 'Noix'; +UPDATE "ingredients" SET "key" = 'noisettes' WHERE "key" = 'Noisettes'; +UPDATE "ingredients" SET "key" = 'noix_de_cajou' WHERE "key" = 'Noix de cajou'; +UPDATE "ingredients" SET "key" = 'pistaches' WHERE "key" = 'Pistaches'; +UPDATE "ingredients" SET "key" = 'noix_de_pecan' WHERE "key" = 'Noix de pécan'; +UPDATE "ingredients" SET "key" = 'poudre_d_amande' WHERE "key" = 'Poudre d''amande'; +UPDATE "ingredients" SET "key" = 'pignons_de_pin' WHERE "key" = 'Pignons de pin'; +UPDATE "ingredients" SET "key" = 'graines_de_tournesol' WHERE "key" = 'Graines de tournesol'; +UPDATE "ingredients" SET "key" = 'graines_de_courge' WHERE "key" = 'Graines de courge'; +UPDATE "ingredients" SET "key" = 'noix_de_coco_rapee' WHERE "key" = 'Noix de coco râpée'; +UPDATE "ingredients" SET "key" = 'raisins_secs' WHERE "key" = 'Raisins secs'; +UPDATE "ingredients" SET "key" = 'pruneaux' WHERE "key" = 'Pruneaux'; +UPDATE "ingredients" SET "key" = 'abricots_secs' WHERE "key" = 'Abricots secs'; +UPDATE "ingredients" SET "key" = 'graines_de_sesame' WHERE "key" = 'Graines de sésame'; +UPDATE "ingredients" SET "key" = 'champignons_noirs' WHERE "key" = 'Champignons noirs'; +UPDATE "ingredients" SET "key" = 'algue_nori' WHERE "key" = 'Algue nori'; +UPDATE "ingredients" SET "key" = 'algue_wakame' WHERE "key" = 'Algue wakamé'; +UPDATE "ingredients" SET "key" = 'algue_kombu' WHERE "key" = 'Algue kombu'; +UPDATE "ingredients" SET "key" = 'pousses_de_bambou' WHERE "key" = 'Pousses de bambou'; +UPDATE "ingredients" SET "key" = 'chataignes_d_eau' WHERE "key" = 'Châtaignes d''eau'; +UPDATE "ingredients" SET "key" = 'pain' WHERE "key" = 'Pain'; +UPDATE "ingredients" SET "key" = 'pain_de_mie' WHERE "key" = 'Pain de mie'; +UPDATE "ingredients" SET "key" = 'pain_complet' WHERE "key" = 'Pain complet'; +UPDATE "ingredients" SET "key" = 'baguette' WHERE "key" = 'Baguette'; +UPDATE "ingredients" SET "key" = 'pain_de_seigle' WHERE "key" = 'Pain de seigle'; +UPDATE "ingredients" SET "key" = 'chapelure' WHERE "key" = 'Chapelure'; +UPDATE "ingredients" SET "key" = 'pain_a_burger' WHERE "key" = 'Pain à burger'; +UPDATE "ingredients" SET "key" = 'pain_brioche' WHERE "key" = 'Pain brioché'; +UPDATE "ingredients" SET "key" = 'pain_a_hot_dog' WHERE "key" = 'Pain à hot-dog'; +UPDATE "ingredients" SET "key" = 'pain_pita' WHERE "key" = 'Pain pita'; +UPDATE "ingredients" SET "key" = 'pain_bagel' WHERE "key" = 'Pain bagel'; +UPDATE "ingredients" SET "key" = 'naan' WHERE "key" = 'Naan'; +UPDATE "ingredients" SET "key" = 'pain_wrap' WHERE "key" = 'Pain wrap'; +UPDATE "ingredients" SET "key" = 'pain_viennois' WHERE "key" = 'Pain viennois'; +UPDATE "ingredients" SET "key" = 'pain_de_campagne' WHERE "key" = 'Pain de campagne'; +UPDATE "ingredients" SET "key" = 'pain_aux_cereales' WHERE "key" = 'Pain aux céréales'; +UPDATE "ingredients" SET "key" = 'petit_pain' WHERE "key" = 'Petit pain'; +UPDATE "ingredients" SET "key" = 'pain_suedois' WHERE "key" = 'Pain suédois'; +UPDATE "ingredients" SET "key" = 'pain_sans_gluten' WHERE "key" = 'Pain sans gluten'; +UPDATE "ingredients" SET "key" = 'biscotte' WHERE "key" = 'Biscotte'; +UPDATE "ingredients" SET "key" = 'croutons' WHERE "key" = 'Croûtons'; +UPDATE "ingredients" SET "key" = 'focaccia' WHERE "key" = 'Focaccia'; +UPDATE "ingredients" SET "key" = 'ciabatta' WHERE "key" = 'Ciabatta'; +UPDATE "ingredients" SET "key" = 'tortilla_de_mais' WHERE "key" = 'Tortilla de maïs'; +UPDATE "ingredients" SET "key" = 'tortilla_de_ble' WHERE "key" = 'Tortilla de blé'; +UPDATE "ingredients" SET "key" = 'pate_feuilletee' WHERE "key" = 'Pâte feuilletée'; +UPDATE "ingredients" SET "key" = 'pate_brisee' WHERE "key" = 'Pâte brisée'; +UPDATE "ingredients" SET "key" = 'pate_a_pizza' WHERE "key" = 'Pâte à pizza'; +UPDATE "ingredients" SET "key" = 'pate_a_tarte_sablee' WHERE "key" = 'Pâte à tarte sablée'; +UPDATE "ingredients" SET "key" = 'lait' WHERE "key" = 'Lait'; +UPDATE "ingredients" SET "key" = 'beurre' WHERE "key" = 'Beurre'; +UPDATE "ingredients" SET "key" = 'creme_fraiche' WHERE "key" = 'Crème fraîche'; +UPDATE "ingredients" SET "key" = 'creme_liquide' WHERE "key" = 'Crème liquide'; +UPDATE "ingredients" SET "key" = 'fromage' WHERE "key" = 'Fromage'; +UPDATE "ingredients" SET "key" = 'emmental' WHERE "key" = 'Emmental'; +UPDATE "ingredients" SET "key" = 'gruyere' WHERE "key" = 'Gruyère'; +UPDATE "ingredients" SET "key" = 'parmesan' WHERE "key" = 'Parmesan'; +UPDATE "ingredients" SET "key" = 'mozzarella' WHERE "key" = 'Mozzarella'; +UPDATE "ingredients" SET "key" = 'chevre_fromage' WHERE "key" = 'Chèvre (fromage)'; +UPDATE "ingredients" SET "key" = 'feta' WHERE "key" = 'Feta'; +UPDATE "ingredients" SET "key" = 'comte' WHERE "key" = 'Comté'; +UPDATE "ingredients" SET "key" = 'fromage_blanc' WHERE "key" = 'Fromage blanc'; +UPDATE "ingredients" SET "key" = 'mascarpone' WHERE "key" = 'Mascarpone'; +UPDATE "ingredients" SET "key" = 'yaourt' WHERE "key" = 'Yaourt'; +UPDATE "ingredients" SET "key" = 'burrata' WHERE "key" = 'Burrata'; +UPDATE "ingredients" SET "key" = 'ricotta' WHERE "key" = 'Ricotta'; +UPDATE "ingredients" SET "key" = 'pecorino' WHERE "key" = 'Pecorino'; +UPDATE "ingredients" SET "key" = 'gorgonzola' WHERE "key" = 'Gorgonzola'; +UPDATE "ingredients" SET "key" = 'cheddar' WHERE "key" = 'Cheddar'; +UPDATE "ingredients" SET "key" = 'oeuf' WHERE "key" = 'Œuf'; +UPDATE "ingredients" SET "key" = 'lait_de_coco' WHERE "key" = 'Lait de coco'; +UPDATE "ingredients" SET "key" = 'creme_de_coco' WHERE "key" = 'Crème de coco'; +UPDATE "ingredients" SET "key" = 'lait_d_amande' WHERE "key" = 'Lait d''amande'; +UPDATE "ingredients" SET "key" = 'lait_d_avoine' WHERE "key" = 'Lait d''avoine'; +UPDATE "ingredients" SET "key" = 'tofu' WHERE "key" = 'Tofu'; +UPDATE "ingredients" SET "key" = 'tofu_soyeux' WHERE "key" = 'Tofu soyeux'; +UPDATE "ingredients" SET "key" = 'herbes_de_provence' WHERE "key" = 'Herbes de Provence'; +UPDATE "ingredients" SET "key" = 'poivre_noir' WHERE "key" = 'Poivre noir'; +UPDATE "ingredients" SET "key" = 'paprika' WHERE "key" = 'Paprika'; +UPDATE "ingredients" SET "key" = 'piment_d_espelette' WHERE "key" = 'Piment d''Espelette'; +UPDATE "ingredients" SET "key" = 'piment_de_cayenne' WHERE "key" = 'Piment de Cayenne'; +UPDATE "ingredients" SET "key" = 'cumin' WHERE "key" = 'Cumin'; +UPDATE "ingredients" SET "key" = 'curry_poudre' WHERE "key" = 'Curry (poudre)'; +UPDATE "ingredients" SET "key" = 'curcuma' WHERE "key" = 'Curcuma'; +UPDATE "ingredients" SET "key" = 'cannelle' WHERE "key" = 'Cannelle'; +UPDATE "ingredients" SET "key" = 'muscade' WHERE "key" = 'Muscade'; +UPDATE "ingredients" SET "key" = 'safran' WHERE "key" = 'Safran'; +UPDATE "ingredients" SET "key" = 'clou_de_girofle' WHERE "key" = 'Clou de girofle'; +UPDATE "ingredients" SET "key" = 'vanille_gousse' WHERE "key" = 'Vanille (gousse)'; +UPDATE "ingredients" SET "key" = 'poivre_blanc' WHERE "key" = 'Poivre blanc'; +UPDATE "ingredients" SET "key" = 'poivre_rose' WHERE "key" = 'Poivre rose'; +UPDATE "ingredients" SET "key" = 'poivre_du_sichuan' WHERE "key" = 'Poivre du Sichuan'; +UPDATE "ingredients" SET "key" = 'paprika_fume' WHERE "key" = 'Paprika fumé'; +UPDATE "ingredients" SET "key" = 'piment_oiseau' WHERE "key" = 'Piment oiseau'; +UPDATE "ingredients" SET "key" = 'baies_de_genievre' WHERE "key" = 'Baies de genièvre'; +UPDATE "ingredients" SET "key" = 'anis_etoile_badiane' WHERE "key" = 'Anis étoilé (badiane)'; +UPDATE "ingredients" SET "key" = 'anis_vert' WHERE "key" = 'Anis vert'; +UPDATE "ingredients" SET "key" = 'graines_de_fenouil' WHERE "key" = 'Graines de fenouil'; +UPDATE "ingredients" SET "key" = 'sumac' WHERE "key" = 'Sumac'; +UPDATE "ingredients" SET "key" = 'nigelle' WHERE "key" = 'Nigelle'; +UPDATE "ingredients" SET "key" = 'quatre_epices' WHERE "key" = 'Quatre épices'; +UPDATE "ingredients" SET "key" = 'colombo_poudre' WHERE "key" = 'Colombo (poudre)'; +UPDATE "ingredients" SET "key" = 'baharat' WHERE "key" = 'Baharat'; +UPDATE "ingredients" SET "key" = 'raifort' WHERE "key" = 'Raifort'; +UPDATE "ingredients" SET "key" = 'sel_aux_herbes' WHERE "key" = 'Sel aux herbes'; +UPDATE "ingredients" SET "key" = 'sel_de_celeri' WHERE "key" = 'Sel de céleri'; +UPDATE "ingredients" SET "key" = 'fleur_de_sel' WHERE "key" = 'Fleur de sel'; +UPDATE "ingredients" SET "key" = 'sel' WHERE "key" = 'Sel'; +UPDATE "ingredients" SET "key" = 'cinq_epices' WHERE "key" = 'Cinq épices'; +UPDATE "ingredients" SET "key" = 'garam_masala' WHERE "key" = 'Garam masala'; +UPDATE "ingredients" SET "key" = 'graines_de_coriandre' WHERE "key" = 'Graines de coriandre'; +UPDATE "ingredients" SET "key" = 'cardamome' WHERE "key" = 'Cardamome'; +UPDATE "ingredients" SET "key" = 'fenugrec' WHERE "key" = 'Fenugrec'; +UPDATE "ingredients" SET "key" = 'piment_jalapeno' WHERE "key" = 'Piment jalapeño'; +UPDATE "ingredients" SET "key" = 'piment_chipotle' WHERE "key" = 'Piment chipotle'; +UPDATE "ingredients" SET "key" = 'piment_poblano' WHERE "key" = 'Piment poblano'; +UPDATE "ingredients" SET "key" = 'piment_habanero' WHERE "key" = 'Piment habanero'; +UPDATE "ingredients" SET "key" = 'ras_el_hanout' WHERE "key" = 'Ras el hanout'; +UPDATE "ingredients" SET "key" = 'za_atar' WHERE "key" = 'Za''atar'; +UPDATE "ingredients" SET "key" = 'sauce_soja' WHERE "key" = 'Sauce soja'; +UPDATE "ingredients" SET "key" = 'moutarde' WHERE "key" = 'Moutarde'; +UPDATE "ingredients" SET "key" = 'mayonnaise' WHERE "key" = 'Mayonnaise'; +UPDATE "ingredients" SET "key" = 'ketchup' WHERE "key" = 'Ketchup'; +UPDATE "ingredients" SET "key" = 'tabasco' WHERE "key" = 'Tabasco'; +UPDATE "ingredients" SET "key" = 'sauce_worcestershire' WHERE "key" = 'Sauce Worcestershire'; +UPDATE "ingredients" SET "key" = 'sauce_nuoc_mam' WHERE "key" = 'Sauce nuoc-mâm'; +UPDATE "ingredients" SET "key" = 'wasabi' WHERE "key" = 'Wasabi'; +UPDATE "ingredients" SET "key" = 'harissa' WHERE "key" = 'Harissa'; +UPDATE "ingredients" SET "key" = 'pate_de_curry' WHERE "key" = 'Pâte de curry'; +UPDATE "ingredients" SET "key" = 'beurre_de_cacahuete' WHERE "key" = 'Beurre de cacahuète'; +UPDATE "ingredients" SET "key" = 'moutarde_de_dijon' WHERE "key" = 'Moutarde de Dijon'; +UPDATE "ingredients" SET "key" = 'moutarde_a_l_ancienne' WHERE "key" = 'Moutarde à l''ancienne'; +UPDATE "ingredients" SET "key" = 'sauce_barbecue' WHERE "key" = 'Sauce barbecue'; +UPDATE "ingredients" SET "key" = 'sauce_tartare' WHERE "key" = 'Sauce tartare'; +UPDATE "ingredients" SET "key" = 'sauce_cocktail' WHERE "key" = 'Sauce cocktail'; +UPDATE "ingredients" SET "key" = 'sauce_bearnaise' WHERE "key" = 'Sauce béarnaise'; +UPDATE "ingredients" SET "key" = 'sauce_hollandaise' WHERE "key" = 'Sauce hollandaise'; +UPDATE "ingredients" SET "key" = 'sauce_bechamel' WHERE "key" = 'Sauce béchamel'; +UPDATE "ingredients" SET "key" = 'sauce_teriyaki' WHERE "key" = 'Sauce teriyaki'; +UPDATE "ingredients" SET "key" = 'sauce_ponzu' WHERE "key" = 'Sauce ponzu'; +UPDATE "ingredients" SET "key" = 'chimichurri' WHERE "key" = 'Chimichurri'; +UPDATE "ingredients" SET "key" = 'pesto_rouge_tomates_sechees' WHERE "key" = 'Pesto rouge (tomates séchées)'; +UPDATE "ingredients" SET "key" = 'pesto' WHERE "key" = 'Pesto'; +UPDATE "ingredients" SET "key" = 'sauce_huitre' WHERE "key" = 'Sauce huître'; +UPDATE "ingredients" SET "key" = 'sauce_hoisin' WHERE "key" = 'Sauce hoisin'; +UPDATE "ingredients" SET "key" = 'sauce_sriracha' WHERE "key" = 'Sauce sriracha'; +UPDATE "ingredients" SET "key" = 'sauce_sweet_chili' WHERE "key" = 'Sauce sweet chili'; +UPDATE "ingredients" SET "key" = 'miso' WHERE "key" = 'Miso'; +UPDATE "ingredients" SET "key" = 'pate_de_crevettes' WHERE "key" = 'Pâte de crevettes'; +UPDATE "ingredients" SET "key" = 'pate_de_curry_rouge_thai' WHERE "key" = 'Pâte de curry rouge (thaï)'; +UPDATE "ingredients" SET "key" = 'pate_de_curry_vert_thai' WHERE "key" = 'Pâte de curry vert (thaï)'; +UPDATE "ingredients" SET "key" = 'tahini' WHERE "key" = 'Tahini'; +UPDATE "ingredients" SET "key" = 'huile_d_olive' WHERE "key" = 'Huile d''olive'; +UPDATE "ingredients" SET "key" = 'huile_de_tournesol' WHERE "key" = 'Huile de tournesol'; +UPDATE "ingredients" SET "key" = 'huile_de_colza' WHERE "key" = 'Huile de colza'; +UPDATE "ingredients" SET "key" = 'huile_de_coco' WHERE "key" = 'Huile de coco'; +UPDATE "ingredients" SET "key" = 'huile_de_sesame' WHERE "key" = 'Huile de sésame'; +UPDATE "ingredients" SET "key" = 'vinaigre_de_cidre' WHERE "key" = 'Vinaigre de cidre'; +UPDATE "ingredients" SET "key" = 'vinaigre_blanc' WHERE "key" = 'Vinaigre blanc'; +UPDATE "ingredients" SET "key" = 'vinaigre_balsamique' WHERE "key" = 'Vinaigre balsamique'; +UPDATE "ingredients" SET "key" = 'capres' WHERE "key" = 'Câpres'; +UPDATE "ingredients" SET "key" = 'olives' WHERE "key" = 'Olives'; +UPDATE "ingredients" SET "key" = 'vin_blanc_cuisine' WHERE "key" = 'Vin blanc (cuisine)'; +UPDATE "ingredients" SET "key" = 'vin_rouge_cuisine' WHERE "key" = 'Vin rouge (cuisine)'; +UPDATE "ingredients" SET "key" = 'vinaigre_de_vin_rouge' WHERE "key" = 'Vinaigre de vin rouge'; +UPDATE "ingredients" SET "key" = 'vinaigre_de_vin_blanc' WHERE "key" = 'Vinaigre de vin blanc'; +UPDATE "ingredients" SET "key" = 'vinaigre_de_xeres' WHERE "key" = 'Vinaigre de xérès'; +UPDATE "ingredients" SET "key" = 'huile_de_noix' WHERE "key" = 'Huile de noix'; +UPDATE "ingredients" SET "key" = 'huile_de_noisette' WHERE "key" = 'Huile de noisette'; +UPDATE "ingredients" SET "key" = 'huile_d_arachide' WHERE "key" = 'Huile d''arachide'; +UPDATE "ingredients" SET "key" = 'huile_pimentee' WHERE "key" = 'Huile pimentée'; +UPDATE "ingredients" SET "key" = 'vinaigre_de_riz' WHERE "key" = 'Vinaigre de riz'; +UPDATE "ingredients" SET "key" = 'mirin' WHERE "key" = 'Mirin'; +UPDATE "ingredients" SET "key" = 'sake_cuisine' WHERE "key" = 'Saké (cuisine)'; +UPDATE "ingredients" SET "key" = 'jus_de_citron' WHERE "key" = 'Jus de citron'; +UPDATE "ingredients" SET "key" = 'jus_de_citron_vert' WHERE "key" = 'Jus de citron vert'; +UPDATE "ingredients" SET "key" = 'jus_d_orange' WHERE "key" = 'Jus d''orange'; +UPDATE "ingredients" SET "key" = 'jus_de_pomme' WHERE "key" = 'Jus de pomme'; +UPDATE "ingredients" SET "key" = 'jus_de_raisin' WHERE "key" = 'Jus de raisin'; +UPDATE "ingredients" SET "key" = 'jus_de_tomate' WHERE "key" = 'Jus de tomate'; +UPDATE "ingredients" SET "key" = 'jus_de_cranberry' WHERE "key" = 'Jus de cranberry'; +UPDATE "ingredients" SET "key" = 'cafe' WHERE "key" = 'Café'; +UPDATE "ingredients" SET "key" = 'the' WHERE "key" = 'Thé'; +UPDATE "ingredients" SET "key" = 'biere_cuisine' WHERE "key" = 'Bière (cuisine)'; +UPDATE "ingredients" SET "key" = 'cidre_cuisine' WHERE "key" = 'Cidre (cuisine)'; +UPDATE "ingredients" SET "key" = 'champagne_vin_petillant_cuisine' WHERE "key" = 'Champagne / vin pétillant (cuisine)'; +UPDATE "ingredients" SET "key" = 'porto_cuisine' WHERE "key" = 'Porto (cuisine)'; +UPDATE "ingredients" SET "key" = 'vin_jaune_cuisine' WHERE "key" = 'Vin jaune (cuisine)'; +UPDATE "ingredients" SET "key" = 'cognac' WHERE "key" = 'Cognac'; +UPDATE "ingredients" SET "key" = 'rhum' WHERE "key" = 'Rhum'; +UPDATE "ingredients" SET "key" = 'whisky' WHERE "key" = 'Whisky'; +UPDATE "ingredients" SET "key" = 'vodka' WHERE "key" = 'Vodka'; +UPDATE "ingredients" SET "key" = 'farine_de_ble' WHERE "key" = 'Farine de blé'; +UPDATE "ingredients" SET "key" = 'farine_complete' WHERE "key" = 'Farine complète'; +UPDATE "ingredients" SET "key" = 'farine_de_mais' WHERE "key" = 'Farine de maïs'; +UPDATE "ingredients" SET "key" = 'farine_de_sarrasin' WHERE "key" = 'Farine de sarrasin'; +UPDATE "ingredients" SET "key" = 'farine_de_riz' WHERE "key" = 'Farine de riz'; +UPDATE "ingredients" SET "key" = 'bouillon_cube_legumes' WHERE "key" = 'Bouillon cube légumes'; +UPDATE "ingredients" SET "key" = 'bouillon_cube_volaille' WHERE "key" = 'Bouillon cube volaille'; +UPDATE "ingredients" SET "key" = 'concentre_de_tomate' WHERE "key" = 'Concentré de tomate'; +UPDATE "ingredients" SET "key" = 'coulis_de_tomate' WHERE "key" = 'Coulis de tomate'; +UPDATE "ingredients" SET "key" = 'tomates_pelees_conserve' WHERE "key" = 'Tomates pelées (conserve)'; +UPDATE "ingredients" SET "key" = 'tomates_sechees' WHERE "key" = 'Tomates séchées'; +UPDATE "ingredients" SET "key" = 'fond_de_veau' WHERE "key" = 'Fond de veau'; +UPDATE "ingredients" SET "key" = 'fond_de_volaille' WHERE "key" = 'Fond de volaille'; +UPDATE "ingredients" SET "key" = 'bouillon_cube_boeuf' WHERE "key" = 'Bouillon cube bœuf'; +UPDATE "ingredients" SET "key" = 'bouillon_cube_poisson' WHERE "key" = 'Bouillon cube poisson'; +UPDATE "ingredients" SET "key" = 'bouillon_de_legumes' WHERE "key" = 'Bouillon de légumes'; +UPDATE "ingredients" SET "key" = 'bouillon_de_volaille' WHERE "key" = 'Bouillon de volaille'; +UPDATE "ingredients" SET "key" = 'bouillon_de_boeuf' WHERE "key" = 'Bouillon de bœuf'; +UPDATE "ingredients" SET "key" = 'court_bouillon' WHERE "key" = 'Court-bouillon'; +UPDATE "ingredients" SET "key" = 'dashi_bouillon_japonais' WHERE "key" = 'Dashi (bouillon japonais)'; +UPDATE "ingredients" SET "key" = 'bisque_de_crustaces' WHERE "key" = 'Bisque de crustacés'; +UPDATE "ingredients" SET "key" = 'farine_de_tapioca' WHERE "key" = 'Farine de tapioca'; +UPDATE "ingredients" SET "key" = 'masa_harina' WHERE "key" = 'Masa harina'; +UPDATE "ingredients" SET "key" = 'eau' WHERE "key" = 'Eau'; +UPDATE "ingredients" SET "key" = 'eau_gazeuse' WHERE "key" = 'Eau gazeuse'; +UPDATE "ingredients" SET "key" = 'eau_de_fleur_d_oranger' WHERE "key" = 'Eau de fleur d''oranger'; +UPDATE "ingredients" SET "key" = 'eau_de_rose' WHERE "key" = 'Eau de rose'; +UPDATE "ingredients" SET "key" = 'fumet_de_poisson' WHERE "key" = 'Fumet de poisson'; +UPDATE "ingredients" SET "key" = 'levure_boulangere' WHERE "key" = 'Levure boulangère'; +UPDATE "ingredients" SET "key" = 'levure_chimique' WHERE "key" = 'Levure chimique'; +UPDATE "ingredients" SET "key" = 'maizena' WHERE "key" = 'Maïzena'; +UPDATE "ingredients" SET "key" = 'farine_de_lupin' WHERE "key" = 'Farine de lupin'; +UPDATE "ingredients" SET "key" = 'gelatine' WHERE "key" = 'Gélatine'; +UPDATE "ingredients" SET "key" = 'bicarbonate_de_soude' WHERE "key" = 'Bicarbonate de soude'; +UPDATE "ingredients" SET "key" = 'fecule_de_pomme_de_terre' WHERE "key" = 'Fécule de pomme de terre'; +UPDATE "ingredients" SET "key" = 'sucre' WHERE "key" = 'Sucre'; +UPDATE "ingredients" SET "key" = 'miel' WHERE "key" = 'Miel'; +UPDATE "ingredients" SET "key" = 'sirop_d_erable' WHERE "key" = 'Sirop d''érable'; +UPDATE "ingredients" SET "key" = 'sucre_roux' WHERE "key" = 'Sucre roux'; +UPDATE "ingredients" SET "key" = 'sucre_glace' WHERE "key" = 'Sucre glace'; +UPDATE "ingredients" SET "key" = 'cassonade' WHERE "key" = 'Cassonade'; +UPDATE "ingredients" SET "key" = 'chocolat_noir' WHERE "key" = 'Chocolat noir'; +UPDATE "ingredients" SET "key" = 'chocolat_au_lait' WHERE "key" = 'Chocolat au lait'; +UPDATE "ingredients" SET "key" = 'chocolat_blanc' WHERE "key" = 'Chocolat blanc'; +UPDATE "ingredients" SET "key" = 'pepites_de_chocolat' WHERE "key" = 'Pépites de chocolat'; +UPDATE "ingredients" SET "key" = 'cacao_en_poudre' WHERE "key" = 'Cacao en poudre'; +UPDATE "ingredients" SET "key" = 'extrait_de_vanille' WHERE "key" = 'Extrait de vanille'; +UPDATE "ingredients" SET "key" = 'sucre_de_palme' WHERE "key" = 'Sucre de palme'; +UPDATE "ingredients" SET "key" = 'sirop_de_sucre_de_canne' WHERE "key" = 'Sirop de sucre de canne'; diff --git a/apps/api/prisma/migrations/20260818193000_catalog_keys_to_english/migration.sql b/apps/api/prisma/migrations/20260818193000_catalog_keys_to_english/migration.sql new file mode 100644 index 0000000..7538101 --- /dev/null +++ b/apps/api/prisma/migrations/20260818193000_catalog_keys_to_english/migration.sql @@ -0,0 +1,465 @@ +-- Auto-generated once by scripts/gen-english-remap-sql.ts — do not re-run, +-- do not hand-edit. Remaps every Diet/Category(allergen)/Ingredient row's +-- "key" from the French slug the previous migration +-- (20260818_catalog_labels_to_keys) produced to the hand-assigned English +-- key in catalog-en-keys.ts. + +UPDATE "diet" SET "key" = 'omnivore' WHERE "key" = 'omnivore'; +UPDATE "diet" SET "key" = 'vegetarian' WHERE "key" = 'vegetarien'; +UPDATE "diet" SET "key" = 'vegan' WHERE "key" = 'vegan'; +UPDATE "diet" SET "key" = 'pescatarian' WHERE "key" = 'pescetarien'; +UPDATE "diet" SET "key" = 'gluten_free' WHERE "key" = 'sans_gluten'; + +UPDATE "category" SET "key" = 'gluten' WHERE "key" = 'gluten'; +UPDATE "category" SET "key" = 'crustaceans' WHERE "key" = 'crustaces'; +UPDATE "category" SET "key" = 'eggs' WHERE "key" = 'oeufs'; +UPDATE "category" SET "key" = 'fish' WHERE "key" = 'poissons'; +UPDATE "category" SET "key" = 'peanuts' WHERE "key" = 'arachides'; +UPDATE "category" SET "key" = 'soy' WHERE "key" = 'soja'; +UPDATE "category" SET "key" = 'milk' WHERE "key" = 'lait'; +UPDATE "category" SET "key" = 'tree_nuts' WHERE "key" = 'fruits_a_coque'; +UPDATE "category" SET "key" = 'celery' WHERE "key" = 'celeri'; +UPDATE "category" SET "key" = 'mustard' WHERE "key" = 'moutarde'; +UPDATE "category" SET "key" = 'sesame_seeds' WHERE "key" = 'graines_de_sesame'; +UPDATE "category" SET "key" = 'sulfites' WHERE "key" = 'sulfites'; +UPDATE "category" SET "key" = 'lupin' WHERE "key" = 'lupin'; +UPDATE "category" SET "key" = 'molluscs' WHERE "key" = 'mollusques'; + +UPDATE "ingredients" SET "key" = 'tomato' WHERE "key" = 'tomate'; +UPDATE "ingredients" SET "key" = 'onion' WHERE "key" = 'oignon'; +UPDATE "ingredients" SET "key" = 'shallot' WHERE "key" = 'echalote'; +UPDATE "ingredients" SET "key" = 'garlic' WHERE "key" = 'ail'; +UPDATE "ingredients" SET "key" = 'carrot' WHERE "key" = 'carotte'; +UPDATE "ingredients" SET "key" = 'zucchini' WHERE "key" = 'courgette'; +UPDATE "ingredients" SET "key" = 'cucumber' WHERE "key" = 'concombre'; +UPDATE "ingredients" SET "key" = 'gherkins' WHERE "key" = 'cornichons'; +UPDATE "ingredients" SET "key" = 'bell_pepper' WHERE "key" = 'poivron'; +UPDATE "ingredients" SET "key" = 'mushroom' WHERE "key" = 'champignon'; +UPDATE "ingredients" SET "key" = 'porcini' WHERE "key" = 'cepes'; +UPDATE "ingredients" SET "key" = 'eggplant' WHERE "key" = 'aubergine'; +UPDATE "ingredients" SET "key" = 'broccoli' WHERE "key" = 'brocoli'; +UPDATE "ingredients" SET "key" = 'cauliflower' WHERE "key" = 'chou_fleur'; +UPDATE "ingredients" SET "key" = 'white_cabbage' WHERE "key" = 'chou_blanc'; +UPDATE "ingredients" SET "key" = 'red_cabbage' WHERE "key" = 'chou_rouge'; +UPDATE "ingredients" SET "key" = 'brussels_sprouts' WHERE "key" = 'chou_de_bruxelles'; +UPDATE "ingredients" SET "key" = 'spinach' WHERE "key" = 'epinard'; +UPDATE "ingredients" SET "key" = 'swiss_chard' WHERE "key" = 'blette'; +UPDATE "ingredients" SET "key" = 'lettuce' WHERE "key" = 'salade'; +UPDATE "ingredients" SET "key" = 'arugula' WHERE "key" = 'roquette'; +UPDATE "ingredients" SET "key" = 'watercress' WHERE "key" = 'cresson'; +UPDATE "ingredients" SET "key" = 'leek' WHERE "key" = 'poireau'; +UPDATE "ingredients" SET "key" = 'celery' WHERE "key" = 'celeri'; +UPDATE "ingredients" SET "key" = 'radish' WHERE "key" = 'radis'; +UPDATE "ingredients" SET "key" = 'beetroot' WHERE "key" = 'betterave'; +UPDATE "ingredients" SET "key" = 'turnip' WHERE "key" = 'navet'; +UPDATE "ingredients" SET "key" = 'parsnip' WHERE "key" = 'panais'; +UPDATE "ingredients" SET "key" = 'green_bean' WHERE "key" = 'haricot_vert'; +UPDATE "ingredients" SET "key" = 'pea' WHERE "key" = 'petit_pois'; +UPDATE "ingredients" SET "key" = 'corn' WHERE "key" = 'mais'; +UPDATE "ingredients" SET "key" = 'artichoke' WHERE "key" = 'artichaut'; +UPDATE "ingredients" SET "key" = 'fennel' WHERE "key" = 'fenouil'; +UPDATE "ingredients" SET "key" = 'endive' WHERE "key" = 'endive'; +UPDATE "ingredients" SET "key" = 'pumpkin' WHERE "key" = 'potiron'; +UPDATE "ingredients" SET "key" = 'butternut_squash' WHERE "key" = 'butternut'; +UPDATE "ingredients" SET "key" = 'asparagus' WHERE "key" = 'asperge'; +UPDATE "ingredients" SET "key" = 'avocado' WHERE "key" = 'avocat'; +UPDATE "ingredients" SET "key" = 'potato' WHERE "key" = 'pomme_de_terre'; +UPDATE "ingredients" SET "key" = 'sweet_potato' WHERE "key" = 'patate_douce'; +UPDATE "ingredients" SET "key" = 'cherry_tomato' WHERE "key" = 'tomates_cerises'; +UPDATE "ingredients" SET "key" = 'bok_choy' WHERE "key" = 'pak_choi'; +UPDATE "ingredients" SET "key" = 'soybean_sprouts' WHERE "key" = 'germes_de_soja'; +UPDATE "ingredients" SET "key" = 'shiitake' WHERE "key" = 'shiitake'; +UPDATE "ingredients" SET "key" = 'daikon' WHERE "key" = 'daikon'; +UPDATE "ingredients" SET "key" = 'fresh_green_chili' WHERE "key" = 'piment_vert_frais'; +UPDATE "ingredients" SET "key" = 'lemon' WHERE "key" = 'citron'; +UPDATE "ingredients" SET "key" = 'lime' WHERE "key" = 'citron_vert'; +UPDATE "ingredients" SET "key" = 'apple' WHERE "key" = 'pomme'; +UPDATE "ingredients" SET "key" = 'pear' WHERE "key" = 'poire'; +UPDATE "ingredients" SET "key" = 'banana' WHERE "key" = 'banane'; +UPDATE "ingredients" SET "key" = 'orange' WHERE "key" = 'orange'; +UPDATE "ingredients" SET "key" = 'clementine' WHERE "key" = 'clementine'; +UPDATE "ingredients" SET "key" = 'grapefruit' WHERE "key" = 'pamplemousse'; +UPDATE "ingredients" SET "key" = 'strawberry' WHERE "key" = 'fraise'; +UPDATE "ingredients" SET "key" = 'raspberry' WHERE "key" = 'framboise'; +UPDATE "ingredients" SET "key" = 'blueberry' WHERE "key" = 'myrtille'; +UPDATE "ingredients" SET "key" = 'blackberry' WHERE "key" = 'mure'; +UPDATE "ingredients" SET "key" = 'cherry' WHERE "key" = 'cerise'; +UPDATE "ingredients" SET "key" = 'apricot' WHERE "key" = 'abricot'; +UPDATE "ingredients" SET "key" = 'peach' WHERE "key" = 'peche'; +UPDATE "ingredients" SET "key" = 'plum' WHERE "key" = 'prune'; +UPDATE "ingredients" SET "key" = 'grape' WHERE "key" = 'raisin'; +UPDATE "ingredients" SET "key" = 'melon' WHERE "key" = 'melon'; +UPDATE "ingredients" SET "key" = 'watermelon' WHERE "key" = 'pasteque'; +UPDATE "ingredients" SET "key" = 'pineapple' WHERE "key" = 'ananas'; +UPDATE "ingredients" SET "key" = 'mango' WHERE "key" = 'mangue'; +UPDATE "ingredients" SET "key" = 'kiwi' WHERE "key" = 'kiwi'; +UPDATE "ingredients" SET "key" = 'fig' WHERE "key" = 'figue'; +UPDATE "ingredients" SET "key" = 'date' WHERE "key" = 'datte'; +UPDATE "ingredients" SET "key" = 'lychee' WHERE "key" = 'litchi'; +UPDATE "ingredients" SET "key" = 'pomegranate' WHERE "key" = 'grenade'; +UPDATE "ingredients" SET "key" = 'rhubarb' WHERE "key" = 'rhubarbe'; +UPDATE "ingredients" SET "key" = 'quince' WHERE "key" = 'coing'; +UPDATE "ingredients" SET "key" = 'basil' WHERE "key" = 'basilic'; +UPDATE "ingredients" SET "key" = 'parsley' WHERE "key" = 'persil'; +UPDATE "ingredients" SET "key" = 'thyme' WHERE "key" = 'thym'; +UPDATE "ingredients" SET "key" = 'rosemary' WHERE "key" = 'romarin'; +UPDATE "ingredients" SET "key" = 'bay_leaf' WHERE "key" = 'laurier'; +UPDATE "ingredients" SET "key" = 'chives' WHERE "key" = 'ciboulette'; +UPDATE "ingredients" SET "key" = 'fresh_cilantro' WHERE "key" = 'coriandre_fraiche'; +UPDATE "ingredients" SET "key" = 'mint' WHERE "key" = 'menthe'; +UPDATE "ingredients" SET "key" = 'oregano' WHERE "key" = 'origan'; +UPDATE "ingredients" SET "key" = 'dill' WHERE "key" = 'aneth'; +UPDATE "ingredients" SET "key" = 'tarragon' WHERE "key" = 'estragon'; +UPDATE "ingredients" SET "key" = 'savory' WHERE "key" = 'sarriette'; +UPDATE "ingredients" SET "key" = 'marjoram' WHERE "key" = 'marjolaine'; +UPDATE "ingredients" SET "key" = 'sage' WHERE "key" = 'sauge'; +UPDATE "ingredients" SET "key" = 'chervil' WHERE "key" = 'cerfeuil'; +UPDATE "ingredients" SET "key" = 'ginger' WHERE "key" = 'gingembre'; +UPDATE "ingredients" SET "key" = 'lemongrass' WHERE "key" = 'citronnelle'; +UPDATE "ingredients" SET "key" = 'kaffir_lime' WHERE "key" = 'combava'; +UPDATE "ingredients" SET "key" = 'rabbit' WHERE "key" = 'lapin'; +UPDATE "ingredients" SET "key" = 'ground_beef' WHERE "key" = 'boeuf_hache'; +UPDATE "ingredients" SET "key" = 'beef_steak' WHERE "key" = 'steak_de_boeuf'; +UPDATE "ingredients" SET "key" = 'beef_roast' WHERE "key" = 'roti_de_boeuf'; +UPDATE "ingredients" SET "key" = 'veal_cutlet' WHERE "key" = 'escalope_de_veau'; +UPDATE "ingredients" SET "key" = 'pork_tenderloin' WHERE "key" = 'filet_mignon_de_porc'; +UPDATE "ingredients" SET "key" = 'pork_chop' WHERE "key" = 'cote_de_porc'; +UPDATE "ingredients" SET "key" = 'lamb' WHERE "key" = 'agneau'; +UPDATE "ingredients" SET "key" = 'leg_of_lamb' WHERE "key" = 'gigot_d_agneau'; +UPDATE "ingredients" SET "key" = 'bacon_lardons' WHERE "key" = 'lardons'; +UPDATE "ingredients" SET "key" = 'bacon' WHERE "key" = 'bacon'; +UPDATE "ingredients" SET "key" = 'ham' WHERE "key" = 'jambon_blanc'; +UPDATE "ingredients" SET "key" = 'cured_ham' WHERE "key" = 'jambon_cru'; +UPDATE "ingredients" SET "key" = 'sausage' WHERE "key" = 'saucisse'; +UPDATE "ingredients" SET "key" = 'chorizo' WHERE "key" = 'chorizo'; +UPDATE "ingredients" SET "key" = 'merguez' WHERE "key" = 'merguez'; +UPDATE "ingredients" SET "key" = 'prosciutto' WHERE "key" = 'prosciutto'; +UPDATE "ingredients" SET "key" = 'pancetta' WHERE "key" = 'pancetta'; +UPDATE "ingredients" SET "key" = 'mortadella' WHERE "key" = 'mortadelle'; +UPDATE "ingredients" SET "key" = 'salami' WHERE "key" = 'salami'; +UPDATE "ingredients" SET "key" = 'chicken' WHERE "key" = 'poulet'; +UPDATE "ingredients" SET "key" = 'turkey' WHERE "key" = 'dinde'; +UPDATE "ingredients" SET "key" = 'duck' WHERE "key" = 'canard'; +UPDATE "ingredients" SET "key" = 'duck_breast' WHERE "key" = 'magret_de_canard'; +UPDATE "ingredients" SET "key" = 'salmon' WHERE "key" = 'saumon'; +UPDATE "ingredients" SET "key" = 'tuna' WHERE "key" = 'thon'; +UPDATE "ingredients" SET "key" = 'cod' WHERE "key" = 'cabillaud'; +UPDATE "ingredients" SET "key" = 'trout' WHERE "key" = 'truite'; +UPDATE "ingredients" SET "key" = 'sardine' WHERE "key" = 'sardine'; +UPDATE "ingredients" SET "key" = 'anchovy' WHERE "key" = 'anchois'; +UPDATE "ingredients" SET "key" = 'whiting' WHERE "key" = 'merlan'; +UPDATE "ingredients" SET "key" = 'surimi' WHERE "key" = 'surimi'; +UPDATE "ingredients" SET "key" = 'sea_bass' WHERE "key" = 'bar_loup_de_mer'; +UPDATE "ingredients" SET "key" = 'sea_bream' WHERE "key" = 'dorade'; +UPDATE "ingredients" SET "key" = 'sole' WHERE "key" = 'sole'; +UPDATE "ingredients" SET "key" = 'turbot' WHERE "key" = 'turbot'; +UPDATE "ingredients" SET "key" = 'hake' WHERE "key" = 'merlu'; +UPDATE "ingredients" SET "key" = 'pollock' WHERE "key" = 'colin'; +UPDATE "ingredients" SET "key" = 'saithe' WHERE "key" = 'lieu_noir'; +UPDATE "ingredients" SET "key" = 'haddock' WHERE "key" = 'eglefin'; +UPDATE "ingredients" SET "key" = 'mackerel' WHERE "key" = 'maquereau'; +UPDATE "ingredients" SET "key" = 'herring' WHERE "key" = 'hareng'; +UPDATE "ingredients" SET "key" = 'red_mullet' WHERE "key" = 'rouget'; +UPDATE "ingredients" SET "key" = 'skate' WHERE "key" = 'raie'; +UPDATE "ingredients" SET "key" = 'monkfish' WHERE "key" = 'lotte'; +UPDATE "ingredients" SET "key" = 'halibut' WHERE "key" = 'fletan'; +UPDATE "ingredients" SET "key" = 'swordfish' WHERE "key" = 'espadon'; +UPDATE "ingredients" SET "key" = 'carp' WHERE "key" = 'carpe'; +UPDATE "ingredients" SET "key" = 'pike' WHERE "key" = 'brochet'; +UPDATE "ingredients" SET "key" = 'perch' WHERE "key" = 'perche'; +UPDATE "ingredients" SET "key" = 'tilapia' WHERE "key" = 'tilapia'; +UPDATE "ingredients" SET "key" = 'pangasius' WHERE "key" = 'panga'; +UPDATE "ingredients" SET "key" = 'smoked_salmon' WHERE "key" = 'saumon_fume'; +UPDATE "ingredients" SET "key" = 'dried_fish' WHERE "key" = 'poisson_seche'; +UPDATE "ingredients" SET "key" = 'shrimp' WHERE "key" = 'crevettes'; +UPDATE "ingredients" SET "key" = 'langoustine' WHERE "key" = 'langoustines'; +UPDATE "ingredients" SET "key" = 'lobster' WHERE "key" = 'homard'; +UPDATE "ingredients" SET "key" = 'crab' WHERE "key" = 'crabe'; +UPDATE "ingredients" SET "key" = 'spiny_lobster' WHERE "key" = 'langouste'; +UPDATE "ingredients" SET "key" = 'mussels' WHERE "key" = 'moules'; +UPDATE "ingredients" SET "key" = 'oysters' WHERE "key" = 'huitres'; +UPDATE "ingredients" SET "key" = 'scallops' WHERE "key" = 'saint_jacques'; +UPDATE "ingredients" SET "key" = 'squid' WHERE "key" = 'calamar'; +UPDATE "ingredients" SET "key" = 'octopus' WHERE "key" = 'poulpe'; +UPDATE "ingredients" SET "key" = 'clams' WHERE "key" = 'palourdes'; +UPDATE "ingredients" SET "key" = 'whelks' WHERE "key" = 'bulots'; +UPDATE "ingredients" SET "key" = 'semolina' WHERE "key" = 'semoule'; +UPDATE "ingredients" SET "key" = 'couscous' WHERE "key" = 'couscous'; +UPDATE "ingredients" SET "key" = 'bulgur' WHERE "key" = 'boulgour'; +UPDATE "ingredients" SET "key" = 'polenta' WHERE "key" = 'polenta'; +UPDATE "ingredients" SET "key" = 'quinoa' WHERE "key" = 'quinoa'; +UPDATE "ingredients" SET "key" = 'pasta' WHERE "key" = 'pates'; +UPDATE "ingredients" SET "key" = 'whole_wheat_pasta' WHERE "key" = 'pates_completes'; +UPDATE "ingredients" SET "key" = 'rice' WHERE "key" = 'riz'; +UPDATE "ingredients" SET "key" = 'basmati_rice' WHERE "key" = 'riz_basmati'; +UPDATE "ingredients" SET "key" = 'brown_rice' WHERE "key" = 'riz_complet'; +UPDATE "ingredients" SET "key" = 'oats' WHERE "key" = 'flocons_d_avoine'; +UPDATE "ingredients" SET "key" = 'spaghetti' WHERE "key" = 'spaghetti'; +UPDATE "ingredients" SET "key" = 'penne' WHERE "key" = 'penne'; +UPDATE "ingredients" SET "key" = 'tagliatelle' WHERE "key" = 'tagliatelles'; +UPDATE "ingredients" SET "key" = 'lasagna_sheets' WHERE "key" = 'lasagnes_feuilles'; +UPDATE "ingredients" SET "key" = 'gnocchi' WHERE "key" = 'gnocchi'; +UPDATE "ingredients" SET "key" = 'arborio_rice' WHERE "key" = 'riz_arborio'; +UPDATE "ingredients" SET "key" = 'rice_noodles' WHERE "key" = 'nouilles_de_riz'; +UPDATE "ingredients" SET "key" = 'udon_noodles' WHERE "key" = 'nouilles_udon'; +UPDATE "ingredients" SET "key" = 'soba_noodles' WHERE "key" = 'nouilles_soba'; +UPDATE "ingredients" SET "key" = 'chinese_noodles' WHERE "key" = 'nouilles_chinoises'; +UPDATE "ingredients" SET "key" = 'rice_vermicelli' WHERE "key" = 'vermicelles_de_riz'; +UPDATE "ingredients" SET "key" = 'soy_vermicelli' WHERE "key" = 'vermicelles_de_soja'; +UPDATE "ingredients" SET "key" = 'sticky_rice' WHERE "key" = 'riz_gluant'; +UPDATE "ingredients" SET "key" = 'sushi_rice' WHERE "key" = 'riz_a_sushi'; +UPDATE "ingredients" SET "key" = 'jasmine_rice' WHERE "key" = 'riz_jasmin'; +UPDATE "ingredients" SET "key" = 'green_lentils' WHERE "key" = 'lentilles_vertes'; +UPDATE "ingredients" SET "key" = 'red_lentils' WHERE "key" = 'lentilles_corail'; +UPDATE "ingredients" SET "key" = 'chickpeas' WHERE "key" = 'pois_chiches'; +UPDATE "ingredients" SET "key" = 'white_beans' WHERE "key" = 'haricots_blancs'; +UPDATE "ingredients" SET "key" = 'kidney_beans' WHERE "key" = 'haricots_rouges'; +UPDATE "ingredients" SET "key" = 'black_beans' WHERE "key" = 'haricots_noirs'; +UPDATE "ingredients" SET "key" = 'split_peas' WHERE "key" = 'pois_casses'; +UPDATE "ingredients" SET "key" = 'fava_beans' WHERE "key" = 'feves'; +UPDATE "ingredients" SET "key" = 'edamame' WHERE "key" = 'edamame'; +UPDATE "ingredients" SET "key" = 'pinto_beans' WHERE "key" = 'haricots_pinto'; +UPDATE "ingredients" SET "key" = 'peanuts_shelled' WHERE "key" = 'cacahuetes'; +UPDATE "ingredients" SET "key" = 'almonds' WHERE "key" = 'amandes'; +UPDATE "ingredients" SET "key" = 'walnuts' WHERE "key" = 'noix'; +UPDATE "ingredients" SET "key" = 'hazelnuts' WHERE "key" = 'noisettes'; +UPDATE "ingredients" SET "key" = 'cashews' WHERE "key" = 'noix_de_cajou'; +UPDATE "ingredients" SET "key" = 'pistachios' WHERE "key" = 'pistaches'; +UPDATE "ingredients" SET "key" = 'pecans' WHERE "key" = 'noix_de_pecan'; +UPDATE "ingredients" SET "key" = 'almond_powder' WHERE "key" = 'poudre_d_amande'; +UPDATE "ingredients" SET "key" = 'pine_nuts' WHERE "key" = 'pignons_de_pin'; +UPDATE "ingredients" SET "key" = 'sunflower_seeds' WHERE "key" = 'graines_de_tournesol'; +UPDATE "ingredients" SET "key" = 'pumpkin_seeds' WHERE "key" = 'graines_de_courge'; +UPDATE "ingredients" SET "key" = 'shredded_coconut' WHERE "key" = 'noix_de_coco_rapee'; +UPDATE "ingredients" SET "key" = 'raisins' WHERE "key" = 'raisins_secs'; +UPDATE "ingredients" SET "key" = 'prunes' WHERE "key" = 'pruneaux'; +UPDATE "ingredients" SET "key" = 'dried_apricots' WHERE "key" = 'abricots_secs'; +UPDATE "ingredients" SET "key" = 'sesame_seeds' WHERE "key" = 'graines_de_sesame'; +UPDATE "ingredients" SET "key" = 'black_mushrooms' WHERE "key" = 'champignons_noirs'; +UPDATE "ingredients" SET "key" = 'nori_seaweed' WHERE "key" = 'algue_nori'; +UPDATE "ingredients" SET "key" = 'wakame_seaweed' WHERE "key" = 'algue_wakame'; +UPDATE "ingredients" SET "key" = 'kombu_seaweed' WHERE "key" = 'algue_kombu'; +UPDATE "ingredients" SET "key" = 'bamboo_shoots' WHERE "key" = 'pousses_de_bambou'; +UPDATE "ingredients" SET "key" = 'water_chestnuts' WHERE "key" = 'chataignes_d_eau'; +UPDATE "ingredients" SET "key" = 'bread' WHERE "key" = 'pain'; +UPDATE "ingredients" SET "key" = 'sandwich_bread' WHERE "key" = 'pain_de_mie'; +UPDATE "ingredients" SET "key" = 'whole_wheat_bread' WHERE "key" = 'pain_complet'; +UPDATE "ingredients" SET "key" = 'baguette' WHERE "key" = 'baguette'; +UPDATE "ingredients" SET "key" = 'rye_bread' WHERE "key" = 'pain_de_seigle'; +UPDATE "ingredients" SET "key" = 'breadcrumbs' WHERE "key" = 'chapelure'; +UPDATE "ingredients" SET "key" = 'burger_bun' WHERE "key" = 'pain_a_burger'; +UPDATE "ingredients" SET "key" = 'brioche_bun' WHERE "key" = 'pain_brioche'; +UPDATE "ingredients" SET "key" = 'hot_dog_bun' WHERE "key" = 'pain_a_hot_dog'; +UPDATE "ingredients" SET "key" = 'pita_bread' WHERE "key" = 'pain_pita'; +UPDATE "ingredients" SET "key" = 'bagel' WHERE "key" = 'pain_bagel'; +UPDATE "ingredients" SET "key" = 'naan' WHERE "key" = 'naan'; +UPDATE "ingredients" SET "key" = 'wrap_bread' WHERE "key" = 'pain_wrap'; +UPDATE "ingredients" SET "key" = 'viennese_bread' WHERE "key" = 'pain_viennois'; +UPDATE "ingredients" SET "key" = 'country_bread' WHERE "key" = 'pain_de_campagne'; +UPDATE "ingredients" SET "key" = 'multigrain_bread' WHERE "key" = 'pain_aux_cereales'; +UPDATE "ingredients" SET "key" = 'bread_roll' WHERE "key" = 'petit_pain'; +UPDATE "ingredients" SET "key" = 'swedish_bread' WHERE "key" = 'pain_suedois'; +UPDATE "ingredients" SET "key" = 'gluten_free_bread' WHERE "key" = 'pain_sans_gluten'; +UPDATE "ingredients" SET "key" = 'rusk' WHERE "key" = 'biscotte'; +UPDATE "ingredients" SET "key" = 'croutons' WHERE "key" = 'croutons'; +UPDATE "ingredients" SET "key" = 'focaccia' WHERE "key" = 'focaccia'; +UPDATE "ingredients" SET "key" = 'ciabatta' WHERE "key" = 'ciabatta'; +UPDATE "ingredients" SET "key" = 'corn_tortilla' WHERE "key" = 'tortilla_de_mais'; +UPDATE "ingredients" SET "key" = 'wheat_tortilla' WHERE "key" = 'tortilla_de_ble'; +UPDATE "ingredients" SET "key" = 'puff_pastry' WHERE "key" = 'pate_feuilletee'; +UPDATE "ingredients" SET "key" = 'shortcrust_pastry' WHERE "key" = 'pate_brisee'; +UPDATE "ingredients" SET "key" = 'pizza_dough' WHERE "key" = 'pate_a_pizza'; +UPDATE "ingredients" SET "key" = 'sweet_shortcrust_pastry' WHERE "key" = 'pate_a_tarte_sablee'; +UPDATE "ingredients" SET "key" = 'milk' WHERE "key" = 'lait'; +UPDATE "ingredients" SET "key" = 'butter' WHERE "key" = 'beurre'; +UPDATE "ingredients" SET "key" = 'creme_fraiche' WHERE "key" = 'creme_fraiche'; +UPDATE "ingredients" SET "key" = 'liquid_cream' WHERE "key" = 'creme_liquide'; +UPDATE "ingredients" SET "key" = 'cheese' WHERE "key" = 'fromage'; +UPDATE "ingredients" SET "key" = 'emmental' WHERE "key" = 'emmental'; +UPDATE "ingredients" SET "key" = 'gruyere' WHERE "key" = 'gruyere'; +UPDATE "ingredients" SET "key" = 'parmesan' WHERE "key" = 'parmesan'; +UPDATE "ingredients" SET "key" = 'mozzarella' WHERE "key" = 'mozzarella'; +UPDATE "ingredients" SET "key" = 'goat_cheese' WHERE "key" = 'chevre_fromage'; +UPDATE "ingredients" SET "key" = 'feta' WHERE "key" = 'feta'; +UPDATE "ingredients" SET "key" = 'comte' WHERE "key" = 'comte'; +UPDATE "ingredients" SET "key" = 'fromage_blanc' WHERE "key" = 'fromage_blanc'; +UPDATE "ingredients" SET "key" = 'mascarpone' WHERE "key" = 'mascarpone'; +UPDATE "ingredients" SET "key" = 'yogurt' WHERE "key" = 'yaourt'; +UPDATE "ingredients" SET "key" = 'burrata' WHERE "key" = 'burrata'; +UPDATE "ingredients" SET "key" = 'ricotta' WHERE "key" = 'ricotta'; +UPDATE "ingredients" SET "key" = 'pecorino' WHERE "key" = 'pecorino'; +UPDATE "ingredients" SET "key" = 'gorgonzola' WHERE "key" = 'gorgonzola'; +UPDATE "ingredients" SET "key" = 'cheddar' WHERE "key" = 'cheddar'; +UPDATE "ingredients" SET "key" = 'egg' WHERE "key" = 'oeuf'; +UPDATE "ingredients" SET "key" = 'coconut_milk' WHERE "key" = 'lait_de_coco'; +UPDATE "ingredients" SET "key" = 'coconut_cream' WHERE "key" = 'creme_de_coco'; +UPDATE "ingredients" SET "key" = 'almond_milk' WHERE "key" = 'lait_d_amande'; +UPDATE "ingredients" SET "key" = 'oat_milk' WHERE "key" = 'lait_d_avoine'; +UPDATE "ingredients" SET "key" = 'tofu' WHERE "key" = 'tofu'; +UPDATE "ingredients" SET "key" = 'silken_tofu' WHERE "key" = 'tofu_soyeux'; +UPDATE "ingredients" SET "key" = 'herbes_de_provence' WHERE "key" = 'herbes_de_provence'; +UPDATE "ingredients" SET "key" = 'black_pepper' WHERE "key" = 'poivre_noir'; +UPDATE "ingredients" SET "key" = 'paprika' WHERE "key" = 'paprika'; +UPDATE "ingredients" SET "key" = 'espelette_pepper' WHERE "key" = 'piment_d_espelette'; +UPDATE "ingredients" SET "key" = 'cayenne_pepper' WHERE "key" = 'piment_de_cayenne'; +UPDATE "ingredients" SET "key" = 'cumin' WHERE "key" = 'cumin'; +UPDATE "ingredients" SET "key" = 'curry_powder' WHERE "key" = 'curry_poudre'; +UPDATE "ingredients" SET "key" = 'turmeric' WHERE "key" = 'curcuma'; +UPDATE "ingredients" SET "key" = 'cinnamon' WHERE "key" = 'cannelle'; +UPDATE "ingredients" SET "key" = 'nutmeg' WHERE "key" = 'muscade'; +UPDATE "ingredients" SET "key" = 'saffron' WHERE "key" = 'safran'; +UPDATE "ingredients" SET "key" = 'clove' WHERE "key" = 'clou_de_girofle'; +UPDATE "ingredients" SET "key" = 'vanilla_bean' WHERE "key" = 'vanille_gousse'; +UPDATE "ingredients" SET "key" = 'white_pepper' WHERE "key" = 'poivre_blanc'; +UPDATE "ingredients" SET "key" = 'pink_pepper' WHERE "key" = 'poivre_rose'; +UPDATE "ingredients" SET "key" = 'sichuan_pepper' WHERE "key" = 'poivre_du_sichuan'; +UPDATE "ingredients" SET "key" = 'smoked_paprika' WHERE "key" = 'paprika_fume'; +UPDATE "ingredients" SET "key" = 'bird_eye_chili' WHERE "key" = 'piment_oiseau'; +UPDATE "ingredients" SET "key" = 'juniper_berries' WHERE "key" = 'baies_de_genievre'; +UPDATE "ingredients" SET "key" = 'star_anise' WHERE "key" = 'anis_etoile_badiane'; +UPDATE "ingredients" SET "key" = 'green_anise' WHERE "key" = 'anis_vert'; +UPDATE "ingredients" SET "key" = 'fennel_seeds' WHERE "key" = 'graines_de_fenouil'; +UPDATE "ingredients" SET "key" = 'sumac' WHERE "key" = 'sumac'; +UPDATE "ingredients" SET "key" = 'nigella' WHERE "key" = 'nigelle'; +UPDATE "ingredients" SET "key" = 'allspice' WHERE "key" = 'quatre_epices'; +UPDATE "ingredients" SET "key" = 'colombo_powder' WHERE "key" = 'colombo_poudre'; +UPDATE "ingredients" SET "key" = 'baharat' WHERE "key" = 'baharat'; +UPDATE "ingredients" SET "key" = 'horseradish' WHERE "key" = 'raifort'; +UPDATE "ingredients" SET "key" = 'herb_salt' WHERE "key" = 'sel_aux_herbes'; +UPDATE "ingredients" SET "key" = 'celery_salt' WHERE "key" = 'sel_de_celeri'; +UPDATE "ingredients" SET "key" = 'fleur_de_sel' WHERE "key" = 'fleur_de_sel'; +UPDATE "ingredients" SET "key" = 'salt' WHERE "key" = 'sel'; +UPDATE "ingredients" SET "key" = 'five_spice' WHERE "key" = 'cinq_epices'; +UPDATE "ingredients" SET "key" = 'garam_masala' WHERE "key" = 'garam_masala'; +UPDATE "ingredients" SET "key" = 'coriander_seeds' WHERE "key" = 'graines_de_coriandre'; +UPDATE "ingredients" SET "key" = 'cardamom' WHERE "key" = 'cardamome'; +UPDATE "ingredients" SET "key" = 'fenugreek' WHERE "key" = 'fenugrec'; +UPDATE "ingredients" SET "key" = 'jalapeno' WHERE "key" = 'piment_jalapeno'; +UPDATE "ingredients" SET "key" = 'chipotle' WHERE "key" = 'piment_chipotle'; +UPDATE "ingredients" SET "key" = 'poblano_pepper' WHERE "key" = 'piment_poblano'; +UPDATE "ingredients" SET "key" = 'habanero' WHERE "key" = 'piment_habanero'; +UPDATE "ingredients" SET "key" = 'ras_el_hanout' WHERE "key" = 'ras_el_hanout'; +UPDATE "ingredients" SET "key" = 'zaatar' WHERE "key" = 'za_atar'; +UPDATE "ingredients" SET "key" = 'soy_sauce' WHERE "key" = 'sauce_soja'; +UPDATE "ingredients" SET "key" = 'mustard' WHERE "key" = 'moutarde'; +UPDATE "ingredients" SET "key" = 'mayonnaise' WHERE "key" = 'mayonnaise'; +UPDATE "ingredients" SET "key" = 'ketchup' WHERE "key" = 'ketchup'; +UPDATE "ingredients" SET "key" = 'tabasco' WHERE "key" = 'tabasco'; +UPDATE "ingredients" SET "key" = 'worcestershire_sauce' WHERE "key" = 'sauce_worcestershire'; +UPDATE "ingredients" SET "key" = 'fish_sauce' WHERE "key" = 'sauce_nuoc_mam'; +UPDATE "ingredients" SET "key" = 'wasabi' WHERE "key" = 'wasabi'; +UPDATE "ingredients" SET "key" = 'harissa' WHERE "key" = 'harissa'; +UPDATE "ingredients" SET "key" = 'curry_paste' WHERE "key" = 'pate_de_curry'; +UPDATE "ingredients" SET "key" = 'peanut_butter' WHERE "key" = 'beurre_de_cacahuete'; +UPDATE "ingredients" SET "key" = 'dijon_mustard' WHERE "key" = 'moutarde_de_dijon'; +UPDATE "ingredients" SET "key" = 'wholegrain_mustard' WHERE "key" = 'moutarde_a_l_ancienne'; +UPDATE "ingredients" SET "key" = 'barbecue_sauce' WHERE "key" = 'sauce_barbecue'; +UPDATE "ingredients" SET "key" = 'tartar_sauce' WHERE "key" = 'sauce_tartare'; +UPDATE "ingredients" SET "key" = 'cocktail_sauce' WHERE "key" = 'sauce_cocktail'; +UPDATE "ingredients" SET "key" = 'bearnaise_sauce' WHERE "key" = 'sauce_bearnaise'; +UPDATE "ingredients" SET "key" = 'hollandaise_sauce' WHERE "key" = 'sauce_hollandaise'; +UPDATE "ingredients" SET "key" = 'bechamel_sauce' WHERE "key" = 'sauce_bechamel'; +UPDATE "ingredients" SET "key" = 'teriyaki_sauce' WHERE "key" = 'sauce_teriyaki'; +UPDATE "ingredients" SET "key" = 'ponzu_sauce' WHERE "key" = 'sauce_ponzu'; +UPDATE "ingredients" SET "key" = 'chimichurri' WHERE "key" = 'chimichurri'; +UPDATE "ingredients" SET "key" = 'red_pesto' WHERE "key" = 'pesto_rouge_tomates_sechees'; +UPDATE "ingredients" SET "key" = 'pesto' WHERE "key" = 'pesto'; +UPDATE "ingredients" SET "key" = 'oyster_sauce' WHERE "key" = 'sauce_huitre'; +UPDATE "ingredients" SET "key" = 'hoisin_sauce' WHERE "key" = 'sauce_hoisin'; +UPDATE "ingredients" SET "key" = 'sriracha' WHERE "key" = 'sauce_sriracha'; +UPDATE "ingredients" SET "key" = 'sweet_chili_sauce' WHERE "key" = 'sauce_sweet_chili'; +UPDATE "ingredients" SET "key" = 'miso' WHERE "key" = 'miso'; +UPDATE "ingredients" SET "key" = 'shrimp_paste' WHERE "key" = 'pate_de_crevettes'; +UPDATE "ingredients" SET "key" = 'red_curry_paste' WHERE "key" = 'pate_de_curry_rouge_thai'; +UPDATE "ingredients" SET "key" = 'green_curry_paste' WHERE "key" = 'pate_de_curry_vert_thai'; +UPDATE "ingredients" SET "key" = 'tahini' WHERE "key" = 'tahini'; +UPDATE "ingredients" SET "key" = 'olive_oil' WHERE "key" = 'huile_d_olive'; +UPDATE "ingredients" SET "key" = 'sunflower_oil' WHERE "key" = 'huile_de_tournesol'; +UPDATE "ingredients" SET "key" = 'rapeseed_oil' WHERE "key" = 'huile_de_colza'; +UPDATE "ingredients" SET "key" = 'coconut_oil' WHERE "key" = 'huile_de_coco'; +UPDATE "ingredients" SET "key" = 'sesame_oil' WHERE "key" = 'huile_de_sesame'; +UPDATE "ingredients" SET "key" = 'cider_vinegar' WHERE "key" = 'vinaigre_de_cidre'; +UPDATE "ingredients" SET "key" = 'white_vinegar' WHERE "key" = 'vinaigre_blanc'; +UPDATE "ingredients" SET "key" = 'balsamic_vinegar' WHERE "key" = 'vinaigre_balsamique'; +UPDATE "ingredients" SET "key" = 'capers' WHERE "key" = 'capres'; +UPDATE "ingredients" SET "key" = 'olives' WHERE "key" = 'olives'; +UPDATE "ingredients" SET "key" = 'white_wine' WHERE "key" = 'vin_blanc_cuisine'; +UPDATE "ingredients" SET "key" = 'red_wine' WHERE "key" = 'vin_rouge_cuisine'; +UPDATE "ingredients" SET "key" = 'red_wine_vinegar' WHERE "key" = 'vinaigre_de_vin_rouge'; +UPDATE "ingredients" SET "key" = 'white_wine_vinegar' WHERE "key" = 'vinaigre_de_vin_blanc'; +UPDATE "ingredients" SET "key" = 'sherry_vinegar' WHERE "key" = 'vinaigre_de_xeres'; +UPDATE "ingredients" SET "key" = 'walnut_oil' WHERE "key" = 'huile_de_noix'; +UPDATE "ingredients" SET "key" = 'hazelnut_oil' WHERE "key" = 'huile_de_noisette'; +UPDATE "ingredients" SET "key" = 'peanut_oil' WHERE "key" = 'huile_d_arachide'; +UPDATE "ingredients" SET "key" = 'chili_oil' WHERE "key" = 'huile_pimentee'; +UPDATE "ingredients" SET "key" = 'rice_vinegar' WHERE "key" = 'vinaigre_de_riz'; +UPDATE "ingredients" SET "key" = 'mirin' WHERE "key" = 'mirin'; +UPDATE "ingredients" SET "key" = 'sake' WHERE "key" = 'sake_cuisine'; +UPDATE "ingredients" SET "key" = 'lemon_juice' WHERE "key" = 'jus_de_citron'; +UPDATE "ingredients" SET "key" = 'lime_juice' WHERE "key" = 'jus_de_citron_vert'; +UPDATE "ingredients" SET "key" = 'orange_juice' WHERE "key" = 'jus_d_orange'; +UPDATE "ingredients" SET "key" = 'apple_juice' WHERE "key" = 'jus_de_pomme'; +UPDATE "ingredients" SET "key" = 'grape_juice' WHERE "key" = 'jus_de_raisin'; +UPDATE "ingredients" SET "key" = 'tomato_juice' WHERE "key" = 'jus_de_tomate'; +UPDATE "ingredients" SET "key" = 'cranberry_juice' WHERE "key" = 'jus_de_cranberry'; +UPDATE "ingredients" SET "key" = 'coffee' WHERE "key" = 'cafe'; +UPDATE "ingredients" SET "key" = 'tea' WHERE "key" = 'the'; +UPDATE "ingredients" SET "key" = 'beer' WHERE "key" = 'biere_cuisine'; +UPDATE "ingredients" SET "key" = 'cider' WHERE "key" = 'cidre_cuisine'; +UPDATE "ingredients" SET "key" = 'champagne' WHERE "key" = 'champagne_vin_petillant_cuisine'; +UPDATE "ingredients" SET "key" = 'port_wine' WHERE "key" = 'porto_cuisine'; +UPDATE "ingredients" SET "key" = 'vin_jaune' WHERE "key" = 'vin_jaune_cuisine'; +UPDATE "ingredients" SET "key" = 'cognac' WHERE "key" = 'cognac'; +UPDATE "ingredients" SET "key" = 'rum' WHERE "key" = 'rhum'; +UPDATE "ingredients" SET "key" = 'whisky' WHERE "key" = 'whisky'; +UPDATE "ingredients" SET "key" = 'vodka' WHERE "key" = 'vodka'; +UPDATE "ingredients" SET "key" = 'wheat_flour' WHERE "key" = 'farine_de_ble'; +UPDATE "ingredients" SET "key" = 'whole_wheat_flour' WHERE "key" = 'farine_complete'; +UPDATE "ingredients" SET "key" = 'corn_flour' WHERE "key" = 'farine_de_mais'; +UPDATE "ingredients" SET "key" = 'buckwheat_flour' WHERE "key" = 'farine_de_sarrasin'; +UPDATE "ingredients" SET "key" = 'rice_flour' WHERE "key" = 'farine_de_riz'; +UPDATE "ingredients" SET "key" = 'vegetable_stock_cube' WHERE "key" = 'bouillon_cube_legumes'; +UPDATE "ingredients" SET "key" = 'chicken_stock_cube' WHERE "key" = 'bouillon_cube_volaille'; +UPDATE "ingredients" SET "key" = 'tomato_paste' WHERE "key" = 'concentre_de_tomate'; +UPDATE "ingredients" SET "key" = 'tomato_coulis' WHERE "key" = 'coulis_de_tomate'; +UPDATE "ingredients" SET "key" = 'canned_peeled_tomatoes' WHERE "key" = 'tomates_pelees_conserve'; +UPDATE "ingredients" SET "key" = 'sun_dried_tomatoes' WHERE "key" = 'tomates_sechees'; +UPDATE "ingredients" SET "key" = 'veal_stock' WHERE "key" = 'fond_de_veau'; +UPDATE "ingredients" SET "key" = 'chicken_stock' WHERE "key" = 'fond_de_volaille'; +UPDATE "ingredients" SET "key" = 'beef_stock_cube' WHERE "key" = 'bouillon_cube_boeuf'; +UPDATE "ingredients" SET "key" = 'fish_stock_cube' WHERE "key" = 'bouillon_cube_poisson'; +UPDATE "ingredients" SET "key" = 'vegetable_broth' WHERE "key" = 'bouillon_de_legumes'; +UPDATE "ingredients" SET "key" = 'chicken_broth' WHERE "key" = 'bouillon_de_volaille'; +UPDATE "ingredients" SET "key" = 'beef_broth' WHERE "key" = 'bouillon_de_boeuf'; +UPDATE "ingredients" SET "key" = 'court_bouillon' WHERE "key" = 'court_bouillon'; +UPDATE "ingredients" SET "key" = 'dashi' WHERE "key" = 'dashi_bouillon_japonais'; +UPDATE "ingredients" SET "key" = 'shellfish_bisque' WHERE "key" = 'bisque_de_crustaces'; +UPDATE "ingredients" SET "key" = 'tapioca_flour' WHERE "key" = 'farine_de_tapioca'; +UPDATE "ingredients" SET "key" = 'masa_harina' WHERE "key" = 'masa_harina'; +UPDATE "ingredients" SET "key" = 'water' WHERE "key" = 'eau'; +UPDATE "ingredients" SET "key" = 'sparkling_water' WHERE "key" = 'eau_gazeuse'; +UPDATE "ingredients" SET "key" = 'orange_blossom_water' WHERE "key" = 'eau_de_fleur_d_oranger'; +UPDATE "ingredients" SET "key" = 'rose_water' WHERE "key" = 'eau_de_rose'; +UPDATE "ingredients" SET "key" = 'fish_fumet' WHERE "key" = 'fumet_de_poisson'; +UPDATE "ingredients" SET "key" = 'bakers_yeast' WHERE "key" = 'levure_boulangere'; +UPDATE "ingredients" SET "key" = 'baking_powder' WHERE "key" = 'levure_chimique'; +UPDATE "ingredients" SET "key" = 'cornstarch' WHERE "key" = 'maizena'; +UPDATE "ingredients" SET "key" = 'lupin_flour' WHERE "key" = 'farine_de_lupin'; +UPDATE "ingredients" SET "key" = 'gelatin' WHERE "key" = 'gelatine'; +UPDATE "ingredients" SET "key" = 'baking_soda' WHERE "key" = 'bicarbonate_de_soude'; +UPDATE "ingredients" SET "key" = 'potato_starch' WHERE "key" = 'fecule_de_pomme_de_terre'; +UPDATE "ingredients" SET "key" = 'sugar' WHERE "key" = 'sucre'; +UPDATE "ingredients" SET "key" = 'honey' WHERE "key" = 'miel'; +UPDATE "ingredients" SET "key" = 'maple_syrup' WHERE "key" = 'sirop_d_erable'; +UPDATE "ingredients" SET "key" = 'brown_sugar' WHERE "key" = 'sucre_roux'; +UPDATE "ingredients" SET "key" = 'powdered_sugar' WHERE "key" = 'sucre_glace'; +UPDATE "ingredients" SET "key" = 'demerara_sugar' WHERE "key" = 'cassonade'; +UPDATE "ingredients" SET "key" = 'dark_chocolate' WHERE "key" = 'chocolat_noir'; +UPDATE "ingredients" SET "key" = 'milk_chocolate' WHERE "key" = 'chocolat_au_lait'; +UPDATE "ingredients" SET "key" = 'white_chocolate' WHERE "key" = 'chocolat_blanc'; +UPDATE "ingredients" SET "key" = 'chocolate_chips' WHERE "key" = 'pepites_de_chocolat'; +UPDATE "ingredients" SET "key" = 'cocoa_powder' WHERE "key" = 'cacao_en_poudre'; +UPDATE "ingredients" SET "key" = 'vanilla_extract' WHERE "key" = 'extrait_de_vanille'; +UPDATE "ingredients" SET "key" = 'palm_sugar' WHERE "key" = 'sucre_de_palme'; +UPDATE "ingredients" SET "key" = 'cane_syrup' WHERE "key" = 'sirop_de_sucre_de_canne'; + diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index c680e77..6b29be4 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -34,13 +34,17 @@ model House { @@map("house") } -/// `name` is `@unique` — not in the original spec doc, added so the seed -/// script (prisma/seed.ts) can `upsert` by name and stay idempotent/safe to +/// `key` is `@unique` — not in the original spec doc, added so the seed +/// script (prisma/seed.ts) can `upsert` by key and stay idempotent/safe to /// re-run, and so two reference rows can never silently duplicate the same -/// regime. +/// regime. A stable slug (e.g. `"vegetarien"`), not the display label — +/// the label itself lives in `apps/web`'s `locales/fr/translation.json` +/// under `catalog.diets.` (see `reference-seed-data.ts`'s `DIETS` and +/// `utils/slugify.ts`), so it can be edited/translated without ever +/// touching this column or the rows that reference it by id. model Diet { - id Int @id @default(autoincrement()) - name String @unique + id Int @id @default(autoincrement()) + key String @unique users UserProfile[] recipes RecipeDiet[] @@ -59,11 +63,13 @@ enum AllergenKind { } /// Enumeration-style table, meant to grow over time (e.g. allergy nuances). -/// `name` is `@unique` for the same reason as `Diet.name` above. `kind` is -/// also not in the original spec doc — see {@link AllergenKind}. +/// `key` is `@unique` for the same reason as `Diet.key` above — a stable +/// slug (`catalog.allergens.` in `apps/web`'s locale file), not the +/// display label. `kind` is also not in the original spec doc — see +/// {@link AllergenKind}. model Category { id Int @id @default(autoincrement()) - name String @unique + key String @unique kind AllergenKind @default(ALLERGY) allergies Allergy[] @@ -288,9 +294,11 @@ model RecipeDiet { @@map("recipe_diet") } -/// `name` is `@unique` — not in the original spec doc, added so the seed -/// script (reference-seed-data.ts) can `upsert` by name and stay -/// idempotent/safe to re-run, same reason as `Diet.name`/`Category.name`. +/// `key` is `@unique` — not in the original spec doc, added so the seed +/// script (reference-seed-data.ts) can `upsert` by key and stay +/// idempotent/safe to re-run, same reason as `Diet.key`/`Category.key`. A +/// stable slug (`catalog.ingredients.` in `apps/web`'s locale file), +/// not the display label. /// Ingredients are reference data (like Diet/Allergy): seeded, never /// created/edited/deleted through the API. /// Not in the original spec doc — supermarket-aisle grouping ("rayons") so @@ -424,7 +432,7 @@ enum IngredientIcon { model Ingredient { id Int @id @default(autoincrement()) - name String @unique + key String @unique icon IngredientIcon @default(JAR) category IngredientCategory @default(EPICERIE_SECHE) subcategory IngredientSubcategory @default(AUTRES) diff --git a/apps/api/scripts/generate-catalog-i18n.ts b/apps/api/scripts/generate-catalog-i18n.ts new file mode 100644 index 0000000..cc1a80c --- /dev/null +++ b/apps/api/scripts/generate-catalog-i18n.ts @@ -0,0 +1,49 @@ +/** + * One-off generator, run by hand whenever the catalog's reference data + * changes (a new ingredient/diet/allergen added to + * `db/reference-seed-data.ts`, or an English key corrected in + * `catalog-en-keys.ts`): regenerates + * `apps/web/src/locales/fr/translation.json`'s + * `catalog.{diets,allergens,ingredients}` sections (English key -> French + * label), merged in without touching the rest of the file. + * + * Doesn't touch the database — a brand new diet/allergen/ingredient is + * created fresh by `seedReferenceData`'s normal create path (see + * `reference-seed-data.ts`), no backfill needed. Renaming an *existing* + * item's English key in `catalog-en-keys.ts` does need a one-off migration + * (`UPDATE ... SET key = ...`, keyed by the *old* key value) written by + * hand for that occasion — see + * `prisma/migrations/20260818193000_catalog_keys_to_english/` for the shape + * one looks like. + * + * Never imported by the app itself — a dev-time tool, run via + * `tsx scripts/generate-catalog-i18n.ts`. + */ +import { readFileSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { getEnglishKey } from "../src/db/catalog-en-keys.js"; +import { ALLERGENS, DIETS, INGREDIENT_GROUPS } from "../src/db/reference-seed-data.js"; + +const here = fileURLToPath(new URL(".", import.meta.url)); + +function toKeyLabelMap(labels: string[]): Record { + const map: Record = {}; + for (const label of labels) { + map[getEnglishKey(label)] = label; + } + return map; +} + +const diets = toKeyLabelMap(DIETS); +const allergens = toKeyLabelMap(ALLERGENS.map((a) => a.name)); +const ingredients = toKeyLabelMap(INGREDIENT_GROUPS.flatMap((g) => g.items.map((i) => i.name))); + +console.log( + `diets: ${Object.keys(diets).length}, allergens: ${Object.keys(allergens).length}, ingredients: ${Object.keys(ingredients).length}`, +); + +const localePath = `${here}../../web/src/locales/fr/translation.json`; +const locale = JSON.parse(readFileSync(localePath, "utf8")); +locale.catalog = { diets, allergens, ingredients }; +writeFileSync(localePath, `${JSON.stringify(locale, null, 2)}\n`); +console.log(`wrote ${localePath}`); diff --git a/apps/api/scripts/validate-catalog-en-keys.ts b/apps/api/scripts/validate-catalog-en-keys.ts new file mode 100644 index 0000000..d7199b2 --- /dev/null +++ b/apps/api/scripts/validate-catalog-en-keys.ts @@ -0,0 +1,46 @@ +import { getEnglishKey } from "../src/db/catalog-en-keys.js"; +/** + * One-off validation, run by hand: checks that `catalog-en-keys.ts` has an + * entry for every diet/allergen/ingredient currently in + * `reference-seed-data.ts`, and that the resulting English keys are unique + * within each table. Not part of the app or the seed itself — just a + * pre-flight check while building/editing the dictionary by hand. + */ +import { ALLERGENS, DIETS, INGREDIENT_GROUPS } from "../src/db/reference-seed-data.js"; + +function check(label: string, names: string[]) { + const keys = new Map(); + const missing: string[] = []; + const duplicates: string[] = []; + for (const name of names) { + let key: string; + try { + key = getEnglishKey(name); + } catch { + missing.push(name); + continue; + } + const existing = keys.get(key); + if (existing !== undefined && existing !== name) { + duplicates.push(`"${existing}" and "${name}" both map to "${key}"`); + } + keys.set(key, name); + } + console.log(`${label}: ${names.length} names, ${keys.size} unique keys`); + if (missing.length > 0) { + console.log(` MISSING (${missing.length}):`, missing); + } + if (duplicates.length > 0) { + console.log(` DUPLICATES (${duplicates.length}):`, duplicates); + } +} + +check("Diets", DIETS); +check( + "Allergens", + ALLERGENS.map((a) => a.name), +); +check( + "Ingredients", + INGREDIENT_GROUPS.flatMap((g) => g.items.map((i) => i.name)), +); diff --git a/apps/api/src/db/catalog-en-keys.ts b/apps/api/src/db/catalog-en-keys.ts new file mode 100644 index 0000000..875548b --- /dev/null +++ b/apps/api/src/db/catalog-en-keys.ts @@ -0,0 +1,557 @@ +/** + * 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 = { + Omnivore: "omnivore", + Végétarien: "vegetarian", + Végan: "vegan", + Pescétarien: "pescatarian", + "Sans gluten": "gluten_free", +}; + +const ALLERGEN_KEYS: Record = { + 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 = { + // --- 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 = { + ...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); +} diff --git a/apps/api/src/db/reference-seed-data.ts b/apps/api/src/db/reference-seed-data.ts index d9de8d5..1d7d63a 100644 --- a/apps/api/src/db/reference-seed-data.ts +++ b/apps/api/src/db/reference-seed-data.ts @@ -5,10 +5,14 @@ import type { IngredientSubcategory, PrismaClient, } from "@prisma/client"; +import { getEnglishKey } from "./catalog-en-keys.js"; // Short, optional-to-pick regime list — `UserProfile.dietId` stays -// nullable, this is not meant to be exhaustive. -const DIETS = ["Omnivore", "Végétarien", "Végan", "Pescétarien", "Sans gluten"]; +// nullable, this is not meant to be exhaustive. Exported for +// `apps/api/scripts/generate-catalog-i18n.ts` (one-off, regenerates +// `apps/web`'s `locales/fr/translation.json` `catalog.diets` section from +// this same list). +export const DIETS = ["Omnivore", "Végétarien", "Végan", "Pescétarien", "Sans gluten"]; // The 14 allergens EU Regulation 1169/2011 (Annex II) requires food // businesses to declare — a standard, defensible reference list rather than @@ -17,7 +21,7 @@ const DIETS = ["Omnivore", "Végétarien", "Végan", "Pescétarien", "Sans glute // sensitivity) per the product decision discussed in chat: only Gluten and // Sulfites are commonly-recognized intolerances among the 14; the rest are // true allergens. -const ALLERGENS: Array<{ name: string; kind: AllergenKind }> = [ +export const ALLERGENS: Array<{ name: string; kind: AllergenKind }> = [ { name: "Gluten", kind: "INTOLERANCE" }, { name: "Crustacés", kind: "ALLERGY" }, { name: "Œufs", kind: "ALLERGY" }, @@ -92,7 +96,7 @@ interface IngredientSeed { // 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. -const INGREDIENT_GROUPS: Array<{ +export const INGREDIENT_GROUPS: Array<{ category: IngredientCategory; subcategory: IngredientSubcategory; defaultDiets: string[]; @@ -879,27 +883,38 @@ const INGREDIENTS: Array< /** * Populates the `Diet`/`Category`/`Allergy` reference tables. Idempotent * (safe to call against a database that already has this data — upserts by - * `name`, both `@unique`) — used both by `prisma/seed.ts` (the CLI entry + * `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 `name` below (`DIETS`, `ALLERGENS`, `INGREDIENT_GROUPS`) is an + * *authoring* label, never written to the database or seen by a client — + * {@link getEnglishKey} resolves each row's real, stable, English `key` + * from it (see `catalog-en-keys.ts` for why the key must be English even + * though this file's labels are French). The database only ever stores + * that key; the French label itself lives in `apps/web`'s + * `locales/fr/translation.json` (`catalog.*` + * namespace, kept in sync by `scripts/generate-catalog-i18n.ts`). */ export async function seedReferenceData(prisma: PrismaClient): Promise { for (const name of DIETS) { - await prisma.diet.upsert({ where: { name }, update: {}, create: { name } }); + const key = getEnglishKey(name); + await prisma.diet.upsert({ where: { key }, update: {}, create: { key } }); } - // `Allergy` itself carries no `name` — it's the selectable instance of a - // named `Category` (see schema.prisma) — so seeding an allergen means one - // Category (upserted by name) plus exactly one Allergy row under it, + // `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 { name, kind } of ALLERGENS) { + const key = getEnglishKey(name); const category = await prisma.category.upsert({ - where: { name }, + where: { key }, update: { kind }, - create: { name, kind }, + create: { key, kind }, }); const existing = await prisma.allergy.findFirst({ where: { categoryId: category.id } }); if (!existing) { @@ -916,17 +931,18 @@ export async function seedReferenceData(prisma: PrismaClient): Promise { // 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) => getEnglishKey(i.name)); const existingIngredients = await prisma.ingredient.findMany({ - where: { name: { in: INGREDIENTS.map((i) => i.name) } }, - select: { id: true, name: true, icon: true, category: true, subcategory: true }, + where: { key: { in: ingredientKeys } }, + select: { id: true, key: true, icon: true, category: true, subcategory: true }, }); - const existingByName = new Map(existingIngredients.map((i) => [i.name, i])); + const existingByKey = new Map(existingIngredients.map((i) => [i.key, i])); - const missingIngredients = INGREDIENTS.filter((i) => !existingByName.has(i.name)); + const missingIngredients = INGREDIENTS.filter((i) => !existingByKey.has(getEnglishKey(i.name))); if (missingIngredients.length > 0) { await prisma.ingredient.createMany({ data: missingIngredients.map(({ name, icon, category, subcategory }) => ({ - name, + key: getEnglishKey(name), icon, category, subcategory, @@ -935,7 +951,7 @@ export async function seedReferenceData(prisma: PrismaClient): Promise { } const changed = INGREDIENTS.filter((i) => { - const existing = existingByName.get(i.name); + const existing = existingByKey.get(getEnglishKey(i.name)); return ( existing && (existing.icon !== i.icon || @@ -944,29 +960,32 @@ export async function seedReferenceData(prisma: PrismaClient): Promise { ); }); for (const { name, icon, category, subcategory } of changed) { - await prisma.ingredient.update({ where: { name }, data: { icon, category, subcategory } }); + await prisma.ingredient.update({ + where: { key: getEnglishKey(name) }, + data: { icon, category, subcategory }, + }); } // Re-resolve every ingredient's id (existing + just-created) and every - // allergy's id (by its category name) once, then link them in a single + // 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: { name: { in: INGREDIENTS.map((i) => i.name) } }, - select: { id: true, name: true }, + where: { key: { in: ingredientKeys } }, + select: { id: true, key: true }, }); - const ingredientIdByName = new Map(allIngredients.map((i) => [i.name, i.id])); + const ingredientIdByKey = new Map(allIngredients.map((i) => [i.key, i.id])); const allergies = await prisma.allergy.findMany({ include: { category: true } }); - const allergyIdByCategoryName = new Map(allergies.map((a) => [a.category.name, a.id])); + const allergyIdByCategoryKey = new Map(allergies.map((a) => [a.category.key, a.id])); const links: Array<{ ingredientId: number; allergyId: number }> = []; for (const { name, allergenNames } of INGREDIENTS) { - const ingredientId = ingredientIdByName.get(name); + const ingredientId = ingredientIdByKey.get(getEnglishKey(name)); if (ingredientId === undefined) continue; for (const allergenName of allergenNames) { - const allergyId = allergyIdByCategoryName.get(allergenName); + const allergyId = allergyIdByCategoryKey.get(getEnglishKey(allergenName)); if (allergyId !== undefined) links.push({ ingredientId, allergyId }); } } @@ -978,14 +997,14 @@ export async function seedReferenceData(prisma: PrismaClient): Promise { // `dietNames` (item override, falling back to its group's `defaultDiets` // in the `INGREDIENTS` flatten step) instead of `allergenNames`. const diets = await prisma.diet.findMany(); - const dietIdByName = new Map(diets.map((d) => [d.name, d.id])); + const dietIdByKey = new Map(diets.map((d) => [d.key, d.id])); const dietLinks: Array<{ ingredientId: number; dietId: number }> = []; for (const { name, dietNames } of INGREDIENTS) { - const ingredientId = ingredientIdByName.get(name); + const ingredientId = ingredientIdByKey.get(getEnglishKey(name)); if (ingredientId === undefined) continue; for (const dietName of dietNames) { - const dietId = dietIdByName.get(dietName); + const dietId = dietIdByKey.get(getEnglishKey(dietName)); if (dietId !== undefined) dietLinks.push({ ingredientId, dietId }); } } diff --git a/apps/api/src/modules/recipe/recipe.service.ts b/apps/api/src/modules/recipe/recipe.service.ts index 0e80a1f..b79f4e3 100644 --- a/apps/api/src/modules/recipe/recipe.service.ts +++ b/apps/api/src/modules/recipe/recipe.service.ts @@ -39,21 +39,21 @@ type IngredientWithDetails = RecipeWithDetails["ingredients"][number]["ingredien function toIngredientView(ingredient: IngredientWithDetails): IngredientView { return { id: ingredient.id, - name: ingredient.name, + key: ingredient.key, icon: ingredient.icon, category: ingredient.category, subcategory: ingredient.subcategory, allergens: ingredient.allergies.map(({ allergy }) => ({ id: allergy.id, - name: allergy.category.name, + key: allergy.category.key, kind: allergy.category.kind, })), - diets: ingredient.diets.map(({ diet }) => ({ id: diet.id, name: diet.name })), + diets: ingredient.diets.map(({ diet }) => ({ id: diet.id, key: diet.key })), }; } -function toDietView(diet: { id: number; name: string }): DietView { - return { id: diet.id, name: diet.name }; +function toDietView(diet: { id: number; key: string }): DietView { + return { id: diet.id, key: diet.key }; } /** Deduplicates allergens (by id) across every ingredient of a recipe, for the aggregated "contains" badge — see {@link RecipeSummaryView.allergens}. */ diff --git a/apps/api/src/modules/reference/reference.service.ts b/apps/api/src/modules/reference/reference.service.ts index 832f770..247d4d2 100644 --- a/apps/api/src/modules/reference/reference.service.ts +++ b/apps/api/src/modules/reference/reference.service.ts @@ -1,35 +1,43 @@ import type { AllergyView, DietView, IngredientView } from "@batch-cooking/shared"; import { prisma } from "../../db/prisma.js"; -/** All reference dietary regimes, alphabetically — small, static list (see prisma/seed.ts). */ +/** + * All reference dietary regimes, ordered by `key` — small, static list (see + * prisma/seed.ts). `key` is a stable slug, not the display label (see + * {@link DietView}), so this is an alphabetical-by-slug order rather than a + * true French alphabetical one — close enough for a 5-item list, and the + * server has no other order to offer now that the label itself only exists + * client-side (`apps/web`'s `locales/fr/translation.json`). + */ export async function getDiets(): Promise { - return prisma.diet.findMany({ orderBy: { name: "asc" } }); + return prisma.diet.findMany({ orderBy: { key: "asc" } }); } /** - * All reference allergens, alphabetically. `Allergy` carries no `name` of - * its own — it's the selectable instance of a named `Category` (see - * schema.prisma) — so this resolves each allergen's display name from its - * category and flattens the split away for callers. + * All reference allergens, ordered by key (see {@link getDiets} for why key, + * not label). `Allergy` carries no `key` of its own — it's the selectable + * instance of a keyed `Category` (see schema.prisma) — so this resolves + * each allergen's key from its category and flattens the split away for + * callers. */ export async function getAllergies(): Promise { const allergies = await prisma.allergy.findMany({ - include: { category: { select: { name: true, kind: true } } }, - orderBy: { category: { name: "asc" } }, + include: { category: { select: { key: true, kind: true } } }, + orderBy: { category: { key: "asc" } }, }); return allergies.map((allergy) => ({ id: allergy.id, - name: allergy.category.name, + key: allergy.category.key, kind: allergy.category.kind, })); } /** - * All reference ingredients, alphabetically, each resolved to its allergens - * (see `IngredientAllergy` in schema.prisma) and compatible diet regimes - * (see `IngredientDiet`) — same aplattening approach as {@link getAllergies}. - * Ingredients with no linked allergen/diet come back with `allergens: []`/ - * `diets: []`. + * All reference ingredients, ordered by key (see {@link getDiets} for why), + * each resolved to its allergens (see `IngredientAllergy` in schema.prisma) + * and compatible diet regimes (see `IngredientDiet`) — same flattening + * approach as {@link getAllergies}. Ingredients with no linked + * allergen/diet come back with `allergens: []`/`diets: []`. */ export async function getIngredients(): Promise { const ingredients = await prisma.ingredient.findMany({ @@ -37,19 +45,19 @@ export async function getIngredients(): Promise { allergies: { include: { allergy: { include: { category: true } } } }, diets: { include: { diet: true } }, }, - orderBy: { name: "asc" }, + orderBy: { key: "asc" }, }); return ingredients.map((ingredient) => ({ id: ingredient.id, - name: ingredient.name, + key: ingredient.key, icon: ingredient.icon, category: ingredient.category, subcategory: ingredient.subcategory, allergens: ingredient.allergies.map(({ allergy }) => ({ id: allergy.id, - name: allergy.category.name, + key: allergy.category.key, kind: allergy.category.kind, })), - diets: ingredient.diets.map(({ diet }) => ({ id: diet.id, name: diet.name })), + diets: ingredient.diets.map(({ diet }) => ({ id: diet.id, key: diet.key })), })); } diff --git a/apps/api/src/utils/slugify.ts b/apps/api/src/utils/slugify.ts new file mode 100644 index 0000000..9649aed --- /dev/null +++ b/apps/api/src/utils/slugify.ts @@ -0,0 +1,26 @@ +/** + * Deterministic slug for a French reference-data label — used as the + * stable, storage-safe `key` for `Diet`/`Category`/`Ingredient` rows (see + * `db/reference-seed-data.ts`), decoupled from the display label so the + * label itself can live in `apps/web`'s `locales/fr/translation.json` + * (`catalog.*` namespace) instead of the database. A row's `key` is derived + * from its seed-time French name once and then never changes — renaming the + * *label* later (a translation fix, a rewording) never touches the key, the + * FK-referencing rows, or any code that looks a row up by key. + * + * Handles the two French ligatures NFD decomposition doesn't touch (`œ`, + * `æ` aren't accented letters, they're distinct glyphs) explicitly, then + * strips every other accent via NFD decomposition + Unicode "Mark" removal + * (`\p{M}`, every combining diacritic NFD can produce), then collapses + * whatever isn't `[a-z0-9]` into single underscores. + */ +export function slugify(label: string): string { + return label + .toLowerCase() + .replace(/œ/g, "oe") + .replace(/æ/g, "ae") + .normalize("NFD") + .replace(/\p{M}/gu, "") + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, ""); +} diff --git a/apps/api/test/profile.test.ts b/apps/api/test/profile.test.ts index b2247ef..4368267 100644 --- a/apps/api/test/profile.test.ts +++ b/apps/api/test/profile.test.ts @@ -3,6 +3,7 @@ import { faker } from "@faker-js/faker"; import { expect } from "chai"; import request from "supertest"; import { createApp } from "../src/app.js"; +import { getEnglishKey } from "../src/db/catalog-en-keys.js"; import { prisma } from "../src/db/prisma.js"; import { resetDatabase } from "../test-support/reset-db.js"; @@ -39,7 +40,9 @@ describe("Profile", () => { it("sets the profile's regime to a valid, seeded diet", async () => { const agent = request.agent(app); await agent.post("/auth/signup").send(buildSignupPayload()); - const diet = await prisma.diet.findFirstOrThrow({ where: { name: "Végétarien" } }); + const diet = await prisma.diet.findFirstOrThrow({ + where: { key: getEnglishKey("Végétarien") }, + }); const res = await agent.patch("/profile/diet").send({ dietId: diet.id }); @@ -50,7 +53,7 @@ describe("Profile", () => { it("clears the regime when dietId is null", async () => { const agent = request.agent(app); await agent.post("/auth/signup").send(buildSignupPayload()); - const diet = await prisma.diet.findFirstOrThrow({ where: { name: "Végan" } }); + const diet = await prisma.diet.findFirstOrThrow({ where: { key: getEnglishKey("Végan") } }); await agent.patch("/profile/diet").send({ dietId: diet.id }); const res = await agent.patch("/profile/diet").send({ dietId: null }); @@ -83,8 +86,8 @@ describe("Profile", () => { const agent = request.agent(app); await agent.post("/auth/signup").send(buildSignupPayload()); const allergies = await prisma.allergy.findMany({ include: { category: true } }); - const peanuts = allergies.find((a) => a.category.name === "Arachides"); - const gluten = allergies.find((a) => a.category.name === "Gluten"); + const peanuts = allergies.find((a) => a.category.key === getEnglishKey("Arachides")); + const gluten = allergies.find((a) => a.category.key === getEnglishKey("Gluten")); if (!peanuts || !gluten) throw new Error("expected seeded allergens missing"); const initial = await agent.get("/profile/allergies"); @@ -104,8 +107,8 @@ describe("Profile", () => { const agent = request.agent(app); await agent.post("/auth/signup").send(buildSignupPayload()); const allergies = await prisma.allergy.findMany({ include: { category: true } }); - const peanuts = allergies.find((a) => a.category.name === "Arachides"); - const gluten = allergies.find((a) => a.category.name === "Gluten"); + const peanuts = allergies.find((a) => a.category.key === getEnglishKey("Arachides")); + const gluten = allergies.find((a) => a.category.key === getEnglishKey("Gluten")); if (!peanuts || !gluten) throw new Error("expected seeded allergens missing"); await agent.patch("/profile/allergies").send({ allergyIds: [peanuts.id] }); @@ -140,8 +143,12 @@ describe("Profile", () => { it("starts empty, then reflects a saved selection", async () => { const agent = request.agent(app); await agent.post("/auth/signup").send(buildSignupPayload()); - const tomate = await prisma.ingredient.findFirstOrThrow({ where: { name: "Tomate" } }); - const oignon = await prisma.ingredient.findFirstOrThrow({ where: { name: "Oignon" } }); + const tomate = await prisma.ingredient.findFirstOrThrow({ + where: { key: getEnglishKey("Tomate") }, + }); + const oignon = await prisma.ingredient.findFirstOrThrow({ + where: { key: getEnglishKey("Oignon") }, + }); const initial = await agent.get("/profile/disliked-ingredients"); expect(initial.body).to.deep.equal([]); @@ -159,8 +166,12 @@ describe("Profile", () => { it("replaces (not merges) the previous selection", async () => { const agent = request.agent(app); await agent.post("/auth/signup").send(buildSignupPayload()); - const tomate = await prisma.ingredient.findFirstOrThrow({ where: { name: "Tomate" } }); - const oignon = await prisma.ingredient.findFirstOrThrow({ where: { name: "Oignon" } }); + const tomate = await prisma.ingredient.findFirstOrThrow({ + where: { key: getEnglishKey("Tomate") }, + }); + const oignon = await prisma.ingredient.findFirstOrThrow({ + where: { key: getEnglishKey("Oignon") }, + }); await agent .patch("/profile/disliked-ingredients") diff --git a/apps/api/test/recipe.test.ts b/apps/api/test/recipe.test.ts index 0af5e82..5cb356e 100644 --- a/apps/api/test/recipe.test.ts +++ b/apps/api/test/recipe.test.ts @@ -4,6 +4,7 @@ import { faker } from "@faker-js/faker"; import { expect } from "chai"; import request from "supertest"; import { createApp } from "../src/app.js"; +import { getEnglishKey } from "../src/db/catalog-en-keys.js"; import { prisma } from "../src/db/prisma.js"; import { resetDatabase } from "../test-support/reset-db.js"; @@ -19,9 +20,11 @@ function buildSignupPayload(): SignupInput { }; } -/** Resolves a reference ingredient's id by its `reference-seed-data.ts` name. */ +/** Resolves a reference ingredient's id by its `reference-seed-data.ts` French name (slugified to match its `key`). */ async function ingredientId(name: string): Promise { - const ingredient = await prisma.ingredient.findFirstOrThrow({ where: { name } }); + const ingredient = await prisma.ingredient.findFirstOrThrow({ + where: { key: getEnglishKey(name) }, + }); return ingredient.id; } @@ -163,7 +166,9 @@ describe("Recipes", () => { const { agent } = await signup(); const tomate = await ingredientId("Tomate"); const oeuf = await ingredientId("Œuf"); - const vegetarien = await prisma.diet.findFirstOrThrow({ where: { name: "Végétarien" } }); + const vegetarien = await prisma.diet.findFirstOrThrow({ + where: { key: getEnglishKey("Végétarien") }, + }); const res = await agent.post("/recipes").send({ name: "Omelette provençale", @@ -183,8 +188,12 @@ describe("Recipes", () => { res.body.steps.map((s: { description: string; order: number }) => s.order), ).to.deep.equal([0, 1]); // Allergens aggregated across ingredients — "Œuf" carries "Œufs". - expect(res.body.allergens.map((a: { name: string }) => a.name)).to.include("Œufs"); - expect(res.body.diets.map((d: { name: string }) => d.name)).to.deep.equal(["Végétarien"]); + expect(res.body.allergens.map((a: { key: string }) => a.key)).to.include( + getEnglishKey("Œufs"), + ); + expect(res.body.diets.map((d: { key: string }) => d.key)).to.deep.equal([ + getEnglishKey("Végétarien"), + ]); }); it("defaults to PERSONAL visibility, and stamps the author's current household", async () => { @@ -279,7 +288,7 @@ describe("Recipes", () => { expect(res.status).to.equal(200); expect(res.body.name).to.equal("Salade"); - expect(res.body.ingredients[0].ingredient.name).to.equal("Tomate"); + expect(res.body.ingredients[0].ingredient.key).to.equal(getEnglishKey("Tomate")); expect(res.body.isFavorite).to.equal(false); }); @@ -364,7 +373,7 @@ describe("Recipes", () => { expect(res.body.name).to.equal("Salade composée"); expect(res.body.visibility).to.equal("PUBLIC"); expect(res.body.ingredients).to.have.length(1); - expect(res.body.ingredients[0].ingredient.name).to.equal("Oignon"); + expect(res.body.ingredients[0].ingredient.key).to.equal(getEnglishKey("Oignon")); expect(res.body.steps).to.have.length(2); }); diff --git a/apps/api/test/reference.test.ts b/apps/api/test/reference.test.ts index 47e4e80..8fb423d 100644 --- a/apps/api/test/reference.test.ts +++ b/apps/api/test/reference.test.ts @@ -1,6 +1,7 @@ import { expect } from "chai"; import request from "supertest"; import { createApp } from "../src/app.js"; +import { getEnglishKey } from "../src/db/catalog-en-keys.js"; import { prisma } from "../src/db/prisma.js"; import { resetDatabase } from "../test-support/reset-db.js"; @@ -21,28 +22,29 @@ describe("Reference data", () => { expect(res.status).to.equal(200); expect(res.body).to.have.length(5); - expect(res.body.map((d: { name: string }) => d.name)).to.include("Végétarien"); - expect(res.body[0]).to.have.keys(["id", "name"]); + expect(res.body.map((d: { key: string }) => d.key)).to.include(getEnglishKey("Végétarien")); + expect(res.body[0]).to.have.keys(["id", "key"]); }); }); describe("GET /reference/allergies", () => { - it("returns the seeded allergens with their name resolved, no session required", async () => { + it("returns the seeded allergens with their key resolved, no session required", async () => { const res = await request(app).get("/reference/allergies"); expect(res.status).to.equal(200); expect(res.body).to.have.length(14); - expect(res.body.map((a: { name: string }) => a.name)).to.include("Arachides"); - expect(res.body[0]).to.have.keys(["id", "name", "kind"]); + expect(res.body.map((a: { key: string }) => a.key)).to.include(getEnglishKey("Arachides")); + expect(res.body[0]).to.have.keys(["id", "key", "kind"]); }); it("classifies Gluten and Sulfites as intolerances, the rest as allergies", async () => { const res = await request(app).get("/reference/allergies"); - const byName = (name: string) => res.body.find((a: { name: string }) => a.name === name); - expect(byName("Gluten").kind).to.equal("INTOLERANCE"); - expect(byName("Sulfites").kind).to.equal("INTOLERANCE"); - expect(byName("Arachides").kind).to.equal("ALLERGY"); + const byKey = (name: string) => + res.body.find((a: { key: string }) => a.key === getEnglishKey(name)); + expect(byKey("Gluten").kind).to.equal("INTOLERANCE"); + expect(byKey("Sulfites").kind).to.equal("INTOLERANCE"); + expect(byKey("Arachides").kind).to.equal("ALLERGY"); expect(res.body.filter((a: { kind: string }) => a.kind === "INTOLERANCE")).to.have.length(2); }); }); @@ -53,10 +55,10 @@ describe("Reference data", () => { expect(res.status).to.equal(200); expect(res.body.length).to.be.greaterThan(0); - expect(res.body.map((i: { name: string }) => i.name)).to.include("Tomate"); + expect(res.body.map((i: { key: string }) => i.key)).to.include(getEnglishKey("Tomate")); expect(res.body[0]).to.have.keys([ "id", - "name", + "key", "icon", "category", "subcategory", @@ -68,9 +70,12 @@ describe("Reference data", () => { it("resolves each ingredient's linked allergens, empty for one with none", async () => { const res = await request(app).get("/reference/ingredients"); - const byName = (name: string) => res.body.find((i: { name: string }) => i.name === name); - expect(byName("Œuf").allergens.map((a: { name: string }) => a.name)).to.include("Œufs"); - expect(byName("Tomate").allergens).to.deep.equal([]); + const byKey = (name: string) => + res.body.find((i: { key: string }) => i.key === getEnglishKey(name)); + expect(byKey("Œuf").allergens.map((a: { key: string }) => a.key)).to.include( + getEnglishKey("Œufs"), + ); + expect(byKey("Tomate").allergens).to.deep.equal([]); }); }); }); diff --git a/apps/web/cypress/e2e/onboarding.cy.ts b/apps/web/cypress/e2e/onboarding.cy.ts index c8cf368..93c2af8 100644 --- a/apps/web/cypress/e2e/onboarding.cy.ts +++ b/apps/web/cypress/e2e/onboarding.cy.ts @@ -32,8 +32,8 @@ describe("Onboarding wizard (regime → foyer → allergens)", () => { cy.intercept("GET", "**/reference/diets", { statusCode: 200, body: [ - { id: 1, name: "Omnivore" }, - { id: 2, name: "Végétarien" }, + { id: 1, key: "omnivore" }, + { id: 2, key: "vegetarian" }, ], }); cy.intercept("PATCH", "**/profile/diet", { @@ -48,8 +48,8 @@ describe("Onboarding wizard (regime → foyer → allergens)", () => { cy.intercept("GET", "**/reference/allergies", { statusCode: 200, body: [ - { id: 1, name: "Arachides", kind: "ALLERGY" }, - { id: 2, name: "Gluten", kind: "INTOLERANCE" }, + { id: 1, key: "peanuts", kind: "ALLERGY" }, + { id: 2, key: "gluten", kind: "INTOLERANCE" }, ], }); cy.intercept("PATCH", "**/profile/allergies", { statusCode: 200, body: [1] }).as( diff --git a/apps/web/cypress/e2e/preferences.cy.ts b/apps/web/cypress/e2e/preferences.cy.ts index fcaaa64..65f3111 100644 --- a/apps/web/cypress/e2e/preferences.cy.ts +++ b/apps/web/cypress/e2e/preferences.cy.ts @@ -16,15 +16,15 @@ describe("Dietary preferences (/parametres/preferences) — hot saving", () => { cy.intercept("GET", "**/reference/diets", { statusCode: 200, body: [ - { id: 1, name: "Omnivore" }, - { id: 2, name: "Végétarien" }, + { id: 1, key: "omnivore" }, + { id: 2, key: "vegetarian" }, ], }); cy.intercept("GET", "**/reference/allergies", { statusCode: 200, body: [ - { id: 1, name: "Arachides", kind: "ALLERGY" }, - { id: 2, name: "Gluten", kind: "INTOLERANCE" }, + { id: 1, key: "peanuts", kind: "ALLERGY" }, + { id: 2, key: "gluten", kind: "INTOLERANCE" }, ], }); cy.intercept("GET", "**/profile/allergies", { statusCode: 200, body: [2] }); diff --git a/apps/web/cypress/e2e/recipe-form.cy.ts b/apps/web/cypress/e2e/recipe-form.cy.ts new file mode 100644 index 0000000..44ae9fb --- /dev/null +++ b/apps/web/cypress/e2e/recipe-form.cy.ts @@ -0,0 +1,178 @@ +// Mocks the API via cy.intercept — see auth.cy.ts for the rationale (no +// live backend in this CI job; apps/api's own Mocha/Cucumber suites cover +// real API behavior against a real database). + +const authenticatedProfile = { + id: 1, + firstName: "Alice", + lastName: "Martin", + email: "alice@example.com", + tokenVersion: 0, + houseId: 1, + dietId: null, +}; + +const tomato = { + id: 1, + key: "tomato", + icon: "VEGETABLE", + category: "PRODUITS_FRAIS", + subcategory: "LEGUMES", + allergens: [], + diets: [{ id: 2, key: "vegetarian" }], +}; +const egg = { + id: 2, + key: "egg", + icon: "EGG", + category: "CREMERIE_FROMAGE", + subcategory: "OEUFS", + allergens: [{ id: 1, key: "eggs", kind: "ALLERGY" }], + diets: [], +}; +const carrot = { + id: 3, + key: "carrot", + icon: "VEGETABLE", + category: "PRODUITS_FRAIS", + subcategory: "LEGUMES", + allergens: [], + diets: [{ id: 2, key: "vegetarian" }], +}; + +const diets = [ + { id: 1, key: "omnivore" }, + { id: 2, key: "vegetarian" }, +]; + +function interceptCatalog() { + cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile }); + cy.intercept("GET", "**/reference/ingredients", { + statusCode: 200, + body: [tomato, egg, carrot], + }); + cy.intercept("GET", "**/reference/diets", { statusCode: 200, body: diets }); +} + +describe("Recipe form — associating ingredients", () => { + beforeEach(() => { + interceptCatalog(); + }); + + it("adds an ingredient from the picker, fills its quantity/unit, and creates the recipe", () => { + cy.intercept("POST", "**/recipes", { statusCode: 201, body: { id: 42 } }).as("createRecipe"); + + cy.visit("/recettes/nouvelle"); + + cy.get("#recipe-name").type("Salade de tomates"); + + cy.get("input[placeholder='Rechercher un ingrédient…']").type("tomat"); + cy.contains(".ingredient-picker__card", "Tomate").click(); + + // The card disappears from the picker once selected (excludeIds), and + // a row for it appears in the recipe's own ingredient list. + cy.contains(".ingredient-picker__card", "Tomate").should("not.exist"); + cy.contains(".ingredient-row__name", "Tomate").should("be.visible"); + + cy.get(".ingredient-row .ingredient-row__quantity").type("3"); + cy.get(".ingredient-row .ingredient-row__unit").type("unité"); + + cy.contains("button", "Ajouter une étape").click(); + cy.get(".step-list-editor__item textarea").type("Couper les tomates."); + + cy.contains("button", "Enregistrer").should("not.be.disabled").click(); + + cy.wait("@createRecipe") + .its("request.body") + .should("deep.include", { + name: "Salade de tomates", + ingredients: [{ ingredientId: 1, quantity: 3, unit: "unité" }], + }); + cy.url().should("include", "/recettes/42"); + }); + + // Regression test for the exact bug reported: `crypto.randomUUID()` (used + // to mint each ingredient/step draft's client-only React key) throws + // outside a secure context — https, or literally the hostname + // `localhost` — so a LAN IP during on-device testing or a Capacitor + // WebView's `capacitor://` origin hit a black screen with "TypeError: + // crypto.randomUUID is not a function" the instant an ingredient was + // added. Cypress's own origin is secure, so this forces the same failure + // by deleting `crypto.randomUUID` before the app boots — see + // `apps/web/src/lib/client-key.ts`, which replaced it. + it("still works when crypto.randomUUID is unavailable (insecure-context regression)", () => { + cy.visit("/recettes/nouvelle", { + onBeforeLoad(win) { + Object.defineProperty(win.crypto, "randomUUID", { + value: undefined, + configurable: true, + }); + }, + }); + + cy.get("#recipe-name").type("Recette hors contexte sécurisé"); + + cy.contains(".ingredient-picker__card", "Tomate").click(); + cy.contains(".ingredient-picker__card", "Œuf").click(); + + // Both rows rendered with distinct identities — no crash, no React + // "same key" warning silently collapsing one of them. + cy.get(".ingredient-row").should("have.length", 2); + cy.contains(".ingredient-row__name", "Tomate").should("be.visible"); + cy.contains(".ingredient-row__name", "Œuf").should("be.visible"); + + cy.contains("button", "Ajouter une étape").click(); + cy.contains("button", "Ajouter une étape").click(); + cy.get(".step-list-editor__item").should("have.length", 2); + }); + + it("excludes an already-selected ingredient from the picker, and removing it brings it back", () => { + cy.visit("/recettes/nouvelle"); + + cy.contains(".ingredient-picker__card", "Carotte").click(); + cy.contains(".ingredient-picker__card", "Carotte").should("not.exist"); + + cy.contains(".ingredient-row", "Carotte") + .find("button[title='Retirer cet ingrédient']") + .click(); + cy.contains(".ingredient-picker__card", "Carotte").should("be.visible"); + cy.get(".ingredient-row").should("have.length", 0); + }); + + it("preloads an existing recipe's ingredients when editing, and lets you add another", () => { + const existingRecipe = { + id: 7, + name: "Omelette", + description: null, + picture: null, + authorId: 1, + visibility: "PERSONAL", + allergens: [{ id: 1, key: "eggs", kind: "ALLERGY" }], + diets: [], + isFavorite: false, + ingredients: [{ ingredient: egg, quantity: 3, unit: "unité" }], + steps: [{ id: 1, description: "Battre les œufs.", picture: null, order: 0 }], + }; + cy.intercept("GET", "**/recipes/7", { statusCode: 200, body: existingRecipe }); + cy.intercept("PATCH", "**/recipes/7", { statusCode: 200, body: { id: 7 } }).as("updateRecipe"); + + cy.visit("/recettes/7/modifier"); + + cy.contains(".ingredient-row__name", "Œuf").should("be.visible"); + cy.get(".ingredient-row .ingredient-row__quantity").should("have.value", "3"); + + cy.contains(".ingredient-picker__card", "Tomate").click(); + cy.get(".ingredient-row").should("have.length", 2); + cy.get(".ingredient-row .ingredient-row__quantity").last().type("1"); + cy.get(".ingredient-row .ingredient-row__unit").last().type("unité"); + + cy.contains("button", "Enregistrer").click(); + + cy.wait("@updateRecipe") + .its("request.body.ingredients") + .should("deep.equal", [ + { ingredientId: 2, quantity: 3, unit: "unité" }, + { ingredientId: 1, quantity: 1, unit: "unité" }, + ]); + }); +}); diff --git a/apps/web/cypress/e2e/recipes.cy.ts b/apps/web/cypress/e2e/recipes.cy.ts new file mode 100644 index 0000000..318a410 --- /dev/null +++ b/apps/web/cypress/e2e/recipes.cy.ts @@ -0,0 +1,241 @@ +// Mocks the API via cy.intercept — see auth.cy.ts for the rationale (no +// live backend in this CI job; apps/api's own Mocha/Cucumber suites cover +// real API behavior against a real database). + +const authenticatedProfile = { + id: 1, + firstName: "Alice", + lastName: "Martin", + email: "alice@example.com", + tokenVersion: 0, + houseId: 1, + dietId: null, +}; + +const vegetarien = { id: 1, key: "vegetarian" }; +const gluten = { id: 1, key: "gluten", kind: "INTOLERANCE" }; +const oeufs = { id: 2, key: "eggs", kind: "ALLERGY" }; + +const ratatouille = { + id: 1, + name: "Ratatouille", + description: null, + picture: null, + authorId: 1, + visibility: "PERSONAL", + allergens: [], + diets: [vegetarien], + isFavorite: true, +}; + +const omelette = { + id: 2, + name: "Omelette", + description: null, + picture: null, + authorId: 1, + visibility: "PERSONAL", + allergens: [oeufs], + diets: [], + isFavorite: false, +}; + +const omeletteDetail = { + ...omelette, + description: "Une omelette toute simple.", + ingredients: [ + { + ingredient: { + id: 10, + key: "egg", + icon: "EGG", + category: "CREMERIE_FROMAGE", + subcategory: "OEUFS", + allergens: [oeufs], + diets: [], + }, + quantity: 3, + unit: "unité", + }, + ], + steps: [ + { id: 1, description: "Battre les œufs.", picture: null, order: 1 }, + { id: 2, description: "Cuire à la poêle.", picture: null, order: 2 }, + ], +}; + +function interceptAuth() { + cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile }); + cy.intercept("GET", "**/profile/disliked-ingredients", { statusCode: 200, body: [] }); +} + +describe("Recipe catalog", () => { + beforeEach(() => { + interceptAuth(); + }); + + it("defaults to the Favoris tab and lists its recipes", () => { + cy.intercept("GET", /\/recipes\?/, (req) => { + expect(req.url).to.include("tab=favoris"); + req.reply({ statusCode: 200, body: [ratatouille] }); + }).as("listRecipes"); + + cy.visit("/recettes"); + cy.wait("@listRecipes"); + + cy.contains("h1", "Recettes").should("be.visible"); + cy.get(".recipe-tabs__tab.active").should("contain.text", "Favoris"); + cy.contains(".recipe-table__name", "Ratatouille").should("be.visible"); + // The favorited row carries the ★ fav-mark. + cy.contains(".recipe-table__name", "Ratatouille") + .find(".recipe-table__fav-mark") + .should("exist"); + cy.contains(".recipe-table__name", "Ratatouille") + .parents("tr") + .find(".diet-badge") + .should("contain.text", "Végétarien"); + }); + + it("shows the empty state when a tab has no recipes", () => { + cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] }); + + cy.visit("/recettes"); + + cy.contains("Aucune recette pour le moment.").should("be.visible"); + }); + + it("shows an error state when the catalog fails to load", () => { + cy.intercept("GET", /\/recipes\?/, { statusCode: 500, body: { code: 5000, message: "boom" } }); + + cy.visit("/recettes"); + + cy.contains(".recipes-page__status--error", "Impossible de charger").should("be.visible"); + }); + + it("switches tabs, re-fetching each one's own recipes", () => { + cy.intercept("GET", /\/recipes\?/, (req) => { + const tab = new URL(req.url).searchParams.get("tab"); + const body = tab === "perso" ? [omelette] : [ratatouille]; + req.reply({ statusCode: 200, body }); + }).as("listRecipes"); + + cy.visit("/recettes"); + cy.wait("@listRecipes"); + cy.contains(".recipe-table__name", "Ratatouille").should("be.visible"); + + // Not asserting the specific request URL here — React StrictMode (see + // main.tsx) double-invokes mount/update effects in dev, so this can + // legitimately fire twice; the rendered result converges either way + // (same reasoning as planning-page.cy.ts's week-navigation tests). + cy.contains(".recipe-tabs__tab", "Perso").click(); + cy.get(".recipe-tabs__tab.active").should("contain.text", "Perso"); + cy.contains(".recipe-table__name", "Omelette").should("be.visible"); + cy.contains(".recipe-table__name", "Ratatouille").should("not.exist"); + + // The disabled "Sources (bientôt)" placeholder never becomes active. + cy.contains(".recipe-tabs__tab", "Sources (bientôt)").should("be.disabled"); + }); + + it("searches within the active tab, debounced", () => { + cy.intercept("GET", /\/recipes\?/, (req) => { + const search = new URL(req.url).searchParams.get("search"); + req.reply({ statusCode: 200, body: search ? [omelette] : [ratatouille, omelette] }); + }).as("listRecipes"); + + cy.visit("/recettes"); + cy.wait("@listRecipes"); + cy.contains(".recipe-table__name", "Ratatouille").should("be.visible"); + + // Same "assert the rendered result, not the request count/URL" reasoning + // as the tab-switch test above — the 300ms debounce plus StrictMode's + // double-invoked effects make the exact number/order of requests an + // implementation detail, not something worth pinning down here. + cy.get(".recipes-page__search").type("Omel"); + cy.contains(".recipe-table__name", "Omelette").should("be.visible"); + cy.contains(".recipe-table__name", "Ratatouille").should("not.exist"); + }); + + it("opens a recipe's detail alongside the table when its row is selected", () => { + cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [ratatouille, omelette] }); + cy.intercept("GET", "**/recipes/2", { statusCode: 200, body: omeletteDetail }).as("getRecipe"); + + cy.visit("/recettes"); + cy.contains(".recipe-table__name", "Omelette").click(); + cy.wait("@getRecipe"); + + cy.url().should("include", "/recettes/2"); + // The table stays mounted (master-detail, not a page navigation) — + // both rows are still visible next to the detail panel. + cy.contains(".recipe-table__name", "Ratatouille").should("be.visible"); + cy.get("tr.selected .recipe-table__name").should("contain.text", "Omelette"); + + cy.get(".recipe-detail-panel").within(() => { + cy.contains("h2", "Omelette").should("be.visible"); + cy.contains("Une omelette toute simple.").should("be.visible"); + cy.contains("Battre les œufs.").should("be.visible"); + cy.contains("Cuire à la poêle.").should("be.visible"); + }); + }); + + it("shows a not-found message for a selected id the API rejects", () => { + cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] }); + cy.intercept("GET", "**/recipes/999", { + statusCode: 404, + // ErrorCode.RECIPE_NOT_FOUND (packages/shared/src/errors/error-codes.ts) + // — RecipeDetailPanel only renders the "not found" message for this + // exact code, anything else falls into its generic error state. + body: { code: 4045, message: "not found" }, + }); + + cy.visit("/recettes/999"); + + cy.contains(".recipe-detail-panel", "Cette recette n'existe pas.").should("be.visible"); + }); + + it("toggles a recipe's favorite from the detail panel and reflects it in the table", () => { + cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [omelette] }); + cy.intercept("GET", "**/recipes/2", { statusCode: 200, body: omeletteDetail }); + cy.intercept("POST", "**/recipes/2/favorite", { statusCode: 204 }).as("favorite"); + + cy.visit("/recettes/2"); + + cy.contains(".recipe-table__name", "Omelette") + .find(".recipe-table__fav-mark") + .should("not.exist"); + + cy.get(".favorite-star-button").click(); + cy.wait("@favorite"); + + cy.get(".favorite-star-button").should("have.class", "is-favorite"); + cy.contains(".recipe-table__name", "Omelette").find(".recipe-table__fav-mark").should("exist"); + }); + + it("deletes a recipe after a two-step confirmation, then clears the selection", () => { + cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [omelette] }); + cy.intercept("GET", "**/recipes/2", { statusCode: 200, body: omeletteDetail }); + cy.intercept("DELETE", "**/recipes/2", { statusCode: 204 }).as("deleteRecipe"); + + cy.visit("/recettes/2"); + cy.contains(".recipe-detail-panel", "Omelette").should("be.visible"); + + cy.contains(".recipe-detail-panel__danger-button", "Supprimer").click(); + cy.contains(".recipe-detail-panel__danger-button", "Confirmer la suppression").click(); + cy.wait("@deleteRecipe"); + + cy.url().should("match", /\/recettes\/?$/); + cy.contains(".recipe-table__name", "Omelette").should("not.exist"); + cy.contains("Sélectionnez une recette dans le tableau").should("be.visible"); + }); + + it("links the new-recipe button to the recipe form", () => { + cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] }); + + cy.visit("/recettes"); + + cy.contains(".recipes-page__new-button", "Nouvelle recette").should( + "have.attr", + "href", + "/recettes/nouvelle", + ); + }); +}); diff --git a/apps/web/package.json b/apps/web/package.json index cf78bf5..eb06d08 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "web", - "version": "0.0.0", + "version": "0.2.0", "private": true, "type": "module", "scripts": { @@ -16,6 +16,7 @@ "@batch-cooking/date-tools": "workspace:*", "@batch-cooking/shared": "workspace:*", "i18next": "^26.3.6", + "lucide-react": "^1.32.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-i18next": "^17.0.11", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 5555b30..448840c 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -12,6 +12,7 @@ import { OnboardingAllergensPage } from "./pages/onboarding/OnboardingAllergensP import { OnboardingDietPage } from "./pages/onboarding/OnboardingDietPage"; import { OnboardingHouseholdPage } from "./pages/onboarding/OnboardingHouseholdPage"; import { AccountSettingsPage } from "./pages/settings/AccountSettingsPage"; +import { CreditsPage } from "./pages/settings/CreditsPage"; import { HouseholdSettingsPage } from "./pages/settings/HouseholdSettingsPage"; import { PreferencesPage } from "./pages/settings/PreferencesPage"; import { UserPreferencesPage } from "./pages/settings/UserPreferencesPage"; @@ -25,8 +26,8 @@ import { UserPreferencesPage } from "./pages/settings/UserPreferencesPage"; * itself redirects to `/login` if needed. * * `/parametres/*` (compte/préférences alimentaires/foyer/préférences - * utilisateur) are the settings pages, reachable from the sidebar's bottom - * "Paramètres" menu and the account menu (see `AppLayout`) — nested under + * utilisateur/crédits) are the settings pages, reachable from the sidebar's + * bottom "Paramètres" menu and the account menu (see `AppLayout`) — nested under * `AppLayout` like every other authenticated section. `/foyer` is the old, * pre-split combined page's path; it now just redirects to * `/parametres/foyer` so an existing bookmark/link keeps working. @@ -61,6 +62,7 @@ export function App() { } /> } /> } /> + } /> } /> input[type="checkbox"])` rule for the actual look (hidden + * native input, a `.check-mark` that scales in, `is-selected` driving the + * tinted/bordered state). Factors out the JSX triplet (`label` → hidden + * `input` → `span.check-mark` → label text) that used to be duplicated + * across `AllergySelect`, `DietTagSelect`, `IngredientPicker`'s display + * menu, and `UserPreferencesPage`'s theme picker (see {@link RadioOption} + * for its `type="radio"` sibling) — one place to get the markup/a11y right + * instead of four. + * + * `is-selected` is applied in JS from the same `checked` boolean the caller + * already has, not derived via a CSS `:has(:checked)` chain — that turned + * out unreliable across browsers (see the callers this replaces for the + * original note). + * + * `className` is for the *container* layout only (grid item, flex-wrap + * chip, stacked list…) — the control's own look never varies, so there's + * no `variant` prop here. + */ +export function CheckboxOption({ + checked, + onChange, + children, + className, +}: { + checked: boolean; + onChange: (checked: boolean) => void; + children: ReactNode; + className?: string; +}) { + return ( + + ); +} diff --git a/apps/web/src/components/ui/Radio.tsx b/apps/web/src/components/ui/Radio.tsx new file mode 100644 index 0000000..1a3e15f --- /dev/null +++ b/apps/web/src/components/ui/Radio.tsx @@ -0,0 +1,38 @@ +import type { ReactNode } from "react"; + +/** + * The `type="radio"` sibling of {@link CheckboxOption} — same "selectable + * card" markup/look (see `global.scss`'s `label:has(> input[...])` rule, + * shared by both), just a native radio input under the hood so a group of + * `RadioOption`s sharing `name` behaves as mutually exclusive (see + * `UserPreferencesPage`'s theme picker, the one caller so far). + */ +export function RadioOption({ + name, + value, + checked, + onChange, + children, + className, +}: { + name: string; + value: T; + checked: boolean; + onChange: (value: T) => void; + children: ReactNode; + className?: string; +}) { + return ( + + ); +} diff --git a/apps/web/src/features/profile/AllergySelect.tsx b/apps/web/src/features/profile/AllergySelect.tsx index 824b321..c886162 100644 --- a/apps/web/src/features/profile/AllergySelect.tsx +++ b/apps/web/src/features/profile/AllergySelect.tsx @@ -1,4 +1,6 @@ import type { AllergyView } from "@batch-cooking/shared"; +import { useTranslation } from "react-i18next"; +import { CheckboxOption } from "../../components/ui/Checkbox"; import "./profile-forms.scss"; interface AllergySelectProps { @@ -28,6 +30,8 @@ interface AllergySelectProps { * rationale as `DietSelect`. */ export function AllergySelect({ legend, allergies, value, onChange }: AllergySelectProps) { + const { t } = useTranslation(); + function toggle(id: number) { onChange(value.includes(id) ? value.filter((existing) => existing !== id) : [...value, id]); } @@ -38,19 +42,14 @@ export function AllergySelect({ legend, allergies, value, onChange }: AllergySel {allergies.map((allergy) => { const checked = value.includes(allergy.id); return ( - + {t(`catalog.allergens.${allergy.key}`)} + ); })} diff --git a/apps/web/src/features/profile/DietSelect.tsx b/apps/web/src/features/profile/DietSelect.tsx index b1211a7..6d9c969 100644 --- a/apps/web/src/features/profile/DietSelect.tsx +++ b/apps/web/src/features/profile/DietSelect.tsx @@ -33,7 +33,7 @@ export function DietSelect({ diets, value, onChange }: DietSelectProps) { {diets.map((diet) => ( ))} diff --git a/apps/web/src/features/profile/DislikedIngredientsField.tsx b/apps/web/src/features/profile/DislikedIngredientsField.tsx index 6c9556f..4139855 100644 --- a/apps/web/src/features/profile/DislikedIngredientsField.tsx +++ b/apps/web/src/features/profile/DislikedIngredientsField.tsx @@ -54,7 +54,7 @@ export function DislikedIngredientsField({ - {ingredient.name} + {t(`catalog.ingredients.${ingredient.key}`)} ); } diff --git a/apps/web/src/features/recipes/IngredientPicker.tsx b/apps/web/src/features/recipes/IngredientPicker.tsx index 79c18fc..307d3a6 100644 --- a/apps/web/src/features/recipes/IngredientPicker.tsx +++ b/apps/web/src/features/recipes/IngredientPicker.tsx @@ -7,6 +7,7 @@ import { } from "@batch-cooking/shared"; import { useState } from "react"; import { useTranslation } from "react-i18next"; +import { CheckboxOption } from "../../components/ui/Checkbox"; import { SettingsIcon } from "../../layouts/nav-icons"; import { AllergenBadges } from "./AllergenBadges"; import { DietBadges } from "./DietBadges"; @@ -73,8 +74,12 @@ export function IngredientPicker({ if (ingredient.category !== category) return false; if (subcategory !== ALL && ingredient.subcategory !== subcategory) return false; } - if (normalizedQuery.length > 0 && !ingredient.name.toLowerCase().includes(normalizedQuery)) { - return false; + // Matches against the *displayed* (translated) label, not the raw slug + // key — searching "œuf" should find "Œuf" the way it always has, not + // require typing its key. + if (normalizedQuery.length > 0) { + const label = t(`catalog.ingredients.${ingredient.key}`).toLowerCase(); + if (!label.includes(normalizedQuery)) return false; } return true; }); @@ -101,28 +106,16 @@ export function IngredientPicker({ aria-expanded={isDisplayMenuOpen} title={t("recipes.form.displayOptions")} > - +