Deux volets de l'issue #38. **Bug de bouclage** — l'`IntersectionObserver` de `SourceItemTable` n'a que `[hasMore]` comme dépendance et ne se réabonne donc jamais pendant un scroll normal (`hasMore` reste `true`). Il figeait la toute première closure `onLoadMore`, laquelle rappelait indéfiniment le même curseur périmé — la liste s'allongeait en boucle avec les mêmes éléments et `nextCursor` n'atteignait jamais son vrai `null`. - `SourceItemTable` lit maintenant `onLoadMore` via une ref réassignée à chaque rendu : l'observer appelle toujours la closure fraîche, sans réabonnement inutile (intention d'origine du `biome-ignore` conservée, le commentaire n'étant plus nécessaire). - `RecipeSourcesPanel` dédoublonne le merge par `externalId` et traite « cette page n'a rien apporté de nouveau » comme une fin de liste même si la source renvoie encore un `nextCursor` — garde-fou contre une éventuelle heuristique de fin imparfaite côté adaptateur. **Couverture recherche vide** — `theMealDbAdapter.list()` tapait `/search.php?s=` même sans requête, ce que TheMealDB borne à ~25 recettes (`nextCursor` toujours `null`). Une requête vide déroule désormais la liste des catégories (`/list.php?c=list` puis `/filter.php?c=…`), une catégorie par pas de curseur, pour un vrai « tout parcourir ». Tests : nouveau scénario Cypress à 3 pages avec curseurs distincts réellement vérifiés (aucun curseur redemandé) ; nouveaux cas Mocha pour la marche par catégories (première page + curseur, suivi du curseur, arrêt sur la dernière catégorie, page vide au-delà, requête blanche = requête vide). Closes #38 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
237 lines
8.7 KiB
TypeScript
237 lines
8.7 KiB
TypeScript
import type {
|
|
ParsedRecipe,
|
|
RecipeSourceAdapter,
|
|
RecipeSourceListParams,
|
|
RecipeSourceListResult,
|
|
} from "../lib/recipe-sources/recipe-source-adapter.js";
|
|
import {
|
|
RecipeSourceFetchError,
|
|
RecipeSourceParseError,
|
|
} from "../lib/recipe-sources/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> {
|
|
try {
|
|
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 (await response.json()) as T;
|
|
} catch (err) {
|
|
// Rethrown as-is — this adapter's only caller (`sources.service.ts`)
|
|
// already handles/logs failures centrally; this method just isn't
|
|
// allowed a bare `await` per the repo's async/try-catch convention.
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
function detailUrl(idMeal: string): string {
|
|
return `https://www.themealdb.com/meal/${idMeal}`;
|
|
}
|
|
|
|
/** Category-list response shape (`/list.php?c=list`) — same `meals` envelope as everything else, but each row only carries `strCategory`. */
|
|
interface TheMealDbCategoryListResponse {
|
|
meals: { strCategory: string | null }[] | null;
|
|
}
|
|
|
|
/** Narrows to meals that actually carry a name — a titleless row isn't worth surfacing in a browse list. */
|
|
function hasName(meal: TheMealDbMeal): meal is TheMealDbMeal & { strMeal: string } {
|
|
return Boolean(meal.strMeal);
|
|
}
|
|
|
|
/** Maps one raw meal (from `/search.php` or `/filter.php`) into the generic list-item shape. */
|
|
function toListItem(meal: TheMealDbMeal & { strMeal: string }) {
|
|
return {
|
|
externalId: meal.idMeal,
|
|
title: meal.strMeal,
|
|
picture: meal.strMealThumb,
|
|
url: detailUrl(meal.idMeal),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Name search (`/search.php?s=`) — the only mode TheMealDB's free tier
|
|
* offers for a non-empty query. One response holds every match, so there's
|
|
* never a next page.
|
|
*/
|
|
async function searchByName(query: string): Promise<RecipeSourceListResult> {
|
|
try {
|
|
const data = await fetchTheMealDb<TheMealDbMealsResponse>(
|
|
`/search.php?s=${encodeURIComponent(query)}`,
|
|
);
|
|
return { items: (data.meals ?? []).filter(hasName).map(toListItem), nextCursor: null };
|
|
} catch (err) {
|
|
throw err; // see fetchTheMealDb()'s catch comment above
|
|
}
|
|
}
|
|
|
|
/**
|
|
* "Browse everything" (an empty/omitted query). TheMealDB's free tier has
|
|
* no catalog-wide listing endpoint — `/search.php?s=` only answers with a
|
|
* fixed ~25-meal sample — so this walks the category list instead:
|
|
* `/list.php?c=list` for the categories, then `/filter.php?c={category}`
|
|
* for one category at a time, exposing **one category per cursor step** so
|
|
* the infinite scroll keeps pulling real, distinct pages instead of
|
|
* stalling on that sample (issue #38). The category list is re-fetched on
|
|
* each step rather than cached in module state — it's a single small
|
|
* request, it keeps this adapter stateless like the others, and the walk
|
|
* is paced one step per scroll anyway.
|
|
*/
|
|
async function listByCategory(cursor: string | null | undefined): Promise<RecipeSourceListResult> {
|
|
try {
|
|
const parsed = Number.parseInt(cursor ?? "0", 10);
|
|
const startAt = Number.isNaN(parsed) || parsed < 0 ? 0 : parsed;
|
|
|
|
const categoryList = await fetchTheMealDb<TheMealDbCategoryListResponse>("/list.php?c=list");
|
|
const categories = (categoryList.meals ?? [])
|
|
.map((row) => row.strCategory)
|
|
.filter((name): name is string => Boolean(name));
|
|
|
|
const category = categories[startAt];
|
|
if (category === undefined) {
|
|
// Cursor has walked past the last category — the clean end of the list.
|
|
return { items: [], nextCursor: null };
|
|
}
|
|
|
|
const data = await fetchTheMealDb<TheMealDbMealsResponse>(
|
|
`/filter.php?c=${encodeURIComponent(category)}`,
|
|
);
|
|
const nextCursor = startAt + 1 < categories.length ? String(startAt + 1) : null;
|
|
return { items: (data.meals ?? []).filter(hasName).map(toListItem), nextCursor };
|
|
} catch (err) {
|
|
throw err; // see fetchTheMealDb()'s catch comment above
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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()` has two modes (see the helpers above): a non-empty `query` is a
|
|
* name search (`/search.php?s=`, single unpaginated response,
|
|
* `nextCursor: null`); an empty/omitted `query` — this contract's "browse
|
|
* everything" — walks the category list one category per cursor step,
|
|
* since the free tier has no real catalog-wide endpoint.
|
|
*/
|
|
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> {
|
|
try {
|
|
const query = (params.query ?? "").trim();
|
|
return query.length > 0 ? await searchByName(query) : await listByCategory(params.cursor);
|
|
} catch (err) {
|
|
throw err; // see fetchTheMealDb()'s catch comment above
|
|
}
|
|
},
|
|
|
|
async fetchDetail(externalId: string): Promise<TheMealDbMeal> {
|
|
try {
|
|
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;
|
|
} catch (err) {
|
|
throw err; // see fetchTheMealDb()'s catch comment above
|
|
}
|
|
},
|
|
|
|
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.
|
|
// TheMealDB frequently numbers each step on its own line ahead of the
|
|
// paragraph that follows (e.g. "…melted.\n\n2\n\nPreheat oven…") rather
|
|
// than inline ("1. Preheat oven…") — the blank-line split above turns
|
|
// that lone number into its own "line", which would otherwise become a
|
|
// bogus step containing nothing but a digit. Drop those rather than
|
|
// keep them as steps in their own right (see issue #52).
|
|
const steps = (meal.strInstructions ?? "")
|
|
.split(/\r?\n+/)
|
|
.map((line) => line.trim())
|
|
.filter((line) => line.length > 0)
|
|
.filter((line) => !/^\d+\.?$/.test(line))
|
|
.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,
|
|
};
|
|
},
|
|
};
|