batchCooking/apps/api/src/lib/recipe-source-adapter.ts
Nicolas dd747af70f feat(recipes): distingue les recettes déjà importées lors du browse
Ajoute markAlreadyImported(items, importedExternalIds) et le type
BrowsableRecipeItem à recipe-source-adapter.ts : quand on parcourt le
catalogue d'une source (list()), on peut désormais annoter chaque
item pour savoir s'il correspond à une recette déjà intégrée dans
notre base ou non.

Reste une fonction pure, volontairement séparée de list() : un
adaptateur ne connaît que sa source, jamais notre base — même
séparation I/O/pur que tech-step-matcher.ts. La constitution du set
d'externalId déjà importés (où/comment on persiste ce lien) est
laissée à une future couche, pas encore décidée.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 07:24:44 +02:00

162 lines
7.3 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",
* 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;
list(params: RecipeSourceListParams): Promise<RecipeSourceListResult>;
fetchDetail(externalId: string): Promise<TRawDetail>;
parse(raw: TRawDetail): ParsedRecipe;
}