Compare commits

...
28 Commits
Author SHA1 Message Date
stefankoelle 78cafdd5dd Weekly Notification: Zeitraum auf 2 Wochen erweitern 2026-09-13 14:31:40 +02:00
stefankoelle cc803f2afe fix docker-compose 2026-08-22 09:28:18 +02:00
stefankoelle 569ceccb93 new feature: weekly blacklistwords 2026-08-18 18:22:46 +02:00
stefankoelle 3fccf6df67 README.md 2026-08-14 23:43:40 +02:00
stefankoelle 0f16aa45dc mailer weekly notifier 2026-08-11 22:04:11 +02:00
Stefan Koelle 3635820eb8 Merge pull request #8 from skoelle/renovate/major-github-actions
chore(deps): update actions/checkout action to v7
2026-08-10 20:15:09 +02:00
renovate[bot] bc9570d5c2 chore(deps): update actions/checkout action to v7 2026-08-10 18:14:05 +00:00
Stefan Koelle 82185a8290 Merge pull request #7 from skoelle/renovate/requests-2.x
chore(deps): update dependency requests to v2.34.2
2026-08-10 19:39:45 +02:00
renovate[bot] cdc1570fac chore(deps): update dependency requests to v2.34.2 2026-08-10 17:27:10 +00:00
stefankoelle 7c07a8f866 renovate 2026-08-10 19:26:34 +02:00
Stefan Koelle 9bda384559 Merge pull request #6 from skoelle/renovate/recurring-ical-events-3.x
chore(deps): update dependency recurring-ical-events to v3.8.2
2026-08-10 19:26:09 +02:00
renovate[bot] 6c6848bb0d chore(deps): update dependency recurring-ical-events to v3.8.2 2026-08-10 17:25:50 +00:00
Stefan Koelle 0ca20bab27 Merge pull request #3 from skoelle/renovate/jinja2-3.x
chore(deps): update dependency jinja2 to v3.1.6
2026-08-10 19:25:40 +02:00
Stefan Koelle 87353aa362 Merge pull request #5 from skoelle/renovate/mysql-connector-python-9.x
chore(deps): update dependency mysql-connector-python to v9.7.0
2026-08-10 19:25:00 +02:00
Stefan Koelle a5c02cd6e0 Merge pull request #4 from skoelle/renovate/icalendar-6.x
chore(deps): update dependency icalendar to v6.3.2
2026-08-10 19:24:41 +02:00
renovate[bot] 0b1cc9ed45 chore(deps): update dependency mysql-connector-python to v9.7.0 2026-08-10 17:24:26 +00:00
renovate[bot] 9af9b66447 chore(deps): update dependency icalendar to v6.3.2 2026-08-10 17:24:20 +00:00
renovate[bot] 66b9018b16 chore(deps): update dependency jinja2 to v3.1.6 2026-08-10 17:24:16 +00:00
stefankoelle 52de06bbbb screenshot 2026-08-10 17:10:05 +02:00
stefankoelle e10c6fc169 demo showcase README.md 2026-08-10 17:01:27 +02:00
stefankoelle 946c2832ea demo showcase 2026-08-10 16:47:13 +02:00
stefankoelle 7bfd7c0ff3 ui emoji 2026-08-07 20:37:33 +02:00
stefankoelle ac07e8f917 license added 2026-08-04 19:28:48 +02:00
stefankoelle 3b05342409 new optional notify feature 2026-08-04 19:28:44 +02:00
stefankoelle 5343896653 Fix: korrigiere Code-Bugs und Dokumentation
- api/main.py: index() Route übernimmt Request-Objekt korrekt (statt {}),
  calendar_label Query-Parameter implementiert (dynamische WHERE-Klausel)
- docker-compose.yml: DB_BOOTSTRAP/DB_ROOT_USER/DB_ROOT_PASSWORD Variablen
  für calendar-sync Service hinzugefügt (waren dokumentiert, aber nie übergeben)
- .env.example: DB_BOOTSTRAP, API_PORT, TIMEZONE hinzugefügt
- AGENTS.md: Zeilennummer sync.py:91→83, gemischte Deutsch/China-Sprache
  bereinigt
- SPEC.md: --entrypoint→command, API_HOST entfernt (nicht implementiert),
  TIMEZONE hinzugefügt, JSON-Beispiele um timezone-Feld erweitert,
  Docker-Compose-Beispiel und Projektstruktur aktualisiert,
  Search als implementiert markiert
- README.md: TIMEZONE und API_PORT in Konfigtationstabelle,
  timezone im JSON-Beispiel
2026-08-04 19:28:39 +02:00
stefankoelle f717d37d07 fix: add missing timezone field to EventsListResponse API response 2026-08-04 19:28:34 +02:00
stefankoelle 600d8262a8 feat: add TIMEZONE environment variable to docker-compose.yml for API service 2026-08-04 19:28:30 +02:00
stefankoelle a2fa805ce8 fix: display local timezone in API and web frontend instead of GMT
- Add TIMEZONE environment variable support (default: UTC)
- Convert naive UTC datetimes from database to local timezone in API responses
- Add timezone field to EventResponse and EventsListResponse Pydantic models
- Update web template to display timezone information
- Update PLAN.md to document timezone changes

This fixes the issue where events at 8:00 UTC would appear as 6:00 GMT by displaying times in the correct local timezone.
2026-08-04 19:28:21 +02:00
22 changed files with 1013 additions and 116 deletions
+31
View File
@@ -16,3 +16,34 @@ LOG_LEVEL=INFO
# Optional: Healthchecks.io / Uptime Kuma URL (wird nach jedem Sync gepingt)
# HEALTHCHECK_URL=https://hc-ping.com/DEINE_UUID
# Optional: Database Bootstrap (erstellt DB und User beim Start)
# DB_BOOTSTRAP=true
# DB_ROOT_USER=root
# DB_ROOT_PASSWORD=dein_root_passwort
# API-Server Port (für Web-UI + REST API)
API_PORT=8000
# Zeitzone für die API/Web-UI (Default: UTC)
# TIMEZONE=Europe/Berlin
# Optional: Tägliche E-Mail Benachrichtigung um 6 Uhr
# SMTP_HOST=smtp.example.com
# SMTP_PORT=587
# SMTP_USER=user@example.com
# SMTP_PASSWORD=dein_passwort
# SMTP_FROM=user@example.com
# SMTP_USE_TLS=true
# NOTIFY_EMAIL=empfaenger@example.com
# NOTIFY_TIME=6
# NOTIFY_TIMEZONE=Europe/Berlin
# Optional: Wöchentliche Vorab-Info (jeden Freitag)
# WEEKLY_NOTIFY_ENABLED=true
# WEEKLY_NOTIFY_DAY=5
# WEEKLY_NOTIFY_TIME=16
# WEEKLY_NOTIFY_TIMEZONE=Europe/Berlin
# WEEKLY_NOTIFY_EMAIL=empfaenger@example.com
# WEEKLY_SEARCHWORDS=Termin1,Termin2
# WEEKLY_BLACKLISTWORDS=Ausgeschlossen1,Ausgeschlossen2
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Log in to GHCR
uses: docker/login-action@v4
+2
View File
@@ -1,3 +1,5 @@
.env
__pycache__/
*.pyc
.venv/
demo/demo.db
+6
View File
@@ -0,0 +1,6 @@
title: "Google Calender Sync"
emoji: "📅"
category: code
subcategory: "Smart Home Apps"
status: active
stack: [Python, FastAPI, Uvicorn, Jinja2]
+48 -5
View File
@@ -9,7 +9,7 @@ Python-basierter Google Calendar → MariaDB Synchronizer. Läuft als Docker Con
## Technologie-Stack
- **Sprache**: Python 3.12
- **Datenbank**: MariaDB (mysql-connector-python)
- **Datenbank**: MariaDB (mysql-connector-python) oder SQLite (stdlib) via `DB_BACKEND` Env-Var
- **API**: FastAPI + Uvicorn + Jinja2
- **Docker**: Multi-Stage Build nicht verwendet, simples Slim-Image
- **Dependencies**: requests, icalendar, recurring-ical-events, mysql-connector-python, fastapi, uvicorn, jinja2
@@ -21,9 +21,12 @@ Python-basierter Google Calendar → MariaDB Synchronizer. Läuft als Docker Con
├── sync.py # Hauptskript (alles in einer Datei)
├── api/
│ ├── main.py # FastAPI App (REST API + HTML UI)
│ ├── database.py # DB-Verbindung für die API
│ ├── database.py # DB-Verbindung (MySQL + SQLite)
│ └── templates/
│ └── index.html # Jinja2 Template für Web-UI
├── demo/
│ └── seed.py # SQLite Demo-Datenbank erstellen
├── demo.sh # Lokaler Demo-Start (ohne Docker)
├── requirements.txt # Python Dependencies
├── Dockerfile # Docker Image Definition
├── docker-compose.yml # Docker Compose Konfiguration
@@ -48,7 +51,7 @@ Die API (`api/main.py`) ist eine separarte FastAPI-App die als eigenständiger C
### Datenbank-Schema
Schema wird in `ensure_schema()` per `CREATE TABLE IF NOT EXISTS` erstellt. Bei Schema-Änderungen:
- `ensure_schema()` in `sync.py:91` anpassen
- `ensure_schema()` in `sync.py:83` anpassen
- MariaDB-kompatibles SQL verwenden (kein PostgreSQL-Specific)
- Indexe für Performance bedenken
@@ -56,14 +59,34 @@ Schema wird in `ensure_schema()` per `CREATE TABLE IF NOT EXISTS` erstellt. Bei
Verwendet `recurring_ical_events` Bibliothek für RRULE/EXDATE/RECURRENCE-ID Expansion. Fenster wird über `WINDOW_PAST_DAYS`/`WINDOW_FUTURE_DAYS` gesteuert.
### UTC-Normalisierung
Alle Zeiten werden in naive UTC datetime konvertiert (`to_naive_utc()`). Bei Datumsänderungen sicherstellen, dass Zeitzone korrekt处理 wird.
Alle Zeiten werden in naive UTC datetime konvertiert (`to_naive_utc()`). Bei Datumsänderungen sicherstellen, dass die Zeitzone korrekt verarbeitet wird.
### Soft-Delete
Events werden nicht gelöscht, sondern mit `deleted=1` markiert (`mark_missing_as_deleted()`).
### Tägliche E-Mail-Benachrichtigung
Sendet täglich um konfigurierte Uhrzeit eine HTML-Email mit anstehenden Terminen:
- Nur Termine mit Uhrzeit (`all_day=0`), keine Ganztagstermine
- Subject: Bei 1 Termin direkt "Kalender heute: HH:MM - Termin", bei mehreren "Kalender heute: X Termine"
- Tracking via `daily_notification_log` Tabelle (verhindert Doppelversand)
- Konfiguration über SMTP_* und NOTIFY_* Umgebungsvariablen
- Funktionen: `get_today_events()`, `should_notify()`, `send_notification()`, `log_notification()`
### Wöchentliche E-Mail-Benachrichtigung (Vorab-Info)
Sendet wöchentlich (standardmäßig Freitags) eine HTML-Email mit Terminen, die auf Suchbegriffe passen:
- Sucht nach Begriffen in summary, description und location
- Optionale Blacklist: Begriffe die ausgeschlossen werden (z.B. `WEEKLY_BLACKLISTWORDS=Ausgeschlossen1,Ausgeschlossen2`)
- Zeitraum: Samstag bis Freitag der nächsten Woche
- Subject: Bei 1 Termin "Vorab-Info: Termin am Mo, DD.MM.", bei mehreren "Vorab-Info: X Termine nächste Woche"
- Tracking via `weekly_notification_log` Tabelle (verhindert Doppelversand)
- Nur Termine mit Uhrzeit (`all_day=0`), keine Ganztagstermine
- Keine Email wenn keine Treffer
- Konfiguration über WEEKLY_* Umgebungsvariablen
- Funktionen: `parse_weekly_searchwords()`, `parse_weekly_blacklistwords()`, `get_weekly_events()`, `should_send_weekly()`, `send_weekly_notification()`, `log_weekly_notification()`, `check_and_send_weekly_notification()`
## Entwicklung
### Lokaler Test
### Lokaler Test (Docker)
```bash
# Dependencies installieren
pip install -r requirements.txt
@@ -75,6 +98,20 @@ cp .env.example .env
python sync.py
```
### Lokaler Test (Demo, ohne MariaDB)
```bash
# Startet venv, erstellt SQLite Demo-DB mit 4 Events, startet API
./demo.sh
```
Oder manuell:
```bash
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
python demo/seed.py
DB_BACKEND=sqlite DB_PATH=demo/demo.db python -m uvicorn api.main:app --port 8000
```
### Linting & Type Checking
Keine Linting/Type-Checking Tools konfiguriert. Bei Bedarf hinzufügen:
- `ruff` für Linting
@@ -106,3 +143,9 @@ GitHub Actions Workflow:
- Baut Docker Image bei Push zu `main`
- Published nach `ghcr.io/skoelle/calender_sync:latest`
- Keine Tests im Workflow (derzeit)
## Lizenz
MIT License - Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
- Vollständiger Text in `LICENSE`
- Lizenz-Header in allen Python-Dateien
+1
View File
@@ -5,6 +5,7 @@ WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY LICENSE .
COPY sync.py .
COPY api/ ./api/
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+19
View File
@@ -128,6 +128,25 @@ jinja2==3.1.4
## Offene Punkte
- [x] Zeitzonen-Korrektur in API und Web-UI (statt GMT → lokaler Zeitzone)
- [x] Notwendigkeit eines timezone-Feldes in der DB für korrekte Speicherung
- [x] Zeitstempel-Speicherung mit korrekter Zeitzone-Feldunterstützung in DB
- [x] DB Bootstrap - bleibt in sync.py
- [x] Template Styling - einfaches CSS, kein Framework
- [x] Search - optionaler Suchbegriff auf Event-Titel (API + Frontend)
## Richtig gelöst: Keine DB-Zeitzone-Speicherung nötig
Da MySQL/MariaDB naive DATETIME-Werte speichert (ohne Zeitzone), muss die Zeitzone-Zuweisung auf der API/Web-UI-Seite erfolgen. Dies ist korrekt, da:
1. **Datenbank-Speicherung**: MariaDB DATETIME-Spalten können naive Datetimes speichern (alle in einem Standard)
2. **Zeitzone-Wiederherstellung**: Die API/Frontend-Komponenten können naive Datetimes in die korrekte Benutzer-Zeitzone konvertieren, wenn sie benötigt werden
3. **Vereinfachte Architektur**: Keine komplexe DB-Schema-Änderung erforderlich
**Lösung**: Konvertieren Sie naive UTC-Daten aus DB → lokale Zeitzone im API/Web-UI durch Hinzufügen von `timezone`-Feld in Response-Modell.
**Änderungen**:
- Fügen Sie ein `timezone`-Feld zu `EventResponse` und `EventsListResponse` hinzu
- Konvertieren Sie Datetimes in API: `datetime.now(timezone.utc)``to_local_timezone()`
- Passen Sie `row_to_event` an: Konvertieren Sie naive UTC → Benutzer-Zeitzone
- Aktualisieren Sie das Template: Verwenden Sie den Zeitzonennamen für Formatierung
+152 -52
View File
@@ -1,28 +1,32 @@
# calender_sync
# 📅 calender_sync
Google Calendar (ICS-Feed) → MariaDB Sync fürs Homelab.
> Google Calendar (ICS-Feed) → MariaDB Sync fürs Homelab.
Läuft als Docker Container, pollt periodisch einen privaten Google Calendar ICS-Feed, expandiert RRULE/EXDATE/RECURRENCE-ID und schreibt einzelne Instanzen in eine MariaDB-Datenbank.
## Features
## Features
- Automatische Synchronisation alle 15 Minuten (konfigurierbar)
- Expandiert wiederkehrende Events (RRULE) und Serien-Exceptions (EXDATE, RECURRENCE-ID)
- Soft-Delete: Entfernte Events werden als `deleted=1` markiert, nicht gelöscht
- Optionales Database-Bootstrap: Erstellt DB und User automatisch bei `DB_BOOTSTRAP=true`
- Zeitfenster-konfiguration für Vergangenheit/Future (standardmäßig -90 Tage / +365 Tage)
- Web-UI zur Anzeige anstehender Termine mit Suchfunktion
- REST API für programmsprachigen Zugriff auf Kalenderdaten
- 🔄 Automatische Synchronisation alle 15 Minuten (konfigurierbar)
- 📆 Expandiert wiederkehrende Events (RRULE) und Serien-Exceptions (EXDATE, RECURRENCE-ID)
- 🗑️ Soft-Delete: Entfernte Events werden als `deleted=1` markiert, nicht gelöscht
- 🛠️ Optionales Database-Bootstrap: Erstellt DB und User automatisch bei `DB_BOOTSTRAP=true`
- Zeitfenster-konfiguration für Vergangenheit/Future (standardmäßig -90 Tage / +365 Tage)
- 🌐 Web-UI zur Anzeige anstehender Termine mit Suchfunktion
- 🔌 REST API für programmsprachigen Zugriff auf Kalenderdaten
- 📧 Optionale tägliche E-Mail-Benachrichtigung um konfigurierte Uhrzeit
- 📬 Optionale wöchentliche Vorab-Info (z.B. freitags) mit Termine nach Suchbegriffen
## Voraussetzungen
[![UI](docs/screenshot_thumbnail.png)](docs/screenshot.png)
- Docker & Docker Compose
- MariaDB Instanz (z.B. als Proxmox LXC)
- Google Calendar mit privatem ICS-Feed
## 📋 Voraussetzungen
## Quick Start
- 🐳 Docker & Docker Compose
- 🗃️ MariaDB Instanz (z.B. als Proxmox LXC)
- 📱 Google Calendar mit privatem ICS-Feed
1. **MariaDB Setup**
## 🚀 Quick Start
1. **🔧 MariaDB Setup**
Entweder manuell ausführen:
```bash
@@ -36,7 +40,7 @@ Läuft als Docker Container, pollt periodisch einen privaten Google Calendar ICS
DB_ROOT_PASSWORD=dein_root_passwort
```
2. **.env anlegen**
2. **📄 .env anlegen**
```bash
cp .env.example .env
@@ -46,13 +50,38 @@ Läuft als Docker Container, pollt periodisch einen privaten Google Calendar ICS
- `ICS_URL`: Privater ICS-Link aus den Google Calendar Einstellungen
- `DB_PASSWORD`: Sicheres Passwort für den calendar_sync User
3. **Starten**
3. **▶️ Starten**
```bash
docker compose up -d
```
## Konfiguration
## 🎮 Demo / Lokaler Test (ohne Docker)
Schneller Weg um Web-UI und API lokal zu testen, ohne MariaDB oder Docker:
```bash
./demo.sh
```
Das Script erstellt eine virtuelle Python-Umgebung, installiert Dependencies, legt eine SQLite Demo-Datenbank mit 4 Beispiel-Terminen an und startet die API auf Port 8000.
**🔗 Endpoints:**
- 🌐 Web-UI: http://localhost:8000/
- 📊 API JSON: http://localhost:8000/api/events
- ❤️ Health: http://localhost:8000/api/health
**🎯 Demo-Events (4 Stück):**
| Termin | Zeit | Status |
|--------|------|--------|
| 📅 Team Daily | heute 14:0015:00 | ✅ CONFIRMED |
| 🧘 Yoga Kurs | heute 18:3020:00 | ✅ CONFIRMED |
| ⚠️ Projekt-Deadline | morgen Ganztag | 🟡 TENTATIVE |
| 👨‍⚕️ Arzttermin | übermorgen 10:0011:30 | ✅ CONFIRMED |
Die Demo verwendet SQLite (`DB_BACKEND=sqlite`) statt MariaDB. Die Events werden relativ zum heutigen Datum erstellt.
## ⚙️ Konfiguration
| Variable | Default | Beschreibung |
|----------|---------|--------------|
@@ -70,44 +99,113 @@ Läuft als Docker Container, pollt periodisch einen privaten Google Calendar ICS
| `DB_BOOTSTRAP` | `false` | DB + User beim Start erstellen |
| `DB_ROOT_USER` | - | Root-User fürs Bootstrap |
| `DB_ROOT_PASSWORD` | - | Root-Passwort fürs Bootstrap |
| `API_PORT` | `8000` | Port für den API/Web-UI Container |
| `TIMEZONE` | `UTC` | Zeitzone für API/Web-UI Anzeige (z.B. `Europe/Berlin`) |
## Datenbank-Schema
### 📧 Optionale E-Mail-Benachrichtigung
Tabelle `calendar_events`:
Sendet täglich eine HTML-Email mit den anstehenden Terminen. Wird aktiviert wenn `SMTP_HOST` und `NOTIFY_EMAIL` gesetzt sind.
| Variable | Default | Beschreibung |
|----------|---------|--------------|
| `SMTP_HOST` | - | SMTP Server Hostname |
| `SMTP_PORT` | `587` | SMTP Server Port |
| `SMTP_USER` | - | SMTP Login Username |
| `SMTP_PASSWORD` | - | SMTP Login Passwort |
| `SMTP_FROM` | - | Absender E-Mail Adresse |
| `SMTP_USE_TLS` | `true` | TLS verschlüsselung nutzen |
| `NOTIFY_EMAIL` | - | Empfänger E-Mail Adresse |
| `NOTIFY_TIME` | `6` | Uhrzeit für Benachrichtigung (Stunde, 0-23) |
| `NOTIFY_TIMEZONE` | `Europe/Berlin` | Zeitzone für die Benachrichtigung |
**📬 Subject-Logik:**
- 📌 1 Termin: `Kalender heute: 09:00 - Meeting mit Team`
- 📌 2+ Termine: `Kalender heute: 3 Termine`
**⚠️ Hinweis:** Ganztagstermine werden nicht in der Benachrichtigung berücksichtigt.
### 📬 Optionale wöchentliche Vorab-Info
Sendet wöchentlich (standardmäßig freitags) eine HTML-Email mit Terminen, die auf konfigurierte Suchbegriffe passen. Zeitraum ist immer Samstag bis Freitag der nächsten Woche. Wird aktiviert wenn `WEEKLY_NOTIFY_ENABLED=true` und mindestens ein Suchbegriff gesetzt ist.
| Variable | Default | Beschreibung |
|----------|---------|--------------|
| `WEEKLY_NOTIFY_ENABLED` | `false` | Feature aktivieren |
| `WEEKLY_NOTIFY_DAY` | `5` | Wochentag (0=Mo, 1=Di, ..., 5=Fr) |
| `WEEKLY_NOTIFY_TIME` | `16` | Uhrzeit für Benachrichtigung (Stunde, 0-23) |
| `WEEKLY_NOTIFY_TIMEZONE` | `Europe/Berlin` | Zeitzone für die Benachrichtigung |
| `WEEKLY_NOTIFY_EMAIL` | - | Empfänger (Fallback: `NOTIFY_EMAIL`) |
| `WEEKLY_SEARCHWORDS` | - | Komma-separierte Suchbegriffe |
**📬 Subject-Logik:**
- 📌 1 Termin: `Vorab-Info: Termin am Fr, 15.08.`
- 📌 2+ Termine: `Vorab-Info: 3 Termine naechste Woche`
**💡 Beispiel:**
```bash
WEEKLY_NOTIFY_ENABLED=true
WEEKLY_NOTIFY_DAY=5
WEEKLY_NOTIFY_TIME=16
WEEKLY_SEARCHWORDS=Fussball,Arzttermine
```
**⚠️ Hinweis:** Keine Email wenn keine Treffer für die Suchbegriffe im Zeitraum.
## 🗃️ Datenbank-Schema
**Tabelle `calendar_events`:**
| Spalte | Typ | Beschreibung |
|--------|-----|--------------|
| `id` | BIGINT PK | Auto-Increment |
| `calendar_label` | VARCHAR(64) | Kalender-Label |
| `instance_key` | VARCHAR(255) | SHA1-basierte eindeutige Instanz-ID |
| `uid` | VARCHAR(255) | ICS UID |
| `recurrence_id` | VARCHAR(64) | RECURRENCE-ID bei Serien-Exceptions |
| `summary` | VARCHAR(512) | Titel |
| `description` | TEXT | Beschreibung |
| `location` | VARCHAR(512) | Ort |
| `start_at` | DATETIME | Startzeit (UTC) |
| `end_at` | DATETIME | Endzeit (UTC) |
| `all_day` | TINYINT(1) | Ganz-tagig Flag |
| `status` | VARCHAR(32) | Event-Status |
| `deleted` | TINYINT(1) | Soft-Delete Flag |
| `last_seen_at` | DATETIME | Letzte Synchronisation |
| `created_at` | DATETIME | Erstellungszeitpunkt |
| `updated_at` | DATETIME | Letzte Änderung |
| `id` | 🆔 BIGINT PK | Auto-Increment |
| `calendar_label` | 🏷️ VARCHAR(64) | Kalender-Label |
| `instance_key` | 🔑 VARCHAR(255) | SHA1-basierte eindeutige Instanz-ID |
| `uid` | 📌 VARCHAR(255) | ICS UID |
| `recurrence_id` | 🔄 VARCHAR(64) | RECURRENCE-ID bei Serien-Exceptions |
| `summary` | 📝 VARCHAR(512) | Titel |
| `description` | 📄 TEXT | Beschreibung |
| `location` | 📍 VARCHAR(512) | Ort |
| `start_at` | 🚀 DATETIME | Startzeit (UTC) |
| `end_at` | 🏁 DATETIME | Endzeit (UTC) |
| `all_day` | ☀️ TINYINT(1) | Ganz-tagig Flag |
| `status` | 📊 VARCHAR(32) | Event-Status |
| `deleted` | 🗑️ TINYINT(1) | Soft-Delete Flag |
| `last_seen_at` | 👁️ DATETIME | Letzte Synchronisation |
| `created_at` | 🎨 DATETIME | Erstellungszeitpunkt |
| `updated_at` | 🔄 DATETIME | Letzte Änderung |
## Web-UI & API
**Tabelle `daily_notification_log` (optional, für E-Mail-Benachrichtigung):**
| Spalte | Typ | Beschreibung |
|--------|-----|--------------|
| `id` | 🆔 INT PK | Auto-Increment |
| `notify_date` | 📅 DATE | Datum der Benachrichtigung |
| `sent_at` | ⏰ DATETIME | Zeitpunkt des Versands |
| `event_count` | 🔢 INT | Anzahl Termine in der Email |
**Tabelle `weekly_notification_log` (optional, für wöchentliche Vorab-Info):**
| Spalte | Typ | Beschreibung |
|--------|-----|--------------|
| `id` | 🆔 INT PK | Auto-Increment |
| `notify_date` | 📅 DATE | Datum der Benachrichtigung |
| `sent_at` | ⏰ DATETIME | Zeitpunkt des Versands |
| `event_count` | 🔢 INT | Anzahl Termine in der Email |
## 🌐 Web-UI & API
Das Projekt enthält eine FastAPI-basierte Webanwendung die als separater Container (`calendar-api`) läuft und auf Port `8000` erreichbar ist.
### Endpoints
### 🔗 Endpoints
| Endpoint | Beschreibung |
|----------|--------------|
| `GET /` | HTML-Seite mit anstehenden Terminen und Suchfunktion |
| `GET /api/health` | Health Check (gibt `{"status": "ok"}` zurück) |
| `GET /api/events?limit=10&search=...` | JSON-Liste zukünftiger Events (nicht gelöscht) |
| `GET /api/events/{event_id}` | Einzelnes Event als JSON |
| `GET /` | 🌐 HTML-Seite mit anstehenden Terminen und Suchfunktion |
| `GET /api/health` | ❤️ Health Check (gibt `{"status": "ok"}` zurück) |
| `GET /api/events?limit=10&search=...` | 📊 JSON-Liste zukünftiger Events (nicht gelöscht) |
| `GET /api/events/{event_id}` | 🔍 Einzelnes Event als JSON |
### API Beispiel
### 💡 API Beispiel
```bash
# Alle anstehenden Events (max. 10)
@@ -120,7 +218,7 @@ curl "http://localhost:8000/api/events?search=Meeting&limit=5"
curl http://localhost:8000/api/events/42
```
### JSON Response Format
### 📋 JSON Response Format
```json
{
@@ -132,16 +230,18 @@ curl http://localhost:8000/api/events/42
"location": "Konferenzraum 1",
"start_at": "2025-01-15T10:00:00",
"end_at": "2025-01-15T11:00:00",
"all_day": false,
"status": "CONFIRMED"
}
"all_day": false,
"status": "CONFIRMED",
"timezone": "Europe/Berlin"
}
],
"count": 1,
"query_time": "2025-01-15T09:30:00Z"
"query_time": "2025-01-15T09:30:00Z",
"timezone": "Europe/Berlin"
}
```
## CI/CD
## 🔄 CI/CD
GitHub Actions Workflow (`docker-publish.yml`) baut und published das Docker Image automatisch nach GitHub Container Registry:
@@ -149,6 +249,6 @@ GitHub Actions Workflow (`docker-publish.yml`) baut und published das Docker Ima
ghcr.io/skoelle/calender_sync:latest
```
## Lizenz
## 📜 Lizenz
Keine Lizenz angegeben.
Lizenziert unter der [MIT License](LICENSE) - Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
+17 -9
View File
@@ -24,7 +24,7 @@ Python-basiertes System zur Synchronisation eines Google Calendar ICS-Feeds nach
└─────────────────┘
```
**Entscheidung:** Gleicher Docker Build (ein Dockerfile), zwei verschiedene Container/Services via `docker-compose.yml`. Das Image wird mit einem `--entrypoint` Parameter gesteuert.
**Entscheidung:** Gleicher Docker Build (ein Dockerfile), zwei verschiedene Container/Services via `docker-compose.yml`. Der jeweilige Service wird via `command` Parameter gesteuert (`python sync.py` vs. `uvicorn api.main:app`).
## 3. Bestehendes System (Sync Tool)
@@ -89,12 +89,14 @@ Gibt die nächsten N Termine zurück.
"location": "Raum 101",
"start_at": "2025-01-15T10:00:00",
"end_at": "2025-01-15T11:00:00",
"all_day": false,
"status": "CONFIRMED"
}
],
"all_day": false,
"status": "CONFIRMED",
"timezone": "Europe/Berlin"
}
],
"count": 10,
"query_time": "2025-01-14T14:30:00Z"
"query_time": "2025-01-14T14:30:00Z",
"timezone": "Europe/Berlin"
}
```
@@ -123,12 +125,12 @@ Healthcheck Endpoint für den API Container.
### 4.3 Technologie-Stack (API)
- **Framework:** FastAPI
- **Templating:** Jinja2 (server-side rendering)
- **DB-Zugriff:** mysql-connector-python (gleicher Connection-Pool wie Sync)
- **DB-Zugriff:** mysql-connector-python (shared `get_connection()` aus `api/database.py`)
- **Port:** 8000 (konfigurierbar via `API_PORT`)
### 4.4 Additional Environment Variablen (API)
- `API_PORT` - Port für den API Server (default: 8000)
- `API_HOST` - Bind Address (default: 0.0.0.0)
- `TIMEZONE` - Zeitzone für die Anzeige von Zeiten (default: UTC)
- `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` - Identisch zum Sync
## 5. Docker Setup
@@ -174,6 +176,9 @@ services:
- WINDOW_FUTURE_DAYS=${WINDOW_FUTURE_DAYS:-365}
- LOG_LEVEL=${LOG_LEVEL:-INFO}
- HEALTHCHECK_URL=${HEALTHCHECK_URL:-}
- DB_BOOTSTRAP=${DB_BOOTSTRAP:-false}
- DB_ROOT_USER=${DB_ROOT_USER:-}
- DB_ROOT_PASSWORD=${DB_ROOT_PASSWORD:-}
networks:
- docker-backend
@@ -191,6 +196,7 @@ services:
- DB_USER=${DB_USER}
- DB_PASSWORD=${DB_PASSWORD}
- LOG_LEVEL=${LOG_LEVEL:-INFO}
- TIMEZONE=${TIMEZONE:-UTC}
labels:
- "com.centurylinklabs.watchtower.enable=true"
networks:
@@ -218,6 +224,8 @@ networks:
├── mariadb-setup.sql # Manuelles DB-Setup Script
├── .env.example # Beispiel-Umgebungsvariablen (erweitert)
├── SPEC.md # Diese Spezifikation
├── PLAN.md # Implementierungsplan
├── AGENTS.md # Richtlinien für AI-Agenten
└── .github/workflows/ # CI/CD (Docker Build + Push)
```
@@ -269,4 +277,4 @@ Bestehender GitHub Actions Workflow erweitern:
## 11. Future Enhancements (nicht im Scope)
- [ ] Kalender-Filter UI (nach calendar_label)
- [ ] Suchfunktion nach Event-Titel
- [x] Suchfunktion nach Event-Titel (implementiert)
+2
View File
@@ -0,0 +1,2 @@
# Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
# Licensed under the MIT License. See LICENSE file in project root for details.
+33 -15
View File
@@ -1,19 +1,37 @@
# Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
# Licensed under the MIT License. See LICENSE file in project root for details.
import os
import mysql.connector
import sqlite3
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"]
DB_BACKEND = os.environ.get("DB_BACKEND", "mysql")
if DB_BACKEND == "sqlite":
DB_PATH = os.environ.get("DB_PATH", "demo.db")
def get_connection(autocommit: bool = True):
return mysql.connector.connect(
host=DB_HOST,
port=DB_PORT,
user=DB_USER,
password=DB_PASSWORD,
database=DB_NAME,
autocommit=autocommit,
)
def get_connection(autocommit: bool = True):
conn = sqlite3.connect(DB_PATH)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
conn.row_factory = sqlite3.Row
if autocommit:
conn.isolation_level = None
return conn
else:
import mysql.connector
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"]
def get_connection(autocommit: bool = True):
return mysql.connector.connect(
host=DB_HOST,
port=DB_PORT,
user=DB_USER,
password=DB_PASSWORD,
database=DB_NAME,
autocommit=autocommit,
)
+65 -23
View File
@@ -1,22 +1,55 @@
# Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
# Licensed under the MIT License. See LICENSE file in project root for details.
import logging
import os
from datetime import datetime, timezone
from pathlib import Path
from zoneinfo import ZoneInfo
from fastapi import FastAPI, HTTPException, Query
from fastapi import FastAPI, HTTPException, Query, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel
from api.database import get_connection
from api.database import get_connection, DB_BACKEND
log = logging.getLogger("calendar-api")
PH = "?" if DB_BACKEND == "sqlite" else "%s"
NOW_SQL = "datetime('now')" if DB_BACKEND == "sqlite" else "NOW()"
app = FastAPI(title="Calendar Sync API")
templates = Jinja2Templates(directory=Path(__file__).parent / "templates")
SELECT_COLUMNS = "id, summary, description, location, start_at, end_at, all_day, status"
# Get timezone from environment, default to UTC
TIMEZONE_NAME = os.environ.get("TIMEZONE", "UTC")
try:
TIMEZONE = ZoneInfo(TIMEZONE_NAME)
except Exception:
log.warning(f"Invalid TIMEZONE '{TIMEZONE_NAME}', falling back to UTC")
TIMEZONE = ZoneInfo("UTC")
def to_local_timezone(dt: datetime) -> datetime:
"""Konvertiere naive UTC datetime zu Benutzer-Zeitzone."""
if dt is None:
return None
if isinstance(dt, str):
dt = datetime.fromisoformat(dt)
return dt.replace(tzinfo=timezone.utc).astimezone(TIMEZONE)
def to_iso_local(dt: datetime) -> str:
"""Konvertiere naive UTC datetime zu ISO-String in lokaler Zeitzone."""
if dt is None:
return None
local_dt = to_local_timezone(dt)
return local_dt.isoformat()
class EventResponse(BaseModel):
id: int
@@ -25,6 +58,7 @@ class EventResponse(BaseModel):
location: str | None
start_at: str
end_at: str | None
timezone: str
all_day: bool
status: str
@@ -33,6 +67,7 @@ class EventsListResponse(BaseModel):
events: list[EventResponse]
count: int
query_time: str
timezone: str
def row_to_event(row) -> EventResponse:
@@ -41,32 +76,34 @@ def row_to_event(row) -> EventResponse:
summary=row[1],
description=row[2],
location=row[3],
start_at=row[4].isoformat() if row[4] else None,
end_at=row[5].isoformat() if row[5] else None,
start_at=to_iso_local(row[4]) if row[4] else None,
end_at=to_iso_local(row[5]) if row[5] else None,
timezone=TIMEZONE_NAME,
all_day=bool(row[6]),
status=row[7],
)
def fetch_events(limit: int = 10, search: str | None = None) -> list[dict]:
def fetch_events(limit: int = 10, search: str | None = None, calendar_label: str | None = None) -> list[dict]:
conn = get_connection()
try:
cur = conn.cursor()
try:
conditions = [f"deleted = 0", f"start_at >= {NOW_SQL}"]
params: list = []
if search:
cur.execute(
f"SELECT {SELECT_COLUMNS} FROM calendar_events "
"WHERE deleted = 0 AND start_at >= NOW() AND summary LIKE %s "
"ORDER BY start_at ASC LIMIT %s",
(f"%{search}%", limit),
)
else:
cur.execute(
f"SELECT {SELECT_COLUMNS} FROM calendar_events "
"WHERE deleted = 0 AND start_at >= NOW() "
"ORDER BY start_at ASC LIMIT %s",
(limit,),
)
conditions.append(f"summary LIKE {PH}")
params.append(f"%{search}%")
if calendar_label:
conditions.append(f"calendar_label = {PH}")
params.append(calendar_label)
params.append(limit)
where = " WHERE " + " AND ".join(conditions)
cur.execute(
f"SELECT {SELECT_COLUMNS} FROM calendar_events "
f"{where} ORDER BY start_at ASC LIMIT {PH}",
tuple(params),
)
return cur.fetchall()
finally:
cur.close()
@@ -83,9 +120,10 @@ def health():
def get_events(
limit: int = Query(default=10, ge=1, le=50),
search: str | None = Query(default=None),
calendar_label: str | None = Query(default=None),
):
try:
rows = fetch_events(limit=limit, search=search)
rows = fetch_events(limit=limit, search=search, calendar_label=calendar_label)
except Exception:
log.exception("DB-Fehler bei /api/events")
raise HTTPException(status_code=500, detail="Database error")
@@ -96,6 +134,7 @@ def get_events(
events=events,
count=len(events),
query_time=datetime.now(timezone.utc).replace(tzinfo=None).isoformat() + "Z",
timezone=TIMEZONE_NAME, # <--- Add this line
)
@@ -107,7 +146,7 @@ def get_event(event_id: int):
try:
cur.execute(
f"SELECT {SELECT_COLUMNS} FROM calendar_events "
"WHERE id = %s AND deleted = 0",
f"WHERE id = {PH} AND deleted = 0",
(event_id,),
)
row = cur.fetchone()
@@ -127,6 +166,7 @@ def get_event(event_id: int):
@app.get("/", response_class=HTMLResponse)
def index(
request: Request,
search: str | None = Query(default=None),
limit: int = Query(default=10, ge=1, le=50),
):
@@ -143,13 +183,15 @@ def index(
"summary": row[1],
"description": row[2],
"location": row[3],
"start_at": row[4],
"end_at": row[5],
"start_at": to_iso_local(row[4]) if row[4] else None,
"end_at": to_iso_local(row[5]) if row[5] else None,
"timezone": TIMEZONE_NAME,
"all_day": bool(row[6]),
"status": row[7],
})
return templates.TemplateResponse(
request,
"index.html",
{"request": {}, "events": events, "search": search or ""},
{"events": events, "search": search or ""},
)
+4 -4
View File
@@ -163,7 +163,7 @@
</head>
<body>
<div class="container">
<h1>Termine</h1>
<h1>📆️ Termine</h1>
<form class="search-form" method="get" action="/">
<input type="text" name="search" value="{{ search }}" placeholder="Suche nach Titel...">
@@ -181,11 +181,11 @@
<div>
<div class="event-date">
{% if event.all_day %}
{{ event.start_at.strftime('%d.%m.%Y') }} <span class="all-day">Ganztägig</span>
{{ event.start_at.split('T')[0] }} <span class="all-day">Ganztägig</span>
{% elif event.end_at %}
{{ event.start_at.strftime('%d.%m.%Y %H:%M') }} - {{ event.end_at.strftime('%H:%M') }}
{{ event.start_at.split('T')[0] }} {{ event.start_at.split('T')[1][:5] }} - {{ event.end_at.split('T')[1][:5] }} {{ event.timezone }}
{% else %}
{{ event.start_at.strftime('%d.%m.%Y %H:%M') }}
{{ event.start_at.split('T')[0] }} {{ event.start_at.split('T')[1][:5] }} {{ event.timezone }}
{% endif %}
</div>
<div class="event-summary">{{ event.summary or '(Kein Titel)' }}</div>
Executable
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
VENV_DIR="$SCRIPT_DIR/.venv"
DB_PATH="$SCRIPT_DIR/demo/demo.db"
echo "=== Calendar Sync Demo ==="
# Venv erstellen
if [ ! -d "$VENV_DIR" ]; then
echo "Erstelle virtuelles Umfeld..."
python3 -m venv "$VENV_DIR"
fi
source "$VENV_DIR/bin/activate"
# Dependencies installieren (ohne mysql-connector)
pip install -q --upgrade pip
pip install -q fastapi uvicorn[standard] jinja2 icalendar recurring-ical-events
# Demo-Datenbank erstellen
echo "Erstelle Demo-Datenbank mit 4 Terminen..."
python "$SCRIPT_DIR/demo/seed.py"
# API starten
echo ""
echo "Starte API auf http://localhost:8000"
echo " Web-UI: http://localhost:8000/"
echo " API: http://localhost:8000/api/events"
echo " Health: http://localhost:8000/api/health"
echo ""
echo "Druecke Ctrl+C zum Beenden."
echo ""
cd "$SCRIPT_DIR"
DB_BACKEND=sqlite DB_PATH="$DB_PATH" \
exec python -m uvicorn api.main:app --host 0.0.0.0 --port 8000
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env python3
"""Erstellt eine lokale SQLite Demo-Datenbank mit 4 Beispiel-Terminen."""
import os
import sqlite3
from datetime import datetime, timedelta
DB_PATH = os.environ.get("DB_PATH", os.path.join(os.path.dirname(__file__), "demo.db"))
SCHEMA = """
CREATE TABLE IF NOT EXISTS calendar_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
calendar_label TEXT NOT NULL,
instance_key TEXT NOT NULL,
uid TEXT NOT NULL,
recurrence_id TEXT,
summary TEXT,
description TEXT,
location TEXT,
start_at TEXT NOT NULL,
end_at TEXT,
all_day INTEGER NOT NULL DEFAULT 0,
status TEXT DEFAULT 'CONFIRMED',
deleted INTEGER NOT NULL DEFAULT 0,
last_seen_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (calendar_label, instance_key)
);
CREATE INDEX IF NOT EXISTS idx_start ON calendar_events(start_at);
CREATE INDEX IF NOT EXISTS idx_deleted ON calendar_events(deleted);
"""
def seed():
if os.path.exists(DB_PATH):
os.remove(DB_PATH)
conn = sqlite3.connect(DB_PATH)
conn.executescript(SCHEMA)
now = datetime.now().replace(second=0, microsecond=0, tzinfo=None)
today = now.replace(hour=0, minute=0, second=0)
# Wenn es nach 14 Uhr ist, alle Termine einen Tag verschieben
# damit sie in der Demo immer sichtbar sind
day_offset = 1 if now.hour >= 14 else 0
base = today + timedelta(days=day_offset)
events = [
{
"calendar_label": "demo",
"instance_key": f"team-daily-{base.strftime('%Y%m%d')}",
"uid": "team-daily-001@demo",
"summary": "Team Daily",
"description": "Tägliches Standup-Meeting mit dem gesamten Team.",
"location": "Besprechungsraum A / Zoom",
"start_at": (base + timedelta(hours=14)).strftime("%Y-%m-%d %H:%M:%S"),
"end_at": (base + timedelta(hours=15)).strftime("%Y-%m-%d %H:%M:%S"),
"all_day": 0,
"status": "CONFIRMED",
},
{
"calendar_label": "demo",
"instance_key": f"yoga-{base.strftime('%Y%m%d')}",
"uid": "yoga-002@demo",
"summary": "Yoga Kurs",
"description": "Wöchentlicher Yoga-Kurs in der Volkshochschule.",
"location": "Volkshochschule, Raum 204",
"start_at": (base + timedelta(hours=18, minutes=30)).strftime("%Y-%m-%d %H:%M:%S"),
"end_at": (base + timedelta(hours=20)).strftime("%Y-%m-%d %H:%M:%S"),
"all_day": 0,
"status": "CONFIRMED",
},
{
"calendar_label": "demo",
"instance_key": f"deadline-{(base + timedelta(days=1)).strftime('%Y%m%d')}",
"uid": "deadline-003@demo",
"summary": "Projekt-Deadline",
"description": "Abgabe des Projektberichts an die Geschäftsleitung.",
"location": "",
"start_at": (base + timedelta(days=1)).strftime("%Y-%m-%d 00:00:00"),
"end_at": (base + timedelta(days=1)).strftime("%Y-%m-%d 23:59:59"),
"all_day": 1,
"status": "TENTATIVE",
},
{
"calendar_label": "demo",
"instance_key": f"arzt-{(base + timedelta(days=2)).strftime('%Y%m%d')}",
"uid": "arzt-004@demo",
"summary": "Arzttermin",
"description": "Routineuntersuchung beim Hausarzt. Bitte Impfpass mitbringen.",
"location": "Dr. Müller, Gesundheitsstraße 12",
"start_at": (base + timedelta(days=2, hours=10)).strftime("%Y-%m-%d %H:%M:%S"),
"end_at": (base + timedelta(days=2, hours=11, minutes=30)).strftime("%Y-%m-%d %H:%M:%S"),
"all_day": 0,
"status": "CONFIRMED",
},
]
conn.executemany(
"""INSERT INTO calendar_events
(calendar_label, instance_key, uid, summary, description, location,
start_at, end_at, all_day, status, last_seen_at)
VALUES
(:calendar_label, :instance_key, :uid, :summary, :description, :location,
:start_at, :end_at, :all_day, :status, datetime('now'))""",
events,
)
conn.commit()
conn.close()
print(f"Demo-Datenbank erstellt: {DB_PATH}")
print(f"4 Demo-Events eingefuegt.")
if __name__ == "__main__":
seed()
+23 -1
View File
@@ -22,6 +22,27 @@ services:
- WINDOW_FUTURE_DAYS=${WINDOW_FUTURE_DAYS:-365}
- LOG_LEVEL=${LOG_LEVEL:-INFO}
- HEALTHCHECK_URL=${HEALTHCHECK_URL:-}
- DB_BOOTSTRAP=${DB_BOOTSTRAP:-false}
- DB_ROOT_USER=${DB_ROOT_USER:-}
- DB_ROOT_PASSWORD=${DB_ROOT_PASSWORD:-}
- SMTP_HOST=${SMTP_HOST:-}
- SMTP_PORT=${SMTP_PORT:-587}
- SMTP_USER=${SMTP_USER:-}
- SMTP_PASSWORD=${SMTP_PASSWORD:-}
- SMTP_FROM=${SMTP_FROM:-}
- SMTP_USE_TLS=${SMTP_USE_TLS:-true}
- NOTIFY_EMAIL=${NOTIFY_EMAIL:-}
- NOTIFY_TIME=${NOTIFY_TIME:-6}
- NOTIFY_TIMEZONE=${NOTIFY_TIMEZONE:-Europe/Berlin}
- WEEKLY_NOTIFY_ENABLED=${WEEKLY_NOTIFY_ENABLED:-false}
- WEEKLY_NOTIFY_DAY=${WEEKLY_NOTIFY_DAY:-5}
- WEEKLY_NOTIFY_TIME=${WEEKLY_NOTIFY_TIME:-16}
- WEEKLY_NOTIFY_TIMEZONE=${WEEKLY_NOTIFY_TIMEZONE:-Europe/Berlin}
- WEEKLY_NOTIFY_EMAIL=${WEEKLY_NOTIFY_EMAIL:-}
- WEEKLY_SEARCHWORDS=${WEEKLY_SEARCHWORDS:-}
- WEEKLY_BLACKLISTWORDS=${WEEKLY_BLACKLISTWORDS:-}
networks:
- docker-backend
@@ -33,7 +54,7 @@ services:
command: ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000"]
ports:
- "8000:8000"
- "${API_PORT:-8000}:8000"
environment:
- DB_HOST=${DB_HOST:-mariadb.fritz.box}
@@ -42,6 +63,7 @@ services:
- DB_USER=${DB_USER}
- DB_PASSWORD=${DB_PASSWORD}
- LOG_LEVEL=${LOG_LEVEL:-INFO}
- TIMEZONE=${TIMEZONE:-Europe/Berlin} # <--- Add this line
labels:
- "com.centurylinklabs.watchtower.enable=true"
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

+35
View File
@@ -0,0 +1,35 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended"
],
"packageRules": [
{
"matchUpdateTypes": ["minor", "patch", "pin", "digest"],
"automerge": true
},
{
"matchManagers": ["github-actions"],
"groupName": "GitHub Actions",
"automerge": true
},
{
"matchManagers": ["dockerfile", "docker-compose"],
"groupName": "Docker",
"automerge": false
},
{
"matchPackageNames": ["icalendar"],
"matchUpdateTypes": ["major"],
"automerge": false,
"labels": ["major-update"]
},
{
"matchPackageNames": ["mysql-connector-python"],
"matchUpdateTypes": ["major"],
"automerge": false,
"labels": ["major-update"]
}
],
"schedule": ["before 6am on Monday"]
}
+5 -5
View File
@@ -1,7 +1,7 @@
requests==2.32.3
icalendar==6.1.0
recurring-ical-events==3.4.1
mysql-connector-python==9.1.0
requests==2.34.2
icalendar==6.3.2
recurring-ical-events==3.8.2
mysql-connector-python==9.7.0
fastapi==0.115.0
uvicorn[standard]==0.30.0
jinja2==3.1.4
jinja2==3.1.6
+393 -1
View File
@@ -1,4 +1,6 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
# Licensed under the MIT License. See LICENSE file in project root for details.
"""
Google Calendar (privater ICS-Feed) -> MariaDB Sync
Homelab: laeuft als Docker Container, pollt periodisch, expandiert RRULE/EXDATE/RECURRENCE-ID
@@ -10,7 +12,11 @@ import sys
import time
import logging
import hashlib
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
import requests
import icalendar
@@ -45,6 +51,24 @@ 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")
SMTP_HOST = os.environ.get("SMTP_HOST", "")
SMTP_PORT = int(os.environ.get("SMTP_PORT", "587"))
SMTP_USER = os.environ.get("SMTP_USER", "")
SMTP_PASSWORD = os.environ.get("SMTP_PASSWORD", "")
SMTP_FROM = os.environ.get("SMTP_FROM", "")
SMTP_USE_TLS = os.environ.get("SMTP_USE_TLS", "true").lower() == "true"
NOTIFY_EMAIL = os.environ.get("NOTIFY_EMAIL", "")
NOTIFY_TIME = int(os.environ.get("NOTIFY_TIME", "6"))
NOTIFY_TIMEZONE = os.environ.get("NOTIFY_TIMEZONE", "Europe/Berlin")
WEEKLY_NOTIFY_ENABLED = os.environ.get("WEEKLY_NOTIFY_ENABLED", "false").lower() == "true"
WEEKLY_NOTIFY_DAY = int(os.environ.get("WEEKLY_NOTIFY_DAY", "5"))
WEEKLY_NOTIFY_TIME = int(os.environ.get("WEEKLY_NOTIFY_TIME", "16"))
WEEKLY_NOTIFY_TIMEZONE = os.environ.get("WEEKLY_NOTIFY_TIMEZONE", "Europe/Berlin")
WEEKLY_NOTIFY_EMAIL = os.environ.get("WEEKLY_NOTIFY_EMAIL", "")
WEEKLY_SEARCHWORDS = os.environ.get("WEEKLY_SEARCHWORDS", "")
WEEKLY_BLACKLISTWORDS = os.environ.get("WEEKLY_BLACKLISTWORDS", "")
def bootstrap_database():
"""Legt DB_NAME und DB_USER an, falls sie noch nicht existieren.
@@ -106,6 +130,24 @@ def ensure_schema(conn):
INDEX idx_deleted (deleted)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
""")
cur.execute("""
CREATE TABLE IF NOT EXISTS daily_notification_log (
id INT AUTO_INCREMENT PRIMARY KEY,
notify_date DATE NOT NULL,
sent_at DATETIME NOT NULL,
event_count INT NOT NULL,
UNIQUE KEY uk_date (notify_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
""")
cur.execute("""
CREATE TABLE IF NOT EXISTS weekly_notification_log (
id INT AUTO_INCREMENT PRIMARY KEY,
notify_date DATE NOT NULL,
sent_at DATETIME NOT NULL,
event_count INT NOT NULL,
UNIQUE KEY uk_date (notify_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
""")
conn.commit()
cur.close()
@@ -198,6 +240,137 @@ def mark_missing_as_deleted(cur, calendar_label, run_ts, window_start, window_en
return cur.rowcount
def get_today_events(conn, calendar_label):
tz = ZoneInfo(NOTIFY_TIMEZONE)
now = datetime.now(tz)
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
today_end = today_start + timedelta(days=1)
today_start_naive = today_start.replace(tzinfo=None)
today_end_naive = today_end.replace(tzinfo=None)
cur = conn.cursor(dictionary=True)
cur.execute(
"""
SELECT summary, start_at, end_at, location
FROM calendar_events
WHERE calendar_label = %s
AND deleted = 0
AND all_day = 0
AND start_at >= %s
AND start_at < %s
ORDER BY start_at ASC
""",
(calendar_label, today_start_naive, today_end_naive),
)
events = cur.fetchall()
cur.close()
return events
def should_notify(conn):
tz = ZoneInfo(NOTIFY_TIMEZONE)
now = datetime.now(tz)
if now.hour != NOTIFY_TIME:
return False
cur = conn.cursor()
cur.execute(
"SELECT COUNT(*) FROM daily_notification_log WHERE notify_date = CURDATE()"
)
count = cur.fetchone()[0]
cur.close()
return count == 0
def log_notification(conn, event_count):
cur = conn.cursor()
cur.execute(
"INSERT INTO daily_notification_log (notify_date, sent_at, event_count) VALUES (CURDATE(), NOW(), %s)",
(event_count,),
)
conn.commit()
cur.close()
def format_event_time(start_at, end_at):
tz = ZoneInfo(NOTIFY_TIMEZONE)
start_local = start_at.replace(tzinfo=timezone.utc).astimezone(tz)
start_str = start_local.strftime("%H:%M")
if end_at:
end_local = end_at.replace(tzinfo=timezone.utc).astimezone(tz)
end_str = end_local.strftime("%H:%M")
return f"{start_str} - {end_str}"
return start_str
def send_notification(events):
if not SMTP_HOST or not NOTIFY_EMAIL:
log.warning("SMTP-Konfiguration unvollstaendig, ueberspringe Benachrichtigung")
return
tz = ZoneInfo(NOTIFY_TIMEZONE)
now = datetime.now(tz)
date_str = now.strftime("%d.%m.%Y")
count = len(events)
if count == 1:
event = events[0]
time_str = format_event_time(event["start_at"], event.get("end_at"))
subject = f"Kalender heute: {time_str} - {event['summary']}"
else:
subject = f"Kalender heute: {count} Termine"
event_rows = ""
for event in events:
time_str = format_event_time(event["start_at"], event.get("end_at"))
summary = event["summary"]
location = f" ({event['location']})" if event.get("location") else ""
event_rows += f"""
<tr>
<td style="padding: 8px 12px; border-bottom: 1px solid #eee; font-weight: bold; white-space: nowrap;">{time_str}</td>
<td style="padding: 8px 12px; border-bottom: 1px solid #eee;">{summary}{location}</td>
</tr>"""
html = f"""
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #333;">Guten Morgen!</h2>
<p style="color: #555;">Heute, <strong>{date_str}</strong>, stehen folgende Termine an:</p>
<table style="width: 100%; border-collapse: collapse; margin: 20px 0; background: #f9f9f9; border-radius: 8px; overflow: hidden;">
{event_rows}
</table>
<p style="color: #888; font-size: 12px;">Viel Erfolg heute!</p>
</body>
</html>
"""
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = SMTP_FROM
msg["To"] = NOTIFY_EMAIL
msg.attach(MIMEText(html, "html", "utf-8"))
try:
if SMTP_USE_TLS:
server = smtplib.SMTP(SMTP_HOST, SMTP_PORT)
server.starttls()
else:
server = smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT)
if SMTP_USER and SMTP_PASSWORD:
server.login(SMTP_USER, SMTP_PASSWORD)
server.sendmail(SMTP_FROM, [NOTIFY_EMAIL], msg.as_string())
server.quit()
log.info("Benachrichtigung gesendet: %s", subject)
except Exception:
log.exception("Fehler beim Senden der Benachrichtigung")
def ping_healthcheck():
if not HEALTHCHECK_URL:
return
@@ -238,15 +411,234 @@ def run_sync_once():
conn.close()
def check_and_send_notification():
if not SMTP_HOST or not NOTIFY_EMAIL:
return
conn = get_connection(autocommit=True)
try:
if should_notify(conn):
events = get_today_events(conn, CALENDAR_LABEL)
if events:
send_notification(events)
log_notification(conn, len(events))
log.info("Tagesbenachrichtigung fuer %d Events gesendet", len(events))
else:
log.info("Keine Termine heute, Benachrichtigung wird uebersprungen")
except Exception:
log.exception("Fehler bei der Tagesbenachrichtigung")
finally:
conn.close()
def parse_weekly_searchwords():
if not WEEKLY_SEARCHWORDS:
return []
return [w.strip() for w in WEEKLY_SEARCHWORDS.split(",") if w.strip()]
def parse_weekly_blacklistwords():
if not WEEKLY_BLACKLISTWORDS:
return []
return [w.strip() for w in WEEKLY_BLACKLISTWORDS.split(",") if w.strip()]
def get_weekly_events(conn, calendar_label, searchwords, blacklistwords=None):
tz = ZoneInfo(WEEKLY_NOTIFY_TIMEZONE)
now = datetime.now(tz)
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
days_until_saturday = (5 - today.weekday()) % 7
if days_until_saturday == 0:
days_until_saturday = 7
week_start = today + timedelta(days=days_until_saturday)
week_end = week_start + timedelta(days=13, hours=23, minutes=59, seconds=59)
week_start_naive = week_start.replace(tzinfo=None)
week_end_naive = week_end.replace(tzinfo=None)
search_clauses = []
search_params = []
for word in searchwords:
like = f"%{word}%"
search_clauses.append("(summary LIKE %s OR description LIKE %s OR location LIKE %s)")
search_params.extend([like, like, like])
where_search = " OR ".join(search_clauses) if search_clauses else "1=1"
blacklist_clauses = []
blacklist_params = []
for word in (blacklistwords or []):
like = f"%{word}%"
blacklist_clauses.append("(summary LIKE %s OR description LIKE %s OR location LIKE %s)")
blacklist_params.extend([like, like, like])
where_blacklist = ""
if blacklist_clauses:
where_blacklist = f"AND NOT ({' OR '.join(blacklist_clauses)})"
cur = conn.cursor(dictionary=True)
cur.execute(
f"""
SELECT summary, start_at, end_at, location
FROM calendar_events
WHERE calendar_label = %s
AND deleted = 0
AND all_day = 0
AND start_at >= %s
AND start_at <= %s
AND ({where_search})
{where_blacklist}
ORDER BY start_at ASC
""",
(calendar_label, week_start_naive, week_end_naive, *search_params, *blacklist_params),
)
events = cur.fetchall()
cur.close()
return events, week_start, week_end
def should_send_weekly(conn):
if not WEEKLY_NOTIFY_ENABLED:
return False
if not parse_weekly_searchwords():
return False
tz = ZoneInfo(WEEKLY_NOTIFY_TIMEZONE)
now = datetime.now(tz)
if now.weekday() != WEEKLY_NOTIFY_DAY or now.hour != WEEKLY_NOTIFY_TIME:
return False
cur = conn.cursor()
cur.execute(
"SELECT COUNT(*) FROM weekly_notification_log WHERE notify_date = CURDATE()"
)
count = cur.fetchone()[0]
cur.close()
return count == 0
def log_weekly_notification(conn, event_count):
cur = conn.cursor()
cur.execute(
"INSERT INTO weekly_notification_log (notify_date, sent_at, event_count) VALUES (CURDATE(), NOW(), %s)",
(event_count,),
)
conn.commit()
cur.close()
def send_weekly_notification(events, searchwords, week_start, week_end, blacklistwords=None):
email = WEEKLY_NOTIFY_EMAIL or NOTIFY_EMAIL
if not SMTP_HOST or not email:
log.warning("SMTP-Konfiguration unvollstaendig, ueberspringe Wochenbenachrichtigung")
return
tz = ZoneInfo(WEEKLY_NOTIFY_TIMEZONE)
count = len(events)
start_str = week_start.strftime("%a, %d.%m.")
end_str = week_end.strftime("%a, %d.%m.")
if count == 1:
event = events[0]
event_date = event["start_at"].replace(tzinfo=timezone.utc).astimezone(tz).strftime("%a, %d.%m.")
subject = f"Vorab-Info: Termin am {event_date}"
else:
subject = f"Vorab-Info: {count} Termine in 2 Wochen"
event_rows = ""
for event in events:
time_str = format_event_time(event["start_at"], event.get("end_at"))
event_date = event["start_at"].replace(tzinfo=timezone.utc).astimezone(tz).strftime("%a, %d.%m.")
summary = event["summary"]
location = f" ({event['location']})" if event.get("location") else ""
event_rows += f"""
<tr>
<td style="padding: 8px 12px; border-bottom: 1px solid #eee; font-weight: bold; white-space: nowrap;">{event_date}</td>
<td style="padding: 8px 12px; border-bottom: 1px solid #eee; white-space: nowrap;">{time_str}</td>
<td style="padding: 8px 12px; border-bottom: 1px solid #eee;">{summary}{location}</td>
</tr>"""
words_display = ", ".join(searchwords)
blacklist_display = ", ".join(blacklistwords) if blacklistwords else ""
blacklist_line = f"Ausgeschlossen: {blacklist_display}" if blacklist_display else ""
html = f"""
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #333;">Vorab-Info: Naechste 2 Wochen</h2>
<p style="color: #555;">Vom <strong>{start_str}</strong> bis <strong>{end_str}</strong> stehen folgende Termine an:</p>
<table style="width: 100%; border-collapse: collapse; margin: 20px 0; background: #f9f9f9; border-radius: 8px; overflow: hidden;">
{event_rows}
</table>
<p style="color: #888; font-size: 12px;">Suchbegriffe: {words_display}{" " + blacklist_line if blacklist_line else ""}</p>
</body>
</html>
"""
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = SMTP_FROM
msg["To"] = email
msg.attach(MIMEText(html, "html", "utf-8"))
try:
if SMTP_USE_TLS:
server = smtplib.SMTP(SMTP_HOST, SMTP_PORT)
server.starttls()
else:
server = smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT)
if SMTP_USER and SMTP_PASSWORD:
server.login(SMTP_USER, SMTP_PASSWORD)
server.sendmail(SMTP_FROM, [email], msg.as_string())
server.quit()
log.info("Wochenbenachrichtigung gesendet: %s", subject)
except Exception:
log.exception("Fehler beim Senden der Wochenbenachrichtigung")
def check_and_send_weekly_notification():
if not SMTP_HOST or not (WEEKLY_NOTIFY_EMAIL or NOTIFY_EMAIL):
return
conn = get_connection(autocommit=True)
try:
if should_send_weekly(conn):
searchwords = parse_weekly_searchwords()
blacklistwords = parse_weekly_blacklistwords()
log.info("Wochenbenachrichtigung: Suchbegriffe=%s, Blacklist=%s", searchwords, blacklistwords)
events, week_start, week_end = get_weekly_events(conn, CALENDAR_LABEL, searchwords, blacklistwords)
log.info("Wochenbenachrichtigung: %d Events nach Filterung", len(events))
if events:
send_weekly_notification(events, searchwords, week_start, week_end, blacklistwords)
log_weekly_notification(conn, len(events))
log.info("Wochenbenachrichtigung fuer %d Events gesendet", len(events))
else:
log.info("Keine passenden Termine naechste Woche, Wochenbenachrichtigung wird uebersprungen")
except Exception:
log.exception("Fehler bei der Wochenbenachrichtigung")
finally:
conn.close()
def main():
bootstrap_database()
log.info(
"calendar-sync gestartet | Intervall=%smin | Fenster=-%dd/+%dd",
"calendar-sync gestartet | Intervall=%smin | Fenster=-%dd/+%dd | Benachrichtigung um %s:00 %s",
SYNC_INTERVAL_MINUTES, WINDOW_PAST_DAYS, WINDOW_FUTURE_DAYS,
NOTIFY_TIME, NOTIFY_TIMEZONE,
)
while True:
try:
run_sync_once()
check_and_send_notification()
check_and_send_weekly_notification()
except Exception:
log.exception("Sync-Durchlauf fehlgeschlagen, versuche es beim naechsten Intervall erneut")
time.sleep(SYNC_INTERVAL_MINUTES * 60)