TECHNISCHE_DOKUMENTATION.md 22 KB

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
  2. Architektur
  3. Datenfluss
  4. Notion-Datenmodell & Schema-Auflösung
  5. Datenverarbeitung & Geschäftslogik
  6. AppleScript-Integration
  7. Konfiguration (config.json)
  8. Protokolldatei (sync_state.json)
  9. Logging & Fehlerbehandlung
  10. Wichtige Eigenheiten und Stolperfallen
  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:

// "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)

Outlook-Titel-Limit & automatisches Aufteilen

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:

  • Jeder Teil enthält so viele Aufgaben, wie in 248 Zeichen passen (greedy, von vorn).
  • Ein einzelner Titel, der das Limit bereits allein überschreitet, wird als eigener Teil belassen (keine weitere Aufteilungsmöglichkeit).
  • Jeder Teil bekommt die Felder part (1-basiert) und total_parts.
  • Die Teile liegen nahtlos hintereinander – Teil 2 beginnt exakt dort, wo Teil 1 endet.

Der --dry-run-Modus zeigt immer nur eine Zeile pro Projekt und Tag (Gesamtdauer, Gesamtzeitraum), unabhängig von der Anzahl der Teile.

Sequenzielle Startzeiten

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

Zustandsbestimmung

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).

  • Teil 1 (oder der einzige Teil): "YYYY-MM-DD|ProjektName" — rückwärtskompatibel mit bestehenden Protokolleinträgen.
  • Teil 2+: "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

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)

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)

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

{
  "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:

{
  "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.

  • 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
_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