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:
|
* The flow a caller drives against one adapter:
|
||||||
* 1. `list()` — browse what's available from the source (paginated,
|
* 1. `list()` — browse what's available from the source (paginated,
|
||||||
* optionally filtered by `query`), like flipping through a catalog.
|
* 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.
|
* 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
|
* pure and synchronous so it's unit-testable without any network
|
||||||
* access (same split as `tech-step-matcher.ts`'s pure `matchTechStep`
|
* access (same split as step 2 above).
|
||||||
* vs its DB-touching `loadTechStepMappingRules`).
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Search/pagination input for {@link RecipeSourceAdapter.list}. */
|
/** 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. */
|
/** 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 {
|
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;
|
externalId: string;
|
||||||
title: string;
|
title: string;
|
||||||
picture: string | null;
|
picture: string | null;
|
||||||
|
|
@ -48,6 +63,31 @@ export interface RecipeSourceListResult {
|
||||||
nextCursor: string | null;
|
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
|
* One ingredient line as lifted from a source, before it's resolved against
|
||||||
* our own `Ingredient`/`Unit` reference catalogs (that resolution —
|
* our own `Ingredient`/`Unit` reference catalogs (that resolution —
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,11 @@ import { expect } from "chai";
|
||||||
import type {
|
import type {
|
||||||
ParsedRecipe,
|
ParsedRecipe,
|
||||||
RecipeSourceAdapter,
|
RecipeSourceAdapter,
|
||||||
|
RecipeSourceListItem,
|
||||||
RecipeSourceListParams,
|
RecipeSourceListParams,
|
||||||
RecipeSourceListResult,
|
RecipeSourceListResult,
|
||||||
} from "../src/lib/recipe-source-adapter.js";
|
} from "../src/lib/recipe-source-adapter.js";
|
||||||
|
import { markAlreadyImported } from "../src/lib/recipe-source-adapter.js";
|
||||||
import {
|
import {
|
||||||
RecipeSourceError,
|
RecipeSourceError,
|
||||||
RecipeSourceFetchError,
|
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", () => {
|
describe("RecipeSourceError hierarchy", () => {
|
||||||
it("RecipeSourceFetchError carries the source key, a message and an optional cause, and is a RecipeSourceError", () => {
|
it("RecipeSourceFetchError carries the source key, a message and an optional cause, and is a RecipeSourceError", () => {
|
||||||
const cause = new Error("network down");
|
const cause = new Error("network down");
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue