mirror of
https://github.com/skoelle/calender_sync.git
synced 2026-09-17 18:20:24 +00:00
initial commit
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
.env
|
||||
.git
|
||||
.github
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -0,0 +1,15 @@
|
||||
# Secret ICS URL aus den Google Kalender Einstellungen
|
||||
ICS_URL=https://calendar.google.com/calendar/ical/DEINE_KALENDER_ID%40group.calendar.google.com/private-DEINTOKEN/basic.ics
|
||||
CALENDAR_LABEL=privat
|
||||
|
||||
# MariaDB LXC Zugangsdaten (neue eigenstaendige DB, nicht healthchecks-DB!)
|
||||
DB_HOST=mariadb.fritz.box
|
||||
DB_PORT=3306
|
||||
DB_NAME=calendar_sync
|
||||
DB_USER=calendar_sync
|
||||
DB_PASSWORD=HIER_SICHERES_PASSWORT
|
||||
|
||||
SYNC_INTERVAL_MINUTES=15
|
||||
WINDOW_PAST_DAYS=90
|
||||
WINDOW_FUTURE_DAYS=365
|
||||
LOG_LEVEL=INFO
|
||||
@@ -0,0 +1,55 @@
|
||||
name: Build and Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
cleanup-old-images:
|
||||
needs: build-and-push
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Delete old, untagged image versions
|
||||
uses: actions/delete-package-versions@v5
|
||||
with:
|
||||
package-name: ${{ github.event.repository.name }}
|
||||
package-type: container
|
||||
min-versions-to-keep: 1
|
||||
delete-only-untagged-versions: true
|
||||
@@ -0,0 +1,3 @@
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY sync.py .
|
||||
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
CMD ["python", "sync.py"]
|
||||
@@ -0,0 +1,32 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
calendar-sync:
|
||||
image: ghcr.io/skoelle/calender_sync:latest
|
||||
container_name: calendar-sync
|
||||
restart: unless-stopped
|
||||
|
||||
environment:
|
||||
- ICS_URL=${ICS_URL}
|
||||
- CALENDAR_LABEL=${CALENDAR_LABEL:-privat}
|
||||
|
||||
- DB_HOST=${DB_HOST:-mariadb.fritz.box}
|
||||
- DB_PORT=${DB_PORT:-3306}
|
||||
- DB_NAME=${DB_NAME:-calendar_sync}
|
||||
- DB_USER=${DB_USER}
|
||||
- DB_PASSWORD=${DB_PASSWORD}
|
||||
|
||||
- SYNC_INTERVAL_MINUTES=${SYNC_INTERVAL_MINUTES:-15}
|
||||
- WINDOW_PAST_DAYS=${WINDOW_PAST_DAYS:-90}
|
||||
- WINDOW_FUTURE_DAYS=${WINDOW_FUTURE_DAYS:-365}
|
||||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=true"
|
||||
|
||||
networks:
|
||||
- docker-backend
|
||||
|
||||
networks:
|
||||
docker-backend:
|
||||
external: true
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Auf dem MariaDB LXC einmalig ausfuehren (z.B. via mysql -u root -p)
|
||||
CREATE DATABASE IF NOT EXISTS calendar_sync CHARACTER SET utf8mb4;
|
||||
CREATE USER IF NOT EXISTS 'calendar_sync'@'%' IDENTIFIED BY 'HIER_SICHERES_PASSWORT';
|
||||
GRANT ALL PRIVILEGES ON calendar_sync.* TO 'calendar_sync'@'%';
|
||||
FLUSH PRIVILEGES;
|
||||
@@ -0,0 +1,4 @@
|
||||
requests==2.32.3
|
||||
icalendar==6.1.0
|
||||
recurring-ical-events==3.4.1
|
||||
mysql-connector-python==9.1.0
|
||||
@@ -0,0 +1,253 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Google Calendar (privater ICS-Feed) -> MariaDB Sync
|
||||
Homelab: laeuft als Docker Container, pollt periodisch, expandiert RRULE/EXDATE/RECURRENCE-ID
|
||||
und schreibt Einzel-Instanzen in eine eigene MariaDB-Datenbank (calendar_sync).
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import logging
|
||||
import hashlib
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import requests
|
||||
import icalendar
|
||||
import recurring_ical_events
|
||||
import mysql.connector
|
||||
from mysql.connector import Error as MySQLError
|
||||
|
||||
logging.basicConfig(
|
||||
level=os.environ.get("LOG_LEVEL", "INFO"),
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
stream=sys.stdout,
|
||||
)
|
||||
log = logging.getLogger("calendar-sync")
|
||||
|
||||
ICS_URL = os.environ["ICS_URL"]
|
||||
DB_HOST = os.environ.get("DB_HOST", "mariadb.fritz.box")
|
||||
DB_PORT = int(os.environ.get("DB_PORT", "3306"))
|
||||
DB_NAME = os.environ.get("DB_NAME", "calendar_sync")
|
||||
DB_USER = os.environ["DB_USER"]
|
||||
DB_PASSWORD = os.environ["DB_PASSWORD"]
|
||||
|
||||
SYNC_INTERVAL_MINUTES = int(os.environ.get("SYNC_INTERVAL_MINUTES", "15"))
|
||||
WINDOW_PAST_DAYS = int(os.environ.get("WINDOW_PAST_DAYS", "90"))
|
||||
WINDOW_FUTURE_DAYS = int(os.environ.get("WINDOW_FUTURE_DAYS", "365"))
|
||||
CALENDAR_LABEL = os.environ.get("CALENDAR_LABEL", "default")
|
||||
|
||||
|
||||
DB_BOOTSTRAP = os.environ.get("DB_BOOTSTRAP", "false").lower() == "true"
|
||||
DB_ROOT_USER = os.environ.get("DB_ROOT_USER")
|
||||
DB_ROOT_PASSWORD = os.environ.get("DB_ROOT_PASSWORD")
|
||||
|
||||
|
||||
def bootstrap_database():
|
||||
"""Legt DB_NAME und DB_USER an, falls sie noch nicht existieren.
|
||||
Erfordert DB_ROOT_USER/DB_ROOT_PASSWORD mit ausreichenden Rechten
|
||||
(z.B. root auf dem MariaDB LXC). Wird nur ausgefuehrt, wenn
|
||||
DB_BOOTSTRAP=true gesetzt ist."""
|
||||
if not DB_BOOTSTRAP:
|
||||
return
|
||||
if not DB_ROOT_USER or not DB_ROOT_PASSWORD:
|
||||
log.warning("DB_BOOTSTRAP=true aber DB_ROOT_USER/DB_ROOT_PASSWORD fehlen, ueberspringe Bootstrap")
|
||||
return
|
||||
|
||||
log.info("Bootstrap: pruefe/erstelle Datenbank '%s' und User '%s'", DB_NAME, DB_USER)
|
||||
root_conn = mysql.connector.connect(
|
||||
host=DB_HOST,
|
||||
port=DB_PORT,
|
||||
user=DB_ROOT_USER,
|
||||
password=DB_ROOT_PASSWORD,
|
||||
autocommit=True,
|
||||
)
|
||||
try:
|
||||
cur = root_conn.cursor()
|
||||
cur.execute(f"CREATE DATABASE IF NOT EXISTS `{DB_NAME}` CHARACTER SET utf8mb4")
|
||||
cur.execute(
|
||||
"CREATE USER IF NOT EXISTS %s@'%%' IDENTIFIED BY %s",
|
||||
(DB_USER, DB_PASSWORD),
|
||||
)
|
||||
cur.execute(f"GRANT ALL PRIVILEGES ON `{DB_NAME}`.* TO %s@'%%'", (DB_USER,))
|
||||
cur.execute("FLUSH PRIVILEGES")
|
||||
cur.close()
|
||||
log.info("Bootstrap abgeschlossen")
|
||||
finally:
|
||||
root_conn.close()
|
||||
|
||||
|
||||
def get_connection():
|
||||
return mysql.connector.connect(
|
||||
host=DB_HOST,
|
||||
port=DB_PORT,
|
||||
user=DB_USER,
|
||||
password=DB_PASSWORD,
|
||||
database=DB_NAME,
|
||||
autocommit=False,
|
||||
)
|
||||
|
||||
|
||||
def ensure_schema(conn):
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS calendar_events (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
calendar_label VARCHAR(64) NOT NULL,
|
||||
instance_key VARCHAR(255) NOT NULL,
|
||||
uid VARCHAR(255) NOT NULL,
|
||||
recurrence_id VARCHAR(64) NULL,
|
||||
summary VARCHAR(512),
|
||||
description TEXT,
|
||||
location VARCHAR(512),
|
||||
start_at DATETIME NOT NULL,
|
||||
end_at DATETIME NULL,
|
||||
all_day TINYINT(1) NOT NULL DEFAULT 0,
|
||||
status VARCHAR(32) DEFAULT 'CONFIRMED',
|
||||
deleted TINYINT(1) NOT NULL DEFAULT 0,
|
||||
last_seen_at DATETIME NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_instance (calendar_label, instance_key),
|
||||
INDEX idx_start (start_at),
|
||||
INDEX idx_deleted (deleted)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
""")
|
||||
conn.commit()
|
||||
cur.close()
|
||||
|
||||
|
||||
def fetch_ics(url: str) -> bytes:
|
||||
resp = requests.get(url, timeout=30)
|
||||
resp.raise_for_status()
|
||||
return resp.content
|
||||
|
||||
|
||||
def to_naive_utc(dt) -> datetime:
|
||||
"""Normalisiert date/datetime auf ein naive UTC datetime fuer MySQL DATETIME."""
|
||||
if isinstance(dt, datetime):
|
||||
if dt.tzinfo is not None:
|
||||
dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
return dt
|
||||
return datetime(dt.year, dt.month, dt.day)
|
||||
|
||||
|
||||
def instance_key_for(event) -> tuple[str, str, str | None]:
|
||||
uid = str(event.get("UID", ""))
|
||||
recurrence_id = event.get("RECURRENCE-ID")
|
||||
rid_str = None
|
||||
if recurrence_id is not None:
|
||||
rid_dt = recurrence_id.dt
|
||||
rid_str = to_naive_utc(rid_dt).isoformat()
|
||||
key_source = uid + "|" + (rid_str or "")
|
||||
key = hashlib.sha1(key_source.encode("utf-8")).hexdigest()
|
||||
return key, uid, rid_str
|
||||
|
||||
|
||||
def expand_events(ics_bytes: bytes, window_start: datetime, window_end: datetime):
|
||||
calendar = icalendar.Calendar.from_ical(ics_bytes)
|
||||
occurrences = recurring_ical_events.of(calendar).between(window_start, window_end)
|
||||
return occurrences
|
||||
|
||||
|
||||
def upsert_event(cur, calendar_label, run_ts, event):
|
||||
key, uid, rid_str = instance_key_for(event)
|
||||
|
||||
summary = str(event.get("SUMMARY", "") or "")
|
||||
description = str(event.get("DESCRIPTION", "") or "")
|
||||
location = str(event.get("LOCATION", "") or "")
|
||||
status = str(event.get("STATUS", "CONFIRMED") or "CONFIRMED")
|
||||
|
||||
dtstart = event["DTSTART"].dt
|
||||
all_day = not isinstance(dtstart, datetime)
|
||||
start_at = to_naive_utc(dtstart)
|
||||
|
||||
dtend_prop = event.get("DTEND")
|
||||
end_at = to_naive_utc(dtend_prop.dt) if dtend_prop else None
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO calendar_events
|
||||
(calendar_label, instance_key, uid, recurrence_id, summary,
|
||||
description, location, start_at, end_at, all_day, status,
|
||||
deleted, last_seen_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 0, %s)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
summary = VALUES(summary),
|
||||
description = VALUES(description),
|
||||
location = VALUES(location),
|
||||
start_at = VALUES(start_at),
|
||||
end_at = VALUES(end_at),
|
||||
all_day = VALUES(all_day),
|
||||
status = VALUES(status),
|
||||
deleted = 0,
|
||||
last_seen_at = VALUES(last_seen_at)
|
||||
""",
|
||||
(
|
||||
calendar_label, key, uid, rid_str, summary, description,
|
||||
location, start_at, end_at, all_day, status, run_ts,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def mark_missing_as_deleted(cur, calendar_label, run_ts, window_start, window_end):
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE calendar_events
|
||||
SET deleted = 1
|
||||
WHERE calendar_label = %s
|
||||
AND deleted = 0
|
||||
AND last_seen_at < %s
|
||||
AND start_at BETWEEN %s AND %s
|
||||
""",
|
||||
(calendar_label, run_ts, window_start, window_end),
|
||||
)
|
||||
return cur.rowcount
|
||||
|
||||
|
||||
def run_sync_once():
|
||||
run_ts = datetime.utcnow().replace(microsecond=0)
|
||||
window_start = run_ts - timedelta(days=WINDOW_PAST_DAYS)
|
||||
window_end = run_ts + timedelta(days=WINDOW_FUTURE_DAYS)
|
||||
|
||||
log.info("Starte Sync fuer '%s' | Fenster %s bis %s", CALENDAR_LABEL, window_start, window_end)
|
||||
|
||||
ics_bytes = fetch_ics(ICS_URL)
|
||||
occurrences = expand_events(ics_bytes, window_start, window_end)
|
||||
log.info("ICS geladen, %d Instanzen im Fenster gefunden", len(occurrences))
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
ensure_schema(conn)
|
||||
cur = conn.cursor()
|
||||
for occ in occurrences:
|
||||
upsert_event(cur, CALENDAR_LABEL, run_ts, occ)
|
||||
deleted_count = mark_missing_as_deleted(cur, CALENDAR_LABEL, run_ts, window_start, window_end)
|
||||
conn.commit()
|
||||
cur.close()
|
||||
log.info("Sync fertig. %d Events als geloescht markiert.", deleted_count)
|
||||
except MySQLError:
|
||||
conn.rollback()
|
||||
log.exception("DB-Fehler beim Sync, Rollback ausgefuehrt")
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def main():
|
||||
bootstrap_database()
|
||||
log.info(
|
||||
"calendar-sync gestartet | Intervall=%smin | Fenster=-%dd/+%dd",
|
||||
SYNC_INTERVAL_MINUTES, WINDOW_PAST_DAYS, WINDOW_FUTURE_DAYS,
|
||||
)
|
||||
while True:
|
||||
try:
|
||||
run_sync_once()
|
||||
except Exception:
|
||||
log.exception("Sync-Durchlauf fehlgeschlagen, versuche es beim naechsten Intervall erneut")
|
||||
time.sleep(SYNC_INTERVAL_MINUTES * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user