diff --git a/README.md b/README.md index dcb8ff6..0d51597 100644 --- a/README.md +++ b/README.md @@ -11,4 +11,11 @@ ## Оценка альтернатив - [Анализ Nylas CLI](NYLAS_ANALYSIS.md) — почему Nylas **не подходит** для локального архива (2026-09-11) -- [Анализ формата хранения: ФС vs Maildir](STORAGE_ANALYSIS.md) — почему текущий формат удобнее Maildir для локальной LLM (2026-09-11) \ No newline at end of file +- [Анализ формата хранения: ФС vs Maildir](STORAGE_ANALYSIS.md) — почему текущий формат удобнее Maildir для локальной LLM (2026-09-11) + +## CardDAV / CalDAV (контакты, календарь, задачи) + +- **Radicale** (:5232, bigbox, Docker) — CalDAV/CardDAV-сервер. Карточки контактов синхронизируются двусторонне: `scripts/contacts_caldav_sync.py` (push 81 контакт → vCard; pull правок/создания/удаления с телефона → `contacts.json`). +- **Vikunja** (:3456) — трекер задач (в Docker, разворачивается). +- **Android:** [DAVx⁵](https://www.davx5.com) (F-Droid/Play) для контактов/календаря, jtx board для задач Vikunja. +- **Публичный доступ:** через Caddy на vps02 (`cal.nixg.ru` → Radicale, `tasks.nixg.ru` → Vikunja) — настройка = следующая задача; подробности и конфиги Caddy: [STATUS.md → «DAVx⁵ (Android: CalDAV/CardDAV-мост)»](STATUS.md) \ No newline at end of file diff --git a/STATUS.md b/STATUS.md index e90fdaf..d50631c 100644 --- a/STATUS.md +++ b/STATUS.md @@ -119,13 +119,15 @@ Hermes cron: - [x] Change `local-calendar-tasks` создан и валиден (опenspec-lab) — proposal/specs/design/tasks - [x] **Radicale развёрнут и РАБОТАЕТ (2026-09-13)**: контейнер на :5232, PROPFIND 207 с паролем / 401 без. Коллекции Личный/Рабочий/Задачи на ФС. - [x] **Vikunja v2.6.0 развёрнут (2026-09-13)**: postgres + app, миграции прошли, HTTP слушает :3456. **Блокер:** снаружи connection reset (docker-proxy/NAT на bigbox) — контейнер отвечает, маунт файлов исправлен (./files → /app/vikunja/files). -- [ ] Vikunja: создать админа + API-токен, проверить tasks.nixg.ru -- [ ] Caddy reverse proxy (cal.nixg.ru → 5232, tasks.nixg.ru → 3456) -- [ ] Android-синхронизация (DAVx5) +- [x] **CardDAV-синк контактов (2026-09-13, change `contacts-caldav-server`)**: `scripts/contacts_caldav_sync.py` — двусторонний sync. PUSH: 81 контакт → vCard в Radicale. PULL: правки/создание/удаление карточек с телефона → contacts.json. Идемпотентно (162 unchanged, 0 PUT на повторе). Конфликты (412) — приоритет телефону, локальная версия в `caldav-sync.log`. Подробнее: `scripts/contacts_caldav_sync.py --help`, лог `/opt/hermes/email/contacts/caldav-sync.log`. +- [ ] **Vikunja: создать админа + API-токен, проверить tasks.nixg.ru** +- [ ] **Caddy reverse proxy (cal.nixg.ru → 5232, tasks.nixg.ru → 3456)** — СДЕЛАТЬ СЛЕДУЮЩЕЙ СЕССИЕЙ (см. ниже «DAVx⁵») +- [ ] **Android-синхронизация (DAVx5)** — СДЕЛАТЬ СЛЕДУЮЩЕЙ СЕССИЕЙ (см. ниже) -### Задача 3: Нативная синхронизация с Android ⬜ (после Задачи 2) -- [ ] DAVx5 на телефоне → Radicale; Vikunja app/token -- [ ] Проверка двусторонней синхронизации (событие с телефона → bigbox) +### Задача 3: Нативная синхронизация с Android 🔵 (следующая сессия) +- [ ] **Первая задача след. сессии: настроить синхронизацию с телефоном** (DAVx5 → Radicale; Vikunja → jtx board) +- [ ] Настроить Caddy reverse proxy для Radicale (cal.nixg.ru) — см. ниже +- [ ] Проверка двусторонней синхронизации (событие/контакт с телефона → bigbox → база) ### Задача 1: Веб-интерфейс ассистента ⬜ - [ ] FastAPI + SQLite FTS5: список писем (дата/адресант/тэги/папка) @@ -235,6 +237,51 @@ Hermes cron: --- +## DAVx⁵ (Android: CalDAV/CardDAV-мост) + +**Назначение:** DAVx⁵ — приложение-синхронизатор для Android, **не имеет собственного UI** для просмотра событий/контактов, а встраивается в стандартные системные приложения Android (Календарь, Контакты). Это стандарт де-факто для синхронизации с Radicale на Android. + +- **Где взять:** F-Droid (бесплатно) или Google Play (платно, поддержка разработчиков). +- **Как работает:** добавляете аккаунт (URL сервера Radicale, логин, пароль) → DAVx⁵ сам находит доступные календари и адресные книги → выбираете, что синхронизировать с системой. +- **Для задач** пользователь поставил **jtx board** (не DAVx⁵). + +### Настройка Caddy для Radicale + +Проксирование Radicale через Caddy требует внимания к путям и заголовкам, иначе CalDAV/CardDAV-клиенты не найдут ресурсы. + +**Рабочий пример для домена `dav.example.com` (Radicale в корне):** +```caddyfile +dav.example.com { + # Важно: сохраняем заголовок Authorization для Radicale + header_up Authorization {header.Authorization} + + # Если Radicale в подпапке — используйте handle_path (см. ниже) + reverse_proxy localhost:5232 +} +``` + +**Ключевые моменты:** +1. **Сохранение Authorization:** Caddy по умолчанию может удалять заголовки. `header_up Authorization {header.Authorization}` гарантирует, что Radicale получит логин/пароль. +2. **X-Script-Name (если в подпапке):** для размещения по `/radicale` нужен `handle_path` для удаления префикса пути + заголовок `X-Script-Name`, чтобы Radicale знал о своём расположении. +3. **Обязательный `handle_path` для подпапки:** простой `reverse_proxy` внутри `handle` может не сработать — Radicale ожидает запросы без префикса (получает его через `X-Script-Name`). + +**Пример для подпапки `/radicale`:** +```caddyfile +dav.example.com { + handle_path /radicale/* { + header_up Authorization {header.Authorization} + header_up X-Script-Name /radicale + reverse_proxy localhost:5232 + } +} +``` + +**В DAVx⁵ указывается базовый URL** (например, `https://dav.example.com` или `https://dav.example.com/radicale`); пути к календарям/контактам приложение определяет автоматически. + +**Наш случай (следующая сессия):** поддомен `cal.nixg.ru` (Caddy на vps02, `reverse_proxy 10.8.0.2:5232` к bigbox) — Radicale слушает 127.0.0.1:5232, user `estorozhenko`, пароль `RADICALE_PASS` в `radicale/.env`. Адресная книга: `Контакты` (кириллица в URL — DAVx5 умеет). Vikunja → jtx board через `tasks.nixg.ru`. + +--- + ## Конфигурация - **Репозиторий:** `https://gitea.nixg.ru/hermes/email-assistant` diff --git a/openspec/changes/contacts-caldav-server/tasks.md b/openspec/changes/contacts-caldav-server/tasks.md index 109e0a0..07770f7 100644 --- a/openspec/changes/contacts-caldav-server/tasks.md +++ b/openspec/changes/contacts-caldav-server/tasks.md @@ -2,59 +2,65 @@ ## 1. Адресная книга в Radicale -- [ ] 1.1 Создать адресную книгу «Контакты» на ФС: +- [x] 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) в коллекцию и проверить, что + **Сделано:** MKCOL через протокол дал 201; `.Radicale.props` = `{"tag": "VADDRESSBOOK"}`. +- [x] 1.2 Положить тестовую vCard (test.vcf) в коллекцию и проверить, что она отдаётся: `curl ... /estorozhenko/Контакты/test.vcf` → 200 + vCard; затем удалить тестовую карточку. ## 2. Синк в contacts_extractor.py -- [ ] 2.1 Рефакторинг: вынести генерацию vCard 4.0 из существующей +- [x] 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, + **Сделано:** в `scripts/contacts_caldav_sync.py` (модульная выноска, contacts_extractor не тронут). +- [x] 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`, +- [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 Двусторонняя сверка: `sync_carddav()` после push выполняет pull — + **Сделано:** CLI в `contacts_caldav_sync.py` (--sync-caldav выключен по умолчанию, --dry-run и пр.) +- [x] 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 — принять версию +- [x] 2.5 ETag-конфликты: при PUT с If-Match и ответе 412 — принять версию с сервера, локальную записать в `caldav-sync.log` (`conflict_local`), продолжить (REQ-011). Проверка: ручной тест — изменить vCard на сервере И контакт в базе, запустить синк, увидеть conflict в логе. ## 3. Сквозной тест sync -- [ ] 3.1 Запустить синк с реальной базой: +- [x] 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 (добавить телефон) → повторный + **Сделано:** 81 карточка создана (81 контакт), PROPFIND 207. +- [x] 3.2 Повторный запуск — количество карточек не растёт (идемпотентность). + **Сделано:** стабильно 162 unchanged (81 push + 81 pull), 0 PUT. +- [x] 3.3 Изменить контакт в contacts.json (добавить телефон) → повторный синк обновляет карточку (TEL появился, ETag изменился). -- [ ] 3.4 `--prune-caldav`: удалить контакт из contacts.json → карточка +- [x] 3.4 `--prune-caldav`: удалить контакт из contacts.json → карточка удалена с сервера. -- [ ] 3.5 **Reverse pull:** вручную (curl PUT) изменить vCard на сервере → +- [x] 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), и контакт в базе → синк + **Сделано:** добавлен/удалён TEL на сервере → база обновилась/обнулила phone. +- [x] 3.6 **Новая карточка на сервере:** curl PUT новой vCard → синк создаёт + контакт в базе. **Сделано:** pulled_created (id=phone-newcard-001, from_device=True). +- [x] 3.7 **Удаление на сервере:** curl DELETE vCard → контакт получает + `deleted: true` в базе. **Сделано:** pulled_deleted, артефакт убран из базы. +- [x] 3.8 **Конфликт:** изменить и vCard (curl), и контакт в базе → синк применяет версию с сервера, локальная в caldav-sync.log. ## 4. Интеграция в cron и документация @@ -62,12 +68,30 @@ - [ ] 4.1 Обновить `config/contacts-cron.sh`: добавить `--sync-caldav` и подтянуть пароль из radicale/.env (env CALDAV_PASS). Проверка: запуск cron-скрипта вручную синкает контакты без ошибок. -- [ ] 4.2 Обновить README/STATUS: раздел «CardDAV (контакты)» — как +- [x] 4.2 Обновить README/STATUS: раздел «CardDAV (контакты)» — как подключить на Android (DAVx5, URL http://cal.nixg.ru:5232 или cal.nixg.ru, логин estorozhenko), порты, флаги синка. + **Сделано (2026-09-13):** STATUS.md — новый раздел «DAVx⁵ (Android: CalDAV/CardDAV-мост)» + Caddy-конфиги (Authorization, handle_path, X-Script-Name), Задача 3 помечена как следующая сессия. - [ ] 4.3 `openspec validate contacts-caldav-server` → valid. - [ ] 4.4 Git commit и push (gitea.nixg.ru/hermes/email-assistant). +## 5. Синхронизация с телефоном (СЛЕДУЮЩАЯ СЕССИЯ — первая задача) + +- [ ] 5.1 Настроить Caddy на vps02: `cal.nixg.ru` → Radicale bigbox :5232 + (header_up Authorization, reverse_proxy 10.8.0.2:5232); + `tasks.nixg.ru` → Vikunja :3456. Reload Caddyfile. +- [ ] 5.2 Проверить CalDAV/CardDAV снаружи: PROPFIND https://cal.nixg.ru/... → 207 + (учесть кириллицу «Контакты» в URL, Auth). +- [ ] 5.3 DAVx⁵ на телефоне: аккаунт https://cal.nixg.ru, логин estorozhenko, + пароль RADICALE_PASS → выбрать календари (Личный/Рабочий/Задачи) + и адресную книгу «Контакты». +- [ ] 5.4 jtx board на телефоне → Vikunja https://tasks.nixg.ru (токен API), + проверить задачи. +- [ ] 5.5 Проверка двусторонней синхронизации: контакт создан/изменён на + телефоне → в базе (contacts.json) после `contacts_caldav_sync.py`; + задача из jtx board → в Vikunja; событие из DAVx⁵ → в Radicale. +- [ ] 5.6 (опц.) Вписать sync в contacts-cron.sh (4.1) и проверить V5. + ## Verification - [ ] V1: `curl -X PROPFIND -u estorozhenko:PASS http://127.0.0.1:5232/estorozhenko/Контакты/` → 207 + addressbook diff --git a/scripts/contacts_caldav_sync.py b/scripts/contacts_caldav_sync.py new file mode 100644 index 0000000..fb726c9 --- /dev/null +++ b/scripts/contacts_caldav_sync.py @@ -0,0 +1,592 @@ +#!/usr/bin/env python3 +""" +contacts_caldav_sync.py — двусторонняя синхронизация контактов +между локальной базой contacts.json и CardDAV-сервером Radicale. + +Направления: + PUSH (база → сервер): новые/изменённые контакты пишутся как vCard + PULL (сервер → база): правки на телефоне (DAVx5) попадают обратно: + - изменённая карточка → обновление контакта + - новая карточка → новый контакт + - удалённая карточка → deleted: true (soft delete) + Конфликты: приоритет серверу (телефон), локальная версия в caldav-sync.log + +Запуск: + python3 contacts_caldav_sync.py [--base-url URL] [--user USER] [--pass PASS] + [--addressbook КОНТАКТЫ] [--prune] [--dry-run] + + Пароль: --pass или env CALDAV_PASS (или RADICALE_PASS). + Без --pass → читает radicale/.env (RADICALE_PASS). +""" + +import argparse +import base64 +import json +import os +import re +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +import xml.etree.ElementTree as ET +from datetime import date, datetime +from pathlib import Path + +EMAIL_ROOT = Path("/opt/hermes/email") +CONTACTS_DIR = EMAIL_ROOT / "contacts" +LOG_PATH = CONTACTS_DIR / "caldav-sync.log" + +DEFAULT_BASE_URL = "http://127.0.0.1:5232" +DEFAULT_USER = "estorozhenko" +DEFAULT_ADDRESSBOOK = "Контакты" + + +# ─── HTTP helpers ────────────────────────────────────────────────────────────── + +def _auth_header(user, password): + token = base64.b64encode(f"{user}:{password}".encode()).decode() + return {"Authorization": f"Basic {token}"} + + +def _request(method, url, headers=None, data=None, timeout=15): + req = urllib.request.Request(url, method=method, headers=headers or {}) + if data is not None: + if isinstance(data, str): + data = data.encode("utf-8") + req.data = data + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + return resp.status, resp.read() + except urllib.error.HTTPError as e: + return e.code, e.read() + except urllib.error.URLError as e: + return 0, str(e).encode() + + +def _etag_from_response(resp_headers): + return resp_headers.get("ETag") or resp_headers.get("etag") + + +# ─── vCard helpers ──────────────────────────────────────────────────────────── + +def contact_to_vcard(contact): + """Собрать vCard 4.0 из контакта (как generate_vcard в contacts_extractor).""" + lines = ["BEGIN:VCARD", "VERSION:4.0"] + uid = contact.get("id") or "" + if uid: + lines.append(f"UID:{uid}") + full_name = contact.get("full_name") or "" + lines.append(f"FN:{full_name}") + name_parts = full_name.split(maxsplit=2) + if len(name_parts) >= 2: + n_line = f"N:{name_parts[-1]};{name_parts[0]};{' '.join(name_parts[1:-1])};;" + else: + n_line = f"N:{full_name};;;;" + lines.append(n_line) + email = contact.get("email") + if email: + lines.append(f"EMAIL;TYPE=WORK:{email}") + if contact.get("phone"): + # Radicale режет value по запятой → заменяем на пробел (доб. 3557 сохраняется) + lines.append(f"TEL;TYPE=WORK:{contact['phone'].replace(',', ' ')}") + if contact.get("phone_secondary"): + lines.append(f"TEL;TYPE=CELL:{contact['phone_secondary'].replace(',', ' ')}") + if contact.get("position"): + lines.append(f"TITLE:{contact['position']}") + if contact.get("company"): + lines.append(f"ORG:{contact['company']}") + if contact.get("address"): + lines.append(f"ADR;TYPE=WORK:;;{contact['address']};;;") + # Источник: X-SOURCES через | (Radicale режет по запятой, | не режет) + if contact.get("source_uids"): + sources = " | ".join(contact.get("source_uids", [])) + lines.append(f"X-SOURCES:{sources}") + lines.append("END:VCARD") + return "\r\n".join(lines) + "\r\n" + + +def parse_vcard_to_contact(vcard_text, uid, base_contact=None): + """Извлечь поля из vCard в dict контакта (для pull-направления).""" + contact = dict(base_contact or {}) + contact["id"] = uid + fields = {} + current = None + for line in vcard_text.replace("\r\n", "\n").split("\n"): + line = line.strip() + if not line: + continue + if line.startswith("BEGIN:"): + continue + if line.startswith("END:"): + continue + if ":" in line: + key, _, value = line.partition(":") + # params: EMAIL;TYPE=WORK → name=EMAIL + name = key.split(";")[0].upper() + value = value.strip() + if name in ("FN", "N", "EMAIL", "TEL", "TITLE", "ORG", "ADR", "NOTE"): + fields.setdefault(name, []).append(value) + if "FN" in fields: + contact["full_name"] = fields["FN"][0] + if "EMAIL" in fields: + email = fields["EMAIL"][0] + # strip mailto: + if email.lower().startswith("mailto:"): + email = email[7:] + contact["email"] = email.lower() + tels = [t for t in fields.get("TEL", []) if t] + if tels: + tel = tels[0] + if tel.lower().startswith("tel:"): + tel = tel[4:] + contact["phone"] = tel + if len(tels) > 1: + contact["phone_secondary"] = tels[1] + if "TITLE" in fields: + contact["position"] = fields["TITLE"][0] + if "ORG" in fields: + contact["company"] = fields["ORG"][0].replace("\\,", ",") + if "ADR" in fields: + # ADR;TYPE=WORK:;;address;;; → берём 3-ю часть + adr = fields["ADR"][0] + parts = adr.split(";") + if len(parts) >= 3 and parts[2]: + contact["address"] = parts[2] + return contact + + +# ─── CardDAV ops ────────────────────────────────────────────────────────────── + +def carddav_base_url(base_url, user, addressbook): + """URL адресной книги.""" + return f"{base_url.rstrip('/')}/{urllib.parse.quote(user)}/{urllib.parse.quote(addressbook)}/" + + +def list_addressbook(base_url, user, addressbook, auth): + """PROPFIND Depth:1 → {href: {'etag': str, 'vcard': str}} (vcard via GET).""" + ab_url = carddav_base_url(base_url, user, addressbook) + body = """ + + +""" + status, content = _request( + "PROPFIND", ab_url, + headers={**auth, "Depth": "1", "Content-Type": "application/xml"}, + data=body, + ) + if status not in (207, 200): + return None, f"PROPFIND → {status}: {content[:200]}" + xml = content.decode("utf-8", errors="replace") + # Парсим response-блоки через ElementTree (Radicale использует префикс D: по умолчанию, + # но может быть и без — ET с namespaces справится). + cards = {} + try: + root = ET.fromstring(xml) + except ET.ParseError as e: + return None, f"PROPFIND XML parse: {e}" + dav = "{DAV:}" + for resp in root.iter(f"{dav}response"): + href_el = resp.find(f"{dav}href") + if href_el is None or not href_el.text: + continue + href = href_el.text.strip() + if href.endswith("/"): + continue # сама коллекция + # etag лежит в propstat/prop/getetag + etag = "" + for prop in resp.iter(f"{dav}prop"): + getetag = prop.find(f"{dav}getetag") + if getetag is not None and getetag.text: + etag = getetag.text.strip() + break + cards[href] = {"etag": etag} + # Тянем содержимое каждой карточки (GET) + for href in list(cards): + card_url = f"{base_url.rstrip('/')}{href}" if href.startswith("/") else f"{ab_url.rstrip('/')}/{href}" + status2, content2 = _request("GET", card_url, headers=auth) + if status2 == 200: + cards[href]["vcard"] = content2.decode("utf-8", errors="replace") + else: + cards[href]["vcard"] = "" + return cards, None + + +def put_card(base_url, user, addressbook, uid, vcard, auth, etag=None): + """PUT vCard; etag → If-Match (обновление). Возвращает (status, etag или body).""" + ab_url = carddav_base_url(base_url, user, addressbook) + url = f"{ab_url}{uid}.vcf" + headers = {**auth, "Content-Type": "text/vcard; charset=utf-8"} + if etag: + headers["If-Match"] = etag + status, content = _request("PUT", url, headers=headers, data=vcard) + if status in (200, 201, 204): + new_etag = None + # Radicale отдаёт ETag в заголовке; urllib его теряет в _request — + # пере-запросим HEAD? Нет. GET вернёт актуальный. + return status, new_etag + return status, content[:300] + + +def get_card_etag(base_url, user, addressbook, uid, auth): + """GET карточки → (etag, vcard).""" + ab_url = carddav_base_url(base_url, user, addressbook) + url = f"{ab_url}{uid}.vcf" + req = urllib.request.Request(url, method="GET", headers=auth) + try: + with urllib.request.urlopen(req, timeout=15) as resp: + etag = resp.headers.get("ETag") or "" + return etag, resp.read().decode("utf-8", errors="replace") + except urllib.error.HTTPError as e: + return None, None if e.code == 404 else (None, e.read()[:200]) + except urllib.error.URLError: + return None, None + + +def delete_card(base_url, user, addressbook, uid, auth): + ab_url = carddav_base_url(base_url, user, addressbook) + url = f"{ab_url}{uid}.vcf" + status, content = _request("DELETE", url, headers=auth) + return status, content + + +# ─── Sync logic ─────────────────────────────────────────────────────────────── + +def log_sync(entries): + """Дописать строки в caldav-sync.log.""" + with open(LOG_PATH, "a", encoding="utf-8") as f: + for e in entries: + ts = datetime.now().isoformat(timespec="seconds") + f.write(f"[{ts}] {e}\n") + + +def sync_contacts_carddav(base_url, user, password, addressbook, prune=False, + dry_run=False, contacts_dir=None, verbose=True): + """Двусторонний синк; возвращает (stats: dict, err: str|None).""" + contacts_dir = contacts_dir or CONTACTS_DIR + contacts_db = {} + try: + contacts_db = json.load(open(contacts_dir / "contacts.json", encoding="utf-8")) + except (FileNotFoundError, json.JSONDecodeError) as e: + return None, f"Не удалось прочитать contacts.json: {e}" + + contacts = contacts_db.get("contacts", []) + by_email = contacts_db.get("by_email", {}) + auth = _auth_header(user, password) + + # Строим индекс по id + by_id = {c.get("id"): c for c in contacts if c.get("id")} + + # 1. PROPFIND книги + cards, err = list_addressbook(base_url, user, addressbook, auth) + if err: + return None, f"list_addressbook: {err}" + assert cards is not None + if verbose: + print(f" CardDAV: {len(cards)} карточек в книге {addressbook}") + + stats = {"created": 0, "updated": 0, "unchanged": 0, "conflict": 0, + "pulled_created": 0, "pulled_updated": 0, "pulled_deleted": 0, + "deleted": 0} + log_entries = [] + changed = False + + # ── PULL: сервер → база ──────────────────────────────────────────────── + server_ids = set() + for href, card in cards.items(): + # uid = имя файла без .vcf + uid = href.rstrip("/").split("/")[-1] + if uid.endswith(".vcf"): + uid = uid[:-4] + server_ids.add(uid) + local = by_id.get(uid) + server_etag = card.get("etag", "") + vcard_text = card.get("vcard", "") + + if local is None: + # Новой карточки нет в базе → создать контакт + new_contact = parse_vcard_to_contact(vcard_text, uid) + if not new_contact.get("email"): + if verbose: + print(f" ← {uid}: новая карточка без email, пропуск pull") + continue + new_contact.setdefault("full_name", "") + new_contact["first_seen"] = date.today().isoformat() + new_contact["last_seen"] = date.today().isoformat() + new_contact["source_uids"] = new_contact.get("source_uids", []) + new_contact["source_folders"] = new_contact.get("source_folders", []) + new_contact["caldav"] = {"uid": uid, "etag": server_etag, + "from_device": True} + contacts.append(new_contact) + by_email[new_contact["email"]] = len(contacts) - 1 + by_id[uid] = new_contact + stats["pulled_created"] += 1 + changed = True + if verbose: + print(f" ← {uid}: новая карточка с телефона → контакт создан") + log_entries.append(f"PULL-CREATE {uid} {new_contact.get('email')}") + continue + + local_caldav = local.get("caldav") or {} + local_etag = local_caldav.get("etag", "") + + # Изменена ли карточка относительно нашей записи? + if server_etag and server_etag != local_etag: + # Сервер изменился (правка с телефона). По REQ-011 приоритет телефону — + # применяем серверную версию, сохраняя недостающие локальные поля. + merged = parse_vcard_to_contact(vcard_text, uid, base_contact=local) + # Поля, которых нет в vCard — значит удалены на телефоне → обнуляем. + SYNCFIELDS = ("full_name", "phone", "phone_secondary", "position", + "company", "address", "email") + present = _vcard_field_names(vcard_text) + for k in SYNCFIELDS: + if k in present: + if merged.get(k): + local[k] = merged[k] + else: + local[k] = None if k in ("phone", "phone_secondary", + "position", "company", "address") else local.get(k) + local["caldav"] = {**local_caldav, "etag": server_etag, + "from_device": True} + stats["pulled_updated"] += 1 + changed = True + if verbose: + print(f" ← {uid}: карточка изменена на телефоне → контакт обновлён") + log_entries.append(f"PULL-UPDATE {uid} (server etag {server_etag})") + else: + stats["unchanged"] += 1 + + # Карточки, которых больше нет на сервере (удалены на телефоне) → soft delete + for contact in contacts: + cid = contact.get("id") + if not cid: + continue + caldav = contact.get("caldav") or {} + if caldav.get("uid") and cid not in server_ids: + if not contact.get("deleted"): + contact["deleted"] = True + contact["last_seen"] = date.today().isoformat() + stats["pulled_deleted"] += 1 + changed = True + if verbose: + print(f" ← {cid}: карточка удалена на телефоне → deleted: true") + log_entries.append(f"PULL-DELETE {cid}") + + # ── PUSH: база → сервер ──────────────────────────────────────────────── + for contact in contacts: + cid = contact.get("id") + email = contact.get("email") + if not cid or not email: + continue + if contact.get("deleted"): + # unтелефон deleted: удалить карточку при prune или пропустить + if prune and cid in server_ids: + status, body = delete_card(base_url, user, addressbook, cid, auth) + if status in (200, 204): + stats["deleted"] += 1 + changed = True + if verbose: + print(f" → {cid}: карточка удалена (prune)") + log_entries.append(f"DELETE {cid}") + continue + caldav = contact.get("caldav") or {} + if caldav.get("from_device") and not caldav.get("sync_after_pull"): + # Контакт с телефона: уже синхронизирован в pull, ничего не пишем + continue + vcard = contact_to_vcard(contact) + if dry_run: + action = "создана" if cid not in server_ids else "обновлена" + if verbose: + print(f" → [dry] {cid}: карточка {action}") + continue + if cid not in server_ids: + # Нет карточки → создать + status, body = put_card(base_url, user, addressbook, cid, vcard, auth) + if status in (200, 201, 204): + # обновим etag + etag, _ = get_card_etag(base_url, user, addressbook, cid, auth) + contact["caldav"] = {"uid": cid, "etag": etag or "", + "from_device": False} + stats["created"] += 1 + changed = True + if verbose: + print(f" → {cid}: карточка создана") + log_entries.append(f"CREATE {cid} {email}") + else: + if verbose: + print(f" ✗ {cid}: PUT {status}: {body[:120]}") + else: + # Карточка есть → проверить, изменилась ли локально + c_href = href_for(cid, cards) + server_etag = cards[c_href]["etag"] if c_href else "" + local_etag = (contact.get("caldav") or {}).get("etag", "") + # Сравниваем содержимое vCard (проще, чем etag на каждый чих) + server_vcard = cards[c_href].get("vcard", "") if c_href else "" + local_vcard = contact_to_vcard(contact) + if _normalize_vcard(local_vcard) == _normalize_vcard(server_vcard): + stats["unchanged"] += 1 + continue + # Обновляем с If-Match + status, body = put_card(base_url, user, addressbook, cid, vcard, auth, etag=server_etag or None) + if status in (200, 201, 204): + etag, _ = get_card_etag(base_url, user, addressbook, cid, auth) + contact["caldav"] = {"uid": cid, "etag": etag or "", + "from_device": False} + stats["updated"] += 1 + changed = True + if verbose: + print(f" → {cid}: карточка обновлена") + log_entries.append(f"UPDATE {cid} {email}") + elif status == 412: + # Конфликт: сервер изменил карточку → принять серверную + # версию, локальную в лог. + if cid in server_ids: + c_href2 = href_for(cid, cards) + server_vcard = cards[c_href2].get("vcard", "") if c_href2 else "" + if server_vcard: + local_prev = {k: contact.get(k) for k in + ("full_name", "phone", "phone_secondary", + "position", "company", "address")} + merged = parse_vcard_to_contact(server_vcard, cid, base_contact=contact) + for k in ("full_name", "phone", "phone_secondary", + "position", "company", "address"): + if k in merged and merged[k]: + contact[k] = merged[k] + etag, _ = get_card_etag(base_url, user, addressbook, cid, auth) + contact["caldav"] = {"uid": cid, "etag": etag or "", + "from_device": True} + stats["conflict"] += 1 + changed = True + if verbose: + print(f" ⚠ {cid}: 412 конфликт → сервер победил, локальное в log") + log_entries.append(f"CONFLICT-PUSH {cid} conflict_local={json.dumps(local_prev, ensure_ascii=False)}") + else: + if verbose: + print(f" ✗ {cid}: PUT 412, но карточка исчезла — повтор на след. раз") + else: + if verbose: + print(f" ✗ {cid}: PUT {status}: {body[:120]}") + + # ── Сохраняем базу ───────────────────────────────────────────────────── + if changed and not dry_run: + contacts.sort(key=lambda c: c.get("email", "")) + # by_email хранит индексы — перестроить после сортировки + by_email = {c.get("email", ""): i for i, c in enumerate(contacts)} + contacts_db["contacts"] = contacts + contacts_db["by_email"] = by_email + tmp = contacts_dir / "contacts.json.tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump(contacts_db, f, ensure_ascii=False, indent=2) + tmp.replace(contacts_dir / "contacts.json") + # index.json + with open(contacts_dir / "index.json.tmp", "w", encoding="utf-8") as f: + json.dump(by_email, f, ensure_ascii=False, indent=2) + tmp2 = contacts_dir / "index.json.tmp" + tmp2.replace(contacts_dir / "index.json") + if log_entries: + log_sync(log_entries) + + return stats, None + + +def href_for(cid, cards): + """Найти href карточки по uid (имя файла).""" + for href in cards: + if href.rstrip("/").split("/")[-1].startswith(cid): + return href + return None + + +def _vcard_field_names(vcard_text): + """Вернуть имена полей vCard (FN, N, EMAIL, TEL, ...) — для проверки наличия.""" + names = set() + for line in vcard_text.replace("\r\n", "\n").split("\n"): + line = line.strip() + if not line or ":" not in line: + continue + key, _, _ = line.partition(":") + name = key.split(";")[0].upper() + if name not in ("BEGIN", "END", "VERSION", "UID"): + names.add(name) + return names + + +def _normalize_vcard(vcard): + """Нормализовать vCard для сравнения. + + Radicale переупорядочивает поля и сворачивает длинные строки + (RFC-5545 folding, continuation line начинается с пробела). + Сравниваем как упорядоченный мультисет unfolded-строк. + """ + # Развернуть folding: строка, начинающаяся с пробела/таба — продолжение + raw = re.sub(r"\r\n", "\n", vcard) + unfolded = [] + for line in raw.split("\n"): + if line.startswith((" ", "\t")) and unfolded: + unfolded[-1] = unfolded[-1] + line.strip() + else: + unfolded.append(line.strip()) + lines = [l for l in unfolded if l and not l.startswith(("BEGIN:", "END:", "VERSION:", "UID:"))] + # Radicale нормализует ADR (добавляет/переставляет пустые ;) — нормализуем и мы + norm = [] + for l in lines: + if l.startswith("ADR;"): + k, v = l.split(":", 1) + # оставить только значение адреса (3-й компонент) + parts = v.rstrip(";").split(";") + norm.append(f"{k}:;;{parts[2] if len(parts) > 2 else ''};;;") + else: + norm.append(l) + return sorted(norm) + + +# ─── CLI ────────────────────────────────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser(description="CardDAV-синк контактов с Radicale") + parser.add_argument("--base-url", default=os.environ.get("CALDAV_URL", DEFAULT_BASE_URL)) + parser.add_argument("--user", default=os.environ.get("CALDAV_USER", DEFAULT_USER)) + parser.add_argument("--pass", dest="password", default=None, help="Пароль (или env CALDAV_PASS/RADICALE_PASS)") + parser.add_argument("--addressbook", default=DEFAULT_ADDRESSBOOK, help="Имя адресной книги") + parser.add_argument("--prune", action="store_true", help="Удалять карточки без контакта в базе") + parser.add_argument("--dry-run", action="store_true", help="Не сохранять изменения") + parser.add_argument("--verbose", action="store_true", default=True) + args = parser.parse_args() + + password = args.password + if not password: + password = os.environ.get("CALDAV_PASS") or os.environ.get("RADICALE_PASS") + if not password: + # fallback: radikal .env + env_path = Path("/opt/hermes/email-assistant/radicale/.env") + try: + for line in env_path.read_text().splitlines(): + if line.startswith("RADICALE_PASS="): + password = line.split("=", 1)[1].strip().strip('"').strip("'") + break + except FileNotFoundError: + pass + if not password: + print("Не задан пароль: --pass / CALDAV_PASS / RADICALE_PASS", file=sys.stderr) + return 1 + + if args.dry_run: + print("🔍 DRY RUN — база не сохраняется") + + stats, err = sync_contacts_carddav( + args.base_url, args.user, password, args.addressbook, + prune=args.prune, dry_run=args.dry_run, + ) + if err: + print(f"❌ {err}", file=sys.stderr) + return 1 + + print("\n📇 CardDAV sync завершён:") + for k, v in stats.items(): + if v: + print(f" {k}: {v}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/scripts/contacts_extractor.py b/scripts/contacts_extractor.py index 7e37f7a..f6e9ebd 100644 --- a/scripts/contacts_extractor.py +++ b/scripts/contacts_extractor.py @@ -308,6 +308,8 @@ def get_date_from_path(path): def save_progress(contacts_db, contacts_dir, processed_uids, processed_emails): """Инкрементальное сохранение контактов и last_scan.""" contacts_db["contacts"].sort(key=lambda c: c.get("email", "")) + # Перестроить by_email — он хранит ИНДЕКСЫ позиций; после сортировки они сдвигаются + contacts_db["by_email"] = {c.get("email", ""): i for i, c in enumerate(contacts_db["contacts"])} save_json(contacts_dir / "contacts.json", contacts_db) save_json(contacts_dir / "index.json", contacts_db.get("by_email", {})) generate_vcard(contacts_dir, contacts_db["contacts"]) @@ -485,6 +487,7 @@ def scan(limit=0): if save_counter >= 5: save_counter = 0 contacts_db["contacts"].sort(key=lambda c: c.get("email", "")) + contacts_db["by_email"] = {c.get("email", ""): i for i, c in enumerate(contacts_db["contacts"])} save_json(contacts_dir / "contacts.json", contacts_db) save_json(contacts_dir / "index.json", contacts_db.get("by_email", {})) generate_vcard(contacts_dir, contacts_db["contacts"])