407 lines
15 KiB
JavaScript
407 lines
15 KiB
JavaScript
/**
|
||
* Classification & construction du catalogue recette.
|
||
* Pur (aucun IO) — prend en entrée les recettes « fusionnées » issues de
|
||
* `pipeline.mjs` et les définitions de sprites résolues par `sprites.mjs`,
|
||
* renvoie le graph `{ bases, variants }` qui sera sérialisé en `catalog.json`.
|
||
*
|
||
* Le tagging des bases (slug/tags → `base:xxx`) reste dans
|
||
* `scripts/recipe-category.mjs` (testé indépendamment).
|
||
*/
|
||
|
||
import { RECIPE_BASE_DISPLAY_ORDER, RECIPE_BASE_FLAVOR, resolveRecipeBaseId } from "../recipe-category.mjs";
|
||
import {
|
||
aliasToIconPx,
|
||
buildIngredientCoverSprite,
|
||
findAliasForIconPx,
|
||
isPreferredVariantAlias,
|
||
resolveIconPxForIngredientName
|
||
} from "./sprites.mjs";
|
||
|
||
/** Mots-clés pour la classification sucrée/salée du bandeau de recette. */
|
||
const flavorKeywords = {
|
||
sweet: ["dessert", "gateau", "gâteau", "cake", "biscuit", "muffin", "financier", "madeleine", "crumble", "crème", "cremeux", "citron", "orange", "banane", "pomme", "chocolat", "sucré", "sucre"],
|
||
savory: ["sale", "salé", "bento", "riz", "onigiri", "mandu", "gimbap", "empanada", "naan", "dan-bing", "omelette", "œuf", "oeuf"]
|
||
};
|
||
|
||
/**
|
||
* Renvoie `"sweet"` ou `"savory"` selon les mots-clés présents dans le slug
|
||
* concaténé aux tags. En cas d’égalité, fallback `"savory"` (préserve le
|
||
* comportement historique du pipeline).
|
||
*/
|
||
const classifyFlavor = (slug, tags) => {
|
||
const haystack = `${slug} ${tags.join(" ")}`.toLowerCase();
|
||
const sweetHits = flavorKeywords.sweet.filter((keyword) => haystack.includes(keyword)).length;
|
||
const savoryHits = flavorKeywords.savory.filter((keyword) => haystack.includes(keyword)).length;
|
||
if (sweetHits > savoryHits) return "sweet";
|
||
if (savoryHits > sweetHits) return "savory";
|
||
return "savory";
|
||
};
|
||
|
||
const buildLocalizedText = (localesMap, fallback) => {
|
||
const fr = localesMap.fr?.title ?? fallback;
|
||
const en = localesMap.en?.title ?? fr;
|
||
const ko = localesMap.ko?.title ?? en;
|
||
const zh = localesMap.zh?.title ?? en;
|
||
return { fr, en, ko, zh };
|
||
};
|
||
|
||
const buildLocalizedParagraph = (localesMap, fallback) => {
|
||
const fr = localesMap.fr?.description ?? fallback;
|
||
const en = localesMap.en?.description ?? fr;
|
||
const ko = localesMap.ko?.description ?? en;
|
||
const zh = localesMap.zh?.description ?? en;
|
||
return { fr, en, ko, zh };
|
||
};
|
||
|
||
/** Libellés affichés (familles produit) — voir `scripts/recipe-category.mjs` pour l’association slug/tags → id. */
|
||
const BASE_LABELS = {
|
||
"base:onigiri": {
|
||
name: { fr: "Bouchées de riz", en: "Rice bites", ko: "밥 한 입", zh: "饭团一口" },
|
||
history: {
|
||
fr: "Riz compact, façonné, garni ou enveloppé — onigiri, inarizushi, musubi…",
|
||
en: "Compact rice, shaped, filled or wrapped—onigiri, inarizushi, musubi…",
|
||
ko: "조리해 모양 낸 밥 — 오니기리, 이나리즈시 등.",
|
||
zh: "塑形或包裹的米饭一口食——饭团、稻荷寿司等。"
|
||
}
|
||
},
|
||
"base:gimbap": {
|
||
name: { fr: "Rouleaux de riz", en: "Rice rolls", ko: "밥 말이", zh: "饭卷" },
|
||
history: {
|
||
fr: "Riz roulé puis découpé — gimbap, maki-like.",
|
||
en: "Rolled and sliced rice—gimbap, maki-style rolls.",
|
||
ko: "말아 자른 밥 — 김밥, 마키 스타일.",
|
||
zh: "卷起再切的饭——紫菜包饭、卷饭。"
|
||
}
|
||
},
|
||
"base:mandu": {
|
||
name: { fr: "Mandu", en: "Mandu / dumplings", ko: "만두", zh: "饺子 / 煎饺" },
|
||
history: {
|
||
fr: "Raviolis et bouchées farcies — mandu, gyoza, jiaozi…",
|
||
en: "Stuffed dumplings—mandu, gyoza, jiaozi…",
|
||
ko: "속을 채운 만두류.",
|
||
zh: "带馅面团——饺子、锅贴等。"
|
||
}
|
||
},
|
||
"base:empanadas-savory": {
|
||
name: { fr: "Empanadas", en: "Empanadas", ko: "엠파나다", zh: "烤饺" },
|
||
history: {
|
||
fr: "Chaussons farcis — empanadas, hand pies, pastels salés.",
|
||
en: "Filled turnovers—empanadas, hand pies, savoury pastels.",
|
||
ko: "속을 채운 페이스트리.",
|
||
zh: "酥皮咸馅小包。"
|
||
}
|
||
},
|
||
"base:empanadas-sweet": {
|
||
name: { fr: "Empanadas", en: "Empanadas", ko: "엠파나다", zh: "烤饺" },
|
||
history: {
|
||
fr: "Chaussons sucrés — farce dessert.",
|
||
en: "Sweet filled turnovers.",
|
||
ko: "달콤한 속을 넣은 페이스트리.",
|
||
zh: "甜馅酥皮小包。"
|
||
}
|
||
},
|
||
"base:galettes": {
|
||
name: { fr: "Galettes", en: "Flatbreads & griddled cakes", ko: "전·난·플랫브레드", zh: "烙饼 / 扁面包" },
|
||
history: {
|
||
fr: "Pains plats, crêpes, galettes poêlées — naan, dan bing, jeon, paratha…",
|
||
en: "Flatbreads and griddled pancakes—naan, dan bing, jeon, paratha…",
|
||
ko: "난, 단빙, 전 등 납작한 빵·전병.",
|
||
zh: "烙饼、印度烤饼、煎饼类等。"
|
||
}
|
||
},
|
||
"base:bao": {
|
||
name: { fr: "Bao", en: "Steamed buns", ko: "바오·만두빵", zh: "包子 / 包点" },
|
||
history: {
|
||
fr: "Pains vapeur farcis ou garnis — bao salé ou sucré, mantou garni.",
|
||
en: "Steamed buns, savoury or sweet filling, stuffed mantou.",
|
||
ko: "찐 빵·만두빵.",
|
||
zh: "蒸制包点。"
|
||
}
|
||
},
|
||
"base:muffin": {
|
||
name: { fr: "Muffins", en: "Muffins", ko: "머핀", zh: "玛芬" },
|
||
history: {
|
||
fr: "Portion moulée individuelle — muffins sucrés ou salés.",
|
||
en: "Individual molded portions—sweet or savoury muffins.",
|
||
ko: "한 입 크기 머핀.",
|
||
zh: "一人份模烤点心。"
|
||
}
|
||
},
|
||
"base:mini-cakes": {
|
||
name: { fr: "Mini-cakes", en: "Small portable cakes", ko: "미니 케이크", zh: "小蛋糕" },
|
||
history: {
|
||
fr: "Petits gâteaux transportables — madeleines, financiers, mini-cakes.",
|
||
en: "Portable small cakes—madeleines, financiers, mini cakes.",
|
||
ko: "휴대하기 좋은 작은 케이크.",
|
||
zh: "便于携带的小蛋糕。"
|
||
}
|
||
},
|
||
"base:biscuits-secs": {
|
||
name: { fr: "Biscuits secs", en: "Crisp cookies", ko: "바삭한 비스킷", zh: "酥脆饼干" },
|
||
history: {
|
||
fr: "Sec, solide, compatible bureau — cookies, sablés, biscuits croquants.",
|
||
en: "Dry, snack-friendly—cookies, shortbread, crisp biscuits.",
|
||
ko: "바삭한 과자·쿠키.",
|
||
zh: "干爽酥脆饼干。"
|
||
}
|
||
},
|
||
"base:fluffy-cakes": {
|
||
name: { fr: "Fluffy cakes", en: "Fluffy cakes", ko: "폭신 케이크", zh: "松软蛋糕" },
|
||
history: {
|
||
fr: "Gâteaux légers, tranchables ou sandwichables — sponge, génoises, fluffy.",
|
||
en: "Light, sliceable or layer cakes—sponge, génoise, fluffy styles.",
|
||
ko: "가볍게 썰어 먹는 케이크.",
|
||
zh: "松软可分切的蛋糕。"
|
||
}
|
||
},
|
||
"base:cakes": {
|
||
name: { fr: "Cakes", en: "Cakes", ko: "케이크", zh: "蛋糕" },
|
||
history: {
|
||
fr: "Gâteaux type layer cake, carrot cake, banana bread…",
|
||
en: "Layer cakes, carrot cake, banana bread-style bakes.",
|
||
ko: "당근 케이크, 바나나 브레드 등.",
|
||
zh: "胡萝卜蛋糕、香蕉蛋糕等。"
|
||
}
|
||
},
|
||
"base:carnet": {
|
||
name: { fr: "Carnet", en: "Notebook", ko: "노트", zh: "手记" },
|
||
history: {
|
||
fr: "Recettes bonus, hors concept strict Ben-to — à taguer « carnet » dans les tags.",
|
||
en: "Bonus recipes outside the core Ben-to grid—tag « carnet » in bentext tags.",
|
||
ko: "보조 레시피 — 태그에 carnet.",
|
||
zh: "附录食谱——在标签中加 carnet。"
|
||
}
|
||
}
|
||
};
|
||
|
||
/** Icônes des tuiles « base » : alias dans `ingredient-sprites.bentext` → coordonnées pixels. */
|
||
const BASE_SPRITE_ALIASES = {
|
||
"base:onigiri": "onigiri",
|
||
"base:gimbap": "gimbap",
|
||
"base:mandu": "mandu",
|
||
"base:empanadas-savory": "empenadas",
|
||
"base:empanadas-sweet": "empenadas",
|
||
"base:galettes": "naan",
|
||
"base:bao": "bao",
|
||
"base:muffin": "muffin",
|
||
"base:mini-cakes": "madelaine",
|
||
"base:biscuits-secs": "muffin",
|
||
"base:fluffy-cakes": "gateau de savoie",
|
||
"base:cakes": "tranche cake",
|
||
"base:carnet": "carnet"
|
||
};
|
||
|
||
const buildBaseIngredientsSteps = (recipe, sheetMeta) => {
|
||
const ingredients = recipe.mergedIngredients.map((ingredient) => {
|
||
const { iconPx, id, names, notes, quantity, unit } = ingredient;
|
||
const coverSprite = buildIngredientCoverSprite(iconPx, sheetMeta);
|
||
const row = {
|
||
id,
|
||
name: {
|
||
fr: names.fr ?? names.en,
|
||
en: names.en ?? names.fr,
|
||
ko: names.ko ?? names.en,
|
||
zh: names.zh ?? names.en
|
||
},
|
||
info: {
|
||
fr: notes.fr ?? "",
|
||
en: notes.en ?? "",
|
||
ko: notes.ko ?? "",
|
||
zh: notes.zh ?? ""
|
||
},
|
||
quantity,
|
||
unit
|
||
};
|
||
if (coverSprite) row.coverSprite = coverSprite;
|
||
return row;
|
||
});
|
||
const steps = recipe.mergedSteps.map((step) => ({
|
||
id: step.id,
|
||
priority: step.priority,
|
||
phase: step.phase,
|
||
dependsOn: step.dependsOn,
|
||
text: {
|
||
fr: step.text.fr,
|
||
en: step.text.en,
|
||
ko: step.text.ko,
|
||
zh: step.text.zh
|
||
}
|
||
}));
|
||
return { ingredients, steps };
|
||
};
|
||
|
||
const buildVariantPayload = (recipe, sheetMeta) => {
|
||
const title = buildLocalizedText(recipe.locales, recipe.slug);
|
||
const story = buildLocalizedParagraph(recipe.locales, "");
|
||
const ingredients = recipe.mergedIngredients.map((ingredient) => {
|
||
const { iconPx, id, names, notes, quantity, unit } = ingredient;
|
||
const coverSprite = buildIngredientCoverSprite(iconPx, sheetMeta);
|
||
const row = {
|
||
id: `variant:${recipe.slug}:${id}`,
|
||
name: {
|
||
fr: names.fr ?? names.en,
|
||
en: names.en ?? names.fr,
|
||
ko: names.ko ?? names.en,
|
||
zh: names.zh ?? names.en
|
||
},
|
||
info: {
|
||
fr: notes.fr ?? "",
|
||
en: notes.en ?? "",
|
||
ko: notes.ko ?? "",
|
||
zh: notes.zh ?? ""
|
||
},
|
||
quantity,
|
||
unit
|
||
};
|
||
if (coverSprite) row.coverSprite = coverSprite;
|
||
return row;
|
||
});
|
||
const steps = recipe.mergedSteps.map((step) => ({
|
||
id: `variant:${recipe.slug}:${step.id}`,
|
||
priority: step.priority + 5000,
|
||
phase: step.phase,
|
||
dependsOn: step.dependsOn.map((dependency) => `variant:${recipe.slug}:${dependency}`),
|
||
text: {
|
||
fr: step.text.fr,
|
||
en: step.text.en,
|
||
ko: step.text.ko,
|
||
zh: step.text.zh
|
||
}
|
||
}));
|
||
return { title, story, ingredients, steps, servings: recipe.servings };
|
||
};
|
||
|
||
const buildRecipeGraph = (recipes, sheetMeta, spriteByAlias, preferredVariantAliases) => {
|
||
const baseBuckets = new Map();
|
||
for (const baseId of RECIPE_BASE_DISPLAY_ORDER) {
|
||
baseBuckets.set(baseId, []);
|
||
}
|
||
for (const recipe of recipes) {
|
||
const id = resolveRecipeBaseId(recipe.slug, recipe.tags);
|
||
baseBuckets.get(id).push(recipe);
|
||
}
|
||
|
||
const bases = [];
|
||
for (const baseId of RECIPE_BASE_DISPLAY_ORDER) {
|
||
const bucket = baseBuckets.get(baseId) ?? [];
|
||
bucket.sort((a, b) => a.slug.localeCompare(b.slug));
|
||
const representative = bucket[0] ?? null;
|
||
const labels = BASE_LABELS[baseId];
|
||
const { ingredients, steps } = representative
|
||
? buildBaseIngredientsSteps(representative, sheetMeta)
|
||
: { ingredients: [], steps: [] };
|
||
const servings = representative?.servings ?? 1;
|
||
const tags = representative ? [...representative.tags] : [];
|
||
const baseSpriteAlias = BASE_SPRITE_ALIASES[baseId];
|
||
const baseIconPx = baseSpriteAlias ? aliasToIconPx(spriteByAlias, baseSpriteAlias) : null;
|
||
if (baseSpriteAlias && !baseIconPx) {
|
||
console.warn(
|
||
`[build-recipes] Icône base absente de ingredient-sprites.bentext (alias « ${baseSpriteAlias} » pour ${baseId}).`
|
||
);
|
||
}
|
||
const baseCoverSprite = buildIngredientCoverSprite(baseIconPx, sheetMeta);
|
||
bases.push(
|
||
Object.freeze({
|
||
id: baseId,
|
||
flavor: RECIPE_BASE_FLAVOR[baseId],
|
||
name: labels.name,
|
||
history: labels.history,
|
||
servings,
|
||
ingredients: Object.freeze(ingredients),
|
||
steps: Object.freeze(steps),
|
||
slug: baseId.replace("base:", ""),
|
||
coverImageUrl: null,
|
||
coverSprite: baseCoverSprite ?? null,
|
||
tags: Object.freeze(tags)
|
||
})
|
||
);
|
||
}
|
||
|
||
const variants = [];
|
||
for (const recipe of recipes) {
|
||
const baseId = resolveRecipeBaseId(recipe.slug, recipe.tags);
|
||
const { title, story, ingredients, steps } = buildVariantPayload(recipe, sheetMeta);
|
||
const variantId = `variant:${recipe.slug}`;
|
||
const variantCoverSprite = resolveVariantCoverSprite(
|
||
recipe,
|
||
sheetMeta,
|
||
spriteByAlias,
|
||
preferredVariantAliases
|
||
);
|
||
variants.push(
|
||
Object.freeze({
|
||
id: variantId,
|
||
baseIds: Object.freeze([baseId]),
|
||
name: title,
|
||
story,
|
||
servings: recipe.servings,
|
||
ingredients: Object.freeze(ingredients),
|
||
steps: Object.freeze(steps),
|
||
slug: recipe.slug,
|
||
coverImageUrl: null,
|
||
coverSprite: variantCoverSprite ?? null,
|
||
recipeCoverImageUrl: recipe.coverImageUrl ?? null,
|
||
tags: Object.freeze(recipe.tags)
|
||
})
|
||
);
|
||
}
|
||
|
||
return { bases: Object.freeze(bases), variants: Object.freeze(variants) };
|
||
};
|
||
|
||
/**
|
||
* Icône tuile garniture : override explicite via tag bentext `sprite:<alias>`
|
||
* d’abord, sinon ingrédient « prioritaire », sinon premier ingrédient avec sprite résolu.
|
||
* Émet un `console.warn` quand l’alias explicite est introuvable.
|
||
*/
|
||
const resolveVariantCoverSprite = (recipe, sheetMeta, spriteByAlias, preferredVariantAliases) => {
|
||
const tileAlias = recipe.variantTileSpriteAlias ?? null;
|
||
if (tileAlias) {
|
||
const sprite = resolveVariantCoverSpriteFromTileAlias(tileAlias, sheetMeta, spriteByAlias);
|
||
if (sprite) return sprite;
|
||
console.warn(
|
||
`[build-recipes] Tuile variante : alias « ${tileAlias} » introuvable (${recipe.slug}), fallback automatique.`
|
||
);
|
||
}
|
||
return pickVariantCoverSprite(recipe, sheetMeta, spriteByAlias, preferredVariantAliases);
|
||
};
|
||
|
||
const resolveVariantCoverSpriteFromTileAlias = (aliasKey, sheetMeta, spriteByAlias) => {
|
||
if (!aliasKey || typeof aliasKey !== "string") return null;
|
||
const normalized = normalizeAliasKeyLocal(aliasKey);
|
||
const px =
|
||
resolveIconPxForIngredientName(aliasKey, spriteByAlias) ??
|
||
aliasToIconPx(spriteByAlias, normalized);
|
||
if (!px || typeof px.x !== "number" || typeof px.y !== "number") return null;
|
||
return buildIngredientCoverSprite(px, sheetMeta);
|
||
};
|
||
|
||
const pickVariantCoverSprite = (recipe, sheetMeta, spriteByAlias, preferredAliases) => {
|
||
const merged = recipe.mergedIngredients ?? [];
|
||
const tryPick = (preferredOnly) => {
|
||
for (const ing of merged) {
|
||
const px = ing.iconPx;
|
||
if (!px || typeof px.x !== "number" || typeof px.y !== "number") continue;
|
||
const alias = findAliasForIconPx(spriteByAlias, px);
|
||
if (preferredOnly) {
|
||
if (!alias || !isPreferredVariantAlias(preferredAliases, alias)) continue;
|
||
}
|
||
return buildIngredientCoverSprite(px, sheetMeta);
|
||
}
|
||
return null;
|
||
};
|
||
return tryPick(true) ?? tryPick(false) ?? null;
|
||
};
|
||
|
||
const normalizeAliasKeyLocal = (s) => s.trim().toLowerCase().replace(/\s+/g, " ");
|
||
|
||
export {
|
||
classifyFlavor,
|
||
buildLocalizedText,
|
||
buildLocalizedParagraph,
|
||
buildBaseIngredientsSteps,
|
||
buildVariantPayload,
|
||
buildRecipeGraph,
|
||
resolveVariantCoverSprite
|
||
};
|