63e3c8d607
- Each dashboard is now a self-contained folder: manifest.json + index.html - Auto-scan dashboards/ and data/dashboards/ on startup - Export: .tsd (dashboards), .tss (scenes), .tsp (game plugins) - all zip format - Dashboard index.html is complete standalone page (WS + data binding) - Aspect ratio constraints handled in each dashboard's own JS - Removed template-based dashboard rendering in favor of static serve - Import via file upload endpoints, export via direct file download
155 lines
5.0 KiB
Python
155 lines
5.0 KiB
Python
from __future__ import annotations
|
|
|
|
import io
|
|
import json
|
|
import shutil
|
|
import uuid
|
|
import zipfile
|
|
from dataclasses import dataclass, field, asdict
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from utils.logger import get_logger
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
DATA_DIR = Path(__file__).resolve().parent.parent / "data"
|
|
SCENES_DIR = DATA_DIR / "scenes"
|
|
|
|
|
|
@dataclass
|
|
class DashboardPlacement:
|
|
dashboard_id: str = ""
|
|
x: float = 0.0
|
|
y: float = 0.0
|
|
width: int = 400
|
|
height: int = 300
|
|
z_index: int = 0
|
|
scale_x: float = 1.0
|
|
scale_y: float = 1.0
|
|
aspect_ratio: str = "auto"
|
|
render_mode: str = "contain"
|
|
custom_config: dict[str, Any] = field(default_factory=dict)
|
|
|
|
|
|
@dataclass
|
|
class SceneCanvas:
|
|
width: int = 1920
|
|
height: int = 1080
|
|
label: str = "16:9"
|
|
placements: list[DashboardPlacement] = field(default_factory=list)
|
|
|
|
|
|
@dataclass
|
|
class Scene:
|
|
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
name: str = "New Scene"
|
|
game_id: str = ""
|
|
description: str = ""
|
|
canvases: list[SceneCanvas] = field(default_factory=list)
|
|
created_at: str = field(default_factory=lambda: datetime.now().isoformat())
|
|
updated_at: str = field(default_factory=lambda: datetime.now().isoformat())
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
d = asdict(self)
|
|
d["canvases"] = [asdict(c) for c in self.canvases]
|
|
return d
|
|
|
|
@classmethod
|
|
def from_dict(cls, d: dict[str, Any]) -> Scene:
|
|
canvases = []
|
|
for cd in d.get("canvases", []):
|
|
placements = [DashboardPlacement(**p) for p in cd.get("placements", [])]
|
|
canvases.append(SceneCanvas(
|
|
width=cd.get("width", 1920),
|
|
height=cd.get("height", 1080),
|
|
label=cd.get("label", ""),
|
|
placements=placements,
|
|
))
|
|
return cls(
|
|
id=d.get("id", str(uuid.uuid4())),
|
|
name=d.get("name", "New Scene"),
|
|
game_id=d.get("game_id", ""),
|
|
description=d.get("description", ""),
|
|
canvases=canvases,
|
|
created_at=d.get("created_at", datetime.now().isoformat()),
|
|
updated_at=d.get("updated_at", datetime.now().isoformat()),
|
|
)
|
|
|
|
|
|
class SceneManager:
|
|
def __init__(self):
|
|
SCENES_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
def list_all(self, game_id: str | None = None) -> list[dict[str, Any]]:
|
|
scenes = []
|
|
for f in SCENES_DIR.glob("*.json"):
|
|
try:
|
|
with open(f, "r", encoding="utf-8") as fp:
|
|
data = json.load(fp)
|
|
scene = Scene.from_dict(data)
|
|
if game_id is None or scene.game_id == game_id:
|
|
scenes.append(scene.to_dict())
|
|
except Exception as e:
|
|
logger.error("Failed to load scene %s: %s", f.name, e)
|
|
return scenes
|
|
|
|
def get(self, scene_id: str) -> dict[str, Any] | None:
|
|
filepath = SCENES_DIR / f"{scene_id}.json"
|
|
if not filepath.exists():
|
|
return None
|
|
try:
|
|
with open(filepath, "r", encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
return Scene.from_dict(data).to_dict()
|
|
except Exception as e:
|
|
logger.error("Failed to read scene %s: %s", scene_id, e)
|
|
return None
|
|
|
|
def save(self, scene: Scene) -> bool:
|
|
scene.updated_at = datetime.now().isoformat()
|
|
filepath = SCENES_DIR / f"{scene.id}.json"
|
|
try:
|
|
with open(filepath, "w", encoding="utf-8") as f:
|
|
json.dump(scene.to_dict(), f, indent=2, ensure_ascii=False)
|
|
logger.info("Scene saved: %s", scene.id)
|
|
return True
|
|
except Exception as e:
|
|
logger.error("Failed to save scene %s: %s", scene.id, e)
|
|
return False
|
|
|
|
def delete(self, scene_id: str) -> bool:
|
|
filepath = SCENES_DIR / f"{scene_id}.json"
|
|
if filepath.exists():
|
|
filepath.unlink()
|
|
logger.info("Scene deleted: %s", scene_id)
|
|
return True
|
|
return False
|
|
|
|
def export_scene_zip(self, scene_id: str) -> bytes | None:
|
|
filepath = SCENES_DIR / f"{scene_id}.json"
|
|
if not filepath.exists():
|
|
return None
|
|
buf = io.BytesIO()
|
|
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
|
|
zf.write(filepath, "scene.json")
|
|
logger.info("Scene exported as zip: %s", scene_id)
|
|
return buf.getvalue()
|
|
|
|
def import_scene_zip(self, zip_data: bytes) -> bool:
|
|
try:
|
|
with zipfile.ZipFile(io.BytesIO(zip_data), 'r') as zf:
|
|
if "scene.json" not in zf.namelist():
|
|
logger.error("No scene.json found in zip")
|
|
return False
|
|
data = json.loads(zf.read("scene.json").decode('utf-8'))
|
|
scene = Scene.from_dict(data)
|
|
return self.save(scene)
|
|
except Exception as e:
|
|
logger.error("Failed to import scene zip: %s", e)
|
|
return False
|
|
|
|
|
|
scene_manager = SceneManager()
|