Files
micronomicon/backend/pages.py
2026-04-01 08:28:44 +02:00

186 lines
5.3 KiB
Python

import os
import shlex
from pathlib import Path
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
import uframe
router = APIRouter()
PAGES_DIR = Path(os.environ.get("PAGES_DIR", "/data/pages"))
SOURCES_DIR = Path(os.environ.get("SOURCES_DIR", "/data/sources"))
class PageMeta(BaseModel):
name: str
title: str | None = None
published: bool = False
has_source: bool = False
last_modified: float | None = None
size: int | None = None
class PageDetail(BaseModel):
name: str
source: str | None = None
class SaveRequest(BaseModel):
source: str
publish: bool = False
def _extract_title(source: str) -> str | None:
"""Extract title from µFrame source or legacy Micron."""
for line in source.splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
# µFrame: page "Title" [width]
if stripped.lower().startswith("page "):
try:
parts = shlex.split(stripped)
if len(parts) >= 2:
return parts[1]
except ValueError:
pass
break
# µFrame: heading 1 "Title"
if stripped.lower().startswith("heading "):
try:
parts = shlex.split(stripped)
if len(parts) >= 3:
return parts[2]
except ValueError:
pass
break
# Legacy Micron: >Title
if stripped.startswith(">") and not stripped.startswith(">>"):
return stripped[1:].strip()
break
return None
def _list_all_page_names() -> set[str]:
names: set[str] = set()
if PAGES_DIR.is_dir():
for f in PAGES_DIR.iterdir():
if f.suffix == ".mu" and f.is_file():
names.add(f.stem)
if SOURCES_DIR.is_dir():
for f in SOURCES_DIR.iterdir():
if f.suffix in (".uf", ".mu") and f.is_file():
names.add(f.stem)
return names
def _source_path(name: str) -> Path:
"""Get source file path, preferring .uf over legacy .mu."""
uf = SOURCES_DIR / f"{name}.uf"
if uf.is_file():
return uf
mu = SOURCES_DIR / f"{name}.mu"
return mu if mu.is_file() else uf # default to .uf for new files
def _page_meta(name: str) -> PageMeta:
src_path = _source_path(name)
mu_path = PAGES_DIR / f"{name}.mu"
title = None
if src_path.is_file():
title = _extract_title(src_path.read_text(encoding="utf-8"))
elif mu_path.is_file():
title = _extract_title(mu_path.read_text(encoding="utf-8"))
published = mu_path.is_file()
last_modified = mu_path.stat().st_mtime if published else None
size = mu_path.stat().st_size if published else None
return PageMeta(
name=name,
title=title,
published=published,
has_source=src_path.is_file(),
last_modified=last_modified,
size=size,
)
@router.get("/pages", response_model=list[PageMeta])
async def list_pages():
return [_page_meta(n) for n in sorted(_list_all_page_names())]
@router.get("/pages/{name}", response_model=PageDetail)
async def get_page(name: str):
src_path = _source_path(name)
mu_path = PAGES_DIR / f"{name}.mu"
if not src_path.is_file() and not mu_path.is_file():
raise HTTPException(status_code=404, detail="Page not found")
source = (
src_path.read_text(encoding="utf-8")
if src_path.is_file()
else mu_path.read_text(encoding="utf-8")
)
return PageDetail(name=name, source=source)
@router.post("/pages/{name}", response_model=PageMeta)
async def save_page(name: str, req: SaveRequest):
SOURCES_DIR.mkdir(parents=True, exist_ok=True)
PAGES_DIR.mkdir(parents=True, exist_ok=True)
# Save source as .uf
src_path = SOURCES_DIR / f"{name}.uf"
src_path.write_text(req.source, encoding="utf-8")
# Remove legacy .mu source if it exists
legacy_mu = SOURCES_DIR / f"{name}.mu"
if legacy_mu.is_file():
legacy_mu.unlink()
# Publish: compile .uf → .mu and write to pages dir
if req.publish:
try:
result = uframe.compile(req.source)
mu_path = PAGES_DIR / f"{name}.mu"
if result.is_dynamic and result.script:
# Dynamic page: write executable Python script
mu_path.write_text(result.script, encoding="utf-8")
mu_path.chmod(0o755) # Set execute bit for NomadNet
else:
# Static page: write compiled Micron
mu_path.write_text(result.micron, encoding="utf-8")
# Remove execute bit if it was previously dynamic
mu_path.chmod(0o644)
except Exception as e:
raise HTTPException(
status_code=422,
detail=f"Compile failed during publish: {e}",
)
return _page_meta(name)
@router.delete("/pages/{name}")
async def delete_page(name: str):
src_path = _source_path(name)
mu_path = PAGES_DIR / f"{name}.mu"
if not src_path.is_file() and not mu_path.is_file():
raise HTTPException(status_code=404, detail="Page not found")
if src_path.is_file():
src_path.unlink()
if mu_path.is_file():
mu_path.unlink()
return {"deleted": name}