mirror of
https://github.com/skoelle/icloud-contacts-sync.git
synced 2026-09-18 07:50:25 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
debaf7dd62 | ||
|
|
00e4e54599 | ||
|
|
3edcc99c61 | ||
|
|
7b1acbb8bb | ||
|
|
af124d803f | ||
|
|
a05a8da3f1 | ||
|
|
e966157f15 | ||
|
|
2dd93d8d68 | ||
|
|
fcaa74f774 | ||
|
|
28bfbc01bf |
@@ -18,21 +18,21 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Log in to GHCR
|
- name: Log in to GHCR
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Extract metadata
|
- name: Extract metadata
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v6
|
||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
tags: |
|
tags: |
|
||||||
@@ -40,7 +40,7 @@ jobs:
|
|||||||
type=raw,value=latest,enable={{is_default_branch}}
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v7
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
.env
|
.env
|
||||||
config/accounts.json
|
config/accounts.json
|
||||||
.venv/
|
.venv/
|
||||||
|
.venv-demo/
|
||||||
|
demo.db
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ config/
|
|||||||
accounts.json — Per-account credentials (NOT in git, volume-mounted)
|
accounts.json — Per-account credentials (NOT in git, volume-mounted)
|
||||||
docker/
|
docker/
|
||||||
entrypoint.sh — Starts scheduler.py (or exec's custom command)
|
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
|
## Key Files
|
||||||
@@ -50,6 +52,8 @@ docker/
|
|||||||
| `src/db.py` | All MariaDB queries |
|
| `src/db.py` | All MariaDB queries |
|
||||||
| `sql/schema.sql` | Canonical schema definition |
|
| `sql/schema.sql` | Canonical schema definition |
|
||||||
| `.env.example` | All supported environment variables |
|
| `.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
|
## Commands
|
||||||
|
|
||||||
@@ -83,6 +87,14 @@ docker compose build
|
|||||||
docker compose up -d
|
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
|
## Code Conventions
|
||||||
|
|
||||||
- All source in `src/`, single package, no `setup.py`/`pyproject.toml`.
|
- All source in `src/`, single package, no `setup.py`/`pyproject.toml`.
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ Synct alle Kontakte mehrerer iCloud-Accounts per CardDAV Delta-Sync
|
|||||||
Geburtstage. Für den vollständigen technischen Hintergrund siehe
|
Geburtstage. Für den vollständigen technischen Hintergrund siehe
|
||||||
[SPEC.md](./SPEC.md).
|
[SPEC.md](./SPEC.md).
|
||||||
|
|
||||||
|
[](docs/screenshot1.png)
|
||||||
|
[](docs/screenshot2.png)
|
||||||
|
|
||||||
|
|
||||||
## Voraussetzungen
|
## Voraussetzungen
|
||||||
|
|
||||||
- Eine oder mehrere Apple-IDs mit aktivierter Zwei-Faktor-Authentifizierung.
|
- Eine oder mehrere Apple-IDs mit aktivierter Zwei-Faktor-Authentifizierung.
|
||||||
@@ -140,7 +144,37 @@ python3 sync.py
|
|||||||
python3 mailer.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
|
- Jeder Push auf `main` baut automatisch ein neues Image und pusht es
|
||||||
nach `ghcr.io/<owner>/icloud-contacts-sync`.
|
nach `ghcr.io/<owner>/icloud-contacts-sync`.
|
||||||
|
|||||||
@@ -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"
|
||||||
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
|
requests==2.34.2
|
||||||
vobject==0.9.6.1
|
vobject==0.9.9
|
||||||
PyMySQL==1.1.1
|
PyMySQL==1.2.0
|
||||||
lxml==5.3.0
|
lxml==5.4.0
|
||||||
python-dotenv==1.0.1
|
python-dotenv==1.2.2
|
||||||
fastapi==0.115.0
|
fastapi==0.141.1
|
||||||
uvicorn[standard]==0.32.0
|
uvicorn[standard]==0.52.1
|
||||||
jinja2==3.1.4
|
jinja2==3.1.6
|
||||||
pydantic==2.9.2
|
pydantic==2.13.4
|
||||||
itsdangerous==2.2.0
|
itsdangerous==2.2.0
|
||||||
|
|||||||
+36
-14
@@ -56,7 +56,7 @@ def _fmt_ts(dt) -> str | None:
|
|||||||
|
|
||||||
def _row_to_contact_out(row: dict, group_names: list[str] | None = None) -> dict:
|
def _row_to_contact_out(row: dict, group_names: list[str] | None = None) -> dict:
|
||||||
row = dict(row)
|
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)
|
raw = row.get(field)
|
||||||
row[field] = json.loads(raw) if raw else []
|
row[field] = json.loads(raw) if raw else []
|
||||||
if not row.get("full_name"):
|
if not row.get("full_name"):
|
||||||
@@ -66,6 +66,25 @@ def _row_to_contact_out(row: dict, group_names: list[str] | None = None) -> dict
|
|||||||
return row
|
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]:
|
def _account_filter_clause(account_name: str | None) -> tuple[str, list]:
|
||||||
if account_name is None:
|
if account_name is None:
|
||||||
return "", []
|
return "", []
|
||||||
@@ -134,7 +153,7 @@ def get_contact(contact_id: int, current_user: str = Depends(get_current_user)):
|
|||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
f"""SELECT id, account, uid, full_name, prefix, given_name, middle_name, family_name, suffix, organization,
|
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}""",
|
FROM contacts {where_clause} {id_clause}""",
|
||||||
params + [contact_id],
|
params + [contact_id],
|
||||||
)
|
)
|
||||||
@@ -146,7 +165,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"])
|
groups = db.get_groups_for_contact(conn, row["account"], row["uid"])
|
||||||
group_names = [g["name"] for g in groups if g.get("name")]
|
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])
|
@app.get("/api/contacts/birthdays/today", response_model=list[ContactOut])
|
||||||
@@ -363,9 +384,9 @@ def web_dashboard(
|
|||||||
last_sync_with_changes = None
|
last_sync_with_changes = None
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
"dashboard.html",
|
"dashboard.html",
|
||||||
{
|
{
|
||||||
"request": request,
|
|
||||||
"current_user": current_user,
|
"current_user": current_user,
|
||||||
"is_admin": is_admin,
|
"is_admin": is_admin,
|
||||||
"show_all": show_all,
|
"show_all": show_all,
|
||||||
@@ -392,9 +413,9 @@ def web_admin(
|
|||||||
|
|
||||||
show_all = request.session.get("show_all", False)
|
show_all = request.session.get("show_all", False)
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
"admin.html",
|
"admin.html",
|
||||||
{
|
{
|
||||||
"request": request,
|
|
||||||
"current_user": current_user,
|
"current_user": current_user,
|
||||||
"show_all": show_all,
|
"show_all": show_all,
|
||||||
},
|
},
|
||||||
@@ -428,9 +449,9 @@ def admin_test_send(
|
|||||||
Config.validate_mailer()
|
Config.validate_mailer()
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
"admin.html",
|
"admin.html",
|
||||||
{
|
{
|
||||||
"request": request,
|
|
||||||
"current_user": current_user,
|
"current_user": current_user,
|
||||||
"show_all": request.session.get("show_all", False),
|
"show_all": request.session.get("show_all", False),
|
||||||
"error": str(exc),
|
"error": str(exc),
|
||||||
@@ -442,9 +463,9 @@ def admin_test_send(
|
|||||||
mail_accounts = [a for a in accounts if a.birthday_mail_to]
|
mail_accounts = [a for a in accounts if a.birthday_mail_to]
|
||||||
if not mail_accounts:
|
if not mail_accounts:
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
"admin.html",
|
"admin.html",
|
||||||
{
|
{
|
||||||
"request": request,
|
|
||||||
"current_user": current_user,
|
"current_user": current_user,
|
||||||
"show_all": request.session.get("show_all", False),
|
"show_all": request.session.get("show_all", False),
|
||||||
"error": "Keine Accounts mit birthday_mail_to konfiguriert",
|
"error": "Keine Accounts mit birthday_mail_to konfiguriert",
|
||||||
@@ -456,9 +477,9 @@ def admin_test_send(
|
|||||||
target = db.get_most_common_birthday(conn)
|
target = db.get_most_common_birthday(conn)
|
||||||
if target is None:
|
if target is None:
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
"admin.html",
|
"admin.html",
|
||||||
{
|
{
|
||||||
"request": request,
|
|
||||||
"current_user": current_user,
|
"current_user": current_user,
|
||||||
"show_all": request.session.get("show_all", False),
|
"show_all": request.session.get("show_all", False),
|
||||||
"error": "Keine Kontakte mit Geburtstag in der Datenbank",
|
"error": "Keine Kontakte mit Geburtstag in der Datenbank",
|
||||||
@@ -482,9 +503,9 @@ def admin_test_send(
|
|||||||
|
|
||||||
if errors:
|
if errors:
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
"admin.html",
|
"admin.html",
|
||||||
{
|
{
|
||||||
"request": request,
|
|
||||||
"current_user": current_user,
|
"current_user": current_user,
|
||||||
"show_all": request.session.get("show_all", False),
|
"show_all": request.session.get("show_all", False),
|
||||||
"error": f"Versand fehlgeschlagen: {'; '.join(errors)}",
|
"error": f"Versand fehlgeschlagen: {'; '.join(errors)}",
|
||||||
@@ -493,9 +514,9 @@ def admin_test_send(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
"admin.html",
|
"admin.html",
|
||||||
{
|
{
|
||||||
"request": request,
|
|
||||||
"current_user": current_user,
|
"current_user": current_user,
|
||||||
"show_all": request.session.get("show_all", False),
|
"show_all": request.session.get("show_all", False),
|
||||||
"success": f"Test-Mails für {target.strftime('%d.%m.%Y')} gesendet ({sent_count} Accounts).",
|
"success": f"Test-Mails für {target.strftime('%d.%m.%Y')} gesendet ({sent_count} Accounts).",
|
||||||
@@ -535,9 +556,9 @@ def web_search_special(
|
|||||||
row["full_name"] = db._build_full_name(row)
|
row["full_name"] = db._build_full_name(row)
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
"index.html",
|
"index.html",
|
||||||
{
|
{
|
||||||
"request": request,
|
|
||||||
"current_user": current_user,
|
"current_user": current_user,
|
||||||
"is_admin": is_admin,
|
"is_admin": is_admin,
|
||||||
"show_all": show_all,
|
"show_all": show_all,
|
||||||
@@ -595,9 +616,9 @@ def web_search(
|
|||||||
row["full_name"] = db._build_full_name(row)
|
row["full_name"] = db._build_full_name(row)
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
"index.html",
|
"index.html",
|
||||||
{
|
{
|
||||||
"request": request,
|
|
||||||
"current_user": current_user,
|
"current_user": current_user,
|
||||||
"is_admin": is_admin,
|
"is_admin": is_admin,
|
||||||
"show_all": show_all,
|
"show_all": show_all,
|
||||||
@@ -625,7 +646,7 @@ def web_contact(
|
|||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
f"""SELECT id, account, uid, full_name, prefix, given_name, middle_name, family_name, suffix, organization,
|
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}""",
|
FROM contacts {where_clause} {id_clause}""",
|
||||||
params + [contact_id],
|
params + [contact_id],
|
||||||
)
|
)
|
||||||
@@ -639,6 +660,7 @@ def web_contact(
|
|||||||
group_names = [g["name"] for g in groups if g.get("name")]
|
group_names = [g["name"] for g in groups if g.get("name")]
|
||||||
|
|
||||||
contact = _row_to_contact_out(row, group_names=group_names)
|
contact = _row_to_contact_out(row, group_names=group_names)
|
||||||
|
_enrich_related_names(conn, contact)
|
||||||
|
|
||||||
homecity = ""
|
homecity = ""
|
||||||
workcity = ""
|
workcity = ""
|
||||||
@@ -674,9 +696,9 @@ def web_contact(
|
|||||||
resolved_links.append({"label": link["label"], "url": url})
|
resolved_links.append({"label": link["label"], "url": url})
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
"contact.html",
|
"contact.html",
|
||||||
{
|
{
|
||||||
"request": request,
|
|
||||||
"current_user": current_user,
|
"current_user": current_user,
|
||||||
"is_admin": is_admin,
|
"is_admin": is_admin,
|
||||||
"show_all": show_all,
|
"show_all": show_all,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ class ContactOut(BaseModel):
|
|||||||
addresses: list
|
addresses: list
|
||||||
urls: list
|
urls: list
|
||||||
social_profiles: list
|
social_profiles: list
|
||||||
|
related_names: list
|
||||||
categories: list
|
categories: list
|
||||||
groups: list[str] = []
|
groups: list[str] = []
|
||||||
updated_at: str
|
updated_at: str
|
||||||
|
|||||||
@@ -436,6 +436,22 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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 %}
|
{% if contact.categories %}
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="section-title">Kategorien</div>
|
<div class="section-title">Kategorien</div>
|
||||||
|
|||||||
@@ -383,6 +383,31 @@ def get_groups_for_contact(conn, account: str, member_uid: str) -> list[dict]:
|
|||||||
return cur.fetchall()
|
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:
|
def get_group_count(conn, account: str | None) -> int:
|
||||||
where_clause, params = _account_filter_clause(account)
|
where_clause, params = _account_filter_clause(account)
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
|
|||||||
+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 []
|
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_url = None
|
||||||
photo_base64 = None
|
photo_base64 = None
|
||||||
if hasattr(vcard, "photo"):
|
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),
|
"addresses": json.dumps(addresses, ensure_ascii=False),
|
||||||
"urls": json.dumps(urls, ensure_ascii=False),
|
"urls": json.dumps(urls, ensure_ascii=False),
|
||||||
"social_profiles": json.dumps(social_profiles, 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),
|
"categories": json.dumps(categories, ensure_ascii=False),
|
||||||
"raw_vcard": raw_text,
|
"raw_vcard": raw_text,
|
||||||
"source": "icloud",
|
"source": "icloud",
|
||||||
|
|||||||
Reference in New Issue
Block a user