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

0
app/__init__.py Normal file
View File

198
app/generator.py Normal file
View 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
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
]

72
app/normaliser.py Normal file
View 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
View 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
View 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">
&nbsp; WORDSMITH &nbsp; · &nbsp; PUZZLE WORKSHOP &nbsp; · &nbsp; EST. 2026 &nbsp;
</footer>
</div>
{% block scripts %}{% endblock %}
</body>
</html>

162
app/templates/index.html Normal file
View 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 %}

View 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
View 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
View 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()]