From dae53fcbbac0553f353618ad2de91775d3740d33 Mon Sep 17 00:00:00 2001 From: mirivlad Date: Sun, 31 May 2026 01:52:23 +0800 Subject: [PATCH] =?UTF-8?q?step=207:=20actions=20=E2=80=94=20table,=20serv?= =?UTF-8?q?ice,=20CLI,=20GUI=20tab=20+=20confirm=20dialog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migration 005: actions table (node_id, title, kind, command, args_json, working_dir, url, confirm_required, capture_output) - ActionService: Create, Get, ListByNode, Delete, Run Run dispatches: open_url/file/folder (xdg-open), run_command/script (exec.Command), open_terminal, launch_app - CLI: verstak action add/list/run/delete 'run' shows confirm prompt for confirm_required actions - GUI 'Действия' tab: button list with kind label, confirm_required opens editor overlay with action info + confirm, delete button - 7 unit tests for ActionService Acceptance: go build ./... pass, go test ./... pass. --- cmd/verstak/main.go | 204 +++++++++++++ internal/core/actions/action.go | 303 ++++++++++++++++++++ internal/core/actions/action_test.go | 154 ++++++++++ internal/core/storage/migrations_005.sql.go | 21 ++ internal/core/storage/storage.go | 3 +- internal/gui/index.html.go | 55 +++- internal/gui/server.go | 65 ++++- 7 files changed, 801 insertions(+), 4 deletions(-) create mode 100644 internal/core/actions/action.go create mode 100644 internal/core/actions/action_test.go create mode 100644 internal/core/storage/migrations_005.sql.go diff --git a/cmd/verstak/main.go b/cmd/verstak/main.go index 6ffcd48..abc5e5a 100644 --- a/cmd/verstak/main.go +++ b/cmd/verstak/main.go @@ -5,7 +5,10 @@ import ( "os" "path/filepath" "strconv" + "strings" + "verstak/internal/core/actions" + "verstak/internal/core/storage" "verstak/internal/core/vault" ) @@ -26,6 +29,8 @@ func main() { runInit(os.Args[2:]) case "node": runNode(os.Args[2:]) + case "action": + runAction(os.Args[2:]) default: fmt.Fprintf(os.Stderr, "Unknown command: %s\n", os.Args[1]) os.Exit(1) @@ -40,6 +45,7 @@ func usage() { fmt.Println("Commands:") fmt.Println(" init Initialize a new vault") fmt.Println(" node Manage nodes") + fmt.Println(" action Manage actions") fmt.Println(" --version Show version") fmt.Println(" --help Show this help") } @@ -191,3 +197,201 @@ func vaultPathFromFlags(args []string) string { } return "." } + +// --- action --- + +func runAction(args []string) { + if len(args) == 0 { + actionUsage() + os.Exit(1) + } + sub := args[0] + rest := args[1:] + switch sub { + case "add": + runActionAdd(rest) + case "list": + runActionList(rest) + case "run": + runActionRun(rest) + case "delete": + runActionDelete(rest) + case "--help", "-h": + actionUsage() + default: + fmt.Fprintf(os.Stderr, "Unknown action command: %s\n", sub) + os.Exit(1) + } +} + +func actionUsage() { + fmt.Println("verstak action — manage actions") + fmt.Println() + fmt.Println("Usage: verstak action [options]") + fmt.Println() + fmt.Println("Commands:") + fmt.Println(" add --node ID --kind KIND --title TITLE Add action") + fmt.Println(" list --node ID List actions for node") + fmt.Println(" run --id ID Run action") + fmt.Println(" delete --id ID Delete action") + fmt.Println() + fmt.Println("Kinds: open_url, open_file, open_folder, run_command, run_script, open_terminal, launch_app") +} + +func openActionDB(args []string) (*actions.Service, string, func()) { + vaultPath, _ := stringFlag(args, "--vault") + abs, _ := filepath.Abs(vaultPath) + dbPath := filepath.Join(abs, ".verstak", "index.db") + db, err := storage.Open(dbPath) + if err != nil { + fmt.Fprintf(os.Stderr, "Open vault: %v\n", err) + os.Exit(1) + } + svc := actions.NewService(db) + return svc, abs, func() { db.Close() } +} + +func runActionAdd(args []string) { + nodeID, _ := stringFlag(args, "--node") + kind, _ := stringFlag(args, "--kind") + title, _ := stringFlag(args, "--title") + cmd, _ := stringFlag(args, "--command") + url, _ := stringFlag(args, "--url") + workingDir, _ := stringFlag(args, "--working-dir") + vaultPath, _ := stringFlag(args, "--vault") + + if nodeID == "" || kind == "" || title == "" { + fmt.Fprintln(os.Stderr, "Error: --node, --kind and --title required") + actionUsage() + os.Exit(1) + } + + abs, _ := filepath.Abs(vaultPath) + dbPath := filepath.Join(abs, ".verstak", "index.db") + db, err := storage.Open(dbPath) + if err != nil { + fmt.Fprintf(os.Stderr, "Open vault: %v\n", err) + os.Exit(1) + } + defer db.Close() + + svc := actions.NewService(db) + rec, err := svc.Create(nodeID, kind, title, cmd, workingDir, url, nil, false, false) + if err != nil { + fmt.Fprintf(os.Stderr, "Add failed: %v\n", err) + os.Exit(1) + } + fmt.Printf("created\t%s\t%s\t%s\t%s\n", rec.ID, rec.Kind, rec.NodeID, rec.Title) +} + +func runActionList(args []string) { + nodeID, _ := stringFlag(args, "--node") + vaultPath, _ := stringFlag(args, "--vault") + + if nodeID == "" { + fmt.Fprintln(os.Stderr, "Error: --node required") + os.Exit(1) + } + + abs, _ := filepath.Abs(vaultPath) + dbPath := filepath.Join(abs, ".verstak", "index.db") + db, err := storage.Open(dbPath) + if err != nil { + fmt.Fprintf(os.Stderr, "Open vault: %v\n", err) + os.Exit(1) + } + defer db.Close() + + svc := actions.NewService(db) + list, err := svc.ListByNode(nodeID) + if err != nil { + fmt.Fprintf(os.Stderr, "List failed: %v\n", err) + os.Exit(1) + } + for _, a := range list { + fmt.Printf("%s\t%s\t%v\t%s\n", a.ID, a.Kind, a.ConfirmRequired, a.Title) + } +} + +func runActionRun(args []string) { + id, _ := stringFlag(args, "--id") + vaultPath, _ := stringFlag(args, "--vault") + + if id == "" { + fmt.Fprintln(os.Stderr, "Error: --id required") + os.Exit(1) + } + + abs, _ := filepath.Abs(vaultPath) + dbPath := filepath.Join(abs, ".verstak", "index.db") + db, err := storage.Open(dbPath) + if err != nil { + fmt.Fprintf(os.Stderr, "Open vault: %v\n", err) + os.Exit(1) + } + defer db.Close() + + svc := actions.NewService(db) + rec, err := svc.Get(id) + if err != nil { + fmt.Fprintf(os.Stderr, "Get failed: %v\n", err) + os.Exit(1) + } + + if rec.ConfirmRequired { + fmt.Printf("Action: %s\n", rec.Title) + fmt.Printf("Kind: %s\n", actions.KindLabel(rec.Kind)) + if rec.Command != "" { + fmt.Printf("Cmd: %s\n", rec.Command) + } + if rec.URL != "" { + fmt.Printf("URL: %s\n", rec.URL) + } + fmt.Print("Run? [y/N] ") + var input string + fmt.Scanln(&input) + if strings.ToLower(input) != "y" { + fmt.Println("Cancelled") + return + } + } + + result, err := svc.Run(id) + if err != nil { + fmt.Fprintf(os.Stderr, "Run failed: %v\n", err) + os.Exit(1) + } + if result.ExitCode != 0 { + fmt.Printf("exit=%d\n", result.ExitCode) + } + if result.Output != "" { + fmt.Print(result.Output) + } + fmt.Println("done") +} + +func runActionDelete(args []string) { + id, _ := stringFlag(args, "--id") + vaultPath, _ := stringFlag(args, "--vault") + + if id == "" { + fmt.Fprintln(os.Stderr, "Error: --id required") + os.Exit(1) + } + + abs, _ := filepath.Abs(vaultPath) + dbPath := filepath.Join(abs, ".verstak", "index.db") + db, err := storage.Open(dbPath) + if err != nil { + fmt.Fprintf(os.Stderr, "Open vault: %v\n", err) + os.Exit(1) + } + defer db.Close() + + svc := actions.NewService(db) + if err := svc.Delete(id); err != nil { + fmt.Fprintf(os.Stderr, "Delete failed: %v\n", err) + os.Exit(1) + } + fmt.Println("deleted") +} diff --git a/internal/core/actions/action.go b/internal/core/actions/action.go new file mode 100644 index 0000000..725888e --- /dev/null +++ b/internal/core/actions/action.go @@ -0,0 +1,303 @@ +package actions + +import ( + "database/sql" + "encoding/json" + "fmt" + "os/exec" + "runtime" + "strings" + "time" + + "verstak/internal/core/storage" + "verstak/internal/core/util" +) + +// Kind constants. +const ( + KindOpenURL = "open_url" + KindOpenFile = "open_file" + KindOpenFolder = "open_folder" + KindRunCommand = "run_command" + KindRunScript = "run_script" + KindOpenTerminal = "open_terminal" + KindLaunchApp = "launch_app" +) + +// Record represents an action attached to a node. +type Record struct { + ID string `json:"id"` + NodeID string `json:"node_id"` + Title string `json:"title"` + Kind string `json:"kind"` + Command string `json:"command,omitempty"` + Args []string `json:"args,omitempty"` + WorkingDir string `json:"working_dir,omitempty"` + URL string `json:"url,omitempty"` + ConfirmRequired bool `json:"confirm_required"` + CaptureOutput bool `json:"capture_output"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// RunResult is returned after executing an action. +type RunResult struct { + ExitCode int `json:"exit_code"` + Output string `json:"output,omitempty"` + Error string `json:"error,omitempty"` +} + +// Service manages actions. +type Service struct { + db *storage.DB +} + +// NewService creates an action service. +func NewService(db *storage.DB) *Service { + return &Service{db: db} +} + +// Create inserts a new action. +func (s *Service) Create(nodeID, kind, title, command, workingDir, url string, args []string, confirmRequired, captureOutput bool) (*Record, error) { + if nodeID == "" { + return nil, fmt.Errorf("node_id required") + } + if title == "" { + return nil, fmt.Errorf("title required") + } + if !isValidKind(kind) { + return nil, fmt.Errorf("invalid kind: %s", kind) + } + + argsJSON, _ := json.Marshal(args) + now := time.Now().UTC() + rec := &Record{ + ID: util.UUID7(), + NodeID: nodeID, + Title: title, + Kind: kind, + Command: command, + Args: args, + WorkingDir: workingDir, + URL: url, + ConfirmRequired: confirmRequired, + CaptureOutput: captureOutput, + CreatedAt: now, + UpdatedAt: now, + } + + _, err := s.db.Exec( + `INSERT INTO actions (id,node_id,title,kind,command,args_json,working_dir,url, + confirm_required,capture_output,created_at,updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`, + rec.ID, rec.NodeID, rec.Title, rec.Kind, rec.Command, string(argsJSON), + rec.WorkingDir, rec.URL, boolInt(rec.ConfirmRequired), boolInt(rec.CaptureOutput), + rec.CreatedAt.Format(time.RFC3339), rec.UpdatedAt.Format(time.RFC3339), + ) + if err != nil { + return nil, err + } + return rec, nil +} + +// Get returns an action by ID. +func (s *Service) Get(id string) (*Record, error) { + row := s.db.QueryRow( + `SELECT id,node_id,title,kind,command,args_json,working_dir,url, + confirm_required,capture_output,created_at,updated_at + FROM actions WHERE id = ?`, id) + return scanRecord(row) +} + +// ListByNode returns all actions for a node. +func (s *Service) ListByNode(nodeID string) ([]Record, error) { + rows, err := s.db.Query( + `SELECT id,node_id,title,kind,command,args_json,working_dir,url, + confirm_required,capture_output,created_at,updated_at + FROM actions WHERE node_id = ? ORDER BY created_at`, nodeID) + if err != nil { + return nil, err + } + defer rows.Close() + return scanRecords(rows) +} + +// Delete removes an action. +func (s *Service) Delete(id string) error { + res, err := s.db.Exec("DELETE FROM actions WHERE id=?", id) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n == 0 { + return fmt.Errorf("action not found") + } + return nil +} + +// Run executes the action. For dangerous actions (confirm_required), +// the caller should check ConfirmRequired before calling Run. +func (s *Service) Run(id string) (*RunResult, error) { + rec, err := s.Get(id) + if err != nil { + return nil, err + } + + switch rec.Kind { + case KindOpenURL: + return &RunResult{}, openWithSystem(rec.URL) + case KindOpenFile: + return &RunResult{}, openWithSystem(rec.Command) + case KindOpenFolder: + return &RunResult{}, openWithSystem(rec.Command) + case KindRunCommand, KindRunScript: + return runCommand(rec.Command, rec.Args, rec.WorkingDir, rec.CaptureOutput) + case KindOpenTerminal: + return runCommand("xdg-open", []string{"terminal"}, rec.WorkingDir, false) + case KindLaunchApp: + return runCommand(rec.Command, rec.Args, rec.WorkingDir, false) + default: + return nil, fmt.Errorf("unknown action kind: %s", rec.Kind) + } +} + +// --- helpers --- + +func isValidKind(k string) bool { + switch k { + case KindOpenURL, KindOpenFile, KindOpenFolder, KindRunCommand, + KindRunScript, KindOpenTerminal, KindLaunchApp: + return true + } + return false +} + +func boolInt(b bool) int { + if b { + return 1 + } + return 0 +} + +func openWithSystem(path string) error { + if path == "" { + return fmt.Errorf("empty path") + } + 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() +} + +func runCommand(command string, args []string, workingDir string, capture bool) (*RunResult, error) { + if command == "" { + return nil, fmt.Errorf("empty command") + } + cmd := exec.Command(command, args...) + if workingDir != "" { + cmd.Dir = workingDir + } + if capture { + out, err := cmd.CombinedOutput() + res := &RunResult{Output: string(out)} + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + res.ExitCode = exitErr.ExitCode() + } + res.Error = err.Error() + } + return res, nil + } + err := cmd.Start() + if err != nil { + return &RunResult{Error: err.Error()}, nil + } + return &RunResult{}, nil +} + +type scanner interface { + Scan(dest ...interface{}) error +} + +func scanRecord(s scanner) (*Record, error) { + var r Record + var argsJSON sql.NullString + var command, workingDir, url sql.NullString + var createdStr, updatedStr string + var confirmInt, captureInt int + + err := s.Scan( + &r.ID, &r.NodeID, &r.Title, &r.Kind, &command, &argsJSON, + &workingDir, &url, &confirmInt, &captureInt, + &createdStr, &updatedStr, + ) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("action not found") + } + if err != nil { + return nil, err + } + + if command.Valid { + r.Command = command.String + } + if workingDir.Valid { + r.WorkingDir = workingDir.String + } + if url.Valid { + r.URL = url.String + } + if argsJSON.Valid && argsJSON.String != "" { + _ = json.Unmarshal([]byte(argsJSON.String), &r.Args) + } + r.ConfirmRequired = confirmInt == 1 + r.CaptureOutput = captureInt == 1 + r.CreatedAt, _ = time.Parse(time.RFC3339, createdStr) + r.UpdatedAt, _ = time.Parse(time.RFC3339, updatedStr) + 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() +} + +// KindLabel returns a human-readable label for a kind. +func KindLabel(kind string) string { + m := map[string]string{ + KindOpenURL: "Открыть URL", + KindOpenFile: "Открыть файл", + KindOpenFolder: "Открыть папку", + KindRunCommand: "Запустить команду", + KindRunScript: "Запустить скрипт", + KindOpenTerminal: "Открыть терминал", + KindLaunchApp: "Запустить приложение", + } + if l, ok := m[kind]; ok { + return l + } + return kind +} + +// ValidKinds returns all valid kind constants. +func ValidKinds() []string { + return []string{KindOpenURL, KindOpenFile, KindOpenFolder, KindRunCommand, KindRunScript, KindOpenTerminal, KindLaunchApp} +} + +// Silence unused import. +var _ = strings.ToLower diff --git a/internal/core/actions/action_test.go b/internal/core/actions/action_test.go new file mode 100644 index 0000000..739384a --- /dev/null +++ b/internal/core/actions/action_test.go @@ -0,0 +1,154 @@ +package actions + +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 TestCreateAndGet(t *testing.T) { + db := openTestDB(t) + svc := NewService(db) + + rec, err := svc.Create("node-1", KindOpenURL, "Открыть сайт", "", "", "https://example.com", nil, false, false) + if err != nil { + t.Fatalf("Create: %v", err) + } + if rec.ID == "" { + t.Fatal("empty id") + } + if rec.Kind != KindOpenURL { + t.Errorf("kind = %q", rec.Kind) + } + if rec.URL != "https://example.com" { + t.Errorf("url = %q", rec.URL) + } + + got, err := svc.Get(rec.ID) + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.Title != "Открыть сайт" { + t.Errorf("title = %q", got.Title) + } + if !got.ConfirmRequired { + // default is false + } +} + +func TestCreateWithConfirm(t *testing.T) { + db := openTestDB(t) + svc := NewService(db) + + rec, err := svc.Create("node-1", KindRunCommand, "Backup", "./backup.sh", "/tmp", "", nil, true, true) + if err != nil { + t.Fatal(err) + } + if !rec.ConfirmRequired { + t.Error("expected confirm_required") + } + if !rec.CaptureOutput { + t.Error("expected capture_output") + } + if rec.Command != "./backup.sh" { + t.Errorf("command = %q", rec.Command) + } + if rec.WorkingDir != "/tmp" { + t.Errorf("working_dir = %q", rec.WorkingDir) + } +} + +func TestListByNode(t *testing.T) { + db := openTestDB(t) + svc := NewService(db) + + svc.Create("node-1", KindOpenURL, "A1", "", "", "https://a.com", nil, false, false) + svc.Create("node-1", KindOpenURL, "A2", "", "", "https://b.com", nil, false, false) + svc.Create("node-2", KindOpenURL, "B1", "", "", "https://c.com", nil, false, false) + + list1, err := svc.ListByNode("node-1") + if err != nil { + t.Fatal(err) + } + if len(list1) != 2 { + t.Errorf("node-1 actions = %d, want 2", len(list1)) + } + + list2, err := svc.ListByNode("node-2") + if err != nil { + t.Fatal(err) + } + if len(list2) != 1 { + t.Errorf("node-2 actions = %d, want 1", len(list2)) + } + + list3, err := svc.ListByNode("node-3") + if err != nil { + t.Fatal(err) + } + if len(list3) != 0 { + t.Errorf("node-3 actions = %d, want 0", len(list3)) + } +} + +func TestDelete(t *testing.T) { + db := openTestDB(t) + svc := NewService(db) + + rec, _ := svc.Create("node-1", KindOpenURL, "ToDelete", "", "", "https://x.com", nil, false, false) + + if err := svc.Delete(rec.ID); err != nil { + t.Fatalf("Delete: %v", err) + } + + if _, err := svc.Get(rec.ID); err == nil { + t.Error("expected error after delete") + } +} + +func TestGetNotFound(t *testing.T) { + db := openTestDB(t) + svc := NewService(db) + + if _, err := svc.Get("nonexistent"); err == nil { + t.Error("expected error") + } +} + +func TestKindLabel(t *testing.T) { + cases := map[string]string{ + KindOpenURL: "Открыть URL", + KindRunCommand: "Запустить команду", + KindOpenFolder: "Открыть папку", + "unknown": "unknown", + } + for kind, want := range cases { + got := KindLabel(kind) + if got != want { + t.Errorf("KindLabel(%q) = %q, want %q", kind, got, want) + } + } +} + +func TestValidKinds(t *testing.T) { + kinds := ValidKinds() + if len(kinds) != 7 { + t.Errorf("valid kinds = %d, want 7", len(kinds)) + } +} + +// Silence unused import. +var _ = os.Args diff --git a/internal/core/storage/migrations_005.sql.go b/internal/core/storage/migrations_005.sql.go new file mode 100644 index 0000000..56f7799 --- /dev/null +++ b/internal/core/storage/migrations_005.sql.go @@ -0,0 +1,21 @@ +package storage + +// migration005 — actions table for runnable actions attached to nodes. +const migration005 = ` +CREATE TABLE IF NOT EXISTS actions ( + id TEXT PRIMARY KEY, + node_id TEXT NOT NULL REFERENCES nodes(id), + title TEXT NOT NULL, + kind TEXT NOT NULL, + command TEXT NULL, + args_json TEXT NULL, + working_dir TEXT NULL, + url TEXT NULL, + confirm_required INTEGER NOT NULL DEFAULT 0, + capture_output INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_actions_node ON actions(node_id); +` diff --git a/internal/core/storage/storage.go b/internal/core/storage/storage.go index 7d5c822..0373d25 100644 --- a/internal/core/storage/storage.go +++ b/internal/core/storage/storage.go @@ -61,7 +61,8 @@ var migrationFiles = map[int]string{ 2: migration002, 3: migration003, 4: migration004, - // 5: migration005, etc. + 5: migration005, + // 6: migration006, etc. } func (db *DB) runInitialSchema() error { diff --git a/internal/gui/index.html.go b/internal/gui/index.html.go index d782a2d..e333a54 100644 --- a/internal/gui/index.html.go +++ b/internal/gui/index.html.go @@ -526,7 +526,7 @@ function switchTabNode(t){ else api('/api/nodes/'+id).then(d=>{nodeCache[id]={detail:d,ts:Date.now()};renderNodeDash(d)}); }else if(t==='notes') loadNodeNotes(id); else if(t==='files') loadNodeFiles(id); - else if(t==='actions') setCnt('
Действия — в разработке
'); + else if(t==='actions') loadNodeActions(id); else if(t==='worklog') setCnt('
Журнал — в разработке
'); else if(t==='activity') setCnt('
Активность — в разработке
'); } @@ -563,6 +563,48 @@ async function loadNodeFiles(nodeId){ function E(msg){setCnt('
'+esc(msg)+'
')} +async function loadNodeActions(nodeId){ + try{ + const list=await api('/api/actions?node='+nodeId); + if(!list.length){setCnt('
Нет действий.
');return} + let h='
'; + for(const a of list){ + h+='
'; + h+=''; + h+=''+esc(a.kind||'')+''; + h+=''; + h+='
'; + } + h+='
';setCnt(h); + }catch(e){E('Ошибка')} +} + +const AL={open_url:'URL',open_file:'Файл',open_folder:'Папка',run_command:'Команда',run_script:'Скрипт',open_terminal:'Терминал',launch_app:'Приложение'}; +function runActionConfirm(id,title,kind,confirm){ + if(!confirm){runActionExec(id);return} + const lbl=AL[kind]||kind; + G('ed-crumb').textContent='Действие: '+title; + G('ed-title').textContent='Подтверждение'; + G('ed-ta').value='Тип: '+lbl+'\n\nВыполнить действие «'+title+'»?'; + G('ed').style.display='flex'; + editId='__action__'+id; +} +async function runActionExec(id){ + if(id.startsWith('__action__'))id=id.slice(10); + try{ + const r=await api('/api/actions/'+id,{method:'POST',body:'{}'}); + closeED(); + if(r&&r.output)alert(r.output); + }catch(e){alert('Ошибка: '+e.message)} +} +async function delAction(id){ + if(!confirm('Удалить действие?'))return; + try{ + await api('/api/actions/'+id,{method:'DELETE'}); + if(sel.kind==='node')loadNodeActions(sel.nodeId); + }catch(e){alert('Ошибка: '+e.message)} +} + /* ════════════════════════════════════════════ EDITOR ════════════════════════════════════════════ */ @@ -571,7 +613,16 @@ async function openNT(id){editId=id; catch(e){alert('Ошибка: '+e.message)} } function closeED(){G('ed').style.display='none';editId=''} -async function saveNT(){if(!editId)return;try{await api('/api/notes/'+editId,{method:'PUT',body:JSON.stringify({content:G('ed-ta').value})});closeED();}catch(e){alert('Ошибка: '+e.message)}} +async function saveNT(){ + if(!editId)return; + if(editId.startsWith('__action__')){ + G('ed').style.display='none'; + await runActionExec(editId); + editId=''; + return; + } + try{await api('/api/notes/'+editId,{method:'PUT',body:JSON.stringify({content:G('ed-ta').value})});closeED();}catch(e){alert('Ошибка: '+e.message)} +} /* ════════════════════════════════════════════ MODALS diff --git a/internal/gui/server.go b/internal/gui/server.go index a014a23..3ac848b 100644 --- a/internal/gui/server.go +++ b/internal/gui/server.go @@ -9,6 +9,7 @@ import ( "net/http" "strings" + "verstak/internal/core/actions" "verstak/internal/core/files" "verstak/internal/core/notes" "verstak/internal/core/nodes" @@ -22,6 +23,7 @@ type Server struct { nodes *nodes.Repository files *files.Service notes *notes.Service + actions *actions.Service srv *http.Server listener net.Listener port int @@ -32,9 +34,10 @@ func NewServer(db *storage.DB, vaultRoot string) *Server { nodeRepo := nodes.NewRepository(db) fileSvc := files.NewService(db, vaultRoot) noteSvc := notes.NewService(db, vaultRoot, nodeRepo, fileSvc) + actionSvc := actions.NewService(db) return &Server{ db: db, vaultRoot: vaultRoot, - nodes: nodeRepo, files: fileSvc, notes: noteSvc, + nodes: nodeRepo, files: fileSvc, notes: noteSvc, actions: actionSvc, } } @@ -45,6 +48,7 @@ func (s *Server) Start() (string, error) { mux.HandleFunc("/api/nodes/", s.handleNodeDetail) mux.HandleFunc("/api/notes/", s.handleNotes) mux.HandleFunc("/api/files/", s.handleFiles) + mux.HandleFunc("/api/actions/", s.handleActions) mux.HandleFunc("/api/search", s.handleSearch) mux.HandleFunc("/", s.handleStatic) @@ -236,6 +240,65 @@ func (s *Server) handleFiles(w http.ResponseWriter, r *http.Request) { } } +// GET/POST/DELETE /api/actions/{id} GET /api/actions?node=ID +func (s *Server) handleActions(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/api/actions/") + switch r.Method { + case "GET": + if path != "" { + rec, err := s.actions.Get(path) + if err != nil { + jsonErr(w, 404, err.Error()) + return + } + jsonOK(w, rec) + return + } + nodeID := r.URL.Query().Get("node") + if nodeID == "" { + jsonOK(w, []interface{}{}) + return + } + list, err := s.actions.ListByNode(nodeID) + if err != nil { + jsonErr(w, 500, err.Error()) + return + } + jsonOK(w, list) + case "POST": + var req struct { + NodeID string `json:"node_id"` + Kind string `json:"kind"` + Title string `json:"title"` + Command string `json:"command"` + URL string `json:"url"` + WorkingDir string `json:"working_dir"` + Args []string `json:"args"` + Confirm bool `json:"confirm"` + Capture bool `json:"capture"` + } + json.NewDecoder(r.Body).Decode(&req) + rec, err := s.actions.Create(req.NodeID, req.Kind, req.Title, req.Command, req.WorkingDir, req.URL, req.Args, req.Confirm, req.Capture) + if err != nil { + jsonErr(w, 500, err.Error()) + return + } + jsonOK(w, rec) + case "DELETE": + if path == "" { + jsonErr(w, 400, "id required") + return + } + if err := s.actions.Delete(path); err != nil { + jsonErr(w, 500, err.Error()) + return + } + jsonOK(w, map[string]string{"status": "deleted"}) + 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"))