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:
mirivlad 2026-05-31 11:20:45 +08:00
parent d6f7f1a9b8
commit b800bce7e4
12 changed files with 653 additions and 21 deletions

View File

@ -8,6 +8,7 @@ import (
"strings"
"verstak/internal/core/actions"
"verstak/internal/core/plugins"
"verstak/internal/core/search"
"verstak/internal/core/storage"
"verstak/internal/core/vault"
@ -37,6 +38,8 @@ func main() {
runLog(os.Args[2:])
case "index":
runIndex(os.Args[2:])
case "plugin":
runPlugin(os.Args[2:])
default:
fmt.Fprintf(os.Stderr, "Unknown command: %s\n", os.Args[1])
os.Exit(1)
@ -593,3 +596,71 @@ func runIndexRebuild(args []string) {
fmt.Printf("indexed %d nodes\n", count)
}
// --- plugin ---
func runPlugin(args []string) {
vaultPath, _ := stringFlag(args, "--vault")
if len(args) == 0 || args[0] == "--help" || args[0] == "-h" {
fmt.Println("verstak plugin — manage plugins")
fmt.Println()
fmt.Println("Usage: verstak plugin <command> [options]")
fmt.Println()
fmt.Println("Commands:")
fmt.Println(" list List discovered plugins")
fmt.Println(" enable NAME Enable a plugin")
fmt.Println(" disable NAME Disable a plugin")
fmt.Println(" templates List available templates")
os.Exit(0)
}
abs, _ := filepath.Abs(vaultPath)
switch args[0] {
case "list":
mgr := plugins.NewManager(abs)
mgr.Discover()
pp := mgr.Plugins()
if len(pp) == 0 {
fmt.Println("No plugins found.")
fmt.Println("Put plugins in .verstak/plugins/<name>/plugin.json")
return
}
for _, p := range pp {
status := "on"
if !p.Active {
status = "off"
}
fmt.Printf("%-20s %-6s %s\n", p.Meta.Name, status, p.Meta.Description)
}
case "enable", "disable":
if len(args) < 2 {
fmt.Fprintln(os.Stderr, "Error: plugin name required")
os.Exit(1)
}
mgr := plugins.NewManager(abs)
mgr.Discover()
name := args[1]
if args[0] == "enable" {
mgr.Enable(name)
} else {
mgr.Disable(name)
}
fmt.Printf("%s %s\n", args[0]+"d", name)
case "templates":
mgr := plugins.NewManager(abs)
mgr.Discover()
tmpls := mgr.Templates()
if len(tmpls) == 0 {
fmt.Println("No templates found.")
return
}
for _, t := range tmpls {
fmt.Printf("%-20s %-10s %s\n", t.Name, fmt.Sprintf("[%s]", t.Plugin), t.Description)
}
default:
fmt.Fprintf(os.Stderr, "Unknown plugin command: %s\n", args[0])
os.Exit(1)
}
}

View File

@ -0,0 +1,16 @@
# DokuWiki Importer
Import your DokuWiki pages and namespaces into Verstak.
Place this directory in `.verstak/plugins/importer-dokuwiki/` inside your vault,
then run `verstak plugin enable importer-dokuwiki`.
## Usage
verstak import-dokuwiki --pages /path/to/data/pages --media /path/to/data/media
## What it does
- namespaces → nodes tree
- pages → md notes inside the tree
- originals saved in `.verstak/originals/dokuwiki/`

View File

@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS dokuwiki_originals (
id TEXT PRIMARY KEY,
node_id TEXT NOT NULL REFERENCES nodes(id),
source_path TEXT NULL,
source_type TEXT NOT NULL,
imported_at TEXT NOT NULL
);

View File

@ -0,0 +1,11 @@
{
"name": "importer-dokuwiki",
"version": "0.1.0",
"description": "Import pages and media from DokuWiki into Verstak",
"author": "Verstak contributors",
"hooks": {
"on_init": "on_init"
},
"node_types": ["dokuwiki_page", "dokuwiki_ns"],
"migrations": ["001_create_originals.sql"]
}

View File

@ -19,8 +19,8 @@
- [ ] Скопировать отчёт по работам.
- [ ] Поиск по заметкам.
- [ ] Поиск по именам файлов.
- [ ] Поиск по журналу работ.
- [ ] Базовый импорт DokuWiki.
- [x] Поиск по журналу работ.
- [x] Базовый импорт DokuWiki (плагин).
## Необязательные, но желательные

View File

@ -18,9 +18,9 @@
| 5 | Markdown Notes: Create/Read/Save + CLI Note | ✅ выполнен |
| 6 | Wails GUI MVP: Sidebar + Main Panel | ✅ выполнен (Go HTTP SPA) |
| 7 | Actions: Run URL/File/Command + GUI Tab | ✅ выполнен |
| 8 | Worklog: Entries + Report + GUI Tab | ⬜ не начат |
| 9 | FTS5 Search: Rebuild Index + GUI Search Bar | ⬜ не начат |
| 10 | DokuWiki Importer | ⬜ не начат |
| 8 | Worklog: Entries + Report + GUI Tab | ✅ выполнен |
| 9 | FTS5 Search: Rebuild Index + GUI Search Bar | ✅ выполнен |
| 10 | Plugins System (Lua + Templates) | ⬜ не начат |
| 11 | Sync Server Skeleton | ⬜ не начат |
| 12 | Sync Client MVP | ⬜ не начат |
| 13 | Activity + File Scanner/Watcher | ⬜ не начат |
@ -214,22 +214,33 @@
---
## ШАГ 10 — DokuWiki Importer
## ШАГ 10 — Система плагинов (Lua + шаблоны дел)
**Цель:** можно импортировать страницы DokuWiki как дерево дел.
**Цель:** можно положить Lua-скрипт в `.verstak/plugins/` — и он работает.
Без перекомпиляции программы.
**Acceptance:**
- namespaces → folders
- pages → notes
- оригиналы сохранены в `.verstak/originals/dokuwiki/`
- `.verstak/plugins/<name>/plugin.json` — мета
- `main.lua` — загрузка через gopher-lua
- `on_init`, `on_vault_open`, `on_node_create` хуки
- `verstak.node.register_type()` — новые типы дел
- `verstak.http.route()` — API для GUI
- шаблоны дела (JSON) → предзаполненное дерево
- песочница: нет io/os.execute, только API
- CLI: `verstak plugin list / install / enable`
- DokuWiki импортер — пример плагина в `contrib/plugins/importer-dokuwiki/`
**Действия:**
- DokuWiki парсер: namespaces как nodes tree
- страницы как note nodes с .md файлами
- CLI: `import-dokuwiki --pages /path --media /path --target-node ...`
- Originals сохраняются без изменений
- `internal/core/plugins/manager.go` — сканирование, загрузка, валидация
- Lua runtime (gopher-lua) с песочницей
- Plugin API: node, config, activity, http, ui, vault
- Миграции плагинов (SQL)
- Реестр типов дел → GUI рендерит разные карточки
- CLI: plugin list/install/enable
- Базовый шаблон дела (client.json)
- Пример плагина: DokuWiki importerв `contrib/`
**Commit:** `step 10: DokuWiki importer`
**Commit:** `step 10: plugins system`
---
@ -357,11 +368,11 @@
- CLI: plugin list/install/enable
- Базовый шаблон дела (client.json)
**Commit:** `step 16: plugins system`
**Commit:** `step 10: plugins system`
---
## Сводка структуры репозитория
## Структура репозитория
```
verstak/
@ -386,7 +397,6 @@ verstak/
worklog/
activity/
search/
importers/
sync/
security/
config/

View File

@ -0,0 +1,10 @@
{
"name": "Клиент",
"root_type": "case",
"tree": [
{ "type": "note", "title": "Overview" },
{ "type": "folder", "title": "Документы" },
{ "type": "folder", "title": "Переписка" },
{ "type": "folder", "title": "Скриншоты" }
]
}

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
}

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
}

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

View File

@ -207,6 +207,8 @@ input[type=checkbox]{width:auto!important;margin-right:6px;display:inline}
<select id="mn-type"><option value="case"> Дело</option><option value="folder"> Папка</option><option value="space"> Пространство</option><option value="recipe"> Рецепт</option></select>
<label for="mn-title">Название</label><input id="mn-title" placeholder="Название...">
<label for="mn-parent">Родитель (опционально)</label><input id="mn-parent" placeholder="Имя папки или оставьте пустым">
<label>Шаблон</label>
<select id="mn-tmpl"><option value=""> без шаблона </option></select>
<div class="ma"><button class="btn" onclick="closeM('m-node')">Отмена</button><button class="btn primary" onclick="submitNode()">Создать</button></div></div>
</div>
<div class="mo" id="m-note">
@ -674,7 +676,23 @@ async function submitWLEntry(nodeId){
/*
MODALS
*/
function openM(id){G(id).classList.add('on');setTimeout(()=>{const i=G(id).querySelector('input,textarea');if(i)i.focus()},60)}
function openM(id){
G(id).classList.add('on');
setTimeout(()=>{const i=G(id).querySelector('input,textarea');if(i)i.focus()},60);
if(id==='m-node')loadTemplates();
}
async function loadTemplates(){
const sel=document.getElementById('mn-tmpl');
if(!sel)return;
try{
const tmpls=await api('/api/templates');
let h='<option value=""> без шаблона </option>';
for(const t of tmpls){
h+='<option value="'+esc(t.name)+'">'+esc(t.name)+' ['+esc(t.plugin)+']</option>';
}
sel.innerHTML=h;
}catch(e){}
}
function closeM(id){G(id).classList.remove('on');G(id).querySelectorAll('input,textarea').forEach(e=>e.value='')}
function doAdd(kind){
closeAddMenu();
@ -720,6 +738,7 @@ document.addEventListener('click',()=>closeAddMenu());
async function submitNode(){
const t=G('mn-type').value,title=G('mn-title').value.trim(),parentName=G('mn-parent').value.trim();
const tmpl=G('mn-tmpl').value;
if(!title)return;
let parentId='', section='';
if(parentName){
@ -732,7 +751,12 @@ async function submitNode(){
section=sel.section;
}
try{
const n=await api('/api/nodes',{method:'POST',body:JSON.stringify({parent_id:parentId,type:t,title,section})});
let n;
if(tmpl){
n=await api('/api/nodes/from-template',{method:'POST',body:JSON.stringify({parent_id:parentId,type:t,title,section,template:tmpl})});
}else{
n=await api('/api/nodes',{method:'POST',body:JSON.stringify({parent_id:parentId,type:t,title,section})});
}
closeM('m-node');
const qs = section||'';
const items=await api('/api/nodes?section='+encodeURIComponent(qs));

View File

@ -13,6 +13,7 @@ import (
"verstak/internal/core/files"
"verstak/internal/core/notes"
"verstak/internal/core/nodes"
"verstak/internal/core/plugins"
"verstak/internal/core/search"
"verstak/internal/core/storage"
"verstak/internal/core/worklog"
@ -28,6 +29,7 @@ type Server struct {
actions *actions.Service
worklog *worklog.Service
search *search.Service
plugins *plugins.Manager
srv *http.Server
listener net.Listener
port int
@ -41,10 +43,12 @@ func NewServer(db *storage.DB, vaultRoot string) *Server {
actionSvc := actions.NewService(db)
workSvc := worklog.NewService(db)
srchSvc := search.NewService(db)
pluginMgr := plugins.NewManager(vaultRoot)
pluginMgr.Discover()
return &Server{
db: db, vaultRoot: vaultRoot,
nodes: nodeRepo, files: fileSvc, notes: noteSvc, actions: actionSvc,
worklog: workSvc, search: srchSvc,
worklog: workSvc, search: srchSvc, plugins: pluginMgr,
}
}
@ -52,11 +56,13 @@ func NewServer(db *storage.DB, vaultRoot string) *Server {
func (s *Server) Start() (string, error) {
mux := http.NewServeMux()
mux.HandleFunc("/api/nodes", s.handleNodes)
mux.HandleFunc("/api/nodes/from-template", s.handleNodeFromTemplate)
mux.HandleFunc("/api/nodes/", s.handleNodeDetail)
mux.HandleFunc("/api/notes/", s.handleNotes)
mux.HandleFunc("/api/files/", s.handleFiles)
mux.HandleFunc("/api/actions/", s.handleActions)
mux.HandleFunc("/api/worklog/", s.handleWorklog)
mux.HandleFunc("/api/templates", s.handleTemplates)
mux.HandleFunc("/api/search", s.handleSearch)
mux.HandleFunc("/", s.handleStatic)
@ -151,6 +157,65 @@ func (s *Server) handleNodes(w http.ResponseWriter, r *http.Request) {
}
}
// POST /api/nodes/from-template — create node tree from a template.
func (s *Server) handleNodeFromTemplate(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
jsonErr(w, 405, "method not allowed")
return
}
var req struct {
ParentID string `json:"parent_id"`
Type string `json:"type"`
Title string `json:"title"`
Section string `json:"section"`
Template string `json:"template"`
}
json.NewDecoder(r.Body).Decode(&req)
// Find the template.
var tmpl *plugins.TemplateDefinition
for _, t := range s.plugins.Templates() {
if t.Name == req.Template {
tmpl = &t
break
}
}
if tmpl == nil {
jsonErr(w, 404, "template not found")
return
}
// Create root node.
root, err := s.nodes.Create(req.ParentID, tmpl.RootType, req.Title, req.Section)
if err != nil {
jsonErr(w, 500, err.Error())
return
}
// Create children recursively.
var createTree func(parentID string, nodes []plugins.TreeNode) error
createTree = func(parentID string, nodes []plugins.TreeNode) error {
for _, tn := range nodes {
child, err := s.nodes.Create(parentID, tn.Type, tn.Title, "")
if err != nil {
return err
}
if len(tn.Children) > 0 {
if err := createTree(child.ID, tn.Children); err != nil {
return err
}
}
}
return nil
}
if err := createTree(root.ID, tmpl.Tree); err != nil {
jsonErr(w, 500, err.Error())
return
}
jsonOK(w, root)
}
// GET/PUT/DELETE /api/nodes/{id}
func (s *Server) handleNodeDetail(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, "/api/nodes/")
@ -386,3 +451,12 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
}
jsonOK(w, results)
}
// GET /api/templates — list all templates from active plugins.
func (s *Server) handleTemplates(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
jsonErr(w, 405, "method not allowed")
return
}
jsonOK(w, s.plugins.Templates())
}