mirror of
https://gitverse.ru/kpa39l/email-assistant.git
synced 2026-09-29 09:15:09 +00:00
openspec: contacts-caldav-server — двусторонний синк (правки с телефона → в базу)
This commit is contained in:
@@ -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
|
||||||
Reference in New Issue
Block a user