/** * 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 `splitIntoClauses` and * its DB/model-touching `TechStepClassifierService`. 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, ): 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 { /** 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 trained-classifier/ingredient-label locale * `translateRecipe` (`recipe-translation.ts`) resolves this source's * recipes against when previewing/importing one. */ locale: string; list(params: RecipeSourceListParams): Promise; fetchDetail(externalId: string): Promise; parse(raw: TRawDetail): ParsedRecipe; }