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>
This commit is contained in:
parent
4a1e46b283
commit
dd747af70f
2 changed files with 100 additions and 5 deletions
|
|
@ -11,12 +11,22 @@
|
|||
* 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. `fetchDetail(externalId)` — once the user picks one item from that
|
||||
* 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.
|
||||
* 3. `parse(raw)` — turn that raw content into a {@link ParsedRecipe},
|
||||
* 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 `tech-step-matcher.ts`'s pure `matchTechStep`
|
||||
* vs its DB-touching `loadTechStepMappingRules`).
|
||||
* access (same split as step 2 above).
|
||||
*/
|
||||
|
||||
/** Search/pagination input for {@link RecipeSourceAdapter.list}. */
|
||||
|
|
@ -34,7 +44,12 @@ export interface RecipeSourceListParams {
|
|||
|
||||
/** 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}. */
|
||||
/**
|
||||
* 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;
|
||||
|
|
@ -48,6 +63,31 @@ export interface RecipeSourceListResult {
|
|||
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 —
|
||||
|
|
|
|||
|
|
@ -2,9 +2,11 @@ import { expect } from "chai";
|
|||
import type {
|
||||
ParsedRecipe,
|
||||
RecipeSourceAdapter,
|
||||
RecipeSourceListItem,
|
||||
RecipeSourceListParams,
|
||||
RecipeSourceListResult,
|
||||
} from "../src/lib/recipe-source-adapter.js";
|
||||
import { markAlreadyImported } from "../src/lib/recipe-source-adapter.js";
|
||||
import {
|
||||
RecipeSourceError,
|
||||
RecipeSourceFetchError,
|
||||
|
|
@ -190,6 +192,59 @@ describe("recipe-source", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("markAlreadyImported", () => {
|
||||
const items: RecipeSourceListItem[] = [
|
||||
{
|
||||
externalId: "1",
|
||||
title: "Tarte aux pommes",
|
||||
picture: null,
|
||||
url: "https://fake.test/recipes/1",
|
||||
},
|
||||
{
|
||||
externalId: "2",
|
||||
title: "Soupe de légumes",
|
||||
picture: null,
|
||||
url: "https://fake.test/recipes/2",
|
||||
},
|
||||
{ externalId: "3", title: "Salade César", picture: null, url: "https://fake.test/recipes/3" },
|
||||
];
|
||||
|
||||
it("flags items whose externalId is in the imported set, leaves the rest false", () => {
|
||||
const result = markAlreadyImported(items, new Set(["1", "3"]));
|
||||
|
||||
expect(
|
||||
result.map((item) => ({
|
||||
externalId: item.externalId,
|
||||
alreadyImported: item.alreadyImported,
|
||||
})),
|
||||
).to.deep.equal([
|
||||
{ externalId: "1", alreadyImported: true },
|
||||
{ externalId: "2", alreadyImported: false },
|
||||
{ externalId: "3", alreadyImported: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("flags nothing when the imported set is empty", () => {
|
||||
const result = markAlreadyImported(items, new Set());
|
||||
expect(result.every((item) => item.alreadyImported === false)).to.be.true;
|
||||
});
|
||||
|
||||
it("returns an empty list unchanged", () => {
|
||||
expect(markAlreadyImported([], new Set(["1"]))).to.deep.equal([]);
|
||||
});
|
||||
|
||||
it("preserves every field from the original item alongside the new flag", () => {
|
||||
const [first] = markAlreadyImported([items[0]], new Set(["1"]));
|
||||
expect(first).to.deep.equal({ ...items[0], alreadyImported: true });
|
||||
});
|
||||
|
||||
it("doesn't mutate the input items", () => {
|
||||
const snapshot = structuredClone(items);
|
||||
markAlreadyImported(items, new Set(["1"]));
|
||||
expect(items).to.deep.equal(snapshot);
|
||||
});
|
||||
});
|
||||
|
||||
describe("RecipeSourceError hierarchy", () => {
|
||||
it("RecipeSourceFetchError carries the source key, a message and an optional cause, and is a RecipeSourceError", () => {
|
||||
const cause = new Error("network down");
|
||||
|
|
|
|||
Loading…
Reference in a new issue