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
@@ -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