Marius 1 місяць тому
батько
коміт
9a38475df7
5 змінених файлів з 2191 додано та 1 видалено
  1. 803 0
      Design Document v1.1.md
  2. 274 1
      README.md
  3. 365 0
      TECHNISCHE_DOKUMENTATION.md
  4. 27 0
      config.json
  5. 722 0
      notion_to_calendar.py

+ 803 - 0
Design Document v1.1.md

@@ -0,0 +1,803 @@
+# Technische Dokumentation
+## Notion → Apple Kalender / Exchange Sync
+**Version:** 1.1  
+**Stand:** Mai 2026  
+**Lösungsansatz:** Python + AppleScript → Apple Kalender → Exchange Online
+
+### Änderungshistorie
+
+| Version | Änderung |
+|---------|----------|
+| 1.0 | Erstversion |
+| 1.1 | (1) Update-Modus für bestehende Termine ergänzt; (2) Termine pro Tag laufen sequenziell (back-to-back), nicht parallel; (3) Notion-Eigenschaft umbenannt: „Completion Date" → „Last Edit" |
+
+---
+
+## Inhaltsverzeichnis
+
+1. [Projektübersicht](#1-projektübersicht)
+2. [Systemumgebung](#2-systemumgebung)
+3. [Beabsichtigtes Verhalten (Functional Specification)](#3-beabsichtigtes-verhalten)
+4. [Architektur & Datenfluss](#4-architektur--datenfluss)
+5. [Notion-Datenmodell](#5-notion-datenmodell)
+6. [Datenverarbeitung & Geschäftslogik](#6-datenverarbeitung--geschäftslogik)
+7. [Komponente 1 – Python-Skript](#7-komponente-1--python-skript)
+8. [Komponente 2 – AppleScript-Integration](#8-komponente-2--applescript-integration)
+9. [Konfiguration durch den Nutzer](#9-konfiguration-durch-den-nutzer)
+10. [Projektstruktur](#10-projektstruktur)
+11. [Fehlerbehandlung & Logging](#11-fehlerbehandlung--logging)
+12. [Testszenarien](#12-testszenarien)
+13. [Ausführung & Workflow](#13-ausführung--workflow)
+
+---
+
+## 1. Projektübersicht
+
+### Ziel
+
+Dieses Tool synchronisiert abgeschlossene Aufgaben aus einer Notion-Datenbank in den Microsoft Outlook-Kalender (via Exchange Online). Da auf dem System ausschließlich macOS eingesetzt wird und Apple Kalender bereits mit dem Exchange-Konto verbunden ist, erfolgt die Kalender-Integration über AppleScript. Apple Kalender übernimmt dann automatisch die Synchronisation zu Exchange/Outlook.
+
+### Kernprobleme
+
+In Notion existieren pro Tag mehrere Einträge für verschiedene Projekte. Im Kalender soll pro Tag und Projekt genau **ein** gebündelter Termin entstehen. Mehrere Termine am selben Tag sollen **nahtlos hintereinander** liegen (back-to-back), nicht alle zur selben Zeit starten. Ändert sich nachträglich etwas in Notion (z. B. eine T-Shirt-Size wird korrigiert), soll der bestehende Kalendertermin **aktualisiert** werden.
+
+### Abgrenzung
+
+- Das Tool läuft **nicht automatisch** im Hintergrund, sondern wird manuell am Ende eines Kalendermonats (oder bei Bedarf zwischendurch) ausgeführt.
+- Bestehende Kalendereinträge können **aktualisiert**, aber **nicht gelöscht** werden.
+- Das Tool ist **idempotent**: Bei unveränderter Notion-Datenlage passiert beim zweiten Lauf nichts.
+
+---
+
+## 2. Systemumgebung
+
+### Hardware & Betriebssystem
+
+| Eigenschaft         | Wert                       |
+|---------------------|----------------------------|
+| Betriebssystem      | macOS (aktuell)            |
+| Ausführungsumgebung | Terminal / Shell           |
+| Sprache             | Python 3.x                 |
+
+### Externe Dienste & Zugänge
+
+| Dienst             | Zugang / Schnittstelle         | Status                    |
+|--------------------|--------------------------------|---------------------------|
+| Notion             | Notion REST API v1 (notion.so) | Integration vorhanden     |
+| Microsoft Exchange | Exchange Online (Arbeitgeber)  | Über Apple Kalender verbunden |
+| Apple Kalender     | AppleScript / `osascript`      | Lokale App, macOS-nativ   |
+
+### Voraussetzungen auf dem System
+
+- **Python 3.8+** installiert (z. B. via Homebrew oder System-Python)
+- **pip**-Pakete (werden in Abschnitt 9 beschrieben)
+- **Apple Kalender App** ist geöffnet und mit Exchange-Konto verbunden
+- **Notion API Integration** mit Zugriff auf die Datenbank „Projekte" eingerichtet
+
+---
+
+## 3. Beabsichtigtes Verhalten
+
+### 3.1 Trigger
+
+Der Nutzer führt das Skript manuell aus. Per Parameter wird der Zielmonat angegeben (z. B. `--monat 2026-04` für April 2026). Das Skript kann auch mehrfach für denselben Monat ausgeführt werden – neue Einträge werden erstellt, geänderte Einträge aktualisiert.
+
+### 3.2 Datenquelle
+
+Das Skript liest alle Seiten aus der Notion-Datenbank **„Projekte"**, deren Eigenschaft `Last Edit` in den angegebenen Zielmonat fällt.
+
+### 3.3 Aggregationslogik
+
+Mehrere Notion-Einträge, die **am gleichen Tag** zum **gleichen Projekt** gehören, werden zu **einem einzigen Kalendertermin** zusammengeführt.
+
+### 3.4 Sequenzielle Terminreihenfolge
+
+An einem Tag mit mehreren Projekten liegen die Termine **nahtlos hintereinander**. Der erste Termin beginnt immer um **09:00 Uhr**. Jeder weitere Termin desselben Tages startet genau dort, wo der vorherige endet.
+
+**Sortierkriterium:** Die Reihenfolge der Termine pro Tag richtet sich nach dem **Projektnamen in alphabetischer Reihenfolge** (A → Z). Dadurch ist die Reihenfolge bei jedem Lauf identisch und reproduzierbar – auch bei nachträglichen Updates.
+
+**Beispiel:**
+
+| Notion-Eintrag | Last Edit  | Project   | T-Shirt Size |
+|----------------|------------|-----------|--------------|
+| Aufgabe A      | 2026-04-03 | EfA-RI NI | S (30 min)   |
+| Aufgabe B      | 2026-04-03 | EfA-RI NI | M (60 min)   |
+| Aufgabe C      | 2026-04-03 | DiPlanung | XS (15 min)  |
+
+**Aggregation (Summen pro Projekt):**
+
+| Projekt   | Gesamtdauer |
+|-----------|-------------|
+| DiPlanung | 15 min      |
+| EfA-RI NI | 90 min      |
+
+**Sortierung:** DiPlanung (D) kommt vor EfA-RI NI (E).
+
+**Ergebnis im Kalender:**
+
+| Kalendertermin | Datum      | Projekt   | Startzeit | Endzeit | Berechnung                        |
+|----------------|------------|-----------|-----------|---------|-----------------------------------|
+| Termin 1       | 03.04.2026 | DiPlanung | 09:00     | 09:15   | 09:00 + 15 min                    |
+| Termin 2       | 03.04.2026 | EfA-RI NI | 09:15     | 10:45   | 09:15 (Ende Termin 1) + 90 min    |
+
+### 3.5 Update-Verhalten
+
+Das Skript unterscheidet bei jedem ermittelten Kalendertermin drei Zustände:
+
+| Zustand          | Bedingung                                                                  | Aktion                                        |
+|------------------|----------------------------------------------------------------------------|-----------------------------------------------|
+| **Neu**          | Kein Eintrag in der Protokolldatei für diese Datum+Projekt-Kombination    | Termin wird neu erstellt                      |
+| **Unverändert**  | Protokolleintrag vorhanden, Inhalt identisch (Dauer, Notizen, Zeiten)     | Kein API-Call, wird übersprungen              |
+| **Geändert**     | Protokolleintrag vorhanden, aber Inhalt hat sich verändert                | Termin wird via UID in Apple Kalender gesucht und aktualisiert |
+
+> **Warum UID?** Apple Kalender vergibt beim Erstellen eines Termins eine eindeutige `uid`. Diese wird in der Protokolldatei gespeichert. Bei einem Update-Lauf sucht das AppleScript den Termin anhand dieser UID und überschreibt dessen Eigenschaften – kein Löschen und Neuerstellen nötig.
+
+> **Wichtiger Seiteneffekt der sequenziellen Reihenfolge:** Ändert sich die Dauer eines Projekts an einem Tag, verschieben sich alle nachfolgenden Termine dieses Tages. Das Skript **berechnet alle Startzeiten eines Tages neu** und aktualisiert alle betroffenen Termine – auch solche, deren eigene Notion-Daten unverändert sind.
+
+### 3.6 Kalendertermin-Eigenschaften
+
+| Feld          | Inhalt                                                                    |
+|---------------|---------------------------------------------------------------------------|
+| **Titel**     | Name des Projekts (z. B. `EfA-RI NI`)                                    |
+| **Datum**     | `Last Edit`-Datum aus Notion                                              |
+| **Startzeit** | Berechneter Beginn (09:00 für ersten Termin, Folgezeiten sequenziell)    |
+| **Endzeit**   | Startzeit + Summe aller T-Shirt-Size-Minuten                             |
+| **Notizen**   | Aufzählung aller enthaltenen Notion-Aufgabentitel + Größen               |
+| **Kalender**  | Der Exchange-Kalender in Apple Kalender (konfigurierbar)                 |
+
+### 3.7 Nicht abgedeckte Fälle
+
+- T-Shirt-Size fehlt bei einem Eintrag → Eintrag wird übersprungen und geloggt
+- `Last Edit` fehlt → Eintrag wird übersprungen und geloggt
+- Projekt-Relation fehlt → Eintrag wird übersprungen und geloggt
+- Termin wurde manuell in Apple Kalender gelöscht, UID existiert nicht mehr → Termin wird neu erstellt, neue UID gespeichert
+
+---
+
+## 4. Architektur & Datenfluss
+
+```
+┌─────────────────────────────────────────────────────────┐
+│                    PYTHON-SKRIPT                        │
+│                                                         │
+│  1. Konfiguration laden (config.json)                   │
+│  2. Protokolldatei laden (sync_state.json)              │
+│  3. Notion API abfragen (Datenbank „Projekte")          │
+│  4. Rohdaten filtern (nach Monat)                       │
+│  5. Daten aggregieren (pro Tag + Projekt)               │
+│  6. Sequenzielle Startzeiten pro Tag berechnen          │
+│  7. Für jeden Termin: Zustand bestimmen                 │
+│     (neu / unverändert / geändert)                      │
+│  8a. Neu      → AppleScript CREATE ausführen            │
+│  8b. Geändert → AppleScript UPDATE (per UID) ausführen  │
+│  8c. Unverändert → überspringen                         │
+│  9. Protokolldatei aktualisieren (UIDs speichern)       │
+└────────────────────┬────────────────────────────────────┘
+                     │  osascript (subprocess)
+                     ▼
+┌─────────────────────────────────────────────────────────┐
+│                  APPLE KALENDER APP                     │
+│                                                         │
+│  - CREATE: Neuen Termin anlegen, UID zurückgeben        │
+│  - UPDATE: Termin per UID suchen, Felder überschreiben  │
+│  - Synchronisiert automatisch mit Exchange Online       │
+└─────────────────────────────────────────────────────────┘
+                     │  Exchange ActiveSync / EWS
+                     ▼
+┌─────────────────────────────────────────────────────────┐
+│            MICROSOFT EXCHANGE ONLINE                    │
+│            → sichtbar in Outlook / OWA                  │
+└─────────────────────────────────────────────────────────┘
+```
+
+---
+
+## 5. Notion-Datenmodell
+
+### Datenbank: „Projekte"
+
+| Eigenschaft    | Notion-Typ | Beschreibung                                               |
+|----------------|------------|------------------------------------------------------------|
+| `Name`         | Title      | Bezeichnung der Aufgabe (frei)                            |
+| `T-Shirt Size` | Select     | Dauer der Aufgabe (XS / S / M / L / XL)                  |
+| `Last Edit`    | Date       | Datum, an dem die Aufgabe abgeschlossen wurde             |
+| `Project`      | Relation   | Verknüpfung zu einer Projekt-Datenbank; liefert ID + Name |
+
+### T-Shirt-Size-Mapping
+
+| Select-Wert | Minuten |
+|-------------|---------|
+| XS          | 15      |
+| S           | 30      |
+| M           | 60      |
+| L           | 120     |
+| XL          | 240     |
+
+Dieses Mapping wird in der Konfigurationsdatei (`config.json`) hinterlegt und kann dort einfach angepasst werden.
+
+### Notion API – Relevante Endpoints
+
+- **Datenbank abfragen:** `POST https://api.notion.com/v1/databases/{database_id}/query`
+- **Filter:** `Last Edit` between Monatsanfang und Monatsende
+- **API-Version:** `2022-06-28`
+
+### Beispiel-API-Filter (JSON-Body)
+
+```json
+{
+  "filter": {
+    "and": [
+      {
+        "property": "Last Edit",
+        "date": {
+          "on_or_after": "2026-04-01"
+        }
+      },
+      {
+        "property": "Last Edit",
+        "date": {
+          "on_or_before": "2026-04-30"
+        }
+      }
+    ]
+  }
+}
+```
+
+### Pagination
+
+Die Notion API gibt maximal 100 Ergebnisse pro Anfrage zurück. Das Skript muss über den `next_cursor`-Mechanismus alle Seiten vollständig einlesen (Pagination-Loop).
+
+### Relation-Feld auslesen
+
+Das Feld `Project` liefert im API-Response eine Liste von IDs. Um den Projektnamen zu erhalten, muss das Skript für jede eindeutige Projekt-ID einmalig `GET https://api.notion.com/v1/pages/{page_id}` aufrufen und den `title`-Wert extrahieren. Die Ergebnisse werden innerhalb eines Laufs gecacht, um API-Calls zu minimieren.
+
+---
+
+## 6. Datenverarbeitung & Geschäftslogik
+
+### 6.1 Datenabruf
+
+```
+notion_pages = query_notion_database(
+    database_id = config["notion_database_id"],
+    month_start = "YYYY-MM-01",
+    month_end   = "YYYY-MM-{letzter_tag}"
+)
+```
+
+### 6.2 Normalisierung je Notion-Seite
+
+Für jede geladene Notion-Seite werden folgende Felder extrahiert:
+
+```python
+{
+    "title":        str,   # Name der Notion-Seite
+    "t_shirt_size": str,   # z. B. "M"
+    "minutes":      int,   # aus Mapping: 60
+    "last_edit":    date,  # Python date-Objekt
+    "project_id":   str,   # Notion-Page-ID des Projekts
+    "project_name": str    # aufgelöst via separatem API-Call + Cache
+}
+```
+
+### 6.3 Aggregation
+
+**Schritt 1 – Gruppierung** nach Schlüssel **(last_edit, project_name)**:
+
+Für jede Gruppe wird ermittelt:
+- `total_minutes` = Summe aller `minutes`-Werte
+- `notes` = Aufzählung aller Aufgabentitel + T-Shirt-Größe, z. B.:
+  ```
+  - Konzept erstellt (S)
+  - Review-Meeting vorbereitet (M)
+  ```
+
+**Schritt 2 – Tagesweise Sortierung und Startzeitberechnung:**
+
+Alle Gruppen eines Tages werden **alphabetisch nach `project_name`** sortiert. Anschließend werden die Startzeiten sequenziell berechnet:
+
+```python
+day_start_hour = config["day_start_hour"]   # Standard: 9
+cursor = datetime(year, month, day, day_start_hour, 0)
+
+for event in sorted_events_for_day:
+    event["start_time"] = cursor
+    event["end_time"]   = cursor + timedelta(minutes=event["total_minutes"])
+    cursor              = event["end_time"]   # nächster Termin beginnt hier
+```
+
+**Ergebnis je Kalender-Event:**
+
+```python
+{
+    "project_name":  str,
+    "date":          date,
+    "start_time":    time,   # berechnete Startzeit
+    "end_time":      time,   # berechnete Endzeit
+    "total_minutes": int,
+    "notes":         str
+}
+```
+
+### 6.4 Zustandsbestimmung (neu / unverändert / geändert)
+
+Die Protokolldatei `sync_state.json` enthält für jede bereits verarbeitete Datum+Projekt-Kombination einen Eintrag:
+
+```json
+{
+  "2026-04-03|DiPlanung": {
+    "uid":           "ABC123-...",
+    "start_time":    "09:00",
+    "end_time":      "09:15",
+    "total_minutes": 15,
+    "notes":         "- Aufgabe C (XS)"
+  },
+  "2026-04-03|EfA-RI NI": {
+    "uid":           "DEF456-...",
+    "start_time":    "09:15",
+    "end_time":      "10:45",
+    "total_minutes": 90,
+    "notes":         "- Aufgabe A (S)\n- Aufgabe B (M)"
+  }
+}
+```
+
+**Vergleichslogik pro ermitteltem Event:**
+
+```
+key = "YYYY-MM-DD|Projektname"
+
+if key not in protocol:
+    → Zustand: NEU
+elif protocol[key].total_minutes != event.total_minutes
+  or protocol[key].notes         != event.notes
+  or protocol[key].start_time    != event.start_time:
+    → Zustand: GEÄNDERT  (uid aus Protokoll übernehmen)
+else:
+    → Zustand: UNVERÄNDERT
+```
+
+> **Hinweis zu Startzeitenänderungen:** Da Startzeiten von anderen Projekten desselben Tages abhängen, kann ein Event als GEÄNDERT gelten, auch wenn seine eigenen Notion-Daten unverändert sind (weil ein Vorgänger-Termin eine andere Dauer bekommen hat). Dies ist korrekt – der Termin muss dann verschoben werden.
+
+### 6.5 UID-Rückgabe bei CREATE
+
+Das AppleScript gibt nach dem Erstellen eines neuen Termins dessen `uid` als String zurück. Python liest diesen Wert aus dem `stdout` des `osascript`-Prozesses und speichert ihn in der Protokolldatei.
+
+---
+
+## 7. Komponente 1 – Python-Skript
+
+### Dateiname: `notion_to_calendar.py`
+
+### Abhängigkeiten (pip)
+
+```
+requests>=2.31.0
+python-dateutil>=2.8.2
+```
+
+Installation: `pip install requests python-dateutil`
+
+### Kommandozeilenparameter
+
+| Parameter    | Pflicht | Beschreibung                                        | Beispiel            |
+|--------------|---------|-----------------------------------------------------|---------------------|
+| `--monat`    | Ja      | Zielmonat im Format YYYY-MM                         | `--monat 2026-04`   |
+| `--dry-run`  | Nein    | Zeigt Termine an, ohne sie anzulegen oder zu ändern | `--dry-run`         |
+| `--kalender` | Nein    | Name des Zielkalenders (überschreibt config.json)   | `--kalender "Work"` |
+
+### Programmablauf (Pseudocode)
+
+```
+1.  Konfiguration laden aus config.json
+2.  Kommandozeilenparameter parsen
+3.  Monatsanfang und Monatsende berechnen
+4.  Protokolldatei laden (sync_state.json), falls vorhanden
+5.  Notion-Datenbank abfragen (mit Pagination)
+6.  Für jede Notion-Seite:
+    a. Pflichtfelder prüfen (Last Edit, T-Shirt Size, Project)
+    b. Falls fehlend: loggen, überspringen
+    c. Daten normalisieren
+    d. Projekt-ID → Projektname auflösen (mit Cache)
+7.  Daten aggregieren (gruppiert nach Last Edit + Projektname)
+8.  Für jeden Tag: Projekte alphabetisch sortieren, Startzeiten sequenziell berechnen
+9.  Für jedes Event: Zustand bestimmen (neu / unverändert / geändert)
+10. Im --dry-run-Modus: alle Events tabellarisch mit Zustand ausgeben, dann Abbruch
+11. Für jedes Event (nicht dry-run):
+    a. UNVERÄNDERT → überspringen, loggen
+    b. NEU         → AppleScript CREATE ausführen
+                   → UID aus stdout lesen
+                   → Protokolldatei aktualisieren
+    c. GEÄNDERT    → AppleScript UPDATE (mit gespeicherter UID) ausführen
+                   → Bei "not found"-Fehler: Fallback auf CREATE, neue UID speichern
+                   → Protokolldatei aktualisieren
+12. Zusammenfassung ausgeben (X erstellt, Y aktualisiert, Z unverändert, W Fehler)
+```
+
+### Wichtige Python-Funktionen (zu implementieren)
+
+```python
+def load_config(path: str) -> dict
+    # Lädt config.json, validiert Pflichtfelder
+
+def get_month_bounds(month_str: str) -> tuple[date, date]
+    # "2026-04" → (date(2026,4,1), date(2026,4,30))
+
+def query_notion_database(database_id: str, token: str,
+                          start: date, end: date) -> list[dict]
+    # Notion API mit Pagination; gibt rohe page-Objekte zurück
+
+def resolve_project_name(page_id: str, token: str,
+                         cache: dict) -> str | None
+    # Löst Projekt-ID → Name auf; nutzt Cache
+
+def normalize_page(page: dict, token: str,
+                   cache: dict, size_map: dict) -> dict | None
+    # Extrahiert und normalisiert Felder; None bei fehlendem Pflichtfeld
+
+def aggregate_events(pages: list[dict]) -> dict[date, list[dict]]
+    # Gruppiert nach (date, project) und summiert Minuten
+    # Gibt dict: date → Liste von Event-Dicts (noch ohne Startzeiten)
+
+def assign_sequential_times(events_by_day: dict,
+                             day_start_hour: int) -> list[dict]
+    # Sortiert pro Tag alphabetisch, berechnet Startzeiten sequenziell
+    # Gibt flache Liste aller Events mit start_time und end_time zurück
+
+def determine_state(event: dict, protocol: dict) -> str
+    # Gibt "new", "unchanged" oder "changed" zurück
+
+def build_create_script(event: dict, calendar_name: str) -> str
+    # Generiert AppleScript für neuen Termin; Script gibt UID auf stdout zurück
+
+def build_update_script(event: dict, uid: str,
+                        calendar_name: str) -> str
+    # Generiert AppleScript für Update eines bestehenden Termins per UID
+
+def run_applescript(script: str) -> tuple[bool, str]
+    # Führt AppleScript via subprocess aus
+    # Gibt (Erfolg: bool, stdout: str) zurück
+
+def load_protocol(path: str) -> dict
+    # Lädt sync_state.json; gibt leeres dict zurück, wenn Datei fehlt
+
+def save_protocol(path: str, protocol: dict) -> None
+    # Schreibt aktualisiertes dict als JSON zurück
+
+def escape_applescript_string(s: str) -> str
+    # Escaped Sonderzeichen für AppleScript-Strings
+```
+
+---
+
+## 8. Komponente 2 – AppleScript-Integration
+
+### Prinzip
+
+Das Python-Skript generiert AppleScript-Strings und führt diese via `subprocess.run(["osascript", "-e", script])` aus. Es gibt zwei Script-Templates: eines für **CREATE** (gibt UID zurück), eines für **UPDATE** (sucht Event per UID und überschreibt Felder).
+
+### AppleScript-Template: CREATE
+
+Das Script erstellt den Termin und gibt dessen `uid` auf stdout aus. Python liest diesen Wert aus.
+
+```applescript
+tell application "Calendar"
+    tell calendar "{CALENDAR_NAME}"
+        set startDate to current date
+        set year of startDate to {YEAR}
+        set month of startDate to {MONTH}
+        set day of startDate to {DAY}
+        set hours of startDate to {START_HOUR}
+        set minutes of startDate to {START_MINUTE}
+        set seconds of startDate to 0
+
+        set endDate to current date
+        set year of endDate to {YEAR}
+        set month of endDate to {MONTH}
+        set day of endDate to {DAY}
+        set hours of endDate to {END_HOUR}
+        set minutes of endDate to {END_MINUTE}
+        set seconds of endDate to 0
+
+        set newEvent to make new event with properties {¬
+            summary:"{PROJECT_NAME}", ¬
+            start date:startDate, ¬
+            end date:endDate, ¬
+            description:"{NOTES}"}
+
+        return uid of newEvent
+    end tell
+end tell
+```
+
+Python liest die UID aus stdout:
+
+```python
+result = subprocess.run(["osascript", "-e", script],
+                        capture_output=True, text=True)
+uid = result.stdout.strip()
+```
+
+### AppleScript-Template: UPDATE
+
+Das Script sucht den Termin anhand der gespeicherten UID und überschreibt alle relevanten Felder.
+
+```applescript
+tell application "Calendar"
+    tell calendar "{CALENDAR_NAME}"
+        set matchingEvents to (every event whose uid is "{UID}")
+        if (count of matchingEvents) = 0 then
+            return "ERROR: not found"
+        end if
+
+        set theEvent to item 1 of matchingEvents
+
+        set startDate to current date
+        set year of startDate to {YEAR}
+        set month of startDate to {MONTH}
+        set day of startDate to {DAY}
+        set hours of startDate to {START_HOUR}
+        set minutes of startDate to {START_MINUTE}
+        set seconds of startDate to 0
+
+        set endDate to current date
+        set year of endDate to {YEAR}
+        set month of endDate to {MONTH}
+        set day of endDate to {DAY}
+        set hours of endDate to {END_HOUR}
+        set minutes of endDate to {END_MINUTE}
+        set seconds of endDate to 0
+
+        set start date of theEvent to startDate
+        set end date of theEvent to endDate
+        set description of theEvent to "{NOTES}"
+        return "OK"
+    end tell
+end tell
+```
+
+### Rückgabewert-Auswertung (UPDATE)
+
+```python
+success, stdout = run_applescript(script)
+if not success or stdout.strip().startswith("ERROR"):
+    # Termin existiert nicht mehr → Fallback auf CREATE
+    log_warning(f"Update fehlgeschlagen ({stdout.strip()}), lege Termin neu an.")
+    # → build_create_script aufrufen, neue UID speichern
+```
+
+### Sonderzeichen escapen
+
+```python
+def escape_applescript_string(s: str) -> str:
+    s = s.replace("\\", "\\\\")  # Backslashes zuerst
+    s = s.replace('"', '\\"')    # Anführungszeichen
+    s = s.replace("\n", "\\n")   # Zeilenumbrüche in description
+    return s
+```
+
+### Kalender-Name-Validierung
+
+Existiert der konfigurierte Kalender nicht, gibt AppleScript einen Fehler zurück. Das Skript fängt diesen ab und bricht mit einer klaren Meldung ab: „Kalender '{name}' nicht gefunden – bitte calendar_name in config.json prüfen."
+
+---
+
+## 9. Konfiguration durch den Nutzer
+
+Die gesamte nutzerspezifische Konfiguration liegt in `config.json`. Diese Datei **darf nicht in ein öffentliches Repository eingecheckt werden**.
+
+### Datei: `config.json`
+
+```json
+{
+  "notion_token": "secret_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
+  "notion_database_id": "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
+  "calendar_name": "NAME_DES_EXCHANGE_KALENDERS",
+  "day_start_hour": 9,
+  "t_shirt_size_map": {
+    "XS": 15,
+    "S":  30,
+    "M":  60,
+    "L":  120,
+    "XL": 240
+  },
+  "protocol_file": "sync_state.json",
+  "log_file": "notion_sync.log"
+}
+```
+
+**Feld `day_start_hour`:** Startzeit des ersten Termins eines Tages in Stunden (Standard: `9` für 09:00 Uhr). Kann hier ohne Code-Änderung angepasst werden.
+
+### Schritt-für-Schritt-Anleitung zur Konfiguration
+
+#### Schritt 1 – Notion API Token ermitteln
+
+1. Öffne [https://www.notion.so/my-integrations](https://www.notion.so/my-integrations)
+2. Wähle deine bestehende Integration aus
+3. Kopiere den **„Internal Integration Token"** (beginnt mit `secret_...`)
+4. Trage den Wert für `"notion_token"` in `config.json` ein
+
+#### Schritt 2 – Notion Datenbank-ID ermitteln
+
+1. Öffne die Notion-Datenbank „Projekte" im Browser
+2. Die URL hat das Format: `https://www.notion.so/{workspace}/{DATABASE_ID}?v=...`
+3. Kopiere die **32-stellige ID** vor dem `?v=`
+4. Falls keine Bindestriche vorhanden: manuell einfügen nach Schema `8-4-4-4-12`  
+   Beispiel: `12345678901234567890123456789012` → `12345678-9012-3456-7890-123456789012`
+5. Trage die ID als `"notion_database_id"` ein
+
+> **Wichtig:** Die Integration muss Datenbankzugriff haben.  
+> In Notion: Datenbank öffnen → `...` Menü → **„Connections"** → Integration auswählen.
+
+#### Schritt 3 – Kalender-Name ermitteln
+
+1. Öffne die **Apple Kalender App**
+2. In der linken Seitenleiste alle verfügbaren Kalender prüfen
+3. Den Kalender finden, der mit dem Exchange-Konto verbunden ist  
+   (erkennbar am Exchange-Icon oder der Arbeits-E-Mail-Adresse)
+4. Den **exakten Namen** notieren (Groß-/Kleinschreibung und Leerzeichen beachten)
+5. Als `"calendar_name"` eintragen
+
+#### Schritt 4 – Python-Abhängigkeiten installieren
+
+```bash
+pip install requests python-dateutil
+# oder bei Homebrew-Python:
+pip3 install requests python-dateutil
+```
+
+---
+
+## 10. Projektstruktur
+
+```
+notion-to-calendar/
+│
+├── notion_to_calendar.py   # Haupt-Skript
+├── config.json             # Nutzerkonfiguration (nicht einchecken!)
+├── sync_state.json         # Protokolldatei mit UIDs (automatisch erstellt)
+├── notion_sync.log         # Log-Datei (automatisch erstellt)
+├── .gitignore
+└── README.md
+```
+
+### `.gitignore` (Mindestinhalt)
+
+```
+config.json
+sync_state.json
+notion_sync.log
+__pycache__/
+*.pyc
+```
+
+---
+
+## 11. Fehlerbehandlung & Logging
+
+### Fehlerklassen
+
+| Fehlertyp                          | Verhalten                                                                    |
+|------------------------------------|------------------------------------------------------------------------------|
+| Notion API nicht erreichbar        | Abbruch mit Fehlermeldung, Exit Code 1                                      |
+| Ungültiger Notion-Token            | Abbruch mit Hinweis, Token in config.json zu prüfen                        |
+| Datenbank-ID nicht gefunden        | Abbruch mit Hinweis auf falsche ID                                          |
+| Pflichtfeld fehlt (Notion-Seite)   | Seite überspringen, in Log schreiben, weitermachen                         |
+| Unbekannter T-Shirt-Size-Wert      | Seite überspringen, in Log schreiben                                        |
+| Apple Kalender nicht geöffnet      | osascript-Fehler abfangen, Meldung ausgeben, Termin überspringen           |
+| Kalender-Name nicht gefunden       | Abbruch mit Hinweis auf falschen Kalender-Namen                            |
+| UID bei UPDATE nicht gefunden      | Fallback auf CREATE, neue UID speichern, Warnung loggen                    |
+| AppleScript-Fehler (allgemein)     | Termin überspringen, Fehler in Log schreiben                               |
+| Protokolldatei nicht schreibbar    | Warnung ausgeben, Skript läuft weiter                                      |
+
+### Log-Format
+
+```
+[2026-04-30 17:42:01] INFO  Starte Sync für Monat: 2026-04
+[2026-04-30 17:42:02] INFO  21 Notion-Seiten geladen
+[2026-04-30 17:42:02] WARN  Seite "Aufgabe X": T-Shirt Size fehlt – übersprungen
+[2026-04-30 17:42:03] INFO  NEU:          DiPlanung  | 2026-04-03 | 09:00–09:15
+[2026-04-30 17:42:03] INFO  NEU:          EfA-RI NI  | 2026-04-03 | 09:15–10:45
+[2026-04-30 17:42:05] INFO  GEÄNDERT:     EfA-RI NI  | 2026-04-07 | 09:00–11:00 (vorher 09:00–10:30)
+[2026-04-30 17:42:06] INFO  UNVERÄNDERT:  DiPlanung  | 2026-04-07 – übersprungen
+[2026-04-30 17:42:10] INFO  Sync abgeschlossen: 10 erstellt, 3 aktualisiert, 8 unverändert, 1 Fehler
+```
+
+---
+
+## 12. Testszenarien
+
+### Szenario 1 – Normalbetrieb, erster Lauf
+- **Input:** 2 Projekte, 3 Tage, mehrere Einträge pro Tag
+- **Erwartung:** Korrekte Anzahl Termine, sequenzielle Uhrzeiten, alphabetische Reihenfolge, UIDs in sync_state.json gespeichert
+
+### Szenario 2 – Zweiter Lauf ohne Änderungen
+- **Input:** Identische Notion-Daten, sync_state.json bereits vorhanden
+- **Erwartung:** 0 Termine erstellt oder geändert; alle als „unverändert" geloggt
+
+### Szenario 3 – Notion-Daten wurden nachträglich geändert
+- **Input:** T-Shirt-Size eines Eintrags für EfA-RI NI von S auf L geändert
+- **Erwartung:** EfA-RI NI-Termin wird aktualisiert; falls DiPlanung am gleichen Tag danach kommt, wird dessen Startzeit ebenfalls neu gesetzt (Update)
+
+### Szenario 4 – Termin manuell in Apple Kalender gelöscht
+- **Input:** UID in sync_state.json vorhanden, aber Termin im Kalender nicht mehr existent
+- **Erwartung:** Fallback auf CREATE, Termin neu angelegt, neue UID gespeichert, Warnung im Log
+
+### Szenario 5 – `--dry-run`
+- **Input:** `--monat 2026-04 --dry-run`
+- **Erwartung:** Tabellarische Ausgabe aller Events mit Zustand (NEU / GEÄNDERT / UNVERÄNDERT), kein AppleScript ausgeführt, sync_state.json unverändert
+
+### Szenario 6 – Fehlende Pflichtfelder
+- **Input:** Notion-Seite ohne `Last Edit`
+- **Erwartung:** Seite wird übersprungen, Warnung im Log, keine Exception
+
+### Szenario 7 – Ungültiger Kalender-Name
+- **Input:** Falscher `calendar_name` in config.json
+- **Erwartung:** Klare Fehlermeldung beim ersten Script-Aufruf, sauberer Abbruch
+
+### Szenario 8 – Einzelner Eintrag pro Tag/Projekt
+- **Input:** Genau ein Notion-Eintrag (XS = 15 min), kein zweites Projekt an diesem Tag
+- **Erwartung:** Termin 09:00–09:15
+
+### Szenario 9 – Drei Projekte an einem Tag
+- **Input:** Projekte „Alpha" (M=60 min), „Beta" (S=30 min), „Gamma" (XS=15 min)
+- **Erwartung:**
+  - Alpha: 09:00–10:00
+  - Beta:  10:00–10:30
+  - Gamma: 10:30–10:45
+
+### Szenario 10 – Große Gesamtdauer
+- **Input:** XL + L + L an einem Tag für ein Projekt (480 min = 8h)
+- **Erwartung:** Termin 09:00–17:00; kein Crash auch wenn nachfolgende Termine über 17:00 hinausgehen
+
+---
+
+## 13. Ausführung & Workflow
+
+### Erster Start (Einrichtung)
+
+```bash
+# 1. Projektverzeichnis anlegen
+mkdir notion-to-calendar && cd notion-to-calendar
+
+# 2. notion_to_calendar.py ablegen
+
+# 3. config.json anlegen und befüllen (siehe Abschnitt 9)
+
+# 4. Abhängigkeiten installieren
+pip install requests python-dateutil
+
+# 5. Probelauf ohne echte Kalendereinträge
+python notion_to_calendar.py --monat 2026-04 --dry-run
+```
+
+### Reguläre Monatsausführung
+
+```bash
+# Apple Kalender sicherstellen (muss geöffnet und mit Exchange verbunden sein)
+open -a Calendar
+
+# Sync ausführen
+python notion_to_calendar.py --monat 2026-04
+```
+
+### Nachträgliche Korrekturen in Notion
+
+Falls nach dem Monatsabschluss Daten in Notion korrigiert wurden:
+
+```bash
+# Skript erneut ausführen – geänderte Termine werden automatisch aktualisiert
+python notion_to_calendar.py --monat 2026-04
+```
+
+### Nach der Ausführung
+
+1. Apple Kalender auf erstellte/aktualisierte Termine prüfen
+2. Kurz warten (~1–5 Minuten) bis Exchange-Sync abgeschlossen
+3. Termine in Outlook / OWA verifizieren
+4. Log-Datei bei Bedarf prüfen: `cat notion_sync.log`
+
+---
+
+*Ende der Dokumentation – Version 1.1*

+ 274 - 1
README.md

@@ -1,2 +1,275 @@
-# Notion-Calendar-Sync
+# Benutzerhandbuch
+## Notion → Apple Kalender / Exchange Sync
 
+Willkommen! Dieses Tool nimmt deine in Notion abgeschlossenen Aufgaben und legt für dich automatisch saubere Kalendertermine im Apple Kalender an — die dann via Exchange in Outlook landen. So musst du am Monatsende nicht mehr manuell Termine nachpflegen.
+
+---
+
+## Inhaltsverzeichnis
+
+1. [Was macht das Tool?](#1-was-macht-das-tool)
+2. [Voraussetzungen](#2-voraussetzungen)
+3. [Einmalige Einrichtung](#3-einmalige-einrichtung)
+4. [Tägliche / monatliche Nutzung](#4-tägliche--monatliche-nutzung)
+5. [Was passiert bei mehrfacher Ausführung?](#5-was-passiert-bei-mehrfacher-ausführung)
+6. [Termin nachträglich korrigieren](#6-termin-nachträglich-korrigieren)
+7. [Häufige Fehler & Lösungen](#7-häufige-fehler--lösungen)
+8. [FAQ](#8-faq)
+
+---
+
+## 1. Was macht das Tool?
+
+Das Skript liest Aufgaben aus deiner Notion-Tasks-Datenbank, die
+
+- den **Status `Done`** haben **und**
+- ein **Erledigt-Datum** in einem bestimmten Monat tragen.
+
+Pro Tag und Projekt entsteht **ein** Kalendertermin. Er enthält:
+
+- **Titel:** alle erledigten Aufgaben dieses Tages für dieses Projekt, mit Semikolon getrennt.
+- **Beschreibung:** der Projekt-Anzeige-Name (z. B. ein Projekt-Code).
+- **Dauer:** Summe aller T-Shirt-Größen der enthaltenen Aufgaben (XS = 15 min, S = 30, M = 60, L = 120, XL = 240).
+- **Startzeit:** Der erste Termin eines Tages startet um 09:00 (konfigurierbar). Folgetermine schließen nahtlos an.
+
+**Beispiel:**
+
+| Notion-Aufgabe       | Datum      | Projekt   | Größe |
+|----------------------|------------|-----------|-------|
+| Konzept erstellt     | 03.04.2026 | EfA-RI-NI | M     |
+| Review-Meeting vorb. | 03.04.2026 | EfA-RI-NI | S     |
+| Statusbericht        | 03.04.2026 | DiPlanung | XS    |
+
+Wird zu:
+
+| Termin   | Datum       | Titel                                       | Beschreibung |
+|----------|-------------|---------------------------------------------|--------------|
+| 09:00–09:15 | 03.04.2026 | Statusbericht                               | DiPlanung    |
+| 09:15–10:45 | 03.04.2026 | Konzept erstellt;\nReview-Meeting vorb.     | EfA-RI-NI    |
+
+Die Reihenfolge pro Tag ist alphabetisch nach Projektname — bei Re-Runs identisch und damit reproduzierbar.
+
+## 2. Voraussetzungen
+
+- **Mac mit macOS** (das Tool nutzt AppleScript).
+- **Apple Kalender App**, verbunden mit deinem Exchange-/Outlook-Account.
+- **Python 3.8 oder neuer**, plus die Pakete `requests` und `python-dateutil`.
+- **Notion-Account** mit:
+  - einer **Tasks-Datenbank** (mit Properties wie `Status`, `Completed on`, `T-Shirt Size`, `Project`),
+  - einer **Projects-Datenbank**, auf die `Project` als Relation zeigt,
+  - einer **Internal Integration** mit Zugriff auf **beide** Datenbanken.
+
+## 3. Einmalige Einrichtung
+
+### 3.1 Python-Pakete installieren
+
+Im Terminal:
+
+```bash
+pip3 install requests python-dateutil
+```
+
+### 3.2 Notion-Integration erstellen
+
+1. Öffne <https://www.notion.so/my-integrations>.
+2. *„+ New integration"* → Name vergeben (z. B. „Calendar Sync") → Workspace wählen.
+3. Nach dem Erstellen siehst du den **„Internal Integration Token"** (beginnt mit `secret_`). Kopieren — den brauchst du gleich.
+
+### 3.3 **Wichtig:** Beide Datenbanken mit der Integration teilen
+
+> Das ist die häufigste Fehlerquelle. Wenn du das überspringst, „verschwindet" die `Project`-Relation aus der API.
+
+Für **jede** der beiden Datenbanken (Tasks **und** Projects):
+
+1. Datenbank in Notion öffnen.
+2. Oben rechts auf das `…`-Menü → **„Connections"**.
+3. Deine Integration auswählen → bestätigen.
+
+### 3.4 Datenbank-ID kopieren
+
+1. **Tasks-Datenbank** in Notion öffnen.
+2. URL ansehen: `https://www.notion.so/<workspace>/<DATABASE_ID>?v=…`
+3. Den 32-stelligen Block vor `?v=` kopieren.
+
+Beispiel: `https://www.notion.so/marius/22710a5f51bd81548772dd6626cfdb7f?v=abc` → ID = `22710a5f51bd81548772dd6626cfdb7f`.
+
+### 3.5 Apple Kalender vorbereiten
+
+1. Apple Kalender öffnen (`open -a Calendar`).
+2. In der linken Seitenleiste den Kalender finden, der auf das Exchange-Konto zeigt.
+3. **Exakten Namen** notieren — Groß-/Kleinschreibung und Leerzeichen zählen (z. B. `TSM`).
+
+### 3.6 `config.json` befüllen
+
+Im Projektordner liegt eine `config.json`. Trage deine Werte ein:
+
+```json
+{
+  "notion_token": "secret_DEIN_TOKEN",
+  "notion_database_id": "DEINE_DATENBANK_ID",
+  "calendar_name": "TSM",
+  "day_start_hour": 9,
+  "t_shirt_size_map": {
+    "XS": 15, "S": 30, "M": 60, "L": 120, "XL": 240
+  },
+  "protocol_file": "sync_state.json",
+  "log_file": "notion_sync.log",
+  "notion_property_names": {
+    "name": "Name",
+    "size": "T-Shirt Size",
+    "last_edit": "Completed on",
+    "project": "Project",
+    "status": "Status"
+  },
+  "projects": {
+    "EfA-RI-NI": "NII001",
+    "DiPlanung": "ITN001"
+  }
+}
+```
+
+**Was bedeutet was?**
+
+| Feld | Bedeutung |
+|------|-----------|
+| `notion_token` | Dein Internal-Integration-Token aus Schritt 3.2. |
+| `notion_database_id` | ID der Tasks-Datenbank aus Schritt 3.4. |
+| `calendar_name` | Name des Exchange-Kalenders aus Schritt 3.5. |
+| `day_start_hour` | Uhrzeit, ab der der erste Termin des Tages liegt. `9` bedeutet 09:00. |
+| `t_shirt_size_map` | Wie lang ist eine Aufgabe je T-Shirt-Größe (in Minuten)? |
+| `protocol_file` / `log_file` | Dateinamen für Status & Log — Default ist meist OK. |
+| `notion_property_names` | Wenn deine Notion-Properties anders heißen, hier eintragen. |
+| `projects` | **Filter + Mapping**: Schlüssel = Projektname **wie er in Notion steht**, Wert = wie er **im Kalender** angezeigt wird (Beschreibung). Nur Projekte aus dieser Liste werden synchronisiert. Ist `projects` leer, werden alle Projekte synchronisiert. |
+
+## 4. Tägliche / monatliche Nutzung
+
+### Standard-Aufruf (vergangener Monat)
+
+```bash
+open -a Calendar
+python3 notion_to_calendar.py
+```
+
+→ Synchronisiert automatisch den **vergangenen Monat**. Wenn du im Mai aufrufst, werden also die Aprildaten gezogen.
+
+### Bestimmten Monat angeben
+
+```bash
+python3 notion_to_calendar.py --monat 2026-04
+```
+
+### Vorschau (nichts ändern)
+
+```bash
+python3 notion_to_calendar.py --monat 2026-04 --dry-run
+```
+
+→ Zeigt eine Tabelle mit allen Terminen, die angelegt/aktualisiert würden, ohne irgendetwas zu schreiben.
+
+### Anderen Kalender wählen
+
+```bash
+python3 notion_to_calendar.py --monat 2026-04 --kalender "Privat"
+```
+
+→ Überschreibt einmalig den Kalendernamen aus `config.json`.
+
+## 5. Was passiert bei mehrfacher Ausführung?
+
+Das Skript ist **idempotent**:
+
+- Termine, die unverändert sind, werden **übersprungen** (kein Update).
+- Termine, die in Notion korrigiert wurden (z. B. T-Shirt-Größe geändert, Aufgabe ergänzt), werden **aktualisiert** — der bestehende Kalendertermin wird überschrieben, **nicht** gelöscht und neu angelegt. Outlook-Empfänger:innen sehen also dieselbe UID.
+- Nachfolgetermine eines Tages, deren Startzeit sich durch eine Längenänderung verschoben hat, werden **automatisch ebenfalls aktualisiert**.
+
+Du kannst das Skript also problemlos mehrfach pro Monat ausführen.
+
+## 6. Termin nachträglich korrigieren
+
+1. Ändere die Aufgabe in Notion (z. B. T-Shirt-Größe).
+2. Stelle sicher, dass `Status = Done` und `Completed on` weiterhin gesetzt sind.
+3. Skript erneut laufen lassen:
+
+   ```bash
+   python3 notion_to_calendar.py --monat 2026-04
+   ```
+
+4. Im Log siehst du `GEÄNDERT: …` für die betroffenen Termine.
+
+## 7. Häufige Fehler & Lösungen
+
+### „Eigenschaft 'Project' nicht in der Notion-Datenbank gefunden."
+
+**Ursache:** Die Projects-Datenbank ist nicht mit deiner Integration geteilt.
+**Lösung:** Schritt 3.3 wiederholen — Connections auch in der Projects-DB setzen.
+
+### „FEHLER: Ungültiger Notion-Token."
+
+**Ursache:** Token in `config.json` falsch oder abgelaufen.
+**Lösung:** Token unter <https://www.notion.so/my-integrations> neu kopieren.
+
+### „FEHLER: Notion-Datenbank nicht gefunden."
+
+**Ursache:** Datenbank-ID falsch **oder** Tasks-DB nicht mit der Integration geteilt.
+**Lösung:** ID kontrollieren, in der Tasks-DB Connections setzen.
+
+### „FEHLER: Kalender 'XYZ' nicht gefunden."
+
+**Ursache:** `calendar_name` weicht vom tatsächlichen Namen in Apple Kalender ab.
+**Lösung:** Namen exakt übernehmen (Groß-/Kleinschreibung, Leerzeichen).
+
+### Apple Kalender fragt nach Berechtigung
+
+Beim ersten Lauf erscheint ein macOS-Dialog: „Terminal möchte 'Calendar' steuern". → **Erlauben**. Lässt sich später unter *Systemeinstellungen → Datenschutz & Sicherheit → Automation* anpassen.
+
+### Im Kalender steht alles Mehrfach!
+
+**Symptom:** Doppelte oder dreifache Termine an denselben Tagen.
+**Ursache:** Wahrscheinlich wurde `sync_state.json` gelöscht und das Skript neu gestartet.
+**Lösung:** Doppelte Termine in Apple Kalender manuell löschen. Ab jetzt `sync_state.json` nicht mehr anfassen.
+
+### „Seite '…' Last Edit fehlt – übersprungen"
+
+**Ursache:** Die Notion-Page hat kein Datum in der konfigurierten `last_edit`-Property.
+**Lösung:** Datum eintragen oder Aufgabe als nicht erledigt belassen.
+
+## 8. FAQ
+
+**Welche Notion-Properties brauche ich genau?**
+
+Pflicht in der Tasks-DB:
+- ein Title-Feld (heißt typischerweise `Name` oder `Task Name`),
+- ein Select-Feld für die T-Shirt-Größe (Werte XS / S / M / L / XL),
+- ein Datums-, `Last edited time`-, `Created time`- oder Formula-Feld für `Completed on`,
+- eine Relation auf die Projects-DB,
+- ein Select- oder Status-Feld `Status` mit dem Wert `Done`.
+
+Heißen sie bei dir anders? Dann passe sie in `notion_property_names` an.
+
+**Werden auch Aufgaben ohne Größe synchronisiert?**
+
+Nein. Ohne T-Shirt-Größe wird die Aufgabe übersprungen und im Log vermerkt.
+
+**Werden Aufgaben **aller** Projekte synchronisiert?**
+
+Nur die in `projects` aufgelisteten — alles andere wird übersprungen. Wenn du **alle** willst, lass `projects` leer (`{}`) oder lass den Block ganz weg.
+
+**Was passiert, wenn ich einen Termin manuell im Kalender lösche?**
+
+Beim nächsten Lauf merkt das Skript, dass die UID nicht mehr existiert, und legt den Termin **neu** an. Eine Warnung erscheint im Log.
+
+**Kann ich das Tool für mehrere Notion-Datenbanken nutzen?**
+
+Aktuell ein Skript = eine `config.json` = eine Tasks-DB. Für eine zweite DB einen separaten Ordner mit eigener `config.json` und eigener `sync_state.json` anlegen.
+
+**Wie lange dauert es, bis Outlook die Termine sieht?**
+
+Apple Kalender synchronisiert im Hintergrund mit Exchange. Üblich sind 1–5 Minuten. Bei größeren Änderungen kann es länger dauern.
+
+**Gibt es einen Zeitplan / Auto-Run?**
+
+Nein, der Sync ist bewusst manuell ausgelöst. So behältst du die Kontrolle, wann Termine entstehen.
+
+---
+
+Viel Erfolg! Bei Auffälligkeiten lohnt sich immer ein Blick in `notion_sync.log` — dort steht, welche Pages aus welchem Grund übersprungen wurden.

+ 365 - 0
TECHNISCHE_DOKUMENTATION.md

@@ -0,0 +1,365 @@
+# Technische Dokumentation
+## Notion → Apple Kalender / Exchange Sync
+
+**Stand:** Mai 2026
+**Implementierung:** Python 3.8+ (`notion_to_calendar.py`) + AppleScript (über `osascript`)
+**Bezugsdokument:** `Design Document v1.1.md`
+
+---
+
+## Inhaltsverzeichnis
+
+1. [Überblick](#1-überblick)
+2. [Architektur](#2-architektur)
+3. [Datenfluss](#3-datenfluss)
+4. [Notion-Datenmodell & Schema-Auflösung](#4-notion-datenmodell--schema-auflösung)
+5. [Datenverarbeitung & Geschäftslogik](#5-datenverarbeitung--geschäftslogik)
+6. [AppleScript-Integration](#6-applescript-integration)
+7. [Konfiguration (`config.json`)](#7-konfiguration-configjson)
+8. [Protokolldatei (`sync_state.json`)](#8-protokolldatei-sync_statejson)
+9. [Logging & Fehlerbehandlung](#9-logging--fehlerbehandlung)
+10. [Wichtige Eigenheiten und Stolperfallen](#10-wichtige-eigenheiten-und-stolperfallen)
+11. [Code-Landkarte](#11-code-landkarte)
+
+---
+
+## 1. Überblick
+
+Das Skript synchronisiert abgeschlossene Aufgaben (`Status = Done`) aus einer Notion-Tasks-Datenbank in den lokalen Apple Kalender. Da der Apple Kalender mit dem Exchange-/Outlook-Konto verbunden ist, übernimmt macOS den weiteren Sync zu Outlook automatisch.
+
+Pro Tag und Projekt entsteht **genau ein** Kalendertermin, der alle an diesem Tag abgeschlossenen Aufgaben dieses Projekts bündelt. Mehrere Termine eines Tages liegen **back-to-back** ab `day_start_hour` (Default: 09:00 Uhr). Mehrfaches Ausführen ist idempotent: Unveränderte Daten erzeugen keine neuen API-Calls.
+
+## 2. Architektur
+
+```
+┌──────────────────────────────────────────────────────────┐
+│                  notion_to_calendar.py                   │
+│                                                          │
+│  Konfig laden  ─→  Schema/Notion-Query  ─→  Normalisierung
+│         │                                       │        │
+│         │                                       ▼        │
+│         │                              Aggregation pro   │
+│         │                              (Datum, Projekt)  │
+│         │                                       │        │
+│         │                                       ▼        │
+│         │                       sequenzielle Startzeiten │
+│         │                                       │        │
+│  Protokoll laden ─────────────→ Zustand bestimmen        │
+│                                                  │       │
+│                            new │ unchanged │ changed     │
+│                                                  │       │
+│                                                  ▼       │
+│                          AppleScript via osascript-Aufruf│
+│                                                  │       │
+│                                                  ▼       │
+│                       Protokoll speichern (UID, Hash)    │
+└──────────────────────────────────────────────────────────┘
+                             │
+                             ▼
+                ┌────────────────────────────┐
+                │   Apple Kalender (lokal)   │
+                │   – Exchange-Account       │
+                └────────────────────────────┘
+                             │
+                  Exchange ActiveSync / EWS
+                             ▼
+                 Microsoft Exchange / Outlook
+```
+
+## 3. Datenfluss
+
+1. `config.json` einlesen, Pflichtfelder prüfen, Defaults für `notion_property_names` und `projects` ergänzen.
+2. Logging initialisieren (Datei + stdout).
+3. Zielmonat ermitteln: `--monat` oder Default = vergangener Monat.
+4. `sync_state.json` laden (oder leeres Dict beginnen).
+5. Notion-Datenbank-Schema via `GET /v1/databases/{id}` abrufen.
+6. Property-Namen aus Config gegen Schema auflösen (Whitespace-/Case-tolerant) und Filtertypen ableiten.
+7. Notion-DB mit `POST /v1/databases/{id}/query` filtern (Last Edit ∈ Monat **AND** Status = Done) — paginiert über `next_cursor`.
+8. Jede Seite normalisieren: Title, T-Shirt-Size → Minuten, Datum, Projekt-Relation auflösen, Whitelist-Filter anwenden.
+9. Pro `(Datum, Projekt)` aggregieren: Summe der Minuten, alphabetisch sortierte Liste der Aufgabentitel.
+10. Pro Tag alphabetisch nach Projektname sortieren, Startzeiten sequenziell ab `day_start_hour` rechnen.
+11. Pro Event Zustand bestimmen (`new`/`unchanged`/`changed`) per Vergleich mit Protokoll.
+12. `--dry-run`: Tabelle ausgeben, Ende.
+    Sonst: AppleScript ausführen, UID einsammeln, Protokoll aktualisieren.
+13. Zusammenfassung loggen.
+
+## 4. Notion-Datenmodell & Schema-Auflösung
+
+### Erwartete Properties in der Tasks-Datenbank
+
+| Rolle (interner Schlüssel) | Default-Name | Akzeptierte Notion-Typen        | Verwendung                     |
+|----------------------------|--------------|---------------------------------|--------------------------------|
+| `name`                     | `Name`       | beliebige Title-Property        | Aufgabentitel (für Termin-Titel) |
+| `size`                     | `T-Shirt Size` | `select`                      | Mapping Größe → Minuten        |
+| `last_edit`                | `Completed on` | `date`, `last_edited_time`, `created_time`, `formula` (Result-Type `date`/`string`/`number`) | Datumsfilter & Termin-Datum |
+| `project`                  | `Project`    | `relation`                      | Aggregation & Whitelist        |
+| `status`                   | `Status`     | `select` oder `status`          | Filter `equals "Done"`         |
+
+**Auflösung:** Beim Start wird `GET /v1/databases/{id}` aufgerufen und für jeden Rollen-Namen aus `notion_property_names` ein Match im Schema gesucht. Match-Reihenfolge: exakter String → Whitespace-/Case-toleranter Match. Kann ein Property nicht gefunden werden, bricht das Skript ab und listet alle vorhandenen Properties.
+
+### Filter-Aufbau (Notion API)
+
+Für `last_edit` wird der Filter abhängig vom Schema-Typ gebaut:
+
+```jsonc
+// "date"
+{"property": "Completed on", "date": {"on_or_after": "2026-04-01"}}
+
+// "last_edited_time"
+{"property": "Last Edit", "last_edited_time": {"on_or_after": "2026-04-01"}}
+
+// "formula" (mit date-Result)
+{"property": "Completed on", "formula": {"date": {"on_or_after": "2026-04-01"}}}
+```
+
+Der Status-Filter passt sich `select`/`status` an. `Status = Done` ist hardcoded (Konstante `STATUS_DONE`).
+
+### Pagination
+
+Der Body wird in einer Schleife versendet, `next_cursor` aus der Response wird als `start_cursor` für den Folgeaufruf gesetzt. Page-Size: 100.
+
+### Auflösung des Projektnamens (Relation)
+
+Pro Notion-Seite wird die erste verknüpfte Projekt-Page (`relation[0].id`) per `GET /v1/pages/{page_id}` geholt. Aus deren Properties wird das Title-Property extrahiert (egal wie es heißt — wir suchen nach `type == "title"`). Ein In-Memory-Cache verhindert Mehrfachaufrufe.
+
+> ⚠ **Voraussetzung:** Die Integration muss Zugriff auf **beide** Datenbanken haben (Tasks **und** Projects). Fehlt der Zugriff auf die Projects-DB, blendet die Notion-API die Relation-Property komplett aus dem Schema aus.
+
+## 5. Datenverarbeitung & Geschäftslogik
+
+### Aggregation
+
+Schlüssel: `(last_edit, project_name)`. Pro Gruppe:
+
+- `total_minutes` = Summe der Größen-Minuten aller enthaltenen Notion-Pages
+- `summary` = Aufgabentitel der Gruppe, alphabetisch sortiert, verbunden mit `;\n`
+- `notes` = Anzeige-Name des Projekts (aus `projects`-Mapping)
+
+### Sequenzielle Startzeiten
+
+Pro Tag wird die Liste alphabetisch nach `project_name` sortiert. Cursor startet bei `datetime(year, month, day, day_start_hour, 0)` und wandert pro Termin um `total_minutes` weiter.
+
+```
+cursor = day_start
+for ev in sorted_by_project:
+    ev.start = cursor
+    ev.end   = cursor + total_minutes
+    cursor   = ev.end
+```
+
+### Zustandsbestimmung
+
+Pro Event wird ein Schlüssel `"YYYY-MM-DD|ProjektName"` gebildet und mit dem Protokoll verglichen. Verglichen werden: `total_minutes`, `summary`, `notes`, `start_time` (HH:MM), `end_time` (HH:MM).
+
+| Bedingung                                  | Zustand     | Aktion                                      |
+|--------------------------------------------|-------------|---------------------------------------------|
+| Schlüssel **nicht** im Protokoll           | `new`       | AppleScript `CREATE`, UID einsammeln        |
+| Schlüssel im Protokoll, alle Felder gleich | `unchanged` | Skip                                        |
+| Schlüssel im Protokoll, Felder weichen ab  | `changed`   | AppleScript `UPDATE` per UID; Fallback `CREATE` falls UID nicht mehr gefunden |
+
+### Seiteneffekt der Sequenzialisierung
+
+Ändert sich die Dauer eines Termins, verschieben sich alle nachfolgenden Termine desselben Tages. Diese werden im selben Lauf neu berechnet und als `changed` erkannt — ihre eigenen Notion-Daten müssen sich nicht geändert haben.
+
+## 6. AppleScript-Integration
+
+### Aufruf
+
+`subprocess.run(["osascript", "-e", script], capture_output=True, text=True, timeout=60)`. Rückgabe ist `(success: bool, stdout_or_stderr: str)`.
+
+### CREATE-Template (Auszug)
+
+```applescript
+tell application "Calendar"
+    tell calendar "{CALENDAR_NAME}"
+        ...
+        set newEvent to make new event with properties {summary:"{TITLE}", ...}
+        return uid of newEvent
+    end tell
+end tell
+```
+
+Die `uid` wird auf stdout zurückgegeben und in `sync_state.json` gespeichert.
+
+### UPDATE-Template (Auszug)
+
+```applescript
+tell application "Calendar"
+    tell calendar "{CALENDAR_NAME}"
+        set matchingEvents to (every event whose uid is "{UID}")
+        if (count of matchingEvents) = 0 then
+            return "ERROR: not found"
+        end if
+        set theEvent to item 1 of matchingEvents
+        set summary of theEvent to "{TITLE}"
+        set start date of theEvent to startDate
+        set end date of theEvent to endDate
+        set description of theEvent to "{NOTES}"
+        return "OK"
+    end tell
+end tell
+```
+
+Bei `ERROR: not found` (manuell gelöschter Termin) erfolgt automatisch ein Fallback-CREATE mit neuer UID.
+
+### Inhalte
+
+| Apple-Kalender-Feld | Quelle                                                     |
+|---------------------|------------------------------------------------------------|
+| `summary` (Titel)   | Aufgabentitel der Gruppe, alphabetisch, getrennt durch `;\n` |
+| `description`       | Projekt-Anzeige-Name (aus `projects`-Mapping)              |
+| `start date`        | Berechnete Startzeit                                       |
+| `end date`          | Startzeit + Summe der Minuten                              |
+| Kalender            | `calendar_name` aus Config (oder `--kalender`-Override)    |
+
+### Sonderzeichen-Escaping
+
+`escape_applescript_string` ersetzt in folgender Reihenfolge: Backslash → `\\`, Doppelanführungszeichen → `\"`, Newline → `\n`.
+
+### Kalender-Existenz
+
+Wenn der Kalendername nicht existiert, gibt AppleScript Fehlercode `-1728` bzw. eine Fehlermeldung mit "can't get calendar". Der Detektor `applescript_calendar_missing` fängt das ab und beendet das Skript mit klarer Meldung.
+
+## 7. Konfiguration (`config.json`)
+
+### Pflichtfelder
+
+| Feld                  | Typ      | Bedeutung                                                |
+|-----------------------|----------|----------------------------------------------------------|
+| `notion_token`        | string   | Internal Integration Token (`secret_…`)                  |
+| `notion_database_id`  | string   | ID der Tasks-Datenbank (32 Zeichen, mit oder ohne Bindestriche) |
+| `calendar_name`       | string   | Exakter Anzeigename des Exchange-Kalenders in Apple Kalender |
+| `day_start_hour`      | int      | Startzeit des ersten Termins eines Tages (24-h, z. B. 9) |
+| `t_shirt_size_map`    | object   | Mapping `Größe → Minuten`                                |
+| `protocol_file`       | string   | Pfad zur Protokolldatei                                  |
+| `log_file`            | string   | Pfad zur Logdatei                                        |
+
+### Optionale Felder (mit Defaults)
+
+| Feld                     | Default                      | Bedeutung                                              |
+|--------------------------|------------------------------|--------------------------------------------------------|
+| `notion_property_names`  | `{name, size, last_edit, project, status}` mit Standardnamen | Override der Notion-Property-Namen pro Rolle |
+| `projects`               | `{}` (leer = kein Filter)    | Whitelist + Anzeige-Name-Mapping. Schlüssel = Notion-Projektname (wie er in der Projects-DB steht), Wert = Anzeige-Name im Kalender |
+
+### Beispiel
+
+```json
+{
+  "notion_token": "secret_...",
+  "notion_database_id": "22710a5f51bd81548772dd6626cfdb7f",
+  "calendar_name": "TSM",
+  "day_start_hour": 9,
+  "t_shirt_size_map": {"XS": 15, "S": 30, "M": 60, "L": 120, "XL": 240},
+  "protocol_file": "sync_state.json",
+  "log_file": "notion_sync.log",
+  "notion_property_names": {
+    "name": "Name",
+    "size": "T-Shirt Size",
+    "last_edit": "Completed on",
+    "project": "Project",
+    "status": "Status"
+  },
+  "projects": {
+    "EfA-RI-NI": "NII001",
+    "DiPlanung": "ITN001"
+  }
+}
+```
+
+## 8. Protokolldatei (`sync_state.json`)
+
+JSON-Dict mit String-Schlüsseln `"YYYY-MM-DD|ProjektAnzeigeName"` und Werten:
+
+```json
+{
+  "2026-04-03|NII001": {
+    "uid": "ABC-123-...",
+    "start_time": "09:00",
+    "end_time": "10:30",
+    "total_minutes": 90,
+    "summary": "Aufgabe A;\nAufgabe B",
+    "notes": "NII001"
+  }
+}
+```
+
+- **Backup-fähig:** reine JSON-Datei, kann kopiert/gesichert werden.
+- **Reset:** Datei löschen → nächster Lauf erkennt alle Termine als `new` und legt sie an. Bestehende Apple-Kalender-Termine bleiben dabei stehen → ggf. Duplikate. Bei einem Reset also auch im Kalender aufräumen.
+- **Manuelle Reparatur:** UID kann gezielt entfernt werden, um einen Einzeltermin neu anzulegen.
+
+## 9. Logging & Fehlerbehandlung
+
+### Format
+
+`[YYYY-MM-DD HH:MM:SS] LEVEL  Message`
+
+Beispiel:
+
+```
+[2026-05-06 17:42:01] INFO  Starte Sync für Monat: 2026-04
+[2026-05-06 17:42:02] INFO  Notion-Schema erkannt: Completed on (formula), Status (select), Project (relation)
+[2026-05-06 17:42:02] INFO  21 Notion-Seiten geladen
+[2026-05-06 17:42:02] WARN  Seite "Aufgabe X": T-Shirt Size fehlt – übersprungen
+[2026-05-06 17:42:03] INFO  NEU:          NII001              | 2026-04-03 | 09:00–10:30
+[2026-05-06 17:42:10] INFO  Sync abgeschlossen: 10 erstellt, 3 aktualisiert, 8 unverändert, 1 Fehler
+```
+
+### Fehlerklassen (Auswahl)
+
+| Fehler                            | Behandlung                                              |
+|-----------------------------------|---------------------------------------------------------|
+| `config.json` fehlt / unvollständig | Sofortiger Abbruch, Hinweis welche Felder fehlen       |
+| Notion HTTP 401                   | Abbruch, Hinweis Token prüfen                          |
+| Notion HTTP 404                   | Abbruch, Hinweis DB-ID & Integrationszugriff prüfen    |
+| Property nicht im Schema          | Abbruch, alle vorhandenen Property-Namen werden gelistet |
+| Pflichtfeld in Page fehlt         | Page wird übersprungen, Warnung                         |
+| Unbekannter T-Shirt-Wert          | Page wird übersprungen, Warnung                         |
+| Projekt nicht in Whitelist        | Page wird übersprungen, Info                            |
+| AppleScript: Kalender nicht gefunden | Abbruch, Hinweis `calendar_name` prüfen              |
+| AppleScript: UID nicht gefunden   | Fallback-CREATE, neue UID gespeichert                   |
+| AppleScript: sonstiger Fehler     | Termin wird übersprungen, Fehler im Log                 |
+
+### Exit-Codes
+
+- `0` — Erfolg, keine Fehler
+- `1` — Abbruch mit Klartext-Fehlermeldung (z. B. Token, Schema, Kalender)
+- `2` — Sync gelaufen, aber einzelne Events haben Fehler produziert
+
+## 10. Wichtige Eigenheiten und Stolperfallen
+
+1. **Relation-Property unsichtbar:** Notion versteckt eine `relation`-Property komplett aus dem Schema, wenn die Ziel-Datenbank **nicht** mit der Integration geteilt ist. Symptom: „Eigenschaft 'Project' nicht in der Notion-Datenbank gefunden", obwohl sie im UI sichtbar ist. Fix: Projects-DB mit der Integration verbinden (`…`-Menü → Connections).
+2. **Formula-Properties:** Der `Last Edit`/`Completed on`-Filter unterstützt explizit `formula` mit Result-Type `date`/`string`/`number`. Andere Formula-Resulttypen werden nicht erkannt → Page wird übersprungen.
+3. **Reihenfolge ist deterministisch:** Sortierung pro Tag erfolgt strikt alphabetisch nach **Anzeige-Projektname** (`project_name` nach Mapping), nicht nach Reihenfolge in Notion. Re-Runs sind dadurch reproduzierbar.
+4. **Änderung der Dauer ⇒ Verschiebung Folgetermine:** Wird ein Vorgänger-Termin länger/kürzer, verschieben sich alle nachfolgenden Termine desselben Tages. Erwartet, aber bei Updates ggf. überraschend für Empfänger:innen.
+5. **Apple Kalender muss laufen:** Ohne offene Kalender-App schlägt `osascript` fehl. Beim Erstaufruf fragt macOS ggf. nach Automation-Berechtigung (Systemeinstellungen → Datenschutz → Automation).
+6. **Exchange-Sync ist asynchron:** Nach AppleScript-CREATE dauert es typischerweise 1–5 Minuten, bis der Termin in Outlook/OWA sichtbar ist.
+7. **Idempotenz hängt am Protokoll:** Geht `sync_state.json` verloren, werden alle Termine erneut angelegt → Duplikate in Apple Kalender. Datei nicht versehentlich löschen.
+8. **`config.json` enthält ein Geheimnis:** Der Notion-Token ist ein API-Schlüssel. Datei ist via `.gitignore` ausgeschlossen — beim Teilen des Repos darauf achten.
+
+## 11. Code-Landkarte
+
+| Funktion                             | Zweck                                                       |
+|--------------------------------------|-------------------------------------------------------------|
+| `load_config`                        | Liest und validiert `config.json`, ergänzt Defaults         |
+| `setup_logging`                      | Logging in Datei + stdout                                   |
+| `get_month_bounds`                   | `"YYYY-MM"` → `(first_day, last_day)`                       |
+| `previous_month_str`                 | Default für `--monat` (Vormonat als String)                 |
+| `get_database_schema`                | `GET /v1/databases/{id}` → `properties`-Dict                |
+| `_resolve_property_name` / `_require_property` | Whitespace-/Case-tolerante Property-Auflösung      |
+| `_detect_filter_type`                | Validiert Property-Typ gegen erlaubte Liste                 |
+| `query_notion_database`              | Filter-Bau, Pagination, gibt rohe Notion-Pages              |
+| `resolve_project_name`               | Projekt-Relation → Page-Title (mit Cache)                   |
+| `normalize_page`                     | Pflichtfeld-Validierung, Whitelist, Anzeige-Name-Mapping    |
+| `aggregate_events`                   | Gruppierung + `summary`/`notes`-Bau                         |
+| `assign_sequential_times`            | Tagesweise Sortierung + Startzeit-Berechnung                |
+| `determine_state`                    | `new` / `unchanged` / `changed` per Vergleich mit Protokoll |
+| `build_create_script` / `build_update_script` | AppleScript-Generatoren                            |
+| `run_applescript`                    | `osascript`-Aufruf, gibt `(ok, stdout_or_stderr)`           |
+| `escape_applescript_string`          | Sonderzeichen-Escaping                                      |
+| `sync_event`                         | State-Maschine: führt CREATE / UPDATE / Fallback aus        |
+| `load_protocol` / `save_protocol`    | JSON-Persistenz                                             |
+| `print_dry_run_table`                | Tabellen-Ausgabe für `--dry-run`                            |
+| `parse_args` / `main`                | CLI-Einstieg                                                |
+
+---
+
+*Ende der Technischen Dokumentation*

+ 27 - 0
config.json

@@ -0,0 +1,27 @@
+{
+  "notion_token": "secret_b7PiPL2FqC9QEikqkAEWOht7LmzPMIJMWTzUPWwbw4H",
+  "notion_database_id": "22710a5f51bd81548772dd6626cfdb7f",
+  "calendar_name": "TSM",
+  "day_start_hour": 9,
+  "t_shirt_size_map": {
+    "XS": 15,
+    "S":  30,
+    "M":  60,
+    "L":  120,
+    "XL": 240,
+    "XXL": 480
+  },
+  "protocol_file": "sync_state.json",
+  "log_file": "notion_sync.log",
+  "notion_property_names": {
+    "name": "Name",
+    "size": "T-Shirt Size",
+    "last_edit": "Completed on",
+    "project": "Project",
+    "status": "Status"
+  },
+  "projects": {
+    "EfA-RI-NI": "NII001",
+    "DiPlanung": "ITN001"
+  }
+}

+ 722 - 0
notion_to_calendar.py

@@ -0,0 +1,722 @@
+#!/usr/bin/env python3
+"""Notion → Apple Kalender / Exchange Sync (Design v1.1)."""
+
+from __future__ import annotations
+
+import argparse
+import calendar as cal
+import json
+import logging
+import subprocess
+import sys
+from collections import defaultdict
+from datetime import date, datetime, timedelta
+from pathlib import Path
+from typing import Any
+
+import requests
+from dateutil.parser import isoparse
+
+NOTION_API_BASE = "https://api.notion.com/v1"
+NOTION_VERSION = "2022-06-28"
+HTTP_TIMEOUT = 30
+
+DEFAULT_PROP_NAMES = {
+    "name": "Name",
+    "size": "T-Shirt Size",
+    "last_edit": "Completed on",
+    "project": "Project",
+    "status": "Status",
+}
+STATUS_DONE = "Done"
+
+log = logging.getLogger("notion_sync")
+
+
+# ---------- Config & Logging ----------
+
+def load_config(path: str) -> dict:
+    p = Path(path)
+    if not p.exists():
+        sys.exit(f"FEHLER: Konfigurationsdatei '{path}' nicht gefunden.")
+    with p.open("r", encoding="utf-8") as f:
+        cfg = json.load(f)
+    required = [
+        "notion_token", "notion_database_id", "calendar_name",
+        "day_start_hour", "t_shirt_size_map", "protocol_file", "log_file",
+    ]
+    missing = [k for k in required if k not in cfg]
+    if missing:
+        sys.exit(f"FEHLER: In config.json fehlen Pflichtfelder: {', '.join(missing)}")
+
+    user_pn = cfg.get("notion_property_names") or {}
+    if not isinstance(user_pn, dict):
+        sys.exit("FEHLER: 'notion_property_names' in config.json muss ein Objekt sein.")
+    cfg["notion_property_names"] = {**DEFAULT_PROP_NAMES, **user_pn}
+
+    projects = cfg.get("projects")
+    if projects is None:
+        projects = {}
+    if not isinstance(projects, dict):
+        sys.exit("FEHLER: 'projects' in config.json muss ein Objekt {Notion-Name: Anzeige-Name} sein.")
+    cfg["projects"] = {str(k): str(v) if v else str(k) for k, v in projects.items()}
+
+    return cfg
+
+
+def setup_logging(log_file: str) -> None:
+    fmt = "[%(asctime)s] %(levelname)-5s %(message)s"
+    datefmt = "%Y-%m-%d %H:%M:%S"
+    formatter = logging.Formatter(fmt, datefmt)
+
+    log.setLevel(logging.INFO)
+    log.handlers.clear()
+
+    fh = logging.FileHandler(log_file, encoding="utf-8")
+    fh.setFormatter(formatter)
+    log.addHandler(fh)
+
+    sh = logging.StreamHandler(sys.stdout)
+    sh.setFormatter(formatter)
+    log.addHandler(sh)
+
+
+# ---------- Datums-Helfer ----------
+
+def get_month_bounds(month_str: str) -> tuple[date, date]:
+    try:
+        year, month = (int(x) for x in month_str.split("-", 1))
+        first = date(year, month, 1)
+        last = date(year, month, cal.monthrange(year, month)[1])
+        return first, last
+    except (ValueError, AttributeError):
+        sys.exit(f"FEHLER: Ungültiger Wert für --monat: '{month_str}'. Format: YYYY-MM.")
+
+
+def previous_month_str(today: date | None = None) -> str:
+    today = today or date.today()
+    first_of_this_month = today.replace(day=1)
+    last_of_prev = first_of_this_month - timedelta(days=1)
+    return f"{last_of_prev.year:04d}-{last_of_prev.month:02d}"
+
+
+# ---------- Notion API ----------
+
+def _notion_headers(token: str) -> dict[str, str]:
+    return {
+        "Authorization": f"Bearer {token}",
+        "Notion-Version": NOTION_VERSION,
+        "Content-Type": "application/json",
+    }
+
+
+def get_database_schema(database_id: str, token: str) -> dict:
+    url = f"{NOTION_API_BASE}/databases/{database_id}"
+    try:
+        resp = requests.get(url, headers=_notion_headers(token), timeout=HTTP_TIMEOUT)
+    except requests.RequestException as e:
+        sys.exit(f"FEHLER: Notion API nicht erreichbar: {e}")
+    if resp.status_code == 401:
+        sys.exit("FEHLER: Ungültiger Notion-Token. Bitte 'notion_token' in config.json prüfen.")
+    if resp.status_code == 404:
+        sys.exit("FEHLER: Notion-Datenbank nicht gefunden. Bitte 'notion_database_id' und Integrationszugriff prüfen.")
+    if not resp.ok:
+        sys.exit(f"FEHLER: Datenbank-Schema konnte nicht abgefragt werden ({resp.status_code}): {resp.text}")
+    return resp.json().get("properties", {})
+
+
+def _resolve_property_name(schema: dict, requested: str) -> str | None:
+    if requested in schema:
+        return requested
+    target = requested.strip().casefold()
+    for actual in schema.keys():
+        if actual.strip().casefold() == target:
+            return actual
+    return None
+
+
+def _require_property(schema: dict, requested: str, role: str) -> str:
+    actual = _resolve_property_name(schema, requested)
+    if actual is None:
+        available = ", ".join(repr(k) for k in sorted(schema.keys()))
+        sys.exit(
+            f"FEHLER: Eigenschaft '{requested}' (für '{role}') nicht in der Notion-Datenbank gefunden. "
+            f"Verfügbare Properties: {available}"
+        )
+    if actual != requested:
+        log.warning("Property '%s' aufgelöst zu '%s' (Whitespace/Groß-Klein-Schreibung).",
+                    requested, actual)
+    return actual
+
+
+def _detect_filter_type(schema: dict, prop_name: str, allowed: tuple[str, ...]) -> str:
+    t = schema[prop_name].get("type")
+    if t in allowed:
+        return t
+    sys.exit(
+        f"FEHLER: Eigenschaft '{prop_name}' hat den nicht unterstützten Typ '{t}'. "
+        f"Erwartet: {', '.join(allowed)}."
+    )
+
+
+def query_notion_database(database_id: str, token: str, start: date, end: date,
+                          prop_names: dict) -> list[dict]:
+    schema = get_database_schema(database_id, token)
+
+    prop_names["last_edit"] = _require_property(schema, prop_names["last_edit"], "last_edit")
+    prop_names["status"] = _require_property(schema, prop_names["status"], "status")
+    prop_names["project"] = _require_property(schema, prop_names["project"], "project")
+    prop_names["size"] = _require_property(schema, prop_names["size"], "size")
+
+    le_name = prop_names["last_edit"]
+    status_name = prop_names["status"]
+    project_name = prop_names["project"]
+
+    le_filter_type = _detect_filter_type(
+        schema, le_name, ("date", "last_edited_time", "created_time", "formula"),
+    )
+    status_filter_type = _detect_filter_type(schema, status_name, ("select", "status"))
+    project_type = schema[project_name].get("type")
+    if project_type != "relation":
+        sys.exit(
+            f"FEHLER: Eigenschaft '{project_name}' hat den Typ '{project_type}', "
+            "erwartet wurde 'relation' (Verknüpfung)."
+        )
+    log.info(
+        "Notion-Schema erkannt: %s (%s), %s (%s), %s (relation)",
+        le_name, le_filter_type, status_name, status_filter_type, project_name,
+    )
+
+    def _date_filter(condition: dict) -> dict:
+        if le_filter_type == "formula":
+            return {"property": le_name, "formula": {"date": condition}}
+        return {"property": le_name, le_filter_type: condition}
+
+    url = f"{NOTION_API_BASE}/databases/{database_id}/query"
+    headers = _notion_headers(token)
+    body = {
+        "filter": {
+            "and": [
+                _date_filter({"on_or_after": start.isoformat()}),
+                _date_filter({"on_or_before": end.isoformat()}),
+                {"property": status_name, status_filter_type: {"equals": STATUS_DONE}},
+            ]
+        },
+        "page_size": 100,
+    }
+
+    pages: list[dict] = []
+    while True:
+        try:
+            resp = requests.post(url, headers=headers, json=body, timeout=HTTP_TIMEOUT)
+        except requests.RequestException as e:
+            sys.exit(f"FEHLER: Notion API nicht erreichbar: {e}")
+
+        if resp.status_code == 401:
+            sys.exit("FEHLER: Ungültiger Notion-Token. Bitte 'notion_token' in config.json prüfen.")
+        if resp.status_code == 404:
+            sys.exit("FEHLER: Notion-Datenbank nicht gefunden. Bitte 'notion_database_id' und Integrationszugriff prüfen.")
+        if not resp.ok:
+            sys.exit(f"FEHLER: Notion API antwortete mit {resp.status_code}: {resp.text}")
+
+        data = resp.json()
+        pages.extend(data.get("results", []))
+
+        if data.get("has_more"):
+            body["start_cursor"] = data["next_cursor"]
+        else:
+            break
+    return pages
+
+
+def resolve_project_name(page_id: str, token: str, cache: dict[str, str | None]) -> str | None:
+    if page_id in cache:
+        return cache[page_id]
+    url = f"{NOTION_API_BASE}/pages/{page_id}"
+    try:
+        resp = requests.get(url, headers=_notion_headers(token), timeout=HTTP_TIMEOUT)
+    except requests.RequestException as e:
+        log.warning("Projekt-Auflösung fehlgeschlagen für %s: %s", page_id, e)
+        cache[page_id] = None
+        return None
+    if not resp.ok:
+        log.warning("Projekt-Auflösung %s: HTTP %s", page_id, resp.status_code)
+        cache[page_id] = None
+        return None
+    page = resp.json()
+    name = _extract_title(page.get("properties", {}))
+    cache[page_id] = name
+    return name
+
+
+def _extract_title(props: dict) -> str | None:
+    for prop in props.values():
+        if prop.get("type") == "title":
+            parts = prop.get("title", [])
+            text = "".join(p.get("plain_text", "") for p in parts).strip()
+            return text or None
+    return None
+
+
+# ---------- Normalisierung ----------
+
+def normalize_page(page: dict, token: str, cache: dict[str, str | None],
+                   size_map: dict[str, int], prop_names: dict,
+                   project_filter: dict[str, str]) -> dict | None:
+    props = page.get("properties", {})
+    page_id = page.get("id", "?")
+
+    title = _extract_title(props)
+    if not title:
+        log.warning("Seite %s: Titel fehlt – übersprungen", page_id)
+        return None
+
+    size_key = prop_names["size"]
+    size_prop = props.get(size_key) or {}
+    size_select = (size_prop.get("select") or {}) if size_prop else {}
+    size = size_select.get("name") if size_select else None
+    if not size:
+        log.warning("Seite \"%s\": %s fehlt – übersprungen", title, size_key)
+        return None
+    if size not in size_map:
+        log.warning("Seite \"%s\": Unbekannte T-Shirt Size '%s' – übersprungen", title, size)
+        return None
+    minutes = int(size_map[size])
+
+    le_key = prop_names["last_edit"]
+    le_prop = props.get(le_key) or {}
+    le_type = le_prop.get("type")
+    if le_type == "date":
+        le_start = (le_prop.get("date") or {}).get("start")
+    elif le_type == "last_edited_time":
+        le_start = le_prop.get("last_edited_time")
+    elif le_type == "created_time":
+        le_start = le_prop.get("created_time")
+    elif le_type == "formula":
+        formula = le_prop.get("formula") or {}
+        ftype = formula.get("type")
+        if ftype == "date":
+            le_start = (formula.get("date") or {}).get("start")
+        elif ftype == "string":
+            le_start = formula.get("string")
+        elif ftype == "number":
+            le_start = formula.get("number")
+        else:
+            le_start = None
+    else:
+        le_start = None
+    if not le_start:
+        log.warning("Seite \"%s\": %s fehlt – übersprungen", title, le_key)
+        return None
+    try:
+        last_edit = isoparse(le_start).date()
+    except (ValueError, TypeError):
+        log.warning("Seite \"%s\": %s nicht parsbar ('%s') – übersprungen", title, le_key, le_start)
+        return None
+
+    project_key = prop_names["project"]
+    rel_prop = props.get(project_key) or {}
+    relations = rel_prop.get("relation") or []
+    if not relations:
+        log.warning("Seite \"%s\": Relation '%s' fehlt – übersprungen", title, project_key)
+        return None
+    project_id = relations[0].get("id")
+    if not project_id:
+        log.warning("Seite \"%s\": Relation '%s' ohne ID – übersprungen", title, project_key)
+        return None
+
+    raw_project_name = resolve_project_name(project_id, token, cache)
+    if not raw_project_name:
+        log.warning("Seite \"%s\": Projektname nicht auflösbar – übersprungen", title)
+        return None
+
+    if project_filter:
+        if raw_project_name not in project_filter:
+            log.info("Seite \"%s\": Projekt '%s' nicht in Whitelist – übersprungen",
+                     title, raw_project_name)
+            return None
+        project_name = project_filter[raw_project_name]
+    else:
+        project_name = raw_project_name
+
+    return {
+        "title": title,
+        "t_shirt_size": size,
+        "minutes": minutes,
+        "last_edit": last_edit,
+        "project_id": project_id,
+        "project_name": project_name,
+    }
+
+
+# ---------- Aggregation & Zeitberechnung ----------
+
+def aggregate_events(pages: list[dict]) -> dict[date, list[dict]]:
+    groups: dict[tuple[date, str], dict] = {}
+    for p in pages:
+        key = (p["last_edit"], p["project_name"])
+        g = groups.get(key)
+        if g is None:
+            g = {
+                "date": p["last_edit"],
+                "project_name": p["project_name"],
+                "total_minutes": 0,
+                "items": [],
+            }
+            groups[key] = g
+        g["total_minutes"] += p["minutes"]
+        g["items"].append((p["title"], p["t_shirt_size"]))
+
+    by_day: dict[date, list[dict]] = defaultdict(list)
+    for g in groups.values():
+        g["items"].sort(key=lambda t: t[0])
+        g["summary"] = ";\n".join(title for title, _ in g["items"])
+        g["notes"] = g["project_name"]
+        by_day[g["date"]].append(g)
+    return by_day
+
+
+def assign_sequential_times(events_by_day: dict[date, list[dict]],
+                            day_start_hour: int) -> list[dict]:
+    result: list[dict] = []
+    for d in sorted(events_by_day.keys()):
+        day_events = sorted(events_by_day[d], key=lambda e: e["project_name"])
+        cursor = datetime(d.year, d.month, d.day, day_start_hour, 0)
+        for ev in day_events:
+            ev["start_time"] = cursor
+            ev["end_time"] = cursor + timedelta(minutes=ev["total_minutes"])
+            cursor = ev["end_time"]
+            result.append(ev)
+    return result
+
+
+# ---------- Protokoll & Zustand ----------
+
+def load_protocol(path: str) -> dict:
+    p = Path(path)
+    if not p.exists():
+        return {}
+    try:
+        with p.open("r", encoding="utf-8") as f:
+            return json.load(f)
+    except (OSError, json.JSONDecodeError) as e:
+        log.warning("Protokolldatei konnte nicht gelesen werden (%s) – starte mit leerem Protokoll.", e)
+        return {}
+
+
+def save_protocol(path: str, protocol: dict) -> None:
+    try:
+        with Path(path).open("w", encoding="utf-8") as f:
+            json.dump(protocol, f, indent=2, ensure_ascii=False, sort_keys=True)
+    except OSError as e:
+        log.warning("Protokolldatei konnte nicht geschrieben werden: %s", e)
+
+
+def protocol_key(event: dict) -> str:
+    return f"{event['date'].isoformat()}|{event['project_name']}"
+
+
+def determine_state(event: dict, protocol: dict) -> str:
+    key = protocol_key(event)
+    prev = protocol.get(key)
+    if prev is None:
+        return "new"
+    same = (
+        prev.get("total_minutes") == event["total_minutes"]
+        and prev.get("notes") == event["notes"]
+        and prev.get("summary") == event["summary"]
+        and prev.get("start_time") == event["start_time"].strftime("%H:%M")
+        and prev.get("end_time") == event["end_time"].strftime("%H:%M")
+    )
+    return "unchanged" if same else "changed"
+
+
+# ---------- AppleScript ----------
+
+def escape_applescript_string(s: str) -> str:
+    s = s.replace("\\", "\\\\")
+    s = s.replace('"', '\\"')
+    s = s.replace("\n", "\\n")
+    return s
+
+
+def build_create_script(event: dict, calendar_name: str) -> str:
+    cal_name = escape_applescript_string(calendar_name)
+    title = escape_applescript_string(event["summary"])
+    notes = escape_applescript_string(event["notes"])
+    s, e = event["start_time"], event["end_time"]
+    return f'''tell application "Calendar"
+    tell calendar "{cal_name}"
+        set startDate to current date
+        set year of startDate to {s.year}
+        set month of startDate to {s.month}
+        set day of startDate to {s.day}
+        set hours of startDate to {s.hour}
+        set minutes of startDate to {s.minute}
+        set seconds of startDate to 0
+
+        set endDate to current date
+        set year of endDate to {e.year}
+        set month of endDate to {e.month}
+        set day of endDate to {e.day}
+        set hours of endDate to {e.hour}
+        set minutes of endDate to {e.minute}
+        set seconds of endDate to 0
+
+        set newEvent to make new event with properties {{summary:"{title}", start date:startDate, end date:endDate, description:"{notes}"}}
+
+        return uid of newEvent
+    end tell
+end tell'''
+
+
+def build_update_script(event: dict, uid: str, calendar_name: str) -> str:
+    cal_name = escape_applescript_string(calendar_name)
+    uid_esc = escape_applescript_string(uid)
+    title = escape_applescript_string(event["summary"])
+    notes = escape_applescript_string(event["notes"])
+    s, e = event["start_time"], event["end_time"]
+    return f'''tell application "Calendar"
+    tell calendar "{cal_name}"
+        set matchingEvents to (every event whose uid is "{uid_esc}")
+        if (count of matchingEvents) = 0 then
+            return "ERROR: not found"
+        end if
+
+        set theEvent to item 1 of matchingEvents
+
+        set startDate to current date
+        set year of startDate to {s.year}
+        set month of startDate to {s.month}
+        set day of startDate to {s.day}
+        set hours of startDate to {s.hour}
+        set minutes of startDate to {s.minute}
+        set seconds of startDate to 0
+
+        set endDate to current date
+        set year of endDate to {e.year}
+        set month of endDate to {e.month}
+        set day of endDate to {e.day}
+        set hours of endDate to {e.hour}
+        set minutes of endDate to {e.minute}
+        set seconds of endDate to 0
+
+        set summary of theEvent to "{title}"
+        set start date of theEvent to startDate
+        set end date of theEvent to endDate
+        set description of theEvent to "{notes}"
+        return "OK"
+    end tell
+end tell'''
+
+
+def run_applescript(script: str) -> tuple[bool, str]:
+    try:
+        result = subprocess.run(
+            ["osascript", "-e", script],
+            capture_output=True, text=True, timeout=60,
+        )
+    except FileNotFoundError:
+        return False, "osascript nicht gefunden (kein macOS?)"
+    except subprocess.TimeoutExpired:
+        return False, "osascript-Timeout"
+    if result.returncode != 0:
+        return False, (result.stderr or result.stdout).strip()
+    return True, result.stdout.strip()
+
+
+def applescript_calendar_missing(stderr: str) -> bool:
+    s = stderr.lower()
+    return "can't get calendar" in s or "kann \"calendar" in s or "-1728" in s
+
+
+# ---------- Ausgabe ----------
+
+def print_dry_run_table(events: list[dict], protocol: dict) -> None:
+    rows = [("Zustand", "Datum", "Projekt", "Start", "Ende", "Min")]
+    for ev in events:
+        state = determine_state(ev, protocol).upper()
+        rows.append((
+            state,
+            ev["date"].isoformat(),
+            ev["project_name"],
+            ev["start_time"].strftime("%H:%M"),
+            ev["end_time"].strftime("%H:%M"),
+            str(ev["total_minutes"]),
+        ))
+    widths = [max(len(r[i]) for r in rows) for i in range(len(rows[0]))]
+    line = "  ".join(c.ljust(widths[i]) for i, c in enumerate(rows[0]))
+    print(line)
+    print("-" * len(line))
+    for r in rows[1:]:
+        print("  ".join(c.ljust(widths[i]) for i, c in enumerate(r)))
+
+
+# ---------- Sync-Ausführung ----------
+
+def sync_event(event: dict, state: str, protocol: dict, calendar_name: str,
+               counters: dict[str, int]) -> None:
+    key = protocol_key(event)
+    s_str = event["start_time"].strftime("%H:%M")
+    e_str = event["end_time"].strftime("%H:%M")
+    label = f"{event['project_name']:<20} | {event['date'].isoformat()} | {s_str}–{e_str}"
+
+    if state == "unchanged":
+        log.info("UNVERÄNDERT:  %s – übersprungen", label)
+        counters["unchanged"] += 1
+        return
+
+    if state == "new":
+        ok, out = run_applescript(build_create_script(event, calendar_name))
+        if not ok:
+            if applescript_calendar_missing(out):
+                sys.exit(f"FEHLER: Kalender '{calendar_name}' nicht gefunden – bitte calendar_name in config.json prüfen.")
+            log.error("CREATE fehlgeschlagen für %s: %s", label, out)
+            counters["errors"] += 1
+            return
+        uid = out
+        if not uid:
+            log.error("CREATE lieferte leere UID für %s", label)
+            counters["errors"] += 1
+            return
+        protocol[key] = {
+            "uid": uid,
+            "start_time": s_str,
+            "end_time": e_str,
+            "total_minutes": event["total_minutes"],
+            "summary": event["summary"],
+            "notes": event["notes"],
+        }
+        log.info("NEU:          %s", label)
+        counters["created"] += 1
+        return
+
+    # state == "changed"
+    prev = protocol.get(key, {})
+    uid = prev.get("uid")
+    prev_label = ""
+    if prev.get("start_time") and prev.get("end_time"):
+        prev_label = f" (vorher {prev['start_time']}–{prev['end_time']})"
+
+    if uid:
+        ok, out = run_applescript(build_update_script(event, uid, calendar_name))
+        if ok and out == "OK":
+            protocol[key] = {
+                "uid": uid,
+                "start_time": s_str,
+                "end_time": e_str,
+                "total_minutes": event["total_minutes"],
+                "summary": event["summary"],
+                "notes": event["notes"],
+            }
+            log.info("GEÄNDERT:     %s%s", label, prev_label)
+            counters["updated"] += 1
+            return
+        if not ok and applescript_calendar_missing(out):
+            sys.exit(f"FEHLER: Kalender '{calendar_name}' nicht gefunden – bitte calendar_name in config.json prüfen.")
+        log.warning("UPDATE fehlgeschlagen (%s) – lege Termin neu an: %s", out or "unbekannt", label)
+    else:
+        log.warning("Kein UID im Protokoll für %s – lege Termin neu an.", label)
+
+    ok, out = run_applescript(build_create_script(event, calendar_name))
+    if not ok:
+        if applescript_calendar_missing(out):
+            sys.exit(f"FEHLER: Kalender '{calendar_name}' nicht gefunden – bitte calendar_name in config.json prüfen.")
+        log.error("Fallback-CREATE fehlgeschlagen für %s: %s", label, out)
+        counters["errors"] += 1
+        return
+    new_uid = out
+    if not new_uid:
+        log.error("Fallback-CREATE lieferte leere UID für %s", label)
+        counters["errors"] += 1
+        return
+    protocol[key] = {
+        "uid": new_uid,
+        "start_time": s_str,
+        "end_time": e_str,
+        "total_minutes": event["total_minutes"],
+        "summary": event["summary"],
+        "notes": event["notes"],
+    }
+    log.info("GEÄNDERT (neu):%s%s", label, prev_label)
+    counters["updated"] += 1
+
+
+# ---------- Hauptablauf ----------
+
+def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
+    parser = argparse.ArgumentParser(description="Notion → Apple Kalender / Exchange Sync")
+    parser.add_argument("--monat", default=None,
+                        help="Zielmonat im Format YYYY-MM, z. B. 2026-04. "
+                             "Ohne Angabe wird der vergangene Monat verwendet.")
+    parser.add_argument("--dry-run", action="store_true", help="Zeigt Termine an, ohne sie anzulegen oder zu ändern")
+    parser.add_argument("--kalender", help="Name des Zielkalenders (überschreibt config.json)")
+    return parser.parse_args(argv)
+
+
+def main(argv: list[str] | None = None) -> int:
+    args = parse_args(argv)
+    cfg = load_config("config.json")
+    setup_logging(cfg["log_file"])
+
+    calendar_name = args.kalender or cfg["calendar_name"]
+    month_str = args.monat or previous_month_str()
+    month_start, month_end = get_month_bounds(month_str)
+
+    if args.monat is None:
+        log.info("Kein --monat angegeben – verwende vergangenen Monat: %s", month_str)
+    log.info("Starte Sync für Monat: %s", month_str)
+    log.info("Kalender: %s | Tagesstart: %02d:00", calendar_name, cfg["day_start_hour"])
+
+    protocol = load_protocol(cfg["protocol_file"])
+    prop_names = cfg["notion_property_names"]
+    project_filter = cfg["projects"]
+    if project_filter:
+        log.info("Projekt-Whitelist aktiv (%d Einträge): %s",
+                 len(project_filter), ", ".join(sorted(project_filter.keys())))
+    else:
+        log.info("Keine Projekt-Whitelist gesetzt – alle Projekte werden synchronisiert.")
+
+    raw_pages = query_notion_database(
+        cfg["notion_database_id"], cfg["notion_token"], month_start, month_end, prop_names,
+    )
+    log.info("%d Notion-Seiten geladen", len(raw_pages))
+
+    project_cache: dict[str, str | None] = {}
+    normalized: list[dict] = []
+    for raw in raw_pages:
+        n = normalize_page(
+            raw, cfg["notion_token"], project_cache,
+            cfg["t_shirt_size_map"], prop_names, project_filter,
+        )
+        if n is not None:
+            normalized.append(n)
+
+    by_day = aggregate_events(normalized)
+    events = assign_sequential_times(by_day, int(cfg["day_start_hour"]))
+
+    if not events:
+        log.info("Keine zu synchronisierenden Termine gefunden.")
+        return 0
+
+    if args.dry_run:
+        log.info("--dry-run aktiv: keine AppleScripts werden ausgeführt.")
+        print_dry_run_table(events, protocol)
+        return 0
+
+    counters = {"created": 0, "updated": 0, "unchanged": 0, "errors": 0}
+    for ev in events:
+        state = determine_state(ev, protocol)
+        sync_event(ev, state, protocol, calendar_name, counters)
+
+    save_protocol(cfg["protocol_file"], protocol)
+
+    log.info(
+        "Sync abgeschlossen: %d erstellt, %d aktualisiert, %d unverändert, %d Fehler",
+        counters["created"], counters["updated"], counters["unchanged"], counters["errors"],
+    )
+    return 0 if counters["errors"] == 0 else 2
+
+
+if __name__ == "__main__":
+    sys.exit(main())