Compare commits
1 commit
main
...
feat/fr-in
| Author | SHA1 | Date | |
|---|---|---|---|
| 42af7e6dbb |
8 changed files with 1089 additions and 79 deletions
|
|
@ -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<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 {
|
||||
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<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}. */
|
||||
export async function loadUnitCatalog(): Promise<UnitMatchEntry[]> {
|
||||
/** 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<UnitMatchEntry[]> {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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<string, string> = {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<IngredientMatchEntry[]>([]),
|
||||
adapter.locale === "en" ? loadUnitCatalog() : Promise.resolve<UnitMatchEntry[]>([]),
|
||||
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]));
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
async function ingredientId(key: string): Promise<number> {
|
||||
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());
|
||||
|
|
|
|||
661
packages/shared/src/data/catalog-labels-fr.ts
Normal file
661
packages/shared/src/data/catalog-labels-fr.ts
Normal 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"],
|
||||
};
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Reference in a new issue