247 lines
10 KiB
Python
247 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-routine-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)
|