Compare commits

..

No commits in common. "1f4a43fc0199c219fbdbcc43cfc513bc803fe222" and "c57cc0845bf0ff1330ae26e4137124cd58490663" have entirely different histories.

4 changed files with 5 additions and 644 deletions

View file

@ -390,178 +390,6 @@ function M.due()
vim.cmd('copen')
end
---@param token string
---@return string|nil field
---@return any value
---@return string|nil err
local function parse_edit_token(token)
local recur = require('pending.recur')
local cfg = require('pending.config').get()
local dk = cfg.date_syntax or 'due'
local rk = cfg.recur_syntax or 'rec'
if token == '+!' then
return 'priority', 1, nil
end
if token == '-!' then
return 'priority', 0, nil
end
if token == '-due' or token == '-' .. dk then
return 'due', vim.NIL, nil
end
if token == '-cat' then
return 'category', vim.NIL, nil
end
if token == '-rec' or token == '-' .. rk then
return 'recur', vim.NIL, nil
end
local due_val = token:match('^' .. vim.pesc(dk) .. ':(.+)$')
if due_val then
local resolved = parse.resolve_date(due_val)
if resolved then
return 'due', resolved, nil
end
if
due_val:match('^%d%d%d%d%-%d%d%-%d%d$') or due_val:match('^%d%d%d%d%-%d%d%-%d%dT%d%d:%d%d$')
then
return 'due', due_val, nil
end
return nil,
nil,
'Invalid date: ' .. due_val .. '. Use YYYY-MM-DD, today, tomorrow, +Nd, weekday names, etc.'
end
local cat_val = token:match('^cat:(.+)$')
if cat_val then
return 'category', cat_val, nil
end
local rec_val = token:match('^' .. vim.pesc(rk) .. ':(.+)$')
if rec_val then
local raw_spec = rec_val
local rec_mode = nil
if raw_spec:sub(1, 1) == '!' then
rec_mode = 'completion'
raw_spec = raw_spec:sub(2)
end
if not recur.validate(raw_spec) then
return nil, nil, 'Invalid recurrence pattern: ' .. rec_val
end
return 'recur', { spec = raw_spec, mode = rec_mode }, nil
end
return nil,
nil,
'Unknown operation: '
.. token
.. '. Valid: '
.. dk
.. ':<date>, cat:<name>, '
.. rk
.. ':<pattern>, +!, -!, -'
.. dk
.. ', -cat, -'
.. rk
end
---@param id_str string
---@param rest string
---@return nil
function M.edit(id_str, rest)
if not id_str or id_str == '' then
vim.notify(
'Usage: :Pending edit <id> [due:<date>] [cat:<name>] [rec:<pattern>] [+!] [-!] [-due] [-cat] [-rec]',
vim.log.levels.ERROR
)
return
end
local id = tonumber(id_str)
if not id then
vim.notify('Invalid task ID: ' .. id_str, vim.log.levels.ERROR)
return
end
store.load()
local task = store.get(id)
if not task then
vim.notify('No task with ID ' .. id .. '.', vim.log.levels.ERROR)
return
end
if not rest or rest == '' then
vim.notify(
'Usage: :Pending edit <id> [due:<date>] [cat:<name>] [rec:<pattern>] [+!] [-!] [-due] [-cat] [-rec]',
vim.log.levels.ERROR
)
return
end
local tokens = {}
for tok in rest:gmatch('%S+') do
table.insert(tokens, tok)
end
local updates = {}
local feedback = {}
for _, tok in ipairs(tokens) do
local field, value, err = parse_edit_token(tok)
if err then
vim.notify(err, vim.log.levels.ERROR)
return
end
if field == 'recur' then
if value == vim.NIL then
updates.recur = vim.NIL
updates.recur_mode = vim.NIL
table.insert(feedback, 'recurrence removed')
else
updates.recur = value.spec
updates.recur_mode = value.mode
table.insert(feedback, 'recurrence set to ' .. value.spec)
end
elseif field == 'due' then
if value == vim.NIL then
updates.due = vim.NIL
table.insert(feedback, 'due date removed')
else
updates.due = value
table.insert(feedback, 'due date set to ' .. tostring(value))
end
elseif field == 'category' then
if value == vim.NIL then
updates.category = vim.NIL
table.insert(feedback, 'category removed')
else
updates.category = value
table.insert(feedback, 'category set to ' .. tostring(value))
end
elseif field == 'priority' then
updates.priority = value
table.insert(feedback, value == 1 and 'priority added' or 'priority removed')
end
end
local snapshot = store.snapshot()
local stack = store.undo_stack()
table.insert(stack, snapshot)
if #stack > UNDO_MAX then
table.remove(stack, 1)
end
store.update(id, updates)
store.save()
local bufnr = buffer.bufnr()
if bufnr and vim.api.nvim_buf_is_valid(bufnr) then
buffer.render(bufnr)
end
vim.notify('Task #' .. id .. ' updated: ' .. table.concat(feedback, ', '))
end
---@param args string
---@return nil
function M.command(args)
@ -572,9 +400,6 @@ function M.command(args)
local cmd, rest = args:match('^(%S+)%s*(.*)')
if cmd == 'add' then
M.add(rest)
elseif cmd == 'edit' then
local id_str, edit_rest = rest:match('^(%S+)%s*(.*)')
M.edit(id_str, edit_rest)
elseif cmd == 'sync' then
M.sync()
elseif cmd == 'archive' then

View file

@ -293,11 +293,7 @@ function M.update(id, fields)
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
task[k] = v
end
end
task.modified = now

View file

@ -3,173 +3,17 @@ if vim.g.loaded_pending then
end
vim.g.loaded_pending = true
---@return string[]
local function edit_field_candidates()
local cfg = require('pending.config').get()
local dk = cfg.date_syntax or 'due'
local rk = cfg.recur_syntax or 'rec'
return {
dk .. ':',
'cat:',
rk .. ':',
'+!',
'-!',
'-' .. dk,
'-cat',
'-' .. rk,
}
end
---@return string[]
local function edit_date_values()
return {
'today',
'tomorrow',
'yesterday',
'+1d',
'+2d',
'+3d',
'+1w',
'+2w',
'+1m',
'mon',
'tue',
'wed',
'thu',
'fri',
'sat',
'sun',
'eod',
'eow',
'eom',
'eoq',
'eoy',
'sow',
'som',
'soq',
'soy',
'later',
}
end
---@return string[]
local function edit_recur_values()
local ok, recur = pcall(require, 'pending.recur')
if not ok then
return {}
end
local result = {}
for _, s in ipairs(recur.shorthand_list()) do
table.insert(result, s)
end
for _, s in ipairs(recur.shorthand_list()) do
table.insert(result, '!' .. s)
end
return result
end
---@param lead string
---@param candidates string[]
---@return string[]
local function filter_candidates(lead, candidates)
return vim.tbl_filter(function(s)
return s:find(lead, 1, true) == 1
end, candidates)
end
---@param arg_lead string
---@param cmd_line string
---@return string[]
local function complete_edit(arg_lead, cmd_line)
local cfg = require('pending.config').get()
local dk = cfg.date_syntax or 'due'
local rk = cfg.recur_syntax or 'rec'
local after_edit = cmd_line:match('^Pending%s+edit%s+(.*)')
if not after_edit then
return {}
end
local parts = {}
for part in after_edit:gmatch('%S+') do
table.insert(parts, part)
end
local trailing_space = after_edit:match('%s$')
if #parts == 0 or (#parts == 1 and not trailing_space) then
local store = require('pending.store')
store.load()
local ids = {}
for _, task in ipairs(store.active_tasks()) do
table.insert(ids, tostring(task.id))
end
return filter_candidates(arg_lead, ids)
end
local prefix = arg_lead:match('^(' .. vim.pesc(dk) .. ':)(.*)$')
if prefix then
local after_colon = arg_lead:sub(#prefix + 1)
local dates = edit_date_values()
local result = {}
for _, d in ipairs(dates) do
if d:find(after_colon, 1, true) == 1 then
table.insert(result, prefix .. d)
end
end
return result
end
local rec_prefix = arg_lead:match('^(' .. vim.pesc(rk) .. ':)(.*)$')
if rec_prefix then
local after_colon = arg_lead:sub(#rec_prefix + 1)
local pats = edit_recur_values()
local result = {}
for _, p in ipairs(pats) do
if p:find(after_colon, 1, true) == 1 then
table.insert(result, rec_prefix .. p)
end
end
return result
end
local cat_prefix = arg_lead:match('^(cat:)(.*)$')
if cat_prefix then
local after_colon = arg_lead:sub(#cat_prefix + 1)
local store = require('pending.store')
store.load()
local seen = {}
local cats = {}
for _, task in ipairs(store.active_tasks()) do
if task.category and not seen[task.category] then
seen[task.category] = true
table.insert(cats, task.category)
end
end
table.sort(cats)
local result = {}
for _, c in ipairs(cats) do
if c:find(after_colon, 1, true) == 1 then
table.insert(result, cat_prefix .. c)
end
end
return result
end
return filter_candidates(arg_lead, edit_field_candidates())
end
vim.api.nvim_create_user_command('Pending', function(opts)
require('pending').command(opts.args)
end, {
bar = true,
nargs = '*',
complete = function(arg_lead, cmd_line)
local subcmds = { 'add', 'archive', 'due', 'edit', 'sync', 'undo' }
local subcmds = { 'add', 'sync', 'archive', 'due', 'undo' }
if not cmd_line:match('^Pending%s+%S') then
return filter_candidates(arg_lead, subcmds)
end
if cmd_line:match('^Pending%s+edit') then
return complete_edit(arg_lead, cmd_line)
return vim.tbl_filter(function(s)
return s:find(arg_lead, 1, true) == 1
end, subcmds)
end
return {}
end,

View file

@ -1,304 +0,0 @@
require('spec.helpers')
local config = require('pending.config')
local store = require('pending.store')
describe('edit', function()
local tmpdir
local pending = require('pending')
before_each(function()
tmpdir = vim.fn.tempname()
vim.fn.mkdir(tmpdir, 'p')
vim.g.pending = { data_path = tmpdir .. '/tasks.json' }
config.reset()
store.unload()
store.load()
end)
after_each(function()
vim.fn.delete(tmpdir, 'rf')
vim.g.pending = nil
config.reset()
end)
it('sets due date with resolve_date vocabulary', function()
local t = store.add({ description = 'Task one' })
store.save()
pending.edit(tostring(t.id), 'due:tomorrow')
local updated = store.get(t.id)
local today = os.date('*t') --[[@as osdate]]
local expected =
os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day + 1 }))
assert.are.equal(expected, updated.due)
end)
it('sets due date with literal YYYY-MM-DD', function()
local t = store.add({ description = 'Task one' })
store.save()
pending.edit(tostring(t.id), 'due:2026-06-15')
local updated = store.get(t.id)
assert.are.equal('2026-06-15', updated.due)
end)
it('sets category', function()
local t = store.add({ description = 'Task one' })
store.save()
pending.edit(tostring(t.id), 'cat:Work')
local updated = store.get(t.id)
assert.are.equal('Work', updated.category)
end)
it('adds priority', function()
local t = store.add({ description = 'Task one' })
store.save()
pending.edit(tostring(t.id), '+!')
local updated = store.get(t.id)
assert.are.equal(1, updated.priority)
end)
it('removes priority', function()
local t = store.add({ description = 'Task one', priority = 1 })
store.save()
pending.edit(tostring(t.id), '-!')
local updated = store.get(t.id)
assert.are.equal(0, updated.priority)
end)
it('removes due date', function()
local t = store.add({ description = 'Task one', due = '2026-06-15' })
store.save()
pending.edit(tostring(t.id), '-due')
local updated = store.get(t.id)
assert.is_nil(updated.due)
end)
it('removes category', function()
local t = store.add({ description = 'Task one', category = 'Work' })
store.save()
pending.edit(tostring(t.id), '-cat')
local updated = store.get(t.id)
assert.is_nil(updated.category)
end)
it('sets recurrence', function()
local t = store.add({ description = 'Task one' })
store.save()
pending.edit(tostring(t.id), 'rec:weekly')
local updated = store.get(t.id)
assert.are.equal('weekly', updated.recur)
assert.is_nil(updated.recur_mode)
end)
it('sets completion-based recurrence', function()
local t = store.add({ description = 'Task one' })
store.save()
pending.edit(tostring(t.id), 'rec:!daily')
local updated = store.get(t.id)
assert.are.equal('daily', updated.recur)
assert.are.equal('completion', updated.recur_mode)
end)
it('removes recurrence', function()
local t = store.add({ description = 'Task one', recur = 'weekly', recur_mode = 'scheduled' })
store.save()
pending.edit(tostring(t.id), '-rec')
local updated = store.get(t.id)
assert.is_nil(updated.recur)
assert.is_nil(updated.recur_mode)
end)
it('applies multiple operations at once', function()
local t = store.add({ description = 'Task one' })
store.save()
pending.edit(tostring(t.id), 'due:today cat:Errands +!')
local updated = store.get(t.id)
assert.are.equal(os.date('%Y-%m-%d'), updated.due)
assert.are.equal('Errands', updated.category)
assert.are.equal(1, updated.priority)
end)
it('pushes to undo stack', function()
local t = store.add({ description = 'Task one' })
store.save()
local stack_before = #store.undo_stack()
pending.edit(tostring(t.id), 'cat:Work')
assert.are.equal(stack_before + 1, #store.undo_stack())
end)
it('persists changes to disk', function()
local t = store.add({ description = 'Task one' })
store.save()
pending.edit(tostring(t.id), 'cat:Work')
store.unload()
store.load()
local updated = store.get(t.id)
assert.are.equal('Work', updated.category)
end)
it('errors on unknown task ID', function()
store.add({ description = 'Task one' })
store.save()
local messages = {}
local orig_notify = vim.notify
vim.notify = function(msg, level)
table.insert(messages, { msg = msg, level = level })
end
pending.edit('999', 'cat:Work')
vim.notify = orig_notify
assert.are.equal(1, #messages)
assert.truthy(messages[1].msg:find('No task with ID 999'))
assert.are.equal(vim.log.levels.ERROR, messages[1].level)
end)
it('errors on invalid date', function()
local t = store.add({ description = 'Task one' })
store.save()
local messages = {}
local orig_notify = vim.notify
vim.notify = function(msg, level)
table.insert(messages, { msg = msg, level = level })
end
pending.edit(tostring(t.id), 'due:notadate')
vim.notify = orig_notify
assert.are.equal(1, #messages)
assert.truthy(messages[1].msg:find('Invalid date'))
assert.are.equal(vim.log.levels.ERROR, messages[1].level)
end)
it('errors on unknown operation token', function()
local t = store.add({ description = 'Task one' })
store.save()
local messages = {}
local orig_notify = vim.notify
vim.notify = function(msg, level)
table.insert(messages, { msg = msg, level = level })
end
pending.edit(tostring(t.id), 'bogus')
vim.notify = orig_notify
assert.are.equal(1, #messages)
assert.truthy(messages[1].msg:find('Unknown operation'))
assert.are.equal(vim.log.levels.ERROR, messages[1].level)
end)
it('errors on invalid recurrence pattern', function()
local t = store.add({ description = 'Task one' })
store.save()
local messages = {}
local orig_notify = vim.notify
vim.notify = function(msg, level)
table.insert(messages, { msg = msg, level = level })
end
pending.edit(tostring(t.id), 'rec:nope')
vim.notify = orig_notify
assert.are.equal(1, #messages)
assert.truthy(messages[1].msg:find('Invalid recurrence'))
assert.are.equal(vim.log.levels.ERROR, messages[1].level)
end)
it('errors when no operations given', function()
local t = store.add({ description = 'Task one' })
store.save()
local messages = {}
local orig_notify = vim.notify
vim.notify = function(msg, level)
table.insert(messages, { msg = msg, level = level })
end
pending.edit(tostring(t.id), '')
vim.notify = orig_notify
assert.are.equal(1, #messages)
assert.truthy(messages[1].msg:find('Usage'))
assert.are.equal(vim.log.levels.ERROR, messages[1].level)
end)
it('errors when no id given', function()
local messages = {}
local orig_notify = vim.notify
vim.notify = function(msg, level)
table.insert(messages, { msg = msg, level = level })
end
pending.edit('', '')
vim.notify = orig_notify
assert.are.equal(1, #messages)
assert.truthy(messages[1].msg:find('Usage'))
assert.are.equal(vim.log.levels.ERROR, messages[1].level)
end)
it('errors on non-numeric id', function()
local messages = {}
local orig_notify = vim.notify
vim.notify = function(msg, level)
table.insert(messages, { msg = msg, level = level })
end
pending.edit('abc', 'cat:Work')
vim.notify = orig_notify
assert.are.equal(1, #messages)
assert.truthy(messages[1].msg:find('Invalid task ID'))
assert.are.equal(vim.log.levels.ERROR, messages[1].level)
end)
it('shows feedback message on success', function()
local t = store.add({ description = 'Task one' })
store.save()
local messages = {}
local orig_notify = vim.notify
vim.notify = function(msg, level)
table.insert(messages, { msg = msg, level = level })
end
pending.edit(tostring(t.id), 'cat:Work')
vim.notify = orig_notify
assert.are.equal(1, #messages)
assert.truthy(messages[1].msg:find('Task #' .. t.id .. ' updated'))
assert.truthy(messages[1].msg:find('category set to Work'))
end)
it('respects custom date_syntax', function()
vim.g.pending = { data_path = tmpdir .. '/tasks.json', date_syntax = 'by' }
config.reset()
store.unload()
store.load()
local t = store.add({ description = 'Task one' })
store.save()
pending.edit(tostring(t.id), 'by:tomorrow')
local updated = store.get(t.id)
local today = os.date('*t') --[[@as osdate]]
local expected =
os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day + 1 }))
assert.are.equal(expected, updated.due)
end)
it('respects custom recur_syntax', function()
vim.g.pending = { data_path = tmpdir .. '/tasks.json', recur_syntax = 'repeat' }
config.reset()
store.unload()
store.load()
local t = store.add({ description = 'Task one' })
store.save()
pending.edit(tostring(t.id), 'repeat:weekly')
local updated = store.get(t.id)
assert.are.equal('weekly', updated.recur)
end)
it('does not modify store on error', function()
local t = store.add({ description = 'Task one', category = 'Original' })
store.save()
local orig_notify = vim.notify
vim.notify = function() end
pending.edit(tostring(t.id), 'due:notadate')
vim.notify = orig_notify
local updated = store.get(t.id)
assert.are.equal('Original', updated.category)
assert.is_nil(updated.due)
end)
it('sets due date with datetime format', function()
local t = store.add({ description = 'Task one' })
store.save()
pending.edit(tostring(t.id), 'due:tomorrow@14:00')
local updated = store.get(t.id)
local today = os.date('*t') --[[@as osdate]]
local expected =
os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day + 1 }))
assert.are.equal(expected .. 'T14:00', updated.due)
end)
end)