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>`.
### 2. Синк в contacts_extractor.py
### 2. Двусторонний синк в contacts_extractor.py
В конец существующего пайплайна (после save_progress) добавляется шаг
**sync_contacts_to_caldav()**, который:
После извлечения/дедупликации запускается **sync_carddav()**, который
выполняет двустороннюю сверку между `contacts.json` и адресной книгой
Radicale.
1. Читает `contacts.json` → список контактов (email, full_name, phone,
phone_secondary, position, company, address, contact_id).
2. Генерирует vCard 4.0 на каждый контакт (FN, EMAIL, TEL, ORG, TITLE, ADR)
— использует уже существующую generate_vcard() логику (перенести/
отрефакторить в общий модуль или дублировать минимально).
3. Определяет URL карточки: `<base>/estorozhenko/Контакты/<uid>.vcf`,
uid = стабильный хэш от email (contact_id из contacts.json).
4. Делает **PROPFIND Depth:1** по адресной книге, строит карту
`uid → (ETag, href)` — чтобы знать, какие карточки уже есть (idempotency).
5. Для каждого контакта:
- если карточки ещё нет → **PUT** (без If-Match);
- если есть и ETag тот же → пропуск (ничего не менять);
- если есть и ETag отличается и локальные данные изменились → **PUT с
If-Match: <etag>**; при 412 → лог конфликта, пропуск.
6. `--prune-caldav`: сравнить uid на сервере с uid в базе, отсутствующие в
базе → DELETE. По умолчанию — выключено.
7. Записывает лог синка: `contacts/caldav-sync.log` (дата, контакт, действие
created/updated/skipped/conflict/deleted).
**Состояние:**
- У каждого контакта в `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 — синк
@@ -117,8 +124,15 @@ CALDAV_PASS — из `radicale/.env` (источник пароля один).
- Создание книги: PROPFIND → 207 + addressbook RS.
- PUT vCard → 201; повторный PUT/Bad Request при невалидной vCard → 400.
- Повторный синк → 204/пропуск, дублей нет.
- Изменение контакта → PUT 204 + обновлённая vCard.
- Конфликт: на сервере вручную поменять vCard → синк даёт 412 + лог.
- Изменение контакта в базе → 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 на телефоне видит контакты (ручная проверка).