diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5852b6c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +.agents +.codex +.git +.gitignore +Dockerfile +README.md +Roster2 +input +output +tests +venv +.venv +**/__pycache__ +**/*.py[cod] +.DS_Store diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1b26b41 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8c1cee7 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..d17f288 --- /dev/null +++ b/README.md @@ -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 , 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/`. diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..39dc84a --- /dev/null +++ b/compose.yaml @@ -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 diff --git a/input/original.pdf b/input/original.pdf index cb578b5..7860879 100644 Binary files a/input/original.pdf and b/input/original.pdf differ diff --git a/main.py b/main.py index b53fe97..00fe52c 100644 --- a/main.py +++ b/main.py @@ -1,102 +1,72 @@ -from modules.adjust_layout import adjust_layout -from modules.add_logo import add_logo -from pathlib import Path -from modules.add_date import add_date -from datetime import datetime -from dateutil.relativedelta import relativedelta # type: ignore +"""Command-line entry point for the roster formatter.""" + +from __future__ import annotations + import argparse -import fitz # type: ignore +from pathlib import Path -def convert_pdf_to_png(pdf_path, output_path, dpi=150): - doc = fitz.open(pdf_path) - page = doc.load_page(0) - pix = page.get_pixmap(dpi=dpi) - pix.save(output_path) - doc.close() - -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 +from roster_formatter import ( + BASE_DIR, + LayoutSettings, + RosterFormatError, + RosterPeriod, + format_roster, ) -convert_pdf_to_png(final_pdf_path, final_png_path) -final_pdf_name = f"{month} {year} Roster.pdf" -final_pdf_path = base / "output" / final_pdf_name +def build_parser() -> argparse.ArgumentParser: + 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") -final_png_path = base / "output" / final_png_name + advanced = parser.add_argument_group("advanced layout adjustments") + 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 -for path in [shifted_pdf, logo_output, final_pdf_path]: - if path.exists(): - path.unlink() \ No newline at end of file +def main() -> int: + args = build_parser().parse_args() + try: + 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()) diff --git a/modules/__pycache__/adjust_layout.cpython-311.pyc b/modules/__pycache__/adjust_layout.cpython-311.pyc index 0d1b6b7..3be2b01 100644 Binary files a/modules/__pycache__/adjust_layout.cpython-311.pyc and b/modules/__pycache__/adjust_layout.cpython-311.pyc differ diff --git a/modules/adjust_layout.py b/modules/adjust_layout.py index 7a6519a..ca3b8f7 100644 --- a/modules/adjust_layout.py +++ b/modules/adjust_layout.py @@ -1,10 +1,10 @@ import fitz # PyMuPDF -LETTER_WIDTH = 792 # 8.5 inches -LETTER_HEIGHT = 612 # 11 inches +LETTER_WIDTH = 792 # 11 inches at 72 points per inch +LETTER_HEIGHT = 612 # 8.5 inches at 72 points per inch 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) new_doc = fitz.open() @@ -17,7 +17,12 @@ def adjust_layout(input_path, output_path, shift_amount): # Shift original content down by shift_amount # 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) diff --git a/output/.gitkeep b/output/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/output/.gitkeep @@ -0,0 +1 @@ + diff --git a/output/JULY 2025 Roster.png b/output/JULY 2025 Roster.png deleted file mode 100644 index 0142b2c..0000000 Binary files a/output/JULY 2025 Roster.png and /dev/null differ diff --git a/requirements.txt b/requirements.txt index e68f823..883dedb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,30 +1,2 @@ -Brotli==1.0.9 -certifi==2022.9.24 -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 +Pillow==11.2.1 +PyMuPDF==1.26.0 diff --git a/roster_formatter.py b/roster_formatter.py new file mode 100644 index 0000000..4c9afbe --- /dev/null +++ b/roster_formatter.py @@ -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) diff --git a/server.py b/server.py new file mode 100644 index 0000000..7a6cdf4 --- /dev/null +++ b/server.py @@ -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()) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..75efd30 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Roster formatter regression tests.""" diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..078398a --- /dev/null +++ b/tests/helpers.py @@ -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 diff --git a/tests/test_formatter.py b/tests/test_formatter.py new file mode 100644 index 0000000..0f92b59 --- /dev/null +++ b/tests/test_formatter.py @@ -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() diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..c4deb30 --- /dev/null +++ b/tests/test_server.py @@ -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) + 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() diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..7897fbf --- /dev/null +++ b/web/app.js @@ -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(); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..475b417 --- /dev/null +++ b/web/index.html @@ -0,0 +1,209 @@ + + + + + + + Roster Formatter + + + + + + + +
+
+

Roster formatter

+

From current roster to ready-to-share PNG.

+

+ 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. +

+
+ PNG + 1650 × 1275 px + 150 DPI +
+
+ +
+
+
+
+

New export

+

Format a roster

+
+ Saved defaults +
+ +
+ + +
+ +
+ + +
+

This controls the label and downloaded filename.

+
+ +
+ + + Advanced adjustments + Use only when this month needs a nudge. + + + +
+
+

Values use PDF points. Saved defaults prefill every new roster.

+
+ + +
+
+ +
+ Roster content +
+ + +
+
+ +
+ Logo +
+ + + +
+
+ +
+ Date label +
+ + + +
+
+ +
+ + +
+ +

+ The page canvas and 150-DPI export stay fixed, even when these values change. +

+
+
+ + + +
+
+ + +
+
+ + + + diff --git a/web/styles.css b/web/styles.css new file mode 100644 index 0000000..61be793 --- /dev/null +++ b/web/styles.css @@ -0,0 +1,1074 @@ +:root { + --ink: #102d3d; + --ink-soft: #46606d; + --blue: #278fd0; + --blue-dark: #176fa7; + --blue-pale: #eaf6fc; + --paper: #fffdfa; + --canvas: #f2f0ea; + --canvas-top: #f8f7f2; + --canvas-glow: rgba(39, 143, 208, 0.12); + --line: #d9dedc; + --line-dark: #bdc9ca; + --border-subtle: rgba(16, 45, 61, 0.12); + --badge-bg: rgba(255, 255, 255, 0.62); + --fact-bg: rgba(255, 255, 255, 0.5); + --surface: rgba(255, 253, 250, 0.94); + --surface-solid: #fff; + --surface-muted: rgba(245, 247, 245, 0.66); + --surface-hover: #f7f8f6; + --preview-bg: #e8e8e5; + --focus-ring: rgba(39, 143, 208, 0.32); + --input-hover-border: #90a4a8; + --action-bg: #102d3d; + --action-hover: #17445c; + --action-ink: #fff; + --action-shadow: 0 8px 18px rgba(16, 45, 61, 0.2); + --brand-backdrop: transparent; + --theme-track: rgba(255, 255, 255, 0.56); + --theme-active: #fff; + --theme-active-shadow: 0 1px 5px rgba(16, 45, 61, 0.15); + --warning: #92610d; + --success: #23845f; + --success-ink: #fff; + --danger: #a33c37; + --shadow: 0 22px 60px rgba(16, 45, 61, 0.1); + color-scheme: light; + font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +:root[data-theme="dark"] { + --ink: #e7eff3; + --ink-soft: #a8bac3; + --blue: #55b7ee; + --blue-dark: #8bcff5; + --blue-pale: #143246; + --paper: #15252d; + --canvas: #0c151a; + --canvas-top: #111f26; + --canvas-glow: rgba(53, 166, 226, 0.16); + --line: #2b414c; + --line-dark: #405d69; + --border-subtle: rgba(198, 224, 235, 0.13); + --badge-bg: rgba(24, 39, 47, 0.8); + --fact-bg: rgba(25, 42, 51, 0.66); + --surface: rgba(20, 34, 41, 0.95); + --surface-solid: #14242c; + --surface-muted: rgba(15, 29, 36, 0.82); + --surface-hover: #1b303a; + --preview-bg: #25363e; + --focus-ring: rgba(85, 183, 238, 0.42); + --input-hover-border: #6f909e; + --action-bg: #3ba7df; + --action-hover: #62bcef; + --action-ink: #07151c; + --action-shadow: 0 8px 22px rgba(0, 0, 0, 0.32); + --brand-backdrop: rgba(255, 255, 255, 0.92); + --theme-track: rgba(7, 18, 23, 0.58); + --theme-active: #263d48; + --theme-active-shadow: 0 1px 7px rgba(0, 0, 0, 0.34); + --warning: #efbd64; + --success: #62cda3; + --success-ink: #082018; + --danger: #f1948e; + --shadow: 0 22px 60px rgba(0, 0, 0, 0.3); + color-scheme: dark; +} + +@media (prefers-color-scheme: dark) { + :root[data-theme="system"] { + --ink: #e7eff3; + --ink-soft: #a8bac3; + --blue: #55b7ee; + --blue-dark: #8bcff5; + --blue-pale: #143246; + --paper: #15252d; + --canvas: #0c151a; + --canvas-top: #111f26; + --canvas-glow: rgba(53, 166, 226, 0.16); + --line: #2b414c; + --line-dark: #405d69; + --border-subtle: rgba(198, 224, 235, 0.13); + --badge-bg: rgba(24, 39, 47, 0.8); + --fact-bg: rgba(25, 42, 51, 0.66); + --surface: rgba(20, 34, 41, 0.95); + --surface-solid: #14242c; + --surface-muted: rgba(15, 29, 36, 0.82); + --surface-hover: #1b303a; + --preview-bg: #25363e; + --focus-ring: rgba(85, 183, 238, 0.42); + --input-hover-border: #6f909e; + --action-bg: #3ba7df; + --action-hover: #62bcef; + --action-ink: #07151c; + --action-shadow: 0 8px 22px rgba(0, 0, 0, 0.32); + --brand-backdrop: rgba(255, 255, 255, 0.92); + --theme-track: rgba(7, 18, 23, 0.58); + --theme-active: #263d48; + --theme-active-shadow: 0 1px 7px rgba(0, 0, 0, 0.34); + --warning: #efbd64; + --success: #62cda3; + --success-ink: #082018; + --danger: #f1948e; + --shadow: 0 22px 60px rgba(0, 0, 0, 0.3); + color-scheme: dark; + } +} + +* { + box-sizing: border-box; +} + +html { + min-width: 320px; + background: var(--canvas); +} + +body { + min-height: 100vh; + margin: 0; + color: var(--ink); + background: + radial-gradient(circle at 84% 9%, var(--canvas-glow), transparent 25rem), + linear-gradient(180deg, var(--canvas-top) 0, var(--canvas) 42rem); +} + +button, +input { + font: inherit; +} + +button, +a { + -webkit-tap-highlight-color: transparent; +} + +button:focus-visible, +a:focus-visible, +input:focus-visible, +summary:focus-visible, +.drop-zone:focus-within { + outline: 3px solid var(--focus-ring); + outline-offset: 3px; +} + +[hidden] { + display: none !important; +} + +.site-header { + display: flex; + align-items: center; + justify-content: space-between; + width: min(1180px, calc(100% - 40px)); + min-height: 104px; + margin: 0 auto; + border-bottom: 1px solid var(--border-subtle); +} + +.brand { + display: inline-flex; + align-items: center; + padding: 6px 9px; + margin-left: -9px; + border-radius: 10px; + background: var(--brand-backdrop); +} + +.brand img { + display: block; + width: 218px; + height: auto; +} + +.header-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 12px; +} + +.theme-switch { + display: inline-flex; + align-items: center; + gap: 2px; + padding: 3px; + border: 1px solid var(--line); + border-radius: 999px; + background: var(--theme-track); +} + +.theme-option { + min-height: 31px; + padding: 5px 10px; + cursor: pointer; + color: var(--ink-soft); + border: 0; + border-radius: 999px; + background: transparent; + font-size: 0.7rem; + font-weight: 780; + line-height: 1; + transition: color 150ms ease, background 150ms ease, box-shadow 150ms ease; +} + +.theme-option:hover { + color: var(--ink); + background: var(--surface-hover); +} + +.theme-option[aria-pressed="true"], +.theme-option.is-active { + color: var(--ink); + background: var(--theme-active); + box-shadow: var(--theme-active-shadow); +} + +.local-badge, +.defaults-status { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + color: var(--ink-soft); + border: 1px solid var(--line); + border-radius: 999px; + background: var(--badge-bg); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.02em; +} + +.local-badge > span { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--success); + box-shadow: 0 0 0 4px rgba(35, 132, 95, 0.12); +} + +main { + width: min(1180px, calc(100% - 40px)); + margin: 0 auto; +} + +.intro { + max-width: 790px; + padding: 74px 0 52px; +} + +.eyebrow, +.step-label { + margin: 0 0 12px; + color: var(--blue-dark); + font-size: 0.73rem; + font-weight: 800; + letter-spacing: 0.16em; + text-transform: uppercase; +} + +h1, +h2, +p { + text-wrap: pretty; +} + +h1 { + max-width: 760px; + margin: 0; + font-family: Georgia, "Times New Roman", serif; + font-size: clamp(2.7rem, 6vw, 5.2rem); + font-weight: 500; + letter-spacing: -0.045em; + line-height: 0.98; +} + +.lede { + max-width: 660px; + margin: 26px 0 0; + color: var(--ink-soft); + font-size: 1.08rem; + line-height: 1.7; +} + +.format-facts { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 28px; +} + +.format-facts span { + padding: 7px 11px; + border: 1px solid var(--border-subtle); + border-radius: 7px; + color: var(--ink-soft); + background: var(--fact-bg); + font-size: 0.78rem; + font-weight: 750; + letter-spacing: 0.025em; +} + +.workspace { + display: grid; + grid-template-columns: minmax(0, 1.45fr) minmax(310px, 0.75fr); + gap: 24px; + align-items: start; + padding-bottom: 80px; +} + +.side-column { + display: grid; + gap: 24px; +} + +.card { + border: 1px solid var(--border-subtle); + border-radius: 20px; + background: var(--surface); + box-shadow: var(--shadow); +} + +.form-card { + padding: clamp(24px, 5vw, 44px); +} + +.card-heading, +.result-heading, +.advanced-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; +} + +.card-heading { + margin-bottom: 30px; +} + +.card-heading h2, +.result-heading h2 { + margin: 0; + font-size: 1.45rem; + letter-spacing: -0.025em; +} + +.card-heading .step-label, +.result-heading .step-label { + margin-bottom: 7px; +} + +.defaults-status { + flex: 0 0 auto; + color: var(--success); + border-color: rgba(35, 132, 95, 0.22); + background: rgba(35, 132, 95, 0.07); +} + +.defaults-status.is-custom { + color: var(--warning); + border-color: rgba(146, 97, 13, 0.24); + background: rgba(217, 151, 35, 0.09); +} + +.drop-zone { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 228px; + padding: 32px 20px; + overflow: hidden; + cursor: pointer; + text-align: center; + border: 1.5px dashed var(--line-dark); + border-radius: 15px; + background: + linear-gradient(135deg, rgba(39, 143, 208, 0.035), rgba(39, 143, 208, 0.09)), + var(--paper); + transition: border-color 160ms ease, background 160ms ease, transform 160ms ease; +} + +.drop-zone::after { + position: absolute; + inset: auto -48px -68px auto; + width: 170px; + height: 170px; + content: ""; + border: 24px solid rgba(39, 143, 208, 0.055); + border-radius: 50%; +} + +.drop-zone:hover, +.drop-zone.is-dragging { + border-color: var(--blue); + background: + linear-gradient(135deg, rgba(39, 143, 208, 0.08), rgba(39, 143, 208, 0.14)), + var(--paper); +} + +.drop-zone.is-dragging { + transform: translateY(-2px); +} + +.drop-zone.has-file { + border-style: solid; + border-color: rgba(35, 132, 95, 0.36); + background: rgba(35, 132, 95, 0.055); +} + +.drop-zone input { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; + pointer-events: none; +} + +.upload-mark { + display: grid; + place-items: center; + width: 50px; + height: 50px; + margin-bottom: 18px; + color: var(--action-ink); + border-radius: 14px; + background: var(--action-bg); + box-shadow: var(--action-shadow); + font-size: 1.45rem; + line-height: 1; +} + +.drop-title { + position: relative; + z-index: 1; + max-width: 90%; + overflow: hidden; + font-size: 1.02rem; + font-weight: 800; + text-overflow: ellipsis; + white-space: nowrap; +} + +.drop-copy { + position: relative; + z-index: 1; + margin-top: 8px; + color: var(--ink-soft); + font-size: 0.82rem; + line-height: 1.5; +} + +.field-block { + margin-top: 30px; +} + +.field-block > label, +.setting-grid label { + display: grid; + gap: 8px; + color: var(--ink); + font-size: 0.82rem; + font-weight: 760; +} + +.month-row { + display: flex; + align-items: center; + gap: 14px; + margin-top: 9px; +} + +input[type="month"], +input[type="number"] { + min-width: 0; + color: var(--ink); + border: 1px solid var(--line-dark); + border-radius: 10px; + background: var(--surface-solid); +} + +input[type="month"] { + width: min(260px, 100%); + min-height: 48px; + padding: 10px 13px; + font-weight: 680; +} + +input[type="number"] { + width: 100%; + min-height: 43px; + padding: 8px 10px; + font-variant-numeric: tabular-nums; +} + +input:hover { + border-color: var(--input-hover-border); +} + +.text-button, +.icon-button { + padding: 0; + cursor: pointer; + color: var(--blue-dark); + border: 0; + background: transparent; + font-size: 0.8rem; + font-weight: 800; +} + +.text-button:hover { + text-decoration: underline; + text-underline-offset: 3px; +} + +.field-help { + margin: 8px 0 0; + color: var(--ink-soft); + font-size: 0.78rem; +} + +.advanced { + margin-top: 30px; + border: 1px solid var(--line); + border-radius: 13px; + background: var(--surface-muted); +} + +.advanced summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + min-height: 72px; + padding: 16px 18px; + cursor: pointer; + list-style: none; +} + +.advanced summary::-webkit-details-marker { + display: none; +} + +.advanced summary > span:first-child { + display: grid; + gap: 4px; +} + +.advanced summary strong { + font-size: 0.87rem; +} + +.advanced summary small { + color: var(--ink-soft); + font-size: 0.74rem; + font-weight: 500; +} + +.chevron { + font-size: 1.3rem; + transition: transform 160ms ease; +} + +.advanced[open] .chevron { + transform: rotate(180deg); +} + +.advanced-body { + padding: 0 18px 20px; + border-top: 1px solid var(--line); +} + +.advanced-heading { + align-items: center; + padding: 16px 0 8px; +} + +.advanced-heading p { + margin: 0; + color: var(--ink-soft); + font-size: 0.75rem; + line-height: 1.45; +} + +.layout-actions { + display: flex; + flex: 0 0 auto; + flex-wrap: wrap; + justify-content: flex-end; + gap: 7px 14px; +} + +.layout-actions .text-button { + white-space: nowrap; +} + +fieldset { + min-width: 0; + margin: 15px 0 0; + padding: 0; + border: 0; +} + +legend { + margin-bottom: 10px; + font-size: 0.74rem; + font-weight: 850; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.setting-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 11px; +} + +.setting-grid--three { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.setting-grid label { + color: var(--ink-soft); + font-size: 0.72rem; +} + +.canvas-lock { + margin: 18px 0 0; + padding: 11px 13px; + color: var(--ink-soft); + border-left: 3px solid var(--blue); + background: var(--blue-pale); + font-size: 0.73rem; + line-height: 1.5; +} + +.save-defaults-row { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 14px; + margin-top: 18px; +} + +#layout-message { + flex: 1 1 auto; + margin: 0; + color: var(--success); + font-size: 0.74rem; + line-height: 1.45; +} + +#layout-message.is-error { + color: var(--danger); +} + +.save-defaults-button { + flex: 0 0 auto; + min-height: 42px; + padding: 9px 14px; + font-size: 0.78rem; +} + +.form-message { + margin-top: 22px; + padding: 12px 14px; + color: var(--danger); + border: 1px solid rgba(163, 60, 55, 0.23); + border-radius: 10px; + background: rgba(163, 60, 55, 0.07); + font-size: 0.82rem; + line-height: 1.5; +} + +.primary-button, +.secondary-button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 51px; + padding: 13px 18px; + cursor: pointer; + border-radius: 10px; + font-size: 0.88rem; + font-weight: 820; + text-decoration: none; + transition: background 150ms ease, border-color 150ms ease, transform 150ms ease; +} + +#format-button { + justify-content: space-between; + width: 100%; + margin-top: 24px; +} + +.primary-button { + color: var(--action-ink); + border: 1px solid var(--action-bg); + background: var(--action-bg); +} + +.primary-button:hover { + border-color: var(--action-hover); + background: var(--action-hover); + transform: translateY(-1px); +} + +.primary-button:disabled { + cursor: wait; + opacity: 0.68; + transform: none; +} + +.button-arrow { + font-size: 1.3rem; + font-weight: 400; +} + +.secondary-button { + color: var(--ink); + border: 1px solid var(--line-dark); + background: var(--surface-solid); +} + +.secondary-button:hover { + border-color: var(--ink-soft); + background: var(--surface-hover); +} + +.secondary-button:disabled { + cursor: wait; + opacity: 0.65; +} + +.result-card, +.history-card { + padding: 26px; +} + +.result-card { + border-color: rgba(35, 132, 95, 0.22); +} + +.result-heading { + margin-bottom: 20px; +} + +.success-mark { + display: grid; + place-items: center; + width: 35px; + height: 35px; + color: var(--success-ink); + border-radius: 50%; + background: var(--success); + font-weight: 900; +} + +.preview-shell { + padding: 8px; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 11px; + background: var(--preview-bg); +} + +.preview-shell img { + display: block; + width: 100%; + height: auto; + border-radius: 5px; + background: white; +} + +.result-name { + margin: 13px 0 0; + overflow-wrap: anywhere; + color: var(--ink-soft); + font-size: 0.78rem; + text-align: center; +} + +.result-actions { + display: grid; + grid-template-columns: 1fr; + gap: 9px; + margin-top: 18px; +} + +.compact-heading { + align-items: center; + margin-bottom: 18px; +} + +.icon-button { + display: grid; + place-items: center; + width: 34px; + height: 34px; + border: 1px solid var(--line); + border-radius: 50%; + font-size: 1.05rem; +} + +.icon-button:hover { + background: var(--blue-pale); +} + +.history-list { + display: grid; + gap: 8px; +} + +.history-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-width: 0; + padding: 12px; + color: inherit; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--surface-solid); + text-decoration: none; + transition: border-color 150ms ease, background 150ms ease; +} + +.history-item:hover { + border-color: rgba(39, 143, 208, 0.48); + background: var(--blue-pale); +} + +.history-file { + min-width: 0; +} + +.history-file strong, +.history-file small { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.history-file strong { + font-size: 0.8rem; +} + +.history-file small { + margin-top: 3px; + color: var(--ink-soft); + font-size: 0.69rem; +} + +.history-download { + flex: 0 0 auto; + color: var(--blue-dark); + font-size: 1.05rem; +} + +.empty-state, +.retention-note { + margin: 0; + color: var(--ink-soft); + font-size: 0.74rem; + line-height: 1.55; +} + +.empty-state { + padding: 22px 12px; + border: 1px dashed var(--line-dark); + border-radius: 10px; + text-align: center; +} + +.retention-note { + margin-top: 15px; +} + +.privacy-note { + display: flex; + align-items: flex-start; + gap: 13px; + padding: 0 8px; + color: var(--ink-soft); +} + +.privacy-note > span { + display: grid; + place-items: center; + flex: 0 0 auto; + width: 28px; + height: 28px; + border: 1px solid var(--line-dark); + border-radius: 50%; + font-size: 0.82rem; +} + +.privacy-note p { + margin: 3px 0 0; + font-size: 0.73rem; + line-height: 1.6; +} + +footer { + display: flex; + justify-content: space-between; + gap: 20px; + width: min(1180px, calc(100% - 40px)); + margin: 0 auto; + padding: 25px 0 32px; + color: var(--ink-soft); + border-top: 1px solid var(--border-subtle); + font-size: 0.72rem; +} + +body.is-busy .drop-zone, +body.is-busy .advanced, +body.is-busy .field-block { + opacity: 0.66; + pointer-events: none; +} + +@media (max-width: 860px) { + .workspace { + grid-template-columns: 1fr; + } + + .side-column { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .result-card, + .privacy-note { + grid-column: 1 / -1; + } +} + +@media (max-width: 620px) { + .site-header, + main, + footer { + width: min(100% - 24px, 1180px); + } + + .site-header { + flex-wrap: wrap; + gap: 10px; + min-height: 82px; + padding: 14px 0; + } + + .brand img { + width: 165px; + } + + .header-actions { + flex: 1 1 100%; + justify-content: space-between; + gap: 8px; + } + + .theme-option { + min-height: 29px; + padding: 5px 8px; + font-size: 0.66rem; + } + + .local-badge { + padding: 7px 9px; + font-size: 0.68rem; + } + + .intro { + padding: 52px 4px 38px; + } + + h1 { + font-size: clamp(2.5rem, 13vw, 4rem); + } + + .lede { + font-size: 0.98rem; + } + + .form-card, + .result-card, + .history-card { + padding: 22px; + border-radius: 16px; + } + + .drop-zone { + min-height: 205px; + } + + .month-row { + align-items: flex-start; + flex-direction: column; + } + + input[type="month"] { + width: 100%; + } + + .setting-grid, + .setting-grid--three { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .advanced-heading { + align-items: flex-start; + flex-direction: column; + } + + .layout-actions { + justify-content: flex-start; + } + + .save-defaults-row { + align-items: stretch; + flex-direction: column; + } + + .side-column { + grid-template-columns: 1fr; + } + + .result-card, + .privacy-note { + grid-column: auto; + } + + footer { + flex-direction: column; + } +} + +@media (max-width: 420px) { + .card-heading { + align-items: flex-start; + flex-direction: column; + } + + .compact-heading { + align-items: center; + flex-direction: row; + } + + .setting-grid, + .setting-grid--three { + grid-template-columns: 1fr; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + } +} diff --git a/web/theme.js b/web/theme.js new file mode 100644 index 0000000..2fc666e --- /dev/null +++ b/web/theme.js @@ -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; +})();