mirror of
https://gitverse.ru/kpa39l/email-assistant.git
synced 2026-09-29 09:15:09 +00:00
CardDAV: двусторонний синк контактов с Radicale (push+pull) + docs DAVx5/Caddy
- scripts/contacts_caldav_sync.py: push 81 контакт → vCard (Radicale :5232), pull правок/создания/удаления с телефона → contacts.json (ETag сверка, soft-delete, конфликты 412 → приоритет телефону, лог caldav-sync.log) - fix(contacts_extractor): перестройка by_email-индексов после sort() - STATUS.md: раздел DAVx5 + Caddy (Authorization/handle_path/X-Script-Name), Задача 3 (синхронизация с телефоном) = следующая сессия - tasks.md: отмечены 1.x-3.x/V (реализовано и протестировано), добавлен раздел 5 (Caddy + DAVx5 + jtx board) для следующей сессии
This commit is contained in:
@@ -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)
|
||||||
@@ -119,13 +119,15 @@ Hermes cron:
|
|||||||
- [x] Change `local-calendar-tasks` создан и валиден (опenspec-lab) — proposal/specs/design/tasks
|
- [x] Change `local-calendar-tasks` создан и валиден (опenspec-lab) — proposal/specs/design/tasks
|
||||||
- [x] **Radicale развёрнут и РАБОТАЕТ (2026-09-13)**: контейнер на :5232, PROPFIND 207 с паролем / 401 без. Коллекции Личный/Рабочий/Задачи на ФС.
|
- [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).
|
- [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
|
- [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`
|
||||||
|
|||||||
@@ -2,59 +2,65 @@
|
|||||||
|
|
||||||
## 1. Адресная книга в Radicale
|
## 1. Адресная книга в Radicale
|
||||||
|
|
||||||
- [ ] 1.1 Создать адресную книгу «Контакты» на ФС:
|
- [x] 1.1 Создать адресную книгу «Контакты» на ФС:
|
||||||
`mkdir -p /opt/hermes/email-assistant/radicale/data/collections/collection-root/estorozhenko/Контакты`
|
`mkdir -p /opt/hermes/email-assistant/radicale/data/collections/collection-root/estorozhenko/Контакты`
|
||||||
и проверить, что Radicale видит её как addressbook:
|
и проверить, что Radicale видит её как addressbook:
|
||||||
`curl -u estorozhenko:$RADICALE_PASS -X PROPFIND -H 'Depth: 0' http://127.0.0.1:5232/estorozhenko/Контакты/`
|
`curl -u estorozhenko:$RADICALE_PASS -X PROPFIND -H 'Depth: 0' http://127.0.0.1:5232/estorozhenko/Контакты/`
|
||||||
→ HTTP 207 и в XML есть `<C:addressbook>` (если RS не определяется —
|
→ HTTP 207 и в XML есть `<C:addressbook>` (если RS не определяется —
|
||||||
добавить `.Radicale.props` с addressbook и повторить).
|
добавить `.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;
|
она отдаётся: `curl ... /estorozhenko/Контакты/test.vcf` → 200 + vCard;
|
||||||
затем удалить тестовую карточку.
|
затем удалить тестовую карточку.
|
||||||
|
|
||||||
## 2. Синк в contacts_extractor.py
|
## 2. Синк в contacts_extractor.py
|
||||||
|
|
||||||
- [ ] 2.1 Рефакторинг: вынести генерацию vCard 4.0 из существующей
|
- [x] 2.1 Рефакторинг: вынести генерацию vCard 4.0 из существующей
|
||||||
generate_vcard() в отдельную функцию `contact_to_vcard(contact) -> str`,
|
generate_vcard() в отдельную функцию `contact_to_vcard(contact) -> str`,
|
||||||
чтобы переиспользовать для CardDAV-карточек. Проверка: скрипт
|
чтобы переиспользовать для CardDAV-карточек. Проверка: скрипт
|
||||||
запускается без ошибок, contacts.vcf генерируется как раньше.
|
запускается без ошибок, 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, строит карту
|
user, password, prune=False)`: читает contacts.json, строит карту
|
||||||
uid→(ETag, href) через PROPFIND Depth:1, PUT создаёт/обновляет vCard,
|
uid→(ETag, href) через PROPFIND Depth:1, PUT создаёт/обновляет vCard,
|
||||||
при `prune=True` DELETE удаляет лишние. Использовать urllib + Basic
|
при `prune=True` DELETE удаляет лишние. Использовать urllib + Basic
|
||||||
Auth. Проверка: юнит-запуск с тестовым Radicale (см. 3.x).
|
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). По умолчанию
|
`--caldav-user`, `--caldav-pass` (или env CALDAV_PASS). По умолчанию
|
||||||
синк выключен. Проверка: `python3 scripts/contacts_extractor.py --help`
|
синк выключен. Проверка: `python3 scripts/contacts_extractor.py --help`
|
||||||
показывает все флаги; без `--sync-caldav` поведение прежнее.
|
показывает все флаги; без `--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, создание/обновление контактов из
|
PROPFIND Depth:1, сравнение ETag, создание/обновление контактов из
|
||||||
новых/изменённых карточек (REQ-008/009), `deleted: true` при удалении
|
новых/изменённых карточек (REQ-008/009), `deleted: true` при удалении
|
||||||
карточки (REQ-010). Проверка: см. 3.5-3.8 (reverse-pull тесты).
|
карточки (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`),
|
с сервера, локальную записать в `caldav-sync.log` (`conflict_local`),
|
||||||
продолжить (REQ-011). Проверка: ручной тест — изменить vCard на
|
продолжить (REQ-011). Проверка: ручной тест — изменить vCard на
|
||||||
сервере И контакт в базе, запустить синк, увидеть conflict в логе.
|
сервере И контакт в базе, запустить синк, увидеть conflict в логе.
|
||||||
|
|
||||||
## 3. Сквозной тест sync
|
## 3. Сквозной тест sync
|
||||||
|
|
||||||
- [ ] 3.1 Запустить синк с реальной базой:
|
- [x] 3.1 Запустить синк с реальной базой:
|
||||||
`python3 scripts/contacts_extractor.py --sync-caldav`
|
`python3 scripts/contacts_extractor.py --sync-caldav`
|
||||||
(или отдельный скрипт) → в Radicale появились карточки (счётчик
|
(или отдельный скрипт) → в Radicale появились карточки (счётчик
|
||||||
PROPFIND/cards): `curl ... PROPFIND Depth:1 /estorozhenko/Контакты/`
|
PROPFIND/cards): `curl ... PROPFIND Depth:1 /estorozhenko/Контакты/`
|
||||||
показывает N карточек ≈ количеству контактов в contacts.json с email.
|
показывает N карточек ≈ количеству контактов в contacts.json с email.
|
||||||
- [ ] 3.2 Повторный запуск — количество карточек не растёт (идемпотентность).
|
**Сделано:** 81 карточка создана (81 контакт), PROPFIND 207.
|
||||||
- [ ] 3.3 Изменить контакт в contacts.json (добавить телефон) → повторный
|
- [x] 3.2 Повторный запуск — количество карточек не растёт (идемпотентность).
|
||||||
|
**Сделано:** стабильно 162 unchanged (81 push + 81 pull), 0 PUT.
|
||||||
|
- [x] 3.3 Изменить контакт в contacts.json (добавить телефон) → повторный
|
||||||
синк обновляет карточку (TEL появился, ETag изменился).
|
синк обновляет карточку (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.
|
повторный синк обновляет контакт в contacts.json.
|
||||||
- [ ] 3.6 **Новая карточка на сервере:** curl PUT новой vCard → синк создаёт
|
**Сделано:** добавлен/удалён TEL на сервере → база обновилась/обнулила phone.
|
||||||
контакт в базе.
|
- [x] 3.6 **Новая карточка на сервере:** curl PUT новой vCard → синк создаёт
|
||||||
- [ ] 3.7 **Удаление на сервере:** curl DELETE vCard → контакт получает
|
контакт в базе. **Сделано:** pulled_created (id=phone-newcard-001, from_device=True).
|
||||||
`deleted: true` в базе.
|
- [x] 3.7 **Удаление на сервере:** curl DELETE vCard → контакт получает
|
||||||
- [ ] 3.8 **Конфликт:** изменить и vCard (curl), и контакт в базе → синк
|
`deleted: true` в базе. **Сделано:** pulled_deleted, артефакт убран из базы.
|
||||||
|
- [x] 3.8 **Конфликт:** изменить и vCard (curl), и контакт в базе → синк
|
||||||
применяет версию с сервера, локальная в caldav-sync.log.
|
применяет версию с сервера, локальная в caldav-sync.log.
|
||||||
|
|
||||||
## 4. Интеграция в cron и документация
|
## 4. Интеграция в cron и документация
|
||||||
@@ -62,12 +68,30 @@
|
|||||||
- [ ] 4.1 Обновить `config/contacts-cron.sh`: добавить `--sync-caldav` и
|
- [ ] 4.1 Обновить `config/contacts-cron.sh`: добавить `--sync-caldav` и
|
||||||
подтянуть пароль из radicale/.env (env CALDAV_PASS). Проверка:
|
подтянуть пароль из radicale/.env (env CALDAV_PASS). Проверка:
|
||||||
запуск cron-скрипта вручную синкает контакты без ошибок.
|
запуск cron-скрипта вручную синкает контакты без ошибок.
|
||||||
- [ ] 4.2 Обновить README/STATUS: раздел «CardDAV (контакты)» — как
|
- [x] 4.2 Обновить README/STATUS: раздел «CardDAV (контакты)» — как
|
||||||
подключить на Android (DAVx5, URL http://cal.nixg.ru:5232
|
подключить на Android (DAVx5, URL http://cal.nixg.ru:5232
|
||||||
или cal.nixg.ru, логин estorozhenko), порты, флаги синка.
|
или 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.3 `openspec validate contacts-caldav-server` → valid.
|
||||||
- [ ] 4.4 Git commit и push (gitea.nixg.ru/hermes/email-assistant).
|
- [ ] 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
|
## Verification
|
||||||
|
|
||||||
- [ ] V1: `curl -X PROPFIND -u estorozhenko:PASS http://127.0.0.1:5232/estorozhenko/Контакты/` → 207 + addressbook
|
- [ ] 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):
|
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"])
|
||||||
|
|||||||
Reference in New Issue
Block a user