Skip to content

Instantly share code, notes, and snippets.

@frugan-dev
Created May 14, 2026 14:51
Show Gist options
  • Select an option

  • Save frugan-dev/dfcd66c034c4c773f9477a8ec6ad655f to your computer and use it in GitHub Desktop.

Select an option

Save frugan-dev/dfcd66c034c4c773f9477a8ec6ad655f to your computer and use it in GitHub Desktop.
Generic ICS event extractor filtered by bracket tags
"""
Extract events from an ICS file filtered by a tag inside square brackets
and an optional date range.
Requirements:
pip install icalendar pytz
Usage:
python extract_ics.py input.ics output.csv --tag PATTERN [--from YYYY-MM-DD] [--to YYYY-MM-DD]
Examples:
python extract_ics.py basic.ics out.csv --tag mysite.tld
python extract_ics.py basic.ics out.csv --tag mysite.tld --from 2025-01-01
python extract_ics.py basic.ics out.csv --tag mysite.tld --from 2024-06-01 --to 2024-12-31
"""
import re
import csv
import sys
import argparse
import pytz
from datetime import datetime
from icalendar import Calendar
ROME_TZ = pytz.timezone("Europe/Rome")
def to_rome(dt) -> datetime:
"""Normalize a VEVENT datetime/date value to Europe/Rome timezone."""
if isinstance(dt, datetime):
if dt.tzinfo is None:
return ROME_TZ.localize(dt)
return dt.astimezone(ROME_TZ)
# all-day date: treat as midnight Rome time
return ROME_TZ.localize(datetime(dt.year, dt.month, dt.day))
def parse_description(raw: str, tag_pattern: re.Pattern) -> tuple[list[str], str]:
"""
Extract tags matching tag_pattern inside square brackets and return a
cleaned plain-text version of the full description.
Returns:
tags - list of tag strings found inside brackets
clean - HTML-stripped, whitespace-normalised description text
"""
tags = tag_pattern.findall(raw)
clean = re.sub(r'<br\s*/?>', '\n', raw, flags=re.IGNORECASE)
clean = re.sub(r'</li>', '\n', clean, flags=re.IGNORECASE)
clean = re.sub(r'</ul>', '\n', clean, flags=re.IGNORECASE)
clean = re.sub(r'<li>', '- ', clean, flags=re.IGNORECASE)
clean = re.sub(r'<[^>]+>', '', clean)
clean = re.sub(r'&nbsp;?\\?', ' ', clean)
#clean = clean.replace('\xa0', ' ')
clean = re.sub(r'\\,', ',', clean)
# Normalize spaces within each line, preserving newlines
clean = '\n'.join(line.strip() for line in clean.splitlines())
clean = re.sub(r'\n{3,}', '\n\n', clean).strip()
return tags, clean
def build_tag_pattern(tag: str) -> re.Pattern:
"""
Build a regex that matches [anything containing <tag>] in the description.
The tag string is treated as a literal (not a regex).
"""
return re.compile(r'\[([^\]]*' + re.escape(tag) + r'[^\]]*)\]')
def parse_date(value: str) -> datetime:
return ROME_TZ.localize(datetime.strptime(value, '%Y-%m-%d'))
def extract_events(
ics_path: str,
tag_pattern: re.Pattern,
tag: str,
date_from: datetime | None,
date_to: datetime | None,
) -> list[dict]:
with open(ics_path, 'rb') as f:
cal = Calendar.from_ical(f.read())
events = []
for component in cal.walk():
if component.name != 'VEVENT':
continue
desc_raw = str(component.get('DESCRIPTION', ''))
# Quick pre-check before running the full regex
if tag not in desc_raw:
continue
dtstart = component.get('DTSTART')
if dtstart is None:
continue
start_rome = to_rome(dtstart.dt)
if date_from and start_rome < date_from:
continue
if date_to and start_rome > date_to:
continue
dtend = component.get('DTEND')
end_rome = to_rome(dtend.dt) if dtend else None
duration_min = None
if end_rome:
duration_min = int((end_rome - start_rome).total_seconds() / 60)
tags, clean_desc = parse_description(desc_raw, tag_pattern)
# Skip events where the tag appears in the description text but not
# inside a [...] bracket (the pre-check above is a substring match)
if not tags:
continue
events.append({
'start': start_rome.strftime('%Y-%m-%d %H:%M'),
'end': end_rome.strftime('%Y-%m-%d %H:%M') if end_rome else '',
'duration_h': f"{duration_min // 60}h {duration_min % 60:02d}m" if duration_min is not None else '',
#'summary': str(component.get('SUMMARY', '')),
'tags': ' | '.join(tags),
'description': clean_desc,
})
events.sort(key=lambda e: e['start'])
return events
def write_csv(events: list[dict], output_path: str) -> None:
fieldnames = [
'start',
'end',
'duration_h',
#'summary',
'tags',
'description'
]
with open(output_path, 'w', newline='', encoding='utf-8') as f:
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(events)
def main() -> None:
parser = argparse.ArgumentParser(
description='Extract ICS events by bracket tag and optional date range.'
)
parser.add_argument('ics', help='Path to the .ics input file')
parser.add_argument('output', help='Path to the .csv output file')
parser.add_argument('--tag', required=True, help='String to match inside [brackets] in the description')
parser.add_argument('--from', dest='date_from', default=None, metavar='YYYY-MM-DD', help='Include events starting from this date (inclusive)')
parser.add_argument('--to', dest='date_to', default=None, metavar='YYYY-MM-DD', help='Include events up to this date (inclusive)')
args = parser.parse_args()
date_from = parse_date(args.date_from) if args.date_from else None
date_to = parse_date(args.date_to) if args.date_to else None
if date_from and date_to and date_from > date_to:
print("Error: --from date must be before --to date", file=sys.stderr)
sys.exit(1)
tag_pattern = build_tag_pattern(args.tag)
events = extract_events(args.ics, tag_pattern, args.tag, date_from, date_to)
write_csv(events, args.output)
print(f"Exported {len(events)} events -> {args.output}")
if __name__ == '__main__':
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment