import type { UnitType } from "@batch-cooking/shared"; 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), })), }; } /** * English label {@link matchUnit} is fed when a quantity was found but no * unit word was — see the `unitId` fallback below. `"piece"` (`UNIT_LABELS_EN`, * `packages/shared`) is the catalog's generic "counted, no further unit" * entry (French "unité"). */ const FALLBACK_COUNT_UNIT_LABEL = "piece"; /** * 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`. * * When a quantity was found but nothing in the remaining text matched a * unit (e.g. `"4 Egg Yolks"` — quantity `4`, remainder `"Egg Yolks"`, no * unit word anywhere in it), `unitId` falls back to the catalog's generic * `piece` unit rather than staying `null`: a bare count with no explicit * unit word is overwhelmingly "N of them" (eggs, onions, cloves not * spelled out as "clove") in practice, not a genuinely missing unit — see * issue #53, where this previously left the import review form's submit * button disabled with no indication why on almost any recipe with a * whole-item ingredient. No fallback when `quantity` itself is `null` * (e.g. `"To taste"`) — there's nothing to count, so nothing to default. */ 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) ?? (quantity !== null ? matchUnit(FALLBACK_COUNT_UNIT_LABEL, unitCatalog) : null); return { ...ingredient, quantity, ingredientId, unitId }; }); } /** What {@link mergeDuplicateIngredients} needs to know about a `Unit` to combine two of them — the `type`/`toBaseFactor` slice of `UnitView` (`packages/shared`). */ export interface UnitConversionEntry { id: number; type: UnitType; toBaseFactor: number; } /** * Combines `a`/`b` — two lines already confirmed to resolve to the same * ingredient — into one, summing their quantities, or returns `null` when * that can't be done safely. `null` (quantity or unit missing on either * side, unit not found in `unitById`, mismatched `UnitType`, or either side * a `COUNT` unit) means "don't merge", not "error" — see * {@link mergeDuplicateIngredients}. * * Same unit on both sides sums directly. Different units of the same * measurable *type* (`MASS`/`VOLUME`) convert `b`'s quantity into `a`'s * unit via `toBaseFactor` first — the groundwork that field's own doc * comment (`UnitView`, `packages/shared`) already anticipated ("a future * conversion feature ... summing '500g' + '0.5kg'"). `COUNT` units are * never converted against each other even when `toBaseFactor` matches — a * "pincée" isn't a fixed fraction of a "gousse" (same doc comment) — so two * different `COUNT` units for the same ingredient are left unmerged. * Rounded to 2 decimal places (`RecipeIngredient.quantity` is * `Decimal(10, 2)`, schema.prisma) to avoid floating-point noise from the * conversion. */ function combineIngredientLines( a: TranslatedRecipeIngredient, b: TranslatedRecipeIngredient, unitById: Map, ): TranslatedRecipeIngredient | null { if (a.quantity === null || b.quantity === null || a.unitId === null || b.unitId === null) { return null; } if (a.unitId === b.unitId) { return { ...a, quantity: a.quantity + b.quantity, rawText: `${a.rawText} + ${b.rawText}` }; } const unitA = unitById.get(a.unitId); const unitB = unitById.get(b.unitId); if (!unitA || !unitB) return null; if (unitA.type !== unitB.type || unitA.type === "COUNT") return null; const combinedInBaseUnit = a.quantity * unitA.toBaseFactor + b.quantity * unitB.toBaseFactor; const quantity = Math.round((combinedInBaseUnit / unitA.toBaseFactor) * 100) / 100; return { ...a, quantity, rawText: `${a.rawText} + ${b.rawText}` }; } /** * Folds `ingredients` down to one line per resolved `ingredientId`, * concatenating (summing the quantity of) every duplicate into the first * line it matches — see issue #53's follow-up: two raw source lines (e.g. * TheMealDB's "Egg Yolks"/"Eggs", or "100g Sugar" used in two different * steps) can independently resolve to the same catalog `Ingredient`, and * `RecipeIngredient`'s primary key (`recipeId`, `ingredientId`) only * allows one row per ingredient per recipe — the review form used to * either crash on submit (before `createRecipeSchema` rejected it) or * require the person to manually delete every extra line by hand. * * Unresolved lines (`ingredientId: null`) are never merged with one * another or with anything else — nothing reliable to key them on. Two * lines that resolve to the same ingredient but can't be combined safely * (see {@link combineIngredientLines} — mismatched quantity/unit, or * genuinely incompatible units) are left as separate, still-duplicate * lines: `createRecipeSchema` still rejects the result, and * `RecipeImportForm` still highlights them, same safety net as before this * merge step existed — merging never *invents* a number it isn't confident * in. * * Pure — testable with a hand-built `unitCatalog`, no database involved. * Order-preserving: a merged line keeps its first occurrence's position. */ export function mergeDuplicateIngredients( ingredients: TranslatedRecipeIngredient[], unitCatalog: UnitConversionEntry[], ): TranslatedRecipeIngredient[] { const unitById = new Map(unitCatalog.map((unit) => [unit.id, unit])); const merged: TranslatedRecipeIngredient[] = []; const mergedIndexByIngredientId = new Map(); for (const line of ingredients) { const existingIndex = line.ingredientId !== null ? mergedIndexByIngredientId.get(line.ingredientId) : undefined; const existingLine = existingIndex !== undefined ? merged[existingIndex] : undefined; if (existingIndex === undefined || existingLine === undefined) { if (line.ingredientId !== null) { mergedIndexByIngredientId.set(line.ingredientId, merged.length); } merged.push(line); continue; } const combined = combineIngredientLines(existingLine, line, unitById); if (combined === null) { merged.push(line); } else { merged[existingIndex] = combined; } } return merged; } /** * 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), }; }