feat: плагин-система Lua + Calendar reference plugin
- Lua VM runtime: gopher-lua с песочницей, хуки on_init/on_tick/on_shutdown - API: verstak.node.* / verstak.db.* / verstak.config.* / verstak.state.* - API: verstak.worklog.* / verstak.activity.* / verstak.file.* - API: verstak.schedule.* / verstak.http.* / verstak.ui.* - Менеджер плагинов: жизненный цикл, инициализация, шаблоны - Scheduler: фоновые задачи с интервалами - PluginPage.svelte: контейнер для iframe-панелей плагинов - Calendar plugin: миграция, категории CRUD, события CRUD - Calendar: расширенный рекарренс (daily/weekly/monthly/yearly) - Calendar: связь с узлами Верстака, напоминания, HTTP-праздники - Calendar: Lua-тест-сьют (15 тестов), Go-интеграционный тест - fix: query_row использует реальные Column() вместо guessColumns
This commit is contained in:
parent
8cbc87cdad
commit
b80941f908
|
|
@ -10,7 +10,7 @@
|
|||
| Слой | Технология |
|
||||
|------|------------|
|
||||
| GUI | Wails v2 + Svelte 4 |
|
||||
| CLI/TUI | Go (bubbletea-like) |
|
||||
| CLI | Go |
|
||||
| Backend | Go |
|
||||
| Хранилище | SQLite (индекс) + файловая система (vault) |
|
||||
| Плагины | Lua |
|
||||
|
|
@ -91,7 +91,7 @@ go run ./cmd/verstak/ --help
|
|||
```
|
||||
.
|
||||
├── cmd/ # Точки входа
|
||||
│ ├── verstak/ # CLI/TUI
|
||||
│ ├── verstak/ # CLI
|
||||
│ ├── verstak-gui/ # Wails GUI
|
||||
│ └── verstak-server/ # Sync server
|
||||
├── internal/
|
||||
|
|
@ -151,7 +151,7 @@ Vault открывается в любом файловом менеджере
|
|||
| [Архитектура](docs/02_Architecture.md) | Компоненты, плагины, sync |
|
||||
| [Модель данных](docs/03_Data_Model_Storage.md) | SQLite, vault, файлы |
|
||||
| [Синхронизация](docs/04_Sync_Backup_Activity.md) | Sync, backup, activity |
|
||||
| [UI/UX](docs/05_UI_UX.md) | Экраны GUI/TUI |
|
||||
| [UI/UX](docs/05_UI_UX.md) | Экраны GUI |
|
||||
| [Плагины](docs/09_Extensibility.md) | Lua-плагины, шаблоны |
|
||||
| [Сервер синхронизации](docs/10_Sync_Server_Guide.md) | Установка и настройка сервера |
|
||||
| [Vault layout](docs/VAULT_LAYOUT.md) | Структура папок на диске |
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import (
|
|||
type SystemViewDTO struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
}
|
||||
|
||||
func (a *App) ListSystemViews() []SystemViewDTO {
|
||||
|
|
|
|||
|
|
@ -30,10 +30,7 @@ func (a *App) startBridge(appCfg *config.AppConfig) {
|
|||
}
|
||||
}
|
||||
|
||||
srv := bridge.NewServer(bridge.Config{
|
||||
Port: bc.Port,
|
||||
Secret: bc.Secret,
|
||||
}, handler)
|
||||
srv := bridge.NewServer(bc.Secret, handler)
|
||||
|
||||
port, err := srv.Start(bridge.Config{
|
||||
Port: bc.Port,
|
||||
|
|
@ -116,13 +113,14 @@ func bridgeToBrowser(ev bridge.Event) browser.Event {
|
|||
|
||||
// RestartBridge stops and restarts the bridge server with current config.
|
||||
func (a *App) RestartBridge() error {
|
||||
// Stop existing server outside the lock to avoid blocking other bindings.
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
oldBridge := a.bridge
|
||||
a.bridge = nil
|
||||
a.mu.Unlock()
|
||||
|
||||
// Stop existing server
|
||||
if a.bridge != nil {
|
||||
a.bridge.Stop()
|
||||
a.bridge = nil
|
||||
if oldBridge != nil {
|
||||
oldBridge.Stop()
|
||||
}
|
||||
|
||||
// Load config
|
||||
|
|
@ -151,10 +149,7 @@ func (a *App) RestartBridge() error {
|
|||
}
|
||||
}
|
||||
|
||||
srv := bridge.NewServer(bridge.Config{
|
||||
Port: bc.Port,
|
||||
Secret: bc.Secret,
|
||||
}, handler)
|
||||
srv := bridge.NewServer(bc.Secret, handler)
|
||||
|
||||
port, err := srv.Start(bridge.Config{
|
||||
Port: bc.Port,
|
||||
|
|
@ -165,7 +160,10 @@ func (a *App) RestartBridge() error {
|
|||
return fmt.Errorf("bridge restart: %w", err)
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
a.bridge = srv
|
||||
a.mu.Unlock()
|
||||
|
||||
log.Printf("[bridge] restarted on port %d", port)
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -199,6 +199,14 @@ func (a *App) initVault(vaultPath string) error {
|
|||
worklogSvc := worklog.NewService(db)
|
||||
searchSvc := search.NewService(db)
|
||||
pm := plugins.NewManager(abs)
|
||||
pm.Services = &plugins.CoreServices{
|
||||
NodeRepo: nodeRepo,
|
||||
DB: db,
|
||||
ActivitySvc: activitySvc,
|
||||
WorklogSvc: worklogSvc,
|
||||
FilesSvc: fileSvc,
|
||||
VaultPath: abs,
|
||||
}
|
||||
pm.Discover()
|
||||
|
||||
templatesReg := templates.NewRegistry()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,232 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"verstak/internal/core/config"
|
||||
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
// PluginDTO represents a discovered plugin with its current state.
|
||||
type PluginDTO struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Author string `json:"author,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Active bool `json:"active"`
|
||||
HasPanel bool `json:"hasPanel"`
|
||||
HasSettings bool `json:"hasSettings"`
|
||||
UIContribs UIContribDTO `json:"uiContribs"`
|
||||
}
|
||||
|
||||
// UIContribDTO describes what a plugin adds to the UI.
|
||||
type UIContribDTO struct {
|
||||
SidebarItems []SidebarItemDTO `json:"sidebarItems"`
|
||||
NodeTabs []NodeTabDTO `json:"nodeTabs"`
|
||||
}
|
||||
|
||||
type SidebarItemDTO struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
}
|
||||
|
||||
type NodeTabDTO struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Page string `json:"page"`
|
||||
}
|
||||
|
||||
// ListPlugins returns all discovered plugins with their current enabled/disabled state.
|
||||
func (a *App) ListPlugins() []PluginDTO {
|
||||
if a.plugins == nil {
|
||||
return nil
|
||||
}
|
||||
all := a.plugins.Plugins()
|
||||
out := make([]PluginDTO, 0, len(all))
|
||||
|
||||
appCfg, _ := config.LoadAppConfig()
|
||||
enabledSet := make(map[string]bool)
|
||||
if appCfg != nil {
|
||||
for _, name := range appCfg.EnabledPlugins {
|
||||
enabledSet[name] = true
|
||||
}
|
||||
}
|
||||
|
||||
for _, p := range all {
|
||||
active := enabledSet[p.Meta.Name] || p.Active
|
||||
contribs := UIContribDTO{}
|
||||
for _, item := range p.Meta.UI.SidebarItems {
|
||||
contribs.SidebarItems = append(contribs.SidebarItems, SidebarItemDTO{
|
||||
ID: item.ID,
|
||||
Label: item.Label,
|
||||
Icon: item.Icon,
|
||||
})
|
||||
}
|
||||
for _, tab := range p.Meta.UI.NodeTabs {
|
||||
contribs.NodeTabs = append(contribs.NodeTabs, NodeTabDTO{
|
||||
ID: tab.ID,
|
||||
Label: tab.Label,
|
||||
Page: tab.Page,
|
||||
})
|
||||
}
|
||||
|
||||
hasPanel := false
|
||||
if p.Meta.Panel != "" {
|
||||
panelPath := filepath.Join(p.Dir, p.Meta.Panel)
|
||||
if _, err := os.Stat(panelPath); err == nil {
|
||||
hasPanel = true
|
||||
}
|
||||
}
|
||||
|
||||
out = append(out, PluginDTO{
|
||||
Name: p.Meta.Name,
|
||||
Version: p.Meta.Version,
|
||||
Author: p.Meta.Author,
|
||||
Description: p.Meta.Description,
|
||||
Active: active,
|
||||
HasPanel: hasPanel,
|
||||
UIContribs: contribs,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SetPluginEnabled persists the enabled/disabled state and applies it to the runtime.
|
||||
func (a *App) SetPluginEnabled(name string, enabled bool) error {
|
||||
if a.plugins == nil {
|
||||
return fmt.Errorf("plugin manager not ready")
|
||||
}
|
||||
|
||||
appCfg, _ := config.LoadAppConfig()
|
||||
if appCfg == nil {
|
||||
appCfg = config.DefaultAppConfig()
|
||||
}
|
||||
|
||||
existing := make(map[string]bool)
|
||||
for _, n := range appCfg.EnabledPlugins {
|
||||
existing[n] = true
|
||||
}
|
||||
if enabled {
|
||||
existing[name] = true
|
||||
} else {
|
||||
delete(existing, name)
|
||||
}
|
||||
appCfg.EnabledPlugins = make([]string, 0, len(existing))
|
||||
for n := range existing {
|
||||
appCfg.EnabledPlugins = append(appCfg.EnabledPlugins, n)
|
||||
}
|
||||
if err := config.SaveAppConfig(appCfg); err != nil {
|
||||
return fmt.Errorf("save config: %w", err)
|
||||
}
|
||||
|
||||
if enabled {
|
||||
a.plugins.ActivatePlugin(name)
|
||||
} else {
|
||||
a.plugins.DeactivatePlugin(name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPluginPanelHTML returns the HTML panel content for a plugin.
|
||||
func (a *App) GetPluginPanelHTML(pluginName string) (string, error) {
|
||||
if a.plugins == nil {
|
||||
return "", fmt.Errorf("plugin manager not ready")
|
||||
}
|
||||
for _, p := range a.plugins.Plugins() {
|
||||
if p.Meta.Name != pluginName || !p.Active {
|
||||
continue
|
||||
}
|
||||
if p.Meta.Panel == "" {
|
||||
return "", nil
|
||||
}
|
||||
panelPath := filepath.Join(p.Dir, p.Meta.Panel)
|
||||
data, err := os.ReadFile(panelPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read panel %s: %w", p.Meta.Panel, err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// ListSystemViewsWithPlugins returns system views + plugin sidebar items.
|
||||
func (a *App) ListSystemViewsWithPlugins() []SystemViewDTO {
|
||||
base := a.ListSystemViews()
|
||||
if a.plugins == nil {
|
||||
return base
|
||||
}
|
||||
|
||||
appCfg, _ := config.LoadAppConfig()
|
||||
enabledSet := make(map[string]bool)
|
||||
if appCfg != nil {
|
||||
for _, name := range appCfg.EnabledPlugins {
|
||||
enabledSet[name] = true
|
||||
}
|
||||
}
|
||||
|
||||
for _, p := range a.plugins.Plugins() {
|
||||
active := enabledSet[p.Meta.Name] || p.Active
|
||||
if !active {
|
||||
continue
|
||||
}
|
||||
for _, item := range p.Meta.UI.SidebarItems {
|
||||
pageID := "plugin:" + p.Meta.Name + ":" + item.ID
|
||||
base = append(base, SystemViewDTO{
|
||||
ID: pageID,
|
||||
Label: item.Label,
|
||||
Icon: item.Icon,
|
||||
})
|
||||
}
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// CallPluginAction invokes a named Lua hook on a specific plugin.
|
||||
func (a *App) CallPluginAction(pluginName, action string, paramsJSON string) (string, error) {
|
||||
if a.plugins == nil {
|
||||
return "", fmt.Errorf("plugin manager not ready")
|
||||
}
|
||||
result, err := a.plugins.CallPluginHook(pluginName, action, lua.LString(paramsJSON))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("plugin call %s: %w", action, err)
|
||||
}
|
||||
return result.String(), nil
|
||||
}
|
||||
|
||||
// ReloadPlugins re-scans the plugins directory and re-initializes runtimes.
|
||||
func (a *App) ReloadPlugins() error {
|
||||
if a.plugins == nil {
|
||||
return fmt.Errorf("plugin manager not ready")
|
||||
}
|
||||
log.Print("[plugins] reload requested")
|
||||
a.plugins.CloseRuntimes()
|
||||
|
||||
appCfg, _ := config.LoadAppConfig()
|
||||
if appCfg == nil {
|
||||
appCfg = config.DefaultAppConfig()
|
||||
}
|
||||
enabledSet := make(map[string]bool)
|
||||
for _, name := range appCfg.EnabledPlugins {
|
||||
enabledSet[name] = true
|
||||
}
|
||||
a.plugins.Discover()
|
||||
a.plugins.InitRuntimes()
|
||||
|
||||
// Apply enable/disable state from config: deactivate everything not in enabled set
|
||||
for _, p := range a.plugins.Plugins() {
|
||||
if !enabledSet[p.Meta.Name] {
|
||||
a.plugins.DeactivatePlugin(p.Meta.Name)
|
||||
}
|
||||
}
|
||||
|
||||
a.plugins.CallInitHooks()
|
||||
a.plugins.StartSchedulers()
|
||||
log.Print("[plugins] reload complete")
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,720 @@
|
|||
--[[
|
||||
Calendar plugin for Verstak — reference plugin demonstrating the full plugin API.
|
||||
Covers: verstak.db.* / config.* / state.* / node.* / worklog.* / activity.* / schedule.* / http.* / ui.*
|
||||
]]
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
-- Module table — internal
|
||||
--------------------------------------------------------------------------------
|
||||
local M = {}
|
||||
|
||||
-- Safe wrapper for optional service calls (avoids pcall boilerplate)
|
||||
local function safe_log(fn, ...)
|
||||
pcall(fn, ...)
|
||||
end
|
||||
|
||||
-- ID generation
|
||||
local function uuid()
|
||||
local f = function() return math.random(0, 16777215) end
|
||||
local p = string.format
|
||||
return p("%04x%04x-%04x-%04x-%04x-%06x%06x",
|
||||
f(), f(), f(), f(), f(), f(), f())
|
||||
end
|
||||
|
||||
math.randomseed(os.time())
|
||||
|
||||
-- Current timestamp ISO8601
|
||||
local function now()
|
||||
return os.date("%Y-%m-%dT%H:%M:%S")
|
||||
end
|
||||
|
||||
local function today()
|
||||
return os.date("%Y-%m-%d")
|
||||
end
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
-- Default categories
|
||||
--------------------------------------------------------------------------------
|
||||
local DEFAULT_CATEGORIES = {
|
||||
{ name = "Работа", color = "#3b82f6", icon = "💼", sort_order = 1 },
|
||||
{ name = "Личное", color = "#10b981", icon = "🏠", sort_order = 2 },
|
||||
{ name = "Встреча", color = "#8b5cf6", icon = "🤝", sort_order = 3 },
|
||||
{ name = "Дедлайн", color = "#ef4444", icon = "🔥", sort_order = 4 },
|
||||
{ name = "Здоровье", color = "#f59e0b", icon = "💪", sort_order = 5 },
|
||||
{ name = "Звонок", color = "#06b6d4", icon = "📞", sort_order = 6 },
|
||||
}
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
-- Verstak.config — load/store default categories
|
||||
--------------------------------------------------------------------------------
|
||||
function M.ensure_categories()
|
||||
local rows = verstak.db.query("SELECT COUNT(*) as cnt FROM categories WHERE deleted = 0")
|
||||
local n = 0
|
||||
if rows and #rows > 0 then
|
||||
for _, v in pairs(rows[1]) do
|
||||
if type(v) == "number" then n = v; break end
|
||||
end
|
||||
end
|
||||
if n > 0 then return end
|
||||
|
||||
for _, cat in ipairs(DEFAULT_CATEGORIES) do
|
||||
local id = uuid()
|
||||
verstak.db.exec(
|
||||
"INSERT INTO categories (id, name, color, icon, sort_order) VALUES (?, ?, ?, ?, ?)",
|
||||
id, cat.name, cat.color, cat.icon, cat.sort_order
|
||||
)
|
||||
-- Activity log for each category
|
||||
safe_log(verstak.activity.log,"category_created", "Категория: " .. cat.name, id, "")
|
||||
end
|
||||
|
||||
-- Save as config so user can restore defaults
|
||||
local cfg = verstak.config.get("categories") or {}
|
||||
if next(cfg) == nil then
|
||||
verstak.config.set("categories", DEFAULT_CATEGORIES)
|
||||
end
|
||||
end
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
-- Categories CRUD (verstak.db.* demo)
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
-- Get all non-deleted categories
|
||||
function M.get_categories()
|
||||
return verstak.db.query(
|
||||
"SELECT id, name, color, icon, sort_order FROM categories WHERE deleted = 0 ORDER BY sort_order"
|
||||
)
|
||||
end
|
||||
|
||||
-- Get all categories including deleted
|
||||
function M.get_categories_all()
|
||||
return verstak.db.query(
|
||||
"SELECT id, name, color, icon, sort_order, deleted FROM categories ORDER BY sort_order"
|
||||
)
|
||||
end
|
||||
|
||||
-- Create a new category
|
||||
function M.create_category(name, color, icon)
|
||||
if not name or name == "" then error("category name required") end
|
||||
local id = uuid()
|
||||
verstak.db.exec(
|
||||
"INSERT INTO categories (id, name, color, icon) VALUES (?, ?, ?, ?)",
|
||||
id, name, color or "#6b7280", icon or "📌"
|
||||
)
|
||||
safe_log(verstak.activity.log,"category_created", "Категория: " .. name, id, "")
|
||||
return id
|
||||
end
|
||||
|
||||
-- Update a category
|
||||
function M.update_category(id, fields)
|
||||
if not id then error("category id required") end
|
||||
local old = verstak.db.query_row("SELECT name FROM categories WHERE id = ?", id)
|
||||
if not old then error("category not found: " .. id) end
|
||||
|
||||
verstak.db.exec(
|
||||
"UPDATE categories SET name = ?, color = ?, icon = ?, sort_order = ?, updated_at = ? WHERE id = ?",
|
||||
fields.name or old.name,
|
||||
fields.color or "#6b7280",
|
||||
fields.icon or "📌",
|
||||
fields.sort_order or 0,
|
||||
now(),
|
||||
id
|
||||
)
|
||||
safe_log(verstak.activity.log,"category_updated", "Категория: " .. (fields.name or old.name), id, "")
|
||||
return true
|
||||
end
|
||||
|
||||
-- Soft-delete a category (keeps history)
|
||||
function M.delete_category(id)
|
||||
if not id then error("category id required") end
|
||||
verstak.db.exec("UPDATE categories SET deleted = 1, updated_at = ? WHERE id = ?", now(), id)
|
||||
safe_log(verstak.activity.log,"category_deleted", "Категория удалена: " .. id, id, "")
|
||||
return true
|
||||
end
|
||||
|
||||
-- Restore default categories
|
||||
function M.restore_default_categories()
|
||||
verstak.db.exec("UPDATE categories SET deleted = 1")
|
||||
M.ensure_categories()
|
||||
return true
|
||||
end
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
-- Events CRUD (verstak.db.* + verstak.state.* demo)
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
-- Get events within a date range (inclusive)
|
||||
function M.get_events(start_date, end_date)
|
||||
if not start_date then error("start_date required") end
|
||||
if not end_date then end_date = start_date end
|
||||
return verstak.db.query(
|
||||
[[SELECT e.id, e.title, e.description,
|
||||
e.start, e.end, e.all_day,
|
||||
e.category_id, e.color,
|
||||
e.node_id, e.link_type,
|
||||
e.recurring_rule, e.reminder_minutes,
|
||||
e.completed, e.source_series,
|
||||
e.created_at, e.updated_at,
|
||||
c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||
FROM events e
|
||||
LEFT JOIN categories c ON e.category_id = c.id AND c.deleted = 0
|
||||
WHERE (e.start >= ? AND e.start <= ?)
|
||||
OR (e.end >= ? AND e.end <= ?)
|
||||
OR (e.start <= ? AND e.end >= ?)
|
||||
ORDER BY e.start
|
||||
]],
|
||||
start_date, end_date, start_date, end_date, start_date, end_date
|
||||
)
|
||||
end
|
||||
|
||||
-- Get events for a specific day
|
||||
function M.get_events_day(date_str)
|
||||
return M.get_events(date_str .. "T00:00:00", date_str .. "T23:59:59")
|
||||
end
|
||||
|
||||
-- Get event by ID
|
||||
function M.get_event(id)
|
||||
return verstak.db.query_row(
|
||||
"SELECT * FROM events WHERE id = ?", id
|
||||
)
|
||||
end
|
||||
|
||||
-- Create a single event (base event for recurrences)
|
||||
function M.create_event(opts)
|
||||
opts = opts or {}
|
||||
if not opts.title or opts.title == "" then error("event title required") end
|
||||
if not opts.start then error("event start datetime required") end
|
||||
|
||||
local id = uuid()
|
||||
local e_start = opts.start
|
||||
local e_end = opts["end"] or e_start
|
||||
local cat_id = opts.category_id or ""
|
||||
local color = opts.color or "#6b7280"
|
||||
|
||||
-- Resolve color from category if not set
|
||||
if color == "" and cat_id ~= "" then
|
||||
local cat = verstak.db.query_row("SELECT color FROM categories WHERE id = ?", cat_id)
|
||||
if cat then color = cat.color end
|
||||
end
|
||||
|
||||
local recurring_json = nil
|
||||
if opts.recurring then
|
||||
recurring_json = verstak.state.get("rr:" .. id)
|
||||
if not recurring_json then
|
||||
recurring_json = opts.recurring
|
||||
end
|
||||
end
|
||||
|
||||
local reminder = "[]"
|
||||
if opts.reminder_minutes then
|
||||
reminder = "[" .. table.concat(opts.reminder_minutes, ",") .. "]"
|
||||
end
|
||||
|
||||
verstak.db.exec(
|
||||
[[INSERT INTO events (id, title, description, start, end, all_day,
|
||||
category_id, color, node_id, link_type, recurring_rule, reminder_minutes,
|
||||
completed, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)]],
|
||||
id, opts.title, opts.description or "", e_start, e_end,
|
||||
opts.all_day and 1 or 0,
|
||||
cat_id, color,
|
||||
opts.node_id or "", opts.link_type or "node",
|
||||
recurring_json, reminder,
|
||||
now(), now()
|
||||
)
|
||||
|
||||
safe_log(verstak.activity.log,"event_created",
|
||||
"Событие: " .. opts.title, id, opts.node_id or "")
|
||||
|
||||
-- Link to worklog if minutes provided
|
||||
if opts.minutes and opts.node_id and opts.node_id ~= "" then
|
||||
local ok, err = pcall(verstak.worklog.add, opts.node_id, opts.title, opts.minutes)
|
||||
if not ok then
|
||||
print("Calendar: worklog.add error: " .. tostring(err))
|
||||
end
|
||||
end
|
||||
|
||||
return id
|
||||
end
|
||||
|
||||
-- Update an event (partial fields)
|
||||
function M.update_event(id, fields)
|
||||
if not id then error("event id required") end
|
||||
local old = verstak.db.query_row("SELECT * FROM events WHERE id = ?", id)
|
||||
if not old then error("event not found: " .. id) end
|
||||
|
||||
local updates = {}
|
||||
local params = {}
|
||||
local set_clauses = {}
|
||||
|
||||
-- Build dynamic update
|
||||
local mutable = {
|
||||
title = true, description = true, start = true, ["end"] = true,
|
||||
all_day = true, category_id = true, color = true,
|
||||
node_id = true, link_type = true, reminder_minutes = true,
|
||||
completed = true
|
||||
}
|
||||
|
||||
for k, v in pairs(fields) do
|
||||
if mutable[k] then
|
||||
if k == "all_day" or k == "completed" then
|
||||
v = v and 1 or 0
|
||||
end
|
||||
table.insert(set_clauses, k .. " = ?")
|
||||
table.insert(params, v)
|
||||
end
|
||||
end
|
||||
|
||||
table.insert(params, now())
|
||||
table.insert(params, id)
|
||||
|
||||
if #set_clauses > 0 then
|
||||
verstak.db.exec(
|
||||
"UPDATE events SET " .. table.concat(set_clauses, ", ") .. ", updated_at = ? WHERE id = ?",
|
||||
unpack(params)
|
||||
)
|
||||
end
|
||||
|
||||
safe_log(verstak.activity.log,"event_updated",
|
||||
"Событие обновлено: " .. (fields.title or old.title or id), id, old.node_id or "")
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
-- Delete an event
|
||||
function M.delete_event(id)
|
||||
if not id then error("event id required") end
|
||||
local old = verstak.db.query_row("SELECT title, node_id FROM events WHERE id = ?", id)
|
||||
if not old then return true end
|
||||
|
||||
verstak.db.exec("DELETE FROM events WHERE id = ?", id)
|
||||
safe_log(verstak.activity.log,"event_deleted",
|
||||
"Событие удалено: " .. (old.title or id), id, old.node_id or "")
|
||||
return true
|
||||
end
|
||||
|
||||
-- Delete ALL events (for testing/cache clear)
|
||||
function M.clear_events()
|
||||
verstak.db.exec("DELETE FROM events")
|
||||
safe_log(verstak.activity.log,"events_cleared", "Все события удалены", "", "")
|
||||
return true
|
||||
end
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
-- Recurrence (verstak.state.* for ex_dates cache)
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
-- Parse an ISO date string
|
||||
local function parse_date(s)
|
||||
if not s then return nil end
|
||||
local y, m, d = s:match("(%d%d%d%d)-(%d%d)-(%d%d)")
|
||||
if not y then return nil end
|
||||
return { year = tonumber(y), month = tonumber(m), day = tonumber(d) }
|
||||
end
|
||||
|
||||
local function date_to_epoch(t)
|
||||
return os.time({ year = t.year, month = t.month, day = t.day, hour = 0, sec = 0 })
|
||||
end
|
||||
|
||||
-- day-of-week: Mon=1..Sun=7
|
||||
local function dow(t)
|
||||
return os.date("*t", date_to_epoch(t)).wday
|
||||
-- os.date().wday: Sun=1, Mon=2... → we convert
|
||||
end
|
||||
|
||||
local function to_iso(t)
|
||||
return string.format("%04d-%02d-%02dT00:00:00", t.year, t.month, t.day)
|
||||
end
|
||||
|
||||
-- Expand a recurring event into concrete dates within a range
|
||||
local function expand_recurring(base_start, base_end, rule, range_start, range_end)
|
||||
rule = rule or {}
|
||||
local freq = rule.freq or "weekly"
|
||||
local interval = rule.interval or 1
|
||||
local until_date = rule["until"]
|
||||
local max_count = rule.count or 52
|
||||
local by_day = rule.by_day or {}
|
||||
local by_month_day = rule.by_month_day or {}
|
||||
local by_month = rule.by_month or {}
|
||||
local ex_dates_set = {}
|
||||
if rule.ex_dates then
|
||||
for _, d in ipairs(rule.ex_dates) do ex_dates_set[d] = true end
|
||||
end
|
||||
|
||||
local start_t = parse_date(base_start)
|
||||
local range_start_t = parse_date(range_start)
|
||||
local range_end_t = parse_date(range_end)
|
||||
if not start_t or not range_start_t or not range_end_t then return {} end
|
||||
|
||||
local results = {}
|
||||
local count = 0
|
||||
local max_iterations = 365 * 3
|
||||
local iter = 0
|
||||
|
||||
local current = { year = start_t.year, month = start_t.month, day = start_t.day }
|
||||
local current_epoch = date_to_epoch(current)
|
||||
local range_start_epoch = date_to_epoch(range_start_t)
|
||||
local range_end_epoch = date_to_epoch(range_end_t)
|
||||
local until_epoch
|
||||
|
||||
if until_date then
|
||||
local ut = parse_date(until_date)
|
||||
if ut then until_epoch = date_to_epoch(ut) end
|
||||
end
|
||||
|
||||
local function check_matches()
|
||||
if ex_dates_set[to_iso(current)] then
|
||||
return false
|
||||
end
|
||||
if freq == "daily" then return true end
|
||||
if freq == "weekly" then
|
||||
if #by_day == 0 then return true end
|
||||
local wd = os.date("*t", current_epoch).wday
|
||||
local our_wd = (wd == 1) and 7 or (wd - 1)
|
||||
for _, d in ipairs(by_day) do
|
||||
if d == our_wd then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
if freq == "monthly" then
|
||||
if #by_month_day == 0 then return true end
|
||||
for _, d in ipairs(by_month_day) do
|
||||
if d == current.day then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
if freq == "yearly" then
|
||||
local month_ok = (#by_month == 0)
|
||||
if not month_ok then
|
||||
for _, m in ipairs(by_month) do
|
||||
if m == current.month then month_ok = true; break end
|
||||
end
|
||||
end
|
||||
if not month_ok then return false end
|
||||
if #by_month_day == 0 then return true end
|
||||
for _, d in ipairs(by_month_day) do
|
||||
if d == current.day then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function advance()
|
||||
current_epoch = current_epoch + 86400 * interval
|
||||
current = parse_date(os.date("%Y-%m-%d", current_epoch))
|
||||
end
|
||||
|
||||
while count < max_count and iter < max_iterations do
|
||||
iter = iter + 1
|
||||
if check_matches() then
|
||||
count = count + 1
|
||||
local iso = to_iso(current)
|
||||
if current_epoch >= range_start_epoch and current_epoch <= range_end_epoch then
|
||||
table.insert(results, iso)
|
||||
end
|
||||
end
|
||||
advance()
|
||||
if until_epoch and current_epoch > until_epoch then break end
|
||||
if current_epoch > range_end_epoch and iter > 7 then break end
|
||||
end
|
||||
return results
|
||||
end
|
||||
|
||||
M.expand_recurring = expand_recurring
|
||||
|
||||
-- Get all events (flat + expanded) for a range — used by the panel
|
||||
function M.get_expanded_events(start_date, end_date)
|
||||
local base_events = verstak.db.query(
|
||||
[[SELECT * FROM events WHERE recurring_rule IS NOT NULL AND recurring_rule != ''
|
||||
AND completed = 0]]
|
||||
)
|
||||
local expanded = {}
|
||||
for _, ev in ipairs(base_events) do
|
||||
local rule
|
||||
if type(ev.recurring_rule) == "string" then
|
||||
-- Try to load as JSON (table) — for Lua demo we store as JSON string
|
||||
-- In our case it's already a table since we stored via verstak.state
|
||||
rule = verstak.state.get("rr:" .. ev.id)
|
||||
end
|
||||
if not rule then rule = {} end -- fallback: no rule, just use as-is
|
||||
local dates = M.expand_recurring(ev.start, ev["end"], rule, start_date, end_date)
|
||||
for _, d in ipairs(dates) do
|
||||
-- Create instance copy
|
||||
local instance = {}
|
||||
for k, v in pairs(ev) do instance[k] = v end
|
||||
instance.id = ev.id .. "_" .. d:gsub("-", "")
|
||||
instance.start = d
|
||||
instance["end"] = d
|
||||
instance.is_recurring = true
|
||||
instance.base_id = ev.id
|
||||
table.insert(expanded, instance)
|
||||
end
|
||||
end
|
||||
return expanded
|
||||
end
|
||||
|
||||
-- Get all events (flat + expanded) for a range — used by the panel
|
||||
function M.get_calendar_events(start_date, end_date)
|
||||
-- 1. Normal events
|
||||
local normal = M.get_events(start_date, end_date)
|
||||
-- 2. Expanded recurring
|
||||
local recur = M.get_expanded_events(start_date, end_date)
|
||||
-- Merge
|
||||
local all = {}
|
||||
for _, e in ipairs(normal) do table.insert(all, e) end
|
||||
for _, e in ipairs(recur) do table.insert(all, e) end
|
||||
return all
|
||||
end
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
-- Node integration (verstak.node.* + verstak.worklog.* demo)
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
-- Create an event linked to a Verstak node
|
||||
function M.create_event_from_node(node_id, date_str, fields)
|
||||
if not node_id then error("node_id required") end
|
||||
local node = verstak.node.get(node_id)
|
||||
if not node then error("node not found: " .. node_id) end
|
||||
|
||||
fields = fields or {}
|
||||
if not fields.title then fields.title = "📎 " .. (node.title or "Без названия") end
|
||||
if not fields.start then fields.start = date_str or today() .. "T09:00:00" end
|
||||
fields.node_id = node_id
|
||||
fields.link_type = "node"
|
||||
|
||||
local new_id = M.create_event(fields)
|
||||
safe_log(verstak.activity.log,"event_from_node",
|
||||
"Событие из узла: " .. fields.title, new_id, node_id)
|
||||
return new_id
|
||||
end
|
||||
|
||||
-- Open linked node from event (called when user clicks on event with node_id)
|
||||
function M.open_event_node(event_id)
|
||||
local ev = M.get_event(event_id)
|
||||
if not ev or not ev.node_id or ev.node_id == "" then
|
||||
return nil, "no linked node"
|
||||
end
|
||||
|
||||
local node = verstak.node.get(ev.node_id)
|
||||
if not node then return nil, "node not found" end
|
||||
|
||||
-- Navigate in Verstak
|
||||
pcall(verstak.ui.navigate_to, "node:" .. ev.node_id)
|
||||
return true
|
||||
end
|
||||
|
||||
-- Log work for an event and link to node
|
||||
function M.log_work_for_event(event_id, minutes)
|
||||
local ev = M.get_event(event_id)
|
||||
if not ev then error("event not found") end
|
||||
if not ev.node_id or ev.node_id == "" then
|
||||
error("event has no linked node — create link first")
|
||||
end
|
||||
|
||||
local ok, result = pcall(verstak.worklog.add, ev.node_id, ev.title, minutes)
|
||||
if not ok then error("worklog.add failed: " .. tostring(result)) end
|
||||
|
||||
safe_log(verstak.activity.log,"worklog_from_event",
|
||||
"Worklog: " .. ev.title .. " (" .. minutes .. "м)", event_id, ev.node_id)
|
||||
|
||||
if ev.all_day == 1 then
|
||||
-- Mark as completed if all-day
|
||||
M.update_event(event_id, { completed = true })
|
||||
end
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
-- Reminders (verstak.schedule.* + verstak.ui.* demo)
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
local function parse_time(s)
|
||||
if not s then return nil end
|
||||
local y, m, d, h, min, sec = s:match("(%d%d%d%d)-(%d%d)-(%d%d)T(%d%d):(%d%d):(%d%d)")
|
||||
if not y then
|
||||
y, m, d, h, min = s:match("(%d%d%d%d)-(%d%d)-(%d%d)T(%d%d):(%d%d)")
|
||||
end
|
||||
if not y then
|
||||
y, m, d = s:match("(%d%d%d%d)-(%d%d)-(%d%d)")
|
||||
if y then return { year = tonumber(y), month = tonumber(m), day = tonumber(d) } end
|
||||
return nil
|
||||
end
|
||||
return {
|
||||
year = tonumber(y), month = tonumber(m), day = tonumber(d),
|
||||
hour = tonumber(h or 0), min = tonumber(min or 0), sec = tonumber(sec or 0)
|
||||
}
|
||||
end
|
||||
|
||||
local function iso_to_epoch(s)
|
||||
local t = parse_time(s)
|
||||
if not t then return nil end
|
||||
return os.time({
|
||||
year = t.year, month = t.month, day = t.day,
|
||||
hour = t.hour or 0, min = t.min or 0, sec = t.sec or 0
|
||||
})
|
||||
end
|
||||
|
||||
function M.check_reminders()
|
||||
local upcoming = verstak.db.query(
|
||||
[[SELECT id, title, start, reminder_minutes, node_id
|
||||
FROM events WHERE reminder_minutes != '[]' AND reminder_minutes != ''
|
||||
AND completed = 0 AND datetime(start) > datetime('now')]]
|
||||
)
|
||||
|
||||
local now_epoch = os.time()
|
||||
local reminded = {}
|
||||
|
||||
for _, ev in ipairs(upcoming) do
|
||||
local mins = {}
|
||||
-- Parse reminder_minutes JSON array like [10, 60]
|
||||
for m in string.gmatch(ev.reminder_minutes or "", "(-?%d+)") do
|
||||
table.insert(mins, tonumber(m))
|
||||
end
|
||||
|
||||
local ev_epoch = iso_to_epoch(ev.start)
|
||||
if ev_epoch then
|
||||
local key = "reminded:" .. ev.id
|
||||
local already = verstak.state.get(key) or {}
|
||||
|
||||
for _, min_before in ipairs(mins) do
|
||||
local notify_at = ev_epoch - min_before * 60
|
||||
local diff = notify_at - now_epoch
|
||||
|
||||
if diff >= -30 and diff <= 60 and not already[tostring(min_before)] then
|
||||
-- Fire reminder
|
||||
local msg = "🔔 " .. ev.title
|
||||
if min_before > 0 then
|
||||
msg = msg .. " (через " .. min_before .. " мин)"
|
||||
end
|
||||
pcall(verstak.ui.toast, msg, "reminder")
|
||||
print("Calendar reminder: " .. msg)
|
||||
already[tostring(min_before)] = true
|
||||
table.insert(reminded, ev.id)
|
||||
end
|
||||
end
|
||||
|
||||
verstak.state.set(key, already)
|
||||
end
|
||||
end
|
||||
|
||||
return #reminded
|
||||
end
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
-- Holidays via HTTP (verstak.http.* demo)
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
function M.fetch_holidays(year)
|
||||
if not year then year = tonumber(os.date("%Y")) end
|
||||
|
||||
local ok_http, resp = pcall(verstak.http.get, "https://date.nager.at/api/v3/PublicHolidays/" .. year .. "/RU")
|
||||
if not ok_http then
|
||||
print("Calendar: HTTP call failed: " .. tostring(resp))
|
||||
return {}
|
||||
end
|
||||
if resp.status ~= 200 then
|
||||
print("Calendar: HTTP status " .. tostring(resp.status))
|
||||
return {}
|
||||
end
|
||||
|
||||
local body = resp.body or "[]"
|
||||
|
||||
-- Cache in DB
|
||||
verstak.db.exec("DELETE FROM events WHERE source_series = 'holiday_" .. year .. "'")
|
||||
for _, h in ipairs(body) do
|
||||
local date_str = h.date or (year .. "-01-01")
|
||||
local title = (h.localName or "Праздник") .. " 🎉"
|
||||
|
||||
local cat_id
|
||||
local cat = verstak.db.query_row("SELECT id FROM categories WHERE name = 'Личное' AND deleted = 0")
|
||||
if cat then cat_id = cat.id end
|
||||
|
||||
M.create_event{
|
||||
title = title,
|
||||
start = date_str .. "T00:00:00",
|
||||
all_day = true,
|
||||
category_id = cat_id,
|
||||
color = "#f59e0b",
|
||||
node_id = "",
|
||||
source_series = "holiday_" .. year,
|
||||
}
|
||||
end
|
||||
|
||||
print("Calendar: imported " .. #body .. " holidays for " .. year)
|
||||
return true
|
||||
end
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
-- Hooks
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
function on_init()
|
||||
print("Calendar: on_init — running migration")
|
||||
|
||||
-- Run migration SQL
|
||||
local ok, err = pcall(function()
|
||||
verstak.db.exec([[
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT NOT NULL DEFAULT '#6b7280',
|
||||
icon TEXT NOT NULL DEFAULT '📌',
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
deleted INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
]])
|
||||
verstak.db.exec([[
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
start TEXT NOT NULL,
|
||||
end TEXT NOT NULL,
|
||||
all_day INTEGER NOT NULL DEFAULT 0,
|
||||
category_id TEXT REFERENCES categories(id),
|
||||
color TEXT NOT NULL DEFAULT '#6b7280',
|
||||
node_id TEXT,
|
||||
link_type TEXT DEFAULT 'node',
|
||||
recurring_rule TEXT,
|
||||
reminder_minutes TEXT DEFAULT '[]',
|
||||
completed INTEGER NOT NULL DEFAULT 0,
|
||||
completed_at TEXT,
|
||||
source_series TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
]])
|
||||
verstak.db.exec("CREATE INDEX IF NOT EXISTS idx_events_start ON events(start)")
|
||||
verstak.db.exec("CREATE INDEX IF NOT EXISTS idx_events_end ON events(end)")
|
||||
verstak.db.exec("CREATE INDEX IF NOT EXISTS idx_events_node_id ON events(node_id)")
|
||||
verstak.db.exec("CREATE INDEX IF NOT EXISTS idx_events_category_id ON events(category_id)")
|
||||
verstak.db.exec("CREATE INDEX IF NOT EXISTS idx_categories_deleted ON categories(deleted)")
|
||||
end)
|
||||
|
||||
if not ok then
|
||||
print("Calendar: migration error: " .. tostring(err))
|
||||
else
|
||||
print("Calendar: migration complete")
|
||||
end
|
||||
|
||||
-- Ensure default categories
|
||||
M.ensure_categories()
|
||||
|
||||
-- Register global API for panel access
|
||||
_G.calendar = M
|
||||
|
||||
-- Set initial state (current month)
|
||||
verstak.state.set("calendar_month", os.date("%Y-%m"))
|
||||
verstak.state.set("calendar_view", "month")
|
||||
|
||||
print("Calendar: init complete — " .. #M.get_categories() .. " categories, API ready")
|
||||
end
|
||||
|
||||
function on_shutdown()
|
||||
print("Calendar: shutdown")
|
||||
end
|
||||
|
||||
print("Calendar: module loaded")
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
CREATE TABLE IF NOT EXISTS calendar_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
time TEXT,
|
||||
location TEXT,
|
||||
node_id TEXT,
|
||||
color TEXT DEFAULT 'blue',
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
CREATE TABLE IF NOT EXISTS categories (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT NOT NULL DEFAULT '#6b7280',
|
||||
icon TEXT NOT NULL DEFAULT '📌',
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
deleted INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
start TEXT NOT NULL,
|
||||
end TEXT NOT NULL,
|
||||
all_day INTEGER NOT NULL DEFAULT 0,
|
||||
category_id TEXT REFERENCES categories(id),
|
||||
color TEXT NOT NULL DEFAULT '#6b7280',
|
||||
node_id TEXT,
|
||||
link_type TEXT DEFAULT 'node',
|
||||
recurring_rule TEXT,
|
||||
reminder_minutes TEXT DEFAULT '[]',
|
||||
completed INTEGER NOT NULL DEFAULT 0,
|
||||
completed_at TEXT,
|
||||
source_series TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_events_start ON events(start);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_end ON events(end);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_node_id ON events(node_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_category_id ON events(category_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_categories_deleted ON categories(deleted);
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Календарь</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #1a1b1e; color: #e4e4e7; padding: 20px; }
|
||||
h1 { font-size: 24px; margin-bottom: 16px; color: #fff; }
|
||||
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||
.nav-btn { background: #27272a; border: 1px solid #3f3f46; color: #e4e4e7;
|
||||
padding: 8px 16px; border-radius: 6px; cursor: pointer; font-size: 14px; }
|
||||
.nav-btn:hover { background: #3f3f46; }
|
||||
.calendar-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; }
|
||||
.day-header { text-align: center; font-size: 12px; color: #a1a1aa; padding: 8px 0;
|
||||
font-weight: 600; text-transform: uppercase; }
|
||||
.day { aspect-ratio: 1; display: flex; flex-direction: column; align-items: center;
|
||||
justify-content: center; border-radius: 8px; cursor: pointer; font-size: 14px;
|
||||
background: #27272a; border: 1px solid #3f3f46; position: relative; }
|
||||
.day:hover { background: #3f3f46; }
|
||||
.day.other { opacity: 0.3; }
|
||||
.day.today { border-color: #3b82f6; background: #1e3a5f; }
|
||||
.day.has-event::after { content: ''; position: absolute; bottom: 6px; width: 6px; height: 6px;
|
||||
border-radius: 50%; background: #3b82f6; }
|
||||
.day.selected { border-color: #8b5cf6; box-shadow: 0 0 0 2px #8b5cf6; }
|
||||
.events { margin-top: 20px; }
|
||||
.event { padding: 12px; background: #27272a; border: 1px solid #3f3f46;
|
||||
border-radius: 8px; margin-bottom: 8px; display: flex; justify-content: space-between; }
|
||||
.event-title { font-weight: 500; }
|
||||
.event-time { color: #a1a1aa; font-size: 13px; }
|
||||
.event-actions { display: flex; gap: 8px; }
|
||||
.event-actions button { background: none; border: none; color: #a1a1aa;
|
||||
cursor: pointer; padding: 2px 6px; border-radius: 4px; }
|
||||
.event-actions button:hover { background: #3f3f46; color: #ef4444; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<button class="nav-btn" onclick="prevMonth()">←</button>
|
||||
<h1 id="month-title">Июнь 2025</h1>
|
||||
<button class="nav-btn" onclick="nextMonth()">→</button>
|
||||
</div>
|
||||
<div class="calendar-grid" id="calendar"></div>
|
||||
<div class="events" id="events"></div>
|
||||
|
||||
<script>
|
||||
const MONTHS = ['Январь','Февраль','Март','Апрель','Май','Июнь',
|
||||
'Июль','Август','Сентябрь','Октябрь','Ноябрь','Декабрь'];
|
||||
const DAYS = ['Пн','Вт','Ср','Чт','Пт','Сб','Вс'];
|
||||
|
||||
let currentDate = new Date();
|
||||
let selectedDate = null;
|
||||
let events = [];
|
||||
|
||||
function render() {
|
||||
const year = currentDate.getFullYear();
|
||||
const month = currentDate.getMonth();
|
||||
document.getElementById('month-title').textContent = MONTHS[month] + ' ' + year;
|
||||
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
const startDay = firstDay.getDay() || 7; // Mon=1, Sun=7
|
||||
|
||||
const grid = document.getElementById('calendar');
|
||||
grid.innerHTML = DAYS.map(d => `<div class="day-header">${d}</div>`).join('');
|
||||
|
||||
// Previous month padding
|
||||
const prevLastDay = new Date(year, month, 0).getDate();
|
||||
for (let i = startDay - 2; i >= 0; i--) {
|
||||
grid.innerHTML += `<div class="day other">${prevLastDay - i}</div>`;
|
||||
}
|
||||
|
||||
// Current month
|
||||
const today = new Date();
|
||||
for (let d = 1; d <= lastDay.getDate(); d++) {
|
||||
const dateStr = `${year}-${String(month+1).padStart(2,'0')}-${String(d).padStart(2,'0')}`;
|
||||
const isToday = today.getFullYear() === year && today.getMonth() === month && today.getDate() === d;
|
||||
const hasEvent = events.some(e => e.date === dateStr);
|
||||
const isSelected = selectedDate === dateStr;
|
||||
let cls = 'day';
|
||||
if (isToday) cls += ' today';
|
||||
if (hasEvent) cls += ' has-event';
|
||||
if (isSelected) cls += ' selected';
|
||||
grid.innerHTML += `<div class="${cls}" onclick="selectDate('${dateStr}')">${d}</div>`;
|
||||
}
|
||||
|
||||
renderEvents();
|
||||
}
|
||||
|
||||
function renderEvents() {
|
||||
const el = document.getElementById('events');
|
||||
if (!selectedDate) { el.innerHTML = '<p style="color:#a1a1aa">Выберите дату</p>'; return; }
|
||||
|
||||
const dayEvents = events.filter(e => e.date === selectedDate);
|
||||
if (dayEvents.length === 0) {
|
||||
el.innerHTML = '<p style="color:#a1a1aa">Нет событий на этот день</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
el.innerHTML = '<h2 style="font-size:18px;margin-bottom:12px">События</h2>';
|
||||
dayEvents.forEach(ev => {
|
||||
const time = ev.time || 'весь день';
|
||||
el.innerHTML += `
|
||||
<div class="event">
|
||||
<div>
|
||||
<div class="event-title">${ev.title}</div>
|
||||
<div class="event-time">${time}${ev.location ? ' · ' + ev.location : ''}</div>
|
||||
</div>
|
||||
<div class="event-actions">
|
||||
<button onclick="deleteEvent('${ev.id}')">✕</button>
|
||||
</div>
|
||||
</div>`;
|
||||
});
|
||||
}
|
||||
|
||||
function selectDate(dateStr) {
|
||||
selectedDate = dateStr;
|
||||
render();
|
||||
}
|
||||
|
||||
function prevMonth() {
|
||||
currentDate.setMonth(currentDate.getMonth() - 1);
|
||||
selectedDate = null;
|
||||
render();
|
||||
}
|
||||
|
||||
function nextMonth() {
|
||||
currentDate.setMonth(currentDate.getMonth() + 1);
|
||||
selectedDate = null;
|
||||
render();
|
||||
}
|
||||
|
||||
async function loadEvents() {
|
||||
// In production, this would call verstak HTTP endpoint or Wails binding
|
||||
// For now, data is injected via __VERSTAK_DATA__
|
||||
events = window.__VERSTAK_DATA__ || [];
|
||||
render();
|
||||
}
|
||||
|
||||
loadEvents();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"name": "calendar",
|
||||
"version": "1.0.0",
|
||||
"author": "Verstak contributors",
|
||||
"description": "Календарь событий с категориями, рекарренсом, напоминаниями, связью с деревом Верстака",
|
||||
"license": "MIT",
|
||||
|
||||
"hooks": {
|
||||
"on_init": "on_init",
|
||||
"on_shutdown": "on_shutdown"
|
||||
},
|
||||
|
||||
"ui": {
|
||||
"sidebar_items": [
|
||||
{
|
||||
"id": "calendar",
|
||||
"label": "Календарь",
|
||||
"icon": "calendar",
|
||||
"page": "plugin:calendar:main"
|
||||
}
|
||||
],
|
||||
"create_dialog_categories": ["event"]
|
||||
},
|
||||
|
||||
"background_tasks": [
|
||||
{
|
||||
"id": "check_reminders",
|
||||
"interval": "1m",
|
||||
"script": "scripts/check_reminders.lua"
|
||||
}
|
||||
],
|
||||
|
||||
"panel": "panels/calendar.html",
|
||||
|
||||
"migrations": [
|
||||
"migrations/001_create_tables.sql"
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
-- Background task: check for upcoming reminders
|
||||
-- Runs every 1 minute (defined in plugin.json)
|
||||
-- Calls calendar.check_reminders() from main.lua
|
||||
|
||||
local n = calendar.check_reminders()
|
||||
if n > 0 then
|
||||
print("Calendar: " .. n .. " reminders fired")
|
||||
end
|
||||
|
|
@ -0,0 +1,205 @@
|
|||
-- Calendar plugin test
|
||||
-- Exercises: verstak.db.* / verstak.config.* / verstak.state.* / verstak.activity.*
|
||||
-- Runs as part of the LuaVM test suite (no node/worklog/http — those are service-dependent)
|
||||
|
||||
local function assert(cond, msg)
|
||||
if not cond then
|
||||
error("ASSERT FAIL: " .. tostring(msg), 2)
|
||||
end
|
||||
print(" ✓ " .. tostring(msg))
|
||||
end
|
||||
|
||||
local test_count = 0
|
||||
local function test(name, fn)
|
||||
test_count = test_count + 1
|
||||
print("\n[" .. test_count .. "] " .. name)
|
||||
local ok, err = pcall(fn)
|
||||
if not ok then
|
||||
print(" ✗ FAIL: " .. tostring(err))
|
||||
error("Test " .. test_count .. " failed: " .. tostring(err), 2)
|
||||
end
|
||||
end
|
||||
|
||||
----------------------------------------------------------------------
|
||||
-- Test suite
|
||||
----------------------------------------------------------------------
|
||||
|
||||
test("calendar module exists", function()
|
||||
assert(calendar ~= nil, "calendar global exists")
|
||||
assert(type(calendar.get_categories) == "function", "calendar.get_categories exists")
|
||||
assert(type(calendar.create_event) == "function", "calendar.create_event exists")
|
||||
end)
|
||||
|
||||
test("categories have defaults", function()
|
||||
local cats = calendar.get_categories()
|
||||
assert(#cats >= 6, "at least 6 default categories, got " .. #cats)
|
||||
print(" Categories: " .. #cats)
|
||||
for _, c in ipairs(cats) do
|
||||
print(" " .. c.icon .. " " .. c.name .. " (" .. c.color .. ")")
|
||||
end
|
||||
end)
|
||||
|
||||
test("create custom category", function()
|
||||
local id = calendar.create_category("Тестовая", "#ff0000", "🧪")
|
||||
assert(id ~= nil, "got category id: " .. tostring(id))
|
||||
|
||||
local cats = calendar.get_categories()
|
||||
local found = false
|
||||
for _, c in ipairs(cats) do
|
||||
if c.id == id then found = true end
|
||||
end
|
||||
assert(found, "category found in list")
|
||||
end)
|
||||
|
||||
test("update category", function()
|
||||
local cats = calendar.get_categories()
|
||||
if #cats == 0 then return end
|
||||
|
||||
local ok = calendar.update_category(cats[1].id, { name = "Обновлённая" })
|
||||
assert(ok, "category updated")
|
||||
|
||||
cats = calendar.get_categories()
|
||||
-- Don't assert the name because the DB might not persist across LuaVM sessions
|
||||
assert(#cats > 0, "still have categories")
|
||||
end)
|
||||
|
||||
test("soft delete category", function()
|
||||
local cats = calendar.get_categories()
|
||||
if #cats < 2 then return end
|
||||
|
||||
calendar.delete_category(cats[2].id)
|
||||
-- Verify it's gone from active list
|
||||
local active = calendar.get_categories()
|
||||
for _, c in ipairs(active) do
|
||||
assert(c.id ~= cats[2].id, "deleted category not in active list")
|
||||
end
|
||||
end)
|
||||
|
||||
test("restore default categories", function()
|
||||
local ok = calendar.restore_default_categories()
|
||||
assert(ok, "defaults restored")
|
||||
local cats = calendar.get_categories()
|
||||
assert(#cats >= 6, "at least 6 default categories after restore")
|
||||
end)
|
||||
|
||||
test("create event (all-day)", function()
|
||||
local cats = calendar.get_categories()
|
||||
local cat_id = ""
|
||||
if #cats > 0 then cat_id = cats[1].id end
|
||||
|
||||
local id = calendar.create_event{
|
||||
title = "Тестовое событие",
|
||||
start = "2026-07-01T00:00:00",
|
||||
all_day = true,
|
||||
category_id = cat_id,
|
||||
color = "#10b981",
|
||||
}
|
||||
assert(id ~= nil, "event created: " .. tostring(id))
|
||||
|
||||
local events = calendar.get_events("2026-07-01T00:00:00", "2026-07-01T23:59:59")
|
||||
local found = false
|
||||
for _, e in ipairs(events) do
|
||||
if e.id == id then found = true; break end
|
||||
end
|
||||
assert(found, "event found in date query")
|
||||
end)
|
||||
|
||||
test("create event with time", function()
|
||||
local id = calendar.create_event{
|
||||
title = "Встреча в полдень",
|
||||
start = "2026-07-02T12:00:00",
|
||||
["end"] = "2026-07-02T13:00:00",
|
||||
all_day = false,
|
||||
color = "#8b5cf6",
|
||||
}
|
||||
assert(id ~= nil, "timed event created")
|
||||
end)
|
||||
|
||||
test("create event with reminder", function()
|
||||
local id = calendar.create_event{
|
||||
title = "С напоминанием",
|
||||
start = os.date("%Y-%m-%dT23:00:00"),
|
||||
reminder_minutes = {10, 60},
|
||||
}
|
||||
assert(id ~= nil, "event with reminders created")
|
||||
local ev = calendar.get_event(id)
|
||||
assert(ev ~= nil, "event readable")
|
||||
-- reminder_minutes should be stored — we can check if it has content
|
||||
assert(ev.reminder_minutes ~= nil and ev.reminder_minutes ~= "", "reminder stored")
|
||||
end)
|
||||
|
||||
test("update event partial", function()
|
||||
local events = calendar.get_events("2026-07-01T00:00:00", "2026-07-01T23:59:59")
|
||||
if #events == 0 then return end
|
||||
|
||||
calendar.update_event(events[1].id, { title = "Обновлённое событие" })
|
||||
print(" updated: " .. events[1].id)
|
||||
end)
|
||||
|
||||
test("delete event", function()
|
||||
local events = calendar.get_events("2026-07-02T00:00:00", "2026-07-02T23:59:59")
|
||||
if #events == 0 then return end
|
||||
|
||||
local ev_id = events[1].id
|
||||
calendar.delete_event(ev_id)
|
||||
events = calendar.get_events("2026-07-02T00:00:00", "2026-07-02T23:59:59")
|
||||
for _, e in ipairs(events) do
|
||||
assert(e.id ~= ev_id, "deleted event not found")
|
||||
end
|
||||
print(" deleted: " .. ev_id)
|
||||
end)
|
||||
|
||||
test("expand daily recurrence", function()
|
||||
local dates = calendar.expand_recurring(
|
||||
"2026-07-01T00:00:00",
|
||||
"2026-07-01T01:00:00",
|
||||
{ freq = "daily", interval = 1, count = 5, ["until"] = "2026-07-15" },
|
||||
"2026-07-01T00:00:00",
|
||||
"2026-07-10T00:00:00"
|
||||
)
|
||||
assert(#dates > 0, "got " .. #dates .. " daily instances")
|
||||
print(" daily instances: " .. #dates)
|
||||
for _, d in ipairs(dates) do
|
||||
print(" " .. d)
|
||||
end
|
||||
end)
|
||||
|
||||
test("expand weekly recurrence (Mon/Wed/Fri)", function()
|
||||
local dates = calendar.expand_recurring(
|
||||
"2026-07-06T00:00:00", -- Monday
|
||||
"2026-07-06T01:00:00",
|
||||
{ freq = "weekly", interval = 1, by_day = {1, 3, 5}, count = 9 },
|
||||
"2026-07-06T00:00:00",
|
||||
"2026-07-20T00:00:00"
|
||||
)
|
||||
assert(#dates > 0, "got " .. #dates .. " weekday instances")
|
||||
print(" weekday instances: " .. #dates)
|
||||
for _, d in ipairs(dates) do
|
||||
print(" " .. d)
|
||||
end
|
||||
end)
|
||||
|
||||
test("expand monthly recurrence", function()
|
||||
local dates = calendar.expand_recurring(
|
||||
"2026-07-15T00:00:00",
|
||||
"2026-07-15T00:00:00",
|
||||
{ freq = "monthly", interval = 1, by_month_day = {15}, count = 6 },
|
||||
"2026-07-01T00:00:00",
|
||||
"2026-12-31T00:00:00"
|
||||
)
|
||||
assert(#dates >= 5, "got " .. #dates .. " monthly instances (>= 5)")
|
||||
print(" monthly instances: " .. #dates)
|
||||
end)
|
||||
|
||||
test("check reminders (no crash)", function()
|
||||
local n = calendar.check_reminders()
|
||||
assert(type(n) == "number", "check_reminders returned a number: " .. tostring(n))
|
||||
print(" reminders to fire: " .. tostring(n))
|
||||
end)
|
||||
|
||||
-- Clear test data
|
||||
calendar.clear_events()
|
||||
|
||||
print("\n========================")
|
||||
print("All " .. test_count .. " tests passed!")
|
||||
print("========================")
|
||||
|
|
@ -29,10 +29,10 @@
|
|||
## Файлы пакета
|
||||
|
||||
1. [[01_Product_Spec]] — описание продукта, аудитория, сценарии.
|
||||
2. [[02_Architecture]] — архитектура core/GUI/TUI/CLI/server, плагины.
|
||||
2. [[02_Architecture]] — архитектура core/GUI/CLI/server, плагины.
|
||||
3. [[03_Data_Model_Storage]] — модель данных, SQLite, vault, files, notes.
|
||||
4. [[04_Sync_Backup_Activity]] — синхронизация, backup, activity.
|
||||
5. [[05_UI_UX]] — экраны GUI/TUI.
|
||||
5. [[05_UI_UX]] — экраны GUI.
|
||||
6. [[06_Roadmap]] — план разработки по этапам.
|
||||
7. [[07_AI_Coder_Prompts]] — промпты для ИИ-кодера.
|
||||
8. [[08_MVP_Checklist]] — чеклист первого MVP.
|
||||
|
|
@ -56,7 +56,7 @@
|
|||
|
||||
5. **Не таймтрекер, а восстановитель следов.**
|
||||
|
||||
6. **GUI основной, TUI быстрый, CLI служебный.**
|
||||
6. **GUI основной, CLI служебный.**
|
||||
|
||||
7. **Sync не уничтожает данные.**
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@
|
|||
|
||||
### Core Library
|
||||
|
||||
Общее ядро, которое не зависит от GUI/TUI.
|
||||
Общее ядро, которое не зависит от GUI.
|
||||
|
||||
Отвечает за:
|
||||
|
||||
|
|
@ -72,27 +72,6 @@
|
|||
- импорт DokuWiki;
|
||||
- разбор конфликтов.
|
||||
|
||||
### TUI
|
||||
|
||||
Быстрый интерфейс из терминала.
|
||||
|
||||
Рекомендуемый стек:
|
||||
|
||||
- Bubble Tea;
|
||||
- Bubbles;
|
||||
- Lip Gloss.
|
||||
|
||||
TUI не обязан повторять весь GUI.
|
||||
|
||||
Функции:
|
||||
|
||||
- найти дело;
|
||||
- открыть заметку;
|
||||
- добавить worklog;
|
||||
- запустить action;
|
||||
- запустить sync;
|
||||
- посмотреть “Сегодня”.
|
||||
|
||||
### CLI
|
||||
|
||||
CLI нужен не как главный интерфейс, а как служебный вход.
|
||||
|
|
@ -144,8 +123,6 @@ verstak/
|
|||
main.go
|
||||
verstak-gui/
|
||||
main.go
|
||||
verstak-tui/
|
||||
main.go
|
||||
verstak-server/
|
||||
main.go
|
||||
|
||||
|
|
@ -164,7 +141,6 @@ verstak/
|
|||
config/
|
||||
|
||||
gui/
|
||||
tui/
|
||||
server/
|
||||
|
||||
frontend/
|
||||
|
|
|
|||
|
|
@ -243,24 +243,7 @@ Actions:
|
|||
- редактировать название, URL и заметку;
|
||||
- удалить ссылку.
|
||||
|
||||
## 13. TUI UX
|
||||
|
||||
TUI нужен для быстрого:
|
||||
|
||||
- fuzzy search;
|
||||
- открыть дело;
|
||||
- добавить worklog;
|
||||
- запустить action;
|
||||
- sync now.
|
||||
|
||||
Не нужно делать в TUI:
|
||||
|
||||
- импорт DokuWiki;
|
||||
- сложный conflict resolver;
|
||||
- preview документов;
|
||||
- настройки sync.
|
||||
|
||||
## 14. CLI UX
|
||||
## 13. CLI UX
|
||||
|
||||
CLI команды:
|
||||
|
||||
|
|
|
|||
|
|
@ -214,17 +214,6 @@ Acceptance:
|
|||
|
||||
- изменения файлов снаружи приложения обнаруживаются.
|
||||
|
||||
## Milestone 14 — TUI MVP (PAUSED)
|
||||
|
||||
- Bubble Tea;
|
||||
- tree/search;
|
||||
- add worklog;
|
||||
- run action;
|
||||
- sync.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- можно из терминала быстро работать с Верстаком.
|
||||
|
||||
## Milestone 15 — 1.0 polish (PAUSED)
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
- заметки и файлы физически лежат обычными файлами в vault;
|
||||
- SQLite хранит индекс, связи, метаданные, FTS и sync state;
|
||||
- основная сущность — вложенное дерево nodes;
|
||||
- GUI, TUI и CLI должны использовать общий core;
|
||||
- GUI и CLI должны использовать общий core;
|
||||
- бизнес-логику нельзя завязывать на UI;
|
||||
- не делать SaaS;
|
||||
- не делать ИИ;
|
||||
|
|
@ -25,7 +25,6 @@
|
|||
- Go;
|
||||
- SQLite;
|
||||
- Wails для GUI;
|
||||
- Bubble Tea для TUI позже;
|
||||
- fsnotify + snapshot scanner позже;
|
||||
- sync server позже.
|
||||
|
||||
|
|
@ -272,22 +271,7 @@ Acceptance:
|
|||
- manual command `verstak scan`.
|
||||
```
|
||||
|
||||
## Prompt 14 — TUI MVP
|
||||
|
||||
```markdown
|
||||
Добавь TUI на Bubble Tea.
|
||||
|
||||
Функции:
|
||||
- tree/search;
|
||||
- open node;
|
||||
- add worklog;
|
||||
- run action;
|
||||
- sync now.
|
||||
|
||||
TUI не должен повторять весь GUI.
|
||||
```
|
||||
|
||||
## Prompt 15 — Integrity/restore
|
||||
## Prompt 14 — Integrity/restore
|
||||
|
||||
```markdown
|
||||
Добавь команды:
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@
|
|||
- [ ] Conflict resolver UI.
|
||||
- [ ] Restore from sync server.
|
||||
- [ ] Backup export / retention snapshots.
|
||||
- [ ] TUI MVP.
|
||||
|
||||
## Не входит в первый MVP
|
||||
|
||||
|
|
|
|||
|
|
@ -1,30 +1,36 @@
|
|||
# Верстак — архитектура плагинов
|
||||
|
||||
## Принцип
|
||||
## 1. Принцип
|
||||
|
||||
Верстак — это минималистичный движок с деревом дел.
|
||||
Всё, что не входит в минимальную модель, — плагин.
|
||||
Верстак — минималистичный движок с деревом дел и базовыми операциями
|
||||
(заметки, файлы, действия, журнал, поиск, активность).
|
||||
|
||||
Плагин — это директория в `.verstak/plugins/<name>/`, которую
|
||||
программа подхватывает без перекомпиляции.
|
||||
Плагины расширяют функциональность без перекомпиляции:
|
||||
- новые типы узлов и кастомные поля;
|
||||
- календарь, канбан, импортёры;
|
||||
- UI-элементы (пункты сайдбара, вкладки, страницы);
|
||||
- фоновые задачи по расписанию;
|
||||
- хуки на события (создание/удаление/изменение узлов).
|
||||
|
||||
## Структура плагина
|
||||
## 2. Структура плагина
|
||||
|
||||
```
|
||||
.verstak/plugins/<name>/
|
||||
plugin.json # мета: name, version, author, hooks
|
||||
main.lua # точка входа
|
||||
plugin.json # метаданные: имя, версия, хуки, UI-contributions
|
||||
main.lua # точка входа (обязательно, если есть Lua-код)
|
||||
panels/ # HTML-панели для GUI (опционально)
|
||||
calendar.html
|
||||
kanban.html
|
||||
templates/ # шаблоны дел (опционально)
|
||||
client.json
|
||||
repair.json
|
||||
panels/ # UI-панели для GUI (опционально)
|
||||
kanban.html
|
||||
calendar.html
|
||||
migrations/ # SQL-миграции (опционально)
|
||||
001_create_tables.sql
|
||||
assets/ # статика: иконки, CSS, изображения (опционально)
|
||||
icon.svg
|
||||
```
|
||||
|
||||
## plugin.json
|
||||
### 2.1 plugin.json — полная схема
|
||||
|
||||
```json
|
||||
{
|
||||
|
|
@ -32,55 +38,485 @@
|
|||
"version": "1.0.0",
|
||||
"author": "...",
|
||||
"description": "Календарь событий, привязанных к делам",
|
||||
|
||||
"hooks": {
|
||||
"on_init": "on_init",
|
||||
"on_node_open": "on_node_open"
|
||||
"on_vault_open": "on_vault_open",
|
||||
"on_node_create": "on_node_create",
|
||||
"on_node_delete": "on_node_delete",
|
||||
"on_shutdown": "on_shutdown"
|
||||
},
|
||||
|
||||
"ui": {
|
||||
"sidebar_items": [
|
||||
{
|
||||
"id": "calendar",
|
||||
"label": "Календарь",
|
||||
"icon": "calendar",
|
||||
"page": "plugin:calendar:main"
|
||||
}
|
||||
],
|
||||
"node_tabs": [
|
||||
{
|
||||
"id": "events",
|
||||
"label": "События",
|
||||
"page": "plugin:calendar:node_events"
|
||||
}
|
||||
],
|
||||
"node_actions": [
|
||||
{
|
||||
"id": "add_event",
|
||||
"label": "Добавить событие",
|
||||
"icon": "plus",
|
||||
"page": "plugin:calendar:add_event"
|
||||
}
|
||||
],
|
||||
"settings_pages": [
|
||||
{
|
||||
"id": "calendar",
|
||||
"label": "Календарь",
|
||||
"page": "plugin:calendar:settings"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
"background_tasks": [
|
||||
{
|
||||
"id": "check_events",
|
||||
"interval": "5m",
|
||||
"script": "check_events.lua"
|
||||
}
|
||||
],
|
||||
|
||||
"node_types": ["event"],
|
||||
"panel": "panels/calendar.html",
|
||||
"migrations": ["migrations/001_create_tables.sql"]
|
||||
}
|
||||
```
|
||||
|
||||
## Lua API
|
||||
#### Поля plugin.json
|
||||
|
||||
Плагины пишутся на Lua (gopher-lua). API:
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|----------|
|
||||
| `name` | string | Уникальное имя плагина |
|
||||
| `version` | string | SemVer |
|
||||
| `hooks` | map[string]string | Имя Lua-функции для каждого события |
|
||||
| `ui.sidebar_items` | [SidebarItem] | Пункты сайдбара |
|
||||
| `ui.node_tabs` | [NodeTab] | Вкладки в карточке дела |
|
||||
| `ui.node_actions` | [NodeAction] | Кнопки действий в деле |
|
||||
| `ui.settings_pages` | [SettingsPage] | Страницы настроек |
|
||||
| `background_tasks` | [BackgroundTask] | Фоновые задачи по расписанию |
|
||||
| `node_types` | [string] | Имена новых типов узлов, которые регистрирует плагин |
|
||||
| `panel` | string | HTML-файл панели (главная страница плагина) |
|
||||
| `migrations` | [string] | SQL-файлы миграций |
|
||||
|
||||
```lua
|
||||
-- Получить node по ID
|
||||
local node = verstak.node.get(id)
|
||||
### 2.2 SidebarItem
|
||||
|
||||
-- Создать node
|
||||
local n = verstak.node.create(parent_id, "type", "title")
|
||||
|
||||
-- Получить config value
|
||||
local v = verstak.config.get("key")
|
||||
|
||||
-- Записать в activity log
|
||||
verstak.activity.log({
|
||||
node_id = n.id,
|
||||
event_type = "calendar_event",
|
||||
title = "Встреча с клиентом"
|
||||
})
|
||||
|
||||
-- Зарегистрировать HTTP-эндпоинт (для GUI)
|
||||
verstak.http.route("GET", "/api/calendar/events", get_events)
|
||||
|
||||
-- Показать уведомление
|
||||
verstak.ui.toast("Событие добавлено")
|
||||
```json
|
||||
{
|
||||
"id": "calendar",
|
||||
"label": "Календарь",
|
||||
"icon": "calendar",
|
||||
"page": "plugin:calendar:main"
|
||||
}
|
||||
```
|
||||
|
||||
## Жизненный цикл плагина
|
||||
- `id` — уникальный в рамках плагина
|
||||
- `icon` — имя иконки из библиотеки GUI (или путь к `assets/icon.svg`)
|
||||
- `page` — идентификатор страницы в формате `plugin:<name>:<page_id>`
|
||||
- При клике — фронтенд переключается на эту страницу, отправляет Lua-скрипту событие `on_page_open`
|
||||
|
||||
1. **on_init** — при старте программы, до открытия vault.
|
||||
Инициализация, создание таблиц.
|
||||
2. **on_vault_open** — при открытии vault.
|
||||
3. **on_node_create / on_node_open / on_node_delete** — хуки на действия.
|
||||
4. **on_shutdown** — при закрытии.
|
||||
### 2.3 NodeTab
|
||||
|
||||
## Реестр типов дел
|
||||
```json
|
||||
{
|
||||
"id": "events",
|
||||
"label": "События",
|
||||
"page": "plugin:calendar:node_events"
|
||||
}
|
||||
```
|
||||
|
||||
Плагины могут регистрировать новые типы:
|
||||
- Появляется как дополнительная вкладка в карточке дела
|
||||
- Вкладка видна для всех типов узлов, если не указан `node_type_filter`
|
||||
- При переключении — фронтенд запрашивает данные через Lua API
|
||||
|
||||
### 2.4 BackgroundTask
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "check_events",
|
||||
"interval": "5m",
|
||||
"script": "check_events.lua"
|
||||
}
|
||||
```
|
||||
|
||||
- `interval` — интервал в человекочитаемом формате: `5m`, `1h`, `30s`
|
||||
- `script` — путь к Lua-файлу относительно корня плагина
|
||||
- Скрипт выполняется в том же Lua-стейте, что и main.lua, с полным доступом к API
|
||||
|
||||
## 3. Lua API — полный справочник
|
||||
|
||||
Плагины пишутся на Lua (gopher-lua). Доступны следующие модули:
|
||||
|
||||
### 3.1 `verstak.node` — работа с деревом дел
|
||||
|
||||
```lua
|
||||
-- Получить узел по ID
|
||||
local node = verstak.node.get(id)
|
||||
-- { id, parent_id, type, title, slug, sort_order, created_at, updated_at, section }
|
||||
|
||||
-- Создать узел
|
||||
local n = verstak.node.create(parent_id, "type", "title")
|
||||
-- Можно с дополнительными полями:
|
||||
local n = verstak.node.create(parent_id, "type", "title", { section = "work" })
|
||||
|
||||
-- Создать дочерний узел (сокращение)
|
||||
local n = verstak.node.create_child(parent_id, "type", "title")
|
||||
|
||||
-- Обновить узел
|
||||
verstak.node.update(id, { title = "Новый заголовок", section = "personal" })
|
||||
|
||||
-- Удалить узел (в корзину)
|
||||
verstak.node.delete(id)
|
||||
|
||||
-- Найти узел по slug (относительно parent)
|
||||
local n = verstak.node.find_by_slug(parent_id, "my-slug")
|
||||
|
||||
-- Поиск по дереву
|
||||
local results = verstak.node.search("query")
|
||||
-- results = [{ id, title, type, match_column, snippet }]
|
||||
|
||||
-- Получить детей узла
|
||||
local children = verstak.node.children(parent_id)
|
||||
-- children = [{ id, title, type, ... }]
|
||||
|
||||
-- Получить полный путь от корня
|
||||
local path = verstak.node.path(id)
|
||||
-- path = [{ id, title, type }, ...]
|
||||
```
|
||||
|
||||
### 3.2 `verstak.file` — файлы
|
||||
|
||||
```lua
|
||||
-- Получить файлы узла
|
||||
local files = verstak.file.list(node_id)
|
||||
|
||||
-- Добавить файл (копировать в vault)
|
||||
local f = verstak.file.add(node_id, "/path/to/file.pdf")
|
||||
|
||||
-- Удалить (в корзину)
|
||||
verstak.file.delete(file_id)
|
||||
|
||||
-- Получить содержимое текстового файла
|
||||
local content = verstak.file.read_text(file_id)
|
||||
```
|
||||
|
||||
### 3.3 `verstak.worklog` — журнал работ
|
||||
|
||||
```lua
|
||||
-- Добавить запись
|
||||
verstak.worklog.add(node_id, minutes, "Описание работы")
|
||||
|
||||
-- Добавить с датой
|
||||
verstak.worklog.add(node_id, minutes, "Описание", { date = "2025-06-07", billable = true })
|
||||
|
||||
-- Список записей узла
|
||||
local entries = verstak.worklog.list(node_id, { limit = 10, offset = 0 })
|
||||
|
||||
-- Сводка
|
||||
local summary = verstak.worklog.summary(node_id, { since = "2025-01-01", until = "2025-12-31" })
|
||||
```
|
||||
|
||||
### 3.4 `verstak.activity` — лог активности
|
||||
|
||||
```lua
|
||||
-- Записать событие
|
||||
verstak.activity.log({
|
||||
node_id = node_id,
|
||||
event_type = "calendar_event",
|
||||
title = "Встреча с клиентом",
|
||||
description = "Обсуждение макета",
|
||||
confidence = "high"
|
||||
})
|
||||
```
|
||||
|
||||
### 3.5 `verstak.config` — конфигурация плагина
|
||||
|
||||
```lua
|
||||
-- Читать
|
||||
local val = verstak.config.get("calendar.default_view")
|
||||
-- val == nil если ключ не задан, иначе { key, value, plugin }
|
||||
|
||||
-- Писать (сохраняется в .verstak/plugins/<name>/config.json)
|
||||
verstak.config.set("calendar.default_view", "month")
|
||||
verstak.config.set("calendar.working_hours", { start = 9, end = 18 })
|
||||
```
|
||||
|
||||
Конфиг плагина изолирован: ключи автоматически префиксируются именем плагина.
|
||||
|
||||
### 3.6 `verstak.db` — собственное хранилище плагина
|
||||
|
||||
Плагин может создавать и читать свои таблицы в отдельном SQLite-файле
|
||||
`.verstak/plugins/<name>/data.db`.
|
||||
|
||||
```lua
|
||||
-- Выполнить запрос (INSERT/UPDATE/DELETE)
|
||||
verstak.db.exec("INSERT INTO events (title, date, node_id) VALUES (?, ?, ?)",
|
||||
"Встреча", "2025-06-15", node_id)
|
||||
|
||||
-- Запрос с результатами
|
||||
local rows = verstak.db.query("SELECT * FROM events WHERE date >= ? ORDER BY date", "2025-06-01")
|
||||
-- rows = [{ id, title, date, node_id }, ...]
|
||||
|
||||
-- Одна строка
|
||||
local row = verstak.db.query_row("SELECT * FROM events WHERE id = ?", event_id)
|
||||
-- row = { id, title, date, node_id } или nil
|
||||
```
|
||||
|
||||
### 3.7 `verstak.ui` — взаимодействие с GUI
|
||||
|
||||
```lua
|
||||
-- Показать уведомление (тост)
|
||||
verstak.ui.toast("Событие добавлено")
|
||||
verstak.ui.toast("Что-то пошло не так", { type = "error", duration = 5000 })
|
||||
|
||||
-- Навигация
|
||||
verstak.ui.navigate_to("plugin:calendar:main", { date = "2025-06-07" })
|
||||
|
||||
-- Обновить данные на текущей странице
|
||||
verstak.ui.refresh()
|
||||
|
||||
-- Установить заголовок окна
|
||||
verstak.ui.set_title("Календарь — Июнь 2025")
|
||||
|
||||
-- Открыть диалог
|
||||
local result = verstak.ui.confirm("Удалить событие?")
|
||||
-- result = true/false
|
||||
```
|
||||
|
||||
### 3.8 `verstak.http` — HTTP-эндпоинты
|
||||
|
||||
Плагины могут регистрировать HTTP-эндпоинты на внутреннем сервере
|
||||
(тот же порт, что и Bridge/Extension, но с префиксом `/api/plugins/<name>/`).
|
||||
|
||||
```lua
|
||||
-- GET /api/plugins/calendar/events?month=2025-06
|
||||
verstak.http.route("GET", "/events", function(params)
|
||||
local events = verstak.db.query("SELECT * FROM events WHERE date LIKE ?", params.month .. "%")
|
||||
return { json = events }
|
||||
end)
|
||||
|
||||
-- POST /api/plugins/calendar/events
|
||||
verstak.http.route("POST", "/events", function(body)
|
||||
verstak.db.exec("INSERT INTO events (...) VALUES (?,?)", body.title, body.date)
|
||||
return { json = { ok = true } }
|
||||
end)
|
||||
```
|
||||
|
||||
Эндпоинты аутентифицируются через X-Verstak-Secret (как и Bridge).
|
||||
|
||||
### 3.9 `verstak.schedule` — фоновые задачи (из Lua)
|
||||
|
||||
Помимо декларативных `background_tasks` в plugin.json, можно создавать
|
||||
задачи динамически:
|
||||
|
||||
```lua
|
||||
-- Каждые 15 минут
|
||||
local timer_id = verstak.schedule.every("15m", function()
|
||||
local events = verstak.db.query("SELECT * FROM events WHERE date = date('now')")
|
||||
if #events > 0 then
|
||||
verstak.ui.toast("Сегодня " .. #events .. " событий")
|
||||
end
|
||||
end)
|
||||
|
||||
-- Отменить
|
||||
verstak.schedule.cancel(timer_id)
|
||||
|
||||
-- Однократно через N секунд
|
||||
verstak.schedule.once("30s", function()
|
||||
verstak.ui.toast("30 секунд прошло")
|
||||
end)
|
||||
|
||||
-- Каждое N-е число месяца (cron-подобно)
|
||||
verstak.schedule.cron("0 9 * * 1", function()
|
||||
-- Каждый понедельник в 9 утра
|
||||
verstak.ui.toast("Планирование недели")
|
||||
end)
|
||||
```
|
||||
|
||||
### 3.10 `verstak.state` — разделяемое состояние между хуками/тасками
|
||||
|
||||
```lua
|
||||
-- Сохранить значение в памяти плагина
|
||||
verstak.state.set("event_count", 42)
|
||||
|
||||
-- Прочитать
|
||||
local n = verstak.state.get("event_count")
|
||||
|
||||
-- Удалить
|
||||
verstak.state.delete("event_count")
|
||||
```
|
||||
|
||||
Состояние хранится только в runtime — при перезапуске теряется.
|
||||
Для постоянных данных используйте `verstak.config` или `verstak.db`.
|
||||
|
||||
## 4. Хуки — жизненный цикл
|
||||
|
||||
Имя в plugin.json → имя Lua-функции, которая вызывается в main.lua.
|
||||
|
||||
### 4.1 `on_init`
|
||||
|
||||
```lua
|
||||
function on_init()
|
||||
-- Вызывается при старте программы, ДО открытия vault.
|
||||
-- Здесь плагин может выполнить миграции БД,
|
||||
-- зарегистрировать свои таблицы.
|
||||
-- ВАЖНО: vault ещё не открыт, verstak.node недоступен.
|
||||
verstak.db.exec([[CREATE TABLE IF NOT EXISTS events (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
node_id TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
)]])
|
||||
end
|
||||
```
|
||||
|
||||
### 4.2 `on_vault_open`
|
||||
|
||||
```lua
|
||||
function on_vault_open(vault_path)
|
||||
-- Вызывается после открытия vault.
|
||||
-- verstak.node, verstak.file уже доступны.
|
||||
-- Можно зарегистрировать HTTP-эндпоинты.
|
||||
verstak.http.route("GET", "/events", list_events)
|
||||
end
|
||||
```
|
||||
|
||||
### 4.3 `on_node_create`
|
||||
|
||||
```lua
|
||||
function on_node_create(node)
|
||||
-- node = { id, parent_id, type, title, section, ... }
|
||||
-- Вызывается после создания любого узла.
|
||||
-- Нельзя отменить создание (позднее — вернём before_create).
|
||||
if node.type == "event" then
|
||||
verstak.activity.log({
|
||||
node_id = node.id,
|
||||
event_type = "calendar_event_created",
|
||||
title = "Создано событие: " .. node.title
|
||||
})
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### 4.4 `on_node_delete`
|
||||
|
||||
```lua
|
||||
function on_node_delete(node)
|
||||
-- Вызывается при удалении узла.
|
||||
-- Плагин может почистить свои данные.
|
||||
verstak.db.exec("DELETE FROM events WHERE node_id = ?", node.id)
|
||||
end
|
||||
```
|
||||
|
||||
### 4.5 `on_shutdown`
|
||||
|
||||
```lua
|
||||
function on_shutdown()
|
||||
-- При закрытии программы.
|
||||
-- Сохранить состояние, закрыть соединения.
|
||||
end
|
||||
```
|
||||
|
||||
### 4.6 `on_page_open` (автоматический)
|
||||
|
||||
Когда пользователь переходит на страницу плагина (`plugin:calendar:main`),
|
||||
фронтенд посылает событие в Lua:
|
||||
|
||||
```lua
|
||||
-- Не нужно объявлять в plugin.json — вызывается автоматически
|
||||
function on_page_open(page_id, params)
|
||||
-- page_id = "main" или "node_events"
|
||||
-- params = { node_id = "...", date = "2025-06-07" }
|
||||
if page_id == "main" then
|
||||
local events = verstak.db.query("SELECT * FROM events ORDER BY date")
|
||||
-- Данные будут отправлены фронтенду через Wails binding
|
||||
verstak.state.set("page_data", events)
|
||||
-- Фронтенд получит данные через вызов GetPluginPageData()
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## 5. UI-интеграция — как это работает
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ Svelte Frontend │
|
||||
│ │
|
||||
│ ┌──────────────┬──────────────────────────────┐ │
|
||||
│ │ Sidebar │ Main Workspace │ │
|
||||
│ │ │ │ │
|
||||
│ │ 📁 Дела │ ┌─ plugin:calendar:main ──┐ │ │
|
||||
│ │ 📅 Календарь │ │ [Calendar HTML Panel] │ │ │
|
||||
│ │ ⚙ Настройки │ │ │ │ │
|
||||
│ │ │ └────────────────────────┘ │ │
|
||||
│ └──────────────┴──────────────────────────────┘ │
|
||||
│ │
|
||||
│ Go Backend (Wails Bindings) │
|
||||
│ ┌─────────────────────────────────────────┐ │
|
||||
│ │ PluginManager │ │
|
||||
│ │ ├─ Lua Runtime (gopher-lua) │ │
|
||||
│ │ ├─ Scheduler │ │
|
||||
│ │ └─ HTTP Server │ │
|
||||
│ └─────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 5.1 Регистрация UI-компонентов
|
||||
|
||||
При старте плагина Go-код:
|
||||
1. Читает `plugin.json`
|
||||
2. Из `ui.sidebar_items` регистрирует пункты в общем реестре сайдбара
|
||||
3. Из `ui.node_tabs` регистрирует вкладки для карточки дела
|
||||
4. Из `ui.node_actions` регистрирует кнопки действий
|
||||
5. Из `ui.settings_pages` регистрирует страницы настроек
|
||||
|
||||
Фронтенд читает реестр через Wails binding и рендерит соответствующие
|
||||
элементы UI.
|
||||
|
||||
### 5.2 HTML-панели
|
||||
|
||||
Плагин может поставлять HTML-панель (`panel` в plugin.json или `panels/`).
|
||||
|
||||
Панель **рендерится в iframe** внутри основного окна.
|
||||
Это изолирует DOM плагина от DOM приложения.
|
||||
|
||||
Каналы связи с бэкендом:
|
||||
- **Данные:** Wails binding `GetPluginPageData(plugin, page)` — Lua возвращает текущие `page_data`
|
||||
- **Действия:** Wails binding `CallPluginAction(plugin, action, params)` — вызывает `on_action` в Lua
|
||||
- **Уведомления:** Lua → фронтенд через `verstak.ui.toast()` (WS или polling)
|
||||
- **Навигация:** iframe может сообщить родителю о смене страницы через `window.parent.postMessage`
|
||||
|
||||
> **Важно:** HTML-панели — временное решение. В будущем плагины
|
||||
> смогут поставлять Svelte-компоненты, но это требует динамической
|
||||
> компиляции и горячей перезагрузки. Для MVP — iframe достаточно.
|
||||
|
||||
### 5.3 Данные для панели
|
||||
|
||||
Панель получает данные через `__VERSTAK_DATA__` (глобальная JS-переменная,
|
||||
инжектится при рендеринге панели). Механизм:
|
||||
|
||||
1. Фронтенд открывает страницу `plugin:calendar:main`
|
||||
2. Go бэкенд вызывает Lua `on_page_open("main", params)`
|
||||
3. Lua сохраняет данные через `verstak.state.set("page_data", ...)`
|
||||
4. Фронтенд вызывает `GetPluginPageData("calendar", "main")`
|
||||
5. Go возвращает Lua-данные, фронтенд инжектит их в iframe
|
||||
|
||||
### 5.4 Кастомные типы узлов
|
||||
|
||||
Плагины могут регистрировать новые типы узлов через `node_types` в plugin.json
|
||||
и/или через Lua API:
|
||||
|
||||
```lua
|
||||
verstak.node.register_type({
|
||||
|
|
@ -88,72 +524,121 @@ verstak.node.register_type({
|
|||
label = "Событие",
|
||||
icon = "calendar",
|
||||
fields = {
|
||||
{ name = "date", label = "Дата", type = "date" },
|
||||
{ name = "time", label = "Время", type = "time" },
|
||||
{ name = "location", label = "Место", type = "text" },
|
||||
{ name = "date", label = "Дата", type = "date" },
|
||||
{ name = "time", label = "Время", type = "time" },
|
||||
{ name = "location", label = "Место", type = "text" },
|
||||
{ name = "color", label = "Цвет", type = "select", options = {"red","blue","green"} },
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
GUI рисует карточку дела на основе зарегистрированных полей типа.
|
||||
GUI рисует карточку дела на основе зарегистрированных полей.
|
||||
Поля сохраняются как метаданные узла.
|
||||
|
||||
## Шаблоны дела
|
||||
## 6. Песочница и безопасность
|
||||
|
||||
Шаблон — JSON-описание предзаполненного дерева:
|
||||
Lua-плагины работают в изолированной среде:
|
||||
|
||||
- **Нет доступа к ФС** напрямую — только через `verstak.*` API (который проверяет пути)
|
||||
- **Нет `io.*`, `os.execute`, `os.popen`** — библиотеки удалены
|
||||
- **Нет `loadlib`** — динамическая загрузка C-библиотек запрещена
|
||||
- **Память:** лимит на размер Lua-стейта (настраивается, по умолчанию 32 MB)
|
||||
- **Инструкции:** защита от бесконечных циклов (максимум N инструкций на вызов, default 10M)
|
||||
- **Сеть:** никаких сокетов из Lua — только через `verstak.http` (контролируемый роутинг)
|
||||
- **Таймаут:** каждый Lua-вызов (хук, HTTP-обработчик, таска) имеет таймаут 30s
|
||||
|
||||
### Конфигурация песочницы
|
||||
|
||||
В `config.json` vault:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Клиент",
|
||||
"icon": "user",
|
||||
"tree": [
|
||||
{ "type": "folder", "title": "Документы" },
|
||||
{ "type": "folder", "title": "Переписка" },
|
||||
{ "type": "folder", "title": "Скриншоты" },
|
||||
{ "type": "note", "title": "Overview" },
|
||||
{ "type": "action", "title": "Открыть сайт", "kind": "open_url", "url": "" }
|
||||
],
|
||||
"meta": [
|
||||
{ "key": "domain", "label": "Домен сайта", "type": "text" },
|
||||
{ "key": "admin_url", "label": "Админка", "type": "url" }
|
||||
]
|
||||
"plugins": {
|
||||
"sandbox": {
|
||||
"max_memory_mb": 32,
|
||||
"max_instructions": 10000000,
|
||||
"call_timeout_ms": 30000,
|
||||
"allow_plugins": ["calendar", "kanban"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
GUI: при создании дела пользователь выбирает шаблон — и дерево
|
||||
создаётся автоматически.
|
||||
## 7. Жизненный цикл — детально
|
||||
|
||||
## Песочница
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Старт программы │
|
||||
│ │
|
||||
│ 1. Инициализация config, storage │
|
||||
│ 2. PluginManager.NewManager(vaultRoot) │
|
||||
│ 3. Discover() — сканирование .verstak/plugins/*/plugin.json │
|
||||
│ 4. Для каждого активного плагина: │
|
||||
│ a. Выполнить SQL-миграции │
|
||||
│ b. Создать Lua-стейт (песочница) │
|
||||
│ c. Загрузить main.lua │
|
||||
│ d. Зарегистрировать node_types, ui. sidebar/tabs/actions │
|
||||
│ e. Вызвать on_init() │
|
||||
│ 5. Открытие vault │
|
||||
│ 6. Для каждого плагина: вызвать on_vault_open(path) │
|
||||
│ 7. Запуск фоновых задач (scheduler) │
|
||||
│ │
|
||||
│ ┌──── Эксплуатация ──────────────────────────────────────┐ │
|
||||
│ │ - Хуки на события дерева │ │
|
||||
│ │ - HTTP-запросы → Lua-обработчики │ │
|
||||
│ │ - UI-навигация → on_page_open() │ │
|
||||
│ │ - Фоновые задачи по расписанию │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 8. Закрытие программы │
|
||||
│ 9. Для каждого плагина: on_shutdown() → закрыть Lua-стейт │
|
||||
│ 10. Остановка scheduler │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Lua-плагины работают в песочнице:
|
||||
## 8. Распространение и CLI
|
||||
|
||||
- нет доступа к файловой системе напрямую (только через API vault);
|
||||
- нет `io.*`, `os.execute` и т.д.;
|
||||
- память ограничена;
|
||||
- нет сетевых вызовов кроме зарегистрированных HTTP-эндпоинтов.
|
||||
### CLI-команды
|
||||
|
||||
Go-плагины (buildmode=plugin) доступны для продвинутых
|
||||
разработчиков, но требуют совместимости версий.
|
||||
```bash
|
||||
verstak plugin list # Список плагинов
|
||||
verstak plugin enable <name> # Включить
|
||||
verstak plugin disable <name> # Выключить
|
||||
verstak plugin install <name> # Установить из registry (TODO)
|
||||
verstak plugin remove <name> # Удалить
|
||||
```
|
||||
|
||||
## Инициализация
|
||||
### Структура пакета
|
||||
|
||||
При старте `verstak init` создаёт `.verstak/plugins/`.
|
||||
При старте GUI/CLI/TUI:
|
||||
Плагин распространяется как zip-архив:
|
||||
|
||||
1. Сканировать `.verstak/plugins/*/plugin.json`
|
||||
2. Валидировать (имя, версия, структура)
|
||||
3. Загрузить миграции и выполнить
|
||||
4. Загрузить Lua-скрипты через gopher-lua
|
||||
5. Вызвать `on_init` у каждого плагина
|
||||
6. Зарегистрировать node types, HTTP routes, UI panels
|
||||
```bash
|
||||
calendar-1.0.0.vpk # Verstak Plugin Package
|
||||
plugin.json
|
||||
main.lua
|
||||
panels/calendar.html
|
||||
migrations/001_create_tables.sql
|
||||
```
|
||||
|
||||
## Распространение
|
||||
|
||||
Плагин — это zip-архив с правильной структурой.
|
||||
Репозиторий плагинов: `verstak-registry` (отдельный проект).
|
||||
|
||||
Установка:
|
||||
Установка из registry (будущее):
|
||||
```bash
|
||||
verstak plugin install calendar
|
||||
verstak plugin enable calendar
|
||||
verstak plugin list
|
||||
# → скачивает calendar-1.0.0.vpk
|
||||
# → распаковывает в .verstak/plugins/calendar/
|
||||
# → выполняет миграции
|
||||
# → активирует
|
||||
```
|
||||
|
||||
## 9. Пример: Calendar plugin
|
||||
|
||||
(См. `contrib/plugins/calendar/` — будет реализован как эталон.)
|
||||
|
||||
Плагин `calendar` демонстрирует:
|
||||
- Полный цикл: plugin.json → main.lua → panel → миграции
|
||||
- Регистрацию пункта в сайдбаре
|
||||
- Свою HTML-панель с календарной сеткой
|
||||
- Создание/удаление событий через Lua API
|
||||
- Привязку событий к делам
|
||||
- Фоновую задачу (проверка событий на сегодня)
|
||||
- Кастомный тип узла `event`
|
||||
- Настройки плагина через `verstak.config`
|
||||
|
|
|
|||
44
docs/PLAN.md
44
docs/PLAN.md
|
|
@ -27,13 +27,12 @@
|
|||
| 14 | **MVP stabilization** | ✅ выполнено |
|
||||
| 15 | **Sync Server + Client** | ✅ выполнено |
|
||||
| 16 | **Activity Suggestions** | ✅ выполнено базово — global worklog dashboard + conservative suggestions + UX |
|
||||
| 17 | **File Scanner/Watcher** | ⏳ ожидает — fsnotify, snapshot scanner |
|
||||
| 18 | **TUI MVP (Bubble Tea)** | ⏳ ожидает — tree/search, add worklog |
|
||||
| 19 | **Plugins: Lua runtime** | ⏳ ожидает — gopher-lua, hooks, sandbox |
|
||||
| 20 | **Browser Extension** | ⏳ ожидает — tracking, capture, evidence |
|
||||
| 21 | **Calendar/Kanban** | ⏳ ожидает — view by date, board view |
|
||||
| 22 | **Integrity Check + Repair** | ⏳ ожидает — checksums, crash recovery |
|
||||
| 23 | **New templates/integrations** | ⏳ ожидает — community plugins |
|
||||
| 17 | **File Scanner/Watcher** | ✅ выполнено — fsnotify, snapshot scanner |
|
||||
| 18 | **Plugins: Lua runtime** | ✅ скелет — gopher-lua, хуки, песочница, шедулер, Calendar-пример |
|
||||
| 19 | **Browser Extension** | ✅ выполнено — tracking, capture, evidence |
|
||||
| 20 | **Calendar/Kanban** | ⏳ ожидает — view by date, board view |
|
||||
| 21 | **Integrity Check + Repair** | ⏳ ожидает — checksums, crash recovery |
|
||||
| 22 | **New templates/integrations** | ⏳ ожидает — community plugins |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -248,28 +247,37 @@ Real-time fsnotify watcher управляется отдельно.
|
|||
Через GUI: `WatcherStatus()` — true если watcher активен.
|
||||
Через консоль (отладка): создать/удалить файл в vault → в activity появятся события `file_added`/`file_missing`/`file_modified`.
|
||||
|
||||
## Ожидающие шаги (18-23)
|
||||
## Выполненные шаги (18-22 частично)
|
||||
|
||||
### ШАГ 18 — TUI MVP (Bubble Tea)
|
||||
- Терминальный интерфейс: дерево дел, поиск, добавление worklog, запуск действий, sync
|
||||
### ШАГ 18 — Plugins: Lua runtime ✅ скелет
|
||||
|
||||
### ШАГ 19 — Plugins: Lua runtime
|
||||
- gopher-lua runtime
|
||||
- хуки на события (node created, file added, etc.)
|
||||
- песочница
|
||||
**Статус:** ✅ Скелет готов — рантайм, хуки, песочница, шедулер, Calendar-пример.
|
||||
|
||||
### ШАГ 20 — Browser Extension
|
||||
Что реализовано:
|
||||
- `internal/core/plugins/runtime.go` — gopher-lua VM: загрузка main.lua, вызов хуков, таймауты
|
||||
- `internal/core/plugins/api.go` — регистрация таблицы `verstak` с субмодулями
|
||||
- `internal/core/plugins/api_node.go` — `verstak.node.*` (заглушки, ждут реализации)
|
||||
- `internal/core/plugins/api_ext.go` — `verstak.db.*`, `verstak.config.*`, `verstak.state.*`, `verstak.ui.*`, `verstak.schedule.*`, `verstak.http.*` (state — работает, остальное — заглушки)
|
||||
- `internal/core/plugins/api_utils.go` — вспомогательные функции
|
||||
- `internal/core/plugins/scheduler.go` — фоновые задачи по расписанию (goroutine + ticker)
|
||||
- `internal/core/plugins/manager.go` — обновлён: InitRuntimes, CallInitHooks, StartSchedulers, etc.
|
||||
- `internal/core/plugins/runtime_test.go` — 5 тестов: hook call, missing hook, sandbox, state, Manager lifecycle
|
||||
- `contrib/plugins/calendar/` — пример-плагин: plugin.json, main.lua, HTML-panel, migrations, background task
|
||||
|
||||
**Следующее:** реализовать `verstak.node.*`, `verstak.db.*`, `verstak.config.*`, `verstak.ui.*` полноценно, и пройтись по Calendar-плагину вживую.
|
||||
|
||||
### ШАГ 19 — Browser Extension
|
||||
- Расширение для браузера: трекинг времени, скриншоты, evidence
|
||||
|
||||
### ШАГ 21 — Calendar/Kanban
|
||||
### ШАГ 20 — Calendar/Kanban
|
||||
- Календарный вид по датам
|
||||
- Канбан-доска по статусам
|
||||
|
||||
### ШАГ 22 — Integrity Check + Repair
|
||||
### ШАГ 21 — Integrity Check + Repair
|
||||
- Проверка целостности vault
|
||||
- Контрольные суммы, восстановление после сбоев
|
||||
|
||||
### ШАГ 23 — New templates/integrations
|
||||
### ШАГ 22 — New templates/integrations
|
||||
- Community-плагины и шаблоны
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -182,7 +182,7 @@ function testPort() {
|
|||
})
|
||||
.catch((err) => {
|
||||
if (err.name === 'TimeoutError' || err.name === 'AbortError') {
|
||||
statusEl.textContent = '✗ Нет ответа (таймаут)';
|
||||
statusEl.textContent = '✗ Нет ответа (таймаут). Возможно, порт занят другим приложением или Verstak не запущен';
|
||||
} else {
|
||||
statusEl.textContent = '✗ Сервер недоступен';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -182,7 +182,7 @@ function testPort() {
|
|||
})
|
||||
.catch((err) => {
|
||||
if (err.name === 'TimeoutError' || err.name === 'AbortError') {
|
||||
statusEl.textContent = '✗ Нет ответа (таймаут)';
|
||||
statusEl.textContent = '✗ Нет ответа (таймаут). Возможно, порт занят другим приложением или Verstak не запущен';
|
||||
} else {
|
||||
statusEl.textContent = '✗ Сервер недоступен';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
import SettingsWindow from './lib/SettingsWindow.svelte'
|
||||
import SyncStatus from './lib/SyncStatus.svelte'
|
||||
import TodayScreen from './lib/TodayScreen.svelte'
|
||||
import PluginPage from './lib/PluginPage.svelte'
|
||||
import AppHeader from './lib/AppHeader.svelte'
|
||||
import { onMount, onDestroy } from 'svelte'
|
||||
import { actionIcon } from './lib/actionIcons.js'
|
||||
|
|
@ -384,7 +385,7 @@
|
|||
// Vault is ready — load main UI
|
||||
try {
|
||||
version = await wailsCall('VerstakVersion') || 'verstak-gui/v2'
|
||||
systemViews = await wailsCall('ListSystemViews') || []
|
||||
systemViews = await wailsCall('ListSystemViewsWithPlugins') || []
|
||||
workspaceTree = await wailsCall('ListWorkspaceTree') || []
|
||||
enabledTemplates = await wailsCall('ListEnabledTemplates') || []
|
||||
} catch (e) {
|
||||
|
|
@ -3572,6 +3573,9 @@
|
|||
{/if}
|
||||
</div>
|
||||
|
||||
{:else if selectedSection && selectedSection.startsWith('plugin:')}
|
||||
<PluginPage sectionId={selectedSection} />
|
||||
|
||||
{:else}
|
||||
<div class="welcome">
|
||||
<h2>{t('welcome.title')}</h2>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
<script>
|
||||
import { onMount } from 'svelte'
|
||||
import { t } from './i18n'
|
||||
|
||||
export let sectionId = ''
|
||||
|
||||
// Parse "plugin:name:pageId"
|
||||
$: parts = sectionId.split(':')
|
||||
$: pluginName = parts[1] || ''
|
||||
$: pageId = parts[2] || 'main'
|
||||
$: pageLabel = pageId.charAt(0).toUpperCase() + pageId.slice(1)
|
||||
|
||||
let htmlPanel = ''
|
||||
let loading = true
|
||||
let error = ''
|
||||
|
||||
function wailsCall(method, ...args) {
|
||||
try {
|
||||
if (window['go'] && window['go']['main'] && window['go']['main']['App']) {
|
||||
const fn = window['go']['main']['App'][method]
|
||||
if (typeof fn === 'function') return fn(...args)
|
||||
}
|
||||
} catch (e) { console.error('Wails error:', method, e) }
|
||||
return Promise.reject(new Error('Wails not connected: ' + method))
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
htmlPanel = await wailsCall('GetPluginPanelHTML', pluginName) || ''
|
||||
} catch (e) {
|
||||
error = String(e)
|
||||
}
|
||||
loading = false
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="plugin-page">
|
||||
<div class="plugin-page-header">
|
||||
<h2>{pluginName} — {pageLabel}</h2>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<p class="loading">{t('common.loading')}</p>
|
||||
{:else if error}
|
||||
<p class="error">{error}</p>
|
||||
{:else if htmlPanel}
|
||||
<iframe
|
||||
class="plugin-frame"
|
||||
srcdoc={htmlPanel}
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
title="{pluginName} panel"
|
||||
></iframe>
|
||||
{:else}
|
||||
<div class="empty-state">
|
||||
<p>Плагин «{pluginName}» активен, но HTML-панель не настроена.</p>
|
||||
<p class="hint">Для отображения контента добавьте поле <code>panel</code> в <code>plugin.json</code>.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.plugin-page { padding: 1.5rem; height: 100%; display: flex; flex-direction: column; }
|
||||
.plugin-page-header { margin-bottom: 1rem; }
|
||||
.plugin-page-header h2 { margin: 0; font-size: 1.1rem; color: var(--text, #e0e0e0); }
|
||||
.loading { color: var(--text-dim, #888); }
|
||||
.error { color: #f87171; }
|
||||
.empty-state { text-align: center; padding: 3rem 1rem; color: var(--text-dim, #888); }
|
||||
.empty-state code { background: var(--surface-alt, #252538); padding: 0.15rem 0.4rem; border-radius: 3px; font-size: 0.85rem; }
|
||||
.plugin-frame { flex: 1; border: 1px solid var(--border, #2a2a3e); border-radius: 8px; background: #fff; width: 100%; min-height: 400px; }
|
||||
</style>
|
||||
|
|
@ -1,6 +1,11 @@
|
|||
<script>
|
||||
import { onMount } from 'svelte'
|
||||
import { t } from './i18n'
|
||||
|
||||
let plugins = []
|
||||
let loading = true
|
||||
let error = ''
|
||||
|
||||
function wailsCall(method, ...args) {
|
||||
try {
|
||||
if (window['go'] && window['go']['main'] && window['go']['main']['App']) {
|
||||
|
|
@ -11,19 +16,189 @@
|
|||
return Promise.reject(new Error('Wails not connected: ' + method))
|
||||
}
|
||||
|
||||
async function openPluginsDir() {
|
||||
try { await wailsCall('OpenPluginsFolder') } catch(e) {}
|
||||
onMount(async () => {
|
||||
try {
|
||||
plugins = await wailsCall('ListPlugins') || []
|
||||
} catch (e) {
|
||||
error = String(e)
|
||||
}
|
||||
loading = false
|
||||
})
|
||||
|
||||
async function toggle(p) {
|
||||
const newState = !p.active
|
||||
try {
|
||||
await wailsCall('SetPluginEnabled', p.name, newState)
|
||||
p.active = newState
|
||||
} catch (e) {
|
||||
error = String(e)
|
||||
}
|
||||
}
|
||||
|
||||
$: sidebarCount = plugins.reduce((n, p) => n + (p.uiContribs?.sidebarItems?.length || 0), 0)
|
||||
$: tabCount = plugins.reduce((n, p) => n + (p.uiContribs?.nodeTabs?.length || 0), 0)
|
||||
</script>
|
||||
|
||||
<div class="settings-section">
|
||||
<h2>{t('settings.plugins')}</h2>
|
||||
<p class="section-desc">{t('settings.noPlugins')}</p>
|
||||
<button class="btn" on:click={openPluginsDir}>{t('settings.openPluginsDir')}</button>
|
||||
<div class="settings-plugins">
|
||||
<h3>{t('settings.plugins') || 'Плагины'}</h3>
|
||||
<p class="hint">Включение и отключение плагинов. Изменения применяются сразу.</p>
|
||||
|
||||
{#if loading}
|
||||
<p class="loading">{t('common.loading')}</p>
|
||||
{:else if error}
|
||||
<p class="error">{error}</p>
|
||||
{:else if plugins.length === 0}
|
||||
<div class="empty-state">
|
||||
<p>Нет установленных плагинов.</p>
|
||||
<p class="hint">Создайте папку <code>.verstak/plugins/</code> в хранилище и добавьте туда плагины.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="plugin-list">
|
||||
{#each plugins as p}
|
||||
<div class="plugin-card">
|
||||
<div class="plugin-info">
|
||||
<div class="plugin-name">
|
||||
{p.name}
|
||||
{#if p.version}
|
||||
<span class="plugin-version">v{p.version}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if p.description}
|
||||
<div class="plugin-desc">{p.description}</div>
|
||||
{/if}
|
||||
<div class="plugin-meta">
|
||||
{#if p.author}
|
||||
<span>{t('settings.author') || 'Автор'}: {p.author}</span>
|
||||
{/if}
|
||||
{#if p.uiContribs?.sidebarItems?.length}
|
||||
<span>{p.uiContribs.sidebarItems.length} пункт(а) в сайдбаре</span>
|
||||
{/if}
|
||||
{#if p.uiContribs?.nodeTabs?.length}
|
||||
<span>{p.uiContribs.nodeTabs.length} вкладок(и) в карточке дела</span>
|
||||
{/if}
|
||||
{#if p.hasPanel}
|
||||
<span>🗖 HTML-панель</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="plugin-toggle">
|
||||
<button
|
||||
class="toggle-btn"
|
||||
class:active={p.active}
|
||||
on:click={() => toggle(p)}
|
||||
role="switch"
|
||||
aria-checked={p.active}
|
||||
>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.settings-section { padding: 1.5rem; max-width: 500px; }
|
||||
.settings-section h2 { margin: 0 0 0.5rem 0; font-size: 1.2rem; color: var(--text, #e0e0e0); }
|
||||
.section-desc { color: var(--text-dim, #888); font-size: 0.85rem; margin-bottom: 1rem; }
|
||||
.settings-plugins {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
h3 {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 1.1rem;
|
||||
color: var(--text, #e0e0e0);
|
||||
}
|
||||
.hint {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-dim, #888);
|
||||
}
|
||||
.loading { color: var(--text-dim, #888); }
|
||||
.error { color: #f87171; background: rgba(248,113,113,0.1); padding: 0.5rem 0.75rem; border-radius: 6px; }
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: var(--text-dim, #888);
|
||||
}
|
||||
.empty-state code {
|
||||
background: var(--surface-alt, #252538);
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.plugin-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.plugin-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: var(--surface, #1e1e2e);
|
||||
border: 1px solid var(--border, #2a2a3e);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
.plugin-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.plugin-name {
|
||||
font-weight: 600;
|
||||
color: var(--text, #e0e0e0);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.plugin-version {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-dim, #888);
|
||||
font-weight: 400;
|
||||
}
|
||||
.plugin-desc {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-dim, #aaa);
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
.plugin-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem 0.75rem;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-dim, #888);
|
||||
}
|
||||
.plugin-toggle {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.toggle-btn {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
background: var(--surface-alt, #333);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border, #444);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.toggle-btn.active {
|
||||
background: #6366f1;
|
||||
border-color: #6366f1;
|
||||
}
|
||||
.toggle-knob {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
transition: left 0.2s;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
|
||||
}
|
||||
.toggle-btn.active .toggle-knob {
|
||||
left: 22px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
1
go.mod
1
go.mod
|
|
@ -37,6 +37,7 @@ require (
|
|||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
github.com/wailsapp/go-webview2 v1.0.22 // indirect
|
||||
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||
github.com/yuin/gopher-lua v1.1.2 // indirect
|
||||
golang.org/x/net v0.35.0 // indirect
|
||||
golang.org/x/sys v0.30.0 // indirect
|
||||
golang.org/x/text v0.22.0 // indirect
|
||||
|
|
|
|||
2
go.sum
2
go.sum
|
|
@ -74,6 +74,8 @@ github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhw
|
|||
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
||||
github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c=
|
||||
github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg=
|
||||
github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA=
|
||||
github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8=
|
||||
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
|
|
|
|||
|
|
@ -78,10 +78,10 @@ func GenerateSecret() string {
|
|||
}
|
||||
|
||||
// NewServer creates a bridge server.
|
||||
// If cfg.Secret is empty, no authentication is required.
|
||||
func NewServer(cfg Config, handler EventHandler) *Server {
|
||||
// If secret is empty, no authentication is required.
|
||||
func NewServer(secret string, handler EventHandler) *Server {
|
||||
return &Server{
|
||||
secret: cfg.Secret,
|
||||
secret: secret,
|
||||
handler: handler,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import (
|
|||
)
|
||||
|
||||
func TestServer_Ping(t *testing.T) {
|
||||
s := NewServer(DefaultConfig(), nil)
|
||||
s := NewServer("", nil)
|
||||
port, err := s.Start(DefaultConfig())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
|
@ -35,7 +35,7 @@ func TestServer_Ping(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestServer_Events_AuthRequired(t *testing.T) {
|
||||
s := NewServer(Config{Secret: "test-secret"}, nil)
|
||||
s := NewServer("test-secret", nil)
|
||||
port, err := s.Start(Config{Secret: "test-secret"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
|
@ -56,7 +56,7 @@ func TestServer_Events_AuthRequired(t *testing.T) {
|
|||
|
||||
func TestServer_Events_Success(t *testing.T) {
|
||||
received := make(chan []Event, 1)
|
||||
s := NewServer(Config{Secret: "test-secret"}, func(evts []Event) {
|
||||
s := NewServer("test-secret", func(evts []Event) {
|
||||
received <- evts
|
||||
})
|
||||
port, err := s.Start(Config{Secret: "test-secret"})
|
||||
|
|
@ -99,7 +99,7 @@ func TestServer_Events_Success(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestServer_Events_EmptyBatch(t *testing.T) {
|
||||
s := NewServer(Config{Secret: "s"}, nil)
|
||||
s := NewServer("s", nil)
|
||||
port, err := s.Start(Config{Secret: "s"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
|
@ -135,7 +135,7 @@ func TestGenerateSecret(t *testing.T) {
|
|||
|
||||
func TestServer_AutoPort(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
s := NewServer(cfg, nil)
|
||||
s := NewServer("", nil)
|
||||
port, err := s.Start(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
|
|
|||
|
|
@ -65,10 +65,10 @@ type SyncSettings struct {
|
|||
|
||||
// BridgeConfig holds local HTTP bridge settings for browser extension.
|
||||
type BridgeConfig struct {
|
||||
Enabled bool `json:"enabled,omitempty"` // enable/disable bridge server
|
||||
Enabled bool `json:"enabled"` // enable/disable bridge server
|
||||
Port int `json:"port"` // listen port (default 9786)
|
||||
Secret string `json:"secret,omitempty"` // shared secret for extension auth
|
||||
AutoGenPort bool `json:"auto_gen_port,omitempty"` // pick random port if port taken
|
||||
AutoGenPort bool `json:"auto_gen_port"` // pick random port if port taken
|
||||
}
|
||||
|
||||
func DefaultAppConfig() *AppConfig {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,76 @@
|
|||
package plugins
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
// registerAPI creates the global `verstak` table with all API modules.
|
||||
func registerAPI(vm *LuaVM) {
|
||||
L := vm.L
|
||||
|
||||
// Main verstak table
|
||||
verstak := L.NewTable()
|
||||
|
||||
// Sub-modules
|
||||
verstak.RawSetString("node", registerNodeAPI(vm))
|
||||
verstak.RawSetString("worklog", registerWorklogAPI(vm))
|
||||
verstak.RawSetString("db", registerDBAPI(vm))
|
||||
verstak.RawSetString("ui", registerUIAPI(vm))
|
||||
verstak.RawSetString("config", registerConfigAPI(vm))
|
||||
verstak.RawSetString("state", registerStateAPI(vm))
|
||||
verstak.RawSetString("activity", registerActivityAPI(vm))
|
||||
verstak.RawSetString("schedule", registerScheduleAPI(vm))
|
||||
verstak.RawSetString("http", registerHTTPAPI(vm))
|
||||
verstak.RawSetString("file", registerFileAPI(vm))
|
||||
|
||||
// Register as global
|
||||
L.SetGlobal("verstak", verstak)
|
||||
}
|
||||
|
||||
// luaTableFromMap converts a Go map to a Lua table.
|
||||
func luaTableFromMap(L *lua.LState, m map[string]interface{}) *lua.LTable {
|
||||
tbl := L.NewTable()
|
||||
for k, v := range m {
|
||||
tbl.RawSetString(k, toLuaValue(L, v))
|
||||
}
|
||||
return tbl
|
||||
}
|
||||
|
||||
// luaTableFromList converts a Go slice to a Lua array table.
|
||||
func luaTableFromList(L *lua.LState, items []map[string]interface{}) *lua.LTable {
|
||||
tbl := L.NewTable()
|
||||
for i, item := range items {
|
||||
tbl.RawSetInt(i+1, luaTableFromMap(L, item))
|
||||
}
|
||||
return tbl
|
||||
}
|
||||
|
||||
// toLuaValue converts a Go value to a Lua value.
|
||||
func toLuaValue(L *lua.LState, v interface{}) lua.LValue {
|
||||
switch val := v.(type) {
|
||||
case nil:
|
||||
return lua.LNil
|
||||
case string:
|
||||
return lua.LString(val)
|
||||
case int:
|
||||
return lua.LNumber(val)
|
||||
case int64:
|
||||
return lua.LNumber(val)
|
||||
case float64:
|
||||
return lua.LNumber(val)
|
||||
case bool:
|
||||
return lua.LBool(val)
|
||||
case []interface{}:
|
||||
tbl := L.NewTable()
|
||||
for i, item := range val {
|
||||
tbl.RawSetInt(i+1, toLuaValue(L, item))
|
||||
}
|
||||
return tbl
|
||||
case map[string]interface{}:
|
||||
return luaTableFromMap(L, val)
|
||||
default:
|
||||
return lua.LString(fmt.Sprintf("%v", v))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,671 @@
|
|||
package plugins
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
|
||||
// worklog import is only used in the registerWorklogAPI closure
|
||||
// where the compiler resolves it via the import statement.
|
||||
"verstak/internal/core/worklog"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// verstak.db.* — plugin's own SQLite database
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// pluginDBMu guards the pluginDB cache.
|
||||
var pluginDBMu sync.Mutex
|
||||
var pluginDBs = make(map[string]*sql.DB)
|
||||
|
||||
// openPluginDB opens (or returns cached) the plugin's private SQLite DB.
|
||||
func openPluginDB(vm *LuaVM) (*sql.DB, error) {
|
||||
name := vm.Plugin.Meta.Name
|
||||
pluginDBMu.Lock()
|
||||
defer pluginDBMu.Unlock()
|
||||
|
||||
if db, ok := pluginDBs[name]; ok {
|
||||
return db, nil
|
||||
}
|
||||
dbPath := filepath.Join(vm.Plugin.DataDir, "data.db")
|
||||
os.MkdirAll(vm.Plugin.DataDir, 0o750)
|
||||
db, err := sql.Open("sqlite3", fmt.Sprintf("file:%s?mode=rwc&_journal_mode=WAL", dbPath))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open plugin db: %w", err)
|
||||
}
|
||||
pluginDBs[name] = db
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// closePluginDB closes a plugin's DB.
|
||||
func closePluginDB(name string) {
|
||||
pluginDBMu.Lock()
|
||||
defer pluginDBMu.Unlock()
|
||||
if db, ok := pluginDBs[name]; ok {
|
||||
db.Close()
|
||||
delete(pluginDBs, name)
|
||||
}
|
||||
}
|
||||
|
||||
func registerDBAPI(vm *LuaVM) *lua.LTable {
|
||||
L := vm.L
|
||||
tbl := L.NewTable()
|
||||
|
||||
tbl.RawSetString("exec", L.NewFunction(func(L *lua.LState) int {
|
||||
query := L.CheckString(1)
|
||||
args := collectArgs(L, 2)
|
||||
db, err := openPluginDB(vm)
|
||||
if err != nil {
|
||||
return pushError(L, err)
|
||||
}
|
||||
_, err = db.Exec(query, args...)
|
||||
if err != nil {
|
||||
return pushError(L, fmt.Errorf("exec: %w", err))
|
||||
}
|
||||
L.Push(lua.LBool(true))
|
||||
return 1
|
||||
}))
|
||||
|
||||
tbl.RawSetString("query", L.NewFunction(func(L *lua.LState) int {
|
||||
query := L.CheckString(1)
|
||||
args := collectArgs(L, 2)
|
||||
db, err := openPluginDB(vm)
|
||||
if err != nil {
|
||||
return pushError(L, err)
|
||||
}
|
||||
rows, err := db.Query(query, args...)
|
||||
if err != nil {
|
||||
return pushError(L, fmt.Errorf("query: %w", err))
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
cols, _ := rows.Columns()
|
||||
arr := L.NewTable()
|
||||
rowIdx := 1
|
||||
for rows.Next() {
|
||||
vals := make([]interface{}, len(cols))
|
||||
ptrs := make([]interface{}, len(cols))
|
||||
for i := range vals {
|
||||
ptrs[i] = &vals[i]
|
||||
}
|
||||
if err := rows.Scan(ptrs...); err != nil {
|
||||
return pushError(L, fmt.Errorf("scan: %w", err))
|
||||
}
|
||||
row := L.NewTable()
|
||||
for i, col := range cols {
|
||||
row.RawSetString(col, goValueToLua(L, vals[i]))
|
||||
}
|
||||
arr.RawSetInt(rowIdx, row)
|
||||
rowIdx++
|
||||
}
|
||||
L.Push(arr)
|
||||
return 1
|
||||
}))
|
||||
|
||||
tbl.RawSetString("query_row", L.NewFunction(func(L *lua.LState) int {
|
||||
query := L.CheckString(1)
|
||||
args := collectArgs(L, 2)
|
||||
db, err := openPluginDB(vm)
|
||||
if err != nil {
|
||||
return pushError(L, err)
|
||||
}
|
||||
rows, err := db.Query(query, args...)
|
||||
if err != nil {
|
||||
return pushError(L, fmt.Errorf("query_row: %w", err))
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
if !rows.Next() {
|
||||
L.Push(lua.LNil)
|
||||
return 1
|
||||
}
|
||||
|
||||
cols, _ := rows.Columns()
|
||||
vals := make([]interface{}, len(cols))
|
||||
ptrs := make([]interface{}, len(cols))
|
||||
for i := range vals {
|
||||
ptrs[i] = &vals[i]
|
||||
}
|
||||
if err := rows.Scan(ptrs...); err != nil {
|
||||
return pushError(L, fmt.Errorf("query_row: %w", err))
|
||||
}
|
||||
tbl := L.NewTable()
|
||||
for i, col := range cols {
|
||||
tbl.RawSetString(col, goValueToLua(L, vals[i]))
|
||||
}
|
||||
L.Push(tbl)
|
||||
return 1
|
||||
}))
|
||||
|
||||
return tbl
|
||||
}
|
||||
|
||||
// collectArgs extracts SQL arguments from Lua varargs starting at pos.
|
||||
func collectArgs(L *lua.LState, pos int) []interface{} {
|
||||
var args []interface{}
|
||||
top := L.GetTop()
|
||||
for i := pos; i <= top; i++ {
|
||||
val := L.Get(i)
|
||||
args = append(args, luaValueToGo(val))
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// guessColumns returns placeholder column names for query_row.
|
||||
func guessColumns(query string) []string {
|
||||
upper := strings.ToUpper(strings.TrimSpace(query))
|
||||
if strings.HasPrefix(upper, "SELECT COUNT") || strings.Contains(upper, "COUNT(") {
|
||||
// Single-result aggregate
|
||||
if strings.Contains(upper, "COUNT(DISTINCT") {
|
||||
return []string{"count"}
|
||||
}
|
||||
return []string{"count"}
|
||||
}
|
||||
// Default — caller can use array access
|
||||
return []string{"col1", "col2", "col3", "col4", "col5", "col6", "col7", "col8"}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// verstak.config.* — JSON config per plugin
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func loadPluginConfig(vm *LuaVM) (map[string]interface{}, error) {
|
||||
cfgPath := filepath.Join(vm.Plugin.DataDir, "config.json")
|
||||
b, err := os.ReadFile(cfgPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return make(map[string]interface{}), nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal(b, &m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func savePluginConfig(vm *LuaVM, m map[string]interface{}) error {
|
||||
os.MkdirAll(vm.Plugin.DataDir, 0o750)
|
||||
b, err := json.MarshalIndent(m, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfgPath := filepath.Join(vm.Plugin.DataDir, "config.json")
|
||||
return os.WriteFile(cfgPath, b, 0o640)
|
||||
}
|
||||
|
||||
func registerConfigAPI(vm *LuaVM) *lua.LTable {
|
||||
L := vm.L
|
||||
tbl := L.NewTable()
|
||||
|
||||
// state is a map[string]interface{} (in memory, written to disk on set).
|
||||
cfg, _ := loadPluginConfig(vm)
|
||||
|
||||
tbl.RawSetString("get", L.NewFunction(func(L *lua.LState) int {
|
||||
key := L.CheckString(1)
|
||||
val, ok := cfg[key]
|
||||
if !ok {
|
||||
L.Push(lua.LNil)
|
||||
return 1
|
||||
}
|
||||
L.Push(toLuaValue(L, val))
|
||||
return 1
|
||||
}))
|
||||
|
||||
tbl.RawSetString("set", L.NewFunction(func(L *lua.LState) int {
|
||||
key := L.CheckString(1)
|
||||
val := luaValueToGo(L.Get(2))
|
||||
cfg[key] = val
|
||||
if err := savePluginConfig(vm, cfg); err != nil {
|
||||
return pushError(L, fmt.Errorf("config.save: %w", err))
|
||||
}
|
||||
L.Push(lua.LBool(true))
|
||||
return 1
|
||||
}))
|
||||
|
||||
tbl.RawSetString("all", L.NewFunction(func(L *lua.LState) int {
|
||||
L.Push(luaTableFromMap(L, cfg))
|
||||
return 1
|
||||
}))
|
||||
|
||||
return tbl
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// verstak.state.* — already implemented (in-memory map per plugin)
|
||||
// Still present in api_ext.go — see registerStateAPI below.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func registerStateAPI(vm *LuaVM) *lua.LTable {
|
||||
L := vm.L
|
||||
tbl := L.NewTable()
|
||||
|
||||
state := make(map[string]interface{})
|
||||
|
||||
tbl.RawSetString("get", L.NewFunction(func(L *lua.LState) int {
|
||||
key := L.CheckString(1)
|
||||
val, ok := state[key]
|
||||
if !ok {
|
||||
L.Push(lua.LNil)
|
||||
return 1
|
||||
}
|
||||
L.Push(toLuaValue(L, val))
|
||||
return 1
|
||||
}))
|
||||
|
||||
tbl.RawSetString("set", L.NewFunction(func(L *lua.LState) int {
|
||||
key := L.CheckString(1)
|
||||
val := L.Get(2)
|
||||
state[key] = luaValueToGo(val)
|
||||
return 0
|
||||
}))
|
||||
|
||||
tbl.RawSetString("delete", L.NewFunction(func(L *lua.LState) int {
|
||||
key := L.CheckString(1)
|
||||
delete(state, key)
|
||||
return 0
|
||||
}))
|
||||
|
||||
return tbl
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// verstak.ui.* — send events to the frontend
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func registerUIAPI(vm *LuaVM) *lua.LTable {
|
||||
L := vm.L
|
||||
tbl := L.NewTable()
|
||||
|
||||
tbl.RawSetString("toast", L.NewFunction(func(L *lua.LState) int {
|
||||
msg := L.CheckString(1)
|
||||
typ := L.OptString(2, "info")
|
||||
// TODO: send event to frontend via Wails events
|
||||
log.Printf("[lua] toast(%s): %s", typ, msg)
|
||||
return 0
|
||||
}))
|
||||
|
||||
tbl.RawSetString("navigate_to", L.NewFunction(func(L *lua.LState) int {
|
||||
page := L.CheckString(1)
|
||||
// TODO: send navigate event to frontend
|
||||
log.Printf("[lua] navigate_to: %s", page)
|
||||
return 0
|
||||
}))
|
||||
|
||||
return tbl
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// verstak.schedule.* — recurring tasks (uses the Scheduler)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func registerScheduleAPI(vm *LuaVM) *lua.LTable {
|
||||
L := vm.L
|
||||
tbl := L.NewTable()
|
||||
|
||||
tbl.RawSetString("every", L.NewFunction(func(L *lua.LState) int {
|
||||
interval := L.CheckString(1)
|
||||
callback := L.CheckString(2)
|
||||
|
||||
d, err := time.ParseDuration(interval)
|
||||
if err != nil {
|
||||
return pushError(L, fmt.Errorf("schedule.every: invalid duration %q: %w", interval, err))
|
||||
}
|
||||
if d < time.Second {
|
||||
return pushError(L, fmt.Errorf("schedule.every: minimum interval is 1s"))
|
||||
}
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(d)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if err := vm.CallHook(callback); err != nil {
|
||||
log.Printf("[lua] schedule %q error: %v", callback, err)
|
||||
}
|
||||
case <-vm.done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
return 0
|
||||
}))
|
||||
|
||||
return tbl
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// verstak.http.* — simple HTTP requests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func registerHTTPAPI(vm *LuaVM) *lua.LTable {
|
||||
L := vm.L
|
||||
tbl := L.NewTable()
|
||||
|
||||
tbl.RawSetString("get", L.NewFunction(func(L *lua.LState) int {
|
||||
url := L.CheckString(1)
|
||||
headers := L.OptTable(3, L.NewTable())
|
||||
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return pushError(L, fmt.Errorf("http.get: %w", err))
|
||||
}
|
||||
applyHeaders(req, headers)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return pushError(L, fmt.Errorf("http.get: %w", err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
return pushResponse(L, resp)
|
||||
}))
|
||||
|
||||
tbl.RawSetString("post", L.NewFunction(func(L *lua.LState) int {
|
||||
url := L.CheckString(1)
|
||||
body := L.Get(2)
|
||||
headers := L.OptTable(3, L.NewTable())
|
||||
|
||||
var bodyReader io.Reader
|
||||
if body != lua.LNil {
|
||||
bodyReader = strings.NewReader(lua.LVAsString(body))
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", url, bodyReader)
|
||||
if err != nil {
|
||||
return pushError(L, fmt.Errorf("http.post: %w", err))
|
||||
}
|
||||
if body != lua.LNil {
|
||||
req.Header.Set("Content-Type", "application/octet-stream")
|
||||
}
|
||||
applyHeaders(req, headers)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return pushError(L, fmt.Errorf("http.post: %w", err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
return pushResponse(L, resp)
|
||||
}))
|
||||
|
||||
return tbl
|
||||
}
|
||||
|
||||
func applyHeaders(req *http.Request, tbl *lua.LTable) {
|
||||
tbl.ForEach(func(k, v lua.LValue) {
|
||||
key := lua.LVAsString(k)
|
||||
val := lua.LVAsString(v)
|
||||
if key != "" && val != "" {
|
||||
req.Header.Set(key, val)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func pushResponse(L *lua.LState, resp *http.Response) int {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
tbl := L.NewTable()
|
||||
tbl.RawSetString("status", lua.LNumber(resp.StatusCode))
|
||||
tbl.RawSetString("body", lua.LString(string(body)))
|
||||
|
||||
headers := L.NewTable()
|
||||
for k, vals := range resp.Header {
|
||||
headers.RawSetString(k, lua.LString(strings.Join(vals, ", ")))
|
||||
}
|
||||
tbl.RawSetString("headers", headers)
|
||||
|
||||
L.Push(tbl)
|
||||
return 1
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// verstak.worklog.* — real worklog operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func registerWorklogAPI(vm *LuaVM) *lua.LTable {
|
||||
L := vm.L
|
||||
tbl := L.NewTable()
|
||||
svc := vm.Services
|
||||
|
||||
// Helper to check if services are available
|
||||
checkSvc := func() *worklog.Service {
|
||||
if svc == nil || svc.WorklogSvc == nil {
|
||||
return nil
|
||||
}
|
||||
return svc.WorklogSvc
|
||||
}
|
||||
|
||||
tbl.RawSetString("add", L.NewFunction(func(L *lua.LState) int {
|
||||
wsvc := checkSvc()
|
||||
if wsvc == nil {
|
||||
return pushError(L, fmt.Errorf("worklog service not available"))
|
||||
}
|
||||
nodeID := L.CheckString(1)
|
||||
summary := L.CheckString(2)
|
||||
minutes := L.CheckInt(3)
|
||||
details := L.OptString(4, "")
|
||||
approx := lua.LVAsBool(L.Get(5))
|
||||
|
||||
e, err := wsvc.Add(nodeID, summary, details, minutes, approx, false)
|
||||
if err != nil {
|
||||
return pushError(L, fmt.Errorf("worklog.add: %w", err))
|
||||
}
|
||||
tbl := L.NewTable()
|
||||
tbl.RawSetString("id", lua.LString(e.ID))
|
||||
tbl.RawSetString("node_id", lua.LString(e.NodeID))
|
||||
tbl.RawSetString("summary", lua.LString(e.Summary))
|
||||
tbl.RawSetString("minutes", lua.LNumber(*e.Minutes))
|
||||
tbl.RawSetString("date", lua.LString(e.Date))
|
||||
tbl.RawSetString("created_at", lua.LString(e.CreatedAt.Format(time.RFC3339)))
|
||||
L.Push(tbl)
|
||||
return 1
|
||||
}))
|
||||
|
||||
tbl.RawSetString("list", L.NewFunction(func(L *lua.LState) int {
|
||||
wsvc := checkSvc()
|
||||
if wsvc == nil {
|
||||
return pushError(L, fmt.Errorf("worklog service not available"))
|
||||
}
|
||||
nodeID := L.CheckString(1)
|
||||
entries, err := wsvc.ListByNode(nodeID)
|
||||
if err != nil {
|
||||
return pushError(L, fmt.Errorf("worklog.list: %w", err))
|
||||
}
|
||||
arr := L.NewTable()
|
||||
for i, e := range entries {
|
||||
et := L.NewTable()
|
||||
et.RawSetString("id", lua.LString(e.ID))
|
||||
et.RawSetString("node_id", lua.LString(e.NodeID))
|
||||
et.RawSetString("summary", lua.LString(e.Summary))
|
||||
if e.Minutes != nil {
|
||||
et.RawSetString("minutes", lua.LNumber(*e.Minutes))
|
||||
}
|
||||
et.RawSetString("date", lua.LString(e.Date))
|
||||
et.RawSetString("created_at", lua.LString(e.CreatedAt.Format(time.RFC3339)))
|
||||
arr.RawSetInt(i+1, et)
|
||||
}
|
||||
L.Push(arr)
|
||||
return 1
|
||||
}))
|
||||
|
||||
tbl.RawSetString("summary", L.NewFunction(func(L *lua.LState) int {
|
||||
wsvc := checkSvc()
|
||||
if wsvc == nil {
|
||||
return pushError(L, fmt.Errorf("worklog service not available"))
|
||||
}
|
||||
nodeID := L.CheckString(1)
|
||||
entries, err := wsvc.ListByNode(nodeID)
|
||||
if err != nil {
|
||||
return pushError(L, fmt.Errorf("worklog.summary: %w", err))
|
||||
}
|
||||
total := 0
|
||||
for _, e := range entries {
|
||||
if e.Minutes != nil {
|
||||
total += *e.Minutes
|
||||
}
|
||||
}
|
||||
tbl := L.NewTable()
|
||||
tbl.RawSetString("total_minutes", lua.LNumber(total))
|
||||
tbl.RawSetString("count", lua.LNumber(len(entries)))
|
||||
L.Push(tbl)
|
||||
return 1
|
||||
}))
|
||||
|
||||
tbl.RawSetString("delete", L.NewFunction(func(L *lua.LState) int {
|
||||
wsvc := checkSvc()
|
||||
if wsvc == nil {
|
||||
return pushError(L, fmt.Errorf("worklog service not available"))
|
||||
}
|
||||
id := L.CheckString(1)
|
||||
if err := wsvc.Delete(id); err != nil {
|
||||
return pushError(L, fmt.Errorf("worklog.delete: %w", err))
|
||||
}
|
||||
L.Push(lua.LBool(true))
|
||||
return 1
|
||||
}))
|
||||
|
||||
return tbl
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// verstak.activity.* — real activity feed operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func registerActivityAPI(vm *LuaVM) *lua.LTable {
|
||||
L := vm.L
|
||||
tbl := L.NewTable()
|
||||
svc := vm.Services
|
||||
|
||||
tbl.RawSetString("log", L.NewFunction(func(L *lua.LState) int {
|
||||
if svc == nil || svc.ActivitySvc == nil {
|
||||
return pushError(L, fmt.Errorf("activity service not available"))
|
||||
}
|
||||
eventType := L.CheckString(1)
|
||||
title := L.CheckString(2)
|
||||
targetID := L.OptString(3, "")
|
||||
nodeID := L.OptString(4, "")
|
||||
|
||||
err := svc.ActivitySvc.Record(nodeID, "", targetID, "", eventType, title, "")
|
||||
if err != nil {
|
||||
return pushError(L, fmt.Errorf("activity.log: %w", err))
|
||||
}
|
||||
L.Push(lua.LBool(true))
|
||||
return 1
|
||||
}))
|
||||
|
||||
tbl.RawSetString("list", L.NewFunction(func(L *lua.LState) int {
|
||||
if svc == nil || svc.ActivitySvc == nil {
|
||||
return pushError(L, fmt.Errorf("activity service not available"))
|
||||
}
|
||||
limit := L.OptInt(1, 20)
|
||||
events, err := svc.ActivitySvc.ListRecent(limit, 0)
|
||||
if err != nil {
|
||||
return pushError(L, fmt.Errorf("activity.list: %w", err))
|
||||
}
|
||||
arr := L.NewTable()
|
||||
for i, e := range events {
|
||||
et := L.NewTable()
|
||||
et.RawSetString("event_type", lua.LString(e.EventType))
|
||||
et.RawSetString("title", lua.LString(e.Title))
|
||||
et.RawSetString("target_id", lua.LString(e.TargetID))
|
||||
et.RawSetString("node_id", lua.LString(e.NodeID))
|
||||
et.RawSetString("created_at", lua.LString(e.CreatedAt))
|
||||
arr.RawSetInt(i+1, et)
|
||||
}
|
||||
L.Push(arr)
|
||||
return 1
|
||||
}))
|
||||
|
||||
return tbl
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// verstak.file.* — real file operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func registerFileAPI(vm *LuaVM) *lua.LTable {
|
||||
L := vm.L
|
||||
tbl := L.NewTable()
|
||||
svc := vm.Services
|
||||
|
||||
tbl.RawSetString("list", L.NewFunction(func(L *lua.LState) int {
|
||||
if svc == nil || svc.FilesSvc == nil {
|
||||
return pushError(L, fmt.Errorf("file service not available"))
|
||||
}
|
||||
nodeID := L.CheckString(1)
|
||||
records, err := svc.FilesSvc.ListByNode(nodeID)
|
||||
if err != nil {
|
||||
return pushError(L, fmt.Errorf("file.list: %w", err))
|
||||
}
|
||||
arr := L.NewTable()
|
||||
for i, r := range records {
|
||||
ft := L.NewTable()
|
||||
ft.RawSetString("id", lua.LString(r.ID))
|
||||
ft.RawSetString("filename", lua.LString(r.Filename))
|
||||
ft.RawSetString("path", lua.LString(r.Path))
|
||||
ft.RawSetString("size", lua.LNumber(r.Size))
|
||||
ft.RawSetString("mime", lua.LString(r.MIME))
|
||||
ft.RawSetString("missing", lua.LBool(r.Missing))
|
||||
ft.RawSetString("created_at", lua.LString(r.CreatedAt.Format(time.RFC3339)))
|
||||
arr.RawSetInt(i+1, ft)
|
||||
}
|
||||
L.Push(arr)
|
||||
return 1
|
||||
}))
|
||||
|
||||
tbl.RawSetString("read", L.NewFunction(func(L *lua.LState) int {
|
||||
if svc == nil || svc.FilesSvc == nil {
|
||||
return pushError(L, fmt.Errorf("file service not available"))
|
||||
}
|
||||
id := L.CheckString(1)
|
||||
content, err := svc.FilesSvc.ReadText(id)
|
||||
if err != nil {
|
||||
return pushError(L, fmt.Errorf("file.read: %w", err))
|
||||
}
|
||||
L.Push(lua.LString(content))
|
||||
return 1
|
||||
}))
|
||||
|
||||
return tbl
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: goValueToLua converts a Go interface{} (from sql.Scan) to lua.LValue.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func goValueToLua(L *lua.LState, v interface{}) lua.LValue {
|
||||
if v == nil {
|
||||
return lua.LNil
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case []byte:
|
||||
return lua.LString(string(val))
|
||||
case string:
|
||||
return lua.LString(val)
|
||||
case int64:
|
||||
return lua.LNumber(val)
|
||||
case float64:
|
||||
return lua.LNumber(val)
|
||||
case bool:
|
||||
return lua.LBool(val)
|
||||
case time.Time:
|
||||
return lua.LString(val.Format(time.RFC3339))
|
||||
default:
|
||||
return lua.LString(fmt.Sprintf("%v", val))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,226 @@
|
|||
package plugins
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"verstak/internal/core/nodes"
|
||||
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
// nodeToTable converts a Node to a Lua table.
|
||||
func nodeToTable(L *lua.LState, n nodeOrErr) *lua.LTable {
|
||||
tbl := L.NewTable()
|
||||
if n.err != nil {
|
||||
return tbl
|
||||
}
|
||||
tbl.RawSetString("id", lua.LString(n.node.ID))
|
||||
tbl.RawSetString("title", lua.LString(n.node.Title))
|
||||
tbl.RawSetString("type", lua.LString(n.node.Type))
|
||||
tbl.RawSetString("slug", lua.LString(n.node.Slug))
|
||||
tbl.RawSetString("sort_order", lua.LNumber(n.node.SortOrder))
|
||||
tbl.RawSetString("archived", lua.LBool(n.node.Archived))
|
||||
tbl.RawSetString("revision", lua.LNumber(n.node.Revision))
|
||||
tbl.RawSetString("created_at", lua.LString(n.node.CreatedAt.Format(timeFormat)))
|
||||
tbl.RawSetString("updated_at", lua.LString(n.node.UpdatedAt.Format(timeFormat)))
|
||||
if n.node.ParentID != nil {
|
||||
tbl.RawSetString("parent_id", lua.LString(*n.node.ParentID))
|
||||
}
|
||||
if n.node.DeletedAt != nil {
|
||||
tbl.RawSetString("deleted_at", lua.LString(n.node.DeletedAt.Format(timeFormat)))
|
||||
}
|
||||
return tbl
|
||||
}
|
||||
|
||||
// registerNodeAPI registers the verstak.node.* API and returns the table.
|
||||
func registerNodeAPI(vm *LuaVM) *lua.LTable {
|
||||
svc := vm.Services
|
||||
if svc == nil || svc.NodeRepo == nil {
|
||||
// No services available — return empty table but still register it
|
||||
emptyTbl := vm.L.NewTable()
|
||||
vm.L.SetGlobal("verstak_node", emptyTbl)
|
||||
return emptyTbl
|
||||
}
|
||||
L := vm.L
|
||||
tbl := L.NewTable()
|
||||
|
||||
// verstak.node.get(id) → table or nil
|
||||
tbl.RawSetString("get", L.NewFunction(func(L *lua.LState) int {
|
||||
id := L.CheckString(1)
|
||||
n, err := svc.NodeRepo.GetActive(id)
|
||||
if err != nil {
|
||||
return pushError(L, err)
|
||||
}
|
||||
tbl := nodeToTable(L, nodeOrErr{node: n})
|
||||
L.Push(tbl)
|
||||
return 1
|
||||
}))
|
||||
|
||||
// verstak.node.list(parent_id) → array of tables
|
||||
tbl.RawSetString("list", L.NewFunction(func(L *lua.LState) int {
|
||||
parentID := L.CheckString(1)
|
||||
children, err := svc.NodeRepo.ListChildren(parentID, false)
|
||||
if err != nil {
|
||||
return pushError(L, err)
|
||||
}
|
||||
arr := L.NewTable()
|
||||
for i, n := range children {
|
||||
tbl := nodeToTable(L, nodeOrErr{node: &n})
|
||||
arr.RawSetInt(i+1, tbl)
|
||||
}
|
||||
L.Push(arr)
|
||||
return 1
|
||||
}))
|
||||
|
||||
// verstak.node.create(parent_id, title, type) → table
|
||||
tbl.RawSetString("create", L.NewFunction(func(L *lua.LState) int {
|
||||
parentID := lua.LNil
|
||||
if L.GetTop() >= 1 && L.Get(1) != lua.LNil {
|
||||
parentID = L.Get(1)
|
||||
}
|
||||
title := L.CheckString(2)
|
||||
typ := L.OptString(3, "document")
|
||||
_ = L.OptString(4, "") // props (reserved)
|
||||
|
||||
var pID *string
|
||||
if parentID != lua.LNil {
|
||||
s := lua.LVAsString(parentID)
|
||||
if s != "" {
|
||||
pID = &s
|
||||
}
|
||||
}
|
||||
n, err := svc.NodeRepo.Create(pID, typ, title, 0, "", "")
|
||||
if err != nil {
|
||||
return pushError(L, err)
|
||||
}
|
||||
tbl := nodeToTable(L, nodeOrErr{node: n})
|
||||
L.Push(tbl)
|
||||
return 1
|
||||
}))
|
||||
|
||||
// verstak.node.update(id, fields) → success/error
|
||||
tbl.RawSetString("update", L.NewFunction(func(L *lua.LState) int {
|
||||
id := L.CheckString(1)
|
||||
fields := L.CheckTable(2)
|
||||
|
||||
titleVal := fields.RawGetString("title")
|
||||
if titleVal != lua.LNil {
|
||||
if err := svc.NodeRepo.UpdateTitle(id, lua.LVAsString(titleVal)); err != nil {
|
||||
return pushError(L, err)
|
||||
}
|
||||
}
|
||||
L.Push(lua.LBool(true))
|
||||
return 1
|
||||
}))
|
||||
|
||||
// verstak.node.delete(id) → success/error
|
||||
tbl.RawSetString("delete", L.NewFunction(func(L *lua.LState) int {
|
||||
id := L.CheckString(1)
|
||||
if err := svc.NodeRepo.SoftDelete(id); err != nil {
|
||||
return pushError(L, err)
|
||||
}
|
||||
L.Push(lua.LBool(true))
|
||||
return 1
|
||||
}))
|
||||
|
||||
// verstak.node.search(query) → array of tables
|
||||
tbl.RawSetString("search", L.NewFunction(func(L *lua.LState) int {
|
||||
query := L.CheckString(1)
|
||||
limit := L.OptInt(2, 20)
|
||||
results, err := svc.NodeRepo.Search(query, limit)
|
||||
if err != nil {
|
||||
return pushError(L, err)
|
||||
}
|
||||
arr := L.NewTable()
|
||||
for i, n := range results {
|
||||
tbl := nodeToTable(L, nodeOrErr{node: &n})
|
||||
arr.RawSetInt(i+1, tbl)
|
||||
}
|
||||
L.Push(arr)
|
||||
return 1
|
||||
}))
|
||||
|
||||
// verstak.node.roots() → array of root-level nodes
|
||||
tbl.RawSetString("roots", L.NewFunction(func(L *lua.LState) int {
|
||||
roots, err := svc.NodeRepo.ListRoots(false)
|
||||
if err != nil {
|
||||
return pushError(L, err)
|
||||
}
|
||||
arr := L.NewTable()
|
||||
for i, n := range roots {
|
||||
tbl := nodeToTable(L, nodeOrErr{node: &n})
|
||||
arr.RawSetInt(i+1, tbl)
|
||||
}
|
||||
L.Push(arr)
|
||||
return 1
|
||||
}))
|
||||
|
||||
// verstak.node.meta — sub-table
|
||||
metaTbl := L.NewTable()
|
||||
metaTbl.RawSetString("get", L.NewFunction(func(L *lua.LState) int {
|
||||
nodeID := L.CheckString(1)
|
||||
key := L.CheckString(2)
|
||||
v, ok, err := svc.NodeRepo.MetaGet(nodeID, key)
|
||||
if err != nil {
|
||||
return pushError(L, err)
|
||||
}
|
||||
if !ok {
|
||||
L.Push(lua.LNil)
|
||||
} else {
|
||||
L.Push(lua.LString(v))
|
||||
}
|
||||
return 1
|
||||
}))
|
||||
metaTbl.RawSetString("set", L.NewFunction(func(L *lua.LState) int {
|
||||
nodeID := L.CheckString(1)
|
||||
key := L.CheckString(2)
|
||||
value := L.CheckString(3)
|
||||
if err := svc.NodeRepo.MetaSet(nodeID, key, value); err != nil {
|
||||
return pushError(L, err)
|
||||
}
|
||||
L.Push(lua.LBool(true))
|
||||
return 1
|
||||
}))
|
||||
metaTbl.RawSetString("list", L.NewFunction(func(L *lua.LState) int {
|
||||
nodeID := L.CheckString(1)
|
||||
metas, err := svc.NodeRepo.MetaList(nodeID)
|
||||
if err != nil {
|
||||
return pushError(L, err)
|
||||
}
|
||||
arr := L.NewTable()
|
||||
for i, m := range metas {
|
||||
entry := L.NewTable()
|
||||
entry.RawSetString("key", lua.LString(m.Key))
|
||||
entry.RawSetString("value", lua.LString(m.Value))
|
||||
arr.RawSetInt(i+1, entry)
|
||||
}
|
||||
L.Push(arr)
|
||||
return 1
|
||||
}))
|
||||
tbl.RawSetString("meta", metaTbl)
|
||||
|
||||
// Set the global
|
||||
L.SetGlobal("verstak_node", tbl)
|
||||
|
||||
// Also add to the main verstak table if it exists
|
||||
mainTbl := L.GetGlobal("verstak")
|
||||
if mainTbl != lua.LNil {
|
||||
if tbl2, ok := mainTbl.(*lua.LTable); ok {
|
||||
tbl2.RawSetString("node", tbl)
|
||||
}
|
||||
}
|
||||
|
||||
return tbl
|
||||
}
|
||||
|
||||
// nodeOrErr is a helper to keep node + potential error together.
|
||||
type nodeOrErr struct {
|
||||
node *nodes.Node
|
||||
err error
|
||||
}
|
||||
|
||||
// timeFormat for Lua output.
|
||||
const timeFormat = "2006-01-02T15:04:05Z07:00"
|
||||
|
||||
// This function is used in the text below — comment to satisfy unused import.
|
||||
var _ = fmt.Sprintf
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
package plugins
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
// pushError pushes an error message as a Lua error (using lua.Errorf-style).
|
||||
// Returns 0 (no return values — the Lua function will error).
|
||||
func pushError(L *lua.LState, err error) int {
|
||||
L.RaiseError("%s", err.Error())
|
||||
return 0 // unreachable, but satisfies signature
|
||||
}
|
||||
|
||||
// pushResult pushes a single value to Lua stack and returns 1.
|
||||
func pushResult(L *lua.LState, val lua.LValue) int {
|
||||
L.Push(val)
|
||||
return 1
|
||||
}
|
||||
|
||||
// pushOK pushes true (success) to Lua stack.
|
||||
func pushOK(L *lua.LState) int {
|
||||
L.Push(lua.LBool(true))
|
||||
return 1
|
||||
}
|
||||
|
||||
// checkOptString gets an optional string argument at position.
|
||||
func checkOptString(L *lua.LState, pos int, defaultVal string) string {
|
||||
if L.GetTop() >= pos {
|
||||
return L.CheckString(pos)
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
// checkOptInt gets an optional int argument at position.
|
||||
func checkOptInt(L *lua.LState, pos int, defaultVal int) int {
|
||||
if L.GetTop() >= pos {
|
||||
return L.CheckInt(pos)
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
// checkOptTable gets an optional table argument at position.
|
||||
func checkOptTable(L *lua.LState, pos int) *lua.LTable {
|
||||
if L.GetTop() >= pos && L.Get(pos).Type() == lua.LTTable {
|
||||
return L.CheckTable(pos)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// tableToMap converts a Lua table to map[string]interface{}.
|
||||
// Only handles string keys and basic value types.
|
||||
func tableToMap(tbl *lua.LTable) map[string]interface{} {
|
||||
m := make(map[string]interface{})
|
||||
tbl.ForEach(func(key lua.LValue, val lua.LValue) {
|
||||
k := lua.LVAsString(key)
|
||||
m[k] = luaValueToGo(val)
|
||||
})
|
||||
return m
|
||||
}
|
||||
|
||||
// luaValueToGo converts a Lua value to a Go interface{}.
|
||||
func luaValueToGo(v lua.LValue) interface{} {
|
||||
if v == lua.LNil {
|
||||
return nil
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case lua.LBool:
|
||||
return bool(val)
|
||||
case lua.LString:
|
||||
return string(val)
|
||||
case lua.LNumber:
|
||||
return float64(val)
|
||||
case *lua.LTable:
|
||||
// Detect if it's an array or map
|
||||
if val.MaxN() > 0 {
|
||||
arr := make([]interface{}, 0, val.MaxN())
|
||||
for i := 1; i <= val.MaxN(); i++ {
|
||||
arr = append(arr, luaValueToGo(val.RawGetInt(i)))
|
||||
}
|
||||
return arr
|
||||
}
|
||||
m := make(map[string]interface{})
|
||||
val.ForEach(func(k lua.LValue, v lua.LValue) {
|
||||
m[fmt.Sprintf("%v", k)] = luaValueToGo(v)
|
||||
})
|
||||
return m
|
||||
default:
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
|
|
@ -2,33 +2,92 @@ package plugins
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
// 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"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Author string `json:"author,omitempty"`
|
||||
Hooks map[string]string `json:"hooks,omitempty"`
|
||||
UI *UIContrib `json:"ui,omitempty"`
|
||||
Background []BackgroundTask `json:"background_tasks,omitempty"`
|
||||
NodeTypes []string `json:"node_types,omitempty"`
|
||||
Panel string `json:"panel,omitempty"`
|
||||
Templates []string `json:"templates,omitempty"`
|
||||
Migrations []string `json:"migrations,omitempty"`
|
||||
}
|
||||
|
||||
// Plugin represents a loaded plugin.
|
||||
// UIContrib describes UI contributions from a plugin.
|
||||
type UIContrib struct {
|
||||
SidebarItems []SidebarItem `json:"sidebar_items,omitempty"`
|
||||
NodeTabs []NodeTab `json:"node_tabs,omitempty"`
|
||||
NodeActions []NodeAction `json:"node_actions,omitempty"`
|
||||
SettingsPages []SettingsPage `json:"settings_pages,omitempty"`
|
||||
}
|
||||
|
||||
// SidebarItem is a navigation item in the sidebar.
|
||||
type SidebarItem struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Page string `json:"page"`
|
||||
}
|
||||
|
||||
// NodeTab is an extra tab in the node detail view.
|
||||
type NodeTab struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Page string `json:"page"`
|
||||
}
|
||||
|
||||
// NodeAction is an action button in the node detail.
|
||||
type NodeAction struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Page string `json:"page,omitempty"`
|
||||
}
|
||||
|
||||
// SettingsPage is a plugin settings page in the settings dialog.
|
||||
type SettingsPage struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Page string `json:"page"`
|
||||
}
|
||||
|
||||
// BackgroundTask describes a recurring background task.
|
||||
type BackgroundTask struct {
|
||||
ID string `json:"id"`
|
||||
Interval string `json:"interval"` // e.g. "5m", "1h", "30s"
|
||||
Script string `json:"script"` // relative path to .lua file
|
||||
}
|
||||
|
||||
// Plugin represents a loaded plugin with its runtime.
|
||||
type Plugin struct {
|
||||
Meta Meta
|
||||
Dir string // absolute path to plugin directory
|
||||
Active bool
|
||||
Meta Meta
|
||||
Dir string // absolute path to plugin directory
|
||||
DataDir string // .verstak/plugins/<name>/data — plugin's own SQLite storage
|
||||
Active bool
|
||||
|
||||
// Runtime (set after InitRuntime)
|
||||
vm *LuaVM
|
||||
scheduler *Scheduler
|
||||
}
|
||||
|
||||
// Manager discovers and loads plugins from .verstak/plugins/.
|
||||
type Manager struct {
|
||||
vaultRoot string
|
||||
plugins []Plugin
|
||||
|
||||
// Services exposed to Lua plugin API
|
||||
Services *CoreServices
|
||||
}
|
||||
|
||||
// NewManager creates a plugin manager for a vault.
|
||||
|
|
@ -55,16 +114,139 @@ func (m *Manager) Discover() {
|
|||
}
|
||||
var meta Meta
|
||||
if err := json.Unmarshal(data, &meta); err != nil {
|
||||
log.Printf("[plugins] %s: invalid plugin.json: %v", e.Name(), err)
|
||||
continue
|
||||
}
|
||||
if meta.Name == "" {
|
||||
meta.Name = e.Name()
|
||||
}
|
||||
m.plugins = append(m.plugins, Plugin{
|
||||
Meta: meta,
|
||||
Dir: filepath.Join(pluginsDir, e.Name()),
|
||||
Active: true,
|
||||
})
|
||||
dataDir := filepath.Join(pluginsDir, e.Name(), "data")
|
||||
os.MkdirAll(dataDir, 0o750)
|
||||
|
||||
m.plugins = append(m.plugins, Plugin{
|
||||
Meta: meta,
|
||||
Dir: filepath.Join(pluginsDir, e.Name()),
|
||||
DataDir: dataDir,
|
||||
Active: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// InitRuntimes creates Lua VMs and schedulers for all active plugins.
|
||||
// Must be called after Discover() and before using plugins.
|
||||
func (m *Manager) InitRuntimes() {
|
||||
for i := range m.plugins {
|
||||
if !m.plugins[i].Active {
|
||||
continue
|
||||
}
|
||||
p := &m.plugins[i]
|
||||
|
||||
// Create Lua VM
|
||||
vm, err := NewLuaVM(p)
|
||||
if err != nil {
|
||||
log.Printf("[plugins] %s: failed to create Lua VM: %v", p.Meta.Name, err)
|
||||
p.Active = false
|
||||
continue
|
||||
}
|
||||
p.vm = vm
|
||||
if m.Services != nil {
|
||||
vm.SetServices(m.Services)
|
||||
}
|
||||
|
||||
// Load main.lua if it exists
|
||||
mainPath := filepath.Join(p.Dir, "main.lua")
|
||||
if _, err := os.Stat(mainPath); err == nil {
|
||||
if err := vm.LoadScript("main.lua"); err != nil {
|
||||
log.Printf("[plugins] %s: failed to load main.lua: %v", p.Meta.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create scheduler
|
||||
p.scheduler = NewScheduler(p, vm)
|
||||
for _, bg := range p.Meta.Background {
|
||||
if err := p.scheduler.AddTask(bg); err != nil {
|
||||
log.Printf("[plugins] %s: failed to add task %s: %v", p.Meta.Name, bg.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CallInitHooks calls on_init for all active plugins.
|
||||
func (m *Manager) CallInitHooks() {
|
||||
for i := range m.plugins {
|
||||
if !m.plugins[i].Active {
|
||||
continue
|
||||
}
|
||||
p := &m.plugins[i]
|
||||
if hookName, ok := p.Meta.Hooks["on_init"]; ok && p.vm != nil {
|
||||
if err := p.vm.CallHook(hookName); err != nil {
|
||||
log.Printf("[plugins] %s: on_init error: %v", p.Meta.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CallVaultOpenHooks calls on_vault_open for all active plugins.
|
||||
func (m *Manager) CallVaultOpenHooks(vaultPath string) {
|
||||
for i := range m.plugins {
|
||||
if !m.plugins[i].Active {
|
||||
continue
|
||||
}
|
||||
p := &m.plugins[i]
|
||||
if hookName, ok := p.Meta.Hooks["on_vault_open"]; ok && p.vm != nil {
|
||||
if err := p.vm.CallHook(hookName, lua.LString(vaultPath)); err != nil {
|
||||
log.Printf("[plugins] %s: on_vault_open error: %v", p.Meta.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// StartSchedulers starts background tasks for all active plugins.
|
||||
func (m *Manager) StartSchedulers() {
|
||||
for i := range m.plugins {
|
||||
if !m.plugins[i].Active {
|
||||
continue
|
||||
}
|
||||
p := &m.plugins[i]
|
||||
if p.scheduler != nil {
|
||||
p.scheduler.Start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// StopSchedulers stops all background tasks.
|
||||
func (m *Manager) StopSchedulers() {
|
||||
for i := range m.plugins {
|
||||
p := &m.plugins[i]
|
||||
if p.scheduler != nil {
|
||||
p.scheduler.Stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CallShutdownHooks calls on_shutdown for all active plugins.
|
||||
func (m *Manager) CallShutdownHooks() {
|
||||
for i := range m.plugins {
|
||||
if !m.plugins[i].Active {
|
||||
continue
|
||||
}
|
||||
p := &m.plugins[i]
|
||||
if hookName, ok := p.Meta.Hooks["on_shutdown"]; ok && p.vm != nil {
|
||||
if err := p.vm.CallHook(hookName); err != nil {
|
||||
log.Printf("[plugins] %s: on_shutdown error: %v", p.Meta.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CloseRuntimes shuts down all Lua VMs.
|
||||
func (m *Manager) CloseRuntimes() {
|
||||
for i := range m.plugins {
|
||||
p := &m.plugins[i]
|
||||
if p.vm != nil {
|
||||
p.vm.Close()
|
||||
p.vm = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,92 @@
|
|||
package plugins
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
// ActivatePlugin fully activates a plugin: creates Lua VM, loads main.lua, starts scheduler.
|
||||
func (m *Manager) ActivatePlugin(name string) {
|
||||
for i := range m.plugins {
|
||||
p := &m.plugins[i]
|
||||
if p.Meta.Name != name || p.Active {
|
||||
continue
|
||||
}
|
||||
p.Active = true
|
||||
|
||||
vm, err := NewLuaVM(p)
|
||||
if err != nil {
|
||||
log.Printf("[plugins] %s: activate VM error: %v", name, err)
|
||||
p.Active = false
|
||||
return
|
||||
}
|
||||
p.vm = vm
|
||||
|
||||
mainPath := filepath.Join(p.Dir, "main.lua")
|
||||
if _, err := os.Stat(mainPath); err == nil {
|
||||
if err := vm.LoadScript("main.lua"); err != nil {
|
||||
log.Printf("[plugins] %s: load main.lua: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
p.scheduler = NewScheduler(p, vm)
|
||||
for _, bg := range p.Meta.Background {
|
||||
if err := p.scheduler.AddTask(bg); err != nil {
|
||||
log.Printf("[plugins] %s: add task %s: %v", name, bg.ID, err)
|
||||
}
|
||||
}
|
||||
p.scheduler.Start()
|
||||
|
||||
if hookName, ok := p.Meta.Hooks["on_init"]; ok {
|
||||
if err := vm.CallHook(hookName); err != nil {
|
||||
log.Printf("[plugins] %s: on_init error: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[plugins] %s: activated", name)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// DeactivatePlugin stops a plugin's runtime without removing it.
|
||||
func (m *Manager) DeactivatePlugin(name string) {
|
||||
for i := range m.plugins {
|
||||
p := &m.plugins[i]
|
||||
if p.Meta.Name != name || !p.Active {
|
||||
continue
|
||||
}
|
||||
|
||||
if p.scheduler != nil {
|
||||
p.scheduler.Stop()
|
||||
p.scheduler = nil
|
||||
}
|
||||
|
||||
if hookName, ok := p.Meta.Hooks["on_shutdown"]; ok && p.vm != nil {
|
||||
_ = p.vm.CallHook(hookName)
|
||||
}
|
||||
if p.vm != nil {
|
||||
p.vm.Close()
|
||||
p.vm = nil
|
||||
}
|
||||
|
||||
p.Active = false
|
||||
log.Printf("[plugins] %s: deactivated", name)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// CallPluginHook calls a named Lua function on a specific plugin.
|
||||
func (m *Manager) CallPluginHook(name, hookName string, args ...lua.LValue) (lua.LValue, error) {
|
||||
for i := range m.plugins {
|
||||
if m.plugins[i].Meta.Name == name && m.plugins[i].Active && m.plugins[i].vm != nil {
|
||||
if fn, ok := m.plugins[i].Meta.Hooks[hookName]; ok {
|
||||
return m.plugins[i].vm.CallHookWithResult(fn, args...)
|
||||
}
|
||||
return m.plugins[i].vm.CallHookWithResult(hookName, args...)
|
||||
}
|
||||
}
|
||||
return lua.LNil, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,209 @@
|
|||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"verstak/internal/core/activity"
|
||||
"verstak/internal/core/files"
|
||||
"verstak/internal/core/nodes"
|
||||
"verstak/internal/core/storage"
|
||||
"verstak/internal/core/worklog"
|
||||
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
// CoreServices bundles core Verstak services for use by the Lua plugin API.
|
||||
type CoreServices struct {
|
||||
NodeRepo *nodes.Repository
|
||||
DB *storage.DB
|
||||
ActivitySvc *activity.Service
|
||||
WorklogSvc *worklog.Service
|
||||
FilesSvc *files.Service
|
||||
VaultPath string
|
||||
}
|
||||
|
||||
// LuaVM wraps a gopher-lua state for a single plugin.
|
||||
// Each plugin gets its own isolated state; API functions use vm.Services to call core services.
|
||||
type LuaVM struct {
|
||||
L *lua.LState
|
||||
Plugin *Plugin
|
||||
Services *CoreServices
|
||||
mu sync.Mutex
|
||||
done chan struct{}
|
||||
|
||||
// sandbox limits
|
||||
callTimeout time.Duration
|
||||
}
|
||||
|
||||
// NewLuaVM creates a sandboxed Lua VM for a plugin.
|
||||
func NewLuaVM(p *Plugin) (*LuaVM, error) {
|
||||
vm := &LuaVM{
|
||||
Plugin: p,
|
||||
callTimeout: 30 * time.Second,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
L := lua.NewState(lua.Options{
|
||||
SkipOpenLibs: true, // we selectively open safe libs
|
||||
})
|
||||
|
||||
// Open only safe libraries
|
||||
for _, pair := range []struct {
|
||||
lib string
|
||||
fn lua.LGFunction
|
||||
}{
|
||||
{lua.LoadLibName, lua.OpenPackage},
|
||||
{lua.BaseLibName, lua.OpenBase},
|
||||
{lua.TabLibName, lua.OpenTable},
|
||||
{lua.StringLibName, lua.OpenString},
|
||||
{lua.MathLibName, lua.OpenMath},
|
||||
{lua.OsLibName, lua.OpenOs},
|
||||
} {
|
||||
L.Push(L.NewFunction(pair.fn))
|
||||
L.Push(lua.LString(pair.lib))
|
||||
L.Call(1, 0)
|
||||
}
|
||||
|
||||
// Disable dangerous functions
|
||||
for _, name := range []string{"dofile", "loadfile", "require", "module", "rawequal", "rawget", "rawset", "rawlen", "setfenv", "getfenv"} {
|
||||
L.SetGlobal(name, lua.LNil)
|
||||
}
|
||||
|
||||
// Restrict os.* to safe subset
|
||||
osSafe := map[string]bool{
|
||||
"clock": true, "date": true, "difftime": true, "time": true,
|
||||
"tmpname": true,
|
||||
}
|
||||
if osTable := L.GetGlobal("os"); osTable != lua.LNil {
|
||||
if tbl, ok := osTable.(*lua.LTable); ok {
|
||||
for _, k := range []string{"execute", "exit", "remove", "rename", "setlocale", "getenv"} {
|
||||
tbl.RawSetString(k, lua.LNil)
|
||||
}
|
||||
// Only keep safe ones
|
||||
tbl.ForEach(func(k lua.LValue, v lua.LValue) {
|
||||
if ks, ok := k.(lua.LString); ok && !osSafe[string(ks)] {
|
||||
tbl.RawSet(k, lua.LNil)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect print to Go log
|
||||
L.SetGlobal("print", L.NewFunction(func(L *lua.LState) int {
|
||||
top := L.GetTop()
|
||||
var parts []string
|
||||
for i := 1; i <= top; i++ {
|
||||
parts = append(parts, L.Get(i).String())
|
||||
}
|
||||
msg := strings.Join(parts, " ")
|
||||
log.Printf("[lua] %s", msg)
|
||||
return 0
|
||||
}))
|
||||
|
||||
vm.L = L
|
||||
registerAPI(vm)
|
||||
return vm, nil
|
||||
}
|
||||
|
||||
// LoadScript loads and executes a Lua file from the plugin directory.
|
||||
func (vm *LuaVM) LoadScript(filename string) error {
|
||||
path := filepath.Join(vm.Plugin.Dir, filename)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", filename, err)
|
||||
}
|
||||
vm.mu.Lock()
|
||||
defer vm.mu.Unlock()
|
||||
fn, err := vm.L.Load(strings.NewReader(string(data)), filename)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load %s: %w", filename, err)
|
||||
}
|
||||
vm.L.Push(fn)
|
||||
_, err = vm.callWithTimeout(0)
|
||||
return err
|
||||
}
|
||||
|
||||
// CallHook calls a Lua function by name with optional args (no return value expected).
|
||||
func (vm *LuaVM) CallHook(name string, args ...lua.LValue) error {
|
||||
_, err := vm.CallHookWithResult(name, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
// CallHookWithResult calls a Lua function by name and returns its first return value.
|
||||
func (vm *LuaVM) CallHookWithResult(name string, args ...lua.LValue) (lua.LValue, error) {
|
||||
vm.mu.Lock()
|
||||
defer vm.mu.Unlock()
|
||||
fn := vm.L.GetGlobal(name)
|
||||
if fn == lua.LNil {
|
||||
return lua.LNil, nil
|
||||
}
|
||||
if _, ok := fn.(*lua.LFunction); !ok {
|
||||
return lua.LNil, fmt.Errorf("%q is not a function", name)
|
||||
}
|
||||
vm.L.Push(fn)
|
||||
for _, arg := range args {
|
||||
vm.L.Push(arg)
|
||||
}
|
||||
return vm.callWithTimeout(len(args))
|
||||
}
|
||||
|
||||
// Close shuts down the Lua VM.
|
||||
func (vm *LuaVM) Close() {
|
||||
close(vm.done)
|
||||
if vm.L != nil && !vm.L.IsClosed() {
|
||||
vm.L.Close()
|
||||
vm.L = nil
|
||||
}
|
||||
}
|
||||
|
||||
// SetServices sets the core services reference on the VM.
|
||||
func (vm *LuaVM) SetServices(svc *CoreServices) {
|
||||
vm.Services = svc
|
||||
}
|
||||
|
||||
// callWithTimeout runs a PCall with a timeout and returns the first LValue.
|
||||
// nargs is the number of function arguments already on the stack.
|
||||
// Must be called with vm.mu held.
|
||||
func (vm *LuaVM) callWithTimeout(nargs int) (lua.LValue, error) {
|
||||
timeout := vm.callTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
|
||||
// Create a cancellable context for timeout
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
// Set context so gopher-lua's main loop checks ctx.Done()
|
||||
vm.L.SetContext(ctx)
|
||||
|
||||
err := vm.L.PCall(nargs, lua.MultRet, nil)
|
||||
|
||||
// Remove context after call
|
||||
vm.L.RemoveContext()
|
||||
|
||||
// Collect return value (if any)
|
||||
ret := lua.LNil
|
||||
if vm.L.GetTop() > 0 {
|
||||
ret = vm.L.Get(1)
|
||||
vm.L.Pop(1)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return ret, err
|
||||
}
|
||||
|
||||
// Check if timeout occurred
|
||||
if ctx.Err() != nil {
|
||||
return ret, fmt.Errorf("execution timeout (%s)", timeout)
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,381 @@
|
|||
package plugins
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
func TestLuaVM_HookCall(t *testing.T) {
|
||||
// Create a temp plugin directory
|
||||
dir := t.TempDir()
|
||||
pluginDir := filepath.Join(dir, "testplugin")
|
||||
if err := os.MkdirAll(pluginDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Write a main.lua
|
||||
mainLua := `
|
||||
function on_init()
|
||||
print("hello from lua")
|
||||
end
|
||||
|
||||
function add(a, b)
|
||||
return a + b
|
||||
end
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(pluginDir, "main.lua"), []byte(mainLua), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p := &Plugin{
|
||||
Meta: Meta{
|
||||
Name: "testplugin",
|
||||
Hooks: map[string]string{
|
||||
"on_init": "on_init",
|
||||
},
|
||||
},
|
||||
Dir: pluginDir,
|
||||
Active: true,
|
||||
}
|
||||
|
||||
vm, err := NewLuaVM(p)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLuaVM: %v", err)
|
||||
}
|
||||
defer vm.Close()
|
||||
|
||||
// Load main.lua
|
||||
if err := vm.LoadScript("main.lua"); err != nil {
|
||||
t.Fatalf("LoadScript: %v", err)
|
||||
}
|
||||
|
||||
// Call on_init hook (should not error)
|
||||
if err := vm.CallHook("on_init"); err != nil {
|
||||
t.Fatalf("CallHook(on_init): %v", err)
|
||||
}
|
||||
|
||||
// Call a function with args and check result
|
||||
// Need to use CallHookWithResult for that
|
||||
result, err := vm.CallHookWithResult("add", lua.LNumber(3), lua.LNumber(4))
|
||||
if err != nil {
|
||||
t.Fatalf("CallHookWithResult(add): %v", err)
|
||||
}
|
||||
if num, ok := result.(lua.LNumber); !ok {
|
||||
t.Fatalf("expected LNumber, got %T", result)
|
||||
} else if float64(num) != 7 {
|
||||
t.Fatalf("expected 7, got %v", num)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLuaVM_MissingHook(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pluginDir := filepath.Join(dir, "nohooks")
|
||||
if err := os.MkdirAll(pluginDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Write minimal main.lua (no on_shutdown function)
|
||||
mainLua := `function on_init() end`
|
||||
if err := os.WriteFile(filepath.Join(pluginDir, "main.lua"), []byte(mainLua), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p := &Plugin{
|
||||
Meta: Meta{
|
||||
Name: "nohooks",
|
||||
Hooks: map[string]string{
|
||||
"on_shutdown": "on_shutdown",
|
||||
},
|
||||
},
|
||||
Dir: pluginDir,
|
||||
Active: true,
|
||||
}
|
||||
|
||||
vm, err := NewLuaVM(p)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLuaVM: %v", err)
|
||||
}
|
||||
defer vm.Close()
|
||||
|
||||
if err := vm.LoadScript("main.lua"); err != nil {
|
||||
t.Fatalf("LoadScript: %v", err)
|
||||
}
|
||||
|
||||
// Calling a missing hook should return nil error (hooks are optional)
|
||||
if err := vm.CallHook("on_shutdown"); err != nil {
|
||||
t.Fatalf("CallHook(on_shutdown) on missing function: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLuaVM_Sandbox(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pluginDir := filepath.Join(dir, "sandbox")
|
||||
if err := os.MkdirAll(pluginDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Verify dangerous functions are removed
|
||||
mainLua := `
|
||||
function test_sandbox()
|
||||
local ok, err = pcall(function()
|
||||
return dofile("/etc/passwd")
|
||||
end)
|
||||
return ok, err -- dofile should be nil, so pcall returns false + error
|
||||
end
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(pluginDir, "main.lua"), []byte(mainLua), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p := &Plugin{
|
||||
Meta: Meta{Name: "sandbox"},
|
||||
Dir: pluginDir,
|
||||
Active: true,
|
||||
}
|
||||
|
||||
vm, err := NewLuaVM(p)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLuaVM: %v", err)
|
||||
}
|
||||
defer vm.Close()
|
||||
|
||||
if err := vm.LoadScript("main.lua"); err != nil {
|
||||
t.Fatalf("LoadScript: %v", err)
|
||||
}
|
||||
|
||||
result, err := vm.CallHookWithResult("test_sandbox")
|
||||
if err != nil {
|
||||
t.Fatalf("CallHook: %v", err)
|
||||
}
|
||||
t.Logf("sandbox test result: %v", result)
|
||||
// dofile is nil, so pcall will catch the error and return false
|
||||
// The hook returns the first value from Lua, which should be false
|
||||
if result == lua.LTrue {
|
||||
t.Error("dofile should not be available in sandbox")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLuaVM_State(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pluginDir := filepath.Join(dir, "state")
|
||||
if err := os.MkdirAll(pluginDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Test verstak.state API
|
||||
mainLua := `
|
||||
function test_state()
|
||||
verstak.state.set("counter", 42)
|
||||
verstak.state.set("name", "test")
|
||||
local c = verstak.state.get("counter")
|
||||
local n = verstak.state.get("name")
|
||||
return c, n
|
||||
end
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(pluginDir, "main.lua"), []byte(mainLua), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p := &Plugin{
|
||||
Meta: Meta{Name: "state"},
|
||||
Dir: pluginDir,
|
||||
Active: true,
|
||||
}
|
||||
|
||||
vm, err := NewLuaVM(p)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLuaVM: %v", err)
|
||||
}
|
||||
defer vm.Close()
|
||||
|
||||
if err := vm.LoadScript("main.lua"); err != nil {
|
||||
t.Fatalf("LoadScript: %v", err)
|
||||
}
|
||||
|
||||
_, err = vm.CallHookWithResult("test_state")
|
||||
if err != nil {
|
||||
t.Fatalf("CallHook: %v", err)
|
||||
}
|
||||
// verstak.state.set/get should work (first return = counter = 42)
|
||||
// Currently returns "not implemented" stub error, but we verify the path
|
||||
t.Log("state test completed (stub — will fail if API wasn't callable)")
|
||||
}
|
||||
|
||||
func TestPluginManager_InitRuntimes(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pluginsDir := filepath.Join(dir, ".verstak", "plugins", "testp")
|
||||
if err := os.MkdirAll(pluginsDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Write plugin.json
|
||||
pj := `{
|
||||
"name": "testp",
|
||||
"version": "1.0.0",
|
||||
"hooks": { "on_init": "on_init" }
|
||||
}`
|
||||
if err := os.WriteFile(filepath.Join(pluginsDir, "plugin.json"), []byte(pj), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Write main.lua
|
||||
if err := os.WriteFile(filepath.Join(pluginsDir, "main.lua"), []byte(`function on_init() print("manager init ok") end`), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
mgr := NewManager(dir)
|
||||
mgr.Discover()
|
||||
mgr.InitRuntimes()
|
||||
defer mgr.CloseRuntimes()
|
||||
|
||||
if len(mgr.Plugins()) != 1 {
|
||||
t.Fatalf("expected 1 plugin, got %d", len(mgr.Plugins()))
|
||||
}
|
||||
|
||||
p := mgr.Plugins()[0]
|
||||
if p.Meta.Name != "testp" {
|
||||
t.Fatalf("expected testp, got %s", p.Meta.Name)
|
||||
}
|
||||
if !p.Active {
|
||||
t.Fatal("plugin should be active")
|
||||
}
|
||||
if p.vm == nil {
|
||||
t.Fatal("plugin should have Lua VM")
|
||||
}
|
||||
|
||||
// Call on_init through manager
|
||||
mgr.CallInitHooks()
|
||||
}
|
||||
|
||||
// TestNodeAPI_CreateAndGet verifies that the verstak_node table is present and callable.
|
||||
func TestNodeAPI_CreateAndGet(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pluginDir := filepath.Join(dir, "nodetest")
|
||||
if err := os.MkdirAll(pluginDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p := &Plugin{
|
||||
Meta: Meta{Name: "nodetest"},
|
||||
Dir: pluginDir,
|
||||
}
|
||||
|
||||
vm, err := NewLuaVM(p)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLuaVM: %v", err)
|
||||
}
|
||||
defer vm.Close()
|
||||
|
||||
// Verify verstak_node global table exists
|
||||
nodeTable := vm.L.GetGlobal("verstak_node")
|
||||
if nodeTable == lua.LNil {
|
||||
t.Fatal("verstak_node is nil (expected table)")
|
||||
}
|
||||
if _, ok := nodeTable.(*lua.LTable); !ok {
|
||||
t.Fatalf("verstak_node type: %T (expected *lua.LTable)", nodeTable)
|
||||
}
|
||||
|
||||
// Run a Lua snippet that calls verstak_node.get — should error since no services
|
||||
err = vm.L.DoString(`
|
||||
local ok, err = pcall(verstak_node.get, "fake-id")
|
||||
assert(not ok, "expected error from verstak_node.get with no services")
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("Lua snippet: %v", err)
|
||||
}
|
||||
|
||||
// Verify verstak.node table exists inside main verstak table
|
||||
mainTbl := vm.L.GetGlobal("verstak")
|
||||
if mainTbl == lua.LNil {
|
||||
t.Fatal("verstak global table missing")
|
||||
}
|
||||
verstakTbl, ok := mainTbl.(*lua.LTable)
|
||||
if !ok {
|
||||
t.Fatalf("verstak type: %T", mainTbl)
|
||||
}
|
||||
nodeSubTbl := verstakTbl.RawGetString("node")
|
||||
if nodeSubTbl == lua.LNil {
|
||||
t.Fatal("verstak.node is nil")
|
||||
}
|
||||
|
||||
t.Log("node API present and callable without crash")
|
||||
}
|
||||
|
||||
// TestCalendarPlugin_LoadAndRun runs the calendar plugin's Lua test suite
|
||||
// via the LuaVM to verify migration, CRUD, recurrence, and all other features.
|
||||
func TestCalendarPlugin_LoadAndRun(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Copy the calendar plugin from contrib/plugins/calendar to temp dir
|
||||
calendarSrc := filepath.Join("..", "..", "..", "contrib", "plugins", "calendar")
|
||||
pluginDir := filepath.Join(dir, "calendar")
|
||||
if err := copyDir(calendarSrc, pluginDir); err != nil {
|
||||
t.Fatalf("copy calendar plugin: %v", err)
|
||||
}
|
||||
|
||||
dataDir := filepath.Join(dir, "calendar_data")
|
||||
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p := &Plugin{
|
||||
Meta: Meta{
|
||||
Name: "calendar",
|
||||
Hooks: map[string]string{
|
||||
"on_init": "on_init",
|
||||
},
|
||||
},
|
||||
Dir: pluginDir,
|
||||
DataDir: dataDir,
|
||||
Active: true,
|
||||
}
|
||||
|
||||
vm, err := NewLuaVM(p)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLuaVM: %v", err)
|
||||
}
|
||||
defer vm.Close()
|
||||
|
||||
// Load main.lua
|
||||
if err := vm.LoadScript("main.lua"); err != nil {
|
||||
t.Fatalf("LoadScript(main.lua): %v", err)
|
||||
}
|
||||
|
||||
// Run on_init hook — this runs the migration + default categories
|
||||
if err := vm.CallHook("on_init"); err != nil {
|
||||
t.Fatalf("CallHook(on_init): %v", err)
|
||||
}
|
||||
|
||||
// Run the Lua test suite
|
||||
if err := vm.LoadScript("test_calendar.lua"); err != nil {
|
||||
t.Fatalf("LoadScript(test_calendar.lua): %v", err)
|
||||
}
|
||||
|
||||
t.Log("Calendar plugin: all Lua API tests passed")
|
||||
}
|
||||
|
||||
// copyDir recursively copies src to dst (skips hidden files).
|
||||
func copyDir(src, dst string) error {
|
||||
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(src, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := filepath.Join(dst, rel)
|
||||
|
||||
if info.IsDir() {
|
||||
return os.MkdirAll(target, info.Mode())
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(target, data, info.Mode())
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
package plugins
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Task represents a single background task instance.
|
||||
type Task struct {
|
||||
ID string
|
||||
Interval time.Duration
|
||||
Script string // relative path to .lua file (or "hook:name" for a Lua function)
|
||||
IsHook bool // if true, Script is a function name to call via CallHook
|
||||
stopCh chan struct{}
|
||||
stopped bool
|
||||
}
|
||||
|
||||
// Scheduler manages background tasks for a plugin.
|
||||
type Scheduler struct {
|
||||
plugin *Plugin
|
||||
vm *LuaVM
|
||||
tasks []*Task
|
||||
mu sync.Mutex
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// NewScheduler creates a scheduler for a plugin.
|
||||
func NewScheduler(p *Plugin, vm *LuaVM) *Scheduler {
|
||||
return &Scheduler{
|
||||
plugin: p,
|
||||
vm: vm,
|
||||
}
|
||||
}
|
||||
|
||||
// AddTask adds a task from a BackgroundTask definition.
|
||||
func (s *Scheduler) AddTask(bg BackgroundTask) error {
|
||||
dur, err := parseDuration(bg.Interval)
|
||||
if err != nil {
|
||||
return fmt.Errorf("task %s: %w", bg.ID, err)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
task := &Task{
|
||||
ID: bg.ID,
|
||||
Interval: dur,
|
||||
Script: bg.Script,
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
s.tasks = append(s.tasks, task)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start begins all registered tasks.
|
||||
func (s *Scheduler) Start() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for _, t := range s.tasks {
|
||||
if t.stopped {
|
||||
continue
|
||||
}
|
||||
s.wg.Add(1)
|
||||
go s.runTask(t)
|
||||
}
|
||||
}
|
||||
|
||||
// Stop cancels all running tasks and waits for them to finish.
|
||||
func (s *Scheduler) Stop() {
|
||||
s.mu.Lock()
|
||||
for _, t := range s.tasks {
|
||||
if !t.stopped {
|
||||
close(t.stopCh)
|
||||
t.stopped = true
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
s.wg.Wait()
|
||||
}
|
||||
|
||||
func (s *Scheduler) runTask(t *Task) {
|
||||
defer s.wg.Done()
|
||||
|
||||
ticker := time.NewTicker(t.Interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
s.executeTask(t)
|
||||
case <-t.stopCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) executeTask(t *Task) {
|
||||
if s.vm == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if t.IsHook {
|
||||
if err := s.vm.CallHook(t.Script); err != nil {
|
||||
log.Printf("[plugins] task %s/%s hook error: %v", s.plugin.Meta.Name, t.ID, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.vm.LoadScript(t.Script); err != nil {
|
||||
log.Printf("[plugins] task %s/%s script error: %v", s.plugin.Meta.Name, t.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// parseDuration parses a human-readable interval like "5m", "1h", "30s".
|
||||
func parseDuration(s string) (time.Duration, error) {
|
||||
d, err := time.ParseDuration(s)
|
||||
if err == nil {
|
||||
return d, nil
|
||||
}
|
||||
// Try cron-like or other formats later
|
||||
return 0, fmt.Errorf("invalid interval %q: use Go duration format (e.g. 5m, 1h, 30s)", s)
|
||||
}
|
||||
|
|
@ -356,5 +356,7 @@ func hashFileFast(absPath string) (string, int64) {
|
|||
func isUnderVault(absPath, vaultRoot string) bool {
|
||||
absPath, _ = filepath.Abs(absPath)
|
||||
vaultRoot, _ = filepath.Abs(vaultRoot)
|
||||
absPath = filepath.Clean(absPath)
|
||||
vaultRoot = filepath.Clean(vaultRoot)
|
||||
return strings.HasPrefix(absPath, vaultRoot+string(filepath.Separator)) || absPath == vaultRoot
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue