Update cookcli-recipe-strategy.js
This commit is contained in:
parent
d25c6ce747
commit
6b88771958
278
dist/cookcli-recipe-strategy.js
vendored
278
dist/cookcli-recipe-strategy.js
vendored
|
|
@ -11,21 +11,43 @@
|
||||||
* recette (un vrai `path` HA différent pour chacune), ce qui force un
|
* recette (un vrai `path` HA différent pour chacune), ce qui force un
|
||||||
* remontage — donc une régénération — à chaque navigation.
|
* remontage — donc une régénération — à chaque navigation.
|
||||||
*
|
*
|
||||||
|
* La vue d'une recette est UNE SEULE carte tabdeck-card : un onglet "Résumé"
|
||||||
|
* (titre, image, ustensiles, tous les ingrédients à cocher, bouton
|
||||||
|
* "Commencer"), puis un onglet par étape (colonne gauche 30% : ingrédients
|
||||||
|
* de l'étape + minuteur ; colonne droite 70% : texte de l'étape ; boutons
|
||||||
|
* Précédent/Suivant en bas).
|
||||||
|
*
|
||||||
|
* Le ratio 30/70 n'a pas d'équivalent natif HA (grid/horizontal-stack ne
|
||||||
|
* font que des colonnes égales) : on s'appuie sur **card-mod**
|
||||||
|
* (https://github.com/thomasloven/lovelace-card-mod, HACS) pour forcer la
|
||||||
|
* largeur des deux colonnes d'un horizontal-stack.
|
||||||
|
*
|
||||||
|
* Pour Précédent/Suivant/Commencer, on n'utilise PAS le hash d'URL
|
||||||
|
* `#tab=...` de tabdeck (son comportement en navigation live après montage
|
||||||
|
* n'est pas documenté avec certitude) mais son mode `remember: entity` —
|
||||||
|
* documenté et fiable : l'onglet actif est piloté par la valeur d'un helper
|
||||||
|
* input_number, que nos boutons changent via `input_number.set_value`.
|
||||||
|
* tabdeck lit cette valeur pour choisir l'onglet actif et se met à jour
|
||||||
|
* comme n'importe quel autre entité HA quand elle change — un mécanisme
|
||||||
|
* standard, pas une supposition sur le comportement interne de la carte.
|
||||||
|
*
|
||||||
* Cartes utilisées, toutes tierces ou natives — aucune carte custom pour le
|
* Cartes utilisées, toutes tierces ou natives — aucune carte custom pour le
|
||||||
* rendu du contenu lui-même :
|
* rendu du contenu lui-même :
|
||||||
* - markdown (native) : titre, image, ustensiles, texte des étapes
|
* - markdown (native) : titre, image, ustensiles, texte des étapes
|
||||||
* - todo-list (native) : checklist des ingrédients (todo.py backend)
|
* - todo-list (native) : checklist des ingrédients (todo.py backend)
|
||||||
* - button (native) : démarre le minuteur avec la bonne durée
|
* - button (native) : Commencer / Précédent / Suivant / Démarrer minuteur
|
||||||
* - custom:circular-timer-card : affichage/contrôle du minuteur partagé
|
* - custom:circular-timer-card : affichage/contrôle du minuteur partagé
|
||||||
* - custom:tabdeck-card : une tab par étape
|
* - custom:tabdeck-card : les onglets (Résumé + une par étape)
|
||||||
|
* - custom:mod-card (card-mod) : force le ratio 30/70 des colonnes d'étape
|
||||||
* - custom:cookcli-card : la liste (voir cookcli-card.js)
|
* - custom:cookcli-card : la liste (voir cookcli-card.js)
|
||||||
*
|
*
|
||||||
* Configuration du tableau de bord (remplace le contenu YAML du dashboard) :
|
* Configuration du tableau de bord (remplace le contenu YAML du dashboard) :
|
||||||
* strategy:
|
* strategy:
|
||||||
* type: custom:cookcli
|
* type: custom:cookcli
|
||||||
* title: Recettes
|
* title: Recettes
|
||||||
* timer_entity: timer.recette_en_cours # créé manuellement, voir README
|
* timer_entity: timer.recette_en_cours # créé manuellement, voir README
|
||||||
* entry_id: xxxx # optionnel, si plusieurs serveurs CookCLI
|
* step_entity: input_number.recette_etape # créé manuellement, voir README
|
||||||
|
* entry_id: xxxx # optionnel, si plusieurs serveurs CookCLI
|
||||||
*/
|
*/
|
||||||
|
|
||||||
class CookCliDashboardStrategy extends HTMLElement {
|
class CookCliDashboardStrategy extends HTMLElement {
|
||||||
|
|
@ -44,8 +66,7 @@ class CookCliDashboardStrategy extends HTMLElement {
|
||||||
const result = await hass.connection.sendMessagePromise(wsMsg);
|
const result = await hass.connection.sendMessagePromise(wsMsg);
|
||||||
recipes = result.recipes || [];
|
recipes = result.recipes || [];
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Liste vide plutôt que planter tout le dashboard ; la carte affichera
|
// Liste vide plutôt que planter tout le dashboard.
|
||||||
// son propre message d'erreur au prochain essai de fetch côté carte.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const listView = {
|
const listView = {
|
||||||
|
|
@ -67,6 +88,7 @@ class CookCliDashboardStrategy extends HTMLElement {
|
||||||
type: "custom:cookcli-recipe",
|
type: "custom:cookcli-recipe",
|
||||||
path: recipe.path,
|
path: recipe.path,
|
||||||
timer_entity: config.timer_entity,
|
timer_entity: config.timer_entity,
|
||||||
|
step_entity: config.step_entity,
|
||||||
entry_id: config.entry_id,
|
entry_id: config.entry_id,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
@ -79,9 +101,8 @@ class CookCliDashboardStrategy extends HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* View strategy : génère le contenu de la vue d'UNE recette. `config.path`
|
* View strategy : génère le contenu de la vue d'UNE recette, sous forme
|
||||||
* est fourni par la dashboard strategy ci-dessus ; utilisable seule (une vue
|
* d'une unique carte tabdeck-card (Résumé + une tab par étape).
|
||||||
* par recette écrite à la main) en le fixant directement dans le YAML.
|
|
||||||
*/
|
*/
|
||||||
class CookCliRecipeViewStrategy extends HTMLElement {
|
class CookCliRecipeViewStrategy extends HTMLElement {
|
||||||
static async generate(config, hass) {
|
static async generate(config, hass) {
|
||||||
|
|
@ -111,111 +132,176 @@ class CookCliRecipeViewStrategy extends HTMLElement {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const cards = [];
|
// Repart de "Résumé" à chaque nouvelle recette ouverte, plutôt que de
|
||||||
cards.push(this._headerCard(recipe));
|
// garder l'étape où on s'était arrêté sur la recette précédente (le
|
||||||
|
// helper input_number est partagé entre toutes les recettes).
|
||||||
if (config.timer_entity) {
|
if (config.step_entity) {
|
||||||
cards.push({
|
await this._setStepIndex(hass, config.step_entity, 0);
|
||||||
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) {
|
// Étapes à plat, avec leur index global (1-based ; 0 est réservé au
|
||||||
cards.push({
|
// "Résumé") pour piloter remember_entity.
|
||||||
type: "todo-list",
|
const flatSteps = [];
|
||||||
entity: recipe.todo_entity_id,
|
for (const section of recipe.sections || []) {
|
||||||
title: "Ingrédients à rassembler",
|
for (const step of section.steps || []) {
|
||||||
card_mod: {
|
flatSteps.push({ section, step });
|
||||||
style: `
|
}
|
||||||
ha-list .header {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
`
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabs = this._stepTabs(recipe, config.timer_entity);
|
const tabs = [this._summaryTab(recipe, config, flatSteps.length)];
|
||||||
if (tabs.length) {
|
flatSteps.forEach(({ section, step }, i) => {
|
||||||
cards.push({ type: "custom:tabdeck-card", tabs });
|
const tabIndex = i + 1; // 0 = Résumé
|
||||||
|
const isLast = tabIndex === flatSteps.length;
|
||||||
|
tabs.push(
|
||||||
|
this._stepTab(section, step, tabIndex, isLast, config)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const tabdeckCard = {
|
||||||
|
type: "custom:tabdeck-card",
|
||||||
|
default_tab: 0,
|
||||||
|
tabs,
|
||||||
|
};
|
||||||
|
if (config.step_entity) {
|
||||||
|
tabdeckCard.remember = "entity";
|
||||||
|
tabdeckCard.remember_entity = config.step_entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: recipe.title || "Recette",
|
title: recipe.title || "Recette",
|
||||||
cards,
|
cards: [tabdeckCard],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
static _headerCard(recipe) {
|
static async _setStepIndex(hass, entity, value) {
|
||||||
|
try {
|
||||||
|
await hass.connection.sendMessagePromise({
|
||||||
|
type: "call_service",
|
||||||
|
domain: "input_number",
|
||||||
|
service: "set_value",
|
||||||
|
service_data: { entity_id: entity, value },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// Pas bloquant : au pire l'utilisateur atterrit sur le dernier onglet
|
||||||
|
// laissé actif plutôt que sur "Résumé".
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static _navButton(entity, targetIndex, label, icon) {
|
||||||
|
return {
|
||||||
|
type: "button",
|
||||||
|
name: label,
|
||||||
|
icon,
|
||||||
|
tap_action: {
|
||||||
|
action: "call-service",
|
||||||
|
service: "input_number.set_value",
|
||||||
|
target: { entity_id: entity },
|
||||||
|
data: { value: targetIndex },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static _summaryTab(recipe, config, stepCount) {
|
||||||
let content = "";
|
let content = "";
|
||||||
if (recipe.image_url) content += `\n\n`;
|
if (recipe.image_url) content += `\n\n`;
|
||||||
content += `## ${recipe.title || ""}\n`;
|
content += `## ${recipe.title || ""}\n`;
|
||||||
if (recipe.cookware && recipe.cookware.length) {
|
if (recipe.cookware && recipe.cookware.length) {
|
||||||
content += `\n**Ustensiles** : ${recipe.cookware.map((c) => c.name).join(", ")}\n`;
|
content += `\n**Ustensiles** : ${recipe.cookware.map((c) => c.name).join(", ")}\n`;
|
||||||
}
|
}
|
||||||
return { type: "markdown", content };
|
|
||||||
|
const cards = [{ type: "markdown", content }];
|
||||||
|
|
||||||
|
if (recipe.todo_entity_id) {
|
||||||
|
cards.push({
|
||||||
|
type: "todo-list",
|
||||||
|
entity: recipe.todo_entity_id,
|
||||||
|
title: "Ingrédients",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.step_entity && stepCount > 0) {
|
||||||
|
cards.push(this._navButton(config.step_entity, 1, "Commencer", "mdi:play"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { name: "Résumé", icon: "mdi:book-open-variant", card: { type: "vertical-stack", cards } };
|
||||||
}
|
}
|
||||||
|
|
||||||
static _stepTabs(recipe, timerEntity) {
|
static _stepTab(section, step, tabIndex, isLast, config) {
|
||||||
const tabs = [];
|
const { markdown: stepMarkdown, timers } = this._renderStepMarkdown(step);
|
||||||
let stepCounter = 0;
|
|
||||||
|
|
||||||
for (const section of recipe.sections || []) {
|
const leftCards = [];
|
||||||
for (const step of section.steps || []) {
|
const stepIngredients = (step.items || []).filter((item) => item.type === "ingredient");
|
||||||
stepCounter += 1;
|
if (stepIngredients.length) {
|
||||||
const { markdown, timers } = this._renderStepMarkdown(step);
|
const lines = stepIngredients.map((item) => {
|
||||||
|
const qty = item.quantity
|
||||||
|
? `${item.quantity.value ?? ""} ${item.quantity.unit ?? ""}`.trim()
|
||||||
|
: "";
|
||||||
|
return `- ${qty ? `**${qty}** ` : ""}${item.name}`;
|
||||||
|
});
|
||||||
|
leftCards.push({ type: "markdown", content: lines.join("\n") });
|
||||||
|
}
|
||||||
|
|
||||||
const stepCards = [{ type: "markdown", content: markdown }];
|
if (config.timer_entity) {
|
||||||
|
for (const timer of timers) {
|
||||||
if (timerEntity) {
|
const seconds = this._parseDurationSeconds(timer.duration, timer.unit);
|
||||||
for (const timer of timers) {
|
if (!seconds) continue;
|
||||||
const seconds = this._parseDurationSeconds(timer.duration, timer.unit);
|
const label = `${timer.duration ?? ""} ${timer.unit ?? ""}`.trim();
|
||||||
if (!seconds) continue;
|
leftCards.push({
|
||||||
const label = `${timer.duration ?? ""} ${timer.unit ?? ""}`.trim();
|
type: "button",
|
||||||
stepCards.push({
|
name: `Démarrer ${label}`,
|
||||||
type: "button",
|
icon: "mdi:timer-outline",
|
||||||
name: `Démarrer ${label}`,
|
tap_action: {
|
||||||
icon: "mdi:timer-outline",
|
action: "call-service",
|
||||||
tap_action: {
|
service: "timer.start",
|
||||||
action: "call-service",
|
target: { entity_id: config.timer_entity },
|
||||||
service: "timer.start",
|
data: { duration: this._secondsToHms(seconds) },
|
||||||
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 },
|
|
||||||
});
|
});
|
||||||
|
leftCards.push({ type: "custom:circular-timer-card", entity: config.timer_entity });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return tabs;
|
if (!leftCards.length) {
|
||||||
|
// horizontal-stack veut deux cartes ; une carte vide maintient le ratio.
|
||||||
|
leftCards.push({ type: "markdown", content: "" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = {
|
||||||
|
type: "custom:mod-card",
|
||||||
|
card_mod: {
|
||||||
|
style:
|
||||||
|
"hui-horizontal-stack-card $: | " +
|
||||||
|
"div#root > :first-child { width: 30%; flex: auto; } " +
|
||||||
|
"div#root > :last-child { width: 70%; flex: auto; }",
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
type: "horizontal-stack",
|
||||||
|
cards: [
|
||||||
|
{ type: "vertical-stack", cards: leftCards },
|
||||||
|
{ type: "markdown", content: stepMarkdown },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const cards = [columns];
|
||||||
|
|
||||||
|
if (config.step_entity) {
|
||||||
|
const navButtons = [
|
||||||
|
this._navButton(config.step_entity, tabIndex - 1, "Précédent", "mdi:arrow-left"),
|
||||||
|
];
|
||||||
|
if (!isLast) {
|
||||||
|
navButtons.push(
|
||||||
|
this._navButton(config.step_entity, tabIndex + 1, "Suivant", "mdi:arrow-right")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
cards.push({ type: "horizontal-stack", cards: navButtons });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: section.name ? `${section.name} ${step.number ?? ""}`.trim() : `Étape ${tabIndex}`,
|
||||||
|
icon: "mdi:numeric-" + (tabIndex <= 9 ? tabIndex : "9-plus") + "-circle-outline",
|
||||||
|
card: { type: "vertical-stack", cards },
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
static _renderStepMarkdown(step) {
|
static _renderStepMarkdown(step) {
|
||||||
|
|
@ -254,9 +340,8 @@ class CookCliRecipeViewStrategy extends HTMLElement {
|
||||||
* `duration` peut être un nombre (secondes/minutes selon `unit`) ou du
|
* `duration` peut être un nombre (secondes/minutes selon `unit`) ou du
|
||||||
* texte libre façon "2-3 minutes" (Cooklang autorise les plages en texte
|
* 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
|
* libre) — dans ce cas on prend le premier nombre trouvé, ce qui donne une
|
||||||
* estimation basse ; ajustable ensuite via circular-timer-card (tap =
|
* estimation basse ; ajustable ensuite en relançant le bouton "Démarrer"
|
||||||
* toggle, double-tap = annuler, appui long = plus d'infos) ou en relançant
|
* de l'étape (pas d'édition de durée à la volée sur circular-timer-card).
|
||||||
* timer.start avec une autre durée.
|
|
||||||
*/
|
*/
|
||||||
static _parseDurationSeconds(duration, unit) {
|
static _parseDurationSeconds(duration, unit) {
|
||||||
let value = null;
|
let value = null;
|
||||||
|
|
@ -280,7 +365,6 @@ class CookCliRecipeViewStrategy extends HTMLElement {
|
||||||
if (unitHint.includes("sec") || unitHint === "s") {
|
if (unitHint.includes("sec") || unitHint === "s") {
|
||||||
return Math.round(value);
|
return Math.round(value);
|
||||||
}
|
}
|
||||||
// Par défaut (minutes, ou pas d'unité reconnue) : on suppose des minutes.
|
|
||||||
return Math.round(value * 60);
|
return Math.round(value * 60);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -302,4 +386,4 @@ window.customStrategies.push({
|
||||||
name: "CookCLI",
|
name: "CookCLI",
|
||||||
description: "Dashboard de recettes CookCLI : liste + une vue par recette.",
|
description: "Dashboard de recettes CookCLI : liste + une vue par recette.",
|
||||||
documentationURL: "https://git.thethomaas.net/TheThomaas/ha-cookcli",
|
documentationURL: "https://git.thethomaas.net/TheThomaas/ha-cookcli",
|
||||||
});
|
});
|
||||||
Reference in a new issue