Compare commits

...

4 Commits

Author SHA1 Message Date
hermes 8ea022f5c0 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) для следующей сессии
2026-09-13 17:21:51 +00:00
hermes 17252ebfa9 openspec: contacts-caldav-server — двусторонний синк (правки с телефона → в базу) 2026-09-13 16:07:27 +00:00
hermes de07fae246 openspec: change contacts-caldav-server (CardDAV sync для контактов из почты) 2026-09-13 15:59:49 +00:00
hermes 2a597f7325 chore: Radicale/Vikunja подняты в docker (2026-09-13), STATUS обновлён 2026-09-13 15:48:28 +00:00
10 changed files with 1135 additions and 10 deletions
+8 -1
View File
@@ -11,4 +11,11 @@
## Оценка альтернатив ## Оценка альтернатив
- [Анализ Nylas CLI](NYLAS_ANALYSIS.md) — почему Nylas **не подходит** для локального архива (2026-09-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)
+55 -8
View File
@@ -117,15 +117,17 @@ Hermes cron:
### Задача 2: Локальный календарь + трекер задач 🔵 (в работе) ### Задача 2: Локальный календарь + трекер задач 🔵 (в работе)
- [x] Решение пользователя: **Radicale (CalDAV) + Vikunja (трекер)**, всё в Docker-контейнерах - [x] Решение пользователя: **Radicale (CalDAV) + Vikunja (трекер)**, всё в Docker-контейнерах
- [x] Change `local-calendar-tasks` создан и валиден (опenspec-lab) — proposal/specs/design/tasks - [x] Change `local-calendar-tasks` создан и валиден (опenspec-lab) — proposal/specs/design/tasks
- [x] **Radicale развёрнут**: контейнер, порт 5232, HTTP Basic (estorozhenko), PROPFIND → 207, без пароля → 401 - [x] **Radicale развёрнут и РАБОТАЕТ (2026-09-13)**: контейнер на :5232, PROPFIND 207 с паролем / 401 без. Коллекции Личный/Рабочий/Задачи на ФС.
- [ ] Коллекции Radicale (Личный/Рабочий/Задачи) — создание через MKCOL вернуло 403 (Radicale 3.x создаёт коллекции иначе: PUT ресурса); **заблокировано ожиданием решения** - [x] **Vikunja v2.6.0 развёрнут (2026-09-13)**: postgres + app, миграции прошли, HTTP слушает :3456. **Блокер:** снаружи connection reset (docker-proxy/NAT на bigbox) — контейнер отвечает, маунт файлов исправлен (./files → /app/vikunja/files).
- [ ] Vikunja — не начат (docker compose + postgres, порт 3456) - [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`.
- [ ] Caddy reverse proxy (cal.nixg.ru → 5232, tasks.nixg.ru → 3456) - [ ] **Vikunja: создать админа + API-токен, проверить tasks.nixg.ru**
- [ ] Android-синхронизация (DAVx5) - [ ] **Caddy reverse proxy (cal.nixg.ru → 5232, tasks.nixg.ru → 3456)** — СДЕЛАТЬ СЛЕДУЮЩЕЙ СЕССИЕЙ (см. ниже «DAVx⁵»)
- [ ] **Android-синхронизация (DAVx5)** — СДЕЛАТЬ СЛЕДУЮЩЕЙ СЕССИЕЙ (см. ниже)
### Задача 3: Нативная синхронизация с Android ⬜ (после Задачи 2) ### Задача 3: Нативная синхронизация с Android 🔵 (следующая сессия)
- [ ] DAVx5 на телефоне → Radicale; Vikunja app/token - [ ] **Первая задача след. сессии: настроить синхронизацию с телефоном** (DAVx5 → Radicale; Vikunja → jtx board)
- [ ] Проверка двусторонней синхронизации (событие с телефона → bigbox) - [ ] Настроить Caddy reverse proxy для Radicale (cal.nixg.ru) — см. ниже
- [ ] Проверка двусторонней синхронизации (событие/контакт с телефона → bigbox → база)
### Задача 1: Веб-интерфейс ассистента ⬜ ### Задача 1: Веб-интерфейс ассистента ⬜
- [ ] FastAPI + SQLite FTS5: список писем (дата/адресант/тэги/папка) - [ ] 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` - **Репозиторий:** `https://gitea.nixg.ru/hermes/email-assistant`
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-13
@@ -0,0 +1,143 @@
# Design: CardDAV-сервер для контактов (Radicale sync)
## Context
- Radicale уже развёрнут в `/opt/hermes/email-assistant/radicale/` (docker,
порт 5232), работает CalDAV (календарь). Radicale из коробки умеет
CardDAV — адресные книги создаются так же, как календари (коллекции на
ФС), разница только в `resourcetype` (`<C:addressbook>` вместо
`<C:calendar>`).
- Пользователь: `estorozhenko`, пароль — в `radicale/.env` (`RADICALE_PASS`),
htpasswd-файл `/data/users` в контейнере.
- Коллекции Radicale лежат на ФС:
`/opt/hermes/email-assistant/radicale/data/collections/collection-root/estorozhenko/`
(подпапки Личный, Рабочий, Задачи — календари; увидим, что у Задач
resourcetype VTODO).
- Контакты извлекаются `scripts/contacts_extractor.py` (Qwen3:8b через
Ollama), пишутся в `/opt/hermes/email/contacts/`:
- `contacts.json` — база `{"contacts": [...], "by_email": {...}}`
- `index.json` — email → contact_id
- `contacts.vcf` — vCard 4.0 (для импорта)
- `last_scan.json` — трекинг обработанных писем
- Сеть: bigbox (10.8.0.2), наружу — Caddy на vps02 (cal.nixg.ru → 5232).
## Решение
### 1. Адресная книга в Radicale (создание на ФС)
Radicale 3.x при `owner_only` правах НЕ даёт создавать коллекции через
MKCOL (403) — проверено на календарях в прошлой сессии. Коллекции создаются
напрямую на ФС (как уже сделано для Личный/Рабочий/Задачи) или через PUT
первого ресурса.
**Способ (ФС):**
```
mkdir -p radicale/data/collections/collection-root/estorozhenko/Контакты
```
Radicale сам распознает коллекцию, когда в неё положат .vcf (Radicale
создаёт .Radicale.props при первом обращении; для CardDAV-книги достаточно,
чтобы в коллекции были .vcf-файлы). Для явного resourcetype можно положить
`.Radicale.props` с `{"C:addressbook": {}}` — но сначала проверить, что
Radicale выставляет addressbook автоматически по наличию .vcf.
**Проверка RS-типа:**
```
curl -u estorozhenko:PASS -X PROPFIND -H 'Depth: 0' \
http://127.0.0.1:5232/estorozhenko/Контакты/
```
→ должен содержать `<C:addressbook>`.
### 2. Двусторонний синк в contacts_extractor.py
После извлечения/дедупликации запускается **sync_carddav()**, который
выполняет двустороннюю сверку между `contacts.json` и адресной книгой
Radicale.
**Состояние:**
- У каждого контакта в `contacts.json` добавляется поле
`caldav: {uid, etag, synced_at, from_device: bool}` (uid = contact_id,
etag — ETag последней применённой версии карточки).
- Локальная база остаётся источником истины для дедупликации по `email`.
**Алгоритм (запуск 1):**
1. `PROPFIND Depth:1` по `/estorozhenko/Контакты/` → карта `href → (ETag, content-ty`pe, vCard)` (vCard тянем GET'ом по href для сравнения содержимого, если нужно).
2. **Pull (сервер → база):**
- карточка есть на сервере, соответствующего контакта нет в базе → создать контакт (id = UID карточки), пометить `from_device: true` (REQ-009);
- ETag карточки ≠ etag из `contacts.json[caldav.etag]`:
- если у контакта `from_device: true` (последний владелец — телефон) → применить серверную версию (REQ-008);
- если `from_device: false` (последний владелец — почта) → **конфликт** (REQ-011): применить версию с сервера, локальную version сохранить в `caldav-sync.log` (`conflict_local`), сбросить `etag` на актуальный;
- карточки на сервере нет, в базе есть `caldav.uid` → контакт `deleted: true` (REQ-010).
3. **Push (база → сервер):**
- у контакта есть `caldav.uid`, но нет карточки, и `deleted != true` → PUT (создание) (REQ-002);
- локальные поля изменились (сравнить с последней применённой vCard или `synced_at`) → PUT с `If-Match: etag`; при 412 → конфликт: принять серверную версию, локальную в лог (REQ-011);
- `deleted: true` у контакта, карточка есть → DELETE (по флагу `--prune-caldav`, по умолчанию — оставить и логировать).
4. `--prune-caldav` (не по умолчанию): удалять с сервера карточки, у которых нет контакта в базе (REQ-005).
**Ключевое правило конфликтов:** приоритет — сервер (телефон) как актуальная
версия; локальная версия никогда не теряется (лог `conflict_local`). Это
сознательное решение: правки руками на телефоне считаются более «живыми»,
чем автопарсинг подписей писем.
**Клиент:** стандартный `urllib.request` + `base64` Basic Auth (без новых
зависимостей) или `curl` через subprocess. Предпочтительно urllib — синк
вызывается из cron (contacts-cron.sh) и не должен зависеть от curl-параметров.
**Флаги CLI:**
```
contacts_extractor.py --sync-caldav # включить синк после обработки
contacts_extractor.py --sync-caldav --prune-caldav
contacts_extractor.py --caldav-url http://127.0.0.1:5232
contacts_extractor.py --caldav-user estorozhenko
contacts_extractor.py --caldav-pass <pass> # или env CALDAV_PASS
```
По умолчанию — без `--sync-caldav` ничего не синкается (обратная
совместимость: старые запуски не меняют поведение).
**Конфиг:** пароль берётся из env `CALDAV_PASS` или `--caldav-pass`;
URL по умолчанию `http://127.0.0.1:5232` (можно переопределить).
### 3. Cron
Добавить `--sync-caldav` в существующий `config/contacts-cron.sh` (тот же
cron `contacts-extractor-every-30m`, no-agent скрипт). Отдельный cron не
нужен — синк происходит в конце каждого инкрементального прогона.
CALDAV_PASS — из `radicale/.env` (источник пароля один).
### 4. Переменные/секрет
Пароль Radicale уже лежит в `radicale/.env`. contacts-cron.sh будет читать
`RADICALE_PASS` оттуда и передавать в `--caldav-pass` (или env).
В git-коммит .env не идёт (.gitignore) — секреты в репозитории нет.
## Open Questions
- **Radicale-версия:** 3.8.1.dev0 в контейнере — проверить, что PROPFIND
Depth:1 по адресной книге возвращает ETag (нет — можно Vary: и x-radicale).
(решается на этапе задач — если ETag не приходит, требование REQ-004
упрощается до PUT с If-None-Match на создание.)
- **Имя коллекции:** «Контакты» (кириллица) — Radicale поддерживает
кириллические имена (уже есть Личный/Рабочий/Задачи). Клиенты DAVx5
нормально работают с кириллическими путями.
## Testing
- Создание книги: PROPFIND → 207 + addressbook RS.
- PUT vCard → 201; повторный PUT/Bad Request при невалидной vCard → 400.
- Повторный синк → 204/пропуск, дублей нет.
- Изменение контакта в базе → PUT 204 + обновлённая vCard.
- **Reverse pull:** DAVx5/curl меняет vCard на сервере → синк обновляет
контакт в базе (REQ-008).
- **Новая карточка на сервере** (curl PUT новой vCard) → синк создаёт
контакт в базе (REQ-009).
- **Удаление на сервере** (curl DELETE vCard) → контакт в базе получает
`deleted: true` (REQ-010).
- **Конфликт:** изменить и vCard (curl), и контакт в базе → синк применяет
версию с сервера, локальная в log (REQ-011).
- `--prune-caldav` удаляет отсутствующие карточки.
- После синка: DAVx5 на телефоне видит контакты (ручная проверка).
## Migration / Rollback
- Миграции данных нет (новые коллекции создаются впервые).
- Откат: убрать `--sync-caldav` из cron + удалить коллекцию Контакты на ФС.
Локальная база/файлы не затрагиваются.
@@ -0,0 +1,70 @@
# Proposal: CardDAV-сервер для синхронизации контактов
## Why
Сейчас контакты, извлечённые LLM из подписей писем, лежат только в файлах
`/opt/hermes/email/contacts/{contacts.json, contacts.vcf, index.json}` и никуда
не синхронизируются. Чтобы пользоваться ими на телефоне (Android) и в других
клиентах, нужен CardDAV-сервер с адресными книгами, куда контакты пишутся
сразу при извлечении.
Почему CardDAV, а не просто наличие .vcf: нативный Android (DAVx5) и
большинство клиентов умеют только CardDAV-протокол. Отдельный файл .vcf на
диске никто не читает.
## What Changes
1. **Radicale расширяется на CardDAV** — это тот же сервис Radicale (:5232),
который уже развёрнут для CalDAV (календарь). Radicale из коробки умеет
CardDAV (addressbook collections). Нужно только:
- создать адресную книгу (например `Контакты`) в коллекциях Radicale
- проверить CardDAV-endpoint (`/estorozhenko/Контакты/`)
2. **Контакты пишутся сразу в карточки** — contacts_extractor.py после
извлечения и дедупликации пишет/обновляет vCard в Radicale через
CardDAV PUT, а не только в локальные файлы:
- на каждый контакт — один `.vcf` в адресной книге
- при обновлении контакта — PUT с новым ETag
- удаление контакта, которого больше нет в базе — DELETE (опционально, см. design)
3. **Двусторонний синк** — правки, сделанные с телефона (DAVx5) в адресной
книге, синхронизируются обратно в `contacts.json`:
- изменённые карточки → обновление контакта
- новые карточки → новые контакты
- удалённые карточки → soft-delete (`deleted: true`) в базе
- конфликт (изменено и в почте, и на телефоне) → приоритет телефону,
локальная версия сохраняется в лог, данные не теряются
4. **Локальная база contacts.json** остаётся источником истины (дедупликация,
трекинг processed_uids) и хранит состояние синка (ETag карточки).
## Capabilities
### New Capabilities
- `contacts/carddav-sync`: Синхронизация извлечённых из почты контактов
в CardDAV-сервер (Radicale) — создание/обновление/удаление vCard-карточек,
доступных клиентам (DAVx5 на Android и др.)
### Modified Capabilities
<!-- нет -->
## Impact
- **Сервис:** Radicale (:5232) — уже работает, добавляется CardDAV-часть
(адресная книга). Новых портов нет.
- **Скрипт:** `scripts/contacts_extractor.py` — добавляется синк в Radicale
(PUT/DELETE vCard), появляется зависимость от CardDAV-клиента/HTTP.
- **Данные:** контакты синхронизируются на сервер; конфликты при
параллельном редактировании на телефоне решаются по ETag (см. design).
- **Документация:** обновить README/STATUS (как подключить адресную книгу
на Android, порты).
- **Риски:** двусторонний синк требует разрешения конфликтов (в change —
приоритет телефону + лог локальной версии, см. REQ-011).
## Rollback
1. Отключить синк: убрать шаг синка в `contacts_extractor.py` (флаг
`--no-caldav` / откат коммита) — локальные файлы и база не затрагиваются.
2. Удалить адресную книгу из Radicale: `rm -rf
/opt/hermes/email-assistant/radicale/data/collections/collection-root/estorozhenko/Контакты`
(или через DAVx5).
3. Radicale сам не откатывается — он как был, так и остаётся (CalDAV
календарь продолжает работать).
4. Данные локально не теряются: contacts.json/contacts.vcf остаются.
@@ -0,0 +1,160 @@
# contacts/carddav-sync Specification
## Purpose
Односторонняя синхронизация контактов, извлечённых из подписей писем
(pipeline contacts_extractor), в CardDAV-сервер Radicale (:5232), чтобы
контакты были доступны клиентам (Android/DAVx5, десктопные клиенты).
## ADDED Requirements
### Requirement: REQ-CON-CARDDAV-001: Адресная книга в Radicale
**MUST** — в Radicale должна существовать адресная книга пользователя
`estorozhenko` (коллекция `Контакты`, доступ по CardDAV
`/estorozhenko/Контакты/`), созданная до начала синка.
#### Scenario: Проверка адресной книги
**GIVEN** Radicale запущен на :5232
**WHEN** выполняется `curl -X PROPFIND -u estorozhenko:<pass>
http://127.0.0.1:5232/estorozhenko/Контакты/`
**THEN** возвращается HTTP 207 (Multi-Status) и в ответе есть
`<D:resourcetype>` с `C:addressbook`.
### Requirement: REQ-CON-CARDDAV-002: vCard для каждого контакта
**MUST** — для каждого контакта из `contacts.json` (поле `contacts`),
имеющего поле `email`, в адресной книге лежит ровно одна vCard-карточка
(.vcf), идентифицируемая по UID, с полями: FN, EMAIL, TEL (если есть),
ORG (если есть), TITLE (если есть), ADR (если есть).
#### Scenario: Карточка создана
**GIVEN** в contacts.json есть контакт `{email: "a@b.ru", full_name: "Иван"}`
**WHEN** выполняется `curl -X PROPFIND .../Контакты/...a@b.ru.vcf`
**THEN** возвращается 200/207 и vCard содержит `FN:Иван`, `EMAIL:a@b.ru`.
### Requirement: REQ-CON-CARDDAV-003: Повторная синхронизация идемпотентна
**MUST** — повторный запуск синка с теми же данными не создаёт дублей
(vCard уже существует → только обновление по ETag, не новый ресурс).
#### Scenario: Двойной запуск
**GIVEN** синк выполнен один раз
**WHEN** синк выполняется второй раз без изменений данных
**THEN** количество ресурсов в адресной книге не изменяется.
### Requirement: REQ-CON-CARDDAV-004: Обновление существующей карточки
**MUST** — при изменении данных контакта в contacts.json (например,
у контакта появился телефон) карточка в Radicale обновляется (PUT с If-Match
по ETag). Если клиент на телефоне уже изменил карточку (ETag не совпал) —
изменения локальной базы не перезаписывают карточку молча; синк пропускает
обновление и логирует конфликт (см. также REQ-CON-CARDDAV-006).
#### Scenario: Контакт обновлён локально
**GIVEN** у контакта в базе появился `phone`
**WHEN** выполняется синк
**THEN** карточка в Radicale содержит новый `TEL`.
### Requirement: REQ-CON-CARDDAV-005: Удаление карточек
**MUST** — контакты, которых больше нет в `contacts.json` (поле `contacts`),
могут удаляться из адресной книги (DELETE). Удаление не выполняется по
умолчанию (флаг `--prune-caldav`); по умолчанию карточки без соответствующего
контакта остаются.
#### Scenario: Удаление по флагу
**GIVEN** контакт удалён из contacts.json
**WHEN** синк запущен с `--prune-caldav`
**THEN** соответствующая vCard удаляется из адресной книги.
### Requirement: REQ-CON-CARDDAV-006: Неконфликтная работа с клиентами
**MUST** — синк должен использовать ETag (If-Match/If-None-Match) при
PUT, чтобы не затирать изменения, сделанные клиентами на телефоне между
запусками синка. При конфликте ETag — пропустить и записать предупреждение
в лог (файл лога синка).
#### Scenario: Конфликт ETag
**GIVEN** клиент (DAVx5) изменил vCard на сервере после последнего синка
**WHEN** выполняется синк с изменёнными локальными данными этого контакта
**THEN** PUT возвращает 412, синк логирует конфликт и продолжает остальные
карточки.
### Requirement: REQ-CON-CARDDAV-007: Локальная база остаётся источником
**MUST** — contacts.json, contacts.vcf, index.json продолжают обновляться
как раньше (источник истины для дедупликации и трекинга). CardDAV — цель
синка, не замена локальной базе.
#### Scenario: Локальная база не затронута
**GIVEN** синк выполнен
**WHEN** проверяется содержимое /opt/hermes/email/contacts/contacts.json
**THEN** файл существует и содержит актуальную базу контактов.
### Requirement: REQ-CON-CARDDAV-008: Изменение карточки на телефоне
**MUST** — если vCard на сервере изменена клиентом (DAVx5) после последнего
синка (ETag изменился), синк должен обновить соответствующий контакт в
`contacts.json` (поля full_name, phone, position, company, address),
сохранив call/email/прочее.
#### Scenario: Контакт дополнен на телефоне
**GIVEN** на телефоне в vCard контакта добавлен `TEL:+7-900...`
**WHEN** выполняется синк
**THEN** контакт в contacts.json содержит этот телефон.
### Requirement: REQ-CON-CARDDAV-009: Новая карточка на сервере
**MUST** — если на сервере появилась новая vCard, не имеющая соответствия в
`contacts.json` (нет контакта с таким UID), синк должен создать контакт в
базе (id = UID карточки, поля из vCard).
#### Scenario: Карточка создана на телефоне
**GIVEN** DAVx5 создал новую карточку в адресной книге
**WHEN** выполняется синк
**THEN** в contacts.json появляется соответствующий контакт.
### Requirement: REQ-CON-CARDDAV-010: Удаление карточки на сервере
**MUST** — если vCard удалена с сервера клиентом (известный UID, карточки
больше нет), синк должен пометить контакт в базе как удалённый
(`deleted: true`), а не физически удалять (сохранение данных).
#### Scenario: Карточка удалена на телефоне
**GIVEN** vCard контакта удалена в DAVx5
**WHEN** выполняется синк
**THEN** контакт в contacts.json имеет `deleted: true`.
### Requirement: REQ-CON-CARDDAV-011: Разрешение конфликтов
**MUST** — при конфликте (ETag карточки на сервере изменился, И локальный
контакт в базе тоже изменился, т.е. правки с обеих сторон) синк должен
применять версию с сервера (телефон) как актуальную, а локальную версию
сохранять в `caldav-sync.log` (поле `conflict_local`) — данные не теряются.
#### Scenario: Конфликт правок
**GIVEN** и телефон, и почтовый экстрактор изменили один контакт
**WHEN** выполняется синк
**THEN** в контакте применены данные с телефона, локальная версия записана
в caldav-sync.log, следующий запуск не повторяет конфликт.
## Non-Goals
- Глубокая биография/полная модель контакта — синк обновляет стандартные
поля vCard (FN, EMAIL, TEL, ORG, TITLE, ADR); произвольные расширения
vCard (X-*, категории, фото) не переносятся в contacts.json.
- Миграция существующих .vcf из локальных файлов (contacts.vcf остаётся
как есть, синк идёт из contacts.json).
- Синхронизация с внешними CardDAV/Google/Cloud — только локальный Radicale.
@@ -0,0 +1,101 @@
# Tasks: CardDAV-сервер для синхронизации контактов
## 1. Адресная книга в Radicale
- [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 и повторить).
**Сделано:** MKCOL через протокол дал 201; `.Radicale.props` = `{"tag": "VADDRESSBOOK"}`.
- [x] 1.2 Положить тестовую vCard (test.vcf) в коллекцию и проверить, что
она отдаётся: `curl ... /estorozhenko/Контакты/test.vcf` → 200 + vCard;
затем удалить тестовую карточку.
## 2. Синк в contacts_extractor.py
- [x] 2.1 Рефакторинг: вынести генерацию vCard 4.0 из существующей
generate_vcard() в отдельную функцию `contact_to_vcard(contact) -> str`,
чтобы переиспользовать для CardDAV-карточек. Проверка: скрипт
запускается без ошибок, contacts.vcf генерируется как раньше.
**Сделано:** в `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).
- [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` поведение прежнее.
**Сделано:** 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 тесты).
- [x] 2.5 ETag-конфликты: при PUT с If-Match и ответе 412 — принять версию
с сервера, локальную записать в `caldav-sync.log` (`conflict_local`),
продолжить (REQ-011). Проверка: ручной тест — изменить vCard на
сервере И контакт в базе, запустить синк, увидеть conflict в логе.
## 3. Сквозной тест sync
- [x] 3.1 Запустить синк с реальной базой:
`python3 scripts/contacts_extractor.py --sync-caldav`
(или отдельный скрипт) → в Radicale появились карточки (счётчик
PROPFIND/cards): `curl ... PROPFIND Depth:1 /estorozhenko/Контакты/`
показывает N карточек ≈ количеству контактов в contacts.json с email.
**Сделано:** 81 карточка создана (81 контакт), PROPFIND 207.
- [x] 3.2 Повторный запуск — количество карточек не растёт (идемпотентность).
**Сделано:** стабильно 162 unchanged (81 push + 81 pull), 0 PUT.
- [x] 3.3 Изменить контакт в contacts.json (добавить телефон) → повторный
синк обновляет карточку (TEL появился, ETag изменился).
- [x] 3.4 `--prune-caldav`: удалить контакт из contacts.json → карточка
удалена с сервера.
- [x] 3.5 **Reverse pull:** вручную (curl PUT) изменить vCard на сервере →
повторный синк обновляет контакт в contacts.json.
**Сделано:** добавлен/удалён 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 и документация
- [ ] 4.1 Обновить `config/contacts-cron.sh`: добавить `--sync-caldav` и
подтянуть пароль из radicale/.env (env CALDAV_PASS). Проверка:
запуск cron-скрипта вручную синкает контакты без ошибок.
- [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
- [ ] V2: после синка количество vCard в адресной книге == числу контактов с email в contacts.json
- [ ] V3: повторный синк → 0 новых карточек
- [ ] V4: правка контакта на сервере (curl PUT vCard) → контакт в contacts.json обновлён после синка
- [ ] V5: `python3 scripts/contacts_extractor.py --sync-caldav` из cron-обёртки завершается кодом 0 и пишет caldav-sync.log
+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): def save_progress(contacts_db, contacts_dir, processed_uids, processed_emails):
"""Инкрементальное сохранение контактов и last_scan.""" """Инкрементальное сохранение контактов и last_scan."""
contacts_db["contacts"].sort(key=lambda c: c.get("email", "")) 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 / "contacts.json", contacts_db)
save_json(contacts_dir / "index.json", contacts_db.get("by_email", {})) save_json(contacts_dir / "index.json", contacts_db.get("by_email", {}))
generate_vcard(contacts_dir, contacts_db["contacts"]) generate_vcard(contacts_dir, contacts_db["contacts"])
@@ -485,6 +487,7 @@ def scan(limit=0):
if save_counter >= 5: if save_counter >= 5:
save_counter = 0 save_counter = 0
contacts_db["contacts"].sort(key=lambda c: c.get("email", "")) 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 / "contacts.json", contacts_db)
save_json(contacts_dir / "index.json", contacts_db.get("by_email", {})) save_json(contacts_dir / "index.json", contacts_db.get("by_email", {}))
generate_vcard(contacts_dir, contacts_db["contacts"]) generate_vcard(contacts_dir, contacts_db["contacts"])
+1 -1
View File
@@ -42,4 +42,4 @@ services:
ports: ports:
- "3456:80" - "3456:80"
volumes: volumes:
- ./files:/app/files - ./files:/app/vikunja/files