from __future__ import annotations import errno import hashlib import json import threading from pathlib import Path from typing import Any from uuid import UUID from app.persistence import atomic_write_bytes LIBRARY_ORDER_SCHEMA_VERSION = 1 LIBRARY_ITEM_TYPES = frozenset({"template", "animation"}) LIBRARY_ORDER_FIELDS = frozenset({"schema_version", "items"}) LIBRARY_ITEM_FIELDS = frozenset({"type", "id"}) class LibraryOrderError(RuntimeError): pass class LibraryOrderValidationError(LibraryOrderError): pass class LibraryOrderConflictError(LibraryOrderError): pass class LibraryOrderStorageFullError(LibraryOrderError): pass def normalize_library_items(value: Any) -> list[dict[str, str]]: if not isinstance(value, list): raise LibraryOrderValidationError("library order items must be an array") normalized: list[dict[str, str]] = [] seen: set[tuple[str, str]] = set() for item in value: if not isinstance(item, dict) or set(item) != LIBRARY_ITEM_FIELDS: raise LibraryOrderValidationError("library order item fields are invalid") item_type = item.get("type") item_id = item.get("id") if item_type not in LIBRARY_ITEM_TYPES: raise LibraryOrderValidationError("library order item type is invalid") if not isinstance(item_id, str): raise LibraryOrderValidationError("library order item id must be a UUID") try: normalized_id = str(UUID(item_id)) except (ValueError, TypeError, AttributeError) as exc: raise LibraryOrderValidationError("library order item id must be a UUID") from exc if normalized_id != item_id: raise LibraryOrderValidationError("library order item id must be a canonical UUID") key = (item_type, normalized_id) if key in seen: raise LibraryOrderValidationError("library order items must be unique") seen.add(key) normalized.append({"type": item_type, "id": normalized_id}) return normalized def library_order_revision(items: list[dict[str, str]]) -> str: canonical = json.dumps(items, ensure_ascii=False, sort_keys=True, separators=(",", ":")) return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:32] class LibraryOrderStore: def __init__(self, data_dir: Path, *, forbidden_ids: set[str] | None = None): self.path = Path(data_dir) / "library" / "order.json" self._forbidden_ids = set(forbidden_ids or ()) self._lock = threading.RLock() if self.path.exists(): self._read_items() @staticmethod def _json_bytes(items: list[dict[str, str]]) -> bytes: document = { "schema_version": LIBRARY_ORDER_SCHEMA_VERSION, "items": items, } return (json.dumps(document, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8") def _read_items(self) -> list[dict[str, str]] | None: if not self.path.exists(): return None try: document = json.loads(self.path.read_bytes().decode("utf-8")) except (OSError, UnicodeError, json.JSONDecodeError) as exc: raise LibraryOrderValidationError("library order file is unreadable") from exc if not isinstance(document, dict) or set(document) != LIBRARY_ORDER_FIELDS: raise LibraryOrderValidationError("library order fields do not match the current schema") version = document.get("schema_version") if type(version) is not int or version != LIBRARY_ORDER_SCHEMA_VERSION: raise LibraryOrderValidationError("library order schema version is unsupported") items = normalize_library_items(document.get("items")) if any(item["id"] in self._forbidden_ids for item in items): raise LibraryOrderValidationError("demo items cannot be included in library order") return items @staticmethod def _resolve_items( stored: list[dict[str, str]] | None, defaults: list[dict[str, str]], ) -> list[dict[str, str]]: current = {(item["type"], item["id"]) for item in defaults} resolved = [item for item in (stored or []) if (item["type"], item["id"]) in current] included = {(item["type"], item["id"]) for item in resolved} resolved.extend(item for item in defaults if (item["type"], item["id"]) not in included) return resolved def get(self, default_items: Any) -> dict[str, Any]: defaults = normalize_library_items(default_items) with self._lock: items = self._resolve_items(self._read_items(), defaults) return {"items": items, "revision": library_order_revision(items)} def update(self, items: Any, default_items: Any, expected_revision: str) -> dict[str, Any]: requested = normalize_library_items(items) if any(item["id"] in self._forbidden_ids for item in requested): raise LibraryOrderValidationError("demo items cannot be included in library order") defaults = normalize_library_items(default_items) with self._lock: current = self._resolve_items(self._read_items(), defaults) if library_order_revision(current) != expected_revision: raise LibraryOrderConflictError("library order was changed by another client") requested_set = {(item["type"], item["id"]) for item in requested} current_set = {(item["type"], item["id"]) for item in defaults} if requested_set != current_set: raise LibraryOrderConflictError("library contents changed; refresh before sorting") try: atomic_write_bytes(self.path, self._json_bytes(requested)) except OSError as exc: if exc.errno in {errno.ENOSPC, getattr(errno, "EDQUOT", errno.ENOSPC)}: raise LibraryOrderStorageFullError("not enough disk space to save library order") from exc raise return {"items": requested, "revision": library_order_revision(requested)}