Skill: davidai-1999/course-schedule-export (version 1.0.0)

--- name: course-schedule-export description: Turn school or university timetables into checked .ics calendars. Use when an agent needs to normalize and verify schedules from xls/xlsx, screenshots, PDFs, OCR text, or pasted timetable text before exporting for Apple Calendar, Google Calendar, or similar apps. Especially use when week-1 alignment, period-time tables, timezone, odd/even week rules, merged periods, or conflicting timetable sources must be resolved safely. --- # Course Schedule Export Convert messy timetable inputs into a checked calendar export. Treat correctness as the primary goal: do not silently infer week ranges, do not skip non-empty cells, and do not deliver a final `.ics` until the normalized schedule has been cross-checked against the source. Prefer a multi-round workflow: normalize, verify, resolve ambiguities, verify again, then export. Do not assume that another school's calendar dates, slot times, timezone, or semester structure apply here. If the user has not provided a week-1 anchor, period-time table, timezone, or any other school-specific calendar setting needed for deterministic export, ask for it before generating the final `.ics`. ## Workflow 1. Gather the authoritative inputs. Require: - at least one primary timetable source - the class-period time table - the week-1 anchor date, ideally the Monday of week 1, if any recurring week-based classes exist Optional: - supplemental screenshots for labs, makeup classes, or weekend sessions - a second timetable source for cross-checking - a previously exported calendar or manifest to diff against If any required item is missing, ask for it explicitly instead of reusing values from a previous school or previous export. 2. Build a normalized schedule manifest before generating calendar output. Use the schema in `references/manifest-schema.md`. Support both of these shapes: - recurring course groups keyed by day + slot + week set - dated course groups keyed by explicit calendar date + slot Capture one course group per distinct combination of: - course name - location - recurrence basis Split a source cell into multiple course groups if it contains multiple courses with different week rules. Preserve source provenance for each course group so later review can tell which source introduced it. 3. Preserve literal meaning from the source. - Expand explicit ranges such as `1-8`. - Expand explicit parity rules such as `9-16 even` or `5-11 odd`. - Preserve explicit calendar dates as dated course groups instead of trying to force them into week math. - If the source text is ambiguous, stop and surface the ambiguity instead of guessing. - Example of unsafe guessing: converting `5,9 odd` into `5-9 odd`. 4. Merge only when the source supports it. Merge consecutive slots into one longer event only when all of these match: - same course name - same teacher if present - same location - same day - same week set Keep them separate otherwise. 5. Run round-1 verification against the source. Before asking follow-up questions or exporting, verify: - every non-empty timetable cell was accounted for - every manifest course group is explained by at least one prepared source entry - no course group was dropped - week-1 Monday maps to the intended calendar week - odd/even handling matches the source text - merged periods really come from adjacent matching slots - supplemental screenshots were either incorporated or explicitly excluded - the manifest contains source references for every derived course group Run `scripts/check_manifest_coverage.py` when you have prepared a source-entry coverage checklist. Treat it as a two-way check: - each prepared source entry must map to at least one manifest course group - each manifest course group must be covered by at least one prepared source entry - if a coverage entry specifies `source_id`, only manifest course groups that cite that source may satisfy it 6. Resolve ambiguities by polling in small rounds. Ask only the minimum unresolved questions for the current blocker set. Good polling pattern: - round 1: timezone and period-time table, plus week-1 date if recurring classes exist - round 2: conflicting week notation or source conflicts - round 3: any remaining merge/split decisions Do not dump all possible questions at once. Batch only related ambiguities that are necessary for the next deterministic step. 7. Run round-2 verification after user answers. Re-check the exact course groups touched by the answers, then re-run a full coverage pass. If a change fixes one ambiguity but creates another inconsistency, stop and surface it. Re-run `scripts/check_manifest_coverage.py` after any manifest edits caused by user answers. 8. Generate the `.ics`. Run `scripts/generate_ics.py` against the normalized manifest. This script must stay standard-library-only so it can run on a typical local machine without extra installs. 9. Report with explicit caveats. In the final response: - give the output path - call out any ambiguous items that required user confirmation - mention any manual interpretations that remain - mention whether the export was checked against one source or multiple sources ## Extraction Rules - Prefer direct extraction from the primary source over OCR from screenshots when both exist. - Treat timetable screenshots as secondary unless they clearly override the primary source. - Support multiple timetable inputs in the same job. Typical source priority: - machine-readable timetable export - timetable PDF or screenshot - supplemental notices for labs, weekends, or makeup sessions - pasted text supplied later in chat - Support both recurring semester schedules and one-off dated sessions. - If two sources disagree, keep both claims visible until one is resolved. Do not silently let the later source overwrite the earlier one. - Preserve a separate note for comments such as attendance reminders or prerequisite warnings; do not encode those notes as fake course meetings. - Keep calendar event summaries short. Put week rules, teacher, and warnings into the description. - Prefer built-in tooling and the Python standard library for export steps. - Treat any parser that needs third-party libraries as optional, not baseline. - If source extraction would require installing packages, first try a no-install path. - If installation is still the best option, ask the user before installing anything and explain why the built-in path is insufficient. ## Ambiguity Rules Stop and ask the user when any of these appear: - punctuation makes a week rule unclear - a cell mixes multiple locations without clear week partitioning - a supplemental image conflicts with the primary timetable - the source gives a week number but not the week-1 anchor date - the source gives period numbers but not start/end times - the timezone is missing and cannot be safely inferred from context - a school-specific holiday, makeup-teaching rule, or semester boundary is needed for correctness - the source mixes week-based classes with explicit calendar dates and the intended precedence is unclear - two timetable sources disagree on the same course group - a class appears in one source but disappears in another - the same course appears in adjacent slots but with slightly different metadata, making merge unsafe Do not ask when the information is explicit in the source and can be normalized deterministically. Do not ask broad catch-all questions such as "anything else I should know?" until the concrete blockers are exhausted. ## Pitfalls Read `references/pitfalls.md` before doing a non-trivial export. Use it as a pre-flight checklist. Common failures to avoid: - dropping a non-empty cell because a previous cell already mentioned the same course - expanding a broken week string into a nicer-looking but unsupported range - forgetting the second half of a cross-slot course - applying one source's period times to another semester without checking - exporting after only one verification pass - losing track of which source introduced a weekend or lab session - baking third-party Python dependencies into the core export path ## Resources - `references/manifest-schema.md` Read this before writing the normalized schedule manifest or reviewing someone else's manifest. - `references/coverage-schema.md` Read this when preparing a source-entry checklist for coverage verification. - `references/pitfalls.md` Read this before extraction and again before final export. - `scripts/generate_ics.py` Use this to turn a normalized manifest JSON file into a deterministic `.ics` file. - `scripts/check_manifest_coverage.py` Use this to enforce two-way coverage between prepared source entries and manifest course groups. ## Minimal Deliverable If the user only wants guidance, do not generate files. Give them: - the missing inputs you still need - the exact ambiguity list - the next deterministic step If the user wants the export completed, do the normalization and generate the `.ics` instead of only describing how.

path: agents/openai.yaml
content: interface:
  display_name: "Course Schedule Export"
  short_description: "Convert messy timetables into verified .ics calendar exports"
  default_prompt: "Use $course-schedule-export to normalize a timetable, verify coverage against the source, and generate a checked .ics file for Apple Calendar, Google Calendar, or similar apps."


path: references/coverage-schema.md
content: # Coverage Schema

Use a separate JSON file to verify that each prepared source entry was mapped into the manifest.

```json
{
  "source_entries": [
    {
      "id": "cell-r3c4",
      "source_id": "main-xls",
      "description": "Compiler Principles in Tuesday 3-4 slot",
      "matches": [
        {
          "name": "Compiler Principles",
          "mode": "recurring",
          "day": "tuesday",
          "slot": "3-4",
          "weeks": [1, 2, 3, 4, 5]
        }
      ]
    },
    {
      "id": "weekend-image-1",
      "source_id": "weekend-image",
      "description": "Weekend lab announcement",
      "matches": [
        {
          "name": "Weekend Lab",
          "mode": "dated",
          "dates": ["2026-04-18", "2026-04-25"],
          "slot": "5-8"
        }
      ]
    }
  ]
}
```

## Model

- `source_entries`
  One item per raw source unit you want to account for.
  Typical units:
  - a non-empty timetable cell
  - a supplemental screenshot item
  - a pasted text block that introduces a concrete class

- `id`
  Stable identifier for the source entry.

- `source_id`
  Optional source id from the manifest `sources` list.
  When present, the checker only accepts manifest course groups whose `sources` list includes this `source_id`.

- `description`
  Short human-facing description used in failure output.

- `matches`
  Required list of expected manifest course-group shapes.
  Use more than one match when a single source entry intentionally splits into multiple manifest course groups.

## Match Rules

Each item in `matches` is a partial manifest-course matcher.

Supported fields:
- `name`
- `location`
- `mode`
- `day`
- `slot`
- `weeks`
- `date`
- `dates`

Matching is exact on any field you include.
Do not include fields you do not want to constrain.
If `source_id` is present on the source entry, source provenance is also checked in addition to the field matcher.

## Recommended Use

Prepare the coverage checklist after you normalize the raw sources but before final export.
The checker enforces both directions:
- every source entry must match at least one manifest course group
- every manifest course group must be covered by at least one source entry
Then run:

```bash
python3 scripts/check_manifest_coverage.py manifest.json coverage.json
```

Use failures as a prompt to re-check the relevant source entry before exporting.
Typical failure labels:
- `entry`: the coverage checklist itself is incomplete or the matcher did not hit any manifest course group
- `provenance`: the matcher hit a course shape, but the course cites different sources than the source entry claimed
- `sources`: manifest course-source metadata is missing or references unknown source ids
- `uncovered`: a manifest course group exists without any source entry explaining it


path: references/manifest-schema.md
content: # Manifest Schema

Use a JSON file with this shape before generating `.ics`.

```json
{
  "calendar_name": "2026 Spring Schedule",
  "timezone": "Asia/Shanghai",
  "week1_monday": "2026-03-09",
  "sources": [
    {
      "id": "main-xls",
      "kind": "xls",
      "path": "/path/to/timetable.xls",
      "role": "primary"
    },
    {
      "id": "weekend-image",
      "kind": "image",
      "path": "/path/to/weekend.png",
      "role": "supplemental"
    }
  ],
  "slots": {
    "1-2": {"start": "08:00", "end": "09:45"},
    "3-4": {"start": "10:00", "end": "11:45"},
    "5-6": {"start": "13:45", "end": "15:30"},
    "7-8": {"start": "15:45", "end": "17:30"},
    "9-10": {"start": "18:30", "end": "20:15"},
    "11-12": {"start": "20:30", "end": "22:15"},
    "5-8": {"start": "13:45", "end": "17:30"},
    "9-12": {"start": "18:30", "end": "22:15"}
  },
  "courses": [
    {
      "name": "Compiler Principles",
      "teacher": "Teacher Name",
      "location": "Building 101",
      "mode": "recurring",
      "day": "tuesday",
      "slot": "3-4",
      "weeks": [1, 2, 3, 4, 5],
      "note": "Optional note shown in DESCRIPTION",
      "sources": ["main-xls"],
      "status": "confirmed"
    },
    {
      "name": "Makeup Lab",
      "teacher": "Teacher Name",
      "location": "Building 202",
      "mode": "dated",
      "dates": ["2026-04-18", "2026-04-25"],
      "slot": "5-8",
      "sources": ["weekend-image"],
      "status": "confirmed"
    }
  ]
}
```

## Field Rules

- `calendar_name`
  Human-facing calendar title written into the `.ics`.

- `timezone`
  IANA timezone such as `Asia/Shanghai`.
  Do not guess from a different school or past export if the current user has not provided it.

- `week1_monday`
  ISO date for the Monday of week 1.
  Do not infer from semester start prose unless the mapping to week 1 is explicit.
  Required only when the manifest contains recurring course groups.

- `slots`
  Mapping from normalized slot keys to `HH:MM` start and end times.
  Treat these as school-specific. If the current timetable does not provide them and the user has not given them, ask first.

- `sources`
  Optional but strongly recommended. Track every source used in extraction, including supplemental screenshots and pasted text.

- `courses`
  One object per normalized course group.
  A course group can be either recurring or dated.

## Course Rules

- `name`
  Required.

- `teacher`
  Optional string. Use `""` if unavailable.

- `location`
  Required. Use a stable string; do not encode week rules here.

- `day`
  Required for recurring course groups. Lowercase day name:
  - `monday`
  - `tuesday`
  - `wednesday`
  - `thursday`
  - `friday`
  - `saturday`
  - `sunday`

- `slot`
  Required slot key present in `slots`.

- `weeks`
  Required for recurring course groups. List of explicit integers. Expand ranges before writing the manifest.

- `mode`
  Optional but recommended:
  - `recurring`
  - `dated`
  If omitted, infer `recurring` when `weeks` and `day` are present, or `dated` when `date` or `dates` is present.

- `date`
  Optional for dated course groups when there is exactly one explicit date. ISO date such as `2026-04-18`.

- `dates`
  Optional for dated course groups. Use this when one course group applies to multiple explicit dates.
  At least one of `date` or `dates` must be present for dated course groups.

- `note`
  Optional. Use for attendance warnings, source caveats, or extra context.

- `sources`
  Recommended list of source ids that support this course group. Use this to trace conflicts and review late edits.

- `status`
  Optional but recommended:
  - `confirmed`
  - `needs_confirmation`
  - `derived`
  Mark anything ambiguous as `needs_confirmation` until the user answers.

## Source Rules

Each source entry should include:
- `id`
  Stable local identifier used by course objects.
- `kind`
  Example values: `xls`, `xlsx`, `pdf`, `image`, `ocr-text`, `chat-text`.
- `path`
  Filesystem path when applicable.
- `role`
  Example values: `primary`, `supplemental`, `cross-check`.

## Verification Checklist

Round 1:
- every non-empty timetable cell maps to at least one course object
- merged periods only combine adjacent slots with identical metadata
- odd/even weeks are expanded explicitly
- week lists are sorted and deduplicated
- explicit calendar-date sessions stay explicit instead of being forced into week-based recurrence
- supplemental screenshots are either encoded as extra course objects or explicitly ignored
- the week-1 Monday aligns with the user's stated semester baseline
- every course object has either a source reference or an explicit explanation for why it was derived

Round 2, after ambiguity polling:
- any `needs_confirmation` item was resolved or remains explicitly blocked
- cross-source conflicts are either resolved or carried forward as caveats
- the final export excludes unresolved guesses


path: references/pitfalls.md
content: # Pitfalls

Use this file as a pre-flight and pre-export checklist.

## High-Risk Failure Modes

### 1. Missing a non-empty cell

Symptom:
- a course appears in the raw timetable but never shows up in the final calendar

Common cause:
- assuming a repeated course name means the later cell is redundant

Mitigation:
- compare final course groups against every non-empty source cell
- keep a checklist keyed by source cell or source note

### 2. Over-interpreting ugly week notation

Symptom:
- a cleaner-looking week range appears in the calendar than in the source

Common cause:
- converting punctuation noise into a guessed range

Mitigation:
- preserve literal meaning unless the source is truly explicit
- if punctuation can mean more than one thing, mark `needs_confirmation`

### 3. Losing the second half of a merged class

Symptom:
- a class that spans adjacent slots appears as only one shorter event

Common cause:
- reading only one source cell, or merging without checking the adjacent cell

Mitigation:
- check adjacent slot cells for same-course continuations
- merge only after metadata equality is confirmed

### 4. Wrong week-1 baseline

Symptom:
- all generated dates are shifted by a week

Common cause:
- using the semester start date instead of the Monday of week 1

Mitigation:
- explicitly ask for the week-1 anchor if not given
- restate the interpreted baseline before export
- do not require week-1 at all when the manifest is entirely date-based

### 5. Wrong period-time table

Symptom:
- events have correct day and week but wrong clock times

Common cause:
- reusing a previous semester's period table

Mitigation:
- require a period-time table for the current export unless already explicit
- record the exact slot mapping in the manifest

### 5.5. Forcing dated sessions into a week-based pattern

Symptom:
- a one-off makeup class or special session is exported on the wrong dates or repeats unexpectedly

Common cause:
- trying to convert an explicit calendar date into week/day recurrence

Mitigation:
- preserve explicit dates as dated course groups
- only use week-based recurrence when the source itself is week-based

### 6. Supplemental source silently overwriting the main source

Symptom:
- weekend or lab sessions disappear or replace the main timetable without explanation

Common cause:
- treating the latest message as authoritative without preserving provenance

Mitigation:
- keep per-course source references
- surface source conflicts instead of auto-overwriting

### 7. Building the workflow around non-portable dependencies

Symptom:
- the export process only works on the author's machine

Common cause:
- requiring packages such as spreadsheet parsers or YAML helpers for the main happy path

Mitigation:
- keep the final `.ics` generation path standard-library-only
- treat third-party parsing helpers as optional accelerators
- ask before installing packages on the user's machine
- document clearly which step needs the dependency and why

## Recommended Check Order

1. Confirm week-1 Monday and slot times.
2. Normalize all non-empty source cells.
3. Compare the normalized manifest to the primary source.
4. Add supplemental sources and mark conflicts.
5. Ask only the minimum ambiguity questions.
6. Re-run coverage checks.
7. Generate `.ics`.
8. Spot-check representative classes:
   - one normal weekly lecture
   - one odd/even or parity-based class
   - one merged cross-slot class
   - one supplemental or weekend class


path: scripts/check_manifest_coverage.py
content: #!/usr/bin/env python3
from __future__ import annotations

import argparse
import json
from pathlib import Path


MATCH_FIELDS = {"name", "location", "mode", "day", "slot", "weeks", "date", "dates"}


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Check that prepared source entries are covered by the normalized schedule manifest."
    )
    parser.add_argument("manifest", help="Path to schedule manifest JSON")
    parser.add_argument("coverage", help="Path to coverage checklist JSON")
    return parser.parse_args()


def load_json(path: str) -> dict:
    return json.loads(Path(path).expanduser().resolve().read_text(encoding="utf-8"))


def normalize_course(course: dict) -> dict:
    normalized = dict(course)
    mode = normalized.get("mode")
    if mode is None:
        if "date" in normalized or "dates" in normalized:
            mode = "dated"
        elif "day" in normalized and "weeks" in normalized:
            mode = "recurring"
    if mode is not None:
        normalized["mode"] = mode
    if "weeks" in normalized:
        normalized["weeks"] = sorted(normalized["weeks"])
    if "dates" in normalized:
        normalized["dates"] = sorted(normalized["dates"])
    return normalized


def match_course(course: dict, matcher: dict) -> bool:
    for key, value in matcher.items():
        if key not in MATCH_FIELDS:
            raise ValueError(f"Unsupported match field '{key}'")
        candidate = course.get(key)
        if key in {"weeks", "dates"} and candidate is not None:
            candidate = sorted(candidate)
            value = sorted(value)
        if candidate != value:
            return False
    return True


def course_label(course: dict, index: int) -> str:
    parts = [
        course.get("name", f"<course-{index}>"),
        course.get("mode", "<unknown-mode>"),
        course.get("slot", "<unknown-slot>"),
    ]
    if course.get("mode") == "recurring":
        parts.insert(2, course.get("day", "<unknown-day>"))
    return " | ".join(parts)


def print_failures(label: str, lines: list[str]) -> None:
    for line in lines:
        print(f"FAIL [{label}] {line}")


def main() -> None:
    args = parse_args()
    manifest = load_json(args.manifest)
    coverage = load_json(args.coverage)

    courses = [normalize_course(course) for course in manifest.get("courses", [])]
    source_ids = {source["id"] for source in manifest.get("sources", []) if "id" in source}
    entries = coverage.get("source_entries")
    if not isinstance(entries, list) or not entries:
        raise ValueError("coverage file must contain a non-empty 'source_entries' list")

    entry_failures: list[str] = []
    provenance_failures: list[str] = []
    source_failures: list[str] = []
    uncovered_courses: list[str] = []
    covered_course_indexes: set[int] = set()

    for idx, entry in enumerate(entries, start=1):
        entry_id = entry.get("id", f"entry-{idx}")
        source_id = entry.get("source_id")
        description = entry.get("description", "")
        matchers = entry.get("matches")
        if not isinstance(matchers, list) or not matchers:
            entry_failures.append(f"{entry_id}: missing non-empty matches list")
            continue
        if source_id is not None and source_id not in source_ids:
            entry_failures.append(f"{entry_id}: unknown source_id '{source_id}'")
            continue
        for matcher_index, matcher in enumerate(matchers, start=1):
            if not isinstance(matcher, dict) or not matcher:
                entry_failures.append(f"{entry_id}: matcher {matcher_index} is empty or invalid")
                continue
            field_matched_indexes = []
            matched_indexes = []
            for course_index, course in enumerate(courses, start=1):
                if not match_course(course, matcher):
                    continue
                field_matched_indexes.append(course_index)
                if source_id is not None and source_id not in course.get("sources", []):
                    continue
                matched_indexes.append(course_index)
            if not matched_indexes:
                source_hint = f", source_id={source_id!r}" if source_id is not None else ""
                matcher_json = json.dumps(matcher, ensure_ascii=False, sort_keys=True)
                if source_id is not None and field_matched_indexes:
                    labels = ", ".join(
                        course_label(courses[course_index - 1], course_index)
                        for course_index in field_matched_indexes
                    )
                    provenance_failures.append(
                        f"{entry_id}: matcher {matcher_index} matched fields but failed source provenance | description={description!r}, source_id={source_id!r} | matcher={matcher_json} | field_matches={labels}"
                    )
                else:
                    entry_failures.append(
                        f"{entry_id}: matcher {matcher_index} not covered | description={description!r}{source_hint} | matcher={matcher_json}"
                    )
                continue
            covered_course_indexes.update(matched_indexes)

    for course in courses:
        course_sources = course.get("sources")
        if course_sources is None:
            source_failures.append(f"course without sources: {course.get('name', '<unknown>')}")
        elif not isinstance(course_sources, list) or not course_sources:
            source_failures.append(f"course with empty sources: {course.get('name', '<unknown>')}")
        else:
            for source_id in course_sources:
                if source_id not in source_ids:
                    source_failures.append(
                        f"course {course.get('name', '<unknown>')} references unknown source_id '{source_id}'"
                    )

    for course_index, course in enumerate(courses, start=1):
        if course_index not in covered_course_indexes:
            uncovered_courses.append(
                f"manifest course not covered by any source entry: {course_label(course, course_index)}"
            )

    if entry_failures or provenance_failures or source_failures or uncovered_courses:
        print_failures("entry", entry_failures)
        print_failures("provenance", provenance_failures)
        print_failures("sources", source_failures)
        print_failures("uncovered", uncovered_courses)
        print(
            "SUMMARY "
            f"entry_failures={len(entry_failures)} "
            f"provenance_failures={len(provenance_failures)} "
            f"source_failures={len(source_failures)} "
            f"uncovered_courses={len(uncovered_courses)}"
        )
        raise SystemExit(1)

    print(f"OK source_entries={len(entries)} courses={len(courses)}")


if __name__ == "__main__":
    main()


path: scripts/generate_ics.py
content: #!/usr/bin/env python3
from __future__ import annotations

# Keep this script standard-library-only. The skill may use optional parsers
# during extraction, but the final .ics generation path should not require
# third-party installs.

import argparse
import hashlib
import json
from datetime import UTC, date, datetime, time, timedelta
from pathlib import Path


DAY_TO_INDEX = {
    "monday": 0,
    "tuesday": 1,
    "wednesday": 2,
    "thursday": 3,
    "friday": 4,
    "saturday": 5,
    "sunday": 6,
}


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Generate an .ics file from a normalized schedule manifest.")
    parser.add_argument("manifest", help="Path to schedule manifest JSON")
    parser.add_argument("-o", "--output", help="Output .ics path")
    return parser.parse_args()


def fold_ics_line(line: str) -> str:
    if len(line) <= 75:
        return line
    parts: list[str] = []
    while len(line) > 75:
        parts.append(line[:75])
        line = " " + line[75:]
    parts.append(line)
    return "\r\n".join(parts)


def esc(text: str) -> str:
    return (
        text.replace("\\", "\\\\")
        .replace(";", r"\;")
        .replace(",", r"\,")
        .replace("\n", r"\n")
    )


def load_manifest(path: Path) -> dict:
    data = json.loads(path.read_text(encoding="utf-8"))
    required = ["calendar_name", "timezone", "slots", "courses"]
    missing = [key for key in required if key not in data]
    if missing:
        raise ValueError(f"Missing top-level keys: {', '.join(missing)}")
    if not isinstance(data["courses"], list) or not data["courses"]:
        raise ValueError("'courses' must be a non-empty list")
    return data


def validate_course(course: dict, slots: dict) -> None:
    required = ["name", "location", "slot"]
    missing = [key for key in required if key not in course]
    if missing:
        raise ValueError(f"Course missing keys: {', '.join(missing)} | {course}")
    if course["slot"] not in slots:
        raise ValueError(f"Unknown slot '{course['slot']}' in course {course['name']}")
    mode = course.get("mode")
    if mode is None:
        if "date" in course or "dates" in course:
            mode = "dated"
        elif "day" in course and "weeks" in course:
            mode = "recurring"
        else:
            raise ValueError(f"Cannot infer mode for course {course['name']}: {course}")
    if mode not in {"recurring", "dated"}:
        raise ValueError(f"Invalid mode '{mode}' in course {course['name']}")
    if mode == "recurring":
        if "day" not in course or "weeks" not in course:
            raise ValueError(f"Recurring course missing day/weeks: {course}")
        if course["day"] not in DAY_TO_INDEX:
            raise ValueError(f"Invalid day '{course['day']}' in course {course['name']}")
        weeks = course["weeks"]
        if not isinstance(weeks, list) or not weeks or any((not isinstance(w, int) or w <= 0) for w in weeks):
            raise ValueError(f"Invalid weeks for course {course['name']}: {weeks}")
    else:
        if "date" not in course and "dates" not in course:
            raise ValueError(f"Dated course missing date/dates: {course}")
        if "date" in course:
            date.fromisoformat(course["date"])
        if "dates" in course:
            if not isinstance(course["dates"], list) or not course["dates"]:
                raise ValueError(f"Invalid dates for course {course['name']}: {course['dates']}")
            for explicit_date in course["dates"]:
                date.fromisoformat(explicit_date)


def uid_for(course: dict, start_dt: datetime) -> str:
    mode = course.get("mode")
    if mode is None:
        mode = "dated" if ("date" in course or "dates" in course) else "recurring"
    raw = json.dumps(
        {
            "name": course["name"],
            "location": course["location"],
            "mode": mode,
            "day": course.get("day"),
            "date": course.get("date"),
            "dates": course.get("dates"),
            "slot": course["slot"],
            "start": start_dt.isoformat(),
        },
        ensure_ascii=False,
        sort_keys=True,
    ).encode("utf-8")
    return f"{hashlib.sha1(raw).hexdigest()}@local.codex"


def build_event(course: dict, start_dt: datetime, end_dt: datetime, timezone: str) -> str:
    stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
    description_lines = []
    teacher = course.get("teacher", "")
    if teacher:
        description_lines.append(f"Teacher: {teacher}")
    mode = course.get("mode")
    if mode is None:
        mode = "dated" if ("date" in course or "dates" in course) else "recurring"
    if mode == "dated":
        if "dates" in course:
            description_lines.append(f"Dates: {','.join(course['dates'])}")
        else:
            description_lines.append(f"Date: {course['date']}")
    else:
        description_lines.append(f"Weeks: {','.join(str(w) for w in course['weeks'])}")
    note = course.get("note", "")
    if note:
        description_lines.append(note)

    fields = [
        "BEGIN:VEVENT",
        f"UID:{uid_for(course, start_dt)}",
        f"DTSTAMP:{stamp}",
        f"DTSTART;TZID={timezone}:{start_dt.strftime('%Y%m%dT%H%M%S')}",
        f"DTEND;TZID={timezone}:{end_dt.strftime('%Y%m%dT%H%M%S')}",
        f"SUMMARY:{esc(course['name'])}",
        f"LOCATION:{esc(course['location'])}",
        f"DESCRIPTION:{esc(chr(10).join(description_lines))}",
        "END:VEVENT",
    ]
    return "\r\n".join(fold_ics_line(field) for field in fields)


def main() -> None:
    args = parse_args()
    manifest_path = Path(args.manifest).expanduser().resolve()
    manifest = load_manifest(manifest_path)

    calendar_name = manifest["calendar_name"]
    timezone = manifest["timezone"]
    slots = manifest["slots"]
    needs_week1 = any(
        (course.get("mode") == "recurring")
        or ("mode" not in course and "day" in course and "weeks" in course)
        for course in manifest["courses"]
    )
    week1_monday = None
    if needs_week1:
        if "week1_monday" not in manifest:
            raise ValueError("Manifest needs week1_monday because it contains recurring courses")
        week1_monday = date.fromisoformat(manifest["week1_monday"])
        if week1_monday.weekday() != 0:
            raise ValueError(f"week1_monday must be a Monday, got {week1_monday.isoformat()}")

    events: list[str] = []
    for course in manifest["courses"]:
        validate_course(course, slots)
        slot = slots[course["slot"]]
        start_clock = time.fromisoformat(slot["start"])
        end_clock = time.fromisoformat(slot["end"])
        mode = course.get("mode")
        if mode is None:
            mode = "dated" if ("date" in course or "dates" in course) else "recurring"
        if mode == "dated":
            if "dates" in course:
                event_dates = [date.fromisoformat(explicit_date) for explicit_date in course["dates"]]
            else:
                event_dates = [date.fromisoformat(course["date"])]
        else:
            day_offset = DAY_TO_INDEX[course["day"]]
            event_dates = [
                week1_monday + timedelta(days=(week - 1) * 7 + day_offset)
                for week in sorted(set(course["weeks"]))
            ]
        for event_date in event_dates:
            start_dt = datetime.combine(event_date, start_clock)
            end_dt = datetime.combine(event_date, end_clock)
            events.append(build_event(course, start_dt, end_dt, timezone))

    lines = [
        "BEGIN:VCALENDAR",
        "VERSION:2.0",
        "CALSCALE:GREGORIAN",
        "METHOD:PUBLISH",
        f"X-WR-CALNAME:{esc(calendar_name)}",
        f"X-WR-TIMEZONE:{timezone}",
        *events,
        "END:VCALENDAR",
        "",
    ]

    output = Path(args.output).expanduser().resolve() if args.output else manifest_path.with_suffix(".ics")
    output.write_text("\r\n".join(lines), encoding="utf-8")
    print(output)
    print(f"events={len(events)}")


if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)