Compare commits

..

No commits in common. "main" and "0.0.5" have entirely different histories.
main ... 0.0.5

12 changed files with 244 additions and 1000 deletions

View file

@ -11,6 +11,16 @@ Cette intégration fonctionne main dans la main avec une **stratégie de tableau
- 🥕 **Vue détaillée** de chaque recette : ingrédients et étapes de préparation
- 🧭 **Carte de navigation** : liste toutes les recettes et permet de naviguer vers la page de détail
## 🧩 Architecture
Le projet est composé de **trois dépôts** qui travaillent ensemble :
| Composant | Rôle | Dépôt |
|---|---|---|
| **Intégration** | Se connecte au serveur CookCLI et expose les recettes à Home Assistant | [ha-cookcli](https://git.thethomaas.net/TheThomaas/ha-cookcli) |
| **Stratégie de tableau de bord** | Crée automatiquement une page par recette (ingrédients + étapes) | [ha-cookcli-strategy](https://git.thethomaas.net/TheThomaas/ha-cookcli-strategy) |
| **Carte personnalisée** | Liste toutes les recettes et permet de naviguer vers le détail d'une recette | [ha-cookcli-card](https://git.thethomaas.net/TheThomaas/ha-cookcli-card) |
## 📦 Installation
### Prérequis
@ -37,6 +47,13 @@ Cette intégration fonctionne main dans la main avec une **stratégie de tableau
2. Copiez le dossier `custom_components/cookcli` dans le répertoire `config/custom_components/` de votre instance Home Assistant.
3. **Redémarrez Home Assistant.**
### Installation de la carte et de la stratégie
Répétez l'opération via HACS (catégorie **Lovelace** / **Dashboard**) pour :
- [ha-cookcli-card](https://git.thethomaas.net/TheThomaas/ha-cookcli-card) — la carte
- [ha-cookcli-strategy](https://git.thethomaas.net/TheThomaas/ha-cookcli-strategy) — la stratégie de tableau de bord
## ⚙️ Configuration
1. Dans Home Assistant, allez dans **Paramètres** → **Appareils et services**.
@ -53,14 +70,6 @@ Cette intégration fonctionne main dans la main avec une **stratégie de tableau
Ajoutez la carte `cookcli-card` à un tableau de bord pour afficher la liste de toutes vos recettes. Cliquer sur une recette vous amène vers sa page de détail.
Options disponibles :
- **title** : Titre de la carte. Par défaut : « Recettes ».
- **tag** : N'afficher que les recettes portant ce tag. Vide = toutes les recettes.
- **show_tag_filter** : Affiche des pills cliquables en haut de la liste pour filtrer par tag.
- **dashboard_path** : Seulement si la carte n'est pas sur le dashboard qui contient les vues recettes.
- **entry_id** : Seulement si tu as plusieurs serveurs CookCLI. Vide = le serveur par défaut.
Exemple (YAML) :
```yaml
@ -68,6 +77,8 @@ type: custom:cookcli-card
title: Mes recettes
```
> Adaptez la configuration selon les options exposées par la carte. Consultez le [README de ha-cookcli-card](https://git.thethomaas.net/TheThomaas/ha-cookcli-card) pour la liste complète des options.
### Stratégie de tableau de bord
La stratégie `cookcli-recipe-strategy` génère automatiquement un tableau de bord avec **une page par recette**. Chaque page affiche :
@ -75,13 +86,6 @@ La stratégie `cookcli-recipe-strategy` génère automatiquement un tableau de b
- la liste des **ingrédients**,
- les **étapes** de préparation.
Options disponibles :
- **title** : Titre de la liste et du dashboard. Par défaut : « Recettes ».
- **timer_entity** : Aide « Minuteur » partagée entre toutes les recettes (ex. `timer.recette_en_cours`). Vide = pas de boutons de minuteur.
- **step_entity** : Aide « Nombre » qui pilote l'onglet actif (ex. `input_number.recette_etape`). Vide = pas de boutons Commencer / Précédent / Suivant.
- **entry_id** : Seulement si tu as plusieurs serveurs CookCLI. Vide = le serveur par défaut.
Pour l'utiliser, créez un nouveau tableau de bord et sélectionnez la stratégie CookCLI, ou référencez-la dans votre configuration de tableau de bord.
## 📄 Licence
@ -90,6 +94,9 @@ Ce projet est distribué sous licence présente dans le fichier [LICENSE](./LICE
## 🔗 Liens
- **Intégration** : https://git.thethomaas.net/TheThomaas/ha-cookcli
- **Stratégie** : https://git.thethomaas.net/TheThomaas/ha-cookcli-strategy
- **Carte** : https://git.thethomaas.net/TheThomaas/ha-cookcli-card
- **CookCLI** : https://github.com/cooklang/CookCLI
- **Cooklang** : https://cooklang.org/

View file

@ -4,18 +4,15 @@ from __future__ import annotations
import logging
from pathlib import Path
import voluptuous as vol
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.components.http import StaticPathConfig
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, ServiceCall
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
# from .const import CONF_HOST, CONF_PORT, DOMAIN, PLATFORMS
from .const import CONF_HOST, CONF_PORT, DOMAIN, PLATFORMS
from .coordinator import CookCliCoordinator
from .image_proxy import CookCliImageView
from .websocket_api import async_setup_websocket_api
@ -68,8 +65,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
),
])
# if PLATFORMS:
# await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
if PLATFORMS:
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
@ -109,43 +106,14 @@ async def async_setup(hass: HomeAssistant, config) -> bool:
hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STARTED, _register_card_resources
)
async def _handle_reload(call: ServiceCall) -> None:
"""cookcli.reload : resynchronise l'add-on puis rafraîchit la liste."""
entries = hass.data.get(DOMAIN, {})
requested = call.data.get("entry_id")
if requested is not None:
if requested not in entries:
raise ServiceValidationError(f"Serveur CookCLI inconnu : {requested}")
targets = {requested: entries[requested]}
else:
targets = dict(entries)
for entry_id, data in targets.items():
try:
await data["client"].async_reload()
except CookCliApiError as err:
raise HomeAssistantError(f"Reload CookCLI impossible : {err}") from err
await data["coordinator"].async_refresh()
# Les cartes ouvertes écoutent cet événement pour recharger leur liste.
hass.bus.async_fire(f"{DOMAIN}_reloaded", {"entry_id": entry_id})
hass.services.async_register(
DOMAIN,
"reload",
_handle_reload,
schema=vol.Schema({vol.Optional("entry_id"): str}),
)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Décharge une entrée de configuration CookCLI."""
unload_ok = True
# if PLATFORMS:
# unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if PLATFORMS:
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)

View file

@ -58,14 +58,12 @@ from contextlib import asynccontextmanager
from dataclasses import dataclass, field
from typing import Any
from .const import RELOAD_PORT
import aiohttp
_LOGGER = logging.getLogger(__name__)
DEFAULT_TIMEOUT = aiohttp.ClientTimeout(total=10)
RELOAD_TIMEOUT = aiohttp.ClientTimeout(total=120)
class CookCliApiError(Exception):
"""Erreur générique lors d'un appel à l'API CookCLI."""
@ -94,7 +92,6 @@ class CookCliApiClient:
def __init__(self, session: aiohttp.ClientSession, host: str, port: int) -> None:
self._session = session
self._base_url = f"http://{host}:{port}/api"
self._reload_url = f"http://{host}:{RELOAD_PORT}/reload"
async def async_test_connection(self) -> None:
"""Vérifie que le serveur répond, utilisé par le config_flow."""
@ -116,10 +113,6 @@ class CookCliApiClient:
data = await self._request_json(f"/recipes/{path}")
return _resolve_recipe(data)
async def async_reload(self) -> None:
async with self._get(self._reload_url, method="POST", timeout=RELOAD_TIMEOUT):
pass
async def async_get_image(self, image_ref: str) -> tuple[bytes, str | None]:
"""Récupère les octets d'une image statique CookCLI.
@ -150,16 +143,13 @@ class CookCliApiClient:
return f"/static/{image_ref}"
@asynccontextmanager
async def _get(
self,
endpoint: str,
method: str = "GET",
timeout: aiohttp.ClientTimeout = DEFAULT_TIMEOUT,
):
"""Contexte partagé pour un appel vers CookCLI, avec gestion d'erreurs."""
url = endpoint if endpoint.startswith("http") else f"{self._base_url}{endpoint}"
async def _get(self, endpoint: str):
"""Contexte partagé pour un GET vers CookCLI, avec gestion d'erreurs."""
url = f"{self._base_url}{endpoint}"
try:
async with self._session.request(method, url, timeout=timeout) as resp:
async with self._session.request(
"GET", url, timeout=DEFAULT_TIMEOUT
) as resp:
if resp.status >= 400:
raise CookCliResponseError(
f"CookCLI a répondu {resp.status} pour {url}"

View file

@ -6,7 +6,6 @@ CONF_HOST = "host"
CONF_PORT = "port"
DEFAULT_PORT = 9081
RELOAD_PORT = 8098
# Nom du service tel que poussé par bashio::discovery.set côté add-on
HASSIO_DISCOVERY_SLUG = "cookcli"
@ -15,3 +14,5 @@ HASSIO_DISCOVERY_SLUG = "cookcli"
# Les fichiers .cook ne changent qu'après un sync git côté add-on,
# donc pas besoin de poller souvent.
UPDATE_INTERVAL_MINUTES = 15
PLATFORMS: list[str] = ["todo"]

View file

@ -1,7 +0,0 @@
reload:
fields:
entry_id:
required: false
selector:
config_entry:
integration: cookcli

View file

@ -30,17 +30,5 @@
}
}
}
},
"services": {
"reload": {
"name": "Recharger les recettes",
"description": "Resynchronise les recettes depuis le dépôt git de l'add-on.",
"fields": {
"entry_id": {
"name": "Serveur CookCLI",
"description": "Laisser vide pour recharger tous les serveurs."
}
}
}
}
}

View file

@ -0,0 +1,72 @@
"""Plateforme to-do : une checklist d'ingrédients par entrée CookCLI.
Une seule entité par serveur CookCLI configuré (pas une par recette) : elle
est repeuplée avec les ingrédients de la recette qu'on vient d'ouvrir à
chaque appel de `cookcli/recipe` (voir websocket_api.py), et affichée dans
la vue générée par la view strategy avec la carte native `todo-list`.
Seule la case à cocher est éditable par l'utilisateur (UPDATE_TODO_ITEM) —
pas d'ajout/suppression manuel, la liste est entièrement dérivée de la
recette ouverte.
"""
from __future__ import annotations
import logging
from homeassistant.components.todo import (
TodoItem,
TodoItemStatus,
TodoListEntity,
TodoListEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Crée l'entité todo pour cette entrée de configuration."""
entity = CookCliIngredientsTodoListEntity(entry)
hass.data[DOMAIN][entry.entry_id]["todo_entity"] = entity
async_add_entities([entity])
class CookCliIngredientsTodoListEntity(TodoListEntity):
"""Checklist des ingrédients de la recette actuellement affichée."""
_attr_has_entity_name = True
_attr_name = "Ingrédients à rassembler"
_attr_supported_features = TodoListEntityFeature.UPDATE_TODO_ITEM
def __init__(self, entry: ConfigEntry) -> None:
self._attr_unique_id = f"{entry.entry_id}_ingredients"
self._attr_todo_items: list[TodoItem] = []
async def async_update_todo_item(self, item: TodoItem) -> None:
"""Coche/décoche un ingrédient — seule modification permise ici."""
items = list(self._attr_todo_items or [])
for index, existing in enumerate(items):
if existing.uid == item.uid:
items[index] = item
break
self._attr_todo_items = items
self.async_write_ha_state()
def set_ingredients(self, summaries: list[str]) -> None:
"""Remplace toute la liste par les ingrédients de la recette ouverte.
Appelé depuis websocket_api.py à chaque `cookcli/recipe` — pas partie
de l'API TodoListEntity standard, c'est notre propre point d'entrée.
"""
self._attr_todo_items = [
TodoItem(uid=str(i), summary=summary, status=TodoItemStatus.NEEDS_ACTION)
for i, summary in enumerate(summaries)
]
self.async_write_ha_state()

View file

@ -30,17 +30,5 @@
}
}
}
},
"services": {
"reload": {
"name": "Reload recipes",
"description": "Sync recipes from git repository in the addon.",
"fields": {
"entry_id": {
"name": "CookCLI Server",
"description": "Leave empty to reload all servers."
}
}
}
}
}

View file

@ -30,17 +30,5 @@
}
}
}
},
"services": {
"reload": {
"name": "Recharger les recettes",
"description": "Resynchronise les recettes depuis le dépôt git de l'add-on.",
"fields": {
"entry_id": {
"name": "Serveur CookCLI",
"description": "Laisser vide pour recharger tous les serveurs."
}
}
}
}
}

View file

@ -12,6 +12,11 @@ un <img src="...">, qui pointe vers CookCliImageView (voir image_proxy.py) —
le frontend n'a pas besoin de connaître le format brut des chemins d'image
renvoyés par CookCLI.
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)
@ -138,6 +143,11 @@ async def ws_get_recipe(
recipe["image_url"] = _image_url(hass, entry_id, recipe.get("image"))
todo_entity = entry_data.get("todo_entity")
if todo_entity is not None:
todo_entity.set_ingredients(_ingredient_summaries(recipe.get("ingredients", [])))
recipe["todo_entity_id"] = todo_entity.entity_id
connection.send_result(msg["id"], recipe)

View file

@ -43,43 +43,30 @@ class CookCliCard extends HTMLElement {
this._loading = false;
this._error = null;
this._lastFetch = 0;
this._selectedTag = null;
this._reloading = false;
this._reloadError = null;
if (!this.shadowRoot) {
this.attachShadow({ mode: "open" });
this.shadowRoot.addEventListener("click", (event) => this._onClick(event));
this.shadowRoot.addEventListener(
"wheel",
(event) => {
const bar = event.target.closest(".tag-filter");
if (!bar || Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return;
const max = bar.scrollWidth - bar.clientWidth;
const atStart = bar.scrollLeft <= 0 && event.deltaY < 0;
const atEnd = bar.scrollLeft >= max && event.deltaY > 0;
if (max <= 0 || atStart || atEnd) return; // laisse la page défiler
event.preventDefault();
bar.scrollLeft += event.deltaY;
},
{ passive: false }
);
}
this._render();
}
/**
* Éditeur visuel : élément dédié (voir CookCliCardEditor plus bas) plutôt
* que getConfigForm(), dont le schéma est statique et n'a pas accès à
* hass — or la liste des dashboards disponibles en dépend.
*/
static getConfigElement() {
return document.createElement("cookcli-card-editor");
* É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;
this._subscribe();
if (this._recipes === null && !this._loading) {
this._fetchRecipes();
}
@ -114,9 +101,6 @@ class CookCliCard extends HTMLElement {
const result = await this._hass.connection.sendMessagePromise(msg);
this._recipes = result.recipes || [];
this._lastFetch = Date.now();
if (this._selectedTag !== null && !this._allTags().includes(this._selectedTag)) {
this._selectedTag = null;
}
} catch (err) {
this._error = (err && err.message) || "Erreur inconnue";
} finally {
@ -139,23 +123,6 @@ class CookCliCard extends HTMLElement {
}
_onClick(event) {
if (event.target.closest("[data-action='reload']")) {
this._reload();
return;
}
if (event.target.closest("[data-tag-clear]")) {
this._selectedTag = null;
this._render();
return;
}
const tagEl = event.target.closest("[data-tag]");
if (tagEl) {
const tag = tagEl.dataset.tag;
// Un second clic sur la pill active revient à « Toutes ».
this._selectedTag = this._selectedTag === tag ? null : tag;
this._render();
return;
}
const itemEl = event.target.closest("[data-view-path]");
if (itemEl) {
this._navigateToView(itemEl.dataset.viewPath);
@ -168,113 +135,6 @@ class CookCliCard extends HTMLElement {
return div.innerHTML;
}
// Les tags peuvent arriver sous forme de tableau ou de chaîne "a, b".
_tagsOf(recipe) {
const tags = recipe.tags;
if (Array.isArray(tags)) return tags.map((t) => String(t).trim()).filter(Boolean);
if (typeof tags === "string") {
return tags.split(",").map((t) => t.trim()).filter(Boolean);
}
return [];
}
_allTags() {
const tags = new Set();
for (const r of this._recipes || []) {
for (const t of this._tagsOf(r)) tags.add(t);
}
return [...tags].sort((a, b) => a.localeCompare(b));
}
_tagFilterEnabled() {
// Les pills n'ont pas de sens quand un tag est déjà imposé par la config.
return this._config.show_tag_filter !== false && !this._config.tag;
}
_filteredRecipes() {
const fixedTag = this._config.tag;
if (fixedTag) {
return this._recipes.filter((r) => this._tagsOf(r).includes(fixedTag));
}
if (!this._tagFilterEnabled() || this._selectedTag === null) return this._recipes;
return this._recipes.filter((r) => this._tagsOf(r).includes(this._selectedTag));
}
connectedCallback() {
this._subscribe();
}
disconnectedCallback() {
if (this._unsub) {
this._unsub();
this._unsub = null;
}
}
async _subscribe() {
if (this._unsub || this._subscribing || !this._hass || !this.isConnected) return;
this._subscribing = true;
try {
this._unsub = await this._hass.connection.subscribeEvents(
(ev) => this._onReloaded(ev),
"cookcli_reloaded"
);
} catch (err) {
// Pas d'abonnement : le bouton retombe sur un rafraîchissement manuel.
} finally {
this._subscribing = false;
}
// La carte a pu être retirée du DOM pendant l'attente.
if (!this.isConnected && this._unsub) {
this._unsub();
this._unsub = null;
}
}
_onReloaded(ev) {
const target = this._config.entry_id;
if (target && ev.data && ev.data.entry_id !== target) return;
this._fetchRecipes();
}
async _reload() {
if (this._reloading || !this._hass) return;
this._reloading = true;
this._reloadError = null;
this._render();
try {
const data = {};
if (this._config.entry_id) data.entry_id = this._config.entry_id;
await this._hass.callService("cookcli", "reload", data);
// Normalement l'événement a déjà déclenché le rechargement ;
// on ne le refait à la main que si l'abonnement a échoué.
if (!this._unsub) await this._fetchRecipes();
} catch (err) {
this._reloadError = (err && err.message) || "Échec du rechargement";
} finally {
this._reloading = false;
this._render();
}
}
_renderTagFilter() {
if (!this._tagFilterEnabled()) return "";
const tags = this._allTags();
if (tags.length === 0) return "";
const allActive = this._selectedTag === null;
const pills = tags
.map((t) => {
const active = this._selectedTag === t;
return `<button type="button" class="tag-pill${active ? " active" : ""}" data-tag="${this._escape(t)}" aria-pressed="${active}">${this._escape(t)}</button>`;
})
.join("");
return `
<div class="tag-filter">
<button type="button" class="tag-pill${allActive ? " active" : ""}" data-tag-clear aria-pressed="${allActive}">Toutes</button>
${pills}
</div>`;
}
_renderList() {
if (this._loading && !this._recipes) {
return `<div class="state-msg">Chargement…</div>`;
@ -286,13 +146,7 @@ class CookCliCard extends HTMLElement {
return `<div class="state-msg">Aucune recette trouvée.</div>`;
}
const filtered = this._filteredRecipes();
const filterBar = this._renderTagFilter();
if (filtered.length === 0) {
return `${filterBar}<div class="state-msg">Aucune recette avec ce tag.</div>`;
}
const cards = filtered
const cards = this._recipes
.map((r) => {
const meta = [];
if (r.time) meta.push(`<span>⏱ ${this._escape(r.time)}</span>`);
@ -311,38 +165,18 @@ class CookCliCard extends HTMLElement {
})
.join("");
return `${filterBar}<div class="recipe-grid">${cards}</div>`;
return `<div class="recipe-grid">${cards}</div>`;
}
_render() {
if (!this.shadowRoot) return;
const title = this._config.title || "Recettes";
const previous = this.shadowRoot.querySelector(".tag-filter");
const scrollLeft = previous ? previous.scrollLeft : 0;
const errorMsg = this._reloadError
? `<div class="state-msg error reload-error">${this._escape(this._reloadError)}</div>`
: "";
this.shadowRoot.innerHTML = `
<style>${this._styles()}</style>
<ha-card>
<div class="card-header">
<span class="card-title">${this._escape(title)}</span>
<ha-icon-button
class="reload-btn ${this._reloading ? "spinning" : ""}"
data-action="reload"
label="Recharger les recettes"
${this._reloading ? "disabled" : ""}
>
<ha-icon icon="mdi:refresh"></ha-icon>
</ha-icon-button>
</div>
${errorMsg}
<ha-card header="${this._escape(title)}">
<div class="card-content">${this._renderList()}</div>
</ha-card>
`;
const next = this.shadowRoot.querySelector(".tag-filter");
if (next) next.scrollLeft = scrollLeft;
}
_styles() {
@ -355,7 +189,7 @@ class CookCliCard extends HTMLElement {
puis 2 / 3 / 4 colonnes selon la largeur disponible. */
.recipe-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(225px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(225px, 1fr));
gap: 12px;
}
@ -410,386 +244,11 @@ class CookCliCard extends HTMLElement {
overflow: hidden;
text-overflow: ellipsis;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 8px 8px 16px;
color: var(--ha-card-header-color, var(--primary-text-color));
font-family: var(--ha-card-header-font-family, inherit);
font-size: var(--ha-card-header-font-size, 24px);
line-height: 32px;
}
.reload-error { padding: 0 16px 8px; }
.reload-btn.spinning ha-icon { animation: cookcli-spin 1s linear infinite; }
@keyframes cookcli-spin { to { transform: rotate(360deg); } }
.tag-filter {
display: flex;
flex-wrap: nowrap;
gap: 8px;
margin-bottom: 12px;
overflow-x: auto;
/* Marge de fin pour que la dernière pill sorte de la zone de fondu. */
padding-right: 24px;
scrollbar-width: none;
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 24px), transparent);
mask-image: linear-gradient(to right, #000 calc(100% - 24px), transparent);
}
.tag-filter::-webkit-scrollbar {
display: none;
}
.tag-pill {
font: inherit;
font-size: 0.85em;
padding: 4px 12px;
border-radius: 999px;
border: 1px solid var(--divider-color);
background: var(--card-background-color);
color: var(--primary-text-color);
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
flex-shrink: 0;
white-space: nowrap;
}
.tag-pill:hover {
border-color: var(--primary-color);
}
.tag-pill.active {
background: var(--primary-color);
border-color: var(--primary-color);
color: var(--text-primary-color, #fff);
}
`;
}
}
/**
* Éditeur visuel de la carte (ha-form + sélecteurs natifs).
*
* "Chemin du dashboard" propose les dashboards Lovelace du serveur, lus dans
* hass.panels (accessible à tous les utilisateurs, contrairement à
* lovelace/dashboards/list qui demande les droits admin). custom_value reste
* activé pour pouvoir saisir un chemin qui n'apparaît pas dans la liste.
*/
const COOKCLI_CARD_EDITOR_FIELDS = ["title", "tag", "show_tag_filter", "dashboard_path", "entry_id"];
const COOKCLI_CARD_EDITOR_LABELS = {
title: "Titre",
tag: "Tag unique",
show_tag_filter: "Filtre par tags",
dashboard_path: "Dashboard des recettes",
entry_id: "Serveur CookCLI",
};
const COOKCLI_CARD_EDITOR_HELPERS = {
title: "Titre de la carte. Par défaut : « Recettes ».",
tag: "N'afficher que les recettes portant ce tag. Vide = toutes les recettes.",
show_tag_filter: "Affiche des pills cliquables en haut de la liste pour filtrer par tag. Sans effet si un tag unique est choisi.",
dashboard_path:
"Seulement si la carte n'est pas sur le dashboard qui contient les vues recettes. Vide = dashboard courant.",
entry_id:
"Seulement si tu as plusieurs serveurs CookCLI. Vide = le serveur par défaut.",
};
class CookCliCardEditor extends HTMLElement {
constructor() {
super();
this._config = {};
this._hass = undefined;
this._form = null;
this._optionsSignature = null;
this._tagOptions = [];
this._tagsLoadedFor = null;
}
setConfig(config) {
this._config = config || {};
this._maybeLoadTags();
this._update();
}
set hass(hass) {
this._hass = hass;
this._maybeLoadTags();
this._update();
}
connectedCallback() {
this._ensureForm();
this._update();
}
_dashboardOptions() {
const panels = (this._hass && this._hass.panels) || {};
return Object.values(panels)
.filter((p) => p.component_name === "lovelace" && p.url_path)
.map((p) => ({
value: p.url_path,
label: p.title ? `${p.title} (${p.url_path})` : p.url_path,
}))
.sort((a, b) => a.label.localeCompare(b.label));
}
_buildSchema(dashboardOptions, tagOptions) {
return [
{ name: "title", selector: { text: {} } },
{
name: "tag",
selector: { select: { options: tagOptions, custom_value: true, mode: "dropdown" } },
},
{ name: "show_tag_filter", selector: { boolean: {} } },
{
name: "dashboard_path",
selector: { select: { options: dashboardOptions, custom_value: true, mode: "dropdown" } },
},
{ name: "entry_id", selector: { config_entry: { integration: "cookcli" } } },
];
}
_ensureForm() {
if (this._form) return;
const form = document.createElement("ha-form");
const options = this._dashboardOptions();
this._optionsSignature = JSON.stringify([options, this._tagOptions]);
form.schema = this._buildSchema(options, this._tagOptions);
form.computeLabel = (schema) => COOKCLI_CARD_EDITOR_LABELS[schema.name] || schema.name;
form.computeHelper = (schema) => COOKCLI_CARD_EDITOR_HELPERS[schema.name] || "";
form.addEventListener("value-changed", (ev) => this._valueChanged(ev));
this.appendChild(form);
this._form = form;
}
_update() {
if (!this._form) return;
if (this._hass) {
this._form.hass = this._hass;
}
const options = this._dashboardOptions();
const signature = JSON.stringify([options, this._tagOptions]);
if (signature !== this._optionsSignature) {
this._optionsSignature = signature;
this._form.schema = this._buildSchema(options, this._tagOptions);
}
const data = {};
for (const name of COOKCLI_CARD_EDITOR_FIELDS) {
if (this._config[name] !== undefined) data[name] = this._config[name];
}
data.show_tag_filter = this._config.show_tag_filter !== false;
this._form.data = data;
}
_valueChanged(ev) {
ev.stopPropagation();
const value = ev.detail.value || {};
// Garde `type` et toute clé inconnue ; un champ vidé est supprimé.
const config = { ...this._config };
for (const name of COOKCLI_CARD_EDITOR_FIELDS) {
const v = value[name];
if (v === undefined || v === null || v === "") delete config[name];
else if (name === "show_tag_filter" && v === true) delete config[name];
else config[name] = v;
}
this._config = config;
this.dispatchEvent(
new CustomEvent("config-changed", {
detail: { config },
bubbles: true,
composed: true,
})
);
}
// Récupère la liste des tags existants pour le sélecteur. Rechargée si le
// serveur (entry_id) change ; l'échec laisse simplement la liste vide, la
// saisie libre restant possible.
_maybeLoadTags() {
if (!this._hass) return;
const key = this._config.entry_id || "";
if (this._tagsLoadedFor === key) return;
this._tagsLoadedFor = key;
const msg = { type: "cookcli/recipes" };
if (key) msg.entry_id = key;
this._hass.connection
.sendMessagePromise(msg)
.then((result) => {
const tags = new Set();
for (const r of result.recipes || []) {
const raw = r.tags;
const list = Array.isArray(raw) ? raw : typeof raw === "string" ? raw.split(",") : [];
for (const t of list) {
const s = String(t).trim();
if (s) tags.add(s);
}
}
this._tagOptions = [...tags]
.sort((a, b) => a.localeCompare(b))
.map((t) => ({ value: t, label: t }));
})
.catch(() => {
this._tagOptions = [];
})
.finally(() => this._update());
}
}
/**
* Checklist d'ingrédients d'une étape (cases à cocher, état local).
*
* L'état coché est stocké dans localStorage du navigateur, par recette et par
* étape (`storage_key`) : il n'est pas partagé entre appareils, ni lié à la
* todo du Résumé. Config :
* type: custom:cookcli-checklist-card
* storage_key: chocolat-chaud.cook:2
* items: [{ quantity: "200 ml", name: "lait" }, ...]
* title: Ingrédients # optionnel
*/
class CookCliChecklistCard extends HTMLElement {
setConfig(config) {
if (!config || !Array.isArray(config.items)) {
throw new Error("La carte a besoin d'une liste `items`.");
}
this._config = config;
this._checked = this._load();
if (!this.shadowRoot) {
this.attachShadow({ mode: "open" });
this.shadowRoot.addEventListener("change", (ev) => this._onChange(ev));
this.shadowRoot.addEventListener("click", (ev) => this._onClick(ev));
}
this._render();
}
getCardSize() {
return (this._config?.items?.length || 0) + 1;
}
_storageKey() {
return `cookcli-checklist:${this._config.storage_key || "default"}`;
}
// Identifiant d'une ligne : index + libellé, pour rester correct si deux
// lignes ont le même nom ou si la recette est modifiée.
_itemId(item, index) {
return `${index}:${item.quantity || ""} ${item.name || ""}`.trim();
}
_load() {
try {
const raw = localStorage.getItem(this._storageKey());
const parsed = raw ? JSON.parse(raw) : [];
return new Set(Array.isArray(parsed) ? parsed : []);
} catch (err) {
return new Set();
}
}
_save() {
try {
localStorage.setItem(this._storageKey(), JSON.stringify([...this._checked]));
} catch (err) {
// localStorage indisponible : l'état reste valable jusqu'au prochain rendu.
}
}
_onChange(ev) {
const input = ev.target.closest("input[data-id]");
if (!input) return;
if (input.checked) this._checked.add(input.dataset.id);
else this._checked.delete(input.dataset.id);
this._save();
this._render();
}
_onClick(ev) {
if (ev.target.closest("[data-reset]")) {
this._checked.clear();
this._save();
this._render();
}
}
_escape(str) {
const div = document.createElement("div");
div.textContent = str ?? "";
return div.innerHTML;
}
_render() {
if (!this.shadowRoot) return;
const items = this._config.items;
const anyChecked = items.some((item, i) => this._checked.has(this._itemId(item, i)));
const rows = items
.map((item, i) => {
const id = this._itemId(item, i);
const checked = this._checked.has(id);
return `
<label class="row ${checked ? "done" : ""}">
<input type="checkbox" data-id="${this._escape(id)}" ${checked ? "checked" : ""}>
<span class="label">
${item.quantity ? `<strong>${this._escape(item.quantity)}</strong> ` : ""}${this._escape(item.name)}
</span>
</label>`;
})
.join("");
this.shadowRoot.innerHTML = `
<style>
ha-card { padding: 8px 16px; }
.title {
padding: 8px 0 4px;
font-size: var(--ha-card-header-font-size, 24px);
font-weight: var(--ha-card-header-font-weight, 400);
line-height: 32px;
color: var(--ha-card-header-color, var(--primary-text-color));
}
.row {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 0;
cursor: pointer;
color: var(--primary-text-color);
}
.row input {
width: 20px;
height: 20px;
flex-shrink: 0;
accent-color: var(--primary-color);
cursor: pointer;
}
.row.done .label {
text-decoration: line-through;
color: var(--secondary-text-color);
}
.reset {
display: ${anyChecked ? "block" : "none"};
margin-left: auto;
padding: 4px 0 0;
background: none;
border: none;
font: inherit;
font-size: 0.85em;
color: var(--primary-color);
cursor: pointer;
}
</style>
<ha-card>
${this._config.title ? `<div class="title">${this._escape(this._config.title)}</div>` : ""}
${rows}
<button class="reset" data-reset>Tout décocher</button>
</ha-card>
`;
}
}
customElements.define("cookcli-card", CookCliCard);
customElements.define("cookcli-card-editor", CookCliCardEditor);
customElements.define("cookcli-checklist-card", CookCliChecklistCard);
window.customCards = window.customCards || [];
window.customCards.push({

View file

@ -34,15 +34,14 @@
* 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
* - custom:cookcli-checklist-card : checklist des ingrédients (Résumé et étapes), état local
* - 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 (éditable aussi via l'éditeur visuel,
* voir CookcliStrategyEditor ; sinon remplace le contenu YAML du dashboard) :
* Configuration du tableau de bord (remplace le contenu YAML du dashboard) :
* strategy:
* type: custom:cookcli
* title: Recettes
@ -114,10 +113,6 @@ class CookCliDashboardStrategy extends HTMLElement {
* d'une unique carte tabdeck-card (Résumé + une tab par étape).
*/
class CookCliRecipeViewStrategy extends HTMLElement {
// Largeur unique de tous les boutons de navigation (Commencer, Précédent,
// Suivant) : c'est elle qu'on ajuste pour les agrandir ou les réduire.
static NAV_BUTTON_WIDTH = "clamp(36px, 20vw, 65px)";
static async generate(config, hass) {
config = config || {};
const path = config.path;
@ -202,150 +197,17 @@ class CookCliRecipeViewStrategy extends HTMLElement {
}
}
static _navButton(entity, targetIndex, label, icon, accent = false) {
static _navButton(entity, targetIndex, label, icon) {
return {
type: "button",
name: label,
icon,
tap_action: {
action: "perform-action",
perform_action: "input_number.set_value",
action: "call-service",
service: "input_number.set_value",
target: { entity_id: entity },
data: { value: targetIndex },
},
card_mod: {
style: `
:host {
flex: 0 0 ${this.NAV_BUTTON_WIDTH} !important;
width: ${this.NAV_BUTTON_WIDTH} !important;
height: ${this.NAV_BUTTON_WIDTH} !important;
min-width: 0 !important;
}
ha-card {
border-radius: 999px;
--ha-card-border-width: 0;
box-shadow: none;
${accent
? `background: var(--primary-color);
--state-color: var(--text-primary-color, #fff) !important;
--paper-item-icon-color: var(--text-primary-color, #fff);`
: `background: var(--secondary-background-color);`}
}
ha-card > span {
border: 0 !important;
clip-path: inset(50%) !important;
height: 1px !important;
margin: -1px !important;
overflow: hidden !important;
padding: 0 !important;
width: 1px !important;
white-space: nowrap !important;
}
`,
},
};
}
/**
* Occupe exactement la même place qu'un bouton de navigation, en invisible
* et non cliquable : sert à garder "Précédent" à la même position sur la
* dernière étape que sur les autres, où il partage la barre avec "Suivant".
*/
static _navSpacer() {
return {
type: "button",
show_name: false,
show_icon: false,
card_mod: {
style: `
:host {
flex: 0 0 ${this.NAV_BUTTON_WIDTH} !important;
width: ${this.NAV_BUTTON_WIDTH} !important;
height: ${this.NAV_BUTTON_WIDTH} !important;
min-width: 0 !important;
visibility: hidden;
pointer-events: none;
}
`,
},
};
}
/**
* Barre de navigation flottante en bas de l'écran. Les boutons ont une
* largeur fixe (et non flex: 1) : un seul bouton fait la même taille que
* deux, et la barre s'adapte à son contenu (max-content).
*/
static _floatingNav(buttons) {
return {
type: "horizontal-stack",
cards: buttons,
card_mod: {
style: `
:host {
position: fixed;
bottom: calc(16px + env(safe-area-inset-bottom, 0px));
right: calc(16px + env(safe-area-inset-bottom, 0px));
width: max-content;
z-index: 5;
padding: 8px;
border-radius: 999px;
background: color-mix(in srgb, var(--card-background-color) 80%, transparent);
backdrop-filter: blur(10px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
height: auto !important;
}
#root {
justify-content: center;
}
@media (max-width: 869px) {
:host {
right: 0;
}
}
`,
},
};
}
/**
* Deux colonnes sur grand écran (gauche étroite, droite large), une seule
* colonne empilée sur mobile. Un horizontal-stack natif ne sait faire ni l'un
* ni l'autre : on surcharge son conteneur interne (#root) avec card-mod.
* 870px = le seuil "mobile" de Home Assistant.
*/
static _responsiveColumns(leftCard, rightCard, leftWidth = "30%") {
return {
type: "horizontal-stack",
cards: [leftCard, rightCard],
card_mod: {
style: `
#root {
flex-direction: column !important;
}
#root > * {
flex: 0 0 auto !important;
width: 100% !important;
min-width: 0;
}
@media (min-width: 870px) {
#root {
flex-direction: row !important;
align-items: flex-start;
}
#root > :first-child {
display: flex;
flex: 0 0 ${leftWidth} !important;
width: auto !important;
}
#root > :last-child {
display: flex;
flex: 1 1 0 !important;
width: auto !important;
}
}
`,
},
};
}
@ -354,17 +216,17 @@ class CookCliRecipeViewStrategy extends HTMLElement {
//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 += `<img src='${cleanImageUrl}' alt='image de la recette' style='width: 100%;aspect-ratio: 16 / 10;object-fit: cover;border-radius: 12px;margin-bottom: 8px;' />\n\n`;
content += `<img src='${cleanImageUrl}' alt='image de la recette 2' style='width: 100%;aspect-ratio: 16 / 10;object-fit: cover;border-radius: 12px;margin-bottom: 8px;' />\n\n`;
}
content += `## ${recipe.title || ""}\n`;
if (recipe.cookware && recipe.cookware.length) {
content += `\n**Ustensiles** : ${recipe.cookware.map((c) => c.name).join(", ")}\n`;
}
const leftCard = {
const cards = [{
type: "markdown",
content,
card_mod: `
card_mod:`
style:
ha-markdown:
$: |
@ -374,122 +236,82 @@ class CookCliRecipeViewStrategy extends HTMLElement {
object-fit: cover;
border-radius: 12px;
margin-bottom: 8px;
}`,
};
}`
}];
const rightCards = [];
const ingredients = recipe.ingredients || [];
if (ingredients.length) {
if (recipe.todo_entity_id) {
rightCards.push({
type: "custom:cookcli-checklist-card",
type: "todo-list",
entity: recipe.todo_entity_id,
title: "Ingrédients",
// État coché propre à cette recette, indépendant de celui des étapes.
storage_key: `${config.path}:summary`,
items: ingredients.map((ingredient) => {
const quantity = ingredient.quantity || {};
return {
quantity: [quantity.value, quantity.unit]
.filter((part) => part !== null && part !== undefined && part !== "")
.join(" "),
name: ingredient.name ?? "",
};
}),
card_mod: {
style: `
ha-list .header {
display: none;
}
`
}
});
}
if (config.step_entity && stepCount > 0) {
rightCards.push(
this._floatingNav([
this._navButton(config.step_entity, 1, "Commencer", "mdi:play", true),
])
);
rightCards.push(this._navButton(config.step_entity, 1, "Commencer", "mdi:play"));
}
const columns = this._responsiveColumns(
leftCard,
{ type: "vertical-stack", cards: rightCards },
"30%"
);
cards.push({ type: "vertical-stack", cards: rightCards });
const tab = {
name: "Résumé",
icon: "mdi:book-open-variant",
card: {
type: "vertical-stack",
cards: [columns],
// Réserve la place de la barre flottante sous la checklist.
card_mod: { style: ":host { display: block; padding-bottom: 96px; }" },
},
};
if (config.step_entity) tab.auto_select = { entity: config.step_entity, state: "0.0" };
return tab;
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 stepLabel = section.name
? `${section.name} ${step.number ?? ""}`.trim()
: `Étape ${tabIndex}`;
const leftCards = [];
const rightCards = [];
rightCards.push({ type: "markdown", content: `## ${stepLabel}\n\n${stepMarkdown}` })
const stepIngredients = (step.items || []).filter((item) => item.type === "ingredient");
if (stepIngredients.length) {
leftCards.push({
type: "custom:cookcli-checklist-card",
title: "Ingrédients",
// recette + étape : l'état coché est propre à chaque étape de chaque recette
storage_key: `${config.path}:${tabIndex}`,
items: stepIngredients.map((item) => ({
quantity: item.quantity
? `${item.quantity.value ?? ""} ${item.quantity.unit ?? ""}`.trim()
: "",
name: item.name ?? "",
})),
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 && timers.length) {
const buttons = [];
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();
buttons.push({
leftCards.push({
type: "button",
name: label,
name: `Démarrer ${label}`,
icon: "mdi:timer-outline",
tap_action: {
action: "perform-action",
perform_action: "timer.start",
action: "call-service",
service: "timer.start",
target: { entity_id: config.timer_entity },
data: { duration: this._secondsToHms(seconds) },
},
});
}
if (buttons.length) {
leftCards.push({ type: "custom:circular-timer-card", entity: config.timer_entity, name: " " });
rightCards.push({
type: "grid",
square: false,
cards: [...buttons, { type: "markdown", content: " ", text_only: true }, { type: "markdown", content: " ", text_only: true }],
});
leftCards.push({ type: "custom:circular-timer-card", entity: config.timer_entity });
}
}
if (!leftCards.length) {
leftCards.push({ type: "markdown", content: " ", text_only: true });
// horizontal-stack veut deux cartes ; une carte vide maintient le ratio.
leftCards.push({ type: "markdown", content: " " });
}
const columns = this._responsiveColumns(
{ type: "vertical-stack", cards: leftCards },
{ type: "vertical-stack", cards: rightCards },
);
const columns = {
type: "horizontal-stack",
cards: [
{ type: "vertical-stack", cards: leftCards },
{ type: "markdown", content: stepMarkdown },
],
};
const cards = [columns];
@ -499,24 +321,16 @@ class CookCliRecipeViewStrategy extends HTMLElement {
];
if (!isLast) {
navButtons.push(
this._navButton(config.step_entity, tabIndex + 1, "Suivant", "mdi:arrow-right", true)
this._navButton(config.step_entity, tabIndex + 1, "Suivant", "mdi:arrow-right")
);
} else {
navButtons.push(this._navSpacer());
}
cards.push(this._floatingNav(navButtons));
cards.push({ type: "horizontal-stack", cards: navButtons });
}
const tab = {
name: stepLabel,
card: {
type: "vertical-stack",
cards,
card_mod: { style: ":host { display: block; padding-bottom: 96px; }" },
},
return {
name: section.name ? `${section.name} ${step.number ?? ""}`.trim() : `Étape ${tabIndex}`,
card: { type: "vertical-stack", cards },
};
if (config.step_entity) tab.auto_select = { entity: config.step_entity, state: `${tabIndex}.0` };
return tab;
}
static _renderStepMarkdown(step) {
@ -591,111 +405,77 @@ class CookCliRecipeViewStrategy extends HTMLElement {
}
}
/**
* Éditeur visuel de la dashboard strategy (remplace l'édition YAML).
*
* Basé sur <ha-form> + sélecteurs natifs HA : mêmes composants, même rendu
* que les éditeurs des cartes officielles (thème, sélecteurs d'entités,
* traductions). Aucune dépendance Lit : on manipule directement l'élément.
*
* Contrat HA pour un éditeur de strategy :
* - setConfig(config) → reçoit la config courante
* - set hass(hass) → reçoit l'objet hass (mis à jour très souvent)
* - événement "config-changed" { detail: { config } } → sauvegarde
*/
const COOKCLI_EDITOR_SCHEMA = [
{ name: "title", selector: { text: {} } },
{ name: "timer_entity", selector: { entity: { domain: "timer" } } },
{ name: "step_entity", selector: { entity: { domain: "input_number" } } },
{ name: "entry_id", selector: { config_entry: { integration: "cookcli" } } },
];
const COOKCLI_EDITOR_LABELS = {
title: "Titre du dashboard",
timer_entity: "Minuteur",
step_entity: "Étape en cours",
entry_id: "Serveur CookCLI",
};
const COOKCLI_EDITOR_HELPERS = {
title: "Titre de la liste et du dashboard. Par défaut : « Recettes ».",
timer_entity:
"Aide « Minuteur » partagée entre toutes les recettes (ex. timer.recette_en_cours). Vide = pas de boutons de minuteur.",
step_entity:
"Aide « Nombre » qui pilote l'onglet actif (ex. input_number.recette_etape). Vide = pas de boutons Commencer / Précédent / Suivant.",
entry_id:
"Seulement si tu as plusieurs serveurs CookCLI. Vide = le serveur par défaut.",
};
class CookcliStrategyEditor extends HTMLElement {
constructor() {
super();
this._config = {};
this._hass = undefined;
this._form = null;
}
setConfig(config) {
this._config = config || {};
this._update();
this._config = config;
}
set hass(hass) {
this._hass = hass;
this._update();
}
// Appelé automatiquement quand l'élément est inséré dans le DOM
connectedCallback() {
this._ensureForm();
this._update();
this._render();
}
_ensureForm() {
if (this._form) return;
const form = document.createElement("ha-form");
form.schema = COOKCLI_EDITOR_SCHEMA;
form.computeLabel = (schema) => COOKCLI_EDITOR_LABELS[schema.name] || schema.name;
form.computeHelper = (schema) => COOKCLI_EDITOR_HELPERS[schema.name] || "";
form.addEventListener("value-changed", (ev) => this._valueChanged(ev));
this.appendChild(form);
this._form = form;
}
_render() {
console.log("Editor render appelé", this._config, this._hass);
// Le formulaire est créé une seule fois ; on ne met à jour que ses
// propriétés (pas de re-rendu du DOM à chaque changement de hass, ce qui
// ferait perdre le focus pendant la saisie).
_update() {
if (!this._form) return;
if (this._hass) this._form.hass = this._hass;
const data = {};
for (const { name } of COOKCLI_EDITOR_SCHEMA) {
if (this._config[name] !== undefined) data[name] = this._config[name];
}
this._form.data = data;
}
_valueChanged(ev) {
ev.stopPropagation();
const value = ev.detail.value || {};
// On part de la config existante (garde `type: custom:cookcli` et toute
// clé inconnue) et on ne touche qu'aux champs de l'éditeur. Un champ vidé
// est supprimé plutôt que stocké comme "".
const config = { ...this._config };
for (const { name } of COOKCLI_EDITOR_SCHEMA) {
const v = value[name];
if (v === undefined || v === null || v === "") delete config[name];
else config[name] = v;
// 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 = `<div style="padding:16px">Chargement…</div>`;
return;
}
this._config = config;
this.dispatchEvent(
new CustomEvent("config-changed", {
detail: { config },
bubbles: true,
composed: true,
})
);
const c = this._config || {};
// Remplacer temporairement ha-textfield par des inputs natifs
this.innerHTML = `
<div style="padding: 16px; display: flex; flex-direction: column; gap: 16px;">
<ha-input
label="Titre du dashboard"
value="${c.title || ""}"
data-config-key="title"
helper="Titre affiché dans l'onglet"
style="width: 100%;"
></ha-input>
<ha-input
label="Entité minuteur"
value="${c.timer_entity || ""}"
data-config-key="timer_entity"
helper="Ex: timer.recette_en_cours"
style="width: 100%;"
></ha-input>
<ha-input
label="Entité étape"
value="${c.step_entity || ""}"
data-config-key="step_entity"
helper="Ex: input_number.recette_etape"
style="width: 100%;"
></ha-input>
<ha-input
label="Entry ID (optionnel)"
value="${c.entry_id || ""}"
data-config-key="entry_id"
helper="Laisser vide si un seul serveur CookCLI"
style="width: 100%;"
></ha-input>
</div>
`;
// É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;
});
});
}
}