Compare commits

...

1 commit

Author SHA1 Message Date
42af7e6dbb fix(recipes): active le matching ingredients/unites pour les sources en francais
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 <noreply@anthropic.com>
2026-08-22 20:18:58 +02:00
8 changed files with 1089 additions and 79 deletions

View file

@ -1,23 +1,26 @@
import { import {
INGREDIENT_LABEL_SYNONYMS_EN, INGREDIENT_LABEL_SYNONYMS_EN,
INGREDIENT_LABEL_SYNONYMS_FR,
INGREDIENT_LABELS_EN, INGREDIENT_LABELS_EN,
INGREDIENT_LABELS_FR,
UNIT_LABELS_EN, UNIT_LABELS_EN,
UNIT_LABELS_FR,
} from "@batch-cooking/shared"; } from "@batch-cooking/shared";
import { prisma } from "../../db/prisma.js"; import { prisma } from "../../db/prisma.js";
import { normalizeText } from "./tech-step-matcher.js"; import { normalizeText } from "./tech-step-matcher.js";
/** /**
* Resolves the free-text `name`/`unit`/`quantity` a `RecipeSourceAdapter` * Resolves the free-text `name`/`unit`/`quantity` a `RecipeSourceAdapter`
* lifts from an English-language source (`ParsedRecipeIngredient`) against * lifts from a recipe source (`ParsedRecipeIngredient`) against our own
* our own `Ingredient`/`Unit` reference catalogs the ingredient-side * `Ingredient`/`Unit` reference catalogs the ingredient-side counterpart
* counterpart to `tech-step-matcher.ts`'s technique detection, built for * to `tech-step-matcher.ts`'s technique detection, built for the same
* the same reason: an English source's raw text has no idea our catalogs * reason: a source's raw text has no idea our catalogs even exist.
* even exist.
* *
* Unlike tech steps (regex mappings hand-authored per technique), * Unlike tech steps (regex mappings hand-authored per technique),
* ingredient/unit labels are plain hand-written English text * ingredient/unit labels are plain hand-written text, one table per
* (`INGREDIENT_LABELS_EN`/`UNIT_LABELS_EN`, `packages/shared`) matching * supported `locale` (`INGREDIENT_LABELS_EN`/`INGREDIENT_LABELS_FR`/
* them against arbitrary free text (extra adjectives, plurals, "large diced * `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, * yellow onion" for a catalog entry that's just "Onion") needs its own,
* lighter algorithm: word-tokenize both sides, naively stem for plurals, * lighter algorithm: word-tokenize both sides, naively stem for plurals,
* then look for the catalog phrase's tokens as a contiguous run inside the * 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-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 { export interface IngredientMatchEntry {
ingredientId: number; 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; 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 { export interface UnitMatchEntry {
unitId: number; 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[]; synonyms: string[];
} }
@ -58,19 +61,45 @@ export interface UnitMatchEntry {
* of every comparison go through it, not linguistically correct on its * of every comparison go through it, not linguistically correct on its
* own see the module doc comment. * 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("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("es") && word.length > 3) return word.slice(0, -2);
if (word.endsWith("s") && !word.endsWith("ss") && word.length > 3) return word.slice(0, -1); if (word.endsWith("s") && !word.endsWith("ss") && word.length > 3) return word.slice(0, -1);
return word; 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) return normalizeText(text)
.split(/[^a-z]+/) .split(/[^a-z]+/)
.filter((word) => word.length > 0) .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. */ /** 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 * Resolves free-text `name` (a `ParsedRecipeIngredient.name`, e.g. `"large
* diced yellow onions"`) to the best-matching `Ingredient` in `catalog`, or * diced yellow onions"`) to the best-matching `Ingredient` in `catalog`, or
* `null` if nothing matches. Among every catalog entry whose label's words * `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 * all appear as a contiguous run in `name`, **in the same order**, the one
* wins (most specific "chicken breast" over bare "chicken"); ties break * with the most words wins (most specific "chicken breast" over bare
* on the lowest `ingredientId`, for a deterministic result independent of * "chicken"); ties break on the lowest `ingredientId`, for a deterministic
* catalog order. * 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 { export function matchIngredientName(
const nameTokens = tokenize(name); name: string,
catalog: IngredientMatchEntry[],
locale = "en",
): number | null {
const nameTokens = tokenize(name, locale);
if (nameTokens.length === 0) return null; if (nameTokens.length === 0) return null;
let best: { ingredientId: number; tokenCount: number } | null = null; let best: { ingredientId: number; tokenCount: number } | null = null;
for (const entry of catalog) { for (const entry of catalog) {
const labelTokens = tokenize(entry.label); const labelTokens = tokenize(entry.label, locale);
if (!containsSubsequence(nameTokens, labelTokens)) continue; if (!containsSubsequence(nameTokens, labelTokens)) continue;
if ( if (
best === null || best === null ||
@ -114,24 +149,44 @@ export function matchIngredientName(name: string, catalog: IngredientMatchEntry[
} }
/** /**
* Resolves free-text `unitText` (e.g. `"tbsp"`, `"Cups"`) to the matching * Resolves free-text `unitText` (e.g. `"tbsp"`, `"Cups"`, `"cuillères à
* `Unit` in `catalog`, or `null` if nothing matches. A unit is a single * soupe de farine"`) to the best-matching `Unit` in `catalog`, or `null` if
* word by convention (see `UNIT_LABELS_EN`), so this is a whole-token * nothing matches. Same ordered-contiguous-run search as
* equality check (after stemming/normalizing), not the substring search * {@link matchIngredientName}, longest match wins **not** the
* `matchIngredientName` does `"cup"` shouldn't match inside an unrelated * single-first-word equality check this function used before French
* longer word. * 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 { export function matchUnit(
const tokens = tokenize(unitText); unitText: string,
if (tokens.length === 0) return null; catalog: UnitMatchEntry[],
const firstToken = tokens[0]; 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) { for (const entry of catalog) {
if (entry.synonyms.some((synonym) => stemWord(normalizeText(synonym)) === firstToken)) { for (const synonym of entry.synonyms) {
return entry.unitId; 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. */ /** 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 // 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 — // 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+)?)/; 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() }; 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<IngredientMatchEntry[]> { * 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<string, Record<string, string>> = {
en: INGREDIENT_LABELS_EN,
fr: INGREDIENT_LABELS_FR,
};
const INGREDIENT_LABEL_SYNONYMS_BY_LOCALE: Record<string, Record<string, string[]>> = {
en: INGREDIENT_LABEL_SYNONYMS_EN,
fr: INGREDIENT_LABEL_SYNONYMS_FR,
};
const UNIT_LABELS_BY_LOCALE: Record<string, Record<string, string[]>> = {
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<IngredientMatchEntry[]> {
try { try {
const labels = INGREDIENT_LABELS_BY_LOCALE[locale] ?? {};
const synonyms = INGREDIENT_LABEL_SYNONYMS_BY_LOCALE[locale] ?? {};
const ingredients = await prisma.ingredient.findMany({ const ingredients = await prisma.ingredient.findMany({
select: { id: true, key: true }, select: { id: true, key: true },
}); });
const catalog: IngredientMatchEntry[] = []; const catalog: IngredientMatchEntry[] = [];
for (const ingredient of ingredients) { for (const ingredient of ingredients) {
const label = INGREDIENT_LABELS_EN[ingredient.key]; const label = labels[ingredient.key];
if (label === undefined) continue; if (label === undefined) continue;
catalog.push({ ingredientId: ingredient.id, label }); 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 }); catalog.push({ ingredientId: ingredient.id, label: synonym });
} }
} }
@ -200,15 +282,16 @@ export async function loadIngredientCatalog(): Promise<IngredientMatchEntry[]> {
} }
} }
/** 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}. */ /** 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(): Promise<UnitMatchEntry[]> { export async function loadUnitCatalog(locale = "en"): Promise<UnitMatchEntry[]> {
try { try {
const labels = UNIT_LABELS_BY_LOCALE[locale] ?? {};
const units = await prisma.unit.findMany({ const units = await prisma.unit.findMany({
select: { id: true, key: true }, select: { id: true, key: true },
}); });
const catalog: UnitMatchEntry[] = []; const catalog: UnitMatchEntry[] = [];
for (const unit of units) { 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 }); if (synonyms !== undefined) catalog.push({ unitId: unit.id, synonyms });
} }
return catalog; return catalog;

View file

@ -100,12 +100,19 @@ export async function translateRecipeSteps(
} }
/** /**
* English label {@link matchUnit} is fed when a quantity was found but no * Locale-specific label {@link matchUnit} is fed when a quantity was found
* unit word was see the `unitId` fallback below. `"piece"` (`UNIT_LABELS_EN`, * but no unit word was see the `unitId` fallback below. Each is the
* `packages/shared`) is the catalog's generic "counted, no further unit" * catalog's generic "counted, no further unit" entry (`Unit.key` `"piece"`)
* entry (French "unité"). * 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<string, string> = {
en: "piece",
fr: "unité",
};
/** /**
* Resolves each of `ingredients`' free-text `name`/`unit`/`quantity` * 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 * button disabled with no indication why on almost any recipe with a
* whole-item ingredient. No fallback when `quantity` itself is `null` * whole-item ingredient. No fallback when `quantity` itself is `null`
* (e.g. `"To taste"`) there's nothing to count, so nothing to default. * (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( export function translateRecipeIngredients(
ingredients: ParsedRecipeIngredient[], ingredients: ParsedRecipeIngredient[],
ingredientCatalog: IngredientMatchEntry[], ingredientCatalog: IngredientMatchEntry[],
unitCatalog: UnitMatchEntry[], unitCatalog: UnitMatchEntry[],
locale = "en",
): TranslatedRecipeIngredient[] { ): TranslatedRecipeIngredient[] {
const fallbackCountUnitLabel = FALLBACK_COUNT_UNIT_LABEL_BY_LOCALE[locale] ?? "piece";
return ingredients.map((ingredient) => { return ingredients.map((ingredient) => {
const ingredientId = matchIngredientName(ingredient.name, ingredientCatalog); const ingredientId = matchIngredientName(ingredient.name, ingredientCatalog, locale);
const extracted = extractQuantity(ingredient.rawText); const extracted = extractQuantity(ingredient.rawText);
const quantity = ingredient.quantity ?? extracted.quantity; const quantity = ingredient.quantity ?? extracted.quantity;
const unitText = ingredient.unit ?? extracted.remainder; const unitText = ingredient.unit ?? extracted.remainder;
const unitId = const unitId =
matchUnit(unitText, unitCatalog) ?? matchUnit(unitText, unitCatalog, locale) ??
(quantity !== null ? matchUnit(FALLBACK_COUNT_UNIT_LABEL, unitCatalog) : null); (quantity !== null ? matchUnit(fallbackCountUnitLabel, unitCatalog, locale) : null);
return { ...ingredient, quantity, ingredientId, unitId }; 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 * locale that doesn't match the actual text's language doesn't degrade
* gracefully, it just gets things wrong. * gracefully, it just gets things wrong.
* *
* Ingredient/unit matching only has English data today * Ingredient/unit matching has data for `"en"` and `"fr"` today
* (`INGREDIENT_LABELS_EN`/`UNIT_LABELS_EN`, `packages/shared`) for any * (`INGREDIENT_LABELS_EN`/`_FR`, `UNIT_LABELS_EN`/`_FR`, `packages/shared`)
* `locale` other than `"en"` this skips `loadIngredientCatalog`/ * `loadIngredientCatalog(locale)`/`loadUnitCatalog(locale)` are always
* `loadUnitCatalog` entirely and leaves every ingredient's `ingredientId`/ * called, never specially skipped for a particular locale: a locale with no
* `unitId` at the neutral `null` `translateRecipeSteps` already stubs in, * label table of its own (anything but `"en"`/`"fr"`) just gets back empty
* the same "no matching-language data" degradation tech-step matching * catalogs from those two loaders, and `translateRecipeIngredients` over an
* already has for a locale with no mappings. * 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( export async function translateRecipe(
recipe: ParsedRecipe, recipe: ParsedRecipe,
@ -288,15 +309,18 @@ export async function translateRecipe(
try { try {
const translated = await translateRecipeSteps(recipe, locale); const translated = await translateRecipeSteps(recipe, locale);
if (locale !== "en") return translated;
const [ingredientCatalog, unitCatalog] = await Promise.all([ const [ingredientCatalog, unitCatalog] = await Promise.all([
loadIngredientCatalog(), loadIngredientCatalog(locale),
loadUnitCatalog(), loadUnitCatalog(locale),
]); ]);
return { return {
...translated, ...translated,
ingredients: translateRecipeIngredients(recipe.ingredients, ingredientCatalog, unitCatalog), ingredients: translateRecipeIngredients(
recipe.ingredients,
ingredientCatalog,
unitCatalog,
locale,
),
}; };
} catch (err) { } catch (err) {
// Rethrown as-is — the caller (`sources.service.ts`) already // Rethrown as-is — the caller (`sources.service.ts`) already

View file

@ -11,10 +11,8 @@ import {
import { prisma } from "../../db/prisma.js"; import { prisma } from "../../db/prisma.js";
import { findImportedRecipeIds } from "../../db/recipe-source-sync.js"; import { findImportedRecipeIds } from "../../db/recipe-source-sync.js";
import { import {
type IngredientMatchEntry,
loadIngredientCatalog, loadIngredientCatalog,
loadUnitCatalog, loadUnitCatalog,
type UnitMatchEntry,
} from "../../lib/recipe-matching/ingredient-matcher.js"; } from "../../lib/recipe-matching/ingredient-matcher.js";
import { import {
mergeDuplicateIngredients, mergeDuplicateIngredients,
@ -141,10 +139,13 @@ export async function browseSource(
* techniques with their exact matched span (`matchTechStepSpans`, the same * techniques with their exact matched span (`matchTechStepSpans`, the same
* function `recipe.service.ts` uses at real save time see its doc * function `recipe.service.ts` uses at real save time see its doc
* comment), all against `adapter.locale`'s catalogs. Ingredient/unit * comment), all against `adapter.locale`'s catalogs. Ingredient/unit
* matching itself only has English data today (see `ingredient-matcher.ts`); * matching has data for `"en"`/`"fr"` today (see `ingredient-matcher.ts`);
* a non-English-locale source simply gets `ingredient`/`unit: null` on * `loadIngredientCatalog`/`loadUnitCatalog` are always called with
* every line, the same graceful "no matching-language data" degradation * `adapter.locale` directly, never specially skipped for a particular
* `translateRecipe` already has. * 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 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. * @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), matches: await techStepClassifier.matchTechStepSpans(step.description, adapter.locale),
})), })),
), ),
adapter.locale === "en" loadIngredientCatalog(adapter.locale),
? loadIngredientCatalog() loadUnitCatalog(adapter.locale),
: Promise.resolve<IngredientMatchEntry[]>([]),
adapter.locale === "en" ? loadUnitCatalog() : Promise.resolve<UnitMatchEntry[]>([]),
prisma.techStep.findMany({ select: { id: true, key: true } }), prisma.techStep.findMany({ select: { id: true, key: true } }),
]); ]);
const techStepById = new Map(techStepsByKey.map((techStep) => [techStep.id, techStep])); const techStepById = new Map(techStepsByKey.map((techStep) => [techStep.id, techStep]));
@ -189,6 +188,7 @@ export async function previewSourceItem(
parsed.ingredients, parsed.ingredients,
ingredientCatalog, ingredientCatalog,
unitCatalog, unitCatalog,
adapter.locale,
); );
const [ingredientViews, unitViews] = await Promise.all([getIngredients(), getUnits()]); const [ingredientViews, unitViews] = await Promise.all([getIngredients(), getUnits()]);
const ingredientById = new Map(ingredientViews.map((view) => [view.id, view])); const ingredientById = new Map(ingredientViews.map((view) => [view.id, view]));

View file

@ -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 { expect } from "chai";
import { prisma } from "../../src/db/prisma.js"; import { prisma } from "../../src/db/prisma.js";
import { import {
@ -90,6 +90,48 @@ describe("ingredient-matcher", () => {
const onionB: IngredientMatchEntry = { ingredientId: 21, label: "Onion" }; const onionB: IngredientMatchEntry = { ingredientId: 21, label: "Onion" };
expect(matchIngredientName("onion", [onionB, onionA])).to.equal(20); 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", () => { describe("matchUnit", () => {
@ -117,10 +159,14 @@ describe("ingredient-matcher", () => {
expect(matchUnit("TBSP", catalog)).to.equal(tablespoon.unitId); 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); 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", () => { it("doesn't match a short abbreviation inside an unrelated word", () => {
// "g" alone must not match "grated" — whole-token comparison. // "g" alone must not match "grated" — whole-token comparison.
expect(matchUnit("grated", catalog)).to.equal(null); expect(matchUnit("grated", catalog)).to.equal(null);
@ -137,6 +183,35 @@ describe("ingredient-matcher", () => {
it("returns null for an empty string", () => { it("returns null for an empty string", () => {
expect(matchUnit("", catalog)).to.equal(null); 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", () => { describe("extractQuantity", () => {
@ -239,5 +314,51 @@ describe("ingredient-matcher", () => {
const cupEntry = catalog.find((entry) => entry.unitId === cup.id); const cupEntry = catalog.find((entry) => entry.unitId === cup.id);
expect(cupEntry?.synonyms).to.deep.equal(["cup", "cups"]); 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([]);
});
}); });
}); });

View file

@ -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 = { const recipe: ParsedRecipe = {
...buildParsedRecipe(["Faire mijoter à feu doux"]), ...buildParsedRecipe(["Faire mijoter à feu doux"]),
ingredients: [ 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([ expect(translated.ingredients).to.deep.equal([
{ {
rawText: "1 cup onions, chopped", rawText: "1 cup onions, chopped",
quantity: null, quantity: 1,
unit: null, unit: null,
name: "onions", name: "onions",
ingredientId: null, ingredientId: null,

View file

@ -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<RecipeSourceListResult> {
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. */ /** 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<number> { async function ingredientId(key: string): Promise<number> {
const ingredient = await prisma.ingredient.findFirstOrThrow({ where: { key } }); const ingredient = await prisma.ingredient.findFirstOrThrow({ where: { key } });
@ -305,6 +354,37 @@ describe("Sources", () => {
expect(res.body.code).to.equal(ErrorCode.RECIPE_NOT_FOUND); 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 () => { it("merges two lines that resolve to the same ingredient, summing their quantity (issue #53 follow-up)", async () => {
const { agent } = await signupWithHouse(); const { agent } = await signupWithHouse();
registerRecipeSource(buildDuplicateIngredientAdapter()); registerRecipeSource(buildDuplicateIngredientAdapter());

View file

@ -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<string, string> = {
// 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<string, string[]> = {
// "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<string, string[]> = {
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"],
};

View file

@ -4,6 +4,7 @@
// detail specific to one side. // detail specific to one side.
export * from "./data/catalog-labels-en.js"; export * from "./data/catalog-labels-en.js";
export * from "./data/catalog-labels-fr.js";
export * from "./errors/error-codes.js"; export * from "./errors/error-codes.js";
export * from "./schemas/account.js"; export * from "./schemas/account.js";
export * from "./schemas/auth.js"; export * from "./schemas/auth.js";