Compare commits
7 commits
doc/minify
...
feat/time-
| Author | SHA1 | Date | |
|---|---|---|---|
| 18049e91a0 | |||
| 66eb93a6d1 | |||
| ee2d125846 | |||
| c69afacc87 | |||
| 9abf070912 | |||
|
|
7d93c4bb45 | ||
|
|
6911c091f6 |
23 changed files with 2031 additions and 328 deletions
2
.github/DISCUSSION_TEMPLATE/q-a.yaml
vendored
2
.github/DISCUSSION_TEMPLATE/q-a.yaml
vendored
|
|
@ -1,4 +1,4 @@
|
||||||
title: 'Q&A'
|
title: "Q&A"
|
||||||
labels: []
|
labels: []
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
|
|
|
||||||
17
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
17
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
|
|
@ -1,14 +1,13 @@
|
||||||
name: Bug Report
|
name: Bug Report
|
||||||
description: Report a bug
|
description: Report a bug
|
||||||
title: 'bug: '
|
title: "bug: "
|
||||||
labels: [bug]
|
labels: [bug]
|
||||||
body:
|
body:
|
||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
attributes:
|
attributes:
|
||||||
label: Prerequisites
|
label: Prerequisites
|
||||||
options:
|
options:
|
||||||
- label:
|
- label: I have searched [existing
|
||||||
I have searched [existing
|
|
||||||
issues](https://github.com/barrettruth/pending.nvim/issues)
|
issues](https://github.com/barrettruth/pending.nvim/issues)
|
||||||
required: true
|
required: true
|
||||||
- label: I have updated to the latest version
|
- label: I have updated to the latest version
|
||||||
|
|
@ -16,16 +15,16 @@ body:
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: 'Neovim version'
|
label: "Neovim version"
|
||||||
description: 'Output of `nvim --version`'
|
description: "Output of `nvim --version`"
|
||||||
render: text
|
render: text
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: input
|
- type: input
|
||||||
attributes:
|
attributes:
|
||||||
label: 'Operating system'
|
label: "Operating system"
|
||||||
placeholder: 'e.g. Arch Linux, macOS 15, Ubuntu 24.04'
|
placeholder: "e.g. Arch Linux, macOS 15, Ubuntu 24.04"
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
|
@ -49,8 +48,8 @@ body:
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: 'Health check'
|
label: "Health check"
|
||||||
description: 'Output of `:checkhealth task`'
|
description: "Output of `:checkhealth task`"
|
||||||
render: text
|
render: text
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
|
|
|
||||||
5
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
5
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
|
|
@ -1,14 +1,13 @@
|
||||||
name: Feature Request
|
name: Feature Request
|
||||||
description: Suggest a feature
|
description: Suggest a feature
|
||||||
title: 'feat: '
|
title: "feat: "
|
||||||
labels: [enhancement]
|
labels: [enhancement]
|
||||||
body:
|
body:
|
||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
attributes:
|
attributes:
|
||||||
label: Prerequisites
|
label: Prerequisites
|
||||||
options:
|
options:
|
||||||
- label:
|
- label: I have searched [existing
|
||||||
I have searched [existing
|
|
||||||
issues](https://github.com/barrettruth/pending.nvim/issues)
|
issues](https://github.com/barrettruth/pending.nvim/issues)
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
|
|
||||||
2
.github/workflows/luarocks.yaml
vendored
2
.github/workflows/luarocks.yaml
vendored
|
|
@ -3,7 +3,7 @@ name: luarocks
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- "v*"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
quality:
|
quality:
|
||||||
|
|
|
||||||
143
README.md
143
README.md
|
|
@ -2,145 +2,30 @@
|
||||||
|
|
||||||
Edit tasks like text. `:w` saves them.
|
Edit tasks like text. `:w` saves them.
|
||||||
|
|
||||||
A buffer-centric task manager for Neovim. Tasks live in a plain buffer — add
|
<!-- insert preview -->
|
||||||
with `o`, delete with `dd`, reorder with `dd`/`p`, rename by editing. Write the
|
|
||||||
buffer and the diff is computed against a JSON store. No UI chrome, no floating
|
|
||||||
windows, no abstractions between you and your tasks.
|
|
||||||
|
|
||||||
## How it works
|
## Requirements
|
||||||
|
|
||||||
```
|
- Neovim 0.10+
|
||||||
School
|
- (Optionally) `curl` and `openssl` for Google Calendar and Google Task sync
|
||||||
! Read chapter 5 Feb 28
|
|
||||||
Submit homework Feb 25
|
|
||||||
|
|
||||||
Errands
|
## Installation
|
||||||
Buy groceries Mar 01
|
|
||||||
Clean apartment
|
|
||||||
```
|
|
||||||
|
|
||||||
Category headers sit at column 0. Tasks are indented below them. `!` marks
|
Install with your package manager of choice or via
|
||||||
priority. Due dates appear as right-aligned virtual text. Done tasks get
|
[luarocks](https://luarocks.org/modules/barrettruth/pending.nvim):
|
||||||
strikethrough. Everything you see is editable buffer text — the IDs are
|
|
||||||
concealed, and metadata is parsed from inline syntax on save.
|
|
||||||
|
|
||||||
## Install
|
|
||||||
|
|
||||||
```
|
```
|
||||||
luarocks install pending.nvim
|
luarocks install pending.nvim
|
||||||
```
|
```
|
||||||
|
|
||||||
**lazy.nvim:**
|
|
||||||
|
|
||||||
```lua
|
|
||||||
{ 'barrettruth/pending.nvim' }
|
|
||||||
```
|
|
||||||
|
|
||||||
Requires Neovim 0.10+. No external dependencies for local use. Google Calendar
|
|
||||||
sync requires `curl` and `openssl`.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
`:Pending` opens the task buffer. From there, it's just vim:
|
|
||||||
|
|
||||||
| Key | Action |
|
|
||||||
| --------- | ------------------------------- |
|
|
||||||
| `o` / `O` | Add a new task |
|
|
||||||
| `dd` | Delete a task (on `:w`) |
|
|
||||||
| `p` | Paste (duplicates get new IDs) |
|
|
||||||
| `:w` | Save all changes |
|
|
||||||
| `<CR>` | Toggle complete (immediate) |
|
|
||||||
| `<Tab>` | Switch category / priority view |
|
|
||||||
| `g?` | Show keybind help |
|
|
||||||
|
|
||||||
### Inline metadata
|
|
||||||
|
|
||||||
Type metadata tokens at the end of a task line before saving:
|
|
||||||
|
|
||||||
```
|
|
||||||
Buy milk due:2026-03-15 cat:Errands
|
|
||||||
```
|
|
||||||
|
|
||||||
On `:w`, the date and category are extracted. The description becomes `Buy milk`,
|
|
||||||
the due date renders as virtual text, and the task moves under the `Errands`
|
|
||||||
header.
|
|
||||||
|
|
||||||
### Quick add
|
|
||||||
|
|
||||||
```vim
|
|
||||||
:Pending add Buy groceries due:2026-03-15
|
|
||||||
:Pending add School: Submit homework
|
|
||||||
```
|
|
||||||
|
|
||||||
### Archive
|
|
||||||
|
|
||||||
```vim
|
|
||||||
:Pending archive " purge done tasks older than 30 days
|
|
||||||
:Pending archive 7 " purge done tasks older than 7 days
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
No `setup()` call required. Set `vim.g.pending` before the plugin loads:
|
|
||||||
|
|
||||||
```lua
|
|
||||||
vim.g.pending = {
|
|
||||||
data_path = vim.fn.stdpath('data') .. '/pending/tasks.json',
|
|
||||||
default_view = 'category', -- 'category' or 'priority'
|
|
||||||
default_category = 'Inbox',
|
|
||||||
date_format = '%b %d', -- strftime format for virtual text
|
|
||||||
date_syntax = 'due', -- inline token name (e.g. 'by' for by:2026-03-15)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
All fields are optional. Absent keys use the defaults shown above.
|
|
||||||
|
|
||||||
## Google Calendar sync
|
|
||||||
|
|
||||||
One-way push of tasks with due dates to a dedicated Google Calendar as all-day
|
|
||||||
events.
|
|
||||||
|
|
||||||
```lua
|
|
||||||
vim.g.pending = {
|
|
||||||
gcal = {
|
|
||||||
calendar = 'Tasks',
|
|
||||||
credentials_path = '/path/to/client_secret.json',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```vim
|
|
||||||
:Pending sync
|
|
||||||
```
|
|
||||||
|
|
||||||
On first run, a browser window opens for OAuth consent. The refresh token is
|
|
||||||
stored at `stdpath('data')/pending/gcal_tokens.json`. Completed or deleted tasks
|
|
||||||
have their calendar events removed. Due date changes update events in place.
|
|
||||||
|
|
||||||
## Mappings
|
|
||||||
|
|
||||||
The plugin defines `<Plug>` mappings for custom keybinds:
|
|
||||||
|
|
||||||
```lua
|
|
||||||
vim.keymap.set('n', '<leader>t', '<Plug>(pending-open)')
|
|
||||||
vim.keymap.set('n', '<leader>T', '<Plug>(pending-toggle)')
|
|
||||||
```
|
|
||||||
|
|
||||||
| Plug mapping | Action |
|
|
||||||
| -------------------------- | -------------------- |
|
|
||||||
| `<Plug>(pending-open)` | Open task buffer |
|
|
||||||
| `<Plug>(pending-toggle)` | Toggle complete |
|
|
||||||
| `<Plug>(pending-view)` | Switch view |
|
|
||||||
| `<Plug>(pending-priority)` | Toggle priority flag |
|
|
||||||
| `<Plug>(pending-date)` | Prompt for due date |
|
|
||||||
|
|
||||||
## Data format
|
|
||||||
|
|
||||||
Tasks are stored as JSON at `stdpath('data')/pending/tasks.json`. The schema is
|
|
||||||
versioned and forward-compatible — unknown fields are preserved on round-trip.
|
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
```vim
|
```vim
|
||||||
:checkhealth pending
|
:help pending.nvim
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Acknowledgements
|
||||||
|
|
||||||
|
- [dooing](https://github.com/atiladefreitas/dooing)
|
||||||
|
- [todo-comments.nvim](https://github.com/folke/todo-comments.nvim)
|
||||||
|
- [todotxt.nvim](https://github.com/arnarg/todotxt.nvim)
|
||||||
|
|
|
||||||
212
doc/pending.txt
212
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, persisted across sessions)
|
||||||
- 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,104 @@ 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`)
|
||||||
|
|
||||||
|
Time suffix: ~ *pending-dates-time*
|
||||||
|
Any named date or absolute date accepts an `@` time suffix. Supported
|
||||||
|
formats: `HH:MM` (24h), `H:MM`, bare hour (`9`, `14`), and am/pm
|
||||||
|
(`2pm`, `9:30am`, `12am`). All forms are normalized to `HH:MM` on save. >
|
||||||
|
|
||||||
|
due:tomorrow@2pm " tomorrow at 14:00
|
||||||
|
due:fri@9 " next Friday at 09:00
|
||||||
|
due:+1w@17:00 " one week from today at 17:00
|
||||||
|
due:tomorrow@9:30am " tomorrow at 09:30
|
||||||
|
due:2026-03-15@08:00 " absolute date with time
|
||||||
|
due:2026-03-15T14:30 " ISO 8601 datetime (also accepted)
|
||||||
|
<
|
||||||
|
|
||||||
|
Tasks with a time component are not considered overdue until after the
|
||||||
|
specified time. The time is displayed alongside the date in virtual text
|
||||||
|
and preserved across recurrence advances.
|
||||||
|
|
||||||
|
==============================================================================
|
||||||
|
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 +232,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.
|
||||||
|
|
||||||
|
|
@ -161,7 +259,7 @@ COMMANDS *pending-commands*
|
||||||
:Pending undo
|
:Pending undo
|
||||||
Undo the last `:w` save, restoring the task store to its previous state.
|
Undo the last `:w` save, restoring the task store to its previous state.
|
||||||
Equivalent to the `U` buffer-local key (see |pending-mappings|). Up to 20
|
Equivalent to the `U` buffer-local key (see |pending-mappings|). Up to 20
|
||||||
levels of undo are retained per session.
|
levels of undo are persisted across sessions.
|
||||||
|
|
||||||
==============================================================================
|
==============================================================================
|
||||||
MAPPINGS *pending-mappings*
|
MAPPINGS *pending-mappings*
|
||||||
|
|
@ -169,27 +267,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 +311,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 +359,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,17 +407,46 @@ 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
|
||||||
disable Google Calendar sync.
|
disable Google Calendar sync.
|
||||||
|
|
||||||
|
==============================================================================
|
||||||
|
RECIPES *pending-recipes*
|
||||||
|
|
||||||
|
Configure blink.cmp to use pending.nvim's omnifunc as a completion source: >lua
|
||||||
|
require('blink.cmp').setup({
|
||||||
|
sources = {
|
||||||
|
per_filetype = {
|
||||||
|
pending = { 'omni', 'buffer' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
<
|
||||||
|
|
||||||
==============================================================================
|
==============================================================================
|
||||||
GOOGLE CALENDAR *pending-gcal*
|
GOOGLE CALENDAR *pending-gcal*
|
||||||
|
|
||||||
|
|
@ -371,6 +529,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 +551,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 +578,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.
|
||||||
|
|
|
||||||
|
|
@ -37,10 +37,12 @@ function M.current_view_name()
|
||||||
return current_view
|
return current_view
|
||||||
end
|
end
|
||||||
|
|
||||||
|
---@return nil
|
||||||
function M.clear_winid()
|
function M.clear_winid()
|
||||||
task_winid = nil
|
task_winid = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
---@return nil
|
||||||
function M.close()
|
function M.close()
|
||||||
if task_winid and vim.api.nvim_win_is_valid(task_winid) then
|
if task_winid and vim.api.nvim_win_is_valid(task_winid) then
|
||||||
vim.api.nvim_win_close(task_winid, false)
|
vim.api.nvim_win_close(task_winid, false)
|
||||||
|
|
@ -55,19 +57,13 @@ 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
|
||||||
local function set_win_options(winid)
|
local function set_win_options(winid)
|
||||||
vim.wo[winid].conceallevel = 3
|
vim.wo[winid].conceallevel = 3
|
||||||
vim.wo[winid].concealcursor = 'nvic'
|
vim.wo[winid].concealcursor = 'nvic'
|
||||||
vim.wo[winid].wrap = false
|
|
||||||
vim.wo[winid].number = false
|
|
||||||
vim.wo[winid].relativenumber = false
|
|
||||||
vim.wo[winid].signcolumn = 'no'
|
|
||||||
vim.wo[winid].foldcolumn = '0'
|
|
||||||
vim.wo[winid].spell = false
|
|
||||||
vim.wo[winid].cursorline = true
|
|
||||||
vim.wo[winid].winfixheight = true
|
vim.wo[winid].winfixheight = true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -85,6 +81,7 @@ local function setup_syntax(bufnr)
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param above boolean
|
---@param above boolean
|
||||||
|
---@return nil
|
||||||
function M.open_line(above)
|
function M.open_line(above)
|
||||||
local bufnr = task_bufnr
|
local bufnr = task_bufnr
|
||||||
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then
|
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then
|
||||||
|
|
@ -122,24 +119,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 +162,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)
|
||||||
|
|
@ -212,6 +208,7 @@ local function restore_folds(bufnr)
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param bufnr? integer
|
---@param bufnr? integer
|
||||||
|
---@return nil
|
||||||
function M.render(bufnr)
|
function M.render(bufnr)
|
||||||
bufnr = bufnr or task_bufnr
|
bufnr = bufnr or task_bufnr
|
||||||
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then
|
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then
|
||||||
|
|
@ -256,6 +253,7 @@ function M.render(bufnr)
|
||||||
restore_folds(bufnr)
|
restore_folds(bufnr)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
---@return nil
|
||||||
function M.toggle_view()
|
function M.toggle_view()
|
||||||
if current_view == 'category' then
|
if current_view == 'category' then
|
||||||
current_view = 'priority'
|
current_view = 'priority'
|
||||||
|
|
|
||||||
170
lua/pending/complete.lua
Normal file
170
lua/pending/complete.lua
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
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 { word: string, info: string }[]
|
||||||
|
local function date_completions()
|
||||||
|
return {
|
||||||
|
{ word = 'today', info = "Today's date" },
|
||||||
|
{ word = 'tomorrow', info = "Tomorrow's date" },
|
||||||
|
{ word = 'yesterday', info = "Yesterday's date" },
|
||||||
|
{ word = '+1d', info = '1 day from today' },
|
||||||
|
{ word = '+2d', info = '2 days from today' },
|
||||||
|
{ word = '+3d', info = '3 days from today' },
|
||||||
|
{ word = '+1w', info = '1 week from today' },
|
||||||
|
{ word = '+2w', info = '2 weeks from today' },
|
||||||
|
{ word = '+1m', info = '1 month from today' },
|
||||||
|
{ word = 'mon', info = 'Next Monday' },
|
||||||
|
{ word = 'tue', info = 'Next Tuesday' },
|
||||||
|
{ word = 'wed', info = 'Next Wednesday' },
|
||||||
|
{ word = 'thu', info = 'Next Thursday' },
|
||||||
|
{ word = 'fri', info = 'Next Friday' },
|
||||||
|
{ word = 'sat', info = 'Next Saturday' },
|
||||||
|
{ word = 'sun', info = 'Next Sunday' },
|
||||||
|
{ word = 'eod', info = 'End of day (today)' },
|
||||||
|
{ word = 'eow', info = 'End of week (Sunday)' },
|
||||||
|
{ word = 'eom', info = 'End of month' },
|
||||||
|
{ word = 'eoq', info = 'End of quarter' },
|
||||||
|
{ word = 'eoy', info = 'End of year (Dec 31)' },
|
||||||
|
{ word = 'sow', info = 'Start of week (Monday)' },
|
||||||
|
{ word = 'som', info = 'Start of month' },
|
||||||
|
{ word = 'soq', info = 'Start of quarter' },
|
||||||
|
{ word = 'soy', info = 'Start of year (Jan 1)' },
|
||||||
|
{ word = 'later', info = 'Someday (sentinel date)' },
|
||||||
|
{ word = 'today@08:00', info = 'Today at 08:00' },
|
||||||
|
{ word = 'today@09:00', info = 'Today at 09:00' },
|
||||||
|
{ word = 'today@10:00', info = 'Today at 10:00' },
|
||||||
|
{ word = 'today@12:00', info = 'Today at 12:00' },
|
||||||
|
{ word = 'today@14:00', info = 'Today at 14:00' },
|
||||||
|
{ word = 'today@17:00', info = 'Today at 17:00' },
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
---@type table<string, string>
|
||||||
|
local recur_descriptions = {
|
||||||
|
daily = 'Every day',
|
||||||
|
weekdays = 'Monday through Friday',
|
||||||
|
weekly = 'Every week',
|
||||||
|
biweekly = 'Every 2 weeks',
|
||||||
|
monthly = 'Every month',
|
||||||
|
quarterly = 'Every 3 months',
|
||||||
|
yearly = 'Every year',
|
||||||
|
['2d'] = 'Every 2 days',
|
||||||
|
['3d'] = 'Every 3 days',
|
||||||
|
['2w'] = 'Every 2 weeks',
|
||||||
|
['3w'] = 'Every 3 weeks',
|
||||||
|
['2m'] = 'Every 2 months',
|
||||||
|
['3m'] = 'Every 3 months',
|
||||||
|
['6m'] = 'Every 6 months',
|
||||||
|
['2y'] = 'Every 2 years',
|
||||||
|
}
|
||||||
|
|
||||||
|
---@return { word: string, info: string }[]
|
||||||
|
local function recur_completions()
|
||||||
|
local recur = require('pending.recur')
|
||||||
|
local list = recur.shorthand_list()
|
||||||
|
local result = {}
|
||||||
|
for _, s in ipairs(list) do
|
||||||
|
local desc = recur_descriptions[s] or s
|
||||||
|
table.insert(result, { word = s, info = desc })
|
||||||
|
end
|
||||||
|
for _, s in ipairs(list) do
|
||||||
|
local desc = recur_descriptions[s] or s
|
||||||
|
table.insert(result, { word = '!' .. s, info = desc .. ' (from completion date)' })
|
||||||
|
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 matches = {}
|
||||||
|
local source = _complete_source or ''
|
||||||
|
|
||||||
|
local dk = date_key()
|
||||||
|
local rk = recur_key()
|
||||||
|
|
||||||
|
if source == dk then
|
||||||
|
for _, c in ipairs(date_completions()) do
|
||||||
|
if base == '' or c.word:sub(1, #base) == base then
|
||||||
|
table.insert(matches, { word = c.word, menu = '[' .. source .. ']', info = c.info })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
elseif source == 'cat' then
|
||||||
|
for _, c in ipairs(get_categories()) do
|
||||||
|
if base == '' or c:sub(1, #base) == base then
|
||||||
|
table.insert(matches, { word = c, menu = '[cat]' })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
elseif source == rk then
|
||||||
|
for _, c in ipairs(recur_completions()) do
|
||||||
|
if base == '' or c.word:sub(1, #base) == base then
|
||||||
|
table.insert(matches, { word = c.word, menu = '[' .. source .. ']', info = c.info })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return matches
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
|
|
@ -2,14 +2,27 @@
|
||||||
---@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'
|
||||||
---@field default_category string
|
---@field default_category string
|
||||||
---@field date_format string
|
---@field date_format string
|
||||||
---@field date_syntax string
|
---@field date_syntax string
|
||||||
|
---@field recur_syntax 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
|
||||||
|
|
@ -22,7 +35,19 @@ local defaults = {
|
||||||
default_category = 'Todo',
|
default_category = 'Todo',
|
||||||
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',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
---@type pending.Config?
|
---@type pending.Config?
|
||||||
|
|
@ -38,6 +63,7 @@ function M.get()
|
||||||
return _resolved
|
return _resolved
|
||||||
end
|
end
|
||||||
|
|
||||||
|
---@return nil
|
||||||
function M.reset()
|
function M.reset()
|
||||||
_resolved = nil
|
_resolved = nil
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ local store = require('pending.store')
|
||||||
---@field status? string
|
---@field status? string
|
||||||
---@field category? string
|
---@field category? string
|
||||||
---@field due? string
|
---@field due? string
|
||||||
|
---@field rec? string
|
||||||
|
---@field rec_mode? string
|
||||||
---@field lnum integer
|
---@field lnum integer
|
||||||
|
|
||||||
---@class pending.diff
|
---@class pending.diff
|
||||||
|
|
@ -48,6 +50,8 @@ function M.parse_buffer(lines)
|
||||||
status = status,
|
status = status,
|
||||||
category = metadata.cat or current_category or config.get().default_category,
|
category = metadata.cat or current_category or config.get().default_category,
|
||||||
due = metadata.due,
|
due = metadata.due,
|
||||||
|
rec = metadata.rec,
|
||||||
|
rec_mode = metadata.rec_mode,
|
||||||
lnum = i,
|
lnum = i,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
@ -61,6 +65,7 @@ function M.parse_buffer(lines)
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param lines string[]
|
---@param lines string[]
|
||||||
|
---@return nil
|
||||||
function M.apply(lines)
|
function M.apply(lines)
|
||||||
local parsed = M.parse_buffer(lines)
|
local parsed = M.parse_buffer(lines)
|
||||||
local now = timestamp()
|
local now = timestamp()
|
||||||
|
|
@ -90,6 +95,8 @@ function M.apply(lines)
|
||||||
category = entry.category,
|
category = entry.category,
|
||||||
priority = entry.priority,
|
priority = entry.priority,
|
||||||
due = entry.due,
|
due = entry.due,
|
||||||
|
recur = entry.rec,
|
||||||
|
recur_mode = entry.rec_mode,
|
||||||
order = order_counter,
|
order = order_counter,
|
||||||
})
|
})
|
||||||
else
|
else
|
||||||
|
|
@ -112,6 +119,14 @@ function M.apply(lines)
|
||||||
task.due = entry.due
|
task.due = entry.due
|
||||||
changed = true
|
changed = true
|
||||||
end
|
end
|
||||||
|
if task.recur ~= entry.rec then
|
||||||
|
task.recur = entry.rec
|
||||||
|
changed = true
|
||||||
|
end
|
||||||
|
if task.recur_mode ~= entry.rec_mode then
|
||||||
|
task.recur_mode = entry.rec_mode
|
||||||
|
changed = true
|
||||||
|
end
|
||||||
if entry.status and task.status ~= entry.status then
|
if entry.status and task.status ~= entry.status then
|
||||||
task.status = entry.status
|
task.status = entry.status
|
||||||
if entry.status == 'done' then
|
if entry.status == 'done' then
|
||||||
|
|
@ -135,6 +150,8 @@ function M.apply(lines)
|
||||||
category = entry.category,
|
category = entry.category,
|
||||||
priority = entry.priority,
|
priority = entry.priority,
|
||||||
due = entry.due,
|
due = entry.due,
|
||||||
|
recur = entry.rec,
|
||||||
|
recur_mode = entry.rec_mode,
|
||||||
order = order_counter,
|
order = order_counter,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
|
---@return nil
|
||||||
function M.check()
|
function M.check()
|
||||||
vim.health.start('pending.nvim')
|
vim.health.start('pending.nvim')
|
||||||
|
|
||||||
|
|
@ -27,6 +28,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
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,6 @@ local store = require('pending.store')
|
||||||
---@class pending.init
|
---@class pending.init
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
---@type pending.Task[][]
|
|
||||||
local _undo_states = {}
|
|
||||||
local UNDO_MAX = 20
|
local UNDO_MAX = 20
|
||||||
|
|
||||||
---@return integer bufnr
|
---@return integer bufnr
|
||||||
|
|
@ -19,6 +17,7 @@ function M.open()
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param bufnr integer
|
---@param bufnr integer
|
||||||
|
---@return nil
|
||||||
function M._setup_autocmds(bufnr)
|
function M._setup_autocmds(bufnr)
|
||||||
local group = vim.api.nvim_create_augroup('PendingBuffer', { clear = true })
|
local group = vim.api.nvim_create_augroup('PendingBuffer', { clear = true })
|
||||||
vim.api.nvim_create_autocmd('BufWriteCmd', {
|
vim.api.nvim_create_autocmd('BufWriteCmd', {
|
||||||
|
|
@ -49,63 +48,76 @@ function M._setup_autocmds(bufnr)
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param bufnr integer
|
---@param bufnr integer
|
||||||
|
---@return nil
|
||||||
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
|
||||||
|
---@return nil
|
||||||
function M._on_write(bufnr)
|
function M._on_write(bufnr)
|
||||||
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
|
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
|
||||||
local snapshot = store.snapshot()
|
local snapshot = store.snapshot()
|
||||||
table.insert(_undo_states, snapshot)
|
local stack = store.undo_stack()
|
||||||
if #_undo_states > UNDO_MAX then
|
table.insert(stack, snapshot)
|
||||||
table.remove(_undo_states, 1)
|
if #stack > UNDO_MAX then
|
||||||
|
table.remove(stack, 1)
|
||||||
end
|
end
|
||||||
diff.apply(lines)
|
diff.apply(lines)
|
||||||
buffer.render(bufnr)
|
buffer.render(bufnr)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
---@return nil
|
||||||
function M.undo_write()
|
function M.undo_write()
|
||||||
if #_undo_states == 0 then
|
local stack = store.undo_stack()
|
||||||
|
if #stack == 0 then
|
||||||
vim.notify('Nothing to undo.', vim.log.levels.WARN)
|
vim.notify('Nothing to undo.', vim.log.levels.WARN)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local state = table.remove(_undo_states)
|
local state = table.remove(stack)
|
||||||
store.replace_tasks(state)
|
store.replace_tasks(state)
|
||||||
store.save()
|
store.save()
|
||||||
buffer.render(buffer.bufnr())
|
buffer.render(buffer.bufnr())
|
||||||
end
|
end
|
||||||
|
|
||||||
|
---@return nil
|
||||||
function M.toggle_complete()
|
function M.toggle_complete()
|
||||||
local bufnr = buffer.bufnr()
|
local bufnr = buffer.bufnr()
|
||||||
if not bufnr then
|
if not bufnr then
|
||||||
|
|
@ -127,6 +139,19 @@ function M.toggle_complete()
|
||||||
if task.status == 'done' then
|
if task.status == 'done' then
|
||||||
store.update(id, { status = 'pending', ['end'] = vim.NIL })
|
store.update(id, { status = 'pending', ['end'] = vim.NIL })
|
||||||
else
|
else
|
||||||
|
if task.recur and task.due then
|
||||||
|
local recur = require('pending.recur')
|
||||||
|
local mode = task.recur_mode or 'scheduled'
|
||||||
|
local next_date = recur.next_due(task.due, task.recur, mode)
|
||||||
|
store.add({
|
||||||
|
description = task.description,
|
||||||
|
category = task.category,
|
||||||
|
priority = task.priority,
|
||||||
|
due = next_date,
|
||||||
|
recur = task.recur,
|
||||||
|
recur_mode = task.recur_mode,
|
||||||
|
})
|
||||||
|
end
|
||||||
store.update(id, { status = 'done' })
|
store.update(id, { status = 'done' })
|
||||||
end
|
end
|
||||||
store.save()
|
store.save()
|
||||||
|
|
@ -139,6 +164,7 @@ function M.toggle_complete()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
---@return nil
|
||||||
function M.toggle_priority()
|
function M.toggle_priority()
|
||||||
local bufnr = buffer.bufnr()
|
local bufnr = buffer.bufnr()
|
||||||
if not bufnr then
|
if not bufnr then
|
||||||
|
|
@ -169,6 +195,7 @@ function M.toggle_priority()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
---@return nil
|
||||||
function M.prompt_date()
|
function M.prompt_date()
|
||||||
local bufnr = buffer.bufnr()
|
local bufnr = buffer.bufnr()
|
||||||
if not bufnr then
|
if not bufnr then
|
||||||
|
|
@ -183,7 +210,7 @@ function M.prompt_date()
|
||||||
if not id then
|
if not id then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
vim.ui.input({ prompt = 'Due date (YYYY-MM-DD): ' }, function(input)
|
vim.ui.input({ prompt = 'Due date (today, +3d, fri@2pm, etc.): ' }, function(input)
|
||||||
if not input then
|
if not input then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
@ -192,8 +219,11 @@ function M.prompt_date()
|
||||||
local resolved = parse.resolve_date(due)
|
local resolved = parse.resolve_date(due)
|
||||||
if resolved then
|
if resolved then
|
||||||
due = resolved
|
due = resolved
|
||||||
elseif not due:match('^%d%d%d%d%-%d%d%-%d%d$') then
|
elseif
|
||||||
vim.notify('Invalid date format. Use YYYY-MM-DD.', vim.log.levels.ERROR)
|
not due:match('^%d%d%d%d%-%d%d%-%d%d$')
|
||||||
|
and not due:match('^%d%d%d%d%-%d%d%-%d%dT%d%d:%d%d$')
|
||||||
|
then
|
||||||
|
vim.notify('Invalid date format. Use YYYY-MM-DD or YYYY-MM-DDThh:mm.', vim.log.levels.ERROR)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -204,6 +234,7 @@ function M.prompt_date()
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param text string
|
---@param text string
|
||||||
|
---@return nil
|
||||||
function M.add(text)
|
function M.add(text)
|
||||||
if not text or text == '' then
|
if not text or text == '' then
|
||||||
vim.notify('Usage: :Pending add <description>', vim.log.levels.ERROR)
|
vim.notify('Usage: :Pending add <description>', vim.log.levels.ERROR)
|
||||||
|
|
@ -219,6 +250,8 @@ function M.add(text)
|
||||||
description = description,
|
description = description,
|
||||||
category = metadata.cat,
|
category = metadata.cat,
|
||||||
due = metadata.due,
|
due = metadata.due,
|
||||||
|
recur = metadata.rec,
|
||||||
|
recur_mode = metadata.rec_mode,
|
||||||
})
|
})
|
||||||
store.save()
|
store.save()
|
||||||
local bufnr = buffer.bufnr()
|
local bufnr = buffer.bufnr()
|
||||||
|
|
@ -228,6 +261,7 @@ function M.add(text)
|
||||||
vim.notify('Pending added: ' .. description)
|
vim.notify('Pending added: ' .. description)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
---@return nil
|
||||||
function M.sync()
|
function M.sync()
|
||||||
local ok, gcal = pcall(require, 'pending.sync.gcal')
|
local ok, gcal = pcall(require, 'pending.sync.gcal')
|
||||||
if not ok then
|
if not ok then
|
||||||
|
|
@ -238,6 +272,7 @@ function M.sync()
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param days? integer
|
---@param days? integer
|
||||||
|
---@return nil
|
||||||
function M.archive(days)
|
function M.archive(days)
|
||||||
days = days or 30
|
days = days or 30
|
||||||
local cutoff = os.time() - (days * 86400)
|
local cutoff = os.time() - (days * 86400)
|
||||||
|
|
@ -274,8 +309,46 @@ function M.archive(days)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function M.due()
|
---@param due string
|
||||||
|
---@return boolean
|
||||||
|
local function is_due_or_overdue(due)
|
||||||
|
local now = os.date('*t') --[[@as osdate]]
|
||||||
local today = os.date('%Y-%m-%d') --[[@as string]]
|
local today = os.date('%Y-%m-%d') --[[@as string]]
|
||||||
|
local date_part, time_part = due:match('^(.+)T(.+)$')
|
||||||
|
if not date_part then
|
||||||
|
return due <= today
|
||||||
|
end
|
||||||
|
if date_part < today then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
if date_part > today then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
local current_time = string.format('%02d:%02d', now.hour, now.min)
|
||||||
|
return time_part <= current_time
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param due string
|
||||||
|
---@return boolean
|
||||||
|
local function is_overdue(due)
|
||||||
|
local now = os.date('*t') --[[@as osdate]]
|
||||||
|
local today = os.date('%Y-%m-%d') --[[@as string]]
|
||||||
|
local date_part, time_part = due:match('^(.+)T(.+)$')
|
||||||
|
if not date_part then
|
||||||
|
return due < today
|
||||||
|
end
|
||||||
|
if date_part < today then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
if date_part > today then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
local current_time = string.format('%02d:%02d', now.hour, now.min)
|
||||||
|
return time_part < current_time
|
||||||
|
end
|
||||||
|
|
||||||
|
---@return nil
|
||||||
|
function M.due()
|
||||||
local bufnr = buffer.bufnr()
|
local bufnr = buffer.bufnr()
|
||||||
local is_valid = bufnr ~= nil and vim.api.nvim_buf_is_valid(bufnr)
|
local is_valid = bufnr ~= nil and vim.api.nvim_buf_is_valid(bufnr)
|
||||||
local meta = is_valid and buffer.meta() or nil
|
local meta = is_valid and buffer.meta() or nil
|
||||||
|
|
@ -283,9 +356,9 @@ function M.due()
|
||||||
|
|
||||||
if meta and bufnr then
|
if meta and bufnr then
|
||||||
for lnum, m in ipairs(meta) do
|
for lnum, m in ipairs(meta) do
|
||||||
if m.type == 'task' and m.raw_due and m.status ~= 'done' and m.raw_due <= today then
|
if m.type == 'task' and m.raw_due and m.status ~= 'done' and is_due_or_overdue(m.raw_due) then
|
||||||
local task = store.get(m.id or 0)
|
local task = store.get(m.id or 0)
|
||||||
local label = m.raw_due < today and '[OVERDUE] ' or '[DUE] '
|
local label = is_overdue(m.raw_due) and '[OVERDUE] ' or '[DUE] '
|
||||||
table.insert(qf_items, {
|
table.insert(qf_items, {
|
||||||
bufnr = bufnr,
|
bufnr = bufnr,
|
||||||
lnum = lnum,
|
lnum = lnum,
|
||||||
|
|
@ -297,8 +370,8 @@ function M.due()
|
||||||
else
|
else
|
||||||
store.load()
|
store.load()
|
||||||
for _, task in ipairs(store.active_tasks()) do
|
for _, task in ipairs(store.active_tasks()) do
|
||||||
if task.status == 'pending' and task.due and task.due <= today then
|
if task.status == 'pending' and task.due and is_due_or_overdue(task.due) then
|
||||||
local label = task.due < today and '[OVERDUE] ' or '[DUE] '
|
local label = is_overdue(task.due) and '[OVERDUE] ' or '[DUE] '
|
||||||
local text = label .. task.description
|
local text = label .. task.description
|
||||||
if task.category then
|
if task.category then
|
||||||
text = text .. ' [' .. task.category .. ']'
|
text = text .. ' [' .. task.category .. ']'
|
||||||
|
|
@ -317,68 +390,8 @@ 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 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',
|
|
||||||
'',
|
|
||||||
'Due date input:',
|
|
||||||
' today, tomorrow, +Nd, mon-sun',
|
|
||||||
' Empty input clears due date',
|
|
||||||
'',
|
|
||||||
'Highlights:',
|
|
||||||
' PendingOverdue overdue tasks (red)',
|
|
||||||
' PendingPriority [!] urgent tasks',
|
|
||||||
'',
|
|
||||||
'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
|
||||||
|
---@return nil
|
||||||
function M.command(args)
|
function M.command(args)
|
||||||
if not args or args == '' then
|
if not args or args == '' then
|
||||||
M.open()
|
M.open()
|
||||||
|
|
|
||||||
|
|
@ -24,11 +24,92 @@ local function is_valid_date(s)
|
||||||
return check.year == yn and check.month == mn and check.day == dn
|
return check.year == yn and check.month == mn and check.day == dn
|
||||||
end
|
end
|
||||||
|
|
||||||
|
---@param s string
|
||||||
|
---@return boolean
|
||||||
|
local function is_valid_time(s)
|
||||||
|
local h, m = s:match('^(%d%d):(%d%d)$')
|
||||||
|
if not h then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
local hn = tonumber(h) --[[@as integer]]
|
||||||
|
local mn = tonumber(m) --[[@as integer]]
|
||||||
|
return hn >= 0 and hn <= 23 and mn >= 0 and mn <= 59
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param s string
|
||||||
|
---@return string|nil
|
||||||
|
local function normalize_time(s)
|
||||||
|
local h, m, period
|
||||||
|
|
||||||
|
h, m, period = s:match('^(%d+):(%d%d)([ap]m)$')
|
||||||
|
if not h then
|
||||||
|
h, period = s:match('^(%d+)([ap]m)$')
|
||||||
|
if h then
|
||||||
|
m = '00'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if not h then
|
||||||
|
h, m = s:match('^(%d%d):(%d%d)$')
|
||||||
|
end
|
||||||
|
if not h then
|
||||||
|
h, m = s:match('^(%d):(%d%d)$')
|
||||||
|
end
|
||||||
|
if not h then
|
||||||
|
h = s:match('^(%d+)$')
|
||||||
|
if h then
|
||||||
|
m = '00'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if not h then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local hn = tonumber(h) --[[@as integer]]
|
||||||
|
local mn = tonumber(m) --[[@as integer]]
|
||||||
|
|
||||||
|
if period then
|
||||||
|
if hn < 1 or hn > 12 then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
if period == 'am' then
|
||||||
|
hn = hn == 12 and 0 or hn
|
||||||
|
else
|
||||||
|
hn = hn == 12 and 12 or hn + 12
|
||||||
|
end
|
||||||
|
else
|
||||||
|
if hn < 0 or hn > 23 then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if mn < 0 or mn > 59 then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
return string.format('%02d:%02d', hn, mn)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param s string
|
||||||
|
---@return boolean
|
||||||
|
local function is_valid_datetime(s)
|
||||||
|
local date_part, time_part = s:match('^(.+)T(.+)$')
|
||||||
|
if not date_part then
|
||||||
|
return is_valid_date(s)
|
||||||
|
end
|
||||||
|
return is_valid_date(date_part) and is_valid_time(time_part)
|
||||||
|
end
|
||||||
|
|
||||||
---@return string
|
---@return string
|
||||||
local function date_key()
|
local function date_key()
|
||||||
return config.get().date_syntax or 'due'
|
return config.get().date_syntax or 'due'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
---@return string
|
||||||
|
local function recur_key()
|
||||||
|
return config.get().recur_syntax or 'rec'
|
||||||
|
end
|
||||||
|
|
||||||
local weekday_map = {
|
local weekday_map = {
|
||||||
sun = 1,
|
sun = 1,
|
||||||
mon = 2,
|
mon = 2,
|
||||||
|
|
@ -39,26 +120,160 @@ local weekday_map = {
|
||||||
sat = 7,
|
sat = 7,
|
||||||
}
|
}
|
||||||
|
|
||||||
---@param text string
|
local month_map = {
|
||||||
---@return string|nil
|
jan = 1,
|
||||||
function M.resolve_date(text)
|
feb = 2,
|
||||||
local lower = text:lower()
|
mar = 3,
|
||||||
local today = os.date('*t') --[[@as osdate]]
|
apr = 4,
|
||||||
|
may = 5,
|
||||||
|
jun = 6,
|
||||||
|
jul = 7,
|
||||||
|
aug = 8,
|
||||||
|
sep = 9,
|
||||||
|
oct = 10,
|
||||||
|
nov = 11,
|
||||||
|
dec = 12,
|
||||||
|
}
|
||||||
|
|
||||||
if lower == 'today' then
|
---@param today osdate
|
||||||
|
---@return string
|
||||||
|
local function today_str(today)
|
||||||
return os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day })) --[[@as string]]
|
return os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day })) --[[@as string]]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
---@param date_part string
|
||||||
|
---@param time_suffix? string
|
||||||
|
---@return string
|
||||||
|
local function append_time(date_part, time_suffix)
|
||||||
|
if time_suffix then
|
||||||
|
return date_part .. 'T' .. time_suffix
|
||||||
|
end
|
||||||
|
return date_part
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param text string
|
||||||
|
---@return string|nil
|
||||||
|
function M.resolve_date(text)
|
||||||
|
local date_input, time_suffix = text:match('^(.+)@(.+)$')
|
||||||
|
if time_suffix then
|
||||||
|
time_suffix = normalize_time(time_suffix)
|
||||||
|
if not time_suffix then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
else
|
||||||
|
date_input = text
|
||||||
|
end
|
||||||
|
|
||||||
|
local dt = date_input:match('^(%d%d%d%d%-%d%d%-%d%dT%d%d:%d%d)$')
|
||||||
|
if dt then
|
||||||
|
local dp, tp = dt:match('^(.+)T(.+)$')
|
||||||
|
if is_valid_date(dp) and is_valid_time(tp) then
|
||||||
|
return dt
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
if is_valid_date(date_input) then
|
||||||
|
return append_time(date_input, time_suffix)
|
||||||
|
end
|
||||||
|
|
||||||
|
local lower = date_input:lower()
|
||||||
|
local today = os.date('*t') --[[@as osdate]]
|
||||||
|
|
||||||
|
if lower == 'today' or lower == 'eod' then
|
||||||
|
return append_time(today_str(today), time_suffix)
|
||||||
|
end
|
||||||
|
|
||||||
|
if lower == 'yesterday' then
|
||||||
|
return append_time(
|
||||||
|
os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day - 1 })) --[[@as string]],
|
||||||
|
time_suffix
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
if lower == 'tomorrow' then
|
if lower == 'tomorrow' then
|
||||||
return os.date(
|
return append_time(
|
||||||
|
os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day + 1 })) --[[@as string]],
|
||||||
|
time_suffix
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
if lower == 'sow' then
|
||||||
|
local delta = -((today.wday - 2) % 7)
|
||||||
|
return append_time(
|
||||||
|
os.date(
|
||||||
'%Y-%m-%d',
|
'%Y-%m-%d',
|
||||||
os.time({ year = today.year, month = today.month, day = today.day + 1 })
|
os.time({ year = today.year, month = today.month, day = today.day + delta })
|
||||||
) --[[@as string]]
|
) --[[@as string]],
|
||||||
|
time_suffix
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
if lower == 'eow' then
|
||||||
|
local delta = (1 - today.wday) % 7
|
||||||
|
return append_time(
|
||||||
|
os.date(
|
||||||
|
'%Y-%m-%d',
|
||||||
|
os.time({ year = today.year, month = today.month, day = today.day + delta })
|
||||||
|
) --[[@as string]],
|
||||||
|
time_suffix
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
if lower == 'som' then
|
||||||
|
return append_time(
|
||||||
|
os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = 1 })) --[[@as string]],
|
||||||
|
time_suffix
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
if lower == 'eom' then
|
||||||
|
return append_time(
|
||||||
|
os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month + 1, day = 0 })) --[[@as string]],
|
||||||
|
time_suffix
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
if lower == 'soq' then
|
||||||
|
local q = math.ceil(today.month / 3)
|
||||||
|
local first_month = (q - 1) * 3 + 1
|
||||||
|
return append_time(
|
||||||
|
os.date('%Y-%m-%d', os.time({ year = today.year, month = first_month, day = 1 })) --[[@as string]],
|
||||||
|
time_suffix
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
if lower == 'eoq' then
|
||||||
|
local q = math.ceil(today.month / 3)
|
||||||
|
local last_month = q * 3
|
||||||
|
return append_time(
|
||||||
|
os.date('%Y-%m-%d', os.time({ year = today.year, month = last_month + 1, day = 0 })) --[[@as string]],
|
||||||
|
time_suffix
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
if lower == 'soy' then
|
||||||
|
return append_time(
|
||||||
|
os.date('%Y-%m-%d', os.time({ year = today.year, month = 1, day = 1 })) --[[@as string]],
|
||||||
|
time_suffix
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
if lower == 'eoy' then
|
||||||
|
return append_time(
|
||||||
|
os.date('%Y-%m-%d', os.time({ year = today.year, month = 12, day = 31 })) --[[@as string]],
|
||||||
|
time_suffix
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
if lower == 'later' or lower == 'someday' then
|
||||||
|
return append_time(config.get().someday_date, time_suffix)
|
||||||
end
|
end
|
||||||
|
|
||||||
local n = lower:match('^%+(%d+)d$')
|
local n = lower:match('^%+(%d+)d$')
|
||||||
if n then
|
if n then
|
||||||
return os.date(
|
return append_time(
|
||||||
|
os.date(
|
||||||
'%Y-%m-%d',
|
'%Y-%m-%d',
|
||||||
os.time({
|
os.time({
|
||||||
year = today.year,
|
year = today.year,
|
||||||
|
|
@ -67,17 +282,133 @@ function M.resolve_date(text)
|
||||||
tonumber(n) --[[@as integer]]
|
tonumber(n) --[[@as integer]]
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
) --[[@as string]]
|
) --[[@as string]],
|
||||||
|
time_suffix
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
n = lower:match('^%+(%d+)w$')
|
||||||
|
if n then
|
||||||
|
return append_time(
|
||||||
|
os.date(
|
||||||
|
'%Y-%m-%d',
|
||||||
|
os.time({
|
||||||
|
year = today.year,
|
||||||
|
month = today.month,
|
||||||
|
day = today.day + (
|
||||||
|
tonumber(n) --[[@as integer]]
|
||||||
|
) * 7,
|
||||||
|
})
|
||||||
|
) --[[@as string]],
|
||||||
|
time_suffix
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
n = lower:match('^%+(%d+)m$')
|
||||||
|
if n then
|
||||||
|
return append_time(
|
||||||
|
os.date(
|
||||||
|
'%Y-%m-%d',
|
||||||
|
os.time({
|
||||||
|
year = today.year,
|
||||||
|
month = today.month + (
|
||||||
|
tonumber(n) --[[@as integer]]
|
||||||
|
),
|
||||||
|
day = today.day,
|
||||||
|
})
|
||||||
|
) --[[@as string]],
|
||||||
|
time_suffix
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
n = lower:match('^%-(%d+)d$')
|
||||||
|
if n then
|
||||||
|
return append_time(
|
||||||
|
os.date(
|
||||||
|
'%Y-%m-%d',
|
||||||
|
os.time({
|
||||||
|
year = today.year,
|
||||||
|
month = today.month,
|
||||||
|
day = today.day - (
|
||||||
|
tonumber(n) --[[@as integer]]
|
||||||
|
),
|
||||||
|
})
|
||||||
|
) --[[@as string]],
|
||||||
|
time_suffix
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
n = lower:match('^%-(%d+)w$')
|
||||||
|
if n then
|
||||||
|
return append_time(
|
||||||
|
os.date(
|
||||||
|
'%Y-%m-%d',
|
||||||
|
os.time({
|
||||||
|
year = today.year,
|
||||||
|
month = today.month,
|
||||||
|
day = today.day - (
|
||||||
|
tonumber(n) --[[@as integer]]
|
||||||
|
) * 7,
|
||||||
|
})
|
||||||
|
) --[[@as string]],
|
||||||
|
time_suffix
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
local ord = lower:match('^(%d+)[snrt][tdh]$')
|
||||||
|
if ord then
|
||||||
|
local day_num = tonumber(ord) --[[@as integer]]
|
||||||
|
if day_num >= 1 and day_num <= 31 then
|
||||||
|
local m, y = today.month, today.year
|
||||||
|
if today.day >= day_num then
|
||||||
|
m = m + 1
|
||||||
|
if m > 12 then
|
||||||
|
m = 1
|
||||||
|
y = y + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local t = os.time({ year = y, month = m, day = day_num })
|
||||||
|
local check = os.date('*t', t) --[[@as osdate]]
|
||||||
|
if check.day == day_num then
|
||||||
|
return append_time(os.date('%Y-%m-%d', t) --[[@as string]], time_suffix)
|
||||||
|
end
|
||||||
|
m = m + 1
|
||||||
|
if m > 12 then
|
||||||
|
m = 1
|
||||||
|
y = y + 1
|
||||||
|
end
|
||||||
|
t = os.time({ year = y, month = m, day = day_num })
|
||||||
|
check = os.date('*t', t) --[[@as osdate]]
|
||||||
|
if check.day == day_num then
|
||||||
|
return append_time(os.date('%Y-%m-%d', t) --[[@as string]], time_suffix)
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local target_month = month_map[lower]
|
||||||
|
if target_month then
|
||||||
|
local y = today.year
|
||||||
|
if today.month >= target_month then
|
||||||
|
y = y + 1
|
||||||
|
end
|
||||||
|
return append_time(
|
||||||
|
os.date('%Y-%m-%d', os.time({ year = y, month = target_month, day = 1 })) --[[@as string]],
|
||||||
|
time_suffix
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
local target_wday = weekday_map[lower]
|
local target_wday = weekday_map[lower]
|
||||||
if target_wday then
|
if target_wday then
|
||||||
local current_wday = today.wday
|
local current_wday = today.wday
|
||||||
local delta = (target_wday - current_wday) % 7
|
local delta = (target_wday - current_wday) % 7
|
||||||
return os.date(
|
return append_time(
|
||||||
|
os.date(
|
||||||
'%Y-%m-%d',
|
'%Y-%m-%d',
|
||||||
os.time({ year = today.year, month = today.month, day = today.day + delta })
|
os.time({ year = today.year, month = today.month, day = today.day + delta })
|
||||||
) --[[@as string]]
|
) --[[@as string]],
|
||||||
|
time_suffix
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -85,7 +416,7 @@ end
|
||||||
|
|
||||||
---@param text string
|
---@param text string
|
||||||
---@return string description
|
---@return string description
|
||||||
---@return { due?: string, cat?: string } metadata
|
---@return { due?: string, cat?: string, rec?: string, rec_mode?: 'scheduled'|'completion' } metadata
|
||||||
function M.body(text)
|
function M.body(text)
|
||||||
local tokens = {}
|
local tokens = {}
|
||||||
for token in text:gmatch('%S+') do
|
for token in text:gmatch('%S+') do
|
||||||
|
|
@ -95,8 +426,10 @@ function M.body(text)
|
||||||
local metadata = {}
|
local metadata = {}
|
||||||
local i = #tokens
|
local i = #tokens
|
||||||
local dk = date_key()
|
local dk = date_key()
|
||||||
local date_pattern_strict = '^' .. vim.pesc(dk) .. ':(%d%d%d%d%-%d%d%-%d%d)$'
|
local rk = recur_key()
|
||||||
|
local date_pattern_strict = '^' .. vim.pesc(dk) .. ':(%d%d%d%d%-%d%d%-%d%d[T%d:]*)$'
|
||||||
local date_pattern_any = '^' .. vim.pesc(dk) .. ':(.+)$'
|
local date_pattern_any = '^' .. vim.pesc(dk) .. ':(.+)$'
|
||||||
|
local rec_pattern = '^' .. vim.pesc(rk) .. ':(%S+)$'
|
||||||
|
|
||||||
while i >= 1 do
|
while i >= 1 do
|
||||||
local token = tokens[i]
|
local token = tokens[i]
|
||||||
|
|
@ -105,7 +438,7 @@ function M.body(text)
|
||||||
if metadata.due then
|
if metadata.due then
|
||||||
break
|
break
|
||||||
end
|
end
|
||||||
if not is_valid_date(due_val) then
|
if not is_valid_datetime(due_val) then
|
||||||
break
|
break
|
||||||
end
|
end
|
||||||
metadata.due = due_val
|
metadata.due = due_val
|
||||||
|
|
@ -131,8 +464,26 @@ function M.body(text)
|
||||||
metadata.cat = cat_val
|
metadata.cat = cat_val
|
||||||
i = i - 1
|
i = i - 1
|
||||||
else
|
else
|
||||||
|
local rec_val = token:match(rec_pattern)
|
||||||
|
if rec_val then
|
||||||
|
if metadata.rec then
|
||||||
break
|
break
|
||||||
end
|
end
|
||||||
|
local recur = require('pending.recur')
|
||||||
|
local raw_spec = rec_val
|
||||||
|
if raw_spec:sub(1, 1) == '!' then
|
||||||
|
metadata.rec_mode = 'completion'
|
||||||
|
raw_spec = raw_spec:sub(2)
|
||||||
|
end
|
||||||
|
if not recur.validate(raw_spec) then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
metadata.rec = raw_spec
|
||||||
|
i = i - 1
|
||||||
|
else
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -148,7 +499,7 @@ end
|
||||||
|
|
||||||
---@param text string
|
---@param text string
|
||||||
---@return string description
|
---@return string description
|
||||||
---@return { due?: string, cat?: string } metadata
|
---@return { due?: string, cat?: string, rec?: string, rec_mode?: 'scheduled'|'completion' } metadata
|
||||||
function M.command_add(text)
|
function M.command_add(text)
|
||||||
local cat_prefix = text:match('^(%S.-):%s')
|
local cat_prefix = text:match('^(%S.-):%s')
|
||||||
if cat_prefix then
|
if cat_prefix then
|
||||||
|
|
|
||||||
188
lua/pending/recur.lua
Normal file
188
lua/pending/recur.lua
Normal file
|
|
@ -0,0 +1,188 @@
|
||||||
|
---@class pending.RecurSpec
|
||||||
|
---@field freq 'daily'|'weekly'|'monthly'|'yearly'
|
||||||
|
---@field interval integer
|
||||||
|
---@field byday? string[]
|
||||||
|
---@field from_completion boolean
|
||||||
|
---@field _raw? string
|
||||||
|
|
||||||
|
---@class pending.recur
|
||||||
|
local M = {}
|
||||||
|
|
||||||
|
---@type table<string, pending.RecurSpec>
|
||||||
|
local named = {
|
||||||
|
daily = { freq = 'daily', interval = 1, 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 },
|
||||||
|
biweekly = { freq = 'weekly', interval = 2, from_completion = false },
|
||||||
|
monthly = { freq = 'monthly', interval = 1, from_completion = false },
|
||||||
|
quarterly = { freq = 'monthly', interval = 3, from_completion = false },
|
||||||
|
yearly = { freq = 'yearly', interval = 1, from_completion = false },
|
||||||
|
annual = { freq = 'yearly', interval = 1, from_completion = false },
|
||||||
|
}
|
||||||
|
|
||||||
|
---@param spec string
|
||||||
|
---@return pending.RecurSpec?
|
||||||
|
function M.parse(spec)
|
||||||
|
local from_completion = false
|
||||||
|
local s = spec
|
||||||
|
|
||||||
|
if s:sub(1, 1) == '!' then
|
||||||
|
from_completion = true
|
||||||
|
s = s:sub(2)
|
||||||
|
end
|
||||||
|
|
||||||
|
local lower = s:lower()
|
||||||
|
|
||||||
|
local base = named[lower]
|
||||||
|
if base then
|
||||||
|
return {
|
||||||
|
freq = base.freq,
|
||||||
|
interval = base.interval,
|
||||||
|
byday = base.byday,
|
||||||
|
from_completion = from_completion,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local n, unit = lower:match('^(%d+)([dwmy])$')
|
||||||
|
if n then
|
||||||
|
local num = tonumber(n) --[[@as integer]]
|
||||||
|
if num < 1 then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local freq_map = { d = 'daily', w = 'weekly', m = 'monthly', y = 'yearly' }
|
||||||
|
return {
|
||||||
|
freq = freq_map[unit],
|
||||||
|
interval = num,
|
||||||
|
from_completion = from_completion,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
if s:match('^FREQ=') then
|
||||||
|
return {
|
||||||
|
freq = 'daily',
|
||||||
|
interval = 1,
|
||||||
|
from_completion = from_completion,
|
||||||
|
_raw = s,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param spec string
|
||||||
|
---@return boolean
|
||||||
|
function M.validate(spec)
|
||||||
|
return M.parse(spec) ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param due string
|
||||||
|
---@return string date_part
|
||||||
|
---@return string? time_part
|
||||||
|
local function split_datetime(due)
|
||||||
|
local dp, tp = due:match('^(.+)T(.+)$')
|
||||||
|
if dp then
|
||||||
|
return dp, tp
|
||||||
|
end
|
||||||
|
return due, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param base_date string
|
||||||
|
---@param freq string
|
||||||
|
---@param interval integer
|
||||||
|
---@return string
|
||||||
|
local function advance_date(base_date, freq, interval)
|
||||||
|
local date_part, time_part = split_datetime(base_date)
|
||||||
|
local y, m, d = date_part:match('^(%d+)-(%d+)-(%d+)$')
|
||||||
|
local yn = tonumber(y) --[[@as integer]]
|
||||||
|
local mn = tonumber(m) --[[@as integer]]
|
||||||
|
local dn = tonumber(d) --[[@as integer]]
|
||||||
|
|
||||||
|
local result
|
||||||
|
if freq == 'daily' then
|
||||||
|
result = os.date('%Y-%m-%d', os.time({ year = yn, month = mn, day = dn + interval })) --[[@as string]]
|
||||||
|
elseif freq == 'weekly' then
|
||||||
|
result = os.date('%Y-%m-%d', os.time({ year = yn, month = mn, day = dn + interval * 7 })) --[[@as string]]
|
||||||
|
elseif freq == 'monthly' then
|
||||||
|
local new_m = mn + interval
|
||||||
|
local new_y = yn
|
||||||
|
while new_m > 12 do
|
||||||
|
new_m = new_m - 12
|
||||||
|
new_y = new_y + 1
|
||||||
|
end
|
||||||
|
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]])
|
||||||
|
result = os.date('%Y-%m-%d', os.time({ year = new_y, month = new_m, day = clamped_d })) --[[@as string]]
|
||||||
|
elseif freq == 'yearly' then
|
||||||
|
local new_y = yn + interval
|
||||||
|
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]])
|
||||||
|
result = os.date('%Y-%m-%d', os.time({ year = new_y, month = mn, day = clamped_d })) --[[@as string]]
|
||||||
|
else
|
||||||
|
return base_date
|
||||||
|
end
|
||||||
|
|
||||||
|
if time_part then
|
||||||
|
return result .. 'T' .. time_part
|
||||||
|
end
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param base_date string
|
||||||
|
---@param spec string
|
||||||
|
---@param mode 'scheduled'|'completion'
|
||||||
|
---@return string
|
||||||
|
function M.next_due(base_date, spec, mode)
|
||||||
|
local parsed = M.parse(spec)
|
||||||
|
if not parsed then
|
||||||
|
return base_date
|
||||||
|
end
|
||||||
|
|
||||||
|
local today = os.date('%Y-%m-%d') --[[@as string]]
|
||||||
|
local _, time_part = split_datetime(base_date)
|
||||||
|
|
||||||
|
if mode == 'completion' then
|
||||||
|
local base = time_part and (today .. 'T' .. time_part) or today
|
||||||
|
return advance_date(base, parsed.freq, parsed.interval)
|
||||||
|
end
|
||||||
|
|
||||||
|
local next_date = advance_date(base_date, parsed.freq, parsed.interval)
|
||||||
|
local compare_today = time_part and (today .. 'T' .. time_part) or today
|
||||||
|
while next_date <= compare_today do
|
||||||
|
next_date = advance_date(next_date, parsed.freq, parsed.interval)
|
||||||
|
end
|
||||||
|
return next_date
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param spec string
|
||||||
|
---@return string
|
||||||
|
function M.to_rrule(spec)
|
||||||
|
local parsed = M.parse(spec)
|
||||||
|
if not parsed then
|
||||||
|
return ''
|
||||||
|
end
|
||||||
|
|
||||||
|
if parsed._raw then
|
||||||
|
return 'RRULE:' .. parsed._raw
|
||||||
|
end
|
||||||
|
|
||||||
|
local parts = { 'FREQ=' .. parsed.freq:upper() }
|
||||||
|
if parsed.interval > 1 then
|
||||||
|
table.insert(parts, 'INTERVAL=' .. parsed.interval)
|
||||||
|
end
|
||||||
|
if parsed.byday then
|
||||||
|
table.insert(parts, 'BYDAY=' .. table.concat(parsed.byday, ','))
|
||||||
|
end
|
||||||
|
return 'RRULE:' .. table.concat(parts, ';')
|
||||||
|
end
|
||||||
|
|
||||||
|
---@return string[]
|
||||||
|
function M.shorthand_list()
|
||||||
|
return { 'daily', 'weekdays', 'weekly', 'biweekly', 'monthly', 'quarterly', 'yearly', 'annual' }
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
|
|
@ -7,6 +7,8 @@ local config = require('pending.config')
|
||||||
---@field category? string
|
---@field category? string
|
||||||
---@field priority integer
|
---@field priority integer
|
||||||
---@field due? string
|
---@field due? string
|
||||||
|
---@field recur? string
|
||||||
|
---@field recur_mode? 'scheduled'|'completion'
|
||||||
---@field entry string
|
---@field entry string
|
||||||
---@field modified string
|
---@field modified string
|
||||||
---@field end? string
|
---@field end? string
|
||||||
|
|
@ -17,6 +19,7 @@ local config = require('pending.config')
|
||||||
---@field version integer
|
---@field version integer
|
||||||
---@field next_id integer
|
---@field next_id integer
|
||||||
---@field tasks pending.Task[]
|
---@field tasks pending.Task[]
|
||||||
|
---@field undo pending.Task[][]
|
||||||
|
|
||||||
---@class pending.store
|
---@class pending.store
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
@ -32,6 +35,7 @@ local function empty_data()
|
||||||
version = SUPPORTED_VERSION,
|
version = SUPPORTED_VERSION,
|
||||||
next_id = 1,
|
next_id = 1,
|
||||||
tasks = {},
|
tasks = {},
|
||||||
|
undo = {},
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -56,6 +60,8 @@ local known_fields = {
|
||||||
category = true,
|
category = true,
|
||||||
priority = true,
|
priority = true,
|
||||||
due = true,
|
due = true,
|
||||||
|
recur = true,
|
||||||
|
recur_mode = true,
|
||||||
entry = true,
|
entry = true,
|
||||||
modified = true,
|
modified = true,
|
||||||
['end'] = true,
|
['end'] = true,
|
||||||
|
|
@ -81,6 +87,12 @@ local function task_to_table(task)
|
||||||
if task.due then
|
if task.due then
|
||||||
t.due = task.due
|
t.due = task.due
|
||||||
end
|
end
|
||||||
|
if task.recur then
|
||||||
|
t.recur = task.recur
|
||||||
|
end
|
||||||
|
if task.recur_mode then
|
||||||
|
t.recur_mode = task.recur_mode
|
||||||
|
end
|
||||||
if task['end'] then
|
if task['end'] then
|
||||||
t['end'] = task['end']
|
t['end'] = task['end']
|
||||||
end
|
end
|
||||||
|
|
@ -105,6 +117,8 @@ local function table_to_task(t)
|
||||||
category = t.category,
|
category = t.category,
|
||||||
priority = t.priority or 0,
|
priority = t.priority or 0,
|
||||||
due = t.due,
|
due = t.due,
|
||||||
|
recur = t.recur,
|
||||||
|
recur_mode = t.recur_mode,
|
||||||
entry = t.entry,
|
entry = t.entry,
|
||||||
modified = t.modified,
|
modified = t.modified,
|
||||||
['end'] = t['end'],
|
['end'] = t['end'],
|
||||||
|
|
@ -153,13 +167,24 @@ function M.load()
|
||||||
version = decoded.version or SUPPORTED_VERSION,
|
version = decoded.version or SUPPORTED_VERSION,
|
||||||
next_id = decoded.next_id or 1,
|
next_id = decoded.next_id or 1,
|
||||||
tasks = {},
|
tasks = {},
|
||||||
|
undo = {},
|
||||||
}
|
}
|
||||||
for _, t in ipairs(decoded.tasks or {}) do
|
for _, t in ipairs(decoded.tasks or {}) do
|
||||||
table.insert(_data.tasks, table_to_task(t))
|
table.insert(_data.tasks, table_to_task(t))
|
||||||
end
|
end
|
||||||
|
for _, snapshot in ipairs(decoded.undo or {}) do
|
||||||
|
if type(snapshot) == 'table' then
|
||||||
|
local tasks = {}
|
||||||
|
for _, raw in ipairs(snapshot) do
|
||||||
|
table.insert(tasks, table_to_task(raw))
|
||||||
|
end
|
||||||
|
table.insert(_data.undo, tasks)
|
||||||
|
end
|
||||||
|
end
|
||||||
return _data
|
return _data
|
||||||
end
|
end
|
||||||
|
|
||||||
|
---@return nil
|
||||||
function M.save()
|
function M.save()
|
||||||
if not _data then
|
if not _data then
|
||||||
return
|
return
|
||||||
|
|
@ -170,10 +195,18 @@ function M.save()
|
||||||
version = _data.version,
|
version = _data.version,
|
||||||
next_id = _data.next_id,
|
next_id = _data.next_id,
|
||||||
tasks = {},
|
tasks = {},
|
||||||
|
undo = {},
|
||||||
}
|
}
|
||||||
for _, task in ipairs(_data.tasks) do
|
for _, task in ipairs(_data.tasks) do
|
||||||
table.insert(out.tasks, task_to_table(task))
|
table.insert(out.tasks, task_to_table(task))
|
||||||
end
|
end
|
||||||
|
for _, snapshot in ipairs(_data.undo) do
|
||||||
|
local serialized = {}
|
||||||
|
for _, task in ipairs(snapshot) do
|
||||||
|
table.insert(serialized, task_to_table(task))
|
||||||
|
end
|
||||||
|
table.insert(out.undo, serialized)
|
||||||
|
end
|
||||||
local encoded = vim.json.encode(out)
|
local encoded = vim.json.encode(out)
|
||||||
local tmp = path .. '.tmp'
|
local tmp = path .. '.tmp'
|
||||||
local f = io.open(tmp, 'w')
|
local f = io.open(tmp, 'w')
|
||||||
|
|
@ -224,7 +257,7 @@ function M.get(id)
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param fields { description: string, status?: string, category?: string, priority?: integer, due?: string, order?: integer, _extra?: table }
|
---@param fields { description: string, status?: string, category?: string, priority?: integer, due?: string, recur?: string, recur_mode?: string, order?: integer, _extra?: table }
|
||||||
---@return pending.Task
|
---@return pending.Task
|
||||||
function M.add(fields)
|
function M.add(fields)
|
||||||
local data = M.data()
|
local data = M.data()
|
||||||
|
|
@ -236,6 +269,8 @@ function M.add(fields)
|
||||||
category = fields.category or config.get().default_category,
|
category = fields.category or config.get().default_category,
|
||||||
priority = fields.priority or 0,
|
priority = fields.priority or 0,
|
||||||
due = fields.due,
|
due = fields.due,
|
||||||
|
recur = fields.recur,
|
||||||
|
recur_mode = fields.recur_mode,
|
||||||
entry = now,
|
entry = now,
|
||||||
modified = now,
|
modified = now,
|
||||||
['end'] = nil,
|
['end'] = nil,
|
||||||
|
|
@ -286,6 +321,7 @@ function M.find_index(id)
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param tasks pending.Task[]
|
---@param tasks pending.Task[]
|
||||||
|
---@return nil
|
||||||
function M.replace_tasks(tasks)
|
function M.replace_tasks(tasks)
|
||||||
M.data().tasks = tasks
|
M.data().tasks = tasks
|
||||||
end
|
end
|
||||||
|
|
@ -311,11 +347,24 @@ function M.snapshot()
|
||||||
return result
|
return result
|
||||||
end
|
end
|
||||||
|
|
||||||
|
---@return pending.Task[][]
|
||||||
|
function M.undo_stack()
|
||||||
|
return M.data().undo
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param stack pending.Task[][]
|
||||||
|
---@return nil
|
||||||
|
function M.set_undo_stack(stack)
|
||||||
|
M.data().undo = stack
|
||||||
|
end
|
||||||
|
|
||||||
---@param id integer
|
---@param id integer
|
||||||
|
---@return nil
|
||||||
function M.set_next_id(id)
|
function M.set_next_id(id)
|
||||||
M.data().next_id = id
|
M.data().next_id = id
|
||||||
end
|
end
|
||||||
|
|
||||||
|
---@return nil
|
||||||
function M.unload()
|
function M.unload()
|
||||||
_data = nil
|
_data = nil
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -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 = {}
|
||||||
|
|
@ -20,7 +21,10 @@ local function format_due(due)
|
||||||
if not due then
|
if not due then
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
local y, m, d = due:match('^(%d%d%d%d)-(%d%d)-(%d%d)$')
|
local y, m, d, hh, mm = due:match('^(%d%d%d%d)-(%d%d)-(%d%d)T(%d%d):(%d%d)$')
|
||||||
|
if not y then
|
||||||
|
y, m, d = due:match('^(%d%d%d%d)-(%d%d)-(%d%d)$')
|
||||||
|
end
|
||||||
if not y then
|
if not y then
|
||||||
return due
|
return due
|
||||||
end
|
end
|
||||||
|
|
@ -29,7 +33,30 @@ local function format_due(due)
|
||||||
month = tonumber(m) --[[@as integer]],
|
month = tonumber(m) --[[@as integer]],
|
||||||
day = tonumber(d) --[[@as integer]],
|
day = tonumber(d) --[[@as integer]],
|
||||||
})
|
})
|
||||||
return os.date(config.get().date_format, t) --[[@as string]]
|
local formatted = os.date(config.get().date_format, t) --[[@as string]]
|
||||||
|
if hh then
|
||||||
|
formatted = formatted .. ' ' .. hh .. ':' .. mm
|
||||||
|
end
|
||||||
|
return formatted
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param due string
|
||||||
|
---@return boolean
|
||||||
|
local function is_overdue(due)
|
||||||
|
local now = os.date('*t') --[[@as osdate]]
|
||||||
|
local today = os.date('%Y-%m-%d') --[[@as string]]
|
||||||
|
local date_part, time_part = due:match('^(.+)T(.+)$')
|
||||||
|
if not date_part then
|
||||||
|
return due < today
|
||||||
|
end
|
||||||
|
if date_part < today then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
if date_part > today then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
local current_time = string.format('%02d:%02d', now.hour, now.min)
|
||||||
|
return time_part < current_time
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param tasks pending.Task[]
|
---@param tasks pending.Task[]
|
||||||
|
|
@ -73,7 +100,6 @@ end
|
||||||
---@return string[] lines
|
---@return string[] lines
|
||||||
---@return pending.LineMeta[] meta
|
---@return pending.LineMeta[] meta
|
||||||
function M.category_view(tasks)
|
function M.category_view(tasks)
|
||||||
local today = os.date('%Y-%m-%d') --[[@as string]]
|
|
||||||
local by_cat = {}
|
local by_cat = {}
|
||||||
local cat_order = {}
|
local cat_order = {}
|
||||||
local cat_seen = {}
|
local cat_seen = {}
|
||||||
|
|
@ -148,7 +174,8 @@ function M.category_view(tasks)
|
||||||
raw_due = task.due,
|
raw_due = task.due,
|
||||||
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 is_overdue(task.due) or nil,
|
||||||
|
recur = task.recur,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -160,7 +187,6 @@ end
|
||||||
---@return string[] lines
|
---@return string[] lines
|
||||||
---@return pending.LineMeta[] meta
|
---@return pending.LineMeta[] meta
|
||||||
function M.priority_view(tasks)
|
function M.priority_view(tasks)
|
||||||
local today = os.date('%Y-%m-%d') --[[@as string]]
|
|
||||||
local pending = {}
|
local pending = {}
|
||||||
local done = {}
|
local done = {}
|
||||||
|
|
||||||
|
|
@ -198,8 +224,9 @@ function M.priority_view(tasks)
|
||||||
raw_due = task.due,
|
raw_due = task.due,
|
||||||
status = task.status,
|
status = task.status,
|
||||||
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 is_overdue(task.due) or nil,
|
||||||
show_category = true,
|
show_category = true,
|
||||||
|
recur = task.recur,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ vim.g.loaded_pending = true
|
||||||
vim.api.nvim_create_user_command('Pending', function(opts)
|
vim.api.nvim_create_user_command('Pending', function(opts)
|
||||||
require('pending').command(opts.args)
|
require('pending').command(opts.args)
|
||||||
end, {
|
end, {
|
||||||
|
bar = true,
|
||||||
nargs = '*',
|
nargs = '*',
|
||||||
complete = function(arg_lead, cmd_line)
|
complete = function(arg_lead, cmd_line)
|
||||||
local subcmds = { 'add', 'sync', 'archive', 'due', 'undo' }
|
local subcmds = { 'add', 'sync', 'archive', 'due', 'undo' }
|
||||||
|
|
@ -22,6 +23,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 +42,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)
|
||||||
|
|
@ -69,6 +69,25 @@ describe('diff', function()
|
||||||
assert.are.equal('Work', result[2].category)
|
assert.are.equal('Work', result[2].category)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
it('extracts rec: token from buffer line', function()
|
||||||
|
local lines = {
|
||||||
|
'## Inbox',
|
||||||
|
'/1/- [ ] Take trash out rec:weekly',
|
||||||
|
}
|
||||||
|
local result = diff.parse_buffer(lines)
|
||||||
|
assert.are.equal('weekly', result[2].rec)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('extracts rec: with completion mode', function()
|
||||||
|
local lines = {
|
||||||
|
'## Inbox',
|
||||||
|
'/1/- [ ] Water plants rec:!daily',
|
||||||
|
}
|
||||||
|
local result = diff.parse_buffer(lines)
|
||||||
|
assert.are.equal('daily', result[2].rec)
|
||||||
|
assert.are.equal('completion', result[2].rec_mode)
|
||||||
|
end)
|
||||||
|
|
||||||
it('inline due: token is parsed', function()
|
it('inline due: token is parsed', function()
|
||||||
local lines = {
|
local lines = {
|
||||||
'## Inbox',
|
'## Inbox',
|
||||||
|
|
@ -206,6 +225,60 @@ describe('diff', function()
|
||||||
assert.is_nil(task.due)
|
assert.is_nil(task.due)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
it('stores recur field on new tasks from buffer', function()
|
||||||
|
local lines = {
|
||||||
|
'## Inbox',
|
||||||
|
'- [ ] Take out trash rec:weekly',
|
||||||
|
}
|
||||||
|
diff.apply(lines)
|
||||||
|
store.unload()
|
||||||
|
store.load()
|
||||||
|
local tasks = store.active_tasks()
|
||||||
|
assert.are.equal(1, #tasks)
|
||||||
|
assert.are.equal('weekly', tasks[1].recur)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('updates recur field when changed inline', function()
|
||||||
|
store.add({ description = 'Task', recur = 'daily' })
|
||||||
|
store.save()
|
||||||
|
local lines = {
|
||||||
|
'## Todo',
|
||||||
|
'/1/- [ ] Task rec:weekly',
|
||||||
|
}
|
||||||
|
diff.apply(lines)
|
||||||
|
store.unload()
|
||||||
|
store.load()
|
||||||
|
local task = store.get(1)
|
||||||
|
assert.are.equal('weekly', task.recur)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('clears recur when token removed from line', function()
|
||||||
|
store.add({ description = 'Task', recur = 'daily' })
|
||||||
|
store.save()
|
||||||
|
local lines = {
|
||||||
|
'## Todo',
|
||||||
|
'/1/- [ ] Task',
|
||||||
|
}
|
||||||
|
diff.apply(lines)
|
||||||
|
store.unload()
|
||||||
|
store.load()
|
||||||
|
local task = store.get(1)
|
||||||
|
assert.is_nil(task.recur)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('parses rec: with completion mode prefix', function()
|
||||||
|
local lines = {
|
||||||
|
'## Inbox',
|
||||||
|
'- [ ] Water plants rec:!weekly',
|
||||||
|
}
|
||||||
|
diff.apply(lines)
|
||||||
|
store.unload()
|
||||||
|
store.load()
|
||||||
|
local tasks = store.active_tasks()
|
||||||
|
assert.are.equal('weekly', tasks[1].recur)
|
||||||
|
assert.are.equal('completion', tasks[1].recur_mode)
|
||||||
|
end)
|
||||||
|
|
||||||
it('clears priority when [N] is removed from buffer line', function()
|
it('clears priority when [N] is removed from buffer line', function()
|
||||||
store.add({ description = 'Task name', priority = 1 })
|
store.add({ description = 'Task name', priority = 1 })
|
||||||
store.save()
|
store.save()
|
||||||
|
|
|
||||||
|
|
@ -154,6 +154,242 @@ describe('parse', function()
|
||||||
local result = parse.resolve_date('')
|
local result = parse.resolve_date('')
|
||||||
assert.is_nil(result)
|
assert.is_nil(result)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
it("returns yesterday's date for 'yesterday'", function()
|
||||||
|
local expected = os.date('%Y-%m-%d', os.time() - 86400)
|
||||||
|
local result = parse.resolve_date('yesterday')
|
||||||
|
assert.are.equal(expected, result)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it("returns today's date for 'eod'", function()
|
||||||
|
local result = parse.resolve_date('eod')
|
||||||
|
assert.are.equal(os.date('%Y-%m-%d'), result)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('returns Monday of current week for sow', function()
|
||||||
|
local result = parse.resolve_date('sow')
|
||||||
|
assert.is_not_nil(result)
|
||||||
|
local y, m, d = result:match('^(%d+)-(%d+)-(%d+)$')
|
||||||
|
local t = os.time({ year = tonumber(y), month = tonumber(m), day = tonumber(d) })
|
||||||
|
local wday = os.date('*t', t).wday
|
||||||
|
assert.are.equal(2, wday)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('returns Sunday of current week for eow', function()
|
||||||
|
local result = parse.resolve_date('eow')
|
||||||
|
assert.is_not_nil(result)
|
||||||
|
local y, m, d = result:match('^(%d+)-(%d+)-(%d+)$')
|
||||||
|
local t = os.time({ year = tonumber(y), month = tonumber(m), day = tonumber(d) })
|
||||||
|
local wday = os.date('*t', t).wday
|
||||||
|
assert.are.equal(1, wday)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('returns first day of current month for som', function()
|
||||||
|
local today = os.date('*t') --[[@as osdate]]
|
||||||
|
local expected = string.format('%04d-%02d-01', today.year, today.month)
|
||||||
|
local result = parse.resolve_date('som')
|
||||||
|
assert.are.equal(expected, result)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('returns last day of current month for eom', function()
|
||||||
|
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 result = parse.resolve_date('eom')
|
||||||
|
assert.are.equal(expected, result)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('returns first day of current quarter for soq', function()
|
||||||
|
local today = os.date('*t') --[[@as osdate]]
|
||||||
|
local q = math.ceil(today.month / 3)
|
||||||
|
local first_month = (q - 1) * 3 + 1
|
||||||
|
local expected = string.format('%04d-%02d-01', today.year, first_month)
|
||||||
|
local result = parse.resolve_date('soq')
|
||||||
|
assert.are.equal(expected, result)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('returns last day of current quarter for eoq', function()
|
||||||
|
local today = os.date('*t') --[[@as osdate]]
|
||||||
|
local q = math.ceil(today.month / 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 result = parse.resolve_date('eoq')
|
||||||
|
assert.are.equal(expected, result)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('returns Jan 1 of current year for soy', function()
|
||||||
|
local today = os.date('*t') --[[@as osdate]]
|
||||||
|
local expected = string.format('%04d-01-01', today.year)
|
||||||
|
local result = parse.resolve_date('soy')
|
||||||
|
assert.are.equal(expected, result)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('returns Dec 31 of current year for eoy', function()
|
||||||
|
local today = os.date('*t') --[[@as osdate]]
|
||||||
|
local expected = string.format('%04d-12-31', today.year)
|
||||||
|
local result = parse.resolve_date('eoy')
|
||||||
|
assert.are.equal(expected, result)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('resolves +2w to 14 days from today', function()
|
||||||
|
local today = os.date('*t') --[[@as osdate]]
|
||||||
|
local expected = os.date(
|
||||||
|
'%Y-%m-%d',
|
||||||
|
os.time({ year = today.year, month = today.month, day = today.day + 14 })
|
||||||
|
)
|
||||||
|
local result = parse.resolve_date('+2w')
|
||||||
|
assert.are.equal(expected, result)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('resolves +3m to 3 months from today', function()
|
||||||
|
local today = os.date('*t') --[[@as osdate]]
|
||||||
|
local expected = os.date(
|
||||||
|
'%Y-%m-%d',
|
||||||
|
os.time({ year = today.year, month = today.month + 3, day = today.day })
|
||||||
|
)
|
||||||
|
local result = parse.resolve_date('+3m')
|
||||||
|
assert.are.equal(expected, result)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('resolves -2d to 2 days ago', function()
|
||||||
|
local today = os.date('*t') --[[@as osdate]]
|
||||||
|
local expected = os.date(
|
||||||
|
'%Y-%m-%d',
|
||||||
|
os.time({ year = today.year, month = today.month, day = today.day - 2 })
|
||||||
|
)
|
||||||
|
local result = parse.resolve_date('-2d')
|
||||||
|
assert.are.equal(expected, result)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('resolves -1w to 7 days ago', function()
|
||||||
|
local today = os.date('*t') --[[@as osdate]]
|
||||||
|
local expected = os.date(
|
||||||
|
'%Y-%m-%d',
|
||||||
|
os.time({ year = today.year, month = today.month, day = today.day - 7 })
|
||||||
|
)
|
||||||
|
local result = parse.resolve_date('-1w')
|
||||||
|
assert.are.equal(expected, result)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it("resolves 'later' to someday_date", function()
|
||||||
|
local result = parse.resolve_date('later')
|
||||||
|
assert.are.equal('9999-12-30', result)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it("resolves 'someday' to someday_date", function()
|
||||||
|
local result = parse.resolve_date('someday')
|
||||||
|
assert.are.equal('9999-12-30', result)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('resolves 15th to next 15th of month', function()
|
||||||
|
local result = parse.resolve_date('15th')
|
||||||
|
assert.is_not_nil(result)
|
||||||
|
local _, _, d = result:match('^(%d+)-(%d+)-(%d+)$')
|
||||||
|
assert.are.equal('15', d)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('resolves 1st to next 1st of month', function()
|
||||||
|
local result = parse.resolve_date('1st')
|
||||||
|
assert.is_not_nil(result)
|
||||||
|
local _, _, d = result:match('^(%d+)-(%d+)-(%d+)$')
|
||||||
|
assert.are.equal('01', d)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('resolves jan to next January 1st', function()
|
||||||
|
local today = os.date('*t') --[[@as osdate]]
|
||||||
|
local result = parse.resolve_date('jan')
|
||||||
|
assert.is_not_nil(result)
|
||||||
|
local y, m, d = result:match('^(%d+)-(%d+)-(%d+)$')
|
||||||
|
assert.are.equal('01', m)
|
||||||
|
assert.are.equal('01', d)
|
||||||
|
if today.month >= 1 then
|
||||||
|
assert.are.equal(tostring(today.year + 1), y)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('resolves dec to next December 1st', function()
|
||||||
|
local today = os.date('*t') --[[@as osdate]]
|
||||||
|
local result = parse.resolve_date('dec')
|
||||||
|
assert.is_not_nil(result)
|
||||||
|
local y, m, d = result:match('^(%d+)-(%d+)-(%d+)$')
|
||||||
|
assert.are.equal('12', m)
|
||||||
|
assert.are.equal('01', d)
|
||||||
|
if today.month >= 12 then
|
||||||
|
assert.are.equal(tostring(today.year + 1), y)
|
||||||
|
else
|
||||||
|
assert.are.equal(tostring(today.year), y)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
describe('resolve_date with time suffix', function()
|
||||||
|
local today = os.date('*t') --[[@as osdate]]
|
||||||
|
local tomorrow_str = os.date(
|
||||||
|
'%Y-%m-%d',
|
||||||
|
os.time({ year = today.year, month = today.month, day = today.day + 1 })
|
||||||
|
) --[[@as string]]
|
||||||
|
|
||||||
|
it('resolves bare hour to T09:00', function()
|
||||||
|
local result = parse.resolve_date('tomorrow@9')
|
||||||
|
assert.are.equal(tomorrow_str .. 'T09:00', result)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('resolves bare military hour to T14:00', function()
|
||||||
|
local result = parse.resolve_date('tomorrow@14')
|
||||||
|
assert.are.equal(tomorrow_str .. 'T14:00', result)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('resolves H:MM to T09:30', function()
|
||||||
|
local result = parse.resolve_date('tomorrow@9:30')
|
||||||
|
assert.are.equal(tomorrow_str .. 'T09:30', result)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('resolves HH:MM (existing format) to T09:30', function()
|
||||||
|
local result = parse.resolve_date('tomorrow@09:30')
|
||||||
|
assert.are.equal(tomorrow_str .. 'T09:30', result)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('resolves 2pm to T14:00', function()
|
||||||
|
local result = parse.resolve_date('tomorrow@2pm')
|
||||||
|
assert.are.equal(tomorrow_str .. 'T14:00', result)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('resolves 9am to T09:00', function()
|
||||||
|
local result = parse.resolve_date('tomorrow@9am')
|
||||||
|
assert.are.equal(tomorrow_str .. 'T09:00', result)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('resolves 9:30pm to T21:30', function()
|
||||||
|
local result = parse.resolve_date('tomorrow@9:30pm')
|
||||||
|
assert.are.equal(tomorrow_str .. 'T21:30', result)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('resolves 12am to T00:00', function()
|
||||||
|
local result = parse.resolve_date('tomorrow@12am')
|
||||||
|
assert.are.equal(tomorrow_str .. 'T00:00', result)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('resolves 12pm to T12:00', function()
|
||||||
|
local result = parse.resolve_date('tomorrow@12pm')
|
||||||
|
assert.are.equal(tomorrow_str .. 'T12:00', result)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('rejects hour 24', function()
|
||||||
|
assert.is_nil(parse.resolve_date('tomorrow@24'))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('rejects 13am', function()
|
||||||
|
assert.is_nil(parse.resolve_date('tomorrow@13am'))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('rejects minute 60', function()
|
||||||
|
assert.is_nil(parse.resolve_date('tomorrow@9:60'))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('rejects alphabetic garbage', function()
|
||||||
|
assert.is_nil(parse.resolve_date('tomorrow@abc'))
|
||||||
|
end)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
describe('command_add', function()
|
describe('command_add', function()
|
||||||
|
|
|
||||||
223
spec/recur_spec.lua
Normal file
223
spec/recur_spec.lua
Normal file
|
|
@ -0,0 +1,223 @@
|
||||||
|
require('spec.helpers')
|
||||||
|
|
||||||
|
describe('recur', function()
|
||||||
|
local recur = require('pending.recur')
|
||||||
|
|
||||||
|
describe('parse', function()
|
||||||
|
it('parses daily', function()
|
||||||
|
local r = recur.parse('daily')
|
||||||
|
assert.are.equal('daily', r.freq)
|
||||||
|
assert.are.equal(1, r.interval)
|
||||||
|
assert.is_false(r.from_completion)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('parses weekdays', function()
|
||||||
|
local r = recur.parse('weekdays')
|
||||||
|
assert.are.equal('weekly', r.freq)
|
||||||
|
assert.are.same({ 'MO', 'TU', 'WE', 'TH', 'FR' }, r.byday)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('parses weekly', function()
|
||||||
|
local r = recur.parse('weekly')
|
||||||
|
assert.are.equal('weekly', r.freq)
|
||||||
|
assert.are.equal(1, r.interval)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('parses biweekly', function()
|
||||||
|
local r = recur.parse('biweekly')
|
||||||
|
assert.are.equal('weekly', r.freq)
|
||||||
|
assert.are.equal(2, r.interval)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('parses monthly', function()
|
||||||
|
local r = recur.parse('monthly')
|
||||||
|
assert.are.equal('monthly', r.freq)
|
||||||
|
assert.are.equal(1, r.interval)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('parses quarterly', function()
|
||||||
|
local r = recur.parse('quarterly')
|
||||||
|
assert.are.equal('monthly', r.freq)
|
||||||
|
assert.are.equal(3, r.interval)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('parses yearly', function()
|
||||||
|
local r = recur.parse('yearly')
|
||||||
|
assert.are.equal('yearly', r.freq)
|
||||||
|
assert.are.equal(1, r.interval)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('parses annual as yearly', function()
|
||||||
|
local r = recur.parse('annual')
|
||||||
|
assert.are.equal('yearly', r.freq)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('parses 3d as every 3 days', function()
|
||||||
|
local r = recur.parse('3d')
|
||||||
|
assert.are.equal('daily', r.freq)
|
||||||
|
assert.are.equal(3, r.interval)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('parses 2w as biweekly', function()
|
||||||
|
local r = recur.parse('2w')
|
||||||
|
assert.are.equal('weekly', r.freq)
|
||||||
|
assert.are.equal(2, r.interval)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('parses 6m as every 6 months', function()
|
||||||
|
local r = recur.parse('6m')
|
||||||
|
assert.are.equal('monthly', r.freq)
|
||||||
|
assert.are.equal(6, r.interval)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('parses 2y as every 2 years', function()
|
||||||
|
local r = recur.parse('2y')
|
||||||
|
assert.are.equal('yearly', r.freq)
|
||||||
|
assert.are.equal(2, r.interval)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('parses ! prefix as completion-based', function()
|
||||||
|
local r = recur.parse('!weekly')
|
||||||
|
assert.are.equal('weekly', r.freq)
|
||||||
|
assert.is_true(r.from_completion)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('parses raw RRULE fragment', function()
|
||||||
|
local r = recur.parse('FREQ=MONTHLY;BYDAY=1MO')
|
||||||
|
assert.is_not_nil(r)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('returns nil for invalid input', function()
|
||||||
|
assert.is_nil(recur.parse(''))
|
||||||
|
assert.is_nil(recur.parse('garbage'))
|
||||||
|
assert.is_nil(recur.parse('0d'))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('is case insensitive', function()
|
||||||
|
local r = recur.parse('Weekly')
|
||||||
|
assert.are.equal('weekly', r.freq)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
describe('validate', function()
|
||||||
|
it('returns true for valid specs', function()
|
||||||
|
assert.is_true(recur.validate('daily'))
|
||||||
|
assert.is_true(recur.validate('2w'))
|
||||||
|
assert.is_true(recur.validate('!monthly'))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('returns false for invalid specs', function()
|
||||||
|
assert.is_false(recur.validate('garbage'))
|
||||||
|
assert.is_false(recur.validate(''))
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
describe('next_due', function()
|
||||||
|
it('advances daily by 1 day', function()
|
||||||
|
local result = recur.next_due('2099-03-01', 'daily', 'scheduled')
|
||||||
|
assert.are.equal('2099-03-02', result)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('advances weekly by 7 days', function()
|
||||||
|
local result = recur.next_due('2099-03-01', 'weekly', 'scheduled')
|
||||||
|
assert.are.equal('2099-03-08', result)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('advances monthly and clamps day', function()
|
||||||
|
local result = recur.next_due('2099-01-31', 'monthly', 'scheduled')
|
||||||
|
assert.are.equal('2099-02-28', result)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('advances yearly and handles leap year', function()
|
||||||
|
local result = recur.next_due('2096-02-29', 'yearly', 'scheduled')
|
||||||
|
assert.are.equal('2097-02-28', result)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('advances biweekly by 14 days', function()
|
||||||
|
local result = recur.next_due('2099-03-01', 'biweekly', 'scheduled')
|
||||||
|
assert.are.equal('2099-03-15', result)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('advances quarterly by 3 months', function()
|
||||||
|
local result = recur.next_due('2099-01-15', 'quarterly', 'scheduled')
|
||||||
|
assert.are.equal('2099-04-15', result)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('scheduled mode skips to future if overdue', function()
|
||||||
|
local result = recur.next_due('2020-01-01', 'yearly', 'scheduled')
|
||||||
|
local today = os.date('%Y-%m-%d') --[[@as string]]
|
||||||
|
assert.is_true(result > today)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('completion mode advances from today', function()
|
||||||
|
local today = os.date('*t') --[[@as osdate]]
|
||||||
|
local expected = os.date(
|
||||||
|
'%Y-%m-%d',
|
||||||
|
os.time({
|
||||||
|
year = today.year,
|
||||||
|
month = today.month,
|
||||||
|
day = today.day + 7,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
local result = recur.next_due('2020-01-01', 'weekly', 'completion')
|
||||||
|
assert.are.equal(expected, result)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('advances 3d by 3 days', function()
|
||||||
|
local result = recur.next_due('2099-06-10', '3d', 'scheduled')
|
||||||
|
assert.are.equal('2099-06-13', result)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
describe('to_rrule', function()
|
||||||
|
it('converts daily', function()
|
||||||
|
assert.are.equal('RRULE:FREQ=DAILY', recur.to_rrule('daily'))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('converts weekly', function()
|
||||||
|
assert.are.equal('RRULE:FREQ=WEEKLY', recur.to_rrule('weekly'))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('converts biweekly with interval', function()
|
||||||
|
assert.are.equal('RRULE:FREQ=WEEKLY;INTERVAL=2', recur.to_rrule('biweekly'))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('converts weekdays with BYDAY', function()
|
||||||
|
assert.are.equal('RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR', recur.to_rrule('weekdays'))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('converts monthly', function()
|
||||||
|
assert.are.equal('RRULE:FREQ=MONTHLY', recur.to_rrule('monthly'))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('converts quarterly with interval', function()
|
||||||
|
assert.are.equal('RRULE:FREQ=MONTHLY;INTERVAL=3', recur.to_rrule('quarterly'))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('converts yearly', function()
|
||||||
|
assert.are.equal('RRULE:FREQ=YEARLY', recur.to_rrule('yearly'))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('converts 2w with interval', function()
|
||||||
|
assert.are.equal('RRULE:FREQ=WEEKLY;INTERVAL=2', recur.to_rrule('2w'))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('prefixes raw RRULE fragment', function()
|
||||||
|
assert.are.equal('RRULE:FREQ=MONTHLY;BYDAY=1MO', recur.to_rrule('FREQ=MONTHLY;BYDAY=1MO'))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('returns empty string for invalid spec', function()
|
||||||
|
assert.are.equal('', recur.to_rrule('garbage'))
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
describe('shorthand_list', function()
|
||||||
|
it('returns a list of named shorthands', function()
|
||||||
|
local list = recur.shorthand_list()
|
||||||
|
assert.is_true(#list >= 8)
|
||||||
|
assert.is_true(vim.tbl_contains(list, 'daily'))
|
||||||
|
assert.is_true(vim.tbl_contains(list, 'weekly'))
|
||||||
|
assert.is_true(vim.tbl_contains(list, 'monthly'))
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
@ -196,6 +196,41 @@ describe('store', function()
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
describe('recurrence fields', function()
|
||||||
|
it('persists recur and recur_mode through round-trip', function()
|
||||||
|
store.load()
|
||||||
|
store.add({ description = 'Recurring', recur = 'weekly', recur_mode = 'scheduled' })
|
||||||
|
store.save()
|
||||||
|
store.unload()
|
||||||
|
store.load()
|
||||||
|
local task = store.get(1)
|
||||||
|
assert.are.equal('weekly', task.recur)
|
||||||
|
assert.are.equal('scheduled', task.recur_mode)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('persists recur without recur_mode', function()
|
||||||
|
store.load()
|
||||||
|
store.add({ description = 'Simple recur', recur = 'daily' })
|
||||||
|
store.save()
|
||||||
|
store.unload()
|
||||||
|
store.load()
|
||||||
|
local task = store.get(1)
|
||||||
|
assert.are.equal('daily', task.recur)
|
||||||
|
assert.is_nil(task.recur_mode)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('omits recur fields when not set', function()
|
||||||
|
store.load()
|
||||||
|
store.add({ description = 'No recur' })
|
||||||
|
store.save()
|
||||||
|
store.unload()
|
||||||
|
store.load()
|
||||||
|
local task = store.get(1)
|
||||||
|
assert.is_nil(task.recur)
|
||||||
|
assert.is_nil(task.recur_mode)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
describe('active_tasks', function()
|
describe('active_tasks', function()
|
||||||
it('excludes deleted tasks', function()
|
it('excludes deleted tasks', function()
|
||||||
store.load()
|
store.load()
|
||||||
|
|
|
||||||
|
|
@ -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