sync_recurring_events.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357
  1. """Sync configurations from the Notion 'Recurring Events' database into the
  2. 'Gemeinsamer Kalender' database by expanding each configuration into its
  3. individual occurrences.
  4. Uses only the Python standard library (urllib, json, datetime).
  5. """
  6. import json
  7. import urllib.request
  8. import urllib.error
  9. from datetime import date, timedelta, datetime
  10. from calendar import monthrange
  11. NOTION_SECRET = "secret_b7PiPL2FqC9QEikqkAEWOht7LmzPMIJMWTzUPWwbw4H"
  12. RECURRING_DB_ID = "34010a5f51bd804091dafc91d094bf8b"
  13. CALENDAR_DB_ID = "34010a5f51bd8093ad67d3d3cd3908a4"
  14. NOTION_VERSION = "2022-06-28"
  15. API_BASE = "https://api.notion.com/v1"
  16. # If a configuration has no End Date, we cap occurrence generation at this
  17. # many years from today so the calendar does not balloon.
  18. DEFAULT_HORIZON_YEARS = 2
  19. WEEKDAY_INDEX = {
  20. "Monday": 0,
  21. "Tuesday": 1,
  22. "Wednesday": 2,
  23. "Thursday": 3,
  24. "Friday": 4,
  25. "Saturday": 5,
  26. "Sunday": 6,
  27. }
  28. # ---------------------------------------------------------------------------
  29. # Notion HTTP helpers
  30. # ---------------------------------------------------------------------------
  31. def _request(method, path, payload=None):
  32. url = f"{API_BASE}{path}"
  33. data = None
  34. if payload is not None:
  35. data = json.dumps(payload).encode("utf-8")
  36. req = urllib.request.Request(url, data=data, method=method)
  37. req.add_header("Authorization", f"Bearer {NOTION_SECRET}")
  38. req.add_header("Notion-Version", NOTION_VERSION)
  39. req.add_header("Content-Type", "application/json")
  40. try:
  41. with urllib.request.urlopen(req) as resp:
  42. return json.loads(resp.read().decode("utf-8"))
  43. except urllib.error.HTTPError as e:
  44. body = e.read().decode("utf-8", errors="replace")
  45. raise RuntimeError(f"Notion API {method} {path} -> {e.code}: {body}") from e
  46. def query_database(database_id, filter_=None):
  47. results = []
  48. payload = {}
  49. if filter_ is not None:
  50. payload["filter"] = filter_
  51. while True:
  52. data = _request("POST", f"/databases/{database_id}/query", payload)
  53. results.extend(data.get("results", []))
  54. if not data.get("has_more"):
  55. break
  56. payload["start_cursor"] = data["next_cursor"]
  57. return results
  58. def create_page(properties, icon=None):
  59. body = {"parent": {"database_id": CALENDAR_DB_ID}, "properties": properties}
  60. if icon is not None:
  61. body["icon"] = icon
  62. return _request("POST", "/pages", body)
  63. def update_page(page_id, properties, icon=None):
  64. body = {"properties": properties}
  65. if icon is not None:
  66. body["icon"] = icon
  67. return _request("PATCH", f"/pages/{page_id}", body)
  68. def archive_page(page_id):
  69. return _request("PATCH", f"/pages/{page_id}", {"archived": True})
  70. # ---------------------------------------------------------------------------
  71. # Property extraction
  72. # ---------------------------------------------------------------------------
  73. def _title_text(prop):
  74. return "".join(t.get("plain_text", "") for t in prop.get("title", []))
  75. def _rich_text(prop):
  76. return "".join(t.get("plain_text", "") for t in prop.get("rich_text", []))
  77. def _select_name(prop):
  78. sel = prop.get("select")
  79. return sel["name"] if sel else None
  80. def _multi_select_names(prop):
  81. return [o["name"] for o in prop.get("multi_select", [])]
  82. def _date_start(prop):
  83. d = prop.get("date")
  84. if not d or not d.get("start"):
  85. return None
  86. return date.fromisoformat(d["start"][:10])
  87. def parse_recurring_event(page):
  88. props = page["properties"]
  89. return {
  90. "page_id": page["id"],
  91. "icon": page.get("icon"),
  92. "name": _title_text(props["Event Name"]),
  93. "description": _rich_text(props["Description"]),
  94. "location": _rich_text(props["Location"]),
  95. "start_date": _date_start(props["Start Date"]),
  96. "end_date": _date_start(props["End Date"]),
  97. "interval": int(props["Interval"].get("number") or 1),
  98. "frequency": _select_name(props["Frequency"]),
  99. "days_of_week": _multi_select_names(props["Days of Week"]),
  100. "days_of_month": [int(x) for x in _multi_select_names(props["Days of Month"])],
  101. "betroffen": _multi_select_names(props.get("Betroffen", {"multi_select": []})),
  102. "bereich": _multi_select_names(props.get("Bereich", {"multi_select": []})),
  103. }
  104. # ---------------------------------------------------------------------------
  105. # Occurrence calculation
  106. # ---------------------------------------------------------------------------
  107. def _add_months(d, months):
  108. """Return a date `months` months after d, clamped to last day of month."""
  109. total = d.month - 1 + months
  110. year = d.year + total // 12
  111. month = total % 12 + 1
  112. day = min(d.day, monthrange(year, month)[1])
  113. return date(year, month, day)
  114. def _effective_end(cfg, today):
  115. horizon = date(today.year + DEFAULT_HORIZON_YEARS, today.month, today.day)
  116. if cfg["end_date"] is None:
  117. return horizon
  118. return min(cfg["end_date"], horizon)
  119. def compute_occurrences(cfg, today=None):
  120. today = today or date.today()
  121. if cfg["start_date"] is None or not cfg["frequency"]:
  122. return []
  123. start = cfg["start_date"]
  124. end = _effective_end(cfg, today)
  125. if end < start:
  126. return []
  127. interval = max(1, cfg["interval"])
  128. freq = cfg["frequency"]
  129. if freq == "Daily":
  130. return _daily(start, end, interval)
  131. if freq == "Weekly":
  132. return _weekly(start, end, interval, cfg["days_of_week"])
  133. if freq == "Monthly":
  134. return _monthly(start, end, interval, cfg["days_of_month"])
  135. if freq == "Yearly":
  136. return _yearly(start, end, interval)
  137. return []
  138. def _daily(start, end, interval):
  139. out = []
  140. d = start
  141. while d <= end:
  142. out.append(d)
  143. d += timedelta(days=interval)
  144. return out
  145. def _weekly(start, end, interval, days_of_week):
  146. # Fall back to start date's weekday if nothing is selected.
  147. if days_of_week:
  148. target_weekdays = {WEEKDAY_INDEX[name] for name in days_of_week if name in WEEKDAY_INDEX}
  149. else:
  150. target_weekdays = {start.weekday()}
  151. if not target_weekdays:
  152. return []
  153. # Anchor at the start of the week containing `start` (Monday).
  154. week_anchor = start - timedelta(days=start.weekday())
  155. out = []
  156. w = week_anchor
  157. while w <= end:
  158. for wd in sorted(target_weekdays):
  159. d = w + timedelta(days=wd)
  160. if start <= d <= end:
  161. out.append(d)
  162. w += timedelta(weeks=interval)
  163. return sorted(out)
  164. def _monthly(start, end, interval, days_of_month):
  165. if days_of_month:
  166. target_days = sorted(set(days_of_month))
  167. else:
  168. target_days = [start.day]
  169. out = []
  170. cursor = date(start.year, start.month, 1)
  171. months_step = interval
  172. # Walk month-by-month in steps of `interval`, starting from start's month.
  173. m = date(start.year, start.month, 1)
  174. while m <= end:
  175. last_day = monthrange(m.year, m.month)[1]
  176. for d_num in target_days:
  177. if 1 <= d_num <= last_day:
  178. d = date(m.year, m.month, d_num)
  179. if start <= d <= end:
  180. out.append(d)
  181. m = _add_months(m, months_step)
  182. return sorted(out)
  183. def _yearly(start, end, interval):
  184. out = []
  185. y = start.year
  186. while True:
  187. try:
  188. d = date(y, start.month, start.day)
  189. except ValueError:
  190. # Feb 29 on non-leap year: skip.
  191. d = None
  192. if d is not None:
  193. if d > end:
  194. break
  195. if d >= start:
  196. out.append(d)
  197. y += interval
  198. # Safety: if start.month/day can never exist (shouldn't happen), break.
  199. if y > end.year + interval:
  200. break
  201. return out
  202. # ---------------------------------------------------------------------------
  203. # Calendar upsert
  204. # ---------------------------------------------------------------------------
  205. def _build_calendar_properties(cfg, occurrence_date, today):
  206. props = {
  207. "Event Name": {"title": [{"text": {"content": cfg["name"]}}]},
  208. "Date": {"date": {"start": occurrence_date.isoformat()}},
  209. "Is Recurring": {"checkbox": True},
  210. "Sync Date": {"date": {"start": today.isoformat()}},
  211. "Description": {
  212. "rich_text": [{"text": {"content": cfg["description"]}}] if cfg["description"] else []
  213. },
  214. "Location": {
  215. "rich_text": [{"text": {"content": cfg["location"]}}] if cfg["location"] else []
  216. },
  217. "Betroffen": {"multi_select": [{"name": n} for n in cfg["betroffen"]]},
  218. "Bereich": {"multi_select": [{"name": n} for n in cfg["bereich"]]},
  219. }
  220. return props
  221. def _existing_index(calendar_pages):
  222. """Map (event_name, date_iso) -> page_id for existing entries."""
  223. index = {}
  224. for page in calendar_pages:
  225. props = page["properties"]
  226. name = _title_text(props["Event Name"])
  227. d = _date_start(props["Date"])
  228. if name and d:
  229. index[(name, d.isoformat())] = page["id"]
  230. return index
  231. def sync():
  232. today = date.today()
  233. print(f"Sync started at {today.isoformat()}")
  234. recurring_pages = query_database(RECURRING_DB_ID)
  235. print(f"Fetched {len(recurring_pages)} recurring event configuration(s).")
  236. calendar_pages = query_database(CALENDAR_DB_ID)
  237. existing = _existing_index(calendar_pages)
  238. print(f"Found {len(existing)} existing calendar entrie(s).")
  239. created = 0
  240. updated = 0
  241. skipped = 0
  242. desired_keys = set()
  243. for page in recurring_pages:
  244. cfg = parse_recurring_event(page)
  245. if not cfg["name"]:
  246. print(" Skipping row without Event Name.")
  247. skipped += 1
  248. continue
  249. occurrences = compute_occurrences(cfg, today=today)
  250. print(f" '{cfg['name']}' ({cfg['frequency']}, interval {cfg['interval']}): "
  251. f"{len(occurrences)} occurrence(s) computed.")
  252. for occ in occurrences:
  253. key = (cfg["name"], occ.isoformat())
  254. desired_keys.add(key)
  255. props = _build_calendar_properties(cfg, occ, today)
  256. icon = cfg["icon"]
  257. if key in existing:
  258. update_page(existing[key], props, icon=icon)
  259. updated += 1
  260. else:
  261. create_page(props, icon=icon)
  262. created += 1
  263. # Purge stale recurring entries: any calendar page marked as recurring
  264. # that no longer corresponds to a desired (name, date) occurrence.
  265. archived = _purge_stale(calendar_pages, desired_keys)
  266. print(f"Done. Created {created}, updated {updated}, archived {archived}, "
  267. f"skipped {skipped}.")
  268. def _purge_stale(calendar_pages, desired_keys):
  269. archived = 0
  270. for page in calendar_pages:
  271. # Skip pages that are already archived / in trash.
  272. if page.get("archived") or page.get("in_trash"):
  273. continue
  274. props = page["properties"]
  275. # Only consider entries flagged as recurring — manual entries are
  276. # always left untouched.
  277. is_recurring = props.get("Is Recurring", {}).get("checkbox", False)
  278. if not is_recurring:
  279. continue
  280. name = _title_text(props["Event Name"])
  281. d = _date_start(props["Date"])
  282. if not name or not d:
  283. continue
  284. key = (name, d.isoformat())
  285. if key not in desired_keys:
  286. archive_page(page["id"])
  287. archived += 1
  288. print(f" Archived stale occurrence: {name} @ {d.isoformat()}")
  289. return archived
  290. if __name__ == "__main__":
  291. sync()