"""Script/prompt file management — CRUD for sanad_script.txt, sanad_rule.txt, etc.""" from __future__ import annotations import asyncio from datetime import datetime from pathlib import Path from fastapi import APIRouter, HTTPException from pydantic import BaseModel from Project.Sanad.config import SCRIPTS_DIR from Project.Sanad.core import persona as _persona from Project.Sanad.dashboard.routes._safe_io import ( atomic_write_text, MAX_UPLOAD_BYTES, ) router = APIRouter() MAX_SCRIPT_BYTES = MAX_UPLOAD_BYTES def _safe_path(name: str) -> Path: cleaned = name.strip() if not cleaned or "/" in cleaned or "\\" in cleaned or cleaned in {".", ".."}: raise HTTPException(400, "Invalid script name.") path = (SCRIPTS_DIR / cleaned).resolve() if not str(path).startswith(str(SCRIPTS_DIR.resolve())): raise HTTPException(400, "Path traversal denied.") return path @router.get("/") async def list_scripts(): SCRIPTS_DIR.mkdir(parents=True, exist_ok=True) active = _persona.active_persona_name() default = _persona.default_persona_name() items = [] for p in sorted(SCRIPTS_DIR.iterdir(), key=lambda x: x.name.lower()): if not p.is_file(): continue st = p.stat() items.append({ "name": p.name, "size_bytes": st.st_size, "modified_at": datetime.fromtimestamp(st.st_mtime).isoformat(timespec="seconds"), "active": p.name == active, # the persona Gemini loads now "is_default": p.name == default, # the fallback (sanad_script.txt) }) return {"path": str(SCRIPTS_DIR), "files": items, "active": active, "default": default} class ScriptActive(BaseModel): name: str | None = None # None / "" / the default name → revert to default restart: bool = False # also restart the live voice so it takes effect now @router.get("/active") async def get_active(): """Which persona Gemini will load, and the default it falls back to.""" return {"active": _persona.active_persona_name(), "default": _persona.default_persona_name()} @router.post("/active") async def set_active(payload: ScriptActive): """Select the persona script Gemini uses. With restart=true, the live voice session is bounced so the new persona takes effect immediately; otherwise it applies on the next voice (re)connect.""" try: active = _persona.set_active_persona(payload.name) except FileNotFoundError: raise HTTPException(404, f"Script not found: {payload.name}") restarted = False if payload.restart: try: from Project.Sanad.main import live_sub if live_sub is not None and hasattr(live_sub, "start"): if hasattr(live_sub, "is_running") and live_sub.is_running(): await asyncio.to_thread(live_sub.stop) await asyncio.sleep(1.5) await asyncio.to_thread(live_sub.start) restarted = True except Exception: pass # selection is saved regardless of restart success return {"ok": True, "active": active, "default": _persona.default_persona_name(), "restarted": restarted} class ScriptLoad(BaseModel): name: str @router.post("/load") async def load_script(payload: ScriptLoad): path = _safe_path(payload.name) if not path.exists(): raise HTTPException(404, f"Script not found: {payload.name}") content = path.read_text(encoding="utf-8-sig") st = path.stat() return { "name": path.name, "content": content, "size_bytes": st.st_size, "modified_at": datetime.fromtimestamp(st.st_mtime).isoformat(timespec="seconds"), } class ScriptSave(BaseModel): name: str content: str @router.post("/save") async def save_script(payload: ScriptSave): if len(payload.content.encode("utf-8")) > MAX_SCRIPT_BYTES: raise HTTPException(413, f"Content too large (max {MAX_SCRIPT_BYTES} bytes).") path = _safe_path(payload.name) SCRIPTS_DIR.mkdir(parents=True, exist_ok=True) atomic_write_text(path, payload.content) return {"ok": True, "name": path.name, "size_bytes": path.stat().st_size} class ScriptCreate(BaseModel): name: str content: str = "" @router.post("/create") async def create_script(payload: ScriptCreate): if len(payload.content.encode("utf-8")) > MAX_SCRIPT_BYTES: raise HTTPException(413, f"Content too large (max {MAX_SCRIPT_BYTES} bytes).") path = _safe_path(payload.name) if path.exists(): raise HTTPException(409, f"File already exists: {payload.name}") SCRIPTS_DIR.mkdir(parents=True, exist_ok=True) atomic_write_text(path, payload.content) return {"ok": True, "name": path.name} class ScriptRename(BaseModel): old_name: str new_name: str @router.post("/rename") async def rename_script(payload: ScriptRename): old = _safe_path(payload.old_name) new = _safe_path(payload.new_name) if not old.exists(): raise HTTPException(404, f"Not found: {payload.old_name}") if new.exists(): raise HTTPException(409, f"Already exists: {payload.new_name}") old.rename(new) return {"ok": True, "old_name": payload.old_name, "new_name": new.name} class ScriptDelete(BaseModel): name: str @router.post("/delete") async def delete_script(payload: ScriptDelete): path = _safe_path(payload.name) if not path.exists(): raise HTTPException(404, f"Not found: {payload.name}") if path.name == _persona.default_persona_name(): raise HTTPException(409, f"Cannot delete the default persona ({path.name}).") path.unlink() # If the active selection was the deleted file, resolution auto-falls-back # to the default — no extra cleanup needed. return {"ok": True, "deleted": payload.name}