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>
155 lines
5.2 KiB
TypeScript
155 lines
5.2 KiB
TypeScript
import type {
|
|
ParsedRecipe,
|
|
RecipeSourceAdapter,
|
|
RecipeSourceListParams,
|
|
RecipeSourceListResult,
|
|
} from "../lib/recipe-source-adapter.js";
|
|
import { RecipeSourceFetchError, RecipeSourceParseError } from "../lib/recipe-source-errors.js";
|
|
|
|
const SOURCE_KEY = "theMealDb";
|
|
|
|
// TheMealDB documents "1" as a shared, public test key, free to use for
|
|
// development (https://www.themealdb.com/api.php) — a deployment serving
|
|
// real traffic is expected to use a supporter-tier key instead (paid, via
|
|
// Patreon). Configurable here via an env var without touching anything
|
|
// else in this adapter.
|
|
const API_KEY = process.env.THE_MEAL_DB_API_KEY ?? "1";
|
|
const API_BASE = `https://www.themealdb.com/api/json/v1/${API_KEY}`;
|
|
|
|
/**
|
|
* TheMealDB's flat meal shape — ingredients/measures are 20 numbered
|
|
* field pairs (`strIngredient1`/`strMeasure1` … `strIngredient20`/
|
|
* `strMeasure20`), not an array, hence the string index signature rather
|
|
* than 20 explicit optional properties.
|
|
*/
|
|
export interface TheMealDbMeal {
|
|
idMeal: string;
|
|
strMeal: string | null;
|
|
strMealThumb: string | null;
|
|
strInstructions: string | null;
|
|
[key: string]: string | null | undefined;
|
|
}
|
|
|
|
interface TheMealDbMealsResponse {
|
|
meals: TheMealDbMeal[] | null;
|
|
}
|
|
|
|
async function fetchTheMealDb<T>(path: string): Promise<T> {
|
|
let response: Response;
|
|
try {
|
|
response = await fetch(`${API_BASE}${path}`);
|
|
} catch (cause) {
|
|
throw new RecipeSourceFetchError(SOURCE_KEY, `Network error calling TheMealDB (${path})`, {
|
|
cause,
|
|
});
|
|
}
|
|
if (!response.ok) {
|
|
throw new RecipeSourceFetchError(
|
|
SOURCE_KEY,
|
|
`TheMealDB responded ${response.status} (${path})`,
|
|
);
|
|
}
|
|
return response.json() as Promise<T>;
|
|
}
|
|
|
|
function detailUrl(idMeal: string): string {
|
|
return `https://www.themealdb.com/meal/${idMeal}`;
|
|
}
|
|
|
|
/**
|
|
* TheMealDB (themealdb.com) — a free, public recipe API (no scraping: the
|
|
* publisher's own structured JSON, hence `official: true`). The first real
|
|
* `RecipeSourceAdapter` implementation, proving the generic contract
|
|
* (recipe-source-adapter.ts) end to end against a live source.
|
|
*
|
|
* `list()` is search-only — TheMealDB has no dedicated "browse everything"
|
|
* endpoint on its free tier. An omitted `query` searches for an empty
|
|
* string, which TheMealDB happens to answer with a small default sample
|
|
* (~25 meals) rather than nothing — close enough to this contract's
|
|
* "omitted `query` means browse everything" convention
|
|
* (`RecipeSourceListParams.query`) to lean on as-is, though it's a fixed
|
|
* sample, not the whole catalog. Search isn't paginated either — one
|
|
* response holds every match, so `nextCursor` is always `null`.
|
|
*/
|
|
export const theMealDbAdapter: RecipeSourceAdapter<TheMealDbMeal> = {
|
|
key: SOURCE_KEY,
|
|
name: "TheMealDB",
|
|
official: true,
|
|
iconUrl: "https://www.themealdb.com/images/logo.svg",
|
|
// TheMealDB's content (names, ingredients, instructions) is English —
|
|
// determines which locale translateRecipe (recipe-translation.ts)
|
|
// resolves this source's recipes against when previewing/importing one.
|
|
locale: "en",
|
|
|
|
async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
|
|
const query = params.query ?? "";
|
|
const data = await fetchTheMealDb<TheMealDbMealsResponse>(
|
|
`/search.php?s=${encodeURIComponent(query)}`,
|
|
);
|
|
const meals = data.meals ?? [];
|
|
return {
|
|
items: meals
|
|
.filter((meal): meal is TheMealDbMeal & { strMeal: string } => Boolean(meal.strMeal))
|
|
.map((meal) => ({
|
|
externalId: meal.idMeal,
|
|
title: meal.strMeal,
|
|
picture: meal.strMealThumb,
|
|
url: detailUrl(meal.idMeal),
|
|
})),
|
|
nextCursor: null,
|
|
};
|
|
},
|
|
|
|
async fetchDetail(externalId: string): Promise<TheMealDbMeal> {
|
|
const data = await fetchTheMealDb<TheMealDbMealsResponse>(`/lookup.php?i=${externalId}`);
|
|
const meal = data.meals?.[0];
|
|
if (!meal) {
|
|
throw new RecipeSourceFetchError(SOURCE_KEY, `No meal found for id "${externalId}"`);
|
|
}
|
|
return meal;
|
|
},
|
|
|
|
parse(meal: TheMealDbMeal): ParsedRecipe {
|
|
if (!meal.strMeal) {
|
|
throw new RecipeSourceParseError(SOURCE_KEY, "Meal is missing its name (strMeal)");
|
|
}
|
|
|
|
const ingredients = [];
|
|
for (let i = 1; i <= 20; i++) {
|
|
const name = meal[`strIngredient${i}`]?.trim();
|
|
if (!name) continue;
|
|
const measure = meal[`strMeasure${i}`]?.trim();
|
|
ingredients.push({
|
|
rawText: measure ? `${measure} ${name}` : name,
|
|
quantity: null,
|
|
unit: null,
|
|
name,
|
|
});
|
|
}
|
|
|
|
// Free-text instructions, usually one step per line — splitting on
|
|
// blank/newlines is the closest this source gets to discrete steps.
|
|
const steps = (meal.strInstructions ?? "")
|
|
.split(/\r?\n+/)
|
|
.map((line) => line.trim())
|
|
.filter((line) => line.length > 0)
|
|
.map((description) => ({ description, picture: null }));
|
|
if (steps.length === 0) {
|
|
throw new RecipeSourceParseError(
|
|
SOURCE_KEY,
|
|
`Meal "${meal.strMeal}" has no usable instructions`,
|
|
);
|
|
}
|
|
|
|
return {
|
|
name: meal.strMeal,
|
|
description: null,
|
|
picture: meal.strMealThumb,
|
|
// TheMealDB's free API doesn't state a serving size.
|
|
portions: null,
|
|
sourceUrl: detailUrl(meal.idMeal),
|
|
ingredients,
|
|
steps,
|
|
};
|
|
},
|
|
};
|