102 lines
3.3 KiB
Python
102 lines
3.3 KiB
Python
from __future__ import annotations
|
||
|
||
from pathlib import Path
|
||
|
||
from fastapi import APIRouter, HTTPException, Request
|
||
from pydantic import BaseModel, Field
|
||
|
||
from app.presets import PRESETS
|
||
from app.services.i18n import DEFAULT_LOCALE, locale_from_request
|
||
|
||
router = APIRouter(prefix="/api")
|
||
|
||
CUSTOM_SIMULATION_DIR = Path(__file__).resolve().parents[3] / "custom_simulation"
|
||
|
||
|
||
class CustomPresetPayload(BaseModel):
|
||
title: str = Field(min_length=1, max_length=160)
|
||
content: str = Field(min_length=1)
|
||
|
||
|
||
def _localize(value, locale: str):
|
||
"""Recursively unwrap {en, zh} dicts to the requested locale.
|
||
|
||
Falls back to the default locale, then to the first available value.
|
||
Strings (and other scalars) are returned as-is.
|
||
"""
|
||
if isinstance(value, dict):
|
||
# Heuristic: dicts with only "en"/"zh" keys are translation maps.
|
||
keys = set(value.keys())
|
||
if keys <= {"en", "zh"}:
|
||
if locale in value:
|
||
return value[locale]
|
||
if DEFAULT_LOCALE in value:
|
||
return value[DEFAULT_LOCALE]
|
||
return next(iter(value.values()))
|
||
return {k: _localize(v, locale) for k, v in value.items()}
|
||
if isinstance(value, list):
|
||
return [_localize(v, locale) for v in value]
|
||
return value
|
||
|
||
|
||
@router.get("/presets")
|
||
async def get_presets(request: Request):
|
||
locale = locale_from_request(request)
|
||
return {"presets": _localize(PRESETS, locale)}
|
||
|
||
|
||
def _custom_presets():
|
||
CUSTOM_SIMULATION_DIR.mkdir(parents=True, exist_ok=True)
|
||
presets = []
|
||
for path in sorted(CUSTOM_SIMULATION_DIR.glob("*.txt"), key=lambda p: p.name.lower()):
|
||
try:
|
||
content = path.read_text(encoding="utf-8")
|
||
except (OSError, UnicodeDecodeError):
|
||
continue
|
||
presets.append({
|
||
"id": f"custom:{path.name}",
|
||
"name": path.stem,
|
||
"rules": content,
|
||
"custom": True,
|
||
"filename": path.name,
|
||
})
|
||
return presets
|
||
|
||
|
||
@router.get("/custom-presets")
|
||
async def get_custom_presets():
|
||
"""Load user-authored scenario files from the project custom_simulation folder."""
|
||
return {"presets": _custom_presets()}
|
||
|
||
|
||
@router.post("/custom-presets")
|
||
async def create_custom_preset(payload: CustomPresetPayload):
|
||
"""Save a custom scenario, suffixing duplicate titles with _1, _2, ... ."""
|
||
title = " ".join(payload.title.strip().split())
|
||
# Keep unicode letters (including Chinese) while preventing path traversal.
|
||
title = title.replace("/", "/").replace("\\", "\")
|
||
if not title or title in {".", ".."}:
|
||
raise HTTPException(status_code=400, detail="Title is required")
|
||
|
||
CUSTOM_SIMULATION_DIR.mkdir(parents=True, exist_ok=True)
|
||
candidate = CUSTOM_SIMULATION_DIR / f"{title}.txt"
|
||
index = 1
|
||
while candidate.exists():
|
||
candidate = CUSTOM_SIMULATION_DIR / f"{title}_{index}.txt"
|
||
index += 1
|
||
|
||
try:
|
||
candidate.write_text(payload.content, encoding="utf-8")
|
||
except OSError as exc:
|
||
raise HTTPException(status_code=500, detail=f"Unable to save preset: {exc}") from exc
|
||
|
||
return {
|
||
"preset": {
|
||
"id": f"custom:{candidate.name}",
|
||
"name": candidate.stem,
|
||
"rules": payload.content,
|
||
"custom": True,
|
||
"filename": candidate.name,
|
||
}
|
||
}
|