"""FastAPI app: routes for puzzle generation and theme management.""" from __future__ import annotations from datetime import datetime from pathlib import Path from fastapi import FastAPI, Form, HTTPException, Request from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from . import themes as theme_store from .generator import generate from .normaliser import normalise_all from .pdf import render_puzzle ROOT = Path(__file__).resolve().parent.parent STATIC_DIR = ROOT / "static" TEMPLATES_DIR = Path(__file__).resolve().parent / "templates" app = FastAPI(title="Word Search Puzzle Generator") app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) def _checkbox(value: str | None) -> bool: return value is not None and value != "" and value.lower() not in {"0", "false", "off"} @app.get("/", response_class=HTMLResponse) def index(request: Request): available = theme_store.list_themes() return templates.TemplateResponse( "index.html", {"request": request, "themes": available, "errors": [], "warnings": []}, ) @app.post("/generate") async def generate_puzzle( request: Request, theme: str = Form(...), grid_size: int = Form(...), word_count: int = Form(...), min_length: int = Form(...), max_length: int = Form(...), title: str = Form(""), diagonal: str | None = Form(None), reversed_: str | None = Form(None, alias="reversed"), overlap: str | None = Form(None), ): available = theme_store.list_themes() errors: list[str] = [] try: loaded = theme_store.load_theme(theme) except theme_store.ThemeError as e: errors.append(str(e)) return templates.TemplateResponse( "index.html", {"request": request, "themes": available, "errors": errors, "warnings": []}, status_code=400, ) if max_length < min_length: errors.append("max length must be greater than or equal to min length") if errors: return templates.TemplateResponse( "index.html", {"request": request, "themes": available, "errors": errors, "warnings": []}, status_code=400, ) try: puzzle = generate( words=loaded.words, grid_size=grid_size, word_count=word_count, min_length=min_length, max_length=max_length, diagonal=_checkbox(diagonal), reversed_=_checkbox(reversed_), allow_overlap=_checkbox(overlap), ) except ValueError as e: return templates.TemplateResponse( "index.html", {"request": request, "themes": available, "errors": [str(e)], "warnings": []}, status_code=400, ) display_title = title.strip() or loaded.name pdf_bytes = render_puzzle(puzzle, display_title) timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") filename = f"{loaded.slug}_{timestamp}.pdf" return Response( content=pdf_bytes, media_type="application/pdf", headers={"Content-Disposition": f'attachment; filename="{filename}"'}, ) @app.get("/themes", response_class=HTMLResponse) def themes_index(request: Request): return templates.TemplateResponse( "themes.html", {"request": request, "themes": theme_store.list_themes()}, ) @app.get("/themes/new", response_class=HTMLResponse) def themes_new(request: Request): return templates.TemplateResponse( "theme_edit.html", { "request": request, "is_new": True, "name": "", "slug": "", "words_text": "", "errors": [], }, ) @app.get("/themes/{slug}/edit", response_class=HTMLResponse) def themes_edit(request: Request, slug: str): try: t = theme_store.load_theme(slug) except theme_store.ThemeError as e: raise HTTPException(status_code=404, detail=str(e)) return templates.TemplateResponse( "theme_edit.html", { "request": request, "is_new": False, "name": t.name, "slug": t.slug, "words_text": "\n".join(t.words), "errors": [], }, ) @app.post("/themes") def themes_create( request: Request, name: str = Form(...), slug: str = Form(""), words: str = Form(""), ): proposed_slug = (slug.strip() or theme_store.slugify(name)) parsed = theme_store.parse_words_textarea(words) errors: list[str] = [] if not theme_store.is_valid_slug(proposed_slug): errors.append("slug must be lowercase letters, digits, and hyphens only") if errors: return templates.TemplateResponse( "theme_edit.html", { "request": request, "is_new": True, "name": name, "slug": proposed_slug, "words_text": words, "errors": errors, }, status_code=400, ) try: theme_store.save_theme(proposed_slug, name, parsed) except theme_store.ThemeError as e: return templates.TemplateResponse( "theme_edit.html", { "request": request, "is_new": True, "name": name, "slug": proposed_slug, "words_text": words, "errors": [str(e)], }, status_code=400, ) return RedirectResponse(url="/themes", status_code=303) @app.post("/themes/{slug}") def themes_update( request: Request, slug: str, name: str = Form(...), words: str = Form(""), ): parsed = theme_store.parse_words_textarea(words) try: theme_store.save_theme(slug, name, parsed) except theme_store.ThemeError as e: return templates.TemplateResponse( "theme_edit.html", { "request": request, "is_new": False, "name": name, "slug": slug, "words_text": words, "errors": [str(e)], }, status_code=400, ) return RedirectResponse(url="/themes", status_code=303) @app.post("/themes/{slug}/delete") def themes_delete(slug: str): try: theme_store.delete_theme(slug) except theme_store.ThemeError as e: raise HTTPException(status_code=400, detail=str(e)) return RedirectResponse(url="/themes", status_code=303) @app.get("/api/themes") def api_themes(): return [{"slug": t.slug, "name": t.name, "word_count": len(t.words)} for t in theme_store.list_themes()] @app.post("/api/normalise") async def api_normalise(request: Request): payload = await request.json() words = payload.get("words", []) if isinstance(payload, dict) else [] if not isinstance(words, list): return JSONResponse({"error": "words must be a list"}, status_code=400) results = normalise_all([str(w) for w in words]) return [ { "display": n.display, "grid": n.grid, "stripped": n.stripped_prefixes, "skipped": n.skipped, "warning": n.warning, } for n in results ]