diff --git a/doc/pending.txt b/doc/pending.txt index 541f773..3052d15 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 +!! < - Trailing `+!`, `+!!`, or `+!!!` tokens set the priority level (capped - at `max_priority`). If the buffer is currently open it is re-rendered - after the add. + `+!`, `+!!`, 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. *:Pending-archive* :Pending archive [{duration}] @@ -348,6 +348,7 @@ 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`) @@ -487,6 +488,12 @@ 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. @@ -557,6 +564,29 @@ 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* @@ -608,8 +638,8 @@ task data. ============================================================================== INLINE METADATA *pending-metadata* -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. +Metadata tokens may appear anywhere in a task line. On save, tokens are +extracted from any position and the remaining words form the description. Supported tokens: ~ @@ -633,9 +663,8 @@ 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. -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. +Only the first occurrence of each metadata type is consumed — duplicate +tokens are silently dropped. Omnifunc completion is available for `due:`, `cat:`, and `rec:` token types. In insert mode, type the token prefix and press `` to see @@ -789,6 +818,7 @@ loads: >lua wip = 'gw', blocked = 'gb', cancelled = 'g/', + edit_notes = 'ge', }, sync = { gcal = {}, @@ -1651,6 +1681,7 @@ 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 403205d..c98ebf9 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -584,6 +584,7 @@ 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 @@ -805,4 +806,329 @@ 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 c282dbd..0015b37 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -82,6 +82,7 @@ ---@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[] @@ -163,6 +164,7 @@ 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 d00031b..7d95b5d 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 then + if #sync_paths == 0 and vim.tbl_isempty(require('pending').registered_backends()) then vim.health.info('No sync backends found') else for _, path in ipairs(sync_paths) do @@ -70,6 +70,12 @@ 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 38fdf50..013533c 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -401,6 +401,9 @@ 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 @@ -888,6 +891,46 @@ 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) @@ -1098,18 +1141,60 @@ end ---@class pending.SyncBackend ---@field name string ----@field auth fun(): nil +---@field auth? fun(sub_action?: string): 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 @@ -1126,6 +1211,12 @@ 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 @@ -1134,8 +1225,8 @@ end ---@param action? string ---@return nil local function run_sync(backend_name, action) - local ok, backend = pcall(require, 'pending.sync.' .. backend_name) - if not ok then + local backend = M.resolve_backend(backend_name) + if not backend then log.error('Unknown sync backend: ' .. backend_name) return end @@ -1500,8 +1591,8 @@ function M.auth(args) local backends_list = discover_backends() local auth_backends = {} for _, name in ipairs(backends_list) do - local ok, mod = pcall(require, 'pending.sync.' .. name) - if ok and type(mod.auth) == 'function' then + local mod = M.resolve_backend(name) + if mod 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 1b36578..9fd179e 100644 --- a/lua/pending/parse.lua +++ b/lua/pending/parse.lua @@ -536,7 +536,6 @@ function M.body(text) end local metadata = {} - local i = #tokens local ck = category_key() local dk = date_key() local rk = recur_key() @@ -544,84 +543,82 @@ 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 forge_indices = {} + local desc_tokens = {} + local forge_tokens = {} + + for _, token in ipairs(tokens) do + local consumed = false - while i >= 1 do - local token = tokens[i] local due_val = token:match(date_pattern_strict) - if due_val then - if metadata.due then - break + if due_val and is_valid_datetime(due_val) then + if not metadata.due then + metadata.due = due_val end - if not is_valid_datetime(due_val) then - break - end - metadata.due = due_val - i = i - 1 - else + consumed = true + end + if not consumed then 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 not resolved then - break - end - metadata.due = resolved - i = i - 1 - else - local cat_val = token:match(cat_pattern) - if cat_val then - if metadata.category then - break + if resolved then + if not metadata.due then + metadata.due = resolved end + consumed = true + 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 - 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 + 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) + else + table.insert(desc_tokens, token) end end end - 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]]) + for _, ft in ipairs(forge_tokens) do + table.insert(desc_tokens, ft) end local description = table.concat(desc_tokens, ' ') diff --git a/lua/pending/store.lua b/lua/pending/store.lua index 7c43c0d..0938eda 100644 --- a/lua/pending/store.lua +++ b/lua/pending/store.lua @@ -24,6 +24,7 @@ local config = require('pending.config') ---@field entry string ---@field modified string ---@field end? string +---@field notes? string ---@field order integer ---@field _extra? pending.TaskExtra @@ -93,6 +94,7 @@ local known_fields = { entry = true, modified = true, ['end'] = true, + notes = true, order = true, } @@ -124,6 +126,9 @@ 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 @@ -150,6 +155,7 @@ 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 7afeeb7..3dbd06f 100644 --- a/lua/pending/views.lua +++ b/lua/pending/views.lua @@ -341,6 +341,7 @@ 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 8e2f633..48ade42 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 ok, mod = pcall(require, 'pending.sync.' .. b) - if ok and type(mod.auth) == 'function' then + local mod = pending.resolve_backend(b) + if mod 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 ok, mod = pcall(require, 'pending.sync.' .. backend_name) - if ok and type(mod.auth_complete) == 'function' then + local mod = pending.resolve_backend(backend_name) + if mod 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 ok, mod = pcall(require, 'pending.sync.' .. matched_backend) - if not ok then + local mod = pending.resolve_backend(matched_backend) + if not mod then return {} end local actions = {} @@ -407,6 +407,10 @@ 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 new file mode 100644 index 0000000..50f7ae7 --- /dev/null +++ b/spec/detail_spec.lua @@ -0,0 +1,402 @@ +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 aebe0c7..e02f1dc 100644 --- a/spec/parse_spec.lua +++ b/spec/parse_spec.lua @@ -48,10 +48,16 @@ describe('parse', function() assert.are.equal('Errands', meta.category) end) - it('stops at duplicate key', function() + it('first occurrence wins for duplicate keys', function() local desc, meta = parse.body('Buy milk due:2026-03-15 due:2026-04-01') - assert.are.equal('Buy milk due:2026-03-15', desc) - assert.are.equal('2026-04-01', meta.due) + 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) end) it('stops at non-meta token', function() @@ -138,6 +144,38 @@ 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 51156bf..b7dfe8d 100644 --- a/spec/sync_spec.lua +++ b/spec/sync_spec.lua @@ -124,6 +124,100 @@ 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()