diff --git a/cmd/verstak/main.go b/cmd/verstak/main.go index 175d7b7..3f1b131 100644 --- a/cmd/verstak/main.go +++ b/cmd/verstak/main.go @@ -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 [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//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) + } +} diff --git a/contrib/plugins/importer-dokuwiki/README.md b/contrib/plugins/importer-dokuwiki/README.md new file mode 100644 index 0000000..c0fdda7 --- /dev/null +++ b/contrib/plugins/importer-dokuwiki/README.md @@ -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/` diff --git a/contrib/plugins/importer-dokuwiki/migrations/001_create_originals.sql b/contrib/plugins/importer-dokuwiki/migrations/001_create_originals.sql new file mode 100644 index 0000000..275b7c8 --- /dev/null +++ b/contrib/plugins/importer-dokuwiki/migrations/001_create_originals.sql @@ -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 +); \ No newline at end of file diff --git a/contrib/plugins/importer-dokuwiki/plugin.json b/contrib/plugins/importer-dokuwiki/plugin.json new file mode 100644 index 0000000..a1ba731 --- /dev/null +++ b/contrib/plugins/importer-dokuwiki/plugin.json @@ -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"] +} \ No newline at end of file diff --git a/docs/08_MVP_Checklist.md b/docs/08_MVP_Checklist.md index 6ecbc6f..5925cae 100644 --- a/docs/08_MVP_Checklist.md +++ b/docs/08_MVP_Checklist.md @@ -19,8 +19,8 @@ - [ ] Скопировать отчёт по работам. - [ ] Поиск по заметкам. - [ ] Поиск по именам файлов. -- [ ] Поиск по журналу работ. -- [ ] Базовый импорт DokuWiki. +- [x] Поиск по журналу работ. +- [x] Базовый импорт DokuWiki (плагин). ## Необязательные, но желательные diff --git a/docs/PLAN.md b/docs/PLAN.md index fcc8365..32515b9 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -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//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/ diff --git a/internal/core/plugins/builtin/templates/client.json b/internal/core/plugins/builtin/templates/client.json new file mode 100644 index 0000000..7a396c6 --- /dev/null +++ b/internal/core/plugins/builtin/templates/client.json @@ -0,0 +1,10 @@ +{ + "name": "Клиент", + "root_type": "case", + "tree": [ + { "type": "note", "title": "Overview" }, + { "type": "folder", "title": "Документы" }, + { "type": "folder", "title": "Переписка" }, + { "type": "folder", "title": "Скриншоты" } + ] +} diff --git a/internal/core/plugins/lua.go b/internal/core/plugins/lua.go new file mode 100644 index 0000000..6a48dee --- /dev/null +++ b/internal/core/plugins/lua.go @@ -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 +} diff --git a/internal/core/plugins/manager.go b/internal/core/plugins/manager.go new file mode 100644 index 0000000..c60fc05 --- /dev/null +++ b/internal/core/plugins/manager.go @@ -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 +} diff --git a/internal/core/plugins/manager_test.go b/internal/core/plugins/manager_test.go new file mode 100644 index 0000000..799e15d --- /dev/null +++ b/internal/core/plugins/manager_test.go @@ -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())) + } +} diff --git a/internal/gui/index.html.go b/internal/gui/index.html.go index 40aa7f7..3ca0fd2 100644 --- a/internal/gui/index.html.go +++ b/internal/gui/index.html.go @@ -207,6 +207,8 @@ input[type=checkbox]{width:auto!important;margin-right:6px;display:inline} + +
@@ -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=''; + for(const t of tmpls){ + h+=''; + } + 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)); diff --git a/internal/gui/server.go b/internal/gui/server.go index ee73530..cebe7bd 100644 --- a/internal/gui/server.go +++ b/internal/gui/server.go @@ -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()) +}