初始化奇妙小屏幕控制器项目
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
"""Editable scene template persistence and thumbnail rendering."""
|
||||
|
||||
@@ -0,0 +1,631 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import errno
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from app.display.colors import parse_color
|
||||
from app.display.text_renderer import FontResolver, TextOptions, render_text_layer
|
||||
from app.persistence import atomic_write_bytes, atomic_write_many_bytes
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SCENE_WIDTH = 64
|
||||
SCENE_HEIGHT = 64
|
||||
RGB_BYTE_LENGTH = SCENE_WIDTH * SCENE_HEIGHT * 3
|
||||
NAME_LIMIT = 80
|
||||
TEMPLATE_SCHEMA_VERSION = 1
|
||||
TEMPLATE_RECORD_FIELDS = frozenset({
|
||||
"id",
|
||||
"name",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"digest",
|
||||
"thumbnail",
|
||||
"scene",
|
||||
})
|
||||
SCENE_FIELDS = frozenset({"version", "width", "height", "pixelRgb", "elements"})
|
||||
TEXT_ELEMENT_FIELDS = frozenset({
|
||||
"id",
|
||||
"type",
|
||||
"text",
|
||||
"font",
|
||||
"size",
|
||||
"x",
|
||||
"y",
|
||||
"align",
|
||||
"color",
|
||||
})
|
||||
HEX_COLOR_RE = re.compile(r"^#[0-9A-Fa-f]{6}$")
|
||||
THUMBNAIL_RE = re.compile(
|
||||
r"^(?P<id>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})-(?P<digest>[0-9a-f]{16})\.png$"
|
||||
)
|
||||
|
||||
|
||||
class TemplateError(RuntimeError):
|
||||
"""Base error for template operations."""
|
||||
|
||||
|
||||
class TemplateValidationError(TemplateError):
|
||||
pass
|
||||
|
||||
|
||||
class TemplateConflictError(TemplateError):
|
||||
pass
|
||||
|
||||
|
||||
class TemplateNotFoundError(TemplateError):
|
||||
pass
|
||||
|
||||
|
||||
class TemplateStorageFullError(TemplateError):
|
||||
pass
|
||||
|
||||
|
||||
def _migrate_template_v0_to_v1(document: dict[str, Any]) -> dict[str, Any]:
|
||||
return dict(document)
|
||||
|
||||
|
||||
TEMPLATE_MIGRATIONS = {0: _migrate_template_v0_to_v1}
|
||||
|
||||
|
||||
def _is_record(value: Any) -> bool:
|
||||
return isinstance(value, dict)
|
||||
|
||||
|
||||
def normalize_name(value: Any) -> str:
|
||||
if not isinstance(value, str):
|
||||
raise TemplateValidationError("template name must be a string")
|
||||
name = value.strip()
|
||||
if not name or len(name) > NAME_LIMIT:
|
||||
raise TemplateValidationError(f"template name must contain 1..{NAME_LIMIT} characters")
|
||||
return name
|
||||
|
||||
|
||||
def normalize_template_id(value: Any) -> str:
|
||||
try:
|
||||
return str(UUID(str(value)))
|
||||
except (ValueError, TypeError, AttributeError) as exc:
|
||||
raise TemplateNotFoundError("template not found") from exc
|
||||
|
||||
|
||||
def validate_scene(value: Any, *, strict_schema: bool = False) -> dict[str, Any]:
|
||||
if not _is_record(value):
|
||||
raise TemplateValidationError("scene must be an object")
|
||||
if strict_schema:
|
||||
unknown = set(value) - SCENE_FIELDS
|
||||
missing = SCENE_FIELDS - set(value)
|
||||
if unknown or missing:
|
||||
raise TemplateValidationError("scene fields do not match the current schema")
|
||||
for field in ("version", "width", "height"):
|
||||
if type(value[field]) is not int:
|
||||
raise TemplateValidationError(f"scene {field} must be an integer")
|
||||
if value.get("version") != 1 or value.get("width") != 64 or value.get("height") != 64:
|
||||
raise TemplateValidationError("scene must be version 1 with a 64x64 frame")
|
||||
pixel_rgb = value.get("pixelRgb")
|
||||
if not isinstance(pixel_rgb, str):
|
||||
raise TemplateValidationError("scene pixelRgb must be base64 RGB888")
|
||||
try:
|
||||
decoded = base64.b64decode(pixel_rgb, validate=True)
|
||||
except (ValueError, binascii.Error) as exc:
|
||||
raise TemplateValidationError("scene pixelRgb must be valid base64 RGB888") from exc
|
||||
if len(decoded) != RGB_BYTE_LENGTH:
|
||||
raise TemplateValidationError("scene pixelRgb must contain 64x64 RGB888 bytes")
|
||||
|
||||
raw_elements = value.get("elements")
|
||||
if not isinstance(raw_elements, list):
|
||||
raise TemplateValidationError("scene elements must be an array")
|
||||
elements: list[dict[str, Any]] = []
|
||||
ids: set[str] = set()
|
||||
for raw in raw_elements:
|
||||
if not _is_record(raw) or raw.get("type") != "text":
|
||||
raise TemplateValidationError("scene contains an unsupported element type")
|
||||
if strict_schema:
|
||||
unknown = set(raw) - TEXT_ELEMENT_FIELDS
|
||||
missing = TEXT_ELEMENT_FIELDS - set(raw)
|
||||
if unknown or missing:
|
||||
raise TemplateValidationError("text element fields do not match the current schema")
|
||||
for field in ("size", "x", "y"):
|
||||
if type(raw[field]) is not int:
|
||||
raise TemplateValidationError(f"text element {field} must be an integer")
|
||||
element_id = raw.get("id")
|
||||
if not isinstance(element_id, str) or not element_id.strip() or len(element_id.strip()) > 128:
|
||||
raise TemplateValidationError("scene element id must contain 1..128 characters")
|
||||
element_id = element_id.strip()
|
||||
if element_id in ids:
|
||||
raise TemplateValidationError("scene element ids must be unique")
|
||||
ids.add(element_id)
|
||||
text = raw.get("text")
|
||||
font = raw.get("font")
|
||||
align = raw.get("align")
|
||||
color = raw.get("color")
|
||||
if not isinstance(text, str) or len(text) > 512:
|
||||
raise TemplateValidationError("text element text must contain at most 512 characters")
|
||||
if not isinstance(font, str) or not font.strip() or len(font.strip()) > 512:
|
||||
raise TemplateValidationError("text element font must contain 1..512 characters")
|
||||
if align not in {"left", "center", "right"}:
|
||||
raise TemplateValidationError("text element align must be left, center, or right")
|
||||
if not isinstance(color, str) or not HEX_COLOR_RE.fullmatch(color.strip()):
|
||||
raise TemplateValidationError("text element color must be #RRGGBB")
|
||||
try:
|
||||
size = int(raw.get("size"))
|
||||
x = int(raw.get("x"))
|
||||
y = int(raw.get("y"))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise TemplateValidationError("text element size and coordinates must be integers") from exc
|
||||
if not 1 <= size <= 64:
|
||||
raise TemplateValidationError("text element size must be in 1..64")
|
||||
if not -128 <= x <= 128 or not -128 <= y <= 128:
|
||||
raise TemplateValidationError("text element coordinates must be in -128..128")
|
||||
elements.append({
|
||||
"id": element_id,
|
||||
"type": "text",
|
||||
"text": text,
|
||||
"font": font.strip(),
|
||||
"size": size,
|
||||
"x": x,
|
||||
"y": y,
|
||||
"align": align,
|
||||
"color": color.strip().upper(),
|
||||
})
|
||||
return {
|
||||
"version": 1,
|
||||
"width": 64,
|
||||
"height": 64,
|
||||
"pixelRgb": base64.b64encode(decoded).decode("ascii"),
|
||||
"elements": elements,
|
||||
}
|
||||
|
||||
|
||||
def scene_digest(scene: dict[str, Any]) -> str:
|
||||
canonical = json.dumps(scene, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
def record_revision(record: dict[str, Any]) -> str:
|
||||
canonical = json.dumps(
|
||||
{
|
||||
"id": record["id"],
|
||||
"name": record["name"],
|
||||
"created_at": record["created_at"],
|
||||
"updated_at": record["updated_at"],
|
||||
"digest": record["digest"],
|
||||
"scene": record["scene"],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:32]
|
||||
|
||||
|
||||
ThumbnailRenderer = Callable[[Image.Image, dict[str, Any], FontResolver | None], Image.Image]
|
||||
|
||||
|
||||
def _render_text(
|
||||
base: Image.Image,
|
||||
element: dict[str, Any],
|
||||
font_resolver: FontResolver | None = None,
|
||||
) -> Image.Image:
|
||||
layer = render_text_layer(TextOptions(
|
||||
text=element["text"],
|
||||
font=element["font"],
|
||||
size=element["size"],
|
||||
x=element["x"],
|
||||
y=element["y"],
|
||||
align=element["align"],
|
||||
color=parse_color(element["color"]),
|
||||
), size=(SCENE_WIDTH, SCENE_HEIGHT), font_resolver=font_resolver)
|
||||
return Image.alpha_composite(base, layer)
|
||||
|
||||
|
||||
ELEMENT_RENDERERS: dict[str, ThumbnailRenderer] = {"text": _render_text}
|
||||
|
||||
|
||||
def render_scene_image(
|
||||
scene: dict[str, Any],
|
||||
*,
|
||||
font_resolver: FontResolver | None = None,
|
||||
) -> Image.Image:
|
||||
checked = validate_scene(scene)
|
||||
pixels = base64.b64decode(checked["pixelRgb"], validate=True)
|
||||
image = Image.frombytes("RGB", (SCENE_WIDTH, SCENE_HEIGHT), pixels).convert("RGBA")
|
||||
for element in checked["elements"]:
|
||||
renderer = ELEMENT_RENDERERS.get(element["type"])
|
||||
if renderer is None:
|
||||
raise TemplateValidationError(f"no thumbnail renderer for {element['type']}")
|
||||
image = renderer(image, element, font_resolver)
|
||||
return image.convert("RGB")
|
||||
|
||||
|
||||
def render_scene_thumbnail(
|
||||
scene: dict[str, Any],
|
||||
*,
|
||||
font_resolver: FontResolver | None = None,
|
||||
) -> bytes:
|
||||
image = render_scene_image(scene, font_resolver=font_resolver)
|
||||
output = BytesIO()
|
||||
image.save(output, format="PNG", optimize=True)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
class TemplateStore:
|
||||
def __init__(self, data_dir: Path, *, font_resolver: FontResolver | None = None) -> None:
|
||||
self.root = Path(data_dir) / "templates"
|
||||
self._font_resolver = font_resolver
|
||||
self.records_dir = self.root / "records"
|
||||
self.thumbnails_dir = self.root / "thumbnails"
|
||||
self._lock = threading.RLock()
|
||||
self.records_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.thumbnails_dir.mkdir(parents=True, exist_ok=True)
|
||||
with self._lock:
|
||||
records, migrations = self._preflight_records()
|
||||
self._write_migrations_atomically(migrations)
|
||||
self._reconcile(records)
|
||||
|
||||
def _record_path(self, template_id: str) -> Path:
|
||||
return self.records_dir / f"{template_id}.json"
|
||||
|
||||
def _thumbnail_path(self, template_id: str, digest: str) -> Path:
|
||||
return self.thumbnails_dir / f"{template_id}-{digest}.png"
|
||||
|
||||
@staticmethod
|
||||
def _timestamp() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
@staticmethod
|
||||
def _json_bytes(record: dict[str, Any]) -> bytes:
|
||||
persisted = {"schema_version": TEMPLATE_SCHEMA_VERSION, **record}
|
||||
return (json.dumps(persisted, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
|
||||
|
||||
@staticmethod
|
||||
def _translate_write_error(exc: OSError) -> None:
|
||||
if exc.errno == errno.ENOSPC:
|
||||
raise TemplateStorageFullError("not enough disk space to save template") from exc
|
||||
raise TemplateError(str(exc)) from exc
|
||||
|
||||
def _write_bytes_atomic(self, path: Path, content: bytes) -> None:
|
||||
try:
|
||||
atomic_write_bytes(path, content)
|
||||
except OSError as exc:
|
||||
self._translate_write_error(exc)
|
||||
|
||||
def _write_migrations_atomically(
|
||||
self,
|
||||
migrations: list[tuple[Path, dict[str, Any]]],
|
||||
) -> None:
|
||||
if not migrations:
|
||||
return
|
||||
try:
|
||||
atomic_write_many_bytes(
|
||||
(path, self._json_bytes(record))
|
||||
for path, record in migrations
|
||||
)
|
||||
except OSError as exc:
|
||||
self._translate_write_error(exc)
|
||||
|
||||
def _write_thumbnail(self, template_id: str, digest: str, scene: dict[str, Any]) -> Path:
|
||||
path = self._thumbnail_path(template_id, digest)
|
||||
if path.exists():
|
||||
return path
|
||||
content = render_scene_thumbnail(scene, font_resolver=self._font_resolver)
|
||||
self._write_bytes_atomic(path, content)
|
||||
try:
|
||||
with Image.open(path) as image:
|
||||
image.load()
|
||||
if image.size != (64, 64) or image.format != "PNG":
|
||||
raise TemplateError("generated thumbnail is invalid")
|
||||
except Exception:
|
||||
path.unlink(missing_ok=True)
|
||||
raise
|
||||
return path
|
||||
|
||||
def _parse_record_document(
|
||||
self,
|
||||
path: Path,
|
||||
raw: Any,
|
||||
) -> tuple[dict[str, Any], bool]:
|
||||
if not _is_record(raw):
|
||||
raise TemplateValidationError("template record must be an object")
|
||||
document = dict(raw)
|
||||
if "schema_version" in document:
|
||||
version = document.pop("schema_version")
|
||||
if type(version) is not int:
|
||||
raise TemplateValidationError("template schema_version must be an integer")
|
||||
else:
|
||||
version = 0
|
||||
if version > TEMPLATE_SCHEMA_VERSION:
|
||||
raise TemplateValidationError(
|
||||
f"template schema version {version} is newer than supported version "
|
||||
f"{TEMPLATE_SCHEMA_VERSION}"
|
||||
)
|
||||
if version < 0:
|
||||
raise TemplateValidationError(f"unsupported template schema version {version}")
|
||||
source_version = version
|
||||
while version < TEMPLATE_SCHEMA_VERSION:
|
||||
migrator = TEMPLATE_MIGRATIONS.get(version)
|
||||
if migrator is None:
|
||||
raise TemplateValidationError(
|
||||
f"no template migration is registered for version {version}"
|
||||
)
|
||||
document = migrator(document)
|
||||
version += 1
|
||||
|
||||
unknown = set(document) - TEMPLATE_RECORD_FIELDS
|
||||
if unknown:
|
||||
raise TemplateValidationError(
|
||||
f"unknown template fields: {', '.join(sorted(unknown))}"
|
||||
)
|
||||
missing = TEMPLATE_RECORD_FIELDS - set(document)
|
||||
if missing:
|
||||
raise TemplateValidationError(
|
||||
f"template record is missing fields: {', '.join(sorted(missing))}"
|
||||
)
|
||||
|
||||
try:
|
||||
template_id = normalize_template_id(document.get("id"))
|
||||
if path.stem != template_id:
|
||||
raise TemplateValidationError("template id does not match filename")
|
||||
name = normalize_name(document.get("name"))
|
||||
scene = validate_scene(
|
||||
document.get("scene"),
|
||||
strict_schema=source_version == TEMPLATE_SCHEMA_VERSION,
|
||||
)
|
||||
digest = scene_digest(scene)
|
||||
if (
|
||||
document.get("digest") != digest
|
||||
or document.get("thumbnail") != f"{template_id}-{digest}.png"
|
||||
):
|
||||
raise TemplateValidationError("template digest is invalid")
|
||||
created_at = str(document.get("created_at", ""))
|
||||
updated_at = str(document.get("updated_at", ""))
|
||||
if not created_at or not updated_at:
|
||||
raise TemplateValidationError("template timestamps are missing")
|
||||
record = {
|
||||
"id": template_id,
|
||||
"name": name,
|
||||
"created_at": created_at,
|
||||
"updated_at": updated_at,
|
||||
"digest": digest,
|
||||
"thumbnail": document["thumbnail"],
|
||||
"scene": scene,
|
||||
}
|
||||
except TemplateValidationError:
|
||||
raise
|
||||
except TemplateError as exc:
|
||||
raise TemplateValidationError(str(exc)) from exc
|
||||
except Exception as exc:
|
||||
raise TemplateValidationError("template record is invalid") from exc
|
||||
|
||||
if source_version == TEMPLATE_SCHEMA_VERSION and record != document:
|
||||
raise TemplateValidationError(
|
||||
f"template record does not match schema version {TEMPLATE_SCHEMA_VERSION}"
|
||||
)
|
||||
return record, source_version != TEMPLATE_SCHEMA_VERSION
|
||||
|
||||
def _load_record_path(self, path: Path) -> tuple[dict[str, Any], bool]:
|
||||
try:
|
||||
raw = json.loads(path.read_bytes().decode("utf-8"))
|
||||
return self._parse_record_document(path, raw)
|
||||
except TemplateValidationError as exc:
|
||||
raise TemplateValidationError(f"invalid template record {path.name}: {exc}") from exc
|
||||
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||
raise TemplateValidationError(f"invalid template record {path.name}: unreadable JSON") from exc
|
||||
|
||||
def _read_record_path(self, path: Path) -> dict[str, Any] | None:
|
||||
if not path.exists():
|
||||
return None
|
||||
record, migrated = self._load_record_path(path)
|
||||
if migrated:
|
||||
self._write_bytes_atomic(path, self._json_bytes(record))
|
||||
return record
|
||||
|
||||
def _preflight_records(
|
||||
self,
|
||||
) -> tuple[list[dict[str, Any]], list[tuple[Path, dict[str, Any]]]]:
|
||||
records: list[dict[str, Any]] = []
|
||||
migrations: list[tuple[Path, dict[str, Any]]] = []
|
||||
for path in sorted(self.records_dir.glob("*.json")):
|
||||
record, migrated = self._load_record_path(path)
|
||||
records.append(record)
|
||||
if migrated:
|
||||
migrations.append((path, record))
|
||||
return records, migrations
|
||||
|
||||
def _records(self) -> list[dict[str, Any]]:
|
||||
records: list[dict[str, Any]] = []
|
||||
for path in sorted(self.records_dir.glob("*.json")):
|
||||
record = self._read_record_path(path)
|
||||
if record is not None:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
def _reconcile(self, records: list[dict[str, Any]] | None = None) -> None:
|
||||
if records is None:
|
||||
records = self._records()
|
||||
expected: set[str] = set()
|
||||
for record in records:
|
||||
expected.add(record["thumbnail"])
|
||||
path = self.thumbnails_dir / record["thumbnail"]
|
||||
if not path.exists():
|
||||
try:
|
||||
self._write_thumbnail(record["id"], record["digest"], record["scene"])
|
||||
except Exception:
|
||||
logger.exception("Failed to rebuild template thumbnail: %s", record["id"])
|
||||
for path in self.thumbnails_dir.iterdir():
|
||||
if not path.is_file():
|
||||
continue
|
||||
if path.name.startswith(".") and path.name.endswith(".tmp"):
|
||||
path.unlink(missing_ok=True)
|
||||
elif THUMBNAIL_RE.fullmatch(path.name) and path.name not in expected:
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
def _ensure_unique_name(self, name: str, exclude_id: str | None = None) -> None:
|
||||
key = name.casefold()
|
||||
if any(record["id"] != exclude_id and record["name"].casefold() == key for record in self._records()):
|
||||
raise TemplateConflictError("template name already exists")
|
||||
|
||||
def _metadata(self, record: dict[str, Any]) -> dict[str, Any]:
|
||||
record_path = self._record_path(record["id"])
|
||||
thumbnail_path = self.thumbnails_dir / record["thumbnail"]
|
||||
size_bytes = (record_path.stat().st_size if record_path.exists() else 0)
|
||||
size_bytes += thumbnail_path.stat().st_size if thumbnail_path.exists() else 0
|
||||
return {
|
||||
"id": record["id"],
|
||||
"name": record["name"],
|
||||
"created_at": record["created_at"],
|
||||
"updated_at": record["updated_at"],
|
||||
"digest": record["digest"],
|
||||
"revision": record_revision(record),
|
||||
"thumbnail_url": f"/api/templates/{record['id']}/thumbnail?v={record['digest']}",
|
||||
"size_bytes": size_bytes,
|
||||
}
|
||||
|
||||
def list(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
records = sorted(self._records(), key=lambda item: item["updated_at"], reverse=True)
|
||||
templates = [self._metadata(record) for record in records]
|
||||
usage = shutil.disk_usage(self.root)
|
||||
return {
|
||||
"templates": templates,
|
||||
"storage": {
|
||||
"templates_bytes": sum(item["size_bytes"] for item in templates),
|
||||
"total_bytes": usage.total,
|
||||
"used_bytes": usage.used,
|
||||
"free_bytes": usage.free,
|
||||
},
|
||||
}
|
||||
|
||||
def get(self, template_id: Any) -> dict[str, Any]:
|
||||
normalized_id = normalize_template_id(template_id)
|
||||
with self._lock:
|
||||
record = self._read_record_path(self._record_path(normalized_id))
|
||||
if record is None:
|
||||
raise TemplateNotFoundError("template not found")
|
||||
return {**self._metadata(record), "scene": record["scene"]}
|
||||
|
||||
def create(self, name: Any, scene: Any) -> dict[str, Any]:
|
||||
checked_name = normalize_name(name)
|
||||
checked_scene = validate_scene(scene)
|
||||
with self._lock:
|
||||
self._ensure_unique_name(checked_name)
|
||||
template_id = str(uuid4())
|
||||
timestamp = self._timestamp()
|
||||
digest = scene_digest(checked_scene)
|
||||
record = {
|
||||
"id": template_id,
|
||||
"name": checked_name,
|
||||
"created_at": timestamp,
|
||||
"updated_at": timestamp,
|
||||
"digest": digest,
|
||||
"thumbnail": f"{template_id}-{digest}.png",
|
||||
"scene": checked_scene,
|
||||
}
|
||||
try:
|
||||
self._write_thumbnail(template_id, digest, checked_scene)
|
||||
self._write_bytes_atomic(self._record_path(template_id), self._json_bytes(record))
|
||||
except Exception:
|
||||
self._thumbnail_path(template_id, digest).unlink(missing_ok=True)
|
||||
raise
|
||||
self._reconcile()
|
||||
return {**self._metadata(record), "scene": record["scene"]}
|
||||
|
||||
@staticmethod
|
||||
def _ensure_revision(record: dict[str, Any], expected_revision: str) -> None:
|
||||
if record_revision(record) != expected_revision:
|
||||
raise TemplateConflictError("template was changed by another client")
|
||||
|
||||
def update_scene(self, template_id: Any, scene: Any, expected_revision: str) -> dict[str, Any]:
|
||||
normalized_id = normalize_template_id(template_id)
|
||||
checked_scene = validate_scene(scene)
|
||||
with self._lock:
|
||||
current = self._read_record_path(self._record_path(normalized_id))
|
||||
if current is None:
|
||||
raise TemplateNotFoundError("template not found")
|
||||
self._ensure_revision(current, expected_revision)
|
||||
digest = scene_digest(checked_scene)
|
||||
updated = {
|
||||
**current,
|
||||
"updated_at": self._timestamp(),
|
||||
"digest": digest,
|
||||
"thumbnail": f"{normalized_id}-{digest}.png",
|
||||
"scene": checked_scene,
|
||||
}
|
||||
self._write_thumbnail(normalized_id, digest, checked_scene)
|
||||
try:
|
||||
self._write_bytes_atomic(self._record_path(normalized_id), self._json_bytes(updated))
|
||||
except Exception:
|
||||
if digest != current["digest"]:
|
||||
self._thumbnail_path(normalized_id, digest).unlink(missing_ok=True)
|
||||
raise
|
||||
self._reconcile()
|
||||
return {**self._metadata(updated), "scene": updated["scene"]}
|
||||
|
||||
def rename(self, template_id: Any, name: Any, expected_revision: str) -> dict[str, Any]:
|
||||
normalized_id = normalize_template_id(template_id)
|
||||
checked_name = normalize_name(name)
|
||||
with self._lock:
|
||||
current = self._read_record_path(self._record_path(normalized_id))
|
||||
if current is None:
|
||||
raise TemplateNotFoundError("template not found")
|
||||
self._ensure_revision(current, expected_revision)
|
||||
self._ensure_unique_name(checked_name, exclude_id=normalized_id)
|
||||
updated = {**current, "name": checked_name, "updated_at": self._timestamp()}
|
||||
self._write_bytes_atomic(self._record_path(normalized_id), self._json_bytes(updated))
|
||||
self._reconcile()
|
||||
return {**self._metadata(updated), "scene": updated["scene"]}
|
||||
|
||||
def copy(self, template_id: Any) -> dict[str, Any]:
|
||||
current = self.get(template_id)
|
||||
with self._lock:
|
||||
base = f"{current['name']} - 副本"
|
||||
candidate = base
|
||||
sequence = 2
|
||||
names = {record["name"].casefold() for record in self._records()}
|
||||
while candidate.casefold() in names:
|
||||
candidate = f"{base} {sequence}"
|
||||
sequence += 1
|
||||
return self.create(candidate, current["scene"])
|
||||
|
||||
def delete(self, template_id: Any, expected_revision: str) -> None:
|
||||
normalized_id = normalize_template_id(template_id)
|
||||
with self._lock:
|
||||
path = self._record_path(normalized_id)
|
||||
current = self._read_record_path(path)
|
||||
if current is None:
|
||||
raise TemplateNotFoundError("template not found")
|
||||
self._ensure_revision(current, expected_revision)
|
||||
try:
|
||||
path.unlink()
|
||||
for thumbnail in self.thumbnails_dir.glob(f"{normalized_id}-*.png"):
|
||||
if THUMBNAIL_RE.fullmatch(thumbnail.name):
|
||||
thumbnail.unlink(missing_ok=True)
|
||||
except OSError as exc:
|
||||
self._translate_write_error(exc)
|
||||
self._reconcile()
|
||||
|
||||
def thumbnail_path(self, template_id: Any, digest: str | None = None) -> Path:
|
||||
normalized_id = normalize_template_id(template_id)
|
||||
with self._lock:
|
||||
record = self._read_record_path(self._record_path(normalized_id))
|
||||
if record is None:
|
||||
raise TemplateNotFoundError("template not found")
|
||||
if digest is not None and digest != record["digest"]:
|
||||
raise TemplateNotFoundError("template thumbnail not found")
|
||||
path = self.thumbnails_dir / record["thumbnail"]
|
||||
if not path.exists():
|
||||
raise TemplateNotFoundError("template thumbnail not found")
|
||||
return path
|
||||
Reference in New Issue
Block a user