batchCooking/apps/api/src/lib/recipe-source-adapter.ts
Nicolas 44ef5e071f feat(recipes): parcourir et prévisualiser les sources externes (étape 1/4)
Première étape du chantier "onglet Sources" (parcourir toutes les
recettes externes des sources activées par le foyer, importées ou non,
et déclencher leur import à l'ajout au planning) — celle-ci pose les
endpoints backend de lecture seule, rien n'est encore sauvegardé.

- RecipeSourceAdapter gagne `locale` (theMealDbAdapter: "en") — nécessaire
  pour que translateRecipe/matchTechStepSpans sachent contre quel jeu de
  TechStepMapping/labels d'ingrédients traduire une source donnée.
- findImportedExternalIds (recipe-source-sync.ts) devient
  findImportedRecipeIds : renvoie une Map<externalId, recipeId> au lieu
  d'un simple Set — son premier vrai appelant (le parcours) a besoin de
  l'id réel pour naviguer directement vers la recette déjà importée, pas
  seulement savoir qu'elle l'est.
- Nouveau module apps/api/src/modules/sources/ :
  - GET /sources/:sourceKey/browse — appelle list() de l'adaptateur,
    flague chaque item alreadyImported/recipeId. Restreint aux sources
    activées par le foyer courant (HouseSource) ; 404 SOURCE_NOT_FOUND
    sinon, même si la source existe (même posture que la visibilité des
    recettes : "pas trouvée" plutôt que "pas autorisée").
  - GET /sources/:sourceKey/preview/:externalId — fetchDetail + parse +
    résolution complète (translateRecipeIngredients, matchTechStepSpans
    avec spans réels) contre la locale de la source, sans rien
    sauvegarder. Ingrédients non résolus → null plutôt qu'une erreur.
- Nouveaux types partagés (packages/shared/src/types/sources.ts) :
  BrowsableSourceItemView, RecipeImportDraftView (+ Draft*View).

Vérifié en conditions réelles contre TheMealDB (recette "Chicken Handi") :
ingrédients résolus avec la bonne quantité/unité (1.2 kg de poulet, 8
gousses d'ail...), non-résolus corrects (huile végétale, piment vert),
et chaque étape avec ses techniques détectées et leurs spans exacts
(cook/fry/plate/setAside sur la même phrase, etc.).

Tests : 276 passing (+8 nouveaux, sources.test.ts). Étape suivante (2/4) :
l'UI de parcours (onglet Sources) — voir le plan de session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 15:51:16 +02:00

188 lines
8.7 KiB
TypeScript

/**
* The generic contract every recipe source (a specific website, an API, …)
* implements — groundwork for the "Import d'une recette" pipeline described
* in specs/batch-cooking-architecture.md (import depuis source → traduction
* en étapes → sauvegarde). This file only defines the shapes; no concrete
* source exists yet (see `recipe-source-registry.ts` for where one would be
* registered) and nothing here talks to the database or an HTTP route —
* that wiring (persisting an imported recipe, resolving `sourceId`) is
* deliberately out of scope until a real source needs it.
*
* The flow a caller drives against one adapter:
* 1. `list()` — browse what's available from the source (paginated,
* optionally filtered by `query`), like flipping through a catalog.
* 2. {@link markAlreadyImported} — flag which of those items we've
* already imported, so browsing a source doesn't dangle recipes the
* user has already brought in as if they were new. A separate, pure
* step rather than something `list()` itself does: an adapter only
* knows its source, never our database — same reasoning as
* `tech-step-matcher.ts`'s split between pure `matchTechStep` and its
* DB-touching `loadTechStepMappingRules`. Whichever future layer
* queries "which externalIds from this source do we already have"
* (not yet decided — it needs a place to persist that link,
* see {@link RecipeSourceListItem.externalId}) calls this to annotate
* the page before returning it.
* 3. `fetchDetail(externalId)` — once the user picks one item from that
* list, fetch its full raw content.
* 4. `parse(raw)` — turn that raw content into a {@link ParsedRecipe},
* pure and synchronous so it's unit-testable without any network
* access (same split as step 2 above).
*/
/** Search/pagination input for {@link RecipeSourceAdapter.list}. */
export interface RecipeSourceListParams {
/** Free-text search, if the source supports it. Omitted means "browse everything". */
query?: string;
/**
* Opaque continuation token from a previous {@link RecipeSourceListResult.nextCursor}
* — omitted (or `null`) means "start from the first page". Deliberately
* opaque (not a page number) so an adapter can back it with whatever its
* source actually supports (page number, offset, an API-provided token).
*/
cursor?: string | null;
}
/** One entry in a {@link RecipeSourceAdapter.list} result — enough to show in a browsing UI and to fetch the full recipe once selected. */
export interface RecipeSourceListItem {
/**
* Source-specific identifier, opaque to callers — passed back verbatim
* to {@link RecipeSourceAdapter.fetchDetail}, and the key
* {@link markAlreadyImported} matches against to tell an already-imported
* item apart from a new one.
*/
externalId: string;
title: string;
picture: string | null;
/** Canonical URL of the recipe on the source, kept for attribution even before it's imported. */
url: string;
}
export interface RecipeSourceListResult {
items: RecipeSourceListItem[];
/** Pass back as `cursor` to fetch the next page — `null` means this was the last page. */
nextCursor: string | null;
}
/** A browsed {@link RecipeSourceListItem}, after {@link markAlreadyImported} has flagged whether we already imported it. What a browsing UI actually renders — e.g. to grey it out or offer "already added" instead of "import". */
export interface BrowsableRecipeItem extends RecipeSourceListItem {
alreadyImported: boolean;
}
/**
* Splits a page of {@link RecipeSourceListItem}s into already-imported vs.
* new, purely by checking each item's `externalId` against
* `importedExternalIds` — no I/O here, the caller is responsible for
* gathering that set (from wherever we end up persisting the link between
* an imported `Recipe` and the source item it came from) before calling
* this. Kept as a tiny, dedicated, easily-testable step rather than folded
* into `list()` itself, so an adapter never needs to know our database
* exists.
*/
export function markAlreadyImported(
items: RecipeSourceListItem[],
importedExternalIds: ReadonlySet<string>,
): BrowsableRecipeItem[] {
return items.map((item) => ({
...item,
alreadyImported: importedExternalIds.has(item.externalId),
}));
}
/**
* One ingredient line as lifted from a source, before it's resolved against
* our own `Ingredient`/`Unit` reference catalogs (that resolution —
* matching free text to a `key`, the way `tech-step-matcher.ts` matches
* step text to a `TechStep` — is a separate, not-yet-built concern; this
* type only carries what a source's raw text actually says). `rawText` is
* kept alongside the (best-effort) parsed fields so a failed/partial parse
* is still traceable back to what the source originally wrote.
*/
export interface ParsedRecipeIngredient {
rawText: string;
quantity: number | null;
/** Free-text unit exactly as written by the source (e.g. `"cuillère à soupe"`, `"g"`) — not yet resolved to a `Unit.key`. */
unit: string | null;
/** Free-text ingredient name exactly as written by the source — not yet resolved to an `Ingredient.key`. */
name: string;
}
export interface ParsedRecipeStep {
description: string;
picture: string | null;
}
/**
* The normalized shape every adapter's {@link RecipeSourceAdapter.parse}
* produces, regardless of the source. Intentionally *not*
* `CreateRecipeInput` (packages/shared/src/schemas/recipe.ts): ingredients
* are still free text (no `ingredientId`/`unitId` — that catalog-matching
* step doesn't exist yet), and there's no `dietIds`/`visibility` since a
* source can't know those. Turning a `ParsedRecipe` into a saved `Recipe`
* is future work for whichever module ends up driving this pipeline.
*/
export interface ParsedRecipe {
name: string;
description: string | null;
picture: string | null;
/** `null` when the source doesn't state a serving size. */
portions: number | null;
/** Canonical URL of the recipe on the source — the eventual `Source`/`Recipe.sourceId` link (schema.prisma) is populated from this once the import pipeline saves the recipe. */
sourceUrl: string;
ingredients: ParsedRecipeIngredient[];
steps: ParsedRecipeStep[];
}
/**
* A single recipe source — a specific website or API, plus the two pieces
* of source-specific logic needed to pull a recipe out of it. `TRawDetail`
* is whatever shape `fetchDetail` naturally returns for this source (an
* HTML string, a parsed JSON body, …); `parse` is the only thing that needs
* to understand it.
*
* @example
* ```ts
* const myAdapter: RecipeSourceAdapter<{ html: string }> = {
* key: "someRecipeSite",
* name: "Some Recipe Site",
* official: false,
* iconUrl: "https://somerecipesite.example/favicon.svg",
* locale: "fr",
* async list(params) { ... },
* async fetchDetail(externalId) { ... },
* parse(raw) { ... },
* };
* registerRecipeSource(myAdapter);
* ```
*/
export interface RecipeSourceAdapter<TRawDetail = unknown> {
/** Stable identifier used to look this adapter up in the registry — same "English camelCase uid" convention as `Diet.key`/`Unit.key`/`TechStep.key`. */
key: string;
/** Human-readable name, for display in a source picker. */
name: string;
/**
* Whether this source is an official API (the site/publisher itself
* provides structured recipe data) versus unofficial web scraping (we
* parse HTML the site never committed to a stable shape for) — surfaced
* to households (`Source.official`, synced via `syncRecipeSources`) so
* they can tell the two apart when deciding which sources to enable
* (see `HouseSource`, schema.prisma). No default on purpose: every
* adapter author has to consciously pick one rather than silently
* inheriting a guess.
*/
official: boolean;
/** URL of the source's own logo/favicon, for `SourceSelect` (apps/web) to display next to its name — `null` if the source has none worth showing. Synced to `Source.iconUrl` the same way as `name`/`official`. */
iconUrl: string | null;
/**
* Language of the text this source produces (`ParsedRecipe.description`/
* `steps[].description`/`ingredients[].name`) — e.g. `"en"` for
* TheMealDB. Not a user preference: the language the source's own
* content is actually written in, regardless of who's browsing it.
* Determines which `TechStepMapping`/ingredient-label locale
* `translateRecipe` (`recipe-translation.ts`) resolves this source's
* recipes against when previewing/importing one.
*/
locale: string;
list(params: RecipeSourceListParams): Promise<RecipeSourceListResult>;
fetchDetail(externalId: string): Promise<TRawDetail>;
parse(raw: TRawDetail): ParsedRecipe;
}