Suite au retour utilisateur sur #53 (follow-up) : au lieu de bloquer l'import et de demander à l'utilisateur de retirer une ligne en double à la main, deux lignes source qui résolvent vers le même ingrédient catalogue sont désormais fusionnées automatiquement, quantité concaténée (sommée), avant même que l'écran de revue ne s'affiche. - mergeDuplicateIngredients (recipe-translation.ts) : même unité des deux côtés -> somme directe. Unité différente mais même UnitType (MASS/VOLUME) -> conversion via toBaseFactor avant de sommer, exprimée dans l'unité de la première ligne. UnitType différent, ou COUNT des deux côtés (une "pincée" n'est pas une fraction fixe d'une "gousse", cf. le commentaire de UnitView) -> jamais fusionnées, laissées en double (createRecipeSchema/RecipeImportForm continuent de les signaler, filet de sécurité déjà en place). Les lignes non résolues (ingredientId: null) ne sont jamais fusionnées entre elles. - rawText concaténé ("100g Sugar + 45g Sugar") pour la traçabilité. - Branché dans previewSourceItem (sources.service.ts), juste après translateRecipeIngredients — c'est le seul endroit où des doublons peuvent apparaître (la création manuelle ne peut pas en produire, IngredientPicker exclut déjà les ingrédients déjà sélectionnés). Vérifié via l'API en local (import réel de "Flan" depuis TheMealDB) : "100g Sugar"/"45g Sugar" -> une seule ligne Sucre, 145g. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
290 lines
13 KiB
TypeScript
290 lines
13 KiB
TypeScript
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<ParsedRecipe, "steps" | "ingredients"> {
|
|
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<number, UnitConversionEntry>,
|
|
): 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<number, number>();
|
|
|
|
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<TranslatedRecipe> {
|
|
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),
|
|
};
|
|
}
|