feat: плагин-система Lua + Calendar reference plugin
- Lua VM runtime: gopher-lua с песочницей, хуки on_init/on_tick/on_shutdown - API: verstak.node.* / verstak.db.* / verstak.config.* / verstak.state.* - API: verstak.worklog.* / verstak.activity.* / verstak.file.* - API: verstak.schedule.* / verstak.http.* / verstak.ui.* - Менеджер плагинов: жизненный цикл, инициализация, шаблоны - Scheduler: фоновые задачи с интервалами - PluginPage.svelte: контейнер для iframe-панелей плагинов - Calendar plugin: миграция, категории CRUD, события CRUD - Calendar: расширенный рекарренс (daily/weekly/monthly/yearly) - Calendar: связь с узлами Верстака, напоминания, HTTP-праздники - Calendar: Lua-тест-сьют (15 тестов), Go-интеграционный тест - fix: query_row использует реальные Column() вместо guessColumns
This commit is contained in:
@@ -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")
|
||||
@@ -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'))
|
||||
);
|
||||
@@ -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);
|
||||
@@ -0,0 +1,145 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Календарь</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #1a1b1e; color: #e4e4e7; padding: 20px; }
|
||||
h1 { font-size: 24px; margin-bottom: 16px; color: #fff; }
|
||||
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||
.nav-btn { background: #27272a; border: 1px solid #3f3f46; color: #e4e4e7;
|
||||
padding: 8px 16px; border-radius: 6px; cursor: pointer; font-size: 14px; }
|
||||
.nav-btn:hover { background: #3f3f46; }
|
||||
.calendar-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; }
|
||||
.day-header { text-align: center; font-size: 12px; color: #a1a1aa; padding: 8px 0;
|
||||
font-weight: 600; text-transform: uppercase; }
|
||||
.day { aspect-ratio: 1; display: flex; flex-direction: column; align-items: center;
|
||||
justify-content: center; border-radius: 8px; cursor: pointer; font-size: 14px;
|
||||
background: #27272a; border: 1px solid #3f3f46; position: relative; }
|
||||
.day:hover { background: #3f3f46; }
|
||||
.day.other { opacity: 0.3; }
|
||||
.day.today { border-color: #3b82f6; background: #1e3a5f; }
|
||||
.day.has-event::after { content: ''; position: absolute; bottom: 6px; width: 6px; height: 6px;
|
||||
border-radius: 50%; background: #3b82f6; }
|
||||
.day.selected { border-color: #8b5cf6; box-shadow: 0 0 0 2px #8b5cf6; }
|
||||
.events { margin-top: 20px; }
|
||||
.event { padding: 12px; background: #27272a; border: 1px solid #3f3f46;
|
||||
border-radius: 8px; margin-bottom: 8px; display: flex; justify-content: space-between; }
|
||||
.event-title { font-weight: 500; }
|
||||
.event-time { color: #a1a1aa; font-size: 13px; }
|
||||
.event-actions { display: flex; gap: 8px; }
|
||||
.event-actions button { background: none; border: none; color: #a1a1aa;
|
||||
cursor: pointer; padding: 2px 6px; border-radius: 4px; }
|
||||
.event-actions button:hover { background: #3f3f46; color: #ef4444; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<button class="nav-btn" onclick="prevMonth()">←</button>
|
||||
<h1 id="month-title">Июнь 2025</h1>
|
||||
<button class="nav-btn" onclick="nextMonth()">→</button>
|
||||
</div>
|
||||
<div class="calendar-grid" id="calendar"></div>
|
||||
<div class="events" id="events"></div>
|
||||
|
||||
<script>
|
||||
const MONTHS = ['Январь','Февраль','Март','Апрель','Май','Июнь',
|
||||
'Июль','Август','Сентябрь','Октябрь','Ноябрь','Декабрь'];
|
||||
const DAYS = ['Пн','Вт','Ср','Чт','Пт','Сб','Вс'];
|
||||
|
||||
let currentDate = new Date();
|
||||
let selectedDate = null;
|
||||
let events = [];
|
||||
|
||||
function render() {
|
||||
const year = currentDate.getFullYear();
|
||||
const month = currentDate.getMonth();
|
||||
document.getElementById('month-title').textContent = MONTHS[month] + ' ' + year;
|
||||
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
const startDay = firstDay.getDay() || 7; // Mon=1, Sun=7
|
||||
|
||||
const grid = document.getElementById('calendar');
|
||||
grid.innerHTML = DAYS.map(d => `<div class="day-header">${d}</div>`).join('');
|
||||
|
||||
// Previous month padding
|
||||
const prevLastDay = new Date(year, month, 0).getDate();
|
||||
for (let i = startDay - 2; i >= 0; i--) {
|
||||
grid.innerHTML += `<div class="day other">${prevLastDay - i}</div>`;
|
||||
}
|
||||
|
||||
// Current month
|
||||
const today = new Date();
|
||||
for (let d = 1; d <= lastDay.getDate(); d++) {
|
||||
const dateStr = `${year}-${String(month+1).padStart(2,'0')}-${String(d).padStart(2,'0')}`;
|
||||
const isToday = today.getFullYear() === year && today.getMonth() === month && today.getDate() === d;
|
||||
const hasEvent = events.some(e => e.date === dateStr);
|
||||
const isSelected = selectedDate === dateStr;
|
||||
let cls = 'day';
|
||||
if (isToday) cls += ' today';
|
||||
if (hasEvent) cls += ' has-event';
|
||||
if (isSelected) cls += ' selected';
|
||||
grid.innerHTML += `<div class="${cls}" onclick="selectDate('${dateStr}')">${d}</div>`;
|
||||
}
|
||||
|
||||
renderEvents();
|
||||
}
|
||||
|
||||
function renderEvents() {
|
||||
const el = document.getElementById('events');
|
||||
if (!selectedDate) { el.innerHTML = '<p style="color:#a1a1aa">Выберите дату</p>'; return; }
|
||||
|
||||
const dayEvents = events.filter(e => e.date === selectedDate);
|
||||
if (dayEvents.length === 0) {
|
||||
el.innerHTML = '<p style="color:#a1a1aa">Нет событий на этот день</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
el.innerHTML = '<h2 style="font-size:18px;margin-bottom:12px">События</h2>';
|
||||
dayEvents.forEach(ev => {
|
||||
const time = ev.time || 'весь день';
|
||||
el.innerHTML += `
|
||||
<div class="event">
|
||||
<div>
|
||||
<div class="event-title">${ev.title}</div>
|
||||
<div class="event-time">${time}${ev.location ? ' · ' + ev.location : ''}</div>
|
||||
</div>
|
||||
<div class="event-actions">
|
||||
<button onclick="deleteEvent('${ev.id}')">✕</button>
|
||||
</div>
|
||||
</div>`;
|
||||
});
|
||||
}
|
||||
|
||||
function selectDate(dateStr) {
|
||||
selectedDate = dateStr;
|
||||
render();
|
||||
}
|
||||
|
||||
function prevMonth() {
|
||||
currentDate.setMonth(currentDate.getMonth() - 1);
|
||||
selectedDate = null;
|
||||
render();
|
||||
}
|
||||
|
||||
function nextMonth() {
|
||||
currentDate.setMonth(currentDate.getMonth() + 1);
|
||||
selectedDate = null;
|
||||
render();
|
||||
}
|
||||
|
||||
async function loadEvents() {
|
||||
// In production, this would call verstak HTTP endpoint or Wails binding
|
||||
// For now, data is injected via __VERSTAK_DATA__
|
||||
events = window.__VERSTAK_DATA__ || [];
|
||||
render();
|
||||
}
|
||||
|
||||
loadEvents();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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("========================")
|
||||
Reference in New Issue
Block a user