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
+7
View File
@@ -12,3 +12,10 @@
- [Анализ Nylas CLI](NYLAS_ANALYSIS.md) — почему Nylas **не подходит** для локального архива (2026-09-11)
- [Анализ формата хранения: ФС vs Maildir](STORAGE_ANALYSIS.md) — почему текущий формат удобнее Maildir для локальной LLM (2026-09-11)
## CardDAV / CalDAV (контакты, календарь, задачи)
- **Radicale** (:5232, bigbox, Docker) — CalDAV/CardDAV-сервер. Карточки контактов синхронизируются двусторонне: `scripts/contacts_caldav_sync.py` (push 81 контакт → vCard; pull правок/создания/удаления с телефона → `contacts.json`).
- **Vikunja** (:3456) — трекер задач (в Docker, разворачивается).
- **Android:** [DAVx⁵](https://www.davx5.com) (F-Droid/Play) для контактов/календаря, jtx board для задач Vikunja.
- **Публичный доступ:** через Caddy на vps02 (`cal.nixg.ru` → Radicale, `tasks.nixg.ru` → Vikunja) — настройка = следующая задача; подробности и конфиги Caddy: [STATUS.md → «DAVx⁵ (Android: CalDAV/CardDAV-мост)»](STATUS.md)
+53 -6
View File
@@ -119,13 +119,15 @@ Hermes cron:
- [x] Change `local-calendar-tasks` создан и валиден (опenspec-lab) — proposal/specs/design/tasks
- [x] **Radicale развёрнут и РАБОТАЕТ (2026-09-13)**: контейнер на :5232, PROPFIND 207 с паролем / 401 без. Коллекции Личный/Рабочий/Задачи на ФС.
- [x] **Vikunja v2.6.0 развёрнут (2026-09-13)**: postgres + app, миграции прошли, HTTP слушает :3456. **Блокер:** снаружи connection reset (docker-proxy/NAT на bigbox) — контейнер отвечает, маунт файлов исправлен (./files → /app/vikunja/files).
- [ ] Vikunja: создать админа + API-токен, проверить tasks.nixg.ru
- [ ] Caddy reverse proxy (cal.nixg.ru → 5232, tasks.nixg.ru → 3456)
- [ ] Android-синхронизация (DAVx5)
- [x] **CardDAV-синк контактов (2026-09-13, change `contacts-caldav-server`)**: `scripts/contacts_caldav_sync.py` — двусторонний sync. PUSH: 81 контакт → vCard в Radicale. PULL: правки/создание/удаление карточек с телефона → contacts.json. Идемпотентно (162 unchanged, 0 PUT на повторе). Конфликты (412) — приоритет телефону, локальная версия в `caldav-sync.log`. Подробнее: `scripts/contacts_caldav_sync.py --help`, лог `/opt/hermes/email/contacts/caldav-sync.log`.
- [ ] **Vikunja: создать админа + API-токен, проверить tasks.nixg.ru**
- [ ] **Caddy reverse proxy (cal.nixg.ru → 5232, tasks.nixg.ru → 3456)** — СДЕЛАТЬ СЛЕДУЮЩЕЙ СЕССИЕЙ (см. ниже «DAVx⁵»)
- [ ] **Android-синхронизация (DAVx5)** — СДЕЛАТЬ СЛЕДУЮЩЕЙ СЕССИЕЙ (см. ниже)
### Задача 3: Нативная синхронизация с Android ⬜ (после Задачи 2)
- [ ] DAVx5 на телефоне → Radicale; Vikunja app/token
- [ ] Проверка двусторонней синхронизации (событие с телефона → bigbox)
### Задача 3: Нативная синхронизация с Android 🔵 (следующая сессия)
- [ ] **Первая задача след. сессии: настроить синхронизацию с телефоном** (DAVx5 → Radicale; Vikunja → jtx board)
- [ ] Настроить Caddy reverse proxy для Radicale (cal.nixg.ru) — см. ниже
- [ ] Проверка двусторонней синхронизации (событие/контакт с телефона → bigbox → база)
### Задача 1: Веб-интерфейс ассистента ⬜
- [ ] FastAPI + SQLite FTS5: список писем (дата/адресант/тэги/папка)
@@ -235,6 +237,51 @@ Hermes cron:
---
## DAVx⁵ (Android: CalDAV/CardDAV-мост)
**Назначение:** DAVx⁵ — приложение-синхронизатор для Android, **не имеет собственного UI** для просмотра событий/контактов, а встраивается в стандартные системные приложения Android (Календарь, Контакты). Это стандарт де-факто для синхронизации с Radicale на Android.
- **Где взять:** F-Droid (бесплатно) или Google Play (платно, поддержка разработчиков).
- **Как работает:** добавляете аккаунт (URL сервера Radicale, логин, пароль) → DAVx⁵ сам находит доступные календари и адресные книги → выбираете, что синхронизировать с системой.
- **Для задач** пользователь поставил **jtx board** (не DAVx⁵).
### Настройка Caddy для Radicale
Проксирование Radicale через Caddy требует внимания к путям и заголовкам, иначе CalDAV/CardDAV-клиенты не найдут ресурсы.
**Рабочий пример для домена `dav.example.com` (Radicale в корне):**
```caddyfile
dav.example.com {
# Важно: сохраняем заголовок Authorization для Radicale
header_up Authorization {header.Authorization}
# Если Radicale в подпапке — используйте handle_path (см. ниже)
reverse_proxy localhost:5232
}
```
**Ключевые моменты:**
1. **Сохранение Authorization:** Caddy по умолчанию может удалять заголовки. `header_up Authorization {header.Authorization}` гарантирует, что Radicale получит логин/пароль.
2. **X-Script-Name (если в подпапке):** для размещения по `/radicale` нужен `handle_path` для удаления префикса пути + заголовок `X-Script-Name`, чтобы Radicale знал о своём расположении.
3. **Обязательный `handle_path` для подпапки:** простой `reverse_proxy` внутри `handle` может не сработать — Radicale ожидает запросы без префикса (получает его через `X-Script-Name`).
**Пример для подпапки `/radicale`:**
```caddyfile
dav.example.com {
handle_path /radicale/* {
header_up Authorization {header.Authorization}
header_up X-Script-Name /radicale
reverse_proxy localhost:5232
}
}
```
**В DAVx⁵ указывается базовый URL** (например, `https://dav.example.com` или `https://dav.example.com/radicale`); пути к календарям/контактам приложение определяет автоматически.
**Наш случай (следующая сессия):** поддомен `cal.nixg.ru` (Caddy на vps02, `reverse_proxy 10.8.0.2:5232` к bigbox) — Radicale слушает 127.0.0.1:5232, user `estorozhenko`, пароль `RADICALE_PASS` в `radicale/.env`. Адресная книга: `Контакты` (кириллица в URL — DAVx5 умеет). Vikunja → jtx board через `tasks.nixg.ru`.
---
## Конфигурация
- **Репозиторий:** `https://gitea.nixg.ru/hermes/email-assistant`
@@ -2,59 +2,65 @@
## 1. Адресная книга в Radicale
- [ ] 1.1 Создать адресную книгу «Контакты» на ФС:
- [x] 1.1 Создать адресную книгу «Контакты» на ФС:
`mkdir -p /opt/hermes/email-assistant/radicale/data/collections/collection-root/estorozhenko/Контакты`
и проверить, что Radicale видит её как addressbook:
`curl -u estorozhenko:$RADICALE_PASS -X PROPFIND -H 'Depth: 0' http://127.0.0.1:5232/estorozhenko/Контакты/`
→ HTTP 207 и в XML есть `<C:addressbook>` (если RS не определяется —
добавить `.Radicale.props` с addressbook и повторить).
- [ ] 1.2 Положить тестовую vCard (test.vcf) в коллекцию и проверить, что
**Сделано:** MKCOL через протокол дал 201; `.Radicale.props` = `{"tag": "VADDRESSBOOK"}`.
- [x] 1.2 Положить тестовую vCard (test.vcf) в коллекцию и проверить, что
она отдаётся: `curl ... /estorozhenko/Контакты/test.vcf` → 200 + vCard;
затем удалить тестовую карточку.
## 2. Синк в contacts_extractor.py
- [ ] 2.1 Рефакторинг: вынести генерацию vCard 4.0 из существующей
- [x] 2.1 Рефакторинг: вынести генерацию vCard 4.0 из существующей
generate_vcard() в отдельную функцию `contact_to_vcard(contact) -> str`,
чтобы переиспользовать для CardDAV-карточек. Проверка: скрипт
запускается без ошибок, contacts.vcf генерируется как раньше.
- [ ] 2.2 Добавить функцию `sync_contacts_to_caldav(contacts_dir, base_url,
**Сделано:** в `scripts/contacts_caldav_sync.py` (модульная выноска, contacts_extractor не тронут).
- [x] 2.2 Добавить функцию `sync_contacts_to_caldav(contacts_dir, base_url,
user, password, prune=False)`: читает contacts.json, строит карту
uid→(ETag, href) через PROPFIND Depth:1, PUT создаёт/обновляет vCard,
при `prune=True` DELETE удаляет лишние. Использовать urllib + Basic
Auth. Проверка: юнит-запуск с тестовым Radicale (см. 3.x).
- [ ] 2.3 CLI-флаги: `--sync-caldav`, `--prune-caldav`, `--caldav-url`,
- [x] 2.3 CLI-флаги: `--sync-caldav`, `--prune-caldav`, `--caldav-url`,
`--caldav-user`, `--caldav-pass` (или env CALDAV_PASS). По умолчанию
синк выключен. Проверка: `python3 scripts/contacts_extractor.py --help`
показывает все флаги; без `--sync-caldav` поведение прежнее.
- [ ] 2.4 Двусторонняя сверка: `sync_carddav()` после push выполняет pull —
**Сделано:** CLI в `contacts_caldav_sync.py` (--sync-caldav выключен по умолчанию, --dry-run и пр.)
- [x] 2.4 Двусторонняя сверка: `sync_carddav()` после push выполняет pull —
PROPFIND Depth:1, сравнение ETag, создание/обновление контактов из
новых/изменённых карточек (REQ-008/009), `deleted: true` при удалении
карточки (REQ-010). Проверка: см. 3.5-3.8 (reverse-pull тесты).
- [ ] 2.5 ETag-конфликты: при PUT с If-Match и ответе 412 — принять версию
- [x] 2.5 ETag-конфликты: при PUT с If-Match и ответе 412 — принять версию
с сервера, локальную записать в `caldav-sync.log` (`conflict_local`),
продолжить (REQ-011). Проверка: ручной тест — изменить vCard на
сервере И контакт в базе, запустить синк, увидеть conflict в логе.
## 3. Сквозной тест sync
- [ ] 3.1 Запустить синк с реальной базой:
- [x] 3.1 Запустить синк с реальной базой:
`python3 scripts/contacts_extractor.py --sync-caldav`
(или отдельный скрипт) → в Radicale появились карточки (счётчик
PROPFIND/cards): `curl ... PROPFIND Depth:1 /estorozhenko/Контакты/`
показывает N карточек ≈ количеству контактов в contacts.json с email.
- [ ] 3.2 Повторный запуск — количество карточек не растёт (идемпотентность).
- [ ] 3.3 Изменить контакт в contacts.json (добавить телефон) → повторный
**Сделано:** 81 карточка создана (81 контакт), PROPFIND 207.
- [x] 3.2 Повторный запуск — количество карточек не растёт (идемпотентность).
**Сделано:** стабильно 162 unchanged (81 push + 81 pull), 0 PUT.
- [x] 3.3 Изменить контакт в contacts.json (добавить телефон) → повторный
синк обновляет карточку (TEL появился, ETag изменился).
- [ ] 3.4 `--prune-caldav`: удалить контакт из contacts.json → карточка
- [x] 3.4 `--prune-caldav`: удалить контакт из contacts.json → карточка
удалена с сервера.
- [ ] 3.5 **Reverse pull:** вручную (curl PUT) изменить vCard на сервере →
- [x] 3.5 **Reverse pull:** вручную (curl PUT) изменить vCard на сервере →
повторный синк обновляет контакт в contacts.json.
- [ ] 3.6 **Новая карточка на сервере:** curl PUT новой vCard → синк создаёт
контакт в базе.
- [ ] 3.7 **Удаление на сервере:** curl DELETE vCard → контакт получает
`deleted: true` в базе.
- [ ] 3.8 **Конфликт:** изменить и vCard (curl), и контакт в базе → синк
**Сделано:** добавлен/удалён TEL на сервере → база обновилась/обнулила phone.
- [x] 3.6 **Новая карточка на сервере:** curl PUT новой vCard → синк создаёт
контакт в базе. **Сделано:** pulled_created (id=phone-newcard-001, from_device=True).
- [x] 3.7 **Удаление на сервере:** curl DELETE vCard → контакт получает
`deleted: true` в базе. **Сделано:** pulled_deleted, артефакт убран из базы.
- [x] 3.8 **Конфликт:** изменить и vCard (curl), и контакт в базе → синк
применяет версию с сервера, локальная в caldav-sync.log.
## 4. Интеграция в cron и документация
@@ -62,12 +68,30 @@
- [ ] 4.1 Обновить `config/contacts-cron.sh`: добавить `--sync-caldav` и
подтянуть пароль из radicale/.env (env CALDAV_PASS). Проверка:
запуск cron-скрипта вручную синкает контакты без ошибок.
- [ ] 4.2 Обновить README/STATUS: раздел «CardDAV (контакты)» — как
- [x] 4.2 Обновить README/STATUS: раздел «CardDAV (контакты)» — как
подключить на Android (DAVx5, URL http://cal.nixg.ru:5232
или cal.nixg.ru, логин estorozhenko), порты, флаги синка.
**Сделано (2026-09-13):** STATUS.md — новый раздел «DAVx⁵ (Android: CalDAV/CardDAV-мост)» + Caddy-конфиги (Authorization, handle_path, X-Script-Name), Задача 3 помечена как следующая сессия.
- [ ] 4.3 `openspec validate contacts-caldav-server` → valid.
- [ ] 4.4 Git commit и push (gitea.nixg.ru/hermes/email-assistant).
## 5. Синхронизация с телефоном (СЛЕДУЮЩАЯ СЕССИЯ — первая задача)
- [ ] 5.1 Настроить Caddy на vps02: `cal.nixg.ru` → Radicale bigbox :5232
(header_up Authorization, reverse_proxy 10.8.0.2:5232);
`tasks.nixg.ru` → Vikunja :3456. Reload Caddyfile.
- [ ] 5.2 Проверить CalDAV/CardDAV снаружи: PROPFIND https://cal.nixg.ru/... → 207
(учесть кириллицу «Контакты» в URL, Auth).
- [ ] 5.3 DAVx⁵ на телефоне: аккаунт https://cal.nixg.ru, логин estorozhenko,
пароль RADICALE_PASS → выбрать календари (Личный/Рабочий/Задачи)
и адресную книгу «Контакты».
- [ ] 5.4 jtx board на телефоне → Vikunja https://tasks.nixg.ru (токен API),
проверить задачи.
- [ ] 5.5 Проверка двусторонней синхронизации: контакт создан/изменён на
телефоне → в базе (contacts.json) после `contacts_caldav_sync.py`;
задача из jtx board → в Vikunja; событие из DAVx⁵ → в Radicale.
- [ ] 5.6 (опц.) Вписать sync в contacts-cron.sh (4.1) и проверить V5.
## Verification
- [ ] V1: `curl -X PROPFIND -u estorozhenko:PASS http://127.0.0.1:5232/estorozhenko/Контакты/` → 207 + addressbook
+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())
+3
View File
@@ -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"])