10 KiB
KIN-013 — Settings + Obsidian Sync: Техническая спецификация
Контекст
Фича добавляет:
- Страницу Settings в GUI для управления конфигурацией проектов
- Двусторонний Obsidian sync: decisions → .md-файлы, чекбоксы Obsidian → статус задач
Sync вызывается явно по кнопке (не демон), через API-эндпоинт.
1. Схема данных
Изменение таблицы projects
Добавить колонку:
ALTER TABLE projects ADD COLUMN obsidian_vault_path TEXT;
Миграция: в core/db.py → _migrate(), по паттерну существующих миграций:
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)
---
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 из заголовка
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
Алгоритм:
- Найти строки по паттерну:
^[-*]\s+\[([xX ])\]\s+([A-Z]+-\d+)\s+(.+)$ - Извлечь:
done(bool),task_id(str),title(str) - Найти задачу в БД по
task_id - Если
done=Trueиtask.status != 'done'→update_task(conn, task_id, status='done') - Если
done=False→ не трогать (не откатываем) - Если задача не найдена → пропустить (не создавать)
Обоснование: строгий маппинг только по task_id исключает случайное создание мусора.
3.3 Функция sync_obsidian
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 модуля
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. Возвращает статистику."""
Вспомогательные (приватные)
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:
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 — новый
@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 — добавить методы
// Обновить настройки проекта
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
Обязательные кейсы:
test_export_decisions_creates_md_files— export создаёт файлы с правильным frontmattertest_export_idempotent— повторный export перезаписывает, не дублируетtest_parse_task_checkboxes_done—- [x] KIN-001 Title→{"task_id": "KIN-001", "done": True}test_parse_task_checkboxes_pending—- [ ] KIN-002 Title→done: Falsetest_parse_task_checkboxes_no_id— строки без task ID пропускаютсяtest_sync_updates_task_status— sync обновляет статус задачи еслиdone=Truetest_sync_no_vault_path— sync без vault_path выбрасывает ValueError
8. Риски и ограничения
- PyYAML не в зависимостях → использовать ручную генерацию YAML-строки для frontmatter, парсинг
re - Vault path может быть недоступен → sync возвращает error в
errors[], не падает - Конфликт при rename decision → файл со старым slug остаётся, создаётся новый. Приемлемо для v1
- Большой vault → scan только в
{vault_path}/{project_id}/tasks/, не весь vault - Одновременный sync → нет блокировки (SQLite WAL + file system). В v1 достаточно
9. Порядок реализации (для dev-агента)
core/db.py— добавитьobsidian_vault_pathв_migrate()core/obsidian_sync.py— реализоватьexport_decisions_to_md,parse_task_checkboxes,sync_obsidiantests/test_obsidian_sync.py— написать тесты (7 кейсов выше)web/api.py— расширитьProjectPatch, добавить/sync/obsidianэндпоинтweb/frontend/src/api.ts— добавитьpatchProjectобновление иsyncObsidianweb/frontend/src/views/SettingsView.vue— создать компонентweb/frontend/src/main.ts— зарегистрировать/settingsмаршрутweb/frontend/src/App.vue— добавить ссылку Settings в nav