Add tags to all entity types with chip-based input and autocomplete

- Add `tags: List[str]` field to all 13 entity types (devices, output targets,
  CSS sources, picture sources, audio sources, value sources, sync clocks,
  automations, scene presets, capture/audio/PP/pattern templates)
- Update all stores, schemas, and route handlers for tag CRUD
- Add GET /api/v1/tags endpoint aggregating unique tags across all stores
- Create TagInput component with chip display, autocomplete dropdown,
  keyboard navigation, and API-backed suggestions
- Display tag chips on all entity cards (searchable via existing text filter)
- Add tag input to all 14 editor modals with dirty check support
- Add CSS styles and i18n keys (en/ru/zh) for tag UI
- Also includes code review fixes: thread safety, perf, store dedup

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-09 22:20:19 +03:00
parent 2712c6682e
commit 30fa107ef7
120 changed files with 2471 additions and 1949 deletions
@@ -1,72 +1,27 @@
"""Automation storage using JSON files."""
import json
import uuid
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional
from datetime import datetime, timezone
from typing import List, Optional
from wled_controller.storage.automation import Automation, Condition
from wled_controller.utils import atomic_write_json, get_logger
from wled_controller.storage.base_store import BaseJsonStore
from wled_controller.utils import get_logger
logger = get_logger(__name__)
class AutomationStore:
"""Persistent storage for automations."""
class AutomationStore(BaseJsonStore[Automation]):
_json_key = "automations"
_entity_name = "Automation"
def __init__(self, file_path: str):
self.file_path = Path(file_path)
self._automations: Dict[str, Automation] = {}
self._load()
super().__init__(file_path, Automation.from_dict)
def _load(self) -> None:
if not self.file_path.exists():
return
try:
with open(self.file_path, "r", encoding="utf-8") as f:
data = json.load(f)
automations_data = data.get("automations", {})
loaded = 0
for auto_id, auto_dict in automations_data.items():
try:
automation = Automation.from_dict(auto_dict)
self._automations[auto_id] = automation
loaded += 1
except Exception as e:
logger.error(f"Failed to load automation {auto_id}: {e}", exc_info=True)
if loaded > 0:
logger.info(f"Loaded {loaded} automations from storage")
except Exception as e:
logger.error(f"Failed to load automations from {self.file_path}: {e}")
raise
logger.info(f"Automation store initialized with {len(self._automations)} automations")
def _save(self) -> None:
try:
data = {
"version": "1.0.0",
"automations": {
aid: a.to_dict() for aid, a in self._automations.items()
},
}
atomic_write_json(self.file_path, data)
except Exception as e:
logger.error(f"Failed to save automations to {self.file_path}: {e}")
raise
def get_all_automations(self) -> List[Automation]:
return list(self._automations.values())
def get_automation(self, automation_id: str) -> Automation:
if automation_id not in self._automations:
raise ValueError(f"Automation not found: {automation_id}")
return self._automations[automation_id]
# Backward-compatible aliases
get_all_automations = BaseJsonStore.get_all
get_automation = BaseJsonStore.get
delete_automation = BaseJsonStore.delete
def create_automation(
self,
@@ -77,13 +32,14 @@ class AutomationStore:
scene_preset_id: Optional[str] = None,
deactivation_mode: str = "none",
deactivation_scene_preset_id: Optional[str] = None,
tags: Optional[List[str]] = None,
) -> Automation:
for a in self._automations.values():
for a in self._items.values():
if a.name == name:
raise ValueError(f"Automation with name '{name}' already exists")
automation_id = f"auto_{uuid.uuid4().hex[:8]}"
now = datetime.utcnow()
now = datetime.now(timezone.utc)
automation = Automation(
id=automation_id,
@@ -96,11 +52,11 @@ class AutomationStore:
deactivation_scene_preset_id=deactivation_scene_preset_id,
created_at=now,
updated_at=now,
tags=tags or [],
)
self._automations[automation_id] = automation
self._items[automation_id] = automation
self._save()
logger.info(f"Created automation: {name} ({automation_id})")
return automation
@@ -114,16 +70,12 @@ class AutomationStore:
scene_preset_id: str = "__unset__",
deactivation_mode: Optional[str] = None,
deactivation_scene_preset_id: str = "__unset__",
tags: Optional[List[str]] = None,
) -> Automation:
if automation_id not in self._automations:
raise ValueError(f"Automation not found: {automation_id}")
automation = self._automations[automation_id]
automation = self.get(automation_id)
if name is not None:
for aid, a in self._automations.items():
if aid != automation_id and a.name == name:
raise ValueError(f"Automation with name '{name}' already exists")
self._check_name_unique(name, exclude_id=automation_id)
automation.name = name
if enabled is not None:
automation.enabled = enabled
@@ -137,21 +89,10 @@ class AutomationStore:
automation.deactivation_mode = deactivation_mode
if deactivation_scene_preset_id != "__unset__":
automation.deactivation_scene_preset_id = deactivation_scene_preset_id
if tags is not None:
automation.tags = tags
automation.updated_at = datetime.utcnow()
automation.updated_at = datetime.now(timezone.utc)
self._save()
logger.info(f"Updated automation: {automation_id}")
return automation
def delete_automation(self, automation_id: str) -> None:
if automation_id not in self._automations:
raise ValueError(f"Automation not found: {automation_id}")
del self._automations[automation_id]
self._save()
logger.info(f"Deleted automation: {automation_id}")
def count(self) -> int:
return len(self._automations)