V2: Added Webserver for faster formatting
This commit is contained in:
@@ -0,0 +1,15 @@
|
|||||||
|
.agents
|
||||||
|
.codex
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
Dockerfile
|
||||||
|
README.md
|
||||||
|
Roster2
|
||||||
|
input
|
||||||
|
output
|
||||||
|
tests
|
||||||
|
venv
|
||||||
|
.venv
|
||||||
|
**/__pycache__
|
||||||
|
**/*.py[cod]
|
||||||
|
.DS_Store
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Roster PDFs and generated images contain names and are local working data.
|
||||||
|
input/*.pdf
|
||||||
|
output/*.pdf
|
||||||
|
output/*.png
|
||||||
|
output/.roster-layout-defaults.json
|
||||||
|
output/.roster-defaults-*.json
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
FROM python:3.11-slim-bookworm
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt ./
|
||||||
|
RUN pip install --no-cache-dir --requirement requirements.txt
|
||||||
|
|
||||||
|
ARG APP_UID=1000
|
||||||
|
ARG APP_GID=1000
|
||||||
|
RUN groupadd --gid "${APP_GID}" roster \
|
||||||
|
&& useradd --uid "${APP_UID}" --gid roster --create-home --shell /usr/sbin/nologin roster \
|
||||||
|
&& mkdir -p /app/output \
|
||||||
|
&& chown -R roster:roster /app
|
||||||
|
|
||||||
|
COPY --chown=roster:roster main.py roster_formatter.py server.py ./
|
||||||
|
COPY --chown=roster:roster modules/ ./modules/
|
||||||
|
COPY --chown=roster:roster utils/ ./utils/
|
||||||
|
COPY --chown=roster:roster assets/ ./assets/
|
||||||
|
COPY --chown=roster:roster web/ ./web/
|
||||||
|
|
||||||
|
USER roster
|
||||||
|
|
||||||
|
EXPOSE 8787
|
||||||
|
|
||||||
|
CMD ["python", "server.py", "--host", "0.0.0.0", "--port", "8787"]
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# Roster Formatter
|
||||||
|
|
||||||
|
A local browser app that turns the current one-page roster PDF into the established formatted PNG. The web app and command-line tool use the same renderer.
|
||||||
|
|
||||||
|
## Start with Docker Compose
|
||||||
|
|
||||||
|
From this folder:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Open <http://127.0.0.1:8787>, upload the roster PDF, choose its month, and select **Format roster**.
|
||||||
|
|
||||||
|
Common commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose restart # restart it later
|
||||||
|
docker compose logs -f app # watch logs
|
||||||
|
docker compose down # stop it
|
||||||
|
docker compose up -d --build # rebuild after code changes
|
||||||
|
```
|
||||||
|
|
||||||
|
The Compose service restarts automatically unless you explicitly stop it. Its direct browser port binds only to `127.0.0.1`.
|
||||||
|
|
||||||
|
The service also joins the existing external `tabernet` Docker network, so Caddy can reach it at `roster-formatter:8787`. For a Caddy hostname, add that hostname to `.env`:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
ROSTER_ALLOWED_HOSTS=roster.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Then a minimal Caddy route is:
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
roster.example.com {
|
||||||
|
reverse_proxy roster-formatter:8787
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `tabernet` network must already exist. Because roster images contain personal information, add authentication in Caddy before exposing this service outside your private network.
|
||||||
|
|
||||||
|
## Saved files and automatic cleanup
|
||||||
|
|
||||||
|
Every successful PNG is returned to the browser with a **Download PNG** button and saved in [`output/`](output). The Compose container mounts that folder, so saved PNGs survive rebuilds and restarts.
|
||||||
|
|
||||||
|
Layout defaults are persistent too. Change values under **Advanced adjustments**, then select **Save as defaults** to prefill them for future rosters. **Reset changes** restores the currently saved values, while **Use original values** loads the production geometry without saving it. The server stores these defaults in `output/.roster-layout-defaults.json`, inside the same mounted volume, and the roster cleanup never removes that settings file.
|
||||||
|
|
||||||
|
On startup, every six hours, and after each successful format, the app removes only filenames matching its roster pattern, such as `MARCH 2026 Roster.png`, when either its roster month or saved-file age is outside the three-calendar-month window. The file-age check prevents an accidentally future-dated filename from bypassing cleanup. Other files are not removed. Cleanup is permanent.
|
||||||
|
|
||||||
|
Change the retention window or browser port with a `.env` file beside `compose.yaml`:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
ROSTER_RETENTION_MONTHS=6
|
||||||
|
ROSTER_PORT=8788
|
||||||
|
TZ=America/Chicago
|
||||||
|
```
|
||||||
|
|
||||||
|
Set `ROSTER_RETENTION_MONTHS=0` to disable cleanup.
|
||||||
|
|
||||||
|
The image runs as UID/GID `1000:1000`, matching this machine. On a host with a different checkout owner, set `ROSTER_UID` and `ROSTER_GID` in `.env`, then rebuild so the container can write to `output/`:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
ROSTER_UID=1001
|
||||||
|
ROSTER_GID=1001
|
||||||
|
```
|
||||||
|
|
||||||
|
## Layout guarantees
|
||||||
|
|
||||||
|
With the advanced fields untouched, the renderer preserves the original production values:
|
||||||
|
|
||||||
|
| Setting | Default |
|
||||||
|
| --- | ---: |
|
||||||
|
| Canvas | 792 × 612 PDF points |
|
||||||
|
| PNG export | 1650 × 1275 pixels at 150 DPI |
|
||||||
|
| Roster vertical shift | 100 |
|
||||||
|
| Bottom trim | 60 |
|
||||||
|
| Logo position | X 50, Y -10 |
|
||||||
|
| Logo scale | 0.27 |
|
||||||
|
| Date position | X 460, Y 88 |
|
||||||
|
| Date font size | 48 |
|
||||||
|
|
||||||
|
The web page exposes the layout values in a collapsed **Advanced adjustments** section. Only an intentional field change—or explicitly saving that change as the new default—alters logo/date scale or placement. The canvas size and PNG DPI are not editable. The original production values remain available through **Use original values**.
|
||||||
|
|
||||||
|
The page theme defaults to **System** and follows the browser/operating-system color preference. The **System / Light / Dark** control remembers the choice in that browser. Theme changes affect only the webpage and never the generated PNG.
|
||||||
|
|
||||||
|
Input is deliberately limited to a one-page, unrotated, 11 × 8.5 inch landscape PDF. This prevents a differently sized upload from silently changing the established geometry.
|
||||||
|
|
||||||
|
## Run without Docker
|
||||||
|
|
||||||
|
The existing `venv` already contains the formatter dependencies. Start the web app with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
venv/bin/python server.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Or create a fresh environment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m venv .venv
|
||||||
|
.venv/bin/pip install -r requirements.txt
|
||||||
|
.venv/bin/python server.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The original CLI workflow is still available and now accepts an explicit input path:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
venv/bin/python main.py --input input/original.pdf --month "July 2026"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Run the standard-library test suite with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 venv/bin/python -m unittest discover -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Uploads and intermediate PDFs are created in isolated temporary directories and removed immediately after formatting. Only the final PNG is retained in `output/`.
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
name: roster-formatter
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
args:
|
||||||
|
APP_UID: "${ROSTER_UID:-1000}"
|
||||||
|
APP_GID: "${ROSTER_GID:-1000}"
|
||||||
|
image: roster-formatter:local
|
||||||
|
container_name: roster-formatter
|
||||||
|
restart: unless-stopped
|
||||||
|
init: true
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${ROSTER_PORT:-8787}:8787"
|
||||||
|
environment:
|
||||||
|
ROSTER_OUTPUT_DIR: /app/output
|
||||||
|
ROSTER_RETENTION_MONTHS: "${ROSTER_RETENTION_MONTHS:-3}"
|
||||||
|
ROSTER_MAX_UPLOAD_MB: "${ROSTER_MAX_UPLOAD_MB:-20}"
|
||||||
|
ROSTER_ALLOWED_HOSTS: "${ROSTER_ALLOWED_HOSTS:-}"
|
||||||
|
TZ: "${TZ:-America/Chicago}"
|
||||||
|
volumes:
|
||||||
|
- ./output:/app/output:rw
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp:size=256m,mode=1777
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
stop_grace_period: 10s
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
- CMD
|
||||||
|
- python
|
||||||
|
- -c
|
||||||
|
- "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8787/health', timeout=3).read()"
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
networks:
|
||||||
|
- tabernet
|
||||||
|
|
||||||
|
networks:
|
||||||
|
tabernet:
|
||||||
|
external: true
|
||||||
|
name: tabernet
|
||||||
Binary file not shown.
@@ -1,102 +1,72 @@
|
|||||||
from modules.adjust_layout import adjust_layout
|
"""Command-line entry point for the roster formatter."""
|
||||||
from modules.add_logo import add_logo
|
|
||||||
from pathlib import Path
|
from __future__ import annotations
|
||||||
from modules.add_date import add_date
|
|
||||||
from datetime import datetime
|
|
||||||
from dateutil.relativedelta import relativedelta # type: ignore
|
|
||||||
import argparse
|
import argparse
|
||||||
import fitz # type: ignore
|
from pathlib import Path
|
||||||
|
|
||||||
def convert_pdf_to_png(pdf_path, output_path, dpi=150):
|
from roster_formatter import (
|
||||||
doc = fitz.open(pdf_path)
|
BASE_DIR,
|
||||||
page = doc.load_page(0)
|
LayoutSettings,
|
||||||
pix = page.get_pixmap(dpi=dpi)
|
RosterFormatError,
|
||||||
pix.save(output_path)
|
RosterPeriod,
|
||||||
doc.close()
|
format_roster,
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description="Generate formatted roster")
|
|
||||||
parser.add_argument("--month", type=str, help="Override the month/year label (e.g., 'September 2025')")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
if args.month:
|
|
||||||
month_year = args.month.upper()
|
|
||||||
else:
|
|
||||||
today = datetime.now()
|
|
||||||
target = today if today.day < 15 else today + relativedelta(months=1)
|
|
||||||
month_year = target.strftime("%B %Y").upper()
|
|
||||||
|
|
||||||
|
|
||||||
# Define paths
|
|
||||||
base = Path(__file__).parent
|
|
||||||
input_pdf = base / "input/original.pdf"
|
|
||||||
shifted_pdf = base / "output/shifted.pdf"
|
|
||||||
|
|
||||||
# Apply layout adjustment
|
|
||||||
adjust_layout(input_pdf, shifted_pdf, shift_amount=100)
|
|
||||||
|
|
||||||
# Paths
|
|
||||||
logo_input = shifted_pdf
|
|
||||||
logo_output = base / "output/with_logo.pdf"
|
|
||||||
logo_file = base / "assets/logo.png"
|
|
||||||
|
|
||||||
add_logo(logo_input, logo_output, logo_file, position=(50, -10), scale=0.27)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Determine month/year label
|
|
||||||
if args.month:
|
|
||||||
parts = args.month.strip().split()
|
|
||||||
full_month = parts[0].capitalize()
|
|
||||||
year = parts[1] if len(parts) > 1 else str(datetime.now().year)
|
|
||||||
|
|
||||||
# Smart abbreviation
|
|
||||||
if len(full_month) > 5:
|
|
||||||
month = full_month[:3].upper() # "September" → "SEP"
|
|
||||||
else:
|
|
||||||
month = full_month.upper() # "June" → "JUNE"
|
|
||||||
|
|
||||||
month_year = f"{month} {year}"
|
|
||||||
else:
|
|
||||||
today = datetime.now()
|
|
||||||
target = today if today.day < 15 else today + relativedelta(months=1)
|
|
||||||
full_month = target.strftime("%B")
|
|
||||||
year = target.strftime("%Y")
|
|
||||||
|
|
||||||
if len(full_month) > 5:
|
|
||||||
month = target.strftime("%b").upper()
|
|
||||||
else:
|
|
||||||
month = full_month.upper()
|
|
||||||
|
|
||||||
month_year = f"{month} {year}"
|
|
||||||
|
|
||||||
final_pdf_name = f"{month} {year} Roster.pdf"
|
|
||||||
final_pdf_path = base / "output" / final_pdf_name
|
|
||||||
final_png_path = final_pdf_path.with_suffix(".png")
|
|
||||||
date_output = final_pdf_path
|
|
||||||
|
|
||||||
# Paths
|
|
||||||
date_input = logo_output
|
|
||||||
font_file = base / "assets/fonts/Roboto-Variable.ttf"
|
|
||||||
|
|
||||||
add_date(
|
|
||||||
input_path=date_input,
|
|
||||||
output_path=date_output,
|
|
||||||
month_year=month_year,
|
|
||||||
font_path=font_file,
|
|
||||||
position=(460, 88),
|
|
||||||
font_size=48
|
|
||||||
)
|
)
|
||||||
|
|
||||||
convert_pdf_to_png(final_pdf_path, final_png_path)
|
|
||||||
|
|
||||||
final_pdf_name = f"{month} {year} Roster.pdf"
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
final_pdf_path = base / "output" / final_pdf_name
|
parser = argparse.ArgumentParser(description="Generate a formatted roster PNG")
|
||||||
|
parser.add_argument(
|
||||||
|
"--input",
|
||||||
|
type=Path,
|
||||||
|
default=BASE_DIR / "input" / "original.pdf",
|
||||||
|
help="one-page landscape Letter roster PDF",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output-dir",
|
||||||
|
type=Path,
|
||||||
|
default=BASE_DIR / "output",
|
||||||
|
help="directory for the formatted PNG",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--month",
|
||||||
|
help="override the month/year label (for example, 'September 2026' or '2026-09')",
|
||||||
|
)
|
||||||
|
|
||||||
final_png_name = final_pdf_name.replace(".pdf", ".png")
|
advanced = parser.add_argument_group("advanced layout adjustments")
|
||||||
final_png_path = base / "output" / final_png_name
|
advanced.add_argument("--content-shift", type=float, default=100)
|
||||||
|
advanced.add_argument("--bottom-margin", type=float, default=60)
|
||||||
|
advanced.add_argument("--logo-x", type=float, default=50)
|
||||||
|
advanced.add_argument("--logo-y", type=float, default=-10)
|
||||||
|
advanced.add_argument("--logo-scale", type=float, default=0.27)
|
||||||
|
advanced.add_argument("--date-x", type=float, default=460)
|
||||||
|
advanced.add_argument("--date-y", type=float, default=88)
|
||||||
|
advanced.add_argument("--date-font-size", type=int, default=48)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
# Clean up intermediate PDFs
|
def main() -> int:
|
||||||
for path in [shifted_pdf, logo_output, final_pdf_path]:
|
args = build_parser().parse_args()
|
||||||
if path.exists():
|
try:
|
||||||
path.unlink()
|
period = RosterPeriod.parse(args.month)
|
||||||
|
settings = LayoutSettings(
|
||||||
|
content_shift=args.content_shift,
|
||||||
|
bottom_margin=args.bottom_margin,
|
||||||
|
logo_x=args.logo_x,
|
||||||
|
logo_y=args.logo_y,
|
||||||
|
logo_scale=args.logo_scale,
|
||||||
|
date_x=args.date_x,
|
||||||
|
date_y=args.date_y,
|
||||||
|
date_font_size=args.date_font_size,
|
||||||
|
)
|
||||||
|
output_path = args.output_dir / period.output_filename
|
||||||
|
result = format_roster(args.input, output_path, period, settings)
|
||||||
|
except RosterFormatError as exc:
|
||||||
|
raise SystemExit(f"error: {exc}") from exc
|
||||||
|
|
||||||
|
print(f"Created {result.path}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
|
|||||||
Binary file not shown.
@@ -1,10 +1,10 @@
|
|||||||
import fitz # PyMuPDF
|
import fitz # PyMuPDF
|
||||||
|
|
||||||
LETTER_WIDTH = 792 # 8.5 inches
|
LETTER_WIDTH = 792 # 11 inches at 72 points per inch
|
||||||
LETTER_HEIGHT = 612 # 11 inches
|
LETTER_HEIGHT = 612 # 8.5 inches at 72 points per inch
|
||||||
BOTTOM_MARGIN = 60
|
BOTTOM_MARGIN = 60
|
||||||
|
|
||||||
def adjust_layout(input_path, output_path, shift_amount):
|
def adjust_layout(input_path, output_path, shift_amount, bottom_margin=BOTTOM_MARGIN):
|
||||||
doc = fitz.open(input_path)
|
doc = fitz.open(input_path)
|
||||||
new_doc = fitz.open()
|
new_doc = fitz.open()
|
||||||
|
|
||||||
@@ -17,7 +17,12 @@ def adjust_layout(input_path, output_path, shift_amount):
|
|||||||
|
|
||||||
# Shift original content down by shift_amount
|
# Shift original content down by shift_amount
|
||||||
# Clip it back to fit on a Letter-sized page
|
# Clip it back to fit on a Letter-sized page
|
||||||
content_rect = fitz.Rect(0, shift_amount, rect.width, rect.height + shift_amount - BOTTOM_MARGIN)
|
content_rect = fitz.Rect(
|
||||||
|
0,
|
||||||
|
shift_amount,
|
||||||
|
rect.width,
|
||||||
|
rect.height + shift_amount - bottom_margin,
|
||||||
|
)
|
||||||
|
|
||||||
new_page.show_pdf_page(content_rect, doc, page.number)
|
new_page.show_pdf_page(content_rect, doc, page.number)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 172 KiB |
+2
-30
@@ -1,30 +1,2 @@
|
|||||||
Brotli==1.0.9
|
Pillow==11.2.1
|
||||||
certifi==2022.9.24
|
PyMuPDF==1.26.0
|
||||||
chardet==5.1.0
|
|
||||||
charset-normalizer==3.0.1
|
|
||||||
dbus-python==1.3.2
|
|
||||||
httplib2==0.20.4
|
|
||||||
idna==3.3
|
|
||||||
Mako==1.2.4.dev0
|
|
||||||
Markdown==3.4.1
|
|
||||||
MarkupSafe==2.1.2
|
|
||||||
meson==1.0.1
|
|
||||||
mutagen==1.46.0
|
|
||||||
pycairo==1.20.1
|
|
||||||
pycryptodomex==3.11.0
|
|
||||||
pycurl==7.45.2
|
|
||||||
Pygments==2.14.0
|
|
||||||
PyGObject==3.42.2
|
|
||||||
pyparsing==3.0.9
|
|
||||||
PySimpleSOAP==1.16.2
|
|
||||||
python-apt==2.6.0
|
|
||||||
python-debian==0.1.49
|
|
||||||
python-debianbts==4.0.1
|
|
||||||
pyxattr==0.8.1
|
|
||||||
PyYAML==6.0
|
|
||||||
reportbug==12.0.0
|
|
||||||
requests==2.28.1
|
|
||||||
six==1.16.0
|
|
||||||
urllib3==1.26.12
|
|
||||||
websockets==10.4
|
|
||||||
yt-dlp==2023.3.4
|
|
||||||
|
|||||||
@@ -0,0 +1,376 @@
|
|||||||
|
"""Shared PDF-to-PNG roster formatter.
|
||||||
|
|
||||||
|
The defaults in :class:`LayoutSettings` are the original production geometry.
|
||||||
|
Both the CLI and web server call this module so the browser workflow cannot
|
||||||
|
silently introduce a second rendering path.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from calendar import month_name
|
||||||
|
from dataclasses import dataclass, fields
|
||||||
|
from datetime import date, datetime
|
||||||
|
from math import isfinite
|
||||||
|
from os import PathLike, fsync, replace
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
from shutil import copyfileobj
|
||||||
|
from tempfile import NamedTemporaryFile, TemporaryDirectory
|
||||||
|
from typing import Mapping
|
||||||
|
|
||||||
|
import fitz # type: ignore
|
||||||
|
|
||||||
|
from modules.add_date import add_date
|
||||||
|
from modules.add_logo import add_logo
|
||||||
|
from modules.adjust_layout import adjust_layout
|
||||||
|
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
|
LOGO_PATH = BASE_DIR / "assets" / "logo.png"
|
||||||
|
FONT_PATH = BASE_DIR / "assets" / "fonts" / "Roboto-Variable.ttf"
|
||||||
|
|
||||||
|
# Fixed export geometry. These are intentionally not web-editable.
|
||||||
|
PAGE_WIDTH = 792
|
||||||
|
PAGE_HEIGHT = 612
|
||||||
|
PNG_DPI = 150
|
||||||
|
PNG_WIDTH = 1650
|
||||||
|
PNG_HEIGHT = 1275
|
||||||
|
|
||||||
|
_MONTH_ALIASES = {
|
||||||
|
alias.lower(): number
|
||||||
|
for number in range(1, 13)
|
||||||
|
for alias in (month_name[number], month_name[number][:3])
|
||||||
|
}
|
||||||
|
_MONTH_ALIASES["sept"] = 9
|
||||||
|
_HUMAN_PERIOD_RE = re.compile(r"^([A-Za-z]+)(?:\s+([0-9]{4}))?$")
|
||||||
|
_ISO_PERIOD_RE = re.compile(r"^([0-9]{4})-([0-9]{2})$")
|
||||||
|
_OUTPUT_NAME_RE = re.compile(
|
||||||
|
r"^(JAN|FEB|MARCH|APRIL|MAY|JUNE|JULY|AUG|SEP|OCT|NOV|DEC) "
|
||||||
|
r"([0-9]{4}) Roster\.png$"
|
||||||
|
)
|
||||||
|
_OUTPUT_LABEL_TO_MONTH = {
|
||||||
|
"JAN": 1,
|
||||||
|
"FEB": 2,
|
||||||
|
"MARCH": 3,
|
||||||
|
"APRIL": 4,
|
||||||
|
"MAY": 5,
|
||||||
|
"JUNE": 6,
|
||||||
|
"JULY": 7,
|
||||||
|
"AUG": 8,
|
||||||
|
"SEP": 9,
|
||||||
|
"OCT": 10,
|
||||||
|
"NOV": 11,
|
||||||
|
"DEC": 12,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class RosterFormatError(ValueError):
|
||||||
|
"""A safe, user-facing roster formatting error."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, order=True)
|
||||||
|
class RosterPeriod:
|
||||||
|
"""The month represented by a formatted roster."""
|
||||||
|
|
||||||
|
year: int
|
||||||
|
month: int
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not 1 <= self.month <= 12:
|
||||||
|
raise RosterFormatError("The roster month must be between 1 and 12.")
|
||||||
|
if not 1900 <= self.year <= 9999:
|
||||||
|
raise RosterFormatError("The roster year must contain four digits.")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def month_token(self) -> str:
|
||||||
|
full_month = month_name[self.month].upper()
|
||||||
|
return full_month[:3] if len(full_month) > 5 else full_month
|
||||||
|
|
||||||
|
@property
|
||||||
|
def label(self) -> str:
|
||||||
|
return f"{self.month_token} {self.year}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def html_value(self) -> str:
|
||||||
|
return f"{self.year:04d}-{self.month:02d}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def output_filename(self) -> str:
|
||||||
|
return f"{self.label} Roster.png"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def suggested(cls, today: date | datetime | None = None) -> "RosterPeriod":
|
||||||
|
current = (today or date.today()).date() if isinstance(today, datetime) else (today or date.today())
|
||||||
|
year = current.year
|
||||||
|
month = current.month
|
||||||
|
if current.day >= 15:
|
||||||
|
month += 1
|
||||||
|
if month == 13:
|
||||||
|
month = 1
|
||||||
|
year += 1
|
||||||
|
return cls(year=year, month=month)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def parse(
|
||||||
|
cls,
|
||||||
|
value: str | None,
|
||||||
|
today: date | datetime | None = None,
|
||||||
|
) -> "RosterPeriod":
|
||||||
|
if value is None or not value.strip():
|
||||||
|
return cls.suggested(today)
|
||||||
|
|
||||||
|
cleaned = value.strip()
|
||||||
|
iso_match = _ISO_PERIOD_RE.fullmatch(cleaned)
|
||||||
|
if iso_match:
|
||||||
|
return cls(year=int(iso_match.group(1)), month=int(iso_match.group(2)))
|
||||||
|
|
||||||
|
human_match = _HUMAN_PERIOD_RE.fullmatch(cleaned)
|
||||||
|
if not human_match:
|
||||||
|
raise RosterFormatError(
|
||||||
|
"Use a month and four-digit year, such as July 2026."
|
||||||
|
)
|
||||||
|
|
||||||
|
month = _MONTH_ALIASES.get(human_match.group(1).lower())
|
||||||
|
if month is None:
|
||||||
|
raise RosterFormatError(f"Unknown month: {human_match.group(1)}.")
|
||||||
|
|
||||||
|
current = (today or date.today()).date() if isinstance(today, datetime) else (today or date.today())
|
||||||
|
year = int(human_match.group(2)) if human_match.group(2) else current.year
|
||||||
|
return cls(year=year, month=month)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_output_filename(cls, filename: str) -> "RosterPeriod | None":
|
||||||
|
match = _OUTPUT_NAME_RE.fullmatch(filename)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
return cls(year=int(match.group(2)), month=_OUTPUT_LABEL_TO_MONTH[match.group(1)])
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LayoutSettings:
|
||||||
|
"""User-adjustable layout values, with the legacy values as defaults."""
|
||||||
|
|
||||||
|
content_shift: float = 100
|
||||||
|
bottom_margin: float = 60
|
||||||
|
logo_x: float = 50
|
||||||
|
logo_y: float = -10
|
||||||
|
logo_scale: float = 0.27
|
||||||
|
date_x: float = 460
|
||||||
|
date_y: float = 88
|
||||||
|
date_font_size: int = 48
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
limits = {
|
||||||
|
"content_shift": (-300, 500),
|
||||||
|
"bottom_margin": (0, 500),
|
||||||
|
"logo_x": (-1000, 2000),
|
||||||
|
"logo_y": (-1000, 2000),
|
||||||
|
"logo_scale": (0.01, 2),
|
||||||
|
"date_x": (-1000, 2000),
|
||||||
|
"date_y": (-1000, 2000),
|
||||||
|
}
|
||||||
|
for name, (minimum, maximum) in limits.items():
|
||||||
|
value = getattr(self, name)
|
||||||
|
if not isinstance(value, (int, float)) or not isfinite(value):
|
||||||
|
raise RosterFormatError(f"{name.replace('_', ' ').title()} must be a number.")
|
||||||
|
if not minimum <= value <= maximum:
|
||||||
|
raise RosterFormatError(
|
||||||
|
f"{name.replace('_', ' ').title()} must be between {minimum} and {maximum}."
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.bottom_margin >= PAGE_HEIGHT:
|
||||||
|
raise RosterFormatError("Bottom margin must be smaller than the page height.")
|
||||||
|
if isinstance(self.date_font_size, bool) or not isinstance(self.date_font_size, int):
|
||||||
|
raise RosterFormatError("Date font size must be a whole number.")
|
||||||
|
if not 8 <= self.date_font_size <= 200:
|
||||||
|
raise RosterFormatError("Date font size must be between 8 and 200.")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_mapping(
|
||||||
|
cls,
|
||||||
|
values: Mapping[str, str],
|
||||||
|
defaults: "LayoutSettings | None" = None,
|
||||||
|
) -> "LayoutSettings":
|
||||||
|
fallback = defaults or cls()
|
||||||
|
|
||||||
|
def parse_float(name: str) -> float:
|
||||||
|
raw = values.get(name)
|
||||||
|
if raw is None or not raw.strip():
|
||||||
|
return float(getattr(fallback, name))
|
||||||
|
try:
|
||||||
|
return float(raw)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise RosterFormatError(
|
||||||
|
f"{name.replace('_', ' ').title()} must be a number."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
raw_font_size = values.get("date_font_size")
|
||||||
|
if raw_font_size is None or not raw_font_size.strip():
|
||||||
|
font_size = fallback.date_font_size
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
font_size = int(raw_font_size)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise RosterFormatError("Date font size must be a whole number.") from exc
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
content_shift=parse_float("content_shift"),
|
||||||
|
bottom_margin=parse_float("bottom_margin"),
|
||||||
|
logo_x=parse_float("logo_x"),
|
||||||
|
logo_y=parse_float("logo_y"),
|
||||||
|
logo_scale=parse_float("logo_scale"),
|
||||||
|
date_x=parse_float("date_x"),
|
||||||
|
date_y=parse_float("date_y"),
|
||||||
|
date_font_size=font_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
def as_form_values(self) -> dict[str, str]:
|
||||||
|
return {field.name: str(getattr(self, field.name)) for field in fields(self)}
|
||||||
|
|
||||||
|
def as_json_values(self) -> dict[str, float | int]:
|
||||||
|
return {field.name: getattr(self, field.name) for field in fields(self)}
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_LAYOUT = LayoutSettings()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FormatResult:
|
||||||
|
path: Path
|
||||||
|
period: RosterPeriod
|
||||||
|
|
||||||
|
@property
|
||||||
|
def filename(self) -> str:
|
||||||
|
return self.period.output_filename
|
||||||
|
|
||||||
|
|
||||||
|
def validate_input_pdf(input_path: str | PathLike[str]) -> None:
|
||||||
|
"""Reject inputs whose geometry would make the established layout drift."""
|
||||||
|
|
||||||
|
path = Path(input_path)
|
||||||
|
try:
|
||||||
|
with path.open("rb") as source:
|
||||||
|
header = source.read(1024)
|
||||||
|
except OSError as exc:
|
||||||
|
raise RosterFormatError("The uploaded PDF could not be read.") from exc
|
||||||
|
|
||||||
|
if b"%PDF-" not in header:
|
||||||
|
raise RosterFormatError("The uploaded file is not a PDF.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
doc = fitz.open(path)
|
||||||
|
try:
|
||||||
|
if doc.needs_pass:
|
||||||
|
raise RosterFormatError("Password-protected PDFs are not supported.")
|
||||||
|
if doc.page_count != 1:
|
||||||
|
raise RosterFormatError("Upload a one-page roster PDF.")
|
||||||
|
|
||||||
|
page = doc.load_page(0)
|
||||||
|
if page.rotation != 0:
|
||||||
|
raise RosterFormatError("The roster PDF must not have page rotation metadata.")
|
||||||
|
if (
|
||||||
|
abs(page.rect.width - PAGE_WIDTH) > 0.5
|
||||||
|
or abs(page.rect.height - PAGE_HEIGHT) > 0.5
|
||||||
|
):
|
||||||
|
raise RosterFormatError(
|
||||||
|
"The roster must be an 11 × 8.5 inch landscape PDF (792 × 612 points)."
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
doc.close()
|
||||||
|
except RosterFormatError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise RosterFormatError("The uploaded PDF is damaged or unsupported.") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def convert_pdf_to_png(
|
||||||
|
pdf_path: str | PathLike[str],
|
||||||
|
output_path: str | PathLike[str],
|
||||||
|
dpi: int = PNG_DPI,
|
||||||
|
) -> None:
|
||||||
|
"""Rasterize page one exactly as the original formatter did."""
|
||||||
|
|
||||||
|
doc = fitz.open(pdf_path)
|
||||||
|
try:
|
||||||
|
page = doc.load_page(0)
|
||||||
|
pix = page.get_pixmap(dpi=dpi)
|
||||||
|
pix.save(output_path)
|
||||||
|
finally:
|
||||||
|
doc.close()
|
||||||
|
|
||||||
|
|
||||||
|
def format_roster(
|
||||||
|
input_pdf: str | PathLike[str],
|
||||||
|
output_png: str | PathLike[str],
|
||||||
|
period: RosterPeriod | str | None = None,
|
||||||
|
settings: LayoutSettings = DEFAULT_LAYOUT,
|
||||||
|
) -> FormatResult:
|
||||||
|
"""Format one roster using isolated intermediate files and an atomic output."""
|
||||||
|
|
||||||
|
selected_period = period if isinstance(period, RosterPeriod) else RosterPeriod.parse(period)
|
||||||
|
input_path = Path(input_pdf)
|
||||||
|
output_path = Path(output_png)
|
||||||
|
|
||||||
|
validate_input_pdf(input_path)
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
with TemporaryDirectory(prefix="roster-format-") as temp_dir:
|
||||||
|
work_dir = Path(temp_dir)
|
||||||
|
shifted_pdf = work_dir / "shifted.pdf"
|
||||||
|
logo_pdf = work_dir / "with_logo.pdf"
|
||||||
|
final_pdf = work_dir / "final.pdf"
|
||||||
|
staged_png = work_dir / "formatted.png"
|
||||||
|
|
||||||
|
try:
|
||||||
|
adjust_layout(
|
||||||
|
input_path,
|
||||||
|
shifted_pdf,
|
||||||
|
shift_amount=settings.content_shift,
|
||||||
|
bottom_margin=settings.bottom_margin,
|
||||||
|
)
|
||||||
|
add_logo(
|
||||||
|
shifted_pdf,
|
||||||
|
logo_pdf,
|
||||||
|
LOGO_PATH,
|
||||||
|
position=(settings.logo_x, settings.logo_y),
|
||||||
|
scale=settings.logo_scale,
|
||||||
|
)
|
||||||
|
add_date(
|
||||||
|
input_path=logo_pdf,
|
||||||
|
output_path=final_pdf,
|
||||||
|
month_year=selected_period.label,
|
||||||
|
font_path=FONT_PATH,
|
||||||
|
position=(settings.date_x, settings.date_y),
|
||||||
|
font_size=settings.date_font_size,
|
||||||
|
)
|
||||||
|
convert_pdf_to_png(final_pdf, staged_png)
|
||||||
|
|
||||||
|
# Only the completed PNG crosses into the destination filesystem.
|
||||||
|
# This keeps working PDFs in /tmp while preserving an atomic replace
|
||||||
|
# even when /tmp and output_path are different mounts.
|
||||||
|
destination_temp: Path | None = None
|
||||||
|
try:
|
||||||
|
with staged_png.open("rb") as source, NamedTemporaryFile(
|
||||||
|
mode="wb",
|
||||||
|
prefix=".roster-output-",
|
||||||
|
suffix=".png",
|
||||||
|
dir=output_path.parent,
|
||||||
|
delete=False,
|
||||||
|
) as destination:
|
||||||
|
destination_temp = Path(destination.name)
|
||||||
|
copyfileobj(source, destination)
|
||||||
|
destination.flush()
|
||||||
|
fsync(destination.fileno())
|
||||||
|
replace(destination_temp, output_path)
|
||||||
|
destination_temp = None
|
||||||
|
finally:
|
||||||
|
if destination_temp is not None:
|
||||||
|
destination_temp.unlink(missing_ok=True)
|
||||||
|
except RosterFormatError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise RosterFormatError(
|
||||||
|
"The roster could not be formatted. Check that the PDF is valid and try again."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
return FormatResult(path=output_path, period=selected_period)
|
||||||
@@ -0,0 +1,817 @@
|
|||||||
|
"""Small, dependency-free local web server for the roster formatter."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
from email import policy
|
||||||
|
from email.parser import BytesParser
|
||||||
|
from http import HTTPStatus
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import stat as stat_module
|
||||||
|
from tempfile import NamedTemporaryFile, TemporaryDirectory
|
||||||
|
from threading import Event, Lock, Thread
|
||||||
|
from typing import Mapping
|
||||||
|
from urllib.parse import quote, unquote, urlsplit
|
||||||
|
|
||||||
|
from roster_formatter import (
|
||||||
|
BASE_DIR,
|
||||||
|
DEFAULT_LAYOUT,
|
||||||
|
LayoutSettings,
|
||||||
|
RosterFormatError,
|
||||||
|
RosterPeriod,
|
||||||
|
format_roster,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger("roster.web")
|
||||||
|
DEFAULT_HOST = "127.0.0.1"
|
||||||
|
DEFAULT_PORT = 8787
|
||||||
|
DEFAULT_RETENTION_MONTHS = 3
|
||||||
|
DEFAULT_MAX_UPLOAD_MB = 20
|
||||||
|
RETENTION_CHECK_SECONDS = 6 * 60 * 60
|
||||||
|
DEFAULT_ALLOWED_HOSTS = frozenset({"127.0.0.1", "localhost", "::1", "0.0.0.0"})
|
||||||
|
LAYOUT_DEFAULTS_FILENAME = ".roster-layout-defaults.json"
|
||||||
|
MAX_DEFAULTS_REQUEST_BYTES = 16 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
class RequestError(ValueError):
|
||||||
|
def __init__(self, status: HTTPStatus, message: str) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.status = status
|
||||||
|
self.message = message
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class UploadedFile:
|
||||||
|
filename: str
|
||||||
|
content_type: str
|
||||||
|
data: bytes
|
||||||
|
|
||||||
|
|
||||||
|
class OutputStore:
|
||||||
|
"""Atomic persistent PNG storage with filename-based calendar retention."""
|
||||||
|
|
||||||
|
def __init__(self, root: Path, retention_months: int = DEFAULT_RETENTION_MONTHS) -> None:
|
||||||
|
if retention_months < 0:
|
||||||
|
raise ValueError("retention_months cannot be negative")
|
||||||
|
self.root = root.resolve()
|
||||||
|
self.retention_months = retention_months
|
||||||
|
self._lock = Lock()
|
||||||
|
self.root.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def layout_defaults_path(self) -> Path:
|
||||||
|
return self.root / LAYOUT_DEFAULTS_FILENAME
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _read_regular_file(path: Path, max_bytes: int | None = None) -> bytes | None:
|
||||||
|
try:
|
||||||
|
path_stat = path.stat(follow_symlinks=False)
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
if not stat_module.S_ISREG(path_stat.st_mode):
|
||||||
|
return None
|
||||||
|
if max_bytes is not None and path_stat.st_size > max_bytes:
|
||||||
|
return None
|
||||||
|
|
||||||
|
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
||||||
|
try:
|
||||||
|
descriptor = os.open(path, flags)
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
opened_stat = os.fstat(descriptor)
|
||||||
|
if not stat_module.S_ISREG(opened_stat.st_mode):
|
||||||
|
return None
|
||||||
|
if max_bytes is not None and opened_stat.st_size > max_bytes:
|
||||||
|
return None
|
||||||
|
source = os.fdopen(descriptor, "rb")
|
||||||
|
descriptor = -1
|
||||||
|
with source:
|
||||||
|
return source.read()
|
||||||
|
finally:
|
||||||
|
if descriptor >= 0:
|
||||||
|
os.close(descriptor)
|
||||||
|
|
||||||
|
def load_layout_defaults(self) -> LayoutSettings:
|
||||||
|
with self._lock:
|
||||||
|
raw = self._read_regular_file(
|
||||||
|
self.layout_defaults_path,
|
||||||
|
max_bytes=MAX_DEFAULTS_REQUEST_BYTES,
|
||||||
|
)
|
||||||
|
if raw is None:
|
||||||
|
return DEFAULT_LAYOUT
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = json.loads(raw.decode("utf-8"))
|
||||||
|
if not isinstance(payload, dict) or payload.get("version") != 1:
|
||||||
|
raise ValueError("unsupported settings format")
|
||||||
|
layout = payload.get("layout")
|
||||||
|
expected = set(DEFAULT_LAYOUT.as_form_values())
|
||||||
|
if not isinstance(layout, dict) or set(layout) != expected:
|
||||||
|
raise ValueError("settings fields do not match")
|
||||||
|
values = {name: str(value) for name, value in layout.items()}
|
||||||
|
return LayoutSettings.from_mapping(values)
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError, ValueError, TypeError) as exc:
|
||||||
|
LOGGER.warning("Ignoring invalid saved layout defaults: %s", exc)
|
||||||
|
return DEFAULT_LAYOUT
|
||||||
|
|
||||||
|
def save_layout_defaults(self, settings: LayoutSettings) -> None:
|
||||||
|
data = (
|
||||||
|
json.dumps(
|
||||||
|
{"version": 1, "layout": settings.as_json_values()},
|
||||||
|
indent=2,
|
||||||
|
sort_keys=True,
|
||||||
|
)
|
||||||
|
+ "\n"
|
||||||
|
).encode("utf-8")
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
temporary_path: Path | None = None
|
||||||
|
try:
|
||||||
|
with NamedTemporaryFile(
|
||||||
|
mode="wb",
|
||||||
|
prefix=".roster-defaults-",
|
||||||
|
suffix=".json",
|
||||||
|
dir=self.root,
|
||||||
|
delete=False,
|
||||||
|
) as temporary:
|
||||||
|
temporary.write(data)
|
||||||
|
temporary.flush()
|
||||||
|
os.fsync(temporary.fileno())
|
||||||
|
temporary_path = Path(temporary.name)
|
||||||
|
os.replace(temporary_path, self.layout_defaults_path)
|
||||||
|
temporary_path = None
|
||||||
|
finally:
|
||||||
|
if temporary_path is not None:
|
||||||
|
temporary_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _cutoff(today: date | datetime, months: int) -> RosterPeriod:
|
||||||
|
current = today.date() if isinstance(today, datetime) else today
|
||||||
|
month_index = current.year * 12 + current.month - 1 - months
|
||||||
|
return RosterPeriod(year=month_index // 12, month=month_index % 12 + 1)
|
||||||
|
|
||||||
|
def _prune_locked(self, today: date | datetime) -> list[str]:
|
||||||
|
if self.retention_months == 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
cutoff = self._cutoff(today, self.retention_months)
|
||||||
|
cutoff_date = date(cutoff.year, cutoff.month, 1)
|
||||||
|
removed: list[str] = []
|
||||||
|
for path in self.root.iterdir():
|
||||||
|
try:
|
||||||
|
path_stat = path.stat(follow_symlinks=False)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if not stat_module.S_ISREG(path_stat.st_mode):
|
||||||
|
continue
|
||||||
|
period = RosterPeriod.from_output_filename(path.name)
|
||||||
|
modified_date = datetime.fromtimestamp(path_stat.st_mtime).date()
|
||||||
|
if period is None or (period >= cutoff and modified_date >= cutoff_date):
|
||||||
|
continue
|
||||||
|
path.unlink()
|
||||||
|
removed.append(path.name)
|
||||||
|
return sorted(removed)
|
||||||
|
|
||||||
|
def prune(self, today: date | datetime | None = None) -> list[str]:
|
||||||
|
with self._lock:
|
||||||
|
return self._prune_locked(today or date.today())
|
||||||
|
|
||||||
|
def save(
|
||||||
|
self,
|
||||||
|
filename: str,
|
||||||
|
data: bytes,
|
||||||
|
today: date | datetime | None = None,
|
||||||
|
) -> tuple[Path, list[str], bool]:
|
||||||
|
if RosterPeriod.from_output_filename(filename) is None:
|
||||||
|
raise ValueError("Refusing to store an unexpected roster filename")
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
temporary_path: Path | None = None
|
||||||
|
try:
|
||||||
|
with NamedTemporaryFile(
|
||||||
|
mode="wb",
|
||||||
|
prefix=".roster-",
|
||||||
|
suffix=".tmp",
|
||||||
|
dir=self.root,
|
||||||
|
delete=False,
|
||||||
|
) as temporary:
|
||||||
|
temporary.write(data)
|
||||||
|
temporary.flush()
|
||||||
|
os.fsync(temporary.fileno())
|
||||||
|
temporary_path = Path(temporary.name)
|
||||||
|
|
||||||
|
final_path = self.root / filename
|
||||||
|
os.replace(temporary_path, final_path)
|
||||||
|
temporary_path = None
|
||||||
|
removed = self._prune_locked(today or date.today())
|
||||||
|
return final_path, removed, final_path.exists()
|
||||||
|
finally:
|
||||||
|
if temporary_path is not None:
|
||||||
|
temporary_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
def list_items(self) -> list[dict[str, object]]:
|
||||||
|
with self._lock:
|
||||||
|
items: list[tuple[RosterPeriod, dict[str, object]]] = []
|
||||||
|
for path in self.root.iterdir():
|
||||||
|
try:
|
||||||
|
path_stat = path.stat(follow_symlinks=False)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if not stat_module.S_ISREG(path_stat.st_mode):
|
||||||
|
continue
|
||||||
|
period = RosterPeriod.from_output_filename(path.name)
|
||||||
|
if period is None:
|
||||||
|
continue
|
||||||
|
items.append(
|
||||||
|
(
|
||||||
|
period,
|
||||||
|
{
|
||||||
|
"filename": path.name,
|
||||||
|
"label": period.label,
|
||||||
|
"url": f"/downloads/{quote(path.name)}",
|
||||||
|
"size": path_stat.st_size,
|
||||||
|
"modified": datetime.fromtimestamp(
|
||||||
|
path_stat.st_mtime,
|
||||||
|
tz=timezone.utc,
|
||||||
|
).isoformat(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
items.sort(key=lambda item: item[0], reverse=True)
|
||||||
|
return [item for _, item in items]
|
||||||
|
|
||||||
|
def verify_writable(self) -> None:
|
||||||
|
"""Exercise both file creation and deletion for the health check."""
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
with NamedTemporaryFile(
|
||||||
|
mode="wb",
|
||||||
|
prefix=".roster-health-",
|
||||||
|
suffix=".tmp",
|
||||||
|
dir=self.root,
|
||||||
|
) as probe:
|
||||||
|
probe.write(b"ok")
|
||||||
|
probe.flush()
|
||||||
|
|
||||||
|
def read(self, filename: str) -> bytes | None:
|
||||||
|
if RosterPeriod.from_output_filename(filename) is None:
|
||||||
|
return None
|
||||||
|
with self._lock:
|
||||||
|
return self._read_regular_file(self.root / filename)
|
||||||
|
|
||||||
|
|
||||||
|
class RosterWebApp:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
output_dir: Path,
|
||||||
|
retention_months: int = DEFAULT_RETENTION_MONTHS,
|
||||||
|
max_upload_bytes: int = DEFAULT_MAX_UPLOAD_MB * 1024 * 1024,
|
||||||
|
allowed_hosts: set[str] | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.web_dir = BASE_DIR / "web"
|
||||||
|
self.logo_path = BASE_DIR / "assets" / "logo.png"
|
||||||
|
self.store = OutputStore(output_dir, retention_months)
|
||||||
|
self.max_upload_bytes = max_upload_bytes
|
||||||
|
self.max_request_bytes = max_upload_bytes + 1024 * 1024
|
||||||
|
self.allowed_hosts = DEFAULT_ALLOWED_HOSTS | frozenset(
|
||||||
|
host.lower().rstrip(".") for host in (allowed_hosts or set())
|
||||||
|
)
|
||||||
|
# PyMuPDF does not support concurrent rendering in multiple threads.
|
||||||
|
self._format_lock = Lock()
|
||||||
|
|
||||||
|
def index_html(self) -> bytes:
|
||||||
|
template = (self.web_dir / "index.html").read_text(encoding="utf-8")
|
||||||
|
saved_layout = self.store.load_layout_defaults()
|
||||||
|
replacements = {
|
||||||
|
"__DEFAULT_MONTH__": RosterPeriod.suggested().html_value,
|
||||||
|
"__RETENTION_MONTHS__": str(self.store.retention_months),
|
||||||
|
"__MAX_UPLOAD_MB__": str(self.max_upload_bytes // (1024 * 1024)),
|
||||||
|
"__CONTENT_SHIFT__": str(saved_layout.content_shift),
|
||||||
|
"__BOTTOM_MARGIN__": str(saved_layout.bottom_margin),
|
||||||
|
"__LOGO_X__": str(saved_layout.logo_x),
|
||||||
|
"__LOGO_Y__": str(saved_layout.logo_y),
|
||||||
|
"__LOGO_SCALE__": str(saved_layout.logo_scale),
|
||||||
|
"__DATE_X__": str(saved_layout.date_x),
|
||||||
|
"__DATE_Y__": str(saved_layout.date_y),
|
||||||
|
"__DATE_FONT_SIZE__": str(saved_layout.date_font_size),
|
||||||
|
"__FACTORY_CONTENT_SHIFT__": str(DEFAULT_LAYOUT.content_shift),
|
||||||
|
"__FACTORY_BOTTOM_MARGIN__": str(DEFAULT_LAYOUT.bottom_margin),
|
||||||
|
"__FACTORY_LOGO_X__": str(DEFAULT_LAYOUT.logo_x),
|
||||||
|
"__FACTORY_LOGO_Y__": str(DEFAULT_LAYOUT.logo_y),
|
||||||
|
"__FACTORY_LOGO_SCALE__": str(DEFAULT_LAYOUT.logo_scale),
|
||||||
|
"__FACTORY_DATE_X__": str(DEFAULT_LAYOUT.date_x),
|
||||||
|
"__FACTORY_DATE_Y__": str(DEFAULT_LAYOUT.date_y),
|
||||||
|
"__FACTORY_DATE_FONT_SIZE__": str(DEFAULT_LAYOUT.date_font_size),
|
||||||
|
}
|
||||||
|
rendered = template
|
||||||
|
for placeholder, value in replacements.items():
|
||||||
|
rendered = rendered.replace(placeholder, value)
|
||||||
|
return rendered.encode("utf-8")
|
||||||
|
|
||||||
|
def format_upload(
|
||||||
|
self,
|
||||||
|
upload: UploadedFile,
|
||||||
|
values: Mapping[str, str],
|
||||||
|
) -> tuple[bytes, str, bool, list[str]]:
|
||||||
|
if not upload.filename:
|
||||||
|
raise RosterFormatError("Choose a roster PDF to upload.")
|
||||||
|
if Path(upload.filename).suffix.lower() != ".pdf":
|
||||||
|
raise RosterFormatError("Choose a file whose name ends in .pdf.")
|
||||||
|
if not upload.data:
|
||||||
|
raise RosterFormatError("The uploaded PDF is empty.")
|
||||||
|
if len(upload.data) > self.max_upload_bytes:
|
||||||
|
maximum = self.max_upload_bytes // (1024 * 1024)
|
||||||
|
raise RequestError(
|
||||||
|
HTTPStatus.REQUEST_ENTITY_TOO_LARGE,
|
||||||
|
f"The PDF must be {maximum} MB or smaller.",
|
||||||
|
)
|
||||||
|
|
||||||
|
period = RosterPeriod.parse(values.get("month"))
|
||||||
|
settings = LayoutSettings.from_mapping(
|
||||||
|
values,
|
||||||
|
defaults=self.store.load_layout_defaults(),
|
||||||
|
)
|
||||||
|
|
||||||
|
with self._format_lock:
|
||||||
|
with TemporaryDirectory(prefix="roster-web-") as temp_dir:
|
||||||
|
work_dir = Path(temp_dir)
|
||||||
|
input_path = work_dir / "upload.pdf"
|
||||||
|
output_path = work_dir / period.output_filename
|
||||||
|
input_path.write_bytes(upload.data)
|
||||||
|
format_roster(input_path, output_path, period, settings)
|
||||||
|
png = output_path.read_bytes()
|
||||||
|
|
||||||
|
_, removed, saved = self.store.save(period.output_filename, png)
|
||||||
|
return png, period.output_filename, saved, removed
|
||||||
|
|
||||||
|
def save_layout_defaults(self, values: Mapping[str, str]) -> LayoutSettings:
|
||||||
|
expected = set(DEFAULT_LAYOUT.as_form_values())
|
||||||
|
if set(values) != expected:
|
||||||
|
raise RosterFormatError("Submit every layout setting before saving defaults.")
|
||||||
|
if any(not values[name].strip() for name in expected):
|
||||||
|
raise RosterFormatError("Layout defaults cannot contain blank values.")
|
||||||
|
settings = LayoutSettings.from_mapping(values)
|
||||||
|
self.store.save_layout_defaults(settings)
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
def parse_multipart(content_type: str, body: bytes) -> tuple[dict[str, str], UploadedFile]:
|
||||||
|
if "\r" in content_type or "\n" in content_type:
|
||||||
|
raise RequestError(HTTPStatus.BAD_REQUEST, "Invalid upload content type.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
header = (
|
||||||
|
f"Content-Type: {content_type}\r\nMIME-Version: 1.0\r\n\r\n"
|
||||||
|
).encode("ascii", errors="strict")
|
||||||
|
except UnicodeEncodeError as exc:
|
||||||
|
raise RequestError(HTTPStatus.BAD_REQUEST, "Invalid upload content type.") from exc
|
||||||
|
message = BytesParser(policy=policy.default).parsebytes(header + body)
|
||||||
|
if not message.is_multipart():
|
||||||
|
raise RequestError(
|
||||||
|
HTTPStatus.BAD_REQUEST,
|
||||||
|
"The upload must use multipart form data.",
|
||||||
|
)
|
||||||
|
|
||||||
|
values: dict[str, str] = {}
|
||||||
|
uploads: list[UploadedFile] = []
|
||||||
|
for part in message.iter_parts():
|
||||||
|
if part.get_content_disposition() != "form-data":
|
||||||
|
continue
|
||||||
|
name = part.get_param("name", header="content-disposition")
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
payload = part.get_payload(decode=True)
|
||||||
|
if payload is None:
|
||||||
|
payload = b""
|
||||||
|
filename = part.get_filename()
|
||||||
|
if filename is not None:
|
||||||
|
if name == "roster":
|
||||||
|
uploads.append(
|
||||||
|
UploadedFile(
|
||||||
|
filename=filename,
|
||||||
|
content_type=part.get_content_type(),
|
||||||
|
data=payload,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if len(payload) > 4096:
|
||||||
|
raise RequestError(HTTPStatus.BAD_REQUEST, "A form value is too long.")
|
||||||
|
charset = part.get_content_charset() or "utf-8"
|
||||||
|
try:
|
||||||
|
values[name] = payload.decode(charset)
|
||||||
|
except (LookupError, UnicodeDecodeError) as exc:
|
||||||
|
raise RequestError(HTTPStatus.BAD_REQUEST, "Invalid form text encoding.") from exc
|
||||||
|
|
||||||
|
if len(uploads) != 1:
|
||||||
|
raise RequestError(
|
||||||
|
HTTPStatus.BAD_REQUEST,
|
||||||
|
"Upload exactly one roster PDF.",
|
||||||
|
)
|
||||||
|
return values, uploads[0]
|
||||||
|
|
||||||
|
|
||||||
|
class RosterHTTPServer(ThreadingHTTPServer):
|
||||||
|
allow_reuse_address = True
|
||||||
|
daemon_threads = True
|
||||||
|
|
||||||
|
def __init__(self, address: tuple[str, int], app: RosterWebApp) -> None:
|
||||||
|
self.web_app = app
|
||||||
|
super().__init__(address, RosterRequestHandler)
|
||||||
|
|
||||||
|
|
||||||
|
class RosterRequestHandler(BaseHTTPRequestHandler):
|
||||||
|
protocol_version = "HTTP/1.1"
|
||||||
|
server_version = "RosterServer/1.0"
|
||||||
|
sys_version = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def app(self) -> RosterWebApp:
|
||||||
|
return self.server.web_app # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
def setup(self) -> None:
|
||||||
|
super().setup()
|
||||||
|
self.connection.settimeout(30)
|
||||||
|
|
||||||
|
def end_headers(self) -> None:
|
||||||
|
self.send_header("X-Content-Type-Options", "nosniff")
|
||||||
|
self.send_header("X-Frame-Options", "DENY")
|
||||||
|
self.send_header("Referrer-Policy", "no-referrer")
|
||||||
|
self.send_header(
|
||||||
|
"Content-Security-Policy",
|
||||||
|
"default-src 'self'; script-src 'self'; style-src 'self'; "
|
||||||
|
"img-src 'self' blob:; connect-src 'self'; base-uri 'none'; "
|
||||||
|
"form-action 'self'; frame-ancestors 'none'",
|
||||||
|
)
|
||||||
|
super().end_headers()
|
||||||
|
|
||||||
|
def _send_bytes(
|
||||||
|
self,
|
||||||
|
status: HTTPStatus,
|
||||||
|
data: bytes,
|
||||||
|
content_type: str,
|
||||||
|
headers: Mapping[str, str] | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", content_type)
|
||||||
|
self.send_header("Content-Length", str(len(data)))
|
||||||
|
if self.close_connection:
|
||||||
|
self.send_header("Connection", "close")
|
||||||
|
if headers:
|
||||||
|
for name, value in headers.items():
|
||||||
|
self.send_header(name, value)
|
||||||
|
self.end_headers()
|
||||||
|
if self.command != "HEAD":
|
||||||
|
self.wfile.write(data)
|
||||||
|
|
||||||
|
def _send_json(self, status: HTTPStatus, payload: Mapping[str, object]) -> None:
|
||||||
|
data = json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
||||||
|
self._send_bytes(
|
||||||
|
status,
|
||||||
|
data,
|
||||||
|
"application/json; charset=utf-8",
|
||||||
|
{"Cache-Control": "no-store"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _same_origin(self) -> bool:
|
||||||
|
origin = self.headers.get("Origin")
|
||||||
|
if not origin:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
parsed = urlsplit(origin)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return (
|
||||||
|
parsed.scheme in {"http", "https"}
|
||||||
|
and parsed.netloc.lower() == self.headers.get("Host", "").lower()
|
||||||
|
)
|
||||||
|
|
||||||
|
def _host_allowed(self) -> bool:
|
||||||
|
raw_host = self.headers.get("Host", "")
|
||||||
|
if not raw_host or any(character in raw_host for character in ("@", "/", "\\", " ", "\t")):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
parsed = urlsplit(f"//{raw_host}")
|
||||||
|
host = parsed.hostname
|
||||||
|
parsed.port # Force validation of a supplied port.
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return bool(host and host.lower().rstrip(".") in self.app.allowed_hosts)
|
||||||
|
|
||||||
|
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
|
||||||
|
path = urlsplit(self.path).path
|
||||||
|
try:
|
||||||
|
if not self._host_allowed():
|
||||||
|
self._send_json(HTTPStatus.FORBIDDEN, {"error": "Host is not allowed."})
|
||||||
|
return
|
||||||
|
if path == "/":
|
||||||
|
self._send_bytes(
|
||||||
|
HTTPStatus.OK,
|
||||||
|
self.app.index_html(),
|
||||||
|
"text/html; charset=utf-8",
|
||||||
|
{"Cache-Control": "no-store"},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if path == "/static/styles.css":
|
||||||
|
self._send_bytes(
|
||||||
|
HTTPStatus.OK,
|
||||||
|
(self.app.web_dir / "styles.css").read_bytes(),
|
||||||
|
"text/css; charset=utf-8",
|
||||||
|
{"Cache-Control": "no-store"},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if path == "/static/app.js":
|
||||||
|
self._send_bytes(
|
||||||
|
HTTPStatus.OK,
|
||||||
|
(self.app.web_dir / "app.js").read_bytes(),
|
||||||
|
"text/javascript; charset=utf-8",
|
||||||
|
{"Cache-Control": "no-store"},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if path == "/static/theme.js":
|
||||||
|
self._send_bytes(
|
||||||
|
HTTPStatus.OK,
|
||||||
|
(self.app.web_dir / "theme.js").read_bytes(),
|
||||||
|
"text/javascript; charset=utf-8",
|
||||||
|
{"Cache-Control": "no-store"},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if path == "/assets/logo.png":
|
||||||
|
self._send_bytes(
|
||||||
|
HTTPStatus.OK,
|
||||||
|
self.app.logo_path.read_bytes(),
|
||||||
|
"image/png",
|
||||||
|
{"Cache-Control": "no-store"},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if path == "/health":
|
||||||
|
try:
|
||||||
|
self.app.store.verify_writable()
|
||||||
|
except OSError:
|
||||||
|
self._send_json(
|
||||||
|
HTTPStatus.SERVICE_UNAVAILABLE,
|
||||||
|
{"status": "error", "error": "Output directory is not writable."},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
self._send_json(HTTPStatus.OK, {"status": "ok"})
|
||||||
|
return
|
||||||
|
if path == "/api/history":
|
||||||
|
self._send_json(
|
||||||
|
HTTPStatus.OK,
|
||||||
|
{
|
||||||
|
"items": self.app.store.list_items(),
|
||||||
|
"retention_months": self.app.store.retention_months,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if path.startswith("/downloads/"):
|
||||||
|
filename = unquote(path.removeprefix("/downloads/"))
|
||||||
|
data = self.app.store.read(filename)
|
||||||
|
if data is None:
|
||||||
|
self._send_json(HTTPStatus.NOT_FOUND, {"error": "Roster not found."})
|
||||||
|
return
|
||||||
|
self._send_bytes(
|
||||||
|
HTTPStatus.OK,
|
||||||
|
data,
|
||||||
|
"image/png",
|
||||||
|
{
|
||||||
|
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
self._send_json(HTTPStatus.NOT_FOUND, {"error": "Not found."})
|
||||||
|
except (BrokenPipeError, ConnectionResetError):
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
LOGGER.exception("GET %s failed", path)
|
||||||
|
self._send_json(HTTPStatus.INTERNAL_SERVER_ERROR, {"error": "Server error."})
|
||||||
|
|
||||||
|
def do_POST(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
|
||||||
|
# Never reuse a connection after a POST. Some validation happens before
|
||||||
|
# the request body is read, so closing prevents leftover bytes from being
|
||||||
|
# interpreted as a second, Origin-less request.
|
||||||
|
self.close_connection = True
|
||||||
|
path = urlsplit(self.path).path
|
||||||
|
try:
|
||||||
|
if not self._host_allowed():
|
||||||
|
raise RequestError(HTTPStatus.FORBIDDEN, "Host is not allowed.")
|
||||||
|
if path not in {"/format", "/api/layout-defaults"}:
|
||||||
|
raise RequestError(HTTPStatus.NOT_FOUND, "Not found.")
|
||||||
|
if not self._same_origin():
|
||||||
|
raise RequestError(HTTPStatus.FORBIDDEN, "Cross-origin requests are not allowed.")
|
||||||
|
if self.headers.get("Transfer-Encoding") is not None:
|
||||||
|
raise RequestError(
|
||||||
|
HTTPStatus.BAD_REQUEST,
|
||||||
|
"Transfer-encoded requests are not supported.",
|
||||||
|
)
|
||||||
|
|
||||||
|
raw_length = self.headers.get("Content-Length")
|
||||||
|
if raw_length is None:
|
||||||
|
raise RequestError(HTTPStatus.LENGTH_REQUIRED, "Request size is required.")
|
||||||
|
try:
|
||||||
|
content_length = int(raw_length)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise RequestError(HTTPStatus.BAD_REQUEST, "Invalid upload size.") from exc
|
||||||
|
if content_length <= 0:
|
||||||
|
raise RequestError(HTTPStatus.BAD_REQUEST, "The request is empty.")
|
||||||
|
|
||||||
|
content_type = self.headers.get("Content-Type", "")
|
||||||
|
if path == "/api/layout-defaults":
|
||||||
|
if content_length > MAX_DEFAULTS_REQUEST_BYTES:
|
||||||
|
raise RequestError(
|
||||||
|
HTTPStatus.REQUEST_ENTITY_TOO_LARGE,
|
||||||
|
"The saved defaults request is too large.",
|
||||||
|
)
|
||||||
|
if content_type.split(";", 1)[0].strip().lower() != "application/json":
|
||||||
|
raise RequestError(
|
||||||
|
HTTPStatus.BAD_REQUEST,
|
||||||
|
"Saved defaults must use JSON.",
|
||||||
|
)
|
||||||
|
body = self.rfile.read(content_length)
|
||||||
|
if len(body) != content_length:
|
||||||
|
raise RequestError(HTTPStatus.BAD_REQUEST, "The request was incomplete.")
|
||||||
|
try:
|
||||||
|
payload = json.loads(body.decode("utf-8"))
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||||
|
raise RequestError(
|
||||||
|
HTTPStatus.BAD_REQUEST,
|
||||||
|
"Saved defaults must contain valid JSON.",
|
||||||
|
) from exc
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise RequestError(
|
||||||
|
HTTPStatus.BAD_REQUEST,
|
||||||
|
"Saved defaults must be a JSON object.",
|
||||||
|
)
|
||||||
|
values = {str(name): str(value) for name, value in payload.items()}
|
||||||
|
settings = self.app.save_layout_defaults(values)
|
||||||
|
self._send_json(
|
||||||
|
HTTPStatus.OK,
|
||||||
|
{
|
||||||
|
"status": "saved",
|
||||||
|
"defaults": settings.as_form_values(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if content_length > self.app.max_request_bytes:
|
||||||
|
raise RequestError(
|
||||||
|
HTTPStatus.REQUEST_ENTITY_TOO_LARGE,
|
||||||
|
"The upload is too large.",
|
||||||
|
)
|
||||||
|
if not content_type.lower().startswith("multipart/form-data"):
|
||||||
|
raise RequestError(
|
||||||
|
HTTPStatus.BAD_REQUEST,
|
||||||
|
"The upload must use multipart form data.",
|
||||||
|
)
|
||||||
|
body = self.rfile.read(content_length)
|
||||||
|
if len(body) != content_length:
|
||||||
|
raise RequestError(HTTPStatus.BAD_REQUEST, "The upload was incomplete.")
|
||||||
|
|
||||||
|
values, upload = parse_multipart(content_type, body)
|
||||||
|
png, filename, saved, removed = self.app.format_upload(upload, values)
|
||||||
|
if removed:
|
||||||
|
LOGGER.info("Retention removed: %s", ", ".join(removed))
|
||||||
|
self._send_bytes(
|
||||||
|
HTTPStatus.OK,
|
||||||
|
png,
|
||||||
|
"image/png",
|
||||||
|
{
|
||||||
|
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
"X-Roster-Saved": "true" if saved else "false",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except RequestError as exc:
|
||||||
|
self._send_json(exc.status, {"error": exc.message})
|
||||||
|
except RosterFormatError as exc:
|
||||||
|
self._send_json(HTTPStatus.UNPROCESSABLE_ENTITY, {"error": str(exc)})
|
||||||
|
except (BrokenPipeError, ConnectionResetError):
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
LOGGER.exception("POST %s failed", path)
|
||||||
|
message = (
|
||||||
|
"The layout defaults could not be saved. Try again."
|
||||||
|
if path == "/api/layout-defaults"
|
||||||
|
else "The roster could not be formatted. Try again."
|
||||||
|
)
|
||||||
|
self._send_json(
|
||||||
|
HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
{"error": message},
|
||||||
|
)
|
||||||
|
|
||||||
|
def log_message(self, format: str, *args: object) -> None:
|
||||||
|
LOGGER.info("%s - %s", self.address_string(), format % args)
|
||||||
|
|
||||||
|
|
||||||
|
def _environment_int(name: str, fallback: int) -> int:
|
||||||
|
raw = os.environ.get(name)
|
||||||
|
if raw is None:
|
||||||
|
return fallback
|
||||||
|
try:
|
||||||
|
return int(raw)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise SystemExit(f"{name} must be a whole number") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _retention_maintenance(store: OutputStore, stop: Event) -> None:
|
||||||
|
while not stop.wait(RETENTION_CHECK_SECONDS):
|
||||||
|
try:
|
||||||
|
removed = store.prune()
|
||||||
|
if removed:
|
||||||
|
LOGGER.info("Scheduled retention removed: %s", ", ".join(removed))
|
||||||
|
except OSError:
|
||||||
|
LOGGER.exception("Scheduled retention cleanup failed")
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(description="Run the local roster web app")
|
||||||
|
parser.add_argument("--host", default=os.environ.get("ROSTER_HOST", DEFAULT_HOST))
|
||||||
|
parser.add_argument(
|
||||||
|
"--port",
|
||||||
|
type=int,
|
||||||
|
default=_environment_int("ROSTER_PORT", DEFAULT_PORT),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output-dir",
|
||||||
|
type=Path,
|
||||||
|
default=Path(os.environ.get("ROSTER_OUTPUT_DIR", BASE_DIR / "output")),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--retention-months",
|
||||||
|
type=int,
|
||||||
|
default=_environment_int("ROSTER_RETENTION_MONTHS", DEFAULT_RETENTION_MONTHS),
|
||||||
|
help="delete roster PNGs more than this many calendar months old; 0 disables cleanup",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--max-upload-mb",
|
||||||
|
type=int,
|
||||||
|
default=_environment_int("ROSTER_MAX_UPLOAD_MB", DEFAULT_MAX_UPLOAD_MB),
|
||||||
|
)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = build_parser().parse_args()
|
||||||
|
if not 1 <= args.port <= 65535:
|
||||||
|
raise SystemExit("port must be between 1 and 65535")
|
||||||
|
if args.retention_months < 0:
|
||||||
|
raise SystemExit("retention months cannot be negative")
|
||||||
|
if not 1 <= args.max_upload_mb <= 100:
|
||||||
|
raise SystemExit("max upload size must be between 1 and 100 MB")
|
||||||
|
|
||||||
|
allowed_hosts = {
|
||||||
|
host.strip().lower().rstrip(".")
|
||||||
|
for host in os.environ.get("ROSTER_ALLOWED_HOSTS", "").split(",")
|
||||||
|
if host.strip()
|
||||||
|
}
|
||||||
|
if args.host not in {"0.0.0.0", "::"}:
|
||||||
|
allowed_hosts.add(args.host.lower().rstrip("."))
|
||||||
|
|
||||||
|
app = RosterWebApp(
|
||||||
|
output_dir=args.output_dir,
|
||||||
|
retention_months=args.retention_months,
|
||||||
|
max_upload_bytes=args.max_upload_mb * 1024 * 1024,
|
||||||
|
allowed_hosts=allowed_hosts,
|
||||||
|
)
|
||||||
|
removed = app.store.prune()
|
||||||
|
if removed:
|
||||||
|
LOGGER.info("Startup retention removed: %s", ", ".join(removed))
|
||||||
|
|
||||||
|
server = RosterHTTPServer((args.host, args.port), app)
|
||||||
|
maintenance_stop = Event()
|
||||||
|
maintenance_thread: Thread | None = None
|
||||||
|
if args.retention_months > 0:
|
||||||
|
maintenance_thread = Thread(
|
||||||
|
target=_retention_maintenance,
|
||||||
|
args=(app.store, maintenance_stop),
|
||||||
|
name="roster-retention",
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
maintenance_thread.start()
|
||||||
|
LOGGER.info("Roster formatter available at http://%s:%s", args.host, args.port)
|
||||||
|
try:
|
||||||
|
server.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
LOGGER.info("Stopping roster formatter")
|
||||||
|
finally:
|
||||||
|
maintenance_stop.set()
|
||||||
|
if maintenance_thread is not None:
|
||||||
|
maintenance_thread.join(timeout=2)
|
||||||
|
server.server_close()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Roster formatter regression tests."""
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import fitz # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
def make_geometry_pdf(path: Path) -> Path:
|
||||||
|
"""Create the same non-empty geometry fixture used for the legacy golden."""
|
||||||
|
|
||||||
|
doc = fitz.open()
|
||||||
|
page = doc.new_page(width=792, height=612)
|
||||||
|
page.draw_rect(page.rect, color=(1, 1, 1), fill=(1, 1, 1), width=0)
|
||||||
|
page.draw_rect(
|
||||||
|
fitz.Rect(0, 0, 96, 48),
|
||||||
|
color=(1, 0, 0),
|
||||||
|
fill=(1, 0, 0),
|
||||||
|
width=0,
|
||||||
|
)
|
||||||
|
page.draw_rect(
|
||||||
|
fitz.Rect(696, 564, 792, 612),
|
||||||
|
color=(0, 0, 1),
|
||||||
|
fill=(0, 0, 1),
|
||||||
|
width=0,
|
||||||
|
)
|
||||||
|
page.draw_line(
|
||||||
|
fitz.Point(0, 306),
|
||||||
|
fitz.Point(792, 306),
|
||||||
|
color=(0, 0, 0),
|
||||||
|
width=2,
|
||||||
|
)
|
||||||
|
page.insert_text(
|
||||||
|
(310, 320),
|
||||||
|
"GEOMETRY FIXTURE",
|
||||||
|
fontsize=20,
|
||||||
|
color=(0, 0, 0),
|
||||||
|
)
|
||||||
|
doc.save(path)
|
||||||
|
doc.close()
|
||||||
|
return path
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
from datetime import date
|
||||||
|
from hashlib import sha256
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import fitz # type: ignore
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from roster_formatter import (
|
||||||
|
DEFAULT_LAYOUT,
|
||||||
|
PAGE_HEIGHT,
|
||||||
|
PAGE_WIDTH,
|
||||||
|
PNG_DPI,
|
||||||
|
PNG_HEIGHT,
|
||||||
|
PNG_WIDTH,
|
||||||
|
LayoutSettings,
|
||||||
|
RosterFormatError,
|
||||||
|
RosterPeriod,
|
||||||
|
format_roster,
|
||||||
|
validate_input_pdf,
|
||||||
|
)
|
||||||
|
from tests.helpers import make_geometry_pdf
|
||||||
|
|
||||||
|
|
||||||
|
LEGACY_GEOMETRY_SHA256 = "8b000127605f4fd78f9f39606de955eb02392ca113e8ddd808421d4f04c09c31"
|
||||||
|
|
||||||
|
|
||||||
|
class RosterPeriodTests(unittest.TestCase):
|
||||||
|
def test_original_month_abbreviation_rules_are_preserved(self) -> None:
|
||||||
|
expected = {
|
||||||
|
1: "JAN 2026",
|
||||||
|
2: "FEB 2026",
|
||||||
|
3: "MARCH 2026",
|
||||||
|
4: "APRIL 2026",
|
||||||
|
5: "MAY 2026",
|
||||||
|
6: "JUNE 2026",
|
||||||
|
7: "JULY 2026",
|
||||||
|
8: "AUG 2026",
|
||||||
|
9: "SEP 2026",
|
||||||
|
10: "OCT 2026",
|
||||||
|
11: "NOV 2026",
|
||||||
|
12: "DEC 2026",
|
||||||
|
}
|
||||||
|
self.assertEqual(
|
||||||
|
expected,
|
||||||
|
{month: RosterPeriod(2026, month).label for month in range(1, 13)},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_suggested_period_changes_on_the_fifteenth(self) -> None:
|
||||||
|
self.assertEqual(RosterPeriod(2026, 7), RosterPeriod.suggested(date(2026, 7, 14)))
|
||||||
|
self.assertEqual(RosterPeriod(2026, 8), RosterPeriod.suggested(date(2026, 7, 15)))
|
||||||
|
self.assertEqual(RosterPeriod(2027, 1), RosterPeriod.suggested(date(2026, 12, 31)))
|
||||||
|
|
||||||
|
def test_parse_accepts_browser_and_cli_formats(self) -> None:
|
||||||
|
self.assertEqual(RosterPeriod(2026, 9), RosterPeriod.parse("2026-09"))
|
||||||
|
self.assertEqual(RosterPeriod(2026, 9), RosterPeriod.parse("September 2026"))
|
||||||
|
self.assertEqual(RosterPeriod(2026, 9), RosterPeriod.parse("SEP 2026"))
|
||||||
|
|
||||||
|
def test_parse_rejects_untrusted_filename_text(self) -> None:
|
||||||
|
with self.assertRaises(RosterFormatError):
|
||||||
|
RosterPeriod.parse("../../September 2026")
|
||||||
|
|
||||||
|
|
||||||
|
class LayoutRegressionTests(unittest.TestCase):
|
||||||
|
def test_production_defaults_are_explicit(self) -> None:
|
||||||
|
self.assertEqual(792, PAGE_WIDTH)
|
||||||
|
self.assertEqual(612, PAGE_HEIGHT)
|
||||||
|
self.assertEqual(150, PNG_DPI)
|
||||||
|
self.assertEqual(1650, PNG_WIDTH)
|
||||||
|
self.assertEqual(1275, PNG_HEIGHT)
|
||||||
|
self.assertEqual(
|
||||||
|
LayoutSettings(
|
||||||
|
content_shift=100,
|
||||||
|
bottom_margin=60,
|
||||||
|
logo_x=50,
|
||||||
|
logo_y=-10,
|
||||||
|
logo_scale=0.27,
|
||||||
|
date_x=460,
|
||||||
|
date_y=88,
|
||||||
|
date_font_size=48,
|
||||||
|
),
|
||||||
|
DEFAULT_LAYOUT,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_legacy_default_is_byte_identical(self) -> None:
|
||||||
|
with TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
source = make_geometry_pdf(root / "source.pdf")
|
||||||
|
output = root / "output.png"
|
||||||
|
format_roster(source, output, RosterPeriod(2026, 7))
|
||||||
|
|
||||||
|
self.assertEqual(LEGACY_GEOMETRY_SHA256, sha256(output.read_bytes()).hexdigest())
|
||||||
|
with Image.open(output) as image:
|
||||||
|
self.assertEqual((PNG_WIDTH, PNG_HEIGHT), image.size)
|
||||||
|
self.assertEqual("RGB", image.mode)
|
||||||
|
|
||||||
|
def test_explicit_adjustment_changes_pixels_but_not_canvas(self) -> None:
|
||||||
|
with TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
source = make_geometry_pdf(root / "source.pdf")
|
||||||
|
default_output = root / "default.png"
|
||||||
|
adjusted_output = root / "adjusted.png"
|
||||||
|
format_roster(source, default_output, RosterPeriod(2026, 7))
|
||||||
|
format_roster(
|
||||||
|
source,
|
||||||
|
adjusted_output,
|
||||||
|
RosterPeriod(2026, 7),
|
||||||
|
LayoutSettings(logo_x=55),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertNotEqual(default_output.read_bytes(), adjusted_output.read_bytes())
|
||||||
|
with Image.open(adjusted_output) as image:
|
||||||
|
self.assertEqual((PNG_WIDTH, PNG_HEIGHT), image.size)
|
||||||
|
|
||||||
|
def test_input_geometry_is_validated(self) -> None:
|
||||||
|
with TemporaryDirectory() as temp_dir:
|
||||||
|
source = Path(temp_dir) / "portrait.pdf"
|
||||||
|
doc = fitz.open()
|
||||||
|
page = doc.new_page(width=612, height=792)
|
||||||
|
page.insert_text((20, 20), "not landscape")
|
||||||
|
doc.save(source)
|
||||||
|
doc.close()
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(RosterFormatError, "landscape"):
|
||||||
|
validate_input_pdf(source)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
from datetime import date, datetime
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import socket
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
from threading import Thread
|
||||||
|
import unittest
|
||||||
|
from urllib.error import HTTPError
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
from roster_formatter import DEFAULT_LAYOUT, LayoutSettings, RosterPeriod, format_roster
|
||||||
|
from server import OutputStore, RosterHTTPServer, RosterWebApp
|
||||||
|
from tests.helpers import make_geometry_pdf
|
||||||
|
|
||||||
|
|
||||||
|
def multipart_body(fields: dict[str, str], filename: str, file_data: bytes) -> tuple[str, bytes]:
|
||||||
|
boundary = "----RosterRegressionBoundary"
|
||||||
|
chunks: list[bytes] = []
|
||||||
|
for name, value in fields.items():
|
||||||
|
chunks.extend(
|
||||||
|
[
|
||||||
|
f"--{boundary}\r\n".encode(),
|
||||||
|
f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode(),
|
||||||
|
value.encode(),
|
||||||
|
b"\r\n",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
chunks.extend(
|
||||||
|
[
|
||||||
|
f"--{boundary}\r\n".encode(),
|
||||||
|
(
|
||||||
|
'Content-Disposition: form-data; name="roster"; '
|
||||||
|
f'filename="{filename}"\r\n'
|
||||||
|
).encode(),
|
||||||
|
b"Content-Type: application/pdf\r\n\r\n",
|
||||||
|
file_data,
|
||||||
|
b"\r\n",
|
||||||
|
f"--{boundary}--\r\n".encode(),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return f"multipart/form-data; boundary={boundary}", b"".join(chunks)
|
||||||
|
|
||||||
|
|
||||||
|
class OutputStoreTests(unittest.TestCase):
|
||||||
|
def test_layout_defaults_persist_across_store_restart_and_prune(self) -> None:
|
||||||
|
with TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
settings = LayoutSettings(
|
||||||
|
content_shift=112,
|
||||||
|
bottom_margin=64,
|
||||||
|
logo_x=57,
|
||||||
|
logo_y=-8,
|
||||||
|
logo_scale=0.31,
|
||||||
|
date_x=472,
|
||||||
|
date_y=91,
|
||||||
|
date_font_size=52,
|
||||||
|
)
|
||||||
|
store = OutputStore(root, retention_months=3)
|
||||||
|
|
||||||
|
store.save_layout_defaults(settings)
|
||||||
|
|
||||||
|
defaults_path = root / ".roster-layout-defaults.json"
|
||||||
|
self.assertTrue(defaults_path.is_file())
|
||||||
|
self.assertEqual(
|
||||||
|
{"version": 1, "layout": settings.as_json_values()},
|
||||||
|
json.loads(defaults_path.read_text(encoding="utf-8")),
|
||||||
|
)
|
||||||
|
restarted_store = OutputStore(root, retention_months=3)
|
||||||
|
self.assertEqual(settings, restarted_store.load_layout_defaults())
|
||||||
|
|
||||||
|
os.utime(defaults_path, (datetime(2020, 1, 1).timestamp(),) * 2)
|
||||||
|
self.assertEqual([], restarted_store.prune(date(2026, 7, 25)))
|
||||||
|
self.assertTrue(defaults_path.is_file())
|
||||||
|
self.assertEqual(settings, restarted_store.load_layout_defaults())
|
||||||
|
|
||||||
|
def test_invalid_layout_defaults_fall_back_to_factory_values(self) -> None:
|
||||||
|
with TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
store = OutputStore(root, retention_months=3)
|
||||||
|
store.layout_defaults_path.write_text("{not valid json", encoding="utf-8")
|
||||||
|
|
||||||
|
with self.assertLogs("roster.web", level="WARNING"):
|
||||||
|
loaded = store.load_layout_defaults()
|
||||||
|
|
||||||
|
self.assertEqual(DEFAULT_LAYOUT, loaded)
|
||||||
|
|
||||||
|
def test_retention_removes_only_matching_rosters_older_than_window(self) -> None:
|
||||||
|
with TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
for filename in (
|
||||||
|
"MARCH 2026 Roster.png",
|
||||||
|
"APRIL 2026 Roster.png",
|
||||||
|
"JULY 2026 Roster.png",
|
||||||
|
"JAN 2099 Roster.png",
|
||||||
|
"notes.png",
|
||||||
|
"shifted.pdf",
|
||||||
|
):
|
||||||
|
(root / filename).write_bytes(b"test")
|
||||||
|
os.utime(
|
||||||
|
root / "JAN 2099 Roster.png",
|
||||||
|
(datetime(2026, 3, 31, 12).timestamp(),) * 2,
|
||||||
|
)
|
||||||
|
|
||||||
|
store = OutputStore(root, retention_months=3)
|
||||||
|
removed = store.prune(date(2026, 7, 25))
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
["JAN 2099 Roster.png", "MARCH 2026 Roster.png"],
|
||||||
|
removed,
|
||||||
|
)
|
||||||
|
self.assertFalse((root / "MARCH 2026 Roster.png").exists())
|
||||||
|
self.assertTrue((root / "APRIL 2026 Roster.png").exists())
|
||||||
|
self.assertTrue((root / "notes.png").exists())
|
||||||
|
self.assertTrue((root / "shifted.pdf").exists())
|
||||||
|
|
||||||
|
def test_symlink_is_never_listed_read_or_pruned(self) -> None:
|
||||||
|
with TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
secret = root / "private.txt"
|
||||||
|
secret.write_bytes(b"private data")
|
||||||
|
link = root / "JAN 2020 Roster.png"
|
||||||
|
link.symlink_to(secret.name)
|
||||||
|
store = OutputStore(root, retention_months=3)
|
||||||
|
|
||||||
|
self.assertIsNone(store.read(link.name))
|
||||||
|
self.assertEqual([], store.list_items())
|
||||||
|
self.assertEqual([], store.prune(date(2026, 7, 25)))
|
||||||
|
self.assertTrue(link.is_symlink())
|
||||||
|
|
||||||
|
def test_zero_retention_disables_cleanup(self) -> None:
|
||||||
|
with TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
old = root / "JAN 2020 Roster.png"
|
||||||
|
old.write_bytes(b"test")
|
||||||
|
self.assertEqual([], OutputStore(root, retention_months=0).prune(date(2026, 7, 25)))
|
||||||
|
self.assertTrue(old.exists())
|
||||||
|
|
||||||
|
|
||||||
|
class WebServerTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.temporary = TemporaryDirectory()
|
||||||
|
self.root = Path(self.temporary.name)
|
||||||
|
self.output_dir = self.root / "output"
|
||||||
|
self.source = make_geometry_pdf(self.root / "source.pdf")
|
||||||
|
self.app = RosterWebApp(
|
||||||
|
self.output_dir,
|
||||||
|
retention_months=0,
|
||||||
|
allowed_hosts={"roster.example.test"},
|
||||||
|
)
|
||||||
|
self.server = RosterHTTPServer(("127.0.0.1", 0), self.app)
|
||||||
|
self.thread = Thread(target=self.server.serve_forever, daemon=True)
|
||||||
|
self.thread.start()
|
||||||
|
host, port = self.server.server_address
|
||||||
|
self.base_url = f"http://{host}:{port}"
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.server.shutdown()
|
||||||
|
self.server.server_close()
|
||||||
|
self.thread.join(timeout=5)
|
||||||
|
self.temporary.cleanup()
|
||||||
|
|
||||||
|
def test_health_and_index(self) -> None:
|
||||||
|
with urlopen(f"{self.base_url}/health", timeout=5) as response:
|
||||||
|
self.assertEqual({"status": "ok"}, json.loads(response.read()))
|
||||||
|
with urlopen(f"{self.base_url}/", timeout=5) as response:
|
||||||
|
html = response.read().decode()
|
||||||
|
self.assertIn("Roster Formatter", html)
|
||||||
|
self.assertNotIn("__DEFAULT_MONTH__", html)
|
||||||
|
self.assertNotIn("__LOGO_SCALE__", html)
|
||||||
|
self.assertIn('data-max-upload-mb="20"', html)
|
||||||
|
|
||||||
|
def test_layout_defaults_endpoint_persists_canonical_values_in_index(self) -> None:
|
||||||
|
submitted = DEFAULT_LAYOUT.as_form_values()
|
||||||
|
submitted.update(
|
||||||
|
{
|
||||||
|
"content_shift": "123",
|
||||||
|
"bottom_margin": "65.5",
|
||||||
|
"logo_x": "58",
|
||||||
|
"logo_y": "-7",
|
||||||
|
"logo_scale": "0.310",
|
||||||
|
"date_x": "475",
|
||||||
|
"date_y": "92",
|
||||||
|
"date_font_size": "53",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
expected = LayoutSettings.from_mapping(submitted).as_form_values()
|
||||||
|
request = Request(
|
||||||
|
f"{self.base_url}/api/layout-defaults",
|
||||||
|
data=json.dumps(submitted).encode("utf-8"),
|
||||||
|
method="POST",
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with urlopen(request, timeout=5) as response:
|
||||||
|
self.assertEqual(
|
||||||
|
{"status": "saved", "defaults": expected},
|
||||||
|
json.loads(response.read()),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue((self.output_dir / ".roster-layout-defaults.json").is_file())
|
||||||
|
restarted_store = OutputStore(self.output_dir, retention_months=0)
|
||||||
|
self.assertEqual(LayoutSettings.from_mapping(submitted), restarted_store.load_layout_defaults())
|
||||||
|
with urlopen(f"{self.base_url}/", timeout=5) as response:
|
||||||
|
html = response.read().decode()
|
||||||
|
self.assertIn(
|
||||||
|
'name="content_shift" type="number" value="123.0" '
|
||||||
|
'data-default="123.0" data-factory="100"',
|
||||||
|
html,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
'name="logo_scale" type="number" value="0.31" '
|
||||||
|
'data-default="0.31" data-factory="0.27"',
|
||||||
|
html,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
'name="date_font_size" type="number" value="53" '
|
||||||
|
'data-default="53" data-factory="48"',
|
||||||
|
html,
|
||||||
|
)
|
||||||
|
self.assertNotIn("__FACTORY_", html)
|
||||||
|
|
||||||
|
def test_layout_defaults_endpoint_requires_every_layout_field(self) -> None:
|
||||||
|
submitted = DEFAULT_LAYOUT.as_form_values()
|
||||||
|
submitted.pop("logo_scale")
|
||||||
|
request = Request(
|
||||||
|
f"{self.base_url}/api/layout-defaults",
|
||||||
|
data=json.dumps(submitted).encode("utf-8"),
|
||||||
|
method="POST",
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaises(HTTPError) as caught:
|
||||||
|
urlopen(request, timeout=5)
|
||||||
|
|
||||||
|
self.assertEqual(422, caught.exception.code)
|
||||||
|
self.assertEqual(
|
||||||
|
{"error": "Submit every layout setting before saving defaults."},
|
||||||
|
json.loads(caught.exception.read()),
|
||||||
|
)
|
||||||
|
self.assertFalse((self.output_dir / ".roster-layout-defaults.json").exists())
|
||||||
|
|
||||||
|
def test_theme_defaults_to_system_and_static_assets_expose_all_choices(self) -> None:
|
||||||
|
with urlopen(f"{self.base_url}/", timeout=5) as response:
|
||||||
|
html = response.read().decode()
|
||||||
|
self.assertIn('<html lang="en" data-theme="system">', html)
|
||||||
|
self.assertLess(html.index('/static/theme.js'), html.index('/static/styles.css'))
|
||||||
|
for choice in ("system", "light", "dark"):
|
||||||
|
self.assertIn(f'data-theme-choice="{choice}"', html)
|
||||||
|
|
||||||
|
with urlopen(f"{self.base_url}/static/theme.js", timeout=5) as response:
|
||||||
|
self.assertTrue(response.headers["Content-Type"].startswith("text/javascript"))
|
||||||
|
theme_script = response.read().decode()
|
||||||
|
self.assertIn('new Set(["system", "light", "dark"])', theme_script)
|
||||||
|
self.assertIn('localStorage.getItem("roster-theme")', theme_script)
|
||||||
|
self.assertIn("document.documentElement.dataset.theme = theme", theme_script)
|
||||||
|
|
||||||
|
with urlopen(f"{self.base_url}/static/styles.css", timeout=5) as response:
|
||||||
|
styles = response.read().decode()
|
||||||
|
self.assertIn(':root[data-theme="dark"]', styles)
|
||||||
|
self.assertIn("prefers-color-scheme: dark", styles)
|
||||||
|
|
||||||
|
def test_untrusted_host_is_rejected(self) -> None:
|
||||||
|
request = Request(
|
||||||
|
f"{self.base_url}/api/history",
|
||||||
|
headers={"Host": "attacker.example"},
|
||||||
|
)
|
||||||
|
with self.assertRaises(HTTPError) as caught:
|
||||||
|
urlopen(request, timeout=5)
|
||||||
|
self.assertEqual(403, caught.exception.code)
|
||||||
|
self.assertEqual(
|
||||||
|
{"error": "Host is not allowed."},
|
||||||
|
json.loads(caught.exception.read()),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_configured_caddy_host_is_allowed(self) -> None:
|
||||||
|
request = Request(
|
||||||
|
f"{self.base_url}/health",
|
||||||
|
headers={"Host": "roster.example.test"},
|
||||||
|
)
|
||||||
|
with urlopen(request, timeout=5) as response:
|
||||||
|
self.assertEqual({"status": "ok"}, json.loads(response.read()))
|
||||||
|
|
||||||
|
def test_web_output_is_byte_identical_to_shared_renderer(self) -> None:
|
||||||
|
direct = self.root / "direct.png"
|
||||||
|
format_roster(self.source, direct, RosterPeriod(2026, 7))
|
||||||
|
fields = DEFAULT_LAYOUT.as_form_values()
|
||||||
|
fields["month"] = "2026-07"
|
||||||
|
content_type, body = multipart_body(
|
||||||
|
fields,
|
||||||
|
"current-roster.pdf",
|
||||||
|
self.source.read_bytes(),
|
||||||
|
)
|
||||||
|
request = Request(
|
||||||
|
f"{self.base_url}/format",
|
||||||
|
data=body,
|
||||||
|
method="POST",
|
||||||
|
headers={"Content-Type": content_type},
|
||||||
|
)
|
||||||
|
with urlopen(request, timeout=15) as response:
|
||||||
|
web_png = response.read()
|
||||||
|
self.assertEqual("true", response.headers["X-Roster-Saved"])
|
||||||
|
self.assertIn("JULY 2026 Roster.png", response.headers["Content-Disposition"])
|
||||||
|
|
||||||
|
self.assertEqual(direct.read_bytes(), web_png)
|
||||||
|
self.assertEqual(web_png, (self.output_dir / "JULY 2026 Roster.png").read_bytes())
|
||||||
|
|
||||||
|
def test_format_missing_layout_fields_uses_saved_defaults(self) -> None:
|
||||||
|
settings = LayoutSettings(
|
||||||
|
content_shift=116,
|
||||||
|
bottom_margin=67,
|
||||||
|
logo_x=54,
|
||||||
|
logo_y=-6,
|
||||||
|
logo_scale=0.3,
|
||||||
|
date_x=468,
|
||||||
|
date_y=94,
|
||||||
|
date_font_size=51,
|
||||||
|
)
|
||||||
|
self.app.store.save_layout_defaults(settings)
|
||||||
|
direct = self.root / "direct-saved-defaults.png"
|
||||||
|
format_roster(self.source, direct, RosterPeriod(2026, 8), settings)
|
||||||
|
content_type, body = multipart_body(
|
||||||
|
{"month": "2026-08"},
|
||||||
|
"current-roster.pdf",
|
||||||
|
self.source.read_bytes(),
|
||||||
|
)
|
||||||
|
request = Request(
|
||||||
|
f"{self.base_url}/format",
|
||||||
|
data=body,
|
||||||
|
method="POST",
|
||||||
|
headers={"Content-Type": content_type},
|
||||||
|
)
|
||||||
|
|
||||||
|
with urlopen(request, timeout=15) as response:
|
||||||
|
web_png = response.read()
|
||||||
|
|
||||||
|
self.assertEqual(direct.read_bytes(), web_png)
|
||||||
|
self.assertEqual(web_png, (self.output_dir / "AUG 2026 Roster.png").read_bytes())
|
||||||
|
|
||||||
|
def test_rejected_post_cannot_smuggle_a_second_request(self) -> None:
|
||||||
|
inner_content_type, inner_body = multipart_body(
|
||||||
|
{"month": "2026-07"},
|
||||||
|
"smuggled.pdf",
|
||||||
|
self.source.read_bytes(),
|
||||||
|
)
|
||||||
|
host, port = self.server.server_address
|
||||||
|
inner_request = (
|
||||||
|
f"POST /format HTTP/1.1\r\n"
|
||||||
|
f"Host: {host}:{port}\r\n"
|
||||||
|
f"Content-Type: {inner_content_type}\r\n"
|
||||||
|
f"Content-Length: {len(inner_body)}\r\n"
|
||||||
|
"Connection: close\r\n"
|
||||||
|
"\r\n"
|
||||||
|
).encode() + inner_body
|
||||||
|
outer_request = (
|
||||||
|
f"POST /format HTTP/1.1\r\n"
|
||||||
|
f"Host: {host}:{port}\r\n"
|
||||||
|
"Origin: http://attacker.example\r\n"
|
||||||
|
"Content-Type: text/plain\r\n"
|
||||||
|
f"Content-Length: {len(inner_request)}\r\n"
|
||||||
|
"\r\n"
|
||||||
|
).encode() + inner_request
|
||||||
|
|
||||||
|
response_parts: list[bytes] = []
|
||||||
|
with socket.create_connection((host, port), timeout=5) as connection:
|
||||||
|
connection.sendall(outer_request)
|
||||||
|
connection.shutdown(socket.SHUT_WR)
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
chunk = connection.recv(65536)
|
||||||
|
except ConnectionResetError:
|
||||||
|
break
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
response_parts.append(chunk)
|
||||||
|
|
||||||
|
response = b"".join(response_parts)
|
||||||
|
self.assertEqual(1, response.count(b"HTTP/1.1"))
|
||||||
|
self.assertIn(b"403 Forbidden", response)
|
||||||
|
self.assertIn(b"Connection: close", response)
|
||||||
|
self.assertEqual([], list(self.output_dir.glob("*.png")))
|
||||||
|
|
||||||
|
def test_invalid_upload_gets_safe_error(self) -> None:
|
||||||
|
content_type, body = multipart_body(
|
||||||
|
{"month": "2026-07"},
|
||||||
|
"not-a-roster.pdf",
|
||||||
|
b"this is not a pdf",
|
||||||
|
)
|
||||||
|
request = Request(
|
||||||
|
f"{self.base_url}/format",
|
||||||
|
data=body,
|
||||||
|
method="POST",
|
||||||
|
headers={"Content-Type": content_type},
|
||||||
|
)
|
||||||
|
with self.assertRaises(HTTPError) as caught:
|
||||||
|
urlopen(request, timeout=5)
|
||||||
|
self.assertEqual(422, caught.exception.code)
|
||||||
|
self.assertEqual("close", caught.exception.headers["Connection"])
|
||||||
|
self.assertEqual(
|
||||||
|
{"error": "The uploaded file is not a PDF."},
|
||||||
|
json.loads(caught.exception.read()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+372
@@ -0,0 +1,372 @@
|
|||||||
|
const body = document.body;
|
||||||
|
const form = document.querySelector("#roster-form");
|
||||||
|
const fileInput = document.querySelector("#roster-file");
|
||||||
|
const dropZone = document.querySelector("#drop-zone");
|
||||||
|
const dropTitle = document.querySelector("#drop-title");
|
||||||
|
const dropCopy = document.querySelector("#drop-copy");
|
||||||
|
const monthInput = document.querySelector("#month");
|
||||||
|
const suggestedMonthButton = document.querySelector("#suggested-month");
|
||||||
|
const resetLayoutButton = document.querySelector("#reset-layout");
|
||||||
|
const factoryLayoutButton = document.querySelector("#factory-layout");
|
||||||
|
const saveLayoutDefaultsButton = document.querySelector("#save-layout-defaults");
|
||||||
|
const layoutMessage = document.querySelector("#layout-message");
|
||||||
|
const layoutInputs = [...document.querySelectorAll("[data-layout-input]")];
|
||||||
|
const defaultsStatus = document.querySelector("#defaults-status");
|
||||||
|
const themeChoiceButtons = [...document.querySelectorAll("[data-theme-choice]")];
|
||||||
|
const formMessage = document.querySelector("#form-message");
|
||||||
|
const formatButton = document.querySelector("#format-button");
|
||||||
|
const buttonLabel = formatButton.querySelector(".button-label");
|
||||||
|
const resultCard = document.querySelector("#result-card");
|
||||||
|
const resultPreview = document.querySelector("#result-preview");
|
||||||
|
const resultName = document.querySelector("#result-name");
|
||||||
|
const downloadResult = document.querySelector("#download-result");
|
||||||
|
const formatAnother = document.querySelector("#format-another");
|
||||||
|
const historyList = document.querySelector("#history-list");
|
||||||
|
const refreshHistoryButton = document.querySelector("#refresh-history");
|
||||||
|
const retentionNote = document.querySelector("#retention-note");
|
||||||
|
|
||||||
|
const defaultMonth = body.dataset.defaultMonth;
|
||||||
|
const retentionMonths = Number(body.dataset.retentionMonths);
|
||||||
|
const maximumUploadMb = Number(body.dataset.maxUploadMb);
|
||||||
|
const maximumUploadBytes = maximumUploadMb * 1024 * 1024;
|
||||||
|
let selectedFile = null;
|
||||||
|
let resultUrl = null;
|
||||||
|
|
||||||
|
const themeChoices = new Set(["system", "light", "dark"]);
|
||||||
|
|
||||||
|
function setThemeChoice(choice, persist = true) {
|
||||||
|
const selected = themeChoices.has(choice) ? choice : "system";
|
||||||
|
document.documentElement.dataset.theme = selected;
|
||||||
|
themeChoiceButtons.forEach((button) => {
|
||||||
|
button.setAttribute("aria-pressed", String(button.dataset.themeChoice === selected));
|
||||||
|
});
|
||||||
|
if (persist) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem("roster-theme", selected);
|
||||||
|
} catch {
|
||||||
|
// The theme still works when browser storage is unavailable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFile(file) {
|
||||||
|
selectedFile = file || null;
|
||||||
|
if (!selectedFile) {
|
||||||
|
fileInput.value = "";
|
||||||
|
dropZone.classList.remove("has-file");
|
||||||
|
dropTitle.textContent = "Choose your roster PDF";
|
||||||
|
dropCopy.textContent = `or drag it here · one-page landscape Letter · up to ${maximumUploadMb} MB`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dropZone.classList.add("has-file");
|
||||||
|
dropTitle.textContent = selectedFile.name;
|
||||||
|
dropCopy.textContent = `${formatFileSize(selectedFile.size)} · ready to format`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFileSize(bytes) {
|
||||||
|
if (bytes < 1024 * 1024) {
|
||||||
|
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
|
||||||
|
}
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(message) {
|
||||||
|
formMessage.textContent = message;
|
||||||
|
formMessage.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearError() {
|
||||||
|
formMessage.textContent = "";
|
||||||
|
formMessage.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLayoutMessage(message, isError = false) {
|
||||||
|
layoutMessage.textContent = message;
|
||||||
|
layoutMessage.classList.toggle("is-error", isError);
|
||||||
|
layoutMessage.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearLayoutMessage() {
|
||||||
|
layoutMessage.textContent = "";
|
||||||
|
layoutMessage.classList.remove("is-error");
|
||||||
|
layoutMessage.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBusy(busy) {
|
||||||
|
body.classList.toggle("is-busy", busy);
|
||||||
|
form.setAttribute("aria-busy", String(busy));
|
||||||
|
formatButton.disabled = busy;
|
||||||
|
buttonLabel.textContent = busy ? "Formatting…" : "Format roster";
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDefaultsStatus() {
|
||||||
|
const custom = layoutInputs.some((input) => {
|
||||||
|
if (input.value.trim() === "") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return Number(input.value) !== Number(input.dataset.default);
|
||||||
|
});
|
||||||
|
defaultsStatus.textContent = custom ? "Unsaved layout" : "Saved defaults";
|
||||||
|
defaultsStatus.classList.toggle("is-custom", custom);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveLayoutDefaults() {
|
||||||
|
const invalidInput = layoutInputs.find((input) => !input.checkValidity());
|
||||||
|
if (invalidInput) {
|
||||||
|
invalidInput.reportValidity();
|
||||||
|
invalidInput.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = Object.fromEntries(layoutInputs.map((input) => [input.name, input.value]));
|
||||||
|
const originalLabel = saveLayoutDefaultsButton.textContent;
|
||||||
|
saveLayoutDefaultsButton.disabled = true;
|
||||||
|
saveLayoutDefaultsButton.textContent = "Saving…";
|
||||||
|
clearLayoutMessage();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/layout-defaults", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(values),
|
||||||
|
});
|
||||||
|
let payload = {};
|
||||||
|
try {
|
||||||
|
payload = await response.json();
|
||||||
|
} catch {
|
||||||
|
// Use the safe fallback below if the server returns an unexpected response.
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(payload.error || "The layout defaults could not be saved.");
|
||||||
|
}
|
||||||
|
if (!payload.defaults || typeof payload.defaults !== "object") {
|
||||||
|
throw new Error("The server returned an invalid defaults response.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const canonicalValues = layoutInputs.map((input) => {
|
||||||
|
const savedValue = payload.defaults[input.name];
|
||||||
|
if (savedValue === undefined) {
|
||||||
|
throw new Error("The server returned an incomplete defaults response.");
|
||||||
|
}
|
||||||
|
return String(savedValue);
|
||||||
|
});
|
||||||
|
layoutInputs.forEach((input, index) => {
|
||||||
|
input.value = canonicalValues[index];
|
||||||
|
input.dataset.default = canonicalValues[index];
|
||||||
|
});
|
||||||
|
updateDefaultsStatus();
|
||||||
|
showLayoutMessage("Saved. These values will prefill future rosters.");
|
||||||
|
} catch (error) {
|
||||||
|
showLayoutMessage(error.message || "The layout defaults could not be saved.", true);
|
||||||
|
} finally {
|
||||||
|
saveLayoutDefaultsButton.disabled = false;
|
||||||
|
saveLayoutDefaultsButton.textContent = originalLabel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function responseFilename(response) {
|
||||||
|
const disposition = response.headers.get("Content-Disposition") || "";
|
||||||
|
const match = disposition.match(/filename="([^"]+)"/i);
|
||||||
|
return match ? match[1] : "Formatted Roster.png";
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHistoryItem(item) {
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.className = "history-item";
|
||||||
|
link.href = item.url;
|
||||||
|
link.download = item.filename;
|
||||||
|
|
||||||
|
const file = document.createElement("span");
|
||||||
|
file.className = "history-file";
|
||||||
|
const name = document.createElement("strong");
|
||||||
|
name.textContent = item.filename;
|
||||||
|
const details = document.createElement("small");
|
||||||
|
details.textContent = `${formatFileSize(item.size)} · PNG`;
|
||||||
|
file.append(name, details);
|
||||||
|
|
||||||
|
const icon = document.createElement("span");
|
||||||
|
icon.className = "history-download";
|
||||||
|
icon.setAttribute("aria-hidden", "true");
|
||||||
|
icon.textContent = "↓";
|
||||||
|
link.append(file, icon);
|
||||||
|
return link;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadHistory() {
|
||||||
|
refreshHistoryButton.disabled = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/history", { cache: "no-store" });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("History could not be loaded.");
|
||||||
|
}
|
||||||
|
const payload = await response.json();
|
||||||
|
historyList.replaceChildren();
|
||||||
|
if (!payload.items.length) {
|
||||||
|
const empty = document.createElement("p");
|
||||||
|
empty.className = "empty-state";
|
||||||
|
empty.textContent = "Your formatted rosters will appear here.";
|
||||||
|
historyList.append(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
payload.items.forEach((item) => historyList.append(createHistoryItem(item)));
|
||||||
|
} catch (error) {
|
||||||
|
const empty = document.createElement("p");
|
||||||
|
empty.className = "empty-state";
|
||||||
|
empty.textContent = error.message;
|
||||||
|
historyList.replaceChildren(empty);
|
||||||
|
} finally {
|
||||||
|
refreshHistoryButton.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fileInput.addEventListener("change", () => {
|
||||||
|
setFile(fileInput.files[0] || null);
|
||||||
|
clearError();
|
||||||
|
});
|
||||||
|
|
||||||
|
["dragenter", "dragover"].forEach((eventName) => {
|
||||||
|
dropZone.addEventListener(eventName, (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
dropZone.classList.add("is-dragging");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
["dragleave", "drop"].forEach((eventName) => {
|
||||||
|
dropZone.addEventListener(eventName, (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
dropZone.classList.remove("is-dragging");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
dropZone.addEventListener("drop", (event) => {
|
||||||
|
const file = [...event.dataTransfer.files].find(
|
||||||
|
(candidate) => candidate.type === "application/pdf" || candidate.name.toLowerCase().endsWith(".pdf"),
|
||||||
|
);
|
||||||
|
if (!file) {
|
||||||
|
showError("Drop a PDF file here.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFile(file);
|
||||||
|
clearError();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("dragover", (event) => event.preventDefault());
|
||||||
|
window.addEventListener("drop", (event) => event.preventDefault());
|
||||||
|
|
||||||
|
suggestedMonthButton.addEventListener("click", () => {
|
||||||
|
monthInput.value = defaultMonth;
|
||||||
|
});
|
||||||
|
|
||||||
|
themeChoiceButtons.forEach((button) => {
|
||||||
|
button.addEventListener("click", () => setThemeChoice(button.dataset.themeChoice));
|
||||||
|
});
|
||||||
|
|
||||||
|
layoutInputs.forEach((input) => {
|
||||||
|
input.addEventListener("input", () => {
|
||||||
|
updateDefaultsStatus();
|
||||||
|
clearLayoutMessage();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
resetLayoutButton.addEventListener("click", () => {
|
||||||
|
layoutInputs.forEach((input) => {
|
||||||
|
input.value = input.dataset.default;
|
||||||
|
});
|
||||||
|
updateDefaultsStatus();
|
||||||
|
showLayoutMessage("Restored the saved defaults.");
|
||||||
|
});
|
||||||
|
|
||||||
|
factoryLayoutButton.addEventListener("click", () => {
|
||||||
|
layoutInputs.forEach((input) => {
|
||||||
|
input.value = input.dataset.factory;
|
||||||
|
});
|
||||||
|
updateDefaultsStatus();
|
||||||
|
showLayoutMessage("Original production values loaded. Save them to make them the defaults.");
|
||||||
|
});
|
||||||
|
|
||||||
|
saveLayoutDefaultsButton.addEventListener("click", saveLayoutDefaults);
|
||||||
|
|
||||||
|
form.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
clearError();
|
||||||
|
|
||||||
|
if (!selectedFile) {
|
||||||
|
showError("Choose a roster PDF first.");
|
||||||
|
fileInput.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!selectedFile.name.toLowerCase().endsWith(".pdf")) {
|
||||||
|
showError("The roster filename must end in .pdf.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selectedFile.size > maximumUploadBytes) {
|
||||||
|
showError(`The roster PDF must be ${maximumUploadMb} MB or smaller.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!form.reportValidity()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData(form);
|
||||||
|
formData.delete("roster");
|
||||||
|
formData.append("roster", selectedFile, selectedFile.name);
|
||||||
|
setBusy(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/format", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
let message = "The roster could not be formatted.";
|
||||||
|
try {
|
||||||
|
const payload = await response.json();
|
||||||
|
message = payload.error || message;
|
||||||
|
} catch {
|
||||||
|
// Keep the safe fallback for an unexpected non-JSON response.
|
||||||
|
}
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const filename = responseFilename(response);
|
||||||
|
const saved = response.headers.get("X-Roster-Saved") === "true";
|
||||||
|
if (resultUrl) {
|
||||||
|
URL.revokeObjectURL(resultUrl);
|
||||||
|
}
|
||||||
|
resultUrl = URL.createObjectURL(blob);
|
||||||
|
resultPreview.src = resultUrl;
|
||||||
|
downloadResult.href = resultUrl;
|
||||||
|
downloadResult.download = filename;
|
||||||
|
resultName.textContent = saved
|
||||||
|
? `${filename} · saved in output/`
|
||||||
|
: `${filename} · available for download (outside retention window)`;
|
||||||
|
resultCard.hidden = false;
|
||||||
|
await loadHistory();
|
||||||
|
resultCard.focus({ preventScroll: true });
|
||||||
|
resultCard.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
} catch (error) {
|
||||||
|
showError(error.message || "The roster could not be formatted.");
|
||||||
|
formMessage.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
formatAnother.addEventListener("click", () => {
|
||||||
|
setFile(null);
|
||||||
|
resultCard.hidden = true;
|
||||||
|
clearError();
|
||||||
|
dropZone.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
});
|
||||||
|
|
||||||
|
refreshHistoryButton.addEventListener("click", loadHistory);
|
||||||
|
|
||||||
|
if (retentionMonths === 0) {
|
||||||
|
retentionNote.textContent = "Automatic roster cleanup is disabled.";
|
||||||
|
}
|
||||||
|
|
||||||
|
setThemeChoice(document.documentElement.dataset.theme, false);
|
||||||
|
updateDefaultsStatus();
|
||||||
|
loadHistory();
|
||||||
+209
@@ -0,0 +1,209 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" data-theme="system">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="theme-color" content="#102d3d">
|
||||||
|
<title>Roster Formatter</title>
|
||||||
|
<script src="/static/theme.js"></script>
|
||||||
|
<link rel="stylesheet" href="/static/styles.css">
|
||||||
|
<script src="/static/app.js" defer></script>
|
||||||
|
</head>
|
||||||
|
<body
|
||||||
|
data-default-month="__DEFAULT_MONTH__"
|
||||||
|
data-retention-months="__RETENTION_MONTHS__"
|
||||||
|
data-max-upload-mb="__MAX_UPLOAD_MB__"
|
||||||
|
>
|
||||||
|
<header class="site-header">
|
||||||
|
<a class="brand" href="/" aria-label="Roster Formatter home">
|
||||||
|
<img src="/assets/logo.png" alt="River of Glory Church">
|
||||||
|
</a>
|
||||||
|
<div class="header-actions">
|
||||||
|
<div class="theme-switch" role="group" aria-label="Color theme">
|
||||||
|
<button class="theme-option" type="button" data-theme-choice="system" aria-pressed="true">System</button>
|
||||||
|
<button class="theme-option" type="button" data-theme-choice="light" aria-pressed="false">Light</button>
|
||||||
|
<button class="theme-option" type="button" data-theme-choice="dark" aria-pressed="false">Dark</button>
|
||||||
|
</div>
|
||||||
|
<span class="local-badge"><span aria-hidden="true"></span> Local & private</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section class="intro" aria-labelledby="page-title">
|
||||||
|
<p class="eyebrow">Roster formatter</p>
|
||||||
|
<h1 id="page-title">From current roster to ready-to-share PNG.</h1>
|
||||||
|
<p class="lede">
|
||||||
|
Upload the one-page PDF, confirm its month, and download the formatted image.
|
||||||
|
The current layout is preserved unless you intentionally change an advanced value.
|
||||||
|
</p>
|
||||||
|
<div class="format-facts" aria-label="Output details">
|
||||||
|
<span>PNG</span>
|
||||||
|
<span>1650 × 1275 px</span>
|
||||||
|
<span>150 DPI</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="workspace">
|
||||||
|
<section class="card form-card" aria-labelledby="format-heading">
|
||||||
|
<div class="card-heading">
|
||||||
|
<div>
|
||||||
|
<p class="step-label">New export</p>
|
||||||
|
<h2 id="format-heading">Format a roster</h2>
|
||||||
|
</div>
|
||||||
|
<span class="defaults-status" id="defaults-status">Saved defaults</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="roster-form" action="/format" method="post" enctype="multipart/form-data">
|
||||||
|
<label class="drop-zone" id="drop-zone" for="roster-file">
|
||||||
|
<input id="roster-file" name="roster" type="file" accept="application/pdf,.pdf">
|
||||||
|
<span class="upload-mark" aria-hidden="true">↑</span>
|
||||||
|
<span class="drop-title" id="drop-title">Choose your roster PDF</span>
|
||||||
|
<span class="drop-copy" id="drop-copy">or drag it here · one-page landscape Letter · up to __MAX_UPLOAD_MB__ MB</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="field-block">
|
||||||
|
<label for="month">Roster month</label>
|
||||||
|
<div class="month-row">
|
||||||
|
<input id="month" name="month" type="month" value="__DEFAULT_MONTH__" required>
|
||||||
|
<button class="text-button" id="suggested-month" type="button">Use suggested</button>
|
||||||
|
</div>
|
||||||
|
<p class="field-help">This controls the label and downloaded filename.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details class="advanced" id="advanced-settings">
|
||||||
|
<summary>
|
||||||
|
<span>
|
||||||
|
<strong>Advanced adjustments</strong>
|
||||||
|
<small>Use only when this month needs a nudge.</small>
|
||||||
|
</span>
|
||||||
|
<span class="chevron" aria-hidden="true">⌄</span>
|
||||||
|
</summary>
|
||||||
|
<div class="advanced-body">
|
||||||
|
<div class="advanced-heading">
|
||||||
|
<p>Values use PDF points. Saved defaults prefill every new roster.</p>
|
||||||
|
<div class="layout-actions">
|
||||||
|
<button class="text-button" id="reset-layout" type="button">Reset changes</button>
|
||||||
|
<button class="text-button" id="factory-layout" type="button">Use original values</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Roster content</legend>
|
||||||
|
<div class="setting-grid">
|
||||||
|
<label>
|
||||||
|
Vertical shift
|
||||||
|
<input name="content_shift" type="number" value="__CONTENT_SHIFT__" data-default="__CONTENT_SHIFT__" data-factory="__FACTORY_CONTENT_SHIFT__" data-layout-input step="1" min="-300" max="500" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Bottom trim
|
||||||
|
<input name="bottom_margin" type="number" value="__BOTTOM_MARGIN__" data-default="__BOTTOM_MARGIN__" data-factory="__FACTORY_BOTTOM_MARGIN__" data-layout-input step="1" min="0" max="500" required>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Logo</legend>
|
||||||
|
<div class="setting-grid setting-grid--three">
|
||||||
|
<label>
|
||||||
|
X position
|
||||||
|
<input name="logo_x" type="number" value="__LOGO_X__" data-default="__LOGO_X__" data-factory="__FACTORY_LOGO_X__" data-layout-input step="1" min="-1000" max="2000" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Y position
|
||||||
|
<input name="logo_y" type="number" value="__LOGO_Y__" data-default="__LOGO_Y__" data-factory="__FACTORY_LOGO_Y__" data-layout-input step="1" min="-1000" max="2000" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Scale
|
||||||
|
<input name="logo_scale" type="number" value="__LOGO_SCALE__" data-default="__LOGO_SCALE__" data-factory="__FACTORY_LOGO_SCALE__" data-layout-input step="0.01" min="0.01" max="2" required>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Date label</legend>
|
||||||
|
<div class="setting-grid setting-grid--three">
|
||||||
|
<label>
|
||||||
|
X position
|
||||||
|
<input name="date_x" type="number" value="__DATE_X__" data-default="__DATE_X__" data-factory="__FACTORY_DATE_X__" data-layout-input step="1" min="-1000" max="2000" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Y position
|
||||||
|
<input name="date_y" type="number" value="__DATE_Y__" data-default="__DATE_Y__" data-factory="__FACTORY_DATE_Y__" data-layout-input step="1" min="-1000" max="2000" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Font size
|
||||||
|
<input name="date_font_size" type="number" value="__DATE_FONT_SIZE__" data-default="__DATE_FONT_SIZE__" data-factory="__FACTORY_DATE_FONT_SIZE__" data-layout-input step="1" min="8" max="200" required>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="save-defaults-row">
|
||||||
|
<p id="layout-message" role="status" aria-live="polite" hidden></p>
|
||||||
|
<button class="secondary-button save-defaults-button" id="save-layout-defaults" type="button">Save as defaults</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="canvas-lock">
|
||||||
|
The page canvas and 150-DPI export stay fixed, even when these values change.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<div class="form-message" id="form-message" role="alert" hidden></div>
|
||||||
|
<button class="primary-button" id="format-button" type="submit">
|
||||||
|
<span class="button-label">Format roster</span>
|
||||||
|
<span class="button-arrow" aria-hidden="true">→</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside class="side-column">
|
||||||
|
<section class="card result-card" id="result-card" aria-labelledby="result-heading" aria-live="polite" tabindex="-1" hidden>
|
||||||
|
<div class="result-heading">
|
||||||
|
<div>
|
||||||
|
<p class="step-label">Ready</p>
|
||||||
|
<h2 id="result-heading">Your formatted roster</h2>
|
||||||
|
</div>
|
||||||
|
<span class="success-mark" aria-hidden="true">✓</span>
|
||||||
|
</div>
|
||||||
|
<div class="preview-shell">
|
||||||
|
<img id="result-preview" alt="Preview of the formatted roster">
|
||||||
|
</div>
|
||||||
|
<p class="result-name" id="result-name"></p>
|
||||||
|
<div class="result-actions">
|
||||||
|
<a class="primary-button" id="download-result" href="#" download>Download PNG</a>
|
||||||
|
<button class="secondary-button" id="format-another" type="button">Format another</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card history-card" aria-labelledby="history-heading">
|
||||||
|
<div class="card-heading compact-heading">
|
||||||
|
<div>
|
||||||
|
<p class="step-label">Saved locally</p>
|
||||||
|
<h2 id="history-heading">Recent rosters</h2>
|
||||||
|
</div>
|
||||||
|
<button class="icon-button" id="refresh-history" type="button" aria-label="Refresh recent rosters">↻</button>
|
||||||
|
</div>
|
||||||
|
<div id="history-list" class="history-list" aria-live="polite">
|
||||||
|
<p class="empty-state">Loading saved rosters…</p>
|
||||||
|
</div>
|
||||||
|
<p class="retention-note" id="retention-note">
|
||||||
|
Saved roster PNGs outside the __RETENTION_MONTHS__-month window are removed automatically.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="privacy-note" aria-label="Privacy information">
|
||||||
|
<span aria-hidden="true">⌂</span>
|
||||||
|
<p>
|
||||||
|
Processing happens on this machine. Uploads and working PDFs are deleted as soon as the PNG is made.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<span>River of Glory · Roster tools</span>
|
||||||
|
<span>Fixed 11 × 8.5 in canvas</span>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+1074
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
|||||||
|
(() => {
|
||||||
|
const allowedThemes = new Set(["system", "light", "dark"]);
|
||||||
|
let theme = "system";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const savedTheme = window.localStorage.getItem("roster-theme");
|
||||||
|
if (allowedThemes.has(savedTheme)) {
|
||||||
|
theme = savedTheme;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Storage can be unavailable in hardened or private browsing contexts.
|
||||||
|
}
|
||||||
|
|
||||||
|
document.documentElement.dataset.theme = theme;
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user