mirror of
https://github.com/skoelle/icloud-contacts-sync.git
synced 2026-09-18 07:50:25 +00:00
Compare commits
20
Commits
1b4892109a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6a7d19948 | ||
|
|
0594a86894 | ||
|
|
43ce0bcfe1 | ||
|
|
113527a4a0 | ||
|
|
5f39d276d6 | ||
|
|
7e2b29a950 | ||
|
|
f307f103cf | ||
|
|
3aa3d648b2 | ||
|
|
4c743036d5 | ||
|
|
619ea84c5f | ||
|
|
debaf7dd62 | ||
|
|
00e4e54599 | ||
|
|
3edcc99c61 | ||
|
|
7b1acbb8bb | ||
|
|
af124d803f | ||
|
|
a05a8da3f1 | ||
|
|
e966157f15 | ||
|
|
2dd93d8d68 | ||
|
|
fcaa74f774 | ||
|
|
28bfbc01bf |
@@ -29,3 +29,8 @@ API_PORT=8000
|
||||
|
||||
# --- Web-URL (optional, wird in Geburtstags-Mails verlinkt) ---
|
||||
WEB_URL=https://kontakte.example.de
|
||||
|
||||
# --- Chat-Archive API (optional, zeigt Chat-Nachrichten in der Kontakt-Detailseite) ---
|
||||
CHATAPI_ENABLED=false
|
||||
CHATAPI_URL=http://chat-archive-host:8420
|
||||
CHATAPI_KEY=change-me
|
||||
|
||||
@@ -18,21 +18,21 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
|
||||
@@ -9,10 +9,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
.env
|
||||
config/accounts.json
|
||||
.venv/
|
||||
.venv-demo/
|
||||
demo.db
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.DS_Store
|
||||
*.log
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
title: "iCloud Contacts Sync"
|
||||
emoji: "🔄"
|
||||
category: code
|
||||
subcategory: "Smart Home Apps"
|
||||
status: active
|
||||
stack: [Python, FastAPI, Uvicorn, Jinja2]
|
||||
@@ -37,6 +37,8 @@ config/
|
||||
accounts.json — Per-account credentials (NOT in git, volume-mounted)
|
||||
docker/
|
||||
entrypoint.sh — Starts scheduler.py (or exec's custom command)
|
||||
demo.py — SQLite demo app with fake contacts (screenshot/showcase)
|
||||
demo.sh — Launches demo: creates venv, installs deps, starts server
|
||||
```
|
||||
|
||||
## Key Files
|
||||
@@ -50,6 +52,8 @@ docker/
|
||||
| `src/db.py` | All MariaDB queries |
|
||||
| `sql/schema.sql` | Canonical schema definition |
|
||||
| `.env.example` | All supported environment variables |
|
||||
| `demo.py` | SQLite demo app with fake contacts (screenshot/showcase) |
|
||||
| `demo.sh` | Launches demo: creates venv, installs deps, starts server |
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -83,6 +87,14 @@ docker compose build
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Run demo (no MariaDB needed)
|
||||
|
||||
```bash
|
||||
./demo.sh
|
||||
```
|
||||
|
||||
Starts SQLite-based demo on `0.0.0.0:8000` with 6 fake contacts.
|
||||
|
||||
## Code Conventions
|
||||
|
||||
- All source in `src/`, single package, no `setup.py`/`pyproject.toml`.
|
||||
@@ -110,6 +122,7 @@ See `.env.example` for full list. Key variables:
|
||||
- `AUTH_REMOTE_USER_HEADER` — Authelia header name (default: `Remote-User`)
|
||||
- `MAILER_ENABLED` — Feature flag for birthday mailer
|
||||
- `MAIL_SEND_HOUR` — Hour (0-23) for daily birthday email
|
||||
- `CHATAPI_ENABLED` / `CHATAPI_URL` / `CHATAPI_KEY` — Chat-Archive integration (optional)
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
@@ -132,6 +145,19 @@ See `.env.example` for full list. Key variables:
|
||||
4. Update endpoint table in `SPEC.md` and `README.md`
|
||||
5. Test with: `curl -H "Remote-User: <user>" http://127.0.0.1:8000/<path>`
|
||||
|
||||
### Querying contact names from the DB
|
||||
**IMPORTANT:** The `full_name` column in `contacts` can be NULL — it is
|
||||
NOT guaranteed to be set. Always SELECT `prefix, given_name, middle_name,
|
||||
family_name, suffix` alongside `full_name` and apply the fallback:
|
||||
```python
|
||||
if not row.get("full_name"):
|
||||
row["full_name"] = db._build_full_name(row)
|
||||
```
|
||||
This is what `_row_to_contact_out()` does for API responses. Any code
|
||||
that needs a contact's display name (e.g. proxying to external APIs)
|
||||
must follow the same pattern. See `get_contact_messages()` in
|
||||
`src/api/main.py` for a reference implementation.
|
||||
|
||||
### Adding a new contact field
|
||||
1. Add column to `contacts` table in `sql/schema.sql`
|
||||
2. Update `src/vcard_parser.py` to extract the field
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# icloud-contacts-sync
|
||||
# 🔄 icloud-contacts-sync
|
||||
|
||||
Synct alle Kontakte mehrerer iCloud-Accounts per CardDAV Delta-Sync
|
||||
(RFC 6578) automatisiert alle 15 Minuten in eine MariaDB-Datenbank
|
||||
@@ -6,17 +6,21 @@ Synct alle Kontakte mehrerer iCloud-Accounts per CardDAV Delta-Sync
|
||||
Geburtstage. Für den vollständigen technischen Hintergrund siehe
|
||||
[SPEC.md](./SPEC.md).
|
||||
|
||||
## Voraussetzungen
|
||||
[](docs/screenshot1.png)
|
||||
[](docs/screenshot2.png)
|
||||
|
||||
- Eine oder mehrere Apple-IDs mit aktivierter Zwei-Faktor-Authentifizierung.
|
||||
- Für jede Apple-ID ein app-spezifisches Passwort.
|
||||
- Eine erreichbare MariaDB-Instanz mit vorbereiteter Datenbank.
|
||||
- Ein SMTP-Relay (z. B. dein Mailprovider oder ein lokaler Relay) für
|
||||
|
||||
## 📋 Voraussetzungen
|
||||
|
||||
- 🔐 Eine oder mehrere Apple-IDs mit aktivierter Zwei-Faktor-Authentifizierung.
|
||||
- 🔑 Für jede Apple-ID ein app-spezifisches Passwort.
|
||||
- 🗄️ Eine erreichbare MariaDB-Instanz mit vorbereiteter Datenbank.
|
||||
- 📬 Ein SMTP-Relay (z. B. dein Mailprovider oder ein lokaler Relay) für
|
||||
den Geburtstags-Mailer.
|
||||
- Docker bzw. Docker Compose auf dem Zielhost (z. B. der Docker-Host auf
|
||||
- 🐳 Docker bzw. Docker Compose auf dem Zielhost (z. B. der Docker-Host auf
|
||||
deinem Proxmox-Host).
|
||||
|
||||
## 1. App-spezifische Passwörter erzeugen
|
||||
## 🔑 1. App-spezifische Passwörter erzeugen
|
||||
|
||||
Für jede Apple-ID, die du syncen willst:
|
||||
|
||||
@@ -25,7 +29,7 @@ Für jede Apple-ID, die du syncen willst:
|
||||
3. Ein neues Passwort mit sprechendem Namen erzeugen (z. B.
|
||||
`contacts-sync-debian`) und sofort sichern.
|
||||
|
||||
## 2. Multi-User-Konfiguration anlegen
|
||||
## ⚙️ 2. Multi-User-Konfiguration anlegen
|
||||
|
||||
```
|
||||
cp config/accounts.json.example config/accounts.json
|
||||
@@ -36,13 +40,15 @@ Trage für jede Apple-ID einen Eintrag mit eindeutigem `name`,
|
||||
`apple_email`, `apple_app_password`, `authelia_user` und (optional)
|
||||
`birthday_mail_to` ein. Optional kann pro Account eine `healthcheck_url`
|
||||
konfiguriert werden, die nach jedem erfolgreichen Sync aufgerufen wird
|
||||
(z.B. für Uptime-Monitoring). Diese Datei
|
||||
(z.B. für Uptime-Monitoring). Optional kann pro Account ein
|
||||
`chat_sender_name` konfiguriert werden, um den Namen in der
|
||||
Chat-Archive DB zuzuordnen (Details siehe Abschnitt 12). Diese Datei
|
||||
bleibt lokal auf dem Host, sie ist in `.gitignore` ausgeschlossen und
|
||||
wird nur als Volume in den Container gemountet.
|
||||
|
||||
Siehe `config/README.md` für eine vollständige Beschreibung der Felder.
|
||||
|
||||
## 3. Datenbank vorbereiten
|
||||
## 🗄️ 3. Datenbank vorbereiten
|
||||
|
||||
Falls Datenbank und Benutzer noch nicht existieren, führe dieses Skript einmalig aus:
|
||||
|
||||
@@ -50,7 +56,7 @@ Falls Datenbank und Benutzer noch nicht existieren, führe dieses Skript einmali
|
||||
mysql -u root -p < sql/db-and-user.sql
|
||||
```
|
||||
|
||||
## 4. Umgebungsvariablen konfigurieren
|
||||
## 🔧 4. Umgebungsvariablen konfigurieren
|
||||
|
||||
```
|
||||
cp .env.example .env
|
||||
@@ -62,7 +68,7 @@ Mailer nutzen willst) `SMTP_HOST` und `MAIL_FROM` ein. Die
|
||||
Empfänger-Adresse wird pro Account in `accounts.json` unter
|
||||
`birthday_mail_to` konfiguriert.
|
||||
|
||||
## 5. Image beziehen
|
||||
## 🐳 5. Image beziehen
|
||||
|
||||
```
|
||||
docker login ghcr.io -u DEIN_GITHUB_USER
|
||||
@@ -72,7 +78,7 @@ Passe in `docker-compose.yml` den Image-Namen
|
||||
(`ghcr.io/DEIN_GITHUB_USER/icloud-contacts-sync:latest`) auf deinen
|
||||
tatsächlichen GitHub-Namespace an.
|
||||
|
||||
## 6. Starten
|
||||
## 🚀 6. Starten
|
||||
|
||||
```
|
||||
docker compose up -d
|
||||
@@ -83,7 +89,7 @@ initialer Sync ausgeführt (kein gespeicherter sync-token vorhanden).
|
||||
Danach laufen alle 15 Minuten nur noch Delta-Syncs, die ausschließlich
|
||||
Änderungen seit dem letzten Lauf übertragen.
|
||||
|
||||
## 7. Logs und Status prüfen
|
||||
## 📊 7. Logs und Status prüfen
|
||||
|
||||
```
|
||||
docker logs -f icloud-contacts-sync
|
||||
@@ -112,7 +118,7 @@ SELECT account, sent_date, contacts_count, sent_at FROM birthday_mail_log
|
||||
ORDER BY sent_date DESC LIMIT 10;
|
||||
```
|
||||
|
||||
## 8. Geburtstags-Mailer
|
||||
## 🎂 8. Geburtstags-Mailer
|
||||
|
||||
- Läuft automatisch täglich um die in `MAIL_SEND_HOUR` konfigurierte
|
||||
Stunde (Default 7 Uhr) innerhalb desselben Containers.
|
||||
@@ -129,7 +135,7 @@ ORDER BY sent_date DESC LIMIT 10;
|
||||
pro Account, solange bereits ein Eintrag in `birthday_mail_log` für
|
||||
heute und diesen Account existiert.
|
||||
|
||||
## 9. Lokale Entwicklung (ohne Docker)
|
||||
## 💻 9. Lokale Entwicklung (ohne Docker)
|
||||
|
||||
```
|
||||
python3 -m venv .venv
|
||||
@@ -140,7 +146,37 @@ python3 sync.py
|
||||
python3 mailer.py
|
||||
```
|
||||
|
||||
## 10. CI/CD
|
||||
## 🎬 10. Demo-Modus (Screenshot/Showcase)
|
||||
|
||||
Lokale Demo mit SQLite-Backend und Fake-Kontakten, ohne MariaDB,
|
||||
Apple-IDs oder Docker. Zeigt Dashboard, Kontakt-Detailseite und
|
||||
Gruppen-Übersicht mit farbigen UI-Avatar-Bildern.
|
||||
|
||||
### Starten
|
||||
|
||||
```bash
|
||||
▶️ ./demo.sh
|
||||
```
|
||||
|
||||
Das Script erstellt automatisch ein virtuelles Umfeld
|
||||
(`.venv-demo/`), installiert die Dependencies und startet den
|
||||
Server auf `0.0.0.0:8000`.
|
||||
|
||||
### 👀 Was angezeigt wird
|
||||
|
||||
- Dashboard mit 6 Kontakten, Geburtstagen der nächsten 7 Tage,
|
||||
2 Gruppen ("Familie", "Arbeit") und成功stem Sync-Status
|
||||
- Kontakt-Detailseite mit E-Mail, Telefon, Adresse, Foto
|
||||
- Farbige Initialen-Avatare via ui-avatars.com
|
||||
|
||||
### 🔧 Technisches
|
||||
|
||||
- SQLite-Datenbank (`demo.db`) wird bei jedem Start frisch angelegt
|
||||
- Kein `.env`, kein `accounts.json` nötig
|
||||
- Templates und CSS werden aus `src/api/` wiederverwendet
|
||||
- `.venv-demo/` und `demo.db` sind in `.gitignore` eingetragen
|
||||
|
||||
## ⚡ 11. CI/CD
|
||||
|
||||
- Jeder Push auf `main` baut automatisch ein neues Image und pusht es
|
||||
nach `ghcr.io/<owner>/icloud-contacts-sync`.
|
||||
@@ -151,14 +187,14 @@ python3 mailer.py
|
||||
reproduzierbar und unabhängig von neuen Ruff-Defaults.
|
||||
- Details siehe SPEC.md, Abschnitt 9.
|
||||
|
||||
## Bekannte Grenzen und geplante Erweiterungen
|
||||
## ⚠️ Bekannte Grenzen und geplante Erweiterungen
|
||||
|
||||
- Delta-Sync reduziert die übertragene Datenmenge stark, ersetzt aber
|
||||
keine vollständige Historie: ein gelöschter iCloud-Kontakt wird auch
|
||||
aus MariaDB entfernt, ohne Archiv.
|
||||
- Nur iCloud als Quelle, Google/Microsoft sind nicht Teil dieses Repos.
|
||||
|
||||
## Kontaktruppen
|
||||
## 👥 Kontaktruppen
|
||||
|
||||
iCloud-Länder speichern Gruppen als eigene vCards mit
|
||||
`X-ADDRESSBOOKSERVER-KIND:group`. Diese werden beim Sync automatisch
|
||||
@@ -177,7 +213,7 @@ entfernt (`ON DELETE CASCADE`). Wird ein Mitglied-Kontakt gelöscht,
|
||||
wird der Member-Eintrag in allen Gruppen ebenfalls entfernt (manueller
|
||||
Cleanup im Sync-Code). Die Gruppe selbst bleibt erhalten.
|
||||
|
||||
### Migration bei erstem Deploy
|
||||
### 🔄 Migration bei erstem Deploy
|
||||
|
||||
Bei Bestands-DBs lagen Gruppen bisher als normale Kontakte in der
|
||||
`contacts`-Tabelle. Nach dem Deploy müssen diese einmalig bereinigt
|
||||
@@ -190,14 +226,14 @@ werden:
|
||||
Beim nächsten Sync-Lauf werden alle vCards neu klassifiziziert —
|
||||
Gruppen landen in `groups`, Kontakte bleiben in `contacts`.
|
||||
|
||||
## 11. Web-Ansicht und API (interner Zugriff über Authelia)
|
||||
## 🌐 11. Web-Ansicht und API (interner Zugriff über Authelia)
|
||||
|
||||
Läuft als zweiter Service aus demselben Image, aber mit anderem
|
||||
Startbefehl, siehe `docker-compose.yml` (`icloud-contacts-api`). Die API
|
||||
selbst hat kein eigenes Login, sie vertraut vollständig dem
|
||||
vorgeschalteten Reverse-Proxy mit Authelia.
|
||||
|
||||
### Voraussetzung: Reverse-Proxy mit Authelia
|
||||
### 🔐 Voraussetzung: Reverse-Proxy mit Authelia
|
||||
|
||||
Dein bestehender Reverse-Proxy muss für den Pfad/Host der
|
||||
Web-Ansicht einen `auth_request` gegen Authelia ausführen und danach
|
||||
@@ -216,7 +252,7 @@ location / {
|
||||
Falls dein Setup den Benutzernamen unter einem anderen Header liefert,
|
||||
passe `AUTH_REMOTE_USER_HEADER` in der `.env` entsprechend an.
|
||||
|
||||
### Accounts-Mapping ergänzen
|
||||
### 👤 Accounts-Mapping ergänzen
|
||||
|
||||
In `config/accounts.json` bekommt jeder Account zusätzlich ein Feld
|
||||
`authelia_user`:
|
||||
@@ -233,7 +269,7 @@ In `config/accounts.json` bekommt jeder Account zusätzlich ein Feld
|
||||
Ein Benutzer aus `admins` sieht alle Accounts, alle anderen gemappten
|
||||
Benutzer sehen ausschließlich ihren eigenen Account.
|
||||
|
||||
### Starten
|
||||
### 🚀 Starten
|
||||
|
||||
```
|
||||
docker compose up -d icloud-contacts-api
|
||||
@@ -242,7 +278,7 @@ docker compose up -d icloud-contacts-api
|
||||
Der Service läuft nur an `127.0.0.1:8000`, ein direkter externer
|
||||
Zugriff ohne den Reverse-Proxy ist damit nicht möglich.
|
||||
|
||||
### Endpunkte (Routes)
|
||||
### 📍 Endpunkte (Routes)
|
||||
|
||||
| Methode | Pfad | Beschreibung |
|
||||
|---------|------|--------------|
|
||||
@@ -260,16 +296,56 @@ Zugriff ohne den Reverse-Proxy ist damit nicht möglich.
|
||||
| `GET` | `/api/groups/{id}/members` | Members einer Gruppe (Kontaktdaten) |
|
||||
| `GET` | `/api/sync-runs` | Letzte 50 Sync-Runs (Status, Zeitstempel, Fehler) |
|
||||
|
||||
**Chat-Archive (optional):**
|
||||
|
||||
| Methode | Pfad | Beschreibung |
|
||||
|---------|------|--------------|
|
||||
| `GET` | `/api/contacts/{id}/messages` | Chat-Nachrichten eines Kontakts via Chat-Archive API (`?offset=0&limit=50`) |
|
||||
|
||||
Alle Endpunkte (außer `/api/health`) erfordern eine Authentifizierung
|
||||
über den `Remote-User`-Header. Nicht-Admins sehen nur die Daten ihres
|
||||
eigenen Accounts.
|
||||
|
||||
### API kurz testen (lokal auf der Docker-Host, mit Header simuliert)
|
||||
### 🧪 API kurz testen (lokal auf der Docker-Host, mit Header simuliert)
|
||||
|
||||
```
|
||||
curl -H "Remote-User: mmustermann" http://127.0.0.1:8000/api/contacts
|
||||
```
|
||||
|
||||
## License
|
||||
## 💬 12. Chat-Archive Integration (optional)
|
||||
|
||||
Zeigt Chat-Nachrichten (Instagram/Facebook) direkt in der
|
||||
Kontakt-Detailseite, mit Infinite Scroll und Messenger-Style Bubbles.
|
||||
|
||||
### Voraussetzung
|
||||
|
||||
- Eine laufende [Chat-Archive API](https://github.com/stefan-koelle/chat-archive)
|
||||
mit importierten Nachrichten.
|
||||
|
||||
### Konfiguration
|
||||
|
||||
In `.env`:
|
||||
|
||||
```
|
||||
CHATAPI_ENABLED=true
|
||||
CHATAPI_URL=http://chat-archive-host:8420
|
||||
CHATAPI_KEY=change-me
|
||||
```
|
||||
|
||||
In `config/accounts.json` pro Account das `chat_sender_name` setzen
|
||||
(Name wie in der Chat-Archive DB als `sender_name` gespeichert):
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "iCloud Stefan",
|
||||
"authelia_user": "stefan",
|
||||
"chat_sender_name": "Stefan Koelle"
|
||||
}
|
||||
```
|
||||
|
||||
Der Name wird umlaut-normalisiert verglichen: "Koelle" und "Kölle"
|
||||
werden als identisch erkannt.
|
||||
|
||||
## 📄 License
|
||||
|
||||
Licensed under the [MIT License](LICENSE) - Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
|
||||
|
||||
@@ -39,7 +39,7 @@ außerhalb des Apple-Ökosystems.
|
||||
```json
|
||||
{
|
||||
"accounts": [
|
||||
{ "name": "markus", "apple_email": "markus@icloud.com", "apple_app_password": "xxxx-xxxx-xxxx-xxxx", "authelia_user": "mmustermann", "birthday_mail_to": "markus@example.de", "healthcheck_url": "https://healthchecks.example.de/ping/abc123" },
|
||||
{ "name": "markus", "apple_email": "markus@icloud.com", "apple_app_password": "xxxx-xxxx-xxxx-xxxx", "authelia_user": "mmustermann", "birthday_mail_to": "markus@example.de", "healthcheck_url": "https://healthchecks.example.de/ping/abc123", "chat_sender_name": "Markus Mustermann" },
|
||||
{ "name": "partner", "apple_email": "partner@icloud.com", "apple_app_password": "yyyy-yyyy-yyyy-yyyy", "authelia_user": "pmustermann", "birthday_mail_to": "partner@example.de" }
|
||||
],
|
||||
"admins": ["mmustermann"]
|
||||
@@ -152,6 +152,9 @@ Siehe `sql/schema.sql`. Wichtigste Änderungen gegenüber v1:
|
||||
| AUTH_REMOTE_USER_HEADER | nein | Default: Remote-User, Header-Name für Authelia-User |
|
||||
| API_HOST | nein | Default: 0.0.0.0, Bindungs-Adresse des API-Services |
|
||||
| API_PORT | nein | Default: 8000, Port des API-Services |
|
||||
| CHATAPI_ENABLED | nein | Default: false, aktiviert Chat-Archive Integration |
|
||||
| CHATAPI_URL | nein | Basis-URL der Chat-Archive API |
|
||||
| CHATAPI_KEY | nein | API-Key für Chat-Archive Authentifizierung |
|
||||
|
||||
Empfänger-Adresse für Geburtstags-Mails: `birthday_mail_to` pro Account
|
||||
in `accounts.json` (keine globale Umgebungsvariable mehr nötig).
|
||||
@@ -279,6 +282,7 @@ geteilt wird. Getrennt ist nur die **Rolle**, in der der Container läuft.
|
||||
| `GET /api/groups/{id}` | Einzelne Gruppe mit aufgelösten Members (Name + UID) |
|
||||
| `GET /api/groups/{id}/members` | Nur Members einer Gruppe (Kontaktdaten aufgelöst) |
|
||||
| `GET /api/sync-runs` | Sync-Historie (kontospezifisch bzw. global für Admins) |
|
||||
| `GET /api/contacts/{id}/messages` | Chat-Nachrichten via Chat-Archive API (optional, `?offset=0&limit=50`) |
|
||||
|
||||
### 12.5 Netzwerkkontext
|
||||
|
||||
@@ -287,3 +291,27 @@ geteilt wird. Getrennt ist nur die **Rolle**, in der der Container läuft.
|
||||
- Externer Zugriff läuft über deinen bestehenden Reverse-Proxy mit
|
||||
Authelia im internen Netzwerk (`deinem lokalen Netz`), der intern auf
|
||||
`127.0.0.1:8000` weiterleitet und den `Remote-User`-Header setzt.
|
||||
|
||||
### 12.6 Chat-Archive Integration (optional)
|
||||
|
||||
- Feature-Flag `CHATAPI_ENABLED` (Default: `false`).
|
||||
- Bei aktivierter Integration zeigt die Kontakt-Detailseite
|
||||
(`/contacts/{id}`) Chat-Nachrichten des Kontakts aus einer externen
|
||||
[Chat-Archive API](https://github.com/stefan-koelle/chat-archive).
|
||||
- Der API-Container agiert als Proxy: der Browser ruft
|
||||
`GET /api/contacts/{id}/messages` auf, der Server liest den
|
||||
`full_name` des Kontakts aus der DB und leitet die Anfrage an
|
||||
`CHATAPI_URL/conversation?contact_names={full_name}&order=desc` weiter.
|
||||
- Der API-Key wird serverseitig aus `CHATAPI_KEY` gelesen, der Browser
|
||||
erhält nie Zugriff auf das Geheimnis.
|
||||
- **Namens-Matching**: Das optionale Feld `chat_sender_name` pro Account
|
||||
in `accounts.json` gibt den Namen an, der als eigene Nachricht
|
||||
erkannt wird (z.B. "Stefan Koelle"). Der Vergleich erfolgt
|
||||
umlaut-normalisiert: "Koelle" und "Kölle" werden als identisch
|
||||
erkannt.
|
||||
- **Infinite Scroll**: Das Frontend lädt initial 50 Nachrichten
|
||||
(neueste zuerst) und lädt bei Bedarf weitere Batches nach, indem
|
||||
ein Intersection Observer den `offset`-Parameter erhöht.
|
||||
- **Darstellung**: Chat-Bubbles im Messenger-Style, eigene Nachrichten
|
||||
rechts (blau), Kontaktnachrichten links (grau). Plattform-Badge
|
||||
(Instagram/Facebook) und Zeitstempel werden angezeigt.
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Lokale Demo-App: SQLite-Backend mit Fake-Kontakten für Dashboard + Kontakt-Detail."""
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import sqlite3
|
||||
from contextlib import contextmanager
|
||||
from datetime import date, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from utils import fmt_birthday_age, fmt_birthday_short, is_unknown_year
|
||||
|
||||
logging.basicConfig(level="INFO", format="%(asctime)s [%(levelname)s] %(message)s")
|
||||
logger = logging.getLogger("demo")
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
DB_PATH = BASE_DIR / "demo.db"
|
||||
SRC_DIR = BASE_DIR / "src"
|
||||
|
||||
app = FastAPI(title="iCloud Contacts Sync – Demo", version="1.0.0")
|
||||
app.add_middleware(SessionMiddleware, secret_key=secrets.token_hex(32), session_cookie="ics_session")
|
||||
app.mount("/static", StaticFiles(directory=str(SRC_DIR / "api" / "static")), name="static")
|
||||
templates = Jinja2Templates(directory=str(SRC_DIR / "api" / "templates"))
|
||||
templates.env.filters["urlquote"] = lambda s: quote_plus(s or "")
|
||||
templates.env.filters["fmt_birthday"] = fmt_birthday_short
|
||||
templates.env.filters["fmt_age"] = fmt_birthday_age
|
||||
templates.env.filters["has_year"] = lambda b: not is_unknown_year(b)
|
||||
|
||||
DEMO_USER = "demo"
|
||||
DEMO_ACCOUNT = "demo-user"
|
||||
|
||||
|
||||
def _fmt_ts(ts: str | None) -> str | None:
|
||||
if not ts:
|
||||
return None
|
||||
try:
|
||||
dt = datetime.fromisoformat(ts)
|
||||
return dt.strftime("%d.%m.%Y %H:%M:%S")
|
||||
except (ValueError, TypeError):
|
||||
return ts
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_connection():
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
try:
|
||||
yield conn
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _build_full_name(row: dict) -> str | None:
|
||||
parts = [
|
||||
row.get("given_name"),
|
||||
row.get("middle_name"),
|
||||
row.get("family_name"),
|
||||
]
|
||||
return " ".join(p for p in parts if p) or None
|
||||
|
||||
|
||||
def _row_to_contact_out(row: dict) -> dict:
|
||||
row = dict(row)
|
||||
for field in ("emails", "phones", "addresses", "urls", "social_profiles", "categories"):
|
||||
raw = row.get(field)
|
||||
row[field] = json.loads(raw) if raw else []
|
||||
if row.get("birthday") and isinstance(row["birthday"], str):
|
||||
row["birthday"] = date.fromisoformat(row["birthday"])
|
||||
if not row.get("full_name"):
|
||||
row["full_name"] = _build_full_name(row)
|
||||
return row
|
||||
|
||||
|
||||
def init_db():
|
||||
with get_connection() as conn:
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS contacts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
account TEXT NOT NULL,
|
||||
uid TEXT NOT NULL,
|
||||
full_name TEXT, given_name TEXT, family_name TEXT,
|
||||
middle_name TEXT, prefix TEXT, suffix TEXT,
|
||||
organization TEXT, job_title TEXT,
|
||||
birthday TEXT, notes TEXT,
|
||||
emails TEXT, phones TEXT, addresses TEXT,
|
||||
urls TEXT, social_profiles TEXT, categories TEXT,
|
||||
photo_url TEXT,
|
||||
raw_vcard TEXT DEFAULT '',
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
UNIQUE(account, uid)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS groups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
account TEXT NOT NULL,
|
||||
uid TEXT NOT NULL,
|
||||
name TEXT,
|
||||
UNIQUE(account, uid)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS group_members (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
group_id INTEGER REFERENCES groups(id),
|
||||
member_uid TEXT NOT NULL,
|
||||
UNIQUE(group_id, member_uid)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sync_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
account TEXT NOT NULL,
|
||||
sync_type TEXT NOT NULL DEFAULT 'delta',
|
||||
started_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
finished_at TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'running',
|
||||
contacts_upserted INTEGER,
|
||||
contacts_deleted INTEGER,
|
||||
error_message TEXT
|
||||
);
|
||||
""")
|
||||
|
||||
|
||||
def seed_data():
|
||||
with get_connection() as conn:
|
||||
count = conn.execute("SELECT COUNT(*) FROM contacts").fetchone()[0]
|
||||
if count > 0:
|
||||
return
|
||||
|
||||
today = date.today()
|
||||
def _avatar(name: str, bg: str = "0066cc") -> str:
|
||||
from urllib.parse import quote
|
||||
return f"https://ui-avatars.com/api/?name={quote(name)}&background={bg}&color=fff&size=160&bold=true"
|
||||
|
||||
contacts = [
|
||||
{
|
||||
"uid": "demo-001", "full_name": "Erika Musterfrau",
|
||||
"given_name": "Erika", "family_name": "Musterfrau",
|
||||
"organization": "Muster GmbH", "job_title": "Geschäftsführerin",
|
||||
"birthday": "1985-03-15",
|
||||
"photo_url": _avatar("Erika Musterfrau", "0066cc"),
|
||||
"emails": json.dumps([{"type": "work", "value": "erika@muster.de"}, {"type": "home", "value": "erika.privat@mail.de"}]),
|
||||
"phones": json.dumps([{"type": "work", "value": "+49 30 1234567"}, {"type": "mobile", "value": "+49 170 1234567"}]),
|
||||
"addresses": json.dumps([{"type": "work", "street": "Berliner Str. 1", "zip": "10115", "city": "Berlin", "region": "Berlin", "country": "Deutschland"}]),
|
||||
},
|
||||
{
|
||||
"uid": "demo-002", "full_name": "Thomas Testermann",
|
||||
"given_name": "Thomas", "family_name": "Testermann",
|
||||
"organization": "Test AG", "job_title": "Entwickler",
|
||||
"birthday": "1990-07-22",
|
||||
"photo_url": _avatar("Thomas Testermann", "28a745"),
|
||||
"emails": json.dumps([{"type": "work", "value": "thomas@test.de"}]),
|
||||
"phones": json.dumps([{"type": "mobile", "value": "+49 171 9876543"}]),
|
||||
"addresses": json.dumps([{"type": "home", "street": "Musterweg 5", "zip": "80331", "city": "München", "region": "Bayern", "country": "Deutschland"}]),
|
||||
},
|
||||
{
|
||||
"uid": "demo-003", "full_name": "Anna Beispiel",
|
||||
"given_name": "Anna", "family_name": "Beispiel",
|
||||
"organization": "Beispiel & Partner", "job_title": "Designerin",
|
||||
"birthday": "1992-11-08",
|
||||
"photo_url": _avatar("Anna Beispiel", "dc3545"),
|
||||
"emails": json.dumps([{"type": "home", "value": "anna@beispiel.de"}]),
|
||||
"phones": json.dumps([{"type": "mobile", "value": "+49 172 5551234"}]),
|
||||
"addresses": json.dumps([{"type": "home", "street": "Gartenstr. 12", "zip": "50667", "city": "Köln", "region": "Nordrhein-Westfalen", "country": "Deutschland"}]),
|
||||
},
|
||||
{
|
||||
"uid": "demo-004", "full_name": "Sabine Mustermann",
|
||||
"given_name": "Sabine", "family_name": "Mustermann",
|
||||
"organization": "", "job_title": "",
|
||||
"birthday": f"1997-08-{(today + timedelta(days=5)).day:02d}",
|
||||
"photo_url": _avatar("Sabine Mustermann", "fd7e14"),
|
||||
"emails": json.dumps([{"type": "home", "value": "sabine@web.de"}]),
|
||||
"phones": json.dumps([{"type": "home", "value": "+49 721 555999"}]),
|
||||
"addresses": json.dumps([{"type": "home", "street": "Waldweg 3", "zip": "70173", "city": "Stuttgart", "region": "Baden-Württemberg", "country": "Deutschland"}]),
|
||||
},
|
||||
{
|
||||
"uid": "demo-005", "full_name": "Peter Beispiel",
|
||||
"given_name": "Peter", "family_name": "Beispiel",
|
||||
"organization": "Beispiel & Partner", "job_title": "Partner",
|
||||
"birthday": "1960-05-01",
|
||||
"photo_url": _avatar("Peter Beispiel", "6f42c1"),
|
||||
"emails": json.dumps([{"type": "work", "value": "peter@beispiel.de"}]),
|
||||
"phones": json.dumps([{"type": "work", "value": "+49 721 555888"}, {"type": "mobile", "value": "+49 175 1112233"}]),
|
||||
"addresses": json.dumps([{"type": "work", "street": "Hauptstr. 42", "zip": "70173", "city": "Stuttgart", "region": "Baden-Württemberg", "country": "Deutschland"}]),
|
||||
},
|
||||
{
|
||||
"uid": "demo-006", "full_name": "Max Demo",
|
||||
"given_name": "Max", "family_name": "Demo",
|
||||
"organization": "Demo Inc.", "job_title": "Projektmanager",
|
||||
"birthday": f"1991-08-{(today + timedelta(days=3)).day:02d}",
|
||||
"photo_url": _avatar("Max Demo", "17a2b8"),
|
||||
"emails": json.dumps([{"type": "work", "value": "max@demo.de"}, {"type": "home", "value": "max.privat@demo.de"}]),
|
||||
"phones": json.dumps([{"type": "mobile", "value": "+49 176 4445566"}]),
|
||||
"addresses": json.dumps([{"type": "home", "street": "Demoallee 7", "zip": "10115", "city": "Berlin", "region": "Berlin", "country": "Deutschland"}]),
|
||||
},
|
||||
]
|
||||
|
||||
for c in contacts:
|
||||
conn.execute(
|
||||
"""INSERT INTO contacts (account, uid, full_name, given_name, family_name,
|
||||
organization, job_title, birthday, photo_url, emails, phones, addresses)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(DEMO_ACCOUNT, c["uid"], c["full_name"], c["given_name"], c["family_name"],
|
||||
c["organization"], c["job_title"], c["birthday"], c["photo_url"],
|
||||
c["emails"], c["phones"], c["addresses"]),
|
||||
)
|
||||
|
||||
groups = [
|
||||
("demo-grp-001", "Familie"),
|
||||
("demo-grp-002", "Arbeit"),
|
||||
]
|
||||
for uid, name in groups:
|
||||
conn.execute(
|
||||
"INSERT INTO groups (account, uid, name) VALUES (?, ?, ?)",
|
||||
(DEMO_ACCOUNT, uid, name),
|
||||
)
|
||||
|
||||
family_id = conn.execute("SELECT id FROM groups WHERE uid = ?", ("demo-grp-001",)).fetchone()[0]
|
||||
work_id = conn.execute("SELECT id FROM groups WHERE uid = ?", ("demo-grp-002",)).fetchone()[0]
|
||||
|
||||
for uid in ["demo-001", "demo-004", "demo-005"]:
|
||||
conn.execute("INSERT INTO group_members (group_id, member_uid) VALUES (?, ?)", (family_id, uid))
|
||||
for uid in ["demo-001", "demo-002", "demo-003", "demo-005"]:
|
||||
conn.execute("INSERT INTO group_members (group_id, member_uid) VALUES (?, ?)", (work_id, uid))
|
||||
|
||||
conn.execute(
|
||||
"""INSERT INTO sync_runs (id, account, sync_type, started_at, finished_at, status, contacts_upserted, contacts_deleted)
|
||||
VALUES (?, ?, ?, datetime('now', '-2 hours'), datetime('now', '-1 hour'), 'success', 6, 0)""",
|
||||
("demo-sync-001", DEMO_ACCOUNT, "delta"),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
logger.info("Demo-Daten erstellt: 6 Kontakte, 2 Gruppen")
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def dashboard(request: Request):
|
||||
with get_connection() as conn:
|
||||
contact_count = conn.execute("SELECT COUNT(*) AS total FROM contacts").fetchone()["total"]
|
||||
|
||||
today = date.today()
|
||||
rows = conn.execute(
|
||||
"""SELECT id, full_name, given_name, middle_name, family_name,
|
||||
organization, birthday, photo_url
|
||||
FROM contacts
|
||||
WHERE birthday IS NOT NULL
|
||||
ORDER BY birthday""",
|
||||
).fetchall()
|
||||
upcoming = []
|
||||
cutoff = today + timedelta(days=7)
|
||||
for r in rows:
|
||||
bday = date.fromisoformat(r["birthday"])
|
||||
bday_this_year = bday.replace(year=today.year)
|
||||
if today <= bday_this_year <= cutoff:
|
||||
d = dict(r)
|
||||
d["birthday"] = bday
|
||||
if not d.get("full_name"):
|
||||
d["full_name"] = _build_full_name(d)
|
||||
upcoming.append(d)
|
||||
|
||||
groups = [dict(g) for g in conn.execute(
|
||||
"SELECT id, name, uid FROM groups ORDER BY name"
|
||||
).fetchall()]
|
||||
|
||||
last_sync_row = conn.execute(
|
||||
"""SELECT id, sync_type, started_at, finished_at, status,
|
||||
contacts_upserted, contacts_deleted, error_message
|
||||
FROM sync_runs ORDER BY started_at DESC LIMIT 1"""
|
||||
).fetchone()
|
||||
last_sync = dict(last_sync_row) if last_sync_row else None
|
||||
if last_sync:
|
||||
last_sync["started_at"] = _fmt_ts(last_sync["started_at"])
|
||||
last_sync["finished_at"] = _fmt_ts(last_sync["finished_at"])
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"dashboard.html",
|
||||
{
|
||||
"request": request,
|
||||
"current_user": DEMO_USER,
|
||||
"is_admin": True,
|
||||
"show_all": False,
|
||||
"account_name": DEMO_ACCOUNT,
|
||||
"contact_count": contact_count,
|
||||
"upcoming_birthdays": upcoming,
|
||||
"last_sync": last_sync,
|
||||
"last_sync_with_changes": None,
|
||||
"groups": groups,
|
||||
"current_year": today.year,
|
||||
"today": today,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/contacts/{contact_id}", response_class=HTMLResponse)
|
||||
def contact_detail(request: Request, contact_id: int):
|
||||
with get_connection() as conn:
|
||||
row = conn.execute(
|
||||
"""SELECT id, account, uid, full_name, given_name, middle_name, family_name,
|
||||
organization, job_title, birthday, notes, photo_url,
|
||||
emails, phones, addresses, urls, social_profiles, categories, updated_at
|
||||
FROM contacts WHERE id = ?""",
|
||||
(contact_id,),
|
||||
).fetchone()
|
||||
|
||||
if not row:
|
||||
return HTMLResponse("Kontakt nicht gefunden", status_code=404)
|
||||
|
||||
contact = _row_to_contact_out(row)
|
||||
|
||||
groups = [dict(g) for g in conn.execute(
|
||||
"""SELECT g.id, g.name, g.uid
|
||||
FROM groups g
|
||||
JOIN group_members gm ON gm.group_id = g.id
|
||||
WHERE gm.member_uid = ?
|
||||
ORDER BY g.name""",
|
||||
(row["uid"],),
|
||||
).fetchall()]
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"contact.html",
|
||||
{
|
||||
"request": request,
|
||||
"current_user": DEMO_USER,
|
||||
"is_admin": True,
|
||||
"show_all": False,
|
||||
"contact": contact,
|
||||
"groups": groups,
|
||||
"search": "",
|
||||
"custom_links": [],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/search", response_class=HTMLResponse)
|
||||
def search_redirect():
|
||||
return HTMLResponse(
|
||||
'<!DOCTYPE html><html><body style="font-family:sans-serif;padding:2rem">'
|
||||
'<h2>Demo-Modus</h2><p><a href="/">← Zurück zum Dashboard</a></p>'
|
||||
'</body></html>',
|
||||
status_code=302,
|
||||
headers={"Location": "/"},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def health():
|
||||
return {"status": "ok", "mode": "demo"}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
init_db()
|
||||
seed_data()
|
||||
logger.info("Starte Demo-Server auf http://127.0.0.1:8000")
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
# Startet die lokale Demo-App mit SQLite-Backend und Fake-Kontakten.
|
||||
# Erstellt automatisch ein virtuelles Umfeld, wenn nicht vorhanden.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
VENV_DIR="$SCRIPT_DIR/.venv-demo"
|
||||
|
||||
if [ ! -d "$VENV_DIR" ]; then
|
||||
echo "Erstelle virtuelles Umfeld in $VENV_DIR ..."
|
||||
python3 -m venv "$VENV_DIR"
|
||||
fi
|
||||
|
||||
echo "Installiere Dependencies ..."
|
||||
"$VENV_DIR/bin/pip" install -q -r "$SCRIPT_DIR/requirements.txt"
|
||||
|
||||
echo "Starte Demo-Server auf http://127.0.0.1:8000"
|
||||
PYTHONPATH="$SCRIPT_DIR/src" exec "$VENV_DIR/bin/python" "$SCRIPT_DIR/demo.py"
|
||||
@@ -58,6 +58,9 @@ services:
|
||||
MAIL_FROM: "${MAIL_FROM}"
|
||||
TIMEZONE: "${TIMEZONE:-Europe/Berlin}"
|
||||
WEB_URL: "${WEB_URL:-}"
|
||||
CHATAPI_ENABLED: "${CHATAPI_ENABLED:-false}"
|
||||
CHATAPI_URL: "${CHATAPI_URL:-}"
|
||||
CHATAPI_KEY: "${CHATAPI_KEY:-}"
|
||||
volumes:
|
||||
- ./config/accounts.json:/app/config/accounts.json:ro
|
||||
ports:
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": ["config:recommended"],
|
||||
"schedule": ["before 6am on Monday"],
|
||||
"packageRules": [
|
||||
{
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"automerge": true
|
||||
},
|
||||
{
|
||||
"matchUpdateTypes": ["major"],
|
||||
"automerge": false,
|
||||
"labels": ["major-update"]
|
||||
},
|
||||
{
|
||||
"matchManagers": ["github-actions"],
|
||||
"groupName": "GitHub Actions",
|
||||
"automerge": true
|
||||
},
|
||||
{
|
||||
"matchManagers": ["dockerfile"],
|
||||
"groupName": "Docker",
|
||||
"automerge": false
|
||||
}
|
||||
]
|
||||
}
|
||||
+9
-9
@@ -1,10 +1,10 @@
|
||||
requests==2.32.3
|
||||
vobject==0.9.6.1
|
||||
PyMySQL==1.1.1
|
||||
lxml==5.3.0
|
||||
python-dotenv==1.0.1
|
||||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.32.0
|
||||
jinja2==3.1.4
|
||||
pydantic==2.9.2
|
||||
requests==2.34.2
|
||||
vobject==0.9.9
|
||||
PyMySQL==1.2.0
|
||||
lxml==5.4.0
|
||||
python-dotenv==1.2.2
|
||||
fastapi==0.141.1
|
||||
uvicorn[standard]==0.52.1
|
||||
jinja2==3.1.6
|
||||
pydantic==2.13.4
|
||||
itsdangerous==2.2.0
|
||||
|
||||
+97
-15
@@ -13,8 +13,9 @@ import secrets
|
||||
from datetime import datetime
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
import requests
|
||||
from fastapi import Depends, FastAPI, Query, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
@@ -56,7 +57,7 @@ def _fmt_ts(dt) -> str | None:
|
||||
|
||||
def _row_to_contact_out(row: dict, group_names: list[str] | None = None) -> dict:
|
||||
row = dict(row)
|
||||
for field in ["emails", "phones", "addresses", "urls", "social_profiles", "categories"]:
|
||||
for field in ["emails", "phones", "addresses", "urls", "social_profiles", "related_names", "categories"]:
|
||||
raw = row.get(field)
|
||||
row[field] = json.loads(raw) if raw else []
|
||||
if not row.get("full_name"):
|
||||
@@ -66,6 +67,25 @@ def _row_to_contact_out(row: dict, group_names: list[str] | None = None) -> dict
|
||||
return row
|
||||
|
||||
|
||||
def _enrich_related_names(conn, contact: dict) -> None:
|
||||
related = contact.get("related_names", [])
|
||||
if not related:
|
||||
return
|
||||
names = list({r["value"] for r in related if r.get("value")})
|
||||
if not names:
|
||||
return
|
||||
resolved = db.resolve_related_names(conn, contact["account"], names)
|
||||
for r in related:
|
||||
name = r.get("value", "")
|
||||
info = resolved.get(name)
|
||||
if info:
|
||||
r["id"] = info["id"]
|
||||
r["name"] = info["name"]
|
||||
else:
|
||||
r["id"] = None
|
||||
r["name"] = name
|
||||
|
||||
|
||||
def _account_filter_clause(account_name: str | None) -> tuple[str, list]:
|
||||
if account_name is None:
|
||||
return "", []
|
||||
@@ -134,7 +154,7 @@ def get_contact(contact_id: int, current_user: str = Depends(get_current_user)):
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
f"""SELECT id, account, uid, full_name, prefix, given_name, middle_name, family_name, suffix, organization,
|
||||
job_title, birthday, notes, photo_url, emails, phones, addresses, urls, social_profiles, categories, updated_at
|
||||
job_title, birthday, notes, photo_url, emails, phones, addresses, urls, social_profiles, related_names, categories, updated_at
|
||||
FROM contacts {where_clause} {id_clause}""",
|
||||
params + [contact_id],
|
||||
)
|
||||
@@ -146,7 +166,9 @@ def get_contact(contact_id: int, current_user: str = Depends(get_current_user)):
|
||||
groups = db.get_groups_for_contact(conn, row["account"], row["uid"])
|
||||
group_names = [g["name"] for g in groups if g.get("name")]
|
||||
|
||||
return _row_to_contact_out(row, group_names=group_names)
|
||||
contact = _row_to_contact_out(row, group_names=group_names)
|
||||
_enrich_related_names(conn, contact)
|
||||
return contact
|
||||
|
||||
|
||||
@app.get("/api/contacts/birthdays/today", response_model=list[ContactOut])
|
||||
@@ -190,6 +212,58 @@ def contact_count(current_user: str = Depends(get_current_user)):
|
||||
return {"total": total}
|
||||
|
||||
|
||||
@app.get("/api/contacts/{contact_id}/messages")
|
||||
def get_contact_messages(
|
||||
contact_id: int,
|
||||
offset: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
current_user: str = Depends(get_current_user),
|
||||
):
|
||||
if not Config.CHATAPI_ENABLED:
|
||||
return JSONResponse(status_code=404, content={"detail": "Chat-Archive nicht aktiviert"})
|
||||
|
||||
with db.get_connection() as conn:
|
||||
where_clause, params = _account_filter_clause(
|
||||
resolve_account_for_user(current_user)[0]
|
||||
)
|
||||
id_clause = "AND id = %s" if where_clause else "WHERE id = %s"
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
f"""SELECT full_name, prefix, given_name, middle_name, family_name, suffix
|
||||
FROM contacts {where_clause} {id_clause}""",
|
||||
params + [contact_id],
|
||||
)
|
||||
row = cur.fetchone()
|
||||
|
||||
if not row:
|
||||
return JSONResponse(status_code=404, content={"detail": "Kontakt nicht gefunden"})
|
||||
|
||||
if not row.get("full_name"):
|
||||
row["full_name"] = db._build_full_name(row)
|
||||
|
||||
if not row.get("full_name"):
|
||||
return JSONResponse(status_code=404, content={"detail": "Kontakt hat keinen Namen"})
|
||||
|
||||
try:
|
||||
resp = requests.get(
|
||||
f"{Config.CHATAPI_URL.rstrip('/')}/conversation",
|
||||
params={
|
||||
"contact_names": [row["full_name"]],
|
||||
"order": "desc",
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
},
|
||||
headers={"X-API-Key": Config.CHATAPI_KEY},
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
except requests.RequestException as e:
|
||||
logger.warning("Chat-Archive API Fehler: %s", e)
|
||||
return JSONResponse(status_code=502, content={"detail": "Chat-Archive nicht erreichbar"})
|
||||
|
||||
return resp.json()
|
||||
|
||||
|
||||
@app.get("/api/sync-runs", response_model=list[SyncRunOut])
|
||||
def list_sync_runs(current_user: str = Depends(get_current_user)):
|
||||
account_name, is_admin = resolve_account_for_user(current_user)
|
||||
@@ -363,9 +437,9 @@ def web_dashboard(
|
||||
last_sync_with_changes = None
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"dashboard.html",
|
||||
{
|
||||
"request": request,
|
||||
"current_user": current_user,
|
||||
"is_admin": is_admin,
|
||||
"show_all": show_all,
|
||||
@@ -392,9 +466,9 @@ def web_admin(
|
||||
|
||||
show_all = request.session.get("show_all", False)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"admin.html",
|
||||
{
|
||||
"request": request,
|
||||
"current_user": current_user,
|
||||
"show_all": show_all,
|
||||
},
|
||||
@@ -428,9 +502,9 @@ def admin_test_send(
|
||||
Config.validate_mailer()
|
||||
except RuntimeError as exc:
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"admin.html",
|
||||
{
|
||||
"request": request,
|
||||
"current_user": current_user,
|
||||
"show_all": request.session.get("show_all", False),
|
||||
"error": str(exc),
|
||||
@@ -442,9 +516,9 @@ def admin_test_send(
|
||||
mail_accounts = [a for a in accounts if a.birthday_mail_to]
|
||||
if not mail_accounts:
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"admin.html",
|
||||
{
|
||||
"request": request,
|
||||
"current_user": current_user,
|
||||
"show_all": request.session.get("show_all", False),
|
||||
"error": "Keine Accounts mit birthday_mail_to konfiguriert",
|
||||
@@ -456,9 +530,9 @@ def admin_test_send(
|
||||
target = db.get_most_common_birthday(conn)
|
||||
if target is None:
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"admin.html",
|
||||
{
|
||||
"request": request,
|
||||
"current_user": current_user,
|
||||
"show_all": request.session.get("show_all", False),
|
||||
"error": "Keine Kontakte mit Geburtstag in der Datenbank",
|
||||
@@ -482,9 +556,9 @@ def admin_test_send(
|
||||
|
||||
if errors:
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"admin.html",
|
||||
{
|
||||
"request": request,
|
||||
"current_user": current_user,
|
||||
"show_all": request.session.get("show_all", False),
|
||||
"error": f"Versand fehlgeschlagen: {'; '.join(errors)}",
|
||||
@@ -493,9 +567,9 @@ def admin_test_send(
|
||||
)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"admin.html",
|
||||
{
|
||||
"request": request,
|
||||
"current_user": current_user,
|
||||
"show_all": request.session.get("show_all", False),
|
||||
"success": f"Test-Mails für {target.strftime('%d.%m.%Y')} gesendet ({sent_count} Accounts).",
|
||||
@@ -515,6 +589,7 @@ def web_search_special(
|
||||
"no_photo": db.search_contacts_without_photo,
|
||||
"no_city": db.search_contacts_without_city,
|
||||
"no_social": db.search_contacts_without_social,
|
||||
"last_updated": db.search_contacts_last_updated,
|
||||
}.get(type)
|
||||
|
||||
if not query_fn:
|
||||
@@ -524,6 +599,7 @@ def web_search_special(
|
||||
"no_photo": "Kontakte ohne Bild",
|
||||
"no_city": "Kontakte ohne Stadt",
|
||||
"no_social": "Kontakte ohne Social Profil",
|
||||
"last_updated": "Zuletzt aktualisiert",
|
||||
}
|
||||
|
||||
with db.get_connection() as conn:
|
||||
@@ -535,9 +611,9 @@ def web_search_special(
|
||||
row["full_name"] = db._build_full_name(row)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"index.html",
|
||||
{
|
||||
"request": request,
|
||||
"current_user": current_user,
|
||||
"is_admin": is_admin,
|
||||
"show_all": show_all,
|
||||
@@ -595,9 +671,9 @@ def web_search(
|
||||
row["full_name"] = db._build_full_name(row)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"index.html",
|
||||
{
|
||||
"request": request,
|
||||
"current_user": current_user,
|
||||
"is_admin": is_admin,
|
||||
"show_all": show_all,
|
||||
@@ -625,7 +701,7 @@ def web_contact(
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
f"""SELECT id, account, uid, full_name, prefix, given_name, middle_name, family_name, suffix, organization,
|
||||
job_title, birthday, notes, photo_url, emails, phones, addresses, urls, social_profiles, categories, updated_at
|
||||
job_title, birthday, notes, photo_url, emails, phones, addresses, urls, social_profiles, related_names, categories, updated_at
|
||||
FROM contacts {where_clause} {id_clause}""",
|
||||
params + [contact_id],
|
||||
)
|
||||
@@ -639,6 +715,7 @@ def web_contact(
|
||||
group_names = [g["name"] for g in groups if g.get("name")]
|
||||
|
||||
contact = _row_to_contact_out(row, group_names=group_names)
|
||||
_enrich_related_names(conn, contact)
|
||||
|
||||
homecity = ""
|
||||
workcity = ""
|
||||
@@ -653,12 +730,14 @@ def web_contact(
|
||||
workcity = city
|
||||
|
||||
custom_links = []
|
||||
chat_sender_name = ""
|
||||
contact_account = contact.get("account")
|
||||
if contact_account:
|
||||
accounts = Config.load_accounts()
|
||||
for acc in accounts:
|
||||
if acc.name == contact_account:
|
||||
custom_links = acc.custom_links
|
||||
chat_sender_name = acc.chat_sender_name
|
||||
break
|
||||
|
||||
resolved_links = []
|
||||
@@ -674,9 +753,9 @@ def web_contact(
|
||||
resolved_links.append({"label": link["label"], "url": url})
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"contact.html",
|
||||
{
|
||||
"request": request,
|
||||
"current_user": current_user,
|
||||
"is_admin": is_admin,
|
||||
"show_all": show_all,
|
||||
@@ -684,5 +763,8 @@ def web_contact(
|
||||
"groups": groups,
|
||||
"search": search or "",
|
||||
"custom_links": resolved_links,
|
||||
"chat_enabled": Config.CHATAPI_ENABLED,
|
||||
"chat_sender_name": chat_sender_name,
|
||||
"contact_id": contact_id,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -25,6 +25,7 @@ class ContactOut(BaseModel):
|
||||
addresses: list
|
||||
urls: list
|
||||
social_profiles: list
|
||||
related_names: list
|
||||
categories: list
|
||||
groups: list[str] = []
|
||||
updated_at: str
|
||||
|
||||
@@ -92,5 +92,6 @@
|
||||
<a href="/search/special?type=no_photo" class="special-search-link">ohne Bild</a>
|
||||
<a href="/search/special?type=no_city" class="special-search-link">ohne City</a>
|
||||
<a href="/search/special?type=no_social" class="special-search-link">ohne Social</a>
|
||||
<a href="/search/special?type=last_updated" class="special-search-link">last updated</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -320,6 +320,146 @@
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-section {
|
||||
margin-top: 1.5rem;
|
||||
border-top: 1px solid #eee;
|
||||
padding-top: 1.5rem;
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.chat-msg {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 75%;
|
||||
}
|
||||
|
||||
.chat-msg.own {
|
||||
align-self: flex-end;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.chat-msg.other {
|
||||
align-self: flex-start;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.chat-msg-sender {
|
||||
font-size: 0.65rem;
|
||||
color: #888;
|
||||
margin-bottom: 0.15rem;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.chat-msg-bubble {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.chat-msg-bubble a {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.chat-msg.own .chat-msg-bubble a {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.chat-msg.own .chat-msg-bubble {
|
||||
background: #0066cc;
|
||||
color: #fff;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
.chat-msg.other .chat-msg-bubble {
|
||||
background: #e9ecef;
|
||||
color: #333;
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
.chat-msg-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.2rem;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.chat-msg-time {
|
||||
font-size: 0.65rem;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.chat-msg-platform {
|
||||
font-size: 0.55rem;
|
||||
padding: 0.05rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
background: #f0f0f0;
|
||||
color: #888;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.chat-msg-type {
|
||||
font-size: 0.65rem;
|
||||
color: #999;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.chat-loading {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
color: #888;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.chat-empty {
|
||||
text-align: center;
|
||||
padding: 1.5rem;
|
||||
color: #888;
|
||||
font-style: italic;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.chat-end {
|
||||
text-align: center;
|
||||
padding: 0.5rem;
|
||||
color: #aaa;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.chat-reactions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
margin-top: 0.25rem;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.chat-reaction {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
padding: 0.1rem 0.35rem;
|
||||
background: rgba(0,0,0,0.05);
|
||||
border-radius: 10px;
|
||||
font-size: 0.65rem;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.chat-reaction-emoji {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -436,6 +576,22 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if contact.related_names %}
|
||||
<div class="section">
|
||||
<div class="section-title">Beziehungen</div>
|
||||
<div class="tiles-grid">
|
||||
{% for rn in contact.related_names %}
|
||||
<div class="tile">
|
||||
<div class="tile-label">{{ rn.type }}</div>
|
||||
<div class="tile-value">
|
||||
{% if rn.id %}<a href="/contacts/{{ rn.id }}">{{ rn.name }}</a>{% else %}{{ rn.name }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if contact.categories %}
|
||||
<div class="section">
|
||||
<div class="section-title">Kategorien</div>
|
||||
@@ -464,6 +620,17 @@
|
||||
<div class="notes-content">{{ contact.notes }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if chat_enabled %}
|
||||
<div class="chat-section">
|
||||
<div class="section-title">Nachrichten</div>
|
||||
<div id="chat-messages" class="chat-messages">
|
||||
<div id="chat-loading" class="chat-loading" style="display:none">Lade Nachrichten…</div>
|
||||
<div id="chat-end" class="chat-end" style="display:none"></div>
|
||||
<div id="chat-sentinel" style="height:1px"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if custom_links %}
|
||||
@@ -485,5 +652,127 @@
|
||||
<img src="{{ contact.photo_url }}" alt="Foto" onerror="this.parentElement.close()">
|
||||
</dialog>
|
||||
{% endif %}
|
||||
|
||||
{% if chat_enabled %}
|
||||
<script>
|
||||
(function() {
|
||||
const contactId = {{ contact_id }};
|
||||
const ownName = {{ chat_sender_name|tojson }};
|
||||
const chatBox = document.getElementById("chat-messages");
|
||||
const loading = document.getElementById("chat-loading");
|
||||
const endEl = document.getElementById("chat-end");
|
||||
const sentinel = document.getElementById("chat-sentinel");
|
||||
let offset = 0;
|
||||
const batchSize = 50;
|
||||
let total = null;
|
||||
let loading_ = false;
|
||||
|
||||
function fmtTime(ms) {
|
||||
const d = new Date(ms);
|
||||
const pad = n => String(n).padStart(2, "0");
|
||||
return pad(d.getDate()) + "." + pad(d.getMonth()+1) + "." + d.getFullYear() +
|
||||
" " + pad(d.getHours()) + ":" + pad(d.getMinutes());
|
||||
}
|
||||
|
||||
function norm(s) {
|
||||
return s.toLowerCase()
|
||||
.replace(/ä/g, "ae").replace(/ö/g, "oe").replace(/ü/g, "ue")
|
||||
.replace(/ß/g, "ss").replace(/é/g, "e").replace(/è/g, "e")
|
||||
.replace(/ê/g, "e").replace(/ë/g, "e").replace(/á/g, "a")
|
||||
.replace(/à/g, "a").replace(/â/g, "a").replace(/í/g, "i")
|
||||
.replace(/ì/g, "i").replace(/î/g, "i").replace(/ó/g, "o")
|
||||
.replace(/ò/g, "o").replace(/ô/g, "o").replace(/ú/g, "u")
|
||||
.replace(/ù/g, "u").replace(/û/g, "u");
|
||||
}
|
||||
|
||||
const ownNorm = norm(ownName);
|
||||
|
||||
function linkify(text) {
|
||||
return text.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener">$1</a>');
|
||||
}
|
||||
|
||||
function renderMsg(m) {
|
||||
const isOwn = norm(m.sender_name) === ownNorm;
|
||||
const div = document.createElement("div");
|
||||
div.className = "chat-msg " + (isOwn ? "own" : "other");
|
||||
|
||||
let html = "";
|
||||
if (!isOwn && total !== null && total > batchSize) {
|
||||
html += '<div class="chat-msg-sender">' + escHtml(m.sender_name) + '</div>';
|
||||
}
|
||||
const text = m.content ? linkify(escHtml(m.content)) : '<span class="chat-msg-type">[' + m.message_type + ']</span>';
|
||||
html += '<div class="chat-msg-bubble">' + text + '</div>';
|
||||
if (m.reactions && m.reactions.length > 0) {
|
||||
html += '<div class="chat-reactions">';
|
||||
for (const r of m.reactions) {
|
||||
html += '<span class="chat-reaction"><span class="chat-reaction-emoji">' + escHtml(r.reaction || r.emoji || "") + '</span>' + (r.user ? " " + escHtml(r.user) : "") + '</span>';
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
html += '<div class="chat-msg-meta">';
|
||||
html += '<span class="chat-msg-time">' + fmtTime(m.timestamp_ms) + '</span>';
|
||||
if (m.platform) {
|
||||
html += '<span class="chat-msg-platform">' + escHtml(m.platform) + '</span>';
|
||||
}
|
||||
html += '</div>';
|
||||
div.innerHTML = html;
|
||||
return div;
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
const el = document.createElement("span");
|
||||
el.textContent = s;
|
||||
return el.innerHTML;
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (loading_) return;
|
||||
loading_ = true;
|
||||
loading.style.display = "";
|
||||
try {
|
||||
const resp = await fetch("/api/contacts/" + contactId + "/messages?offset=" + offset + "&limit=" + batchSize);
|
||||
if (!resp.ok) {
|
||||
loading.style.display = "none";
|
||||
loading_ = false;
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
if (total === null) total = data.total;
|
||||
loading.style.display = "none";
|
||||
if (!data.messages || data.messages.length === 0) {
|
||||
if (total === 0) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "chat-empty";
|
||||
empty.textContent = "Keine Nachrichten gefunden";
|
||||
chatBox.insertBefore(empty, sentinel);
|
||||
} else {
|
||||
endEl.textContent = "Alle Nachrichten geladen";
|
||||
endEl.style.display = "";
|
||||
}
|
||||
sentinel.style.display = "none";
|
||||
return;
|
||||
}
|
||||
for (const m of data.messages) {
|
||||
chatBox.insertBefore(renderMsg(m), sentinel);
|
||||
}
|
||||
offset += data.messages.length;
|
||||
if (offset >= total) {
|
||||
endEl.textContent = "Alle " + total + " Nachrichten geladen";
|
||||
endEl.style.display = "";
|
||||
sentinel.style.display = "none";
|
||||
}
|
||||
} catch(e) {
|
||||
loading.style.display = "none";
|
||||
}
|
||||
loading_ = false;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(entries => {
|
||||
if (entries[0].isIntersecting && !loading_) loadMore();
|
||||
}, { root: chatBox, rootMargin: "200px" });
|
||||
observer.observe(sentinel);
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+8
-2
@@ -14,7 +14,7 @@ ICLOUD_BASE_URL = "https://contacts.icloud.com/"
|
||||
class Account:
|
||||
def __init__(self, name: str, apple_email: str, apple_app_password: str, authelia_user: str | None,
|
||||
custom_links: list[dict] | None = None, healthcheck_url: str = "",
|
||||
birthday_mail_to: str | None = None):
|
||||
birthday_mail_to: str | None = None, chat_sender_name: str = ""):
|
||||
self.name = name
|
||||
self.apple_email = apple_email
|
||||
self.apple_app_password = apple_app_password
|
||||
@@ -22,6 +22,7 @@ class Account:
|
||||
self.custom_links = custom_links or []
|
||||
self.healthcheck_url = healthcheck_url
|
||||
self.birthday_mail_to = birthday_mail_to
|
||||
self.chat_sender_name = chat_sender_name
|
||||
|
||||
|
||||
class Config:
|
||||
@@ -50,6 +51,10 @@ class Config:
|
||||
# vorgeschalteten nginx/traefik als Remote-User weitergereicht wird.
|
||||
AUTH_REMOTE_USER_HEADER = os.environ.get("AUTH_REMOTE_USER_HEADER", "Remote-User")
|
||||
|
||||
CHATAPI_ENABLED = os.environ.get("CHATAPI_ENABLED", "false").lower() == "true"
|
||||
CHATAPI_URL = os.environ.get("CHATAPI_URL", "")
|
||||
CHATAPI_KEY = os.environ.get("CHATAPI_KEY", "")
|
||||
|
||||
API_HOST = os.environ.get("API_HOST", "0.0.0.0")
|
||||
API_PORT = int(os.environ.get("API_PORT", "8000"))
|
||||
WEB_URL = os.environ.get("WEB_URL", "")
|
||||
@@ -104,7 +109,8 @@ class Config:
|
||||
custom_links = entry.get("custom_links", [])
|
||||
healthcheck_url = entry.get("healthcheck_url", "")
|
||||
birthday_mail_to = entry.get("birthday_mail_to") or None
|
||||
accounts.append(Account(name, email, pwd, authelia_user, custom_links, healthcheck_url, birthday_mail_to))
|
||||
chat_sender_name = entry.get("chat_sender_name", "")
|
||||
accounts.append(Account(name, email, pwd, authelia_user, custom_links, healthcheck_url, birthday_mail_to, chat_sender_name))
|
||||
return accounts
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -232,6 +232,24 @@ def search_contacts_without_social(conn, account: str | None) -> list[dict]:
|
||||
return cur.fetchall()
|
||||
|
||||
|
||||
def search_contacts_last_updated(conn, account: str | None) -> list[dict]:
|
||||
where_clause, params = _account_filter_clause(account)
|
||||
op = "AND" if where_clause else "WHERE"
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
f"""SELECT id, full_name, given_name, middle_name, family_name,
|
||||
prefix, suffix, organization, birthday, account, photo_url
|
||||
FROM contacts {where_clause}
|
||||
{op} given_name IS NOT NULL AND given_name != ''
|
||||
AND family_name IS NOT NULL AND family_name != ''
|
||||
AND family_name != 'X'
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 200""",
|
||||
params,
|
||||
)
|
||||
return cur.fetchall()
|
||||
|
||||
|
||||
def get_most_common_birthday(conn) -> date | None:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
@@ -383,6 +401,31 @@ def get_groups_for_contact(conn, account: str, member_uid: str) -> list[dict]:
|
||||
return cur.fetchall()
|
||||
|
||||
|
||||
def resolve_related_names(conn, account: str, names: list[str]) -> dict[str, dict]:
|
||||
if not names:
|
||||
return {}
|
||||
conditions = []
|
||||
params: list = [account]
|
||||
for name in names:
|
||||
conditions.append("full_name = %s")
|
||||
params.append(name)
|
||||
parts = name.strip().split()
|
||||
if len(parts) >= 2:
|
||||
conditions.append("(given_name = %s AND family_name = %s)")
|
||||
params.extend([parts[0], parts[-1]])
|
||||
placeholders = " OR ".join(conditions)
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
f"SELECT full_name, id, given_name, family_name FROM contacts WHERE account = %s AND ({placeholders})",
|
||||
params,
|
||||
)
|
||||
result = {}
|
||||
for row in cur.fetchall():
|
||||
fn = row["full_name"] or f"{row.get('given_name') or ''} {row.get('family_name') or ''}".strip()
|
||||
result[fn] = {"id": row["id"], "name": fn}
|
||||
return result
|
||||
|
||||
|
||||
def get_group_count(conn, account: str | None) -> int:
|
||||
where_clause, params = _account_filter_clause(account)
|
||||
with conn.cursor() as cur:
|
||||
|
||||
+4
-1
@@ -69,6 +69,9 @@ def build_message(account_name: str, birthdays: list[dict], target_date: date |
|
||||
msg.set_content("Heute hat niemand aus deinen Kontakten Geburtstag.")
|
||||
return msg
|
||||
|
||||
if len(birthdays) == 1:
|
||||
msg["Subject"] = f"Geburtstage heute ({today.isoformat()}): {birthdays[0]['full_name']}"
|
||||
else:
|
||||
msg["Subject"] = f"Geburtstage heute ({today.isoformat()}): {len(birthdays)}"
|
||||
|
||||
plain_lines = [f"Heutige Geburtstage ({today.isoformat()}):", ""]
|
||||
@@ -154,7 +157,7 @@ def build_message(account_name: str, birthdays: list[dict], target_date: date |
|
||||
<tr>
|
||||
<td style="padding:16px;">
|
||||
<h1 style="font-size:20px;font-weight:600;color:#222;margin:0;">Geburtstage heute</h1>
|
||||
<p style="font-size:13px;color:#666;margin:8px 0 16px 0;">{today.strftime('%d.%m.%Y')} · {len(birthdays)} Kontakte</p>
|
||||
<p style="font-size:13px;color:#666;margin:8px 0 16px 0;">{today.strftime('%d.%m.%Y')} · {len(birthdays)} Kontakt{"e" if len(birthdays) != 1 else ""}</p>
|
||||
<table width="100%" cellpadding="0" cellspacing="0">
|
||||
{cards_html}
|
||||
</table>
|
||||
|
||||
@@ -107,6 +107,23 @@ def main():
|
||||
last_sync = datetime.now(Config.TIMEZONE)
|
||||
next_mailer = next_run_time(MAIL_SEND_HOUR)
|
||||
|
||||
if MAILER_ENABLED:
|
||||
try:
|
||||
today = datetime.now(Config.TIMEZONE).date()
|
||||
with db.get_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT 1 FROM birthday_mail_log WHERE sent_date = %s LIMIT 1",
|
||||
(today,),
|
||||
)
|
||||
already_sent = cur.fetchone() is not None
|
||||
if not already_sent and next_mailer.date() > today:
|
||||
logger.info("Mailer fuer heute noch nicht gesendet — hole nach")
|
||||
run_mailer()
|
||||
next_mailer = next_run_time(MAIL_SEND_HOUR)
|
||||
except Exception:
|
||||
logger.exception("Pruefung/Nachholen des Mailers fehlgeschlagen")
|
||||
|
||||
while not _shutdown:
|
||||
now = datetime.now(Config.TIMEZONE)
|
||||
|
||||
|
||||
+33
-1
@@ -124,6 +124,38 @@ def parse_vcard(raw_text: str, account: str, etag: str | None = None) -> dict |
|
||||
|
||||
categories = [c.strip() for c in vcard.categories.value] if hasattr(vcard, "categories") else []
|
||||
|
||||
_APPLE_LABEL_MAP = {
|
||||
"father": "Vater", "mother": "Mutter", "parent": "Elternteil",
|
||||
"son": "Sohn", "daughter": "Tochter", "child": "Kind",
|
||||
"spouse": "Ehepartner", "wife": "Ehefrau", "husband": "Ehemann",
|
||||
"partner": "Partner", "sibling": "Geschwister", "brother": "Bruder", "sister": "Schwester",
|
||||
"friend": "Freund", "femalefriend": "Freundin", "malefriend": "Freund",
|
||||
"colleague": "Kollege", "coworker": "Mitarbeiter",
|
||||
"manager": "Vorgesetzter", "assistant": "Assistent",
|
||||
"related": "Verwandter", "other": "Sonstige",
|
||||
}
|
||||
|
||||
related_names = []
|
||||
abrelated = vcard.contents.get("x-abrelatednames", [])
|
||||
ablabels = vcard.contents.get("x-ablabel", [])
|
||||
labels_by_group = {}
|
||||
for lbl in ablabels:
|
||||
labels_by_group[getattr(lbl, "group", "")] = lbl.value
|
||||
for r in abrelated:
|
||||
group = getattr(r, "group", "")
|
||||
raw_label = labels_by_group.get(group, "")
|
||||
if raw_label.startswith("_$!<") and raw_label.endswith(">!$_"):
|
||||
key = raw_label[4:-4].lower()
|
||||
label = _APPLE_LABEL_MAP.get(key, key.capitalize())
|
||||
elif raw_label:
|
||||
label = raw_label
|
||||
else:
|
||||
label = "Sonstige"
|
||||
related_names.append({
|
||||
"type": label,
|
||||
"value": r.value if r.value else "",
|
||||
})
|
||||
|
||||
photo_url = None
|
||||
photo_base64 = None
|
||||
if hasattr(vcard, "photo"):
|
||||
@@ -168,7 +200,7 @@ def parse_vcard(raw_text: str, account: str, etag: str | None = None) -> dict |
|
||||
"addresses": json.dumps(addresses, ensure_ascii=False),
|
||||
"urls": json.dumps(urls, ensure_ascii=False),
|
||||
"social_profiles": json.dumps(social_profiles, ensure_ascii=False),
|
||||
"related_names": json.dumps([], ensure_ascii=False),
|
||||
"related_names": json.dumps(related_names, ensure_ascii=False),
|
||||
"categories": json.dumps(categories, ensure_ascii=False),
|
||||
"raw_vcard": raw_text,
|
||||
"source": "icloud",
|
||||
|
||||
Reference in New Issue
Block a user