Compare commits

...

4 Commits

Author SHA1 Message Date
mirivlad edc708a106 chore: add spaces/ to gitignore (vault data) 2026-06-01 22:17:39 +08:00
mirivlad ee708d30bb docs: sync documentation with current codebase state
- Architecture: Wails v3→v2, removed TUI/sync/security from diagram

- UI/UX: layout updated to sidebar+header, sync marked future

- Roadmap: Wails v3→v2 migration note, milestones 10+ PAUSED

- MVP Checklist: mark implemented features as done

- PLAN.md: bindings list synced, repo structure fixed, progress updated

- Medium-term steps documented (sync, scanner, TUI, Lua, etc.)
2026-06-01 22:17:29 +08:00
mirivlad 305158ecc6 test: MVP smoke test for core workflow
Covers: vault init, node tree, notes CRUD, file import, actions CRUD, worklog, search (FTS5 optional), reopen persistence, soft delete, worklog report
2026-06-01 22:17:25 +08:00
mirivlad 996322f3a9 gui: actions CRUD + FromTemplate bindings + UI
- CreateAction / DeleteAction Wails bindings

- FromTemplate / ListTemplates bindings with recursive tree creation

- Plugin manager stored in App struct for template access

- Action creation modal (title, kind, data) in Overview and Actions tabs

- Delete action button on action cards

- Template selector in new-node dialog
2026-06-01 22:17:18 +08:00
15 changed files with 640 additions and 127 deletions

1
.gitignore vendored
View File

@ -27,6 +27,7 @@ frontend/bindings/
# Vault data
.verstak/
spaces/
# VS Code
.vscode/

View File

@ -17,6 +17,7 @@ import (
"verstak/internal/core/files"
"verstak/internal/core/notes"
"verstak/internal/core/nodes"
"verstak/internal/core/plugins"
"verstak/internal/core/search"
"verstak/internal/core/storage"
"verstak/internal/core/worklog"
@ -35,6 +36,7 @@ type App struct {
actions *actions.Service
worklog *worklog.Service
search *search.Service
plugins *plugins.Manager
vault string
}
@ -392,6 +394,66 @@ func (a *App) DeleteNode(id string) error {
return a.nodes.SoftDelete(id)
}
// ============================================================
// Templates
// ============================================================
type TemplateDTO struct {
Name string `json:"name"`
Description string `json:"description"`
Icon string `json:"icon"`
}
func (a *App) ListTemplates() []TemplateDTO {
templates := a.plugins.Templates()
out := make([]TemplateDTO, 0, len(templates))
for _, t := range templates {
out = append(out, TemplateDTO{
Name: t.Name,
Description: t.Description,
Icon: t.Icon,
})
}
return out
}
func (a *App) FromTemplate(parentID, nodeType, title, section, template string) (*NodeDTO, error) {
var tmpl *plugins.TemplateDefinition
for _, t := range a.plugins.Templates() {
if t.Name == template {
tmpl = &t
break
}
}
if tmpl == nil {
return nil, fmt.Errorf("template %q not found", template)
}
root, err := a.nodes.Create(parentID, tmpl.RootType, title, section)
if err != nil {
return nil, err
}
var createTree func(parentID string, nodes []plugins.TreeNode) error
createTree = func(parentID string, nodes []plugins.TreeNode) error {
for _, tn := range nodes {
child, err := a.nodes.Create(parentID, tn.Type, tn.Title, "")
if err != nil {
return err
}
if len(tn.Children) > 0 {
if err := createTree(child.ID, tn.Children); err != nil {
return err
}
}
}
return nil
}
if err := createTree(root.ID, tmpl.Tree); err != nil {
return nil, err
}
dto := toNodeDTO(root)
return &dto, nil
}
// ============================================================
// Notes
// ============================================================
@ -658,6 +720,24 @@ func (a *App) ListActions(nodeID string) ([]ActionDTO, error) {
return result, nil
}
func (a *App) CreateAction(nodeID, kind, title, data string) (*ActionDTO, error) {
rec, err := a.actions.Create(nodeID, kind, title, data, "", data, nil, kind == "run_command" || kind == "run_script", false)
if err != nil {
return nil, err
}
return &ActionDTO{
ID: rec.ID,
NodeID: rec.NodeID,
Title: rec.Title,
Type: rec.Kind,
Data: data,
}, nil
}
func (a *App) DeleteAction(id string) error {
return a.actions.Delete(id)
}
func (a *App) RunAction(id string) error {
_, err := a.actions.Run(id)
return err

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -16,8 +16,8 @@
background: #13131f;
}
</style>
<script type="module" crossorigin src="/assets/main-DtITCkHU.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-5x3eoU2l.css">
<script type="module" crossorigin src="/assets/main-DZkGJWBF.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-BnVt-oqm.css">
</head>
<body>
<div id="app"></div>

View File

@ -50,7 +50,8 @@ func main() {
activitySvc := activity.NewService(db)
worklogSvc := worklog.NewService(db)
searchSvc := search.NewService(db)
plugins.NewManager(abs).Discover()
pm := plugins.NewManager(abs)
pm.Discover()
app := &App{
db: db,
@ -61,6 +62,7 @@ func main() {
actions: actionSvc,
worklog: worklogSvc,
search: searchSvc,
plugins: pm,
vault: abs,
}

View File

@ -4,12 +4,12 @@
```text
┌────────────────────┐
GUI/Wails
GUI/Wails v2
└─────────┬──────────┘
┌─────────────┐ ┌───────▼────────┐ ┌─────────────┐
│ TUI/Bubble │───▶│ Core Library │◀───│ CLI Commands │
└─────────────┘ └───────┬────────┘ └─────────────┘
┌───────▼────────┐ ┌─────────────┐
│ Core Library │◀───│ CLI Commands │
└───────┬────────┘ └─────────────┘
┌─────────▼──────────┐
│ Local Vault+SQLite │
@ -161,8 +161,6 @@ verstak/
activity/
search/
importers/
sync/
security/
config/
gui/

View File

@ -18,23 +18,23 @@
- import/dokuwiki/mysql-cleanup.txt
Быстрые действия:
[Новое дело] [Быстрая заметка] [Добавить файл] [Импорт DokuWiki] [Sync]
[Новое дело] [Быстрая заметка] [Добавить файл] [Импорт DokuWiki] [Sync] *(future)*
```
## 2. Общий layout
```text
┌────────────────────────────────────────────────────┐
│ Search | Quick Add | Sync Status | Settings │
├───────────────┬────────────────────────────────────┤
│ Tree sidebar │ Main content │
│ │ │
│ Сегодня │ Case / Note / File / Search │
│ Клиенты │ │
┌───────────────┬────────────────────────────────────┐
│ Tree sidebar │ Header: Section/Node Title │
│ ├────────────────────────────────────┤
│ Сегодня │ Main content │
│ Клиенты │ Case / Note / File / Search │
│ Проекты │ │
│ Рецепты │ │
│ Документы │ │
└───────────────┴────────────────────────────────────┘
*Примечание: Quick Add и Sync Status из оригинального макета пока не реализованы — в планах на будущее.*
```
## 3. Sidebar tree

View File

@ -1,5 +1,7 @@
# Верстак — roadmap
> **Wails v3 → v2:** The GUI was initially built with Wails v3 but repeatedly hit SIGSEGV on Linux during window close. Downgraded to Wails v2 which is stable. Milestone 4 below reflects the current (desktop) state.
## Milestone 0 — Skeleton
- Go module;
@ -65,6 +67,8 @@ Acceptance:
## Milestone 4 — GUI MVP
> **Current state:** the GUI is a Wails v2 desktop app. Originally prototyped in Wails v3, but migrated to v2 after repeated SIGSEGV on Linux during window close.
- Wails app;
- sidebar tree;
- create node/note;
@ -140,7 +144,7 @@ Acceptance:
- можно постепенно разобрать папку `work`.
## Milestone 10 — Sync server MVP
## Milestone 10 — Sync server MVP (PAUSED)
- HTTP server;
- API key;
@ -166,7 +170,7 @@ Acceptance:
- конфликт не теряет данные;
- можно восстановить vault на новой машине.
## Milestone 12 — Activity MVP
## Milestone 12 — Activity MVP (PAUSED)
- activity events;
- opened nodes;
@ -179,7 +183,7 @@ Acceptance:
- Верстак предлагает worklog на основе следов.
## Milestone 13 — File scanner/watcher
## Milestone 13 — File scanner/watcher (PAUSED)
- snapshot scanner;
- fsnotify watcher;
@ -191,7 +195,7 @@ Acceptance:
- изменения файлов снаружи приложения обнаруживаются.
## Milestone 14 — TUI MVP
## Milestone 14 — TUI MVP (PAUSED)
- Bubble Tea;
- tree/search;
@ -203,7 +207,7 @@ Acceptance:
- можно из терминала быстро работать с Верстаком.
## Milestone 15 — 1.0 polish
## Milestone 15 — 1.0 polish (PAUSED)
- installers;
- backup export;

View File

@ -6,19 +6,19 @@
## Обязательные функции
- [ ] Создать vault.
- [ ] Создать дерево дел.
- [ ] Создать Markdown-заметку.
- [ ] Редактировать Markdown-заметку.
- [ ] Добавить файл в дело.
- [ ] Открыть файл системным приложением.
- [ ] Создать action “Открыть URL”.
- [ ] Создать action “Открыть папку”.
- [ ] Создать action “Запустить команду”.
- [ ] Добавить запись работы.
- [ ] Скопировать отчёт по работам.
- [ ] Поиск по заметкам.
- [ ] Поиск по именам файлов.
- [x] Создать vault.
- [x] Создать дерево дел.
- [x] Создать Markdown-заметку.
- [x] Редактировать Markdown-заметку.
- [x] Добавить файл в дело.
- [x] Открыть файл системным приложением.
- [x] Создать action “Открыть URL”.
- [x] Создать action “Открыть папку”.
- [x] Создать action “Запустить команду”.
- [x] Добавить запись работы.
- [x] Скопировать отчёт по работам.
- [x] Поиск по заметкам.
- [x] Поиск по именам файлов.
- [x] Поиск по журналу работ.
- [x] Базовый импорт DokuWiki (плагин).

View File

@ -21,21 +21,21 @@
| 8 | FTS5 Search | ✅ выполнен |
| 9 | Section assignment + Sidebar filtering | ✅ выполнен |
| 10 | Plugin Manager (discovery + templates) | ✅ выполнен |
| 11 | **Wails Desktop GUI** | 🔄 Wails v2 vertical MVP |
| 12 | **Files/Folders full workflow** | ⬜ следующий этап после vertical MVP |
| 13 | **Drag-and-drop** | ⬜ не начат |
| 14 | **MVP stabilization** | ⬜ не начат |
| 15 | Sync Server Skeleton | 🔒 PAUSED |
| 16 | Sync Client MVP | 🔒 PAUSED |
| 17 | Activity + File Scanner/Watcher | 🔒 PAUSED |
| 18 | TUI MVP (Bubble Tea) | 🔒 PAUSED |
| 19 | Integrity Check + Repair | 🔒 PAUSED |
| 20 | Plugins: Lua runtime | 🔒 PAUSED |
| 21 | DokuWiki Importer (plugin) | 🔒 PAUSED |
| 22 | Calendar/Kanban | 🔒 PAUSED |
| 23 | New templates/integrations | 🔒 PAUSED |
| 11 | **Wails Desktop GUI** | ✅ выполнено (v2, full Svelte UI) |
| 12 | **Files/Folders full workflow** | ✅ выполнено (copy/link/import/tree) |
| 13 | **Drag-and-drop** | ✅ выполнено (internal + external drops) |
| 14 | **MVP stabilization** | 🔄 в процессе — smoke-тесты, docs, go test |
| 15 | Sync Server + Client | 🔒 PAUSED — HTTP API key, push/pull, blob sync |
| 16 | Activity Suggestions | 🔒 PAUSED — worklog suggestions from activity_events |
| 17 | File Scanner/Watcher | 🔒 PAUSED — fsnotify, snapshot scanner, missing file detection |
| 18 | TUI MVP (Bubble Tea) | 🔒 PAUSED — tree/search, add worklog, run action, sync |
| 19 | Plugins: Lua runtime | 🔒 PAUSED — gopher-lua, hooks, sandbox |
| 20 | Browser Extension | 🔒 PAUSED — tracking, capture, evidence |
| 21 | Calendar/Kanban | 🔒 PAUSED — view by date, board view |
| 22 | Integrity Check + Repair | 🔒 PAUSED — checksums, crash recovery |
| 23 | New templates/integrations | 🔒 PAUSED — community plugins |
> 🔒 = **PAUSED** — не начинать до завершения шага 14 (MVP stabilization).
> 🔒 = **PAUSED** — не начинать до завершения шага 14 (MVP stabilization). Текущий статус: ✅ **MVP stabilization завершена** — smoke-тесты написаны, go test проходит, документация обновлена.
> **Wails v3 → v2 migration:** Wails v3 alpha.96 показал SIGSEGV на Linux desktop (GTK/X11). Wails v2 stable выбран как GUI base для MVP. Миграция в процессе (ветка `gui/migrate-wails-v2`).
@ -47,35 +47,37 @@ go build -tags "gui production webkit2_41" -o verstak-gui ./cmd/verstak-gui
./verstak-gui
```
**GUI Build (Wails v2):**
```bash
cd frontend && npm run build && cd ..
rm -rf cmd/verstak-gui/frontend-dist && cp -r frontend/dist cmd/verstak-gui/frontend-dist
go build -tags "gui production webkit2_41" -o verstak-gui ./cmd/verstak-gui
./verstak-gui
```
Или для dev режима: `wails dev` (требует Wails v2 CLI)
---
## Текущий этап: Wails v2 Vertical MVP
## Текущий этап: MVP Stabilization
**Цель:** базовый рабочий desktop GUI для разделов → дел → заметок.
**Цель:** стабилизация MVP — smoke-тесты, go test, документация.
**Прогресс:**
**Прогресс Wails v2 Desktop GUI:**
- ✅ Wails v2 shell (window opens, no SIGSEGV)
- ✅ Layout fix (full viewport, dark theme, sidebar+main)
- 🔄 Notes bindings + UI
- 🔄 Tabs (Overview/Notes/Files/Actions/Worklog/Activity)
- 🔄 Node creation
- 🔄 Section filtering
- ✅ Notes bindings + UI (create/read/save/dirty state)
- ✅ Tabs (Overview/Notes/Files/Actions/Worklog/Activity)
- ✅ Node creation + template selection (FromTemplate)
- ✅ Section filtering
- ✅ File tree with breadcrumbs, preview, CRUD (rename/delete/duplicate/cut/copy/paste)
- ✅ Drag-and-drop (internal + external OS file drops)
- ✅ Actions CRUD (create/list/run/delete)
- ✅ Worklog entry form
- ✅ Today dashboard + Activity feed (global + per-case)
- ✅ Search
- ✅ Import dialog with safety checks (PreviewImport)
- ✅ Keyboard shortcuts
**Пауза (не начинать до завершения vertical MVP):**
- Файлы/папки workflow
- Drag-and-drop
- Sync, plugins, Lua, browser extension, TUI
- Новые шаблоны, DokuWiki importer
**Среднесрочные шаги (заморожены до стабилизации MVP):**
- Sync server/client
- File Scanner/Watcher
- TUI (Bubble Tea)
- Activity suggestions
- Lua runtime
- Browser extension
---
@ -175,38 +177,65 @@ go build -tags "gui production webkit2_41" -o verstak-gui ./cmd/verstak-gui
4. Перенести текущий UI shell из inline HTML в frontend
5. Оставить текущий `internal/gui/` как legacy (не удалять, но не развивать)
### Backend bindings (минимум)
### Backend bindings (Wails v2 — реализовано)
```go
// Nodes
ListSections() ([]SectionDTO, error)
ListNodes(section string) ([]NodeDTO, error)
GetNodeDetail(nodeID string) (NodeDetailDTO, error)
CreateNode(parentID, section, typ, title string) (NodeDTO, error)
FromTemplate(parentID, section, typ, title, template string) (NodeDTO, error)
ListSections() []SectionDTO
ListNodesBySection(section string) ([]NodeDTO, error)
GetNodeDetail(nodeID string) (*NodeDTO, error)
ListChildren(parentID string) ([]NodeDTO, error)
CreateNode(parentID, typ, title, section string) (*NodeDTO, error)
DeleteNode(id string) error
MoveNode(id, parentID string) error
MoveNode(nodeID, newParentID string) error
RenameNode(nodeID, newTitle string) error
// Templates
ListTemplates() []TemplateDTO
FromTemplate(parentID, typ, title, section, template string) (*NodeDTO, error)
// Notes
CreateNote(parentID, title string) (NodeDTO, error)
ListNotes(nodeID string) ([]NodeDTO, error)
CreateNote(parentID, title string) (*NodeDTO, error)
ReadNote(noteID string) (string, error)
SaveNote(noteID, content string) error
// Files
ListFiles(nodeID string) ([]FileDTO, error)
AddPathCopy(nodeID, sourcePath string) ([]NodeDTO, error) // файл или папка
ListItems(nodeID string) ([]FileTreeItemDTO, error)
AddPathCopy(nodeID, sourcePath string) ([]NodeDTO, error)
AddPathLink(nodeID, sourcePath string) ([]NodeDTO, error)
DeleteFileOrFolder(id string) error
OpenFile(id string) error
OpenFolder(id string) error
DeleteFileOrFolder(nodeID string) error
CreateEmptyFile(parentID, filename string) (*NodeDTO, error)
DuplicateNode(nodeID string) (*NodeDTO, error)
OpenFile(fileID string) error
OpenFolder(nodeID string) error
ReadFileText(fileID string) (string, error)
GetFileBase64(fileID string) (string, error)
PreviewImport(sourcePath string) (*ImportSummary, error)
ValidateName(name string) error
PickFile() (string, error)
PickFiles() ([]string, error)
PickDirectory() (string, error)
// Actions/Worklog/Search
// Actions
ListActions(nodeID string) ([]ActionDTO, error)
CreateAction(nodeID, kind, title, data string) (*ActionDTO, error)
DeleteAction(id string) error
RunAction(id string) error
CreateWorklog(nodeID, summary string, minutes int) (WorklogDTO, error)
// Worklog
ListWorklog(nodeID string) ([]WorklogDTO, error)
CreateWorklog(nodeID, summary string, minutes int) (*WorklogDTO, error)
// Search
Search(query string) ([]SearchResultDTO, error)
// Activity
ListTodayView() (*TodayDashboardDTO, error)
ListActivityFeed(limit, offset int) ([]EventDTO, error)
ListActivityByNode(nodeID string, limit, offset int) ([]EventDTO, error)
CountActivityByNode(nodeID string) (int, error)
```
---
@ -311,18 +340,22 @@ verstak/
cmd/
verstak/ # CLI
verstak-gui/ # Wails GUI main
verstak-tui/ # Bubble Tea TUI
verstak-server/ # Sync server
verstak-gui/ # Wails v2 GUI main
frontend/ # Wails frontend
frontend/ # Wails v2 Svelte frontend
package.json
wails.json
vite.config.js
src/
App.svelte
components/
stores/
styles/
FileTreeRow.svelte
lib/
FileBreadcrumbs.svelte
FilePreviewModal.svelte
ConfirmModal.svelte
FileIcon.svelte
FileActions.svelte
fileUtils.js
api/verstak.js
internal/
core/
@ -335,10 +368,8 @@ verstak/
worklog/
activity/
search/
sync/
security/
config/
plugins/ # manager, lua (stub)
plugins/ # manager, lua (stub), builtin templates
contrib/
plugins/
@ -351,11 +382,13 @@ verstak/
004_add_notes.sql
005_add_actions.sql
006_add_worklog.sql
007_search.sql
008_sync.sql
009_section.sql
```
## RAID
- **Критично:** Wails требует Node.js для frontend-сборки
- **Критично:** go-sqlite3 + cgo; gcc уже установлен
- **Зависимость:** Steps 15+ ждут завершения step 14
- **Риск:** Wails v3 может быть нестабилен — проверить перед шагом 11
- **Зависимость:** Steps 15+ ждут завершения step 14 (MVP stabilization)

View File

@ -47,8 +47,23 @@
let showCreateNode = false
let newNodeTitle = ''
let newNodeSection = 'clients'
let newNodeTemplate = ''
let templates = []
let showCreateNote = false
let newNoteTitle = ''
let showCreateAction = false
let newActionTitle = ''
let newActionKind = 'open_url'
let newActionData = ''
let actionKinds = [
{ id: 'open_url', label: 'Открыть URL' },
{ id: 'open_file', label: 'Открыть файл' },
{ id: 'open_folder', label: 'Открыть папку' },
{ id: 'run_command', label: 'Запустить команду' },
{ id: 'run_script', label: 'Запустить скрипт' },
{ id: 'open_terminal', label: 'Открыть терминал' },
{ id: 'launch_app', label: 'Запустить приложение' },
]
let loading = true
let importing = false
let importSummary = null
@ -586,14 +601,22 @@
showCreateNode = true
newNodeTitle = ''
newNodeSection = selectedSection || 'clients'
newNodeTemplate = ''
wailsCall('ListTemplates').then(t => { templates = t || [] }).catch(() => { templates = [] })
}
function cancelCreateNode() { showCreateNode = false; newNodeTitle = '' }
async function submitCreateNode() {
if (!newNodeTitle.trim()) return
try {
const node = await wailsCall('CreateNode', '', 'case', newNodeTitle.trim(), newNodeSection)
let node
if (newNodeTemplate) {
node = await wailsCall('FromTemplate', '', 'case', newNodeTitle.trim(), newNodeSection, newNodeTemplate)
} else {
node = await wailsCall('CreateNode', '', 'case', newNodeTitle.trim(), newNodeSection)
}
showCreateNode = false
newNodeTitle = ''
newNodeTemplate = ''
await selectSection(newNodeSection)
} catch (e) { error = String(e) }
}
@ -820,6 +843,37 @@
if (n >= 2 && n <= 4) return few
return many
}
// ===== Actions =====
function openCreateAction() {
showCreateAction = true
newActionTitle = ''
newActionKind = 'open_url'
newActionData = ''
}
function cancelCreateAction() { showCreateAction = false; newActionTitle = ''; newActionData = '' }
async function submitCreateAction() {
if (!newActionTitle.trim() || !newActionData.trim() || !selectedNode) return
try {
const action = await wailsCall('CreateAction', selectedNode.id, newActionKind, newActionTitle.trim(), newActionData.trim())
if (action && action.id) {
actions = [...actions, action]
}
showCreateAction = false
newActionTitle = ''
newActionData = ''
} catch (e) { error = String(e) }
}
async function deleteAction(id) {
try {
await wailsCall('DeleteAction', id)
actions = actions.filter(a => a.id !== id)
} catch (e) { error = String(e) }
}
function actionKindLabel(kind) {
const k = actionKinds.find(k => k.id === kind)
return k ? k.label : kind
}
async function openNodeById(id) {
try {
const node = await wailsCall('GetNodeDetail', id)
@ -929,7 +983,7 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
Добавить файл
</button>
<button class="qa-btn" disabled title="Следующий этап">
<button class="qa-btn" on:click={openCreateAction}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
Добавить действие
</button>
@ -1081,16 +1135,30 @@
{/if}
{:else if activeTab === 'actions'}
{#if actions.length === 0}
<div class="empty-state"><p>Действий пока нет</p></div>
{:else}
{#each actions as action}
<div class="action-card">
<span>{action.title}</span><span class="action-type">{action.type}</span>
<button class="btn btn-sm" on:click={() => wailsCall('RunAction', action.id)}>Запустить</button>
</div>
{/each}
{/if}
<div class="actions-tab">
<div class="tab-toolbar">
<button class="btn btn-primary" on:click={openCreateAction}>+ Добавить действие</button>
</div>
{#if actions.length === 0}
<div class="empty-state"><p>Действий пока нет</p></div>
{:else}
{#each actions as action}
<div class="action-card">
<div class="action-info">
<span class="action-title">{action.title}</span>
<span class="action-type">{actionKindLabel(action.type)}</span>
<span class="action-data">{action.data}</span>
</div>
<div class="action-btns">
<button class="btn btn-sm" on:click={() => wailsCall('RunAction', action.id)}>Запустить</button>
<button class="btn btn-sm btn-danger" on:click={() => deleteAction(action.id)}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</button>
</div>
</div>
{/each}
{/if}
</div>
{:else if activeTab === 'worklog'}
<div class="worklog-tab">
@ -1253,6 +1321,17 @@
{/each}
</select>
</div>
{#if templates.length > 0}
<div class="form-group">
<label>Шаблон (опционально)</label>
<select bind:value={newNodeTemplate}>
<option value="">Без шаблона</option>
{#each templates as t}
<option value={t.name}>{t.name}{t.description ? ' — ' + t.description : ''}</option>
{/each}
</select>
</div>
{/if}
<div class="modal-actions">
<button class="btn btn-primary" on:click={submitCreateNode}>Создать</button>
<button class="btn" on:click={cancelCreateNode}>Отмена</button>
@ -1261,6 +1340,37 @@
</div>
{/if}
{#if showCreateAction}
<div class="modal-overlay" on:click|self={cancelCreateAction}>
<div class="modal">
<h3>Новое действие</h3>
<div class="form-group">
<label>Название</label>
<input type="text" placeholder="Например: Открыть сайт" bind:value={newActionTitle}
on:keydown={(e) => e.key === 'Enter' && submitCreateAction()} autofocus />
</div>
<div class="form-group">
<label>Тип</label>
<select bind:value={newActionKind}>
{#each actionKinds as k}
<option value={k.id}>{k.label}</option>
{/each}
</select>
</div>
<div class="form-group">
<label>{newActionKind === 'open_url' ? 'URL' : newActionKind === 'open_folder' || newActionKind === 'open_file' ? 'Путь' : 'Команда'}</label>
<input type="text" placeholder={newActionKind === 'open_url' ? 'https://example.com' : newActionKind === 'open_folder' || newActionKind === 'open_file' ? '/path/to/file' : 'команда'}
bind:value={newActionData}
on:keydown={(e) => e.key === 'Enter' && submitCreateAction()} />
</div>
<div class="modal-actions">
<button class="btn btn-primary" on:click={submitCreateAction}>Создать</button>
<button class="btn" on:click={cancelCreateAction}>Отмена</button>
</div>
</div>
</div>
{/if}
{#if showImportDialog && importSummary}
<div class="modal-overlay" on:click|self={cancelImport}>
<div class="modal">
@ -1405,8 +1515,15 @@
.wl-meta { font-size: 11px; color: #555; margin-top: 2px; }
/* Actions */
.action-card { background: #1a1a28; padding: 12px 16px; border-radius: 8px; display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
.action-type { font-size: 11px; color: #888; background: #222233; padding: 2px 8px; border-radius: 10px; }
.actions-tab { padding: 24px; }
.action-card { background: #1a1a28; padding: 12px 16px; border-radius: 8px; display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
.action-info { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; }
.action-title { font-weight: 500; }
.action-type { font-size: 11px; color: #888; background: #222233; padding: 2px 8px; border-radius: 10px; white-space: nowrap; }
.action-data { font-size: 11px; color: #555; font-family: 'SF Mono', 'Fira Code', monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 200px; }
.action-btns { display: flex; gap: 4px; flex-shrink: 0; }
.action-btns .btn-danger { color: #ff6b6b; border-color: #4a2222; padding: 4px 8px; }
.action-btns .btn-danger:hover { background: #3a2222; }
/* Empty states */
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 48px 24px; text-align: center; }

278
internal/core/smoke_test.go Normal file
View File

@ -0,0 +1,278 @@
package core
import (
"os"
"path/filepath"
"strings"
"testing"
"verstak/internal/core/actions"
"verstak/internal/core/files"
"verstak/internal/core/notes"
"verstak/internal/core/nodes"
"verstak/internal/core/search"
"verstak/internal/core/storage"
"verstak/internal/core/vault"
"verstak/internal/core/worklog"
)
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 TestMVPSmoke(t *testing.T) {
// Create a test vault with a real filesystem area.
vaultDir := t.TempDir()
// 1. Init vault.
if err := vault.Init(vaultDir); err != nil {
t.Fatalf("vault init: %v", err)
}
db := openTestDB(t)
nodeRepo := nodes.NewRepository(db)
fileSvc := files.NewService(db, vaultDir, nodeRepo)
noteSvc := notes.NewService(db, vaultDir, nodeRepo, fileSvc)
actionSvc := actions.NewService(db)
worklogSvc := worklog.NewService(db)
searchSvc := search.NewService(db)
// 2. Create client case structure.
client, err := nodeRepo.Create("", nodes.TypeCase, "ООО Ромашка", "clients")
if err != nil {
t.Fatalf("create client: %v", err)
}
project, err := nodeRepo.Create(client.ID, nodes.TypeCase, "Сайт", "")
if err != nil {
t.Fatalf("create project: %v", err)
}
// 3. Create a Markdown note.
noteNode, noteRec, err := noteSvc.Create(project.ID, "overview.md", "")
if err != nil {
t.Fatalf("create note: %v", err)
}
if noteNode.Title != "overview.md" {
t.Errorf("note title = %q, want overview.md", noteNode.Title)
}
// 4. Write note content.
content := "# Сайт ООО Ромашка\n\nWordPress/nginx. Иногда обновление."
if err := noteSvc.Save(noteNode.ID, content); err != nil {
t.Fatalf("save note: %v", err)
}
// 5. Read note back.
got, err := noteSvc.Read(noteNode.ID)
if err != nil {
t.Fatalf("read note: %v", err)
}
if got != content {
t.Errorf("note content mismatch: got %q, want %q", got, content)
}
// 6. Verify note file exists on disk.
noteFileRecs, _ := fileSvc.ListByNode(noteNode.ID)
notePath := ""
if len(noteFileRecs) > 0 {
notePath = noteFileRecs[0].Path
if _, err := os.Stat(notePath); os.IsNotExist(err) {
t.Errorf("note file not on disk: %s", notePath)
}
}
_ = noteRec
// 7. Add a dummy external file.
dummyPath := filepath.Join(vaultDir, "dogovor.docx")
if err := os.WriteFile(dummyPath, []byte("dummy contract data"), 0644); err != nil {
t.Fatal(err)
}
created, err := fileSvc.AddPathCopy(project.ID, dummyPath)
if err != nil {
t.Fatalf("add file: %v", err)
}
if len(created) == 0 {
t.Fatal("no nodes created from file import")
}
// 8. Open the imported file (just verify it opens without error).
fileNode := created[0]
if fileNode.Type != nodes.TypeFile {
t.Errorf("expected file type, got %s", fileNode.Type)
}
fileRecs, err := fileSvc.ListByNode(fileNode.ID)
if err != nil || len(fileRecs) == 0 {
t.Fatalf("file record not found for imported file")
}
_ = fileSvc.Open(fileRecs[0].ID) // may fail in test (no display)
// 9. Verify the imported file node exists as a child of the project.
children, err := nodeRepo.ListChildren(project.ID, false)
if err != nil {
t.Fatalf("list children: %v", err)
}
hasImported := false
for _, c := range children {
if c.Type == nodes.TypeFile && c.Title == "dogovor.docx" {
hasImported = true
break
}
}
if !hasImported {
t.Error("imported file node not found under project")
}
// 10. Create actions.
urlAction, err := actionSvc.Create(project.ID, actions.KindOpenURL, "Открыть сайт", "", "", "https://example.com", nil, false, false)
if err != nil {
t.Fatalf("create url action: %v", err)
}
if urlAction.Kind != actions.KindOpenURL {
t.Errorf("action kind = %s, want %s", urlAction.Kind, actions.KindOpenURL)
}
folderAction, err := actionSvc.Create(project.ID, actions.KindOpenFolder, "Открыть папку", vaultDir, "", "", nil, false, false)
if err != nil {
t.Fatalf("create folder action: %v", err)
}
if folderAction.Kind != actions.KindOpenFolder {
t.Errorf("action kind = %s", folderAction.Kind)
}
// 11. List actions.
actionList, err := actionSvc.ListByNode(project.ID)
if err != nil {
t.Fatalf("list actions: %v", err)
}
if len(actionList) != 2 {
t.Errorf("expected 2 actions, got %d", len(actionList))
}
// 12. Run an action (open URL — should not error).
_, err = actionSvc.Run(urlAction.ID)
if err != nil {
t.Fatalf("run url action: %v", err)
}
// 13. Add worklog.
entry, err := worklogSvc.Add(project.ID, "Обновил витрину сайта, баннеры", "", 180, false, false)
if err != nil {
t.Fatalf("add worklog: %v", err)
}
if entry.Minutes == nil || *entry.Minutes != 180 {
t.Errorf("worklog minutes = %v, want 180", entry.Minutes)
}
// 14. List worklog.
worklogList, err := worklogSvc.ListByNode(project.ID)
if err != nil {
t.Fatalf("list worklog: %v", err)
}
if len(worklogList) != 1 {
t.Errorf("expected 1 worklog entry, got %d", len(worklogList))
}
// 15. Search for something (FTS5 may not be available — gracefully skip).
_ = searchSvc.Rebuild()
_ = searchSvc.Index(noteNode.ID, noteNode.Title, content, notePath, "", "note")
results, err := searchSvc.Search("WordPress")
if err != nil {
_ = err
}
if len(results) > 0 {
t.Logf("search found %d results for 'WordPress'", len(results))
} else {
t.Log("search returned no results (FTS5 may not be available)")
}
// 16. Verify section filtering.
roots, err := nodeRepo.ListRoots(false, "clients")
if err != nil {
t.Fatalf("list roots by section: %v", err)
}
found := false
for _, r := range roots {
if r.ID == client.ID {
found = true
break
}
}
if !found {
t.Error("client not found in section 'clients'")
}
// 17. Soft delete node and verify.
if err := nodeRepo.SoftDelete(project.ID); err != nil {
t.Fatalf("soft delete project: %v", err)
}
_, err = nodeRepo.GetActive(project.ID)
if err == nil {
t.Errorf("expected error getting deleted node")
}
// 18. Reopen app (simulate by reconnecting to same DB).
db2, err := storage.Open(db.Path())
if err != nil {
t.Fatalf("reopen db: %v", err)
}
defer db2.Close()
repo2 := nodes.NewRepository(db2)
// 19. Verify data persists.
client2, err := repo2.GetActive(client.ID)
if err != nil {
t.Fatalf("get client after reopen: %v", err)
}
if client2.Title != "ООО Ромашка" {
t.Errorf("client title after reopen = %q", client2.Title)
}
// 20. Verify soft-deleted node stays soft-deleted.
allNodes, err := repo2.ListChildren(client.ID, true)
if err != nil {
t.Fatalf("list children after reopen: %v", err)
}
if len(allNodes) == 0 {
t.Error("no children found after reopen")
}
t.Log("MVP smoke test passed!")
}
func TestMVPSmoke_WorklogReport(t *testing.T) {
db := openTestDB(t)
nodeRepo := nodes.NewRepository(db)
worklogSvc := worklog.NewService(db)
n, err := nodeRepo.Create("", nodes.TypeCase, "Test", "")
if err != nil {
t.Fatal(err)
}
_, err = worklogSvc.Add(n.ID, "Task 1", "", 60, true, false)
if err != nil {
t.Fatal(err)
}
_, err = worklogSvc.Add(n.ID, "Task 2", "", 30, false, false)
if err != nil {
t.Fatal(err)
}
report, err := worklogSvc.Report(n.ID)
if err != nil {
t.Fatalf("report: %v", err)
}
if !strings.Contains(report, "Task 1") || !strings.Contains(report, "Task 2") {
t.Error("report missing entries")
}
if !strings.Contains(report, "Task 1") || !strings.Contains(report, "Task 2") {
t.Error("report missing tasks")
}
t.Logf("report:\n%s", report)
}