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

@ -504,6 +504,11 @@ token, the `D` prompt, and `:Pending add`.
`soy` / `eoy` January 1 / December 31 of current year `soy` / `eoy` January 1 / December 31 of current year
`later` / `someday` Sentinel date (default: `9999-12-30`) `later` / `someday` Sentinel date (default: `9999-12-30`)
Custom formats: ~ *pending-dates-custom*
Additional input formats can be configured via `input_date_formats` in
|pending-config|. They are tried in order after all built-in keywords fail.
See |pending-input-formats| for supported specifiers and examples.
Time suffix: ~ *pending-dates-time* Time suffix: ~ *pending-dates-time*
Any named date or absolute date accepts an `@` time suffix. Supported Any named date or absolute date accepts an `@` time suffix. Supported
formats: `HH:MM` (24h), `H:MM`, bare hour (`9`, `14`), and am/pm formats: `HH:MM` (24h), `H:MM`, bare hour (`9`, `14`), and am/pm
@ -636,6 +641,23 @@ Fields: ~
virtual text in the buffer. Examples: `'%Y-%m-%d'` virtual text in the buffer. Examples: `'%Y-%m-%d'`
for ISO dates, `'%d %b'` for day-first. for ISO dates, `'%d %b'` for day-first.
{input_date_formats} (string[], default: {}) *pending-input-formats*
List of strftime-like format strings tried in order
when parsing a `due:` token that does not match the
built-in keywords or ISO `YYYY-MM-DD` format.
Specifiers supported: `%Y` (4-digit year), `%y`
(2-digit year, 0069 → 2000s, 7099 → 1900s), `%m`
(numeric month), `%d` / `%e` (day), `%b` / `%B`
(abbreviated or full month name, case-insensitive).
When no year specifier is present the current year is
used, advancing to next year if the date has already
passed. Examples: >lua
input_date_formats = {
'%m/%d/%Y', -- 03/15/2026
'%d-%b-%Y', -- 15-Mar-2026
'%m/%d', -- 03/15 (year inferred)
}
<
{date_syntax} (string, default: 'due') {date_syntax} (string, default: 'due')
The token name for inline due-date metadata. Change The token name for inline due-date metadata. Change
this to use a different keyword, for example `'by'` this to use a different keyword, for example `'by'`

View file

@ -47,6 +47,7 @@
---@field date_syntax string ---@field date_syntax string
---@field recur_syntax string ---@field recur_syntax string
---@field someday_date string ---@field someday_date string
---@field input_date_formats? string[]
---@field category_order? string[] ---@field category_order? string[]
---@field drawer_height? integer ---@field drawer_height? integer
---@field debug? boolean ---@field debug? boolean

View file

@ -121,17 +121,19 @@ function M.apply(lines, s, hidden_ids)
task.priority = entry.priority task.priority = entry.priority
changed = true changed = true
end end
if task.due ~= entry.due then if entry.due ~= nil and task.due ~= entry.due then
task.due = entry.due task.due = entry.due
changed = true changed = true
end end
if task.recur ~= entry.rec then if entry.rec ~= nil then
task.recur = entry.rec if task.recur ~= entry.rec then
changed = true task.recur = entry.rec
end changed = true
if task.recur_mode ~= entry.rec_mode then end
task.recur_mode = entry.rec_mode if task.recur_mode ~= entry.rec_mode then
changed = true task.recur_mode = entry.rec_mode
changed = true
end
end end
if entry.status and task.status ~= entry.status then if entry.status and task.status ~= entry.status then
task.status = entry.status task.status = entry.status

View file

@ -10,7 +10,7 @@ function M.check()
return return
end end
local cfg = config.get() config.get()
vim.health.ok('Config loaded') vim.health.ok('Config loaded')
local store_ok, store = pcall(require, 'pending.store') local store_ok, store = pcall(require, 'pending.store')
@ -21,9 +21,6 @@ function M.check()
local resolved_path = store.resolve_path() local resolved_path = store.resolve_path()
vim.health.info('Store path: ' .. resolved_path) vim.health.info('Store path: ' .. resolved_path)
if resolved_path ~= cfg.data_path then
vim.health.info('(project-local store; global path: ' .. cfg.data_path .. ')')
end
if vim.fn.filereadable(resolved_path) == 1 then if vim.fn.filereadable(resolved_path) == 1 then
local s = store.new(resolved_path) local s = store.new(resolved_path)

View file

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

30
lua/pending/log.lua Normal file
View file

@ -0,0 +1,30 @@
---@class pending.log
local M = {}
local PREFIX = '[pending.nvim]: '
---@param msg string
function M.info(msg)
vim.notify(PREFIX .. msg)
end
---@param msg string
function M.warn(msg)
vim.notify(PREFIX .. msg, vim.log.levels.WARN)
end
---@param msg string
function M.error(msg)
vim.notify(PREFIX .. msg, vim.log.levels.ERROR)
end
---@param msg string
---@param override? boolean
function M.debug(msg, override)
local cfg = require('pending.config').get()
if cfg.debug or override then
vim.notify(PREFIX .. msg, vim.log.levels.DEBUG)
end
end
return M

View file

@ -151,6 +151,105 @@ local function append_time(date_part, time_suffix)
return date_part return date_part
end 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 ---@param text string
---@return string|nil ---@return string|nil
function M.resolve_date(text) function M.resolve_date(text)
@ -411,7 +510,7 @@ function M.resolve_date(text)
) )
end end
return nil return try_input_date_formats(date_input, time_suffix)
end end
---@param text string ---@param text string

View file

@ -384,14 +384,6 @@ end
---@return string ---@return string
function M.resolve_path() function M.resolve_path()
local results = vim.fs.find('.pending.json', {
upward = true,
path = vim.fn.getcwd(),
type = 'file',
})
if results and #results > 0 then
return results[1]
end
return config.get().data_path return config.get().data_path
end end

View file

@ -1,4 +1,5 @@
local config = require('pending.config') local config = require('pending.config')
local log = require('pending.log')
local oauth = require('pending.sync.oauth') local oauth = require('pending.sync.oauth')
local M = {} local M = {}
@ -148,7 +149,7 @@ function M.push()
local calendars, cal_err = get_all_calendars(access_token) local calendars, cal_err = get_all_calendars(access_token)
if cal_err or not calendars then if cal_err or not calendars then
vim.notify('pending.nvim: ' .. (cal_err or 'failed to fetch calendars'), vim.log.levels.ERROR) log.error(cal_err or 'failed to fetch calendars')
return return
end end
@ -172,7 +173,7 @@ function M.push()
local del_err = local del_err =
delete_event(access_token, cal_id --[[@as string]], event_id --[[@as string]]) delete_event(access_token, cal_id --[[@as string]], event_id --[[@as string]])
if del_err then if del_err then
vim.notify('pending.nvim: gcal delete failed: ' .. del_err, vim.log.levels.WARN) log.warn('gcal delete failed: ' .. del_err)
else else
extra['_gcal_event_id'] = nil extra['_gcal_event_id'] = nil
extra['_gcal_calendar_id'] = nil extra['_gcal_calendar_id'] = nil
@ -189,21 +190,18 @@ function M.push()
if event_id and cal_id then if event_id and cal_id then
local upd_err = update_event(access_token, cal_id, event_id, task) local upd_err = update_event(access_token, cal_id, event_id, task)
if upd_err then if upd_err then
vim.notify('pending.nvim: gcal update failed: ' .. upd_err, vim.log.levels.WARN) log.warn('gcal update failed: ' .. upd_err)
else else
updated = updated + 1 updated = updated + 1
end end
else else
local lid, lid_err = find_or_create_calendar(access_token, cat, calendars) local lid, lid_err = find_or_create_calendar(access_token, cat, calendars)
if lid_err or not lid then if lid_err or not lid then
vim.notify( log.warn('gcal calendar failed: ' .. (lid_err or 'unknown'))
'pending.nvim: gcal calendar failed: ' .. (lid_err or 'unknown'),
vim.log.levels.WARN
)
else else
local new_id, create_err = create_event(access_token, lid, task) local new_id, create_err = create_event(access_token, lid, task)
if create_err then if create_err then
vim.notify('pending.nvim: gcal create failed: ' .. create_err, vim.log.levels.WARN) log.warn('gcal create failed: ' .. create_err)
elseif new_id then elseif new_id then
if not task._extra then if not task._extra then
task._extra = {} task._extra = {}
@ -224,14 +222,7 @@ function M.push()
if buffer.bufnr() and vim.api.nvim_buf_is_valid(buffer.bufnr()) then if buffer.bufnr() and vim.api.nvim_buf_is_valid(buffer.bufnr()) then
buffer.render(buffer.bufnr()) buffer.render(buffer.bufnr())
end end
vim.notify( log.info(string.format('Google Calendar pushed — +%d ~%d -%d', created, updated, deleted))
string.format(
'pending.nvim: Google Calendar pushed — +%d ~%d -%d',
created,
updated,
deleted
)
)
end) end)
end end

View file

@ -1,4 +1,5 @@
local config = require('pending.config') local config = require('pending.config')
local log = require('pending.log')
local oauth = require('pending.sync.oauth') local oauth = require('pending.sync.oauth')
local M = {} local M = {}
@ -248,7 +249,7 @@ local function push_pass(access_token, tasklists, s, now_ts, by_gtasks_id)
if task.status == 'deleted' and gtid and list_id then if task.status == 'deleted' and gtid and list_id then
local err = delete_gtask(access_token, list_id, gtid) local err = delete_gtask(access_token, list_id, gtid)
if err then if err then
vim.notify('pending.nvim: gtasks delete failed: ' .. err, vim.log.levels.WARN) log.warn('gtasks delete failed: ' .. err)
else else
if not task._extra then if not task._extra then
task._extra = {} task._extra = {}
@ -265,7 +266,7 @@ local function push_pass(access_token, tasklists, s, now_ts, by_gtasks_id)
if gtid and list_id then if gtid and list_id then
local err = update_gtask(access_token, list_id, gtid, task_to_gtask(task)) local err = update_gtask(access_token, list_id, gtid, task_to_gtask(task))
if err then if err then
vim.notify('pending.nvim: gtasks update failed: ' .. err, vim.log.levels.WARN) log.warn('gtasks update failed: ' .. err)
else else
updated = updated + 1 updated = updated + 1
end end
@ -275,7 +276,7 @@ local function push_pass(access_token, tasklists, s, now_ts, by_gtasks_id)
if not err and lid then if not err and lid then
local new_id, create_err = create_gtask(access_token, lid, task_to_gtask(task)) local new_id, create_err = create_gtask(access_token, lid, task_to_gtask(task))
if create_err then if create_err then
vim.notify('pending.nvim: gtasks create failed: ' .. create_err, vim.log.levels.WARN) log.warn('gtasks create failed: ' .. create_err)
elseif new_id then elseif new_id then
if not task._extra then if not task._extra then
task._extra = {} task._extra = {}
@ -305,10 +306,7 @@ local function pull_pass(access_token, tasklists, s, now_ts, by_gtasks_id)
for list_name, list_id in pairs(tasklists) do for list_name, list_id in pairs(tasklists) do
local items, err = list_gtasks(access_token, list_id) local items, err = list_gtasks(access_token, list_id)
if err then if err then
vim.notify( log.warn('error fetching list ' .. list_name .. ': ' .. err)
'pending.nvim: error fetching list ' .. list_name .. ': ' .. err,
vim.log.levels.WARN
)
else else
for _, gtask in ipairs(items or {}) do for _, gtask in ipairs(items or {}) do
local local_task = by_gtasks_id[gtask.id] local local_task = by_gtasks_id[gtask.id]
@ -350,7 +348,7 @@ local function sync_setup()
end end
local tasklists, tl_err = get_all_tasklists(access_token) local tasklists, tl_err = get_all_tasklists(access_token)
if tl_err or not tasklists then if tl_err or not tasklists then
vim.notify('pending.nvim: ' .. (tl_err or 'failed to fetch task lists'), vim.log.levels.ERROR) log.error(tl_err or 'failed to fetch task lists')
return nil return nil
end end
local s = require('pending').store() local s = require('pending').store()
@ -379,9 +377,7 @@ function M.push()
if buffer.bufnr() and vim.api.nvim_buf_is_valid(buffer.bufnr()) then if buffer.bufnr() and vim.api.nvim_buf_is_valid(buffer.bufnr()) then
buffer.render(buffer.bufnr()) buffer.render(buffer.bufnr())
end end
vim.notify( log.info(string.format('Google Tasks pushed — +%d ~%d -%d', created, updated, deleted))
string.format('pending.nvim: Google Tasks pushed — +%d ~%d -%d', created, updated, deleted)
)
end) end)
end end
@ -402,7 +398,7 @@ function M.pull()
if buffer.bufnr() and vim.api.nvim_buf_is_valid(buffer.bufnr()) then if buffer.bufnr() and vim.api.nvim_buf_is_valid(buffer.bufnr()) then
buffer.render(buffer.bufnr()) buffer.render(buffer.bufnr())
end end
vim.notify(string.format('pending.nvim: Google Tasks pulled — +%d ~%d', created, updated)) log.info(string.format('Google Tasks pulled — +%d ~%d', created, updated))
end) end)
end end
@ -425,9 +421,9 @@ function M.sync()
if buffer.bufnr() and vim.api.nvim_buf_is_valid(buffer.bufnr()) then if buffer.bufnr() and vim.api.nvim_buf_is_valid(buffer.bufnr()) then
buffer.render(buffer.bufnr()) buffer.render(buffer.bufnr())
end end
vim.notify( log.info(
string.format( string.format(
'pending.nvim: Google Tasks synced — push: +%d ~%d -%d, pull: +%d ~%d', 'Google Tasks synced — push: +%d ~%d -%d, pull: +%d ~%d',
pushed_create, pushed_create,
pushed_update, pushed_update,
pushed_delete, pushed_delete,

View file

@ -1,4 +1,5 @@
local config = require('pending.config') local config = require('pending.config')
local log = require('pending.log')
local TOKEN_URL = 'https://oauth2.googleapis.com/token' local TOKEN_URL = 'https://oauth2.googleapis.com/token'
local AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth' local AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'
@ -247,7 +248,7 @@ function OAuthClient:get_access_token()
if now - obtained > expires - 60 then if now - obtained > expires - 60 then
tokens = self:refresh_access_token(creds, tokens) tokens = self:refresh_access_token(creds, tokens)
if not tokens then if not tokens then
vim.notify('pending.nvim: Failed to refresh access token.', vim.log.levels.ERROR) log.error('Failed to refresh access token.')
return nil return nil
end end
end end
@ -289,7 +290,7 @@ function OAuthClient:auth()
.. '&code_challenge_method=S256' .. '&code_challenge_method=S256'
vim.ui.open(auth_url) vim.ui.open(auth_url)
vim.notify('pending.nvim: Opening browser for Google authorization...') log.info('Opening browser for Google authorization...')
local server = vim.uv.new_tcp() local server = vim.uv.new_tcp()
local server_closed = false local server_closed = false
@ -337,7 +338,7 @@ function OAuthClient:auth()
vim.defer_fn(function() vim.defer_fn(function()
if not server_closed then if not server_closed then
close_server() close_server()
vim.notify('pending.nvim: OAuth callback timed out (120s).', vim.log.levels.WARN) log.warn('OAuth callback timed out (120s).')
end end
end, 120000) end, 120000)
end end
@ -373,19 +374,19 @@ function OAuthClient:_exchange_code(creds, code, code_verifier, port)
}, { text = true }) }, { text = true })
if result.code ~= 0 then if result.code ~= 0 then
vim.notify('pending.nvim: Token exchange failed.', vim.log.levels.ERROR) log.error('Token exchange failed.')
return return
end end
local ok, decoded = pcall(vim.json.decode, result.stdout or '') local ok, decoded = pcall(vim.json.decode, result.stdout or '')
if not ok or not decoded.access_token then if not ok or not decoded.access_token then
vim.notify('pending.nvim: Invalid token response.', vim.log.levels.ERROR) log.error('Invalid token response.')
return return
end end
decoded.obtained_at = os.time() decoded.obtained_at = os.time()
self:save_tokens(decoded) self:save_tokens(decoded)
vim.notify('pending.nvim: ' .. self.name .. ' authorized successfully.') log.info(self.name .. ' authorized successfully.')
end end
---@param opts { name: string, scope: string, port: integer, config_key: string } ---@param opts { name: string, scope: string, port: integer, config_key: string }

View file

@ -1,5 +1,6 @@
local buffer = require('pending.buffer') local buffer = require('pending.buffer')
local config = require('pending.config') local config = require('pending.config')
local log = require('pending.log')
---@class pending.textobj ---@class pending.textobj
local M = {} local M = {}
@ -7,9 +8,7 @@ local M = {}
---@param ... any ---@param ... any
---@return nil ---@return nil
local function dbg(...) local function dbg(...)
if config.get().debug then log.debug(string.format(...))
vim.notify('[pending.textobj] ' .. string.format(...), vim.log.levels.INFO)
end
end end
---@param lnum integer ---@param lnum integer

View file

@ -167,7 +167,7 @@ end, {
nargs = '*', nargs = '*',
complete = function(arg_lead, cmd_line) complete = function(arg_lead, cmd_line)
local pending = require('pending') local pending = require('pending')
local subcmds = { 'add', 'archive', 'due', 'edit', 'filter', 'init', 'undo' } local subcmds = { 'add', 'archive', 'due', 'edit', 'filter', 'undo' }
for _, b in ipairs(pending.sync_backends()) do for _, b in ipairs(pending.sync_backends()) do
table.insert(subcmds, b) table.insert(subcmds, b)
end end

View file

@ -199,7 +199,7 @@ describe('diff', function()
assert.are.equal(modified_after_first, task.modified) assert.are.equal(modified_after_first, task.modified)
end) end)
it('clears due when removed from buffer line', function() it('preserves due when not present in buffer line', function()
s:add({ description = 'Pay bill', due = '2026-03-15' }) s:add({ description = 'Pay bill', due = '2026-03-15' })
s:save() s:save()
local lines = { local lines = {
@ -209,7 +209,20 @@ describe('diff', function()
diff.apply(lines, s) diff.apply(lines, s)
s:load() s:load()
local task = s:get(1) local task = s:get(1)
assert.is_nil(task.due) assert.are.equal('2026-03-15', task.due)
end)
it('updates due when inline token is present', function()
s:add({ description = 'Pay bill', due = '2026-03-15' })
s:save()
local lines = {
'# Inbox',
'/1/- [ ] Pay bill due:2026-04-01',
}
diff.apply(lines, s)
s:load()
local task = s:get(1)
assert.are.equal('2026-04-01', task.due)
end) end)
it('stores recur field on new tasks from buffer', function() it('stores recur field on new tasks from buffer', function()
@ -237,7 +250,7 @@ describe('diff', function()
assert.are.equal('weekly', task.recur) assert.are.equal('weekly', task.recur)
end) end)
it('clears recur when token removed from line', function() it('preserves recur when not present in buffer line', function()
s:add({ description = 'Task', recur = 'daily' }) s:add({ description = 'Task', recur = 'daily' })
s:save() s:save()
local lines = { local lines = {
@ -247,7 +260,7 @@ describe('diff', function()
diff.apply(lines, s) diff.apply(lines, s)
s:load() s:load()
local task = s:get(1) local task = s:get(1)
assert.is_nil(task.recur) assert.are.equal('daily', task.recur)
end) end)
it('parses rec: with completion mode prefix', function() it('parses rec: with completion mode prefix', function()

View file

@ -415,4 +415,73 @@ describe('parse', function()
assert.are.equal('2026-03-15', meta.due) assert.are.equal('2026-03-15', meta.due)
end) end)
end) end)
describe('input_date_formats', function()
before_each(function()
config.reset()
end)
after_each(function()
vim.g.pending = nil
config.reset()
end)
it('parses MM/DD/YYYY format', function()
vim.g.pending = { input_date_formats = { '%m/%d/%Y' } }
config.reset()
local result = parse.resolve_date('03/15/2026')
assert.are.equal('2026-03-15', result)
end)
it('parses DD-Mon-YYYY format', function()
vim.g.pending = { input_date_formats = { '%d-%b-%Y' } }
config.reset()
local result = parse.resolve_date('15-Mar-2026')
assert.are.equal('2026-03-15', result)
end)
it('parses month name case-insensitively', function()
vim.g.pending = { input_date_formats = { '%d-%b-%Y' } }
config.reset()
local result = parse.resolve_date('15-MARCH-2026')
assert.are.equal('2026-03-15', result)
end)
it('parses two-digit year', function()
vim.g.pending = { input_date_formats = { '%m/%d/%y' } }
config.reset()
local result = parse.resolve_date('03/15/26')
assert.are.equal('2026-03-15', result)
end)
it('infers year when format has no year field', function()
vim.g.pending = { input_date_formats = { '%m/%d' } }
config.reset()
local result = parse.resolve_date('12/31')
assert.is_not_nil(result)
assert.truthy(result:match('^%d%d%d%d%-12%-31$'))
end)
it('returns nil for non-matching input', function()
vim.g.pending = { input_date_formats = { '%m/%d/%Y' } }
config.reset()
local result = parse.resolve_date('not-a-date')
assert.is_nil(result)
end)
it('tries formats in order, returns first match', function()
vim.g.pending = { input_date_formats = { '%d/%m/%Y', '%m/%d/%Y' } }
config.reset()
local result = parse.resolve_date('01/03/2026')
assert.are.equal('2026-03-01', result)
end)
it('works with body() for inline due token', function()
vim.g.pending = { input_date_formats = { '%m/%d/%Y' } }
config.reset()
local desc, meta = parse.body('Pay rent due:03/15/2026')
assert.are.equal('Pay rent', desc)
assert.are.equal('2026-03-15', meta.due)
end)
end)
end) end)

View file

@ -33,7 +33,7 @@ describe('sync', function()
end end
pending.command('notreal') pending.command('notreal')
vim.notify = orig vim.notify = orig
assert.are.equal('Unknown Pending subcommand: notreal', msg) assert.are.equal('[pending.nvim]: Unknown Pending subcommand: notreal', msg)
end) end)
it('errors on unknown action for valid backend', function() it('errors on unknown action for valid backend', function()
@ -46,7 +46,7 @@ describe('sync', function()
end end
pending.command('gcal notreal') pending.command('gcal notreal')
vim.notify = orig vim.notify = orig
assert.are.equal("gcal backend has no 'notreal' action", msg) assert.are.equal("[pending.nvim]: gcal backend has no 'notreal' action", msg)
end) end)
it('lists actions when action is omitted', function() it('lists actions when action is omitted', function()