ICS-Kalenderdaten bereinigen //Update

Update: Leider gab es in der ersten Version des Scripts noch einen Fehler, der dazu führen konnte, dass bestimmte Arten (UNTIL, COUNT) von Serienterminen nicht übernommen werden, obwohl sie erst nach dem Löschtermin enden. Sollten Sie das Script bereits installiert haben, laden Sie es bitte noch einmal herunter. Wir bedauern die Unannehmlichkeiten.

Benutzen Sie auch das ICS-Format für Ihre Kalenderdaten? Dann sind Sie vielleicht schon einmal auf folgendes Problem gestoßen: Nehmen wir an, Sie haben im Jahr 2010 begonnen, Ihren Kalender im ICS-Format zu führen.

Über die Jahre sammeln sich viele Termine an, und nach einer Zeit könnten die natürlich weg. Aber Sie möchten die alten Termine nicht löschen, weil darin auch wiederkehrende Termine enthalten sind. Bestes Beispiel sind Geburtstage. Sie haben den Geburtstag eines Freundes im Jahr 2010 erfasst und möchten jährlich daran erinnert werden. Wenn Sie nun aber alle Termine vor z.B. 01.01.2023 löschen würden, dann wäre auch die Erinnerung an den Geburtstag des Freundes weg, weil dieser Termin vor 2023 angelegt wurde.

Und deshalb löschen Sie natürlich nicht, und die ICS-Datei wächst und wächst und schwillt mit der Zeit so weit an, dass es ein Performance-Problem ergibt.

Was tun? Hier ist ein Python-Script, das nicht nur das Datum berücksichtigt, an welchem der Termin angelegt wurde, sondern auch dessen Wiederholungen. Es ermittelt, wann die letzte Wiederholung eines Termins ist, und wenn dieser nach dem Löschdatum liegt, wird der Termin übernommen.

Das Script löscht jedoch alle Termin-Einträge, die (incl. deren etwaiger Wiederholungen) vor dem Löschdatum liegen. Der Default für das Löschdatum ist der 01.01. des laufenden Jahres, oder Sie geben es als Parameter an. Hinweis: Alle sonstigen Einträge im ICS-Kalender (Todo, Zeitzonen, Journal) werden unverändert übernommen, unabhängig von ihrem Erstellungsdatum – das Script bearbeitet nur Termine.

Wichtig! Wir übernehmen keinerlei Garantie für das Ergebnis. Bitte erzeugen Sie zunächst eine neue Datei und prüfen Sie das Ergebnis sorgfältig, bevor Sie das Ergebnis dieses Scripts in Ihre Kalenderverwaltung übernehmen. Heben Sie unbedingt den Stand vor der Bearbeitung auf, damit Sie zur Not ein Backup haben. Wir haben das Tool sehr sorgfältig getestet, aber das ICS-Format ist komplex und leistungsstark, und wir können nur sagen, wie wir selbst es benutzen, aber nicht, was irgendwer irgendwo damit alles anfangen könnte.

Und noch eine Anmerkung: Wir haben im Script die Zeitzone „Europe/Berlin“ als Default fest eingestellt vorgegeben. Falls Sie in einer anderen Zeitzone sind, sollten Sie diesen Parameter im Code anpassen. Oder Sie erweitern das Script, um die Zeitzone vom System her automatisch zu ermitteln – ohnehin ist dieses Script vorwiegend als Beispiel für die Verwendung der Bibliothek icalendar in Python gedacht. Vielleicht haben Sie ja eine ganz andere Problemstellung und können von unserem Script nur den Rahmen brauchen?

Genug der Vorrede, Sie können sich das Script als ZIP von unserem Server herunterladen, oder Sie kopieren es aus der Textbox:

#!/usr/bin/env python3

# Erfordert diese beiden Nicht-Standard-Bibliotheken
# pip install pytz
# pip install icalendar

# usage: remove-old-ics-entries.py [-h] [-d DATE] [-o OUTPUT] input_filename
#
# Old ICS Calendar Entries Cleaner
#
# positional arguments:
#  input_filename        The ICS input filename
#
# options:
#  -h, --help            show this help message and exit
#  -d DATE, --date DATE  Start date for cleaning (format: YYYY-MM-DD). Default:
#                        {lfd.Jahr}-01-01
#  -o OUTPUT, --output OUTPUT
#                        Output file name (default: stdout)

import argparse, sys, os
from icalendar import Calendar
from datetime import datetime
from pytz import timezone
from dateutil.rrule import rrulestr

def remove_old_events(ics_file_path, cutoff_date, output_file_path):
    # Lade den Kalender aus der ICS-Datei
    with open(ics_file_path, 'r', encoding='utf-8') as f:
        cal = Calendar.from_ical(f.read())

    # Neuer Kalender ohne die alten Events
    new_cal = Calendar()

    # Kalender-Metadaten (PRODID, VERSION, X-WR-*) vom Original übernehmen
    for prop, val in cal.items():
        new_cal.add(prop, val)

    # Konvertiere cutoff_date zu einer "aware" datetime mit Zeitzoneninformation
    tz = timezone('Europe/Berlin')
    cutoff_date = tz.localize(cutoff_date)

    for component in cal.walk():
        if component.name == "VEVENT":
            event_date = component.get('DTSTART').dt

            # Prüfen, ob das Datum eine Zeitzone hat, und angleichen
            if isinstance(event_date, datetime) and event_date.tzinfo is not None:
                event_date = event_date.astimezone(tz)
            elif isinstance(event_date, datetime):
                event_date = tz.localize(event_date)
            else:
                event_date = datetime.combine(event_date, datetime.min.time())
                event_date = tz.localize(event_date)

            # Prüfen, ob das Event wiederkehrend ist
            rrule_component = component.get('RRULE')
            if rrule_component is not None:
                # Extrahiere die RRULE und berechne die Wiederholungen
                rrule_str = str(rrule_component.to_ical(), 'utf-8')
                rrule = rrulestr(rrule_str, dtstart=event_date)

                # Überprüfe das Enddatum (UNTIL) oder berechne das letzte Vorkommen basierend auf COUNT
                has_until = 'UNTIL' in rrule_component
                has_count = 'COUNT' in rrule_component
                last_occurrence = None

                if has_until:
                    # Berechne das letzte Vorkommen, wenn UNTIL vorhanden ist
                    last_occurrence = rrule[-1]
                elif has_count:
                    # Berechne das letzte Vorkommen basierend auf COUNT
                    occurrences = list(rrule)
                    last_occurrence = occurrences[-1] if occurrences else None

                # Bedingungen für das Nicht-Übernehmen der Serie
                if (has_until or has_count) and (last_occurrence < cutoff_date):
                    continue  # Wenn die Serie ein Enddatum hat und dieses vor cutoff_date liegt, überspringen wir sie
            else:
                # Einzelereignisse, die vor dem cutoff_date liegen, überspringen
                if event_date < cutoff_date:
                    continue

            # Füge das Event zum neuen Kalender hinzu
            new_cal.add_component(component)
        elif component.name in ["VTODO", "VTIMEZONE", "VJOURNAL"]:
            # Füge andere Komponenten ohne Änderung hinzu
            new_cal.add_component(component)

    if output_file_path == sys.stdout:
        sys.stdout.buffer.write(new_cal.to_ical())  # Schreibe binär auf stdout
    else:
        with open(output_file_path, 'wb') as f:
            f.write(new_cal.to_ical())

def valid_date(s):
    try:
        return datetime.strptime(s, "%Y-%m-%d")
    except ValueError:
        raise argparse.ArgumentTypeError(f"Invalid date format: {s}. Use YYYY-MM-DD format.")

def check_file_exists(filename):
    if not os.path.exists(filename):
        raise argparse.ArgumentTypeError(f"The file {filename} does not exist.")
    return filename

# Eine benutzerdefinierte Fehlerklasse, die bei fehlenden Argumenten die Usage ausgibt
class MyParser(argparse.ArgumentParser):
    def error(self, message):
        sys.stderr.write(f"error: {message}\n")
        self.print_help()
        sys.exit(2)

if __name__ == "__main__":
    cutoff_date_default = datetime(datetime.now().year, 1, 1)

    parser = MyParser(description='Old ICS Calendar Entries Cleaner')
    parser.add_argument('input_filename', type=check_file_exists, help='The ICS input filename')  # Verpflichtendes Argument
    parser.add_argument('-d', '--date', type=valid_date, default=cutoff_date_default,
                        help=f'Start date for cleaning (format: YYYY-MM-DD). Default: {cutoff_date_default.strftime("%Y-%m-%d")}')
    parser.add_argument('-o', '--output', default=sys.stdout, help='Output file name (default: stdout)')
    args = parser.parse_args()

    input_filename = args.input_filename
    cutoff_date = args.date
    output_filename = args.output

    remove_old_events(input_filename, cutoff_date, output_filename)

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert