Calendar conversion

To convert an Excel file to the events.yaml format, the following Python script is useful (TODO: associate with email-trigger or export from system):

#!/usr/bin/env python3
"""
Read the XLSX via pandas and save YAML events to a file.

Usage:
  python xlsx_to_yaml_pandas.py input.xlsx output.yml
  python xlsx_to_yaml_pandas.py input.xlsx output.yml --sheet 0
"""

from __future__ import annotations

import argparse
import re
from datetime import datetime
from typing import Any, Dict

import pandas as pd


DATE_IN = "%d.%m.%Y"


def norm_str(v: Any) -> str:
    if v is None or (isinstance(v, float) and pd.isna(v)):
        return ""
    return str(v).strip()


def norm_hhmm(v: Any) -> str:
    if v is None or (isinstance(v, float) and pd.isna(v)):
        raise ValueError("missing time")

    if isinstance(v, datetime):
        return v.strftime("%H:%M")

    s = norm_str(v)
    m = re.fullmatch(r"(\d{1,2}):(\d{2})", s)
    if m:
        return f"{int(m.group(1)):02d}:{int(m.group(2)):02d}"

    # Excel time as fraction of day
    try:
        fv = float(v)
        if 0 <= fv < 1:
            total_minutes = int(round(fv * 24 * 60))
            hh = total_minutes // 60
            mm = total_minutes % 60
            return f"{hh:02d}:{mm:02d}"
    except Exception:
        pass

    raise ValueError(f"unrecognized time value: {v!r}")


def norm_ymd(date_val: Any) -> str:
    if date_val is None or (isinstance(date_val, float) and pd.isna(date_val)):
        raise ValueError("missing date")

    if isinstance(date_val, datetime):
        return date_val.strftime("%Y-%m-%d")

    s = norm_str(date_val)
    return datetime.strptime(s, DATE_IN).strftime("%Y-%m-%d")


def yaml_escape(s: str) -> str:
    return s.replace("\\", "\\\\").replace('"', '\\"')


def status_to_color(status: str) -> str:
    status_l = status.strip().lower()
    return {
        "planned": "lightyellow",
        "confirmed": "lightgreen",
        "cancelled": "lightcoral",
        "canceled": "lightcoral",
        "tentative": "gold",
    }.get(status_l, "lightyellow")


def build_title(row: Dict[str, Any]) -> str:
    section = norm_str(row.get("Section"))
    module = norm_str(row.get("Module"))
    lecturer = norm_str(row.get("Lecturer, Tutor, Guest lecturer"))

    if section and module:
        title = f"{section}: {module}"
    else:
        title = section or module or "Event"

    if lecturer:
        title = f"{title}{lecturer}"

    return title


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("xlsx", help="Path to input .xlsx")
    ap.add_argument("output", help="Path to output .yml/.yaml")
    ap.add_argument("--sheet", default=0, help="Sheet name or index (default: 0)")
    args = ap.parse_args()

    try:
        sheet: Any = int(args.sheet)
    except ValueError:
        sheet = args.sheet

    df = pd.read_excel(args.xlsx, sheet_name=sheet, dtype=object)

    required = ["Begin date", "Begin time", "End date", "End time"]
    missing = [c for c in required if c not in df.columns]
    if missing:
        raise SystemExit(
            f"Missing required columns: {missing}\nFound columns: {list(df.columns)}"
        )

    df = df.dropna(subset=required, how="any")

    lines: list[str] = []
    for _, r in df.iterrows():
        row = r.to_dict()

        start = f'{norm_ymd(row["Begin date"])} {norm_hhmm(row["Begin time"])}'
        end = f'{norm_ymd(row["End date"])} {norm_hhmm(row["End time"])}'

        title = build_title(row)
        location = norm_str(row.get("Room"))
        status = norm_str(row.get("Status"))
        color = status_to_color(status) if status else "lightyellow"

        lines.append(f'- title: "{yaml_escape(title)}"')
        lines.append(f'  start: "{start}"')
        lines.append(f'  end: "{end}"')
        lines.append(f'  color: "{color}"')
        if location:
            lines.append(f'  location: "{yaml_escape(location)}"')

    yaml_text = "\n".join(lines) + ("\n" if lines else "")
    with open(args.output, "w", encoding="utf-8") as f:
        f.write(yaml_text)

    print(f"Wrote {len(df)} events to {args.output}")


if __name__ == "__main__":
    main()