V2: Added Webserver for faster formatting

This commit is contained in:
Media
2026-07-25 15:13:23 -05:00
parent eed911cffa
commit 3eab672c6c
22 changed files with 3737 additions and 128 deletions
+15
View File
@@ -0,0 +1,15 @@
.agents
.codex
.git
.gitignore
Dockerfile
README.md
Roster2
input
output
tests
venv
.venv
**/__pycache__
**/*.py[cod]
.DS_Store
+13
View File
@@ -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
View File
@@ -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"]
+118
View File
@@ -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/`.
+48
View File
@@ -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.
+64 -94
View File
@@ -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.
+9 -4
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

+2 -30
View File
@@ -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
+376
View File
@@ -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)
+817
View File
@@ -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())
+1
View File
@@ -0,0 +1 @@
"""Roster formatter regression tests."""
+38
View File
@@ -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
+130
View File
@@ -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()
+406
View File
@@ -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
View File
@@ -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
View File
@@ -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 &amp; 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
View File
File diff suppressed because it is too large Load Diff
+15
View File
@@ -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;
})();