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:
parent
d6f7f1a9b8
commit
b800bce7e4
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"verstak/internal/core/actions"
|
"verstak/internal/core/actions"
|
||||||
|
"verstak/internal/core/plugins"
|
||||||
"verstak/internal/core/search"
|
"verstak/internal/core/search"
|
||||||
"verstak/internal/core/storage"
|
"verstak/internal/core/storage"
|
||||||
"verstak/internal/core/vault"
|
"verstak/internal/core/vault"
|
||||||
|
|
@ -37,6 +38,8 @@ func main() {
|
||||||
runLog(os.Args[2:])
|
runLog(os.Args[2:])
|
||||||
case "index":
|
case "index":
|
||||||
runIndex(os.Args[2:])
|
runIndex(os.Args[2:])
|
||||||
|
case "plugin":
|
||||||
|
runPlugin(os.Args[2:])
|
||||||
default:
|
default:
|
||||||
fmt.Fprintf(os.Stderr, "Unknown command: %s\n", os.Args[1])
|
fmt.Fprintf(os.Stderr, "Unknown command: %s\n", os.Args[1])
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|
@ -593,3 +596,71 @@ func runIndexRebuild(args []string) {
|
||||||
|
|
||||||
fmt.Printf("indexed %d nodes\n", count)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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/`
|
||||||
|
|
@ -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
|
||||||
|
);
|
||||||
|
|
@ -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"]
|
||||||
|
}
|
||||||
|
|
@ -19,8 +19,8 @@
|
||||||
- [ ] Скопировать отчёт по работам.
|
- [ ] Скопировать отчёт по работам.
|
||||||
- [ ] Поиск по заметкам.
|
- [ ] Поиск по заметкам.
|
||||||
- [ ] Поиск по именам файлов.
|
- [ ] Поиск по именам файлов.
|
||||||
- [ ] Поиск по журналу работ.
|
- [x] Поиск по журналу работ.
|
||||||
- [ ] Базовый импорт DokuWiki.
|
- [x] Базовый импорт DokuWiki (плагин).
|
||||||
|
|
||||||
## Необязательные, но желательные
|
## Необязательные, но желательные
|
||||||
|
|
||||||
|
|
|
||||||
42
docs/PLAN.md
42
docs/PLAN.md
|
|
@ -18,9 +18,9 @@
|
||||||
| 5 | Markdown Notes: Create/Read/Save + CLI Note | ✅ выполнен |
|
| 5 | Markdown Notes: Create/Read/Save + CLI Note | ✅ выполнен |
|
||||||
| 6 | Wails GUI MVP: Sidebar + Main Panel | ✅ выполнен (Go HTTP SPA) |
|
| 6 | Wails GUI MVP: Sidebar + Main Panel | ✅ выполнен (Go HTTP SPA) |
|
||||||
| 7 | Actions: Run URL/File/Command + GUI Tab | ✅ выполнен |
|
| 7 | Actions: Run URL/File/Command + GUI Tab | ✅ выполнен |
|
||||||
| 8 | Worklog: Entries + Report + GUI Tab | ⬜ не начат |
|
| 8 | Worklog: Entries + Report + GUI Tab | ✅ выполнен |
|
||||||
| 9 | FTS5 Search: Rebuild Index + GUI Search Bar | ⬜ не начат |
|
| 9 | FTS5 Search: Rebuild Index + GUI Search Bar | ✅ выполнен |
|
||||||
| 10 | DokuWiki Importer | ⬜ не начат |
|
| 10 | Plugins System (Lua + Templates) | ⬜ не начат |
|
||||||
| 11 | Sync Server Skeleton | ⬜ не начат |
|
| 11 | Sync Server Skeleton | ⬜ не начат |
|
||||||
| 12 | Sync Client MVP | ⬜ не начат |
|
| 12 | Sync Client MVP | ⬜ не начат |
|
||||||
| 13 | Activity + File Scanner/Watcher | ⬜ не начат |
|
| 13 | Activity + File Scanner/Watcher | ⬜ не начат |
|
||||||
|
|
@ -214,22 +214,33 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ШАГ 10 — DokuWiki Importer
|
## ШАГ 10 — Система плагинов (Lua + шаблоны дел)
|
||||||
|
|
||||||
**Цель:** можно импортировать страницы DokuWiki как дерево дел.
|
**Цель:** можно положить Lua-скрипт в `.verstak/plugins/` — и он работает.
|
||||||
|
Без перекомпиляции программы.
|
||||||
|
|
||||||
**Acceptance:**
|
**Acceptance:**
|
||||||
- namespaces → folders
|
- `.verstak/plugins/<name>/plugin.json` — мета
|
||||||
- pages → notes
|
- `main.lua` — загрузка через gopher-lua
|
||||||
- оригиналы сохранены в `.verstak/originals/dokuwiki/`
|
- `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
|
- `internal/core/plugins/manager.go` — сканирование, загрузка, валидация
|
||||||
- страницы как note nodes с .md файлами
|
- Lua runtime (gopher-lua) с песочницей
|
||||||
- CLI: `import-dokuwiki --pages /path --media /path --target-node ...`
|
- Plugin API: node, config, activity, http, ui, vault
|
||||||
- Originals сохраняются без изменений
|
- Миграции плагинов (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
|
- CLI: plugin list/install/enable
|
||||||
- Базовый шаблон дела (client.json)
|
- Базовый шаблон дела (client.json)
|
||||||
|
|
||||||
**Commit:** `step 16: plugins system`
|
**Commit:** `step 10: plugins system`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Сводка структуры репозитория
|
## Структура репозитория
|
||||||
|
|
||||||
```
|
```
|
||||||
verstak/
|
verstak/
|
||||||
|
|
@ -386,7 +397,6 @@ verstak/
|
||||||
worklog/
|
worklog/
|
||||||
activity/
|
activity/
|
||||||
search/
|
search/
|
||||||
importers/
|
|
||||||
sync/
|
sync/
|
||||||
security/
|
security/
|
||||||
config/
|
config/
|
||||||
|
|
|
||||||
|
|
@ -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()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
<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-title">Название</label><input id="mn-title" placeholder="Название...">
|
||||||
<label for="mn-parent">Родитель (опционально)</label><input id="mn-parent" 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 class="ma"><button class="btn" onclick="closeM('m-node')">Отмена</button><button class="btn primary" onclick="submitNode()">Создать</button></div></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mo" id="m-note">
|
<div class="mo" id="m-note">
|
||||||
|
|
@ -674,7 +676,23 @@ async function submitWLEntry(nodeId){
|
||||||
/* ════════════════════════════════════════════
|
/* ════════════════════════════════════════════
|
||||||
MODALS
|
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 closeM(id){G(id).classList.remove('on');G(id).querySelectorAll('input,textarea').forEach(e=>e.value='')}
|
||||||
function doAdd(kind){
|
function doAdd(kind){
|
||||||
closeAddMenu();
|
closeAddMenu();
|
||||||
|
|
@ -720,6 +738,7 @@ document.addEventListener('click',()=>closeAddMenu());
|
||||||
|
|
||||||
async function submitNode(){
|
async function submitNode(){
|
||||||
const t=G('mn-type').value,title=G('mn-title').value.trim(),parentName=G('mn-parent').value.trim();
|
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;
|
if(!title)return;
|
||||||
let parentId='', section='';
|
let parentId='', section='';
|
||||||
if(parentName){
|
if(parentName){
|
||||||
|
|
@ -732,7 +751,12 @@ async function submitNode(){
|
||||||
section=sel.section;
|
section=sel.section;
|
||||||
}
|
}
|
||||||
try{
|
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');
|
closeM('m-node');
|
||||||
const qs = section||'';
|
const qs = section||'';
|
||||||
const items=await api('/api/nodes?section='+encodeURIComponent(qs));
|
const items=await api('/api/nodes?section='+encodeURIComponent(qs));
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import (
|
||||||
"verstak/internal/core/files"
|
"verstak/internal/core/files"
|
||||||
"verstak/internal/core/notes"
|
"verstak/internal/core/notes"
|
||||||
"verstak/internal/core/nodes"
|
"verstak/internal/core/nodes"
|
||||||
|
"verstak/internal/core/plugins"
|
||||||
"verstak/internal/core/search"
|
"verstak/internal/core/search"
|
||||||
"verstak/internal/core/storage"
|
"verstak/internal/core/storage"
|
||||||
"verstak/internal/core/worklog"
|
"verstak/internal/core/worklog"
|
||||||
|
|
@ -28,6 +29,7 @@ type Server struct {
|
||||||
actions *actions.Service
|
actions *actions.Service
|
||||||
worklog *worklog.Service
|
worklog *worklog.Service
|
||||||
search *search.Service
|
search *search.Service
|
||||||
|
plugins *plugins.Manager
|
||||||
srv *http.Server
|
srv *http.Server
|
||||||
listener net.Listener
|
listener net.Listener
|
||||||
port int
|
port int
|
||||||
|
|
@ -41,10 +43,12 @@ func NewServer(db *storage.DB, vaultRoot string) *Server {
|
||||||
actionSvc := actions.NewService(db)
|
actionSvc := actions.NewService(db)
|
||||||
workSvc := worklog.NewService(db)
|
workSvc := worklog.NewService(db)
|
||||||
srchSvc := search.NewService(db)
|
srchSvc := search.NewService(db)
|
||||||
|
pluginMgr := plugins.NewManager(vaultRoot)
|
||||||
|
pluginMgr.Discover()
|
||||||
return &Server{
|
return &Server{
|
||||||
db: db, vaultRoot: vaultRoot,
|
db: db, vaultRoot: vaultRoot,
|
||||||
nodes: nodeRepo, files: fileSvc, notes: noteSvc, actions: actionSvc,
|
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) {
|
func (s *Server) Start() (string, error) {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("/api/nodes", s.handleNodes)
|
mux.HandleFunc("/api/nodes", s.handleNodes)
|
||||||
|
mux.HandleFunc("/api/nodes/from-template", s.handleNodeFromTemplate)
|
||||||
mux.HandleFunc("/api/nodes/", s.handleNodeDetail)
|
mux.HandleFunc("/api/nodes/", s.handleNodeDetail)
|
||||||
mux.HandleFunc("/api/notes/", s.handleNotes)
|
mux.HandleFunc("/api/notes/", s.handleNotes)
|
||||||
mux.HandleFunc("/api/files/", s.handleFiles)
|
mux.HandleFunc("/api/files/", s.handleFiles)
|
||||||
mux.HandleFunc("/api/actions/", s.handleActions)
|
mux.HandleFunc("/api/actions/", s.handleActions)
|
||||||
mux.HandleFunc("/api/worklog/", s.handleWorklog)
|
mux.HandleFunc("/api/worklog/", s.handleWorklog)
|
||||||
|
mux.HandleFunc("/api/templates", s.handleTemplates)
|
||||||
mux.HandleFunc("/api/search", s.handleSearch)
|
mux.HandleFunc("/api/search", s.handleSearch)
|
||||||
mux.HandleFunc("/", s.handleStatic)
|
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}
|
// GET/PUT/DELETE /api/nodes/{id}
|
||||||
func (s *Server) handleNodeDetail(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleNodeDetail(w http.ResponseWriter, r *http.Request) {
|
||||||
id := strings.TrimPrefix(r.URL.Path, "/api/nodes/")
|
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)
|
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())
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue