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