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