#!/usr/bin/env python3 """ contacts_caldav_sync.py — двусторонняя синхронизация контактов между локальной базой contacts.json и CardDAV-сервером Radicale. Направления: PUSH (база → сервер): новые/изменённые контакты пишутся как vCard PULL (сервер → база): правки на телефоне (DAVx5) попадают обратно: - изменённая карточка → обновление контакта - новая карточка → новый контакт - удалённая карточка → deleted: true (soft delete) Конфликты: приоритет серверу (телефон), локальная версия в caldav-sync.log Запуск: python3 contacts_caldav_sync.py [--base-url URL] [--user USER] [--pass PASS] [--addressbook КОНТАКТЫ] [--prune] [--dry-run] Пароль: --pass или env CALDAV_PASS (или RADICALE_PASS). Без --pass → читает radicale/.env (RADICALE_PASS). """ import argparse import base64 import json import os import re import sys import time import urllib.error import urllib.parse import urllib.request import xml.etree.ElementTree as ET from datetime import date, datetime from pathlib import Path EMAIL_ROOT = Path("/opt/hermes/email") CONTACTS_DIR = EMAIL_ROOT / "contacts" LOG_PATH = CONTACTS_DIR / "caldav-sync.log" DEFAULT_BASE_URL = "http://127.0.0.1:5232" DEFAULT_USER = "estorozhenko" DEFAULT_ADDRESSBOOK = "Контакты" # ─── HTTP helpers ────────────────────────────────────────────────────────────── def _auth_header(user, password): token = base64.b64encode(f"{user}:{password}".encode()).decode() return {"Authorization": f"Basic {token}"} def _request(method, url, headers=None, data=None, timeout=15): req = urllib.request.Request(url, method=method, headers=headers or {}) if data is not None: if isinstance(data, str): data = data.encode("utf-8") req.data = data try: with urllib.request.urlopen(req, timeout=timeout) as resp: return resp.status, resp.read() except urllib.error.HTTPError as e: return e.code, e.read() except urllib.error.URLError as e: return 0, str(e).encode() def _etag_from_response(resp_headers): return resp_headers.get("ETag") or resp_headers.get("etag") # ─── vCard helpers ──────────────────────────────────────────────────────────── def contact_to_vcard(contact): """Собрать vCard 4.0 из контакта (как generate_vcard в contacts_extractor).""" lines = ["BEGIN:VCARD", "VERSION:4.0"] uid = contact.get("id") or "" if uid: lines.append(f"UID:{uid}") full_name = contact.get("full_name") or "" lines.append(f"FN:{full_name}") name_parts = full_name.split(maxsplit=2) if len(name_parts) >= 2: n_line = f"N:{name_parts[-1]};{name_parts[0]};{' '.join(name_parts[1:-1])};;" else: n_line = f"N:{full_name};;;;" lines.append(n_line) email = contact.get("email") if email: lines.append(f"EMAIL;TYPE=WORK:{email}") if contact.get("phone"): # Radicale режет value по запятой → заменяем на пробел (доб. 3557 сохраняется) lines.append(f"TEL;TYPE=WORK:{contact['phone'].replace(',', ' ')}") if contact.get("phone_secondary"): lines.append(f"TEL;TYPE=CELL:{contact['phone_secondary'].replace(',', ' ')}") if contact.get("position"): lines.append(f"TITLE:{contact['position']}") if contact.get("company"): lines.append(f"ORG:{contact['company']}") if contact.get("address"): lines.append(f"ADR;TYPE=WORK:;;{contact['address']};;;") # Источник: X-SOURCES через | (Radicale режет по запятой, | не режет) if contact.get("source_uids"): sources = " | ".join(contact.get("source_uids", [])) lines.append(f"X-SOURCES:{sources}") lines.append("END:VCARD") return "\r\n".join(lines) + "\r\n" def parse_vcard_to_contact(vcard_text, uid, base_contact=None): """Извлечь поля из vCard в dict контакта (для pull-направления).""" contact = dict(base_contact or {}) contact["id"] = uid fields = {} current = None for line in vcard_text.replace("\r\n", "\n").split("\n"): line = line.strip() if not line: continue if line.startswith("BEGIN:"): continue if line.startswith("END:"): continue if ":" in line: key, _, value = line.partition(":") # params: EMAIL;TYPE=WORK → name=EMAIL name = key.split(";")[0].upper() value = value.strip() if name in ("FN", "N", "EMAIL", "TEL", "TITLE", "ORG", "ADR", "NOTE"): fields.setdefault(name, []).append(value) if "FN" in fields: contact["full_name"] = fields["FN"][0] if "EMAIL" in fields: email = fields["EMAIL"][0] # strip mailto: if email.lower().startswith("mailto:"): email = email[7:] contact["email"] = email.lower() tels = [t for t in fields.get("TEL", []) if t] if tels: tel = tels[0] if tel.lower().startswith("tel:"): tel = tel[4:] contact["phone"] = tel if len(tels) > 1: contact["phone_secondary"] = tels[1] if "TITLE" in fields: contact["position"] = fields["TITLE"][0] if "ORG" in fields: contact["company"] = fields["ORG"][0].replace("\\,", ",") if "ADR" in fields: # ADR;TYPE=WORK:;;address;;; → берём 3-ю часть adr = fields["ADR"][0] parts = adr.split(";") if len(parts) >= 3 and parts[2]: contact["address"] = parts[2] return contact # ─── CardDAV ops ────────────────────────────────────────────────────────────── def carddav_base_url(base_url, user, addressbook): """URL адресной книги.""" return f"{base_url.rstrip('/')}/{urllib.parse.quote(user)}/{urllib.parse.quote(addressbook)}/" def list_addressbook(base_url, user, addressbook, auth): """PROPFIND Depth:1 → {href: {'etag': str, 'vcard': str}} (vcard via GET).""" ab_url = carddav_base_url(base_url, user, addressbook) body = """ """ status, content = _request( "PROPFIND", ab_url, headers={**auth, "Depth": "1", "Content-Type": "application/xml"}, data=body, ) if status not in (207, 200): return None, f"PROPFIND → {status}: {content[:200]}" xml = content.decode("utf-8", errors="replace") # Парсим response-блоки через ElementTree (Radicale использует префикс D: по умолчанию, # но может быть и без — ET с namespaces справится). cards = {} try: root = ET.fromstring(xml) except ET.ParseError as e: return None, f"PROPFIND XML parse: {e}" dav = "{DAV:}" for resp in root.iter(f"{dav}response"): href_el = resp.find(f"{dav}href") if href_el is None or not href_el.text: continue href = href_el.text.strip() if href.endswith("/"): continue # сама коллекция # etag лежит в propstat/prop/getetag etag = "" for prop in resp.iter(f"{dav}prop"): getetag = prop.find(f"{dav}getetag") if getetag is not None and getetag.text: etag = getetag.text.strip() break cards[href] = {"etag": etag} # Тянем содержимое каждой карточки (GET) for href in list(cards): card_url = f"{base_url.rstrip('/')}{href}" if href.startswith("/") else f"{ab_url.rstrip('/')}/{href}" status2, content2 = _request("GET", card_url, headers=auth) if status2 == 200: cards[href]["vcard"] = content2.decode("utf-8", errors="replace") else: cards[href]["vcard"] = "" return cards, None def put_card(base_url, user, addressbook, uid, vcard, auth, etag=None): """PUT vCard; etag → If-Match (обновление). Возвращает (status, etag или body).""" ab_url = carddav_base_url(base_url, user, addressbook) url = f"{ab_url}{uid}.vcf" headers = {**auth, "Content-Type": "text/vcard; charset=utf-8"} if etag: headers["If-Match"] = etag status, content = _request("PUT", url, headers=headers, data=vcard) if status in (200, 201, 204): new_etag = None # Radicale отдаёт ETag в заголовке; urllib его теряет в _request — # пере-запросим HEAD? Нет. GET вернёт актуальный. return status, new_etag return status, content[:300] def get_card_etag(base_url, user, addressbook, uid, auth): """GET карточки → (etag, vcard).""" ab_url = carddav_base_url(base_url, user, addressbook) url = f"{ab_url}{uid}.vcf" req = urllib.request.Request(url, method="GET", headers=auth) try: with urllib.request.urlopen(req, timeout=15) as resp: etag = resp.headers.get("ETag") or "" return etag, resp.read().decode("utf-8", errors="replace") except urllib.error.HTTPError as e: return None, None if e.code == 404 else (None, e.read()[:200]) except urllib.error.URLError: return None, None def delete_card(base_url, user, addressbook, uid, auth): ab_url = carddav_base_url(base_url, user, addressbook) url = f"{ab_url}{uid}.vcf" status, content = _request("DELETE", url, headers=auth) return status, content # ─── Sync logic ─────────────────────────────────────────────────────────────── def log_sync(entries): """Дописать строки в caldav-sync.log.""" with open(LOG_PATH, "a", encoding="utf-8") as f: for e in entries: ts = datetime.now().isoformat(timespec="seconds") f.write(f"[{ts}] {e}\n") def sync_contacts_carddav(base_url, user, password, addressbook, prune=False, dry_run=False, contacts_dir=None, verbose=True): """Двусторонний синк; возвращает (stats: dict, err: str|None).""" contacts_dir = contacts_dir or CONTACTS_DIR contacts_db = {} try: contacts_db = json.load(open(contacts_dir / "contacts.json", encoding="utf-8")) except (FileNotFoundError, json.JSONDecodeError) as e: return None, f"Не удалось прочитать contacts.json: {e}" contacts = contacts_db.get("contacts", []) by_email = contacts_db.get("by_email", {}) auth = _auth_header(user, password) # Строим индекс по id by_id = {c.get("id"): c for c in contacts if c.get("id")} # 1. PROPFIND книги cards, err = list_addressbook(base_url, user, addressbook, auth) if err: return None, f"list_addressbook: {err}" assert cards is not None if verbose: print(f" CardDAV: {len(cards)} карточек в книге {addressbook}") stats = {"created": 0, "updated": 0, "unchanged": 0, "conflict": 0, "pulled_created": 0, "pulled_updated": 0, "pulled_deleted": 0, "deleted": 0} log_entries = [] changed = False # ── PULL: сервер → база ──────────────────────────────────────────────── server_ids = set() for href, card in cards.items(): # uid = имя файла без .vcf uid = href.rstrip("/").split("/")[-1] if uid.endswith(".vcf"): uid = uid[:-4] server_ids.add(uid) local = by_id.get(uid) server_etag = card.get("etag", "") vcard_text = card.get("vcard", "") if local is None: # Новой карточки нет в базе → создать контакт new_contact = parse_vcard_to_contact(vcard_text, uid) if not new_contact.get("email"): if verbose: print(f" ← {uid}: новая карточка без email, пропуск pull") continue new_contact.setdefault("full_name", "") new_contact["first_seen"] = date.today().isoformat() new_contact["last_seen"] = date.today().isoformat() new_contact["source_uids"] = new_contact.get("source_uids", []) new_contact["source_folders"] = new_contact.get("source_folders", []) new_contact["caldav"] = {"uid": uid, "etag": server_etag, "from_device": True} contacts.append(new_contact) by_email[new_contact["email"]] = len(contacts) - 1 by_id[uid] = new_contact stats["pulled_created"] += 1 changed = True if verbose: print(f" ← {uid}: новая карточка с телефона → контакт создан") log_entries.append(f"PULL-CREATE {uid} {new_contact.get('email')}") continue local_caldav = local.get("caldav") or {} local_etag = local_caldav.get("etag", "") # Изменена ли карточка относительно нашей записи? if server_etag and server_etag != local_etag: # Сервер изменился (правка с телефона). По REQ-011 приоритет телефону — # применяем серверную версию, сохраняя недостающие локальные поля. merged = parse_vcard_to_contact(vcard_text, uid, base_contact=local) # Поля, которых нет в vCard — значит удалены на телефоне → обнуляем. SYNCFIELDS = ("full_name", "phone", "phone_secondary", "position", "company", "address", "email") present = _vcard_field_names(vcard_text) for k in SYNCFIELDS: if k in present: if merged.get(k): local[k] = merged[k] else: local[k] = None if k in ("phone", "phone_secondary", "position", "company", "address") else local.get(k) local["caldav"] = {**local_caldav, "etag": server_etag, "from_device": True} stats["pulled_updated"] += 1 changed = True if verbose: print(f" ← {uid}: карточка изменена на телефоне → контакт обновлён") log_entries.append(f"PULL-UPDATE {uid} (server etag {server_etag})") else: stats["unchanged"] += 1 # Карточки, которых больше нет на сервере (удалены на телефоне) → soft delete for contact in contacts: cid = contact.get("id") if not cid: continue caldav = contact.get("caldav") or {} if caldav.get("uid") and cid not in server_ids: if not contact.get("deleted"): contact["deleted"] = True contact["last_seen"] = date.today().isoformat() stats["pulled_deleted"] += 1 changed = True if verbose: print(f" ← {cid}: карточка удалена на телефоне → deleted: true") log_entries.append(f"PULL-DELETE {cid}") # ── PUSH: база → сервер ──────────────────────────────────────────────── for contact in contacts: cid = contact.get("id") email = contact.get("email") if not cid or not email: continue if contact.get("deleted"): # unтелефон deleted: удалить карточку при prune или пропустить if prune and cid in server_ids: status, body = delete_card(base_url, user, addressbook, cid, auth) if status in (200, 204): stats["deleted"] += 1 changed = True if verbose: print(f" → {cid}: карточка удалена (prune)") log_entries.append(f"DELETE {cid}") continue caldav = contact.get("caldav") or {} if caldav.get("from_device") and not caldav.get("sync_after_pull"): # Контакт с телефона: уже синхронизирован в pull, ничего не пишем continue vcard = contact_to_vcard(contact) if dry_run: action = "создана" if cid not in server_ids else "обновлена" if verbose: print(f" → [dry] {cid}: карточка {action}") continue if cid not in server_ids: # Нет карточки → создать status, body = put_card(base_url, user, addressbook, cid, vcard, auth) if status in (200, 201, 204): # обновим etag etag, _ = get_card_etag(base_url, user, addressbook, cid, auth) contact["caldav"] = {"uid": cid, "etag": etag or "", "from_device": False} stats["created"] += 1 changed = True if verbose: print(f" → {cid}: карточка создана") log_entries.append(f"CREATE {cid} {email}") else: if verbose: print(f" ✗ {cid}: PUT {status}: {body[:120]}") else: # Карточка есть → проверить, изменилась ли локально c_href = href_for(cid, cards) server_etag = cards[c_href]["etag"] if c_href else "" local_etag = (contact.get("caldav") or {}).get("etag", "") # Сравниваем содержимое vCard (проще, чем etag на каждый чих) server_vcard = cards[c_href].get("vcard", "") if c_href else "" local_vcard = contact_to_vcard(contact) if _normalize_vcard(local_vcard) == _normalize_vcard(server_vcard): stats["unchanged"] += 1 continue # Обновляем с If-Match status, body = put_card(base_url, user, addressbook, cid, vcard, auth, etag=server_etag or None) if status in (200, 201, 204): etag, _ = get_card_etag(base_url, user, addressbook, cid, auth) contact["caldav"] = {"uid": cid, "etag": etag or "", "from_device": False} stats["updated"] += 1 changed = True if verbose: print(f" → {cid}: карточка обновлена") log_entries.append(f"UPDATE {cid} {email}") elif status == 412: # Конфликт: сервер изменил карточку → принять серверную # версию, локальную в лог. if cid in server_ids: c_href2 = href_for(cid, cards) server_vcard = cards[c_href2].get("vcard", "") if c_href2 else "" if server_vcard: local_prev = {k: contact.get(k) for k in ("full_name", "phone", "phone_secondary", "position", "company", "address")} merged = parse_vcard_to_contact(server_vcard, cid, base_contact=contact) for k in ("full_name", "phone", "phone_secondary", "position", "company", "address"): if k in merged and merged[k]: contact[k] = merged[k] etag, _ = get_card_etag(base_url, user, addressbook, cid, auth) contact["caldav"] = {"uid": cid, "etag": etag or "", "from_device": True} stats["conflict"] += 1 changed = True if verbose: print(f" ⚠ {cid}: 412 конфликт → сервер победил, локальное в log") log_entries.append(f"CONFLICT-PUSH {cid} conflict_local={json.dumps(local_prev, ensure_ascii=False)}") else: if verbose: print(f" ✗ {cid}: PUT 412, но карточка исчезла — повтор на след. раз") else: if verbose: print(f" ✗ {cid}: PUT {status}: {body[:120]}") # ── Сохраняем базу ───────────────────────────────────────────────────── if changed and not dry_run: contacts.sort(key=lambda c: c.get("email", "")) # by_email хранит индексы — перестроить после сортировки by_email = {c.get("email", ""): i for i, c in enumerate(contacts)} contacts_db["contacts"] = contacts contacts_db["by_email"] = by_email tmp = contacts_dir / "contacts.json.tmp" with open(tmp, "w", encoding="utf-8") as f: json.dump(contacts_db, f, ensure_ascii=False, indent=2) tmp.replace(contacts_dir / "contacts.json") # index.json with open(contacts_dir / "index.json.tmp", "w", encoding="utf-8") as f: json.dump(by_email, f, ensure_ascii=False, indent=2) tmp2 = contacts_dir / "index.json.tmp" tmp2.replace(contacts_dir / "index.json") if log_entries: log_sync(log_entries) return stats, None def href_for(cid, cards): """Найти href карточки по uid (имя файла).""" for href in cards: if href.rstrip("/").split("/")[-1].startswith(cid): return href return None def _vcard_field_names(vcard_text): """Вернуть имена полей vCard (FN, N, EMAIL, TEL, ...) — для проверки наличия.""" names = set() for line in vcard_text.replace("\r\n", "\n").split("\n"): line = line.strip() if not line or ":" not in line: continue key, _, _ = line.partition(":") name = key.split(";")[0].upper() if name not in ("BEGIN", "END", "VERSION", "UID"): names.add(name) return names def _normalize_vcard(vcard): """Нормализовать vCard для сравнения. Radicale переупорядочивает поля и сворачивает длинные строки (RFC-5545 folding, continuation line начинается с пробела). Сравниваем как упорядоченный мультисет unfolded-строк. """ # Развернуть folding: строка, начинающаяся с пробела/таба — продолжение raw = re.sub(r"\r\n", "\n", vcard) unfolded = [] for line in raw.split("\n"): if line.startswith((" ", "\t")) and unfolded: unfolded[-1] = unfolded[-1] + line.strip() else: unfolded.append(line.strip()) lines = [l for l in unfolded if l and not l.startswith(("BEGIN:", "END:", "VERSION:", "UID:"))] # Radicale нормализует ADR (добавляет/переставляет пустые ;) — нормализуем и мы norm = [] for l in lines: if l.startswith("ADR;"): k, v = l.split(":", 1) # оставить только значение адреса (3-й компонент) parts = v.rstrip(";").split(";") norm.append(f"{k}:;;{parts[2] if len(parts) > 2 else ''};;;") else: norm.append(l) return sorted(norm) # ─── CLI ────────────────────────────────────────────────────────────────────── def main(): parser = argparse.ArgumentParser(description="CardDAV-синк контактов с Radicale") parser.add_argument("--base-url", default=os.environ.get("CALDAV_URL", DEFAULT_BASE_URL)) parser.add_argument("--user", default=os.environ.get("CALDAV_USER", DEFAULT_USER)) parser.add_argument("--pass", dest="password", default=None, help="Пароль (или env CALDAV_PASS/RADICALE_PASS)") parser.add_argument("--addressbook", default=DEFAULT_ADDRESSBOOK, help="Имя адресной книги") parser.add_argument("--prune", action="store_true", help="Удалять карточки без контакта в базе") parser.add_argument("--dry-run", action="store_true", help="Не сохранять изменения") parser.add_argument("--verbose", action="store_true", default=True) args = parser.parse_args() password = args.password if not password: password = os.environ.get("CALDAV_PASS") or os.environ.get("RADICALE_PASS") if not password: # fallback: radikal .env env_path = Path("/opt/hermes/email-assistant/radicale/.env") try: for line in env_path.read_text().splitlines(): if line.startswith("RADICALE_PASS="): password = line.split("=", 1)[1].strip().strip('"').strip("'") break except FileNotFoundError: pass if not password: print("Не задан пароль: --pass / CALDAV_PASS / RADICALE_PASS", file=sys.stderr) return 1 if args.dry_run: print("🔍 DRY RUN — база не сохраняется") stats, err = sync_contacts_carddav( args.base_url, args.user, password, args.addressbook, prune=args.prune, dry_run=args.dry_run, ) if err: print(f"❌ {err}", file=sys.stderr) return 1 print("\n📇 CardDAV sync завершён:") for k, v in stats.items(): if v: print(f" {k}: {v}") return 0 if __name__ == "__main__": sys.exit(main())