Skip to content

Instantly share code, notes, and snippets.

@dkiesow
Created May 10, 2026 15:57
Show Gist options
  • Select an option

  • Save dkiesow/9a667c939f24c1b57a6d5539b90e00c3 to your computer and use it in GitHub Desktop.

Select an option

Save dkiesow/9a667c939f24c1b57a6d5539b90e00c3 to your computer and use it in GitHub Desktop.
Fix ICA ICS
#!/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