pending.nvim/lua/pending/store.lua
Barrett Ruth 85cf0d42ed feat: :Pending edit command for CLI metadata editing (#41)
* 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
2026-02-26 16:34:07 -05:00

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