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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user