feat: плагин-система Lua + Calendar reference plugin
- Lua VM runtime: gopher-lua с песочницей, хуки on_init/on_tick/on_shutdown - API: verstak.node.* / verstak.db.* / verstak.config.* / verstak.state.* - API: verstak.worklog.* / verstak.activity.* / verstak.file.* - API: verstak.schedule.* / verstak.http.* / verstak.ui.* - Менеджер плагинов: жизненный цикл, инициализация, шаблоны - Scheduler: фоновые задачи с интервалами - PluginPage.svelte: контейнер для iframe-панелей плагинов - Calendar plugin: миграция, категории CRUD, события CRUD - Calendar: расширенный рекарренс (daily/weekly/monthly/yearly) - Calendar: связь с узлами Верстака, напоминания, HTTP-праздники - Calendar: Lua-тест-сьют (15 тестов), Go-интеграционный тест - fix: query_row использует реальные Column() вместо guessColumns
This commit is contained in:
@@ -78,10 +78,10 @@ func GenerateSecret() string {
|
||||
}
|
||||
|
||||
// NewServer creates a bridge server.
|
||||
// If cfg.Secret is empty, no authentication is required.
|
||||
func NewServer(cfg Config, handler EventHandler) *Server {
|
||||
// If secret is empty, no authentication is required.
|
||||
func NewServer(secret string, handler EventHandler) *Server {
|
||||
return &Server{
|
||||
secret: cfg.Secret,
|
||||
secret: secret,
|
||||
handler: handler,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
)
|
||||
|
||||
func TestServer_Ping(t *testing.T) {
|
||||
s := NewServer(DefaultConfig(), nil)
|
||||
s := NewServer("", nil)
|
||||
port, err := s.Start(DefaultConfig())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -35,7 +35,7 @@ func TestServer_Ping(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestServer_Events_AuthRequired(t *testing.T) {
|
||||
s := NewServer(Config{Secret: "test-secret"}, nil)
|
||||
s := NewServer("test-secret", nil)
|
||||
port, err := s.Start(Config{Secret: "test-secret"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -56,7 +56,7 @@ func TestServer_Events_AuthRequired(t *testing.T) {
|
||||
|
||||
func TestServer_Events_Success(t *testing.T) {
|
||||
received := make(chan []Event, 1)
|
||||
s := NewServer(Config{Secret: "test-secret"}, func(evts []Event) {
|
||||
s := NewServer("test-secret", func(evts []Event) {
|
||||
received <- evts
|
||||
})
|
||||
port, err := s.Start(Config{Secret: "test-secret"})
|
||||
@@ -99,7 +99,7 @@ func TestServer_Events_Success(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestServer_Events_EmptyBatch(t *testing.T) {
|
||||
s := NewServer(Config{Secret: "s"}, nil)
|
||||
s := NewServer("s", nil)
|
||||
port, err := s.Start(Config{Secret: "s"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -135,7 +135,7 @@ func TestGenerateSecret(t *testing.T) {
|
||||
|
||||
func TestServer_AutoPort(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
s := NewServer(cfg, nil)
|
||||
s := NewServer("", nil)
|
||||
port, err := s.Start(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -65,10 +65,10 @@ type SyncSettings struct {
|
||||
|
||||
// BridgeConfig holds local HTTP bridge settings for browser extension.
|
||||
type BridgeConfig struct {
|
||||
Enabled bool `json:"enabled,omitempty"` // enable/disable bridge server
|
||||
Enabled bool `json:"enabled"` // enable/disable bridge server
|
||||
Port int `json:"port"` // listen port (default 9786)
|
||||
Secret string `json:"secret,omitempty"` // shared secret for extension auth
|
||||
AutoGenPort bool `json:"auto_gen_port,omitempty"` // pick random port if port taken
|
||||
AutoGenPort bool `json:"auto_gen_port"` // pick random port if port taken
|
||||
}
|
||||
|
||||
func DefaultAppConfig() *AppConfig {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
// registerAPI creates the global `verstak` table with all API modules.
|
||||
func registerAPI(vm *LuaVM) {
|
||||
L := vm.L
|
||||
|
||||
// Main verstak table
|
||||
verstak := L.NewTable()
|
||||
|
||||
// Sub-modules
|
||||
verstak.RawSetString("node", registerNodeAPI(vm))
|
||||
verstak.RawSetString("worklog", registerWorklogAPI(vm))
|
||||
verstak.RawSetString("db", registerDBAPI(vm))
|
||||
verstak.RawSetString("ui", registerUIAPI(vm))
|
||||
verstak.RawSetString("config", registerConfigAPI(vm))
|
||||
verstak.RawSetString("state", registerStateAPI(vm))
|
||||
verstak.RawSetString("activity", registerActivityAPI(vm))
|
||||
verstak.RawSetString("schedule", registerScheduleAPI(vm))
|
||||
verstak.RawSetString("http", registerHTTPAPI(vm))
|
||||
verstak.RawSetString("file", registerFileAPI(vm))
|
||||
|
||||
// Register as global
|
||||
L.SetGlobal("verstak", verstak)
|
||||
}
|
||||
|
||||
// luaTableFromMap converts a Go map to a Lua table.
|
||||
func luaTableFromMap(L *lua.LState, m map[string]interface{}) *lua.LTable {
|
||||
tbl := L.NewTable()
|
||||
for k, v := range m {
|
||||
tbl.RawSetString(k, toLuaValue(L, v))
|
||||
}
|
||||
return tbl
|
||||
}
|
||||
|
||||
// luaTableFromList converts a Go slice to a Lua array table.
|
||||
func luaTableFromList(L *lua.LState, items []map[string]interface{}) *lua.LTable {
|
||||
tbl := L.NewTable()
|
||||
for i, item := range items {
|
||||
tbl.RawSetInt(i+1, luaTableFromMap(L, item))
|
||||
}
|
||||
return tbl
|
||||
}
|
||||
|
||||
// toLuaValue converts a Go value to a Lua value.
|
||||
func toLuaValue(L *lua.LState, v interface{}) lua.LValue {
|
||||
switch val := v.(type) {
|
||||
case nil:
|
||||
return lua.LNil
|
||||
case string:
|
||||
return lua.LString(val)
|
||||
case int:
|
||||
return lua.LNumber(val)
|
||||
case int64:
|
||||
return lua.LNumber(val)
|
||||
case float64:
|
||||
return lua.LNumber(val)
|
||||
case bool:
|
||||
return lua.LBool(val)
|
||||
case []interface{}:
|
||||
tbl := L.NewTable()
|
||||
for i, item := range val {
|
||||
tbl.RawSetInt(i+1, toLuaValue(L, item))
|
||||
}
|
||||
return tbl
|
||||
case map[string]interface{}:
|
||||
return luaTableFromMap(L, val)
|
||||
default:
|
||||
return lua.LString(fmt.Sprintf("%v", v))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,671 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
|
||||
// worklog import is only used in the registerWorklogAPI closure
|
||||
// where the compiler resolves it via the import statement.
|
||||
"verstak/internal/core/worklog"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// verstak.db.* — plugin's own SQLite database
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// pluginDBMu guards the pluginDB cache.
|
||||
var pluginDBMu sync.Mutex
|
||||
var pluginDBs = make(map[string]*sql.DB)
|
||||
|
||||
// openPluginDB opens (or returns cached) the plugin's private SQLite DB.
|
||||
func openPluginDB(vm *LuaVM) (*sql.DB, error) {
|
||||
name := vm.Plugin.Meta.Name
|
||||
pluginDBMu.Lock()
|
||||
defer pluginDBMu.Unlock()
|
||||
|
||||
if db, ok := pluginDBs[name]; ok {
|
||||
return db, nil
|
||||
}
|
||||
dbPath := filepath.Join(vm.Plugin.DataDir, "data.db")
|
||||
os.MkdirAll(vm.Plugin.DataDir, 0o750)
|
||||
db, err := sql.Open("sqlite3", fmt.Sprintf("file:%s?mode=rwc&_journal_mode=WAL", dbPath))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open plugin db: %w", err)
|
||||
}
|
||||
pluginDBs[name] = db
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// closePluginDB closes a plugin's DB.
|
||||
func closePluginDB(name string) {
|
||||
pluginDBMu.Lock()
|
||||
defer pluginDBMu.Unlock()
|
||||
if db, ok := pluginDBs[name]; ok {
|
||||
db.Close()
|
||||
delete(pluginDBs, name)
|
||||
}
|
||||
}
|
||||
|
||||
func registerDBAPI(vm *LuaVM) *lua.LTable {
|
||||
L := vm.L
|
||||
tbl := L.NewTable()
|
||||
|
||||
tbl.RawSetString("exec", L.NewFunction(func(L *lua.LState) int {
|
||||
query := L.CheckString(1)
|
||||
args := collectArgs(L, 2)
|
||||
db, err := openPluginDB(vm)
|
||||
if err != nil {
|
||||
return pushError(L, err)
|
||||
}
|
||||
_, err = db.Exec(query, args...)
|
||||
if err != nil {
|
||||
return pushError(L, fmt.Errorf("exec: %w", err))
|
||||
}
|
||||
L.Push(lua.LBool(true))
|
||||
return 1
|
||||
}))
|
||||
|
||||
tbl.RawSetString("query", L.NewFunction(func(L *lua.LState) int {
|
||||
query := L.CheckString(1)
|
||||
args := collectArgs(L, 2)
|
||||
db, err := openPluginDB(vm)
|
||||
if err != nil {
|
||||
return pushError(L, err)
|
||||
}
|
||||
rows, err := db.Query(query, args...)
|
||||
if err != nil {
|
||||
return pushError(L, fmt.Errorf("query: %w", err))
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
cols, _ := rows.Columns()
|
||||
arr := L.NewTable()
|
||||
rowIdx := 1
|
||||
for rows.Next() {
|
||||
vals := make([]interface{}, len(cols))
|
||||
ptrs := make([]interface{}, len(cols))
|
||||
for i := range vals {
|
||||
ptrs[i] = &vals[i]
|
||||
}
|
||||
if err := rows.Scan(ptrs...); err != nil {
|
||||
return pushError(L, fmt.Errorf("scan: %w", err))
|
||||
}
|
||||
row := L.NewTable()
|
||||
for i, col := range cols {
|
||||
row.RawSetString(col, goValueToLua(L, vals[i]))
|
||||
}
|
||||
arr.RawSetInt(rowIdx, row)
|
||||
rowIdx++
|
||||
}
|
||||
L.Push(arr)
|
||||
return 1
|
||||
}))
|
||||
|
||||
tbl.RawSetString("query_row", L.NewFunction(func(L *lua.LState) int {
|
||||
query := L.CheckString(1)
|
||||
args := collectArgs(L, 2)
|
||||
db, err := openPluginDB(vm)
|
||||
if err != nil {
|
||||
return pushError(L, err)
|
||||
}
|
||||
rows, err := db.Query(query, args...)
|
||||
if err != nil {
|
||||
return pushError(L, fmt.Errorf("query_row: %w", err))
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
if !rows.Next() {
|
||||
L.Push(lua.LNil)
|
||||
return 1
|
||||
}
|
||||
|
||||
cols, _ := rows.Columns()
|
||||
vals := make([]interface{}, len(cols))
|
||||
ptrs := make([]interface{}, len(cols))
|
||||
for i := range vals {
|
||||
ptrs[i] = &vals[i]
|
||||
}
|
||||
if err := rows.Scan(ptrs...); err != nil {
|
||||
return pushError(L, fmt.Errorf("query_row: %w", err))
|
||||
}
|
||||
tbl := L.NewTable()
|
||||
for i, col := range cols {
|
||||
tbl.RawSetString(col, goValueToLua(L, vals[i]))
|
||||
}
|
||||
L.Push(tbl)
|
||||
return 1
|
||||
}))
|
||||
|
||||
return tbl
|
||||
}
|
||||
|
||||
// collectArgs extracts SQL arguments from Lua varargs starting at pos.
|
||||
func collectArgs(L *lua.LState, pos int) []interface{} {
|
||||
var args []interface{}
|
||||
top := L.GetTop()
|
||||
for i := pos; i <= top; i++ {
|
||||
val := L.Get(i)
|
||||
args = append(args, luaValueToGo(val))
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// guessColumns returns placeholder column names for query_row.
|
||||
func guessColumns(query string) []string {
|
||||
upper := strings.ToUpper(strings.TrimSpace(query))
|
||||
if strings.HasPrefix(upper, "SELECT COUNT") || strings.Contains(upper, "COUNT(") {
|
||||
// Single-result aggregate
|
||||
if strings.Contains(upper, "COUNT(DISTINCT") {
|
||||
return []string{"count"}
|
||||
}
|
||||
return []string{"count"}
|
||||
}
|
||||
// Default — caller can use array access
|
||||
return []string{"col1", "col2", "col3", "col4", "col5", "col6", "col7", "col8"}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// verstak.config.* — JSON config per plugin
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func loadPluginConfig(vm *LuaVM) (map[string]interface{}, error) {
|
||||
cfgPath := filepath.Join(vm.Plugin.DataDir, "config.json")
|
||||
b, err := os.ReadFile(cfgPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return make(map[string]interface{}), nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal(b, &m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func savePluginConfig(vm *LuaVM, m map[string]interface{}) error {
|
||||
os.MkdirAll(vm.Plugin.DataDir, 0o750)
|
||||
b, err := json.MarshalIndent(m, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfgPath := filepath.Join(vm.Plugin.DataDir, "config.json")
|
||||
return os.WriteFile(cfgPath, b, 0o640)
|
||||
}
|
||||
|
||||
func registerConfigAPI(vm *LuaVM) *lua.LTable {
|
||||
L := vm.L
|
||||
tbl := L.NewTable()
|
||||
|
||||
// state is a map[string]interface{} (in memory, written to disk on set).
|
||||
cfg, _ := loadPluginConfig(vm)
|
||||
|
||||
tbl.RawSetString("get", L.NewFunction(func(L *lua.LState) int {
|
||||
key := L.CheckString(1)
|
||||
val, ok := cfg[key]
|
||||
if !ok {
|
||||
L.Push(lua.LNil)
|
||||
return 1
|
||||
}
|
||||
L.Push(toLuaValue(L, val))
|
||||
return 1
|
||||
}))
|
||||
|
||||
tbl.RawSetString("set", L.NewFunction(func(L *lua.LState) int {
|
||||
key := L.CheckString(1)
|
||||
val := luaValueToGo(L.Get(2))
|
||||
cfg[key] = val
|
||||
if err := savePluginConfig(vm, cfg); err != nil {
|
||||
return pushError(L, fmt.Errorf("config.save: %w", err))
|
||||
}
|
||||
L.Push(lua.LBool(true))
|
||||
return 1
|
||||
}))
|
||||
|
||||
tbl.RawSetString("all", L.NewFunction(func(L *lua.LState) int {
|
||||
L.Push(luaTableFromMap(L, cfg))
|
||||
return 1
|
||||
}))
|
||||
|
||||
return tbl
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// verstak.state.* — already implemented (in-memory map per plugin)
|
||||
// Still present in api_ext.go — see registerStateAPI below.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func registerStateAPI(vm *LuaVM) *lua.LTable {
|
||||
L := vm.L
|
||||
tbl := L.NewTable()
|
||||
|
||||
state := make(map[string]interface{})
|
||||
|
||||
tbl.RawSetString("get", L.NewFunction(func(L *lua.LState) int {
|
||||
key := L.CheckString(1)
|
||||
val, ok := state[key]
|
||||
if !ok {
|
||||
L.Push(lua.LNil)
|
||||
return 1
|
||||
}
|
||||
L.Push(toLuaValue(L, val))
|
||||
return 1
|
||||
}))
|
||||
|
||||
tbl.RawSetString("set", L.NewFunction(func(L *lua.LState) int {
|
||||
key := L.CheckString(1)
|
||||
val := L.Get(2)
|
||||
state[key] = luaValueToGo(val)
|
||||
return 0
|
||||
}))
|
||||
|
||||
tbl.RawSetString("delete", L.NewFunction(func(L *lua.LState) int {
|
||||
key := L.CheckString(1)
|
||||
delete(state, key)
|
||||
return 0
|
||||
}))
|
||||
|
||||
return tbl
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// verstak.ui.* — send events to the frontend
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func registerUIAPI(vm *LuaVM) *lua.LTable {
|
||||
L := vm.L
|
||||
tbl := L.NewTable()
|
||||
|
||||
tbl.RawSetString("toast", L.NewFunction(func(L *lua.LState) int {
|
||||
msg := L.CheckString(1)
|
||||
typ := L.OptString(2, "info")
|
||||
// TODO: send event to frontend via Wails events
|
||||
log.Printf("[lua] toast(%s): %s", typ, msg)
|
||||
return 0
|
||||
}))
|
||||
|
||||
tbl.RawSetString("navigate_to", L.NewFunction(func(L *lua.LState) int {
|
||||
page := L.CheckString(1)
|
||||
// TODO: send navigate event to frontend
|
||||
log.Printf("[lua] navigate_to: %s", page)
|
||||
return 0
|
||||
}))
|
||||
|
||||
return tbl
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// verstak.schedule.* — recurring tasks (uses the Scheduler)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func registerScheduleAPI(vm *LuaVM) *lua.LTable {
|
||||
L := vm.L
|
||||
tbl := L.NewTable()
|
||||
|
||||
tbl.RawSetString("every", L.NewFunction(func(L *lua.LState) int {
|
||||
interval := L.CheckString(1)
|
||||
callback := L.CheckString(2)
|
||||
|
||||
d, err := time.ParseDuration(interval)
|
||||
if err != nil {
|
||||
return pushError(L, fmt.Errorf("schedule.every: invalid duration %q: %w", interval, err))
|
||||
}
|
||||
if d < time.Second {
|
||||
return pushError(L, fmt.Errorf("schedule.every: minimum interval is 1s"))
|
||||
}
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(d)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if err := vm.CallHook(callback); err != nil {
|
||||
log.Printf("[lua] schedule %q error: %v", callback, err)
|
||||
}
|
||||
case <-vm.done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
return 0
|
||||
}))
|
||||
|
||||
return tbl
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// verstak.http.* — simple HTTP requests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func registerHTTPAPI(vm *LuaVM) *lua.LTable {
|
||||
L := vm.L
|
||||
tbl := L.NewTable()
|
||||
|
||||
tbl.RawSetString("get", L.NewFunction(func(L *lua.LState) int {
|
||||
url := L.CheckString(1)
|
||||
headers := L.OptTable(3, L.NewTable())
|
||||
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return pushError(L, fmt.Errorf("http.get: %w", err))
|
||||
}
|
||||
applyHeaders(req, headers)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return pushError(L, fmt.Errorf("http.get: %w", err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
return pushResponse(L, resp)
|
||||
}))
|
||||
|
||||
tbl.RawSetString("post", L.NewFunction(func(L *lua.LState) int {
|
||||
url := L.CheckString(1)
|
||||
body := L.Get(2)
|
||||
headers := L.OptTable(3, L.NewTable())
|
||||
|
||||
var bodyReader io.Reader
|
||||
if body != lua.LNil {
|
||||
bodyReader = strings.NewReader(lua.LVAsString(body))
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", url, bodyReader)
|
||||
if err != nil {
|
||||
return pushError(L, fmt.Errorf("http.post: %w", err))
|
||||
}
|
||||
if body != lua.LNil {
|
||||
req.Header.Set("Content-Type", "application/octet-stream")
|
||||
}
|
||||
applyHeaders(req, headers)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return pushError(L, fmt.Errorf("http.post: %w", err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
return pushResponse(L, resp)
|
||||
}))
|
||||
|
||||
return tbl
|
||||
}
|
||||
|
||||
func applyHeaders(req *http.Request, tbl *lua.LTable) {
|
||||
tbl.ForEach(func(k, v lua.LValue) {
|
||||
key := lua.LVAsString(k)
|
||||
val := lua.LVAsString(v)
|
||||
if key != "" && val != "" {
|
||||
req.Header.Set(key, val)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func pushResponse(L *lua.LState, resp *http.Response) int {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
tbl := L.NewTable()
|
||||
tbl.RawSetString("status", lua.LNumber(resp.StatusCode))
|
||||
tbl.RawSetString("body", lua.LString(string(body)))
|
||||
|
||||
headers := L.NewTable()
|
||||
for k, vals := range resp.Header {
|
||||
headers.RawSetString(k, lua.LString(strings.Join(vals, ", ")))
|
||||
}
|
||||
tbl.RawSetString("headers", headers)
|
||||
|
||||
L.Push(tbl)
|
||||
return 1
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// verstak.worklog.* — real worklog operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func registerWorklogAPI(vm *LuaVM) *lua.LTable {
|
||||
L := vm.L
|
||||
tbl := L.NewTable()
|
||||
svc := vm.Services
|
||||
|
||||
// Helper to check if services are available
|
||||
checkSvc := func() *worklog.Service {
|
||||
if svc == nil || svc.WorklogSvc == nil {
|
||||
return nil
|
||||
}
|
||||
return svc.WorklogSvc
|
||||
}
|
||||
|
||||
tbl.RawSetString("add", L.NewFunction(func(L *lua.LState) int {
|
||||
wsvc := checkSvc()
|
||||
if wsvc == nil {
|
||||
return pushError(L, fmt.Errorf("worklog service not available"))
|
||||
}
|
||||
nodeID := L.CheckString(1)
|
||||
summary := L.CheckString(2)
|
||||
minutes := L.CheckInt(3)
|
||||
details := L.OptString(4, "")
|
||||
approx := lua.LVAsBool(L.Get(5))
|
||||
|
||||
e, err := wsvc.Add(nodeID, summary, details, minutes, approx, false)
|
||||
if err != nil {
|
||||
return pushError(L, fmt.Errorf("worklog.add: %w", err))
|
||||
}
|
||||
tbl := L.NewTable()
|
||||
tbl.RawSetString("id", lua.LString(e.ID))
|
||||
tbl.RawSetString("node_id", lua.LString(e.NodeID))
|
||||
tbl.RawSetString("summary", lua.LString(e.Summary))
|
||||
tbl.RawSetString("minutes", lua.LNumber(*e.Minutes))
|
||||
tbl.RawSetString("date", lua.LString(e.Date))
|
||||
tbl.RawSetString("created_at", lua.LString(e.CreatedAt.Format(time.RFC3339)))
|
||||
L.Push(tbl)
|
||||
return 1
|
||||
}))
|
||||
|
||||
tbl.RawSetString("list", L.NewFunction(func(L *lua.LState) int {
|
||||
wsvc := checkSvc()
|
||||
if wsvc == nil {
|
||||
return pushError(L, fmt.Errorf("worklog service not available"))
|
||||
}
|
||||
nodeID := L.CheckString(1)
|
||||
entries, err := wsvc.ListByNode(nodeID)
|
||||
if err != nil {
|
||||
return pushError(L, fmt.Errorf("worklog.list: %w", err))
|
||||
}
|
||||
arr := L.NewTable()
|
||||
for i, e := range entries {
|
||||
et := L.NewTable()
|
||||
et.RawSetString("id", lua.LString(e.ID))
|
||||
et.RawSetString("node_id", lua.LString(e.NodeID))
|
||||
et.RawSetString("summary", lua.LString(e.Summary))
|
||||
if e.Minutes != nil {
|
||||
et.RawSetString("minutes", lua.LNumber(*e.Minutes))
|
||||
}
|
||||
et.RawSetString("date", lua.LString(e.Date))
|
||||
et.RawSetString("created_at", lua.LString(e.CreatedAt.Format(time.RFC3339)))
|
||||
arr.RawSetInt(i+1, et)
|
||||
}
|
||||
L.Push(arr)
|
||||
return 1
|
||||
}))
|
||||
|
||||
tbl.RawSetString("summary", L.NewFunction(func(L *lua.LState) int {
|
||||
wsvc := checkSvc()
|
||||
if wsvc == nil {
|
||||
return pushError(L, fmt.Errorf("worklog service not available"))
|
||||
}
|
||||
nodeID := L.CheckString(1)
|
||||
entries, err := wsvc.ListByNode(nodeID)
|
||||
if err != nil {
|
||||
return pushError(L, fmt.Errorf("worklog.summary: %w", err))
|
||||
}
|
||||
total := 0
|
||||
for _, e := range entries {
|
||||
if e.Minutes != nil {
|
||||
total += *e.Minutes
|
||||
}
|
||||
}
|
||||
tbl := L.NewTable()
|
||||
tbl.RawSetString("total_minutes", lua.LNumber(total))
|
||||
tbl.RawSetString("count", lua.LNumber(len(entries)))
|
||||
L.Push(tbl)
|
||||
return 1
|
||||
}))
|
||||
|
||||
tbl.RawSetString("delete", L.NewFunction(func(L *lua.LState) int {
|
||||
wsvc := checkSvc()
|
||||
if wsvc == nil {
|
||||
return pushError(L, fmt.Errorf("worklog service not available"))
|
||||
}
|
||||
id := L.CheckString(1)
|
||||
if err := wsvc.Delete(id); err != nil {
|
||||
return pushError(L, fmt.Errorf("worklog.delete: %w", err))
|
||||
}
|
||||
L.Push(lua.LBool(true))
|
||||
return 1
|
||||
}))
|
||||
|
||||
return tbl
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// verstak.activity.* — real activity feed operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func registerActivityAPI(vm *LuaVM) *lua.LTable {
|
||||
L := vm.L
|
||||
tbl := L.NewTable()
|
||||
svc := vm.Services
|
||||
|
||||
tbl.RawSetString("log", L.NewFunction(func(L *lua.LState) int {
|
||||
if svc == nil || svc.ActivitySvc == nil {
|
||||
return pushError(L, fmt.Errorf("activity service not available"))
|
||||
}
|
||||
eventType := L.CheckString(1)
|
||||
title := L.CheckString(2)
|
||||
targetID := L.OptString(3, "")
|
||||
nodeID := L.OptString(4, "")
|
||||
|
||||
err := svc.ActivitySvc.Record(nodeID, "", targetID, "", eventType, title, "")
|
||||
if err != nil {
|
||||
return pushError(L, fmt.Errorf("activity.log: %w", err))
|
||||
}
|
||||
L.Push(lua.LBool(true))
|
||||
return 1
|
||||
}))
|
||||
|
||||
tbl.RawSetString("list", L.NewFunction(func(L *lua.LState) int {
|
||||
if svc == nil || svc.ActivitySvc == nil {
|
||||
return pushError(L, fmt.Errorf("activity service not available"))
|
||||
}
|
||||
limit := L.OptInt(1, 20)
|
||||
events, err := svc.ActivitySvc.ListRecent(limit, 0)
|
||||
if err != nil {
|
||||
return pushError(L, fmt.Errorf("activity.list: %w", err))
|
||||
}
|
||||
arr := L.NewTable()
|
||||
for i, e := range events {
|
||||
et := L.NewTable()
|
||||
et.RawSetString("event_type", lua.LString(e.EventType))
|
||||
et.RawSetString("title", lua.LString(e.Title))
|
||||
et.RawSetString("target_id", lua.LString(e.TargetID))
|
||||
et.RawSetString("node_id", lua.LString(e.NodeID))
|
||||
et.RawSetString("created_at", lua.LString(e.CreatedAt))
|
||||
arr.RawSetInt(i+1, et)
|
||||
}
|
||||
L.Push(arr)
|
||||
return 1
|
||||
}))
|
||||
|
||||
return tbl
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// verstak.file.* — real file operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func registerFileAPI(vm *LuaVM) *lua.LTable {
|
||||
L := vm.L
|
||||
tbl := L.NewTable()
|
||||
svc := vm.Services
|
||||
|
||||
tbl.RawSetString("list", L.NewFunction(func(L *lua.LState) int {
|
||||
if svc == nil || svc.FilesSvc == nil {
|
||||
return pushError(L, fmt.Errorf("file service not available"))
|
||||
}
|
||||
nodeID := L.CheckString(1)
|
||||
records, err := svc.FilesSvc.ListByNode(nodeID)
|
||||
if err != nil {
|
||||
return pushError(L, fmt.Errorf("file.list: %w", err))
|
||||
}
|
||||
arr := L.NewTable()
|
||||
for i, r := range records {
|
||||
ft := L.NewTable()
|
||||
ft.RawSetString("id", lua.LString(r.ID))
|
||||
ft.RawSetString("filename", lua.LString(r.Filename))
|
||||
ft.RawSetString("path", lua.LString(r.Path))
|
||||
ft.RawSetString("size", lua.LNumber(r.Size))
|
||||
ft.RawSetString("mime", lua.LString(r.MIME))
|
||||
ft.RawSetString("missing", lua.LBool(r.Missing))
|
||||
ft.RawSetString("created_at", lua.LString(r.CreatedAt.Format(time.RFC3339)))
|
||||
arr.RawSetInt(i+1, ft)
|
||||
}
|
||||
L.Push(arr)
|
||||
return 1
|
||||
}))
|
||||
|
||||
tbl.RawSetString("read", L.NewFunction(func(L *lua.LState) int {
|
||||
if svc == nil || svc.FilesSvc == nil {
|
||||
return pushError(L, fmt.Errorf("file service not available"))
|
||||
}
|
||||
id := L.CheckString(1)
|
||||
content, err := svc.FilesSvc.ReadText(id)
|
||||
if err != nil {
|
||||
return pushError(L, fmt.Errorf("file.read: %w", err))
|
||||
}
|
||||
L.Push(lua.LString(content))
|
||||
return 1
|
||||
}))
|
||||
|
||||
return tbl
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: goValueToLua converts a Go interface{} (from sql.Scan) to lua.LValue.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func goValueToLua(L *lua.LState, v interface{}) lua.LValue {
|
||||
if v == nil {
|
||||
return lua.LNil
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case []byte:
|
||||
return lua.LString(string(val))
|
||||
case string:
|
||||
return lua.LString(val)
|
||||
case int64:
|
||||
return lua.LNumber(val)
|
||||
case float64:
|
||||
return lua.LNumber(val)
|
||||
case bool:
|
||||
return lua.LBool(val)
|
||||
case time.Time:
|
||||
return lua.LString(val.Format(time.RFC3339))
|
||||
default:
|
||||
return lua.LString(fmt.Sprintf("%v", val))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"verstak/internal/core/nodes"
|
||||
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
// nodeToTable converts a Node to a Lua table.
|
||||
func nodeToTable(L *lua.LState, n nodeOrErr) *lua.LTable {
|
||||
tbl := L.NewTable()
|
||||
if n.err != nil {
|
||||
return tbl
|
||||
}
|
||||
tbl.RawSetString("id", lua.LString(n.node.ID))
|
||||
tbl.RawSetString("title", lua.LString(n.node.Title))
|
||||
tbl.RawSetString("type", lua.LString(n.node.Type))
|
||||
tbl.RawSetString("slug", lua.LString(n.node.Slug))
|
||||
tbl.RawSetString("sort_order", lua.LNumber(n.node.SortOrder))
|
||||
tbl.RawSetString("archived", lua.LBool(n.node.Archived))
|
||||
tbl.RawSetString("revision", lua.LNumber(n.node.Revision))
|
||||
tbl.RawSetString("created_at", lua.LString(n.node.CreatedAt.Format(timeFormat)))
|
||||
tbl.RawSetString("updated_at", lua.LString(n.node.UpdatedAt.Format(timeFormat)))
|
||||
if n.node.ParentID != nil {
|
||||
tbl.RawSetString("parent_id", lua.LString(*n.node.ParentID))
|
||||
}
|
||||
if n.node.DeletedAt != nil {
|
||||
tbl.RawSetString("deleted_at", lua.LString(n.node.DeletedAt.Format(timeFormat)))
|
||||
}
|
||||
return tbl
|
||||
}
|
||||
|
||||
// registerNodeAPI registers the verstak.node.* API and returns the table.
|
||||
func registerNodeAPI(vm *LuaVM) *lua.LTable {
|
||||
svc := vm.Services
|
||||
if svc == nil || svc.NodeRepo == nil {
|
||||
// No services available — return empty table but still register it
|
||||
emptyTbl := vm.L.NewTable()
|
||||
vm.L.SetGlobal("verstak_node", emptyTbl)
|
||||
return emptyTbl
|
||||
}
|
||||
L := vm.L
|
||||
tbl := L.NewTable()
|
||||
|
||||
// verstak.node.get(id) → table or nil
|
||||
tbl.RawSetString("get", L.NewFunction(func(L *lua.LState) int {
|
||||
id := L.CheckString(1)
|
||||
n, err := svc.NodeRepo.GetActive(id)
|
||||
if err != nil {
|
||||
return pushError(L, err)
|
||||
}
|
||||
tbl := nodeToTable(L, nodeOrErr{node: n})
|
||||
L.Push(tbl)
|
||||
return 1
|
||||
}))
|
||||
|
||||
// verstak.node.list(parent_id) → array of tables
|
||||
tbl.RawSetString("list", L.NewFunction(func(L *lua.LState) int {
|
||||
parentID := L.CheckString(1)
|
||||
children, err := svc.NodeRepo.ListChildren(parentID, false)
|
||||
if err != nil {
|
||||
return pushError(L, err)
|
||||
}
|
||||
arr := L.NewTable()
|
||||
for i, n := range children {
|
||||
tbl := nodeToTable(L, nodeOrErr{node: &n})
|
||||
arr.RawSetInt(i+1, tbl)
|
||||
}
|
||||
L.Push(arr)
|
||||
return 1
|
||||
}))
|
||||
|
||||
// verstak.node.create(parent_id, title, type) → table
|
||||
tbl.RawSetString("create", L.NewFunction(func(L *lua.LState) int {
|
||||
parentID := lua.LNil
|
||||
if L.GetTop() >= 1 && L.Get(1) != lua.LNil {
|
||||
parentID = L.Get(1)
|
||||
}
|
||||
title := L.CheckString(2)
|
||||
typ := L.OptString(3, "document")
|
||||
_ = L.OptString(4, "") // props (reserved)
|
||||
|
||||
var pID *string
|
||||
if parentID != lua.LNil {
|
||||
s := lua.LVAsString(parentID)
|
||||
if s != "" {
|
||||
pID = &s
|
||||
}
|
||||
}
|
||||
n, err := svc.NodeRepo.Create(pID, typ, title, 0, "", "")
|
||||
if err != nil {
|
||||
return pushError(L, err)
|
||||
}
|
||||
tbl := nodeToTable(L, nodeOrErr{node: n})
|
||||
L.Push(tbl)
|
||||
return 1
|
||||
}))
|
||||
|
||||
// verstak.node.update(id, fields) → success/error
|
||||
tbl.RawSetString("update", L.NewFunction(func(L *lua.LState) int {
|
||||
id := L.CheckString(1)
|
||||
fields := L.CheckTable(2)
|
||||
|
||||
titleVal := fields.RawGetString("title")
|
||||
if titleVal != lua.LNil {
|
||||
if err := svc.NodeRepo.UpdateTitle(id, lua.LVAsString(titleVal)); err != nil {
|
||||
return pushError(L, err)
|
||||
}
|
||||
}
|
||||
L.Push(lua.LBool(true))
|
||||
return 1
|
||||
}))
|
||||
|
||||
// verstak.node.delete(id) → success/error
|
||||
tbl.RawSetString("delete", L.NewFunction(func(L *lua.LState) int {
|
||||
id := L.CheckString(1)
|
||||
if err := svc.NodeRepo.SoftDelete(id); err != nil {
|
||||
return pushError(L, err)
|
||||
}
|
||||
L.Push(lua.LBool(true))
|
||||
return 1
|
||||
}))
|
||||
|
||||
// verstak.node.search(query) → array of tables
|
||||
tbl.RawSetString("search", L.NewFunction(func(L *lua.LState) int {
|
||||
query := L.CheckString(1)
|
||||
limit := L.OptInt(2, 20)
|
||||
results, err := svc.NodeRepo.Search(query, limit)
|
||||
if err != nil {
|
||||
return pushError(L, err)
|
||||
}
|
||||
arr := L.NewTable()
|
||||
for i, n := range results {
|
||||
tbl := nodeToTable(L, nodeOrErr{node: &n})
|
||||
arr.RawSetInt(i+1, tbl)
|
||||
}
|
||||
L.Push(arr)
|
||||
return 1
|
||||
}))
|
||||
|
||||
// verstak.node.roots() → array of root-level nodes
|
||||
tbl.RawSetString("roots", L.NewFunction(func(L *lua.LState) int {
|
||||
roots, err := svc.NodeRepo.ListRoots(false)
|
||||
if err != nil {
|
||||
return pushError(L, err)
|
||||
}
|
||||
arr := L.NewTable()
|
||||
for i, n := range roots {
|
||||
tbl := nodeToTable(L, nodeOrErr{node: &n})
|
||||
arr.RawSetInt(i+1, tbl)
|
||||
}
|
||||
L.Push(arr)
|
||||
return 1
|
||||
}))
|
||||
|
||||
// verstak.node.meta — sub-table
|
||||
metaTbl := L.NewTable()
|
||||
metaTbl.RawSetString("get", L.NewFunction(func(L *lua.LState) int {
|
||||
nodeID := L.CheckString(1)
|
||||
key := L.CheckString(2)
|
||||
v, ok, err := svc.NodeRepo.MetaGet(nodeID, key)
|
||||
if err != nil {
|
||||
return pushError(L, err)
|
||||
}
|
||||
if !ok {
|
||||
L.Push(lua.LNil)
|
||||
} else {
|
||||
L.Push(lua.LString(v))
|
||||
}
|
||||
return 1
|
||||
}))
|
||||
metaTbl.RawSetString("set", L.NewFunction(func(L *lua.LState) int {
|
||||
nodeID := L.CheckString(1)
|
||||
key := L.CheckString(2)
|
||||
value := L.CheckString(3)
|
||||
if err := svc.NodeRepo.MetaSet(nodeID, key, value); err != nil {
|
||||
return pushError(L, err)
|
||||
}
|
||||
L.Push(lua.LBool(true))
|
||||
return 1
|
||||
}))
|
||||
metaTbl.RawSetString("list", L.NewFunction(func(L *lua.LState) int {
|
||||
nodeID := L.CheckString(1)
|
||||
metas, err := svc.NodeRepo.MetaList(nodeID)
|
||||
if err != nil {
|
||||
return pushError(L, err)
|
||||
}
|
||||
arr := L.NewTable()
|
||||
for i, m := range metas {
|
||||
entry := L.NewTable()
|
||||
entry.RawSetString("key", lua.LString(m.Key))
|
||||
entry.RawSetString("value", lua.LString(m.Value))
|
||||
arr.RawSetInt(i+1, entry)
|
||||
}
|
||||
L.Push(arr)
|
||||
return 1
|
||||
}))
|
||||
tbl.RawSetString("meta", metaTbl)
|
||||
|
||||
// Set the global
|
||||
L.SetGlobal("verstak_node", tbl)
|
||||
|
||||
// Also add to the main verstak table if it exists
|
||||
mainTbl := L.GetGlobal("verstak")
|
||||
if mainTbl != lua.LNil {
|
||||
if tbl2, ok := mainTbl.(*lua.LTable); ok {
|
||||
tbl2.RawSetString("node", tbl)
|
||||
}
|
||||
}
|
||||
|
||||
return tbl
|
||||
}
|
||||
|
||||
// nodeOrErr is a helper to keep node + potential error together.
|
||||
type nodeOrErr struct {
|
||||
node *nodes.Node
|
||||
err error
|
||||
}
|
||||
|
||||
// timeFormat for Lua output.
|
||||
const timeFormat = "2006-01-02T15:04:05Z07:00"
|
||||
|
||||
// This function is used in the text below — comment to satisfy unused import.
|
||||
var _ = fmt.Sprintf
|
||||
@@ -0,0 +1,92 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
// pushError pushes an error message as a Lua error (using lua.Errorf-style).
|
||||
// Returns 0 (no return values — the Lua function will error).
|
||||
func pushError(L *lua.LState, err error) int {
|
||||
L.RaiseError("%s", err.Error())
|
||||
return 0 // unreachable, but satisfies signature
|
||||
}
|
||||
|
||||
// pushResult pushes a single value to Lua stack and returns 1.
|
||||
func pushResult(L *lua.LState, val lua.LValue) int {
|
||||
L.Push(val)
|
||||
return 1
|
||||
}
|
||||
|
||||
// pushOK pushes true (success) to Lua stack.
|
||||
func pushOK(L *lua.LState) int {
|
||||
L.Push(lua.LBool(true))
|
||||
return 1
|
||||
}
|
||||
|
||||
// checkOptString gets an optional string argument at position.
|
||||
func checkOptString(L *lua.LState, pos int, defaultVal string) string {
|
||||
if L.GetTop() >= pos {
|
||||
return L.CheckString(pos)
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
// checkOptInt gets an optional int argument at position.
|
||||
func checkOptInt(L *lua.LState, pos int, defaultVal int) int {
|
||||
if L.GetTop() >= pos {
|
||||
return L.CheckInt(pos)
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
// checkOptTable gets an optional table argument at position.
|
||||
func checkOptTable(L *lua.LState, pos int) *lua.LTable {
|
||||
if L.GetTop() >= pos && L.Get(pos).Type() == lua.LTTable {
|
||||
return L.CheckTable(pos)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// tableToMap converts a Lua table to map[string]interface{}.
|
||||
// Only handles string keys and basic value types.
|
||||
func tableToMap(tbl *lua.LTable) map[string]interface{} {
|
||||
m := make(map[string]interface{})
|
||||
tbl.ForEach(func(key lua.LValue, val lua.LValue) {
|
||||
k := lua.LVAsString(key)
|
||||
m[k] = luaValueToGo(val)
|
||||
})
|
||||
return m
|
||||
}
|
||||
|
||||
// luaValueToGo converts a Lua value to a Go interface{}.
|
||||
func luaValueToGo(v lua.LValue) interface{} {
|
||||
if v == lua.LNil {
|
||||
return nil
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case lua.LBool:
|
||||
return bool(val)
|
||||
case lua.LString:
|
||||
return string(val)
|
||||
case lua.LNumber:
|
||||
return float64(val)
|
||||
case *lua.LTable:
|
||||
// Detect if it's an array or map
|
||||
if val.MaxN() > 0 {
|
||||
arr := make([]interface{}, 0, val.MaxN())
|
||||
for i := 1; i <= val.MaxN(); i++ {
|
||||
arr = append(arr, luaValueToGo(val.RawGetInt(i)))
|
||||
}
|
||||
return arr
|
||||
}
|
||||
m := make(map[string]interface{})
|
||||
val.ForEach(func(k lua.LValue, v lua.LValue) {
|
||||
m[fmt.Sprintf("%v", k)] = luaValueToGo(v)
|
||||
})
|
||||
return m
|
||||
default:
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
@@ -2,33 +2,92 @@ package plugins
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
// Meta is the plugin.json descriptor.
|
||||
type Meta struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description"`
|
||||
Author string `json:"author"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Author string `json:"author,omitempty"`
|
||||
Hooks map[string]string `json:"hooks,omitempty"`
|
||||
UI *UIContrib `json:"ui,omitempty"`
|
||||
Background []BackgroundTask `json:"background_tasks,omitempty"`
|
||||
NodeTypes []string `json:"node_types,omitempty"`
|
||||
Panel string `json:"panel,omitempty"`
|
||||
Templates []string `json:"templates,omitempty"`
|
||||
Migrations []string `json:"migrations,omitempty"`
|
||||
}
|
||||
|
||||
// Plugin represents a loaded plugin.
|
||||
// UIContrib describes UI contributions from a plugin.
|
||||
type UIContrib struct {
|
||||
SidebarItems []SidebarItem `json:"sidebar_items,omitempty"`
|
||||
NodeTabs []NodeTab `json:"node_tabs,omitempty"`
|
||||
NodeActions []NodeAction `json:"node_actions,omitempty"`
|
||||
SettingsPages []SettingsPage `json:"settings_pages,omitempty"`
|
||||
}
|
||||
|
||||
// SidebarItem is a navigation item in the sidebar.
|
||||
type SidebarItem struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Page string `json:"page"`
|
||||
}
|
||||
|
||||
// NodeTab is an extra tab in the node detail view.
|
||||
type NodeTab struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Page string `json:"page"`
|
||||
}
|
||||
|
||||
// NodeAction is an action button in the node detail.
|
||||
type NodeAction struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Page string `json:"page,omitempty"`
|
||||
}
|
||||
|
||||
// SettingsPage is a plugin settings page in the settings dialog.
|
||||
type SettingsPage struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Page string `json:"page"`
|
||||
}
|
||||
|
||||
// BackgroundTask describes a recurring background task.
|
||||
type BackgroundTask struct {
|
||||
ID string `json:"id"`
|
||||
Interval string `json:"interval"` // e.g. "5m", "1h", "30s"
|
||||
Script string `json:"script"` // relative path to .lua file
|
||||
}
|
||||
|
||||
// Plugin represents a loaded plugin with its runtime.
|
||||
type Plugin struct {
|
||||
Meta Meta
|
||||
Dir string // absolute path to plugin directory
|
||||
Active bool
|
||||
Meta Meta
|
||||
Dir string // absolute path to plugin directory
|
||||
DataDir string // .verstak/plugins/<name>/data — plugin's own SQLite storage
|
||||
Active bool
|
||||
|
||||
// Runtime (set after InitRuntime)
|
||||
vm *LuaVM
|
||||
scheduler *Scheduler
|
||||
}
|
||||
|
||||
// Manager discovers and loads plugins from .verstak/plugins/.
|
||||
type Manager struct {
|
||||
vaultRoot string
|
||||
plugins []Plugin
|
||||
|
||||
// Services exposed to Lua plugin API
|
||||
Services *CoreServices
|
||||
}
|
||||
|
||||
// NewManager creates a plugin manager for a vault.
|
||||
@@ -55,16 +114,139 @@ func (m *Manager) Discover() {
|
||||
}
|
||||
var meta Meta
|
||||
if err := json.Unmarshal(data, &meta); err != nil {
|
||||
log.Printf("[plugins] %s: invalid plugin.json: %v", e.Name(), err)
|
||||
continue
|
||||
}
|
||||
if meta.Name == "" {
|
||||
meta.Name = e.Name()
|
||||
}
|
||||
m.plugins = append(m.plugins, Plugin{
|
||||
Meta: meta,
|
||||
Dir: filepath.Join(pluginsDir, e.Name()),
|
||||
Active: true,
|
||||
})
|
||||
dataDir := filepath.Join(pluginsDir, e.Name(), "data")
|
||||
os.MkdirAll(dataDir, 0o750)
|
||||
|
||||
m.plugins = append(m.plugins, Plugin{
|
||||
Meta: meta,
|
||||
Dir: filepath.Join(pluginsDir, e.Name()),
|
||||
DataDir: dataDir,
|
||||
Active: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// InitRuntimes creates Lua VMs and schedulers for all active plugins.
|
||||
// Must be called after Discover() and before using plugins.
|
||||
func (m *Manager) InitRuntimes() {
|
||||
for i := range m.plugins {
|
||||
if !m.plugins[i].Active {
|
||||
continue
|
||||
}
|
||||
p := &m.plugins[i]
|
||||
|
||||
// Create Lua VM
|
||||
vm, err := NewLuaVM(p)
|
||||
if err != nil {
|
||||
log.Printf("[plugins] %s: failed to create Lua VM: %v", p.Meta.Name, err)
|
||||
p.Active = false
|
||||
continue
|
||||
}
|
||||
p.vm = vm
|
||||
if m.Services != nil {
|
||||
vm.SetServices(m.Services)
|
||||
}
|
||||
|
||||
// Load main.lua if it exists
|
||||
mainPath := filepath.Join(p.Dir, "main.lua")
|
||||
if _, err := os.Stat(mainPath); err == nil {
|
||||
if err := vm.LoadScript("main.lua"); err != nil {
|
||||
log.Printf("[plugins] %s: failed to load main.lua: %v", p.Meta.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create scheduler
|
||||
p.scheduler = NewScheduler(p, vm)
|
||||
for _, bg := range p.Meta.Background {
|
||||
if err := p.scheduler.AddTask(bg); err != nil {
|
||||
log.Printf("[plugins] %s: failed to add task %s: %v", p.Meta.Name, bg.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CallInitHooks calls on_init for all active plugins.
|
||||
func (m *Manager) CallInitHooks() {
|
||||
for i := range m.plugins {
|
||||
if !m.plugins[i].Active {
|
||||
continue
|
||||
}
|
||||
p := &m.plugins[i]
|
||||
if hookName, ok := p.Meta.Hooks["on_init"]; ok && p.vm != nil {
|
||||
if err := p.vm.CallHook(hookName); err != nil {
|
||||
log.Printf("[plugins] %s: on_init error: %v", p.Meta.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CallVaultOpenHooks calls on_vault_open for all active plugins.
|
||||
func (m *Manager) CallVaultOpenHooks(vaultPath string) {
|
||||
for i := range m.plugins {
|
||||
if !m.plugins[i].Active {
|
||||
continue
|
||||
}
|
||||
p := &m.plugins[i]
|
||||
if hookName, ok := p.Meta.Hooks["on_vault_open"]; ok && p.vm != nil {
|
||||
if err := p.vm.CallHook(hookName, lua.LString(vaultPath)); err != nil {
|
||||
log.Printf("[plugins] %s: on_vault_open error: %v", p.Meta.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// StartSchedulers starts background tasks for all active plugins.
|
||||
func (m *Manager) StartSchedulers() {
|
||||
for i := range m.plugins {
|
||||
if !m.plugins[i].Active {
|
||||
continue
|
||||
}
|
||||
p := &m.plugins[i]
|
||||
if p.scheduler != nil {
|
||||
p.scheduler.Start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// StopSchedulers stops all background tasks.
|
||||
func (m *Manager) StopSchedulers() {
|
||||
for i := range m.plugins {
|
||||
p := &m.plugins[i]
|
||||
if p.scheduler != nil {
|
||||
p.scheduler.Stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CallShutdownHooks calls on_shutdown for all active plugins.
|
||||
func (m *Manager) CallShutdownHooks() {
|
||||
for i := range m.plugins {
|
||||
if !m.plugins[i].Active {
|
||||
continue
|
||||
}
|
||||
p := &m.plugins[i]
|
||||
if hookName, ok := p.Meta.Hooks["on_shutdown"]; ok && p.vm != nil {
|
||||
if err := p.vm.CallHook(hookName); err != nil {
|
||||
log.Printf("[plugins] %s: on_shutdown error: %v", p.Meta.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CloseRuntimes shuts down all Lua VMs.
|
||||
func (m *Manager) CloseRuntimes() {
|
||||
for i := range m.plugins {
|
||||
p := &m.plugins[i]
|
||||
if p.vm != nil {
|
||||
p.vm.Close()
|
||||
p.vm = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
// ActivatePlugin fully activates a plugin: creates Lua VM, loads main.lua, starts scheduler.
|
||||
func (m *Manager) ActivatePlugin(name string) {
|
||||
for i := range m.plugins {
|
||||
p := &m.plugins[i]
|
||||
if p.Meta.Name != name || p.Active {
|
||||
continue
|
||||
}
|
||||
p.Active = true
|
||||
|
||||
vm, err := NewLuaVM(p)
|
||||
if err != nil {
|
||||
log.Printf("[plugins] %s: activate VM error: %v", name, err)
|
||||
p.Active = false
|
||||
return
|
||||
}
|
||||
p.vm = vm
|
||||
|
||||
mainPath := filepath.Join(p.Dir, "main.lua")
|
||||
if _, err := os.Stat(mainPath); err == nil {
|
||||
if err := vm.LoadScript("main.lua"); err != nil {
|
||||
log.Printf("[plugins] %s: load main.lua: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
p.scheduler = NewScheduler(p, vm)
|
||||
for _, bg := range p.Meta.Background {
|
||||
if err := p.scheduler.AddTask(bg); err != nil {
|
||||
log.Printf("[plugins] %s: add task %s: %v", name, bg.ID, err)
|
||||
}
|
||||
}
|
||||
p.scheduler.Start()
|
||||
|
||||
if hookName, ok := p.Meta.Hooks["on_init"]; ok {
|
||||
if err := vm.CallHook(hookName); err != nil {
|
||||
log.Printf("[plugins] %s: on_init error: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[plugins] %s: activated", name)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// DeactivatePlugin stops a plugin's runtime without removing it.
|
||||
func (m *Manager) DeactivatePlugin(name string) {
|
||||
for i := range m.plugins {
|
||||
p := &m.plugins[i]
|
||||
if p.Meta.Name != name || !p.Active {
|
||||
continue
|
||||
}
|
||||
|
||||
if p.scheduler != nil {
|
||||
p.scheduler.Stop()
|
||||
p.scheduler = nil
|
||||
}
|
||||
|
||||
if hookName, ok := p.Meta.Hooks["on_shutdown"]; ok && p.vm != nil {
|
||||
_ = p.vm.CallHook(hookName)
|
||||
}
|
||||
if p.vm != nil {
|
||||
p.vm.Close()
|
||||
p.vm = nil
|
||||
}
|
||||
|
||||
p.Active = false
|
||||
log.Printf("[plugins] %s: deactivated", name)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// CallPluginHook calls a named Lua function on a specific plugin.
|
||||
func (m *Manager) CallPluginHook(name, hookName string, args ...lua.LValue) (lua.LValue, error) {
|
||||
for i := range m.plugins {
|
||||
if m.plugins[i].Meta.Name == name && m.plugins[i].Active && m.plugins[i].vm != nil {
|
||||
if fn, ok := m.plugins[i].Meta.Hooks[hookName]; ok {
|
||||
return m.plugins[i].vm.CallHookWithResult(fn, args...)
|
||||
}
|
||||
return m.plugins[i].vm.CallHookWithResult(hookName, args...)
|
||||
}
|
||||
}
|
||||
return lua.LNil, nil
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"verstak/internal/core/activity"
|
||||
"verstak/internal/core/files"
|
||||
"verstak/internal/core/nodes"
|
||||
"verstak/internal/core/storage"
|
||||
"verstak/internal/core/worklog"
|
||||
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
// CoreServices bundles core Verstak services for use by the Lua plugin API.
|
||||
type CoreServices struct {
|
||||
NodeRepo *nodes.Repository
|
||||
DB *storage.DB
|
||||
ActivitySvc *activity.Service
|
||||
WorklogSvc *worklog.Service
|
||||
FilesSvc *files.Service
|
||||
VaultPath string
|
||||
}
|
||||
|
||||
// LuaVM wraps a gopher-lua state for a single plugin.
|
||||
// Each plugin gets its own isolated state; API functions use vm.Services to call core services.
|
||||
type LuaVM struct {
|
||||
L *lua.LState
|
||||
Plugin *Plugin
|
||||
Services *CoreServices
|
||||
mu sync.Mutex
|
||||
done chan struct{}
|
||||
|
||||
// sandbox limits
|
||||
callTimeout time.Duration
|
||||
}
|
||||
|
||||
// NewLuaVM creates a sandboxed Lua VM for a plugin.
|
||||
func NewLuaVM(p *Plugin) (*LuaVM, error) {
|
||||
vm := &LuaVM{
|
||||
Plugin: p,
|
||||
callTimeout: 30 * time.Second,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
L := lua.NewState(lua.Options{
|
||||
SkipOpenLibs: true, // we selectively open safe libs
|
||||
})
|
||||
|
||||
// Open only safe libraries
|
||||
for _, pair := range []struct {
|
||||
lib string
|
||||
fn lua.LGFunction
|
||||
}{
|
||||
{lua.LoadLibName, lua.OpenPackage},
|
||||
{lua.BaseLibName, lua.OpenBase},
|
||||
{lua.TabLibName, lua.OpenTable},
|
||||
{lua.StringLibName, lua.OpenString},
|
||||
{lua.MathLibName, lua.OpenMath},
|
||||
{lua.OsLibName, lua.OpenOs},
|
||||
} {
|
||||
L.Push(L.NewFunction(pair.fn))
|
||||
L.Push(lua.LString(pair.lib))
|
||||
L.Call(1, 0)
|
||||
}
|
||||
|
||||
// Disable dangerous functions
|
||||
for _, name := range []string{"dofile", "loadfile", "require", "module", "rawequal", "rawget", "rawset", "rawlen", "setfenv", "getfenv"} {
|
||||
L.SetGlobal(name, lua.LNil)
|
||||
}
|
||||
|
||||
// Restrict os.* to safe subset
|
||||
osSafe := map[string]bool{
|
||||
"clock": true, "date": true, "difftime": true, "time": true,
|
||||
"tmpname": true,
|
||||
}
|
||||
if osTable := L.GetGlobal("os"); osTable != lua.LNil {
|
||||
if tbl, ok := osTable.(*lua.LTable); ok {
|
||||
for _, k := range []string{"execute", "exit", "remove", "rename", "setlocale", "getenv"} {
|
||||
tbl.RawSetString(k, lua.LNil)
|
||||
}
|
||||
// Only keep safe ones
|
||||
tbl.ForEach(func(k lua.LValue, v lua.LValue) {
|
||||
if ks, ok := k.(lua.LString); ok && !osSafe[string(ks)] {
|
||||
tbl.RawSet(k, lua.LNil)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect print to Go log
|
||||
L.SetGlobal("print", L.NewFunction(func(L *lua.LState) int {
|
||||
top := L.GetTop()
|
||||
var parts []string
|
||||
for i := 1; i <= top; i++ {
|
||||
parts = append(parts, L.Get(i).String())
|
||||
}
|
||||
msg := strings.Join(parts, " ")
|
||||
log.Printf("[lua] %s", msg)
|
||||
return 0
|
||||
}))
|
||||
|
||||
vm.L = L
|
||||
registerAPI(vm)
|
||||
return vm, nil
|
||||
}
|
||||
|
||||
// LoadScript loads and executes a Lua file from the plugin directory.
|
||||
func (vm *LuaVM) LoadScript(filename string) error {
|
||||
path := filepath.Join(vm.Plugin.Dir, filename)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", filename, err)
|
||||
}
|
||||
vm.mu.Lock()
|
||||
defer vm.mu.Unlock()
|
||||
fn, err := vm.L.Load(strings.NewReader(string(data)), filename)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load %s: %w", filename, err)
|
||||
}
|
||||
vm.L.Push(fn)
|
||||
_, err = vm.callWithTimeout(0)
|
||||
return err
|
||||
}
|
||||
|
||||
// CallHook calls a Lua function by name with optional args (no return value expected).
|
||||
func (vm *LuaVM) CallHook(name string, args ...lua.LValue) error {
|
||||
_, err := vm.CallHookWithResult(name, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
// CallHookWithResult calls a Lua function by name and returns its first return value.
|
||||
func (vm *LuaVM) CallHookWithResult(name string, args ...lua.LValue) (lua.LValue, error) {
|
||||
vm.mu.Lock()
|
||||
defer vm.mu.Unlock()
|
||||
fn := vm.L.GetGlobal(name)
|
||||
if fn == lua.LNil {
|
||||
return lua.LNil, nil
|
||||
}
|
||||
if _, ok := fn.(*lua.LFunction); !ok {
|
||||
return lua.LNil, fmt.Errorf("%q is not a function", name)
|
||||
}
|
||||
vm.L.Push(fn)
|
||||
for _, arg := range args {
|
||||
vm.L.Push(arg)
|
||||
}
|
||||
return vm.callWithTimeout(len(args))
|
||||
}
|
||||
|
||||
// Close shuts down the Lua VM.
|
||||
func (vm *LuaVM) Close() {
|
||||
close(vm.done)
|
||||
if vm.L != nil && !vm.L.IsClosed() {
|
||||
vm.L.Close()
|
||||
vm.L = nil
|
||||
}
|
||||
}
|
||||
|
||||
// SetServices sets the core services reference on the VM.
|
||||
func (vm *LuaVM) SetServices(svc *CoreServices) {
|
||||
vm.Services = svc
|
||||
}
|
||||
|
||||
// callWithTimeout runs a PCall with a timeout and returns the first LValue.
|
||||
// nargs is the number of function arguments already on the stack.
|
||||
// Must be called with vm.mu held.
|
||||
func (vm *LuaVM) callWithTimeout(nargs int) (lua.LValue, error) {
|
||||
timeout := vm.callTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
|
||||
// Create a cancellable context for timeout
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
// Set context so gopher-lua's main loop checks ctx.Done()
|
||||
vm.L.SetContext(ctx)
|
||||
|
||||
err := vm.L.PCall(nargs, lua.MultRet, nil)
|
||||
|
||||
// Remove context after call
|
||||
vm.L.RemoveContext()
|
||||
|
||||
// Collect return value (if any)
|
||||
ret := lua.LNil
|
||||
if vm.L.GetTop() > 0 {
|
||||
ret = vm.L.Get(1)
|
||||
vm.L.Pop(1)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return ret, err
|
||||
}
|
||||
|
||||
// Check if timeout occurred
|
||||
if ctx.Err() != nil {
|
||||
return ret, fmt.Errorf("execution timeout (%s)", timeout)
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
func TestLuaVM_HookCall(t *testing.T) {
|
||||
// Create a temp plugin directory
|
||||
dir := t.TempDir()
|
||||
pluginDir := filepath.Join(dir, "testplugin")
|
||||
if err := os.MkdirAll(pluginDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Write a main.lua
|
||||
mainLua := `
|
||||
function on_init()
|
||||
print("hello from lua")
|
||||
end
|
||||
|
||||
function add(a, b)
|
||||
return a + b
|
||||
end
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(pluginDir, "main.lua"), []byte(mainLua), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p := &Plugin{
|
||||
Meta: Meta{
|
||||
Name: "testplugin",
|
||||
Hooks: map[string]string{
|
||||
"on_init": "on_init",
|
||||
},
|
||||
},
|
||||
Dir: pluginDir,
|
||||
Active: true,
|
||||
}
|
||||
|
||||
vm, err := NewLuaVM(p)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLuaVM: %v", err)
|
||||
}
|
||||
defer vm.Close()
|
||||
|
||||
// Load main.lua
|
||||
if err := vm.LoadScript("main.lua"); err != nil {
|
||||
t.Fatalf("LoadScript: %v", err)
|
||||
}
|
||||
|
||||
// Call on_init hook (should not error)
|
||||
if err := vm.CallHook("on_init"); err != nil {
|
||||
t.Fatalf("CallHook(on_init): %v", err)
|
||||
}
|
||||
|
||||
// Call a function with args and check result
|
||||
// Need to use CallHookWithResult for that
|
||||
result, err := vm.CallHookWithResult("add", lua.LNumber(3), lua.LNumber(4))
|
||||
if err != nil {
|
||||
t.Fatalf("CallHookWithResult(add): %v", err)
|
||||
}
|
||||
if num, ok := result.(lua.LNumber); !ok {
|
||||
t.Fatalf("expected LNumber, got %T", result)
|
||||
} else if float64(num) != 7 {
|
||||
t.Fatalf("expected 7, got %v", num)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLuaVM_MissingHook(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pluginDir := filepath.Join(dir, "nohooks")
|
||||
if err := os.MkdirAll(pluginDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Write minimal main.lua (no on_shutdown function)
|
||||
mainLua := `function on_init() end`
|
||||
if err := os.WriteFile(filepath.Join(pluginDir, "main.lua"), []byte(mainLua), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p := &Plugin{
|
||||
Meta: Meta{
|
||||
Name: "nohooks",
|
||||
Hooks: map[string]string{
|
||||
"on_shutdown": "on_shutdown",
|
||||
},
|
||||
},
|
||||
Dir: pluginDir,
|
||||
Active: true,
|
||||
}
|
||||
|
||||
vm, err := NewLuaVM(p)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLuaVM: %v", err)
|
||||
}
|
||||
defer vm.Close()
|
||||
|
||||
if err := vm.LoadScript("main.lua"); err != nil {
|
||||
t.Fatalf("LoadScript: %v", err)
|
||||
}
|
||||
|
||||
// Calling a missing hook should return nil error (hooks are optional)
|
||||
if err := vm.CallHook("on_shutdown"); err != nil {
|
||||
t.Fatalf("CallHook(on_shutdown) on missing function: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLuaVM_Sandbox(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pluginDir := filepath.Join(dir, "sandbox")
|
||||
if err := os.MkdirAll(pluginDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Verify dangerous functions are removed
|
||||
mainLua := `
|
||||
function test_sandbox()
|
||||
local ok, err = pcall(function()
|
||||
return dofile("/etc/passwd")
|
||||
end)
|
||||
return ok, err -- dofile should be nil, so pcall returns false + error
|
||||
end
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(pluginDir, "main.lua"), []byte(mainLua), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p := &Plugin{
|
||||
Meta: Meta{Name: "sandbox"},
|
||||
Dir: pluginDir,
|
||||
Active: true,
|
||||
}
|
||||
|
||||
vm, err := NewLuaVM(p)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLuaVM: %v", err)
|
||||
}
|
||||
defer vm.Close()
|
||||
|
||||
if err := vm.LoadScript("main.lua"); err != nil {
|
||||
t.Fatalf("LoadScript: %v", err)
|
||||
}
|
||||
|
||||
result, err := vm.CallHookWithResult("test_sandbox")
|
||||
if err != nil {
|
||||
t.Fatalf("CallHook: %v", err)
|
||||
}
|
||||
t.Logf("sandbox test result: %v", result)
|
||||
// dofile is nil, so pcall will catch the error and return false
|
||||
// The hook returns the first value from Lua, which should be false
|
||||
if result == lua.LTrue {
|
||||
t.Error("dofile should not be available in sandbox")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLuaVM_State(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pluginDir := filepath.Join(dir, "state")
|
||||
if err := os.MkdirAll(pluginDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Test verstak.state API
|
||||
mainLua := `
|
||||
function test_state()
|
||||
verstak.state.set("counter", 42)
|
||||
verstak.state.set("name", "test")
|
||||
local c = verstak.state.get("counter")
|
||||
local n = verstak.state.get("name")
|
||||
return c, n
|
||||
end
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(pluginDir, "main.lua"), []byte(mainLua), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p := &Plugin{
|
||||
Meta: Meta{Name: "state"},
|
||||
Dir: pluginDir,
|
||||
Active: true,
|
||||
}
|
||||
|
||||
vm, err := NewLuaVM(p)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLuaVM: %v", err)
|
||||
}
|
||||
defer vm.Close()
|
||||
|
||||
if err := vm.LoadScript("main.lua"); err != nil {
|
||||
t.Fatalf("LoadScript: %v", err)
|
||||
}
|
||||
|
||||
_, err = vm.CallHookWithResult("test_state")
|
||||
if err != nil {
|
||||
t.Fatalf("CallHook: %v", err)
|
||||
}
|
||||
// verstak.state.set/get should work (first return = counter = 42)
|
||||
// Currently returns "not implemented" stub error, but we verify the path
|
||||
t.Log("state test completed (stub — will fail if API wasn't callable)")
|
||||
}
|
||||
|
||||
func TestPluginManager_InitRuntimes(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pluginsDir := filepath.Join(dir, ".verstak", "plugins", "testp")
|
||||
if err := os.MkdirAll(pluginsDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Write plugin.json
|
||||
pj := `{
|
||||
"name": "testp",
|
||||
"version": "1.0.0",
|
||||
"hooks": { "on_init": "on_init" }
|
||||
}`
|
||||
if err := os.WriteFile(filepath.Join(pluginsDir, "plugin.json"), []byte(pj), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Write main.lua
|
||||
if err := os.WriteFile(filepath.Join(pluginsDir, "main.lua"), []byte(`function on_init() print("manager init ok") end`), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
mgr := NewManager(dir)
|
||||
mgr.Discover()
|
||||
mgr.InitRuntimes()
|
||||
defer mgr.CloseRuntimes()
|
||||
|
||||
if len(mgr.Plugins()) != 1 {
|
||||
t.Fatalf("expected 1 plugin, got %d", len(mgr.Plugins()))
|
||||
}
|
||||
|
||||
p := mgr.Plugins()[0]
|
||||
if p.Meta.Name != "testp" {
|
||||
t.Fatalf("expected testp, got %s", p.Meta.Name)
|
||||
}
|
||||
if !p.Active {
|
||||
t.Fatal("plugin should be active")
|
||||
}
|
||||
if p.vm == nil {
|
||||
t.Fatal("plugin should have Lua VM")
|
||||
}
|
||||
|
||||
// Call on_init through manager
|
||||
mgr.CallInitHooks()
|
||||
}
|
||||
|
||||
// TestNodeAPI_CreateAndGet verifies that the verstak_node table is present and callable.
|
||||
func TestNodeAPI_CreateAndGet(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pluginDir := filepath.Join(dir, "nodetest")
|
||||
if err := os.MkdirAll(pluginDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p := &Plugin{
|
||||
Meta: Meta{Name: "nodetest"},
|
||||
Dir: pluginDir,
|
||||
}
|
||||
|
||||
vm, err := NewLuaVM(p)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLuaVM: %v", err)
|
||||
}
|
||||
defer vm.Close()
|
||||
|
||||
// Verify verstak_node global table exists
|
||||
nodeTable := vm.L.GetGlobal("verstak_node")
|
||||
if nodeTable == lua.LNil {
|
||||
t.Fatal("verstak_node is nil (expected table)")
|
||||
}
|
||||
if _, ok := nodeTable.(*lua.LTable); !ok {
|
||||
t.Fatalf("verstak_node type: %T (expected *lua.LTable)", nodeTable)
|
||||
}
|
||||
|
||||
// Run a Lua snippet that calls verstak_node.get — should error since no services
|
||||
err = vm.L.DoString(`
|
||||
local ok, err = pcall(verstak_node.get, "fake-id")
|
||||
assert(not ok, "expected error from verstak_node.get with no services")
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("Lua snippet: %v", err)
|
||||
}
|
||||
|
||||
// Verify verstak.node table exists inside main verstak table
|
||||
mainTbl := vm.L.GetGlobal("verstak")
|
||||
if mainTbl == lua.LNil {
|
||||
t.Fatal("verstak global table missing")
|
||||
}
|
||||
verstakTbl, ok := mainTbl.(*lua.LTable)
|
||||
if !ok {
|
||||
t.Fatalf("verstak type: %T", mainTbl)
|
||||
}
|
||||
nodeSubTbl := verstakTbl.RawGetString("node")
|
||||
if nodeSubTbl == lua.LNil {
|
||||
t.Fatal("verstak.node is nil")
|
||||
}
|
||||
|
||||
t.Log("node API present and callable without crash")
|
||||
}
|
||||
|
||||
// TestCalendarPlugin_LoadAndRun runs the calendar plugin's Lua test suite
|
||||
// via the LuaVM to verify migration, CRUD, recurrence, and all other features.
|
||||
func TestCalendarPlugin_LoadAndRun(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Copy the calendar plugin from contrib/plugins/calendar to temp dir
|
||||
calendarSrc := filepath.Join("..", "..", "..", "contrib", "plugins", "calendar")
|
||||
pluginDir := filepath.Join(dir, "calendar")
|
||||
if err := copyDir(calendarSrc, pluginDir); err != nil {
|
||||
t.Fatalf("copy calendar plugin: %v", err)
|
||||
}
|
||||
|
||||
dataDir := filepath.Join(dir, "calendar_data")
|
||||
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p := &Plugin{
|
||||
Meta: Meta{
|
||||
Name: "calendar",
|
||||
Hooks: map[string]string{
|
||||
"on_init": "on_init",
|
||||
},
|
||||
},
|
||||
Dir: pluginDir,
|
||||
DataDir: dataDir,
|
||||
Active: true,
|
||||
}
|
||||
|
||||
vm, err := NewLuaVM(p)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLuaVM: %v", err)
|
||||
}
|
||||
defer vm.Close()
|
||||
|
||||
// Load main.lua
|
||||
if err := vm.LoadScript("main.lua"); err != nil {
|
||||
t.Fatalf("LoadScript(main.lua): %v", err)
|
||||
}
|
||||
|
||||
// Run on_init hook — this runs the migration + default categories
|
||||
if err := vm.CallHook("on_init"); err != nil {
|
||||
t.Fatalf("CallHook(on_init): %v", err)
|
||||
}
|
||||
|
||||
// Run the Lua test suite
|
||||
if err := vm.LoadScript("test_calendar.lua"); err != nil {
|
||||
t.Fatalf("LoadScript(test_calendar.lua): %v", err)
|
||||
}
|
||||
|
||||
t.Log("Calendar plugin: all Lua API tests passed")
|
||||
}
|
||||
|
||||
// copyDir recursively copies src to dst (skips hidden files).
|
||||
func copyDir(src, dst string) error {
|
||||
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(src, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := filepath.Join(dst, rel)
|
||||
|
||||
if info.IsDir() {
|
||||
return os.MkdirAll(target, info.Mode())
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(target, data, info.Mode())
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Task represents a single background task instance.
|
||||
type Task struct {
|
||||
ID string
|
||||
Interval time.Duration
|
||||
Script string // relative path to .lua file (or "hook:name" for a Lua function)
|
||||
IsHook bool // if true, Script is a function name to call via CallHook
|
||||
stopCh chan struct{}
|
||||
stopped bool
|
||||
}
|
||||
|
||||
// Scheduler manages background tasks for a plugin.
|
||||
type Scheduler struct {
|
||||
plugin *Plugin
|
||||
vm *LuaVM
|
||||
tasks []*Task
|
||||
mu sync.Mutex
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// NewScheduler creates a scheduler for a plugin.
|
||||
func NewScheduler(p *Plugin, vm *LuaVM) *Scheduler {
|
||||
return &Scheduler{
|
||||
plugin: p,
|
||||
vm: vm,
|
||||
}
|
||||
}
|
||||
|
||||
// AddTask adds a task from a BackgroundTask definition.
|
||||
func (s *Scheduler) AddTask(bg BackgroundTask) error {
|
||||
dur, err := parseDuration(bg.Interval)
|
||||
if err != nil {
|
||||
return fmt.Errorf("task %s: %w", bg.ID, err)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
task := &Task{
|
||||
ID: bg.ID,
|
||||
Interval: dur,
|
||||
Script: bg.Script,
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
s.tasks = append(s.tasks, task)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start begins all registered tasks.
|
||||
func (s *Scheduler) Start() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for _, t := range s.tasks {
|
||||
if t.stopped {
|
||||
continue
|
||||
}
|
||||
s.wg.Add(1)
|
||||
go s.runTask(t)
|
||||
}
|
||||
}
|
||||
|
||||
// Stop cancels all running tasks and waits for them to finish.
|
||||
func (s *Scheduler) Stop() {
|
||||
s.mu.Lock()
|
||||
for _, t := range s.tasks {
|
||||
if !t.stopped {
|
||||
close(t.stopCh)
|
||||
t.stopped = true
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
s.wg.Wait()
|
||||
}
|
||||
|
||||
func (s *Scheduler) runTask(t *Task) {
|
||||
defer s.wg.Done()
|
||||
|
||||
ticker := time.NewTicker(t.Interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
s.executeTask(t)
|
||||
case <-t.stopCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) executeTask(t *Task) {
|
||||
if s.vm == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if t.IsHook {
|
||||
if err := s.vm.CallHook(t.Script); err != nil {
|
||||
log.Printf("[plugins] task %s/%s hook error: %v", s.plugin.Meta.Name, t.ID, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.vm.LoadScript(t.Script); err != nil {
|
||||
log.Printf("[plugins] task %s/%s script error: %v", s.plugin.Meta.Name, t.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// parseDuration parses a human-readable interval like "5m", "1h", "30s".
|
||||
func parseDuration(s string) (time.Duration, error) {
|
||||
d, err := time.ParseDuration(s)
|
||||
if err == nil {
|
||||
return d, nil
|
||||
}
|
||||
// Try cron-like or other formats later
|
||||
return 0, fmt.Errorf("invalid interval %q: use Go duration format (e.g. 5m, 1h, 30s)", s)
|
||||
}
|
||||
@@ -356,5 +356,7 @@ func hashFileFast(absPath string) (string, int64) {
|
||||
func isUnderVault(absPath, vaultRoot string) bool {
|
||||
absPath, _ = filepath.Abs(absPath)
|
||||
vaultRoot, _ = filepath.Abs(vaultRoot)
|
||||
absPath = filepath.Clean(absPath)
|
||||
vaultRoot = filepath.Clean(vaultRoot)
|
||||
return strings.HasPrefix(absPath, vaultRoot+string(filepath.Separator)) || absPath == vaultRoot
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user