step 10: plugins system (Lua + templates) + DokuWiki as optional plugin
Plugin Manager: - Discover plugins from .verstak/plugins/<name>/plugin.json - Enable/disable per plugin - Template definitions (JSON) → pre-filled node trees - SQL migrations from plugins - Built-in templates loaded from internal/core/plugins/builtin/templates/ Lua Runtime: - Stub (gopher-lua placeholder) — ready for real implementation - When dep added: hooks (on_init, on_vault_open, on_node_create), sandbox (no io/os.execute), Plugin API GUI: - Template selector in create node modal - POST /api/nodes/from-template creates tree from template - Built-in "Клиент" template: Overview note + Документы/Переписка/Скриншоты CLI: - verstak plugin list/enable/disable/templates DokuWiki Importer: - Moved to contrib/plugins/importer-dokuwiki/ (optional plugin) - plugin.json + migration + README DokuWiki removed from MVP core — now an opt-in plugin. Acceptance: go build ./... pass, go test ./... pass (all packages).
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "Клиент",
|
||||
"root_type": "case",
|
||||
"tree": [
|
||||
{ "type": "note", "title": "Overview" },
|
||||
{ "type": "folder", "title": "Документы" },
|
||||
{ "type": "folder", "title": "Переписка" },
|
||||
{ "type": "folder", "title": "Скриншоты" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package plugins
|
||||
|
||||
// LuaRuntime is a placeholder for the Lua plugin runtime.
|
||||
// When gopher-lua is available (go get github.com/yuin/gopher-lua),
|
||||
// this will be replaced with a real implementation.
|
||||
//
|
||||
// For now, the plugin system works with:
|
||||
// - plugin.json discovery
|
||||
// - template-based node creation
|
||||
// - SQL migrations from plugins
|
||||
// - enable/disable via CLI
|
||||
type LuaRuntime struct{}
|
||||
|
||||
// NewLuaRuntime creates a Lua runtime (stub).
|
||||
func NewLuaRuntime() *LuaRuntime {
|
||||
return &LuaRuntime{}
|
||||
}
|
||||
|
||||
// LoadPlugin loads a plugin's main.lua (stub — no-op).
|
||||
func (r *LuaRuntime) LoadPlugin(dir string) error {
|
||||
// TODO: implement when gopher-lua is available
|
||||
return nil
|
||||
}
|
||||
|
||||
// CallHook calls a Lua hook function (stub — no-op).
|
||||
func (r *LuaRuntime) CallHook(hook string, args ...interface{}) error {
|
||||
// TODO: implement when gopher-lua is available
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 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"`
|
||||
Hooks map[string]string `json:"hooks,omitempty"`
|
||||
NodeTypes []string `json:"node_types,omitempty"`
|
||||
Templates []string `json:"templates,omitempty"`
|
||||
Migrations []string `json:"migrations,omitempty"`
|
||||
}
|
||||
|
||||
// Plugin represents a loaded plugin.
|
||||
type Plugin struct {
|
||||
Meta Meta
|
||||
Dir string // absolute path to plugin directory
|
||||
Active bool
|
||||
}
|
||||
|
||||
// Manager discovers and loads plugins from .verstak/plugins/.
|
||||
type Manager struct {
|
||||
vaultRoot string
|
||||
plugins []Plugin
|
||||
}
|
||||
|
||||
// NewManager creates a plugin manager for a vault.
|
||||
func NewManager(vaultRoot string) *Manager {
|
||||
return &Manager{vaultRoot: vaultRoot}
|
||||
}
|
||||
|
||||
// Discover scans .verstak/plugins/* for plugin.json files.
|
||||
func (m *Manager) Discover() {
|
||||
pluginsDir := filepath.Join(m.vaultRoot, ".verstak", "plugins")
|
||||
entries, err := os.ReadDir(pluginsDir)
|
||||
if err != nil {
|
||||
return // no plugins dir — OK
|
||||
}
|
||||
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
metaPath := filepath.Join(pluginsDir, e.Name(), "plugin.json")
|
||||
data, err := os.ReadFile(metaPath)
|
||||
if err != nil {
|
||||
continue // no plugin.json — skip
|
||||
}
|
||||
var meta Meta
|
||||
if err := json.Unmarshal(data, &meta); err != nil {
|
||||
continue
|
||||
}
|
||||
if meta.Name == "" {
|
||||
meta.Name = e.Name()
|
||||
}
|
||||
m.plugins = append(m.plugins, Plugin{
|
||||
Meta: meta,
|
||||
Dir: filepath.Join(pluginsDir, e.Name()),
|
||||
Active: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Plugins returns all discovered plugins.
|
||||
func (m *Manager) Plugins() []Plugin {
|
||||
return m.plugins
|
||||
}
|
||||
|
||||
// Active returns only active plugins.
|
||||
func (m *Manager) Active() []Plugin {
|
||||
var out []Plugin
|
||||
for _, p := range m.plugins {
|
||||
if p.Active {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Templates returns all templates from all active plugins + builtins.
|
||||
func (m *Manager) Templates() []TemplateDefinition {
|
||||
var out []TemplateDefinition
|
||||
// Built-in templates.
|
||||
for _, t := range builtinTemplates {
|
||||
out = append(out, t)
|
||||
}
|
||||
// Plugin templates.
|
||||
for _, p := range m.Active() {
|
||||
for _, tmplName := range p.Meta.Templates {
|
||||
tmplPath := filepath.Join(p.Dir, "templates", tmplName+".json")
|
||||
data, err := os.ReadFile(tmplPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var tmpl TemplateDefinition
|
||||
if err := json.Unmarshal(data, &tmpl); err != nil {
|
||||
continue
|
||||
}
|
||||
if tmpl.Name == "" {
|
||||
tmpl.Name = tmplName
|
||||
}
|
||||
tmpl.Plugin = p.Meta.Name
|
||||
out = append(out, tmpl)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TemplateDefinition describes a predefined tree of nodes.
|
||||
type TemplateDefinition struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Plugin string // source plugin name
|
||||
RootType string `json:"root_type"`
|
||||
Tree []TreeNode `json:"tree"`
|
||||
Meta []NodeMeta `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
// TreeNode is a single item in a template tree.
|
||||
type TreeNode struct {
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Slug string `json:"slug,omitempty"`
|
||||
Children []TreeNode `json:"children,omitempty"`
|
||||
}
|
||||
|
||||
// NodeMeta is key-value metadata for the root node.
|
||||
type NodeMeta struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Type string `json:"type"` // text, url, etc.
|
||||
}
|
||||
|
||||
// Enable activates a plugin by name.
|
||||
func (m *Manager) Enable(name string) {
|
||||
for i := range m.plugins {
|
||||
if m.plugins[i].Meta.Name == name {
|
||||
m.plugins[i].Active = true
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Disable deactivates a plugin by name.
|
||||
func (m *Manager) Disable(name string) {
|
||||
for i := range m.plugins {
|
||||
if m.plugins[i].Meta.Name == name {
|
||||
m.plugins[i].Active = false
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ActiveNames returns names of active plugins.
|
||||
func (m *Manager) ActiveNames() []string {
|
||||
var out []string
|
||||
for _, p := range m.Active() {
|
||||
out = append(out, p.Meta.Name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MigrationFiles returns paths to SQL migration files from active plugins.
|
||||
func (m *Manager) MigrationFiles() []string {
|
||||
var out []string
|
||||
for _, p := range m.Active() {
|
||||
for _, mig := range p.Meta.Migrations {
|
||||
path := filepath.Join(p.Dir, "migrations", mig)
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
out = append(out, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Silence unused strings import.
|
||||
var _ = strings.ToLower
|
||||
|
||||
// builtinTemplates are shipped with the application.
|
||||
var builtinTemplates = loadBuiltinTemplates()
|
||||
|
||||
func loadBuiltinTemplates() []TemplateDefinition {
|
||||
var out []TemplateDefinition
|
||||
dir := "internal/core/plugins/builtin/templates"
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(dir, e.Name()))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var tmpl TemplateDefinition
|
||||
if err := json.Unmarshal(data, &tmpl); err != nil {
|
||||
continue
|
||||
}
|
||||
if tmpl.Name == "" {
|
||||
tmpl.Name = strings.TrimSuffix(e.Name(), ".json")
|
||||
}
|
||||
tmpl.Plugin = "builtin"
|
||||
out = append(out, tmpl)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type fsDir struct {
|
||||
name string
|
||||
files map[string][]byte
|
||||
dirs map[string]*fsDir
|
||||
}
|
||||
|
||||
func setupPluginDir(t *testing.T, plugins map[string]*fsDir) string {
|
||||
t.Helper()
|
||||
base := filepath.Join(t.TempDir(), ".verstak", "plugins")
|
||||
os.MkdirAll(base, 0o750)
|
||||
|
||||
for name, plugin := range plugins {
|
||||
pDir := filepath.Join(base, name)
|
||||
os.MkdirAll(pDir, 0o750)
|
||||
for fname, content := range plugin.files {
|
||||
os.WriteFile(filepath.Join(pDir, fname), content, 0o640)
|
||||
}
|
||||
for dname, d := range plugin.dirs {
|
||||
dDir := filepath.Join(pDir, dname)
|
||||
os.MkdirAll(dDir, 0o750)
|
||||
for fname, content := range d.files {
|
||||
os.WriteFile(filepath.Join(dDir, fname), content, 0o640)
|
||||
}
|
||||
}
|
||||
}
|
||||
return filepath.Dir(filepath.Dir(base)) // vault root
|
||||
}
|
||||
|
||||
func TestDiscover(t *testing.T) {
|
||||
root := setupPluginDir(t, map[string]*fsDir{
|
||||
"client": {
|
||||
files: map[string][]byte{
|
||||
"plugin.json": []byte(`{
|
||||
"name": "client",
|
||||
"version": "1.0.0",
|
||||
"description": "Client template",
|
||||
"templates": ["client"]
|
||||
}`),
|
||||
},
|
||||
dirs: map[string]*fsDir{
|
||||
"templates": {
|
||||
files: map[string][]byte{
|
||||
"client.json": []byte(`{
|
||||
"name": "Клиент",
|
||||
"root_type": "case",
|
||||
"tree": [
|
||||
{"type": "folder", "title": "Документы"},
|
||||
{"type": "note", "title": "Overview"},
|
||||
{"type": "folder", "title": "Переписка"}
|
||||
]
|
||||
}`),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"empty-dir": {},
|
||||
"no-json": {
|
||||
files: map[string][]byte{"README.md": []byte("hi")},
|
||||
},
|
||||
})
|
||||
|
||||
mgr := NewManager(root)
|
||||
mgr.Discover()
|
||||
|
||||
plugins := mgr.Plugins()
|
||||
if len(plugins) != 1 {
|
||||
t.Errorf("plugins = %d, want 1", len(plugins))
|
||||
}
|
||||
if len(plugins) > 0 && plugins[0].Meta.Name != "client" {
|
||||
t.Errorf("plugin name = %q", plugins[0].Meta.Name)
|
||||
}
|
||||
|
||||
// Templates.
|
||||
tmpls := mgr.Templates()
|
||||
if len(tmpls) != 1 {
|
||||
t.Errorf("templates = %d, want 1", len(tmpls))
|
||||
}
|
||||
if len(tmpls) > 0 {
|
||||
tmpl := tmpls[0]
|
||||
if tmpl.Name != "Клиент" {
|
||||
t.Errorf("template name = %q", tmpl.Name)
|
||||
}
|
||||
if tmpl.Plugin != "client" {
|
||||
t.Errorf("template plugin = %q", tmpl.Plugin)
|
||||
}
|
||||
if len(tmpl.Tree) != 3 {
|
||||
t.Errorf("template tree = %d items, want 3", len(tmpl.Tree))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnableDisable(t *testing.T) {
|
||||
root := setupPluginDir(t, map[string]*fsDir{
|
||||
"plugin-a": {
|
||||
files: map[string][]byte{
|
||||
"plugin.json": []byte(`{"name": "a", "version": "1.0"}`),
|
||||
},
|
||||
},
|
||||
"plugin-b": {
|
||||
files: map[string][]byte{
|
||||
"plugin.json": []byte(`{"name": "b", "version": "1.0"}`),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
mgr := NewManager(root)
|
||||
mgr.Discover()
|
||||
|
||||
if len(mgr.Plugins()) != 2 {
|
||||
t.Fatalf("plugins = %d, want 2", len(mgr.Plugins()))
|
||||
}
|
||||
|
||||
// All active by default.
|
||||
if len(mgr.Active()) != 2 {
|
||||
t.Errorf("active = %d, want 2", len(mgr.Active()))
|
||||
}
|
||||
|
||||
// Disable one.
|
||||
mgr.Disable("a")
|
||||
if len(mgr.Active()) != 1 {
|
||||
t.Errorf("active after disable = %d, want 1", len(mgr.Active()))
|
||||
}
|
||||
|
||||
// Re-enable.
|
||||
mgr.Enable("a")
|
||||
if len(mgr.Active()) != 2 {
|
||||
t.Errorf("active after enable = %d, want 2", len(mgr.Active()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestActiveNames(t *testing.T) {
|
||||
root := setupPluginDir(t, map[string]*fsDir{
|
||||
"p1": {files: map[string][]byte{"plugin.json": []byte(`{"name":"p1"}`)}},
|
||||
"p2": {files: map[string][]byte{"plugin.json": []byte(`{"name":"p2"}`)}},
|
||||
})
|
||||
|
||||
mgr := NewManager(root)
|
||||
mgr.Discover()
|
||||
mgr.Disable("p1")
|
||||
|
||||
names := mgr.ActiveNames()
|
||||
if len(names) != 1 || names[0] != "p2" {
|
||||
t.Errorf("active names = %v, want [p2]", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoPluginsDir(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mgr := NewManager(root)
|
||||
mgr.Discover() // Should not crash.
|
||||
if len(mgr.Plugins()) != 0 {
|
||||
t.Errorf("plugins = %d, want 0", len(mgr.Plugins()))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user