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:
@@ -13,6 +13,7 @@ import (
|
||||
type SystemViewDTO struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
}
|
||||
|
||||
func (a *App) ListSystemViews() []SystemViewDTO {
|
||||
|
||||
@@ -30,10 +30,7 @@ func (a *App) startBridge(appCfg *config.AppConfig) {
|
||||
}
|
||||
}
|
||||
|
||||
srv := bridge.NewServer(bridge.Config{
|
||||
Port: bc.Port,
|
||||
Secret: bc.Secret,
|
||||
}, handler)
|
||||
srv := bridge.NewServer(bc.Secret, handler)
|
||||
|
||||
port, err := srv.Start(bridge.Config{
|
||||
Port: bc.Port,
|
||||
@@ -116,13 +113,14 @@ func bridgeToBrowser(ev bridge.Event) browser.Event {
|
||||
|
||||
// RestartBridge stops and restarts the bridge server with current config.
|
||||
func (a *App) RestartBridge() error {
|
||||
// Stop existing server outside the lock to avoid blocking other bindings.
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
oldBridge := a.bridge
|
||||
a.bridge = nil
|
||||
a.mu.Unlock()
|
||||
|
||||
// Stop existing server
|
||||
if a.bridge != nil {
|
||||
a.bridge.Stop()
|
||||
a.bridge = nil
|
||||
if oldBridge != nil {
|
||||
oldBridge.Stop()
|
||||
}
|
||||
|
||||
// Load config
|
||||
@@ -151,10 +149,7 @@ func (a *App) RestartBridge() error {
|
||||
}
|
||||
}
|
||||
|
||||
srv := bridge.NewServer(bridge.Config{
|
||||
Port: bc.Port,
|
||||
Secret: bc.Secret,
|
||||
}, handler)
|
||||
srv := bridge.NewServer(bc.Secret, handler)
|
||||
|
||||
port, err := srv.Start(bridge.Config{
|
||||
Port: bc.Port,
|
||||
@@ -165,7 +160,10 @@ func (a *App) RestartBridge() error {
|
||||
return fmt.Errorf("bridge restart: %w", err)
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
a.bridge = srv
|
||||
a.mu.Unlock()
|
||||
|
||||
log.Printf("[bridge] restarted on port %d", port)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -199,6 +199,14 @@ func (a *App) initVault(vaultPath string) error {
|
||||
worklogSvc := worklog.NewService(db)
|
||||
searchSvc := search.NewService(db)
|
||||
pm := plugins.NewManager(abs)
|
||||
pm.Services = &plugins.CoreServices{
|
||||
NodeRepo: nodeRepo,
|
||||
DB: db,
|
||||
ActivitySvc: activitySvc,
|
||||
WorklogSvc: worklogSvc,
|
||||
FilesSvc: fileSvc,
|
||||
VaultPath: abs,
|
||||
}
|
||||
pm.Discover()
|
||||
|
||||
templatesReg := templates.NewRegistry()
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"verstak/internal/core/config"
|
||||
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
// PluginDTO represents a discovered plugin with its current state.
|
||||
type PluginDTO struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Author string `json:"author,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Active bool `json:"active"`
|
||||
HasPanel bool `json:"hasPanel"`
|
||||
HasSettings bool `json:"hasSettings"`
|
||||
UIContribs UIContribDTO `json:"uiContribs"`
|
||||
}
|
||||
|
||||
// UIContribDTO describes what a plugin adds to the UI.
|
||||
type UIContribDTO struct {
|
||||
SidebarItems []SidebarItemDTO `json:"sidebarItems"`
|
||||
NodeTabs []NodeTabDTO `json:"nodeTabs"`
|
||||
}
|
||||
|
||||
type SidebarItemDTO struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
}
|
||||
|
||||
type NodeTabDTO struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Page string `json:"page"`
|
||||
}
|
||||
|
||||
// ListPlugins returns all discovered plugins with their current enabled/disabled state.
|
||||
func (a *App) ListPlugins() []PluginDTO {
|
||||
if a.plugins == nil {
|
||||
return nil
|
||||
}
|
||||
all := a.plugins.Plugins()
|
||||
out := make([]PluginDTO, 0, len(all))
|
||||
|
||||
appCfg, _ := config.LoadAppConfig()
|
||||
enabledSet := make(map[string]bool)
|
||||
if appCfg != nil {
|
||||
for _, name := range appCfg.EnabledPlugins {
|
||||
enabledSet[name] = true
|
||||
}
|
||||
}
|
||||
|
||||
for _, p := range all {
|
||||
active := enabledSet[p.Meta.Name] || p.Active
|
||||
contribs := UIContribDTO{}
|
||||
for _, item := range p.Meta.UI.SidebarItems {
|
||||
contribs.SidebarItems = append(contribs.SidebarItems, SidebarItemDTO{
|
||||
ID: item.ID,
|
||||
Label: item.Label,
|
||||
Icon: item.Icon,
|
||||
})
|
||||
}
|
||||
for _, tab := range p.Meta.UI.NodeTabs {
|
||||
contribs.NodeTabs = append(contribs.NodeTabs, NodeTabDTO{
|
||||
ID: tab.ID,
|
||||
Label: tab.Label,
|
||||
Page: tab.Page,
|
||||
})
|
||||
}
|
||||
|
||||
hasPanel := false
|
||||
if p.Meta.Panel != "" {
|
||||
panelPath := filepath.Join(p.Dir, p.Meta.Panel)
|
||||
if _, err := os.Stat(panelPath); err == nil {
|
||||
hasPanel = true
|
||||
}
|
||||
}
|
||||
|
||||
out = append(out, PluginDTO{
|
||||
Name: p.Meta.Name,
|
||||
Version: p.Meta.Version,
|
||||
Author: p.Meta.Author,
|
||||
Description: p.Meta.Description,
|
||||
Active: active,
|
||||
HasPanel: hasPanel,
|
||||
UIContribs: contribs,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SetPluginEnabled persists the enabled/disabled state and applies it to the runtime.
|
||||
func (a *App) SetPluginEnabled(name string, enabled bool) error {
|
||||
if a.plugins == nil {
|
||||
return fmt.Errorf("plugin manager not ready")
|
||||
}
|
||||
|
||||
appCfg, _ := config.LoadAppConfig()
|
||||
if appCfg == nil {
|
||||
appCfg = config.DefaultAppConfig()
|
||||
}
|
||||
|
||||
existing := make(map[string]bool)
|
||||
for _, n := range appCfg.EnabledPlugins {
|
||||
existing[n] = true
|
||||
}
|
||||
if enabled {
|
||||
existing[name] = true
|
||||
} else {
|
||||
delete(existing, name)
|
||||
}
|
||||
appCfg.EnabledPlugins = make([]string, 0, len(existing))
|
||||
for n := range existing {
|
||||
appCfg.EnabledPlugins = append(appCfg.EnabledPlugins, n)
|
||||
}
|
||||
if err := config.SaveAppConfig(appCfg); err != nil {
|
||||
return fmt.Errorf("save config: %w", err)
|
||||
}
|
||||
|
||||
if enabled {
|
||||
a.plugins.ActivatePlugin(name)
|
||||
} else {
|
||||
a.plugins.DeactivatePlugin(name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPluginPanelHTML returns the HTML panel content for a plugin.
|
||||
func (a *App) GetPluginPanelHTML(pluginName string) (string, error) {
|
||||
if a.plugins == nil {
|
||||
return "", fmt.Errorf("plugin manager not ready")
|
||||
}
|
||||
for _, p := range a.plugins.Plugins() {
|
||||
if p.Meta.Name != pluginName || !p.Active {
|
||||
continue
|
||||
}
|
||||
if p.Meta.Panel == "" {
|
||||
return "", nil
|
||||
}
|
||||
panelPath := filepath.Join(p.Dir, p.Meta.Panel)
|
||||
data, err := os.ReadFile(panelPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read panel %s: %w", p.Meta.Panel, err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// ListSystemViewsWithPlugins returns system views + plugin sidebar items.
|
||||
func (a *App) ListSystemViewsWithPlugins() []SystemViewDTO {
|
||||
base := a.ListSystemViews()
|
||||
if a.plugins == nil {
|
||||
return base
|
||||
}
|
||||
|
||||
appCfg, _ := config.LoadAppConfig()
|
||||
enabledSet := make(map[string]bool)
|
||||
if appCfg != nil {
|
||||
for _, name := range appCfg.EnabledPlugins {
|
||||
enabledSet[name] = true
|
||||
}
|
||||
}
|
||||
|
||||
for _, p := range a.plugins.Plugins() {
|
||||
active := enabledSet[p.Meta.Name] || p.Active
|
||||
if !active {
|
||||
continue
|
||||
}
|
||||
for _, item := range p.Meta.UI.SidebarItems {
|
||||
pageID := "plugin:" + p.Meta.Name + ":" + item.ID
|
||||
base = append(base, SystemViewDTO{
|
||||
ID: pageID,
|
||||
Label: item.Label,
|
||||
Icon: item.Icon,
|
||||
})
|
||||
}
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// CallPluginAction invokes a named Lua hook on a specific plugin.
|
||||
func (a *App) CallPluginAction(pluginName, action string, paramsJSON string) (string, error) {
|
||||
if a.plugins == nil {
|
||||
return "", fmt.Errorf("plugin manager not ready")
|
||||
}
|
||||
result, err := a.plugins.CallPluginHook(pluginName, action, lua.LString(paramsJSON))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("plugin call %s: %w", action, err)
|
||||
}
|
||||
return result.String(), nil
|
||||
}
|
||||
|
||||
// ReloadPlugins re-scans the plugins directory and re-initializes runtimes.
|
||||
func (a *App) ReloadPlugins() error {
|
||||
if a.plugins == nil {
|
||||
return fmt.Errorf("plugin manager not ready")
|
||||
}
|
||||
log.Print("[plugins] reload requested")
|
||||
a.plugins.CloseRuntimes()
|
||||
|
||||
appCfg, _ := config.LoadAppConfig()
|
||||
if appCfg == nil {
|
||||
appCfg = config.DefaultAppConfig()
|
||||
}
|
||||
enabledSet := make(map[string]bool)
|
||||
for _, name := range appCfg.EnabledPlugins {
|
||||
enabledSet[name] = true
|
||||
}
|
||||
a.plugins.Discover()
|
||||
a.plugins.InitRuntimes()
|
||||
|
||||
// Apply enable/disable state from config: deactivate everything not in enabled set
|
||||
for _, p := range a.plugins.Plugins() {
|
||||
if !enabledSet[p.Meta.Name] {
|
||||
a.plugins.DeactivatePlugin(p.Meta.Name)
|
||||
}
|
||||
}
|
||||
|
||||
a.plugins.CallInitHooks()
|
||||
a.plugins.StartSchedulers()
|
||||
log.Print("[plugins] reload complete")
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user