fix db sync bugs

This commit is contained in:
2026-08-05 15:10:55 +02:00
parent 85f6e79a7f
commit 12e36b2f17
4 changed files with 61 additions and 27 deletions
+22 -8
View File
@@ -89,8 +89,10 @@ class CardDAVClient:
def sync_collection(self, collection_url: str, sync_token: str | None): def sync_collection(self, collection_url: str, sync_token: str | None):
""" """
Führt REPORT sync-collection aus. Gibt (changed_or_new_vcards, deleted_hrefs, new_sync_token) zurück. Führt REPORT sync-collection aus.
changed_or_new_vcards: list[str] roher vCard-Text Gibt (changed_vcards, etags, deleted_hrefs, new_sync_token) zurück.
changed_vcards: list[str] roher vCard-Text
etags: list[str|None] ETag pro vCard (None wenn nicht vorhanden)
deleted_hrefs: list[str] Hrefs von gelöschten Kontakten (status 404) deleted_hrefs: list[str] Hrefs von gelöschten Kontakten (status 404)
""" """
token_element = f"<d:sync-token>{sync_token}</d:sync-token>" if sync_token else "<d:sync-token/>" token_element = f"<d:sync-token>{sync_token}</d:sync-token>" if sync_token else "<d:sync-token/>"
@@ -102,7 +104,7 @@ class CardDAVClient:
</d:sync-collection>""" </d:sync-collection>"""
root = self._request("REPORT", collection_url, body, depth="1") root = self._request("REPORT", collection_url, body, depth="1")
vcards, deleted_hrefs = [], [] vcards, etags, deleted_hrefs = [], [], []
for response in root.findall("d:response", NS): for response in root.findall("d:response", NS):
status_el = response.find(".//d:status", NS) status_el = response.find(".//d:status", NS)
status_text = status_el.text if status_el is not None else "" status_text = status_el.text if status_el is not None else ""
@@ -114,28 +116,40 @@ class CardDAVClient:
deleted_hrefs.append(href) deleted_hrefs.append(href)
continue continue
if not any(s in status_text for s in ("200", "207")):
logger.warning("Unerwarteter Status %s für %s, überspringe", status_text, href)
continue
etag_el = response.find("d:getetag", NS)
etag = etag_el.text if etag_el is not None else None
data = response.find(".//card:address-data", NS) data = response.find(".//card:address-data", NS)
if data is not None and data.text: if data is not None and data.text:
vcards.append(data.text) vcards.append(data.text)
etags.append(etag)
new_token_el = root.find("d:sync-token", NS) new_token_el = root.find("d:sync-token", NS)
new_token = new_token_el.text if new_token_el is not None else None new_token = new_token_el.text if new_token_el is not None else None
return vcards, deleted_hrefs, new_token return vcards, etags, deleted_hrefs, new_token
def fetch_all_vcards(self, collection_url: str) -> list[str]: def fetch_all_vcards(self, collection_url: str) -> tuple[list[str], list[str | None]]:
"""Fallback für den allerersten, vollen Abruf über addressbook-query.""" """Fallback für den allerersten, vollen Abruf über addressbook-query.
Gibt (vcards, etags) zurück."""
body = """<?xml version="1.0" encoding="utf-8" ?> body = """<?xml version="1.0" encoding="utf-8" ?>
<card:addressbook-query xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav"> <card:addressbook-query xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
<d:prop><d:getetag/><card:address-data/></d:prop> <d:prop><d:getetag/><card:address-data/></d:prop>
<card:filter/> <card:filter/>
</card:addressbook-query>""" </card:addressbook-query>"""
root = self._request("REPORT", collection_url, body, depth="1") root = self._request("REPORT", collection_url, body, depth="1")
vcards = [] vcards, etags = [], []
for response in root.findall("d:response", NS): for response in root.findall("d:response", NS):
etag_el = response.find("d:getetag", NS)
etag = etag_el.text if etag_el is not None else None
data = response.find(".//card:address-data", NS) data = response.find(".//card:address-data", NS)
if data is not None and data.text: if data is not None and data.text:
vcards.append(data.text) vcards.append(data.text)
return vcards etags.append(etag)
return vcards, etags
def discover_collection(self) -> str: def discover_collection(self) -> str:
principal = self.discover_principal() principal = self.discover_principal()
+3 -1
View File
@@ -5,7 +5,7 @@ import logging
import os import os
import uuid import uuid
from contextlib import contextmanager from contextlib import contextmanager
from datetime import date from datetime import date, datetime
import pymysql import pymysql
from pymysql.cursors import DictCursor from pymysql.cursors import DictCursor
@@ -95,9 +95,11 @@ def finish_sync_run(conn, run_id: str, status: str, upserted: int = None, delete
def upsert_contacts(conn, contacts: list[dict], run_id: str): def upsert_contacts(conn, contacts: list[dict], run_id: str):
if not contacts: if not contacts:
return return
now = datetime.now()
with conn.cursor() as cur: with conn.cursor() as cur:
for c in contacts: for c in contacts:
c["sync_run_id"] = run_id c["sync_run_id"] = run_id
c["last_synced_at"] = now
c = _sanitize_contact(c) c = _sanitize_contact(c)
cols = list(c.keys()) cols = list(c.keys())
placeholders = ", ".join(["%s"] * len(cols)) placeholders = ", ".join(["%s"] * len(cols))
+34 -16
View File
@@ -9,10 +9,10 @@ vollständiger Re-Sync."""
import logging import logging
import sys import sys
from config import Config
from carddav_client import CardDAVClient, ICLOUD_BASE_URL, SyncTokenInvalid
from vcard_parser import parse_vcard
import db import db
from carddav_client import ICLOUD_BASE_URL, CardDAVClient, SyncTokenInvalid
from config import Config
from vcard_parser import 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")
@@ -29,35 +29,53 @@ def sync_account(conn, account, href_to_uid_cache: dict):
try: try:
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 = client.fetch_all_vcards(collection_url) raw_vcards, raw_etags = client.fetch_all_vcards(collection_url)
contacts = [c for c in (parse_vcard(v, account.name) for v in raw_vcards) if c] 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)
db.replace_all_contacts_for_account(conn, account.name, contacts, run_id) db.replace_all_contacts_for_account(conn, account.name, contacts, run_id)
_, _, new_token = client.sync_collection(collection_url, None)
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) 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", account.name, len(contacts))
return return
try: try:
changed_vcards, deleted_hrefs, new_token = client.sync_collection(collection_url, stored_token) changed_vcards, etags, deleted_hrefs, new_token = client.sync_collection(collection_url, stored_token)
except SyncTokenInvalid: except SyncTokenInvalid:
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 = client.fetch_all_vcards(collection_url) raw_vcards, raw_etags = client.fetch_all_vcards(collection_url)
contacts = [c for c in (parse_vcard(v, account.name) for v in raw_vcards) if c] 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)
db.replace_all_contacts_for_account(conn, account.name, contacts, run_id) db.replace_all_contacts_for_account(conn, account.name, contacts, run_id)
_, _, new_token = client.sync_collection(collection_url, None)
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) 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", account.name, len(contacts))
return return
contacts = [c for c in (parse_vcard(v, account.name) for v in changed_vcards) if c] 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)
db.upsert_contacts(conn, contacts, run_id) db.upsert_contacts(conn, contacts, run_id)
deleted_uids = [href.rstrip("/").rsplit("/", 1)[-1].replace(".vcf", "") for href in deleted_hrefs] deleted_uids = []
for href in deleted_hrefs:
uid = href.rstrip("/").rsplit("/", 1)[-1].replace(".vcf", "")
if uid:
deleted_uids.append(uid)
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_contacts_by_href_uids(conn, account.name, deleted_uids)
if new_token: if new_token:
+2 -2
View File
@@ -33,7 +33,7 @@ def _type_str(obj) -> str:
return "other" return "other"
def parse_vcard(raw_text: str, account: str) -> 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)
except Exception as exc: except Exception as exc:
@@ -81,7 +81,7 @@ def parse_vcard(raw_text: str, account: str) -> dict | None:
return { return {
"account": account, "account": account,
"uid": uid, "uid": uid,
"etag": None, "etag": etag,
"full_name": _scalar(_get(vcard, "fn")), "full_name": _scalar(_get(vcard, "fn")),
"given_name": given_name, "given_name": given_name,
"family_name": family_name, "family_name": family_name,