Browse Source

Initial Comit

Marius Ott 2 months ago
parent
commit
52580f738c
1 changed files with 322 additions and 0 deletions
  1. 322 0
      sync_recurring_events.py

+ 322 - 0
sync_recurring_events.py

@@ -0,0 +1,322 @@
+"""Sync configurations from the Notion 'Recurring Events' database into the
+'Gemeinsamer Kalender' database by expanding each configuration into its
+individual occurrences.
+
+Uses only the Python standard library (urllib, json, datetime).
+"""
+
+import json
+import urllib.request
+import urllib.error
+from datetime import date, timedelta, datetime
+from calendar import monthrange
+
+NOTION_SECRET = "secret_b7PiPL2FqC9QEikqkAEWOht7LmzPMIJMWTzUPWwbw4H"
+RECURRING_DB_ID = "34010a5f51bd804091dafc91d094bf8b"
+CALENDAR_DB_ID = "34010a5f51bd8093ad67d3d3cd3908a4"
+NOTION_VERSION = "2022-06-28"
+API_BASE = "https://api.notion.com/v1"
+
+# If a configuration has no End Date, we cap occurrence generation at this
+# many years from today so the calendar does not balloon.
+DEFAULT_HORIZON_YEARS = 2
+
+WEEKDAY_INDEX = {
+    "Monday": 0,
+    "Tuesday": 1,
+    "Wednesday": 2,
+    "Thursday": 3,
+    "Friday": 4,
+    "Saturday": 5,
+    "Sunday": 6,
+}
+
+
+# ---------------------------------------------------------------------------
+# Notion HTTP helpers
+# ---------------------------------------------------------------------------
+
+
+def _request(method, path, payload=None):
+    url = f"{API_BASE}{path}"
+    data = None
+    if payload is not None:
+        data = json.dumps(payload).encode("utf-8")
+    req = urllib.request.Request(url, data=data, method=method)
+    req.add_header("Authorization", f"Bearer {NOTION_SECRET}")
+    req.add_header("Notion-Version", NOTION_VERSION)
+    req.add_header("Content-Type", "application/json")
+    try:
+        with urllib.request.urlopen(req) as resp:
+            return json.loads(resp.read().decode("utf-8"))
+    except urllib.error.HTTPError as e:
+        body = e.read().decode("utf-8", errors="replace")
+        raise RuntimeError(f"Notion API {method} {path} -> {e.code}: {body}") from e
+
+
+def query_database(database_id, filter_=None):
+    results = []
+    payload = {}
+    if filter_ is not None:
+        payload["filter"] = filter_
+    while True:
+        data = _request("POST", f"/databases/{database_id}/query", payload)
+        results.extend(data.get("results", []))
+        if not data.get("has_more"):
+            break
+        payload["start_cursor"] = data["next_cursor"]
+    return results
+
+
+def create_page(properties, icon=None):
+    body = {"parent": {"database_id": CALENDAR_DB_ID}, "properties": properties}
+    if icon is not None:
+        body["icon"] = icon
+    return _request("POST", "/pages", body)
+
+
+def update_page(page_id, properties, icon=None):
+    body = {"properties": properties}
+    if icon is not None:
+        body["icon"] = icon
+    return _request("PATCH", f"/pages/{page_id}", body)
+
+
+# ---------------------------------------------------------------------------
+# Property extraction
+# ---------------------------------------------------------------------------
+
+
+def _title_text(prop):
+    return "".join(t.get("plain_text", "") for t in prop.get("title", []))
+
+
+def _rich_text(prop):
+    return "".join(t.get("plain_text", "") for t in prop.get("rich_text", []))
+
+
+def _select_name(prop):
+    sel = prop.get("select")
+    return sel["name"] if sel else None
+
+
+def _multi_select_names(prop):
+    return [o["name"] for o in prop.get("multi_select", [])]
+
+
+def _date_start(prop):
+    d = prop.get("date")
+    if not d or not d.get("start"):
+        return None
+    return date.fromisoformat(d["start"][:10])
+
+
+def parse_recurring_event(page):
+    props = page["properties"]
+    return {
+        "page_id": page["id"],
+        "icon": page.get("icon"),
+        "name": _title_text(props["Event Name"]),
+        "description": _rich_text(props["Description"]),
+        "location": _rich_text(props["Location"]),
+        "start_date": _date_start(props["Start Date"]),
+        "end_date": _date_start(props["End Date"]),
+        "interval": int(props["Interval"].get("number") or 1),
+        "frequency": _select_name(props["Frequency"]),
+        "days_of_week": _multi_select_names(props["Days of Week"]),
+        "days_of_month": [int(x) for x in _multi_select_names(props["Days of Month"])],
+        "betroffen": _multi_select_names(props.get("Betroffen", {"multi_select": []})),
+        "bereich": _multi_select_names(props.get("Bereich", {"multi_select": []})),
+    }
+
+
+# ---------------------------------------------------------------------------
+# Occurrence calculation
+# ---------------------------------------------------------------------------
+
+
+def _add_months(d, months):
+    """Return a date `months` months after d, clamped to last day of month."""
+    total = d.month - 1 + months
+    year = d.year + total // 12
+    month = total % 12 + 1
+    day = min(d.day, monthrange(year, month)[1])
+    return date(year, month, day)
+
+
+def _effective_end(cfg, today):
+    horizon = date(today.year + DEFAULT_HORIZON_YEARS, today.month, today.day)
+    if cfg["end_date"] is None:
+        return horizon
+    return min(cfg["end_date"], horizon)
+
+
+def compute_occurrences(cfg, today=None):
+    today = today or date.today()
+    if cfg["start_date"] is None or not cfg["frequency"]:
+        return []
+    start = cfg["start_date"]
+    end = _effective_end(cfg, today)
+    if end < start:
+        return []
+    interval = max(1, cfg["interval"])
+    freq = cfg["frequency"]
+
+    if freq == "Daily":
+        return _daily(start, end, interval)
+    if freq == "Weekly":
+        return _weekly(start, end, interval, cfg["days_of_week"])
+    if freq == "Monthly":
+        return _monthly(start, end, interval, cfg["days_of_month"])
+    if freq == "Yearly":
+        return _yearly(start, end, interval)
+    return []
+
+
+def _daily(start, end, interval):
+    out = []
+    d = start
+    while d <= end:
+        out.append(d)
+        d += timedelta(days=interval)
+    return out
+
+
+def _weekly(start, end, interval, days_of_week):
+    # Fall back to start date's weekday if nothing is selected.
+    if days_of_week:
+        target_weekdays = {WEEKDAY_INDEX[name] for name in days_of_week if name in WEEKDAY_INDEX}
+    else:
+        target_weekdays = {start.weekday()}
+    if not target_weekdays:
+        return []
+    # Anchor at the start of the week containing `start` (Monday).
+    week_anchor = start - timedelta(days=start.weekday())
+    out = []
+    w = week_anchor
+    while w <= end:
+        for wd in sorted(target_weekdays):
+            d = w + timedelta(days=wd)
+            if start <= d <= end:
+                out.append(d)
+        w += timedelta(weeks=interval)
+    return sorted(out)
+
+
+def _monthly(start, end, interval, days_of_month):
+    if days_of_month:
+        target_days = sorted(set(days_of_month))
+    else:
+        target_days = [start.day]
+    out = []
+    cursor = date(start.year, start.month, 1)
+    months_step = interval
+    # Walk month-by-month in steps of `interval`, starting from start's month.
+    m = date(start.year, start.month, 1)
+    while m <= end:
+        last_day = monthrange(m.year, m.month)[1]
+        for d_num in target_days:
+            if 1 <= d_num <= last_day:
+                d = date(m.year, m.month, d_num)
+                if start <= d <= end:
+                    out.append(d)
+        m = _add_months(m, months_step)
+    return sorted(out)
+
+
+def _yearly(start, end, interval):
+    out = []
+    y = start.year
+    while True:
+        try:
+            d = date(y, start.month, start.day)
+        except ValueError:
+            # Feb 29 on non-leap year: skip.
+            d = None
+        if d is not None:
+            if d > end:
+                break
+            if d >= start:
+                out.append(d)
+        y += interval
+        # Safety: if start.month/day can never exist (shouldn't happen), break.
+        if y > end.year + interval:
+            break
+    return out
+
+
+# ---------------------------------------------------------------------------
+# Calendar upsert
+# ---------------------------------------------------------------------------
+
+
+def _build_calendar_properties(cfg, occurrence_date, today):
+    props = {
+        "Event Name": {"title": [{"text": {"content": cfg["name"]}}]},
+        "Date": {"date": {"start": occurrence_date.isoformat()}},
+        "Is Recurring": {"checkbox": True},
+        "Sync Date": {"date": {"start": today.isoformat()}},
+        "Description": {
+            "rich_text": [{"text": {"content": cfg["description"]}}] if cfg["description"] else []
+        },
+        "Location": {
+            "rich_text": [{"text": {"content": cfg["location"]}}] if cfg["location"] else []
+        },
+        "Betroffen": {"multi_select": [{"name": n} for n in cfg["betroffen"]]},
+        "Bereich": {"multi_select": [{"name": n} for n in cfg["bereich"]]},
+    }
+    return props
+
+
+def _existing_index(calendar_pages):
+    """Map (event_name, date_iso) -> page_id for existing entries."""
+    index = {}
+    for page in calendar_pages:
+        props = page["properties"]
+        name = _title_text(props["Event Name"])
+        d = _date_start(props["Date"])
+        if name and d:
+            index[(name, d.isoformat())] = page["id"]
+    return index
+
+
+def sync():
+    today = date.today()
+    print(f"Sync started at {today.isoformat()}")
+
+    recurring_pages = query_database(RECURRING_DB_ID)
+    print(f"Fetched {len(recurring_pages)} recurring event configuration(s).")
+
+    calendar_pages = query_database(CALENDAR_DB_ID)
+    existing = _existing_index(calendar_pages)
+    print(f"Found {len(existing)} existing calendar entrie(s).")
+
+    created = 0
+    updated = 0
+    skipped = 0
+
+    for page in recurring_pages:
+        cfg = parse_recurring_event(page)
+        if not cfg["name"]:
+            print("  Skipping row without Event Name.")
+            skipped += 1
+            continue
+        occurrences = compute_occurrences(cfg, today=today)
+        print(f"  '{cfg['name']}' ({cfg['frequency']}, interval {cfg['interval']}): "
+              f"{len(occurrences)} occurrence(s) computed.")
+        for occ in occurrences:
+            key = (cfg["name"], occ.isoformat())
+            props = _build_calendar_properties(cfg, occ, today)
+            icon = cfg["icon"]
+            if key in existing:
+                update_page(existing[key], props, icon=icon)
+                updated += 1
+            else:
+                create_page(props, icon=icon)
+                created += 1
+
+    print(f"Done. Created {created}, updated {updated}, skipped {skipped}.")
+
+
+if __name__ == "__main__":
+    sync()