Parcourir la source

Split appointments when Outlook's 248-char title limit would be exceeded

When the combined task titles for a project on a given day would exceed
Outlook's 248-character meeting-title limit, the script now automatically
creates multiple back-to-back appointments — each holding as many tasks
as fit within the limit. Key details:

- OUTLOOK_TITLE_MAX = 248 and TITLE_SEP = ";\n" are named constants
- _split_items_by_title bins sorted items greedily into title-safe groups
- aggregate_events emits one event dict per group, tagged with part/total_parts
- assign_sequential_times sorts by (project_name, part) so split parts stay
  consecutive; each part starts exactly where the previous one ends
- protocol_key appends |part2, |part3 … for parts > 1, keeping the original
  key format for part 1 (backward compatible with existing sync_state.json)
- sync_event log lines show [1/2], [2/2] etc. when a project is split
- --dry-run table collapses split parts back into one display row per
  project per day (combined duration, overall start–end, worst-case state)

Update README, TECHNISCHE_DOKUMENTATION and Design Document accordingly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Marius il y a 2 semaines
Parent
commit
e8b7beb882
4 fichiers modifiés avec 146 ajouts et 28 suppressions
  1. 2 2
      Design Document v1.1.md
  2. 3 1
      README.md
  3. 32 6
      TECHNISCHE_DOKUMENTATION.md
  4. 109 19
      notion_to_calendar.py

+ 2 - 2
Design Document v1.1.md

@@ -88,13 +88,13 @@ Das Skript liest alle Seiten aus der Notion-Datenbank **„Projekte"**, deren Ei
 
 ### 3.3 Aggregationslogik
 
-Mehrere Notion-Einträge, die **am gleichen Tag** zum **gleichen Projekt** gehören, werden zu **einem einzigen Kalendertermin** zusammengeführt.
+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.
 
 ### 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.
+**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:**
 

+ 3 - 1
README.md

@@ -25,13 +25,15 @@ 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:
+Pro Tag und Projekt entsteht **mindestens 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.
 
+> **Outlook-Titel-Limit:** Outlook schneidet Terminbezeichnungen bei 248 Zeichen ab. Würde der zusammengesetzte Titel eines Projekts diese Grenze überschreiten, legt das Skript automatisch mehrere aufeinanderfolgende Termine an – jeder mit einer Teilmenge der Aufgaben, die ins Limit passt. Im `--dry-run`-Modus wird trotzdem nur **eine Zeile pro Projekt und Tag** angezeigt (Gesamtdauer und Gesamtzeitraum), damit die Übersicht erhalten bleibt.
+
 **Beispiel:**
 
 | Notion-Aufgabe       | Datum      | Projekt   | Größe |

+ 32 - 6
TECHNISCHE_DOKUMENTATION.md

@@ -134,13 +134,24 @@ Schlüssel: `(last_edit, project_name)`. Pro Gruppe:
 - `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 alphabetisch nach `project_name` sortiert. Cursor startet bei `datetime(year, month, day, day_start_hour, 0)` und wandert pro Termin um `total_minutes` weiter.
+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:
+for ev in sorted_by_(project, part):
     ev.start = cursor
     ev.end   = cursor + total_minutes
     cursor   = ev.end
@@ -148,7 +159,10 @@ for ev in sorted_by_project:
 
 ### 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).
+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                                      |
 |--------------------------------------------|-------------|---------------------------------------------|
@@ -278,10 +292,20 @@ JSON-Dict mit String-Schlüsseln `"YYYY-MM-DD|ProjektAnzeigeName"` und Werten:
     "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.
@@ -349,15 +373,17 @@ Beispiel:
 | `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                |
+| `_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`; gibt Legende aus, färbt „Dauer" (grün/rot) und „Datum" (orange bei Wochenüberschreitung) |
+| `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) |

+ 109 - 19
notion_to_calendar.py

@@ -30,6 +30,9 @@ DEFAULT_PROP_NAMES = {
 }
 STATUS_DONE = "Done"
 
+OUTLOOK_TITLE_MAX = 248   # Outlook truncates meeting titles beyond this length
+TITLE_SEP = ";\n"         # separator between task titles within one appointment
+
 log = logging.getLogger("notion_sync")
 
 
@@ -351,7 +354,41 @@ def normalize_page(page: dict, token: str, cache: dict[str, str | None],
 
 # ---------- Aggregation & Zeitberechnung ----------
 
+# Each item stored during aggregation is (title, t_shirt_size, minutes).
+_Item = tuple[str, str, int]
+
+
+def _split_items_by_title(items: list[_Item]) -> list[list[_Item]]:
+    """Split a sorted item list into groups whose joined title fits within
+    OUTLOOK_TITLE_MAX characters.  A single item that already exceeds the
+    limit is kept as its own group (nothing we can do about that)."""
+    groups: list[list[_Item]] = []
+    current: list[_Item] = []
+    current_len = 0
+    sep_len = len(TITLE_SEP)
+
+    for item in items:
+        title_len = len(item[0])
+        if not current:
+            current.append(item)
+            current_len = title_len
+        else:
+            needed = current_len + sep_len + title_len
+            if needed > OUTLOOK_TITLE_MAX:
+                groups.append(current)
+                current = [item]
+                current_len = title_len
+            else:
+                current.append(item)
+                current_len = needed
+
+    if current:
+        groups.append(current)
+    return groups
+
+
 def aggregate_events(pages: list[dict]) -> dict[date, list[dict]]:
+    # First pass: collect all items per (date, project).
     groups: dict[tuple[date, str], dict] = {}
     for p in pages:
         key = (p["last_edit"], p["project_name"])
@@ -360,19 +397,35 @@ def aggregate_events(pages: list[dict]) -> dict[date, list[dict]]:
             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"]))
+        g["items"].append((p["title"], p["t_shirt_size"], p["minutes"]))
 
     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)
+        item_groups = _split_items_by_title(g["items"])
+
+        if len(item_groups) > 1:
+            log.info(
+                "Termin '%s' am %s wird in %d Teile aufgeteilt "
+                "(Outlook-Titel-Limit %d Zeichen).",
+                g["project_name"], g["date"].isoformat(),
+                len(item_groups), OUTLOOK_TITLE_MAX,
+            )
+
+        total_parts = len(item_groups)
+        for part_idx, sub_items in enumerate(item_groups, start=1):
+            by_day[g["date"]].append({
+                "date": g["date"],
+                "project_name": g["project_name"],
+                "total_minutes": sum(m for _, _, m in sub_items),
+                "summary": TITLE_SEP.join(title for title, _, _ in sub_items),
+                "notes": g["project_name"],
+                "part": part_idx,
+                "total_parts": total_parts,
+            })
     return by_day
 
 
@@ -380,7 +433,10 @@ 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"])
+        # Primary sort: project name; secondary: part index so split events
+        # stay consecutive and in the correct order.
+        day_events = sorted(events_by_day[d],
+                            key=lambda e: (e["project_name"], e.get("part", 1)))
         cursor = datetime(d.year, d.month, d.day, day_start_hour, 0)
         for ev in day_events:
             ev["start_time"] = cursor
@@ -413,7 +469,11 @@ def save_protocol(path: str, protocol: dict) -> None:
 
 
 def protocol_key(event: dict) -> str:
-    return f"{event['date'].isoformat()}|{event['project_name']}"
+    key = f"{event['date'].isoformat()}|{event['project_name']}"
+    part = event.get("part", 1)
+    if part > 1:
+        key += f"|part{part}"
+    return key
 
 
 def determine_state(event: dict, protocol: dict) -> str:
@@ -571,24 +631,52 @@ def _print_legend() -> None:
     print()
 
 
+_STATE_PRIO: dict[str, int] = {"new": 2, "changed": 1, "unchanged": 0}
+
+
 def print_dry_run_table(events: list[dict], protocol: dict) -> None:
     overloaded_dates = _overloaded_week_dates(events)
 
+    # Collapse split parts into one display row per (date, project).
+    # Events are already sorted by (date, project_name, part), so parts of the
+    # same project arrive consecutively — we just accumulate into the last row.
+    merged: list[dict] = []
+    _seen: dict[tuple[date, str], int] = {}  # maps (date, project) → index in merged
+
+    for ev in events:
+        key = (ev["date"], ev["project_name"])
+        state = determine_state(ev, protocol)
+        if key not in _seen:
+            _seen[key] = len(merged)
+            merged.append({
+                "date":          ev["date"],
+                "project_name":  ev["project_name"],
+                "start_time":    ev["start_time"],
+                "end_time":      ev["end_time"],
+                "total_minutes": ev["total_minutes"],
+                "state":         state,
+            })
+        else:
+            row = merged[_seen[key]]
+            row["end_time"]      = ev["end_time"]       # last part's end is the overall end
+            row["total_minutes"] += ev["total_minutes"]
+            if _STATE_PRIO[state] > _STATE_PRIO[row["state"]]:
+                row["state"] = state  # promote to worst-case state
+
     header = ("Zustand", "Datum", "Projekt", "Start", "Ende", "Dauer")
     data_rows: list[tuple[tuple[str, ...], int, date]] = []
-    for ev in events:
-        state = determine_state(ev, protocol).upper()
+    for row in merged:
         data_rows.append((
             (
-                state,
-                ev["date"].isoformat(),
-                ev["project_name"],
-                ev["start_time"].strftime("%H:%M"),
-                ev["end_time"].strftime("%H:%M"),
-                format_duration(ev["total_minutes"]),
+                row["state"].upper(),
+                row["date"].isoformat(),
+                row["project_name"],
+                row["start_time"].strftime("%H:%M"),
+                row["end_time"].strftime("%H:%M"),
+                format_duration(row["total_minutes"]),
             ),
-            ev["total_minutes"],
-            ev["date"],
+            row["total_minutes"],
+            row["date"],
         ))
 
     all_rows = [header] + [r for r, _, _ in data_rows]
@@ -629,7 +717,9 @@ def sync_event(event: dict, state: str, protocol: dict, calendar_name: str,
     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}"
+    part_tag = (f" [{event['part']}/{event['total_parts']}]"
+                if event.get("total_parts", 1) > 1 else "")
+    label = f"{event['project_name']:<20}{part_tag} | {event['date'].isoformat()} | {s_str}–{e_str}"
 
     if state == "unchanged":
         log.info("UNVERÄNDERT:  %s – übersprungen", label)