Stand: Mai 2026
Implementierung: Python 3.8+ (notion_to_calendar.py) + AppleScript (über osascript)
Bezugsdokument: Design Document v1.1.md
config.json)sync_state.json)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.
┌──────────────────────────────────────────────────────────┐
│ 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
config.json einlesen, Pflichtfelder prüfen, Defaults für notion_property_names und projects ergänzen.--monat oder Default = vergangener Monat.sync_state.json laden (oder leeres Dict beginnen).GET /v1/databases/{id} abrufen.POST /v1/databases/{id}/query filtern (Last Edit ∈ Monat AND Status = Done) — paginiert über next_cursor.(Datum, Projekt) aggregieren: Summe der Minuten, alphabetisch sortierte Liste der Aufgabentitel.day_start_hour rechnen.new/unchanged/changed) per Vergleich mit Protokoll.--dry-run: Tabelle ausgeben, Ende.
Sonst: AppleScript ausführen, UID einsammeln, Protokoll aktualisieren.| 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.
Für last_edit wird der Filter abhängig vom Schema-Typ gebaut:
// "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).
Der Body wird in einer Schleife versendet, next_cursor aus der Response wird als start_cursor für den Folgeaufruf gesetzt. Page-Size: 100.
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.
Schlüssel: (last_edit, project_name). Pro Gruppe:
total_minutes = Summe der Größen-Minuten aller enthaltenen Notion-Pagessummary = Aufgabentitel der Gruppe, alphabetisch sortiert, verbunden mit ;\nnotes = Anzeige-Name des Projekts (aus projects-Mapping)Outlook schneidet Kalendertermine bei 248 Zeichen im Titel ab (OUTLOOK_TITLE_MAX = 248). Nach der Sortierung der Aufgabentitel prüft _split_items_by_title, ob der zusammengesetzte Titel die Grenze überschreitet. Ist das der Fall, wird die Gruppe in mehrere Teile aufgeteilt:
part (1-basiert) und total_parts.Der --dry-run-Modus zeigt immer nur eine Zeile pro Projekt und Tag (Gesamtdauer, Gesamtzeitraum), unabhängig von der Anzahl der Teile.
Pro Tag wird die Liste sortiert nach (project_name, part), sodass Teile desselben Projekts konsekutiv und in der richtigen Reihenfolge erscheinen. 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, part):
ev.start = cursor
ev.end = cursor + total_minutes
cursor = ev.end
Pro Termin wird ein Schlüssel gebildet und mit dem Protokoll verglichen. Verglichen werden: total_minutes, summary, notes, start_time (HH:MM), end_time (HH:MM).
"YYYY-MM-DD|ProjektName" — rückwärtskompatibel mit bestehenden Protokolleinträgen."YYYY-MM-DD|ProjektName|part2", "YYYY-MM-DD|ProjektName|part3" usw.| 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 |
Ä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.
subprocess.run(["osascript", "-e", script], capture_output=True, text=True, timeout=60). Rückgabe ist (success: bool, stdout_or_stderr: str).
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.
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.
| 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) |
escape_applescript_string ersetzt in folgender Reihenfolge: Backslash → \\, Doppelanführungszeichen → \", Newline → \n.
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.
config.json)| 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 |
| 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 |
{
"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"
}
}
sync_state.json)JSON-Dict mit String-Schlüsseln "YYYY-MM-DD|ProjektAnzeigeName" und Werten:
{
"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"
},
"2026-04-03|NII001|part2": {
"uid": "DEF-456-...",
"start_time": "10:30",
"end_time": "11:00",
"total_minutes": 30,
"summary": "Aufgabe C",
"notes": "NII001"
}
}
Einträge mit |part2 (und höher) entstehen nur, wenn der Projekttitel für einen Tag das Outlook-Limit von 248 Zeichen überschreitet und aufgeteilt werden muss.
new und legt sie an. Bestehende Apple-Kalender-Termine bleiben dabei stehen → ggf. Duplikate. Bei einem Reset also auch im Kalender aufräumen.[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
| 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 |
0 — Erfolg, keine Fehler1 — Abbruch mit Klartext-Fehlermeldung (z. B. Token, Schema, Kalender)2 — Sync gelaufen, aber einzelne Events haben Fehler produziertrelation-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).Last Edit/Completed on-Filter unterstützt explizit formula mit Result-Type date/string/number. Andere Formula-Resulttypen werden nicht erkannt → Page wird übersprungen.project_name nach Mapping), nicht nach Reihenfolge in Notion. Re-Runs sind dadurch reproduzierbar.osascript fehl. Beim Erstaufruf fragt macOS ggf. nach Automation-Berechtigung (Systemeinstellungen → Datenschutz → Automation).sync_state.json verloren, werden alle Termine erneut angelegt → Duplikate in Apple Kalender. Datei nicht versehentlich löschen.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.| 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 |
_split_items_by_title |
Teilt sortierte Item-Liste in Gruppen ≤ 248 Zeichen Titel |
aggregate_events |
Gruppierung, Titel-Splitting, summary/notes/part-Bau |
assign_sequential_times |
Tagesweise Sortierung nach (project, part) + 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; fasst Split-Teile zu einer Anzeigezeile zusammen, gibt Legende aus, färbt „Dauer" (grün/rot) und „Datum" (orange bei Wochenüberschreitung) |
_STATE_PRIO |
Prioritäts-Dict für Zustandsmerge beim Zusammenfassen von Teilen (new > changed > unchanged) |
format_duration |
Minuten → Xh Ymin-String |
duration_color |
Gibt ANSI-Farbcode für Dauer-Spalte zurück (None / grün / rot) |
_overloaded_week_dates |
Berechnet die Menge der Datumsangaben in ISO-Wochen, deren Gesamtminuten > 2880 (48 h) |
_print_legend |
Gibt die Farbkodierungs-Legende oberhalb der Tabelle aus |
parse_args / main |
CLI-Einstieg |
Ende der Technischen Dokumentation