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,19 +1,18 @@
|
||||
"""Template storage using JSON files."""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from wled_controller.core.capture_engines.factory import EngineRegistry
|
||||
from wled_controller.storage.base_store import BaseJsonStore
|
||||
from wled_controller.storage.template import CaptureTemplate
|
||||
from wled_controller.utils import atomic_write_json, get_logger
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class TemplateStore:
|
||||
class TemplateStore(BaseJsonStore[CaptureTemplate]):
|
||||
"""Storage for capture templates.
|
||||
|
||||
All templates are persisted to the JSON file.
|
||||
@@ -21,20 +20,21 @@ class TemplateStore:
|
||||
highest-priority available engine.
|
||||
"""
|
||||
|
||||
def __init__(self, file_path: str):
|
||||
"""Initialize template store.
|
||||
_json_key = "templates"
|
||||
_entity_name = "Capture template"
|
||||
|
||||
Args:
|
||||
file_path: Path to templates JSON file
|
||||
"""
|
||||
self.file_path = Path(file_path)
|
||||
self._templates: Dict[str, CaptureTemplate] = {}
|
||||
self._load()
|
||||
def __init__(self, file_path: str):
|
||||
super().__init__(file_path, CaptureTemplate.from_dict)
|
||||
self._ensure_initial_template()
|
||||
|
||||
# Backward-compatible aliases
|
||||
get_all_templates = BaseJsonStore.get_all
|
||||
get_template = BaseJsonStore.get
|
||||
delete_template = BaseJsonStore.delete
|
||||
|
||||
def _ensure_initial_template(self) -> None:
|
||||
"""Auto-create a template if none exist, using the best available engine."""
|
||||
if self._templates:
|
||||
if self._items:
|
||||
return
|
||||
|
||||
best_engine = EngineRegistry.get_best_available_engine()
|
||||
@@ -44,7 +44,7 @@ class TemplateStore:
|
||||
|
||||
engine_class = EngineRegistry.get_engine(best_engine)
|
||||
default_config = engine_class.get_default_config()
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(timezone.utc)
|
||||
template_id = f"tpl_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
template = CaptureTemplate(
|
||||
@@ -57,111 +57,22 @@ class TemplateStore:
|
||||
description=f"Default capture template using {best_engine.upper()} engine",
|
||||
)
|
||||
|
||||
self._templates[template_id] = template
|
||||
self._items[template_id] = template
|
||||
self._save()
|
||||
logger.info(f"Auto-created initial template: {template.name} ({template_id}, engine={best_engine})")
|
||||
|
||||
def _load(self) -> None:
|
||||
"""Load templates from file."""
|
||||
if not self.file_path.exists():
|
||||
return
|
||||
|
||||
try:
|
||||
with open(self.file_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
templates_data = data.get("templates", {})
|
||||
loaded = 0
|
||||
for template_id, template_dict in templates_data.items():
|
||||
try:
|
||||
template = CaptureTemplate.from_dict(template_dict)
|
||||
self._templates[template_id] = template
|
||||
loaded += 1
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to load template {template_id}: {e}",
|
||||
exc_info=True
|
||||
)
|
||||
|
||||
if loaded > 0:
|
||||
logger.info(f"Loaded {loaded} templates from storage")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load templates from {self.file_path}: {e}")
|
||||
raise
|
||||
|
||||
logger.info(f"Template store initialized with {len(self._templates)} templates")
|
||||
|
||||
def _save(self) -> None:
|
||||
"""Save all templates to file."""
|
||||
try:
|
||||
data = {
|
||||
"version": "1.0.0",
|
||||
"templates": {
|
||||
template_id: template.to_dict()
|
||||
for template_id, template in self._templates.items()
|
||||
},
|
||||
}
|
||||
atomic_write_json(self.file_path, data)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save templates to {self.file_path}: {e}")
|
||||
raise
|
||||
|
||||
def get_all_templates(self) -> List[CaptureTemplate]:
|
||||
"""Get all templates.
|
||||
|
||||
Returns:
|
||||
List of all templates
|
||||
"""
|
||||
return list(self._templates.values())
|
||||
|
||||
def get_template(self, template_id: str) -> CaptureTemplate:
|
||||
"""Get template by ID.
|
||||
|
||||
Args:
|
||||
template_id: Template ID
|
||||
|
||||
Returns:
|
||||
Template instance
|
||||
|
||||
Raises:
|
||||
ValueError: If template not found
|
||||
"""
|
||||
if template_id not in self._templates:
|
||||
raise ValueError(f"Template not found: {template_id}")
|
||||
return self._templates[template_id]
|
||||
|
||||
def create_template(
|
||||
self,
|
||||
name: str,
|
||||
engine_type: str,
|
||||
engine_config: Dict[str, any],
|
||||
description: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
) -> CaptureTemplate:
|
||||
"""Create a new template.
|
||||
self._check_name_unique(name)
|
||||
|
||||
Args:
|
||||
name: Template name
|
||||
engine_type: Engine type (mss, dxcam, wgc)
|
||||
engine_config: Engine-specific configuration
|
||||
description: Optional description
|
||||
|
||||
Returns:
|
||||
Created template
|
||||
|
||||
Raises:
|
||||
ValueError: If template with same name exists
|
||||
"""
|
||||
# Check for duplicate name
|
||||
for template in self._templates.values():
|
||||
if template.name == name:
|
||||
raise ValueError(f"Template with name '{name}' already exists")
|
||||
|
||||
# Generate new ID
|
||||
template_id = f"tpl_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Create template
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(timezone.utc)
|
||||
template = CaptureTemplate(
|
||||
id=template_id,
|
||||
name=name,
|
||||
@@ -170,10 +81,10 @@ class TemplateStore:
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
description=description,
|
||||
tags=tags or [],
|
||||
)
|
||||
|
||||
# Store and save
|
||||
self._templates[template_id] = template
|
||||
self._items[template_id] = template
|
||||
self._save()
|
||||
|
||||
logger.info(f"Created template: {name} ({template_id})")
|
||||
@@ -186,32 +97,12 @@ class TemplateStore:
|
||||
engine_type: Optional[str] = None,
|
||||
engine_config: Optional[Dict[str, any]] = None,
|
||||
description: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
) -> CaptureTemplate:
|
||||
"""Update an existing template.
|
||||
template = self.get(template_id)
|
||||
|
||||
Args:
|
||||
template_id: Template ID
|
||||
name: New name (optional)
|
||||
engine_type: New engine type (optional)
|
||||
engine_config: New engine config (optional)
|
||||
description: New description (optional)
|
||||
|
||||
Returns:
|
||||
Updated template
|
||||
|
||||
Raises:
|
||||
ValueError: If template not found
|
||||
"""
|
||||
if template_id not in self._templates:
|
||||
raise ValueError(f"Template not found: {template_id}")
|
||||
|
||||
template = self._templates[template_id]
|
||||
|
||||
# Update fields
|
||||
if name is not None:
|
||||
for tid, t in self._templates.items():
|
||||
if tid != template_id and t.name == name:
|
||||
raise ValueError(f"Template with name '{name}' already exists")
|
||||
self._check_name_unique(name, exclude_id=template_id)
|
||||
template.name = name
|
||||
if engine_type is not None:
|
||||
template.engine_type = engine_type
|
||||
@@ -219,29 +110,11 @@ class TemplateStore:
|
||||
template.engine_config = engine_config
|
||||
if description is not None:
|
||||
template.description = description
|
||||
if tags is not None:
|
||||
template.tags = tags
|
||||
|
||||
template.updated_at = datetime.utcnow()
|
||||
|
||||
# Save
|
||||
template.updated_at = datetime.now(timezone.utc)
|
||||
self._save()
|
||||
|
||||
logger.info(f"Updated template: {template_id}")
|
||||
return template
|
||||
|
||||
def delete_template(self, template_id: str) -> None:
|
||||
"""Delete a template.
|
||||
|
||||
Args:
|
||||
template_id: Template ID
|
||||
|
||||
Raises:
|
||||
ValueError: If template not found
|
||||
"""
|
||||
if template_id not in self._templates:
|
||||
raise ValueError(f"Template not found: {template_id}")
|
||||
|
||||
# Remove and save
|
||||
del self._templates[template_id]
|
||||
self._save()
|
||||
|
||||
logger.info(f"Deleted template: {template_id}")
|
||||
|
||||
Reference in New Issue
Block a user