diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 8512210..0fcd564 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -514,6 +514,178 @@ 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 + .. ':, cat:, ' + .. rk + .. ':, +!, -!, -' + .. 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 [due:] [cat:] [rec:] [+!] [-!] [-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 [due:] [cat:] [rec:] [+!] [-!] [-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) @@ -524,6 +696,9 @@ 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 diff --git a/lua/pending/store.lua b/lua/pending/store.lua index c9e9b45..b9a4e38 100644 --- a/lua/pending/store.lua +++ b/lua/pending/store.lua @@ -293,7 +293,11 @@ function M.update(id, fields) local now = timestamp() for k, v in pairs(fields) do if k ~= 'id' and k ~= 'entry' then - task[k] = v + if v == vim.NIL then + task[k] = nil + else + task[k] = v + end end end task.modified = now diff --git a/plugin/pending.lua b/plugin/pending.lua index a239c7a..f9a8df1 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -3,17 +3,173 @@ 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', 'sync', 'archive', 'due', 'undo' } + local subcmds = { 'add', 'archive', 'due', 'edit', 'sync', 'undo' } if not cmd_line:match('^Pending%s+%S') then - return vim.tbl_filter(function(s) - return s:find(arg_lead, 1, true) == 1 - end, subcmds) + return filter_candidates(arg_lead, subcmds) + end + if cmd_line:match('^Pending%s+edit') then + return complete_edit(arg_lead, cmd_line) end return {} end, diff --git a/spec/edit_spec.lua b/spec/edit_spec.lua new file mode 100644 index 0000000..ba9f98e --- /dev/null +++ b/spec/edit_spec.lua @@ -0,0 +1,304 @@ +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)