From de07fae246a0b4624424a67d18ac3c08180d5cec Mon Sep 17 00:00:00 2001 From: hermes Date: Sun, 13 Sep 2026 15:59:49 +0000 Subject: [PATCH] =?UTF-8?q?openspec:=20change=20contacts-caldav-server=20(?= =?UTF-8?q?CardDAV=20sync=20=D0=B4=D0=BB=D1=8F=20=D0=BA=D0=BE=D0=BD=D1=82?= =?UTF-8?q?=D0=B0=D0=BA=D1=82=D0=BE=D0=B2=20=D0=B8=D0=B7=20=D0=BF=D0=BE?= =?UTF-8?q?=D1=87=D1=82=D1=8B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../contacts-caldav-server/.openspec.yaml | 2 + .../changes/contacts-caldav-server/design.md | 129 ++++++++++++++++++ .../contacts-caldav-server/proposal.md | 65 +++++++++ .../specs/contacts/carddav-sync/spec.md | 108 +++++++++++++++ .../changes/contacts-caldav-server/tasks.md | 64 +++++++++ 5 files changed, 368 insertions(+) create mode 100644 openspec/changes/contacts-caldav-server/.openspec.yaml create mode 100644 openspec/changes/contacts-caldav-server/design.md create mode 100644 openspec/changes/contacts-caldav-server/proposal.md create mode 100644 openspec/changes/contacts-caldav-server/specs/contacts/carddav-sync/spec.md create mode 100644 openspec/changes/contacts-caldav-server/tasks.md diff --git a/openspec/changes/contacts-caldav-server/.openspec.yaml b/openspec/changes/contacts-caldav-server/.openspec.yaml new file mode 100644 index 0000000..c238415 --- /dev/null +++ b/openspec/changes/contacts-caldav-server/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-13 diff --git a/openspec/changes/contacts-caldav-server/design.md b/openspec/changes/contacts-caldav-server/design.md new file mode 100644 index 0000000..82b191f --- /dev/null +++ b/openspec/changes/contacts-caldav-server/design.md @@ -0,0 +1,129 @@ +# Design: CardDAV-сервер для контактов (Radicale sync) + +## Context + +- Radicale уже развёрнут в `/opt/hermes/email-assistant/radicale/` (docker, + порт 5232), работает CalDAV (календарь). Radicale из коробки умеет + CardDAV — адресные книги создаются так же, как календари (коллекции на + ФС), разница только в `resourcetype` (`` вместо + ``). +- Пользователь: `estorozhenko`, пароль — в `radicale/.env` (`RADICALE_PASS`), + htpasswd-файл `/data/users` в контейнере. +- Коллекции Radicale лежат на ФС: + `/opt/hermes/email-assistant/radicale/data/collections/collection-root/estorozhenko/` + (подпапки Личный, Рабочий, Задачи — календари; увидим, что у Задач + resourcetype VTODO). +- Контакты извлекаются `scripts/contacts_extractor.py` (Qwen3:8b через + Ollama), пишутся в `/opt/hermes/email/contacts/`: + - `contacts.json` — база `{"contacts": [...], "by_email": {...}}` + - `index.json` — email → contact_id + - `contacts.vcf` — vCard 4.0 (для импорта) + - `last_scan.json` — трекинг обработанных писем +- Сеть: bigbox (10.8.0.2), наружу — Caddy на vps02 (cal.nixg.ru → 5232). + +## Решение + +### 1. Адресная книга в Radicale (создание на ФС) + +Radicale 3.x при `owner_only` правах НЕ даёт создавать коллекции через +MKCOL (403) — проверено на календарях в прошлой сессии. Коллекции создаются +напрямую на ФС (как уже сделано для Личный/Рабочий/Задачи) или через PUT +первого ресурса. + +**Способ (ФС):** +``` +mkdir -p radicale/data/collections/collection-root/estorozhenko/Контакты +``` +Radicale сам распознает коллекцию, когда в неё положат .vcf (Radicale +создаёт .Radicale.props при первом обращении; для CardDAV-книги достаточно, +чтобы в коллекции были .vcf-файлы). Для явного resourcetype можно положить +`.Radicale.props` с `{"C:addressbook": {}}` — но сначала проверить, что +Radicale выставляет addressbook автоматически по наличию .vcf. + +**Проверка RS-типа:** +``` +curl -u estorozhenko:PASS -X PROPFIND -H 'Depth: 0' \ + http://127.0.0.1:5232/estorozhenko/Контакты/ +``` +→ должен содержать ``. + +### 2. Синк в contacts_extractor.py + +В конец существующего пайплайна (после save_progress) добавляется шаг +**sync_contacts_to_caldav()**, который: + +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). + +**Клиент:** стандартный `urllib.request` + `base64` Basic Auth (без новых +зависимостей) или `curl` через subprocess. Предпочтительно urllib — синк +вызывается из cron (contacts-cron.sh) и не должен зависеть от curl-параметров. + +**Флаги CLI:** +``` +contacts_extractor.py --sync-caldav # включить синк после обработки +contacts_extractor.py --sync-caldav --prune-caldav +contacts_extractor.py --caldav-url http://127.0.0.1:5232 +contacts_extractor.py --caldav-user estorozhenko +contacts_extractor.py --caldav-pass # или env CALDAV_PASS +``` +По умолчанию — без `--sync-caldav` ничего не синкается (обратная +совместимость: старые запуски не меняют поведение). + +**Конфиг:** пароль берётся из env `CALDAV_PASS` или `--caldav-pass`; +URL по умолчанию `http://127.0.0.1:5232` (можно переопределить). + +### 3. Cron + +Добавить `--sync-caldav` в существующий `config/contacts-cron.sh` (тот же +cron `contacts-extractor-every-30m`, no-agent скрипт). Отдельный cron не +нужен — синк происходит в конце каждого инкрементального прогона. +CALDAV_PASS — из `radicale/.env` (источник пароля один). + +### 4. Переменные/секрет + +Пароль Radicale уже лежит в `radicale/.env`. contacts-cron.sh будет читать +`RADICALE_PASS` оттуда и передавать в `--caldav-pass` (или env). +В git-коммит .env не идёт (.gitignore) — секреты в репозитории нет. + +## Open Questions + +- **Radicale-версия:** 3.8.1.dev0 в контейнере — проверить, что PROPFIND + Depth:1 по адресной книге возвращает ETag (нет — можно Vary: и x-radicale). + (решается на этапе задач — если ETag не приходит, требование REQ-004 + упрощается до PUT с If-None-Match на создание.) +- **Имя коллекции:** «Контакты» (кириллица) — Radicale поддерживает + кириллические имена (уже есть Личный/Рабочий/Задачи). Клиенты DAVx5 + нормально работают с кириллическими путями. + +## Testing + +- Создание книги: PROPFIND → 207 + addressbook RS. +- PUT vCard → 201; повторный PUT/Bad Request при невалидной vCard → 400. +- Повторный синк → 204/пропуск, дублей нет. +- Изменение контакта → PUT 204 + обновлённая vCard. +- Конфликт: на сервере вручную поменять vCard → синк даёт 412 + лог. +- `--prune-caldav` удаляет отсутствующие карточки. +- После синка: DAVx5 на телефоне видит контакты (ручная проверка). + +## Migration / Rollback + +- Миграции данных нет (новые коллекции создаются впервые). +- Откат: убрать `--sync-caldav` из cron + удалить коллекцию Контакты на ФС. + Локальная база/файлы не затрагиваются. \ No newline at end of file diff --git a/openspec/changes/contacts-caldav-server/proposal.md b/openspec/changes/contacts-caldav-server/proposal.md new file mode 100644 index 0000000..82cbd67 --- /dev/null +++ b/openspec/changes/contacts-caldav-server/proposal.md @@ -0,0 +1,65 @@ +# Proposal: CardDAV-сервер для синхронизации контактов + +## Why + +Сейчас контакты, извлечённые LLM из подписей писем, лежат только в файлах +`/opt/hermes/email/contacts/{contacts.json, contacts.vcf, index.json}` и никуда +не синхронизируются. Чтобы пользоваться ими на телефоне (Android) и в других +клиентах, нужен CardDAV-сервер с адресными книгами, куда контакты пишутся +сразу при извлечении. + +Почему CardDAV, а не просто наличие .vcf: нативный Android (DAVx5) и +большинство клиентов умеют только CardDAV-протокол. Отдельный файл .vcf на +диске никто не читает. + +## What Changes + +1. **Radicale расширяется на CardDAV** — это тот же сервис Radicale (:5232), + который уже развёрнут для CalDAV (календарь). Radicale из коробки умеет + CardDAV (addressbook collections). Нужно только: + - создать адресную книгу (например `Контакты`) в коллекциях Radicale + - проверить CardDAV-endpoint (`/estorozhenko/Контакты/`) +2. **Контакты пишутся сразу в карточки** — contacts_extractor.py после + извлечения и дедупликации пишет/обновляет vCard в Radicale через + CardDAV PUT, а не только в локальные файлы: + - на каждый контакт — один `.vcf` в адресной книге + - при обновлении контакта — PUT с новым ETag + - удаление контакта, которого больше нет в базе — DELETE (опционально, см. design) +3. **Остаётся локальная база contacts.json** — она продолжает быть источником + истины (дедупликация, трекинг processed_uids), а Radicale — цель синка. + +## Capabilities + +### New Capabilities +- `contacts/carddav-sync`: Синхронизация извлечённых из почты контактов + в CardDAV-сервер (Radicale) — создание/обновление/удаление vCard-карточек, + доступных клиентам (DAVx5 на Android и др.) + +### Modified Capabilities + + +## Impact + +- **Сервис:** Radicale (:5232) — уже работает, добавляется CardDAV-часть + (адресная книга). Новых портов нет. +- **Скрипт:** `scripts/contacts_extractor.py` — добавляется синк в Radicale + (PUT/DELETE vCard), появляется зависимость от CardDAV-клиента/HTTP. +- **Данные:** контакты синхронизируются на сервер; конфликты при + параллельном редактировании на телефоне решаются по ETag (см. design). +- **Документация:** обновить README/STATUS (как подключить адресную книгу + на Android, порты). +- **Риски:** двусторонняя синхронизация (правка на телефоне → обратно в базу) + — НЕ в скоупе этого change (односторонний синк: почта → сервер). + Односторонний синк без конфликтов: только PUT новых/обновлённых, DELETE + только явно помеченных. + +## Rollback + +1. Отключить синк: убрать шаг синка в `contacts_extractor.py` (флаг + `--no-caldav` / откат коммита) — локальные файлы и база не затрагиваются. +2. Удалить адресную книгу из Radicale: `rm -rf + /opt/hermes/email-assistant/radicale/data/collections/collection-root/estorozhenko/Контакты` + (или через DAVx5). +3. Radicale сам не откатывается — он как был, так и остаётся (CalDAV + календарь продолжает работать). +4. Данные локально не теряются: contacts.json/contacts.vcf остаются. \ No newline at end of file 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 new file mode 100644 index 0000000..742ea21 --- /dev/null +++ b/openspec/changes/contacts-caldav-server/specs/contacts/carddav-sync/spec.md @@ -0,0 +1,108 @@ +# 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** файл существует и содержит актуальную базу контактов. + +## Non-Goals + +- Двусторонняя синхронизация (правки на телефоне не пишутся обратно в + 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 new file mode 100644 index 0000000..d7fa664 --- /dev/null +++ b/openspec/changes/contacts-caldav-server/tasks.md @@ -0,0 +1,64 @@ +# Tasks: CardDAV-сервер для синхронизации контактов + +## 1. Адресная книга в Radicale + +- [ ] 1.1 Создать адресную книгу «Контакты» на ФС: + `mkdir -p /opt/hermes/email-assistant/radicale/data/collections/collection-root/estorozhenko/Контакты` + и проверить, что Radicale видит её как addressbook: + `curl -u estorozhenko:$RADICALE_PASS -X PROPFIND -H 'Depth: 0' http://127.0.0.1:5232/estorozhenko/Контакты/` + → HTTP 207 и в XML есть `` (если RS не определяется — + добавить `.Radicale.props` с addressbook и повторить). +- [ ] 1.2 Положить тестовую vCard (test.vcf) в коллекцию и проверить, что + она отдаётся: `curl ... /estorozhenko/Контакты/test.vcf` → 200 + vCard; + затем удалить тестовую карточку. + +## 2. Синк в contacts_extractor.py + +- [ ] 2.1 Рефакторинг: вынести генерацию vCard 4.0 из существующей + generate_vcard() в отдельную функцию `contact_to_vcard(contact) -> str`, + чтобы переиспользовать для CardDAV-карточек. Проверка: скрипт + запускается без ошибок, contacts.vcf генерируется как раньше. +- [ ] 2.2 Добавить функцию `sync_contacts_to_caldav(contacts_dir, base_url, + user, password, prune=False)`: читает contacts.json, строит карту + uid→(ETag, href) через PROPFIND Depth:1, PUT создаёт/обновляет vCard, + при `prune=True` DELETE удаляет лишние. Использовать urllib + Basic + Auth. Проверка: юнит-запуск с тестовым Radicale (см. 3.x). +- [ ] 2.3 CLI-флаги: `--sync-caldav`, `--prune-caldav`, `--caldav-url`, + `--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 + в логе. + +## 3. Сквозной тест sync + +- [ ] 3.1 Запустить синк с реальной базой: + `python3 scripts/contacts_extractor.py --sync-caldav` + (или отдельный скрипт) → в Radicale появились карточки (счётчик + PROPFIND/cards): `curl ... PROPFIND Depth:1 /estorozhenko/Контакты/` + показывает N карточек ≈ количеству контактов в contacts.json с email. +- [ ] 3.2 Повторный запуск — количество карточек не растёт (идемпотентность). +- [ ] 3.3 Изменить контакт в contacts.json (добавить телефон) → повторный + синк обновляет карточку (TEL появился, ETag изменился). +- [ ] 3.4 `--prune-caldav`: удалить контакт из contacts.json → карточка + удалена с сервера. + +## 4. Интеграция в cron и документация + +- [ ] 4.1 Обновить `config/contacts-cron.sh`: добавить `--sync-caldav` и + подтянуть пароль из radicale/.env (env CALDAV_PASS). Проверка: + запуск cron-скрипта вручную синкает контакты без ошибок. +- [ ] 4.2 Обновить README/STATUS: раздел «CardDAV (контакты)» — как + подключить на Android (DAVx5, URL http://cal.nixg.ru:5232 + или cal.nixg.ru, логин estorozhenko), порты, флаги синка. +- [ ] 4.3 `openspec validate contacts-caldav-server` → valid. +- [ ] 4.4 Git commit и push (gitea.nixg.ru/hermes/email-assistant). + +## Verification + +- [ ] 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