Compare commits

..

No commits in common. "eebe1bbd7e425e302d1570c1ba40b46b8a24423f" and "1156722b4d1ac11d402c50f8a14aeb9eb2940067" have entirely different histories.

5 changed files with 57 additions and 660 deletions

View file

@ -12,7 +12,6 @@ from .api import CookCliApiClient, CookCliApiError
from .const import CONF_HOST, CONF_PORT, DOMAIN, PLATFORMS from .const import CONF_HOST, CONF_PORT, DOMAIN, PLATFORMS
from .coordinator import CookCliCoordinator from .coordinator import CookCliCoordinator
from .websocket_api import async_setup_websocket_api from .websocket_api import async_setup_websocket_api
from .image_proxy import CookCliImageView
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)

View file

@ -1,60 +1,24 @@
"""Client API pour le serveur CookCLI. """Client API pour le serveur CookCLI.
Endpoints CookCLI utilisés : Endpoints CookCLI utilisés :
- GET /api/recipes -> arborescence complète des recettes - GET /api/recipes -> liste/arborescence des recettes
- GET /api/recipes/{path} -> détail d'une recette (ingredients, steps, metadata) - GET /api/recipes/{path} -> détail d'une recette (ingredients, steps, metadata)
Format réel confirmé pour GET /api/recipes (arborescence récursive) : Le endpoint de liste n'a pas pu être vérifié à 100% depuis les sources au
moment de l'écriture de ce client : le format exact (liste plate vs
arborescence de dossiers) doit être confirmé avec un `curl` réel contre le
serveur, ex :
{ curl http://<ip-addon>:9081/api/recipes | jq
"name": "recipes", "path": "/config/recipes", "recipe": null,
"children": {
"Chocolat chaud": {
"name": "Chocolat chaud",
"path": "/config/recipes/chocolat-chaud.cook",
"recipe": {"metadata": {...}, "source": {...}},
"children": {}
},
"example": {
"name": "example", "path": "/config/recipes/example", "recipe": null,
"children": { ... sous-dossiers/recettes imbriqués ... }
}
}
}
Un nœud est une recette si sa clé "recipe" n'est pas null ; sinon c'est un `_normalize_recipe_list` ci-dessous encaisse les deux formes les plus
dossier à parcourir récursivement via "children". `_normalize_recipe_list` probables ; ajuste-le si le format réel diffère.
aplatit cette arborescence en une liste de `RecipeSummary`, avec un `path`
relatif au dossier racine (ex: "chocolat-chaud.cook",
"example/sauces/Hollandaise.cook") pour correspondre à ce qu'attend
GET /api/recipes/{path}.
Hypothèse à vérifier : ce path relatif inclut l'extension ".cook", ce qui
correspond au path filesystem brut renvoyé par le serveur. Si
GET /api/recipes/{path} attend un format différent (sans extension, slugifié,
etc.), ajuste `async_get_recipe` en conséquence.
Format réel confirmé pour GET /api/recipes/{path} (détail d'une recette) :
la réponse encapsule un objet `recipe` du parser cooklang-rs, où les listes
`ingredients`/`cookware`/`timers` sont à plat, et les étapes
(`recipe.sections[].content[].value.items[]`) référencent ces objets **par
index** plutôt que de les inclure directement (ex: `{"type": "ingredient",
"index": 0}`). `async_get_recipe` résout ces références côté intégration
avant de renvoyer les données, pour éviter que la carte Lovelace (ou tout
autre consommateur) ait à recroiser les index elle-même.
Les valeurs numériques suivent aussi un format imbriqué
(`{"type": "number", "value": {"type": "regular", "value": 1.0}}`). Seul le
sous-type "regular" a été observé en pratique ; les sous-types "fraction" ou
similaires (non confirmés) sont retournés bruts par `_resolve_value` en cas
de format inconnu, plutôt que de faire planter la résolution.
""" """
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import logging import logging
from contextlib import asynccontextmanager
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
@ -99,249 +63,65 @@ class CookCliApiClient:
async def async_list_recipes(self) -> list[RecipeSummary]: async def async_list_recipes(self) -> list[RecipeSummary]:
"""Récupère la liste des recettes disponibles.""" """Récupère la liste des recettes disponibles."""
data = await self._request_json("/recipes") data = await self._request("GET", "/recipes")
return self._normalize_recipe_list(data) return self._normalize_recipe_list(data)
async def async_get_recipe(self, path: str) -> dict[str, Any]: async def async_get_recipe(self, path: str) -> dict[str, Any]:
"""Récupère le détail d'une recette, avec étapes déjà résolues. """Récupère le détail complet d'une recette (ingredients + steps)."""
return await self._request("GET", f"/recipes/{path}")
La réponse brute de CookCLI référence les ingrédients/ustensiles/ async def _request(self, method: str, endpoint: str) -> Any:
minuteurs par index dans les étapes ; ce qui est renvoyé ici a déjà
remplacé ces index par les objets correspondants (voir
`_resolve_recipe`).
"""
data = await self._request_json(f"/recipes/{path}")
return _resolve_recipe(data)
async def async_get_image(self, image_ref: str) -> tuple[bytes, str | None]:
"""Récupère les octets d'une image statique CookCLI.
`image_ref` peut prendre trois formes selon d'où il vient :
- "/api/static/img/x.jpg" : valeur brute du champ "image" du détail
d'une recette (chemin déjà préfixé par /api).
- "/img/x.jpg" ou "img/x.jpg" : valeur brute de metadata.image dans
l'arborescence de /api/recipes (chemin relatif, avec ou sans "/"
initial selon comment la recette a été écrite).
Ces trois formes ont été observées dans des réponses réelles du
serveur (pas juste supposées) ; `_image_endpoint` les normalise
toutes vers /static/... — à vérifier visuellement une fois la carte
rechargée, au cas où une recette aurait un format différent.
"""
endpoint = self._image_endpoint(image_ref)
async with self._get(endpoint) as resp:
content_type = resp.headers.get("Content-Type")
data = await resp.read()
return data, content_type
@staticmethod
def _image_endpoint(image_ref: str) -> str:
if image_ref.startswith("/api/"):
return image_ref[len("/api") :]
if image_ref.startswith("/"):
return f"/static{image_ref}"
return f"/static/{image_ref}"
@asynccontextmanager
async def _get(self, endpoint: str):
"""Contexte partagé pour un GET vers CookCLI, avec gestion d'erreurs."""
url = f"{self._base_url}{endpoint}" url = f"{self._base_url}{endpoint}"
try: try:
async with self._session.request( async with self._session.request(
"GET", url, timeout=DEFAULT_TIMEOUT method, url, timeout=DEFAULT_TIMEOUT
) as resp: ) 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}"
) )
yield resp try:
return await resp.json()
except (aiohttp.ContentTypeError, ValueError) as err:
raise CookCliResponseError(
f"Réponse non-JSON de CookCLI pour {url}"
) from err
except asyncio.TimeoutError as err: except asyncio.TimeoutError as err:
raise CookCliConnectionError(f"Timeout en contactant {url}") from err raise CookCliConnectionError(f"Timeout en contactant {url}") from err
except aiohttp.ClientError as err: except aiohttp.ClientError as err:
raise CookCliConnectionError(f"Impossible de joindre {url}: {err}") from err raise CookCliConnectionError(f"Impossible de joindre {url}: {err}") from err
async def _request_json(self, endpoint: str) -> Any:
async with self._get(endpoint) as resp:
try:
return await resp.json()
except (aiohttp.ContentTypeError, ValueError) as err:
raise CookCliResponseError(
f"Réponse non-JSON de CookCLI pour {endpoint}"
) from err
@staticmethod @staticmethod
def _normalize_recipe_list(data: Any) -> list[RecipeSummary]: def _normalize_recipe_list(data: Any) -> list[RecipeSummary]:
"""Aplatit l'arborescence renvoyée par GET /api/recipes en liste plate.""" """Normalise la réponse de listing en une liste plate de RecipeSummary.
if not isinstance(data, dict):
_LOGGER.warning( Encaisse plusieurs formes possibles :
"Format de /api/recipes inattendu (attendu un objet, reçu %s)", - une liste de chemins/objets directement
type(data), - un dict avec une clé "recipes" ou "index"
) - une arborescence imbriquée par dossier
return [] """
items: list[Any]
if isinstance(data, list):
items = data
elif isinstance(data, dict):
items = data.get("recipes") or data.get("index") or []
else:
items = []
root_path = data.get("path", "")
summaries: list[RecipeSummary] = [] summaries: list[RecipeSummary] = []
CookCliApiClient._walk_recipe_tree(data, root_path, summaries) for item in items:
if isinstance(item, str):
summaries.append(RecipeSummary(path=item, name=_name_from_path(item)))
elif isinstance(item, dict):
path = item.get("path") or item.get("slug") or item.get("name", "")
name = item.get("name") or _name_from_path(path)
summaries.append(RecipeSummary(path=path, name=name, raw=item))
else:
_LOGGER.debug("Entrée de recette ignorée (format inattendu): %s", item)
return summaries return summaries
@staticmethod
def _walk_recipe_tree(
node: dict[str, Any], root_path: str, out: list[RecipeSummary]
) -> None:
"""Parcourt récursivement un nœud de l'arborescence de recettes."""
recipe = node.get("recipe")
if recipe is not None:
abs_path = node.get("path", "")
rel_path = abs_path
if root_path and abs_path.startswith(root_path):
rel_path = abs_path[len(root_path) :].lstrip("/")
name = node.get("name") or _name_from_path(rel_path)
out.append(RecipeSummary(path=rel_path, name=name, raw=node))
for child in (node.get("children") or {}).values():
CookCliApiClient._walk_recipe_tree(child, root_path, out)
def _name_from_path(path: str) -> str: def _name_from_path(path: str) -> str:
"""Dérive un nom lisible depuis un chemin de fichier .cook.""" """Dérive un nom lisible depuis un chemin de fichier .cook."""
return path.rsplit("/", 1)[-1].removesuffix(".cook") return path.rsplit("/", 1)[-1].removesuffix(".cook")
# ---------------------------------------------------------------------------
# Résolution du détail d'une recette : remplace les références par index
# (ingredient/cookware/timer) par les objets correspondants, et aplatit les
# valeurs numériques imbriquées du parser cooklang-rs.
# ---------------------------------------------------------------------------
def _resolve_number(number_obj: Any) -> Any:
"""Résout un objet 'number' cooklang-rs en valeur simple.
Seul le sous-type "regular" (nombre décimal simple) a été observé en
pratique. Les autres sous-types (fractions, valeurs mixtes...) ne sont
pas confirmés : on renvoie l'objet brut plutôt que de deviner un format.
"""
if not isinstance(number_obj, dict):
return number_obj
if number_obj.get("type") == "regular":
return number_obj.get("value")
_LOGGER.debug("Sous-type de nombre non géré, valeur brute conservée: %s", number_obj)
return number_obj
def _resolve_value(value_obj: Any) -> Any:
"""Résout un objet 'value' générique (nombre, texte...) en valeur simple."""
if not isinstance(value_obj, dict):
return value_obj
value_type = value_obj.get("type")
inner = value_obj.get("value")
if value_type == "number":
return _resolve_number(inner)
if value_type == "text":
return inner
_LOGGER.debug("Type de valeur non géré, valeur brute conservée: %s", value_obj)
return inner if inner is not None else value_obj
def _resolve_quantity(quantity_obj: Any) -> dict[str, Any] | None:
"""Résout une quantité (ingredient/cookware/timer) en {value, unit}."""
if not isinstance(quantity_obj, dict):
return None
return {
"value": _resolve_value(quantity_obj.get("value")),
"unit": quantity_obj.get("unit"),
}
def _resolve_ingredient(ingredient: dict[str, Any]) -> dict[str, Any]:
return {
"name": ingredient.get("name"),
"quantity": _resolve_quantity(ingredient.get("quantity")),
"note": ingredient.get("note"),
}
def _resolve_cookware(cookware: dict[str, Any]) -> dict[str, Any]:
return {
"name": cookware.get("name"),
"quantity": _resolve_quantity(cookware.get("quantity")),
"note": cookware.get("note"),
}
def _resolve_timer(timer: dict[str, Any]) -> dict[str, Any]:
quantity = timer.get("quantity") or {}
return {
"name": timer.get("name"),
"duration": _resolve_value(quantity.get("value")),
"unit": quantity.get("unit"),
}
def _resolve_step_item(
item: dict[str, Any],
ingredients: list[dict[str, Any]],
cookware: list[dict[str, Any]],
timers: list[dict[str, Any]],
) -> dict[str, Any]:
"""Remplace un item d'étape référencé par index par l'objet résolu."""
item_type = item.get("type")
if item_type == "text":
return {"type": "text", "value": item.get("value", "")}
lookup = {"ingredient": ingredients, "cookware": cookware, "timer": timers}
if item_type in lookup:
index = item.get("index")
objects = lookup[item_type]
resolved = objects[index] if index is not None and 0 <= index < len(objects) else {}
if not resolved:
_LOGGER.debug(
"Index %s introuvable pour un item de type %s", index, item_type
)
return {"type": item_type, **resolved}
_LOGGER.debug("Type d'item d'étape non géré, conservé brut: %s", item_type)
return {"type": item_type, "raw": item}
def _resolve_recipe(data: dict[str, Any]) -> dict[str, Any]:
"""Transforme la réponse brute de GET /api/recipes/{path} en structure
directement exploitable (titre, ingrédients, étapes avec items résolus).
"""
recipe = data.get("recipe") or {}
metadata = (recipe.get("metadata") or {}).get("map") or {}
ingredients = [_resolve_ingredient(i) for i in recipe.get("ingredients", [])]
cookware = [_resolve_cookware(c) for c in recipe.get("cookware", [])]
timers = [_resolve_timer(t) for t in recipe.get("timers", [])]
sections: list[dict[str, Any]] = []
for section in recipe.get("sections", []):
steps: list[dict[str, Any]] = []
for content_item in section.get("content", []):
if content_item.get("type") != "step":
_LOGGER.debug(
"Type de contenu de section ignoré: %s", content_item.get("type")
)
continue
step_value = content_item.get("value") or {}
items = [
_resolve_step_item(it, ingredients, cookware, timers)
for it in step_value.get("items", [])
]
steps.append({"number": step_value.get("number"), "items": items})
sections.append({"name": section.get("name"), "steps": steps})
return {
"title": metadata.get("title"),
"image": data.get("image"),
"scale": data.get("scale"),
"metadata": metadata,
"ingredients": ingredients,
"cookware": cookware,
"sections": sections,
}

View file

@ -1,52 +0,0 @@
"""Proxy HTTP pour les images statiques de CookCLI.
Le navigateur ne peut pas atteindre le serveur CookCLI directement (ni le
port ingress 9080, restreint à l'IP du Supervisor par nginx, ni le port
direct 9081, non publié sur le LAN). Seul le backend Home Assistant, sur le
même réseau Docker interne que l'add-on, peut le faire. Cette vue relaie
donc les octets de l'image en passant par ce backend.
"""
from __future__ import annotations
import logging
from aiohttp import web
from homeassistant.components.http import HomeAssistantView
from homeassistant.core import HomeAssistant
from .api import CookCliApiError
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
class CookCliImageView(HomeAssistantView):
"""GET /api/cookcli/image/{entry_id}/{image_ref} -> octets de l'image."""
url = "/api/cookcli/image/{entry_id}/{image_ref:.*}"
name = "api:cookcli:image"
requires_auth = True
def __init__(self, hass: HomeAssistant) -> None:
self.hass = hass
async def get(
self, request: web.Request, entry_id: str, image_ref: str
) -> web.Response:
entry_data = self.hass.data.get(DOMAIN, {}).get(entry_id)
if entry_data is None:
return web.Response(status=404, text="Intégration CookCLI introuvable")
client = entry_data["client"]
try:
data, content_type = await client.async_get_image(image_ref)
except CookCliApiError as err:
_LOGGER.debug("Échec de récupération de l'image %s: %s", image_ref, err)
return web.Response(status=502, text="Impossible de récupérer l'image")
return web.Response(
body=data,
content_type=content_type or "application/octet-stream",
headers={"Cache-Control": "max-age=3600"},
)

View file

@ -6,17 +6,10 @@ Deux commandes :
Si plusieurs serveurs CookCLI sont configurés, `entry_id` permet de préciser Si plusieurs serveurs CookCLI sont configurés, `entry_id` permet de préciser
lequel interroger ; sinon, le premier configuré est utilisé. lequel interroger ; sinon, le premier configuré est utilisé.
Les deux commandes ajoutent un champ "image_url" déjà prêt à l'emploi pour
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.
""" """
from __future__ import annotations from __future__ import annotations
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.core import HomeAssistant from homeassistant.core import HomeAssistant
@ -25,22 +18,13 @@ from .api import CookCliApiError
from .const import DOMAIN from .const import DOMAIN
def _resolve_entry( def _resolve_entry_data(hass: HomeAssistant, entry_id: str | None) -> dict | None:
hass: HomeAssistant, entry_id: str | None
) -> tuple[str | None, dict | None]:
entries = hass.data.get(DOMAIN, {}) entries = hass.data.get(DOMAIN, {})
if entry_id is not None: if entry_id is not None:
return entry_id, entries.get(entry_id) return entries.get(entry_id)
if entries: if entries:
first_id, first_data = next(iter(entries.items())) return next(iter(entries.values()))
return first_id, first_data return None
return None, None
def _image_url(entry_id: str, image_ref: str | None) -> str | None:
if not image_ref:
return None
return f"/api/cookcli/image/{entry_id}/{quote(image_ref, safe='/')}"
@websocket_api.websocket_command( @websocket_api.websocket_command(
@ -53,26 +37,20 @@ def _image_url(entry_id: str, image_ref: str | None) -> str | None:
async def ws_list_recipes( async def ws_list_recipes(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
) -> None: ) -> None:
entry_id, entry_data = _resolve_entry(hass, msg.get("entry_id")) entry_data = _resolve_entry_data(hass, msg.get("entry_id"))
if entry_data is None: if entry_data is None:
connection.send_error(msg["id"], "not_found", "Aucune intégration CookCLI configurée") connection.send_error(msg["id"], "not_found", "Aucune intégration CookCLI configurée")
return return
coordinator = entry_data["coordinator"] coordinator = entry_data["coordinator"]
recipes = [] connection.send_result(
for r in coordinator.data or []: msg["id"],
metadata = ((r.raw or {}).get("recipe") or {}).get("metadata") or {} {
recipes.append( "recipes": [
{ {"path": r.path, "name": r.name} for r in coordinator.data or []
"path": r.path, ]
"name": r.name, },
"time": metadata.get("time"), )
"servings": metadata.get("servings"),
"tags": metadata.get("tags", []),
"image_url": _image_url(entry_id, metadata.get("image")),
}
)
connection.send_result(msg["id"], {"recipes": recipes})
@websocket_api.websocket_command( @websocket_api.websocket_command(
@ -86,7 +64,7 @@ async def ws_list_recipes(
async def ws_get_recipe( async def ws_get_recipe(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
) -> None: ) -> None:
entry_id, entry_data = _resolve_entry(hass, msg.get("entry_id")) entry_data = _resolve_entry_data(hass, msg.get("entry_id"))
if entry_data is None: if entry_data is None:
connection.send_error(msg["id"], "not_found", "Aucune intégration CookCLI configurée") connection.send_error(msg["id"], "not_found", "Aucune intégration CookCLI configurée")
return return
@ -98,7 +76,6 @@ 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"))
connection.send_result(msg["id"], recipe) connection.send_result(msg["id"], recipe)

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/cookcli-card.js
* 2. Paramètres -> Tableaux de bord -> menu ⋮ -> Ressources -> Ajouter :
* URL "/local/cookcli-card.js", type "Module JavaScript"
* 3. Ajoute une carte manuelle sur un tableau de bord :
* type: custom:cookcli-card
* title: Mes recettes
*/
class CookCliCard 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("cookcli-card", CookCliCard);
window.customCards = window.customCards || [];
window.customCards.push({
type: "cookcli-card",
name: "CookCLI Recipes",
description: "Liste navigable de tes recettes CookCLI, avec ingrédients et étapes.",
});