--[[ 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_install() print("Calendar: on_install — creating tables") 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)) error(err) else print("Calendar: migration complete") end -- Insert default categories M.ensure_categories() print("Calendar: install complete") end function on_uninstall() print("Calendar: on_uninstall — dropping tables") local ok, err = pcall(function() verstak.db.exec("DROP TABLE IF EXISTS events") verstak.db.exec("DROP TABLE IF EXISTS categories") end) if not ok then print("Calendar: uninstall error: " .. tostring(err)) else print("Calendar: tables dropped") end -- Clean up config pcall(verstak.config.set, "categories", nil) print("Calendar: uninstall complete") end function on_init() print("Calendar: on_init — registering API") -- 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")