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
"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"),

View file

@ -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";
_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 détail.
const dashboardRoot = window.location.pathname.split("/").slice(0, 2).join("/");
const url = `${dashboardRoot}/${detailPath}?path=${encodeURIComponent(path)}`;
// "/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 {
? `<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>`;
return `
<div class="recipe-row" data-path="${this._escape(r.path)}">
<div class="recipe-row" data-view-path="${this._escape(r.view_path)}">
${thumb}
<div class="recipe-row-text">
<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
* 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é
* - 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
* Configuration du tableau de bord (remplace le contenu YAML du dashboard) :
* strategy:
* type: custom:cookcli-recipe
* timer_entity: timer.cookcli # créé manuellement, voir README
* 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",
});