From 89722ba79081fd0d91084e7b49e94c2eaec24541 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Thu, 20 Aug 2026 13:10:00 +0200 Subject: [PATCH] =?UTF-8?q?feat(recipes):=20matching=20anglais=20pour=20le?= =?UTF-8?q?s=20tech=20steps=20et=20les=20ingr=C3=A9dients?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/api/src/db/reference-seed-data.ts | 136 +++- apps/api/src/lib/ingredient-matcher.ts | 191 ++++++ apps/api/src/lib/recipe-translation.ts | 131 +++- apps/api/test/ingredient-matcher.test.ts | 218 ++++++ apps/api/test/recipe-translation.test.ts | 195 +++++- apps/api/test/reference.test.ts | 8 +- apps/api/test/tech-step-matcher.test.ts | 11 +- apps/web/src/locales/fr/translation.json | 5 +- packages/shared/src/data/catalog-labels-en.ts | 632 ++++++++++++++++++ packages/shared/src/index.ts | 1 + 10 files changed, 1467 insertions(+), 61 deletions(-) create mode 100644 apps/api/src/lib/ingredient-matcher.ts create mode 100644 apps/api/test/ingredient-matcher.test.ts create mode 100644 packages/shared/src/data/catalog-labels-en.ts diff --git a/apps/api/src/db/reference-seed-data.ts b/apps/api/src/db/reference-seed-data.ts index 11bbaa5..e83f892 100644 --- a/apps/api/src/db/reference-seed-data.ts +++ b/apps/api/src/db/reference-seed-data.ts @@ -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 }, + ], }, ]; diff --git a/apps/api/src/lib/ingredient-matcher.ts b/apps/api/src/lib/ingredient-matcher.ts new file mode 100644 index 0000000..467f13e --- /dev/null +++ b/apps/api/src/lib/ingredient-matcher.ts @@ -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 { + 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 { + 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; +} diff --git a/apps/api/src/lib/recipe-translation.ts b/apps/api/src/lib/recipe-translation.ts index 6eedfac..b93c493 100644 --- a/apps/api/src/lib/recipe-translation.ts +++ b/apps/api/src/lib/recipe-translation.ts @@ -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 { +/** 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 { 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 { 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), + }; } diff --git a/apps/api/test/ingredient-matcher.test.ts b/apps/api/test/ingredient-matcher.test.ts new file mode 100644 index 0000000..f50858e --- /dev/null +++ b/apps/api/test/ingredient-matcher.test.ts @@ -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"]); + }); + }); +}); diff --git a/apps/api/test/recipe-translation.test.ts b/apps/api/test/recipe-translation.test.ts index a984b2f..636fad8 100644 --- a/apps/api/test/recipe-translation.test.ts +++ b/apps/api/test/recipe-translation.test.ts @@ -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 & { 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, + }, + ]); + }); }); }); diff --git a/apps/api/test/reference.test.ts b/apps/api/test/reference.test.ts index 245f508..128c949 100644 --- a/apps/api/test/reference.test.ts +++ b/apps/api/test/reference.test.ts @@ -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); }); }); diff --git a/apps/api/test/tech-step-matcher.test.ts b/apps/api/test/tech-step-matcher.test.ts index 57ddcd0..acd309a 100644 --- a/apps/api/test/tech-step-matcher.test.ts +++ b/apps/api/test/tech-step-matcher.test.ts @@ -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 }, ]); }); diff --git a/apps/web/src/locales/fr/translation.json b/apps/web/src/locales/fr/translation.json index 47b8a9a..bda4cfe 100644 --- a/apps/web/src/locales/fr/translation.json +++ b/apps/web/src/locales/fr/translation.json @@ -347,7 +347,10 @@ "clove": "gousse", "bunch": "botte", "sachet": "sachet", - "sprig": "brin" + "sprig": "brin", + "cup": "tasse", + "ounce": "once", + "pound": "livre" }, "diets": { "omnivore": "Omnivore", diff --git a/packages/shared/src/data/catalog-labels-en.ts b/packages/shared/src/data/catalog-labels-en.ts new file mode 100644 index 0000000..417d813 --- /dev/null +++ b/packages/shared/src/data/catalog-labels-en.ts @@ -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.`), 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 = { + // 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 = { + 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"], +}; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 40b7334..1f6beed 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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";