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
parent ee362f7785
commit 710cf562c9
16 changed files with 300 additions and 109 deletions

View file

@ -151,6 +151,105 @@ local function append_time(date_part, time_suffix)
return date_part
end
---@param name string
---@return integer?
local function month_name_to_num(name)
return month_map[name:lower():sub(1, 3)]
end
---@param fmt string
---@return string, string[]
local function input_format_to_pattern(fmt)
local fields = {}
local parts = {}
local i = 1
while i <= #fmt do
local c = fmt:sub(i, i)
if c == '%' and i < #fmt then
local spec = fmt:sub(i + 1, i + 1)
if spec == '%' then
parts[#parts + 1] = '%%'
i = i + 2
elseif spec == 'Y' then
fields[#fields + 1] = 'year'
parts[#parts + 1] = '(%d%d%d%d)'
i = i + 2
elseif spec == 'y' then
fields[#fields + 1] = 'year2'
parts[#parts + 1] = '(%d%d)'
i = i + 2
elseif spec == 'm' then
fields[#fields + 1] = 'month_num'
parts[#parts + 1] = '(%d%d?)'
i = i + 2
elseif spec == 'd' or spec == 'e' then
fields[#fields + 1] = 'day'
parts[#parts + 1] = '(%d%d?)'
i = i + 2
elseif spec == 'b' or spec == 'B' then
fields[#fields + 1] = 'month_name'
parts[#parts + 1] = '(%a+)'
i = i + 2
else
parts[#parts + 1] = vim.pesc(c)
i = i + 1
end
else
parts[#parts + 1] = vim.pesc(c)
i = i + 1
end
end
return '^' .. table.concat(parts) .. '$', fields
end
---@param date_input string
---@param time_suffix? string
---@return string?
local function try_input_date_formats(date_input, time_suffix)
local fmts = config.get().input_date_formats
if not fmts or #fmts == 0 then
return nil
end
local today = os.date('*t') --[[@as osdate]]
for _, fmt in ipairs(fmts) do
local pat, fields = input_format_to_pattern(fmt)
local caps = { date_input:match(pat) }
if caps[1] ~= nil then
local year, month, day
for j = 1, #fields do
local field = fields[j]
local val = caps[j]
if field == 'year' then
year = tonumber(val)
elseif field == 'year2' then
local y = tonumber(val) --[[@as integer]]
year = y + (y >= 70 and 1900 or 2000)
elseif field == 'month_num' then
month = tonumber(val)
elseif field == 'day' then
day = tonumber(val)
elseif field == 'month_name' then
month = month_name_to_num(val)
end
end
if month and day then
if not year then
year = today.year
if month < today.month or (month == today.month and day < today.day) then
year = year + 1
end
end
local t = os.time({ year = year, month = month, day = day })
local check = os.date('*t', t) --[[@as osdate]]
if check.year == year and check.month == month and check.day == day then
return append_time(os.date('%Y-%m-%d', t) --[[@as string]], time_suffix)
end
end
end
end
return nil
end
---@param text string
---@return string|nil
function M.resolve_date(text)
@ -411,7 +510,7 @@ function M.resolve_date(text)
)
end
return nil
return try_input_date_formats(date_input, time_suffix)
end
---@param text string