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}

{t('welcome.title')}

diff --git a/frontend/src/lib/PluginPage.svelte b/frontend/src/lib/PluginPage.svelte new file mode 100644 index 0000000..49bf3fd --- /dev/null +++ b/frontend/src/lib/PluginPage.svelte @@ -0,0 +1,70 @@ + + +
+
+

{pluginName} — {pageLabel}

+
+ + {#if loading} +

{t('common.loading')}

+ {:else if error} +

{error}

+ {:else if htmlPanel} + + {:else} +
+

Плагин «{pluginName}» активен, но HTML-панель не настроена.

+

Для отображения контента добавьте поле panel в plugin.json.

+
+ {/if} +
+ + diff --git a/frontend/src/lib/SettingsPlugins.svelte b/frontend/src/lib/SettingsPlugins.svelte index 1014529..56c9c5e 100644 --- a/frontend/src/lib/SettingsPlugins.svelte +++ b/frontend/src/lib/SettingsPlugins.svelte @@ -1,6 +1,11 @@ -
-

{t('settings.plugins')}

-

{t('settings.noPlugins')}

- +
+

{t('settings.plugins') || 'Плагины'}

+

Включение и отключение плагинов. Изменения применяются сразу.

+ + {#if loading} +

{t('common.loading')}

+ {:else if error} +

{error}

+ {:else if plugins.length === 0} +
+

Нет установленных плагинов.

+

Создайте папку .verstak/plugins/ в хранилище и добавьте туда плагины.

+
+ {:else} +
+ {#each plugins as p} +
+
+
+ {p.name} + {#if p.version} + v{p.version} + {/if} +
+ {#if p.description} +
{p.description}
+ {/if} +
+ {#if p.author} + {t('settings.author') || 'Автор'}: {p.author} + {/if} + {#if p.uiContribs?.sidebarItems?.length} + {p.uiContribs.sidebarItems.length} пункт(а) в сайдбаре + {/if} + {#if p.uiContribs?.nodeTabs?.length} + {p.uiContribs.nodeTabs.length} вкладок(и) в карточке дела + {/if} + {#if p.hasPanel} + 🗖 HTML-панель + {/if} +
+
+
+ +
+
+ {/each} +
+ {/if}
diff --git a/go.mod b/go.mod index c6522f5..f798200 100644 --- a/go.mod +++ b/go.mod @@ -37,6 +37,7 @@ require ( github.com/valyala/fasttemplate v1.2.2 // indirect github.com/wailsapp/go-webview2 v1.0.22 // indirect github.com/wailsapp/mimetype v1.4.1 // indirect + github.com/yuin/gopher-lua v1.1.2 // indirect golang.org/x/net v0.35.0 // indirect golang.org/x/sys v0.30.0 // indirect golang.org/x/text v0.22.0 // indirect diff --git a/go.sum b/go.sum index aa07bba..836c139 100644 --- a/go.sum +++ b/go.sum @@ -74,6 +74,8 @@ github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhw github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c= github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg= +github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA= +github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8= golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= diff --git a/internal/core/bridge/bridge.go b/internal/core/bridge/bridge.go index ae34fb8..42324e8 100644 --- a/internal/core/bridge/bridge.go +++ b/internal/core/bridge/bridge.go @@ -78,10 +78,10 @@ func GenerateSecret() string { } // NewServer creates a bridge server. -// If cfg.Secret is empty, no authentication is required. -func NewServer(cfg Config, handler EventHandler) *Server { +// If secret is empty, no authentication is required. +func NewServer(secret string, handler EventHandler) *Server { return &Server{ - secret: cfg.Secret, + secret: secret, handler: handler, } } diff --git a/internal/core/bridge/bridge_test.go b/internal/core/bridge/bridge_test.go index 47aa342..f1bdb0f 100644 --- a/internal/core/bridge/bridge_test.go +++ b/internal/core/bridge/bridge_test.go @@ -10,7 +10,7 @@ import ( ) func TestServer_Ping(t *testing.T) { - s := NewServer(DefaultConfig(), nil) + s := NewServer("", nil) port, err := s.Start(DefaultConfig()) if err != nil { t.Fatal(err) @@ -35,7 +35,7 @@ func TestServer_Ping(t *testing.T) { } func TestServer_Events_AuthRequired(t *testing.T) { - s := NewServer(Config{Secret: "test-secret"}, nil) + s := NewServer("test-secret", nil) port, err := s.Start(Config{Secret: "test-secret"}) if err != nil { t.Fatal(err) @@ -56,7 +56,7 @@ func TestServer_Events_AuthRequired(t *testing.T) { func TestServer_Events_Success(t *testing.T) { received := make(chan []Event, 1) - s := NewServer(Config{Secret: "test-secret"}, func(evts []Event) { + s := NewServer("test-secret", func(evts []Event) { received <- evts }) port, err := s.Start(Config{Secret: "test-secret"}) @@ -99,7 +99,7 @@ func TestServer_Events_Success(t *testing.T) { } func TestServer_Events_EmptyBatch(t *testing.T) { - s := NewServer(Config{Secret: "s"}, nil) + s := NewServer("s", nil) port, err := s.Start(Config{Secret: "s"}) if err != nil { t.Fatal(err) @@ -135,7 +135,7 @@ func TestGenerateSecret(t *testing.T) { func TestServer_AutoPort(t *testing.T) { cfg := DefaultConfig() - s := NewServer(cfg, nil) + s := NewServer("", nil) port, err := s.Start(cfg) if err != nil { t.Fatal(err) diff --git a/internal/core/config/appconfig.go b/internal/core/config/appconfig.go index 228ce6a..cbea5c4 100644 --- a/internal/core/config/appconfig.go +++ b/internal/core/config/appconfig.go @@ -65,10 +65,10 @@ type SyncSettings struct { // BridgeConfig holds local HTTP bridge settings for browser extension. type BridgeConfig struct { - Enabled bool `json:"enabled,omitempty"` // enable/disable bridge server + Enabled bool `json:"enabled"` // enable/disable bridge server Port int `json:"port"` // listen port (default 9786) Secret string `json:"secret,omitempty"` // shared secret for extension auth - AutoGenPort bool `json:"auto_gen_port,omitempty"` // pick random port if port taken + AutoGenPort bool `json:"auto_gen_port"` // pick random port if port taken } func DefaultAppConfig() *AppConfig { diff --git a/internal/core/plugins/api.go b/internal/core/plugins/api.go new file mode 100644 index 0000000..b29600f --- /dev/null +++ b/internal/core/plugins/api.go @@ -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)) + } +} diff --git a/internal/core/plugins/api_ext.go b/internal/core/plugins/api_ext.go new file mode 100644 index 0000000..cc3e5bc --- /dev/null +++ b/internal/core/plugins/api_ext.go @@ -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)) + } +} diff --git a/internal/core/plugins/api_node.go b/internal/core/plugins/api_node.go new file mode 100644 index 0000000..76207a3 --- /dev/null +++ b/internal/core/plugins/api_node.go @@ -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 diff --git a/internal/core/plugins/api_utils.go b/internal/core/plugins/api_utils.go new file mode 100644 index 0000000..93531fe --- /dev/null +++ b/internal/core/plugins/api_utils.go @@ -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) + } +} diff --git a/internal/core/plugins/manager.go b/internal/core/plugins/manager.go index bf71d51..4c59e50 100644 --- a/internal/core/plugins/manager.go +++ b/internal/core/plugins/manager.go @@ -2,33 +2,92 @@ package plugins import ( "encoding/json" + "log" "os" "path/filepath" + + lua "github.com/yuin/gopher-lua" ) // Meta is the plugin.json descriptor. type Meta struct { Name string `json:"name"` Version string `json:"version"` - Description string `json:"description"` - Author string `json:"author"` + Description string `json:"description,omitempty"` + Author string `json:"author,omitempty"` Hooks map[string]string `json:"hooks,omitempty"` + UI *UIContrib `json:"ui,omitempty"` + Background []BackgroundTask `json:"background_tasks,omitempty"` NodeTypes []string `json:"node_types,omitempty"` + Panel string `json:"panel,omitempty"` Templates []string `json:"templates,omitempty"` Migrations []string `json:"migrations,omitempty"` } -// Plugin represents a loaded plugin. +// UIContrib describes UI contributions from a plugin. +type UIContrib struct { + SidebarItems []SidebarItem `json:"sidebar_items,omitempty"` + NodeTabs []NodeTab `json:"node_tabs,omitempty"` + NodeActions []NodeAction `json:"node_actions,omitempty"` + SettingsPages []SettingsPage `json:"settings_pages,omitempty"` +} + +// SidebarItem is a navigation item in the sidebar. +type SidebarItem struct { + ID string `json:"id"` + Label string `json:"label"` + Icon string `json:"icon,omitempty"` + Page string `json:"page"` +} + +// NodeTab is an extra tab in the node detail view. +type NodeTab struct { + ID string `json:"id"` + Label string `json:"label"` + Page string `json:"page"` +} + +// NodeAction is an action button in the node detail. +type NodeAction struct { + ID string `json:"id"` + Label string `json:"label"` + Icon string `json:"icon,omitempty"` + Page string `json:"page,omitempty"` +} + +// SettingsPage is a plugin settings page in the settings dialog. +type SettingsPage struct { + ID string `json:"id"` + Label string `json:"label"` + Page string `json:"page"` +} + +// BackgroundTask describes a recurring background task. +type BackgroundTask struct { + ID string `json:"id"` + Interval string `json:"interval"` // e.g. "5m", "1h", "30s" + Script string `json:"script"` // relative path to .lua file +} + +// Plugin represents a loaded plugin with its runtime. type Plugin struct { - Meta Meta - Dir string // absolute path to plugin directory - Active bool + Meta Meta + Dir string // absolute path to plugin directory + DataDir string // .verstak/plugins//data — plugin's own SQLite storage + Active bool + + // Runtime (set after InitRuntime) + vm *LuaVM + scheduler *Scheduler } // Manager discovers and loads plugins from .verstak/plugins/. type Manager struct { vaultRoot string plugins []Plugin + + // Services exposed to Lua plugin API + Services *CoreServices } // NewManager creates a plugin manager for a vault. @@ -55,16 +114,139 @@ func (m *Manager) Discover() { } var meta Meta if err := json.Unmarshal(data, &meta); err != nil { + log.Printf("[plugins] %s: invalid plugin.json: %v", e.Name(), err) continue } if meta.Name == "" { meta.Name = e.Name() } - m.plugins = append(m.plugins, Plugin{ - Meta: meta, - Dir: filepath.Join(pluginsDir, e.Name()), - Active: true, - }) + dataDir := filepath.Join(pluginsDir, e.Name(), "data") + os.MkdirAll(dataDir, 0o750) + + m.plugins = append(m.plugins, Plugin{ + Meta: meta, + Dir: filepath.Join(pluginsDir, e.Name()), + DataDir: dataDir, + Active: true, + }) + } +} + +// InitRuntimes creates Lua VMs and schedulers for all active plugins. +// Must be called after Discover() and before using plugins. +func (m *Manager) InitRuntimes() { + for i := range m.plugins { + if !m.plugins[i].Active { + continue + } + p := &m.plugins[i] + + // Create Lua VM + vm, err := NewLuaVM(p) + if err != nil { + log.Printf("[plugins] %s: failed to create Lua VM: %v", p.Meta.Name, err) + p.Active = false + continue + } + p.vm = vm + if m.Services != nil { + vm.SetServices(m.Services) + } + + // Load main.lua if it exists + mainPath := filepath.Join(p.Dir, "main.lua") + if _, err := os.Stat(mainPath); err == nil { + if err := vm.LoadScript("main.lua"); err != nil { + log.Printf("[plugins] %s: failed to load main.lua: %v", p.Meta.Name, err) + } + } + + // Create scheduler + p.scheduler = NewScheduler(p, vm) + for _, bg := range p.Meta.Background { + if err := p.scheduler.AddTask(bg); err != nil { + log.Printf("[plugins] %s: failed to add task %s: %v", p.Meta.Name, bg.ID, err) + } + } + } +} + +// CallInitHooks calls on_init for all active plugins. +func (m *Manager) CallInitHooks() { + for i := range m.plugins { + if !m.plugins[i].Active { + continue + } + p := &m.plugins[i] + if hookName, ok := p.Meta.Hooks["on_init"]; ok && p.vm != nil { + if err := p.vm.CallHook(hookName); err != nil { + log.Printf("[plugins] %s: on_init error: %v", p.Meta.Name, err) + } + } + } +} + +// CallVaultOpenHooks calls on_vault_open for all active plugins. +func (m *Manager) CallVaultOpenHooks(vaultPath string) { + for i := range m.plugins { + if !m.plugins[i].Active { + continue + } + p := &m.plugins[i] + if hookName, ok := p.Meta.Hooks["on_vault_open"]; ok && p.vm != nil { + if err := p.vm.CallHook(hookName, lua.LString(vaultPath)); err != nil { + log.Printf("[plugins] %s: on_vault_open error: %v", p.Meta.Name, err) + } + } + } +} + +// StartSchedulers starts background tasks for all active plugins. +func (m *Manager) StartSchedulers() { + for i := range m.plugins { + if !m.plugins[i].Active { + continue + } + p := &m.plugins[i] + if p.scheduler != nil { + p.scheduler.Start() + } + } +} + +// StopSchedulers stops all background tasks. +func (m *Manager) StopSchedulers() { + for i := range m.plugins { + p := &m.plugins[i] + if p.scheduler != nil { + p.scheduler.Stop() + } + } +} + +// CallShutdownHooks calls on_shutdown for all active plugins. +func (m *Manager) CallShutdownHooks() { + for i := range m.plugins { + if !m.plugins[i].Active { + continue + } + p := &m.plugins[i] + if hookName, ok := p.Meta.Hooks["on_shutdown"]; ok && p.vm != nil { + if err := p.vm.CallHook(hookName); err != nil { + log.Printf("[plugins] %s: on_shutdown error: %v", p.Meta.Name, err) + } + } + } +} + +// CloseRuntimes shuts down all Lua VMs. +func (m *Manager) CloseRuntimes() { + for i := range m.plugins { + p := &m.plugins[i] + if p.vm != nil { + p.vm.Close() + p.vm = nil + } } } diff --git a/internal/core/plugins/manager_lifecycle.go b/internal/core/plugins/manager_lifecycle.go new file mode 100644 index 0000000..77006c2 --- /dev/null +++ b/internal/core/plugins/manager_lifecycle.go @@ -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 +} diff --git a/internal/core/plugins/runtime.go b/internal/core/plugins/runtime.go new file mode 100644 index 0000000..d7eb86d --- /dev/null +++ b/internal/core/plugins/runtime.go @@ -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 +} diff --git a/internal/core/plugins/runtime_test.go b/internal/core/plugins/runtime_test.go new file mode 100644 index 0000000..0817d22 --- /dev/null +++ b/internal/core/plugins/runtime_test.go @@ -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()) + }) +} diff --git a/internal/core/plugins/scheduler.go b/internal/core/plugins/scheduler.go new file mode 100644 index 0000000..cfad300 --- /dev/null +++ b/internal/core/plugins/scheduler.go @@ -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) +} diff --git a/internal/core/watcher/watcher.go b/internal/core/watcher/watcher.go index 33be71f..cb16b06 100644 --- a/internal/core/watcher/watcher.go +++ b/internal/core/watcher/watcher.go @@ -356,5 +356,7 @@ func hashFileFast(absPath string) (string, int64) { func isUnderVault(absPath, vaultRoot string) bool { absPath, _ = filepath.Abs(absPath) vaultRoot, _ = filepath.Abs(vaultRoot) + absPath = filepath.Clean(absPath) + vaultRoot = filepath.Clean(vaultRoot) return strings.HasPrefix(absPath, vaultRoot+string(filepath.Separator)) || absPath == vaultRoot }