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 {
|
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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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]));
|
||||||
|
|
|
||||||
|
|
@ -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([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
|
|
||||||
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.
|
// 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";
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue