openspec: contacts-caldav-server — двусторонний синк (правки с телефона → в базу)

This commit is contained in:
2026-09-13 16:07:27 +00:00
parent de07fae246
commit 17252ebfa9
4 changed files with 120 additions and 36 deletions
@@ -47,29 +47,36 @@ curl -u estorozhenko:PASS -X PROPFIND -H 'Depth: 0' \
``` ```
→ должен содержать `<C:addressbook>`. → должен содержать `<C:addressbook>`.
### 2. Синк в contacts_extractor.py ### 2. Двусторонний синк в contacts_extractor.py
В конец существующего пайплайна (после save_progress) добавляется шаг После извлечения/дедупликации запускается **sync_carddav()**, который
**sync_contacts_to_caldav()**, который: выполняет двустороннюю сверку между `contacts.json` и адресной книгой
Radicale.
1. Читает `contacts.json` → список контактов (email, full_name, phone, **Состояние:**
phone_secondary, position, company, address, contact_id). - У каждого контакта в `contacts.json` добавляется поле
2. Генерирует vCard 4.0 на каждый контакт (FN, EMAIL, TEL, ORG, TITLE, ADR) `caldav: {uid, etag, synced_at, from_device: bool}` (uid = contact_id,
— использует уже существующую generate_vcard() логику (перенести/ etag — ETag последней применённой версии карточки).
отрефакторить в общий модуль или дублировать минимально). - Локальная база остаётся источником истины для дедупликации по `email`.
3. Определяет URL карточки: `<base>/estorozhenko/Контакты/<uid>.vcf`,
uid = стабильный хэш от email (contact_id из contacts.json). **Алгоритм (запуск 1):**
4. Делает **PROPFIND Depth:1** по адресной книге, строит карту 1. `PROPFIND Depth:1` по `/estorozhenko/Контакты/` → карта `href → (ETag, content-ty`pe, vCard)` (vCard тянем GET'ом по href для сравнения содержимого, если нужно).
`uid → (ETag, href)` — чтобы знать, какие карточки уже есть (idempotency). 2. **Pull (сервер → база):**
5. Для каждого контакта: - карточка есть на сервере, соответствующего контакта нет в базе → создать контакт (id = UID карточки), пометить `from_device: true` (REQ-009);
- если карточки ещё нет → **PUT** (без If-Match); - ETag карточки ≠ etag из `contacts.json[caldav.etag]`:
- если есть и ETag тот же → пропуск (ничего не менять); - если у контакта `from_device: true` (последний владелец — телефон) → применить серверную версию (REQ-008);
- если есть и ETag отличается и локальные данные изменились → **PUT с - если `from_device: false` (последний владелец — почта) → **конфликт** (REQ-011): применить версию с сервера, локальную version сохранить в `caldav-sync.log` (`conflict_local`), сбросить `etag` на актуальный;
If-Match: <etag>**; при 412 → лог конфликта, пропуск. - карточки на сервере нет, в базе есть `caldav.uid` → контакт `deleted: true` (REQ-010).
6. `--prune-caldav`: сравнить uid на сервере с uid в базе, отсутствующие в 3. **Push (база → сервер):**
базе → DELETE. По умолчанию — выключено. - у контакта есть `caldav.uid`, но нет карточки, и `deleted != true` → PUT (создание) (REQ-002);
7. Записывает лог синка: `contacts/caldav-sync.log` (дата, контакт, действие - локальные поля изменились (сравнить с последней применённой vCard или `synced_at`) → PUT с `If-Match: etag`; при 412 → конфликт: принять серверную версию, локальную в лог (REQ-011);
created/updated/skipped/conflict/deleted). - `deleted: true` у контакта, карточка есть → DELETE (по флагу `--prune-caldav`, по умолчанию — оставить и логировать).
4. `--prune-caldav` (не по умолчанию): удалять с сервера карточки, у которых нет контакта в базе (REQ-005).
**Ключевое правило конфликтов:** приоритет — сервер (телефон) как актуальная
версия; локальная версия никогда не теряется (лог `conflict_local`). Это
сознательное решение: правки руками на телефоне считаются более «живыми»,
чем автопарсинг подписей писем.
**Клиент:** стандартный `urllib.request` + `base64` Basic Auth (без новых **Клиент:** стандартный `urllib.request` + `base64` Basic Auth (без новых
зависимостей) или `curl` через subprocess. Предпочтительно urllib — синк зависимостей) или `curl` через subprocess. Предпочтительно urllib — синк
@@ -117,8 +124,15 @@ CALDAV_PASS — из `radicale/.env` (источник пароля один).
- Создание книги: PROPFIND → 207 + addressbook RS. - Создание книги: PROPFIND → 207 + addressbook RS.
- PUT vCard → 201; повторный PUT/Bad Request при невалидной vCard → 400. - PUT vCard → 201; повторный PUT/Bad Request при невалидной vCard → 400.
- Повторный синк → 204/пропуск, дублей нет. - Повторный синк → 204/пропуск, дублей нет.
- Изменение контакта → PUT 204 + обновлённая vCard. - Изменение контакта в базе → PUT 204 + обновлённая vCard.
- Конфликт: на сервере вручную поменять vCard → синк даёт 412 + лог. - **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` удаляет отсутствующие карточки. - `--prune-caldav` удаляет отсутствующие карточки.
- После синка: DAVx5 на телефоне видит контакты (ручная проверка). - После синка: DAVx5 на телефоне видит контакты (ручная проверка).
@@ -25,8 +25,15 @@
- на каждый контакт — один `.vcf` в адресной книге - на каждый контакт — один `.vcf` в адресной книге
- при обновлении контакта — PUT с новым ETag - при обновлении контакта — PUT с новым ETag
- удаление контакта, которого больше нет в базе — DELETE (опционально, см. design) - удаление контакта, которого больше нет в базе — DELETE (опционально, см. design)
3. **Остаётся локальная база contacts.json** — она продолжает быть источником 3. **Двусторонний синк** — правки, сделанные с телефона (DAVx5) в адресной
истины (дедупликация, трекинг processed_uids), а Radicale — цель синка. книге, синхронизируются обратно в `contacts.json`:
- изменённые карточки → обновление контакта
- новые карточки → новые контакты
- удалённые карточки → soft-delete (`deleted: true`) в базе
- конфликт (изменено и в почте, и на телефоне) → приоритет телефону,
локальная версия сохраняется в лог, данные не теряются
4. **Локальная база contacts.json** остаётся источником истины (дедупликация,
трекинг processed_uids) и хранит состояние синка (ETag карточки).
## Capabilities ## Capabilities
@@ -48,10 +55,8 @@
параллельном редактировании на телефоне решаются по ETag (см. design). параллельном редактировании на телефоне решаются по ETag (см. design).
- **Документация:** обновить README/STATUS (как подключить адресную книгу - **Документация:** обновить README/STATUS (как подключить адресную книгу
на Android, порты). на Android, порты).
- **Риски:** двусторонняя синхронизация (правка на телефоне → обратно в базу) - **Риски:** двусторонний синк требует разрешения конфликтов (в change —
— НЕ в скоупе этого change (односторонний синк: почта → сервер). приоритет телефону + лог локальной версии, см. REQ-011).
Односторонний синк без конфликтов: только PUT новых/обновлённых, DELETE
только явно помеченных.
## Rollback ## Rollback
@@ -99,10 +99,62 @@ PUT, чтобы не затирать изменения, сделанные к
**WHEN** проверяется содержимое /opt/hermes/email/contacts/contacts.json **WHEN** проверяется содержимое /opt/hermes/email/contacts/contacts.json
**THEN** файл существует и содержит актуальную базу контактов. **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 ## Non-Goals
- Двусторонняя синхронизация (правки на телефоне не пишутся обратно в - Глубокая биография/полная модель контакта — синк обновляет стандартные
contacts.json). поля vCard (FN, EMAIL, TEL, ORG, TITLE, ADR); произвольные расширения
vCard (X-*, категории, фото) не переносятся в contacts.json.
- Миграция существующих .vcf из локальных файлов (contacts.vcf остаётся - Миграция существующих .vcf из локальных файлов (contacts.vcf остаётся
как есть, синк идёт из contacts.json). как есть, синк идёт из contacts.json).
- Синхронизация с внешними CardDAV/Google/Cloud — только локальный Radicale. - Синхронизация с внешними CardDAV/Google/Cloud — только локальный Radicale.
@@ -27,10 +27,14 @@
`--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 ETag-конфликты: при PUT с If-Match и ответе 412 — записать - [ ] 2.4 Двусторонняя сверка: `sync_carddav()` после push выполняет pull —
`caldav-sync.log` (контакт, conflict) и продолжить. Проверка: ручной PROPFIND Depth:1, сравнение ETag, создание/обновление контактов из
тест — изменить vCard на сервере, запустить синк, увидеть conflict новых/изменённых карточек (REQ-008/009), `deleted: true` при удалении
в логе. карточки (REQ-010). Проверка: см. 3.5-3.8 (reverse-pull тесты).
- [ ] 2.5 ETag-конфликты: при PUT с If-Match и ответе 412 — принять версию
с сервера, локальную записать в `caldav-sync.log` (`conflict_local`),
продолжить (REQ-011). Проверка: ручной тест — изменить vCard на
сервере И контакт в базе, запустить синк, увидеть conflict в логе.
## 3. Сквозной тест sync ## 3. Сквозной тест sync
@@ -44,6 +48,14 @@
синк обновляет карточку (TEL появился, ETag изменился). синк обновляет карточку (TEL появился, ETag изменился).
- [ ] 3.4 `--prune-caldav`: удалить контакт из contacts.json → карточка - [ ] 3.4 `--prune-caldav`: удалить контакт из contacts.json → карточка
удалена с сервера. удалена с сервера.
- [ ] 3.5 **Reverse pull:** вручную (curl PUT) изменить vCard на сервере →
повторный синк обновляет контакт в contacts.json.
- [ ] 3.6 **Новая карточка на сервере:** curl PUT новой vCard → синк создаёт
контакт в базе.
- [ ] 3.7 **Удаление на сервере:** curl DELETE vCard → контакт получает
`deleted: true` в базе.
- [ ] 3.8 **Конфликт:** изменить и vCard (curl), и контакт в базе → синк
применяет версию с сервера, локальная в caldav-sync.log.
## 4. Интеграция в cron и документация ## 4. Интеграция в cron и документация
@@ -61,4 +73,5 @@
- [ ] 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
- [ ] V2: после синка количество vCard в адресной книге == числу контактов с email в contacts.json - [ ] V2: после синка количество vCard в адресной книге == числу контактов с email в contacts.json
- [ ] V3: повторный синк → 0 новых карточек - [ ] V3: повторный синк → 0 новых карточек
- [ ] V4: `python3 scripts/contacts_extractor.py --sync-caldav` из cron-обёртки завершается кодом 0 и пишет caldav-sync.log - [ ] V4: правка контакта на сервере (curl PUT vCard) → контакт в contacts.json обновлён после синка
- [ ] V5: `python3 scripts/contacts_extractor.py --sync-caldav` из cron-обёртки завершается кодом 0 и пишет caldav-sync.log