Initial commit
This commit is contained in:
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
198
app/generator.py
Normal file
198
app/generator.py
Normal file
@@ -0,0 +1,198 @@
|
||||
"""Grid building and word placement."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import string
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from .normaliser import Normalised, normalise_all
|
||||
|
||||
# Direction vectors: (drow, dcol)
|
||||
RIGHT = (0, 1)
|
||||
DOWN = (1, 0)
|
||||
DOWN_RIGHT = (1, 1)
|
||||
UP_RIGHT = (-1, 1)
|
||||
LEFT = (0, -1)
|
||||
UP = (-1, 0)
|
||||
UP_LEFT = (-1, -1)
|
||||
DOWN_LEFT = (1, -1)
|
||||
|
||||
PLACEMENT_RETRIES = 200
|
||||
|
||||
|
||||
@dataclass
|
||||
class Placement:
|
||||
word: Normalised
|
||||
row: int
|
||||
col: int
|
||||
direction: tuple[int, int]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Puzzle:
|
||||
grid: list[list[str]]
|
||||
placements: list[Placement]
|
||||
skipped: list[Normalised] = field(default_factory=list)
|
||||
filtered_out: list[Normalised] = field(default_factory=list)
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
diagonal: bool = False
|
||||
reversed_: bool = False
|
||||
allow_overlap: bool = False
|
||||
|
||||
@property
|
||||
def size(self) -> int:
|
||||
return len(self.grid)
|
||||
|
||||
@property
|
||||
def option_labels(self) -> list[str]:
|
||||
labels = []
|
||||
if self.diagonal:
|
||||
labels.append("Diagonal")
|
||||
if self.reversed_:
|
||||
labels.append("Reversed")
|
||||
if self.allow_overlap:
|
||||
labels.append("Overlapping")
|
||||
return labels
|
||||
|
||||
|
||||
def active_directions(diagonal: bool, reversed_: bool) -> list[tuple[int, int]]:
|
||||
base = [RIGHT, DOWN]
|
||||
if diagonal:
|
||||
base += [DOWN_RIGHT, UP_RIGHT]
|
||||
if reversed_:
|
||||
base = base + [(-dr, -dc) for (dr, dc) in base]
|
||||
return base
|
||||
|
||||
|
||||
def _can_place(grid: list[list[str]], word: str, row: int, col: int,
|
||||
direction: tuple[int, int], allow_overlap: bool) -> bool:
|
||||
size = len(grid)
|
||||
dr, dc = direction
|
||||
for i, ch in enumerate(word):
|
||||
r, c = row + dr * i, col + dc * i
|
||||
if not (0 <= r < size and 0 <= c < size):
|
||||
return False
|
||||
existing = grid[r][c]
|
||||
if existing == "":
|
||||
continue
|
||||
if allow_overlap and existing == ch:
|
||||
continue
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _commit(grid: list[list[str]], word: str, row: int, col: int,
|
||||
direction: tuple[int, int]) -> None:
|
||||
dr, dc = direction
|
||||
for i, ch in enumerate(word):
|
||||
grid[row + dr * i][col + dc * i] = ch
|
||||
|
||||
|
||||
def _start_bounds(size: int, length: int,
|
||||
direction: tuple[int, int]) -> tuple[range, range]:
|
||||
"""Valid starting (row, col) ranges so the whole word fits."""
|
||||
dr, dc = direction
|
||||
if dr >= 0:
|
||||
rows = range(0, size - (length - 1) * dr)
|
||||
else:
|
||||
rows = range((length - 1) * (-dr), size)
|
||||
if dc >= 0:
|
||||
cols = range(0, size - (length - 1) * dc)
|
||||
else:
|
||||
cols = range((length - 1) * (-dc), size)
|
||||
return rows, cols
|
||||
|
||||
|
||||
def generate(
|
||||
*,
|
||||
words: list[str],
|
||||
grid_size: int,
|
||||
word_count: int,
|
||||
min_length: int,
|
||||
max_length: int,
|
||||
diagonal: bool,
|
||||
reversed_: bool,
|
||||
allow_overlap: bool,
|
||||
rng: random.Random | None = None,
|
||||
) -> Puzzle:
|
||||
if rng is None:
|
||||
rng = random.Random()
|
||||
|
||||
if grid_size < 5 or grid_size > 25:
|
||||
raise ValueError("grid size must be between 5 and 25")
|
||||
if word_count < 1 or word_count > 30:
|
||||
raise ValueError("word count must be between 1 and 30")
|
||||
if min_length < 1:
|
||||
raise ValueError("min length must be at least 1")
|
||||
if max_length < min_length:
|
||||
raise ValueError("max length must be greater than or equal to min length")
|
||||
|
||||
effective_max = min(max_length, grid_size)
|
||||
|
||||
normalised = normalise_all(words)
|
||||
eligible: list[Normalised] = []
|
||||
filtered_out: list[Normalised] = []
|
||||
skipped: list[Normalised] = []
|
||||
for n in normalised:
|
||||
if n.skipped:
|
||||
skipped.append(n)
|
||||
continue
|
||||
length = len(n.grid)
|
||||
if min_length <= length <= effective_max:
|
||||
eligible.append(n)
|
||||
else:
|
||||
filtered_out.append(n)
|
||||
|
||||
rng.shuffle(eligible)
|
||||
directions = active_directions(diagonal, reversed_)
|
||||
|
||||
grid: list[list[str]] = [["" for _ in range(grid_size)]
|
||||
for _ in range(grid_size)]
|
||||
placements: list[Placement] = []
|
||||
warnings: list[str] = []
|
||||
|
||||
for word in eligible:
|
||||
if len(placements) >= word_count:
|
||||
break
|
||||
placed = False
|
||||
for _ in range(PLACEMENT_RETRIES):
|
||||
direction = rng.choice(directions)
|
||||
rows, cols = _start_bounds(grid_size, len(word.grid), direction)
|
||||
if not rows or not cols:
|
||||
continue
|
||||
row = rng.choice(rows)
|
||||
col = rng.choice(cols)
|
||||
if _can_place(grid, word.grid, row, col, direction, allow_overlap):
|
||||
_commit(grid, word.grid, row, col, direction)
|
||||
placements.append(Placement(word=word, row=row, col=col,
|
||||
direction=direction))
|
||||
placed = True
|
||||
break
|
||||
if not placed:
|
||||
warnings.append(
|
||||
f"could not place word {word.grid!r} after "
|
||||
f"{PLACEMENT_RETRIES} attempts; skipping"
|
||||
)
|
||||
|
||||
if filtered_out:
|
||||
warnings.append(
|
||||
f"{len(filtered_out)} word(s) excluded by length filter "
|
||||
f"(min={min_length}, max={effective_max})"
|
||||
)
|
||||
|
||||
if len(placements) < word_count:
|
||||
warnings.append(
|
||||
f"asked for {word_count} word(s); placed {len(placements)}"
|
||||
)
|
||||
|
||||
# Fill remaining empty cells with random uppercase letters.
|
||||
for r in range(grid_size):
|
||||
for c in range(grid_size):
|
||||
if grid[r][c] == "":
|
||||
grid[r][c] = rng.choice(string.ascii_uppercase)
|
||||
|
||||
return Puzzle(grid=grid, placements=placements, skipped=skipped,
|
||||
filtered_out=filtered_out, warnings=warnings,
|
||||
diagonal=diagonal, reversed_=reversed_,
|
||||
allow_overlap=allow_overlap)
|
||||
251
app/main.py
Normal file
251
app/main.py
Normal 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
|
||||
]
|
||||
72
app/normaliser.py
Normal file
72
app/normaliser.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Word normalisation: produce display + grid forms with prefix stripping."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
|
||||
PREFIXES = {
|
||||
"mr", "mrs", "ms", "miss",
|
||||
"dr", "sir", "dame", "lord", "lady", "master",
|
||||
"captain", "capt", "cpt",
|
||||
"professor", "prof",
|
||||
"saint", "st",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Normalised:
|
||||
display: str
|
||||
grid: str
|
||||
stripped_prefixes: list[str]
|
||||
skipped: bool = False
|
||||
warning: str | None = None
|
||||
|
||||
|
||||
def _strip_prefix_tokens(tokens: list[str]) -> tuple[list[str], list[str]]:
|
||||
"""Remove leading prefix tokens. Returns (stripped_prefixes, remaining_tokens)."""
|
||||
stripped: list[str] = []
|
||||
i = 0
|
||||
while i < len(tokens):
|
||||
bare = tokens[i].rstrip(".").lower()
|
||||
if bare in PREFIXES:
|
||||
stripped.append(tokens[i])
|
||||
i += 1
|
||||
else:
|
||||
break
|
||||
return stripped, tokens[i:]
|
||||
|
||||
|
||||
def normalise(word: str) -> Normalised:
|
||||
display = word
|
||||
tokens = word.split()
|
||||
if not tokens:
|
||||
return Normalised(display=display, grid="", stripped_prefixes=[],
|
||||
skipped=True, warning="empty word")
|
||||
|
||||
stripped, remainder = _strip_prefix_tokens(tokens)
|
||||
|
||||
if not remainder:
|
||||
# Word is *only* prefix tokens — keep raw form, warn.
|
||||
joined = "".join(tokens)
|
||||
grid = re.sub(r"[^A-Za-z0-9]", "", joined).upper()
|
||||
warning = f"word {word!r} is only prefix tokens; keeping as {grid}"
|
||||
print(f"[normaliser] warning: {warning}", file=sys.stderr)
|
||||
return Normalised(display=display, grid=grid, stripped_prefixes=[],
|
||||
warning=warning)
|
||||
|
||||
joined = "".join(remainder)
|
||||
grid = re.sub(r"[^A-Za-z0-9]", "", joined).upper()
|
||||
|
||||
if not grid:
|
||||
warning = f"word {word!r} normalises to empty after stripping; skipping"
|
||||
print(f"[normaliser] warning: {warning}", file=sys.stderr)
|
||||
return Normalised(display=display, grid="", stripped_prefixes=stripped,
|
||||
skipped=True, warning=warning)
|
||||
|
||||
return Normalised(display=display, grid=grid, stripped_prefixes=stripped)
|
||||
|
||||
|
||||
def normalise_all(words: list[str]) -> list[Normalised]:
|
||||
return [normalise(w) for w in words]
|
||||
143
app/pdf.py
Normal file
143
app/pdf.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""PDF rendering of a generated puzzle (A4 portrait, single page)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from typing import Iterable
|
||||
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.units import mm
|
||||
from reportlab.pdfbase.pdfmetrics import stringWidth
|
||||
from reportlab.pdfgen import canvas
|
||||
|
||||
from .generator import Puzzle
|
||||
|
||||
PAGE_W, PAGE_H = A4
|
||||
MARGIN = 18 * mm
|
||||
|
||||
TITLE_FONT = "Helvetica-Bold"
|
||||
TITLE_SIZE = 24
|
||||
|
||||
OPTIONS_FONT = "Helvetica"
|
||||
OPTIONS_SIZE = 10
|
||||
|
||||
GRID_FONT = "Courier-Bold"
|
||||
|
||||
LIST_HEADING_FONT = "Helvetica-Bold"
|
||||
LIST_HEADING_SIZE = 14
|
||||
LIST_ENTRY_FONT_BOLD = "Helvetica-Bold"
|
||||
LIST_ENTRY_FONT_LIGHT = "Helvetica"
|
||||
LIST_ENTRY_SIZE = 11
|
||||
|
||||
|
||||
def _column_count(n: int) -> int:
|
||||
if n <= 8:
|
||||
return 2
|
||||
if n <= 18:
|
||||
return 3
|
||||
return 4
|
||||
|
||||
|
||||
def _wrap_columns(items: list[tuple], cols: int) -> list[list[tuple]]:
|
||||
"""Lay items out top-to-bottom, then left-to-right, in `cols` columns."""
|
||||
rows = (len(items) + cols - 1) // cols
|
||||
columns: list[list[tuple]] = [[] for _ in range(cols)]
|
||||
for i, item in enumerate(items):
|
||||
columns[i // rows].append(item)
|
||||
return columns
|
||||
|
||||
|
||||
def _grid_geometry(grid_size: int, available_w: float, available_h: float
|
||||
) -> tuple[float, float]:
|
||||
"""Choose (font_size, cell_size) so a `grid_size` x `grid_size` grid
|
||||
fits comfortably in the available box."""
|
||||
# Cell size driven by whichever dimension is tighter, with padding.
|
||||
target_cell = min(available_w, available_h) / grid_size
|
||||
cell = min(target_cell, 14 * mm) # cap so 5x5 isn't comically huge
|
||||
cell = max(cell, 5 * mm)
|
||||
font_size = cell * 0.7
|
||||
return font_size, cell
|
||||
|
||||
|
||||
def render_puzzle(puzzle: Puzzle, title: str) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
c = canvas.Canvas(buf, pagesize=A4)
|
||||
|
||||
# Title
|
||||
c.setFont(TITLE_FONT, TITLE_SIZE)
|
||||
title_y = PAGE_H - MARGIN - TITLE_SIZE
|
||||
c.drawCentredString(PAGE_W / 2, title_y, title)
|
||||
|
||||
# Active option labels under the title (only when extras are enabled).
|
||||
options_y = title_y
|
||||
labels = puzzle.option_labels
|
||||
if labels:
|
||||
options_y = title_y - TITLE_SIZE * 0.7
|
||||
c.setFont(OPTIONS_FONT, OPTIONS_SIZE)
|
||||
c.setFillGray(0.35)
|
||||
c.drawCentredString(PAGE_W / 2, options_y,
|
||||
" · ".join(s.upper() for s in labels))
|
||||
c.setFillGray(0)
|
||||
|
||||
# Reserve space for word list at the bottom; estimate from entry count.
|
||||
placed = puzzle.placements
|
||||
cols = _column_count(len(placed)) if placed else 2
|
||||
rows_per_col = (len(placed) + cols - 1) // cols if placed else 0
|
||||
list_line_height = LIST_ENTRY_SIZE * 1.5
|
||||
list_block_h = LIST_HEADING_SIZE * 2 + rows_per_col * list_line_height
|
||||
list_block_h = max(list_block_h, 30 * mm)
|
||||
|
||||
grid_top = (options_y if labels else title_y) - 8 * mm
|
||||
grid_bottom = MARGIN + list_block_h + 6 * mm
|
||||
available_h = grid_top - grid_bottom
|
||||
available_w = PAGE_W - 2 * MARGIN
|
||||
|
||||
grid_size = puzzle.size
|
||||
font_size, cell = _grid_geometry(grid_size, available_w, available_h)
|
||||
grid_w = grid_size * cell
|
||||
grid_h = grid_size * cell
|
||||
origin_x = (PAGE_W - grid_w) / 2
|
||||
origin_y = grid_top - grid_h # top of grid sits at grid_top
|
||||
|
||||
c.setFont(GRID_FONT, font_size)
|
||||
for r in range(grid_size):
|
||||
for col_idx in range(grid_size):
|
||||
ch = puzzle.grid[r][col_idx]
|
||||
x = origin_x + col_idx * cell + cell / 2
|
||||
# Top row in the grid array should print at the top of the grid box.
|
||||
y = origin_y + (grid_size - 1 - r) * cell + (cell - font_size) / 2
|
||||
c.drawCentredString(x, y, ch)
|
||||
|
||||
# Word list
|
||||
list_top = grid_bottom - 2 * mm
|
||||
c.setFont(LIST_HEADING_FONT, LIST_HEADING_SIZE)
|
||||
c.drawCentredString(PAGE_W / 2, list_top, "Find these words")
|
||||
|
||||
entries: list[tuple[str, str]] = []
|
||||
for p in placed:
|
||||
prefix_text = ""
|
||||
if p.word.stripped_prefixes:
|
||||
prefix_text = " (" + " ".join(p.word.stripped_prefixes) + ")"
|
||||
entries.append((p.word.grid, prefix_text))
|
||||
|
||||
if entries:
|
||||
column_lists = _wrap_columns(entries, cols)
|
||||
col_w = available_w / cols
|
||||
list_y_start = list_top - LIST_HEADING_SIZE - 4 * mm
|
||||
for col_idx, items in enumerate(column_lists):
|
||||
x = MARGIN + col_idx * col_w
|
||||
for i, (grid_form, prefix) in enumerate(items):
|
||||
y = list_y_start - i * list_line_height
|
||||
c.setFont(LIST_ENTRY_FONT_BOLD, LIST_ENTRY_SIZE)
|
||||
c.drawString(x, y, grid_form)
|
||||
if prefix:
|
||||
bold_w = stringWidth(grid_form, LIST_ENTRY_FONT_BOLD,
|
||||
LIST_ENTRY_SIZE)
|
||||
c.setFont(LIST_ENTRY_FONT_LIGHT, LIST_ENTRY_SIZE)
|
||||
c.setFillGray(0.4)
|
||||
c.drawString(x + bold_w, y, prefix)
|
||||
c.setFillGray(0)
|
||||
|
||||
c.showPage()
|
||||
c.save()
|
||||
return buf.getvalue()
|
||||
41
app/templates/base.html
Normal file
41
app/templates/base.html
Normal file
@@ -0,0 +1,41 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{% block title %}Wordsmith — Puzzle Workshop{% endblock %}</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500;600&family=Playfair+Display:wght@800;900&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
{% set path = request.url.path %}
|
||||
<div class="shell">
|
||||
<header class="site-header">
|
||||
<div class="site-header__inner">
|
||||
<a class="brand" href="/">
|
||||
<span class="brand__mark">W</span>
|
||||
<span>
|
||||
<span class="brand__name">Wordsmith</span>
|
||||
<span class="brand__tagline">PUZZLE WORKSHOP</span>
|
||||
</span>
|
||||
</a>
|
||||
<nav class="tabs" aria-label="Primary">
|
||||
<a href="/" {% if path in ['/', '/generate'] %}aria-current="page"{% endif %}>Generate</a>
|
||||
<a href="/themes" {% if path.startswith('/themes') %}aria-current="page"{% endif %}>Themes</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
✦ WORDSMITH · PUZZLE WORKSHOP · EST. 2026 ✦
|
||||
</footer>
|
||||
</div>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
162
app/templates/index.html
Normal file
162
app/templates/index.html
Normal file
@@ -0,0 +1,162 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Generate · Wordsmith{% endblock %}
|
||||
{% block content %}
|
||||
<section class="container">
|
||||
<div class="page-head">
|
||||
<div class="page-head__title">
|
||||
<div class="eyebrow">№ 01 · Studio</div>
|
||||
<h1>Generate a puzzle</h1>
|
||||
</div>
|
||||
<div class="page-head__meta">{{ themes|length }} theme{{ '' if themes|length == 1 else 's' }} on file</div>
|
||||
</div>
|
||||
<hr class="section-rule" />
|
||||
|
||||
{% if errors %}
|
||||
<div class="notice">
|
||||
<strong>Hold on</strong>
|
||||
<ul>
|
||||
{% for e in errors %}<li>{{ e }}</li>{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not themes %}
|
||||
<div class="panel">
|
||||
<p>No themes yet. <a href="/themes/new">Create one</a> to get started.</p>
|
||||
</div>
|
||||
{% else %}
|
||||
|
||||
<form class="studio" method="post" action="/generate">
|
||||
<div class="form-grid">
|
||||
<label class="field">
|
||||
<span class="field__label">Theme</span>
|
||||
<select name="theme" id="theme" required>
|
||||
{% for t in themes %}
|
||||
<option value="{{ t.slug }}" data-words="{{ t.words|join(',') }}">{{ t.name }} ({{ t.words|length }} words)</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class="row">
|
||||
<label class="field">
|
||||
<span class="field__label">Grid</span>
|
||||
<input type="number" name="grid_size" id="grid-size" value="12" min="5" max="25" required />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field__label">Words</span>
|
||||
<input type="number" name="word_count" id="word-count-input" value="10" min="1" max="30" required />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field__label">Min len</span>
|
||||
<input type="number" name="min_length" value="3" min="1" required />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field__label">Max len</span>
|
||||
<input type="number" name="max_length" value="12" min="1" required />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="field">
|
||||
<span class="field__label">Title override</span>
|
||||
<input type="text" name="title" placeholder="leave blank to use theme name" />
|
||||
</label>
|
||||
|
||||
<fieldset class="fieldset">
|
||||
<legend>Directions</legend>
|
||||
<div class="directions-grid">
|
||||
<label class="check check--locked">
|
||||
<input type="checkbox" checked disabled />
|
||||
<span>→ Horizontal</span>
|
||||
<span class="lock">REQ</span>
|
||||
</label>
|
||||
<label class="check check--locked">
|
||||
<input type="checkbox" checked disabled />
|
||||
<span>↓ Vertical</span>
|
||||
<span class="lock">REQ</span>
|
||||
</label>
|
||||
<label class="check">
|
||||
<input type="checkbox" name="diagonal" />
|
||||
<span>↘ ↗ Diagonal</span>
|
||||
</label>
|
||||
<label class="check">
|
||||
<input type="checkbox" name="reversed" />
|
||||
<span>← ↑ ↖ ↙ Reversed</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="fieldset__divider">
|
||||
<label class="check">
|
||||
<input type="checkbox" name="overlap" />
|
||||
<span>Allow overlapping words</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="button-row">
|
||||
<button class="btn" type="submit">✦ Generate</button>
|
||||
<button class="btn btn--ghost" type="reset">Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="preview-card" aria-label="Preview">
|
||||
<div class="preview-card__head">
|
||||
<span class="preview-card__title">Sample preview</span>
|
||||
<span class="mono muted" id="preview-stats" style="font-size:11px;">12 × 12 · 10 words</span>
|
||||
</div>
|
||||
<div class="ws-grid" id="ws-grid" style="grid-template-columns: repeat(12, 1fr);"></div>
|
||||
<div class="ws-words" id="ws-words"></div>
|
||||
</aside>
|
||||
</form>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
(function () {
|
||||
const themeSel = document.getElementById('theme');
|
||||
const gridInput = document.getElementById('grid-size');
|
||||
const countInput = document.getElementById('word-count-input');
|
||||
const grid = document.getElementById('ws-grid');
|
||||
const wordsEl = document.getElementById('ws-words');
|
||||
const stats = document.getElementById('preview-stats');
|
||||
if (!themeSel || !grid) return;
|
||||
|
||||
function selectedWords() {
|
||||
const opt = themeSel.options[themeSel.selectedIndex];
|
||||
return (opt.dataset.words || '').split(',').filter(Boolean);
|
||||
}
|
||||
|
||||
function renderPreview() {
|
||||
const N = Math.max(5, Math.min(25, parseInt(gridInput.value || '12', 10) || 12));
|
||||
const want = Math.max(1, parseInt(countInput.value || '10', 10) || 10);
|
||||
grid.style.gridTemplateColumns = `repeat(${N}, 1fr)`;
|
||||
|
||||
const words = selectedWords();
|
||||
const grids = words
|
||||
.map(w => w.replace(/^(mr|mrs|ms|miss|dr|sir|dame|lord|lady|master|captain|capt|cpt|professor|prof|saint|st)\.?\s+/gi, '')
|
||||
.replace(/[^a-z0-9]/gi, '').toUpperCase())
|
||||
.filter(g => g.length > 0);
|
||||
|
||||
const pool = (grids.join('') || 'WORDSMITH').replace(/[^A-Z]/g, '') || 'WORDSMITH';
|
||||
|
||||
let html = '';
|
||||
for (let i = 0; i < N * N; i++) {
|
||||
html += `<span>${pool[(i * 7 + 3) % pool.length]}</span>`;
|
||||
}
|
||||
grid.innerHTML = html;
|
||||
|
||||
const sample = grids.slice(0, Math.min(want, grids.length));
|
||||
wordsEl.innerHTML = sample.map((g, i) =>
|
||||
`<b>${g}</b>${i < sample.length - 1 ? '<span class="sep">·</span>' : ''}`
|
||||
).join('');
|
||||
|
||||
stats.textContent = `${N} × ${N} · ${sample.length} word${sample.length === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
themeSel.addEventListener('change', renderPreview);
|
||||
gridInput.addEventListener('input', renderPreview);
|
||||
countInput.addEventListener('input', renderPreview);
|
||||
renderPreview();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
161
app/templates/theme_edit.html
Normal file
161
app/templates/theme_edit.html
Normal file
@@ -0,0 +1,161 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{% if is_new %}New theme{% else %}Edit {{ name }}{% endif %} · Wordsmith{% endblock %}
|
||||
{% block content %}
|
||||
<section class="container">
|
||||
<div class="crumbs">
|
||||
<a href="/themes">themes</a>
|
||||
<span class="sep">/</span>
|
||||
<span class="here">{% if is_new %}new{% else %}{{ slug }}{% endif %}</span>
|
||||
</div>
|
||||
|
||||
<div class="page-head">
|
||||
<div class="page-head__title">
|
||||
<div class="eyebrow">№ 03 · Workbench</div>
|
||||
<h1>{% if is_new %}New theme{% else %}Edit theme{% endif %}</h1>
|
||||
</div>
|
||||
{% if not is_new %}
|
||||
<div class="page-head__meta">slug · {{ slug }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<hr class="section-rule" />
|
||||
|
||||
{% if errors %}
|
||||
<div class="notice">
|
||||
<strong>Hold on</strong>
|
||||
<ul>
|
||||
{% for e in errors %}<li>{{ e }}</li>{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post"
|
||||
action="{% if is_new %}/themes{% else %}/themes/{{ slug }}{% endif %}">
|
||||
|
||||
<div class="row" style="margin-bottom: var(--s-4);">
|
||||
<label class="field">
|
||||
<span class="field__label">Theme name</span>
|
||||
<input type="text" name="name" id="name" value="{{ name }}" required />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field__label">Slug</span>
|
||||
<input type="text" name="slug" id="slug" value="{{ slug }}"
|
||||
{% if not is_new %}readonly{% endif %} />
|
||||
<span class="field__hint">lowercase letters, digits, hyphens · auto-generated on create</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="workbench">
|
||||
<label class="field">
|
||||
<span class="field__label">Words · one per line</span>
|
||||
<textarea name="words" id="words">{{ words_text }}</textarea>
|
||||
</label>
|
||||
|
||||
<aside class="panel panel--inset" aria-label="Live preview">
|
||||
<div class="preview-card__head" style="margin: -4px 0 12px;">
|
||||
<span class="preview-card__title">Live preview</span>
|
||||
<span class="mono muted" style="font-size:11px;" id="word-count">0 entries</span>
|
||||
</div>
|
||||
<div class="preview-list" id="preview-list">
|
||||
<div class="muted">Type words to see the grid form.</div>
|
||||
</div>
|
||||
<div class="stat-row" id="word-stats">
|
||||
<span>no words yet</span>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div class="bottom-bar">
|
||||
<button class="btn" type="submit">Save</button>
|
||||
<a class="btn btn--ghost" href="/themes">Cancel</a>
|
||||
<span class="spacer"></span>
|
||||
{% if not is_new %}
|
||||
<form method="post" action="/themes/{{ slug }}/delete"
|
||||
onsubmit="return confirm('Delete theme {{ name }}?');">
|
||||
<button class="btn btn--danger" type="submit">⌫ Delete theme</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
(function () {
|
||||
const nameEl = document.getElementById('name');
|
||||
const slugEl = document.getElementById('slug');
|
||||
const wordsEl = document.getElementById('words');
|
||||
const list = document.getElementById('preview-list');
|
||||
const count = document.getElementById('word-count');
|
||||
const stats = document.getElementById('word-stats');
|
||||
const isNew = {{ 'true' if is_new else 'false' }};
|
||||
|
||||
function slugify(s) {
|
||||
return s.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
if (isNew && nameEl && slugEl) {
|
||||
nameEl.addEventListener('input', () => {
|
||||
if (!slugEl.dataset.touched) slugEl.value = slugify(nameEl.value);
|
||||
});
|
||||
slugEl.addEventListener('input', () => { slugEl.dataset.touched = '1'; });
|
||||
}
|
||||
|
||||
let timer = null;
|
||||
async function refresh() {
|
||||
const lines = wordsEl.value.split('\n').map(s => s.trim()).filter(Boolean);
|
||||
if (lines.length === 0) {
|
||||
list.innerHTML = '<div class="muted">Type words to see the grid form.</div>';
|
||||
count.textContent = '0 entries';
|
||||
stats.innerHTML = '<span>no words yet</span>';
|
||||
return;
|
||||
}
|
||||
let data;
|
||||
try {
|
||||
const res = await fetch('/api/normalise', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({words: lines}),
|
||||
});
|
||||
data = await res.json();
|
||||
} catch (e) {
|
||||
list.innerHTML = '<div class="muted">Preview failed.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = data.map(item => {
|
||||
if (item.skipped) {
|
||||
return `<div class="row skipped"><span class="src">${item.display}</span><span class="dst">(skipped)</span></div>`;
|
||||
}
|
||||
return `<div class="row"><span class="src">${item.display}</span><span class="dst">${item.grid}</span></div>`;
|
||||
}).join('');
|
||||
|
||||
const placeable = data.filter(d => !d.skipped);
|
||||
count.textContent = `${data.length} ${data.length === 1 ? 'entry' : 'entries'}`;
|
||||
|
||||
if (placeable.length) {
|
||||
const lens = placeable.map(d => d.grid.length);
|
||||
const minIdx = lens.indexOf(Math.min(...lens));
|
||||
const maxIdx = lens.indexOf(Math.max(...lens));
|
||||
const min = placeable[minIdx];
|
||||
const max = placeable[maxIdx];
|
||||
const avg = (lens.reduce((a, b) => a + b, 0) / lens.length).toFixed(1);
|
||||
stats.innerHTML = `
|
||||
<span>shortest · ${min.grid} (${min.grid.length})</span>
|
||||
<span>longest · ${max.grid} (${max.grid.length})</span>
|
||||
<span>avg · ${avg}</span>`;
|
||||
} else {
|
||||
stats.innerHTML = '<span>nothing placeable</span>';
|
||||
}
|
||||
}
|
||||
|
||||
if (wordsEl) {
|
||||
wordsEl.addEventListener('input', () => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(refresh, 250);
|
||||
});
|
||||
refresh();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
57
app/templates/themes.html
Normal file
57
app/templates/themes.html
Normal file
@@ -0,0 +1,57 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Themes · Wordsmith{% endblock %}
|
||||
{% block content %}
|
||||
{% set total_words = themes|map(attribute='words')|map('length')|sum %}
|
||||
<section class="container">
|
||||
<div class="page-head">
|
||||
<div class="page-head__title">
|
||||
<div class="eyebrow">№ 02 · Ledger</div>
|
||||
<h1>Themes</h1>
|
||||
<div class="muted mono" style="font-size:12px; margin-top:6px;">
|
||||
{{ themes|length }} theme{{ '' if themes|length == 1 else 's' }} · {{ total_words }} word{{ '' if total_words == 1 else 's' }} on file
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<a class="btn" href="/themes/new">+ New theme</a>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="section-rule" />
|
||||
|
||||
{% if themes %}
|
||||
<div class="panel" style="padding:0; overflow:hidden;">
|
||||
<table class="ledger">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Slug</th>
|
||||
<th class="col-words">Words</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for t in themes %}
|
||||
<tr>
|
||||
<td class="col-name">{{ t.name }}</td>
|
||||
<td class="col-slug">{{ t.slug }}</td>
|
||||
<td class="col-words">{{ t.words|length }}</td>
|
||||
<td class="col-actions">
|
||||
<span class="row-actions">
|
||||
<a href="/themes/{{ t.slug }}/edit">edit</a>
|
||||
<form method="post" action="/themes/{{ t.slug }}/delete"
|
||||
onsubmit="return confirm('Delete theme {{ t.name }}?');">
|
||||
<button type="submit" class="del">delete</button>
|
||||
</form>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="panel">
|
||||
<p>No themes yet. <a href="/themes/new">Create the first one</a>.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
96
app/themes.py
Normal file
96
app/themes.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""Theme storage: flat JSON files under themes/."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
THEMES_DIR = Path(__file__).resolve().parent.parent / "themes"
|
||||
|
||||
_SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Theme:
|
||||
slug: str
|
||||
name: str
|
||||
words: list[str]
|
||||
|
||||
|
||||
class ThemeError(ValueError):
|
||||
"""Raised for invalid theme input."""
|
||||
|
||||
|
||||
def slugify(name: str) -> str:
|
||||
"""Lowercase, replace non-alphanumeric runs with single hyphen, trim."""
|
||||
s = name.lower().strip()
|
||||
s = re.sub(r"[^a-z0-9]+", "-", s)
|
||||
s = s.strip("-")
|
||||
return s
|
||||
|
||||
|
||||
def is_valid_slug(slug: str) -> bool:
|
||||
return bool(_SLUG_RE.match(slug))
|
||||
|
||||
|
||||
def _path(slug: str) -> Path:
|
||||
if not is_valid_slug(slug):
|
||||
raise ThemeError(f"invalid slug: {slug!r}")
|
||||
return THEMES_DIR / f"{slug}.json"
|
||||
|
||||
|
||||
def list_themes() -> list[Theme]:
|
||||
THEMES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
out: list[Theme] = []
|
||||
for path in sorted(THEMES_DIR.glob("*.json")):
|
||||
slug = path.stem
|
||||
if not is_valid_slug(slug):
|
||||
continue
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
out.append(Theme(slug=slug, name=data.get("name", slug),
|
||||
words=list(data.get("words", []))))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def load_theme(slug: str) -> Theme:
|
||||
path = _path(slug)
|
||||
if not path.exists():
|
||||
raise ThemeError(f"theme not found: {slug!r}")
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
return Theme(slug=slug, name=data.get("name", slug),
|
||||
words=list(data.get("words", [])))
|
||||
|
||||
|
||||
def save_theme(slug: str, name: str, words: list[str]) -> Theme:
|
||||
if not is_valid_slug(slug):
|
||||
raise ThemeError(
|
||||
"slug must be lowercase letters, digits, and hyphens only"
|
||||
)
|
||||
name = name.strip()
|
||||
if not name:
|
||||
raise ThemeError("theme name is required")
|
||||
cleaned = [w.strip() for w in words if w.strip()]
|
||||
if not cleaned:
|
||||
raise ThemeError("theme must contain at least one word")
|
||||
THEMES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
path = THEMES_DIR / f"{slug}.json"
|
||||
payload = {"name": name, "words": cleaned}
|
||||
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n",
|
||||
encoding="utf-8")
|
||||
return Theme(slug=slug, name=name, words=cleaned)
|
||||
|
||||
|
||||
def delete_theme(slug: str) -> None:
|
||||
path = _path(slug)
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
|
||||
|
||||
def parse_words_textarea(blob: str) -> list[str]:
|
||||
"""One word per line; ignore blank lines and surrounding whitespace."""
|
||||
return [line.strip() for line in blob.splitlines() if line.strip()]
|
||||
Reference in New Issue
Block a user