# 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) ### 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) ```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" }, "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*