From fa1103ad4e089804ec3dd27e91a3d93378464187 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 25 Feb 2026 09:37:49 -0500 Subject: [PATCH 01/21] doc: minify readme --- README.md | 143 ++++++------------------------------------------------ 1 file changed, 14 insertions(+), 129 deletions(-) diff --git a/README.md b/README.md index 98e14d3..df7f3dd 100644 --- a/README.md +++ b/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. + -## 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 | -| `` | Toggle complete (immediate) | -| `` | 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 `` mappings for custom keybinds: - -```lua -vim.keymap.set('n', 't', '(pending-open)') -vim.keymap.set('n', 'T', '(pending-toggle)') -``` - -| Plug mapping | Action | -| -------------------------- | -------------------- | -| `(pending-open)` | Open task buffer | -| `(pending-toggle)` | Toggle complete | -| `(pending-view)` | Switch view | -| `(pending-priority)` | Toggle priority flag | -| `(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) From 8433d928575e692cf74f20edb124580a9146db3e Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 25 Feb 2026 09:39:11 -0500 Subject: [PATCH 02/21] ci: format --- .github/DISCUSSION_TEMPLATE/q-a.yaml | 2 +- .github/ISSUE_TEMPLATE/bug_report.yaml | 17 ++++++++--------- .github/ISSUE_TEMPLATE/feature_request.yaml | 5 ++--- .github/workflows/luarocks.yaml | 2 +- 4 files changed, 12 insertions(+), 14 deletions(-) diff --git a/.github/DISCUSSION_TEMPLATE/q-a.yaml b/.github/DISCUSSION_TEMPLATE/q-a.yaml index a65fd46..0e657eb 100644 --- a/.github/DISCUSSION_TEMPLATE/q-a.yaml +++ b/.github/DISCUSSION_TEMPLATE/q-a.yaml @@ -1,4 +1,4 @@ -title: 'Q&A' +title: "Q&A" labels: [] body: - type: markdown diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index baae06b..0796c39 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -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 diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index cabb27c..f4c02eb 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -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 diff --git a/.github/workflows/luarocks.yaml b/.github/workflows/luarocks.yaml index 9b6664e..9f934a5 100644 --- a/.github/workflows/luarocks.yaml +++ b/.github/workflows/luarocks.yaml @@ -3,7 +3,7 @@ name: luarocks on: push: tags: - - 'v*' + - "v*" jobs: quality: From 6911c091f672058992cdee03f83864ff82101f86 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 25 Feb 2026 09:40:06 -0500 Subject: [PATCH 03/21] doc: minify readme (#24) * doc: minify readme * ci: format --- .github/DISCUSSION_TEMPLATE/q-a.yaml | 2 +- .github/ISSUE_TEMPLATE/bug_report.yaml | 17 ++- .github/ISSUE_TEMPLATE/feature_request.yaml | 5 +- .github/workflows/luarocks.yaml | 2 +- README.md | 143 ++------------------ 5 files changed, 26 insertions(+), 143 deletions(-) diff --git a/.github/DISCUSSION_TEMPLATE/q-a.yaml b/.github/DISCUSSION_TEMPLATE/q-a.yaml index a65fd46..0e657eb 100644 --- a/.github/DISCUSSION_TEMPLATE/q-a.yaml +++ b/.github/DISCUSSION_TEMPLATE/q-a.yaml @@ -1,4 +1,4 @@ -title: 'Q&A' +title: "Q&A" labels: [] body: - type: markdown diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index baae06b..0796c39 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -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 diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index cabb27c..f4c02eb 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -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 diff --git a/.github/workflows/luarocks.yaml b/.github/workflows/luarocks.yaml index 9b6664e..9f934a5 100644 --- a/.github/workflows/luarocks.yaml +++ b/.github/workflows/luarocks.yaml @@ -3,7 +3,7 @@ name: luarocks on: push: tags: - - 'v*' + - "v*" jobs: quality: diff --git a/README.md b/README.md index 98e14d3..df7f3dd 100644 --- a/README.md +++ b/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. + -## 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 | -| `` | Toggle complete (immediate) | -| `` | 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 `` mappings for custom keybinds: - -```lua -vim.keymap.set('n', 't', '(pending-open)') -vim.keymap.set('n', 'T', '(pending-toggle)') -``` - -| Plug mapping | Action | -| -------------------------- | -------------------- | -| `(pending-open)` | Open task buffer | -| `(pending-toggle)` | Toggle complete | -| `(pending-view)` | Switch view | -| `(pending-priority)` | Toggle priority flag | -| `(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) From 7d93c4bb45f3939e7cd3d06bfbd603cc6ad1a03f Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 25 Feb 2026 13:27:52 -0500 Subject: [PATCH 04/21] feat: omnifunc completion, recurring tasks, expanded date syntax (#27) * feat(config): add recur_syntax and someday_date fields Problem: the plugin needs configuration for the recurrence token name and the sentinel date used by the `later`/`someday` named dates. Solution: add `recur_syntax` (default 'rec') and `someday_date` (default '9999-12-30') to pending.Config and the defaults table. * feat(parse): expand date vocabulary with named dates Problem: the date input only supports today, tomorrow, +Nd, and weekday names, lacking relative offsets like weeks/months, period boundaries, ordinals, month names, and backdating. Solution: add yesterday, eod, sow/eow, som/eom, soq/eoq, soy/eoy, +Nw, +Nm, -Nd, -Nw, ordinals (1st-31st), month names (jan-dec), and later/someday to resolve_date(). Add tests for all new tokens. * feat(recur): add recurrence parsing and next-date computation Problem: the plugin has no concept of recurring tasks, which is needed for habits and repeating deadlines. Solution: add recur.lua with parse(), validate(), next_due(), to_rrule(), and shorthand_list(). Supports named shorthands (daily, weekdays, weekly, etc.), interval notation (Nd, Nw, Nm, Ny), raw RRULE passthrough, and ! prefix for completion-based mode. Includes day-clamping for month/year advancement. * feat(store): add recur and recur_mode task fields Problem: the task schema has no fields for storing recurrence rules. Solution: add recur and recur_mode to the Task class, known_fields, task_to_table, table_to_task, and the add() signature. * feat(parse): add rec: inline token parsing Problem: the buffer parser does not recognize recurrence tokens, so users cannot set recurrence rules inline. Solution: add recur_key() helper and rec: token parsing in body() and command_add(), with ! prefix handling for completion-based mode and validation via recur.validate(). * feat(diff): propagate recurrence through buffer reconciliation Problem: the diff layer does not extract or apply recurrence fields, so rec: tokens written in the buffer are silently ignored on :w. Solution: add rec and rec_mode to ParsedEntry, extract them in parse_buffer(), and pass them through create and update paths in apply(). * feat(init): spawn next task on recurring task completion Problem: completing a recurring task does not create the next occurrence, and :Pending add does not pass recurrence fields. Solution: in toggle_complete(), detect recurrence and spawn a new pending task with the next due date. Wire rec/rec_mode through the add() command path. * feat(views): add recurrence to LineMeta Problem: LineMeta does not carry recurrence info, so the buffer layer cannot display recurrence indicators. Solution: add recur field to LineMeta and populate it in both category_view() and priority_view(). * feat(buffer): add PendingRecur highlight and recurrence virtual text Problem: recurring tasks have no visual indicator in the buffer, and the extmark logic uses a rigid if/elseif chain that does not compose well with additional virtual text fields. Solution: add PendingRecur highlight group linking to DiagnosticInfo. Refactor apply_extmarks() to build virtual text parts dynamically, appending category, recurrence indicator, and due date as separate composable segments. Set omnifunc on the pending buffer. * feat(complete): add omnifunc for cat:, due:, and rec: tokens Problem: the pending buffer has no completion source, requiring users to type metadata tokens from memory. Solution: add complete.lua with an omnifunc that completes cat: tokens from existing categories, due: tokens from the named date vocabulary, and rec: tokens from recurrence shorthands. * docs: document recurrence, expanded dates, omnifunc, new config Problem: the vimdoc does not cover recurrence, expanded date syntax, omnifunc completion, or the new config fields. Solution: add DATE INPUT and RECURRENCE sections, update INLINE METADATA, COMMANDS, CONFIGURATION, HIGHLIGHT GROUPS, HEALTH CHECK, and DATA FORMAT. Expand the help popup with recurrence patterns and new date tokens. Add recurrence validation to healthcheck. * ci: fix * fix(recur): resolve LuaLS type errors Problem: LuaLS reported undefined-field for `_raw` on RecurSpec and param-type-mismatch for `last_day.day` in `advance_date` because `osdate.day` infers as `string|integer`. Solution: Add `_raw` to the RecurSpec class annotation and cast `last_day.day` to integer in both `math.min` call sites. * refactor(init): remove help popup, use config-driven keymaps Problem: Buffer-local keymaps were hardcoded with no way for users to customize them. The g? help popup duplicated information already in the vimdoc. Solution: Remove show_help() and the g? mapping. Refactor _setup_buf_mappings to read from cfg.keymaps, letting users override or disable any buffer-local binding via vim.g.pending. * feat(config): add keymaps table for buffer-local bindings Problem: Users had no way to customize or disable buffer-local key bindings in the pending buffer. Solution: Add a pending.Keymaps class and keymaps field to pending.Config with defaults for all eight buffer actions. Setting any key to false disables that binding. * feat(plugin): add Plug mappings for all buffer actions Problem: Only five of nine buffer actions had mappings, so users could not bind close, undo, open-line, or open-line-above globally. Solution: Add (pending-close), (pending-undo), (pending-open-line), and (pending-open-line-above). * docs: update mappings and config for keymaps and new Plug entries Problem: Vimdoc still listed g? help popup, lacked documentation for the four new mappings, and had no keymaps config section. Solution: Remove g? from mappings table, document all nine mappings, add keymaps table to the config example and field reference, and note that buffer-local keys are configurable. --- doc/pending.txt | 178 +++++++++++++++++++++++++++---- lua/pending/buffer.lua | 32 +++--- lua/pending/complete.lua | 138 ++++++++++++++++++++++++ lua/pending/config.lua | 25 +++++ lua/pending/diff.lua | 16 +++ lua/pending/health.lua | 11 ++ lua/pending/init.lua | 145 ++++++++++--------------- lua/pending/parse.lua | 207 +++++++++++++++++++++++++++++++++++- lua/pending/recur.lua | 166 +++++++++++++++++++++++++++++ lua/pending/store.lua | 16 ++- lua/pending/views.lua | 3 + plugin/pending.lua | 16 +++ spec/complete_spec.lua | 171 ++++++++++++++++++++++++++++++ spec/diff_spec.lua | 73 +++++++++++++ spec/parse_spec.lua | 167 +++++++++++++++++++++++++++++ spec/recur_spec.lua | 223 +++++++++++++++++++++++++++++++++++++++ spec/store_spec.lua | 35 ++++++ spec/views_spec.lua | 48 +++++++++ 18 files changed, 1536 insertions(+), 134 deletions(-) create mode 100644 lua/pending/complete.lua create mode 100644 lua/pending/recur.lua create mode 100644 spec/complete_spec.lua create mode 100644 spec/recur_spec.lua diff --git a/doc/pending.txt b/doc/pending.txt index 4eb8e40..66882b9 100644 --- a/doc/pending.txt +++ b/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 +- Inline metadata syntax: `due:`, `cat:`, and `rec:` 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 priority flat list - Multi-level undo (up to 20 `:w` saves, session-only) - 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 (``) - Google Calendar one-way push via OAuth PKCE ============================================================================== @@ -95,20 +98,18 @@ parsed from the right and consumed until a non-metadata token is reached. Supported tokens: ~ `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:` Resolve a named date (see |pending-dates| below). `cat:Name` Move the task to the named category on save. + `rec:` Set a recurrence rule (see |pending-recurrence|). 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 +117,87 @@ 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:`, and one `rec:` per task line are consumed. + +Omnifunc completion is available for all three token types. In insert mode, +type `due:`, `cat:`, or `rec:` and press `` to see suggestions. + +============================================================================== +DATE INPUT *pending-dates* + +Named dates can be used anywhere a date is accepted: the `due:` inline +token, the `D` prompt, and `:Pending add`. + + Token Resolves to ~ + ----- ----------- + `today` Today's date + `tomorrow` Tomorrow's date + `yesterday` Yesterday's date + `eod` Today (end of day semantics) + `+Nd` N days from today (e.g. `+3d`) + `+Nw` N weeks from today (e.g. `+2w`) + `+Nm` N months from today (e.g. `+1m`) + `-Nd` N days ago (e.g. `-2d`) + `-Nw` N weeks ago (e.g. `-1w`) + `mon`–`sun` Next occurrence of that weekday + `jan`–`dec` 1st of next occurrence of that month + `1st`–`31st` Next occurrence of that day-of-month + `sow` / `eow` Monday / Sunday of current week + `som` / `eom` First / last day of current month + `soq` / `eoq` First / last day of current quarter + `soy` / `eoy` January 1 / December 31 of current year + `later` / `someday` Sentinel date (default: `9999-12-30`) + +============================================================================== +RECURRENCE *pending-recurrence* + +Tasks can recur on a schedule. Add a `rec:` token to set recurrence: > + + - [ ] Take out trash due:monday rec:weekly + - [ ] Pay rent due:2026-03-01 rec:monthly + - [ ] Standup due:tomorrow rec:weekdays +< + +When a recurring task is marked done with ``: +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 +215,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. @@ -169,27 +250,34 @@ 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 ~ ------- ------------------------------------------------ - `` 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 - `` 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`) + `` Toggle complete / uncomplete (`toggle`) + `!` Toggle the priority flag (`priority`) + `D` Prompt for a due date (`date`) + `` Switch between category / priority 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`) `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. +`dd`, `p`, `P`, and `:w` work as standard Vim operations. *(pending-open)* (pending-open) Open the task buffer. Maps to |:Pending| with no arguments. + *(pending-close)* +(pending-close) + Close the task buffer window. + *(pending-toggle)* (pending-toggle) Toggle complete / uncomplete for the task under the cursor. @@ -206,6 +294,18 @@ indentation. `dd`, `p`, `P`, and `:w` work as expected. (pending-view) Switch between category view and priority view. + *(pending-undo)* +(pending-undo) + Undo the last `:w` save. + + *(pending-open-line)* +(pending-open-line) + Insert a correctly-formatted blank task line below the cursor. + + *(pending-open-line-above)* +(pending-open-line-above) + Insert a correctly-formatted blank task line above the cursor. + Example configuration: >lua vim.keymap.set('n', 't', '(pending-open)') vim.keymap.set('n', 'T', '(pending-toggle)') @@ -242,7 +342,19 @@ 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 = '', + view = '', + priority = '!', + date = 'D', + undo = 'U', + open_line = 'o', + open_line_above = 'O', + }, gcal = { calendar = 'Tasks', credentials_path = '/path/to/client_secret.json', @@ -278,12 +390,28 @@ 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. + {gcal} (table, default: nil) Google Calendar sync configuration. See |pending.GcalConfig|. Omit this field entirely to @@ -371,6 +499,11 @@ PendingDone Applied to the text of completed tasks. PendingPriority Applied to the `! ` priority marker on priority tasks. Default: links to `DiagnosticWarn`. + *PendingRecur* +PendingRecur Applied to the recurrence indicator virtual text shown + alongside due dates for recurring tasks. + Default: links to `DiagnosticInfo`. + To override a group in your colorscheme or config: >lua vim.api.nvim_set_hl(0, 'PendingDue', { fg = '#aaaaaa', italic = true }) < @@ -388,6 +521,7 @@ 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 +- Validates recurrence specs on stored tasks - Whether `curl` is available (required for Google Calendar sync) - Whether `openssl` is available (required for OAuth PKCE) @@ -414,6 +548,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. diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index d11254b..14636ea 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -55,6 +55,7 @@ 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 @@ -122,24 +123,22 @@ 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 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 #virt_parts > 0 then + for p = 1, #virt_parts - 1 do + virt_parts[p][1] = virt_parts[p][1] .. ' ' end - if virt_text then - vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, { - virt_text = virt_text, - virt_text_pos = 'eol', - }) - end - elseif m.due then vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, { - virt_text = { { m.due, due_hl } }, + virt_text = virt_parts, virt_text_pos = 'eol', }) end @@ -167,6 +166,7 @@ local function setup_highlights() vim.api.nvim_set_hl(0, 'PendingOverdue', { link = 'DiagnosticError', default = true }) vim.api.nvim_set_hl(0, '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 }) end local function snapshot_folds(bufnr) diff --git a/lua/pending/complete.lua b/lua/pending/complete.lua new file mode 100644 index 0000000..f83b6a4 --- /dev/null +++ b/lua/pending/complete.lua @@ -0,0 +1,138 @@ +local config = require('pending.config') + +---@class pending.complete +local M = {} + +---@return string +local function date_key() + return config.get().date_syntax or 'due' +end + +---@return string +local function recur_key() + return config.get().recur_syntax or 'rec' +end + +---@return string[] +local function get_categories() + local store = require('pending.store') + local seen = {} + local result = {} + for _, task in ipairs(store.active_tasks()) do + local cat = task.category + if cat and not seen[cat] then + seen[cat] = true + table.insert(result, cat) + end + end + table.sort(result) + return result +end + +---@return string[] +local function date_completions() + return { + 'today', + 'tomorrow', + 'yesterday', + '+1d', + '+2d', + '+3d', + '+1w', + '+2w', + '+1m', + 'mon', + 'tue', + 'wed', + 'thu', + 'fri', + 'sat', + 'sun', + 'eod', + 'eow', + 'eom', + 'eoq', + 'eoy', + 'sow', + 'som', + 'soq', + 'soy', + 'later', + } +end + +---@return string[] +local function recur_completions() + local recur = require('pending.recur') + local list = recur.shorthand_list() + local result = {} + for _, s in ipairs(list) do + table.insert(result, s) + end + for _, s in ipairs(list) do + table.insert(result, '!' .. s) + end + return result +end + +---@type string? +local _complete_source = nil + +---@param findstart integer +---@param base string +---@return integer|table[] +function M.omnifunc(findstart, base) + if findstart == 1 then + local line = vim.api.nvim_get_current_line() + local col = vim.api.nvim_win_get_cursor(0)[2] + local before = line:sub(1, col) + + local dk = date_key() + local rk = recur_key() + + local checks = { + { vim.pesc(dk) .. ':([%S]*)$', dk }, + { 'cat:([%S]*)$', 'cat' }, + { vim.pesc(rk) .. ':([%S]*)$', rk }, + } + + for _, check in ipairs(checks) do + local start = before:find(check[1]) + if start then + local colon_pos = before:find(':', start, true) + if colon_pos then + _complete_source = check[2] + return colon_pos + end + end + end + + _complete_source = nil + return -1 + end + + local candidates = {} + local source = _complete_source or '' + + local dk = date_key() + local rk = recur_key() + + if source == dk then + candidates = date_completions() + elseif source == 'cat' then + candidates = get_categories() + elseif source == rk then + candidates = recur_completions() + end + + local matches = {} + for _, c in ipairs(candidates) do + if base == '' or c:sub(1, #base) == base then + table.insert(matches, { word = c, menu = '[' .. source .. ']' }) + end + end + + return matches +end + +return M diff --git a/lua/pending/config.lua b/lua/pending/config.lua index b61f44a..3318b3d 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -2,14 +2,27 @@ ---@field calendar? string ---@field credentials_path? string +---@class pending.Keymaps +---@field close? string|false +---@field toggle? string|false +---@field view? string|false +---@field priority? string|false +---@field date? string|false +---@field undo? string|false +---@field open_line? string|false +---@field open_line_above? string|false + ---@class pending.Config ---@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 keymaps pending.Keymaps ---@field gcal? pending.GcalConfig ---@class pending.config @@ -22,7 +35,19 @@ 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 = '', + view = '', + priority = '!', + date = 'D', + undo = 'U', + open_line = 'o', + open_line_above = 'O', + }, } ---@type pending.Config? diff --git a/lua/pending/diff.lua b/lua/pending/diff.lua index 85f083c..bec3baa 100644 --- a/lua/pending/diff.lua +++ b/lua/pending/diff.lua @@ -10,6 +10,8 @@ local store = require('pending.store') ---@field status? string ---@field category? string ---@field due? string +---@field rec? string +---@field rec_mode? string ---@field lnum integer ---@class pending.diff @@ -48,6 +50,8 @@ 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, lnum = i, }) end @@ -90,6 +94,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 +118,14 @@ 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 if entry.status and task.status ~= entry.status then task.status = entry.status if entry.status == 'done' then @@ -135,6 +149,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 diff --git a/lua/pending/health.lua b/lua/pending/health.lua index 8a12da4..78311d2 100644 --- a/lua/pending/health.lua +++ b/lua/pending/health.lua @@ -27,6 +27,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 diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 14b9c24..216b8b3 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -50,37 +50,44 @@ end ---@param bufnr integer 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() - buffer.close() - end, opts) - vim.keymap.set('n', '', function() - buffer.close() - end, opts) - vim.keymap.set('n', '', function() - M.toggle_complete() - end, opts) - vim.keymap.set('n', '', function() - buffer.toggle_view() - end, opts) - vim.keymap.set('n', 'g?', function() - M.show_help() - end, opts) - vim.keymap.set('n', '!', function() - M.toggle_priority() - end, opts) - vim.keymap.set('n', 'D', function() - M.prompt_date() - end, opts) - vim.keymap.set('n', 'U', function() - M.undo_write() - end, opts) - vim.keymap.set('n', 'o', function() - buffer.open_line(false) - end, opts) - vim.keymap.set('n', 'O', function() - buffer.open_line(true) - end, opts) + + ---@type table + local actions = { + close = function() + buffer.close() + end, + toggle = function() + M.toggle_complete() + end, + view = function() + buffer.toggle_view() + end, + priority = function() + M.toggle_priority() + end, + date = function() + M.prompt_date() + end, + undo = function() + M.undo_write() + end, + open_line = function() + buffer.open_line(false) + 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 end ---@param bufnr integer @@ -127,6 +134,21 @@ 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 base = mode == 'completion' and os.date('%Y-%m-%d') --[[@as string]] + or task.due + local next_date = recur.next_due(base, 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() @@ -219,6 +241,8 @@ function M.add(text) description = description, category = metadata.cat, due = metadata.due, + recur = metadata.rec, + recur_mode = metadata.rec_mode, }) store.save() local bufnr = buffer.bufnr() @@ -317,67 +341,6 @@ function M.due() vim.cmd('copen') end -function M.show_help() - local cfg = require('pending.config').get() - local dk = cfg.date_syntax or 'due' - local lines = { - 'pending.nvim keybindings', - '', - ' Toggle complete/uncomplete', - ' 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 Quick-add task', - ':Pending add Cat: 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 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', '', function() - vim.api.nvim_win_close(win, true) - end, { buffer = buf, silent = true }) -end - ---@param args string function M.command(args) if not args or args == '' then diff --git a/lua/pending/parse.lua b/lua/pending/parse.lua index ebe909a..853fa2c 100644 --- a/lua/pending/parse.lua +++ b/lua/pending/parse.lua @@ -29,6 +29,11 @@ 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,14 +44,42 @@ local weekday_map = { sat = 7, } +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, +} + +---@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 text string ---@return string|nil function M.resolve_date(text) local lower = text:lower() local today = os.date('*t') --[[@as osdate]] - if lower == 'today' then - return os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day })) --[[@as string]] + if lower == 'today' or lower == 'eod' then + return today_str(today) + end + + if lower == 'yesterday' then + return os.date( + '%Y-%m-%d', + os.time({ year = today.year, month = today.month, day = today.day - 1 }) + ) --[[@as string]] end if lower == 'tomorrow' then @@ -56,6 +89,54 @@ function M.resolve_date(text) ) --[[@as string]] end + if lower == 'sow' then + local delta = -((today.wday - 2) % 7) + return os.date( + '%Y-%m-%d', + os.time({ year = today.year, month = today.month, day = today.day + delta }) + ) --[[@as string]] + end + + if lower == 'eow' then + local delta = (1 - today.wday) % 7 + return os.date( + '%Y-%m-%d', + os.time({ year = today.year, month = today.month, day = today.day + delta }) + ) --[[@as string]] + end + + if lower == 'som' then + return os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = 1 })) --[[@as string]] + end + + if lower == 'eom' then + return os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month + 1, day = 0 })) --[[@as string]] + end + + if lower == 'soq' then + local q = math.ceil(today.month / 3) + local first_month = (q - 1) * 3 + 1 + return os.date('%Y-%m-%d', os.time({ year = today.year, month = first_month, day = 1 })) --[[@as string]] + end + + if lower == 'eoq' then + local q = math.ceil(today.month / 3) + local last_month = q * 3 + return os.date('%Y-%m-%d', os.time({ year = today.year, month = last_month + 1, day = 0 })) --[[@as string]] + end + + if lower == 'soy' then + return os.date('%Y-%m-%d', os.time({ year = today.year, month = 1, day = 1 })) --[[@as string]] + end + + if lower == 'eoy' then + return os.date('%Y-%m-%d', os.time({ year = today.year, month = 12, day = 31 })) --[[@as string]] + end + + if lower == 'later' or lower == 'someday' then + return config.get().someday_date + end + local n = lower:match('^%+(%d+)d$') if n then return os.date( @@ -70,6 +151,102 @@ function M.resolve_date(text) ) --[[@as string]] end + n = lower:match('^%+(%d+)w$') + if n then + return os.date( + '%Y-%m-%d', + os.time({ + year = today.year, + month = today.month, + day = today.day + ( + tonumber(n) --[[@as integer]] + ) * 7, + }) + ) --[[@as string]] + end + + n = lower:match('^%+(%d+)m$') + if n then + return os.date( + '%Y-%m-%d', + os.time({ + year = today.year, + month = today.month + ( + tonumber(n) --[[@as integer]] + ), + day = today.day, + }) + ) --[[@as string]] + end + + n = lower:match('^%-(%d+)d$') + if n then + return os.date( + '%Y-%m-%d', + os.time({ + year = today.year, + month = today.month, + day = today.day - ( + tonumber(n) --[[@as integer]] + ), + }) + ) --[[@as string]] + end + + n = lower:match('^%-(%d+)w$') + if n then + return os.date( + '%Y-%m-%d', + os.time({ + year = today.year, + month = today.month, + day = today.day - ( + tonumber(n) --[[@as integer]] + ) * 7, + }) + ) --[[@as string]] + 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 os.date('%Y-%m-%d', t) --[[@as string]] + 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 os.date('%Y-%m-%d', t) --[[@as string]] + 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 os.date('%Y-%m-%d', os.time({ year = y, month = target_month, day = 1 })) --[[@as string]] + end + local target_wday = weekday_map[lower] if target_wday then local current_wday = today.wday @@ -85,7 +262,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' } metadata function M.body(text) local tokens = {} for token in text:gmatch('%S+') do @@ -95,8 +272,10 @@ function M.body(text) local metadata = {} local i = #tokens local dk = date_key() + local rk = recur_key() local date_pattern_strict = '^' .. vim.pesc(dk) .. ':(%d%d%d%d%-%d%d%-%d%d)$' local date_pattern_any = '^' .. vim.pesc(dk) .. ':(.+)$' + local rec_pattern = '^' .. vim.pesc(rk) .. ':(%S+)$' while i >= 1 do local token = tokens[i] @@ -131,7 +310,25 @@ function M.body(text) metadata.cat = cat_val i = i - 1 else - break + 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 + break + end end end end @@ -148,7 +345,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' } metadata function M.command_add(text) local cat_prefix = text:match('^(%S.-):%s') if cat_prefix then diff --git a/lua/pending/recur.lua b/lua/pending/recur.lua new file mode 100644 index 0000000..c0a2091 --- /dev/null +++ b/lua/pending/recur.lua @@ -0,0 +1,166 @@ +---@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 +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 base_date string +---@param freq string +---@param interval integer +---@return string +local function advance_date(base_date, freq, interval) + local y, m, d = base_date:match('^(%d+)-(%d+)-(%d+)$') + local yn = tonumber(y) --[[@as integer]] + local mn = tonumber(m) --[[@as integer]] + local dn = tonumber(d) --[[@as integer]] + + if freq == 'daily' then + return os.date('%Y-%m-%d', os.time({ year = yn, month = mn, day = dn + interval })) --[[@as string]] + elseif freq == 'weekly' then + return 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]]) + return 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]]) + return os.date('%Y-%m-%d', os.time({ year = new_y, month = mn, day = clamped_d })) --[[@as string]] + end + return base_date +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]] + + if mode == 'completion' then + return advance_date(today, parsed.freq, parsed.interval) + end + + local next_date = advance_date(base_date, parsed.freq, parsed.interval) + while next_date <= 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 diff --git a/lua/pending/store.lua b/lua/pending/store.lua index 5838414..4f3d2f1 100644 --- a/lua/pending/store.lua +++ b/lua/pending/store.lua @@ -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 @@ -56,6 +58,8 @@ local known_fields = { category = true, priority = true, due = true, + recur = true, + recur_mode = true, entry = true, modified = true, ['end'] = true, @@ -81,6 +85,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 +115,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'], @@ -224,7 +236,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 +248,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, diff --git a/lua/pending/views.lua b/lua/pending/views.lua index 7bcfaca..17a7a37 100644 --- a/lua/pending/views.lua +++ b/lua/pending/views.lua @@ -10,6 +10,7 @@ local config = require('pending.config') ---@field overdue? boolean ---@field show_category? boolean ---@field priority? integer +---@field recur? string ---@class pending.views local M = {} @@ -149,6 +150,7 @@ function M.category_view(tasks) status = task.status, category = cat, overdue = task.status == 'pending' and task.due ~= nil and task.due < today or nil, + recur = task.recur, }) end end @@ -200,6 +202,7 @@ function M.priority_view(tasks) category = task.category, overdue = task.status == 'pending' and task.due ~= nil and task.due < today or nil, show_category = true, + recur = task.recur, }) end diff --git a/plugin/pending.lua b/plugin/pending.lua index 465ee65..2f3a38f 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -22,6 +22,10 @@ vim.keymap.set('n', '(pending-open)', function() require('pending').open() end) +vim.keymap.set('n', '(pending-close)', function() + require('pending.buffer').close() +end) + vim.keymap.set('n', '(pending-toggle)', function() require('pending').toggle_complete() end) @@ -37,3 +41,15 @@ end) vim.keymap.set('n', '(pending-date)', function() require('pending').prompt_date() end) + +vim.keymap.set('n', '(pending-undo)', function() + require('pending').undo_write() +end) + +vim.keymap.set('n', '(pending-open-line)', function() + require('pending.buffer').open_line(false) +end) + +vim.keymap.set('n', '(pending-open-line-above)', function() + require('pending.buffer').open_line(true) +end) diff --git a/spec/complete_spec.lua b/spec/complete_spec.lua new file mode 100644 index 0000000..7b45e5b --- /dev/null +++ b/spec/complete_spec.lua @@ -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) diff --git a/spec/diff_spec.lua b/spec/diff_spec.lua index fda2165..d8e25c2 100644 --- a/spec/diff_spec.lua +++ b/spec/diff_spec.lua @@ -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() diff --git a/spec/parse_spec.lua b/spec/parse_spec.lua index ca8047c..edeffcd 100644 --- a/spec/parse_spec.lua +++ b/spec/parse_spec.lua @@ -154,6 +154,173 @@ 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('command_add', function() diff --git a/spec/recur_spec.lua b/spec/recur_spec.lua new file mode 100644 index 0000000..53b7478 --- /dev/null +++ b/spec/recur_spec.lua @@ -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) diff --git a/spec/store_spec.lua b/spec/store_spec.lua index bb6266d..ebe4da1 100644 --- a/spec/store_spec.lua +++ b/spec/store_spec.lua @@ -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() diff --git a/spec/views_spec.lua b/spec/views_spec.lua index 4d91e06..e8d5c2d 100644 --- a/spec/views_spec.lua +++ b/spec/views_spec.lua @@ -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) From 379e281ecd0f65667dbe35c43d2ff724234adc1b Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 25 Feb 2026 13:40:36 -0500 Subject: [PATCH 05/21] fix(plugin): allow command chaining with bar separator (#29) Problem: :Pending|only failed because the command definition lacked the bar attribute, causing | to be consumed as an argument. Solution: Add bar = true to nvim_create_user_command so | is treated as a command separator, matching fugitive's :Git behavior. --- plugin/pending.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/plugin/pending.lua b/plugin/pending.lua index 2f3a38f..bfacfec 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -6,6 +6,7 @@ vim.g.loaded_pending = true 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' } From b76c680e1f729c61f052d92e4e7d58ef17e7eaf8 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 25 Feb 2026 13:45:42 -0500 Subject: [PATCH 06/21] feat: fix q on close last window (#31) * fix(plugin): allow command chaining with bar separator Problem: :Pending|only failed because the command definition lacked the bar attribute, causing | to be consumed as an argument. Solution: Add bar = true to nvim_create_user_command so | is treated as a command separator, matching fugitive's :Git behavior. * fix: last window --- lua/pending/buffer.lua | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index 14636ea..c9e1686 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -42,7 +42,14 @@ function M.clear_winid() end 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 From 72dbf037c7cc7c5711a19ac981a32bf857549054 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 25 Feb 2026 17:34:40 -0500 Subject: [PATCH 07/21] refactor(buffer): remove opinionated window options, fix close (#32) * fix(plugin): allow command chaining with bar separator Problem: :Pending|only failed because the command definition lacked the bar attribute, causing | to be consumed as an argument. Solution: Add bar = true to nvim_create_user_command so | is treated as a command separator, matching fugitive's :Git behavior. * refactor(buffer): remove opinionated window options Problem: The plugin hardcoded number, relativenumber, wrap, spell, signcolumn, foldcolumn, and cursorline in set_win_options, overriding user preferences with no way to opt out. Solution: Remove all cosmetic window options. Users who want them can set them in after/ftplugin/pending.lua. Only conceallevel, concealcursor, and winfixheight remain as functionally required. --- lua/pending/buffer.lua | 7 ------- 1 file changed, 7 deletions(-) diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index c9e1686..06a14ac 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -69,13 +69,6 @@ end 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 From c57cc0845bf0ff1330ae26e4137124cd58490663 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 25 Feb 2026 20:37:50 -0500 Subject: [PATCH 08/21] feat: time-aware due dates, persistent undo, @return audit (#33) * fix(plugin): allow command chaining with bar separator Problem: :Pending|only failed because the command definition lacked the bar attribute, causing | to be consumed as an argument. Solution: Add bar = true to nvim_create_user_command so | is treated as a command separator, matching fugitive's :Git behavior. * refactor(buffer): remove opinionated window options Problem: The plugin hardcoded number, relativenumber, wrap, spell, signcolumn, foldcolumn, and cursorline in set_win_options, overriding user preferences with no way to opt out. Solution: Remove all cosmetic window options. Users who want them can set them in after/ftplugin/pending.lua. Only conceallevel, concealcursor, and winfixheight remain as functionally required. * feat: time-aware due dates, persistent undo, @return audit Problem: Due dates had no time component, the undo stack was lost on restart and stored in a separate file, and many public functions lacked required @return annotations. Solution: Add YYYY-MM-DDThh:mm support across parse, views, recur, complete, and init with time-aware overdue checks. Merge the undo stack into the task store JSON so a single file holds all state. Add @return nil annotations to all 27 void public functions across every module. * feat(parse): flexible time parsing for @ suffix Problem: the @HH:MM time suffix required zero-padded 24-hour format, forcing users to write due:tomorrow@14:00 instead of due:tomorrow@2pm. Solution: add normalize_time() that accepts bare hours (9, 14), H:MM (9:30), am/pm (2pm, 9:30am, 12am), and existing HH:MM format, normalizing all to canonical HH:MM on save. * feat(complete): add info descriptions to omnifunc items Problem: completion menu items had no description, making it hard to distinguish between similar entries like date shorthands and recurrence patterns. Solution: return { word, info } tables from date_completions() and recur_completions(), surfacing human-readable descriptions in the completion popup. * ci: format --- doc/pending.txt | 34 ++++- lua/pending/buffer.lua | 5 + lua/pending/complete.lua | 112 +++++++++----- lua/pending/config.lua | 1 + lua/pending/diff.lua | 1 + lua/pending/health.lua | 1 + lua/pending/init.lua | 86 ++++++++--- lua/pending/parse.lua | 322 +++++++++++++++++++++++++++++---------- lua/pending/recur.lua | 38 ++++- lua/pending/store.lua | 35 +++++ lua/pending/views.lua | 36 ++++- spec/parse_spec.lua | 67 ++++++++ 12 files changed, 580 insertions(+), 158 deletions(-) diff --git a/doc/pending.txt b/doc/pending.txt index 66882b9..aad924c 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -35,7 +35,7 @@ Features: ~ names, month names, ordinals, and more - Recurring tasks with automatic next-date spawning on completion - Two views: category (default) and priority flat list -- Multi-level undo (up to 20 `:w` saves, session-only) +- Multi-level undo (up to 20 `:w` saves, persisted across sessions) - Quick-add from the command line with `:Pending add` - Quickfix list of overdue/due-today tasks via `:Pending due` - Foldable category sections (`zc`/`zo`) in category view @@ -149,6 +149,23 @@ token, the `D` prompt, and `:Pending add`. `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* @@ -242,7 +259,7 @@ COMMANDS *pending-commands* :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* @@ -417,6 +434,19 @@ Fields: ~ |pending.GcalConfig|. Omit this field entirely to disable Google Calendar sync. +============================================================================== +RECIPES *pending-recipes* + +Configure blink.cmp to use pending.nvim's omnifunc as a completion source: >lua + require('blink.cmp').setup({ + sources = { + per_filetype = { + pending = { 'omni', 'buffer' }, + }, + }, + }) +< + ============================================================================== GOOGLE CALENDAR *pending-gcal* diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index 06a14ac..4738830 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -37,10 +37,12 @@ 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 not task_winid or not vim.api.nvim_win_is_valid(task_winid) then task_winid = nil @@ -86,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 @@ -212,6 +215,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 @@ -256,6 +260,7 @@ function M.render(bufnr) restore_folds(bufnr) end +---@return nil function M.toggle_view() if current_view == 'category' then current_view = 'priority' diff --git a/lua/pending/complete.lua b/lua/pending/complete.lua index f83b6a4..79f338b 100644 --- a/lua/pending/complete.lua +++ b/lua/pending/complete.lua @@ -29,48 +29,75 @@ local function get_categories() return result end ----@return string[] +---@return { word: string, info: string }[] local function date_completions() return { - 'today', - 'tomorrow', - 'yesterday', - '+1d', - '+2d', - '+3d', - '+1w', - '+2w', - '+1m', - 'mon', - 'tue', - 'wed', - 'thu', - 'fri', - 'sat', - 'sun', - 'eod', - 'eow', - 'eom', - 'eoq', - 'eoy', - 'sow', - 'som', - 'soq', - 'soy', - 'later', + { 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 ----@return string[] +---@type table +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 - table.insert(result, s) + local desc = recur_descriptions[s] or s + table.insert(result, { word = s, info = desc }) end for _, s in ipairs(list) do - table.insert(result, '!' .. s) + local desc = recur_descriptions[s] or s + table.insert(result, { word = '!' .. s, info = desc .. ' (from completion date)' }) end return result end @@ -111,24 +138,29 @@ function M.omnifunc(findstart, base) return -1 end - local candidates = {} + local matches = {} local source = _complete_source or '' local dk = date_key() local rk = recur_key() if source == dk then - candidates = date_completions() + 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 - candidates = get_categories() + 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 - candidates = recur_completions() - end - - local matches = {} - for _, c in ipairs(candidates) do - if base == '' or c:sub(1, #base) == base then - table.insert(matches, { word = c, menu = '[' .. source .. ']' }) + for _, c in ipairs(recur_completions()) do + if base == '' or c.word:sub(1, #base) == base then + table.insert(matches, { word = c.word, menu = '[' .. source .. ']', info = c.info }) + end end end diff --git a/lua/pending/config.lua b/lua/pending/config.lua index 3318b3d..ec89cb2 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -63,6 +63,7 @@ function M.get() return _resolved end +---@return nil function M.reset() _resolved = nil end diff --git a/lua/pending/diff.lua b/lua/pending/diff.lua index bec3baa..daab788 100644 --- a/lua/pending/diff.lua +++ b/lua/pending/diff.lua @@ -65,6 +65,7 @@ function M.parse_buffer(lines) end ---@param lines string[] +---@return nil function M.apply(lines) local parsed = M.parse_buffer(lines) local now = timestamp() diff --git a/lua/pending/health.lua b/lua/pending/health.lua index 78311d2..cc285e0 100644 --- a/lua/pending/health.lua +++ b/lua/pending/health.lua @@ -1,5 +1,6 @@ local M = {} +---@return nil function M.check() vim.health.start('pending.nvim') diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 216b8b3..631c0e3 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -6,8 +6,6 @@ local store = require('pending.store') ---@class pending.init local M = {} ----@type pending.Task[][] -local _undo_states = {} local UNDO_MAX = 20 ---@return integer bufnr @@ -19,6 +17,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,6 +48,7 @@ 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 @@ -91,28 +91,33 @@ function M._setup_buf_mappings(bufnr) 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) 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() buffer.render(buffer.bufnr()) end +---@return nil function M.toggle_complete() local bufnr = buffer.bufnr() if not bufnr then @@ -137,9 +142,7 @@ function M.toggle_complete() if task.recur and task.due then local recur = require('pending.recur') local mode = task.recur_mode or 'scheduled' - local base = mode == 'completion' and os.date('%Y-%m-%d') --[[@as string]] - or task.due - local next_date = recur.next_due(base, task.recur, mode) + local next_date = recur.next_due(task.due, task.recur, mode) store.add({ description = task.description, category = task.category, @@ -161,6 +164,7 @@ function M.toggle_complete() end end +---@return nil function M.toggle_priority() local bufnr = buffer.bufnr() if not bufnr then @@ -191,6 +195,7 @@ function M.toggle_priority() end end +---@return nil function M.prompt_date() local bufnr = buffer.bufnr() if not bufnr then @@ -205,7 +210,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 @@ -214,8 +219,11 @@ 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 @@ -226,6 +234,7 @@ function M.prompt_date() end ---@param text string +---@return nil function M.add(text) if not text or text == '' then vim.notify('Usage: :Pending add ', vim.log.levels.ERROR) @@ -252,6 +261,7 @@ function M.add(text) vim.notify('Pending added: ' .. description) end +---@return nil function M.sync() local ok, gcal = pcall(require, 'pending.sync.gcal') if not ok then @@ -262,6 +272,7 @@ function M.sync() end ---@param days? integer +---@return nil function M.archive(days) days = days or 30 local cutoff = os.time() - (days * 86400) @@ -298,8 +309,46 @@ function M.archive(days) end end -function M.due() +---@param due string +---@return boolean +local function is_due_or_overdue(due) + local now = os.date('*t') --[[@as osdate]] local today = os.date('%Y-%m-%d') --[[@as string]] + local date_part, time_part = due:match('^(.+)T(.+)$') + if not date_part then + return due <= today + end + if date_part < today then + return true + end + if date_part > today then + return false + end + local current_time = string.format('%02d:%02d', now.hour, now.min) + return time_part <= current_time +end + +---@param due string +---@return boolean +local function is_overdue(due) + local now = os.date('*t') --[[@as osdate]] + local today = os.date('%Y-%m-%d') --[[@as string]] + local date_part, time_part = due:match('^(.+)T(.+)$') + if not date_part then + return due < today + end + if date_part < today then + return true + end + if date_part > today then + return false + end + local current_time = string.format('%02d:%02d', now.hour, now.min) + return time_part < current_time +end + +---@return nil +function M.due() local bufnr = buffer.bufnr() local is_valid = bufnr ~= nil and vim.api.nvim_buf_is_valid(bufnr) local meta = is_valid and buffer.meta() or nil @@ -307,9 +356,9 @@ 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 is_due_or_overdue(m.raw_due) then local task = store.get(m.id or 0) - local label = m.raw_due < today and '[OVERDUE] ' or '[DUE] ' + local label = is_overdue(m.raw_due) and '[OVERDUE] ' or '[DUE] ' table.insert(qf_items, { bufnr = bufnr, lnum = lnum, @@ -321,8 +370,8 @@ 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 is_due_or_overdue(task.due) then + local label = is_overdue(task.due) and '[OVERDUE] ' or '[DUE] ' local text = label .. task.description if task.category then text = text .. ' [' .. task.category .. ']' @@ -342,6 +391,7 @@ function M.due() end ---@param args string +---@return nil function M.command(args) if not args or args == '' then M.open() diff --git a/lua/pending/parse.lua b/lua/pending/parse.lua index 853fa2c..e234269 100644 --- a/lua/pending/parse.lua +++ b/lua/pending/parse.lua @@ -24,6 +24,82 @@ 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' @@ -65,146 +141,218 @@ 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 lower = text:lower() + 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 today_str(today) + return append_time(today_str(today), time_suffix) end if lower == 'yesterday' then - return os.date( - '%Y-%m-%d', - os.time({ year = today.year, month = today.month, day = today.day - 1 }) - ) --[[@as string]] + 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( - '%Y-%m-%d', - os.time({ year = today.year, month = today.month, day = today.day + 1 }) - ) --[[@as string]] + 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 os.date( - '%Y-%m-%d', - os.time({ year = today.year, month = today.month, day = today.day + delta }) - ) --[[@as string]] + 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 == 'eow' then local delta = (1 - today.wday) % 7 - return os.date( - '%Y-%m-%d', - os.time({ year = today.year, month = today.month, day = today.day + delta }) - ) --[[@as string]] + 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 os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = 1 })) --[[@as string]] + 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 os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month + 1, day = 0 })) --[[@as string]] + 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 os.date('%Y-%m-%d', os.time({ year = today.year, month = first_month, day = 1 })) --[[@as string]] + 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 os.date('%Y-%m-%d', os.time({ year = today.year, month = last_month + 1, day = 0 })) --[[@as string]] + 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 os.date('%Y-%m-%d', os.time({ year = today.year, month = 1, day = 1 })) --[[@as string]] + 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 os.date('%Y-%m-%d', os.time({ year = today.year, month = 12, day = 31 })) --[[@as string]] + 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 config.get().someday_date + return append_time(config.get().someday_date, time_suffix) end local n = lower:match('^%+(%d+)d$') if n then - return os.date( - '%Y-%m-%d', - os.time({ - year = today.year, - month = today.month, - day = today.day + ( - tonumber(n) --[[@as integer]] - ), - }) - ) --[[@as string]] + 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 os.date( - '%Y-%m-%d', - os.time({ - year = today.year, - month = today.month, - day = today.day + ( - tonumber(n) --[[@as integer]] - ) * 7, - }) - ) --[[@as string]] + 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 os.date( - '%Y-%m-%d', - os.time({ - year = today.year, - month = today.month + ( - tonumber(n) --[[@as integer]] - ), - day = today.day, - }) - ) --[[@as string]] + 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 os.date( - '%Y-%m-%d', - os.time({ - year = today.year, - month = today.month, - day = today.day - ( - tonumber(n) --[[@as integer]] - ), - }) - ) --[[@as string]] + 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 os.date( - '%Y-%m-%d', - os.time({ - year = today.year, - month = today.month, - day = today.day - ( - tonumber(n) --[[@as integer]] - ) * 7, - }) - ) --[[@as string]] + 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]$') @@ -222,7 +370,7 @@ function M.resolve_date(text) 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 os.date('%Y-%m-%d', t) --[[@as string]] + return append_time(os.date('%Y-%m-%d', t) --[[@as string]], time_suffix) end m = m + 1 if m > 12 then @@ -232,7 +380,7 @@ function M.resolve_date(text) t = os.time({ year = y, month = m, day = day_num }) check = os.date('*t', t) --[[@as osdate]] if check.day == day_num then - return os.date('%Y-%m-%d', t) --[[@as string]] + return append_time(os.date('%Y-%m-%d', t) --[[@as string]], time_suffix) end return nil end @@ -244,17 +392,23 @@ function M.resolve_date(text) if today.month >= target_month then y = y + 1 end - return os.date('%Y-%m-%d', os.time({ year = y, month = target_month, day = 1 })) --[[@as string]] + 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( - '%Y-%m-%d', - os.time({ year = today.year, month = today.month, day = today.day + delta }) - ) --[[@as string]] + 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 return nil @@ -273,7 +427,7 @@ function M.body(text) local i = #tokens local dk = date_key() local rk = recur_key() - local date_pattern_strict = '^' .. vim.pesc(dk) .. ':(%d%d%d%d%-%d%d%-%d%d)$' + 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+)$' @@ -284,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 diff --git a/lua/pending/recur.lua b/lua/pending/recur.lua index c0a2091..9c647aa 100644 --- a/lua/pending/recur.lua +++ b/lua/pending/recur.lua @@ -80,20 +80,33 @@ 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 y, m, d = base_date:match('^(%d+)-(%d+)-(%d+)$') + 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 - return os.date('%Y-%m-%d', os.time({ year = yn, month = mn, day = dn + interval })) --[[@as string]] + result = os.date('%Y-%m-%d', os.time({ year = yn, month = mn, day = dn + interval })) --[[@as string]] elseif freq == 'weekly' then - return os.date('%Y-%m-%d', os.time({ year = yn, month = mn, day = dn + interval * 7 })) --[[@as string]] + 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 @@ -103,14 +116,20 @@ local function advance_date(base_date, freq, interval) 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]]) - return os.date('%Y-%m-%d', os.time({ year = new_y, month = new_m, day = clamped_d })) --[[@as string]] + 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]]) - return os.date('%Y-%m-%d', os.time({ year = new_y, month = mn, day = clamped_d })) --[[@as string]] + result = os.date('%Y-%m-%d', os.time({ year = new_y, month = mn, day = clamped_d })) --[[@as string]] + else + return base_date end - return base_date + + if time_part then + return result .. 'T' .. time_part + end + return result end ---@param base_date string @@ -124,13 +143,16 @@ function M.next_due(base_date, spec, mode) end local today = os.date('%Y-%m-%d') --[[@as string]] + local _, time_part = split_datetime(base_date) if mode == 'completion' then - return advance_date(today, parsed.freq, parsed.interval) + 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) - while next_date <= today do + 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 diff --git a/lua/pending/store.lua b/lua/pending/store.lua index 4f3d2f1..c9e9b45 100644 --- a/lua/pending/store.lua +++ b/lua/pending/store.lua @@ -19,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 = {} @@ -34,6 +35,7 @@ local function empty_data() version = SUPPORTED_VERSION, next_id = 1, tasks = {}, + undo = {}, } end @@ -165,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 @@ -182,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') @@ -300,6 +321,7 @@ function M.find_index(id) end ---@param tasks pending.Task[] +---@return nil function M.replace_tasks(tasks) M.data().tasks = tasks end @@ -325,11 +347,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 diff --git a/lua/pending/views.lua b/lua/pending/views.lua index 17a7a37..a9f56bf 100644 --- a/lua/pending/views.lua +++ b/lua/pending/views.lua @@ -21,7 +21,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 @@ -30,7 +33,30 @@ 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 due string +---@return boolean +local function is_overdue(due) + local now = os.date('*t') --[[@as osdate]] + local today = os.date('%Y-%m-%d') --[[@as string]] + local date_part, time_part = due:match('^(.+)T(.+)$') + if not date_part then + return due < today + end + if date_part < today then + return true + end + if date_part > today then + return false + end + local current_time = string.format('%02d:%02d', now.hour, now.min) + return time_part < current_time end ---@param tasks pending.Task[] @@ -74,7 +100,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 = {} @@ -149,7 +174,7 @@ 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 is_overdue(task.due) or nil, recur = task.recur, }) end @@ -162,7 +187,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 = {} @@ -200,7 +224,7 @@ 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 is_overdue(task.due) or nil, show_category = true, recur = task.recur, }) diff --git a/spec/parse_spec.lua b/spec/parse_spec.lua index edeffcd..bc313b0 100644 --- a/spec/parse_spec.lua +++ b/spec/parse_spec.lua @@ -323,6 +323,73 @@ describe('parse', function() 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() it('parses simple text', function() local desc, meta = parse.command_add('Buy milk') From 302bf8126fed034af0731f91d50d7f4d75325780 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 26 Feb 2026 16:28:58 -0500 Subject: [PATCH 09/21] feat: text objects and motions for the pending buffer (#39) * feat: text objects and motions for the pending buffer Problem: the pending buffer has action-button mappings but no Vim grammar. You cannot dat to delete a task, cit to change a description, or ]] to jump to the next category header. Solution: add textobj.lua with at/it (a task / inner task), aC/iC (a category / inner category), ]]/[[ (next/prev header), and ]t/[t (next/prev task). All text objects work in operator-pending and visual modes; motions work in normal, visual, and operator-pending. Mappings are configurable via the keymaps table and exposed as mappings. * fix(textobj): escape Lua pattern hyphen, fix test expectations Problem: inner_task_range used unescaped '-' in Lua patterns, which acts as a lazy quantifier instead of matching a literal hyphen. The metadata-stripping logic also tokenized the full line including the prefix, so the rebuilt string could never be found after the prefix. All test column expectations were off by one. Solution: escape hyphens with %-, rewrite metadata stripping to tokenize only the description portion after the prefix, and correct all test assertions to match actual rendered column positions. * feat(textobj): add debug mode, rename priority view buffer Problem: the ]] motion reportedly lands one line past the header in some environments, and ]t/[t may not override Neovim defaults. No way to diagnose these at runtime. Also, pending://priority is a poor buffer name for the flat ranked view. Solution: add a debug config option (vim.g.pending = { debug = true }) that logs meta state, cursor positions, and mapping registration to :messages at DEBUG level. Rename the buffer from pending://priority to pending://queue. Internal view identifier stays 'priority'. * docs: text objects, motions, debug mode, queue view rename Problem: vimdoc had no documentation for the new text objects, motions, debug config, or the pending://queue buffer rename. Solution: add text object and motion tables to the mappings section, document all eight mappings, add debug field to the config reference, update config example with new keymap defaults, rename priority view references to queue throughout the vimdoc. * fix(textobj): use correct config variable, raise log level Problem: motion keymaps (]], [[, ]t, [t) were never set because `config.get().debug` referenced an undefined `config` variable, crashing _setup_buf_mappings before the motion loop. Debug logging also used vim.log.levels.DEBUG which is filtered by default. Solution: replace `config` with `cfg` (already in scope) and raise both debug notify calls from DEBUG to INFO. * ci: formt --- doc/pending.txt | 87 ++++++++- lua/pending/buffer.lua | 3 +- lua/pending/config.lua | 17 ++ lua/pending/init.lua | 66 +++++++ lua/pending/textobj.lua | 384 ++++++++++++++++++++++++++++++++++++++++ plugin/pending.lua | 32 ++++ spec/textobj_spec.lua | 194 ++++++++++++++++++++ 7 files changed, 778 insertions(+), 5 deletions(-) create mode 100644 lua/pending/textobj.lua create mode 100644 spec/textobj_spec.lua diff --git a/doc/pending.txt b/doc/pending.txt index aad924c..d3cf136 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -34,7 +34,7 @@ Features: ~ - Relative date input: `today`, `tomorrow`, `+Nd`, `+Nw`, `+Nm`, weekday names, month names, ordinals, and more - Recurring tasks with automatic next-date spawning on completion -- Two views: category (default) and priority flat list +- Two views: category (default) and 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` @@ -278,13 +278,41 @@ Default buffer-local keys: ~ `` Toggle complete / uncomplete (`toggle`) `!` Toggle the priority flag (`priority`) `D` Prompt for a due date (`date`) - `` Switch between category / priority view (`view`) + `` 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`) `zc` Fold the current category section (category view only) `zo` Unfold the current category section (category view only) +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. *(pending-open)* @@ -323,6 +351,38 @@ Default buffer-local keys: ~ (pending-open-line-above) Insert a correctly-formatted blank task line above the cursor. + *(pending-a-task)* +(pending-a-task) + Select the current task line (linewise). Supports count. + + *(pending-i-task)* +(pending-i-task) + Select the task description text (characterwise). + + *(pending-a-category)* +(pending-a-category) + Select a full category section: header, tasks, and surrounding blanks. + + *(pending-i-category)* +(pending-i-category) + Select tasks within a category, excluding the header and blanks. + + *(pending-next-header)* +(pending-next-header) + Jump to the next category header. Supports count. + + *(pending-prev-header)* +(pending-prev-header) + Jump to the previous category header. Supports count. + + *(pending-next-task)* +(pending-next-task) + Jump to the next task line, skipping headers and blanks. + + *(pending-prev-task)* +(pending-prev-task) + Jump to the previous task line, skipping headers and blanks. + Example configuration: >lua vim.keymap.set('n', 't', '(pending-open)') vim.keymap.set('n', 'T', '(pending-toggle)') @@ -341,12 +401,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* @@ -371,6 +431,14 @@ loads: >lua 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', }, gcal = { calendar = 'Tasks', @@ -429,6 +497,17 @@ Fields: ~ See |pending-mappings| for the full list of actions and their default keys. + {debug} (boolean, default: false) + Enable diagnostic logging. When `true`, textobj + motions, mapping registration, and cursor jumps + emit messages at `vim.log.levels.DEBUG`. Use + |:messages| to inspect the output. Useful for + diagnosing keymap conflicts (e.g. `]t` colliding + with Neovim defaults) or motion misbehavior. + Example: >lua + vim.g.pending = { debug = true } +< + {gcal} (table, default: nil) Google Calendar sync configuration. See |pending.GcalConfig|. Omit this field entirely to diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index 4738830..a427b68 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -223,7 +223,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 diff --git a/lua/pending/config.lua b/lua/pending/config.lua index ec89cb2..ac98b64 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -11,6 +11,14 @@ ---@field undo? string|false ---@field open_line? string|false ---@field open_line_above? string|false +---@field a_task? string|false +---@field i_task? string|false +---@field a_category? string|false +---@field i_category? string|false +---@field next_header? string|false +---@field prev_header? string|false +---@field next_task? string|false +---@field prev_task? string|false ---@class pending.Config ---@field data_path string @@ -22,6 +30,7 @@ ---@field someday_date string ---@field category_order? string[] ---@field drawer_height? integer +---@field debug? boolean ---@field keymaps pending.Keymaps ---@field gcal? pending.GcalConfig @@ -47,6 +56,14 @@ local defaults = { 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', }, } diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 631c0e3..d176646 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -88,6 +88,72 @@ function M._setup_buf_mappings(bufnr) vim.keymap.set('n', key --[[@as string]], fn, opts) end end + + local textobj = require('pending.textobj') + + ---@type table + 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 + local motions = { + next_header = textobj.next_header, + prev_header = textobj.prev_header, + next_task = textobj.next_task, + prev_task = textobj.prev_task, + } + + for name, fn in pairs(motions) do + local key = km[name] + if cfg.debug then + vim.notify( + ('[pending] mapping motion %s → %s (buf=%d)'):format(name, key or 'nil', bufnr), + vim.log.levels.INFO + ) + end + if key and key ~= false then + vim.keymap.set({ 'n', 'x', 'o' }, key --[[@as string]], function() + fn(vim.v.count1) + end, opts) + end + end end ---@param bufnr integer diff --git a/lua/pending/textobj.lua b/lua/pending/textobj.lua new file mode 100644 index 0000000..62d6db3 --- /dev/null +++ b/lua/pending/textobj.lua @@ -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 diff --git a/plugin/pending.lua b/plugin/pending.lua index bfacfec..a239c7a 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -54,3 +54,35 @@ end) vim.keymap.set('n', '(pending-open-line-above)', function() require('pending.buffer').open_line(true) end) + +vim.keymap.set({ 'o', 'x' }, '(pending-a-task)', function() + require('pending.textobj').a_task(vim.v.count1) +end) + +vim.keymap.set({ 'o', 'x' }, '(pending-i-task)', function() + require('pending.textobj').i_task(vim.v.count1) +end) + +vim.keymap.set({ 'o', 'x' }, '(pending-a-category)', function() + require('pending.textobj').a_category(vim.v.count1) +end) + +vim.keymap.set({ 'o', 'x' }, '(pending-i-category)', function() + require('pending.textobj').i_category(vim.v.count1) +end) + +vim.keymap.set({ 'n', 'x', 'o' }, '(pending-next-header)', function() + require('pending.textobj').next_header(vim.v.count1) +end) + +vim.keymap.set({ 'n', 'x', 'o' }, '(pending-prev-header)', function() + require('pending.textobj').prev_header(vim.v.count1) +end) + +vim.keymap.set({ 'n', 'x', 'o' }, '(pending-next-task)', function() + require('pending.textobj').next_task(vim.v.count1) +end) + +vim.keymap.set({ 'n', 'x', 'o' }, '(pending-prev-task)', function() + require('pending.textobj').prev_task(vim.v.count1) +end) diff --git a/spec/textobj_spec.lua b/spec/textobj_spec.lua new file mode 100644 index 0000000..1253f58 --- /dev/null +++ b/spec/textobj_spec.lua @@ -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) From e62e09f60958bfa80746ec630957030de46a1d03 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 26 Feb 2026 16:30:06 -0500 Subject: [PATCH 10/21] feat: statusline API, counts, and PendingStatusChanged event (#40) Problem: no way to know about overdue or due-today tasks without opening :Pending. No ambient awareness for statusline plugins. Solution: add counts(), statusline(), and has_due() public API functions backed by a module-local cache that recomputes after every store.save() and store.load(). Fire a User PendingStatusChanged event on every recompute. Extract is_overdue() and is_today() from duplicate locals into parse.lua as public functions. Refactor views.lua and init.lua to use the shared date logic. Add vimdoc API section and integration recipes for lualine, heirline, manual statusline, startup notification, and event-driven refresh. --- doc/pending.txt | 97 ++++++++++++++ lua/pending/init.lua | 154 +++++++++++++++------- lua/pending/parse.lua | 35 +++++ lua/pending/sync/gcal.lua | 1 + lua/pending/views.lua | 25 +--- spec/status_spec.lua | 264 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 507 insertions(+), 69 deletions(-) create mode 100644 spec/status_spec.lua diff --git a/doc/pending.txt b/doc/pending.txt index d3cf136..fd73e30 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -513,6 +513,57 @@ Fields: ~ |pending.GcalConfig|. Omit this field entirely to disable Google Calendar sync. +============================================================================== +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* @@ -526,6 +577,52 @@ Configure blink.cmp to use pending.nvim's omnifunc as a completion source: >lua }) < +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* diff --git a/lua/pending/init.lua b/lua/pending/init.lua index d176646..8512210 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -3,11 +3,97 @@ 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 = {} 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() @@ -167,6 +253,7 @@ function M._on_write(bufnr) table.remove(stack, 1) end diff.apply(lines) + M._recompute_counts() buffer.render(bufnr) end @@ -179,7 +266,7 @@ function M.undo_write() end local state = table.remove(stack) store.replace_tasks(state) - store.save() + _save_and_notify() buffer.render(buffer.bufnr()) end @@ -220,7 +307,7 @@ function M.toggle_complete() 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 @@ -251,7 +338,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 @@ -294,7 +381,7 @@ function M.prompt_date() end end store.update(id, { due = due }) - store.save() + _save_and_notify() buffer.render(bufnr) end) end @@ -319,7 +406,7 @@ function M.add(text) 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) @@ -367,7 +454,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 @@ -375,44 +462,6 @@ function M.archive(days) end end ----@param due string ----@return boolean -local function is_due_or_overdue(due) - local now = os.date('*t') --[[@as osdate]] - local today = os.date('%Y-%m-%d') --[[@as string]] - local date_part, time_part = due:match('^(.+)T(.+)$') - if not date_part then - return due <= today - end - if date_part < today then - return true - end - if date_part > today then - return false - end - local current_time = string.format('%02d:%02d', now.hour, now.min) - return time_part <= current_time -end - ----@param due string ----@return boolean -local function is_overdue(due) - local now = os.date('*t') --[[@as osdate]] - local today = os.date('%Y-%m-%d') --[[@as string]] - local date_part, time_part = due:match('^(.+)T(.+)$') - if not date_part then - return due < today - end - if date_part < today then - return true - end - if date_part > today then - return false - end - local current_time = string.format('%02d:%02d', now.hour, now.min) - return time_part < current_time -end - ---@return nil function M.due() local bufnr = buffer.bufnr() @@ -422,9 +471,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 is_due_or_overdue(m.raw_due) 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 = is_overdue(m.raw_due) and '[OVERDUE] ' or '[DUE] ' + local label = parse.is_overdue(m.raw_due) and '[OVERDUE] ' or '[DUE] ' table.insert(qf_items, { bufnr = bufnr, lnum = lnum, @@ -436,8 +490,12 @@ function M.due() else store.load() for _, task in ipairs(store.active_tasks()) do - if task.status == 'pending' and task.due and is_due_or_overdue(task.due) then - local label = is_overdue(task.due) 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 .. ']' diff --git a/lua/pending/parse.lua b/lua/pending/parse.lua index e234269..9ce4c0d 100644 --- a/lua/pending/parse.lua +++ b/lua/pending/parse.lua @@ -516,4 +516,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 diff --git a/lua/pending/sync/gcal.lua b/lua/pending/sync/gcal.lua index 6635575..3b29b33 100644 --- a/lua/pending/sync/gcal.lua +++ b/lua/pending/sync/gcal.lua @@ -503,6 +503,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)', diff --git a/lua/pending/views.lua b/lua/pending/views.lua index a9f56bf..32cc2fb 100644 --- a/lua/pending/views.lua +++ b/lua/pending/views.lua @@ -1,4 +1,5 @@ local config = require('pending.config') +local parse = require('pending.parse') ---@class pending.LineMeta ---@field type 'task'|'header'|'blank' @@ -40,25 +41,6 @@ local function format_due(due) return formatted end ----@param due string ----@return boolean -local function is_overdue(due) - local now = os.date('*t') --[[@as osdate]] - local today = os.date('%Y-%m-%d') --[[@as string]] - local date_part, time_part = due:match('^(.+)T(.+)$') - if not date_part then - return due < today - end - if date_part < today then - return true - end - if date_part > today then - return false - end - local current_time = string.format('%02d:%02d', now.hour, now.min) - return time_part < current_time -end - ---@param tasks pending.Task[] local function sort_tasks(tasks) table.sort(tasks, function(a, b) @@ -174,7 +156,8 @@ function M.category_view(tasks) raw_due = task.due, status = task.status, category = cat, - overdue = task.status == 'pending' and task.due ~= nil and is_overdue(task.due) or nil, + overdue = task.status == 'pending' and task.due ~= nil and parse.is_overdue(task.due) + or nil, recur = task.recur, }) end @@ -224,7 +207,7 @@ function M.priority_view(tasks) raw_due = task.due, status = task.status, category = task.category, - overdue = task.status == 'pending' and task.due ~= nil and is_overdue(task.due) or nil, + overdue = task.status == 'pending' and task.due ~= nil and parse.is_overdue(task.due) or nil, show_category = true, recur = task.recur, }) diff --git a/spec/status_spec.lua b/spec/status_spec.lua new file mode 100644 index 0000000..ecbe127 --- /dev/null +++ b/spec/status_spec.lua @@ -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) From 8d3d21b3309f06888cc207f11916f82696933de1 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 26 Feb 2026 16:34:07 -0500 Subject: [PATCH 11/21] feat: :Pending edit command for CLI metadata editing (#41) * feat: :Pending edit command for CLI metadata editing Problem: editing task metadata (due date, category, priority, recurrence) requires opening the buffer and editing inline. No way to make quick metadata changes from the command line. Solution: add :Pending edit {id} [operations...] command that applies metadata changes by numeric task ID. Supports due:, cat:, rec:, +!, -!, -due, -cat, -rec operations with full date vocabulary and recurrence validation. Pushes to undo stack, re-renders the buffer if open, and provides feedback messages. Tab completion for IDs, field names, date vocabulary, categories, and recurrence patterns. Also fixes store.update() to properly clear fields set to vim.NIL. * ci: formt --- lua/pending/init.lua | 175 ++++++++++++++++++++++++ lua/pending/store.lua | 6 +- plugin/pending.lua | 164 ++++++++++++++++++++++- spec/edit_spec.lua | 304 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 644 insertions(+), 5 deletions(-) create mode 100644 spec/edit_spec.lua diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 8512210..0fcd564 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -514,6 +514,178 @@ function M.due() vim.cmd('copen') end +---@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 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 + + 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 + .. ':, cat:, ' + .. rk + .. ':, +!, -!, -' + .. dk + .. ', -cat, -' + .. rk +end + +---@param id_str string +---@param rest string +---@return nil +function M.edit(id_str, rest) + if not id_str or id_str == '' then + vim.notify( + 'Usage: :Pending edit [due:] [cat:] [rec:] [+!] [-!] [-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 [due:] [cat:] [rec:] [+!] [-!] [-due] [-cat] [-rec]', + vim.log.levels.ERROR + ) + return + end + + local tokens = {} + for tok in rest:gmatch('%S+') do + table.insert(tokens, tok) + end + + local updates = {} + local feedback = {} + + for _, tok in ipairs(tokens) do + local field, value, err = parse_edit_token(tok) + if err then + vim.notify(err, vim.log.levels.ERROR) + return + end + if field == 'recur' then + if value == vim.NIL then + updates.recur = vim.NIL + updates.recur_mode = vim.NIL + table.insert(feedback, 'recurrence removed') + else + updates.recur = value.spec + updates.recur_mode = value.mode + table.insert(feedback, 'recurrence set to ' .. value.spec) + end + elseif field == 'due' then + if value == vim.NIL then + updates.due = vim.NIL + table.insert(feedback, 'due date removed') + else + updates.due = value + table.insert(feedback, 'due date set to ' .. tostring(value)) + end + elseif field == 'category' then + if value == vim.NIL then + updates.category = vim.NIL + table.insert(feedback, 'category removed') + else + updates.category = value + table.insert(feedback, 'category set to ' .. tostring(value)) + end + elseif field == 'priority' then + updates.priority = value + table.insert(feedback, value == 1 and 'priority added' or 'priority removed') + end + end + + local snapshot = store.snapshot() + local stack = store.undo_stack() + table.insert(stack, snapshot) + if #stack > UNDO_MAX then + table.remove(stack, 1) + end + + store.update(id, updates) + store.save() + + local bufnr = buffer.bufnr() + if bufnr and vim.api.nvim_buf_is_valid(bufnr) then + buffer.render(bufnr) + end + + vim.notify('Task #' .. id .. ' updated: ' .. table.concat(feedback, ', ')) +end + ---@param args string ---@return nil function M.command(args) @@ -524,6 +696,9 @@ 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() elseif cmd == 'archive' then diff --git a/lua/pending/store.lua b/lua/pending/store.lua index c9e9b45..b9a4e38 100644 --- a/lua/pending/store.lua +++ b/lua/pending/store.lua @@ -293,7 +293,11 @@ function M.update(id, fields) local now = timestamp() for k, v in pairs(fields) do if k ~= 'id' and k ~= 'entry' then - task[k] = v + if v == vim.NIL then + task[k] = nil + else + task[k] = v + end end end task.modified = now diff --git a/plugin/pending.lua b/plugin/pending.lua index a239c7a..f9a8df1 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -3,17 +3,173 @@ 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 .. ':', + '+!', + '-!', + '-' .. dk, + '-cat', + '-' .. rk, + } +end + +---@return string[] +local function edit_date_values() + return { + 'today', + 'tomorrow', + 'yesterday', + '+1d', + '+2d', + '+3d', + '+1w', + '+2w', + '+1m', + 'mon', + 'tue', + 'wed', + 'thu', + 'fri', + 'sat', + 'sun', + 'eod', + 'eow', + 'eom', + 'eoq', + 'eoy', + 'sow', + 'som', + 'soq', + 'soy', + 'later', + } +end + +---@return string[] +local function edit_recur_values() + local ok, recur = pcall(require, 'pending.recur') + if not ok then + return {} + end + local result = {} + for _, s in ipairs(recur.shorthand_list()) do + table.insert(result, s) + end + for _, s in ipairs(recur.shorthand_list()) do + table.insert(result, '!' .. s) + end + return result +end + +---@param lead string +---@param candidates string[] +---@return string[] +local function filter_candidates(lead, candidates) + return vim.tbl_filter(function(s) + return s:find(lead, 1, true) == 1 + end, candidates) +end + +---@param arg_lead string +---@param cmd_line string +---@return string[] +local function complete_edit(arg_lead, cmd_line) + local cfg = require('pending.config').get() + local dk = cfg.date_syntax or 'due' + local rk = cfg.recur_syntax or 'rec' + + local after_edit = cmd_line:match('^Pending%s+edit%s+(.*)') + if not after_edit then + return {} + end + + local parts = {} + for part in after_edit:gmatch('%S+') do + table.insert(parts, part) + end + + local trailing_space = after_edit:match('%s$') + if #parts == 0 or (#parts == 1 and not trailing_space) then + local store = require('pending.store') + store.load() + local ids = {} + for _, task in ipairs(store.active_tasks()) do + table.insert(ids, tostring(task.id)) + end + return filter_candidates(arg_lead, ids) + end + + local prefix = arg_lead:match('^(' .. vim.pesc(dk) .. ':)(.*)$') + if prefix then + local after_colon = arg_lead:sub(#prefix + 1) + local dates = edit_date_values() + local result = {} + for _, d in ipairs(dates) do + if d:find(after_colon, 1, true) == 1 then + table.insert(result, prefix .. d) + end + end + return result + end + + local rec_prefix = arg_lead:match('^(' .. vim.pesc(rk) .. ':)(.*)$') + if rec_prefix then + local after_colon = arg_lead:sub(#rec_prefix + 1) + local pats = edit_recur_values() + local result = {} + for _, p in ipairs(pats) do + if p:find(after_colon, 1, true) == 1 then + table.insert(result, rec_prefix .. p) + end + end + return result + end + + local cat_prefix = arg_lead:match('^(cat:)(.*)$') + if cat_prefix then + local after_colon = arg_lead:sub(#cat_prefix + 1) + local store = require('pending.store') + store.load() + local seen = {} + local cats = {} + for _, task in ipairs(store.active_tasks()) do + if task.category and not seen[task.category] then + seen[task.category] = true + table.insert(cats, task.category) + end + end + table.sort(cats) + local result = {} + for _, c in ipairs(cats) do + if c:find(after_colon, 1, true) == 1 then + table.insert(result, cat_prefix .. c) + end + end + return result + end + + return filter_candidates(arg_lead, edit_field_candidates()) +end + vim.api.nvim_create_user_command('Pending', function(opts) 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 return {} end, diff --git a/spec/edit_spec.lua b/spec/edit_spec.lua new file mode 100644 index 0000000..ba9f98e --- /dev/null +++ b/spec/edit_spec.lua @@ -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) From 3da23c924aa32f9f7c21d4ec105c53e0c81c74d5 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 26 Feb 2026 17:59:04 -0500 Subject: [PATCH 12/21] feat(sync): backend interface + CLI refactor (#42) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(sync): extract backend interface, adapt gcal module Problem: :Pending sync hardcodes Google Calendar — M.sync() does pcall(require, 'pending.sync.gcal') and calls gcal.sync() directly. The config has a flat gcal field. This prevents adding new sync backends without modifying init.lua. Solution: Define a backend interface contract (name, auth, sync, health fields), refactor :Pending sync to dispatch via require('pending.sync.' .. backend_name), add sync table to config with legacy gcal migration, rename gcal.authorize to gcal.auth, add gcal.health for checkhealth, and add tab completion for backend names and actions. * docs(sync): update vimdoc for backend interface Problem: Vimdoc documents :Pending sync as a bare command that pushes to Google Calendar, with no mention of backends or the sync table config. Solution: Update :Pending sync section to show {backend} [{action}] syntax with examples, add SYNC BACKENDS section documenting the interface contract, update config example to use sync.gcal, document legacy gcal migration, and update health check description. * test(sync): add backend dispatch tests Problem: No test coverage for sync dispatch logic, config migration, or gcal module interface conformance. Solution: Add spec/sync_spec.lua with tests for: bare sync errors, empty backend errors, unknown backend errors, unknown action errors, default-to-sync routing, explicit sync/auth routing, legacy gcal config migration, explicit sync.gcal precedence, and gcal module interface fields (name, auth, sync, health). --- doc/pending.txt | 86 +++++++++++++++---- lua/pending/config.lua | 9 ++ lua/pending/health.lua | 20 +++-- lua/pending/init.lua | 24 ++++-- lua/pending/sync/gcal.lua | 22 ++++- plugin/pending.lua | 28 ++++++ spec/sync_spec.lua | 174 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 327 insertions(+), 36 deletions(-) create mode 100644 spec/sync_spec.lua diff --git a/doc/pending.txt b/doc/pending.txt index fd73e30..be369b5 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -47,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* @@ -250,10 +250,23 @@ 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/.lua`. + + Examples: >vim + :Pending sync gcal " runs gcal.sync() + :Pending sync gcal auth " runs gcal.auth() + :Pending sync gcal sync " explicit sync (same as bare) +< + + Tab completion after `:Pending sync ` lists discovered backends. + Tab completion after `:Pending sync gcal ` lists available actions. + + Built-in backends: ~ + + `gcal` Google Calendar one-way push. See |pending-gcal|. *:Pending-undo* :Pending undo @@ -440,9 +453,11 @@ loads: >lua next_task = ']t', prev_task = '[t', }, - gcal = { - calendar = 'Tasks', - credentials_path = '/path/to/client_secret.json', + sync = { + gcal = { + calendar = 'Tasks', + credentials_path = '/path/to/client_secret.json', + }, }, } < @@ -508,10 +523,16 @@ Fields: ~ 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* @@ -632,13 +653,18 @@ not pulled back into pending.nvim. Configuration: >lua vim.g.pending = { - gcal = { - calendar = 'Tasks', - credentials_path = '/path/to/client_secret.json', + 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') @@ -654,7 +680,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 — @@ -664,7 +690,7 @@ authorization code is exchanged for tokens and the refresh token is stored at use the stored refresh token and refresh the access token automatically when 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. @@ -677,6 +703,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/.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 `. + {auth} Authorization flow. Called by `:Pending sync 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.` in |pending-config|. + ============================================================================== HIGHLIGHT GROUPS *pending-highlights* @@ -728,8 +778,8 @@ Checks performed: ~ - Whether the data directory exists (warning if not yet created) - Whether the data file exists and can be parsed; reports total task count - Validates recurrence specs on stored tasks -- Whether `curl` is available (required for Google Calendar sync) -- Whether `openssl` is available (required for OAuth PKCE) +- Discovers sync backends under `lua/pending/sync/` and runs each backend's + `health()` function if it exists (e.g. gcal checks for `curl` and `openssl`) ============================================================================== DATA FORMAT *pending-data* diff --git a/lua/pending/config.lua b/lua/pending/config.lua index ac98b64..a1767db 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -2,6 +2,9 @@ ---@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 @@ -32,6 +35,7 @@ ---@field drawer_height? integer ---@field debug? boolean ---@field keymaps pending.Keymaps +---@field sync? pending.SyncConfig ---@field gcal? pending.GcalConfig ---@class pending.config @@ -65,6 +69,7 @@ local defaults = { next_task = ']t', prev_task = '[t', }, + sync = {}, } ---@type pending.Config? @@ -77,6 +82,10 @@ 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 diff --git a/lua/pending/health.lua b/lua/pending/health.lua index cc285e0..93f7c72 100644 --- a/lua/pending/health.lua +++ b/lua/pending/health.lua @@ -47,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)') - 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)') + for _, path in ipairs(sync_paths) do + local name = vim.fn.fnamemodify(path, ':t:r') + local bok, backend = pcall(require, 'pending.sync.' .. name) + if bok and type(backend.health) == 'function' then + vim.health.start('pending.nvim: sync/' .. name) + backend.health() + end + end end end diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 0fcd564..cae13a9 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -414,14 +414,25 @@ function M.add(text) vim.notify('Pending added: ' .. description) end +---@param backend_name string +---@param action? string ---@return nil -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) +function M.sync(backend_name, action) + if not backend_name or backend_name == '' then + vim.notify('Usage: :Pending sync [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 @@ -700,7 +711,8 @@ function M.command(args) 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) diff --git a/lua/pending/sync/gcal.lua b/lua/pending/sync/gcal.lua index 3b29b33..843f310 100644 --- a/lua/pending/sync/gcal.lua +++ b/lua/pending/sync/gcal.lua @@ -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 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( @@ -514,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 diff --git a/plugin/pending.lua b/plugin/pending.lua index f9a8df1..839b351 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -171,6 +171,34 @@ 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, }) diff --git a/spec/sync_spec.lua b/spec/sync_spec.lua new file mode 100644 index 0000000..4d8a3dc --- /dev/null +++ b/spec/sync_spec.lua @@ -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 [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 [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) From dcb6a4781de5ed38878c4bd709c260bb0d647bdf Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 26 Feb 2026 18:29:56 -0500 Subject: [PATCH 13/21] feat(filter): oil-like editable filter line (#43) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(filter): oil-like editable filter line with predicate dispatch Problem: no way to narrow the pending buffer to a subset of tasks without manual scrolling; filtered-out tasks would be silently deleted on :w because diff.apply() marks unseen IDs as deleted. Solution: add a FILTER: line rendered at the top of the buffer when a filter is active. The line is editable — :w re-parses it and updates the hidden set. diff.apply() gains a hidden_ids param that prevents filtered-out tasks from being marked deleted. Predicates: cat:X, overdue, today, priority (space-separated AND). :Pending filter sets it programmatically; :Pending filter clear removes it. * ci: format --- doc/pending.txt | 70 ++++++++++ lua/pending/buffer.lua | 44 +++++- lua/pending/diff.lua | 12 +- lua/pending/init.lua | 83 +++++++++++- lua/pending/views.lua | 2 +- plugin/pending.lua | 26 +++- spec/filter_spec.lua | 297 +++++++++++++++++++++++++++++++++++++++++ 7 files changed, 526 insertions(+), 8 deletions(-) create mode 100644 spec/filter_spec.lua diff --git a/doc/pending.txt b/doc/pending.txt index be369b5..a1f8198 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -268,6 +268,30 @@ COMMANDS *pending-commands* `gcal` Google Calendar one-way push. See |pending-gcal|. + *:Pending-filter* +:Pending filter {predicates} + Apply a filter to the task buffer. {predicates} is a space-separated list + of one or more predicate tokens. Only tasks matching all predicates (AND + semantics) are shown. Hidden tasks are not deleted — they are preserved in + the store and reappear when the filter is cleared. >vim + :Pending filter cat:Work + :Pending filter overdue + :Pending filter cat:Work overdue + :Pending filter priority + :Pending filter clear +< + When a filter is active the buffer's first line shows: > + FILTER: cat:Work overdue +< + The user can edit this line inline and `:w` to change the active filter. + Deleting the `FILTER:` line entirely and saving clears the filter. + `:Pending filter clear` also clears the filter programmatically. + + Tab completion after `:Pending filter ` lists available predicates and + category values. Already-used predicates are excluded from completions. + + See |pending-filters| for the full list of supported predicates. + *:Pending-undo* :Pending undo Undo the last `:w` save, restoring the task store to its previous state. @@ -421,6 +445,47 @@ Queue view: ~ *pending-view-queue* text alongside the due date virtual text so tasks remain identifiable across categories. The buffer is named `pending://queue`. +============================================================================== +FILTERS *pending-filters* + +Filters narrow the task buffer to a subset of tasks without deleting any data. +Hidden tasks are preserved in the store and reappear when the filter is +cleared. Filter state is session-local — it does not persist across Neovim +restarts. + +Set a filter with |:Pending-filter| or by editing the `FILTER:` line: >vim + :Pending filter cat:Work overdue +< + +Multiple predicates are separated by spaces and combined with AND logic — a +task must match every predicate to be shown. + +Available predicates: ~ + + `cat:X` Show only tasks whose category is exactly `X`. Tasks with no + category (assigned to `default_category`) are hidden unless + `default_category` matches `X`. + + `overdue` Show only pending tasks with a due date strictly before today. + + `today` Show only pending tasks with a due date equal to today. + + `priority` Show only tasks with priority > 0 (the `!` marker). + + `clear` Special value for |:Pending-filter| — clears the active filter + and shows all tasks. + +FILTER: line: ~ *pending-filter-line* + +When a filter is active, the first line of the task buffer is: > + FILTER: cat:Work overdue +< + +This line is editable. Write the buffer with `:w` to apply the updated +predicates. Deleting the `FILTER:` line and saving clears the filter. The +line is highlighted with |PendingFilter| and does not appear in the stored +task data. + ============================================================================== CONFIGURATION *pending-config* @@ -760,6 +825,11 @@ PendingRecur Applied to the recurrence indicator virtual text shown alongside due dates for recurring tasks. Default: links to `DiagnosticInfo`. + *PendingFilter* +PendingFilter Applied to the `FILTER:` header line shown at the top of + the buffer when a filter is active. + Default: links to `DiagnosticWarn`. + To override a group in your colorscheme or config: >lua vim.api.nvim_set_hl(0, 'PendingDue', { fg = '#aaaaaa', italic = true }) < diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index a427b68..0372ef6 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -16,6 +16,10 @@ local current_view = nil local _meta = {} ---@type table> local _fold_state = {} +---@type string[] +local _filter_predicates = {} +---@type table +local _hidden_ids = {} ---@return pending.LineMeta[] function M.meta() @@ -37,6 +41,24 @@ function M.current_view_name() return current_view end +---@return string[] +function M.filter_predicates() + return _filter_predicates +end + +---@return table +function M.hidden_ids() + return _hidden_ids +end + +---@param predicates string[] +---@param hidden table +---@return nil +function M.set_filter(predicates, hidden) + _filter_predicates = predicates + _hidden_ids = hidden +end + ---@return nil function M.clear_winid() task_winid = nil @@ -124,7 +146,13 @@ local function apply_extmarks(bufnr, line_meta) vim.api.nvim_buf_clear_namespace(bufnr, task_ns, 0, -1) for i, m in ipairs(line_meta) do local row = i - 1 - if m.type == 'task' then + if m.type == 'filter' then + local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or '' + vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, { + end_col = #line, + hl_group = 'PendingFilter', + }) + elseif m.type == 'task' then local due_hl = m.overdue and 'PendingOverdue' or 'PendingDue' local virt_parts = {} if m.show_category and m.category then @@ -170,6 +198,7 @@ local function setup_highlights() 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, 'PendingFilter', { link = 'DiagnosticWarn', default = true }) end local function snapshot_folds(bufnr) @@ -225,7 +254,13 @@ function M.render(bufnr) current_view = current_view or config.get().default_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 all_tasks = store.active_tasks() + local tasks = {} + for _, task in ipairs(all_tasks) do + if not _hidden_ids[task.id] then + table.insert(tasks, task) + end + end local lines, line_meta if current_view == 'priority' then @@ -234,6 +269,11 @@ function M.render(bufnr) lines, line_meta = views.category_view(tasks) end + if #_filter_predicates > 0 then + table.insert(lines, 1, 'FILTER: ' .. table.concat(_filter_predicates, ' ')) + table.insert(line_meta, 1, { type = 'filter' }) + end + _meta = line_meta snapshot_folds(bufnr) diff --git a/lua/pending/diff.lua b/lua/pending/diff.lua index daab788..4fd83c3 100644 --- a/lua/pending/diff.lua +++ b/lua/pending/diff.lua @@ -27,8 +27,13 @@ end function M.parse_buffer(lines) local result = {} local current_category = nil + local start = 1 + if lines[1] and lines[1]:match('^FILTER:') then + start = 2 + end - for i, line in ipairs(lines) do + for i = start, #lines do + local line = lines[i] local id, body = line:match('^/(%d+)/(- %[.%] .*)$') if not id then body = line:match('^(- %[.%] .*)$') @@ -65,8 +70,9 @@ function M.parse_buffer(lines) end ---@param lines string[] +---@param hidden_ids? table ---@return nil -function M.apply(lines) +function M.apply(lines, hidden_ids) local parsed = M.parse_buffer(lines) local now = timestamp() local data = store.data() @@ -160,7 +166,7 @@ function M.apply(lines) end for id, task in pairs(old_by_id) do - if not seen_ids[id] then + if not seen_ids[id] and not (hidden_ids and hidden_ids[id]) then task.status = 'deleted' task['end'] = now task.modified = now diff --git a/lua/pending/init.lua b/lua/pending/init.lua index cae13a9..7409fb5 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -94,6 +94,47 @@ function M.has_due() return c.overdue > 0 or c.today > 0 end +---@param tasks pending.Task[] +---@param predicates string[] +---@return table +local function compute_hidden_ids(tasks, predicates) + if #predicates == 0 then + return {} + end + local hidden = {} + for _, task in ipairs(tasks) do + local visible = true + for _, pred in ipairs(predicates) do + local cat_val = pred:match('^cat:(.+)$') + if cat_val then + if task.category ~= cat_val then + visible = false + break + end + elseif pred == 'overdue' then + if not (task.status == 'pending' and task.due and parse.is_overdue(task.due)) then + visible = false + break + end + elseif pred == 'today' then + if not (task.status == 'pending' and task.due and parse.is_today(task.due)) then + visible = false + break + end + elseif pred == 'priority' then + if not (task.priority and task.priority > 0) then + visible = false + break + end + end + end + if not visible then + hidden[task.id] = true + end + end + return hidden +end + ---@return integer bufnr function M.open() local bufnr = buffer.open() @@ -102,6 +143,30 @@ function M.open() return bufnr end +---@param pred_str string +---@return nil +function M.filter(pred_str) + if pred_str == 'clear' or pred_str == '' then + buffer.set_filter({}, {}) + local bufnr = buffer.bufnr() + if bufnr then + buffer.render(bufnr) + end + return + end + local predicates = {} + for word in pred_str:gmatch('%S+') do + table.insert(predicates, word) + end + local tasks = store.active_tasks() + local hidden = compute_hidden_ids(tasks, predicates) + buffer.set_filter(predicates, hidden) + local bufnr = buffer.bufnr() + if bufnr then + buffer.render(bufnr) + end +end + ---@param bufnr integer ---@return nil function M._setup_autocmds(bufnr) @@ -246,13 +311,27 @@ end ---@return nil function M._on_write(bufnr) local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + local predicates = buffer.filter_predicates() + if lines[1] and lines[1]:match('^FILTER:') then + local pred_str = lines[1]:match('^FILTER:%s*(.*)$') or '' + predicates = {} + for word in pred_str:gmatch('%S+') do + table.insert(predicates, word) + end + lines = vim.list_slice(lines, 2) + elseif #buffer.filter_predicates() > 0 then + predicates = {} + end + local tasks = store.active_tasks() + local hidden = compute_hidden_ids(tasks, predicates) + buffer.set_filter(predicates, hidden) local snapshot = store.snapshot() local stack = store.undo_stack() table.insert(stack, snapshot) if #stack > UNDO_MAX then table.remove(stack, 1) end - diff.apply(lines) + diff.apply(lines, hidden) M._recompute_counts() buffer.render(bufnr) end @@ -718,6 +797,8 @@ function M.command(args) M.archive(d) elseif cmd == 'due' then M.due() + elseif cmd == 'filter' then + M.filter(rest) elseif cmd == 'undo' then M.undo_write() else diff --git a/lua/pending/views.lua b/lua/pending/views.lua index 32cc2fb..286db9a 100644 --- a/lua/pending/views.lua +++ b/lua/pending/views.lua @@ -2,7 +2,7 @@ local config = require('pending.config') local parse = require('pending.parse') ---@class pending.LineMeta ----@field type 'task'|'header'|'blank' +---@field type 'task'|'header'|'blank'|'filter' ---@field id? integer ---@field due? string ---@field raw_due? string diff --git a/plugin/pending.lua b/plugin/pending.lua index 839b351..be546c5 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -164,10 +164,34 @@ end, { bar = true, nargs = '*', complete = function(arg_lead, cmd_line) - local subcmds = { 'add', 'archive', 'due', 'edit', 'sync', 'undo' } + local subcmds = { 'add', 'archive', 'due', 'edit', 'filter', 'sync', 'undo' } if not cmd_line:match('^Pending%s+%S') then return filter_candidates(arg_lead, subcmds) end + if cmd_line:match('^Pending%s+filter') then + local after_filter = cmd_line:match('^Pending%s+filter%s+(.*)') or '' + local used = {} + for word in after_filter:gmatch('%S+') do + used[word] = true + end + local candidates = { 'clear', 'overdue', 'today', 'priority' } + local store = require('pending.store') + store.load() + local seen = {} + for _, task in ipairs(store.active_tasks()) do + if task.category and not seen[task.category] then + seen[task.category] = true + table.insert(candidates, 'cat:' .. task.category) + end + end + local filtered = {} + for _, c in ipairs(candidates) do + if not used[c] and (arg_lead == '' or c:find(arg_lead, 1, true) == 1) then + table.insert(filtered, c) + end + end + return filtered + end if cmd_line:match('^Pending%s+edit') then return complete_edit(arg_lead, cmd_line) end diff --git a/spec/filter_spec.lua b/spec/filter_spec.lua new file mode 100644 index 0000000..8756c5f --- /dev/null +++ b/spec/filter_spec.lua @@ -0,0 +1,297 @@ +require('spec.helpers') + +local config = require('pending.config') +local diff = require('pending.diff') +local store = require('pending.store') + +describe('filter', function() + local tmpdir + local pending + local buffer + + 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 + package.loaded['pending.buffer'] = nil + pending = require('pending') + buffer = require('pending.buffer') + buffer.set_filter({}, {}) + end) + + after_each(function() + vim.fn.delete(tmpdir, 'rf') + vim.g.pending = nil + config.reset() + store.unload() + package.loaded['pending'] = nil + package.loaded['pending.buffer'] = nil + end) + + describe('filter predicates', function() + it('cat: hides tasks with non-matching category', function() + store.load() + store.add({ description = 'Work task', category = 'Work' }) + store.add({ description = 'Home task', category = 'Home' }) + store.save() + pending.filter('cat:Work') + local hidden = buffer.hidden_ids() + local tasks = store.active_tasks() + local work_task = nil + local home_task = nil + for _, t in ipairs(tasks) do + if t.category == 'Work' then + work_task = t + end + if t.category == 'Home' then + home_task = t + end + end + assert.is_not_nil(work_task) + assert.is_not_nil(home_task) + assert.is_nil(hidden[work_task.id]) + assert.is_true(hidden[home_task.id]) + end) + + it('cat: hides tasks with no category (default category)', function() + store.load() + store.add({ description = 'Work task', category = 'Work' }) + store.add({ description = 'Inbox task' }) + store.save() + pending.filter('cat:Work') + local hidden = buffer.hidden_ids() + local tasks = store.active_tasks() + local inbox_task = nil + for _, t in ipairs(tasks) do + if t.category ~= 'Work' then + inbox_task = t + end + end + assert.is_not_nil(inbox_task) + assert.is_true(hidden[inbox_task.id]) + end) + + it('overdue hides non-overdue tasks', function() + store.load() + store.add({ description = 'Old task', due = '2020-01-01' }) + store.add({ description = 'Future task', due = '2099-01-01' }) + store.add({ description = 'No due task' }) + store.save() + pending.filter('overdue') + local hidden = buffer.hidden_ids() + local tasks = store.active_tasks() + local overdue_task, future_task, nodue_task + for _, t in ipairs(tasks) do + if t.due == '2020-01-01' then + overdue_task = t + end + if t.due == '2099-01-01' then + future_task = t + end + if not t.due then + nodue_task = t + end + end + assert.is_nil(hidden[overdue_task.id]) + assert.is_true(hidden[future_task.id]) + assert.is_true(hidden[nodue_task.id]) + end) + + it('today hides non-today tasks', function() + store.load() + local today = os.date('%Y-%m-%d') --[[@as string]] + store.add({ description = 'Today task', due = today }) + store.add({ description = 'Old task', due = '2020-01-01' }) + store.add({ description = 'Future task', due = '2099-01-01' }) + store.save() + pending.filter('today') + local hidden = buffer.hidden_ids() + local tasks = store.active_tasks() + local today_task, old_task, future_task + for _, t in ipairs(tasks) do + if t.due == today then + today_task = t + end + if t.due == '2020-01-01' then + old_task = t + end + if t.due == '2099-01-01' then + future_task = t + end + end + assert.is_nil(hidden[today_task.id]) + assert.is_true(hidden[old_task.id]) + assert.is_true(hidden[future_task.id]) + end) + + it('priority hides non-priority tasks', function() + store.load() + store.add({ description = 'Important', priority = 1 }) + store.add({ description = 'Normal' }) + store.save() + pending.filter('priority') + local hidden = buffer.hidden_ids() + local tasks = store.active_tasks() + local important_task, normal_task + for _, t in ipairs(tasks) do + if t.priority and t.priority > 0 then + important_task = t + end + if not t.priority or t.priority == 0 then + normal_task = t + end + end + assert.is_nil(hidden[important_task.id]) + assert.is_true(hidden[normal_task.id]) + end) + + it('multi-predicate AND: cat:Work + overdue', function() + store.load() + store.add({ description = 'Work overdue', category = 'Work', due = '2020-01-01' }) + store.add({ description = 'Work future', category = 'Work', due = '2099-01-01' }) + store.add({ description = 'Home overdue', category = 'Home', due = '2020-01-01' }) + store.save() + pending.filter('cat:Work overdue') + local hidden = buffer.hidden_ids() + local tasks = store.active_tasks() + local work_overdue, work_future, home_overdue + for _, t in ipairs(tasks) do + if t.description == 'Work overdue' then + work_overdue = t + end + if t.description == 'Work future' then + work_future = t + end + if t.description == 'Home overdue' then + home_overdue = t + end + end + assert.is_nil(hidden[work_overdue.id]) + assert.is_true(hidden[work_future.id]) + assert.is_true(hidden[home_overdue.id]) + end) + + it('filter clear removes all predicates and hidden ids', function() + store.load() + store.add({ description = 'Work task', category = 'Work' }) + store.add({ description = 'Home task', category = 'Home' }) + store.save() + pending.filter('cat:Work') + assert.are.equal(1, #buffer.filter_predicates()) + pending.filter('clear') + assert.are.equal(0, #buffer.filter_predicates()) + assert.are.same({}, buffer.hidden_ids()) + end) + + it('filter empty string clears filter', function() + store.load() + store.add({ description = 'Work task', category = 'Work' }) + store.save() + pending.filter('cat:Work') + assert.are.equal(1, #buffer.filter_predicates()) + pending.filter('') + assert.are.equal(0, #buffer.filter_predicates()) + end) + + it('filter predicates persist across set_filter calls', function() + store.load() + store.add({ description = 'Work task', category = 'Work' }) + store.add({ description = 'Home task', category = 'Home' }) + store.save() + pending.filter('cat:Work') + local preds = buffer.filter_predicates() + assert.are.equal(1, #preds) + assert.are.equal('cat:Work', preds[1]) + local hidden = buffer.hidden_ids() + local tasks = store.active_tasks() + local home_task + for _, t in ipairs(tasks) do + if t.category == 'Home' then + home_task = t + end + end + assert.is_true(hidden[home_task.id]) + end) + end) + + describe('diff.apply with hidden_ids', function() + it('does not mark hidden tasks as deleted', function() + store.load() + store.add({ description = 'Visible task' }) + store.add({ description = 'Hidden task' }) + store.save() + local tasks = store.active_tasks() + local hidden_task + for _, t in ipairs(tasks) do + if t.description == 'Hidden task' then + hidden_task = t + end + end + local hidden_ids = { [hidden_task.id] = true } + local lines = { + '/1/- [ ] Visible task', + } + diff.apply(lines, hidden_ids) + store.unload() + store.load() + local hidden = store.get(hidden_task.id) + assert.are.equal('pending', hidden.status) + end) + + it('marks tasks deleted when not hidden and not in buffer', function() + store.load() + store.add({ description = 'Keep task' }) + store.add({ description = 'Delete task' }) + store.save() + local tasks = store.active_tasks() + local keep_task, delete_task + for _, t in ipairs(tasks) do + if t.description == 'Keep task' then + keep_task = t + end + if t.description == 'Delete task' then + delete_task = t + end + end + local lines = { + '/' .. keep_task.id .. '/- [ ] Keep task', + } + diff.apply(lines, {}) + store.unload() + store.load() + local deleted = store.get(delete_task.id) + assert.are.equal('deleted', deleted.status) + end) + + it('strips FILTER: line before parsing', function() + store.load() + store.add({ description = 'My task' }) + store.save() + local tasks = store.active_tasks() + local task = tasks[1] + local lines = { + 'FILTER: cat:Work', + '/' .. task.id .. '/- [ ] My task', + } + diff.apply(lines, {}) + store.unload() + store.load() + local t = store.get(task.id) + assert.are.equal('pending', t.status) + end) + + it('parse_buffer skips FILTER: header line', function() + local lines = { + 'FILTER: overdue', + '/1/- [ ] A task', + } + local result = diff.parse_buffer(lines) + assert.are.equal(1, #result) + assert.are.equal('task', result[1].type) + assert.are.equal('A task', result[1].description) + end) + end) +end) From 994294393c2813bf986370079a0f925330c5da5a Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 26 Feb 2026 18:30:14 -0500 Subject: [PATCH 14/21] docs(textobj): add mini.ai integration recipe to vimdoc (#44) Problem: users with mini.ai installed find that buffer-local `at`, `it`, `aC`, `iC` text objects never fire because mini.ai intercepts `a`/`i` as single-key handlers in operator-pending/visual modes before Neovim's mapping system can route them to buffer-local maps. Solution: add a *pending-mini-ai* recipe section to the RECIPES block in pending.txt. The recipe explains the conflict, describes mini.ai's custom_textobjects spec (`{ from = {line,col}, to = {line,col} }`), and shows how to wrap `textobj.inner_task_range` and `textobj.category_bounds` (the two functions that return positional data) into the shape mini.ai expects, registered via `vim.b.miniai_config` in a FileType autocmd. Notes that `aC` cannot be expressed this way due to its linewise selection, and that the built-in keymaps work fine for users without mini.ai. --- doc/pending.txt | 71 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/doc/pending.txt b/doc/pending.txt index a1f8198..9122a2e 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -709,6 +709,77 @@ Event-driven statusline refresh: >lua }) < +mini.ai integration: ~ *pending-mini-ai* +mini.ai (from mini.nvim) maps `a` and `i` as single-key handlers in +operator-pending and visual modes. It captures the next keystroke internally +rather than routing it through Neovim's mapping system, which means the +buffer-local `at`, `it`, `aC`, and `iC` maps never fire for users who have +mini.ai installed. + +The fix is to register pending.nvim's text objects as mini.ai custom +textobjects via `vim.b.miniai_config` in a `FileType` autocmd. mini.ai's +`custom_textobjects` spec expects each entry to be a function returning +`{ from = { line, col }, to = { line, col } }` (1-indexed, col is +byte-offset from 1). + +pending.nvim's `textobj.inner_task_range(line)` returns the start and end +column offsets within the current line. Combine it with the cursor row and +the buffer line to build the region tables mini.ai expects: >lua + + vim.api.nvim_create_autocmd('FileType', { + pattern = 'pending', + callback = function() + local function task_inner() + local textobj = require('pending.textobj') + local row = vim.api.nvim_win_get_cursor(0)[1] + local line = vim.api.nvim_buf_get_lines(0, row - 1, row, false)[1] + if not line then return end + local s, e = textobj.inner_task_range(line) + if s > e then return end + return { from = { line = row, col = s }, to = { line = row, col = e } } + end + + local function category_inner() + local textobj = require('pending.textobj') + local buffer = require('pending.buffer') + local meta = buffer.meta() + if not meta then return end + local row = vim.api.nvim_win_get_cursor(0)[1] + local header_row, last_row = textobj.category_bounds(row, meta) + if not header_row then return end + local first_task, last_task + 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 then return end + local first_line = vim.api.nvim_buf_get_lines(0, first_task - 1, first_task, false)[1] or '' + local last_line = vim.api.nvim_buf_get_lines(0, last_task - 1, last_task, false)[1] or '' + return { + from = { line = first_task, col = 1 }, + to = { line = last_task, col = #last_line }, + } + end + + vim.b.miniai_config = { + custom_textobjects = { t = task_inner, C = category_inner }, + } + end, + }) +< + +Note that the default `keymaps.a_task = 'at'` and friends still work in +standard Neovim operator-pending mode for users who do not have mini.ai. The +`vim.b.miniai_config` block is only needed when mini.ai is active. + +`aC` (outer category) is not exposed here because mini.ai does not support +the linewise selection that `aC` requires. Use the buffer-local `aC` key +directly, or disable `a_category` in `keymaps` and handle it via a +`vim.b.miniai_config` entry that returns a linewise region if mini.ai's +spec allows it in your version. + ============================================================================== GOOGLE CALENDAR *pending-gcal* From 1748e5caa12b011d4db581eef1a6526805399232 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 26 Feb 2026 19:12:48 -0500 Subject: [PATCH 15/21] feat(file-token): file: inline metadata token with gf navigation (#45) * feat(file-token): add file: inline metadata token with gf navigation Problem: there was no way to link a task to a specific location in a source file, or to quickly jump from a task to the relevant code. Solution: add a file:: inline token that stores a relative file reference in task._extra.file. Virtual text renders basename:line in a new PendingFile highlight group. A buffer-local gf mapping (configurable via keymaps.goto_file) opens the file at the given line. M.add_here() lets users attach the current cursor position to any task via vim.ui.select(). M.edit() gains -file support to clear the reference. (pending-goto-file) and (pending-add-here) are exposed for custom mappings. * test(file-token): add parse, diff, views, edit, and navigation tests Problem: the file: token implementation had no test coverage. Solution: add spec/file_spec.lua covering parse.body extraction, malformed token handling, duplicate token stop-parsing, diff reconciliation (store/update/clear/round-trip), LineMeta population in both views, :Pending edit -file, and goto_file notify paths for no-file and unreadable-file cases. All 292 tests pass. * style: apply stylua formatting * fix(types): remove empty elseif block, fix file? annotation nullability --- doc/pending.txt | 94 ++++++++++- lua/pending/buffer.lua | 5 + lua/pending/complete.lua | 1 + lua/pending/config.lua | 1 + lua/pending/diff.lua | 15 ++ lua/pending/init.lua | 116 ++++++++++++- lua/pending/parse.lua | 17 +- lua/pending/views.lua | 3 + plugin/pending.lua | 10 ++ spec/file_spec.lua | 351 +++++++++++++++++++++++++++++++++++++++ 10 files changed, 605 insertions(+), 8 deletions(-) create mode 100644 spec/file_spec.lua diff --git a/doc/pending.txt b/doc/pending.txt index 9122a2e..08c63f9 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -30,7 +30,7 @@ concealed tokens and are never visible during editing. Features: ~ - Oil-style buffer editing: standard Vim motions for all task operations -- Inline metadata syntax: `due:`, `cat:`, and `rec:` tokens parsed on `:w` +- Inline metadata syntax: `due:`, `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 @@ -101,6 +101,7 @@ Supported tokens: ~ `due:` Resolve a named date (see |pending-dates| below). `cat:Name` Move the task to the named category on save. `rec:` Set a recurrence rule (see |pending-recurrence|). + `file::` 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|. The token name for recurrence defaults to @@ -118,10 +119,44 @@ 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:`, one -`cat:`, and one `rec:` per task line are consumed. +`cat:`, one `rec:`, and one `file:` per task line are consumed. -Omnifunc completion is available for all three token types. In insert mode, -type `due:`, `cat:`, or `rec:` and press `` to see suggestions. +Omnifunc completion is available for `due:`, `cat:`, and `rec:` token types. +In insert mode, type the token prefix and press `` to see +suggestions. + +============================================================================== +FILE TOKEN *pending-file-token* + +The `file:` inline token attaches a source file reference to a task. The +syntax is: > + + file:: +< + +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 +|(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* @@ -292,6 +327,29 @@ COMMANDS *pending-commands* See |pending-filters| for the full list of supported predicates. + *: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:` Set due date (accepts all |pending-dates| vocabulary). + `cat:` Set category. + `rec:` 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. @@ -319,6 +377,7 @@ Default buffer-local keys: ~ `U` Undo the last `:w` save (`undo`) `o` Insert a new task line below (`open_line`) `O` Insert a new task line above (`open_line_above`) + `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) @@ -420,6 +479,21 @@ All motions support count: `3]]` jumps three headers forward. `]]` and (pending-prev-task) Jump to the previous task line, skipping headers and blanks. + *(pending-goto-file)* +(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|. + + *(pending-add-here)* +(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', 't', '(pending-open)') vim.keymap.set('n', 'T', '(pending-toggle)') @@ -517,6 +591,7 @@ loads: >lua prev_header = '[[', next_task = ']t', prev_task = '[t', + goto_file = 'gf', }, sync = { gcal = { @@ -577,6 +652,11 @@ Fields: ~ 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 @@ -901,6 +981,12 @@ PendingFilter Applied to the `FILTER:` header line shown at the top of the buffer when a filter is active. Default: links to `DiagnosticWarn`. + *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 }) < diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index 0372ef6..0aa78bb 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -164,6 +164,10 @@ local function apply_extmarks(bufnr, line_meta) 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] .. ' ' @@ -199,6 +203,7 @@ local function setup_highlights() 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, 'PendingFilter', { link = 'DiagnosticWarn', default = true }) + vim.api.nvim_set_hl(0, 'PendingFile', { link = 'Directory', default = true }) end local function snapshot_folds(bufnr) diff --git a/lua/pending/complete.lua b/lua/pending/complete.lua index 79f338b..6c2b964 100644 --- a/lua/pending/complete.lua +++ b/lua/pending/complete.lua @@ -121,6 +121,7 @@ function M.omnifunc(findstart, base) { vim.pesc(dk) .. ':([%S]*)$', dk }, { 'cat:([%S]*)$', 'cat' }, { vim.pesc(rk) .. ':([%S]*)$', rk }, + { 'file:([%S]*)$', 'file' }, } for _, check in ipairs(checks) do diff --git a/lua/pending/config.lua b/lua/pending/config.lua index a1767db..000ac2b 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -22,6 +22,7 @@ ---@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 diff --git a/lua/pending/diff.lua b/lua/pending/diff.lua index 4fd83c3..c731d95 100644 --- a/lua/pending/diff.lua +++ b/lua/pending/diff.lua @@ -12,6 +12,7 @@ local store = require('pending.store') ---@field due? string ---@field rec? string ---@field rec_mode? string +---@field file? string ---@field lnum integer ---@class pending.diff @@ -57,6 +58,7 @@ function M.parse_buffer(lines) due = metadata.due, rec = metadata.rec, rec_mode = metadata.rec_mode, + file = metadata.file, lnum = i, }) end @@ -133,6 +135,19 @@ function M.apply(lines, hidden_ids) 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 diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 7409fb5..73b3051 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -1,4 +1,5 @@ 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') @@ -305,6 +306,16 @@ function M._setup_buf_mappings(bufnr) 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 @@ -629,6 +640,9 @@ local function parse_edit_token(token) 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 @@ -673,10 +687,11 @@ local function parse_edit_token(token) .. dk .. ':, cat:, ' .. rk - .. ':, +!, -!, -' + .. ':, file::, +!, -!, -' .. dk .. ', -cat, -' .. rk + .. ', -file' end ---@param id_str string @@ -755,6 +770,9 @@ function M.edit(id_str, rest) 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 @@ -766,6 +784,18 @@ function M.edit(id_str, rest) 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() @@ -776,6 +806,90 @@ function M.edit(id_str, rest) 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) diff --git a/lua/pending/parse.lua b/lua/pending/parse.lua index 9ce4c0d..6d43be4 100644 --- a/lua/pending/parse.lua +++ b/lua/pending/parse.lua @@ -416,7 +416,7 @@ end ---@param text string ---@return string description ----@return { due?: string, cat?: string, rec?: string, rec_mode?: 'scheduled'|'completion' } 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 @@ -481,7 +481,18 @@ function M.body(text) metadata.rec = raw_spec i = i - 1 else - break + 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 @@ -499,7 +510,7 @@ end ---@param text string ---@return string description ----@return { due?: string, cat?: string, rec?: string, rec_mode?: 'scheduled'|'completion' } 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 diff --git a/lua/pending/views.lua b/lua/pending/views.lua index 286db9a..5447a90 100644 --- a/lua/pending/views.lua +++ b/lua/pending/views.lua @@ -12,6 +12,7 @@ local parse = require('pending.parse') ---@field show_category? boolean ---@field priority? integer ---@field recur? string +---@field file? string ---@class pending.views local M = {} @@ -159,6 +160,7 @@ function M.category_view(tasks) 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 @@ -210,6 +212,7 @@ function M.priority_view(tasks) 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 diff --git a/plugin/pending.lua b/plugin/pending.lua index be546c5..5cd94d0 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -12,11 +12,13 @@ local function edit_field_candidates() dk .. ':', 'cat:', rk .. ':', + 'file:', '+!', '-!', '-' .. dk, '-cat', '-' .. rk, + '-file', } end @@ -294,3 +296,11 @@ end) vim.keymap.set({ 'n', 'x', 'o' }, '(pending-prev-task)', function() require('pending.textobj').prev_task(vim.v.count1) end) + +vim.keymap.set('n', '(pending-goto-file)', function() + require('pending').goto_file() +end) + +vim.keymap.set('n', '(pending-add-here)', function() + require('pending').add_here() +end) diff --git a/spec/file_spec.lua b/spec/file_spec.lua new file mode 100644 index 0000000..9835387 --- /dev/null +++ b/spec/file_spec.lua @@ -0,0 +1,351 @@ +require('spec.helpers') + +local config = require('pending.config') +local diff = require('pending.diff') +local parse = require('pending.parse') +local store = require('pending.store') +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) From a4a470ce5a2c095aa2aed648110864121bdcca92 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Thu, 26 Feb 2026 18:23:32 -0500 Subject: [PATCH 16/21] feat(config): add icons table with unicode defaults --- lua/pending/config.lua | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/lua/pending/config.lua b/lua/pending/config.lua index 000ac2b..6aeaf3a 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -1,3 +1,12 @@ +---@class pending.Icons +---@field pending string +---@field done string +---@field priority string +---@field header string +---@field due string +---@field recur string +---@field category string + ---@class pending.GcalConfig ---@field calendar? string ---@field credentials_path? string @@ -38,6 +47,7 @@ ---@field keymaps pending.Keymaps ---@field sync? pending.SyncConfig ---@field gcal? pending.GcalConfig +---@field icons pending.Icons ---@class pending.config local M = {} @@ -71,6 +81,15 @@ local defaults = { prev_task = '[t', }, sync = {}, + icons = { + pending = '○', + done = '✓', + priority = '●', + header = '▸', + due = '·', + recur = '↺', + category = '#', + }, } ---@type pending.Config? From e867078cbd346fe08767b4b35ca0a90a39af0acf Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Thu, 26 Feb 2026 18:23:40 -0500 Subject: [PATCH 17/21] feat(buffer): render icon overlays from config.icons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: status characters ([ ], [x], [!]) and metadata prefixes are hardcoded literals with no user customization. Solution: read config.icons in apply_extmarks and apply overlay extmarks for checkboxes/headers, replace hardcoded recur ↺ with icons.recur, and prefix due/category virt_text with configurable icon characters. --- lua/pending/buffer.lua | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index 0aa78bb..09412f3 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -143,6 +143,7 @@ end ---@param bufnr integer ---@param line_meta pending.LineMeta[] local function apply_extmarks(bufnr, line_meta) + local icons = config.get().icons vim.api.nvim_buf_clear_namespace(bufnr, task_ns, 0, -1) for i, m in ipairs(line_meta) do local row = i - 1 @@ -156,13 +157,13 @@ local function apply_extmarks(bufnr, line_meta) local due_hl = m.overdue and 'PendingOverdue' or 'PendingDue' local virt_parts = {} if m.show_category and m.category then - table.insert(virt_parts, { m.category, 'PendingHeader' }) + table.insert(virt_parts, { icons.category .. ' ' .. m.category, 'PendingHeader' }) end if m.recur then - table.insert(virt_parts, { '\u{21bb} ' .. m.recur, 'PendingRecur' }) + table.insert(virt_parts, { icons.recur .. ' ' .. m.recur, 'PendingRecur' }) end if m.due then - table.insert(virt_parts, { m.due, due_hl }) + table.insert(virt_parts, { icons.due .. ' ' .. m.due, due_hl }) end if m.file then local display = m.file:match('([^/]+:%d+)$') or m.file @@ -185,12 +186,33 @@ local function apply_extmarks(bufnr, line_meta) hl_group = 'PendingDone', }) end + local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or '' + local bracket_col = (line:find('%[') or 1) - 1 + local icon, icon_hl + if m.status == 'done' then + icon, icon_hl = icons.done, 'PendingDone' + elseif m.priority and m.priority > 0 then + icon, icon_hl = icons.priority, 'PendingPriority' + else + icon, icon_hl = icons.pending, 'Normal' + end + local icon_padded = icon .. ' ' + vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, bracket_col, { + virt_text = { { icon_padded, icon_hl } }, + virt_text_pos = 'overlay', + priority = 100, + }) elseif m.type == 'header' then local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or '' vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, { end_col = #line, hl_group = 'PendingHeader', }) + vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, { + virt_text = { { icons.header .. ' ', 'PendingHeader' } }, + virt_text_pos = 'overlay', + priority = 100, + }) end end end From 9fc24f52395029b8be7c7db4088c4304256cac25 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Thu, 26 Feb 2026 18:23:43 -0500 Subject: [PATCH 18/21] feat(plugin): add PendingTab command and (pending-tab) --- plugin/pending.lua | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/plugin/pending.lua b/plugin/pending.lua index 5cd94d0..ce62d1b 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -304,3 +304,13 @@ end) vim.keymap.set('n', '(pending-add-here)', function() require('pending').add_here() end) + +vim.keymap.set('n', '(pending-tab)', function() + vim.cmd.tabnew() + require('pending').open() +end) + +vim.api.nvim_create_user_command('PendingTab', function() + vim.cmd.tabnew() + require('pending').open() +end, {}) From cd96a269a44a60d8909603ae9503d321dd3e7e27 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Thu, 26 Feb 2026 18:23:50 -0500 Subject: [PATCH 19/21] docs: add icons config, PendingTab recipes, and demo infrastructure Problem: icon customization and auto-start workflow are undocumented; no demo asset exists for the README. Solution: document pending.Icons in vimdoc with nerd font and ASCII recipes, add PendingTab to commands and mappings, add open-on-startup recipe, add demo-init.lua and demo.tape for VHS screenshot generation, add assets/ directory, add README icons section and demo placeholder. --- README.md | 17 ++++++++++++- assets/.gitkeep | 0 doc/pending.txt | 52 ++++++++++++++++++++++++++++++++++++++ scripts/demo-init.lua | 33 ++++++++++++++++++++++++ scripts/demo.tape | 28 ++++++++++++++++++++ spec/icons_spec.lua | 59 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 188 insertions(+), 1 deletion(-) create mode 100644 assets/.gitkeep create mode 100644 scripts/demo-init.lua create mode 100644 scripts/demo.tape create mode 100644 spec/icons_spec.lua diff --git a/README.md b/README.md index df7f3dd..f6add96 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Edit tasks like text. `:w` saves them. - +![demo](assets/demo.gif) ## Requirements @@ -24,6 +24,21 @@ luarocks install pending.nvim :help pending.nvim ``` +## Icons + +pending.nvim renders task status and metadata using configurable icon characters. The defaults use plain unicode (no nerd font required): + +```lua +vim.g.pending = { + icons = { + pending = '○', done = '✓', priority = '●', + header = '▸', due = '·', recur = '↺', category = '#', + }, +} +``` + +See `:help pending.Icons` for nerd font examples. + ## Acknowledgements - [dooing](https://github.com/atiladefreitas/dooing) diff --git a/assets/.gitkeep b/assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/doc/pending.txt b/doc/pending.txt index 08c63f9..486ea32 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -356,6 +356,10 @@ COMMANDS *pending-commands* Equivalent to the `U` buffer-local key (see |pending-mappings|). Up to 20 levels of undo are persisted across sessions. + *:PendingTab* +:PendingTab + Open the task buffer in a new tab. + ============================================================================== MAPPINGS *pending-mappings* @@ -494,6 +498,9 @@ All motions support count: `3]]` jumps three headers forward. `]]` and to the current buffer's file and the cursor's line number. See |pending-file-token|. +(pending-tab) *(pending-tab)* + Open the task buffer in a new tab. See |:PendingTab|. + Example configuration: >lua vim.keymap.set('n', 't', '(pending-open)') vim.keymap.set('n', 'T', '(pending-toggle)') @@ -679,6 +686,16 @@ Fields: ~ automatically. New configs should use `sync.gcal` instead. See |pending.GcalConfig|. + {icons} (table) *pending.Icons* + Icon characters displayed in the buffer. Fields: + {pending} Uncompleted task icon. Default: '○' + {done} Completed task icon. Default: '✓' + {priority} Priority task icon. Default: '●' + {header} Category header prefix. Default: '▸' + {due} Due date prefix. Default: '·' + {recur} Recurrence prefix. Default: '↺' + {category} Category label prefix. Default: '#' + ============================================================================== LUA API *pending-api* @@ -860,6 +877,41 @@ directly, or disable `a_category` in `keymaps` and handle it via a `vim.b.miniai_config` entry that returns a linewise region if mini.ai's spec allows it in your version. +Nerd font icons: >lua + vim.g.pending = { + icons = { + pending = '', + done = '', + priority = '', + header = '', + due = '', + recur = '󰁯', + category = '', + }, + } +< + +ASCII fallback icons: >lua + vim.g.pending = { + icons = { + pending = '-', + done = 'x', + priority = '!', + header = '>', + due = '@', + recur = '~', + category = '+', + }, + } +< + +Open tasks in a new tab on startup: >lua + vim.api.nvim_create_autocmd('VimEnter', { + callback = function() + vim.cmd.PendingTab() + end, + }) +< ============================================================================== GOOGLE CALENDAR *pending-gcal* diff --git a/scripts/demo-init.lua b/scripts/demo-init.lua new file mode 100644 index 0000000..9c19e2a --- /dev/null +++ b/scripts/demo-init.lua @@ -0,0 +1,33 @@ +vim.opt.runtimepath:prepend(vim.fn.getcwd()) +local tmpdir = vim.fn.tempname() +vim.fn.mkdir(tmpdir, 'p') + +vim.g.pending = { + data_path = tmpdir .. '/tasks.json', + icons = { + pending = '○', + done = '✓', + priority = '●', + header = '▸', + due = '·', + recur = '↺', + category = '#', + }, +} + +local store = require('pending.store') +store.load() + +local today = os.date('%Y-%m-%d') +local yesterday = os.date('%Y-%m-%d', os.time() - 86400) +local tomorrow = os.date('%Y-%m-%d', os.time() + 86400) + +store.add({ description = 'Finish quarterly report', category = 'Work', due = tomorrow, recur = 'monthly', priority = 1 }) +store.add({ description = 'Review pull requests', category = 'Work' }) +store.add({ description = 'Update deployment docs', category = 'Work', status = 'done' }) +store.add({ description = 'Buy groceries', category = 'Personal', due = today }) +store.add({ description = 'Call dentist', category = 'Personal', due = yesterday, priority = 1 }) +store.add({ description = 'Read chapter 5', category = 'Personal' }) +store.add({ description = 'Learn a new language', category = 'Someday' }) +store.add({ description = 'Plan hiking trip', category = 'Someday' }) +store.save() diff --git a/scripts/demo.tape b/scripts/demo.tape new file mode 100644 index 0000000..3a1eee5 --- /dev/null +++ b/scripts/demo.tape @@ -0,0 +1,28 @@ +Output assets/demo.gif + +Require nvim + +Set Shell "bash" +Set FontSize 14 +Set Width 900 +Set Height 450 + +Type "nvim -u scripts/demo-init.lua -c 'autocmd VimEnter * Pending'" +Enter + +Sleep 2s + +Down +Down +Sleep 300ms +Down +Sleep 300ms + +Enter +Sleep 500ms + +Tab +Sleep 1s + +Type "q" +Sleep 200ms diff --git a/spec/icons_spec.lua b/spec/icons_spec.lua new file mode 100644 index 0000000..478400f --- /dev/null +++ b/spec/icons_spec.lua @@ -0,0 +1,59 @@ +require('spec.helpers') + +local config = require('pending.config') + +describe('icons', function() + before_each(function() + vim.g.pending = nil + config.reset() + end) + + after_each(function() + vim.g.pending = nil + config.reset() + end) + + it('has default icon values', function() + local icons = config.get().icons + assert.equals('○', icons.pending) + assert.equals('✓', icons.done) + assert.equals('●', icons.priority) + assert.equals('▸', icons.header) + assert.equals('·', icons.due) + assert.equals('↺', icons.recur) + assert.equals('#', icons.category) + end) + + it('allows overriding individual icons', function() + vim.g.pending = { icons = { pending = '-', done = 'x' } } + config.reset() + local icons = config.get().icons + assert.equals('-', icons.pending) + assert.equals('x', icons.done) + assert.equals('●', icons.priority) + assert.equals('▸', icons.header) + end) + + it('allows overriding all icons', function() + vim.g.pending = { + icons = { + pending = '-', + done = 'x', + priority = '!', + header = '>', + due = '@', + recur = '~', + category = '+', + }, + } + config.reset() + local icons = config.get().icons + assert.equals('-', icons.pending) + assert.equals('x', icons.done) + assert.equals('!', icons.priority) + assert.equals('>', icons.header) + assert.equals('@', icons.due) + assert.equals('~', icons.recur) + assert.equals('+', icons.category) + end) +end) From e182cc67a2ba5de64c66829829f68e55776d7136 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Thu, 26 Feb 2026 19:17:58 -0500 Subject: [PATCH 20/21] ci: format --- lua/pending/config.lua | 10 +++++----- scripts/demo-init.lua | 18 ++++++++++++------ spec/icons_spec.lua | 10 +++++----- 3 files changed, 22 insertions(+), 16 deletions(-) diff --git a/lua/pending/config.lua b/lua/pending/config.lua index 6aeaf3a..6adf1c3 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -82,12 +82,12 @@ local defaults = { }, sync = {}, icons = { - pending = '○', - done = '✓', + pending = '○', + done = '✓', priority = '●', - header = '▸', - due = '·', - recur = '↺', + header = '▸', + due = '·', + recur = '↺', category = '#', }, } diff --git a/scripts/demo-init.lua b/scripts/demo-init.lua index 9c19e2a..f2a6213 100644 --- a/scripts/demo-init.lua +++ b/scripts/demo-init.lua @@ -5,12 +5,12 @@ vim.fn.mkdir(tmpdir, 'p') vim.g.pending = { data_path = tmpdir .. '/tasks.json', icons = { - pending = '○', - done = '✓', + pending = '○', + done = '✓', priority = '●', - header = '▸', - due = '·', - recur = '↺', + header = '▸', + due = '·', + recur = '↺', category = '#', }, } @@ -22,7 +22,13 @@ local today = os.date('%Y-%m-%d') local yesterday = os.date('%Y-%m-%d', os.time() - 86400) local tomorrow = os.date('%Y-%m-%d', os.time() + 86400) -store.add({ description = 'Finish quarterly report', category = 'Work', due = tomorrow, recur = 'monthly', priority = 1 }) +store.add({ + description = 'Finish quarterly report', + category = 'Work', + due = tomorrow, + recur = 'monthly', + priority = 1, +}) store.add({ description = 'Review pull requests', category = 'Work' }) store.add({ description = 'Update deployment docs', category = 'Work', status = 'done' }) store.add({ description = 'Buy groceries', category = 'Personal', due = today }) diff --git a/spec/icons_spec.lua b/spec/icons_spec.lua index 478400f..fe3288f 100644 --- a/spec/icons_spec.lua +++ b/spec/icons_spec.lua @@ -37,12 +37,12 @@ describe('icons', function() it('allows overriding all icons', function() vim.g.pending = { icons = { - pending = '-', - done = 'x', + pending = '-', + done = 'x', priority = '!', - header = '>', - due = '@', - recur = '~', + header = '>', + due = '@', + recur = '~', category = '+', }, } From d672d68c60a2a23be72b868b3bbe576910fedcf0 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Thu, 26 Feb 2026 19:30:49 -0500 Subject: [PATCH 21/21] docs(readme): remove icons section, covered in vimdoc --- README.md | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/README.md b/README.md index f6add96..b740f8f 100644 --- a/README.md +++ b/README.md @@ -24,21 +24,6 @@ luarocks install pending.nvim :help pending.nvim ``` -## Icons - -pending.nvim renders task status and metadata using configurable icon characters. The defaults use plain unicode (no nerd font required): - -```lua -vim.g.pending = { - icons = { - pending = '○', done = '✓', priority = '●', - header = '▸', due = '·', recur = '↺', category = '#', - }, -} -``` - -See `:help pending.Icons` for nerd font examples. - ## Acknowledgements - [dooing](https://github.com/atiladefreitas/dooing)