---
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 `.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 `\,` `\;` `\` `
`. |
| 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 (`
`).
- 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.
Bring 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 "
" not in raw and "
" in raw:
rep.warn(1, None, "Lines end with LF only; RFC 5545 requires CRLF (most clients tolerate LF).")
physical = raw.replace("
", "
").split("
")
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 (" ", " ") 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
PRODID:-//Test//EN
BEGIN:VEVENT
DTSTAMP:20261015T070000
"
"DTSTART:20261020T100000Z
DTEND:20261020T090000Z
DURATION:PT1H
"
"RRULE:FREQ=WEEKLY;COUNT=5;UNTIL=20261201T000000Z;BYDAY=XX
SUMMARY:Bad, event
"
"END:VEVENT
BEGIN:VEVENT
UID:a@x
DTSTAMP:20261015T070000Z
"
"DTSTART;TZID=Mars/Olympus:20261020T100000
END:VTODO
END:VCALENDAR
"
)
GOOD = (
"BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Test//EN
BEGIN:VEVENT
UID:ok@x
"
"DTSTAMP:20261015T070000Z
DTSTART:20261020T070000Z
DTEND:20261020T080000Z
"
"RRULE:FREQ=MONTHLY;BYDAY=1MO;COUNT=3
SUMMARY:Fine
END:VEVENT
END:VCALENDAR
"
)
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"
{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"
{len(rep.errors)} error(s), {len(rep.warnings)} warning(s)")
return 1 if rep.errors else 0
if __name__ == "__main__":
sys.exit(main())