diff --git a/README.md b/README.md
index 7473ce0..15abe94 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@
| Слой | Технология |
|------|------------|
| GUI | Wails v2 + Svelte 4 |
-| CLI/TUI | Go (bubbletea-like) |
+| CLI | Go |
| Backend | Go |
| Хранилище | SQLite (индекс) + файловая система (vault) |
| Плагины | Lua |
@@ -91,7 +91,7 @@ go run ./cmd/verstak/ --help
```
.
├── cmd/ # Точки входа
-│ ├── verstak/ # CLI/TUI
+│ ├── verstak/ # CLI
│ ├── verstak-gui/ # Wails GUI
│ └── verstak-server/ # Sync server
├── internal/
@@ -151,7 +151,7 @@ Vault открывается в любом файловом менеджере
| [Архитектура](docs/02_Architecture.md) | Компоненты, плагины, sync |
| [Модель данных](docs/03_Data_Model_Storage.md) | SQLite, vault, файлы |
| [Синхронизация](docs/04_Sync_Backup_Activity.md) | Sync, backup, activity |
-| [UI/UX](docs/05_UI_UX.md) | Экраны GUI/TUI |
+| [UI/UX](docs/05_UI_UX.md) | Экраны GUI |
| [Плагины](docs/09_Extensibility.md) | Lua-плагины, шаблоны |
| [Сервер синхронизации](docs/10_Sync_Server_Guide.md) | Установка и настройка сервера |
| [Vault layout](docs/VAULT_LAYOUT.md) | Структура папок на диске |
diff --git a/cmd/verstak-gui/bindings_activity.go b/cmd/verstak-gui/bindings_activity.go
index 4518a28..c79b30f 100644
--- a/cmd/verstak-gui/bindings_activity.go
+++ b/cmd/verstak-gui/bindings_activity.go
@@ -13,6 +13,7 @@ import (
type SystemViewDTO struct {
ID string `json:"id"`
Label string `json:"label"`
+ Icon string `json:"icon,omitempty"`
}
func (a *App) ListSystemViews() []SystemViewDTO {
diff --git a/cmd/verstak-gui/bindings_bridge.go b/cmd/verstak-gui/bindings_bridge.go
index fc945ce..614d1f1 100644
--- a/cmd/verstak-gui/bindings_bridge.go
+++ b/cmd/verstak-gui/bindings_bridge.go
@@ -30,10 +30,7 @@ func (a *App) startBridge(appCfg *config.AppConfig) {
}
}
- srv := bridge.NewServer(bridge.Config{
- Port: bc.Port,
- Secret: bc.Secret,
- }, handler)
+ srv := bridge.NewServer(bc.Secret, handler)
port, err := srv.Start(bridge.Config{
Port: bc.Port,
@@ -116,13 +113,14 @@ func bridgeToBrowser(ev bridge.Event) browser.Event {
// RestartBridge stops and restarts the bridge server with current config.
func (a *App) RestartBridge() error {
+ // Stop existing server outside the lock to avoid blocking other bindings.
a.mu.Lock()
- defer a.mu.Unlock()
+ oldBridge := a.bridge
+ a.bridge = nil
+ a.mu.Unlock()
- // Stop existing server
- if a.bridge != nil {
- a.bridge.Stop()
- a.bridge = nil
+ if oldBridge != nil {
+ oldBridge.Stop()
}
// Load config
@@ -151,10 +149,7 @@ func (a *App) RestartBridge() error {
}
}
- srv := bridge.NewServer(bridge.Config{
- Port: bc.Port,
- Secret: bc.Secret,
- }, handler)
+ srv := bridge.NewServer(bc.Secret, handler)
port, err := srv.Start(bridge.Config{
Port: bc.Port,
@@ -165,7 +160,10 @@ func (a *App) RestartBridge() error {
return fmt.Errorf("bridge restart: %w", err)
}
+ a.mu.Lock()
a.bridge = srv
+ a.mu.Unlock()
+
log.Printf("[bridge] restarted on port %d", port)
return nil
}
diff --git a/cmd/verstak-gui/bindings_config.go b/cmd/verstak-gui/bindings_config.go
index 48ed148..0668373 100644
--- a/cmd/verstak-gui/bindings_config.go
+++ b/cmd/verstak-gui/bindings_config.go
@@ -199,6 +199,14 @@ func (a *App) initVault(vaultPath string) error {
worklogSvc := worklog.NewService(db)
searchSvc := search.NewService(db)
pm := plugins.NewManager(abs)
+ pm.Services = &plugins.CoreServices{
+ NodeRepo: nodeRepo,
+ DB: db,
+ ActivitySvc: activitySvc,
+ WorklogSvc: worklogSvc,
+ FilesSvc: fileSvc,
+ VaultPath: abs,
+ }
pm.Discover()
templatesReg := templates.NewRegistry()
diff --git a/cmd/verstak-gui/bindings_plugins.go b/cmd/verstak-gui/bindings_plugins.go
new file mode 100644
index 0000000..5ed371c
--- /dev/null
+++ b/cmd/verstak-gui/bindings_plugins.go
@@ -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
+}
diff --git a/contrib/plugins/calendar/main.lua b/contrib/plugins/calendar/main.lua
new file mode 100644
index 0000000..98777f7
--- /dev/null
+++ b/contrib/plugins/calendar/main.lua
@@ -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")
diff --git a/contrib/plugins/calendar/migrations/001_create_events.sql b/contrib/plugins/calendar/migrations/001_create_events.sql
new file mode 100644
index 0000000..a494988
--- /dev/null
+++ b/contrib/plugins/calendar/migrations/001_create_events.sql
@@ -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'))
+);
diff --git a/contrib/plugins/calendar/migrations/001_create_tables.sql b/contrib/plugins/calendar/migrations/001_create_tables.sql
new file mode 100644
index 0000000..773590e
--- /dev/null
+++ b/contrib/plugins/calendar/migrations/001_create_tables.sql
@@ -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);
diff --git a/contrib/plugins/calendar/panels/calendar.html b/contrib/plugins/calendar/panels/calendar.html
new file mode 100644
index 0000000..b08cdd2
--- /dev/null
+++ b/contrib/plugins/calendar/panels/calendar.html
@@ -0,0 +1,145 @@
+
+
+
+
+
+Календарь
+
+
+
+
+
+
Июнь 2025
+
+
+
+
+
+
+
+
diff --git a/contrib/plugins/calendar/plugin.json b/contrib/plugins/calendar/plugin.json
new file mode 100644
index 0000000..0eac582
--- /dev/null
+++ b/contrib/plugins/calendar/plugin.json
@@ -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"
+ ]
+}
diff --git a/contrib/plugins/calendar/scripts/check_reminders.lua b/contrib/plugins/calendar/scripts/check_reminders.lua
new file mode 100644
index 0000000..62bf3ed
--- /dev/null
+++ b/contrib/plugins/calendar/scripts/check_reminders.lua
@@ -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
diff --git a/contrib/plugins/calendar/test_calendar.lua b/contrib/plugins/calendar/test_calendar.lua
new file mode 100644
index 0000000..9dfec71
--- /dev/null
+++ b/contrib/plugins/calendar/test_calendar.lua
@@ -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("========================")
diff --git a/docs/00_README.md b/docs/00_README.md
index de07656..fa6e2b9 100644
--- a/docs/00_README.md
+++ b/docs/00_README.md
@@ -29,10 +29,10 @@
## Файлы пакета
1. [[01_Product_Spec]] — описание продукта, аудитория, сценарии.
-2. [[02_Architecture]] — архитектура core/GUI/TUI/CLI/server, плагины.
+2. [[02_Architecture]] — архитектура core/GUI/CLI/server, плагины.
3. [[03_Data_Model_Storage]] — модель данных, SQLite, vault, files, notes.
4. [[04_Sync_Backup_Activity]] — синхронизация, backup, activity.
-5. [[05_UI_UX]] — экраны GUI/TUI.
+5. [[05_UI_UX]] — экраны GUI.
6. [[06_Roadmap]] — план разработки по этапам.
7. [[07_AI_Coder_Prompts]] — промпты для ИИ-кодера.
8. [[08_MVP_Checklist]] — чеклист первого MVP.
@@ -56,7 +56,7 @@
5. **Не таймтрекер, а восстановитель следов.**
-6. **GUI основной, TUI быстрый, CLI служебный.**
+6. **GUI основной, CLI служебный.**
7. **Sync не уничтожает данные.**
diff --git a/docs/02_Architecture.md b/docs/02_Architecture.md
index b4fb49c..7ad6d8a 100644
--- a/docs/02_Architecture.md
+++ b/docs/02_Architecture.md
@@ -28,7 +28,7 @@
### Core Library
-Общее ядро, которое не зависит от GUI/TUI.
+Общее ядро, которое не зависит от GUI.
Отвечает за:
@@ -72,27 +72,6 @@
- импорт DokuWiki;
- разбор конфликтов.
-### TUI
-
-Быстрый интерфейс из терминала.
-
-Рекомендуемый стек:
-
-- Bubble Tea;
-- Bubbles;
-- Lip Gloss.
-
-TUI не обязан повторять весь GUI.
-
-Функции:
-
-- найти дело;
-- открыть заметку;
-- добавить worklog;
-- запустить action;
-- запустить sync;
-- посмотреть “Сегодня”.
-
### CLI
CLI нужен не как главный интерфейс, а как служебный вход.
@@ -144,8 +123,6 @@ verstak/
main.go
verstak-gui/
main.go
- verstak-tui/
- main.go
verstak-server/
main.go
@@ -164,7 +141,6 @@ verstak/
config/
gui/
- tui/
server/
frontend/
diff --git a/docs/05_UI_UX.md b/docs/05_UI_UX.md
index 5b5b2e5..ff47682 100644
--- a/docs/05_UI_UX.md
+++ b/docs/05_UI_UX.md
@@ -243,24 +243,7 @@ Actions:
- редактировать название, URL и заметку;
- удалить ссылку.
-## 13. TUI UX
-
-TUI нужен для быстрого:
-
-- fuzzy search;
-- открыть дело;
-- добавить worklog;
-- запустить action;
-- sync now.
-
-Не нужно делать в TUI:
-
-- импорт DokuWiki;
-- сложный conflict resolver;
-- preview документов;
-- настройки sync.
-
-## 14. CLI UX
+## 13. CLI UX
CLI команды:
diff --git a/docs/06_Roadmap.md b/docs/06_Roadmap.md
index 7bd43f2..571b844 100644
--- a/docs/06_Roadmap.md
+++ b/docs/06_Roadmap.md
@@ -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)
diff --git a/docs/07_AI_Coder_Prompts.md b/docs/07_AI_Coder_Prompts.md
index ed2ccc3..04cb742 100644
--- a/docs/07_AI_Coder_Prompts.md
+++ b/docs/07_AI_Coder_Prompts.md
@@ -12,7 +12,7 @@
- заметки и файлы физически лежат обычными файлами в vault;
- SQLite хранит индекс, связи, метаданные, FTS и sync state;
- основная сущность — вложенное дерево nodes;
-- GUI, TUI и CLI должны использовать общий core;
+- GUI и CLI должны использовать общий core;
- бизнес-логику нельзя завязывать на UI;
- не делать SaaS;
- не делать ИИ;
@@ -25,7 +25,6 @@
- Go;
- SQLite;
- Wails для GUI;
-- Bubble Tea для TUI позже;
- fsnotify + snapshot scanner позже;
- sync server позже.
@@ -272,22 +271,7 @@ Acceptance:
- manual command `verstak scan`.
```
-## Prompt 14 — TUI MVP
-
-```markdown
-Добавь TUI на Bubble Tea.
-
-Функции:
-- tree/search;
-- open node;
-- add worklog;
-- run action;
-- sync now.
-
-TUI не должен повторять весь GUI.
-```
-
-## Prompt 15 — Integrity/restore
+## Prompt 14 — Integrity/restore
```markdown
Добавь команды:
diff --git a/docs/08_MVP_Checklist.md b/docs/08_MVP_Checklist.md
index 68c4f9e..dc43fc0 100644
--- a/docs/08_MVP_Checklist.md
+++ b/docs/08_MVP_Checklist.md
@@ -35,7 +35,6 @@
- [ ] Conflict resolver UI.
- [ ] Restore from sync server.
- [ ] Backup export / retention snapshots.
-- [ ] TUI MVP.
## Не входит в первый MVP
diff --git a/docs/09_Extensibility.md b/docs/09_Extensibility.md
index cfee6ec..ac32643 100644
--- a/docs/09_Extensibility.md
+++ b/docs/09_Extensibility.md
@@ -1,30 +1,36 @@
# Верстак — архитектура плагинов
-## Принцип
+## 1. Принцип
-Верстак — это минималистичный движок с деревом дел.
-Всё, что не входит в минимальную модель, — плагин.
+Верстак — минималистичный движок с деревом дел и базовыми операциями
+(заметки, файлы, действия, журнал, поиск, активность).
-Плагин — это директория в `.verstak/plugins//`, которую
-программа подхватывает без перекомпиляции.
+Плагины расширяют функциональность без перекомпиляции:
+- новые типы узлов и кастомные поля;
+- календарь, канбан, импортёры;
+- UI-элементы (пункты сайдбара, вкладки, страницы);
+- фоновые задачи по расписанию;
+- хуки на события (создание/удаление/изменение узлов).
-## Структура плагина
+## 2. Структура плагина
```
.verstak/plugins//
- plugin.json # мета: name, version, author, hooks
- main.lua # точка входа
+ plugin.json # метаданные: имя, версия, хуки, UI-contributions
+ main.lua # точка входа (обязательно, если есть Lua-код)
+ panels/ # HTML-панели для GUI (опционально)
+ calendar.html
+ kanban.html
templates/ # шаблоны дел (опционально)
client.json
repair.json
- panels/ # UI-панели для GUI (опционально)
- kanban.html
- calendar.html
migrations/ # SQL-миграции (опционально)
001_create_tables.sql
+ assets/ # статика: иконки, CSS, изображения (опционально)
+ icon.svg
```
-## plugin.json
+### 2.1 plugin.json — полная схема
```json
{
@@ -32,55 +38,485 @@
"version": "1.0.0",
"author": "...",
"description": "Календарь событий, привязанных к делам",
+
"hooks": {
"on_init": "on_init",
- "on_node_open": "on_node_open"
+ "on_vault_open": "on_vault_open",
+ "on_node_create": "on_node_create",
+ "on_node_delete": "on_node_delete",
+ "on_shutdown": "on_shutdown"
},
+
+ "ui": {
+ "sidebar_items": [
+ {
+ "id": "calendar",
+ "label": "Календарь",
+ "icon": "calendar",
+ "page": "plugin:calendar:main"
+ }
+ ],
+ "node_tabs": [
+ {
+ "id": "events",
+ "label": "События",
+ "page": "plugin:calendar:node_events"
+ }
+ ],
+ "node_actions": [
+ {
+ "id": "add_event",
+ "label": "Добавить событие",
+ "icon": "plus",
+ "page": "plugin:calendar:add_event"
+ }
+ ],
+ "settings_pages": [
+ {
+ "id": "calendar",
+ "label": "Календарь",
+ "page": "plugin:calendar:settings"
+ }
+ ]
+ },
+
+ "background_tasks": [
+ {
+ "id": "check_events",
+ "interval": "5m",
+ "script": "check_events.lua"
+ }
+ ],
+
"node_types": ["event"],
"panel": "panels/calendar.html",
"migrations": ["migrations/001_create_tables.sql"]
}
```
-## Lua API
+#### Поля plugin.json
-Плагины пишутся на Lua (gopher-lua). API:
+| Поле | Тип | Описание |
+|------|-----|----------|
+| `name` | string | Уникальное имя плагина |
+| `version` | string | SemVer |
+| `hooks` | map[string]string | Имя Lua-функции для каждого события |
+| `ui.sidebar_items` | [SidebarItem] | Пункты сайдбара |
+| `ui.node_tabs` | [NodeTab] | Вкладки в карточке дела |
+| `ui.node_actions` | [NodeAction] | Кнопки действий в деле |
+| `ui.settings_pages` | [SettingsPage] | Страницы настроек |
+| `background_tasks` | [BackgroundTask] | Фоновые задачи по расписанию |
+| `node_types` | [string] | Имена новых типов узлов, которые регистрирует плагин |
+| `panel` | string | HTML-файл панели (главная страница плагина) |
+| `migrations` | [string] | SQL-файлы миграций |
-```lua
--- Получить node по ID
-local node = verstak.node.get(id)
+### 2.2 SidebarItem
--- Создать node
-local n = verstak.node.create(parent_id, "type", "title")
-
--- Получить config value
-local v = verstak.config.get("key")
-
--- Записать в activity log
-verstak.activity.log({
- node_id = n.id,
- event_type = "calendar_event",
- title = "Встреча с клиентом"
-})
-
--- Зарегистрировать HTTP-эндпоинт (для GUI)
-verstak.http.route("GET", "/api/calendar/events", get_events)
-
--- Показать уведомление
-verstak.ui.toast("Событие добавлено")
+```json
+{
+ "id": "calendar",
+ "label": "Календарь",
+ "icon": "calendar",
+ "page": "plugin:calendar:main"
+}
```
-## Жизненный цикл плагина
+- `id` — уникальный в рамках плагина
+- `icon` — имя иконки из библиотеки GUI (или путь к `assets/icon.svg`)
+- `page` — идентификатор страницы в формате `plugin::`
+- При клике — фронтенд переключается на эту страницу, отправляет Lua-скрипту событие `on_page_open`
-1. **on_init** — при старте программы, до открытия vault.
- Инициализация, создание таблиц.
-2. **on_vault_open** — при открытии vault.
-3. **on_node_create / on_node_open / on_node_delete** — хуки на действия.
-4. **on_shutdown** — при закрытии.
+### 2.3 NodeTab
-## Реестр типов дел
+```json
+{
+ "id": "events",
+ "label": "События",
+ "page": "plugin:calendar:node_events"
+}
+```
-Плагины могут регистрировать новые типы:
+- Появляется как дополнительная вкладка в карточке дела
+- Вкладка видна для всех типов узлов, если не указан `node_type_filter`
+- При переключении — фронтенд запрашивает данные через Lua API
+
+### 2.4 BackgroundTask
+
+```json
+{
+ "id": "check_events",
+ "interval": "5m",
+ "script": "check_events.lua"
+}
+```
+
+- `interval` — интервал в человекочитаемом формате: `5m`, `1h`, `30s`
+- `script` — путь к Lua-файлу относительно корня плагина
+- Скрипт выполняется в том же Lua-стейте, что и main.lua, с полным доступом к API
+
+## 3. Lua API — полный справочник
+
+Плагины пишутся на Lua (gopher-lua). Доступны следующие модули:
+
+### 3.1 `verstak.node` — работа с деревом дел
+
+```lua
+-- Получить узел по ID
+local node = verstak.node.get(id)
+-- { id, parent_id, type, title, slug, sort_order, created_at, updated_at, section }
+
+-- Создать узел
+local n = verstak.node.create(parent_id, "type", "title")
+-- Можно с дополнительными полями:
+local n = verstak.node.create(parent_id, "type", "title", { section = "work" })
+
+-- Создать дочерний узел (сокращение)
+local n = verstak.node.create_child(parent_id, "type", "title")
+
+-- Обновить узел
+verstak.node.update(id, { title = "Новый заголовок", section = "personal" })
+
+-- Удалить узел (в корзину)
+verstak.node.delete(id)
+
+-- Найти узел по slug (относительно parent)
+local n = verstak.node.find_by_slug(parent_id, "my-slug")
+
+-- Поиск по дереву
+local results = verstak.node.search("query")
+-- results = [{ id, title, type, match_column, snippet }]
+
+-- Получить детей узла
+local children = verstak.node.children(parent_id)
+-- children = [{ id, title, type, ... }]
+
+-- Получить полный путь от корня
+local path = verstak.node.path(id)
+-- path = [{ id, title, type }, ...]
+```
+
+### 3.2 `verstak.file` — файлы
+
+```lua
+-- Получить файлы узла
+local files = verstak.file.list(node_id)
+
+-- Добавить файл (копировать в vault)
+local f = verstak.file.add(node_id, "/path/to/file.pdf")
+
+-- Удалить (в корзину)
+verstak.file.delete(file_id)
+
+-- Получить содержимое текстового файла
+local content = verstak.file.read_text(file_id)
+```
+
+### 3.3 `verstak.worklog` — журнал работ
+
+```lua
+-- Добавить запись
+verstak.worklog.add(node_id, minutes, "Описание работы")
+
+-- Добавить с датой
+verstak.worklog.add(node_id, minutes, "Описание", { date = "2025-06-07", billable = true })
+
+-- Список записей узла
+local entries = verstak.worklog.list(node_id, { limit = 10, offset = 0 })
+
+-- Сводка
+local summary = verstak.worklog.summary(node_id, { since = "2025-01-01", until = "2025-12-31" })
+```
+
+### 3.4 `verstak.activity` — лог активности
+
+```lua
+-- Записать событие
+verstak.activity.log({
+ node_id = node_id,
+ event_type = "calendar_event",
+ title = "Встреча с клиентом",
+ description = "Обсуждение макета",
+ confidence = "high"
+})
+```
+
+### 3.5 `verstak.config` — конфигурация плагина
+
+```lua
+-- Читать
+local val = verstak.config.get("calendar.default_view")
+-- val == nil если ключ не задан, иначе { key, value, plugin }
+
+-- Писать (сохраняется в .verstak/plugins//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//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//`).
+
+```lua
+-- GET /api/plugins/calendar/events?month=2025-06
+verstak.http.route("GET", "/events", function(params)
+ local events = verstak.db.query("SELECT * FROM events WHERE date LIKE ?", params.month .. "%")
+ return { json = events }
+end)
+
+-- POST /api/plugins/calendar/events
+verstak.http.route("POST", "/events", function(body)
+ verstak.db.exec("INSERT INTO events (...) VALUES (?,?)", body.title, body.date)
+ return { json = { ok = true } }
+end)
+```
+
+Эндпоинты аутентифицируются через X-Verstak-Secret (как и Bridge).
+
+### 3.9 `verstak.schedule` — фоновые задачи (из Lua)
+
+Помимо декларативных `background_tasks` в plugin.json, можно создавать
+задачи динамически:
+
+```lua
+-- Каждые 15 минут
+local timer_id = verstak.schedule.every("15m", function()
+ local events = verstak.db.query("SELECT * FROM events WHERE date = date('now')")
+ if #events > 0 then
+ verstak.ui.toast("Сегодня " .. #events .. " событий")
+ end
+end)
+
+-- Отменить
+verstak.schedule.cancel(timer_id)
+
+-- Однократно через N секунд
+verstak.schedule.once("30s", function()
+ verstak.ui.toast("30 секунд прошло")
+end)
+
+-- Каждое N-е число месяца (cron-подобно)
+verstak.schedule.cron("0 9 * * 1", function()
+ -- Каждый понедельник в 9 утра
+ verstak.ui.toast("Планирование недели")
+end)
+```
+
+### 3.10 `verstak.state` — разделяемое состояние между хуками/тасками
+
+```lua
+-- Сохранить значение в памяти плагина
+verstak.state.set("event_count", 42)
+
+-- Прочитать
+local n = verstak.state.get("event_count")
+
+-- Удалить
+verstak.state.delete("event_count")
+```
+
+Состояние хранится только в runtime — при перезапуске теряется.
+Для постоянных данных используйте `verstak.config` или `verstak.db`.
+
+## 4. Хуки — жизненный цикл
+
+Имя в plugin.json → имя Lua-функции, которая вызывается в main.lua.
+
+### 4.1 `on_init`
+
+```lua
+function on_init()
+ -- Вызывается при старте программы, ДО открытия vault.
+ -- Здесь плагин может выполнить миграции БД,
+ -- зарегистрировать свои таблицы.
+ -- ВАЖНО: vault ещё не открыт, verstak.node недоступен.
+ verstak.db.exec([[CREATE TABLE IF NOT EXISTS events (
+ id TEXT PRIMARY KEY,
+ title TEXT NOT NULL,
+ date TEXT NOT NULL,
+ node_id TEXT,
+ created_at TEXT DEFAULT (datetime('now'))
+ )]])
+end
+```
+
+### 4.2 `on_vault_open`
+
+```lua
+function on_vault_open(vault_path)
+ -- Вызывается после открытия vault.
+ -- verstak.node, verstak.file уже доступны.
+ -- Можно зарегистрировать HTTP-эндпоинты.
+ verstak.http.route("GET", "/events", list_events)
+end
+```
+
+### 4.3 `on_node_create`
+
+```lua
+function on_node_create(node)
+ -- node = { id, parent_id, type, title, section, ... }
+ -- Вызывается после создания любого узла.
+ -- Нельзя отменить создание (позднее — вернём before_create).
+ if node.type == "event" then
+ verstak.activity.log({
+ node_id = node.id,
+ event_type = "calendar_event_created",
+ title = "Создано событие: " .. node.title
+ })
+ end
+end
+```
+
+### 4.4 `on_node_delete`
+
+```lua
+function on_node_delete(node)
+ -- Вызывается при удалении узла.
+ -- Плагин может почистить свои данные.
+ verstak.db.exec("DELETE FROM events WHERE node_id = ?", node.id)
+end
+```
+
+### 4.5 `on_shutdown`
+
+```lua
+function on_shutdown()
+ -- При закрытии программы.
+ -- Сохранить состояние, закрыть соединения.
+end
+```
+
+### 4.6 `on_page_open` (автоматический)
+
+Когда пользователь переходит на страницу плагина (`plugin:calendar:main`),
+фронтенд посылает событие в Lua:
+
+```lua
+-- Не нужно объявлять в plugin.json — вызывается автоматически
+function on_page_open(page_id, params)
+ -- page_id = "main" или "node_events"
+ -- params = { node_id = "...", date = "2025-06-07" }
+ if page_id == "main" then
+ local events = verstak.db.query("SELECT * FROM events ORDER BY date")
+ -- Данные будут отправлены фронтенду через Wails binding
+ verstak.state.set("page_data", events)
+ -- Фронтенд получит данные через вызов GetPluginPageData()
+ end
+end
+```
+
+## 5. UI-интеграция — как это работает
+
+```
+┌──────────────────────────────────────────────────┐
+│ Svelte Frontend │
+│ │
+│ ┌──────────────┬──────────────────────────────┐ │
+│ │ Sidebar │ Main Workspace │ │
+│ │ │ │ │
+│ │ 📁 Дела │ ┌─ plugin:calendar:main ──┐ │ │
+│ │ 📅 Календарь │ │ [Calendar HTML Panel] │ │ │
+│ │ ⚙ Настройки │ │ │ │ │
+│ │ │ └────────────────────────┘ │ │
+│ └──────────────┴──────────────────────────────┘ │
+│ │
+│ Go Backend (Wails Bindings) │
+│ ┌─────────────────────────────────────────┐ │
+│ │ PluginManager │ │
+│ │ ├─ Lua Runtime (gopher-lua) │ │
+│ │ ├─ Scheduler │ │
+│ │ └─ HTTP Server │ │
+│ └─────────────────────────────────────────┘ │
+└──────────────────────────────────────────────────┘
+```
+
+### 5.1 Регистрация UI-компонентов
+
+При старте плагина Go-код:
+1. Читает `plugin.json`
+2. Из `ui.sidebar_items` регистрирует пункты в общем реестре сайдбара
+3. Из `ui.node_tabs` регистрирует вкладки для карточки дела
+4. Из `ui.node_actions` регистрирует кнопки действий
+5. Из `ui.settings_pages` регистрирует страницы настроек
+
+Фронтенд читает реестр через Wails binding и рендерит соответствующие
+элементы UI.
+
+### 5.2 HTML-панели
+
+Плагин может поставлять HTML-панель (`panel` в plugin.json или `panels/`).
+
+Панель **рендерится в iframe** внутри основного окна.
+Это изолирует DOM плагина от DOM приложения.
+
+Каналы связи с бэкендом:
+- **Данные:** Wails binding `GetPluginPageData(plugin, page)` — Lua возвращает текущие `page_data`
+- **Действия:** Wails binding `CallPluginAction(plugin, action, params)` — вызывает `on_action` в Lua
+- **Уведомления:** Lua → фронтенд через `verstak.ui.toast()` (WS или polling)
+- **Навигация:** iframe может сообщить родителю о смене страницы через `window.parent.postMessage`
+
+> **Важно:** HTML-панели — временное решение. В будущем плагины
+> смогут поставлять Svelte-компоненты, но это требует динамической
+> компиляции и горячей перезагрузки. Для MVP — iframe достаточно.
+
+### 5.3 Данные для панели
+
+Панель получает данные через `__VERSTAK_DATA__` (глобальная JS-переменная,
+инжектится при рендеринге панели). Механизм:
+
+1. Фронтенд открывает страницу `plugin:calendar:main`
+2. Go бэкенд вызывает Lua `on_page_open("main", params)`
+3. Lua сохраняет данные через `verstak.state.set("page_data", ...)`
+4. Фронтенд вызывает `GetPluginPageData("calendar", "main")`
+5. Go возвращает Lua-данные, фронтенд инжектит их в iframe
+
+### 5.4 Кастомные типы узлов
+
+Плагины могут регистрировать новые типы узлов через `node_types` в plugin.json
+и/или через Lua API:
```lua
verstak.node.register_type({
@@ -88,72 +524,121 @@ verstak.node.register_type({
label = "Событие",
icon = "calendar",
fields = {
- { name = "date", label = "Дата", type = "date" },
- { name = "time", label = "Время", type = "time" },
- { name = "location", label = "Место", type = "text" },
+ { name = "date", label = "Дата", type = "date" },
+ { name = "time", label = "Время", type = "time" },
+ { name = "location", label = "Место", type = "text" },
+ { name = "color", label = "Цвет", type = "select", options = {"red","blue","green"} },
}
})
```
-GUI рисует карточку дела на основе зарегистрированных полей типа.
+GUI рисует карточку дела на основе зарегистрированных полей.
+Поля сохраняются как метаданные узла.
-## Шаблоны дела
+## 6. Песочница и безопасность
-Шаблон — JSON-описание предзаполненного дерева:
+Lua-плагины работают в изолированной среде:
+
+- **Нет доступа к ФС** напрямую — только через `verstak.*` API (который проверяет пути)
+- **Нет `io.*`, `os.execute`, `os.popen`** — библиотеки удалены
+- **Нет `loadlib`** — динамическая загрузка C-библиотек запрещена
+- **Память:** лимит на размер Lua-стейта (настраивается, по умолчанию 32 MB)
+- **Инструкции:** защита от бесконечных циклов (максимум N инструкций на вызов, default 10M)
+- **Сеть:** никаких сокетов из Lua — только через `verstak.http` (контролируемый роутинг)
+- **Таймаут:** каждый Lua-вызов (хук, HTTP-обработчик, таска) имеет таймаут 30s
+
+### Конфигурация песочницы
+
+В `config.json` vault:
```json
{
- "name": "Клиент",
- "icon": "user",
- "tree": [
- { "type": "folder", "title": "Документы" },
- { "type": "folder", "title": "Переписка" },
- { "type": "folder", "title": "Скриншоты" },
- { "type": "note", "title": "Overview" },
- { "type": "action", "title": "Открыть сайт", "kind": "open_url", "url": "" }
- ],
- "meta": [
- { "key": "domain", "label": "Домен сайта", "type": "text" },
- { "key": "admin_url", "label": "Админка", "type": "url" }
- ]
+ "plugins": {
+ "sandbox": {
+ "max_memory_mb": 32,
+ "max_instructions": 10000000,
+ "call_timeout_ms": 30000,
+ "allow_plugins": ["calendar", "kanban"]
+ }
+ }
}
```
-GUI: при создании дела пользователь выбирает шаблон — и дерево
-создаётся автоматически.
+## 7. Жизненный цикл — детально
-## Песочница
+```
+┌─────────────────────────────────────────────────────────────┐
+│ Старт программы │
+│ │
+│ 1. Инициализация config, storage │
+│ 2. PluginManager.NewManager(vaultRoot) │
+│ 3. Discover() — сканирование .verstak/plugins/*/plugin.json │
+│ 4. Для каждого активного плагина: │
+│ a. Выполнить SQL-миграции │
+│ b. Создать Lua-стейт (песочница) │
+│ c. Загрузить main.lua │
+│ d. Зарегистрировать node_types, ui. sidebar/tabs/actions │
+│ e. Вызвать on_init() │
+│ 5. Открытие vault │
+│ 6. Для каждого плагина: вызвать on_vault_open(path) │
+│ 7. Запуск фоновых задач (scheduler) │
+│ │
+│ ┌──── Эксплуатация ──────────────────────────────────────┐ │
+│ │ - Хуки на события дерева │ │
+│ │ - HTTP-запросы → Lua-обработчики │ │
+│ │ - UI-навигация → on_page_open() │ │
+│ │ - Фоновые задачи по расписанию │ │
+│ └─────────────────────────────────────────────────────────┘ │
+│ │
+│ 8. Закрытие программы │
+│ 9. Для каждого плагина: on_shutdown() → закрыть Lua-стейт │
+│ 10. Остановка scheduler │
+└─────────────────────────────────────────────────────────────┘
+```
-Lua-плагины работают в песочнице:
+## 8. Распространение и CLI
-- нет доступа к файловой системе напрямую (только через API vault);
-- нет `io.*`, `os.execute` и т.д.;
-- память ограничена;
-- нет сетевых вызовов кроме зарегистрированных HTTP-эндпоинтов.
+### CLI-команды
-Go-плагины (buildmode=plugin) доступны для продвинутых
-разработчиков, но требуют совместимости версий.
+```bash
+verstak plugin list # Список плагинов
+verstak plugin enable # Включить
+verstak plugin disable # Выключить
+verstak plugin install # Установить из registry (TODO)
+verstak plugin remove # Удалить
+```
-## Инициализация
+### Структура пакета
-При старте `verstak init` создаёт `.verstak/plugins/`.
-При старте GUI/CLI/TUI:
+Плагин распространяется как zip-архив:
-1. Сканировать `.verstak/plugins/*/plugin.json`
-2. Валидировать (имя, версия, структура)
-3. Загрузить миграции и выполнить
-4. Загрузить Lua-скрипты через gopher-lua
-5. Вызвать `on_init` у каждого плагина
-6. Зарегистрировать node types, HTTP routes, UI panels
+```bash
+calendar-1.0.0.vpk # Verstak Plugin Package
+ plugin.json
+ main.lua
+ panels/calendar.html
+ migrations/001_create_tables.sql
+```
-## Распространение
-
-Плагин — это zip-архив с правильной структурой.
-Репозиторий плагинов: `verstak-registry` (отдельный проект).
-
-Установка:
+Установка из registry (будущее):
```bash
verstak plugin install calendar
-verstak plugin enable calendar
-verstak plugin list
+# → скачивает calendar-1.0.0.vpk
+# → распаковывает в .verstak/plugins/calendar/
+# → выполняет миграции
+# → активирует
```
+
+## 9. Пример: Calendar plugin
+
+(См. `contrib/plugins/calendar/` — будет реализован как эталон.)
+
+Плагин `calendar` демонстрирует:
+- Полный цикл: plugin.json → main.lua → panel → миграции
+- Регистрацию пункта в сайдбаре
+- Свою HTML-панель с календарной сеткой
+- Создание/удаление событий через Lua API
+- Привязку событий к делам
+- Фоновую задачу (проверка событий на сегодня)
+- Кастомный тип узла `event`
+- Настройки плагина через `verstak.config`
diff --git a/docs/PLAN.md b/docs/PLAN.md
index e46ce00..d6f3fd9 100644
--- a/docs/PLAN.md
+++ b/docs/PLAN.md
@@ -27,13 +27,12 @@
| 14 | **MVP stabilization** | ✅ выполнено |
| 15 | **Sync Server + Client** | ✅ выполнено |
| 16 | **Activity Suggestions** | ✅ выполнено базово — global worklog dashboard + conservative suggestions + UX |
-| 17 | **File Scanner/Watcher** | ⏳ ожидает — fsnotify, snapshot scanner |
-| 18 | **TUI MVP (Bubble Tea)** | ⏳ ожидает — tree/search, add worklog |
-| 19 | **Plugins: Lua runtime** | ⏳ ожидает — gopher-lua, hooks, sandbox |
-| 20 | **Browser Extension** | ⏳ ожидает — tracking, capture, evidence |
-| 21 | **Calendar/Kanban** | ⏳ ожидает — view by date, board view |
-| 22 | **Integrity Check + Repair** | ⏳ ожидает — checksums, crash recovery |
-| 23 | **New templates/integrations** | ⏳ ожидает — community plugins |
+| 17 | **File Scanner/Watcher** | ✅ выполнено — fsnotify, snapshot scanner |
+| 18 | **Plugins: Lua runtime** | ✅ скелет — gopher-lua, хуки, песочница, шедулер, Calendar-пример |
+| 19 | **Browser Extension** | ✅ выполнено — tracking, capture, evidence |
+| 20 | **Calendar/Kanban** | ⏳ ожидает — view by date, board view |
+| 21 | **Integrity Check + Repair** | ⏳ ожидает — checksums, crash recovery |
+| 22 | **New templates/integrations** | ⏳ ожидает — community plugins |
---
@@ -248,28 +247,37 @@ Real-time fsnotify watcher управляется отдельно.
Через GUI: `WatcherStatus()` — true если watcher активен.
Через консоль (отладка): создать/удалить файл в vault → в activity появятся события `file_added`/`file_missing`/`file_modified`.
-## Ожидающие шаги (18-23)
+## Выполненные шаги (18-22 частично)
-### ШАГ 18 — TUI MVP (Bubble Tea)
-- Терминальный интерфейс: дерево дел, поиск, добавление worklog, запуск действий, sync
+### ШАГ 18 — Plugins: Lua runtime ✅ скелет
-### ШАГ 19 — Plugins: Lua runtime
-- gopher-lua runtime
-- хуки на события (node created, file added, etc.)
-- песочница
+**Статус:** ✅ Скелет готов — рантайм, хуки, песочница, шедулер, Calendar-пример.
-### ШАГ 20 — Browser Extension
+Что реализовано:
+- `internal/core/plugins/runtime.go` — gopher-lua VM: загрузка main.lua, вызов хуков, таймауты
+- `internal/core/plugins/api.go` — регистрация таблицы `verstak` с субмодулями
+- `internal/core/plugins/api_node.go` — `verstak.node.*` (заглушки, ждут реализации)
+- `internal/core/plugins/api_ext.go` — `verstak.db.*`, `verstak.config.*`, `verstak.state.*`, `verstak.ui.*`, `verstak.schedule.*`, `verstak.http.*` (state — работает, остальное — заглушки)
+- `internal/core/plugins/api_utils.go` — вспомогательные функции
+- `internal/core/plugins/scheduler.go` — фоновые задачи по расписанию (goroutine + ticker)
+- `internal/core/plugins/manager.go` — обновлён: InitRuntimes, CallInitHooks, StartSchedulers, etc.
+- `internal/core/plugins/runtime_test.go` — 5 тестов: hook call, missing hook, sandbox, state, Manager lifecycle
+- `contrib/plugins/calendar/` — пример-плагин: plugin.json, main.lua, HTML-panel, migrations, background task
+
+**Следующее:** реализовать `verstak.node.*`, `verstak.db.*`, `verstak.config.*`, `verstak.ui.*` полноценно, и пройтись по Calendar-плагину вживую.
+
+### ШАГ 19 — Browser Extension
- Расширение для браузера: трекинг времени, скриншоты, evidence
-### ШАГ 21 — Calendar/Kanban
+### ШАГ 20 — Calendar/Kanban
- Календарный вид по датам
- Канбан-доска по статусам
-### ШАГ 22 — Integrity Check + Repair
+### ШАГ 21 — Integrity Check + Repair
- Проверка целостности vault
- Контрольные суммы, восстановление после сбоев
-### ШАГ 23 — New templates/integrations
+### ШАГ 22 — New templates/integrations
- Community-плагины и шаблоны
---
diff --git a/extension-firefox/popup/popup.js b/extension-firefox/popup/popup.js
index 629b8c0..aa3ada4 100644
--- a/extension-firefox/popup/popup.js
+++ b/extension-firefox/popup/popup.js
@@ -182,7 +182,7 @@ function testPort() {
})
.catch((err) => {
if (err.name === 'TimeoutError' || err.name === 'AbortError') {
- statusEl.textContent = '✗ Нет ответа (таймаут)';
+ statusEl.textContent = '✗ Нет ответа (таймаут). Возможно, порт занят другим приложением или Verstak не запущен';
} else {
statusEl.textContent = '✗ Сервер недоступен';
}
diff --git a/extension/popup/popup.js b/extension/popup/popup.js
index 629b8c0..aa3ada4 100644
--- a/extension/popup/popup.js
+++ b/extension/popup/popup.js
@@ -182,7 +182,7 @@ function testPort() {
})
.catch((err) => {
if (err.name === 'TimeoutError' || err.name === 'AbortError') {
- statusEl.textContent = '✗ Нет ответа (таймаут)';
+ statusEl.textContent = '✗ Нет ответа (таймаут). Возможно, порт занят другим приложением или Verstak не запущен';
} else {
statusEl.textContent = '✗ Сервер недоступен';
}
diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte
index deaa673..4918765 100644
--- a/frontend/src/App.svelte
+++ b/frontend/src/App.svelte
@@ -10,6 +10,7 @@
import SettingsWindow from './lib/SettingsWindow.svelte'
import SyncStatus from './lib/SyncStatus.svelte'
import TodayScreen from './lib/TodayScreen.svelte'
+ import PluginPage from './lib/PluginPage.svelte'
import AppHeader from './lib/AppHeader.svelte'
import { onMount, onDestroy } from 'svelte'
import { actionIcon } from './lib/actionIcons.js'
@@ -384,7 +385,7 @@
// Vault is ready — load main UI
try {
version = await wailsCall('VerstakVersion') || 'verstak-gui/v2'
- systemViews = await wailsCall('ListSystemViews') || []
+ systemViews = await wailsCall('ListSystemViewsWithPlugins') || []
workspaceTree = await wailsCall('ListWorkspaceTree') || []
enabledTemplates = await wailsCall('ListEnabledTemplates') || []
} catch (e) {
@@ -3572,6 +3573,9 @@
{/if}
+ {:else if selectedSection && selectedSection.startsWith('plugin:')}
+
+
{:else}