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