diff --git a/lua/pending/init.lua b/lua/pending/init.lua index f431a7f..631c0e3 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -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 - .. ':, 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) @@ -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 diff --git a/lua/pending/store.lua b/lua/pending/store.lua index b9a4e38..c9e9b45 100644 --- a/lua/pending/store.lua +++ b/lua/pending/store.lua @@ -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 diff --git a/plugin/pending.lua b/plugin/pending.lua index e04db1e..bfacfec 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -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, diff --git a/spec/edit_spec.lua b/spec/edit_spec.lua deleted file mode 100644 index ba9f98e..0000000 --- a/spec/edit_spec.lua +++ /dev/null @@ -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)