Initial commit

This commit is contained in:
Ovidiu U
2026-05-04 09:45:17 +01:00
commit 18b7e11657
31 changed files with 2838 additions and 0 deletions

251
app/main.py Normal file
View File

@@ -0,0 +1,251 @@
"""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
]