Created
May 14, 2026 14:51
-
-
Save frugan-dev/dfcd66c034c4c773f9477a8ec6ad655f to your computer and use it in GitHub Desktop.
Generic ICS event extractor filtered by bracket tags
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
| """ | |
| 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' ?\\?', ' ', 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