steps 4-6 + doc overhaul: files, notes, GUI, plugins docs

DOCUMENTATION (shift from personal to universal product):
- README.md: rewritten with 'one product, different doors' framing,
  universal entities table, audience segments
- 01_Product_Spec.md: removed personal references (sshkeeper, Godot,
  DokuWiki, servers), added audience segments (freelancer, repairmaster,
  developer, maker, consultant), universal scenarios
- 02_Architecture.md: added 'Plugins (Extensibility)' section with
  calendar/kanban/importer/template examples
- 03_Data_Model_Storage.md: added section 6 on plugin extensibility
  (node_meta, type registry, SQL migrations per plugin)
- 09_Extensibility.md (NEW): full plugin architecture — Lua runtime,
  plugin.json, hooks, sandbox, templates, registry
- PLAN.md: added step 16 (plugins), updated status table
- 00_README.md: rewritten product index with plugin principle

CODE — STEP 4 (Files):
- migration 002: files table (id, node_id, filename, path,
  storage_mode, size, sha256, mime, ...)
- FileService: AddExternal, CopyIntoVault, Get, ListByNode,
  MarkMissing, DeleteToTrash, Open (xdg-open)
- file_test.go: 5 tests (external, copy-vault, list-node,
  delete-trash, MIME guess)

CODE — STEP 5 (Notes):
- migration 003: notes table (node_id PK, file_id, format,
  original_format, encrypted)
- NoteService: Create (node+file+link), Read, Save (with backup to
  .verstak/history/), Delete, Load
- note_test.go: 3 tests (create-read, save-backup, delete)

CODE — STEP 6 (GUI):
- cmd/verstak-gui/main.go: launches GUI server, opens browser
- internal/gui/server.go: HTTP API for nodes/notes/files/search
- internal/gui/index.html.go: full inline SPA frontend (dark theme,
  sidebar tree, cards grid, note editor, search, create modals)
- Navigation: sidebar tree → click node → detail view with
  children + files cards → tab switch (overview/notes/files)
  → create node/note via modal → edit note in fullscreen
  textarea → save (with history backup)

Acceptance: go build ./... pass, go build -tags gui ./cmd/verstak-gui pass,
  go test ./... pass (20+ tests). GUI serves on random port, opens browser.
  API returns JSON for all resource types.
This commit is contained in:
mirivlad 2026-05-30 20:35:04 +08:00
parent 69eb909d48
commit 39271fc28f
19 changed files with 1948 additions and 236 deletions

View File

@ -1,27 +1,46 @@
# Верстак
**Верстак** — local-first рабочий vault для дел, клиентов, проектов,
документов, заметок, файлов, действий запуска, журнала работ
и синхронизации между машинами.
**Верстак** — локальная программа, где по каждому клиенту или проекту
лежат все его файлы, заметки, документы, ссылки, действия и история работ.
Это не просто заметочник и не CRM. Главная сущность — **дело**.
Это не замечатель, не CRM, не таск-трекер. **Нишевая аудитория** — люди,
у которых работа организована через дела, а не через задачи:
Дело может быть: клиентом, сайтом клиента, личным проектом,
Godot-проектом, набором документов, рецептом/инструкцией,
архивом, разовой помощью человеку, рабочей областью.
Стек: Go + SQLite + Wails + Bubble Tea.
Документация: [docs/](docs/)
План разработки: [docs/PLAN.md](docs/PLAN.md)
## Сборка
```bash
go build ./cmd/verstak
```
дело → файлы → заметки → документы → действия → история → вернуться через месяц
```
## Разработка
## Для кого
Разработка ведётся пошагово. Каждый шаг — отдельный commit.
Подробнее в [docs/PLAN.md](docs/PLAN.md).
Один продукт — разные входные двери:
| Кто | Как видит Верстак |
|-----|-------------------|
| Фрилансер / дизайнер | клиентские проекты, файлы, правки, история работ |
| Мастер по ПК | клиенты, устройства, серийники, фото, журнал |
| Разработчик | локальный workspace: заметки, репы, команды, файлы |
| Писатель / мейкер | мастерская проектов: материалы, заметки, версии, история |
## Универсальные сущности
Базовая модель предельно проста — плагины добавляют функционал:
- **Дело** — контекст для всего остального
- **Заметка** — Markdown внутри vault
- **Файл / Документ** — любой файл, привязанный к делу
- **Действие** — кнопка запуска: URL, файл, папка, команда
- **Журнал** — записи о затраченном времени
Плагины (шаблоны дел, календарь, канбан, импортёры) расширяют
эти сущности без перекомпиляции программы.
## Стек
Go + SQLite + Lua (плагины) + Wails + Bubble Tea.
## Документация
- Описание продукта: [docs/01_Product_Spec.md](docs/01_Product_Spec.md)
- Архитектура: [docs/02_Architecture.md](docs/02_Architecture.md)
- Плагины: [docs/09_Extensibility.md](docs/09_Extensibility.md)
- План разработки: [docs/PLAN.md](docs/PLAN.md)

71
cmd/verstak-gui/main.go Normal file
View File

@ -0,0 +1,71 @@
//go:build gui
// +build gui
package main
import (
"fmt"
"log"
"os"
"os/exec"
"os/signal"
"path/filepath"
"runtime"
"syscall"
gui "verstak/internal/gui"
"verstak/internal/core/storage"
)
func main() {
vaultPath := "."
if len(os.Args) > 1 {
vaultPath = os.Args[1]
}
abs, err := filepath.Abs(vaultPath)
if err != nil {
log.Fatal(err)
}
dbPath := filepath.Join(abs, ".verstak", "index.db")
db, err := storage.Open(dbPath)
if err != nil {
log.Fatalf("Open vault: %v", err)
}
defer db.Close()
srv := gui.NewServer(db, abs)
addr, err := srv.Start()
if err != nil {
log.Fatalf("Start GUI: %v", err)
}
fmt.Println("Верстак GUI:", addr)
openBrowser(addr)
// Wait for interrupt.
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
<-sig
srv.Stop()
deferFunc()
}
func openBrowser(url string) {
var cmd *exec.Cmd
switch runtime.GOOS {
case "linux":
cmd = exec.Command("xdg-open", url)
case "darwin":
cmd = exec.Command("open", url)
case "windows":
cmd = exec.Command("cmd", "/c", "start", "", url)
}
if cmd != nil {
go cmd.Start()
}
}
func deferFunc() {}

View File

@ -1,69 +1,66 @@
# Верстак — индекс документации
**Верстак** — local-first рабочий vault для дел, клиентов, проектов, документов, заметок, файлов, скриптов, действий запуска, журнала работ и синхронизации между машинами.
**Верстак** — local-first рабочий vault, где всё организовано вокруг "дел".
Это не просто заметочник и не CRM. Главная сущность — **дело**.
Верстак нужен людям, у которых работа организована через дела,
а не через задачи.
Дело может быть:
- клиентом;
- сайтом клиента;
- личным проектом;
- Godot-проектом;
- набором документов;
- рецептом/инструкцией;
- архивом;
- разовой помощью человеку;
- рабочей областью вроде `Рецепты / MySQL / Backup сайта`.
- разовой работой.
Внутри дела живут:
- вложенные папки;
- Markdown-заметки;
- документы `docx/pdf/xlsx/odt`;
- скриншоты;
- архивы;
- исходники;
- скрипты;
- SQL-фрагменты;
- ссылки;
- запускаемые действия;
- документы (docx/pdf/xlsx/odt/png/zip);
- файлы любых типов;
- запускаемые действия (URL, файл, папка, команда);
- журнал работ;
- примерное время;
- история активности;
- связанные дела.
- история активности.
## Файлы пакета
1. [[01_Product_Spec]] — полное описание продукта и сценариев.
2. [[02_Architecture]] — архитектура core/GUI/TUI/CLI/server.
3. [[03_Data_Model_Storage]] — модель данных, SQLite, vault, files, notes, actions.
4. [[04_Sync_Backup_Activity]] — синхронизация, восстановление, backup, activity/time tracking.
5. [[05_UI_UX]] — экраны GUI/TUI, дерево, дело, поиск, документы, действия.
1. [[01_Product_Spec]] — описание продукта, аудитория, сценарии.
2. [[02_Architecture]] — архитектура core/GUI/TUI/CLI/server, плагины.
3. [[03_Data_Model_Storage]] — модель данных, SQLite, vault, files, notes.
4. [[04_Sync_Backup_Activity]] — синхронизация, backup, activity.
5. [[05_UI_UX]] — экраны GUI/TUI.
6. [[06_Roadmap]] — план разработки по этапам.
7. [[07_AI_Coder_Prompts]] — промпты для ИИ-кодера.
8. [[08_MVP_Checklist]] — чеклист первого MVP.
9. [[09_Extensibility]] — архитектура плагинов (Lua + шаблоны дел).
## Главные принципы
1. **Local-first.**
Рабочая копия всегда локальная. Сервер нужен для sync/backup/restore, но программа не должна зависеть от сервера каждый день.
1. **Local-first.**
Рабочая копия всегда локальная. Сервер — для sync/backup.
2. **Данные принадлежат пользователю.**
Заметки и файлы физически лежат обычными файлами в vault. SQLite хранит индекс, связи, метаданные, FTS и sync state.
2. **Универсальная база + плагины.**
Базовая модель (дело + заметка + файл + действие + журнал)
работает для любого сегмента. Плагины добавляют календарь,
канбан, импортёры — без перекомпиляции.
3. **Дерево дел важнее тегов.**
Теги полезны, но основная навигация — вложенное дерево: `Клиенты / Ромашка / Сайт / Документы`.
3. **Данные принадлежат пользователю.**
Заметки и файлы лежат обычными файлами. SQLite — индекс.
4. **Не таймтрекер, а восстановитель следов.**
Верстак не требует постоянно нажимать Start/Stop. Он собирает следы работы и предлагает записать их в журнал.
4. **Дерево дел важнее тегов.**
5. **GUI основной, TUI быстрый, CLI служебный.**
GUI — основная рабочая среда. TUI — быстрый доступ из терминала. CLI — sync, import, scripts, rescue mode.
5. **Не таймтрекер, а восстановитель следов.**
6. **Sync не должен уничтожать данные.**
Нужны trash, conflict copies, versions, snapshots и retention.
6. **GUI основной, TUI быстрый, CLI служебный.**
7. **Sync не уничтожает данные.**
## Короткая формула
> Верстак — это локальный рабочий кабинет для людей, у которых жизнь состоит из проектов, клиентов, документов, заметок, скриптов, файлов, репозиториев и вечного “где я это сохранил?”.
> Верстак — локальная программа, где по каждому клиенту или проекту
> лежат все его файлы, заметки, документы, ссылки, действия и
> история работ.

View File

@ -2,242 +2,172 @@
## 1. Проблема
У пользователя есть много разнородной рабочей информации:
У фрилансеров, мастеров, разработчиков и мейкеров есть много
разнородной рабочей информации:
- папка `work` и подпапки;
- архивы нужных файлов;
- служебки;
- договоры;
- письма;
- скриншоты;
- файлы с серийными номерами;
- инструкции;
- статьи по установке;
- скрипты;
- SQL-фрагменты;
- заметки в DokuWiki;
- доступы к серверам и сервисам клиентов;
- записи о нестандартных действиях;
- репозитории личных проектов;
- Godot-проекты;
- локальные утилиты вроде sshkeeper.
- клиентские проекты и переписка;
- договоры, счета, акты;
- заметки и инструкции;
- скриншоты и фото;
- скрипты и конфиги;
- доступы и серийники;
- файлы с правками и версиями.
Проблема не только в хранении. Проблема в **контексте**:
Проблема не в хранении. Проблема в **контексте**:
- что к чему относится;
- где лежит актуальная версия;
- где заметка по клиенту;
- где договор;
- где скрипт;
- где актуальная версия;
- что было сделано в прошлый раз;
- сколько примерно времени ушло;
- что можно сказать человеку, когда он спрашивает “сколько должен?”.
- сколько времени ушло;
- где договор, где заметка, где скрипт.
Обычные инструменты закрывают только кусок:
- Obsidian — заметки, но не рабочий кабинет с документами, действиями и журналом работ;
- DokuWiki — заметки, но не локальная рабочая оболочка над файлами и программами;
- CRM — клиенты и продажи, но не личная техническая память;
- файловый менеджер — файлы, но без смысла;
- таймтрекер — время, но требует дисциплины;
- лаунчер — запуск, но не память;
- Nextcloud — файлы, но не дела.
Обычные инструменты закрывают только кусок: Obsidian — заметки,
CRM — продажи, таймтрекер — время, файловый менеджер — файлы без смысла.
## 2. Что такое Верстак
**Верстак** — local-first рабочий vault, где всё организовано вокруг “дел”.
**Верстак** — local-first рабочий vault, где всё организовано вокруг "дел".
Дело — это контекст, в который складываются заметки, документы, файлы, действия и история работы.
Дело — это контекст, в который складываются заметки, документы,
файлы, действия и история работы.
Примеры дерева:
**Главная формула:**
> Верстак — локальная программа, где по каждому клиенту или проекту
> лежат все его файлы, заметки, документы, ссылки, действия и история работ.
## 3. Аудитория
Верстак нужен людям, у которых работа идёт через **дела**, а не через задачи.
Есть люди, которым нужен таск-трекер (task → done). А есть люди,
которым нужно место, где накапливается контекст по каждому клиенту
или проекту — и всё это доступно через месяц, через год.
### Сегменты
| Сегмент | Зачем Верстак |
|---------|---------------|
| Фрилансер / дизайнер | Клиенты, файлы, правки, история работ, отчёты |
| Мастер по ремонту/ПК | Клиенты, устройства, серийники, фото, журнал |
| Разработчик | Workspace: заметки, репозитории, команды, логи |
| Мейкер / писатель | Проекты: материалы, заметки, версии, история |
| Консультант | Клиенты, документы, журнал времени, отчёты |
## 4. Основные сущности (универсальные)
### Дело
Главный рабочий контекст. Поля: название, тип, родитель,
описание, статус (active / sleeping / archived), теги.
### Заметка
Markdown-файл внутри vault. Резервная копия при перезаписи.
### Файл / Документ
Любой файл, привязанный к делу. Открывается системным приложением.
### Действие
Кнопка запуска: URL, файл, папка, команда. Опасные — с подтверждением.
### Журнал работ
Записи о затраченном времени: дата, длительность, описание.
### Активность
Следы работы: открыт файл, изменена заметка, запущено действие.
Используется для восстановления времени.
## 5. Примеры дерева (универсальные)
```text
Клиенты
ООО Ромашка
Сайт
Обзор.md
Документы
Скрипты
Скриншоты
Журнал работ
Почта
Договоры
Личные проекты
sshkeeper
Roadmap.md
Releases
dist
Действия
Tyaplyapiya
Godot project
Design notes
Проекты
Мой проект
Notes
Assets
dist
Рецепты
MySQL
Очистка таблиц
Backup dump
Сайты
Backup сайта одной строкой
Очистка кеша WordPress
Backup одной строкой
Очистка кеша
Документы
Служебки
Счета
Договоры
Серийники
```
## 3. Основные сущности
## 6. Сценарии (сегмент-agnostic)
### Дело
Главный рабочий контекст.
Поля:
- название;
- тип: клиент / проект / рецепт / документальная область / архив / личное;
- родитель;
- описание;
- статус: active / sleeping / archived;
- теги;
- связанные ссылки;
- связанные actions;
- журнал работ.
### Заметка
Обычный Markdown-файл внутри vault.
Примеры:
- `overview.md`;
- `nginx.md`;
- `mysql-cleanup.md`;
- `roadmap.md`;
- `access.secret.md`.
### Документ
Файл внутри дела:
- `docx`;
- `xlsx`;
- `pdf`;
- `odt`;
- `png/jpg`;
- `zip`;
- любые другие файлы.
В MVP документы открываются системным приложением. Встроенный preview можно добавить позже.
### Действие
Кнопка, которую можно запустить из дела:
- открыть URL;
- открыть папку;
- открыть файл;
- запустить Godot;
- открыть IDE;
- запустить sshkeeper;
- выполнить скрипт;
- открыть терминал;
- собрать проект.
### Журнал работ
Записи вида:
```text
2026-05-30
Дело: ООО Ромашка / Сайт
Время: примерно 3ч
Описание: обновил витрину сайта, товары, баннеры, проверил отображение.
```
### Активность
Сырые следы:
- открыто дело;
- открыта заметка;
- изменён файл;
- запущено действие;
- открыта папка;
- later: активное окно;
- later: browser URL;
- later: sshkeeper session.
## 4. Основные сценарии
### Клиентская работа
### Работа с клиентом
1. Открыть дело клиента.
2. Посмотреть заметки и документы.
3. Открыть админку сайта.
4. Запустить sshkeeper или скрипт.
5. Добавить скриншоты.
6. Записать работу.
7. Сформировать текст отчёта.
3. Открыть связанный URL или папку.
4. Добавить файлы.
5. Записать время.
6. Сформировать отчёт для клиента.
### Личный проект
1. Открыть проект `sshkeeper`.
2. Нажать “Открыть IDE”.
3. Нажать “Собрать”.
4. Посмотреть roadmap.
5. Добавить заметку “на чём остановился”.
### Импорт DokuWiki
1. Выбрать `data/pages`.
2. Выбрать `data/media`.
3. Импортировать namespaces как дерево.
4. Сохранить оригиналы.
5. Постепенно разобрать по делам.
1. Открыть дело проекта.
2. Нажать "Открыть в IDE".
3. Обновить roadmap-заметку.
4. Записать "на чём остановился".
### Восстановление времени
Пользователь не нажимал таймер, но вечером видит:
```text
Похоже, ты работал по делу “ООО Ромашка / Сайт”:
Похоже, работа по "Клиенты / Ромашка / Сайт":
14:0517:12, примерно 3ч.
Основания:
- открывалась админка сайта;
- открывался URL админки сайта;
- менялся catalog.xlsx;
- запускался sshkeeper profile;
- создавались скриншоты.
[Записать 3ч] [Исправить] [Игнорировать]
[Записать 3ч] [Изменить] [Игнорировать]
```
## 5. Что точно не делать в начале
## 7. Расширяемость
Базовые сущности универсальны. Плагины добавляют функционал
без перекомпиляции программы:
- шаблоны дел (клиент, ремонт, проект, рецепт...);
- календарь;
- канбан;
- импортёры (DokuWiki, Obsidian, plain folder);
- интеграции с внешними сервисами.
Подробнее: [docs/09_Extensibility.md](docs/09_Extensibility.md).
## 8. Что не делать в начале
- не делать SaaS;
- не делать multi-user CRM;
- не делать встроенный офисный пакет;
- не делать полноценный password manager;
- не делать офисный пакет;
- не делать password manager;
- не делать ИИ;
- не делать мобильное приложение;
- не делать сложные права пользователей;
- не делать бухгалтерию;
- не пытаться автоматически понимать всё.
- не делать бухгалтерию.
## 6. Уникальность
## 9. Уникальность
Верстак отличается тем, что объединяет:
- заметочник;
- файловый кабинет;
- project launcher;
- журнал работ;
- рабочий контекст;
- sync/backup;
- TUI/GUI;
- миграцию из DokuWiki.
Но всё это не как отдельные модули, а вокруг одного понятия: **дело**.
Верстак объединяет заметочник, файловый кабинет, лаунчер,
журнал работ и контекст вокруг одного понятия: **дело**.
Плагины делают его адаптируемым под любой сегмент.

View File

@ -311,7 +311,25 @@ Scanner сравнивает реальность с SQLite:
- moved file later;
- hash mismatch.
## 6. Внешние приложения
## 6. Плагины (Extensibility)
Верстак изначально проектируется как база с плагинами.
Базовая модель (дело + заметка + файл + действие + журнал)
универсальна. Плагины добавляют функционал без перекомпиляции.
Примеры плагинов:
- `calendar` — календарь событий с привязкой к делам
- `kanban` — доска задач внутри дела
- `importer-dokuwiki` — импорт из DokuWiki
- `importer-obsidian` — импорт из Obsidian
- `browser-activity` — отслеживание браузерной активности
- `secret-notes` — зашифрованные заметки
- `client-template` — шаблон "Клиент" с полями (сайт, домен, ...)
Архитектура плагинов: [docs/09_Extensibility.md](docs/09_Extensibility.md)
### Внешние приложения
Верстак не пишет свой офисный пакет.

View File

@ -274,3 +274,17 @@ CREATE TABLE sync_ops (
- private keys;
- token-like values;
- binary content in MVP.
## 6. Расширяемость через плагины
Базовая схема фиксирована и поддерживает плагины:
- Новые типы нод регистрируются плагинами через Lua API
(`verstak.node.register_type()`) — схема таблицы `nodes` не меняется,
`type` принимает любое строковое значение.
- Мета-поля (`node_meta`) хранят произвольные key-value пары,
зарегистрированные плагинами.
- Плагины могут создавать собственные таблицы через SQL-миграции
в своей директории `.verstak/plugins/<name>/migrations/`.
- `device_id` на уровне nodes позволяет плагинам синхронизировать
свои данные через sync_ops.

159
docs/09_Extensibility.md Normal file
View File

@ -0,0 +1,159 @@
# Верстак — архитектура плагинов
## Принцип
Верстак — это минималистичный движок с деревом дел.
Всё, что не входит в минимальную модель, — плагин.
Плагин — это директория в `.verstak/plugins/<name>/`, которую
программа подхватывает без перекомпиляции.
## Структура плагина
```
.verstak/plugins/<name>/
plugin.json # мета: name, version, author, hooks
main.lua # точка входа
templates/ # шаблоны дел (опционально)
client.json
repair.json
panels/ # UI-панели для GUI (опционально)
kanban.html
calendar.html
migrations/ # SQL-миграции (опционально)
001_create_tables.sql
```
## plugin.json
```json
{
"name": "calendar",
"version": "1.0.0",
"author": "...",
"description": "Календарь событий, привязанных к делам",
"hooks": {
"on_init": "on_init",
"on_node_open": "on_node_open"
},
"node_types": ["event"],
"panel": "panels/calendar.html",
"migrations": ["migrations/001_create_tables.sql"]
}
```
## Lua API
Плагины пишутся на Lua (gopher-lua). API:
```lua
-- Получить node по ID
local node = verstak.node.get(id)
-- Создать node
local n = verstak.node.create(parent_id, "type", "title")
-- Получить config value
local v = verstak.config.get("key")
-- Записать в activity log
verstak.activity.log({
node_id = n.id,
event_type = "calendar_event",
title = "Встреча с клиентом"
})
-- Зарегистрировать HTTP-эндпоинт (для GUI)
verstak.http.route("GET", "/api/calendar/events", get_events)
-- Показать уведомление
verstak.ui.toast("Событие добавлено")
```
## Жизненный цикл плагина
1. **on_init** — при старте программы, до открытия vault.
Инициализация, создание таблиц.
2. **on_vault_open** — при открытии vault.
3. **on_node_create / on_node_open / on_node_delete** — хуки на действия.
4. **on_shutdown** — при закрытии.
## Реестр типов дел
Плагины могут регистрировать новые типы:
```lua
verstak.node.register_type({
name = "event",
label = "Событие",
icon = "calendar",
fields = {
{ name = "date", label = "Дата", type = "date" },
{ name = "time", label = "Время", type = "time" },
{ name = "location", label = "Место", type = "text" },
}
})
```
GUI рисует карточку дела на основе зарегистрированных полей типа.
## Шаблоны дела
Шаблон — JSON-описание предзаполненного дерева:
```json
{
"name": "Клиент",
"icon": "user",
"tree": [
{ "type": "folder", "title": "Документы" },
{ "type": "folder", "title": "Переписка" },
{ "type": "folder", "title": "Скриншоты" },
{ "type": "note", "title": "Overview" },
{ "type": "action", "title": "Открыть сайт", "kind": "open_url", "url": "" }
],
"meta": [
{ "key": "domain", "label": "Домен сайта", "type": "text" },
{ "key": "admin_url", "label": "Админка", "type": "url" }
]
}
```
GUI: при создании дела пользователь выбирает шаблон — и дерево
создаётся автоматически.
## Песочница
Lua-плагины работают в песочнице:
- нет доступа к файловой системе напрямую (только через API vault);
- нет `io.*`, `os.execute` и т.д.;
- память ограничена;
- нет сетевых вызовов кроме зарегистрированных HTTP-эндпоинтов.
Go-плагины (buildmode=plugin) доступны для продвинутых
разработчиков, но требуют совместимости версий.
## Инициализация
При старте `verstak init` создаёт `.verstak/plugins/`.
При старте GUI/CLI/TUI:
1. Сканировать `.verstak/plugins/*/plugin.json`
2. Валидировать (имя, версия, структура)
3. Загрузить миграции и выполнить
4. Загрузить Lua-скрипты через gopher-lua
5. Вызвать `on_init` у каждого плагина
6. Зарегистрировать node types, HTTP routes, UI panels
## Распространение
Плагин — это zip-архив с правильной структурой.
Репозиторий плагинов: `verstak-registry` (отдельный проект).
Установка:
```bash
verstak plugin install calendar
verstak plugin enable calendar
verstak plugin list
```

View File

@ -13,7 +13,7 @@
|---|-----|--------|
| 1 | Git init + Skeleton | ✅ выполнен |
| 2 | Init + SQLite + First Migration | ✅ выполнен |
| 3 | Nodes Repository + CRUD + CLI Node | ⬜ не начат |
| 3 | Nodes Repository + CRUD + CLI Node | ✅ выполнен |
| 4 | Vault Files: Trash + File Service + CLI File | ⬜ не начат |
| 5 | Markdown Notes: Create/Read/Save + CLI Note | ⬜ не начат |
| 6 | Wails GUI MVP: Sidebar + Main Panel | ⬜ не начат |
@ -26,6 +26,7 @@
| 13 | Activity + File Scanner/Watcher | ⬜ не начат |
| 14 | TUI MVP (Bubble Tea) | ⬜ не начат |
| 15 | Integrity Check + Repair + Vault Restore | ⬜ не начат |
| 16 | Plugins System (Lua + Templates) | ⬜ не начат |
---
@ -334,6 +335,32 @@
---
## ШАГ 16 — Система плагинов (Lua + шаблоны дел)
**Цель:** можно положить Lua-скрипт в `.verstak/plugins/` — и он работает.
**Acceptance:**
- `.verstak/plugins/<name>/plugin.json` — мета
- `main.lua` — загрузка через gopher-lua
- `on_init`, `on_vault_open`, `on_node_create` хуки
- `verstak.node.register_type()` — новые типы дел
- `verstak.http.route()` — API для GUI
- шаблоны дела (JSON) → предзаполненное дерево
- CLI: `verstak plugin list / install / enable`
**Действия:**
- `internal/core/plugins/manager.go` — сканирование, загрузка, валидация
- Lua runtime (gopher-lua) с песочницей
- Plugin API: node, config, activity, http, ui, vault
- Миграции плагинов (SQL)
- Реестр типов дел → GUI рендерит разные карточки
- CLI: plugin list/install/enable
- Базовый шаблон дела (client.json)
**Commit:** `step 16: plugins system`
---
## Сводка структуры репозитория
```
@ -363,6 +390,7 @@ verstak/
sync/
security/
config/
plugins/
frontend/ # Wails frontend (Svelte/Vue)

294
internal/core/files/file.go Normal file
View File

@ -0,0 +1,294 @@
package files
import (
"crypto/sha256"
"database/sql"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"verstak/internal/core/storage"
"verstak/internal/core/util"
)
// Record represents a file entry linked to a node.
type Record struct {
ID string `json:"id"`
NodeID string `json:"node_id"`
Filename string `json:"filename"`
Path string `json:"path"` // relative to vault root
StorageMode string `json:"storage_mode"` // "vault" | "external"
Size int64 `json:"size"`
SHA256 string `json:"sha256,omitempty"`
MIME string `json:"mime,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
Missing bool `json:"missing"`
}
// Service provides file operations inside a vault.
type Service struct {
db *storage.DB
vaultRoot string
}
// NewService creates a file service bound to a vault.
func NewService(db *storage.DB, vaultRoot string) *Service {
return &Service{db: db, vaultRoot: vaultRoot}
}
// DB returns the underlying storage.
func (s *Service) DB() *storage.DB {
return s.db
}
// --- public operations ---
// AddExternal registers an external file (absolute path) without copying.
func (s *Service) AddExternal(nodeID, absPath string) (*Record, error) {
info, err := os.Stat(absPath)
if err != nil {
return nil, fmt.Errorf("stat: %w", err)
}
absPath, _ = filepath.Abs(absPath)
return s.insertRecord(nodeID, filepath.Base(absPath), absPath, "external", info.Size(), "")
}
// CopyIntoVault copies an external file into the vault.
// The file lands at <vaultRoot>/spaces/<nodeSlug>/<filename>.
func (s *Service) CopyIntoVault(nodeID, absPath, nodeSlug string) (*Record, error) {
info, err := os.Stat(absPath)
if err != nil {
return nil, fmt.Errorf("stat: %w", err)
}
if nodeSlug == "" {
nodeSlug = nodeID[:8]
}
destDir := filepath.Join(s.vaultRoot, "spaces", nodeSlug)
if err := os.MkdirAll(destDir, 0o750); err != nil {
return nil, fmt.Errorf("mkdir: %w", err)
}
filename := filepath.Base(absPath)
dest := filepath.Join(destDir, filename)
// If destination exists, add a numeric suffix.
if _, err := os.Stat(dest); err == nil {
ext := filepath.Ext(filename)
name := strings.TrimSuffix(filename, ext)
dest = filepath.Join(destDir, fmt.Sprintf("%s_%d%s", name, time.Now().Unix(), ext))
filename = filepath.Base(dest)
}
hash, err := copyAndHash(absPath, dest)
if err != nil {
return nil, fmt.Errorf("copy: %w", err)
}
relPath, _ := filepath.Rel(s.vaultRoot, dest)
return s.insertRecord(nodeID, filename, relPath, "vault", info.Size(), hash)
}
// Get returns a file record by ID.
func (s *Service) Get(id string) (*Record, error) {
row := s.db.QueryRow(
`SELECT id,node_id,filename,path,storage_mode,size,sha256,mime,
created_at,updated_at,last_seen_at,missing
FROM files WHERE id = ?`, id)
return scanRecord(row)
}
// ListByNode returns all files linked to a node.
func (s *Service) ListByNode(nodeID string) ([]Record, error) {
rows, err := s.db.Query(
`SELECT id,node_id,filename,path,storage_mode,size,sha256,mime,
created_at,updated_at,last_seen_at,missing
FROM files WHERE node_id = ? ORDER BY created_at`, nodeID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanRecords(rows)
}
// MarkMissing flags a file as missing.
func (s *Service) MarkMissing(id string, missing bool) error {
m := 0
if missing {
m = 1
}
_, err := s.db.Exec(
`UPDATE files SET missing=?, updated_at=? WHERE id=?`,
m, time.Now().UTC().Format(time.RFC3339), id)
return err
}
// DeleteToTrash moves a vault file to .verstak/trash/ and removes the record.
func (s *Service) DeleteToTrash(id string) error {
rec, err := s.Get(id)
if err != nil {
return err
}
if rec.StorageMode == "vault" {
src := filepath.Join(s.vaultRoot, rec.Path)
trashDir := filepath.Join(s.vaultRoot, ".verstak", "trash")
if err := os.MkdirAll(trashDir, 0o750); err != nil {
return err
}
dest := filepath.Join(trashDir, rec.ID+"_"+rec.Filename)
if err := os.Rename(src, dest); err != nil {
return fmt.Errorf("move to trash: %w", err)
}
}
_, err = s.db.Exec("DELETE FROM files WHERE id=?", id)
return err
}
// Open launches the file with the system default application.
func (s *Service) Open(id string) error {
rec, err := s.Get(id)
if err != nil {
return err
}
var abs string
if rec.StorageMode == "vault" {
abs = filepath.Join(s.vaultRoot, rec.Path)
} else {
abs = rec.Path
}
return openWithSystem(abs)
}
// --- implementation details ---
func (s *Service) insertRecord(nodeID, filename, path, mode string, size int64, sha string) (*Record, error) {
rec := &Record{
ID: util.UUID7(),
NodeID: nodeID,
Filename: filename,
Path: path,
StorageMode: mode,
Size: size,
SHA256: sha,
MIME: guessMIME(filename),
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
_, err := s.db.Exec(
`INSERT INTO files (id,node_id,filename,path,storage_mode,size,sha256,mime,
created_at,updated_at,missing)
VALUES (?,?,?,?,?,?,?,?,?,?,0)`,
rec.ID, rec.NodeID, rec.Filename, rec.Path, rec.StorageMode,
rec.Size, rec.SHA256, rec.MIME,
rec.CreatedAt.Format(time.RFC3339), rec.UpdatedAt.Format(time.RFC3339))
if err != nil {
return nil, err
}
return rec, nil
}
func copyAndHash(src, dest string) (string, error) {
in, err := os.Open(src)
if err != nil {
return "", err
}
defer in.Close()
out, err := os.Create(dest)
if err != nil {
return "", err
}
defer out.Close()
h := sha256.New()
if _, err := io.Copy(io.MultiWriter(out, h), in); err != nil {
return "", err
}
return fmt.Sprintf("%x", h.Sum(nil)), nil
}
func guessMIME(filename string) string {
ext := strings.ToLower(filepath.Ext(filename))
switch ext {
case ".md", ".txt", ".go", ".py", ".js", ".ts", ".sh", ".sql", ".yml", ".yaml", ".json", ".toml", ".xml", ".html", ".css", ".csv", ".rst":
return "text/plain"
case ".png":
return "image/png"
case ".jpg", ".jpeg":
return "image/jpeg"
case ".gif":
return "image/gif"
case ".pdf":
return "application/pdf"
case ".docx":
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
case ".xlsx":
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
case ".odt":
return "application/vnd.oasis.opendocument.text"
case ".zip":
return "application/zip"
}
return "application/octet-stream"
}
func openWithSystem(path string) error {
var cmd *exec.Cmd
switch runtime.GOOS {
case "linux":
cmd = exec.Command("xdg-open", path)
case "darwin":
cmd = exec.Command("open", path)
case "windows":
cmd = exec.Command("cmd", "/c", "start", "", path)
default:
return fmt.Errorf("unsupported platform")
}
return cmd.Start()
}
// --- scanning helpers ---
type scanFace interface {
Scan(dest ...interface{}) error
}
func scanRecord(s scanFace) (*Record, error) {
var r Record
var lastSeen sql.NullString
var createdStr, updatedStr string
err := s.Scan(
&r.ID, &r.NodeID, &r.Filename, &r.Path, &r.StorageMode,
&r.Size, &r.SHA256, &r.MIME,
&createdStr, &updatedStr, &lastSeen, &r.Missing)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("file not found")
}
if err != nil {
return nil, err
}
r.CreatedAt, _ = time.Parse(time.RFC3339, createdStr)
r.UpdatedAt, _ = time.Parse(time.RFC3339, updatedStr)
if lastSeen.Valid {
t, _ := time.Parse(time.RFC3339, lastSeen.String)
r.LastSeenAt = &t
}
return &r, nil
}
func scanRecords(rows *sql.Rows) ([]Record, error) {
var out []Record
for rows.Next() {
r, err := scanRecord(rows)
if err != nil {
return nil, err
}
out = append(out, *r)
}
return out, rows.Err()
}

View File

@ -0,0 +1,158 @@
package files
import (
"os"
"path/filepath"
"testing"
"verstak/internal/core/storage"
)
func openTestDB(t *testing.T) *storage.DB {
t.Helper()
dir := t.TempDir()
db, err := storage.Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { db.Close() })
return db
}
func TestAddExternal(t *testing.T) {
db := openTestDB(t)
// Run migration 002 manually since storage.Open already applied it.
// We can verify the table exists by inserting.
filesSvc := NewService(db, t.TempDir())
// Create a real temp file to register.
tmpDir := t.TempDir()
tmpFile := filepath.Join(tmpDir, "test.txt")
if err := os.WriteFile(tmpFile, []byte("hello world"), 0o640); err != nil {
t.Fatal(err)
}
rec, err := filesSvc.AddExternal("node-1", tmpFile)
if err != nil {
t.Fatalf("AddExternal: %v", err)
}
if rec.ID == "" {
t.Fatal("empty id")
}
if rec.Filename != "test.txt" {
t.Errorf("filename = %q", rec.Filename)
}
if rec.StorageMode != "external" {
t.Errorf("mode = %q", rec.StorageMode)
}
if rec.Size != 11 {
t.Errorf("size = %d, want 11", rec.Size)
}
// Verify stored.
got, err := filesSvc.Get(rec.ID)
if err != nil {
t.Fatal(err)
}
if got.Filename != "test.txt" {
t.Errorf("got filename = %q", got.Filename)
}
}
func TestCopyIntoVault(t *testing.T) {
db := openTestDB(t)
vaultRoot := t.TempDir()
svc := NewService(db, vaultRoot)
// Source file.
srcDir := t.TempDir()
srcFile := filepath.Join(srcDir, "doc.pdf")
os.WriteFile(srcFile, []byte("PDF content here"), 0o640)
rec, err := svc.CopyIntoVault("node-1", srcFile, "my-node")
if err != nil {
t.Fatalf("CopyIntoVault: %v", err)
}
if rec.SHA256 == "" {
t.Error("expected sha256")
}
if rec.StorageMode != "vault" {
t.Errorf("mode = %q", rec.StorageMode)
}
// Verify file on disk.
if _, err := os.Stat(filepath.Join(vaultRoot, rec.Path)); err != nil {
t.Errorf("file on disk: %v", err)
}
}
func TestListByNode(t *testing.T) {
db := openTestDB(t)
svc := NewService(db, t.TempDir())
os.WriteFile(filepath.Join(t.TempDir(), "a.txt"), []byte("a"), 0o640)
f1 := filepath.Join(t.TempDir(), "a1.txt")
f2 := filepath.Join(t.TempDir(), "a2.txt")
os.WriteFile(f1, []byte("a"), 0o640)
os.WriteFile(f2, []byte("bb"), 0o640)
svc.AddExternal("node-a", f1)
svc.AddExternal("node-a", f2)
list, err := svc.ListByNode("node-a")
if err != nil {
t.Fatal(err)
}
if len(list) != 2 {
t.Errorf("list len = %d, want 2", len(list))
}
}
func TestDeleteToTrash(t *testing.T) {
db := openTestDB(t)
vaultRoot := t.TempDir()
svc := NewService(db, vaultRoot)
src := filepath.Join(t.TempDir(), "important.pdf")
os.WriteFile(src, []byte("important data"), 0o640)
rec, _ := svc.CopyIntoVault("node-x", src, "node-x")
if err := svc.DeleteToTrash(rec.ID); err != nil {
t.Fatalf("DeleteToTrash: %v", err)
}
// File record should be gone.
if _, err := svc.Get(rec.ID); err == nil {
t.Error("expected error after trash")
}
// Original file should not exist anymore (moved to trash).
if _, err := os.Stat(filepath.Join(vaultRoot, rec.Path)); !os.IsNotExist(err) {
t.Error("expected file to be moved from original location")
}
// Trash dir should have it.
trashDir := filepath.Join(vaultRoot, ".verstak", "trash")
entries, _ := os.ReadDir(trashDir)
if len(entries) != 1 {
t.Errorf("trash entries = %d, want 1", len(entries))
}
}
func TestGuessMIME(t *testing.T) {
cases := map[string]string{
"a.md": "text/plain",
"a.png": "image/png",
"a.pdf": "application/pdf",
"a.docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"a.go": "text/plain",
"a.unknown": "application/octet-stream",
}
for name, want := range cases {
got := guessMIME(name)
if got != want {
t.Errorf("guessMIME(%q) = %q, want %q", name, got, want)
}
}
}

203
internal/core/notes/note.go Normal file
View File

@ -0,0 +1,203 @@
package notes
import (
"fmt"
"os"
"path/filepath"
"time"
"verstak/internal/core/files"
"verstak/internal/core/nodes"
"verstak/internal/core/storage"
"verstak/internal/core/util"
)
// Record represents a note entry (links a node to a file).
type Record struct {
NodeID string `json:"node_id"`
FileID string `json:"file_id"`
Format string `json:"format"`
Encrypted bool `json:"encrypted"`
}
// Service handles markdown notes.
type Service struct {
db *storage.DB
vaultRoot string
nodes *nodes.Repository
files *files.Service
}
// NewService creates a note service.
func NewService(db *storage.DB, vaultRoot string, nodeRepo *nodes.Repository, fileSvc *files.Service) *Service {
return &Service{db: db, vaultRoot: vaultRoot, nodes: nodeRepo, files: fileSvc}
}
// Create makes a new note node, an empty .md file, and links them.
func (s *Service) Create(parentID, title string) (*nodes.Node, *files.Record, error) {
node, err := s.nodes.Create(parentID, nodes.TypeNote, title)
if err != nil {
return nil, nil, fmt.Errorf("create node: %w", err)
}
slug := node.Slug
if slug == "" {
slug = "note"
}
filename := slug + ".md"
destDir := filepath.Join(s.vaultRoot, "spaces")
os.MkdirAll(destDir, 0o750)
dest := filepath.Join(destDir, filename)
if _, err := os.Stat(dest); err == nil {
filename = fmt.Sprintf("%s_%s.md", slug, node.ID[:8])
dest = filepath.Join(destDir, filename)
}
// Write initial content.
if err := os.WriteFile(dest, []byte("# "+title+"\n\n"), 0o640); err != nil {
return nil, nil, fmt.Errorf("write: %w", err)
}
// Register file record.
relPath, _ := filepath.Rel(s.vaultRoot, dest)
fileRec, err := insertFileRecord(s.db, node.ID, filename, relPath, "vault", 0)
if err != nil {
return nil, nil, fmt.Errorf("insert file: %w", err)
}
// Link.
_, err = s.db.Exec(
`INSERT INTO notes (node_id, file_id, format) VALUES (?,?,?)`,
node.ID, fileRec.ID, "markdown")
if err != nil {
return nil, nil, fmt.Errorf("link note: %w", err)
}
return node, fileRec, nil
}
// Read returns the content of a note.
func (s *Service) Read(nodeID string) (string, error) {
var filePath, storageMode string
err := s.db.QueryRow(
`SELECT f.path, f.storage_mode
FROM notes n JOIN files f ON n.file_id = f.id
WHERE n.node_id = ?`, nodeID).Scan(&filePath, &storageMode)
if err != nil {
return "", fmt.Errorf("query note: %w", err)
}
var abs string
if storageMode == "vault" {
abs = filepath.Join(s.vaultRoot, filePath)
} else {
abs = filePath
}
data, err := os.ReadFile(abs)
if err != nil {
return "", fmt.Errorf("read: %w", err)
}
return string(data), nil
}
// Save writes new content, backing up the old version.
func (s *Service) Save(nodeID, content string) error {
var filePath, storageMode string
err := s.db.QueryRow(
`SELECT f.path, f.storage_mode
FROM notes n JOIN files f ON n.file_id = f.id
WHERE n.node_id = ?`, nodeID).Scan(&filePath, &storageMode)
if err != nil {
return fmt.Errorf("query: %w", err)
}
var abs string
if storageMode == "vault" {
abs = filepath.Join(s.vaultRoot, filePath)
} else {
abs = filePath
}
// Backup old version.
if info, err := os.Stat(abs); err == nil && info.Size() > 0 {
histDir := filepath.Join(s.vaultRoot, ".verstak", "history")
os.MkdirAll(histDir, 0o750)
name := filepath.Base(abs)
backup := filepath.Join(histDir,
fmt.Sprintf("%s_%d.bak", name, time.Now().Unix()))
os.WriteFile(backup, mustRead(abs), 0o640)
}
if err := os.WriteFile(abs, []byte(content), 0o640); err != nil {
return fmt.Errorf("write: %w", err)
}
// Update file size.
info, _ := os.Stat(abs)
size := int64(0)
if info != nil {
size = info.Size()
}
_, err = s.db.Exec(
`UPDATE files SET size=?, updated_at=? WHERE path=? AND storage_mode=?`,
size, utcNow(), filePath, storageMode)
return err
}
// Delete soft-deletes the note node.
func (s *Service) Delete(nodeID string) error {
return s.nodes.SoftDelete(nodeID)
}
// Load looks up the note record for a node.
func (s *Service) Load(nodeID string) (*Record, error) {
var rec Record
var enc int
err := s.db.QueryRow(
`SELECT node_id, file_id, format, encrypted FROM notes WHERE node_id=?`, nodeID,
).Scan(&rec.NodeID, &rec.FileID, &rec.Format, &enc)
if err != nil {
return nil, err
}
rec.Encrypted = enc == 1
return &rec, nil
}
// --- helpers ---
func insertFileRecord(db *storage.DB, nodeID, filename, relPath, mode string, size int64) (*files.Record, error) {
rec := &files.Record{
ID: util.UUID7(),
NodeID: nodeID,
Filename: filename,
Path: relPath,
StorageMode: mode,
Size: size,
MIME: "text/plain",
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
_, err := db.Exec(
`INSERT INTO files (id,node_id,filename,path,storage_mode,size,mime,
created_at,updated_at,missing)
VALUES (?,?,?,?,?,?,?,?,?,0)`,
rec.ID, rec.NodeID, rec.Filename, rec.Path, rec.StorageMode,
rec.Size, rec.MIME,
rec.CreatedAt.Format(time.RFC3339), rec.UpdatedAt.Format(time.RFC3339))
if err != nil {
return nil, err
}
return rec, nil
}
func mustRead(path string) []byte {
data, err := os.ReadFile(path)
if err != nil {
return nil
}
return data
}
func utcNow() string {
return time.Now().UTC().Format(time.RFC3339)
}

View File

@ -0,0 +1,99 @@
package notes
import (
"os"
"path/filepath"
"strings"
"testing"
"verstak/internal/core/files"
"verstak/internal/core/nodes"
"verstak/internal/core/storage"
)
func setupService(t *testing.T) (*Service, *nodes.Repository, string) {
t.Helper()
dir := t.TempDir()
db, err := storage.Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { db.Close() })
nodeRepo := nodes.NewRepository(db)
fileSvc := files.NewService(db, dir)
svc := NewService(db, dir, nodeRepo, fileSvc)
return svc, nodeRepo, dir
}
func TestCreateAndRead(t *testing.T) {
svc, _, vaultRoot := setupService(t)
node, fileRec, err := svc.Create("", "My Note")
if err != nil {
t.Fatalf("Create: %v", err)
}
if node.Title != "My Note" {
t.Errorf("title = %q", node.Title)
}
if fileRec == nil || fileRec.ID == "" {
t.Fatal("file record missing")
}
// Read initial content.
content, err := svc.Read(node.ID)
if err != nil {
t.Fatalf("Read: %v", err)
}
if !strings.Contains(content, "My Note") {
t.Errorf("content = %q", content)
}
// Verify file on disk.
spacesDir := filepath.Join(vaultRoot, "spaces")
entries, _ := os.ReadDir(spacesDir)
if len(entries) == 0 {
t.Error("expected file in spaces/")
}
}
func TestSaveAndBackup(t *testing.T) {
svc, _, vaultRoot := setupService(t)
node, _, _ := svc.Create("", "Backup Test")
// Save new content.
newContent := "# Updated\n\nThis is the new content."
if err := svc.Save(node.ID, newContent); err != nil {
t.Fatalf("Save: %v", err)
}
// Read back.
got, err := svc.Read(node.ID)
if err != nil {
t.Fatal(err)
}
if got != newContent {
t.Errorf("content = %q, want %q", got, newContent)
}
// Check backup exists.
histDir := filepath.Join(vaultRoot, ".verstak", "history")
entries, _ := os.ReadDir(histDir)
if len(entries) != 1 {
t.Errorf("backup count = %d, want 1", len(entries))
}
}
func TestDeleteNote(t *testing.T) {
svc, nodeRepo, _ := setupService(t)
node, _, _ := svc.Create("", "To Delete")
if err := svc.Delete(node.ID); err != nil {
t.Fatal(err)
}
if _, err := nodeRepo.GetActive(node.ID); err == nil {
t.Error("expected deleted node to be inactive")
}
}

View File

@ -0,0 +1,21 @@
package storage
// migration002 — files table for vault file tracking.
const migration002 = `
CREATE TABLE IF NOT EXISTS files (
id TEXT PRIMARY KEY,
node_id TEXT NOT NULL REFERENCES nodes(id),
filename TEXT NOT NULL,
path TEXT NOT NULL,
storage_mode TEXT NOT NULL DEFAULT 'vault',
size INTEGER NOT NULL DEFAULT 0,
sha256 TEXT NULL,
mime TEXT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_seen_at TEXT NULL,
missing INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_files_node ON files(node_id);
`

View File

@ -0,0 +1,12 @@
package storage
// migration003 — notes table.
const migration003 = `
CREATE TABLE IF NOT EXISTS notes (
node_id TEXT PRIMARY KEY REFERENCES nodes(id),
file_id TEXT NOT NULL REFERENCES files(id),
format TEXT NOT NULL DEFAULT 'markdown',
original_format TEXT NULL,
encrypted INTEGER NOT NULL DEFAULT 0
);
`

View File

@ -58,7 +58,9 @@ CREATE TABLE IF NOT EXISTS _schema_ver (
var migrationFiles = map[int]string{
1: migration001,
// 2: migration002, etc.
2: migration002,
3: migration003,
// 4: migration004, etc.
}
func (db *DB) runInitialSchema() error {

179
internal/gui/index.html.go Normal file
View File

@ -0,0 +1,179 @@
package gui
// indexHTML is the GUI frontend served inline.
const indexHTML = `<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Верстак</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#1a1a2e;color:#ccc;height:100vh;display:flex;overflow:hidden}
#sb{width:260px;min-width:200px;background:#16213e;border-right:1px solid #0f3460;display:flex;flex-direction:column;overflow:hidden}
#sb-hdr{padding:14px 16px;font-size:16px;font-weight:700;color:#e94560;border-bottom:1px solid #0f3460;display:flex;justify-content:space-between;align-items:center}
#sb-hdr button{background:#0f3460;color:#ccc;border:1px solid #1a4080;border-radius:4px;padding:4px 10px;cursor:pointer;font-size:11px}
#sb-hdr button:hover{background:#1a4080;color:#fff}
#tree{flex:1;overflow-y:auto;padding:6px 0}
.ti{padding:5px 14px;cursor:pointer;display:flex;align-items:center;gap:6px;font-size:13px;border-radius:3px;margin:0 4px}
.ti:hover{background:#0f3460}
.ti.sel{background:#1a4080;color:#fff}
.ti .ic{width:14px;color:#e94560;text-align:center;font-size:13px}
.empty{padding:40px 14px;color:#555;font-size:13px;text-align:center}
#main{flex:1;display:flex;flex-direction:column;overflow:hidden;min-width:0}
#mh{padding:10px 20px;border-bottom:1px solid #0f3460;display:flex;justify-content:space-between;align-items:center;gap:10px}
#mh h1{font-size:17px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
#mh .acts{display:flex;gap:5px;flex-shrink:0}
#mh button{background:#0f3460;color:#ccc;border:1px solid #1a4080;border-radius:4px;padding:5px 11px;cursor:pointer;font-size:12px}
#mh button:hover{background:#1a4080;color:#fff}
#mh button.p{background:#e94560;border-color:#e94560}
#mh button.p:hover{background:#c73652}
#srch{padding:6px 20px;border-bottom:1px solid #0f3460}
#srch input{width:100%;padding:7px 12px;background:#0f0f23;color:#ccc;border:1px solid #0f3460;border-radius:5px;font-size:13px;outline:none}
#srch input:focus{border-color:#e94560}
#tabs{display:flex;padding:0 20px;border-bottom:1px solid #0f3460;gap:0;flex-shrink:0}
#tabs .t{padding:9px 16px;cursor:pointer;font-size:13px;color:#666;border-bottom:2px solid transparent}
#tabs .t:hover{color:#ddd}
#tabs .t.a{color:#e94560;border-bottom-color:#e94560}
#cnt{flex:1;overflow-y:auto;padding:20px}
.cg{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:12px}
.card{background:#16213e;border:1px solid #0f3460;border-radius:7px;padding:14px;cursor:pointer;transition:border-color .15s}
.card:hover{border-color:#e94560}
.card .ct{font-weight:600;margin-bottom:4px}
.card .cy{font-size:11px;color:#777;text-transform:uppercase}
.card .cm{font-size:11px;color:#555;margin-top:6px}
.bc{font-size:12px;color:#555;margin-bottom:10px}
#ed{display:none;position:fixed;inset:0;background:#1a1a2e;z-index:90;flex-direction:column}
#ed-hdr{padding:10px 20px;border-bottom:1px solid #0f3460;display:flex;justify-content:space-between;align-items:center}
#ed-hdr span{font-size:15px;font-weight:600}
#ed button{background:#0f3460;color:#ccc;border:1px solid #1a4080;border-radius:4px;padding:5px 12px;cursor:pointer;font-size:12px;margin-left:6px}
#ed button.p{background:#e94560;border-color:#e94560}
#ed-ta{flex:1;background:#0f0f23;color:#ccc;border:none;padding:20px;font-family:Consolas,monospace;font-size:13px;line-height:1.7;resize:none;outline:none}
.mo{display:none;position:fixed;inset:0;background:rgba(0,0,0,.7);z-index:100;align-items:center;justify-content:center}
.mo.on{display:flex}
.md{background:#16213e;border:1px solid #0f3460;border-radius:10px;padding:22px;width:400px;max-width:90vw}
.md h3{margin-bottom:14px;font-size:15px}
.md label{display:block;font-size:11px;color:#777;margin-bottom:3px;margin-top:10px}
.md input,.md select{width:100%;padding:7px 10px;background:#0f0f23;color:#ccc;border:1px solid #1a4080;border-radius:4px;font-size:13px;outline:none}
.md input:focus{border-color:#e94560}
.md .ma{display:flex;gap:8px;justify-content:flex-end;margin-top:18px}
.md button{padding:7px 14px;border-radius:4px;border:1px solid #1a4080;background:#0f3460;color:#ccc;cursor:pointer}
.md button.p{background:#e94560;border-color:#e94560}
#sr-res{display:none;position:absolute;background:#16213e;border:1px solid #0f3460;border-radius:6px;width:350px;max-height:300px;overflow-y:auto;z-index:50;box-shadow:0 4px 20px rgba(0,0,0,.4)}
#sr-res .sri{padding:8px 12px;cursor:pointer;font-size:13px;border-bottom:1px solid #0f3460}
#sr-res .sri:last-child{border-bottom:none}
#sr-res .sri:hover{background:#0f3460}
#sr-res .sri .srt{font-size:10px;color:#e94560;text-transform:uppercase}
ul{list-style:none}
</style>
</head>
<body>
<div id="sb">
<div id="sb-hdr"><span>&#9874; ВЕРСТАК</span><button onclick="showCM()">+</button></div>
<div id="tree"><div class="empty">Загрузка...</div></div>
</div>
<div id="main">
<div id="mh">
<h1 id="pt">Верстак</h1>
<div class="acts">
<button onclick="showCNM()">+ Заметка</button>
<button class="p" onclick="showCM()">+ Дело</button>
</div>
</div>
<div id="srch" style="position:relative">
<input id="si" placeholder="Поиск по делам..." autocomplete="off" oninput="handleSR(this.value)">
<div id="sr-res" style="top:38px;left:0"></div>
</div>
<div id="tabs">
<div class="t a" data-t="ov" onclick="switchTab(this)">Обзор</div>
<div class="t" data-t="nt" onclick="switchTab(this)">Заметки</div>
<div class="t" data-t="fl" onclick="switchTab(this)">Файлы</div>
</div>
<div id="cnt"><div class="empty">Выберите дело или создайте новое</div></div>
</div>
<div id="ed">
<div id="ed-hdr"><span id="et">Редактор</span><div><button onclick="closeED()">Закрыть</button><button class="p" onclick="saveNT()">Сохранить</button></div></div>
<textarea id="ed-ta" placeholder="Пишите в Markdown..."></textarea>
</div>
<div class="mo" id="cm">
<div class="md"><h3>Новое дело</h3>
<label>Тип</label><select id="ct"><option value="case">Дело</option><option value="folder">Папка</option><option value="space">Пространство</option><option value="recipe">Рецепт</option></select>
<label>Название</label><input id="cn" placeholder="Название...">
<div class="ma"><button onclick="closeCM()">Отмена</button><button class="p" onclick="submitCM()">Создать</button></div></div>
</div>
<div class="mo" id="cnm">
<div class="md"><h3>Новая заметка</h3>
<label>Название</label><input id="cnn" placeholder="Название...">
<div class="ma"><button onclick="closeCNM()">Отмена</button><button class="p" onclick="submitCNM()">Создать</button></div></div>
</div>
<script>
const A='';let cur='',editId='';
async function api(p,o){const r=await fetch(A+p,{headers:{'Content-Type':'application/json'},...o});if(!r.ok)throw Error(r.status);return r.json()}
function esc(s){let d=document.createElement('div');d.textContent=s;return d.innerHTML}
const ni={case:'&#9670;',folder:'&#9656;',space:'&#9678;',note:'&#9997;',recipe:'&#9672;',document:'&#128196;',file:'&#128206;',action:'&#9889;',secret:'&#128274;',worklog:'&#9201;',link:'&#128279;'};
async function loadTree(){
try{const items=await api('/api/nodes');const t=document.getElementById('tree');
if(!items.length){t.innerHTML='<div class="empty">Создайте первое дело</div>';return}
let h='<ul>';for(const n of items){h+='<li><div class="ti'+(cur===n.id?' sel':'')+'" onclick="selN(\''+n.id+'\')"><span class="ic">'+(ni[n.type]||'&#9670;')+'</span><span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+esc(n.title)+'</span></div></li>'}
h+='</ul>';t.innerHTML=h}
catch(e){document.getElementById('tree').innerHTML='<div class="empty">Ошибка</div>'}
}
async function selN(id){
cur=id;document.querySelectorAll('.ti').forEach(e=>e.classList.remove('sel'));
const d=event.currentTarget;if(d)d.classList.add('sel');
try{const d=await api('/api/nodes/'+id);document.getElementById('pt').textContent=d.node.title;renderOV(d)}
catch(e){document.getElementById('cnt').innerHTML='<div class="empty">Ошибка</div>'}
}
function renderOV(d){const ch=d.children||[],fl=d.files||[];
let h='<div class="bc">'+d.node.type+'</div>';
if(ch.length||fl.length){h+='<div class="cg">';
for(const c of ch){h+='<div class="card" onclick="selN(\''+c.id+'\')"><div class="ct">'+esc(c.title)+'</div><div class="cy">'+c.type+'</div></div>'}
for(const f of fl){h+='<div class="card"><div class="ct">'+esc(f.filename)+'</div><div class="cy">'+(f.mime||'file')+'</div><div class="cm">'+fsz(f.size)+'</div></div>'}
h+='</div>'}else{h+='<div class="empty" style="margin-top:60px">Пусто. Добавьте заметку или файл.</div>'}
document.getElementById('cnt').innerHTML=h
}
function fsz(b){if(b<1024)return b+' Б';if(b<1048576)return(b/1024).toFixed(1)+' КБ';return(b/1048576).toFixed(1)+' МБ'}
function switchTab(el){
document.querySelectorAll('#tabs .t').forEach(e=>e.classList.remove('a'));el.classList.add('a');
const t=el.dataset.t;if(t==='nt')loadNT();else if(t==='fl')loadFL();else if(cur)selN(cur);
}
async function loadNT(){
if(!cur){document.getElementById('cnt').innerHTML='<div class="empty">Выберите дело</div>';return}
const d=await api('/api/nodes/'+cur);const ns=(d.children||[]).filter(c=>c.type==='note');
if(!ns.length){document.getElementById('cnt').innerHTML='<div class="empty">Нет заметок</div>';return}
let h='<div class="cg">';for(const n of ns){h+='<div class="card" onclick="openNT(\''+n.id+'\')"><div class="ct">'+esc(n.title)+'</div><div class="cy">заметка</div></div>'}h+='</div>';
document.getElementById('cnt').innerHTML=h
}
async function loadFL(){
if(!cur){document.getElementById('cnt').innerHTML='<div class="empty">Выберите дело</div>';return}
const d=(await api('/api/nodes/'+cur)).files||[];
if(!d.length){document.getElementById('cnt').innerHTML='<div class="empty">Нет файлов</div>';return}
let h='<div class="cg">';for(const f of d){h+='<div class="card"><div class="ct">'+esc(f.filename)+'</div><div class="cy">'+(f.mime||'file')+'</div><div class="cm">'+fsz(f.size)+'</div></div>'}h+='</div>';
document.getElementById('cnt').innerHTML=h
}
async function openNT(id){editId=id;
try{const d=await api('/api/notes/'+id);document.getElementById('et').textContent='Заметка';document.getElementById('ed-ta').value=d.content||'';document.getElementById('ed').style.display='flex';document.getElementById('ed-ta').focus()}
catch(e){alert('Ошибка: '+e.message)}
}
function closeED(){document.getElementById('ed').style.display='none';editId=''}
async function saveNT(){if(!editId)return;try{await api('/api/notes/'+editId,{method:'PUT',body:JSON.stringify({content:document.getElementById('ed-ta').value})});closeED();if(cur)selN(cur)}catch(e){alert('Ошибка: '+e.message)}}
function showCM(){document.getElementById('cm').classList.add('on');setTimeout(()=>document.getElementById('cn').focus(),50)}
function closeCM(){document.getElementById('cm').classList.remove('on');document.getElementById('cn').value=''}
async function submitCM(){const t=document.getElementById('ct').value,title=document.getElementById('cn').value.trim();if(!title)return;try{await api('/api/nodes',{method:'POST',body:JSON.stringify({parent_id:cur,type:t,title})});closeCM();loadTree();if(cur)selN(cur)}catch(e){alert('Ошибка: '+e.message)}}
function showCNM(){document.getElementById('cnm').classList.add('on');setTimeout(()=>document.getElementById('cnn').focus(),50)}
function closeCNM(){document.getElementById('cnm').classList.remove('on');document.getElementById('cnn').value=''}
async function submitCNM(){const title=document.getElementById('cnn').value.trim();if(!title)return;try{const n=await api('/api/notes/'+(cur||''),{method:'POST',body:JSON.stringify({parent_id:cur,title})});closeCNM();loadTree();selN(n.id)}catch(e){alert('Ошибка: '+e.message)}}
let sT=null;
async function handleSR(q){clearTimeout(sT);const b=document.getElementById('sr-res');
if(!q||q.length<2){b.style.display='none';return}
sT=setTimeout(async()=>{try{const items=await api('/api/nodes');
const hits=items.filter(n=>n.title.toLowerCase().includes(q.toLowerCase()));
if(!hits.length){b.style.display='none';return}
let h='';for(const r of hits){h+='<div class="sri" onclick="b.style.display=\'none\';selN(\''+r.id+'\')"><div class="srt">'+r.type+'</div><div>'+esc(r.title)+'</div></div>'}
b.innerHTML=h;b.style.display='block'
}catch(e){b.style.display='none'}},200)}
document.addEventListener('keydown',e=>{if(e.key==='Escape'){closeED();closeCM();closeCNM();document.getElementById('sr-res').style.display='none'}});
loadTree();
</script>
</body>
</html>`

249
internal/gui/server.go Normal file
View File

@ -0,0 +1,249 @@
package gui
import (
"encoding/json"
"fmt"
"html/template"
"log"
"net"
"net/http"
"strings"
"verstak/internal/core/files"
"verstak/internal/core/notes"
"verstak/internal/core/nodes"
"verstak/internal/core/storage"
)
// Server is the GUI HTTP server bound to a vault.
type Server struct {
db *storage.DB
vaultRoot string
nodes *nodes.Repository
files *files.Service
notes *notes.Service
srv *http.Server
listener net.Listener
port int
}
// NewServer creates a GUI server for the given vault.
func NewServer(db *storage.DB, vaultRoot string) *Server {
nodeRepo := nodes.NewRepository(db)
fileSvc := files.NewService(db, vaultRoot)
noteSvc := notes.NewService(db, vaultRoot, nodeRepo, fileSvc)
return &Server{
db: db, vaultRoot: vaultRoot,
nodes: nodeRepo, files: fileSvc, notes: noteSvc,
}
}
// Start binds on a free port and returns the base URL.
func (s *Server) Start() (string, error) {
mux := http.NewServeMux()
mux.HandleFunc("/api/nodes", s.handleNodes)
mux.HandleFunc("/api/nodes/", s.handleNodeDetail)
mux.HandleFunc("/api/notes/", s.handleNotes)
mux.HandleFunc("/api/files/", s.handleFiles)
mux.HandleFunc("/api/search", s.handleSearch)
mux.HandleFunc("/", s.handleStatic)
s.srv = &http.Server{Handler: mux}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return "", err
}
s.listener = ln
s.port = ln.Addr().(*net.TCPAddr).Port
go func() {
if e := s.srv.Serve(ln); e != nil && e != http.ErrServerClosed {
log.Printf("GUI: %v", e)
}
}()
return fmt.Sprintf("http://127.0.0.1:%d", s.port), nil
}
// Stop shuts down the server.
func (s *Server) Stop() error {
if s.srv != nil {
return s.srv.Close()
}
return nil
}
// Addr returns the base URL.
func (s *Server) Addr() string {
return fmt.Sprintf("http://127.0.0.1:%d", s.port)
}
// --- handlers ---
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" && r.URL.Path != "/index.html" {
http.NotFound(w, r)
return
}
t := template.Must(template.New("idx").Parse(indexHTML))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
t.Execute(w, nil)
}
func jsonOK(w http.ResponseWriter, v interface{}) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
func jsonErr(w http.ResponseWriter, code int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(map[string]string{"error": msg})
}
// GET /api/nodes[?parent=ID] POST /api/nodes
func (s *Server) handleNodes(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
parent := r.URL.Query().Get("parent")
var list interface{}
var err error
if parent == "" {
list, err = s.nodes.ListRoots(false)
} else {
list, err = s.nodes.ListChildren(parent, false)
}
if err != nil {
jsonErr(w, 500, err.Error())
return
}
jsonOK(w, list)
case "POST":
var req struct {
ParentID string `json:"parent_id"`
Type string `json:"type"`
Title string `json:"title"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "bad json")
return
}
n, err := s.nodes.Create(req.ParentID, req.Type, req.Title)
if err != nil {
jsonErr(w, 500, err.Error())
return
}
jsonOK(w, n)
default:
jsonErr(w, 405, "method not allowed")
}
}
// GET/PUT/DELETE /api/nodes/{id}
func (s *Server) handleNodeDetail(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, "/api/nodes/")
switch r.Method {
case "GET":
n, err := s.nodes.GetActive(id)
if err != nil {
jsonErr(w, 404, "not found")
return
}
children, _ := s.nodes.ListChildren(id, false)
fl, _ := s.files.ListByNode(id)
meta, _ := s.nodes.MetaList(id)
jsonOK(w, map[string]interface{}{
"node": n, "children": children, "files": fl, "meta": meta,
})
case "PUT":
var req struct {
Title string `json:"title"`
ParentID string `json:"parent_id"`
Sort int `json:"sort_order"`
}
json.NewDecoder(r.Body).Decode(&req)
if req.Title != "" {
s.nodes.UpdateTitle(id, req.Title)
}
if req.ParentID != "" {
s.nodes.Move(id, req.ParentID, req.Sort)
}
jsonOK(w, map[string]string{"status": "ok"})
case "DELETE":
s.nodes.SoftDelete(id)
jsonOK(w, map[string]string{"status": "deleted"})
default:
jsonErr(w, 405, "method not allowed")
}
}
// POST /api/notes/{parentID} PUT/GET /api/notes/{nodeID}
func (s *Server) handleNotes(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/notes/")
switch r.Method {
case "GET":
rec, err := s.notes.Load(path)
if err != nil {
jsonErr(w, 404, "not found")
return
}
content, _ := s.notes.Read(path)
jsonOK(w, map[string]interface{}{"record": rec, "content": content})
case "POST":
var req struct{ Title string `json:"title"` }
json.NewDecoder(r.Body).Decode(&req)
n, _, err := s.notes.Create(path, req.Title)
if err != nil {
jsonErr(w, 500, err.Error())
return
}
jsonOK(w, n)
case "PUT":
var req struct{ Content string `json:"content"` }
json.NewDecoder(r.Body).Decode(&req)
if err := s.notes.Save(path, req.Content); err != nil {
jsonErr(w, 500, err.Error())
return
}
jsonOK(w, map[string]string{"status": "saved"})
default:
jsonErr(w, 405, "method not allowed")
}
}
// GET/DELETE /api/files/{id}
func (s *Server) handleFiles(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, "/api/files/")
switch r.Method {
case "GET":
rec, err := s.files.Get(id)
if err != nil {
jsonErr(w, 404, err.Error())
return
}
jsonOK(w, rec)
case "DELETE":
if err := s.files.DeleteToTrash(id); err != nil {
jsonErr(w, 500, err.Error())
return
}
jsonOK(w, map[string]string{"status": "trashed"})
default:
jsonErr(w, 405, "method not allowed")
}
}
// GET /api/search?q=...
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
q := strings.ToLower(r.URL.Query().Get("q"))
if len(q) < 2 {
jsonOK(w, []interface{}{})
return
}
roots, _ := s.nodes.ListRoots(false)
var hits []map[string]interface{}
for _, n := range roots {
if strings.Contains(strings.ToLower(n.Title), q) {
hits = append(hits, map[string]interface{}{"id": n.ID, "title": n.Title, "type": n.Type})
}
}
jsonOK(w, hits)
}

View File

@ -0,0 +1,259 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Верстак</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#1a1a2e;color:#e0e0e0;height:100vh;display:flex;overflow:hidden}
#sb{width:280px;background:#16213e;border-right:1px solid #0f3460;display:flex;flex-direction:column;overflow:hidden;flex-shrink:0}
#sb-hdr{padding:16px;font-size:18px;font-weight:700;color:#e94560;border-bottom:1px solid #0f3460;display:flex;justify-content:space-between;align-items:center}
#sb-hdr button{background:#0f3460;color:#e0e0e0;border:1px solid #1a4080;border-radius:4px;padding:4px 10px;cursor:pointer;font-size:12px}
#sb-hdr button:hover{background:#1a4080}
#tree{flex:1;overflow-y:auto;padding:8px 0}
#tree ul{list-style:none}
#tree li{user-select:none}
.ti{padding:6px 16px;cursor:pointer;display:flex;align-items:center;gap:8px;font-size:13px;border-radius:4px;margin:1px 6px}
.ti:hover{background:#0f3460}
.ti.sel{background:#1a4080;color:#fff}
.ti .ic{width:16px;text-align:center;color:#e94560;font-size:14px}
.ti .ar{width:12px;font-size:10px;color:#555}
.tc{margin-left:18px;border-left:1px dashed #0f3460}
#main{flex:1;display:flex;flex-direction:column;overflow:hidden;min-width:0}
#mh{padding:14px 24px;border-bottom:1px solid #0f3460;display:flex;justify-content:space-between;align-items:center;gap:12px}
#mh h1{font-size:18px;font-weight:600;white-space:overflow:hidden;text-overflow:ellipsis}
#mh .acts{display:flex;gap:6px;flex-shrink:0}
#mh button{background:#0f3460;color:#e0e0e0;border:1px solid #1a4080;border-radius:4px;padding:6px 12px;cursor:pointer;font-size:12px}
#mh button:hover{background:#1a4080}
#mh button.p{background:#e94560;border-color:#e94560;color:#fff}
#mh button.p:hover{background:#c73652}
#tabs{display:flex;padding:0 24px;border-bottom:1px solid #0f3460;flex-shrink:0}
#tabs .t{padding:10px 18px;cursor:pointer;font-size:13px;color:#888;border-bottom:2px solid transparent;white-space:nowrap}
#tabs .t:hover{color:#e0e0e0}
#tabs .t.a{color:#e94560;border-bottom-color:#e94560}
#srch{padding:8px 24px}
#srch input{width:100%;padding:8px 12px;background:#0f0f23;color:#e0e0e0;border:1px solid #0f3460;border-radius:6px;font-size:13px;outline:none}
#srch input:focus{border-color:#e94560}
#sr-res{display:none;position:absolute;top:100px;left:50%;transform:translateX(-50%);width:400px;max-width:90vw;background:#16213e;border:1px solid #0f3460;border-radius:8px;max-height:350px;overflow-y:auto;z-index:60}
#sr-res .sri{padding:10px 14px;cursor:pointer;border-bottom:1px solid #0f3460;font-size:13px}
#sr-res .sri:hover{background:#0f3460}
#sr-res .sri .srt{font-size:10px;color:#e94560;text-transform:uppercase}
#cnt{flex:1;overflow-y:auto;padding:24px}
.bc{font-size:12px;color:#666;margin-bottom:12px}
.empty{display:flex;align-items:center;justify-content:center;height:100%;color:#555;font-size:15px}
.cg{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:14px}
.card{background:#16213e;border:1px solid #0f3460;border-radius:8px;padding:14px;cursor:pointer;transition:border-color .15s}
.card:hover{border-color:#e94560}
.card .ct{font-weight:600;margin-bottom:4px;font-size:14px}
.card .cy{font-size:11px;color:#888;text-transform:uppercase}
.card .cm{font-size:11px;color:#555;margin-top:6px}
#ed{display:none;position:fixed;inset:0;background:#1a1a2e;z-index:90;flex-direction:column}
#ed-hdr{padding:12px 24px;border-bottom:1px solid #0f3460;display:flex;justify-content:space-between;align-items:center}
#ed-hdr span{font-size:16px;font-weight:600}
#ed button{background:#0f3460;color:#e0e0e0;border:1px solid #1a4080;border-radius:4px;padding:6px 14px;cursor:pointer;font-size:12px}
#ed button.p{background:#e94560;border-color:#e94560}
#ed-ta{flex:1;background:#0f0f23;color:#e0e0e0;border:none;padding:24px;font-family:"Fira Code",Consolas,monospace;font-size:14px;line-height:1.7;resize:none;outline:none}
#ed-ftr{padding:10px 24px;border-top:1px solid #0f3460;display:flex;gap:8px}
.mo{display:none;position:fixed;inset:0;background:rgba(0,0,0,.7);z-index:100;align-items:center;justify-content:center}
.mo.on{display:flex}
.md{background:#16213e;border:1px solid #0f3460;border-radius:12px;padding:24px;width:420px;max-width:90vw}
.md h3{margin-bottom:16px;font-size:16px}
.md label{display:block;font-size:12px;color:#888;margin-bottom:4px;margin-top:12px}
.md input,.md select{width:100%;padding:8px 12px;background:#0f0f23;color:#e0e0e0;border:1px solid #1a4080;border-radius:4px;font-size:14px;outline:none}
.md input:focus{border-color:#e94560}
.md .ma{display:flex;gap:8px;justify-content:flex-end;margin-top:20px}
.md button{padding:8px 16px;border-radius:4px;border:1px solid #1a4080;background:#0f3460;color:#e0e0e0;cursor:pointer}
.md button.p{background:#e94560;border-color:#e94560;color:#fff}
</style>
</head>
<body>
<div id="sb">
<div id="sb-hdr"><span>ВЕРСТАК</span><button onclick="showCM()">+</button></div>
<div id="tree"><div class="empty" style="padding:30px 14px;font-size:13px">Пусто. Создайте первое дело.</div></div>
</div>
<div id="main">
<div id="mh"><h1 id="pt">Верстак</h1><div class="acts">
<button onclick="showCNM()">+ Заметка</button>
<button onclick="showCM()">+ Дело</button>
<button onclick="document.getElementById('fi').click()">+ Файл</button>
<input type="file" id="fi" style="display:none" onchange="handleFA(event)">
</div></div>
<div id="tabs"><div class="t a" onclick="switchTab(this,'ov')">Обзор</div><div class="t" onclick="switchTab(this,'nt')">Заметки</div><div class="t" onclick="switchTab(this,'fl')">Файлы</div></div>
<div id="srch"><input id="si" placeholder="Поиск по делам и заметкам..." autocomplete="off" oninput="handleSR(this.value)"></div>
<div id="sr-res"></div>
<div id="cnt"><div class="empty">Выберите дело или создайте новое</div></div>
</div>
<div id="ed">
<div id="ed-hdr"><span id="et">Редактор</span><div><button onclick="closeED()"></button> <button class="p" onclick="saveNT()">💾 Сохранить</button></div></div>
<textarea id="ed-ta" placeholder="Пишите в Markdown..."></textarea>
<div id="ed-ftr"></div>
</div>
<div class="mo" id="cm">
<div class="md"><h3>Новое дело</h3>
<label>Тип</label><select id="ct"><option value="case">◇ Дело</option><option value="folder">▸ Папка</option><option value="space">◎ Пространство</option><option value="recipe">◈ Рецепт</option></select>
<label>Название</label><input id="cn" placeholder="Название..." autofocus>
<div class="ma"><button onclick="closeCM()">Отмена</button><button class="p" onclick="submitCM()">Создать</button></div></div>
</div>
<div class="mo" id="cnm">
<div class="md"><h3>Новая заметка</h3>
<label>Название</label><input id="cnn" placeholder="Название заметки..." autofocus>
<div class="ma"><button onclick="closeCNM()">Отмена</button><button class="p" onclick="submitCNM()">Создать</button></div></div>
</div>
<script>
const API='';
let cur='',editId='',tab='ov';
async function api(p,opt){
const r=await fetch(API+p,{headers:{'Content-Type':'application/json'},...opt});
if(!r.ok)throw new Error(r.status);
return r.json();
}
// tree
async function loadTree(){
try{
const items=await api('/api/nodes');
const t=document.getElementById('tree');
if(!items.length){t.innerHTML='<div class="empty" style="padding:30px 14px;font-size:13px">Пусто. Создайте первое дело.</div>';return}
t.innerHTML=renderL(items,'');
}catch(e){document.getElementById('tree').innerHTML='<div class="empty" style="padding:30px 14px">Ошибка загрузки</div>'}
}
function renderL(items,pid){
let h='<ul'+(pid?' class="tc"':'')+'>';
for(const n of items){
h+='<li><div class="ti'+(cur===n.id?' sel':'')+'" data-id="'+n.id+'" onclick="selN(\''+n.id+'\')">';
h+='<span class="ar">'+('▸')+'</span><span class="ic">'+nic(n.type)+'</span>';
h+='<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+esc(n.title)+'</span></div></li>';
}
h+='</ul>';return h;
}
function nic(t){const i={case:'◇',folder:'▸',space:'◎',note:'📝',document:'📄',file:'📎',recipe:'◈',action:'⚡',secret:'🔒',worklog:'⏱',link:'🔗'};return i[t]||'◇'}
function esc(s){const d=document.createElement('div');d.textContent=s;return d.innerHTML}
async function selN(id){
cur=id;
document.querySelectorAll('.ti').forEach(e=>e.classList.toggle('sel',e.dataset.id===id));
try{
const d=await api('/api/nodes/'+id);
document.getElementById('pt').textContent=d.node.title;
renderOV(d);
}catch(e){document.getElementById('cnt').innerHTML='<div class="empty">Ошибка</div>'}
}
function renderOV(d){
const ch=d.children||[],fl=d.files||[];
let h='<div class="bc">'+(d.node.type||'Дело')+'</div>';
if(ch.length||fl.length){
h+='<div class="cg">';
for(const c of ch){
h+='<div class="card" onclick="selN(\''+c.id+'\')"><div class="ct">'+esc(c.title)+'</div><div class="cy">'+c.type+'</div></div>';
}
for(const f of fl){
h+='<div class="card"><div class="ct">📎 '+esc(f.filename)+'</div><div class="cy">'+(f.mime||'file')+'</div><div class="cm">'+fsz(f.size)+'</div></div>';
}
h+='</div>';
} else {
h+='<div class="empty" style="margin-top:40px">Пусто. Добавьте заметку или файл.</div>';
}
document.getElementById('cnt').innerHTML=h;
}
function fsz(b){if(b<1024)return b+' B';if(b<1048576)return(b/1024).toFixed(1)+' KB';return(b/1048576).toFixed(1)+' MB'}
function switchTab(el,t){
document.querySelectorAll('#tabs .t').forEach(e=>e.classList.remove('a'));
el.classList.add('a');tab=t;
if(t==='ov'&&cur)selN(cur);else if(t==='nt')loadNT();else if(t==='fl')loadFL();
}
async function loadNT(){
if(!cur){document.getElementById('cnt').innerHTML='<div class="empty">Выберите делó</div>';return}
const d=await api('/api/nodes/'+cur);
const ns=(d.children||[]).filter(c=>c.type==='note');
if(!ns.length){document.getElementById('cnt').innerHTML='<div class="empty">Нет заметок. Создайте первую.</div>';return}
let h='<div class="cg">';
for(const n of ns)h+='<div class="card" onclick="openNT(\''+n.id+'\')"><div class="ct">📝 '+esc(n.title)+'</div><div class="cy">заметка</div></div>';
h+='</div>';document.getElementById('cnt').innerHTML=h;
}
async function loadFL(){
if(!cur){document.getElementById('cnt').innerHTML='<div class="empty">Выберите дело</div>';return}
const d=await api('/api/nodes/'+cur);
const fl=d.files||[];
if(!fl.length){document.getElementById('cnt').innerHTML='<div class="empty">Нет файлов</div>';return}
let h='<div class="cg">';
for(const f of fl)h+='<div class="card"><div class="ct">📎 '+esc(f.filename)+'</div><div class="cy">'+(f.mime||'file')+'</div><div class="cm">'+fsz(f.size)+'</div></div>';
h+='</div>';document.getElementById('cnt').innerHTML=h;
}
// editor
async function openNT(id){
editId=id;
try{const d=await api('/api/notes/'+id);document.getElementById('et').textContent='Заметка';document.getElementById('ed-ta').value=d.content||'';document.getElementById('ed').style.display='flex'}
catch(e){alert('Ошибка: '+e.message)}
}
function closeED(){document.getElementById('ed').style.display='none';editId=''}
async function saveNT(){
if(!editId)return;
try{await api('/api/notes/'+editId,{method:'PUT',body:JSON.stringify({content:document.getElementById('ed-ta').value})});closeED();if(cur)selN(cur)}
catch(e){alert('Ошибка: '+e.message)}
}
// modals
function showCM(){document.getElementById('cm').classList.add('on');document.getElementById('cn').focus()}
function closeCM(){document.getElementById('cm').classList.remove('on');document.getElementById('cn').value=''}
async function submitCM(){
const type=document.getElementById('ct').value,title=document.getElementById('cn').value.trim();
if(!title)return;
try{await api('/api/nodes',{method:'POST',body:JSON.stringify({parent_id:cur,type,title})});closeCM();loadTree();if(cur)selN(cur)}
catch(e){alert('Ошибка: '+e.message)}
}
function showCNM(){document.getElementById('cnm').classList.add('on');document.getElementById('cnn').focus()}
function closeCNM(){document.getElementById('cnm').classList.remove('on');document.getElementById('cnn').value=''}
async function submitCNM(){
const title=document.getElementById('cnn').value.trim();
if(!title)return;
try{const n=await api('/api/notes/'+(cur||''),{method:'POST',body:JSON.stringify({parent_id:cur,title})});closeCNM();loadTree();selN(n.id);switchTab(document.querySelectorAll('#tabs .t')[1],'nt')}
catch(e){alert('Ошибка: '+e.message)}
}
function handleFA(ev){const f=ev.target.files[0];if(!f)return;alert('MVP: загрузка файлов будет в следующей версии. Файл: '+f.name);ev.target.value=''}
// search
let sT=null;
async function handleSR(q){
clearTimeout(sT);
const b=document.getElementById('sr-res');
if(!q||q.length<2){b.style.display='none';return}
sT=setTimeout(async()=>{
try{
// For MVP, fetch all roots and filter locally
const items=await api('/api/nodes');
const hits=items.filter(n=>n.title.toLowerCase().includes(q.toLowerCase()));
if(!hits.length){b.style.display='none';return}
let h='';for(const r of hits){h+='<div class="sri" onclick="selN(\''+r.id+'\');b.style.display=\'none\'"><div class="srt">'+r.type+'</div><div>'+esc(r.title)+'</div></div>'}
b.innerHTML=h;b.style.display='block';
}catch(e){b.style.display='none'}
},200);
}
// keyboard shortcuts
document.addEventListener('keydown',e=>{
if(e.key==='Escape'){closeED();closeCM();closeCNM();document.getElementById('sr-res').style.display='none'}
if(e.key==='n'&&(e.ctrlKey||e.metaKey)){e.preventDefault();showCM()}
});
loadTree();
</script>
</body>
</html>

View File