Version: 1.1
Stand: Mai 2026
Lösungsansatz: Python + AppleScript → Apple Kalender → Exchange Online
| 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" |
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.
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.
| Eigenschaft | Wert |
|---|---|
| Betriebssystem | macOS (aktuell) |
| Ausführungsumgebung | Terminal / Shell |
| Sprache | Python 3.x |
| 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 |
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.
Das Skript liest alle Seiten aus der Notion-Datenbank „Projekte", deren Eigenschaft Last Edit in den angegebenen Zielmonat fällt.
Mehrere Notion-Einträge, die am gleichen Tag zum gleichen Projekt gehören, werden zu einem Kalendertermin zusammengeführt. Würde der zusammengesetzte Titel die Outlook-Grenze von 248 Zeichen überschreiten, werden automatisch mehrere aufeinanderfolgende Termine für dasselbe Projekt erzeugt – jeder mit einer Teilmenge der Aufgaben, die ins Limit passt.
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), sekundär nach der Teilnummer (bei aufgeteilten Projekten). 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 |
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.
| 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) |
Last Edit fehlt → Eintrag wird übersprungen und geloggt┌─────────────────────────────────────────────────────────┐
│ 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 │
└─────────────────────────────────────────────────────────┘
| 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 |
| 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.
POST https://api.notion.com/v1/databases/{database_id}/queryLast Edit between Monatsanfang und Monatsende2022-06-28{
"filter": {
"and": [
{
"property": "Last Edit",
"date": {
"on_or_after": "2026-04-01"
}
},
{
"property": "Last Edit",
"date": {
"on_or_before": "2026-04-30"
}
}
]
}
}
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).
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.
notion_pages = query_notion_database(
database_id = config["notion_database_id"],
month_start = "YYYY-MM-01",
month_end = "YYYY-MM-{letzter_tag}"
)
Für jede geladene Notion-Seite werden folgende Felder extrahiert:
{
"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
}
Schritt 1 – Gruppierung nach Schlüssel (last_edit, project_name):
Für jede Gruppe wird ermittelt:
total_minutes = Summe aller minutes-Wertenotes = 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:
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:
{
"project_name": str,
"date": date,
"start_time": time, # berechnete Startzeit
"end_time": time, # berechnete Endzeit
"total_minutes": int,
"notes": str
}
Die Protokolldatei sync_state.json enthält für jede bereits verarbeitete Datum+Projekt-Kombination einen Eintrag:
{
"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.
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.
notion_to_calendar.pyrequests>=2.31.0
python-dateutil>=2.8.2
Installation: pip install requests python-dateutil
| 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" |
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)
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
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).
Das Script erstellt den Termin und gibt dessen uid auf stdout aus. Python liest diesen Wert aus.
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:
result = subprocess.run(["osascript", "-e", script],
capture_output=True, text=True)
uid = result.stdout.strip()
Das Script sucht den Termin anhand der gespeicherten UID und überschreibt alle relevanten Felder.
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
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
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
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."
Die gesamte nutzerspezifische Konfiguration liegt in config.json. Diese Datei darf nicht in ein öffentliches Repository eingecheckt werden.
config.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.
secret_...)"notion_token" in config.json einhttps://www.notion.so/{workspace}/{DATABASE_ID}?v=...?v=8-4-4-4-1212345678901234567890123456789012 → 12345678-9012-3456-7890-123456789012"notion_database_id" einWichtig: Die Integration muss Datenbankzugriff haben.
In Notion: Datenbank öffnen →...Menü → „Connections" → Integration auswählen.
"calendar_name" eintragenpip install requests python-dateutil
# oder bei Homebrew-Python:
pip3 install requests python-dateutil
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
| 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 |
[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
--dry-run--monat 2026-04 --dry-runLast Editcalendar_name in config.json# 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
# 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
Falls nach dem Monatsabschluss Daten in Notion korrigiert wurden:
# Skript erneut ausführen – geänderte Termine werden automatisch aktualisiert
python notion_to_calendar.py --monat 2026-04
cat notion_sync.logEnde der Dokumentation – Version 1.1