From 7fb3289b21b9b7ba4c8b81bd7df669374428b7ea Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 5 Mar 2026 12:46:54 -0500 Subject: [PATCH] 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:` or `rec:`; clearing them requires `:Pending edit -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. --- doc/pending.txt | 22 ++++++++ lua/pending/config.lua | 1 + lua/pending/diff.lua | 18 ++++--- lua/pending/health.lua | 5 +- lua/pending/init.lua | 63 ++++++++-------------- lua/pending/log.lua | 30 +++++++++++ lua/pending/parse.lua | 101 +++++++++++++++++++++++++++++++++++- lua/pending/store.lua | 8 --- lua/pending/sync/gcal.lua | 23 +++----- lua/pending/sync/gtasks.lua | 24 ++++----- lua/pending/sync/oauth.lua | 13 ++--- lua/pending/textobj.lua | 5 +- plugin/pending.lua | 2 +- spec/diff_spec.lua | 21 ++++++-- spec/parse_spec.lua | 69 ++++++++++++++++++++++++ spec/sync_spec.lua | 4 +- 16 files changed, 300 insertions(+), 109 deletions(-) create mode 100644 lua/pending/log.lua 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()