notion_to_calendar.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722
  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. log = logging.getLogger("notion_sync")
  28. # ---------- Config & Logging ----------
  29. def load_config(path: str) -> dict:
  30. p = Path(path)
  31. if not p.exists():
  32. sys.exit(f"FEHLER: Konfigurationsdatei '{path}' nicht gefunden.")
  33. with p.open("r", encoding="utf-8") as f:
  34. cfg = json.load(f)
  35. required = [
  36. "notion_token", "notion_database_id", "calendar_name",
  37. "day_start_hour", "t_shirt_size_map", "protocol_file", "log_file",
  38. ]
  39. missing = [k for k in required if k not in cfg]
  40. if missing:
  41. sys.exit(f"FEHLER: In config.json fehlen Pflichtfelder: {', '.join(missing)}")
  42. user_pn = cfg.get("notion_property_names") or {}
  43. if not isinstance(user_pn, dict):
  44. sys.exit("FEHLER: 'notion_property_names' in config.json muss ein Objekt sein.")
  45. cfg["notion_property_names"] = {**DEFAULT_PROP_NAMES, **user_pn}
  46. projects = cfg.get("projects")
  47. if projects is None:
  48. projects = {}
  49. if not isinstance(projects, dict):
  50. sys.exit("FEHLER: 'projects' in config.json muss ein Objekt {Notion-Name: Anzeige-Name} sein.")
  51. cfg["projects"] = {str(k): str(v) if v else str(k) for k, v in projects.items()}
  52. return cfg
  53. def setup_logging(log_file: str) -> None:
  54. fmt = "[%(asctime)s] %(levelname)-5s %(message)s"
  55. datefmt = "%Y-%m-%d %H:%M:%S"
  56. formatter = logging.Formatter(fmt, datefmt)
  57. log.setLevel(logging.INFO)
  58. log.handlers.clear()
  59. fh = logging.FileHandler(log_file, encoding="utf-8")
  60. fh.setFormatter(formatter)
  61. log.addHandler(fh)
  62. sh = logging.StreamHandler(sys.stdout)
  63. sh.setFormatter(formatter)
  64. log.addHandler(sh)
  65. # ---------- Datums-Helfer ----------
  66. def get_month_bounds(month_str: str) -> tuple[date, date]:
  67. try:
  68. year, month = (int(x) for x in month_str.split("-", 1))
  69. first = date(year, month, 1)
  70. last = date(year, month, cal.monthrange(year, month)[1])
  71. return first, last
  72. except (ValueError, AttributeError):
  73. sys.exit(f"FEHLER: Ungültiger Wert für --monat: '{month_str}'. Format: YYYY-MM.")
  74. def previous_month_str(today: date | None = None) -> str:
  75. today = today or date.today()
  76. first_of_this_month = today.replace(day=1)
  77. last_of_prev = first_of_this_month - timedelta(days=1)
  78. return f"{last_of_prev.year:04d}-{last_of_prev.month:02d}"
  79. # ---------- Notion API ----------
  80. def _notion_headers(token: str) -> dict[str, str]:
  81. return {
  82. "Authorization": f"Bearer {token}",
  83. "Notion-Version": NOTION_VERSION,
  84. "Content-Type": "application/json",
  85. }
  86. def get_database_schema(database_id: str, token: str) -> dict:
  87. url = f"{NOTION_API_BASE}/databases/{database_id}"
  88. try:
  89. resp = requests.get(url, headers=_notion_headers(token), timeout=HTTP_TIMEOUT)
  90. except requests.RequestException as e:
  91. sys.exit(f"FEHLER: Notion API nicht erreichbar: {e}")
  92. if resp.status_code == 401:
  93. sys.exit("FEHLER: Ungültiger Notion-Token. Bitte 'notion_token' in config.json prüfen.")
  94. if resp.status_code == 404:
  95. sys.exit("FEHLER: Notion-Datenbank nicht gefunden. Bitte 'notion_database_id' und Integrationszugriff prüfen.")
  96. if not resp.ok:
  97. sys.exit(f"FEHLER: Datenbank-Schema konnte nicht abgefragt werden ({resp.status_code}): {resp.text}")
  98. return resp.json().get("properties", {})
  99. def _resolve_property_name(schema: dict, requested: str) -> str | None:
  100. if requested in schema:
  101. return requested
  102. target = requested.strip().casefold()
  103. for actual in schema.keys():
  104. if actual.strip().casefold() == target:
  105. return actual
  106. return None
  107. def _require_property(schema: dict, requested: str, role: str) -> str:
  108. actual = _resolve_property_name(schema, requested)
  109. if actual is None:
  110. available = ", ".join(repr(k) for k in sorted(schema.keys()))
  111. sys.exit(
  112. f"FEHLER: Eigenschaft '{requested}' (für '{role}') nicht in der Notion-Datenbank gefunden. "
  113. f"Verfügbare Properties: {available}"
  114. )
  115. if actual != requested:
  116. log.warning("Property '%s' aufgelöst zu '%s' (Whitespace/Groß-Klein-Schreibung).",
  117. requested, actual)
  118. return actual
  119. def _detect_filter_type(schema: dict, prop_name: str, allowed: tuple[str, ...]) -> str:
  120. t = schema[prop_name].get("type")
  121. if t in allowed:
  122. return t
  123. sys.exit(
  124. f"FEHLER: Eigenschaft '{prop_name}' hat den nicht unterstützten Typ '{t}'. "
  125. f"Erwartet: {', '.join(allowed)}."
  126. )
  127. def query_notion_database(database_id: str, token: str, start: date, end: date,
  128. prop_names: dict) -> list[dict]:
  129. schema = get_database_schema(database_id, token)
  130. prop_names["last_edit"] = _require_property(schema, prop_names["last_edit"], "last_edit")
  131. prop_names["status"] = _require_property(schema, prop_names["status"], "status")
  132. prop_names["project"] = _require_property(schema, prop_names["project"], "project")
  133. prop_names["size"] = _require_property(schema, prop_names["size"], "size")
  134. le_name = prop_names["last_edit"]
  135. status_name = prop_names["status"]
  136. project_name = prop_names["project"]
  137. le_filter_type = _detect_filter_type(
  138. schema, le_name, ("date", "last_edited_time", "created_time", "formula"),
  139. )
  140. status_filter_type = _detect_filter_type(schema, status_name, ("select", "status"))
  141. project_type = schema[project_name].get("type")
  142. if project_type != "relation":
  143. sys.exit(
  144. f"FEHLER: Eigenschaft '{project_name}' hat den Typ '{project_type}', "
  145. "erwartet wurde 'relation' (Verknüpfung)."
  146. )
  147. log.info(
  148. "Notion-Schema erkannt: %s (%s), %s (%s), %s (relation)",
  149. le_name, le_filter_type, status_name, status_filter_type, project_name,
  150. )
  151. def _date_filter(condition: dict) -> dict:
  152. if le_filter_type == "formula":
  153. return {"property": le_name, "formula": {"date": condition}}
  154. return {"property": le_name, le_filter_type: condition}
  155. url = f"{NOTION_API_BASE}/databases/{database_id}/query"
  156. headers = _notion_headers(token)
  157. body = {
  158. "filter": {
  159. "and": [
  160. _date_filter({"on_or_after": start.isoformat()}),
  161. _date_filter({"on_or_before": end.isoformat()}),
  162. {"property": status_name, status_filter_type: {"equals": STATUS_DONE}},
  163. ]
  164. },
  165. "page_size": 100,
  166. }
  167. pages: list[dict] = []
  168. while True:
  169. try:
  170. resp = requests.post(url, headers=headers, json=body, timeout=HTTP_TIMEOUT)
  171. except requests.RequestException as e:
  172. sys.exit(f"FEHLER: Notion API nicht erreichbar: {e}")
  173. if resp.status_code == 401:
  174. sys.exit("FEHLER: Ungültiger Notion-Token. Bitte 'notion_token' in config.json prüfen.")
  175. if resp.status_code == 404:
  176. sys.exit("FEHLER: Notion-Datenbank nicht gefunden. Bitte 'notion_database_id' und Integrationszugriff prüfen.")
  177. if not resp.ok:
  178. sys.exit(f"FEHLER: Notion API antwortete mit {resp.status_code}: {resp.text}")
  179. data = resp.json()
  180. pages.extend(data.get("results", []))
  181. if data.get("has_more"):
  182. body["start_cursor"] = data["next_cursor"]
  183. else:
  184. break
  185. return pages
  186. def resolve_project_name(page_id: str, token: str, cache: dict[str, str | None]) -> str | None:
  187. if page_id in cache:
  188. return cache[page_id]
  189. url = f"{NOTION_API_BASE}/pages/{page_id}"
  190. try:
  191. resp = requests.get(url, headers=_notion_headers(token), timeout=HTTP_TIMEOUT)
  192. except requests.RequestException as e:
  193. log.warning("Projekt-Auflösung fehlgeschlagen für %s: %s", page_id, e)
  194. cache[page_id] = None
  195. return None
  196. if not resp.ok:
  197. log.warning("Projekt-Auflösung %s: HTTP %s", page_id, resp.status_code)
  198. cache[page_id] = None
  199. return None
  200. page = resp.json()
  201. name = _extract_title(page.get("properties", {}))
  202. cache[page_id] = name
  203. return name
  204. def _extract_title(props: dict) -> str | None:
  205. for prop in props.values():
  206. if prop.get("type") == "title":
  207. parts = prop.get("title", [])
  208. text = "".join(p.get("plain_text", "") for p in parts).strip()
  209. return text or None
  210. return None
  211. # ---------- Normalisierung ----------
  212. def normalize_page(page: dict, token: str, cache: dict[str, str | None],
  213. size_map: dict[str, int], prop_names: dict,
  214. project_filter: dict[str, str]) -> dict | None:
  215. props = page.get("properties", {})
  216. page_id = page.get("id", "?")
  217. title = _extract_title(props)
  218. if not title:
  219. log.warning("Seite %s: Titel fehlt – übersprungen", page_id)
  220. return None
  221. size_key = prop_names["size"]
  222. size_prop = props.get(size_key) or {}
  223. size_select = (size_prop.get("select") or {}) if size_prop else {}
  224. size = size_select.get("name") if size_select else None
  225. if not size:
  226. log.warning("Seite \"%s\": %s fehlt – übersprungen", title, size_key)
  227. return None
  228. if size not in size_map:
  229. log.warning("Seite \"%s\": Unbekannte T-Shirt Size '%s' – übersprungen", title, size)
  230. return None
  231. minutes = int(size_map[size])
  232. le_key = prop_names["last_edit"]
  233. le_prop = props.get(le_key) or {}
  234. le_type = le_prop.get("type")
  235. if le_type == "date":
  236. le_start = (le_prop.get("date") or {}).get("start")
  237. elif le_type == "last_edited_time":
  238. le_start = le_prop.get("last_edited_time")
  239. elif le_type == "created_time":
  240. le_start = le_prop.get("created_time")
  241. elif le_type == "formula":
  242. formula = le_prop.get("formula") or {}
  243. ftype = formula.get("type")
  244. if ftype == "date":
  245. le_start = (formula.get("date") or {}).get("start")
  246. elif ftype == "string":
  247. le_start = formula.get("string")
  248. elif ftype == "number":
  249. le_start = formula.get("number")
  250. else:
  251. le_start = None
  252. else:
  253. le_start = None
  254. if not le_start:
  255. log.warning("Seite \"%s\": %s fehlt – übersprungen", title, le_key)
  256. return None
  257. try:
  258. last_edit = isoparse(le_start).date()
  259. except (ValueError, TypeError):
  260. log.warning("Seite \"%s\": %s nicht parsbar ('%s') – übersprungen", title, le_key, le_start)
  261. return None
  262. project_key = prop_names["project"]
  263. rel_prop = props.get(project_key) or {}
  264. relations = rel_prop.get("relation") or []
  265. if not relations:
  266. log.warning("Seite \"%s\": Relation '%s' fehlt – übersprungen", title, project_key)
  267. return None
  268. project_id = relations[0].get("id")
  269. if not project_id:
  270. log.warning("Seite \"%s\": Relation '%s' ohne ID – übersprungen", title, project_key)
  271. return None
  272. raw_project_name = resolve_project_name(project_id, token, cache)
  273. if not raw_project_name:
  274. log.warning("Seite \"%s\": Projektname nicht auflösbar – übersprungen", title)
  275. return None
  276. if project_filter:
  277. if raw_project_name not in project_filter:
  278. log.info("Seite \"%s\": Projekt '%s' nicht in Whitelist – übersprungen",
  279. title, raw_project_name)
  280. return None
  281. project_name = project_filter[raw_project_name]
  282. else:
  283. project_name = raw_project_name
  284. return {
  285. "title": title,
  286. "t_shirt_size": size,
  287. "minutes": minutes,
  288. "last_edit": last_edit,
  289. "project_id": project_id,
  290. "project_name": project_name,
  291. }
  292. # ---------- Aggregation & Zeitberechnung ----------
  293. def aggregate_events(pages: list[dict]) -> dict[date, list[dict]]:
  294. groups: dict[tuple[date, str], dict] = {}
  295. for p in pages:
  296. key = (p["last_edit"], p["project_name"])
  297. g = groups.get(key)
  298. if g is None:
  299. g = {
  300. "date": p["last_edit"],
  301. "project_name": p["project_name"],
  302. "total_minutes": 0,
  303. "items": [],
  304. }
  305. groups[key] = g
  306. g["total_minutes"] += p["minutes"]
  307. g["items"].append((p["title"], p["t_shirt_size"]))
  308. by_day: dict[date, list[dict]] = defaultdict(list)
  309. for g in groups.values():
  310. g["items"].sort(key=lambda t: t[0])
  311. g["summary"] = ";\n".join(title for title, _ in g["items"])
  312. g["notes"] = g["project_name"]
  313. by_day[g["date"]].append(g)
  314. return by_day
  315. def assign_sequential_times(events_by_day: dict[date, list[dict]],
  316. day_start_hour: int) -> list[dict]:
  317. result: list[dict] = []
  318. for d in sorted(events_by_day.keys()):
  319. day_events = sorted(events_by_day[d], key=lambda e: e["project_name"])
  320. cursor = datetime(d.year, d.month, d.day, day_start_hour, 0)
  321. for ev in day_events:
  322. ev["start_time"] = cursor
  323. ev["end_time"] = cursor + timedelta(minutes=ev["total_minutes"])
  324. cursor = ev["end_time"]
  325. result.append(ev)
  326. return result
  327. # ---------- Protokoll & Zustand ----------
  328. def load_protocol(path: str) -> dict:
  329. p = Path(path)
  330. if not p.exists():
  331. return {}
  332. try:
  333. with p.open("r", encoding="utf-8") as f:
  334. return json.load(f)
  335. except (OSError, json.JSONDecodeError) as e:
  336. log.warning("Protokolldatei konnte nicht gelesen werden (%s) – starte mit leerem Protokoll.", e)
  337. return {}
  338. def save_protocol(path: str, protocol: dict) -> None:
  339. try:
  340. with Path(path).open("w", encoding="utf-8") as f:
  341. json.dump(protocol, f, indent=2, ensure_ascii=False, sort_keys=True)
  342. except OSError as e:
  343. log.warning("Protokolldatei konnte nicht geschrieben werden: %s", e)
  344. def protocol_key(event: dict) -> str:
  345. return f"{event['date'].isoformat()}|{event['project_name']}"
  346. def determine_state(event: dict, protocol: dict) -> str:
  347. key = protocol_key(event)
  348. prev = protocol.get(key)
  349. if prev is None:
  350. return "new"
  351. same = (
  352. prev.get("total_minutes") == event["total_minutes"]
  353. and prev.get("notes") == event["notes"]
  354. and prev.get("summary") == event["summary"]
  355. and prev.get("start_time") == event["start_time"].strftime("%H:%M")
  356. and prev.get("end_time") == event["end_time"].strftime("%H:%M")
  357. )
  358. return "unchanged" if same else "changed"
  359. # ---------- AppleScript ----------
  360. def escape_applescript_string(s: str) -> str:
  361. s = s.replace("\\", "\\\\")
  362. s = s.replace('"', '\\"')
  363. s = s.replace("\n", "\\n")
  364. return s
  365. def build_create_script(event: dict, calendar_name: str) -> str:
  366. cal_name = escape_applescript_string(calendar_name)
  367. title = escape_applescript_string(event["summary"])
  368. notes = escape_applescript_string(event["notes"])
  369. s, e = event["start_time"], event["end_time"]
  370. return f'''tell application "Calendar"
  371. tell calendar "{cal_name}"
  372. set startDate to current date
  373. set year of startDate to {s.year}
  374. set month of startDate to {s.month}
  375. set day of startDate to {s.day}
  376. set hours of startDate to {s.hour}
  377. set minutes of startDate to {s.minute}
  378. set seconds of startDate to 0
  379. set endDate to current date
  380. set year of endDate to {e.year}
  381. set month of endDate to {e.month}
  382. set day of endDate to {e.day}
  383. set hours of endDate to {e.hour}
  384. set minutes of endDate to {e.minute}
  385. set seconds of endDate to 0
  386. set newEvent to make new event with properties {{summary:"{title}", start date:startDate, end date:endDate, description:"{notes}"}}
  387. return uid of newEvent
  388. end tell
  389. end tell'''
  390. def build_update_script(event: dict, uid: str, calendar_name: str) -> str:
  391. cal_name = escape_applescript_string(calendar_name)
  392. uid_esc = escape_applescript_string(uid)
  393. title = escape_applescript_string(event["summary"])
  394. notes = escape_applescript_string(event["notes"])
  395. s, e = event["start_time"], event["end_time"]
  396. return f'''tell application "Calendar"
  397. tell calendar "{cal_name}"
  398. set matchingEvents to (every event whose uid is "{uid_esc}")
  399. if (count of matchingEvents) = 0 then
  400. return "ERROR: not found"
  401. end if
  402. set theEvent to item 1 of matchingEvents
  403. set startDate to current date
  404. set year of startDate to {s.year}
  405. set month of startDate to {s.month}
  406. set day of startDate to {s.day}
  407. set hours of startDate to {s.hour}
  408. set minutes of startDate to {s.minute}
  409. set seconds of startDate to 0
  410. set endDate to current date
  411. set year of endDate to {e.year}
  412. set month of endDate to {e.month}
  413. set day of endDate to {e.day}
  414. set hours of endDate to {e.hour}
  415. set minutes of endDate to {e.minute}
  416. set seconds of endDate to 0
  417. set summary of theEvent to "{title}"
  418. set start date of theEvent to startDate
  419. set end date of theEvent to endDate
  420. set description of theEvent to "{notes}"
  421. return "OK"
  422. end tell
  423. end tell'''
  424. def run_applescript(script: str) -> tuple[bool, str]:
  425. try:
  426. result = subprocess.run(
  427. ["osascript", "-e", script],
  428. capture_output=True, text=True, timeout=60,
  429. )
  430. except FileNotFoundError:
  431. return False, "osascript nicht gefunden (kein macOS?)"
  432. except subprocess.TimeoutExpired:
  433. return False, "osascript-Timeout"
  434. if result.returncode != 0:
  435. return False, (result.stderr or result.stdout).strip()
  436. return True, result.stdout.strip()
  437. def applescript_calendar_missing(stderr: str) -> bool:
  438. s = stderr.lower()
  439. return "can't get calendar" in s or "kann \"calendar" in s or "-1728" in s
  440. # ---------- Ausgabe ----------
  441. def print_dry_run_table(events: list[dict], protocol: dict) -> None:
  442. rows = [("Zustand", "Datum", "Projekt", "Start", "Ende", "Min")]
  443. for ev in events:
  444. state = determine_state(ev, protocol).upper()
  445. rows.append((
  446. state,
  447. ev["date"].isoformat(),
  448. ev["project_name"],
  449. ev["start_time"].strftime("%H:%M"),
  450. ev["end_time"].strftime("%H:%M"),
  451. str(ev["total_minutes"]),
  452. ))
  453. widths = [max(len(r[i]) for r in rows) for i in range(len(rows[0]))]
  454. line = " ".join(c.ljust(widths[i]) for i, c in enumerate(rows[0]))
  455. print(line)
  456. print("-" * len(line))
  457. for r in rows[1:]:
  458. print(" ".join(c.ljust(widths[i]) for i, c in enumerate(r)))
  459. # ---------- Sync-Ausführung ----------
  460. def sync_event(event: dict, state: str, protocol: dict, calendar_name: str,
  461. counters: dict[str, int]) -> None:
  462. key = protocol_key(event)
  463. s_str = event["start_time"].strftime("%H:%M")
  464. e_str = event["end_time"].strftime("%H:%M")
  465. label = f"{event['project_name']:<20} | {event['date'].isoformat()} | {s_str}–{e_str}"
  466. if state == "unchanged":
  467. log.info("UNVERÄNDERT: %s – übersprungen", label)
  468. counters["unchanged"] += 1
  469. return
  470. if state == "new":
  471. ok, out = run_applescript(build_create_script(event, calendar_name))
  472. if not ok:
  473. if applescript_calendar_missing(out):
  474. sys.exit(f"FEHLER: Kalender '{calendar_name}' nicht gefunden – bitte calendar_name in config.json prüfen.")
  475. log.error("CREATE fehlgeschlagen für %s: %s", label, out)
  476. counters["errors"] += 1
  477. return
  478. uid = out
  479. if not uid:
  480. log.error("CREATE lieferte leere UID für %s", label)
  481. counters["errors"] += 1
  482. return
  483. protocol[key] = {
  484. "uid": uid,
  485. "start_time": s_str,
  486. "end_time": e_str,
  487. "total_minutes": event["total_minutes"],
  488. "summary": event["summary"],
  489. "notes": event["notes"],
  490. }
  491. log.info("NEU: %s", label)
  492. counters["created"] += 1
  493. return
  494. # state == "changed"
  495. prev = protocol.get(key, {})
  496. uid = prev.get("uid")
  497. prev_label = ""
  498. if prev.get("start_time") and prev.get("end_time"):
  499. prev_label = f" (vorher {prev['start_time']}–{prev['end_time']})"
  500. if uid:
  501. ok, out = run_applescript(build_update_script(event, uid, calendar_name))
  502. if ok and out == "OK":
  503. protocol[key] = {
  504. "uid": uid,
  505. "start_time": s_str,
  506. "end_time": e_str,
  507. "total_minutes": event["total_minutes"],
  508. "summary": event["summary"],
  509. "notes": event["notes"],
  510. }
  511. log.info("GEÄNDERT: %s%s", label, prev_label)
  512. counters["updated"] += 1
  513. return
  514. if not ok and applescript_calendar_missing(out):
  515. sys.exit(f"FEHLER: Kalender '{calendar_name}' nicht gefunden – bitte calendar_name in config.json prüfen.")
  516. log.warning("UPDATE fehlgeschlagen (%s) – lege Termin neu an: %s", out or "unbekannt", label)
  517. else:
  518. log.warning("Kein UID im Protokoll für %s – lege Termin neu an.", label)
  519. ok, out = run_applescript(build_create_script(event, calendar_name))
  520. if not ok:
  521. if applescript_calendar_missing(out):
  522. sys.exit(f"FEHLER: Kalender '{calendar_name}' nicht gefunden – bitte calendar_name in config.json prüfen.")
  523. log.error("Fallback-CREATE fehlgeschlagen für %s: %s", label, out)
  524. counters["errors"] += 1
  525. return
  526. new_uid = out
  527. if not new_uid:
  528. log.error("Fallback-CREATE lieferte leere UID für %s", label)
  529. counters["errors"] += 1
  530. return
  531. protocol[key] = {
  532. "uid": new_uid,
  533. "start_time": s_str,
  534. "end_time": e_str,
  535. "total_minutes": event["total_minutes"],
  536. "summary": event["summary"],
  537. "notes": event["notes"],
  538. }
  539. log.info("GEÄNDERT (neu):%s%s", label, prev_label)
  540. counters["updated"] += 1
  541. # ---------- Hauptablauf ----------
  542. def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
  543. parser = argparse.ArgumentParser(description="Notion → Apple Kalender / Exchange Sync")
  544. parser.add_argument("--monat", default=None,
  545. help="Zielmonat im Format YYYY-MM, z. B. 2026-04. "
  546. "Ohne Angabe wird der vergangene Monat verwendet.")
  547. parser.add_argument("--dry-run", action="store_true", help="Zeigt Termine an, ohne sie anzulegen oder zu ändern")
  548. parser.add_argument("--kalender", help="Name des Zielkalenders (überschreibt config.json)")
  549. return parser.parse_args(argv)
  550. def main(argv: list[str] | None = None) -> int:
  551. args = parse_args(argv)
  552. cfg = load_config("config.json")
  553. setup_logging(cfg["log_file"])
  554. calendar_name = args.kalender or cfg["calendar_name"]
  555. month_str = args.monat or previous_month_str()
  556. month_start, month_end = get_month_bounds(month_str)
  557. if args.monat is None:
  558. log.info("Kein --monat angegeben – verwende vergangenen Monat: %s", month_str)
  559. log.info("Starte Sync für Monat: %s", month_str)
  560. log.info("Kalender: %s | Tagesstart: %02d:00", calendar_name, cfg["day_start_hour"])
  561. protocol = load_protocol(cfg["protocol_file"])
  562. prop_names = cfg["notion_property_names"]
  563. project_filter = cfg["projects"]
  564. if project_filter:
  565. log.info("Projekt-Whitelist aktiv (%d Einträge): %s",
  566. len(project_filter), ", ".join(sorted(project_filter.keys())))
  567. else:
  568. log.info("Keine Projekt-Whitelist gesetzt – alle Projekte werden synchronisiert.")
  569. raw_pages = query_notion_database(
  570. cfg["notion_database_id"], cfg["notion_token"], month_start, month_end, prop_names,
  571. )
  572. log.info("%d Notion-Seiten geladen", len(raw_pages))
  573. project_cache: dict[str, str | None] = {}
  574. normalized: list[dict] = []
  575. for raw in raw_pages:
  576. n = normalize_page(
  577. raw, cfg["notion_token"], project_cache,
  578. cfg["t_shirt_size_map"], prop_names, project_filter,
  579. )
  580. if n is not None:
  581. normalized.append(n)
  582. by_day = aggregate_events(normalized)
  583. events = assign_sequential_times(by_day, int(cfg["day_start_hour"]))
  584. if not events:
  585. log.info("Keine zu synchronisierenden Termine gefunden.")
  586. return 0
  587. if args.dry_run:
  588. log.info("--dry-run aktiv: keine AppleScripts werden ausgeführt.")
  589. print_dry_run_table(events, protocol)
  590. return 0
  591. counters = {"created": 0, "updated": 0, "unchanged": 0, "errors": 0}
  592. for ev in events:
  593. state = determine_state(ev, protocol)
  594. sync_event(ev, state, protocol, calendar_name, counters)
  595. save_protocol(cfg["protocol_file"], protocol)
  596. log.info(
  597. "Sync abgeschlossen: %d erstellt, %d aktualisiert, %d unverändert, %d Fehler",
  598. counters["created"], counters["updated"], counters["unchanged"], counters["errors"],
  599. )
  600. return 0 if counters["errors"] == 0 else 2
  601. if __name__ == "__main__":
  602. sys.exit(main())