verstak/contrib/plugins/calendar/main.lua

781 lines
26 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

--[[
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(params, end_date)
-- Debug: log what we received
print("get_events: params type=" .. type(params) .. ", end_date type=" .. type(end_date))
if type(params) == "table" then
for k, v in pairs(params) do
print(" params[" .. tostring(k) .. "] type=" .. type(v))
end
end
-- Backward compat: support positional (start, end) and table {start=, end=}
if type(params) == "string" then
print("get_events: backward compat path, end_date=" .. tostring(end_date))
local e = end_date
if type(e) ~= "string" then e = nil end
return M.get_events{ start_date = params, ["end"] = e or params }
end
local start_date = params.start_date or params.start
if type(start_date) ~= "string" then print("WARN get_events start_date not string: " .. type(start_date)); start_date = tostring(start_date) end
local end_date = params["end"] or params.end_date or params.end_date
if type(end_date) ~= "string" then print("WARN get_events end_date not string: " .. type(end_date)); end_date = start_date end
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(params)
local date_str = params.date or params
if type(date_str) ~= "string" then date_str = tostring(date_str) end
return M.get_events({ start_date = date_str .. "T00:00:00", end_date = date_str .. "T23:59:59" })
end
-- Get event by ID
function M.get_event(params)
local id = params.id or params
if not id then error("event id required") end
return verstak.db.query_row(
"SELECT * FROM events WHERE id = ?", id
)
end
-- Create a single event (base event for recurrences)
-- Already accepts a single table — no change needed
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(params, fields)
-- Backward compat: support positional (id, fields) and table { id = ..., ... }
if type(params) == "string" then
local t = { id = params }
if fields then
for k, v in pairs(fields) do t[k] = v end
end
return M.update_event(t)
end
local id = params.id
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 set_clauses = {}
local sql_params = {}
-- 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(params) do
if k ~= "id" and 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(sql_params, v)
end
end
table.insert(sql_params, now())
table.insert(sql_params, id)
if #set_clauses > 0 then
verstak.db.exec(
"UPDATE events SET " .. table.concat(set_clauses, ", ") .. ", updated_at = ? WHERE id = ?",
unpack(sql_params)
)
end
safe_log(verstak.activity.log,"event_updated",
"Событие обновлено: " .. (params.title or old.title or id), id, old.node_id or "")
return true
end
-- Delete an event
function M.delete_event(params)
local id = params.id or params
if not id or type(id) ~= "string" 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(params)
local start_date = params.start_date or params.start
local end_date = params.end_date or params["end"]
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(params)
-- 1. Normal events
local normal = M.get_events(params)
-- 2. Expanded recurring
local recur = M.get_expanded_events(params)
-- 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")