verstak/internal/core/plugins/api_ext.go

672 lines
17 KiB
Go

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))
}
}