pending.nvim/lua/pending/store.lua
Barrett Ruth 7c3ba31c43
feat: add cancelled task status with configurable state chars (#158)
Problem: the task lifecycle only has `pending`, `wip`, `blocked`, and
`done`. There is no way to mark a task as abandoned. Additionally,
state characters (`>`, `=`) are hardcoded rather than reading from
`config.icons`, so customizing them has no effect on rendering or
parsing.

Solution: add a `cancelled` status with default state char `c`, `g/`
keymap, `PendingCancelled` highlight, filter predicate, and archive
support. Unify state chars by making `state_char()`, `parse_buffer()`,
and `infer_status()` read from `config.icons`. Change defaults to
mnemonic chars: `w` (wip), `b` (blocked), `c` (cancelled).
2026-03-12 20:55:21 -04:00

428 lines
9.3 KiB
Lua

local config = require('pending.config')
---@alias pending.TaskStatus 'pending'|'done'|'deleted'|'wip'|'blocked'|'cancelled'
---@alias pending.RecurMode 'scheduled'|'completion'
---@class pending.TaskExtra
---@field _forge_ref? pending.ForgeRef
---@field _forge_cache? pending.ForgeCache
---@field _gtasks_task_id? string
---@field _gtasks_list_id? string
---@field _gcal_event_id? string
---@field _gcal_calendar_id? string
---@field [string] any
---@class pending.Task
---@field id integer
---@field description string
---@field status pending.TaskStatus
---@field category? string
---@field priority integer
---@field due? string
---@field recur? string
---@field recur_mode? pending.RecurMode
---@field entry string
---@field modified string
---@field end? string
---@field order integer
---@field _extra? pending.TaskExtra
---@class pending.Data
---@field version integer
---@field next_id integer
---@field tasks pending.Task[]
---@field undo pending.Task[][]
---@field folded_categories string[]
---@class pending.TaskFields
---@field description string
---@field status? pending.TaskStatus
---@field category? string
---@field priority? integer
---@field due? string
---@field recur? string
---@field recur_mode? pending.RecurMode
---@field order? integer
---@field _extra? pending.TaskExtra
---@class pending.Store
---@field path string
---@field _data pending.Data?
local Store = {}
Store.__index = Store
---@class pending.store
local M = {}
local SUPPORTED_VERSION = 1
---@return pending.Data
local function empty_data()
return {
version = SUPPORTED_VERSION,
next_id = 1,
tasks = {},
undo = {},
folded_categories = {},
}
end
---@param path string
local function ensure_dir(path)
local dir = vim.fn.fnamemodify(path, ':h')
if vim.fn.isdirectory(dir) == 0 then
vim.fn.mkdir(dir, 'p')
end
end
---@return string
local function timestamp()
return os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]]
end
---@type table<string, true>
local known_fields = {
id = true,
description = true,
status = true,
category = true,
priority = true,
due = true,
recur = true,
recur_mode = true,
entry = true,
modified = true,
['end'] = true,
order = true,
}
---@param task pending.Task
---@return table
local function task_to_table(task)
local t = {
id = task.id,
description = task.description,
status = task.status,
entry = task.entry,
modified = task.modified,
}
if task.category then
t.category = task.category
end
if task.priority and task.priority ~= 0 then
t.priority = task.priority
end
if task.due then
t.due = task.due
end
if task.recur then
t.recur = task.recur
end
if task.recur_mode then
t.recur_mode = task.recur_mode
end
if task['end'] then
t['end'] = task['end']
end
if task.order and task.order ~= 0 then
t.order = task.order
end
if task._extra then
for k, v in pairs(task._extra) do
t[k] = v
end
end
return t
end
---@param t table
---@return pending.Task
local function table_to_task(t)
local task = {
id = t.id,
description = t.description,
status = t.status or 'pending',
category = t.category,
priority = t.priority or 0,
due = t.due,
recur = t.recur,
recur_mode = t.recur_mode,
entry = t.entry,
modified = t.modified,
['end'] = t['end'],
order = t.order or 0,
_extra = {},
}
for k, v in pairs(t) do
if not known_fields[k] then
task._extra[k] = v
end
end
if next(task._extra) == nil then
task._extra = nil
end
return task
end
---@return pending.Data
function Store:load()
local path = self.path
local f = io.open(path, 'r')
if not f then
self._data = empty_data()
return self._data
end
local content = f:read('*a')
f:close()
if content == '' then
self._data = empty_data()
return self._data
end
local ok, decoded = pcall(vim.json.decode, content)
if not ok then
error('pending.nvim: failed to parse ' .. path .. ': ' .. tostring(decoded))
end
if decoded.version and decoded.version > SUPPORTED_VERSION then
error(
'pending.nvim: data file version '
.. decoded.version
.. ' is newer than supported version '
.. SUPPORTED_VERSION
.. '. Please update the plugin.'
)
end
self._data = {
version = decoded.version or SUPPORTED_VERSION,
next_id = decoded.next_id or 1,
tasks = {},
undo = {},
folded_categories = decoded.folded_categories or {},
}
for _, t in ipairs(decoded.tasks or {}) do
table.insert(self._data.tasks, table_to_task(t))
end
for _, snapshot in ipairs(decoded.undo or {}) do
if type(snapshot) == 'table' then
local tasks = {}
for _, raw in ipairs(snapshot) do
table.insert(tasks, table_to_task(raw))
end
table.insert(self._data.undo, tasks)
end
end
return self._data
end
---@return nil
function Store:save()
if not self._data then
return
end
local path = self.path
ensure_dir(path)
local out = {
version = self._data.version,
next_id = self._data.next_id,
tasks = {},
undo = {},
folded_categories = self._data.folded_categories,
}
for _, task in ipairs(self._data.tasks) do
table.insert(out.tasks, task_to_table(task))
end
for _, snapshot in ipairs(self._data.undo) do
local serialized = {}
for _, task in ipairs(snapshot) do
table.insert(serialized, task_to_table(task))
end
table.insert(out.undo, serialized)
end
local encoded = vim.json.encode(out)
local tmp = path .. '.tmp'
local f = io.open(tmp, 'w')
if not f then
error('pending.nvim: cannot write to ' .. tmp)
end
f:write(encoded)
f:close()
local ok, rename_err = os.rename(tmp, path)
if not ok then
os.remove(tmp)
error('pending.nvim: cannot rename ' .. tmp .. ' to ' .. path .. ': ' .. tostring(rename_err))
end
end
---@return pending.Data
function Store:data()
if not self._data then
self:load()
end
return self._data --[[@as pending.Data]]
end
---@return pending.Task[]
function Store:tasks()
return self:data().tasks
end
---@return pending.Task[]
function Store:active_tasks()
local result = {}
for _, task in ipairs(self:tasks()) do
if task.status ~= 'deleted' then
table.insert(result, task)
end
end
return result
end
---@param id integer
---@return pending.Task?
function Store:get(id)
for _, task in ipairs(self:tasks()) do
if task.id == id then
return task
end
end
return nil
end
---@param fields pending.TaskFields
---@return pending.Task
function Store:add(fields)
local data = self:data()
local now = timestamp()
local task = {
id = data.next_id,
description = fields.description,
status = fields.status or 'pending',
category = fields.category or config.get().default_category,
priority = fields.priority or 0,
due = fields.due,
recur = fields.recur,
recur_mode = fields.recur_mode,
entry = now,
modified = now,
['end'] = nil,
order = fields.order or 0,
_extra = fields._extra,
}
data.next_id = data.next_id + 1
table.insert(data.tasks, task)
return task
end
---@param id integer
---@param fields table<string, any>
---@return pending.Task?
function Store:update(id, fields)
local task = self:get(id)
if not task then
return nil
end
local now = timestamp()
for k, v in pairs(fields) do
if k ~= 'id' and k ~= 'entry' then
if v == vim.NIL then
task[k] = nil
else
task[k] = v
end
end
end
task.modified = now
if fields.status == 'done' or fields.status == 'deleted' or fields.status == 'cancelled' then
task['end'] = task['end'] or now
end
return task
end
---@param id integer
---@return pending.Task?
function Store:delete(id)
return self:update(id, { status = 'deleted', ['end'] = timestamp() })
end
---@param id integer
---@return integer?
function Store:find_index(id)
for i, task in ipairs(self:tasks()) do
if task.id == id then
return i
end
end
return nil
end
---@param tasks pending.Task[]
---@return nil
function Store:replace_tasks(tasks)
self:data().tasks = tasks
end
---@return pending.Task[]
function Store:snapshot()
local result = {}
for _, task in ipairs(self:active_tasks()) do
local copy = {}
for k, v in pairs(task) do
if k ~= '_extra' then
copy[k] = v
end
end
if task._extra then
copy._extra = {}
for k, v in pairs(task._extra) do
copy._extra[k] = v
end
end
table.insert(result, copy --[[@as pending.Task]])
end
return result
end
---@return pending.Task[][]
function Store:undo_stack()
return self:data().undo
end
---@param stack pending.Task[][]
---@return nil
function Store:set_undo_stack(stack)
self:data().undo = stack
end
---@param id integer
---@return nil
function Store:set_next_id(id)
self:data().next_id = id
end
---@return string[]
function Store:get_folded_categories()
return self:data().folded_categories
end
---@param cats string[]
---@return nil
function Store:set_folded_categories(cats)
self:data().folded_categories = cats
end
---@return nil
function Store:unload()
self._data = nil
end
---@param path string
---@return pending.Store
function M.new(path)
return setmetatable({ path = path, _data = nil }, Store)
end
---@return string
function M.resolve_path()
return config.get().data_path
end
return M