fix(diff): preserve due/rec when absent from buffer line (#68)

* fix(diff): preserve due/rec when absent from buffer line

Problem: `diff.apply` overwrites `task.due` and `task.recur` with `nil`
whenever those fields aren't present as inline tokens in the buffer line.
Because metadata is rendered as virtual text (never in the line text),
every description edit silently clears due dates and recurrence rules.

Solution: Only update `due`, `recur`, and `recur_mode` in the existing-
task branch when the parsed entry actually contains them (non-nil). Users
can still set/change these inline by typing `due:<date>` or `rec:<rule>`;
clearing them requires `:Pending edit <id> -due`.

* refactor: remove project-local store discovery

Problem: `store.resolve_path()` searched upward for `.pending.json`,
silently splitting task data across multiple files depending on CWD.

Solution: `resolve_path()` now always returns `config.get().data_path`.
Remove `M.init()` and the `:Pending init` command and tab-completion
entry. Remove the project-local health message.

* refactor: extract log.lua, standardise [pending.nvim]: prefix

Problem: Notifications were scattered across files using bare
`vim.notify` with inconsistent `pending.nvim: ` prefixes, and the
`debug` guard in `textobj.lua` and `init.lua` was duplicated inline.

Solution: Add `lua/pending/log.lua` with `info`, `warn`, `error`, and
`debug` functions (prefix `[pending.nvim]: `). `log.debug` only fires
when `config.debug = true` or the optional `override` param is `true`.
Replace all `vim.notify` callsites and remove inline debug guards.

* feat(parse): configurable input date formats

Problem: `due:` only accepted ISO `YYYY-MM-DD` and built-in keywords;
users expecting locale-style dates like `03/15/2026` or `15-Mar-2026`
had no way to configure alternative input formats.

Solution: Add `input_date_formats` config field (string[]). Each entry
is a strftime-like format string supporting `%Y`, `%y`, `%m`, `%d`,
`%e`, `%b`, `%B`. Formats are tried in order after built-in keywords
fail. When no year specifier is present the current or next year is
inferred. Update vimdoc and add 8 parse_spec tests.
This commit is contained in:
Barrett Ruth 2026-03-05 12:46:54 -05:00 committed by GitHub
parent b7ce1c05ec
commit 7fb3289b21
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 300 additions and 109 deletions

View file

@ -1,5 +1,6 @@
local buffer = require('pending.buffer')
local diff = require('pending.diff')
local log = require('pending.log')
local parse = require('pending.parse')
local store = require('pending.store')
@ -328,12 +329,7 @@ function M._setup_buf_mappings(bufnr)
for name, fn in pairs(motions) do
local key = km[name]
if cfg.debug then
vim.notify(
('[pending] mapping motion %s → %s (buf=%d)'):format(name, key or 'nil', bufnr),
vim.log.levels.INFO
)
end
log.debug(('mapping motion %s → %s (buf=%d)'):format(name, key or 'nil', bufnr))
if key and key ~= false then
vim.keymap.set({ 'n', 'x', 'o' }, key --[[@as string]], function()
fn(vim.v.count1)
@ -377,7 +373,7 @@ function M.undo_write()
local s = get_store()
local stack = s:undo_stack()
if #stack == 0 then
vim.notify('Nothing to undo.', vim.log.levels.WARN)
log.warn('Nothing to undo.')
return
end
local state = table.remove(stack)
@ -494,7 +490,7 @@ function M.prompt_date()
not due:match('^%d%d%d%d%-%d%d%-%d%d$')
and not due:match('^%d%d%d%d%-%d%d%-%d%dT%d%d:%d%d$')
then
vim.notify('Invalid date format. Use YYYY-MM-DD or YYYY-MM-DDThh:mm.', vim.log.levels.ERROR)
log.error('Invalid date format. Use YYYY-MM-DD or YYYY-MM-DDThh:mm.')
return
end
end
@ -508,14 +504,14 @@ end
---@return nil
function M.add(text)
if not text or text == '' then
vim.notify('Usage: :Pending add <description>', vim.log.levels.ERROR)
log.error('Usage: :Pending add <description>')
return
end
local s = get_store()
s:load()
local description, metadata = parse.command_add(text)
if not description or description == '' then
vim.notify('Pending must have a description.', vim.log.levels.ERROR)
log.error('Pending must have a description.')
return
end
s:add({
@ -530,7 +526,7 @@ function M.add(text)
if bufnr and vim.api.nvim_buf_is_valid(bufnr) then
buffer.render(bufnr)
end
vim.notify('Pending added: ' .. description)
log.info('Pending added: ' .. description)
end
---@type string[]
@ -548,7 +544,7 @@ end
local function run_sync(backend_name, action)
local ok, backend = pcall(require, 'pending.sync.' .. backend_name)
if not ok then
vim.notify('Unknown sync backend: ' .. backend_name, vim.log.levels.ERROR)
log.error('Unknown sync backend: ' .. backend_name)
return
end
if not action or action == '' then
@ -559,11 +555,11 @@ local function run_sync(backend_name, action)
end
end
table.sort(actions)
vim.notify(backend_name .. ' actions: ' .. table.concat(actions, ', '), vim.log.levels.INFO)
log.info(backend_name .. ' actions: ' .. table.concat(actions, ', '))
return
end
if type(backend[action]) ~= 'function' then
vim.notify(backend_name .. " backend has no '" .. action .. "' action", vim.log.levels.ERROR)
log.error(backend_name .. " backend has no '" .. action .. "' action")
return
end
backend[action]()
@ -601,7 +597,7 @@ function M.archive(days)
end
s:replace_tasks(kept)
_save_and_notify()
vim.notify('Archived ' .. archived .. ' tasks.')
log.info('Archived ' .. archived .. ' tasks.')
local bufnr = buffer.bufnr()
if bufnr and vim.api.nvim_buf_is_valid(bufnr) then
buffer.render(bufnr)
@ -653,7 +649,7 @@ function M.due()
end
if #qf_items == 0 then
vim.notify('No due or overdue tasks.')
log.info('No due or overdue tasks.')
return
end
@ -740,16 +736,15 @@ end
---@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
log.error(
'Usage: :Pending edit <id> [due:<date>] [cat:<name>] [rec:<pattern>] [+!] [-!] [-due] [-cat] [-rec]'
)
return
end
local id = tonumber(id_str)
if not id then
vim.notify('Invalid task ID: ' .. id_str, vim.log.levels.ERROR)
log.error('Invalid task ID: ' .. id_str)
return
end
@ -757,14 +752,13 @@ function M.edit(id_str, rest)
s:load()
local task = s:get(id)
if not task then
vim.notify('No task with ID ' .. id .. '.', vim.log.levels.ERROR)
log.error('No task with ID ' .. id .. '.')
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
log.error(
'Usage: :Pending edit <id> [due:<date>] [cat:<name>] [rec:<pattern>] [+!] [-!] [-due] [-cat] [-rec]'
)
return
end
@ -780,7 +774,7 @@ function M.edit(id_str, rest)
for _, tok in ipairs(tokens) do
local field, value, err = parse_edit_token(tok)
if err then
vim.notify(err, vim.log.levels.ERROR)
log.error(err)
return
end
if field == 'recur' then
@ -831,20 +825,7 @@ function M.edit(id_str, rest)
buffer.render(bufnr)
end
vim.notify('Task #' .. id .. ' updated: ' .. table.concat(feedback, ', '))
end
---@return nil
function M.init()
local path = vim.fn.getcwd() .. '/.pending.json'
if vim.fn.filereadable(path) == 1 then
vim.notify('pending.nvim: .pending.json already exists', vim.log.levels.WARN)
return
end
local s = store.new(path)
s:load()
s:save()
vim.notify('pending.nvim: created ' .. path)
log.info('Task #' .. id .. ' updated: ' .. table.concat(feedback, ', '))
end
---@param args string
@ -872,10 +853,8 @@ function M.command(args)
M.filter(rest)
elseif cmd == 'undo' then
M.undo_write()
elseif cmd == 'init' then
M.init()
else
vim.notify('Unknown Pending subcommand: ' .. cmd, vim.log.levels.ERROR)
log.error('Unknown Pending subcommand: ' .. cmd)
end
end