# 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: http://127.0.0.1:5232/estorozhenko/Контакты/` **THEN** возвращается HTTP 207 (Multi-Status) и в ответе есть `` с `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.