139 lines
6.0 KiB
Python
139 lines
6.0 KiB
Python
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)}
|