Compare commits

...

55 commits
0.0.2 ... main

Author SHA1 Message Date
TheThomaas 9c8f791988 Update README.md 2026-09-25 21:19:10 +02:00
TheThomaas e94d47a22f Update cookcli-recipe-strategy.js 2026-09-25 21:10:13 +02:00
TheThomaas d90deca759 Remove todo list integration 2026-09-25 21:02:43 +02:00
TheThomaas bcdb062d1f Remove todo list handling 2026-09-25 20:59:11 +02:00
TheThomaas 25fe50be87 Update cookcli-recipe-strategy.js 2026-09-25 17:38:32 +02:00
TheThomaas d5b177a5e8 Update cookcli-recipe-strategy.js 2026-09-25 17:33:23 +02:00
TheThomaas f883bf361a Update cookcli-recipe-strategy.js 2026-09-25 17:30:20 +02:00
TheThomaas 3bbf66238d Update cookcli-recipe-strategy.js 2026-09-25 17:17:05 +02:00
TheThomaas 8e2b8ec632 Update cookcli-recipe-strategy.js 2026-09-25 17:09:26 +02:00
TheThomaas 206e64490e Update cookcli-recipe-strategy.js 2026-09-25 17:05:41 +02:00
TheThomaas abc0c966cb Remove previous changes 2026-09-25 17:00:30 +02:00
TheThomaas 127129dcf5 Add timer card 2026-09-25 16:56:31 +02:00
TheThomaas ac035b3a97 Update cookcli-recipe-strategy.js 2026-09-25 16:43:48 +02:00
TheThomaas 0d292ee14d Add step name inside tab 2026-09-25 16:37:33 +02:00
TheThomaas bcfb62ccc4 Update cookcli-recipe-strategy.js 2026-09-24 18:48:07 +02:00
TheThomaas 5f89d7c166 Update cookcli-recipe-strategy.js 2026-09-24 18:43:24 +02:00
TheThomaas 3275e33d5d Update cookcli-recipe-strategy.js 2026-09-24 18:40:20 +02:00
TheThomaas 276e0db7f4 Update cookcli-recipe-strategy.js 2026-09-24 18:37:16 +02:00
TheThomaas e2b2b210f6 Update cookcli-recipe-strategy.js 2026-09-24 18:23:54 +02:00
TheThomaas 67bf82b8ad Update cookcli-recipe-strategy.js 2026-09-24 18:18:40 +02:00
TheThomaas a9466ebdd5 Update cookcli-recipe-strategy.js 2026-09-24 16:51:25 +02:00
TheThomaas 5a57884fc7 Update cookcli-recipe-strategy.js 2026-09-24 16:47:51 +02:00
TheThomaas 058a2bdbb3 Update cookcli-recipe-strategy.js 2026-09-24 16:44:30 +02:00
TheThomaas 4c51d60259 Update cookcli-recipe-strategy.js 2026-09-24 16:35:56 +02:00
TheThomaas 692d4747ca Update cookcli-recipe-strategy.js 2026-09-24 16:32:40 +02:00
TheThomaas 0e7de813aa Update cookcli-recipe-strategy.js 2026-09-24 16:29:59 +02:00
TheThomaas a1ecac5cb8 Update cookcli-recipe-strategy.js 2026-09-24 16:24:27 +02:00
TheThomaas 3534fbcccf Update cookcli-card.js 2026-09-24 16:24:25 +02:00
TheThomaas 9e33d3c7df Update cookcli-recipe-strategy.js 2026-09-24 15:09:50 +02:00
TheThomaas a2aabbf8a9 Add custom checklist card 2026-09-24 15:01:58 +02:00
TheThomaas faf9eed3c1 Update cookcli-recipe-strategy.js 2026-09-24 14:52:28 +02:00
TheThomaas c0e9d3aff6 Update cookcli-recipe-strategy.js 2026-09-24 14:48:12 +02:00
TheThomaas 530f66338c Update cookcli-recipe-strategy.js 2026-09-24 14:41:29 +02:00
TheThomaas a1df031f07 Update cookcli-recipe-strategy.js 2026-09-24 14:38:37 +02:00
TheThomaas 1ed188f590 Update cookcli-recipe-strategy.js 2026-09-24 14:32:54 +02:00
TheThomaas 64a58d01ae Update cookcli-recipe-strategy.js 2026-09-24 14:29:29 +02:00
TheThomaas d9bea6f6fb Update cookcli-recipe-strategy.js 2026-09-24 14:23:49 +02:00
TheThomaas 70190a8f0c Update cookcli-recipe-strategy.js 2026-09-24 14:17:23 +02:00
TheThomaas 12500b9fa0 Update cookcli-recipe-strategy.js 2026-09-24 14:13:01 +02:00
TheThomaas 6d6a5c11b6 Update cookcli-recipe-strategy.js 2026-09-24 14:02:14 +02:00
TheThomaas 30504a3ded Update cookcli-recipe-strategy.js 2026-09-24 13:51:14 +02:00
TheThomaas df8cffe74c Update cookcli-recipe-strategy.js 2026-09-24 13:43:16 +02:00
TheThomaas cdf11290a0 Update cookcli-recipe-strategy.js 2026-09-24 13:36:41 +02:00
TheThomaas 038bc3126b Update cookcli-recipe-strategy.js 2026-09-24 13:33:01 +02:00
TheThomaas 67ee41cb24 Update cookcli-recipe-strategy.js 2026-09-24 13:04:46 +02:00
TheThomaas f876730ead Add service to reload recipes 2026-09-20 18:49:38 +02:00
TheThomaas 7e3b4674c4 Update cookcli-recipe-strategy.js 2026-09-20 10:54:01 +02:00
TheThomaas 6668acd5fb Update cookcli-card.js 2026-09-20 10:51:11 +02:00
TheThomaas af7d27c160 Update visual editors 2026-09-20 09:30:01 +02:00
TheThomaas b06d43b883 Update cookcli-recipe-strategy.js 2026-09-19 19:34:07 +02:00
TheThomaas 5c63f62bd6 Add card and strategy automatically 2026-09-19 19:06:14 +02:00
TheThomaas 3cbd094edd Update images path 2026-09-18 14:58:12 +02:00
TheThomaas 75000a01cf Merge branch 'main' of https://git.thethomaas.net/TheThomaas/ha-cookcli 2026-09-18 14:27:20 +02:00
TheThomaas a084c7abdb Remove / 2026-09-18 14:27:07 +02:00
TheThomaas a852cdedf4 Add brand images 2026-09-17 16:25:06 +02:00
18 changed files with 1697 additions and 932 deletions

View file

@ -11,16 +11,6 @@ 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 - 🥕 **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 - 🧭 **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 ## 📦 Installation
### Prérequis ### Prérequis
@ -47,13 +37,6 @@ Le projet est composé de **trois dépôts** qui travaillent ensemble :
2. Copiez le dossier `custom_components/cookcli` dans le répertoire `config/custom_components/` de votre instance Home Assistant. 2. Copiez le dossier `custom_components/cookcli` dans le répertoire `config/custom_components/` de votre instance Home Assistant.
3. **Redémarrez 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 ## ⚙️ Configuration
1. Dans Home Assistant, allez dans **Paramètres** → **Appareils et services**. 1. Dans Home Assistant, allez dans **Paramètres** → **Appareils et services**.
@ -70,6 +53,14 @@ Répétez l'opération via HACS (catégorie **Lovelace** / **Dashboard**) pour :
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. 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) : Exemple (YAML) :
```yaml ```yaml
@ -77,8 +68,6 @@ type: custom:cookcli-card
title: Mes recettes 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 ### 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 : La stratégie `cookcli-recipe-strategy` génère automatiquement un tableau de bord avec **une page par recette**. Chaque page affiche :
@ -86,6 +75,13 @@ La stratégie `cookcli-recipe-strategy` génère automatiquement un tableau de b
- la liste des **ingrédients**, - la liste des **ingrédients**,
- les **étapes** de préparation. - 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. 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 ## 📄 Licence
@ -94,9 +90,6 @@ Ce projet est distribué sous licence présente dans le fichier [LICENSE](./LICE
## 🔗 Liens ## 🔗 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 - **CookCLI** : https://github.com/cooklang/CookCLI
- **Cooklang** : https://cooklang.org/ - **Cooklang** : https://cooklang.org/

View file

@ -3,13 +3,19 @@
from __future__ import annotations from __future__ import annotations
import logging 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.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
from .api import CookCliApiClient, CookCliApiError from .api import CookCliApiClient, CookCliApiError
from .const import CONF_HOST, CONF_PORT, DOMAIN, PLATFORMS from .const import CONF_HOST, CONF_PORT, DOMAIN
# from .const import CONF_HOST, CONF_PORT, DOMAIN, PLATFORMS
from .coordinator import CookCliCoordinator from .coordinator import CookCliCoordinator
from .image_proxy import CookCliImageView from .image_proxy import CookCliImageView
from .websocket_api import async_setup_websocket_api from .websocket_api import async_setup_websocket_api
@ -38,26 +44,108 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"coordinator": coordinator, "coordinator": coordinator,
} }
# Les commandes websocket et la vue de proxy d'images sont globales au # Les commandes websocket, la vue de proxy d'images et les chemins
# domaine, on ne les enregistre qu'une seule fois même si plusieurs # statiques sont globaux au domaine : on ne les enregistre qu'une
# serveurs CookCLI sont configurés. # seule fois, même si plusieurs serveurs CookCLI sont configurés.
if len(hass.data[DOMAIN]) == 1: if len(hass.data[DOMAIN]) == 1:
async_setup_websocket_api(hass) async_setup_websocket_api(hass)
hass.http.register_view(CookCliImageView(hass)) hass.http.register_view(CookCliImageView(hass))
if PLATFORMS: www_path = Path(__file__).parent / "www"
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 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)
entry.async_on_unload(entry.add_update_listener(_async_update_listener)) entry.async_on_unload(entry.add_update_listener(_async_update_listener))
return True 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
)
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: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Décharge une entrée de configuration CookCLI.""" """Décharge une entrée de configuration CookCLI."""
unload_ok = True unload_ok = True
if PLATFORMS: # if PLATFORMS:
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) # unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok: if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id) hass.data[DOMAIN].pop(entry.entry_id)

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View file

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

View file

@ -22,11 +22,11 @@ _LOGGER = logging.getLogger(__name__)
class CookCliImageView(HomeAssistantView): class CookCliImageView(HomeAssistantView):
"""GET /api/cookcli/image/{entry_id}/{image_ref} -> octets de l'image.""" """GET /api/cookcli/image/{entry_id}/{image_ref}?authSig=... -> octets de l'image."""
url = "/api/cookcli/image/{entry_id}/{image_ref:.*}" url = "/api/cookcli/image/{entry_id}{image_ref:.*}"
name = "api:cookcli:image" name = "api:cookcli:image"
requires_auth = True requires_auth = False
def __init__(self, hass: HomeAssistant) -> None: def __init__(self, hass: HomeAssistant) -> None:
self.hass = hass self.hass = hass

View file

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

View file

@ -30,5 +30,17 @@
} }
} }
} }
},
"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

@ -1,72 +0,0 @@
"""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,5 +30,17 @@
} }
} }
} }
},
"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,5 +30,17 @@
} }
} }
} }
},
"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,11 +12,6 @@ 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 le frontend n'a pas besoin de connaître le format brut des chemins d'image
renvoyés par CookCLI. 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 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 de vue HA, calculé une seule fois ici (voir _view_path) pour que
cookcli-card.js et la dashboard strategy (cookcli-recipe-strategy.js) cookcli-card.js et la dashboard strategy (cookcli-recipe-strategy.js)
@ -25,11 +20,13 @@ naviguent vers le même identifiant sans dupliquer la logique de slug.
from __future__ import annotations from __future__ import annotations
from datetime import timedelta
import re import re
from urllib.parse import quote from urllib.parse import quote
import voluptuous as vol import voluptuous as vol
from homeassistant.components import websocket_api from homeassistant.components import websocket_api
from homeassistant.components.http.auth import async_sign_path
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from .api import CookCliApiError from .api import CookCliApiError
@ -48,11 +45,11 @@ def _resolve_entry(
return None, None return None, None
def _image_url(entry_id: str, image_ref: str | None) -> str | None: def _image_url(hass: HomeAssistant, entry_id: str, image_ref: str | None) -> str | None:
if not image_ref: if not image_ref:
return None return None
return f"/api/cookcli/image/{entry_id}/{quote(image_ref, safe='/')}" raw_path = f"/api/cookcli/image/{entry_id}/{quote(image_ref, safe='/')}"
return async_sign_path(hass, raw_path, timedelta(hours=1))
def _view_path(recipe_path: str) -> str: def _view_path(recipe_path: str) -> str:
"""Slug utilisé comme path de vue HA pour cette recette. """Slug utilisé comme path de vue HA pour cette recette.
@ -110,7 +107,7 @@ async def ws_list_recipes(
"time": metadata.get("time"), "time": metadata.get("time"),
"servings": metadata.get("servings"), "servings": metadata.get("servings"),
"tags": metadata.get("tags", []), "tags": metadata.get("tags", []),
"image_url": _image_url(entry_id, metadata.get("image")), "image_url": _image_url(hass, entry_id, metadata.get("image")),
} }
) )
connection.send_result(msg["id"], {"recipes": recipes}) connection.send_result(msg["id"], {"recipes": recipes})
@ -139,12 +136,7 @@ async def ws_get_recipe(
connection.send_error(msg["id"], "cannot_connect", str(err)) connection.send_error(msg["id"], "cannot_connect", str(err))
return return
recipe["image_url"] = _image_url(entry_id, recipe.get("image")) 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) connection.send_result(msg["id"], recipe)

View file

@ -0,0 +1,800 @@
/**
* Carte Lovelace pour l'intégration CookCLI — liste navigable des recettes.
*
* 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, 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. 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
* 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" };
}
setConfig(config) {
this._config = config || {};
this._recipes = null;
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");
}
set hass(hass) {
this._hass = hass;
this._subscribe();
if (this._recipes === null && !this._loading) {
this._fetchRecipes();
}
}
/**
* 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;
}
async _fetchRecipes() {
this._loading = true;
this._error = null;
this._render();
try {
const msg = { type: "cookcli/recipes" };
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();
if (this._selectedTag !== null && !this._allTags().includes(this._selectedTag)) {
this._selectedTag = null;
}
} catch (err) {
this._error = (err && err.message) || "Erreur inconnue";
} finally {
this._loading = false;
this._render();
}
}
_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);
window.dispatchEvent(new CustomEvent("location-changed", { detail: { replace: false } }));
}
_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);
}
}
_escape(str) {
const div = document.createElement("div");
div.textContent = str ?? "";
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>`;
}
if (this._error && !this._recipes) {
return `<div class="state-msg error">${this._escape(this._error)}</div>`;
}
if (!this._recipes || this._recipes.length === 0) {
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
.map((r) => {
const meta = [];
if (r.time) meta.push(`<span>⏱ ${this._escape(r.time)}</span>`);
if (r.servings) meta.push(`<span>🍽 ${this._escape(String(r.servings))}</span>`);
const thumb = r.image_url
? `<img class="recipe-thumb" src="${this._escape(r.image_url)}" alt="" loading="lazy" onerror="this.style.background='var(--divider-color)';this.removeAttribute('src')">`
: `<div class="recipe-thumb recipe-thumb-placeholder"></div>`;
return `
<div class="recipe-card" data-view-path="${this._escape(r.view_path)}">
${thumb}
<div class="recipe-card-text">
<div class="recipe-name">${this._escape(r.name)}</div>
<div class="recipe-meta">${meta.join("")}</div>
</div>
</div>`;
})
.join("");
return `${filterBar}<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}
<div class="card-content">${this._renderList()}</div>
</ha-card>
`;
const next = this.shadowRoot.querySelector(".tag-filter");
if (next) next.scrollLeft = scrollLeft;
}
_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); }
/* 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-fill, 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-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: 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-name {
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;
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({
type: "cookcli-card",
name: "CookCLI Recipes",
preview: false,
description: "Liste navigable de tes recettes CookCLI.",
});

View file

@ -0,0 +1,713 @@
/**
* 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
* - custom:cookcli-checklist-card : checklist des ingrédients (Résumé et étapes), état local
* - 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) :
* 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 {
// 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;
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, accent = false) {
return {
type: "button",
name: label,
icon,
tap_action: {
action: "perform-action",
perform_action: "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;
}
}
`,
},
};
}
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 += `<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 += `## ${recipe.title || ""}\n`;
if (recipe.cookware && recipe.cookware.length) {
content += `\n**Ustensiles** : ${recipe.cookware.map((c) => c.name).join(", ")}\n`;
}
const leftCard = {
type: "markdown",
content,
card_mod: `
style:
ha-markdown:
$: |
img {
width: 100%;
aspect-ratio: 16 / 10;
object-fit: cover;
border-radius: 12px;
margin-bottom: 8px;
}`,
};
const rightCards = [];
const ingredients = recipe.ingredients || [];
if (ingredients.length) {
rightCards.push({
type: "custom:cookcli-checklist-card",
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 ?? "",
};
}),
});
}
if (config.step_entity && stepCount > 0) {
rightCards.push(
this._floatingNav([
this._navButton(config.step_entity, 1, "Commencer", "mdi:play", true),
])
);
}
const columns = this._responsiveColumns(
leftCard,
{ type: "vertical-stack", cards: rightCards },
"30%"
);
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;
}
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 ?? "",
})),
});
}
if (config.timer_entity && timers.length) {
const buttons = [];
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({
type: "button",
name: label,
icon: "mdi:timer-outline",
tap_action: {
action: "perform-action",
perform_action: "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 }],
});
}
}
if (!leftCards.length) {
leftCards.push({ type: "markdown", content: " ", text_only: true });
}
const columns = this._responsiveColumns(
{ type: "vertical-stack", cards: leftCards },
{ type: "vertical-stack", cards: rightCards },
);
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", true)
);
} else {
navButtons.push(this._navSpacer());
}
cards.push(this._floatingNav(navButtons));
}
const tab = {
name: stepLabel,
card: {
type: "vertical-stack",
cards,
card_mod: { style: ":host { display: block; padding-bottom: 96px; }" },
},
};
if (config.step_entity) tab.auto_select = { entity: config.step_entity, state: `${tabIndex}.0` };
return tab;
}
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(":");
}
}
/**
* É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();
}
set hass(hass) {
this._hass = hass;
this._update();
}
connectedCallback() {
this._ensureForm();
this._update();
}
_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;
}
// 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;
}
this._config = config;
this.dispatchEvent(
new CustomEvent("config-changed", {
detail: { config },
bubbles: true,
composed: true,
})
);
}
}
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",
});

View file

@ -1,189 +0,0 @@
/**
* Carte Lovelace pour l'intégration CookCLI — liste navigable des recettes.
*
* 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, 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. 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
* 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" };
}
setConfig(config) {
this._config = config || {};
this._recipes = 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;
}
async _fetchRecipes() {
this._loading = true;
this._error = null;
this._render();
try {
const msg = { type: "cookcli/recipes" };
if (this._config.entry_id) msg.entry_id = this._config.entry_id;
const result = await this._hass.connection.sendMessagePromise(msg);
this._recipes = result.recipes || [];
} catch (err) {
this._error = (err && err.message) || "Erreur inconnue";
} finally {
this._loading = false;
this._render();
}
}
_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);
window.dispatchEvent(new CustomEvent("location-changed", { detail: { replace: false } }));
}
_onClick(event) {
const itemEl = event.target.closest("[data-view-path]");
if (itemEl) {
this._navigateToView(itemEl.dataset.viewPath);
}
}
_escape(str) {
const div = document.createElement("div");
div.textContent = str ?? "";
return div.innerHTML;
}
_renderList() {
if (this._loading) {
return `<div class="state-msg">Chargement…</div>`;
}
if (this._error) {
return `<div class="state-msg error">${this._escape(this._error)}</div>`;
}
if (!this._recipes || this._recipes.length === 0) {
return `<div class="state-msg">Aucune recette trouvée.</div>`;
}
const rows = this._recipes
.map((r) => {
const meta = [];
if (r.time) meta.push(`<span>⏱ ${this._escape(r.time)}</span>`);
if (r.servings) meta.push(`<span>🍽 ${this._escape(String(r.servings))}</span>`);
const thumb = r.image_url
? `<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-view-path="${this._escape(r.view_path)}">
${thumb}
<div class="recipe-row-text">
<div class="recipe-name">${this._escape(r.name)}</div>
<div class="recipe-meta">${meta.join("")}</div>
</div>
</div>`;
})
.join("");
return `<div class="recipe-list">${rows}</div>`;
}
_render() {
if (!this.shadowRoot) return;
const title = this._config.title || "Recettes";
this.shadowRoot.innerHTML = `
<style>${this._styles()}</style>
<ha-card header="${this._escape(title)}">
<div class="card-content">${this._renderList()}</div>
</ha-card>
`;
}
_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;
}
`;
}
}
customElements.define("cookcli-card", CookCliCard);
window.customCards = window.customCards || [];
window.customCards.push({
type: "cookcli-card",
name: "CookCLI Recipes",
description: "Liste navigable de tes recettes CookCLI.",
});

View file

@ -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",
});

View file

@ -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 `<span class="ingredient">${this._escape(item.name)}${
qty ? ` (${this._escape(qty)})` : ""
}</span>`;
}
case "cookware":
return `<span class="cookware">${this._escape(item.name)}</span>`;
case "timer": {
const duration = item.duration ?? "";
const unit = item.unit ?? "";
return `<span class="timer">⏱ ${this._escape(String(duration))}${
unit ? " " + this._escape(unit) : ""
}</span>`;
}
default:
return "";
}
}
_renderList() {
if (this._loading) {
return `<div class="state-msg">Chargement…</div>`;
}
if (this._error) {
return `<div class="state-msg error">${this._escape(this._error)}</div>`;
}
if (!this._recipes || this._recipes.length === 0) {
return `<div class="state-msg">Aucune recette trouvée.</div>`;
}
const rows = this._recipes
.map((r) => {
const meta = [];
if (r.time) meta.push(`<span>⏱ ${this._escape(r.time)}</span>`);
if (r.servings) meta.push(`<span>🍽 ${this._escape(String(r.servings))}</span>`);
const thumb = r.image_url
? `<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)}">
${thumb}
<div class="recipe-row-text">
<div class="recipe-name">${this._escape(r.name)}</div>
<div class="recipe-meta">${meta.join("")}</div>
</div>
</div>`;
})
.join("");
return `<div class="recipe-list">${rows}</div>`;
}
_renderDetail() {
if (this._loading) {
return `<div class="state-msg">Chargement…</div>`;
}
if (this._error) {
return `
<button class="back-btn" data-action="back">← Retour à la liste</button>
<div class="state-msg error">${this._escape(this._error)}</div>`;
}
if (!this._selected) return "";
const r = this._selected;
const ingredients = (r.ingredients || [])
.map((i) => {
const qty = this._formatQuantity(i.quantity);
return `<li>${qty ? `<strong>${this._escape(qty)}</strong> ` : ""}${this._escape(
i.name
)}</li>`;
})
.join("");
const cookware = (r.cookware || [])
.map((c) => `<li>${this._escape(c.name)}</li>`)
.join("");
const sections = (r.sections || [])
.map((section) => {
const steps = (section.steps || [])
.map(
(step) =>
`<li>${(step.items || [])
.map((item) => this._renderStepItem(item))
.join("")}</li>`
)
.join("");
return `
${section.name ? `<h4>${this._escape(section.name)}</h4>` : ""}
<ol class="steps">${steps}</ol>`;
})
.join("");
return `
<button class="back-btn" data-action="back">← Retour à la liste</button>
${
r.image_url
? `<img class="recipe-hero" src="${this._escape(r.image_url)}" alt="" onerror="this.style.display='none'">`
: ""
}
<h2>${this._escape(r.title || "")}</h2>
${cookware ? `<h4>Ustensiles</h4><ul class="cookware-list">${cookware}</ul>` : ""}
<h4>Ingrédients</h4>
<ul class="ingredients">${ingredients}</ul>
<h4>Préparation</h4>
${sections}
`;
}
_render() {
if (!this.shadowRoot) return;
const title = this._config.title || "Recettes";
this.shadowRoot.innerHTML = `
<style>${this._styles()}</style>
<ha-card header="${this._view === "list" ? this._escape(title) : ""}">
<div class="card-content">
${this._view === "list" ? this._renderList() : this._renderDetail()}
</div>
</ha-card>
`;
}
_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.",
});