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
+26 -2
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));
+75 -1
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())
}