# KIN-013 — Settings + Obsidian Sync: Техническая спецификация ## Контекст Фича добавляет: 1. Страницу Settings в GUI для управления конфигурацией проектов 2. Двусторонний Obsidian sync: decisions → .md-файлы, чекбоксы Obsidian → статус задач Sync вызывается явно по кнопке (не демон), через API-эндпоинт. --- ## 1. Схема данных ### Изменение таблицы `projects` Добавить колонку: ```sql ALTER TABLE projects ADD COLUMN obsidian_vault_path TEXT; ``` **Миграция**: в `core/db.py` → `_migrate()`, по паттерну существующих миграций: ```python if "obsidian_vault_path" not in proj_cols: conn.execute("ALTER TABLE projects ADD COLUMN obsidian_vault_path TEXT") conn.commit() ``` **Семантика**: путь к корневой папке Obsidian vault для данного проекта. Пример: `/Users/grosfrumos/Library/Mobile Documents/iCloud~md~obsidian/Documents/MyVault` --- ## 2. Формат .md-файлов для decisions ### Расположение ``` {vault_path}/{project_id}/decisions/{id}-{slug}.md ``` Пример: `.../kin/decisions/42-proxy-jump-ssh-gotcha.md` ### Формат файла (YAML frontmatter + Markdown body) ```markdown --- kin_decision_id: 42 project: kin type: gotcha category: testing tags: [testing, mock, subprocess] created_at: 2026-03-10 --- # Proxy через SSH не работает без ssh-agent Описание: полный текст description из БД. ``` **Обоснование frontmatter**: - Позволяет идентифицировать файл при импорте (поле `kin_decision_id`) - Позволяет Obsidian показывать метаданные в Properties panel - Поддерживает round-trip sync без парсинга имени файла ### Slug из заголовка ```python import re def _slug(title: str) -> str: s = title.lower() s = re.sub(r"[^a-zа-я0-9\s-]", "", s) s = re.sub(r"\s+", "-", s.strip()) return s[:50] ``` --- ## 3. Механизм двустороннего sync ### 3.1 Decisions → Obsidian (export) - Создать/перезаписать `.md`-файл для каждого decision проекта - Директория создаётся автоматически (`mkdir -p`) - Если файл для `kin_decision_id` уже существует — перезаписать (идемпотентно) - Решения, удалённые из БД → файлы НЕ удаляются (безопасно) ### 3.2 Obsidian чекбоксы → Tasks (import) **Источник**: файлы `*.md` в `{vault_path}/{project_id}/tasks/` Дополнительно: файлы `{vault_path}/{project_id}/*.md` **Формат строки задачи**: ``` - [x] KIN-013 Title of the task - [ ] KIN-014 Another task ``` **Алгоритм**: 1. Найти строки по паттерну: `^[-*]\s+\[([xX ])\]\s+([A-Z]+-\d+)\s+(.+)$` 2. Извлечь: `done` (bool), `task_id` (str), `title` (str) 3. Найти задачу в БД по `task_id` 4. Если `done=True` и `task.status != 'done'` → `update_task(conn, task_id, status='done')` 5. Если `done=False` → не трогать (не откатываем) 6. Если задача не найдена → пропустить (не создавать) **Обоснование**: строгий маппинг только по task_id исключает случайное создание мусора. ### 3.3 Функция `sync_obsidian` ```python def sync_obsidian(conn, project_id: str) -> dict: """ Returns: { "exported_decisions": int, "tasks_updated": int, "errors": list[str], "vault_path": str } """ ``` --- ## 4. Модуль `core/obsidian_sync.py` ### Публичный API модуля ```python def export_decisions_to_md( project_id: str, decisions: list[dict], vault_path: Path, ) -> list[Path]: """Экспортирует decisions в .md файлы Obsidian. Возвращает список созданных файлов.""" def parse_task_checkboxes( vault_path: Path, project_id: str, ) -> list[dict]: """Парсит *.md файлы в vault/{project_id}/tasks/ на чекбоксы с task ID. Returns: [{"task_id": "KIN-013", "done": True, "title": "..."}] """ def sync_obsidian(conn, project_id: str) -> dict: """Оркестратор: export + import. Возвращает статистику.""" ``` ### Вспомогательные (приватные) ```python def _slug(title: str) -> str # slug для имени файла def _decision_to_md(decision: dict) -> str # форматирует .md с frontmatter def _parse_frontmatter(text: str) -> dict # для будущего round-trip ``` ### Зависимости - Только стандартная библиотека Python: `pathlib`, `re`, `yaml` (через `import yaml`) или ручной YAML-парсер - Важно: PyYAML может не быть установлен → использовать простой ручной вывод YAML-фронтматтера, парсинг через `re` - Импортирует из `core.models`: `get_project`, `get_decisions`, `get_task`, `update_task` - Импортирует из `core.db`: `get_connection` — НЕ нужен, conn передаётся снаружи --- ## 5. API-эндпоинты ### 5.1 PATCH /api/projects/{project_id} — расширить Добавить в `ProjectPatch`: ```python class ProjectPatch(BaseModel): execution_mode: str | None = None autocommit_enabled: bool | None = None obsidian_vault_path: str | None = None # новое поле ``` Обновить обработчик: если `obsidian_vault_path` provided → `update_project(conn, id, obsidian_vault_path=...)` Убрать проверку "Nothing to update" → включить `obsidian_vault_path` в условие. ### 5.2 POST /api/projects/{project_id}/sync/obsidian — новый ```python @app.post("/api/projects/{project_id}/sync/obsidian") def sync_obsidian_endpoint(project_id: str): conn = get_conn() p = models.get_project(conn, project_id) if not p: conn.close() raise HTTPException(404, ...) if not p.get("obsidian_vault_path"): conn.close() raise HTTPException(400, "obsidian_vault_path not set for this project") from core.obsidian_sync import sync_obsidian result = sync_obsidian(conn, project_id) conn.close() return result ``` --- ## 6. Frontend: Settings страница ### Маршрут - Path: `/settings` - Component: `web/frontend/src/views/SettingsView.vue` - Регистрация в `main.ts` ### Навигация в `App.vue` Добавить ссылку `Settings` в хедер рядом с `Kin`. ### SettingsView.vue — структура ``` Settings ├── Список проектов (v-for) │ ├── Название + id │ ├── Input: obsidian_vault_path (text input) │ ├── [Save] кнопка → PATCH /api/projects/{id} │ └── [Sync Obsidian] кнопка → POST /api/projects/{id}/sync/obsidian │ └── Показывает результат: "Exported: 5 decisions, Updated: 2 tasks" ``` ### api.ts — добавить методы ```typescript // Обновить настройки проекта patchProject(id: string, data: { obsidian_vault_path?: string, execution_mode?: string, autocommit_enabled?: boolean }) // Запустить Obsidian sync syncObsidian(projectId: string): Promise<{ exported_decisions: number, tasks_updated: number, errors: string[] }> ``` --- ## 7. Тесты ### `tests/test_obsidian_sync.py` Обязательные кейсы: 1. `test_export_decisions_creates_md_files` — export создаёт файлы с правильным frontmatter 2. `test_export_idempotent` — повторный export перезаписывает, не дублирует 3. `test_parse_task_checkboxes_done` — `- [x] KIN-001 Title` → `{"task_id": "KIN-001", "done": True}` 4. `test_parse_task_checkboxes_pending` — `- [ ] KIN-002 Title` → `done: False` 5. `test_parse_task_checkboxes_no_id` — строки без task ID пропускаются 6. `test_sync_updates_task_status` — sync обновляет статус задачи если `done=True` 7. `test_sync_no_vault_path` — sync без vault_path выбрасывает ValueError --- ## 8. Риски и ограничения 1. **PyYAML не в зависимостях** → использовать ручную генерацию YAML-строки для frontmatter, парсинг `re` 2. **Vault path может быть недоступен** → sync возвращает error в `errors[]`, не падает 3. **Конфликт при rename decision** → файл со старым slug остаётся, создаётся новый. Приемлемо для v1 4. **Большой vault** → scan только в `{vault_path}/{project_id}/tasks/`, не весь vault 5. **Одновременный sync** → нет блокировки (SQLite WAL + file system). В v1 достаточно --- ## 9. Порядок реализации (для dev-агента) 1. `core/db.py` — добавить `obsidian_vault_path` в `_migrate()` 2. `core/obsidian_sync.py` — реализовать `export_decisions_to_md`, `parse_task_checkboxes`, `sync_obsidian` 3. `tests/test_obsidian_sync.py` — написать тесты (7 кейсов выше) 4. `web/api.py` — расширить `ProjectPatch`, добавить `/sync/obsidian` эндпоинт 5. `web/frontend/src/api.ts` — добавить `patchProject` обновление и `syncObsidian` 6. `web/frontend/src/views/SettingsView.vue` — создать компонент 7. `web/frontend/src/main.ts` — зарегистрировать `/settings` маршрут 8. `web/frontend/src/App.vue` — добавить ссылку Settings в nav