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:
115
server/src/wled_controller/storage/base_store.py
Normal file
115
server/src/wled_controller/storage/base_store.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""Base class for JSON entity stores — eliminates boilerplate across 12+ stores."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Callable, Dict, Generic, List, TypeVar
|
||||
|
||||
from wled_controller.utils import atomic_write_json, get_logger
|
||||
|
||||
T = TypeVar("T")
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class BaseJsonStore(Generic[T]):
|
||||
"""JSON-file-backed entity store with common CRUD helpers.
|
||||
|
||||
Provides:
|
||||
- ``_load()`` / ``_save()``: atomic JSON file I/O
|
||||
- ``get_all()`` / ``get(id)`` / ``delete(id)`` / ``count()``: read/delete
|
||||
- ``_check_name_unique(name, exclude_id)``: duplicate-name guard
|
||||
|
||||
Subclasses must set class attributes:
|
||||
- ``_json_key``: root key in JSON file (e.g. ``"sync_clocks"``)
|
||||
- ``_entity_name``: human label for errors (e.g. ``"Sync clock"``)
|
||||
- ``_version``: schema version string (default ``"1.0.0"``)
|
||||
"""
|
||||
|
||||
_json_key: str
|
||||
_entity_name: str
|
||||
_version: str = "1.0.0"
|
||||
|
||||
def __init__(self, file_path: str, deserializer: Callable[[dict], T]):
|
||||
self.file_path = Path(file_path)
|
||||
self._items: Dict[str, T] = {}
|
||||
self._deserializer = deserializer
|
||||
self._load()
|
||||
|
||||
# ── I/O ────────────────────────────────────────────────────────
|
||||
|
||||
def _load(self) -> None:
|
||||
if not self.file_path.exists():
|
||||
logger.info(f"{self._entity_name} store file not found — starting empty")
|
||||
return
|
||||
|
||||
try:
|
||||
with open(self.file_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
items_data = data.get(self._json_key, {})
|
||||
loaded = 0
|
||||
for item_id, item_dict in items_data.items():
|
||||
try:
|
||||
self._items[item_id] = self._deserializer(item_dict)
|
||||
loaded += 1
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to load {self._entity_name} {item_id}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if loaded > 0:
|
||||
logger.info(f"Loaded {loaded} {self._json_key} from storage")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load {self._json_key} from {self.file_path}: {e}")
|
||||
raise
|
||||
|
||||
logger.info(
|
||||
f"{self._entity_name} store initialized with {len(self._items)} items"
|
||||
)
|
||||
|
||||
def _save(self) -> None:
|
||||
try:
|
||||
data = {
|
||||
"version": self._version,
|
||||
self._json_key: {
|
||||
item_id: item.to_dict()
|
||||
for item_id, item in self._items.items()
|
||||
},
|
||||
}
|
||||
atomic_write_json(self.file_path, data)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save {self._json_key} to {self.file_path}: {e}")
|
||||
raise
|
||||
|
||||
# ── Common CRUD ────────────────────────────────────────────────
|
||||
|
||||
def get_all(self) -> List[T]:
|
||||
return list(self._items.values())
|
||||
|
||||
def get(self, item_id: str) -> T:
|
||||
if item_id not in self._items:
|
||||
raise ValueError(f"{self._entity_name} not found: {item_id}")
|
||||
return self._items[item_id]
|
||||
|
||||
def delete(self, item_id: str) -> None:
|
||||
if item_id not in self._items:
|
||||
raise ValueError(f"{self._entity_name} not found: {item_id}")
|
||||
del self._items[item_id]
|
||||
self._save()
|
||||
logger.info(f"Deleted {self._entity_name}: {item_id}")
|
||||
|
||||
def count(self) -> int:
|
||||
return len(self._items)
|
||||
|
||||
# ── Helpers ────────────────────────────────────────────────────
|
||||
|
||||
def _check_name_unique(self, name: str, exclude_id: str = None) -> None:
|
||||
"""Raise ValueError if *name* is empty or already taken."""
|
||||
if not name or not name.strip():
|
||||
raise ValueError("Name is required")
|
||||
for item_id, item in self._items.items():
|
||||
if item_id != exclude_id and getattr(item, "name", None) == name:
|
||||
raise ValueError(
|
||||
f"{self._entity_name} with name '{name}' already exists"
|
||||
)
|
||||
Reference in New Issue
Block a user