- 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>
116 lines
4.3 KiB
Python
116 lines
4.3 KiB
Python
"""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"
|
|
)
|