Merge pull request #41 from kyuno053/feat/english-matching
feat(recipes): matching anglais pour les tech steps et les ingrédients
This commit is contained in:
commit
6178205603
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: "bunch", type: "COUNT", toBaseFactor: 1 },
|
||||||
{ uid: "sachet", type: "COUNT", toBaseFactor: 1 },
|
{ uid: "sachet", type: "COUNT", toBaseFactor: 1 },
|
||||||
{ uid: "sprig", 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
|
// 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
|
// weighted higher than the generic single-verb forms they overlap with
|
||||||
// ("cuire", "sauter") so the more specific technique wins when both match
|
// ("cuire", "sauter") so the more specific technique wins when both match
|
||||||
// the same words. `locale` lets the same technique carry one matching rule
|
// 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
|
// set per language — `"fr"` and `"en"` today (the latter mainly for
|
||||||
// so other languages can be added later without a schema change.
|
// 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<{
|
export const TECH_STEPS: Array<{
|
||||||
uid: string;
|
uid: string;
|
||||||
mappings: Array<{ locale: string; expression: string; weight: number }>;
|
mappings: Array<{ locale: string; expression: string; weight: number }>;
|
||||||
|
|
@ -70,11 +81,15 @@ export const TECH_STEPS: Array<{
|
||||||
uid: "cook",
|
uid: "cook",
|
||||||
mappings: [
|
mappings: [
|
||||||
{ locale: "fr", expression: "\\bcui(re|sez|sant|sson)\\b|\\bcuit(e|es|s)?\\b", weight: 10 },
|
{ 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",
|
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",
|
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",
|
"\\bfondre\\b|\\bfondu(e|es|s)?\\b|\\bfaire fondre\\b|\\bfaites fondre\\b|\\bfaire chauffer\\b|\\bfaites chauffer\\b",
|
||||||
weight: 15,
|
weight: 15,
|
||||||
},
|
},
|
||||||
|
{ locale: "en", expression: "\\bmelt(s|ed|ing)?\\b", weight: 15 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
uid: "deglaze",
|
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",
|
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",
|
uid: "boil",
|
||||||
mappings: [
|
mappings: [
|
||||||
{ locale: "fr", expression: "\\bbouill(ir|ant|ie|ies)\\b|\\b[ée]bullition\\b", weight: 12 },
|
{ 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",
|
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",
|
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",
|
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",
|
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",
|
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",
|
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",
|
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",
|
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",
|
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",
|
uid: "whisk",
|
||||||
mappings: [
|
mappings: [
|
||||||
{ locale: "fr", expression: "\\bfouett(er|ez|é|ée|ées)\\b|\\bau fouet\\b", weight: 15 },
|
{ 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",
|
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",
|
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",
|
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",
|
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",
|
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",
|
"\\bfaire revenir\\b|\\bfaites revenir\\b|\\bfais revenir\\b|\\bfaire dorer\\b|\\bfaites dorer\\b",
|
||||||
weight: 25,
|
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",
|
uid: "rest",
|
||||||
mappings: [
|
mappings: [
|
||||||
{ locale: "fr", expression: "\\blaiss(er|ez|e) reposer\\b|\\breposer\\b", weight: 20 },
|
{ 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",
|
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",
|
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",
|
"\\bcuire au four\\b|\\bcuisson au four\\b|\\benfourn(er|ez|é|ée|ées)\\b|\\bau four\\b",
|
||||||
weight: 25,
|
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",
|
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 {
|
import {
|
||||||
type TechStepMappingRule,
|
type TechStepMappingRule,
|
||||||
loadTechStepMappingRules,
|
loadTechStepMappingRules,
|
||||||
|
|
@ -13,17 +26,20 @@ import {
|
||||||
* technique sequence, the same `techStepIds: number[]` shape
|
* technique sequence, the same `techStepIds: number[]` shape
|
||||||
* `Step.techSteps`/`StepTechStep` (schema.prisma) will eventually persist.
|
* `Step.techSteps`/`StepTechStep` (schema.prisma) will eventually persist.
|
||||||
*
|
*
|
||||||
* Deliberately doesn't touch ingredients — resolving free-text ingredient
|
* Also resolves ingredients — matching each free-text `ParsedRecipeIngredient`
|
||||||
* lines against our `Ingredient`/`Unit` catalogs is a separate, not-yet-built
|
* line against our `Ingredient`/`Unit` catalogs (`ingredient-matcher.ts`),
|
||||||
* concern (see `ParsedRecipeIngredient`'s doc comment) — and doesn't turn
|
* the same "free source text -> our catalog id" idea as tech-step
|
||||||
* the result into a saveable `Recipe` either (no `dietIds`/`visibility`/
|
* detection, just for ingredients/units/quantities instead of technique
|
||||||
* author, a source can't know those). This is one step of the pipeline, not
|
* verbs. Like tech-step matching, this doesn't turn the result into a
|
||||||
* the whole thing.
|
* 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
|
* `translateRecipeSteps`/`translateRecipeIngredients` are pure (take their
|
||||||
* argument, same convention as `matchTechSteps` itself) so it's unit-testable
|
* matching data as plain arguments, same convention as `matchTechSteps`/
|
||||||
* without a database; `translateRecipe` is the DB-backed convenience wrapper
|
* `matchIngredientName` themselves) so they're unit-testable without a
|
||||||
* a caller reaches for in practice, mirroring `tech-step-matcher.ts`'s own
|
* 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.
|
* pure/DB-touching split.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
@ -33,18 +49,30 @@ export interface TranslatedRecipeStep extends ParsedRecipeStep {
|
||||||
techStepIds: number[];
|
techStepIds: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A {@link ParsedRecipe} whose `steps` have been translated — everything else (name, ingredients, portions, …) passes through unchanged. */
|
/** 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 TranslatedRecipe extends Omit<ParsedRecipe, "steps"> {
|
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[];
|
steps: TranslatedRecipeStep[];
|
||||||
|
ingredients: TranslatedRecipeIngredient[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Declares each of `recipe`'s steps' technique sequence against
|
* Declares each of `recipe`'s steps' technique sequence against
|
||||||
* `techStepMappings`, leaving everything else about the recipe untouched.
|
* `techStepMappings`, leaving everything else about the recipe untouched —
|
||||||
* Pure — testable with a hand-built mapping list, no database involved (see
|
* including ingredients, which are only stubbed to `TranslatedRecipeIngredient`'s
|
||||||
* `translateRecipe` for the DB-backed loader). `techStepMappings` should
|
* shape here (`ingredientId`/`unitId: null`, `quantity`/`unit` untouched);
|
||||||
* already be filtered to the locale the caller cares about, same
|
* actually resolving them is {@link translateRecipeIngredients}'s job, kept
|
||||||
* requirement `matchTechSteps` itself has.
|
* 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(
|
export function translateRecipeSteps(
|
||||||
recipe: ParsedRecipe,
|
recipe: ParsedRecipe,
|
||||||
|
|
@ -52,6 +80,11 @@ export function translateRecipeSteps(
|
||||||
): TranslatedRecipe {
|
): TranslatedRecipe {
|
||||||
return {
|
return {
|
||||||
...recipe,
|
...recipe,
|
||||||
|
ingredients: recipe.ingredients.map((ingredient) => ({
|
||||||
|
...ingredient,
|
||||||
|
ingredientId: null,
|
||||||
|
unitId: null,
|
||||||
|
})),
|
||||||
steps: recipe.steps.map((step) => ({
|
steps: recipe.steps.map((step) => ({
|
||||||
...step,
|
...step,
|
||||||
techStepIds: matchTechSteps(step.description, techStepMappings),
|
techStepIds: matchTechSteps(step.description, techStepMappings),
|
||||||
|
|
@ -60,14 +93,39 @@ export function translateRecipeSteps(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convenience wrapper around {@link translateRecipeSteps} that loads
|
* Resolves each of `ingredients`' free-text `name`/`unit`/`quantity`
|
||||||
* `locale`'s mapping catalog itself — what a caller reaches for when
|
* against `ingredientCatalog`/`unitCatalog` (see `ingredient-matcher.ts`).
|
||||||
* translating a single recipe on its own (e.g. the eventual "import this
|
* Pure — testable with hand-built catalogs, no database involved (see
|
||||||
* one recipe" endpoint). A caller translating many recipes at once should
|
* `translateRecipe` for the DB-backed loader). `quantity` falls back to
|
||||||
* call `loadTechStepMappingRules` once and reuse it across
|
* `extractQuantity(rawText)` only when the source itself left it `null`;
|
||||||
* `translateRecipeSteps` calls instead, the same "don't requery per item"
|
* same for `unit` falling back to `extractQuantity`'s `remainder` before
|
||||||
* reasoning `recipe.service.ts`'s `createRecipe`/`updateRecipe` already
|
* being matched against `unitCatalog` — a source that already states a
|
||||||
* follow for manually-authored recipes.
|
* 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
|
* No user- or recipe-level language preference exists anywhere in the app
|
||||||
* yet (see `tech-step-matcher.ts`'s `loadTechStepMappingRules`) — callers
|
* 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
|
* mappings will currently get an empty `techStepIds` sequence on every
|
||||||
* step — matching-language mappings for that source's language don't exist
|
* step — matching-language mappings for that source's language don't exist
|
||||||
* yet, this stage doesn't invent them.
|
* 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(
|
export async function translateRecipe(
|
||||||
recipe: ParsedRecipe,
|
recipe: ParsedRecipe,
|
||||||
locale: string,
|
locale: string,
|
||||||
): Promise<TranslatedRecipe> {
|
): Promise<TranslatedRecipe> {
|
||||||
const techStepMappings = await loadTechStepMappingRules(locale);
|
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 { expect } from "chai";
|
||||||
import { prisma } from "../src/db/prisma.js";
|
import { prisma } from "../src/db/prisma.js";
|
||||||
import type { ParsedRecipe } from "../src/lib/recipe-source-adapter.js";
|
import type { IngredientMatchEntry, UnitMatchEntry } from "../src/lib/ingredient-matcher.js";
|
||||||
import { translateRecipe, translateRecipeSteps } from "../src/lib/recipe-translation.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 type { TechStepMappingRule } from "../src/lib/tech-step-matcher.js";
|
||||||
import { resetDatabase } from "../test-support/reset-db.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.picture).to.equal(recipe.picture);
|
||||||
expect(translated.portions).to.equal(recipe.portions);
|
expect(translated.portions).to.equal(recipe.portions);
|
||||||
expect(translated.sourceUrl).to.equal(recipe.sourceUrl);
|
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", () => {
|
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", () => {
|
describe("translateRecipe", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await resetDatabase();
|
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.
|
// A step lifted verbatim from a real TheMealDB recipe.
|
||||||
const recipe = buildParsedRecipe([
|
const recipe = buildParsedRecipe([
|
||||||
"Bring a large saucepan of salted water to the boil",
|
"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([[], []]);
|
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 () => {
|
it("resolves real TechStep ids from the seeded English catalog", async () => {
|
||||||
const recipe = buildParsedRecipe(["Faire mijoter à feu doux"]);
|
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");
|
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([[]]);
|
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");
|
const res = await request(app).get("/reference/units");
|
||||||
|
|
||||||
expect(res.status).to.equal(200);
|
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.map((u: { key: string }) => u.key)).to.include("gram");
|
||||||
expect(res.body[0]).to.have.keys(["id", "key", "type", "toBaseFactor"]);
|
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("liter")).to.include({ type: "VOLUME", toBaseFactor: 1000 });
|
||||||
expect(byKey("piece")).to.include({ type: "COUNT", toBaseFactor: 1 });
|
expect(byKey("piece")).to.include({ type: "COUNT", toBaseFactor: 1 });
|
||||||
expect(byKey("pinch")).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");
|
const res = await request(app).get("/reference/tech-steps");
|
||||||
expect(res.body).to.have.length(26);
|
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 () => {
|
it("only returns mappings for the requested locale", async () => {
|
||||||
const simmer = await prisma.techStep.findFirstOrThrow({ where: { key: "simmer" } });
|
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({
|
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
|
// 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");
|
const frRules = await loadTechStepMappingRules("fr");
|
||||||
expect(frRules).to.have.length(26);
|
expect(frRules).to.have.length(26);
|
||||||
expect(frRules.map((rule) => rule.expression)).to.not.include("\\bsimmer\\b");
|
expect(frRules.map((rule) => rule.expression)).to.not.include("\\bsimmer\\b");
|
||||||
|
|
||||||
const enRules = await loadTechStepMappingRules("en");
|
const deRules = await loadTechStepMappingRules("de");
|
||||||
expect(enRules).to.deep.equal([
|
expect(deRules).to.deep.equal([
|
||||||
{ techStepId: simmer.id, expression: "\\bsimmer\\b", weight: 15 },
|
{ techStepId: simmer.id, expression: "\\bsimmer\\b", weight: 15 },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -347,7 +347,10 @@
|
||||||
"clove": "gousse",
|
"clove": "gousse",
|
||||||
"bunch": "botte",
|
"bunch": "botte",
|
||||||
"sachet": "sachet",
|
"sachet": "sachet",
|
||||||
"sprig": "brin"
|
"sprig": "brin",
|
||||||
|
"cup": "tasse",
|
||||||
|
"ounce": "once",
|
||||||
|
"pound": "livre"
|
||||||
},
|
},
|
||||||
"diets": {
|
"diets": {
|
||||||
"omnivore": "Omnivore",
|
"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
|
// intentional (types, validation schemas, error codes), not an implementation
|
||||||
// detail specific to one side.
|
// detail specific to one side.
|
||||||
|
|
||||||
|
export * from "./data/catalog-labels-en.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