CardDAV: двусторонний синк контактов с Radicale (push+pull) + docs DAVx5/Caddy

- scripts/contacts_caldav_sync.py: push 81 контакт → vCard (Radicale :5232),
  pull правок/создания/удаления с телефона → contacts.json (ETag сверка,
  soft-delete, конфликты 412 → приоритет телефону, лог caldav-sync.log)
- fix(contacts_extractor): перестройка by_email-индексов после sort()
- STATUS.md: раздел DAVx5 + Caddy (Authorization/handle_path/X-Script-Name),
  Задача 3 (синхронизация с телефоном) = следующая сессия
- tasks.md: отмечены 1.x-3.x/V (реализовано и протестировано), добавлен
  раздел 5 (Caddy + DAVx5 + jtx board) для следующей сессии
This commit is contained in:
2026-09-13 17:21:51 +00:00
parent 17252ebfa9
commit 8ea022f5c0
5 changed files with 698 additions and 25 deletions
+592
View File
@@ -0,0 +1,592 @@
#!/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 = """<?xml version="1.0" encoding="utf-8"?>
<propfind xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:carddav">
<prop><getetag/><resourcetype/><href/></prop>
</propfind>"""
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())