From 17252ebfa9779ec33dd9cd178b2e52cb80e1ecb2 Mon Sep 17 00:00:00 2001 From: hermes Date: Sun, 13 Sep 2026 16:07:27 +0000 Subject: [PATCH] =?UTF-8?q?openspec:=20contacts-caldav-server=20=E2=80=94?= =?UTF-8?q?=20=D0=B4=D0=B2=D1=83=D1=81=D1=82=D0=BE=D1=80=D0=BE=D0=BD=D0=BD?= =?UTF-8?q?=D0=B8=D0=B9=20=D1=81=D0=B8=D0=BD=D0=BA=20(=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=BA=D0=B8=20=D1=81=20=D1=82=D0=B5=D0=BB=D0=B5=D1=84?= =?UTF-8?q?=D0=BE=D0=BD=D0=B0=20=E2=86=92=20=D0=B2=20=D0=B1=D0=B0=D0=B7?= =?UTF-8?q?=D1=83)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../changes/contacts-caldav-server/design.md | 60 ++++++++++++------- .../contacts-caldav-server/proposal.md | 17 ++++-- .../specs/contacts/carddav-sync/spec.md | 56 ++++++++++++++++- .../changes/contacts-caldav-server/tasks.md | 23 +++++-- 4 files changed, 120 insertions(+), 36 deletions(-) diff --git a/openspec/changes/contacts-caldav-server/design.md b/openspec/changes/contacts-caldav-server/design.md index 82b191f..0c1eb91 100644 --- a/openspec/changes/contacts-caldav-server/design.md +++ b/openspec/changes/contacts-caldav-server/design.md @@ -47,29 +47,36 @@ curl -u estorozhenko:PASS -X PROPFIND -H 'Depth: 0' \ ``` → должен содержать ``. -### 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 карточки: `/estorozhenko/Контакты/.vcf`, - uid = стабильный хэш от email (contact_id из contacts.json). -4. Делает **PROPFIND Depth:1** по адресной книге, строит карту - `uid → (ETag, href)` — чтобы знать, какие карточки уже есть (idempotency). -5. Для каждого контакта: - - если карточки ещё нет → **PUT** (без If-Match); - - если есть и ETag тот же → пропуск (ничего не менять); - - если есть и ETag отличается и локальные данные изменились → **PUT с - If-Match: **; при 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 на телефоне видит контакты (ручная проверка). diff --git a/openspec/changes/contacts-caldav-server/proposal.md b/openspec/changes/contacts-caldav-server/proposal.md index 82cbd67..ddb48f8 100644 --- a/openspec/changes/contacts-caldav-server/proposal.md +++ b/openspec/changes/contacts-caldav-server/proposal.md @@ -25,8 +25,15 @@ - на каждый контакт — один `.vcf` в адресной книге - при обновлении контакта — PUT с новым ETag - удаление контакта, которого больше нет в базе — DELETE (опционально, см. design) -3. **Остаётся локальная база contacts.json** — она продолжает быть источником - истины (дедупликация, трекинг processed_uids), а Radicale — цель синка. +3. **Двусторонний синк** — правки, сделанные с телефона (DAVx5) в адресной + книге, синхронизируются обратно в `contacts.json`: + - изменённые карточки → обновление контакта + - новые карточки → новые контакты + - удалённые карточки → soft-delete (`deleted: true`) в базе + - конфликт (изменено и в почте, и на телефоне) → приоритет телефону, + локальная версия сохраняется в лог, данные не теряются +4. **Локальная база contacts.json** остаётся источником истины (дедупликация, + трекинг processed_uids) и хранит состояние синка (ETag карточки). ## Capabilities @@ -48,10 +55,8 @@ параллельном редактировании на телефоне решаются по ETag (см. design). - **Документация:** обновить README/STATUS (как подключить адресную книгу на Android, порты). -- **Риски:** двусторонняя синхронизация (правка на телефоне → обратно в базу) - — НЕ в скоупе этого change (односторонний синк: почта → сервер). - Односторонний синк без конфликтов: только PUT новых/обновлённых, DELETE - только явно помеченных. +- **Риски:** двусторонний синк требует разрешения конфликтов (в change — + приоритет телефону + лог локальной версии, см. REQ-011). ## Rollback diff --git a/openspec/changes/contacts-caldav-server/specs/contacts/carddav-sync/spec.md b/openspec/changes/contacts-caldav-server/specs/contacts/carddav-sync/spec.md index 742ea21..45e747a 100644 --- a/openspec/changes/contacts-caldav-server/specs/contacts/carddav-sync/spec.md +++ b/openspec/changes/contacts-caldav-server/specs/contacts/carddav-sync/spec.md @@ -99,10 +99,62 @@ PUT, чтобы не затирать изменения, сделанные к **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 -- Двусторонняя синхронизация (правки на телефоне не пишутся обратно в - contacts.json). +- Глубокая биография/полная модель контакта — синк обновляет стандартные + поля vCard (FN, EMAIL, TEL, ORG, TITLE, ADR); произвольные расширения + vCard (X-*, категории, фото) не переносятся в contacts.json. - Миграция существующих .vcf из локальных файлов (contacts.vcf остаётся как есть, синк идёт из contacts.json). - Синхронизация с внешними CardDAV/Google/Cloud — только локальный Radicale. \ No newline at end of file diff --git a/openspec/changes/contacts-caldav-server/tasks.md b/openspec/changes/contacts-caldav-server/tasks.md index d7fa664..109e0a0 100644 --- a/openspec/changes/contacts-caldav-server/tasks.md +++ b/openspec/changes/contacts-caldav-server/tasks.md @@ -27,10 +27,14 @@ `--caldav-user`, `--caldav-pass` (или env CALDAV_PASS). По умолчанию синк выключен. Проверка: `python3 scripts/contacts_extractor.py --help` показывает все флаги; без `--sync-caldav` поведение прежнее. -- [ ] 2.4 ETag-конфликты: при PUT с If-Match и ответе 412 — записать - `caldav-sync.log` (контакт, conflict) и продолжить. Проверка: ручной - тест — изменить vCard на сервере, запустить синк, увидеть conflict - в логе. +- [ ] 2.4 Двусторонняя сверка: `sync_carddav()` после push выполняет pull — + PROPFIND Depth:1, сравнение ETag, создание/обновление контактов из + новых/изменённых карточек (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 @@ -44,6 +48,14 @@ синк обновляет карточку (TEL появился, ETag изменился). - [ ] 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 и документация @@ -61,4 +73,5 @@ - [ ] V1: `curl -X PROPFIND -u estorozhenko:PASS http://127.0.0.1:5232/estorozhenko/Контакты/` → 207 + addressbook - [ ] V2: после синка количество vCard в адресной книге == числу контактов с email в contacts.json - [ ] V3: повторный синк → 0 новых карточек -- [ ] V4: `python3 scripts/contacts_extractor.py --sync-caldav` из cron-обёртки завершается кодом 0 и пишет caldav-sync.log \ No newline at end of file +- [ ] V4: правка контакта на сервере (curl PUT vCard) → контакт в contacts.json обновлён после синка +- [ ] V5: `python3 scripts/contacts_extractor.py --sync-caldav` из cron-обёртки завершается кодом 0 и пишет caldav-sync.log \ No newline at end of file