Namespace quest definition icon keys with legacy back-compat

Add core/icons.py as the one place icon keys are validated and
normalized. A definition icon is now stored as lucide:<name>,
fa:<name> or emoji:<payload> (at most 8 code points, no whitespace,
control characters or markup). Create and edit validate strictly and
store the canonical form; a legacy bare name is stored as
lucide:<name>, and None still clears the icon.

Reads normalize leniently so existing bare-name rows need no data
migration: the admin quest-definition response and the panel snapshot's
per-instance icon both return the namespaced form.

Task 707994b7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-26 07:18:49 -04:00
co-authored by Claude Opus 5.5
parent fb9907ae7f
commit 7af0b5a6cc
9 changed files with 411 additions and 15 deletions
+112
View File
@@ -0,0 +1,112 @@
"""Namespaced quest-definition icon keys.
A definition's ``icon`` is stored as ``<namespace>:<name>`` where the
namespace is one of :data:`ICON_NAMESPACES`:
- ``lucide:<name>`` / ``fa:<name>`` — ``<name>`` is a lowercase kebab
name (ASCII ``[a-z0-9-]``, not starting or ending with ``-``).
- ``emoji:<payload>`` — a short emoji string of at most
:data:`EMOJI_MAX_CODE_POINTS` code points, with no whitespace, no
control characters and none of ``<``, ``>``, ``&``.
Rows written before namespacing hold a bare Lucide name (``dog``).
:func:`validate_icon` (writes) normalizes such a legacy value to
``lucide:<name>``; :func:`normalize_icon_for_read` (reads) does the same
leniently so legacy rows render without a data migration.
Nothing here imports :mod:`homeassistant`.
"""
from __future__ import annotations
import re
import unicodedata
ICON_NAMESPACE_LUCIDE = "lucide"
ICON_NAMESPACE_FA = "fa"
ICON_NAMESPACE_EMOJI = "emoji"
ICON_NAMESPACES = frozenset(
{ICON_NAMESPACE_LUCIDE, ICON_NAMESPACE_FA, ICON_NAMESPACE_EMOJI}
)
#: Upper bound on an emoji payload's length in code points — enough for
#: ZWJ sequences such as a family emoji, too short for arbitrary text.
EMOJI_MAX_CODE_POINTS = 8
_KEBAB_NAME = re.compile(r"[a-z0-9](?:[a-z0-9-]*[a-z0-9])?")
_EMOJI_FORBIDDEN = frozenset("<>&")
def _is_kebab_name(value: str) -> bool:
return _KEBAB_NAME.fullmatch(value) is not None
def _is_valid_emoji_payload(value: str) -> bool:
if not value or len(value) > EMOJI_MAX_CODE_POINTS:
return False
return not any(
char.isspace()
or char in _EMOJI_FORBIDDEN
or unicodedata.category(char) == "Cc"
for char in value
)
def validate_icon(value: object) -> str | None:
"""Return the canonical namespaced icon key, or raise naming ``icon``.
``None`` or an empty/whitespace-only string returns ``None`` (the
icon is cleared). A value with no ``:`` is a legacy bare Lucide
name and is stored as ``lucide:<value>``. An unknown namespace, an
empty or malformed name, or an invalid emoji payload raises
ValueError.
"""
if value is None:
return None
if not isinstance(value, str):
raise ValueError(f"icon must be a string, got {value!r}")
trimmed = value.strip()
if not trimmed:
return None
namespace, separator, name = trimmed.partition(":")
if not separator:
if not _is_kebab_name(trimmed):
raise ValueError(
f"icon must be a lowercase kebab name, got {value!r}"
)
return f"{ICON_NAMESPACE_LUCIDE}:{trimmed}"
if namespace not in ICON_NAMESPACES:
raise ValueError(
f"icon has an unknown namespace {namespace!r}; expected one of "
f"{sorted(ICON_NAMESPACES)}"
)
if namespace == ICON_NAMESPACE_EMOJI:
if not _is_valid_emoji_payload(name):
raise ValueError(
f"icon emoji must be 1-{EMOJI_MAX_CODE_POINTS} code points "
f"with no whitespace, control characters or markup, "
f"got {value!r}"
)
elif not _is_kebab_name(name):
raise ValueError(
f"icon {namespace} name must be a lowercase kebab name, "
f"got {value!r}"
)
return trimmed
def normalize_icon_for_read(value: str | None) -> str | None:
"""Return a stored icon in namespaced form for output.
Lenient: ``None``/empty returns ``None``, a recognised namespace is
returned unchanged, and anything else becomes ``lucide:<value>`` so
legacy rows render (an unknown name falls through to the frontend's
fallback icon rather than erroring).
"""
if not value or not value.strip():
return None
namespace, separator, _ = value.partition(":")
if separator and namespace in ICON_NAMESPACES:
return value
return f"{ICON_NAMESPACE_LUCIDE}:{value}"
@@ -59,6 +59,7 @@ from .dao_rules import (
schedule_rule_to_storage,
)
from .db import NestQuestDatabase
from .icons import validate_icon
from .materialize import regenerate_for_definition
from .recurrence import ScheduleRule, occurs_on
@@ -248,7 +249,7 @@ async def create_quest_definition(
assignee_ids = _validate_assignee_ids(assignee_child_ids)
window_specs = _normalize_windows(windows)
description_value = _validate_text(description, "description")
icon_value = _validate_text(icon, "icon")
icon_value = validate_icon(icon)
skip_on_away_value = _validate_skip_on_away(skip_on_away)
storage = schedule_rule_to_storage(rule)
@@ -351,7 +352,7 @@ async def edit_quest_definition(
icon_value: str | None | object = _UNSET
if icon is not _UNSET:
icon_value = _validate_text(icon, "icon")
icon_value = validate_icon(icon)
rule_storage: ScheduleRuleStorage | object = _UNSET
if rule is not _UNSET:
+6 -1
View File
@@ -42,6 +42,7 @@ from .dao_instances import CompletionEventsDao, QuestInstancesDao
from .dao_presence import PresenceOverridesDao, PresencePatternsDao
from .dao_rules import QuestDefinitionsDao
from .db import NestQuestDatabase
from .icons import normalize_icon_for_read
from .presence import (
MAX_PREVIEW_SCAN_DAYS,
PresenceEngine,
@@ -262,7 +263,11 @@ async def build_snapshot(
definition_id=instance.definition_id,
child_id=instance.child_id,
title=definition.title if definition else "Quest",
icon=definition.icon if definition else None,
icon=(
normalize_icon_for_read(definition.icon)
if definition
else None
),
window=instance.window,
due_date=instance.due_date,
due_time=instance.due_time,