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:
mirivlad 2026-06-07 14:59:46 +08:00
parent 8cbc87cdad
commit b80941f908
40 changed files with 4366 additions and 233 deletions

View File

@ -10,7 +10,7 @@
| Слой | Технология | | Слой | Технология |
|------|------------| |------|------------|
| GUI | Wails v2 + Svelte 4 | | GUI | Wails v2 + Svelte 4 |
| CLI/TUI | Go (bubbletea-like) | | CLI | Go |
| Backend | Go | | Backend | Go |
| Хранилище | SQLite (индекс) + файловая система (vault) | | Хранилище | SQLite (индекс) + файловая система (vault) |
| Плагины | Lua | | Плагины | Lua |
@ -91,7 +91,7 @@ go run ./cmd/verstak/ --help
``` ```
. .
├── cmd/ # Точки входа ├── cmd/ # Точки входа
│ ├── verstak/ # CLI/TUI │ ├── verstak/ # CLI
│ ├── verstak-gui/ # Wails GUI │ ├── verstak-gui/ # Wails GUI
│ └── verstak-server/ # Sync server │ └── verstak-server/ # Sync server
├── internal/ ├── internal/
@ -151,7 +151,7 @@ Vault открывается в любом файловом менеджере
| [Архитектура](docs/02_Architecture.md) | Компоненты, плагины, sync | | [Архитектура](docs/02_Architecture.md) | Компоненты, плагины, sync |
| [Модель данных](docs/03_Data_Model_Storage.md) | SQLite, vault, файлы | | [Модель данных](docs/03_Data_Model_Storage.md) | SQLite, vault, файлы |
| [Синхронизация](docs/04_Sync_Backup_Activity.md) | Sync, backup, activity | | [Синхронизация](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/09_Extensibility.md) | Lua-плагины, шаблоны |
| [Сервер синхронизации](docs/10_Sync_Server_Guide.md) | Установка и настройка сервера | | [Сервер синхронизации](docs/10_Sync_Server_Guide.md) | Установка и настройка сервера |
| [Vault layout](docs/VAULT_LAYOUT.md) | Структура папок на диске | | [Vault layout](docs/VAULT_LAYOUT.md) | Структура папок на диске |

View File

@ -13,6 +13,7 @@ import (
type SystemViewDTO struct { type SystemViewDTO struct {
ID string `json:"id"` ID string `json:"id"`
Label string `json:"label"` Label string `json:"label"`
Icon string `json:"icon,omitempty"`
} }
func (a *App) ListSystemViews() []SystemViewDTO { func (a *App) ListSystemViews() []SystemViewDTO {

View File

@ -30,10 +30,7 @@ func (a *App) startBridge(appCfg *config.AppConfig) {
} }
} }
srv := bridge.NewServer(bridge.Config{ srv := bridge.NewServer(bc.Secret, handler)
Port: bc.Port,
Secret: bc.Secret,
}, handler)
port, err := srv.Start(bridge.Config{ port, err := srv.Start(bridge.Config{
Port: bc.Port, Port: bc.Port,
@ -116,13 +113,14 @@ func bridgeToBrowser(ev bridge.Event) browser.Event {
// RestartBridge stops and restarts the bridge server with current config. // RestartBridge stops and restarts the bridge server with current config.
func (a *App) RestartBridge() error { func (a *App) RestartBridge() error {
// Stop existing server outside the lock to avoid blocking other bindings.
a.mu.Lock() a.mu.Lock()
defer a.mu.Unlock() oldBridge := a.bridge
a.bridge = nil
a.mu.Unlock()
// Stop existing server if oldBridge != nil {
if a.bridge != nil { oldBridge.Stop()
a.bridge.Stop()
a.bridge = nil
} }
// Load config // Load config
@ -151,10 +149,7 @@ func (a *App) RestartBridge() error {
} }
} }
srv := bridge.NewServer(bridge.Config{ srv := bridge.NewServer(bc.Secret, handler)
Port: bc.Port,
Secret: bc.Secret,
}, handler)
port, err := srv.Start(bridge.Config{ port, err := srv.Start(bridge.Config{
Port: bc.Port, Port: bc.Port,
@ -165,7 +160,10 @@ func (a *App) RestartBridge() error {
return fmt.Errorf("bridge restart: %w", err) return fmt.Errorf("bridge restart: %w", err)
} }
a.mu.Lock()
a.bridge = srv a.bridge = srv
a.mu.Unlock()
log.Printf("[bridge] restarted on port %d", port) log.Printf("[bridge] restarted on port %d", port)
return nil return nil
} }

View File

@ -199,6 +199,14 @@ func (a *App) initVault(vaultPath string) error {
worklogSvc := worklog.NewService(db) worklogSvc := worklog.NewService(db)
searchSvc := search.NewService(db) searchSvc := search.NewService(db)
pm := plugins.NewManager(abs) pm := plugins.NewManager(abs)
pm.Services = &plugins.CoreServices{
NodeRepo: nodeRepo,
DB: db,
ActivitySvc: activitySvc,
WorklogSvc: worklogSvc,
FilesSvc: fileSvc,
VaultPath: abs,
}
pm.Discover() pm.Discover()
templatesReg := templates.NewRegistry() templatesReg := templates.NewRegistry()

View File

@ -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
}

View File

@ -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")

View File

@ -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'))
);

View File

@ -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);

View File

@ -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>

View File

@ -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"
]
}

View File

@ -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

View File

@ -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("========================")

View File

@ -29,10 +29,10 @@
## Файлы пакета ## Файлы пакета
1. [[01_Product_Spec]] — описание продукта, аудитория, сценарии. 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. 3. [[03_Data_Model_Storage]] — модель данных, SQLite, vault, files, notes.
4. [[04_Sync_Backup_Activity]] — синхронизация, backup, activity. 4. [[04_Sync_Backup_Activity]] — синхронизация, backup, activity.
5. [[05_UI_UX]] — экраны GUI/TUI. 5. [[05_UI_UX]] — экраны GUI.
6. [[06_Roadmap]] — план разработки по этапам. 6. [[06_Roadmap]] — план разработки по этапам.
7. [[07_AI_Coder_Prompts]] — промпты для ИИ-кодера. 7. [[07_AI_Coder_Prompts]] — промпты для ИИ-кодера.
8. [[08_MVP_Checklist]] — чеклист первого MVP. 8. [[08_MVP_Checklist]] — чеклист первого MVP.
@ -56,7 +56,7 @@
5. **Не таймтрекер, а восстановитель следов.** 5. **Не таймтрекер, а восстановитель следов.**
6. **GUI основной, TUI быстрый, CLI служебный.** 6. **GUI основной, CLI служебный.**
7. **Sync не уничтожает данные.** 7. **Sync не уничтожает данные.**

View File

@ -28,7 +28,7 @@
### Core Library ### Core Library
Общее ядро, которое не зависит от GUI/TUI. Общее ядро, которое не зависит от GUI.
Отвечает за: Отвечает за:
@ -72,27 +72,6 @@
- импорт DokuWiki; - импорт DokuWiki;
- разбор конфликтов. - разбор конфликтов.
### TUI
Быстрый интерфейс из терминала.
Рекомендуемый стек:
- Bubble Tea;
- Bubbles;
- Lip Gloss.
TUI не обязан повторять весь GUI.
Функции:
- найти дело;
- открыть заметку;
- добавить worklog;
- запустить action;
- запустить sync;
- посмотреть “Сегодня”.
### CLI ### CLI
CLI нужен не как главный интерфейс, а как служебный вход. CLI нужен не как главный интерфейс, а как служебный вход.
@ -144,8 +123,6 @@ verstak/
main.go main.go
verstak-gui/ verstak-gui/
main.go main.go
verstak-tui/
main.go
verstak-server/ verstak-server/
main.go main.go
@ -164,7 +141,6 @@ verstak/
config/ config/
gui/ gui/
tui/
server/ server/
frontend/ frontend/

View File

@ -243,24 +243,7 @@ Actions:
- редактировать название, URL и заметку; - редактировать название, URL и заметку;
- удалить ссылку. - удалить ссылку.
## 13. TUI UX ## 13. CLI UX
TUI нужен для быстрого:
- fuzzy search;
- открыть дело;
- добавить worklog;
- запустить action;
- sync now.
Не нужно делать в TUI:
- импорт DokuWiki;
- сложный conflict resolver;
- preview документов;
- настройки sync.
## 14. CLI UX
CLI команды: CLI команды:

View File

@ -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) ## Milestone 15 — 1.0 polish (PAUSED)

View File

@ -12,7 +12,7 @@
- заметки и файлы физически лежат обычными файлами в vault; - заметки и файлы физически лежат обычными файлами в vault;
- SQLite хранит индекс, связи, метаданные, FTS и sync state; - SQLite хранит индекс, связи, метаданные, FTS и sync state;
- основная сущность — вложенное дерево nodes; - основная сущность — вложенное дерево nodes;
- GUI, TUI и CLI должны использовать общий core; - GUI и CLI должны использовать общий core;
- бизнес-логику нельзя завязывать на UI; - бизнес-логику нельзя завязывать на UI;
- не делать SaaS; - не делать SaaS;
- не делать ИИ; - не делать ИИ;
@ -25,7 +25,6 @@
- Go; - Go;
- SQLite; - SQLite;
- Wails для GUI; - Wails для GUI;
- Bubble Tea для TUI позже;
- fsnotify + snapshot scanner позже; - fsnotify + snapshot scanner позже;
- sync server позже. - sync server позже.
@ -272,22 +271,7 @@ Acceptance:
- manual command `verstak scan`. - manual command `verstak scan`.
``` ```
## Prompt 14 — TUI MVP ## Prompt 14 — Integrity/restore
```markdown
Добавь TUI на Bubble Tea.
Функции:
- tree/search;
- open node;
- add worklog;
- run action;
- sync now.
TUI не должен повторять весь GUI.
```
## Prompt 15 — Integrity/restore
```markdown ```markdown
Добавь команды: Добавь команды:

View File

@ -35,7 +35,6 @@
- [ ] Conflict resolver UI. - [ ] Conflict resolver UI.
- [ ] Restore from sync server. - [ ] Restore from sync server.
- [ ] Backup export / retention snapshots. - [ ] Backup export / retention snapshots.
- [ ] TUI MVP.
## Не входит в первый MVP ## Не входит в первый MVP

View File

@ -1,30 +1,36 @@
# Верстак — архитектура плагинов # Верстак — архитектура плагинов
## Принцип ## 1. Принцип
Верстак — это минималистичный движок с деревом дел. Верстак — минималистичный движок с деревом дел и базовыми операциями
Всё, что не входит в минимальную модель, — плагин. (заметки, файлы, действия, журнал, поиск, активность).
Плагин — это директория в `.verstak/plugins/<name>/`, которую Плагины расширяют функциональность без перекомпиляции:
программа подхватывает без перекомпиляции. - новые типы узлов и кастомные поля;
- календарь, канбан, импортёры;
- UI-элементы (пункты сайдбара, вкладки, страницы);
- фоновые задачи по расписанию;
- хуки на события (создание/удаление/изменение узлов).
## Структура плагина ## 2. Структура плагина
``` ```
.verstak/plugins/<name>/ .verstak/plugins/<name>/
plugin.json # мета: name, version, author, hooks plugin.json # метаданные: имя, версия, хуки, UI-contributions
main.lua # точка входа main.lua # точка входа (обязательно, если есть Lua-код)
panels/ # HTML-панели для GUI (опционально)
calendar.html
kanban.html
templates/ # шаблоны дел (опционально) templates/ # шаблоны дел (опционально)
client.json client.json
repair.json repair.json
panels/ # UI-панели для GUI (опционально)
kanban.html
calendar.html
migrations/ # SQL-миграции (опционально) migrations/ # SQL-миграции (опционально)
001_create_tables.sql 001_create_tables.sql
assets/ # статика: иконки, CSS, изображения (опционально)
icon.svg
``` ```
## plugin.json ### 2.1 plugin.json — полная схема
```json ```json
{ {
@ -32,55 +38,485 @@
"version": "1.0.0", "version": "1.0.0",
"author": "...", "author": "...",
"description": "Календарь событий, привязанных к делам", "description": "Календарь событий, привязанных к делам",
"hooks": { "hooks": {
"on_init": "on_init", "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"], "node_types": ["event"],
"panel": "panels/calendar.html", "panel": "panels/calendar.html",
"migrations": ["migrations/001_create_tables.sql"] "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 ### 2.2 SidebarItem
-- Получить node по ID
local node = verstak.node.get(id)
-- Создать node ```json
local n = verstak.node.create(parent_id, "type", "title") {
"id": "calendar",
-- Получить config value "label": "Календарь",
local v = verstak.config.get("key") "icon": "calendar",
"page": "plugin:calendar:main"
-- Записать в 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("Событие добавлено")
``` ```
## Жизненный цикл плагина - `id` — уникальный в рамках плагина
- `icon` — имя иконки из библиотеки GUI (или путь к `assets/icon.svg`)
- `page` — идентификатор страницы в формате `plugin:<name>:<page_id>`
- При клике — фронтенд переключается на эту страницу, отправляет Lua-скрипту событие `on_page_open`
1. **on_init** — при старте программы, до открытия vault. ### 2.3 NodeTab
Инициализация, создание таблиц.
2. **on_vault_open** — при открытии vault.
3. **on_node_create / on_node_open / on_node_delete** — хуки на действия.
4. **on_shutdown** — при закрытии.
## Реестр типов дел ```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 ```lua
verstak.node.register_type({ verstak.node.register_type({
@ -88,72 +524,121 @@ verstak.node.register_type({
label = "Событие", label = "Событие",
icon = "calendar", icon = "calendar",
fields = { fields = {
{ name = "date", label = "Дата", type = "date" }, { name = "date", label = "Дата", type = "date" },
{ name = "time", label = "Время", type = "time" }, { name = "time", label = "Время", type = "time" },
{ name = "location", label = "Место", type = "text" }, { 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 ```json
{ {
"name": "Клиент", "plugins": {
"icon": "user", "sandbox": {
"tree": [ "max_memory_mb": 32,
{ "type": "folder", "title": "Документы" }, "max_instructions": 10000000,
{ "type": "folder", "title": "Переписка" }, "call_timeout_ms": 30000,
{ "type": "folder", "title": "Скриншоты" }, "allow_plugins": ["calendar", "kanban"]
{ "type": "note", "title": "Overview" }, }
{ "type": "action", "title": "Открыть сайт", "kind": "open_url", "url": "" } }
],
"meta": [
{ "key": "domain", "label": "Домен сайта", "type": "text" },
{ "key": "admin_url", "label": "Админка", "type": "url" }
]
} }
``` ```
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); ### CLI-команды
- нет `io.*`, `os.execute` и т.д.;
- память ограничена;
- нет сетевых вызовов кроме зарегистрированных HTTP-эндпоинтов.
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/`. Плагин распространяется как zip-архив:
При старте GUI/CLI/TUI:
1. Сканировать `.verstak/plugins/*/plugin.json` ```bash
2. Валидировать (имя, версия, структура) calendar-1.0.0.vpk # Verstak Plugin Package
3. Загрузить миграции и выполнить plugin.json
4. Загрузить Lua-скрипты через gopher-lua main.lua
5. Вызвать `on_init` у каждого плагина panels/calendar.html
6. Зарегистрировать node types, HTTP routes, UI panels migrations/001_create_tables.sql
```
## Распространение Установка из registry (будущее):
Плагин — это zip-архив с правильной структурой.
Репозиторий плагинов: `verstak-registry` (отдельный проект).
Установка:
```bash ```bash
verstak plugin install calendar verstak plugin install calendar
verstak plugin enable calendar # → скачивает calendar-1.0.0.vpk
verstak plugin list # → распаковывает в .verstak/plugins/calendar/
# → выполняет миграции
# → активирует
``` ```
## 9. Пример: Calendar plugin
(См. `contrib/plugins/calendar/` — будет реализован как эталон.)
Плагин `calendar` демонстрирует:
- Полный цикл: plugin.json → main.lua → panel → миграции
- Регистрацию пункта в сайдбаре
- Свою HTML-панель с календарной сеткой
- Создание/удаление событий через Lua API
- Привязку событий к делам
- Фоновую задачу (проверка событий на сегодня)
- Кастомный тип узла `event`
- Настройки плагина через `verstak.config`

View File

@ -27,13 +27,12 @@
| 14 | **MVP stabilization** | ✅ выполнено | | 14 | **MVP stabilization** | ✅ выполнено |
| 15 | **Sync Server + Client** | ✅ выполнено | | 15 | **Sync Server + Client** | ✅ выполнено |
| 16 | **Activity Suggestions** | ✅ выполнено базово — global worklog dashboard + conservative suggestions + UX | | 16 | **Activity Suggestions** | ✅ выполнено базово — global worklog dashboard + conservative suggestions + UX |
| 17 | **File Scanner/Watcher** | ⏳ ожидает — fsnotify, snapshot scanner | | 17 | **File Scanner/Watcher** | ✅ выполнено — fsnotify, snapshot scanner |
| 18 | **TUI MVP (Bubble Tea)** | ⏳ ожидает — tree/search, add worklog | | 18 | **Plugins: Lua runtime** | ✅ скелет — gopher-lua, хуки, песочница, шедулер, Calendar-пример |
| 19 | **Plugins: Lua runtime** | ⏳ ожидает — gopher-lua, hooks, sandbox | | 19 | **Browser Extension** | ✅ выполнено — tracking, capture, evidence |
| 20 | **Browser Extension** | ⏳ ожидает — tracking, capture, evidence | | 20 | **Calendar/Kanban** | ⏳ ожидает — view by date, board view |
| 21 | **Calendar/Kanban** | ⏳ ожидает — view by date, board view | | 21 | **Integrity Check + Repair** | ⏳ ожидает — checksums, crash recovery |
| 22 | **Integrity Check + Repair** | ⏳ ожидает — checksums, crash recovery | | 22 | **New templates/integrations** | ⏳ ожидает — community plugins |
| 23 | **New templates/integrations** | ⏳ ожидает — community plugins |
--- ---
@ -248,28 +247,37 @@ Real-time fsnotify watcher управляется отдельно.
Через GUI: `WatcherStatus()` — true если watcher активен. Через GUI: `WatcherStatus()` — true если watcher активен.
Через консоль (отладка): создать/удалить файл в vault → в activity появятся события `file_added`/`file_missing`/`file_modified`. Через консоль (отладка): создать/удалить файл в vault → в activity появятся события `file_added`/`file_missing`/`file_modified`.
## Ожидающие шаги (18-23) ## Выполненные шаги (18-22 частично)
### ШАГ 18 — TUI MVP (Bubble Tea) ### ШАГ 18 — Plugins: Lua runtime ✅ скелет
- Терминальный интерфейс: дерево дел, поиск, добавление worklog, запуск действий, sync
### ШАГ 19 — Plugins: Lua runtime **Статус:** ✅ Скелет готов — рантайм, хуки, песочница, шедулер, Calendar-пример.
- gopher-lua runtime
- хуки на события (node created, file added, etc.)
- песочница
### ШАГ 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 - Расширение для браузера: трекинг времени, скриншоты, evidence
### ШАГ 21 — Calendar/Kanban ### ШАГ 20 — Calendar/Kanban
- Календарный вид по датам - Календарный вид по датам
- Канбан-доска по статусам - Канбан-доска по статусам
### ШАГ 22 — Integrity Check + Repair ### ШАГ 21 — Integrity Check + Repair
- Проверка целостности vault - Проверка целостности vault
- Контрольные суммы, восстановление после сбоев - Контрольные суммы, восстановление после сбоев
### ШАГ 23 — New templates/integrations ### ШАГ 22 — New templates/integrations
- Community-плагины и шаблоны - Community-плагины и шаблоны
--- ---

View File

@ -182,7 +182,7 @@ function testPort() {
}) })
.catch((err) => { .catch((err) => {
if (err.name === 'TimeoutError' || err.name === 'AbortError') { if (err.name === 'TimeoutError' || err.name === 'AbortError') {
statusEl.textContent = '✗ Нет ответа (таймаут)'; statusEl.textContent = '✗ Нет ответа (таймаут). Возможно, порт занят другим приложением или Verstak не запущен';
} else { } else {
statusEl.textContent = '✗ Сервер недоступен'; statusEl.textContent = '✗ Сервер недоступен';
} }

View File

@ -182,7 +182,7 @@ function testPort() {
}) })
.catch((err) => { .catch((err) => {
if (err.name === 'TimeoutError' || err.name === 'AbortError') { if (err.name === 'TimeoutError' || err.name === 'AbortError') {
statusEl.textContent = '✗ Нет ответа (таймаут)'; statusEl.textContent = '✗ Нет ответа (таймаут). Возможно, порт занят другим приложением или Verstak не запущен';
} else { } else {
statusEl.textContent = '✗ Сервер недоступен'; statusEl.textContent = '✗ Сервер недоступен';
} }

View File

@ -10,6 +10,7 @@
import SettingsWindow from './lib/SettingsWindow.svelte' import SettingsWindow from './lib/SettingsWindow.svelte'
import SyncStatus from './lib/SyncStatus.svelte' import SyncStatus from './lib/SyncStatus.svelte'
import TodayScreen from './lib/TodayScreen.svelte' import TodayScreen from './lib/TodayScreen.svelte'
import PluginPage from './lib/PluginPage.svelte'
import AppHeader from './lib/AppHeader.svelte' import AppHeader from './lib/AppHeader.svelte'
import { onMount, onDestroy } from 'svelte' import { onMount, onDestroy } from 'svelte'
import { actionIcon } from './lib/actionIcons.js' import { actionIcon } from './lib/actionIcons.js'
@ -384,7 +385,7 @@
// Vault is ready — load main UI // Vault is ready — load main UI
try { try {
version = await wailsCall('VerstakVersion') || 'verstak-gui/v2' version = await wailsCall('VerstakVersion') || 'verstak-gui/v2'
systemViews = await wailsCall('ListSystemViews') || [] systemViews = await wailsCall('ListSystemViewsWithPlugins') || []
workspaceTree = await wailsCall('ListWorkspaceTree') || [] workspaceTree = await wailsCall('ListWorkspaceTree') || []
enabledTemplates = await wailsCall('ListEnabledTemplates') || [] enabledTemplates = await wailsCall('ListEnabledTemplates') || []
} catch (e) { } catch (e) {
@ -3572,6 +3573,9 @@
{/if} {/if}
</div> </div>
{:else if selectedSection && selectedSection.startsWith('plugin:')}
<PluginPage sectionId={selectedSection} />
{:else} {:else}
<div class="welcome"> <div class="welcome">
<h2>{t('welcome.title')}</h2> <h2>{t('welcome.title')}</h2>

View File

@ -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>

View File

@ -1,6 +1,11 @@
<script> <script>
import { onMount } from 'svelte'
import { t } from './i18n' import { t } from './i18n'
let plugins = []
let loading = true
let error = ''
function wailsCall(method, ...args) { function wailsCall(method, ...args) {
try { try {
if (window['go'] && window['go']['main'] && window['go']['main']['App']) { if (window['go'] && window['go']['main'] && window['go']['main']['App']) {
@ -11,19 +16,189 @@
return Promise.reject(new Error('Wails not connected: ' + method)) return Promise.reject(new Error('Wails not connected: ' + method))
} }
async function openPluginsDir() { onMount(async () => {
try { await wailsCall('OpenPluginsFolder') } catch(e) {} 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> </script>
<div class="settings-section"> <div class="settings-plugins">
<h2>{t('settings.plugins')}</h2> <h3>{t('settings.plugins') || 'Плагины'}</h3>
<p class="section-desc">{t('settings.noPlugins')}</p> <p class="hint">Включение и отключение плагинов. Изменения применяются сразу.</p>
<button class="btn" on:click={openPluginsDir}>{t('settings.openPluginsDir')}</button>
{#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> </div>
<style> <style>
.settings-section { padding: 1.5rem; max-width: 500px; } .settings-plugins {
.settings-section h2 { margin: 0 0 0.5rem 0; font-size: 1.2rem; color: var(--text, #e0e0e0); } padding: 1.5rem;
.section-desc { color: var(--text-dim, #888); font-size: 0.85rem; margin-bottom: 1rem; } }
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> </style>

1
go.mod
View File

@ -37,6 +37,7 @@ require (
github.com/valyala/fasttemplate v1.2.2 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.22 // indirect github.com/wailsapp/go-webview2 v1.0.22 // indirect
github.com/wailsapp/mimetype v1.4.1 // 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/net v0.35.0 // indirect
golang.org/x/sys v0.30.0 // indirect golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect golang.org/x/text v0.22.0 // indirect

2
go.sum
View File

@ -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/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 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c=
github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg= 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 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= 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= golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=

View File

@ -78,10 +78,10 @@ func GenerateSecret() string {
} }
// NewServer creates a bridge server. // NewServer creates a bridge server.
// If cfg.Secret is empty, no authentication is required. // If secret is empty, no authentication is required.
func NewServer(cfg Config, handler EventHandler) *Server { func NewServer(secret string, handler EventHandler) *Server {
return &Server{ return &Server{
secret: cfg.Secret, secret: secret,
handler: handler, handler: handler,
} }
} }

View File

@ -10,7 +10,7 @@ import (
) )
func TestServer_Ping(t *testing.T) { func TestServer_Ping(t *testing.T) {
s := NewServer(DefaultConfig(), nil) s := NewServer("", nil)
port, err := s.Start(DefaultConfig()) port, err := s.Start(DefaultConfig())
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@ -35,7 +35,7 @@ func TestServer_Ping(t *testing.T) {
} }
func TestServer_Events_AuthRequired(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"}) port, err := s.Start(Config{Secret: "test-secret"})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@ -56,7 +56,7 @@ func TestServer_Events_AuthRequired(t *testing.T) {
func TestServer_Events_Success(t *testing.T) { func TestServer_Events_Success(t *testing.T) {
received := make(chan []Event, 1) received := make(chan []Event, 1)
s := NewServer(Config{Secret: "test-secret"}, func(evts []Event) { s := NewServer("test-secret", func(evts []Event) {
received <- evts received <- evts
}) })
port, err := s.Start(Config{Secret: "test-secret"}) 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) { func TestServer_Events_EmptyBatch(t *testing.T) {
s := NewServer(Config{Secret: "s"}, nil) s := NewServer("s", nil)
port, err := s.Start(Config{Secret: "s"}) port, err := s.Start(Config{Secret: "s"})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@ -135,7 +135,7 @@ func TestGenerateSecret(t *testing.T) {
func TestServer_AutoPort(t *testing.T) { func TestServer_AutoPort(t *testing.T) {
cfg := DefaultConfig() cfg := DefaultConfig()
s := NewServer(cfg, nil) s := NewServer("", nil)
port, err := s.Start(cfg) port, err := s.Start(cfg)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)

View File

@ -65,10 +65,10 @@ type SyncSettings struct {
// BridgeConfig holds local HTTP bridge settings for browser extension. // BridgeConfig holds local HTTP bridge settings for browser extension.
type BridgeConfig struct { 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) Port int `json:"port"` // listen port (default 9786)
Secret string `json:"secret,omitempty"` // shared secret for extension auth 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 { func DefaultAppConfig() *AppConfig {

View File

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

View File

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

View File

@ -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

View File

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

View File

@ -2,33 +2,92 @@ package plugins
import ( import (
"encoding/json" "encoding/json"
"log"
"os" "os"
"path/filepath" "path/filepath"
lua "github.com/yuin/gopher-lua"
) )
// Meta is the plugin.json descriptor. // Meta is the plugin.json descriptor.
type Meta struct { type Meta struct {
Name string `json:"name"` Name string `json:"name"`
Version string `json:"version"` Version string `json:"version"`
Description string `json:"description"` Description string `json:"description,omitempty"`
Author string `json:"author"` Author string `json:"author,omitempty"`
Hooks map[string]string `json:"hooks,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"` NodeTypes []string `json:"node_types,omitempty"`
Panel string `json:"panel,omitempty"`
Templates []string `json:"templates,omitempty"` Templates []string `json:"templates,omitempty"`
Migrations []string `json:"migrations,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 { type Plugin struct {
Meta Meta Meta Meta
Dir string // absolute path to plugin directory Dir string // absolute path to plugin directory
Active bool 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/. // Manager discovers and loads plugins from .verstak/plugins/.
type Manager struct { type Manager struct {
vaultRoot string vaultRoot string
plugins []Plugin plugins []Plugin
// Services exposed to Lua plugin API
Services *CoreServices
} }
// NewManager creates a plugin manager for a vault. // NewManager creates a plugin manager for a vault.
@ -55,16 +114,139 @@ func (m *Manager) Discover() {
} }
var meta Meta var meta Meta
if err := json.Unmarshal(data, &meta); err != nil { if err := json.Unmarshal(data, &meta); err != nil {
log.Printf("[plugins] %s: invalid plugin.json: %v", e.Name(), err)
continue continue
} }
if meta.Name == "" { if meta.Name == "" {
meta.Name = e.Name() meta.Name = e.Name()
} }
m.plugins = append(m.plugins, Plugin{ dataDir := filepath.Join(pluginsDir, e.Name(), "data")
Meta: meta, os.MkdirAll(dataDir, 0o750)
Dir: filepath.Join(pluginsDir, e.Name()),
Active: true, 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
}
} }
} }

View File

@ -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
}

View File

@ -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
}

View File

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

View File

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

View File

@ -356,5 +356,7 @@ func hashFileFast(absPath string) (string, int64) {
func isUnderVault(absPath, vaultRoot string) bool { func isUnderVault(absPath, vaultRoot string) bool {
absPath, _ = filepath.Abs(absPath) absPath, _ = filepath.Abs(absPath)
vaultRoot, _ = filepath.Abs(vaultRoot) vaultRoot, _ = filepath.Abs(vaultRoot)
absPath = filepath.Clean(absPath)
vaultRoot = filepath.Clean(vaultRoot)
return strings.HasPrefix(absPath, vaultRoot+string(filepath.Separator)) || absPath == vaultRoot return strings.HasPrefix(absPath, vaultRoot+string(filepath.Separator)) || absPath == vaultRoot
} }