From 42af7e6dbb7f4b8c13dd02a3d30057b26617fec0 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Sat, 22 Aug 2026 20:16:44 +0200 Subject: [PATCH] fix(recipes): active le matching ingredients/unites pour les sources en francais MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cause racine du signalement "beaucoup d'ingredients ne sont pas linkes, de meme pour les unites et les quantites" sur Marmiton/750g/Manger Bouger : translateRecipe (recipe-translation.ts) ET previewSourceItem (sources.service.ts) sautaient integralement loadIngredientCatalog/ loadUnitCatalog/translateRecipeIngredients des que locale !== "en" — aucune tentative de matching n'etait jamais faite pour une source francaise, pas un probleme de qualite de matching. Les trois sources ajoutees dans cette session sont toutes locale: "fr". Corrige en trois temps : - packages/shared/src/data/catalog-labels-fr.ts (nouveau) : INGREDIENT_LABELS_FR (554 entrees, copiees depuis apps/web/src/locales/fr/translation.json qui les avait deja pour l'UI — pas une nouvelle redaction), INGREDIENT_LABEL_SYNONYMS_FR (mecanisme existant, pour patcher au cas par cas les libelles dont le phrasage "affichage" ne correspond pas a l'ordre naturel d'un texte de recette — ex. vanillaBean), UNIT_LABELS_FR (17 entrees, redigees a la main comme UNIT_LABELS_EN — abreviations/variantes reellement utilisees en francais : cuillere a soupe/cas/c.a.s...). - ingredient-matcher.ts : stemWord se scinde en stemWordEn/stemWordFr (locale parametrable, defaut "en" pour ne rien casser) — le stemmer anglais appliquait sa regle "es" -> "" a des pluriels francais reguliers ("carottes" -> "carott" au lieu de "carotte"), cassant silencieusement le matching pour la quasi-totalite des ingredients francais dont le singulier se termine par une voyelle. matchUnit est reecrit pour chercher une sous-sequence ordonnee (comme matchIngredientName) plutot qu'une egalite du seul premier mot : un synonyme francais peut etre multi-mots ("cuillere a soupe"), une phrase entiere ne pouvant jamais egaler un seul mot extrait. loadIngredientCatalog/loadUnitCatalog prennent un parametre locale. - recipe-translation.ts/sources.service.ts : suppression du if (locale !== "en") qui court-circuitait tout — les catalogues sont desormais toujours charges avec la locale de la source ; une locale sans table de libelles recoit simplement des catalogues vides (degrade gracieusement, ne plante pas). Tests : 14 nouveaux tests purs (matchIngredientName/matchUnit fr, stemmer, regression), 3 nouveaux tests DB (loadIngredientCatalog/ loadUnitCatalog fr + locale inconnue), 3 nouveaux tests recipe-translation remplacant un test qui figeait l'ancien comportement cassé, 1 nouveau test d'integration HTTP (sources.test.ts) avec un adaptateur factice francais bout en bout. Les tests DB n'ont pas pu etre executes localement (pas de Postgres/Docker dans cet environnement sandbox) — a verifier en CI. Co-Authored-By: Claude Sonnet 5 --- .../lib/recipe-matching/ingredient-matcher.ts | 171 +++-- .../lib/recipe-matching/recipe-translation.ts | 64 +- .../src/modules/sources/sources.service.ts | 20 +- .../ingredient-matcher.test.ts | 125 +++- .../recipe-translation.test.ts | 46 +- apps/api/test/sources/sources.test.ts | 80 +++ packages/shared/src/data/catalog-labels-fr.ts | 661 ++++++++++++++++++ packages/shared/src/index.ts | 1 + 8 files changed, 1089 insertions(+), 79 deletions(-) create mode 100644 packages/shared/src/data/catalog-labels-fr.ts diff --git a/apps/api/src/lib/recipe-matching/ingredient-matcher.ts b/apps/api/src/lib/recipe-matching/ingredient-matcher.ts index cc47781..493897f 100644 --- a/apps/api/src/lib/recipe-matching/ingredient-matcher.ts +++ b/apps/api/src/lib/recipe-matching/ingredient-matcher.ts @@ -1,23 +1,26 @@ import { INGREDIENT_LABEL_SYNONYMS_EN, + INGREDIENT_LABEL_SYNONYMS_FR, INGREDIENT_LABELS_EN, + INGREDIENT_LABELS_FR, UNIT_LABELS_EN, + UNIT_LABELS_FR, } from "@batch-cooking/shared"; import { prisma } from "../../db/prisma.js"; import { normalizeText } from "./tech-step-matcher.js"; /** * Resolves the free-text `name`/`unit`/`quantity` a `RecipeSourceAdapter` - * lifts from an English-language source (`ParsedRecipeIngredient`) against - * our own `Ingredient`/`Unit` reference catalogs — the ingredient-side - * counterpart to `tech-step-matcher.ts`'s technique detection, built for - * the same reason: an English source's raw text has no idea our catalogs - * even exist. + * lifts from a recipe source (`ParsedRecipeIngredient`) against our own + * `Ingredient`/`Unit` reference catalogs — the ingredient-side counterpart + * to `tech-step-matcher.ts`'s technique detection, built for the same + * reason: a source's raw text has no idea our catalogs even exist. * * Unlike tech steps (regex mappings hand-authored per technique), - * ingredient/unit labels are plain hand-written English text - * (`INGREDIENT_LABELS_EN`/`UNIT_LABELS_EN`, `packages/shared`) — matching - * them against arbitrary free text (extra adjectives, plurals, "large diced + * ingredient/unit labels are plain hand-written text, one table per + * supported `locale` (`INGREDIENT_LABELS_EN`/`INGREDIENT_LABELS_FR`/ + * `UNIT_LABELS_EN`/`UNIT_LABELS_FR`, `packages/shared`) — matching them + * against arbitrary free text (extra adjectives, plurals, "large diced * yellow onion" for a catalog entry that's just "Onion") needs its own, * lighter algorithm: word-tokenize both sides, naively stem for plurals, * then look for the catalog phrase's tokens as a contiguous run inside the @@ -36,17 +39,17 @@ import { normalizeText } from "./tech-step-matcher.js"; * one-time training pass. */ -/** One `Ingredient` row trimmed to what {@link matchIngredientName} needs, alongside its English matching label. */ +/** One `Ingredient` row trimmed to what {@link matchIngredientName} needs, alongside its matching label in whichever locale it was loaded for. */ export interface IngredientMatchEntry { ingredientId: number; - /** English label from `INGREDIENT_LABELS_EN`, e.g. `"Chicken breast"` — matched against free text, never displayed. */ + /** Matching label — English (`INGREDIENT_LABELS_EN`) or French (`INGREDIENT_LABELS_FR`) depending on which locale {@link loadIngredientCatalog} was called with, e.g. `"Chicken breast"`/`"Blanc de poulet"` — matched against free text, never displayed. */ label: string; } -/** One `Unit` row trimmed to what {@link matchUnit} needs, alongside its accepted English spellings. */ +/** One `Unit` row trimmed to what {@link matchUnit} needs, alongside its accepted spellings in whichever locale it was loaded for. */ export interface UnitMatchEntry { unitId: number; - /** Accepted spellings from `UNIT_LABELS_EN`, e.g. `["tbsp", "tbs", "tablespoon", "tablespoons"]`. */ + /** Accepted spellings — English (`UNIT_LABELS_EN`) or French (`UNIT_LABELS_FR`), e.g. `["tbsp", "tbs", "tablespoon", "tablespoons"]` or `["cuillère à soupe", "cas", ...]`. Unlike English, a French entry can be genuinely multi-word — see `matchUnit`'s own doc comment. */ synonyms: string[]; } @@ -58,19 +61,45 @@ export interface UnitMatchEntry { * of every comparison go through it, not linguistically correct on its * own — see the module doc comment. */ -function stemWord(word: string): string { +function stemWordEn(word: string): string { if (word.endsWith("ies") && word.length > 4) return `${word.slice(0, -3)}y`; if (word.endsWith("es") && word.length > 3) return word.slice(0, -2); if (word.endsWith("s") && !word.endsWith("ss") && word.length > 3) return word.slice(0, -1); return word; } -/** Lowercases, strips diacritics (via `normalizeText`) and splits `text` into stemmed word tokens — empty/non-letter runs are dropped, not kept as empty tokens. */ -function tokenize(text: string): string[] { +/** + * Naive French stemmer — French regular plurals are overwhelmingly just + * "+s" on the singular (`"carotte"`/`"carottes"`, `"pomme"`/`"pommes"`), + * unlike English's several suffix patterns, so this only strips a single + * trailing "s". Deliberately *not* {@link stemWordEn}'s `"es"` rule reused + * here: applying it to French would silently corrupt any word whose + * singular itself ends in "e" plus a consonant before the final "s" — e.g. + * `"carottes"` would wrongly stem to `"carott"` (dropping the "e" that's + * actually part of the singular `"carotte"`) instead of `"carotte"`, + * exactly the class of near-miss that made ingredient matching + * French-locale silently broken before this stemmer existed (almost every + * regular French plural ends in "es" this way — it's not an edge case). + * Irregular plurals (`"cheval"`/`"chevaux"`, `"chou"`/`"choux"`) aren't + * handled — same "consistent, not linguistically perfect" trade-off as + * {@link stemWordEn}. + */ +function stemWordFr(word: string): string { + if (word.endsWith("s") && !word.endsWith("ss") && word.length > 3) return word.slice(0, -1); + return word; +} + +/** Dispatches to {@link stemWordEn}/{@link stemWordFr} by `locale` — any locale other than `"fr"` uses the English rules (the long-standing default, unchanged for every existing caller that doesn't pass a locale at all). */ +function stemWord(word: string, locale: string): string { + return locale === "fr" ? stemWordFr(word) : stemWordEn(word); +} + +/** Lowercases, strips diacritics (via `normalizeText`) and splits `text` into stemmed word tokens — empty/non-letter runs are dropped, not kept as empty tokens. `locale` picks the stemming rules (see {@link stemWord}); defaults to `"en"`, the original behavior every pre-existing caller still gets without passing one. */ +function tokenize(text: string, locale = "en"): string[] { return normalizeText(text) .split(/[^a-z]+/) .filter((word) => word.length > 0) - .map(stemWord); + .map((word) => stemWord(word, locale)); } /** Whether `needle` appears as a contiguous run inside `haystack`, at any starting position. */ @@ -86,18 +115,24 @@ function containsSubsequence(haystack: string[], needle: string[]): boolean { * Resolves free-text `name` (a `ParsedRecipeIngredient.name`, e.g. `"large * diced yellow onions"`) to the best-matching `Ingredient` in `catalog`, or * `null` if nothing matches. Among every catalog entry whose label's words - * all appear as a contiguous run in `name`, the one with the most words - * wins (most specific — "chicken breast" over bare "chicken"); ties break - * on the lowest `ingredientId`, for a deterministic result independent of - * catalog order. + * all appear as a contiguous run in `name`, **in the same order**, the one + * with the most words wins (most specific — "chicken breast" over bare + * "chicken"); ties break on the lowest `ingredientId`, for a deterministic + * result independent of catalog order. `locale` must match whatever + * `catalog`'s labels were loaded in (see {@link loadIngredientCatalog}) — + * defaults to `"en"`. */ -export function matchIngredientName(name: string, catalog: IngredientMatchEntry[]): number | null { - const nameTokens = tokenize(name); +export function matchIngredientName( + name: string, + catalog: IngredientMatchEntry[], + locale = "en", +): number | null { + const nameTokens = tokenize(name, locale); if (nameTokens.length === 0) return null; let best: { ingredientId: number; tokenCount: number } | null = null; for (const entry of catalog) { - const labelTokens = tokenize(entry.label); + const labelTokens = tokenize(entry.label, locale); if (!containsSubsequence(nameTokens, labelTokens)) continue; if ( best === null || @@ -114,24 +149,44 @@ export function matchIngredientName(name: string, catalog: IngredientMatchEntry[ } /** - * Resolves free-text `unitText` (e.g. `"tbsp"`, `"Cups"`) to the matching - * `Unit` in `catalog`, or `null` if nothing matches. A unit is a single - * word by convention (see `UNIT_LABELS_EN`), so this is a whole-token - * equality check (after stemming/normalizing), not the substring search - * `matchIngredientName` does — `"cup"` shouldn't match inside an unrelated - * longer word. + * Resolves free-text `unitText` (e.g. `"tbsp"`, `"Cups"`, `"cuillères à + * soupe de farine"`) to the best-matching `Unit` in `catalog`, or `null` if + * nothing matches. Same ordered-contiguous-run search as + * {@link matchIngredientName}, longest match wins — **not** the + * single-first-word equality check this function used before French + * support existed: every English unit synonym happens to be one word, so + * comparing only `unitText`'s first token against each *whole* synonym + * string used to be enough, but a French unit can be genuinely multi-word + * (`"cuillère à soupe"`, see `UNIT_LABELS_FR`) — a whole multi-word phrase + * (spaces and all) can never equal a single extracted token, so that + * approach would have silently matched nothing for any French unit + * requiring more than one word. `locale` must match whatever `catalog`'s + * synonyms were loaded in (see {@link loadUnitCatalog}) — defaults to + * `"en"`. */ -export function matchUnit(unitText: string, catalog: UnitMatchEntry[]): number | null { - const tokens = tokenize(unitText); - if (tokens.length === 0) return null; - const firstToken = tokens[0]; +export function matchUnit( + unitText: string, + catalog: UnitMatchEntry[], + locale = "en", +): number | null { + const textTokens = tokenize(unitText, locale); + if (textTokens.length === 0) return null; + let best: { unitId: number; tokenCount: number } | null = null; for (const entry of catalog) { - if (entry.synonyms.some((synonym) => stemWord(normalizeText(synonym)) === firstToken)) { - return entry.unitId; + for (const synonym of entry.synonyms) { + const synonymTokens = tokenize(synonym, locale); + if (!containsSubsequence(textTokens, synonymTokens)) continue; + if ( + best === null || + synonymTokens.length > best.tokenCount || + (synonymTokens.length === best.tokenCount && entry.unitId < best.unitId) + ) { + best = { unitId: entry.unitId, tokenCount: synonymTokens.length }; + } } } - return null; + return best?.unitId ?? null; } /** What {@link extractQuantity} pulls out of a leading numeric expression, alongside what's left of the string after it. */ @@ -143,7 +198,10 @@ export interface ExtractedQuantity { // Leading "1 1/2", "1/2", "1.5", "1,5" or "2" (optionally followed by a // hyphenated range like "2-3", in which case only the first number counts — -// good enough for a best-effort quantity, not meant to model ranges. +// good enough for a best-effort quantity, not meant to model ranges. The +// `[.,]` decimal separator already covers French recipe text ("1,5") as-is, +// same pattern used for English ("1.5") — no locale-specific handling +// needed here, unlike tokenize/stemWord above. const LEADING_QUANTITY_PATTERN = /^(\d+)\s+(\d+)\/(\d+)|^(\d+)\/(\d+)|^(\d+(?:[.,]\d+)?)/; /** @@ -176,18 +234,42 @@ export function extractQuantity(rawText: string): ExtractedQuantity { return { quantity, remainder: trimmed.slice(match[0].length).trim() }; } -/** Loads the full `Ingredient` catalog as {@link IngredientMatchEntry}s — one entry per key with an authored English label (see `INGREDIENT_LABELS_EN`), plus one extra entry per alternate wording (`INGREDIENT_LABEL_SYNONYMS_EN`, e.g. "Vanilla pod" alongside "Vanilla bean" — see issue #54) sharing the same `ingredientId`; `matchIngredientName` doesn't need to know synonyms exist, it just sees more candidate labels for the same ingredient. An ingredient with no English label yet is silently skipped, never a matching target. Meant to be fetched once per request and reused across every ingredient line, not re-queried per line. */ -export async function loadIngredientCatalog(): Promise { +/** + * Per-locale matching label tables {@link loadIngredientCatalog}/ + * {@link loadUnitCatalog} pick from — the only two locales with any + * matching data authored yet (see `packages/shared/src/data/`). A locale + * with no entry here (anything but `"en"`/`"fr"`) falls back to empty + * tables in both loaders below — the same "no matching-language data, + * degrade to doing nothing rather than guess" behavior `tech-step-matcher.ts` + * already has for a locale with no trained mappings, not a thrown error. + */ +const INGREDIENT_LABELS_BY_LOCALE: Record> = { + en: INGREDIENT_LABELS_EN, + fr: INGREDIENT_LABELS_FR, +}; +const INGREDIENT_LABEL_SYNONYMS_BY_LOCALE: Record> = { + en: INGREDIENT_LABEL_SYNONYMS_EN, + fr: INGREDIENT_LABEL_SYNONYMS_FR, +}; +const UNIT_LABELS_BY_LOCALE: Record> = { + en: UNIT_LABELS_EN, + fr: UNIT_LABELS_FR, +}; + +/** Loads the full `Ingredient` catalog as {@link IngredientMatchEntry}s for `locale` (default `"en"`) — one entry per key with an authored label in that locale, plus one extra entry per alternate wording (`INGREDIENT_LABEL_SYNONYMS_EN`/`_FR`, e.g. "Vanilla pod" alongside "Vanilla bean" — see issue #54) sharing the same `ingredientId`; `matchIngredientName` doesn't need to know synonyms exist, it just sees more candidate labels for the same ingredient. An ingredient with no label in `locale` yet is silently skipped, never a matching target — same for every ingredient when `locale` itself has no label table at all (see {@link INGREDIENT_LABELS_BY_LOCALE}). Meant to be fetched once per request and reused across every ingredient line, not re-queried per line. */ +export async function loadIngredientCatalog(locale = "en"): Promise { try { + const labels = INGREDIENT_LABELS_BY_LOCALE[locale] ?? {}; + const synonyms = INGREDIENT_LABEL_SYNONYMS_BY_LOCALE[locale] ?? {}; const ingredients = await prisma.ingredient.findMany({ select: { id: true, key: true }, }); const catalog: IngredientMatchEntry[] = []; for (const ingredient of ingredients) { - const label = INGREDIENT_LABELS_EN[ingredient.key]; + const label = labels[ingredient.key]; if (label === undefined) continue; catalog.push({ ingredientId: ingredient.id, label }); - for (const synonym of INGREDIENT_LABEL_SYNONYMS_EN[ingredient.key] ?? []) { + for (const synonym of synonyms[ingredient.key] ?? []) { catalog.push({ ingredientId: ingredient.id, label: synonym }); } } @@ -200,15 +282,16 @@ export async function loadIngredientCatalog(): Promise { } } -/** Loads the full `Unit` catalog as {@link UnitMatchEntry}s — one entry per key with authored English synonyms (see `UNIT_LABELS_EN`); a unit with none yet is silently skipped. Meant to be fetched once per request, same reasoning as {@link loadIngredientCatalog}. */ -export async function loadUnitCatalog(): Promise { +/** Loads the full `Unit` catalog as {@link UnitMatchEntry}s for `locale` (default `"en"`) — one entry per key with authored synonyms in that locale (see {@link UNIT_LABELS_BY_LOCALE}); a unit with none yet is silently skipped. Meant to be fetched once per request, same reasoning as {@link loadIngredientCatalog}. */ +export async function loadUnitCatalog(locale = "en"): Promise { try { + const labels = UNIT_LABELS_BY_LOCALE[locale] ?? {}; const units = await prisma.unit.findMany({ select: { id: true, key: true }, }); const catalog: UnitMatchEntry[] = []; for (const unit of units) { - const synonyms = UNIT_LABELS_EN[unit.key]; + const synonyms = labels[unit.key]; if (synonyms !== undefined) catalog.push({ unitId: unit.id, synonyms }); } return catalog; diff --git a/apps/api/src/lib/recipe-matching/recipe-translation.ts b/apps/api/src/lib/recipe-matching/recipe-translation.ts index dd328b8..44919c4 100644 --- a/apps/api/src/lib/recipe-matching/recipe-translation.ts +++ b/apps/api/src/lib/recipe-matching/recipe-translation.ts @@ -100,12 +100,19 @@ export async function translateRecipeSteps( } /** - * English label {@link matchUnit} is fed when a quantity was found but no - * unit word was — see the `unitId` fallback below. `"piece"` (`UNIT_LABELS_EN`, - * `packages/shared`) is the catalog's generic "counted, no further unit" - * entry (French "unité"). + * Locale-specific label {@link matchUnit} is fed when a quantity was found + * but no unit word was — see the `unitId` fallback below. Each is the + * catalog's generic "counted, no further unit" entry (`Unit.key` `"piece"`) + * in that locale's own label table (`UNIT_LABELS_EN`/`UNIT_LABELS_FR`, + * `packages/shared`). A locale with neither entry (anything but + * `"en"`/`"fr"`) falls back to the English spelling — harmless, since + * `unitCatalog` itself is already empty for an unsupported locale (see + * `loadUnitCatalog`), so this fallback lookup finds nothing either way. */ -const FALLBACK_COUNT_UNIT_LABEL = "piece"; +const FALLBACK_COUNT_UNIT_LABEL_BY_LOCALE: Record = { + en: "piece", + fr: "unité", +}; /** * Resolves each of `ingredients`' free-text `name`/`unit`/`quantity` @@ -127,20 +134,28 @@ const FALLBACK_COUNT_UNIT_LABEL = "piece"; * button disabled with no indication why on almost any recipe with a * whole-item ingredient. No fallback when `quantity` itself is `null` * (e.g. `"To taste"`) — there's nothing to count, so nothing to default. + * + * `locale` (default `"en"`, matching every pre-existing caller) must agree + * with whichever locale `ingredientCatalog`/`unitCatalog` were loaded in + * (see `loadIngredientCatalog`/`loadUnitCatalog`) — it's threaded through to + * `matchIngredientName`/`matchUnit` for stemming, and picks the right + * spelling of the "piece" fallback below. */ export function translateRecipeIngredients( ingredients: ParsedRecipeIngredient[], ingredientCatalog: IngredientMatchEntry[], unitCatalog: UnitMatchEntry[], + locale = "en", ): TranslatedRecipeIngredient[] { + const fallbackCountUnitLabel = FALLBACK_COUNT_UNIT_LABEL_BY_LOCALE[locale] ?? "piece"; return ingredients.map((ingredient) => { - const ingredientId = matchIngredientName(ingredient.name, ingredientCatalog); + const ingredientId = matchIngredientName(ingredient.name, ingredientCatalog, locale); const extracted = extractQuantity(ingredient.rawText); const quantity = ingredient.quantity ?? extracted.quantity; const unitText = ingredient.unit ?? extracted.remainder; const unitId = - matchUnit(unitText, unitCatalog) ?? - (quantity !== null ? matchUnit(FALLBACK_COUNT_UNIT_LABEL, unitCatalog) : null); + matchUnit(unitText, unitCatalog, locale) ?? + (quantity !== null ? matchUnit(fallbackCountUnitLabel, unitCatalog, locale) : null); return { ...ingredient, quantity, ingredientId, unitId }; }); } @@ -273,13 +288,19 @@ export function mergeDuplicateIngredients( * locale that doesn't match the actual text's language doesn't degrade * gracefully, it just gets things wrong. * - * Ingredient/unit matching only has English data today - * (`INGREDIENT_LABELS_EN`/`UNIT_LABELS_EN`, `packages/shared`) — for any - * `locale` other than `"en"` this skips `loadIngredientCatalog`/ - * `loadUnitCatalog` entirely and leaves every ingredient's `ingredientId`/ - * `unitId` at the neutral `null` `translateRecipeSteps` already stubs in, - * the same "no matching-language data" degradation tech-step matching - * already has for a locale with no mappings. + * Ingredient/unit matching has data for `"en"` and `"fr"` today + * (`INGREDIENT_LABELS_EN`/`_FR`, `UNIT_LABELS_EN`/`_FR`, `packages/shared`) + * — `loadIngredientCatalog(locale)`/`loadUnitCatalog(locale)` are always + * called, never specially skipped for a particular locale: a locale with no + * label table of its own (anything but `"en"`/`"fr"`) just gets back empty + * catalogs from those two loaders, and `translateRecipeIngredients` over an + * empty catalog naturally leaves every ingredient's `ingredientId`/`unitId` + * at the neutral `null` `translateRecipeSteps` already stubs in — the same + * "no matching-language data" degradation tech-step matching already has + * for a locale with no mappings, just arrived at by *not* special-casing + * which locales are "supported" here at all (that's `INGREDIENT_LABELS_BY_LOCALE`/ + * `UNIT_LABELS_BY_LOCALE`'s job, in `ingredient-matcher.ts` — this function + * doesn't need its own copy of that list to stay in sync with). */ export async function translateRecipe( recipe: ParsedRecipe, @@ -288,15 +309,18 @@ export async function translateRecipe( try { const translated = await translateRecipeSteps(recipe, locale); - if (locale !== "en") return translated; - const [ingredientCatalog, unitCatalog] = await Promise.all([ - loadIngredientCatalog(), - loadUnitCatalog(), + loadIngredientCatalog(locale), + loadUnitCatalog(locale), ]); return { ...translated, - ingredients: translateRecipeIngredients(recipe.ingredients, ingredientCatalog, unitCatalog), + ingredients: translateRecipeIngredients( + recipe.ingredients, + ingredientCatalog, + unitCatalog, + locale, + ), }; } catch (err) { // Rethrown as-is — the caller (`sources.service.ts`) already diff --git a/apps/api/src/modules/sources/sources.service.ts b/apps/api/src/modules/sources/sources.service.ts index 0a93f78..18703a1 100644 --- a/apps/api/src/modules/sources/sources.service.ts +++ b/apps/api/src/modules/sources/sources.service.ts @@ -11,10 +11,8 @@ import { import { prisma } from "../../db/prisma.js"; import { findImportedRecipeIds } from "../../db/recipe-source-sync.js"; import { - type IngredientMatchEntry, loadIngredientCatalog, loadUnitCatalog, - type UnitMatchEntry, } from "../../lib/recipe-matching/ingredient-matcher.js"; import { mergeDuplicateIngredients, @@ -141,10 +139,13 @@ export async function browseSource( * techniques with their exact matched span (`matchTechStepSpans`, the same * function `recipe.service.ts` uses at real save time — see its doc * comment), all against `adapter.locale`'s catalogs. Ingredient/unit - * matching itself only has English data today (see `ingredient-matcher.ts`); - * a non-English-locale source simply gets `ingredient`/`unit: null` on - * every line, the same graceful "no matching-language data" degradation - * `translateRecipe` already has. + * matching has data for `"en"`/`"fr"` today (see `ingredient-matcher.ts`); + * `loadIngredientCatalog`/`loadUnitCatalog` are always called with + * `adapter.locale` directly, never specially skipped for a particular + * one — a source whose locale has no label table of its own just gets back + * empty catalogs from those two loaders, so every line's `ingredient`/ + * `unit` end up `null` the same way, the same graceful "no + * matching-language data" degradation `translateRecipe` already has. * * @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet. * @throws {HttpError} `404 SOURCE_NOT_FOUND` if `sourceKey` doesn't match a source enabled for this household. @@ -177,10 +178,8 @@ export async function previewSourceItem( matches: await techStepClassifier.matchTechStepSpans(step.description, adapter.locale), })), ), - adapter.locale === "en" - ? loadIngredientCatalog() - : Promise.resolve([]), - adapter.locale === "en" ? loadUnitCatalog() : Promise.resolve([]), + loadIngredientCatalog(adapter.locale), + loadUnitCatalog(adapter.locale), prisma.techStep.findMany({ select: { id: true, key: true } }), ]); const techStepById = new Map(techStepsByKey.map((techStep) => [techStep.id, techStep])); @@ -189,6 +188,7 @@ export async function previewSourceItem( parsed.ingredients, ingredientCatalog, unitCatalog, + adapter.locale, ); const [ingredientViews, unitViews] = await Promise.all([getIngredients(), getUnits()]); const ingredientById = new Map(ingredientViews.map((view) => [view.id, view])); diff --git a/apps/api/test/recipe-matching/ingredient-matcher.test.ts b/apps/api/test/recipe-matching/ingredient-matcher.test.ts index 913bce8..7597256 100644 --- a/apps/api/test/recipe-matching/ingredient-matcher.test.ts +++ b/apps/api/test/recipe-matching/ingredient-matcher.test.ts @@ -1,4 +1,4 @@ -import { INGREDIENT_LABEL_SYNONYMS_EN } from "@batch-cooking/shared"; +import { INGREDIENT_LABEL_SYNONYMS_EN, INGREDIENT_LABEL_SYNONYMS_FR } from "@batch-cooking/shared"; import { expect } from "chai"; import { prisma } from "../../src/db/prisma.js"; import { @@ -90,6 +90,48 @@ describe("ingredient-matcher", () => { const onionB: IngredientMatchEntry = { ingredientId: 21, label: "Onion" }; expect(matchIngredientName("onion", [onionB, onionA])).to.equal(20); }); + + describe("locale: fr", () => { + const carotte: IngredientMatchEntry = { ingredientId: 30, label: "Carotte" }; + const poulet: IngredientMatchEntry = { ingredientId: 31, label: "Poulet" }; + const blancDePoulet: IngredientMatchEntry = { ingredientId: 32, label: "Blanc de poulet" }; + const frCatalog = [carotte, poulet, blancDePoulet]; + + it("tolerates a regular French plural (a bare 's', unlike English's several suffix patterns)", () => { + // Regression case: French plurals like "carottes" end in "es", which + // the English stemmer's own "es" rule would wrongly strip down to + // "carott" (losing the "e" that's part of the singular "carotte") + // — see stemWordFr's own doc comment. Locale "fr" must use the + // French stemmer instead, or this never matches. + expect(matchIngredientName("carottes", frCatalog, "fr")).to.equal(carotte.ingredientId); + }); + + it("is accent-insensitive the same way the English path is", () => { + expect(matchIngredientName("CAROTTES", frCatalog, "fr")).to.equal(carotte.ingredientId); + }); + + it("tolerates extra descriptive words around the match", () => { + expect(matchIngredientName("2 carottes râpées", frCatalog, "fr")).to.equal( + carotte.ingredientId, + ); + }); + + it("prefers the more specific multi-word label over a shorter one it contains", () => { + expect(matchIngredientName("blancs de poulet fermier", frCatalog, "fr")).to.equal( + blancDePoulet.ingredientId, + ); + }); + + it("defaults to the English stemmer when no locale is passed — 'fr' text needs to opt in explicitly", () => { + // Without locale: "fr", "carottes" stems via the English rules + // (endsWith("es") -> strip 2 chars) into "carott", which doesn't + // equal the catalog's own (also English-stemmed) "carotte" — no + // match. This is the exact bug locale-aware stemming fixes; this + // test pins down that the *default* stays exactly as it was for + // every pre-existing English-only caller. + expect(matchIngredientName("carottes", frCatalog)).to.equal(null); + }); + }); }); describe("matchUnit", () => { @@ -117,10 +159,14 @@ describe("ingredient-matcher", () => { expect(matchUnit("TBSP", catalog)).to.equal(tablespoon.unitId); }); - it("only looks at the first word — ignores trailing text", () => { + it("ignores trailing text after the unit word", () => { expect(matchUnit("cup flour", catalog)).to.equal(cup.unitId); }); + it("also finds the unit word when it isn't first — unlike before French support existed, this is no longer only a first-word check (see the function's own doc comment)", () => { + expect(matchUnit("a heaped tablespoon of sugar", catalog)).to.equal(tablespoon.unitId); + }); + it("doesn't match a short abbreviation inside an unrelated word", () => { // "g" alone must not match "grated" — whole-token comparison. expect(matchUnit("grated", catalog)).to.equal(null); @@ -137,6 +183,35 @@ describe("ingredient-matcher", () => { it("returns null for an empty string", () => { expect(matchUnit("", catalog)).to.equal(null); }); + + describe("locale: fr", () => { + const gramme: UnitMatchEntry = { unitId: 40, synonyms: ["g", "gr", "gramme", "grammes"] }; + const cuillereASoupe: UnitMatchEntry = { + unitId: 41, + synonyms: ["cuillère à soupe", "cuillères à soupe", "càs"], + }; + const frCatalog = [gramme, cuillereASoupe]; + + it("matches a genuinely multi-word synonym — the bug this locale support fixes: the old single-first-token check could never equal a whole multi-word phrase", () => { + expect(matchUnit("cuillères à soupe de farine", frCatalog, "fr")).to.equal( + cuillereASoupe.unitId, + ); + }); + + it("matches a single-word abbreviation the same way English units do", () => { + expect(matchUnit("càs de farine", frCatalog, "fr")).to.equal(cuillereASoupe.unitId); + }); + + it("is accent-insensitive", () => { + expect(matchUnit("2 CUILLÈRES À SOUPE de farine", frCatalog, "fr")).to.equal( + cuillereASoupe.unitId, + ); + }); + + it("doesn't match a multi-word phrase against unrelated text mentioning the same first word alone", () => { + expect(matchUnit("cuillère de bois", frCatalog, "fr")).to.equal(null); + }); + }); }); describe("extractQuantity", () => { @@ -239,5 +314,51 @@ describe("ingredient-matcher", () => { const cupEntry = catalog.find((entry) => entry.unitId === cup.id); expect(cupEntry?.synonyms).to.deep.equal(["cup", "cups"]); }); + + it("loads one entry per Ingredient that has a French label, plus one per alternate wording (INGREDIENT_LABEL_SYNONYMS_FR), keyed by real ingredientId", async () => { + const carrot = await prisma.ingredient.findFirstOrThrow({ where: { key: "carrot" } }); + const vanillaBean = await prisma.ingredient.findFirstOrThrow({ + where: { key: "vanillaBean" }, + }); + const ingredientCount = await prisma.ingredient.count(); + const synonymCount = Object.values(INGREDIENT_LABEL_SYNONYMS_FR).reduce( + (sum, synonyms) => sum + synonyms.length, + 0, + ); + + const catalog = await loadIngredientCatalog("fr"); + + // Every seeded ingredient has an authored French label too (copied + // from apps/web's fr locale — see catalog-labels-fr.ts's own doc + // comment), so this mirrors the English test above 1:1. + expect(catalog).to.have.length(ingredientCount + synonymCount); + const carrotEntry = catalog.find((entry) => entry.ingredientId === carrot.id); + expect(carrotEntry?.label).to.equal("Carotte"); + + const vanillaBeanEntries = catalog.filter((entry) => entry.ingredientId === vanillaBean.id); + expect(vanillaBeanEntries.map((entry) => entry.label)).to.deep.equal([ + "Vanille (gousse)", + "Gousse de vanille", + ]); + }); + + it("loads one entry per Unit that has French synonyms, keyed by real unitId", async () => { + const cup = await prisma.unit.findFirstOrThrow({ where: { key: "cup" } }); + const unitCount = await prisma.unit.count(); + + const catalog = await loadUnitCatalog("fr"); + + expect(catalog).to.have.length(unitCount); + const cupEntry = catalog.find((entry) => entry.unitId === cup.id); + expect(cupEntry?.synonyms).to.deep.equal(["tasse", "tasses"]); + }); + + it("returns an empty catalog for a locale with no label table at all — the DB is still queried, there's just nothing in either table to match a row against", async () => { + const ingredientCatalog = await loadIngredientCatalog("de"); + const unitCatalog = await loadUnitCatalog("de"); + + expect(ingredientCatalog).to.deep.equal([]); + expect(unitCatalog).to.deep.equal([]); + }); }); }); diff --git a/apps/api/test/recipe-matching/recipe-translation.test.ts b/apps/api/test/recipe-matching/recipe-translation.test.ts index b06c51e..6cc3460 100644 --- a/apps/api/test/recipe-matching/recipe-translation.test.ts +++ b/apps/api/test/recipe-matching/recipe-translation.test.ts @@ -453,7 +453,47 @@ describe("recipe-translation", () => { ]); }); - it("leaves ingredients untouched (no quantity extraction either) for a non-English locale — no matching data exists yet, and the DB isn't even queried for it", async () => { + it("resolves a real Ingredient id from the seeded French catalog, tolerating a regular French plural", async () => { + const carrot = await prisma.ingredient.findFirstOrThrow({ where: { key: "carrot" } }); + const recipe: ParsedRecipe = { + ...buildParsedRecipe(["Faire mijoter à feu doux"]), + ingredients: [{ rawText: "3 carottes", quantity: null, unit: null, name: "carottes" }], + }; + + const translated = await translateRecipe(recipe, "fr"); + + expect(translated.ingredients[0]?.ingredientId).to.equal(carrot.id); + expect(translated.ingredients[0]?.quantity).to.equal(3); + }); + + it("resolves a real multi-word Unit id from the seeded French catalog (issue: matchUnit used to only ever compare a single word)", async () => { + const wheatFlour = await prisma.ingredient.findFirstOrThrow({ where: { key: "wheatFlour" } }); + const tablespoon = await prisma.unit.findFirstOrThrow({ where: { key: "tablespoon" } }); + const recipe: ParsedRecipe = { + ...buildParsedRecipe(["Faire mijoter à feu doux"]), + ingredients: [ + { + rawText: "2 cuillères à soupe de farine de blé", + quantity: null, + unit: null, + name: "farine de blé", + }, + ], + }; + + const translated = await translateRecipe(recipe, "fr"); + + expect(translated.ingredients[0]).to.deep.equal({ + rawText: "2 cuillères à soupe de farine de blé", + quantity: 2, + unit: null, + name: "farine de blé", + ingredientId: wheatFlour.id, + unitId: tablespoon.id, + }); + }); + + it("still extracts a locale-agnostic quantity even for a locale with no ingredient/unit matching data at all, leaving only the ids null", async () => { const recipe: ParsedRecipe = { ...buildParsedRecipe(["Faire mijoter à feu doux"]), ingredients: [ @@ -461,12 +501,12 @@ describe("recipe-translation", () => { ], }; - const translated = await translateRecipe(recipe, "fr"); + const translated = await translateRecipe(recipe, "de"); expect(translated.ingredients).to.deep.equal([ { rawText: "1 cup onions, chopped", - quantity: null, + quantity: 1, unit: null, name: "onions", ingredientId: null, diff --git a/apps/api/test/sources/sources.test.ts b/apps/api/test/sources/sources.test.ts index 95428f0..6075cd1 100644 --- a/apps/api/test/sources/sources.test.ts +++ b/apps/api/test/sources/sources.test.ts @@ -120,6 +120,55 @@ function buildDuplicateIngredientAdapter(key = "duplicateFakeSource"): RecipeSou }; } +/** + * A minimal French-content fake adapter — same shape as {@link buildFakeAdapter}, + * `locale: "fr"` instead of `"en"`. Exercises `previewSourceItem` actually + * resolving ingredients for a non-English source through the real HTTP + * endpoint/catalog: `loadIngredientCatalog`/`loadUnitCatalog` used to be + * called only for `locale === "en"`, silently leaving every ingredient + * unresolved for a French source like Marmiton/750g/Manger Bouger — the + * regression this test guards against. + */ +function buildFrenchFakeAdapter(key = "fakeFrSource"): RecipeSourceAdapter<{ externalId: string }> { + return { + key, + name: "Fake French Source", + official: true, + iconUrl: null, + locale: "fr", + async list(_params: RecipeSourceListParams): Promise { + return { + items: [ + { externalId: "1", title: "Soupe à l'oignon", picture: null, url: "https://fake.test/1" }, + ], + nextCursor: null, + }; + }, + async fetchDetail(externalId: string): Promise<{ externalId: string }> { + return { externalId }; + }, + parse(raw: { externalId: string }): ParsedRecipe { + return { + name: `Recette factice ${raw.externalId}`, + description: null, + picture: null, + portions: 4, + sourceUrl: `https://fake.test/${raw.externalId}`, + ingredients: [ + { rawText: "3 carottes", quantity: null, unit: null, name: "carottes" }, + { + rawText: "un ingrédient mystère", + quantity: null, + unit: null, + name: "ingrédient mystère", + }, + ], + steps: [{ description: "Faire mijoter à feu doux", picture: null }], + }; + }, + }; +} + /** Resolves a reference ingredient's id by its `reference-seed-data.ts` uid (also its DB `key`) — same reasoning as `recipe.test.ts`'s own helper. */ async function ingredientId(key: string): Promise { const ingredient = await prisma.ingredient.findFirstOrThrow({ where: { key } }); @@ -305,6 +354,37 @@ describe("Sources", () => { expect(res.body.code).to.equal(ErrorCode.RECIPE_NOT_FOUND); }); + it("translates a French-locale source's item too, resolving ingredients against the French catalog (previously only 'en' sources ever got matched)", async () => { + const { agent } = await signupWithHouse(); + registerRecipeSource(buildFrenchFakeAdapter()); + await syncRecipeSources(prisma); + const source = await prisma.source.findUniqueOrThrow({ where: { key: "fakeFrSource" } }); + await agent.patch("/house/current/sources").send({ sourceIds: [source.id] }); + const carrot = await prisma.ingredient.findFirstOrThrow({ where: { key: "carrot" } }); + const piece = await prisma.unit.findFirstOrThrow({ where: { key: "piece" } }); + const simmer = await prisma.techStep.findFirstOrThrow({ where: { key: "simmer" } }); + + const res = await agent.get("/sources/fakeFrSource/preview/1"); + + expect(res.status).to.equal(200); + const [resolved, unresolved] = res.body.ingredients; + expect(resolved.rawText).to.equal("3 carottes"); + expect(resolved.ingredient).to.deep.include({ id: carrot.id, key: "carrot" }); + // No explicit unit word in "3 carottes" — falls back to the generic + // "piece" unit (see translateRecipeIngredients' own doc comment on + // issue #53), same as the English fake adapter's "1 onion" would. + expect(resolved.unit).to.deep.include({ id: piece.id, key: "piece" }); + expect(resolved.quantity).to.equal(3); + expect(unresolved.rawText).to.equal("un ingrédient mystère"); + expect(unresolved.ingredient).to.equal(null); + + expect(res.body.steps).to.have.length(1); + expect(res.body.steps[0].techSteps[0].techStep).to.deep.equal({ + id: simmer.id, + key: "simmer", + }); + }); + it("merges two lines that resolve to the same ingredient, summing their quantity (issue #53 follow-up)", async () => { const { agent } = await signupWithHouse(); registerRecipeSource(buildDuplicateIngredientAdapter()); diff --git a/packages/shared/src/data/catalog-labels-fr.ts b/packages/shared/src/data/catalog-labels-fr.ts new file mode 100644 index 0000000..f5bdd5c --- /dev/null +++ b/packages/shared/src/data/catalog-labels-fr.ts @@ -0,0 +1,661 @@ +/** + * French display/matching labels for the `Ingredient` reference catalog + * (`apps/api/src/db/reference-seed-data.ts`'s `INGREDIENT_GROUPS`), keyed + * by the same `Ingredient.key` used throughout the app — the French + * counterpart to {@link INGREDIENT_LABELS_EN} (catalog-labels-en.ts), used + * by `apps/api/src/lib/recipe-matching/ingredient-matcher.ts` to resolve + * free-text ingredient lines from French-language recipe sources (Marmiton, + * 750g, Manger Bouger) against our catalog. + * + * Deliberately **not** hand-authored from scratch: every value here is + * copied verbatim from `apps/web/src/locales/fr/translation.json`'s + * `catalog.ingredients` (the same French names already shown in the UI), + * not written fresh for matching purposes the way {@link INGREDIENT_LABELS_EN} + * was. That reuse is a deliberate trade-off, not an oversight: it guarantees + * every one of this catalog's ~550 ingredients gets *some* French matching + * label for free, at the cost of a few labels being phrased for display + * (a UI-friendly short name) rather than for how someone would actually + * write it in running recipe text — e.g. `vanillaBean`'s "Vanille (gousse)" + * won't match "1 gousse de vanille" (matching is ordered — see + * `matchIngredientName`'s own doc comment — and "gousse" comes first in + * real usage, not "vanille"), which is exactly what + * {@link INGREDIENT_LABEL_SYNONYMS_FR} below exists to patch up, entry by + * entry, as real mismatches like that one turn up — the ingredient still + * resolves correctly once a synonym in the natural word order is added, no + * change to the primary (display) label required. + */ +export const INGREDIENT_LABELS_FR: Record = { + // Vegetables + tomato: "Tomate", + onion: "Oignon", + shallot: "Échalote", + garlic: "Ail", + carrot: "Carotte", + zucchini: "Courgette", + cucumber: "Concombre", + gherkins: "Cornichons", + bellPepper: "Poivron", + mushroom: "Champignon", + porcini: "Cèpes", + eggplant: "Aubergine", + broccoli: "Brocoli", + cauliflower: "Chou-fleur", + whiteCabbage: "Chou blanc", + redCabbage: "Chou rouge", + brusselsSprouts: "Chou de Bruxelles", + spinach: "Épinard", + swissChard: "Blette", + lettuce: "Salade", + arugula: "Roquette", + watercress: "Cresson", + leek: "Poireau", + celery: "Céleri", + radish: "Radis", + beetroot: "Betterave", + turnip: "Navet", + parsnip: "Panais", + greenBean: "Haricot vert", + pea: "Petit pois", + corn: "Maïs", + artichoke: "Artichaut", + fennel: "Fenouil", + endive: "Endive", + pumpkin: "Potiron", + butternutSquash: "Butternut", + asparagus: "Asperge", + avocado: "Avocat", + potato: "Pomme de terre", + sweetPotato: "Patate douce", + cherryTomato: "Tomates cerises", + bokChoy: "Pak-choï", + soybeanSprouts: "Germes de soja", + shiitake: "Shiitake", + daikon: "Daikon", + freshGreenChili: "Piment vert frais", + cardoon: "Cardon", + radicchio: "Chicorée rouge", + romanesco: "Chou romanesco", + kohlrabi: "Chou-rave", + napaCabbage: "Chou chinois", + celeriac: "Céleri-rave", + okra: "Gombo", + springOnion: "Oignon nouveau", + redKuriSquash: "Potimarron", + rutabaga: "Rutabaga", + samphire: "Salicorne", + salsify: "Salsifis", + lambsLettuce: "Mâche", + escarole: "Scarole", + // Fruits + lemon: "Citron", + lime: "Citron vert", + apple: "Pomme", + pear: "Poire", + banana: "Banane", + orange: "Orange", + clementine: "Clémentine", + grapefruit: "Pamplemousse", + strawberry: "Fraise", + raspberry: "Framboise", + blueberry: "Myrtille", + blackberry: "Mûre", + cherry: "Cerise", + apricot: "Abricot", + peach: "Pêche", + plum: "Prune", + grape: "Raisin", + melon: "Melon", + watermelon: "Pastèque", + pineapple: "Ananas", + mango: "Mangue", + kiwi: "Kiwi", + fig: "Figue", + date: "Datte", + lychee: "Litchi", + pomegranate: "Grenade", + rhubarb: "Rhubarbe", + quince: "Coing", + blackcurrant: "Cassis", + cranberry: "Canneberge", + redcurrant: "Groseille", + persimmon: "Kaki", + nectarine: "Nectarine", + tamarind: "Tamarin", + // Fresh herbs + basil: "Basilic", + parsley: "Persil", + thyme: "Thym", + rosemary: "Romarin", + bayLeaf: "Laurier", + chives: "Ciboulette", + freshCilantro: "Coriandre fraîche", + mint: "Menthe", + oregano: "Origan", + dill: "Aneth", + tarragon: "Estragon", + savory: "Sarriette", + marjoram: "Marjolaine", + sage: "Sauge", + chervil: "Cerfeuil", + ginger: "Gingembre", + lemongrass: "Citronnelle", + kaffirLime: "Combava", + // Meats + rabbit: "Lapin", + groundBeef: "Bœuf haché", + beefSteak: "Steak de bœuf", + beefRoast: "Rôti de bœuf", + vealCutlet: "Escalope de veau", + porkTenderloin: "Filet mignon de porc", + porkChop: "Côte de porc", + groundVeal: "Veau haché", + groundPork: "Porc haché", + groundLamb: "Agneau haché", + lamb: "Agneau", + legOfLamb: "Gigot d'agneau", + baconLardons: "Lardons", + bacon: "Bacon", + ham: "Jambon blanc", + curedHam: "Jambon cru", + sausage: "Saucisse", + chorizo: "Chorizo", + merguez: "Merguez", + prosciutto: "Prosciutto", + pancetta: "Pancetta", + mortadella: "Mortadelle", + salami: "Salami", + andouille: "Andouille", + andouillette: "Andouillette", + whitePudding: "Boudin blanc", + blackPudding: "Boudin noir", + cervelat: "Cervelas", + rillettes: "Rillettes", + dryCuredSausage: "Saucisson sec", + bayonneHam: "Jambon de Bayonne", + coppa: "Coppa", + rosetteSausage: "Rosette (saucisson)", + vealLiver: "Foie de veau", + vealKidneys: "Rognons de veau", + vealBrain: "Cervelle de veau", + vealSweetbread: "Ris de veau", + beefTongue: "Langue de bœuf", + tripe: "Tripes", + venison: "Cerf", + roeDeer: "Chevreuil", + wildBoar: "Sanglier", + horseMeat: "Cheval", + beefHeart: "Cœur de bœuf", + foieGras: "Foie gras", + beefMuzzle: "Museau de bœuf", + grisonsDriedBeef: "Viande des Grisons", + // Poultry + chicken: "Poulet", + groundChicken: "Poulet haché", + turkey: "Dinde", + groundTurkey: "Dinde hachée", + duck: "Canard", + duckBreast: "Magret de canard", + quail: "Caille", + guineaFowl: "Pintade", + goose: "Oie", + poultryLiver: "Foie de volaille", + capon: "Chapon", + pigeon: "Pigeon", + pheasant: "Faisan", + // Fish + salmon: "Saumon", + tuna: "Thon", + cod: "Cabillaud", + trout: "Truite", + sardine: "Sardine", + anchovy: "Anchois", + whiting: "Merlan", + surimi: "Surimi", + seaBass: "Bar (loup de mer)", + seaBream: "Dorade", + sole: "Sole", + turbot: "Turbot", + hake: "Merlu", + pollock: "Colin", + saithe: "Lieu noir", + haddock: "Églefin", + mackerel: "Maquereau", + herring: "Hareng", + redMullet: "Rouget", + skate: "Raie", + monkfish: "Lotte", + halibut: "Flétan", + swordfish: "Espadon", + carp: "Carpe", + pike: "Brochet", + perch: "Perche", + tilapia: "Tilapia", + pangasius: "Panga", + smokedSalmon: "Saumon fumé", + driedFish: "Poisson séché", + eel: "Anguille", + plaice: "Carrelet (ou plie)", + saltCod: "Morue", + lemonSole: "Limande", + scorpionfish: "Rascasse", + // Shellfish + shrimp: "Crevettes", + langoustine: "Langoustines", + lobster: "Homard", + crab: "Crabe", + spinyLobster: "Langouste", + mussels: "Moules", + oysters: "Huîtres", + scallops: "Saint-Jacques", + squid: "Calamar", + octopus: "Poulpe", + clams: "Palourdes", + whelks: "Bulots", + spiderCrab: "Araignée de mer", + periwinkle: "Bigorneau", + crayfish: "Écrevisse", + greyShrimp: "Crevette grise", + cockle: "Coque", + snail: "Escargot", + cuttlefish: "Seiche", + // Starches + semolina: "Semoule", + couscous: "Couscous", + bulgur: "Boulgour", + polenta: "Polenta", + quinoa: "Quinoa", + pasta: "Pâtes", + wholeWheatPasta: "Pâtes complètes", + rice: "Riz", + basmatiRice: "Riz basmati", + brownRice: "Riz complet", + oats: "Flocons d'avoine", + spaghetti: "Spaghetti", + penne: "Penne", + tagliatelle: "Tagliatelles", + lasagnaSheets: "Lasagnes (feuilles)", + gnocchi: "Gnocchi", + arborioRice: "Riz arborio", + riceNoodles: "Nouilles de riz", + udonNoodles: "Nouilles udon", + sobaNoodles: "Nouilles soba", + chineseNoodles: "Nouilles chinoises", + riceVermicelli: "Vermicelles de riz", + soyVermicelli: "Vermicelles de soja", + stickyRice: "Riz gluant", + sushiRice: "Riz à sushi", + jasmineRice: "Riz jasmin", + // Legumes + greenLentils: "Lentilles vertes", + redLentils: "Lentilles corail", + chickpeas: "Pois chiches", + whiteBeans: "Haricots blancs", + kidneyBeans: "Haricots rouges", + blackBeans: "Haricots noirs", + splitPeas: "Pois cassés", + favaBeans: "Fèves", + edamame: "Edamame", + pintoBeans: "Haricots pinto", + flageoletBeans: "Haricots flageolets", + goldenLentils: "Lentilles blondes", + // Nuts, seeds and other dry goods + peanutsShelled: "Cacahuètes", + almonds: "Amandes", + walnuts: "Noix", + hazelnuts: "Noisettes", + cashews: "Noix de cajou", + pistachios: "Pistaches", + pecans: "Noix de pécan", + almondPowder: "Poudre d'amande", + pineNuts: "Pignons de pin", + sunflowerSeeds: "Graines de tournesol", + pumpkinSeeds: "Graines de courge", + shreddedCoconut: "Noix de coco râpée", + raisins: "Raisins secs", + prunes: "Pruneaux", + driedApricots: "Abricots secs", + sesameSeeds: "Graines de sésame", + blackMushrooms: "Champignons noirs", + noriSeaweed: "Algue nori", + wakameSeaweed: "Algue wakamé", + kombuSeaweed: "Algue kombu", + bambooShoots: "Pousses de bambou", + waterChestnuts: "Châtaignes d'eau", + // Breads + bread: "Pain", + sandwichBread: "Pain de mie", + wholeWheatBread: "Pain complet", + baguette: "Baguette", + ryeBread: "Pain de seigle", + breadcrumbs: "Chapelure", + burgerBun: "Pain à burger", + briocheBun: "Pain brioché", + hotDogBun: "Pain à hot-dog", + pitaBread: "Pain pita", + bagel: "Pain bagel", + naan: "Naan", + wrapBread: "Pain wrap", + vienneseBread: "Pain viennois", + countryBread: "Pain de campagne", + multigrainBread: "Pain aux céréales", + breadRoll: "Petit pain", + swedishBread: "Pain suédois", + glutenFreeBread: "Pain sans gluten", + rusk: "Biscotte", + croutons: "Croûtons", + focaccia: "Focaccia", + ciabatta: "Ciabatta", + cornTortilla: "Tortilla de maïs", + wheatTortilla: "Tortilla de blé", + breadstick: "Gressin", + // Raw dough + puffPastry: "Pâte feuilletée", + shortcrustPastry: "Pâte brisée", + pizzaDough: "Pâte à pizza", + sweetShortcrustPastry: "Pâte à tarte sablée", + // Dairy + milk: "Lait", + butter: "Beurre", + cremeFraiche: "Crème fraîche", + liquidCream: "Crème liquide", + cheese: "Fromage", + emmental: "Emmental", + gruyere: "Gruyère", + parmesan: "Parmesan", + mozzarella: "Mozzarella", + goatCheese: "Chèvre (fromage)", + feta: "Feta", + comte: "Comté", + fromageBlanc: "Fromage blanc", + mascarpone: "Mascarpone", + yogurt: "Yaourt", + burrata: "Burrata", + ricotta: "Ricotta", + pecorino: "Pecorino", + gorgonzola: "Gorgonzola", + cheddar: "Cheddar", + brie: "Brie", + camembert: "Camembert", + roquefort: "Roquefort", + munster: "Munster", + reblochon: "Reblochon", + cantal: "Cantal", + beaufort: "Beaufort", + saintNectaire: "Saint-Nectaire", + blueCheese: "Bleu (fromage)", + cancoillotte: "Cancoillotte", + tomme: "Tomme", + epoisses: "Époisses", + chaource: "Chaource", + livarot: "Livarot", + pontLeveque: "Pont-l'Évêque", + morbier: "Morbier", + racletteCheese: "Raclette (fromage)", + fourmeDAmbert: "Fourme d'Ambert", + salers: "Salers", + ossauIraty: "Ossau-Iraty", + vacherin: "Vacherin", + saintMarcellin: "Saint-Marcellin", + neufchatel: "Neufchâtel", + crottinDeChavignol: "Crottin de Chavignol", + abondanceCheese: "Abondance", + carreDeLEst: "Carré de l'Est", + edam: "Edam", + gouda: "Gouda", + mimolette: "Mimolette", + maroilles: "Maroilles", + montDor: "Mont d'or", + kefir: "Kéfir", + greekYogurt: "Yaourt à la grecque", + // Eggs + egg: "Oeuf", + eggYolk: "Jaune d'oeuf", + eggWhite: "Blanc d'oeuf", + // Plant-based alternatives + coconutMilk: "Lait de coco", + coconutCream: "Crème de coco", + almondMilk: "Lait d'amande", + oatMilk: "Lait d'avoine", + tofu: "Tofu", + silkenTofu: "Tofu soyeux", + // Spices + herbesDeProvence: "Herbes de Provence", + blackPepper: "Poivre noir", + paprika: "Paprika", + espelettePepper: "Piment d'Espelette", + cayennePepper: "Piment de Cayenne", + cumin: "Cumin", + curryPowder: "Curry (poudre)", + turmeric: "Curcuma", + cinnamon: "Cannelle", + nutmeg: "Muscade", + saffron: "Safran", + clove: "Clou de girofle", + vanillaBean: "Vanille (gousse)", + whitePepper: "Poivre blanc", + pinkPepper: "Poivre rose", + sichuanPepper: "Poivre du Sichuan", + smokedPaprika: "Paprika fumé", + birdEyeChili: "Piment oiseau", + juniperBerries: "Baies de genièvre", + starAnise: "Anis étoilé (badiane)", + greenAnise: "Anis vert", + fennelSeeds: "Graines de fenouil", + sumac: "Sumac", + nigella: "Nigelle", + allspice: "Quatre épices", + colomboPowder: "Colombo (poudre)", + baharat: "Baharat", + horseradish: "Raifort", + herbSalt: "Sel aux herbes", + celerySalt: "Sel de céleri", + fleurDeSel: "Fleur de sel", + salt: "Sel", + fiveSpice: "Cinq épices", + garamMasala: "Garam masala", + corianderSeeds: "Graines de coriandre", + groundCoriander: "Coriandre en poudre", + cardamom: "Cardamome", + fenugreek: "Fenugrec", + jalapeno: "Piment jalapeño", + chipotle: "Piment chipotle", + poblanoPepper: "Piment poblano", + habanero: "Piment habanero", + rasElHanout: "Ras el hanout", + zaatar: "Za'atar", + // Sauces + soySauce: "Sauce soja", + mustard: "Moutarde", + mayonnaise: "Mayonnaise", + ketchup: "Ketchup", + tabasco: "Tabasco", + worcestershireSauce: "Sauce Worcestershire", + fishSauce: "Sauce nuoc-mâm", + wasabi: "Wasabi", + harissa: "Harissa", + curryPaste: "Pâte de curry", + peanutButter: "Beurre de cacahuète", + dijonMustard: "Moutarde de Dijon", + wholegrainMustard: "Moutarde à l'ancienne", + barbecueSauce: "Sauce barbecue", + tartarSauce: "Sauce tartare", + cocktailSauce: "Sauce cocktail", + bearnaiseSauce: "Sauce béarnaise", + hollandaiseSauce: "Sauce hollandaise", + bechamelSauce: "Sauce béchamel", + teriyakiSauce: "Sauce teriyaki", + ponzuSauce: "Sauce ponzu", + chimichurri: "Chimichurri", + redPesto: "Pesto rouge (tomates séchées)", + pesto: "Pesto", + oysterSauce: "Sauce huître", + hoisinSauce: "Sauce hoisin", + sriracha: "Sauce sriracha", + sweetChiliSauce: "Sauce sweet chili", + miso: "Miso", + shrimpPaste: "Pâte de crevettes", + redCurryPaste: "Pâte de curry rouge (thaï)", + greenCurryPaste: "Pâte de curry vert (thaï)", + tahini: "Tahini", + aioli: "Aïoli", + vinaigrette: "Sauce vinaigrette", + hummus: "Houmous", + // Seasonings — oils, vinegars, wines and other flavorings + oliveOil: "Huile d'olive", + sunflowerOil: "Huile de tournesol", + rapeseedOil: "Huile de colza", + coconutOil: "Huile de coco", + sesameOil: "Huile de sésame", + ciderVinegar: "Vinaigre de cidre", + whiteVinegar: "Vinaigre blanc", + balsamicVinegar: "Vinaigre balsamique", + capers: "Câpres", + olives: "Olives", + blackOlives: "Olives noires", + greenOlives: "Olives vertes", + whiteWine: "Vin blanc (cuisine)", + redWine: "Vin rouge (cuisine)", + roseWine: "Vin rosé (cuisine)", + redWineVinegar: "Vinaigre de vin rouge", + whiteWineVinegar: "Vinaigre de vin blanc", + sherryVinegar: "Vinaigre de xérès", + walnutOil: "Huile de noix", + hazelnutOil: "Huile de noisette", + peanutOil: "Huile d'arachide", + chiliOil: "Huile pimentée", + riceVinegar: "Vinaigre de riz", + cornOil: "Huile de maïs", + grapeseedOil: "Huile de pépins de raisin", + soybeanOil: "Huile de soja", + palmOil: "Huile de palme", + mirin: "Mirin", + sake: "Saké (cuisine)", + lemonJuice: "Jus de citron", + limeJuice: "Jus de citron vert", + orangeJuice: "Jus d'orange", + appleJuice: "Jus de pomme", + grapeJuice: "Jus de raisin", + tomatoJuice: "Jus de tomate", + cranberryJuice: "Jus de cranberry", + coffee: "Café", + tea: "Thé", + beer: "Bière (cuisine)", + cider: "Cidre (cuisine)", + champagne: "Champagne / vin pétillant (cuisine)", + portWine: "Porto (cuisine)", + vinJaune: "Vin jaune (cuisine)", + cognac: "Cognac", + rum: "Rhum", + whisky: "Whisky", + vodka: "Vodka", + // Bases — flours, stocks and other cooking essentials + wheatFlour: "Farine de blé", + wholeWheatFlour: "Farine complète", + cornFlour: "Farine de maïs", + buckwheatFlour: "Farine de sarrasin", + riceFlour: "Farine de riz", + vegetableStockCube: "Bouillon cube légumes", + chickenStockCube: "Bouillon cube volaille", + tomatoPaste: "Concentré de tomate", + tomatoCoulis: "Coulis de tomate", + cannedPeeledTomatoes: "Tomates pelées (conserve)", + sunDriedTomatoes: "Tomates séchées", + vealStock: "Fond de veau", + chickenStock: "Fond de volaille", + beefStockCube: "Bouillon cube bœuf", + fishStockCube: "Bouillon cube poisson", + vegetableBroth: "Bouillon de légumes", + chickenBroth: "Bouillon de volaille", + beefBroth: "Bouillon de bœuf", + courtBouillon: "Court-bouillon", + dashi: "Dashi (bouillon japonais)", + shellfishBisque: "Bisque de crustacés", + tapiocaFlour: "Farine de tapioca", + masaHarina: "Masa harina", + water: "Eau", + sparklingWater: "Eau gazeuse", + orangeBlossomWater: "Eau de fleur d'oranger", + roseWater: "Eau de rose", + fishFumet: "Fumet de poisson", + // Thickeners and raising agents + bakersYeast: "Levure boulangère", + bakingPowder: "Levure chimique", + cornstarch: "Maïzena", + lupinFlour: "Farine de lupin", + gelatin: "Gélatine", + bakingSoda: "Bicarbonate de soude", + potatoStarch: "Fécule de pomme de terre", + // Sugars + sugar: "Sucre", + honey: "Miel", + mapleSyrup: "Sirop d'érable", + brownSugar: "Sucre roux", + powderedSugar: "Sucre glace", + demeraraSugar: "Cassonade", + darkChocolate: "Chocolat noir", + milkChocolate: "Chocolat au lait", + whiteChocolate: "Chocolat blanc", + chocolateChips: "Pépites de chocolat", + cocoaPowder: "Cacao en poudre", + vanillaExtract: "Extrait de vanille", + palmSugar: "Sucre de palme", + caneSyrup: "Sirop de sucre de canne", +}; + +/** + * Extra French matching phrases for a handful of {@link INGREDIENT_LABELS_FR} + * entries whose primary (display) label doesn't match how the phrase is + * actually written in running recipe text — see that constant's own doc + * comment for why this is expected to grow over time, the same "exceptions + * only, not every entry" shape as {@link INGREDIENT_LABEL_SYNONYMS_EN}. + */ +export const INGREDIENT_LABEL_SYNONYMS_FR: Record = { + // "Vanille (gousse)" is display-first-word-last; real recipe text says + // "gousse de vanille" (pod word first) — see INGREDIENT_LABELS_FR's doc + // comment. + vanillaBean: ["Gousse de vanille"], +}; + +/** + * French matching synonyms for the `Unit` reference catalog + * (`apps/api/src/db/reference-seed-data.ts`'s `UNITS`), keyed by + * `Unit.key` — the French counterpart to {@link UNIT_LABELS_EN}. Unlike + * {@link INGREDIENT_LABELS_FR}, these are **not** copied from + * `apps/web`'s locale file: that file has exactly one display label per + * unit (`apps/web/src/locales/fr/translation.json`'s `catalog.units`, + * e.g. `tablespoon`: "cuillère à soupe"), fine for a dropdown but not + * enough to *match* French recipe text against — real recipes freely mix + * the full phrase, its plural, and common abbreviations ("cuillère à + * soupe", "cuillères à soupe", "c. à soupe", "càs" all appear in the + * wild), so each entry here is hand-authored the same way + * {@link UNIT_LABELS_EN} was, starting from that same display label. + * + * Several of these are genuinely multi-word ("cuillère à soupe") — unlike + * English units, which are always a single word/abbreviation. Matching a + * multi-word unit needs the same ordered-contiguous-run search + * `matchIngredientName` already does for ingredients, not the older + * single-first-word check `matchUnit` used before French support existed + * — see `matchUnit`'s own doc comment (`ingredient-matcher.ts`) for the + * bug that would otherwise cause: a multi-word French synonym's *whole + * phrase* (spaces and all) would never equal a single extracted word, so + * it could never match anything at all. + */ +export const UNIT_LABELS_FR: Record = { + gram: ["g", "gr", "gramme", "grammes"], + kilogram: ["kg", "kilo", "kilos", "kilogramme", "kilogrammes"], + milliliter: ["ml", "millilitre", "millilitres"], + centiliter: ["cl", "centilitre", "centilitres"], + liter: ["l", "litre", "litres"], + tablespoon: ["cuillère à soupe", "cuillères à soupe", "c. à soupe", "c à soupe", "cas", "càs"], + teaspoon: ["cuillère à café", "cuillères à café", "c. à café", "c à café", "cac", "càc"], + piece: ["unité", "unités", "pièce", "pièces"], + pinch: ["pincée", "pincées"], + slice: ["tranche", "tranches"], + clove: ["gousse", "gousses"], + bunch: ["botte", "bottes"], + sachet: ["sachet", "sachets"], + sprig: ["brin", "brins"], + cup: ["tasse", "tasses"], + ounce: ["once", "onces"], + pound: ["livre", "livres"], +}; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index f4e6cd7..1af4fa6 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -4,6 +4,7 @@ // detail specific to one side. export * from "./data/catalog-labels-en.js"; +export * from "./data/catalog-labels-fr.js"; export * from "./errors/error-codes.js"; export * from "./schemas/account.js"; export * from "./schemas/auth.js";