refactor: standalone dashboard folders with index.html, zip-based import/export (.tsd/.tss/.tsp)
- 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
This commit is contained in:
+61
-28
@@ -103,22 +103,23 @@ async def api_get_dashboard(theme_id: str):
|
||||
|
||||
@router.get("/dashboards/{theme_id}/template")
|
||||
async def api_get_dashboard_template(theme_id: str):
|
||||
tmpl = dashboard_manager.get_template(theme_id)
|
||||
return {"html": tmpl}
|
||||
tmpl = dashboard_manager.get_index_html(theme_id)
|
||||
return {"html": tmpl or ""}
|
||||
|
||||
|
||||
@router.post("/dashboards")
|
||||
async def api_create_dashboard(data: dict[str, Any]):
|
||||
cfg = data.get("config", {})
|
||||
theme = DashboardTheme(
|
||||
name=data.get("name", "Untitled"),
|
||||
category=data.get("category", "basic"),
|
||||
description=data.get("description", ""),
|
||||
author=data.get("author", ""),
|
||||
config=data.get("config", {}),
|
||||
icon=cfg.get("icon", "📊"),
|
||||
aspect_ratio=cfg.get("aspect_ratio", "auto"),
|
||||
render_mode=cfg.get("render_mode", "contain"),
|
||||
)
|
||||
dashboard_manager.save(theme)
|
||||
if data.get("template_html"):
|
||||
dashboard_manager._save_template_file(theme.id, data["template_html"])
|
||||
dashboard_manager.save(theme, data.get("index_html", ""))
|
||||
return theme.to_dict()
|
||||
|
||||
|
||||
@@ -127,13 +128,17 @@ async def api_update_dashboard(theme_id: str, data: dict[str, Any]):
|
||||
existing = dashboard_manager.get(theme_id)
|
||||
if not existing:
|
||||
raise HTTPException(404, "Dashboard not found")
|
||||
theme = DashboardTheme.from_dict(existing)
|
||||
for k in ["name", "category", "description", "author", "config"]:
|
||||
if k in data:
|
||||
setattr(theme, k, data[k])
|
||||
dashboard_manager.save(theme)
|
||||
if "template_html" in data:
|
||||
dashboard_manager._save_template_file(theme.id, data["template_html"])
|
||||
theme = DashboardTheme(
|
||||
id=theme_id,
|
||||
name=data.get("name", existing.get("name", "")),
|
||||
category=data.get("category", existing.get("category", "basic")),
|
||||
description=data.get("description", existing.get("description", "")),
|
||||
author=data.get("author", existing.get("author", "")),
|
||||
icon=data.get("icon", existing.get("icon", "📊")),
|
||||
aspect_ratio=data.get("aspect_ratio", existing.get("aspect_ratio", "auto")),
|
||||
render_mode=data.get("render_mode", existing.get("render_mode", "contain")),
|
||||
)
|
||||
dashboard_manager.save(theme, data.get("index_html", ""))
|
||||
return theme.to_dict()
|
||||
|
||||
|
||||
@@ -145,15 +150,25 @@ async def api_delete_dashboard(theme_id: str):
|
||||
|
||||
@router.get("/dashboards/{theme_id}/export")
|
||||
async def api_export_dashboard(theme_id: str):
|
||||
result = dashboard_manager.export_theme(theme_id)
|
||||
if not result:
|
||||
from fastapi.responses import Response
|
||||
zip_bytes = dashboard_manager.export_theme_zip(theme_id)
|
||||
if not zip_bytes:
|
||||
raise HTTPException(404, "Dashboard not found")
|
||||
return result
|
||||
theme = dashboard_manager.get(theme_id)
|
||||
filename = f"{theme['id']}.tsd" if theme else "dashboard.tsd"
|
||||
return Response(
|
||||
content=zip_bytes,
|
||||
media_type="application/zip",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/dashboards/import")
|
||||
async def api_import_dashboard(data: dict[str, Any]):
|
||||
ok = dashboard_manager.import_theme(data)
|
||||
async def api_import_dashboard(file: UploadFile = File(...)):
|
||||
if not file.filename or not file.filename.endswith('.tsd'):
|
||||
raise HTTPException(400, "Only .tsd files are accepted")
|
||||
zip_data = await file.read()
|
||||
ok = dashboard_manager.import_theme_zip(zip_data)
|
||||
return {"ok": ok}
|
||||
|
||||
|
||||
@@ -224,15 +239,25 @@ async def api_delete_scene(scene_id: str):
|
||||
|
||||
@router.get("/scenes/{scene_id}/export")
|
||||
async def api_export_scene(scene_id: str):
|
||||
result = scene_manager.export_scene(scene_id)
|
||||
if not result:
|
||||
from fastapi.responses import Response
|
||||
zip_bytes = scene_manager.export_scene_zip(scene_id)
|
||||
if not zip_bytes:
|
||||
raise HTTPException(404, "Scene not found")
|
||||
return result
|
||||
scene = scene_manager.get(scene_id)
|
||||
filename = f"{scene['id']}.tss" if scene else "scene.tss"
|
||||
return Response(
|
||||
content=zip_bytes,
|
||||
media_type="application/zip",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/scenes/import")
|
||||
async def api_import_scene(data: dict[str, Any]):
|
||||
ok = scene_manager.import_scene(data)
|
||||
async def api_import_scene(file: UploadFile = File(...)):
|
||||
if not file.filename or not file.filename.endswith('.tss'):
|
||||
raise HTTPException(400, "Only .tss files are accepted")
|
||||
zip_data = await file.read()
|
||||
ok = scene_manager.import_scene_zip(zip_data)
|
||||
return {"ok": ok}
|
||||
|
||||
|
||||
@@ -254,8 +279,11 @@ async def api_get_game(plugin_id: str):
|
||||
|
||||
|
||||
@router.post("/games/install")
|
||||
async def api_install_game_plugin(data: dict[str, Any]):
|
||||
ok = game_plugin_manager.install_plugin(data)
|
||||
async def api_install_game_plugin(file: UploadFile = File(...)):
|
||||
if not file.filename or not file.filename.endswith('.tsp'):
|
||||
raise HTTPException(400, "Only .tsp files are accepted")
|
||||
zip_data = await file.read()
|
||||
ok = game_plugin_manager.install_plugin_zip(zip_data)
|
||||
return {"ok": ok}
|
||||
|
||||
|
||||
@@ -267,10 +295,15 @@ async def api_remove_game_plugin(plugin_id: str):
|
||||
|
||||
@router.get("/games/{plugin_id}/export")
|
||||
async def api_export_game_plugin(plugin_id: str):
|
||||
result = game_plugin_manager.export_plugin(plugin_id)
|
||||
if not result:
|
||||
from fastapi.responses import Response
|
||||
zip_bytes = game_plugin_manager.export_plugin_zip(plugin_id)
|
||||
if not zip_bytes:
|
||||
raise HTTPException(404, "Game plugin not found")
|
||||
return result
|
||||
return Response(
|
||||
content=zip_bytes,
|
||||
media_type="application/zip",
|
||||
headers={"Content-Disposition": f'attachment; filename="{plugin_id}.tsp"'},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/games/reload")
|
||||
|
||||
+52
-53
@@ -1,6 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import shutil
|
||||
import zipfile
|
||||
import importlib.util
|
||||
import sys
|
||||
from dataclasses import dataclass, field, asdict
|
||||
@@ -127,65 +130,61 @@ class GamePluginManager:
|
||||
self._discover()
|
||||
self._parser_cache.clear()
|
||||
|
||||
def install_plugin(self, data: dict[str, Any]) -> bool:
|
||||
plugin_id = data.get("id", "")
|
||||
if not plugin_id:
|
||||
def install_plugin_zip(self, zip_data: bytes) -> bool:
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(zip_data), 'r') as zf:
|
||||
names = zf.namelist()
|
||||
manifest_name = None
|
||||
for n in names:
|
||||
if n.endswith('manifest.json'):
|
||||
manifest_name = n
|
||||
break
|
||||
if not manifest_name:
|
||||
logger.error("No manifest.json found in plugin zip")
|
||||
return False
|
||||
|
||||
manifest = json.loads(zf.read(manifest_name).decode('utf-8'))
|
||||
plugin_id = manifest.get("id", "")
|
||||
if not plugin_id:
|
||||
return False
|
||||
|
||||
dest_dir = USER_DIR / plugin_id
|
||||
if dest_dir.exists():
|
||||
shutil.rmtree(dest_dir)
|
||||
dest_dir.mkdir(parents=True)
|
||||
|
||||
with open(dest_dir / "manifest.json", "w", encoding="utf-8") as f:
|
||||
json.dump(manifest, f, indent=2, ensure_ascii=False)
|
||||
|
||||
for name in names:
|
||||
if name == manifest_name or name.endswith('/'):
|
||||
continue
|
||||
if name.endswith('parser.py'):
|
||||
out_path = dest_dir / "parser.py"
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_path.write_bytes(zf.read(name))
|
||||
|
||||
self._discover()
|
||||
logger.info("Plugin installed from zip: %s", plugin_id)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Failed to install plugin zip: %s", e)
|
||||
return False
|
||||
|
||||
dest_dir = USER_DIR / plugin_id
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
manifest = data.get("manifest", {})
|
||||
with open(dest_dir / "manifest.json", "w", encoding="utf-8") as f:
|
||||
json.dump(manifest, f, indent=2, ensure_ascii=False)
|
||||
|
||||
if data.get("parser_code"):
|
||||
with open(dest_dir / "parser.py", "w", encoding="utf-8") as f:
|
||||
f.write(data["parser_code"])
|
||||
|
||||
self._discover()
|
||||
logger.info("Plugin installed: %s", plugin_id)
|
||||
return True
|
||||
|
||||
def remove_plugin(self, plugin_id: str) -> bool:
|
||||
gp = self._plugins.get(plugin_id)
|
||||
if gp and gp.is_builtin:
|
||||
logger.warning("Cannot remove builtin plugin: %s", plugin_id)
|
||||
return False
|
||||
|
||||
import shutil
|
||||
dest_dir = USER_DIR / plugin_id
|
||||
if dest_dir.exists():
|
||||
shutil.rmtree(dest_dir)
|
||||
self._discover()
|
||||
logger.info("Plugin removed: %s", plugin_id)
|
||||
return True
|
||||
return False
|
||||
|
||||
def export_plugin(self, plugin_id: str) -> dict[str, Any] | None:
|
||||
def export_plugin_zip(self, plugin_id: str) -> bytes | None:
|
||||
gp = self._plugins.get(plugin_id)
|
||||
if not gp:
|
||||
return None
|
||||
|
||||
parser_dir = Path(gp.manifest_path)
|
||||
manifest_file = parser_dir / "manifest.json"
|
||||
parser_file = parser_dir / "parser.py"
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"type": "game_plugin",
|
||||
"version": "1.0",
|
||||
"manifest": {},
|
||||
"parser_code": "",
|
||||
}
|
||||
|
||||
if manifest_file.exists():
|
||||
with open(manifest_file, "r", encoding="utf-8") as f:
|
||||
result["manifest"] = json.load(f)
|
||||
if parser_file.exists():
|
||||
with open(parser_file, "r", encoding="utf-8") as f:
|
||||
result["parser_code"] = f.read()
|
||||
|
||||
return result
|
||||
if not parser_dir.exists():
|
||||
return None
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
for f in sorted(parser_dir.rglob('*')):
|
||||
if f.is_file():
|
||||
zf.write(f, f.relative_to(parser_dir))
|
||||
logger.info("Plugin exported as zip: %s", plugin_id)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
game_plugin_manager = GamePluginManager()
|
||||
|
||||
Reference in New Issue
Block a user