首页 全部提示词 Agent Skill

Calendar ICS Validator

技能 @f 25,813 字符 在 prompts.chat 查看 译文有问题?

这条还没有中文译文,当前显示英文原文。译文由定时任务补齐,也可以到仓库提 issue 催更。

English original

Calendar ICS Validator

You help people find and fix problems in iCalendar (.ics) files exported from apps, generated by code, or attached to invites. You check structure, required properties, date-time values, time zones, and recurrence rules, then explain each problem in plain words with the exact fix. You follow RFC 5545 and note where popular clients (Google Calendar, Outlook, Apple Calendar) are stricter or looser.

Files in this skill

  • scripts/check_ics.py - parses an .ics file (with line unfolding) and reports errors and warnings with line numbers (Python 3 standard library only)
  • references/ics-fields.md - components, required and common properties, and value formats
  • references/recurrence-rules.md - RRULE parts, valid values, EXDATE/RDATE, and common recurrence bugs
  • templates/fix-checklist.md - the checklist and report shape to return
  • examples/sample.ics - a small valid calendar with a time zone, a one-off event, and a weekly recurring event

Workflow

1. Get the file

Ask for the .ics content or path, the app that produced it, the app that fails to import it, and the symptom (import error, wrong time, missing occurrences, duplicates).

2. Run the checker

python3 scripts/check_ics.py path/to/calendar.ics
python3 scripts/check_ics.py --json path/to/calendar.ics
python3 scripts/check_ics.py --self-test

Exit code 0 means no errors (warnings may remain), 1 means errors were found.

3. Interpret

  • Errors break imports or violate RFC 5545 (missing UID, DTEND before DTSTART, unbalanced BEGIN/END, invalid RRULE).
  • Warnings often cause wrong behavior in some clients (floating times, TZID without VTIMEZONE, lines over 75 octets, LF-only line endings). Use references/ics-fields.md and references/recurrence-rules.md to explain each finding.

4. Diagnose the symptom

  • Wrong time: check for floating times (no Z, no TZID), mismatched TZID names, or UTC Z added to a local time.
  • Missing or extra occurrences: check RRULE UNTIL vs DTSTART type, COUNT, BYDAY, and EXDATE format matching DTSTART.
  • Duplicates on re-import: check that UID is stable and SEQUENCE increases on updates.

5. Report

Fill templates/fix-checklist.md: summary, findings table (severity, line, property, problem, fix), corrected snippet, and re-test steps.

Rules

  • Quote the exact offending line and give a corrected line.
  • Do not silently change event times; if a time zone must change, say so and ask.
  • Keep UID values unchanged when fixing, unless they are missing or duplicated.
  • Never include private attendee emails in examples you write back; replace them with placeholders. FILE:references/ics-fields.md

iCalendar Fields Reference (RFC 5545 essentials)

Structure

BEGIN:VCALENDAR
  VERSION:2.0            (required)
  PRODID:-//Org//App//EN (required)
  CALSCALE:GREGORIAN     (optional, default)
  METHOD:PUBLISH         (optional; REQUEST/REPLY/CANCEL for invites)
  BEGIN:VTIMEZONE ... END:VTIMEZONE   (one per TZID used)
  BEGIN:VEVENT ... END:VEVENT
  BEGIN:VTODO ... END:VTODO
END:VCALENDAR

Every BEGIN:X needs a matching END:X in the right order. VALARM lives inside VEVENT or VTODO.

VEVENT properties

Property Required Notes
UID yes Globally unique and stable across updates (e.g. uuid@yourdomain).
DTSTAMP yes When this object was created/sent, in UTC: 20261015T070000Z.
DTSTART yes* Required unless METHOD is set and the client allows it; always include it.
DTEND no Must be later than DTSTART. Exclusive end.
DURATION no e.g. PT1H30M, P1D. Not allowed together with DTEND.
SUMMARY no Title shown in calendars.
DESCRIPTION, LOCATION no Escape , ; \ and newlines as \, \; \\ \n.
RRULE no Recurrence rule; see recurrence-rules.md.
EXDATE / RDATE no Excluded / extra dates; same value type and TZID as DTSTART.
SEQUENCE no Integer; increment on each significant update.
STATUS no TENTATIVE, CONFIRMED, CANCELLED.
ORGANIZER / ATTENDEE no mailto: URIs with parameters like CN= and PARTSTAT=.

Date and time values

Form Example Meaning
DATE DTSTART;VALUE=DATE:20261020 All-day; DTEND is the next day (exclusive).
UTC DATE-TIME DTSTART:20261020T090000Z Absolute time in UTC.
Local with TZID DTSTART;TZID=Europe/Istanbul:20261020T120000 Wall time in that zone; needs a matching VTIMEZONE for strict clients.
Floating DTSTART:20261020T120000 No zone; shows the same wall time everywhere. Usually a bug for meetings.

Format: YYYYMMDD or YYYYMMDDTHHMMSS with optional Z. Hours 00-23, minutes and seconds 00-59 (60 allowed for leap seconds).

Line rules

  • Lines end with CRLF (\r\n).
  • Lines longer than 75 octets must be folded: insert CRLF followed by one space or tab; parsers remove CRLF+whitespace when unfolding.
  • Property names and parameter names are case-insensitive; values may be case-sensitive.

DURATION format

[+/-]P[n]W or [+/-]P[n]DT[n]H[n]M[n]S, e.g. P1W, PT45M, P1DT12H. FILE:references/recurrence-rules.md

Recurrence Rules (RRULE) Reference

Syntax: RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE;UNTIL=20261231T235959Z

Parts

Part Values Notes
FREQ SECONDLY, MINUTELY, HOURLY, DAILY, WEEKLY, MONTHLY, YEARLY Required, exactly once.
INTERVAL positive integer Default 1.
COUNT positive integer Number of occurrences, including the first.
UNTIL DATE or DATE-TIME Inclusive end. Must not be used with COUNT.
BYDAY MO TU WE TH FR SA SU, optional ordinal (1MO, -1FR) Ordinals only make sense with MONTHLY or YEARLY.
BYMONTHDAY 1..31 or -31..-1 -1 = last day of month.
BYMONTH 1..12
BYYEARDAY 1..366 or negative
BYWEEKNO 1..53 or negative YEARLY only.
BYHOUR / BYMINUTE / BYSECOND 0..23 / 0..59 / 0..60
BYSETPOS 1..366 or negative e.g. last weekday of month: BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1.
WKST MO..SU Week start, default MO.

UNTIL type rule

  • If DTSTART is a DATE, UNTIL must be a DATE.
  • If DTSTART has a TZID or is UTC, UNTIL must be UTC (...Z).
  • If DTSTART is floating, UNTIL must also be floating.

EXDATE and RDATE

  • Must use the same value type and TZID as DTSTART, otherwise clients ignore them.
  • Example: EXDATE;TZID=Europe/Istanbul:20261103T100000

Common bugs

  1. Both COUNT and UNTIL present (invalid).
  2. UNTIL as a local time when DTSTART has a TZID (should be UTC).
  3. BYDAY ordinals with WEEKLY (FREQ=WEEKLY;BYDAY=1MO) - meaningless.
  4. DTSTART that does not match the pattern (DTSTART on Tuesday with BYDAY=MO): RFC says DTSTART still counts as the first occurrence, which surprises users.
  5. EXDATE in UTC while DTSTART uses TZID: exceptions do not match.
  6. Lowercase or misspelled keys (freq=weekly, BYDAYS). Values should be uppercase.
  7. Daylight saving: a recurring 09:00 meeting must use TZID, not UTC, or it shifts by an hour twice a year.

Examples

  • Every weekday at 09:00: RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR
  • First Monday monthly, 6 times: RRULE:FREQ=MONTHLY;BYDAY=1MO;COUNT=6
  • Last day of each month: RRULE:FREQ=MONTHLY;BYMONTHDAY=-1
  • Every other Friday until year end: RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=FR;UNTIL=20261231T210000Z FILE:templates/fix-checklist.md

ICS Fix Checklist and Report

Summary

  • File: <name.ics>
  • Produced by: <app or script> | Fails in: <client>
  • Symptom: <import error / wrong time / missing occurrences / duplicates>
  • Result: <N errors, M warnings>

Checklist

  • File starts with BEGIN:VCALENDAR and ends with END:VCALENDAR
  • All BEGIN/END pairs balanced and correctly nested
  • VERSION:2.0 and PRODID present once in VCALENDAR
  • Each VEVENT has UID, DTSTAMP, DTSTART
  • UIDs are unique within the file and stable across updates
  • DTEND later than DTSTART, and not combined with DURATION
  • Date-time values valid (YYYYMMDD / YYYYMMDDTHHMMSS[Z])
  • All-day events use VALUE=DATE on both DTSTART and DTEND
  • Every TZID used has a VTIMEZONE (or an IANA name the target client accepts)
  • No unintended floating times
  • RRULE has FREQ; no COUNT with UNTIL; UNTIL type matches DTSTART
  • EXDATE/RDATE match DTSTART value type and TZID
  • Text values escape , ; \ and newlines
  • CRLF line endings; lines over 75 octets folded
  • SEQUENCE increments when an existing event is updated

Findings

# Severity Line Property Problem Fix
1 error

Corrected snippet

<fixed lines>

Re-test

  1. Run python3 scripts/check_ics.py <file> and confirm 0 errors.
  2. Import into a test calendar in the failing client.
  3. Check the first occurrence, one middle occurrence, and the last occurrence times. FILE:examples/sample.ics BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Example Co//Team Calendar 1.0//EN CALSCALE:GREGORIAN METHOD:PUBLISH BEGIN:VTIMEZONE TZID:Europe/Istanbul BEGIN:STANDARD DTSTART:19700101T000000 TZOFFSETFROM:+0300 TZOFFSETTO:+0300 TZNAME:+03 END:STANDARD END:VTIMEZONE BEGIN:VEVENT UID:kickoff-20261020@example.com DTSTAMP:20261015T070000Z DTSTART;TZID=Europe/Istanbul:20261020T100000 DTEND;TZID=Europe/Istanbul:20261020T113000 SUMMARY:Project kickoff LOCATION:Room 4, Second Floor DESCRIPTION:Agenda: goals, roles, and first milestones.\nBring lapt ops. STATUS:CONFIRMED SEQUENCE:0 END:VEVENT BEGIN:VEVENT UID:weekly-sync-2026@example.com DTSTAMP:20261015T070000Z DTSTART;TZID=Europe/Istanbul:20261022T093000 DURATION:PT30M RRULE:FREQ=WEEKLY;BYDAY=TH;UNTIL=20261231T063000Z EXDATE;TZID=Europe/Istanbul:20261126T093000 SUMMARY:Weekly team sync BEGIN:VALARM ACTION:DISPLAY DESCRIPTION:Weekly team sync in 10 minutes TRIGGER:-PT10M END:VALARM END:VEVENT BEGIN:VEVENT UID:offsite-2026@example.com DTSTAMP:20261015T070000Z DTSTART;VALUE=DATE:20261106 DTEND;VALUE=DATE:20261108 SUMMARY:Team offsite (all day) END:VEVENT END:VCALENDAR FILE:scripts/check_ics.py

#!/usr/bin/env python3 """Validate an iCalendar (.ics) file against RFC 5545 basics.

Usage: python3 check_ics.py FILE.ics [--json] python3 check_ics.py --self-test

Exit codes: 0 = no errors, 1 = errors found, 2 = usage / read error. Python 3 standard library only. """ import argparse import datetime as dt import json import re import sys

DATE_RE = re.compile(r"^\d{8}$") DATETIME_RE = re.compile(r"^\d{8}T\d{6}Z?$") DURATION_RE = re.compile(r"^[+-]?P(\d+W|(\d+D)?(T(\d+H)?(\d+M)?(\d+S)?)?)$") FREQS = {"SECONDLY", "MINUTELY", "HOURLY", "DAILY", "WEEKLY", "MONTHLY", "YEARLY"} DAYS = {"MO", "TU", "WE", "TH", "FR", "SA", "SU"} RRULE_KEYS = {"FREQ", "UNTIL", "COUNT", "INTERVAL", "BYSECOND", "BYMINUTE", "BYHOUR", "BYDAY", "BYMONTHDAY", "BYYEARDAY", "BYWEEKNO", "BYMONTH", "BYSETPOS", "WKST"} EVENT_LIKE = {"VEVENT", "VTODO", "VJOURNAL"}

class Report: def init(self): self.items = []

def add(self, severity, line, prop, message):
    self.items.append({"severity": severity, "line": line, "property": prop, "message": message})

def error(self, line, prop, msg):
    self.add("error", line, prop, msg)

def warn(self, line, prop, msg):
    self.add("warning", line, prop, msg)

@property
def errors(self):
    return [i for i in self.items if i["severity"] == "error"]

@property
def warnings(self):
    return [i for i in self.items if i["severity"] == "warning"]

def unfold(raw, rep): """Return list of (line_no, logical_line). Checks line endings and length.""" if "\r\n" not in raw and "\n" in raw: rep.warn(1, None, "Lines end with LF only; RFC 5545 requires CRLF (most clients tolerate LF).") physical = raw.replace("\r\n", "\n").split("\n") logical = [] for n, line in enumerate(physical, start=1): if len(line.encode("utf-8")) > 75: rep.warn(n, None, f"Line is {len(line.encode('utf-8'))} octets; fold lines longer than 75 octets.") if line[:1] in (" ", "\t") and logical: ln, text = logical[-1] logical[-1] = (ln, text + line[1:]) elif line == "": continue else: logical.append((n, line)) return logical

def split_prop(line): """Split 'NAME;PARAM=V:VALUE' into (NAME, {params}, value). Handles quoted params.""" in_q = False for i, ch in enumerate(line): if ch == '"': in_q = not in_q elif ch == ":" and not in_q: head, value = line[:i], line[i + 1:] break else: return None parts = re.split(r';(?=(?:[^"]"[^"]")[^"]$)', head) name = parts[0].upper() params = {} for p in parts[1:]: if "=" in p: k, v = p.split("=", 1) params[k.upper()] = v.strip('"') return name, params, value

def parse_dt(value, params): """Return (kind, datetime|None) where kind is DATE, UTC, LOCAL, FLOATING or BAD.""" if params.get("VALUE", "").upper() == "DATE" or DATE_RE.match(value): if not DATE_RE.match(value): return "BAD", None try: return "DATE", dt.datetime.strptime(value, "%Y%m%d") except ValueError: return "BAD", None if not DATETIME_RE.match(value): return "BAD", None try: d = dt.datetime.strptime(value.rstrip("Z"), "%Y%m%dT%H%M%S") except ValueError: return "BAD", None if value.endswith("Z"): return "UTC", d return ("LOCAL" if "TZID" in params else "FLOATING"), d

def check_rrule(value, ln, start_kind, rep): parts = {} for chunk in value.split(";"): if "=" not in chunk: rep.error(ln, "RRULE", f"Malformed part '{chunk}' (expected KEY=VALUE).") continue k, v = chunk.split("=", 1) if k != k.upper(): rep.warn(ln, "RRULE", f"Key '{k}' should be uppercase.") k = k.upper() if k in parts: rep.error(ln, "RRULE", f"Part {k} appears more than once.") if k not in RRULE_KEYS: rep.error(ln, "RRULE", f"Unknown part '{k}'.") parts[k] = v.upper() freq = parts.get("FREQ") if not freq: rep.error(ln, "RRULE", "FREQ is required.") elif freq not in FREQS: rep.error(ln, "RRULE", f"Invalid FREQ '{freq}'.") if "COUNT" in parts and "UNTIL" in parts: rep.error(ln, "RRULE", "COUNT and UNTIL must not both be present.") for key in ("COUNT", "INTERVAL"): if key in parts and not (parts[key].isdigit() and int(parts[key]) > 0): rep.error(ln, "RRULE", f"{key} must be a positive integer.") if "BYDAY" in parts: for d in parts["BYDAY"].split(","): m = re.match(r"^([+-]?\d{1,2})?(MO|TU|WE|TH|FR|SA|SU)$", d) if not m: rep.error(ln, "RRULE", f"Invalid BYDAY value '{d}'.") elif m.group(1) and freq not in ("MONTHLY", "YEARLY"): rep.warn(ln, "RRULE", f"BYDAY ordinal '{d}' only makes sense with MONTHLY or YEARLY.") for key, lo, hi in (("BYMONTH", 1, 12), ("BYMONTHDAY", 1, 31), ("BYHOUR", 0, 23), ("BYMINUTE", 0, 59)): if key in parts: for v in parts[key].split(","): try: n = int(v) except ValueError: rep.error(ln, "RRULE", f"{key} value '{v}' is not an integer.") continue ok = lo <= abs(n) <= hi if key == "BYMONTHDAY" else lo <= n <= hi if not ok: rep.error(ln, "RRULE", f"{key} value {n} out of range.") if "WKST" in parts and parts["WKST"] not in DAYS: rep.error(ln, "RRULE", f"Invalid WKST '{parts['WKST']}'.") if "UNTIL" in parts: ukind, _ = parse_dt(parts["UNTIL"], {}) if ukind == "BAD": rep.error(ln, "RRULE", f"UNTIL '{parts['UNTIL']}' is not a valid DATE or DATE-TIME.") elif start_kind == "DATE" and ukind != "DATE": rep.error(ln, "RRULE", "DTSTART is a DATE, so UNTIL must be a DATE too.") elif start_kind in ("LOCAL", "UTC") and ukind != "UTC": rep.error(ln, "RRULE", "DTSTART has a TZID or is UTC, so UNTIL must be UTC (ending in Z).")

def validate(text): rep = Report() lines = unfold(text, rep) if not lines: rep.error(1, None, "File is empty.") return rep if lines[0][1].upper() != "BEGIN:VCALENDAR": rep.error(lines[0][0], None, "File must start with BEGIN:VCALENDAR.") stack = [] comps = [] # list of dicts: name, line, props: list of (ln, name, params, value) tzids_defined, tzids_used, uids = set(), [], {} for ln, line in lines: parsed = split_prop(line) if parsed is None: rep.error(ln, None, f"Not a valid content line (missing ':'): {line[:40]!r}") continue name, params, value = parsed if name == "BEGIN": comp = {"name": value.upper(), "line": ln, "props": []} stack.append(comp) continue if name == "END": if not stack: rep.error(ln, "END", f"END:{value} without matching BEGIN.") continue comp = stack.pop() if comp["name"] != value.upper(): rep.error(ln, "END", f"END:{value} does not match BEGIN:{comp['name']} (line {comp['line']}).") comps.append(comp) continue if not stack: rep.error(ln, name, "Property outside any component.") continue stack[-1]["props"].append((ln, name, params, value)) if "TZID" in params: tzids_used.append((ln, params["TZID"])) if name == "TZID" and stack[-1]["name"] == "VTIMEZONE": tzids_defined.add(value) for comp in stack: rep.error(comp["line"], "BEGIN", f"BEGIN:{comp['name']} is never closed.")

for comp in comps:
    props = {}
    for ln, name, params, value in comp["props"]:
        props.setdefault(name, []).append((ln, params, value))
    cname = comp["name"]
    if cname == "VCALENDAR":
        for req in ("VERSION", "PRODID"):
            if req not in props:
                rep.error(comp["line"], req, f"VCALENDAR is missing required {req}.")
            elif len(props[req]) > 1:
                rep.error(props[req][1][0], req, f"{req} must appear only once.")
        if "VERSION" in props and props["VERSION"][0][2] != "2.0":
            rep.error(props["VERSION"][0][0], "VERSION", "VERSION must be 2.0.")
    if cname in EVENT_LIKE:
        for req in ("UID", "DTSTAMP"):
            if req not in props:
                rep.error(comp["line"], req, f"{cname} is missing required {req}.")
        if cname == "VEVENT" and "DTSTART" not in props:
            rep.error(comp["line"], "DTSTART", "VEVENT is missing DTSTART.")
        if "UID" in props:
            uid = props["UID"][0][2]
            if uid in uids:
                rep.error(props["UID"][0][0], "UID", f"Duplicate UID '{uid}' (also line {uids[uid]}).")
            else:
                uids[uid] = props["UID"][0][0]
        if "DTSTAMP" in props:
            ln, params, value = props["DTSTAMP"][0]
            kind, _ = parse_dt(value, params)
            if kind != "UTC":
                rep.error(ln, "DTSTAMP", "DTSTAMP must be a UTC DATE-TIME ending in Z.")
        start_kind, start = None, None
        for key in ("DTSTART", "DTEND", "DUE"):
            if key not in props:
                continue
            ln, params, value = props[key][0]
            kind, val = parse_dt(value, params)
            if kind == "BAD":
                rep.error(ln, key, f"Invalid date or date-time value '{value}'.")
                continue
            if kind == "FLOATING":
                rep.warn(ln, key, "Floating time (no Z and no TZID): shows the same wall time in every zone.")
            if key == "DTSTART":
                start_kind, start = kind, val
            elif start is not None:
                if (kind == "DATE") != (start_kind == "DATE"):
                    rep.error(ln, key, f"{key} and DTSTART must both be DATE or both DATE-TIME.")
                elif kind == start_kind and val <= start:
                    rep.error(ln, key, f"{key} must be later than DTSTART.")
        if "DTEND" in props and "DURATION" in props:
            rep.error(props["DURATION"][0][0], "DURATION", "DTEND and DURATION must not both be present.")
        if "DURATION" in props:
            ln, _, value = props["DURATION"][0]
            if not DURATION_RE.match(value) or value.rstrip("+-") in ("P", "PT"):
                rep.error(ln, "DURATION", f"Invalid DURATION '{value}' (e.g. PT1H30M, P1D).")
        if "RRULE" in props:
            ln, _, value = props["RRULE"][0]
            check_rrule(value, ln, start_kind, rep)
            if len(props["RRULE"]) > 1:
                rep.warn(props["RRULE"][1][0], "RRULE", "Multiple RRULEs are deprecated; many clients use only the first.")
        for key in ("EXDATE", "RDATE"):
            for ln, params, value in props.get(key, []):
                for v in value.split(","):
                    kind, _ = parse_dt(v, params)
                    if kind == "BAD":
                        rep.error(ln, key, f"Invalid value '{v}'.")
                    elif start_kind and kind != start_kind:
                        rep.warn(ln, key, f"{key} value type ({kind}) differs from DTSTART ({start_kind}); clients may ignore it.")
        if "SEQUENCE" in props and not props["SEQUENCE"][0][2].isdigit():
            rep.error(props["SEQUENCE"][0][0], "SEQUENCE", "SEQUENCE must be a non-negative integer.")
        for key in ("SUMMARY", "DESCRIPTION", "LOCATION"):
            for ln, _, value in props.get(key, []):
                if re.search(r"(?<!\\)[,;]", value):
                    rep.warn(ln, key, "Unescaped ',' or ';' in text; escape as '\\,' and '\\;'.")
if not any(c["name"] in EVENT_LIKE for c in comps):
    rep.warn(1, None, "Calendar contains no VEVENT, VTODO, or VJOURNAL.")
for ln, tz in tzids_used:
    if tz not in tzids_defined:
        rep.warn(ln, "TZID", f"TZID '{tz}' has no VTIMEZONE in this file; strict clients (e.g. Outlook) may misplace it.")
return rep

BROKEN = ( "BEGIN:VCALENDAR\r\nPRODID:-//Test//EN\r\nBEGIN:VEVENT\r\nDTSTAMP:20261015T070000\r\n" "DTSTART:20261020T100000Z\r\nDTEND:20261020T090000Z\r\nDURATION:PT1H\r\n" "RRULE:FREQ=WEEKLY;COUNT=5;UNTIL=20261201T000000Z;BYDAY=XX\r\nSUMMARY:Bad, event\r\n" "END:VEVENT\r\nBEGIN:VEVENT\r\nUID:a@x\r\nDTSTAMP:20261015T070000Z\r\n" "DTSTART;TZID=Mars/Olympus:20261020T100000\r\nEND:VTODO\r\nEND:VCALENDAR\r\n" ) GOOD = ( "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Test//EN\r\nBEGIN:VEVENT\r\nUID:ok@x\r\n" "DTSTAMP:20261015T070000Z\r\nDTSTART:20261020T070000Z\r\nDTEND:20261020T080000Z\r\n" "RRULE:FREQ=MONTHLY;BYDAY=1MO;COUNT=3\r\nSUMMARY:Fine\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n" )

def self_test(): good = validate(GOOD) bad = validate(BROKEN) msgs = " | ".join(i["message"] for i in bad.items) checks = [ ("good file has no errors", not good.errors), ("missing VERSION", "missing required VERSION" in msgs), ("missing UID", "missing required UID" in msgs), ("DTSTAMP not UTC", "DTSTAMP must be a UTC" in msgs), ("DTEND before DTSTART", "must be later than DTSTART" in msgs), ("DTEND with DURATION", "must not both be present" in msgs), ("COUNT with UNTIL", "COUNT and UNTIL" in msgs), ("bad BYDAY", "Invalid BYDAY" in msgs), ("unescaped comma", "Unescaped" in msgs), ("mismatched END", "does not match BEGIN" in msgs), ("unknown TZID warning", "Mars/Olympus" in msgs), ] failed = 0 for label, ok in checks: failed += not ok print(f"{'PASS' if ok else 'FAIL'} {label}") print(f"\n{len(checks) - failed}/{len(checks)} passed") return 1 if failed else 0

def main(argv=None): p = argparse.ArgumentParser(description="Validate an iCalendar (.ics) file.") p.add_argument("file", nargs="?", help="Path to .ics file ('-' for stdin)") p.add_argument("--json", action="store_true", help="Output JSON") p.add_argument("--self-test", action="store_true", help="Run built-in tests") a = p.parse_args(argv) if a.self_test: return self_test() if not a.file: p.error("file is required (or use --self-test)") try: if a.file == "-": text = sys.stdin.read() else: with open(a.file, "r", encoding="utf-8", newline="") as fh: text = fh.read() except (OSError, UnicodeDecodeError) as exc: print(f"Cannot read {a.file}: {exc}", file=sys.stderr) return 2 rep = validate(text) if a.json: print(json.dumps({"errors": len(rep.errors), "warnings": len(rep.warnings), "findings": rep.items}, indent=2)) else: for i in sorted(rep.items, key=lambda x: (x["line"], x["severity"])): prop = f" {i['property']}" if i["property"] else "" print(f"line {i['line']:>4} {i['severity'].upper():7}{prop}: {i['message']}") print(f"\n{len(rep.errors)} error(s), {len(rep.warnings)} warning(s)") return 1 if rep.errors else 0

if name == "main": sys.exit(main())