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:
2026-05-31 11:20:45 +08:00
parent d6f7f1a9b8
commit b800bce7e4
12 changed files with 653 additions and 21 deletions
@@ -0,0 +1,10 @@
{
"name": "Клиент",
"root_type": "case",
"tree": [
{ "type": "note", "title": "Overview" },
{ "type": "folder", "title": "Документы" },
{ "type": "folder", "title": "Переписка" },
{ "type": "folder", "title": "Скриншоты" }
]
}
+29
View File
@@ -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
}
+218
View File
@@ -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
}
+162
View File
@@ -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()))
}
}