Skip to content

Commit 5a4b56b

Browse files
committed
add a post-processing script to make our ICS files RFC 5545 compliant
Apparently there is a minor bug in yaml2ics (or somewhere lower), that causes our ICS files to not be RFC 5545 compliant in regards to the RRULE. Until this is fixed in yaml2ics, a post-processing script on our side can mitigate that. Signed-off-by: Felix Kronlage-Dammers <[email protected]>
1 parent 61f7ffb commit 5a4b56b

File tree

3 files changed

+272
-0
lines changed

3 files changed

+272
-0
lines changed

.github/workflows/test-yaml.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,5 +27,8 @@ jobs:
2727
run: |
2828
mkdir ./calendar
2929
yaml2ics main.yml > ./calendar/scs.ics
30+
python fix_ics_rrule.py ./calendar/scs.ics
3031
yaml2ics openops.yml > ./calendar/openops.ics
32+
python fix_ics_rrule.py ./calendar/openops.ics
3133
yaml2ics sig_standard_cert.yml > ./calendar/sig_standard_cert.ics
34+
python fix_ics_rrule.py ./calendar/sig_standard_cert.ics

.github/workflows/yaml2ics.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,11 @@ jobs:
2828
run: |
2929
mkdir ./calendar
3030
yaml2ics main.yml > ./calendar/scs.ics
31+
python fix_ics_rrule.py ./calendar/scs.ics
3132
yaml2ics openops.yml > ./calendar/openops.ics
33+
python fix_ics_rrule.py ./calendar/openops.ics
3234
yaml2ics sig_standard_cert.yml > ./calendar/sig_standard_cert.ics
35+
python fix_ics_rrule.py ./calendar/sig_standard_cert.ics
3336
- name: Deploy
3437
uses: peaceiris/actions-gh-pages@v3
3538
with:

fix_ics_rrule.py

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Fix RRULE UNTIL values in ICS files to be RFC 5545 compliant.
4+
5+
According to RFC 5545 Section 3.3.10:
6+
"If the DTSTART property is specified as a date with UTC time or a date
7+
with local time and time zone reference, then the UNTIL rule part MUST
8+
be specified as a date with UTC time."
9+
10+
This script ensures that RRULE UNTIL values include the 'Z' suffix when
11+
DTSTART uses a TZID parameter.
12+
13+
Usage:
14+
python fix_ics_rrule.py input.ics [output.ics]
15+
16+
If output.ics is not specified, it will overwrite the input file.
17+
"""
18+
19+
import re
20+
import sys
21+
from datetime import datetime
22+
from pathlib import Path
23+
24+
25+
def extract_timezone_from_dtstart(dtstart_line):
26+
"""
27+
Extract timezone information from a DTSTART line.
28+
29+
Handles various formats including:
30+
- DTSTART;TZID=Europe/Berlin:20250221T090500
31+
- DTSTART;TZID=/ics.py/2020.1/Europe/Berlin:20250221T090500
32+
33+
Returns:
34+
tuple: (timezone_name, is_utc) where is_utc indicates if DTSTART is already in UTC
35+
"""
36+
# Check if DTSTART is in UTC format (ends with Z)
37+
if dtstart_line.endswith('Z'):
38+
return None, True
39+
40+
# Check if DTSTART has no timezone (floating time)
41+
if 'TZID=' not in dtstart_line:
42+
return None, False
43+
44+
# Extract TZID value
45+
tzid_match = re.search(r'TZID=([^:;]+)', dtstart_line)
46+
if not tzid_match:
47+
return None, False
48+
49+
tzid = tzid_match.group(1)
50+
51+
# Handle /ics.py/version/Timezone/Name format
52+
# Extract the actual timezone (last two path components for standard timezones)
53+
if '/' in tzid:
54+
parts = tzid.split('/')
55+
# Filter out empty parts and 'ics.py' and version numbers
56+
clean_parts = [p for p in parts if p and p != 'ics.py' and not re.match(r'^\d{4}\.\d+$', p)]
57+
58+
# Reconstruct timezone name (usually last 1-2 components)
59+
if len(clean_parts) >= 2:
60+
# Most timezones are Continent/City format
61+
tzid = '/'.join(clean_parts[-2:])
62+
elif len(clean_parts) == 1:
63+
tzid = clean_parts[0]
64+
65+
return tzid, False
66+
67+
68+
def convert_local_to_utc(datetime_str, timezone_name):
69+
"""
70+
Convert a local datetime string to UTC.
71+
72+
Args:
73+
datetime_str: String in format YYYYMMDDTHHMMSS
74+
timezone_name: IANA timezone name (e.g., 'Europe/Berlin')
75+
76+
Returns:
77+
String in format YYYYMMDDTHHMMSSZ (UTC)
78+
"""
79+
try:
80+
import pytz
81+
82+
# Parse the datetime string
83+
dt = datetime.strptime(datetime_str, '%Y%m%dT%H%M%S')
84+
85+
# Get timezone
86+
tz = pytz.timezone(timezone_name)
87+
88+
# Localize to the timezone (handles DST properly)
89+
dt_local = tz.localize(dt)
90+
91+
# Convert to UTC
92+
dt_utc = dt_local.astimezone(pytz.UTC)
93+
94+
# Format back to string with Z suffix
95+
return dt_utc.strftime('%Y%m%dT%H%M%SZ')
96+
97+
except Exception as e:
98+
print(f"Warning: Could not convert {datetime_str} in timezone {timezone_name} to UTC: {e}",
99+
file=sys.stderr)
100+
print(f" Appending 'Z' without conversion (may be incorrect!)", file=sys.stderr)
101+
# Fallback: just append Z (not ideal but maintains format)
102+
return datetime_str + 'Z'
103+
104+
105+
def fix_until_in_rrule(rrule_line, timezone_name, dtstart_is_utc):
106+
"""
107+
Fix UNTIL values in an RRULE line to be RFC 5545 compliant.
108+
109+
Args:
110+
rrule_line: The RRULE line to fix
111+
timezone_name: The timezone of the associated DTSTART
112+
dtstart_is_utc: Whether DTSTART is already in UTC
113+
114+
Returns:
115+
Fixed RRULE line
116+
"""
117+
# If DTSTART is not timezone-aware (floating time), no fix needed
118+
if timezone_name is None and not dtstart_is_utc:
119+
return rrule_line
120+
121+
# Check if RRULE has an UNTIL value
122+
if 'UNTIL=' not in rrule_line:
123+
return rrule_line
124+
125+
# Check if UNTIL already has a Z suffix (already in UTC)
126+
if re.search(r'UNTIL=\d{8}T\d{6}Z', rrule_line):
127+
return rrule_line
128+
129+
# Check if UNTIL is a date-only value (no time component)
130+
# Date-only values don't need the Z suffix according to RFC 5545
131+
if re.search(r'UNTIL=\d{8}(?:[;\s]|$)', rrule_line):
132+
return rrule_line
133+
134+
# Find UNTIL datetime value without Z
135+
until_match = re.search(r'UNTIL=(\d{8}T\d{6})(?=[;\s]|$)', rrule_line)
136+
if not until_match:
137+
return rrule_line
138+
139+
until_value = until_match.group(1)
140+
141+
# Convert to UTC if we have timezone information
142+
if timezone_name:
143+
until_utc = convert_local_to_utc(until_value, timezone_name)
144+
else:
145+
# DTSTART is in UTC, so assume UNTIL is too
146+
until_utc = until_value + 'Z'
147+
148+
# Replace the UNTIL value
149+
fixed_line = rrule_line.replace(f'UNTIL={until_value}', f'UNTIL={until_utc}')
150+
151+
return fixed_line
152+
153+
154+
def fix_ics_file(input_path, output_path=None):
155+
"""
156+
Fix RRULE UNTIL values in an ICS file.
157+
158+
Args:
159+
input_path: Path to input ICS file
160+
output_path: Path to output ICS file (optional, defaults to input_path)
161+
162+
Returns:
163+
Number of RRULE lines that were fixed
164+
"""
165+
if output_path is None:
166+
output_path = input_path
167+
168+
input_path = Path(input_path)
169+
output_path = Path(output_path)
170+
171+
if not input_path.exists():
172+
raise FileNotFoundError(f"Input file not found: {input_path}")
173+
174+
with open(input_path, 'r', encoding='utf-8') as f:
175+
content = f.read()
176+
177+
lines = content.split('\n')
178+
result = []
179+
fixes_count = 0
180+
181+
# Track state as we parse the file
182+
current_timezone = None
183+
current_dtstart_is_utc = False
184+
in_vevent = False
185+
186+
for line in lines:
187+
# Track when we're in a VEVENT
188+
if line.strip() == 'BEGIN:VEVENT':
189+
in_vevent = True
190+
current_timezone = None
191+
current_dtstart_is_utc = False
192+
elif line.strip() == 'END:VEVENT':
193+
in_vevent = False
194+
current_timezone = None
195+
current_dtstart_is_utc = False
196+
197+
# Extract timezone from DTSTART
198+
if in_vevent and line.startswith('DTSTART'):
199+
current_timezone, current_dtstart_is_utc = extract_timezone_from_dtstart(line)
200+
result.append(line)
201+
202+
# Fix RRULE if needed
203+
elif in_vevent and line.startswith('RRULE:'):
204+
original_line = line
205+
fixed_line = fix_until_in_rrule(line, current_timezone, current_dtstart_is_utc)
206+
207+
if fixed_line != original_line:
208+
fixes_count += 1
209+
print(f"Fixed RRULE:", file=sys.stderr)
210+
print(f" Before: {original_line}", file=sys.stderr)
211+
print(f" After: {fixed_line}", file=sys.stderr)
212+
213+
result.append(fixed_line)
214+
215+
else:
216+
result.append(line)
217+
218+
# Write output
219+
fixed_content = '\n'.join(result)
220+
221+
with open(output_path, 'w', encoding='utf-8') as f:
222+
f.write(fixed_content)
223+
224+
return fixes_count
225+
226+
227+
def main():
228+
"""Main entry point for the script."""
229+
if len(sys.argv) < 2:
230+
print("Usage: python fix_ics_rrule.py input.ics [output.ics]", file=sys.stderr)
231+
print("\nIf output.ics is not specified, the input file will be modified in place.",
232+
file=sys.stderr)
233+
sys.exit(1)
234+
235+
input_file = sys.argv[1]
236+
output_file = sys.argv[2] if len(sys.argv) > 2 else None
237+
238+
try:
239+
# Check if pytz is available
240+
try:
241+
import pytz
242+
except ImportError:
243+
print("Warning: pytz module not found. Timezone conversions may be incorrect.",
244+
file=sys.stderr)
245+
print("Install with: pip install pytz", file=sys.stderr)
246+
print("", file=sys.stderr)
247+
248+
fixes_count = fix_ics_file(input_file, output_file)
249+
250+
if output_file:
251+
print(f"\nFixed {fixes_count} RRULE(s) in {input_file}", file=sys.stderr)
252+
print(f"Output written to: {output_file}", file=sys.stderr)
253+
else:
254+
print(f"\nFixed {fixes_count} RRULE(s) in {input_file}", file=sys.stderr)
255+
print(f"File modified in place.", file=sys.stderr)
256+
257+
if fixes_count == 0:
258+
print("No RRULE fixes were needed - file is already compliant!", file=sys.stderr)
259+
260+
except Exception as e:
261+
print(f"Error: {e}", file=sys.stderr)
262+
sys.exit(1)
263+
264+
265+
if __name__ == '__main__':
266+
main()

0 commit comments

Comments
 (0)