From fa1103ad4e089804ec3dd27e91a3d93378464187 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 25 Feb 2026 09:37:49 -0500 Subject: [PATCH 01/66] 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/66] 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/66] 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/66] 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/66] 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/66] 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/66] 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/66] 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/66] 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/66] 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/66] 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/66] 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/66] 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/66] 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/66] 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 64b19360b1d2954ef76c8c9ce97dba152dca4796 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 26 Feb 2026 19:20:29 -0500 Subject: [PATCH 16/66] feat(customization): icons config, PendingTab, and demo infrastructure (#46) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(config): add icons table with unicode defaults * feat(buffer): render icon overlays from config.icons 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. * feat(plugin): add PendingTab command and (pending-tab) * 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. * ci: format --- README.md | 17 +++++++++++- assets/.gitkeep | 0 doc/pending.txt | 52 +++++++++++++++++++++++++++++++++++++ lua/pending/buffer.lua | 28 +++++++++++++++++--- lua/pending/config.lua | 19 ++++++++++++++ plugin/pending.lua | 10 +++++++ scripts/demo-init.lua | 39 ++++++++++++++++++++++++++++ scripts/demo.tape | 28 ++++++++++++++++++++ spec/icons_spec.lua | 59 ++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 248 insertions(+), 4 deletions(-) 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/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 diff --git a/lua/pending/config.lua b/lua/pending/config.lua index 000ac2b..6adf1c3 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? 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, {}) diff --git a/scripts/demo-init.lua b/scripts/demo-init.lua new file mode 100644 index 0000000..f2a6213 --- /dev/null +++ b/scripts/demo-init.lua @@ -0,0 +1,39 @@ +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..fe3288f --- /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 0e0568769d1967a5cee9a2cde0f4c9d50f100464 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 26 Feb 2026 20:03:42 -0500 Subject: [PATCH 17/66] refactor: organize tests and dry (#49) * refactor(store): convert singleton to Store.new() factory Problem: store.lua used module-level _data singleton, making project-local stores impossible and creating hidden global state. Solution: introduce Store metatable with all operations as instance methods. M.new(path) constructs an instance; M.resolve_path() searches upward for .pending.json and falls back to config.get().data_path. Singleton module API is removed. * refactor(diff): accept store instance as parameter Problem: diff.apply called store singleton methods directly, coupling it to global state and preventing use with project-local stores. Solution: change signature to apply(lines, s, hidden_ids?) where s is a pending.Store instance. All store operations now go through s. * refactor(buffer): add set_store/store accessors, drop singleton dep Problem: buffer.lua imported store directly and called singleton methods, preventing it from working with per-project store instances. Solution: add module-level _store, M.set_store(s), and M.store() accessors. open() and render() use _store instead of the singleton. init.lua will call buffer.set_store(s) before buffer.open(). * refactor(complete,health,sync,plugin): update callers to store instance API Problem: complete.lua, health.lua, sync/gcal.lua, and plugin/pending.lua all called singleton store methods directly. Solution: complete.lua uses buffer.store() for category lookups; health.lua uses store.new(store.resolve_path()) and reports the resolved path; gcal.lua calls require('pending').store() for task access; plugin tab-completion creates ephemeral store instances via store.new(store.resolve_path()). Add 'init' to the subcommands list. * feat(init): thread Store instance through init, add :Pending init Problem: init.lua called singleton store methods throughout, and there was no way to create a project-local .pending.json file. Solution: add module-level _store and private get_store() that lazy-constructs via store.new(store.resolve_path()). Add public M.store() accessor used by specs and sync backends. M.open() calls buffer.set_store(get_store()) before buffer.open(). All store callsites converted to get_store():method(). goto_file() and add_here() derive the data directory from get_store().path. Add M.init() which creates .pending.json in cwd and dispatches from M.command() as ':Pending init'. * test: update all specs for Store instance API Problem: every spec used the old singleton API (store.unload(), store.load(), store.add(), etc.) and diff.apply(lines, hidden). Solution: lower-level specs (store, diff, views, complete, file) use s = store.new(path); s:load() directly. Higher-level specs (archive, edit, filter, status, sync) reset package.loaded['pending'] in before_each and use pending.store() to access the live instance. diff.apply calls updated to diff.apply(lines, s, hidden_ids). * docs(pending): document :Pending init and store resolution Add *pending-store-resolution* section explaining upward .pending.json discovery and fallback to the global data_path. Document :Pending init under COMMANDS. Add a cross-reference from the data_path config field. * ci: format * ci: remove unused variable --- doc/pending.txt | 34 +++++++- lua/pending/buffer.lua | 21 ++++- lua/pending/complete.lua | 7 +- lua/pending/diff.lua | 12 +-- lua/pending/health.lua | 53 +++++++----- lua/pending/init.lua | 123 ++++++++++++++++++--------- lua/pending/store.lua | 122 ++++++++++++++++----------- lua/pending/sync/gcal.lua | 5 +- plugin/pending.lua | 17 ++-- spec/archive_spec.lua | 90 +++++++++++--------- spec/complete_spec.lua | 20 +++-- spec/diff_spec.lua | 152 +++++++++++++++------------------ spec/edit_spec.lua | 173 ++++++++++++++++++++++---------------- spec/file_spec.lua | 109 +++++++++++++----------- spec/filter_spec.lua | 143 +++++++++++++++---------------- spec/status_spec.lua | 122 +++++++++++++-------------- spec/store_spec.lua | 147 ++++++++++++++------------------ spec/sync_spec.lua | 3 - spec/views_spec.lua | 169 ++++++++++++++++++------------------- 19 files changed, 819 insertions(+), 703 deletions(-) diff --git a/doc/pending.txt b/doc/pending.txt index 486ea32..fc04dc4 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -356,6 +356,13 @@ COMMANDS *pending-commands* Equivalent to the `U` buffer-local key (see |pending-mappings|). Up to 20 levels of undo are persisted across sessions. + *:Pending-init* +:Pending init + Create a project-local `.pending.json` file in the current working + directory. After creation, `:Pending` will use this file instead of the + global store (see |pending-store-resolution|). Errors if `.pending.json` + already exists in the current directory. + *:PendingTab* :PendingTab Open the task buffer in a new tab. @@ -614,9 +621,11 @@ All fields are optional. Unset fields use the defaults shown above. *pending.Config* Fields: ~ {data_path} (string) - Path to the JSON file where tasks are stored. + Path to the global JSON file where tasks are stored. Default: `stdpath('data') .. '/pending/tasks.json'`. The directory is created automatically on first save. + See |pending-store-resolution| for how the active + store is chosen at runtime. {default_view} ('category'|'priority', default: 'category') The view to use when the buffer is opened for the @@ -1060,10 +1069,31 @@ Checks performed: ~ - 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`) +============================================================================== +STORE RESOLUTION *pending-store-resolution* + +When pending.nvim opens the task buffer it resolves which store file to use: + +1. Search upward from `vim.fn.getcwd()` for a file named `.pending.json`. +2. If found, use that file as the active store (project-local store). +3. If not found, fall back to `data_path` from |pending-config| (global + store). + +This means placing a `.pending.json` file in a project root makes that +project use an isolated task list. Tasks in the project store are completely +separate from tasks in the global store; there is no aggregation. + +To create a project-local store in the current directory: >vim + :Pending init +< + +The `:checkhealth pending` report shows which store file is currently active. + ============================================================================== DATA FORMAT *pending-data* -Tasks are stored as JSON at `data_path`. The file is safe to edit by hand and +Tasks are stored as JSON at the active store path (see +|pending-store-resolution|). The file is safe to edit by hand and is forward-compatible — unknown fields are preserved on every read/write cycle via the `_extra` table. diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index 09412f3..e9d7318 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -1,10 +1,12 @@ local config = require('pending.config') -local store = require('pending.store') local views = require('pending.views') ---@class pending.buffer local M = {} +---@type pending.Store? +local _store = nil + ---@type integer? local task_bufnr = nil ---@type integer? @@ -41,6 +43,17 @@ function M.current_view_name() return current_view end +---@param s pending.Store? +---@return nil +function M.set_store(s) + _store = s +end + +---@return pending.Store? +function M.store() + return _store +end + ---@return string[] function M.filter_predicates() return _filter_predicates @@ -281,7 +294,7 @@ 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 all_tasks = store.active_tasks() + local all_tasks = _store and _store:active_tasks() or {} local tasks = {} for _, task in ipairs(all_tasks) do if not _hidden_ids[task.id] then @@ -341,7 +354,9 @@ end ---@return integer bufnr function M.open() setup_highlights() - store.load() + if _store then + _store:load() + end if task_winid and vim.api.nvim_win_is_valid(task_winid) then vim.api.nvim_set_current_win(task_winid) diff --git a/lua/pending/complete.lua b/lua/pending/complete.lua index 6c2b964..ceeecc9 100644 --- a/lua/pending/complete.lua +++ b/lua/pending/complete.lua @@ -15,10 +15,13 @@ end ---@return string[] local function get_categories() - local store = require('pending.store') + local s = require('pending.buffer').store() + if not s then + return {} + end local seen = {} local result = {} - for _, task in ipairs(store.active_tasks()) do + for _, task in ipairs(s:active_tasks()) do local cat = task.category if cat and not seen[cat] then seen[cat] = true diff --git a/lua/pending/diff.lua b/lua/pending/diff.lua index c731d95..b507179 100644 --- a/lua/pending/diff.lua +++ b/lua/pending/diff.lua @@ -1,6 +1,5 @@ local config = require('pending.config') local parse = require('pending.parse') -local store = require('pending.store') ---@class pending.ParsedEntry ---@field type 'task'|'header'|'blank' @@ -72,12 +71,13 @@ function M.parse_buffer(lines) end ---@param lines string[] +---@param s pending.Store ---@param hidden_ids? table ---@return nil -function M.apply(lines, hidden_ids) +function M.apply(lines, s, hidden_ids) local parsed = M.parse_buffer(lines) local now = timestamp() - local data = store.data() + local data = s:data() local old_by_id = {} for _, task in ipairs(data.tasks) do @@ -98,7 +98,7 @@ function M.apply(lines, hidden_ids) if entry.id and old_by_id[entry.id] then if seen_ids[entry.id] then - store.add({ + s:add({ description = entry.description, category = entry.category, priority = entry.priority, @@ -166,7 +166,7 @@ function M.apply(lines, hidden_ids) end end else - store.add({ + s:add({ description = entry.description, category = entry.category, priority = entry.priority, @@ -188,7 +188,7 @@ function M.apply(lines, hidden_ids) end end - store.save() + s:save() end return M diff --git a/lua/pending/health.lua b/lua/pending/health.lua index 93f7c72..ca28298 100644 --- a/lua/pending/health.lua +++ b/lua/pending/health.lua @@ -12,36 +12,47 @@ function M.check() local cfg = config.get() vim.health.ok('Config loaded') - vim.health.info('Data path: ' .. cfg.data_path) - local data_dir = vim.fn.fnamemodify(cfg.data_path, ':h') + local store_ok, store = pcall(require, 'pending.store') + if not store_ok then + vim.health.error('Failed to load pending.store') + return + end + + local resolved_path = store.resolve_path() + vim.health.info('Store path: ' .. resolved_path) + if resolved_path ~= cfg.data_path then + vim.health.info('(project-local store; global path: ' .. cfg.data_path .. ')') + end + + local data_dir = vim.fn.fnamemodify(resolved_path, ':h') if vim.fn.isdirectory(data_dir) == 1 then vim.health.ok('Data directory exists: ' .. data_dir) else vim.health.warn('Data directory does not exist yet: ' .. data_dir) end - if vim.fn.filereadable(cfg.data_path) == 1 then - local store_ok, store = pcall(require, 'pending.store') - if store_ok then - local load_ok, err = pcall(store.load) - 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 + if vim.fn.filereadable(resolved_path) == 1 then + local s = store.new(resolved_path) + local load_ok, err = pcall(function() + s:load() + end) + if load_ok then + local tasks = s: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 - 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 + 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 else vim.health.info('No data file yet (will be created on first save)') diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 73b3051..5205182 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -1,5 +1,4 @@ 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') @@ -19,6 +18,22 @@ local UNDO_MAX = 20 ---@type pending.Counts? local _counts = nil +---@type pending.Store? +local _store = nil + +---@return pending.Store +local function get_store() + if not _store then + _store = store.new(store.resolve_path()) + end + return _store +end + +---@return pending.Store +function M.store() + return get_store() +end + ---@return nil function M._recompute_counts() local cfg = require('pending.config').get() @@ -30,7 +45,7 @@ function M._recompute_counts() local next_due = nil ---@type string? local today_str = os.date('%Y-%m-%d') --[[@as string]] - for _, task in ipairs(store.active_tasks()) do + for _, task in ipairs(get_store():active_tasks()) do if task.status == 'pending' then pending = pending + 1 if task.priority > 0 then @@ -63,14 +78,14 @@ end ---@return nil local function _save_and_notify() - store.save() + get_store():save() M._recompute_counts() end ---@return pending.Counts function M.counts() if not _counts then - store.load() + get_store():load() M._recompute_counts() end return _counts --[[@as pending.Counts]] @@ -138,6 +153,8 @@ end ---@return integer bufnr function M.open() + local s = get_store() + buffer.set_store(s) local bufnr = buffer.open() M._setup_autocmds(bufnr) M._setup_buf_mappings(bufnr) @@ -159,7 +176,7 @@ function M.filter(pred_str) for word in pred_str:gmatch('%S+') do table.insert(predicates, word) end - local tasks = store.active_tasks() + local tasks = get_store():active_tasks() local hidden = compute_hidden_ids(tasks, predicates) buffer.set_filter(predicates, hidden) local bufnr = buffer.bufnr() @@ -184,7 +201,7 @@ function M._setup_autocmds(bufnr) buffer = bufnr, callback = function() if not vim.bo[bufnr].modified then - store.load() + get_store():load() buffer.render(bufnr) end end, @@ -333,29 +350,31 @@ function M._on_write(bufnr) elseif #buffer.filter_predicates() > 0 then predicates = {} end - local tasks = store.active_tasks() + local s = get_store() + local tasks = s:active_tasks() local hidden = compute_hidden_ids(tasks, predicates) buffer.set_filter(predicates, hidden) - local snapshot = store.snapshot() - local stack = store.undo_stack() + local snapshot = s:snapshot() + local stack = s:undo_stack() table.insert(stack, snapshot) if #stack > UNDO_MAX then table.remove(stack, 1) end - diff.apply(lines, hidden) + diff.apply(lines, s, hidden) M._recompute_counts() buffer.render(bufnr) end ---@return nil function M.undo_write() - local stack = store.undo_stack() + local s = get_store() + local stack = s:undo_stack() if #stack == 0 then vim.notify('Nothing to undo.', vim.log.levels.WARN) return end local state = table.remove(stack) - store.replace_tasks(state) + s:replace_tasks(state) _save_and_notify() buffer.render(buffer.bufnr()) end @@ -375,18 +394,19 @@ function M.toggle_complete() if not id then return end - local task = store.get(id) + local s = get_store() + local task = s:get(id) if not task then return end if task.status == 'done' then - store.update(id, { status = 'pending', ['end'] = vim.NIL }) + s:update(id, { status = 'pending', ['end'] = vim.NIL }) else if task.recur and task.due then local recur = require('pending.recur') local mode = task.recur_mode or 'scheduled' local next_date = recur.next_due(task.due, task.recur, mode) - store.add({ + s:add({ description = task.description, category = task.category, priority = task.priority, @@ -395,7 +415,7 @@ function M.toggle_complete() recur_mode = task.recur_mode, }) end - store.update(id, { status = 'done' }) + s:update(id, { status = 'done' }) end _save_and_notify() buffer.render(bufnr) @@ -422,12 +442,13 @@ function M.toggle_priority() if not id then return end - local task = store.get(id) + local s = get_store() + local task = s:get(id) if not task then return end local new_priority = task.priority > 0 and 0 or 1 - store.update(id, { priority = new_priority }) + s:update(id, { priority = new_priority }) _save_and_notify() buffer.render(bufnr) for lnum, m in ipairs(buffer.meta()) do @@ -470,7 +491,7 @@ function M.prompt_date() return end end - store.update(id, { due = due }) + get_store():update(id, { due = due }) _save_and_notify() buffer.render(bufnr) end) @@ -483,13 +504,14 @@ function M.add(text) vim.notify('Usage: :Pending add ', vim.log.levels.ERROR) return end - store.load() + local s = get_store() + s:load() local description, metadata = parse.command_add(text) if not description or description == '' then vim.notify('Pending must have a description.', vim.log.levels.ERROR) return end - store.add({ + s:add({ description = description, category = metadata.cat, due = metadata.due, @@ -530,12 +552,13 @@ end function M.archive(days) days = days or 30 local cutoff = os.time() - (days * 86400) - local tasks = store.tasks() + local s = get_store() + local tasks = s:tasks() local archived = 0 local kept = {} for _, task in ipairs(tasks) do if (task.status == 'done' or task.status == 'deleted') and task['end'] then - local y, mo, d, h, mi, s = task['end']:match('^(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+)Z$') + local y, mo, d, h, mi, sec = task['end']:match('^(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+)Z$') if y then local t = os.time({ year = tonumber(y) --[[@as integer]], @@ -543,7 +566,7 @@ function M.archive(days) day = tonumber(d) --[[@as integer]], hour = tonumber(h) --[[@as integer]], min = tonumber(mi) --[[@as integer]], - sec = tonumber(s) --[[@as integer]], + sec = tonumber(sec) --[[@as integer]], }) if t < cutoff then archived = archived + 1 @@ -554,7 +577,7 @@ function M.archive(days) table.insert(kept, task) ::skip:: end - store.replace_tasks(kept) + s:replace_tasks(kept) _save_and_notify() vim.notify('Archived ' .. archived .. ' tasks.') local bufnr = buffer.bufnr() @@ -578,7 +601,7 @@ function M.due() and m.status ~= 'done' and (parse.is_overdue(m.raw_due) or parse.is_today(m.raw_due)) then - local task = store.get(m.id or 0) + local task = get_store():get(m.id or 0) local label = parse.is_overdue(m.raw_due) and '[OVERDUE] ' or '[DUE] ' table.insert(qf_items, { bufnr = bufnr, @@ -589,8 +612,9 @@ function M.due() end end else - store.load() - for _, task in ipairs(store.active_tasks()) do + local s = get_store() + s:load() + for _, task in ipairs(s:active_tasks()) do if task.status == 'pending' and task.due @@ -712,8 +736,9 @@ function M.edit(id_str, rest) return end - store.load() - local task = store.get(id) + local s = get_store() + s:load() + local task = s:get(id) if not task then vim.notify('No task with ID ' .. id .. '.', vim.log.levels.ERROR) return @@ -776,17 +801,17 @@ function M.edit(id_str, rest) end end - local snapshot = store.snapshot() - local stack = store.undo_stack() + local snapshot = s:snapshot() + local stack = s:undo_stack() table.insert(stack, snapshot) if #stack > UNDO_MAX then table.remove(stack, 1) end - store.update(id, updates) + s:update(id, updates) if updates.file_clear then - local t = store.get(id) + local t = s:get(id) if t and t._extra then t._extra.file = nil if next(t._extra) == nil then @@ -796,7 +821,7 @@ function M.edit(id_str, rest) end end - store.save() + s:save() local bufnr = buffer.bufnr() if bufnr and vim.api.nvim_buf_is_valid(bufnr) then @@ -819,7 +844,7 @@ function M.goto_file() vim.notify('No task on this line', vim.log.levels.WARN) return end - local task = store.get(m.id) + local task = get_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 @@ -830,7 +855,7 @@ function M.goto_file() 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 data_dir = vim.fn.fnamemodify(get_store().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) @@ -854,7 +879,8 @@ function M.add_here() 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 s = get_store() + local data_dir = vim.fn.fnamemodify(s.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 @@ -863,8 +889,8 @@ function M.add_here() rel_file = abs_file end local file_spec = rel_file .. ':' .. cur_lnum - store.load() - local tasks = store.active_tasks() + s:load() + local tasks = s:active_tasks() if #tasks == 0 then vim.notify('No active tasks', vim.log.levels.INFO) return @@ -885,11 +911,24 @@ function M.add_here() task._extra = task._extra or {} task._extra.file = file_spec task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]] - store.save() + s:save() vim.notify('Attached ' .. file_spec .. ' to task ' .. task.id) end) end +---@return nil +function M.init() + local path = vim.fn.getcwd() .. '/.pending.json' + if vim.fn.filereadable(path) == 1 then + vim.notify('pending.nvim: .pending.json already exists', vim.log.levels.WARN) + return + end + local s = store.new(path) + s:load() + s:save() + vim.notify('pending.nvim: created ' .. path) +end + ---@param args string ---@return nil function M.command(args) @@ -915,6 +954,8 @@ function M.command(args) M.filter(rest) elseif cmd == 'undo' then M.undo_write() + elseif cmd == 'init' then + M.init() else vim.notify('Unknown Pending subcommand: ' .. cmd, vim.log.levels.ERROR) end diff --git a/lua/pending/store.lua b/lua/pending/store.lua index b9a4e38..5a5b370 100644 --- a/lua/pending/store.lua +++ b/lua/pending/store.lua @@ -21,14 +21,17 @@ local config = require('pending.config') ---@field tasks pending.Task[] ---@field undo pending.Task[][] +---@class pending.Store +---@field path string +---@field _data pending.Data? +local Store = {} +Store.__index = Store + ---@class pending.store local M = {} local SUPPORTED_VERSION = 1 ----@type pending.Data? -local _data = nil - ---@return pending.Data local function empty_data() return { @@ -137,18 +140,18 @@ local function table_to_task(t) end ---@return pending.Data -function M.load() - local path = config.get().data_path +function Store:load() + local path = self.path local f = io.open(path, 'r') if not f then - _data = empty_data() - return _data + self._data = empty_data() + return self._data end local content = f:read('*a') f:close() if content == '' then - _data = empty_data() - return _data + self._data = empty_data() + return self._data end local ok, decoded = pcall(vim.json.decode, content) if not ok then @@ -163,14 +166,14 @@ function M.load() .. '. Please update the plugin.' ) end - _data = { + self._data = { 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)) + table.insert(self._data.tasks, table_to_task(t)) end for _, snapshot in ipairs(decoded.undo or {}) do if type(snapshot) == 'table' then @@ -178,29 +181,29 @@ function M.load() for _, raw in ipairs(snapshot) do table.insert(tasks, table_to_task(raw)) end - table.insert(_data.undo, tasks) + table.insert(self._data.undo, tasks) end end - return _data + return self._data end ---@return nil -function M.save() - if not _data then +function Store:save() + if not self._data then return end - local path = config.get().data_path + local path = self.path ensure_dir(path) local out = { - version = _data.version, - next_id = _data.next_id, + version = self._data.version, + next_id = self._data.next_id, tasks = {}, undo = {}, } - for _, task in ipairs(_data.tasks) do + for _, task in ipairs(self._data.tasks) do table.insert(out.tasks, task_to_table(task)) end - for _, snapshot in ipairs(_data.undo) do + for _, snapshot in ipairs(self._data.undo) do local serialized = {} for _, task in ipairs(snapshot) do table.insert(serialized, task_to_table(task)) @@ -223,22 +226,22 @@ function M.save() end ---@return pending.Data -function M.data() - if not _data then - M.load() +function Store:data() + if not self._data then + self:load() end - return _data --[[@as pending.Data]] + return self._data --[[@as pending.Data]] end ---@return pending.Task[] -function M.tasks() - return M.data().tasks +function Store:tasks() + return self:data().tasks end ---@return pending.Task[] -function M.active_tasks() +function Store:active_tasks() local result = {} - for _, task in ipairs(M.tasks()) do + for _, task in ipairs(self:tasks()) do if task.status ~= 'deleted' then table.insert(result, task) end @@ -248,8 +251,8 @@ end ---@param id integer ---@return pending.Task? -function M.get(id) - for _, task in ipairs(M.tasks()) do +function Store:get(id) + for _, task in ipairs(self:tasks()) do if task.id == id then return task end @@ -259,8 +262,8 @@ end ---@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() +function Store:add(fields) + local data = self:data() local now = timestamp() local task = { id = data.next_id, @@ -285,8 +288,8 @@ end ---@param id integer ---@param fields table ---@return pending.Task? -function M.update(id, fields) - local task = M.get(id) +function Store:update(id, fields) + local task = self:get(id) if not task then return nil end @@ -309,14 +312,14 @@ end ---@param id integer ---@return pending.Task? -function M.delete(id) - return M.update(id, { status = 'deleted', ['end'] = timestamp() }) +function Store:delete(id) + return self:update(id, { status = 'deleted', ['end'] = timestamp() }) end ---@param id integer ---@return integer? -function M.find_index(id) - for i, task in ipairs(M.tasks()) do +function Store:find_index(id) + for i, task in ipairs(self:tasks()) do if task.id == id then return i end @@ -326,14 +329,14 @@ end ---@param tasks pending.Task[] ---@return nil -function M.replace_tasks(tasks) - M.data().tasks = tasks +function Store:replace_tasks(tasks) + self:data().tasks = tasks end ---@return pending.Task[] -function M.snapshot() +function Store:snapshot() local result = {} - for _, task in ipairs(M.active_tasks()) do + for _, task in ipairs(self:active_tasks()) do local copy = {} for k, v in pairs(task) do if k ~= '_extra' then @@ -352,25 +355,44 @@ function M.snapshot() end ---@return pending.Task[][] -function M.undo_stack() - return M.data().undo +function Store:undo_stack() + return self:data().undo end ---@param stack pending.Task[][] ---@return nil -function M.set_undo_stack(stack) - M.data().undo = stack +function Store:set_undo_stack(stack) + self:data().undo = stack end ---@param id integer ---@return nil -function M.set_next_id(id) - M.data().next_id = id +function Store:set_next_id(id) + self:data().next_id = id end ---@return nil -function M.unload() - _data = nil +function Store:unload() + self._data = nil +end + +---@param path string +---@return pending.Store +function M.new(path) + return setmetatable({ path = path, _data = nil }, Store) +end + +---@return string +function M.resolve_path() + local results = vim.fs.find('.pending.json', { + upward = true, + path = vim.fn.getcwd(), + type = 'file', + }) + if results and #results > 0 then + return results[1] + end + return config.get().data_path end return M diff --git a/lua/pending/sync/gcal.lua b/lua/pending/sync/gcal.lua index 843f310..a2d9992 100644 --- a/lua/pending/sync/gcal.lua +++ b/lua/pending/sync/gcal.lua @@ -1,5 +1,4 @@ local config = require('pending.config') -local store = require('pending.store') local M = {} @@ -458,7 +457,7 @@ function M.sync() return end - local tasks = store.tasks() + local tasks = require('pending').store():tasks() local created, updated, deleted = 0, 0, 0 for _, task in ipairs(tasks) do @@ -504,7 +503,7 @@ function M.sync() end end - store.save() + require('pending').store():save() require('pending')._recompute_counts() vim.notify( string.format( diff --git a/plugin/pending.lua b/plugin/pending.lua index ce62d1b..4814f50 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -100,9 +100,10 @@ local function complete_edit(arg_lead, cmd_line) 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 s = store.new(store.resolve_path()) + s:load() local ids = {} - for _, task in ipairs(store.active_tasks()) do + for _, task in ipairs(s:active_tasks()) do table.insert(ids, tostring(task.id)) end return filter_candidates(arg_lead, ids) @@ -138,10 +139,11 @@ local function complete_edit(arg_lead, cmd_line) if cat_prefix then local after_colon = arg_lead:sub(#cat_prefix + 1) local store = require('pending.store') - store.load() + local s = store.new(store.resolve_path()) + s:load() local seen = {} local cats = {} - for _, task in ipairs(store.active_tasks()) do + for _, task in ipairs(s:active_tasks()) do if task.category and not seen[task.category] then seen[task.category] = true table.insert(cats, task.category) @@ -166,7 +168,7 @@ end, { bar = true, nargs = '*', complete = function(arg_lead, cmd_line) - local subcmds = { 'add', 'archive', 'due', 'edit', 'filter', 'sync', 'undo' } + local subcmds = { 'add', 'archive', 'due', 'edit', 'filter', 'init', 'sync', 'undo' } if not cmd_line:match('^Pending%s+%S') then return filter_candidates(arg_lead, subcmds) end @@ -178,9 +180,10 @@ end, { end local candidates = { 'clear', 'overdue', 'today', 'priority' } local store = require('pending.store') - store.load() + local s = store.new(store.resolve_path()) + s:load() local seen = {} - for _, task in ipairs(store.active_tasks()) do + for _, task in ipairs(s:active_tasks()) do if task.category and not seen[task.category] then seen[task.category] = true table.insert(candidates, 'cat:' .. task.category) diff --git a/spec/archive_spec.lua b/spec/archive_spec.lua index df1a912..e7046fa 100644 --- a/spec/archive_spec.lua +++ b/spec/archive_spec.lua @@ -1,87 +1,96 @@ require('spec.helpers') local config = require('pending.config') -local store = require('pending.store') describe('archive', function() local tmpdir - local pending = require('pending') + 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() - store.load() + package.loaded['pending'] = nil + pending = require('pending') + pending.store():load() end) after_each(function() vim.fn.delete(tmpdir, 'rf') vim.g.pending = nil config.reset() + package.loaded['pending'] = nil end) it('removes done tasks completed more than 30 days ago', function() - local t = store.add({ description = 'Old done task' }) - store.update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' }) + local s = pending.store() + local t = s:add({ description = 'Old done task' }) + s:update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' }) pending.archive() - assert.are.equal(0, #store.active_tasks()) + assert.are.equal(0, #s:active_tasks()) end) it('keeps done tasks completed fewer than 30 days ago', function() + local s = pending.store() local recent_end = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (5 * 86400)) - local t = store.add({ description = 'Recent done task' }) - store.update(t.id, { status = 'done', ['end'] = recent_end }) + local t = s:add({ description = 'Recent done task' }) + s:update(t.id, { status = 'done', ['end'] = recent_end }) pending.archive() - local active = store.active_tasks() + local active = s:active_tasks() assert.are.equal(1, #active) assert.are.equal('Recent done task', active[1].description) end) it('respects a custom day count', function() + local s = pending.store() local eight_days_ago = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (8 * 86400)) - local t = store.add({ description = 'Old for 7 days' }) - store.update(t.id, { status = 'done', ['end'] = eight_days_ago }) + local t = s:add({ description = 'Old for 7 days' }) + s:update(t.id, { status = 'done', ['end'] = eight_days_ago }) pending.archive(7) - assert.are.equal(0, #store.active_tasks()) + assert.are.equal(0, #s:active_tasks()) end) it('keeps tasks within the custom day cutoff', function() + local s = pending.store() local five_days_ago = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (5 * 86400)) - local t = store.add({ description = 'Recent for 7 days' }) - store.update(t.id, { status = 'done', ['end'] = five_days_ago }) + local t = s:add({ description = 'Recent for 7 days' }) + s:update(t.id, { status = 'done', ['end'] = five_days_ago }) pending.archive(7) - local active = store.active_tasks() + local active = s:active_tasks() assert.are.equal(1, #active) end) it('never archives pending tasks regardless of age', function() - store.add({ description = 'Still pending' }) + local s = pending.store() + s:add({ description = 'Still pending' }) pending.archive() - local active = store.active_tasks() + local active = s:active_tasks() assert.are.equal(1, #active) assert.are.equal('pending', active[1].status) end) it('removes deleted tasks past the cutoff', function() - local t = store.add({ description = 'Old deleted task' }) - store.update(t.id, { status = 'deleted', ['end'] = '2020-01-01T00:00:00Z' }) + local s = pending.store() + local t = s:add({ description = 'Old deleted task' }) + s:update(t.id, { status = 'deleted', ['end'] = '2020-01-01T00:00:00Z' }) pending.archive() - local all = store.tasks() + local all = s:tasks() assert.are.equal(0, #all) end) it('keeps deleted tasks within the cutoff', function() + local s = pending.store() local recent_end = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (5 * 86400)) - local t = store.add({ description = 'Recent deleted' }) - store.update(t.id, { status = 'deleted', ['end'] = recent_end }) + local t = s:add({ description = 'Recent deleted' }) + s:update(t.id, { status = 'deleted', ['end'] = recent_end }) pending.archive() - local all = store.tasks() + local all = s:tasks() assert.are.equal(1, #all) end) it('reports the correct count in vim.notify', function() + local s = pending.store() local messages = {} local orig_notify = vim.notify vim.notify = function(msg, ...) @@ -89,11 +98,11 @@ describe('archive', function() return orig_notify(msg, ...) end - local t1 = store.add({ description = 'Old 1' }) - local t2 = store.add({ description = 'Old 2' }) - store.add({ description = 'Keep' }) - store.update(t1.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' }) - store.update(t2.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' }) + local t1 = s:add({ description = 'Old 1' }) + local t2 = s:add({ description = 'Old 2' }) + s:add({ description = 'Keep' }) + s:update(t1.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' }) + s:update(t2.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' }) pending.archive() @@ -110,16 +119,17 @@ describe('archive', function() end) it('leaves only kept tasks in store.active_tasks after archive', function() - local t1 = store.add({ description = 'Old done' }) - store.add({ description = 'Keep pending' }) + local s = pending.store() + local t1 = s:add({ description = 'Old done' }) + s:add({ description = 'Keep pending' }) local recent_end = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (5 * 86400)) - local t3 = store.add({ description = 'Keep recent done' }) - store.update(t1.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' }) - store.update(t3.id, { status = 'done', ['end'] = recent_end }) + local t3 = s:add({ description = 'Keep recent done' }) + s:update(t1.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' }) + s:update(t3.id, { status = 'done', ['end'] = recent_end }) pending.archive() - local active = store.active_tasks() + local active = s:active_tasks() assert.are.equal(2, #active) local descs = {} for _, task in ipairs(active) do @@ -130,11 +140,11 @@ describe('archive', function() end) it('persists archived tasks to disk after unload/reload', function() - local t = store.add({ description = 'Archived task' }) - store.update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' }) + local s = pending.store() + local t = s:add({ description = 'Archived task' }) + s:update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' }) pending.archive() - store.unload() - store.load() - assert.are.equal(0, #store.active_tasks()) + s:load() + assert.are.equal(0, #s:active_tasks()) end) end) diff --git a/spec/complete_spec.lua b/spec/complete_spec.lua index 7b45e5b..98547e8 100644 --- a/spec/complete_spec.lua +++ b/spec/complete_spec.lua @@ -1,25 +1,27 @@ require('spec.helpers') +local buffer = require('pending.buffer') local config = require('pending.config') local store = require('pending.store') describe('complete', function() local tmpdir + local s 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() + s = store.new(tmpdir .. '/tasks.json') + s:load() + buffer.set_store(s) end) after_each(function() vim.fn.delete(tmpdir, 'rf') - vim.g.pending = nil config.reset() + buffer.set_store(nil) end) describe('findstart', function() @@ -66,9 +68,9 @@ describe('complete', function() 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' }) + s:add({ description = 'A', category = 'Work' }) + s:add({ description = 'B', category = 'Home' }) + s: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) @@ -85,8 +87,8 @@ describe('complete', function() end) it('filters categories by base', function() - store.add({ description = 'A', category = 'Work' }) - store.add({ description = 'B', category = 'Home' }) + s:add({ description = 'A', category = 'Work' }) + s: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) diff --git a/spec/diff_spec.lua b/spec/diff_spec.lua index d8e25c2..2322ded 100644 --- a/spec/diff_spec.lua +++ b/spec/diff_spec.lua @@ -1,25 +1,21 @@ require('spec.helpers') -local config = require('pending.config') local store = require('pending.store') describe('diff', function() local tmpdir + local s local diff = require('pending.diff') 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() + s = store.new(tmpdir .. '/tasks.json') + s:load() end) after_each(function() vim.fn.delete(tmpdir, 'rf') - vim.g.pending = nil - config.reset() end) describe('parse_buffer', function() @@ -107,121 +103,112 @@ describe('diff', function() '- [ ] First task', '- [ ] Second task', } - diff.apply(lines) - store.unload() - store.load() - local tasks = store.active_tasks() + diff.apply(lines, s) + s:load() + local tasks = s:active_tasks() assert.are.equal(2, #tasks) assert.are.equal('First task', tasks[1].description) assert.are.equal('Second task', tasks[2].description) end) it('deletes tasks removed from buffer', function() - store.add({ description = 'Keep me' }) - store.add({ description = 'Delete me' }) - store.save() + s:add({ description = 'Keep me' }) + s:add({ description = 'Delete me' }) + s:save() local lines = { '## Inbox', '/1/- [ ] Keep me', } - diff.apply(lines) - store.unload() - store.load() - local active = store.active_tasks() + diff.apply(lines, s) + s:load() + local active = s:active_tasks() assert.are.equal(1, #active) assert.are.equal('Keep me', active[1].description) - local deleted = store.get(2) + local deleted = s:get(2) assert.are.equal('deleted', deleted.status) end) it('updates modified tasks', function() - store.add({ description = 'Original' }) - store.save() + s:add({ description = 'Original' }) + s:save() local lines = { '## Inbox', '/1/- [ ] Renamed', } - diff.apply(lines) - store.unload() - store.load() - local task = store.get(1) + diff.apply(lines, s) + s:load() + local task = s:get(1) assert.are.equal('Renamed', task.description) end) it('updates modified when description is renamed', function() - local t = store.add({ description = 'Original', category = 'Inbox' }) + local t = s:add({ description = 'Original', category = 'Inbox' }) t.modified = '2020-01-01T00:00:00Z' - store.save() + s:save() local lines = { '## Inbox', '/1/- [ ] Renamed', } - diff.apply(lines) - store.unload() - store.load() - local task = store.get(1) + diff.apply(lines, s) + s:load() + local task = s:get(1) assert.are.equal('Renamed', task.description) assert.is_not.equal('2020-01-01T00:00:00Z', task.modified) end) it('handles duplicate ids as copies', function() - store.add({ description = 'Original' }) - store.save() + s:add({ description = 'Original' }) + s:save() local lines = { '## Inbox', '/1/- [ ] Original', '/1/- [ ] Copy of original', } - diff.apply(lines) - store.unload() - store.load() - local tasks = store.active_tasks() + diff.apply(lines, s) + s:load() + local tasks = s:active_tasks() assert.are.equal(2, #tasks) end) it('moves tasks between categories', function() - store.add({ description = 'Moving task', category = 'Inbox' }) - store.save() + s:add({ description = 'Moving task', category = 'Inbox' }) + s:save() local lines = { '## Work', '/1/- [ ] Moving task', } - diff.apply(lines) - store.unload() - store.load() - local task = store.get(1) + diff.apply(lines, s) + s:load() + local task = s:get(1) assert.are.equal('Work', task.category) end) it('does not update modified when task is unchanged', function() - store.add({ description = 'Stable task', category = 'Inbox' }) - store.save() + s:add({ description = 'Stable task', category = 'Inbox' }) + s:save() local lines = { '## Inbox', '/1/- [ ] Stable task', } - diff.apply(lines) - store.unload() - store.load() - local modified_after_first = store.get(1).modified - diff.apply(lines) - store.unload() - store.load() - local task = store.get(1) + diff.apply(lines, s) + s:load() + local modified_after_first = s:get(1).modified + diff.apply(lines, s) + s:load() + local task = s:get(1) assert.are.equal(modified_after_first, task.modified) end) it('clears due when removed from buffer line', function() - store.add({ description = 'Pay bill', due = '2026-03-15' }) - store.save() + s:add({ description = 'Pay bill', due = '2026-03-15' }) + s:save() local lines = { '## Inbox', '/1/- [ ] Pay bill', } - diff.apply(lines) - store.unload() - store.load() - local task = store.get(1) + diff.apply(lines, s) + s:load() + local task = s:get(1) assert.is_nil(task.due) end) @@ -230,39 +217,36 @@ describe('diff', function() '## Inbox', '- [ ] Take out trash rec:weekly', } - diff.apply(lines) - store.unload() - store.load() - local tasks = store.active_tasks() + diff.apply(lines, s) + s:load() + local tasks = s: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() + s:add({ description = 'Task', recur = 'daily' }) + s:save() local lines = { '## Todo', '/1/- [ ] Task rec:weekly', } - diff.apply(lines) - store.unload() - store.load() - local task = store.get(1) + diff.apply(lines, s) + s:load() + local task = s: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() + s:add({ description = 'Task', recur = 'daily' }) + s:save() local lines = { '## Todo', '/1/- [ ] Task', } - diff.apply(lines) - store.unload() - store.load() - local task = store.get(1) + diff.apply(lines, s) + s:load() + local task = s:get(1) assert.is_nil(task.recur) end) @@ -271,25 +255,23 @@ describe('diff', function() '## Inbox', '- [ ] Water plants rec:!weekly', } - diff.apply(lines) - store.unload() - store.load() - local tasks = store.active_tasks() + diff.apply(lines, s) + s:load() + local tasks = s: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() + s:add({ description = 'Task name', priority = 1 }) + s:save() local lines = { '## Inbox', '/1/- [ ] Task name', } - diff.apply(lines) - store.unload() - store.load() - local task = store.get(1) + diff.apply(lines, s) + s:load() + local task = s:get(1) assert.are.equal(0, task.priority) end) end) diff --git a/spec/edit_spec.lua b/spec/edit_spec.lua index ba9f98e..08ef9e0 100644 --- a/spec/edit_spec.lua +++ b/spec/edit_spec.lua @@ -1,32 +1,34 @@ require('spec.helpers') local config = require('pending.config') -local store = require('pending.store') describe('edit', function() local tmpdir - local pending = require('pending') + 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() - store.load() + package.loaded['pending'] = nil + pending = require('pending') + pending.store():load() end) after_each(function() vim.fn.delete(tmpdir, 'rf') vim.g.pending = nil config.reset() + package.loaded['pending'] = nil end) it('sets due date with resolve_date vocabulary', function() - local t = store.add({ description = 'Task one' }) - store.save() + local s = pending.store() + local t = s:add({ description = 'Task one' }) + s:save() pending.edit(tostring(t.id), 'due:tomorrow') - local updated = store.get(t.id) + local updated = s: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 })) @@ -34,111 +36,123 @@ describe('edit', function() end) it('sets due date with literal YYYY-MM-DD', function() - local t = store.add({ description = 'Task one' }) - store.save() + local s = pending.store() + local t = s:add({ description = 'Task one' }) + s:save() pending.edit(tostring(t.id), 'due:2026-06-15') - local updated = store.get(t.id) + local updated = s: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() + local s = pending.store() + local t = s:add({ description = 'Task one' }) + s:save() pending.edit(tostring(t.id), 'cat:Work') - local updated = store.get(t.id) + local updated = s:get(t.id) assert.are.equal('Work', updated.category) end) it('adds priority', function() - local t = store.add({ description = 'Task one' }) - store.save() + local s = pending.store() + local t = s:add({ description = 'Task one' }) + s:save() pending.edit(tostring(t.id), '+!') - local updated = store.get(t.id) + local updated = s: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() + local s = pending.store() + local t = s:add({ description = 'Task one', priority = 1 }) + s:save() pending.edit(tostring(t.id), '-!') - local updated = store.get(t.id) + local updated = s: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() + local s = pending.store() + local t = s:add({ description = 'Task one', due = '2026-06-15' }) + s:save() pending.edit(tostring(t.id), '-due') - local updated = store.get(t.id) + local updated = s:get(t.id) assert.is_nil(updated.due) end) it('removes category', function() - local t = store.add({ description = 'Task one', category = 'Work' }) - store.save() + local s = pending.store() + local t = s:add({ description = 'Task one', category = 'Work' }) + s:save() pending.edit(tostring(t.id), '-cat') - local updated = store.get(t.id) + local updated = s:get(t.id) assert.is_nil(updated.category) end) it('sets recurrence', function() - local t = store.add({ description = 'Task one' }) - store.save() + local s = pending.store() + local t = s:add({ description = 'Task one' }) + s:save() pending.edit(tostring(t.id), 'rec:weekly') - local updated = store.get(t.id) + local updated = s: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() + local s = pending.store() + local t = s:add({ description = 'Task one' }) + s:save() pending.edit(tostring(t.id), 'rec:!daily') - local updated = store.get(t.id) + local updated = s: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() + local s = pending.store() + local t = s:add({ description = 'Task one', recur = 'weekly', recur_mode = 'scheduled' }) + s:save() pending.edit(tostring(t.id), '-rec') - local updated = store.get(t.id) + local updated = s: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() + local s = pending.store() + local t = s:add({ description = 'Task one' }) + s:save() pending.edit(tostring(t.id), 'due:today cat:Errands +!') - local updated = store.get(t.id) + local updated = s: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() + local s = pending.store() + local t = s:add({ description = 'Task one' }) + s:save() + local stack_before = #s:undo_stack() pending.edit(tostring(t.id), 'cat:Work') - assert.are.equal(stack_before + 1, #store.undo_stack()) + assert.are.equal(stack_before + 1, #s:undo_stack()) end) it('persists changes to disk', function() - local t = store.add({ description = 'Task one' }) - store.save() + local s = pending.store() + local t = s:add({ description = 'Task one' }) + s:save() pending.edit(tostring(t.id), 'cat:Work') - store.unload() - store.load() - local updated = store.get(t.id) + s:load() + local updated = s: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 s = pending.store() + s:add({ description = 'Task one' }) + s:save() local messages = {} local orig_notify = vim.notify vim.notify = function(msg, level) @@ -152,8 +166,9 @@ describe('edit', function() end) it('errors on invalid date', function() - local t = store.add({ description = 'Task one' }) - store.save() + local s = pending.store() + local t = s:add({ description = 'Task one' }) + s:save() local messages = {} local orig_notify = vim.notify vim.notify = function(msg, level) @@ -167,8 +182,9 @@ describe('edit', function() end) it('errors on unknown operation token', function() - local t = store.add({ description = 'Task one' }) - store.save() + local s = pending.store() + local t = s:add({ description = 'Task one' }) + s:save() local messages = {} local orig_notify = vim.notify vim.notify = function(msg, level) @@ -182,8 +198,9 @@ describe('edit', function() end) it('errors on invalid recurrence pattern', function() - local t = store.add({ description = 'Task one' }) - store.save() + local s = pending.store() + local t = s:add({ description = 'Task one' }) + s:save() local messages = {} local orig_notify = vim.notify vim.notify = function(msg, level) @@ -197,8 +214,9 @@ describe('edit', function() end) it('errors when no operations given', function() - local t = store.add({ description = 'Task one' }) - store.save() + local s = pending.store() + local t = s:add({ description = 'Task one' }) + s:save() local messages = {} local orig_notify = vim.notify vim.notify = function(msg, level) @@ -238,8 +256,9 @@ describe('edit', function() end) it('shows feedback message on success', function() - local t = store.add({ description = 'Task one' }) - store.save() + local s = pending.store() + local t = s:add({ description = 'Task one' }) + s:save() local messages = {} local orig_notify = vim.notify vim.notify = function(msg, level) @@ -255,12 +274,14 @@ describe('edit', function() 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() + package.loaded['pending'] = nil + pending = require('pending') + local s = pending.store() + s:load() + local t = s:add({ description = 'Task one' }) + s:save() pending.edit(tostring(t.id), 'by:tomorrow') - local updated = store.get(t.id) + local updated = s: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 })) @@ -270,32 +291,36 @@ describe('edit', function() 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() + package.loaded['pending'] = nil + pending = require('pending') + local s = pending.store() + s:load() + local t = s:add({ description = 'Task one' }) + s:save() pending.edit(tostring(t.id), 'repeat:weekly') - local updated = store.get(t.id) + local updated = s: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 s = pending.store() + local t = s:add({ description = 'Task one', category = 'Original' }) + s: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) + local updated = s: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() + local s = pending.store() + local t = s:add({ description = 'Task one' }) + s:save() pending.edit(tostring(t.id), 'due:tomorrow@14:00') - local updated = store.get(t.id) + local updated = s: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 })) diff --git a/spec/file_spec.lua b/spec/file_spec.lua index 9835387..c7e3151 100644 --- a/spec/file_spec.lua +++ b/spec/file_spec.lua @@ -8,21 +8,25 @@ local views = require('pending.views') describe('file token', function() local tmpdir + local s 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() + package.loaded['pending'] = nil + package.loaded['pending.buffer'] = nil + s = store.new(tmpdir .. '/tasks.json') + s:load() 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('parse.body', function() @@ -78,89 +82,88 @@ describe('file token', function() describe('diff reconciliation', function() it('stores file field in _extra on write', function() - local t = store.add({ description = 'Task one' }) - store.save() + local t = s:add({ description = 'Task one' }) + s:save() local lines = { '/' .. t.id .. '/- [ ] Task one file:src/auth.lua:42', } - diff.apply(lines) - local updated = store.get(t.id) + diff.apply(lines, s) + local updated = s: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 t = s:add({ description = 'Task one', _extra = { file = 'old.lua:1' } }) + s:save() local lines = { '/' .. t.id .. '/- [ ] Task one file:new.lua:99', } - diff.apply(lines) - local updated = store.get(t.id) + diff.apply(lines, s) + local updated = s: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 t = s:add({ description = 'Task one', _extra = { file = 'src/auth.lua:42' } }) + s:save() local lines = { '/' .. t.id .. '/- [ ] Task one', } - diff.apply(lines) - local updated = store.get(t.id) + diff.apply(lines, s) + local updated = s:get(t.id) assert.is_nil(updated._extra) end) it('preserves other _extra fields when file is cleared', function() - local t = store.add({ + local t = s:add({ description = 'Task one', _extra = { file = 'src/auth.lua:42', _gcal_event_id = 'abc123' }, }) - store.save() + s:save() local lines = { '/' .. t.id .. '/- [ ] Task one', } - diff.apply(lines) - local updated = store.get(t.id) + diff.apply(lines, s) + local updated = s: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 t = s:add({ description = 'Task one' }) + s: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) + diff.apply(lines, s) + s:load() + local loaded = s: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 t = s:add({ description = 'Task one' }) + s:save() local lines = { '/' .. t.id .. '/- [ ] Task one', } assert.has_no_error(function() - diff.apply(lines, {}) + diff.apply(lines, s, {}) end) end) end) describe('LineMeta', function() it('category_view populates file field in LineMeta', function() - local t = store.add({ + local t = s:add({ description = 'Task one', _extra = { file = 'src/auth.lua:42' }, }) - store.save() - local tasks = store.active_tasks() + s:save() + local tasks = s:active_tasks() local _, meta = views.category_view(tasks) local task_meta = nil for _, m in ipairs(meta) do @@ -174,12 +177,12 @@ describe('file token', function() end) it('priority_view populates file field in LineMeta', function() - local t = store.add({ + local t = s:add({ description = 'Task one', _extra = { file = 'src/auth.lua:42' }, }) - store.save() - local tasks = store.active_tasks() + s:save() + local tasks = s:active_tasks() local _, meta = views.priority_view(tasks) local task_meta = nil for _, m in ipairs(meta) do @@ -193,9 +196,9 @@ describe('file token', function() 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 t = s:add({ description = 'Task one' }) + s:save() + local tasks = s:active_tasks() local _, meta = views.category_view(tasks) local task_meta = nil for _, m in ipairs(meta) do @@ -212,17 +215,18 @@ describe('file token', function() 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() + local t = s:add({ description = 'Task one', _extra = { file = 'src/auth.lua:42' } }) + s:save() pending.edit(tostring(t.id), '-file') - local updated = store.get(t.id) + s:load() + local updated = s: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 t = s:add({ description = 'Task one', _extra = { file = 'src/auth.lua:42' } }) + s:save() local messages = {} local orig_notify = vim.notify vim.notify = function(msg, level) @@ -236,8 +240,8 @@ describe('file token', function() it('does not error when task has no file', function() local pending = require('pending') - local t = store.add({ description = 'Task one' }) - store.save() + local t = s:add({ description = 'Task one' }) + s:save() assert.has_no_error(function() pending.edit(tostring(t.id), '-file') end) @@ -245,13 +249,14 @@ describe('file token', function() it('preserves other _extra fields when -file is used', function() local pending = require('pending') - local t = store.add({ + local t = s:add({ description = 'Task one', _extra = { file = 'src/auth.lua:42', _gcal_event_id = 'abc' }, }) - store.save() + s:save() pending.edit(tostring(t.id), '-file') - local updated = store.get(t.id) + s:load() + local updated = s:get(t.id) assert.is_not_nil(updated._extra) assert.is_nil(updated._extra.file) assert.are.equal('abc', updated._extra._gcal_event_id) @@ -263,9 +268,10 @@ describe('file token', function() local pending = require('pending') local buffer = require('pending.buffer') - local t = store.add({ description = 'Task one' }) - store.save() + local t = s:add({ description = 'Task one' }) + s:save() + buffer.set_store(s) local bufnr = buffer.open() vim.bo[bufnr].filetype = 'pending' vim.api.nvim_set_current_buf(bufnr) @@ -306,12 +312,13 @@ describe('file token', function() local pending = require('pending') local buffer = require('pending.buffer') - local t = store.add({ + local t = s:add({ description = 'Task one', _extra = { file = 'nonexistent/path.lua:1' }, }) - store.save() + s:save() + buffer.set_store(s) local bufnr = buffer.open() vim.bo[bufnr].filetype = 'pending' vim.api.nvim_set_current_buf(bufnr) diff --git a/spec/filter_spec.lua b/spec/filter_spec.lua index 8756c5f..5e00b60 100644 --- a/spec/filter_spec.lua +++ b/spec/filter_spec.lua @@ -2,7 +2,6 @@ require('spec.helpers') local config = require('pending.config') local diff = require('pending.diff') -local store = require('pending.store') describe('filter', function() local tmpdir @@ -14,32 +13,31 @@ describe('filter', function() 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({}, {}) + pending.store():load() 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() + local s = pending.store() + s:add({ description = 'Work task', category = 'Work' }) + s:add({ description = 'Home task', category = 'Home' }) + s:save() pending.filter('cat:Work') local hidden = buffer.hidden_ids() - local tasks = store.active_tasks() + local tasks = s:active_tasks() local work_task = nil local home_task = nil for _, t in ipairs(tasks) do @@ -57,13 +55,13 @@ describe('filter', function() 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() + local s = pending.store() + s:add({ description = 'Work task', category = 'Work' }) + s:add({ description = 'Inbox task' }) + s:save() pending.filter('cat:Work') local hidden = buffer.hidden_ids() - local tasks = store.active_tasks() + local tasks = s:active_tasks() local inbox_task = nil for _, t in ipairs(tasks) do if t.category ~= 'Work' then @@ -75,14 +73,14 @@ describe('filter', function() 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() + local s = pending.store() + s:add({ description = 'Old task', due = '2020-01-01' }) + s:add({ description = 'Future task', due = '2099-01-01' }) + s:add({ description = 'No due task' }) + s:save() pending.filter('overdue') local hidden = buffer.hidden_ids() - local tasks = store.active_tasks() + local tasks = s:active_tasks() local overdue_task, future_task, nodue_task for _, t in ipairs(tasks) do if t.due == '2020-01-01' then @@ -101,15 +99,15 @@ describe('filter', function() end) it('today hides non-today tasks', function() - store.load() + local s = pending.store() 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() + s:add({ description = 'Today task', due = today }) + s:add({ description = 'Old task', due = '2020-01-01' }) + s:add({ description = 'Future task', due = '2099-01-01' }) + s:save() pending.filter('today') local hidden = buffer.hidden_ids() - local tasks = store.active_tasks() + local tasks = s:active_tasks() local today_task, old_task, future_task for _, t in ipairs(tasks) do if t.due == today then @@ -128,13 +126,13 @@ describe('filter', function() end) it('priority hides non-priority tasks', function() - store.load() - store.add({ description = 'Important', priority = 1 }) - store.add({ description = 'Normal' }) - store.save() + local s = pending.store() + s:add({ description = 'Important', priority = 1 }) + s:add({ description = 'Normal' }) + s:save() pending.filter('priority') local hidden = buffer.hidden_ids() - local tasks = store.active_tasks() + local tasks = s:active_tasks() local important_task, normal_task for _, t in ipairs(tasks) do if t.priority and t.priority > 0 then @@ -149,14 +147,14 @@ describe('filter', function() 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() + local s = pending.store() + s:add({ description = 'Work overdue', category = 'Work', due = '2020-01-01' }) + s:add({ description = 'Work future', category = 'Work', due = '2099-01-01' }) + s:add({ description = 'Home overdue', category = 'Home', due = '2020-01-01' }) + s:save() pending.filter('cat:Work overdue') local hidden = buffer.hidden_ids() - local tasks = store.active_tasks() + local tasks = s:active_tasks() local work_overdue, work_future, home_overdue for _, t in ipairs(tasks) do if t.description == 'Work overdue' then @@ -175,10 +173,10 @@ describe('filter', function() 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() + local s = pending.store() + s:add({ description = 'Work task', category = 'Work' }) + s:add({ description = 'Home task', category = 'Home' }) + s:save() pending.filter('cat:Work') assert.are.equal(1, #buffer.filter_predicates()) pending.filter('clear') @@ -187,9 +185,9 @@ describe('filter', function() end) it('filter empty string clears filter', function() - store.load() - store.add({ description = 'Work task', category = 'Work' }) - store.save() + local s = pending.store() + s:add({ description = 'Work task', category = 'Work' }) + s:save() pending.filter('cat:Work') assert.are.equal(1, #buffer.filter_predicates()) pending.filter('') @@ -197,16 +195,16 @@ describe('filter', function() 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() + local s = pending.store() + s:add({ description = 'Work task', category = 'Work' }) + s:add({ description = 'Home task', category = 'Home' }) + s: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 tasks = s:active_tasks() local home_task for _, t in ipairs(tasks) do if t.category == 'Home' then @@ -219,11 +217,11 @@ describe('filter', function() 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 s = pending.store() + s:add({ description = 'Visible task' }) + s:add({ description = 'Hidden task' }) + s:save() + local tasks = s:active_tasks() local hidden_task for _, t in ipairs(tasks) do if t.description == 'Hidden task' then @@ -234,19 +232,18 @@ describe('filter', function() local lines = { '/1/- [ ] Visible task', } - diff.apply(lines, hidden_ids) - store.unload() - store.load() - local hidden = store.get(hidden_task.id) + diff.apply(lines, s, hidden_ids) + s:load() + local hidden = s: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 s = pending.store() + s:add({ description = 'Keep task' }) + s:add({ description = 'Delete task' }) + s:save() + local tasks = s:active_tasks() local keep_task, delete_task for _, t in ipairs(tasks) do if t.description == 'Keep task' then @@ -259,27 +256,25 @@ describe('filter', function() local lines = { '/' .. keep_task.id .. '/- [ ] Keep task', } - diff.apply(lines, {}) - store.unload() - store.load() - local deleted = store.get(delete_task.id) + diff.apply(lines, s, {}) + s:load() + local deleted = s: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 s = pending.store() + s:add({ description = 'My task' }) + s:save() + local tasks = s: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) + diff.apply(lines, s, {}) + s:load() + local t = s:get(task.id) assert.are.equal('pending', t.status) end) diff --git a/spec/status_spec.lua b/spec/status_spec.lua index ecbe127..e2d4223 100644 --- a/spec/status_spec.lua +++ b/spec/status_spec.lua @@ -2,7 +2,6 @@ require('spec.helpers') local config = require('pending.config') local parse = require('pending.parse') -local store = require('pending.store') describe('status', function() local tmpdir @@ -13,22 +12,20 @@ describe('status', function() vim.fn.mkdir(tmpdir, 'p') vim.g.pending = { data_path = tmpdir .. '/tasks.json' } config.reset() - store.unload() package.loaded['pending'] = nil pending = require('pending') + pending.store():load() 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) @@ -38,48 +35,48 @@ describe('status', function() end) it('counts pending tasks', function() - store.load() - store.add({ description = 'One' }) - store.add({ description = 'Two' }) - store.save() + local s = pending.store() + s:add({ description = 'One' }) + s:add({ description = 'Two' }) + s: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() + local s = pending.store() + s:add({ description = 'Urgent', priority = 1 }) + s:add({ description = 'Normal' }) + s: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() + local s = pending.store() + s:add({ description = 'Old task', due = '2020-01-01' }) + s: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() + local s = pending.store() + s:add({ description = 'Old task', due = '2020-01-01T08:00' }) + s:save() pending._recompute_counts() local c = pending.counts() assert.are.equal(1, c.overdue) end) it('counts today tasks', function() - store.load() + local s = pending.store() local today = os.date('%Y-%m-%d') --[[@as string]] - store.add({ description = 'Today task', due = today }) - store.save() + s:add({ description = 'Today task', due = today }) + s:save() pending._recompute_counts() local c = pending.counts() assert.are.equal(1, c.today) @@ -87,11 +84,11 @@ describe('status', function() end) it('counts mixed overdue and today', function() - store.load() + local s = pending.store() 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() + s:add({ description = 'Overdue', due = '2020-01-01' }) + s:add({ description = 'Today', due = today }) + s:save() pending._recompute_counts() local c = pending.counts() assert.are.equal(1, c.overdue) @@ -99,10 +96,10 @@ describe('status', function() 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() + local s = pending.store() + local t = s:add({ description = 'Done', due = '2020-01-01' }) + s:update(t.id, { status = 'done' }) + s:save() pending._recompute_counts() local c = pending.counts() assert.are.equal(0, c.overdue) @@ -110,10 +107,10 @@ describe('status', function() end) it('excludes deleted tasks', function() - store.load() - local t = store.add({ description = 'Deleted', due = '2020-01-01' }) - store.delete(t.id) - store.save() + local s = pending.store() + local t = s:add({ description = 'Deleted', due = '2020-01-01' }) + s:delete(t.id) + s:save() pending._recompute_counts() local c = pending.counts() assert.are.equal(0, c.overdue) @@ -121,9 +118,9 @@ describe('status', function() end) it('excludes someday sentinel', function() - store.load() - store.add({ description = 'Someday', due = '9999-12-30' }) - store.save() + local s = pending.store() + s:add({ description = 'Someday', due = '9999-12-30' }) + s:save() pending._recompute_counts() local c = pending.counts() assert.are.equal(0, c.overdue) @@ -132,12 +129,12 @@ describe('status', function() end) it('picks earliest future date as next_due', function() - store.load() + local s = pending.store() 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() + s:add({ description = 'Soon', due = '2099-06-01' }) + s:add({ description = 'Sooner', due = '2099-03-01' }) + s:add({ description = 'Today', due = today }) + s:save() pending._recompute_counts() local c = pending.counts() assert.are.equal(today, c.next_due) @@ -161,7 +158,6 @@ describe('status', function() }, })) f:close() - store.unload() package.loaded['pending'] = nil pending = require('pending') local c = pending.counts() @@ -171,35 +167,35 @@ describe('status', function() describe('statusline', function() it('returns empty string when nothing actionable', function() - store.load() - store.save() + local s = pending.store() + s: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() + local s = pending.store() + s:add({ description = 'Old', due = '2020-01-01' }) + s:save() pending._recompute_counts() assert.are.equal('1 overdue', pending.statusline()) end) it('formats today only', function() - store.load() + local s = pending.store() local today = os.date('%Y-%m-%d') --[[@as string]] - store.add({ description = 'Today', due = today }) - store.save() + s:add({ description = 'Today', due = today }) + s:save() pending._recompute_counts() assert.are.equal('1 today', pending.statusline()) end) it('formats overdue and today', function() - store.load() + local s = pending.store() 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() + s:add({ description = 'Old', due = '2020-01-01' }) + s:add({ description = 'Today', due = today }) + s:save() pending._recompute_counts() assert.are.equal('1 overdue, 1 today', pending.statusline()) end) @@ -207,26 +203,26 @@ describe('status', function() describe('has_due', function() it('returns false when nothing due', function() - store.load() - store.add({ description = 'Future', due = '2099-01-01' }) - store.save() + local s = pending.store() + s:add({ description = 'Future', due = '2099-01-01' }) + s: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() + local s = pending.store() + s:add({ description = 'Old', due = '2020-01-01' }) + s:save() pending._recompute_counts() assert.is_true(pending.has_due()) end) it('returns true when today', function() - store.load() + local s = pending.store() local today = os.date('%Y-%m-%d') --[[@as string]] - store.add({ description = 'Now', due = today }) - store.save() + s:add({ description = 'Now', due = today }) + s:save() pending._recompute_counts() assert.is_true(pending.has_due()) end) diff --git a/spec/store_spec.lua b/spec/store_spec.lua index ebe4da1..0bed750 100644 --- a/spec/store_spec.lua +++ b/spec/store_spec.lua @@ -5,31 +5,30 @@ local store = require('pending.store') describe('store', function() local tmpdir + local s before_each(function() tmpdir = vim.fn.tempname() vim.fn.mkdir(tmpdir, 'p') - vim.g.pending = { data_path = tmpdir .. '/tasks.json' } - config.reset() - store.unload() + s = store.new(tmpdir .. '/tasks.json') + s:load() end) after_each(function() vim.fn.delete(tmpdir, 'rf') - vim.g.pending = nil config.reset() end) describe('load', function() it('returns empty data when no file exists', function() - local data = store.load() + local data = s:load() assert.are.equal(1, data.version) assert.are.equal(1, data.next_id) assert.are.same({}, data.tasks) end) it('loads existing data', function() - local path = config.get().data_path + local path = tmpdir .. '/tasks.json' local f = io.open(path, 'w') f:write(vim.json.encode({ version = 1, @@ -52,7 +51,7 @@ describe('store', function() }, })) f:close() - local data = store.load() + local data = s:load() assert.are.equal(3, data.next_id) assert.are.equal(2, #data.tasks) assert.are.equal('Pending one', data.tasks[1].description) @@ -60,7 +59,7 @@ describe('store', function() end) it('preserves unknown fields', function() - local path = config.get().data_path + local path = tmpdir .. '/tasks.json' local f = io.open(path, 'w') f:write(vim.json.encode({ version = 1, @@ -77,8 +76,8 @@ describe('store', function() }, })) f:close() - store.load() - local task = store.get(1) + s:load() + local task = s:get(1) assert.is_not_nil(task._extra) assert.are.equal('hello', task._extra.custom_field) end) @@ -86,9 +85,8 @@ describe('store', function() describe('add', function() it('creates a task with incremented id', function() - store.load() - local t1 = store.add({ description = 'First' }) - local t2 = store.add({ description = 'Second' }) + local t1 = s:add({ description = 'First' }) + local t2 = s:add({ description = 'Second' }) assert.are.equal(1, t1.id) assert.are.equal(2, t2.id) assert.are.equal('pending', t1.status) @@ -96,60 +94,54 @@ describe('store', function() end) it('uses provided category', function() - store.load() - local t = store.add({ description = 'Test', category = 'Work' }) + local t = s:add({ description = 'Test', category = 'Work' }) assert.are.equal('Work', t.category) end) end) describe('update', function() it('updates fields and sets modified', function() - store.load() - local t = store.add({ description = 'Original' }) + local t = s:add({ description = 'Original' }) t.modified = '2025-01-01T00:00:00Z' - store.update(t.id, { description = 'Updated' }) - local updated = store.get(t.id) + s:update(t.id, { description = 'Updated' }) + local updated = s:get(t.id) assert.are.equal('Updated', updated.description) assert.is_not.equal('2025-01-01T00:00:00Z', updated.modified) end) it('sets end timestamp on completion', function() - store.load() - local t = store.add({ description = 'Test' }) + local t = s:add({ description = 'Test' }) assert.is_nil(t['end']) - store.update(t.id, { status = 'done' }) - local updated = store.get(t.id) + s:update(t.id, { status = 'done' }) + local updated = s:get(t.id) assert.is_not_nil(updated['end']) end) it('does not overwrite id or entry', function() - store.load() - local t = store.add({ description = 'Immutable fields' }) + local t = s:add({ description = 'Immutable fields' }) local original_id = t.id local original_entry = t.entry - store.update(t.id, { id = 999, entry = 'x' }) - local updated = store.get(original_id) + s:update(t.id, { id = 999, entry = 'x' }) + local updated = s:get(original_id) assert.are.equal(original_id, updated.id) assert.are.equal(original_entry, updated.entry) end) it('does not overwrite end on second completion', function() - store.load() - local t = store.add({ description = 'Complete twice' }) - store.update(t.id, { status = 'done', ['end'] = '2026-01-15T10:00:00Z' }) - local first_end = store.get(t.id)['end'] - store.update(t.id, { status = 'done' }) - local task = store.get(t.id) + local t = s:add({ description = 'Complete twice' }) + s:update(t.id, { status = 'done', ['end'] = '2026-01-15T10:00:00Z' }) + local first_end = s:get(t.id)['end'] + s:update(t.id, { status = 'done' }) + local task = s:get(t.id) assert.are.equal(first_end, task['end']) end) end) describe('delete', function() it('marks task as deleted', function() - store.load() - local t = store.add({ description = 'To delete' }) - store.delete(t.id) - local deleted = store.get(t.id) + local t = s:add({ description = 'To delete' }) + s:delete(t.id) + local deleted = s:get(t.id) assert.are.equal('deleted', deleted.status) assert.is_not_nil(deleted['end']) end) @@ -157,12 +149,10 @@ describe('store', function() describe('save and round-trip', function() it('persists and reloads correctly', function() - store.load() - store.add({ description = 'Persisted', category = 'Work', priority = 1 }) - store.save() - store.unload() - store.load() - local tasks = store.active_tasks() + s:add({ description = 'Persisted', category = 'Work', priority = 1 }) + s:save() + s:load() + local tasks = s:active_tasks() assert.are.equal(1, #tasks) assert.are.equal('Persisted', tasks[1].description) assert.are.equal('Work', tasks[1].category) @@ -170,7 +160,7 @@ describe('store', function() end) it('round-trips unknown fields', function() - local path = config.get().data_path + local path = tmpdir .. '/tasks.json' local f = io.open(path, 'w') f:write(vim.json.encode({ version = 1, @@ -187,45 +177,38 @@ describe('store', function() }, })) f:close() - store.load() - store.save() - store.unload() - store.load() - local task = store.get(1) + s:load() + s:save() + s:load() + local task = s:get(1) assert.are.equal('abc123', task._extra._gcal_event_id) 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) + s:add({ description = 'Recurring', recur = 'weekly', recur_mode = 'scheduled' }) + s:save() + s:load() + local task = s: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) + s:add({ description = 'Simple recur', recur = 'daily' }) + s:save() + s:load() + local task = s: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) + s:add({ description = 'No recur' }) + s:save() + s:load() + local task = s:get(1) assert.is_nil(task.recur) assert.is_nil(task.recur_mode) end) @@ -233,11 +216,10 @@ describe('store', function() describe('active_tasks', function() it('excludes deleted tasks', function() - store.load() - store.add({ description = 'Active' }) - local t2 = store.add({ description = 'To delete' }) - store.delete(t2.id) - local active = store.active_tasks() + s:add({ description = 'Active' }) + local t2 = s:add({ description = 'To delete' }) + s:delete(t2.id) + local active = s:active_tasks() assert.are.equal(1, #active) assert.are.equal('Active', active[1].description) end) @@ -245,27 +227,24 @@ describe('store', function() describe('snapshot', function() it('returns a table of tasks', function() - store.load() - store.add({ description = 'Snap one' }) - store.add({ description = 'Snap two' }) - local snap = store.snapshot() + s:add({ description = 'Snap one' }) + s:add({ description = 'Snap two' }) + local snap = s:snapshot() assert.are.equal(2, #snap) end) it('returns a copy that does not affect the store', function() - store.load() - local t = store.add({ description = 'Original' }) - local snap = store.snapshot() + local t = s:add({ description = 'Original' }) + local snap = s:snapshot() snap[1].description = 'Mutated' - local live = store.get(t.id) + local live = s:get(t.id) assert.are.equal('Original', live.description) end) it('excludes deleted tasks', function() - store.load() - local t = store.add({ description = 'Will be deleted' }) - store.delete(t.id) - local snap = store.snapshot() + local t = s:add({ description = 'Will be deleted' }) + s:delete(t.id) + local snap = s:snapshot() assert.are.equal(0, #snap) end) end) diff --git a/spec/sync_spec.lua b/spec/sync_spec.lua index 4d8a3dc..28bd0e3 100644 --- a/spec/sync_spec.lua +++ b/spec/sync_spec.lua @@ -1,7 +1,6 @@ require('spec.helpers') local config = require('pending.config') -local store = require('pending.store') describe('sync', function() local tmpdir @@ -12,7 +11,6 @@ describe('sync', function() 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) @@ -21,7 +19,6 @@ describe('sync', function() vim.fn.delete(tmpdir, 'rf') vim.g.pending = nil config.reset() - store.unload() package.loaded['pending'] = nil end) diff --git a/spec/views_spec.lua b/spec/views_spec.lua index e8d5c2d..c9785f9 100644 --- a/spec/views_spec.lua +++ b/spec/views_spec.lua @@ -5,28 +5,27 @@ local store = require('pending.store') describe('views', function() local tmpdir + local s local views = require('pending.views') 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() + s = store.new(tmpdir .. '/tasks.json') + s:load() end) after_each(function() vim.fn.delete(tmpdir, 'rf') - vim.g.pending = nil config.reset() end) describe('category_view', function() it('groups tasks under their category header', function() - store.add({ description = 'Task A', category = 'Work' }) - store.add({ description = 'Task B', category = 'Work' }) - local lines, meta = views.category_view(store.active_tasks()) + s:add({ description = 'Task A', category = 'Work' }) + s:add({ description = 'Task B', category = 'Work' }) + local lines, meta = views.category_view(s:active_tasks()) assert.are.equal('## Work', lines[1]) assert.are.equal('header', meta[1].type) assert.is_true(lines[2]:find('Task A') ~= nil) @@ -34,10 +33,10 @@ describe('views', function() end) it('places pending tasks before done tasks within a category', function() - local t1 = store.add({ description = 'Done task', category = 'Work' }) - store.add({ description = 'Pending task', category = 'Work' }) - store.update(t1.id, { status = 'done' }) - local _, meta = views.category_view(store.active_tasks()) + local t1 = s:add({ description = 'Done task', category = 'Work' }) + s:add({ description = 'Pending task', category = 'Work' }) + s:update(t1.id, { status = 'done' }) + local _, meta = views.category_view(s:active_tasks()) local pending_row, done_row for i, m in ipairs(meta) do if m.type == 'task' and m.status == 'pending' then @@ -50,9 +49,9 @@ describe('views', function() end) it('sorts high-priority tasks before normal tasks within pending group', function() - store.add({ description = 'Normal', category = 'Work', priority = 0 }) - store.add({ description = 'High', category = 'Work', priority = 1 }) - local lines, meta = views.category_view(store.active_tasks()) + s:add({ description = 'Normal', category = 'Work', priority = 0 }) + s:add({ description = 'High', category = 'Work', priority = 1 }) + local lines, meta = views.category_view(s:active_tasks()) local high_row, normal_row for i, m in ipairs(meta) do if m.type == 'task' then @@ -68,11 +67,11 @@ describe('views', function() end) it('sorts high-priority tasks before normal tasks within done group', function() - local t1 = store.add({ description = 'Done Normal', category = 'Work', priority = 0 }) - local t2 = store.add({ description = 'Done High', category = 'Work', priority = 1 }) - store.update(t1.id, { status = 'done' }) - store.update(t2.id, { status = 'done' }) - local lines, meta = views.category_view(store.active_tasks()) + local t1 = s:add({ description = 'Done Normal', category = 'Work', priority = 0 }) + local t2 = s:add({ description = 'Done High', category = 'Work', priority = 1 }) + s:update(t1.id, { status = 'done' }) + s:update(t2.id, { status = 'done' }) + local lines, meta = views.category_view(s:active_tasks()) local high_row, normal_row for i, m in ipairs(meta) do if m.type == 'task' then @@ -88,9 +87,9 @@ describe('views', function() end) it('gives each category its own header with blank lines between them', function() - store.add({ description = 'Task A', category = 'Work' }) - store.add({ description = 'Task B', category = 'Personal' }) - local lines, meta = views.category_view(store.active_tasks()) + s:add({ description = 'Task A', category = 'Work' }) + s:add({ description = 'Task B', category = 'Personal' }) + local lines, meta = views.category_view(s:active_tasks()) local headers = {} local blank_found = false for i, m in ipairs(meta) do @@ -105,8 +104,8 @@ describe('views', function() end) it('formats task lines as /ID/ description', function() - store.add({ description = 'My task', category = 'Inbox' }) - local lines, meta = views.category_view(store.active_tasks()) + s:add({ description = 'My task', category = 'Inbox' }) + local lines, meta = views.category_view(s:active_tasks()) local task_line for i, m in ipairs(meta) do if m.type == 'task' then @@ -117,8 +116,8 @@ describe('views', function() end) it('formats priority task lines as /ID/- [!] description', function() - store.add({ description = 'Important', category = 'Inbox', priority = 1 }) - local lines, meta = views.category_view(store.active_tasks()) + s:add({ description = 'Important', category = 'Inbox', priority = 1 }) + local lines, meta = views.category_view(s:active_tasks()) local task_line for i, m in ipairs(meta) do if m.type == 'task' then @@ -129,15 +128,15 @@ describe('views', function() end) it('sets LineMeta type=header for header lines with correct category', function() - store.add({ description = 'T', category = 'School' }) - local _, meta = views.category_view(store.active_tasks()) + s:add({ description = 'T', category = 'School' }) + local _, meta = views.category_view(s:active_tasks()) assert.are.equal('header', meta[1].type) assert.are.equal('School', meta[1].category) end) it('sets LineMeta type=task with correct id and status', function() - local t = store.add({ description = 'Do something', category = 'Inbox' }) - local _, meta = views.category_view(store.active_tasks()) + local t = s:add({ description = 'Do something', category = 'Inbox' }) + local _, meta = views.category_view(s:active_tasks()) local task_meta for _, m in ipairs(meta) do if m.type == 'task' then @@ -150,9 +149,9 @@ describe('views', function() end) it('sets LineMeta type=blank for blank separator lines', function() - store.add({ description = 'A', category = 'Work' }) - store.add({ description = 'B', category = 'Home' }) - local _, meta = views.category_view(store.active_tasks()) + s:add({ description = 'A', category = 'Work' }) + s:add({ description = 'B', category = 'Home' }) + local _, meta = views.category_view(s:active_tasks()) local blank_meta for _, m in ipairs(meta) do if m.type == 'blank' then @@ -166,8 +165,8 @@ describe('views', function() it('marks overdue pending tasks with meta.overdue=true', function() local yesterday = os.date('%Y-%m-%d', os.time() - 86400) - local t = store.add({ description = 'Overdue task', category = 'Inbox', due = yesterday }) - local _, meta = views.category_view(store.active_tasks()) + local t = s:add({ description = 'Overdue task', category = 'Inbox', due = yesterday }) + local _, meta = views.category_view(s:active_tasks()) local task_meta for _, m in ipairs(meta) do if m.type == 'task' and m.id == t.id then @@ -179,8 +178,8 @@ describe('views', function() it('does not mark future pending tasks as overdue', function() local tomorrow = os.date('%Y-%m-%d', os.time() + 86400) - local t = store.add({ description = 'Future task', category = 'Inbox', due = tomorrow }) - local _, meta = views.category_view(store.active_tasks()) + local t = s:add({ description = 'Future task', category = 'Inbox', due = tomorrow }) + local _, meta = views.category_view(s:active_tasks()) local task_meta for _, m in ipairs(meta) do if m.type == 'task' and m.id == t.id then @@ -192,9 +191,9 @@ describe('views', function() it('does not mark done tasks with overdue due dates as overdue', function() local yesterday = os.date('%Y-%m-%d', os.time() - 86400) - local t = store.add({ description = 'Done late', category = 'Inbox', due = yesterday }) - store.update(t.id, { status = 'done' }) - local _, meta = views.category_view(store.active_tasks()) + local t = s:add({ description = 'Done late', category = 'Inbox', due = yesterday }) + s:update(t.id, { status = 'done' }) + local _, meta = views.category_view(s:active_tasks()) local task_meta for _, m in ipairs(meta) do if m.type == 'task' and m.id == t.id then @@ -205,8 +204,8 @@ describe('views', function() 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()) + s:add({ description = 'Recurring', category = 'Inbox', recur = 'weekly' }) + local _, meta = views.category_view(s:active_tasks()) local task_meta for _, m in ipairs(meta) do if m.type == 'task' then @@ -217,8 +216,8 @@ describe('views', function() 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()) + s:add({ description = 'Normal', category = 'Inbox' }) + local _, meta = views.category_view(s:active_tasks()) local task_meta for _, m in ipairs(meta) do if m.type == 'task' then @@ -231,9 +230,9 @@ describe('views', function() it('respects category_order when set', function() vim.g.pending = { data_path = tmpdir .. '/tasks.json', category_order = { 'Work', 'Inbox' } } config.reset() - store.add({ description = 'Inbox task', category = 'Inbox' }) - store.add({ description = 'Work task', category = 'Work' }) - local lines, meta = views.category_view(store.active_tasks()) + s:add({ description = 'Inbox task', category = 'Inbox' }) + s:add({ description = 'Work task', category = 'Work' }) + local lines, meta = views.category_view(s:active_tasks()) local first_header, second_header for i, m in ipairs(meta) do if m.type == 'header' then @@ -251,9 +250,9 @@ describe('views', function() it('appends categories not in category_order after ordered ones', function() vim.g.pending = { data_path = tmpdir .. '/tasks.json', category_order = { 'Work' } } config.reset() - store.add({ description = 'Errand', category = 'Errands' }) - store.add({ description = 'Work task', category = 'Work' }) - local lines, meta = views.category_view(store.active_tasks()) + s:add({ description = 'Errand', category = 'Errands' }) + s:add({ description = 'Work task', category = 'Work' }) + local lines, meta = views.category_view(s:active_tasks()) local headers = {} for i, m in ipairs(meta) do if m.type == 'header' then @@ -265,9 +264,9 @@ describe('views', function() end) it('preserves insertion order when category_order is empty', function() - store.add({ description = 'Alpha task', category = 'Alpha' }) - store.add({ description = 'Beta task', category = 'Beta' }) - local lines, meta = views.category_view(store.active_tasks()) + s:add({ description = 'Alpha task', category = 'Alpha' }) + s:add({ description = 'Beta task', category = 'Beta' }) + local lines, meta = views.category_view(s:active_tasks()) local headers = {} for i, m in ipairs(meta) do if m.type == 'header' then @@ -281,10 +280,10 @@ describe('views', function() describe('priority_view', function() it('places all pending tasks before done tasks', function() - local t1 = store.add({ description = 'Done A', category = 'Work' }) - store.add({ description = 'Pending B', category = 'Work' }) - store.update(t1.id, { status = 'done' }) - local _, meta = views.priority_view(store.active_tasks()) + local t1 = s:add({ description = 'Done A', category = 'Work' }) + s:add({ description = 'Pending B', category = 'Work' }) + s:update(t1.id, { status = 'done' }) + local _, meta = views.priority_view(s:active_tasks()) local last_pending_row, first_done_row for i, m in ipairs(meta) do if m.type == 'task' then @@ -299,9 +298,9 @@ describe('views', function() end) it('sorts pending tasks by priority desc within pending group', function() - store.add({ description = 'Low', category = 'Work', priority = 0 }) - store.add({ description = 'High', category = 'Work', priority = 1 }) - local lines, meta = views.priority_view(store.active_tasks()) + s:add({ description = 'Low', category = 'Work', priority = 0 }) + s:add({ description = 'High', category = 'Work', priority = 1 }) + local lines, meta = views.priority_view(s:active_tasks()) local high_row, low_row for i, m in ipairs(meta) do if m.type == 'task' then @@ -316,9 +315,9 @@ describe('views', function() end) it('sorts pending tasks with due dates before those without', function() - store.add({ description = 'No due', category = 'Work' }) - store.add({ description = 'Has due', category = 'Work', due = '2099-12-31' }) - local lines, meta = views.priority_view(store.active_tasks()) + s:add({ description = 'No due', category = 'Work' }) + s:add({ description = 'Has due', category = 'Work', due = '2099-12-31' }) + local lines, meta = views.priority_view(s:active_tasks()) local due_row, nodue_row for i, m in ipairs(meta) do if m.type == 'task' then @@ -333,9 +332,9 @@ describe('views', function() end) it('sorts pending tasks with earlier due dates before later due dates', function() - store.add({ description = 'Later', category = 'Work', due = '2099-12-31' }) - store.add({ description = 'Earlier', category = 'Work', due = '2050-01-01' }) - local lines, meta = views.priority_view(store.active_tasks()) + s:add({ description = 'Later', category = 'Work', due = '2099-12-31' }) + s:add({ description = 'Earlier', category = 'Work', due = '2050-01-01' }) + local lines, meta = views.priority_view(s:active_tasks()) local earlier_row, later_row for i, m in ipairs(meta) do if m.type == 'task' then @@ -350,15 +349,15 @@ describe('views', function() end) it('formats task lines as /ID/- [ ] description', function() - store.add({ description = 'My task', category = 'Inbox' }) - local lines, _ = views.priority_view(store.active_tasks()) + s:add({ description = 'My task', category = 'Inbox' }) + local lines, _ = views.priority_view(s:active_tasks()) assert.are.equal('/1/- [ ] My task', lines[1]) end) it('sets show_category=true for all task meta entries', function() - store.add({ description = 'T1', category = 'Work' }) - store.add({ description = 'T2', category = 'Personal' }) - local _, meta = views.priority_view(store.active_tasks()) + s:add({ description = 'T1', category = 'Work' }) + s:add({ description = 'T2', category = 'Personal' }) + local _, meta = views.priority_view(s:active_tasks()) for _, m in ipairs(meta) do if m.type == 'task' then assert.is_true(m.show_category == true) @@ -367,9 +366,9 @@ describe('views', function() end) it('sets meta.category correctly for each task', function() - store.add({ description = 'Work task', category = 'Work' }) - store.add({ description = 'Home task', category = 'Home' }) - local lines, meta = views.priority_view(store.active_tasks()) + s:add({ description = 'Work task', category = 'Work' }) + s:add({ description = 'Home task', category = 'Home' }) + local lines, meta = views.priority_view(s:active_tasks()) local categories = {} for i, m in ipairs(meta) do if m.type == 'task' then @@ -386,8 +385,8 @@ describe('views', function() it('marks overdue pending tasks with meta.overdue=true', function() local yesterday = os.date('%Y-%m-%d', os.time() - 86400) - local t = store.add({ description = 'Overdue', category = 'Inbox', due = yesterday }) - local _, meta = views.priority_view(store.active_tasks()) + local t = s:add({ description = 'Overdue', category = 'Inbox', due = yesterday }) + local _, meta = views.priority_view(s:active_tasks()) local task_meta for _, m in ipairs(meta) do if m.type == 'task' and m.id == t.id then @@ -399,8 +398,8 @@ describe('views', function() it('does not mark future pending tasks as overdue', function() local tomorrow = os.date('%Y-%m-%d', os.time() + 86400) - local t = store.add({ description = 'Future', category = 'Inbox', due = tomorrow }) - local _, meta = views.priority_view(store.active_tasks()) + local t = s:add({ description = 'Future', category = 'Inbox', due = tomorrow }) + local _, meta = views.priority_view(s:active_tasks()) local task_meta for _, m in ipairs(meta) do if m.type == 'task' and m.id == t.id then @@ -412,9 +411,9 @@ describe('views', function() it('does not mark done tasks with overdue due dates as overdue', function() local yesterday = os.date('%Y-%m-%d', os.time() - 86400) - local t = store.add({ description = 'Done late', category = 'Inbox', due = yesterday }) - store.update(t.id, { status = 'done' }) - local _, meta = views.priority_view(store.active_tasks()) + local t = s:add({ description = 'Done late', category = 'Inbox', due = yesterday }) + s:update(t.id, { status = 'done' }) + local _, meta = views.priority_view(s:active_tasks()) local task_meta for _, m in ipairs(meta) do if m.type == 'task' and m.id == t.id then @@ -425,8 +424,8 @@ describe('views', function() 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()) + s:add({ description = 'Recurring', category = 'Inbox', recur = 'daily' }) + local _, meta = views.priority_view(s:active_tasks()) local task_meta for _, m in ipairs(meta) do if m.type == 'task' then @@ -437,8 +436,8 @@ describe('views', function() 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()) + s:add({ description = 'Normal', category = 'Inbox' }) + local _, meta = views.priority_view(s:active_tasks()) local task_meta for _, m in ipairs(meta) do if m.type == 'task' then From 8c90d0ddd18a35552d5eca113755073ee33974f8 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 26 Feb 2026 22:41:38 -0500 Subject: [PATCH 18/66] refactor: remove file token feature (#50) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: remove file token feature Problem: The file metadata token (file::) was implemented but is no longer wanted. Solution: Remove all traces — parse.lua token parsing, diff.lua reconciliation, views.lua LineMeta field, buffer.lua virtual text and PendingFile highlight, complete.lua omnifunc trigger, init.lua goto_file/add_here functions and -file edit token, plugin keymaps (pending-goto-file) and (pending-add-here), config.lua goto_file keymap field, vimdoc FILE TOKEN section, and spec/file_spec.lua. * ci: format --- doc/pending.txt | 78 +-------- 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 | 358 --------------------------------------- 10 files changed, 6 insertions(+), 598 deletions(-) delete mode 100644 spec/file_spec.lua diff --git a/doc/pending.txt b/doc/pending.txt index fc04dc4..f8c4b50 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:`, `rec:`, and `file:` tokens parsed on `:w` +- 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 @@ -101,7 +101,6 @@ 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 @@ -119,45 +118,12 @@ 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:`, one `rec:`, and one `file:` per task line are consumed. +`cat:`, and one `rec:` per task line are consumed. Omnifunc completion is available for `due:`, `cat:`, and `rec:` token types. In insert mode, type the token prefix and press `` 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* @@ -334,7 +300,6 @@ COMMANDS *pending-commands* :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). @@ -345,7 +310,6 @@ COMMANDS *pending-commands* `-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. @@ -388,7 +352,6 @@ 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) @@ -490,21 +453,6 @@ 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|. - (pending-tab) *(pending-tab)* Open the task buffer in a new tab. See |:PendingTab|. @@ -605,7 +553,6 @@ loads: >lua prev_header = '[[', next_task = ']t', prev_task = '[t', - goto_file = 'gf', }, sync = { gcal = { @@ -668,11 +615,6 @@ 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 @@ -1042,12 +984,6 @@ 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 }) < @@ -1059,16 +995,6 @@ Run |:checkhealth| pending to verify your setup: >vim :checkhealth pending < -Checks performed: ~ -- Config loads without error -- Reports active configuration values (data path, default view, default - 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 -- 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`) - ============================================================================== STORE RESOLUTION *pending-store-resolution* diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index e9d7318..8b661a0 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -178,10 +178,6 @@ local function apply_extmarks(bufnr, line_meta) if m.due then table.insert(virt_parts, { icons.due .. ' ' .. 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] .. ' ' @@ -238,7 +234,6 @@ 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 ceeecc9..9ed4971 100644 --- a/lua/pending/complete.lua +++ b/lua/pending/complete.lua @@ -124,7 +124,6 @@ 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 6adf1c3..f1749e2 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -31,7 +31,6 @@ ---@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 b507179..e5a93e5 100644 --- a/lua/pending/diff.lua +++ b/lua/pending/diff.lua @@ -11,7 +11,6 @@ local parse = require('pending.parse') ---@field due? string ---@field rec? string ---@field rec_mode? string ----@field file? string ---@field lnum integer ---@class pending.diff @@ -57,7 +56,6 @@ function M.parse_buffer(lines) due = metadata.due, rec = metadata.rec, rec_mode = metadata.rec_mode, - file = metadata.file, lnum = i, }) end @@ -135,19 +133,6 @@ function M.apply(lines, s, 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 5205182..bb17617 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -323,16 +323,6 @@ 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 @@ -664,10 +654,6 @@ 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 local resolved = parse.resolve_date(due_val) @@ -711,11 +697,10 @@ local function parse_edit_token(token) .. dk .. ':, cat:, ' .. rk - .. ':, file::, +!, -!, -' + .. ':, +!, -!, -' .. dk .. ', -cat, -' .. rk - .. ', -file' end ---@param id_str string @@ -795,9 +780,6 @@ 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 @@ -810,17 +792,6 @@ function M.edit(id_str, rest) s:update(id, updates) - if updates.file_clear then - local t = s: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 - s:save() local bufnr = buffer.bufnr() @@ -831,91 +802,6 @@ 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 = get_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(get_store().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 s = get_store() - local data_dir = vim.fn.fnamemodify(s.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 - s:load() - local tasks = s: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]] - s:save() - vim.notify('Attached ' .. file_spec .. ' to task ' .. task.id) - end) -end - ---@return nil function M.init() local path = vim.fn.getcwd() .. '/.pending.json' diff --git a/lua/pending/parse.lua b/lua/pending/parse.lua index 6d43be4..9ce4c0d 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', file?: 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 @@ -481,18 +481,7 @@ function M.body(text) metadata.rec = raw_spec i = i - 1 else - local file_path_val, file_line_val = token:match('^file:(.+):(%d+)$') - if file_path_val and file_line_val then - if metadata.file then - break - end - metadata.file = file_path_val .. ':' .. file_line_val - i = i - 1 - elseif token:match('^file:') then - break - else - break - end + break end end end @@ -510,7 +499,7 @@ end ---@param text string ---@return string description ----@return { due?: string, cat?: string, rec?: string, rec_mode?: 'scheduled'|'completion', file?: 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/views.lua b/lua/pending/views.lua index 5447a90..286db9a 100644 --- a/lua/pending/views.lua +++ b/lua/pending/views.lua @@ -12,7 +12,6 @@ local parse = require('pending.parse') ---@field show_category? boolean ---@field priority? integer ---@field recur? string ----@field file? string ---@class pending.views local M = {} @@ -160,7 +159,6 @@ 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 @@ -212,7 +210,6 @@ 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 4814f50..0350b73 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -12,13 +12,11 @@ local function edit_field_candidates() dk .. ':', 'cat:', rk .. ':', - 'file:', '+!', '-!', '-' .. dk, '-cat', '-' .. rk, - '-file', } end @@ -300,14 +298,6 @@ 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) - vim.keymap.set('n', '(pending-tab)', function() vim.cmd.tabnew() require('pending').open() diff --git a/spec/file_spec.lua b/spec/file_spec.lua deleted file mode 100644 index c7e3151..0000000 --- a/spec/file_spec.lua +++ /dev/null @@ -1,358 +0,0 @@ -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 - local s - - before_each(function() - tmpdir = vim.fn.tempname() - vim.fn.mkdir(tmpdir, 'p') - vim.g.pending = { data_path = tmpdir .. '/tasks.json' } - config.reset() - package.loaded['pending'] = nil - package.loaded['pending.buffer'] = nil - s = store.new(tmpdir .. '/tasks.json') - s:load() - end) - - after_each(function() - vim.fn.delete(tmpdir, 'rf') - vim.g.pending = nil - config.reset() - package.loaded['pending'] = nil - package.loaded['pending.buffer'] = nil - 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 = s:add({ description = 'Task one' }) - s:save() - local lines = { - '/' .. t.id .. '/- [ ] Task one file:src/auth.lua:42', - } - diff.apply(lines, s) - local updated = s: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 = s:add({ description = 'Task one', _extra = { file = 'old.lua:1' } }) - s:save() - local lines = { - '/' .. t.id .. '/- [ ] Task one file:new.lua:99', - } - diff.apply(lines, s) - local updated = s: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 = s:add({ description = 'Task one', _extra = { file = 'src/auth.lua:42' } }) - s:save() - local lines = { - '/' .. t.id .. '/- [ ] Task one', - } - diff.apply(lines, s) - local updated = s:get(t.id) - assert.is_nil(updated._extra) - end) - - it('preserves other _extra fields when file is cleared', function() - local t = s:add({ - description = 'Task one', - _extra = { file = 'src/auth.lua:42', _gcal_event_id = 'abc123' }, - }) - s:save() - local lines = { - '/' .. t.id .. '/- [ ] Task one', - } - diff.apply(lines, s) - local updated = s: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 = s:add({ description = 'Task one' }) - s:save() - local lines = { - '/' .. t.id .. '/- [ ] Task one file:src/auth.lua:42', - } - diff.apply(lines, s) - s:load() - local loaded = s: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 = s:add({ description = 'Task one' }) - s:save() - local lines = { - '/' .. t.id .. '/- [ ] Task one', - } - assert.has_no_error(function() - diff.apply(lines, s, {}) - end) - end) - end) - - describe('LineMeta', function() - it('category_view populates file field in LineMeta', function() - local t = s:add({ - description = 'Task one', - _extra = { file = 'src/auth.lua:42' }, - }) - s:save() - local tasks = s: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 = s:add({ - description = 'Task one', - _extra = { file = 'src/auth.lua:42' }, - }) - s:save() - local tasks = s: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 = s:add({ description = 'Task one' }) - s:save() - local tasks = s: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 = s:add({ description = 'Task one', _extra = { file = 'src/auth.lua:42' } }) - s:save() - pending.edit(tostring(t.id), '-file') - s:load() - local updated = s:get(t.id) - assert.is_nil(updated._extra) - end) - - it('shows feedback when file reference is removed', function() - local pending = require('pending') - local t = s:add({ description = 'Task one', _extra = { file = 'src/auth.lua:42' } }) - s: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 = s:add({ description = 'Task one' }) - s: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 = s:add({ - description = 'Task one', - _extra = { file = 'src/auth.lua:42', _gcal_event_id = 'abc' }, - }) - s:save() - pending.edit(tostring(t.id), '-file') - s:load() - local updated = s: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 = s:add({ description = 'Task one' }) - s:save() - - buffer.set_store(s) - 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 = s:add({ - description = 'Task one', - _extra = { file = 'nonexistent/path.lua:1' }, - }) - s:save() - - buffer.set_store(s) - 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 59479ddb0d9b16df516fc3748d935da61c3bf068 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Thu, 26 Feb 2026 20:00:49 -0500 Subject: [PATCH 19/66] test: update all specs for Store instance API Problem: every spec used the old singleton API (store.unload(), store.load(), store.add(), etc.) and diff.apply(lines, hidden). Solution: lower-level specs (store, diff, views, complete, file) use s = store.new(path); s:load() directly. Higher-level specs (archive, edit, filter, status, sync) reset package.loaded['pending'] in before_each and use pending.store() to access the live instance. diff.apply calls updated to diff.apply(lines, s, hidden_ids). --- spec/complete_spec.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/spec/complete_spec.lua b/spec/complete_spec.lua index 98547e8..4e650b1 100644 --- a/spec/complete_spec.lua +++ b/spec/complete_spec.lua @@ -3,6 +3,7 @@ require('spec.helpers') local buffer = require('pending.buffer') local config = require('pending.config') local store = require('pending.store') +local buffer = require('pending.buffer') describe('complete', function() local tmpdir From 3ee26112a6f303f0207830abc234c5569ecc530e Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Thu, 26 Feb 2026 20:00:54 -0500 Subject: [PATCH 20/66] docs(pending): document :Pending init and store resolution Add *pending-store-resolution* section explaining upward .pending.json discovery and fallback to the global data_path. Document :Pending init under COMMANDS. Add a cross-reference from the data_path config field. --- doc/pending.txt | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/doc/pending.txt b/doc/pending.txt index f8c4b50..13ebb77 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -1015,6 +1015,26 @@ To create a project-local store in the current directory: >vim The `:checkhealth pending` report shows which store file is currently active. +============================================================================== +STORE RESOLUTION *pending-store-resolution* + +When pending.nvim opens the task buffer it resolves which store file to use: + +1. Search upward from `vim.fn.getcwd()` for a file named `.pending.json`. +2. If found, use that file as the active store (project-local store). +3. If not found, fall back to `data_path` from |pending-config| (global + store). + +This means placing a `.pending.json` file in a project root makes that +project use an isolated task list. Tasks in the project store are completely +separate from tasks in the global store; there is no aggregation. + +To create a project-local store in the current directory: >vim + :Pending init +< + +The `:checkhealth pending` report shows which store file is currently active. + ============================================================================== DATA FORMAT *pending-data* From 4612960b9a67a7c190b76b25a5d9c41921da9f04 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 26 Feb 2026 22:53:51 -0500 Subject: [PATCH 21/66] refactor(config): remove legacy gcal top-level config key (#51) * refactor(config): remove legacy gcal top-level config key Problem: the gcal migration shim silently accepted vim.g.pending = { gcal = {...} } and copied it to sync.gcal, adding complexity and a deprecated API surface. Solution: remove the migration block in config.get(), drop the cfg.gcal fallback in gcal_config(), delete the two migration tests, and clean up the vimdoc references. Callers must now use sync.gcal directly. * ci: fix * fix(spec): remove duplicate buffer require in complete_spec --- doc/pending.txt | 9 --------- lua/pending/config.lua | 5 ----- lua/pending/sync/gcal.lua | 2 +- spec/complete_spec.lua | 1 - spec/sync_spec.lua | 41 ++++++++------------------------------- 5 files changed, 9 insertions(+), 49 deletions(-) diff --git a/doc/pending.txt b/doc/pending.txt index 13ebb77..3577f49 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -631,12 +631,6 @@ Fields: ~ name and the value is the backend-specific config table. Currently only `gcal` is built-in. - {gcal} (table, default: nil) - 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|. - {icons} (table) *pending.Icons* Icon characters displayed in the buffer. Fields: {pending} Uncompleted task icon. Default: '○' @@ -881,9 +875,6 @@ Configuration: >lua } < -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') diff --git a/lua/pending/config.lua b/lua/pending/config.lua index f1749e2..d8df30e 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -45,7 +45,6 @@ ---@field debug? boolean ---@field keymaps pending.Keymaps ---@field sync? pending.SyncConfig ----@field gcal? pending.GcalConfig ---@field icons pending.Icons ---@class pending.config @@ -101,10 +100,6 @@ 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/sync/gcal.lua b/lua/pending/sync/gcal.lua index a2d9992..2ec96a8 100644 --- a/lua/pending/sync/gcal.lua +++ b/lua/pending/sync/gcal.lua @@ -23,7 +23,7 @@ local SCOPE = 'https://www.googleapis.com/auth/calendar' ---@return table local function gcal_config() local cfg = config.get() - return (cfg.sync and cfg.sync.gcal) or cfg.gcal or {} + return (cfg.sync and cfg.sync.gcal) or {} end ---@return string diff --git a/spec/complete_spec.lua b/spec/complete_spec.lua index 4e650b1..98547e8 100644 --- a/spec/complete_spec.lua +++ b/spec/complete_spec.lua @@ -3,7 +3,6 @@ require('spec.helpers') local buffer = require('pending.buffer') local config = require('pending.config') local store = require('pending.store') -local buffer = require('pending.buffer') describe('complete', function() local tmpdir diff --git a/spec/sync_spec.lua b/spec/sync_spec.lua index 28bd0e3..9e24e7d 100644 --- a/spec/sync_spec.lua +++ b/spec/sync_spec.lua @@ -112,39 +112,14 @@ describe('sync', function() 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) + it('works with sync.gcal config', 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) describe('gcal module', function() From e0b192a88a778a8118c7c07b44484c0692632b30 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 26 Feb 2026 23:09:05 -0500 Subject: [PATCH 22/66] docs(pending): reorganize vimdoc and fix incorrect defaults (#52) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(config): remove legacy gcal top-level config key Problem: the gcal migration shim silently accepted vim.g.pending = { gcal = {...} } and copied it to sync.gcal, adding complexity and a deprecated API surface. Solution: remove the migration block in config.get(), drop the cfg.gcal fallback in gcal_config(), delete the two migration tests, and clean up the vimdoc references. Callers must now use sync.gcal directly. * ci: fix * fix(spec): remove duplicate buffer require in complete_spec * docs(pending): reorganize vimdoc and fix incorrect defaults Problem: sections were out of logical order — inline metadata appeared before commands, GCal before its own backend framework, store resolution duplicated and buried after health check. Two defaults were wrong: default_category documented as 'Inbox' (should be 'Todo') and the gcal calendar example used 'Tasks' (should be 'Pendings'). Solution: reorder all 21 sections into onboarding-first flow, add a CONTENTS table with hyperlinks, fix both incorrect defaults in every location they appeared, and remove the duplicate STORE RESOLUTION section. --- doc/pending.txt | 496 ++++++++++++++++++++++++------------------------ 1 file changed, 250 insertions(+), 246 deletions(-) diff --git a/doc/pending.txt b/doc/pending.txt index 3577f49..56a87d5 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -42,6 +42,30 @@ Features: ~ - Omnifunc completion for `cat:`, `due:`, and `rec:` tokens (``) - Google Calendar one-way push via OAuth PKCE +============================================================================== +CONTENTS *pending-contents* + + 1. Introduction ............................................. |pending.nvim| + 2. Requirements ..................................... |pending-requirements| + 3. Install ............................................... |pending-install| + 4. Usage ................................................... |pending-usage| + 5. Commands .............................................. |pending-commands| + 6. Mappings .............................................. |pending-mappings| + 7. Views ................................................... |pending-views| + 8. Filters ............................................... |pending-filters| + 9. Inline Metadata ....................................... |pending-metadata| + 10. Date Input .............................................. |pending-dates| + 11. Recurrence ......................................... |pending-recurrence| + 12. Configuration ........................................... |pending-config| + 13. Store Resolution .......................... |pending-store-resolution| + 14. Highlight Groups .................................... |pending-highlights| + 15. Lua API ................................................... |pending-api| + 16. Recipes ............................................... |pending-recipes| + 17. Sync Backends ................................... |pending-sync-backend| + 18. Google Calendar .......................................... |pending-gcal| + 19. Data Format .............................................. |pending-data| + 20. Health Check ........................................... |pending-health| + ============================================================================== REQUIREMENTS *pending-requirements* @@ -89,134 +113,6 @@ persists across window switches; reopening with `:Pending` focuses the existing window if one is open. The buffer is automatically reloaded from disk when entered unmodified. -============================================================================== -INLINE METADATA *pending-metadata* - -Metadata tokens may be appended to any task line before saving. Tokens are -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:` 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|. 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 -`2026-03-15` and rendered as right-aligned virtual text, and the task is -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. - -Omnifunc completion is available for `due:`, `cat:`, and `rec:` token types. -In insert mode, type the token prefix 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`) - -Time suffix: ~ *pending-dates-time* -Any named date or absolute date accepts an `@` time suffix. Supported -formats: `HH:MM` (24h), `H:MM`, bare hour (`9`, `14`), and am/pm -(`2pm`, `9:30am`, `12am`). All forms are normalized to `HH:MM` on save. > - - due:tomorrow@2pm " tomorrow at 14:00 - due:fri@9 " next Friday at 09:00 - due:+1w@17:00 " one week from today at 17:00 - due:tomorrow@9:30am " tomorrow at 09:30 - due:2026-03-15@08:00 " absolute date with time - due:2026-03-15T14:30 " ISO 8601 datetime (also accepted) -< - -Tasks with a time component are not considered overdue until after the -specified time. The time is displayed alongside the date in virtual text -and preserved across recurrence advances. - -============================================================================== -RECURRENCE *pending-recurrence* - -Tasks can recur on a schedule. Add a `rec:` token to set recurrence: > - - - [ ] Take out trash due:monday rec:weekly - - [ ] Pay rent due:2026-03-01 rec:monthly - - [ ] Standup due:tomorrow rec:weekdays -< - -When a recurring task is marked done with ``: -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* @@ -522,6 +418,134 @@ 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. +============================================================================== +INLINE METADATA *pending-metadata* + +Metadata tokens may be appended to any task line before saving. Tokens are +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:` 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|. 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 +`2026-03-15` and rendered as right-aligned virtual text, and the task is +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. + +Omnifunc completion is available for `due:`, `cat:`, and `rec:` token types. +In insert mode, type the token prefix 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`) + +Time suffix: ~ *pending-dates-time* +Any named date or absolute date accepts an `@` time suffix. Supported +formats: `HH:MM` (24h), `H:MM`, bare hour (`9`, `14`), and am/pm +(`2pm`, `9:30am`, `12am`). All forms are normalized to `HH:MM` on save. > + + due:tomorrow@2pm " tomorrow at 14:00 + due:fri@9 " next Friday at 09:00 + due:+1w@17:00 " one week from today at 17:00 + due:tomorrow@9:30am " tomorrow at 09:30 + due:2026-03-15@08:00 " absolute date with time + due:2026-03-15T14:30 " ISO 8601 datetime (also accepted) +< + +Tasks with a time component are not considered overdue until after the +specified time. The time is displayed alongside the date in virtual text +and preserved across recurrence advances. + +============================================================================== +RECURRENCE *pending-recurrence* + +Tasks can recur on a schedule. Add a `rec:` token to set recurrence: > + + - [ ] Take out trash due:monday rec:weekly + - [ ] Pay rent due:2026-03-01 rec:monthly + - [ ] Standup due:tomorrow rec:weekdays +< + +When a recurring task is marked done with ``: +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). + ============================================================================== CONFIGURATION *pending-config* @@ -530,7 +554,7 @@ loads: >lua vim.g.pending = { data_path = vim.fn.stdpath('data') .. '/pending/tasks.json', default_view = 'category', - default_category = 'Inbox', + default_category = 'Todo', date_format = '%b %d', date_syntax = 'due', recur_syntax = 'rec', @@ -556,7 +580,7 @@ loads: >lua }, sync = { gcal = { - calendar = 'Tasks', + calendar = 'Pendings', credentials_path = '/path/to/client_secret.json', }, }, @@ -578,7 +602,7 @@ Fields: ~ The view to use when the buffer is opened for the first time in a session. - {default_category} (string, default: 'Inbox') + {default_category} (string, default: 'Todo') Category assigned to new tasks when no `cat:` token is present and no `Category: ` prefix is used with `:Pending add`. @@ -641,6 +665,68 @@ Fields: ~ {recur} Recurrence prefix. Default: '↺' {category} Category label prefix. Default: '#' +============================================================================== +STORE RESOLUTION *pending-store-resolution* + +When pending.nvim opens the task buffer it resolves which store file to use: + +1. Search upward from `vim.fn.getcwd()` for a file named `.pending.json`. +2. If found, use that file as the active store (project-local store). +3. If not found, fall back to `data_path` from |pending-config| (global + store). + +This means placing a `.pending.json` file in a project root makes that +project use an isolated task list. Tasks in the project store are completely +separate from tasks in the global store; there is no aggregation. + +To create a project-local store in the current directory: >vim + :Pending init +< + +The `:checkhealth pending` report shows which store file is currently active. + +============================================================================== +HIGHLIGHT GROUPS *pending-highlights* + +pending.nvim defines the following highlight groups. All groups are set with +`default`, so colorschemes can override them by defining the group without +`default` before or after the plugin loads. + + *PendingHeader* +PendingHeader Applied to category header lines (text at column 0). + Default: links to `Title`. + + *PendingDue* +PendingDue Applied to the due date virtual text shown at the right + margin of each task line. + Default: links to `DiagnosticHint`. + + *PendingOverdue* +PendingOverdue Applied to the due date virtual text of overdue tasks. + Default: links to `DiagnosticError`. + + *PendingDone* +PendingDone Applied to the text of completed tasks. + Default: links to `Comment`. + + *PendingPriority* +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`. + + *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 }) +< + ============================================================================== LUA API *pending-api* @@ -857,6 +943,31 @@ Open tasks in a new tab on startup: >lua end, }) < + +============================================================================== +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|. + ============================================================================== GOOGLE CALENDAR *pending-gcal* @@ -868,7 +979,7 @@ Configuration: >lua vim.g.pending = { sync = { gcal = { - calendar = 'Tasks', + calendar = 'Pendings', credentials_path = '/path/to/client_secret.json', }, }, @@ -913,119 +1024,6 @@ 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* - -pending.nvim defines the following highlight groups. All groups are set with -`default`, so colorschemes can override them by defining the group without -`default` before or after the plugin loads. - - *PendingHeader* -PendingHeader Applied to category header lines (text at column 0). - Default: links to `Title`. - - *PendingDue* -PendingDue Applied to the due date virtual text shown at the right - margin of each task line. - Default: links to `DiagnosticHint`. - - *PendingOverdue* -PendingOverdue Applied to the due date virtual text of overdue tasks. - Default: links to `DiagnosticError`. - - *PendingDone* -PendingDone Applied to the text of completed tasks. - Default: links to `Comment`. - - *PendingPriority* -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`. - - *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 }) -< - -============================================================================== -HEALTH CHECK *pending-health* - -Run |:checkhealth| pending to verify your setup: >vim - :checkhealth pending -< - -============================================================================== -STORE RESOLUTION *pending-store-resolution* - -When pending.nvim opens the task buffer it resolves which store file to use: - -1. Search upward from `vim.fn.getcwd()` for a file named `.pending.json`. -2. If found, use that file as the active store (project-local store). -3. If not found, fall back to `data_path` from |pending-config| (global - store). - -This means placing a `.pending.json` file in a project root makes that -project use an isolated task list. Tasks in the project store are completely -separate from tasks in the global store; there is no aggregation. - -To create a project-local store in the current directory: >vim - :Pending init -< - -The `:checkhealth pending` report shows which store file is currently active. - -============================================================================== -STORE RESOLUTION *pending-store-resolution* - -When pending.nvim opens the task buffer it resolves which store file to use: - -1. Search upward from `vim.fn.getcwd()` for a file named `.pending.json`. -2. If found, use that file as the active store (project-local store). -3. If not found, fall back to `data_path` from |pending-config| (global - store). - -This means placing a `.pending.json` file in a project root makes that -project use an isolated task list. Tasks in the project store are completely -separate from tasks in the global store; there is no aggregation. - -To create a project-local store in the current directory: >vim - :Pending init -< - -The `:checkhealth pending` report shows which store file is currently active. - ============================================================================== DATA FORMAT *pending-data* @@ -1067,4 +1065,10 @@ version the plugin supports, loading is aborted with an error message asking you to update the plugin. ============================================================================== - vim:tw=78:ts=8:ft=help:norl: +HEALTH CHECK *pending-health* + +Run |:checkhealth| pending to verify your setup: >vim + :checkhealth pending +< + +============================================================================== From a24521ee4e29b215d51c253a2028276351216ac9 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 26 Feb 2026 23:25:39 -0500 Subject: [PATCH 23/66] feat(filter): wire F key and (pending-filter) mapping (#53) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(config): remove legacy gcal top-level config key Problem: the gcal migration shim silently accepted vim.g.pending = { gcal = {...} } and copied it to sync.gcal, adding complexity and a deprecated API surface. Solution: remove the migration block in config.get(), drop the cfg.gcal fallback in gcal_config(), delete the two migration tests, and clean up the vimdoc references. Callers must now use sync.gcal directly. * ci: fix * fix(spec): remove duplicate buffer require in complete_spec * docs(pending): reorganize vimdoc and fix incorrect defaults Problem: sections were out of logical order — inline metadata appeared before commands, GCal before its own backend framework, store resolution duplicated and buried after health check. Two defaults were wrong: default_category documented as 'Inbox' (should be 'Todo') and the gcal calendar example used 'Tasks' (should be 'Pendings'). Solution: reorder all 21 sections into onboarding-first flow, add a CONTENTS table with hyperlinks, fix both incorrect defaults in every location they appeared, and remove the duplicate STORE RESOLUTION section. * feat(filter): wire F key and (pending-filter) mapping Problem: the filter predicate logic, diff guard, _on_write handling, :Pending filter command, and filter_spec were already implemented, but there was no buffer-local key to invoke filtering interactively. Solution: add filter = 'F' to keymaps config and defaults, wire the filter action in _setup_buf_mappings via vim.ui.input, add (pending-filter), and update the vimdoc (mappings table, Plug section, config example, and FILTERS section). --- doc/pending.txt | 9 ++++++++- lua/pending/config.lua | 2 ++ lua/pending/init.lua | 7 +++++++ plugin/pending.lua | 8 ++++++++ 4 files changed, 25 insertions(+), 1 deletion(-) diff --git a/doc/pending.txt b/doc/pending.txt index 56a87d5..5543516 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -244,6 +244,7 @@ Default buffer-local keys: ~ `` Toggle complete / uncomplete (`toggle`) `!` Toggle the priority flag (`priority`) `D` Prompt for a due date (`date`) + `F` Prompt for filter predicates (`filter`) `` Switch between category / queue view (`view`) `U` Undo the last `:w` save (`undo`) `o` Insert a new task line below (`open_line`) @@ -309,6 +310,10 @@ All motions support count: `3]]` jumps three headers forward. `]]` and (pending-undo) Undo the last `:w` save. + *(pending-filter)* +(pending-filter) + Prompt for filter predicates via |vim.ui.input|. + *(pending-open-line)* (pending-open-line) Insert a correctly-formatted blank task line below the cursor. @@ -385,7 +390,8 @@ 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 +Set a filter with |:Pending-filter|, the `F` buffer key, or by editing the +`FILTER:` line: >vim :Pending filter cat:Work overdue < @@ -567,6 +573,7 @@ loads: >lua priority = '!', date = 'D', undo = 'U', + filter = 'F', open_line = 'o', open_line_above = 'O', a_task = 'at', diff --git a/lua/pending/config.lua b/lua/pending/config.lua index d8df30e..153c71f 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -21,6 +21,7 @@ ---@field priority? string|false ---@field date? string|false ---@field undo? string|false +---@field filter? string|false ---@field open_line? string|false ---@field open_line_above? string|false ---@field a_task? string|false @@ -67,6 +68,7 @@ local defaults = { priority = '!', date = 'D', undo = 'U', + filter = 'F', open_line = 'o', open_line_above = 'O', a_task = 'at', diff --git a/lua/pending/init.lua b/lua/pending/init.lua index bb17617..12b6a7e 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -243,6 +243,13 @@ function M._setup_buf_mappings(bufnr) undo = function() M.undo_write() end, + filter = function() + vim.ui.input({ prompt = 'Filter: ' }, function(input) + if input then + M.filter(input) + end + end) + end, open_line = function() buffer.open_line(false) end, diff --git a/plugin/pending.lua b/plugin/pending.lua index 0350b73..f533dcf 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -258,6 +258,14 @@ vim.keymap.set('n', '(pending-undo)', function() require('pending').undo_write() end) +vim.keymap.set('n', '(pending-filter)', function() + vim.ui.input({ prompt = 'Filter: ' }, function(input) + if input then + require('pending').filter(input) + end + end) +end) + vim.keymap.set('n', '(pending-open-line)', function() require('pending.buffer').open_line(false) end) From 51508285ac1c9b2b3fe945b9177d41b51955283b Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 4 Mar 2026 14:07:04 -0500 Subject: [PATCH 24/66] ci: nix --- flake.nix | 5 ++++- scripts/ci.sh | 10 ++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) create mode 100755 scripts/ci.sh diff --git a/flake.nix b/flake.nix index da16aea..f895154 100644 --- a/flake.nix +++ b/flake.nix @@ -13,9 +13,12 @@ ... }: let - forEachSystem = f: nixpkgs.lib.genAttrs (import systems) (system: f nixpkgs.legacyPackages.${system}); + forEachSystem = + f: nixpkgs.lib.genAttrs (import systems) (system: f nixpkgs.legacyPackages.${system}); in { + formatter = forEachSystem (pkgs: pkgs.nixfmt-tree); + devShells = forEachSystem (pkgs: { default = pkgs.mkShell { packages = [ diff --git a/scripts/ci.sh b/scripts/ci.sh new file mode 100755 index 0000000..e06bf09 --- /dev/null +++ b/scripts/ci.sh @@ -0,0 +1,10 @@ +#!/bin/sh +set -eu + +nix develop --command stylua --check . +git ls-files '*.lua' | xargs nix develop --command selene --display-style quiet +nix develop --command prettier --check . +nix fmt +git diff --exit-code -- '*.nix' +nix develop --command lua-language-server --check . --checklevel=Warning +nix develop --command busted From 627100eb8cb75d4fdecdc2ced62268b30b852e60 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 4 Mar 2026 14:18:46 -0500 Subject: [PATCH 25/66] ci: scripts & format --- .luarc.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.luarc.json b/.luarc.json index 23646d3..3f6276a 100644 --- a/.luarc.json +++ b/.luarc.json @@ -2,7 +2,13 @@ "runtime.version": "LuaJIT", "runtime.path": ["lua/?.lua", "lua/?/init.lua"], "diagnostics.globals": ["vim", "jit"], - "workspace.library": ["$VIMRUNTIME/lua", "${3rd}/luv/library"], + "workspace.library": [ + "$VIMRUNTIME/lua", + "${3rd}/luv/library", + "${3rd}/busted/library", + "${3rd}/luassert/library" + ], "workspace.checkThirdParty": false, + "workspace.ignoreDir": [".direnv"], "completion.callSnippet": "Replace" } From 7718ebed42b8850be2897bbd9fb5275e475e0357 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 4 Mar 2026 17:49:30 -0500 Subject: [PATCH 26/66] refactor(config): default icons to ascii (#55) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(config): default icons to ascii Problem: default icons used unicode characters (○, ✓, ●, ▸, ·, ↺) which render poorly in some terminals and font configurations. Solution: replace defaults with ascii equivalents (-, x, !, >, ., ~). Users can still override to unicode or nerd font icons via config. * ci: ignore library type checking --- .luarc.json | 1 + README.md | 8 ++++---- doc/pending.txt | 12 ++++++------ lua/pending/config.lua | 12 ++++++------ scripts/demo-init.lua | 9 --------- spec/icons_spec.lua | 22 +++++++++++----------- 6 files changed, 28 insertions(+), 36 deletions(-) diff --git a/.luarc.json b/.luarc.json index 3f6276a..c8eaaf9 100644 --- a/.luarc.json +++ b/.luarc.json @@ -2,6 +2,7 @@ "runtime.version": "LuaJIT", "runtime.path": ["lua/?.lua", "lua/?/init.lua"], "diagnostics.globals": ["vim", "jit"], + "diagnostics.libraryFiles": "Disable", "workspace.library": [ "$VIMRUNTIME/lua", "${3rd}/luv/library", diff --git a/README.md b/README.md index f6add96..5d57bcb 100644 --- a/README.md +++ b/README.md @@ -26,18 +26,18 @@ luarocks install pending.nvim ## Icons -pending.nvim renders task status and metadata using configurable icon characters. The defaults use plain unicode (no nerd font required): +pending.nvim renders task status and metadata using configurable icon characters. The defaults are ASCII-only (no unicode or nerd font required): ```lua vim.g.pending = { icons = { - pending = '○', done = '✓', priority = '●', - header = '▸', due = '·', recur = '↺', category = '#', + pending = '-', done = 'x', priority = '!', + header = '>', due = '.', recur = '~', category = '#', }, } ``` -See `:help pending.Icons` for nerd font examples. +See `:help pending.Icons` for unicode and nerd font examples. ## Acknowledgements diff --git a/doc/pending.txt b/doc/pending.txt index 5543516..b811288 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -664,12 +664,12 @@ Fields: ~ {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: '↺' + {pending} Uncompleted task icon. Default: '-' + {done} Completed task icon. Default: 'x' + {priority} Priority task icon. Default: '!' + {header} Category header prefix. Default: '>' + {due} Due date prefix. Default: '.' + {recur} Recurrence prefix. Default: '~' {category} Category label prefix. Default: '#' ============================================================================== diff --git a/lua/pending/config.lua b/lua/pending/config.lua index 153c71f..dfc3052 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -82,12 +82,12 @@ local defaults = { }, sync = {}, icons = { - pending = '○', - done = '✓', - priority = '●', - header = '▸', - due = '·', - recur = '↺', + pending = '-', + done = 'x', + priority = '!', + header = '>', + due = '.', + recur = '~', category = '#', }, } diff --git a/scripts/demo-init.lua b/scripts/demo-init.lua index f2a6213..57da080 100644 --- a/scripts/demo-init.lua +++ b/scripts/demo-init.lua @@ -4,15 +4,6 @@ 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') diff --git a/spec/icons_spec.lua b/spec/icons_spec.lua index fe3288f..d6569cc 100644 --- a/spec/icons_spec.lua +++ b/spec/icons_spec.lua @@ -15,23 +15,23 @@ describe('icons', function() 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.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) it('allows overriding individual icons', function() - vim.g.pending = { icons = { pending = '-', done = 'x' } } + vim.g.pending = { icons = { pending = '○', done = '✓' } } 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.pending) + assert.equals('✓', icons.done) + assert.equals('!', icons.priority) + assert.equals('>', icons.header) end) it('allows overriding all icons', function() From ee8b660f7c5294ae2bafd7823f6c0197d63383c4 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 4 Mar 2026 17:52:25 -0500 Subject: [PATCH 27/66] ci: fix local script (#56) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(config): default icons to ascii Problem: default icons used unicode characters (○, ✓, ●, ▸, ·, ↺) which render poorly in some terminals and font configurations. Solution: replace defaults with ascii equivalents (-, x, !, >, ., ~). Users can still override to unicode or nerd font icons via config. * ci: ignore library type checking * ci: cleanup ci script --- scripts/ci.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/ci.sh b/scripts/ci.sh index e06bf09..854fe09 100755 --- a/scripts/ci.sh +++ b/scripts/ci.sh @@ -6,5 +6,5 @@ git ls-files '*.lua' | xargs nix develop --command selene --display-style quiet nix develop --command prettier --check . nix fmt git diff --exit-code -- '*.nix' -nix develop --command lua-language-server --check . --checklevel=Warning +nix develop --command lua-language-server --check lua --configpath "$(pwd)/.luarc.json" --checklevel=Warning nix develop --command busted From 3e8fd0a6a34475016b62cb41e85e3454025e8a41 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 4 Mar 2026 18:44:41 -0500 Subject: [PATCH 28/66] refactor(icons): ascii defaults, checkbox overlays, and cleanup (#57) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: remove unnecessary mini.ai recipe from vimdoc Problem: the `*pending-mini-ai*` section assumed mini.ai intercepts buffer-local `at`/`it`/`aC`/`iC` mappings, requiring a manual `vim.b.miniai_config` workaround. Solution: remove the section. Neovim's keymap resolver already prioritizes longer buffer-local mappings over mini.ai's global `a`/`i` handlers — no recipe needed. * refactor(icons): unify category/header icon and use checkbox overlays Problem: `header` and `category` were separate icons for the same concept. The icon overlay replaced `[ ]` with a bare character, hiding the markdown checkbox syntax. Header format `## ` produced a double-space with single-char icons. Solution: merge `header` into `category` (one icon for both header lines and EOL labels). Overlay renders `[icon]` preserving bracket syntax. Change header line format from `## ` to `# ` so the 2-char overlay (`# `) maps cleanly. * ci: remove empty `assets/` placeholder --- README.md | 8 ++-- assets/.gitkeep | 0 doc/pending.txt | 105 ++++------------------------------------- lua/pending/buffer.lua | 7 ++- lua/pending/config.lua | 4 +- lua/pending/diff.lua | 4 +- lua/pending/views.lua | 2 +- spec/diff_spec.lua | 40 ++++++++-------- spec/icons_spec.lua | 29 +++++------- spec/views_spec.lua | 14 +++--- 10 files changed, 61 insertions(+), 152 deletions(-) delete mode 100644 assets/.gitkeep diff --git a/README.md b/README.md index 5d57bcb..cb3d3eb 100644 --- a/README.md +++ b/README.md @@ -26,18 +26,18 @@ luarocks install pending.nvim ## Icons -pending.nvim renders task status and metadata using configurable icon characters. The defaults are ASCII-only (no unicode or nerd font required): +All display characters are configurable. Defaults produce markdown-style checkboxes (`[ ]`, `[x]`, `[!]`): ```lua vim.g.pending = { icons = { - pending = '-', done = 'x', priority = '!', - header = '>', due = '.', recur = '~', category = '#', + pending = ' ', done = 'x', priority = '!', + due = '.', recur = '~', category = '#', }, } ``` -See `:help pending.Icons` for unicode and nerd font examples. +See `:help pending.Icons` for nerd font examples. ## Acknowledgements diff --git a/assets/.gitkeep b/assets/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/doc/pending.txt b/doc/pending.txt index b811288..01728a3 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -663,14 +663,18 @@ Fields: ~ table. Currently only `gcal` is built-in. {icons} (table) *pending.Icons* - Icon characters displayed in the buffer. Fields: - {pending} Uncompleted task icon. Default: '-' - {done} Completed task icon. Default: 'x' - {priority} Priority task icon. Default: '!' - {header} Category header prefix. Default: '>' + Icon characters displayed in the buffer. The + {pending}, {done}, and {priority} characters + appear inside brackets (`[icon]`) as an overlay + on the checkbox. The {category} character + prefixes both header lines and EOL category + labels. Fields: + {pending} Pending task character. Default: ' ' + {done} Done task character. Default: 'x' + {priority} Priority task character. Default: '!' {due} Due date prefix. Default: '.' {recur} Recurrence prefix. Default: '~' - {category} Category label prefix. Default: '#' + {category} Category prefix. Default: '#' ============================================================================== STORE RESOLUTION *pending-store-resolution* @@ -844,84 +848,9 @@ 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. - Nerd font icons: >lua vim.g.pending = { icons = { - pending = '', - done = '', - priority = '', - header = '', due = '', recur = '󰁯', category = '', @@ -929,20 +858,6 @@ Nerd font icons: >lua } < -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() diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index 8b661a0..8fdcbe1 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -115,7 +115,7 @@ local function setup_syntax(bufnr) vim.cmd([[ syntax clear syntax match taskId /^\/\d\+\// conceal - syntax match taskHeader /^## .*$/ contains=taskId + syntax match taskHeader /^# .*$/ contains=taskId syntax match taskCheckbox /\[!\]/ contained containedin=taskLine syntax match taskLine /^\/\d\+\/- \[.\] .*$/ contains=taskId,taskCheckbox ]]) @@ -205,9 +205,8 @@ local function apply_extmarks(bufnr, line_meta) 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 = { { '[' .. icon .. ']', icon_hl } }, virt_text_pos = 'overlay', priority = 100, }) @@ -218,7 +217,7 @@ local function apply_extmarks(bufnr, line_meta) hl_group = 'PendingHeader', }) vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, { - virt_text = { { icons.header .. ' ', 'PendingHeader' } }, + virt_text = { { icons.category .. ' ', 'PendingHeader' } }, virt_text_pos = 'overlay', priority = 100, }) diff --git a/lua/pending/config.lua b/lua/pending/config.lua index dfc3052..09c5cf0 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -2,7 +2,6 @@ ---@field pending string ---@field done string ---@field priority string ----@field header string ---@field due string ---@field recur string ---@field category string @@ -82,10 +81,9 @@ local defaults = { }, sync = {}, icons = { - pending = '-', + pending = ' ', done = 'x', priority = '!', - header = '>', due = '.', recur = '~', category = '#', diff --git a/lua/pending/diff.lua b/lua/pending/diff.lua index e5a93e5..7ebbfe1 100644 --- a/lua/pending/diff.lua +++ b/lua/pending/diff.lua @@ -59,8 +59,8 @@ function M.parse_buffer(lines) lnum = i, }) end - elseif line:match('^## (.+)$') then - current_category = line:match('^## (.+)$') + elseif line:match('^# (.+)$') then + current_category = line:match('^# (.+)$') table.insert(result, { type = 'header', category = current_category, lnum = i }) end end diff --git a/lua/pending/views.lua b/lua/pending/views.lua index 286db9a..87fcee1 100644 --- a/lua/pending/views.lua +++ b/lua/pending/views.lua @@ -133,7 +133,7 @@ function M.category_view(tasks) table.insert(lines, '') table.insert(meta, { type = 'blank' }) end - table.insert(lines, '## ' .. cat) + table.insert(lines, '# ' .. cat) table.insert(meta, { type = 'header', category = cat }) local all = {} diff --git a/spec/diff_spec.lua b/spec/diff_spec.lua index 2322ded..c2a0406 100644 --- a/spec/diff_spec.lua +++ b/spec/diff_spec.lua @@ -21,11 +21,11 @@ describe('diff', function() describe('parse_buffer', function() it('parses headers and tasks', function() local lines = { - '## School', + '# School', '/1/- [ ] Do homework', '/2/- [!] Read chapter 5', '', - '## Errands', + '# Errands', '/3/- [ ] Buy groceries', } local result = diff.parse_buffer(lines) @@ -44,7 +44,7 @@ describe('diff', function() it('handles new tasks without ids', function() local lines = { - '## Inbox', + '# Inbox', '- [ ] New task here', } local result = diff.parse_buffer(lines) @@ -56,7 +56,7 @@ describe('diff', function() it('inline cat: token overrides header category', function() local lines = { - '## Inbox', + '# Inbox', '/1/- [ ] Buy milk cat:Work', } local result = diff.parse_buffer(lines) @@ -67,7 +67,7 @@ describe('diff', function() it('extracts rec: token from buffer line', function() local lines = { - '## Inbox', + '# Inbox', '/1/- [ ] Take trash out rec:weekly', } local result = diff.parse_buffer(lines) @@ -76,7 +76,7 @@ describe('diff', function() it('extracts rec: with completion mode', function() local lines = { - '## Inbox', + '# Inbox', '/1/- [ ] Water plants rec:!daily', } local result = diff.parse_buffer(lines) @@ -86,7 +86,7 @@ describe('diff', function() it('inline due: token is parsed', function() local lines = { - '## Inbox', + '# Inbox', '/1/- [ ] Buy milk due:2026-03-15', } local result = diff.parse_buffer(lines) @@ -99,7 +99,7 @@ describe('diff', function() describe('apply', function() it('creates new tasks from buffer lines', function() local lines = { - '## Inbox', + '# Inbox', '- [ ] First task', '- [ ] Second task', } @@ -116,7 +116,7 @@ describe('diff', function() s:add({ description = 'Delete me' }) s:save() local lines = { - '## Inbox', + '# Inbox', '/1/- [ ] Keep me', } diff.apply(lines, s) @@ -132,7 +132,7 @@ describe('diff', function() s:add({ description = 'Original' }) s:save() local lines = { - '## Inbox', + '# Inbox', '/1/- [ ] Renamed', } diff.apply(lines, s) @@ -146,7 +146,7 @@ describe('diff', function() t.modified = '2020-01-01T00:00:00Z' s:save() local lines = { - '## Inbox', + '# Inbox', '/1/- [ ] Renamed', } diff.apply(lines, s) @@ -160,7 +160,7 @@ describe('diff', function() s:add({ description = 'Original' }) s:save() local lines = { - '## Inbox', + '# Inbox', '/1/- [ ] Original', '/1/- [ ] Copy of original', } @@ -174,7 +174,7 @@ describe('diff', function() s:add({ description = 'Moving task', category = 'Inbox' }) s:save() local lines = { - '## Work', + '# Work', '/1/- [ ] Moving task', } diff.apply(lines, s) @@ -187,7 +187,7 @@ describe('diff', function() s:add({ description = 'Stable task', category = 'Inbox' }) s:save() local lines = { - '## Inbox', + '# Inbox', '/1/- [ ] Stable task', } diff.apply(lines, s) @@ -203,7 +203,7 @@ describe('diff', function() s:add({ description = 'Pay bill', due = '2026-03-15' }) s:save() local lines = { - '## Inbox', + '# Inbox', '/1/- [ ] Pay bill', } diff.apply(lines, s) @@ -214,7 +214,7 @@ describe('diff', function() it('stores recur field on new tasks from buffer', function() local lines = { - '## Inbox', + '# Inbox', '- [ ] Take out trash rec:weekly', } diff.apply(lines, s) @@ -228,7 +228,7 @@ describe('diff', function() s:add({ description = 'Task', recur = 'daily' }) s:save() local lines = { - '## Todo', + '# Todo', '/1/- [ ] Task rec:weekly', } diff.apply(lines, s) @@ -241,7 +241,7 @@ describe('diff', function() s:add({ description = 'Task', recur = 'daily' }) s:save() local lines = { - '## Todo', + '# Todo', '/1/- [ ] Task', } diff.apply(lines, s) @@ -252,7 +252,7 @@ describe('diff', function() it('parses rec: with completion mode prefix', function() local lines = { - '## Inbox', + '# Inbox', '- [ ] Water plants rec:!weekly', } diff.apply(lines, s) @@ -266,7 +266,7 @@ describe('diff', function() s:add({ description = 'Task name', priority = 1 }) s:save() local lines = { - '## Inbox', + '# Inbox', '/1/- [ ] Task name', } diff.apply(lines, s) diff --git a/spec/icons_spec.lua b/spec/icons_spec.lua index d6569cc..47b518c 100644 --- a/spec/icons_spec.lua +++ b/spec/icons_spec.lua @@ -15,45 +15,42 @@ describe('icons', function() it('has default icon values', function() local icons = config.get().icons - assert.equals('-', icons.pending) + 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) it('allows overriding individual icons', function() - vim.g.pending = { icons = { pending = '○', done = '✓' } } + vim.g.pending = { icons = { pending = '*', done = '+' } } config.reset() local icons = config.get().icons - assert.equals('○', icons.pending) - assert.equals('✓', icons.done) + assert.equals('*', icons.pending) + assert.equals('+', icons.done) assert.equals('!', icons.priority) - assert.equals('>', icons.header) + assert.equals('#', icons.category) end) it('allows overriding all icons', function() vim.g.pending = { icons = { pending = '-', - done = 'x', - priority = '!', - header = '>', + done = '+', + priority = '*', due = '@', - recur = '~', - category = '+', + 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.done) + assert.equals('*', icons.priority) assert.equals('@', icons.due) - assert.equals('~', icons.recur) - assert.equals('+', icons.category) + assert.equals('^', icons.recur) + assert.equals('&', icons.category) end) end) diff --git a/spec/views_spec.lua b/spec/views_spec.lua index c9785f9..ede9de9 100644 --- a/spec/views_spec.lua +++ b/spec/views_spec.lua @@ -26,7 +26,7 @@ describe('views', function() s:add({ description = 'Task A', category = 'Work' }) s:add({ description = 'Task B', category = 'Work' }) local lines, meta = views.category_view(s:active_tasks()) - assert.are.equal('## Work', lines[1]) + assert.are.equal('# Work', lines[1]) assert.are.equal('header', meta[1].type) assert.is_true(lines[2]:find('Task A') ~= nil) assert.is_true(lines[3]:find('Task B') ~= nil) @@ -243,8 +243,8 @@ describe('views', function() end end end - assert.are.equal('## Work', first_header) - assert.are.equal('## Inbox', second_header) + assert.are.equal('# Work', first_header) + assert.are.equal('# Inbox', second_header) end) it('appends categories not in category_order after ordered ones', function() @@ -259,8 +259,8 @@ describe('views', function() table.insert(headers, lines[i]) end end - assert.are.equal('## Work', headers[1]) - assert.are.equal('## Errands', headers[2]) + assert.are.equal('# Work', headers[1]) + assert.are.equal('# Errands', headers[2]) end) it('preserves insertion order when category_order is empty', function() @@ -273,8 +273,8 @@ describe('views', function() table.insert(headers, lines[i]) end end - assert.are.equal('## Alpha', headers[1]) - assert.are.equal('## Beta', headers[2]) + assert.are.equal('# Alpha', headers[1]) + assert.are.equal('# Beta', headers[2]) end) end) From 21628abe5348cd348126e374a690ff36abbb2790 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 5 Mar 2026 01:01:29 -0500 Subject: [PATCH 29/66] feat: Google Tasks bidirectional sync and CLI refactor (#59) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(gtasks): add Google Tasks bidirectional sync Problem: pending.nvim only supported one-way push to Google Calendar. Users who use Google Tasks had no way to sync tasks bidirectionally. Solution: add `lua/pending/sync/gtasks.lua` backend with OAuth PKCE auth, push/pull/sync actions, and field mapping between pending tasks and Google Tasks (category↔tasklist, `priority`/`recur` via notes). * refactor(cli): promote sync backends to top-level subcommands Problem: `:Pending sync gtasks auth` required an extra `sync` keyword that added no value and made the command unnecessarily verbose. Solution: route `gtasks` and `gcal` as top-level `:Pending` subcommands via `SYNC_BACKEND_SET` lookup. Tab completion introspects backend modules for available actions instead of hardcoding `{ 'auth', 'sync' }`. * docs(gtasks): document Google Tasks backend and CLI changes Problem: vimdoc had no coverage for the gtasks backend and still referenced the old `:Pending sync ` command form. Solution: add `:Pending-gtasks` and `:Pending-gcal` command sections with per-action docs, update sync backend interface, and add gtasks config example. * ci: format --- doc/pending.txt | 136 ++++++- lua/pending/config.lua | 4 + lua/pending/init.lua | 31 +- lua/pending/sync/gtasks.lua | 767 ++++++++++++++++++++++++++++++++++++ plugin/pending.lua | 43 +- spec/gtasks_spec.lua | 178 +++++++++ spec/sync_spec.lua | 40 +- 7 files changed, 1114 insertions(+), 85 deletions(-) create mode 100644 lua/pending/sync/gtasks.lua create mode 100644 spec/gtasks_spec.lua diff --git a/doc/pending.txt b/doc/pending.txt index 01728a3..08c6315 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -41,6 +41,7 @@ Features: ~ - Foldable category sections (`zc`/`zo`) in category view - Omnifunc completion for `cat:`, `due:`, and `rec:` tokens (``) - Google Calendar one-way push via OAuth PKCE +- Google Tasks bidirectional sync via OAuth PKCE ============================================================================== CONTENTS *pending-contents* @@ -63,15 +64,16 @@ CONTENTS *pending-contents* 16. Recipes ............................................... |pending-recipes| 17. Sync Backends ................................... |pending-sync-backend| 18. Google Calendar .......................................... |pending-gcal| - 19. Data Format .............................................. |pending-data| - 20. Health Check ........................................... |pending-health| + 19. Google Tasks ............................................ |pending-gtasks| + 20. Data Format .............................................. |pending-data| + 21. Health Check ........................................... |pending-health| ============================================================================== REQUIREMENTS *pending-requirements* - Neovim 0.10+ - No external dependencies for local use -- `curl` and `openssl` are required for the `gcal` sync backend +- `curl` and `openssl` are required for the `gcal` and `gtasks` sync backends ============================================================================== INSTALL *pending-install* @@ -146,24 +148,42 @@ COMMANDS *pending-commands* Populate the quickfix list with all tasks that are overdue or due today. Open the list with |:copen| to navigate to each task's category. - *:Pending-sync* -: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`. + *:Pending-gtasks* +:Pending gtasks [{action}] + Run a Google Tasks sync action. {action} defaults to `sync` when omitted. + + Actions: ~ + `sync` Push local changes then pull remote changes (default). + `push` Push local changes to Google Tasks only. + `pull` Pull remote changes from Google Tasks only. + `auth` Run the OAuth authorization flow. Examples: >vim - :Pending sync gcal " runs gcal.sync() - :Pending sync gcal auth " runs gcal.auth() - :Pending sync gcal sync " explicit sync (same as bare) + :Pending gtasks " push then pull (default) + :Pending gtasks push " push local → Google Tasks + :Pending gtasks pull " pull Google Tasks → local + :Pending gtasks auth " authorize < - Tab completion after `:Pending sync ` lists discovered backends. - Tab completion after `:Pending sync gcal ` lists available actions. + Tab completion after `:Pending gtasks ` lists available actions. + See |pending-gtasks| for full details. - Built-in backends: ~ + *:Pending-gcal* +:Pending gcal [{action}] + Run a Google Calendar sync action. {action} defaults to `sync` when + omitted. - `gcal` Google Calendar one-way push. See |pending-gcal|. + Actions: ~ + `sync` Push tasks with due dates to Google Calendar (default). + `auth` Run the OAuth authorization flow. + + Examples: >vim + :Pending gcal " push to Google Calendar (default) + :Pending gcal auth " authorize +< + + Tab completion after `:Pending gcal ` lists available actions. + See |pending-gcal| for full details. *:Pending-filter* :Pending filter {predicates} @@ -590,6 +610,9 @@ loads: >lua calendar = 'Pendings', credentials_path = '/path/to/client_secret.json', }, + gtasks = { + credentials_path = '/path/to/client_secret.json', + }, }, } < @@ -870,21 +893,30 @@ Open tasks in a new tab on startup: >lua 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 +backend is exposed as a top-level `:Pending` subcommand: >vim + :Pending gtasks [action] + :Pending gcal [action] +< + +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 push? fun(): nil + ---@field pull? 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`. + {sync} Main sync action. Called by `:Pending `. + {auth} Authorization flow. Called by `:Pending auth`. Optional fields: ~ + {push} Push-only action. Called by `:Pending push`. + {pull} Pull-only action. Called by `:Pending pull`. {health} Called by `:checkhealth pending` to report backend-specific diagnostics (e.g. checking for external tools). @@ -923,7 +955,7 @@ Fields: ~ that Google provides or as a bare credentials object. OAuth flow: ~ -On the first `:Pending sync gcal` call the plugin detects that no refresh token +On the first `:Pending 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 — @@ -933,7 +965,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 gcal` behavior: ~ +`:Pending 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. @@ -946,6 +978,67 @@ For each task in the store: A summary notification is shown after sync: `created: N, updated: N, deleted: N`. +============================================================================== +GOOGLE TASKS *pending-gtasks* + +pending.nvim can sync tasks bidirectionally with Google Tasks. Each +pending.nvim category maps to a Google Tasks list of the same name. Lists are +created automatically on first sync. + +Configuration: >lua + vim.g.pending = { + sync = { + gtasks = { + credentials_path = '/path/to/client_secret.json', + }, + }, + } +< + + *pending.GtasksConfig* +Fields: ~ + {credentials_path} (string) + Path to the OAuth client secret JSON file downloaded + from the Google Cloud Console. Default: + `stdpath('data')..'/pending/gtasks_credentials.json'`. + Accepts the `installed` wrapper format or a bare + credentials object. + +OAuth flow: ~ +Same PKCE flow as the gcal backend; listens on port 18393. Tokens are stored +at `stdpath('data')/pending/gtasks_tokens.json`. Run `:Pending gtasks auth` +to authorize; subsequent syncs refresh the token automatically. + +`:Pending gtasks` actions: ~ + +`:Pending gtasks` (or `:Pending gtasks sync`) runs push then pull. Use +`:Pending gtasks push` or `:Pending gtasks pull` to run only one direction. + +Push (local → Google Tasks, `:Pending gtasks push`): +- Pending task with no `_gtasks_task_id`: created in the matching list. +- Pending task with an existing ID: updated in Google Tasks. +- Done task with an existing ID: marked `completed` in Google Tasks. +- Deleted task with an existing ID: deleted from Google Tasks. + +Pull (Google Tasks → local, `:Pending gtasks pull`): +- GTasks task already known (matched by `_gtasks_task_id`): updated locally + if `gtasks.updated` timestamp is newer than `task.modified`. +- GTasks task not known locally: created as a new pending.nvim task in the + category matching the list name. + +Field mapping: ~ + {title} ↔ task description + {status} `needsAction` ↔ `pending`, `completed` ↔ `done` + {due} date-only; time component ignored (GTasks limitation) + {notes} serializes extra fields: `pri:1 rec:weekly` + +The `notes` field is used exclusively for pending.nvim metadata. Any existing +notes on tasks created outside pending.nvim are parsed for known tokens and +the remainder is ignored. + +Recurrence (`rec:`) is stored in notes for round-tripping but is not +expanded by Google Tasks (GTasks has no recurrence API). + ============================================================================== DATA FORMAT *pending-data* @@ -979,7 +1072,8 @@ Task fields: ~ Any field not in the list above is preserved in `_extra` and written back on save. This is used internally to store the Google Calendar event ID -(`_gcal_event_id`) and allows third-party tooling to annotate tasks without +(`_gcal_event_id`) and Google Tasks IDs (`_gtasks_task_id`, +`_gtasks_list_id`), and allows third-party tooling to annotate tasks without data loss. The `version` field is checked on load. If the file version is newer than the diff --git a/lua/pending/config.lua b/lua/pending/config.lua index 09c5cf0..58da035 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -10,8 +10,12 @@ ---@field calendar? string ---@field credentials_path? string +---@class pending.GtasksConfig +---@field credentials_path? string + ---@class pending.SyncConfig ---@field gcal? pending.GcalConfig +---@field gtasks? pending.GtasksConfig ---@class pending.Keymaps ---@field close? string|false diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 12b6a7e..f4f7264 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -523,14 +523,19 @@ function M.add(text) vim.notify('Pending added: ' .. description) end +---@type string[] +local SYNC_BACKENDS = { 'gcal', 'gtasks' } + +---@type table +local SYNC_BACKEND_SET = {} +for _, b in ipairs(SYNC_BACKENDS) do + SYNC_BACKEND_SET[b] = true +end + ---@param backend_name string ---@param action? string ---@return nil -function M.sync(backend_name, action) - if not backend_name or backend_name == '' then - vim.notify('Usage: :Pending sync [action]', vim.log.levels.ERROR) - return - end +local function run_sync(backend_name, action) action = (action and action ~= '') and action or 'sync' local ok, backend = pcall(require, 'pending.sync.' .. backend_name) if not ok then @@ -835,9 +840,9 @@ function M.command(args) elseif cmd == 'edit' then local id_str, edit_rest = rest:match('^(%S+)%s*(.*)') M.edit(id_str, edit_rest) - elseif cmd == 'sync' then - local backend, action = rest:match('^(%S+)%s*(.*)') - M.sync(backend, action) + elseif SYNC_BACKEND_SET[cmd] then + local action = rest:match('^(%S+)') or 'sync' + run_sync(cmd, action) elseif cmd == 'archive' then local d = rest ~= '' and tonumber(rest) or nil M.archive(d) @@ -854,4 +859,14 @@ function M.command(args) end end +---@return string[] +function M.sync_backends() + return SYNC_BACKENDS +end + +---@return table +function M.sync_backend_set() + return SYNC_BACKEND_SET +end + return M diff --git a/lua/pending/sync/gtasks.lua b/lua/pending/sync/gtasks.lua new file mode 100644 index 0000000..7476ee6 --- /dev/null +++ b/lua/pending/sync/gtasks.lua @@ -0,0 +1,767 @@ +local config = require('pending.config') + +local M = {} + +M.name = 'gtasks' + +local BASE_URL = 'https://tasks.googleapis.com/tasks/v1' +local TOKEN_URL = 'https://oauth2.googleapis.com/token' +local AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth' +local SCOPE = 'https://www.googleapis.com/auth/tasks' + +---@class pending.GtasksCredentials +---@field client_id string +---@field client_secret string +---@field redirect_uris? string[] + +---@class pending.GtasksTokens +---@field access_token string +---@field refresh_token string +---@field expires_in? integer +---@field obtained_at? integer + +---@return table +local function gtasks_config() + local cfg = config.get() + return (cfg.sync and cfg.sync.gtasks) or {} +end + +---@return string +local function token_path() + return vim.fn.stdpath('data') .. '/pending/gtasks_tokens.json' +end + +---@return string +local function credentials_path() + local gc = gtasks_config() + return gc.credentials_path or (vim.fn.stdpath('data') .. '/pending/gtasks_credentials.json') +end + +---@param path string +---@return table? +local function load_json_file(path) + local f = io.open(path, 'r') + if not f then + return nil + end + local content = f:read('*a') + f:close() + if content == '' then + return nil + end + local ok, decoded = pcall(vim.json.decode, content) + if not ok then + return nil + end + return decoded +end + +---@param path string +---@param data table +---@return boolean +local function save_json_file(path, data) + local dir = vim.fn.fnamemodify(path, ':h') + if vim.fn.isdirectory(dir) == 0 then + vim.fn.mkdir(dir, 'p') + end + local f = io.open(path, 'w') + if not f then + return false + end + f:write(vim.json.encode(data)) + f:close() + vim.fn.setfperm(path, 'rw-------') + return true +end + +---@return pending.GtasksCredentials? +local function load_credentials() + local creds = load_json_file(credentials_path()) + if not creds then + return nil + end + if creds.installed then + return creds.installed --[[@as pending.GtasksCredentials]] + end + return creds --[[@as pending.GtasksCredentials]] +end + +---@return pending.GtasksTokens? +local function load_tokens() + return load_json_file(token_path()) --[[@as pending.GtasksTokens?]] +end + +---@param tokens pending.GtasksTokens +---@return boolean +local function save_tokens(tokens) + return save_json_file(token_path(), tokens) +end + +---@param str string +---@return string +local function url_encode(str) + return ( + str:gsub('([^%w%-%.%_%~])', function(c) + return string.format('%%%02X', string.byte(c)) + end) + ) +end + +---@param method string +---@param url string +---@param headers? string[] +---@param body? string +---@return table? result +---@return string? err +local function curl_request(method, url, headers, body) + local args = { 'curl', '-s', '-X', method } + for _, h in ipairs(headers or {}) do + table.insert(args, '-H') + table.insert(args, h) + end + if body then + table.insert(args, '-d') + table.insert(args, body) + end + table.insert(args, url) + local result = vim.system(args, { text = true }):wait() + if result.code ~= 0 then + return nil, 'curl failed: ' .. (result.stderr or '') + end + if not result.stdout or result.stdout == '' then + return {}, nil + end + local ok, decoded = pcall(vim.json.decode, result.stdout) + if not ok then + return nil, 'failed to parse response: ' .. result.stdout + end + if decoded.error then + return nil, 'API error: ' .. (decoded.error.message or vim.json.encode(decoded.error)) + end + return decoded, nil +end + +---@param access_token string +---@return string[] +local function auth_headers(access_token) + return { + 'Authorization: Bearer ' .. access_token, + 'Content-Type: application/json', + } +end + +---@param creds pending.GtasksCredentials +---@param tokens pending.GtasksTokens +---@return pending.GtasksTokens? +local function refresh_access_token(creds, tokens) + local body = 'client_id=' + .. url_encode(creds.client_id) + .. '&client_secret=' + .. url_encode(creds.client_secret) + .. '&grant_type=refresh_token' + .. '&refresh_token=' + .. url_encode(tokens.refresh_token) + local result = vim + .system({ + 'curl', + '-s', + '-X', + 'POST', + '-H', + 'Content-Type: application/x-www-form-urlencoded', + '-d', + body, + TOKEN_URL, + }, { text = true }) + :wait() + if result.code ~= 0 then + return nil + end + local ok, decoded = pcall(vim.json.decode, result.stdout or '') + if not ok or not decoded.access_token then + return nil + end + tokens.access_token = decoded.access_token --[[@as string]] + tokens.expires_in = decoded.expires_in --[[@as integer?]] + tokens.obtained_at = os.time() + save_tokens(tokens) + return tokens +end + +---@return string? +local function get_access_token() + local creds = load_credentials() + if not creds then + vim.notify( + 'pending.nvim: No Google credentials found at ' .. credentials_path(), + vim.log.levels.ERROR + ) + return nil + end + local tokens = load_tokens() + if not tokens or not tokens.refresh_token then + M.auth() + tokens = load_tokens() + if not tokens then + return nil + end + end + local now = os.time() + local obtained = tokens.obtained_at or 0 + local expires = tokens.expires_in or 3600 + if now - obtained > expires - 60 then + tokens = refresh_access_token(creds, tokens) + if not tokens then + vim.notify('pending.nvim: Failed to refresh access token.', vim.log.levels.ERROR) + return nil + end + end + return tokens.access_token +end + +function M.auth() + local creds = load_credentials() + if not creds then + vim.notify( + 'pending.nvim: No Google credentials found at ' .. credentials_path(), + vim.log.levels.ERROR + ) + return + end + + local port = 18393 + local verifier_chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~' + local verifier = {} + math.randomseed(os.time()) + for _ = 1, 64 do + local idx = math.random(1, #verifier_chars) + table.insert(verifier, verifier_chars:sub(idx, idx)) + end + local code_verifier = table.concat(verifier) + + local sha_pipe = vim + .system({ + 'sh', + '-c', + 'printf "%s" "' + .. code_verifier + .. '" | openssl dgst -sha256 -binary | openssl base64 -A | tr "+/" "-_" | tr -d "="', + }, { text = true }) + :wait() + local code_challenge = sha_pipe.stdout or '' + + local auth_url = AUTH_URL + .. '?client_id=' + .. url_encode(creds.client_id) + .. '&redirect_uri=' + .. url_encode('http://127.0.0.1:' .. port) + .. '&response_type=code' + .. '&scope=' + .. url_encode(SCOPE) + .. '&access_type=offline' + .. '&prompt=consent' + .. '&code_challenge=' + .. url_encode(code_challenge) + .. '&code_challenge_method=S256' + + vim.ui.open(auth_url) + vim.notify('pending.nvim: Opening browser for Google authorization...') + + local server = vim.uv.new_tcp() + server:bind('127.0.0.1', port) + server:listen(1, function(err) + if err then + return + end + local client = vim.uv.new_tcp() + server:accept(client) + client:read_start(function(read_err, data) + if read_err or not data then + return + end + local code = data:match('[?&]code=([^&%s]+)') + local response_body = code + and '

Authorization successful

You can close this tab.

' + or '

Authorization failed

' + local http_response = 'HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n' + .. response_body + client:write(http_response, function() + client:shutdown(function() + client:close() + end) + end) + server:close() + if code then + vim.schedule(function() + M._exchange_code(creds, code, code_verifier, port) + end) + end + end) + end) +end + +---@param creds pending.GtasksCredentials +---@param code string +---@param code_verifier string +---@param port integer +function M._exchange_code(creds, code, code_verifier, port) + local body = 'client_id=' + .. url_encode(creds.client_id) + .. '&client_secret=' + .. url_encode(creds.client_secret) + .. '&code=' + .. url_encode(code) + .. '&code_verifier=' + .. url_encode(code_verifier) + .. '&grant_type=authorization_code' + .. '&redirect_uri=' + .. url_encode('http://127.0.0.1:' .. port) + + local result = vim + .system({ + 'curl', + '-s', + '-X', + 'POST', + '-H', + 'Content-Type: application/x-www-form-urlencoded', + '-d', + body, + TOKEN_URL, + }, { text = true }) + :wait() + + if result.code ~= 0 then + vim.notify('pending.nvim: Token exchange failed.', vim.log.levels.ERROR) + return + end + + local ok, decoded = pcall(vim.json.decode, result.stdout or '') + if not ok or not decoded.access_token then + vim.notify('pending.nvim: Invalid token response.', vim.log.levels.ERROR) + return + end + + decoded.obtained_at = os.time() + save_tokens(decoded) + vim.notify('pending.nvim: Google Tasks authorized successfully.') +end + +---@param access_token string +---@return table? name_to_id +---@return string? err +local function get_all_tasklists(access_token) + local data, err = curl_request('GET', BASE_URL .. '/users/@me/lists', auth_headers(access_token)) + if err then + return nil, err + end + local result = {} + for _, item in ipairs(data and data.items or {}) do + result[item.title] = item.id + end + return result, nil +end + +---@param access_token string +---@param name string +---@param existing table +---@return string? list_id +---@return string? err +local function find_or_create_tasklist(access_token, name, existing) + if existing[name] then + return existing[name], nil + end + local body = vim.json.encode({ title = name }) + local created, err = + curl_request('POST', BASE_URL .. '/users/@me/lists', auth_headers(access_token), body) + if err then + return nil, err + end + local id = created and created.id + if id then + existing[name] = id + end + return id, nil +end + +---@param access_token string +---@param list_id string +---@return table[]? items +---@return string? err +local function list_gtasks(access_token, list_id) + local url = BASE_URL + .. '/lists/' + .. url_encode(list_id) + .. '/tasks?showCompleted=true&showHidden=true' + local data, err = curl_request('GET', url, auth_headers(access_token)) + if err then + return nil, err + end + return data and data.items or {}, nil +end + +---@param access_token string +---@param list_id string +---@param body table +---@return string? task_id +---@return string? err +local function create_gtask(access_token, list_id, body) + local data, err = curl_request( + 'POST', + BASE_URL .. '/lists/' .. url_encode(list_id) .. '/tasks', + auth_headers(access_token), + vim.json.encode(body) + ) + if err then + return nil, err + end + return data and data.id, nil +end + +---@param access_token string +---@param list_id string +---@param task_id string +---@param body table +---@return string? err +local function update_gtask(access_token, list_id, task_id, body) + local _, err = curl_request( + 'PATCH', + BASE_URL .. '/lists/' .. url_encode(list_id) .. '/tasks/' .. url_encode(task_id), + auth_headers(access_token), + vim.json.encode(body) + ) + return err +end + +---@param access_token string +---@param list_id string +---@param task_id string +---@return string? err +local function delete_gtask(access_token, list_id, task_id) + local _, err = curl_request( + 'DELETE', + BASE_URL .. '/lists/' .. url_encode(list_id) .. '/tasks/' .. url_encode(task_id), + auth_headers(access_token) + ) + return err +end + +---@param due string YYYY-MM-DD or YYYY-MM-DDThh:mm +---@return string RFC 3339 +local function due_to_rfc3339(due) + local date = due:match('^(%d%d%d%d%-%d%d%-%d%d)') + return (date or due) .. 'T00:00:00.000Z' +end + +---@param rfc string RFC 3339 from GTasks +---@return string YYYY-MM-DD +local function rfc3339_to_date(rfc) + return rfc:match('^(%d%d%d%d%-%d%d%-%d%d)') or rfc +end + +---@param task pending.Task +---@return string? +local function build_notes(task) + local parts = {} + if task.priority and task.priority > 0 then + table.insert(parts, 'pri:' .. task.priority) + end + if task.recur then + local spec = task.recur + if task.recur_mode == 'completion' then + spec = '!' .. spec + end + table.insert(parts, 'rec:' .. spec) + end + if #parts == 0 then + return nil + end + return table.concat(parts, ' ') +end + +---@param notes string? +---@return integer priority +---@return string? recur +---@return string? recur_mode +local function parse_notes(notes) + if not notes then + return 0, nil, nil + end + local priority = 0 + local recur = nil + local recur_mode = nil + local pri = notes:match('pri:(%d+)') + if pri then + priority = tonumber(pri) or 0 + end + local rec = notes:match('rec:(!?[%w]+)') + if rec then + if rec:sub(1, 1) == '!' then + recur = rec:sub(2) + recur_mode = 'completion' + else + recur = rec + end + end + return priority, recur, recur_mode +end + +---@param task pending.Task +---@return table +local function task_to_gtask(task) + local body = { + title = task.description, + status = task.status == 'done' and 'completed' or 'needsAction', + } + if task.due then + body.due = due_to_rfc3339(task.due) + end + local notes = build_notes(task) + if notes then + body.notes = notes + end + return body +end + +---@param gtask table +---@param category string +---@return table fields for store:add / store:update +local function gtask_to_fields(gtask, category) + local priority, recur, recur_mode = parse_notes(gtask.notes) + local fields = { + description = gtask.title or '', + category = category, + status = gtask.status == 'completed' and 'done' or 'pending', + priority = priority, + recur = recur, + recur_mode = recur_mode, + } + if gtask.due then + fields.due = rfc3339_to_date(gtask.due) + end + return fields +end + +---@param s pending.Store +---@return table +local function build_id_index(s) + ---@type table + local index = {} + for _, task in ipairs(s:tasks()) do + local extra = task._extra or {} + local gtid = extra['_gtasks_task_id'] --[[@as string?]] + if gtid then + index[gtid] = task + end + end + return index +end + +---@param access_token string +---@param tasklists table +---@param s pending.Store +---@param now_ts string +---@param by_gtasks_id table +---@return integer created +---@return integer updated +---@return integer deleted +local function push_pass(access_token, tasklists, s, now_ts, by_gtasks_id) + local created, updated, deleted = 0, 0, 0 + for _, task in ipairs(s:tasks()) do + local extra = task._extra or {} + local gtid = extra['_gtasks_task_id'] --[[@as string?]] + local list_id = extra['_gtasks_list_id'] --[[@as string?]] + + if task.status == 'deleted' and gtid and list_id then + local err = delete_gtask(access_token, list_id, gtid) + if not err then + if not task._extra then + task._extra = {} + end + task._extra['_gtasks_task_id'] = nil + task._extra['_gtasks_list_id'] = nil + if next(task._extra) == nil then + task._extra = nil + end + task.modified = now_ts + deleted = deleted + 1 + end + elseif task.status ~= 'deleted' then + if gtid and list_id then + local err = update_gtask(access_token, list_id, gtid, task_to_gtask(task)) + if not err then + updated = updated + 1 + end + elseif task.status == 'pending' then + local cat = task.category or config.get().default_category + local lid, err = find_or_create_tasklist(access_token, cat, tasklists) + if not err and lid then + local new_id, create_err = create_gtask(access_token, lid, task_to_gtask(task)) + if not create_err and new_id then + if not task._extra then + task._extra = {} + end + task._extra['_gtasks_task_id'] = new_id + task._extra['_gtasks_list_id'] = lid + task.modified = now_ts + by_gtasks_id[new_id] = task + created = created + 1 + end + end + end + end + end + return created, updated, deleted +end + +---@param access_token string +---@param tasklists table +---@param s pending.Store +---@param now_ts string +---@param by_gtasks_id table +---@return integer created +---@return integer updated +local function pull_pass(access_token, tasklists, s, now_ts, by_gtasks_id) + local created, updated = 0, 0 + for list_name, list_id in pairs(tasklists) do + local items, err = list_gtasks(access_token, list_id) + if err then + vim.notify( + 'pending.nvim: error fetching list ' .. list_name .. ': ' .. err, + vim.log.levels.WARN + ) + else + for _, gtask in ipairs(items or {}) do + local local_task = by_gtasks_id[gtask.id] + if local_task then + local gtask_updated = gtask.updated or '' + local local_modified = local_task.modified or '' + if gtask_updated > local_modified then + local fields = gtask_to_fields(gtask, list_name) + for k, v in pairs(fields) do + local_task[k] = v + end + local_task.modified = now_ts + updated = updated + 1 + end + else + local fields = gtask_to_fields(gtask, list_name) + fields._extra = { + _gtasks_task_id = gtask.id, + _gtasks_list_id = list_id, + } + local new_task = s:add(fields) + by_gtasks_id[gtask.id] = new_task + created = created + 1 + end + end + end + end + return created, updated +end + +---@return string? access_token +---@return table? tasklists +---@return pending.Store? store +---@return string? now_ts +local function sync_setup() + local access_token = get_access_token() + if not access_token then + return nil + end + local tasklists, tl_err = get_all_tasklists(access_token) + if tl_err or not tasklists then + vim.notify('pending.nvim: ' .. (tl_err or 'failed to fetch task lists'), vim.log.levels.ERROR) + return nil + end + local s = require('pending').store() + local now_ts = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]] + return access_token, tasklists, s, now_ts +end + +function M.push() + local access_token, tasklists, s, now_ts = sync_setup() + if not access_token then + return + end + ---@cast tasklists table + ---@cast s pending.Store + ---@cast now_ts string + local by_gtasks_id = build_id_index(s) + local created, updated, deleted = push_pass(access_token, tasklists, s, now_ts, by_gtasks_id) + s:save() + require('pending')._recompute_counts() + vim.notify( + string.format('pending.nvim: Google Tasks pushed — +%d ~%d -%d', created, updated, deleted) + ) +end + +function M.pull() + local access_token, tasklists, s, now_ts = sync_setup() + if not access_token then + return + end + ---@cast tasklists table + ---@cast s pending.Store + ---@cast now_ts string + local by_gtasks_id = build_id_index(s) + local created, updated = pull_pass(access_token, tasklists, s, now_ts, by_gtasks_id) + s:save() + require('pending')._recompute_counts() + vim.notify(string.format('pending.nvim: Google Tasks pulled — +%d ~%d', created, updated)) +end + +function M.sync() + local access_token, tasklists, s, now_ts = sync_setup() + if not access_token then + return + end + ---@cast tasklists table + ---@cast s pending.Store + ---@cast now_ts string + local by_gtasks_id = build_id_index(s) + local pushed_create, pushed_update, pushed_delete = + push_pass(access_token, tasklists, s, now_ts, by_gtasks_id) + local pulled_create, pulled_update = pull_pass(access_token, tasklists, s, now_ts, by_gtasks_id) + s:save() + require('pending')._recompute_counts() + vim.notify( + string.format( + 'pending.nvim: Google Tasks synced — push: +%d ~%d -%d, pull: +%d ~%d', + pushed_create, + pushed_update, + pushed_delete, + pulled_create, + pulled_update + ) + ) +end + +M._due_to_rfc3339 = due_to_rfc3339 +M._rfc3339_to_date = rfc3339_to_date +M._build_notes = build_notes +M._parse_notes = parse_notes +M._task_to_gtask = task_to_gtask +M._gtask_to_fields = gtask_to_fields + +---@return nil +function M.health() + if vim.fn.executable('curl') == 1 then + vim.health.ok('curl found (required for gtasks sync)') + else + vim.health.warn('curl not found (needed for gtasks sync)') + end + if vim.fn.executable('openssl') == 1 then + vim.health.ok('openssl found (required for gtasks OAuth PKCE)') + else + vim.health.warn('openssl not found (needed for gtasks OAuth)') + end + local tokens = load_tokens() + if tokens and tokens.refresh_token then + vim.health.ok('gtasks tokens found') + else + vim.health.info('no gtasks tokens — run :Pending gtasks auth') + end +end + +return M diff --git a/plugin/pending.lua b/plugin/pending.lua index f533dcf..13f16d3 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -166,7 +166,12 @@ end, { bar = true, nargs = '*', complete = function(arg_lead, cmd_line) - local subcmds = { 'add', 'archive', 'due', 'edit', 'filter', 'init', 'sync', 'undo' } + local pending = require('pending') + local subcmds = { 'add', 'archive', 'due', 'edit', 'filter', 'init', 'undo' } + for _, b in ipairs(pending.sync_backends()) do + table.insert(subcmds, b) + end + table.sort(subcmds) if not cmd_line:match('^Pending%s+%S') then return filter_candidates(arg_lead, subcmds) end @@ -198,33 +203,25 @@ 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 + local backend_set = pending.sync_backend_set() + local matched_backend = cmd_line:match('^Pending%s+(%S+)') + if matched_backend and backend_set[matched_backend] then + local after_backend = cmd_line:match('^Pending%s+%S+%s+(.*)') + if not after_backend then return {} end - local parts = {} - for part in after_sync:gmatch('%S+') do - table.insert(parts, part) + local ok, mod = pcall(require, 'pending.sync.' .. matched_backend) + if not ok then + return {} 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) + local actions = {} + for k, v in pairs(mod) do + if type(v) == 'function' and k:sub(1, 1) ~= '_' then + table.insert(actions, k) 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 {} + table.sort(actions) + return filter_candidates(arg_lead, actions) end return {} end, diff --git a/spec/gtasks_spec.lua b/spec/gtasks_spec.lua new file mode 100644 index 0000000..19328d9 --- /dev/null +++ b/spec/gtasks_spec.lua @@ -0,0 +1,178 @@ +require('spec.helpers') + +local gtasks = require('pending.sync.gtasks') + +describe('gtasks field conversion', function() + describe('due date helpers', function() + it('converts date-only to RFC 3339', function() + assert.equals('2026-03-15T00:00:00.000Z', gtasks._due_to_rfc3339('2026-03-15')) + end) + + it('converts datetime to RFC 3339 (strips time)', function() + assert.equals('2026-03-15T00:00:00.000Z', gtasks._due_to_rfc3339('2026-03-15T14:30')) + end) + + it('strips RFC 3339 to date-only', function() + assert.equals('2026-03-15', gtasks._rfc3339_to_date('2026-03-15T00:00:00.000Z')) + end) + end) + + describe('build_notes', function() + it('returns nil when no priority or recur', function() + assert.is_nil(gtasks._build_notes({ priority = 0, recur = nil })) + end) + + it('encodes priority', function() + assert.equals('pri:1', gtasks._build_notes({ priority = 1, recur = nil })) + end) + + it('encodes recur', function() + assert.equals('rec:weekly', gtasks._build_notes({ priority = 0, recur = 'weekly' })) + end) + + it('encodes completion-mode recur with ! prefix', function() + assert.equals( + 'rec:!daily', + gtasks._build_notes({ priority = 0, recur = 'daily', recur_mode = 'completion' }) + ) + end) + + it('encodes both priority and recur', function() + assert.equals('pri:1 rec:weekly', gtasks._build_notes({ priority = 1, recur = 'weekly' })) + end) + end) + + describe('parse_notes', function() + it('returns zeros/nils for nil input', function() + local pri, rec, mode = gtasks._parse_notes(nil) + assert.equals(0, pri) + assert.is_nil(rec) + assert.is_nil(mode) + end) + + it('parses priority', function() + local pri = gtasks._parse_notes('pri:1') + assert.equals(1, pri) + end) + + it('parses recur', function() + local _, rec = gtasks._parse_notes('rec:weekly') + assert.equals('weekly', rec) + end) + + it('parses completion-mode recur', function() + local _, rec, mode = gtasks._parse_notes('rec:!daily') + assert.equals('daily', rec) + assert.equals('completion', mode) + end) + + it('parses both priority and recur', function() + local pri, rec = gtasks._parse_notes('pri:1 rec:monthly') + assert.equals(1, pri) + assert.equals('monthly', rec) + end) + + it('round-trips through build_notes', function() + local task = { priority = 1, recur = 'weekly', recur_mode = nil } + local notes = gtasks._build_notes(task) + local pri, rec = gtasks._parse_notes(notes) + assert.equals(1, pri) + assert.equals('weekly', rec) + end) + end) + + describe('task_to_gtask', function() + it('maps description to title', function() + local body = gtasks._task_to_gtask({ + description = 'Buy milk', + status = 'pending', + priority = 0, + }) + assert.equals('Buy milk', body.title) + end) + + it('maps pending status to needsAction', function() + local body = gtasks._task_to_gtask({ description = 'x', status = 'pending', priority = 0 }) + assert.equals('needsAction', body.status) + end) + + it('maps done status to completed', function() + local body = gtasks._task_to_gtask({ description = 'x', status = 'done', priority = 0 }) + assert.equals('completed', body.status) + end) + + it('converts due date to RFC 3339', function() + local body = gtasks._task_to_gtask({ + description = 'x', + status = 'pending', + priority = 0, + due = '2026-03-15', + }) + assert.equals('2026-03-15T00:00:00.000Z', body.due) + end) + + it('omits due when nil', function() + local body = gtasks._task_to_gtask({ description = 'x', status = 'pending', priority = 0 }) + assert.is_nil(body.due) + end) + + it('includes notes when priority is set', function() + local body = gtasks._task_to_gtask({ description = 'x', status = 'pending', priority = 1 }) + assert.equals('pri:1', body.notes) + end) + + it('omits notes when no extra fields', function() + local body = gtasks._task_to_gtask({ description = 'x', status = 'pending', priority = 0 }) + assert.is_nil(body.notes) + end) + end) + + describe('gtask_to_fields', function() + it('maps title to description', function() + local fields = gtasks._gtask_to_fields({ title = 'Buy milk', status = 'needsAction' }, 'Work') + assert.equals('Buy milk', fields.description) + end) + + it('maps category from list name', function() + local fields = gtasks._gtask_to_fields({ title = 'x', status = 'needsAction' }, 'Personal') + assert.equals('Personal', fields.category) + end) + + it('maps needsAction to pending', function() + local fields = gtasks._gtask_to_fields({ title = 'x', status = 'needsAction' }, 'Work') + assert.equals('pending', fields.status) + end) + + it('maps completed to done', function() + local fields = gtasks._gtask_to_fields({ title = 'x', status = 'completed' }, 'Work') + assert.equals('done', fields.status) + end) + + it('strips due date to YYYY-MM-DD', function() + local fields = gtasks._gtask_to_fields({ + title = 'x', + status = 'needsAction', + due = '2026-03-15T00:00:00.000Z', + }, 'Work') + assert.equals('2026-03-15', fields.due) + end) + + it('parses priority from notes', function() + local fields = gtasks._gtask_to_fields({ + title = 'x', + status = 'needsAction', + notes = 'pri:1', + }, 'Work') + assert.equals(1, fields.priority) + end) + + it('parses recur from notes', function() + local fields = gtasks._gtask_to_fields({ + title = 'x', + status = 'needsAction', + notes = 'rec:weekly', + }, 'Work') + assert.equals('weekly', fields.recur) + end) + end) +end) diff --git a/spec/sync_spec.lua b/spec/sync_spec.lua index 9e24e7d..ce38635 100644 --- a/spec/sync_spec.lua +++ b/spec/sync_spec.lua @@ -23,7 +23,7 @@ describe('sync', function() end) describe('dispatch', function() - it('errors on bare :Pending sync with no backend', function() + it('errors on unknown subcommand', function() local msg local orig = vim.notify vim.notify = function(m, level) @@ -31,35 +31,9 @@ describe('sync', function() msg = m end end - pending.sync(nil) + pending.command('notreal') 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) + assert.are.equal('Unknown Pending subcommand: notreal', msg) end) it('errors on unknown action for valid backend', function() @@ -70,7 +44,7 @@ describe('sync', function() msg = m end end - pending.sync('gcal', 'notreal') + pending.command('gcal notreal') vim.notify = orig assert.are.equal("gcal backend has no 'notreal' action", msg) end) @@ -82,7 +56,7 @@ describe('sync', function() gcal.sync = function() called = true end - pending.sync('gcal') + pending.command('gcal') gcal.sync = orig_sync assert.is_true(called) end) @@ -94,7 +68,7 @@ describe('sync', function() gcal.sync = function() called = true end - pending.sync('gcal', 'sync') + pending.command('gcal sync') gcal.sync = orig_sync assert.is_true(called) end) @@ -106,7 +80,7 @@ describe('sync', function() gcal.auth = function() called = true end - pending.sync('gcal', 'auth') + pending.command('gcal auth') gcal.auth = orig_auth assert.is_true(called) end) From e0e3af6787c948d879ed069c7e7e582bd72e591c Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 5 Mar 2026 01:21:18 -0500 Subject: [PATCH 30/66] Google Tasks sync + shared OAuth module (#60) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(gtasks): add Google Tasks bidirectional sync Problem: pending.nvim only supported one-way push to Google Calendar. Users who use Google Tasks had no way to sync tasks bidirectionally. Solution: add `lua/pending/sync/gtasks.lua` backend with OAuth PKCE auth, push/pull/sync actions, and field mapping between pending tasks and Google Tasks (category↔tasklist, `priority`/`recur` via notes). * refactor(cli): promote sync backends to top-level subcommands Problem: `:Pending sync gtasks auth` required an extra `sync` keyword that added no value and made the command unnecessarily verbose. Solution: route `gtasks` and `gcal` as top-level `:Pending` subcommands via `SYNC_BACKEND_SET` lookup. Tab completion introspects backend modules for available actions instead of hardcoding `{ 'auth', 'sync' }`. * docs(gtasks): document Google Tasks backend and CLI changes Problem: vimdoc had no coverage for the gtasks backend and still referenced the old `:Pending sync ` command form. Solution: add `:Pending-gtasks` and `:Pending-gcal` command sections with per-action docs, update sync backend interface, and add gtasks config example. * ci: format * refactor(sync): extract shared OAuth into `oauth.lua` Problem: `gcal.lua` and `gtasks.lua` duplicated ~250 lines of identical OAuth code (token management, PKCE flow, credential loading, curl helpers, url encoding). Solution: Extract a shared `OAuthClient` metatable in `oauth.lua` with module-level utilities and instance methods. Both backends now delegate all OAuth to `oauth.new()`. Skip `oauth` in `health.lua` backend discovery by checking for a `name` field. * feat(sync): ship bundled OAuth credentials Problem: Users must manually create a Google Cloud project and place a credentials JSON file before sync works — terrible onboarding. Solution: Add `client_id`/`client_secret` fields to `GcalConfig` and `GtasksConfig`. `oauth.lua` resolves credentials in three tiers: config fields, credentials file, then bundled defaults (placeholders for now). * docs(sync): document bundled credentials and config fields * ci: format --- doc/pending.txt | 53 +++-- lua/pending/config.lua | 4 + lua/pending/health.lua | 2 +- lua/pending/sync/gcal.lua | 402 ++++-------------------------------- lua/pending/sync/gtasks.lua | 398 +++-------------------------------- lua/pending/sync/oauth.lua | 380 ++++++++++++++++++++++++++++++++++ spec/oauth_spec.lua | 230 +++++++++++++++++++++ 7 files changed, 726 insertions(+), 743 deletions(-) create mode 100644 lua/pending/sync/oauth.lua create mode 100644 spec/oauth_spec.lua diff --git a/doc/pending.txt b/doc/pending.txt index 08c6315..d3eb03b 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -608,11 +608,8 @@ loads: >lua sync = { gcal = { calendar = 'Pendings', - credentials_path = '/path/to/client_secret.json', - }, - gtasks = { - credentials_path = '/path/to/client_secret.json', }, + gtasks = {}, }, } < @@ -683,7 +680,9 @@ Fields: ~ {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. + table. Built-in backends: `gcal`, `gtasks`. Both + ship bundled OAuth credentials so no setup is + needed beyond `:Pending auth`. {icons} (table) *pending.Icons* Icon characters displayed in the buffer. The @@ -934,12 +933,14 @@ Configuration: >lua sync = { gcal = { calendar = 'Pendings', - credentials_path = '/path/to/client_secret.json', }, }, } < +No configuration is required to get started — bundled OAuth credentials are +used by default. Run `:Pending gcal auth` and the browser opens immediately. + *pending.GcalConfig* Fields: ~ {calendar} (string, default: 'Pendings') @@ -947,13 +948,27 @@ Fields: ~ with this name does not exist it is created automatically on the first sync. - {credentials_path} (string) - Path to the OAuth client secret JSON file downloaded + {client_id} (string, optional) + OAuth client ID. When set together with + {client_secret}, these take priority over the + credentials file and bundled defaults. + + {client_secret} (string, optional) + OAuth client secret. See {client_id}. + + {credentials_path} (string, optional) + Path to an OAuth client secret JSON file downloaded from the Google Cloud Console. Default: `stdpath('data')..'/pending/gcal_credentials.json'`. The file may be in the `installed` wrapper format that Google provides or as a bare credentials object. +Credential resolution: ~ +Credentials are resolved in order: +1. `client_id` + `client_secret` config fields (highest priority). +2. JSON file at `credentials_path` (or the default path). +3. Bundled credentials shipped with the plugin (always available). + OAuth flow: ~ On the first `:Pending gcal` call the plugin detects that no refresh token exists and opens the Google authorization URL in the browser using @@ -988,22 +1003,34 @@ created automatically on first sync. Configuration: >lua vim.g.pending = { sync = { - gtasks = { - credentials_path = '/path/to/client_secret.json', - }, + gtasks = {}, }, } < +No configuration is required to get started — bundled OAuth credentials are +used by default. Run `:Pending gtasks auth` and the browser opens immediately. + *pending.GtasksConfig* Fields: ~ - {credentials_path} (string) - Path to the OAuth client secret JSON file downloaded + {client_id} (string, optional) + OAuth client ID. When set together with + {client_secret}, these take priority over the + credentials file and bundled defaults. + + {client_secret} (string, optional) + OAuth client secret. See {client_id}. + + {credentials_path} (string, optional) + Path to an OAuth client secret JSON file downloaded from the Google Cloud Console. Default: `stdpath('data')..'/pending/gtasks_credentials.json'`. Accepts the `installed` wrapper format or a bare credentials object. +Credential resolution: ~ +Same three-tier resolution as the gcal backend (see |pending-gcal|). + OAuth flow: ~ Same PKCE flow as the gcal backend; listens on port 18393. Tokens are stored at `stdpath('data')/pending/gtasks_tokens.json`. Run `:Pending gtasks auth` diff --git a/lua/pending/config.lua b/lua/pending/config.lua index 58da035..263cc8c 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -9,9 +9,13 @@ ---@class pending.GcalConfig ---@field calendar? string ---@field credentials_path? string +---@field client_id? string +---@field client_secret? string ---@class pending.GtasksConfig ---@field credentials_path? string +---@field client_id? string +---@field client_secret? string ---@class pending.SyncConfig ---@field gcal? pending.GcalConfig diff --git a/lua/pending/health.lua b/lua/pending/health.lua index ca28298..0f1bad8 100644 --- a/lua/pending/health.lua +++ b/lua/pending/health.lua @@ -65,7 +65,7 @@ function M.check() 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 + if bok and backend.name and type(backend.health) == 'function' then vim.health.start('pending.nvim: sync/' .. name) backend.health() end diff --git a/lua/pending/sync/gcal.lua b/lua/pending/sync/gcal.lua index 2ec96a8..9158ca1 100644 --- a/lua/pending/sync/gcal.lua +++ b/lua/pending/sync/gcal.lua @@ -1,361 +1,32 @@ local config = require('pending.config') +local oauth = require('pending.sync.oauth') 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' local SCOPE = 'https://www.googleapis.com/auth/calendar' ----@class pending.GcalCredentials ----@field client_id string ----@field client_secret string ----@field redirect_uris? string[] +local client = oauth.new({ + name = 'gcal', + scope = SCOPE, + port = 18392, + config_key = 'gcal', +}) ----@class pending.GcalTokens ----@field access_token string ----@field refresh_token string ----@field expires_in? integer ----@field obtained_at? integer - ----@return table -local function gcal_config() - local cfg = config.get() - return (cfg.sync and cfg.sync.gcal) or {} -end - ----@return string -local function token_path() - return vim.fn.stdpath('data') .. '/pending/gcal_tokens.json' -end - ----@return string -local function credentials_path() - local gc = gcal_config() - return gc.credentials_path or (vim.fn.stdpath('data') .. '/pending/gcal_credentials.json') -end - ----@param path string ----@return table? -local function load_json_file(path) - local f = io.open(path, 'r') - if not f then - return nil - end - local content = f:read('*a') - f:close() - if content == '' then - return nil - end - local ok, decoded = pcall(vim.json.decode, content) - if not ok then - return nil - end - return decoded -end - ----@param path string ----@param data table ----@return boolean -local function save_json_file(path, data) - local dir = vim.fn.fnamemodify(path, ':h') - if vim.fn.isdirectory(dir) == 0 then - vim.fn.mkdir(dir, 'p') - end - local f = io.open(path, 'w') - if not f then - return false - end - f:write(vim.json.encode(data)) - f:close() - vim.fn.setfperm(path, 'rw-------') - return true -end - ----@return pending.GcalCredentials? -local function load_credentials() - local creds = load_json_file(credentials_path()) - if not creds then - return nil - end - if creds.installed then - return creds.installed --[[@as pending.GcalCredentials]] - end - return creds --[[@as pending.GcalCredentials]] -end - ----@return pending.GcalTokens? -local function load_tokens() - return load_json_file(token_path()) --[[@as pending.GcalTokens?]] -end - ----@param tokens pending.GcalTokens ----@return boolean -local function save_tokens(tokens) - return save_json_file(token_path(), tokens) -end - ----@param str string ----@return string -local function url_encode(str) - return ( - str:gsub('([^%w%-%.%_%~])', function(c) - return string.format('%%%02X', string.byte(c)) - end) - ) -end - ----@param method string ----@param url string ----@param headers? string[] ----@param body? string ----@return table? result ----@return string? err -local function curl_request(method, url, headers, body) - local args = { 'curl', '-s', '-X', method } - for _, h in ipairs(headers or {}) do - table.insert(args, '-H') - table.insert(args, h) - end - if body then - table.insert(args, '-d') - table.insert(args, body) - end - table.insert(args, url) - local result = vim.system(args, { text = true }):wait() - if result.code ~= 0 then - return nil, 'curl failed: ' .. (result.stderr or '') - end - if not result.stdout or result.stdout == '' then - return {}, nil - end - local ok, decoded = pcall(vim.json.decode, result.stdout) - if not ok then - return nil, 'failed to parse response: ' .. result.stdout - end - if decoded.error then - return nil, 'API error: ' .. (decoded.error.message or vim.json.encode(decoded.error)) - end - return decoded, nil -end - ----@param access_token string ----@return string[] -local function auth_headers(access_token) - return { - 'Authorization: Bearer ' .. access_token, - 'Content-Type: application/json', - } -end - ----@param creds pending.GcalCredentials ----@param tokens pending.GcalTokens ----@return pending.GcalTokens? -local function refresh_access_token(creds, tokens) - local body = 'client_id=' - .. url_encode(creds.client_id) - .. '&client_secret=' - .. url_encode(creds.client_secret) - .. '&grant_type=refresh_token' - .. '&refresh_token=' - .. url_encode(tokens.refresh_token) - local result = vim - .system({ - 'curl', - '-s', - '-X', - 'POST', - '-H', - 'Content-Type: application/x-www-form-urlencoded', - '-d', - body, - TOKEN_URL, - }, { text = true }) - :wait() - if result.code ~= 0 then - return nil - end - local ok, decoded = pcall(vim.json.decode, result.stdout or '') - if not ok or not decoded.access_token then - return nil - end - tokens.access_token = decoded.access_token --[[@as string]] - tokens.expires_in = decoded.expires_in --[[@as integer?]] - tokens.obtained_at = os.time() - save_tokens(tokens) - return tokens -end - ----@return string? -local function get_access_token() - local creds = load_credentials() - if not creds then - vim.notify( - 'pending.nvim: No Google Calendar credentials found at ' .. credentials_path(), - vim.log.levels.ERROR - ) - return nil - end - local tokens = load_tokens() - if not tokens or not tokens.refresh_token then - M.auth() - tokens = load_tokens() - if not tokens then - return nil - end - end - local now = os.time() - local obtained = tokens.obtained_at or 0 - local expires = tokens.expires_in or 3600 - if now - obtained > expires - 60 then - tokens = refresh_access_token(creds, tokens) - if not tokens then - vim.notify('pending.nvim: Failed to refresh access token.', vim.log.levels.ERROR) - return nil - end - end - return tokens.access_token -end - -function M.auth() - local creds = load_credentials() - if not creds then - vim.notify( - 'pending.nvim: No Google Calendar credentials found at ' .. credentials_path(), - vim.log.levels.ERROR - ) - return - end - - local port = 18392 - local verifier_chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~' - local verifier = {} - math.randomseed(os.time()) - for _ = 1, 64 do - local idx = math.random(1, #verifier_chars) - table.insert(verifier, verifier_chars:sub(idx, idx)) - end - local code_verifier = table.concat(verifier) - - local sha_pipe = vim - .system({ - 'sh', - '-c', - 'printf "%s" "' - .. code_verifier - .. '" | openssl dgst -sha256 -binary | openssl base64 -A | tr "+/" "-_" | tr -d "="', - }, { text = true }) - :wait() - local code_challenge = sha_pipe.stdout or '' - - local auth_url = AUTH_URL - .. '?client_id=' - .. url_encode(creds.client_id) - .. '&redirect_uri=' - .. url_encode('http://127.0.0.1:' .. port) - .. '&response_type=code' - .. '&scope=' - .. url_encode(SCOPE) - .. '&access_type=offline' - .. '&prompt=consent' - .. '&code_challenge=' - .. url_encode(code_challenge) - .. '&code_challenge_method=S256' - - vim.ui.open(auth_url) - vim.notify('pending.nvim: Opening browser for Google authorization...') - - local server = vim.uv.new_tcp() - server:bind('127.0.0.1', port) - server:listen(1, function(err) - if err then - return - end - local client = vim.uv.new_tcp() - server:accept(client) - client:read_start(function(read_err, data) - if read_err or not data then - return - end - local code = data:match('[?&]code=([^&%s]+)') - local response_body = code - and '

Authorization successful

You can close this tab.

' - or '

Authorization failed

' - local http_response = 'HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n' - .. response_body - client:write(http_response, function() - client:shutdown(function() - client:close() - end) - end) - server:close() - if code then - vim.schedule(function() - M._exchange_code(creds, code, code_verifier, port) - end) - end - end) - end) -end - ----@param creds pending.GcalCredentials ----@param code string ----@param code_verifier string ----@param port integer -function M._exchange_code(creds, code, code_verifier, port) - local body = 'client_id=' - .. url_encode(creds.client_id) - .. '&client_secret=' - .. url_encode(creds.client_secret) - .. '&code=' - .. url_encode(code) - .. '&code_verifier=' - .. url_encode(code_verifier) - .. '&grant_type=authorization_code' - .. '&redirect_uri=' - .. url_encode('http://127.0.0.1:' .. port) - - local result = vim - .system({ - 'curl', - '-s', - '-X', - 'POST', - '-H', - 'Content-Type: application/x-www-form-urlencoded', - '-d', - body, - TOKEN_URL, - }, { text = true }) - :wait() - - if result.code ~= 0 then - vim.notify('pending.nvim: Token exchange failed.', vim.log.levels.ERROR) - return - end - - local ok, decoded = pcall(vim.json.decode, result.stdout or '') - if not ok or not decoded.access_token then - vim.notify('pending.nvim: Invalid token response.', vim.log.levels.ERROR) - return - end - - decoded.obtained_at = os.time() - save_tokens(decoded) - vim.notify('pending.nvim: Google Calendar authorized successfully.') -end - ----@param access_token string ---@return string? calendar_id ---@return string? err local function find_or_create_calendar(access_token) - local gc = gcal_config() + local cfg = config.get() + local gc = (cfg.sync and cfg.sync.gcal) or {} local cal_name = gc.calendar or 'Pendings' - local data, err = - curl_request('GET', BASE_URL .. '/users/me/calendarList', auth_headers(access_token)) + local data, err = oauth.curl_request( + 'GET', + BASE_URL .. '/users/me/calendarList', + oauth.auth_headers(access_token) + ) if err then return nil, err end @@ -368,7 +39,7 @@ local function find_or_create_calendar(access_token) local body = vim.json.encode({ summary = cal_name }) local created, create_err = - curl_request('POST', BASE_URL .. '/calendars', auth_headers(access_token), body) + oauth.curl_request('POST', BASE_URL .. '/calendars', oauth.auth_headers(access_token), body) if create_err then return nil, create_err end @@ -400,10 +71,10 @@ local function create_event(access_token, calendar_id, task) private = { taskId = tostring(task.id) }, }, } - local data, err = curl_request( + local data, err = oauth.curl_request( 'POST', - BASE_URL .. '/calendars/' .. url_encode(calendar_id) .. '/events', - auth_headers(access_token), + BASE_URL .. '/calendars/' .. oauth.url_encode(calendar_id) .. '/events', + oauth.auth_headers(access_token), vim.json.encode(event) ) if err then @@ -423,10 +94,14 @@ local function update_event(access_token, calendar_id, event_id, task) start = { date = task.due }, ['end'] = { date = next_day(task.due or '') }, } - local _, err = curl_request( + local _, err = oauth.curl_request( 'PATCH', - BASE_URL .. '/calendars/' .. url_encode(calendar_id) .. '/events/' .. url_encode(event_id), - auth_headers(access_token), + BASE_URL + .. '/calendars/' + .. oauth.url_encode(calendar_id) + .. '/events/' + .. oauth.url_encode(event_id), + oauth.auth_headers(access_token), vim.json.encode(event) ) return err @@ -437,16 +112,24 @@ end ---@param event_id string ---@return string? err local function delete_event(access_token, calendar_id, event_id) - local _, err = curl_request( + local _, err = oauth.curl_request( 'DELETE', - BASE_URL .. '/calendars/' .. url_encode(calendar_id) .. '/events/' .. url_encode(event_id), - auth_headers(access_token) + BASE_URL + .. '/calendars/' + .. oauth.url_encode(calendar_id) + .. '/events/' + .. oauth.url_encode(event_id), + oauth.auth_headers(access_token) ) return err end +function M.auth() + client:auth() +end + function M.sync() - local access_token = get_access_token() + local access_token = client:get_access_token() if not access_token then return end @@ -517,16 +200,7 @@ 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 + oauth.health(M.name) end return M diff --git a/lua/pending/sync/gtasks.lua b/lua/pending/sync/gtasks.lua index 7476ee6..f31de99 100644 --- a/lua/pending/sync/gtasks.lua +++ b/lua/pending/sync/gtasks.lua @@ -1,357 +1,26 @@ local config = require('pending.config') +local oauth = require('pending.sync.oauth') local M = {} M.name = 'gtasks' local BASE_URL = 'https://tasks.googleapis.com/tasks/v1' -local TOKEN_URL = 'https://oauth2.googleapis.com/token' -local AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth' local SCOPE = 'https://www.googleapis.com/auth/tasks' ----@class pending.GtasksCredentials ----@field client_id string ----@field client_secret string ----@field redirect_uris? string[] - ----@class pending.GtasksTokens ----@field access_token string ----@field refresh_token string ----@field expires_in? integer ----@field obtained_at? integer - ----@return table -local function gtasks_config() - local cfg = config.get() - return (cfg.sync and cfg.sync.gtasks) or {} -end - ----@return string -local function token_path() - return vim.fn.stdpath('data') .. '/pending/gtasks_tokens.json' -end - ----@return string -local function credentials_path() - local gc = gtasks_config() - return gc.credentials_path or (vim.fn.stdpath('data') .. '/pending/gtasks_credentials.json') -end - ----@param path string ----@return table? -local function load_json_file(path) - local f = io.open(path, 'r') - if not f then - return nil - end - local content = f:read('*a') - f:close() - if content == '' then - return nil - end - local ok, decoded = pcall(vim.json.decode, content) - if not ok then - return nil - end - return decoded -end - ----@param path string ----@param data table ----@return boolean -local function save_json_file(path, data) - local dir = vim.fn.fnamemodify(path, ':h') - if vim.fn.isdirectory(dir) == 0 then - vim.fn.mkdir(dir, 'p') - end - local f = io.open(path, 'w') - if not f then - return false - end - f:write(vim.json.encode(data)) - f:close() - vim.fn.setfperm(path, 'rw-------') - return true -end - ----@return pending.GtasksCredentials? -local function load_credentials() - local creds = load_json_file(credentials_path()) - if not creds then - return nil - end - if creds.installed then - return creds.installed --[[@as pending.GtasksCredentials]] - end - return creds --[[@as pending.GtasksCredentials]] -end - ----@return pending.GtasksTokens? -local function load_tokens() - return load_json_file(token_path()) --[[@as pending.GtasksTokens?]] -end - ----@param tokens pending.GtasksTokens ----@return boolean -local function save_tokens(tokens) - return save_json_file(token_path(), tokens) -end - ----@param str string ----@return string -local function url_encode(str) - return ( - str:gsub('([^%w%-%.%_%~])', function(c) - return string.format('%%%02X', string.byte(c)) - end) - ) -end - ----@param method string ----@param url string ----@param headers? string[] ----@param body? string ----@return table? result ----@return string? err -local function curl_request(method, url, headers, body) - local args = { 'curl', '-s', '-X', method } - for _, h in ipairs(headers or {}) do - table.insert(args, '-H') - table.insert(args, h) - end - if body then - table.insert(args, '-d') - table.insert(args, body) - end - table.insert(args, url) - local result = vim.system(args, { text = true }):wait() - if result.code ~= 0 then - return nil, 'curl failed: ' .. (result.stderr or '') - end - if not result.stdout or result.stdout == '' then - return {}, nil - end - local ok, decoded = pcall(vim.json.decode, result.stdout) - if not ok then - return nil, 'failed to parse response: ' .. result.stdout - end - if decoded.error then - return nil, 'API error: ' .. (decoded.error.message or vim.json.encode(decoded.error)) - end - return decoded, nil -end - ----@param access_token string ----@return string[] -local function auth_headers(access_token) - return { - 'Authorization: Bearer ' .. access_token, - 'Content-Type: application/json', - } -end - ----@param creds pending.GtasksCredentials ----@param tokens pending.GtasksTokens ----@return pending.GtasksTokens? -local function refresh_access_token(creds, tokens) - local body = 'client_id=' - .. url_encode(creds.client_id) - .. '&client_secret=' - .. url_encode(creds.client_secret) - .. '&grant_type=refresh_token' - .. '&refresh_token=' - .. url_encode(tokens.refresh_token) - local result = vim - .system({ - 'curl', - '-s', - '-X', - 'POST', - '-H', - 'Content-Type: application/x-www-form-urlencoded', - '-d', - body, - TOKEN_URL, - }, { text = true }) - :wait() - if result.code ~= 0 then - return nil - end - local ok, decoded = pcall(vim.json.decode, result.stdout or '') - if not ok or not decoded.access_token then - return nil - end - tokens.access_token = decoded.access_token --[[@as string]] - tokens.expires_in = decoded.expires_in --[[@as integer?]] - tokens.obtained_at = os.time() - save_tokens(tokens) - return tokens -end - ----@return string? -local function get_access_token() - local creds = load_credentials() - if not creds then - vim.notify( - 'pending.nvim: No Google credentials found at ' .. credentials_path(), - vim.log.levels.ERROR - ) - return nil - end - local tokens = load_tokens() - if not tokens or not tokens.refresh_token then - M.auth() - tokens = load_tokens() - if not tokens then - return nil - end - end - local now = os.time() - local obtained = tokens.obtained_at or 0 - local expires = tokens.expires_in or 3600 - if now - obtained > expires - 60 then - tokens = refresh_access_token(creds, tokens) - if not tokens then - vim.notify('pending.nvim: Failed to refresh access token.', vim.log.levels.ERROR) - return nil - end - end - return tokens.access_token -end - -function M.auth() - local creds = load_credentials() - if not creds then - vim.notify( - 'pending.nvim: No Google credentials found at ' .. credentials_path(), - vim.log.levels.ERROR - ) - return - end - - local port = 18393 - local verifier_chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~' - local verifier = {} - math.randomseed(os.time()) - for _ = 1, 64 do - local idx = math.random(1, #verifier_chars) - table.insert(verifier, verifier_chars:sub(idx, idx)) - end - local code_verifier = table.concat(verifier) - - local sha_pipe = vim - .system({ - 'sh', - '-c', - 'printf "%s" "' - .. code_verifier - .. '" | openssl dgst -sha256 -binary | openssl base64 -A | tr "+/" "-_" | tr -d "="', - }, { text = true }) - :wait() - local code_challenge = sha_pipe.stdout or '' - - local auth_url = AUTH_URL - .. '?client_id=' - .. url_encode(creds.client_id) - .. '&redirect_uri=' - .. url_encode('http://127.0.0.1:' .. port) - .. '&response_type=code' - .. '&scope=' - .. url_encode(SCOPE) - .. '&access_type=offline' - .. '&prompt=consent' - .. '&code_challenge=' - .. url_encode(code_challenge) - .. '&code_challenge_method=S256' - - vim.ui.open(auth_url) - vim.notify('pending.nvim: Opening browser for Google authorization...') - - local server = vim.uv.new_tcp() - server:bind('127.0.0.1', port) - server:listen(1, function(err) - if err then - return - end - local client = vim.uv.new_tcp() - server:accept(client) - client:read_start(function(read_err, data) - if read_err or not data then - return - end - local code = data:match('[?&]code=([^&%s]+)') - local response_body = code - and '

Authorization successful

You can close this tab.

' - or '

Authorization failed

' - local http_response = 'HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n' - .. response_body - client:write(http_response, function() - client:shutdown(function() - client:close() - end) - end) - server:close() - if code then - vim.schedule(function() - M._exchange_code(creds, code, code_verifier, port) - end) - end - end) - end) -end - ----@param creds pending.GtasksCredentials ----@param code string ----@param code_verifier string ----@param port integer -function M._exchange_code(creds, code, code_verifier, port) - local body = 'client_id=' - .. url_encode(creds.client_id) - .. '&client_secret=' - .. url_encode(creds.client_secret) - .. '&code=' - .. url_encode(code) - .. '&code_verifier=' - .. url_encode(code_verifier) - .. '&grant_type=authorization_code' - .. '&redirect_uri=' - .. url_encode('http://127.0.0.1:' .. port) - - local result = vim - .system({ - 'curl', - '-s', - '-X', - 'POST', - '-H', - 'Content-Type: application/x-www-form-urlencoded', - '-d', - body, - TOKEN_URL, - }, { text = true }) - :wait() - - if result.code ~= 0 then - vim.notify('pending.nvim: Token exchange failed.', vim.log.levels.ERROR) - return - end - - local ok, decoded = pcall(vim.json.decode, result.stdout or '') - if not ok or not decoded.access_token then - vim.notify('pending.nvim: Invalid token response.', vim.log.levels.ERROR) - return - end - - decoded.obtained_at = os.time() - save_tokens(decoded) - vim.notify('pending.nvim: Google Tasks authorized successfully.') -end +local client = oauth.new({ + name = 'gtasks', + scope = SCOPE, + port = 18393, + config_key = 'gtasks', +}) ---@param access_token string ---@return table? name_to_id ---@return string? err local function get_all_tasklists(access_token) - local data, err = curl_request('GET', BASE_URL .. '/users/@me/lists', auth_headers(access_token)) + local data, err = + oauth.curl_request('GET', BASE_URL .. '/users/@me/lists', oauth.auth_headers(access_token)) if err then return nil, err end @@ -372,8 +41,12 @@ local function find_or_create_tasklist(access_token, name, existing) return existing[name], nil end local body = vim.json.encode({ title = name }) - local created, err = - curl_request('POST', BASE_URL .. '/users/@me/lists', auth_headers(access_token), body) + local created, err = oauth.curl_request( + 'POST', + BASE_URL .. '/users/@me/lists', + oauth.auth_headers(access_token), + body + ) if err then return nil, err end @@ -391,9 +64,9 @@ end local function list_gtasks(access_token, list_id) local url = BASE_URL .. '/lists/' - .. url_encode(list_id) + .. oauth.url_encode(list_id) .. '/tasks?showCompleted=true&showHidden=true' - local data, err = curl_request('GET', url, auth_headers(access_token)) + local data, err = oauth.curl_request('GET', url, oauth.auth_headers(access_token)) if err then return nil, err end @@ -406,10 +79,10 @@ end ---@return string? task_id ---@return string? err local function create_gtask(access_token, list_id, body) - local data, err = curl_request( + local data, err = oauth.curl_request( 'POST', - BASE_URL .. '/lists/' .. url_encode(list_id) .. '/tasks', - auth_headers(access_token), + BASE_URL .. '/lists/' .. oauth.url_encode(list_id) .. '/tasks', + oauth.auth_headers(access_token), vim.json.encode(body) ) if err then @@ -424,10 +97,10 @@ end ---@param body table ---@return string? err local function update_gtask(access_token, list_id, task_id, body) - local _, err = curl_request( + local _, err = oauth.curl_request( 'PATCH', - BASE_URL .. '/lists/' .. url_encode(list_id) .. '/tasks/' .. url_encode(task_id), - auth_headers(access_token), + BASE_URL .. '/lists/' .. oauth.url_encode(list_id) .. '/tasks/' .. oauth.url_encode(task_id), + oauth.auth_headers(access_token), vim.json.encode(body) ) return err @@ -438,10 +111,10 @@ end ---@param task_id string ---@return string? err local function delete_gtask(access_token, list_id, task_id) - local _, err = curl_request( + local _, err = oauth.curl_request( 'DELETE', - BASE_URL .. '/lists/' .. url_encode(list_id) .. '/tasks/' .. url_encode(task_id), - auth_headers(access_token) + BASE_URL .. '/lists/' .. oauth.url_encode(list_id) .. '/tasks/' .. oauth.url_encode(task_id), + oauth.auth_headers(access_token) ) return err end @@ -665,7 +338,7 @@ end ---@return pending.Store? store ---@return string? now_ts local function sync_setup() - local access_token = get_access_token() + local access_token = client:get_access_token() if not access_token then return nil end @@ -679,6 +352,10 @@ local function sync_setup() return access_token, tasklists, s, now_ts end +function M.auth() + client:auth() +end + function M.push() local access_token, tasklists, s, now_ts = sync_setup() if not access_token then @@ -746,17 +423,8 @@ M._gtask_to_fields = gtask_to_fields ---@return nil function M.health() - if vim.fn.executable('curl') == 1 then - vim.health.ok('curl found (required for gtasks sync)') - else - vim.health.warn('curl not found (needed for gtasks sync)') - end - if vim.fn.executable('openssl') == 1 then - vim.health.ok('openssl found (required for gtasks OAuth PKCE)') - else - vim.health.warn('openssl not found (needed for gtasks OAuth)') - end - local tokens = load_tokens() + oauth.health(M.name) + local tokens = client:load_tokens() if tokens and tokens.refresh_token then vim.health.ok('gtasks tokens found') else diff --git a/lua/pending/sync/oauth.lua b/lua/pending/sync/oauth.lua new file mode 100644 index 0000000..7dc5ede --- /dev/null +++ b/lua/pending/sync/oauth.lua @@ -0,0 +1,380 @@ +local config = require('pending.config') + +local TOKEN_URL = 'https://oauth2.googleapis.com/token' +local AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth' + +local BUNDLED_CLIENT_ID = 'PLACEHOLDER' +local BUNDLED_CLIENT_SECRET = 'PLACEHOLDER' + +---@class pending.OAuthCredentials +---@field client_id string +---@field client_secret string + +---@class pending.OAuthTokens +---@field access_token string +---@field refresh_token string +---@field expires_in? integer +---@field obtained_at? integer + +---@class pending.OAuthClient +---@field name string +---@field scope string +---@field port integer +---@field config_key string +local OAuthClient = {} +OAuthClient.__index = OAuthClient + +---@class pending.oauth +local M = {} + +---@param str string +---@return string +function M.url_encode(str) + return ( + str:gsub('([^%w%-%.%_%~])', function(c) + return string.format('%%%02X', string.byte(c)) + end) + ) +end + +---@param path string +---@return table? +function M.load_json_file(path) + local f = io.open(path, 'r') + if not f then + return nil + end + local content = f:read('*a') + f:close() + if content == '' then + return nil + end + local ok, decoded = pcall(vim.json.decode, content) + if not ok then + return nil + end + return decoded +end + +---@param path string +---@param data table +---@return boolean +function M.save_json_file(path, data) + local dir = vim.fn.fnamemodify(path, ':h') + if vim.fn.isdirectory(dir) == 0 then + vim.fn.mkdir(dir, 'p') + end + local f = io.open(path, 'w') + if not f then + return false + end + f:write(vim.json.encode(data)) + f:close() + vim.fn.setfperm(path, 'rw-------') + return true +end + +---@param method string +---@param url string +---@param headers? string[] +---@param body? string +---@return table? result +---@return string? err +function M.curl_request(method, url, headers, body) + local args = { 'curl', '-s', '-X', method } + for _, h in ipairs(headers or {}) do + table.insert(args, '-H') + table.insert(args, h) + end + if body then + table.insert(args, '-d') + table.insert(args, body) + end + table.insert(args, url) + local result = vim.system(args, { text = true }):wait() + if result.code ~= 0 then + return nil, 'curl failed: ' .. (result.stderr or '') + end + if not result.stdout or result.stdout == '' then + return {}, nil + end + local ok, decoded = pcall(vim.json.decode, result.stdout) + if not ok then + return nil, 'failed to parse response: ' .. result.stdout + end + if decoded.error then + return nil, 'API error: ' .. (decoded.error.message or vim.json.encode(decoded.error)) + end + return decoded, nil +end + +---@param access_token string +---@return string[] +function M.auth_headers(access_token) + return { + 'Authorization: Bearer ' .. access_token, + 'Content-Type: application/json', + } +end + +---@param backend_name string +---@return nil +function M.health(backend_name) + if vim.fn.executable('curl') == 1 then + vim.health.ok('curl found (required for ' .. backend_name .. ' sync)') + else + vim.health.warn('curl not found (needed for ' .. backend_name .. ' sync)') + end + if vim.fn.executable('openssl') == 1 then + vim.health.ok('openssl found (required for ' .. backend_name .. ' OAuth PKCE)') + else + vim.health.warn('openssl not found (needed for ' .. backend_name .. ' OAuth)') + end +end + +---@return string +function OAuthClient:token_path() + return vim.fn.stdpath('data') .. '/pending/' .. self.name .. '_tokens.json' +end + +---@return pending.OAuthCredentials +function OAuthClient:resolve_credentials() + local cfg = config.get() + local backend_cfg = (cfg.sync and cfg.sync[self.config_key]) or {} + + if backend_cfg.client_id and backend_cfg.client_secret then + return { + client_id = backend_cfg.client_id, + client_secret = backend_cfg.client_secret, + } + end + + local cred_path = backend_cfg.credentials_path + or (vim.fn.stdpath('data') .. '/pending/' .. self.name .. '_credentials.json') + local creds = M.load_json_file(cred_path) + if creds then + if creds.installed then + creds = creds.installed + end + if creds.client_id and creds.client_secret then + return creds --[[@as pending.OAuthCredentials]] + end + end + + return { + client_id = BUNDLED_CLIENT_ID, + client_secret = BUNDLED_CLIENT_SECRET, + } +end + +---@return pending.OAuthTokens? +function OAuthClient:load_tokens() + return M.load_json_file(self:token_path()) --[[@as pending.OAuthTokens?]] +end + +---@param tokens pending.OAuthTokens +---@return boolean +function OAuthClient:save_tokens(tokens) + return M.save_json_file(self:token_path(), tokens) +end + +---@param creds pending.OAuthCredentials +---@param tokens pending.OAuthTokens +---@return pending.OAuthTokens? +function OAuthClient:refresh_access_token(creds, tokens) + local body = 'client_id=' + .. M.url_encode(creds.client_id) + .. '&client_secret=' + .. M.url_encode(creds.client_secret) + .. '&grant_type=refresh_token' + .. '&refresh_token=' + .. M.url_encode(tokens.refresh_token) + local result = vim + .system({ + 'curl', + '-s', + '-X', + 'POST', + '-H', + 'Content-Type: application/x-www-form-urlencoded', + '-d', + body, + TOKEN_URL, + }, { text = true }) + :wait() + if result.code ~= 0 then + return nil + end + local ok, decoded = pcall(vim.json.decode, result.stdout or '') + if not ok or not decoded.access_token then + return nil + end + tokens.access_token = decoded.access_token --[[@as string]] + tokens.expires_in = decoded.expires_in --[[@as integer?]] + tokens.obtained_at = os.time() + self:save_tokens(tokens) + return tokens +end + +---@return string? +function OAuthClient:get_access_token() + local creds = self:resolve_credentials() + local tokens = self:load_tokens() + if not tokens or not tokens.refresh_token then + self:auth() + tokens = self:load_tokens() + if not tokens then + return nil + end + end + local now = os.time() + local obtained = tokens.obtained_at or 0 + local expires = tokens.expires_in or 3600 + if now - obtained > expires - 60 then + tokens = self:refresh_access_token(creds, tokens) + if not tokens then + vim.notify('pending.nvim: Failed to refresh access token.', vim.log.levels.ERROR) + return nil + end + end + return tokens.access_token +end + +---@return nil +function OAuthClient:auth() + local creds = self:resolve_credentials() + local port = self.port + + local verifier_chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~' + local verifier = {} + math.randomseed(os.time()) + for _ = 1, 64 do + local idx = math.random(1, #verifier_chars) + table.insert(verifier, verifier_chars:sub(idx, idx)) + end + local code_verifier = table.concat(verifier) + + local sha_pipe = vim + .system({ + 'sh', + '-c', + 'printf "%s" "' + .. code_verifier + .. '" | openssl dgst -sha256 -binary | openssl base64 -A | tr "+/" "-_" | tr -d "="', + }, { text = true }) + :wait() + local code_challenge = sha_pipe.stdout or '' + + local auth_url = AUTH_URL + .. '?client_id=' + .. M.url_encode(creds.client_id) + .. '&redirect_uri=' + .. M.url_encode('http://127.0.0.1:' .. port) + .. '&response_type=code' + .. '&scope=' + .. M.url_encode(self.scope) + .. '&access_type=offline' + .. '&prompt=consent' + .. '&code_challenge=' + .. M.url_encode(code_challenge) + .. '&code_challenge_method=S256' + + vim.ui.open(auth_url) + vim.notify('pending.nvim: Opening browser for Google authorization...') + + local server = vim.uv.new_tcp() + server:bind('127.0.0.1', port) + server:listen(1, function(err) + if err then + return + end + local conn = vim.uv.new_tcp() + server:accept(conn) + conn:read_start(function(read_err, data) + if read_err or not data then + return + end + local code = data:match('[?&]code=([^&%s]+)') + local response_body = code + and '

Authorization successful

You can close this tab.

' + or '

Authorization failed

' + local http_response = 'HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n' + .. response_body + conn:write(http_response, function() + conn:shutdown(function() + conn:close() + end) + end) + server:close() + if code then + vim.schedule(function() + self:_exchange_code(creds, code, code_verifier, port) + end) + end + end) + end) +end + +---@param creds pending.OAuthCredentials +---@param code string +---@param code_verifier string +---@param port integer +---@return nil +function OAuthClient:_exchange_code(creds, code, code_verifier, port) + local body = 'client_id=' + .. M.url_encode(creds.client_id) + .. '&client_secret=' + .. M.url_encode(creds.client_secret) + .. '&code=' + .. M.url_encode(code) + .. '&code_verifier=' + .. M.url_encode(code_verifier) + .. '&grant_type=authorization_code' + .. '&redirect_uri=' + .. M.url_encode('http://127.0.0.1:' .. port) + + local result = vim + .system({ + 'curl', + '-s', + '-X', + 'POST', + '-H', + 'Content-Type: application/x-www-form-urlencoded', + '-d', + body, + TOKEN_URL, + }, { text = true }) + :wait() + + if result.code ~= 0 then + vim.notify('pending.nvim: Token exchange failed.', vim.log.levels.ERROR) + return + end + + local ok, decoded = pcall(vim.json.decode, result.stdout or '') + if not ok or not decoded.access_token then + vim.notify('pending.nvim: Invalid token response.', vim.log.levels.ERROR) + return + end + + decoded.obtained_at = os.time() + self:save_tokens(decoded) + vim.notify('pending.nvim: ' .. self.name .. ' authorized successfully.') +end + +---@param opts { name: string, scope: string, port: integer, config_key: string } +---@return pending.OAuthClient +function M.new(opts) + return setmetatable({ + name = opts.name, + scope = opts.scope, + port = opts.port, + config_key = opts.config_key, + }, OAuthClient) +end + +M._BUNDLED_CLIENT_ID = BUNDLED_CLIENT_ID +M._BUNDLED_CLIENT_SECRET = BUNDLED_CLIENT_SECRET + +return M diff --git a/spec/oauth_spec.lua b/spec/oauth_spec.lua new file mode 100644 index 0000000..520227d --- /dev/null +++ b/spec/oauth_spec.lua @@ -0,0 +1,230 @@ +require('spec.helpers') + +local config = require('pending.config') +local oauth = require('pending.sync.oauth') + +describe('oauth', 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() + end) + + after_each(function() + vim.fn.delete(tmpdir, 'rf') + vim.g.pending = nil + config.reset() + end) + + describe('url_encode', function() + it('leaves alphanumerics unchanged', function() + assert.equals('hello123', oauth.url_encode('hello123')) + end) + + it('encodes spaces', function() + assert.equals('hello%20world', oauth.url_encode('hello world')) + end) + + it('encodes special characters', function() + assert.equals('a%3Db%26c', oauth.url_encode('a=b&c')) + end) + + it('preserves hyphens, dots, underscores, tildes', function() + assert.equals('a-b.c_d~e', oauth.url_encode('a-b.c_d~e')) + end) + end) + + describe('load_json_file', function() + it('returns nil for missing file', function() + assert.is_nil(oauth.load_json_file(tmpdir .. '/nonexistent.json')) + end) + + it('returns nil for empty file', function() + local path = tmpdir .. '/empty.json' + local f = io.open(path, 'w') + f:write('') + f:close() + assert.is_nil(oauth.load_json_file(path)) + end) + + it('returns nil for invalid JSON', function() + local path = tmpdir .. '/bad.json' + local f = io.open(path, 'w') + f:write('not json') + f:close() + assert.is_nil(oauth.load_json_file(path)) + end) + + it('parses valid JSON', function() + local path = tmpdir .. '/good.json' + local f = io.open(path, 'w') + f:write('{"key":"value"}') + f:close() + local data = oauth.load_json_file(path) + assert.equals('value', data.key) + end) + end) + + describe('save_json_file', function() + it('creates parent directories', function() + local path = tmpdir .. '/sub/dir/file.json' + local ok = oauth.save_json_file(path, { test = true }) + assert.is_true(ok) + local data = oauth.load_json_file(path) + assert.is_true(data.test) + end) + + it('sets restrictive permissions', function() + local path = tmpdir .. '/secret.json' + oauth.save_json_file(path, { x = 1 }) + local perms = vim.fn.getfperm(path) + assert.equals('rw-------', perms) + end) + end) + + describe('resolve_credentials', function() + it('uses config fields when set', function() + config.reset() + vim.g.pending = { + data_path = tmpdir .. '/tasks.json', + sync = { + gtasks = { + client_id = 'config-id', + client_secret = 'config-secret', + }, + }, + } + local c = oauth.new({ name = 'gtasks', scope = 'x', port = 0, config_key = 'gtasks' }) + local creds = c:resolve_credentials() + assert.equals('config-id', creds.client_id) + assert.equals('config-secret', creds.client_secret) + end) + + it('uses credentials file when config fields absent', function() + local cred_path = tmpdir .. '/creds.json' + oauth.save_json_file(cred_path, { + client_id = 'file-id', + client_secret = 'file-secret', + }) + config.reset() + vim.g.pending = { + data_path = tmpdir .. '/tasks.json', + sync = { gtasks = { credentials_path = cred_path } }, + } + local c = oauth.new({ name = 'gtasks', scope = 'x', port = 0, config_key = 'gtasks' }) + local creds = c:resolve_credentials() + assert.equals('file-id', creds.client_id) + assert.equals('file-secret', creds.client_secret) + end) + + it('unwraps installed wrapper format', function() + local cred_path = tmpdir .. '/wrapped.json' + oauth.save_json_file(cred_path, { + installed = { + client_id = 'wrapped-id', + client_secret = 'wrapped-secret', + }, + }) + config.reset() + vim.g.pending = { + data_path = tmpdir .. '/tasks.json', + sync = { gcal = { credentials_path = cred_path } }, + } + local c = oauth.new({ name = 'gcal', scope = 'x', port = 0, config_key = 'gcal' }) + local creds = c:resolve_credentials() + assert.equals('wrapped-id', creds.client_id) + assert.equals('wrapped-secret', creds.client_secret) + end) + + it('falls back to bundled credentials', function() + config.reset() + vim.g.pending = { data_path = tmpdir .. '/tasks.json' } + local c = oauth.new({ name = 'gtasks', scope = 'x', port = 0, config_key = 'gtasks' }) + local creds = c:resolve_credentials() + assert.equals(oauth._BUNDLED_CLIENT_ID, creds.client_id) + assert.equals(oauth._BUNDLED_CLIENT_SECRET, creds.client_secret) + end) + + it('prefers config fields over credentials file', function() + local cred_path = tmpdir .. '/creds2.json' + oauth.save_json_file(cred_path, { + client_id = 'file-id', + client_secret = 'file-secret', + }) + config.reset() + vim.g.pending = { + data_path = tmpdir .. '/tasks.json', + sync = { + gtasks = { + credentials_path = cred_path, + client_id = 'config-id', + client_secret = 'config-secret', + }, + }, + } + local c = oauth.new({ name = 'gtasks', scope = 'x', port = 0, config_key = 'gtasks' }) + local creds = c:resolve_credentials() + assert.equals('config-id', creds.client_id) + assert.equals('config-secret', creds.client_secret) + end) + end) + + describe('token_path', function() + it('includes backend name', function() + local c = oauth.new({ name = 'gtasks', scope = 'x', port = 0, config_key = 'gtasks' }) + assert.truthy(c:token_path():match('gtasks_tokens%.json$')) + end) + + it('differs between backends', function() + local g = oauth.new({ name = 'gcal', scope = 'x', port = 0, config_key = 'gcal' }) + local t = oauth.new({ name = 'gtasks', scope = 'x', port = 0, config_key = 'gtasks' }) + assert.not_equals(g:token_path(), t:token_path()) + end) + end) + + describe('load_tokens / save_tokens', function() + it('round-trips tokens', function() + local c = oauth.new({ name = 'test', scope = 'x', port = 0, config_key = 'gtasks' }) + local path = c:token_path() + local dir = vim.fn.fnamemodify(path, ':h') + vim.fn.mkdir(dir, 'p') + local tokens = { + access_token = 'at', + refresh_token = 'rt', + expires_in = 3600, + obtained_at = 1000, + } + c:save_tokens(tokens) + local loaded = c:load_tokens() + assert.equals('at', loaded.access_token) + assert.equals('rt', loaded.refresh_token) + vim.fn.delete(dir, 'rf') + end) + end) + + describe('auth_headers', function() + it('includes bearer token', function() + local headers = oauth.auth_headers('mytoken') + assert.equals('Authorization: Bearer mytoken', headers[1]) + assert.equals('Content-Type: application/json', headers[2]) + end) + end) + + describe('new', function() + it('creates client with correct fields', function() + local c = oauth.new({ + name = 'test', + scope = 'https://example.com', + port = 12345, + config_key = 'test', + }) + assert.equals('test', c.name) + assert.equals('https://example.com', c.scope) + assert.equals(12345, c.port) + assert.equals('test', c.config_key) + end) + end) +end) From b7ce1c05ec20efbc8412078fe723e944f185eb16 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:50:13 -0500 Subject: [PATCH 31/66] fix: harden sync backends and fix edit recompute (#66) * refactor(oauth): async coroutine support, pure-Lua PKCE, server hardening Problem: OAuth module shelled out to openssl for PKCE, used blocking `vim.system():wait()`, had a weak `os.time()` PRNG seed, and the TCP callback server leaked on read errors with no timeout. Solution: Add `M.system()` coroutine wrapper and `M.async()` helper, replace openssl with `vim.fn.sha256` + `vim.base64.encode`, seed from `vim.uv.hrtime()`, add `close_server()` guard with 120s timeout, and close the server on read errors. * fix(gtasks): async operations, error notifications, buffer refresh Problem: Sync operations blocked the editor, `push_pass` silently dropped delete/update/create API errors, and the buffer was not re-rendered after push/pull/sync. Solution: Wrap `push`, `pull`, `sync` in `oauth.async()`, add `vim.notify` for all `push_pass` failure paths, and re-render the pending buffer after each operation. * fix(init): edit recompute, filter predicates, sync action listing Problem: `M.edit()` skipped `_recompute_counts()` after saving, `compute_hidden_ids` lacked `done`/`pending` predicates, and `run_sync` defaulted to `sync` instead of listing available actions. Solution: Replace `s:save()` with `_save_and_notify()` in `M.edit()`, add `done` and `pending` filter predicates, and list backend actions when no action is specified. * refactor(gcal): per-category calendars, async push, error notifications Problem: gcal used a single hardcoded calendar name, ran synchronously blocking the editor, and silently dropped some API errors. Solution: Fetch all calendars and map categories to calendars (creating on demand), wrap push in `oauth.async()`, notify on individual API failures, track `_gcal_calendar_id` in `_extra`, and remove the `$` anchor from `next_day` pattern. * refactor: formatting fixes, config cleanup, health simplification Problem: Formatter disagreements in `init.lua` and `gtasks.lua`, stale `calendar` field in gcal config, and redundant health checks for data directory existence. Solution: Apply stylua formatting, remove `calendar` field from `pending.GcalConfig`, drop data-dir and no-file health messages, add `done`/`pending` to filter tab-completion candidates. * docs: update vimdoc for sync refactor, remove demo scripts Problem: Docs still referenced openssl dependency, defaulting to `sync` action, and the `calendar` config field. Demo scripts used the old singleton `store` API. Solution: Update vimdoc and README to reflect explicit actions, per- category calendars, and pure-Lua PKCE. Remove stale demo scripts and update sync specs to match new behavior. * fix(types): correct LuaLS annotations in oauth and gcal --- .gitignore | 1 + README.md | 2 +- doc/pending.txt | 57 +++++------- lua/pending/config.lua | 1 - lua/pending/health.lua | 9 -- lua/pending/init.lua | 26 +++++- lua/pending/sync/gcal.lua | 181 ++++++++++++++++++++++-------------- lua/pending/sync/gtasks.lua | 126 +++++++++++++++---------- lua/pending/sync/oauth.lua | 113 +++++++++++++--------- plugin/pending.lua | 2 +- scripts/demo-init.lua | 30 ------ scripts/demo.tape | 28 ------ spec/sync_spec.lua | 34 +++---- 13 files changed, 319 insertions(+), 291 deletions(-) delete mode 100644 scripts/demo-init.lua delete mode 100644 scripts/demo.tape diff --git a/.gitignore b/.gitignore index 93ac2c5..7cdfb66 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ doc/tags *.log +minimal_init.lua .*cache* CLAUDE.md diff --git a/README.md b/README.md index cb3d3eb..43c8447 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Edit tasks like text. `:w` saves them. ## Requirements - Neovim 0.10+ -- (Optionally) `curl` and `openssl` for Google Calendar and Google Task sync +- (Optionally) `curl` for Google Calendar and Google Task sync ## Installation diff --git a/doc/pending.txt b/doc/pending.txt index d3eb03b..914644e 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -73,7 +73,7 @@ REQUIREMENTS *pending-requirements* - Neovim 0.10+ - No external dependencies for local use -- `curl` and `openssl` are required for the `gcal` and `gtasks` sync backends +- `curl` is required for the `gcal` and `gtasks` sync backends ============================================================================== INSTALL *pending-install* @@ -149,17 +149,17 @@ COMMANDS *pending-commands* Open the list with |:copen| to navigate to each task's category. *:Pending-gtasks* -:Pending gtasks [{action}] - Run a Google Tasks sync action. {action} defaults to `sync` when omitted. +:Pending gtasks {action} + Run a Google Tasks action. An explicit action is required. Actions: ~ - `sync` Push local changes then pull remote changes (default). + `sync` Push local changes then pull remote changes. `push` Push local changes to Google Tasks only. `pull` Pull remote changes from Google Tasks only. `auth` Run the OAuth authorization flow. Examples: >vim - :Pending gtasks " push then pull (default) + :Pending gtasks sync " push then pull :Pending gtasks push " push local → Google Tasks :Pending gtasks pull " pull Google Tasks → local :Pending gtasks auth " authorize @@ -169,16 +169,15 @@ COMMANDS *pending-commands* See |pending-gtasks| for full details. *:Pending-gcal* -:Pending gcal [{action}] - Run a Google Calendar sync action. {action} defaults to `sync` when - omitted. +:Pending gcal {action} + Run a Google Calendar action. An explicit action is required. Actions: ~ - `sync` Push tasks with due dates to Google Calendar (default). + `push` Push tasks with due dates to Google Calendar. `auth` Run the OAuth authorization flow. Examples: >vim - :Pending gcal " push to Google Calendar (default) + :Pending gcal push " push to Google Calendar :Pending gcal auth " authorize < @@ -606,9 +605,7 @@ loads: >lua prev_task = '[t', }, sync = { - gcal = { - calendar = 'Pendings', - }, + gcal = {}, gtasks = {}, }, } @@ -893,8 +890,8 @@ SYNC BACKENDS *pending-sync-backend* Sync backends are Lua modules under `lua/pending/sync/.lua`. Each backend is exposed as a top-level `:Pending` subcommand: >vim - :Pending gtasks [action] - :Pending gcal [action] + :Pending gtasks {action} + :Pending gcal {action} < Each module returns a table conforming to the backend interface: >lua @@ -902,9 +899,9 @@ 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 push? fun(): nil ---@field pull? fun(): nil + ---@field sync? fun(): nil ---@field health? fun(): nil < @@ -924,16 +921,15 @@ Backend-specific configuration goes under `sync.` in |pending-config|. ============================================================================== GOOGLE CALENDAR *pending-gcal* -pending.nvim can push tasks with due dates to a dedicated Google Calendar as -all-day events. This is a one-way push; changes made in Google Calendar are -not pulled back into pending.nvim. +pending.nvim can push tasks with due dates to Google Calendar as all-day +events. Each pending.nvim category maps to a Google Calendar of the same +name. Calendars are created automatically on first push. This is a one-way +push; changes made in Google Calendar are not pulled back. Configuration: >lua vim.g.pending = { sync = { - gcal = { - calendar = 'Pendings', - }, + gcal = {}, }, } < @@ -943,11 +939,6 @@ used by default. Run `:Pending gcal auth` and the browser opens immediately. *pending.GcalConfig* Fields: ~ - {calendar} (string, default: 'Pendings') - Name of the Google Calendar to sync to. If a calendar - with this name does not exist it is created - automatically on the first sync. - {client_id} (string, optional) OAuth client ID. When set together with {client_secret}, these take priority over the @@ -973,26 +964,24 @@ OAuth flow: ~ On the first `:Pending 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 — -`openssl` generates the code challenge. After the user grants consent, the +OAuth redirect. The PKCE (Proof Key for Code Exchange) flow is used. After +the user grants consent, the authorization code is exchanged for tokens and the refresh token is stored at `stdpath('data')/pending/gcal_tokens.json` with mode `600`. Subsequent syncs use the stored refresh token and refresh the access token automatically when it is about to expire. -`:Pending gcal` behavior: ~ +`:Pending gcal push` 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. + created in the calendar matching the task's category. The event ID and + calendar ID are stored in the task's `_extra` table. - A pending task with a due date and an existing event: the event summary and date are updated in place. - A done or deleted task with an existing event: the event is deleted. - A pending task with no due date that had an existing event: the event is deleted. -A summary notification is shown after sync: `created: N, updated: N, -deleted: N`. - ============================================================================== GOOGLE TASKS *pending-gtasks* diff --git a/lua/pending/config.lua b/lua/pending/config.lua index 263cc8c..f488e41 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -7,7 +7,6 @@ ---@field category string ---@class pending.GcalConfig ----@field calendar? string ---@field credentials_path? string ---@field client_id? string ---@field client_secret? string diff --git a/lua/pending/health.lua b/lua/pending/health.lua index 0f1bad8..d3dbe2c 100644 --- a/lua/pending/health.lua +++ b/lua/pending/health.lua @@ -25,13 +25,6 @@ function M.check() vim.health.info('(project-local store; global path: ' .. cfg.data_path .. ')') end - local data_dir = vim.fn.fnamemodify(resolved_path, ':h') - if vim.fn.isdirectory(data_dir) == 1 then - vim.health.ok('Data directory exists: ' .. data_dir) - else - vim.health.warn('Data directory does not exist yet: ' .. data_dir) - end - if vim.fn.filereadable(resolved_path) == 1 then local s = store.new(resolved_path) local load_ok, err = pcall(function() @@ -54,8 +47,6 @@ function M.check() else vim.health.error('Failed to load data file: ' .. tostring(err)) end - else - vim.health.info('No data file yet (will be created on first save)') end local sync_paths = vim.fn.globpath(vim.o.runtimepath, 'lua/pending/sync/*.lua', false, true) diff --git a/lua/pending/init.lua b/lua/pending/init.lua index f4f7264..a83692d 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -142,6 +142,16 @@ local function compute_hidden_ids(tasks, predicates) visible = false break end + elseif pred == 'done' then + if task.status ~= 'done' then + visible = false + break + end + elseif pred == 'pending' then + if task.status ~= 'pending' then + visible = false + break + end end end if not visible then @@ -536,12 +546,22 @@ end ---@param action? string ---@return nil local function run_sync(backend_name, action) - 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 not action or action == '' then + local actions = {} + for k, v in pairs(backend) do + if type(v) == 'function' and k:sub(1, 1) ~= '_' then + table.insert(actions, k) + end + end + table.sort(actions) + vim.notify(backend_name .. ' actions: ' .. table.concat(actions, ', '), vim.log.levels.INFO) + return + end if type(backend[action]) ~= 'function' then vim.notify(backend_name .. " backend has no '" .. action .. "' action", vim.log.levels.ERROR) return @@ -804,7 +824,7 @@ function M.edit(id_str, rest) s:update(id, updates) - s:save() + _save_and_notify() local bufnr = buffer.bufnr() if bufnr and vim.api.nvim_buf_is_valid(bufnr) then @@ -841,7 +861,7 @@ function M.command(args) local id_str, edit_rest = rest:match('^(%S+)%s*(.*)') M.edit(id_str, edit_rest) elseif SYNC_BACKEND_SET[cmd] then - local action = rest:match('^(%S+)') or 'sync' + local action = rest:match('^(%S+)') run_sync(cmd, action) elseif cmd == 'archive' then local d = rest ~= '' and tonumber(rest) or nil diff --git a/lua/pending/sync/gcal.lua b/lua/pending/sync/gcal.lua index 9158ca1..44f7742 100644 --- a/lua/pending/sync/gcal.lua +++ b/lua/pending/sync/gcal.lua @@ -15,13 +15,10 @@ local client = oauth.new({ config_key = 'gcal', }) ----@return string? calendar_id +---@param access_token string +---@return table? name_to_id ---@return string? err -local function find_or_create_calendar(access_token) - local cfg = config.get() - local gc = (cfg.sync and cfg.sync.gcal) or {} - local cal_name = gc.calendar or 'Pendings' - +local function get_all_calendars(access_token) local data, err = oauth.curl_request( 'GET', BASE_URL .. '/users/me/calendarList', @@ -30,27 +27,41 @@ local function find_or_create_calendar(access_token) if err then return nil, err end - + local result = {} for _, item in ipairs(data and data.items or {}) do - if item.summary == cal_name then - return item.id, nil + if item.summary then + result[item.summary] = item.id end end + return result, nil +end - local body = vim.json.encode({ summary = cal_name }) - local created, create_err = - oauth.curl_request('POST', BASE_URL .. '/calendars', oauth.auth_headers(access_token), body) - if create_err then - return nil, create_err +---@param access_token string +---@param name string +---@param existing table +---@return string? calendar_id +---@return string? err +local function find_or_create_calendar(access_token, name, existing) + if existing[name] then + return existing[name], nil end - - return created and created.id, nil + local body = vim.json.encode({ summary = name }) + local created, err = + oauth.curl_request('POST', BASE_URL .. '/calendars', oauth.auth_headers(access_token), body) + if err then + return nil, err + end + local id = created and created.id + if id then + existing[name] = id + end + return id, nil end ---@param date_str string ---@return string local function next_day(date_str) - local y, m, d = date_str:match('^(%d%d%d%d)-(%d%d)-(%d%d)$') + local y, m, d = date_str:match('^(%d%d%d%d)-(%d%d)-(%d%d)') local t = os.time({ year = tonumber(y) or 0, month = tonumber(m) or 0, day = tonumber(d) or 0 }) + 86400 return os.date('%Y-%m-%d', t) --[[@as string]] @@ -128,74 +139,100 @@ function M.auth() client:auth() end -function M.sync() - local access_token = client:get_access_token() - if not access_token then - return - end +function M.push() + oauth.async(function() + local access_token = client:get_access_token() + if not access_token then + return + end - local calendar_id, err = find_or_create_calendar(access_token) - if err or not calendar_id then - vim.notify('pending.nvim: ' .. (err or 'calendar not found'), vim.log.levels.ERROR) - return - end + local calendars, cal_err = get_all_calendars(access_token) + if cal_err or not calendars then + vim.notify('pending.nvim: ' .. (cal_err or 'failed to fetch calendars'), vim.log.levels.ERROR) + return + end - local tasks = require('pending').store():tasks() - local created, updated, deleted = 0, 0, 0 + local s = require('pending').store() + local created, updated, deleted = 0, 0, 0 - for _, task in ipairs(tasks) do - local extra = task._extra or {} - local event_id = extra['_gcal_event_id'] --[[@as string?]] + for _, task in ipairs(s:tasks()) do + local extra = task._extra or {} + local event_id = extra['_gcal_event_id'] --[[@as string?]] + local cal_id = extra['_gcal_calendar_id'] --[[@as string?]] - local should_delete = event_id ~= nil - and ( - task.status == 'done' - or task.status == 'deleted' - or (task.status == 'pending' and not task.due) - ) + local should_delete = event_id ~= nil + and cal_id ~= nil + and ( + task.status == 'done' + or task.status == 'deleted' + or (task.status == 'pending' and not task.due) + ) - if should_delete and event_id then - local del_err = delete_event(access_token, calendar_id, event_id) --[[@as string]] - if not del_err then - extra['_gcal_event_id'] = nil - if next(extra) == nil then - task._extra = nil + if should_delete then + local del_err = + delete_event(access_token, cal_id --[[@as string]], event_id --[[@as string]]) + if del_err then + vim.notify('pending.nvim: gcal delete failed: ' .. del_err, vim.log.levels.WARN) else - task._extra = extra - end - task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]] - deleted = deleted + 1 - end - elseif task.status == 'pending' and task.due then - if event_id then - local upd_err = update_event(access_token, calendar_id, event_id, task) - if not upd_err then - updated = updated + 1 - end - else - local new_id, create_err = create_event(access_token, calendar_id, task) - if not create_err and new_id then - if not task._extra then - task._extra = {} + extra['_gcal_event_id'] = nil + extra['_gcal_calendar_id'] = nil + if next(extra) == nil then + task._extra = nil + else + task._extra = extra end - task._extra['_gcal_event_id'] = new_id task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]] - created = created + 1 + deleted = deleted + 1 + end + elseif task.status == 'pending' and task.due then + local cat = task.category or config.get().default_category + if event_id and cal_id then + local upd_err = update_event(access_token, cal_id, event_id, task) + if upd_err then + vim.notify('pending.nvim: gcal update failed: ' .. upd_err, vim.log.levels.WARN) + else + updated = updated + 1 + end + else + local lid, lid_err = find_or_create_calendar(access_token, cat, calendars) + if lid_err or not lid then + vim.notify( + 'pending.nvim: gcal calendar failed: ' .. (lid_err or 'unknown'), + vim.log.levels.WARN + ) + else + local new_id, create_err = create_event(access_token, lid, task) + if create_err then + vim.notify('pending.nvim: gcal create failed: ' .. create_err, vim.log.levels.WARN) + elseif new_id then + if not task._extra then + task._extra = {} + end + task._extra['_gcal_event_id'] = new_id + task._extra['_gcal_calendar_id'] = lid + task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]] + created = created + 1 + end + end end end end - end - require('pending').store():save() - require('pending')._recompute_counts() - vim.notify( - string.format( - 'pending.nvim: Synced to Google Calendar (created: %d, updated: %d, deleted: %d)', - created, - updated, - deleted + s:save() + require('pending')._recompute_counts() + local buffer = require('pending.buffer') + if buffer.bufnr() and vim.api.nvim_buf_is_valid(buffer.bufnr()) then + buffer.render(buffer.bufnr()) + end + vim.notify( + string.format( + 'pending.nvim: Google Calendar pushed — +%d ~%d -%d', + created, + updated, + deleted + ) ) - ) + end) end ---@return nil diff --git a/lua/pending/sync/gtasks.lua b/lua/pending/sync/gtasks.lua index f31de99..a046a51 100644 --- a/lua/pending/sync/gtasks.lua +++ b/lua/pending/sync/gtasks.lua @@ -247,7 +247,9 @@ local function push_pass(access_token, tasklists, s, now_ts, by_gtasks_id) if task.status == 'deleted' and gtid and list_id then local err = delete_gtask(access_token, list_id, gtid) - if not err then + if err then + vim.notify('pending.nvim: gtasks delete failed: ' .. err, vim.log.levels.WARN) + else if not task._extra then task._extra = {} end @@ -262,7 +264,9 @@ local function push_pass(access_token, tasklists, s, now_ts, by_gtasks_id) elseif task.status ~= 'deleted' then if gtid and list_id then local err = update_gtask(access_token, list_id, gtid, task_to_gtask(task)) - if not err then + if err then + vim.notify('pending.nvim: gtasks update failed: ' .. err, vim.log.levels.WARN) + else updated = updated + 1 end elseif task.status == 'pending' then @@ -270,7 +274,9 @@ local function push_pass(access_token, tasklists, s, now_ts, by_gtasks_id) local lid, err = find_or_create_tasklist(access_token, cat, tasklists) if not err and lid then local new_id, create_err = create_gtask(access_token, lid, task_to_gtask(task)) - if not create_err and new_id then + if create_err then + vim.notify('pending.nvim: gtasks create failed: ' .. create_err, vim.log.levels.WARN) + elseif new_id then if not task._extra then task._extra = {} end @@ -357,61 +363,79 @@ function M.auth() end function M.push() - local access_token, tasklists, s, now_ts = sync_setup() - if not access_token then - return - end - ---@cast tasklists table - ---@cast s pending.Store - ---@cast now_ts string - local by_gtasks_id = build_id_index(s) - local created, updated, deleted = push_pass(access_token, tasklists, s, now_ts, by_gtasks_id) - s:save() - require('pending')._recompute_counts() - vim.notify( - string.format('pending.nvim: Google Tasks pushed — +%d ~%d -%d', created, updated, deleted) - ) + oauth.async(function() + local access_token, tasklists, s, now_ts = sync_setup() + if not access_token then + return + end + ---@cast tasklists table + ---@cast s pending.Store + ---@cast now_ts string + local by_gtasks_id = build_id_index(s) + local created, updated, deleted = push_pass(access_token, tasklists, s, now_ts, by_gtasks_id) + s:save() + require('pending')._recompute_counts() + local buffer = require('pending.buffer') + if buffer.bufnr() and vim.api.nvim_buf_is_valid(buffer.bufnr()) then + buffer.render(buffer.bufnr()) + end + vim.notify( + string.format('pending.nvim: Google Tasks pushed — +%d ~%d -%d', created, updated, deleted) + ) + end) end function M.pull() - local access_token, tasklists, s, now_ts = sync_setup() - if not access_token then - return - end - ---@cast tasklists table - ---@cast s pending.Store - ---@cast now_ts string - local by_gtasks_id = build_id_index(s) - local created, updated = pull_pass(access_token, tasklists, s, now_ts, by_gtasks_id) - s:save() - require('pending')._recompute_counts() - vim.notify(string.format('pending.nvim: Google Tasks pulled — +%d ~%d', created, updated)) + oauth.async(function() + local access_token, tasklists, s, now_ts = sync_setup() + if not access_token then + return + end + ---@cast tasklists table + ---@cast s pending.Store + ---@cast now_ts string + local by_gtasks_id = build_id_index(s) + local created, updated = pull_pass(access_token, tasklists, s, now_ts, by_gtasks_id) + s:save() + require('pending')._recompute_counts() + local buffer = require('pending.buffer') + if buffer.bufnr() and vim.api.nvim_buf_is_valid(buffer.bufnr()) then + buffer.render(buffer.bufnr()) + end + vim.notify(string.format('pending.nvim: Google Tasks pulled — +%d ~%d', created, updated)) + end) end function M.sync() - local access_token, tasklists, s, now_ts = sync_setup() - if not access_token then - return - end - ---@cast tasklists table - ---@cast s pending.Store - ---@cast now_ts string - local by_gtasks_id = build_id_index(s) - local pushed_create, pushed_update, pushed_delete = - push_pass(access_token, tasklists, s, now_ts, by_gtasks_id) - local pulled_create, pulled_update = pull_pass(access_token, tasklists, s, now_ts, by_gtasks_id) - s:save() - require('pending')._recompute_counts() - vim.notify( - string.format( - 'pending.nvim: Google Tasks synced — push: +%d ~%d -%d, pull: +%d ~%d', - pushed_create, - pushed_update, - pushed_delete, - pulled_create, - pulled_update + oauth.async(function() + local access_token, tasklists, s, now_ts = sync_setup() + if not access_token then + return + end + ---@cast tasklists table + ---@cast s pending.Store + ---@cast now_ts string + local by_gtasks_id = build_id_index(s) + local pushed_create, pushed_update, pushed_delete = + push_pass(access_token, tasklists, s, now_ts, by_gtasks_id) + local pulled_create, pulled_update = pull_pass(access_token, tasklists, s, now_ts, by_gtasks_id) + s:save() + require('pending')._recompute_counts() + local buffer = require('pending.buffer') + if buffer.bufnr() and vim.api.nvim_buf_is_valid(buffer.bufnr()) then + buffer.render(buffer.bufnr()) + end + vim.notify( + string.format( + 'pending.nvim: Google Tasks synced — push: +%d ~%d -%d, pull: +%d ~%d', + pushed_create, + pushed_update, + pushed_delete, + pulled_create, + pulled_update + ) ) - ) + end) end M._due_to_rfc3339 = due_to_rfc3339 diff --git a/lua/pending/sync/oauth.lua b/lua/pending/sync/oauth.lua index 7dc5ede..c53e3b1 100644 --- a/lua/pending/sync/oauth.lua +++ b/lua/pending/sync/oauth.lua @@ -27,6 +27,27 @@ OAuthClient.__index = OAuthClient ---@class pending.oauth local M = {} +---@param args string[] +---@param opts? table +---@return { code: integer, stdout: string, stderr: string } +function M.system(args, opts) + local co = coroutine.running() + if not co then + return vim.system(args, opts or {}):wait() --[[@as { code: integer, stdout: string, stderr: string }]] + end + vim.system(args, opts or {}, function(result) + vim.schedule(function() + coroutine.resume(co, result) + end) + end) + return coroutine.yield() --[[@as { code: integer, stdout: string, stderr: string }]] +end + +---@param fn fun(): nil +function M.async(fn) + coroutine.resume(coroutine.create(fn)) +end + ---@param str string ---@return string function M.url_encode(str) @@ -91,7 +112,7 @@ function M.curl_request(method, url, headers, body) table.insert(args, body) end table.insert(args, url) - local result = vim.system(args, { text = true }):wait() + local result = M.system(args, { text = true }) if result.code ~= 0 then return nil, 'curl failed: ' .. (result.stderr or '') end @@ -125,11 +146,6 @@ function M.health(backend_name) else vim.health.warn('curl not found (needed for ' .. backend_name .. ' sync)') end - if vim.fn.executable('openssl') == 1 then - vim.health.ok('openssl found (required for ' .. backend_name .. ' OAuth PKCE)') - else - vim.health.warn('openssl not found (needed for ' .. backend_name .. ' OAuth)') - end end ---@return string @@ -189,19 +205,17 @@ function OAuthClient:refresh_access_token(creds, tokens) .. '&grant_type=refresh_token' .. '&refresh_token=' .. M.url_encode(tokens.refresh_token) - local result = vim - .system({ - 'curl', - '-s', - '-X', - 'POST', - '-H', - 'Content-Type: application/x-www-form-urlencoded', - '-d', - body, - TOKEN_URL, - }, { text = true }) - :wait() + local result = M.system({ + 'curl', + '-s', + '-X', + 'POST', + '-H', + 'Content-Type: application/x-www-form-urlencoded', + '-d', + body, + TOKEN_URL, + }, { text = true }) if result.code ~= 0 then return nil end @@ -247,23 +261,18 @@ function OAuthClient:auth() local verifier_chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~' local verifier = {} - math.randomseed(os.time()) + math.randomseed(vim.uv.hrtime()) for _ = 1, 64 do local idx = math.random(1, #verifier_chars) table.insert(verifier, verifier_chars:sub(idx, idx)) end local code_verifier = table.concat(verifier) - local sha_pipe = vim - .system({ - 'sh', - '-c', - 'printf "%s" "' - .. code_verifier - .. '" | openssl dgst -sha256 -binary | openssl base64 -A | tr "+/" "-_" | tr -d "="', - }, { text = true }) - :wait() - local code_challenge = sha_pipe.stdout or '' + local hex = vim.fn.sha256(code_verifier) + local binary = hex:gsub('..', function(h) + return string.char(tonumber(h, 16)) + end) + local code_challenge = vim.base64.encode(binary):gsub('+', '-'):gsub('/', '_'):gsub('=', '') local auth_url = AUTH_URL .. '?client_id=' @@ -283,6 +292,15 @@ function OAuthClient:auth() vim.notify('pending.nvim: Opening browser for Google authorization...') local server = vim.uv.new_tcp() + local server_closed = false + local function close_server() + if server_closed then + return + end + server_closed = true + server:close() + end + server:bind('127.0.0.1', port) server:listen(1, function(err) if err then @@ -292,6 +310,8 @@ function OAuthClient:auth() server:accept(conn) conn:read_start(function(read_err, data) if read_err or not data then + conn:close() + close_server() return end local code = data:match('[?&]code=([^&%s]+)') @@ -305,7 +325,7 @@ function OAuthClient:auth() conn:close() end) end) - server:close() + close_server() if code then vim.schedule(function() self:_exchange_code(creds, code, code_verifier, port) @@ -313,6 +333,13 @@ function OAuthClient:auth() end end) end) + + vim.defer_fn(function() + if not server_closed then + close_server() + vim.notify('pending.nvim: OAuth callback timed out (120s).', vim.log.levels.WARN) + end + end, 120000) end ---@param creds pending.OAuthCredentials @@ -333,19 +360,17 @@ function OAuthClient:_exchange_code(creds, code, code_verifier, port) .. '&redirect_uri=' .. M.url_encode('http://127.0.0.1:' .. port) - local result = vim - .system({ - 'curl', - '-s', - '-X', - 'POST', - '-H', - 'Content-Type: application/x-www-form-urlencoded', - '-d', - body, - TOKEN_URL, - }, { text = true }) - :wait() + local result = M.system({ + 'curl', + '-s', + '-X', + 'POST', + '-H', + 'Content-Type: application/x-www-form-urlencoded', + '-d', + body, + TOKEN_URL, + }, { text = true }) if result.code ~= 0 then vim.notify('pending.nvim: Token exchange failed.', vim.log.levels.ERROR) diff --git a/plugin/pending.lua b/plugin/pending.lua index 13f16d3..162dfd7 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -181,7 +181,7 @@ end, { for word in after_filter:gmatch('%S+') do used[word] = true end - local candidates = { 'clear', 'overdue', 'today', 'priority' } + local candidates = { 'clear', 'overdue', 'today', 'priority', 'done', 'pending' } local store = require('pending.store') local s = store.new(store.resolve_path()) s:load() diff --git a/scripts/demo-init.lua b/scripts/demo-init.lua deleted file mode 100644 index 57da080..0000000 --- a/scripts/demo-init.lua +++ /dev/null @@ -1,30 +0,0 @@ -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', -} - -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 deleted file mode 100644 index 3a1eee5..0000000 --- a/scripts/demo.tape +++ /dev/null @@ -1,28 +0,0 @@ -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/sync_spec.lua b/spec/sync_spec.lua index ce38635..93d3e2c 100644 --- a/spec/sync_spec.lua +++ b/spec/sync_spec.lua @@ -49,27 +49,27 @@ describe('sync', function() 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 + it('lists actions when action is omitted', function() + local msg = nil + local orig = vim.notify + vim.notify = function(m) + msg = m end pending.command('gcal') - gcal.sync = orig_sync - assert.is_true(called) + vim.notify = orig + assert.is_not_nil(msg) + assert.is_truthy(msg:find('push')) end) - it('routes explicit sync action', function() + it('routes explicit push action', function() local called = false local gcal = require('pending.sync.gcal') - local orig_sync = gcal.sync - gcal.sync = function() + local orig_push = gcal.push + gcal.push = function() called = true end - pending.command('gcal sync') - gcal.sync = orig_sync + pending.command('gcal push') + gcal.push = orig_push assert.is_true(called) end) @@ -90,10 +90,10 @@ describe('sync', function() config.reset() vim.g.pending = { data_path = tmpdir .. '/tasks.json', - sync = { gcal = { calendar = 'NewStyle' } }, + sync = { gcal = { client_id = 'test-id' } }, } local cfg = config.get() - assert.are.equal('NewStyle', cfg.sync.gcal.calendar) + assert.are.equal('test-id', cfg.sync.gcal.client_id) end) describe('gcal module', function() @@ -107,9 +107,9 @@ describe('sync', function() assert.are.equal('function', type(gcal.auth)) end) - it('has sync function', function() + it('has push function', function() local gcal = require('pending.sync.gcal') - assert.are.equal('function', type(gcal.sync)) + assert.are.equal('function', type(gcal.push)) end) it('has health function', function() From 7fb3289b21b9b7ba4c8b81bd7df669374428b7ea Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 5 Mar 2026 12:46:54 -0500 Subject: [PATCH 32/66] fix(diff): preserve due/rec when absent from buffer line (#68) * fix(diff): preserve due/rec when absent from buffer line Problem: `diff.apply` overwrites `task.due` and `task.recur` with `nil` whenever those fields aren't present as inline tokens in the buffer line. Because metadata is rendered as virtual text (never in the line text), every description edit silently clears due dates and recurrence rules. Solution: Only update `due`, `recur`, and `recur_mode` in the existing- task branch when the parsed entry actually contains them (non-nil). Users can still set/change these inline by typing `due:` or `rec:`; clearing them requires `:Pending edit -due`. * refactor: remove project-local store discovery Problem: `store.resolve_path()` searched upward for `.pending.json`, silently splitting task data across multiple files depending on CWD. Solution: `resolve_path()` now always returns `config.get().data_path`. Remove `M.init()` and the `:Pending init` command and tab-completion entry. Remove the project-local health message. * refactor: extract log.lua, standardise [pending.nvim]: prefix Problem: Notifications were scattered across files using bare `vim.notify` with inconsistent `pending.nvim: ` prefixes, and the `debug` guard in `textobj.lua` and `init.lua` was duplicated inline. Solution: Add `lua/pending/log.lua` with `info`, `warn`, `error`, and `debug` functions (prefix `[pending.nvim]: `). `log.debug` only fires when `config.debug = true` or the optional `override` param is `true`. Replace all `vim.notify` callsites and remove inline debug guards. * feat(parse): configurable input date formats Problem: `due:` only accepted ISO `YYYY-MM-DD` and built-in keywords; users expecting locale-style dates like `03/15/2026` or `15-Mar-2026` had no way to configure alternative input formats. Solution: Add `input_date_formats` config field (string[]). Each entry is a strftime-like format string supporting `%Y`, `%y`, `%m`, `%d`, `%e`, `%b`, `%B`. Formats are tried in order after built-in keywords fail. When no year specifier is present the current or next year is inferred. Update vimdoc and add 8 parse_spec tests. --- doc/pending.txt | 22 ++++++++ lua/pending/config.lua | 1 + lua/pending/diff.lua | 18 ++++--- lua/pending/health.lua | 5 +- lua/pending/init.lua | 63 ++++++++-------------- lua/pending/log.lua | 30 +++++++++++ lua/pending/parse.lua | 101 +++++++++++++++++++++++++++++++++++- lua/pending/store.lua | 8 --- lua/pending/sync/gcal.lua | 23 +++----- lua/pending/sync/gtasks.lua | 24 ++++----- lua/pending/sync/oauth.lua | 13 ++--- lua/pending/textobj.lua | 5 +- plugin/pending.lua | 2 +- spec/diff_spec.lua | 21 ++++++-- spec/parse_spec.lua | 69 ++++++++++++++++++++++++ spec/sync_spec.lua | 4 +- 16 files changed, 300 insertions(+), 109 deletions(-) create mode 100644 lua/pending/log.lua diff --git a/doc/pending.txt b/doc/pending.txt index 914644e..2465ba3 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -504,6 +504,11 @@ token, the `D` prompt, and `:Pending add`. `soy` / `eoy` January 1 / December 31 of current year `later` / `someday` Sentinel date (default: `9999-12-30`) +Custom formats: ~ *pending-dates-custom* +Additional input formats can be configured via `input_date_formats` in +|pending-config|. They are tried in order after all built-in keywords fail. +See |pending-input-formats| for supported specifiers and examples. + 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 @@ -636,6 +641,23 @@ Fields: ~ virtual text in the buffer. Examples: `'%Y-%m-%d'` for ISO dates, `'%d %b'` for day-first. + {input_date_formats} (string[], default: {}) *pending-input-formats* + List of strftime-like format strings tried in order + when parsing a `due:` token that does not match the + built-in keywords or ISO `YYYY-MM-DD` format. + Specifiers supported: `%Y` (4-digit year), `%y` + (2-digit year, 00–69 → 2000s, 70–99 → 1900s), `%m` + (numeric month), `%d` / `%e` (day), `%b` / `%B` + (abbreviated or full month name, case-insensitive). + When no year specifier is present the current year is + used, advancing to next year if the date has already + passed. Examples: >lua + input_date_formats = { + '%m/%d/%Y', -- 03/15/2026 + '%d-%b-%Y', -- 15-Mar-2026 + '%m/%d', -- 03/15 (year inferred) + } +< {date_syntax} (string, default: 'due') The token name for inline due-date metadata. Change this to use a different keyword, for example `'by'` diff --git a/lua/pending/config.lua b/lua/pending/config.lua index f488e41..592ef67 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -47,6 +47,7 @@ ---@field date_syntax string ---@field recur_syntax string ---@field someday_date string +---@field input_date_formats? string[] ---@field category_order? string[] ---@field drawer_height? integer ---@field debug? boolean diff --git a/lua/pending/diff.lua b/lua/pending/diff.lua index 7ebbfe1..5df332f 100644 --- a/lua/pending/diff.lua +++ b/lua/pending/diff.lua @@ -121,17 +121,19 @@ function M.apply(lines, s, hidden_ids) task.priority = entry.priority changed = true end - if task.due ~= entry.due then + if entry.due ~= nil and task.due ~= entry.due then 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 + if entry.rec ~= nil then + 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 end if entry.status and task.status ~= entry.status then task.status = entry.status diff --git a/lua/pending/health.lua b/lua/pending/health.lua index d3dbe2c..f819269 100644 --- a/lua/pending/health.lua +++ b/lua/pending/health.lua @@ -10,7 +10,7 @@ function M.check() return end - local cfg = config.get() + config.get() vim.health.ok('Config loaded') local store_ok, store = pcall(require, 'pending.store') @@ -21,9 +21,6 @@ function M.check() local resolved_path = store.resolve_path() vim.health.info('Store path: ' .. resolved_path) - if resolved_path ~= cfg.data_path then - vim.health.info('(project-local store; global path: ' .. cfg.data_path .. ')') - end if vim.fn.filereadable(resolved_path) == 1 then local s = store.new(resolved_path) diff --git a/lua/pending/init.lua b/lua/pending/init.lua index a83692d..1e05c36 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -1,5 +1,6 @@ local buffer = require('pending.buffer') local diff = require('pending.diff') +local log = require('pending.log') local parse = require('pending.parse') local store = require('pending.store') @@ -328,12 +329,7 @@ function M._setup_buf_mappings(bufnr) 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 + log.debug(('mapping motion %s → %s (buf=%d)'):format(name, key or 'nil', bufnr)) if key and key ~= false then vim.keymap.set({ 'n', 'x', 'o' }, key --[[@as string]], function() fn(vim.v.count1) @@ -377,7 +373,7 @@ function M.undo_write() local s = get_store() local stack = s:undo_stack() if #stack == 0 then - vim.notify('Nothing to undo.', vim.log.levels.WARN) + log.warn('Nothing to undo.') return end local state = table.remove(stack) @@ -494,7 +490,7 @@ function M.prompt_date() 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) + log.error('Invalid date format. Use YYYY-MM-DD or YYYY-MM-DDThh:mm.') return end end @@ -508,14 +504,14 @@ end ---@return nil function M.add(text) if not text or text == '' then - vim.notify('Usage: :Pending add ', vim.log.levels.ERROR) + log.error('Usage: :Pending add ') return end local s = get_store() s:load() local description, metadata = parse.command_add(text) if not description or description == '' then - vim.notify('Pending must have a description.', vim.log.levels.ERROR) + log.error('Pending must have a description.') return end s:add({ @@ -530,7 +526,7 @@ function M.add(text) if bufnr and vim.api.nvim_buf_is_valid(bufnr) then buffer.render(bufnr) end - vim.notify('Pending added: ' .. description) + log.info('Pending added: ' .. description) end ---@type string[] @@ -548,7 +544,7 @@ end local function run_sync(backend_name, action) local ok, backend = pcall(require, 'pending.sync.' .. backend_name) if not ok then - vim.notify('Unknown sync backend: ' .. backend_name, vim.log.levels.ERROR) + log.error('Unknown sync backend: ' .. backend_name) return end if not action or action == '' then @@ -559,11 +555,11 @@ local function run_sync(backend_name, action) end end table.sort(actions) - vim.notify(backend_name .. ' actions: ' .. table.concat(actions, ', '), vim.log.levels.INFO) + log.info(backend_name .. ' actions: ' .. table.concat(actions, ', ')) return end if type(backend[action]) ~= 'function' then - vim.notify(backend_name .. " backend has no '" .. action .. "' action", vim.log.levels.ERROR) + log.error(backend_name .. " backend has no '" .. action .. "' action") return end backend[action]() @@ -601,7 +597,7 @@ function M.archive(days) end s:replace_tasks(kept) _save_and_notify() - vim.notify('Archived ' .. archived .. ' tasks.') + log.info('Archived ' .. archived .. ' tasks.') local bufnr = buffer.bufnr() if bufnr and vim.api.nvim_buf_is_valid(bufnr) then buffer.render(bufnr) @@ -653,7 +649,7 @@ function M.due() end if #qf_items == 0 then - vim.notify('No due or overdue tasks.') + log.info('No due or overdue tasks.') return end @@ -740,16 +736,15 @@ end ---@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 + log.error( + 'Usage: :Pending edit [due:] [cat:] [rec:] [+!] [-!] [-due] [-cat] [-rec]' ) return end local id = tonumber(id_str) if not id then - vim.notify('Invalid task ID: ' .. id_str, vim.log.levels.ERROR) + log.error('Invalid task ID: ' .. id_str) return end @@ -757,14 +752,13 @@ function M.edit(id_str, rest) s:load() local task = s:get(id) if not task then - vim.notify('No task with ID ' .. id .. '.', vim.log.levels.ERROR) + log.error('No task with ID ' .. id .. '.') return end if not rest or rest == '' then - vim.notify( - 'Usage: :Pending edit [due:] [cat:] [rec:] [+!] [-!] [-due] [-cat] [-rec]', - vim.log.levels.ERROR + log.error( + 'Usage: :Pending edit [due:] [cat:] [rec:] [+!] [-!] [-due] [-cat] [-rec]' ) return end @@ -780,7 +774,7 @@ function M.edit(id_str, rest) for _, tok in ipairs(tokens) do local field, value, err = parse_edit_token(tok) if err then - vim.notify(err, vim.log.levels.ERROR) + log.error(err) return end if field == 'recur' then @@ -831,20 +825,7 @@ function M.edit(id_str, rest) buffer.render(bufnr) end - vim.notify('Task #' .. id .. ' updated: ' .. table.concat(feedback, ', ')) -end - ----@return nil -function M.init() - local path = vim.fn.getcwd() .. '/.pending.json' - if vim.fn.filereadable(path) == 1 then - vim.notify('pending.nvim: .pending.json already exists', vim.log.levels.WARN) - return - end - local s = store.new(path) - s:load() - s:save() - vim.notify('pending.nvim: created ' .. path) + log.info('Task #' .. id .. ' updated: ' .. table.concat(feedback, ', ')) end ---@param args string @@ -872,10 +853,8 @@ function M.command(args) M.filter(rest) elseif cmd == 'undo' then M.undo_write() - elseif cmd == 'init' then - M.init() else - vim.notify('Unknown Pending subcommand: ' .. cmd, vim.log.levels.ERROR) + log.error('Unknown Pending subcommand: ' .. cmd) end end diff --git a/lua/pending/log.lua b/lua/pending/log.lua new file mode 100644 index 0000000..1f37c4e --- /dev/null +++ b/lua/pending/log.lua @@ -0,0 +1,30 @@ +---@class pending.log +local M = {} + +local PREFIX = '[pending.nvim]: ' + +---@param msg string +function M.info(msg) + vim.notify(PREFIX .. msg) +end + +---@param msg string +function M.warn(msg) + vim.notify(PREFIX .. msg, vim.log.levels.WARN) +end + +---@param msg string +function M.error(msg) + vim.notify(PREFIX .. msg, vim.log.levels.ERROR) +end + +---@param msg string +---@param override? boolean +function M.debug(msg, override) + local cfg = require('pending.config').get() + if cfg.debug or override then + vim.notify(PREFIX .. msg, vim.log.levels.DEBUG) + end +end + +return M diff --git a/lua/pending/parse.lua b/lua/pending/parse.lua index 9ce4c0d..3e90b65 100644 --- a/lua/pending/parse.lua +++ b/lua/pending/parse.lua @@ -151,6 +151,105 @@ local function append_time(date_part, time_suffix) return date_part end +---@param name string +---@return integer? +local function month_name_to_num(name) + return month_map[name:lower():sub(1, 3)] +end + +---@param fmt string +---@return string, string[] +local function input_format_to_pattern(fmt) + local fields = {} + local parts = {} + local i = 1 + while i <= #fmt do + local c = fmt:sub(i, i) + if c == '%' and i < #fmt then + local spec = fmt:sub(i + 1, i + 1) + if spec == '%' then + parts[#parts + 1] = '%%' + i = i + 2 + elseif spec == 'Y' then + fields[#fields + 1] = 'year' + parts[#parts + 1] = '(%d%d%d%d)' + i = i + 2 + elseif spec == 'y' then + fields[#fields + 1] = 'year2' + parts[#parts + 1] = '(%d%d)' + i = i + 2 + elseif spec == 'm' then + fields[#fields + 1] = 'month_num' + parts[#parts + 1] = '(%d%d?)' + i = i + 2 + elseif spec == 'd' or spec == 'e' then + fields[#fields + 1] = 'day' + parts[#parts + 1] = '(%d%d?)' + i = i + 2 + elseif spec == 'b' or spec == 'B' then + fields[#fields + 1] = 'month_name' + parts[#parts + 1] = '(%a+)' + i = i + 2 + else + parts[#parts + 1] = vim.pesc(c) + i = i + 1 + end + else + parts[#parts + 1] = vim.pesc(c) + i = i + 1 + end + end + return '^' .. table.concat(parts) .. '$', fields +end + +---@param date_input string +---@param time_suffix? string +---@return string? +local function try_input_date_formats(date_input, time_suffix) + local fmts = config.get().input_date_formats + if not fmts or #fmts == 0 then + return nil + end + local today = os.date('*t') --[[@as osdate]] + for _, fmt in ipairs(fmts) do + local pat, fields = input_format_to_pattern(fmt) + local caps = { date_input:match(pat) } + if caps[1] ~= nil then + local year, month, day + for j = 1, #fields do + local field = fields[j] + local val = caps[j] + if field == 'year' then + year = tonumber(val) + elseif field == 'year2' then + local y = tonumber(val) --[[@as integer]] + year = y + (y >= 70 and 1900 or 2000) + elseif field == 'month_num' then + month = tonumber(val) + elseif field == 'day' then + day = tonumber(val) + elseif field == 'month_name' then + month = month_name_to_num(val) + end + end + if month and day then + if not year then + year = today.year + if month < today.month or (month == today.month and day < today.day) then + year = year + 1 + end + end + local t = os.time({ year = year, month = month, day = day }) + local check = os.date('*t', t) --[[@as osdate]] + if check.year == year and check.month == month and check.day == day then + return append_time(os.date('%Y-%m-%d', t) --[[@as string]], time_suffix) + end + end + end + end + return nil +end + ---@param text string ---@return string|nil function M.resolve_date(text) @@ -411,7 +510,7 @@ function M.resolve_date(text) ) end - return nil + return try_input_date_formats(date_input, time_suffix) end ---@param text string diff --git a/lua/pending/store.lua b/lua/pending/store.lua index 5a5b370..ff68525 100644 --- a/lua/pending/store.lua +++ b/lua/pending/store.lua @@ -384,14 +384,6 @@ end ---@return string function M.resolve_path() - local results = vim.fs.find('.pending.json', { - upward = true, - path = vim.fn.getcwd(), - type = 'file', - }) - if results and #results > 0 then - return results[1] - end return config.get().data_path end diff --git a/lua/pending/sync/gcal.lua b/lua/pending/sync/gcal.lua index 44f7742..2ae5e05 100644 --- a/lua/pending/sync/gcal.lua +++ b/lua/pending/sync/gcal.lua @@ -1,4 +1,5 @@ local config = require('pending.config') +local log = require('pending.log') local oauth = require('pending.sync.oauth') local M = {} @@ -148,7 +149,7 @@ function M.push() local calendars, cal_err = get_all_calendars(access_token) if cal_err or not calendars then - vim.notify('pending.nvim: ' .. (cal_err or 'failed to fetch calendars'), vim.log.levels.ERROR) + log.error(cal_err or 'failed to fetch calendars') return end @@ -172,7 +173,7 @@ function M.push() local del_err = delete_event(access_token, cal_id --[[@as string]], event_id --[[@as string]]) if del_err then - vim.notify('pending.nvim: gcal delete failed: ' .. del_err, vim.log.levels.WARN) + log.warn('gcal delete failed: ' .. del_err) else extra['_gcal_event_id'] = nil extra['_gcal_calendar_id'] = nil @@ -189,21 +190,18 @@ function M.push() if event_id and cal_id then local upd_err = update_event(access_token, cal_id, event_id, task) if upd_err then - vim.notify('pending.nvim: gcal update failed: ' .. upd_err, vim.log.levels.WARN) + log.warn('gcal update failed: ' .. upd_err) else updated = updated + 1 end else local lid, lid_err = find_or_create_calendar(access_token, cat, calendars) if lid_err or not lid then - vim.notify( - 'pending.nvim: gcal calendar failed: ' .. (lid_err or 'unknown'), - vim.log.levels.WARN - ) + log.warn('gcal calendar failed: ' .. (lid_err or 'unknown')) else local new_id, create_err = create_event(access_token, lid, task) if create_err then - vim.notify('pending.nvim: gcal create failed: ' .. create_err, vim.log.levels.WARN) + log.warn('gcal create failed: ' .. create_err) elseif new_id then if not task._extra then task._extra = {} @@ -224,14 +222,7 @@ function M.push() if buffer.bufnr() and vim.api.nvim_buf_is_valid(buffer.bufnr()) then buffer.render(buffer.bufnr()) end - vim.notify( - string.format( - 'pending.nvim: Google Calendar pushed — +%d ~%d -%d', - created, - updated, - deleted - ) - ) + log.info(string.format('Google Calendar pushed — +%d ~%d -%d', created, updated, deleted)) end) end diff --git a/lua/pending/sync/gtasks.lua b/lua/pending/sync/gtasks.lua index a046a51..f627eb7 100644 --- a/lua/pending/sync/gtasks.lua +++ b/lua/pending/sync/gtasks.lua @@ -1,4 +1,5 @@ local config = require('pending.config') +local log = require('pending.log') local oauth = require('pending.sync.oauth') local M = {} @@ -248,7 +249,7 @@ local function push_pass(access_token, tasklists, s, now_ts, by_gtasks_id) if task.status == 'deleted' and gtid and list_id then local err = delete_gtask(access_token, list_id, gtid) if err then - vim.notify('pending.nvim: gtasks delete failed: ' .. err, vim.log.levels.WARN) + log.warn('gtasks delete failed: ' .. err) else if not task._extra then task._extra = {} @@ -265,7 +266,7 @@ local function push_pass(access_token, tasklists, s, now_ts, by_gtasks_id) if gtid and list_id then local err = update_gtask(access_token, list_id, gtid, task_to_gtask(task)) if err then - vim.notify('pending.nvim: gtasks update failed: ' .. err, vim.log.levels.WARN) + log.warn('gtasks update failed: ' .. err) else updated = updated + 1 end @@ -275,7 +276,7 @@ local function push_pass(access_token, tasklists, s, now_ts, by_gtasks_id) if not err and lid then local new_id, create_err = create_gtask(access_token, lid, task_to_gtask(task)) if create_err then - vim.notify('pending.nvim: gtasks create failed: ' .. create_err, vim.log.levels.WARN) + log.warn('gtasks create failed: ' .. create_err) elseif new_id then if not task._extra then task._extra = {} @@ -305,10 +306,7 @@ local function pull_pass(access_token, tasklists, s, now_ts, by_gtasks_id) for list_name, list_id in pairs(tasklists) do local items, err = list_gtasks(access_token, list_id) if err then - vim.notify( - 'pending.nvim: error fetching list ' .. list_name .. ': ' .. err, - vim.log.levels.WARN - ) + log.warn('error fetching list ' .. list_name .. ': ' .. err) else for _, gtask in ipairs(items or {}) do local local_task = by_gtasks_id[gtask.id] @@ -350,7 +348,7 @@ local function sync_setup() end local tasklists, tl_err = get_all_tasklists(access_token) if tl_err or not tasklists then - vim.notify('pending.nvim: ' .. (tl_err or 'failed to fetch task lists'), vim.log.levels.ERROR) + log.error(tl_err or 'failed to fetch task lists') return nil end local s = require('pending').store() @@ -379,9 +377,7 @@ function M.push() if buffer.bufnr() and vim.api.nvim_buf_is_valid(buffer.bufnr()) then buffer.render(buffer.bufnr()) end - vim.notify( - string.format('pending.nvim: Google Tasks pushed — +%d ~%d -%d', created, updated, deleted) - ) + log.info(string.format('Google Tasks pushed — +%d ~%d -%d', created, updated, deleted)) end) end @@ -402,7 +398,7 @@ function M.pull() if buffer.bufnr() and vim.api.nvim_buf_is_valid(buffer.bufnr()) then buffer.render(buffer.bufnr()) end - vim.notify(string.format('pending.nvim: Google Tasks pulled — +%d ~%d', created, updated)) + log.info(string.format('Google Tasks pulled — +%d ~%d', created, updated)) end) end @@ -425,9 +421,9 @@ function M.sync() if buffer.bufnr() and vim.api.nvim_buf_is_valid(buffer.bufnr()) then buffer.render(buffer.bufnr()) end - vim.notify( + log.info( string.format( - 'pending.nvim: Google Tasks synced — push: +%d ~%d -%d, pull: +%d ~%d', + 'Google Tasks synced — push: +%d ~%d -%d, pull: +%d ~%d', pushed_create, pushed_update, pushed_delete, diff --git a/lua/pending/sync/oauth.lua b/lua/pending/sync/oauth.lua index c53e3b1..dc9eb5c 100644 --- a/lua/pending/sync/oauth.lua +++ b/lua/pending/sync/oauth.lua @@ -1,4 +1,5 @@ local config = require('pending.config') +local log = require('pending.log') local TOKEN_URL = 'https://oauth2.googleapis.com/token' local AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth' @@ -247,7 +248,7 @@ function OAuthClient:get_access_token() if now - obtained > expires - 60 then tokens = self:refresh_access_token(creds, tokens) if not tokens then - vim.notify('pending.nvim: Failed to refresh access token.', vim.log.levels.ERROR) + log.error('Failed to refresh access token.') return nil end end @@ -289,7 +290,7 @@ function OAuthClient:auth() .. '&code_challenge_method=S256' vim.ui.open(auth_url) - vim.notify('pending.nvim: Opening browser for Google authorization...') + log.info('Opening browser for Google authorization...') local server = vim.uv.new_tcp() local server_closed = false @@ -337,7 +338,7 @@ function OAuthClient:auth() vim.defer_fn(function() if not server_closed then close_server() - vim.notify('pending.nvim: OAuth callback timed out (120s).', vim.log.levels.WARN) + log.warn('OAuth callback timed out (120s).') end end, 120000) end @@ -373,19 +374,19 @@ function OAuthClient:_exchange_code(creds, code, code_verifier, port) }, { text = true }) if result.code ~= 0 then - vim.notify('pending.nvim: Token exchange failed.', vim.log.levels.ERROR) + log.error('Token exchange failed.') return end local ok, decoded = pcall(vim.json.decode, result.stdout or '') if not ok or not decoded.access_token then - vim.notify('pending.nvim: Invalid token response.', vim.log.levels.ERROR) + log.error('Invalid token response.') return end decoded.obtained_at = os.time() self:save_tokens(decoded) - vim.notify('pending.nvim: ' .. self.name .. ' authorized successfully.') + log.info(self.name .. ' authorized successfully.') end ---@param opts { name: string, scope: string, port: integer, config_key: string } diff --git a/lua/pending/textobj.lua b/lua/pending/textobj.lua index 62d6db3..887ef8f 100644 --- a/lua/pending/textobj.lua +++ b/lua/pending/textobj.lua @@ -1,5 +1,6 @@ local buffer = require('pending.buffer') local config = require('pending.config') +local log = require('pending.log') ---@class pending.textobj local M = {} @@ -7,9 +8,7 @@ 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 + log.debug(string.format(...)) end ---@param lnum integer diff --git a/plugin/pending.lua b/plugin/pending.lua index 162dfd7..f6ed6bb 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -167,7 +167,7 @@ end, { nargs = '*', complete = function(arg_lead, cmd_line) local pending = require('pending') - local subcmds = { 'add', 'archive', 'due', 'edit', 'filter', 'init', 'undo' } + local subcmds = { 'add', 'archive', 'due', 'edit', 'filter', 'undo' } for _, b in ipairs(pending.sync_backends()) do table.insert(subcmds, b) end diff --git a/spec/diff_spec.lua b/spec/diff_spec.lua index c2a0406..01d8aac 100644 --- a/spec/diff_spec.lua +++ b/spec/diff_spec.lua @@ -199,7 +199,7 @@ describe('diff', function() assert.are.equal(modified_after_first, task.modified) end) - it('clears due when removed from buffer line', function() + it('preserves due when not present in buffer line', function() s:add({ description = 'Pay bill', due = '2026-03-15' }) s:save() local lines = { @@ -209,7 +209,20 @@ describe('diff', function() diff.apply(lines, s) s:load() local task = s:get(1) - assert.is_nil(task.due) + assert.are.equal('2026-03-15', task.due) + end) + + it('updates due when inline token is present', function() + s:add({ description = 'Pay bill', due = '2026-03-15' }) + s:save() + local lines = { + '# Inbox', + '/1/- [ ] Pay bill due:2026-04-01', + } + diff.apply(lines, s) + s:load() + local task = s:get(1) + assert.are.equal('2026-04-01', task.due) end) it('stores recur field on new tasks from buffer', function() @@ -237,7 +250,7 @@ describe('diff', function() assert.are.equal('weekly', task.recur) end) - it('clears recur when token removed from line', function() + it('preserves recur when not present in buffer line', function() s:add({ description = 'Task', recur = 'daily' }) s:save() local lines = { @@ -247,7 +260,7 @@ describe('diff', function() diff.apply(lines, s) s:load() local task = s:get(1) - assert.is_nil(task.recur) + assert.are.equal('daily', task.recur) end) it('parses rec: with completion mode prefix', function() diff --git a/spec/parse_spec.lua b/spec/parse_spec.lua index bc313b0..0e6ac19 100644 --- a/spec/parse_spec.lua +++ b/spec/parse_spec.lua @@ -415,4 +415,73 @@ describe('parse', function() assert.are.equal('2026-03-15', meta.due) end) end) + + describe('input_date_formats', function() + before_each(function() + config.reset() + end) + + after_each(function() + vim.g.pending = nil + config.reset() + end) + + it('parses MM/DD/YYYY format', function() + vim.g.pending = { input_date_formats = { '%m/%d/%Y' } } + config.reset() + local result = parse.resolve_date('03/15/2026') + assert.are.equal('2026-03-15', result) + end) + + it('parses DD-Mon-YYYY format', function() + vim.g.pending = { input_date_formats = { '%d-%b-%Y' } } + config.reset() + local result = parse.resolve_date('15-Mar-2026') + assert.are.equal('2026-03-15', result) + end) + + it('parses month name case-insensitively', function() + vim.g.pending = { input_date_formats = { '%d-%b-%Y' } } + config.reset() + local result = parse.resolve_date('15-MARCH-2026') + assert.are.equal('2026-03-15', result) + end) + + it('parses two-digit year', function() + vim.g.pending = { input_date_formats = { '%m/%d/%y' } } + config.reset() + local result = parse.resolve_date('03/15/26') + assert.are.equal('2026-03-15', result) + end) + + it('infers year when format has no year field', function() + vim.g.pending = { input_date_formats = { '%m/%d' } } + config.reset() + local result = parse.resolve_date('12/31') + assert.is_not_nil(result) + assert.truthy(result:match('^%d%d%d%d%-12%-31$')) + end) + + it('returns nil for non-matching input', function() + vim.g.pending = { input_date_formats = { '%m/%d/%Y' } } + config.reset() + local result = parse.resolve_date('not-a-date') + assert.is_nil(result) + end) + + it('tries formats in order, returns first match', function() + vim.g.pending = { input_date_formats = { '%d/%m/%Y', '%m/%d/%Y' } } + config.reset() + local result = parse.resolve_date('01/03/2026') + assert.are.equal('2026-03-01', result) + end) + + it('works with body() for inline due token', function() + vim.g.pending = { input_date_formats = { '%m/%d/%Y' } } + config.reset() + local desc, meta = parse.body('Pay rent due:03/15/2026') + assert.are.equal('Pay rent', desc) + assert.are.equal('2026-03-15', meta.due) + end) + end) end) diff --git a/spec/sync_spec.lua b/spec/sync_spec.lua index 93d3e2c..20a85c1 100644 --- a/spec/sync_spec.lua +++ b/spec/sync_spec.lua @@ -33,7 +33,7 @@ describe('sync', function() end pending.command('notreal') vim.notify = orig - assert.are.equal('Unknown Pending subcommand: notreal', msg) + assert.are.equal('[pending.nvim]: Unknown Pending subcommand: notreal', msg) end) it('errors on unknown action for valid backend', function() @@ -46,7 +46,7 @@ describe('sync', function() end pending.command('gcal notreal') vim.notify = orig - assert.are.equal("gcal backend has no 'notreal' action", msg) + assert.are.equal("[pending.nvim]: gcal backend has no 'notreal' action", msg) end) it('lists actions when action is omitted', function() From 0163941a2b0f752e8bcb897bc5e2ee8ed1102304 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 5 Mar 2026 13:24:43 -0500 Subject: [PATCH 33/66] fix(sync): trigger auth then resume operation when not authenticated (#69) * fix(sync): trigger auth then resume operation when not authenticated Problem: `get_access_token()` called `auth()` then immediately tried to load tokens, but `auth()` is async (TCP server + browser redirect), so tokens were never present at that point. All sync operations silently aborted when unauthenticated. Solution: Remove the inline auth attempt from `get_access_token()` and add an `on_complete` callback to `auth()` / `_exchange_code()`. Add a `with_token(callback)` helper in `gtasks.lua` and `gcal.lua` that triggers auth with the sync operation as the continuation, so `push`/`pull`/`sync` resume automatically after the OAuth flow completes. * ci: format --- lua/pending/sync/gcal.lua | 26 +++++++++++++----- lua/pending/sync/gtasks.lua | 54 ++++++++++++++++++++++--------------- lua/pending/sync/oauth.lua | 17 ++++++------ 3 files changed, 62 insertions(+), 35 deletions(-) diff --git a/lua/pending/sync/gcal.lua b/lua/pending/sync/gcal.lua index 2ae5e05..69c175d 100644 --- a/lua/pending/sync/gcal.lua +++ b/lua/pending/sync/gcal.lua @@ -136,17 +136,31 @@ local function delete_event(access_token, calendar_id, event_id) return err end +---@param callback fun(access_token: string): nil +local function with_token(callback) + oauth.async(function() + local token = client:get_access_token() + if not token then + client:auth(function() + oauth.async(function() + local fresh = client:get_access_token() + if fresh then + callback(fresh) + end + end) + end) + return + end + callback(token) + end) +end + function M.auth() client:auth() end function M.push() - oauth.async(function() - local access_token = client:get_access_token() - if not access_token then - return - end - + with_token(function(access_token) local calendars, cal_err = get_all_calendars(access_token) if cal_err or not calendars then log.error(cal_err or 'failed to fetch calendars') diff --git a/lua/pending/sync/gtasks.lua b/lua/pending/sync/gtasks.lua index f627eb7..d383c77 100644 --- a/lua/pending/sync/gtasks.lua +++ b/lua/pending/sync/gtasks.lua @@ -337,23 +337,38 @@ local function pull_pass(access_token, tasklists, s, now_ts, by_gtasks_id) return created, updated end ----@return string? access_token +---@param access_token string ---@return table? tasklists ----@return pending.Store? store +---@return pending.Store? s ---@return string? now_ts -local function sync_setup() - local access_token = client:get_access_token() - if not access_token then - return nil - end +local function sync_setup(access_token) local tasklists, tl_err = get_all_tasklists(access_token) if tl_err or not tasklists then log.error(tl_err or 'failed to fetch task lists') - return nil + return nil, nil, nil end local s = require('pending').store() local now_ts = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]] - return access_token, tasklists, s, now_ts + return tasklists, s, now_ts +end + +---@param callback fun(access_token: string): nil +local function with_token(callback) + oauth.async(function() + local token = client:get_access_token() + if not token then + client:auth(function() + oauth.async(function() + local fresh = client:get_access_token() + if fresh then + callback(fresh) + end + end) + end) + return + end + callback(token) + end) end function M.auth() @@ -361,12 +376,11 @@ function M.auth() end function M.push() - oauth.async(function() - local access_token, tasklists, s, now_ts = sync_setup() - if not access_token then + with_token(function(access_token) + local tasklists, s, now_ts = sync_setup(access_token) + if not tasklists then return end - ---@cast tasklists table ---@cast s pending.Store ---@cast now_ts string local by_gtasks_id = build_id_index(s) @@ -382,12 +396,11 @@ function M.push() end function M.pull() - oauth.async(function() - local access_token, tasklists, s, now_ts = sync_setup() - if not access_token then + with_token(function(access_token) + local tasklists, s, now_ts = sync_setup(access_token) + if not tasklists then return end - ---@cast tasklists table ---@cast s pending.Store ---@cast now_ts string local by_gtasks_id = build_id_index(s) @@ -403,12 +416,11 @@ function M.pull() end function M.sync() - oauth.async(function() - local access_token, tasklists, s, now_ts = sync_setup() - if not access_token then + with_token(function(access_token) + local tasklists, s, now_ts = sync_setup(access_token) + if not tasklists then return end - ---@cast tasklists table ---@cast s pending.Store ---@cast now_ts string local by_gtasks_id = build_id_index(s) diff --git a/lua/pending/sync/oauth.lua b/lua/pending/sync/oauth.lua index dc9eb5c..88eaf35 100644 --- a/lua/pending/sync/oauth.lua +++ b/lua/pending/sync/oauth.lua @@ -236,11 +236,7 @@ function OAuthClient:get_access_token() local creds = self:resolve_credentials() local tokens = self:load_tokens() if not tokens or not tokens.refresh_token then - self:auth() - tokens = self:load_tokens() - if not tokens then - return nil - end + return nil end local now = os.time() local obtained = tokens.obtained_at or 0 @@ -255,8 +251,9 @@ function OAuthClient:get_access_token() return tokens.access_token end +---@param on_complete? fun(): nil ---@return nil -function OAuthClient:auth() +function OAuthClient:auth(on_complete) local creds = self:resolve_credentials() local port = self.port @@ -329,7 +326,7 @@ function OAuthClient:auth() close_server() if code then vim.schedule(function() - self:_exchange_code(creds, code, code_verifier, port) + self:_exchange_code(creds, code, code_verifier, port, on_complete) end) end end) @@ -347,8 +344,9 @@ end ---@param code string ---@param code_verifier string ---@param port integer +---@param on_complete? fun(): nil ---@return nil -function OAuthClient:_exchange_code(creds, code, code_verifier, port) +function OAuthClient:_exchange_code(creds, code, code_verifier, port, on_complete) local body = 'client_id=' .. M.url_encode(creds.client_id) .. '&client_secret=' @@ -387,6 +385,9 @@ function OAuthClient:_exchange_code(creds, code, code_verifier, port) decoded.obtained_at = os.time() self:save_tokens(decoded) log.info(self.name .. ' authorized successfully.') + if on_complete then + on_complete() + end end ---@param opts { name: string, scope: string, port: integer, config_key: string } From f78f8e42fad2bca13a043c4f14fecedb0b7c87ed Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 5 Mar 2026 15:29:32 -0500 Subject: [PATCH 34/66] feat(sync): interactive setup, auth continuation, and credential resolution fixes (#70) * feat(sync): add `setup` command to configure credentials interactively Problem: users had to manually create a JSON credentials file at the correct path before authenticating, with no guidance from the plugin. Solution: add `OAuthClient:setup()` that prompts for client ID and secret via `vim.ui.input`, writes to the shared `google_credentials.json`, then immediately starts the OAuth flow. Expose as `:Pending {gtasks,gcal} setup`. Also extend `resolve_credentials()` to fall back to a shared `google_credentials.json` so one file covers both backends. * fix(sync): improve `setup` input loop with validation and masking Problem: `setup()` used async `vim.ui.input` for both prompts, causing newline and re-prompt issues when validation failed. The secret was also echoed in plain text. Solution: switch to synchronous `vim.fn.input` / `vim.fn.inputsecret` loops with `vim.cmd.redraw()` + `nvim_echo` for inline error display and re-prompting. Validate client ID format and `GOCSPX-` secret prefix before saving. * fix(oauth): fix `ipairs` nil truncation in `resolve_credentials` and add file-path setup option Problem: `resolve_credentials` built `cred_paths` with a potentially nil first element (`credentials_path`), causing `ipairs` to stop immediately and always fall through to bundled placeholder credentials. Solution: build `cred_paths` without nil entries using `table.insert`. Also add a `2. Load from JSON file path` option to `setup()` via `vim.fn.inputlist`, with `vim.fn.expand` for `~`/`$HOME` support and the `installed` wrapper unwrap. * doc: cleanup * ci: format --- README.md | 19 +----- lua/pending/sync/gcal.lua | 4 ++ lua/pending/sync/gtasks.lua | 4 ++ lua/pending/sync/oauth.lua | 114 ++++++++++++++++++++++++++++++++---- 4 files changed, 115 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 43c8447..356096a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # pending.nvim -Edit tasks like text. `:w` saves them. +Edit tasks like text. Inspired by +[oil.nvim](https://github.com/stevearc/oil.nvim), +[vim-fugitive](https://github.com/tpope/vim-fugitive) ![demo](assets/demo.gif) @@ -24,21 +26,6 @@ luarocks install pending.nvim :help pending.nvim ``` -## Icons - -All display characters are configurable. Defaults produce markdown-style checkboxes (`[ ]`, `[x]`, `[!]`): - -```lua -vim.g.pending = { - icons = { - pending = ' ', done = 'x', priority = '!', - due = '.', recur = '~', category = '#', - }, -} -``` - -See `:help pending.Icons` for nerd font examples. - ## Acknowledgements - [dooing](https://github.com/atiladefreitas/dooing) diff --git a/lua/pending/sync/gcal.lua b/lua/pending/sync/gcal.lua index 69c175d..53a9111 100644 --- a/lua/pending/sync/gcal.lua +++ b/lua/pending/sync/gcal.lua @@ -155,6 +155,10 @@ local function with_token(callback) end) end +function M.setup() + client:setup() +end + function M.auth() client:auth() end diff --git a/lua/pending/sync/gtasks.lua b/lua/pending/sync/gtasks.lua index d383c77..1bf3848 100644 --- a/lua/pending/sync/gtasks.lua +++ b/lua/pending/sync/gtasks.lua @@ -371,6 +371,10 @@ local function with_token(callback) end) end +function M.setup() + client:setup() +end + function M.auth() client:auth() end diff --git a/lua/pending/sync/oauth.lua b/lua/pending/sync/oauth.lua index 88eaf35..9e13870 100644 --- a/lua/pending/sync/oauth.lua +++ b/lua/pending/sync/oauth.lua @@ -166,18 +166,26 @@ function OAuthClient:resolve_credentials() } end - local cred_path = backend_cfg.credentials_path - or (vim.fn.stdpath('data') .. '/pending/' .. self.name .. '_credentials.json') - local creds = M.load_json_file(cred_path) - if creds then - if creds.installed then - creds = creds.installed - end - if creds.client_id and creds.client_secret then - return creds --[[@as pending.OAuthCredentials]] + local data_dir = vim.fn.stdpath('data') .. '/pending/' + local cred_paths = {} + if backend_cfg.credentials_path then + table.insert(cred_paths, backend_cfg.credentials_path) + end + table.insert(cred_paths, data_dir .. self.name .. '_credentials.json') + table.insert(cred_paths, data_dir .. 'google_credentials.json') + for _, cred_path in ipairs(cred_paths) do + if cred_path then + local creds = M.load_json_file(cred_path) + if creds then + if creds.installed then + creds = creds.installed + end + if creds.client_id and creds.client_secret then + return creds --[[@as pending.OAuthCredentials]] + end + end end end - return { client_id = BUNDLED_CLIENT_ID, client_secret = BUNDLED_CLIENT_SECRET, @@ -251,6 +259,92 @@ function OAuthClient:get_access_token() return tokens.access_token end +---@return nil +function OAuthClient:setup() + local choice = vim.fn.inputlist({ + self.name .. ' setup:', + '1. Enter client ID and secret', + '2. Load from JSON file path', + }) + vim.cmd.redraw() + + local id, secret + + if choice == 1 then + while true do + id = vim.trim(vim.fn.input(self.name .. ' client ID: ')) + if id == '' then + return + end + if id:match('^%d+%-[%w_]+%.apps%.googleusercontent%.com$') then + break + end + vim.cmd.redraw() + vim.api.nvim_echo({ + { + 'invalid client ID — expected -.apps.googleusercontent.com', + 'ErrorMsg', + }, + }, false, {}) + end + + while true do + secret = vim.trim(vim.fn.inputsecret(self.name .. ' client secret: ')) + if secret == '' then + return + end + if secret:match('^GOCSPX%-') then + break + end + vim.cmd.redraw() + vim.api.nvim_echo( + { { 'invalid client secret — expected GOCSPX-...', 'ErrorMsg' } }, + false, + {} + ) + end + elseif choice == 2 then + local fpath + while true do + fpath = vim.trim(vim.fn.input(self.name .. ' credentials file: ', '', 'file')) + if fpath == '' then + return + end + fpath = vim.fn.expand(fpath) + local creds = M.load_json_file(fpath) + if creds then + if creds.installed then + creds = creds.installed + end + if creds.client_id and creds.client_secret then + id = creds.client_id + secret = creds.client_secret + break + end + end + vim.cmd.redraw() + vim.api.nvim_echo( + { { 'could not read client_id/client_secret from ' .. fpath, 'ErrorMsg' } }, + false, + {} + ) + end + else + return + end + + vim.schedule(function() + local path = vim.fn.stdpath('data') .. '/pending/google_credentials.json' + local ok = M.save_json_file(path, { client_id = id, client_secret = secret }) + if not ok then + log.error(self.name .. ': failed to save credentials') + return + end + log.info(self.name .. ': credentials saved, starting authorization...') + self:auth() + end) +end + ---@param on_complete? fun(): nil ---@return nil function OAuthClient:auth(on_complete) From 87d8bf08961b42a45b11f238a9d5a9f41d54e4b9 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 5 Mar 2026 18:58:14 -0500 Subject: [PATCH 35/66] feat(sync): credentials setup, auth continuation, and error surfacing (#71) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(sync): add `setup` command to configure credentials interactively Problem: users had to manually create a JSON credentials file at the correct path before authenticating, with no guidance from the plugin. Solution: add `OAuthClient:setup()` that prompts for client ID and secret via `vim.ui.input`, writes to the shared `google_credentials.json`, then immediately starts the OAuth flow. Expose as `:Pending {gtasks,gcal} setup`. Also extend `resolve_credentials()` to fall back to a shared `google_credentials.json` so one file covers both backends. * fix(sync): improve `setup` input loop with validation and masking Problem: `setup()` used async `vim.ui.input` for both prompts, causing newline and re-prompt issues when validation failed. The secret was also echoed in plain text. Solution: switch to synchronous `vim.fn.input` / `vim.fn.inputsecret` loops with `vim.cmd.redraw()` + `nvim_echo` for inline error display and re-prompting. Validate client ID format and `GOCSPX-` secret prefix before saving. * fix(oauth): fix `ipairs` nil truncation in `resolve_credentials` and add file-path setup option Problem: `resolve_credentials` built `cred_paths` with a potentially nil first element (`credentials_path`), causing `ipairs` to stop immediately and always fall through to bundled placeholder credentials. Solution: build `cred_paths` without nil entries using `table.insert`. Also add a `2. Load from JSON file path` option to `setup()` via `vim.fn.inputlist`, with `vim.fn.expand` for `~`/`$HOME` support and the `installed` wrapper unwrap. * doc: cleanup * ci: format * fix(sync): surface auth failures and detect missing credentials Problem: three silent failure paths remained in the sync auth flow — `with_token` gave no feedback when auth was cancelled or failed, `get_access_token` logged a generic message on refresh failure, and `auth()` opened a browser with `PLACEHOLDER` credentials with no Neovim-side error. Solution: add `log.error` in `with_token` when `get_access_token` returns nil after auth, improve the refresh-failure message to name the backend and hint at re-auth, and guard `auth()` with a pre-flight check that errors immediately when bundled placeholder credentials are detected. --- lua/pending/sync/gcal.lua | 2 ++ lua/pending/sync/gtasks.lua | 2 ++ lua/pending/sync/oauth.lua | 6 +++++- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/lua/pending/sync/gcal.lua b/lua/pending/sync/gcal.lua index 53a9111..bddb461 100644 --- a/lua/pending/sync/gcal.lua +++ b/lua/pending/sync/gcal.lua @@ -146,6 +146,8 @@ local function with_token(callback) local fresh = client:get_access_token() if fresh then callback(fresh) + else + log.error(client.name .. ': authorization failed or was cancelled') end end) end) diff --git a/lua/pending/sync/gtasks.lua b/lua/pending/sync/gtasks.lua index 1bf3848..d1ae10f 100644 --- a/lua/pending/sync/gtasks.lua +++ b/lua/pending/sync/gtasks.lua @@ -362,6 +362,8 @@ local function with_token(callback) local fresh = client:get_access_token() if fresh then callback(fresh) + else + log.error(client.name .. ': authorization failed or was cancelled') end end) end) diff --git a/lua/pending/sync/oauth.lua b/lua/pending/sync/oauth.lua index 9e13870..cb490e4 100644 --- a/lua/pending/sync/oauth.lua +++ b/lua/pending/sync/oauth.lua @@ -252,7 +252,7 @@ function OAuthClient:get_access_token() if now - obtained > expires - 60 then tokens = self:refresh_access_token(creds, tokens) if not tokens then - log.error('Failed to refresh access token.') + log.error(self.name .. ': token refresh failed — re-authenticating...') return nil end end @@ -349,6 +349,10 @@ end ---@return nil function OAuthClient:auth(on_complete) local creds = self:resolve_credentials() + if creds.client_id == BUNDLED_CLIENT_ID then + log.error(self.name .. ': no credentials configured — run :Pending ' .. self.name .. ' setup') + return + end local port = self.port local verifier_chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~' From ad59e894c7ce7fdbd5ab273e5fb9df6c4012be90 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 5 Mar 2026 21:08:22 -0500 Subject: [PATCH 36/66] feat(sync): unify Google auth under :Pending auth (#72) * feat(sync): unify Google auth under :Pending auth Problem: users had to run `:Pending gtasks auth` and `:Pending gcal auth` separately, producing two token files and two browser consents for the same Google account. Solution: introduce `oauth.google_client` with combined tasks + calendar scopes and a single `google_tokens.json`. Remove per-backend `auth`/`setup` from `gcal` and `gtasks`; add top-level `:Pending auth` that prompts with `vim.ui.select` and delegates to the shared client's `setup()` or `auth()` based on credential availability. * docs: update vimdoc for unified Google auth Problem: `doc/pending.txt` still documented per-backend `:Pending gtasks auth` / `:Pending gcal auth` commands and separate token files, which no longer exist after the auth unification. Solution: add `:Pending auth` entry to COMMANDS and a new `*pending-google-auth*` section covering the shared PKCE flow, combined scopes, and `google_tokens.json`. Remove `auth` from gcal/gtasks action tables and update all cross-references to use `:Pending auth`. * ci: format --- doc/pending.txt | 81 ++++++++++++++++++++++++++----------- lua/pending/init.lua | 20 +++++++++ lua/pending/sync/gcal.lua | 24 ++--------- lua/pending/sync/gtasks.lua | 28 +++---------- lua/pending/sync/oauth.lua | 8 ++++ plugin/pending.lua | 2 +- spec/sync_spec.lua | 16 +++----- 7 files changed, 102 insertions(+), 77 deletions(-) diff --git a/doc/pending.txt b/doc/pending.txt index 2465ba3..994afc6 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -65,8 +65,9 @@ CONTENTS *pending-contents* 17. Sync Backends ................................... |pending-sync-backend| 18. Google Calendar .......................................... |pending-gcal| 19. Google Tasks ............................................ |pending-gtasks| - 20. Data Format .............................................. |pending-data| - 21. Health Check ........................................... |pending-health| + 20. Google Authentication ......................... |pending-google-auth| + 21. Data Format .............................................. |pending-data| + 22. Health Check ........................................... |pending-health| ============================================================================== REQUIREMENTS *pending-requirements* @@ -148,6 +149,15 @@ COMMANDS *pending-commands* Populate the quickfix list with all tasks that are overdue or due today. Open the list with |:copen| to navigate to each task's category. + *:Pending-auth* +:Pending auth + Authorize pending.nvim to access Google services (Tasks and Calendar). + Prompts with |vim.ui.select| to choose gtasks, gcal, or both — all + options run the same combined OAuth flow and produce a single shared + token file. If no credentials are configured, the setup wizard runs + first to collect a client ID and secret. + See |pending-google-auth| for full details. + *:Pending-gtasks* :Pending gtasks {action} Run a Google Tasks action. An explicit action is required. @@ -156,13 +166,11 @@ COMMANDS *pending-commands* `sync` Push local changes then pull remote changes. `push` Push local changes to Google Tasks only. `pull` Pull remote changes from Google Tasks only. - `auth` Run the OAuth authorization flow. Examples: >vim :Pending gtasks sync " push then pull :Pending gtasks push " push local → Google Tasks :Pending gtasks pull " pull Google Tasks → local - :Pending gtasks auth " authorize < Tab completion after `:Pending gtasks ` lists available actions. @@ -174,11 +182,9 @@ COMMANDS *pending-commands* Actions: ~ `push` Push tasks with due dates to Google Calendar. - `auth` Run the OAuth authorization flow. Examples: >vim :Pending gcal push " push to Google Calendar - :Pending gcal auth " authorize < Tab completion after `:Pending gcal ` lists available actions. @@ -920,7 +926,6 @@ Each module returns a table conforming to the backend interface: >lua ---@class pending.SyncBackend ---@field name string - ---@field auth fun(): nil ---@field push? fun(): nil ---@field pull? fun(): nil ---@field sync? fun(): nil @@ -929,15 +934,17 @@ Each module returns a table conforming to the backend interface: >lua Required fields: ~ {name} Backend identifier (matches the filename). - {sync} Main sync action. Called by `:Pending `. - {auth} Authorization flow. Called by `:Pending auth`. Optional fields: ~ {push} Push-only action. Called by `:Pending push`. {pull} Pull-only action. Called by `:Pending pull`. + {sync} Main sync action. Called by `:Pending sync`. {health} Called by `:checkhealth pending` to report backend-specific diagnostics (e.g. checking for external tools). +Note: authorization is not a per-backend action. Use `:Pending auth` to +authenticate all Google backends at once. See |pending-google-auth|. + Backend-specific configuration goes under `sync.` in |pending-config|. ============================================================================== @@ -957,7 +964,7 @@ Configuration: >lua < No configuration is required to get started — bundled OAuth credentials are -used by default. Run `:Pending gcal auth` and the browser opens immediately. +used by default. Run `:Pending auth` and the browser opens immediately. *pending.GcalConfig* Fields: ~ @@ -983,15 +990,8 @@ Credentials are resolved in order: 3. Bundled credentials shipped with the plugin (always available). OAuth flow: ~ -On the first `:Pending 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. After -the user grants consent, the -authorization code is exchanged for tokens and the refresh token is stored at -`stdpath('data')/pending/gcal_tokens.json` with mode `600`. Subsequent syncs -use the stored refresh token and refresh the access token automatically when -it is about to expire. +See |pending-google-auth|. Tokens are shared with the gtasks backend and +stored at `stdpath('data')/pending/google_tokens.json`. `:Pending gcal push` behavior: ~ For each task in the store: @@ -1020,7 +1020,7 @@ Configuration: >lua < No configuration is required to get started — bundled OAuth credentials are -used by default. Run `:Pending gtasks auth` and the browser opens immediately. +used by default. Run `:Pending auth` and the browser opens immediately. *pending.GtasksConfig* Fields: ~ @@ -1043,9 +1043,8 @@ Credential resolution: ~ Same three-tier resolution as the gcal backend (see |pending-gcal|). OAuth flow: ~ -Same PKCE flow as the gcal backend; listens on port 18393. Tokens are stored -at `stdpath('data')/pending/gtasks_tokens.json`. Run `:Pending gtasks auth` -to authorize; subsequent syncs refresh the token automatically. +See |pending-google-auth|. Tokens are shared with the gcal backend and +stored at `stdpath('data')/pending/google_tokens.json`. `:Pending gtasks` actions: ~ @@ -1077,6 +1076,42 @@ the remainder is ignored. Recurrence (`rec:`) is stored in notes for round-tripping but is not expanded by Google Tasks (GTasks has no recurrence API). +============================================================================== +GOOGLE AUTHENTICATION *pending-google-auth* + +Both the gcal and gtasks backends share a single OAuth client with combined +scopes (`tasks` + `calendar`). One authorization flow covers both services +and produces one token file. + +:Pending auth ~ +Prompts with |vim.ui.select| offering three options: `gtasks`, `gcal`, and +`both`. All three options run the identical combined OAuth flow — the choice +is informational only. If no real credentials are configured (i.e. bundled +placeholders are in use), the setup wizard runs first to collect a client ID +and client secret before opening the browser. + +OAuth flow: ~ +A PKCE (Proof Key for Code Exchange) flow is used: +1. A random 64-character `code_verifier` is generated. +2. Its SHA-256 hash is base64url-encoded as the `code_challenge`. +3. The Google authorization URL is opened in the browser via |vim.ui.open()|. +4. A temporary TCP server on port 18392 waits up to 120 seconds for the + OAuth redirect. +5. The authorization code is exchanged for tokens via `curl`. +6. The refresh token is written to + `stdpath('data')/pending/google_tokens.json` with mode `600`. +7. Subsequent syncs refresh the access token automatically when it is about + to expire (within 60 seconds of the `expires_in` window). + +Credential resolution: ~ +Credentials are resolved in order for the `google` config key: +1. `client_id` + `client_secret` under `sync.google` (highest priority). +2. JSON file at `sync.google.credentials_path` or the default path + `stdpath('data')/pending/google_credentials.json`. +3. Bundled placeholder credentials (always available; trigger setup wizard). + +The `installed` wrapper format from the Google Cloud Console is accepted. + ============================================================================== DATA FORMAT *pending-data* diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 1e05c36..446d375 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -828,6 +828,24 @@ function M.edit(id_str, rest) log.info('Task #' .. id .. ' updated: ' .. table.concat(feedback, ', ')) end +---@return nil +function M.auth() + local oauth = require('pending.sync.oauth') + vim.ui.select({ 'gtasks', 'gcal', 'both' }, { + prompt = 'Authenticate:', + }, function(choice) + if not choice then + return + end + local creds = oauth.google_client:resolve_credentials() + if creds.client_id == oauth.BUNDLED_CLIENT_ID then + oauth.google_client:setup() + else + oauth.google_client:auth() + end + end) +end + ---@param args string ---@return nil function M.command(args) @@ -841,6 +859,8 @@ function M.command(args) elseif cmd == 'edit' then local id_str, edit_rest = rest:match('^(%S+)%s*(.*)') M.edit(id_str, edit_rest) + elseif cmd == 'auth' then + M.auth() elseif SYNC_BACKEND_SET[cmd] then local action = rest:match('^(%S+)') run_sync(cmd, action) diff --git a/lua/pending/sync/gcal.lua b/lua/pending/sync/gcal.lua index bddb461..99b9e76 100644 --- a/lua/pending/sync/gcal.lua +++ b/lua/pending/sync/gcal.lua @@ -7,14 +7,6 @@ local M = {} M.name = 'gcal' local BASE_URL = 'https://www.googleapis.com/calendar/v3' -local SCOPE = 'https://www.googleapis.com/auth/calendar' - -local client = oauth.new({ - name = 'gcal', - scope = SCOPE, - port = 18392, - config_key = 'gcal', -}) ---@param access_token string ---@return table? name_to_id @@ -139,15 +131,15 @@ end ---@param callback fun(access_token: string): nil local function with_token(callback) oauth.async(function() - local token = client:get_access_token() + local token = oauth.google_client:get_access_token() if not token then - client:auth(function() + oauth.google_client:auth(function() oauth.async(function() - local fresh = client:get_access_token() + local fresh = oauth.google_client:get_access_token() if fresh then callback(fresh) else - log.error(client.name .. ': authorization failed or was cancelled') + log.error(oauth.google_client.name .. ': authorization failed or was cancelled') end end) end) @@ -157,14 +149,6 @@ local function with_token(callback) end) end -function M.setup() - client:setup() -end - -function M.auth() - client:auth() -end - function M.push() with_token(function(access_token) local calendars, cal_err = get_all_calendars(access_token) diff --git a/lua/pending/sync/gtasks.lua b/lua/pending/sync/gtasks.lua index d1ae10f..d747c51 100644 --- a/lua/pending/sync/gtasks.lua +++ b/lua/pending/sync/gtasks.lua @@ -7,14 +7,6 @@ local M = {} M.name = 'gtasks' local BASE_URL = 'https://tasks.googleapis.com/tasks/v1' -local SCOPE = 'https://www.googleapis.com/auth/tasks' - -local client = oauth.new({ - name = 'gtasks', - scope = SCOPE, - port = 18393, - config_key = 'gtasks', -}) ---@param access_token string ---@return table? name_to_id @@ -355,15 +347,15 @@ end ---@param callback fun(access_token: string): nil local function with_token(callback) oauth.async(function() - local token = client:get_access_token() + local token = oauth.google_client:get_access_token() if not token then - client:auth(function() + oauth.google_client:auth(function() oauth.async(function() - local fresh = client:get_access_token() + local fresh = oauth.google_client:get_access_token() if fresh then callback(fresh) else - log.error(client.name .. ': authorization failed or was cancelled') + log.error(oauth.google_client.name .. ': authorization failed or was cancelled') end end) end) @@ -373,14 +365,6 @@ local function with_token(callback) end) end -function M.setup() - client:setup() -end - -function M.auth() - client:auth() -end - function M.push() with_token(function(access_token) local tasklists, s, now_ts = sync_setup(access_token) @@ -462,11 +446,11 @@ M._gtask_to_fields = gtask_to_fields ---@return nil function M.health() oauth.health(M.name) - local tokens = client:load_tokens() + local tokens = oauth.google_client:load_tokens() if tokens and tokens.refresh_token then vim.health.ok('gtasks tokens found') else - vim.health.info('no gtasks tokens — run :Pending gtasks auth') + vim.health.info('no gtasks tokens — run :Pending auth') end end diff --git a/lua/pending/sync/oauth.lua b/lua/pending/sync/oauth.lua index cb490e4..887769c 100644 --- a/lua/pending/sync/oauth.lua +++ b/lua/pending/sync/oauth.lua @@ -501,5 +501,13 @@ end M._BUNDLED_CLIENT_ID = BUNDLED_CLIENT_ID M._BUNDLED_CLIENT_SECRET = BUNDLED_CLIENT_SECRET +M.BUNDLED_CLIENT_ID = BUNDLED_CLIENT_ID + +M.google_client = M.new({ + name = 'google', + scope = 'https://www.googleapis.com/auth/tasks' .. ' https://www.googleapis.com/auth/calendar', + port = 18392, + config_key = 'google', +}) return M diff --git a/plugin/pending.lua b/plugin/pending.lua index f6ed6bb..cba4916 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -167,7 +167,7 @@ end, { nargs = '*', complete = function(arg_lead, cmd_line) local pending = require('pending') - local subcmds = { 'add', 'archive', 'due', 'edit', 'filter', 'undo' } + local subcmds = { 'add', 'archive', 'auth', 'due', 'edit', 'filter', 'undo' } for _, b in ipairs(pending.sync_backends()) do table.insert(subcmds, b) end diff --git a/spec/sync_spec.lua b/spec/sync_spec.lua index 20a85c1..a491dd3 100644 --- a/spec/sync_spec.lua +++ b/spec/sync_spec.lua @@ -73,15 +73,14 @@ describe('sync', function() assert.is_true(called) end) - it('routes auth action', function() + it('routes auth command', function() local called = false - local gcal = require('pending.sync.gcal') - local orig_auth = gcal.auth - gcal.auth = function() + local orig_auth = pending.auth + pending.auth = function() called = true end - pending.command('gcal auth') - gcal.auth = orig_auth + pending.command('auth') + pending.auth = orig_auth assert.is_true(called) end) end) @@ -102,11 +101,6 @@ describe('sync', function() 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 push function', function() local gcal = require('pending.sync.gcal') assert.are.equal('function', type(gcal.push)) From 6e381c0d5fc752d24170e813bf72b9f44e3df80d Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 5 Mar 2026 22:40:19 -0500 Subject: [PATCH 37/66] feat(sync): diff metadata preservation, auth unification, and sync quality improvements (#74) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(sync): unify Google auth under :Pending auth Problem: users had to run `:Pending gtasks auth` and `:Pending gcal auth` separately, producing two token files and two browser consents for the same Google account. Solution: introduce `oauth.google_client` with combined tasks + calendar scopes and a single `google_tokens.json`. Remove per-backend `auth`/`setup` from `gcal` and `gtasks`; add top-level `:Pending auth` that prompts with `vim.ui.select` and delegates to the shared client's `setup()` or `auth()` based on credential availability. * docs: update vimdoc for unified Google auth Problem: `doc/pending.txt` still documented per-backend `:Pending gtasks auth` / `:Pending gcal auth` commands and separate token files, which no longer exist after the auth unification. Solution: add `:Pending auth` entry to COMMANDS and a new `*pending-google-auth*` section covering the shared PKCE flow, combined scopes, and `google_tokens.json`. Remove `auth` from gcal/gtasks action tables and update all cross-references to use `:Pending auth`. * ci: format * feat(sync): selective push, remote deletion detection, and gcal fix Problem: `push_pass` updated all remote-linked tasks unconditionally, causing unnecessary API calls and potential clobbering of remote edits made between syncs. `pull`/`sync` never noticed when a task disappeared from remote. `update_event` omitted `transparency` that `create_event` set. Failure counts were absent from sync log summaries. Solution: Introduce `_gtasks_synced_at` in `_extra` — stamped after every successful push/pull create or update — so `push_pass` skips tasks unchanged since last sync. Add `detect_remote_deletions` to unlink local tasks whose remote entry disappeared from a successfully fetched list. Surface failures as `!N` in all sync logs and `unlinked: N` for pull/sync. Add `transparency = 'transparent'` to `update_event`. Cover new behaviour with 7 tests in `gtasks_spec.lua`. * ci: formt --- lua/pending/sync/gcal.lua | 1 + lua/pending/sync/gtasks.lua | 110 +++++++++++++++++---- spec/gtasks_spec.lua | 190 ++++++++++++++++++++++++++++++++++++ 3 files changed, 284 insertions(+), 17 deletions(-) diff --git a/lua/pending/sync/gcal.lua b/lua/pending/sync/gcal.lua index 99b9e76..f90d7c1 100644 --- a/lua/pending/sync/gcal.lua +++ b/lua/pending/sync/gcal.lua @@ -97,6 +97,7 @@ local function update_event(access_token, calendar_id, event_id, task) summary = task.description, start = { date = task.due }, ['end'] = { date = next_day(task.due or '') }, + transparency = 'transparent', } local _, err = oauth.curl_request( 'PATCH', diff --git a/lua/pending/sync/gtasks.lua b/lua/pending/sync/gtasks.lua index d747c51..9fc7459 100644 --- a/lua/pending/sync/gtasks.lua +++ b/lua/pending/sync/gtasks.lua @@ -231,8 +231,9 @@ end ---@return integer created ---@return integer updated ---@return integer deleted +---@return integer failed local function push_pass(access_token, tasklists, s, now_ts, by_gtasks_id) - local created, updated, deleted = 0, 0, 0 + local created, updated, deleted, failed = 0, 0, 0, 0 for _, task in ipairs(s:tasks()) do local extra = task._extra or {} local gtid = extra['_gtasks_task_id'] --[[@as string?]] @@ -242,12 +243,14 @@ local function push_pass(access_token, tasklists, s, now_ts, by_gtasks_id) local err = delete_gtask(access_token, list_id, gtid) if err then log.warn('gtasks delete failed: ' .. err) + failed = failed + 1 else if not task._extra then task._extra = {} end task._extra['_gtasks_task_id'] = nil task._extra['_gtasks_list_id'] = nil + task._extra['_gtasks_synced_at'] = nil if next(task._extra) == nil then task._extra = nil end @@ -256,11 +259,17 @@ local function push_pass(access_token, tasklists, s, now_ts, by_gtasks_id) end elseif task.status ~= 'deleted' then if gtid and list_id then - local err = update_gtask(access_token, list_id, gtid, task_to_gtask(task)) - if err then - log.warn('gtasks update failed: ' .. err) - else - updated = updated + 1 + local synced_at = extra['_gtasks_synced_at'] --[[@as string?]] + if not synced_at or task.modified > synced_at then + local err = update_gtask(access_token, list_id, gtid, task_to_gtask(task)) + if err then + log.warn('gtasks update failed: ' .. err) + failed = failed + 1 + else + task._extra = task._extra or {} + task._extra['_gtasks_synced_at'] = now_ts + updated = updated + 1 + end end elseif task.status == 'pending' then local cat = task.category or config.get().default_category @@ -269,12 +278,14 @@ local function push_pass(access_token, tasklists, s, now_ts, by_gtasks_id) local new_id, create_err = create_gtask(access_token, lid, task_to_gtask(task)) if create_err then log.warn('gtasks create failed: ' .. create_err) + failed = failed + 1 elseif new_id then if not task._extra then task._extra = {} end task._extra['_gtasks_task_id'] = new_id task._extra['_gtasks_list_id'] = lid + task._extra['_gtasks_synced_at'] = now_ts task.modified = now_ts by_gtasks_id[new_id] = task created = created + 1 @@ -283,7 +294,7 @@ local function push_pass(access_token, tasklists, s, now_ts, by_gtasks_id) end end end - return created, updated, deleted + return created, updated, deleted, failed end ---@param access_token string @@ -293,14 +304,24 @@ end ---@param by_gtasks_id table ---@return integer created ---@return integer updated +---@return integer failed +---@return table seen_remote_ids +---@return table fetched_list_ids local function pull_pass(access_token, tasklists, s, now_ts, by_gtasks_id) - local created, updated = 0, 0 + local created, updated, failed = 0, 0, 0 + ---@type table + local seen_remote_ids = {} + ---@type table + local fetched_list_ids = {} for list_name, list_id in pairs(tasklists) do local items, err = list_gtasks(access_token, list_id) if err then log.warn('error fetching list ' .. list_name .. ': ' .. err) + failed = failed + 1 else + fetched_list_ids[list_id] = true for _, gtask in ipairs(items or {}) do + seen_remote_ids[gtask.id] = true local local_task = by_gtasks_id[gtask.id] if local_task then local gtask_updated = gtask.updated or '' @@ -310,6 +331,8 @@ local function pull_pass(access_token, tasklists, s, now_ts, by_gtasks_id) for k, v in pairs(fields) do local_task[k] = v end + local_task._extra = local_task._extra or {} + local_task._extra['_gtasks_synced_at'] = now_ts local_task.modified = now_ts updated = updated + 1 end @@ -318,6 +341,7 @@ local function pull_pass(access_token, tasklists, s, now_ts, by_gtasks_id) fields._extra = { _gtasks_task_id = gtask.id, _gtasks_list_id = list_id, + _gtasks_synced_at = now_ts, } local new_task = s:add(fields) by_gtasks_id[gtask.id] = new_task @@ -326,7 +350,38 @@ local function pull_pass(access_token, tasklists, s, now_ts, by_gtasks_id) end end end - return created, updated + return created, updated, failed, seen_remote_ids, fetched_list_ids +end + +---@param s pending.Store +---@param seen_remote_ids table +---@param fetched_list_ids table +---@param now_ts string +---@return integer unlinked +local function detect_remote_deletions(s, seen_remote_ids, fetched_list_ids, now_ts) + local unlinked = 0 + for _, task in ipairs(s:tasks()) do + local extra = task._extra or {} + local gtid = extra['_gtasks_task_id'] + local list_id = extra['_gtasks_list_id'] + if + task.status ~= 'deleted' + and gtid + and list_id + and fetched_list_ids[list_id] + and not seen_remote_ids[gtid] + then + task._extra['_gtasks_task_id'] = nil + task._extra['_gtasks_list_id'] = nil + task._extra['_gtasks_synced_at'] = nil + if next(task._extra) == nil then + task._extra = nil + end + task.modified = now_ts + unlinked = unlinked + 1 + end + end + return unlinked end ---@param access_token string @@ -374,14 +429,17 @@ function M.push() ---@cast s pending.Store ---@cast now_ts string local by_gtasks_id = build_id_index(s) - local created, updated, deleted = push_pass(access_token, tasklists, s, now_ts, by_gtasks_id) + local created, updated, deleted, failed = + push_pass(access_token, tasklists, s, now_ts, by_gtasks_id) s:save() require('pending')._recompute_counts() local buffer = require('pending.buffer') if buffer.bufnr() and vim.api.nvim_buf_is_valid(buffer.bufnr()) then buffer.render(buffer.bufnr()) end - log.info(string.format('Google Tasks pushed — +%d ~%d -%d', created, updated, deleted)) + log.info( + string.format('Google Tasks pushed — +%d ~%d -%d !%d', created, updated, deleted, failed) + ) end) end @@ -394,14 +452,24 @@ function M.pull() ---@cast s pending.Store ---@cast now_ts string local by_gtasks_id = build_id_index(s) - local created, updated = pull_pass(access_token, tasklists, s, now_ts, by_gtasks_id) + local created, updated, failed, seen_remote_ids, fetched_list_ids = + pull_pass(access_token, tasklists, s, now_ts, by_gtasks_id) + local unlinked = detect_remote_deletions(s, seen_remote_ids, fetched_list_ids, now_ts) s:save() require('pending')._recompute_counts() local buffer = require('pending.buffer') if buffer.bufnr() and vim.api.nvim_buf_is_valid(buffer.bufnr()) then buffer.render(buffer.bufnr()) end - log.info(string.format('Google Tasks pulled — +%d ~%d', created, updated)) + log.info( + string.format( + 'Google Tasks pulled — +%d ~%d !%d, unlinked: %d', + created, + updated, + failed, + unlinked + ) + ) end) end @@ -414,9 +482,11 @@ function M.sync() ---@cast s pending.Store ---@cast now_ts string local by_gtasks_id = build_id_index(s) - local pushed_create, pushed_update, pushed_delete = + local pushed_create, pushed_update, pushed_delete, pushed_failed = push_pass(access_token, tasklists, s, now_ts, by_gtasks_id) - local pulled_create, pulled_update = pull_pass(access_token, tasklists, s, now_ts, by_gtasks_id) + local pulled_create, pulled_update, pulled_failed, seen_remote_ids, fetched_list_ids = + pull_pass(access_token, tasklists, s, now_ts, by_gtasks_id) + local unlinked = detect_remote_deletions(s, seen_remote_ids, fetched_list_ids, now_ts) s:save() require('pending')._recompute_counts() local buffer = require('pending.buffer') @@ -425,12 +495,15 @@ function M.sync() end log.info( string.format( - 'Google Tasks synced — push: +%d ~%d -%d, pull: +%d ~%d', + 'Google Tasks synced — push: +%d ~%d -%d !%d, pull: +%d ~%d !%d, unlinked: %d', pushed_create, pushed_update, pushed_delete, + pushed_failed, pulled_create, - pulled_update + pulled_update, + pulled_failed, + unlinked ) ) end) @@ -442,6 +515,9 @@ M._build_notes = build_notes M._parse_notes = parse_notes M._task_to_gtask = task_to_gtask M._gtask_to_fields = gtask_to_fields +M._push_pass = push_pass +M._pull_pass = pull_pass +M._detect_remote_deletions = detect_remote_deletions ---@return nil function M.health() diff --git a/spec/gtasks_spec.lua b/spec/gtasks_spec.lua index 19328d9..1e0f7ef 100644 --- a/spec/gtasks_spec.lua +++ b/spec/gtasks_spec.lua @@ -176,3 +176,193 @@ describe('gtasks field conversion', function() end) end) end) + +describe('gtasks push_pass _gtasks_synced_at', function() + local helpers = require('spec.helpers') + local store_mod = require('pending.store') + local oauth = require('pending.sync.oauth') + local s + local orig_curl + + before_each(function() + local dir = helpers.tmpdir() + s = store_mod.new(dir .. '/pending.json') + s:load() + orig_curl = oauth.curl_request + end) + + after_each(function() + oauth.curl_request = orig_curl + end) + + it('sets _gtasks_synced_at after push create', function() + local task = + s:add({ description = 'New task', status = 'pending', category = 'Work', priority = 0 }) + + oauth.curl_request = function(method, url, _headers, _body) + if method == 'POST' and url:find('/tasks$') then + return { id = 'gtask-new-1' }, nil + end + return {}, nil + end + + local now_ts = '2026-03-05T10:00:00Z' + local tasklists = { Work = 'list-1' } + local by_id = {} + gtasks._push_pass('fake-token', tasklists, s, now_ts, by_id) + + assert.is_not_nil(task._extra) + assert.equals('2026-03-05T10:00:00Z', task._extra['_gtasks_synced_at']) + end) + + it('skips update when modified <= _gtasks_synced_at', function() + local task = + s:add({ description = 'Existing task', status = 'pending', category = 'Work', priority = 0 }) + task._extra = { + _gtasks_task_id = 'remote-1', + _gtasks_list_id = 'list-1', + _gtasks_synced_at = '2026-03-05T10:00:00Z', + } + task.modified = '2026-03-05T09:00:00Z' + + local patch_called = false + oauth.curl_request = function(method, _url, _headers, _body) + if method == 'PATCH' then + patch_called = true + end + return {}, nil + end + + local now_ts = '2026-03-05T11:00:00Z' + local tasklists = { Work = 'list-1' } + local by_id = { ['remote-1'] = task } + gtasks._push_pass('fake-token', tasklists, s, now_ts, by_id) + + assert.is_false(patch_called) + end) + + it('pushes update when modified > _gtasks_synced_at', function() + local task = + s:add({ description = 'Changed task', status = 'pending', category = 'Work', priority = 0 }) + task._extra = { + _gtasks_task_id = 'remote-2', + _gtasks_list_id = 'list-1', + _gtasks_synced_at = '2026-03-05T08:00:00Z', + } + task.modified = '2026-03-05T09:00:00Z' + + local patch_called = false + oauth.curl_request = function(method, _url, _headers, _body) + if method == 'PATCH' then + patch_called = true + end + return {}, nil + end + + local now_ts = '2026-03-05T11:00:00Z' + local tasklists = { Work = 'list-1' } + local by_id = { ['remote-2'] = task } + gtasks._push_pass('fake-token', tasklists, s, now_ts, by_id) + + assert.is_true(patch_called) + end) + + it('pushes update when no _gtasks_synced_at (backwards compat)', function() + local task = + s:add({ description = 'Old task', status = 'pending', category = 'Work', priority = 0 }) + task._extra = { + _gtasks_task_id = 'remote-3', + _gtasks_list_id = 'list-1', + } + task.modified = '2026-01-01T00:00:00Z' + + local patch_called = false + oauth.curl_request = function(method, _url, _headers, _body) + if method == 'PATCH' then + patch_called = true + end + return {}, nil + end + + local now_ts = '2026-03-05T11:00:00Z' + local tasklists = { Work = 'list-1' } + local by_id = { ['remote-3'] = task } + gtasks._push_pass('fake-token', tasklists, s, now_ts, by_id) + + assert.is_true(patch_called) + end) +end) + +describe('gtasks detect_remote_deletions', function() + local helpers = require('spec.helpers') + local store_mod = require('pending.store') + local s + + before_each(function() + local dir = helpers.tmpdir() + s = store_mod.new(dir .. '/pending.json') + s:load() + end) + + it('clears remote IDs when list was fetched but task ID is absent', function() + local task = + s:add({ description = 'Gone remote', status = 'pending', category = 'Work', priority = 0 }) + task._extra = { + _gtasks_task_id = 'old-remote-id', + _gtasks_list_id = 'list-1', + _gtasks_synced_at = '2026-01-01T00:00:00Z', + } + + local seen = {} + local fetched = { ['list-1'] = true } + local now_ts = '2026-03-05T10:00:00Z' + + local unlinked = gtasks._detect_remote_deletions(s, seen, fetched, now_ts) + + assert.equals(1, unlinked) + assert.is_nil(task._extra) + assert.equals('2026-03-05T10:00:00Z', task.modified) + end) + + it('leaves task untouched when its list fetch failed', function() + local task = s:add({ + description = 'Unknown list task', + status = 'pending', + category = 'Work', + priority = 0, + }) + task._extra = { + _gtasks_task_id = 'remote-id', + _gtasks_list_id = 'list-unfetched', + } + + local seen = {} + local fetched = {} + local now_ts = '2026-03-05T10:00:00Z' + + local unlinked = gtasks._detect_remote_deletions(s, seen, fetched, now_ts) + + assert.equals(0, unlinked) + assert.is_not_nil(task._extra) + assert.equals('remote-id', task._extra['_gtasks_task_id']) + end) + + it('skips tasks with status == deleted', function() + local task = + s:add({ description = 'Deleted task', status = 'deleted', category = 'Work', priority = 0 }) + task._extra = { + _gtasks_task_id = 'remote-del', + _gtasks_list_id = 'list-1', + } + + local seen = {} + local fetched = { ['list-1'] = true } + local now_ts = '2026-03-05T10:00:00Z' + + local unlinked = gtasks._detect_remote_deletions(s, seen, fetched, now_ts) + + assert.equals(0, unlinked) + assert.is_not_nil(task._extra) + assert.equals('remote-del', task._extra['_gtasks_task_id']) + end) +end) From 0e64aa59f1ceff6d2f5f27e61170f71573c11ca4 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 5 Mar 2026 23:26:09 -0500 Subject: [PATCH 38/66] fix(sync): auth and health UX improvements (#75) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: Failed token exchange left credential files on disk, trapping users in a broken auth loop with no way back to setup. The `auth` prompt used raw backend names and a terse prompt string. The `health` action appeared in `:Pending gcal health` tab completion but silently no-oped outside `:checkhealth`. gcal health omitted the token check that gtasks had. Solution: `_exchange_code` now calls `_wipe()` on both failure paths, clearing the token and credentials files so the next `:Pending auth` routes back through `setup()`. Prompt uses full service names and "Authenticate with:". `health` is filtered from sync subcommand completion and dispatch — its home is `:checkhealth pending`. gcal health now checks for tokens. --- lua/pending/init.lua | 8 ++++---- lua/pending/sync/gcal.lua | 6 ++++++ lua/pending/sync/oauth.lua | 8 ++++++++ plugin/pending.lua | 2 +- 4 files changed, 19 insertions(+), 5 deletions(-) diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 446d375..8ede23a 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -550,7 +550,7 @@ local function run_sync(backend_name, action) if not action or action == '' then local actions = {} for k, v in pairs(backend) do - if type(v) == 'function' and k:sub(1, 1) ~= '_' then + if type(v) == 'function' and k:sub(1, 1) ~= '_' and k ~= 'health' then table.insert(actions, k) end end @@ -558,7 +558,7 @@ local function run_sync(backend_name, action) log.info(backend_name .. ' actions: ' .. table.concat(actions, ', ')) return end - if type(backend[action]) ~= 'function' then + if action == 'health' or type(backend[action]) ~= 'function' then log.error(backend_name .. " backend has no '" .. action .. "' action") return end @@ -831,8 +831,8 @@ end ---@return nil function M.auth() local oauth = require('pending.sync.oauth') - vim.ui.select({ 'gtasks', 'gcal', 'both' }, { - prompt = 'Authenticate:', + vim.ui.select({ 'Google Tasks', 'Google Calendar', 'Google Tasks and Google Calendar' }, { + prompt = 'Authenticate with:', }, function(choice) if not choice then return diff --git a/lua/pending/sync/gcal.lua b/lua/pending/sync/gcal.lua index f90d7c1..942fbec 100644 --- a/lua/pending/sync/gcal.lua +++ b/lua/pending/sync/gcal.lua @@ -234,6 +234,12 @@ end ---@return nil function M.health() oauth.health(M.name) + local tokens = oauth.google_client:load_tokens() + if tokens and tokens.refresh_token then + vim.health.ok('gcal tokens found') + else + vim.health.info('no gcal tokens — run :Pending auth') + end end return M diff --git a/lua/pending/sync/oauth.lua b/lua/pending/sync/oauth.lua index 887769c..bc00208 100644 --- a/lua/pending/sync/oauth.lua +++ b/lua/pending/sync/oauth.lua @@ -470,12 +470,14 @@ function OAuthClient:_exchange_code(creds, code, code_verifier, port, on_complet }, { text = true }) if result.code ~= 0 then + self:_wipe() log.error('Token exchange failed.') return end local ok, decoded = pcall(vim.json.decode, result.stdout or '') if not ok or not decoded.access_token then + self:_wipe() log.error('Invalid token response.') return end @@ -488,6 +490,12 @@ function OAuthClient:_exchange_code(creds, code, code_verifier, port, on_complet end end +---@return nil +function OAuthClient:_wipe() + os.remove(self:token_path()) + os.remove(vim.fn.stdpath('data') .. '/pending/google_credentials.json') +end + ---@param opts { name: string, scope: string, port: integer, config_key: string } ---@return pending.OAuthClient function M.new(opts) diff --git a/plugin/pending.lua b/plugin/pending.lua index cba4916..93a0b11 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -216,7 +216,7 @@ end, { end local actions = {} for k, v in pairs(mod) do - if type(v) == 'function' and k:sub(1, 1) ~= '_' then + if type(v) == 'function' and k:sub(1, 1) ~= '_' and k ~= 'health' then table.insert(actions, k) end end From 55e83644b37349cf4a15dace6416b71546c92da3 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 5 Mar 2026 23:56:11 -0500 Subject: [PATCH 39/66] feat: add \`:Pending done \` command (#76) Toggles a task's done/pending status by ID from the command line, matching the buffer \`\` behaviour including recurrence spawning. Tab-completes active task IDs. --- lua/pending/init.lua | 44 ++++++++++++++++++++++++++++++++++++++ lua/pending/sync/oauth.lua | 2 +- plugin/pending.lua | 12 ++++++++++- 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 8ede23a..36f5282 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -430,6 +430,48 @@ function M.toggle_complete() end end +---@param id_str string +---@return nil +function M.done(id_str) + local id = tonumber(id_str) + if not id then + log.error('Invalid task ID: ' .. tostring(id_str)) + return + end + local s = get_store() + s:load() + local task = s:get(id) + if not task then + log.error('No task with ID ' .. id .. '.') + return + end + local was_done = task.status == 'done' + if was_done then + s:update(id, { status = 'pending', ['end'] = vim.NIL }) + else + if task.recur and task.due then + local recur = require('pending.recur') + local mode = task.recur_mode or 'scheduled' + local next_date = recur.next_due(task.due, task.recur, mode) + s:add({ + description = task.description, + category = task.category, + priority = task.priority, + due = next_date, + recur = task.recur, + recur_mode = task.recur_mode, + }) + end + s:update(id, { status = 'done' }) + end + _save_and_notify() + local bufnr = buffer.bufnr() + if bufnr and vim.api.nvim_buf_is_valid(bufnr) then + buffer.render(bufnr) + end + log.info('Task #' .. id .. ' marked ' .. (was_done and 'pending' or 'done')) +end + ---@return nil function M.toggle_priority() local bufnr = buffer.bufnr() @@ -856,6 +898,8 @@ function M.command(args) local cmd, rest = args:match('^(%S+)%s*(.*)') if cmd == 'add' then M.add(rest) + elseif cmd == 'done' then + M.done(rest:match('^(%S+)')) elseif cmd == 'edit' then local id_str, edit_rest = rest:match('^(%S+)%s*(.*)') M.edit(id_str, edit_rest) diff --git a/lua/pending/sync/oauth.lua b/lua/pending/sync/oauth.lua index bc00208..224476b 100644 --- a/lua/pending/sync/oauth.lua +++ b/lua/pending/sync/oauth.lua @@ -350,7 +350,7 @@ end function OAuthClient:auth(on_complete) local creds = self:resolve_credentials() if creds.client_id == BUNDLED_CLIENT_ID then - log.error(self.name .. ': no credentials configured — run :Pending ' .. self.name .. ' setup') + log.error(self.name .. ': no credentials configured — run :Pending auth') return end local port = self.port diff --git a/plugin/pending.lua b/plugin/pending.lua index 93a0b11..d246fba 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -167,7 +167,7 @@ end, { nargs = '*', complete = function(arg_lead, cmd_line) local pending = require('pending') - local subcmds = { 'add', 'archive', 'auth', 'due', 'edit', 'filter', 'undo' } + local subcmds = { 'add', 'archive', 'auth', 'done', 'due', 'edit', 'filter', 'undo' } for _, b in ipairs(pending.sync_backends()) do table.insert(subcmds, b) end @@ -200,6 +200,16 @@ end, { end return filtered end + if cmd_line:match('^Pending%s+done%s') then + local store = require('pending.store') + local s = store.new(store.resolve_path()) + s:load() + local ids = {} + for _, task in ipairs(s:active_tasks()) do + table.insert(ids, tostring(task.id)) + end + return filter_candidates(arg_lead, ids) + end if cmd_line:match('^Pending%s+edit') then return complete_edit(arg_lead, cmd_line) end From 7ad27f6fcaf5c29f816a0712f932f8bd045a5d9b Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Fri, 6 Mar 2026 12:07:52 -0500 Subject: [PATCH 40/66] fix: empty buffer placeholder and checkbox pattern fixes (#82) * fix(buffer): use `default_category` config for empty placeholder Problem: The empty-buffer fallback hardcoded the category name `TODO`, ignoring the user's `default_category` config value (default: `Todo`). Solution: Read `config.get().default_category` at render time and use that value for both the header line and `LineMeta` category field. * fix(diff): match optional checkbox char in `parse_buffer` patterns Problem: `parse_buffer` used `%[.%]` which requires exactly one character between brackets, failing to parse empty `[]` checkboxes. Solution: Change to `%[.?%]` so the character is optional, matching `[]`, `[ ]`, `[x]`, and `[!]` uniformly. --- lua/pending/buffer.lua | 6 ++++++ lua/pending/diff.lua | 6 +++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index 8fdcbe1..827ff82 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -303,6 +303,12 @@ function M.render(bufnr) lines, line_meta = views.category_view(tasks) end + if #lines == 0 and #_filter_predicates == 0 then + local default_cat = config.get().default_category + lines = { '# ' .. default_cat } + line_meta = { { type = 'header', category = default_cat } } + end + if #_filter_predicates > 0 then table.insert(lines, 1, 'FILTER: ' .. table.concat(_filter_predicates, ' ')) table.insert(line_meta, 1, { type = 'filter' }) diff --git a/lua/pending/diff.lua b/lua/pending/diff.lua index 5df332f..6b79b8a 100644 --- a/lua/pending/diff.lua +++ b/lua/pending/diff.lua @@ -33,14 +33,14 @@ function M.parse_buffer(lines) for i = start, #lines do local line = lines[i] - local id, body = line:match('^/(%d+)/(- %[.%] .*)$') + local id, body = line:match('^/(%d+)/(- %[.?%] .*)$') if not id then - body = line:match('^(- %[.%] .*)$') + body = line:match('^(- %[.?%] .*)$') end if line == '' then table.insert(result, { type = 'blank', lnum = i }) elseif id or body then - local stripped = body:match('^- %[.%] (.*)$') or body + local stripped = body:match('^- %[.?%] (.*)$') or body local state_char = body:match('^- %[(.-)%]') or ' ' local priority = state_char == '!' and 1 or 0 local status = state_char == 'x' and 'done' or 'pending' From 2929b4d8fa60b4dc41f69160042b41ce11ad377d Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Fri, 6 Mar 2026 12:08:10 -0500 Subject: [PATCH 41/66] feat: warn on dirty buffer before store-dependent actions (#83) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(buffer): use `default_category` config for empty placeholder Problem: The empty-buffer fallback hardcoded the category name `TODO`, ignoring the user's `default_category` config value (default: `Todo`). Solution: Read `config.get().default_category` at render time and use that value for both the header line and `LineMeta` category field. * fix(diff): match optional checkbox char in `parse_buffer` patterns Problem: `parse_buffer` used `%[.%]` which requires exactly one character between brackets, failing to parse empty `[]` checkboxes. Solution: Change to `%[.?%]` so the character is optional, matching `[]`, `[ ]`, `[x]`, and `[!]` uniformly. * fix(init): add `nowait` to buffer keymap opts Problem: Buffer-local mappings like `!` could be swallowed by Neovim's operator-pending machinery or by global maps sharing a prefix, since the keymap opts did not include `nowait`. Solution: Add `nowait = true` to the shared `opts` table used for all buffer-local mappings in `_setup_buf_mappings`. * feat(init): allow `:Pending done` with no args to use cursor line Problem: `:Pending done` required an explicit task ID, making it awkward to mark the current task done while inside the pending buffer. Solution: When called with no ID, `M.done()` reads the cursor row from `buffer.meta()` to resolve the task ID, erroring if the cursor is not on a saved task line. * fix(views): populate `priority` field in `LineMeta` Problem: Both `category_view` and `priority_view` omitted `priority` from the `LineMeta` they produced. `apply_extmarks` checks `m.priority` to decide whether to render the priority icon, so it was always nil, causing the `[ ]` pending-icon overlay to replace the `[!]` buffer text. Solution: Add `priority = task.priority` to both LineMeta constructors. * fix(buffer): keep `_meta` in sync when `open_line` inserts a new line Problem: `open_line` inserted a buffer line without updating `_meta`, leaving the entry at that row pointing to the task that was shifted down. Pressing `` (toggle_complete) would read the stale meta, find a real task ID, toggle it, and re-render — destroying the unsaved new line. Solution: Insert a `{ type = 'blank' }` sentinel into `_meta` at the new line's position so buffer-local actions see no task there. * fix(buffer): use task sentinel in `open_line` for better unsaved-task errors * feat(init): warn on dirty buffer before store-dependent actions Problem: `toggle_complete`, `toggle_priority`, `prompt_date`, and `done` (no-args) all read from `buffer.meta()` which is stale whenever the buffer has unsaved edits, leading to silent no-ops or acting on the wrong task. Solution: Add a `require_saved()` guard that emits a `log.warn` and returns false when the buffer is modified. Each store-dependent action calls it before touching meta or the store. * fix(init): guard `view`, `undo`, and `filter` against dirty buffer Problem: `toggle_view`, `undo_write`, and `filter` all call `buffer.render()` which rewrites the buffer from the store, silently discarding any unsaved edits. The previous `require_saved()` change missed these three entry points. Solution: Add `require_saved()` to the `view` and `filter` keymap lambdas and to `M.undo_write()`. Also guard `M.filter()` directly so `:Pending filter` from the command line is covered too. * fix(init): improve dirty-buffer warning message * fix(init): tighten dirty-buffer warning message --- lua/pending/buffer.lua | 1 + lua/pending/init.lua | 60 +++++++++++++++++++++++++++++++++++++----- lua/pending/views.lua | 2 ++ 3 files changed, 57 insertions(+), 6 deletions(-) diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index 827ff82..adcf2dc 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -133,6 +133,7 @@ function M.open_line(above) local insert_row = above and (row - 1) or row vim.bo[bufnr].modifiable = true vim.api.nvim_buf_set_lines(bufnr, insert_row, insert_row, false, { '- [ ] ' }) + table.insert(_meta, insert_row + 1, { type = 'task' }) vim.api.nvim_win_set_cursor(0, { insert_row + 1, 6 }) vim.cmd('startinsert!') end diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 36f5282..0fd3a98 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -83,6 +83,16 @@ local function _save_and_notify() M._recompute_counts() end +---@return boolean +local function require_saved() + local bufnr = buffer.bufnr() + if bufnr and vim.bo[bufnr].modified then + log.warn('save changes first (:w)') + return false + end + return true +end + ---@return pending.Counts function M.counts() if not _counts then @@ -175,6 +185,9 @@ end ---@param pred_str string ---@return nil function M.filter(pred_str) + if not require_saved() then + return + end if pred_str == 'clear' or pred_str == '' then buffer.set_filter({}, {}) local bufnr = buffer.bufnr() @@ -232,7 +245,7 @@ end function M._setup_buf_mappings(bufnr) local cfg = require('pending.config').get() local km = cfg.keymaps - local opts = { buffer = bufnr, silent = true } + local opts = { buffer = bufnr, silent = true, nowait = true } ---@type table local actions = { @@ -243,6 +256,9 @@ function M._setup_buf_mappings(bufnr) M.toggle_complete() end, view = function() + if not require_saved() then + return + end buffer.toggle_view() end, priority = function() @@ -255,6 +271,9 @@ function M._setup_buf_mappings(bufnr) M.undo_write() end, filter = function() + if not require_saved() then + return + end vim.ui.input({ prompt = 'Filter: ' }, function(input) if input then M.filter(input) @@ -370,6 +389,9 @@ end ---@return nil function M.undo_write() + if not require_saved() then + return + end local s = get_store() local stack = s:undo_stack() if #stack == 0 then @@ -388,6 +410,9 @@ function M.toggle_complete() if not bufnr then return end + if not require_saved() then + return + end local row = vim.api.nvim_win_get_cursor(0)[1] local meta = buffer.meta() if not meta[row] or meta[row].type ~= 'task' then @@ -430,13 +455,30 @@ function M.toggle_complete() end end ----@param id_str string +---@param id_str? string ---@return nil function M.done(id_str) - local id = tonumber(id_str) - if not id then - log.error('Invalid task ID: ' .. tostring(id_str)) - return + local id + if not id_str or id_str == '' then + if not require_saved() then + return + end + local row = vim.api.nvim_win_get_cursor(0)[1] + local meta = buffer.meta() + if not meta[row] or meta[row].type ~= 'task' then + log.error('Cursor is not on a task line.') + return + end + id = meta[row].id + if not id then + return + end + else + id = tonumber(id_str) + if not id then + log.error('Invalid task ID: ' .. tostring(id_str)) + return + end end local s = get_store() s:load() @@ -478,6 +520,9 @@ function M.toggle_priority() if not bufnr then return end + if not require_saved() then + return + end local row = vim.api.nvim_win_get_cursor(0)[1] local meta = buffer.meta() if not meta[row] or meta[row].type ~= 'task' then @@ -510,6 +555,9 @@ function M.prompt_date() if not bufnr then return end + if not require_saved() then + return + end local row = vim.api.nvim_win_get_cursor(0)[1] local meta = buffer.meta() if not meta[row] or meta[row].type ~= 'task' then diff --git a/lua/pending/views.lua b/lua/pending/views.lua index 87fcee1..3b67f90 100644 --- a/lua/pending/views.lua +++ b/lua/pending/views.lua @@ -156,6 +156,7 @@ function M.category_view(tasks) raw_due = task.due, status = task.status, category = cat, + priority = task.priority, overdue = task.status == 'pending' and task.due ~= nil and parse.is_overdue(task.due) or nil, recur = task.recur, @@ -207,6 +208,7 @@ function M.priority_view(tasks) raw_due = task.due, status = task.status, category = task.category, + priority = task.priority, overdue = task.status == 'pending' and task.due ~= nil and parse.is_overdue(task.due) or nil, show_category = true, recur = task.recur, From 1e2196fe2eb189e8554abe6925bee3c3cb433f57 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Fri, 6 Mar 2026 12:36:47 -0500 Subject: [PATCH 42/66] feat: :Pending auth subcommands + fix #61 (#84) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(buffer): use `default_category` config for empty placeholder Problem: The empty-buffer fallback hardcoded the category name `TODO`, ignoring the user's `default_category` config value (default: `Todo`). Solution: Read `config.get().default_category` at render time and use that value for both the header line and `LineMeta` category field. * fix(diff): match optional checkbox char in `parse_buffer` patterns Problem: `parse_buffer` used `%[.%]` which requires exactly one character between brackets, failing to parse empty `[]` checkboxes. Solution: Change to `%[.?%]` so the character is optional, matching `[]`, `[ ]`, `[x]`, and `[!]` uniformly. * fix(init): add `nowait` to buffer keymap opts Problem: Buffer-local mappings like `!` could be swallowed by Neovim's operator-pending machinery or by global maps sharing a prefix, since the keymap opts did not include `nowait`. Solution: Add `nowait = true` to the shared `opts` table used for all buffer-local mappings in `_setup_buf_mappings`. * feat(init): allow `:Pending done` with no args to use cursor line Problem: `:Pending done` required an explicit task ID, making it awkward to mark the current task done while inside the pending buffer. Solution: When called with no ID, `M.done()` reads the cursor row from `buffer.meta()` to resolve the task ID, erroring if the cursor is not on a saved task line. * fix(views): populate `priority` field in `LineMeta` Problem: Both `category_view` and `priority_view` omitted `priority` from the `LineMeta` they produced. `apply_extmarks` checks `m.priority` to decide whether to render the priority icon, so it was always nil, causing the `[ ]` pending-icon overlay to replace the `[!]` buffer text. Solution: Add `priority = task.priority` to both LineMeta constructors. * fix(buffer): keep `_meta` in sync when `open_line` inserts a new line Problem: `open_line` inserted a buffer line without updating `_meta`, leaving the entry at that row pointing to the task that was shifted down. Pressing `` (toggle_complete) would read the stale meta, find a real task ID, toggle it, and re-render — destroying the unsaved new line. Solution: Insert a `{ type = 'blank' }` sentinel into `_meta` at the new line's position so buffer-local actions see no task there. * fix(buffer): use task sentinel in `open_line` for better unsaved-task errors * feat(init): warn on dirty buffer before store-dependent actions Problem: `toggle_complete`, `toggle_priority`, `prompt_date`, and `done` (no-args) all read from `buffer.meta()` which is stale whenever the buffer has unsaved edits, leading to silent no-ops or acting on the wrong task. Solution: Add a `require_saved()` guard that emits a `log.warn` and returns false when the buffer is modified. Each store-dependent action calls it before touching meta or the store. * fix(init): guard `view`, `undo`, and `filter` against dirty buffer Problem: `toggle_view`, `undo_write`, and `filter` all call `buffer.render()` which rewrites the buffer from the store, silently discarding any unsaved edits. The previous `require_saved()` change missed these three entry points. Solution: Add `require_saved()` to the `view` and `filter` keymap lambdas and to `M.undo_write()`. Also guard `M.filter()` directly so `:Pending filter` from the command line is covered too. * fix(init): improve dirty-buffer warning message * fix(init): tighten dirty-buffer warning message * feat(oauth): add `OAuthClient:clear_tokens()` method Problem: no way to wipe just the token file while keeping credentials intact — `_wipe()` removed both. Solution: add `clear_tokens()` that removes only the token file. * fix(sync): warn instead of auto-reauth when token is missing Problem: `with_token` silently triggered an OAuth browser flow when no tokens existed, with no user-facing explanation. Solution: replace the auto-reauth branch with a `log.warn` directing the user to run `:Pending auth`. * feat(init): add `clear` and `reset` actions to `:Pending auth` Problem: no CLI path existed to wipe stale tokens or reset credentials, and the `vim.ui.select` backend picker was misleading given shared tokens. Solution: accept an args string in `M.auth()`, dispatching `clear` to `clear_tokens()`, `reset` to `_wipe()`, and bare backend names to the existing auth flow. Remove the picker. * feat(plugin): add tab completion for `:Pending auth` subcommands `:Pending auth ` completes `gcal gtasks clear reset`; `:Pending auth ` completes `clear reset`. --- lua/pending/init.lua | 29 ++++++++++++++++++++--------- lua/pending/sync/gcal.lua | 11 +---------- lua/pending/sync/gtasks.lua | 11 +---------- lua/pending/sync/oauth.lua | 5 +++++ plugin/pending.lua | 15 +++++++++++++++ 5 files changed, 42 insertions(+), 29 deletions(-) diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 0fd3a98..4e093bf 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -918,22 +918,33 @@ function M.edit(id_str, rest) log.info('Task #' .. id .. ' updated: ' .. table.concat(feedback, ', ')) end +---@param args? string ---@return nil -function M.auth() +function M.auth(args) local oauth = require('pending.sync.oauth') - vim.ui.select({ 'Google Tasks', 'Google Calendar', 'Google Tasks and Google Calendar' }, { - prompt = 'Authenticate with:', - }, function(choice) - if not choice then - return - end + local parts = {} + for w in (args or ''):gmatch('%S+') do + table.insert(parts, w) + end + local action = parts[#parts] + if action == parts[1] and (action == 'gtasks' or action == 'gcal') then + action = nil + end + + if action == 'clear' then + oauth.google_client:clear_tokens() + log.info('OAuth tokens cleared — run :Pending auth to re-authenticate.') + elseif action == 'reset' then + oauth.google_client:_wipe() + log.info('OAuth tokens and credentials cleared — run :Pending auth to set up from scratch.') + else local creds = oauth.google_client:resolve_credentials() if creds.client_id == oauth.BUNDLED_CLIENT_ID then oauth.google_client:setup() else oauth.google_client:auth() end - end) + end end ---@param args string @@ -952,7 +963,7 @@ function M.command(args) local id_str, edit_rest = rest:match('^(%S+)%s*(.*)') M.edit(id_str, edit_rest) elseif cmd == 'auth' then - M.auth() + M.auth(rest) elseif SYNC_BACKEND_SET[cmd] then local action = rest:match('^(%S+)') run_sync(cmd, action) diff --git a/lua/pending/sync/gcal.lua b/lua/pending/sync/gcal.lua index 942fbec..1fe8557 100644 --- a/lua/pending/sync/gcal.lua +++ b/lua/pending/sync/gcal.lua @@ -134,16 +134,7 @@ local function with_token(callback) oauth.async(function() local token = oauth.google_client:get_access_token() if not token then - oauth.google_client:auth(function() - oauth.async(function() - local fresh = oauth.google_client:get_access_token() - if fresh then - callback(fresh) - else - log.error(oauth.google_client.name .. ': authorization failed or was cancelled') - end - end) - end) + log.warn('not authenticated — run :Pending auth') return end callback(token) diff --git a/lua/pending/sync/gtasks.lua b/lua/pending/sync/gtasks.lua index 9fc7459..6c8bef3 100644 --- a/lua/pending/sync/gtasks.lua +++ b/lua/pending/sync/gtasks.lua @@ -404,16 +404,7 @@ local function with_token(callback) oauth.async(function() local token = oauth.google_client:get_access_token() if not token then - oauth.google_client:auth(function() - oauth.async(function() - local fresh = oauth.google_client:get_access_token() - if fresh then - callback(fresh) - else - log.error(oauth.google_client.name .. ': authorization failed or was cancelled') - end - end) - end) + log.warn('not authenticated — run :Pending auth') return end callback(token) diff --git a/lua/pending/sync/oauth.lua b/lua/pending/sync/oauth.lua index 224476b..8539ea6 100644 --- a/lua/pending/sync/oauth.lua +++ b/lua/pending/sync/oauth.lua @@ -496,6 +496,11 @@ function OAuthClient:_wipe() os.remove(vim.fn.stdpath('data') .. '/pending/google_credentials.json') end +---@return nil +function OAuthClient:clear_tokens() + os.remove(self:token_path()) +end + ---@param opts { name: string, scope: string, port: integer, config_key: string } ---@return pending.OAuthClient function M.new(opts) diff --git a/plugin/pending.lua b/plugin/pending.lua index d246fba..e456f09 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -213,6 +213,21 @@ end, { if cmd_line:match('^Pending%s+edit') then return complete_edit(arg_lead, cmd_line) end + if cmd_line:match('^Pending%s+auth') then + local after_auth = cmd_line:match('^Pending%s+auth%s+(.*)') or '' + local parts = {} + for w in after_auth:gmatch('%S+') do + table.insert(parts, w) + end + local trailing = after_auth:match('%s$') + if #parts == 0 or (#parts == 1 and not trailing) then + return filter_candidates(arg_lead, { 'gcal', 'gtasks', 'clear', 'reset' }) + end + if #parts == 1 or (#parts == 2 and not trailing) then + return filter_candidates(arg_lead, { 'clear', 'reset' }) + end + return {} + end local backend_set = pending.sync_backend_set() local matched_backend = cmd_line:match('^Pending%s+(%S+)') if matched_backend and backend_set[matched_backend] then From 991ac5b4673d370a2d06dabf47843e21957fbd67 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Fri, 6 Mar 2026 13:12:53 -0500 Subject: [PATCH 43/66] feat(sync): add opt-in remote deletion for gcal and gtasks (#85) Problem: push/sync permanently deleted remote Google Calendar events and Google Tasks entries whenever a local task was marked deleted, done, or de-due'd. There was no opt-out, so a misfire could silently cause irreversible data loss on the remote side. Solution: add a `remote_delete` boolean to the config (default `false`). A unified flag at `sync.remote_delete` sets the base; per-backend overrides at `sync.gcal.remote_delete` / `sync.gtasks.remote_delete` take precedence when non-nil. When disabled, `_extra` remote IDs are cleared silently (unlinking) so stale IDs don't accumulate. --- lua/pending/config.lua | 3 +++ lua/pending/sync/gcal.lua | 51 ++++++++++++++++++++++++++++--------- lua/pending/sync/gtasks.lua | 50 ++++++++++++++++++++++++++---------- 3 files changed, 78 insertions(+), 26 deletions(-) diff --git a/lua/pending/config.lua b/lua/pending/config.lua index 592ef67..9f1c760 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -7,16 +7,19 @@ ---@field category string ---@class pending.GcalConfig +---@field remote_delete? boolean ---@field credentials_path? string ---@field client_id? string ---@field client_secret? string ---@class pending.GtasksConfig +---@field remote_delete? boolean ---@field credentials_path? string ---@field client_id? string ---@field client_secret? string ---@class pending.SyncConfig +---@field remote_delete? boolean ---@field gcal? pending.GcalConfig ---@field gtasks? pending.GtasksConfig diff --git a/lua/pending/sync/gcal.lua b/lua/pending/sync/gcal.lua index 1fe8557..6dddaa6 100644 --- a/lua/pending/sync/gcal.lua +++ b/lua/pending/sync/gcal.lua @@ -129,6 +129,31 @@ local function delete_event(access_token, calendar_id, event_id) return err end +---@return boolean +local function allow_remote_delete() + local cfg = config.get() + local sync = cfg.sync or {} + local per = (sync.gcal or {}) --[[@as pending.GcalConfig]] + if per.remote_delete ~= nil then + return per.remote_delete == true + end + return sync.remote_delete == true +end + +---@param task pending.Task +---@param extra table +---@param now_ts string +local function unlink_remote(task, extra, now_ts) + extra['_gcal_event_id'] = nil + extra['_gcal_calendar_id'] = nil + if next(extra) == nil then + task._extra = nil + else + task._extra = extra + end + task.modified = now_ts +end + ---@param callback fun(access_token: string): nil local function with_token(callback) oauth.async(function() @@ -150,6 +175,7 @@ function M.push() end local s = require('pending').store() + local now_ts = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]] local created, updated, deleted = 0, 0, 0 for _, task in ipairs(s:tasks()) do @@ -166,19 +192,20 @@ function M.push() ) if should_delete then - local del_err = - delete_event(access_token, cal_id --[[@as string]], event_id --[[@as string]]) - if del_err then - log.warn('gcal delete failed: ' .. del_err) - else - extra['_gcal_event_id'] = nil - extra['_gcal_calendar_id'] = nil - if next(extra) == nil then - task._extra = nil + if allow_remote_delete() then + local del_err = + delete_event(access_token, cal_id --[[@as string]], event_id --[[@as string]]) + if del_err then + log.warn('gcal delete failed: ' .. del_err) else - task._extra = extra + unlink_remote(task, extra, now_ts) + deleted = deleted + 1 end - task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]] + else + log.debug( + 'gcal: remote delete skipped (remote_delete disabled) — unlinked task #' .. task.id + ) + unlink_remote(task, extra, now_ts) deleted = deleted + 1 end elseif task.status == 'pending' and task.due then @@ -204,7 +231,7 @@ function M.push() end task._extra['_gcal_event_id'] = new_id task._extra['_gcal_calendar_id'] = lid - task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]] + task.modified = now_ts created = created + 1 end end diff --git a/lua/pending/sync/gtasks.lua b/lua/pending/sync/gtasks.lua index 6c8bef3..7337030 100644 --- a/lua/pending/sync/gtasks.lua +++ b/lua/pending/sync/gtasks.lua @@ -172,6 +172,29 @@ local function parse_notes(notes) return priority, recur, recur_mode end +---@return boolean +local function allow_remote_delete() + local cfg = config.get() + local sync = cfg.sync or {} + local per = (sync.gtasks or {}) --[[@as pending.GtasksConfig]] + if per.remote_delete ~= nil then + return per.remote_delete == true + end + return sync.remote_delete == true +end + +---@param task pending.Task +---@param now_ts string +local function unlink_remote(task, now_ts) + task._extra['_gtasks_task_id'] = nil + task._extra['_gtasks_list_id'] = nil + task._extra['_gtasks_synced_at'] = nil + if next(task._extra) == nil then + task._extra = nil + end + task.modified = now_ts +end + ---@param task pending.Task ---@return table local function task_to_gtask(task) @@ -240,21 +263,20 @@ local function push_pass(access_token, tasklists, s, now_ts, by_gtasks_id) local list_id = extra['_gtasks_list_id'] --[[@as string?]] if task.status == 'deleted' and gtid and list_id then - local err = delete_gtask(access_token, list_id, gtid) - if err then - log.warn('gtasks delete failed: ' .. err) - failed = failed + 1 + if allow_remote_delete() then + local err = delete_gtask(access_token, list_id, gtid) + if err then + log.warn('gtasks delete failed: ' .. err) + failed = failed + 1 + else + unlink_remote(task, now_ts) + deleted = deleted + 1 + end else - if not task._extra then - task._extra = {} - end - task._extra['_gtasks_task_id'] = nil - task._extra['_gtasks_list_id'] = nil - task._extra['_gtasks_synced_at'] = nil - if next(task._extra) == nil then - task._extra = nil - end - task.modified = now_ts + log.debug( + 'gtasks: remote delete skipped (remote_delete disabled) — unlinked task #' .. task.id + ) + unlink_remote(task, now_ts) deleted = deleted + 1 end elseif task.status ~= 'deleted' then From ff73164b615b6db76fd3696ca6010905dc4ea144 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Fri, 6 Mar 2026 13:26:23 -0500 Subject: [PATCH 44/66] fix(sync): replace cryptic sigil counters with readable output (#86) * fix(sync): replace cryptic sigil counters with readable output Problem: sync summaries used unexplained sigils (`+/-/~` and `!`) that conveyed no meaning, mixed symbol and prose formats across operations, and `gcal push` silently swallowed failures with no aggregate counter. Solution: replace all summary `log.info` calls with a shared `fmt_counts` helper that formats `N label` pairs separated by ` | `, suppresses zero counts, and falls back to "nothing to do". Add a `failed` counter to `gcal.push` to surface errors previously only emitted as individual warnings. * ci: format --- lua/pending/sync/gcal.lua | 28 +++++++++++++++-- lua/pending/sync/gtasks.lua | 63 ++++++++++++++++++++++--------------- 2 files changed, 64 insertions(+), 27 deletions(-) diff --git a/lua/pending/sync/gcal.lua b/lua/pending/sync/gcal.lua index 6dddaa6..12264dc 100644 --- a/lua/pending/sync/gcal.lua +++ b/lua/pending/sync/gcal.lua @@ -154,6 +154,21 @@ local function unlink_remote(task, extra, now_ts) task.modified = now_ts end +---@param parts {[1]: integer, [2]: string}[] +---@return string +local function fmt_counts(parts) + local items = {} + for _, p in ipairs(parts) do + if p[1] > 0 then + table.insert(items, p[1] .. ' ' .. p[2]) + end + end + if #items == 0 then + return 'nothing to do' + end + return table.concat(items, ' | ') +end + ---@param callback fun(access_token: string): nil local function with_token(callback) oauth.async(function() @@ -176,7 +191,7 @@ function M.push() local s = require('pending').store() local now_ts = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]] - local created, updated, deleted = 0, 0, 0 + local created, updated, deleted, failed = 0, 0, 0, 0 for _, task in ipairs(s:tasks()) do local extra = task._extra or {} @@ -197,6 +212,7 @@ function M.push() delete_event(access_token, cal_id --[[@as string]], event_id --[[@as string]]) if del_err then log.warn('gcal delete failed: ' .. del_err) + failed = failed + 1 else unlink_remote(task, extra, now_ts) deleted = deleted + 1 @@ -214,6 +230,7 @@ function M.push() local upd_err = update_event(access_token, cal_id, event_id, task) if upd_err then log.warn('gcal update failed: ' .. upd_err) + failed = failed + 1 else updated = updated + 1 end @@ -221,10 +238,12 @@ function M.push() local lid, lid_err = find_or_create_calendar(access_token, cat, calendars) if lid_err or not lid then log.warn('gcal calendar failed: ' .. (lid_err or 'unknown')) + failed = failed + 1 else local new_id, create_err = create_event(access_token, lid, task) if create_err then log.warn('gcal create failed: ' .. create_err) + failed = failed + 1 elseif new_id then if not task._extra then task._extra = {} @@ -245,7 +264,12 @@ function M.push() if buffer.bufnr() and vim.api.nvim_buf_is_valid(buffer.bufnr()) then buffer.render(buffer.bufnr()) end - log.info(string.format('Google Calendar pushed — +%d ~%d -%d', created, updated, deleted)) + log.info('gcal push: ' .. fmt_counts({ + { created, 'added' }, + { updated, 'updated' }, + { deleted, 'removed' }, + { failed, 'failed' }, + })) end) end diff --git a/lua/pending/sync/gtasks.lua b/lua/pending/sync/gtasks.lua index 7337030..a2a6da0 100644 --- a/lua/pending/sync/gtasks.lua +++ b/lua/pending/sync/gtasks.lua @@ -195,6 +195,21 @@ local function unlink_remote(task, now_ts) task.modified = now_ts end +---@param parts {[1]: integer, [2]: string}[] +---@return string +local function fmt_counts(parts) + local items = {} + for _, p in ipairs(parts) do + if p[1] > 0 then + table.insert(items, p[1] .. ' ' .. p[2]) + end + end + if #items == 0 then + return 'nothing to do' + end + return table.concat(items, ' | ') +end + ---@param task pending.Task ---@return table local function task_to_gtask(task) @@ -450,9 +465,12 @@ function M.push() if buffer.bufnr() and vim.api.nvim_buf_is_valid(buffer.bufnr()) then buffer.render(buffer.bufnr()) end - log.info( - string.format('Google Tasks pushed — +%d ~%d -%d !%d', created, updated, deleted, failed) - ) + log.info('gtasks push: ' .. fmt_counts({ + { created, 'added' }, + { updated, 'updated' }, + { deleted, 'deleted' }, + { failed, 'failed' }, + })) end) end @@ -474,15 +492,12 @@ function M.pull() if buffer.bufnr() and vim.api.nvim_buf_is_valid(buffer.bufnr()) then buffer.render(buffer.bufnr()) end - log.info( - string.format( - 'Google Tasks pulled — +%d ~%d !%d, unlinked: %d', - created, - updated, - failed, - unlinked - ) - ) + log.info('gtasks pull: ' .. fmt_counts({ + { created, 'added' }, + { updated, 'updated' }, + { unlinked, 'unlinked' }, + { failed, 'failed' }, + })) end) end @@ -506,19 +521,17 @@ function M.sync() if buffer.bufnr() and vim.api.nvim_buf_is_valid(buffer.bufnr()) then buffer.render(buffer.bufnr()) end - log.info( - string.format( - 'Google Tasks synced — push: +%d ~%d -%d !%d, pull: +%d ~%d !%d, unlinked: %d', - pushed_create, - pushed_update, - pushed_delete, - pushed_failed, - pulled_create, - pulled_update, - pulled_failed, - unlinked - ) - ) + log.info('gtasks sync — push: ' .. fmt_counts({ + { pushed_create, 'added' }, + { pushed_update, 'updated' }, + { pushed_delete, 'deleted' }, + { pushed_failed, 'failed' }, + }) .. ' pull: ' .. fmt_counts({ + { pulled_create, 'added' }, + { pulled_update, 'updated' }, + { unlinked, 'unlinked' }, + { pulled_failed, 'failed' }, + })) end) end From b641c93a0a51e9d3b77f3c0ec0c5d3e3e5541303 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Fri, 6 Mar 2026 15:47:42 -0500 Subject: [PATCH 45/66] fix(oauth): resolve re-auth deadlock and improve flow robustness (#87) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(oauth): resolve re-auth deadlock and improve flow robustness Problem: in-flight TCP server held port 18392 for up to 120 seconds. Calling `auth()` again caused `bind()` to fail silently — the browser opened but no listener could receive the OAuth callback. `_wipe()` on exchange failure also destroyed credentials, forcing full re-setup. Solution: `_active_close` at module scope cancels any in-flight server when `auth()` or `clear_tokens()` is called. Binding is guarded with `pcall`; the browser only opens after the server is listening. Swapped `_wipe()` for `clear_tokens()` in `_exchange_code` to preserve credentials on failure. Added `select_account` to `prompt` so Google always shows the account picker on re-auth. * test(oauth): isolate bundled-credentials fallback from real filesystem Problem: `resolve_credentials` reads from `vim.fn.stdpath('data')`, the real Neovim data dir. The test passed only because `_wipe()` was incidentally deleting the user's credential file mid-run. Solution: stub `oauth.load_json_file` for the duration of the test so real credential files cannot interfere with the fallback assertion. * ci: format --- lua/pending/sync/oauth.lua | 35 ++++++++++++++++++++++++++++------- spec/oauth_spec.lua | 5 +++++ 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/lua/pending/sync/oauth.lua b/lua/pending/sync/oauth.lua index 8539ea6..fd6e88d 100644 --- a/lua/pending/sync/oauth.lua +++ b/lua/pending/sync/oauth.lua @@ -25,6 +25,8 @@ local BUNDLED_CLIENT_SECRET = 'PLACEHOLDER' local OAuthClient = {} OAuthClient.__index = OAuthClient +local _active_close = nil + ---@class pending.oauth local M = {} @@ -348,6 +350,11 @@ end ---@param on_complete? fun(): nil ---@return nil function OAuthClient:auth(on_complete) + if _active_close then + _active_close() + _active_close = nil + end + local creds = self:resolve_credentials() if creds.client_id == BUNDLED_CLIENT_ID then log.error(self.name .. ': no credentials configured — run :Pending auth') @@ -379,14 +386,11 @@ function OAuthClient:auth(on_complete) .. '&scope=' .. M.url_encode(self.scope) .. '&access_type=offline' - .. '&prompt=consent' + .. '&prompt=select_account%20consent' .. '&code_challenge=' .. M.url_encode(code_challenge) .. '&code_challenge_method=S256' - vim.ui.open(auth_url) - log.info('Opening browser for Google authorization...') - local server = vim.uv.new_tcp() local server_closed = false local function close_server() @@ -394,10 +398,20 @@ function OAuthClient:auth(on_complete) return end server_closed = true + if _active_close == close_server then + _active_close = nil + end server:close() end + _active_close = close_server + + local bind_ok, bind_err = pcall(server.bind, server, '127.0.0.1', port) + if not bind_ok or bind_err == nil then + close_server() + log.error(self.name .. ': port ' .. port .. ' already in use — try again in a moment') + return + end - server:bind('127.0.0.1', port) server:listen(1, function(err) if err then return @@ -430,6 +444,9 @@ function OAuthClient:auth(on_complete) end) end) + vim.ui.open(auth_url) + log.info('Opening browser for Google authorization...') + vim.defer_fn(function() if not server_closed then close_server() @@ -470,14 +487,14 @@ function OAuthClient:_exchange_code(creds, code, code_verifier, port, on_complet }, { text = true }) if result.code ~= 0 then - self:_wipe() + self:clear_tokens() log.error('Token exchange failed.') return end local ok, decoded = pcall(vim.json.decode, result.stdout or '') if not ok or not decoded.access_token then - self:_wipe() + self:clear_tokens() log.error('Invalid token response.') return end @@ -498,6 +515,10 @@ end ---@return nil function OAuthClient:clear_tokens() + if _active_close then + _active_close() + _active_close = nil + end os.remove(self:token_path()) end diff --git a/spec/oauth_spec.lua b/spec/oauth_spec.lua index 520227d..a4a6f1d 100644 --- a/spec/oauth_spec.lua +++ b/spec/oauth_spec.lua @@ -142,8 +142,13 @@ describe('oauth', function() it('falls back to bundled credentials', function() config.reset() vim.g.pending = { data_path = tmpdir .. '/tasks.json' } + local orig_load = oauth.load_json_file + oauth.load_json_file = function() + return nil + end local c = oauth.new({ name = 'gtasks', scope = 'x', port = 0, config_key = 'gtasks' }) local creds = c:resolve_credentials() + oauth.load_json_file = orig_load assert.equals(oauth._BUNDLED_CLIENT_ID, creds.client_id) assert.equals(oauth._BUNDLED_CLIENT_SECRET, creds.client_secret) end) From 874ff381f96ac6b44727845a147dfc7cc937b097 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Fri, 6 Mar 2026 16:09:45 -0500 Subject: [PATCH 46/66] fix: resolve OAuth re-auth deadlock and sync concurrency races (#88) * fix(gtasks): prevent concurrent push/pull from racing on the store Problem: `push` and `pull` both run via `oauth.async`, so issuing them back-to-back starts two coroutines that interleave at every curl yield. Both snapshot `build_id_index` before either has mutated the store, which can cause push to create a remote task that pull would have recognized as already linked, producing duplicates on Google. Solution: guard `with_token` with a module-level `_in_flight` flag set before `oauth.async` is called so no second operation can start during a token-refresh yield. A `pcall` around the callback guarantees the flag is always cleared, even on an unexpected error. * refactor(sync): centralize `with_token` in oauth.lua with shared lock Problem: `with_token` was duplicated in `gcal.lua` and `gtasks.lua`, with the concurrency lock added only to the gtasks copy. Any new backend would silently inherit the same race, and gcal back-to-back push could still create duplicate remote calendar events. Solution: lift `with_token` into `oauth.lua` as `M.with_token(client, name, callback)` behind a module-level `_sync_in_flight` guard. All backends share one implementation; the lock covers gcal, gtasks, and any future backend automatically. * ci: format --- lua/pending/sync/gcal.lua | 14 +------------- lua/pending/sync/gtasks.lua | 18 +++--------------- lua/pending/sync/oauth.lua | 25 +++++++++++++++++++++++++ 3 files changed, 29 insertions(+), 28 deletions(-) diff --git a/lua/pending/sync/gcal.lua b/lua/pending/sync/gcal.lua index 12264dc..4669b89 100644 --- a/lua/pending/sync/gcal.lua +++ b/lua/pending/sync/gcal.lua @@ -169,20 +169,8 @@ local function fmt_counts(parts) return table.concat(items, ' | ') end ----@param callback fun(access_token: string): nil -local function with_token(callback) - oauth.async(function() - local token = oauth.google_client:get_access_token() - if not token then - log.warn('not authenticated — run :Pending auth') - return - end - callback(token) - end) -end - function M.push() - with_token(function(access_token) + oauth.with_token(oauth.google_client, 'gcal', function(access_token) local calendars, cal_err = get_all_calendars(access_token) if cal_err or not calendars then log.error(cal_err or 'failed to fetch calendars') diff --git a/lua/pending/sync/gtasks.lua b/lua/pending/sync/gtasks.lua index a2a6da0..5b19118 100644 --- a/lua/pending/sync/gtasks.lua +++ b/lua/pending/sync/gtasks.lua @@ -436,20 +436,8 @@ local function sync_setup(access_token) return tasklists, s, now_ts end ----@param callback fun(access_token: string): nil -local function with_token(callback) - oauth.async(function() - local token = oauth.google_client:get_access_token() - if not token then - log.warn('not authenticated — run :Pending auth') - return - end - callback(token) - end) -end - function M.push() - with_token(function(access_token) + oauth.with_token(oauth.google_client, 'gtasks', function(access_token) local tasklists, s, now_ts = sync_setup(access_token) if not tasklists then return @@ -475,7 +463,7 @@ function M.push() end function M.pull() - with_token(function(access_token) + oauth.with_token(oauth.google_client, 'gtasks', function(access_token) local tasklists, s, now_ts = sync_setup(access_token) if not tasklists then return @@ -502,7 +490,7 @@ function M.pull() end function M.sync() - with_token(function(access_token) + oauth.with_token(oauth.google_client, 'gtasks', function(access_token) local tasklists, s, now_ts = sync_setup(access_token) if not tasklists then return diff --git a/lua/pending/sync/oauth.lua b/lua/pending/sync/oauth.lua index fd6e88d..8c30268 100644 --- a/lua/pending/sync/oauth.lua +++ b/lua/pending/sync/oauth.lua @@ -26,6 +26,7 @@ local OAuthClient = {} OAuthClient.__index = OAuthClient local _active_close = nil +local _sync_in_flight = false ---@class pending.oauth local M = {} @@ -51,6 +52,30 @@ function M.async(fn) coroutine.resume(coroutine.create(fn)) end +---@param client pending.OAuthClient +---@param name string +---@param callback fun(access_token: string): nil +function M.with_token(client, name, callback) + if _sync_in_flight then + require('pending.log').warn(name .. ': sync operation in progress — please wait') + return + end + _sync_in_flight = true + M.async(function() + local token = client:get_access_token() + if not token then + _sync_in_flight = false + require('pending.log').warn(name .. ': not authenticated — run :Pending auth') + return + end + local ok, err = pcall(callback, token) + _sync_in_flight = false + if not ok then + require('pending.log').error(name .. ': ' .. tostring(err)) + end + end) +end + ---@param str string ---@return string function M.url_encode(str) From 12b9295c345ad4d111b07e2fce4d5db5ffbffa47 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Fri, 6 Mar 2026 18:38:17 -0500 Subject: [PATCH 47/66] refactor: normalize log message grammar and capitalization (#89) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: Log messages used inconsistent capitalization, punctuation, and phrasing — some started lowercase, some omitted periods, "Pending" was used instead of "task", and sync backend errors used ad-hoc formatting. Solution: Apply sentence case after backend prefixes, add trailing periods to complete sentences, rename "Pending" to "task", use `'Failed to : '` pattern for operation errors, and pluralize "Archived N task(s)" correctly. --- lua/pending/init.lua | 16 ++++++++-------- lua/pending/sync/gcal.lua | 10 +++++----- lua/pending/sync/gtasks.lua | 10 +++++----- lua/pending/sync/oauth.lua | 16 ++++++++-------- spec/sync_spec.lua | 4 ++-- 5 files changed, 28 insertions(+), 28 deletions(-) diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 4e093bf..e70e2fb 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -87,7 +87,7 @@ end local function require_saved() local bufnr = buffer.bufnr() if bufnr and vim.bo[bufnr].modified then - log.warn('save changes first (:w)') + log.warn('Save changes first (:w).') return false end return true @@ -511,7 +511,7 @@ function M.done(id_str) if bufnr and vim.api.nvim_buf_is_valid(bufnr) then buffer.render(bufnr) end - log.info('Task #' .. id .. ' marked ' .. (was_done and 'pending' or 'done')) + log.info('Task #' .. id .. ' marked ' .. (was_done and 'pending' or 'done') .. '.') end ---@return nil @@ -601,7 +601,7 @@ function M.add(text) s:load() local description, metadata = parse.command_add(text) if not description or description == '' then - log.error('Pending must have a description.') + log.error('Task must have a description.') return end s:add({ @@ -616,7 +616,7 @@ function M.add(text) if bufnr and vim.api.nvim_buf_is_valid(bufnr) then buffer.render(bufnr) end - log.info('Pending added: ' .. description) + log.info('Task added: ' .. description) end ---@type string[] @@ -649,7 +649,7 @@ local function run_sync(backend_name, action) return end if action == 'health' or type(backend[action]) ~= 'function' then - log.error(backend_name .. " backend has no '" .. action .. "' action") + log.error(backend_name .. ": No '" .. action .. "' action.") return end backend[action]() @@ -687,7 +687,7 @@ function M.archive(days) end s:replace_tasks(kept) _save_and_notify() - log.info('Archived ' .. archived .. ' tasks.') + log.info('Archived ' .. archived .. ' task' .. (archived == 1 and '' or 's') .. '.') local bufnr = buffer.bufnr() if bufnr and vim.api.nvim_buf_is_valid(bufnr) then buffer.render(bufnr) @@ -915,7 +915,7 @@ function M.edit(id_str, rest) buffer.render(bufnr) end - log.info('Task #' .. id .. ' updated: ' .. table.concat(feedback, ', ')) + log.info('Task #' .. id .. ' updated: ' .. table.concat(feedback, ', ') .. '.') end ---@param args? string @@ -977,7 +977,7 @@ function M.command(args) elseif cmd == 'undo' then M.undo_write() else - log.error('Unknown Pending subcommand: ' .. cmd) + log.error('Unknown subcommand: ' .. cmd) end end diff --git a/lua/pending/sync/gcal.lua b/lua/pending/sync/gcal.lua index 4669b89..80802a7 100644 --- a/lua/pending/sync/gcal.lua +++ b/lua/pending/sync/gcal.lua @@ -173,7 +173,7 @@ function M.push() oauth.with_token(oauth.google_client, 'gcal', function(access_token) local calendars, cal_err = get_all_calendars(access_token) if cal_err or not calendars then - log.error(cal_err or 'failed to fetch calendars') + log.error(cal_err or 'Failed to fetch calendars.') return end @@ -199,7 +199,7 @@ function M.push() local del_err = delete_event(access_token, cal_id --[[@as string]], event_id --[[@as string]]) if del_err then - log.warn('gcal delete failed: ' .. del_err) + log.warn('Failed to delete calendar event: ' .. del_err) failed = failed + 1 else unlink_remote(task, extra, now_ts) @@ -217,7 +217,7 @@ function M.push() if event_id and cal_id then local upd_err = update_event(access_token, cal_id, event_id, task) if upd_err then - log.warn('gcal update failed: ' .. upd_err) + log.warn('Failed to update calendar event: ' .. upd_err) failed = failed + 1 else updated = updated + 1 @@ -225,12 +225,12 @@ function M.push() else local lid, lid_err = find_or_create_calendar(access_token, cat, calendars) if lid_err or not lid then - log.warn('gcal calendar failed: ' .. (lid_err or 'unknown')) + log.warn('Failed to create calendar: ' .. (lid_err or 'unknown')) failed = failed + 1 else local new_id, create_err = create_event(access_token, lid, task) if create_err then - log.warn('gcal create failed: ' .. create_err) + log.warn('Failed to create calendar event: ' .. create_err) failed = failed + 1 elseif new_id then if not task._extra then diff --git a/lua/pending/sync/gtasks.lua b/lua/pending/sync/gtasks.lua index 5b19118..014e80a 100644 --- a/lua/pending/sync/gtasks.lua +++ b/lua/pending/sync/gtasks.lua @@ -281,7 +281,7 @@ local function push_pass(access_token, tasklists, s, now_ts, by_gtasks_id) if allow_remote_delete() then local err = delete_gtask(access_token, list_id, gtid) if err then - log.warn('gtasks delete failed: ' .. err) + log.warn('Failed to delete remote task: ' .. err) failed = failed + 1 else unlink_remote(task, now_ts) @@ -300,7 +300,7 @@ local function push_pass(access_token, tasklists, s, now_ts, by_gtasks_id) if not synced_at or task.modified > synced_at then local err = update_gtask(access_token, list_id, gtid, task_to_gtask(task)) if err then - log.warn('gtasks update failed: ' .. err) + log.warn('Failed to update remote task: ' .. err) failed = failed + 1 else task._extra = task._extra or {} @@ -314,7 +314,7 @@ local function push_pass(access_token, tasklists, s, now_ts, by_gtasks_id) if not err and lid then local new_id, create_err = create_gtask(access_token, lid, task_to_gtask(task)) if create_err then - log.warn('gtasks create failed: ' .. create_err) + log.warn('Failed to create remote task: ' .. create_err) failed = failed + 1 elseif new_id then if not task._extra then @@ -353,7 +353,7 @@ local function pull_pass(access_token, tasklists, s, now_ts, by_gtasks_id) for list_name, list_id in pairs(tasklists) do local items, err = list_gtasks(access_token, list_id) if err then - log.warn('error fetching list ' .. list_name .. ': ' .. err) + log.warn('Failed to fetch task list "' .. list_name .. '": ' .. err) failed = failed + 1 else fetched_list_ids[list_id] = true @@ -428,7 +428,7 @@ end local function sync_setup(access_token) local tasklists, tl_err = get_all_tasklists(access_token) if tl_err or not tasklists then - log.error(tl_err or 'failed to fetch task lists') + log.error(tl_err or 'Failed to fetch task lists.') return nil, nil, nil end local s = require('pending').store() diff --git a/lua/pending/sync/oauth.lua b/lua/pending/sync/oauth.lua index 8c30268..dabbe2d 100644 --- a/lua/pending/sync/oauth.lua +++ b/lua/pending/sync/oauth.lua @@ -57,7 +57,7 @@ end ---@param callback fun(access_token: string): nil function M.with_token(client, name, callback) if _sync_in_flight then - require('pending.log').warn(name .. ': sync operation in progress — please wait') + require('pending.log').warn(name .. ': Sync already in progress — please wait.') return end _sync_in_flight = true @@ -65,7 +65,7 @@ function M.with_token(client, name, callback) local token = client:get_access_token() if not token then _sync_in_flight = false - require('pending.log').warn(name .. ': not authenticated — run :Pending auth') + require('pending.log').warn(name .. ': Not authenticated — run :Pending auth.') return end local ok, err = pcall(callback, token) @@ -279,7 +279,7 @@ function OAuthClient:get_access_token() if now - obtained > expires - 60 then tokens = self:refresh_access_token(creds, tokens) if not tokens then - log.error(self.name .. ': token refresh failed — re-authenticating...') + log.error(self.name .. ': Token refresh failed — re-authenticating...') return nil end end @@ -364,10 +364,10 @@ function OAuthClient:setup() local path = vim.fn.stdpath('data') .. '/pending/google_credentials.json' local ok = M.save_json_file(path, { client_id = id, client_secret = secret }) if not ok then - log.error(self.name .. ': failed to save credentials') + log.error(self.name .. ': Failed to save credentials.') return end - log.info(self.name .. ': credentials saved, starting authorization...') + log.info(self.name .. ': Credentials saved, starting authorization...') self:auth() end) end @@ -382,7 +382,7 @@ function OAuthClient:auth(on_complete) local creds = self:resolve_credentials() if creds.client_id == BUNDLED_CLIENT_ID then - log.error(self.name .. ': no credentials configured — run :Pending auth') + log.error(self.name .. ': No credentials configured — run :Pending auth.') return end local port = self.port @@ -433,7 +433,7 @@ function OAuthClient:auth(on_complete) local bind_ok, bind_err = pcall(server.bind, server, '127.0.0.1', port) if not bind_ok or bind_err == nil then close_server() - log.error(self.name .. ': port ' .. port .. ' already in use — try again in a moment') + log.error(self.name .. ': Port ' .. port .. ' already in use — try again in a moment.') return end @@ -526,7 +526,7 @@ function OAuthClient:_exchange_code(creds, code, code_verifier, port, on_complet decoded.obtained_at = os.time() self:save_tokens(decoded) - log.info(self.name .. ' authorized successfully.') + log.info(self.name .. ': Authorized successfully.') if on_complete then on_complete() end diff --git a/spec/sync_spec.lua b/spec/sync_spec.lua index a491dd3..7771553 100644 --- a/spec/sync_spec.lua +++ b/spec/sync_spec.lua @@ -33,7 +33,7 @@ describe('sync', function() end pending.command('notreal') vim.notify = orig - assert.are.equal('[pending.nvim]: Unknown Pending subcommand: notreal', msg) + assert.are.equal('[pending.nvim]: Unknown subcommand: notreal', msg) end) it('errors on unknown action for valid backend', function() @@ -46,7 +46,7 @@ describe('sync', function() end pending.command('gcal notreal') vim.notify = orig - assert.are.equal("[pending.nvim]: gcal backend has no 'notreal' action", msg) + assert.are.equal("[pending.nvim]: gcal: No 'notreal' action.", msg) end) it('lists actions when action is omitted', function() From c39287431192907c46d36369777c2d93c238e93d Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Fri, 6 Mar 2026 20:08:49 -0500 Subject: [PATCH 48/66] feat(buffer): add configurable category-level folds (#91) Problem: category folds were hardcoded with no config option, no custom foldtext, and no vimdoc coverage. Solution: add `folding` config field (boolean or table with `foldtext` format string). Default foldtext is `%c (%n tasks)` with automatic singular/plural. Gate all fold logic on the config so `folding = false` disables folds entirely. Document the new option in vimdoc. --- doc/pending.txt | 36 +++++++++++++++++++++++++++++++++--- lua/pending/buffer.lua | 34 +++++++++++++++++++++++++++++++--- lua/pending/config.lua | 20 ++++++++++++++++++++ 3 files changed, 84 insertions(+), 6 deletions(-) diff --git a/doc/pending.txt b/doc/pending.txt index 994afc6..2883664 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -38,7 +38,7 @@ Features: ~ - 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 +- Configurable category folds (`zc`/`zo`) with custom foldtext - Omnifunc completion for `cat:`, `due:`, and `rec:` tokens (``) - Google Calendar one-way push via OAuth PKCE - Google Tasks bidirectional sync via OAuth PKCE @@ -274,8 +274,8 @@ 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`) - `zc` Fold the current category section (category view only) - `zo` Unfold the current category section (category view only) + `zc` Fold the current category section (requires `folding`) + `zo` Unfold the current category section (requires `folding`) Text objects (operator-pending and visual): ~ @@ -595,6 +595,7 @@ loads: >lua date_syntax = 'due', recur_syntax = 'rec', someday_date = '9999-12-30', + folding = true, category_order = {}, keymaps = { close = 'q', @@ -684,6 +685,35 @@ Fields: ~ given order. Categories not in the list are appended after the ordered ones in their natural order. + {folding} (boolean|table, default: true) *pending.FoldingConfig* + Controls category-level folds in category view. When + `true`, folds are enabled with the default foldtext + `'%c (%n tasks)'`. When `false`, folds are disabled + entirely. When a table, folds are enabled and the + table may contain: + + {foldtext} (string|false, default: '%c (%n tasks)') + Custom foldtext format string. Set to + `false` to use Vim's built-in + foldtext. Two specifiers are + available: + `%c` category name + `%n` number of tasks in the fold + The category icon is prepended + automatically. When `false`, the + default Vim foldtext is used. + + Folds only apply to category view; priority view + is always fold-free regardless of this setting. + + Examples: >lua + vim.g.pending = { folding = true } + vim.g.pending = { folding = false } + vim.g.pending = { + folding = { foldtext = '%c (%n tasks)' }, + } +< + {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 diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index adcf2dc..a54388b 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -236,8 +236,30 @@ local function setup_highlights() vim.api.nvim_set_hl(0, 'PendingFilter', { link = 'DiagnosticWarn', default = true }) end +---@return string +function M.get_foldtext() + local folding = config.resolve_folding() + if not folding.foldtext then + return vim.fn.foldtext() + end + local line = vim.fn.getline(vim.v.foldstart) + local cat = line:match('^#%s+(.+)$') or line + local task_count = vim.v.foldend - vim.v.foldstart + local icons = config.get().icons + local result = folding.foldtext + :gsub('%%c', cat) + :gsub('%%n', tostring(task_count)) + :gsub('(%d+) (%w+)s%)', function(n, word) + if n == '1' then + return n .. ' ' .. word .. ')' + end + return n .. ' ' .. word .. 's)' + end) + return icons.category .. ' ' .. result +end + local function snapshot_folds(bufnr) - if current_view ~= 'category' then + if current_view ~= 'category' or not config.resolve_folding().enabled then return end for _, winid in ipairs(vim.fn.win_findbuf(bufnr)) do @@ -256,7 +278,7 @@ local function snapshot_folds(bufnr) end local function restore_folds(bufnr) - if current_view ~= 'category' then + if current_view ~= 'category' or not config.resolve_folding().enabled then return end for _, winid in ipairs(vim.fn.win_findbuf(bufnr)) do @@ -328,12 +350,18 @@ function M.render(bufnr) setup_syntax(bufnr) apply_extmarks(bufnr, line_meta) + local folding = config.resolve_folding() for _, winid in ipairs(vim.fn.win_findbuf(bufnr)) do - if current_view == 'category' then + if current_view == 'category' and folding.enabled then vim.wo[winid].foldmethod = 'expr' vim.wo[winid].foldexpr = 'v:lua.require("pending.buffer").get_fold()' vim.wo[winid].foldlevel = 99 vim.wo[winid].foldenable = true + if folding.foldtext then + vim.wo[winid].foldtext = 'v:lua.require("pending.buffer").get_foldtext()' + else + vim.wo[winid].foldtext = 'foldtext()' + end else vim.wo[winid].foldmethod = 'manual' vim.wo[winid].foldenable = false diff --git a/lua/pending/config.lua b/lua/pending/config.lua index 9f1c760..d926037 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -1,3 +1,10 @@ +---@class pending.FoldingConfig +---@field foldtext? string|false + +---@class pending.ResolvedFolding +---@field enabled boolean +---@field foldtext string|false + ---@class pending.Icons ---@field pending string ---@field done string @@ -55,6 +62,7 @@ ---@field drawer_height? integer ---@field debug? boolean ---@field keymaps pending.Keymaps +---@field folding? boolean|pending.FoldingConfig ---@field sync? pending.SyncConfig ---@field icons pending.Icons @@ -70,6 +78,7 @@ local defaults = { date_syntax = 'due', recur_syntax = 'rec', someday_date = '9999-12-30', + folding = true, category_order = {}, keymaps = { close = 'q', @@ -119,4 +128,15 @@ function M.reset() _resolved = nil end +---@return pending.ResolvedFolding +function M.resolve_folding() + local raw = M.get().folding + if raw == false then + return { enabled = false, foldtext = false } + elseif raw == true or raw == nil then + return { enabled = true, foldtext = '%c (%n tasks)' } + end + return { enabled = true, foldtext = raw.foldtext or false } +end + return M From 559ab863a82cb79ce175bb945734171d070785fc Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Fri, 6 Mar 2026 21:36:04 -0500 Subject: [PATCH 49/66] fix(buffer): fix stale extmarks, duplicate window, and fold state loss (#92) Problem: Deleting lines (`dd`, `dat`, `d3j`) left extmarks stranded on adjacent rows since `render()` only clears and reapplies marks on `:w`. Quickfix `` opened the pending buffer in a second window because `BufEnter` did not redirect to `task_winid`. Category fold state was lost across `/` view toggles because `render()` overwrote the saved state with an empty snapshot taken while folds were disabled. Solution: Add a `TextChanged`/`TextChangedI` autocmd that clears the extmark namespace immediately on any edit. Fix `BufEnter` to close duplicate windows and redirect focus to `task_winid`, updating it when stale. Fix `snapshot_folds` to skip if a state is already saved, and `restore_folds` to always clear the saved state; snapshot in `toggle_view` before the view flips so the state survives the round-trip. --- lua/pending/buffer.lua | 19 ++++++++++++++++++- lua/pending/init.lua | 25 +++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index a54388b..7f13558 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -77,6 +77,18 @@ function M.clear_winid() task_winid = nil end +---@param winid integer +---@return nil +function M.update_winid(winid) + task_winid = winid +end + +---@param b? integer +---@return nil +function M.clear_marks(b) + vim.api.nvim_buf_clear_namespace(b or task_bufnr, task_ns, 0, -1) +end + ---@return nil function M.close() if not task_winid or not vim.api.nvim_win_is_valid(task_winid) then @@ -263,6 +275,9 @@ local function snapshot_folds(bufnr) return end for _, winid in ipairs(vim.fn.win_findbuf(bufnr)) do + if _fold_state[winid] ~= nil then + goto continue + end local state = {} vim.api.nvim_win_call(winid, function() for lnum, m in ipairs(_meta) do @@ -274,6 +289,7 @@ local function snapshot_folds(bufnr) end end) _fold_state[winid] = state + ::continue:: end end @@ -283,6 +299,7 @@ local function restore_folds(bufnr) end for _, winid in ipairs(vim.fn.win_findbuf(bufnr)) do local state = _fold_state[winid] + _fold_state[winid] = nil if state and next(state) ~= nil then vim.api.nvim_win_call(winid, function() vim.cmd('normal! zx') @@ -295,7 +312,6 @@ local function restore_folds(bufnr) end vim.api.nvim_win_set_cursor(0, saved) end) - _fold_state[winid] = nil end end end @@ -372,6 +388,7 @@ end ---@return nil function M.toggle_view() + snapshot_folds(task_bufnr) if current_view == 'category' then current_view = 'priority' else diff --git a/lua/pending/init.lua b/lua/pending/init.lua index e70e2fb..484711d 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -224,12 +224,37 @@ function M._setup_autocmds(bufnr) group = group, buffer = bufnr, callback = function() + local cur_win = vim.api.nvim_get_current_win() + local tw = buffer.winid() + if tw and vim.api.nvim_win_is_valid(tw) and cur_win ~= tw then + local cursor = vim.api.nvim_win_get_cursor(cur_win) + vim.schedule(function() + if vim.api.nvim_win_is_valid(cur_win) and #vim.api.nvim_list_wins() > 1 then + pcall(vim.api.nvim_win_close, cur_win, false) + end + if vim.api.nvim_win_is_valid(tw) then + vim.api.nvim_set_current_win(tw) + pcall(vim.api.nvim_win_set_cursor, tw, cursor) + end + end) + return + end + if not tw or not vim.api.nvim_win_is_valid(tw) then + buffer.update_winid(cur_win) + end if not vim.bo[bufnr].modified then get_store():load() buffer.render(bufnr) end end, }) + vim.api.nvim_create_autocmd({ 'TextChanged', 'TextChangedI' }, { + group = group, + buffer = bufnr, + callback = function() + buffer.clear_marks(bufnr) + end, + }) vim.api.nvim_create_autocmd('WinClosed', { group = group, callback = function(ev) From 5fe6dcecad49473c80b75601703dbc1eb0ab98c5 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 6 Mar 2026 21:36:46 -0500 Subject: [PATCH 50/66] ci: cleanup --- lua/pending/buffer.lua | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index 7f13558..3d29128 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -275,21 +275,19 @@ local function snapshot_folds(bufnr) return end for _, winid in ipairs(vim.fn.win_findbuf(bufnr)) do - if _fold_state[winid] ~= nil then - goto continue - end - local state = {} - vim.api.nvim_win_call(winid, function() - for lnum, m in ipairs(_meta) do - if m.type == 'header' and m.category then - if vim.fn.foldclosed(lnum) ~= -1 then - state[m.category] = true + if _fold_state[winid] == nil then + local state = {} + vim.api.nvim_win_call(winid, function() + for lnum, m in ipairs(_meta) do + if m.type == 'header' and m.category then + if vim.fn.foldclosed(lnum) ~= -1 then + state[m.category] = true + end end end - end - end) - _fold_state[winid] = state - ::continue:: + end) + _fold_state[winid] = state + end end end From d176ccccd13b2fdef96244301315831d0bf7d5a1 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Sat, 7 Mar 2026 01:38:12 -0500 Subject: [PATCH 51/66] fix(init): fix cursor position when navigating from quickfix (#93) Problem: `:Pending due` quickfix items landed on line 1 instead of the task line. The `BufEnter` redirect branch captured cursor before quickfix had positioned it in the new window, so the stale position was used when transferring focus back to the registered pending window. Solution: move cursor capture inside `vim.schedule` so it reads after quickfix navigation has completed. Also guard `clear_marks` behind a `modified` check so extmarks are only cleared on actual edits. --- lua/pending/init.lua | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 484711d..9fef199 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -227,14 +227,18 @@ function M._setup_autocmds(bufnr) local cur_win = vim.api.nvim_get_current_win() local tw = buffer.winid() if tw and vim.api.nvim_win_is_valid(tw) and cur_win ~= tw then - local cursor = vim.api.nvim_win_get_cursor(cur_win) vim.schedule(function() + local cursor = vim.api.nvim_win_is_valid(cur_win) + and vim.api.nvim_win_get_cursor(cur_win) + or nil if vim.api.nvim_win_is_valid(cur_win) and #vim.api.nvim_list_wins() > 1 then pcall(vim.api.nvim_win_close, cur_win, false) end if vim.api.nvim_win_is_valid(tw) then vim.api.nvim_set_current_win(tw) - pcall(vim.api.nvim_win_set_cursor, tw, cursor) + if cursor then + pcall(vim.api.nvim_win_set_cursor, tw, cursor) + end end end) return @@ -252,7 +256,9 @@ function M._setup_autocmds(bufnr) group = group, buffer = bufnr, callback = function() - buffer.clear_marks(bufnr) + if vim.bo[bufnr].modified then + buffer.clear_marks(bufnr) + end end, }) vim.api.nvim_create_autocmd('WinClosed', { From 522daf3a21baf09cf07aa8e3ae826d53d3554506 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sat, 7 Mar 2026 01:38:24 -0500 Subject: [PATCH 52/66] ci: format --- lua/pending/init.lua | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 9fef199..ee77c48 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -228,8 +228,7 @@ function M._setup_autocmds(bufnr) local tw = buffer.winid() if tw and vim.api.nvim_win_is_valid(tw) and cur_win ~= tw then vim.schedule(function() - local cursor = vim.api.nvim_win_is_valid(cur_win) - and vim.api.nvim_win_get_cursor(cur_win) + local cursor = vim.api.nvim_win_is_valid(cur_win) and vim.api.nvim_win_get_cursor(cur_win) or nil if vim.api.nvim_win_is_valid(cur_win) and #vim.api.nvim_list_wins() > 1 then pcall(vim.api.nvim_win_close, cur_win, false) From 79aeeba9bb9c17da22b70bbf9a15de2a19d70085 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sat, 7 Mar 2026 01:54:09 -0500 Subject: [PATCH 53/66] fix: minor login --- lua/pending/init.lua | 27 +++++++++------------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/lua/pending/init.lua b/lua/pending/init.lua index ee77c48..ccb5cf5 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -688,28 +688,20 @@ end ---@param days? integer ---@return nil function M.archive(days) - days = days or 30 - local cutoff = os.time() - (days * 86400) + if days == nil then + days = 30 + end + local cutoff = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (days * 86400)) --[[@as string]] local s = get_store() local tasks = s:tasks() + log.debug(('archive: days=%d cutoff=%s total_tasks=%d'):format(days, cutoff, #tasks)) local archived = 0 local kept = {} for _, task in ipairs(tasks) do if (task.status == 'done' or task.status == 'deleted') and task['end'] then - local y, mo, d, h, mi, sec = task['end']:match('^(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+)Z$') - if y then - local t = os.time({ - year = tonumber(y) --[[@as integer]], - month = tonumber(mo) --[[@as integer]], - day = tonumber(d) --[[@as integer]], - hour = tonumber(h) --[[@as integer]], - min = tonumber(mi) --[[@as integer]], - sec = tonumber(sec) --[[@as integer]], - }) - if t < cutoff then - archived = archived + 1 - goto skip - end + if task['end'] < cutoff then + archived = archived + 1 + goto skip end end table.insert(kept, task) @@ -998,8 +990,7 @@ function M.command(args) local action = rest:match('^(%S+)') run_sync(cmd, action) elseif cmd == 'archive' then - local d = rest ~= '' and tonumber(rest) or nil - M.archive(d) + M.archive(tonumber(rest)) elseif cmd == 'due' then M.due() elseif cmd == 'filter' then From 09757a593b5e4d9512f151a41d53206269cc661a Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Sat, 7 Mar 2026 01:58:09 -0500 Subject: [PATCH 54/66] Fix demo image link in README Updated demo image link in README. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 356096a..3d78405 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Edit tasks like text. Inspired by [oil.nvim](https://github.com/stevearc/oil.nvim), [vim-fugitive](https://github.com/tpope/vim-fugitive) -![demo](assets/demo.gif) +https://github.com/user-attachments/assets/f3898ecb-ec95-43fe-a71f-9c9f49628ba9 ## Requirements From 0176592ae22ed8ddfb5529bdb35d2c3913e7bd4a Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Sat, 7 Mar 2026 01:59:02 -0500 Subject: [PATCH 55/66] Enhance README with bold text and context Emphasize task editing feature and provide additional context. --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3d78405..7ecebaf 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # pending.nvim -Edit tasks like text. Inspired by +**Edit tasks like text.** + +Oil-like task management for todos in Neovim, inspired by [oil.nvim](https://github.com/stevearc/oil.nvim), [vim-fugitive](https://github.com/tpope/vim-fugitive) From 26d43688d0b732c1047274700ce84cb4e9248b52 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sat, 7 Mar 2026 01:59:10 -0500 Subject: [PATCH 56/66] ci: format --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7ecebaf..2e1c8a2 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # pending.nvim -**Edit tasks like text.** +**Edit tasks like text.** Oil-like task management for todos in Neovim, inspired by [oil.nvim](https://github.com/stevearc/oil.nvim), From 9af6086959fb2558e1c7053e6019f4f7cbf88638 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Sat, 7 Mar 2026 20:18:34 -0500 Subject: [PATCH 57/66] feat(buffer): persist fold state across sessions (#94) Problem: folded category headers are lost when Neovim exits because `_fold_state` only lives in memory. Users must re-fold categories every session. Solution: store folded category names in the JSON data file as a top-level `folded_categories` field. On first render, `restore_folds` seeds from the store instead of the empty in-memory state. Folds are persisted on `M.close()` and `VimLeavePre`. --- lua/pending/buffer.lua | 66 +++++++++++++++++++++++++++++++++++++++++- lua/pending/init.lua | 12 ++++++++ lua/pending/store.lua | 15 ++++++++++ spec/store_spec.lua | 29 +++++++++++++++++++ 4 files changed, 121 insertions(+), 1 deletion(-) diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index 3d29128..5865fb4 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -1,4 +1,5 @@ local config = require('pending.config') +local log = require('pending.log') local views = require('pending.views') ---@class pending.buffer @@ -18,6 +19,8 @@ local current_view = nil local _meta = {} ---@type table> local _fold_state = {} +---@type boolean +local _initial_fold_loaded = false ---@type string[] local _filter_predicates = {} ---@type table @@ -89,12 +92,52 @@ function M.clear_marks(b) vim.api.nvim_buf_clear_namespace(b or task_bufnr, task_ns, 0, -1) end +---@return nil +function M.persist_folds() + log.debug(('persist_folds: view=%s store=%s'):format(tostring(current_view), tostring(_store ~= nil))) + if current_view ~= 'category' or not _store then + log.debug('persist_folds: early return (view or store)') + return + end + local bufnr = task_bufnr + if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then + log.debug('persist_folds: early return (no valid bufnr)') + return + end + local folded = {} + local seen = {} + local wins = vim.fn.win_findbuf(bufnr) + log.debug(('persist_folds: checking %d windows for bufnr=%d, meta has %d entries'):format(#wins, bufnr, #_meta)) + for _, winid in ipairs(wins) do + if vim.api.nvim_win_is_valid(winid) then + vim.api.nvim_win_call(winid, function() + for lnum, m in ipairs(_meta) do + if m.type == 'header' and m.category and not seen[m.category] then + local closed = vim.fn.foldclosed(lnum) + log.debug(('persist_folds: win=%d lnum=%d cat=%s foldclosed=%d'):format(winid, lnum, m.category, closed)) + if closed ~= -1 then + seen[m.category] = true + table.insert(folded, m.category) + end + end + end + end) + end + end + log.debug(('persist_folds: saving %d folded categories: %s'):format(#folded, table.concat(folded, ', '))) + _store:set_folded_categories(folded) +end + ---@return nil function M.close() if not task_winid or not vim.api.nvim_win_is_valid(task_winid) then task_winid = nil return end + M.persist_folds() + if _store then + _store:save() + end local wins = vim.api.nvim_list_wins() if #wins == 1 then vim.cmd.enew() @@ -275,7 +318,7 @@ local function snapshot_folds(bufnr) return end for _, winid in ipairs(vim.fn.win_findbuf(bufnr)) do - if _fold_state[winid] == nil then + if _fold_state[winid] == nil and _initial_fold_loaded then local state = {} vim.api.nvim_win_call(winid, function() for lnum, m in ipairs(_meta) do @@ -292,18 +335,39 @@ local function snapshot_folds(bufnr) end local function restore_folds(bufnr) + log.debug(('restore_folds: view=%s folding_enabled=%s'):format( + tostring(current_view), tostring(config.resolve_folding().enabled))) if current_view ~= 'category' or not config.resolve_folding().enabled then return end for _, winid in ipairs(vim.fn.win_findbuf(bufnr)) do local state = _fold_state[winid] _fold_state[winid] = nil + log.debug(('restore_folds: win=%d has_fold_state=%s initial_loaded=%s has_store=%s'):format( + winid, tostring(state ~= nil), tostring(_initial_fold_loaded), tostring(_store ~= nil))) + if not state and not _initial_fold_loaded and _store then + _initial_fold_loaded = true + local cats = _store:get_folded_categories() + log.debug(('restore_folds: loaded %d categories from store: %s'):format(#cats, table.concat(cats, ', '))) + if #cats > 0 then + state = {} + for _, cat in ipairs(cats) do + state[cat] = true + end + end + end if state and next(state) ~= nil then + local applying = {} + for k in pairs(state) do + table.insert(applying, k) + end + log.debug(('restore_folds: applying folds for: %s'):format(table.concat(applying, ', '))) vim.api.nvim_win_call(winid, function() vim.cmd('normal! zx') local saved = vim.api.nvim_win_get_cursor(0) for lnum, m in ipairs(_meta) do if m.type == 'header' and m.category and state[m.category] then + log.debug(('restore_folds: folding lnum=%d cat=%s'):format(lnum, m.category)) vim.api.nvim_win_set_cursor(0, { lnum, 0 }) vim.cmd('normal! zc') end diff --git a/lua/pending/init.lua b/lua/pending/init.lua index ccb5cf5..d298062 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -268,6 +268,18 @@ function M._setup_autocmds(bufnr) end end, }) + vim.api.nvim_create_autocmd('VimLeavePre', { + group = group, + callback = function() + local bnr = buffer.bufnr() + log.debug(('VimLeavePre: bufnr=%s valid=%s'):format( + tostring(bnr), tostring(bnr and vim.api.nvim_buf_is_valid(bnr)))) + if bnr and vim.api.nvim_buf_is_valid(bnr) then + buffer.persist_folds() + get_store():save() + end + end, + }) end ---@param bufnr integer diff --git a/lua/pending/store.lua b/lua/pending/store.lua index ff68525..20898fd 100644 --- a/lua/pending/store.lua +++ b/lua/pending/store.lua @@ -20,6 +20,7 @@ local config = require('pending.config') ---@field next_id integer ---@field tasks pending.Task[] ---@field undo pending.Task[][] +---@field folded_categories string[] ---@class pending.Store ---@field path string @@ -39,6 +40,7 @@ local function empty_data() next_id = 1, tasks = {}, undo = {}, + folded_categories = {}, } end @@ -171,6 +173,7 @@ function Store:load() next_id = decoded.next_id or 1, tasks = {}, undo = {}, + folded_categories = decoded.folded_categories or {}, } for _, t in ipairs(decoded.tasks or {}) do table.insert(self._data.tasks, table_to_task(t)) @@ -199,6 +202,7 @@ function Store:save() next_id = self._data.next_id, tasks = {}, undo = {}, + folded_categories = self._data.folded_categories, } for _, task in ipairs(self._data.tasks) do table.insert(out.tasks, task_to_table(task)) @@ -371,6 +375,17 @@ function Store:set_next_id(id) self:data().next_id = id end +---@return string[] +function Store:get_folded_categories() + return self:data().folded_categories +end + +---@param cats string[] +---@return nil +function Store:set_folded_categories(cats) + self:data().folded_categories = cats +end + ---@return nil function Store:unload() self._data = nil diff --git a/spec/store_spec.lua b/spec/store_spec.lua index 0bed750..827dd21 100644 --- a/spec/store_spec.lua +++ b/spec/store_spec.lua @@ -214,6 +214,35 @@ describe('store', function() end) end) + describe('folded_categories', function() + it('defaults to empty table when missing from JSON', function() + local path = tmpdir .. '/tasks.json' + local f = io.open(path, 'w') + f:write(vim.json.encode({ + version = 1, + next_id = 1, + tasks = {}, + })) + f:close() + s:load() + assert.are.same({}, s:get_folded_categories()) + end) + + it('round-trips folded categories through save and load', function() + s:set_folded_categories({ 'Work', 'Home' }) + s:save() + s:load() + assert.are.same({ 'Work', 'Home' }, s:get_folded_categories()) + end) + + it('persists empty list', function() + s:set_folded_categories({}) + s:save() + s:load() + assert.are.same({}, s:get_folded_categories()) + end) + end) + describe('active_tasks', function() it('excludes deleted tasks', function() s:add({ description = 'Active' }) From d06731a7fdfaeedd4214e2ece2030f016d6f0d5c Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 8 Mar 2026 13:24:44 -0400 Subject: [PATCH 58/66] ci: format --- lua/pending/buffer.lua | 50 ++++++++++++++++++++++++++++++++++-------- lua/pending/init.lua | 8 +++++-- 2 files changed, 47 insertions(+), 11 deletions(-) diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index 5865fb4..ecdae4c 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -94,7 +94,9 @@ end ---@return nil function M.persist_folds() - log.debug(('persist_folds: view=%s store=%s'):format(tostring(current_view), tostring(_store ~= nil))) + log.debug( + ('persist_folds: view=%s store=%s'):format(tostring(current_view), tostring(_store ~= nil)) + ) if current_view ~= 'category' or not _store then log.debug('persist_folds: early return (view or store)') return @@ -107,14 +109,27 @@ function M.persist_folds() local folded = {} local seen = {} local wins = vim.fn.win_findbuf(bufnr) - log.debug(('persist_folds: checking %d windows for bufnr=%d, meta has %d entries'):format(#wins, bufnr, #_meta)) + log.debug( + ('persist_folds: checking %d windows for bufnr=%d, meta has %d entries'):format( + #wins, + bufnr, + #_meta + ) + ) for _, winid in ipairs(wins) do if vim.api.nvim_win_is_valid(winid) then vim.api.nvim_win_call(winid, function() for lnum, m in ipairs(_meta) do if m.type == 'header' and m.category and not seen[m.category] then local closed = vim.fn.foldclosed(lnum) - log.debug(('persist_folds: win=%d lnum=%d cat=%s foldclosed=%d'):format(winid, lnum, m.category, closed)) + log.debug( + ('persist_folds: win=%d lnum=%d cat=%s foldclosed=%d'):format( + winid, + lnum, + m.category, + closed + ) + ) if closed ~= -1 then seen[m.category] = true table.insert(folded, m.category) @@ -124,7 +139,9 @@ function M.persist_folds() end) end end - log.debug(('persist_folds: saving %d folded categories: %s'):format(#folded, table.concat(folded, ', '))) + log.debug( + ('persist_folds: saving %d folded categories: %s'):format(#folded, table.concat(folded, ', ')) + ) _store:set_folded_categories(folded) end @@ -335,20 +352,35 @@ local function snapshot_folds(bufnr) end local function restore_folds(bufnr) - log.debug(('restore_folds: view=%s folding_enabled=%s'):format( - tostring(current_view), tostring(config.resolve_folding().enabled))) + log.debug( + ('restore_folds: view=%s folding_enabled=%s'):format( + tostring(current_view), + tostring(config.resolve_folding().enabled) + ) + ) if current_view ~= 'category' or not config.resolve_folding().enabled then return end for _, winid in ipairs(vim.fn.win_findbuf(bufnr)) do local state = _fold_state[winid] _fold_state[winid] = nil - log.debug(('restore_folds: win=%d has_fold_state=%s initial_loaded=%s has_store=%s'):format( - winid, tostring(state ~= nil), tostring(_initial_fold_loaded), tostring(_store ~= nil))) + log.debug( + ('restore_folds: win=%d has_fold_state=%s initial_loaded=%s has_store=%s'):format( + winid, + tostring(state ~= nil), + tostring(_initial_fold_loaded), + tostring(_store ~= nil) + ) + ) if not state and not _initial_fold_loaded and _store then _initial_fold_loaded = true local cats = _store:get_folded_categories() - log.debug(('restore_folds: loaded %d categories from store: %s'):format(#cats, table.concat(cats, ', '))) + log.debug( + ('restore_folds: loaded %d categories from store: %s'):format( + #cats, + table.concat(cats, ', ') + ) + ) if #cats > 0 then state = {} for _, cat in ipairs(cats) do diff --git a/lua/pending/init.lua b/lua/pending/init.lua index d298062..af018a7 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -272,8 +272,12 @@ function M._setup_autocmds(bufnr) group = group, callback = function() local bnr = buffer.bufnr() - log.debug(('VimLeavePre: bufnr=%s valid=%s'):format( - tostring(bnr), tostring(bnr and vim.api.nvim_buf_is_valid(bnr)))) + log.debug( + ('VimLeavePre: bufnr=%s valid=%s'):format( + tostring(bnr), + tostring(bnr and vim.api.nvim_buf_is_valid(bnr)) + ) + ) if bnr and vim.api.nvim_buf_is_valid(bnr) then buffer.persist_folds() get_store():save() From ab06cfcf69e96c63d691a9eca4cc1137bbc10626 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Sun, 8 Mar 2026 14:19:47 -0400 Subject: [PATCH 59/66] feat(buffer): persist extmarks during editing (#96) * refactor(buffer): split extmark namespace into `ns_eol` and `ns_inline` Problem: all extmarks shared a single `pending` namespace, making it impossible to selectively clear position-sensitive extmarks (overlays, highlights) while preserving stable EOL virtual text (due dates, recurrence). Solution: introduce `ns_eol` for end-of-line virtual text and `ns_inline` for overlays and highlights. `clear_marks()` and `apply_extmarks()` operate on both namespaces independently. * feat(buffer): track line changes via `on_bytes` to keep `_meta` aligned Problem: `_meta` is a positional array keyed by line number. Line insertions and deletions during editing desync it from actual buffer content, breaking `get_fold()`, cursor-based task lookups, and extmark re-application. Solution: attach an `on_bytes` callback that adjusts `_meta` on line insertions/deletions and tracks dirty rows. Remove the manual `_meta` insert from `open_line()` since `on_bytes` now handles it. Reset dirty rows on each full render. * feat(buffer): clear only inline extmarks on dirty rows during edits Problem: `TextChanged` cleared all extmarks (both namespaces) on every edit, causing EOL virtual text (due dates, recurrence) to vanish while the user types. Solution: replace blanket `clear_marks()` with per-row `clear_inline_row()` that only removes `ns_inline` extmarks on rows flagged dirty by `on_bytes`. EOL virtual text is preserved untouched. * feat(buffer): re-apply inline extmarks after edits Problem: inline extmarks (checkbox overlays, strikethrough, header highlights) were cleared during edits and only restored on `:w`, leaving the buffer visually bare while editing. Solution: extract `apply_inline_row()` from `apply_extmarks()` and call it via `reapply_dirty_inline()` on `InsertLeave` and normal-mode `TextChanged`. Insert-mode `TextChangedI` still only clears inline marks on dirty rows to avoid overlay flicker while typing. * fix(buffer): suppress `on_bytes` during render and fix definition order Problem: `on_bytes` fired during `render()`'s `nvim_buf_set_lines`, corrupting `_meta` with duplicate entries and causing out-of-range extmark errors. Also, `apply_inline_row` was defined after its first caller `reapply_dirty_inline`. Solution: add `_rendering` guard flag around `nvim_buf_set_lines` in `render()` so `on_bytes` is a no-op during authoritative renders. Move `apply_inline_row` above `reapply_dirty_inline` to satisfy Lua local scoping rules. --- lua/pending/buffer.lua | 184 ++++++++++++++++++++++++++++++----------- lua/pending/init.lua | 26 +++++- 2 files changed, 162 insertions(+), 48 deletions(-) diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index ecdae4c..c3e666f 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -12,7 +12,8 @@ local _store = nil local task_bufnr = nil ---@type integer? local task_winid = nil -local task_ns = vim.api.nvim_create_namespace('pending') +local ns_eol = vim.api.nvim_create_namespace('pending_eol') +local ns_inline = vim.api.nvim_create_namespace('pending_inline') ---@type 'category'|'priority'|nil local current_view = nil ---@type pending.LineMeta[] @@ -25,6 +26,12 @@ local _initial_fold_loaded = false local _filter_predicates = {} ---@type table local _hidden_ids = {} +---@type table +local _dirty_rows = {} +---@type boolean +local _on_bytes_active = false +---@type boolean +local _rendering = false ---@return pending.LineMeta[] function M.meta() @@ -89,7 +96,127 @@ end ---@param b? integer ---@return nil function M.clear_marks(b) - vim.api.nvim_buf_clear_namespace(b or task_bufnr, task_ns, 0, -1) + local bufnr = b or task_bufnr + vim.api.nvim_buf_clear_namespace(bufnr, ns_eol, 0, -1) + vim.api.nvim_buf_clear_namespace(bufnr, ns_inline, 0, -1) +end + +---@param b integer +---@param row integer +---@return nil +function M.clear_inline_row(b, row) + vim.api.nvim_buf_clear_namespace(b, ns_inline, row - 1, row) +end + +---@return table +function M.dirty_rows() + return _dirty_rows +end + +---@return nil +function M.clear_dirty_rows() + _dirty_rows = {} +end + +---@param bufnr integer +---@param row integer +---@param m pending.LineMeta +---@param icons table +local function apply_inline_row(bufnr, row, m, icons) + 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, ns_inline, row, 0, { + end_col = #line, + hl_group = 'PendingFilter', + }) + elseif m.type == 'task' then + if m.status == 'done' then + local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or '' + local col_start = line:find('/%d+/') and select(2, line:find('/%d+/')) or 0 + vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, col_start, { + end_col = #line, + 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 + vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, bracket_col, { + virt_text = { { '[' .. icon .. ']', 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, ns_inline, row, 0, { + end_col = #line, + hl_group = 'PendingHeader', + }) + vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, 0, { + virt_text = { { icons.category .. ' ', 'PendingHeader' } }, + virt_text_pos = 'overlay', + priority = 100, + }) + end +end + +---@param bufnr integer +---@return nil +function M.reapply_dirty_inline(bufnr) + if not next(_dirty_rows) then + return + end + local icons = config.get().icons + for row in pairs(_dirty_rows) do + local m = _meta[row] + if m then + vim.api.nvim_buf_clear_namespace(bufnr, ns_inline, row - 1, row) + apply_inline_row(bufnr, row - 1, m, icons) + end + end + _dirty_rows = {} +end + +---@param bufnr integer +---@return nil +function M.attach_bytes(bufnr) + if _on_bytes_active then + return + end + _on_bytes_active = true + vim.api.nvim_buf_attach(bufnr, false, { + on_bytes = function(_, buf, _, start_row, _, _, old_end_row, _, _, new_end_row, _, _) + if buf ~= task_bufnr then + _on_bytes_active = false + return true + end + if _rendering then + return + end + local delta = new_end_row - old_end_row + if delta > 0 then + for _ = 1, delta do + table.insert(_meta, start_row + 2, { type = 'task' }) + end + elseif delta < 0 then + for _ = 1, -delta do + if _meta[start_row + 2] then + table.remove(_meta, start_row + 2) + end + end + end + for r = start_row + 1, start_row + 1 + math.max(0, new_end_row) do + _dirty_rows[r] = true + end + end, + }) end ---@return nil @@ -205,7 +332,6 @@ function M.open_line(above) local insert_row = above and (row - 1) or row vim.bo[bufnr].modifiable = true vim.api.nvim_buf_set_lines(bufnr, insert_row, insert_row, false, { '- [ ] ' }) - table.insert(_meta, insert_row + 1, { type = 'task' }) vim.api.nvim_win_set_cursor(0, { insert_row + 1, 6 }) vim.cmd('startinsert!') end @@ -230,16 +356,11 @@ end ---@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) + vim.api.nvim_buf_clear_namespace(bufnr, ns_eol, 0, -1) + vim.api.nvim_buf_clear_namespace(bufnr, ns_inline, 0, -1) for i, m in ipairs(line_meta) do local row = i - 1 - 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 + if m.type == 'task' then local due_hl = m.overdue and 'PendingOverdue' or 'PendingDue' local virt_parts = {} if m.show_category and m.category then @@ -255,46 +376,13 @@ local function apply_extmarks(bufnr, line_meta) for p = 1, #virt_parts - 1 do virt_parts[p][1] = virt_parts[p][1] .. ' ' end - vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, { + vim.api.nvim_buf_set_extmark(bufnr, ns_eol, row, 0, { virt_text = virt_parts, virt_text_pos = 'eol', }) end - if m.status == 'done' then - local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or '' - local col_start = line:find('/%d+/') and select(2, line:find('/%d+/')) or 0 - vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, col_start, { - end_col = #line, - 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 - vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, bracket_col, { - virt_text = { { '[' .. icon .. ']', 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.category .. ' ', 'PendingHeader' } }, - virt_text_pos = 'overlay', - priority = 100, - }) end + apply_inline_row(bufnr, row, m, icons) end end @@ -448,12 +536,15 @@ function M.render(bufnr) end _meta = line_meta + _dirty_rows = {} snapshot_folds(bufnr) vim.bo[bufnr].modifiable = true local saved = vim.bo[bufnr].undolevels vim.bo[bufnr].undolevels = -1 + _rendering = true vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + _rendering = false vim.bo[bufnr].modified = false vim.bo[bufnr].undolevels = saved @@ -507,6 +598,7 @@ function M.open() if not (task_bufnr and vim.api.nvim_buf_is_valid(task_bufnr)) then task_bufnr = vim.api.nvim_create_buf(true, false) set_buf_options(task_bufnr) + M.attach_bytes(task_bufnr) end vim.cmd('botright new') diff --git a/lua/pending/init.lua b/lua/pending/init.lua index af018a7..4d05503 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -251,12 +251,34 @@ function M._setup_autocmds(bufnr) end end, }) - vim.api.nvim_create_autocmd({ 'TextChanged', 'TextChangedI' }, { + vim.api.nvim_create_autocmd('TextChangedI', { + group = group, + buffer = bufnr, + callback = function() + if not vim.bo[bufnr].modified then + return + end + for row in pairs(buffer.dirty_rows()) do + buffer.clear_inline_row(bufnr, row) + end + end, + }) + vim.api.nvim_create_autocmd('TextChanged', { + group = group, + buffer = bufnr, + callback = function() + if not vim.bo[bufnr].modified then + return + end + buffer.reapply_dirty_inline(bufnr) + end, + }) + vim.api.nvim_create_autocmd('InsertLeave', { group = group, buffer = bufnr, callback = function() if vim.bo[bufnr].modified then - buffer.clear_marks(bufnr) + buffer.reapply_dirty_inline(bufnr) end end, }) From c9471ebe90584c58f33a73af3744ff71d681899b Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Sun, 8 Mar 2026 14:28:10 -0400 Subject: [PATCH 60/66] feat: persistent inline extmarks and configurable EOL format (#97) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(buffer): split extmark namespace into `ns_eol` and `ns_inline` Problem: all extmarks shared a single `pending` namespace, making it impossible to selectively clear position-sensitive extmarks (overlays, highlights) while preserving stable EOL virtual text (due dates, recurrence). Solution: introduce `ns_eol` for end-of-line virtual text and `ns_inline` for overlays and highlights. `clear_marks()` and `apply_extmarks()` operate on both namespaces independently. * feat(buffer): track line changes via `on_bytes` to keep `_meta` aligned Problem: `_meta` is a positional array keyed by line number. Line insertions and deletions during editing desync it from actual buffer content, breaking `get_fold()`, cursor-based task lookups, and extmark re-application. Solution: attach an `on_bytes` callback that adjusts `_meta` on line insertions/deletions and tracks dirty rows. Remove the manual `_meta` insert from `open_line()` since `on_bytes` now handles it. Reset dirty rows on each full render. * feat(buffer): clear only inline extmarks on dirty rows during edits Problem: `TextChanged` cleared all extmarks (both namespaces) on every edit, causing EOL virtual text (due dates, recurrence) to vanish while the user types. Solution: replace blanket `clear_marks()` with per-row `clear_inline_row()` that only removes `ns_inline` extmarks on rows flagged dirty by `on_bytes`. EOL virtual text is preserved untouched. * feat(buffer): re-apply inline extmarks after edits Problem: inline extmarks (checkbox overlays, strikethrough, header highlights) were cleared during edits and only restored on `:w`, leaving the buffer visually bare while editing. Solution: extract `apply_inline_row()` from `apply_extmarks()` and call it via `reapply_dirty_inline()` on `InsertLeave` and normal-mode `TextChanged`. Insert-mode `TextChangedI` still only clears inline marks on dirty rows to avoid overlay flicker while typing. * fix(buffer): suppress `on_bytes` during render and fix definition order Problem: `on_bytes` fired during `render()`'s `nvim_buf_set_lines`, corrupting `_meta` with duplicate entries and causing out-of-range extmark errors. Also, `apply_inline_row` was defined after its first caller `reapply_dirty_inline`. Solution: add `_rendering` guard flag around `nvim_buf_set_lines` in `render()` so `on_bytes` is a no-op during authoritative renders. Move `apply_inline_row` above `reapply_dirty_inline` to satisfy Lua local scoping rules. * feat(buffer): add configurable `eol_format` for EOL virtual text Problem: EOL virtual text order (category → recurrence → due) and the double-space separator are hardcoded in `apply_extmarks()`. Users cannot reorder, omit, or restyle metadata fields. Solution: Add `eol_format` config field (default `'%c %r %d'`) with `%c`, `%r`, `%d` specifiers. `parse_eol_format()` tokenizes the format string; `build_eol_virt()` resolves specifiers against `LineMeta` and collapses literals around absent fields. * ci: format --- doc/pending.txt | 26 +++++++++++ lua/pending/buffer.lua | 102 +++++++++++++++++++++++++++++++++++------ lua/pending/config.lua | 2 + 3 files changed, 115 insertions(+), 15 deletions(-) diff --git a/doc/pending.txt b/doc/pending.txt index 2883664..11c3973 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -648,6 +648,32 @@ Fields: ~ virtual text in the buffer. Examples: `'%Y-%m-%d'` for ISO dates, `'%d %b'` for day-first. + {eol_format} (string, default: '%c %r %d') + Format string controlling the order, content, and + separators of end-of-line virtual text on task lines. + Three specifiers are available: + + `%c` category icon + name (`PendingHeader`) + `%r` recurrence icon + pattern (`PendingRecur`) + `%d` due icon + date (`PendingDue` / `PendingOverdue`) + + Literal text between specifiers is rendered with the + `Normal` highlight group and acts as a separator. + When a specifier's data is absent (e.g. `%d` on a + task with no due date), the specifier and any + surrounding literal text up to the next specifier + are omitted — missing fields never leave gaps. + + `%c` only renders in priority view (where + `show_category` is true). In category view it is + always omitted regardless of the format string. + + Examples: >lua + vim.g.pending = { eol_format = '%d %r' } + vim.g.pending = { eol_format = '%d | %r' } + vim.g.pending = { eol_format = '%c %d %r' } +< + {input_date_formats} (string[], default: {}) *pending-input-formats* List of strftime-like format strings tried in order when parsing a `due:` token that does not match the diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index c3e666f..638ea60 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -352,30 +352,102 @@ function M.get_fold() end end +---@class pending.EolSegment +---@field type 'specifier'|'literal' +---@field key? 'c'|'r'|'d' +---@field text? string + +---@param fmt string +---@return pending.EolSegment[] +local function parse_eol_format(fmt) + local segments = {} + local pos = 1 + local len = #fmt + while pos <= len do + if fmt:sub(pos, pos) == '%' and pos + 1 <= len then + local key = fmt:sub(pos + 1, pos + 1) + if key == 'c' or key == 'r' or key == 'd' then + table.insert(segments, { type = 'specifier', key = key }) + pos = pos + 2 + else + table.insert(segments, { type = 'literal', text = '%' .. key }) + pos = pos + 2 + end + else + local next_pct = fmt:find('%%', pos + 1) + local chunk = next_pct and fmt:sub(pos, next_pct - 1) or fmt:sub(pos) + table.insert(segments, { type = 'literal', text = chunk }) + pos = pos + #chunk + end + end + return segments +end + +---@param segments pending.EolSegment[] +---@param m pending.LineMeta +---@param icons pending.Icons +---@return string[][] +local function build_eol_virt(segments, m, icons) + local due_hl = m.overdue and 'PendingOverdue' or 'PendingDue' + local resolved = {} + for i, seg in ipairs(segments) do + if seg.type == 'specifier' then + local text, hl + if seg.key == 'c' and m.show_category and m.category then + text = icons.category .. ' ' .. m.category + hl = 'PendingHeader' + elseif seg.key == 'r' and m.recur then + text = icons.recur .. ' ' .. m.recur + hl = 'PendingRecur' + elseif seg.key == 'd' and m.due then + text = icons.due .. ' ' .. m.due + hl = due_hl + end + resolved[i] = text and { text = text, hl = hl, present = true } or { present = false } + else + resolved[i] = { text = seg.text, hl = 'Normal', literal = true } + end + end + + local virt_parts = {} + for i, r in ipairs(resolved) do + if r.literal then + local prev_present, next_present = false, false + for j = i - 1, 1, -1 do + if not resolved[j].literal then + prev_present = resolved[j].present + break + end + end + for j = i + 1, #resolved do + if not resolved[j].literal then + next_present = resolved[j].present + break + end + end + if prev_present and next_present then + table.insert(virt_parts, { r.text, r.hl }) + end + elseif r.present then + table.insert(virt_parts, { r.text, r.hl }) + end + end + return virt_parts +end + ---@param bufnr integer ---@param line_meta pending.LineMeta[] local function apply_extmarks(bufnr, line_meta) - local icons = config.get().icons + local cfg = config.get() + local icons = cfg.icons + local eol_segments = parse_eol_format(cfg.eol_format or '%c %r %d') vim.api.nvim_buf_clear_namespace(bufnr, ns_eol, 0, -1) vim.api.nvim_buf_clear_namespace(bufnr, ns_inline, 0, -1) for i, m in ipairs(line_meta) do local row = i - 1 if m.type == 'task' then - local due_hl = m.overdue and 'PendingOverdue' or 'PendingDue' - local virt_parts = {} - if m.show_category and m.category then - table.insert(virt_parts, { icons.category .. ' ' .. m.category, 'PendingHeader' }) - end - if m.recur then - table.insert(virt_parts, { icons.recur .. ' ' .. m.recur, 'PendingRecur' }) - end - if m.due then - table.insert(virt_parts, { icons.due .. ' ' .. m.due, due_hl }) - end + local virt_parts = build_eol_virt(eol_segments, m, icons) if #virt_parts > 0 then - for p = 1, #virt_parts - 1 do - virt_parts[p][1] = virt_parts[p][1] .. ' ' - end vim.api.nvim_buf_set_extmark(bufnr, ns_eol, row, 0, { virt_text = virt_parts, virt_text_pos = 'eol', diff --git a/lua/pending/config.lua b/lua/pending/config.lua index d926037..b7a42ad 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -64,6 +64,7 @@ ---@field keymaps pending.Keymaps ---@field folding? boolean|pending.FoldingConfig ---@field sync? pending.SyncConfig +---@field eol_format? string ---@field icons pending.Icons ---@class pending.config @@ -78,6 +79,7 @@ local defaults = { date_syntax = 'due', recur_syntax = 'rec', someday_date = '9999-12-30', + eol_format = '%c %r %d', folding = true, category_order = {}, keymaps = { From 91cce0a82ec1a803c4aca28c4b8c2c93eb92fefc Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Sun, 8 Mar 2026 14:59:30 -0400 Subject: [PATCH 61/66] Fix formatting in README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2e1c8a2..3448941 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ **Edit tasks like text.** Oil-like task management for todos in Neovim, inspired by -[oil.nvim](https://github.com/stevearc/oil.nvim), +[oil.nvim](https://github.com/stevearc/oil.nvim) and [vim-fugitive](https://github.com/tpope/vim-fugitive) https://github.com/user-attachments/assets/f3898ecb-ec95-43fe-a71f-9c9f49628ba9 From a43f7693832cb96fd785155c3884e1eddbfd585a Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Sun, 8 Mar 2026 19:13:17 -0400 Subject: [PATCH 62/66] refactor(config): nest view settings under `view` key (#103) Problem: View-related config fields (`default_view`, `eol_format`, `category_order`, `folding`) are scattered as top-level siblings alongside unrelated fields like `data_path` and `date_syntax`. Solution: Group them under a `view` table with per-view sub-tables: `view.default`, `view.eol_format`, `view.category.order`, `view.category.folding`, and `view.queue` (empty, ready for #100). Update all call sites, tests, and vimdoc. --- doc/pending.txt | 113 ++++++++++++++++++++--------------------- lua/pending/buffer.lua | 4 +- lua/pending/config.lua | 32 ++++++++---- lua/pending/views.lua | 2 +- spec/views_spec.lua | 4 +- 5 files changed, 82 insertions(+), 73 deletions(-) diff --git a/doc/pending.txt b/doc/pending.txt index 11c3973..f8c61a9 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -589,14 +589,20 @@ Configuration is done via `vim.g.pending`. Set this before the plugin loads: >lua vim.g.pending = { data_path = vim.fn.stdpath('data') .. '/pending/tasks.json', - default_view = 'category', default_category = 'Todo', date_format = '%b %d', date_syntax = 'due', recur_syntax = 'rec', someday_date = '9999-12-30', - folding = true, - category_order = {}, + view = { + default = 'category', + eol_format = '%c %r %d', + category = { + order = {}, + folding = true, + }, + queue = {}, + }, keymaps = { close = 'q', toggle = '', @@ -634,10 +640,6 @@ Fields: ~ See |pending-store-resolution| for how the active store is chosen at runtime. - {default_view} ('category'|'priority', default: 'category') - The view to use when the buffer is opened for the - first time in a session. - {default_category} (string, default: 'Todo') Category assigned to new tasks when no `cat:` token is present and no `Category: ` prefix is used with @@ -648,32 +650,6 @@ Fields: ~ virtual text in the buffer. Examples: `'%Y-%m-%d'` for ISO dates, `'%d %b'` for day-first. - {eol_format} (string, default: '%c %r %d') - Format string controlling the order, content, and - separators of end-of-line virtual text on task lines. - Three specifiers are available: - - `%c` category icon + name (`PendingHeader`) - `%r` recurrence icon + pattern (`PendingRecur`) - `%d` due icon + date (`PendingDue` / `PendingOverdue`) - - Literal text between specifiers is rendered with the - `Normal` highlight group and acts as a separator. - When a specifier's data is absent (e.g. `%d` on a - task with no due date), the specifier and any - surrounding literal text up to the next specifier - are omitted — missing fields never leave gaps. - - `%c` only renders in priority view (where - `show_category` is true). In category view it is - always omitted regardless of the format string. - - Examples: >lua - vim.g.pending = { eol_format = '%d %r' } - vim.g.pending = { eol_format = '%d | %r' } - vim.g.pending = { eol_format = '%c %d %r' } -< - {input_date_formats} (string[], default: {}) *pending-input-formats* List of strftime-like format strings tried in order when parsing a `due:` token that does not match the @@ -705,38 +681,57 @@ Fields: ~ 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. + {view} (table) *pending.ViewConfig* + View rendering configuration. Groups all settings + that affect how the buffer displays tasks. - {folding} (boolean|table, default: true) *pending.FoldingConfig* - Controls category-level folds in category view. When - `true`, folds are enabled with the default foldtext - `'%c (%n tasks)'`. When `false`, folds are disabled - entirely. When a table, folds are enabled and the - table may contain: + {default} ('category'|'priority', default: 'category') + The view to use when the buffer is opened + for the first time in a session. - {foldtext} (string|false, default: '%c (%n tasks)') - Custom foldtext format string. Set to - `false` to use Vim's built-in - foldtext. Two specifiers are - available: - `%c` category name - `%n` number of tasks in the fold - The category icon is prepended - automatically. When `false`, the - default Vim foldtext is used. + {eol_format} (string, default: '%c %r %d') + Format string for end-of-line virtual text. + Specifiers: + `%c` category icon + name (`PendingHeader`) + `%r` recurrence icon + pattern (`PendingRecur`) + `%d` due icon + date (`PendingDue`/`PendingOverdue`) + Literal text between specifiers acts as a + separator. Absent fields and surrounding + literals are collapsed automatically. `%c` + only renders in priority view. - Folds only apply to category view; priority view - is always fold-free regardless of this setting. + {category} (table) *pending.CategoryViewConfig* + Category view settings. + + {order} (string[], default: {}) + Ordered list of category names. Categories + in this list appear in the given order; + others are appended after. + + {folding} (boolean|table, default: true) + *pending.FoldingConfig* + Controls category-level folds. `true` + enables with default foldtext `'%c (%n + tasks)'`. `false` disables entirely. A + table may contain: + {foldtext} (string|false) Format string + with `%c` (category) and `%n` (count). + `false` uses Vim's built-in foldtext. + Folds only apply to category view. + + {queue} (table) *pending.QueueViewConfig* + Queue (priority) view settings. Examples: >lua - vim.g.pending = { folding = true } - vim.g.pending = { folding = false } vim.g.pending = { - folding = { foldtext = '%c (%n tasks)' }, + view = { + default = 'priority', + eol_format = '%d | %r', + category = { + order = { 'Work', 'Personal' }, + folding = { foldtext = '%c: %n items' }, + }, + }, } < diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index 638ea60..012dc35 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -440,7 +440,7 @@ end local function apply_extmarks(bufnr, line_meta) local cfg = config.get() local icons = cfg.icons - local eol_segments = parse_eol_format(cfg.eol_format or '%c %r %d') + local eol_segments = parse_eol_format(cfg.view.eol_format or '%c %r %d') vim.api.nvim_buf_clear_namespace(bufnr, ns_eol, 0, -1) vim.api.nvim_buf_clear_namespace(bufnr, ns_inline, 0, -1) for i, m in ipairs(line_meta) do @@ -578,7 +578,7 @@ function M.render(bufnr) return end - current_view = current_view or config.get().default_view + current_view = current_view or config.get().view.default local view_label = current_view == 'priority' and 'queue' or current_view vim.api.nvim_buf_set_name(bufnr, 'pending://' .. view_label) local all_tasks = _store and _store:active_tasks() or {} diff --git a/lua/pending/config.lua b/lua/pending/config.lua index b7a42ad..36c63d2 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -49,22 +49,31 @@ ---@field next_task? string|false ---@field prev_task? string|false +---@class pending.CategoryViewConfig +---@field order? string[] +---@field folding? boolean|pending.FoldingConfig + +---@class pending.QueueViewConfig + +---@class pending.ViewConfig +---@field default? 'category'|'priority' +---@field eol_format? string +---@field category? pending.CategoryViewConfig +---@field queue? pending.QueueViewConfig + ---@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 input_date_formats? string[] ----@field category_order? string[] ---@field drawer_height? integer ---@field debug? boolean ---@field keymaps pending.Keymaps ----@field folding? boolean|pending.FoldingConfig +---@field view pending.ViewConfig ---@field sync? pending.SyncConfig ----@field eol_format? string ---@field icons pending.Icons ---@class pending.config @@ -73,15 +82,20 @@ local M = {} ---@type pending.Config local defaults = { data_path = vim.fn.stdpath('data') .. '/pending/tasks.json', - default_view = 'category', default_category = 'Todo', date_format = '%b %d', date_syntax = 'due', recur_syntax = 'rec', someday_date = '9999-12-30', - eol_format = '%c %r %d', - folding = true, - category_order = {}, + view = { + default = 'category', + eol_format = '%c %r %d', + category = { + order = {}, + folding = true, + }, + queue = {}, + }, keymaps = { close = 'q', toggle = '', @@ -132,7 +146,7 @@ end ---@return pending.ResolvedFolding function M.resolve_folding() - local raw = M.get().folding + local raw = M.get().view.category.folding if raw == false then return { enabled = false, foldtext = false } elseif raw == true or raw == nil then diff --git a/lua/pending/views.lua b/lua/pending/views.lua index 3b67f90..3f7a4cf 100644 --- a/lua/pending/views.lua +++ b/lua/pending/views.lua @@ -102,7 +102,7 @@ function M.category_view(tasks) end end - local cfg_order = config.get().category_order + local cfg_order = config.get().view.category.order if cfg_order and #cfg_order > 0 then local ordered = {} local seen = {} diff --git a/spec/views_spec.lua b/spec/views_spec.lua index ede9de9..b09633f 100644 --- a/spec/views_spec.lua +++ b/spec/views_spec.lua @@ -228,7 +228,7 @@ describe('views', function() end) it('respects category_order when set', function() - vim.g.pending = { data_path = tmpdir .. '/tasks.json', category_order = { 'Work', 'Inbox' } } + vim.g.pending = { data_path = tmpdir .. '/tasks.json', view = { category = { order = { 'Work', 'Inbox' } } } } config.reset() s:add({ description = 'Inbox task', category = 'Inbox' }) s:add({ description = 'Work task', category = 'Work' }) @@ -248,7 +248,7 @@ describe('views', function() end) it('appends categories not in category_order after ordered ones', function() - vim.g.pending = { data_path = tmpdir .. '/tasks.json', category_order = { 'Work' } } + vim.g.pending = { data_path = tmpdir .. '/tasks.json', view = { category = { order = { 'Work' } } } } config.reset() s:add({ description = 'Errand', category = 'Errands' }) s:add({ description = 'Work task', category = 'Work' }) From 36a469e9640e6deb4e3ddda76d432aa5b2aac9d4 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Sun, 8 Mar 2026 19:13:25 -0400 Subject: [PATCH 63/66] docs: update default keymaps to `g`-prefixed keys (#101) (#104) Problem: Default buffer-local keys `!`, `D`, `F`, `U` shadow common Vim builtins (`!` filter, `D` delete-to-eol, `F` reverse-find, `U` line-undo). Solution: Document new defaults `g!`, `gd`, `gf`, `gz` in the mappings table, config example, and command references. Add a deprecated-keys section listing the old-to-new mapping with removal timeline. --- doc/pending.txt | 35 ++++++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/doc/pending.txt b/doc/pending.txt index f8c61a9..cc4f00d 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -238,7 +238,7 @@ COMMANDS *pending-commands* *:Pending-undo* :Pending undo Undo the last `:w` save, restoring the task store to its previous state. - Equivalent to the `U` buffer-local key (see |pending-mappings|). Up to 20 + Equivalent to the `gz` buffer-local key (see |pending-mappings|). Up to 20 levels of undo are persisted across sessions. *:Pending-init* @@ -267,11 +267,11 @@ Default buffer-local keys: ~ ------- ------------------------------------------------ `q` Close the task buffer (`close`) `` Toggle complete / uncomplete (`toggle`) - `!` Toggle the priority flag (`priority`) - `D` Prompt for a due date (`date`) - `F` Prompt for filter predicates (`filter`) + `g!` Toggle the priority flag (`priority`) + `gd` Prompt for a due date (`date`) + `gf` Prompt for filter predicates (`filter`) `` Switch between category / queue view (`view`) - `U` Undo the last `:w` save (`undo`) + `gz` 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 (requires `folding`) @@ -307,6 +307,23 @@ All motions support count: `3]]` jumps three headers forward. `]]` and `dd`, `p`, `P`, and `:w` work as standard Vim operations. +Deprecated keys: ~ *pending-deprecated-keys* +The following keys were renamed to avoid shadowing Vim builtins. The old +keys still work but emit a deprecation warning and will be removed in a +future release: + + Old New Action ~ + ------- ------- ------------------------------------------------ + `!` `g!` Toggle the priority flag + `D` `gd` Prompt for a due date + `F` `gf` Prompt for filter predicates + `U` `gz` Undo the last `:w` save + +To silence warnings, set the new keys explicitly in your config or set the +old keys to `false`: >lua + vim.g.pending = { keymaps = { priority = 'g!' } } +< + *(pending-open)* (pending-open) Open the task buffer. Maps to |:Pending| with no arguments. @@ -607,10 +624,10 @@ loads: >lua close = 'q', toggle = '', view = '', - priority = '!', - date = 'D', - undo = 'U', - filter = 'F', + priority = 'g!', + date = 'gd', + undo = 'gz', + filter = 'gf', open_line = 'o', open_line_above = 'O', a_task = 'at', From e534e869a88a97c660cca9192498835c4dfcdc5b Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Sun, 8 Mar 2026 19:13:57 -0400 Subject: [PATCH 64/66] docs: document `wip` and `blocked` task states (#99) (#106) Problem: The vimdoc only describes `pending`/`done`/`deleted` statuses with no mention of work-in-progress or blocked states. Solution: Document new `wip` and `blocked` statuses across views (sort order), filters (new predicates), icons (`>` and `=`), highlight groups (`PendingWip`, `PendingBlocked`), and the data format schema. --- doc/pending.txt | 39 +++++++++++++++++++++++++++------------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/doc/pending.txt b/doc/pending.txt index cc4f00d..edb9022 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -413,14 +413,14 @@ Category view (default): ~ *pending-view-category* Tasks are grouped under their category header. Categories appear in the order tasks were added unless `category_order` is set (see |pending-config|). Blank lines separate categories. Within each category, - pending tasks appear before done tasks. Priority tasks (`!`) are sorted - first within each group. Category sections are foldable with `zc` and - `zo`. + tasks are sorted by status (wip → pending → blocked → done), then by + priority, then by insertion order. Category sections are foldable with + `zc` and `zo`. 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 + A flat list of all tasks sorted by status (wip → pending → blocked → + done), then by priority, then by due date (tasks without a due date sort + last), then by internal order. Category names are shown as right-aligned virtual text alongside the due date virtual text so tasks remain identifiable across categories. The buffer is named `pending://queue`. @@ -452,6 +452,10 @@ Available predicates: ~ `priority` Show only tasks with priority > 0 (the `!` marker). + `wip` Show only tasks with status `wip` (work in progress). + + `blocked` Show only tasks with status `blocked`. + `clear` Special value for |:Pending-filter| — clears the active filter and shows all tasks. @@ -779,14 +783,16 @@ Fields: ~ {icons} (table) *pending.Icons* Icon characters displayed in the buffer. The - {pending}, {done}, and {priority} characters - appear inside brackets (`[icon]`) as an overlay - on the checkbox. The {category} character - prefixes both header lines and EOL category - labels. Fields: + {pending}, {done}, {priority}, {wip}, and + {blocked} characters appear inside brackets + (`[icon]`) as an overlay on the checkbox. The + {category} character prefixes both header lines + and EOL category labels. Fields: {pending} Pending task character. Default: ' ' {done} Done task character. Default: 'x' {priority} Priority task character. Default: '!' + {wip} Work-in-progress character. Default: '>' + {blocked} Blocked task character. Default: '=' {due} Due date prefix. Default: '.' {recur} Recurrence prefix. Default: '~' {category} Category prefix. Default: '#' @@ -835,6 +841,14 @@ PendingOverdue Applied to the due date virtual text of overdue tasks. PendingDone Applied to the text of completed tasks. Default: links to `Comment`. + *PendingWip* +PendingWip Applied to the checkbox icon of work-in-progress tasks. + Default: links to `DiagnosticInfo`. + + *PendingBlocked* +PendingBlocked Applied to the checkbox icon and text of blocked tasks. + Default: links to `DiagnosticError`. + *PendingPriority* PendingPriority Applied to the `! ` priority marker on priority tasks. Default: links to `DiagnosticWarn`. @@ -1200,7 +1214,8 @@ Schema: > Task fields: ~ {id} (integer) Unique, auto-incrementing task identifier. {description} (string) Task text as shown in the buffer. - {status} (string) `'pending'`, `'done'`, or `'deleted'`. + {status} (string) `'pending'`, `'wip'`, `'blocked'`, `'done'`, + or `'deleted'`. {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. From 073541424e0a4cdee2501d2b42c555a5bf2be059 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Sun, 8 Mar 2026 19:16:49 -0400 Subject: [PATCH 65/66] docs: add `queue_sort` and `category_sort` config fields (#100) (#105) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: The queue view sort order (priority → due → order) is hardcoded with no documentation of a configurable alternative. Solution: Document `queue_sort` and `category_sort` config fields with named presets, sort key syntax, `-` direction prefix, and the `status` key opt-in for disabling the pending/done split. Update the views section to reference the new `pending-sort` tag. --- doc/pending.txt | 55 ++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 3 deletions(-) diff --git a/doc/pending.txt b/doc/pending.txt index edb9022..d062375 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -414,13 +414,16 @@ Category view (default): ~ *pending-view-category* order tasks were added unless `category_order` is set (see |pending-config|). Blank lines separate categories. Within each category, tasks are sorted by status (wip → pending → blocked → done), then by - priority, then by insertion order. Category sections are foldable with - `zc` and `zo`. + priority, then by insertion order. The within-category sort order is + configurable via `category.sort` (see |pending-sort|). Category sections + are foldable with `zc` and `zo`. Queue view: ~ *pending-view-queue* A flat list of all tasks sorted by status (wip → pending → blocked → done), then by priority, then by due date (tasks without a due date sort - last), then by internal order. Category names are shown as right-aligned virtual + last), then by internal order. The sort order is configurable via + `queue.sort` (see |pending-sort|). Category names are shown as + right-aligned virtual text alongside the due date virtual text so tasks remain identifiable across categories. The buffer is named `pending://queue`. @@ -729,6 +732,11 @@ Fields: ~ in this list appear in the given order; others are appended after. + {sort} (string|string[], default: 'default') + Sort order within each category. See + |pending-sort| for syntax. The `'default'` + preset is priority → order → id. + {folding} (boolean|table, default: true) *pending.FoldingConfig* Controls category-level folds. `true` @@ -743,6 +751,43 @@ Fields: ~ {queue} (table) *pending.QueueViewConfig* Queue (priority) view settings. + {sort} (string|string[], default: 'default') + Sort order for the queue view. See + |pending-sort| for syntax. The `'default'` + preset is priority → due → order → id. + + Sort keys: ~ *pending-sort* + Both `category.sort` and `queue.sort` accept a named + preset string or an ordered list of sort keys. + + Presets: ~ + `'default'` priority → due → order → id + `'due-first'` due → priority → order → id + `'alphabetical'` description → priority → order → id + `'newest-first'` entry (desc) → priority → order → id + `'recent'` modified (desc) → priority → order → id + + Available keys: ~ + `'priority'` Higher priority first (descending) + `'due'` Earlier due date first (nil last) + `'status'` Pending before done + `'category'` Alphabetical by category + `'description'` Alphabetical by task text + `'entry'` Oldest creation date first + `'modified'` Oldest modification first + `'order'` Internal insertion order + `'id'` Task creation order + + Prefix a key with `-` to flip its default direction + (e.g. `'-due'` for latest-first). `'priority'` + defaults to descending; all others default to + ascending. Implicit `order`, `id` tiebreakers are + appended when absent for stable, deterministic sort. + + When `'status'` appears in the key list, the + pending-before-done split is disabled and status + participates as a normal sort field. + Examples: >lua vim.g.pending = { view = { @@ -750,8 +795,12 @@ Fields: ~ eol_format = '%d | %r', category = { order = { 'Work', 'Personal' }, + sort = { 'due', 'priority', 'order' }, folding = { foldtext = '%c: %n items' }, }, + queue = { + sort = 'due-first', + }, }, } < From 6c3869b3c48d87eefe1f30bed55cb7a121ba782a Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 8 Mar 2026 19:43:03 -0400 Subject: [PATCH 66/66] feat: complete task editing coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: the task editing surface had gaps — category and recurrence had no keymaps, `:Pending edit` required knowing the task ID, tasks couldn't be reordered with a keymap, priority was binary (0/1), and `wip`/`blocked` states were documented but unimplemented. Solution: fill every cell so every property is editable in every way. - `gc`/`gr` keymaps for category select and recurrence prompt - cursor-aware `:Pending edit` (omit ID to use task under cursor) - `J`/`K` keymaps to reorder tasks within a category - multi-level priorities (`max_priority` config, `g!` cycles 0→1→2→3→0) - `+!!`/`+!!!` tokens in `:Pending edit`, `:Pending add`, `parse.body()` - `PendingPriority2`/`PendingPriority3` highlight groups - `gw`/`gb` keymaps toggle `wip`/`blocked` status - `>`/`=` state chars in buffer rendering and diff parsing - `PendingWip`/`PendingBlocked` highlight groups - sort order: wip → pending → blocked → done - `wip`/`blocked` filter predicates and icons --- doc/pending.txt | 143 +++++++++++--------- lua/pending/buffer.lua | 19 +++ lua/pending/config.lua | 18 +++ lua/pending/diff.lua | 16 ++- lua/pending/init.lua | 299 ++++++++++++++++++++++++++++++++++++++--- lua/pending/parse.lua | 38 ++++-- lua/pending/store.lua | 2 +- lua/pending/views.lua | 36 ++++- plugin/pending.lua | 28 +++- 9 files changed, 498 insertions(+), 101 deletions(-) diff --git a/doc/pending.txt b/doc/pending.txt index d062375..f6179d3 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -133,8 +133,11 @@ COMMANDS *pending-commands* :Pending add School: Submit homework :Pending add Errands: Pick up dry cleaning due:fri :Pending add Work: standup due:tomorrow rec:weekdays + :Pending add Buy milk due:fri +!! < - If the buffer is currently open it is re-rendered after the add. + Trailing `+!`, `+!!`, or `+!!!` tokens set the priority level (capped + at `max_priority`). If the buffer is currently open it is re-rendered + after the add. *:Pending-archive* :Pending archive [{days}] @@ -215,18 +218,22 @@ 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 [{id}] [{operations}] + Edit metadata on an existing task. {id} is the numeric task ID. When + {id} is omitted and the task buffer is open, the task under the cursor + is used. This makes `:Pending edit +!` work without knowing the 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 +!! < Operations: ~ `due:` Set due date (accepts all |pending-dates| vocabulary). `cat:` Set category. `rec:` Set recurrence (prefix `!` for completion-based). - `+!` Add priority flag. + `+!` Set priority to 1. + `+!!` Set priority to 2. + `+!!!` Set priority to 3 (capped at `max_priority`). `-!` Remove priority flag. `-due` Clear due date. `-cat` Clear category. @@ -267,13 +274,19 @@ Default buffer-local keys: ~ ------- ------------------------------------------------ `q` Close the task buffer (`close`) `` Toggle complete / uncomplete (`toggle`) - `g!` Toggle the priority flag (`priority`) + `g!` Cycle priority: 0→1→2→3→0 (`priority`) `gd` Prompt for a due date (`date`) + `gc` Select a category from existing categories (`category`) + `gr` Prompt for a recurrence pattern (`recur`) + `gw` Toggle work-in-progress status (`wip`) + `gb` Toggle blocked status (`blocked`) `gf` Prompt for filter predicates (`filter`) `` Switch between category / queue view (`view`) `gz` 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`) + `J` Move task down within its category (`move_down`) + `K` Move task up within its category (`move_up`) `zc` Fold the current category section (requires `folding`) `zo` Unfold the current category section (requires `folding`) @@ -338,7 +351,8 @@ old keys to `false`: >lua *(pending-priority)* (pending-priority) - Toggle the priority flag for the task under the cursor. + Cycle the priority level for the task under the cursor (0→1→2→3→0). + The maximum level is controlled by `max_priority` in |pending-config|. *(pending-date)* (pending-date) @@ -356,6 +370,35 @@ old keys to `false`: >lua (pending-filter) Prompt for filter predicates via |vim.ui.input|. + *(pending-category)* +(pending-category) + Select a category for the task under the cursor via |vim.ui.select|. + + *(pending-recur)* +(pending-recur) + Prompt for a recurrence pattern for the task under the cursor. + Prefix with `!` for completion mode (e.g. `!weekly`). Empty input + removes recurrence. + + *(pending-move-down)* +(pending-move-down) + Swap the task under the cursor with the one below it. In category + view, movement is limited to tasks within the same category. + + *(pending-move-up)* +(pending-move-up) + Swap the task under the cursor with the one above it. + + *(pending-wip)* +(pending-wip) + Toggle work-in-progress status for the task under the cursor. + If the task is already `wip`, reverts to `pending`. + + *(pending-blocked)* +(pending-blocked) + Toggle blocked status for the task under the cursor. + If the task is already `blocked`, reverts to `pending`. + *(pending-open-line)* (pending-open-line) Insert a correctly-formatted blank task line below the cursor. @@ -414,16 +457,13 @@ Category view (default): ~ *pending-view-category* order tasks were added unless `category_order` is set (see |pending-config|). Blank lines separate categories. Within each category, tasks are sorted by status (wip → pending → blocked → done), then by - priority, then by insertion order. The within-category sort order is - configurable via `category.sort` (see |pending-sort|). Category sections - are foldable with `zc` and `zo`. + priority, then by insertion order. Category sections are foldable with + `zc` and `zo`. Queue view: ~ *pending-view-queue* A flat list of all tasks sorted by status (wip → pending → blocked → done), then by priority, then by due date (tasks without a due date sort - last), then by internal order. The sort order is configurable via - `queue.sort` (see |pending-sort|). Category names are shown as - right-aligned virtual + last), then by internal order. Category names are shown as right-aligned virtual text alongside the due date virtual text so tasks remain identifiable across categories. The buffer is named `pending://queue`. @@ -618,6 +658,7 @@ loads: >lua date_syntax = 'due', recur_syntax = 'rec', someday_date = '9999-12-30', + max_priority = 3, view = { default = 'category', eol_format = '%c %r %d', @@ -645,6 +686,12 @@ loads: >lua prev_header = '[[', next_task = ']t', prev_task = '[t', + category = 'gc', + recur = 'gr', + move_down = 'J', + move_up = 'K', + wip = 'gw', + blocked = 'gb', }, sync = { gcal = {}, @@ -732,11 +779,6 @@ Fields: ~ in this list appear in the given order; others are appended after. - {sort} (string|string[], default: 'default') - Sort order within each category. See - |pending-sort| for syntax. The `'default'` - preset is priority → order → id. - {folding} (boolean|table, default: true) *pending.FoldingConfig* Controls category-level folds. `true` @@ -751,43 +793,6 @@ Fields: ~ {queue} (table) *pending.QueueViewConfig* Queue (priority) view settings. - {sort} (string|string[], default: 'default') - Sort order for the queue view. See - |pending-sort| for syntax. The `'default'` - preset is priority → due → order → id. - - Sort keys: ~ *pending-sort* - Both `category.sort` and `queue.sort` accept a named - preset string or an ordered list of sort keys. - - Presets: ~ - `'default'` priority → due → order → id - `'due-first'` due → priority → order → id - `'alphabetical'` description → priority → order → id - `'newest-first'` entry (desc) → priority → order → id - `'recent'` modified (desc) → priority → order → id - - Available keys: ~ - `'priority'` Higher priority first (descending) - `'due'` Earlier due date first (nil last) - `'status'` Pending before done - `'category'` Alphabetical by category - `'description'` Alphabetical by task text - `'entry'` Oldest creation date first - `'modified'` Oldest modification first - `'order'` Internal insertion order - `'id'` Task creation order - - Prefix a key with `-` to flip its default direction - (e.g. `'-due'` for latest-first). `'priority'` - defaults to descending; all others default to - ascending. Implicit `order`, `id` tiebreakers are - appended when absent for stable, deterministic sort. - - When `'status'` appears in the key list, the - pending-before-done split is disabled and status - participates as a normal sort field. - Examples: >lua vim.g.pending = { view = { @@ -795,12 +800,8 @@ Fields: ~ eol_format = '%d | %r', category = { order = { 'Work', 'Personal' }, - sort = { 'due', 'priority', 'order' }, folding = { foldtext = '%c: %n items' }, }, - queue = { - sort = 'due-first', - }, }, } < @@ -812,6 +813,15 @@ Fields: ~ See |pending-mappings| for the full list of actions and their default keys. + {max_priority} (integer, default: 3) + Maximum priority level. The `g!` keymap cycles + through `0 → 1 → … → max_priority → 0`. Priority + levels map to highlight groups: `PendingPriority` + (1), `PendingPriority2` (2), `PendingPriority3` + (3+). `:Pending edit +!!` and `:Pending add +!!!` + accept multi-bang syntax capped at this value. + Set to `1` for the old binary on/off behavior. + {debug} (boolean, default: false) Enable diagnostic logging. When `true`, textobj motions, mapping registration, and cursor jumps @@ -899,9 +909,17 @@ PendingBlocked Applied to the checkbox icon and text of blocked tasks. Default: links to `DiagnosticError`. *PendingPriority* -PendingPriority Applied to the `! ` priority marker on priority tasks. +PendingPriority Applied to the checkbox icon of priority 1 tasks. Default: links to `DiagnosticWarn`. + *PendingPriority2* +PendingPriority2 Applied to the checkbox icon of priority 2 tasks. + Default: links to `DiagnosticError`. + + *PendingPriority3* +PendingPriority3 Applied to the checkbox icon of priority 3+ tasks. + Default: links to `DiagnosticError`. + *PendingRecur* PendingRecur Applied to the recurrence indicator virtual text shown alongside due dates for recurring tasks. @@ -1266,7 +1284,8 @@ Task fields: ~ {status} (string) `'pending'`, `'wip'`, `'blocked'`, `'done'`, or `'deleted'`. {category} (string) Category name. Defaults to `default_category`. - {priority} (integer) `1` for priority tasks, `0` otherwise. + {priority} (integer) Priority level: `0` (none), `1`–`3` (or up to + `max_priority`). Higher values sort first. {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. diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index 012dc35..5d18e1f 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -137,12 +137,27 @@ local function apply_inline_row(bufnr, row, m, icons) end_col = #line, hl_group = 'PendingDone', }) + elseif m.status == 'blocked' then + local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or '' + local col_start = line:find('/%d+/') and select(2, line:find('/%d+/')) or 0 + vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, col_start, { + end_col = #line, + hl_group = 'PendingBlocked', + }) 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.status == 'wip' then + icon, icon_hl = icons.wip or '>', 'PendingWip' + elseif m.status == 'blocked' then + icon, icon_hl = icons.blocked or '=', 'PendingBlocked' + elseif m.priority and m.priority >= 3 then + icon, icon_hl = icons.priority, 'PendingPriority3' + elseif m.priority and m.priority == 2 then + icon, icon_hl = icons.priority, 'PendingPriority2' elseif m.priority and m.priority > 0 then icon, icon_hl = icons.priority, 'PendingPriority' else @@ -464,6 +479,10 @@ 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, 'PendingPriority2', { link = 'DiagnosticError', default = true }) + vim.api.nvim_set_hl(0, 'PendingPriority3', { link = 'DiagnosticError', default = true }) + vim.api.nvim_set_hl(0, 'PendingWip', { link = 'DiagnosticInfo', default = true }) + vim.api.nvim_set_hl(0, 'PendingBlocked', { link = 'DiagnosticError', 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 diff --git a/lua/pending/config.lua b/lua/pending/config.lua index 36c63d2..81e7168 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -9,6 +9,8 @@ ---@field pending string ---@field done string ---@field priority string +---@field wip string +---@field blocked string ---@field due string ---@field recur string ---@field category string @@ -48,6 +50,12 @@ ---@field prev_header? string|false ---@field next_task? string|false ---@field prev_task? string|false +---@field category? string|false +---@field recur? string|false +---@field move_down? string|false +---@field move_up? string|false +---@field wip? string|false +---@field blocked? string|false ---@class pending.CategoryViewConfig ---@field order? string[] @@ -73,6 +81,7 @@ ---@field debug? boolean ---@field keymaps pending.Keymaps ---@field view pending.ViewConfig +---@field max_priority? integer ---@field sync? pending.SyncConfig ---@field icons pending.Icons @@ -87,6 +96,7 @@ local defaults = { date_syntax = 'due', recur_syntax = 'rec', someday_date = '9999-12-30', + max_priority = 3, view = { default = 'category', eol_format = '%c %r %d', @@ -114,12 +124,20 @@ local defaults = { prev_header = '[[', next_task = ']t', prev_task = '[t', + category = 'gc', + recur = 'gr', + move_down = 'J', + move_up = 'K', + wip = 'gw', + blocked = 'gb', }, sync = {}, icons = { pending = ' ', done = 'x', priority = '!', + wip = '>', + blocked = '=', due = '.', recur = '~', category = '#', diff --git a/lua/pending/diff.lua b/lua/pending/diff.lua index 6b79b8a..723dee1 100644 --- a/lua/pending/diff.lua +++ b/lua/pending/diff.lua @@ -43,7 +43,16 @@ function M.parse_buffer(lines) local stripped = body:match('^- %[.?%] (.*)$') or body local state_char = body:match('^- %[(.-)%]') or ' ' local priority = state_char == '!' and 1 or 0 - local status = state_char == 'x' and 'done' or 'pending' + local status + if state_char == 'x' then + status = 'done' + elseif state_char == '>' then + status = 'wip' + elseif state_char == '=' then + status = 'blocked' + else + status = 'pending' + end local description, metadata = parse.body(stripped) if description and description ~= '' then table.insert(result, { @@ -117,7 +126,10 @@ function M.apply(lines, s, hidden_ids) task.category = entry.category changed = true end - if task.priority ~= entry.priority then + if entry.priority == 0 and task.priority > 0 then + task.priority = 0 + changed = true + elseif entry.priority > 0 and task.priority == 0 then task.priority = entry.priority changed = true end diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 4d05503..46c6bb7 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -47,7 +47,7 @@ function M._recompute_counts() local today_str = os.date('%Y-%m-%d') --[[@as string]] for _, task in ipairs(get_store():active_tasks()) do - if task.status == 'pending' then + if task.status ~= 'done' and task.status ~= 'deleted' then pending = pending + 1 if task.priority > 0 then priority = priority + 1 @@ -163,6 +163,16 @@ local function compute_hidden_ids(tasks, predicates) visible = false break end + elseif pred == 'wip' then + if task.status ~= 'wip' then + visible = false + break + end + elseif pred == 'blocked' then + if task.status ~= 'blocked' then + visible = false + break + end end end if not visible then @@ -335,6 +345,24 @@ function M._setup_buf_mappings(bufnr) date = function() M.prompt_date() end, + category = function() + M.prompt_category() + end, + recur = function() + M.prompt_recur() + end, + move_down = function() + M.move_task('down') + end, + move_up = function() + M.move_task('up') + end, + wip = function() + M.toggle_status('wip') + end, + blocked = function() + M.toggle_status('blocked') + end, undo = function() M.undo_write() end, @@ -605,7 +633,8 @@ function M.toggle_priority() if not task then return end - local new_priority = task.priority > 0 and 0 or 1 + local max = require('pending.config').get().max_priority or 3 + local new_priority = (task.priority + 1) % (max + 1) s:update(id, { priority = new_priority }) _save_and_notify() buffer.render(bufnr) @@ -658,6 +687,222 @@ function M.prompt_date() end) end +---@param target_status 'wip'|'blocked' +---@return nil +function M.toggle_status(target_status) + local bufnr = buffer.bufnr() + if not bufnr then + return + end + if not require_saved() then + return + end + local row = vim.api.nvim_win_get_cursor(0)[1] + local meta = buffer.meta() + if not meta[row] or meta[row].type ~= 'task' then + return + end + local id = meta[row].id + if not id then + return + end + local s = get_store() + local task = s:get(id) + if not task then + return + end + if task.status == target_status then + s:update(id, { status = 'pending' }) + else + s:update(id, { status = target_status }) + end + _save_and_notify() + buffer.render(bufnr) + for lnum, m in ipairs(buffer.meta()) do + if m.id == id then + vim.api.nvim_win_set_cursor(0, { lnum, 0 }) + break + end + end +end + +---@param direction 'up'|'down' +---@return nil +function M.move_task(direction) + local bufnr = buffer.bufnr() + if not bufnr then + return + end + if not require_saved() then + return + end + local row = vim.api.nvim_win_get_cursor(0)[1] + local meta = buffer.meta() + if not meta[row] or meta[row].type ~= 'task' then + return + end + local id = meta[row].id + if not id then + return + end + + local target_row + if direction == 'down' then + target_row = row + 1 + else + target_row = row - 1 + end + if not meta[target_row] or meta[target_row].type ~= 'task' then + return + end + + local current_view_name = buffer.current_view_name() or 'category' + if current_view_name == 'category' then + if meta[target_row].category ~= meta[row].category then + return + end + end + + local target_id = meta[target_row].id + if not target_id then + return + end + + local s = get_store() + local task_a = s:get(id) + local task_b = s:get(target_id) + if not task_a or not task_b then + return + end + + if task_a.order == 0 or task_b.order == 0 then + local tasks + if current_view_name == 'category' then + tasks = {} + for _, t in ipairs(s:active_tasks()) do + if t.category == task_a.category then + table.insert(tasks, t) + end + end + else + tasks = s:active_tasks() + end + table.sort(tasks, function(a, b) + if a.order ~= b.order then + return a.order < b.order + end + return a.id < b.id + end) + for i, t in ipairs(tasks) do + s:update(t.id, { order = i }) + end + task_a = s:get(id) + task_b = s:get(target_id) + if not task_a or not task_b then + return + end + end + + local order_a, order_b = task_a.order, task_b.order + s:update(id, { order = order_b }) + s:update(target_id, { order = order_a }) + _save_and_notify() + buffer.render(bufnr) + + for lnum, m in ipairs(buffer.meta()) do + if m.id == id then + vim.api.nvim_win_set_cursor(0, { lnum, 0 }) + break + end + end +end + +---@return nil +function M.prompt_category() + local bufnr = buffer.bufnr() + if not bufnr then + return + end + if not require_saved() then + return + end + local row = vim.api.nvim_win_get_cursor(0)[1] + local meta = buffer.meta() + if not meta[row] or meta[row].type ~= 'task' then + return + end + local id = meta[row].id + if not id then + return + end + local s = get_store() + local seen = {} + local categories = {} + for _, task in ipairs(s:active_tasks()) do + if task.category and not seen[task.category] then + seen[task.category] = true + table.insert(categories, task.category) + end + end + table.sort(categories) + vim.ui.select(categories, { prompt = 'Category: ' }, function(choice) + if not choice then + return + end + s:update(id, { category = choice }) + _save_and_notify() + buffer.render(bufnr) + end) +end + +---@return nil +function M.prompt_recur() + local bufnr = buffer.bufnr() + if not bufnr then + return + end + if not require_saved() then + return + end + local row = vim.api.nvim_win_get_cursor(0)[1] + local meta = buffer.meta() + if not meta[row] or meta[row].type ~= 'task' then + return + end + local id = meta[row].id + if not id then + return + end + vim.ui.input({ prompt = 'Recurrence (e.g. weekly, !daily): ' }, function(input) + if not input then + return + end + local s = get_store() + if input == '' then + s:update(id, { recur = vim.NIL, recur_mode = vim.NIL }) + _save_and_notify() + buffer.render(bufnr) + log.info('Task #' .. id .. ': recurrence removed.') + return + end + local raw_spec = input + local rec_mode = nil + if raw_spec:sub(1, 1) == '!' then + rec_mode = 'completion' + raw_spec = raw_spec:sub(2) + end + local recur = require('pending.recur') + if not recur.validate(raw_spec) then + log.error('Invalid recurrence pattern: ' .. input) + return + end + s:update(id, { recur = raw_spec, recur_mode = rec_mode }) + _save_and_notify() + buffer.render(bufnr) + log.info('Task #' .. id .. ': recurrence set to ' .. raw_spec .. '.') + end) +end + ---@param text string ---@return nil function M.add(text) @@ -678,6 +923,7 @@ function M.add(text) due = metadata.due, recur = metadata.rec, recur_mode = metadata.rec_mode, + priority = metadata.priority, }) _save_and_notify() local bufnr = buffer.bufnr() @@ -817,8 +1063,11 @@ local function parse_edit_token(token) local dk = cfg.date_syntax or 'due' local rk = cfg.recur_syntax or 'rec' - if token == '+!' then - return 'priority', 1, nil + local bangs = token:match('^%+(!+)$') + if bangs then + local max = cfg.max_priority or 3 + local level = math.min(#bangs, max) + return 'priority', level, nil end if token == '-!' then return 'priority', 0, nil @@ -881,21 +1130,33 @@ local function parse_edit_token(token) .. rk end ----@param id_str string ----@param rest string +---@param id_str? string +---@param rest? string ---@return nil function M.edit(id_str, rest) - if not id_str or id_str == '' then - log.error( - 'Usage: :Pending edit [due:] [cat:] [rec:] [+!] [-!] [-due] [-cat] [-rec]' - ) - return - end - - local id = tonumber(id_str) + local id = id_str and tonumber(id_str) if not id then - log.error('Invalid task ID: ' .. id_str) - return + local bufnr = buffer.bufnr() + if bufnr and vim.api.nvim_buf_is_valid(bufnr) then + local row = vim.api.nvim_win_get_cursor(0)[1] + local meta = buffer.meta() + if meta[row] and meta[row].type == 'task' and meta[row].id then + id = meta[row].id + if id_str and id_str ~= '' then + rest = rest and (id_str .. ' ' .. rest) or id_str + end + end + end + if not id then + if id_str and id_str ~= '' then + log.error('Invalid task ID: ' .. id_str) + else + log.error( + 'Usage: :Pending edit [] [due:] [cat:] [rec:] [+!] [-!] [-due] [-cat] [-rec]' + ) + end + return + end end local s = get_store() @@ -955,7 +1216,11 @@ function M.edit(id_str, rest) end elseif field == 'priority' then updates.priority = value - table.insert(feedback, value == 1 and 'priority added' or 'priority removed') + if value == 0 then + table.insert(feedback, 'priority removed') + else + table.insert(feedback, 'priority set to ' .. value) + end end end diff --git a/lua/pending/parse.lua b/lua/pending/parse.lua index 3e90b65..ea838f7 100644 --- a/lua/pending/parse.lua +++ b/lua/pending/parse.lua @@ -563,24 +563,34 @@ function M.body(text) metadata.cat = cat_val i = i - 1 else - local rec_val = token:match(rec_pattern) - if rec_val then - if metadata.rec then + local pri_bangs = token:match('^%+(!+)$') + if pri_bangs then + if metadata.priority 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 + local max = config.get().max_priority or 3 + metadata.priority = math.min(#pri_bangs, max) 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 diff --git a/lua/pending/store.lua b/lua/pending/store.lua index 20898fd..640e256 100644 --- a/lua/pending/store.lua +++ b/lua/pending/store.lua @@ -3,7 +3,7 @@ local config = require('pending.config') ---@class pending.Task ---@field id integer ---@field description string ----@field status 'pending'|'done'|'deleted' +---@field status 'pending'|'done'|'deleted'|'wip'|'blocked' ---@field category? string ---@field priority integer ---@field due? string diff --git a/lua/pending/views.lua b/lua/pending/views.lua index 3f7a4cf..d6c706b 100644 --- a/lua/pending/views.lua +++ b/lua/pending/views.lua @@ -41,9 +41,32 @@ local function format_due(due) return formatted end +---@type table +local status_rank = { wip = 0, pending = 1, blocked = 2, done = 3 } + +---@param task pending.Task +---@return string +local function state_char(task) + if task.status == 'done' then + return 'x' + elseif task.status == 'wip' then + return '>' + elseif task.status == 'blocked' then + return '=' + elseif task.priority > 0 then + return '!' + end + return ' ' +end + ---@param tasks pending.Task[] local function sort_tasks(tasks) table.sort(tasks, function(a, b) + local ra = status_rank[a.status] or 1 + local rb = status_rank[b.status] or 1 + if ra ~= rb then + return ra < rb + end if a.priority ~= b.priority then return a.priority > b.priority end @@ -57,6 +80,11 @@ end ---@param tasks pending.Task[] local function sort_tasks_priority(tasks) table.sort(tasks, function(a, b) + local ra = status_rank[a.status] or 1 + local rb = status_rank[b.status] or 1 + if ra ~= rb then + return ra < rb + end if a.priority ~= b.priority then return a.priority > b.priority end @@ -95,7 +123,7 @@ function M.category_view(tasks) by_cat[cat] = {} done_by_cat[cat] = {} end - if task.status == 'done' then + if task.status == 'done' or task.status == 'deleted' then table.insert(done_by_cat[cat], task) else table.insert(by_cat[cat], task) @@ -146,7 +174,7 @@ function M.category_view(tasks) for _, task in ipairs(all) do local prefix = '/' .. task.id .. '/' - local state = task.status == 'done' and 'x' or (task.priority > 0 and '!' or ' ') + local state = state_char(task) local line = prefix .. '- [' .. state .. '] ' .. task.description table.insert(lines, line) table.insert(meta, { @@ -157,7 +185,7 @@ function M.category_view(tasks) status = task.status, category = cat, priority = task.priority, - overdue = task.status == 'pending' and task.due ~= nil and parse.is_overdue(task.due) + overdue = task.status ~= 'done' and task.due ~= nil and parse.is_overdue(task.due) or nil, recur = task.recur, }) @@ -209,7 +237,7 @@ function M.priority_view(tasks) status = task.status, category = task.category, priority = task.priority, - overdue = task.status == 'pending' and task.due ~= nil and parse.is_overdue(task.due) or nil, + overdue = task.status ~= 'done' and task.due ~= nil and parse.is_overdue(task.due) or nil, show_category = true, recur = task.recur, }) diff --git a/plugin/pending.lua b/plugin/pending.lua index e456f09..9b67a59 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -13,6 +13,8 @@ local function edit_field_candidates() 'cat:', rk .. ':', '+!', + '+!!', + '+!!!', '-!', '-' .. dk, '-cat', @@ -181,7 +183,7 @@ end, { for word in after_filter:gmatch('%S+') do used[word] = true end - local candidates = { 'clear', 'overdue', 'today', 'priority', 'done', 'pending' } + local candidates = { 'clear', 'overdue', 'today', 'priority', 'done', 'pending', 'wip', 'blocked' } local store = require('pending.store') local s = store.new(store.resolve_path()) s:load() @@ -280,6 +282,30 @@ vim.keymap.set('n', '(pending-undo)', function() require('pending').undo_write() end) +vim.keymap.set('n', '(pending-category)', function() + require('pending').prompt_category() +end) + +vim.keymap.set('n', '(pending-recur)', function() + require('pending').prompt_recur() +end) + +vim.keymap.set('n', '(pending-move-down)', function() + require('pending').move_task('down') +end) + +vim.keymap.set('n', '(pending-move-up)', function() + require('pending').move_task('up') +end) + +vim.keymap.set('n', '(pending-wip)', function() + require('pending').toggle_status('wip') +end) + +vim.keymap.set('n', '(pending-blocked)', function() + require('pending').toggle_status('blocked') +end) + vim.keymap.set('n', '(pending-filter)', function() vim.ui.input({ prompt = 'Filter: ' }, function(input) if input then