ha-cookcli/www/cookcli-recipe-strategy.js

306 lines
9.4 KiB
JavaScript

/**
* Strategies CookCLI pour Home Assistant : une dashboard strategy + une view
* strategy compagne.
*
* IMPORTANT : la view strategy seule ne suffit pas pour une navigation
* fluide. Une view strategy régénère son contenu au MONTAGE de la vue, pas à
* chaque changement d'URL — avec une seule vue partagée "recette?path=..."
* pour toutes les recettes, passer de l'une à l'autre sans recharger la
* page laissait afficher l'ancienne recette (vue déjà montée, jamais
* remontée). D'où la dashboard strategy : elle génère une VUE DISTINCTE par
* recette (un vrai `path` HA différent pour chacune), ce qui force un
* remontage — donc une régénération — à chaque navigation.
*
* Cartes utilisées, toutes tierces ou natives — aucune carte custom pour le
* rendu du contenu lui-même :
* - markdown (native) : titre, image, ustensiles, texte des étapes
* - todo-list (native) : checklist des ingrédients (todo.py backend)
* - button (native) : démarre le minuteur avec la bonne durée
* - custom:circular-timer-card : affichage/contrôle du minuteur partagé
* - custom:tabdeck-card : une tab par étape
* - custom:cookcli-card : la liste (voir cookcli-card.js)
*
* Configuration du tableau de bord (remplace le contenu YAML du dashboard) :
* strategy:
* type: custom:cookcli
* title: Recettes
* timer_entity: timer.recette_en_cours # créé manuellement, voir README
* entry_id: xxxx # optionnel, si plusieurs serveurs CookCLI
*/
class CookCliDashboardStrategy extends HTMLElement {
static getCreateSuggestions() {
return { title: "Recettes", icon: "mdi:chef-hat" };
}
static async generate(config, hass) {
config = config || {};
const wsMsg = { type: "cookcli/recipes" };
if (config.entry_id) wsMsg.entry_id = config.entry_id;
let recipes = [];
try {
const result = await hass.connection.sendMessagePromise(wsMsg);
recipes = result.recipes || [];
} catch (err) {
// Liste vide plutôt que planter tout le dashboard ; la carte affichera
// son propre message d'erreur au prochain essai de fetch côté carte.
}
const listView = {
title: config.title || "Recettes",
path: "recettes",
cards: [
{
type: "custom:cookcli-card",
title: config.title || "Mes recettes",
entry_id: config.entry_id,
},
],
};
const recipeViews = recipes.map((recipe) => ({
path: recipe.view_path,
subview: true,
strategy: {
type: "custom:cookcli-recipe",
path: recipe.path,
timer_entity: config.timer_entity,
entry_id: config.entry_id,
},
}));
return {
title: config.title || "Recettes",
views: [listView, ...recipeViews],
};
}
}
/**
* View strategy : génère le contenu de la vue d'UNE recette. `config.path`
* est fourni par la dashboard strategy ci-dessus ; utilisable seule (une vue
* par recette écrite à la main) en le fixant directement dans le YAML.
*/
class CookCliRecipeViewStrategy extends HTMLElement {
static async generate(config, hass) {
config = config || {};
const path = config.path;
if (!path) {
return {
cards: [{ type: "markdown", content: "Aucune recette sélectionnée." }],
};
}
const wsMsg = { type: "cookcli/recipe", path };
if (config.entry_id) wsMsg.entry_id = config.entry_id;
let recipe;
try {
recipe = await hass.connection.sendMessagePromise(wsMsg);
} catch (err) {
return {
cards: [
{
type: "markdown",
content: `**Erreur en chargeant la recette**\n\n${(err && err.message) || err}`,
},
],
};
}
const cards = [];
cards.push(this._headerCard(recipe));
if (config.timer_entity) {
cards.push({
type: "custom:simple-timer-card",
style: "fill_horizontal",
show_active_header: false,
entities: [{
entity: config.timer_entity,
keep_timer_visible_when_idle: true,
name: " "
}]
});
cards.push({
type: "horizontal-stack",
cards: [
{
type: "markdown",
content: " ",
text_only: true
},
{
type: "custom:circular-timer-card",
entity: config.timer_entity,
primary_info: "none"
}
]
});
}
if (recipe.todo_entity_id) {
cards.push({
type: "todo-list",
entity: recipe.todo_entity_id,
title: "Ingrédients à rassembler",
card_mod: {
style: `
ha-list .header {
display: none;
}
`
}
});
}
const tabs = this._stepTabs(recipe, config.timer_entity);
if (tabs.length) {
cards.push({ type: "custom:tabdeck-card", tabs });
}
return {
title: recipe.title || "Recette",
cards,
};
}
static _headerCard(recipe) {
let content = "";
if (recipe.image_url) content += `![](${recipe.image_url})\n\n`;
content += `## ${recipe.title || ""}\n`;
if (recipe.cookware && recipe.cookware.length) {
content += `\n**Ustensiles** : ${recipe.cookware.map((c) => c.name).join(", ")}\n`;
}
return { type: "markdown", content };
}
static _stepTabs(recipe, timerEntity) {
const tabs = [];
let stepCounter = 0;
for (const section of recipe.sections || []) {
for (const step of section.steps || []) {
stepCounter += 1;
const { markdown, timers } = this._renderStepMarkdown(step);
const stepCards = [{ type: "markdown", content: markdown }];
if (timerEntity) {
for (const timer of timers) {
const seconds = this._parseDurationSeconds(timer.duration, timer.unit);
if (!seconds) continue;
const label = `${timer.duration ?? ""} ${timer.unit ?? ""}`.trim();
stepCards.push({
type: "button",
name: `Démarrer ${label}`,
icon: "mdi:timer-outline",
tap_action: {
action: "call-service",
service: "timer.start",
target: { entity_id: timerEntity },
data: { duration: this._secondsToHms(seconds) },
},
});
}
}
tabs.push({
name: section.name ? `${section.name} ${step.number ?? ""}`.trim() : `Étape ${stepCounter}`,
card: { type: "vertical-stack", cards: stepCards },
});
}
}
return tabs;
}
static _renderStepMarkdown(step) {
let markdown = "";
const timers = [];
for (const item of step.items || []) {
switch (item.type) {
case "text":
markdown += item.value ?? "";
break;
case "ingredient":
case "cookware": {
const qty = item.quantity
? `${item.quantity.value ?? ""} ${item.quantity.unit ?? ""}`.trim()
: "";
markdown += `**${item.name}${qty ? ` (${qty})` : ""}**`;
break;
}
case "timer": {
const label = `${item.duration ?? ""} ${item.unit ?? ""}`.trim();
markdown += `⏱ *${label}*`;
timers.push(item);
break;
}
default:
break;
}
}
return { markdown, timers };
}
/**
* Devine une durée en secondes à partir d'une valeur de minuteur résolue.
* `duration` peut être un nombre (secondes/minutes selon `unit`) ou du
* texte libre façon "2-3 minutes" (Cooklang autorise les plages en texte
* libre) — dans ce cas on prend le premier nombre trouvé, ce qui donne une
* estimation basse ; ajustable ensuite via circular-timer-card (tap =
* toggle, double-tap = annuler, appui long = plus d'infos) ou en relançant
* timer.start avec une autre durée.
*/
static _parseDurationSeconds(duration, unit) {
let value = null;
let unitHint = (unit || "").toLowerCase();
if (typeof duration === "number") {
value = duration;
} else if (typeof duration === "string") {
const match = duration.match(/(\d+(?:[.,]\d+)?)/);
if (match) {
value = parseFloat(match[1].replace(",", "."));
if (!unitHint) unitHint = duration.toLowerCase();
}
}
if (value === null) return null;
if (unitHint.includes("heure") || unitHint.includes("hour") || unitHint === "h") {
return Math.round(value * 3600);
}
if (unitHint.includes("sec") || unitHint === "s") {
return Math.round(value);
}
// Par défaut (minutes, ou pas d'unité reconnue) : on suppose des minutes.
return Math.round(value * 60);
}
static _secondsToHms(totalSeconds) {
const h = Math.floor(totalSeconds / 3600);
const m = Math.floor((totalSeconds % 3600) / 60);
const s = Math.floor(totalSeconds % 60);
return [h, m, s].map((n) => String(n).padStart(2, "0")).join(":");
}
}
customElements.define("ll-strategy-dashboard-cookcli", CookCliDashboardStrategy);
customElements.define("ll-strategy-view-cookcli-recipe", CookCliRecipeViewStrategy);
window.customStrategies = window.customStrategies || [];
window.customStrategies.push({
type: "cookcli",
strategyType: "dashboard",
name: "CookCLI",
description: "Dashboard de recettes CookCLI : liste + une vue par recette.",
documentationURL: "https://git.thethomaas.net/TheThomaas/ha-cookcli",
});