feat: iCloud-Kontaktgruppen synchronisieren und API-Endpunkte hinzufügen

Erkennt iCloud-Gruppen (vCards mit X-ADDRESSBOOKSERVER-KIND:group) beim
CardDAV-Sync und speichert sie separat in den neuen Tabellen groups und
group_members statt als Kontakte in die contacts-Tabelle.

Änderungen:
- sql/schema.sql: Neue Tabellen groups + group_members (FK CASCADE)
- src/vcard_parser.py: is_group_vcard() + parse_group() Funktionen
- src/db.py: DB-Funktionen für Gruppen (upsert, delete, replace, get)
- src/sync.py: _classify_vcards() trennt Gruppen von Kontakten
- src/api/schemas.py: Pydantic-Modelle für Gruppen-Responses
- src/api/main.py: 3 neue Endpunkte (/api/groups, /{id}, /{id}/members)
- src/api/templates/contact.html: Gruppen-Section im Kontakt-Detail
- README.md + SPEC.md: Dokumentation aktualisiert
This commit is contained in:
2026-08-07 15:18:44 +02:00
parent 38b03a2c8a
commit 94a279f207
9 changed files with 369 additions and 38 deletions
+23 -2
View File
@@ -148,6 +148,24 @@ python3 mailer.py
aus MariaDB entfernt, ohne Archiv. aus MariaDB entfernt, ohne Archiv.
- Nur iCloud als Quelle, Google/Microsoft sind nicht Teil dieses Repos. - Nur iCloud als Quelle, Google/Microsoft sind nicht Teil dieses Repos.
## Kontaktruppen
iCloud-Länder speichern Gruppen als eigene vCards mit
`X-ADDRESSBOOKSERVER-KIND:group`. Diese werden beim Sync automatisch
erkannt und separat in den Tabellen `groups` und `group_members`
gespeichert (nicht als Kontakte).
Gruppen sind über die API abrufbar:
- `GET /api/groups` — Alle Gruppen mit Member-Anzahl
- `GET /api/groups/{id}` — Gruppe mit aufgelösten Members
- `GET /api/groups/{id}/members` — Nur Members einer Gruppe
- `GET /api/contacts/{id}` — Enthält `groups`-Feld mit Gruppennamen
Wird ein Kontakt gelöscht, wird die Mitgliedschaft in Gruppen
automatisch entfernt (`ON DELETE CASCADE`). Die Gruppe selbst bleibt
erhalten.
## 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 Läuft als zweiter Service aus demselben Image, aber mit anderem
@@ -206,13 +224,16 @@ Zugriff ohne den Reverse-Proxy ist damit nicht möglich.
|---------|------|--------------| |---------|------|--------------|
| `GET` | `/` | Dashboard mit Kontaktdaten-Übersicht, letzten Sync-Status und Geburtstagen der nächsten 7 Tage (HTML) | | `GET` | `/` | Dashboard mit Kontaktdaten-Übersicht, letzten Sync-Status und Geburtstagen der nächsten 7 Tage (HTML) |
| `GET` | `/search` | Web-UI -- Suchfunktion, zeigt Kontakte des eingeloggten Users | | `GET` | `/search` | Web-UI -- Suchfunktion, zeigt Kontakte des eingeloggten Users |
| `GET` | `/contacts/{id}` | Web-UI -- Detailseite eines einzelnen Kontakts | | `GET` | `/contacts/{id}` | Web-UI -- Detailseite eines einzelnen Kontakts (inkl. Gruppen) |
| `GET` | `/api/health` | Health Check (`{"status": "ok"}`), kein Login nötig | | `GET` | `/api/health` | Health Check (`{"status": "ok"}`), kein Login nötig |
| `GET` | `/api/contacts` | Kontaktsuche mit Pagination (`?q=...&limit=...&offset=...`) | | `GET` | `/api/contacts` | Kontaktsuche mit Pagination (`?q=...&limit=...&offset=...`) |
| `GET` | `/api/contacts/{id}` | Einzelnen Kontakt per ID abrufen | | `GET` | `/api/contacts/{id}` | Einzelnen Kontakt per ID abrufen (inkl. `groups`-Feld) |
| `GET` | `/api/contacts/count` | Anzahl der Kontakte des eingeloggten Users | | `GET` | `/api/contacts/count` | Anzahl der Kontakte des eingeloggten Users |
| `GET` | `/api/contacts/birthdays/today` | Heutige Geburtstage | | `GET` | `/api/contacts/birthdays/today` | Heutige Geburtstage |
| `GET` | `/api/contacts/birthdays/upcoming` | Geburtstage der nächsten N Tage (`?days=7`, Default 7) | | `GET` | `/api/contacts/birthdays/upcoming` | Geburtstage der nächsten N Tage (`?days=7`, Default 7) |
| `GET` | `/api/groups` | Gruppenliste mit Member-Anzahl |
| `GET` | `/api/groups/{id}` | Einzelne Gruppe mit aufgelösten Members |
| `GET` | `/api/groups/{id}/members` | Members einer Gruppe (Kontaktdaten) |
| `GET` | `/api/sync-runs` | Letzte 50 Sync-Runs (Status, Zeitstempel, Fehler) | | `GET` | `/api/sync-runs` | Letzte 50 Sync-Runs (Status, Zeitstempel, Fehler) |
Alle Endpunkte (außer `/api/health`) erfordern eine Authentifizierung Alle Endpunkte (außer `/api/health`) erfordern eine Authentifizierung
+9 -1
View File
@@ -92,6 +92,11 @@ Siehe `sql/schema.sql`. Wichtigste Änderungen gegenüber v1:
- Neue Tabelle `birthday_mail_log`: ein Datensatz pro Tag, an dem - Neue Tabelle `birthday_mail_log`: ein Datensatz pro Tag, an dem
erfolgreich eine Geburtstagsmail versendet wurde, verhindert erfolgreich eine Geburtstagsmail versendet wurde, verhindert
Doppelversand bei mehrfachem Container-Neustart am selben Tag. Doppelversand bei mehrfachem Container-Neustart am selben Tag.
- Neue Tabellen `groups` und `group_members`: Speichert
iCloud-Kontaktgruppen (vCards mit `X-ADDRESSBOOKSERVER-KIND:group`)
und deren Mitgliedschaften. Gruppen werden beim Sync erkannt und
nicht als Kontakte in die `contacts`-Tabelle geschrieben.
`group_members` referenziert `groups(id)` mit `ON DELETE CASCADE`.
## 6. Geburtstags-Mailer ## 6. Geburtstags-Mailer
@@ -247,10 +252,13 @@ geteilt wird. Getrennt ist nur die **Rolle**, in der der Container läuft.
| `GET /contacts/{id}` | HTML-Detailseite eines einzelnen Kontakts (Jinja2-Template) | | `GET /contacts/{id}` | HTML-Detailseite eines einzelnen Kontakts (Jinja2-Template) |
| `GET /api/health` | Health-Check ohne Auth-Anforderung | | `GET /api/health` | Health-Check ohne Auth-Anforderung |
| `GET /api/contacts` | Kontaktliste, Filter `q` (Freitext), Pagination `limit`/`offset` | | `GET /api/contacts` | Kontaktliste, Filter `q` (Freitext), Pagination `limit`/`offset` |
| `GET /api/contacts/{id}` | Einzelner Kontakt (JSON) | | `GET /api/contacts/{id}` | Einzelner Kontakt (JSON), inklusive `groups`-Feld mit zugehörigen Gruppennamen |
| `GET /api/contacts/count` | Anzahl der Kontakte des zugeordneten Accounts | | `GET /api/contacts/count` | Anzahl der Kontakte des zugeordneten Accounts |
| `GET /api/contacts/birthdays/today` | Heutige Geburtstage (kontospezifisch bzw. global für Admins) | | `GET /api/contacts/birthdays/today` | Heutige Geburtstage (kontospezifisch bzw. global für Admins) |
| `GET /api/contacts/birthdays/upcoming` | Geburtstage der nächsten N Tage (Parameter `days`, Default 7) | | `GET /api/contacts/birthdays/upcoming` | Geburtstage der nächsten N Tage (Parameter `days`, Default 7) |
| `GET /api/groups` | Gruppenliste mit `member_count`, Pagination `limit`/`offset` |
| `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/sync-runs` | Sync-Historie (kontospezifisch bzw. global für Admins) |
### 12.5 Netzwerkkontext ### 12.5 Netzwerkkontext
+23
View File
@@ -57,6 +57,29 @@ CREATE TABLE IF NOT EXISTS sync_runs (
error_message TEXT NULL error_message TEXT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- iCloud-Kontaktgruppen (vCards mit X-ADDRESSBOOKSERVER-KIND:group)
CREATE TABLE IF NOT EXISTS `groups` (
id INT AUTO_INCREMENT PRIMARY KEY,
account VARCHAR(100) NOT NULL,
uid VARCHAR(255) NOT NULL,
etag VARCHAR(255) NULL,
name VARCHAR(512) NULL,
raw_vcard LONGTEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
last_synced_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
sync_run_id VARCHAR(64) NULL,
UNIQUE KEY uq_groups_account_uid (account, uid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS group_members (
id INT AUTO_INCREMENT PRIMARY KEY,
group_id INT NOT NULL,
member_uid VARCHAR(255) NOT NULL,
FOREIGN KEY (group_id) REFERENCES `groups`(id) ON DELETE CASCADE,
UNIQUE KEY uq_group_member (group_id, member_uid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Protokoll der Geburtstags-Mails, verhindert Doppelversand am selben Tag. -- Protokoll der Geburtstags-Mails, verhindert Doppelversand am selben Tag.
CREATE TABLE IF NOT EXISTS birthday_mail_log ( CREATE TABLE IF NOT EXISTS birthday_mail_log (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
+120 -4
View File
@@ -21,7 +21,13 @@ from starlette.middleware.sessions import SessionMiddleware
import db import db
from api.auth import get_current_user, resolve_account_for_user from api.auth import get_current_user, resolve_account_for_user
from api.schemas import ContactListResponse, ContactOut, SyncRunOut from api.schemas import (
ContactListResponse,
ContactOut,
GroupDetailOut,
GroupListResponse,
SyncRunOut,
)
from config import Config from config import Config
from mailer import build_message, fetch_birthdays_for_date, send_message from mailer import build_message, fetch_birthdays_for_date, send_message
from utils import fmt_birthday_age, fmt_birthday_short, is_unknown_year from utils import fmt_birthday_age, fmt_birthday_short, is_unknown_year
@@ -48,7 +54,7 @@ def _fmt_ts(dt) -> str | None:
return dt.astimezone(Config.TIMEZONE).strftime("%d.%m.%Y %H:%M:%S") return dt.astimezone(Config.TIMEZONE).strftime("%d.%m.%Y %H:%M:%S")
def _row_to_contact_out(row: dict) -> 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", "categories"]:
raw = row.get(field) raw = row.get(field)
@@ -56,6 +62,7 @@ def _row_to_contact_out(row: dict) -> dict:
if not row.get("full_name"): if not row.get("full_name"):
row["full_name"] = db._build_full_name(row) row["full_name"] = db._build_full_name(row)
row["updated_at"] = _fmt_ts(row["updated_at"]) row["updated_at"] = _fmt_ts(row["updated_at"])
row["groups"] = group_names if group_names is not None else []
return row return row
@@ -135,7 +142,11 @@ def get_contact(contact_id: int, current_user: str = Depends(get_current_user)):
if not row: if not row:
return {} return {}
return _row_to_contact_out(row)
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)
@app.get("/api/contacts/birthdays/today", response_model=list[ContactOut]) @app.get("/api/contacts/birthdays/today", response_model=list[ContactOut])
@@ -202,6 +213,108 @@ def list_sync_runs(current_user: str = Depends(get_current_user)):
return rows return rows
@app.get("/api/groups", response_model=GroupListResponse)
def list_groups(
request: Request,
limit: int = Query(default=50, le=200),
offset: int = Query(default=0, ge=0),
current_user: str = Depends(get_current_user),
):
account_name, is_admin = resolve_account_for_user(current_user)
with db.get_connection() as conn:
where_clause, params = _account_filter_clause(account_name)
with conn.cursor() as cur:
cur.execute(f"SELECT COUNT(*) AS total FROM `groups` {where_clause}", params)
total = cur.fetchone()["total"]
cur.execute(
f"""SELECT g.id, g.account, g.uid, g.name, g.updated_at,
(SELECT COUNT(*) FROM group_members gm WHERE gm.group_id = g.id) AS member_count
FROM `groups` g {where_clause}
ORDER BY g.name
LIMIT %s OFFSET %s""",
params + [limit, offset],
)
rows = cur.fetchall()
for r in rows:
r["updated_at"] = _fmt_ts(r["updated_at"])
return {"total": total, "items": rows}
@app.get("/api/groups/{group_id}", response_model=GroupDetailOut)
def get_group(group_id: int, current_user: str = Depends(get_current_user)):
account_name, is_admin = resolve_account_for_user(current_user)
with db.get_connection() as conn:
where_clause, params = _account_filter_clause(account_name)
id_clause = "AND g.id = %s" if where_clause else "WHERE g.id = %s"
with conn.cursor() as cur:
cur.execute(
f"""SELECT g.id, g.account, g.uid, g.name, g.updated_at,
(SELECT COUNT(*) FROM group_members gm WHERE gm.group_id = g.id) AS member_count
FROM `groups` g {where_clause} {id_clause}""",
params + [group_id],
)
group_row = cur.fetchone()
if not group_row:
return {}
cur.execute(
"""SELECT gm.member_uid, c.id, c.full_name, c.given_name, c.family_name
FROM group_members gm
LEFT JOIN contacts c ON c.account = g.account AND c.uid = gm.member_uid
CROSS JOIN `groups` g
WHERE g.id = %s AND gm.group_id = g.id""",
(group_id,),
)
members = cur.fetchall()
for m in members:
if not m.get("full_name"):
m["full_name"] = db._build_full_name(m) if any(m.get(k) for k in ("given_name", "family_name")) else None
group_row["updated_at"] = _fmt_ts(group_row["updated_at"])
group_row["members"] = [
{"member_uid": m["member_uid"], "full_name": m["full_name"], "id": m["id"]}
for m in members
]
return group_row
@app.get("/api/groups/{group_id}/members")
def get_group_members(group_id: int, current_user: str = Depends(get_current_user)):
account_name, is_admin = resolve_account_for_user(current_user)
with db.get_connection() as conn:
where_clause, params = _account_filter_clause(account_name)
with conn.cursor() as cur:
cur.execute(
f"""SELECT g.id FROM `groups` g {where_clause}
{"AND" if where_clause else "WHERE"} g.id = %s""",
params + [group_id],
)
if not cur.fetchone():
return {}
cur.execute(
"""SELECT gm.member_uid, c.id, c.full_name, c.given_name, c.family_name,
c.organization, c.birthday, c.photo_url
FROM group_members gm
LEFT JOIN contacts c ON c.uid = gm.member_uid
WHERE gm.group_id = %s""",
(group_id,),
)
rows = cur.fetchall()
for r in rows:
if not r.get("full_name"):
r["full_name"] = db._build_full_name(r) if any(r.get(k) for k in ("given_name", "family_name")) else None
return {"group_id": group_id, "members": rows}
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
def web_dashboard( def web_dashboard(
request: Request, request: Request,
@@ -466,7 +579,10 @@ def web_contact(
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
return RedirectResponse(url="/search", status_code=303) return RedirectResponse(url="/search", status_code=303)
contact = _row_to_contact_out(row) groups = db.get_groups_for_contact(conn, row["account"], row["uid"])
group_names = [g["name"] for g in groups if g.get("name")]
contact = _row_to_contact_out(row, group_names=group_names)
homecity = "" homecity = ""
workcity = "" workcity = ""
+28
View File
@@ -23,6 +23,7 @@ class ContactOut(BaseModel):
urls: list urls: list
social_profiles: list social_profiles: list
categories: list categories: list
groups: list[str] = []
updated_at: str updated_at: str
class Config: class Config:
@@ -44,3 +45,30 @@ class SyncRunOut(BaseModel):
contacts_upserted: int | None contacts_upserted: int | None
contacts_deleted: int | None contacts_deleted: int | None
error_message: str | None error_message: str | None
class GroupMemberOut(BaseModel):
member_uid: str
full_name: str | None
id: int | None = None
class GroupOut(BaseModel):
id: int
account: str
uid: str
name: str | None
member_count: int
updated_at: str
class Config:
from_attributes = True
class GroupDetailOut(GroupOut):
members: list[GroupMemberOut]
class GroupListResponse(BaseModel):
total: int
items: list[GroupOut]
+11
View File
@@ -422,6 +422,17 @@
</div> </div>
{% endif %} {% endif %}
{% if contact.groups %}
<div class="section">
<div class="section-title">Gruppen</div>
<div class="tags">
{% for group_name in contact.groups %}
<span class="tag">{{ group_name }}</span>
{% endfor %}
</div>
</div>
{% endif %}
{% if contact.notes %} {% if contact.notes %}
<div class="section"> <div class="section">
<div class="section-title">Notizen</div> <div class="section-title">Notizen</div>
+78
View File
@@ -279,3 +279,81 @@ def replace_all_contacts_for_account(conn, account: str, contacts: list[dict], r
raise raise
conn.commit() conn.commit()
logger.info("Voller Re-Sync für Account %s abgeschlossen: %d Kontakte", account, len(contacts)) logger.info("Voller Re-Sync für Account %s abgeschlossen: %d Kontakte", account, len(contacts))
def upsert_groups(conn, groups: list[dict], run_id: str):
if not groups:
return
now = datetime.now(timezone.utc)
with conn.cursor() as cur:
for g in groups:
g["sync_run_id"] = run_id
g["last_synced_at"] = now
member_uids = g.pop("member_uids", [])
cols = [k for k in g if k != "member_uids"]
placeholders = ", ".join(["%s"] * len(cols))
update_clause = ", ".join(f"{col}=VALUES({col})" for col in cols if col not in ("account", "uid"))
sql = (
f"INSERT INTO `groups` ({', '.join(cols)}) VALUES ({placeholders}) "
f"ON DUPLICATE KEY UPDATE {update_clause}"
)
try:
cur.execute(sql, [g[c] for c in cols])
except Exception as exc:
logger.error("INSERT Gruppe fehlgeschlagen für UID %s: %s", g.get("uid"), exc)
raise
cur.execute("SELECT id FROM `groups` WHERE account = %s AND uid = %s", (g["account"], g["uid"]))
row = cur.fetchone()
if not row:
logger.error("Konnte Gruppen-ID nicht ermitteln für UID %s", g.get("uid"))
continue
group_id = row["id"]
cur.execute("DELETE FROM group_members WHERE group_id = %s", (group_id,))
if member_uids:
member_values = [(group_id, uid) for uid in member_uids]
cur.executemany(
"INSERT INTO group_members (group_id, member_uid) VALUES (%s, %s)",
member_values,
)
conn.commit()
def delete_groups_by_uids(conn, account: str, uids: list[str]):
if not uids:
return
with conn.cursor() as cur:
placeholders = ", ".join(["%s"] * len(uids))
cur.execute(
f"DELETE FROM `groups` WHERE account = %s AND uid IN ({placeholders})",
[account] + uids,
)
conn.commit()
def replace_all_groups_for_account(conn, account: str, groups: list[dict], run_id: str):
with conn.cursor() as cur:
cur.execute("DELETE FROM `groups` WHERE account = %s", (account,))
conn.commit()
if groups:
upsert_groups(conn, groups, run_id)
logger.info("Gruppen-Re-Sync für Account %s abgeschlossen: %d Gruppen", account, len(groups))
def get_groups_for_contact(conn, account: str, member_uid: str) -> list[dict]:
with conn.cursor() as cur:
cur.execute(
"""SELECT g.id, g.name, g.uid
FROM `groups` g
JOIN group_members gm ON gm.group_id = g.id
WHERE g.account = %s AND gm.member_uid = %s
ORDER BY g.name""",
(account, member_uid),
)
return cur.fetchall()
def get_group_count(conn, account: str | None) -> int:
where_clause, params = _account_filter_clause(account)
with conn.cursor() as cur:
cur.execute(f"SELECT COUNT(*) AS total FROM `groups` {where_clause}", params)
return cur.fetchone()["total"]
+30 -26
View File
@@ -12,12 +12,30 @@ import sys
import db import db
from carddav_client import ICLOUD_BASE_URL, CardDAVClient, SyncTokenInvalid from carddav_client import ICLOUD_BASE_URL, CardDAVClient, SyncTokenInvalid
from config import Config from config import Config
from vcard_parser import parse_vcard from vcard_parser import is_group_vcard, parse_group, parse_vcard
logging.basicConfig(level=Config.LOG_LEVEL, format="%(asctime)s [%(levelname)s] %(message)s") logging.basicConfig(level=Config.LOG_LEVEL, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("sync") logger = logging.getLogger("sync")
def _classify_vcards(raw_vcards, raw_etags, account_name):
contacts, groups = [], []
for v, etag in zip(raw_vcards, raw_etags):
if is_group_vcard(v):
g = parse_group(v, account_name, etag=etag)
if g:
groups.append(g)
else:
logger.warning("[%s] Gruppen-vCard konnte nicht geparst werden, überspringe", account_name)
else:
c = parse_vcard(v, account_name, etag=etag)
if c:
contacts.append(c)
else:
logger.warning("[%s] vCard konnte nicht geparst werden, überspringe", account_name)
return contacts, groups
def sync_account(conn, account, href_to_uid_cache: dict): def sync_account(conn, account, href_to_uid_cache: dict):
client = CardDAVClient(ICLOUD_BASE_URL, account.apple_email, account.apple_app_password) client = CardDAVClient(ICLOUD_BASE_URL, account.apple_email, account.apple_app_password)
collection_url = client.discover_collection() collection_url = client.discover_collection()
@@ -30,19 +48,14 @@ def sync_account(conn, account, href_to_uid_cache: dict):
if not stored_token: if not stored_token:
logger.info("[%s] Kein sync-token vorhanden, führe initialen Full-Sync aus", account.name) logger.info("[%s] Kein sync-token vorhanden, führe initialen Full-Sync aus", account.name)
raw_vcards, raw_etags = client.fetch_all_vcards(collection_url) raw_vcards, raw_etags = client.fetch_all_vcards(collection_url)
contacts = [] contacts, groups = _classify_vcards(raw_vcards, raw_etags, account.name)
for v, etag in zip(raw_vcards, raw_etags):
c = parse_vcard(v, account.name, etag=etag)
if c:
contacts.append(c)
else:
logger.warning("[%s] vCard konnte nicht geparst werden, überspringe", account.name)
db.replace_all_contacts_for_account(conn, account.name, contacts, run_id) db.replace_all_contacts_for_account(conn, account.name, contacts, run_id)
db.replace_all_groups_for_account(conn, account.name, groups, run_id)
_, _, _, new_token = client.sync_collection(collection_url, None, fetch_missing=False) _, _, _, new_token = client.sync_collection(collection_url, None, fetch_missing=False)
if new_token: if new_token:
db.save_sync_token(conn, account.name, new_token) db.save_sync_token(conn, account.name, new_token)
db.finish_sync_run(conn, run_id, "success", upserted=len(contacts), deleted=0) db.finish_sync_run(conn, run_id, "success", upserted=len(contacts), deleted=0)
logger.info("[%s] Initialer Sync abgeschlossen: %d Kontakte", account.name, len(contacts)) logger.info("[%s] Initialer Sync abgeschlossen: %d Kontakte, %d Gruppen", account.name, len(contacts), len(groups))
return return
try: try:
@@ -51,29 +64,19 @@ def sync_account(conn, account, href_to_uid_cache: dict):
logger.warning("[%s] sync-token vom Server abgelehnt, führe vollen Re-Sync aus", account.name) logger.warning("[%s] sync-token vom Server abgelehnt, führe vollen Re-Sync aus", account.name)
db.clear_sync_token(conn, account.name) db.clear_sync_token(conn, account.name)
raw_vcards, raw_etags = client.fetch_all_vcards(collection_url) raw_vcards, raw_etags = client.fetch_all_vcards(collection_url)
contacts = [] contacts, groups = _classify_vcards(raw_vcards, raw_etags, account.name)
for v, etag in zip(raw_vcards, raw_etags):
c = parse_vcard(v, account.name, etag=etag)
if c:
contacts.append(c)
else:
logger.warning("[%s] vCard konnte nicht geparst werden, überspringe", account.name)
db.replace_all_contacts_for_account(conn, account.name, contacts, run_id) db.replace_all_contacts_for_account(conn, account.name, contacts, run_id)
db.replace_all_groups_for_account(conn, account.name, groups, run_id)
_, _, _, new_token = client.sync_collection(collection_url, None, fetch_missing=False) _, _, _, new_token = client.sync_collection(collection_url, None, fetch_missing=False)
if new_token: if new_token:
db.save_sync_token(conn, account.name, new_token) db.save_sync_token(conn, account.name, new_token)
db.finish_sync_run(conn, run_id, "success", upserted=len(contacts), deleted=0) db.finish_sync_run(conn, run_id, "success", upserted=len(contacts), deleted=0)
logger.info("[%s] Re-Sync abgeschlossen: %d Kontakte", account.name, len(contacts)) logger.info("[%s] Re-Sync abgeschlossen: %d Kontakte, %d Gruppen", account.name, len(contacts), len(groups))
return return
contacts = [] contacts, groups = _classify_vcards(changed_vcards, etags, account.name)
for v, etag in zip(changed_vcards, etags):
c = parse_vcard(v, account.name, etag=etag)
if c:
contacts.append(c)
else:
logger.warning("[%s] vCard konnte nicht geparst werden, überspringe", account.name)
db.upsert_contacts(conn, contacts, run_id) db.upsert_contacts(conn, contacts, run_id)
db.upsert_groups(conn, groups, run_id)
deleted_uids = [] deleted_uids = []
for href in deleted_hrefs: for href in deleted_hrefs:
@@ -83,14 +86,15 @@ def sync_account(conn, account, href_to_uid_cache: dict):
else: else:
logger.warning("[%s] Konnte UID nicht aus href extrahieren: %s", account.name, href) logger.warning("[%s] Konnte UID nicht aus href extrahieren: %s", account.name, href)
db.delete_contacts_by_href_uids(conn, account.name, deleted_uids) db.delete_contacts_by_href_uids(conn, account.name, deleted_uids)
db.delete_groups_by_uids(conn, account.name, deleted_uids)
if new_token: if new_token:
db.save_sync_token(conn, account.name, new_token) db.save_sync_token(conn, account.name, new_token)
db.finish_sync_run(conn, run_id, "success", upserted=len(contacts), deleted=len(deleted_uids)) db.finish_sync_run(conn, run_id, "success", upserted=len(contacts), deleted=len(deleted_uids))
logger.info( logger.info(
"[%s] Delta-Sync abgeschlossen: %d geändert/neu, %d gelöscht", "[%s] Delta-Sync abgeschlossen: %d geändert/neu, %d gelöscht (%d Gruppen geändert)",
account.name, len(contacts), len(deleted_uids), account.name, len(contacts), len(deleted_uids), len(groups),
) )
except Exception as exc: except Exception as exc:
logger.exception("[%s] Sync-Lauf %s fehlgeschlagen", account.name, run_id) logger.exception("[%s] Sync-Lauf %s fehlgeschlagen", account.name, run_id)
+42
View File
@@ -33,6 +33,48 @@ def _type_str(obj) -> str:
return "other" return "other"
_GROUP_MARKER = "X-ADDRESSBOOKSERVER-KIND:group"
_MEMBER_PREFIX = "urn:uuid:"
def is_group_vcard(raw_text: str) -> bool:
return _GROUP_MARKER in raw_text
def parse_group(raw_text: str, account: str, etag: str | None = None) -> dict | None:
try:
vcard = vobject.readOne(raw_text)
except Exception as exc:
logger.warning("Gruppen-vCard konnte nicht geparst werden: %s", exc)
return None
uid = _get(vcard, "uid")
if not uid:
logger.warning("Gruppen-vCard ohne UID übersprungen")
return None
member_uids = []
for member in vcard.contents.get("x-addressbookserver-member", []):
value = member.value if hasattr(member, "value") else str(member)
if value.startswith(_MEMBER_PREFIX):
member_uids.append(value[len(_MEMBER_PREFIX):])
else:
member_uids.append(value)
if not member_uids:
logger.debug("Gruppe %s hat keine Members, überspringe", uid)
return None
return {
"account": account,
"uid": uid,
"etag": etag,
"name": _scalar(_get(vcard, "fn")),
"raw_vcard": raw_text,
"member_uids": member_uids,
}
def parse_vcard(raw_text: str, account: str, etag: str | None = None) -> dict | None: def parse_vcard(raw_text: str, account: str, etag: str | None = None) -> dict | None:
try: try:
vcard = vobject.readOne(raw_text) vcard = vobject.readOne(raw_text)