Compare commits
12 commits
doc/minify
...
feat/sync-
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e2c5ef11c | |||
| 23d34addc8 | |||
| 7de9c23ec1 | |||
|
|
8d3d21b330 | ||
|
|
e62e09f609 | ||
|
|
302bf8126f | ||
|
|
c57cc0845b | ||
|
|
72dbf037c7 | ||
|
|
b76c680e1f | ||
|
|
379e281ecd | ||
|
|
7d93c4bb45 | ||
|
|
6911c091f6 |
29 changed files with 4225 additions and 376 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)
|
||||||
|
|
|
||||||
468
doc/pending.txt
468
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
|
||||||
- Two views: category (default) and priority flat list
|
names, month names, ordinals, and more
|
||||||
- Multi-level undo (up to 20 `:w` saves, session-only)
|
- Recurring tasks with automatic next-date spawning on completion
|
||||||
|
- Two views: category (default) and queue (priority-sorted flat list)
|
||||||
|
- 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
|
||||||
|
|
||||||
==============================================================================
|
==============================================================================
|
||||||
|
|
@ -44,7 +47,7 @@ REQUIREMENTS *pending-requirements*
|
||||||
|
|
||||||
- Neovim 0.10+
|
- Neovim 0.10+
|
||||||
- No external dependencies for local use
|
- No external dependencies for local use
|
||||||
- `curl` and `openssl` are required for Google Calendar sync
|
- `curl` and `openssl` are required for the `gcal` sync backend
|
||||||
|
|
||||||
==============================================================================
|
==============================================================================
|
||||||
INSTALL *pending-install*
|
INSTALL *pending-install*
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
@ -152,16 +250,29 @@ COMMANDS *pending-commands*
|
||||||
Open the list with |:copen| to navigate to each task's category.
|
Open the list with |:copen| to navigate to each task's category.
|
||||||
|
|
||||||
*:Pending-sync*
|
*:Pending-sync*
|
||||||
:Pending sync
|
:Pending sync {backend} [{action}]
|
||||||
Push pending tasks that have a due date to Google Calendar as all-day
|
Run a sync action against a named backend. {backend} is required — bare
|
||||||
events. Requires |pending-gcal| to be configured. See |pending-gcal| for
|
`:Pending sync` prints a usage message. {action} defaults to `sync`
|
||||||
full details on what gets created, updated, and deleted.
|
when omitted. Each backend lives at `lua/pending/sync/<name>.lua`.
|
||||||
|
|
||||||
|
Examples: >vim
|
||||||
|
:Pending sync gcal " runs gcal.sync()
|
||||||
|
:Pending sync gcal auth " runs gcal.auth()
|
||||||
|
:Pending sync gcal sync " explicit sync (same as bare)
|
||||||
|
<
|
||||||
|
|
||||||
|
Tab completion after `:Pending sync ` lists discovered backends.
|
||||||
|
Tab completion after `:Pending sync gcal ` lists available actions.
|
||||||
|
|
||||||
|
Built-in backends: ~
|
||||||
|
|
||||||
|
`gcal` Google Calendar one-way push. See |pending-gcal|.
|
||||||
|
|
||||||
*:Pending-undo*
|
*:Pending-undo*
|
||||||
: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 +280,62 @@ 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 / queue 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
|
Text objects (operator-pending and visual): ~
|
||||||
at the position below or above the cursor rather than using standard Vim
|
|
||||||
indentation. `dd`, `p`, `P`, and `:w` work as expected.
|
Key Action ~
|
||||||
|
------- ------------------------------------------------
|
||||||
|
`at` Select the current task line (`a_task`)
|
||||||
|
`it` Select the task description only (`i_task`)
|
||||||
|
`aC` Select a category: header + tasks + blanks (`a_category`)
|
||||||
|
`iC` Select inner category: tasks only (`i_category`)
|
||||||
|
|
||||||
|
`at` supports count: `d3at` deletes three consecutive tasks. `it` selects
|
||||||
|
the description text between the checkbox prefix and trailing metadata
|
||||||
|
tokens (`due:`, `cat:`, `rec:`), making `cit` the natural way to retype a
|
||||||
|
task description without touching its metadata.
|
||||||
|
|
||||||
|
`aC` and `iC` are no-ops in the queue view (no headers to delimit).
|
||||||
|
|
||||||
|
Motions (normal, visual, operator-pending): ~
|
||||||
|
|
||||||
|
Key Action ~
|
||||||
|
------- ------------------------------------------------
|
||||||
|
`]]` Jump to the next category header (`next_header`)
|
||||||
|
`[[` Jump to the previous category header (`prev_header`)
|
||||||
|
`]t` Jump to the next task line (`next_task`)
|
||||||
|
`[t` Jump to the previous task line (`prev_task`)
|
||||||
|
|
||||||
|
All motions support count: `3]]` jumps three headers forward. `]]` and
|
||||||
|
`[[` are no-ops in the queue view. `]t` and `[t` work in both views.
|
||||||
|
|
||||||
|
`dd`, `p`, `P`, and `:w` work as standard Vim operations.
|
||||||
|
|
||||||
*<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 +352,50 @@ 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.
|
||||||
|
|
||||||
|
*<Plug>(pending-a-task)*
|
||||||
|
<Plug>(pending-a-task)
|
||||||
|
Select the current task line (linewise). Supports count.
|
||||||
|
|
||||||
|
*<Plug>(pending-i-task)*
|
||||||
|
<Plug>(pending-i-task)
|
||||||
|
Select the task description text (characterwise).
|
||||||
|
|
||||||
|
*<Plug>(pending-a-category)*
|
||||||
|
<Plug>(pending-a-category)
|
||||||
|
Select a full category section: header, tasks, and surrounding blanks.
|
||||||
|
|
||||||
|
*<Plug>(pending-i-category)*
|
||||||
|
<Plug>(pending-i-category)
|
||||||
|
Select tasks within a category, excluding the header and blanks.
|
||||||
|
|
||||||
|
*<Plug>(pending-next-header)*
|
||||||
|
<Plug>(pending-next-header)
|
||||||
|
Jump to the next category header. Supports count.
|
||||||
|
|
||||||
|
*<Plug>(pending-prev-header)*
|
||||||
|
<Plug>(pending-prev-header)
|
||||||
|
Jump to the previous category header. Supports count.
|
||||||
|
|
||||||
|
*<Plug>(pending-next-task)*
|
||||||
|
<Plug>(pending-next-task)
|
||||||
|
Jump to the next task line, skipping headers and blanks.
|
||||||
|
|
||||||
|
*<Plug>(pending-prev-task)*
|
||||||
|
<Plug>(pending-prev-task)
|
||||||
|
Jump to the previous task line, skipping headers and blanks.
|
||||||
|
|
||||||
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)')
|
||||||
|
|
@ -224,12 +414,12 @@ Category view (default): ~ *pending-view-category*
|
||||||
first within each group. Category sections are foldable with `zc` and
|
first within each group. Category sections are foldable with `zc` and
|
||||||
`zo`.
|
`zo`.
|
||||||
|
|
||||||
Priority view: ~ *pending-view-priority*
|
Queue view: ~ *pending-view-queue*
|
||||||
A flat list of all tasks sorted by priority, then by due date (tasks
|
A flat list of all tasks sorted by priority, then by due date (tasks
|
||||||
without a due date sort last), then by internal order. Done tasks appear
|
without a due date sort last), then by internal order. Done tasks appear
|
||||||
after all pending tasks. Category names are shown as right-aligned virtual
|
after all pending tasks. Category names are shown as right-aligned virtual
|
||||||
text alongside the due date virtual text so tasks remain identifiable
|
text alongside the due date virtual text so tasks remain identifiable
|
||||||
across categories.
|
across categories. The buffer is named `pending://queue`.
|
||||||
|
|
||||||
==============================================================================
|
==============================================================================
|
||||||
CONFIGURATION *pending-config*
|
CONFIGURATION *pending-config*
|
||||||
|
|
@ -242,11 +432,33 @@ 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',
|
||||||
|
a_task = 'at',
|
||||||
|
i_task = 'it',
|
||||||
|
a_category = 'aC',
|
||||||
|
i_category = 'iC',
|
||||||
|
next_header = ']]',
|
||||||
|
prev_header = '[[',
|
||||||
|
next_task = ']t',
|
||||||
|
prev_task = '[t',
|
||||||
|
},
|
||||||
|
sync = {
|
||||||
gcal = {
|
gcal = {
|
||||||
calendar = 'Tasks',
|
calendar = 'Tasks',
|
||||||
credentials_path = '/path/to/client_secret.json',
|
credentials_path = '/path/to/client_secret.json',
|
||||||
},
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
<
|
<
|
||||||
|
|
||||||
|
|
@ -278,16 +490,159 @@ 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.
|
||||||
|
|
||||||
|
{debug} (boolean, default: false)
|
||||||
|
Enable diagnostic logging. When `true`, textobj
|
||||||
|
motions, mapping registration, and cursor jumps
|
||||||
|
emit messages at `vim.log.levels.DEBUG`. Use
|
||||||
|
|:messages| to inspect the output. Useful for
|
||||||
|
diagnosing keymap conflicts (e.g. `]t` colliding
|
||||||
|
with Neovim defaults) or motion misbehavior.
|
||||||
|
Example: >lua
|
||||||
|
vim.g.pending = { debug = true }
|
||||||
|
<
|
||||||
|
|
||||||
|
{sync} (table, default: {}) *pending.SyncConfig*
|
||||||
|
Sync backend configuration. Each key is a backend
|
||||||
|
name and the value is the backend-specific config
|
||||||
|
table. Currently only `gcal` is built-in.
|
||||||
|
|
||||||
{gcal} (table, default: nil)
|
{gcal} (table, default: nil)
|
||||||
Google Calendar sync configuration. See
|
Legacy shorthand for `sync.gcal`. If `gcal` is set
|
||||||
|pending.GcalConfig|. Omit this field entirely to
|
but `sync.gcal` is not, the value is migrated
|
||||||
disable Google Calendar sync.
|
automatically. New configs should use `sync.gcal`
|
||||||
|
instead. See |pending.GcalConfig|.
|
||||||
|
|
||||||
|
==============================================================================
|
||||||
|
LUA API *pending-api*
|
||||||
|
|
||||||
|
The following functions are available on `require('pending')` for use in
|
||||||
|
statuslines, autocmds, and other integrations.
|
||||||
|
|
||||||
|
*pending.counts()*
|
||||||
|
pending.counts()
|
||||||
|
Returns a table of current task counts: >lua
|
||||||
|
{
|
||||||
|
overdue = 2, -- pending tasks past their due date/time
|
||||||
|
today = 1, -- pending tasks due today (not yet overdue)
|
||||||
|
pending = 10, -- total pending tasks (all statuses)
|
||||||
|
priority = 3, -- pending tasks with priority > 0
|
||||||
|
next_due = "2026-03-01", -- earliest future due date, or nil
|
||||||
|
}
|
||||||
|
<
|
||||||
|
The counts are read from a module-local cache that is invalidated on every
|
||||||
|
`:w`, toggle, date change, archive, undo, and sync. The first call triggers
|
||||||
|
a lazy `store.load()` if the store has not been loaded yet.
|
||||||
|
|
||||||
|
Done, deleted, and `someday` sentinel-dated tasks are excluded from the
|
||||||
|
`overdue` and `today` counts. The `someday` sentinel is the value of
|
||||||
|
`someday_date` in |pending-config| (default `9999-12-30`).
|
||||||
|
|
||||||
|
*pending.statusline()*
|
||||||
|
pending.statusline()
|
||||||
|
Returns a pre-formatted string suitable for embedding in a statusline:
|
||||||
|
|
||||||
|
- `"2 overdue, 1 today"` when both overdue and today counts are non-zero
|
||||||
|
- `"2 overdue"` when only overdue
|
||||||
|
- `"1 today"` when only today
|
||||||
|
- `""` (empty string) when nothing is actionable
|
||||||
|
|
||||||
|
*pending.has_due()*
|
||||||
|
pending.has_due()
|
||||||
|
Returns `true` when `overdue > 0` or `today > 0`. Useful as a conditional
|
||||||
|
for statusline components that should only render when tasks need attention.
|
||||||
|
|
||||||
|
*PendingStatusChanged*
|
||||||
|
PendingStatusChanged
|
||||||
|
A |User| autocmd event fired after every count recomputation. Use this to
|
||||||
|
trigger statusline refreshes or notifications: >lua
|
||||||
|
vim.api.nvim_create_autocmd('User', {
|
||||||
|
pattern = 'PendingStatusChanged',
|
||||||
|
callback = function()
|
||||||
|
vim.cmd.redrawstatus()
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
<
|
||||||
|
|
||||||
|
==============================================================================
|
||||||
|
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' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
<
|
||||||
|
|
||||||
|
Lualine integration: >lua
|
||||||
|
require('lualine').setup({
|
||||||
|
sections = {
|
||||||
|
lualine_x = {
|
||||||
|
{
|
||||||
|
function() return require('pending').statusline() end,
|
||||||
|
cond = function() return require('pending').has_due() end,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
<
|
||||||
|
|
||||||
|
Heirline integration: >lua
|
||||||
|
local Pending = {
|
||||||
|
condition = function() return require('pending').has_due() end,
|
||||||
|
provider = function() return require('pending').statusline() end,
|
||||||
|
}
|
||||||
|
<
|
||||||
|
|
||||||
|
Manual statusline: >vim
|
||||||
|
set statusline+=%{%v:lua.require('pending').statusline()%}
|
||||||
|
<
|
||||||
|
|
||||||
|
Startup notification: >lua
|
||||||
|
vim.api.nvim_create_autocmd('User', {
|
||||||
|
pattern = 'PendingStatusChanged',
|
||||||
|
once = true,
|
||||||
|
callback = function()
|
||||||
|
local c = require('pending').counts()
|
||||||
|
if c.overdue > 0 then
|
||||||
|
vim.notify(c.overdue .. ' overdue task(s)')
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
<
|
||||||
|
|
||||||
|
Event-driven statusline refresh: >lua
|
||||||
|
vim.api.nvim_create_autocmd('User', {
|
||||||
|
pattern = 'PendingStatusChanged',
|
||||||
|
callback = function()
|
||||||
|
vim.cmd.redrawstatus()
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
<
|
||||||
|
|
||||||
==============================================================================
|
==============================================================================
|
||||||
GOOGLE CALENDAR *pending-gcal*
|
GOOGLE CALENDAR *pending-gcal*
|
||||||
|
|
@ -298,13 +653,18 @@ not pulled back into pending.nvim.
|
||||||
|
|
||||||
Configuration: >lua
|
Configuration: >lua
|
||||||
vim.g.pending = {
|
vim.g.pending = {
|
||||||
|
sync = {
|
||||||
gcal = {
|
gcal = {
|
||||||
calendar = 'Tasks',
|
calendar = 'Tasks',
|
||||||
credentials_path = '/path/to/client_secret.json',
|
credentials_path = '/path/to/client_secret.json',
|
||||||
},
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
<
|
<
|
||||||
|
|
||||||
|
The legacy `gcal` top-level key is still accepted and migrated automatically.
|
||||||
|
New configurations should use `sync.gcal`.
|
||||||
|
|
||||||
*pending.GcalConfig*
|
*pending.GcalConfig*
|
||||||
Fields: ~
|
Fields: ~
|
||||||
{calendar} (string, default: 'Pendings')
|
{calendar} (string, default: 'Pendings')
|
||||||
|
|
@ -320,7 +680,7 @@ Fields: ~
|
||||||
that Google provides or as a bare credentials object.
|
that Google provides or as a bare credentials object.
|
||||||
|
|
||||||
OAuth flow: ~
|
OAuth flow: ~
|
||||||
On the first `:Pending sync` call the plugin detects that no refresh token
|
On the first `:Pending sync gcal` call the plugin detects that no refresh token
|
||||||
exists and opens the Google authorization URL in the browser using
|
exists and opens the Google authorization URL in the browser using
|
||||||
|vim.ui.open()|. A temporary local HTTP server listens on port 18392 for the
|
|vim.ui.open()|. A temporary local HTTP server listens on port 18392 for the
|
||||||
OAuth redirect. The PKCE (Proof Key for Code Exchange) flow is used —
|
OAuth redirect. The PKCE (Proof Key for Code Exchange) flow is used —
|
||||||
|
|
@ -330,7 +690,7 @@ authorization code is exchanged for tokens and the refresh token is stored at
|
||||||
use the stored refresh token and refresh the access token automatically when
|
use the stored refresh token and refresh the access token automatically when
|
||||||
it is about to expire.
|
it is about to expire.
|
||||||
|
|
||||||
`:Pending sync` behavior: ~
|
`:Pending sync gcal` behavior: ~
|
||||||
For each task in the store:
|
For each task in the store:
|
||||||
- A pending task with a due date and no existing event: a new all-day event is
|
- A pending task with a due date and no existing event: a new all-day event is
|
||||||
created and the event ID is stored in the task's `_extra` table.
|
created and the event ID is stored in the task's `_extra` table.
|
||||||
|
|
@ -343,6 +703,30 @@ For each task in the store:
|
||||||
A summary notification is shown after sync: `created: N, updated: N,
|
A summary notification is shown after sync: `created: N, updated: N,
|
||||||
deleted: N`.
|
deleted: N`.
|
||||||
|
|
||||||
|
==============================================================================
|
||||||
|
SYNC BACKENDS *pending-sync-backend*
|
||||||
|
|
||||||
|
Sync backends are Lua modules under `lua/pending/sync/<name>.lua`. Each
|
||||||
|
module returns a table conforming to the backend interface: >lua
|
||||||
|
|
||||||
|
---@class pending.SyncBackend
|
||||||
|
---@field name string
|
||||||
|
---@field auth fun(): nil
|
||||||
|
---@field sync fun(): nil
|
||||||
|
---@field health? fun(): nil
|
||||||
|
<
|
||||||
|
|
||||||
|
Required fields: ~
|
||||||
|
{name} Backend identifier (matches the filename).
|
||||||
|
{sync} Main sync action. Called by `:Pending sync <name>`.
|
||||||
|
{auth} Authorization flow. Called by `:Pending sync <name> auth`.
|
||||||
|
|
||||||
|
Optional fields: ~
|
||||||
|
{health} Called by `:checkhealth pending` to report backend-specific
|
||||||
|
diagnostics (e.g. checking for external tools).
|
||||||
|
|
||||||
|
Backend-specific configuration goes under `sync.<name>` in |pending-config|.
|
||||||
|
|
||||||
==============================================================================
|
==============================================================================
|
||||||
HIGHLIGHT GROUPS *pending-highlights*
|
HIGHLIGHT GROUPS *pending-highlights*
|
||||||
|
|
||||||
|
|
@ -371,6 +755,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,8 +777,9 @@ 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
|
||||||
- Whether `curl` is available (required for Google Calendar sync)
|
- Validates recurrence specs on stored tasks
|
||||||
- Whether `openssl` is available (required for OAuth PKCE)
|
- Discovers sync backends under `lua/pending/sync/` and runs each backend's
|
||||||
|
`health()` function if it exists (e.g. gcal checks for `curl` and `openssl`)
|
||||||
|
|
||||||
==============================================================================
|
==============================================================================
|
||||||
DATA FORMAT *pending-data*
|
DATA FORMAT *pending-data*
|
||||||
|
|
@ -414,6 +804,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,12 +37,21 @@ 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 not task_winid or not vim.api.nvim_win_is_valid(task_winid) then
|
||||||
|
task_winid = nil
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local wins = vim.api.nvim_list_wins()
|
||||||
|
if #wins == 1 then
|
||||||
|
vim.cmd.enew()
|
||||||
|
else
|
||||||
vim.api.nvim_win_close(task_winid, false)
|
vim.api.nvim_win_close(task_winid, false)
|
||||||
end
|
end
|
||||||
task_winid = nil
|
task_winid = nil
|
||||||
|
|
@ -55,19 +64,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 +88,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 +126,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 +169,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 +215,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
|
||||||
|
|
@ -219,7 +223,8 @@ function M.render(bufnr)
|
||||||
end
|
end
|
||||||
|
|
||||||
current_view = current_view or config.get().default_view
|
current_view = current_view or config.get().default_view
|
||||||
vim.api.nvim_buf_set_name(bufnr, 'pending://' .. current_view)
|
local view_label = current_view == 'priority' and 'queue' or current_view
|
||||||
|
vim.api.nvim_buf_set_name(bufnr, 'pending://' .. view_label)
|
||||||
local tasks = store.active_tasks()
|
local tasks = store.active_tasks()
|
||||||
|
|
||||||
local lines, line_meta
|
local lines, line_meta
|
||||||
|
|
@ -256,6 +261,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,40 @@
|
||||||
---@field calendar? string
|
---@field calendar? string
|
||||||
---@field credentials_path? string
|
---@field credentials_path? string
|
||||||
|
|
||||||
|
---@class pending.SyncConfig
|
||||||
|
---@field gcal? pending.GcalConfig
|
||||||
|
|
||||||
|
---@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
|
||||||
|
---@field a_task? string|false
|
||||||
|
---@field i_task? string|false
|
||||||
|
---@field a_category? string|false
|
||||||
|
---@field i_category? string|false
|
||||||
|
---@field next_header? string|false
|
||||||
|
---@field prev_header? string|false
|
||||||
|
---@field next_task? string|false
|
||||||
|
---@field prev_task? 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 debug? boolean
|
||||||
|
---@field keymaps pending.Keymaps
|
||||||
|
---@field sync? pending.SyncConfig
|
||||||
---@field gcal? pending.GcalConfig
|
---@field gcal? pending.GcalConfig
|
||||||
|
|
||||||
---@class pending.config
|
---@class pending.config
|
||||||
|
|
@ -22,7 +48,28 @@ 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',
|
||||||
|
a_task = 'at',
|
||||||
|
i_task = 'it',
|
||||||
|
a_category = 'aC',
|
||||||
|
i_category = 'iC',
|
||||||
|
next_header = ']]',
|
||||||
|
prev_header = '[[',
|
||||||
|
next_task = ']t',
|
||||||
|
prev_task = '[t',
|
||||||
|
},
|
||||||
|
sync = {},
|
||||||
}
|
}
|
||||||
|
|
||||||
---@type pending.Config?
|
---@type pending.Config?
|
||||||
|
|
@ -35,9 +82,14 @@ function M.get()
|
||||||
end
|
end
|
||||||
local user = vim.g.pending or {}
|
local user = vim.g.pending or {}
|
||||||
_resolved = vim.tbl_deep_extend('force', defaults, user)
|
_resolved = vim.tbl_deep_extend('force', defaults, user)
|
||||||
|
if _resolved.gcal and not (_resolved.sync and _resolved.sync.gcal) then
|
||||||
|
_resolved.sync = _resolved.sync or {}
|
||||||
|
_resolved.sync.gcal = _resolved.gcal
|
||||||
|
end
|
||||||
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
|
||||||
|
|
@ -35,16 +47,18 @@ function M.check()
|
||||||
vim.health.info('No data file yet (will be created on first save)')
|
vim.health.info('No data file yet (will be created on first save)')
|
||||||
end
|
end
|
||||||
|
|
||||||
if vim.fn.executable('curl') == 1 then
|
local sync_paths = vim.fn.globpath(vim.o.runtimepath, 'lua/pending/sync/*.lua', false, true)
|
||||||
vim.health.ok('curl found (required for Google Calendar sync)')
|
if #sync_paths == 0 then
|
||||||
|
vim.health.info('No sync backends found')
|
||||||
else
|
else
|
||||||
vim.health.warn('curl not found (needed for Google Calendar sync)')
|
for _, path in ipairs(sync_paths) do
|
||||||
|
local name = vim.fn.fnamemodify(path, ':t:r')
|
||||||
|
local bok, backend = pcall(require, 'pending.sync.' .. name)
|
||||||
|
if bok and type(backend.health) == 'function' then
|
||||||
|
vim.health.start('pending.nvim: sync/' .. name)
|
||||||
|
backend.health()
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
if vim.fn.executable('openssl') == 1 then
|
|
||||||
vim.health.ok('openssl found (required for OAuth PKCE)')
|
|
||||||
else
|
|
||||||
vim.health.warn('openssl not found (needed for Google Calendar OAuth)')
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,97 @@ local diff = require('pending.diff')
|
||||||
local parse = require('pending.parse')
|
local parse = require('pending.parse')
|
||||||
local store = require('pending.store')
|
local store = require('pending.store')
|
||||||
|
|
||||||
|
---@class pending.Counts
|
||||||
|
---@field overdue integer
|
||||||
|
---@field today integer
|
||||||
|
---@field pending integer
|
||||||
|
---@field priority integer
|
||||||
|
---@field next_due? string
|
||||||
|
|
||||||
---@class pending.init
|
---@class pending.init
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
---@type pending.Task[][]
|
|
||||||
local _undo_states = {}
|
|
||||||
local UNDO_MAX = 20
|
local UNDO_MAX = 20
|
||||||
|
|
||||||
|
---@type pending.Counts?
|
||||||
|
local _counts = nil
|
||||||
|
|
||||||
|
---@return nil
|
||||||
|
function M._recompute_counts()
|
||||||
|
local cfg = require('pending.config').get()
|
||||||
|
local someday = cfg.someday_date
|
||||||
|
local overdue = 0
|
||||||
|
local today = 0
|
||||||
|
local pending = 0
|
||||||
|
local priority = 0
|
||||||
|
local next_due = nil ---@type string?
|
||||||
|
local today_str = os.date('%Y-%m-%d') --[[@as string]]
|
||||||
|
|
||||||
|
for _, task in ipairs(store.active_tasks()) do
|
||||||
|
if task.status == 'pending' then
|
||||||
|
pending = pending + 1
|
||||||
|
if task.priority > 0 then
|
||||||
|
priority = priority + 1
|
||||||
|
end
|
||||||
|
if task.due and task.due ~= someday then
|
||||||
|
if parse.is_overdue(task.due) then
|
||||||
|
overdue = overdue + 1
|
||||||
|
elseif parse.is_today(task.due) then
|
||||||
|
today = today + 1
|
||||||
|
end
|
||||||
|
local date_part = task.due:match('^(%d%d%d%d%-%d%d%-%d%d)') or task.due
|
||||||
|
if date_part >= today_str and (not next_due or task.due < next_due) then
|
||||||
|
next_due = task.due
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
_counts = {
|
||||||
|
overdue = overdue,
|
||||||
|
today = today,
|
||||||
|
pending = pending,
|
||||||
|
priority = priority,
|
||||||
|
next_due = next_due,
|
||||||
|
}
|
||||||
|
|
||||||
|
vim.api.nvim_exec_autocmds('User', { pattern = 'PendingStatusChanged' })
|
||||||
|
end
|
||||||
|
|
||||||
|
---@return nil
|
||||||
|
local function _save_and_notify()
|
||||||
|
store.save()
|
||||||
|
M._recompute_counts()
|
||||||
|
end
|
||||||
|
|
||||||
|
---@return pending.Counts
|
||||||
|
function M.counts()
|
||||||
|
if not _counts then
|
||||||
|
store.load()
|
||||||
|
M._recompute_counts()
|
||||||
|
end
|
||||||
|
return _counts --[[@as pending.Counts]]
|
||||||
|
end
|
||||||
|
|
||||||
|
---@return string
|
||||||
|
function M.statusline()
|
||||||
|
local c = M.counts()
|
||||||
|
if c.overdue > 0 and c.today > 0 then
|
||||||
|
return c.overdue .. ' overdue, ' .. c.today .. ' today'
|
||||||
|
elseif c.overdue > 0 then
|
||||||
|
return c.overdue .. ' overdue'
|
||||||
|
elseif c.today > 0 then
|
||||||
|
return c.today .. ' today'
|
||||||
|
end
|
||||||
|
return ''
|
||||||
|
end
|
||||||
|
|
||||||
|
---@return boolean
|
||||||
|
function M.has_due()
|
||||||
|
local c = M.counts()
|
||||||
|
return c.overdue > 0 or c.today > 0
|
||||||
|
end
|
||||||
|
|
||||||
---@return integer bufnr
|
---@return integer bufnr
|
||||||
function M.open()
|
function M.open()
|
||||||
local bufnr = buffer.open()
|
local bufnr = buffer.open()
|
||||||
|
|
@ -19,6 +103,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 +134,143 @@ 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
local textobj = require('pending.textobj')
|
||||||
|
|
||||||
|
---@type table<string, { modes: string[], fn: fun(count: integer), visual_fn?: fun(count: integer) }>
|
||||||
|
local textobjs = {
|
||||||
|
a_task = {
|
||||||
|
modes = { 'o', 'x' },
|
||||||
|
fn = textobj.a_task,
|
||||||
|
visual_fn = textobj.a_task_visual,
|
||||||
|
},
|
||||||
|
i_task = {
|
||||||
|
modes = { 'o', 'x' },
|
||||||
|
fn = textobj.i_task,
|
||||||
|
visual_fn = textobj.i_task_visual,
|
||||||
|
},
|
||||||
|
a_category = {
|
||||||
|
modes = { 'o', 'x' },
|
||||||
|
fn = textobj.a_category,
|
||||||
|
visual_fn = textobj.a_category_visual,
|
||||||
|
},
|
||||||
|
i_category = {
|
||||||
|
modes = { 'o', 'x' },
|
||||||
|
fn = textobj.i_category,
|
||||||
|
visual_fn = textobj.i_category_visual,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, spec in pairs(textobjs) do
|
||||||
|
local key = km[name]
|
||||||
|
if key and key ~= false then
|
||||||
|
for _, mode in ipairs(spec.modes) do
|
||||||
|
if mode == 'x' and spec.visual_fn then
|
||||||
|
vim.keymap.set(mode, key --[[@as string]], function()
|
||||||
|
spec.visual_fn(vim.v.count1)
|
||||||
end, opts)
|
end, opts)
|
||||||
|
else
|
||||||
|
vim.keymap.set(mode, key --[[@as string]], function()
|
||||||
|
spec.fn(vim.v.count1)
|
||||||
|
end, opts)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
---@type table<string, fun(count: integer)>
|
||||||
|
local motions = {
|
||||||
|
next_header = textobj.next_header,
|
||||||
|
prev_header = textobj.prev_header,
|
||||||
|
next_task = textobj.next_task,
|
||||||
|
prev_task = textobj.prev_task,
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, fn in pairs(motions) do
|
||||||
|
local key = km[name]
|
||||||
|
if cfg.debug then
|
||||||
|
vim.notify(
|
||||||
|
('[pending] mapping motion %s → %s (buf=%d)'):format(name, key or 'nil', bufnr),
|
||||||
|
vim.log.levels.INFO
|
||||||
|
)
|
||||||
|
end
|
||||||
|
if key and key ~= false then
|
||||||
|
vim.keymap.set({ 'n', 'x', 'o' }, key --[[@as string]], function()
|
||||||
|
fn(vim.v.count1)
|
||||||
|
end, 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)
|
||||||
|
M._recompute_counts()
|
||||||
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()
|
_save_and_notify()
|
||||||
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,9 +292,22 @@ 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()
|
_save_and_notify()
|
||||||
buffer.render(bufnr)
|
buffer.render(bufnr)
|
||||||
for lnum, m in ipairs(buffer.meta()) do
|
for lnum, m in ipairs(buffer.meta()) do
|
||||||
if m.id == id then
|
if m.id == id then
|
||||||
|
|
@ -139,6 +317,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
|
||||||
|
|
@ -159,7 +338,7 @@ function M.toggle_priority()
|
||||||
end
|
end
|
||||||
local new_priority = task.priority > 0 and 0 or 1
|
local new_priority = task.priority > 0 and 0 or 1
|
||||||
store.update(id, { priority = new_priority })
|
store.update(id, { priority = new_priority })
|
||||||
store.save()
|
_save_and_notify()
|
||||||
buffer.render(bufnr)
|
buffer.render(bufnr)
|
||||||
for lnum, m in ipairs(buffer.meta()) do
|
for lnum, m in ipairs(buffer.meta()) do
|
||||||
if m.id == id then
|
if m.id == id then
|
||||||
|
|
@ -169,6 +348,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 +363,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,18 +372,22 @@ 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
|
||||||
store.update(id, { due = due })
|
store.update(id, { due = due })
|
||||||
store.save()
|
_save_and_notify()
|
||||||
buffer.render(bufnr)
|
buffer.render(bufnr)
|
||||||
end)
|
end)
|
||||||
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,8 +403,10 @@ 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()
|
_save_and_notify()
|
||||||
local bufnr = buffer.bufnr()
|
local bufnr = buffer.bufnr()
|
||||||
if bufnr and vim.api.nvim_buf_is_valid(bufnr) then
|
if bufnr and vim.api.nvim_buf_is_valid(bufnr) then
|
||||||
buffer.render(bufnr)
|
buffer.render(bufnr)
|
||||||
|
|
@ -228,16 +414,29 @@ function M.add(text)
|
||||||
vim.notify('Pending added: ' .. description)
|
vim.notify('Pending added: ' .. description)
|
||||||
end
|
end
|
||||||
|
|
||||||
function M.sync()
|
---@param backend_name string
|
||||||
local ok, gcal = pcall(require, 'pending.sync.gcal')
|
---@param action? string
|
||||||
if not ok then
|
---@return nil
|
||||||
vim.notify('Google Calendar sync module not available.', vim.log.levels.ERROR)
|
function M.sync(backend_name, action)
|
||||||
|
if not backend_name or backend_name == '' then
|
||||||
|
vim.notify('Usage: :Pending sync <backend> [action]', vim.log.levels.ERROR)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
gcal.sync()
|
action = (action and action ~= '') and action or 'sync'
|
||||||
|
local ok, backend = pcall(require, 'pending.sync.' .. backend_name)
|
||||||
|
if not ok then
|
||||||
|
vim.notify('Unknown sync backend: ' .. backend_name, vim.log.levels.ERROR)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if type(backend[action]) ~= 'function' then
|
||||||
|
vim.notify(backend_name .. " backend has no '" .. action .. "' action", vim.log.levels.ERROR)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
backend[action]()
|
||||||
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)
|
||||||
|
|
@ -266,7 +465,7 @@ function M.archive(days)
|
||||||
::skip::
|
::skip::
|
||||||
end
|
end
|
||||||
store.replace_tasks(kept)
|
store.replace_tasks(kept)
|
||||||
store.save()
|
_save_and_notify()
|
||||||
vim.notify('Archived ' .. archived .. ' tasks.')
|
vim.notify('Archived ' .. archived .. ' tasks.')
|
||||||
local bufnr = buffer.bufnr()
|
local bufnr = buffer.bufnr()
|
||||||
if bufnr and vim.api.nvim_buf_is_valid(bufnr) then
|
if bufnr and vim.api.nvim_buf_is_valid(bufnr) then
|
||||||
|
|
@ -274,8 +473,8 @@ function M.archive(days)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
---@return nil
|
||||||
function M.due()
|
function M.due()
|
||||||
local today = os.date('%Y-%m-%d') --[[@as string]]
|
|
||||||
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 +482,14 @@ 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 (parse.is_overdue(m.raw_due) or parse.is_today(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 = parse.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 +501,12 @@ 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
|
||||||
local label = task.due < today and '[OVERDUE] ' or '[DUE] '
|
task.status == 'pending'
|
||||||
|
and task.due
|
||||||
|
and (parse.is_overdue(task.due) or parse.is_today(task.due))
|
||||||
|
then
|
||||||
|
local label = parse.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 +525,180 @@ function M.due()
|
||||||
vim.cmd('copen')
|
vim.cmd('copen')
|
||||||
end
|
end
|
||||||
|
|
||||||
function M.show_help()
|
---@param token string
|
||||||
|
---@return string|nil field
|
||||||
|
---@return any value
|
||||||
|
---@return string|nil err
|
||||||
|
local function parse_edit_token(token)
|
||||||
|
local recur = require('pending.recur')
|
||||||
local cfg = require('pending.config').get()
|
local cfg = require('pending.config').get()
|
||||||
local dk = cfg.date_syntax or 'due'
|
local dk = cfg.date_syntax or 'due'
|
||||||
local lines = {
|
local rk = cfg.recur_syntax or 'rec'
|
||||||
'pending.nvim keybindings',
|
|
||||||
'',
|
if token == '+!' then
|
||||||
'<CR> Toggle complete/uncomplete',
|
return 'priority', 1, nil
|
||||||
'<Tab> Switch category/priority view',
|
end
|
||||||
'! Toggle urgent',
|
if token == '-!' then
|
||||||
'D Set due date',
|
return 'priority', 0, nil
|
||||||
'U Undo last write',
|
end
|
||||||
'o / O Add new task line',
|
if token == '-due' or token == '-' .. dk then
|
||||||
'dd Delete task line (on :w)',
|
return 'due', vim.NIL, nil
|
||||||
'p / P Paste (duplicates get new IDs)',
|
end
|
||||||
'zc / zo Fold/unfold category (category view)',
|
if token == '-cat' then
|
||||||
':w Save all changes',
|
return 'category', vim.NIL, nil
|
||||||
'',
|
end
|
||||||
':Pending add <text> Quick-add task',
|
if token == '-rec' or token == '-' .. rk then
|
||||||
':Pending add Cat: <text> Quick-add with category',
|
return 'recur', vim.NIL, nil
|
||||||
':Pending due Show overdue/due qflist',
|
end
|
||||||
':Pending sync Push to Google Calendar',
|
|
||||||
':Pending archive [days] Purge old done tasks',
|
local due_val = token:match('^' .. vim.pesc(dk) .. ':(.+)$')
|
||||||
':Pending undo Undo last write',
|
if due_val then
|
||||||
'',
|
local resolved = parse.resolve_date(due_val)
|
||||||
'Inline metadata (on new lines before :w):',
|
if resolved then
|
||||||
' ' .. dk .. ':YYYY-MM-DD Set due date',
|
return 'due', resolved, nil
|
||||||
' cat:Name Set category',
|
end
|
||||||
'',
|
if
|
||||||
'Due date input:',
|
due_val:match('^%d%d%d%d%-%d%d%-%d%d$') or due_val:match('^%d%d%d%d%-%d%d%-%d%dT%d%d:%d%d$')
|
||||||
' today, tomorrow, +Nd, mon-sun',
|
then
|
||||||
' Empty input clears due date',
|
return 'due', due_val, nil
|
||||||
'',
|
end
|
||||||
'Highlights:',
|
return nil,
|
||||||
' PendingOverdue overdue tasks (red)',
|
nil,
|
||||||
' PendingPriority [!] urgent tasks',
|
'Invalid date: ' .. due_val .. '. Use YYYY-MM-DD, today, tomorrow, +Nd, weekday names, etc.'
|
||||||
'',
|
end
|
||||||
'Press q or <Esc> to close',
|
|
||||||
}
|
local cat_val = token:match('^cat:(.+)$')
|
||||||
local buf = vim.api.nvim_create_buf(false, true)
|
if cat_val then
|
||||||
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
|
return 'category', cat_val, nil
|
||||||
vim.bo[buf].modifiable = false
|
end
|
||||||
vim.bo[buf].bufhidden = 'wipe'
|
|
||||||
local width = 54
|
local rec_val = token:match('^' .. vim.pesc(rk) .. ':(.+)$')
|
||||||
local height = #lines
|
if rec_val then
|
||||||
local win = vim.api.nvim_open_win(buf, true, {
|
local raw_spec = rec_val
|
||||||
relative = 'editor',
|
local rec_mode = nil
|
||||||
width = width,
|
if raw_spec:sub(1, 1) == '!' then
|
||||||
height = height,
|
rec_mode = 'completion'
|
||||||
col = math.floor((vim.o.columns - width) / 2),
|
raw_spec = raw_spec:sub(2)
|
||||||
row = math.floor((vim.o.lines - height) / 2),
|
end
|
||||||
style = 'minimal',
|
if not recur.validate(raw_spec) then
|
||||||
border = 'rounded',
|
return nil, nil, 'Invalid recurrence pattern: ' .. rec_val
|
||||||
})
|
end
|
||||||
vim.keymap.set('n', 'q', function()
|
return 'recur', { spec = raw_spec, mode = rec_mode }, nil
|
||||||
vim.api.nvim_win_close(win, true)
|
end
|
||||||
end, { buffer = buf, silent = true })
|
|
||||||
vim.keymap.set('n', '<Esc>', function()
|
return nil,
|
||||||
vim.api.nvim_win_close(win, true)
|
nil,
|
||||||
end, { buffer = buf, silent = true })
|
'Unknown operation: '
|
||||||
|
.. token
|
||||||
|
.. '. Valid: '
|
||||||
|
.. dk
|
||||||
|
.. ':<date>, cat:<name>, '
|
||||||
|
.. rk
|
||||||
|
.. ':<pattern>, +!, -!, -'
|
||||||
|
.. dk
|
||||||
|
.. ', -cat, -'
|
||||||
|
.. rk
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param id_str string
|
||||||
|
---@param rest string
|
||||||
|
---@return nil
|
||||||
|
function M.edit(id_str, rest)
|
||||||
|
if not id_str or id_str == '' then
|
||||||
|
vim.notify(
|
||||||
|
'Usage: :Pending edit <id> [due:<date>] [cat:<name>] [rec:<pattern>] [+!] [-!] [-due] [-cat] [-rec]',
|
||||||
|
vim.log.levels.ERROR
|
||||||
|
)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local id = tonumber(id_str)
|
||||||
|
if not id then
|
||||||
|
vim.notify('Invalid task ID: ' .. id_str, vim.log.levels.ERROR)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
store.load()
|
||||||
|
local task = store.get(id)
|
||||||
|
if not task then
|
||||||
|
vim.notify('No task with ID ' .. id .. '.', vim.log.levels.ERROR)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if not rest or rest == '' then
|
||||||
|
vim.notify(
|
||||||
|
'Usage: :Pending edit <id> [due:<date>] [cat:<name>] [rec:<pattern>] [+!] [-!] [-due] [-cat] [-rec]',
|
||||||
|
vim.log.levels.ERROR
|
||||||
|
)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local tokens = {}
|
||||||
|
for tok in rest:gmatch('%S+') do
|
||||||
|
table.insert(tokens, tok)
|
||||||
|
end
|
||||||
|
|
||||||
|
local updates = {}
|
||||||
|
local feedback = {}
|
||||||
|
|
||||||
|
for _, tok in ipairs(tokens) do
|
||||||
|
local field, value, err = parse_edit_token(tok)
|
||||||
|
if err then
|
||||||
|
vim.notify(err, vim.log.levels.ERROR)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if field == 'recur' then
|
||||||
|
if value == vim.NIL then
|
||||||
|
updates.recur = vim.NIL
|
||||||
|
updates.recur_mode = vim.NIL
|
||||||
|
table.insert(feedback, 'recurrence removed')
|
||||||
|
else
|
||||||
|
updates.recur = value.spec
|
||||||
|
updates.recur_mode = value.mode
|
||||||
|
table.insert(feedback, 'recurrence set to ' .. value.spec)
|
||||||
|
end
|
||||||
|
elseif field == 'due' then
|
||||||
|
if value == vim.NIL then
|
||||||
|
updates.due = vim.NIL
|
||||||
|
table.insert(feedback, 'due date removed')
|
||||||
|
else
|
||||||
|
updates.due = value
|
||||||
|
table.insert(feedback, 'due date set to ' .. tostring(value))
|
||||||
|
end
|
||||||
|
elseif field == 'category' then
|
||||||
|
if value == vim.NIL then
|
||||||
|
updates.category = vim.NIL
|
||||||
|
table.insert(feedback, 'category removed')
|
||||||
|
else
|
||||||
|
updates.category = value
|
||||||
|
table.insert(feedback, 'category set to ' .. tostring(value))
|
||||||
|
end
|
||||||
|
elseif field == 'priority' then
|
||||||
|
updates.priority = value
|
||||||
|
table.insert(feedback, value == 1 and 'priority added' or 'priority removed')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local snapshot = store.snapshot()
|
||||||
|
local stack = store.undo_stack()
|
||||||
|
table.insert(stack, snapshot)
|
||||||
|
if #stack > UNDO_MAX then
|
||||||
|
table.remove(stack, 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
store.update(id, updates)
|
||||||
|
store.save()
|
||||||
|
|
||||||
|
local bufnr = buffer.bufnr()
|
||||||
|
if bufnr and vim.api.nvim_buf_is_valid(bufnr) then
|
||||||
|
buffer.render(bufnr)
|
||||||
|
end
|
||||||
|
|
||||||
|
vim.notify('Task #' .. id .. ' updated: ' .. table.concat(feedback, ', '))
|
||||||
end
|
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()
|
||||||
|
|
@ -387,8 +707,12 @@ function M.command(args)
|
||||||
local cmd, rest = args:match('^(%S+)%s*(.*)')
|
local cmd, rest = args:match('^(%S+)%s*(.*)')
|
||||||
if cmd == 'add' then
|
if cmd == 'add' then
|
||||||
M.add(rest)
|
M.add(rest)
|
||||||
|
elseif cmd == 'edit' then
|
||||||
|
local id_str, edit_rest = rest:match('^(%S+)%s*(.*)')
|
||||||
|
M.edit(id_str, edit_rest)
|
||||||
elseif cmd == 'sync' then
|
elseif cmd == 'sync' then
|
||||||
M.sync()
|
local backend, action = rest:match('^(%S+)%s*(.*)')
|
||||||
|
M.sync(backend, action)
|
||||||
elseif cmd == 'archive' then
|
elseif cmd == 'archive' then
|
||||||
local d = rest ~= '' and tonumber(rest) or nil
|
local d = rest ~= '' and tonumber(rest) or nil
|
||||||
M.archive(d)
|
M.archive(d)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -165,4 +516,39 @@ function M.command_add(text)
|
||||||
return M.body(text)
|
return M.body(text)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
---@param due string
|
||||||
|
---@return boolean
|
||||||
|
function M.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
|
||||||
|
|
||||||
|
---@param due string
|
||||||
|
---@return boolean
|
||||||
|
function M.is_today(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 false
|
||||||
|
end
|
||||||
|
local current_time = string.format('%02d:%02d', now.hour, now.min)
|
||||||
|
return time_part >= current_time
|
||||||
|
end
|
||||||
|
|
||||||
return M
|
return M
|
||||||
|
|
|
||||||
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,
|
||||||
|
|
@ -258,9 +293,13 @@ function M.update(id, fields)
|
||||||
local now = timestamp()
|
local now = timestamp()
|
||||||
for k, v in pairs(fields) do
|
for k, v in pairs(fields) do
|
||||||
if k ~= 'id' and k ~= 'entry' then
|
if k ~= 'id' and k ~= 'entry' then
|
||||||
|
if v == vim.NIL then
|
||||||
|
task[k] = nil
|
||||||
|
else
|
||||||
task[k] = v
|
task[k] = v
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
task.modified = now
|
task.modified = now
|
||||||
if fields.status == 'done' or fields.status == 'deleted' then
|
if fields.status == 'done' or fields.status == 'deleted' then
|
||||||
task['end'] = task['end'] or now
|
task['end'] = task['end'] or now
|
||||||
|
|
@ -286,6 +325,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 +351,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
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ local store = require('pending.store')
|
||||||
|
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
|
M.name = 'gcal'
|
||||||
|
|
||||||
local BASE_URL = 'https://www.googleapis.com/calendar/v3'
|
local BASE_URL = 'https://www.googleapis.com/calendar/v3'
|
||||||
local TOKEN_URL = 'https://oauth2.googleapis.com/token'
|
local TOKEN_URL = 'https://oauth2.googleapis.com/token'
|
||||||
local AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'
|
local AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'
|
||||||
|
|
@ -22,7 +24,7 @@ local SCOPE = 'https://www.googleapis.com/auth/calendar'
|
||||||
---@return table<string, any>
|
---@return table<string, any>
|
||||||
local function gcal_config()
|
local function gcal_config()
|
||||||
local cfg = config.get()
|
local cfg = config.get()
|
||||||
return cfg.gcal or {}
|
return (cfg.sync and cfg.sync.gcal) or cfg.gcal or {}
|
||||||
end
|
end
|
||||||
|
|
||||||
---@return string
|
---@return string
|
||||||
|
|
@ -199,7 +201,7 @@ local function get_access_token()
|
||||||
end
|
end
|
||||||
local tokens = load_tokens()
|
local tokens = load_tokens()
|
||||||
if not tokens or not tokens.refresh_token then
|
if not tokens or not tokens.refresh_token then
|
||||||
M.authorize()
|
M.auth()
|
||||||
tokens = load_tokens()
|
tokens = load_tokens()
|
||||||
if not tokens then
|
if not tokens then
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -218,7 +220,7 @@ local function get_access_token()
|
||||||
return tokens.access_token
|
return tokens.access_token
|
||||||
end
|
end
|
||||||
|
|
||||||
function M.authorize()
|
function M.auth()
|
||||||
local creds = load_credentials()
|
local creds = load_credentials()
|
||||||
if not creds then
|
if not creds then
|
||||||
vim.notify(
|
vim.notify(
|
||||||
|
|
@ -503,6 +505,7 @@ function M.sync()
|
||||||
end
|
end
|
||||||
|
|
||||||
store.save()
|
store.save()
|
||||||
|
require('pending')._recompute_counts()
|
||||||
vim.notify(
|
vim.notify(
|
||||||
string.format(
|
string.format(
|
||||||
'pending.nvim: Synced to Google Calendar (created: %d, updated: %d, deleted: %d)',
|
'pending.nvim: Synced to Google Calendar (created: %d, updated: %d, deleted: %d)',
|
||||||
|
|
@ -513,4 +516,18 @@ function M.sync()
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
---@return nil
|
||||||
|
function M.health()
|
||||||
|
if vim.fn.executable('curl') == 1 then
|
||||||
|
vim.health.ok('curl found (required for gcal sync)')
|
||||||
|
else
|
||||||
|
vim.health.warn('curl not found (needed for gcal sync)')
|
||||||
|
end
|
||||||
|
if vim.fn.executable('openssl') == 1 then
|
||||||
|
vim.health.ok('openssl found (required for gcal OAuth PKCE)')
|
||||||
|
else
|
||||||
|
vim.health.warn('openssl not found (needed for gcal OAuth)')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
return M
|
return M
|
||||||
|
|
|
||||||
384
lua/pending/textobj.lua
Normal file
384
lua/pending/textobj.lua
Normal file
|
|
@ -0,0 +1,384 @@
|
||||||
|
local buffer = require('pending.buffer')
|
||||||
|
local config = require('pending.config')
|
||||||
|
|
||||||
|
---@class pending.textobj
|
||||||
|
local M = {}
|
||||||
|
|
||||||
|
---@param ... any
|
||||||
|
---@return nil
|
||||||
|
local function dbg(...)
|
||||||
|
if config.get().debug then
|
||||||
|
vim.notify('[pending.textobj] ' .. string.format(...), vim.log.levels.INFO)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param lnum integer
|
||||||
|
---@param meta pending.LineMeta[]
|
||||||
|
---@return string
|
||||||
|
local function get_line_from_buf(lnum, meta)
|
||||||
|
local _ = meta
|
||||||
|
local bufnr = buffer.bufnr()
|
||||||
|
if not bufnr then
|
||||||
|
return ''
|
||||||
|
end
|
||||||
|
local lines = vim.api.nvim_buf_get_lines(bufnr, lnum - 1, lnum, false)
|
||||||
|
return lines[1] or ''
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param line string
|
||||||
|
---@return integer start_col
|
||||||
|
---@return integer end_col
|
||||||
|
function M.inner_task_range(line)
|
||||||
|
local prefix_end = line:find('/') and select(2, line:find('^/%d+/%- %[.%] '))
|
||||||
|
if not prefix_end then
|
||||||
|
prefix_end = select(2, line:find('^%- %[.%] ')) or 0
|
||||||
|
end
|
||||||
|
local start_col = prefix_end + 1
|
||||||
|
|
||||||
|
local dk = config.get().date_syntax or 'due'
|
||||||
|
local rk = config.get().recur_syntax or 'rec'
|
||||||
|
local dk_pat = '^' .. vim.pesc(dk) .. ':%S+$'
|
||||||
|
local rk_pat = '^' .. vim.pesc(rk) .. ':%S+$'
|
||||||
|
|
||||||
|
local rest = line:sub(start_col)
|
||||||
|
local words = {}
|
||||||
|
for word in rest:gmatch('%S+') do
|
||||||
|
table.insert(words, word)
|
||||||
|
end
|
||||||
|
|
||||||
|
local i = #words
|
||||||
|
while i >= 1 do
|
||||||
|
local word = words[i]
|
||||||
|
if word:match(dk_pat) or word:match('^cat:%S+$') or word:match(rk_pat) then
|
||||||
|
i = i - 1
|
||||||
|
else
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if i < 1 then
|
||||||
|
return start_col, start_col
|
||||||
|
end
|
||||||
|
|
||||||
|
local desc = table.concat(words, ' ', 1, i)
|
||||||
|
local end_col = start_col + #desc - 1
|
||||||
|
return start_col, end_col
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param row integer
|
||||||
|
---@param meta pending.LineMeta[]
|
||||||
|
---@return integer? header_row
|
||||||
|
---@return integer? last_row
|
||||||
|
function M.category_bounds(row, meta)
|
||||||
|
if not meta or #meta == 0 then
|
||||||
|
return nil, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local header_row = nil
|
||||||
|
local m = meta[row]
|
||||||
|
if not m then
|
||||||
|
return nil, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
if m.type == 'header' then
|
||||||
|
header_row = row
|
||||||
|
else
|
||||||
|
for r = row, 1, -1 do
|
||||||
|
if meta[r] and meta[r].type == 'header' then
|
||||||
|
header_row = r
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if not header_row then
|
||||||
|
return nil, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local last_row = header_row
|
||||||
|
local total = #meta
|
||||||
|
for r = header_row + 1, total do
|
||||||
|
if meta[r].type == 'header' then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
last_row = r
|
||||||
|
end
|
||||||
|
|
||||||
|
return header_row, last_row
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param count integer
|
||||||
|
---@return nil
|
||||||
|
function M.a_task(count)
|
||||||
|
local meta = buffer.meta()
|
||||||
|
if not meta or #meta == 0 then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local row = vim.api.nvim_win_get_cursor(0)[1]
|
||||||
|
local m = meta[row]
|
||||||
|
if not m or m.type ~= 'task' then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local start_row = row
|
||||||
|
local end_row = row
|
||||||
|
count = math.max(1, count)
|
||||||
|
for _ = 2, count do
|
||||||
|
local next_row = end_row + 1
|
||||||
|
if next_row > #meta then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
if meta[next_row] and meta[next_row].type == 'task' then
|
||||||
|
end_row = next_row
|
||||||
|
else
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
vim.cmd('normal! ' .. start_row .. 'GV' .. end_row .. 'G')
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param count integer
|
||||||
|
---@return nil
|
||||||
|
function M.a_task_visual(count)
|
||||||
|
vim.cmd('normal! \27')
|
||||||
|
M.a_task(count)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param count integer
|
||||||
|
---@return nil
|
||||||
|
function M.i_task(count)
|
||||||
|
local _ = count
|
||||||
|
local meta = buffer.meta()
|
||||||
|
if not meta or #meta == 0 then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local row = vim.api.nvim_win_get_cursor(0)[1]
|
||||||
|
local m = meta[row]
|
||||||
|
if not m or m.type ~= 'task' then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local line = get_line_from_buf(row, meta)
|
||||||
|
local start_col, end_col = M.inner_task_range(line)
|
||||||
|
if start_col > end_col then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
vim.api.nvim_win_set_cursor(0, { row, start_col - 1 })
|
||||||
|
vim.cmd('normal! v')
|
||||||
|
vim.api.nvim_win_set_cursor(0, { row, end_col - 1 })
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param count integer
|
||||||
|
---@return nil
|
||||||
|
function M.i_task_visual(count)
|
||||||
|
vim.cmd('normal! \27')
|
||||||
|
M.i_task(count)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param count integer
|
||||||
|
---@return nil
|
||||||
|
function M.a_category(count)
|
||||||
|
local _ = count
|
||||||
|
local meta = buffer.meta()
|
||||||
|
if not meta or #meta == 0 then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local view = buffer.current_view_name()
|
||||||
|
if view == 'priority' then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local row = vim.api.nvim_win_get_cursor(0)[1]
|
||||||
|
local header_row, last_row = M.category_bounds(row, meta)
|
||||||
|
if not header_row or not last_row then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local start_row = header_row
|
||||||
|
if header_row > 1 and meta[header_row - 1] and meta[header_row - 1].type == 'blank' then
|
||||||
|
start_row = header_row - 1
|
||||||
|
end
|
||||||
|
local end_row = last_row
|
||||||
|
if last_row < #meta and meta[last_row + 1] and meta[last_row + 1].type == 'blank' then
|
||||||
|
end_row = last_row + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
vim.cmd('normal! ' .. start_row .. 'GV' .. end_row .. 'G')
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param count integer
|
||||||
|
---@return nil
|
||||||
|
function M.a_category_visual(count)
|
||||||
|
vim.cmd('normal! \27')
|
||||||
|
M.a_category(count)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param count integer
|
||||||
|
---@return nil
|
||||||
|
function M.i_category(count)
|
||||||
|
local _ = count
|
||||||
|
local meta = buffer.meta()
|
||||||
|
if not meta or #meta == 0 then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local view = buffer.current_view_name()
|
||||||
|
if view == 'priority' then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local row = vim.api.nvim_win_get_cursor(0)[1]
|
||||||
|
local header_row, last_row = M.category_bounds(row, meta)
|
||||||
|
if not header_row or not last_row then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local first_task = nil
|
||||||
|
local last_task = nil
|
||||||
|
for r = header_row + 1, last_row do
|
||||||
|
if meta[r] and meta[r].type == 'task' then
|
||||||
|
if not first_task then
|
||||||
|
first_task = r
|
||||||
|
end
|
||||||
|
last_task = r
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if not first_task or not last_task then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
vim.cmd('normal! ' .. first_task .. 'GV' .. last_task .. 'G')
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param count integer
|
||||||
|
---@return nil
|
||||||
|
function M.i_category_visual(count)
|
||||||
|
vim.cmd('normal! \27')
|
||||||
|
M.i_category(count)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param count integer
|
||||||
|
---@return nil
|
||||||
|
function M.next_header(count)
|
||||||
|
local meta = buffer.meta()
|
||||||
|
if not meta or #meta == 0 then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local view = buffer.current_view_name()
|
||||||
|
if view == 'priority' then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local row = vim.api.nvim_win_get_cursor(0)[1]
|
||||||
|
dbg('next_header: cursor=%d, meta_len=%d, view=%s', row, #meta, view or 'nil')
|
||||||
|
local found = 0
|
||||||
|
count = math.max(1, count)
|
||||||
|
for r = row + 1, #meta do
|
||||||
|
if meta[r] and meta[r].type == 'header' then
|
||||||
|
found = found + 1
|
||||||
|
dbg(
|
||||||
|
'next_header: found header at row=%d, cat=%s, found=%d/%d',
|
||||||
|
r,
|
||||||
|
meta[r].category or '?',
|
||||||
|
found,
|
||||||
|
count
|
||||||
|
)
|
||||||
|
if found == count then
|
||||||
|
vim.api.nvim_win_set_cursor(0, { r, 0 })
|
||||||
|
dbg('next_header: cursor set to row=%d, actual=%d', r, vim.api.nvim_win_get_cursor(0)[1])
|
||||||
|
return
|
||||||
|
end
|
||||||
|
else
|
||||||
|
dbg('next_header: row=%d type=%s', r, meta[r] and meta[r].type or 'nil')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
dbg('next_header: no header found after row=%d', row)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param count integer
|
||||||
|
---@return nil
|
||||||
|
function M.prev_header(count)
|
||||||
|
local meta = buffer.meta()
|
||||||
|
if not meta or #meta == 0 then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local view = buffer.current_view_name()
|
||||||
|
if view == 'priority' then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local row = vim.api.nvim_win_get_cursor(0)[1]
|
||||||
|
dbg('prev_header: cursor=%d, meta_len=%d', row, #meta)
|
||||||
|
local found = 0
|
||||||
|
count = math.max(1, count)
|
||||||
|
for r = row - 1, 1, -1 do
|
||||||
|
if meta[r] and meta[r].type == 'header' then
|
||||||
|
found = found + 1
|
||||||
|
dbg(
|
||||||
|
'prev_header: found header at row=%d, cat=%s, found=%d/%d',
|
||||||
|
r,
|
||||||
|
meta[r].category or '?',
|
||||||
|
found,
|
||||||
|
count
|
||||||
|
)
|
||||||
|
if found == count then
|
||||||
|
vim.api.nvim_win_set_cursor(0, { r, 0 })
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param count integer
|
||||||
|
---@return nil
|
||||||
|
function M.next_task(count)
|
||||||
|
local meta = buffer.meta()
|
||||||
|
if not meta or #meta == 0 then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local row = vim.api.nvim_win_get_cursor(0)[1]
|
||||||
|
dbg('next_task: cursor=%d, meta_len=%d', row, #meta)
|
||||||
|
local found = 0
|
||||||
|
count = math.max(1, count)
|
||||||
|
for r = row + 1, #meta do
|
||||||
|
if meta[r] and meta[r].type == 'task' then
|
||||||
|
found = found + 1
|
||||||
|
if found == count then
|
||||||
|
dbg('next_task: jumping to row=%d', r)
|
||||||
|
vim.api.nvim_win_set_cursor(0, { r, 0 })
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
dbg('next_task: no task found after row=%d', row)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param count integer
|
||||||
|
---@return nil
|
||||||
|
function M.prev_task(count)
|
||||||
|
local meta = buffer.meta()
|
||||||
|
if not meta or #meta == 0 then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local row = vim.api.nvim_win_get_cursor(0)[1]
|
||||||
|
dbg('prev_task: cursor=%d, meta_len=%d', row, #meta)
|
||||||
|
local found = 0
|
||||||
|
count = math.max(1, count)
|
||||||
|
for r = row - 1, 1, -1 do
|
||||||
|
if meta[r] and meta[r].type == 'task' then
|
||||||
|
found = found + 1
|
||||||
|
if found == count then
|
||||||
|
dbg('prev_task: jumping to row=%d', r)
|
||||||
|
vim.api.nvim_win_set_cursor(0, { r, 0 })
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
dbg('prev_task: no task found before row=%d', row)
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
local config = require('pending.config')
|
local config = require('pending.config')
|
||||||
|
local parse = require('pending.parse')
|
||||||
|
|
||||||
---@class pending.LineMeta
|
---@class pending.LineMeta
|
||||||
---@field type 'task'|'header'|'blank'
|
---@field type 'task'|'header'|'blank'
|
||||||
|
|
@ -10,6 +11,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 +22,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 +34,11 @@ 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
|
end
|
||||||
|
|
||||||
---@param tasks pending.Task[]
|
---@param tasks pending.Task[]
|
||||||
|
|
@ -73,7 +82,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 +156,9 @@ 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 parse.is_overdue(task.due)
|
||||||
|
or nil,
|
||||||
|
recur = task.recur,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -160,7 +170,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 +207,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 parse.is_overdue(task.due) or nil,
|
||||||
show_category = true,
|
show_category = true,
|
||||||
|
recur = task.recur,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,201 @@ if vim.g.loaded_pending then
|
||||||
end
|
end
|
||||||
vim.g.loaded_pending = true
|
vim.g.loaded_pending = true
|
||||||
|
|
||||||
|
---@return string[]
|
||||||
|
local function edit_field_candidates()
|
||||||
|
local cfg = require('pending.config').get()
|
||||||
|
local dk = cfg.date_syntax or 'due'
|
||||||
|
local rk = cfg.recur_syntax or 'rec'
|
||||||
|
return {
|
||||||
|
dk .. ':',
|
||||||
|
'cat:',
|
||||||
|
rk .. ':',
|
||||||
|
'+!',
|
||||||
|
'-!',
|
||||||
|
'-' .. dk,
|
||||||
|
'-cat',
|
||||||
|
'-' .. rk,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
---@return string[]
|
||||||
|
local function edit_date_values()
|
||||||
|
return {
|
||||||
|
'today',
|
||||||
|
'tomorrow',
|
||||||
|
'yesterday',
|
||||||
|
'+1d',
|
||||||
|
'+2d',
|
||||||
|
'+3d',
|
||||||
|
'+1w',
|
||||||
|
'+2w',
|
||||||
|
'+1m',
|
||||||
|
'mon',
|
||||||
|
'tue',
|
||||||
|
'wed',
|
||||||
|
'thu',
|
||||||
|
'fri',
|
||||||
|
'sat',
|
||||||
|
'sun',
|
||||||
|
'eod',
|
||||||
|
'eow',
|
||||||
|
'eom',
|
||||||
|
'eoq',
|
||||||
|
'eoy',
|
||||||
|
'sow',
|
||||||
|
'som',
|
||||||
|
'soq',
|
||||||
|
'soy',
|
||||||
|
'later',
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
---@return string[]
|
||||||
|
local function edit_recur_values()
|
||||||
|
local ok, recur = pcall(require, 'pending.recur')
|
||||||
|
if not ok then
|
||||||
|
return {}
|
||||||
|
end
|
||||||
|
local result = {}
|
||||||
|
for _, s in ipairs(recur.shorthand_list()) do
|
||||||
|
table.insert(result, s)
|
||||||
|
end
|
||||||
|
for _, s in ipairs(recur.shorthand_list()) do
|
||||||
|
table.insert(result, '!' .. s)
|
||||||
|
end
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param lead string
|
||||||
|
---@param candidates string[]
|
||||||
|
---@return string[]
|
||||||
|
local function filter_candidates(lead, candidates)
|
||||||
|
return vim.tbl_filter(function(s)
|
||||||
|
return s:find(lead, 1, true) == 1
|
||||||
|
end, candidates)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param arg_lead string
|
||||||
|
---@param cmd_line string
|
||||||
|
---@return string[]
|
||||||
|
local function complete_edit(arg_lead, cmd_line)
|
||||||
|
local cfg = require('pending.config').get()
|
||||||
|
local dk = cfg.date_syntax or 'due'
|
||||||
|
local rk = cfg.recur_syntax or 'rec'
|
||||||
|
|
||||||
|
local after_edit = cmd_line:match('^Pending%s+edit%s+(.*)')
|
||||||
|
if not after_edit then
|
||||||
|
return {}
|
||||||
|
end
|
||||||
|
|
||||||
|
local parts = {}
|
||||||
|
for part in after_edit:gmatch('%S+') do
|
||||||
|
table.insert(parts, part)
|
||||||
|
end
|
||||||
|
|
||||||
|
local trailing_space = after_edit:match('%s$')
|
||||||
|
if #parts == 0 or (#parts == 1 and not trailing_space) then
|
||||||
|
local store = require('pending.store')
|
||||||
|
store.load()
|
||||||
|
local ids = {}
|
||||||
|
for _, task in ipairs(store.active_tasks()) do
|
||||||
|
table.insert(ids, tostring(task.id))
|
||||||
|
end
|
||||||
|
return filter_candidates(arg_lead, ids)
|
||||||
|
end
|
||||||
|
|
||||||
|
local prefix = arg_lead:match('^(' .. vim.pesc(dk) .. ':)(.*)$')
|
||||||
|
if prefix then
|
||||||
|
local after_colon = arg_lead:sub(#prefix + 1)
|
||||||
|
local dates = edit_date_values()
|
||||||
|
local result = {}
|
||||||
|
for _, d in ipairs(dates) do
|
||||||
|
if d:find(after_colon, 1, true) == 1 then
|
||||||
|
table.insert(result, prefix .. d)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
|
||||||
|
local rec_prefix = arg_lead:match('^(' .. vim.pesc(rk) .. ':)(.*)$')
|
||||||
|
if rec_prefix then
|
||||||
|
local after_colon = arg_lead:sub(#rec_prefix + 1)
|
||||||
|
local pats = edit_recur_values()
|
||||||
|
local result = {}
|
||||||
|
for _, p in ipairs(pats) do
|
||||||
|
if p:find(after_colon, 1, true) == 1 then
|
||||||
|
table.insert(result, rec_prefix .. p)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
|
||||||
|
local cat_prefix = arg_lead:match('^(cat:)(.*)$')
|
||||||
|
if cat_prefix then
|
||||||
|
local after_colon = arg_lead:sub(#cat_prefix + 1)
|
||||||
|
local store = require('pending.store')
|
||||||
|
store.load()
|
||||||
|
local seen = {}
|
||||||
|
local cats = {}
|
||||||
|
for _, task in ipairs(store.active_tasks()) do
|
||||||
|
if task.category and not seen[task.category] then
|
||||||
|
seen[task.category] = true
|
||||||
|
table.insert(cats, task.category)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.sort(cats)
|
||||||
|
local result = {}
|
||||||
|
for _, c in ipairs(cats) do
|
||||||
|
if c:find(after_colon, 1, true) == 1 then
|
||||||
|
table.insert(result, cat_prefix .. c)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
|
||||||
|
return filter_candidates(arg_lead, edit_field_candidates())
|
||||||
|
end
|
||||||
|
|
||||||
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', 'archive', 'due', 'edit', 'sync', 'undo' }
|
||||||
if not cmd_line:match('^Pending%s+%S') then
|
if not cmd_line:match('^Pending%s+%S') then
|
||||||
return vim.tbl_filter(function(s)
|
return filter_candidates(arg_lead, subcmds)
|
||||||
return s:find(arg_lead, 1, true) == 1
|
end
|
||||||
end, subcmds)
|
if cmd_line:match('^Pending%s+edit') then
|
||||||
|
return complete_edit(arg_lead, cmd_line)
|
||||||
|
end
|
||||||
|
if cmd_line:match('^Pending%s+sync') then
|
||||||
|
local after_sync = cmd_line:match('^Pending%s+sync%s+(.*)')
|
||||||
|
if not after_sync then
|
||||||
|
return {}
|
||||||
|
end
|
||||||
|
local parts = {}
|
||||||
|
for part in after_sync:gmatch('%S+') do
|
||||||
|
table.insert(parts, part)
|
||||||
|
end
|
||||||
|
local trailing_space = after_sync:match('%s$')
|
||||||
|
if #parts == 0 or (#parts == 1 and not trailing_space) then
|
||||||
|
local backends = {}
|
||||||
|
local pattern = vim.fn.globpath(vim.o.runtimepath, 'lua/pending/sync/*.lua', false, true)
|
||||||
|
for _, path in ipairs(pattern) do
|
||||||
|
local name = vim.fn.fnamemodify(path, ':t:r')
|
||||||
|
table.insert(backends, name)
|
||||||
|
end
|
||||||
|
table.sort(backends)
|
||||||
|
return filter_candidates(arg_lead, backends)
|
||||||
|
end
|
||||||
|
if #parts == 1 and trailing_space then
|
||||||
|
return filter_candidates(arg_lead, { 'auth', 'sync' })
|
||||||
|
end
|
||||||
|
if #parts >= 2 and not trailing_space then
|
||||||
|
return filter_candidates(arg_lead, { 'auth', 'sync' })
|
||||||
|
end
|
||||||
|
return {}
|
||||||
end
|
end
|
||||||
return {}
|
return {}
|
||||||
end,
|
end,
|
||||||
|
|
@ -22,6 +207,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 +226,47 @@ 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)
|
||||||
|
|
||||||
|
vim.keymap.set({ 'o', 'x' }, '<Plug>(pending-a-task)', function()
|
||||||
|
require('pending.textobj').a_task(vim.v.count1)
|
||||||
|
end)
|
||||||
|
|
||||||
|
vim.keymap.set({ 'o', 'x' }, '<Plug>(pending-i-task)', function()
|
||||||
|
require('pending.textobj').i_task(vim.v.count1)
|
||||||
|
end)
|
||||||
|
|
||||||
|
vim.keymap.set({ 'o', 'x' }, '<Plug>(pending-a-category)', function()
|
||||||
|
require('pending.textobj').a_category(vim.v.count1)
|
||||||
|
end)
|
||||||
|
|
||||||
|
vim.keymap.set({ 'o', 'x' }, '<Plug>(pending-i-category)', function()
|
||||||
|
require('pending.textobj').i_category(vim.v.count1)
|
||||||
|
end)
|
||||||
|
|
||||||
|
vim.keymap.set({ 'n', 'x', 'o' }, '<Plug>(pending-next-header)', function()
|
||||||
|
require('pending.textobj').next_header(vim.v.count1)
|
||||||
|
end)
|
||||||
|
|
||||||
|
vim.keymap.set({ 'n', 'x', 'o' }, '<Plug>(pending-prev-header)', function()
|
||||||
|
require('pending.textobj').prev_header(vim.v.count1)
|
||||||
|
end)
|
||||||
|
|
||||||
|
vim.keymap.set({ 'n', 'x', 'o' }, '<Plug>(pending-next-task)', function()
|
||||||
|
require('pending.textobj').next_task(vim.v.count1)
|
||||||
|
end)
|
||||||
|
|
||||||
|
vim.keymap.set({ 'n', 'x', 'o' }, '<Plug>(pending-prev-task)', function()
|
||||||
|
require('pending.textobj').prev_task(vim.v.count1)
|
||||||
|
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()
|
||||||
|
|
|
||||||
304
spec/edit_spec.lua
Normal file
304
spec/edit_spec.lua
Normal file
|
|
@ -0,0 +1,304 @@
|
||||||
|
require('spec.helpers')
|
||||||
|
|
||||||
|
local config = require('pending.config')
|
||||||
|
local store = require('pending.store')
|
||||||
|
|
||||||
|
describe('edit', function()
|
||||||
|
local tmpdir
|
||||||
|
local pending = require('pending')
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
it('sets due date with resolve_date vocabulary', function()
|
||||||
|
local t = store.add({ description = 'Task one' })
|
||||||
|
store.save()
|
||||||
|
pending.edit(tostring(t.id), 'due:tomorrow')
|
||||||
|
local updated = store.get(t.id)
|
||||||
|
local today = os.date('*t') --[[@as osdate]]
|
||||||
|
local expected =
|
||||||
|
os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day + 1 }))
|
||||||
|
assert.are.equal(expected, updated.due)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('sets due date with literal YYYY-MM-DD', function()
|
||||||
|
local t = store.add({ description = 'Task one' })
|
||||||
|
store.save()
|
||||||
|
pending.edit(tostring(t.id), 'due:2026-06-15')
|
||||||
|
local updated = store.get(t.id)
|
||||||
|
assert.are.equal('2026-06-15', updated.due)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('sets category', function()
|
||||||
|
local t = store.add({ description = 'Task one' })
|
||||||
|
store.save()
|
||||||
|
pending.edit(tostring(t.id), 'cat:Work')
|
||||||
|
local updated = store.get(t.id)
|
||||||
|
assert.are.equal('Work', updated.category)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('adds priority', function()
|
||||||
|
local t = store.add({ description = 'Task one' })
|
||||||
|
store.save()
|
||||||
|
pending.edit(tostring(t.id), '+!')
|
||||||
|
local updated = store.get(t.id)
|
||||||
|
assert.are.equal(1, updated.priority)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('removes priority', function()
|
||||||
|
local t = store.add({ description = 'Task one', priority = 1 })
|
||||||
|
store.save()
|
||||||
|
pending.edit(tostring(t.id), '-!')
|
||||||
|
local updated = store.get(t.id)
|
||||||
|
assert.are.equal(0, updated.priority)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('removes due date', function()
|
||||||
|
local t = store.add({ description = 'Task one', due = '2026-06-15' })
|
||||||
|
store.save()
|
||||||
|
pending.edit(tostring(t.id), '-due')
|
||||||
|
local updated = store.get(t.id)
|
||||||
|
assert.is_nil(updated.due)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('removes category', function()
|
||||||
|
local t = store.add({ description = 'Task one', category = 'Work' })
|
||||||
|
store.save()
|
||||||
|
pending.edit(tostring(t.id), '-cat')
|
||||||
|
local updated = store.get(t.id)
|
||||||
|
assert.is_nil(updated.category)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('sets recurrence', function()
|
||||||
|
local t = store.add({ description = 'Task one' })
|
||||||
|
store.save()
|
||||||
|
pending.edit(tostring(t.id), 'rec:weekly')
|
||||||
|
local updated = store.get(t.id)
|
||||||
|
assert.are.equal('weekly', updated.recur)
|
||||||
|
assert.is_nil(updated.recur_mode)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('sets completion-based recurrence', function()
|
||||||
|
local t = store.add({ description = 'Task one' })
|
||||||
|
store.save()
|
||||||
|
pending.edit(tostring(t.id), 'rec:!daily')
|
||||||
|
local updated = store.get(t.id)
|
||||||
|
assert.are.equal('daily', updated.recur)
|
||||||
|
assert.are.equal('completion', updated.recur_mode)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('removes recurrence', function()
|
||||||
|
local t = store.add({ description = 'Task one', recur = 'weekly', recur_mode = 'scheduled' })
|
||||||
|
store.save()
|
||||||
|
pending.edit(tostring(t.id), '-rec')
|
||||||
|
local updated = store.get(t.id)
|
||||||
|
assert.is_nil(updated.recur)
|
||||||
|
assert.is_nil(updated.recur_mode)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('applies multiple operations at once', function()
|
||||||
|
local t = store.add({ description = 'Task one' })
|
||||||
|
store.save()
|
||||||
|
pending.edit(tostring(t.id), 'due:today cat:Errands +!')
|
||||||
|
local updated = store.get(t.id)
|
||||||
|
assert.are.equal(os.date('%Y-%m-%d'), updated.due)
|
||||||
|
assert.are.equal('Errands', updated.category)
|
||||||
|
assert.are.equal(1, updated.priority)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('pushes to undo stack', function()
|
||||||
|
local t = store.add({ description = 'Task one' })
|
||||||
|
store.save()
|
||||||
|
local stack_before = #store.undo_stack()
|
||||||
|
pending.edit(tostring(t.id), 'cat:Work')
|
||||||
|
assert.are.equal(stack_before + 1, #store.undo_stack())
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('persists changes to disk', function()
|
||||||
|
local t = store.add({ description = 'Task one' })
|
||||||
|
store.save()
|
||||||
|
pending.edit(tostring(t.id), 'cat:Work')
|
||||||
|
store.unload()
|
||||||
|
store.load()
|
||||||
|
local updated = store.get(t.id)
|
||||||
|
assert.are.equal('Work', updated.category)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('errors on unknown task ID', function()
|
||||||
|
store.add({ description = 'Task one' })
|
||||||
|
store.save()
|
||||||
|
local messages = {}
|
||||||
|
local orig_notify = vim.notify
|
||||||
|
vim.notify = function(msg, level)
|
||||||
|
table.insert(messages, { msg = msg, level = level })
|
||||||
|
end
|
||||||
|
pending.edit('999', 'cat:Work')
|
||||||
|
vim.notify = orig_notify
|
||||||
|
assert.are.equal(1, #messages)
|
||||||
|
assert.truthy(messages[1].msg:find('No task with ID 999'))
|
||||||
|
assert.are.equal(vim.log.levels.ERROR, messages[1].level)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('errors on invalid date', function()
|
||||||
|
local t = store.add({ description = 'Task one' })
|
||||||
|
store.save()
|
||||||
|
local messages = {}
|
||||||
|
local orig_notify = vim.notify
|
||||||
|
vim.notify = function(msg, level)
|
||||||
|
table.insert(messages, { msg = msg, level = level })
|
||||||
|
end
|
||||||
|
pending.edit(tostring(t.id), 'due:notadate')
|
||||||
|
vim.notify = orig_notify
|
||||||
|
assert.are.equal(1, #messages)
|
||||||
|
assert.truthy(messages[1].msg:find('Invalid date'))
|
||||||
|
assert.are.equal(vim.log.levels.ERROR, messages[1].level)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('errors on unknown operation token', function()
|
||||||
|
local t = store.add({ description = 'Task one' })
|
||||||
|
store.save()
|
||||||
|
local messages = {}
|
||||||
|
local orig_notify = vim.notify
|
||||||
|
vim.notify = function(msg, level)
|
||||||
|
table.insert(messages, { msg = msg, level = level })
|
||||||
|
end
|
||||||
|
pending.edit(tostring(t.id), 'bogus')
|
||||||
|
vim.notify = orig_notify
|
||||||
|
assert.are.equal(1, #messages)
|
||||||
|
assert.truthy(messages[1].msg:find('Unknown operation'))
|
||||||
|
assert.are.equal(vim.log.levels.ERROR, messages[1].level)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('errors on invalid recurrence pattern', function()
|
||||||
|
local t = store.add({ description = 'Task one' })
|
||||||
|
store.save()
|
||||||
|
local messages = {}
|
||||||
|
local orig_notify = vim.notify
|
||||||
|
vim.notify = function(msg, level)
|
||||||
|
table.insert(messages, { msg = msg, level = level })
|
||||||
|
end
|
||||||
|
pending.edit(tostring(t.id), 'rec:nope')
|
||||||
|
vim.notify = orig_notify
|
||||||
|
assert.are.equal(1, #messages)
|
||||||
|
assert.truthy(messages[1].msg:find('Invalid recurrence'))
|
||||||
|
assert.are.equal(vim.log.levels.ERROR, messages[1].level)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('errors when no operations given', function()
|
||||||
|
local t = store.add({ description = 'Task one' })
|
||||||
|
store.save()
|
||||||
|
local messages = {}
|
||||||
|
local orig_notify = vim.notify
|
||||||
|
vim.notify = function(msg, level)
|
||||||
|
table.insert(messages, { msg = msg, level = level })
|
||||||
|
end
|
||||||
|
pending.edit(tostring(t.id), '')
|
||||||
|
vim.notify = orig_notify
|
||||||
|
assert.are.equal(1, #messages)
|
||||||
|
assert.truthy(messages[1].msg:find('Usage'))
|
||||||
|
assert.are.equal(vim.log.levels.ERROR, messages[1].level)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('errors when no id given', function()
|
||||||
|
local messages = {}
|
||||||
|
local orig_notify = vim.notify
|
||||||
|
vim.notify = function(msg, level)
|
||||||
|
table.insert(messages, { msg = msg, level = level })
|
||||||
|
end
|
||||||
|
pending.edit('', '')
|
||||||
|
vim.notify = orig_notify
|
||||||
|
assert.are.equal(1, #messages)
|
||||||
|
assert.truthy(messages[1].msg:find('Usage'))
|
||||||
|
assert.are.equal(vim.log.levels.ERROR, messages[1].level)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('errors on non-numeric id', function()
|
||||||
|
local messages = {}
|
||||||
|
local orig_notify = vim.notify
|
||||||
|
vim.notify = function(msg, level)
|
||||||
|
table.insert(messages, { msg = msg, level = level })
|
||||||
|
end
|
||||||
|
pending.edit('abc', 'cat:Work')
|
||||||
|
vim.notify = orig_notify
|
||||||
|
assert.are.equal(1, #messages)
|
||||||
|
assert.truthy(messages[1].msg:find('Invalid task ID'))
|
||||||
|
assert.are.equal(vim.log.levels.ERROR, messages[1].level)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('shows feedback message on success', function()
|
||||||
|
local t = store.add({ description = 'Task one' })
|
||||||
|
store.save()
|
||||||
|
local messages = {}
|
||||||
|
local orig_notify = vim.notify
|
||||||
|
vim.notify = function(msg, level)
|
||||||
|
table.insert(messages, { msg = msg, level = level })
|
||||||
|
end
|
||||||
|
pending.edit(tostring(t.id), 'cat:Work')
|
||||||
|
vim.notify = orig_notify
|
||||||
|
assert.are.equal(1, #messages)
|
||||||
|
assert.truthy(messages[1].msg:find('Task #' .. t.id .. ' updated'))
|
||||||
|
assert.truthy(messages[1].msg:find('category set to Work'))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('respects custom date_syntax', function()
|
||||||
|
vim.g.pending = { data_path = tmpdir .. '/tasks.json', date_syntax = 'by' }
|
||||||
|
config.reset()
|
||||||
|
store.unload()
|
||||||
|
store.load()
|
||||||
|
local t = store.add({ description = 'Task one' })
|
||||||
|
store.save()
|
||||||
|
pending.edit(tostring(t.id), 'by:tomorrow')
|
||||||
|
local updated = store.get(t.id)
|
||||||
|
local today = os.date('*t') --[[@as osdate]]
|
||||||
|
local expected =
|
||||||
|
os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day + 1 }))
|
||||||
|
assert.are.equal(expected, updated.due)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('respects custom recur_syntax', function()
|
||||||
|
vim.g.pending = { data_path = tmpdir .. '/tasks.json', recur_syntax = 'repeat' }
|
||||||
|
config.reset()
|
||||||
|
store.unload()
|
||||||
|
store.load()
|
||||||
|
local t = store.add({ description = 'Task one' })
|
||||||
|
store.save()
|
||||||
|
pending.edit(tostring(t.id), 'repeat:weekly')
|
||||||
|
local updated = store.get(t.id)
|
||||||
|
assert.are.equal('weekly', updated.recur)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('does not modify store on error', function()
|
||||||
|
local t = store.add({ description = 'Task one', category = 'Original' })
|
||||||
|
store.save()
|
||||||
|
local orig_notify = vim.notify
|
||||||
|
vim.notify = function() end
|
||||||
|
pending.edit(tostring(t.id), 'due:notadate')
|
||||||
|
vim.notify = orig_notify
|
||||||
|
local updated = store.get(t.id)
|
||||||
|
assert.are.equal('Original', updated.category)
|
||||||
|
assert.is_nil(updated.due)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('sets due date with datetime format', function()
|
||||||
|
local t = store.add({ description = 'Task one' })
|
||||||
|
store.save()
|
||||||
|
pending.edit(tostring(t.id), 'due:tomorrow@14:00')
|
||||||
|
local updated = store.get(t.id)
|
||||||
|
local today = os.date('*t') --[[@as osdate]]
|
||||||
|
local expected =
|
||||||
|
os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day + 1 }))
|
||||||
|
assert.are.equal(expected .. 'T14:00', updated.due)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
@ -154,6 +154,240 @@ 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)
|
||||||
264
spec/status_spec.lua
Normal file
264
spec/status_spec.lua
Normal file
|
|
@ -0,0 +1,264 @@
|
||||||
|
require('spec.helpers')
|
||||||
|
|
||||||
|
local config = require('pending.config')
|
||||||
|
local parse = require('pending.parse')
|
||||||
|
local store = require('pending.store')
|
||||||
|
|
||||||
|
describe('status', function()
|
||||||
|
local tmpdir
|
||||||
|
local pending
|
||||||
|
|
||||||
|
before_each(function()
|
||||||
|
tmpdir = vim.fn.tempname()
|
||||||
|
vim.fn.mkdir(tmpdir, 'p')
|
||||||
|
vim.g.pending = { data_path = tmpdir .. '/tasks.json' }
|
||||||
|
config.reset()
|
||||||
|
store.unload()
|
||||||
|
package.loaded['pending'] = nil
|
||||||
|
pending = require('pending')
|
||||||
|
end)
|
||||||
|
|
||||||
|
after_each(function()
|
||||||
|
vim.fn.delete(tmpdir, 'rf')
|
||||||
|
vim.g.pending = nil
|
||||||
|
config.reset()
|
||||||
|
store.unload()
|
||||||
|
package.loaded['pending'] = nil
|
||||||
|
end)
|
||||||
|
|
||||||
|
describe('counts', function()
|
||||||
|
it('returns zeroes for empty store', function()
|
||||||
|
store.load()
|
||||||
|
local c = pending.counts()
|
||||||
|
assert.are.equal(0, c.overdue)
|
||||||
|
assert.are.equal(0, c.today)
|
||||||
|
assert.are.equal(0, c.pending)
|
||||||
|
assert.are.equal(0, c.priority)
|
||||||
|
assert.is_nil(c.next_due)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('counts pending tasks', function()
|
||||||
|
store.load()
|
||||||
|
store.add({ description = 'One' })
|
||||||
|
store.add({ description = 'Two' })
|
||||||
|
store.save()
|
||||||
|
pending._recompute_counts()
|
||||||
|
local c = pending.counts()
|
||||||
|
assert.are.equal(2, c.pending)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('counts priority tasks', function()
|
||||||
|
store.load()
|
||||||
|
store.add({ description = 'Urgent', priority = 1 })
|
||||||
|
store.add({ description = 'Normal' })
|
||||||
|
store.save()
|
||||||
|
pending._recompute_counts()
|
||||||
|
local c = pending.counts()
|
||||||
|
assert.are.equal(1, c.priority)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('counts overdue tasks with date-only', function()
|
||||||
|
store.load()
|
||||||
|
store.add({ description = 'Old task', due = '2020-01-01' })
|
||||||
|
store.save()
|
||||||
|
pending._recompute_counts()
|
||||||
|
local c = pending.counts()
|
||||||
|
assert.are.equal(1, c.overdue)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('counts overdue tasks with datetime', function()
|
||||||
|
store.load()
|
||||||
|
store.add({ description = 'Old task', due = '2020-01-01T08:00' })
|
||||||
|
store.save()
|
||||||
|
pending._recompute_counts()
|
||||||
|
local c = pending.counts()
|
||||||
|
assert.are.equal(1, c.overdue)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('counts today tasks', function()
|
||||||
|
store.load()
|
||||||
|
local today = os.date('%Y-%m-%d') --[[@as string]]
|
||||||
|
store.add({ description = 'Today task', due = today })
|
||||||
|
store.save()
|
||||||
|
pending._recompute_counts()
|
||||||
|
local c = pending.counts()
|
||||||
|
assert.are.equal(1, c.today)
|
||||||
|
assert.are.equal(0, c.overdue)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('counts mixed overdue and today', function()
|
||||||
|
store.load()
|
||||||
|
local today = os.date('%Y-%m-%d') --[[@as string]]
|
||||||
|
store.add({ description = 'Overdue', due = '2020-01-01' })
|
||||||
|
store.add({ description = 'Today', due = today })
|
||||||
|
store.save()
|
||||||
|
pending._recompute_counts()
|
||||||
|
local c = pending.counts()
|
||||||
|
assert.are.equal(1, c.overdue)
|
||||||
|
assert.are.equal(1, c.today)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('excludes done tasks', function()
|
||||||
|
store.load()
|
||||||
|
local t = store.add({ description = 'Done', due = '2020-01-01' })
|
||||||
|
store.update(t.id, { status = 'done' })
|
||||||
|
store.save()
|
||||||
|
pending._recompute_counts()
|
||||||
|
local c = pending.counts()
|
||||||
|
assert.are.equal(0, c.overdue)
|
||||||
|
assert.are.equal(0, c.pending)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('excludes deleted tasks', function()
|
||||||
|
store.load()
|
||||||
|
local t = store.add({ description = 'Deleted', due = '2020-01-01' })
|
||||||
|
store.delete(t.id)
|
||||||
|
store.save()
|
||||||
|
pending._recompute_counts()
|
||||||
|
local c = pending.counts()
|
||||||
|
assert.are.equal(0, c.overdue)
|
||||||
|
assert.are.equal(0, c.pending)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('excludes someday sentinel', function()
|
||||||
|
store.load()
|
||||||
|
store.add({ description = 'Someday', due = '9999-12-30' })
|
||||||
|
store.save()
|
||||||
|
pending._recompute_counts()
|
||||||
|
local c = pending.counts()
|
||||||
|
assert.are.equal(0, c.overdue)
|
||||||
|
assert.are.equal(0, c.today)
|
||||||
|
assert.are.equal(1, c.pending)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('picks earliest future date as next_due', function()
|
||||||
|
store.load()
|
||||||
|
local today = os.date('%Y-%m-%d') --[[@as string]]
|
||||||
|
store.add({ description = 'Soon', due = '2099-06-01' })
|
||||||
|
store.add({ description = 'Sooner', due = '2099-03-01' })
|
||||||
|
store.add({ description = 'Today', due = today })
|
||||||
|
store.save()
|
||||||
|
pending._recompute_counts()
|
||||||
|
local c = pending.counts()
|
||||||
|
assert.are.equal(today, c.next_due)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('lazy loads on first counts() call', function()
|
||||||
|
local path = config.get().data_path
|
||||||
|
local f = io.open(path, 'w')
|
||||||
|
f:write(vim.json.encode({
|
||||||
|
version = 1,
|
||||||
|
next_id = 2,
|
||||||
|
tasks = {
|
||||||
|
{
|
||||||
|
id = 1,
|
||||||
|
description = 'Overdue',
|
||||||
|
status = 'pending',
|
||||||
|
due = '2020-01-01',
|
||||||
|
entry = '2020-01-01T00:00:00Z',
|
||||||
|
modified = '2020-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
f:close()
|
||||||
|
store.unload()
|
||||||
|
package.loaded['pending'] = nil
|
||||||
|
pending = require('pending')
|
||||||
|
local c = pending.counts()
|
||||||
|
assert.are.equal(1, c.overdue)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
describe('statusline', function()
|
||||||
|
it('returns empty string when nothing actionable', function()
|
||||||
|
store.load()
|
||||||
|
store.save()
|
||||||
|
pending._recompute_counts()
|
||||||
|
assert.are.equal('', pending.statusline())
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('formats overdue only', function()
|
||||||
|
store.load()
|
||||||
|
store.add({ description = 'Old', due = '2020-01-01' })
|
||||||
|
store.save()
|
||||||
|
pending._recompute_counts()
|
||||||
|
assert.are.equal('1 overdue', pending.statusline())
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('formats today only', function()
|
||||||
|
store.load()
|
||||||
|
local today = os.date('%Y-%m-%d') --[[@as string]]
|
||||||
|
store.add({ description = 'Today', due = today })
|
||||||
|
store.save()
|
||||||
|
pending._recompute_counts()
|
||||||
|
assert.are.equal('1 today', pending.statusline())
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('formats overdue and today', function()
|
||||||
|
store.load()
|
||||||
|
local today = os.date('%Y-%m-%d') --[[@as string]]
|
||||||
|
store.add({ description = 'Old', due = '2020-01-01' })
|
||||||
|
store.add({ description = 'Today', due = today })
|
||||||
|
store.save()
|
||||||
|
pending._recompute_counts()
|
||||||
|
assert.are.equal('1 overdue, 1 today', pending.statusline())
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
describe('has_due', function()
|
||||||
|
it('returns false when nothing due', function()
|
||||||
|
store.load()
|
||||||
|
store.add({ description = 'Future', due = '2099-01-01' })
|
||||||
|
store.save()
|
||||||
|
pending._recompute_counts()
|
||||||
|
assert.is_false(pending.has_due())
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('returns true when overdue', function()
|
||||||
|
store.load()
|
||||||
|
store.add({ description = 'Old', due = '2020-01-01' })
|
||||||
|
store.save()
|
||||||
|
pending._recompute_counts()
|
||||||
|
assert.is_true(pending.has_due())
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('returns true when today', function()
|
||||||
|
store.load()
|
||||||
|
local today = os.date('%Y-%m-%d') --[[@as string]]
|
||||||
|
store.add({ description = 'Now', due = today })
|
||||||
|
store.save()
|
||||||
|
pending._recompute_counts()
|
||||||
|
assert.is_true(pending.has_due())
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
describe('parse.is_overdue', function()
|
||||||
|
it('date before today is overdue', function()
|
||||||
|
assert.is_true(parse.is_overdue('2020-01-01'))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('date after today is not overdue', function()
|
||||||
|
assert.is_false(parse.is_overdue('2099-01-01'))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('today date-only is not overdue', function()
|
||||||
|
local today = os.date('%Y-%m-%d') --[[@as string]]
|
||||||
|
assert.is_false(parse.is_overdue(today))
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
describe('parse.is_today', function()
|
||||||
|
it('today date-only is today', function()
|
||||||
|
local today = os.date('%Y-%m-%d') --[[@as string]]
|
||||||
|
assert.is_true(parse.is_today(today))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('yesterday is not today', function()
|
||||||
|
assert.is_false(parse.is_today('2020-01-01'))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('tomorrow is not today', function()
|
||||||
|
assert.is_false(parse.is_today('2099-01-01'))
|
||||||
|
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()
|
||||||
|
|
|
||||||
174
spec/sync_spec.lua
Normal file
174
spec/sync_spec.lua
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
require('spec.helpers')
|
||||||
|
|
||||||
|
local config = require('pending.config')
|
||||||
|
local store = require('pending.store')
|
||||||
|
|
||||||
|
describe('sync', function()
|
||||||
|
local tmpdir
|
||||||
|
local pending
|
||||||
|
|
||||||
|
before_each(function()
|
||||||
|
tmpdir = vim.fn.tempname()
|
||||||
|
vim.fn.mkdir(tmpdir, 'p')
|
||||||
|
vim.g.pending = { data_path = tmpdir .. '/tasks.json' }
|
||||||
|
config.reset()
|
||||||
|
store.unload()
|
||||||
|
package.loaded['pending'] = nil
|
||||||
|
pending = require('pending')
|
||||||
|
end)
|
||||||
|
|
||||||
|
after_each(function()
|
||||||
|
vim.fn.delete(tmpdir, 'rf')
|
||||||
|
vim.g.pending = nil
|
||||||
|
config.reset()
|
||||||
|
store.unload()
|
||||||
|
package.loaded['pending'] = nil
|
||||||
|
end)
|
||||||
|
|
||||||
|
describe('dispatch', function()
|
||||||
|
it('errors on bare :Pending sync with no backend', function()
|
||||||
|
local msg
|
||||||
|
local orig = vim.notify
|
||||||
|
vim.notify = function(m, level)
|
||||||
|
if level == vim.log.levels.ERROR then
|
||||||
|
msg = m
|
||||||
|
end
|
||||||
|
end
|
||||||
|
pending.sync(nil)
|
||||||
|
vim.notify = orig
|
||||||
|
assert.are.equal('Usage: :Pending sync <backend> [action]', msg)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('errors on empty backend string', function()
|
||||||
|
local msg
|
||||||
|
local orig = vim.notify
|
||||||
|
vim.notify = function(m, level)
|
||||||
|
if level == vim.log.levels.ERROR then
|
||||||
|
msg = m
|
||||||
|
end
|
||||||
|
end
|
||||||
|
pending.sync('')
|
||||||
|
vim.notify = orig
|
||||||
|
assert.are.equal('Usage: :Pending sync <backend> [action]', msg)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('errors on unknown backend', function()
|
||||||
|
local msg
|
||||||
|
local orig = vim.notify
|
||||||
|
vim.notify = function(m, level)
|
||||||
|
if level == vim.log.levels.ERROR then
|
||||||
|
msg = m
|
||||||
|
end
|
||||||
|
end
|
||||||
|
pending.sync('notreal')
|
||||||
|
vim.notify = orig
|
||||||
|
assert.are.equal('Unknown sync backend: notreal', msg)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('errors on unknown action for valid backend', function()
|
||||||
|
local msg
|
||||||
|
local orig = vim.notify
|
||||||
|
vim.notify = function(m, level)
|
||||||
|
if level == vim.log.levels.ERROR then
|
||||||
|
msg = m
|
||||||
|
end
|
||||||
|
end
|
||||||
|
pending.sync('gcal', 'notreal')
|
||||||
|
vim.notify = orig
|
||||||
|
assert.are.equal("gcal backend has no 'notreal' action", msg)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('defaults to sync action when action is omitted', function()
|
||||||
|
local called = false
|
||||||
|
local gcal = require('pending.sync.gcal')
|
||||||
|
local orig_sync = gcal.sync
|
||||||
|
gcal.sync = function()
|
||||||
|
called = true
|
||||||
|
end
|
||||||
|
pending.sync('gcal')
|
||||||
|
gcal.sync = orig_sync
|
||||||
|
assert.is_true(called)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('routes explicit sync action', function()
|
||||||
|
local called = false
|
||||||
|
local gcal = require('pending.sync.gcal')
|
||||||
|
local orig_sync = gcal.sync
|
||||||
|
gcal.sync = function()
|
||||||
|
called = true
|
||||||
|
end
|
||||||
|
pending.sync('gcal', 'sync')
|
||||||
|
gcal.sync = orig_sync
|
||||||
|
assert.is_true(called)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('routes auth action', function()
|
||||||
|
local called = false
|
||||||
|
local gcal = require('pending.sync.gcal')
|
||||||
|
local orig_auth = gcal.auth
|
||||||
|
gcal.auth = function()
|
||||||
|
called = true
|
||||||
|
end
|
||||||
|
pending.sync('gcal', 'auth')
|
||||||
|
gcal.auth = orig_auth
|
||||||
|
assert.is_true(called)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
describe('config migration', function()
|
||||||
|
it('migrates legacy gcal to sync.gcal', function()
|
||||||
|
config.reset()
|
||||||
|
vim.g.pending = {
|
||||||
|
data_path = tmpdir .. '/tasks.json',
|
||||||
|
gcal = { calendar = 'MyCalendar' },
|
||||||
|
}
|
||||||
|
local cfg = config.get()
|
||||||
|
assert.is_not_nil(cfg.sync)
|
||||||
|
assert.is_not_nil(cfg.sync.gcal)
|
||||||
|
assert.are.equal('MyCalendar', cfg.sync.gcal.calendar)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('does not overwrite explicit sync.gcal with legacy gcal', function()
|
||||||
|
config.reset()
|
||||||
|
vim.g.pending = {
|
||||||
|
data_path = tmpdir .. '/tasks.json',
|
||||||
|
gcal = { calendar = 'Legacy' },
|
||||||
|
sync = { gcal = { calendar = 'Explicit' } },
|
||||||
|
}
|
||||||
|
local cfg = config.get()
|
||||||
|
assert.are.equal('Explicit', cfg.sync.gcal.calendar)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('works with sync.gcal and no legacy gcal', function()
|
||||||
|
config.reset()
|
||||||
|
vim.g.pending = {
|
||||||
|
data_path = tmpdir .. '/tasks.json',
|
||||||
|
sync = { gcal = { calendar = 'NewStyle' } },
|
||||||
|
}
|
||||||
|
local cfg = config.get()
|
||||||
|
assert.are.equal('NewStyle', cfg.sync.gcal.calendar)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
describe('gcal module', function()
|
||||||
|
it('has name field', function()
|
||||||
|
local gcal = require('pending.sync.gcal')
|
||||||
|
assert.are.equal('gcal', gcal.name)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('has auth function', function()
|
||||||
|
local gcal = require('pending.sync.gcal')
|
||||||
|
assert.are.equal('function', type(gcal.auth))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('has sync function', function()
|
||||||
|
local gcal = require('pending.sync.gcal')
|
||||||
|
assert.are.equal('function', type(gcal.sync))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('has health function', function()
|
||||||
|
local gcal = require('pending.sync.gcal')
|
||||||
|
assert.are.equal('function', type(gcal.health))
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
194
spec/textobj_spec.lua
Normal file
194
spec/textobj_spec.lua
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
require('spec.helpers')
|
||||||
|
|
||||||
|
local config = require('pending.config')
|
||||||
|
|
||||||
|
describe('textobj', function()
|
||||||
|
local textobj = require('pending.textobj')
|
||||||
|
|
||||||
|
before_each(function()
|
||||||
|
vim.g.pending = nil
|
||||||
|
config.reset()
|
||||||
|
end)
|
||||||
|
|
||||||
|
after_each(function()
|
||||||
|
vim.g.pending = nil
|
||||||
|
config.reset()
|
||||||
|
end)
|
||||||
|
|
||||||
|
describe('inner_task_range', function()
|
||||||
|
it('returns description range for task with id prefix', function()
|
||||||
|
local s, e = textobj.inner_task_range('/1/- [ ] Buy groceries')
|
||||||
|
assert.are.equal(10, s)
|
||||||
|
assert.are.equal(22, e)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('returns description range for task without id prefix', function()
|
||||||
|
local s, e = textobj.inner_task_range('- [ ] Buy groceries')
|
||||||
|
assert.are.equal(7, s)
|
||||||
|
assert.are.equal(19, e)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('excludes trailing due: token', function()
|
||||||
|
local s, e = textobj.inner_task_range('/1/- [ ] Buy groceries due:2026-03-15')
|
||||||
|
assert.are.equal(10, s)
|
||||||
|
assert.are.equal(22, e)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('excludes trailing cat: token', function()
|
||||||
|
local s, e = textobj.inner_task_range('/1/- [ ] Buy groceries cat:Errands')
|
||||||
|
assert.are.equal(10, s)
|
||||||
|
assert.are.equal(22, e)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('excludes trailing rec: token', function()
|
||||||
|
local s, e = textobj.inner_task_range('/1/- [ ] Take out trash rec:weekly')
|
||||||
|
assert.are.equal(10, s)
|
||||||
|
assert.are.equal(23, e)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('excludes multiple trailing metadata tokens', function()
|
||||||
|
local s, e =
|
||||||
|
textobj.inner_task_range('/1/- [ ] Buy milk due:2026-03-15 cat:Errands rec:weekly')
|
||||||
|
assert.are.equal(10, s)
|
||||||
|
assert.are.equal(17, e)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('handles priority checkbox', function()
|
||||||
|
local s, e = textobj.inner_task_range('/1/- [!] Important task')
|
||||||
|
assert.are.equal(10, s)
|
||||||
|
assert.are.equal(23, e)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('handles done checkbox', function()
|
||||||
|
local s, e = textobj.inner_task_range('/1/- [x] Finished task')
|
||||||
|
assert.are.equal(10, s)
|
||||||
|
assert.are.equal(22, e)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('handles multi-digit task ids', function()
|
||||||
|
local s, e = textobj.inner_task_range('/123/- [ ] Some task')
|
||||||
|
assert.are.equal(12, s)
|
||||||
|
assert.are.equal(20, e)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('does not strip non-metadata tokens', function()
|
||||||
|
local s, e = textobj.inner_task_range('/1/- [ ] Buy groceries for dinner')
|
||||||
|
assert.are.equal(10, s)
|
||||||
|
assert.are.equal(33, e)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('stops stripping at first non-metadata token from right', function()
|
||||||
|
local s, e = textobj.inner_task_range('/1/- [ ] Buy groceries for dinner due:2026-03-15')
|
||||||
|
assert.are.equal(10, s)
|
||||||
|
assert.are.equal(33, e)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('respects custom date_syntax', function()
|
||||||
|
vim.g.pending = { date_syntax = 'by' }
|
||||||
|
config.reset()
|
||||||
|
local s, e = textobj.inner_task_range('/1/- [ ] Buy groceries by:2026-03-15')
|
||||||
|
assert.are.equal(10, s)
|
||||||
|
assert.are.equal(22, e)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('respects custom recur_syntax', function()
|
||||||
|
vim.g.pending = { recur_syntax = 'repeat' }
|
||||||
|
config.reset()
|
||||||
|
local s, e = textobj.inner_task_range('/1/- [ ] Take trash repeat:weekly')
|
||||||
|
assert.are.equal(10, s)
|
||||||
|
assert.are.equal(19, e)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('handles task with only metadata after description', function()
|
||||||
|
local s, e = textobj.inner_task_range('/1/- [ ] X due:tomorrow')
|
||||||
|
assert.are.equal(10, s)
|
||||||
|
assert.are.equal(10, e)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
describe('category_bounds', function()
|
||||||
|
it('returns header and last row for single category', function()
|
||||||
|
---@type pending.LineMeta[]
|
||||||
|
local meta = {
|
||||||
|
{ type = 'header', category = 'Work' },
|
||||||
|
{ type = 'task', id = 1 },
|
||||||
|
{ type = 'task', id = 2 },
|
||||||
|
}
|
||||||
|
local h, l = textobj.category_bounds(2, meta)
|
||||||
|
assert.are.equal(1, h)
|
||||||
|
assert.are.equal(3, l)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('returns bounds for first category with trailing blank', function()
|
||||||
|
---@type pending.LineMeta[]
|
||||||
|
local meta = {
|
||||||
|
{ type = 'header', category = 'Work' },
|
||||||
|
{ type = 'task', id = 1 },
|
||||||
|
{ type = 'blank' },
|
||||||
|
{ type = 'header', category = 'Personal' },
|
||||||
|
{ type = 'task', id = 2 },
|
||||||
|
}
|
||||||
|
local h, l = textobj.category_bounds(2, meta)
|
||||||
|
assert.are.equal(1, h)
|
||||||
|
assert.are.equal(3, l)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('returns bounds for second category', function()
|
||||||
|
---@type pending.LineMeta[]
|
||||||
|
local meta = {
|
||||||
|
{ type = 'header', category = 'Work' },
|
||||||
|
{ type = 'task', id = 1 },
|
||||||
|
{ type = 'blank' },
|
||||||
|
{ type = 'header', category = 'Personal' },
|
||||||
|
{ type = 'task', id = 2 },
|
||||||
|
{ type = 'task', id = 3 },
|
||||||
|
}
|
||||||
|
local h, l = textobj.category_bounds(5, meta)
|
||||||
|
assert.are.equal(4, h)
|
||||||
|
assert.are.equal(6, l)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('returns bounds when cursor is on header', function()
|
||||||
|
---@type pending.LineMeta[]
|
||||||
|
local meta = {
|
||||||
|
{ type = 'header', category = 'Work' },
|
||||||
|
{ type = 'task', id = 1 },
|
||||||
|
}
|
||||||
|
local h, l = textobj.category_bounds(1, meta)
|
||||||
|
assert.are.equal(1, h)
|
||||||
|
assert.are.equal(2, l)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('returns nil for blank line with no preceding header', function()
|
||||||
|
---@type pending.LineMeta[]
|
||||||
|
local meta = {
|
||||||
|
{ type = 'blank' },
|
||||||
|
{ type = 'header', category = 'Work' },
|
||||||
|
{ type = 'task', id = 1 },
|
||||||
|
}
|
||||||
|
local h, l = textobj.category_bounds(1, meta)
|
||||||
|
assert.is_nil(h)
|
||||||
|
assert.is_nil(l)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('returns nil for empty meta', function()
|
||||||
|
local h, l = textobj.category_bounds(1, {})
|
||||||
|
assert.is_nil(h)
|
||||||
|
assert.is_nil(l)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('includes blank between header and next header in bounds', function()
|
||||||
|
---@type pending.LineMeta[]
|
||||||
|
local meta = {
|
||||||
|
{ type = 'header', category = 'Work' },
|
||||||
|
{ type = 'task', id = 1 },
|
||||||
|
{ type = 'blank' },
|
||||||
|
{ type = 'header', category = 'Home' },
|
||||||
|
{ type = 'task', id = 2 },
|
||||||
|
}
|
||||||
|
local h, l = textobj.category_bounds(1, meta)
|
||||||
|
assert.are.equal(1, h)
|
||||||
|
assert.are.equal(3, l)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
@ -204,6 +204,30 @@ describe('views', function()
|
||||||
assert.is_falsy(task_meta.overdue)
|
assert.is_falsy(task_meta.overdue)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
it('includes recur in LineMeta for recurring tasks', function()
|
||||||
|
store.add({ description = 'Recurring', category = 'Inbox', recur = 'weekly' })
|
||||||
|
local _, meta = views.category_view(store.active_tasks())
|
||||||
|
local task_meta
|
||||||
|
for _, m in ipairs(meta) do
|
||||||
|
if m.type == 'task' then
|
||||||
|
task_meta = m
|
||||||
|
end
|
||||||
|
end
|
||||||
|
assert.are.equal('weekly', task_meta.recur)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('has nil recur in LineMeta for non-recurring tasks', function()
|
||||||
|
store.add({ description = 'Normal', category = 'Inbox' })
|
||||||
|
local _, meta = views.category_view(store.active_tasks())
|
||||||
|
local task_meta
|
||||||
|
for _, m in ipairs(meta) do
|
||||||
|
if m.type == 'task' then
|
||||||
|
task_meta = m
|
||||||
|
end
|
||||||
|
end
|
||||||
|
assert.is_nil(task_meta.recur)
|
||||||
|
end)
|
||||||
|
|
||||||
it('respects category_order when set', function()
|
it('respects category_order when set', function()
|
||||||
vim.g.pending = { data_path = tmpdir .. '/tasks.json', category_order = { 'Work', 'Inbox' } }
|
vim.g.pending = { data_path = tmpdir .. '/tasks.json', category_order = { 'Work', 'Inbox' } }
|
||||||
config.reset()
|
config.reset()
|
||||||
|
|
@ -399,5 +423,29 @@ describe('views', function()
|
||||||
end
|
end
|
||||||
assert.is_falsy(task_meta.overdue)
|
assert.is_falsy(task_meta.overdue)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
it('includes recur in LineMeta for recurring tasks', function()
|
||||||
|
store.add({ description = 'Recurring', category = 'Inbox', recur = 'daily' })
|
||||||
|
local _, meta = views.priority_view(store.active_tasks())
|
||||||
|
local task_meta
|
||||||
|
for _, m in ipairs(meta) do
|
||||||
|
if m.type == 'task' then
|
||||||
|
task_meta = m
|
||||||
|
end
|
||||||
|
end
|
||||||
|
assert.are.equal('daily', task_meta.recur)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('has nil recur in LineMeta for non-recurring tasks', function()
|
||||||
|
store.add({ description = 'Normal', category = 'Inbox' })
|
||||||
|
local _, meta = views.priority_view(store.active_tasks())
|
||||||
|
local task_meta
|
||||||
|
for _, m in ipairs(meta) do
|
||||||
|
if m.type == 'task' then
|
||||||
|
task_meta = m
|
||||||
|
end
|
||||||
|
end
|
||||||
|
assert.is_nil(task_meta.recur)
|
||||||
|
end)
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue