Compare commits
12 commits
doc/minify
...
feat/file-
| Author | SHA1 | Date | |
|---|---|---|---|
| 735b5ec51b | |||
| 7835dc4687 | |||
|
|
3da23c924a | ||
|
|
8d3d21b330 | ||
|
|
e62e09f609 | ||
|
|
302bf8126f | ||
|
|
c57cc0845b | ||
|
|
72dbf037c7 | ||
|
|
b76c680e1f | ||
|
|
379e281ecd | ||
|
|
7d93c4bb45 | ||
|
|
6911c091f6 |
30 changed files with 4822 additions and 377 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: []
|
||||
body:
|
||||
- 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
|
||||
description: Report a bug
|
||||
title: 'bug: '
|
||||
title: "bug: "
|
||||
labels: [bug]
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Prerequisites
|
||||
options:
|
||||
- label:
|
||||
I have searched [existing
|
||||
- label: I have searched [existing
|
||||
issues](https://github.com/barrettruth/pending.nvim/issues)
|
||||
required: true
|
||||
- label: I have updated to the latest version
|
||||
|
|
@ -16,16 +15,16 @@ body:
|
|||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: 'Neovim version'
|
||||
description: 'Output of `nvim --version`'
|
||||
label: "Neovim version"
|
||||
description: "Output of `nvim --version`"
|
||||
render: text
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
attributes:
|
||||
label: 'Operating system'
|
||||
placeholder: 'e.g. Arch Linux, macOS 15, Ubuntu 24.04'
|
||||
label: "Operating system"
|
||||
placeholder: "e.g. Arch Linux, macOS 15, Ubuntu 24.04"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
|
@ -49,8 +48,8 @@ body:
|
|||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: 'Health check'
|
||||
description: 'Output of `:checkhealth task`'
|
||||
label: "Health check"
|
||||
description: "Output of `:checkhealth task`"
|
||||
render: text
|
||||
|
||||
- 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
|
||||
description: Suggest a feature
|
||||
title: 'feat: '
|
||||
title: "feat: "
|
||||
labels: [enhancement]
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Prerequisites
|
||||
options:
|
||||
- label:
|
||||
I have searched [existing
|
||||
- label: I have searched [existing
|
||||
issues](https://github.com/barrettruth/pending.nvim/issues)
|
||||
required: true
|
||||
|
||||
|
|
|
|||
2
.github/workflows/luarocks.yaml
vendored
2
.github/workflows/luarocks.yaml
vendored
|
|
@ -3,7 +3,7 @@ name: luarocks
|
|||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
|
|
|
|||
143
README.md
143
README.md
|
|
@ -2,145 +2,30 @@
|
|||
|
||||
Edit tasks like text. `:w` saves them.
|
||||
|
||||
A buffer-centric task manager for Neovim. Tasks live in a plain buffer — add
|
||||
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.
|
||||
<!-- insert preview -->
|
||||
|
||||
## How it works
|
||||
## Requirements
|
||||
|
||||
```
|
||||
School
|
||||
! Read chapter 5 Feb 28
|
||||
Submit homework Feb 25
|
||||
- Neovim 0.10+
|
||||
- (Optionally) `curl` and `openssl` for Google Calendar and Google Task sync
|
||||
|
||||
Errands
|
||||
Buy groceries Mar 01
|
||||
Clean apartment
|
||||
```
|
||||
## Installation
|
||||
|
||||
Category headers sit at column 0. Tasks are indented below them. `!` marks
|
||||
priority. Due dates appear as right-aligned virtual text. Done tasks get
|
||||
strikethrough. Everything you see is editable buffer text — the IDs are
|
||||
concealed, and metadata is parsed from inline syntax on save.
|
||||
|
||||
## Install
|
||||
Install with your package manager of choice or via
|
||||
[luarocks](https://luarocks.org/modules/barrettruth/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
|
||||
|
||||
```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)
|
||||
|
|
|
|||
554
doc/pending.txt
554
doc/pending.txt
|
|
@ -30,13 +30,16 @@ concealed tokens and are never visible during editing.
|
|||
|
||||
Features: ~
|
||||
- Oil-style buffer editing: standard Vim motions for all task operations
|
||||
- Inline metadata syntax: `due:` and `cat:` tokens parsed on `:w`
|
||||
- Relative date input: `today`, `tomorrow`, `+Nd`, weekday names
|
||||
- Two views: category (default) and priority flat list
|
||||
- Multi-level undo (up to 20 `:w` saves, session-only)
|
||||
- Inline metadata syntax: `due:`, `cat:`, `rec:`, and `file:` tokens parsed on `:w`
|
||||
- Relative date input: `today`, `tomorrow`, `+Nd`, `+Nw`, `+Nm`, weekday
|
||||
names, month names, ordinals, and more
|
||||
- Recurring tasks with automatic next-date spawning on completion
|
||||
- Two views: category (default) and 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`
|
||||
- Quickfix list of overdue/due-today tasks via `:Pending due`
|
||||
- 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
|
||||
|
||||
==============================================================================
|
||||
|
|
@ -44,7 +47,7 @@ REQUIREMENTS *pending-requirements*
|
|||
|
||||
- Neovim 0.10+
|
||||
- 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*
|
||||
|
|
@ -95,20 +98,19 @@ parsed from the right and consumed until a non-metadata token is reached.
|
|||
Supported tokens: ~
|
||||
|
||||
`due:YYYY-MM-DD` Set a due date using an absolute date.
|
||||
`due:today` Resolve to today's date.
|
||||
`due:tomorrow` Resolve to tomorrow's date.
|
||||
`due:+Nd` Resolve to N days from today (e.g. `due:+3d`).
|
||||
`due:mon` Resolve to the next occurrence of that weekday.
|
||||
Supported: `sun` `mon` `tue` `wed` `thu` `fri` `sat`
|
||||
`due:<name>` Resolve a named date (see |pending-dates| below).
|
||||
`cat:Name` Move the task to the named category on save.
|
||||
`rec:<pattern>` Set a recurrence rule (see |pending-recurrence|).
|
||||
`file:<path>:<n>` Attach a file reference (see |pending-file-token|).
|
||||
|
||||
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
|
||||
`by:2026-03-15` instead.
|
||||
`date_syntax` in |pending-config|. The token name for recurrence defaults to
|
||||
`rec` and is configurable via `recur_syntax`.
|
||||
|
||||
Example: >
|
||||
|
||||
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
|
||||
|
|
@ -116,8 +118,138 @@ On `:w`, the description becomes `Buy milk`, the due date is stored as
|
|||
placed under the `Errands` category header.
|
||||
|
||||
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
|
||||
`cat:` per task line are consumed.
|
||||
Repeated tokens of the same type also stop parsing — only one `due:`, one
|
||||
`cat:`, one `rec:`, and one `file:` per task line are consumed.
|
||||
|
||||
Omnifunc completion is available for `due:`, `cat:`, and `rec:` token types.
|
||||
In insert mode, type the token prefix and press `<C-x><C-o>` to see
|
||||
suggestions.
|
||||
|
||||
==============================================================================
|
||||
FILE TOKEN *pending-file-token*
|
||||
|
||||
The `file:` inline token attaches a source file reference to a task. The
|
||||
syntax is: >
|
||||
|
||||
file:<relative-path>:<line-number>
|
||||
<
|
||||
|
||||
The path is stored relative to the directory containing the data file. The
|
||||
token is rendered as virtual text at the end of the task line, showing only
|
||||
the basename and line number (e.g. `auth.lua:42`) using the |PendingFile|
|
||||
highlight group.
|
||||
|
||||
Example: >
|
||||
|
||||
Fix null pointer file:src/auth.lua:42
|
||||
Update tests file:spec/parse_spec.lua:100
|
||||
<
|
||||
|
||||
`gf` in normal mode in the task buffer follows the file reference, opening
|
||||
the file and jumping to the specified line. The default key is `gf` and can
|
||||
be changed via the `goto_file` keymap in |pending-config|. Set it to `false`
|
||||
to disable.
|
||||
|
||||
To attach the current file and cursor position to an existing task, invoke
|
||||
|<Plug>(pending-add-here)| from any source file. A `vim.ui.select()` picker
|
||||
lists all active tasks; selecting one records the current file and line.
|
||||
|
||||
To clear a file reference with `:Pending edit`: >vim
|
||||
:Pending edit 5 -file
|
||||
<
|
||||
|
||||
==============================================================================
|
||||
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*
|
||||
|
|
@ -135,6 +267,7 @@ COMMANDS *pending-commands*
|
|||
:Pending add Buy groceries due:2026-03-15
|
||||
:Pending add School: Submit homework
|
||||
: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.
|
||||
|
||||
|
|
@ -152,16 +285,52 @@ COMMANDS *pending-commands*
|
|||
Open the list with |:copen| to navigate to each task's category.
|
||||
|
||||
*:Pending-sync*
|
||||
:Pending sync
|
||||
Push pending tasks that have a due date to Google Calendar as all-day
|
||||
events. Requires |pending-gcal| to be configured. See |pending-gcal| for
|
||||
full details on what gets created, updated, and deleted.
|
||||
:Pending sync {backend} [{action}]
|
||||
Run a sync action against a named backend. {backend} is required — bare
|
||||
`:Pending sync` prints a usage message. {action} defaults to `sync`
|
||||
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-edit*
|
||||
:Pending edit {id} [{operations}]
|
||||
Edit metadata on an existing task without opening the buffer. {id} is the
|
||||
numeric task ID. One or more operations follow: >vim
|
||||
:Pending edit 5 due:tomorrow cat:Work +!
|
||||
:Pending edit 5 -due -cat -rec
|
||||
:Pending edit 5 rec:!weekly due:fri
|
||||
:Pending edit 5 -file
|
||||
<
|
||||
Operations: ~
|
||||
`due:<date>` Set due date (accepts all |pending-dates| vocabulary).
|
||||
`cat:<name>` Set category.
|
||||
`rec:<pattern>` Set recurrence (prefix `!` for completion-based).
|
||||
`+!` Add priority flag.
|
||||
`-!` Remove priority flag.
|
||||
`-due` Clear due date.
|
||||
`-cat` Clear category.
|
||||
`-rec` Clear recurrence.
|
||||
`-file` Clear the attached file reference (see |pending-file-token|).
|
||||
|
||||
Tab completion is available for IDs, field names, date values, categories,
|
||||
and recurrence patterns.
|
||||
|
||||
*:Pending-undo*
|
||||
:Pending undo
|
||||
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
|
||||
levels of undo are retained per session.
|
||||
levels of undo are persisted across sessions.
|
||||
|
||||
==============================================================================
|
||||
MAPPINGS *pending-mappings*
|
||||
|
|
@ -169,27 +338,63 @@ MAPPINGS *pending-mappings*
|
|||
The following keys are set buffer-locally when the task buffer opens. They
|
||||
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 ~
|
||||
------- ------------------------------------------------
|
||||
`<CR>` Toggle complete / uncomplete the task at cursor
|
||||
`!` Toggle the priority flag on the task at cursor
|
||||
`D` Prompt for a due date on the task at cursor
|
||||
`<Tab>` Switch between category view and priority view
|
||||
`U` Undo the last `:w` save
|
||||
`g?` Show a help popup with available keys
|
||||
`q` Close the task buffer (`close`)
|
||||
`<CR>` Toggle complete / uncomplete (`toggle`)
|
||||
`!` Toggle the priority flag (`priority`)
|
||||
`D` Prompt for a due date (`date`)
|
||||
`<Tab>` Switch between category / queue view (`view`)
|
||||
`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`)
|
||||
`gf` Open the file attached to the task under the cursor (`goto_file`)
|
||||
`zc` Fold 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
|
||||
at the position below or above the cursor rather than using standard Vim
|
||||
indentation. `dd`, `p`, `P`, and `:w` work as expected.
|
||||
Text objects (operator-pending and visual): ~
|
||||
|
||||
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)
|
||||
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)
|
||||
Toggle complete / uncomplete for the task under the cursor.
|
||||
|
|
@ -206,6 +411,65 @@ indentation. `dd`, `p`, `P`, and `:w` work as expected.
|
|||
<Plug>(pending-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.
|
||||
|
||||
*<Plug>(pending-goto-file)*
|
||||
<Plug>(pending-goto-file)
|
||||
Open the file attached to the task under the cursor. If the cursor is not
|
||||
on a task line, or the task has no file reference, a warning is shown. If
|
||||
the referenced file cannot be read, an error is shown.
|
||||
See |pending-file-token|.
|
||||
|
||||
*<Plug>(pending-add-here)*
|
||||
<Plug>(pending-add-here)
|
||||
Attach the current file and cursor line to an existing task. Invoke from
|
||||
any source file (not the pending buffer itself) to open a picker listing
|
||||
all active tasks. The selected task receives a `file:` reference pointing
|
||||
to the current buffer's file and the cursor's line number.
|
||||
See |pending-file-token|.
|
||||
|
||||
Example configuration: >lua
|
||||
vim.keymap.set('n', '<leader>t', '<Plug>(pending-open)')
|
||||
vim.keymap.set('n', '<leader>T', '<Plug>(pending-toggle)')
|
||||
|
|
@ -224,12 +488,12 @@ Category view (default): ~ *pending-view-category*
|
|||
first within each group. Category sections are foldable with `zc` and
|
||||
`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
|
||||
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
|
||||
text alongside the due date virtual text so tasks remain identifiable
|
||||
across categories.
|
||||
across categories. The buffer is named `pending://queue`.
|
||||
|
||||
==============================================================================
|
||||
CONFIGURATION *pending-config*
|
||||
|
|
@ -242,11 +506,34 @@ loads: >lua
|
|||
default_category = 'Inbox',
|
||||
date_format = '%b %d',
|
||||
date_syntax = 'due',
|
||||
recur_syntax = 'rec',
|
||||
someday_date = '9999-12-30',
|
||||
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',
|
||||
goto_file = 'gf',
|
||||
},
|
||||
sync = {
|
||||
gcal = {
|
||||
calendar = 'Tasks',
|
||||
credentials_path = '/path/to/client_secret.json',
|
||||
},
|
||||
},
|
||||
}
|
||||
<
|
||||
|
||||
|
|
@ -278,16 +565,164 @@ Fields: ~
|
|||
this to use a different keyword, for example `'by'`
|
||||
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: {})
|
||||
Ordered list of category names. In category view,
|
||||
categories that appear in this list are shown in the
|
||||
given order. Categories not in the list are appended
|
||||
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.
|
||||
|
||||
{goto_file} (string|false, default: 'gf')
|
||||
Open the file attached to the task under the
|
||||
cursor. Set to `false` to disable. See
|
||||
|pending-file-token|.
|
||||
|
||||
{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)
|
||||
Google Calendar sync configuration. See
|
||||
|pending.GcalConfig|. Omit this field entirely to
|
||||
disable Google Calendar sync.
|
||||
Legacy shorthand for `sync.gcal`. If `gcal` is set
|
||||
but `sync.gcal` is not, the value is migrated
|
||||
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*
|
||||
|
|
@ -298,13 +733,18 @@ not pulled back into pending.nvim.
|
|||
|
||||
Configuration: >lua
|
||||
vim.g.pending = {
|
||||
sync = {
|
||||
gcal = {
|
||||
calendar = 'Tasks',
|
||||
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*
|
||||
Fields: ~
|
||||
{calendar} (string, default: 'Pendings')
|
||||
|
|
@ -320,7 +760,7 @@ Fields: ~
|
|||
that Google provides or as a bare credentials object.
|
||||
|
||||
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
|
||||
|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 —
|
||||
|
|
@ -330,7 +770,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
|
||||
it is about to expire.
|
||||
|
||||
`:Pending sync` behavior: ~
|
||||
`:Pending sync gcal` behavior: ~
|
||||
For each task in the store:
|
||||
- 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.
|
||||
|
|
@ -343,6 +783,30 @@ For each task in the store:
|
|||
A summary notification is shown after sync: `created: N, updated: 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*
|
||||
|
||||
|
|
@ -371,6 +835,17 @@ PendingDone Applied to the text of completed tasks.
|
|||
PendingPriority Applied to the `! ` priority marker on priority tasks.
|
||||
Default: links to `DiagnosticWarn`.
|
||||
|
||||
*PendingRecur*
|
||||
PendingRecur Applied to the recurrence indicator virtual text shown
|
||||
alongside due dates for recurring tasks.
|
||||
Default: links to `DiagnosticInfo`.
|
||||
|
||||
*PendingFile*
|
||||
PendingFile Applied to the file reference virtual text shown for tasks
|
||||
that have a `file:` token attached (see |pending-file-token|).
|
||||
Displays the basename and line number (e.g. `auth.lua:42`).
|
||||
Default: links to `Directory`.
|
||||
|
||||
To override a group in your colorscheme or config: >lua
|
||||
vim.api.nvim_set_hl(0, 'PendingDue', { fg = '#aaaaaa', italic = true })
|
||||
<
|
||||
|
|
@ -388,8 +863,9 @@ Checks performed: ~
|
|||
category, date format, date syntax)
|
||||
- Whether the data directory exists (warning if not yet created)
|
||||
- Whether the data file exists and can be parsed; reports total task count
|
||||
- Whether `curl` is available (required for Google Calendar sync)
|
||||
- Whether `openssl` is available (required for OAuth PKCE)
|
||||
- Validates recurrence specs on stored tasks
|
||||
- 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*
|
||||
|
|
@ -414,6 +890,8 @@ Task fields: ~
|
|||
{category} (string) Category name. Defaults to `default_category`.
|
||||
{priority} (integer) `1` for priority tasks, `0` otherwise.
|
||||
{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.
|
||||
{modified} (string) ISO 8601 UTC timestamp of last modification.
|
||||
{end} (string) ISO 8601 UTC timestamp of completion or deletion.
|
||||
|
|
|
|||
|
|
@ -37,12 +37,21 @@ function M.current_view_name()
|
|||
return current_view
|
||||
end
|
||||
|
||||
---@return nil
|
||||
function M.clear_winid()
|
||||
task_winid = nil
|
||||
end
|
||||
|
||||
---@return nil
|
||||
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)
|
||||
end
|
||||
task_winid = nil
|
||||
|
|
@ -55,19 +64,13 @@ local function set_buf_options(bufnr)
|
|||
vim.bo[bufnr].swapfile = false
|
||||
vim.bo[bufnr].filetype = 'pending'
|
||||
vim.bo[bufnr].modifiable = true
|
||||
vim.bo[bufnr].omnifunc = 'v:lua.require("pending.complete").omnifunc'
|
||||
end
|
||||
|
||||
---@param winid integer
|
||||
local function set_win_options(winid)
|
||||
vim.wo[winid].conceallevel = 3
|
||||
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
|
||||
end
|
||||
|
||||
|
|
@ -85,6 +88,7 @@ local function setup_syntax(bufnr)
|
|||
end
|
||||
|
||||
---@param above boolean
|
||||
---@return nil
|
||||
function M.open_line(above)
|
||||
local bufnr = task_bufnr
|
||||
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then
|
||||
|
|
@ -122,24 +126,26 @@ local function apply_extmarks(bufnr, line_meta)
|
|||
local row = i - 1
|
||||
if m.type == 'task' then
|
||||
local due_hl = m.overdue and 'PendingOverdue' or 'PendingDue'
|
||||
if m.show_category then
|
||||
local virt_text
|
||||
if m.category and m.due then
|
||||
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 } }
|
||||
local virt_parts = {}
|
||||
if m.show_category and m.category then
|
||||
table.insert(virt_parts, { m.category, 'PendingHeader' })
|
||||
end
|
||||
if virt_text then
|
||||
vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, {
|
||||
virt_text = virt_text,
|
||||
virt_text_pos = 'eol',
|
||||
})
|
||||
if m.recur then
|
||||
table.insert(virt_parts, { '\u{21bb} ' .. m.recur, 'PendingRecur' })
|
||||
end
|
||||
if m.due then
|
||||
table.insert(virt_parts, { m.due, due_hl })
|
||||
end
|
||||
if m.file then
|
||||
local display = m.file:match('([^/]+:%d+)$') or m.file
|
||||
table.insert(virt_parts, { display, 'PendingFile' })
|
||||
end
|
||||
if #virt_parts > 0 then
|
||||
for p = 1, #virt_parts - 1 do
|
||||
virt_parts[p][1] = virt_parts[p][1] .. ' '
|
||||
end
|
||||
elseif m.due then
|
||||
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',
|
||||
})
|
||||
end
|
||||
|
|
@ -167,6 +173,8 @@ local function setup_highlights()
|
|||
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, 'PendingPriority', { link = 'DiagnosticWarn', default = true })
|
||||
vim.api.nvim_set_hl(0, 'PendingRecur', { link = 'DiagnosticInfo', default = true })
|
||||
vim.api.nvim_set_hl(0, 'PendingFile', { link = 'Directory', default = true })
|
||||
end
|
||||
|
||||
local function snapshot_folds(bufnr)
|
||||
|
|
@ -212,6 +220,7 @@ local function restore_folds(bufnr)
|
|||
end
|
||||
|
||||
---@param bufnr? integer
|
||||
---@return nil
|
||||
function M.render(bufnr)
|
||||
bufnr = bufnr or task_bufnr
|
||||
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then
|
||||
|
|
@ -219,7 +228,8 @@ function M.render(bufnr)
|
|||
end
|
||||
|
||||
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 lines, line_meta
|
||||
|
|
@ -256,6 +266,7 @@ function M.render(bufnr)
|
|||
restore_folds(bufnr)
|
||||
end
|
||||
|
||||
---@return nil
|
||||
function M.toggle_view()
|
||||
if current_view == 'category' then
|
||||
current_view = 'priority'
|
||||
|
|
|
|||
172
lua/pending/complete.lua
Normal file
172
lua/pending/complete.lua
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
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 },
|
||||
{ 'file:([%S]*)$', 'file' },
|
||||
}
|
||||
|
||||
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
|
||||
elseif source == 'file' then
|
||||
end
|
||||
|
||||
return matches
|
||||
end
|
||||
|
||||
return M
|
||||
|
|
@ -2,14 +2,41 @@
|
|||
---@field calendar? 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
|
||||
---@field goto_file? string|false
|
||||
|
||||
---@class pending.Config
|
||||
---@field data_path string
|
||||
---@field default_view 'category'|'priority'
|
||||
---@field default_category string
|
||||
---@field date_format string
|
||||
---@field date_syntax string
|
||||
---@field recur_syntax string
|
||||
---@field someday_date string
|
||||
---@field category_order? string[]
|
||||
---@field drawer_height? integer
|
||||
---@field debug? boolean
|
||||
---@field keymaps pending.Keymaps
|
||||
---@field sync? pending.SyncConfig
|
||||
---@field gcal? pending.GcalConfig
|
||||
|
||||
---@class pending.config
|
||||
|
|
@ -22,7 +49,28 @@ local defaults = {
|
|||
default_category = 'Todo',
|
||||
date_format = '%b %d',
|
||||
date_syntax = 'due',
|
||||
recur_syntax = 'rec',
|
||||
someday_date = '9999-12-30',
|
||||
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?
|
||||
|
|
@ -35,9 +83,14 @@ function M.get()
|
|||
end
|
||||
local user = vim.g.pending or {}
|
||||
_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
|
||||
end
|
||||
|
||||
---@return nil
|
||||
function M.reset()
|
||||
_resolved = nil
|
||||
end
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ local store = require('pending.store')
|
|||
---@field status? string
|
||||
---@field category? string
|
||||
---@field due? string
|
||||
---@field rec? string
|
||||
---@field rec_mode? string
|
||||
---@field file? string
|
||||
---@field lnum integer
|
||||
|
||||
---@class pending.diff
|
||||
|
|
@ -48,6 +51,9 @@ function M.parse_buffer(lines)
|
|||
status = status,
|
||||
category = metadata.cat or current_category or config.get().default_category,
|
||||
due = metadata.due,
|
||||
rec = metadata.rec,
|
||||
rec_mode = metadata.rec_mode,
|
||||
file = metadata.file,
|
||||
lnum = i,
|
||||
})
|
||||
end
|
||||
|
|
@ -61,7 +67,9 @@ function M.parse_buffer(lines)
|
|||
end
|
||||
|
||||
---@param lines string[]
|
||||
function M.apply(lines)
|
||||
---@param hidden_ids? table<integer, boolean>
|
||||
---@return nil
|
||||
function M.apply(lines, hidden_ids)
|
||||
local parsed = M.parse_buffer(lines)
|
||||
local now = timestamp()
|
||||
local data = store.data()
|
||||
|
|
@ -90,6 +98,8 @@ function M.apply(lines)
|
|||
category = entry.category,
|
||||
priority = entry.priority,
|
||||
due = entry.due,
|
||||
recur = entry.rec,
|
||||
recur_mode = entry.rec_mode,
|
||||
order = order_counter,
|
||||
})
|
||||
else
|
||||
|
|
@ -112,6 +122,27 @@ function M.apply(lines)
|
|||
task.due = entry.due
|
||||
changed = true
|
||||
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
|
||||
local old_file = (task._extra and task._extra.file) or nil
|
||||
if entry.file ~= old_file then
|
||||
task._extra = task._extra or {}
|
||||
if entry.file then
|
||||
task._extra.file = entry.file
|
||||
else
|
||||
task._extra.file = nil
|
||||
if next(task._extra) == nil then
|
||||
task._extra = nil
|
||||
end
|
||||
end
|
||||
changed = true
|
||||
end
|
||||
if entry.status and task.status ~= entry.status then
|
||||
task.status = entry.status
|
||||
if entry.status == 'done' then
|
||||
|
|
@ -135,6 +166,8 @@ function M.apply(lines)
|
|||
category = entry.category,
|
||||
priority = entry.priority,
|
||||
due = entry.due,
|
||||
recur = entry.rec,
|
||||
recur_mode = entry.rec_mode,
|
||||
order = order_counter,
|
||||
})
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
local M = {}
|
||||
|
||||
---@return nil
|
||||
function M.check()
|
||||
vim.health.start('pending.nvim')
|
||||
|
||||
|
|
@ -27,6 +28,17 @@ function M.check()
|
|||
if load_ok then
|
||||
local tasks = store.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
|
||||
vim.health.error('Failed to load data file: ' .. tostring(err))
|
||||
end
|
||||
|
|
@ -35,16 +47,18 @@ function M.check()
|
|||
vim.health.info('No data file yet (will be created on first save)')
|
||||
end
|
||||
|
||||
if vim.fn.executable('curl') == 1 then
|
||||
vim.health.ok('curl found (required for Google Calendar sync)')
|
||||
local sync_paths = vim.fn.globpath(vim.o.runtimepath, 'lua/pending/sync/*.lua', false, true)
|
||||
if #sync_paths == 0 then
|
||||
vim.health.info('No sync backends found')
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,100 @@
|
|||
local buffer = require('pending.buffer')
|
||||
local config = require('pending.config')
|
||||
local diff = require('pending.diff')
|
||||
local parse = require('pending.parse')
|
||||
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
|
||||
local M = {}
|
||||
|
||||
---@type pending.Task[][]
|
||||
local _undo_states = {}
|
||||
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
|
||||
function M.open()
|
||||
local bufnr = buffer.open()
|
||||
|
|
@ -19,6 +104,7 @@ function M.open()
|
|||
end
|
||||
|
||||
---@param bufnr integer
|
||||
---@return nil
|
||||
function M._setup_autocmds(bufnr)
|
||||
local group = vim.api.nvim_create_augroup('PendingBuffer', { clear = true })
|
||||
vim.api.nvim_create_autocmd('BufWriteCmd', {
|
||||
|
|
@ -49,63 +135,153 @@ function M._setup_autocmds(bufnr)
|
|||
end
|
||||
|
||||
---@param bufnr integer
|
||||
---@return nil
|
||||
function M._setup_buf_mappings(bufnr)
|
||||
local cfg = require('pending.config').get()
|
||||
local km = cfg.keymaps
|
||||
local opts = { buffer = bufnr, silent = true }
|
||||
vim.keymap.set('n', 'q', function()
|
||||
|
||||
---@type table<string, fun()>
|
||||
local actions = {
|
||||
close = function()
|
||||
buffer.close()
|
||||
end, opts)
|
||||
vim.keymap.set('n', '<Esc>', function()
|
||||
buffer.close()
|
||||
end, opts)
|
||||
vim.keymap.set('n', '<CR>', function()
|
||||
end,
|
||||
toggle = function()
|
||||
M.toggle_complete()
|
||||
end, opts)
|
||||
vim.keymap.set('n', '<Tab>', function()
|
||||
end,
|
||||
view = function()
|
||||
buffer.toggle_view()
|
||||
end, opts)
|
||||
vim.keymap.set('n', 'g?', function()
|
||||
M.show_help()
|
||||
end, opts)
|
||||
vim.keymap.set('n', '!', function()
|
||||
end,
|
||||
priority = function()
|
||||
M.toggle_priority()
|
||||
end, opts)
|
||||
vim.keymap.set('n', 'D', function()
|
||||
end,
|
||||
date = function()
|
||||
M.prompt_date()
|
||||
end, opts)
|
||||
vim.keymap.set('n', 'U', function()
|
||||
end,
|
||||
undo = function()
|
||||
M.undo_write()
|
||||
end, opts)
|
||||
vim.keymap.set('n', 'o', function()
|
||||
end,
|
||||
open_line = function()
|
||||
buffer.open_line(false)
|
||||
end, opts)
|
||||
vim.keymap.set('n', 'O', function()
|
||||
end,
|
||||
open_line_above = function()
|
||||
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)
|
||||
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
|
||||
|
||||
local goto_key = km.goto_file
|
||||
if goto_key == nil then
|
||||
goto_key = 'gf'
|
||||
end
|
||||
if goto_key and goto_key ~= false then
|
||||
vim.keymap.set('n', goto_key --[[@as string]], function()
|
||||
M.goto_file()
|
||||
end, opts)
|
||||
end
|
||||
end
|
||||
|
||||
---@param bufnr integer
|
||||
---@return nil
|
||||
function M._on_write(bufnr)
|
||||
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
|
||||
local snapshot = store.snapshot()
|
||||
table.insert(_undo_states, snapshot)
|
||||
if #_undo_states > UNDO_MAX then
|
||||
table.remove(_undo_states, 1)
|
||||
local stack = store.undo_stack()
|
||||
table.insert(stack, snapshot)
|
||||
if #stack > UNDO_MAX then
|
||||
table.remove(stack, 1)
|
||||
end
|
||||
diff.apply(lines)
|
||||
M._recompute_counts()
|
||||
buffer.render(bufnr)
|
||||
end
|
||||
|
||||
---@return nil
|
||||
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)
|
||||
return
|
||||
end
|
||||
local state = table.remove(_undo_states)
|
||||
local state = table.remove(stack)
|
||||
store.replace_tasks(state)
|
||||
store.save()
|
||||
_save_and_notify()
|
||||
buffer.render(buffer.bufnr())
|
||||
end
|
||||
|
||||
---@return nil
|
||||
function M.toggle_complete()
|
||||
local bufnr = buffer.bufnr()
|
||||
if not bufnr then
|
||||
|
|
@ -127,9 +303,22 @@ function M.toggle_complete()
|
|||
if task.status == 'done' then
|
||||
store.update(id, { status = 'pending', ['end'] = vim.NIL })
|
||||
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' })
|
||||
end
|
||||
store.save()
|
||||
_save_and_notify()
|
||||
buffer.render(bufnr)
|
||||
for lnum, m in ipairs(buffer.meta()) do
|
||||
if m.id == id then
|
||||
|
|
@ -139,6 +328,7 @@ function M.toggle_complete()
|
|||
end
|
||||
end
|
||||
|
||||
---@return nil
|
||||
function M.toggle_priority()
|
||||
local bufnr = buffer.bufnr()
|
||||
if not bufnr then
|
||||
|
|
@ -159,7 +349,7 @@ function M.toggle_priority()
|
|||
end
|
||||
local new_priority = task.priority > 0 and 0 or 1
|
||||
store.update(id, { priority = new_priority })
|
||||
store.save()
|
||||
_save_and_notify()
|
||||
buffer.render(bufnr)
|
||||
for lnum, m in ipairs(buffer.meta()) do
|
||||
if m.id == id then
|
||||
|
|
@ -169,6 +359,7 @@ function M.toggle_priority()
|
|||
end
|
||||
end
|
||||
|
||||
---@return nil
|
||||
function M.prompt_date()
|
||||
local bufnr = buffer.bufnr()
|
||||
if not bufnr then
|
||||
|
|
@ -183,7 +374,7 @@ function M.prompt_date()
|
|||
if not id then
|
||||
return
|
||||
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
|
||||
return
|
||||
end
|
||||
|
|
@ -192,18 +383,22 @@ function M.prompt_date()
|
|||
local resolved = parse.resolve_date(due)
|
||||
if resolved then
|
||||
due = resolved
|
||||
elseif not due:match('^%d%d%d%d%-%d%d%-%d%d$') then
|
||||
vim.notify('Invalid date format. Use YYYY-MM-DD.', vim.log.levels.ERROR)
|
||||
elseif
|
||||
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
|
||||
end
|
||||
end
|
||||
store.update(id, { due = due })
|
||||
store.save()
|
||||
_save_and_notify()
|
||||
buffer.render(bufnr)
|
||||
end)
|
||||
end
|
||||
|
||||
---@param text string
|
||||
---@return nil
|
||||
function M.add(text)
|
||||
if not text or text == '' then
|
||||
vim.notify('Usage: :Pending add <description>', vim.log.levels.ERROR)
|
||||
|
|
@ -219,8 +414,10 @@ function M.add(text)
|
|||
description = description,
|
||||
category = metadata.cat,
|
||||
due = metadata.due,
|
||||
recur = metadata.rec,
|
||||
recur_mode = metadata.rec_mode,
|
||||
})
|
||||
store.save()
|
||||
_save_and_notify()
|
||||
local bufnr = buffer.bufnr()
|
||||
if bufnr and vim.api.nvim_buf_is_valid(bufnr) then
|
||||
buffer.render(bufnr)
|
||||
|
|
@ -228,16 +425,29 @@ function M.add(text)
|
|||
vim.notify('Pending added: ' .. description)
|
||||
end
|
||||
|
||||
function M.sync()
|
||||
local ok, gcal = pcall(require, 'pending.sync.gcal')
|
||||
if not ok then
|
||||
vim.notify('Google Calendar sync module not available.', vim.log.levels.ERROR)
|
||||
---@param backend_name string
|
||||
---@param action? string
|
||||
---@return nil
|
||||
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
|
||||
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
|
||||
|
||||
---@param days? integer
|
||||
---@return nil
|
||||
function M.archive(days)
|
||||
days = days or 30
|
||||
local cutoff = os.time() - (days * 86400)
|
||||
|
|
@ -266,7 +476,7 @@ function M.archive(days)
|
|||
::skip::
|
||||
end
|
||||
store.replace_tasks(kept)
|
||||
store.save()
|
||||
_save_and_notify()
|
||||
vim.notify('Archived ' .. archived .. ' tasks.')
|
||||
local bufnr = buffer.bufnr()
|
||||
if bufnr and vim.api.nvim_buf_is_valid(bufnr) then
|
||||
|
|
@ -274,8 +484,8 @@ function M.archive(days)
|
|||
end
|
||||
end
|
||||
|
||||
---@return nil
|
||||
function M.due()
|
||||
local today = os.date('%Y-%m-%d') --[[@as string]]
|
||||
local bufnr = buffer.bufnr()
|
||||
local is_valid = bufnr ~= nil and vim.api.nvim_buf_is_valid(bufnr)
|
||||
local meta = is_valid and buffer.meta() or nil
|
||||
|
|
@ -283,9 +493,14 @@ function M.due()
|
|||
|
||||
if meta and bufnr then
|
||||
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 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, {
|
||||
bufnr = bufnr,
|
||||
lnum = lnum,
|
||||
|
|
@ -297,8 +512,12 @@ function M.due()
|
|||
else
|
||||
store.load()
|
||||
for _, task in ipairs(store.active_tasks()) do
|
||||
if task.status == 'pending' and task.due and task.due <= today then
|
||||
local label = task.due < today and '[OVERDUE] ' or '[DUE] '
|
||||
if
|
||||
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
|
||||
if task.category then
|
||||
text = text .. ' [' .. task.category .. ']'
|
||||
|
|
@ -317,68 +536,283 @@ function M.due()
|
|||
vim.cmd('copen')
|
||||
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 dk = cfg.date_syntax or 'due'
|
||||
local lines = {
|
||||
'pending.nvim keybindings',
|
||||
'',
|
||||
'<CR> Toggle complete/uncomplete',
|
||||
'<Tab> Switch category/priority view',
|
||||
'! Toggle urgent',
|
||||
'D Set due date',
|
||||
'U Undo last write',
|
||||
'o / O Add new task line',
|
||||
'dd Delete task line (on :w)',
|
||||
'p / P Paste (duplicates get new IDs)',
|
||||
'zc / zo Fold/unfold category (category view)',
|
||||
':w Save all changes',
|
||||
'',
|
||||
':Pending add <text> Quick-add task',
|
||||
':Pending add Cat: <text> Quick-add with category',
|
||||
':Pending due Show overdue/due qflist',
|
||||
':Pending sync Push to Google Calendar',
|
||||
':Pending archive [days] Purge old done tasks',
|
||||
':Pending undo Undo last write',
|
||||
'',
|
||||
'Inline metadata (on new lines before :w):',
|
||||
' ' .. dk .. ':YYYY-MM-DD Set due date',
|
||||
' cat:Name Set category',
|
||||
'',
|
||||
'Due date input:',
|
||||
' today, tomorrow, +Nd, mon-sun',
|
||||
' Empty input clears due date',
|
||||
'',
|
||||
'Highlights:',
|
||||
' PendingOverdue overdue tasks (red)',
|
||||
' PendingPriority [!] urgent tasks',
|
||||
'',
|
||||
'Press q or <Esc> to close',
|
||||
}
|
||||
local buf = vim.api.nvim_create_buf(false, true)
|
||||
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
|
||||
vim.bo[buf].modifiable = false
|
||||
vim.bo[buf].bufhidden = 'wipe'
|
||||
local width = 54
|
||||
local height = #lines
|
||||
local win = vim.api.nvim_open_win(buf, true, {
|
||||
relative = 'editor',
|
||||
width = width,
|
||||
height = height,
|
||||
col = math.floor((vim.o.columns - width) / 2),
|
||||
row = math.floor((vim.o.lines - height) / 2),
|
||||
style = 'minimal',
|
||||
border = 'rounded',
|
||||
})
|
||||
vim.keymap.set('n', 'q', function()
|
||||
vim.api.nvim_win_close(win, true)
|
||||
end, { buffer = buf, silent = true })
|
||||
vim.keymap.set('n', '<Esc>', function()
|
||||
vim.api.nvim_win_close(win, true)
|
||||
end, { buffer = buf, silent = true })
|
||||
local rk = cfg.recur_syntax or 'rec'
|
||||
|
||||
if token == '+!' then
|
||||
return 'priority', 1, nil
|
||||
end
|
||||
if token == '-!' then
|
||||
return 'priority', 0, nil
|
||||
end
|
||||
if token == '-due' or token == '-' .. dk then
|
||||
return 'due', vim.NIL, nil
|
||||
end
|
||||
if token == '-cat' then
|
||||
return 'category', vim.NIL, nil
|
||||
end
|
||||
if token == '-rec' or token == '-' .. rk then
|
||||
return 'recur', vim.NIL, nil
|
||||
end
|
||||
if token == '-file' then
|
||||
return 'file_clear', true, nil
|
||||
end
|
||||
|
||||
local due_val = token:match('^' .. vim.pesc(dk) .. ':(.+)$')
|
||||
if due_val then
|
||||
local resolved = parse.resolve_date(due_val)
|
||||
if resolved then
|
||||
return 'due', resolved, nil
|
||||
end
|
||||
if
|
||||
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$')
|
||||
then
|
||||
return 'due', due_val, nil
|
||||
end
|
||||
return nil,
|
||||
nil,
|
||||
'Invalid date: ' .. due_val .. '. Use YYYY-MM-DD, today, tomorrow, +Nd, weekday names, etc.'
|
||||
end
|
||||
|
||||
local cat_val = token:match('^cat:(.+)$')
|
||||
if cat_val then
|
||||
return 'category', cat_val, nil
|
||||
end
|
||||
|
||||
local rec_val = token:match('^' .. vim.pesc(rk) .. ':(.+)$')
|
||||
if rec_val then
|
||||
local raw_spec = rec_val
|
||||
local rec_mode = nil
|
||||
if raw_spec:sub(1, 1) == '!' then
|
||||
rec_mode = 'completion'
|
||||
raw_spec = raw_spec:sub(2)
|
||||
end
|
||||
if not recur.validate(raw_spec) then
|
||||
return nil, nil, 'Invalid recurrence pattern: ' .. rec_val
|
||||
end
|
||||
return 'recur', { spec = raw_spec, mode = rec_mode }, nil
|
||||
end
|
||||
|
||||
return nil,
|
||||
nil,
|
||||
'Unknown operation: '
|
||||
.. token
|
||||
.. '. Valid: '
|
||||
.. dk
|
||||
.. ':<date>, cat:<name>, '
|
||||
.. rk
|
||||
.. ':<pattern>, file:<path>:<line>, +!, -!, -'
|
||||
.. dk
|
||||
.. ', -cat, -'
|
||||
.. rk
|
||||
.. ', -file'
|
||||
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')
|
||||
elseif field == 'file_clear' then
|
||||
updates.file_clear = true
|
||||
table.insert(feedback, 'file reference 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)
|
||||
|
||||
if updates.file_clear then
|
||||
local t = store.get(id)
|
||||
if t and t._extra then
|
||||
t._extra.file = nil
|
||||
if next(t._extra) == nil then
|
||||
t._extra = nil
|
||||
end
|
||||
t.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]]
|
||||
end
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
---@return nil
|
||||
function M.goto_file()
|
||||
local bufnr = vim.api.nvim_get_current_buf()
|
||||
if vim.bo[bufnr].filetype ~= 'pending' then
|
||||
return
|
||||
end
|
||||
local lnum = vim.api.nvim_win_get_cursor(0)[1]
|
||||
local meta = buffer.meta()
|
||||
local m = meta and meta[lnum]
|
||||
if not m or m.type ~= 'task' then
|
||||
vim.notify('No task on this line', vim.log.levels.WARN)
|
||||
return
|
||||
end
|
||||
local task = store.get(m.id)
|
||||
if not task or not task._extra or not task._extra.file then
|
||||
vim.notify('No file attached to this task', vim.log.levels.WARN)
|
||||
return
|
||||
end
|
||||
local file_spec = task._extra.file
|
||||
local rel_path, line_str = file_spec:match('^(.+):(%d+)$')
|
||||
if not rel_path then
|
||||
vim.notify('Invalid file spec: ' .. file_spec, vim.log.levels.ERROR)
|
||||
return
|
||||
end
|
||||
local data_dir = vim.fn.fnamemodify(config.get().data_path, ':h')
|
||||
local abs_path = data_dir .. '/' .. rel_path
|
||||
if vim.fn.filereadable(abs_path) == 0 then
|
||||
vim.notify('File not found: ' .. abs_path, vim.log.levels.ERROR)
|
||||
return
|
||||
end
|
||||
vim.cmd.edit(abs_path)
|
||||
local lnum_target = tonumber(line_str) or 1
|
||||
vim.api.nvim_win_set_cursor(0, { lnum_target, 0 })
|
||||
end
|
||||
|
||||
---@return nil
|
||||
function M.add_here()
|
||||
local cur_bufnr = vim.api.nvim_get_current_buf()
|
||||
if vim.bo[cur_bufnr].filetype == 'pending' then
|
||||
vim.notify('Already in pending buffer', vim.log.levels.WARN)
|
||||
return
|
||||
end
|
||||
local cur_file = vim.api.nvim_buf_get_name(cur_bufnr)
|
||||
if cur_file == '' or vim.fn.filereadable(cur_file) == 0 then
|
||||
vim.notify('Not editing a readable file', vim.log.levels.ERROR)
|
||||
return
|
||||
end
|
||||
local cur_lnum = vim.api.nvim_win_get_cursor(0)[1]
|
||||
local data_dir = vim.fn.fnamemodify(config.get().data_path, ':h')
|
||||
local abs_file = vim.fn.fnamemodify(cur_file, ':p')
|
||||
local rel_file
|
||||
if abs_file:sub(1, #data_dir + 1) == data_dir .. '/' then
|
||||
rel_file = abs_file:sub(#data_dir + 2)
|
||||
else
|
||||
rel_file = abs_file
|
||||
end
|
||||
local file_spec = rel_file .. ':' .. cur_lnum
|
||||
store.load()
|
||||
local tasks = store.active_tasks()
|
||||
if #tasks == 0 then
|
||||
vim.notify('No active tasks', vim.log.levels.INFO)
|
||||
return
|
||||
end
|
||||
local items = {}
|
||||
for _, task in ipairs(tasks) do
|
||||
table.insert(items, task)
|
||||
end
|
||||
vim.ui.select(items, {
|
||||
prompt = 'Attach file to task:',
|
||||
format_item = function(task)
|
||||
return '[' .. task.id .. '] ' .. task.description
|
||||
end,
|
||||
}, function(task)
|
||||
if not task then
|
||||
return
|
||||
end
|
||||
task._extra = task._extra or {}
|
||||
task._extra.file = file_spec
|
||||
task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]]
|
||||
store.save()
|
||||
vim.notify('Attached ' .. file_spec .. ' to task ' .. task.id)
|
||||
end)
|
||||
end
|
||||
|
||||
---@param args string
|
||||
---@return nil
|
||||
function M.command(args)
|
||||
if not args or args == '' then
|
||||
M.open()
|
||||
|
|
@ -387,8 +821,12 @@ function M.command(args)
|
|||
local cmd, rest = args:match('^(%S+)%s*(.*)')
|
||||
if cmd == 'add' then
|
||||
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
|
||||
M.sync()
|
||||
local backend, action = rest:match('^(%S+)%s*(.*)')
|
||||
M.sync(backend, action)
|
||||
elseif cmd == 'archive' then
|
||||
local d = rest ~= '' and tonumber(rest) or nil
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
local weekday_map = {
|
||||
sun = 1,
|
||||
mon = 2,
|
||||
|
|
@ -39,26 +120,160 @@ local weekday_map = {
|
|||
sat = 7,
|
||||
}
|
||||
|
||||
---@param text string
|
||||
---@return string|nil
|
||||
function M.resolve_date(text)
|
||||
local lower = text:lower()
|
||||
local today = os.date('*t') --[[@as osdate]]
|
||||
local month_map = {
|
||||
jan = 1,
|
||||
feb = 2,
|
||||
mar = 3,
|
||||
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]]
|
||||
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
|
||||
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',
|
||||
os.time({ year = today.year, month = today.month, day = today.day + 1 })
|
||||
) --[[@as string]]
|
||||
os.time({ year = today.year, month = today.month, day = today.day + delta })
|
||||
) --[[@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
|
||||
|
||||
local n = lower:match('^%+(%d+)d$')
|
||||
if n then
|
||||
return os.date(
|
||||
return append_time(
|
||||
os.date(
|
||||
'%Y-%m-%d',
|
||||
os.time({
|
||||
year = today.year,
|
||||
|
|
@ -67,17 +282,133 @@ function M.resolve_date(text)
|
|||
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
|
||||
|
||||
local target_wday = weekday_map[lower]
|
||||
if target_wday then
|
||||
local current_wday = today.wday
|
||||
local delta = (target_wday - current_wday) % 7
|
||||
return os.date(
|
||||
return append_time(
|
||||
os.date(
|
||||
'%Y-%m-%d',
|
||||
os.time({ year = today.year, month = today.month, day = today.day + delta })
|
||||
) --[[@as string]]
|
||||
) --[[@as string]],
|
||||
time_suffix
|
||||
)
|
||||
end
|
||||
|
||||
return nil
|
||||
|
|
@ -85,7 +416,7 @@ end
|
|||
|
||||
---@param text string
|
||||
---@return string description
|
||||
---@return { due?: string, cat?: string } metadata
|
||||
---@return { due?: string, cat?: string, rec?: string, rec_mode?: 'scheduled'|'completion', file?: string } metadata
|
||||
function M.body(text)
|
||||
local tokens = {}
|
||||
for token in text:gmatch('%S+') do
|
||||
|
|
@ -95,8 +426,10 @@ function M.body(text)
|
|||
local metadata = {}
|
||||
local i = #tokens
|
||||
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 rec_pattern = '^' .. vim.pesc(rk) .. ':(%S+)$'
|
||||
|
||||
while i >= 1 do
|
||||
local token = tokens[i]
|
||||
|
|
@ -105,7 +438,7 @@ function M.body(text)
|
|||
if metadata.due then
|
||||
break
|
||||
end
|
||||
if not is_valid_date(due_val) then
|
||||
if not is_valid_datetime(due_val) then
|
||||
break
|
||||
end
|
||||
metadata.due = due_val
|
||||
|
|
@ -131,8 +464,37 @@ function M.body(text)
|
|||
metadata.cat = cat_val
|
||||
i = i - 1
|
||||
else
|
||||
local rec_val = token:match(rec_pattern)
|
||||
if rec_val then
|
||||
if metadata.rec then
|
||||
break
|
||||
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
|
||||
local file_path_val, file_line_val = token:match('^file:(.+):(%d+)$')
|
||||
if file_path_val and file_line_val then
|
||||
if metadata.file then
|
||||
break
|
||||
end
|
||||
metadata.file = file_path_val .. ':' .. file_line_val
|
||||
i = i - 1
|
||||
elseif token:match('^file:') then
|
||||
break
|
||||
else
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -148,7 +510,7 @@ end
|
|||
|
||||
---@param text string
|
||||
---@return string description
|
||||
---@return { due?: string, cat?: string } metadata
|
||||
---@return { due?: string, cat?: string, rec?: string, rec_mode?: 'scheduled'|'completion', file?: string } metadata
|
||||
function M.command_add(text)
|
||||
local cat_prefix = text:match('^(%S.-):%s')
|
||||
if cat_prefix then
|
||||
|
|
@ -165,4 +527,39 @@ function M.command_add(text)
|
|||
return M.body(text)
|
||||
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
|
||||
|
|
|
|||
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 priority integer
|
||||
---@field due? string
|
||||
---@field recur? string
|
||||
---@field recur_mode? 'scheduled'|'completion'
|
||||
---@field entry string
|
||||
---@field modified string
|
||||
---@field end? string
|
||||
|
|
@ -17,6 +19,7 @@ local config = require('pending.config')
|
|||
---@field version integer
|
||||
---@field next_id integer
|
||||
---@field tasks pending.Task[]
|
||||
---@field undo pending.Task[][]
|
||||
|
||||
---@class pending.store
|
||||
local M = {}
|
||||
|
|
@ -32,6 +35,7 @@ local function empty_data()
|
|||
version = SUPPORTED_VERSION,
|
||||
next_id = 1,
|
||||
tasks = {},
|
||||
undo = {},
|
||||
}
|
||||
end
|
||||
|
||||
|
|
@ -56,6 +60,8 @@ local known_fields = {
|
|||
category = true,
|
||||
priority = true,
|
||||
due = true,
|
||||
recur = true,
|
||||
recur_mode = true,
|
||||
entry = true,
|
||||
modified = true,
|
||||
['end'] = true,
|
||||
|
|
@ -81,6 +87,12 @@ local function task_to_table(task)
|
|||
if task.due then
|
||||
t.due = task.due
|
||||
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
|
||||
t['end'] = task['end']
|
||||
end
|
||||
|
|
@ -105,6 +117,8 @@ local function table_to_task(t)
|
|||
category = t.category,
|
||||
priority = t.priority or 0,
|
||||
due = t.due,
|
||||
recur = t.recur,
|
||||
recur_mode = t.recur_mode,
|
||||
entry = t.entry,
|
||||
modified = t.modified,
|
||||
['end'] = t['end'],
|
||||
|
|
@ -153,13 +167,24 @@ function M.load()
|
|||
version = decoded.version or SUPPORTED_VERSION,
|
||||
next_id = decoded.next_id or 1,
|
||||
tasks = {},
|
||||
undo = {},
|
||||
}
|
||||
for _, t in ipairs(decoded.tasks or {}) do
|
||||
table.insert(_data.tasks, table_to_task(t))
|
||||
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
|
||||
end
|
||||
|
||||
---@return nil
|
||||
function M.save()
|
||||
if not _data then
|
||||
return
|
||||
|
|
@ -170,10 +195,18 @@ function M.save()
|
|||
version = _data.version,
|
||||
next_id = _data.next_id,
|
||||
tasks = {},
|
||||
undo = {},
|
||||
}
|
||||
for _, task in ipairs(_data.tasks) do
|
||||
table.insert(out.tasks, task_to_table(task))
|
||||
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 tmp = path .. '.tmp'
|
||||
local f = io.open(tmp, 'w')
|
||||
|
|
@ -224,7 +257,7 @@ function M.get(id)
|
|||
return nil
|
||||
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
|
||||
function M.add(fields)
|
||||
local data = M.data()
|
||||
|
|
@ -236,6 +269,8 @@ function M.add(fields)
|
|||
category = fields.category or config.get().default_category,
|
||||
priority = fields.priority or 0,
|
||||
due = fields.due,
|
||||
recur = fields.recur,
|
||||
recur_mode = fields.recur_mode,
|
||||
entry = now,
|
||||
modified = now,
|
||||
['end'] = nil,
|
||||
|
|
@ -258,9 +293,13 @@ function M.update(id, fields)
|
|||
local now = timestamp()
|
||||
for k, v in pairs(fields) do
|
||||
if k ~= 'id' and k ~= 'entry' then
|
||||
if v == vim.NIL then
|
||||
task[k] = nil
|
||||
else
|
||||
task[k] = v
|
||||
end
|
||||
end
|
||||
end
|
||||
task.modified = now
|
||||
if fields.status == 'done' or fields.status == 'deleted' then
|
||||
task['end'] = task['end'] or now
|
||||
|
|
@ -286,6 +325,7 @@ function M.find_index(id)
|
|||
end
|
||||
|
||||
---@param tasks pending.Task[]
|
||||
---@return nil
|
||||
function M.replace_tasks(tasks)
|
||||
M.data().tasks = tasks
|
||||
end
|
||||
|
|
@ -311,11 +351,24 @@ function M.snapshot()
|
|||
return result
|
||||
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
|
||||
---@return nil
|
||||
function M.set_next_id(id)
|
||||
M.data().next_id = id
|
||||
end
|
||||
|
||||
---@return nil
|
||||
function M.unload()
|
||||
_data = nil
|
||||
end
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ local store = require('pending.store')
|
|||
|
||||
local M = {}
|
||||
|
||||
M.name = 'gcal'
|
||||
|
||||
local BASE_URL = 'https://www.googleapis.com/calendar/v3'
|
||||
local TOKEN_URL = 'https://oauth2.googleapis.com/token'
|
||||
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>
|
||||
local function gcal_config()
|
||||
local cfg = config.get()
|
||||
return cfg.gcal or {}
|
||||
return (cfg.sync and cfg.sync.gcal) or cfg.gcal or {}
|
||||
end
|
||||
|
||||
---@return string
|
||||
|
|
@ -199,7 +201,7 @@ local function get_access_token()
|
|||
end
|
||||
local tokens = load_tokens()
|
||||
if not tokens or not tokens.refresh_token then
|
||||
M.authorize()
|
||||
M.auth()
|
||||
tokens = load_tokens()
|
||||
if not tokens then
|
||||
return nil
|
||||
|
|
@ -218,7 +220,7 @@ local function get_access_token()
|
|||
return tokens.access_token
|
||||
end
|
||||
|
||||
function M.authorize()
|
||||
function M.auth()
|
||||
local creds = load_credentials()
|
||||
if not creds then
|
||||
vim.notify(
|
||||
|
|
@ -503,6 +505,7 @@ function M.sync()
|
|||
end
|
||||
|
||||
store.save()
|
||||
require('pending')._recompute_counts()
|
||||
vim.notify(
|
||||
string.format(
|
||||
'pending.nvim: Synced to Google Calendar (created: %d, updated: %d, deleted: %d)',
|
||||
|
|
@ -513,4 +516,18 @@ function M.sync()
|
|||
)
|
||||
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
|
||||
|
|
|
|||
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 parse = require('pending.parse')
|
||||
|
||||
---@class pending.LineMeta
|
||||
---@field type 'task'|'header'|'blank'
|
||||
|
|
@ -10,6 +11,8 @@ local config = require('pending.config')
|
|||
---@field overdue? boolean
|
||||
---@field show_category? boolean
|
||||
---@field priority? integer
|
||||
---@field recur? string
|
||||
---@field file? string
|
||||
|
||||
---@class pending.views
|
||||
local M = {}
|
||||
|
|
@ -20,7 +23,10 @@ local function format_due(due)
|
|||
if not due then
|
||||
return nil
|
||||
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
|
||||
return due
|
||||
end
|
||||
|
|
@ -29,7 +35,11 @@ local function format_due(due)
|
|||
month = tonumber(m) --[[@as integer]],
|
||||
day = tonumber(d) --[[@as integer]],
|
||||
})
|
||||
return os.date(config.get().date_format, t) --[[@as string]]
|
||||
local formatted = os.date(config.get().date_format, t) --[[@as string]]
|
||||
if hh then
|
||||
formatted = formatted .. ' ' .. hh .. ':' .. mm
|
||||
end
|
||||
return formatted
|
||||
end
|
||||
|
||||
---@param tasks pending.Task[]
|
||||
|
|
@ -73,7 +83,6 @@ end
|
|||
---@return string[] lines
|
||||
---@return pending.LineMeta[] meta
|
||||
function M.category_view(tasks)
|
||||
local today = os.date('%Y-%m-%d') --[[@as string]]
|
||||
local by_cat = {}
|
||||
local cat_order = {}
|
||||
local cat_seen = {}
|
||||
|
|
@ -148,7 +157,10 @@ function M.category_view(tasks)
|
|||
raw_due = task.due,
|
||||
status = task.status,
|
||||
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,
|
||||
file = task._extra and task._extra.file or nil,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
|
@ -160,7 +172,6 @@ end
|
|||
---@return string[] lines
|
||||
---@return pending.LineMeta[] meta
|
||||
function M.priority_view(tasks)
|
||||
local today = os.date('%Y-%m-%d') --[[@as string]]
|
||||
local pending = {}
|
||||
local done = {}
|
||||
|
||||
|
|
@ -198,8 +209,10 @@ function M.priority_view(tasks)
|
|||
raw_due = task.due,
|
||||
status = task.status,
|
||||
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,
|
||||
recur = task.recur,
|
||||
file = task._extra and task._extra.file or nil,
|
||||
})
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -3,16 +3,203 @@ if vim.g.loaded_pending then
|
|||
end
|
||||
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 .. ':',
|
||||
'file:',
|
||||
'+!',
|
||||
'-!',
|
||||
'-' .. dk,
|
||||
'-cat',
|
||||
'-' .. rk,
|
||||
'-file',
|
||||
}
|
||||
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)
|
||||
require('pending').command(opts.args)
|
||||
end, {
|
||||
bar = true,
|
||||
nargs = '*',
|
||||
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
|
||||
return vim.tbl_filter(function(s)
|
||||
return s:find(arg_lead, 1, true) == 1
|
||||
end, subcmds)
|
||||
return filter_candidates(arg_lead, subcmds)
|
||||
end
|
||||
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
|
||||
return {}
|
||||
end,
|
||||
|
|
@ -22,6 +209,10 @@ vim.keymap.set('n', '<Plug>(pending-open)', function()
|
|||
require('pending').open()
|
||||
end)
|
||||
|
||||
vim.keymap.set('n', '<Plug>(pending-close)', function()
|
||||
require('pending.buffer').close()
|
||||
end)
|
||||
|
||||
vim.keymap.set('n', '<Plug>(pending-toggle)', function()
|
||||
require('pending').toggle_complete()
|
||||
end)
|
||||
|
|
@ -37,3 +228,55 @@ end)
|
|||
vim.keymap.set('n', '<Plug>(pending-date)', function()
|
||||
require('pending').prompt_date()
|
||||
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)
|
||||
|
||||
vim.keymap.set('n', '<Plug>(pending-goto-file)', function()
|
||||
require('pending').goto_file()
|
||||
end)
|
||||
|
||||
vim.keymap.set('n', '<Plug>(pending-add-here)', function()
|
||||
require('pending').add_here()
|
||||
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)
|
||||
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()
|
||||
local lines = {
|
||||
'## Inbox',
|
||||
|
|
@ -206,6 +225,60 @@ describe('diff', function()
|
|||
assert.is_nil(task.due)
|
||||
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()
|
||||
store.add({ description = 'Task name', priority = 1 })
|
||||
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)
|
||||
348
spec/file_spec.lua
Normal file
348
spec/file_spec.lua
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
require('spec.helpers')
|
||||
|
||||
local config = require('pending.config')
|
||||
local parse = require('pending.parse')
|
||||
local store = require('pending.store')
|
||||
local diff = require('pending.diff')
|
||||
local views = require('pending.views')
|
||||
|
||||
describe('file token', function()
|
||||
local tmpdir
|
||||
|
||||
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()
|
||||
store.unload()
|
||||
end)
|
||||
|
||||
describe('parse.body', function()
|
||||
it('extracts file token with path and line number', function()
|
||||
local desc, meta = parse.body('Fix the bug file:src/auth.lua:42')
|
||||
assert.are.equal('Fix the bug', desc)
|
||||
assert.are.equal('src/auth.lua:42', meta.file)
|
||||
end)
|
||||
|
||||
it('extracts file token with nested path', function()
|
||||
local desc, meta = parse.body('Do something file:lua/pending/init.lua:100')
|
||||
assert.are.equal('Do something', desc)
|
||||
assert.are.equal('lua/pending/init.lua:100', meta.file)
|
||||
end)
|
||||
|
||||
it('strips file token from description', function()
|
||||
local desc, meta = parse.body('Task description file:foo.lua:1')
|
||||
assert.are.equal('Task description', desc)
|
||||
assert.are.equal('foo.lua:1', meta.file)
|
||||
end)
|
||||
|
||||
it('stops parsing on duplicate file token', function()
|
||||
local desc, meta = parse.body('Task file:b.lua:2 file:a.lua:1')
|
||||
assert.are.equal('Task file:b.lua:2', desc)
|
||||
assert.are.equal('a.lua:1', meta.file)
|
||||
end)
|
||||
|
||||
it('treats malformed file token (no line number) as non-metadata', function()
|
||||
local desc, meta = parse.body('Task file:nolineno')
|
||||
assert.are.equal('Task file:nolineno', desc)
|
||||
assert.is_nil(meta.file)
|
||||
end)
|
||||
|
||||
it('treats file: prefix with no path as non-metadata', function()
|
||||
local desc, meta = parse.body('Task file:')
|
||||
assert.are.equal('Task file:', desc)
|
||||
assert.is_nil(meta.file)
|
||||
end)
|
||||
|
||||
it('handles file token alongside other metadata tokens', function()
|
||||
local desc, meta = parse.body('Task cat:Work file:src/main.lua:10')
|
||||
assert.are.equal('Task', desc)
|
||||
assert.are.equal('Work', meta.cat)
|
||||
assert.are.equal('src/main.lua:10', meta.file)
|
||||
end)
|
||||
|
||||
it('does not extract file token when line number is not numeric', function()
|
||||
local desc, meta = parse.body('Task file:src/foo.lua:abc')
|
||||
assert.are.equal('Task file:src/foo.lua:abc', desc)
|
||||
assert.is_nil(meta.file)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe('diff reconciliation', function()
|
||||
it('stores file field in _extra on write', function()
|
||||
local t = store.add({ description = 'Task one' })
|
||||
store.save()
|
||||
local lines = {
|
||||
'/' .. t.id .. '/- [ ] Task one file:src/auth.lua:42',
|
||||
}
|
||||
diff.apply(lines)
|
||||
local updated = store.get(t.id)
|
||||
assert.is_not_nil(updated._extra)
|
||||
assert.are.equal('src/auth.lua:42', updated._extra.file)
|
||||
end)
|
||||
|
||||
it('updates file field when token changes', function()
|
||||
local t = store.add({ description = 'Task one', _extra = { file = 'old.lua:1' } })
|
||||
store.save()
|
||||
local lines = {
|
||||
'/' .. t.id .. '/- [ ] Task one file:new.lua:99',
|
||||
}
|
||||
diff.apply(lines)
|
||||
local updated = store.get(t.id)
|
||||
assert.are.equal('new.lua:99', updated._extra.file)
|
||||
end)
|
||||
|
||||
it('clears file field when token is removed from line', function()
|
||||
local t = store.add({ description = 'Task one', _extra = { file = 'src/auth.lua:42' } })
|
||||
store.save()
|
||||
local lines = {
|
||||
'/' .. t.id .. '/- [ ] Task one',
|
||||
}
|
||||
diff.apply(lines)
|
||||
local updated = store.get(t.id)
|
||||
assert.is_nil(updated._extra)
|
||||
end)
|
||||
|
||||
it('preserves other _extra fields when file is cleared', function()
|
||||
local t = store.add({ description = 'Task one', _extra = { file = 'src/auth.lua:42', _gcal_event_id = 'abc123' } })
|
||||
store.save()
|
||||
local lines = {
|
||||
'/' .. t.id .. '/- [ ] Task one',
|
||||
}
|
||||
diff.apply(lines)
|
||||
local updated = store.get(t.id)
|
||||
assert.is_not_nil(updated._extra)
|
||||
assert.is_nil(updated._extra.file)
|
||||
assert.are.equal('abc123', updated._extra._gcal_event_id)
|
||||
end)
|
||||
|
||||
it('round-trips file field through JSON', function()
|
||||
local t = store.add({ description = 'Task one' })
|
||||
store.save()
|
||||
local lines = {
|
||||
'/' .. t.id .. '/- [ ] Task one file:src/auth.lua:42',
|
||||
}
|
||||
diff.apply(lines)
|
||||
store.unload()
|
||||
store.load()
|
||||
local loaded = store.get(t.id)
|
||||
assert.is_not_nil(loaded._extra)
|
||||
assert.are.equal('src/auth.lua:42', loaded._extra.file)
|
||||
end)
|
||||
|
||||
it('accepts optional hidden_ids parameter without error', function()
|
||||
local t = store.add({ description = 'Task one' })
|
||||
store.save()
|
||||
local lines = {
|
||||
'/' .. t.id .. '/- [ ] Task one',
|
||||
}
|
||||
assert.has_no_error(function()
|
||||
diff.apply(lines, {})
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe('LineMeta', function()
|
||||
it('category_view populates file field in LineMeta', function()
|
||||
local t = store.add({
|
||||
description = 'Task one',
|
||||
_extra = { file = 'src/auth.lua:42' },
|
||||
})
|
||||
store.save()
|
||||
local tasks = store.active_tasks()
|
||||
local _, meta = views.category_view(tasks)
|
||||
local task_meta = nil
|
||||
for _, m in ipairs(meta) do
|
||||
if m.type == 'task' and m.id == t.id then
|
||||
task_meta = m
|
||||
break
|
||||
end
|
||||
end
|
||||
assert.is_not_nil(task_meta)
|
||||
assert.are.equal('src/auth.lua:42', task_meta.file)
|
||||
end)
|
||||
|
||||
it('priority_view populates file field in LineMeta', function()
|
||||
local t = store.add({
|
||||
description = 'Task one',
|
||||
_extra = { file = 'src/auth.lua:42' },
|
||||
})
|
||||
store.save()
|
||||
local tasks = store.active_tasks()
|
||||
local _, meta = views.priority_view(tasks)
|
||||
local task_meta = nil
|
||||
for _, m in ipairs(meta) do
|
||||
if m.type == 'task' and m.id == t.id then
|
||||
task_meta = m
|
||||
break
|
||||
end
|
||||
end
|
||||
assert.is_not_nil(task_meta)
|
||||
assert.are.equal('src/auth.lua:42', task_meta.file)
|
||||
end)
|
||||
|
||||
it('file field is nil in LineMeta when task has no file', function()
|
||||
local t = store.add({ description = 'Task one' })
|
||||
store.save()
|
||||
local tasks = store.active_tasks()
|
||||
local _, meta = views.category_view(tasks)
|
||||
local task_meta = nil
|
||||
for _, m in ipairs(meta) do
|
||||
if m.type == 'task' and m.id == t.id then
|
||||
task_meta = m
|
||||
break
|
||||
end
|
||||
end
|
||||
assert.is_not_nil(task_meta)
|
||||
assert.is_nil(task_meta.file)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe(':Pending edit -file', function()
|
||||
it('clears file reference from task', function()
|
||||
local pending = require('pending')
|
||||
local t = store.add({ description = 'Task one', _extra = { file = 'src/auth.lua:42' } })
|
||||
store.save()
|
||||
pending.edit(tostring(t.id), '-file')
|
||||
local updated = store.get(t.id)
|
||||
assert.is_nil(updated._extra)
|
||||
end)
|
||||
|
||||
it('shows feedback when file reference is removed', function()
|
||||
local pending = require('pending')
|
||||
local t = store.add({ description = 'Task one', _extra = { file = 'src/auth.lua:42' } })
|
||||
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), '-file')
|
||||
vim.notify = orig_notify
|
||||
assert.are.equal(1, #messages)
|
||||
assert.truthy(messages[1].msg:find('file reference removed'))
|
||||
end)
|
||||
|
||||
it('does not error when task has no file', function()
|
||||
local pending = require('pending')
|
||||
local t = store.add({ description = 'Task one' })
|
||||
store.save()
|
||||
assert.has_no_error(function()
|
||||
pending.edit(tostring(t.id), '-file')
|
||||
end)
|
||||
end)
|
||||
|
||||
it('preserves other _extra fields when -file is used', function()
|
||||
local pending = require('pending')
|
||||
local t = store.add({
|
||||
description = 'Task one',
|
||||
_extra = { file = 'src/auth.lua:42', _gcal_event_id = 'abc' },
|
||||
})
|
||||
store.save()
|
||||
pending.edit(tostring(t.id), '-file')
|
||||
local updated = store.get(t.id)
|
||||
assert.is_not_nil(updated._extra)
|
||||
assert.is_nil(updated._extra.file)
|
||||
assert.are.equal('abc', updated._extra._gcal_event_id)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe('goto_file', function()
|
||||
it('notifies warn when task has no file attached', function()
|
||||
local pending = require('pending')
|
||||
local buffer = require('pending.buffer')
|
||||
|
||||
local t = store.add({ description = 'Task one' })
|
||||
store.save()
|
||||
|
||||
local bufnr = buffer.open()
|
||||
vim.bo[bufnr].filetype = 'pending'
|
||||
vim.api.nvim_set_current_buf(bufnr)
|
||||
|
||||
local meta = buffer.meta()
|
||||
local task_lnum = nil
|
||||
for lnum, m in ipairs(meta) do
|
||||
if m.type == 'task' and m.id == t.id then
|
||||
task_lnum = lnum
|
||||
break
|
||||
end
|
||||
end
|
||||
assert.is_not_nil(task_lnum)
|
||||
vim.api.nvim_win_set_cursor(0, { task_lnum, 0 })
|
||||
|
||||
local messages = {}
|
||||
local orig_notify = vim.notify
|
||||
vim.notify = function(msg, level)
|
||||
table.insert(messages, { msg = msg, level = level })
|
||||
end
|
||||
|
||||
pending.goto_file()
|
||||
|
||||
vim.notify = orig_notify
|
||||
|
||||
local warned = false
|
||||
for _, m in ipairs(messages) do
|
||||
if m.level == vim.log.levels.WARN and m.msg:find('No file attached') then
|
||||
warned = true
|
||||
end
|
||||
end
|
||||
assert.is_true(warned)
|
||||
|
||||
vim.api.nvim_buf_delete(bufnr, { force = true })
|
||||
end)
|
||||
|
||||
it('notifies error when file spec is unreadable', function()
|
||||
local pending = require('pending')
|
||||
local buffer = require('pending.buffer')
|
||||
|
||||
local t = store.add({
|
||||
description = 'Task one',
|
||||
_extra = { file = 'nonexistent/path.lua:1' },
|
||||
})
|
||||
store.save()
|
||||
|
||||
local bufnr = buffer.open()
|
||||
vim.bo[bufnr].filetype = 'pending'
|
||||
vim.api.nvim_set_current_buf(bufnr)
|
||||
|
||||
local meta = buffer.meta()
|
||||
local task_lnum = nil
|
||||
for lnum, m in ipairs(meta) do
|
||||
if m.type == 'task' and m.id == t.id then
|
||||
task_lnum = lnum
|
||||
break
|
||||
end
|
||||
end
|
||||
assert.is_not_nil(task_lnum)
|
||||
vim.api.nvim_win_set_cursor(0, { task_lnum, 0 })
|
||||
|
||||
local messages = {}
|
||||
local orig_notify = vim.notify
|
||||
vim.notify = function(msg, level)
|
||||
table.insert(messages, { msg = msg, level = level })
|
||||
end
|
||||
|
||||
pending.goto_file()
|
||||
|
||||
vim.notify = orig_notify
|
||||
|
||||
local errored = false
|
||||
for _, m in ipairs(messages) do
|
||||
if m.level == vim.log.levels.ERROR and m.msg:find('File not found') then
|
||||
errored = true
|
||||
end
|
||||
end
|
||||
assert.is_true(errored)
|
||||
|
||||
vim.api.nvim_buf_delete(bufnr, { force = true })
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
|
|
@ -154,6 +154,240 @@ describe('parse', function()
|
|||
local result = parse.resolve_date('')
|
||||
assert.is_nil(result)
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
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()
|
||||
it('excludes deleted tasks', function()
|
||||
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)
|
||||
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()
|
||||
vim.g.pending = { data_path = tmpdir .. '/tasks.json', category_order = { 'Work', 'Inbox' } }
|
||||
config.reset()
|
||||
|
|
@ -399,5 +423,29 @@ describe('views', function()
|
|||
end
|
||||
assert.is_falsy(task_meta.overdue)
|
||||
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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue