diff --git a/doc/pending.txt b/doc/pending.txt index 914644e..2465ba3 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -504,6 +504,11 @@ token, the `D` prompt, and `:Pending add`. `soy` / `eoy` January 1 / December 31 of current year `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* Any named date or absolute date accepts an `@` time suffix. Supported 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'` 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, 00–69 → 2000s, 70–99 → 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') The token name for inline due-date metadata. Change this to use a different keyword, for example `'by'` diff --git a/lua/pending/config.lua b/lua/pending/config.lua index f488e41..592ef67 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -47,6 +47,7 @@ ---@field date_syntax string ---@field recur_syntax string ---@field someday_date string +---@field input_date_formats? string[] ---@field category_order? string[] ---@field drawer_height? integer ---@field debug? boolean diff --git a/lua/pending/diff.lua b/lua/pending/diff.lua index 7ebbfe1..5df332f 100644 --- a/lua/pending/diff.lua +++ b/lua/pending/diff.lua @@ -121,17 +121,19 @@ function M.apply(lines, s, hidden_ids) task.priority = entry.priority changed = true end - if task.due ~= entry.due then + if entry.due ~= nil and task.due ~= entry.due then task.due = entry.due changed = true end - if task.recur ~= entry.rec then - task.recur = entry.rec - changed = true - end - if task.recur_mode ~= entry.rec_mode then - task.recur_mode = entry.rec_mode - changed = true + if entry.rec ~= nil then + if task.recur ~= entry.rec then + task.recur = entry.rec + changed = true + end + if task.recur_mode ~= entry.rec_mode then + task.recur_mode = entry.rec_mode + changed = true + end end if entry.status and task.status ~= entry.status then task.status = entry.status diff --git a/lua/pending/health.lua b/lua/pending/health.lua index d3dbe2c..f819269 100644 --- a/lua/pending/health.lua +++ b/lua/pending/health.lua @@ -10,7 +10,7 @@ function M.check() return end - local cfg = config.get() + config.get() vim.health.ok('Config loaded') local store_ok, store = pcall(require, 'pending.store') @@ -21,9 +21,6 @@ function M.check() local resolved_path = store.resolve_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 local s = store.new(resolved_path) diff --git a/lua/pending/init.lua b/lua/pending/init.lua index a83692d..1e05c36 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -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 ', vim.log.levels.ERROR) + log.error('Usage: :Pending add ') 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 [due:] [cat:] [rec:] [+!] [-!] [-due] [-cat] [-rec]', - vim.log.levels.ERROR + log.error( + 'Usage: :Pending edit [due:] [cat:] [rec:] [+!] [-!] [-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 [due:] [cat:] [rec:] [+!] [-!] [-due] [-cat] [-rec]', - vim.log.levels.ERROR + log.error( + 'Usage: :Pending edit [due:] [cat:] [rec:] [+!] [-!] [-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 diff --git a/lua/pending/log.lua b/lua/pending/log.lua new file mode 100644 index 0000000..1f37c4e --- /dev/null +++ b/lua/pending/log.lua @@ -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 diff --git a/lua/pending/parse.lua b/lua/pending/parse.lua index 9ce4c0d..3e90b65 100644 --- a/lua/pending/parse.lua +++ b/lua/pending/parse.lua @@ -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 diff --git a/lua/pending/store.lua b/lua/pending/store.lua index 5a5b370..ff68525 100644 --- a/lua/pending/store.lua +++ b/lua/pending/store.lua @@ -384,14 +384,6 @@ end ---@return string 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 end diff --git a/lua/pending/sync/gcal.lua b/lua/pending/sync/gcal.lua index 44f7742..2ae5e05 100644 --- a/lua/pending/sync/gcal.lua +++ b/lua/pending/sync/gcal.lua @@ -1,4 +1,5 @@ local config = require('pending.config') +local log = require('pending.log') local oauth = require('pending.sync.oauth') local M = {} @@ -148,7 +149,7 @@ function M.push() local calendars, cal_err = get_all_calendars(access_token) 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 end @@ -172,7 +173,7 @@ function M.push() local del_err = delete_event(access_token, cal_id --[[@as string]], event_id --[[@as string]]) 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 extra['_gcal_event_id'] = nil extra['_gcal_calendar_id'] = nil @@ -189,21 +190,18 @@ function M.push() if event_id and cal_id then local upd_err = update_event(access_token, cal_id, event_id, task) 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 updated = updated + 1 end else local lid, lid_err = find_or_create_calendar(access_token, cat, calendars) if lid_err or not lid then - vim.notify( - 'pending.nvim: gcal calendar failed: ' .. (lid_err or 'unknown'), - vim.log.levels.WARN - ) + log.warn('gcal calendar failed: ' .. (lid_err or 'unknown')) else local new_id, create_err = create_event(access_token, lid, task) 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 if not task._extra then task._extra = {} @@ -224,14 +222,7 @@ function M.push() if buffer.bufnr() and vim.api.nvim_buf_is_valid(buffer.bufnr()) then buffer.render(buffer.bufnr()) end - vim.notify( - string.format( - 'pending.nvim: Google Calendar pushed — +%d ~%d -%d', - created, - updated, - deleted - ) - ) + log.info(string.format('Google Calendar pushed — +%d ~%d -%d', created, updated, deleted)) end) end diff --git a/lua/pending/sync/gtasks.lua b/lua/pending/sync/gtasks.lua index a046a51..f627eb7 100644 --- a/lua/pending/sync/gtasks.lua +++ b/lua/pending/sync/gtasks.lua @@ -1,4 +1,5 @@ local config = require('pending.config') +local log = require('pending.log') local oauth = require('pending.sync.oauth') 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 local err = delete_gtask(access_token, list_id, gtid) if err then - vim.notify('pending.nvim: gtasks delete failed: ' .. err, vim.log.levels.WARN) + log.warn('gtasks delete failed: ' .. err) else if not task._extra then 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 local err = update_gtask(access_token, list_id, gtid, task_to_gtask(task)) if err then - vim.notify('pending.nvim: gtasks update failed: ' .. err, vim.log.levels.WARN) + log.warn('gtasks update failed: ' .. err) else updated = updated + 1 end @@ -275,7 +276,7 @@ local function push_pass(access_token, tasklists, s, now_ts, by_gtasks_id) if not err and lid then local new_id, create_err = create_gtask(access_token, lid, task_to_gtask(task)) 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 if not task._extra then 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 local items, err = list_gtasks(access_token, list_id) if err then - vim.notify( - 'pending.nvim: error fetching list ' .. list_name .. ': ' .. err, - vim.log.levels.WARN - ) + log.warn('error fetching list ' .. list_name .. ': ' .. err) else for _, gtask in ipairs(items or {}) do local local_task = by_gtasks_id[gtask.id] @@ -350,7 +348,7 @@ local function sync_setup() end local tasklists, tl_err = get_all_tasklists(access_token) 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 end 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 buffer.render(buffer.bufnr()) end - vim.notify( - string.format('pending.nvim: Google Tasks pushed — +%d ~%d -%d', created, updated, deleted) - ) + log.info(string.format('Google Tasks pushed — +%d ~%d -%d', created, updated, deleted)) end) end @@ -402,7 +398,7 @@ function M.pull() if buffer.bufnr() and vim.api.nvim_buf_is_valid(buffer.bufnr()) then buffer.render(buffer.bufnr()) 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 @@ -425,9 +421,9 @@ function M.sync() if buffer.bufnr() and vim.api.nvim_buf_is_valid(buffer.bufnr()) then buffer.render(buffer.bufnr()) end - vim.notify( + log.info( 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_update, pushed_delete, diff --git a/lua/pending/sync/oauth.lua b/lua/pending/sync/oauth.lua index c53e3b1..dc9eb5c 100644 --- a/lua/pending/sync/oauth.lua +++ b/lua/pending/sync/oauth.lua @@ -1,4 +1,5 @@ local config = require('pending.config') +local log = require('pending.log') local TOKEN_URL = 'https://oauth2.googleapis.com/token' 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 tokens = self:refresh_access_token(creds, tokens) 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 end end @@ -289,7 +290,7 @@ function OAuthClient:auth() .. '&code_challenge_method=S256' 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_closed = false @@ -337,7 +338,7 @@ function OAuthClient:auth() vim.defer_fn(function() if not server_closed then close_server() - vim.notify('pending.nvim: OAuth callback timed out (120s).', vim.log.levels.WARN) + log.warn('OAuth callback timed out (120s).') end end, 120000) end @@ -373,19 +374,19 @@ function OAuthClient:_exchange_code(creds, code, code_verifier, port) }, { text = true }) if result.code ~= 0 then - vim.notify('pending.nvim: Token exchange failed.', vim.log.levels.ERROR) + log.error('Token exchange failed.') return end local ok, decoded = pcall(vim.json.decode, result.stdout or '') 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 end decoded.obtained_at = os.time() self:save_tokens(decoded) - vim.notify('pending.nvim: ' .. self.name .. ' authorized successfully.') + log.info(self.name .. ' authorized successfully.') end ---@param opts { name: string, scope: string, port: integer, config_key: string } diff --git a/lua/pending/textobj.lua b/lua/pending/textobj.lua index 62d6db3..887ef8f 100644 --- a/lua/pending/textobj.lua +++ b/lua/pending/textobj.lua @@ -1,5 +1,6 @@ local buffer = require('pending.buffer') local config = require('pending.config') +local log = require('pending.log') ---@class pending.textobj local M = {} @@ -7,9 +8,7 @@ local M = {} ---@param ... any ---@return nil local function dbg(...) - if config.get().debug then - vim.notify('[pending.textobj] ' .. string.format(...), vim.log.levels.INFO) - end + log.debug(string.format(...)) end ---@param lnum integer diff --git a/plugin/pending.lua b/plugin/pending.lua index 162dfd7..f6ed6bb 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -167,7 +167,7 @@ end, { nargs = '*', complete = function(arg_lead, cmd_line) 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 table.insert(subcmds, b) end diff --git a/spec/diff_spec.lua b/spec/diff_spec.lua index c2a0406..01d8aac 100644 --- a/spec/diff_spec.lua +++ b/spec/diff_spec.lua @@ -199,7 +199,7 @@ describe('diff', function() assert.are.equal(modified_after_first, task.modified) 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:save() local lines = { @@ -209,7 +209,20 @@ describe('diff', function() diff.apply(lines, s) s:load() 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) it('stores recur field on new tasks from buffer', function() @@ -237,7 +250,7 @@ describe('diff', function() assert.are.equal('weekly', task.recur) 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:save() local lines = { @@ -247,7 +260,7 @@ describe('diff', function() diff.apply(lines, s) s:load() local task = s:get(1) - assert.is_nil(task.recur) + assert.are.equal('daily', task.recur) end) it('parses rec: with completion mode prefix', function() diff --git a/spec/parse_spec.lua b/spec/parse_spec.lua index bc313b0..0e6ac19 100644 --- a/spec/parse_spec.lua +++ b/spec/parse_spec.lua @@ -415,4 +415,73 @@ describe('parse', function() assert.are.equal('2026-03-15', meta.due) 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) diff --git a/spec/sync_spec.lua b/spec/sync_spec.lua index 93d3e2c..20a85c1 100644 --- a/spec/sync_spec.lua +++ b/spec/sync_spec.lua @@ -33,7 +33,7 @@ describe('sync', function() end pending.command('notreal') vim.notify = orig - assert.are.equal('Unknown Pending subcommand: notreal', msg) + assert.are.equal('[pending.nvim]: Unknown Pending subcommand: notreal', msg) end) it('errors on unknown action for valid backend', function() @@ -46,7 +46,7 @@ describe('sync', function() end pending.command('gcal notreal') 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) it('lists actions when action is omitted', function()