From 95cb2f48ad6253d0711803a6b5c67aa84fbbff8e Mon Sep 17 00:00:00 2001 From: TheThomaas Date: Tue, 15 Sep 2026 14:12:45 +0200 Subject: [PATCH] Switch to dashboard strategy from view strategy --- custom_components/cookcli/websocket_api.py | 21 ++++ www/cookcli-card.js | 49 ++++++--- www/cookcli-recipe-strategy.js | 119 ++++++++++++++++----- 3 files changed, 144 insertions(+), 45 deletions(-) diff --git a/custom_components/cookcli/websocket_api.py b/custom_components/cookcli/websocket_api.py index 6a3308a..ca499af 100644 --- a/custom_components/cookcli/websocket_api.py +++ b/custom_components/cookcli/websocket_api.py @@ -16,10 +16,16 @@ cookcli/recipe repeuple aussi l'entité todo d'ingrédients (todo.py) avec les ingrédients de la recette ouverte, et renvoie son entity_id dans "todo_entity_id" pour que le frontend puisse l'afficher avec la carte native `todo-list`. + +cookcli/recipes inclut "view_path" par recette : le slug utilisé comme path +de vue HA, calculé une seule fois ici (voir _view_path) pour que +cookcli-card.js et la dashboard strategy (cookcli-recipe-strategy.js) +naviguent vers le même identifiant sans dupliquer la logique de slug. """ from __future__ import annotations +import re from urllib.parse import quote import voluptuous as vol @@ -48,6 +54,20 @@ def _image_url(entry_id: str, image_ref: str | None) -> str | None: return f"/api/cookcli/image/{entry_id}/{quote(image_ref, safe='/')}" +def _view_path(recipe_path: str) -> str: + """Slug utilisé comme path de vue HA pour cette recette. + + Calculé une fois ici et renvoyé au frontend (dans "view_path"), pour que + cookcli-card.js et la dashboard strategy utilisent exactement le même + identifiant sans dupliquer cette logique en JS des deux côtés. + Ne gère pas les collisions entre deux recettes qui se slugifieraient + pareil (rare en pratique, mais à garder en tête). + """ + base = re.sub(r"\.cook$", "", recipe_path, flags=re.IGNORECASE) + slug = re.sub(r"[^a-zA-Z0-9]+", "-", base).strip("-").lower() + return slug or "recette" + + def _ingredient_summaries(ingredients: list[dict]) -> list[str]: """Formate chaque ingrédient en une ligne lisible pour la checklist.""" summaries = [] @@ -85,6 +105,7 @@ async def ws_list_recipes( recipes.append( { "path": r.path, + "view_path": _view_path(r.path), "name": r.name, "time": metadata.get("time"), "servings": metadata.get("servings"), diff --git a/www/cookcli-card.js b/www/cookcli-card.js index d538286..e581cf9 100644 --- a/www/cookcli-card.js +++ b/www/cookcli-card.js @@ -1,26 +1,40 @@ /** * Carte Lovelace pour l'intégration CookCLI — liste navigable des recettes. * - * Le détail d'une recette n'est plus géré par cette carte : un clic sur une - * ligne navigue vers une vue générée par cookcli-recipe-strategy.js (voir ce - * fichier), qui affiche étapes/ingrédients/minuteur via des cartes natives - * et des cartes tierces (tabdeck-card, simple-timer-card). + * Le détail d'une recette est une VUE DISTINCTE par recette (path = le + * "view_path" renvoyé par le backend), générée par une dashboard strategy + * (voir cookcli-recipe-strategy.js). C'est nécessaire : une view strategy ne + * régénère son contenu qu'au montage de la vue, pas à chaque changement de + * l'URL — avec une seule vue partagée + un paramètre `?path=`, il fallait + * recharger la page pour voir une autre recette. Une vraie vue par recette + * force un remontage à chaque navigation, donc ça marche en SPA normal. * - * Consomme cookcli/recipes (path, name, time, servings, tags, image_url). + * Consomme cookcli/recipes (path, view_path, name, time, servings, tags, + * image_url). * * Installation : * 1. Copie ce fichier dans config/www/cookcli-card.js * 2. Paramètres -> Tableaux de bord -> menu ⋮ -> Ressources -> Ajouter : * URL "/local/cookcli-card.js", type "Module JavaScript" - * 3. Ajoute une carte manuelle sur un tableau de bord : + * 3. Cette carte est normalement insérée automatiquement par la dashboard + * strategy cookcli-recipe-strategy.js — voir son README pour la config + * complète du tableau de bord. Utilisable aussi seule en carte manuelle, + * y compris sur un AUTRE tableau de bord que celui des recettes : * type: custom:cookcli-card * title: Mes recettes - * detail_view_path: recette # doit correspondre au "path" de la vue de détail + * dashboard_path: test-cuisine # optionnel — voir plus bas + * + * Par défaut, un clic navigue vers une vue du tableau de bord COURANT (celui + * où la carte est affichée). Si tu places cette carte sur un autre tableau + * de bord que celui qui contient les vues générées par la dashboard + * strategy (ex: carte sur /lovelace, vues sur /test-cuisine), précise + * `dashboard_path: test-cuisine` pour que la navigation cible le bon + * tableau de bord plutôt que celui où la carte est physiquement posée. */ class CookCliCard extends HTMLElement { static getStubConfig() { - return { title: "Recettes", detail_view_path: "recette" }; + return { title: "Recettes" }; } setConfig(config) { @@ -64,12 +78,13 @@ class CookCliCard extends HTMLElement { } } - _navigateToRecipe(path) { - const detailPath = this._config.detail_view_path || "recette"; - // Racine du dashboard courant, ex: "/lovelace-cookcli" depuis - // "/lovelace-cookcli/recettes" — on y accroche la vue de détail. - const dashboardRoot = window.location.pathname.split("/").slice(0, 2).join("/"); - const url = `${dashboardRoot}/${detailPath}?path=${encodeURIComponent(path)}`; + _navigateToView(viewPath) { + const dashboardRoot = this._config.dashboard_path + ? `/${this._config.dashboard_path.replace(/^\/+|\/+$/g, "")}` + // Racine du dashboard courant, ex: "/lovelace-cookcli" depuis + // "/lovelace-cookcli/recettes" — on y accroche la vue de la recette. + : window.location.pathname.split("/").slice(0, 2).join("/"); + const url = `${dashboardRoot}/${viewPath}`; // Navigation interne HA, sans recharger la page (comme un tap_action navigate). window.history.pushState(null, "", url); @@ -77,9 +92,9 @@ class CookCliCard extends HTMLElement { } _onClick(event) { - const itemEl = event.target.closest("[data-path]"); + const itemEl = event.target.closest("[data-view-path]"); if (itemEl) { - this._navigateToRecipe(itemEl.dataset.path); + this._navigateToView(itemEl.dataset.viewPath); } } @@ -109,7 +124,7 @@ class CookCliCard extends HTMLElement { ? `` : `
`; return ` -
+
${thumb}
${this._escape(r.name)}
diff --git a/www/cookcli-recipe-strategy.js b/www/cookcli-recipe-strategy.js index e4b0696..e629a4d 100644 --- a/www/cookcli-recipe-strategy.js +++ b/www/cookcli-recipe-strategy.js @@ -1,40 +1,92 @@ /** - * View strategy CookCLI : génère la vue de détail d'une recette. + * Strategies CookCLI pour Home Assistant : une dashboard strategy + une view + * strategy compagne. * - * Contrairement à une carte custom classique, une view strategy ne gère pas - * son propre état de navigation : elle génère la config d'UNE vue HA - * (titre + liste de cartes) à partir de `config` (donné en YAML) et de - * `hass`, au moment où la vue est ouverte. Voir : - * https://developers.home-assistant.io/docs/frontend/custom-ui/custom-strategy/#views - * - * Le chemin de la recette à afficher (`path`) est lu soit dans `config` - * (fixe, une vue par recette), soit — cas normal ici — dans le paramètre - * d'URL `?path=...` que cookcli-card.js pose avant de naviguer. Une view - * strategy tourne dans le navigateur, donc lire window.location est légitime - * même si HA ne le passe pas explicitement dans `config`. + * 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 côté backend) - * - button (native) : démarre le minuteur partagé avec la bonne durée - * - custom:simple-timer-card : affichage/contrôle du minuteur partagé - * - custom:tabdeck-card : une tab par étape + * - 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 de la vue (YAML) : - * views: - * - path: recette - * strategy: - * type: custom:cookcli-recipe - * timer_entity: timer.cookcli # créé manuellement, voir README - * entry_id: xxxx # optionnel, si plusieurs serveurs CookCLI + * 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 params = new URLSearchParams(window.location.search); - const path = config.path || params.get("path"); + const path = config.path; if (!path) { return { @@ -202,8 +254,9 @@ class CookCliRecipeViewStrategy extends HTMLElement { * `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 les boutons +/- de - * simple-timer-card ou l'éditeur de durée intégré. + * 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; @@ -239,4 +292,14 @@ class CookCliRecipeViewStrategy extends HTMLElement { } } +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", +});