mirror of
https://github.com/skoelle/icloud-contacts-sync.git
synced 2026-09-17 15:30:24 +00:00
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:
@@ -148,6 +148,24 @@ python3 mailer.py
|
||||
aus MariaDB entfernt, ohne Archiv.
|
||||
- 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)
|
||||
|
||||
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` | `/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/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/birthdays/today` | Heutige Geburtstage |
|
||||
| `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) |
|
||||
|
||||
Alle Endpunkte (außer `/api/health`) erfordern eine Authentifizierung
|
||||
|
||||
@@ -92,6 +92,11 @@ Siehe `sql/schema.sql`. Wichtigste Änderungen gegenüber v1:
|
||||
- Neue Tabelle `birthday_mail_log`: ein Datensatz pro Tag, an dem
|
||||
erfolgreich eine Geburtstagsmail versendet wurde, verhindert
|
||||
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
|
||||
|
||||
@@ -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 /api/health` | Health-Check ohne Auth-Anforderung |
|
||||
| `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/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/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) |
|
||||
|
||||
### 12.5 Netzwerkkontext
|
||||
|
||||
@@ -57,6 +57,29 @@ CREATE TABLE IF NOT EXISTS sync_runs (
|
||||
error_message TEXT NULL
|
||||
) 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.
|
||||
CREATE TABLE IF NOT EXISTS birthday_mail_log (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
|
||||
+125
-9
@@ -21,7 +21,13 @@ from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
import db
|
||||
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 mailer import build_message, fetch_birthdays_for_date, send_message
|
||||
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")
|
||||
|
||||
|
||||
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)
|
||||
for field in ["emails", "phones", "addresses", "urls", "social_profiles", "categories"]:
|
||||
raw = row.get(field)
|
||||
@@ -56,6 +62,7 @@ def _row_to_contact_out(row: dict) -> dict:
|
||||
if not row.get("full_name"):
|
||||
row["full_name"] = db._build_full_name(row)
|
||||
row["updated_at"] = _fmt_ts(row["updated_at"])
|
||||
row["groups"] = group_names if group_names is not None else []
|
||||
return row
|
||||
|
||||
|
||||
@@ -133,9 +140,13 @@ def get_contact(contact_id: int, current_user: str = Depends(get_current_user)):
|
||||
)
|
||||
row = cur.fetchone()
|
||||
|
||||
if not row:
|
||||
return {}
|
||||
return _row_to_contact_out(row)
|
||||
if not row:
|
||||
return {}
|
||||
|
||||
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])
|
||||
@@ -202,6 +213,108 @@ def list_sync_runs(current_user: str = Depends(get_current_user)):
|
||||
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)
|
||||
def web_dashboard(
|
||||
request: Request,
|
||||
@@ -462,11 +575,14 @@ def web_contact(
|
||||
)
|
||||
row = cur.fetchone()
|
||||
|
||||
if not row:
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse(url="/search", status_code=303)
|
||||
if not row:
|
||||
from fastapi.responses import RedirectResponse
|
||||
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 = ""
|
||||
workcity = ""
|
||||
|
||||
@@ -23,6 +23,7 @@ class ContactOut(BaseModel):
|
||||
urls: list
|
||||
social_profiles: list
|
||||
categories: list
|
||||
groups: list[str] = []
|
||||
updated_at: str
|
||||
|
||||
class Config:
|
||||
@@ -44,3 +45,30 @@ class SyncRunOut(BaseModel):
|
||||
contacts_upserted: int | None
|
||||
contacts_deleted: int | 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]
|
||||
|
||||
@@ -422,6 +422,17 @@
|
||||
</div>
|
||||
{% 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 %}
|
||||
<div class="section">
|
||||
<div class="section-title">Notizen</div>
|
||||
|
||||
@@ -279,3 +279,81 @@ def replace_all_contacts_for_account(conn, account: str, contacts: list[dict], r
|
||||
raise
|
||||
conn.commit()
|
||||
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
@@ -12,12 +12,30 @@ import sys
|
||||
import db
|
||||
from carddav_client import ICLOUD_BASE_URL, CardDAVClient, SyncTokenInvalid
|
||||
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")
|
||||
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):
|
||||
client = CardDAVClient(ICLOUD_BASE_URL, account.apple_email, account.apple_app_password)
|
||||
collection_url = client.discover_collection()
|
||||
@@ -30,19 +48,14 @@ def sync_account(conn, account, href_to_uid_cache: dict):
|
||||
if not stored_token:
|
||||
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)
|
||||
contacts = []
|
||||
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)
|
||||
contacts, groups = _classify_vcards(raw_vcards, raw_etags, account.name)
|
||||
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)
|
||||
if new_token:
|
||||
db.save_sync_token(conn, account.name, new_token)
|
||||
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
|
||||
|
||||
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)
|
||||
db.clear_sync_token(conn, account.name)
|
||||
raw_vcards, raw_etags = client.fetch_all_vcards(collection_url)
|
||||
contacts = []
|
||||
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)
|
||||
contacts, groups = _classify_vcards(raw_vcards, raw_etags, account.name)
|
||||
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)
|
||||
if new_token:
|
||||
db.save_sync_token(conn, account.name, new_token)
|
||||
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
|
||||
|
||||
contacts = []
|
||||
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)
|
||||
contacts, groups = _classify_vcards(changed_vcards, etags, account.name)
|
||||
db.upsert_contacts(conn, contacts, run_id)
|
||||
db.upsert_groups(conn, groups, run_id)
|
||||
|
||||
deleted_uids = []
|
||||
for href in deleted_hrefs:
|
||||
@@ -83,14 +86,15 @@ def sync_account(conn, account, href_to_uid_cache: dict):
|
||||
else:
|
||||
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_groups_by_uids(conn, account.name, deleted_uids)
|
||||
|
||||
if 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))
|
||||
logger.info(
|
||||
"[%s] Delta-Sync abgeschlossen: %d geändert/neu, %d gelöscht",
|
||||
account.name, len(contacts), len(deleted_uids),
|
||||
"[%s] Delta-Sync abgeschlossen: %d geändert/neu, %d gelöscht (%d Gruppen geändert)",
|
||||
account.name, len(contacts), len(deleted_uids), len(groups),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("[%s] Sync-Lauf %s fehlgeschlagen", account.name, run_id)
|
||||
|
||||
@@ -33,6 +33,48 @@ def _type_str(obj) -> str:
|
||||
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:
|
||||
try:
|
||||
vcard = vobject.readOne(raw_text)
|
||||
|
||||
Reference in New Issue
Block a user