Compare commits
4 Commits
02e2b87072
...
8ea022f5c0
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ea022f5c0 | |||
| 17252ebfa9 | |||
| de07fae246 | |||
| 2a597f7325 |
@@ -11,4 +11,11 @@
|
||||
## Оценка альтернатив
|
||||
|
||||
- [Анализ Nylas CLI](NYLAS_ANALYSIS.md) — почему Nylas **не подходит** для локального архива (2026-09-11)
|
||||
- [Анализ формата хранения: ФС vs Maildir](STORAGE_ANALYSIS.md) — почему текущий формат удобнее Maildir для локальной LLM (2026-09-11)
|
||||
- [Анализ формата хранения: ФС vs Maildir](STORAGE_ANALYSIS.md) — почему текущий формат удобнее Maildir для локальной LLM (2026-09-11)
|
||||
|
||||
## CardDAV / CalDAV (контакты, календарь, задачи)
|
||||
|
||||
- **Radicale** (:5232, bigbox, Docker) — CalDAV/CardDAV-сервер. Карточки контактов синхронизируются двусторонне: `scripts/contacts_caldav_sync.py` (push 81 контакт → vCard; pull правок/создания/удаления с телефона → `contacts.json`).
|
||||
- **Vikunja** (:3456) — трекер задач (в Docker, разворачивается).
|
||||
- **Android:** [DAVx⁵](https://www.davx5.com) (F-Droid/Play) для контактов/календаря, jtx board для задач Vikunja.
|
||||
- **Публичный доступ:** через Caddy на vps02 (`cal.nixg.ru` → Radicale, `tasks.nixg.ru` → Vikunja) — настройка = следующая задача; подробности и конфиги Caddy: [STATUS.md → «DAVx⁵ (Android: CalDAV/CardDAV-мост)»](STATUS.md)
|
||||
@@ -117,15 +117,17 @@ Hermes cron:
|
||||
### Задача 2: Локальный календарь + трекер задач 🔵 (в работе)
|
||||
- [x] Решение пользователя: **Radicale (CalDAV) + Vikunja (трекер)**, всё в Docker-контейнерах
|
||||
- [x] Change `local-calendar-tasks` создан и валиден (опenspec-lab) — proposal/specs/design/tasks
|
||||
- [x] **Radicale развёрнут**: контейнер, порт 5232, HTTP Basic (estorozhenko), PROPFIND → 207, без пароля → 401
|
||||
- [ ] Коллекции Radicale (Личный/Рабочий/Задачи) — создание через MKCOL вернуло 403 (Radicale 3.x создаёт коллекции иначе: PUT ресурса); **заблокировано ожиданием решения**
|
||||
- [ ] Vikunja — не начат (docker compose + postgres, порт 3456)
|
||||
- [ ] Caddy reverse proxy (cal.nixg.ru → 5232, tasks.nixg.ru → 3456)
|
||||
- [ ] Android-синхронизация (DAVx5)
|
||||
- [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] **CardDAV-синк контактов (2026-09-13, change `contacts-caldav-server`)**: `scripts/contacts_caldav_sync.py` — двусторонний sync. PUSH: 81 контакт → vCard в Radicale. PULL: правки/создание/удаление карточек с телефона → contacts.json. Идемпотентно (162 unchanged, 0 PUT на повторе). Конфликты (412) — приоритет телефону, локальная версия в `caldav-sync.log`. Подробнее: `scripts/contacts_caldav_sync.py --help`, лог `/opt/hermes/email/contacts/caldav-sync.log`.
|
||||
- [ ] **Vikunja: создать админа + API-токен, проверить tasks.nixg.ru**
|
||||
- [ ] **Caddy reverse proxy (cal.nixg.ru → 5232, tasks.nixg.ru → 3456)** — СДЕЛАТЬ СЛЕДУЮЩЕЙ СЕССИЕЙ (см. ниже «DAVx⁵»)
|
||||
- [ ] **Android-синхронизация (DAVx5)** — СДЕЛАТЬ СЛЕДУЮЩЕЙ СЕССИЕЙ (см. ниже)
|
||||
|
||||
### Задача 3: Нативная синхронизация с Android ⬜ (после Задачи 2)
|
||||
- [ ] DAVx5 на телефоне → Radicale; Vikunja app/token
|
||||
- [ ] Проверка двусторонней синхронизации (событие с телефона → bigbox)
|
||||
### Задача 3: Нативная синхронизация с Android 🔵 (следующая сессия)
|
||||
- [ ] **Первая задача след. сессии: настроить синхронизацию с телефоном** (DAVx5 → Radicale; Vikunja → jtx board)
|
||||
- [ ] Настроить Caddy reverse proxy для Radicale (cal.nixg.ru) — см. ниже
|
||||
- [ ] Проверка двусторонней синхронизации (событие/контакт с телефона → bigbox → база)
|
||||
|
||||
### Задача 1: Веб-интерфейс ассистента ⬜
|
||||
- [ ] FastAPI + SQLite FTS5: список писем (дата/адресант/тэги/папка)
|
||||
@@ -235,6 +237,51 @@ Hermes cron:
|
||||
|
||||
---
|
||||
|
||||
## DAVx⁵ (Android: CalDAV/CardDAV-мост)
|
||||
|
||||
**Назначение:** DAVx⁵ — приложение-синхронизатор для Android, **не имеет собственного UI** для просмотра событий/контактов, а встраивается в стандартные системные приложения Android (Календарь, Контакты). Это стандарт де-факто для синхронизации с Radicale на Android.
|
||||
|
||||
- **Где взять:** F-Droid (бесплатно) или Google Play (платно, поддержка разработчиков).
|
||||
- **Как работает:** добавляете аккаунт (URL сервера Radicale, логин, пароль) → DAVx⁵ сам находит доступные календари и адресные книги → выбираете, что синхронизировать с системой.
|
||||
- **Для задач** пользователь поставил **jtx board** (не DAVx⁵).
|
||||
|
||||
### Настройка Caddy для Radicale
|
||||
|
||||
Проксирование Radicale через Caddy требует внимания к путям и заголовкам, иначе CalDAV/CardDAV-клиенты не найдут ресурсы.
|
||||
|
||||
**Рабочий пример для домена `dav.example.com` (Radicale в корне):**
|
||||
```caddyfile
|
||||
dav.example.com {
|
||||
# Важно: сохраняем заголовок Authorization для Radicale
|
||||
header_up Authorization {header.Authorization}
|
||||
|
||||
# Если Radicale в подпапке — используйте handle_path (см. ниже)
|
||||
reverse_proxy localhost:5232
|
||||
}
|
||||
```
|
||||
|
||||
**Ключевые моменты:**
|
||||
1. **Сохранение Authorization:** Caddy по умолчанию может удалять заголовки. `header_up Authorization {header.Authorization}` гарантирует, что Radicale получит логин/пароль.
|
||||
2. **X-Script-Name (если в подпапке):** для размещения по `/radicale` нужен `handle_path` для удаления префикса пути + заголовок `X-Script-Name`, чтобы Radicale знал о своём расположении.
|
||||
3. **Обязательный `handle_path` для подпапки:** простой `reverse_proxy` внутри `handle` может не сработать — Radicale ожидает запросы без префикса (получает его через `X-Script-Name`).
|
||||
|
||||
**Пример для подпапки `/radicale`:**
|
||||
```caddyfile
|
||||
dav.example.com {
|
||||
handle_path /radicale/* {
|
||||
header_up Authorization {header.Authorization}
|
||||
header_up X-Script-Name /radicale
|
||||
reverse_proxy localhost:5232
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**В DAVx⁵ указывается базовый URL** (например, `https://dav.example.com` или `https://dav.example.com/radicale`); пути к календарям/контактам приложение определяет автоматически.
|
||||
|
||||
**Наш случай (следующая сессия):** поддомен `cal.nixg.ru` (Caddy на vps02, `reverse_proxy 10.8.0.2:5232` к bigbox) — Radicale слушает 127.0.0.1:5232, user `estorozhenko`, пароль `RADICALE_PASS` в `radicale/.env`. Адресная книга: `Контакты` (кириллица в URL — DAVx5 умеет). Vikunja → jtx board через `tasks.nixg.ru`.
|
||||
|
||||
---
|
||||
|
||||
## Конфигурация
|
||||
|
||||
- **Репозиторий:** `https://gitea.nixg.ru/hermes/email-assistant`
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,592 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
contacts_caldav_sync.py — двусторонняя синхронизация контактов
|
||||
между локальной базой contacts.json и CardDAV-сервером Radicale.
|
||||
|
||||
Направления:
|
||||
PUSH (база → сервер): новые/изменённые контакты пишутся как vCard
|
||||
PULL (сервер → база): правки на телефоне (DAVx5) попадают обратно:
|
||||
- изменённая карточка → обновление контакта
|
||||
- новая карточка → новый контакт
|
||||
- удалённая карточка → deleted: true (soft delete)
|
||||
Конфликты: приоритет серверу (телефон), локальная версия в caldav-sync.log
|
||||
|
||||
Запуск:
|
||||
python3 contacts_caldav_sync.py [--base-url URL] [--user USER] [--pass PASS]
|
||||
[--addressbook КОНТАКТЫ] [--prune] [--dry-run]
|
||||
|
||||
Пароль: --pass или env CALDAV_PASS (или RADICALE_PASS).
|
||||
Без --pass → читает radicale/.env (RADICALE_PASS).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
|
||||
EMAIL_ROOT = Path("/opt/hermes/email")
|
||||
CONTACTS_DIR = EMAIL_ROOT / "contacts"
|
||||
LOG_PATH = CONTACTS_DIR / "caldav-sync.log"
|
||||
|
||||
DEFAULT_BASE_URL = "http://127.0.0.1:5232"
|
||||
DEFAULT_USER = "estorozhenko"
|
||||
DEFAULT_ADDRESSBOOK = "Контакты"
|
||||
|
||||
|
||||
# ─── HTTP helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _auth_header(user, password):
|
||||
token = base64.b64encode(f"{user}:{password}".encode()).decode()
|
||||
return {"Authorization": f"Basic {token}"}
|
||||
|
||||
|
||||
def _request(method, url, headers=None, data=None, timeout=15):
|
||||
req = urllib.request.Request(url, method=method, headers=headers or {})
|
||||
if data is not None:
|
||||
if isinstance(data, str):
|
||||
data = data.encode("utf-8")
|
||||
req.data = data
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return resp.status, resp.read()
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, e.read()
|
||||
except urllib.error.URLError as e:
|
||||
return 0, str(e).encode()
|
||||
|
||||
|
||||
def _etag_from_response(resp_headers):
|
||||
return resp_headers.get("ETag") or resp_headers.get("etag")
|
||||
|
||||
|
||||
# ─── vCard helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
def contact_to_vcard(contact):
|
||||
"""Собрать vCard 4.0 из контакта (как generate_vcard в contacts_extractor)."""
|
||||
lines = ["BEGIN:VCARD", "VERSION:4.0"]
|
||||
uid = contact.get("id") or ""
|
||||
if uid:
|
||||
lines.append(f"UID:{uid}")
|
||||
full_name = contact.get("full_name") or ""
|
||||
lines.append(f"FN:{full_name}")
|
||||
name_parts = full_name.split(maxsplit=2)
|
||||
if len(name_parts) >= 2:
|
||||
n_line = f"N:{name_parts[-1]};{name_parts[0]};{' '.join(name_parts[1:-1])};;"
|
||||
else:
|
||||
n_line = f"N:{full_name};;;;"
|
||||
lines.append(n_line)
|
||||
email = contact.get("email")
|
||||
if email:
|
||||
lines.append(f"EMAIL;TYPE=WORK:{email}")
|
||||
if contact.get("phone"):
|
||||
# Radicale режет value по запятой → заменяем на пробел (доб. 3557 сохраняется)
|
||||
lines.append(f"TEL;TYPE=WORK:{contact['phone'].replace(',', ' ')}")
|
||||
if contact.get("phone_secondary"):
|
||||
lines.append(f"TEL;TYPE=CELL:{contact['phone_secondary'].replace(',', ' ')}")
|
||||
if contact.get("position"):
|
||||
lines.append(f"TITLE:{contact['position']}")
|
||||
if contact.get("company"):
|
||||
lines.append(f"ORG:{contact['company']}")
|
||||
if contact.get("address"):
|
||||
lines.append(f"ADR;TYPE=WORK:;;{contact['address']};;;")
|
||||
# Источник: X-SOURCES через | (Radicale режет по запятой, | не режет)
|
||||
if contact.get("source_uids"):
|
||||
sources = " | ".join(contact.get("source_uids", []))
|
||||
lines.append(f"X-SOURCES:{sources}")
|
||||
lines.append("END:VCARD")
|
||||
return "\r\n".join(lines) + "\r\n"
|
||||
|
||||
|
||||
def parse_vcard_to_contact(vcard_text, uid, base_contact=None):
|
||||
"""Извлечь поля из vCard в dict контакта (для pull-направления)."""
|
||||
contact = dict(base_contact or {})
|
||||
contact["id"] = uid
|
||||
fields = {}
|
||||
current = None
|
||||
for line in vcard_text.replace("\r\n", "\n").split("\n"):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
if line.startswith("BEGIN:"):
|
||||
continue
|
||||
if line.startswith("END:"):
|
||||
continue
|
||||
if ":" in line:
|
||||
key, _, value = line.partition(":")
|
||||
# params: EMAIL;TYPE=WORK → name=EMAIL
|
||||
name = key.split(";")[0].upper()
|
||||
value = value.strip()
|
||||
if name in ("FN", "N", "EMAIL", "TEL", "TITLE", "ORG", "ADR", "NOTE"):
|
||||
fields.setdefault(name, []).append(value)
|
||||
if "FN" in fields:
|
||||
contact["full_name"] = fields["FN"][0]
|
||||
if "EMAIL" in fields:
|
||||
email = fields["EMAIL"][0]
|
||||
# strip mailto:
|
||||
if email.lower().startswith("mailto:"):
|
||||
email = email[7:]
|
||||
contact["email"] = email.lower()
|
||||
tels = [t for t in fields.get("TEL", []) if t]
|
||||
if tels:
|
||||
tel = tels[0]
|
||||
if tel.lower().startswith("tel:"):
|
||||
tel = tel[4:]
|
||||
contact["phone"] = tel
|
||||
if len(tels) > 1:
|
||||
contact["phone_secondary"] = tels[1]
|
||||
if "TITLE" in fields:
|
||||
contact["position"] = fields["TITLE"][0]
|
||||
if "ORG" in fields:
|
||||
contact["company"] = fields["ORG"][0].replace("\\,", ",")
|
||||
if "ADR" in fields:
|
||||
# ADR;TYPE=WORK:;;address;;; → берём 3-ю часть
|
||||
adr = fields["ADR"][0]
|
||||
parts = adr.split(";")
|
||||
if len(parts) >= 3 and parts[2]:
|
||||
contact["address"] = parts[2]
|
||||
return contact
|
||||
|
||||
|
||||
# ─── CardDAV ops ──────────────────────────────────────────────────────────────
|
||||
|
||||
def carddav_base_url(base_url, user, addressbook):
|
||||
"""URL адресной книги."""
|
||||
return f"{base_url.rstrip('/')}/{urllib.parse.quote(user)}/{urllib.parse.quote(addressbook)}/"
|
||||
|
||||
|
||||
def list_addressbook(base_url, user, addressbook, auth):
|
||||
"""PROPFIND Depth:1 → {href: {'etag': str, 'vcard': str}} (vcard via GET)."""
|
||||
ab_url = carddav_base_url(base_url, user, addressbook)
|
||||
body = """<?xml version="1.0" encoding="utf-8"?>
|
||||
<propfind xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:carddav">
|
||||
<prop><getetag/><resourcetype/><href/></prop>
|
||||
</propfind>"""
|
||||
status, content = _request(
|
||||
"PROPFIND", ab_url,
|
||||
headers={**auth, "Depth": "1", "Content-Type": "application/xml"},
|
||||
data=body,
|
||||
)
|
||||
if status not in (207, 200):
|
||||
return None, f"PROPFIND → {status}: {content[:200]}"
|
||||
xml = content.decode("utf-8", errors="replace")
|
||||
# Парсим response-блоки через ElementTree (Radicale использует префикс D: по умолчанию,
|
||||
# но может быть и без — ET с namespaces справится).
|
||||
cards = {}
|
||||
try:
|
||||
root = ET.fromstring(xml)
|
||||
except ET.ParseError as e:
|
||||
return None, f"PROPFIND XML parse: {e}"
|
||||
dav = "{DAV:}"
|
||||
for resp in root.iter(f"{dav}response"):
|
||||
href_el = resp.find(f"{dav}href")
|
||||
if href_el is None or not href_el.text:
|
||||
continue
|
||||
href = href_el.text.strip()
|
||||
if href.endswith("/"):
|
||||
continue # сама коллекция
|
||||
# etag лежит в propstat/prop/getetag
|
||||
etag = ""
|
||||
for prop in resp.iter(f"{dav}prop"):
|
||||
getetag = prop.find(f"{dav}getetag")
|
||||
if getetag is not None and getetag.text:
|
||||
etag = getetag.text.strip()
|
||||
break
|
||||
cards[href] = {"etag": etag}
|
||||
# Тянем содержимое каждой карточки (GET)
|
||||
for href in list(cards):
|
||||
card_url = f"{base_url.rstrip('/')}{href}" if href.startswith("/") else f"{ab_url.rstrip('/')}/{href}"
|
||||
status2, content2 = _request("GET", card_url, headers=auth)
|
||||
if status2 == 200:
|
||||
cards[href]["vcard"] = content2.decode("utf-8", errors="replace")
|
||||
else:
|
||||
cards[href]["vcard"] = ""
|
||||
return cards, None
|
||||
|
||||
|
||||
def put_card(base_url, user, addressbook, uid, vcard, auth, etag=None):
|
||||
"""PUT vCard; etag → If-Match (обновление). Возвращает (status, etag или body)."""
|
||||
ab_url = carddav_base_url(base_url, user, addressbook)
|
||||
url = f"{ab_url}{uid}.vcf"
|
||||
headers = {**auth, "Content-Type": "text/vcard; charset=utf-8"}
|
||||
if etag:
|
||||
headers["If-Match"] = etag
|
||||
status, content = _request("PUT", url, headers=headers, data=vcard)
|
||||
if status in (200, 201, 204):
|
||||
new_etag = None
|
||||
# Radicale отдаёт ETag в заголовке; urllib его теряет в _request —
|
||||
# пере-запросим HEAD? Нет. GET вернёт актуальный.
|
||||
return status, new_etag
|
||||
return status, content[:300]
|
||||
|
||||
|
||||
def get_card_etag(base_url, user, addressbook, uid, auth):
|
||||
"""GET карточки → (etag, vcard)."""
|
||||
ab_url = carddav_base_url(base_url, user, addressbook)
|
||||
url = f"{ab_url}{uid}.vcf"
|
||||
req = urllib.request.Request(url, method="GET", headers=auth)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
etag = resp.headers.get("ETag") or ""
|
||||
return etag, resp.read().decode("utf-8", errors="replace")
|
||||
except urllib.error.HTTPError as e:
|
||||
return None, None if e.code == 404 else (None, e.read()[:200])
|
||||
except urllib.error.URLError:
|
||||
return None, None
|
||||
|
||||
|
||||
def delete_card(base_url, user, addressbook, uid, auth):
|
||||
ab_url = carddav_base_url(base_url, user, addressbook)
|
||||
url = f"{ab_url}{uid}.vcf"
|
||||
status, content = _request("DELETE", url, headers=auth)
|
||||
return status, content
|
||||
|
||||
|
||||
# ─── Sync logic ───────────────────────────────────────────────────────────────
|
||||
|
||||
def log_sync(entries):
|
||||
"""Дописать строки в caldav-sync.log."""
|
||||
with open(LOG_PATH, "a", encoding="utf-8") as f:
|
||||
for e in entries:
|
||||
ts = datetime.now().isoformat(timespec="seconds")
|
||||
f.write(f"[{ts}] {e}\n")
|
||||
|
||||
|
||||
def sync_contacts_carddav(base_url, user, password, addressbook, prune=False,
|
||||
dry_run=False, contacts_dir=None, verbose=True):
|
||||
"""Двусторонний синк; возвращает (stats: dict, err: str|None)."""
|
||||
contacts_dir = contacts_dir or CONTACTS_DIR
|
||||
contacts_db = {}
|
||||
try:
|
||||
contacts_db = json.load(open(contacts_dir / "contacts.json", encoding="utf-8"))
|
||||
except (FileNotFoundError, json.JSONDecodeError) as e:
|
||||
return None, f"Не удалось прочитать contacts.json: {e}"
|
||||
|
||||
contacts = contacts_db.get("contacts", [])
|
||||
by_email = contacts_db.get("by_email", {})
|
||||
auth = _auth_header(user, password)
|
||||
|
||||
# Строим индекс по id
|
||||
by_id = {c.get("id"): c for c in contacts if c.get("id")}
|
||||
|
||||
# 1. PROPFIND книги
|
||||
cards, err = list_addressbook(base_url, user, addressbook, auth)
|
||||
if err:
|
||||
return None, f"list_addressbook: {err}"
|
||||
assert cards is not None
|
||||
if verbose:
|
||||
print(f" CardDAV: {len(cards)} карточек в книге {addressbook}")
|
||||
|
||||
stats = {"created": 0, "updated": 0, "unchanged": 0, "conflict": 0,
|
||||
"pulled_created": 0, "pulled_updated": 0, "pulled_deleted": 0,
|
||||
"deleted": 0}
|
||||
log_entries = []
|
||||
changed = False
|
||||
|
||||
# ── PULL: сервер → база ────────────────────────────────────────────────
|
||||
server_ids = set()
|
||||
for href, card in cards.items():
|
||||
# uid = имя файла без .vcf
|
||||
uid = href.rstrip("/").split("/")[-1]
|
||||
if uid.endswith(".vcf"):
|
||||
uid = uid[:-4]
|
||||
server_ids.add(uid)
|
||||
local = by_id.get(uid)
|
||||
server_etag = card.get("etag", "")
|
||||
vcard_text = card.get("vcard", "")
|
||||
|
||||
if local is None:
|
||||
# Новой карточки нет в базе → создать контакт
|
||||
new_contact = parse_vcard_to_contact(vcard_text, uid)
|
||||
if not new_contact.get("email"):
|
||||
if verbose:
|
||||
print(f" ← {uid}: новая карточка без email, пропуск pull")
|
||||
continue
|
||||
new_contact.setdefault("full_name", "")
|
||||
new_contact["first_seen"] = date.today().isoformat()
|
||||
new_contact["last_seen"] = date.today().isoformat()
|
||||
new_contact["source_uids"] = new_contact.get("source_uids", [])
|
||||
new_contact["source_folders"] = new_contact.get("source_folders", [])
|
||||
new_contact["caldav"] = {"uid": uid, "etag": server_etag,
|
||||
"from_device": True}
|
||||
contacts.append(new_contact)
|
||||
by_email[new_contact["email"]] = len(contacts) - 1
|
||||
by_id[uid] = new_contact
|
||||
stats["pulled_created"] += 1
|
||||
changed = True
|
||||
if verbose:
|
||||
print(f" ← {uid}: новая карточка с телефона → контакт создан")
|
||||
log_entries.append(f"PULL-CREATE {uid} {new_contact.get('email')}")
|
||||
continue
|
||||
|
||||
local_caldav = local.get("caldav") or {}
|
||||
local_etag = local_caldav.get("etag", "")
|
||||
|
||||
# Изменена ли карточка относительно нашей записи?
|
||||
if server_etag and server_etag != local_etag:
|
||||
# Сервер изменился (правка с телефона). По REQ-011 приоритет телефону —
|
||||
# применяем серверную версию, сохраняя недостающие локальные поля.
|
||||
merged = parse_vcard_to_contact(vcard_text, uid, base_contact=local)
|
||||
# Поля, которых нет в vCard — значит удалены на телефоне → обнуляем.
|
||||
SYNCFIELDS = ("full_name", "phone", "phone_secondary", "position",
|
||||
"company", "address", "email")
|
||||
present = _vcard_field_names(vcard_text)
|
||||
for k in SYNCFIELDS:
|
||||
if k in present:
|
||||
if merged.get(k):
|
||||
local[k] = merged[k]
|
||||
else:
|
||||
local[k] = None if k in ("phone", "phone_secondary",
|
||||
"position", "company", "address") else local.get(k)
|
||||
local["caldav"] = {**local_caldav, "etag": server_etag,
|
||||
"from_device": True}
|
||||
stats["pulled_updated"] += 1
|
||||
changed = True
|
||||
if verbose:
|
||||
print(f" ← {uid}: карточка изменена на телефоне → контакт обновлён")
|
||||
log_entries.append(f"PULL-UPDATE {uid} (server etag {server_etag})")
|
||||
else:
|
||||
stats["unchanged"] += 1
|
||||
|
||||
# Карточки, которых больше нет на сервере (удалены на телефоне) → soft delete
|
||||
for contact in contacts:
|
||||
cid = contact.get("id")
|
||||
if not cid:
|
||||
continue
|
||||
caldav = contact.get("caldav") or {}
|
||||
if caldav.get("uid") and cid not in server_ids:
|
||||
if not contact.get("deleted"):
|
||||
contact["deleted"] = True
|
||||
contact["last_seen"] = date.today().isoformat()
|
||||
stats["pulled_deleted"] += 1
|
||||
changed = True
|
||||
if verbose:
|
||||
print(f" ← {cid}: карточка удалена на телефоне → deleted: true")
|
||||
log_entries.append(f"PULL-DELETE {cid}")
|
||||
|
||||
# ── PUSH: база → сервер ────────────────────────────────────────────────
|
||||
for contact in contacts:
|
||||
cid = contact.get("id")
|
||||
email = contact.get("email")
|
||||
if not cid or not email:
|
||||
continue
|
||||
if contact.get("deleted"):
|
||||
# unтелефон deleted: удалить карточку при prune или пропустить
|
||||
if prune and cid in server_ids:
|
||||
status, body = delete_card(base_url, user, addressbook, cid, auth)
|
||||
if status in (200, 204):
|
||||
stats["deleted"] += 1
|
||||
changed = True
|
||||
if verbose:
|
||||
print(f" → {cid}: карточка удалена (prune)")
|
||||
log_entries.append(f"DELETE {cid}")
|
||||
continue
|
||||
caldav = contact.get("caldav") or {}
|
||||
if caldav.get("from_device") and not caldav.get("sync_after_pull"):
|
||||
# Контакт с телефона: уже синхронизирован в pull, ничего не пишем
|
||||
continue
|
||||
vcard = contact_to_vcard(contact)
|
||||
if dry_run:
|
||||
action = "создана" if cid not in server_ids else "обновлена"
|
||||
if verbose:
|
||||
print(f" → [dry] {cid}: карточка {action}")
|
||||
continue
|
||||
if cid not in server_ids:
|
||||
# Нет карточки → создать
|
||||
status, body = put_card(base_url, user, addressbook, cid, vcard, auth)
|
||||
if status in (200, 201, 204):
|
||||
# обновим etag
|
||||
etag, _ = get_card_etag(base_url, user, addressbook, cid, auth)
|
||||
contact["caldav"] = {"uid": cid, "etag": etag or "",
|
||||
"from_device": False}
|
||||
stats["created"] += 1
|
||||
changed = True
|
||||
if verbose:
|
||||
print(f" → {cid}: карточка создана")
|
||||
log_entries.append(f"CREATE {cid} {email}")
|
||||
else:
|
||||
if verbose:
|
||||
print(f" ✗ {cid}: PUT {status}: {body[:120]}")
|
||||
else:
|
||||
# Карточка есть → проверить, изменилась ли локально
|
||||
c_href = href_for(cid, cards)
|
||||
server_etag = cards[c_href]["etag"] if c_href else ""
|
||||
local_etag = (contact.get("caldav") or {}).get("etag", "")
|
||||
# Сравниваем содержимое vCard (проще, чем etag на каждый чих)
|
||||
server_vcard = cards[c_href].get("vcard", "") if c_href else ""
|
||||
local_vcard = contact_to_vcard(contact)
|
||||
if _normalize_vcard(local_vcard) == _normalize_vcard(server_vcard):
|
||||
stats["unchanged"] += 1
|
||||
continue
|
||||
# Обновляем с If-Match
|
||||
status, body = put_card(base_url, user, addressbook, cid, vcard, auth, etag=server_etag or None)
|
||||
if status in (200, 201, 204):
|
||||
etag, _ = get_card_etag(base_url, user, addressbook, cid, auth)
|
||||
contact["caldav"] = {"uid": cid, "etag": etag or "",
|
||||
"from_device": False}
|
||||
stats["updated"] += 1
|
||||
changed = True
|
||||
if verbose:
|
||||
print(f" → {cid}: карточка обновлена")
|
||||
log_entries.append(f"UPDATE {cid} {email}")
|
||||
elif status == 412:
|
||||
# Конфликт: сервер изменил карточку → принять серверную
|
||||
# версию, локальную в лог.
|
||||
if cid in server_ids:
|
||||
c_href2 = href_for(cid, cards)
|
||||
server_vcard = cards[c_href2].get("vcard", "") if c_href2 else ""
|
||||
if server_vcard:
|
||||
local_prev = {k: contact.get(k) for k in
|
||||
("full_name", "phone", "phone_secondary",
|
||||
"position", "company", "address")}
|
||||
merged = parse_vcard_to_contact(server_vcard, cid, base_contact=contact)
|
||||
for k in ("full_name", "phone", "phone_secondary",
|
||||
"position", "company", "address"):
|
||||
if k in merged and merged[k]:
|
||||
contact[k] = merged[k]
|
||||
etag, _ = get_card_etag(base_url, user, addressbook, cid, auth)
|
||||
contact["caldav"] = {"uid": cid, "etag": etag or "",
|
||||
"from_device": True}
|
||||
stats["conflict"] += 1
|
||||
changed = True
|
||||
if verbose:
|
||||
print(f" ⚠ {cid}: 412 конфликт → сервер победил, локальное в log")
|
||||
log_entries.append(f"CONFLICT-PUSH {cid} conflict_local={json.dumps(local_prev, ensure_ascii=False)}")
|
||||
else:
|
||||
if verbose:
|
||||
print(f" ✗ {cid}: PUT 412, но карточка исчезла — повтор на след. раз")
|
||||
else:
|
||||
if verbose:
|
||||
print(f" ✗ {cid}: PUT {status}: {body[:120]}")
|
||||
|
||||
# ── Сохраняем базу ─────────────────────────────────────────────────────
|
||||
if changed and not dry_run:
|
||||
contacts.sort(key=lambda c: c.get("email", ""))
|
||||
# by_email хранит индексы — перестроить после сортировки
|
||||
by_email = {c.get("email", ""): i for i, c in enumerate(contacts)}
|
||||
contacts_db["contacts"] = contacts
|
||||
contacts_db["by_email"] = by_email
|
||||
tmp = contacts_dir / "contacts.json.tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(contacts_db, f, ensure_ascii=False, indent=2)
|
||||
tmp.replace(contacts_dir / "contacts.json")
|
||||
# index.json
|
||||
with open(contacts_dir / "index.json.tmp", "w", encoding="utf-8") as f:
|
||||
json.dump(by_email, f, ensure_ascii=False, indent=2)
|
||||
tmp2 = contacts_dir / "index.json.tmp"
|
||||
tmp2.replace(contacts_dir / "index.json")
|
||||
if log_entries:
|
||||
log_sync(log_entries)
|
||||
|
||||
return stats, None
|
||||
|
||||
|
||||
def href_for(cid, cards):
|
||||
"""Найти href карточки по uid (имя файла)."""
|
||||
for href in cards:
|
||||
if href.rstrip("/").split("/")[-1].startswith(cid):
|
||||
return href
|
||||
return None
|
||||
|
||||
|
||||
def _vcard_field_names(vcard_text):
|
||||
"""Вернуть имена полей vCard (FN, N, EMAIL, TEL, ...) — для проверки наличия."""
|
||||
names = set()
|
||||
for line in vcard_text.replace("\r\n", "\n").split("\n"):
|
||||
line = line.strip()
|
||||
if not line or ":" not in line:
|
||||
continue
|
||||
key, _, _ = line.partition(":")
|
||||
name = key.split(";")[0].upper()
|
||||
if name not in ("BEGIN", "END", "VERSION", "UID"):
|
||||
names.add(name)
|
||||
return names
|
||||
|
||||
|
||||
def _normalize_vcard(vcard):
|
||||
"""Нормализовать vCard для сравнения.
|
||||
|
||||
Radicale переупорядочивает поля и сворачивает длинные строки
|
||||
(RFC-5545 folding, continuation line начинается с пробела).
|
||||
Сравниваем как упорядоченный мультисет unfolded-строк.
|
||||
"""
|
||||
# Развернуть folding: строка, начинающаяся с пробела/таба — продолжение
|
||||
raw = re.sub(r"\r\n", "\n", vcard)
|
||||
unfolded = []
|
||||
for line in raw.split("\n"):
|
||||
if line.startswith((" ", "\t")) and unfolded:
|
||||
unfolded[-1] = unfolded[-1] + line.strip()
|
||||
else:
|
||||
unfolded.append(line.strip())
|
||||
lines = [l for l in unfolded if l and not l.startswith(("BEGIN:", "END:", "VERSION:", "UID:"))]
|
||||
# Radicale нормализует ADR (добавляет/переставляет пустые ;) — нормализуем и мы
|
||||
norm = []
|
||||
for l in lines:
|
||||
if l.startswith("ADR;"):
|
||||
k, v = l.split(":", 1)
|
||||
# оставить только значение адреса (3-й компонент)
|
||||
parts = v.rstrip(";").split(";")
|
||||
norm.append(f"{k}:;;{parts[2] if len(parts) > 2 else ''};;;")
|
||||
else:
|
||||
norm.append(l)
|
||||
return sorted(norm)
|
||||
|
||||
|
||||
# ─── CLI ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="CardDAV-синк контактов с Radicale")
|
||||
parser.add_argument("--base-url", default=os.environ.get("CALDAV_URL", DEFAULT_BASE_URL))
|
||||
parser.add_argument("--user", default=os.environ.get("CALDAV_USER", DEFAULT_USER))
|
||||
parser.add_argument("--pass", dest="password", default=None, help="Пароль (или env CALDAV_PASS/RADICALE_PASS)")
|
||||
parser.add_argument("--addressbook", default=DEFAULT_ADDRESSBOOK, help="Имя адресной книги")
|
||||
parser.add_argument("--prune", action="store_true", help="Удалять карточки без контакта в базе")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Не сохранять изменения")
|
||||
parser.add_argument("--verbose", action="store_true", default=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
password = args.password
|
||||
if not password:
|
||||
password = os.environ.get("CALDAV_PASS") or os.environ.get("RADICALE_PASS")
|
||||
if not password:
|
||||
# fallback: radikal .env
|
||||
env_path = Path("/opt/hermes/email-assistant/radicale/.env")
|
||||
try:
|
||||
for line in env_path.read_text().splitlines():
|
||||
if line.startswith("RADICALE_PASS="):
|
||||
password = line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||
break
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
if not password:
|
||||
print("Не задан пароль: --pass / CALDAV_PASS / RADICALE_PASS", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if args.dry_run:
|
||||
print("🔍 DRY RUN — база не сохраняется")
|
||||
|
||||
stats, err = sync_contacts_carddav(
|
||||
args.base_url, args.user, password, args.addressbook,
|
||||
prune=args.prune, dry_run=args.dry_run,
|
||||
)
|
||||
if err:
|
||||
print(f"❌ {err}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print("\n📇 CardDAV sync завершён:")
|
||||
for k, v in stats.items():
|
||||
if v:
|
||||
print(f" {k}: {v}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -308,6 +308,8 @@ def get_date_from_path(path):
|
||||
def save_progress(contacts_db, contacts_dir, processed_uids, processed_emails):
|
||||
"""Инкрементальное сохранение контактов и last_scan."""
|
||||
contacts_db["contacts"].sort(key=lambda c: c.get("email", ""))
|
||||
# Перестроить by_email — он хранит ИНДЕКСЫ позиций; после сортировки они сдвигаются
|
||||
contacts_db["by_email"] = {c.get("email", ""): i for i, c in enumerate(contacts_db["contacts"])}
|
||||
save_json(contacts_dir / "contacts.json", contacts_db)
|
||||
save_json(contacts_dir / "index.json", contacts_db.get("by_email", {}))
|
||||
generate_vcard(contacts_dir, contacts_db["contacts"])
|
||||
@@ -485,6 +487,7 @@ def scan(limit=0):
|
||||
if save_counter >= 5:
|
||||
save_counter = 0
|
||||
contacts_db["contacts"].sort(key=lambda c: c.get("email", ""))
|
||||
contacts_db["by_email"] = {c.get("email", ""): i for i, c in enumerate(contacts_db["contacts"])}
|
||||
save_json(contacts_dir / "contacts.json", contacts_db)
|
||||
save_json(contacts_dir / "index.json", contacts_db.get("by_email", {}))
|
||||
generate_vcard(contacts_dir, contacts_db["contacts"])
|
||||
|
||||
@@ -42,4 +42,4 @@ services:
|
||||
ports:
|
||||
- "3456:80"
|
||||
volumes:
|
||||
- ./files:/app/files
|
||||
- ./files:/app/vikunja/files
|
||||
Reference in New Issue
Block a user