Compare commits
No commits in common. "e816e6fb7e145e12fa22942fecf52db48d8ba176" and "0b0b64fc3d3f02d8e9ca1d72b83999f3f64f8b88" have entirely different histories.
e816e6fb7e
...
0b0b64fc3d
12 changed files with 90 additions and 1088 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>',
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
metadata.due = due_val
|
break
|
||||||
end
|
end
|
||||||
consumed = true
|
if not is_valid_datetime(due_val) then
|
||||||
end
|
break
|
||||||
if not consumed then
|
end
|
||||||
|
metadata.due = due_val
|
||||||
|
i = i - 1
|
||||||
|
else
|
||||||
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
|
||||||
metadata.due = resolved
|
|
||||||
end
|
|
||||||
consumed = true
|
|
||||||
end
|
end
|
||||||
end
|
metadata.due = resolved
|
||||||
end
|
i = i - 1
|
||||||
|
|
||||||
if not consumed then
|
|
||||||
local cat_val = token:match(cat_pattern)
|
|
||||||
if cat_val then
|
|
||||||
if not metadata.category then
|
|
||||||
metadata.category = cat_val
|
|
||||||
end
|
|
||||||
consumed = true
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
if not consumed then
|
|
||||||
local pri_bangs = token:match('^%+(!+)$')
|
|
||||||
if pri_bangs then
|
|
||||||
if not metadata.priority then
|
|
||||||
local max = config.get().max_priority or 3
|
|
||||||
metadata.priority = math.min(#pri_bangs, max)
|
|
||||||
end
|
|
||||||
consumed = true
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
if not consumed then
|
|
||||||
local rec_val = token:match(rec_pattern)
|
|
||||||
if rec_val then
|
|
||||||
local recur = require('pending.recur')
|
|
||||||
local raw_spec = rec_val
|
|
||||||
if raw_spec:sub(1, 1) == '!' then
|
|
||||||
raw_spec = raw_spec:sub(2)
|
|
||||||
end
|
|
||||||
if recur.validate(raw_spec) then
|
|
||||||
if not metadata.recur then
|
|
||||||
metadata.recur_mode = rec_val:sub(1, 1) == '!' and 'completion' or nil
|
|
||||||
metadata.recur = raw_spec
|
|
||||||
end
|
|
||||||
consumed = true
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
if not consumed then
|
|
||||||
if forge.parse_ref(token) then
|
|
||||||
table.insert(forge_tokens, token)
|
|
||||||
else
|
else
|
||||||
table.insert(desc_tokens, token)
|
local cat_val = token:match(cat_pattern)
|
||||||
|
if cat_val then
|
||||||
|
if metadata.category then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
metadata.category = cat_val
|
||||||
|
i = i - 1
|
||||||
|
else
|
||||||
|
local pri_bangs = token:match('^%+(!+)$')
|
||||||
|
if pri_bangs then
|
||||||
|
if metadata.priority then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
local max = config.get().max_priority or 3
|
||||||
|
metadata.priority = math.min(#pri_bangs, max)
|
||||||
|
i = i - 1
|
||||||
|
else
|
||||||
|
local rec_val = token:match(rec_pattern)
|
||||||
|
if rec_val then
|
||||||
|
if metadata.recur then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
local recur = require('pending.recur')
|
||||||
|
local raw_spec = rec_val
|
||||||
|
if raw_spec:sub(1, 1) == '!' then
|
||||||
|
metadata.recur_mode = 'completion'
|
||||||
|
raw_spec = raw_spec:sub(2)
|
||||||
|
end
|
||||||
|
if not recur.validate(raw_spec) then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
metadata.recur = raw_spec
|
||||||
|
i = i - 1
|
||||||
|
elseif forge.parse_ref(token) then
|
||||||
|
table.insert(forge_indices, i)
|
||||||
|
i = i - 1
|
||||||
|
else
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
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, ' ')
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 = {},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue