diff --git a/doc/pending.txt b/doc/pending.txt index 3052d15..541f773 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -140,9 +140,9 @@ COMMANDS *pending-commands* :Pending add Work: standup due:tomorrow rec:weekdays :Pending add Buy milk due:fri +!! < - `+!`, `+!!`, or `+!!!` tokens anywhere in the text set the priority - level (capped at `max_priority`). If the buffer is currently open it - is re-rendered after the add. + Trailing `+!`, `+!!`, or `+!!!` tokens set the priority level (capped + at `max_priority`). If the buffer is currently open it is re-rendered + after the add. *:Pending-archive* :Pending archive [{duration}] @@ -348,7 +348,6 @@ Default buffer-local keys: ~ `gw` Toggle work-in-progress status (`wip`) `gb` Toggle blocked status (`blocked`) `g/` Toggle cancelled status (`cancelled`) - `ge` Open markdown detail buffer for task notes (`edit_notes`) `gf` Prompt for filter predicates (`filter`) `` Switch between category / queue view (`view`) `gz` Undo the last `:w` save (`undo`) @@ -488,12 +487,6 @@ old keys to `false`: >lua Decrement the priority level for the task under the cursor, clamped at 0. Default key: ``. - *(pending-edit-notes)* -(pending-edit-notes) - Open the markdown detail buffer for the task under the cursor. - Shows a read-only metadata header and editable notes below a `---` - separator. Press `q` to return to the task list. Default key: `ge`. - *(pending-open-line)* (pending-open-line) Insert a correctly-formatted blank task line below the cursor. @@ -564,29 +557,6 @@ Queue view: ~ *pending-view-queue* virtual text so tasks remain identifiable across categories. The buffer is named `pending://queue`. -============================================================================== -DETAIL BUFFER *pending-detail-buffer* - -Press `ge` (or `keymaps.edit_notes`) on a task to open a markdown detail -buffer named `pending://task/`. The buffer replaces the task list in -the same split. - -Layout: ~ - - Line 1: `# ` (task description as heading) - Lines 2-3: Read-only metadata (status, priority, category, due, - recurrence) rendered as virtual text overlays - Line 4: `---` separator - Line 5+: Free-form markdown notes (editable) - -The metadata header is not editable — it is rendered via extmarks on -empty buffer lines. To change metadata, return to the task list and use -the normal keymaps or `:Pending edit`. - -Write (`:w`) saves the notes content (everything below the `---` -separator) to the `notes` field in the task store. Press `q` to return -to the task list. - ============================================================================== FILTERS *pending-filters* @@ -638,8 +608,8 @@ task data. ============================================================================== INLINE METADATA *pending-metadata* -Metadata tokens may appear anywhere in a task line. On save, tokens are -extracted from any position and the remaining words form the description. +Metadata tokens may be appended to any task line before saving. Tokens are +parsed from the right and consumed until a non-metadata token is reached. Supported tokens: ~ @@ -663,8 +633,9 @@ On `:w`, the description becomes `Buy milk`, the due date is stored as `2026-03-15` and rendered as right-aligned virtual text, and the task is placed under the `Errands` category header. -Only the first occurrence of each metadata type is consumed — duplicate -tokens are silently dropped. +Parsing stops at the first token that is not a recognised metadata token. +Repeated tokens of the same type also stop parsing — only one `due:`, one +`cat:`, and one `rec:` per task line are consumed. Omnifunc completion is available for `due:`, `cat:`, and `rec:` token types. In insert mode, type the token prefix and press `` to see @@ -818,7 +789,6 @@ loads: >lua wip = 'gw', blocked = 'gb', cancelled = 'g/', - edit_notes = 'ge', }, sync = { gcal = {}, @@ -1681,7 +1651,6 @@ Task fields: ~ {entry} (string) ISO 8601 UTC timestamp of creation. {modified} (string) ISO 8601 UTC timestamp of last modification. {end} (string) ISO 8601 UTC timestamp of completion or deletion. - {notes} (string) Free-form markdown notes (from detail buffer). {order} (integer) Relative ordering within a category. Any field not in the list above is preserved in `_extra` and written back on diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index c98ebf9..403205d 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -584,7 +584,6 @@ local function setup_highlights() vim.api.nvim_set_hl(0, 'PendingFilter', { link = 'DiagnosticWarn', default = true }) vim.api.nvim_set_hl(0, 'PendingForge', { link = 'DiagnosticInfo', default = true }) vim.api.nvim_set_hl(0, 'PendingForgeClosed', { link = 'Comment', default = true }) - vim.api.nvim_set_hl(0, 'PendingDetailMeta', { link = 'Comment', default = true }) end ---@return string @@ -806,329 +805,4 @@ function M.open() return task_bufnr end -local ns_detail = vim.api.nvim_create_namespace('pending_detail') -local DETAIL_SEPARATOR = '---' - ----@type integer? -local _detail_bufnr = nil ----@type integer? -local _detail_task_id = nil - ----@return integer? -function M.detail_bufnr() - return _detail_bufnr -end - ----@return integer? -function M.detail_task_id() - return _detail_task_id -end - -local VALID_STATUSES = { - pending = true, - done = true, - wip = true, - blocked = true, - cancelled = true, -} - ----@param task pending.Task ----@return string[] -local function build_detail_frontmatter(task) - local lines = {} - table.insert(lines, 'Status: ' .. (task.status or 'pending')) - table.insert(lines, 'Priority: ' .. (task.priority or 0)) - if task.category then - table.insert(lines, 'Category: ' .. task.category) - end - if task.due then - table.insert(lines, 'Due: ' .. task.due) - end - if task.recur then - local recur_val = task.recur - if task.recur_mode == 'completion' then - recur_val = '!' .. recur_val - end - table.insert(lines, 'Recur: ' .. recur_val) - end - return lines -end - ----@param bufnr integer ----@param sep_row integer ----@return nil -local function apply_detail_extmarks(bufnr, sep_row) - vim.api.nvim_buf_clear_namespace(bufnr, ns_detail, 0, -1) - for i = 1, sep_row - 1 do - vim.api.nvim_buf_set_extmark(bufnr, ns_detail, i, 0, { - end_row = i, - end_col = #(vim.api.nvim_buf_get_lines(bufnr, i, i + 1, false)[1] or ''), - hl_group = 'PendingDetailMeta', - }) - end -end - ----@param task_id integer ----@return integer? bufnr -function M.open_detail(task_id) - if not _store then - return nil - end - if _detail_bufnr and vim.api.nvim_buf_is_valid(_detail_bufnr) then - if _detail_task_id == task_id then - return _detail_bufnr - end - vim.api.nvim_buf_delete(_detail_bufnr, { force = true }) - _detail_bufnr = nil - _detail_task_id = nil - end - local task = _store:get(task_id) - if not task then - log.warn('task not found: ' .. task_id) - return nil - end - - setup_highlights() - - local bufnr = vim.api.nvim_create_buf(true, false) - vim.api.nvim_buf_set_name(bufnr, 'pending://task/' .. task_id) - vim.bo[bufnr].buftype = 'acwrite' - vim.bo[bufnr].filetype = 'markdown' - vim.bo[bufnr].swapfile = false - - local lines = { '# ' .. task.description } - local fm = build_detail_frontmatter(task) - for _, fl in ipairs(fm) do - table.insert(lines, fl) - end - table.insert(lines, DETAIL_SEPARATOR) - local notes = task.notes or '' - if notes ~= '' then - for note_line in (notes .. '\n'):gmatch('(.-)\n') do - table.insert(lines, note_line) - end - else - table.insert(lines, '') - end - - vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) - vim.bo[bufnr].modified = false - - local sep_row = #fm + 1 - apply_detail_extmarks(bufnr, sep_row) - - local winid = task_winid - if winid and vim.api.nvim_win_is_valid(winid) then - vim.api.nvim_win_set_buf(winid, bufnr) - end - - vim.wo[winid].conceallevel = 0 - vim.wo[winid].foldmethod = 'manual' - vim.wo[winid].foldenable = false - - _detail_bufnr = bufnr - _detail_task_id = task_id - - local cursor_row = sep_row + 2 - local total = vim.api.nvim_buf_line_count(bufnr) - if cursor_row > total then - cursor_row = total - end - pcall(vim.api.nvim_win_set_cursor, winid, { cursor_row, 0 }) - - return bufnr -end - ----@return nil -function M.close_detail() - if _detail_bufnr and vim.api.nvim_buf_is_valid(_detail_bufnr) then - vim.api.nvim_buf_delete(_detail_bufnr, { force = true }) - end - _detail_bufnr = nil - _detail_task_id = nil - - if task_winid and vim.api.nvim_win_is_valid(task_winid) then - if task_bufnr and vim.api.nvim_buf_is_valid(task_bufnr) then - vim.api.nvim_win_set_buf(task_winid, task_bufnr) - set_win_options(task_winid) - M.render(task_bufnr) - end - end -end - ----@param lines string[] ----@return integer? sep_row ----@return pending.DetailFields? fields ----@return string? err -local function parse_detail_frontmatter(lines) - local parse = require('pending.parse') - local recur = require('pending.recur') - local cfg = config.get() - - local sep_row = nil - for i, line in ipairs(lines) do - if line == DETAIL_SEPARATOR then - sep_row = i - break - end - end - if not sep_row then - return nil, nil, 'missing separator (---)' - end - - local desc = lines[1] and lines[1]:match('^# (.+)$') - if not desc or desc:match('^%s*$') then - return nil, nil, 'missing or empty title (first line must be # )' - end - - ---@class pending.DetailFields - ---@field description string - ---@field status pending.TaskStatus - ---@field priority integer - ---@field category? string|userdata - ---@field due? string|userdata - ---@field recur? string|userdata - ---@field recur_mode? pending.RecurMode|userdata - local fields = { - description = desc, - status = 'pending', - priority = 0, - category = vim.NIL, - due = vim.NIL, - recur = vim.NIL, - recur_mode = vim.NIL, - } - - local seen = {} ---@type table<string, boolean> - for i = 2, sep_row - 1 do - local line = lines[i] - if line:match('^%s*$') then - goto continue - end - local key, val = line:match('^(%S+):%s*(.*)$') - if not key then - return nil, nil, 'invalid frontmatter line: ' .. line - end - key = key:lower() - if seen[key] then - return nil, nil, 'duplicate field: ' .. key - end - seen[key] = true - - if key == 'status' then - val = val:lower() - if not VALID_STATUSES[val] then - return nil, nil, 'invalid status: ' .. val - end - fields.status = val --[[@as pending.TaskStatus]] - elseif key == 'priority' then - local n = tonumber(val) - if not n or n ~= math.floor(n) or n < 0 then - return nil, nil, 'invalid priority: ' .. val .. ' (must be integer >= 0)' - end - local max = cfg.max_priority or 3 - if n > max then - return nil, nil, 'invalid priority: ' .. val .. ' (max is ' .. max .. ')' - end - fields.priority = n --[[@as integer]] - elseif key == 'category' then - if val == '' then - return nil, nil, 'empty category value' - end - fields.category = val - elseif key == 'due' then - if val == '' then - return nil, nil, 'empty due value (remove the line to clear)' - end - local resolved = parse.resolve_date(val) - if resolved then - fields.due = resolved - elseif - val:match('^%d%d%d%d%-%d%d%-%d%d$') or val:match('^%d%d%d%d%-%d%d%-%d%dT%d%d:%d%d$') - then - fields.due = val - else - return nil, nil, 'invalid due date: ' .. val - end - elseif key == 'recur' then - if val == '' then - return nil, nil, 'empty recur value (remove the line to clear)' - end - local raw_spec = val - local rec_mode = nil - if raw_spec:sub(1, 1) == '!' then - rec_mode = 'completion' - raw_spec = raw_spec:sub(2) - end - if not recur.validate(raw_spec) then - return nil, nil, 'invalid recurrence: ' .. val - end - fields.recur = raw_spec - fields.recur_mode = rec_mode or vim.NIL - else - return nil, nil, 'unknown field: ' .. key - end - ::continue:: - end - - return sep_row, fields, nil -end - ----@return nil -function M.save_detail() - if not _detail_bufnr or not _detail_task_id or not _store then - return - end - local task = _store:get(_detail_task_id) - if not task then - log.warn('task was deleted') - M.close_detail() - return - end - - local lines = vim.api.nvim_buf_get_lines(_detail_bufnr, 0, -1, false) - - local sep_row, fields, err = parse_detail_frontmatter(lines) - if err then - log.error(err) - return - end - ---@cast sep_row integer - ---@cast fields pending.DetailFields - - local notes_text = '' - if sep_row < #lines then - local note_lines = {} - for i = sep_row + 1, #lines do - table.insert(note_lines, lines[i]) - end - notes_text = table.concat(note_lines, '\n') - notes_text = notes_text:gsub('%s+$', '') - end - - local update = { - description = fields.description, - status = fields.status, - priority = fields.priority, - category = fields.category, - due = fields.due, - recur = fields.recur, - recur_mode = fields.recur_mode, - } - if notes_text == '' then - update.notes = vim.NIL - else - update.notes = notes_text - end - - _store:update(_detail_task_id, update) - _store:save() - - vim.bo[_detail_bufnr].modified = false - apply_detail_extmarks(_detail_bufnr, sep_row - 1) -end - -M._parse_detail_frontmatter = parse_detail_frontmatter -M._build_detail_frontmatter = build_detail_frontmatter - return M diff --git a/lua/pending/config.lua b/lua/pending/config.lua index 0015b37..c282dbd 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -82,7 +82,6 @@ ---@field priority_up_visual? string|false ---@field priority_down_visual? string|false ---@field cancelled? string|false ----@field edit_notes? string|false ---@class pending.CategoryViewConfig ---@field order? string[] @@ -164,7 +163,6 @@ local defaults = { wip = 'gw', blocked = 'gb', cancelled = 'g/', - edit_notes = 'ge', priority_up = '<C-a>', priority_down = '<C-x>', priority_up_visual = 'g<C-a>', diff --git a/lua/pending/health.lua b/lua/pending/health.lua index 7d95b5d..d00031b 100644 --- a/lua/pending/health.lua +++ b/lua/pending/health.lua @@ -59,7 +59,7 @@ function M.check() end local sync_paths = vim.fn.globpath(vim.o.runtimepath, 'lua/pending/sync/*.lua', false, true) - if #sync_paths == 0 and vim.tbl_isempty(require('pending').registered_backends()) then + if #sync_paths == 0 then vim.health.info('No sync backends found') else for _, path in ipairs(sync_paths) do @@ -70,12 +70,6 @@ function M.check() backend.health() end end - for rname, rbackend in pairs(require('pending').registered_backends()) do - if type(rbackend.health) == 'function' then - vim.health.start('pending.nvim: sync/' .. rname) - rbackend.health() - end - end end end diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 013533c..38fdf50 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -401,9 +401,6 @@ function M._setup_buf_mappings(bufnr) open_line_above = function() buffer.open_line(true) end, - edit_notes = function() - M.open_detail() - end, } for name, fn in pairs(actions) do @@ -891,46 +888,6 @@ function M.toggle_status(target_status) end end ----@return nil -function M.open_detail() - local bufnr = buffer.bufnr() - if not bufnr then - return - end - if not require_saved() then - return - end - local row = vim.api.nvim_win_get_cursor(0)[1] - local meta = buffer.meta() - if not meta[row] or meta[row].type ~= 'task' then - return - end - local id = meta[row].id - if not id then - return - end - - local detail_bufnr = buffer.open_detail(id) - if not detail_bufnr then - return - end - - local group = vim.api.nvim_create_augroup('PendingDetail', { clear = true }) - vim.api.nvim_create_autocmd('BufWriteCmd', { - group = group, - buffer = detail_bufnr, - callback = function() - buffer.save_detail() - end, - }) - - local km = require('pending.config').get().keymaps - vim.keymap.set('n', km.close or 'q', function() - vim.api.nvim_del_augroup_by_name('PendingDetail') - buffer.close_detail() - end, { buffer = detail_bufnr }) -end - ---@param direction 'up'|'down' ---@return nil function M.move_task(direction) @@ -1141,60 +1098,18 @@ end ---@class pending.SyncBackend ---@field name string ----@field auth? fun(sub_action?: string): nil +---@field auth fun(): nil ---@field push? fun(): nil ---@field pull? fun(): nil ---@field sync? fun(): nil ---@field health? fun(): nil ----@type table<string, pending.SyncBackend> -local _registered_backends = {} - ---@type string[]? local _sync_backends = nil ---@type table<string, true>? local _sync_backend_set = nil ----@param name string ----@return pending.SyncBackend? -function M.resolve_backend(name) - if _registered_backends[name] then - return _registered_backends[name] - end - local ok, mod = pcall(require, 'pending.sync.' .. name) - if ok and type(mod) == 'table' and mod.name then - return mod - end - return nil -end - ----@param backend pending.SyncBackend ----@return nil -function M.register_backend(backend) - if type(backend) ~= 'table' or type(backend.name) ~= 'string' or backend.name == '' then - log.error('register_backend: backend must have a non-empty `name` field') - return - end - local builtin_ok, builtin = pcall(require, 'pending.sync.' .. backend.name) - if builtin_ok and type(builtin) == 'table' and builtin.name then - log.error('register_backend: backend `' .. backend.name .. '` already exists as a built-in') - return - end - if _registered_backends[backend.name] then - log.error('register_backend: backend `' .. backend.name .. '` is already registered') - return - end - _registered_backends[backend.name] = backend - _sync_backends = nil - _sync_backend_set = nil -end - ----@return table<string, pending.SyncBackend> -function M.registered_backends() - return _registered_backends -end - ---@return string[], table<string, true> local function discover_backends() if _sync_backends then @@ -1211,12 +1126,6 @@ local function discover_backends() _sync_backend_set[mod.name] = true end end - for name, _ in pairs(_registered_backends) do - if not _sync_backend_set[name] then - table.insert(_sync_backends, name) - _sync_backend_set[name] = true - end - end table.sort(_sync_backends) return _sync_backends, _sync_backend_set end @@ -1225,8 +1134,8 @@ end ---@param action? string ---@return nil local function run_sync(backend_name, action) - local backend = M.resolve_backend(backend_name) - if not backend then + local ok, backend = pcall(require, 'pending.sync.' .. backend_name) + if not ok then log.error('Unknown sync backend: ' .. backend_name) return end @@ -1591,8 +1500,8 @@ function M.auth(args) local backends_list = discover_backends() local auth_backends = {} for _, name in ipairs(backends_list) do - local mod = M.resolve_backend(name) - if mod and type(mod.auth) == 'function' then + local ok, mod = pcall(require, 'pending.sync.' .. name) + if ok and type(mod.auth) == 'function' then table.insert(auth_backends, { name = name, mod = mod }) end end diff --git a/lua/pending/parse.lua b/lua/pending/parse.lua index 9fd179e..1b36578 100644 --- a/lua/pending/parse.lua +++ b/lua/pending/parse.lua @@ -536,6 +536,7 @@ function M.body(text) end local metadata = {} + local i = #tokens local ck = category_key() local dk = date_key() local rk = recur_key() @@ -543,82 +544,84 @@ function M.body(text) local date_pattern_strict = '^' .. vim.pesc(dk) .. ':(%d%d%d%d%-%d%d%-%d%d[T%d:]*)$' local date_pattern_any = '^' .. vim.pesc(dk) .. ':(.+)$' local rec_pattern = '^' .. vim.pesc(rk) .. ':(%S+)$' - local desc_tokens = {} - local forge_tokens = {} - - for _, token in ipairs(tokens) do - local consumed = false + local forge_indices = {} + while i >= 1 do + local token = tokens[i] local due_val = token:match(date_pattern_strict) - if due_val and is_valid_datetime(due_val) then - if not metadata.due then - metadata.due = due_val + if due_val then + if metadata.due then + break end - consumed = true - end - if not consumed then + if not is_valid_datetime(due_val) then + break + end + metadata.due = due_val + i = i - 1 + else local raw_val = token:match(date_pattern_any) if raw_val then + if metadata.due then + break + end local resolved = M.resolve_date(raw_val) - if resolved then - if not metadata.due then - metadata.due = resolved - end - consumed = true + if not resolved then + break end - end - end - - if not consumed then - local cat_val = token:match(cat_pattern) - if cat_val then - if not metadata.category then - metadata.category = cat_val - end - consumed = true - end - end - - if not consumed then - local pri_bangs = token:match('^%+(!+)$') - if pri_bangs then - if not metadata.priority then - local max = config.get().max_priority or 3 - metadata.priority = math.min(#pri_bangs, max) - end - consumed = true - end - end - - if not consumed then - local rec_val = token:match(rec_pattern) - if rec_val then - local recur = require('pending.recur') - local raw_spec = rec_val - if raw_spec:sub(1, 1) == '!' then - raw_spec = raw_spec:sub(2) - end - if recur.validate(raw_spec) then - if not metadata.recur then - metadata.recur_mode = rec_val:sub(1, 1) == '!' and 'completion' or nil - metadata.recur = raw_spec - end - consumed = true - end - end - end - - if not consumed then - if forge.parse_ref(token) then - table.insert(forge_tokens, token) + metadata.due = resolved + i = i - 1 else - table.insert(desc_tokens, token) + local cat_val = token:match(cat_pattern) + if cat_val then + if metadata.category then + break + end + metadata.category = cat_val + i = i - 1 + else + local pri_bangs = token:match('^%+(!+)$') + if pri_bangs then + if metadata.priority then + break + end + local max = config.get().max_priority or 3 + metadata.priority = math.min(#pri_bangs, max) + i = i - 1 + else + local rec_val = token:match(rec_pattern) + if rec_val then + if metadata.recur then + break + end + local recur = require('pending.recur') + local raw_spec = rec_val + if raw_spec:sub(1, 1) == '!' then + metadata.recur_mode = 'completion' + raw_spec = raw_spec:sub(2) + end + if not recur.validate(raw_spec) then + break + end + metadata.recur = raw_spec + i = i - 1 + elseif forge.parse_ref(token) then + table.insert(forge_indices, i) + i = i - 1 + else + break + end + end + end end end end - for _, ft in ipairs(forge_tokens) do - table.insert(desc_tokens, ft) + local desc_tokens = {} + for j = 1, i do + table.insert(desc_tokens, tokens[j]) + end + for fi = #forge_indices, 1, -1 do + table.insert(desc_tokens, tokens[forge_indices[fi]]) end local description = table.concat(desc_tokens, ' ') diff --git a/lua/pending/store.lua b/lua/pending/store.lua index 0938eda..7c43c0d 100644 --- a/lua/pending/store.lua +++ b/lua/pending/store.lua @@ -24,7 +24,6 @@ local config = require('pending.config') ---@field entry string ---@field modified string ---@field end? string ----@field notes? string ---@field order integer ---@field _extra? pending.TaskExtra @@ -94,7 +93,6 @@ local known_fields = { entry = true, modified = true, ['end'] = true, - notes = true, order = true, } @@ -126,9 +124,6 @@ local function task_to_table(task) if task['end'] then t['end'] = task['end'] end - if task.notes then - t.notes = task.notes - end if task.order and task.order ~= 0 then t.order = task.order end @@ -155,7 +150,6 @@ local function table_to_task(t) entry = t.entry, modified = t.modified, ['end'] = t['end'], - notes = t.notes, order = t.order or 0, _extra = {}, } diff --git a/lua/pending/views.lua b/lua/pending/views.lua index 3dbd06f..7afeeb7 100644 --- a/lua/pending/views.lua +++ b/lua/pending/views.lua @@ -341,7 +341,6 @@ function M.priority_view(tasks) forge_ref = task._extra and task._extra._forge_ref or nil, forge_cache = task._extra and task._extra._forge_cache or nil, forge_spans = compute_forge_spans(task, prefix_len), - has_notes = task.notes ~= nil and task.notes ~= '', }) end diff --git a/plugin/pending.lua b/plugin/pending.lua index 48ade42..8e2f633 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -304,8 +304,8 @@ end, { if #parts == 0 or (#parts == 1 and not trailing) then local auth_names = {} for _, b in ipairs(pending.sync_backends()) do - local mod = pending.resolve_backend(b) - if mod and type(mod.auth) == 'function' then + local ok, mod = pcall(require, 'pending.sync.' .. b) + if ok and type(mod.auth) == 'function' then table.insert(auth_names, b) end end @@ -313,8 +313,8 @@ end, { end local backend_name = parts[1] if #parts == 1 or (#parts == 2 and not trailing) then - local mod = pending.resolve_backend(backend_name) - if mod and type(mod.auth_complete) == 'function' then + local ok, mod = pcall(require, 'pending.sync.' .. backend_name) + if ok and type(mod.auth_complete) == 'function' then return filter_candidates(arg_lead, mod.auth_complete()) end return {} @@ -328,8 +328,8 @@ end, { if not after_backend then return {} end - local mod = pending.resolve_backend(matched_backend) - if not mod then + local ok, mod = pcall(require, 'pending.sync.' .. matched_backend) + if not ok then return {} end local actions = {} @@ -407,10 +407,6 @@ vim.keymap.set('n', '<Plug>(pending-cancelled)', function() require('pending').toggle_status('cancelled') end) -vim.keymap.set('n', '<Plug>(pending-edit-notes)', function() - require('pending').open_detail() -end) - vim.keymap.set('n', '<Plug>(pending-priority-up)', function() require('pending').increment_priority() end) diff --git a/spec/detail_spec.lua b/spec/detail_spec.lua deleted file mode 100644 index 50f7ae7..0000000 --- a/spec/detail_spec.lua +++ /dev/null @@ -1,402 +0,0 @@ -require('spec.helpers') - -local config = require('pending.config') - -describe('detail frontmatter', function() - local buffer - local tmpdir - - before_each(function() - tmpdir = vim.fn.tempname() - vim.fn.mkdir(tmpdir, 'p') - vim.g.pending = { data_path = tmpdir .. '/tasks.json' } - config.reset() - package.loaded['pending'] = nil - package.loaded['pending.buffer'] = nil - buffer = require('pending.buffer') - end) - - after_each(function() - vim.fn.delete(tmpdir, 'rf') - vim.g.pending = nil - config.reset() - package.loaded['pending'] = nil - package.loaded['pending.buffer'] = nil - end) - - describe('build_detail_frontmatter', function() - it('renders status and priority for minimal task', function() - local lines = buffer._build_detail_frontmatter({ - id = 1, - description = 'Test', - status = 'pending', - priority = 0, - entry = '', - modified = '', - order = 0, - }) - assert.are.equal(2, #lines) - assert.are.equal('Status: pending', lines[1]) - assert.are.equal('Priority: 0', lines[2]) - end) - - it('renders all fields', function() - local lines = buffer._build_detail_frontmatter({ - id = 1, - description = 'Test', - status = 'wip', - priority = 2, - category = 'Work', - due = '2026-03-15', - recur = 'weekly', - entry = '', - modified = '', - order = 0, - }) - assert.are.equal(5, #lines) - assert.are.equal('Status: wip', lines[1]) - assert.are.equal('Priority: 2', lines[2]) - assert.are.equal('Category: Work', lines[3]) - assert.are.equal('Due: 2026-03-15', lines[4]) - assert.are.equal('Recur: weekly', lines[5]) - end) - - it('prefixes recur with ! for completion mode', function() - local lines = buffer._build_detail_frontmatter({ - id = 1, - description = 'Test', - status = 'pending', - priority = 0, - recur = 'daily', - recur_mode = 'completion', - entry = '', - modified = '', - order = 0, - }) - assert.are.equal('Recur: !daily', lines[3]) - end) - - it('omits optional fields when absent', function() - local lines = buffer._build_detail_frontmatter({ - id = 1, - description = 'Test', - status = 'done', - priority = 1, - entry = '', - modified = '', - order = 0, - }) - assert.are.equal(2, #lines) - assert.are.equal('Status: done', lines[1]) - assert.are.equal('Priority: 1', lines[2]) - end) - end) - - describe('parse_detail_frontmatter', function() - it('parses minimal frontmatter', function() - local lines = { - '# My task', - 'Status: pending', - 'Priority: 0', - '---', - 'some notes', - } - local sep, fields, err = buffer._parse_detail_frontmatter(lines) - assert.is_nil(err) - assert.are.equal(4, sep) - assert.are.equal('My task', fields.description) - assert.are.equal('pending', fields.status) - assert.are.equal(0, fields.priority) - end) - - it('parses all fields', function() - local lines = { - '# Fix the bug', - 'Status: wip', - 'Priority: 2', - 'Category: Work', - 'Due: 2026-03-15', - 'Recur: weekly', - '---', - } - local sep, fields, err = buffer._parse_detail_frontmatter(lines) - assert.is_nil(err) - assert.are.equal(7, sep) - assert.are.equal('Fix the bug', fields.description) - assert.are.equal('wip', fields.status) - assert.are.equal(2, fields.priority) - assert.are.equal('Work', fields.category) - assert.are.equal('2026-03-15', fields.due) - assert.are.equal('weekly', fields.recur) - end) - - it('resolves due date keywords', function() - local lines = { - '# Task', - 'Status: pending', - 'Priority: 0', - 'Due: tomorrow', - '---', - } - local _, fields, err = buffer._parse_detail_frontmatter(lines) - assert.is_nil(err) - local today = os.date('*t') --[[@as osdate]] - local expected = os.date( - '%Y-%m-%d', - os.time({ year = today.year, month = today.month, day = today.day + 1 }) - ) - assert.are.equal(expected, fields.due) - end) - - it('parses completion-mode recurrence', function() - local lines = { - '# Task', - 'Status: pending', - 'Priority: 0', - 'Recur: !daily', - '---', - } - local _, fields, err = buffer._parse_detail_frontmatter(lines) - assert.is_nil(err) - assert.are.equal('daily', fields.recur) - assert.are.equal('completion', fields.recur_mode) - end) - - it('clears optional fields when lines removed', function() - local lines = { - '# Task', - 'Status: done', - 'Priority: 1', - '---', - } - local _, fields, err = buffer._parse_detail_frontmatter(lines) - assert.is_nil(err) - assert.are.equal(vim.NIL, fields.category) - assert.are.equal(vim.NIL, fields.due) - assert.are.equal(vim.NIL, fields.recur) - end) - - it('skips blank lines in frontmatter', function() - local lines = { - '# Task', - 'Status: pending', - '', - 'Priority: 0', - '---', - } - local _, fields, err = buffer._parse_detail_frontmatter(lines) - assert.is_nil(err) - assert.are.equal('pending', fields.status) - assert.are.equal(0, fields.priority) - end) - - it('errors on missing separator', function() - local lines = { - '# Task', - 'Status: pending', - 'Priority: 0', - } - local _, _, err = buffer._parse_detail_frontmatter(lines) - assert.truthy(err:find('missing separator')) - end) - - it('errors on missing title', function() - local lines = { - '', - 'Status: pending', - '---', - } - local _, _, err = buffer._parse_detail_frontmatter(lines) - assert.truthy(err:find('missing or empty title')) - end) - - it('errors on empty title', function() - local lines = { - '# ', - 'Status: pending', - '---', - } - local _, _, err = buffer._parse_detail_frontmatter(lines) - assert.truthy(err:find('missing or empty title')) - end) - - it('errors on invalid status', function() - local lines = { - '# Task', - 'Status: bogus', - 'Priority: 0', - '---', - } - local _, _, err = buffer._parse_detail_frontmatter(lines) - assert.truthy(err:find('invalid status')) - end) - - it('errors on negative priority', function() - local lines = { - '# Task', - 'Status: pending', - 'Priority: -1', - '---', - } - local _, _, err = buffer._parse_detail_frontmatter(lines) - assert.truthy(err:find('invalid priority')) - end) - - it('errors on non-integer priority', function() - local lines = { - '# Task', - 'Status: pending', - 'Priority: 1.5', - '---', - } - local _, _, err = buffer._parse_detail_frontmatter(lines) - assert.truthy(err:find('invalid priority')) - end) - - it('errors on priority exceeding max', function() - local lines = { - '# Task', - 'Status: pending', - 'Priority: 4', - '---', - } - local _, _, err = buffer._parse_detail_frontmatter(lines) - assert.truthy(err:find('max is 3')) - end) - - it('errors on invalid due date', function() - local lines = { - '# Task', - 'Status: pending', - 'Priority: 0', - 'Due: notadate', - '---', - } - local _, _, err = buffer._parse_detail_frontmatter(lines) - assert.truthy(err:find('invalid due date')) - end) - - it('errors on empty due value', function() - local lines = { - '# Task', - 'Due: ', - '---', - } - local _, _, err = buffer._parse_detail_frontmatter(lines) - assert.truthy(err:find('empty due value')) - end) - - it('errors on invalid recurrence', function() - local lines = { - '# Task', - 'Recur: nope', - '---', - } - local _, _, err = buffer._parse_detail_frontmatter(lines) - assert.truthy(err:find('invalid recurrence')) - end) - - it('errors on empty recur value', function() - local lines = { - '# Task', - 'Recur: ', - '---', - } - local _, _, err = buffer._parse_detail_frontmatter(lines) - assert.truthy(err:find('empty recur value')) - end) - - it('errors on empty category value', function() - local lines = { - '# Task', - 'Category: ', - '---', - } - local _, _, err = buffer._parse_detail_frontmatter(lines) - assert.truthy(err:find('empty category')) - end) - - it('errors on unknown field', function() - local lines = { - '# Task', - 'Status: pending', - 'Foo: bar', - '---', - } - local _, _, err = buffer._parse_detail_frontmatter(lines) - assert.truthy(err:find('unknown field: foo')) - end) - - it('errors on duplicate field', function() - local lines = { - '# Task', - 'Status: pending', - 'Status: done', - '---', - } - local _, _, err = buffer._parse_detail_frontmatter(lines) - assert.truthy(err:find('duplicate field')) - end) - - it('errors on malformed frontmatter line', function() - local lines = { - '# Task', - 'not a key value pair', - '---', - } - local _, _, err = buffer._parse_detail_frontmatter(lines) - assert.truthy(err:find('invalid frontmatter line')) - end) - - it('is case-insensitive for field keys', function() - local lines = { - '# Task', - 'STATUS: wip', - 'PRIORITY: 1', - 'CATEGORY: Work', - '---', - } - local _, fields, err = buffer._parse_detail_frontmatter(lines) - assert.is_nil(err) - assert.are.equal('wip', fields.status) - assert.are.equal(1, fields.priority) - assert.are.equal('Work', fields.category) - end) - - it('accepts datetime due format', function() - local lines = { - '# Task', - 'Due: 2026-03-15T14:00', - '---', - } - local _, fields, err = buffer._parse_detail_frontmatter(lines) - assert.is_nil(err) - assert.are.equal('2026-03-15T14:00', fields.due) - end) - - it('respects custom max_priority', function() - vim.g.pending = { data_path = tmpdir .. '/tasks.json', max_priority = 5 } - config.reset() - local lines = { - '# Task', - 'Priority: 5', - '---', - } - local _, fields, err = buffer._parse_detail_frontmatter(lines) - assert.is_nil(err) - assert.are.equal(5, fields.priority) - end) - - it('updates description from title line', function() - local lines = { - '# Updated title', - 'Status: pending', - 'Priority: 0', - '---', - } - local _, fields, err = buffer._parse_detail_frontmatter(lines) - assert.is_nil(err) - assert.are.equal('Updated title', fields.description) - end) - end) -end) diff --git a/spec/parse_spec.lua b/spec/parse_spec.lua index e02f1dc..aebe0c7 100644 --- a/spec/parse_spec.lua +++ b/spec/parse_spec.lua @@ -48,16 +48,10 @@ describe('parse', function() assert.are.equal('Errands', meta.category) end) - it('first occurrence wins for duplicate keys', function() + it('stops at duplicate key', function() local desc, meta = parse.body('Buy milk due:2026-03-15 due:2026-04-01') - assert.are.equal('Buy milk', desc) - assert.are.equal('2026-03-15', meta.due) - end) - - it('drops identical duplicate metadata tokens', function() - local desc, meta = parse.body('Buy milk due:tomorrow due:tomorrow') - assert.are.equal('Buy milk', desc) - assert.are.equal(os.date('%Y-%m-%d', os.time() + 86400), meta.due) + assert.are.equal('Buy milk due:2026-03-15', desc) + assert.are.equal('2026-04-01', meta.due) end) it('stops at non-meta token', function() @@ -144,38 +138,6 @@ describe('parse', function() assert.are.equal('Work', meta.category) assert.truthy(desc:find('gl:a/b#12', 1, true)) end) - - it('extracts leading metadata', function() - local desc, meta = parse.body('due:2026-03-15 Fix the bug') - assert.are.equal('Fix the bug', desc) - assert.are.equal('2026-03-15', meta.due) - end) - - it('extracts metadata from the middle', function() - local desc, meta = parse.body('Fix due:2026-03-15 the bug') - assert.are.equal('Fix the bug', desc) - assert.are.equal('2026-03-15', meta.due) - end) - - it('extracts multiple metadata from any position', function() - local desc, meta = parse.body('cat:Work Fix due:2026-03-15 the bug') - assert.are.equal('Fix the bug', desc) - assert.are.equal('2026-03-15', meta.due) - assert.are.equal('Work', meta.category) - end) - - it('extracts all metadata types from mixed positions', function() - local today = os.date('*t') --[[@as osdate]] - local tomorrow = os.date( - '%Y-%m-%d', - os.time({ year = today.year, month = today.month, day = today.day + 1 }) - ) - local desc, meta = parse.body('due:tomorrow cat:Work Fix the bug +!') - assert.are.equal('Fix the bug', desc) - assert.are.equal(tomorrow, meta.due) - assert.are.equal('Work', meta.category) - assert.are.equal(1, meta.priority) - end) end) describe('parse.resolve_date', function() diff --git a/spec/sync_spec.lua b/spec/sync_spec.lua index b7dfe8d..51156bf 100644 --- a/spec/sync_spec.lua +++ b/spec/sync_spec.lua @@ -124,100 +124,6 @@ describe('sync', function() end) end) - describe('register_backend', function() - it('registers a custom backend', function() - pending.register_backend({ name = 'custom', pull = function() end }) - local set = pending.sync_backend_set() - assert.is_true(set['custom'] == true) - assert.is_true(vim.tbl_contains(pending.sync_backends(), 'custom')) - end) - - it('rejects backend without name', function() - local msg - local orig = vim.notify - vim.notify = function(m, level) - if level == vim.log.levels.ERROR then - msg = m - end - end - pending.register_backend({}) - vim.notify = orig - assert.truthy(msg and msg:find('non%-empty')) - end) - - it('rejects backend with empty name', function() - local msg - local orig = vim.notify - vim.notify = function(m, level) - if level == vim.log.levels.ERROR then - msg = m - end - end - pending.register_backend({ name = '' }) - vim.notify = orig - assert.truthy(msg and msg:find('non%-empty')) - end) - - it('rejects duplicate of built-in backend', function() - local msg - local orig = vim.notify - vim.notify = function(m, level) - if level == vim.log.levels.ERROR then - msg = m - end - end - pending.register_backend({ name = 'gcal' }) - vim.notify = orig - assert.truthy(msg and msg:find('already exists')) - end) - - it('rejects duplicate registered backend', function() - pending.register_backend({ name = 'dup_test', pull = function() end }) - local msg - local orig = vim.notify - vim.notify = function(m, level) - if level == vim.log.levels.ERROR then - msg = m - end - end - pending.register_backend({ name = 'dup_test' }) - vim.notify = orig - assert.truthy(msg and msg:find('already registered')) - end) - end) - - describe('resolve_backend', function() - it('resolves built-in backend', function() - local mod = pending.resolve_backend('gcal') - assert.is_not_nil(mod) - assert.are.equal('gcal', mod.name) - end) - - it('resolves registered backend', function() - local custom = { name = 'resolve_test', pull = function() end } - pending.register_backend(custom) - local mod = pending.resolve_backend('resolve_test') - assert.is_not_nil(mod) - assert.are.equal('resolve_test', mod.name) - end) - - it('returns nil for unknown backend', function() - assert.is_nil(pending.resolve_backend('nonexistent_xyz')) - end) - - it('dispatches command to registered backend', function() - local called = false - pending.register_backend({ - name = 'cmd_test', - pull = function() - called = true - end, - }) - pending.command('cmd_test pull') - assert.is_true(called) - end) - end) - describe('auto-discovery', function() it('discovers gcal and gtasks backends', function() local backends = pending.sync_backends()