Compare commits

..

No commits in common. "be81abfe48e59d9d6ff6b7acda6afbd351c4cbc7" and "b08d01fe787adac9e9af7a865c7191dcc0661028" have entirely different histories.

14 changed files with 183 additions and 675 deletions

View file

@ -30,16 +30,13 @@ concealed tokens and are never visible during editing.
Features: ~ Features: ~
- Oil-style buffer editing: standard Vim motions for all task operations - Oil-style buffer editing: standard Vim motions for all task operations
- Inline metadata syntax: `due:`, `cat:`, and `rec:` tokens parsed on `:w` - Inline metadata syntax: `due:` and `cat:` tokens parsed on `:w`
- Relative date input: `today`, `tomorrow`, `+Nd`, `+Nw`, `+Nm`, weekday - Relative date input: `today`, `tomorrow`, `+Nd`, weekday names
names, month names, ordinals, and more
- Recurring tasks with automatic next-date spawning on completion
- Two views: category (default) and priority flat list - Two views: category (default) and priority flat list
- Multi-level undo (up to 20 `:w` saves, session-only) - Multi-level undo (up to 20 `:w` saves, session-only)
- Quick-add from the command line with `:Pending add` - Quick-add from the command line with `:Pending add`
- Quickfix list of overdue/due-today tasks via `:Pending due` - Quickfix list of overdue/due-today tasks via `:Pending due`
- Foldable category sections (`zc`/`zo`) in category view - Foldable category sections (`zc`/`zo`) in category view
- Omnifunc completion for `cat:`, `due:`, and `rec:` tokens (`<C-x><C-o>`)
- Google Calendar one-way push via OAuth PKCE - Google Calendar one-way push via OAuth PKCE
============================================================================== ==============================================================================
@ -98,18 +95,20 @@ parsed from the right and consumed until a non-metadata token is reached.
Supported tokens: ~ Supported tokens: ~
`due:YYYY-MM-DD` Set a due date using an absolute date. `due:YYYY-MM-DD` Set a due date using an absolute date.
`due:<name>` Resolve a named date (see |pending-dates| below). `due:today` Resolve to today's date.
`due:tomorrow` Resolve to tomorrow's date.
`due:+Nd` Resolve to N days from today (e.g. `due:+3d`).
`due:mon` Resolve to the next occurrence of that weekday.
Supported: `sun` `mon` `tue` `wed` `thu` `fri` `sat`
`cat:Name` Move the task to the named category on save. `cat:Name` Move the task to the named category on save.
`rec:<pattern>` Set a recurrence rule (see |pending-recurrence|).
The token name for due dates defaults to `due` and is configurable via The token name for due dates defaults to `due` and is configurable via
`date_syntax` in |pending-config|. The token name for recurrence defaults to `date_syntax` in |pending-config|. If `date_syntax` is set to `by`, write
`rec` and is configurable via `recur_syntax`. `by:2026-03-15` instead.
Example: > Example: >
Buy milk due:2026-03-15 cat:Errands Buy milk due:2026-03-15 cat:Errands
Take out trash due:monday rec:weekly
< <
On `:w`, the description becomes `Buy milk`, the due date is stored as On `:w`, the description becomes `Buy milk`, the due date is stored as
@ -117,87 +116,8 @@ On `:w`, the description becomes `Buy milk`, the due date is stored as
placed under the `Errands` category header. placed under the `Errands` category header.
Parsing stops at the first token that is not a recognised metadata token. Parsing stops at the first token that is not a recognised metadata token.
Repeated tokens of the same type also stop parsing — only one `due:`, one Repeated tokens of the same type also stop parsing — only one `due:` and one
`cat:`, and one `rec:` per task line are consumed. `cat:` per task line are consumed.
Omnifunc completion is available for all three token types. In insert mode,
type `due:`, `cat:`, or `rec:` and press `<C-x><C-o>` to see suggestions.
==============================================================================
DATE INPUT *pending-dates*
Named dates can be used anywhere a date is accepted: the `due:` inline
token, the `D` prompt, and `:Pending add`.
Token Resolves to ~
----- -----------
`today` Today's date
`tomorrow` Tomorrow's date
`yesterday` Yesterday's date
`eod` Today (end of day semantics)
`+Nd` N days from today (e.g. `+3d`)
`+Nw` N weeks from today (e.g. `+2w`)
`+Nm` N months from today (e.g. `+1m`)
`-Nd` N days ago (e.g. `-2d`)
`-Nw` N weeks ago (e.g. `-1w`)
`mon``sun` Next occurrence of that weekday
`jan``dec` 1st of next occurrence of that month
`1st``31st` Next occurrence of that day-of-month
`sow` / `eow` Monday / Sunday of current week
`som` / `eom` First / last day of current month
`soq` / `eoq` First / last day of current quarter
`soy` / `eoy` January 1 / December 31 of current year
`later` / `someday` Sentinel date (default: `9999-12-30`)
==============================================================================
RECURRENCE *pending-recurrence*
Tasks can recur on a schedule. Add a `rec:` token to set recurrence: >
- [ ] Take out trash due:monday rec:weekly
- [ ] Pay rent due:2026-03-01 rec:monthly
- [ ] Standup due:tomorrow rec:weekdays
<
When a recurring task is marked done with `<CR>`:
1. The current task stays as done (preserving history).
2. A new pending task is created with the same description, category,
priority, and recurrence — with the due date advanced to the next
occurrence.
Shorthand patterns: ~
Pattern Meaning ~
------- -------
`daily` Every day
`weekdays` Monday through Friday
`weekly` Every week
`biweekly` Every 2 weeks (alias: `2w`)
`monthly` Every month
`quarterly` Every 3 months (alias: `3m`)
`yearly` Every year (alias: `annual`)
`Nd` Every N days (e.g. `3d`)
`Nw` Every N weeks (e.g. `2w`)
`Nm` Every N months (e.g. `6m`)
`Ny` Every N years (e.g. `2y`)
For patterns the shorthand cannot express, use a raw RRULE fragment: >
rec:FREQ=MONTHLY;BYDAY=1MO
<
Completion-based recurrence: ~ *pending-recur-completion*
By default, recurrence is schedule-based: the next due date advances from the
original schedule, skipping to the next future occurrence. Prefix the pattern
with `!` for completion-based mode, where the next due date advances from the
completion date: >
rec:!weekly
<
Schedule-based is like org-mode `++`; completion-based is like `.+`.
Google Calendar: ~
Recurrence patterns map directly to iCalendar RRULE strings for future GCal
sync support. Completion-based recurrence cannot be synced (it is inherently
local).
============================================================================== ==============================================================================
COMMANDS *pending-commands* COMMANDS *pending-commands*
@ -215,7 +135,6 @@ COMMANDS *pending-commands*
:Pending add Buy groceries due:2026-03-15 :Pending add Buy groceries due:2026-03-15
:Pending add School: Submit homework :Pending add School: Submit homework
:Pending add Errands: Pick up dry cleaning due:fri :Pending add Errands: Pick up dry cleaning due:fri
:Pending add Work: standup due:tomorrow rec:weekdays
< <
If the buffer is currently open it is re-rendered after the add. If the buffer is currently open it is re-rendered after the add.
@ -250,34 +169,27 @@ MAPPINGS *pending-mappings*
The following keys are set buffer-locally when the task buffer opens. They The following keys are set buffer-locally when the task buffer opens. They
are active only in the `pending://` buffer. are active only in the `pending://` buffer.
Buffer-local keys are configured via the `keymaps` table in |pending-config|. Buffer-local keys: ~
The defaults are shown below. Set any key to `false` to disable it.
Default buffer-local keys: ~
Key Action ~ Key Action ~
------- ------------------------------------------------ ------- ------------------------------------------------
`q` Close the task buffer (`close`) `<CR>` Toggle complete / uncomplete the task at cursor
`<CR>` Toggle complete / uncomplete (`toggle`) `!` Toggle the priority flag on the task at cursor
`!` Toggle the priority flag (`priority`) `D` Prompt for a due date on the task at cursor
`D` Prompt for a due date (`date`) `<Tab>` Switch between category view and priority view
`<Tab>` Switch between category / priority view (`view`) `U` Undo the last `:w` save
`U` Undo the last `:w` save (`undo`) `g?` Show a help popup with available keys
`o` Insert a new task line below (`open_line`)
`O` Insert a new task line above (`open_line_above`)
`zc` Fold the current category section (category view only) `zc` Fold the current category section (category view only)
`zo` Unfold the current category section (category view only) `zo` Unfold the current category section (category view only)
`dd`, `p`, `P`, and `:w` work as standard Vim operations. `o` and `O` are overridden to insert a correctly-formatted blank task line
at the position below or above the cursor rather than using standard Vim
indentation. `dd`, `p`, `P`, and `:w` work as expected.
*<Plug>(pending-open)* *<Plug>(pending-open)*
<Plug>(pending-open) <Plug>(pending-open)
Open the task buffer. Maps to |:Pending| with no arguments. Open the task buffer. Maps to |:Pending| with no arguments.
*<Plug>(pending-close)*
<Plug>(pending-close)
Close the task buffer window.
*<Plug>(pending-toggle)* *<Plug>(pending-toggle)*
<Plug>(pending-toggle) <Plug>(pending-toggle)
Toggle complete / uncomplete for the task under the cursor. Toggle complete / uncomplete for the task under the cursor.
@ -294,18 +206,6 @@ Default buffer-local keys: ~
<Plug>(pending-view) <Plug>(pending-view)
Switch between category view and priority view. Switch between category view and priority view.
*<Plug>(pending-undo)*
<Plug>(pending-undo)
Undo the last `:w` save.
*<Plug>(pending-open-line)*
<Plug>(pending-open-line)
Insert a correctly-formatted blank task line below the cursor.
*<Plug>(pending-open-line-above)*
<Plug>(pending-open-line-above)
Insert a correctly-formatted blank task line above the cursor.
Example configuration: >lua Example configuration: >lua
vim.keymap.set('n', '<leader>t', '<Plug>(pending-open)') vim.keymap.set('n', '<leader>t', '<Plug>(pending-open)')
vim.keymap.set('n', '<leader>T', '<Plug>(pending-toggle)') vim.keymap.set('n', '<leader>T', '<Plug>(pending-toggle)')
@ -342,19 +242,7 @@ loads: >lua
default_category = 'Inbox', default_category = 'Inbox',
date_format = '%b %d', date_format = '%b %d',
date_syntax = 'due', date_syntax = 'due',
recur_syntax = 'rec',
someday_date = '9999-12-30',
category_order = {}, category_order = {},
keymaps = {
close = 'q',
toggle = '<CR>',
view = '<Tab>',
priority = '!',
date = 'D',
undo = 'U',
open_line = 'o',
open_line_above = 'O',
},
gcal = { gcal = {
calendar = 'Tasks', calendar = 'Tasks',
credentials_path = '/path/to/client_secret.json', credentials_path = '/path/to/client_secret.json',
@ -390,28 +278,12 @@ Fields: ~
this to use a different keyword, for example `'by'` this to use a different keyword, for example `'by'`
to write `by:2026-03-15` instead of `due:2026-03-15`. to write `by:2026-03-15` instead of `due:2026-03-15`.
{recur_syntax} (string, default: 'rec')
The token name for inline recurrence metadata. Change
this to use a different keyword, for example
`'repeat'` to write `repeat:weekly`.
{someday_date} (string, default: '9999-12-30')
The date that `later` and `someday` resolve to. This
acts as a "no date" sentinel for GTD-style workflows.
{category_order} (string[], default: {}) {category_order} (string[], default: {})
Ordered list of category names. In category view, Ordered list of category names. In category view,
categories that appear in this list are shown in the categories that appear in this list are shown in the
given order. Categories not in the list are appended given order. Categories not in the list are appended
after the ordered ones in their natural order. after the ordered ones in their natural order.
{keymaps} (table, default: see below) *pending.Keymaps*
Buffer-local key bindings. Each field maps an action
name to a key string. Set a field to `false` to
disable that binding. Unset fields use the default.
See |pending-mappings| for the full list of actions
and their default keys.
{gcal} (table, default: nil) {gcal} (table, default: nil)
Google Calendar sync configuration. See Google Calendar sync configuration. See
|pending.GcalConfig|. Omit this field entirely to |pending.GcalConfig|. Omit this field entirely to
@ -499,11 +371,6 @@ PendingDone Applied to the text of completed tasks.
PendingPriority Applied to the `! ` priority marker on priority tasks. PendingPriority Applied to the `! ` priority marker on priority tasks.
Default: links to `DiagnosticWarn`. Default: links to `DiagnosticWarn`.
*PendingRecur*
PendingRecur Applied to the recurrence indicator virtual text shown
alongside due dates for recurring tasks.
Default: links to `DiagnosticInfo`.
To override a group in your colorscheme or config: >lua To override a group in your colorscheme or config: >lua
vim.api.nvim_set_hl(0, 'PendingDue', { fg = '#aaaaaa', italic = true }) vim.api.nvim_set_hl(0, 'PendingDue', { fg = '#aaaaaa', italic = true })
< <
@ -521,7 +388,6 @@ Checks performed: ~
category, date format, date syntax) category, date format, date syntax)
- Whether the data directory exists (warning if not yet created) - Whether the data directory exists (warning if not yet created)
- Whether the data file exists and can be parsed; reports total task count - Whether the data file exists and can be parsed; reports total task count
- Validates recurrence specs on stored tasks
- Whether `curl` is available (required for Google Calendar sync) - Whether `curl` is available (required for Google Calendar sync)
- Whether `openssl` is available (required for OAuth PKCE) - Whether `openssl` is available (required for OAuth PKCE)
@ -548,8 +414,6 @@ Task fields: ~
{category} (string) Category name. Defaults to `default_category`. {category} (string) Category name. Defaults to `default_category`.
{priority} (integer) `1` for priority tasks, `0` otherwise. {priority} (integer) `1` for priority tasks, `0` otherwise.
{due} (string) ISO date string `YYYY-MM-DD`, or absent. {due} (string) ISO date string `YYYY-MM-DD`, or absent.
{recur} (string) Recurrence shorthand (e.g. `weekly`), or absent.
{recur_mode} (string) `'scheduled'` or `'completion'`, or absent.
{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.

View file

@ -55,7 +55,6 @@ local function set_buf_options(bufnr)
vim.bo[bufnr].swapfile = false vim.bo[bufnr].swapfile = false
vim.bo[bufnr].filetype = 'pending' vim.bo[bufnr].filetype = 'pending'
vim.bo[bufnr].modifiable = true vim.bo[bufnr].modifiable = true
vim.bo[bufnr].omnifunc = 'v:lua.require("pending.complete").omnifunc'
end end
---@param winid integer ---@param winid integer
@ -123,22 +122,24 @@ local function apply_extmarks(bufnr, line_meta)
local row = i - 1 local row = i - 1
if m.type == 'task' then if m.type == 'task' then
local due_hl = m.overdue and 'PendingOverdue' or 'PendingDue' local due_hl = m.overdue and 'PendingOverdue' or 'PendingDue'
local virt_parts = {} if m.show_category then
if m.show_category and m.category then local virt_text
table.insert(virt_parts, { m.category, 'PendingHeader' }) if m.category and m.due then
end virt_text = { { m.category .. ' ', 'PendingHeader' }, { m.due, due_hl } }
if m.recur then elseif m.category then
table.insert(virt_parts, { '\u{21bb} ' .. m.recur, 'PendingRecur' }) virt_text = { { m.category, 'PendingHeader' } }
end elseif m.due then
if m.due then virt_text = { { m.due, due_hl } }
table.insert(virt_parts, { m.due, due_hl })
end
if #virt_parts > 0 then
for p = 1, #virt_parts - 1 do
virt_parts[p][1] = virt_parts[p][1] .. ' '
end end
if virt_text then
vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, { vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, {
virt_text = virt_parts, virt_text = virt_text,
virt_text_pos = 'eol',
})
end
elseif m.due then
vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, {
virt_text = { { m.due, due_hl } },
virt_text_pos = 'eol', virt_text_pos = 'eol',
}) })
end end
@ -166,7 +167,6 @@ local function setup_highlights()
vim.api.nvim_set_hl(0, 'PendingOverdue', { link = 'DiagnosticError', default = true }) vim.api.nvim_set_hl(0, 'PendingOverdue', { link = 'DiagnosticError', default = true })
vim.api.nvim_set_hl(0, 'PendingDone', { link = 'Comment', default = true }) vim.api.nvim_set_hl(0, 'PendingDone', { link = 'Comment', default = true })
vim.api.nvim_set_hl(0, 'PendingPriority', { link = 'DiagnosticWarn', default = true }) vim.api.nvim_set_hl(0, 'PendingPriority', { link = 'DiagnosticWarn', default = true })
vim.api.nvim_set_hl(0, 'PendingRecur', { link = 'DiagnosticInfo', default = true })
end end
local function snapshot_folds(bufnr) local function snapshot_folds(bufnr)

View file

@ -1,138 +0,0 @@
local config = require('pending.config')
---@class pending.complete
local M = {}
---@return string
local function date_key()
return config.get().date_syntax or 'due'
end
---@return string
local function recur_key()
return config.get().recur_syntax or 'rec'
end
---@return string[]
local function get_categories()
local store = require('pending.store')
local seen = {}
local result = {}
for _, task in ipairs(store.active_tasks()) do
local cat = task.category
if cat and not seen[cat] then
seen[cat] = true
table.insert(result, cat)
end
end
table.sort(result)
return result
end
---@return string[]
local function date_completions()
return {
'today',
'tomorrow',
'yesterday',
'+1d',
'+2d',
'+3d',
'+1w',
'+2w',
'+1m',
'mon',
'tue',
'wed',
'thu',
'fri',
'sat',
'sun',
'eod',
'eow',
'eom',
'eoq',
'eoy',
'sow',
'som',
'soq',
'soy',
'later',
}
end
---@return string[]
local function recur_completions()
local recur = require('pending.recur')
local list = recur.shorthand_list()
local result = {}
for _, s in ipairs(list) do
table.insert(result, s)
end
for _, s in ipairs(list) do
table.insert(result, '!' .. s)
end
return result
end
---@type string?
local _complete_source = nil
---@param findstart integer
---@param base string
---@return integer|table[]
function M.omnifunc(findstart, base)
if findstart == 1 then
local line = vim.api.nvim_get_current_line()
local col = vim.api.nvim_win_get_cursor(0)[2]
local before = line:sub(1, col)
local dk = date_key()
local rk = recur_key()
local checks = {
{ vim.pesc(dk) .. ':([%S]*)$', dk },
{ 'cat:([%S]*)$', 'cat' },
{ vim.pesc(rk) .. ':([%S]*)$', rk },
}
for _, check in ipairs(checks) do
local start = before:find(check[1])
if start then
local colon_pos = before:find(':', start, true)
if colon_pos then
_complete_source = check[2]
return colon_pos
end
end
end
_complete_source = nil
return -1
end
local candidates = {}
local source = _complete_source or ''
local dk = date_key()
local rk = recur_key()
if source == dk then
candidates = date_completions()
elseif source == 'cat' then
candidates = get_categories()
elseif source == rk then
candidates = recur_completions()
end
local matches = {}
for _, c in ipairs(candidates) do
if base == '' or c:sub(1, #base) == base then
table.insert(matches, { word = c, menu = '[' .. source .. ']' })
end
end
return matches
end
return M

View file

@ -2,16 +2,6 @@
---@field calendar? string ---@field calendar? string
---@field credentials_path? string ---@field credentials_path? string
---@class pending.Keymaps
---@field close? string|false
---@field toggle? string|false
---@field view? string|false
---@field priority? string|false
---@field date? string|false
---@field undo? string|false
---@field open_line? string|false
---@field open_line_above? string|false
---@class pending.Config ---@class pending.Config
---@field data_path string ---@field data_path string
---@field default_view 'category'|'priority' ---@field default_view 'category'|'priority'
@ -22,7 +12,6 @@
---@field someday_date string ---@field someday_date string
---@field category_order? string[] ---@field category_order? string[]
---@field drawer_height? integer ---@field drawer_height? integer
---@field keymaps pending.Keymaps
---@field gcal? pending.GcalConfig ---@field gcal? pending.GcalConfig
---@class pending.config ---@class pending.config
@ -38,16 +27,6 @@ local defaults = {
recur_syntax = 'rec', recur_syntax = 'rec',
someday_date = '9999-12-30', someday_date = '9999-12-30',
category_order = {}, category_order = {},
keymaps = {
close = 'q',
toggle = '<CR>',
view = '<Tab>',
priority = '!',
date = 'D',
undo = 'U',
open_line = 'o',
open_line_above = 'O',
},
} }
---@type pending.Config? ---@type pending.Config?

View file

@ -27,17 +27,6 @@ function M.check()
if load_ok then if load_ok then
local tasks = store.tasks() local tasks = store.tasks()
vim.health.ok('Data file loaded: ' .. #tasks .. ' tasks') vim.health.ok('Data file loaded: ' .. #tasks .. ' tasks')
local recur = require('pending.recur')
local invalid_count = 0
for _, task in ipairs(tasks) do
if task.recur and not recur.validate(task.recur) then
invalid_count = invalid_count + 1
vim.health.warn('Task ' .. task.id .. ' has invalid recurrence spec: ' .. task.recur)
end
end
if invalid_count == 0 then
vim.health.ok('All recurrence specs are valid')
end
else else
vim.health.error('Failed to load data file: ' .. tostring(err)) vim.health.error('Failed to load data file: ' .. tostring(err))
end end

View file

@ -50,44 +50,37 @@ end
---@param bufnr integer ---@param bufnr integer
function M._setup_buf_mappings(bufnr) function M._setup_buf_mappings(bufnr)
local cfg = require('pending.config').get()
local km = cfg.keymaps
local opts = { buffer = bufnr, silent = true } local opts = { buffer = bufnr, silent = true }
vim.keymap.set('n', 'q', function()
---@type table<string, fun()>
local actions = {
close = function()
buffer.close() buffer.close()
end, end, opts)
toggle = function() vim.keymap.set('n', '<Esc>', function()
buffer.close()
end, opts)
vim.keymap.set('n', '<CR>', function()
M.toggle_complete() M.toggle_complete()
end, end, opts)
view = function() vim.keymap.set('n', '<Tab>', function()
buffer.toggle_view() buffer.toggle_view()
end, end, opts)
priority = function() vim.keymap.set('n', 'g?', function()
M.show_help()
end, opts)
vim.keymap.set('n', '!', function()
M.toggle_priority() M.toggle_priority()
end, end, opts)
date = function() vim.keymap.set('n', 'D', function()
M.prompt_date() M.prompt_date()
end, end, opts)
undo = function() vim.keymap.set('n', 'U', function()
M.undo_write() M.undo_write()
end, end, opts)
open_line = function() vim.keymap.set('n', 'o', function()
buffer.open_line(false) buffer.open_line(false)
end, end, opts)
open_line_above = function() vim.keymap.set('n', 'O', function()
buffer.open_line(true) buffer.open_line(true)
end, end, opts)
}
for name, fn in pairs(actions) do
local key = km[name]
if key and key ~= false then
vim.keymap.set('n', key --[[@as string]], fn, opts)
end
end
end end
---@param bufnr integer ---@param bufnr integer
@ -137,8 +130,7 @@ function M.toggle_complete()
if task.recur and task.due then if task.recur and task.due then
local recur = require('pending.recur') local recur = require('pending.recur')
local mode = task.recur_mode or 'scheduled' local mode = task.recur_mode or 'scheduled'
local base = mode == 'completion' and os.date('%Y-%m-%d') --[[@as string]] local base = mode == 'completion' and os.date('%Y-%m-%d') --[[@as string]] or task.due
or task.due
local next_date = recur.next_due(base, task.recur, mode) local next_date = recur.next_due(base, task.recur, mode)
store.add({ store.add({
description = task.description, description = task.description,
@ -341,6 +333,75 @@ function M.due()
vim.cmd('copen') vim.cmd('copen')
end end
function M.show_help()
local cfg = require('pending.config').get()
local dk = cfg.date_syntax or 'due'
local rk = cfg.recur_syntax or 'rec'
local lines = {
'pending.nvim keybindings',
'',
'<CR> Toggle complete/uncomplete',
'<Tab> Switch category/priority view',
'! Toggle urgent',
'D Set due date',
'U Undo last write',
'o / O Add new task line',
'dd Delete task line (on :w)',
'p / P Paste (duplicates get new IDs)',
'zc / zo Fold/unfold category (category view)',
':w Save all changes',
'',
':Pending add <text> Quick-add task',
':Pending add Cat: <text> Quick-add with category',
':Pending due Show overdue/due qflist',
':Pending sync Push to Google Calendar',
':Pending archive [days] Purge old done tasks',
':Pending undo Undo last write',
'',
'Inline metadata (on new lines before :w):',
' ' .. dk .. ':YYYY-MM-DD Set due date',
' cat:Name Set category',
' ' .. rk .. ':pattern Set recurrence',
'',
'Due date input:',
' today, tomorrow, yesterday, +Nd, +Nw, +Nm',
' -Nd, -Nw, mon-sun, jan-dec, 1st-31st',
' eod, eow, eom, eoq, eoy, sow, som, soq, soy',
' later, someday',
' Empty input clears due date',
'',
'Recurrence patterns:',
' daily, weekdays, weekly, biweekly',
' monthly, quarterly, yearly, Nd, Nw, Nm, Ny',
' Prefix ! for completion-based (e.g. !weekly)',
'',
'Completion: <C-x><C-o> after due:, cat:, rec:',
'',
'Press q or <Esc> to close',
}
local buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
vim.bo[buf].modifiable = false
vim.bo[buf].bufhidden = 'wipe'
local width = 54
local height = #lines
local win = vim.api.nvim_open_win(buf, true, {
relative = 'editor',
width = width,
height = height,
col = math.floor((vim.o.columns - width) / 2),
row = math.floor((vim.o.lines - height) / 2),
style = 'minimal',
border = 'rounded',
})
vim.keymap.set('n', 'q', function()
vim.api.nvim_win_close(win, true)
end, { buffer = buf, silent = true })
vim.keymap.set('n', '<Esc>', function()
vim.api.nvim_win_close(win, true)
end, { buffer = buf, silent = true })
end
---@param args string ---@param args string
function M.command(args) function M.command(args)
if not args or args == '' then if not args or args == '' then

View file

@ -45,18 +45,9 @@ local weekday_map = {
} }
local month_map = { local month_map = {
jan = 1, jan = 1, feb = 2, mar = 3, apr = 4,
feb = 2, may = 5, jun = 6, jul = 7, aug = 8,
mar = 3, sep = 9, oct = 10, nov = 11, dec = 12,
apr = 4,
may = 5,
jun = 6,
jul = 7,
aug = 8,
sep = 9,
oct = 10,
nov = 11,
dec = 12,
} }
---@param today osdate ---@param today osdate
@ -106,31 +97,49 @@ function M.resolve_date(text)
end end
if lower == 'som' then if lower == 'som' then
return os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = 1 })) --[[@as string]] return os.date(
'%Y-%m-%d',
os.time({ year = today.year, month = today.month, day = 1 })
) --[[@as string]]
end end
if lower == 'eom' then if lower == 'eom' then
return os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month + 1, day = 0 })) --[[@as string]] return os.date(
'%Y-%m-%d',
os.time({ year = today.year, month = today.month + 1, day = 0 })
) --[[@as string]]
end end
if lower == 'soq' then if lower == 'soq' then
local q = math.ceil(today.month / 3) local q = math.ceil(today.month / 3)
local first_month = (q - 1) * 3 + 1 local first_month = (q - 1) * 3 + 1
return os.date('%Y-%m-%d', os.time({ year = today.year, month = first_month, day = 1 })) --[[@as string]] return os.date(
'%Y-%m-%d',
os.time({ year = today.year, month = first_month, day = 1 })
) --[[@as string]]
end end
if lower == 'eoq' then if lower == 'eoq' then
local q = math.ceil(today.month / 3) local q = math.ceil(today.month / 3)
local last_month = q * 3 local last_month = q * 3
return os.date('%Y-%m-%d', os.time({ year = today.year, month = last_month + 1, day = 0 })) --[[@as string]] return os.date(
'%Y-%m-%d',
os.time({ year = today.year, month = last_month + 1, day = 0 })
) --[[@as string]]
end end
if lower == 'soy' then if lower == 'soy' then
return os.date('%Y-%m-%d', os.time({ year = today.year, month = 1, day = 1 })) --[[@as string]] return os.date(
'%Y-%m-%d',
os.time({ year = today.year, month = 1, day = 1 })
) --[[@as string]]
end end
if lower == 'eoy' then if lower == 'eoy' then
return os.date('%Y-%m-%d', os.time({ year = today.year, month = 12, day = 31 })) --[[@as string]] return os.date(
'%Y-%m-%d',
os.time({ year = today.year, month = 12, day = 31 })
) --[[@as string]]
end end
if lower == 'later' or lower == 'someday' then if lower == 'later' or lower == 'someday' then
@ -144,9 +153,7 @@ function M.resolve_date(text)
os.time({ os.time({
year = today.year, year = today.year,
month = today.month, month = today.month,
day = today.day + ( day = today.day + (tonumber(n) --[[@as integer]]),
tonumber(n) --[[@as integer]]
),
}) })
) --[[@as string]] ) --[[@as string]]
end end
@ -158,9 +165,7 @@ function M.resolve_date(text)
os.time({ os.time({
year = today.year, year = today.year,
month = today.month, month = today.month,
day = today.day + ( day = today.day + (tonumber(n) --[[@as integer]]) * 7,
tonumber(n) --[[@as integer]]
) * 7,
}) })
) --[[@as string]] ) --[[@as string]]
end end
@ -171,9 +176,7 @@ function M.resolve_date(text)
'%Y-%m-%d', '%Y-%m-%d',
os.time({ os.time({
year = today.year, year = today.year,
month = today.month + ( month = today.month + (tonumber(n) --[[@as integer]]),
tonumber(n) --[[@as integer]]
),
day = today.day, day = today.day,
}) })
) --[[@as string]] ) --[[@as string]]
@ -186,9 +189,7 @@ function M.resolve_date(text)
os.time({ os.time({
year = today.year, year = today.year,
month = today.month, month = today.month,
day = today.day - ( day = today.day - (tonumber(n) --[[@as integer]]),
tonumber(n) --[[@as integer]]
),
}) })
) --[[@as string]] ) --[[@as string]]
end end
@ -200,9 +201,7 @@ function M.resolve_date(text)
os.time({ os.time({
year = today.year, year = today.year,
month = today.month, month = today.month,
day = today.day - ( day = today.day - (tonumber(n) --[[@as integer]]) * 7,
tonumber(n) --[[@as integer]]
) * 7,
}) })
) --[[@as string]] ) --[[@as string]]
end end
@ -244,7 +243,10 @@ function M.resolve_date(text)
if today.month >= target_month then if today.month >= target_month then
y = y + 1 y = y + 1
end end
return os.date('%Y-%m-%d', os.time({ year = y, month = target_month, day = 1 })) --[[@as string]] return os.date(
'%Y-%m-%d',
os.time({ year = y, month = target_month, day = 1 })
) --[[@as string]]
end end
local target_wday = weekday_map[lower] local target_wday = weekday_map[lower]

View file

@ -3,7 +3,6 @@
---@field interval integer ---@field interval integer
---@field byday? string[] ---@field byday? string[]
---@field from_completion boolean ---@field from_completion boolean
---@field _raw? string
---@class pending.recur ---@class pending.recur
local M = {} local M = {}
@ -11,12 +10,7 @@ local M = {}
---@type table<string, pending.RecurSpec> ---@type table<string, pending.RecurSpec>
local named = { local named = {
daily = { freq = 'daily', interval = 1, from_completion = false }, daily = { freq = 'daily', interval = 1, from_completion = false },
weekdays = { weekdays = { freq = 'weekly', interval = 1, byday = { 'MO', 'TU', 'WE', 'TH', 'FR' }, from_completion = false },
freq = 'weekly',
interval = 1,
byday = { 'MO', 'TU', 'WE', 'TH', 'FR' },
from_completion = false,
},
weekly = { freq = 'weekly', interval = 1, from_completion = false }, weekly = { freq = 'weekly', interval = 1, from_completion = false },
biweekly = { freq = 'weekly', interval = 2, from_completion = false }, biweekly = { freq = 'weekly', interval = 2, from_completion = false },
monthly = { freq = 'monthly', interval = 1, from_completion = false }, monthly = { freq = 'monthly', interval = 1, from_completion = false },
@ -102,12 +96,12 @@ local function advance_date(base_date, freq, interval)
new_y = new_y + 1 new_y = new_y + 1
end end
local last_day = os.date('*t', os.time({ year = new_y, month = new_m + 1, day = 0 })) --[[@as osdate]] local last_day = os.date('*t', os.time({ year = new_y, month = new_m + 1, day = 0 })) --[[@as osdate]]
local clamped_d = math.min(dn, last_day.day --[[@as integer]]) local clamped_d = math.min(dn, last_day.day)
return os.date('%Y-%m-%d', os.time({ year = new_y, month = new_m, day = clamped_d })) --[[@as string]] return os.date('%Y-%m-%d', os.time({ year = new_y, month = new_m, day = clamped_d })) --[[@as string]]
elseif freq == 'yearly' then elseif freq == 'yearly' then
local new_y = yn + interval local new_y = yn + interval
local last_day = os.date('*t', os.time({ year = new_y, month = mn + 1, day = 0 })) --[[@as osdate]] local last_day = os.date('*t', os.time({ year = new_y, month = mn + 1, day = 0 })) --[[@as osdate]]
local clamped_d = math.min(dn, last_day.day --[[@as integer]]) local clamped_d = math.min(dn, last_day.day)
return os.date('%Y-%m-%d', os.time({ year = new_y, month = mn, day = clamped_d })) --[[@as string]] return os.date('%Y-%m-%d', os.time({ year = new_y, month = mn, day = clamped_d })) --[[@as string]]
end end
return base_date return base_date

View file

@ -10,7 +10,6 @@ local config = require('pending.config')
---@field overdue? boolean ---@field overdue? boolean
---@field show_category? boolean ---@field show_category? boolean
---@field priority? integer ---@field priority? integer
---@field recur? string
---@class pending.views ---@class pending.views
local M = {} local M = {}
@ -150,7 +149,6 @@ function M.category_view(tasks)
status = task.status, status = task.status,
category = cat, category = cat,
overdue = task.status == 'pending' and task.due ~= nil and task.due < today or nil, overdue = task.status == 'pending' and task.due ~= nil and task.due < today or nil,
recur = task.recur,
}) })
end end
end end
@ -202,7 +200,6 @@ function M.priority_view(tasks)
category = task.category, category = task.category,
overdue = task.status == 'pending' and task.due ~= nil and task.due < today or nil, overdue = task.status == 'pending' and task.due ~= nil and task.due < today or nil,
show_category = true, show_category = true,
recur = task.recur,
}) })
end end

View file

@ -22,10 +22,6 @@ vim.keymap.set('n', '<Plug>(pending-open)', function()
require('pending').open() require('pending').open()
end) end)
vim.keymap.set('n', '<Plug>(pending-close)', function()
require('pending.buffer').close()
end)
vim.keymap.set('n', '<Plug>(pending-toggle)', function() vim.keymap.set('n', '<Plug>(pending-toggle)', function()
require('pending').toggle_complete() require('pending').toggle_complete()
end) end)
@ -41,15 +37,3 @@ end)
vim.keymap.set('n', '<Plug>(pending-date)', function() vim.keymap.set('n', '<Plug>(pending-date)', function()
require('pending').prompt_date() require('pending').prompt_date()
end) end)
vim.keymap.set('n', '<Plug>(pending-undo)', function()
require('pending').undo_write()
end)
vim.keymap.set('n', '<Plug>(pending-open-line)', function()
require('pending.buffer').open_line(false)
end)
vim.keymap.set('n', '<Plug>(pending-open-line-above)', function()
require('pending.buffer').open_line(true)
end)

View file

@ -1,171 +0,0 @@
require('spec.helpers')
local config = require('pending.config')
local store = require('pending.store')
describe('complete', function()
local tmpdir
local complete = require('pending.complete')
before_each(function()
tmpdir = vim.fn.tempname()
vim.fn.mkdir(tmpdir, 'p')
vim.g.pending = { data_path = tmpdir .. '/tasks.json' }
config.reset()
store.unload()
store.load()
end)
after_each(function()
vim.fn.delete(tmpdir, 'rf')
vim.g.pending = nil
config.reset()
end)
describe('findstart', function()
it('returns column after colon for cat: prefix', function()
local bufnr = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '- [ ] task cat:Wo' })
vim.api.nvim_set_current_buf(bufnr)
vim.api.nvim_win_set_cursor(0, { 1, 16 })
local result = complete.omnifunc(1, '')
assert.are.equal(15, result)
vim.api.nvim_buf_delete(bufnr, { force = true })
end)
it('returns column after colon for due: prefix', function()
local bufnr = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '- [ ] task due:to' })
vim.api.nvim_set_current_buf(bufnr)
vim.api.nvim_win_set_cursor(0, { 1, 16 })
local result = complete.omnifunc(1, '')
assert.are.equal(15, result)
vim.api.nvim_buf_delete(bufnr, { force = true })
end)
it('returns column after colon for rec: prefix', function()
local bufnr = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '- [ ] task rec:we' })
vim.api.nvim_set_current_buf(bufnr)
vim.api.nvim_win_set_cursor(0, { 1, 16 })
local result = complete.omnifunc(1, '')
assert.are.equal(15, result)
vim.api.nvim_buf_delete(bufnr, { force = true })
end)
it('returns -1 for non-token position', function()
local bufnr = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '- [ ] some task ' })
vim.api.nvim_set_current_buf(bufnr)
vim.api.nvim_win_set_cursor(0, { 1, 14 })
local result = complete.omnifunc(1, '')
assert.are.equal(-1, result)
vim.api.nvim_buf_delete(bufnr, { force = true })
end)
end)
describe('completions', function()
it('returns existing categories for cat:', function()
store.add({ description = 'A', category = 'Work' })
store.add({ description = 'B', category = 'Home' })
store.add({ description = 'C', category = 'Work' })
local bufnr = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '- [ ] task cat: x' })
vim.api.nvim_set_current_buf(bufnr)
vim.api.nvim_win_set_cursor(0, { 1, 15 })
complete.omnifunc(1, '')
local result = complete.omnifunc(0, '')
local words = {}
for _, item in ipairs(result) do
table.insert(words, item.word)
end
assert.is_true(vim.tbl_contains(words, 'Work'))
assert.is_true(vim.tbl_contains(words, 'Home'))
vim.api.nvim_buf_delete(bufnr, { force = true })
end)
it('filters categories by base', function()
store.add({ description = 'A', category = 'Work' })
store.add({ description = 'B', category = 'Home' })
local bufnr = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '- [ ] task cat:W' })
vim.api.nvim_set_current_buf(bufnr)
vim.api.nvim_win_set_cursor(0, { 1, 15 })
complete.omnifunc(1, '')
local result = complete.omnifunc(0, 'W')
assert.are.equal(1, #result)
assert.are.equal('Work', result[1].word)
vim.api.nvim_buf_delete(bufnr, { force = true })
end)
it('returns named dates for due:', function()
local bufnr = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '- [ ] task due: x' })
vim.api.nvim_set_current_buf(bufnr)
vim.api.nvim_win_set_cursor(0, { 1, 15 })
complete.omnifunc(1, '')
local result = complete.omnifunc(0, '')
assert.is_true(#result > 0)
local words = {}
for _, item in ipairs(result) do
table.insert(words, item.word)
end
assert.is_true(vim.tbl_contains(words, 'today'))
assert.is_true(vim.tbl_contains(words, 'tomorrow'))
assert.is_true(vim.tbl_contains(words, 'eom'))
vim.api.nvim_buf_delete(bufnr, { force = true })
end)
it('filters dates by base prefix', function()
local bufnr = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '- [ ] task due:to' })
vim.api.nvim_set_current_buf(bufnr)
vim.api.nvim_win_set_cursor(0, { 1, 16 })
complete.omnifunc(1, '')
local result = complete.omnifunc(0, 'to')
local words = {}
for _, item in ipairs(result) do
table.insert(words, item.word)
end
assert.is_true(vim.tbl_contains(words, 'today'))
assert.is_true(vim.tbl_contains(words, 'tomorrow'))
assert.is_false(vim.tbl_contains(words, 'eom'))
vim.api.nvim_buf_delete(bufnr, { force = true })
end)
it('returns recurrence shorthands for rec:', function()
local bufnr = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '- [ ] task rec: x' })
vim.api.nvim_set_current_buf(bufnr)
vim.api.nvim_win_set_cursor(0, { 1, 15 })
complete.omnifunc(1, '')
local result = complete.omnifunc(0, '')
assert.is_true(#result > 0)
local words = {}
for _, item in ipairs(result) do
table.insert(words, item.word)
end
assert.is_true(vim.tbl_contains(words, 'daily'))
assert.is_true(vim.tbl_contains(words, 'weekly'))
assert.is_true(vim.tbl_contains(words, '!weekly'))
vim.api.nvim_buf_delete(bufnr, { force = true })
end)
it('filters recurrence by base prefix', function()
local bufnr = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '- [ ] task rec:we' })
vim.api.nvim_set_current_buf(bufnr)
vim.api.nvim_win_set_cursor(0, { 1, 16 })
complete.omnifunc(1, '')
local result = complete.omnifunc(0, 'we')
local words = {}
for _, item in ipairs(result) do
table.insert(words, item.word)
end
assert.is_true(vim.tbl_contains(words, 'weekly'))
assert.is_true(vim.tbl_contains(words, 'weekdays'))
assert.is_false(vim.tbl_contains(words, 'daily'))
vim.api.nvim_buf_delete(bufnr, { force = true })
end)
end)
end)

View file

@ -193,8 +193,7 @@ describe('parse', function()
it('returns last day of current month for eom', function() it('returns last day of current month for eom', function()
local today = os.date('*t') --[[@as osdate]] local today = os.date('*t') --[[@as osdate]]
local expected = local expected = os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month + 1, day = 0 }))
os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month + 1, day = 0 }))
local result = parse.resolve_date('eom') local result = parse.resolve_date('eom')
assert.are.equal(expected, result) assert.are.equal(expected, result)
end) end)
@ -212,8 +211,7 @@ describe('parse', function()
local today = os.date('*t') --[[@as osdate]] local today = os.date('*t') --[[@as osdate]]
local q = math.ceil(today.month / 3) local q = math.ceil(today.month / 3)
local last_month = q * 3 local last_month = q * 3
local expected = local expected = os.date('%Y-%m-%d', os.time({ year = today.year, month = last_month + 1, day = 0 }))
os.date('%Y-%m-%d', os.time({ year = today.year, month = last_month + 1, day = 0 }))
local result = parse.resolve_date('eoq') local result = parse.resolve_date('eoq')
assert.are.equal(expected, result) assert.are.equal(expected, result)
end) end)

View file

@ -151,14 +151,11 @@ describe('recur', function()
it('completion mode advances from today', function() it('completion mode advances from today', function()
local today = os.date('*t') --[[@as osdate]] local today = os.date('*t') --[[@as osdate]]
local expected = os.date( local expected = os.date('%Y-%m-%d', os.time({
'%Y-%m-%d',
os.time({
year = today.year, year = today.year,
month = today.month, month = today.month,
day = today.day + 7, day = today.day + 7,
}) }))
)
local result = recur.next_due('2020-01-01', 'weekly', 'completion') local result = recur.next_due('2020-01-01', 'weekly', 'completion')
assert.are.equal(expected, result) assert.are.equal(expected, result)
end) end)

View file

@ -204,30 +204,6 @@ describe('views', function()
assert.is_falsy(task_meta.overdue) assert.is_falsy(task_meta.overdue)
end) end)
it('includes recur in LineMeta for recurring tasks', function()
store.add({ description = 'Recurring', category = 'Inbox', recur = 'weekly' })
local _, meta = views.category_view(store.active_tasks())
local task_meta
for _, m in ipairs(meta) do
if m.type == 'task' then
task_meta = m
end
end
assert.are.equal('weekly', task_meta.recur)
end)
it('has nil recur in LineMeta for non-recurring tasks', function()
store.add({ description = 'Normal', category = 'Inbox' })
local _, meta = views.category_view(store.active_tasks())
local task_meta
for _, m in ipairs(meta) do
if m.type == 'task' then
task_meta = m
end
end
assert.is_nil(task_meta.recur)
end)
it('respects category_order when set', function() it('respects category_order when set', function()
vim.g.pending = { data_path = tmpdir .. '/tasks.json', category_order = { 'Work', 'Inbox' } } vim.g.pending = { data_path = tmpdir .. '/tasks.json', category_order = { 'Work', 'Inbox' } }
config.reset() config.reset()
@ -423,29 +399,5 @@ describe('views', function()
end end
assert.is_falsy(task_meta.overdue) assert.is_falsy(task_meta.overdue)
end) end)
it('includes recur in LineMeta for recurring tasks', function()
store.add({ description = 'Recurring', category = 'Inbox', recur = 'daily' })
local _, meta = views.priority_view(store.active_tasks())
local task_meta
for _, m in ipairs(meta) do
if m.type == 'task' then
task_meta = m
end
end
assert.are.equal('daily', task_meta.recur)
end)
it('has nil recur in LineMeta for non-recurring tasks', function()
store.add({ description = 'Normal', category = 'Inbox' })
local _, meta = views.priority_view(store.active_tasks())
local task_meta
for _, m in ipairs(meta) do
if m.type == 'task' then
task_meta = m
end
end
assert.is_nil(task_meta.recur)
end)
end) end)
end) end)