Switch to dashboard strategy from view strategy

This commit is contained in:
TheThomaas 2026-09-15 14:12:45 +02:00
parent ef218a94af
commit 95cb2f48ad
3 changed files with 144 additions and 45 deletions

View file

@ -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 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 "todo_entity_id" pour que le frontend puisse l'afficher avec la carte
native `todo-list`. 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 from __future__ import annotations
import re
from urllib.parse import quote from urllib.parse import quote
import voluptuous as vol 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='/')}" 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]: def _ingredient_summaries(ingredients: list[dict]) -> list[str]:
"""Formate chaque ingrédient en une ligne lisible pour la checklist.""" """Formate chaque ingrédient en une ligne lisible pour la checklist."""
summaries = [] summaries = []
@ -85,6 +105,7 @@ async def ws_list_recipes(
recipes.append( recipes.append(
{ {
"path": r.path, "path": r.path,
"view_path": _view_path(r.path),
"name": r.name, "name": r.name,
"time": metadata.get("time"), "time": metadata.get("time"),
"servings": metadata.get("servings"), "servings": metadata.get("servings"),

View file

@ -1,26 +1,40 @@
/** /**
* Carte Lovelace pour l'intégration CookCLI — liste navigable des recettes. * 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 * Le détail d'une recette est une VUE DISTINCTE par recette (path = le
* ligne navigue vers une vue générée par cookcli-recipe-strategy.js (voir ce * "view_path" renvoyé par le backend), générée par une dashboard strategy
* fichier), qui affiche étapes/ingrédients/minuteur via des cartes natives * (voir cookcli-recipe-strategy.js). C'est nécessaire : une view strategy ne
* et des cartes tierces (tabdeck-card, simple-timer-card). * 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 : * Installation :
* 1. Copie ce fichier dans config/www/cookcli-card.js * 1. Copie ce fichier dans config/www/cookcli-card.js
* 2. Paramètres -> Tableaux de bord -> menu ⋮ -> Ressources -> Ajouter : * 2. Paramètres -> Tableaux de bord -> menu ⋮ -> Ressources -> Ajouter :
* URL "/local/cookcli-card.js", type "Module JavaScript" * 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 * type: custom:cookcli-card
* title: Mes recettes * 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 { class CookCliCard extends HTMLElement {
static getStubConfig() { static getStubConfig() {
return { title: "Recettes", detail_view_path: "recette" }; return { title: "Recettes" };
} }
setConfig(config) { setConfig(config) {
@ -64,12 +78,13 @@ class CookCliCard extends HTMLElement {
} }
} }
_navigateToRecipe(path) { _navigateToView(viewPath) {
const detailPath = this._config.detail_view_path || "recette"; const dashboardRoot = this._config.dashboard_path
// Racine du dashboard courant, ex: "/lovelace-cookcli" depuis ? `/${this._config.dashboard_path.replace(/^\/+|\/+$/g, "")}`
// "/lovelace-cookcli/recettes" — on y accroche la vue de détail. // Racine du dashboard courant, ex: "/lovelace-cookcli" depuis
const dashboardRoot = window.location.pathname.split("/").slice(0, 2).join("/"); // "/lovelace-cookcli/recettes" — on y accroche la vue de la recette.
const url = `${dashboardRoot}/${detailPath}?path=${encodeURIComponent(path)}`; : window.location.pathname.split("/").slice(0, 2).join("/");
const url = `${dashboardRoot}/${viewPath}`;
// Navigation interne HA, sans recharger la page (comme un tap_action navigate). // Navigation interne HA, sans recharger la page (comme un tap_action navigate).
window.history.pushState(null, "", url); window.history.pushState(null, "", url);
@ -77,9 +92,9 @@ class CookCliCard extends HTMLElement {
} }
_onClick(event) { _onClick(event) {
const itemEl = event.target.closest("[data-path]"); const itemEl = event.target.closest("[data-view-path]");
if (itemEl) { if (itemEl) {
this._navigateToRecipe(itemEl.dataset.path); this._navigateToView(itemEl.dataset.viewPath);
} }
} }
@ -109,7 +124,7 @@ class CookCliCard extends HTMLElement {
? `<img class="recipe-thumb" src="${this._escape(r.image_url)}" alt="" loading="lazy" onerror="this.style.display='none'">` ? `<img class="recipe-thumb" src="${this._escape(r.image_url)}" alt="" loading="lazy" onerror="this.style.display='none'">`
: `<div class="recipe-thumb recipe-thumb-placeholder"></div>`; : `<div class="recipe-thumb recipe-thumb-placeholder"></div>`;
return ` return `
<div class="recipe-row" data-path="${this._escape(r.path)}"> <div class="recipe-row" data-view-path="${this._escape(r.view_path)}">
${thumb} ${thumb}
<div class="recipe-row-text"> <div class="recipe-row-text">
<div class="recipe-name">${this._escape(r.name)}</div> <div class="recipe-name">${this._escape(r.name)}</div>

View file

@ -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 * IMPORTANT : la view strategy seule ne suffit pas pour une navigation
* son propre état de navigation : elle génère la config d'UNE vue HA * fluide. Une view strategy régénère son contenu au MONTAGE de la vue, pas à
* (titre + liste de cartes) à partir de `config` (donné en YAML) et de * chaque changement d'URL — avec une seule vue partagée "recette?path=..."
* `hass`, au moment où la vue est ouverte. Voir : * pour toutes les recettes, passer de l'une à l'autre sans recharger la
* https://developers.home-assistant.io/docs/frontend/custom-ui/custom-strategy/#views * 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
* Le chemin de la recette à afficher (`path`) est lu soit dans `config` * recette (un vrai `path` HA différent pour chacune), ce qui force un
* (fixe, une vue par recette), soit — cas normal ici — dans le paramètre * remontage — donc une régénération — à chaque navigation.
* 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`.
* *
* 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 côté backend) * - todo-list (native) : checklist des ingrédients (todo.py backend)
* - button (native) : démarre le minuteur partagé avec la bonne durée * - button (native) : démarre le minuteur avec la bonne durée
* - custom:simple-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 : une tab par étape
* - custom:cookcli-card : la liste (voir cookcli-card.js)
* *
* Configuration de la vue (YAML) : * Configuration du tableau de bord (remplace le contenu YAML du dashboard) :
* views: * strategy:
* - path: recette * type: custom:cookcli
* strategy: * title: Recettes
* type: custom:cookcli-recipe * timer_entity: timer.recette_en_cours # créé manuellement, voir README
* timer_entity: timer.cookcli # créé manuellement, voir README * entry_id: xxxx # optionnel, si plusieurs serveurs CookCLI
* 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 { class CookCliRecipeViewStrategy extends HTMLElement {
static async generate(config, hass) { static async generate(config, hass) {
config = config || {}; config = config || {};
const params = new URLSearchParams(window.location.search); const path = config.path;
const path = config.path || params.get("path");
if (!path) { if (!path) {
return { return {
@ -202,8 +254,9 @@ 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 les boutons +/- de * estimation basse ; ajustable ensuite via circular-timer-card (tap =
* simple-timer-card ou l'éditeur de durée intégré. * toggle, double-tap = annuler, appui long = plus d'infos) ou en relançant
* timer.start avec une autre durée.
*/ */
static _parseDurationSeconds(duration, unit) { static _parseDurationSeconds(duration, unit) {
let value = null; 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); 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",
});