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:
@@ -11,4 +11,11 @@
|
||||
## Оценка альтернатив
|
||||
|
||||
- [Анализ Nylas CLI](NYLAS_ANALYSIS.md) — почему Nylas **не подходит** для локального архива (2026-09-11)
|
||||
- [Анализ формата хранения: ФС vs Maildir](STORAGE_ANALYSIS.md) — почему текущий формат удобнее Maildir для локальной LLM (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)
|
||||
@@ -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
|
||||
|
||||
@@ -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