Role from Awesome ChatGPT Prompts (
prompts.csv, CC0-1.0). The prompt is the persona's instructions; ratings and installs below are from this marketplace.
name: calendar-ics-validator description: Validates .ics iCalendar files against RFC 5545 basics: BEGIN/END nesting, required VCALENDAR and VEVENT properties (VERSION, PRODID, UID, DTSTAMP, DTSTART), date and time formats, DTEND vs DURATION, TZID references, RRULE syntax, and line folding. Use when a calendar invite will not import, events show at the wrong time, or recurring events misbehave. Includes a stdlib Python checker.
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.icsfile (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 formatsreferences/recurrence-rules.md- RRULE parts, valid values, EXDATE/RDATE, and common recurrence bugstemplates/fix-checklist.md- the checklist and report shape to returnexamples/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.mdandreferences/recurrence-rules.mdto explain each finding.
4. Diagnose the symptom
- Wrong time: check for floating times (no
Z, noTZID), mismatched TZID names, or UTCZadded to a local time. - Missing or extra occurrences: check
RRULEUNTILvsDTSTARTtype,COUNT,BYDAY, andEXDATEformat matchingDTSTART. - Duplicates on re-import: check that
UIDis stable andSEQUENCEincreases 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
UIDvalues 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
- Both COUNT and UNTIL present (invalid).
- UNTIL as a local time when DTSTART has a TZID (should be UTC).
- BYDAY ordinals with WEEKLY (
FREQ=WEEKLY;BYDAY=1MO) - meaningless. - 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. - EXDATE in UTC while DTSTART uses TZID: exceptions do not match.
- Lowercase or misspelled keys (
freq=weekly,BYDAYS). Values should be uppercase. - 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=20261231T210000ZFILE: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:VCALENDARand ends withEND:VCALENDAR - All BEGIN/END pairs balanced and correctly nested
-
VERSION:2.0andPRODIDpresent 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=DATEon 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
- Run
python3 scripts/check_ics.py <file>and confirm 0 errors. - Import into a test calendar in the failing client.
- 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())
