769 lines
25 KiB
Lua
769 lines
25 KiB
Lua
--[[
|
||
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)
|
||
-- Backward compat: support positional (start, end) and table {start=, end=}
|
||
if type(params) == "string" then
|
||
return M.get_events{ start_date = params, ["end"] = end_date or params }
|
||
end
|
||
local start_date = params.start_date or params.start
|
||
local end_date = params["end"] or params.end_date or params.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(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")
|