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>
188 lines
8.7 KiB
TypeScript
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;
|
|
}
|