diff --git a/doc/pending.txt b/doc/pending.txt index 9195c76..9a62c3d 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}] @@ -347,6 +347,8 @@ Default buffer-local keys: ~ `gr` Prompt for a recurrence pattern (`recur`) `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`) @@ -354,6 +356,8 @@ Default buffer-local keys: ~ `O` Insert a new task line above (`open_line_above`) `` Increment priority (clamped at `max_priority`) (`priority_up`) `` Decrement priority (clamped at 0) (`priority_down`) + `g` Increment priority for visual selection (`priority_up_visual`) + `g` Decrement priority for visual selection (`priority_down_visual`) `J` Move task down within its category (`move_down`) `K` Move task up within its category (`move_up`) `zc` Fold the current category section (requires `folding`) @@ -468,6 +472,12 @@ old keys to `false`: >lua Toggle blocked status for the task under the cursor. If the task is already `blocked`, reverts to `pending`. + *(pending-cancelled)* +(pending-cancelled) + Toggle cancelled status for the task under the cursor. + If the task is already `cancelled`, reverts to `pending`. + Toggling on a `done` task switches it to `cancelled`. + *(pending-priority-up)* (pending-priority-up) Increment the priority level for the task under the cursor, clamped @@ -478,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. @@ -535,16 +551,41 @@ Category view (default): ~ *pending-view-category* Tasks are grouped under their category header. Categories appear in the order tasks were added unless `category_order` is set (see |pending-config|). Blank lines separate categories. Within each category, - tasks are sorted by status (wip → pending → blocked → done), then by + tasks are sorted by status (wip → pending → blocked → done → cancelled), then by priority, then by insertion order. Category sections are foldable with `zc` and `zo`. Queue view: ~ *pending-view-queue* - A flat list of all tasks sorted by status (wip → pending → blocked → - done), then by priority, then by due date (tasks without a due date sort - last), then by internal order. Category names are shown as right-aligned virtual - text alongside the due date virtual text so tasks remain identifiable - across categories. The buffer is named `pending://queue`. + A flat list of all tasks sorted by a configurable tiebreak chain + (default: status → priority → due → order → id). See + `view.queue.sort` in |pending-config| for customization. Status + order: wip → pending → blocked → done → cancelled. Category + names are shown as right-aligned virtual text alongside the due date + 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* @@ -578,6 +619,8 @@ Available predicates: ~ `blocked` Show only tasks with status `blocked`. + `cancelled` Show only tasks with status `cancelled`. + `clear` Special value for |:Pending-filter| — clears the active filter and shows all tasks. @@ -595,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: ~ @@ -605,9 +648,10 @@ Supported tokens: ~ `cat:Name` Move the task to the named category on save. `rec:` Set a recurrence rule (see |pending-recurrence|). -The token name for due dates defaults to `due` and is configurable via -`date_syntax` in |pending-config|. The token name for recurrence defaults to -`rec` and is configurable via `recur_syntax`. +The token name for categories defaults to `cat` and is configurable via +`category_syntax` in |pending-config|. The token name for due dates defaults +to `due` and is configurable via `date_syntax`. The token name for recurrence +defaults to `rec` and is configurable via `recur_syntax`. Example: > @@ -619,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 dropped with a warning. Omnifunc completion is available for `due:`, `cat:`, and `rec:` token types. In insert mode, type the token prefix and press `` to see @@ -734,6 +777,7 @@ loads: >lua data_path = vim.fn.stdpath('data') .. '/pending/tasks.json', default_category = 'Todo', date_format = '%b %d', + category_syntax = 'cat', date_syntax = 'due', recur_syntax = 'rec', someday_date = '9999-12-30', @@ -745,7 +789,9 @@ loads: >lua order = {}, folding = true, }, - queue = {}, + queue = { + sort = { 'status', 'priority', 'due', 'order', 'id' }, + }, }, keymaps = { close = 'q', @@ -771,6 +817,8 @@ loads: >lua move_up = 'K', wip = 'gw', blocked = 'gb', + cancelled = 'g/', + edit_notes = 'ge', }, sync = { gcal = {}, @@ -817,6 +865,12 @@ Fields: ~ '%m/%d', -- 03/15 (year inferred) } < + {category_syntax} (string, default: 'cat') + The token name for inline category metadata. Change + this to use a different keyword, for example + `'category'` to write `category:Work` instead of + `cat:Work`. + {date_syntax} (string, default: 'due') The token name for inline due-date metadata. Change this to use a different keyword, for example `'by'` @@ -871,6 +925,24 @@ Fields: ~ {queue} (table) *pending.QueueViewConfig* Queue (priority) view settings. + {sort} (string[], default: + `{ 'status', 'priority', 'due', + 'order', 'id' }`) + Ordered tiebreak chain for the + queue view sort. Each element is a + sort key; the comparator walks the + list and returns on the first + non-equal comparison. Valid keys: + `status` wip < pending < + blocked < done + `priority` higher number first + `due` sooner first, no-due + last + `order` ascending + `id` ascending + `age` alias for `id` + Unknown keys are ignored with a + warning. Examples: >lua vim.g.pending = { @@ -881,6 +953,10 @@ Fields: ~ order = { 'Work', 'Personal' }, folding = { foldtext = '%c: %n items' }, }, + queue = { + sort = { 'status', 'due', 'priority', + 'order', 'id' }, + }, }, } < @@ -922,17 +998,21 @@ Fields: ~ See |pending-gcal|, |pending-gtasks|, |pending-s3|. {icons} (table) *pending.Icons* - Icon characters displayed in the buffer. The - {pending}, {done}, {priority}, {wip}, and - {blocked} characters appear inside brackets - (`[icon]`) as an overlay on the checkbox. The - {category} character prefixes both header lines - and EOL category labels. Fields: + Icon characters used for rendering and parsing + task checkboxes. The {pending}, {done}, + {priority}, {wip}, {blocked}, and {cancelled} + characters determine what is written inside + brackets (`[icon]`) in the buffer text and how + status is inferred on `:w`. Each must be a + single character. The {category} character + prefixes header lines and EOL category labels. + Fields: {pending} Pending task character. Default: ' ' {done} Done task character. Default: 'x' {priority} Priority task character. Default: '!' - {wip} Work-in-progress character. Default: '>' - {blocked} Blocked task character. Default: '=' + {wip} Work-in-progress character. Default: 'w' + {blocked} Blocked task character. Default: 'b' + {cancelled} Cancelled task character. Default: '/' {due} Due date prefix. Default: '.' {recur} Recurrence prefix. Default: '~' {category} Category prefix. Default: '#' @@ -989,6 +1069,10 @@ PendingWip Applied to the checkbox icon of work-in-progress tasks. PendingBlocked Applied to the checkbox icon and text of blocked tasks. Default: links to `DiagnosticError`. + *PendingCancelled* +PendingCancelled Applied to the checkbox icon and text of cancelled tasks. + Default: links to `NonText`. + *PendingPriority* PendingPriority Applied to the checkbox icon of priority 1 tasks. Default: links to `DiagnosticWarn`. @@ -1500,7 +1584,8 @@ Configuration: ~ >lua vim.g.pending = { forge = { - auto_close = false, + close = false, + validate = false, warn_missing_cli = true, github = { icon = '', @@ -1522,9 +1607,15 @@ Configuration: ~ < Top-level fields: ~ - {auto_close} (boolean, default: false) When true, tasks linked to + {close} (boolean, default: false) When true, tasks linked to closed/merged remote issues are automatically marked - done on buffer open. + done on buffer open. Only forges with an explicit + per-forge key (e.g. `github = {}`) are checked; + unconfigured forges are skipped entirely. + {validate} (boolean, default: false) When true, new or changed + forge refs are validated on `:w` by fetching metadata. + Logs a warning if the ref is not found, auth fails, or + the CLI is missing. {warn_missing_cli} (boolean, default: true) When true, warns once per forge per session if the CLI is missing or fails. @@ -1550,9 +1641,9 @@ than 5 minutes are re-fetched asynchronously. The buffer renders immediately with cached data and updates extmarks when the fetch completes. State pull: ~ -Requires `forge.auto_close = true`. After fetching, if the remote issue/PR -is closed or merged and the local task is pending/wip/blocked, the task is -automatically marked as done. Disabled by default. One-way: local status +Requires `forge.close = true`. After fetching, if the remote issue/PR +is closed or merged and the local task is pending/wip/blocked (not cancelled), +the task is automatically marked as done. Disabled by default. One-way: local status changes do not push back to the forge. Highlight groups: ~ @@ -1580,7 +1671,7 @@ Task fields: ~ {id} (integer) Unique, auto-incrementing task identifier. {description} (string) Task text as shown in the buffer. {status} (string) `'pending'`, `'wip'`, `'blocked'`, `'done'`, - or `'deleted'`. + `'cancelled'`, or `'deleted'`. {category} (string) Category name. Defaults to `default_category`. {priority} (integer) Priority level: `0` (none), `1`–`3` (or up to `max_priority`). Higher values sort first. @@ -1590,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 8c0433e..c98ebf9 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -128,6 +128,7 @@ local function apply_inline_row(bufnr, row, m, icons) vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, 0, { end_col = #line, hl_group = 'PendingFilter', + invalidate = true, }) elseif m.type == 'task' then if m.status == 'done' then @@ -136,6 +137,15 @@ local function apply_inline_row(bufnr, row, m, icons) vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, col_start, { end_col = #line, hl_group = 'PendingDone', + invalidate = true, + }) + elseif m.status == 'cancelled' then + local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or '' + local col_start = line:find('/%d+/') and select(2, line:find('/%d+/')) or 0 + vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, col_start, { + end_col = #line, + hl_group = 'PendingCancelled', + invalidate = true, }) elseif m.status == 'blocked' then local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or '' @@ -143,6 +153,7 @@ local function apply_inline_row(bufnr, row, m, icons) vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, col_start, { end_col = #line, hl_group = 'PendingBlocked', + invalidate = true, }) end local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or '' @@ -150,10 +161,12 @@ local function apply_inline_row(bufnr, row, m, icons) local icon, icon_hl if m.status == 'done' then icon, icon_hl = icons.done, 'PendingDone' + elseif m.status == 'cancelled' then + icon, icon_hl = icons.cancelled, 'PendingCancelled' elseif m.status == 'wip' then - icon, icon_hl = icons.wip or '>', 'PendingWip' + icon, icon_hl = icons.wip, 'PendingWip' elseif m.status == 'blocked' then - icon, icon_hl = icons.blocked or '=', 'PendingBlocked' + icon, icon_hl = icons.blocked, 'PendingBlocked' elseif m.priority and m.priority >= 3 then icon, icon_hl = icons.priority, 'PendingPriority3' elseif m.priority and m.priority == 2 then @@ -167,6 +180,7 @@ local function apply_inline_row(bufnr, row, m, icons) virt_text = { { '[' .. icon .. ']', icon_hl } }, virt_text_pos = 'overlay', priority = 100, + invalidate = true, }) if m.forge_spans then local forge = require('pending.forge') @@ -178,6 +192,7 @@ local function apply_inline_row(bufnr, row, m, icons) virt_text = { { label_text, hl_group } }, virt_text_pos = 'inline', priority = 90, + invalidate = true, }) end end @@ -186,11 +201,13 @@ local function apply_inline_row(bufnr, row, m, icons) vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, 0, { end_col = #line, hl_group = 'PendingHeader', + invalidate = true, }) vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, 0, { virt_text = { { icons.category .. ' ', 'PendingHeader' } }, virt_text_pos = 'overlay', priority = 100, + invalidate = true, }) end end @@ -202,11 +219,14 @@ local function infer_status(line) if not ch then return nil end - if ch == 'x' then + local icons = config.get().icons + if ch == icons.done then return 'done' - elseif ch == '>' then + elseif ch == icons.cancelled then + return 'cancelled' + elseif ch == icons.wip then return 'wip' - elseif ch == '=' then + elseif ch == icons.blocked then return 'blocked' end return 'pending' @@ -541,6 +561,7 @@ local function apply_extmarks(bufnr, line_meta) vim.api.nvim_buf_set_extmark(bufnr, ns_eol, row, 0, { virt_text = virt_parts, virt_text_pos = 'eol', + invalidate = true, }) end end @@ -558,10 +579,12 @@ local function setup_highlights() vim.api.nvim_set_hl(0, 'PendingPriority3', { link = 'DiagnosticError', default = true }) vim.api.nvim_set_hl(0, 'PendingWip', { link = 'DiagnosticInfo', default = true }) vim.api.nvim_set_hl(0, 'PendingBlocked', { link = 'DiagnosticError', default = true }) + vim.api.nvim_set_hl(0, 'PendingCancelled', { link = 'NonText', default = true }) vim.api.nvim_set_hl(0, 'PendingRecur', { link = 'DiagnosticInfo', default = true }) 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 @@ -783,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/complete.lua b/lua/pending/complete.lua index 135d1a4..480a488 100644 --- a/lua/pending/complete.lua +++ b/lua/pending/complete.lua @@ -1,4 +1,5 @@ local config = require('pending.config') +local forge = require('pending.forge') ---@class pending.CompletionItem ---@field word string @@ -109,6 +110,17 @@ local function recur_completions() return result end +---@param source string +---@return boolean +function M._is_forge_source(source) + for _, b in ipairs(forge.backends()) do + if b.shorthand == source then + return true + end + end + return false +end + ---@type string? local _complete_source = nil @@ -124,14 +136,16 @@ function M.omnifunc(findstart, base) local dk = date_key() local rk = recur_key() + local ck = config.get().category_syntax or 'cat' + local checks = { { vim.pesc(dk) .. ':([%S]*)$', dk }, - { 'cat:([%S]*)$', 'cat' }, + { vim.pesc(ck) .. ':([%S]*)$', ck }, { vim.pesc(rk) .. ':([%S]*)$', rk }, - { 'gh:([%S]*)$', 'gh' }, - { 'gl:([%S]*)$', 'gl' }, - { 'cb:([%S]*)$', 'cb' }, } + for _, b in ipairs(forge.backends()) do + table.insert(checks, { vim.pesc(b.shorthand) .. ':([%S]*)$', b.shorthand }) + end for _, check in ipairs(checks) do local start = before:find(check[1]) @@ -160,10 +174,10 @@ function M.omnifunc(findstart, base) table.insert(matches, { word = c.word, menu = '[' .. source .. ']', info = c.info }) end end - elseif source == 'cat' then + elseif source == (config.get().category_syntax or 'cat') then for _, c in ipairs(get_categories()) do if base == '' or c:sub(1, #base) == base then - table.insert(matches, { word = c, menu = '[cat]' }) + table.insert(matches, { word = c, menu = '[' .. source .. ']' }) end end elseif source == rk then @@ -172,19 +186,25 @@ function M.omnifunc(findstart, base) table.insert(matches, { word = c.word, menu = '[' .. source .. ']', info = c.info }) end end - elseif source == 'gh' or source == 'gl' or source == 'cb' then + elseif M._is_forge_source(source) then local s = require('pending.buffer').store() if s then local seen = {} for _, task in ipairs(s:tasks()) do if task._extra and task._extra._forge_ref then - local ref = task._extra._forge_ref + local ref = task._extra._forge_ref --[[@as pending.ForgeRef]] local key = ref.owner .. '/' .. ref.repo if not seen[key] then seen[key] = true - local word = key .. '#' - if base == '' or word:sub(1, #base) == base then - table.insert(matches, { word = word, menu = '[' .. source .. ']' }) + local word_num = key .. '#' + if base == '' or word_num:sub(1, #base) == base then + table.insert(matches, { word = word_num, menu = '[' .. source .. ']' }) + end + if base == '' or key:sub(1, #base) == base then + table.insert( + matches, + { word = key, menu = '[' .. source .. ']', info = 'Bare repo link' } + ) end end end diff --git a/lua/pending/config.lua b/lua/pending/config.lua index b1ab639..0015b37 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -11,6 +11,7 @@ ---@field priority string ---@field wip string ---@field blocked string +---@field cancelled string ---@field due string ---@field recur string ---@field category string @@ -37,10 +38,15 @@ ---@field icon? string ---@field issue_format? string ---@field instances? string[] +---@field shorthand? string ---@class pending.ForgeConfig ----@field auto_close? boolean +---@field close? boolean +---@field validate? boolean ---@field warn_missing_cli? boolean +---@field github? pending.ForgeInstanceConfig +---@field gitlab? pending.ForgeInstanceConfig +---@field codeberg? pending.ForgeInstanceConfig ---@field [string] pending.ForgeInstanceConfig ---@class pending.SyncConfig @@ -73,12 +79,17 @@ ---@field move_up? string|false ---@field wip? string|false ---@field blocked? string|false +---@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[] ---@field folding? boolean|pending.FoldingConfig ---@class pending.QueueViewConfig +---@field sort? string[] ---@class pending.ViewConfig ---@field default? 'category'|'priority' @@ -90,6 +101,7 @@ ---@field data_path string ---@field default_category string ---@field date_format string +---@field category_syntax string ---@field date_syntax string ---@field recur_syntax string ---@field someday_date string @@ -111,6 +123,7 @@ local defaults = { data_path = vim.fn.stdpath('data') .. '/pending/tasks.json', default_category = 'Todo', date_format = '%b %d', + category_syntax = 'cat', date_syntax = 'due', recur_syntax = 'rec', someday_date = '9999-12-30', @@ -122,7 +135,9 @@ local defaults = { order = {}, folding = true, }, - queue = {}, + queue = { + sort = { 'status', 'priority', 'due', 'order', 'id' }, + }, }, keymaps = { close = 'q', @@ -148,12 +163,17 @@ local defaults = { move_up = 'K', wip = 'gw', blocked = 'gb', + cancelled = 'g/', + edit_notes = 'ge', priority_up = '<C-a>', priority_down = '<C-x>', + priority_up_visual = 'g<C-a>', + priority_down_visual = 'g<C-x>', }, sync = {}, forge = { - auto_close = false, + close = false, + validate = false, warn_missing_cli = true, github = { icon = '', @@ -175,8 +195,9 @@ local defaults = { pending = ' ', done = 'x', priority = '!', - wip = '>', - blocked = '=', + wip = 'w', + blocked = 'b', + cancelled = '/', due = '.', recur = '~', category = '#', diff --git a/lua/pending/diff.lua b/lua/pending/diff.lua index 29c292b..fd00c0e 100644 --- a/lua/pending/diff.lua +++ b/lua/pending/diff.lua @@ -7,11 +7,11 @@ local parse = require('pending.parse') ---@field id? integer ---@field description? string ---@field priority? integer ----@field status? string +---@field status? pending.TaskStatus ---@field category? string ---@field due? string ----@field rec? string ----@field rec_mode? string +---@field recur? string +---@field recur_mode? pending.RecurMode ---@field forge_ref? pending.ForgeRef ---@field lnum integer @@ -43,14 +43,17 @@ function M.parse_buffer(lines) table.insert(result, { type = 'blank', lnum = i }) elseif id or body then local stripped = body:match('^- %[.?%] (.*)$') or body - local state_char = body:match('^- %[(.-)%]') or ' ' - local priority = state_char == '!' and 1 or 0 + local icons = config.get().icons + local state_char = body:match('^- %[(.-)%]') or icons.pending + local priority = state_char == icons.priority and 1 or 0 local status - if state_char == 'x' then + if state_char == icons.done then status = 'done' - elseif state_char == '>' then + elseif state_char == icons.cancelled then + status = 'cancelled' + elseif state_char == icons.wip then status = 'wip' - elseif state_char == '=' then + elseif state_char == icons.blocked then status = 'blocked' else status = 'pending' @@ -63,12 +66,12 @@ function M.parse_buffer(lines) type = 'task', id = id and tonumber(id) or nil, description = description, - priority = priority, + priority = metadata.priority or priority, status = status, - category = metadata.cat or current_category or config.get().default_category, + category = metadata.category or current_category or config.get().default_category, due = metadata.due, - rec = metadata.rec, - rec_mode = metadata.rec_mode, + recur = metadata.recur, + recur_mode = metadata.recur_mode, forge_ref = forge_ref, lnum = i, }) @@ -82,14 +85,25 @@ function M.parse_buffer(lines) return result end +---@param a? pending.ForgeRef +---@param b? pending.ForgeRef +---@return boolean +local function refs_equal(a, b) + if not a or not b then + return false + end + return a.forge == b.forge and a.owner == b.owner and a.repo == b.repo and a.number == b.number +end + ---@param lines string[] ---@param s pending.Store ---@param hidden_ids? table<integer, true> ----@return nil +---@return pending.ForgeRef[] function M.apply(lines, s, hidden_ids) local parsed = M.parse_buffer(lines) local now = timestamp() local data = s:data() + local new_refs = {} ---@type pending.ForgeRef[] local old_by_id = {} for _, task in ipairs(data.tasks) do @@ -115,11 +129,14 @@ function M.apply(lines, s, hidden_ids) category = entry.category, priority = entry.priority, due = entry.due, - recur = entry.rec, - recur_mode = entry.rec_mode, + recur = entry.recur, + recur_mode = entry.recur_mode, order = order_counter, _extra = entry.forge_ref and { _forge_ref = entry.forge_ref } or nil, }) + if entry.forge_ref then + table.insert(new_refs, entry.forge_ref) + end else seen_ids[entry.id] = true local task = old_by_id[entry.id] @@ -132,10 +149,7 @@ function M.apply(lines, s, hidden_ids) task.category = entry.category changed = true end - if entry.priority == 0 and task.priority > 0 then - task.priority = 0 - changed = true - elseif entry.priority > 0 and task.priority == 0 then + if entry.priority ~= task.priority then task.priority = entry.priority changed = true end @@ -143,17 +157,21 @@ function M.apply(lines, s, hidden_ids) task.due = entry.due changed = true end - if entry.rec ~= nil then - if task.recur ~= entry.rec then - task.recur = entry.rec + if entry.recur ~= nil then + if task.recur ~= entry.recur then + task.recur = entry.recur changed = true end - if task.recur_mode ~= entry.rec_mode then - task.recur_mode = entry.rec_mode + if task.recur_mode ~= entry.recur_mode then + task.recur_mode = entry.recur_mode changed = true end end if entry.forge_ref ~= nil then + local old_ref = task._extra and task._extra._forge_ref or nil + if not refs_equal(old_ref, entry.forge_ref) then + table.insert(new_refs, entry.forge_ref) + end if not task._extra then task._extra = {} end @@ -162,7 +180,7 @@ function M.apply(lines, s, hidden_ids) end if entry.status and task.status ~= entry.status then task.status = entry.status - if entry.status == 'done' then + if entry.status == 'done' or entry.status == 'cancelled' then task['end'] = now else task['end'] = nil @@ -183,11 +201,14 @@ function M.apply(lines, s, hidden_ids) category = entry.category, priority = entry.priority, due = entry.due, - recur = entry.rec, - recur_mode = entry.rec_mode, + recur = entry.recur, + recur_mode = entry.recur_mode, order = order_counter, _extra = entry.forge_ref and { _forge_ref = entry.forge_ref } or nil, }) + if entry.forge_ref then + table.insert(new_refs, entry.forge_ref) + end end ::continue:: @@ -202,6 +223,7 @@ function M.apply(lines, s, hidden_ids) end s:save() + return new_refs end return M diff --git a/lua/pending/forge.lua b/lua/pending/forge.lua index 9b32655..28c173a 100644 --- a/lua/pending/forge.lua +++ b/lua/pending/forge.lua @@ -1,32 +1,40 @@ local config = require('pending.config') local log = require('pending.log') +---@alias pending.ForgeType 'issue'|'pull_request'|'merge_request'|'repo' +---@alias pending.ForgeState 'open'|'closed'|'merged' +---@alias pending.ForgeAuthStatus 'unknown'|'ok'|'failed' + ---@class pending.ForgeRef ---@field forge string ---@field owner string ---@field repo string ----@field type 'issue'|'pull_request'|'merge_request' ----@field number integer +---@field type pending.ForgeType +---@field number? integer ---@field url string ---@class pending.ForgeCache ---@field title? string ----@field state 'open'|'closed'|'merged' +---@field state pending.ForgeState ---@field labels? string[] ---@field fetched_at string +---@class pending.ForgeFetchError +---@field kind 'not_found'|'auth'|'network' + ---@class pending.ForgeBackend ---@field name string ---@field shorthand string ---@field default_host string ---@field cli string ---@field auth_cmd string +---@field auth_status_args string[] ---@field default_icon string ---@field default_issue_format string ----@field _warned boolean +---@field _auth? pending.ForgeAuthStatus ---@field parse_url fun(self: pending.ForgeBackend, url: string): pending.ForgeRef? ---@field api_args fun(self: pending.ForgeBackend, ref: pending.ForgeRef): string[] ----@field parse_state fun(self: pending.ForgeBackend, decoded: table): 'open'|'closed'|'merged' +---@field parse_state fun(self: pending.ForgeBackend, decoded: table): pending.ForgeState ---@class pending.forge local M = {} @@ -49,7 +57,7 @@ local _instances_resolved = false ---@param backend pending.ForgeBackend ---@return nil function M.register(backend) - backend._warned = false + backend._auth = 'unknown' table.insert(_backends, backend) _by_name[backend.name] = backend _by_shorthand[backend.shorthand] = backend @@ -62,6 +70,61 @@ function M.backends() return _backends end +---@param forge_name string +---@return boolean +function M.is_configured(forge_name) + local raw = vim.g.pending + if not raw or not raw.forge then + return false + end + return raw.forge[forge_name] ~= nil +end + +---@param backend pending.ForgeBackend +---@param callback fun(ok: boolean) +function M.check_auth(backend, callback) + if backend._auth == 'ok' then + callback(true) + return + end + if backend._auth == 'failed' then + callback(false) + return + end + if vim.fn.executable(backend.cli) == 0 then + backend._auth = 'failed' + local forge_cfg = config.get().forge or {} + if forge_cfg.warn_missing_cli ~= false then + log.warn(('%s not found — run `%s`'):format(backend.cli, backend.auth_cmd)) + end + callback(false) + return + end + vim.system(backend.auth_status_args, { text = true }, function(result) + vim.schedule(function() + if result.code == 0 then + backend._auth = 'ok' + callback(true) + else + backend._auth = 'failed' + local forge_cfg = config.get().forge or {} + if forge_cfg.warn_missing_cli ~= false then + log.warn(('%s not authenticated — run `%s`'):format(backend.cli, backend.auth_cmd)) + end + callback(false) + end + end) + end) +end + +function M._reset_instances() + _instances_resolved = false + _by_shorthand = {} + for _, b in ipairs(_backends) do + _by_shorthand[b.shorthand] = b + end +end + local function _ensure_instances() if _instances_resolved then return @@ -73,33 +136,60 @@ local function _ensure_instances() for _, inst in ipairs(forge_cfg.instances or {}) do _by_host[inst] = backend end + if forge_cfg.shorthand and forge_cfg.shorthand ~= backend.shorthand then + _by_shorthand[backend.shorthand] = nil + backend.shorthand = forge_cfg.shorthand + _by_shorthand[backend.shorthand] = backend + end end end ---@param token string ---@return pending.ForgeRef? function M._parse_shorthand(token) - local prefix, rest = token:match('^(%l%l):(.+)$') - if not prefix then - return nil + _ensure_instances() + local backend, rest + for prefix, b in pairs(_by_shorthand) do + local candidate = token:match('^' .. vim.pesc(prefix) .. ':(.+)$') + if candidate then + backend = b + rest = candidate + break + end end - local backend = _by_shorthand[prefix] if not backend then return nil end local owner, repo, number = rest:match('^([%w%.%-_]+)/([%w%.%-_]+)#(%d+)$') + if owner then + local num = tonumber(number) --[[@as integer]] + local url = 'https://' + .. backend.default_host + .. '/' + .. owner + .. '/' + .. repo + .. '/issues/' + .. num + return { + forge = backend.name, + owner = owner, + repo = repo, + type = 'issue', + number = num, + url = url, + } + end + owner, repo = rest:match('^([%w%.%-_]+)/([%w%.%-_]+)$') if not owner then return nil end - local num = tonumber(number) --[[@as integer]] - local url = 'https://' .. backend.default_host .. '/' .. owner .. '/' .. repo .. '/issues/' .. num return { forge = backend.name, owner = owner, repo = repo, - type = 'issue', - number = num, - url = url, + type = 'repo', + url = 'https://' .. backend.default_host .. '/' .. owner .. '/' .. repo, } end @@ -192,7 +282,7 @@ end ---@return string[] function M._api_args(ref) local backend = _by_name[ref.forge] - if not backend then + if not backend or not ref.number then return {} end return backend:api_args(ref) @@ -209,12 +299,15 @@ function M.format_label(ref, cache) local default_icon = backend and backend.default_icon or '' local default_fmt = backend and backend.default_issue_format or '%i %o/%r#%n' local fmt = forge_cfg.issue_format or default_fmt + if ref.type == 'repo' then + fmt = fmt:gsub('#?%%n', ''):gsub('%s+$', '') + end local icon = forge_cfg.icon or default_icon local text = fmt :gsub('%%i', icon) :gsub('%%o', ref.owner) :gsub('%%r', ref.repo) - :gsub('%%n', tostring(ref.number)) + :gsub('%%n', ref.number and tostring(ref.number) or '') local hl = 'PendingForge' if cache then if cache.state == 'closed' or cache.state == 'merged' then @@ -225,29 +318,31 @@ function M.format_label(ref, cache) end ---@param ref pending.ForgeRef ----@param callback fun(cache: pending.ForgeCache?) +---@param callback fun(cache: pending.ForgeCache?, err: pending.ForgeFetchError?) function M.fetch_metadata(ref, callback) + if ref.type == 'repo' then + callback(nil) + return + end local args = M._api_args(ref) - vim.system(args, { text = true }, function(result) if result.code ~= 0 or not result.stdout or result.stdout == '' then + local kind = 'network' + local stderr = result.stderr or '' + if stderr:find('404') or stderr:find('Not Found') then + kind = 'not_found' + elseif stderr:find('401') or stderr:find('403') or stderr:find('auth') then + kind = 'auth' + end vim.schedule(function() - local forge_cfg = config.get().forge or {} - local backend = _by_name[ref.forge] - if backend and forge_cfg.warn_missing_cli ~= false and not backend._warned then - backend._warned = true - log.warn( - ('%s not found or not authenticated — run `%s`'):format(backend.cli, backend.auth_cmd) - ) - end - callback(nil) + callback(nil, { kind = kind }) end) return end local ok, decoded = pcall(vim.json.decode, result.stdout) if not ok or not decoded then vim.schedule(function() - callback(nil) + callback(nil, { kind = 'network' }) end) return end @@ -277,92 +372,155 @@ end ---@param s pending.Store function M.refresh(s) + local forge_cfg = config.get().forge or {} + if not forge_cfg.close then + return + end local tasks = s:tasks() - local pending_fetches = 0 - local any_changed = false - local any_fetched = false + local by_forge = {} ---@type table<string, pending.Task[]> for _, task in ipairs(tasks) do - if task.status ~= 'deleted' and task._extra and task._extra._forge_ref then - local ref = task._extra._forge_ref --[[@as pending.ForgeRef]] - pending_fetches = pending_fetches + 1 - M.fetch_metadata(ref, function(cache) - pending_fetches = pending_fetches - 1 - if cache then - task._extra._forge_cache = cache - any_fetched = true - local forge_cfg = config.get().forge or {} - if - forge_cfg.auto_close - and (cache.state == 'closed' or cache.state == 'merged') - and (task.status == 'pending' or task.status == 'wip' or task.status == 'blocked') - then - task.status = 'done' - task['end'] = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]] - task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]] - any_changed = true - end - else - task._extra._forge_cache = { - state = 'open', - fetched_at = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]], - } + if + task.status ~= 'deleted' + and task._extra + and task._extra._forge_ref + and task._extra._forge_ref.type ~= 'repo' + then + local fname = task._extra._forge_ref.forge + if not by_forge[fname] then + by_forge[fname] = {} + end + table.insert(by_forge[fname], task) + end + end + local any_work = false + for fname, forge_tasks in pairs(by_forge) do + if M.is_configured(fname) and _by_name[fname] then + any_work = true + M.check_auth(_by_name[fname], function(authed) + if not authed then + return end - if pending_fetches == 0 then - if any_changed then - s:save() - end - local buffer = require('pending.buffer') - if - (any_changed or any_fetched) - and buffer.bufnr() - and vim.api.nvim_buf_is_valid(buffer.bufnr()) - then - buffer.render() - end + local remaining = #forge_tasks + local any_changed = false + local any_fetched = false + for _, task in ipairs(forge_tasks) do + local ref = task._extra._forge_ref --[[@as pending.ForgeRef]] + M.fetch_metadata(ref, function(cache) + remaining = remaining - 1 + if cache then + task._extra._forge_cache = cache + any_fetched = true + if + (cache.state == 'closed' or cache.state == 'merged') + and (task.status == 'pending' or task.status == 'wip' or task.status == 'blocked') + then + task.status = 'done' + task['end'] = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]] + task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]] + any_changed = true + end + else + task._extra._forge_cache = { + state = 'open', + fetched_at = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]], + } + end + if remaining == 0 then + if any_changed then + s:save() + end + local buffer = require('pending.buffer') + if + (any_changed or any_fetched) + and buffer.bufnr() + and vim.api.nvim_buf_is_valid(buffer.bufnr()) + then + buffer.render() + end + end + end) end end) end end - if pending_fetches == 0 then + if not any_work then log.info('No linked tasks to refresh.') end end ----@param opts {name: string, shorthand: string, default_host: string, cli?: string, auth_cmd?: string, default_icon?: string, default_issue_format?: string} +---@param refs pending.ForgeRef[] +function M.validate_refs(refs) + local by_forge = {} ---@type table<string, pending.ForgeRef[]> + for _, ref in ipairs(refs) do + if ref.type == 'repo' then + goto skip_ref + end + local fname = ref.forge + if not by_forge[fname] then + by_forge[fname] = {} + end + table.insert(by_forge[fname], ref) + ::skip_ref:: + end + for fname, forge_refs in pairs(by_forge) do + if not M.is_configured(fname) or not _by_name[fname] then + goto continue + end + M.check_auth(_by_name[fname], function(authed) + if not authed then + return + end + for _, ref in ipairs(forge_refs) do + M.fetch_metadata(ref, function(_, err) + if err and err.kind == 'not_found' then + log.warn(('%s:%s/%s#%d not found'):format(ref.forge, ref.owner, ref.repo, ref.number)) + end + end) + end + end) + ::continue:: + end +end + +---@param opts {name: string, shorthand: string, default_host: string, cli?: string, auth_cmd?: string, auth_status_args?: string[], default_icon?: string, default_issue_format?: string} ---@return pending.ForgeBackend -function M.gitea_backend(opts) +function M.gitea_forge(opts) return { name = opts.name, shorthand = opts.shorthand, default_host = opts.default_host, cli = opts.cli or 'tea', auth_cmd = opts.auth_cmd or 'tea login add', + auth_status_args = opts.auth_status_args or { opts.cli or 'tea', 'login', 'list' }, default_icon = opts.default_icon or '', default_issue_format = opts.default_issue_format or '%i %o/%r#%n', - _warned = false, parse_url = function(self, url) _ensure_instances() local host, owner, repo, kind, number = url:match('^https?://([^/]+)/([%w%.%-_]+)/([%w%.%-_]+)/(%a+)/(%d+)$') - if not host then - return nil + if host and (kind == 'issues' or kind == 'pulls') and _by_host[host] == self then + local num = tonumber(number) --[[@as integer]] + local ref_type = kind == 'pulls' and 'pull_request' or 'issue' + return { + forge = self.name, + owner = owner, + repo = repo, + type = ref_type, + number = num, + url = url, + } end - if kind ~= 'issues' and kind ~= 'pulls' then - return nil + host, owner, repo = url:match('^https?://([^/]+)/([%w%.%-_]+)/([%w%.%-_]+)/?$') + if host and _by_host[host] == self then + return { + forge = self.name, + owner = owner, + repo = repo, + type = 'repo', + url = url, + } end - if _by_host[host] ~= self then - return nil - end - local num = tonumber(number) --[[@as integer]] - local ref_type = kind == 'pulls' and 'pull_request' or 'issue' - return { - forge = self.name, - owner = owner, - repo = repo, - type = ref_type, - number = num, - url = url, - } + return nil end, api_args = function(self, ref) local endpoint = ref.type == 'pull_request' and 'pulls' or 'issues' @@ -387,32 +545,36 @@ M.register({ default_host = 'github.com', cli = 'gh', auth_cmd = 'gh auth login', + auth_status_args = { 'gh', 'auth', 'status' }, default_icon = '', default_issue_format = '%i %o/%r#%n', - _warned = false, parse_url = function(self, url) _ensure_instances() local host, owner, repo, kind, number = url:match('^https?://([^/]+)/([%w%.%-_]+)/([%w%.%-_]+)/(%a+)/(%d+)$') - if not host then - return nil + if host and (kind == 'issues' or kind == 'pull') and _by_host[host] == self then + local num = tonumber(number) --[[@as integer]] + local ref_type = kind == 'pull' and 'pull_request' or 'issue' + return { + forge = 'github', + owner = owner, + repo = repo, + type = ref_type, + number = num, + url = url, + } end - if kind ~= 'issues' and kind ~= 'pull' then - return nil + host, owner, repo = url:match('^https?://([^/]+)/([%w%.%-_]+)/([%w%.%-_]+)/?$') + if host and _by_host[host] == self then + return { + forge = 'github', + owner = owner, + repo = repo, + type = 'repo', + url = url, + } end - if _by_host[host] ~= self then - return nil - end - local num = tonumber(number) --[[@as integer]] - local ref_type = kind == 'pull' and 'pull_request' or 'issue' - return { - forge = 'github', - owner = owner, - repo = repo, - type = ref_type, - number = num, - url = url, - } + return nil end, api_args = function(_, ref) return { @@ -437,35 +599,44 @@ M.register({ default_host = 'gitlab.com', cli = 'glab', auth_cmd = 'glab auth login', + auth_status_args = { 'glab', 'auth', 'status' }, default_icon = '', default_issue_format = '%i %o/%r#%n', - _warned = false, parse_url = function(self, url) _ensure_instances() local host, path, kind, number = url:match('^https?://([^/]+)/(.+)/%-/([%w_]+)/(%d+)$') - if not host then - return nil + if host and (kind == 'issues' or kind == 'merge_requests') and _by_host[host] == self then + local owner, repo = path:match('^(.+)/([^/]+)$') + if owner then + local num = tonumber(number) --[[@as integer]] + local ref_type = kind == 'merge_requests' and 'merge_request' or 'issue' + return { + forge = 'gitlab', + owner = owner, + repo = repo, + type = ref_type, + number = num, + url = url, + } + end end - if kind ~= 'issues' and kind ~= 'merge_requests' then - return nil + host, path = url:match('^https?://([^/]+)/(.+)$') + if host and _by_host[host] == self then + local trimmed = path:gsub('/$', '') + if not trimmed:find('/%-/') then + local owner, repo = trimmed:match('^(.+)/([^/]+)$') + if owner then + return { + forge = 'gitlab', + owner = owner, + repo = repo, + type = 'repo', + url = url, + } + end + end end - if _by_host[host] ~= self then - return nil - end - local owner, repo = path:match('^(.+)/([^/]+)$') - if not owner then - return nil - end - local num = tonumber(number) --[[@as integer]] - local ref_type = kind == 'merge_requests' and 'merge_request' or 'issue' - return { - forge = 'gitlab', - owner = owner, - repo = repo, - type = ref_type, - number = num, - url = url, - } + return nil end, api_args = function(_, ref) local encoded = (ref.owner .. '/' .. ref.repo):gsub('/', '%%2F') @@ -492,32 +663,36 @@ M.register({ default_host = 'codeberg.org', cli = 'tea', auth_cmd = 'tea login add', + auth_status_args = { 'tea', 'login', 'list' }, default_icon = '', default_issue_format = '%i %o/%r#%n', - _warned = false, parse_url = function(self, url) _ensure_instances() local host, owner, repo, kind, number = url:match('^https?://([^/]+)/([%w%.%-_]+)/([%w%.%-_]+)/(%a+)/(%d+)$') - if not host then - return nil + if host and (kind == 'issues' or kind == 'pulls') and _by_host[host] == self then + local num = tonumber(number) --[[@as integer]] + local ref_type = kind == 'pulls' and 'pull_request' or 'issue' + return { + forge = 'codeberg', + owner = owner, + repo = repo, + type = ref_type, + number = num, + url = url, + } end - if kind ~= 'issues' and kind ~= 'pulls' then - return nil + host, owner, repo = url:match('^https?://([^/]+)/([%w%.%-_]+)/([%w%.%-_]+)/?$') + if host and _by_host[host] == self then + return { + forge = 'codeberg', + owner = owner, + repo = repo, + type = 'repo', + url = url, + } end - if _by_host[host] ~= self then - return nil - end - local num = tonumber(number) --[[@as integer]] - local ref_type = kind == 'pulls' and 'pull_request' or 'issue' - return { - forge = 'codeberg', - owner = owner, - repo = repo, - type = ref_type, - number = num, - url = url, - } + return nil end, api_args = function(_, ref) local endpoint = ref.type == 'pull_request' and 'pulls' or 'issues' diff --git a/lua/pending/health.lua b/lua/pending/health.lua index 457eb67..7d95b5d 100644 --- a/lua/pending/health.lua +++ b/lua/pending/health.lua @@ -49,7 +49,9 @@ function M.check() vim.health.start('pending.nvim: forge') local forge = require('pending.forge') for _, backend in ipairs(forge.backends()) do - if vim.fn.executable(backend.cli) == 1 then + if not forge.is_configured(backend.name) then + vim.health.info(('%s: not configured (skipped)'):format(backend.name)) + elseif vim.fn.executable(backend.cli) == 1 then vim.health.ok(('%s found'):format(backend.cli)) else vim.health.warn(('%s not found — run `%s`'):format(backend.cli, backend.auth_cmd)) @@ -57,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 @@ -68,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 6c5eaee..013533c 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -47,7 +47,7 @@ function M._recompute_counts() local today_str = os.date('%Y-%m-%d') --[[@as string]] for _, task in ipairs(get_store():active_tasks()) do - if task.status ~= 'done' and task.status ~= 'deleted' then + if task.status ~= 'done' and task.status ~= 'deleted' and task.status ~= 'cancelled' then pending = pending + 1 if task.priority > 0 then priority = priority + 1 @@ -173,6 +173,11 @@ local function compute_hidden_ids(tasks, predicates) visible = false break end + elseif pred == 'cancelled' then + if task.status ~= 'cancelled' then + visible = false + break + end end end if not visible then @@ -368,6 +373,9 @@ function M._setup_buf_mappings(bufnr) blocked = function() M.toggle_status('blocked') end, + cancelled = function() + M.toggle_status('cancelled') + end, priority_up = function() M.increment_priority() end, @@ -393,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 @@ -402,6 +413,30 @@ function M._setup_buf_mappings(bufnr) end end + ---@type table<string, fun()> + local visual_actions = { + priority_up_visual = function() + M.increment_priority_visual() + end, + priority_down_visual = function() + M.decrement_priority_visual() + end, + } + + for name, fn in pairs(visual_actions) do + local key = km[name] + if key and key ~= false then + vim.keymap.set('x', key --[[@as string]], function() + vim.api.nvim_feedkeys( + vim.api.nvim_replace_termcodes('<Esc>', true, false, true), + 'nx', + false + ) + fn() + end, opts) + end + end + local textobj = require('pending.textobj') ---@type table<string, { modes: string[], fn: fun(count: integer), visual_fn?: fun(count: integer) }> @@ -489,9 +524,13 @@ function M._on_write(bufnr) if #stack > UNDO_MAX then table.remove(stack, 1) end - diff.apply(lines, s, hidden) + local new_refs = diff.apply(lines, s, hidden) M._recompute_counts() buffer.render(bufnr) + if new_refs and #new_refs > 0 then + local forge = require('pending.forge') + forge.validate_refs(new_refs) + end end ---@return nil @@ -520,7 +559,8 @@ function M.toggle_complete() if not require_saved() then return end - local row = vim.api.nvim_win_get_cursor(0)[1] + local cursor = vim.api.nvim_win_get_cursor(0) + local row, col = cursor[1], cursor[2] local meta = buffer.meta() if not meta[row] or meta[row].type ~= 'task' then return @@ -554,11 +594,25 @@ function M.toggle_complete() end _save_and_notify() buffer.render(bufnr) - for lnum, m in ipairs(buffer.meta()) do - if m.id == id then - vim.api.nvim_win_set_cursor(0, { lnum, 0 }) - break + local new_meta = buffer.meta() + local total = #new_meta + local target = math.min(row, total) + if new_meta[target] and new_meta[target].type == 'task' then + vim.api.nvim_win_set_cursor(0, { target, col }) + else + for r = target, total do + if new_meta[r] and new_meta[r].type == 'task' then + vim.api.nvim_win_set_cursor(0, { r, col }) + return + end end + for r = target, 1, -1 do + if new_meta[r] and new_meta[r].type == 'task' then + vim.api.nvim_win_set_cursor(0, { r, col }) + return + end + end + vim.api.nvim_win_set_cursor(0, { target, col }) end end @@ -630,7 +684,8 @@ function M.toggle_priority() if not require_saved() then return end - local row = vim.api.nvim_win_get_cursor(0)[1] + local cursor = vim.api.nvim_win_get_cursor(0) + local row, col = cursor[1], cursor[2] local meta = buffer.meta() if not meta[row] or meta[row].type ~= 'task' then return @@ -651,7 +706,7 @@ function M.toggle_priority() buffer.render(bufnr) for lnum, m in ipairs(buffer.meta()) do if m.id == id then - vim.api.nvim_win_set_cursor(0, { lnum, 0 }) + vim.api.nvim_win_set_cursor(0, { lnum, col }) break end end @@ -667,7 +722,8 @@ local function adjust_priority(delta) if not require_saved() then return end - local row = vim.api.nvim_win_get_cursor(0)[1] + local cursor = vim.api.nvim_win_get_cursor(0) + local row, col = cursor[1], cursor[2] local meta = buffer.meta() if not meta[row] or meta[row].type ~= 'task' then return @@ -691,7 +747,7 @@ local function adjust_priority(delta) buffer.render(bufnr) for lnum, m in ipairs(buffer.meta()) do if m.id == id then - vim.api.nvim_win_set_cursor(0, { lnum, 0 }) + vim.api.nvim_win_set_cursor(0, { lnum, col }) break end end @@ -707,6 +763,53 @@ function M.decrement_priority() adjust_priority(-1) end +---@param delta integer +---@return nil +local function adjust_priority_visual(delta) + local bufnr = buffer.bufnr() + if not bufnr then + return + end + if not require_saved() then + return + end + local start_row = vim.fn.line("'<") + local end_row = vim.fn.line("'>") + local cursor = vim.api.nvim_win_get_cursor(0) + local meta = buffer.meta() + local s = get_store() + local max = require('pending.config').get().max_priority or 3 + local changed = false + for row = start_row, end_row do + if meta[row] and meta[row].type == 'task' and meta[row].id then + local task = s:get(meta[row].id) + if task then + local new_priority = math.max(0, math.min(max, task.priority + delta)) + if new_priority ~= task.priority then + s:update(meta[row].id, { priority = new_priority }) + changed = true + end + end + end + end + if not changed then + return + end + _save_and_notify() + buffer.render(bufnr) + pcall(vim.api.nvim_win_set_cursor, 0, cursor) +end + +---@return nil +function M.increment_priority_visual() + adjust_priority_visual(1) +end + +---@return nil +function M.decrement_priority_visual() + adjust_priority_visual(-1) +end + ---@return nil function M.prompt_date() local bufnr = buffer.bufnr() @@ -748,9 +851,48 @@ function M.prompt_date() end) end ----@param target_status 'wip'|'blocked' +---@param target_status 'wip'|'blocked'|'cancelled' ---@return nil function M.toggle_status(target_status) + local bufnr = buffer.bufnr() + if not bufnr then + return + end + if not require_saved() then + return + end + local cursor = vim.api.nvim_win_get_cursor(0) + local row, col = cursor[1], cursor[2] + 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 s = get_store() + local task = s:get(id) + if not task then + return + end + if task.status == target_status then + s:update(id, { status = 'pending', ['end'] = vim.NIL }) + else + s:update(id, { status = target_status }) + end + _save_and_notify() + buffer.render(bufnr) + for lnum, m in ipairs(buffer.meta()) do + if m.id == id then + vim.api.nvim_win_set_cursor(0, { lnum, col }) + break + end + end +end + +---@return nil +function M.open_detail() local bufnr = buffer.bufnr() if not bufnr then return @@ -767,24 +909,26 @@ function M.toggle_status(target_status) if not id then return end - local s = get_store() - local task = s:get(id) - if not task then + + local detail_bufnr = buffer.open_detail(id) + if not detail_bufnr then return end - if task.status == target_status then - s:update(id, { status = 'pending' }) - else - s:update(id, { status = target_status }) - end - _save_and_notify() - buffer.render(bufnr) - for lnum, m in ipairs(buffer.meta()) do - if m.id == id then - vim.api.nvim_win_set_cursor(0, { lnum, 0 }) - break - end - 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' @@ -797,7 +941,8 @@ function M.move_task(direction) if not require_saved() then return end - local row = vim.api.nvim_win_get_cursor(0)[1] + local cursor = vim.api.nvim_win_get_cursor(0) + local row, col = cursor[1], cursor[2] local meta = buffer.meta() if not meta[row] or meta[row].type ~= 'task' then return @@ -872,7 +1017,7 @@ function M.move_task(direction) for lnum, m in ipairs(buffer.meta()) do if m.id == id then - vim.api.nvim_win_set_cursor(0, { lnum, 0 }) + vim.api.nvim_win_set_cursor(0, { lnum, col }) break end end @@ -980,10 +1125,10 @@ function M.add(text) end s:add({ description = description, - category = metadata.cat, + category = metadata.category, due = metadata.due, - recur = metadata.rec, - recur_mode = metadata.rec_mode, + recur = metadata.recur, + recur_mode = metadata.recur_mode, priority = metadata.priority, }) _save_and_notify() @@ -994,12 +1139,62 @@ function M.add(text) log.info('Task added: ' .. description) end +---@class pending.SyncBackend +---@field name string +---@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 @@ -1016,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 @@ -1024,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 @@ -1082,7 +1283,10 @@ function M.archive(arg) log.debug(('archive: days=%d cutoff=%s total_tasks=%d'):format(days, cutoff, #tasks)) local count = 0 for _, task in ipairs(tasks) do - if (task.status == 'done' or task.status == 'deleted') and task['end'] then + if + (task.status == 'done' or task.status == 'deleted' or task.status == 'cancelled') + and task['end'] + then if task['end'] < cutoff then count = count + 1 end @@ -1103,7 +1307,10 @@ function M.archive(arg) function() local kept = {} for _, task in ipairs(tasks) do - if (task.status == 'done' or task.status == 'deleted') and task['end'] then + if + (task.status == 'done' or task.status == 'deleted' or task.status == 'cancelled') + and task['end'] + then if task['end'] < cutoff then goto skip end @@ -1182,6 +1389,7 @@ end local function parse_edit_token(token) local recur = require('pending.recur') local cfg = require('pending.config').get() + local ck = cfg.category_syntax or 'cat' local dk = cfg.date_syntax or 'due' local rk = cfg.recur_syntax or 'rec' @@ -1197,7 +1405,7 @@ local function parse_edit_token(token) if token == '-due' or token == '-' .. dk then return 'due', vim.NIL, nil end - if token == '-cat' then + if token == '-' .. ck then return 'category', vim.NIL, nil end if token == '-rec' or token == '-' .. rk then @@ -1219,7 +1427,7 @@ local function parse_edit_token(token) 'Invalid date: ' .. due_val .. '. Use YYYY-MM-DD, today, tomorrow, +Nd, weekday names, etc.' end - local cat_val = token:match('^cat:(.+)$') + local cat_val = token:match('^' .. vim.pesc(ck) .. ':(.+)$') if cat_val then return 'category', cat_val, nil end @@ -1244,11 +1452,15 @@ local function parse_edit_token(token) .. token .. '. Valid: ' .. dk - .. ':<date>, cat:<name>, ' + .. ':<date>, ' + .. ck + .. ':<name>, ' .. rk .. ':<pattern>, +!, -!, -' .. dk - .. ', -cat, -' + .. ', -' + .. ck + .. ', -' .. rk end @@ -1379,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 5a705ef..a85d7af 100644 --- a/lua/pending/parse.lua +++ b/lua/pending/parse.lua @@ -1,10 +1,12 @@ local config = require('pending.config') +local forge = require('pending.forge') +local log = require('pending.log') ---@class pending.Metadata ---@field due? string ----@field cat? string ----@field rec? string ----@field rec_mode? 'scheduled'|'completion' +---@field category? string +---@field recur? string +---@field recur_mode? pending.RecurMode ---@field priority? integer ---@class pending.parse @@ -107,6 +109,11 @@ local function is_valid_datetime(s) return is_valid_date(date_part) and is_valid_time(time_part) end +---@return string +local function category_key() + return config.get().category_syntax or 'cat' +end + ---@return string local function date_key() return config.get().date_syntax or 'due' @@ -530,83 +537,99 @@ function M.body(text) end local metadata = {} - local i = #tokens + local ck = category_key() local dk = date_key() local rk = recur_key() + local cat_pattern = '^' .. vim.pesc(ck) .. ':(%S+)$' 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 - 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 + else + log.warn('duplicate ' .. dk .. ': token ignored: ' .. token) 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:(%S+)$') - if cat_val then - if metadata.cat then - break - end - metadata.cat = 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 + if resolved then + if not metadata.due then + metadata.due = resolved else - local rec_val = token:match(rec_pattern) - if rec_val then - if metadata.rec then - break - end - local recur = require('pending.recur') - local raw_spec = rec_val - if raw_spec:sub(1, 1) == '!' then - metadata.rec_mode = 'completion' - raw_spec = raw_spec:sub(2) - end - if not recur.validate(raw_spec) then - break - end - metadata.rec = raw_spec - i = i - 1 - else - break - end + log.warn('duplicate ' .. dk .. ': token ignored: ' .. token) 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 + else + log.warn('duplicate ' .. ck .. ': token ignored: ' .. token) + 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) + else + log.warn('duplicate priority token ignored: ' .. token) + 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 + else + log.warn('duplicate ' .. rk .. ': token ignored: ' .. token) + 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]) + for _, ft in ipairs(forge_tokens) do + table.insert(desc_tokens, ft) end local description = table.concat(desc_tokens, ' ') @@ -624,7 +647,7 @@ function M.command_add(text) local rest = text:sub(#cat_prefix + 2):match('^%s*(.+)$') if rest then local desc, meta = M.body(rest) - meta.cat = meta.cat or cat_prefix + meta.category = meta.category or cat_prefix return desc, meta end end diff --git a/lua/pending/recur.lua b/lua/pending/recur.lua index 9c647aa..8891381 100644 --- a/lua/pending/recur.lua +++ b/lua/pending/recur.lua @@ -2,7 +2,7 @@ ---@field freq 'daily'|'weekly'|'monthly'|'yearly' ---@field interval integer ---@field byday? string[] ----@field from_completion boolean +---@field mode pending.RecurMode ---@field _raw? string ---@class pending.recur @@ -10,29 +10,29 @@ local M = {} ---@type table<string, pending.RecurSpec> local named = { - daily = { freq = 'daily', interval = 1, from_completion = false }, + daily = { freq = 'daily', interval = 1, mode = 'scheduled' }, weekdays = { freq = 'weekly', interval = 1, byday = { 'MO', 'TU', 'WE', 'TH', 'FR' }, - from_completion = false, + mode = 'scheduled', }, - weekly = { freq = 'weekly', interval = 1, from_completion = false }, - biweekly = { freq = 'weekly', interval = 2, from_completion = false }, - monthly = { freq = 'monthly', interval = 1, from_completion = false }, - quarterly = { freq = 'monthly', interval = 3, from_completion = false }, - yearly = { freq = 'yearly', interval = 1, from_completion = false }, - annual = { freq = 'yearly', interval = 1, from_completion = false }, + weekly = { freq = 'weekly', interval = 1, mode = 'scheduled' }, + biweekly = { freq = 'weekly', interval = 2, mode = 'scheduled' }, + monthly = { freq = 'monthly', interval = 1, mode = 'scheduled' }, + quarterly = { freq = 'monthly', interval = 3, mode = 'scheduled' }, + yearly = { freq = 'yearly', interval = 1, mode = 'scheduled' }, + annual = { freq = 'yearly', interval = 1, mode = 'scheduled' }, } ---@param spec string ---@return pending.RecurSpec? function M.parse(spec) - local from_completion = false + local mode = 'scheduled' ---@type pending.RecurMode local s = spec if s:sub(1, 1) == '!' then - from_completion = true + mode = 'completion' s = s:sub(2) end @@ -44,7 +44,7 @@ function M.parse(spec) freq = base.freq, interval = base.interval, byday = base.byday, - from_completion = from_completion, + mode = mode, } end @@ -58,7 +58,7 @@ function M.parse(spec) return { freq = freq_map[unit], interval = num, - from_completion = from_completion, + mode = mode, } end @@ -66,7 +66,7 @@ function M.parse(spec) return { freq = 'daily', interval = 1, - from_completion = from_completion, + mode = mode, _raw = s, } end @@ -134,7 +134,7 @@ end ---@param base_date string ---@param spec string ----@param mode 'scheduled'|'completion' +---@param mode pending.RecurMode ---@return string function M.next_due(base_date, spec, mode) local parsed = M.parse(spec) diff --git a/lua/pending/store.lua b/lua/pending/store.lua index fcf420e..0938eda 100644 --- a/lua/pending/store.lua +++ b/lua/pending/store.lua @@ -1,19 +1,32 @@ local config = require('pending.config') +---@alias pending.TaskStatus 'pending'|'done'|'deleted'|'wip'|'blocked'|'cancelled' +---@alias pending.RecurMode 'scheduled'|'completion' + +---@class pending.TaskExtra +---@field _forge_ref? pending.ForgeRef +---@field _forge_cache? pending.ForgeCache +---@field _gtasks_task_id? string +---@field _gtasks_list_id? string +---@field _gcal_event_id? string +---@field _gcal_calendar_id? string +---@field [string] any + ---@class pending.Task ---@field id integer ---@field description string ----@field status 'pending'|'done'|'deleted'|'wip'|'blocked' +---@field status pending.TaskStatus ---@field category? string ---@field priority integer ---@field due? string ---@field recur? string ----@field recur_mode? 'scheduled'|'completion' +---@field recur_mode? pending.RecurMode ---@field entry string ---@field modified string ---@field end? string +---@field notes? string ---@field order integer ----@field _extra? table<string, any> +---@field _extra? pending.TaskExtra ---@class pending.Data ---@field version integer @@ -24,14 +37,14 @@ local config = require('pending.config') ---@class pending.TaskFields ---@field description string ----@field status? string +---@field status? pending.TaskStatus ---@field category? string ---@field priority? integer ---@field due? string ---@field recur? string ----@field recur_mode? string +---@field recur_mode? pending.RecurMode ---@field order? integer ----@field _extra? table +---@field _extra? pending.TaskExtra ---@class pending.Store ---@field path string @@ -81,6 +94,7 @@ local known_fields = { entry = true, modified = true, ['end'] = true, + notes = true, order = true, } @@ -112,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 @@ -138,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 = {}, } @@ -319,7 +337,7 @@ function Store:update(id, fields) end end task.modified = now - if fields.status == 'done' or fields.status == 'deleted' then + if fields.status == 'done' or fields.status == 'deleted' or fields.status == 'cancelled' then task['end'] = task['end'] or now end return task diff --git a/lua/pending/sync/gcal.lua b/lua/pending/sync/gcal.lua index a0a7617..811105e 100644 --- a/lua/pending/sync/gcal.lua +++ b/lua/pending/sync/gcal.lua @@ -177,6 +177,7 @@ function M.push() and ( task.status == 'done' or task.status == 'deleted' + or task.status == 'cancelled' or (task.status == 'pending' and not task.due) ) diff --git a/lua/pending/sync/oauth.lua b/lua/pending/sync/oauth.lua index 8e670c1..a49595c 100644 --- a/lua/pending/sync/oauth.lua +++ b/lua/pending/sync/oauth.lua @@ -24,6 +24,15 @@ local BUNDLED_CLIENT_SECRET = 'PLACEHOLDER' ---@field config_key string ---@class pending.OAuthClient : pending.OAuthClientOpts +---@field token_path fun(self: pending.OAuthClient): string +---@field resolve_credentials fun(self: pending.OAuthClient): pending.OAuthCredentials +---@field load_tokens fun(self: pending.OAuthClient): pending.OAuthTokens? +---@field save_tokens fun(self: pending.OAuthClient, tokens: pending.OAuthTokens): boolean +---@field refresh_access_token fun(self: pending.OAuthClient, creds: pending.OAuthCredentials, tokens: pending.OAuthTokens): pending.OAuthTokens? +---@field get_access_token fun(self: pending.OAuthClient): string? +---@field setup fun(self: pending.OAuthClient): nil +---@field auth fun(self: pending.OAuthClient, on_complete?: fun(ok: boolean): nil): nil +---@field clear_tokens fun(self: pending.OAuthClient): nil local OAuthClient = {} OAuthClient.__index = OAuthClient diff --git a/lua/pending/views.lua b/lua/pending/views.lua index b1e691e..3dbd06f 100644 --- a/lua/pending/views.lua +++ b/lua/pending/views.lua @@ -13,7 +13,7 @@ local parse = require('pending.parse') ---@field id? integer ---@field due? string ---@field raw_due? string ----@field status? string +---@field status? pending.TaskStatus ---@field category? string ---@field overdue? boolean ---@field show_category? boolean @@ -71,21 +71,24 @@ local function compute_forge_spans(task, prefix_len) end ---@type table<string, integer> -local status_rank = { wip = 0, pending = 1, blocked = 2, done = 3 } +local status_rank = { wip = 0, pending = 1, blocked = 2, done = 3, cancelled = 4 } ---@param task pending.Task ---@return string local function state_char(task) + local icons = config.get().icons if task.status == 'done' then - return 'x' + return icons.done + elseif task.status == 'cancelled' then + return icons.cancelled elseif task.status == 'wip' then - return '>' + return icons.wip elseif task.status == 'blocked' then - return '=' + return icons.blocked elseif task.priority > 0 then - return '!' + return icons.priority end - return ' ' + return icons.pending end ---@param tasks pending.Task[] @@ -106,17 +109,23 @@ local function sort_tasks(tasks) end) end ----@param tasks pending.Task[] -local function sort_tasks_priority(tasks) - table.sort(tasks, function(a, b) +---@type table<string, fun(a: pending.Task, b: pending.Task): boolean?> +local sort_key_comparators = { + status = function(a, b) local ra = status_rank[a.status] or 1 local rb = status_rank[b.status] or 1 if ra ~= rb then return ra < rb end + return nil + end, + priority = function(a, b) if a.priority ~= b.priority then return a.priority > b.priority end + return nil + end, + due = function(a, b) local a_due = a.due or '' local b_due = b.due or '' if a_due ~= b_due then @@ -128,11 +137,61 @@ local function sort_tasks_priority(tasks) end return a_due < b_due end + return nil + end, + order = function(a, b) if a.order ~= b.order then return a.order < b.order end - return a.id < b.id - end) + return nil + end, + id = function(a, b) + if a.id ~= b.id then + return a.id < b.id + end + return nil + end, + age = function(a, b) + if a.id ~= b.id then + return a.id < b.id + end + return nil + end, +} + +---@return fun(a: pending.Task, b: pending.Task): boolean +local function build_queue_comparator() + local log = require('pending.log') + local keys = config.get().view.queue.sort or { 'status', 'priority', 'due', 'order', 'id' } + local comparators = {} + local unknown = {} + for _, key in ipairs(keys) do + local cmp = sort_key_comparators[key] + if cmp then + table.insert(comparators, cmp) + else + table.insert(unknown, key) + end + end + if #unknown > 0 then + local label = #unknown == 1 and 'unknown queue sort key: ' or 'unknown queue sort keys: ' + log.warn(label .. table.concat(unknown, ', ')) + end + return function(a, b) + for _, cmp in ipairs(comparators) do + local result = cmp(a, b) + if result ~= nil then + return result + end + end + return false + end +end + +---@param tasks pending.Task[] +local function sort_tasks_priority(tasks) + local cmp = build_queue_comparator() + table.sort(tasks, cmp) end ---@param tasks pending.Task[] @@ -152,7 +211,7 @@ function M.category_view(tasks) by_cat[cat] = {} done_by_cat[cat] = {} end - if task.status == 'done' or task.status == 'deleted' then + if task.status == 'done' or task.status == 'deleted' or task.status == 'cancelled' then table.insert(done_by_cat[cat], task) else table.insert(by_cat[cat], task) @@ -215,7 +274,11 @@ function M.category_view(tasks) status = task.status, category = cat, priority = task.priority, - overdue = task.status ~= 'done' and task.due ~= nil and parse.is_overdue(task.due) or nil, + overdue = task.status ~= 'done' + and task.status ~= 'cancelled' + and task.due ~= nil + and parse.is_overdue(task.due) + or nil, recur = task.recur, forge_spans = compute_forge_spans(task, prefix_len), }) @@ -233,7 +296,7 @@ function M.priority_view(tasks) local done = {} for _, task in ipairs(tasks) do - if task.status == 'done' then + if task.status == 'done' or task.status == 'cancelled' then table.insert(done, task) else table.insert(pending, task) @@ -256,7 +319,7 @@ function M.priority_view(tasks) for _, task in ipairs(all) do local prefix = '/' .. task.id .. '/' - local state = task.status == 'done' and 'x' or (task.priority > 0 and '!' or ' ') + local state = state_char(task) local line = prefix .. '- [' .. state .. '] ' .. task.description local prefix_len = #prefix + #('- [' .. state .. '] ') table.insert(lines, line) @@ -268,12 +331,17 @@ function M.priority_view(tasks) status = task.status, category = task.category, priority = task.priority, - overdue = task.status ~= 'done' and task.due ~= nil and parse.is_overdue(task.due) or nil, + overdue = task.status ~= 'done' + and task.status ~= 'cancelled' + and task.due ~= nil + and parse.is_overdue(task.due) + or nil, show_category = true, recur = task.recur, 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 084f162..48ade42 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -6,18 +6,19 @@ vim.g.loaded_pending = true ---@return string[] local function edit_field_candidates() local cfg = require('pending.config').get() + local ck = cfg.category_syntax or 'cat' local dk = cfg.date_syntax or 'due' local rk = cfg.recur_syntax or 'rec' return { dk .. ':', - 'cat:', + ck .. ':', rk .. ':', '+!', '+!!', '+!!!', '-!', '-' .. dk, - '-cat', + '-' .. ck, '-' .. rk, } end @@ -79,6 +80,65 @@ local function filter_candidates(lead, candidates) end, candidates) end +---@param arg_lead string +---@return string[] +local function complete_add(arg_lead) + local cfg = require('pending.config').get() + local dk = cfg.date_syntax or 'due' + local rk = cfg.recur_syntax or 'rec' + local ck = cfg.category_syntax or 'cat' + + local prefix = arg_lead:match('^(' .. vim.pesc(dk) .. ':)(.*)$') + if prefix then + local after_colon = arg_lead:sub(#prefix + 1) + local result = {} + for _, d in ipairs(edit_date_values()) do + if d:find(after_colon, 1, true) == 1 then + table.insert(result, prefix .. d) + end + end + return result + end + + local rec_prefix = arg_lead:match('^(' .. vim.pesc(rk) .. ':)(.*)$') + if rec_prefix then + local after_colon = arg_lead:sub(#rec_prefix + 1) + local result = {} + for _, p in ipairs(edit_recur_values()) do + if p:find(after_colon, 1, true) == 1 then + table.insert(result, rec_prefix .. p) + end + end + return result + end + + local cat_prefix = arg_lead:match('^(' .. vim.pesc(ck) .. ':)(.*)$') + if cat_prefix then + local after_colon = arg_lead:sub(#cat_prefix + 1) + local store = require('pending.store') + local s = store.new(store.resolve_path()) + s:load() + local seen = {} + local cats = {} + for _, task in ipairs(s:active_tasks()) do + if task.category and not seen[task.category] then + seen[task.category] = true + table.insert(cats, task.category) + end + end + table.sort(cats) + local result = {} + for _, c in ipairs(cats) do + if c:find(after_colon, 1, true) == 1 then + table.insert(result, cat_prefix .. c) + end + end + return result + end + + return {} +end + ---@param arg_lead string ---@param cmd_line string ---@return string[] @@ -135,7 +195,9 @@ local function complete_edit(arg_lead, cmd_line) return result end - local cat_prefix = arg_lead:match('^(cat:)(.*)$') + local ck = cfg.category_syntax or 'cat' + + local cat_prefix = arg_lead:match('^(' .. vim.pesc(ck) .. ':)(.*)$') if cat_prefix then local after_colon = arg_lead:sub(#cat_prefix + 1) local store = require('pending.store') @@ -183,8 +245,17 @@ end, { for word in after_filter:gmatch('%S+') do used[word] = true end - local candidates = - { 'clear', 'overdue', 'today', 'priority', 'done', 'pending', 'wip', 'blocked' } + local candidates = { + 'clear', + 'overdue', + 'today', + 'priority', + 'done', + 'pending', + 'wip', + 'blocked', + 'cancelled', + } local store = require('pending.store') local s = store.new(store.resolve_path()) s:load() @@ -192,7 +263,8 @@ end, { for _, task in ipairs(s:active_tasks()) do if task.category and not seen[task.category] then seen[task.category] = true - table.insert(candidates, 'cat:' .. task.category) + local ck = (require('pending.config').get().category_syntax or 'cat') + table.insert(candidates, ck .. ':' .. task.category) end end local filtered = {} @@ -203,6 +275,9 @@ end, { end return filtered end + if cmd_line:match('^Pending%s+add%s') then + return complete_add(arg_lead) + end if cmd_line:match('^Pending%s+archive%s') then return filter_candidates(arg_lead, { '7d', '2w', '30d', '3m', '6m', '1y' }) end @@ -229,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 @@ -238,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 {} @@ -253,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 = {} @@ -328,6 +403,14 @@ vim.keymap.set('n', '<Plug>(pending-blocked)', function() require('pending').toggle_status('blocked') end) +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) @@ -336,6 +419,16 @@ vim.keymap.set('n', '<Plug>(pending-priority-down)', function() require('pending').decrement_priority() end) +vim.keymap.set('x', '<Plug>(pending-priority-up-visual)', function() + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes('<Esc>', true, false, true), 'nx', false) + require('pending').increment_priority_visual() +end) + +vim.keymap.set('x', '<Plug>(pending-priority-down-visual)', function() + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes('<Esc>', true, false, true), 'nx', false) + require('pending').decrement_priority_visual() +end) + vim.keymap.set('n', '<Plug>(pending-filter)', function() vim.ui.input({ prompt = 'Filter: ' }, function(input) if input then 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/diff_spec.lua b/spec/diff_spec.lua index 01d8aac..b69bd5a 100644 --- a/spec/diff_spec.lua +++ b/spec/diff_spec.lua @@ -71,7 +71,7 @@ describe('diff', function() '/1/- [ ] Take trash out rec:weekly', } local result = diff.parse_buffer(lines) - assert.are.equal('weekly', result[2].rec) + assert.are.equal('weekly', result[2].recur) end) it('extracts rec: with completion mode', function() @@ -80,8 +80,8 @@ describe('diff', function() '/1/- [ ] Water plants rec:!daily', } local result = diff.parse_buffer(lines) - assert.are.equal('daily', result[2].rec) - assert.are.equal('completion', result[2].rec_mode) + assert.are.equal('daily', result[2].recur) + assert.are.equal('completion', result[2].recur_mode) end) it('inline due: token is parsed', function() @@ -275,6 +275,98 @@ describe('diff', function() assert.are.equal('completion', tasks[1].recur_mode) end) + it('returns forge refs for new tasks', function() + local lines = { + '# Inbox', + '- [ ] Fix bug gh:user/repo#42', + } + local refs = diff.apply(lines, s) + assert.are.equal(1, #refs) + assert.are.equal('github', refs[1].forge) + assert.are.equal(42, refs[1].number) + end) + + it('returns forge refs for changed refs on existing tasks', function() + s:add({ + description = 'Fix bug gh:user/repo#1', + _extra = { + _forge_ref = { + forge = 'github', + owner = 'user', + repo = 'repo', + type = 'issue', + number = 1, + url = '', + }, + }, + }) + s:save() + local lines = { + '# Todo', + '/1/- [ ] Fix bug gh:user/repo#99', + } + local refs = diff.apply(lines, s) + assert.are.equal(1, #refs) + assert.are.equal(99, refs[1].number) + end) + + it('returns empty when forge ref is unchanged', function() + s:add({ + description = 'Fix bug gh:user/repo#42', + _extra = { + _forge_ref = { + forge = 'github', + owner = 'user', + repo = 'repo', + type = 'issue', + number = 42, + url = '', + }, + }, + }) + s:save() + local lines = { + '# Todo', + '/1/- [ ] Fix bug gh:user/repo#42', + } + local refs = diff.apply(lines, s) + assert.are.equal(0, #refs) + end) + + it('returns empty for tasks without forge refs', function() + local lines = { + '# Inbox', + '- [ ] Plain task', + } + local refs = diff.apply(lines, s) + assert.are.equal(0, #refs) + end) + + it('returns forge refs for duplicated tasks', function() + s:add({ + description = 'Fix bug gh:user/repo#42', + _extra = { + _forge_ref = { + forge = 'github', + owner = 'user', + repo = 'repo', + type = 'issue', + number = 42, + url = '', + }, + }, + }) + s:save() + local lines = { + '# Todo', + '/1/- [ ] Fix bug gh:user/repo#42', + '/1/- [ ] Fix bug gh:user/repo#42', + } + local refs = diff.apply(lines, s) + assert.are.equal(1, #refs) + assert.are.equal(42, refs[1].number) + end) + it('clears priority when [N] is removed from buffer line', function() s:add({ description = 'Task name', priority = 1 }) s:save() @@ -287,5 +379,41 @@ describe('diff', function() local task = s:get(1) assert.are.equal(0, task.priority) end) + + it('sets priority from +!! token', function() + local lines = { + '# Inbox', + '- [ ] Pay bills +!!', + } + diff.apply(lines, s) + s:load() + local task = s:get(1) + assert.are.equal(2, task.priority) + end) + + it('updates priority between non-zero values', function() + s:add({ description = 'Task name', priority = 2 }) + s:save() + local lines = { + '# Inbox', + '/1/- [!] Task name', + } + diff.apply(lines, s) + s:load() + local task = s:get(1) + assert.are.equal(1, task.priority) + end) + + it('parses metadata with forge ref on same line', function() + local lines = { + '# Inbox', + '- [ ] Fix bug due:2026-03-15 gh:user/repo#42', + } + diff.apply(lines, s) + s:load() + local task = s:get(1) + assert.are.equal('2026-03-15', task.due) + assert.is_not_nil(task._extra._forge_ref) + end) end) end) diff --git a/spec/forge_spec.lua b/spec/forge_spec.lua index fac8021..ab8d5c4 100644 --- a/spec/forge_spec.lua +++ b/spec/forge_spec.lua @@ -44,12 +44,30 @@ describe('forge', function() assert.is_nil(forge._parse_shorthand('xx:user/repo#1')) end) - it('rejects missing number', function() - assert.is_nil(forge._parse_shorthand('gh:user/repo')) + it('parses bare gh: shorthand without number', function() + local ref = forge._parse_shorthand('gh:user/repo') + assert.is_not_nil(ref) + assert.equals('github', ref.forge) + assert.equals('user', ref.owner) + assert.equals('repo', ref.repo) + assert.equals('repo', ref.type) + assert.is_nil(ref.number) + assert.equals('https://github.com/user/repo', ref.url) + end) + + it('parses bare gl: shorthand without number', function() + local ref = forge._parse_shorthand('gl:group/project') + assert.is_not_nil(ref) + assert.equals('gitlab', ref.forge) + assert.equals('group', ref.owner) + assert.equals('project', ref.repo) + assert.equals('repo', ref.type) + assert.is_nil(ref.number) end) it('rejects missing repo', function() assert.is_nil(forge._parse_shorthand('gh:user#1')) + assert.is_nil(forge._parse_shorthand('gh:user')) end) end) @@ -73,6 +91,23 @@ describe('forge', function() it('rejects non-github URL', function() assert.is_nil(forge._parse_github_url('https://example.com/user/repo/issues/1')) end) + + it('parses bare repo URL', function() + local ref = forge._parse_github_url('https://github.com/user/repo') + assert.is_not_nil(ref) + assert.equals('github', ref.forge) + assert.equals('user', ref.owner) + assert.equals('repo', ref.repo) + assert.equals('repo', ref.type) + assert.is_nil(ref.number) + end) + + it('parses bare repo URL with trailing slash', function() + local ref = forge._parse_github_url('https://github.com/user/repo/') + assert.is_not_nil(ref) + assert.equals('repo', ref.type) + assert.is_nil(ref.number) + end) end) describe('_parse_gitlab_url', function() @@ -98,6 +133,16 @@ describe('forge', function() assert.equals('org/sub', ref.owner) assert.equals('project', ref.repo) end) + + it('parses bare repo URL', function() + local ref = forge._parse_gitlab_url('https://gitlab.com/group/project') + assert.is_not_nil(ref) + assert.equals('gitlab', ref.forge) + assert.equals('group', ref.owner) + assert.equals('project', ref.repo) + assert.equals('repo', ref.type) + assert.is_nil(ref.number) + end) end) describe('_parse_codeberg_url', function() @@ -116,6 +161,16 @@ describe('forge', function() assert.is_not_nil(ref) assert.equals('pull_request', ref.type) end) + + it('parses bare repo URL', function() + local ref = forge._parse_codeberg_url('https://codeberg.org/user/repo') + assert.is_not_nil(ref) + assert.equals('codeberg', ref.forge) + assert.equals('user', ref.owner) + assert.equals('repo', ref.repo) + assert.equals('repo', ref.type) + assert.is_nil(ref.number) + end) end) describe('parse_ref', function() @@ -141,6 +196,14 @@ describe('forge', function() assert.is_nil(forge.parse_ref('hello')) assert.is_nil(forge.parse_ref('due:tomorrow')) end) + + it('dispatches bare shorthand', function() + local ref = forge.parse_ref('gh:user/repo') + assert.is_not_nil(ref) + assert.equals('github', ref.forge) + assert.equals('repo', ref.type) + assert.is_nil(ref.number) + end) end) describe('find_refs', function() @@ -184,6 +247,17 @@ describe('forge', function() assert.equals(0, refs[1].start_byte) assert.equals(8, refs[1].end_byte) end) + + it('finds bare shorthand ref', function() + local refs = forge.find_refs('Fix gh:user/repo') + assert.equals(1, #refs) + assert.equals('github', refs[1].ref.forge) + assert.equals('repo', refs[1].ref.type) + assert.is_nil(refs[1].ref.number) + assert.equals('gh:user/repo', refs[1].raw) + assert.equals(4, refs[1].start_byte) + assert.equals(16, refs[1].end_byte) + end) end) describe('_api_args', function() @@ -262,6 +336,30 @@ describe('forge', function() assert.equals('PendingForgeClosed', hl) end) + it('formats bare repo ref without #N', function() + local text = forge.format_label({ + forge = 'github', + owner = 'user', + repo = 'repo', + type = 'repo', + url = '', + }, nil) + assert.truthy(text:find('user/repo')) + assert.is_nil(text:find('#')) + end) + + it('still formats numbered ref with #N', function() + local text = forge.format_label({ + forge = 'github', + owner = 'user', + repo = 'repo', + type = 'issue', + number = 42, + url = '', + }, nil) + assert.truthy(text:find('user/repo#42')) + end) + it('uses closed highlight for merged state', function() local _, hl = forge.format_label({ forge = 'gitlab', @@ -306,7 +404,7 @@ describe('forge parse.body integration', function() it('extracts category but keeps forge ref in description', function() local desc, meta = parse.body('Fix bug gh:user/repo#42 cat:Work') assert.equals('Fix bug gh:user/repo#42', desc) - assert.equals('Work', meta.cat) + assert.equals('Work', meta.category) end) it('leaves non-forge tokens as description', function() @@ -330,7 +428,7 @@ describe('forge registry', function() end) it('register() with custom backend resolves URLs', function() - local custom = forge.gitea_backend({ + local custom = forge.gitea_forge({ name = 'mygitea', shorthand = 'mg', default_host = 'gitea.example.com', @@ -367,8 +465,8 @@ describe('forge registry', function() assert.same({ 'tea', 'api', '/repos/alice/proj/issues/7' }, args) end) - it('gitea_backend() creates a working backend', function() - local b = forge.gitea_backend({ + it('gitea_forge() creates a working backend', function() + local b = forge.gitea_forge({ name = 'forgejo', shorthand = 'fj', default_host = 'forgejo.example.com', @@ -391,6 +489,99 @@ describe('forge registry', function() end) end) +describe('custom forge prefixes', function() + local config = require('pending.config') + local complete = require('pending.complete') + + it('parses custom-length shorthand (3+ chars)', function() + local custom = forge.gitea_forge({ + name = 'customforge', + shorthand = 'cgf', + default_host = 'custom.example.com', + }) + forge.register(custom) + + local ref = forge._parse_shorthand('cgf:alice/proj#99') + assert.is_not_nil(ref) + assert.equals('customforge', ref.forge) + assert.equals('alice', ref.owner) + assert.equals('proj', ref.repo) + assert.equals(99, ref.number) + end) + + it('parse_ref dispatches custom-length shorthand', function() + local ref = forge.parse_ref('cgf:alice/proj#5') + assert.is_not_nil(ref) + assert.equals('customforge', ref.forge) + assert.equals(5, ref.number) + end) + + it('find_refs finds custom-length shorthand', function() + local refs = forge.find_refs('Fix cgf:alice/proj#12') + assert.equals(1, #refs) + assert.equals('customforge', refs[1].ref.forge) + assert.equals(12, refs[1].ref.number) + end) + + it('completion returns entries for custom backends', function() + assert.is_true(complete._is_forge_source('cgf')) + end) + + it('config shorthand override re-registers backend', function() + vim.g.pending = { + forge = { + github = { shorthand = 'github' }, + }, + } + config.reset() + forge._reset_instances() + + local ref = forge._parse_shorthand('github:user/repo#1') + assert.is_not_nil(ref) + assert.equals('github', ref.forge) + assert.equals('user', ref.owner) + assert.equals('repo', ref.repo) + assert.equals(1, ref.number) + + assert.is_nil(forge._parse_shorthand('gh:user/repo#1')) + + vim.g.pending = nil + config.reset() + for _, b in ipairs(forge.backends()) do + if b.name == 'github' then + b.shorthand = 'gh' + end + end + forge._reset_instances() + end) +end) + +describe('is_configured', function() + it('returns false when vim.g.pending is nil', function() + vim.g.pending = nil + assert.is_false(forge.is_configured('github')) + end) + + it('returns false when forge key is absent', function() + vim.g.pending = { forge = { close = true } } + assert.is_false(forge.is_configured('github')) + vim.g.pending = nil + end) + + it('returns true when forge key is present', function() + vim.g.pending = { forge = { github = {} } } + assert.is_true(forge.is_configured('github')) + assert.is_false(forge.is_configured('gitlab')) + vim.g.pending = nil + end) + + it('returns true for non-empty forge config', function() + vim.g.pending = { forge = { gitlab = { icon = '' } } } + assert.is_true(forge.is_configured('gitlab')) + vim.g.pending = nil + end) +end) + describe('forge diff integration', function() local store = require('pending.store') local diff = require('pending.diff') @@ -449,4 +640,19 @@ describe('forge diff integration', function() assert.equals(1, updated._extra._forge_ref.number) os.remove(tmp) end) + + it('stores bare forge_ref in _extra on new task', function() + local tmp = os.tmpname() + local s = store.new(tmp) + s:load() + diff.apply({ '- [ ] Check out gh:user/repo' }, s) + local tasks = s:active_tasks() + assert.equals(1, #tasks) + assert.is_not_nil(tasks[1]._extra) + assert.is_not_nil(tasks[1]._extra._forge_ref) + assert.equals('github', tasks[1]._extra._forge_ref.forge) + assert.equals('repo', tasks[1]._extra._forge_ref.type) + assert.is_nil(tasks[1]._extra._forge_ref.number) + os.remove(tmp) + end) end) diff --git a/spec/parse_spec.lua b/spec/parse_spec.lua index 0820356..b0a3f8e 100644 --- a/spec/parse_spec.lua +++ b/spec/parse_spec.lua @@ -31,27 +31,52 @@ describe('parse', function() it('extracts category', function() local desc, meta = parse.body('Buy groceries cat:Errands') assert.are.equal('Buy groceries', desc) - assert.are.equal('Errands', meta.cat) + assert.are.equal('Errands', meta.category) end) it('extracts both due and cat', function() local desc, meta = parse.body('Buy milk due:2026-03-15 cat:Errands') assert.are.equal('Buy milk', desc) assert.are.equal('2026-03-15', meta.due) - assert.are.equal('Errands', meta.cat) + assert.are.equal('Errands', meta.category) end) it('extracts metadata in any order', function() local desc, meta = parse.body('Buy milk cat:Errands due:2026-03-15') assert.are.equal('Buy milk', desc) assert.are.equal('2026-03-15', meta.due) - assert.are.equal('Errands', meta.cat) + assert.are.equal('Errands', meta.category) end) - it('stops at duplicate key', function() + it('first occurrence wins for duplicate keys and warns', function() + local warnings = {} + local orig = vim.notify + vim.notify = function(m, level) + if level == vim.log.levels.WARN then + table.insert(warnings, m) + end + end 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) + vim.notify = orig + assert.are.equal('Buy milk', desc) + assert.are.equal('2026-03-15', meta.due) + assert.are.equal(1, #warnings) + assert.truthy(warnings[1]:find('duplicate', 1, true)) + end) + + it('drops identical duplicate metadata tokens and warns', function() + local warnings = {} + local orig = vim.notify + vim.notify = function(m, level) + if level == vim.log.levels.WARN then + table.insert(warnings, m) + end + end + local desc, meta = parse.body('Buy milk due:tomorrow due:tomorrow') + vim.notify = orig + assert.are.equal('Buy milk', desc) + assert.are.equal(os.date('%Y-%m-%d', os.time() + 86400), meta.due) + assert.are.equal(1, #warnings) end) it('stops at non-meta token', function() @@ -110,6 +135,66 @@ describe('parse', function() assert.is_nil(meta.due) assert.truthy(desc:find('due:garbage', 1, true)) end) + + it('parses metadata before a forge ref', function() + local desc, meta = parse.body('Fix bug due:2026-03-15 gh:user/repo#42') + assert.are.equal('2026-03-15', meta.due) + assert.truthy(desc:find('gh:user/repo#42', 1, true)) + assert.truthy(desc:find('Fix bug', 1, true)) + end) + + it('parses metadata after a forge ref', function() + local desc, meta = parse.body('Fix bug gh:user/repo#42 due:2026-03-15') + assert.are.equal('2026-03-15', meta.due) + assert.truthy(desc:find('gh:user/repo#42', 1, true)) + assert.truthy(desc:find('Fix bug', 1, true)) + end) + + it('parses all metadata around forge ref', function() + local desc, meta = parse.body('Fix bug due:tomorrow gh:user/repo#42 cat:Work') + assert.are.equal(os.date('%Y-%m-%d', os.time() + 86400), meta.due) + assert.are.equal('Work', meta.category) + assert.truthy(desc:find('gh:user/repo#42', 1, true)) + end) + + it('parses forge ref between metadata tokens', function() + local desc, meta = parse.body('Fix bug cat:Work gl:a/b#12 due:2026-03-15') + assert.are.equal('2026-03-15', meta.due) + 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() @@ -400,7 +485,7 @@ describe('parse', function() it('detects category prefix', function() local desc, meta = parse.command_add('School: Do homework') assert.are.equal('Do homework', desc) - assert.are.equal('School', meta.cat) + assert.are.equal('School', meta.category) end) it('ignores lowercase prefix', function() @@ -411,7 +496,7 @@ describe('parse', function() it('combines category prefix with inline metadata', function() local desc, meta = parse.command_add('School: Do homework due:2026-03-15') assert.are.equal('Do homework', desc) - assert.are.equal('School', meta.cat) + assert.are.equal('School', meta.category) assert.are.equal('2026-03-15', meta.due) end) end) diff --git a/spec/recur_spec.lua b/spec/recur_spec.lua index 53b7478..c072b7b 100644 --- a/spec/recur_spec.lua +++ b/spec/recur_spec.lua @@ -8,7 +8,7 @@ describe('recur', function() local r = recur.parse('daily') assert.are.equal('daily', r.freq) assert.are.equal(1, r.interval) - assert.is_false(r.from_completion) + assert.are.equal('scheduled', r.mode) end) it('parses weekdays', function() @@ -79,7 +79,7 @@ describe('recur', function() it('parses ! prefix as completion-based', function() local r = recur.parse('!weekly') assert.are.equal('weekly', r.freq) - assert.is_true(r.from_completion) + assert.are.equal('completion', r.mode) end) it('parses raw RRULE fragment', 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() diff --git a/spec/views_spec.lua b/spec/views_spec.lua index ff8ad93..e841deb 100644 --- a/spec/views_spec.lua +++ b/spec/views_spec.lua @@ -450,5 +450,83 @@ describe('views', function() end assert.is_nil(task_meta.recur) end) + + it('sorts by due before priority when sort config is reordered', function() + vim.g.pending = { + data_path = tmpdir .. '/tasks.json', + view = { queue = { sort = { 'status', 'due', 'priority', 'order', 'id' } } }, + } + config.reset() + s:add({ description = 'High no due', category = 'Work', priority = 2 }) + s:add({ description = 'Low with due', category = 'Work', priority = 0, due = '2050-01-01' }) + local lines, meta = views.priority_view(s:active_tasks()) + local due_row, nodue_row + for i, m in ipairs(meta) do + if m.type == 'task' then + if lines[i]:find('Low with due') then + due_row = i + elseif lines[i]:find('High no due') then + nodue_row = i + end + end + end + assert.is_true(due_row < nodue_row) + end) + + it('uses default sort when config sort is nil', function() + vim.g.pending = { + data_path = tmpdir .. '/tasks.json', + view = { queue = {} }, + } + config.reset() + s:add({ description = 'Low', category = 'Work', priority = 0 }) + s:add({ description = 'High', category = 'Work', priority = 1 }) + local lines, meta = views.priority_view(s:active_tasks()) + local high_row, low_row + for i, m in ipairs(meta) do + if m.type == 'task' then + if lines[i]:find('High') then + high_row = i + elseif lines[i]:find('Low') then + low_row = i + end + end + end + assert.is_true(high_row < low_row) + end) + + it('ignores unknown sort keys with a warning', function() + vim.g.pending = { + data_path = tmpdir .. '/tasks.json', + view = { queue = { sort = { 'bogus', 'status', 'id' } } }, + } + config.reset() + s:add({ description = 'A', category = 'Work' }) + s:add({ description = 'B', category = 'Work' }) + local lines = views.priority_view(s:active_tasks()) + assert.is_true(#lines == 2) + end) + + it('supports age sort key as alias for id', function() + vim.g.pending = { + data_path = tmpdir .. '/tasks.json', + view = { queue = { sort = { 'age' } } }, + } + config.reset() + s:add({ description = 'Older', category = 'Work' }) + s:add({ description = 'Newer', category = 'Work' }) + local lines, meta = views.priority_view(s:active_tasks()) + local older_row, newer_row + for i, m in ipairs(meta) do + if m.type == 'task' then + if lines[i]:find('Older') then + older_row = i + elseif lines[i]:find('Newer') then + newer_row = i + end + end + end + assert.is_true(older_row < newer_row) + end) end) end)