Compare commits

..

No commits in common. "e816e6fb7e145e12fa22942fecf52db48d8ba176" and "0b0b64fc3d3f02d8e9ca1d72b83999f3f64f8b88" have entirely different histories.

12 changed files with 90 additions and 1088 deletions

View file

@ -140,9 +140,9 @@ COMMANDS *pending-commands*
:Pending add Work: standup due:tomorrow rec:weekdays :Pending add Work: standup due:tomorrow rec:weekdays
:Pending add Buy milk due:fri +!! :Pending add Buy milk due:fri +!!
< <
`+!`, `+!!`, or `+!!!` tokens anywhere in the text set the priority Trailing `+!`, `+!!`, or `+!!!` tokens set the priority level (capped
level (capped at `max_priority`). If the buffer is currently open it at `max_priority`). If the buffer is currently open it is re-rendered
is re-rendered after the add. after the add.
*:Pending-archive* *:Pending-archive*
:Pending archive [{duration}] :Pending archive [{duration}]
@ -348,7 +348,6 @@ Default buffer-local keys: ~
`gw` Toggle work-in-progress status (`wip`) `gw` Toggle work-in-progress status (`wip`)
`gb` Toggle blocked status (`blocked`) `gb` Toggle blocked status (`blocked`)
`g/` Toggle cancelled status (`cancelled`) `g/` Toggle cancelled status (`cancelled`)
`ge` Open markdown detail buffer for task notes (`edit_notes`)
`gf` Prompt for filter predicates (`filter`) `gf` Prompt for filter predicates (`filter`)
`<Tab>` Switch between category / queue view (`view`) `<Tab>` Switch between category / queue view (`view`)
`gz` Undo the last `:w` save (`undo`) `gz` Undo the last `:w` save (`undo`)
@ -488,12 +487,6 @@ old keys to `false`: >lua
Decrement the priority level for the task under the cursor, clamped Decrement the priority level for the task under the cursor, clamped
at 0. Default key: `<C-x>`. at 0. Default key: `<C-x>`.
*<Plug>(pending-edit-notes)*
<Plug>(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`.
*<Plug>(pending-open-line)* *<Plug>(pending-open-line)*
<Plug>(pending-open-line) <Plug>(pending-open-line)
Insert a correctly-formatted blank task line below the cursor. Insert a correctly-formatted blank task line below the cursor.
@ -564,29 +557,6 @@ Queue view: ~ *pending-view-queue*
virtual text so tasks remain identifiable across categories. The virtual text so tasks remain identifiable across categories. The
buffer is named `pending://queue`. 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/<id>`. The buffer replaces the task list in
the same split.
Layout: ~
Line 1: `# <description>` (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* FILTERS *pending-filters*
@ -638,8 +608,8 @@ task data.
============================================================================== ==============================================================================
INLINE METADATA *pending-metadata* INLINE METADATA *pending-metadata*
Metadata tokens may appear anywhere in a task line. On save, tokens are Metadata tokens may be appended to any task line before saving. Tokens are
extracted from any position and the remaining words form the description. parsed from the right and consumed until a non-metadata token is reached.
Supported tokens: ~ Supported tokens: ~
@ -663,8 +633,9 @@ On `:w`, the description becomes `Buy milk`, the due date is stored as
`2026-03-15` and rendered as right-aligned virtual text, and the task is `2026-03-15` and rendered as right-aligned virtual text, and the task is
placed under the `Errands` category header. placed under the `Errands` category header.
Only the first occurrence of each metadata type is consumed — duplicate Parsing stops at the first token that is not a recognised metadata token.
tokens are silently dropped. Repeated tokens of the same type also stop parsing — only one `due:`, one
`cat:`, and one `rec:` per task line are consumed.
Omnifunc completion is available for `due:`, `cat:`, and `rec:` token types. Omnifunc completion is available for `due:`, `cat:`, and `rec:` token types.
In insert mode, type the token prefix and press `<C-x><C-o>` to see In insert mode, type the token prefix and press `<C-x><C-o>` to see
@ -818,7 +789,6 @@ loads: >lua
wip = 'gw', wip = 'gw',
blocked = 'gb', blocked = 'gb',
cancelled = 'g/', cancelled = 'g/',
edit_notes = 'ge',
}, },
sync = { sync = {
gcal = {}, gcal = {},
@ -1681,7 +1651,6 @@ Task fields: ~
{entry} (string) ISO 8601 UTC timestamp of creation. {entry} (string) ISO 8601 UTC timestamp of creation.
{modified} (string) ISO 8601 UTC timestamp of last modification. {modified} (string) ISO 8601 UTC timestamp of last modification.
{end} (string) ISO 8601 UTC timestamp of completion or deletion. {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. {order} (integer) Relative ordering within a category.
Any field not in the list above is preserved in `_extra` and written back on Any field not in the list above is preserved in `_extra` and written back on

View file

@ -584,7 +584,6 @@ local function setup_highlights()
vim.api.nvim_set_hl(0, 'PendingFilter', { link = 'DiagnosticWarn', default = true }) vim.api.nvim_set_hl(0, 'PendingFilter', { link = 'DiagnosticWarn', default = true })
vim.api.nvim_set_hl(0, 'PendingForge', { link = 'DiagnosticInfo', 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, 'PendingForgeClosed', { link = 'Comment', default = true })
vim.api.nvim_set_hl(0, 'PendingDetailMeta', { link = 'Comment', default = true })
end end
---@return string ---@return string
@ -806,329 +805,4 @@ function M.open()
return task_bufnr return task_bufnr
end 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 # <title>)'
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 return M

View file

@ -82,7 +82,6 @@
---@field priority_up_visual? string|false ---@field priority_up_visual? string|false
---@field priority_down_visual? string|false ---@field priority_down_visual? string|false
---@field cancelled? string|false ---@field cancelled? string|false
---@field edit_notes? string|false
---@class pending.CategoryViewConfig ---@class pending.CategoryViewConfig
---@field order? string[] ---@field order? string[]
@ -164,7 +163,6 @@ local defaults = {
wip = 'gw', wip = 'gw',
blocked = 'gb', blocked = 'gb',
cancelled = 'g/', cancelled = 'g/',
edit_notes = 'ge',
priority_up = '<C-a>', priority_up = '<C-a>',
priority_down = '<C-x>', priority_down = '<C-x>',
priority_up_visual = 'g<C-a>', priority_up_visual = 'g<C-a>',

View file

@ -59,7 +59,7 @@ function M.check()
end end
local sync_paths = vim.fn.globpath(vim.o.runtimepath, 'lua/pending/sync/*.lua', false, true) local sync_paths = vim.fn.globpath(vim.o.runtimepath, 'lua/pending/sync/*.lua', false, true)
if #sync_paths == 0 and vim.tbl_isempty(require('pending').registered_backends()) then if #sync_paths == 0 then
vim.health.info('No sync backends found') vim.health.info('No sync backends found')
else else
for _, path in ipairs(sync_paths) do for _, path in ipairs(sync_paths) do
@ -70,12 +70,6 @@ function M.check()
backend.health() backend.health()
end end
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
end end

View file

@ -401,9 +401,6 @@ function M._setup_buf_mappings(bufnr)
open_line_above = function() open_line_above = function()
buffer.open_line(true) buffer.open_line(true)
end, end,
edit_notes = function()
M.open_detail()
end,
} }
for name, fn in pairs(actions) do for name, fn in pairs(actions) do
@ -891,46 +888,6 @@ function M.toggle_status(target_status)
end end
end end
---@return nil
function M.open_detail()
local bufnr = buffer.bufnr()
if not bufnr then
return
end
if not require_saved() then
return
end
local row = vim.api.nvim_win_get_cursor(0)[1]
local meta = buffer.meta()
if not meta[row] or meta[row].type ~= 'task' then
return
end
local id = meta[row].id
if not id then
return
end
local detail_bufnr = buffer.open_detail(id)
if not detail_bufnr then
return
end
local group = vim.api.nvim_create_augroup('PendingDetail', { clear = true })
vim.api.nvim_create_autocmd('BufWriteCmd', {
group = group,
buffer = detail_bufnr,
callback = function()
buffer.save_detail()
end,
})
local km = require('pending.config').get().keymaps
vim.keymap.set('n', km.close or 'q', function()
vim.api.nvim_del_augroup_by_name('PendingDetail')
buffer.close_detail()
end, { buffer = detail_bufnr })
end
---@param direction 'up'|'down' ---@param direction 'up'|'down'
---@return nil ---@return nil
function M.move_task(direction) function M.move_task(direction)
@ -1141,60 +1098,18 @@ end
---@class pending.SyncBackend ---@class pending.SyncBackend
---@field name string ---@field name string
---@field auth? fun(sub_action?: string): nil ---@field auth fun(): nil
---@field push? fun(): nil ---@field push? fun(): nil
---@field pull? fun(): nil ---@field pull? fun(): nil
---@field sync? fun(): nil ---@field sync? fun(): nil
---@field health? fun(): nil ---@field health? fun(): nil
---@type table<string, pending.SyncBackend>
local _registered_backends = {}
---@type string[]? ---@type string[]?
local _sync_backends = nil local _sync_backends = nil
---@type table<string, true>? ---@type table<string, true>?
local _sync_backend_set = nil 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> ---@return string[], table<string, true>
local function discover_backends() local function discover_backends()
if _sync_backends then if _sync_backends then
@ -1211,12 +1126,6 @@ local function discover_backends()
_sync_backend_set[mod.name] = true _sync_backend_set[mod.name] = true
end end
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) table.sort(_sync_backends)
return _sync_backends, _sync_backend_set return _sync_backends, _sync_backend_set
end end
@ -1225,8 +1134,8 @@ end
---@param action? string ---@param action? string
---@return nil ---@return nil
local function run_sync(backend_name, action) local function run_sync(backend_name, action)
local backend = M.resolve_backend(backend_name) local ok, backend = pcall(require, 'pending.sync.' .. backend_name)
if not backend then if not ok then
log.error('Unknown sync backend: ' .. backend_name) log.error('Unknown sync backend: ' .. backend_name)
return return
end end
@ -1591,8 +1500,8 @@ function M.auth(args)
local backends_list = discover_backends() local backends_list = discover_backends()
local auth_backends = {} local auth_backends = {}
for _, name in ipairs(backends_list) do for _, name in ipairs(backends_list) do
local mod = M.resolve_backend(name) local ok, mod = pcall(require, 'pending.sync.' .. name)
if mod and type(mod.auth) == 'function' then if ok and type(mod.auth) == 'function' then
table.insert(auth_backends, { name = name, mod = mod }) table.insert(auth_backends, { name = name, mod = mod })
end end
end end

View file

@ -536,6 +536,7 @@ function M.body(text)
end end
local metadata = {} local metadata = {}
local i = #tokens
local ck = category_key() local ck = category_key()
local dk = date_key() local dk = date_key()
local rk = recur_key() local rk = recur_key()
@ -543,82 +544,84 @@ function M.body(text)
local date_pattern_strict = '^' .. vim.pesc(dk) .. ':(%d%d%d%d%-%d%d%-%d%d[T%d:]*)$' local date_pattern_strict = '^' .. vim.pesc(dk) .. ':(%d%d%d%d%-%d%d%-%d%d[T%d:]*)$'
local date_pattern_any = '^' .. vim.pesc(dk) .. ':(.+)$' local date_pattern_any = '^' .. vim.pesc(dk) .. ':(.+)$'
local rec_pattern = '^' .. vim.pesc(rk) .. ':(%S+)$' local rec_pattern = '^' .. vim.pesc(rk) .. ':(%S+)$'
local desc_tokens = {} local forge_indices = {}
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) local due_val = token:match(date_pattern_strict)
if due_val and is_valid_datetime(due_val) then if due_val then
if not metadata.due then if metadata.due then
break
end
if not is_valid_datetime(due_val) then
break
end
metadata.due = due_val metadata.due = due_val
end i = i - 1
consumed = true else
end
if not consumed then
local raw_val = token:match(date_pattern_any) local raw_val = token:match(date_pattern_any)
if raw_val then if raw_val then
if metadata.due then
break
end
local resolved = M.resolve_date(raw_val) local resolved = M.resolve_date(raw_val)
if resolved then if not resolved then
if not metadata.due then break
end
metadata.due = resolved metadata.due = resolved
end i = i - 1
consumed = true else
end
end
end
if not consumed then
local cat_val = token:match(cat_pattern) local cat_val = token:match(cat_pattern)
if cat_val then if cat_val then
if not metadata.category then if metadata.category then
break
end
metadata.category = cat_val metadata.category = cat_val
end i = i - 1
consumed = true else
end
end
if not consumed then
local pri_bangs = token:match('^%+(!+)$') local pri_bangs = token:match('^%+(!+)$')
if pri_bangs then if pri_bangs then
if not metadata.priority then if metadata.priority then
break
end
local max = config.get().max_priority or 3 local max = config.get().max_priority or 3
metadata.priority = math.min(#pri_bangs, max) metadata.priority = math.min(#pri_bangs, max)
end i = i - 1
consumed = true else
end
end
if not consumed then
local rec_val = token:match(rec_pattern) local rec_val = token:match(rec_pattern)
if rec_val then if rec_val then
if metadata.recur then
break
end
local recur = require('pending.recur') local recur = require('pending.recur')
local raw_spec = rec_val local raw_spec = rec_val
if raw_spec:sub(1, 1) == '!' then if raw_spec:sub(1, 1) == '!' then
metadata.recur_mode = 'completion'
raw_spec = raw_spec:sub(2) raw_spec = raw_spec:sub(2)
end end
if recur.validate(raw_spec) then if not recur.validate(raw_spec) then
if not metadata.recur then break
metadata.recur_mode = rec_val:sub(1, 1) == '!' and 'completion' or nil end
metadata.recur = raw_spec metadata.recur = raw_spec
end i = i - 1
consumed = true elseif forge.parse_ref(token) then
end table.insert(forge_indices, i)
end i = i - 1
end
if not consumed then
if forge.parse_ref(token) then
table.insert(forge_tokens, token)
else else
table.insert(desc_tokens, token) break
end
end
end
end end
end end
end end
for _, ft in ipairs(forge_tokens) do local desc_tokens = {}
table.insert(desc_tokens, ft) for j = 1, i do
table.insert(desc_tokens, tokens[j])
end
for fi = #forge_indices, 1, -1 do
table.insert(desc_tokens, tokens[forge_indices[fi]])
end end
local description = table.concat(desc_tokens, ' ') local description = table.concat(desc_tokens, ' ')

View file

@ -24,7 +24,6 @@ local config = require('pending.config')
---@field entry string ---@field entry string
---@field modified string ---@field modified string
---@field end? string ---@field end? string
---@field notes? string
---@field order integer ---@field order integer
---@field _extra? pending.TaskExtra ---@field _extra? pending.TaskExtra
@ -94,7 +93,6 @@ local known_fields = {
entry = true, entry = true,
modified = true, modified = true,
['end'] = true, ['end'] = true,
notes = true,
order = true, order = true,
} }
@ -126,9 +124,6 @@ local function task_to_table(task)
if task['end'] then if task['end'] then
t['end'] = task['end'] t['end'] = task['end']
end end
if task.notes then
t.notes = task.notes
end
if task.order and task.order ~= 0 then if task.order and task.order ~= 0 then
t.order = task.order t.order = task.order
end end
@ -155,7 +150,6 @@ local function table_to_task(t)
entry = t.entry, entry = t.entry,
modified = t.modified, modified = t.modified,
['end'] = t['end'], ['end'] = t['end'],
notes = t.notes,
order = t.order or 0, order = t.order or 0,
_extra = {}, _extra = {},
} }

View file

@ -341,7 +341,6 @@ function M.priority_view(tasks)
forge_ref = task._extra and task._extra._forge_ref or nil, forge_ref = task._extra and task._extra._forge_ref or nil,
forge_cache = task._extra and task._extra._forge_cache or nil, forge_cache = task._extra and task._extra._forge_cache or nil,
forge_spans = compute_forge_spans(task, prefix_len), forge_spans = compute_forge_spans(task, prefix_len),
has_notes = task.notes ~= nil and task.notes ~= '',
}) })
end end

View file

@ -304,8 +304,8 @@ end, {
if #parts == 0 or (#parts == 1 and not trailing) then if #parts == 0 or (#parts == 1 and not trailing) then
local auth_names = {} local auth_names = {}
for _, b in ipairs(pending.sync_backends()) do for _, b in ipairs(pending.sync_backends()) do
local mod = pending.resolve_backend(b) local ok, mod = pcall(require, 'pending.sync.' .. b)
if mod and type(mod.auth) == 'function' then if ok and type(mod.auth) == 'function' then
table.insert(auth_names, b) table.insert(auth_names, b)
end end
end end
@ -313,8 +313,8 @@ end, {
end end
local backend_name = parts[1] local backend_name = parts[1]
if #parts == 1 or (#parts == 2 and not trailing) then if #parts == 1 or (#parts == 2 and not trailing) then
local mod = pending.resolve_backend(backend_name) local ok, mod = pcall(require, 'pending.sync.' .. backend_name)
if mod and type(mod.auth_complete) == 'function' then if ok and type(mod.auth_complete) == 'function' then
return filter_candidates(arg_lead, mod.auth_complete()) return filter_candidates(arg_lead, mod.auth_complete())
end end
return {} return {}
@ -328,8 +328,8 @@ end, {
if not after_backend then if not after_backend then
return {} return {}
end end
local mod = pending.resolve_backend(matched_backend) local ok, mod = pcall(require, 'pending.sync.' .. matched_backend)
if not mod then if not ok then
return {} return {}
end end
local actions = {} local actions = {}
@ -407,10 +407,6 @@ vim.keymap.set('n', '<Plug>(pending-cancelled)', function()
require('pending').toggle_status('cancelled') require('pending').toggle_status('cancelled')
end) end)
vim.keymap.set('n', '<Plug>(pending-edit-notes)', function()
require('pending').open_detail()
end)
vim.keymap.set('n', '<Plug>(pending-priority-up)', function() vim.keymap.set('n', '<Plug>(pending-priority-up)', function()
require('pending').increment_priority() require('pending').increment_priority()
end) end)

View file

@ -1,402 +0,0 @@
require('spec.helpers')
local config = require('pending.config')
describe('detail frontmatter', function()
local buffer
local tmpdir
before_each(function()
tmpdir = vim.fn.tempname()
vim.fn.mkdir(tmpdir, 'p')
vim.g.pending = { data_path = tmpdir .. '/tasks.json' }
config.reset()
package.loaded['pending'] = nil
package.loaded['pending.buffer'] = nil
buffer = require('pending.buffer')
end)
after_each(function()
vim.fn.delete(tmpdir, 'rf')
vim.g.pending = nil
config.reset()
package.loaded['pending'] = nil
package.loaded['pending.buffer'] = nil
end)
describe('build_detail_frontmatter', function()
it('renders status and priority for minimal task', function()
local lines = buffer._build_detail_frontmatter({
id = 1,
description = 'Test',
status = 'pending',
priority = 0,
entry = '',
modified = '',
order = 0,
})
assert.are.equal(2, #lines)
assert.are.equal('Status: pending', lines[1])
assert.are.equal('Priority: 0', lines[2])
end)
it('renders all fields', function()
local lines = buffer._build_detail_frontmatter({
id = 1,
description = 'Test',
status = 'wip',
priority = 2,
category = 'Work',
due = '2026-03-15',
recur = 'weekly',
entry = '',
modified = '',
order = 0,
})
assert.are.equal(5, #lines)
assert.are.equal('Status: wip', lines[1])
assert.are.equal('Priority: 2', lines[2])
assert.are.equal('Category: Work', lines[3])
assert.are.equal('Due: 2026-03-15', lines[4])
assert.are.equal('Recur: weekly', lines[5])
end)
it('prefixes recur with ! for completion mode', function()
local lines = buffer._build_detail_frontmatter({
id = 1,
description = 'Test',
status = 'pending',
priority = 0,
recur = 'daily',
recur_mode = 'completion',
entry = '',
modified = '',
order = 0,
})
assert.are.equal('Recur: !daily', lines[3])
end)
it('omits optional fields when absent', function()
local lines = buffer._build_detail_frontmatter({
id = 1,
description = 'Test',
status = 'done',
priority = 1,
entry = '',
modified = '',
order = 0,
})
assert.are.equal(2, #lines)
assert.are.equal('Status: done', lines[1])
assert.are.equal('Priority: 1', lines[2])
end)
end)
describe('parse_detail_frontmatter', function()
it('parses minimal frontmatter', function()
local lines = {
'# My task',
'Status: pending',
'Priority: 0',
'---',
'some notes',
}
local sep, fields, err = buffer._parse_detail_frontmatter(lines)
assert.is_nil(err)
assert.are.equal(4, sep)
assert.are.equal('My task', fields.description)
assert.are.equal('pending', fields.status)
assert.are.equal(0, fields.priority)
end)
it('parses all fields', function()
local lines = {
'# Fix the bug',
'Status: wip',
'Priority: 2',
'Category: Work',
'Due: 2026-03-15',
'Recur: weekly',
'---',
}
local sep, fields, err = buffer._parse_detail_frontmatter(lines)
assert.is_nil(err)
assert.are.equal(7, sep)
assert.are.equal('Fix the bug', fields.description)
assert.are.equal('wip', fields.status)
assert.are.equal(2, fields.priority)
assert.are.equal('Work', fields.category)
assert.are.equal('2026-03-15', fields.due)
assert.are.equal('weekly', fields.recur)
end)
it('resolves due date keywords', function()
local lines = {
'# Task',
'Status: pending',
'Priority: 0',
'Due: tomorrow',
'---',
}
local _, fields, err = buffer._parse_detail_frontmatter(lines)
assert.is_nil(err)
local today = os.date('*t') --[[@as osdate]]
local expected = os.date(
'%Y-%m-%d',
os.time({ year = today.year, month = today.month, day = today.day + 1 })
)
assert.are.equal(expected, fields.due)
end)
it('parses completion-mode recurrence', function()
local lines = {
'# Task',
'Status: pending',
'Priority: 0',
'Recur: !daily',
'---',
}
local _, fields, err = buffer._parse_detail_frontmatter(lines)
assert.is_nil(err)
assert.are.equal('daily', fields.recur)
assert.are.equal('completion', fields.recur_mode)
end)
it('clears optional fields when lines removed', function()
local lines = {
'# Task',
'Status: done',
'Priority: 1',
'---',
}
local _, fields, err = buffer._parse_detail_frontmatter(lines)
assert.is_nil(err)
assert.are.equal(vim.NIL, fields.category)
assert.are.equal(vim.NIL, fields.due)
assert.are.equal(vim.NIL, fields.recur)
end)
it('skips blank lines in frontmatter', function()
local lines = {
'# Task',
'Status: pending',
'',
'Priority: 0',
'---',
}
local _, fields, err = buffer._parse_detail_frontmatter(lines)
assert.is_nil(err)
assert.are.equal('pending', fields.status)
assert.are.equal(0, fields.priority)
end)
it('errors on missing separator', function()
local lines = {
'# Task',
'Status: pending',
'Priority: 0',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('missing separator'))
end)
it('errors on missing title', function()
local lines = {
'',
'Status: pending',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('missing or empty title'))
end)
it('errors on empty title', function()
local lines = {
'# ',
'Status: pending',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('missing or empty title'))
end)
it('errors on invalid status', function()
local lines = {
'# Task',
'Status: bogus',
'Priority: 0',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('invalid status'))
end)
it('errors on negative priority', function()
local lines = {
'# Task',
'Status: pending',
'Priority: -1',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('invalid priority'))
end)
it('errors on non-integer priority', function()
local lines = {
'# Task',
'Status: pending',
'Priority: 1.5',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('invalid priority'))
end)
it('errors on priority exceeding max', function()
local lines = {
'# Task',
'Status: pending',
'Priority: 4',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('max is 3'))
end)
it('errors on invalid due date', function()
local lines = {
'# Task',
'Status: pending',
'Priority: 0',
'Due: notadate',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('invalid due date'))
end)
it('errors on empty due value', function()
local lines = {
'# Task',
'Due: ',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('empty due value'))
end)
it('errors on invalid recurrence', function()
local lines = {
'# Task',
'Recur: nope',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('invalid recurrence'))
end)
it('errors on empty recur value', function()
local lines = {
'# Task',
'Recur: ',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('empty recur value'))
end)
it('errors on empty category value', function()
local lines = {
'# Task',
'Category: ',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('empty category'))
end)
it('errors on unknown field', function()
local lines = {
'# Task',
'Status: pending',
'Foo: bar',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('unknown field: foo'))
end)
it('errors on duplicate field', function()
local lines = {
'# Task',
'Status: pending',
'Status: done',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('duplicate field'))
end)
it('errors on malformed frontmatter line', function()
local lines = {
'# Task',
'not a key value pair',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('invalid frontmatter line'))
end)
it('is case-insensitive for field keys', function()
local lines = {
'# Task',
'STATUS: wip',
'PRIORITY: 1',
'CATEGORY: Work',
'---',
}
local _, fields, err = buffer._parse_detail_frontmatter(lines)
assert.is_nil(err)
assert.are.equal('wip', fields.status)
assert.are.equal(1, fields.priority)
assert.are.equal('Work', fields.category)
end)
it('accepts datetime due format', function()
local lines = {
'# Task',
'Due: 2026-03-15T14:00',
'---',
}
local _, fields, err = buffer._parse_detail_frontmatter(lines)
assert.is_nil(err)
assert.are.equal('2026-03-15T14:00', fields.due)
end)
it('respects custom max_priority', function()
vim.g.pending = { data_path = tmpdir .. '/tasks.json', max_priority = 5 }
config.reset()
local lines = {
'# Task',
'Priority: 5',
'---',
}
local _, fields, err = buffer._parse_detail_frontmatter(lines)
assert.is_nil(err)
assert.are.equal(5, fields.priority)
end)
it('updates description from title line', function()
local lines = {
'# Updated title',
'Status: pending',
'Priority: 0',
'---',
}
local _, fields, err = buffer._parse_detail_frontmatter(lines)
assert.is_nil(err)
assert.are.equal('Updated title', fields.description)
end)
end)
end)

View file

@ -48,16 +48,10 @@ describe('parse', function()
assert.are.equal('Errands', meta.category) assert.are.equal('Errands', meta.category)
end) end)
it('first occurrence wins for duplicate keys', function() it('stops at duplicate key', function()
local desc, meta = parse.body('Buy milk due:2026-03-15 due:2026-04-01') local desc, meta = parse.body('Buy milk due:2026-03-15 due:2026-04-01')
assert.are.equal('Buy milk', desc) assert.are.equal('Buy milk due:2026-03-15', desc)
assert.are.equal('2026-03-15', meta.due) assert.are.equal('2026-04-01', meta.due)
end)
it('drops identical duplicate metadata tokens', function()
local desc, meta = parse.body('Buy milk due:tomorrow due:tomorrow')
assert.are.equal('Buy milk', desc)
assert.are.equal(os.date('%Y-%m-%d', os.time() + 86400), meta.due)
end) end)
it('stops at non-meta token', function() it('stops at non-meta token', function()
@ -144,38 +138,6 @@ describe('parse', function()
assert.are.equal('Work', meta.category) assert.are.equal('Work', meta.category)
assert.truthy(desc:find('gl:a/b#12', 1, true)) assert.truthy(desc:find('gl:a/b#12', 1, true))
end) 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) end)
describe('parse.resolve_date', function() describe('parse.resolve_date', function()

View file

@ -124,100 +124,6 @@ describe('sync', function()
end) end)
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() describe('auto-discovery', function()
it('discovers gcal and gtasks backends', function() it('discovers gcal and gtasks backends', function()
local backends = pending.sync_backends() local backends = pending.sync_backends()