Compare commits
10 commits
b08d01fe78
...
be81abfe48
| Author | SHA1 | Date | |
|---|---|---|---|
| be81abfe48 | |||
| 4f6a5224de | |||
| 11df8407a1 | |||
| c038130a8b | |||
| 39fd4ef17a | |||
| 9c5d57bece | |||
| 7ebfcc63c3 | |||
| 153f614990 | |||
| a1a8d1db3b | |||
| 1e2d72914c |
14 changed files with 675 additions and 183 deletions
178
doc/pending.txt
178
doc/pending.txt
|
|
@ -30,13 +30,16 @@ 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:` and `cat:` tokens parsed on `:w`
|
- Inline metadata syntax: `due:`, `cat:`, and `rec:` tokens parsed on `:w`
|
||||||
- Relative date input: `today`, `tomorrow`, `+Nd`, weekday names
|
- Relative date input: `today`, `tomorrow`, `+Nd`, `+Nw`, `+Nm`, weekday
|
||||||
|
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
|
||||||
|
|
||||||
==============================================================================
|
==============================================================================
|
||||||
|
|
@ -95,20 +98,18 @@ 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:today` Resolve to today's date.
|
`due:<name>` Resolve a named date (see |pending-dates| below).
|
||||||
`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|. If `date_syntax` is set to `by`, write
|
`date_syntax` in |pending-config|. The token name for recurrence defaults to
|
||||||
`by:2026-03-15` instead.
|
`rec` and is configurable via `recur_syntax`.
|
||||||
|
|
||||||
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
|
||||||
|
|
@ -116,8 +117,87 @@ 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:` and one
|
Repeated tokens of the same type also stop parsing — only one `due:`, one
|
||||||
`cat:` per task line are consumed.
|
`cat:`, and one `rec:` 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*
|
||||||
|
|
@ -135,6 +215,7 @@ 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.
|
||||||
|
|
||||||
|
|
@ -169,27 +250,34 @@ 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: ~
|
Buffer-local keys are configured via the `keymaps` table in |pending-config|.
|
||||||
|
The defaults are shown below. Set any key to `false` to disable it.
|
||||||
|
|
||||||
|
Default buffer-local keys: ~
|
||||||
|
|
||||||
Key Action ~
|
Key Action ~
|
||||||
------- ------------------------------------------------
|
------- ------------------------------------------------
|
||||||
`<CR>` Toggle complete / uncomplete the task at cursor
|
`q` Close the task buffer (`close`)
|
||||||
`!` Toggle the priority flag on the task at cursor
|
`<CR>` Toggle complete / uncomplete (`toggle`)
|
||||||
`D` Prompt for a due date on the task at cursor
|
`!` Toggle the priority flag (`priority`)
|
||||||
`<Tab>` Switch between category view and priority view
|
`D` Prompt for a due date (`date`)
|
||||||
`U` Undo the last `:w` save
|
`<Tab>` Switch between category / priority view (`view`)
|
||||||
`g?` Show a help popup with available keys
|
`U` Undo the last `:w` save (`undo`)
|
||||||
|
`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)
|
||||||
|
|
||||||
`o` and `O` are overridden to insert a correctly-formatted blank task line
|
`dd`, `p`, `P`, and `:w` work as standard Vim operations.
|
||||||
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.
|
||||||
|
|
@ -206,6 +294,18 @@ indentation. `dd`, `p`, `P`, and `:w` work as expected.
|
||||||
<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)')
|
||||||
|
|
@ -242,7 +342,19 @@ 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',
|
||||||
|
|
@ -278,12 +390,28 @@ 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
|
||||||
|
|
@ -371,6 +499,11 @@ 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 })
|
||||||
<
|
<
|
||||||
|
|
@ -388,6 +521,7 @@ 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)
|
||||||
|
|
||||||
|
|
@ -414,6 +548,8 @@ 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.
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ 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
|
||||||
|
|
@ -122,24 +123,22 @@ 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'
|
||||||
if m.show_category then
|
local virt_parts = {}
|
||||||
local virt_text
|
if m.show_category and m.category then
|
||||||
if m.category and m.due then
|
table.insert(virt_parts, { m.category, 'PendingHeader' })
|
||||||
virt_text = { { m.category .. ' ', 'PendingHeader' }, { m.due, due_hl } }
|
|
||||||
elseif m.category then
|
|
||||||
virt_text = { { m.category, 'PendingHeader' } }
|
|
||||||
elseif m.due then
|
|
||||||
virt_text = { { m.due, due_hl } }
|
|
||||||
end
|
end
|
||||||
if virt_text then
|
if m.recur then
|
||||||
vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, {
|
table.insert(virt_parts, { '\u{21bb} ' .. m.recur, 'PendingRecur' })
|
||||||
virt_text = virt_text,
|
end
|
||||||
virt_text_pos = 'eol',
|
if m.due then
|
||||||
})
|
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
|
||||||
elseif m.due 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 = { { m.due, due_hl } },
|
virt_text = virt_parts,
|
||||||
virt_text_pos = 'eol',
|
virt_text_pos = 'eol',
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
@ -167,6 +166,7 @@ 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)
|
||||||
|
|
|
||||||
138
lua/pending/complete.lua
Normal file
138
lua/pending/complete.lua
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
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
|
||||||
|
|
@ -2,6 +2,16 @@
|
||||||
---@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'
|
||||||
|
|
@ -12,6 +22,7 @@
|
||||||
---@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
|
||||||
|
|
@ -27,6 +38,16 @@ 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?
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,17 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -50,37 +50,44 @@ 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, opts)
|
end,
|
||||||
vim.keymap.set('n', '<Esc>', function()
|
toggle = function()
|
||||||
buffer.close()
|
|
||||||
end, opts)
|
|
||||||
vim.keymap.set('n', '<CR>', function()
|
|
||||||
M.toggle_complete()
|
M.toggle_complete()
|
||||||
end, opts)
|
end,
|
||||||
vim.keymap.set('n', '<Tab>', function()
|
view = function()
|
||||||
buffer.toggle_view()
|
buffer.toggle_view()
|
||||||
end, opts)
|
end,
|
||||||
vim.keymap.set('n', 'g?', function()
|
priority = function()
|
||||||
M.show_help()
|
|
||||||
end, opts)
|
|
||||||
vim.keymap.set('n', '!', function()
|
|
||||||
M.toggle_priority()
|
M.toggle_priority()
|
||||||
end, opts)
|
end,
|
||||||
vim.keymap.set('n', 'D', function()
|
date = function()
|
||||||
M.prompt_date()
|
M.prompt_date()
|
||||||
end, opts)
|
end,
|
||||||
vim.keymap.set('n', 'U', function()
|
undo = function()
|
||||||
M.undo_write()
|
M.undo_write()
|
||||||
end, opts)
|
end,
|
||||||
vim.keymap.set('n', 'o', function()
|
open_line = function()
|
||||||
buffer.open_line(false)
|
buffer.open_line(false)
|
||||||
end, opts)
|
end,
|
||||||
vim.keymap.set('n', 'O', function()
|
open_line_above = function()
|
||||||
buffer.open_line(true)
|
buffer.open_line(true)
|
||||||
end, opts)
|
end,
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
@ -130,7 +137,8 @@ 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]] or task.due
|
local base = mode == 'completion' and os.date('%Y-%m-%d') --[[@as string]]
|
||||||
|
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,
|
||||||
|
|
@ -333,75 +341,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -45,9 +45,18 @@ local weekday_map = {
|
||||||
}
|
}
|
||||||
|
|
||||||
local month_map = {
|
local month_map = {
|
||||||
jan = 1, feb = 2, mar = 3, apr = 4,
|
jan = 1,
|
||||||
may = 5, jun = 6, jul = 7, aug = 8,
|
feb = 2,
|
||||||
sep = 9, oct = 10, nov = 11, dec = 12,
|
mar = 3,
|
||||||
|
apr = 4,
|
||||||
|
may = 5,
|
||||||
|
jun = 6,
|
||||||
|
jul = 7,
|
||||||
|
aug = 8,
|
||||||
|
sep = 9,
|
||||||
|
oct = 10,
|
||||||
|
nov = 11,
|
||||||
|
dec = 12,
|
||||||
}
|
}
|
||||||
|
|
||||||
---@param today osdate
|
---@param today osdate
|
||||||
|
|
@ -97,49 +106,31 @@ function M.resolve_date(text)
|
||||||
end
|
end
|
||||||
|
|
||||||
if lower == 'som' then
|
if lower == 'som' then
|
||||||
return os.date(
|
return os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = 1 })) --[[@as string]]
|
||||||
'%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(
|
return os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month + 1, day = 0 })) --[[@as string]]
|
||||||
'%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(
|
return os.date('%Y-%m-%d', os.time({ year = today.year, month = first_month, day = 1 })) --[[@as string]]
|
||||||
'%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(
|
return os.date('%Y-%m-%d', os.time({ year = today.year, month = last_month + 1, day = 0 })) --[[@as string]]
|
||||||
'%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(
|
return os.date('%Y-%m-%d', os.time({ year = today.year, month = 1, day = 1 })) --[[@as string]]
|
||||||
'%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(
|
return os.date('%Y-%m-%d', os.time({ year = today.year, month = 12, day = 31 })) --[[@as string]]
|
||||||
'%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
|
||||||
|
|
@ -153,7 +144,9 @@ function M.resolve_date(text)
|
||||||
os.time({
|
os.time({
|
||||||
year = today.year,
|
year = today.year,
|
||||||
month = today.month,
|
month = today.month,
|
||||||
day = today.day + (tonumber(n) --[[@as integer]]),
|
day = today.day + (
|
||||||
|
tonumber(n) --[[@as integer]]
|
||||||
|
),
|
||||||
})
|
})
|
||||||
) --[[@as string]]
|
) --[[@as string]]
|
||||||
end
|
end
|
||||||
|
|
@ -165,7 +158,9 @@ function M.resolve_date(text)
|
||||||
os.time({
|
os.time({
|
||||||
year = today.year,
|
year = today.year,
|
||||||
month = today.month,
|
month = today.month,
|
||||||
day = today.day + (tonumber(n) --[[@as integer]]) * 7,
|
day = today.day + (
|
||||||
|
tonumber(n) --[[@as integer]]
|
||||||
|
) * 7,
|
||||||
})
|
})
|
||||||
) --[[@as string]]
|
) --[[@as string]]
|
||||||
end
|
end
|
||||||
|
|
@ -176,7 +171,9 @@ function M.resolve_date(text)
|
||||||
'%Y-%m-%d',
|
'%Y-%m-%d',
|
||||||
os.time({
|
os.time({
|
||||||
year = today.year,
|
year = today.year,
|
||||||
month = today.month + (tonumber(n) --[[@as integer]]),
|
month = today.month + (
|
||||||
|
tonumber(n) --[[@as integer]]
|
||||||
|
),
|
||||||
day = today.day,
|
day = today.day,
|
||||||
})
|
})
|
||||||
) --[[@as string]]
|
) --[[@as string]]
|
||||||
|
|
@ -189,7 +186,9 @@ function M.resolve_date(text)
|
||||||
os.time({
|
os.time({
|
||||||
year = today.year,
|
year = today.year,
|
||||||
month = today.month,
|
month = today.month,
|
||||||
day = today.day - (tonumber(n) --[[@as integer]]),
|
day = today.day - (
|
||||||
|
tonumber(n) --[[@as integer]]
|
||||||
|
),
|
||||||
})
|
})
|
||||||
) --[[@as string]]
|
) --[[@as string]]
|
||||||
end
|
end
|
||||||
|
|
@ -201,7 +200,9 @@ function M.resolve_date(text)
|
||||||
os.time({
|
os.time({
|
||||||
year = today.year,
|
year = today.year,
|
||||||
month = today.month,
|
month = today.month,
|
||||||
day = today.day - (tonumber(n) --[[@as integer]]) * 7,
|
day = today.day - (
|
||||||
|
tonumber(n) --[[@as integer]]
|
||||||
|
) * 7,
|
||||||
})
|
})
|
||||||
) --[[@as string]]
|
) --[[@as string]]
|
||||||
end
|
end
|
||||||
|
|
@ -243,10 +244,7 @@ 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(
|
return os.date('%Y-%m-%d', os.time({ year = y, month = target_month, day = 1 })) --[[@as string]]
|
||||||
'%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]
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
---@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 = {}
|
||||||
|
|
@ -10,7 +11,12 @@ 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 = { freq = 'weekly', interval = 1, byday = { 'MO', 'TU', 'WE', 'TH', 'FR' }, from_completion = false },
|
weekdays = {
|
||||||
|
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 },
|
||||||
|
|
@ -96,12 +102,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)
|
local clamped_d = math.min(dn, last_day.day --[[@as integer]])
|
||||||
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)
|
local clamped_d = math.min(dn, last_day.day --[[@as integer]])
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ 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 = {}
|
||||||
|
|
@ -149,6 +150,7 @@ 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
|
||||||
|
|
@ -200,6 +202,7 @@ 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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,10 @@ 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)
|
||||||
|
|
@ -37,3 +41,15 @@ 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)
|
||||||
|
|
|
||||||
171
spec/complete_spec.lua
Normal file
171
spec/complete_spec.lua
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
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)
|
||||||
|
|
@ -193,7 +193,8 @@ 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 = os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month + 1, day = 0 }))
|
local expected =
|
||||||
|
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)
|
||||||
|
|
@ -211,7 +212,8 @@ 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 = os.date('%Y-%m-%d', os.time({ year = today.year, month = last_month + 1, day = 0 }))
|
local expected =
|
||||||
|
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)
|
||||||
|
|
|
||||||
|
|
@ -151,11 +151,14 @@ 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('%Y-%m-%d', os.time({
|
local expected = os.date(
|
||||||
|
'%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)
|
||||||
|
|
|
||||||
|
|
@ -204,6 +204,30 @@ 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()
|
||||||
|
|
@ -399,5 +423,29 @@ 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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue