notion_to_calendar.py 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882
  1. #!/usr/bin/env python3
  2. """Notion → Apple Kalender / Exchange Sync (Design v1.1)."""
  3. from __future__ import annotations
  4. import argparse
  5. import calendar as cal
  6. import json
  7. import logging
  8. import subprocess
  9. import sys
  10. from collections import defaultdict
  11. from datetime import date, datetime, timedelta
  12. from pathlib import Path
  13. from typing import Any
  14. import requests
  15. from dateutil.parser import isoparse
  16. NOTION_API_BASE = "https://api.notion.com/v1"
  17. NOTION_VERSION = "2022-06-28"
  18. HTTP_TIMEOUT = 30
  19. DEFAULT_PROP_NAMES = {
  20. "name": "Name",
  21. "size": "T-Shirt Size",
  22. "last_edit": "Completed on",
  23. "project": "Project",
  24. "status": "Status",
  25. }
  26. STATUS_DONE = "Done"
  27. OUTLOOK_TITLE_MAX = 248 # Outlook truncates meeting titles beyond this length
  28. TITLE_SEP = ";\n" # separator between task titles within one appointment
  29. log = logging.getLogger("notion_sync")
  30. # ---------- Config & Logging ----------
  31. def load_config(path: str) -> dict:
  32. p = Path(path)
  33. if not p.exists():
  34. sys.exit(f"FEHLER: Konfigurationsdatei '{path}' nicht gefunden.")
  35. with p.open("r", encoding="utf-8") as f:
  36. cfg = json.load(f)
  37. required = [
  38. "notion_token", "notion_database_id", "calendar_name",
  39. "day_start_hour", "t_shirt_size_map", "protocol_file", "log_file",
  40. ]
  41. missing = [k for k in required if k not in cfg]
  42. if missing:
  43. sys.exit(f"FEHLER: In config.json fehlen Pflichtfelder: {', '.join(missing)}")
  44. user_pn = cfg.get("notion_property_names") or {}
  45. if not isinstance(user_pn, dict):
  46. sys.exit("FEHLER: 'notion_property_names' in config.json muss ein Objekt sein.")
  47. cfg["notion_property_names"] = {**DEFAULT_PROP_NAMES, **user_pn}
  48. projects = cfg.get("projects")
  49. if projects is None:
  50. projects = {}
  51. if not isinstance(projects, dict):
  52. sys.exit("FEHLER: 'projects' in config.json muss ein Objekt {Notion-Name: Anzeige-Name} sein.")
  53. cfg["projects"] = {str(k): str(v) if v else str(k) for k, v in projects.items()}
  54. return cfg
  55. def setup_logging(log_file: str) -> None:
  56. fmt = "[%(asctime)s] %(levelname)-5s %(message)s"
  57. datefmt = "%Y-%m-%d %H:%M:%S"
  58. formatter = logging.Formatter(fmt, datefmt)
  59. log.setLevel(logging.INFO)
  60. log.handlers.clear()
  61. fh = logging.FileHandler(log_file, encoding="utf-8")
  62. fh.setFormatter(formatter)
  63. log.addHandler(fh)
  64. sh = logging.StreamHandler(sys.stdout)
  65. sh.setFormatter(formatter)
  66. log.addHandler(sh)
  67. # ---------- Datums-Helfer ----------
  68. def get_month_bounds(month_str: str) -> tuple[date, date]:
  69. try:
  70. year, month = (int(x) for x in month_str.split("-", 1))
  71. first = date(year, month, 1)
  72. last = date(year, month, cal.monthrange(year, month)[1])
  73. return first, last
  74. except (ValueError, AttributeError):
  75. sys.exit(f"FEHLER: Ungültiger Wert für --monat: '{month_str}'. Format: YYYY-MM.")
  76. def previous_month_str(today: date | None = None) -> str:
  77. today = today or date.today()
  78. first_of_this_month = today.replace(day=1)
  79. last_of_prev = first_of_this_month - timedelta(days=1)
  80. return f"{last_of_prev.year:04d}-{last_of_prev.month:02d}"
  81. # ---------- Notion API ----------
  82. def _notion_headers(token: str) -> dict[str, str]:
  83. return {
  84. "Authorization": f"Bearer {token}",
  85. "Notion-Version": NOTION_VERSION,
  86. "Content-Type": "application/json",
  87. }
  88. def get_database_schema(database_id: str, token: str) -> dict:
  89. url = f"{NOTION_API_BASE}/databases/{database_id}"
  90. try:
  91. resp = requests.get(url, headers=_notion_headers(token), timeout=HTTP_TIMEOUT)
  92. except requests.RequestException as e:
  93. sys.exit(f"FEHLER: Notion API nicht erreichbar: {e}")
  94. if resp.status_code == 401:
  95. sys.exit("FEHLER: Ungültiger Notion-Token. Bitte 'notion_token' in config.json prüfen.")
  96. if resp.status_code == 404:
  97. sys.exit("FEHLER: Notion-Datenbank nicht gefunden. Bitte 'notion_database_id' und Integrationszugriff prüfen.")
  98. if not resp.ok:
  99. sys.exit(f"FEHLER: Datenbank-Schema konnte nicht abgefragt werden ({resp.status_code}): {resp.text}")
  100. return resp.json().get("properties", {})
  101. def _resolve_property_name(schema: dict, requested: str) -> str | None:
  102. if requested in schema:
  103. return requested
  104. target = requested.strip().casefold()
  105. for actual in schema.keys():
  106. if actual.strip().casefold() == target:
  107. return actual
  108. return None
  109. def _require_property(schema: dict, requested: str, role: str) -> str:
  110. actual = _resolve_property_name(schema, requested)
  111. if actual is None:
  112. available = ", ".join(repr(k) for k in sorted(schema.keys()))
  113. sys.exit(
  114. f"FEHLER: Eigenschaft '{requested}' (für '{role}') nicht in der Notion-Datenbank gefunden. "
  115. f"Verfügbare Properties: {available}"
  116. )
  117. if actual != requested:
  118. log.warning("Property '%s' aufgelöst zu '%s' (Whitespace/Groß-Klein-Schreibung).",
  119. requested, actual)
  120. return actual
  121. def _detect_filter_type(schema: dict, prop_name: str, allowed: tuple[str, ...]) -> str:
  122. t = schema[prop_name].get("type")
  123. if t in allowed:
  124. return t
  125. sys.exit(
  126. f"FEHLER: Eigenschaft '{prop_name}' hat den nicht unterstützten Typ '{t}'. "
  127. f"Erwartet: {', '.join(allowed)}."
  128. )
  129. def query_notion_database(database_id: str, token: str, start: date, end: date,
  130. prop_names: dict) -> list[dict]:
  131. schema = get_database_schema(database_id, token)
  132. prop_names["last_edit"] = _require_property(schema, prop_names["last_edit"], "last_edit")
  133. prop_names["status"] = _require_property(schema, prop_names["status"], "status")
  134. prop_names["project"] = _require_property(schema, prop_names["project"], "project")
  135. prop_names["size"] = _require_property(schema, prop_names["size"], "size")
  136. le_name = prop_names["last_edit"]
  137. status_name = prop_names["status"]
  138. project_name = prop_names["project"]
  139. le_filter_type = _detect_filter_type(
  140. schema, le_name, ("date", "last_edited_time", "created_time", "formula"),
  141. )
  142. status_filter_type = _detect_filter_type(schema, status_name, ("select", "status"))
  143. project_type = schema[project_name].get("type")
  144. if project_type != "relation":
  145. sys.exit(
  146. f"FEHLER: Eigenschaft '{project_name}' hat den Typ '{project_type}', "
  147. "erwartet wurde 'relation' (Verknüpfung)."
  148. )
  149. log.info(
  150. "Notion-Schema erkannt: %s (%s), %s (%s), %s (relation)",
  151. le_name, le_filter_type, status_name, status_filter_type, project_name,
  152. )
  153. def _date_filter(condition: dict) -> dict:
  154. if le_filter_type == "formula":
  155. return {"property": le_name, "formula": {"date": condition}}
  156. return {"property": le_name, le_filter_type: condition}
  157. url = f"{NOTION_API_BASE}/databases/{database_id}/query"
  158. headers = _notion_headers(token)
  159. body = {
  160. "filter": {
  161. "and": [
  162. _date_filter({"on_or_after": start.isoformat()}),
  163. _date_filter({"on_or_before": end.isoformat()}),
  164. {"property": status_name, status_filter_type: {"equals": STATUS_DONE}},
  165. ]
  166. },
  167. "page_size": 100,
  168. }
  169. pages: list[dict] = []
  170. while True:
  171. try:
  172. resp = requests.post(url, headers=headers, json=body, timeout=HTTP_TIMEOUT)
  173. except requests.RequestException as e:
  174. sys.exit(f"FEHLER: Notion API nicht erreichbar: {e}")
  175. if resp.status_code == 401:
  176. sys.exit("FEHLER: Ungültiger Notion-Token. Bitte 'notion_token' in config.json prüfen.")
  177. if resp.status_code == 404:
  178. sys.exit("FEHLER: Notion-Datenbank nicht gefunden. Bitte 'notion_database_id' und Integrationszugriff prüfen.")
  179. if not resp.ok:
  180. sys.exit(f"FEHLER: Notion API antwortete mit {resp.status_code}: {resp.text}")
  181. data = resp.json()
  182. pages.extend(data.get("results", []))
  183. if data.get("has_more"):
  184. body["start_cursor"] = data["next_cursor"]
  185. else:
  186. break
  187. return pages
  188. def resolve_project_name(page_id: str, token: str, cache: dict[str, str | None]) -> str | None:
  189. if page_id in cache:
  190. return cache[page_id]
  191. url = f"{NOTION_API_BASE}/pages/{page_id}"
  192. try:
  193. resp = requests.get(url, headers=_notion_headers(token), timeout=HTTP_TIMEOUT)
  194. except requests.RequestException as e:
  195. log.warning("Projekt-Auflösung fehlgeschlagen für %s: %s", page_id, e)
  196. cache[page_id] = None
  197. return None
  198. if not resp.ok:
  199. log.warning("Projekt-Auflösung %s: HTTP %s", page_id, resp.status_code)
  200. cache[page_id] = None
  201. return None
  202. page = resp.json()
  203. name = _extract_title(page.get("properties", {}))
  204. cache[page_id] = name
  205. return name
  206. def _extract_title(props: dict) -> str | None:
  207. for prop in props.values():
  208. if prop.get("type") == "title":
  209. parts = prop.get("title", [])
  210. text = "".join(p.get("plain_text", "") for p in parts).strip()
  211. return text or None
  212. return None
  213. # ---------- Normalisierung ----------
  214. def normalize_page(page: dict, token: str, cache: dict[str, str | None],
  215. size_map: dict[str, int], prop_names: dict,
  216. project_filter: dict[str, str]) -> dict | None:
  217. props = page.get("properties", {})
  218. page_id = page.get("id", "?")
  219. title = _extract_title(props)
  220. if not title:
  221. log.warning("Seite %s: Titel fehlt – übersprungen", page_id)
  222. return None
  223. size_key = prop_names["size"]
  224. size_prop = props.get(size_key) or {}
  225. size_select = (size_prop.get("select") or {}) if size_prop else {}
  226. size = size_select.get("name") if size_select else None
  227. if not size:
  228. log.warning("Seite \"%s\": %s fehlt – übersprungen", title, size_key)
  229. return None
  230. if size not in size_map:
  231. log.warning("Seite \"%s\": Unbekannte T-Shirt Size '%s' – übersprungen", title, size)
  232. return None
  233. minutes = int(size_map[size])
  234. le_key = prop_names["last_edit"]
  235. le_prop = props.get(le_key) or {}
  236. le_type = le_prop.get("type")
  237. if le_type == "date":
  238. le_start = (le_prop.get("date") or {}).get("start")
  239. elif le_type == "last_edited_time":
  240. le_start = le_prop.get("last_edited_time")
  241. elif le_type == "created_time":
  242. le_start = le_prop.get("created_time")
  243. elif le_type == "formula":
  244. formula = le_prop.get("formula") or {}
  245. ftype = formula.get("type")
  246. if ftype == "date":
  247. le_start = (formula.get("date") or {}).get("start")
  248. elif ftype == "string":
  249. le_start = formula.get("string")
  250. elif ftype == "number":
  251. le_start = formula.get("number")
  252. else:
  253. le_start = None
  254. else:
  255. le_start = None
  256. if not le_start:
  257. log.warning("Seite \"%s\": %s fehlt – übersprungen", title, le_key)
  258. return None
  259. try:
  260. last_edit = isoparse(le_start).date()
  261. except (ValueError, TypeError):
  262. log.warning("Seite \"%s\": %s nicht parsbar ('%s') – übersprungen", title, le_key, le_start)
  263. return None
  264. project_key = prop_names["project"]
  265. rel_prop = props.get(project_key) or {}
  266. relations = rel_prop.get("relation") or []
  267. if not relations:
  268. log.warning("Seite \"%s\": Relation '%s' fehlt – übersprungen", title, project_key)
  269. return None
  270. project_id = relations[0].get("id")
  271. if not project_id:
  272. log.warning("Seite \"%s\": Relation '%s' ohne ID – übersprungen", title, project_key)
  273. return None
  274. raw_project_name = resolve_project_name(project_id, token, cache)
  275. if not raw_project_name:
  276. log.warning("Seite \"%s\": Projektname nicht auflösbar – übersprungen", title)
  277. return None
  278. if project_filter:
  279. if raw_project_name not in project_filter:
  280. log.info("Seite \"%s\": Projekt '%s' nicht in Whitelist – übersprungen",
  281. title, raw_project_name)
  282. return None
  283. project_name = project_filter[raw_project_name]
  284. else:
  285. project_name = raw_project_name
  286. return {
  287. "title": title,
  288. "t_shirt_size": size,
  289. "minutes": minutes,
  290. "last_edit": last_edit,
  291. "project_id": project_id,
  292. "project_name": project_name,
  293. }
  294. # ---------- Aggregation & Zeitberechnung ----------
  295. # Each item stored during aggregation is (title, t_shirt_size, minutes).
  296. _Item = tuple[str, str, int]
  297. def _split_items_by_title(items: list[_Item]) -> list[list[_Item]]:
  298. """Split a sorted item list into groups whose joined title fits within
  299. OUTLOOK_TITLE_MAX characters. A single item that already exceeds the
  300. limit is kept as its own group (nothing we can do about that)."""
  301. groups: list[list[_Item]] = []
  302. current: list[_Item] = []
  303. current_len = 0
  304. sep_len = len(TITLE_SEP)
  305. for item in items:
  306. title_len = len(item[0])
  307. if not current:
  308. current.append(item)
  309. current_len = title_len
  310. else:
  311. needed = current_len + sep_len + title_len
  312. if needed > OUTLOOK_TITLE_MAX:
  313. groups.append(current)
  314. current = [item]
  315. current_len = title_len
  316. else:
  317. current.append(item)
  318. current_len = needed
  319. if current:
  320. groups.append(current)
  321. return groups
  322. def aggregate_events(pages: list[dict]) -> dict[date, list[dict]]:
  323. # First pass: collect all items per (date, project).
  324. groups: dict[tuple[date, str], dict] = {}
  325. for p in pages:
  326. key = (p["last_edit"], p["project_name"])
  327. g = groups.get(key)
  328. if g is None:
  329. g = {
  330. "date": p["last_edit"],
  331. "project_name": p["project_name"],
  332. "items": [],
  333. }
  334. groups[key] = g
  335. g["items"].append((p["title"], p["t_shirt_size"], p["minutes"]))
  336. by_day: dict[date, list[dict]] = defaultdict(list)
  337. for g in groups.values():
  338. g["items"].sort(key=lambda t: t[0])
  339. item_groups = _split_items_by_title(g["items"])
  340. if len(item_groups) > 1:
  341. log.info(
  342. "Termin '%s' am %s wird in %d Teile aufgeteilt "
  343. "(Outlook-Titel-Limit %d Zeichen).",
  344. g["project_name"], g["date"].isoformat(),
  345. len(item_groups), OUTLOOK_TITLE_MAX,
  346. )
  347. total_parts = len(item_groups)
  348. for part_idx, sub_items in enumerate(item_groups, start=1):
  349. by_day[g["date"]].append({
  350. "date": g["date"],
  351. "project_name": g["project_name"],
  352. "total_minutes": sum(m for _, _, m in sub_items),
  353. "summary": TITLE_SEP.join(title for title, _, _ in sub_items),
  354. "notes": g["project_name"],
  355. "part": part_idx,
  356. "total_parts": total_parts,
  357. })
  358. return by_day
  359. def assign_sequential_times(events_by_day: dict[date, list[dict]],
  360. day_start_hour: int) -> list[dict]:
  361. result: list[dict] = []
  362. for d in sorted(events_by_day.keys()):
  363. # Primary sort: project name; secondary: part index so split events
  364. # stay consecutive and in the correct order.
  365. day_events = sorted(events_by_day[d],
  366. key=lambda e: (e["project_name"], e.get("part", 1)))
  367. cursor = datetime(d.year, d.month, d.day, day_start_hour, 0)
  368. for ev in day_events:
  369. ev["start_time"] = cursor
  370. ev["end_time"] = cursor + timedelta(minutes=ev["total_minutes"])
  371. cursor = ev["end_time"]
  372. result.append(ev)
  373. return result
  374. # ---------- Protokoll & Zustand ----------
  375. def load_protocol(path: str) -> dict:
  376. p = Path(path)
  377. if not p.exists():
  378. return {}
  379. try:
  380. with p.open("r", encoding="utf-8") as f:
  381. return json.load(f)
  382. except (OSError, json.JSONDecodeError) as e:
  383. log.warning("Protokolldatei konnte nicht gelesen werden (%s) – starte mit leerem Protokoll.", e)
  384. return {}
  385. def save_protocol(path: str, protocol: dict) -> None:
  386. try:
  387. with Path(path).open("w", encoding="utf-8") as f:
  388. json.dump(protocol, f, indent=2, ensure_ascii=False, sort_keys=True)
  389. except OSError as e:
  390. log.warning("Protokolldatei konnte nicht geschrieben werden: %s", e)
  391. def protocol_key(event: dict) -> str:
  392. key = f"{event['date'].isoformat()}|{event['project_name']}"
  393. part = event.get("part", 1)
  394. if part > 1:
  395. key += f"|part{part}"
  396. return key
  397. def determine_state(event: dict, protocol: dict) -> str:
  398. key = protocol_key(event)
  399. prev = protocol.get(key)
  400. if prev is None:
  401. return "new"
  402. same = (
  403. prev.get("total_minutes") == event["total_minutes"]
  404. and prev.get("notes") == event["notes"]
  405. and prev.get("summary") == event["summary"]
  406. and prev.get("start_time") == event["start_time"].strftime("%H:%M")
  407. and prev.get("end_time") == event["end_time"].strftime("%H:%M")
  408. )
  409. return "unchanged" if same else "changed"
  410. # ---------- AppleScript ----------
  411. def escape_applescript_string(s: str) -> str:
  412. s = s.replace("\\", "\\\\")
  413. s = s.replace('"', '\\"')
  414. s = s.replace("\n", "\\n")
  415. return s
  416. def build_create_script(event: dict, calendar_name: str) -> str:
  417. cal_name = escape_applescript_string(calendar_name)
  418. title = escape_applescript_string(event["summary"])
  419. notes = escape_applescript_string(event["notes"])
  420. s, e = event["start_time"], event["end_time"]
  421. return f'''tell application "Calendar"
  422. tell calendar "{cal_name}"
  423. set startDate to current date
  424. set year of startDate to {s.year}
  425. set month of startDate to {s.month}
  426. set day of startDate to {s.day}
  427. set hours of startDate to {s.hour}
  428. set minutes of startDate to {s.minute}
  429. set seconds of startDate to 0
  430. set endDate to current date
  431. set year of endDate to {e.year}
  432. set month of endDate to {e.month}
  433. set day of endDate to {e.day}
  434. set hours of endDate to {e.hour}
  435. set minutes of endDate to {e.minute}
  436. set seconds of endDate to 0
  437. set newEvent to make new event with properties {{summary:"{title}", start date:startDate, end date:endDate, description:"{notes}"}}
  438. return uid of newEvent
  439. end tell
  440. end tell'''
  441. def build_update_script(event: dict, uid: str, calendar_name: str) -> str:
  442. cal_name = escape_applescript_string(calendar_name)
  443. uid_esc = escape_applescript_string(uid)
  444. title = escape_applescript_string(event["summary"])
  445. notes = escape_applescript_string(event["notes"])
  446. s, e = event["start_time"], event["end_time"]
  447. return f'''tell application "Calendar"
  448. tell calendar "{cal_name}"
  449. set matchingEvents to (every event whose uid is "{uid_esc}")
  450. if (count of matchingEvents) = 0 then
  451. return "ERROR: not found"
  452. end if
  453. set theEvent to item 1 of matchingEvents
  454. set startDate to current date
  455. set year of startDate to {s.year}
  456. set month of startDate to {s.month}
  457. set day of startDate to {s.day}
  458. set hours of startDate to {s.hour}
  459. set minutes of startDate to {s.minute}
  460. set seconds of startDate to 0
  461. set endDate to current date
  462. set year of endDate to {e.year}
  463. set month of endDate to {e.month}
  464. set day of endDate to {e.day}
  465. set hours of endDate to {e.hour}
  466. set minutes of endDate to {e.minute}
  467. set seconds of endDate to 0
  468. set summary of theEvent to "{title}"
  469. set start date of theEvent to startDate
  470. set end date of theEvent to endDate
  471. set description of theEvent to "{notes}"
  472. return "OK"
  473. end tell
  474. end tell'''
  475. def run_applescript(script: str) -> tuple[bool, str]:
  476. try:
  477. result = subprocess.run(
  478. ["osascript", "-e", script],
  479. capture_output=True, text=True, timeout=60,
  480. )
  481. except FileNotFoundError:
  482. return False, "osascript nicht gefunden (kein macOS?)"
  483. except subprocess.TimeoutExpired:
  484. return False, "osascript-Timeout"
  485. if result.returncode != 0:
  486. return False, (result.stderr or result.stdout).strip()
  487. return True, result.stdout.strip()
  488. def applescript_calendar_missing(stderr: str) -> bool:
  489. s = stderr.lower()
  490. return "can't get calendar" in s or "kann \"calendar" in s or "-1728" in s
  491. # ---------- Ausgabe ----------
  492. ANSI_GREEN = "\033[32m"
  493. ANSI_RED = "\033[31m"
  494. ANSI_ORANGE = "\033[38;5;208m"
  495. ANSI_RESET = "\033[0m"
  496. WEEKLY_OVERTIME_THRESHOLD = 2880 # 48 h in Minuten
  497. def format_duration(minutes: int) -> str:
  498. hours, mins = divmod(int(minutes), 60)
  499. return f"{hours}h {mins}min"
  500. def duration_color(minutes: int) -> str | None:
  501. if minutes > 600:
  502. return ANSI_RED
  503. if minutes >= 480:
  504. return ANSI_GREEN
  505. return None
  506. def _overloaded_week_dates(events: list[dict]) -> set[date]:
  507. """Dates belonging to ISO weeks whose combined minutes exceed 2880 (48 h)."""
  508. weekly: dict[tuple[int, int], int] = defaultdict(int)
  509. for ev in events:
  510. iso = ev["date"].isocalendar()
  511. weekly[(iso[0], iso[1])] += ev["total_minutes"]
  512. overloaded = {k for k, v in weekly.items() if v > WEEKLY_OVERTIME_THRESHOLD}
  513. return {ev["date"] for ev in events if (ev["date"].isocalendar()[0], ev["date"].isocalendar()[1]) in overloaded}
  514. def _print_legend() -> None:
  515. print("Legende:")
  516. print(f" Dauer: {ANSI_GREEN}■{ANSI_RESET} grün ≥ 8 h (≥ 480 min) "
  517. f"{ANSI_RED}■{ANSI_RESET} rot > 10 h (> 600 min)")
  518. print(f" Datum: {ANSI_ORANGE}■{ANSI_RESET} orange = Wochensumme > 48 h (> 2880 min)")
  519. print()
  520. _STATE_PRIO: dict[str, int] = {"new": 2, "changed": 1, "unchanged": 0}
  521. def print_dry_run_table(events: list[dict], protocol: dict) -> None:
  522. overloaded_dates = _overloaded_week_dates(events)
  523. # Collapse split parts into one display row per (date, project).
  524. # Events are already sorted by (date, project_name, part), so parts of the
  525. # same project arrive consecutively — we just accumulate into the last row.
  526. merged: list[dict] = []
  527. _seen: dict[tuple[date, str], int] = {} # maps (date, project) → index in merged
  528. for ev in events:
  529. key = (ev["date"], ev["project_name"])
  530. state = determine_state(ev, protocol)
  531. if key not in _seen:
  532. _seen[key] = len(merged)
  533. merged.append({
  534. "date": ev["date"],
  535. "project_name": ev["project_name"],
  536. "start_time": ev["start_time"],
  537. "end_time": ev["end_time"],
  538. "total_minutes": ev["total_minutes"],
  539. "state": state,
  540. })
  541. else:
  542. row = merged[_seen[key]]
  543. row["end_time"] = ev["end_time"] # last part's end is the overall end
  544. row["total_minutes"] += ev["total_minutes"]
  545. if _STATE_PRIO[state] > _STATE_PRIO[row["state"]]:
  546. row["state"] = state # promote to worst-case state
  547. header = ("Zustand", "Datum", "Projekt", "Start", "Ende", "Dauer")
  548. data_rows: list[tuple[tuple[str, ...], int, date]] = []
  549. for row in merged:
  550. data_rows.append((
  551. (
  552. row["state"].upper(),
  553. row["date"].isoformat(),
  554. row["project_name"],
  555. row["start_time"].strftime("%H:%M"),
  556. row["end_time"].strftime("%H:%M"),
  557. format_duration(row["total_minutes"]),
  558. ),
  559. row["total_minutes"],
  560. row["date"],
  561. ))
  562. all_rows = [header] + [r for r, _, _ in data_rows]
  563. widths = [max(len(r[i]) for r in all_rows) for i in range(len(header))]
  564. _print_legend()
  565. table_width = sum(widths) + 2 * (len(widths) - 1)
  566. header_line = " ".join(c.ljust(widths[i]) for i, c in enumerate(header))
  567. week_sep = "·" * table_width
  568. print(header_line)
  569. print("-" * table_width)
  570. date_idx = 1
  571. duration_idx = len(header) - 1
  572. prev_iso_week: tuple[int, int] | None = None
  573. for row, minutes, ev_date in data_rows:
  574. iso_week = (ev_date.isocalendar()[0], ev_date.isocalendar()[1])
  575. if prev_iso_week is not None and iso_week != prev_iso_week:
  576. print(week_sep)
  577. prev_iso_week = iso_week
  578. cells = []
  579. for i, c in enumerate(row):
  580. padded = c.ljust(widths[i])
  581. if i == duration_idx:
  582. color = duration_color(minutes)
  583. if color:
  584. padded = f"{color}{padded}{ANSI_RESET}"
  585. elif i == date_idx and ev_date in overloaded_dates:
  586. padded = f"{ANSI_ORANGE}{padded}{ANSI_RESET}"
  587. cells.append(padded)
  588. print(" ".join(cells))
  589. # ---------- Sync-Ausführung ----------
  590. def sync_event(event: dict, state: str, protocol: dict, calendar_name: str,
  591. counters: dict[str, int]) -> None:
  592. key = protocol_key(event)
  593. s_str = event["start_time"].strftime("%H:%M")
  594. e_str = event["end_time"].strftime("%H:%M")
  595. part_tag = (f" [{event['part']}/{event['total_parts']}]"
  596. if event.get("total_parts", 1) > 1 else "")
  597. label = f"{event['project_name']:<20}{part_tag} | {event['date'].isoformat()} | {s_str}–{e_str}"
  598. if state == "unchanged":
  599. log.info("UNVERÄNDERT: %s – übersprungen", label)
  600. counters["unchanged"] += 1
  601. return
  602. if state == "new":
  603. ok, out = run_applescript(build_create_script(event, calendar_name))
  604. if not ok:
  605. if applescript_calendar_missing(out):
  606. sys.exit(f"FEHLER: Kalender '{calendar_name}' nicht gefunden – bitte calendar_name in config.json prüfen.")
  607. log.error("CREATE fehlgeschlagen für %s: %s", label, out)
  608. counters["errors"] += 1
  609. return
  610. uid = out
  611. if not uid:
  612. log.error("CREATE lieferte leere UID für %s", label)
  613. counters["errors"] += 1
  614. return
  615. protocol[key] = {
  616. "uid": uid,
  617. "start_time": s_str,
  618. "end_time": e_str,
  619. "total_minutes": event["total_minutes"],
  620. "summary": event["summary"],
  621. "notes": event["notes"],
  622. }
  623. log.info("NEU: %s", label)
  624. counters["created"] += 1
  625. return
  626. # state == "changed"
  627. prev = protocol.get(key, {})
  628. uid = prev.get("uid")
  629. prev_label = ""
  630. if prev.get("start_time") and prev.get("end_time"):
  631. prev_label = f" (vorher {prev['start_time']}–{prev['end_time']})"
  632. if uid:
  633. ok, out = run_applescript(build_update_script(event, uid, calendar_name))
  634. if ok and out == "OK":
  635. protocol[key] = {
  636. "uid": uid,
  637. "start_time": s_str,
  638. "end_time": e_str,
  639. "total_minutes": event["total_minutes"],
  640. "summary": event["summary"],
  641. "notes": event["notes"],
  642. }
  643. log.info("GEÄNDERT: %s%s", label, prev_label)
  644. counters["updated"] += 1
  645. return
  646. if not ok and applescript_calendar_missing(out):
  647. sys.exit(f"FEHLER: Kalender '{calendar_name}' nicht gefunden – bitte calendar_name in config.json prüfen.")
  648. log.warning("UPDATE fehlgeschlagen (%s) – lege Termin neu an: %s", out or "unbekannt", label)
  649. else:
  650. log.warning("Kein UID im Protokoll für %s – lege Termin neu an.", label)
  651. ok, out = run_applescript(build_create_script(event, calendar_name))
  652. if not ok:
  653. if applescript_calendar_missing(out):
  654. sys.exit(f"FEHLER: Kalender '{calendar_name}' nicht gefunden – bitte calendar_name in config.json prüfen.")
  655. log.error("Fallback-CREATE fehlgeschlagen für %s: %s", label, out)
  656. counters["errors"] += 1
  657. return
  658. new_uid = out
  659. if not new_uid:
  660. log.error("Fallback-CREATE lieferte leere UID für %s", label)
  661. counters["errors"] += 1
  662. return
  663. protocol[key] = {
  664. "uid": new_uid,
  665. "start_time": s_str,
  666. "end_time": e_str,
  667. "total_minutes": event["total_minutes"],
  668. "summary": event["summary"],
  669. "notes": event["notes"],
  670. }
  671. log.info("GEÄNDERT (neu):%s%s", label, prev_label)
  672. counters["updated"] += 1
  673. # ---------- Hauptablauf ----------
  674. def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
  675. parser = argparse.ArgumentParser(description="Notion → Apple Kalender / Exchange Sync")
  676. parser.add_argument("--monat", default=None,
  677. help="Zielmonat im Format YYYY-MM, z. B. 2026-04. "
  678. "Ohne Angabe wird der vergangene Monat verwendet.")
  679. parser.add_argument("--dry-run", action="store_true", help="Zeigt Termine an, ohne sie anzulegen oder zu ändern")
  680. parser.add_argument("--kalender", help="Name des Zielkalenders (überschreibt config.json)")
  681. return parser.parse_args(argv)
  682. def main(argv: list[str] | None = None) -> int:
  683. args = parse_args(argv)
  684. cfg = load_config("config.json")
  685. setup_logging(cfg["log_file"])
  686. calendar_name = args.kalender or cfg["calendar_name"]
  687. month_str = args.monat or previous_month_str()
  688. month_start, month_end = get_month_bounds(month_str)
  689. if args.monat is None:
  690. log.info("Kein --monat angegeben – verwende vergangenen Monat: %s", month_str)
  691. log.info("Starte Sync für Monat: %s", month_str)
  692. log.info("Kalender: %s | Tagesstart: %02d:00", calendar_name, cfg["day_start_hour"])
  693. protocol = load_protocol(cfg["protocol_file"])
  694. prop_names = cfg["notion_property_names"]
  695. project_filter = cfg["projects"]
  696. if project_filter:
  697. log.info("Projekt-Whitelist aktiv (%d Einträge): %s",
  698. len(project_filter), ", ".join(sorted(project_filter.keys())))
  699. else:
  700. log.info("Keine Projekt-Whitelist gesetzt – alle Projekte werden synchronisiert.")
  701. raw_pages = query_notion_database(
  702. cfg["notion_database_id"], cfg["notion_token"], month_start, month_end, prop_names,
  703. )
  704. log.info("%d Notion-Seiten geladen", len(raw_pages))
  705. project_cache: dict[str, str | None] = {}
  706. normalized: list[dict] = []
  707. for raw in raw_pages:
  708. n = normalize_page(
  709. raw, cfg["notion_token"], project_cache,
  710. cfg["t_shirt_size_map"], prop_names, project_filter,
  711. )
  712. if n is not None:
  713. normalized.append(n)
  714. by_day = aggregate_events(normalized)
  715. events = assign_sequential_times(by_day, int(cfg["day_start_hour"]))
  716. if not events:
  717. log.info("Keine zu synchronisierenden Termine gefunden.")
  718. return 0
  719. if args.dry_run:
  720. log.info("--dry-run aktiv: keine AppleScripts werden ausgeführt.")
  721. print_dry_run_table(events, protocol)
  722. return 0
  723. counters = {"created": 0, "updated": 0, "unchanged": 0, "errors": 0}
  724. for ev in events:
  725. state = determine_state(ev, protocol)
  726. sync_event(ev, state, protocol, calendar_name, counters)
  727. save_protocol(cfg["protocol_file"], protocol)
  728. log.info(
  729. "Sync abgeschlossen: %d erstellt, %d aktualisiert, %d unverändert, %d Fehler",
  730. counters["created"], counters["updated"], counters["unchanged"], counters["errors"],
  731. )
  732. return 0 if counters["errors"] == 0 else 2
  733. if __name__ == "__main__":
  734. sys.exit(main())