266 lines
10 KiB
Markdown
266 lines
10 KiB
Markdown
# 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
|