* feat: :Pending edit command for CLI metadata editing
Problem: editing task metadata (due date, category, priority,
recurrence) requires opening the buffer and editing inline. No way
to make quick metadata changes from the command line.
Solution: add :Pending edit {id} [operations...] command that applies
metadata changes by numeric task ID. Supports due:<date>, cat:<name>,
rec:<pattern>, +!, -!, -due, -cat, -rec operations with full date
vocabulary and recurrence validation. Pushes to undo stack, re-renders
the buffer if open, and provides feedback messages. Tab completion for
IDs, field names, date vocabulary, categories, and recurrence patterns.
Also fixes store.update() to properly clear fields set to vim.NIL.
* ci: formt
376 lines
7.9 KiB
Lua
376 lines
7.9 KiB
Lua
local config = require('pending.config')
|
|
|
|
---@class pending.Task
|
|
---@field id integer
|
|
---@field description string
|
|
---@field status 'pending'|'done'|'deleted'
|
|
---@field category? string
|
|
---@field priority integer
|
|
---@field due? string
|
|
---@field recur? string
|
|
---@field recur_mode? 'scheduled'|'completion'
|
|
---@field entry string
|
|
---@field modified string
|
|
---@field end? string
|
|
---@field order integer
|
|
---@field _extra? table<string, any>
|
|
|
|
---@class pending.Data
|
|
---@field version integer
|
|
---@field next_id integer
|
|
---@field tasks pending.Task[]
|
|
---@field undo pending.Task[][]
|
|
|
|
---@class pending.store
|
|
local M = {}
|
|
|
|
local SUPPORTED_VERSION = 1
|
|
|
|
---@type pending.Data?
|
|
local _data = nil
|
|
|
|
---@return pending.Data
|
|
local function empty_data()
|
|
return {
|
|
version = SUPPORTED_VERSION,
|
|
next_id = 1,
|
|
tasks = {},
|
|
undo = {},
|
|
}
|
|
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 M.load()
|
|
local path = config.get().data_path
|
|
local f = io.open(path, 'r')
|
|
if not f then
|
|
_data = empty_data()
|
|
return _data
|
|
end
|
|
local content = f:read('*a')
|
|
f:close()
|
|
if content == '' then
|
|
_data = empty_data()
|
|
return _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
|
|
_data = {
|
|
version = decoded.version or SUPPORTED_VERSION,
|
|
next_id = decoded.next_id or 1,
|
|
tasks = {},
|
|
undo = {},
|
|
}
|
|
for _, t in ipairs(decoded.tasks or {}) do
|
|
table.insert(_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(_data.undo, tasks)
|
|
end
|
|
end
|
|
return _data
|
|
end
|
|
|
|
---@return nil
|
|
function M.save()
|
|
if not _data then
|
|
return
|
|
end
|
|
local path = config.get().data_path
|
|
ensure_dir(path)
|
|
local out = {
|
|
version = _data.version,
|
|
next_id = _data.next_id,
|
|
tasks = {},
|
|
undo = {},
|
|
}
|
|
for _, task in ipairs(_data.tasks) do
|
|
table.insert(out.tasks, task_to_table(task))
|
|
end
|
|
for _, snapshot in ipairs(_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 M.data()
|
|
if not _data then
|
|
M.load()
|
|
end
|
|
return _data --[[@as pending.Data]]
|
|
end
|
|
|
|
---@return pending.Task[]
|
|
function M.tasks()
|
|
return M.data().tasks
|
|
end
|
|
|
|
---@return pending.Task[]
|
|
function M.active_tasks()
|
|
local result = {}
|
|
for _, task in ipairs(M.tasks()) do
|
|
if task.status ~= 'deleted' then
|
|
table.insert(result, task)
|
|
end
|
|
end
|
|
return result
|
|
end
|
|
|
|
---@param id integer
|
|
---@return pending.Task?
|
|
function M.get(id)
|
|
for _, task in ipairs(M.tasks()) do
|
|
if task.id == id then
|
|
return task
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
---@param fields { description: string, status?: string, category?: string, priority?: integer, due?: string, recur?: string, recur_mode?: string, order?: integer, _extra?: table }
|
|
---@return pending.Task
|
|
function M.add(fields)
|
|
local data = M.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 M.update(id, fields)
|
|
local task = M.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' then
|
|
task['end'] = task['end'] or now
|
|
end
|
|
return task
|
|
end
|
|
|
|
---@param id integer
|
|
---@return pending.Task?
|
|
function M.delete(id)
|
|
return M.update(id, { status = 'deleted', ['end'] = timestamp() })
|
|
end
|
|
|
|
---@param id integer
|
|
---@return integer?
|
|
function M.find_index(id)
|
|
for i, task in ipairs(M.tasks()) do
|
|
if task.id == id then
|
|
return i
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
---@param tasks pending.Task[]
|
|
---@return nil
|
|
function M.replace_tasks(tasks)
|
|
M.data().tasks = tasks
|
|
end
|
|
|
|
---@return pending.Task[]
|
|
function M.snapshot()
|
|
local result = {}
|
|
for _, task in ipairs(M.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 M.undo_stack()
|
|
return M.data().undo
|
|
end
|
|
|
|
---@param stack pending.Task[][]
|
|
---@return nil
|
|
function M.set_undo_stack(stack)
|
|
M.data().undo = stack
|
|
end
|
|
|
|
---@param id integer
|
|
---@return nil
|
|
function M.set_next_id(id)
|
|
M.data().next_id = id
|
|
end
|
|
|
|
---@return nil
|
|
function M.unload()
|
|
_data = nil
|
|
end
|
|
|
|
return M
|