mirror of
https://gitverse.ru/kpa39l/email-assistant.git
synced 2026-09-29 09:15:09 +00:00
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:
@@ -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())
|
||||
@@ -308,6 +308,8 @@ def get_date_from_path(path):
|
||||
def save_progress(contacts_db, contacts_dir, processed_uids, processed_emails):
|
||||
"""Инкрементальное сохранение контактов и last_scan."""
|
||||
contacts_db["contacts"].sort(key=lambda c: c.get("email", ""))
|
||||
# Перестроить by_email — он хранит ИНДЕКСЫ позиций; после сортировки они сдвигаются
|
||||
contacts_db["by_email"] = {c.get("email", ""): i for i, c in enumerate(contacts_db["contacts"])}
|
||||
save_json(contacts_dir / "contacts.json", contacts_db)
|
||||
save_json(contacts_dir / "index.json", contacts_db.get("by_email", {}))
|
||||
generate_vcard(contacts_dir, contacts_db["contacts"])
|
||||
@@ -485,6 +487,7 @@ def scan(limit=0):
|
||||
if save_counter >= 5:
|
||||
save_counter = 0
|
||||
contacts_db["contacts"].sort(key=lambda c: c.get("email", ""))
|
||||
contacts_db["by_email"] = {c.get("email", ""): i for i, c in enumerate(contacts_db["contacts"])}
|
||||
save_json(contacts_dir / "contacts.json", contacts_db)
|
||||
save_json(contacts_dir / "index.json", contacts_db.get("by_email", {}))
|
||||
generate_vcard(contacts_dir, contacts_db["contacts"])
|
||||
|
||||
Reference in New Issue
Block a user