Files
wled-screen-controller-mixed/server/src/wled_controller/storage/base_store.py
alexei.dolgolyov 30fa107ef7 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>
2026-03-09 22:20:19 +03:00

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"
)