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, matchTechSteps, } from "./tech-step-matcher.js"; /** * The "Traduction en étapes" stage of the import pipeline described in * specs/batch-cooking-architecture.md (Import depuis source → **Traduction * en étapes** → Sauvegarde) — takes a source-agnostic {@link ParsedRecipe} * (recipe-source-adapter.ts's `parse()` output) and declares each step's * technique sequence, the same `techStepIds: number[]` shape * `Step.techSteps`/`StepTechStep` (schema.prisma) will eventually persist. * * 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`/`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. */ /** A {@link ParsedRecipeStep}, after tech-step detection — declares its technique sequence alongside the description/picture it already had. */ export interface TranslatedRecipeStep extends ParsedRecipeStep { /** Ordered sequence of detected `TechStep` ids (see `matchTechSteps`) — empty if this step doesn't mention any known technique. */ techStepIds: number[]; } /** 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 — * 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, techStepMappings: TechStepMappingRule[], ): TranslatedRecipe { return { ...recipe, ingredients: recipe.ingredients.map((ingredient) => ({ ...ingredient, ingredientId: null, unitId: null, })), steps: recipe.steps.map((step) => ({ ...step, techStepIds: matchTechSteps(step.description, techStepMappings), })), }; } /** * 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 * pass a locale explicitly rather than this module guessing one. Note that * an English-language source (e.g. TheMealDB) translated against `"fr"` * 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); 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), }; }