Files
Home-Assistant/custom_components/taskmate/frontend.py
T
2026-07-08 10:43:39 -04:00

246 lines
10 KiB
Python

"""Frontend registration for TaskMate custom cards."""
from __future__ import annotations
import json
import logging
from pathlib import Path
from typing import Final
from homeassistant.components.frontend import add_extra_js_url
from homeassistant.components.http import StaticPathConfig
from homeassistant.core import HomeAssistant
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
# URL base path for serving static files
URL_BASE: Final = "/taskmate"
# Lovelace resources. Listed in load order: shared utilities first so that
# window.__taskmate_attrs / __taskmate_localize are reliably defined by the
# time card modules execute. Without this, a cold-cache load could render
# cards before the utility globals existed, producing empty chore lists and
# raw localisation keys for non-admin users.
CARDS: Final = [
"taskmate-attr-resolver.js",
"taskmate-localize.js",
"taskmate-design.js",
"taskmate-badges-card.js",
"taskmate-child-card.js",
"taskmate-rewards-card.js",
"taskmate-approvals-card.js",
"taskmate-points-card.js",
"taskmate-reorder-card.js",
"taskmate-overview-card.js",
"taskmate-activity-card.js",
"taskmate-streak-card.js",
"taskmate-weekly-card.js",
"taskmate-graph-card.js",
"taskmate-reward-progress-card.js",
"taskmate-leaderboard-card.js",
"taskmate-parent-dashboard-card.js",
"taskmate-penalties-card.js",
"taskmate-bonuses-card.js",
"taskmate-points-display-card.js",
"taskmate-calendar-card.js",
"taskmate-photo-gallery-card.js",
"taskmate-family-goal-card.js",
]
# Cards that USED to ship but were removed. Their files no longer exist, so any
# Lovelace resource still registered for them 404s on every dashboard load for
# users upgrading from a version that had them. We deregister these by EXACT
# URL only (never a heuristic diff — that once wiped every resource, which is
# why blanket stale-cleanup was removed from async_register_cards).
RETIRED_CARDS: Final = [
"taskmate-task-groups-card.js", # removed #452
"taskmate-templates-card.js", # removed #448
"taskmate-reminders-card.js", # removed #450
]
# JS modules loaded on every HA frontend page (config flow sound preview).
# taskmate-localize.js is ALSO listed in CARDS (Lovelace resources) so it loads
# early on dashboards; it must be here too because the admin panel (panel.py) is
# a panel_custom page, not a Lovelace dashboard, and never loads Lovelace
# resources — without this the panel renders raw i18n keys. Double-loading is a
# no-op: the module is keyed by URL (loaded once) and only assigns idempotent
# window.__taskmate_localize globals.
GLOBAL_MODULES: Final = [
"taskmate-config-sounds.js",
"taskmate-localize.js",
# Like localize.js above, the admin panel (a panel_custom page, not a
# Lovelace dashboard) never loads Lovelace resources, so the shared design
# layer must be loaded globally for window.__taskmate_design to exist when
# the panel resolves/stamps its design. Double-loading is a no-op (keyed by
# URL, assigns idempotent globals).
"taskmate-design.js",
]
# Track if frontend is registered
FRONTEND_REGISTERED: Final = "frontend_registered"
async def _async_get_version(hass: HomeAssistant) -> str:
"""Get version from manifest.json for cache busting (async-safe)."""
manifest_path = Path(__file__).parent / "manifest.json"
try:
content = await hass.async_add_executor_job(
manifest_path.read_text, "utf-8"
)
return json.loads(content).get("version", "1.0.0")
except (OSError, json.JSONDecodeError, AttributeError):
return "1.0.0"
async def async_register_frontend(hass: HomeAssistant) -> None:
"""Register static paths for serving card JavaScript files."""
# Only register once
if hass.data.get(DOMAIN, {}).get(FRONTEND_REGISTERED):
_LOGGER.debug("Frontend already registered, skipping")
return
www_path = Path(__file__).parent / "www"
if not www_path.exists():
_LOGGER.warning("www directory not found at %s", www_path)
return
# Register the www folder as a static path
await hass.http.async_register_static_paths(
[StaticPathConfig(URL_BASE, str(www_path), False)]
)
_LOGGER.debug("Registered static path: %s -> %s", URL_BASE, www_path)
# Authenticated upload/serve endpoints for chore evidence photos.
from .http_photos import async_register_photo_views
async_register_photo_views(hass)
# Token-gated ICS calendar feed (FEAT-10).
from .http_calendar import async_register_calendar_view
async_register_calendar_view(hass)
# Register global JS modules (loaded on all pages, including config flow)
version = await _async_get_version(hass)
for module in GLOBAL_MODULES:
module_url = f"{URL_BASE}/{module}?v={version}"
add_extra_js_url(hass, module_url)
_LOGGER.info("Registered global frontend module: %s", module_url)
# Mark as registered
hass.data.setdefault(DOMAIN, {})[FRONTEND_REGISTERED] = True
async def async_register_cards(hass: HomeAssistant) -> None:
"""Register and version-update TaskMate Lovelace resources on every startup.
Safety rules — this function will ONLY ever:
1. Add missing TaskMate cards (create)
2. Update the ?v= query string on existing TaskMate cards (update)
It will NEVER delete any resource. Stale cleanup is removed entirely
because URL mismatches caused accidental deletion of all resources.
Only URLs that begin with /taskmate/ are ever touched.
"""
version = await _async_get_version(hass)
_LOGGER.info("TaskMate resource manager: version=%s", version)
lovelace_data = hass.data.get("lovelace")
if lovelace_data is None:
_LOGGER.warning("TaskMate: Lovelace not available — skipping resource registration.")
return
mode = getattr(lovelace_data, "mode", "storage")
if mode == "yaml":
_LOGGER.info("TaskMate: Lovelace YAML mode — add resources manually:")
for card in CARDS:
_LOGGER.info(" - url: %s/%s?v=%s (type: module)", URL_BASE, card, version)
return
try:
resources = lovelace_data.resources
if resources is None:
_LOGGER.warning("TaskMate: Lovelace resources object not available.")
return
# Force load storage from disk BEFORE reading items.
# Without this, async_items() returns empty if storage hasn't been
# read yet — causing us to create duplicate entries which then get
# wiped when lovelace subsequently loads its own storage file.
#
# Browser Mod uses: resources.async_load() + resources.loaded flag
# WebRTC uses: resources.async_get_info()
# We use both as a belt-and-braces approach.
if hasattr(resources, "async_load"):
await resources.async_load()
if hasattr(resources, "async_get_info"):
await resources.async_get_info()
# Build a map of base_url (without ?v=...) -> full resource item
# ONLY for resources whose URL starts with /taskmate/
# Everything else is completely ignored
existing: dict[str, dict] = {}
all_items = list(resources.async_items())
_LOGGER.debug("TaskMate: total Lovelace resources = %d", len(all_items))
for item in all_items:
url = item.get("url", "")
base_url = url.split("?")[0]
if base_url.startswith(URL_BASE + "/"):
existing[base_url] = item
_LOGGER.debug("TaskMate: found existing resource: %s", url)
_LOGGER.info("TaskMate: found %d existing TaskMate resources", len(existing))
# Add missing cards or update version on existing ones
# NEVER delete anything
for card in CARDS:
card_url = f"{URL_BASE}/{card}"
versioned_url = f"{card_url}?v={version}"
if card_url not in existing:
# Card not registered yet — add it
await resources.async_create_item(
{"url": versioned_url, "res_type": "module"}
)
_LOGGER.info("TaskMate: added resource: %s", versioned_url)
else:
item = existing[card_url]
current_url = item.get("url", "")
if current_url != versioned_url:
# Version string changed — update it
await resources.async_update_item(
item["id"],
{"url": versioned_url, "res_type": "module"},
)
_LOGGER.info(
"TaskMate: updated resource: %s -> %s",
current_url, versioned_url,
)
else:
_LOGGER.debug("TaskMate: resource up to date: %s", versioned_url)
# Deregister retired cards by EXACT URL match only. This is the one
# delete we allow: each target is a specific /taskmate/<file>.js that no
# longer exists, looked up in the existing map we already built. No
# diffing of "unexpected" URLs, so it cannot cascade into deleting live
# resources the way the old blanket cleanup did.
if hasattr(resources, "async_delete_item"):
for retired in RETIRED_CARDS:
item = existing.get(f"{URL_BASE}/{retired}")
if item is None:
continue
try:
await resources.async_delete_item(item["id"])
_LOGGER.info(
"TaskMate: removed retired resource: %s", item.get("url")
)
except (AttributeError, KeyError, TypeError, OSError) as err:
_LOGGER.warning(
"TaskMate: could not remove retired resource %s: %s",
item.get("url"), err,
)
except (AttributeError, KeyError, TypeError, OSError) as err:
_LOGGER.error("TaskMate: error managing Lovelace resources: %s", err)