feat(recipes): matching anglais pour les tech steps et les ingrédients
- Ajoute une expression régulière anglaise à chacun des 26 TechStepMapping
du catalogue (locale "en"), en plus du "fr" existant — les recettes en
anglais (TheMealDB, etc.) peuvent désormais matcher leurs étapes.
- Nouveau apps/api/src/lib/ingredient-matcher.ts : moteur de matching pur
(nom d'ingrédient, unité, quantité) contre les catalogues Ingredient/Unit,
à partir de labels anglais écrits à la main (packages/shared/src/data/
catalog-labels-en.ts — 546 INGREDIENT_LABELS_EN + 17 UNIT_LABELS_EN avec
synonymes/abréviations). Tokenise et stem naïvement les deux côtés pour
tolérer pluriels et mots descriptifs superflus ; la correspondance la
plus spécifique (le plus de mots) l'emporte en cas de recoupement.
- extractQuantity() : lit un nombre en tête de texte libre (entier,
décimal, fraction simple ou nombre mixte) pour déduire la quantité et
l'unité quand la source ne les fournit pas séparément.
- Étend recipe-translation.ts : translateRecipe(recipe, locale) résout
aussi ingredientId/unitId/quantity de chaque ligne d'ingrédient — mais
uniquement pour locale "en" (seules langue avec des labels), pour ne pas
interroger la base inutilement ni halluciner un match dans une autre
langue.
- Ajoute cup/ounce/pound au catalogue Unit (toBaseFactor réel), absents
jusqu'ici alors que très fréquents dans les recettes anglaises.
- Vérifié en conditions réelles contre TheMealDB (Teriyaki Chicken
Casserole) : 8/9 ingrédients résolus avec la bonne quantité/unité, le
seul raté ("stir-fry vegetables") étant un mélange sans entrée dédiée au
catalogue — dégradation gracieuse (unitId/ingredientId: null) comme prévu.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
7fc74541f6
commit
89722ba790
10 changed files with 1467 additions and 61 deletions
|
|
@ -41,6 +41,14 @@ export const UNITS: Array<{ uid: string; type: UnitType; toBaseFactor: number }>
|
|||
{ uid: "bunch", type: "COUNT", toBaseFactor: 1 },
|
||||
{ uid: "sachet", type: "COUNT", toBaseFactor: 1 },
|
||||
{ uid: "sprig", type: "COUNT", toBaseFactor: 1 },
|
||||
// US customary units — added for English-language sources (TheMealDB and
|
||||
// similar routinely state quantities in cups/oz/lb, not metric), so
|
||||
// `ingredient-matcher.ts` has something to resolve them to instead of
|
||||
// always falling back to `unitId: null`. Convertible against their
|
||||
// metric siblings like any other MASS/VOLUME unit.
|
||||
{ uid: "cup", type: "VOLUME", toBaseFactor: 236.5882 },
|
||||
{ uid: "ounce", type: "MASS", toBaseFactor: 28.3495 },
|
||||
{ uid: "pound", type: "MASS", toBaseFactor: 453.5924 },
|
||||
];
|
||||
|
||||
// Cooking-technique catalog (French recipe-step normalization) — a static
|
||||
|
|
@ -60,8 +68,11 @@ export const UNITS: Array<{ uid: string; type: UnitType; toBaseFactor: number }>
|
|||
// weighted higher than the generic single-verb forms they overlap with
|
||||
// ("cuire", "sauter") so the more specific technique wins when both match
|
||||
// the same words. `locale` lets the same technique carry one matching rule
|
||||
// set per language — every entry below is `"fr"` for now, the field exists
|
||||
// so other languages can be added later without a schema change.
|
||||
// set per language — `"fr"` and `"en"` today (the latter mainly for
|
||||
// English-language sources like TheMealDB), more can be added later
|
||||
// without a schema change. The two locales are independent rule sets, not
|
||||
// translations of each other — an English recipe is matched only against
|
||||
// the `"en"` mappings, never a mix of both.
|
||||
export const TECH_STEPS: Array<{
|
||||
uid: string;
|
||||
mappings: Array<{ locale: string; expression: string; weight: number }>;
|
||||
|
|
@ -70,11 +81,15 @@ export const TECH_STEPS: Array<{
|
|||
uid: "cook",
|
||||
mappings: [
|
||||
{ locale: "fr", expression: "\\bcui(re|sez|sant|sson)\\b|\\bcuit(e|es|s)?\\b", weight: 10 },
|
||||
{ locale: "en", expression: "\\bcook(s|ed|ing)?\\b", weight: 10 },
|
||||
],
|
||||
},
|
||||
{
|
||||
uid: "fry",
|
||||
mappings: [{ locale: "fr", expression: "\\bfri(re|t|te|ts|tes|ture)\\b", weight: 15 }],
|
||||
mappings: [
|
||||
{ locale: "fr", expression: "\\bfri(re|t|te|ts|tes|ture)\\b", weight: 15 },
|
||||
{ locale: "en", expression: "\\bfr(y|ies|ied|ying)\\b", weight: 15 },
|
||||
],
|
||||
},
|
||||
{
|
||||
uid: "melt",
|
||||
|
|
@ -85,79 +100,131 @@ export const TECH_STEPS: Array<{
|
|||
"\\bfondre\\b|\\bfondu(e|es|s)?\\b|\\bfaire fondre\\b|\\bfaites fondre\\b|\\bfaire chauffer\\b|\\bfaites chauffer\\b",
|
||||
weight: 15,
|
||||
},
|
||||
{ locale: "en", expression: "\\bmelt(s|ed|ing)?\\b", weight: 15 },
|
||||
],
|
||||
},
|
||||
{
|
||||
uid: "deglaze",
|
||||
mappings: [{ locale: "fr", expression: "\\bd[ée]glac(er|ez|é|ée|age)\\b", weight: 20 }],
|
||||
mappings: [
|
||||
{ locale: "fr", expression: "\\bd[ée]glac(er|ez|é|ée|age)\\b", weight: 20 },
|
||||
{ locale: "en", expression: "\\bdeglaz(e|es|ed|ing)\\b", weight: 20 },
|
||||
],
|
||||
},
|
||||
{
|
||||
uid: "simmer",
|
||||
mappings: [{ locale: "fr", expression: "\\bmijot(er|ez|e|ant|é)\\b", weight: 15 }],
|
||||
mappings: [
|
||||
{ locale: "fr", expression: "\\bmijot(er|ez|e|ant|é)\\b", weight: 15 },
|
||||
{ locale: "en", expression: "\\bsimmer(s|ed|ing)?\\b", weight: 15 },
|
||||
],
|
||||
},
|
||||
{
|
||||
uid: "boil",
|
||||
mappings: [
|
||||
{ locale: "fr", expression: "\\bbouill(ir|ant|ie|ies)\\b|\\b[ée]bullition\\b", weight: 12 },
|
||||
{ locale: "en", expression: "\\bboil(s|ed|ing)?\\b", weight: 12 },
|
||||
],
|
||||
},
|
||||
{
|
||||
uid: "roast",
|
||||
mappings: [{ locale: "fr", expression: "\\br[ôo]tir\\b|\\br[ôo]ti(e|es|s)?\\b", weight: 15 }],
|
||||
mappings: [
|
||||
{ locale: "fr", expression: "\\br[ôo]tir\\b|\\br[ôo]ti(e|es|s)?\\b", weight: 15 },
|
||||
{ locale: "en", expression: "\\broast(s|ed|ing)?\\b", weight: 15 },
|
||||
],
|
||||
},
|
||||
{
|
||||
uid: "grill",
|
||||
mappings: [{ locale: "fr", expression: "\\bgrill(er|ez|é|ée|ées|ade)\\b", weight: 15 }],
|
||||
mappings: [
|
||||
{ locale: "fr", expression: "\\bgrill(er|ez|é|ée|ées|ade)\\b", weight: 15 },
|
||||
{ locale: "en", expression: "\\bgrill(s|ed|ing)?\\b", weight: 15 },
|
||||
],
|
||||
},
|
||||
{
|
||||
uid: "panFry",
|
||||
mappings: [{ locale: "fr", expression: "\\bsaut(er|ez|é|ée|ées|ant)\\b", weight: 12 }],
|
||||
mappings: [
|
||||
{ locale: "fr", expression: "\\bsaut(er|ez|é|ée|ées|ant)\\b", weight: 12 },
|
||||
{
|
||||
locale: "en",
|
||||
expression: "\\bsaut[ée](s|ed|ing)?\\b|\\bpan[- ]?fr(y|ies|ied|ying)\\b",
|
||||
weight: 12,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
uid: "blanch",
|
||||
mappings: [{ locale: "fr", expression: "\\bblanch(ir|issez|i|ie|ies|iment)\\b", weight: 18 }],
|
||||
mappings: [
|
||||
{ locale: "fr", expression: "\\bblanch(ir|issez|i|ie|ies|iment)\\b", weight: 18 },
|
||||
{ locale: "en", expression: "\\bblanch(es|ed|ing)?\\b", weight: 18 },
|
||||
],
|
||||
},
|
||||
{
|
||||
uid: "marinate",
|
||||
mappings: [{ locale: "fr", expression: "\\bmarin(er|ez|é|ée|ées|ade)\\b", weight: 18 }],
|
||||
mappings: [
|
||||
{ locale: "fr", expression: "\\bmarin(er|ez|é|ée|ées|ade)\\b", weight: 18 },
|
||||
{ locale: "en", expression: "\\bmarinat(e|es|ed|ing)\\b|\\bmarinad(e|es)\\b", weight: 18 },
|
||||
],
|
||||
},
|
||||
{
|
||||
uid: "chop",
|
||||
mappings: [{ locale: "fr", expression: "\\bhach(er|ez|é|ée|ées|is)\\b", weight: 15 }],
|
||||
mappings: [
|
||||
{ locale: "fr", expression: "\\bhach(er|ez|é|ée|ées|is)\\b", weight: 15 },
|
||||
{ locale: "en", expression: "\\bchop(s|ped|ping)?\\b", weight: 15 },
|
||||
],
|
||||
},
|
||||
{
|
||||
uid: "peel",
|
||||
mappings: [{ locale: "fr", expression: "\\b[ée]pluch(er|ez|é|ée|ées|age)\\b", weight: 15 }],
|
||||
mappings: [
|
||||
{ locale: "fr", expression: "\\b[ée]pluch(er|ez|é|ée|ées|age)\\b", weight: 15 },
|
||||
{ locale: "en", expression: "\\bpeel(s|ed|ing)?\\b", weight: 15 },
|
||||
],
|
||||
},
|
||||
{
|
||||
uid: "mince",
|
||||
mappings: [{ locale: "fr", expression: "\\b[ée]minc(er|ez|é|ée|ées)\\b", weight: 18 }],
|
||||
mappings: [
|
||||
{ locale: "fr", expression: "\\b[ée]minc(er|ez|é|ée|ées)\\b", weight: 18 },
|
||||
{ locale: "en", expression: "\\bminc(e|es|ed|ing)\\b", weight: 18 },
|
||||
],
|
||||
},
|
||||
{
|
||||
uid: "mix",
|
||||
mappings: [{ locale: "fr", expression: "\\bm[ée]lang(er|ez|é|ée|ées|e|es)\\b", weight: 10 }],
|
||||
mappings: [
|
||||
{ locale: "fr", expression: "\\bm[ée]lang(er|ez|é|ée|ées|e|es)\\b", weight: 10 },
|
||||
{ locale: "en", expression: "\\bmix(es|ed|ing)?\\b|\\bcombine(s|d)?\\b", weight: 10 },
|
||||
],
|
||||
},
|
||||
{
|
||||
uid: "whisk",
|
||||
mappings: [
|
||||
{ locale: "fr", expression: "\\bfouett(er|ez|é|ée|ées)\\b|\\bau fouet\\b", weight: 15 },
|
||||
{ locale: "en", expression: "\\bwhisk(s|ed|ing)?\\b", weight: 15 },
|
||||
],
|
||||
},
|
||||
{
|
||||
uid: "foldIn",
|
||||
mappings: [{ locale: "fr", expression: "\\bincorpor(er|ez|é|ée|ées|ant)\\b", weight: 15 }],
|
||||
mappings: [
|
||||
{ locale: "fr", expression: "\\bincorpor(er|ez|é|ée|ées|ant)\\b", weight: 15 },
|
||||
{ locale: "en", expression: "\\bfold(s|ed|ing)? in\\b", weight: 15 },
|
||||
],
|
||||
},
|
||||
{
|
||||
uid: "setAside",
|
||||
mappings: [{ locale: "fr", expression: "\\br[ée]serv(er|ez|é|ée|ées)\\b", weight: 15 }],
|
||||
mappings: [
|
||||
{ locale: "fr", expression: "\\br[ée]serv(er|ez|é|ée|ées)\\b", weight: 15 },
|
||||
{ locale: "en", expression: "\\bset(s)? aside\\b|\\bsetting aside\\b", weight: 15 },
|
||||
],
|
||||
},
|
||||
{
|
||||
uid: "season",
|
||||
mappings: [{ locale: "fr", expression: "\\bassaisonn(er|ez|é|ée|ées|ement)\\b", weight: 15 }],
|
||||
mappings: [
|
||||
{ locale: "fr", expression: "\\bassaisonn(er|ez|é|ée|ées|ement)\\b", weight: 15 },
|
||||
{ locale: "en", expression: "\\bseason(s|ed|ing)?\\b", weight: 15 },
|
||||
],
|
||||
},
|
||||
{
|
||||
uid: "drain",
|
||||
mappings: [{ locale: "fr", expression: "\\b[ée]goutt(er|ez|é|ée|ées)\\b", weight: 15 }],
|
||||
mappings: [
|
||||
{ locale: "fr", expression: "\\b[ée]goutt(er|ez|é|ée|ées)\\b", weight: 15 },
|
||||
{ locale: "en", expression: "\\bdrain(s|ed|ing)?\\b", weight: 15 },
|
||||
],
|
||||
},
|
||||
{
|
||||
uid: "brown",
|
||||
|
|
@ -168,17 +235,30 @@ export const TECH_STEPS: Array<{
|
|||
"\\bfaire revenir\\b|\\bfaites revenir\\b|\\bfais revenir\\b|\\bfaire dorer\\b|\\bfaites dorer\\b",
|
||||
weight: 25,
|
||||
},
|
||||
// Verb forms only (not bare "brown"), which would false-positive on
|
||||
// ingredient descriptions like "brown sugar"/"brown rice".
|
||||
{ locale: "en", expression: "\\bbrown(ed|ing)\\b", weight: 25 },
|
||||
],
|
||||
},
|
||||
{
|
||||
uid: "rest",
|
||||
mappings: [
|
||||
{ locale: "fr", expression: "\\blaiss(er|ez|e) reposer\\b|\\breposer\\b", weight: 20 },
|
||||
// Anchored to "let ... rest"/"rest for" rather than bare "rest",
|
||||
// which would false-positive on phrases like "the rest of the".
|
||||
{
|
||||
locale: "en",
|
||||
expression: "\\blet (it |them )?rest\\b|\\brest(s|ed|ing)? for\\b",
|
||||
weight: 20,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
uid: "preheat",
|
||||
mappings: [{ locale: "fr", expression: "\\bpr[ée]chauff(er|ez|é|ée)\\b", weight: 20 }],
|
||||
mappings: [
|
||||
{ locale: "fr", expression: "\\bpr[ée]chauff(er|ez|é|ée)\\b", weight: 20 },
|
||||
{ locale: "en", expression: "\\bpreheat(s|ed|ing)?\\b", weight: 20 },
|
||||
],
|
||||
},
|
||||
{
|
||||
uid: "bake",
|
||||
|
|
@ -189,12 +269,26 @@ export const TECH_STEPS: Array<{
|
|||
"\\bcuire au four\\b|\\bcuisson au four\\b|\\benfourn(er|ez|é|ée|ées)\\b|\\bau four\\b",
|
||||
weight: 25,
|
||||
},
|
||||
{
|
||||
locale: "en",
|
||||
expression: "\\bbak(e|es|ed|ing)\\b|\\bin (a|the) (preheated )?oven\\b",
|
||||
weight: 25,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
uid: "plate",
|
||||
mappings: [
|
||||
{ locale: "fr", expression: "\\bdress(er|ez|age)\\b", weight: 15 },
|
||||
{ locale: "en", expression: "\\bplat(e|es|ed|ing)\\b", weight: 15 },
|
||||
],
|
||||
},
|
||||
{ uid: "plate", mappings: [{ locale: "fr", expression: "\\bdress(er|ez|age)\\b", weight: 15 }] },
|
||||
{
|
||||
uid: "coat",
|
||||
mappings: [{ locale: "fr", expression: "\\bnapp(er|ez|é|ée|ées|age)\\b", weight: 15 }],
|
||||
mappings: [
|
||||
{ locale: "fr", expression: "\\bnapp(er|ez|é|ée|ées|age)\\b", weight: 15 },
|
||||
{ locale: "en", expression: "\\bcoat(s|ed|ing)?\\b", weight: 15 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
191
apps/api/src/lib/ingredient-matcher.ts
Normal file
191
apps/api/src/lib/ingredient-matcher.ts
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
import { INGREDIENT_LABELS_EN, UNIT_LABELS_EN } 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.
|
||||
*
|
||||
* 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
|
||||
* 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
|
||||
* ingredient text's tokens. The longest (most specific) matching catalog
|
||||
* phrase wins, same "specificity resolves overlaps" idea as
|
||||
* `matchTechSteps`, just without weights (there's no need to rank two
|
||||
* *unrelated* ingredients — only a phrase against its own substrings, e.g.
|
||||
* "chicken breast" beating bare "chicken").
|
||||
*
|
||||
* `matchIngredientName`/`matchUnit`/`extractQuantity` are pure, so they're
|
||||
* unit-testable without a database (see `test/ingredient-matcher.test.ts`);
|
||||
* `loadIngredientCatalog`/`loadUnitCatalog` are the only DB-touching pieces,
|
||||
* meant to be fetched once per request and reused across every ingredient
|
||||
* line, the same "don't requery per item" convention as
|
||||
* `loadTechStepMappingRules`.
|
||||
*/
|
||||
|
||||
/** One `Ingredient` row trimmed to what {@link matchIngredientName} needs, alongside its English matching label. */
|
||||
export interface IngredientMatchEntry {
|
||||
ingredientId: number;
|
||||
/** English label from `INGREDIENT_LABELS_EN`, e.g. `"Chicken breast"` — matched against free text, never displayed. */
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** One `Unit` row trimmed to what {@link matchUnit} needs, alongside its accepted English spellings. */
|
||||
export interface UnitMatchEntry {
|
||||
unitId: number;
|
||||
/** Accepted spellings from `UNIT_LABELS_EN`, e.g. `["tbsp", "tbs", "tablespoon", "tablespoons"]`. */
|
||||
synonyms: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Naive English stemmer — strips a plural suffix so "tomato"/"tomatoes",
|
||||
* "onion"/"onions", "cherry"/"cherries" compare equal after stemming.
|
||||
* Deliberately not a real linguistic stemmer: it only has to be
|
||||
* *consistent* (the same word always stems the same way) since both sides
|
||||
* of every comparison go through it, not linguistically correct on its
|
||||
* own — see the module doc comment.
|
||||
*/
|
||||
function stemWord(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[] {
|
||||
return normalizeText(text)
|
||||
.split(/[^a-z]+/)
|
||||
.filter((word) => word.length > 0)
|
||||
.map(stemWord);
|
||||
}
|
||||
|
||||
/** Whether `needle` appears as a contiguous run inside `haystack`, at any starting position. */
|
||||
function containsSubsequence(haystack: string[], needle: string[]): boolean {
|
||||
if (needle.length === 0 || needle.length > haystack.length) return false;
|
||||
for (let start = 0; start <= haystack.length - needle.length; start++) {
|
||||
if (needle.every((word, i) => haystack[start + i] === word)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function matchIngredientName(name: string, catalog: IngredientMatchEntry[]): number | null {
|
||||
const nameTokens = tokenize(name);
|
||||
if (nameTokens.length === 0) return null;
|
||||
|
||||
let best: { ingredientId: number; tokenCount: number } | null = null;
|
||||
for (const entry of catalog) {
|
||||
const labelTokens = tokenize(entry.label);
|
||||
if (!containsSubsequence(nameTokens, labelTokens)) continue;
|
||||
if (
|
||||
best === null ||
|
||||
labelTokens.length > best.tokenCount ||
|
||||
(labelTokens.length === best.tokenCount && entry.ingredientId < best.ingredientId)
|
||||
) {
|
||||
best = { ingredientId: entry.ingredientId, tokenCount: labelTokens.length };
|
||||
}
|
||||
}
|
||||
return best?.ingredientId ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function matchUnit(unitText: string, catalog: UnitMatchEntry[]): number | null {
|
||||
const tokens = tokenize(unitText);
|
||||
if (tokens.length === 0) return null;
|
||||
const firstToken = tokens[0];
|
||||
|
||||
for (const entry of catalog) {
|
||||
if (entry.synonyms.some((synonym) => stemWord(normalizeText(synonym)) === firstToken)) {
|
||||
return entry.unitId;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** What {@link extractQuantity} pulls out of a leading numeric expression, alongside what's left of the string after it. */
|
||||
export interface ExtractedQuantity {
|
||||
quantity: number | null;
|
||||
/** `rawText` with the leading quantity (and any separating whitespace) removed — `rawText` unchanged if none was found. */
|
||||
remainder: string;
|
||||
}
|
||||
|
||||
// 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.
|
||||
const LEADING_QUANTITY_PATTERN = /^(\d+)\s+(\d+)\/(\d+)|^(\d+)\/(\d+)|^(\d+(?:[.,]\d+)?)/;
|
||||
|
||||
/**
|
||||
* Pulls a leading quantity off `rawText` (e.g. `"1 1/2 cups flour"` ->
|
||||
* `{ quantity: 1.5, remainder: "cups flour" }`), supporting a plain
|
||||
* integer/decimal, a simple fraction (`"1/2"`), or a mixed number
|
||||
* (`"1 1/2"`). `quantity: null` (remainder === rawText, trimmed) when
|
||||
* `rawText` doesn't start with a recognizable number — e.g. `"salt to
|
||||
* taste"`, which has none.
|
||||
*/
|
||||
export function extractQuantity(rawText: string): ExtractedQuantity {
|
||||
const trimmed = rawText.trim();
|
||||
const match = LEADING_QUANTITY_PATTERN.exec(trimmed);
|
||||
if (!match) return { quantity: null, remainder: trimmed };
|
||||
|
||||
let quantity: number;
|
||||
if (match[1] !== undefined) {
|
||||
// Mixed number: "1 1/2".
|
||||
quantity = Number(match[1]) + Number(match[2]) / Number(match[3]);
|
||||
} else if (match[4] !== undefined) {
|
||||
// Simple fraction: "1/2".
|
||||
quantity = Number(match[4]) / Number(match[5]);
|
||||
} else {
|
||||
// Plain integer/decimal: "2" or "1.5"/"1,5" — group 6 is guaranteed
|
||||
// defined here (the only remaining alternative in the pattern), `?? ""`
|
||||
// is just satisfying the indexed-access type, not a real fallback.
|
||||
quantity = Number((match[6] ?? "").replace(",", "."));
|
||||
}
|
||||
|
||||
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`); 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[]> {
|
||||
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];
|
||||
if (label !== undefined) catalog.push({ ingredientId: ingredient.id, label });
|
||||
}
|
||||
return catalog;
|
||||
}
|
||||
|
||||
/** 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[]> {
|
||||
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];
|
||||
if (synonyms !== undefined) catalog.push({ unitId: unit.id, synonyms });
|
||||
}
|
||||
return catalog;
|
||||
}
|
||||
|
|
@ -1,4 +1,17 @@
|
|||
import type { ParsedRecipe, ParsedRecipeStep } from "./recipe-source-adapter.js";
|
||||
import {
|
||||
type IngredientMatchEntry,
|
||||
type UnitMatchEntry,
|
||||
extractQuantity,
|
||||
loadIngredientCatalog,
|
||||
loadUnitCatalog,
|
||||
matchIngredientName,
|
||||
matchUnit,
|
||||
} from "./ingredient-matcher.js";
|
||||
import type {
|
||||
ParsedRecipe,
|
||||
ParsedRecipeIngredient,
|
||||
ParsedRecipeStep,
|
||||
} from "./recipe-source-adapter.js";
|
||||
import {
|
||||
type TechStepMappingRule,
|
||||
loadTechStepMappingRules,
|
||||
|
|
@ -13,17 +26,20 @@ import {
|
|||
* technique sequence, the same `techStepIds: number[]` shape
|
||||
* `Step.techSteps`/`StepTechStep` (schema.prisma) will eventually persist.
|
||||
*
|
||||
* Deliberately doesn't touch ingredients — resolving free-text ingredient
|
||||
* lines against our `Ingredient`/`Unit` catalogs is a separate, not-yet-built
|
||||
* concern (see `ParsedRecipeIngredient`'s doc comment) — and doesn't turn
|
||||
* the result into a saveable `Recipe` either (no `dietIds`/`visibility`/
|
||||
* author, a source can't know those). This is one step of the pipeline, not
|
||||
* the whole thing.
|
||||
* Also resolves ingredients — matching each free-text `ParsedRecipeIngredient`
|
||||
* line against our `Ingredient`/`Unit` catalogs (`ingredient-matcher.ts`),
|
||||
* the same "free source text -> our catalog id" idea as tech-step
|
||||
* detection, just for ingredients/units/quantities instead of technique
|
||||
* verbs. Like tech-step matching, this doesn't turn the result into a
|
||||
* saveable `Recipe` either (no `dietIds`/`visibility`/author, a source
|
||||
* can't know those) — this is one step of the pipeline, not the whole
|
||||
* thing.
|
||||
*
|
||||
* `translateRecipeSteps` is pure (takes `techStepMappings` as a plain
|
||||
* argument, same convention as `matchTechSteps` itself) so it's unit-testable
|
||||
* without a database; `translateRecipe` is the DB-backed convenience wrapper
|
||||
* a caller reaches for in practice, mirroring `tech-step-matcher.ts`'s own
|
||||
* `translateRecipeSteps`/`translateRecipeIngredients` are pure (take their
|
||||
* matching data as plain arguments, same convention as `matchTechSteps`/
|
||||
* `matchIngredientName` themselves) so they're unit-testable without a
|
||||
* database; `translateRecipe` is the DB-backed convenience wrapper a caller
|
||||
* reaches for in practice, mirroring `tech-step-matcher.ts`'s own
|
||||
* pure/DB-touching split.
|
||||
*/
|
||||
|
||||
|
|
@ -33,18 +49,30 @@ export interface TranslatedRecipeStep extends ParsedRecipeStep {
|
|||
techStepIds: number[];
|
||||
}
|
||||
|
||||
/** A {@link ParsedRecipe} whose `steps` have been translated — everything else (name, ingredients, portions, …) passes through unchanged. */
|
||||
export interface TranslatedRecipe extends Omit<ParsedRecipe, "steps"> {
|
||||
/** A {@link ParsedRecipeIngredient}, after ingredient/unit matching — `quantity` is filled in from `rawText` when the source itself left it `null` (see `extractQuantity`); `ingredientId`/`unitId` are `null` when nothing in the catalog matched. */
|
||||
export interface TranslatedRecipeIngredient extends ParsedRecipeIngredient {
|
||||
ingredientId: number | null;
|
||||
unitId: number | null;
|
||||
}
|
||||
|
||||
/** A {@link ParsedRecipe} whose `steps`/`ingredients` have been translated — everything else (name, portions, …) passes through unchanged. */
|
||||
export interface TranslatedRecipe extends Omit<ParsedRecipe, "steps" | "ingredients"> {
|
||||
steps: TranslatedRecipeStep[];
|
||||
ingredients: TranslatedRecipeIngredient[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Declares each of `recipe`'s steps' technique sequence against
|
||||
* `techStepMappings`, leaving everything else about the recipe untouched.
|
||||
* Pure — testable with a hand-built mapping list, no database involved (see
|
||||
* `translateRecipe` for the DB-backed loader). `techStepMappings` should
|
||||
* already be filtered to the locale the caller cares about, same
|
||||
* requirement `matchTechSteps` itself has.
|
||||
* `techStepMappings`, leaving everything else about the recipe untouched —
|
||||
* including ingredients, which are only stubbed to `TranslatedRecipeIngredient`'s
|
||||
* shape here (`ingredientId`/`unitId: null`, `quantity`/`unit` untouched);
|
||||
* actually resolving them is {@link translateRecipeIngredients}'s job, kept
|
||||
* separate the same way tech-step and ingredient matching are two
|
||||
* independent concerns everywhere else in this module. Pure — testable with
|
||||
* a hand-built mapping list, no database involved (see `translateRecipe`
|
||||
* for the DB-backed loader). `techStepMappings` should already be filtered
|
||||
* to the locale the caller cares about, same requirement `matchTechSteps`
|
||||
* itself has.
|
||||
*/
|
||||
export function translateRecipeSteps(
|
||||
recipe: ParsedRecipe,
|
||||
|
|
@ -52,6 +80,11 @@ export function translateRecipeSteps(
|
|||
): TranslatedRecipe {
|
||||
return {
|
||||
...recipe,
|
||||
ingredients: recipe.ingredients.map((ingredient) => ({
|
||||
...ingredient,
|
||||
ingredientId: null,
|
||||
unitId: null,
|
||||
})),
|
||||
steps: recipe.steps.map((step) => ({
|
||||
...step,
|
||||
techStepIds: matchTechSteps(step.description, techStepMappings),
|
||||
|
|
@ -60,14 +93,39 @@ export function translateRecipeSteps(
|
|||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper around {@link translateRecipeSteps} that loads
|
||||
* `locale`'s mapping catalog itself — what a caller reaches for when
|
||||
* translating a single recipe on its own (e.g. the eventual "import this
|
||||
* one recipe" endpoint). A caller translating many recipes at once should
|
||||
* call `loadTechStepMappingRules` once and reuse it across
|
||||
* `translateRecipeSteps` calls instead, the same "don't requery per item"
|
||||
* reasoning `recipe.service.ts`'s `createRecipe`/`updateRecipe` already
|
||||
* follow for manually-authored recipes.
|
||||
* Resolves each of `ingredients`' free-text `name`/`unit`/`quantity`
|
||||
* against `ingredientCatalog`/`unitCatalog` (see `ingredient-matcher.ts`).
|
||||
* Pure — testable with hand-built catalogs, no database involved (see
|
||||
* `translateRecipe` for the DB-backed loader). `quantity` falls back to
|
||||
* `extractQuantity(rawText)` only when the source itself left it `null`;
|
||||
* same for `unit` falling back to `extractQuantity`'s `remainder` before
|
||||
* being matched against `unitCatalog` — a source that already states a
|
||||
* clean unit/quantity is trusted over re-deriving it from `rawText`.
|
||||
*/
|
||||
export function translateRecipeIngredients(
|
||||
ingredients: ParsedRecipeIngredient[],
|
||||
ingredientCatalog: IngredientMatchEntry[],
|
||||
unitCatalog: UnitMatchEntry[],
|
||||
): TranslatedRecipeIngredient[] {
|
||||
return ingredients.map((ingredient) => {
|
||||
const ingredientId = matchIngredientName(ingredient.name, ingredientCatalog);
|
||||
const extracted = extractQuantity(ingredient.rawText);
|
||||
const quantity = ingredient.quantity ?? extracted.quantity;
|
||||
const unitText = ingredient.unit ?? extracted.remainder;
|
||||
const unitId = matchUnit(unitText, unitCatalog);
|
||||
return { ...ingredient, quantity, ingredientId, unitId };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper around {@link translateRecipeSteps}/
|
||||
* {@link translateRecipeIngredients} that loads every catalog itself —
|
||||
* what a caller reaches for when translating a single recipe on its own
|
||||
* (e.g. the eventual "import this one recipe" endpoint). A caller
|
||||
* translating many recipes at once should load the catalogs once and reuse
|
||||
* them across calls instead, the same "don't requery per item" reasoning
|
||||
* `recipe.service.ts`'s `createRecipe`/`updateRecipe` already follow for
|
||||
* manually-authored recipes.
|
||||
*
|
||||
* No user- or recipe-level language preference exists anywhere in the app
|
||||
* yet (see `tech-step-matcher.ts`'s `loadTechStepMappingRules`) — callers
|
||||
|
|
@ -76,11 +134,30 @@ export function translateRecipeSteps(
|
|||
* mappings will currently get an empty `techStepIds` sequence on every
|
||||
* step — matching-language mappings for that source's language don't exist
|
||||
* yet, this stage doesn't invent them.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export async function translateRecipe(
|
||||
recipe: ParsedRecipe,
|
||||
locale: string,
|
||||
): Promise<TranslatedRecipe> {
|
||||
const techStepMappings = await loadTechStepMappingRules(locale);
|
||||
return translateRecipeSteps(recipe, techStepMappings);
|
||||
const translated = translateRecipeSteps(recipe, techStepMappings);
|
||||
|
||||
if (locale !== "en") return translated;
|
||||
|
||||
const [ingredientCatalog, unitCatalog] = await Promise.all([
|
||||
loadIngredientCatalog(),
|
||||
loadUnitCatalog(),
|
||||
]);
|
||||
return {
|
||||
...translated,
|
||||
ingredients: translateRecipeIngredients(recipe.ingredients, ingredientCatalog, unitCatalog),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
218
apps/api/test/ingredient-matcher.test.ts
Normal file
218
apps/api/test/ingredient-matcher.test.ts
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
import { expect } from "chai";
|
||||
import { prisma } from "../src/db/prisma.js";
|
||||
import {
|
||||
type IngredientMatchEntry,
|
||||
type UnitMatchEntry,
|
||||
extractQuantity,
|
||||
loadIngredientCatalog,
|
||||
loadUnitCatalog,
|
||||
matchIngredientName,
|
||||
matchUnit,
|
||||
} from "../src/lib/ingredient-matcher.js";
|
||||
import { resetDatabase } from "../test-support/reset-db.js";
|
||||
|
||||
describe("ingredient-matcher", () => {
|
||||
describe("matchIngredientName", () => {
|
||||
const tomato: IngredientMatchEntry = { ingredientId: 1, label: "Tomato" };
|
||||
const chicken: IngredientMatchEntry = { ingredientId: 2, label: "Chicken" };
|
||||
const chickenBreast: IngredientMatchEntry = { ingredientId: 3, label: "Chicken breast" };
|
||||
const onion: IngredientMatchEntry = { ingredientId: 4, label: "Onion" };
|
||||
const allPurposeFlour: IngredientMatchEntry = { ingredientId: 5, label: "All-purpose flour" };
|
||||
const catalog = [tomato, chicken, chickenBreast, onion, allPurposeFlour];
|
||||
|
||||
it("matches an exact single-word label", () => {
|
||||
expect(matchIngredientName("tomato", catalog)).to.equal(tomato.ingredientId);
|
||||
});
|
||||
|
||||
it("is case- and accent-insensitive", () => {
|
||||
expect(matchIngredientName("TOMATO", catalog)).to.equal(tomato.ingredientId);
|
||||
expect(matchIngredientName("Tömato", catalog)).to.equal(tomato.ingredientId);
|
||||
});
|
||||
|
||||
it("tolerates a regular plural", () => {
|
||||
expect(matchIngredientName("tomatoes", catalog)).to.equal(tomato.ingredientId);
|
||||
expect(matchIngredientName("onions", catalog)).to.equal(onion.ingredientId);
|
||||
});
|
||||
|
||||
it("tolerates extra descriptive words around the match", () => {
|
||||
expect(matchIngredientName("2 large diced yellow onions", catalog)).to.equal(
|
||||
onion.ingredientId,
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers the more specific multi-word label over a shorter one it contains", () => {
|
||||
expect(matchIngredientName("boneless skinless chicken breasts", catalog)).to.equal(
|
||||
chickenBreast.ingredientId,
|
||||
);
|
||||
});
|
||||
|
||||
it("still matches the shorter label when the more specific one isn't mentioned", () => {
|
||||
expect(matchIngredientName("diced chicken thighs", catalog)).to.equal(chicken.ingredientId);
|
||||
});
|
||||
|
||||
it("matches a hyphenated multi-word label", () => {
|
||||
expect(matchIngredientName("2 cups all-purpose flour", catalog)).to.equal(
|
||||
allPurposeFlour.ingredientId,
|
||||
);
|
||||
});
|
||||
|
||||
it("doesn't false-positive a short label inside an unrelated longer word", () => {
|
||||
// "egg" must not match inside "eggplant" — whole-token comparison, not substring.
|
||||
const eggplant: IngredientMatchEntry = { ingredientId: 6, label: "Eggplant" };
|
||||
const egg: IngredientMatchEntry = { ingredientId: 7, label: "Egg" };
|
||||
expect(matchIngredientName("eggplant", [egg, eggplant])).to.equal(eggplant.ingredientId);
|
||||
});
|
||||
|
||||
it("returns null when nothing matches", () => {
|
||||
expect(matchIngredientName("mango", catalog)).to.equal(null);
|
||||
});
|
||||
|
||||
it("returns null for an empty catalog", () => {
|
||||
expect(matchIngredientName("tomato", [])).to.equal(null);
|
||||
});
|
||||
|
||||
it("returns null for an empty name", () => {
|
||||
expect(matchIngredientName("", catalog)).to.equal(null);
|
||||
});
|
||||
|
||||
it("breaks a same-specificity tie by the lowest ingredientId", () => {
|
||||
const onionA: IngredientMatchEntry = { ingredientId: 20, label: "Onion" };
|
||||
const onionB: IngredientMatchEntry = { ingredientId: 21, label: "Onion" };
|
||||
expect(matchIngredientName("onion", [onionB, onionA])).to.equal(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchUnit", () => {
|
||||
const gram: UnitMatchEntry = { unitId: 1, synonyms: ["g", "gram", "grams"] };
|
||||
const tablespoon: UnitMatchEntry = {
|
||||
unitId: 2,
|
||||
synonyms: ["tbsp", "tbs", "tablespoon", "tablespoons"],
|
||||
};
|
||||
const cup: UnitMatchEntry = { unitId: 3, synonyms: ["cup", "cups"] };
|
||||
const catalog = [gram, tablespoon, cup];
|
||||
|
||||
it("matches a full word synonym", () => {
|
||||
expect(matchUnit("tablespoon", catalog)).to.equal(tablespoon.unitId);
|
||||
});
|
||||
|
||||
it("matches an abbreviation synonym", () => {
|
||||
expect(matchUnit("tbsp", catalog)).to.equal(tablespoon.unitId);
|
||||
});
|
||||
|
||||
it("matches a plural synonym via the same stemming as ingredients", () => {
|
||||
expect(matchUnit("cups", catalog)).to.equal(cup.unitId);
|
||||
});
|
||||
|
||||
it("is case-insensitive", () => {
|
||||
expect(matchUnit("TBSP", catalog)).to.equal(tablespoon.unitId);
|
||||
});
|
||||
|
||||
it("only looks at the first word — ignores trailing text", () => {
|
||||
expect(matchUnit("cup flour", catalog)).to.equal(cup.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);
|
||||
});
|
||||
|
||||
it("returns null when nothing matches", () => {
|
||||
expect(matchUnit("pound", catalog)).to.equal(null);
|
||||
});
|
||||
|
||||
it("returns null for an empty catalog", () => {
|
||||
expect(matchUnit("cup", [])).to.equal(null);
|
||||
});
|
||||
|
||||
it("returns null for an empty string", () => {
|
||||
expect(matchUnit("", catalog)).to.equal(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractQuantity", () => {
|
||||
it("extracts a plain integer", () => {
|
||||
expect(extractQuantity("2 onions")).to.deep.equal({ quantity: 2, remainder: "onions" });
|
||||
});
|
||||
|
||||
it("extracts a decimal using a dot", () => {
|
||||
expect(extractQuantity("1.5 cups flour")).to.deep.equal({
|
||||
quantity: 1.5,
|
||||
remainder: "cups flour",
|
||||
});
|
||||
});
|
||||
|
||||
it("extracts a decimal using a comma", () => {
|
||||
expect(extractQuantity("1,5 cups flour")).to.deep.equal({
|
||||
quantity: 1.5,
|
||||
remainder: "cups flour",
|
||||
});
|
||||
});
|
||||
|
||||
it("extracts a simple fraction", () => {
|
||||
expect(extractQuantity("1/2 cup sugar")).to.deep.equal({
|
||||
quantity: 0.5,
|
||||
remainder: "cup sugar",
|
||||
});
|
||||
});
|
||||
|
||||
it("extracts a mixed number", () => {
|
||||
expect(extractQuantity("1 1/2 cups sugar")).to.deep.equal({
|
||||
quantity: 1.5,
|
||||
remainder: "cups sugar",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null quantity and the trimmed original text when there's no leading number", () => {
|
||||
expect(extractQuantity("salt to taste")).to.deep.equal({
|
||||
quantity: null,
|
||||
remainder: "salt to taste",
|
||||
});
|
||||
});
|
||||
|
||||
it("trims surrounding whitespace", () => {
|
||||
expect(extractQuantity(" 2 eggs ")).to.deep.equal({ quantity: 2, remainder: "eggs" });
|
||||
});
|
||||
|
||||
it("only takes the first number of a hyphenated range", () => {
|
||||
expect(extractQuantity("2-3 carrots")).to.deep.equal({
|
||||
quantity: 2,
|
||||
remainder: "-3 carrots",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadIngredientCatalog / loadUnitCatalog", () => {
|
||||
beforeEach(async () => {
|
||||
await resetDatabase();
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
it("loads one entry per Ingredient that has an English label, keyed by real ingredientId", async () => {
|
||||
const tomato = await prisma.ingredient.findFirstOrThrow({ where: { key: "tomato" } });
|
||||
const ingredientCount = await prisma.ingredient.count();
|
||||
|
||||
const catalog = await loadIngredientCatalog();
|
||||
|
||||
// Every seeded ingredient has an authored English label (verified at
|
||||
// generation time — see packages/shared/src/data/catalog-labels-en.ts),
|
||||
// so nothing should be silently skipped.
|
||||
expect(catalog).to.have.length(ingredientCount);
|
||||
const tomatoEntry = catalog.find((entry) => entry.ingredientId === tomato.id);
|
||||
expect(tomatoEntry?.label).to.equal("Tomato");
|
||||
});
|
||||
|
||||
it("loads one entry per Unit that has English 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();
|
||||
|
||||
expect(catalog).to.have.length(unitCount);
|
||||
const cupEntry = catalog.find((entry) => entry.unitId === cup.id);
|
||||
expect(cupEntry?.synonyms).to.deep.equal(["cup", "cups"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,7 +1,12 @@
|
|||
import { expect } from "chai";
|
||||
import { prisma } from "../src/db/prisma.js";
|
||||
import type { ParsedRecipe } from "../src/lib/recipe-source-adapter.js";
|
||||
import { translateRecipe, translateRecipeSteps } from "../src/lib/recipe-translation.js";
|
||||
import type { IngredientMatchEntry, UnitMatchEntry } from "../src/lib/ingredient-matcher.js";
|
||||
import type { ParsedRecipe, ParsedRecipeIngredient } from "../src/lib/recipe-source-adapter.js";
|
||||
import {
|
||||
translateRecipe,
|
||||
translateRecipeIngredients,
|
||||
translateRecipeSteps,
|
||||
} from "../src/lib/recipe-translation.js";
|
||||
import type { TechStepMappingRule } from "../src/lib/tech-step-matcher.js";
|
||||
import { resetDatabase } from "../test-support/reset-db.js";
|
||||
|
||||
|
|
@ -78,7 +83,16 @@ describe("recipe-translation", () => {
|
|||
expect(translated.picture).to.equal(recipe.picture);
|
||||
expect(translated.portions).to.equal(recipe.portions);
|
||||
expect(translated.sourceUrl).to.equal(recipe.sourceUrl);
|
||||
expect(translated.ingredients).to.deep.equal(recipe.ingredients);
|
||||
});
|
||||
|
||||
it("stubs every ingredient's ingredientId/unitId to null, leaving the rest of it untouched — actual ingredient matching is translateRecipeIngredients' job", () => {
|
||||
const recipe = buildParsedRecipe(["Servir"]);
|
||||
|
||||
const translated = translateRecipeSteps(recipe, []);
|
||||
|
||||
expect(translated.ingredients).to.deep.equal([
|
||||
{ ...recipe.ingredients[0], ingredientId: null, unitId: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it("gives every step an empty sequence when there are no mappings at all", () => {
|
||||
|
|
@ -98,6 +112,111 @@ describe("recipe-translation", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("translateRecipeIngredients", () => {
|
||||
const tomato: IngredientMatchEntry = { ingredientId: 1, label: "Tomato" };
|
||||
const chicken: IngredientMatchEntry = { ingredientId: 2, label: "Chicken" };
|
||||
const chickenBreast: IngredientMatchEntry = { ingredientId: 3, label: "Chicken breast" };
|
||||
const onion: IngredientMatchEntry = { ingredientId: 4, label: "Onion" };
|
||||
|
||||
const gram: UnitMatchEntry = { unitId: 10, synonyms: ["g", "gram", "grams"] };
|
||||
const cup: UnitMatchEntry = { unitId: 11, synonyms: ["cup", "cups"] };
|
||||
|
||||
function buildIngredient(
|
||||
overrides: Partial<ParsedRecipeIngredient> & { rawText: string; name: string },
|
||||
): ParsedRecipeIngredient {
|
||||
return { quantity: null, unit: null, ...overrides };
|
||||
}
|
||||
|
||||
it("resolves ingredientId from free-text name, tolerating extra descriptive words and plurals", () => {
|
||||
const translated = translateRecipeIngredients(
|
||||
[
|
||||
buildIngredient({
|
||||
rawText: "2 large diced yellow onions",
|
||||
name: "large diced yellow onions",
|
||||
}),
|
||||
],
|
||||
[tomato, chicken, chickenBreast, onion],
|
||||
[],
|
||||
);
|
||||
|
||||
expect(translated[0].ingredientId).to.equal(onion.ingredientId);
|
||||
});
|
||||
|
||||
it("prefers the more specific multi-word label over a shorter one it contains", () => {
|
||||
const translated = translateRecipeIngredients(
|
||||
[
|
||||
buildIngredient({
|
||||
rawText: "2 boneless chicken breasts",
|
||||
name: "boneless chicken breasts",
|
||||
}),
|
||||
],
|
||||
[tomato, chicken, chickenBreast, onion],
|
||||
[],
|
||||
);
|
||||
|
||||
expect(translated[0].ingredientId).to.equal(chickenBreast.ingredientId);
|
||||
});
|
||||
|
||||
it("returns a null ingredientId when nothing in the catalog matches", () => {
|
||||
const translated = translateRecipeIngredients(
|
||||
[buildIngredient({ rawText: "1 mango", name: "mango" })],
|
||||
[tomato, chicken, chickenBreast, onion],
|
||||
[],
|
||||
);
|
||||
|
||||
expect(translated[0].ingredientId).to.equal(null);
|
||||
});
|
||||
|
||||
it("extracts a mixed-number quantity and unit from rawText when the source left them null", () => {
|
||||
const translated = translateRecipeIngredients(
|
||||
[buildIngredient({ rawText: "1 1/2 cups chicken breast", name: "chicken breast" })],
|
||||
[chickenBreast],
|
||||
[cup],
|
||||
);
|
||||
|
||||
expect(translated[0].quantity).to.equal(1.5);
|
||||
expect(translated[0].unitId).to.equal(cup.unitId);
|
||||
});
|
||||
|
||||
it("trusts the source's own quantity/unit over re-deriving them from rawText", () => {
|
||||
const translated = translateRecipeIngredients(
|
||||
[
|
||||
buildIngredient({
|
||||
rawText: "some raw text that happens to mention cups",
|
||||
name: "tomato",
|
||||
quantity: 3,
|
||||
unit: "g",
|
||||
}),
|
||||
],
|
||||
[tomato],
|
||||
[gram, cup],
|
||||
);
|
||||
|
||||
expect(translated[0].quantity).to.equal(3);
|
||||
expect(translated[0].unitId).to.equal(gram.unitId);
|
||||
});
|
||||
|
||||
it("leaves quantity/unitId null when rawText has neither a leading number nor a recognizable unit", () => {
|
||||
const translated = translateRecipeIngredients(
|
||||
[buildIngredient({ rawText: "salt to taste", name: "salt" })],
|
||||
[],
|
||||
[],
|
||||
);
|
||||
|
||||
expect(translated[0].quantity).to.equal(null);
|
||||
expect(translated[0].unitId).to.equal(null);
|
||||
});
|
||||
|
||||
it("leaves rawText/name/description untouched", () => {
|
||||
const ingredient = buildIngredient({ rawText: "1 cup onions", name: "onions" });
|
||||
|
||||
const translated = translateRecipeIngredients([ingredient], [onion], [cup]);
|
||||
|
||||
expect(translated[0].rawText).to.equal(ingredient.rawText);
|
||||
expect(translated[0].name).to.equal(ingredient.name);
|
||||
});
|
||||
});
|
||||
|
||||
describe("translateRecipe", () => {
|
||||
beforeEach(async () => {
|
||||
await resetDatabase();
|
||||
|
|
@ -120,7 +239,7 @@ describe("recipe-translation", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it("finds nothing for English text against the French catalog — the current locale gap", async () => {
|
||||
it("finds nothing for English text against the French catalog — locales are separate rule sets, never mixed", async () => {
|
||||
// A step lifted verbatim from a real TheMealDB recipe.
|
||||
const recipe = buildParsedRecipe([
|
||||
"Bring a large saucepan of salted water to the boil",
|
||||
|
|
@ -132,12 +251,76 @@ describe("recipe-translation", () => {
|
|||
expect(translated.steps.map((step) => step.techStepIds)).to.deep.equal([[], []]);
|
||||
});
|
||||
|
||||
it("finds nothing for a locale with no mappings, even for text that would otherwise match", async () => {
|
||||
const recipe = buildParsedRecipe(["Faire mijoter à feu doux"]);
|
||||
it("resolves real TechStep ids from the seeded English catalog", async () => {
|
||||
const boil = await prisma.techStep.findFirstOrThrow({ where: { key: "boil" } });
|
||||
const chop = await prisma.techStep.findFirstOrThrow({ where: { key: "chop" } });
|
||||
// Same two steps as the French/English mismatch test above, this
|
||||
// time matched against the matching-language catalog.
|
||||
const recipe = buildParsedRecipe([
|
||||
"Bring a large saucepan of salted water to the boil",
|
||||
"Chop the onions finely",
|
||||
]);
|
||||
|
||||
const translated = await translateRecipe(recipe, "en");
|
||||
|
||||
expect(translated.steps.map((step) => step.techStepIds)).to.deep.equal([
|
||||
[boil.id],
|
||||
[chop.id],
|
||||
]);
|
||||
});
|
||||
|
||||
it("finds nothing for a locale with no mappings at all", async () => {
|
||||
const recipe = buildParsedRecipe(["Faire mijoter à feu doux"]);
|
||||
|
||||
const translated = await translateRecipe(recipe, "de");
|
||||
|
||||
expect(translated.steps.map((step) => step.techStepIds)).to.deep.equal([[]]);
|
||||
});
|
||||
|
||||
it("resolves real Ingredient/Unit ids from the seeded English catalog for an 'en' translation", async () => {
|
||||
const onion = await prisma.ingredient.findFirstOrThrow({ where: { key: "onion" } });
|
||||
const cup = await prisma.unit.findFirstOrThrow({ where: { key: "cup" } });
|
||||
const recipe: ParsedRecipe = {
|
||||
...buildParsedRecipe(["Chop the onions finely"]),
|
||||
ingredients: [
|
||||
{ rawText: "1 cup onions, chopped", quantity: null, unit: null, name: "onions" },
|
||||
],
|
||||
};
|
||||
|
||||
const translated = await translateRecipe(recipe, "en");
|
||||
|
||||
expect(translated.ingredients).to.deep.equal([
|
||||
{
|
||||
rawText: "1 cup onions, chopped",
|
||||
quantity: 1,
|
||||
unit: null,
|
||||
name: "onions",
|
||||
ingredientId: onion.id,
|
||||
unitId: cup.id,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const recipe: ParsedRecipe = {
|
||||
...buildParsedRecipe(["Faire mijoter à feu doux"]),
|
||||
ingredients: [
|
||||
{ rawText: "1 cup onions, chopped", quantity: null, unit: null, name: "onions" },
|
||||
],
|
||||
};
|
||||
|
||||
const translated = await translateRecipe(recipe, "fr");
|
||||
|
||||
expect(translated.ingredients).to.deep.equal([
|
||||
{
|
||||
rawText: "1 cup onions, chopped",
|
||||
quantity: null,
|
||||
unit: null,
|
||||
name: "onions",
|
||||
ingredientId: null,
|
||||
unitId: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ describe("Reference data", () => {
|
|||
const res = await request(app).get("/reference/units");
|
||||
|
||||
expect(res.status).to.equal(200);
|
||||
expect(res.body).to.have.length(14);
|
||||
expect(res.body).to.have.length(17);
|
||||
expect(res.body.map((u: { key: string }) => u.key)).to.include("gram");
|
||||
expect(res.body[0]).to.have.keys(["id", "key", "type", "toBaseFactor"]);
|
||||
});
|
||||
|
|
@ -122,6 +122,9 @@ describe("Reference data", () => {
|
|||
expect(byKey("liter")).to.include({ type: "VOLUME", toBaseFactor: 1000 });
|
||||
expect(byKey("piece")).to.include({ type: "COUNT", toBaseFactor: 1 });
|
||||
expect(byKey("pinch")).to.include({ type: "COUNT", toBaseFactor: 1 });
|
||||
expect(byKey("cup")).to.include({ type: "VOLUME", toBaseFactor: 236.5882 });
|
||||
expect(byKey("ounce")).to.include({ type: "MASS", toBaseFactor: 28.3495 });
|
||||
expect(byKey("pound")).to.include({ type: "MASS", toBaseFactor: 453.5924 });
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -149,7 +152,8 @@ describe("Reference data", () => {
|
|||
|
||||
const res = await request(app).get("/reference/tech-steps");
|
||||
expect(res.body).to.have.length(26);
|
||||
expect(await prisma.techStepMapping.count()).to.equal(26);
|
||||
// 26 techniques × one "fr" + one "en" mapping each.
|
||||
expect(await prisma.techStepMapping.count()).to.equal(52);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -162,18 +162,21 @@ describe("tech-step-matcher", () => {
|
|||
|
||||
it("only returns mappings for the requested locale", async () => {
|
||||
const simmer = await prisma.techStep.findFirstOrThrow({ where: { key: "simmer" } });
|
||||
// "de" has no seeded mappings at all (unlike "fr"/"en", which the
|
||||
// real catalog now both populate) — a clean locale to attach one
|
||||
// synthetic row to without conflating it with real seed data.
|
||||
await prisma.techStepMapping.create({
|
||||
data: { techStepId: simmer.id, locale: "en", expression: "\\bsimmer\\b", weight: 15 },
|
||||
data: { techStepId: simmer.id, locale: "de", expression: "\\bsimmer\\b", weight: 15 },
|
||||
});
|
||||
|
||||
// The seeded catalog (26 "fr" mappings) must be untouched by the extra
|
||||
// "en" row — same count, and none of them carry the English expression.
|
||||
// "de" row — same count, and none of them carry its expression.
|
||||
const frRules = await loadTechStepMappingRules("fr");
|
||||
expect(frRules).to.have.length(26);
|
||||
expect(frRules.map((rule) => rule.expression)).to.not.include("\\bsimmer\\b");
|
||||
|
||||
const enRules = await loadTechStepMappingRules("en");
|
||||
expect(enRules).to.deep.equal([
|
||||
const deRules = await loadTechStepMappingRules("de");
|
||||
expect(deRules).to.deep.equal([
|
||||
{ techStepId: simmer.id, expression: "\\bsimmer\\b", weight: 15 },
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -347,7 +347,10 @@
|
|||
"clove": "gousse",
|
||||
"bunch": "botte",
|
||||
"sachet": "sachet",
|
||||
"sprig": "brin"
|
||||
"sprig": "brin",
|
||||
"cup": "tasse",
|
||||
"ounce": "once",
|
||||
"pound": "livre"
|
||||
},
|
||||
"diets": {
|
||||
"omnivore": "Omnivore",
|
||||
|
|
|
|||
632
packages/shared/src/data/catalog-labels-en.ts
Normal file
632
packages/shared/src/data/catalog-labels-en.ts
Normal file
|
|
@ -0,0 +1,632 @@
|
|||
/**
|
||||
* English 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. Used by
|
||||
* `apps/api/src/lib/ingredient-matcher.ts` to resolve free-text ingredient
|
||||
* lines from English-language recipe sources (e.g. TheMealDB) against our
|
||||
* catalog — the English-language counterpart to `apps/web`'s
|
||||
* `locales/fr/translation.json` (`catalog.ingredients.<key>`), which
|
||||
* serves the same role for the French UI. Hand-authored (not derived from
|
||||
* the key) for the same reason the French labels are: natural food
|
||||
* vocabulary needs real phrasing, not a mechanical transform.
|
||||
*/
|
||||
export const INGREDIENT_LABELS_EN: Record<string, string> = {
|
||||
// Vegetables
|
||||
tomato: "Tomato",
|
||||
onion: "Onion",
|
||||
shallot: "Shallot",
|
||||
garlic: "Garlic",
|
||||
carrot: "Carrot",
|
||||
zucchini: "Zucchini",
|
||||
cucumber: "Cucumber",
|
||||
gherkins: "Gherkins",
|
||||
bellPepper: "Bell pepper",
|
||||
mushroom: "Mushroom",
|
||||
porcini: "Porcini mushrooms",
|
||||
eggplant: "Eggplant",
|
||||
broccoli: "Broccoli",
|
||||
cauliflower: "Cauliflower",
|
||||
whiteCabbage: "White cabbage",
|
||||
redCabbage: "Red cabbage",
|
||||
brusselsSprouts: "Brussels sprouts",
|
||||
spinach: "Spinach",
|
||||
swissChard: "Swiss chard",
|
||||
lettuce: "Lettuce",
|
||||
arugula: "Arugula",
|
||||
watercress: "Watercress",
|
||||
leek: "Leek",
|
||||
celery: "Celery",
|
||||
radish: "Radish",
|
||||
beetroot: "Beetroot",
|
||||
turnip: "Turnip",
|
||||
parsnip: "Parsnip",
|
||||
greenBean: "Green bean",
|
||||
pea: "Pea",
|
||||
corn: "Corn",
|
||||
artichoke: "Artichoke",
|
||||
fennel: "Fennel",
|
||||
endive: "Endive",
|
||||
pumpkin: "Pumpkin",
|
||||
butternutSquash: "Butternut squash",
|
||||
asparagus: "Asparagus",
|
||||
avocado: "Avocado",
|
||||
potato: "Potato",
|
||||
sweetPotato: "Sweet potato",
|
||||
cherryTomato: "Cherry tomatoes",
|
||||
bokChoy: "Bok choy",
|
||||
soybeanSprouts: "Soybean sprouts",
|
||||
shiitake: "Shiitake mushrooms",
|
||||
daikon: "Daikon radish",
|
||||
freshGreenChili: "Fresh green chili",
|
||||
cardoon: "Cardoon",
|
||||
radicchio: "Radicchio",
|
||||
romanesco: "Romanesco cauliflower",
|
||||
kohlrabi: "Kohlrabi",
|
||||
napaCabbage: "Napa cabbage",
|
||||
celeriac: "Celeriac",
|
||||
okra: "Okra",
|
||||
springOnion: "Spring onion",
|
||||
redKuriSquash: "Red kuri squash",
|
||||
rutabaga: "Rutabaga",
|
||||
samphire: "Samphire",
|
||||
salsify: "Salsify",
|
||||
lambsLettuce: "Lamb's lettuce",
|
||||
escarole: "Escarole",
|
||||
|
||||
// Fruits
|
||||
lemon: "Lemon",
|
||||
lime: "Lime",
|
||||
apple: "Apple",
|
||||
pear: "Pear",
|
||||
banana: "Banana",
|
||||
orange: "Orange",
|
||||
clementine: "Clementine",
|
||||
grapefruit: "Grapefruit",
|
||||
strawberry: "Strawberry",
|
||||
raspberry: "Raspberry",
|
||||
blueberry: "Blueberry",
|
||||
blackberry: "Blackberry",
|
||||
cherry: "Cherry",
|
||||
apricot: "Apricot",
|
||||
peach: "Peach",
|
||||
plum: "Plum",
|
||||
grape: "Grape",
|
||||
melon: "Melon",
|
||||
watermelon: "Watermelon",
|
||||
pineapple: "Pineapple",
|
||||
mango: "Mango",
|
||||
kiwi: "Kiwi",
|
||||
fig: "Fig",
|
||||
date: "Date",
|
||||
lychee: "Lychee",
|
||||
pomegranate: "Pomegranate",
|
||||
rhubarb: "Rhubarb",
|
||||
quince: "Quince",
|
||||
blackcurrant: "Blackcurrant",
|
||||
cranberry: "Cranberry",
|
||||
redcurrant: "Redcurrant",
|
||||
persimmon: "Persimmon",
|
||||
nectarine: "Nectarine",
|
||||
tamarind: "Tamarind",
|
||||
|
||||
// Fresh herbs
|
||||
basil: "Basil",
|
||||
parsley: "Parsley",
|
||||
thyme: "Thyme",
|
||||
rosemary: "Rosemary",
|
||||
bayLeaf: "Bay leaf",
|
||||
chives: "Chives",
|
||||
freshCilantro: "Fresh cilantro",
|
||||
mint: "Mint",
|
||||
oregano: "Oregano",
|
||||
dill: "Dill",
|
||||
tarragon: "Tarragon",
|
||||
savory: "Savory",
|
||||
marjoram: "Marjoram",
|
||||
sage: "Sage",
|
||||
chervil: "Chervil",
|
||||
ginger: "Ginger",
|
||||
lemongrass: "Lemongrass",
|
||||
kaffirLime: "Kaffir lime",
|
||||
|
||||
// Meats
|
||||
rabbit: "Rabbit",
|
||||
groundBeef: "Ground beef",
|
||||
beefSteak: "Beef steak",
|
||||
beefRoast: "Beef roast",
|
||||
vealCutlet: "Veal cutlet",
|
||||
porkTenderloin: "Pork tenderloin",
|
||||
porkChop: "Pork chop",
|
||||
lamb: "Lamb",
|
||||
legOfLamb: "Leg of lamb",
|
||||
baconLardons: "Bacon lardons",
|
||||
bacon: "Bacon",
|
||||
ham: "Cooked ham",
|
||||
curedHam: "Cured ham",
|
||||
sausage: "Sausage",
|
||||
chorizo: "Chorizo",
|
||||
merguez: "Merguez",
|
||||
prosciutto: "Prosciutto",
|
||||
pancetta: "Pancetta",
|
||||
mortadella: "Mortadella",
|
||||
salami: "Salami",
|
||||
andouille: "Andouille sausage",
|
||||
andouillette: "Andouillette",
|
||||
whitePudding: "White pudding",
|
||||
blackPudding: "Black pudding",
|
||||
cervelat: "Cervelat",
|
||||
rillettes: "Rillettes",
|
||||
dryCuredSausage: "Dry-cured sausage",
|
||||
bayonneHam: "Bayonne ham",
|
||||
coppa: "Coppa",
|
||||
rosetteSausage: "Rosette sausage",
|
||||
vealLiver: "Veal liver",
|
||||
vealKidneys: "Veal kidneys",
|
||||
vealBrain: "Veal brain",
|
||||
vealSweetbread: "Veal sweetbread",
|
||||
beefTongue: "Beef tongue",
|
||||
tripe: "Tripe",
|
||||
venison: "Venison",
|
||||
roeDeer: "Roe deer",
|
||||
wildBoar: "Wild boar",
|
||||
horseMeat: "Horse meat",
|
||||
beefHeart: "Beef heart",
|
||||
foieGras: "Foie gras",
|
||||
beefMuzzle: "Beef muzzle",
|
||||
grisonsDriedBeef: "Grisons dried beef",
|
||||
|
||||
// Poultry
|
||||
chicken: "Chicken",
|
||||
turkey: "Turkey",
|
||||
duck: "Duck",
|
||||
duckBreast: "Duck breast",
|
||||
quail: "Quail",
|
||||
guineaFowl: "Guinea fowl",
|
||||
goose: "Goose",
|
||||
poultryLiver: "Poultry liver",
|
||||
capon: "Capon",
|
||||
pigeon: "Pigeon",
|
||||
pheasant: "Pheasant",
|
||||
|
||||
// Fish
|
||||
salmon: "Salmon",
|
||||
tuna: "Tuna",
|
||||
cod: "Cod",
|
||||
trout: "Trout",
|
||||
sardine: "Sardine",
|
||||
anchovy: "Anchovy",
|
||||
whiting: "Whiting",
|
||||
surimi: "Surimi",
|
||||
seaBass: "Sea bass",
|
||||
seaBream: "Sea bream",
|
||||
sole: "Sole",
|
||||
turbot: "Turbot",
|
||||
hake: "Hake",
|
||||
pollock: "Pollock",
|
||||
saithe: "Saithe",
|
||||
haddock: "Haddock",
|
||||
mackerel: "Mackerel",
|
||||
herring: "Herring",
|
||||
redMullet: "Red mullet",
|
||||
skate: "Skate",
|
||||
monkfish: "Monkfish",
|
||||
halibut: "Halibut",
|
||||
swordfish: "Swordfish",
|
||||
carp: "Carp",
|
||||
pike: "Pike",
|
||||
perch: "Perch",
|
||||
tilapia: "Tilapia",
|
||||
pangasius: "Pangasius",
|
||||
smokedSalmon: "Smoked salmon",
|
||||
driedFish: "Dried fish",
|
||||
eel: "Eel",
|
||||
plaice: "Plaice",
|
||||
saltCod: "Salt cod",
|
||||
lemonSole: "Lemon sole",
|
||||
scorpionfish: "Scorpionfish",
|
||||
|
||||
// Shellfish
|
||||
shrimp: "Shrimp",
|
||||
langoustine: "Langoustines",
|
||||
lobster: "Lobster",
|
||||
crab: "Crab",
|
||||
spinyLobster: "Spiny lobster",
|
||||
mussels: "Mussels",
|
||||
oysters: "Oysters",
|
||||
scallops: "Scallops",
|
||||
squid: "Squid",
|
||||
octopus: "Octopus",
|
||||
clams: "Clams",
|
||||
whelks: "Whelks",
|
||||
spiderCrab: "Spider crab",
|
||||
periwinkle: "Periwinkle",
|
||||
crayfish: "Crayfish",
|
||||
greyShrimp: "Grey shrimp",
|
||||
cockle: "Cockle",
|
||||
snail: "Snail",
|
||||
cuttlefish: "Cuttlefish",
|
||||
|
||||
// Starches
|
||||
semolina: "Semolina",
|
||||
couscous: "Couscous",
|
||||
bulgur: "Bulgur",
|
||||
polenta: "Polenta",
|
||||
quinoa: "Quinoa",
|
||||
pasta: "Pasta",
|
||||
wholeWheatPasta: "Whole wheat pasta",
|
||||
rice: "Rice",
|
||||
basmatiRice: "Basmati rice",
|
||||
brownRice: "Brown rice",
|
||||
oats: "Rolled oats",
|
||||
spaghetti: "Spaghetti",
|
||||
penne: "Penne",
|
||||
tagliatelle: "Tagliatelle",
|
||||
lasagnaSheets: "Lasagna sheets",
|
||||
gnocchi: "Gnocchi",
|
||||
arborioRice: "Arborio rice",
|
||||
riceNoodles: "Rice noodles",
|
||||
udonNoodles: "Udon noodles",
|
||||
sobaNoodles: "Soba noodles",
|
||||
chineseNoodles: "Chinese noodles",
|
||||
riceVermicelli: "Rice vermicelli",
|
||||
soyVermicelli: "Soy vermicelli",
|
||||
stickyRice: "Sticky rice",
|
||||
sushiRice: "Sushi rice",
|
||||
jasmineRice: "Jasmine rice",
|
||||
|
||||
// Legumes
|
||||
greenLentils: "Green lentils",
|
||||
redLentils: "Red lentils",
|
||||
chickpeas: "Chickpeas",
|
||||
whiteBeans: "White beans",
|
||||
kidneyBeans: "Kidney beans",
|
||||
blackBeans: "Black beans",
|
||||
splitPeas: "Split peas",
|
||||
favaBeans: "Fava beans",
|
||||
edamame: "Edamame",
|
||||
pintoBeans: "Pinto beans",
|
||||
flageoletBeans: "Flageolet beans",
|
||||
goldenLentils: "Golden lentils",
|
||||
|
||||
// Nuts, seeds and other dry goods
|
||||
peanutsShelled: "Peanuts",
|
||||
almonds: "Almonds",
|
||||
walnuts: "Walnuts",
|
||||
hazelnuts: "Hazelnuts",
|
||||
cashews: "Cashews",
|
||||
pistachios: "Pistachios",
|
||||
pecans: "Pecans",
|
||||
almondPowder: "Ground almonds",
|
||||
pineNuts: "Pine nuts",
|
||||
sunflowerSeeds: "Sunflower seeds",
|
||||
pumpkinSeeds: "Pumpkin seeds",
|
||||
shreddedCoconut: "Shredded coconut",
|
||||
raisins: "Raisins",
|
||||
prunes: "Prunes",
|
||||
driedApricots: "Dried apricots",
|
||||
sesameSeeds: "Sesame seeds",
|
||||
blackMushrooms: "Black mushrooms",
|
||||
noriSeaweed: "Nori seaweed",
|
||||
wakameSeaweed: "Wakame seaweed",
|
||||
kombuSeaweed: "Kombu seaweed",
|
||||
bambooShoots: "Bamboo shoots",
|
||||
waterChestnuts: "Water chestnuts",
|
||||
|
||||
// Breads
|
||||
bread: "Bread",
|
||||
sandwichBread: "Sandwich bread",
|
||||
wholeWheatBread: "Whole wheat bread",
|
||||
baguette: "Baguette",
|
||||
ryeBread: "Rye bread",
|
||||
breadcrumbs: "Breadcrumbs",
|
||||
burgerBun: "Burger bun",
|
||||
briocheBun: "Brioche bun",
|
||||
hotDogBun: "Hot dog bun",
|
||||
pitaBread: "Pita bread",
|
||||
bagel: "Bagel",
|
||||
naan: "Naan",
|
||||
wrapBread: "Wrap bread",
|
||||
vienneseBread: "Vienna bread",
|
||||
countryBread: "Country bread",
|
||||
multigrainBread: "Multigrain bread",
|
||||
breadRoll: "Bread roll",
|
||||
swedishBread: "Swedish bread",
|
||||
glutenFreeBread: "Gluten-free bread",
|
||||
rusk: "Rusk",
|
||||
croutons: "Croutons",
|
||||
focaccia: "Focaccia",
|
||||
ciabatta: "Ciabatta",
|
||||
cornTortilla: "Corn tortilla",
|
||||
wheatTortilla: "Wheat tortilla",
|
||||
breadstick: "Breadstick",
|
||||
|
||||
// Raw dough
|
||||
puffPastry: "Puff pastry",
|
||||
shortcrustPastry: "Shortcrust pastry",
|
||||
pizzaDough: "Pizza dough",
|
||||
sweetShortcrustPastry: "Sweet shortcrust pastry",
|
||||
|
||||
// Dairy
|
||||
milk: "Milk",
|
||||
butter: "Butter",
|
||||
cremeFraiche: "Crème fraîche",
|
||||
liquidCream: "Liquid cream",
|
||||
cheese: "Cheese",
|
||||
emmental: "Emmental",
|
||||
gruyere: "Gruyère",
|
||||
parmesan: "Parmesan",
|
||||
mozzarella: "Mozzarella",
|
||||
goatCheese: "Goat cheese",
|
||||
feta: "Feta",
|
||||
comte: "Comté",
|
||||
fromageBlanc: "Fromage blanc",
|
||||
mascarpone: "Mascarpone",
|
||||
yogurt: "Yogurt",
|
||||
burrata: "Burrata",
|
||||
ricotta: "Ricotta",
|
||||
pecorino: "Pecorino",
|
||||
gorgonzola: "Gorgonzola",
|
||||
cheddar: "Cheddar",
|
||||
brie: "Brie",
|
||||
camembert: "Camembert",
|
||||
roquefort: "Roquefort",
|
||||
munster: "Munster cheese",
|
||||
reblochon: "Reblochon",
|
||||
cantal: "Cantal",
|
||||
beaufort: "Beaufort",
|
||||
saintNectaire: "Saint-Nectaire",
|
||||
blueCheese: "Blue cheese",
|
||||
cancoillotte: "Cancoillotte",
|
||||
tomme: "Tomme cheese",
|
||||
epoisses: "Époisses",
|
||||
chaource: "Chaource",
|
||||
livarot: "Livarot",
|
||||
pontLeveque: "Pont-l'Évêque",
|
||||
morbier: "Morbier",
|
||||
racletteCheese: "Raclette cheese",
|
||||
fourmeDAmbert: "Fourme d'Ambert",
|
||||
salers: "Salers",
|
||||
ossauIraty: "Ossau-Iraty",
|
||||
vacherin: "Vacherin",
|
||||
saintMarcellin: "Saint-Marcellin",
|
||||
neufchatel: "Neufchâtel",
|
||||
crottinDeChavignol: "Crottin de Chavignol",
|
||||
abondanceCheese: "Abondance cheese",
|
||||
carreDeLEst: "Carré de l'Est",
|
||||
edam: "Edam",
|
||||
gouda: "Gouda",
|
||||
mimolette: "Mimolette",
|
||||
maroilles: "Maroilles",
|
||||
montDor: "Mont d'Or",
|
||||
kefir: "Kefir",
|
||||
greekYogurt: "Greek yogurt",
|
||||
|
||||
// Eggs
|
||||
egg: "Egg",
|
||||
|
||||
// Plant-based alternatives
|
||||
coconutMilk: "Coconut milk",
|
||||
coconutCream: "Coconut cream",
|
||||
almondMilk: "Almond milk",
|
||||
oatMilk: "Oat milk",
|
||||
tofu: "Tofu",
|
||||
silkenTofu: "Silken tofu",
|
||||
|
||||
// Spices
|
||||
herbesDeProvence: "Herbes de Provence",
|
||||
blackPepper: "Black pepper",
|
||||
paprika: "Paprika",
|
||||
espelettePepper: "Espelette pepper",
|
||||
cayennePepper: "Cayenne pepper",
|
||||
cumin: "Cumin",
|
||||
curryPowder: "Curry powder",
|
||||
turmeric: "Turmeric",
|
||||
cinnamon: "Cinnamon",
|
||||
nutmeg: "Nutmeg",
|
||||
saffron: "Saffron",
|
||||
clove: "Clove",
|
||||
vanillaBean: "Vanilla bean",
|
||||
whitePepper: "White pepper",
|
||||
pinkPepper: "Pink peppercorns",
|
||||
sichuanPepper: "Sichuan pepper",
|
||||
smokedPaprika: "Smoked paprika",
|
||||
birdEyeChili: "Bird's eye chili",
|
||||
juniperBerries: "Juniper berries",
|
||||
starAnise: "Star anise",
|
||||
greenAnise: "Green anise",
|
||||
fennelSeeds: "Fennel seeds",
|
||||
sumac: "Sumac",
|
||||
nigella: "Nigella seeds",
|
||||
allspice: "Allspice",
|
||||
colomboPowder: "Colombo powder",
|
||||
baharat: "Baharat",
|
||||
horseradish: "Horseradish",
|
||||
herbSalt: "Herb salt",
|
||||
celerySalt: "Celery salt",
|
||||
fleurDeSel: "Fleur de sel",
|
||||
salt: "Salt",
|
||||
fiveSpice: "Five-spice powder",
|
||||
garamMasala: "Garam masala",
|
||||
corianderSeeds: "Coriander seeds",
|
||||
cardamom: "Cardamom",
|
||||
fenugreek: "Fenugreek",
|
||||
jalapeno: "Jalapeño",
|
||||
chipotle: "Chipotle",
|
||||
poblanoPepper: "Poblano pepper",
|
||||
habanero: "Habanero",
|
||||
rasElHanout: "Ras el hanout",
|
||||
zaatar: "Za'atar",
|
||||
|
||||
// Sauces
|
||||
soySauce: "Soy sauce",
|
||||
mustard: "Mustard",
|
||||
mayonnaise: "Mayonnaise",
|
||||
ketchup: "Ketchup",
|
||||
tabasco: "Tabasco sauce",
|
||||
worcestershireSauce: "Worcestershire sauce",
|
||||
fishSauce: "Fish sauce",
|
||||
wasabi: "Wasabi",
|
||||
harissa: "Harissa",
|
||||
curryPaste: "Curry paste",
|
||||
peanutButter: "Peanut butter",
|
||||
dijonMustard: "Dijon mustard",
|
||||
wholegrainMustard: "Wholegrain mustard",
|
||||
barbecueSauce: "Barbecue sauce",
|
||||
tartarSauce: "Tartar sauce",
|
||||
cocktailSauce: "Cocktail sauce",
|
||||
bearnaiseSauce: "Béarnaise sauce",
|
||||
hollandaiseSauce: "Hollandaise sauce",
|
||||
bechamelSauce: "Béchamel sauce",
|
||||
teriyakiSauce: "Teriyaki sauce",
|
||||
ponzuSauce: "Ponzu sauce",
|
||||
chimichurri: "Chimichurri",
|
||||
redPesto: "Red pesto",
|
||||
pesto: "Pesto",
|
||||
oysterSauce: "Oyster sauce",
|
||||
hoisinSauce: "Hoisin sauce",
|
||||
sriracha: "Sriracha",
|
||||
sweetChiliSauce: "Sweet chili sauce",
|
||||
miso: "Miso",
|
||||
shrimpPaste: "Shrimp paste",
|
||||
redCurryPaste: "Red curry paste",
|
||||
greenCurryPaste: "Green curry paste",
|
||||
tahini: "Tahini",
|
||||
aioli: "Aioli",
|
||||
vinaigrette: "Vinaigrette",
|
||||
hummus: "Hummus",
|
||||
|
||||
// Seasonings — oils, vinegars, wines and other flavorings
|
||||
oliveOil: "Olive oil",
|
||||
sunflowerOil: "Sunflower oil",
|
||||
rapeseedOil: "Rapeseed oil",
|
||||
coconutOil: "Coconut oil",
|
||||
sesameOil: "Sesame oil",
|
||||
ciderVinegar: "Cider vinegar",
|
||||
whiteVinegar: "White vinegar",
|
||||
balsamicVinegar: "Balsamic vinegar",
|
||||
capers: "Capers",
|
||||
olives: "Olives",
|
||||
blackOlives: "Black olives",
|
||||
greenOlives: "Green olives",
|
||||
whiteWine: "White wine",
|
||||
redWine: "Red wine",
|
||||
roseWine: "Rosé wine",
|
||||
redWineVinegar: "Red wine vinegar",
|
||||
whiteWineVinegar: "White wine vinegar",
|
||||
sherryVinegar: "Sherry vinegar",
|
||||
walnutOil: "Walnut oil",
|
||||
hazelnutOil: "Hazelnut oil",
|
||||
peanutOil: "Peanut oil",
|
||||
chiliOil: "Chili oil",
|
||||
riceVinegar: "Rice vinegar",
|
||||
cornOil: "Corn oil",
|
||||
grapeseedOil: "Grapeseed oil",
|
||||
soybeanOil: "Soybean oil",
|
||||
palmOil: "Palm oil",
|
||||
mirin: "Mirin",
|
||||
sake: "Sake",
|
||||
lemonJuice: "Lemon juice",
|
||||
limeJuice: "Lime juice",
|
||||
orangeJuice: "Orange juice",
|
||||
appleJuice: "Apple juice",
|
||||
grapeJuice: "Grape juice",
|
||||
tomatoJuice: "Tomato juice",
|
||||
cranberryJuice: "Cranberry juice",
|
||||
coffee: "Coffee",
|
||||
tea: "Tea",
|
||||
beer: "Beer",
|
||||
cider: "Cider",
|
||||
champagne: "Champagne / sparkling wine",
|
||||
portWine: "Port wine",
|
||||
vinJaune: "Vin jaune (Jura wine)",
|
||||
cognac: "Cognac",
|
||||
rum: "Rum",
|
||||
whisky: "Whisky",
|
||||
vodka: "Vodka",
|
||||
|
||||
// Bases — flours, stocks and other cooking essentials
|
||||
wheatFlour: "Wheat flour",
|
||||
wholeWheatFlour: "Whole wheat flour",
|
||||
cornFlour: "Corn flour",
|
||||
buckwheatFlour: "Buckwheat flour",
|
||||
riceFlour: "Rice flour",
|
||||
vegetableStockCube: "Vegetable stock cube",
|
||||
chickenStockCube: "Chicken stock cube",
|
||||
tomatoPaste: "Tomato paste",
|
||||
tomatoCoulis: "Tomato coulis",
|
||||
cannedPeeledTomatoes: "Canned peeled tomatoes",
|
||||
sunDriedTomatoes: "Sun-dried tomatoes",
|
||||
vealStock: "Veal stock",
|
||||
chickenStock: "Chicken stock",
|
||||
beefStockCube: "Beef stock cube",
|
||||
fishStockCube: "Fish stock cube",
|
||||
vegetableBroth: "Vegetable broth",
|
||||
chickenBroth: "Chicken broth",
|
||||
beefBroth: "Beef broth",
|
||||
courtBouillon: "Court bouillon",
|
||||
dashi: "Dashi (Japanese stock)",
|
||||
shellfishBisque: "Shellfish bisque",
|
||||
tapiocaFlour: "Tapioca flour",
|
||||
masaHarina: "Masa harina",
|
||||
water: "Water",
|
||||
sparklingWater: "Sparkling water",
|
||||
orangeBlossomWater: "Orange blossom water",
|
||||
roseWater: "Rose water",
|
||||
fishFumet: "Fish fumet",
|
||||
|
||||
// Thickeners and raising agents
|
||||
bakersYeast: "Baker's yeast",
|
||||
bakingPowder: "Baking powder",
|
||||
cornstarch: "Cornstarch",
|
||||
lupinFlour: "Lupin flour",
|
||||
gelatin: "Gelatin",
|
||||
bakingSoda: "Baking soda",
|
||||
potatoStarch: "Potato starch",
|
||||
|
||||
// Sugars
|
||||
sugar: "Sugar",
|
||||
honey: "Honey",
|
||||
mapleSyrup: "Maple syrup",
|
||||
brownSugar: "Brown sugar",
|
||||
powderedSugar: "Powdered sugar",
|
||||
demeraraSugar: "Demerara sugar",
|
||||
darkChocolate: "Dark chocolate",
|
||||
milkChocolate: "Milk chocolate",
|
||||
whiteChocolate: "White chocolate",
|
||||
chocolateChips: "Chocolate chips",
|
||||
cocoaPowder: "Cocoa powder",
|
||||
vanillaExtract: "Vanilla extract",
|
||||
palmSugar: "Palm sugar",
|
||||
caneSyrup: "Cane syrup",
|
||||
};
|
||||
|
||||
/**
|
||||
* English matching synonyms for the `Unit` reference catalog
|
||||
* (`apps/api/src/db/reference-seed-data.ts`'s `UNITS`), keyed by
|
||||
* `Unit.key`. Unlike {@link INGREDIENT_LABELS_EN} (one display label per
|
||||
* entry), a unit needs *several* accepted spellings to be useful for
|
||||
* matching — English recipe text freely mixes full words and abbreviations
|
||||
* ("tablespoon", "tbsp", "tbs" all appear in the wild), which a single
|
||||
* label can't capture. Used by `apps/api/src/lib/ingredient-matcher.ts` the
|
||||
* same way `INGREDIENT_LABELS_EN` is: resolving free text from
|
||||
* English-language sources against our catalog.
|
||||
*/
|
||||
export const UNIT_LABELS_EN: Record<string, string[]> = {
|
||||
gram: ["g", "gr", "gram", "grams"],
|
||||
kilogram: ["kg", "kilo", "kilos", "kilogram", "kilograms"],
|
||||
milliliter: ["ml", "milliliter", "milliliters", "millilitre", "millilitres"],
|
||||
centiliter: ["cl", "centiliter", "centiliters", "centilitre", "centilitres"],
|
||||
liter: ["l", "liter", "liters", "litre", "litres"],
|
||||
tablespoon: ["tbsp", "tbs", "tbl", "tablespoon", "tablespoons"],
|
||||
teaspoon: ["tsp", "teaspoon", "teaspoons"],
|
||||
piece: ["piece", "pieces", "pc", "pcs"],
|
||||
pinch: ["pinch", "pinches"],
|
||||
slice: ["slice", "slices"],
|
||||
clove: ["clove", "cloves"],
|
||||
bunch: ["bunch", "bunches"],
|
||||
sachet: ["sachet", "sachets", "packet", "packets", "sac", "sacs"],
|
||||
sprig: ["sprig", "sprigs"],
|
||||
cup: ["cup", "cups"],
|
||||
ounce: ["oz", "ounce", "ounces"],
|
||||
pound: ["lb", "lbs", "pound", "pounds"],
|
||||
};
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
// intentional (types, validation schemas, error codes), not an implementation
|
||||
// detail specific to one side.
|
||||
|
||||
export * from "./data/catalog-labels-en.js";
|
||||
export * from "./errors/error-codes.js";
|
||||
export * from "./schemas/account.js";
|
||||
export * from "./schemas/auth.js";
|
||||
|
|
|
|||
Loading…
Reference in a new issue