Created
May 10, 2026 15:57
-
-
Save dkiesow/9a667c939f24c1b57a6d5539b90e00c3 to your computer and use it in GitHub Desktop.
Fix ICA ICS
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| fix_ics.py — Fix malformed ICS files for Outlook import. | |
| Fixes applied: | |
| - Removes UTF-8 BOM | |
| - Adds UID and DTSTAMP to any VEVENT missing them | |
| - Adds CALSCALE:GREGORIAN and METHOD:PUBLISH if missing | |
| - Folds long lines to ≤75 bytes per RFC 5545 | |
| - Normalizes all line endings to CRLF | |
| Usage: | |
| python3 fix_ics.py input.ics | |
| python3 fix_ics.py input.ics output.ics (optional custom output path) | |
| """ | |
| import re, sys, uuid, os | |
| from datetime import datetime, timezone | |
| VTIMEZONE_SAST = """\ | |
| BEGIN:VTIMEZONE | |
| TZID:Africa/Johannesburg | |
| BEGIN:STANDARD | |
| TZNAME:SAST | |
| DTSTART:19700101T000000 | |
| TZOFFSETFROM:+0200 | |
| TZOFFSETTO:+0200 | |
| END:STANDARD | |
| END:VTIMEZONE""" | |
| def fix_ics(src: str, dst: str, tzid: str = "Africa/Johannesburg") -> None: | |
| with open(src, encoding="utf-8-sig") as f: | |
| content = f.read() | |
| # Normalize line endings | |
| content = content.replace("\r\n", "\n").replace("\r", "\n") | |
| # Add CALSCALE and METHOD after VERSION:2.0 if missing | |
| if "CALSCALE:" not in content: | |
| content = content.replace("VERSION:2.0\n", "VERSION:2.0\nCALSCALE:GREGORIAN\n", 1) | |
| if "METHOD:" not in content: | |
| content = content.replace("CALSCALE:GREGORIAN\n", "CALSCALE:GREGORIAN\nMETHOD:PUBLISH\n", 1) | |
| # Inject VTIMEZONE block before first VEVENT if not already present | |
| if "BEGIN:VTIMEZONE" not in content: | |
| content = content.replace("BEGIN:VEVENT\n", VTIMEZONE_SAST + "\n" + "BEGIN:VEVENT\n", 1) | |
| # Tag bare DTSTART/DTEND inside VEVENTs only (not inside VTIMEZONE) | |
| def tag_vevent_times(match): | |
| block = match.group(0) | |
| block = re.sub( | |
| r"(DTSTART|DTEND):([\dT]+)(?!\d|Z)", | |
| lambda m: f"{m.group(1)};TZID={tzid}:{m.group(2)}", | |
| block, | |
| ) | |
| return block | |
| content = re.sub( | |
| r"BEGIN:VEVENT.*?END:VEVENT", | |
| tag_vevent_times, | |
| content, | |
| flags=re.DOTALL, | |
| ) | |
| # Add UID + DTSTAMP to each VEVENT that lacks them | |
| stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") | |
| def add_required(match): | |
| block = match.group(0) | |
| uid = f"UID:{uuid.uuid4()}@fixed" | |
| dtstamp = f"DTSTAMP:{stamp}" | |
| return block.replace("BEGIN:VEVENT\n", f"BEGIN:VEVENT\n{uid}\n{dtstamp}\n", 1) | |
| content = re.sub( | |
| r"BEGIN:VEVENT\n(?!UID:).*?END:VEVENT", | |
| add_required, | |
| content, | |
| flags=re.DOTALL, | |
| ) | |
| # Fold lines at 75 bytes per RFC 5545 | |
| def fold_line(line: str) -> str: | |
| if len(line.encode("utf-8")) <= 75: | |
| return line | |
| chunks = [] | |
| buf = b"" | |
| for char in line: | |
| cb = char.encode("utf-8") | |
| limit = 75 if not chunks else 74 | |
| if len(buf) + len(cb) > limit: | |
| chunks.append(buf.decode("utf-8")) | |
| buf = b" " + cb | |
| else: | |
| buf += cb | |
| if buf: | |
| chunks.append(buf.decode("utf-8")) | |
| return "\r\n".join(chunks) | |
| out_lines = [fold_line(line) for line in content.split("\n")] | |
| # Write with CRLF, no BOM | |
| with open(dst, "w", encoding="utf-8", newline="") as f: | |
| f.write("\r\n".join(out_lines)) | |
| # Report | |
| with open(dst, "rb") as f: | |
| raw = f.read() | |
| events = raw.count(b"BEGIN:VEVENT") | |
| long_lines = len([l for l in raw.split(b"\r\n") if len(l) > 75]) | |
| bare_lf = raw.count(b"\n") - raw.count(b"\r\n") | |
| print(f"Fixed: {dst}") | |
| print(f" Events : {events}") | |
| print(f" Long lines: {long_lines}") | |
| print(f" Bare LFs : {bare_lf}") | |
| if __name__ == "__main__": | |
| if len(sys.argv) < 2: | |
| print("Usage: python3 fix_ics.py input.ics [output.ics] [--tz TZID]") | |
| print(" Default TZID: Africa/Johannesburg (SAST, UTC+2)") | |
| sys.exit(1) | |
| args = sys.argv[1:] | |
| tzid = "Africa/Johannesburg" | |
| if "--tz" in args: | |
| idx = args.index("--tz") | |
| tzid = args[idx + 1] | |
| args = args[:idx] + args[idx + 2:] | |
| src = args[0] | |
| if len(args) >= 2: | |
| dst = args[1] | |
| else: | |
| base, ext = os.path.splitext(src) | |
| dst = f"{base}_fixed{ext}" | |
| fix_ics(src, dst, tzid=tzid) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment