diff --git a/custom_components/cookcli/__init__.py b/custom_components/cookcli/__init__.py index 72126df..bcb1b19 100644 --- a/custom_components/cookcli/__init__.py +++ b/custom_components/cookcli/__init__.py @@ -3,10 +3,13 @@ from __future__ import annotations import logging +from pathlib import Path +from homeassistant.components.http import StaticPathConfig from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from .api import CookCliApiClient, CookCliApiError from .const import CONF_HOST, CONF_PORT, DOMAIN, PLATFORMS @@ -38,13 +41,30 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: "coordinator": coordinator, } - # Les commandes websocket et la vue de proxy d'images sont globales au - # domaine, on ne les enregistre qu'une seule fois même si plusieurs - # serveurs CookCLI sont configurés. + # Les commandes websocket, la vue de proxy d'images et les chemins + # statiques sont globaux au domaine : on ne les enregistre qu'une + # seule fois, même si plusieurs serveurs CookCLI sont configurés. if len(hass.data[DOMAIN]) == 1: async_setup_websocket_api(hass) hass.http.register_view(CookCliImageView(hass)) + www_path = Path(__file__).parent / "www" + card_file = www_path / "cookcli-card.js" + strategy_file = www_path / "cookcli-recipe-strategy.js" + + await hass.http.async_register_static_paths([ + StaticPathConfig( + f"/api/{DOMAIN}/cookcli-card.js", + str(card_file), + cache_headers=False, + ), + StaticPathConfig( + f"/api/{DOMAIN}/cookcli-recipe-strategy.js", + str(strategy_file), + cache_headers=False, + ), + ]) + if PLATFORMS: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -53,6 +73,42 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +async def async_setup(hass: HomeAssistant, config) -> bool: + """Enregistrement global, appelé une seule fois au démarrage de HA.""" + + async def _register_card_resources(event=None): + """Enregistre la carte et la stratégie comme ressources Lovelace.""" + lovelace = hass.data.get("lovelace") + if not lovelace or not hasattr(lovelace, "resources"): + _LOGGER.warning( + "Lovelace resources indisponibles, carte CookCLI non enregistrée" + ) + return + + resources = lovelace.resources + base_url = f"/api/{DOMAIN}" + + urls_to_register = [ + f"{base_url}/cookcli-card.js", + f"{base_url}/cookcli-recipe-strategy.js", + ] + + existing_urls = {r.get("url", "") for r in resources.async_items()} + + for url in urls_to_register: + if url not in existing_urls: + await resources.async_create_item({ + "url": url, + "res_type": "module", + }) + _LOGGER.info("Ressource Lovelace CookCLI enregistrée: %s", url) + + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STARTED, _register_card_resources + ) + return True + + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Décharge une entrée de configuration CookCLI.""" unload_ok = True diff --git a/www/cookcli-card.js b/custom_components/cookcli/www/cookcli-card.js similarity index 65% rename from www/cookcli-card.js rename to custom_components/cookcli/www/cookcli-card.js index e581cf9..2f44bdd 100644 --- a/www/cookcli-card.js +++ b/custom_components/cookcli/www/cookcli-card.js @@ -42,6 +42,7 @@ class CookCliCard extends HTMLElement { this._recipes = null; this._loading = false; this._error = null; + this._lastFetch = 0; if (!this.shadowRoot) { this.attachShadow({ mode: "open" }); @@ -50,6 +51,20 @@ class CookCliCard extends HTMLElement { this._render(); } + /** + * Éditeur de configuration automatique intégré à Home Assistant. + * HA utilise ce schéma pour générer le formulaire visuel. + */ + static getConfigForm() { + return { + schema: [ + { name: "title", selector: { text: {} }, label: "Titre" }, + { name: "dashboard_path", selector: { text: {} }, label: "Chemin du dashboard", helper: "Laisser vide pour le dashboard courant" }, + { name: "entry_id", selector: { text: {} }, label: "Entry ID", helper: "Optionnel — si plusieurs serveurs CookCLI" }, + ], + }; + } + set hass(hass) { this._hass = hass; if (this._recipes === null && !this._loading) { @@ -57,6 +72,21 @@ class CookCliCard extends HTMLElement { } } + /** + * Vérifie si les signatures d'images risquent d'avoir expiré. + * Si le dernier chargement date de plus de 45 minutes, on relance une requête propre. + */ + _checkAndRefresh() { + if (this._loading || !this._recipes) return; + + const now = Date.now(); + const fortyFiveMinutes = 45 * 60 * 1000; + + if (now - this._lastFetch > fortyFiveMinutes) { + this._fetchRecipes(); + } + } + getCardSize() { return 6; } @@ -70,6 +100,7 @@ class CookCliCard extends HTMLElement { if (this._config.entry_id) msg.entry_id = this._config.entry_id; const result = await this._hass.connection.sendMessagePromise(msg); this._recipes = result.recipes || []; + this._lastFetch = Date.now(); } catch (err) { this._error = (err && err.message) || "Erreur inconnue"; } finally { @@ -105,28 +136,28 @@ class CookCliCard extends HTMLElement { } _renderList() { - if (this._loading) { + if (this._loading && !this._recipes) { return `
Chargement…
`; } - if (this._error) { + if (this._error && !this._recipes) { return `
${this._escape(this._error)}
`; } if (!this._recipes || this._recipes.length === 0) { return `
Aucune recette trouvée.
`; } - const rows = this._recipes + const cards = this._recipes .map((r) => { const meta = []; if (r.time) meta.push(`⏱ ${this._escape(r.time)}`); if (r.servings) meta.push(`🍽 ${this._escape(String(r.servings))}`); const thumb = r.image_url - ? `` + ? `` : `
`; return ` -
+
${thumb} -
+
${this._escape(r.name)}
${meta.join("")}
@@ -134,7 +165,7 @@ class CookCliCard extends HTMLElement { }) .join(""); - return `
${rows}
`; + return `
${cards}
`; } _render() { @@ -153,27 +184,65 @@ class CookCliCard extends HTMLElement { .card-content { padding: 0 16px 16px; } .state-msg { padding: 16px 0; color: var(--secondary-text-color); } .state-msg.error { color: var(--error-color, #db4437); } - .recipe-list { display: flex; flex-direction: column; } - .recipe-row { - display: flex; align-items: center; gap: 12px; - padding: 10px 4px; border-bottom: 1px solid var(--divider-color); + + /* Grille responsive : 1 colonne par défaut (mobile), + puis 2 / 3 / 4 colonnes selon la largeur disponible. */ + .recipe-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(225px, 1fr)); + gap: 12px; + } + + .recipe-card { + display: flex; + flex-direction: column; + background: var(--card-background-color); + border-radius: 12px; + overflow: hidden; cursor: pointer; + border: 1px solid var(--divider-color); + transition: transform 0.15s ease, box-shadow 0.15s ease; } - .recipe-row:last-child { border-bottom: none; } - .recipe-row:hover { background: var(--secondary-background-color); } + .recipe-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + } + .recipe-card:hover .recipe-name { + color: var(--primary-color); + } + .recipe-thumb { - width: 56px; height: 56px; border-radius: 8px; object-fit: cover; - flex-shrink: 0; background: var(--secondary-background-color); + width: 100%; + aspect-ratio: 16 / 10; + object-fit: cover; + display: block; + background: var(--secondary-background-color); + flex-shrink: 0; + } + .recipe-thumb-placeholder { + background: var(--divider-color); + } + + .recipe-card-text { + padding: 10px 12px 12px; + min-width: 0; } - .recipe-thumb-placeholder { background: var(--divider-color); } - .recipe-row-text { min-width: 0; } .recipe-name { - font-weight: 500; color: var(--primary-text-color); - overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + font-weight: 500; + color: var(--primary-text-color); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-bottom: 4px; } .recipe-meta { - display: flex; gap: 12px; font-size: 0.85em; - color: var(--secondary-text-color); white-space: nowrap; + display: flex; + gap: 12px; + font-size: 0.85em; + color: var(--secondary-text-color); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } `; } @@ -185,5 +254,6 @@ window.customCards = window.customCards || []; window.customCards.push({ type: "cookcli-card", name: "CookCLI Recipes", + preview: false, description: "Liste navigable de tes recettes CookCLI.", }); diff --git a/custom_components/cookcli/www/cookcli-recipe-strategy.js b/custom_components/cookcli/www/cookcli-recipe-strategy.js new file mode 100644 index 0000000..12d51b8 --- /dev/null +++ b/custom_components/cookcli/www/cookcli-recipe-strategy.js @@ -0,0 +1,492 @@ +/** + * 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. + * + * 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 + * 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) : Commencer / Précédent / Suivant / Démarrer minuteur + * - custom:circular-timer-card : affichage/contrôle du minuteur partagé + * - 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) + * + * 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 + * step_entity: input_number.recette_etape # 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 getConfigElement() { + return document.createElement("cookcli-strategy-editor"); + } + + static configRequired = true; + + 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. + } + + const listView = { + title: config.title || "Recettes", + path: "recettes", + panel: true, + 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, + panel: true, + strategy: { + type: "custom:cookcli-recipe", + path: recipe.path, + timer_entity: config.timer_entity, + step_entity: config.step_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, sous forme + * d'une unique carte tabdeck-card (Résumé + une tab par étape). + */ +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}`, + }, + ], + }; + } + + // Repart de "Résumé" à chaque nouvelle recette ouverte, plutôt que de + // 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.step_entity) { + await this._setStepIndex(hass, config.step_entity, 0); + } + + // Étapes à plat, avec leur index global (1-based ; 0 est réservé au + // "Résumé") pour piloter remember_entity. + const flatSteps = []; + for (const section of recipe.sections || []) { + for (const step of section.steps || []) { + flatSteps.push({ section, step }); + } + } + + const tabs = [this._summaryTab(recipe, config, flatSteps.length)]; + flatSteps.forEach(({ section, step }, i) => { + 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", + panel: true, + default_tab: 0, + tabs, + }; + if (config.step_entity) { + tabdeckCard.remember = "entity"; + tabdeckCard.remember_entity = config.step_entity; + } + + return { + title: recipe.title || "Recette", + panel: true, + cards: [tabdeckCard], + }; + } + + 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 = ""; + //if (recipe.image_url) content += `![image de la recette](${recipe.image_url})\n\n`; + if (recipe.image_url) { + const cleanImageUrl = recipe.image_url.replace(/([^:]\/)\/+/g, "\$1"); + content += `image de la recette 2\n\n`; + } + content += `## ${recipe.title || ""}\n`; + if (recipe.cookware && recipe.cookware.length) { + content += `\n**Ustensiles** : ${recipe.cookware.map((c) => c.name).join(", ")}\n`; + } + + const cards = [{ + type: "markdown", + content, + card_mod: ` + style: | + img { + width: 100%; + aspect-ratio: 16 / 10; + object-fit: cover; + border-radius: 12px; + margin-bottom: 8px; + } + ` + }]; + + const rightCards = []; + + if (recipe.todo_entity_id) { + rightCards.push({ + type: "todo-list", + entity: recipe.todo_entity_id, + title: "Ingrédients", + card_mod: { + style: ` + ha-list .header { + display: none; + } + ` + } + }); + } + + if (config.step_entity && stepCount > 0) { + rightCards.push(this._navButton(config.step_entity, 1, "Commencer", "mdi:play")); + } + + cards.push({ type: "vertical-stack", cards: rightCards }); + + return { name: "Résumé", icon: "mdi:book-open-variant", card: { type: "horizontal-stack", cards } }; + } + + static _stepTab(section, step, tabIndex, isLast, config) { + const { markdown: stepMarkdown, timers } = this._renderStepMarkdown(step); + + const leftCards = []; + const stepIngredients = (step.items || []).filter((item) => item.type === "ingredient"); + if (stepIngredients.length) { + 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") }); + } + + if (config.timer_entity) { + for (const timer of timers) { + const seconds = this._parseDurationSeconds(timer.duration, timer.unit); + if (!seconds) continue; + const label = `${timer.duration ?? ""} ${timer.unit ?? ""}`.trim(); + leftCards.push({ + type: "button", + name: `Démarrer ${label}`, + icon: "mdi:timer-outline", + tap_action: { + action: "call-service", + service: "timer.start", + target: { entity_id: config.timer_entity }, + data: { duration: this._secondsToHms(seconds) }, + }, + }); + leftCards.push({ type: "custom:circular-timer-card", entity: config.timer_entity }); + } + } + + if (!leftCards.length) { + // horizontal-stack veut deux cartes ; une carte vide maintient le ratio. + leftCards.push({ type: "markdown", content: " " }); + } + + const columns = { + 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}`, + card: { type: "vertical-stack", cards }, + }; + } + + 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 en relançant le bouton "Démarrer" + * de l'étape (pas d'édition de durée à la volée sur circular-timer-card). + */ + 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); + } + 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(":"); + } +} + +class CookcliStrategyEditor extends HTMLElement { + setConfig(config) { + this._config = config; + } + + set hass(hass) { + this._hass = hass; + } + + // Appelé automatiquement quand l'élément est inséré dans le DOM + connectedCallback() { + this._render(); + } + + _render() { + console.log("Editor render appelé", this._config, this._hass); + + // On attend d'avoir hass avant de rendre : ha-input en a besoin + // pour son thème et ses styles. + if (!this._hass) { + this.innerHTML = `
Chargement…
`; + return; + } + + const c = this._config || {}; + + // Remplacer temporairement ha-textfield par des inputs natifs + this.innerHTML = ` +
+ + + + + + + +
+ `; + + // Écouteurs natifs (change au lieu de input pour éviter les re-rendus intempestifs) + this.querySelectorAll("[data-config-key]").forEach((champ) => { + champ.addEventListener("change", (ev) => { + const cle = ev.target.dataset.configKey; + const nouvelleConfig = { ...this._config, [cle]: ev.target.value }; + this._config = nouvelleConfig; + }); + }); + } +} + +customElements.define("ll-strategy-dashboard-cookcli", CookCliDashboardStrategy); +customElements.define("ll-strategy-view-cookcli-recipe", CookCliRecipeViewStrategy); +customElements.define("cookcli-strategy-editor", CookcliStrategyEditor); + +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", +}); \ No newline at end of file diff --git a/www/cookcli-recipe-strategy.js b/www/cookcli-recipe-strategy.js deleted file mode 100644 index e629a4d..0000000 --- a/www/cookcli-recipe-strategy.js +++ /dev/null @@ -1,305 +0,0 @@ -/** - * 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", -}); diff --git a/www/simple-cookcli-card.js b/www/simple-cookcli-card.js deleted file mode 100644 index 2bffef5..0000000 --- a/www/simple-cookcli-card.js +++ /dev/null @@ -1,307 +0,0 @@ -/** - * Carte Lovelace pour l'intégration CookCLI. - * - * Consomme les commandes websocket exposées par l'intégration : - * - cookcli/recipes -> liste (path, name, time, servings, tags) - * - cookcli/recipe -> détail (title, ingredients, cookware, sections) - * - * Limitation connue : les images de recettes ne sont pas affichées. Le - * serveur CookCLI n'est joignable depuis le navigateur ni sur son port - * ingress (restreint au Supervisor) ni sur son port direct (non exposé au - * LAN) — il faudrait un endpoint de proxy côté intégration pour les servir. - * Voir le README du projet pour plus de détails si tu veux l'ajouter. - * - * Installation : - * 1. Copie ce fichier dans config/www/simple-cookcli-card.js - * 2. Paramètres -> Tableaux de bord -> menu ⋮ -> Ressources -> Ajouter : - * URL "/local/simple-cookcli-card.js", type "Module JavaScript" - * 3. Ajoute une carte manuelle sur un tableau de bord : - * type: custom:simple-cookcli-card - * title: Mes recettes - */ - -class SimpleCookCliCard extends HTMLElement { - static getStubConfig() { - return { title: "Recettes" }; - } - - setConfig(config) { - this._config = config || {}; - this._view = "list"; - this._recipes = null; - this._selected = null; - this._loading = false; - this._error = null; - - if (!this.shadowRoot) { - this.attachShadow({ mode: "open" }); - this.shadowRoot.addEventListener("click", (event) => this._onClick(event)); - } - this._render(); - } - - set hass(hass) { - this._hass = hass; - if (this._recipes === null && !this._loading) { - this._fetchRecipes(); - } - } - - getCardSize() { - return 6; - } - - _wsMessage(extra) { - const msg = { ...extra }; - if (this._config.entry_id) { - msg.entry_id = this._config.entry_id; - } - return msg; - } - - async _fetchRecipes() { - this._loading = true; - this._error = null; - this._render(); - try { - const result = await this._hass.connection.sendMessagePromise( - this._wsMessage({ type: "cookcli/recipes" }) - ); - this._recipes = result.recipes || []; - } catch (err) { - this._error = (err && err.message) || "Erreur inconnue"; - } finally { - this._loading = false; - this._render(); - } - } - - async _openRecipe(path) { - this._loading = true; - this._error = null; - this._render(); - try { - const result = await this._hass.connection.sendMessagePromise( - this._wsMessage({ type: "cookcli/recipe", path }) - ); - this._selected = result; - this._view = "detail"; - } catch (err) { - this._error = (err && err.message) || "Erreur inconnue"; - } finally { - this._loading = false; - this._render(); - } - } - - _onClick(event) { - const backEl = event.target.closest("[data-action='back']"); - if (backEl) { - this._view = "list"; - this._selected = null; - this._error = null; - this._render(); - return; - } - const itemEl = event.target.closest("[data-path]"); - if (itemEl) { - this._openRecipe(itemEl.dataset.path); - } - } - - _escape(str) { - const div = document.createElement("div"); - div.textContent = str ?? ""; - return div.innerHTML; - } - - _formatQuantity(quantity) { - if (!quantity) return ""; - const value = quantity.value ?? ""; - const unit = quantity.unit ?? ""; - return `${value} ${unit}`.trim(); - } - - _renderStepItem(item) { - switch (item.type) { - case "text": - return this._escape(item.value); - case "ingredient": { - const qty = this._formatQuantity(item.quantity); - return `${this._escape(item.name)}${ - qty ? ` (${this._escape(qty)})` : "" - }`; - } - case "cookware": - return `${this._escape(item.name)}`; - case "timer": { - const duration = item.duration ?? ""; - const unit = item.unit ?? ""; - return `⏱ ${this._escape(String(duration))}${ - unit ? " " + this._escape(unit) : "" - }`; - } - default: - return ""; - } - } - - _renderList() { - if (this._loading) { - return `
Chargement…
`; - } - if (this._error) { - return `
${this._escape(this._error)}
`; - } - if (!this._recipes || this._recipes.length === 0) { - return `
Aucune recette trouvée.
`; - } - - const rows = this._recipes - .map((r) => { - const meta = []; - if (r.time) meta.push(`⏱ ${this._escape(r.time)}`); - if (r.servings) meta.push(`🍽 ${this._escape(String(r.servings))}`); - const thumb = r.image_url - ? `` - : `
`; - return ` -
- ${thumb} -
-
${this._escape(r.name)}
-
${meta.join("")}
-
-
`; - }) - .join(""); - - return `
${rows}
`; - } - - _renderDetail() { - if (this._loading) { - return `
Chargement…
`; - } - if (this._error) { - return ` - -
${this._escape(this._error)}
`; - } - if (!this._selected) return ""; - - const r = this._selected; - - const ingredients = (r.ingredients || []) - .map((i) => { - const qty = this._formatQuantity(i.quantity); - return `
  • ${qty ? `${this._escape(qty)} ` : ""}${this._escape( - i.name - )}
  • `; - }) - .join(""); - - const cookware = (r.cookware || []) - .map((c) => `
  • ${this._escape(c.name)}
  • `) - .join(""); - - const sections = (r.sections || []) - .map((section) => { - const steps = (section.steps || []) - .map( - (step) => - `
  • ${(step.items || []) - .map((item) => this._renderStepItem(item)) - .join("")}
  • ` - ) - .join(""); - return ` - ${section.name ? `

    ${this._escape(section.name)}

    ` : ""} -
      ${steps}
    `; - }) - .join(""); - - return ` - - ${ - r.image_url - ? `` - : "" - } -

    ${this._escape(r.title || "")}

    - ${cookware ? `

    Ustensiles

      ${cookware}
    ` : ""} -

    Ingrédients

    -
      ${ingredients}
    -

    Préparation

    - ${sections} - `; - } - - _render() { - if (!this.shadowRoot) return; - const title = this._config.title || "Recettes"; - this.shadowRoot.innerHTML = ` - - -
    - ${this._view === "list" ? this._renderList() : this._renderDetail()} -
    -
    - `; - } - - _styles() { - return ` - .card-content { padding: 0 16px 16px; } - .state-msg { padding: 16px 0; color: var(--secondary-text-color); } - .state-msg.error { color: var(--error-color, #db4437); } - .recipe-list { display: flex; flex-direction: column; } - .recipe-row { - display: flex; align-items: center; gap: 12px; - padding: 10px 4px; border-bottom: 1px solid var(--divider-color); - cursor: pointer; - } - .recipe-row:last-child { border-bottom: none; } - .recipe-row:hover { background: var(--secondary-background-color); } - .recipe-thumb { - width: 56px; height: 56px; border-radius: 8px; object-fit: cover; - flex-shrink: 0; background: var(--secondary-background-color); - } - .recipe-thumb-placeholder { background: var(--divider-color); } - .recipe-row-text { min-width: 0; } - .recipe-name { - font-weight: 500; color: var(--primary-text-color); - overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - } - .recipe-meta { - display: flex; gap: 12px; font-size: 0.85em; - color: var(--secondary-text-color); white-space: nowrap; - } - .recipe-hero { - width: 100%; max-height: 220px; object-fit: cover; - border-radius: 8px; margin: 4px 0 8px; display: block; - } - .back-btn { - background: none; border: none; color: var(--primary-color); - font-size: 0.95em; cursor: pointer; padding: 12px 0 4px; display: block; - } - h2 { margin: 4px 0 8px; color: var(--primary-text-color); } - h4 { margin: 16px 0 4px; color: var(--primary-text-color); } - ul, ol { margin: 4px 0; padding-left: 20px; color: var(--primary-text-color); } - ul.ingredients li, ul.cookware-list li { margin: 2px 0; } - ol.steps li { margin: 8px 0; line-height: 1.5; } - .ingredient { color: var(--primary-color); font-weight: 500; } - .cookware { font-style: italic; color: var(--secondary-text-color); } - .timer { color: var(--warning-color, #ff9800); font-weight: 500; } - `; - } -} - -customElements.define("simple-cookcli-card", SimpleCookCliCard); - -window.customCards = window.customCards || []; -window.customCards.push({ - type: "simple-cookcli-card", - name: "CookCLI Recipes", - description: "Liste navigable de tes recettes CookCLI, avec ingrédients et étapes.", -});