From 717a6133cdec4587d524d54fa961bc9ae4e6fe19 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 15:08:35 -0500 Subject: [PATCH 001/199] build: initial repo scaffold Problem: need project foundation files. Solution: add MIT license, gitignore, and editorconfig. --- .editorconfig | 9 +++++++++ .gitignore | 13 +++++++++++++ LICENSE | 21 +++++++++++++++++++++ 3 files changed, 43 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 LICENSE diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..b9de190 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +insert_final_newline = true +charset = utf-8 + +[*.lua] +indent_style = space +indent_size = 2 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..93ac2c5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +doc/tags +*.log + +.*cache* +CLAUDE.md +.claude/ + +node_modules/ + +result +result-* +.direnv/ +.envrc diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3aba69a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Raphael + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From 6cc8fad646d024e66dcf3ab6829fd54b6e0fab97 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 15:08:35 -0500 Subject: [PATCH 002/199] build: initial repo scaffold Problem: need project foundation files. Solution: add MIT license, gitignore, and editorconfig. --- .editorconfig | 9 +++++++++ .gitignore | 13 +++++++++++++ LICENSE | 21 +++++++++++++++++++++ 3 files changed, 43 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 LICENSE diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..b9de190 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +insert_final_newline = true +charset = utf-8 + +[*.lua] +indent_style = space +indent_size = 2 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..93ac2c5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +doc/tags +*.log + +.*cache* +CLAUDE.md +.claude/ + +node_modules/ + +result +result-* +.direnv/ +.envrc diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3aba69a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Raphael + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From b617ee284e410bad0b38d79a4cd645a88fdcbce4 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 15:08:44 -0500 Subject: [PATCH 003/199] build: add tooling and dev environment configs Problem: need formatting, linting, testing, and nix dev shell configs. Solution: add stylua, selene, lua-language-server, busted, pre-commit, flake.nix, and luarocks rockspec configuration. --- .busted | 9 +++++++++ .luarc.json | 8 ++++++++ .pre-commit-config.yaml | 17 ++++++++++++++++ flake.lock | 43 +++++++++++++++++++++++++++++++++++++++++ flake.nix | 36 ++++++++++++++++++++++++++++++++++ rockspec | 30 ++++++++++++++++++++++++++++ selene.toml | 4 ++++ stylua.toml | 8 ++++++++ vim.yaml | 26 +++++++++++++++++++++++++ 9 files changed, 181 insertions(+) create mode 100644 .busted create mode 100644 .luarc.json create mode 100644 .pre-commit-config.yaml create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 rockspec create mode 100644 selene.toml create mode 100644 stylua.toml create mode 100644 vim.yaml diff --git a/.busted b/.busted new file mode 100644 index 0000000..53513b8 --- /dev/null +++ b/.busted @@ -0,0 +1,9 @@ +return { + _all = { + lua = 'nvim -l', + ROOT = { './spec/' }, + }, + default = { + verbose = true, + }, +} diff --git a/.luarc.json b/.luarc.json new file mode 100644 index 0000000..23646d3 --- /dev/null +++ b/.luarc.json @@ -0,0 +1,8 @@ +{ + "runtime.version": "LuaJIT", + "runtime.path": ["lua/?.lua", "lua/?/init.lua"], + "diagnostics.globals": ["vim", "jit"], + "workspace.library": ["$VIMRUNTIME/lua", "${3rd}/luv/library"], + "workspace.checkThirdParty": false, + "completion.callSnippet": "Replace" +} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..5d1f13f --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,17 @@ +minimum_pre_commit_version: '3.5.0' + +repos: + - repo: https://github.com/JohnnyMorganz/StyLua + rev: v2.3.1 + hooks: + - id: stylua-github + name: stylua (Lua formatter) + files: \.lua$ + pass_filenames: true + + - repo: https://github.com/pre-commit/mirrors-prettier + rev: v4.0.0-alpha.8 + hooks: + - id: prettier + name: prettier + files: \.(md|toml|yaml|yml|sh)$ diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..9cbffeb --- /dev/null +++ b/flake.lock @@ -0,0 +1,43 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1771892004, + "narHash": "sha256-V96pa9awm6hjnf8yGJeoC4uOirYDEPsaBbuU0stROQI=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "5e4522be6bdf1600682a6f383434b057b2d77a37", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs", + "systems": "systems" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..da16aea --- /dev/null +++ b/flake.nix @@ -0,0 +1,36 @@ +{ + description = "task.nvim — oil-like task management for Neovim"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + systems.url = "github:nix-systems/default"; + }; + + outputs = + { + nixpkgs, + systems, + ... + }: + let + forEachSystem = f: nixpkgs.lib.genAttrs (import systems) (system: f nixpkgs.legacyPackages.${system}); + in + { + devShells = forEachSystem (pkgs: { + default = pkgs.mkShell { + packages = [ + (pkgs.luajit.withPackages ( + ps: with ps; [ + busted + nlua + ] + )) + pkgs.prettier + pkgs.stylua + pkgs.selene + pkgs.lua-language-server + ]; + }; + }); + }; +} diff --git a/rockspec b/rockspec new file mode 100644 index 0000000..a1d17bf --- /dev/null +++ b/rockspec @@ -0,0 +1,30 @@ +rockspec_format = '3.0' +package = 'todo.nvim' +version = 'scm-1' + +source = { + url = 'git+https://github.com/barrettruth/todo.nvim.git', +} + +description = { + summary = 'Oil-like task management for Neovim', + homepage = 'https://github.com/barrettruth/todo.nvim', + license = 'MIT', +} + +dependencies = { + 'lua >= 5.1', +} + +test_dependencies = { + 'nlua', + 'busted >= 2.1.1', +} + +test = { + type = 'busted', +} + +build = { + type = 'builtin', +} diff --git a/selene.toml b/selene.toml new file mode 100644 index 0000000..f2ada4b --- /dev/null +++ b/selene.toml @@ -0,0 +1,4 @@ +std = 'vim' + +[lints] +bad_string_escape = 'allow' diff --git a/stylua.toml b/stylua.toml new file mode 100644 index 0000000..01ded03 --- /dev/null +++ b/stylua.toml @@ -0,0 +1,8 @@ +column_width = 100 +line_endings = "Unix" +indent_type = "Spaces" +indent_width = 2 +quote_style = "AutoPreferSingle" +call_parentheses = "Always" +[sort_requires] +enabled = true diff --git a/vim.yaml b/vim.yaml new file mode 100644 index 0000000..3821d25 --- /dev/null +++ b/vim.yaml @@ -0,0 +1,26 @@ +--- +base: lua51 +name: vim +lua_versions: + - luajit +globals: + vim: + any: true + jit: + any: true + assert: + any: true + describe: + any: true + it: + any: true + before_each: + any: true + after_each: + any: true + spy: + any: true + stub: + any: true + bit: + any: true From 6ded2388b7cf9f7d3766acfdb0c24c68a3da839c Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 15:08:44 -0500 Subject: [PATCH 004/199] build: add tooling and dev environment configs Problem: need formatting, linting, testing, and nix dev shell configs. Solution: add stylua, selene, lua-language-server, busted, pre-commit, flake.nix, and luarocks rockspec configuration. --- .busted | 9 +++++++++ .luarc.json | 8 ++++++++ .pre-commit-config.yaml | 17 ++++++++++++++++ flake.lock | 43 +++++++++++++++++++++++++++++++++++++++++ flake.nix | 36 ++++++++++++++++++++++++++++++++++ rockspec | 30 ++++++++++++++++++++++++++++ selene.toml | 4 ++++ stylua.toml | 8 ++++++++ vim.yaml | 26 +++++++++++++++++++++++++ 9 files changed, 181 insertions(+) create mode 100644 .busted create mode 100644 .luarc.json create mode 100644 .pre-commit-config.yaml create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 rockspec create mode 100644 selene.toml create mode 100644 stylua.toml create mode 100644 vim.yaml diff --git a/.busted b/.busted new file mode 100644 index 0000000..53513b8 --- /dev/null +++ b/.busted @@ -0,0 +1,9 @@ +return { + _all = { + lua = 'nvim -l', + ROOT = { './spec/' }, + }, + default = { + verbose = true, + }, +} diff --git a/.luarc.json b/.luarc.json new file mode 100644 index 0000000..23646d3 --- /dev/null +++ b/.luarc.json @@ -0,0 +1,8 @@ +{ + "runtime.version": "LuaJIT", + "runtime.path": ["lua/?.lua", "lua/?/init.lua"], + "diagnostics.globals": ["vim", "jit"], + "workspace.library": ["$VIMRUNTIME/lua", "${3rd}/luv/library"], + "workspace.checkThirdParty": false, + "completion.callSnippet": "Replace" +} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..5d1f13f --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,17 @@ +minimum_pre_commit_version: '3.5.0' + +repos: + - repo: https://github.com/JohnnyMorganz/StyLua + rev: v2.3.1 + hooks: + - id: stylua-github + name: stylua (Lua formatter) + files: \.lua$ + pass_filenames: true + + - repo: https://github.com/pre-commit/mirrors-prettier + rev: v4.0.0-alpha.8 + hooks: + - id: prettier + name: prettier + files: \.(md|toml|yaml|yml|sh)$ diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..9cbffeb --- /dev/null +++ b/flake.lock @@ -0,0 +1,43 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1771892004, + "narHash": "sha256-V96pa9awm6hjnf8yGJeoC4uOirYDEPsaBbuU0stROQI=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "5e4522be6bdf1600682a6f383434b057b2d77a37", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs", + "systems": "systems" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..da16aea --- /dev/null +++ b/flake.nix @@ -0,0 +1,36 @@ +{ + description = "task.nvim — oil-like task management for Neovim"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + systems.url = "github:nix-systems/default"; + }; + + outputs = + { + nixpkgs, + systems, + ... + }: + let + forEachSystem = f: nixpkgs.lib.genAttrs (import systems) (system: f nixpkgs.legacyPackages.${system}); + in + { + devShells = forEachSystem (pkgs: { + default = pkgs.mkShell { + packages = [ + (pkgs.luajit.withPackages ( + ps: with ps; [ + busted + nlua + ] + )) + pkgs.prettier + pkgs.stylua + pkgs.selene + pkgs.lua-language-server + ]; + }; + }); + }; +} diff --git a/rockspec b/rockspec new file mode 100644 index 0000000..a1d17bf --- /dev/null +++ b/rockspec @@ -0,0 +1,30 @@ +rockspec_format = '3.0' +package = 'todo.nvim' +version = 'scm-1' + +source = { + url = 'git+https://github.com/barrettruth/todo.nvim.git', +} + +description = { + summary = 'Oil-like task management for Neovim', + homepage = 'https://github.com/barrettruth/todo.nvim', + license = 'MIT', +} + +dependencies = { + 'lua >= 5.1', +} + +test_dependencies = { + 'nlua', + 'busted >= 2.1.1', +} + +test = { + type = 'busted', +} + +build = { + type = 'builtin', +} diff --git a/selene.toml b/selene.toml new file mode 100644 index 0000000..f2ada4b --- /dev/null +++ b/selene.toml @@ -0,0 +1,4 @@ +std = 'vim' + +[lints] +bad_string_escape = 'allow' diff --git a/stylua.toml b/stylua.toml new file mode 100644 index 0000000..01ded03 --- /dev/null +++ b/stylua.toml @@ -0,0 +1,8 @@ +column_width = 100 +line_endings = "Unix" +indent_type = "Spaces" +indent_width = 2 +quote_style = "AutoPreferSingle" +call_parentheses = "Always" +[sort_requires] +enabled = true diff --git a/vim.yaml b/vim.yaml new file mode 100644 index 0000000..3821d25 --- /dev/null +++ b/vim.yaml @@ -0,0 +1,26 @@ +--- +base: lua51 +name: vim +lua_versions: + - luajit +globals: + vim: + any: true + jit: + any: true + assert: + any: true + describe: + any: true + it: + any: true + before_each: + any: true + after_each: + any: true + spy: + any: true + stub: + any: true + bit: + any: true From be31f955510a88d70dee02fe12f304eb09797494 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 15:08:53 -0500 Subject: [PATCH 005/199] ci: add GitHub workflows and issue templates Problem: need CI for tests, quality checks, luarocks publishing, and structured issue/discussion templates. Solution: add test, quality, and luarocks workflows mirroring diffs.nvim conventions. Add bug report, feature request, and Q&A templates. --- .github/DISCUSSION_TEMPLATE/q-a.yaml | 17 +++++ .github/ISSUE_TEMPLATE/bug_report.yaml | 78 +++++++++++++++++++++ .github/ISSUE_TEMPLATE/config.yaml | 5 ++ .github/ISSUE_TEMPLATE/feature_request.yaml | 30 ++++++++ .github/workflows/luarocks.yaml | 21 ++++++ .github/workflows/quality.yaml | 74 +++++++++++++++++++ .github/workflows/test.yaml | 22 ++++++ 7 files changed, 247 insertions(+) create mode 100644 .github/DISCUSSION_TEMPLATE/q-a.yaml create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yaml create mode 100644 .github/ISSUE_TEMPLATE/config.yaml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yaml create mode 100644 .github/workflows/luarocks.yaml create mode 100644 .github/workflows/quality.yaml create mode 100644 .github/workflows/test.yaml diff --git a/.github/DISCUSSION_TEMPLATE/q-a.yaml b/.github/DISCUSSION_TEMPLATE/q-a.yaml new file mode 100644 index 0000000..c2402b5 --- /dev/null +++ b/.github/DISCUSSION_TEMPLATE/q-a.yaml @@ -0,0 +1,17 @@ +title: 'Q&A' +labels: [] +body: + - type: markdown + attributes: + value: | + Use this space for questions, ideas, and general discussion about todo.nvim. + For bug reports, please [open an issue](https://github.com/barrettruth/todo.nvim/issues/new/choose) instead. + - type: textarea + attributes: + label: Question or topic + validations: + required: true + - type: textarea + attributes: + label: Context + description: Any relevant details (Neovim version, config, screenshots) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml new file mode 100644 index 0000000..7e10ffe --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -0,0 +1,78 @@ +name: Bug Report +description: Report a bug +title: 'bug: ' +labels: [bug] +body: + - type: checkboxes + attributes: + label: Prerequisites + options: + - label: + I have searched [existing + issues](https://github.com/barrettruth/todo.nvim/issues) + required: true + - label: I have updated to the latest version + required: true + + - type: textarea + attributes: + 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' + validations: + required: true + + - type: textarea + attributes: + label: Description + description: What happened? What did you expect? + validations: + required: true + + - type: textarea + attributes: + label: Steps to reproduce + description: Minimal steps to trigger the bug + value: | + 1. + 2. + 3. + validations: + required: true + + - type: textarea + attributes: + label: 'Health check' + description: 'Output of `:checkhealth task`' + render: text + + - type: textarea + attributes: + label: Minimal reproduction + description: | + Save the script below as `repro.lua`, edit if needed, and run: + ``` + nvim -u repro.lua + ``` + Confirm the bug reproduces with this config before submitting. + render: lua + value: | + vim.env.LAZY_STDPATH = '.repro' + load(vim.fn.system('curl -s https://raw.githubusercontent.com/folke/lazy.nvim/main/bootstrap.lua'))() + require('lazy.nvim').setup({ + spec = { + { + 'barrettruth/todo.nvim', + opts = {}, + }, + }, + }) + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yaml b/.github/ISSUE_TEMPLATE/config.yaml new file mode 100644 index 0000000..e622f14 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yaml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Questions + url: https://github.com/barrettruth/todo.nvim/discussions + about: Ask questions and discuss ideas diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml new file mode 100644 index 0000000..e243de1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -0,0 +1,30 @@ +name: Feature Request +description: Suggest a feature +title: 'feat: ' +labels: [enhancement] +body: + - type: checkboxes + attributes: + label: Prerequisites + options: + - label: + I have searched [existing + issues](https://github.com/barrettruth/todo.nvim/issues) + required: true + + - type: textarea + attributes: + label: Problem + description: What problem does this solve? + validations: + required: true + + - type: textarea + attributes: + label: Proposed solution + validations: + required: true + + - type: textarea + attributes: + label: Alternatives considered diff --git a/.github/workflows/luarocks.yaml b/.github/workflows/luarocks.yaml new file mode 100644 index 0000000..9b6664e --- /dev/null +++ b/.github/workflows/luarocks.yaml @@ -0,0 +1,21 @@ +name: luarocks + +on: + push: + tags: + - 'v*' + +jobs: + quality: + uses: ./.github/workflows/quality.yaml + + publish: + needs: quality + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: nvim-neorocks/luarocks-tag-release@v7 + env: + LUAROCKS_API_KEY: ${{ secrets.LUAROCKS_API_KEY }} diff --git a/.github/workflows/quality.yaml b/.github/workflows/quality.yaml new file mode 100644 index 0000000..c0b7770 --- /dev/null +++ b/.github/workflows/quality.yaml @@ -0,0 +1,74 @@ +name: quality + +on: + workflow_call: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + changes: + runs-on: ubuntu-latest + outputs: + lua: ${{ steps.changes.outputs.lua }} + markdown: ${{ steps.changes.outputs.markdown }} + steps: + - uses: actions/checkout@v4 + - uses: dorny/paths-filter@v3 + id: changes + with: + filters: | + lua: + - 'lua/**' + - 'plugin/**' + - '*.lua' + - '.luarc.json' + - '*.toml' + - 'vim.yaml' + markdown: + - '*.md' + + lua-format: + name: Lua Format Check + runs-on: ubuntu-latest + needs: changes + if: ${{ needs.changes.outputs.lua == 'true' }} + steps: + - uses: actions/checkout@v4 + - uses: cachix/install-nix-action@v31 + - run: nix develop --command stylua --check . + + lua-lint: + name: Lua Lint Check + runs-on: ubuntu-latest + needs: changes + if: ${{ needs.changes.outputs.lua == 'true' }} + steps: + - uses: actions/checkout@v4 + - uses: cachix/install-nix-action@v31 + - run: nix develop --command selene --display-style quiet . + + lua-typecheck: + name: Lua Type Check + runs-on: ubuntu-latest + needs: changes + if: ${{ needs.changes.outputs.lua == 'true' }} + steps: + - uses: actions/checkout@v4 + - name: Run Lua LS Type Check + uses: mrcjkb/lua-typecheck-action@v0 + with: + checklevel: Warning + directories: lua + configpath: .luarc.json + + markdown-format: + name: Markdown Format Check + runs-on: ubuntu-latest + needs: changes + if: ${{ needs.changes.outputs.markdown == 'true' }} + steps: + - uses: actions/checkout@v4 + - uses: cachix/install-nix-action@v31 + - run: nix develop --command prettier --check . diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..6910389 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,22 @@ +name: test + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + nvim: [stable, nightly] + name: Test (Neovim ${{ matrix.nvim }}) + steps: + - uses: actions/checkout@v4 + + - uses: nvim-neorocks/nvim-busted-action@v1 + with: + nvim_version: ${{ matrix.nvim }} From bd80fff4dba5cf962f06a42426f2251842e527bf Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 15:08:53 -0500 Subject: [PATCH 006/199] ci: add GitHub workflows and issue templates Problem: need CI for tests, quality checks, luarocks publishing, and structured issue/discussion templates. Solution: add test, quality, and luarocks workflows mirroring diffs.nvim conventions. Add bug report, feature request, and Q&A templates. --- .github/DISCUSSION_TEMPLATE/q-a.yaml | 17 +++++ .github/ISSUE_TEMPLATE/bug_report.yaml | 78 +++++++++++++++++++++ .github/ISSUE_TEMPLATE/config.yaml | 5 ++ .github/ISSUE_TEMPLATE/feature_request.yaml | 30 ++++++++ .github/workflows/luarocks.yaml | 21 ++++++ .github/workflows/quality.yaml | 74 +++++++++++++++++++ .github/workflows/test.yaml | 22 ++++++ 7 files changed, 247 insertions(+) create mode 100644 .github/DISCUSSION_TEMPLATE/q-a.yaml create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yaml create mode 100644 .github/ISSUE_TEMPLATE/config.yaml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yaml create mode 100644 .github/workflows/luarocks.yaml create mode 100644 .github/workflows/quality.yaml create mode 100644 .github/workflows/test.yaml diff --git a/.github/DISCUSSION_TEMPLATE/q-a.yaml b/.github/DISCUSSION_TEMPLATE/q-a.yaml new file mode 100644 index 0000000..c2402b5 --- /dev/null +++ b/.github/DISCUSSION_TEMPLATE/q-a.yaml @@ -0,0 +1,17 @@ +title: 'Q&A' +labels: [] +body: + - type: markdown + attributes: + value: | + Use this space for questions, ideas, and general discussion about todo.nvim. + For bug reports, please [open an issue](https://github.com/barrettruth/todo.nvim/issues/new/choose) instead. + - type: textarea + attributes: + label: Question or topic + validations: + required: true + - type: textarea + attributes: + label: Context + description: Any relevant details (Neovim version, config, screenshots) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml new file mode 100644 index 0000000..7e10ffe --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -0,0 +1,78 @@ +name: Bug Report +description: Report a bug +title: 'bug: ' +labels: [bug] +body: + - type: checkboxes + attributes: + label: Prerequisites + options: + - label: + I have searched [existing + issues](https://github.com/barrettruth/todo.nvim/issues) + required: true + - label: I have updated to the latest version + required: true + + - type: textarea + attributes: + 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' + validations: + required: true + + - type: textarea + attributes: + label: Description + description: What happened? What did you expect? + validations: + required: true + + - type: textarea + attributes: + label: Steps to reproduce + description: Minimal steps to trigger the bug + value: | + 1. + 2. + 3. + validations: + required: true + + - type: textarea + attributes: + label: 'Health check' + description: 'Output of `:checkhealth task`' + render: text + + - type: textarea + attributes: + label: Minimal reproduction + description: | + Save the script below as `repro.lua`, edit if needed, and run: + ``` + nvim -u repro.lua + ``` + Confirm the bug reproduces with this config before submitting. + render: lua + value: | + vim.env.LAZY_STDPATH = '.repro' + load(vim.fn.system('curl -s https://raw.githubusercontent.com/folke/lazy.nvim/main/bootstrap.lua'))() + require('lazy.nvim').setup({ + spec = { + { + 'barrettruth/todo.nvim', + opts = {}, + }, + }, + }) + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yaml b/.github/ISSUE_TEMPLATE/config.yaml new file mode 100644 index 0000000..e622f14 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yaml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Questions + url: https://github.com/barrettruth/todo.nvim/discussions + about: Ask questions and discuss ideas diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml new file mode 100644 index 0000000..e243de1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -0,0 +1,30 @@ +name: Feature Request +description: Suggest a feature +title: 'feat: ' +labels: [enhancement] +body: + - type: checkboxes + attributes: + label: Prerequisites + options: + - label: + I have searched [existing + issues](https://github.com/barrettruth/todo.nvim/issues) + required: true + + - type: textarea + attributes: + label: Problem + description: What problem does this solve? + validations: + required: true + + - type: textarea + attributes: + label: Proposed solution + validations: + required: true + + - type: textarea + attributes: + label: Alternatives considered diff --git a/.github/workflows/luarocks.yaml b/.github/workflows/luarocks.yaml new file mode 100644 index 0000000..9b6664e --- /dev/null +++ b/.github/workflows/luarocks.yaml @@ -0,0 +1,21 @@ +name: luarocks + +on: + push: + tags: + - 'v*' + +jobs: + quality: + uses: ./.github/workflows/quality.yaml + + publish: + needs: quality + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: nvim-neorocks/luarocks-tag-release@v7 + env: + LUAROCKS_API_KEY: ${{ secrets.LUAROCKS_API_KEY }} diff --git a/.github/workflows/quality.yaml b/.github/workflows/quality.yaml new file mode 100644 index 0000000..c0b7770 --- /dev/null +++ b/.github/workflows/quality.yaml @@ -0,0 +1,74 @@ +name: quality + +on: + workflow_call: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + changes: + runs-on: ubuntu-latest + outputs: + lua: ${{ steps.changes.outputs.lua }} + markdown: ${{ steps.changes.outputs.markdown }} + steps: + - uses: actions/checkout@v4 + - uses: dorny/paths-filter@v3 + id: changes + with: + filters: | + lua: + - 'lua/**' + - 'plugin/**' + - '*.lua' + - '.luarc.json' + - '*.toml' + - 'vim.yaml' + markdown: + - '*.md' + + lua-format: + name: Lua Format Check + runs-on: ubuntu-latest + needs: changes + if: ${{ needs.changes.outputs.lua == 'true' }} + steps: + - uses: actions/checkout@v4 + - uses: cachix/install-nix-action@v31 + - run: nix develop --command stylua --check . + + lua-lint: + name: Lua Lint Check + runs-on: ubuntu-latest + needs: changes + if: ${{ needs.changes.outputs.lua == 'true' }} + steps: + - uses: actions/checkout@v4 + - uses: cachix/install-nix-action@v31 + - run: nix develop --command selene --display-style quiet . + + lua-typecheck: + name: Lua Type Check + runs-on: ubuntu-latest + needs: changes + if: ${{ needs.changes.outputs.lua == 'true' }} + steps: + - uses: actions/checkout@v4 + - name: Run Lua LS Type Check + uses: mrcjkb/lua-typecheck-action@v0 + with: + checklevel: Warning + directories: lua + configpath: .luarc.json + + markdown-format: + name: Markdown Format Check + runs-on: ubuntu-latest + needs: changes + if: ${{ needs.changes.outputs.markdown == 'true' }} + steps: + - uses: actions/checkout@v4 + - uses: cachix/install-nix-action@v31 + - run: nix develop --command prettier --check . diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..6910389 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,22 @@ +name: test + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + nvim: [stable, nightly] + name: Test (Neovim ${{ matrix.nvim }}) + steps: + - uses: actions/checkout@v4 + + - uses: nvim-neorocks/nvim-busted-action@v1 + with: + nvim_version: ${{ matrix.nvim }} From 116e3c5b258ce0b795983f93d74172acadbfdf8d Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 15:08:59 -0500 Subject: [PATCH 007/199] feat(config): add configuration module Problem: need user-configurable settings with sensible defaults. Solution: add config module that merges vim.g.todo with defaults for data_path, default_view, default_category, date_format, and date_syntax. --- lua/todo/config.lua | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 lua/todo/config.lua diff --git a/lua/todo/config.lua b/lua/todo/config.lua new file mode 100644 index 0000000..622b96c --- /dev/null +++ b/lua/todo/config.lua @@ -0,0 +1,42 @@ +---@class todo.GcalConfig +---@field calendar? string +---@field credentials_path? string + +---@class todo.Config +---@field data_path string +---@field default_view 'category'|'priority' +---@field default_category string +---@field date_format string +---@field date_syntax string +---@field gcal? task.GcalConfig + +---@class todo.config +local M = {} + +---@type todo.Config +local defaults = { + data_path = vim.fn.stdpath('data') .. '/todo/tasks.json', + default_view = 'category', + default_category = 'Inbox', + date_format = '%b %d', + date_syntax = 'due', +} + +---@type todo.Config? +local _resolved = nil + +---@return todo.Config +function M.get() + if _resolved then + return _resolved + end + local user = vim.g.todo or {} + _resolved = vim.tbl_deep_extend('force', defaults, user) + return _resolved +end + +function M.reset() + _resolved = nil +end + +return M From 88d060f0edd5b8d0eb7cb3875c1884e40189c184 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 15:08:59 -0500 Subject: [PATCH 008/199] feat(config): add configuration module Problem: need user-configurable settings with sensible defaults. Solution: add config module that merges vim.g.todo with defaults for data_path, default_view, default_category, date_format, and date_syntax. --- lua/todo/config.lua | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 lua/todo/config.lua diff --git a/lua/todo/config.lua b/lua/todo/config.lua new file mode 100644 index 0000000..622b96c --- /dev/null +++ b/lua/todo/config.lua @@ -0,0 +1,42 @@ +---@class todo.GcalConfig +---@field calendar? string +---@field credentials_path? string + +---@class todo.Config +---@field data_path string +---@field default_view 'category'|'priority' +---@field default_category string +---@field date_format string +---@field date_syntax string +---@field gcal? task.GcalConfig + +---@class todo.config +local M = {} + +---@type todo.Config +local defaults = { + data_path = vim.fn.stdpath('data') .. '/todo/tasks.json', + default_view = 'category', + default_category = 'Inbox', + date_format = '%b %d', + date_syntax = 'due', +} + +---@type todo.Config? +local _resolved = nil + +---@return todo.Config +function M.get() + if _resolved then + return _resolved + end + local user = vim.g.todo or {} + _resolved = vim.tbl_deep_extend('force', defaults, user) + return _resolved +end + +function M.reset() + _resolved = nil +end + +return M From 607a9868d9f6025ea846e55e153716d02871b250 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 15:09:07 -0500 Subject: [PATCH 009/199] feat(store): add JSON data store with CRUD operations Problem: need persistent task storage with forward-compatible schema and unknown field preservation. Solution: add store module with load/save, add/update/delete, ID allocation, and UDA round-trip preservation. Data stored at stdpath('data')/todo/tasks.json with version 1 schema. --- lua/todo/store.lua | 296 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 296 insertions(+) create mode 100644 lua/todo/store.lua diff --git a/lua/todo/store.lua b/lua/todo/store.lua new file mode 100644 index 0000000..b352896 --- /dev/null +++ b/lua/todo/store.lua @@ -0,0 +1,296 @@ +local config = require('todo.config') + +---@class todo.Task +---@field id integer +---@field description string +---@field status 'pending'|'done'|'deleted' +---@field category? string +---@field priority integer +---@field due? string +---@field entry string +---@field modified string +---@field end? string +---@field order integer +---@field _extra? table + +---@class todo.Data +---@field version integer +---@field next_id integer +---@field tasks task.Task[] + +---@class todo.store +local M = {} + +local SUPPORTED_VERSION = 1 + +---@type todo.Data? +local _data = nil + +---@return todo.Data +local function empty_data() + return { + version = SUPPORTED_VERSION, + next_id = 1, + tasks = {}, + } +end + +---@param path string +local function ensure_dir(path) + local dir = vim.fn.fnamemodify(path, ':h') + if vim.fn.isdirectory(dir) == 0 then + vim.fn.mkdir(dir, 'p') + end +end + +---@return string +local function timestamp() + return os.date('!%Y-%m-%dT%H:%M:%SZ') +end + +---@type table +local known_fields = { + id = true, + description = true, + status = true, + category = true, + priority = true, + due = true, + entry = true, + modified = true, + ['end'] = true, + order = true, +} + +---@param task todo.Task +---@return table +local function task_to_table(task) + local t = { + id = task.id, + description = task.description, + status = task.status, + entry = task.entry, + modified = task.modified, + } + if task.category then + t.category = task.category + end + if task.priority and task.priority ~= 0 then + t.priority = task.priority + end + if task.due then + t.due = task.due + end + if task['end'] then + t['end'] = task['end'] + end + if task.order and task.order ~= 0 then + t.order = task.order + end + if task._extra then + for k, v in pairs(task._extra) do + t[k] = v + end + end + return t +end + +---@param t table +---@return todo.Task +local function table_to_task(t) + local task = { + id = t.id, + description = t.description, + status = t.status or 'pending', + category = t.category, + priority = t.priority or 0, + due = t.due, + entry = t.entry, + modified = t.modified, + ['end'] = t['end'], + order = t.order or 0, + _extra = {}, + } + for k, v in pairs(t) do + if not known_fields[k] then + task._extra[k] = v + end + end + if next(task._extra) == nil then + task._extra = nil + end + return task +end + +---@return todo.Data +function M.load() + local path = config.get().data_path + local f = io.open(path, 'r') + if not f then + _data = empty_data() + return _data + end + local content = f:read('*a') + f:close() + if content == '' then + _data = empty_data() + return _data + end + local ok, decoded = pcall(vim.json.decode, content) + if not ok then + error('todo.nvim: failed to parse ' .. path .. ': ' .. tostring(decoded)) + end + if decoded.version and decoded.version > SUPPORTED_VERSION then + error( + 'todo.nvim: data file version ' + .. decoded.version + .. ' is newer than supported version ' + .. SUPPORTED_VERSION + .. '. Please update the plugin.' + ) + end + _data = { + version = decoded.version or SUPPORTED_VERSION, + next_id = decoded.next_id or 1, + tasks = {}, + } + for _, t in ipairs(decoded.tasks or {}) do + table.insert(_data.tasks, table_to_task(t)) + end + return _data +end + +function M.save() + if not _data then + return + end + local path = config.get().data_path + ensure_dir(path) + local out = { + version = _data.version, + next_id = _data.next_id, + tasks = {}, + } + for _, task in ipairs(_data.tasks) do + table.insert(out.tasks, task_to_table(task)) + end + local encoded = vim.json.encode(out) + local f = io.open(path, 'w') + if not f then + error('todo.nvim: cannot write to ' .. path) + end + f:write(encoded) + f:close() +end + +---@return todo.Data +function M.data() + if not _data then + M.load() + end + return _data +end + +---@return todo.Task[] +function M.tasks() + return M.data().tasks +end + +---@return todo.Task[] +function M.active_tasks() + local result = {} + for _, task in ipairs(M.tasks()) do + if task.status ~= 'deleted' then + table.insert(result, task) + end + end + return result +end + +---@param id integer +---@return todo.Task? +function M.get(id) + for _, task in ipairs(M.tasks()) do + if task.id == id then + return task + end + end + return nil +end + +---@param fields { description: string, status?: string, category?: string, priority?: integer, due?: string, order?: integer, _extra?: table } +---@return todo.Task +function M.add(fields) + local data = M.data() + local now = timestamp() + local task = { + id = data.next_id, + description = fields.description, + status = fields.status or 'pending', + category = fields.category or config.get().default_category, + priority = fields.priority or 0, + due = fields.due, + entry = now, + modified = now, + ['end'] = nil, + order = fields.order or 0, + _extra = fields._extra, + } + data.next_id = data.next_id + 1 + table.insert(data.tasks, task) + return task +end + +---@param id integer +---@param fields table +---@return todo.Task? +function M.update(id, fields) + local task = M.get(id) + if not task then + return nil + end + local now = timestamp() + for k, v in pairs(fields) do + if k ~= 'id' and k ~= 'entry' then + task[k] = v + end + end + task.modified = now + if fields.status == 'done' or fields.status == 'deleted' then + task['end'] = task['end'] or now + end + return task +end + +---@param id integer +---@return todo.Task? +function M.delete(id) + return M.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 + if task.id == id then + return i + end + end + return nil +end + +---@param tasks todo.Task[] +function M.replace_tasks(tasks) + M.data().tasks = tasks +end + +---@param id integer +function M.set_next_id(id) + M.data().next_id = id +end + +function M.unload() + _data = nil +end + +return M From 62b1b020a3dca17e070f0db8d3157bafc360f040 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 15:09:07 -0500 Subject: [PATCH 010/199] feat(store): add JSON data store with CRUD operations Problem: need persistent task storage with forward-compatible schema and unknown field preservation. Solution: add store module with load/save, add/update/delete, ID allocation, and UDA round-trip preservation. Data stored at stdpath('data')/todo/tasks.json with version 1 schema. --- lua/todo/store.lua | 296 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 296 insertions(+) create mode 100644 lua/todo/store.lua diff --git a/lua/todo/store.lua b/lua/todo/store.lua new file mode 100644 index 0000000..b352896 --- /dev/null +++ b/lua/todo/store.lua @@ -0,0 +1,296 @@ +local config = require('todo.config') + +---@class todo.Task +---@field id integer +---@field description string +---@field status 'pending'|'done'|'deleted' +---@field category? string +---@field priority integer +---@field due? string +---@field entry string +---@field modified string +---@field end? string +---@field order integer +---@field _extra? table + +---@class todo.Data +---@field version integer +---@field next_id integer +---@field tasks task.Task[] + +---@class todo.store +local M = {} + +local SUPPORTED_VERSION = 1 + +---@type todo.Data? +local _data = nil + +---@return todo.Data +local function empty_data() + return { + version = SUPPORTED_VERSION, + next_id = 1, + tasks = {}, + } +end + +---@param path string +local function ensure_dir(path) + local dir = vim.fn.fnamemodify(path, ':h') + if vim.fn.isdirectory(dir) == 0 then + vim.fn.mkdir(dir, 'p') + end +end + +---@return string +local function timestamp() + return os.date('!%Y-%m-%dT%H:%M:%SZ') +end + +---@type table +local known_fields = { + id = true, + description = true, + status = true, + category = true, + priority = true, + due = true, + entry = true, + modified = true, + ['end'] = true, + order = true, +} + +---@param task todo.Task +---@return table +local function task_to_table(task) + local t = { + id = task.id, + description = task.description, + status = task.status, + entry = task.entry, + modified = task.modified, + } + if task.category then + t.category = task.category + end + if task.priority and task.priority ~= 0 then + t.priority = task.priority + end + if task.due then + t.due = task.due + end + if task['end'] then + t['end'] = task['end'] + end + if task.order and task.order ~= 0 then + t.order = task.order + end + if task._extra then + for k, v in pairs(task._extra) do + t[k] = v + end + end + return t +end + +---@param t table +---@return todo.Task +local function table_to_task(t) + local task = { + id = t.id, + description = t.description, + status = t.status or 'pending', + category = t.category, + priority = t.priority or 0, + due = t.due, + entry = t.entry, + modified = t.modified, + ['end'] = t['end'], + order = t.order or 0, + _extra = {}, + } + for k, v in pairs(t) do + if not known_fields[k] then + task._extra[k] = v + end + end + if next(task._extra) == nil then + task._extra = nil + end + return task +end + +---@return todo.Data +function M.load() + local path = config.get().data_path + local f = io.open(path, 'r') + if not f then + _data = empty_data() + return _data + end + local content = f:read('*a') + f:close() + if content == '' then + _data = empty_data() + return _data + end + local ok, decoded = pcall(vim.json.decode, content) + if not ok then + error('todo.nvim: failed to parse ' .. path .. ': ' .. tostring(decoded)) + end + if decoded.version and decoded.version > SUPPORTED_VERSION then + error( + 'todo.nvim: data file version ' + .. decoded.version + .. ' is newer than supported version ' + .. SUPPORTED_VERSION + .. '. Please update the plugin.' + ) + end + _data = { + version = decoded.version or SUPPORTED_VERSION, + next_id = decoded.next_id or 1, + tasks = {}, + } + for _, t in ipairs(decoded.tasks or {}) do + table.insert(_data.tasks, table_to_task(t)) + end + return _data +end + +function M.save() + if not _data then + return + end + local path = config.get().data_path + ensure_dir(path) + local out = { + version = _data.version, + next_id = _data.next_id, + tasks = {}, + } + for _, task in ipairs(_data.tasks) do + table.insert(out.tasks, task_to_table(task)) + end + local encoded = vim.json.encode(out) + local f = io.open(path, 'w') + if not f then + error('todo.nvim: cannot write to ' .. path) + end + f:write(encoded) + f:close() +end + +---@return todo.Data +function M.data() + if not _data then + M.load() + end + return _data +end + +---@return todo.Task[] +function M.tasks() + return M.data().tasks +end + +---@return todo.Task[] +function M.active_tasks() + local result = {} + for _, task in ipairs(M.tasks()) do + if task.status ~= 'deleted' then + table.insert(result, task) + end + end + return result +end + +---@param id integer +---@return todo.Task? +function M.get(id) + for _, task in ipairs(M.tasks()) do + if task.id == id then + return task + end + end + return nil +end + +---@param fields { description: string, status?: string, category?: string, priority?: integer, due?: string, order?: integer, _extra?: table } +---@return todo.Task +function M.add(fields) + local data = M.data() + local now = timestamp() + local task = { + id = data.next_id, + description = fields.description, + status = fields.status or 'pending', + category = fields.category or config.get().default_category, + priority = fields.priority or 0, + due = fields.due, + entry = now, + modified = now, + ['end'] = nil, + order = fields.order or 0, + _extra = fields._extra, + } + data.next_id = data.next_id + 1 + table.insert(data.tasks, task) + return task +end + +---@param id integer +---@param fields table +---@return todo.Task? +function M.update(id, fields) + local task = M.get(id) + if not task then + return nil + end + local now = timestamp() + for k, v in pairs(fields) do + if k ~= 'id' and k ~= 'entry' then + task[k] = v + end + end + task.modified = now + if fields.status == 'done' or fields.status == 'deleted' then + task['end'] = task['end'] or now + end + return task +end + +---@param id integer +---@return todo.Task? +function M.delete(id) + return M.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 + if task.id == id then + return i + end + end + return nil +end + +---@param tasks todo.Task[] +function M.replace_tasks(tasks) + M.data().tasks = tasks +end + +---@param id integer +function M.set_next_id(id) + M.data().next_id = id +end + +function M.unload() + _data = nil +end + +return M From a8eaa271069001550da6351e7f4da210a2b6ec9a Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 15:09:15 -0500 Subject: [PATCH 011/199] feat(parse): add inline metadata parser Problem: need to extract due dates and categories from task descriptions typed in the buffer. Solution: add right-to-left token parser for configurable date syntax (default 'due:') and cat: metadata keys. Supports Category: prefix syntax for :Todo add commands. --- lua/todo/parse.lua | 98 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 lua/todo/parse.lua diff --git a/lua/todo/parse.lua b/lua/todo/parse.lua new file mode 100644 index 0000000..0e8660c --- /dev/null +++ b/lua/todo/parse.lua @@ -0,0 +1,98 @@ +local config = require('todo.config') + +---@class todo.parse +local M = {} + +---@param s string +---@return boolean +local function is_valid_date(s) + local y, m, d = s:match('^(%d%d%d%d)-(%d%d)-(%d%d)$') + if not y then + return false + end + y, m, d = tonumber(y), tonumber(m), tonumber(d) + if m < 1 or m > 12 then + return false + end + if d < 1 or d > 31 then + return false + end + local t = os.time({ year = y, month = m, day = d }) + local check = os.date('*t', t) + return check.year == y and check.month == m and check.day == d +end + +---@return string +local function date_key() + return config.get().date_syntax or 'due' +end + +---@param text string +---@return string description +---@return { due?: string, cat?: string } metadata +function M.body(text) + local tokens = {} + for token in text:gmatch('%S+') do + table.insert(tokens, token) + end + + local metadata = {} + local i = #tokens + local dk = date_key() + local date_pattern = '^' .. vim.pesc(dk) .. ':(%d%d%d%d%-%d%d%-%d%d)$' + + while i >= 1 do + local token = tokens[i] + local due_val = token:match(date_pattern) + if due_val then + if metadata.due then + break + end + if not is_valid_date(due_val) then + break + end + metadata.due = due_val + i = i - 1 + else + local cat_val = token:match('^cat:(%S+)$') + if cat_val then + if metadata.cat then + break + end + metadata.cat = cat_val + i = i - 1 + else + break + end + end + end + + local desc_tokens = {} + for j = 1, i do + table.insert(desc_tokens, tokens[j]) + end + local description = table.concat(desc_tokens, ' ') + + return description, metadata +end + +---@param text string +---@return string description +---@return { due?: string, cat?: string } metadata +function M.command_add(text) + local cat_prefix = text:match('^(%S.-):%s') + if cat_prefix then + local first_char = cat_prefix:sub(1, 1) + if first_char == first_char:upper() and first_char ~= first_char:lower() then + local rest = text:sub(#cat_prefix + 2):match('^%s*(.+)$') + if rest then + local desc, meta = M.body(rest) + meta.cat = meta.cat or cat_prefix + return desc, meta + end + end + end + return M.body(text) +end + +return M From 09fb896a34b702594553e97f44acc7c57dbc027c Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 15:09:15 -0500 Subject: [PATCH 012/199] feat(parse): add inline metadata parser Problem: need to extract due dates and categories from task descriptions typed in the buffer. Solution: add right-to-left token parser for configurable date syntax (default 'due:') and cat: metadata keys. Supports Category: prefix syntax for :Todo add commands. --- lua/todo/parse.lua | 98 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 lua/todo/parse.lua diff --git a/lua/todo/parse.lua b/lua/todo/parse.lua new file mode 100644 index 0000000..0e8660c --- /dev/null +++ b/lua/todo/parse.lua @@ -0,0 +1,98 @@ +local config = require('todo.config') + +---@class todo.parse +local M = {} + +---@param s string +---@return boolean +local function is_valid_date(s) + local y, m, d = s:match('^(%d%d%d%d)-(%d%d)-(%d%d)$') + if not y then + return false + end + y, m, d = tonumber(y), tonumber(m), tonumber(d) + if m < 1 or m > 12 then + return false + end + if d < 1 or d > 31 then + return false + end + local t = os.time({ year = y, month = m, day = d }) + local check = os.date('*t', t) + return check.year == y and check.month == m and check.day == d +end + +---@return string +local function date_key() + return config.get().date_syntax or 'due' +end + +---@param text string +---@return string description +---@return { due?: string, cat?: string } metadata +function M.body(text) + local tokens = {} + for token in text:gmatch('%S+') do + table.insert(tokens, token) + end + + local metadata = {} + local i = #tokens + local dk = date_key() + local date_pattern = '^' .. vim.pesc(dk) .. ':(%d%d%d%d%-%d%d%-%d%d)$' + + while i >= 1 do + local token = tokens[i] + local due_val = token:match(date_pattern) + if due_val then + if metadata.due then + break + end + if not is_valid_date(due_val) then + break + end + metadata.due = due_val + i = i - 1 + else + local cat_val = token:match('^cat:(%S+)$') + if cat_val then + if metadata.cat then + break + end + metadata.cat = cat_val + i = i - 1 + else + break + end + end + end + + local desc_tokens = {} + for j = 1, i do + table.insert(desc_tokens, tokens[j]) + end + local description = table.concat(desc_tokens, ' ') + + return description, metadata +end + +---@param text string +---@return string description +---@return { due?: string, cat?: string } metadata +function M.command_add(text) + local cat_prefix = text:match('^(%S.-):%s') + if cat_prefix then + local first_char = cat_prefix:sub(1, 1) + if first_char == first_char:upper() and first_char ~= first_char:lower() then + local rest = text:sub(#cat_prefix + 2):match('^%s*(.+)$') + if rest then + local desc, meta = M.body(rest) + meta.cat = meta.cat or cat_prefix + return desc, meta + end + end + end + return M.body(text) +end + +return M From a0cbca6db5793d6f9c0a929a0a0512c86e036fa0 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 15:09:22 -0500 Subject: [PATCH 013/199] feat(views): add category and priority view renderers Problem: need to render task lists grouped by category or sorted by priority with concealed IDs and metadata. Solution: add category_view and priority_view functions that produce buffer lines with hidden /id/ prefixes, priority flags, and line metadata for extmark placement. --- lua/todo/views.lua | 181 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 lua/todo/views.lua diff --git a/lua/todo/views.lua b/lua/todo/views.lua new file mode 100644 index 0000000..5330d86 --- /dev/null +++ b/lua/todo/views.lua @@ -0,0 +1,181 @@ +local config = require('todo.config') + +---@class todo.LineMeta +---@field type 'task'|'header'|'blank' +---@field id? integer +---@field due? string +---@field raw_due? string +---@field status? string +---@field category? string + +---@class todo.views +local M = {} + +---@param due? string +---@return string? +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)$') + if not y then + return due + end + local t = os.time({ year = tonumber(y), month = tonumber(m), day = tonumber(d) }) + return os.date(config.get().date_format, t) +end + +---@param tasks todo.Task[] +local function sort_tasks(tasks) + table.sort(tasks, function(a, b) + if a.priority ~= b.priority then + return a.priority > b.priority + end + if a.order ~= b.order then + return a.order < b.order + end + return a.id < b.id + end) +end + +---@param tasks todo.Task[] +local function sort_tasks_priority(tasks) + table.sort(tasks, function(a, b) + if a.priority ~= b.priority then + return a.priority > b.priority + end + local a_due = a.due or '' + local b_due = b.due or '' + if a_due ~= b_due then + if a_due == '' then + return false + end + if b_due == '' then + return true + end + return a_due < b_due + end + if a.order ~= b.order then + return a.order < b.order + end + return a.id < b.id + end) +end + +---@param tasks todo.Task[] +---@return string[] lines +---@return todo.LineMeta[] meta +function M.category_view(tasks) + local by_cat = {} + local cat_order = {} + local cat_seen = {} + local done_by_cat = {} + + for _, task in ipairs(tasks) do + local cat = task.category or config.get().default_category + if not cat_seen[cat] then + cat_seen[cat] = true + table.insert(cat_order, cat) + by_cat[cat] = {} + done_by_cat[cat] = {} + end + if task.status == 'done' then + table.insert(done_by_cat[cat], task) + else + table.insert(by_cat[cat], task) + end + end + + for _, cat in ipairs(cat_order) do + sort_tasks(by_cat[cat]) + sort_tasks(done_by_cat[cat]) + end + + local lines = {} + local meta = {} + + for i, cat in ipairs(cat_order) do + if i > 1 then + table.insert(lines, '') + table.insert(meta, { type = 'blank' }) + end + table.insert(lines, cat) + table.insert(meta, { type = 'header', category = cat }) + + local all = {} + for _, t in ipairs(by_cat[cat]) do + table.insert(all, t) + end + for _, t in ipairs(done_by_cat[cat]) do + table.insert(all, t) + end + + for _, task in ipairs(all) do + local prefix = '/' .. task.id .. '/' + local indent = ' ' + local prio = task.priority == 1 and '! ' or '' + local line = prefix .. indent .. prio .. task.description + table.insert(lines, line) + table.insert(meta, { + type = 'task', + id = task.id, + due = format_due(task.due), + raw_due = task.due, + status = task.status, + category = cat, + }) + end + end + + return lines, meta +end + +---@param tasks todo.Task[] +---@return string[] lines +---@return todo.LineMeta[] meta +function M.priority_view(tasks) + local pending = {} + local done = {} + + for _, task in ipairs(tasks) do + if task.status == 'done' then + table.insert(done, task) + else + table.insert(pending, task) + end + end + + sort_tasks_priority(pending) + sort_tasks_priority(done) + + local lines = {} + local meta = {} + + local all = {} + for _, t in ipairs(pending) do + table.insert(all, t) + end + for _, t in ipairs(done) do + table.insert(all, t) + end + + for _, task in ipairs(all) do + local prefix = '/' .. task.id .. '/' + local indent = ' ' + local prio = task.priority == 1 and '! ' or '' + local line = prefix .. indent .. prio .. task.description + table.insert(lines, line) + table.insert(meta, { + type = 'task', + id = task.id, + due = format_due(task.due), + raw_due = task.due, + status = task.status, + category = task.category, + }) + end + + return lines, meta +end + +return M From 3a5aa5bcc306f1f1bfdf64b49a5100194d6a3bdb Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 15:09:22 -0500 Subject: [PATCH 014/199] feat(views): add category and priority view renderers Problem: need to render task lists grouped by category or sorted by priority with concealed IDs and metadata. Solution: add category_view and priority_view functions that produce buffer lines with hidden /id/ prefixes, priority flags, and line metadata for extmark placement. --- lua/todo/views.lua | 181 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 lua/todo/views.lua diff --git a/lua/todo/views.lua b/lua/todo/views.lua new file mode 100644 index 0000000..5330d86 --- /dev/null +++ b/lua/todo/views.lua @@ -0,0 +1,181 @@ +local config = require('todo.config') + +---@class todo.LineMeta +---@field type 'task'|'header'|'blank' +---@field id? integer +---@field due? string +---@field raw_due? string +---@field status? string +---@field category? string + +---@class todo.views +local M = {} + +---@param due? string +---@return string? +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)$') + if not y then + return due + end + local t = os.time({ year = tonumber(y), month = tonumber(m), day = tonumber(d) }) + return os.date(config.get().date_format, t) +end + +---@param tasks todo.Task[] +local function sort_tasks(tasks) + table.sort(tasks, function(a, b) + if a.priority ~= b.priority then + return a.priority > b.priority + end + if a.order ~= b.order then + return a.order < b.order + end + return a.id < b.id + end) +end + +---@param tasks todo.Task[] +local function sort_tasks_priority(tasks) + table.sort(tasks, function(a, b) + if a.priority ~= b.priority then + return a.priority > b.priority + end + local a_due = a.due or '' + local b_due = b.due or '' + if a_due ~= b_due then + if a_due == '' then + return false + end + if b_due == '' then + return true + end + return a_due < b_due + end + if a.order ~= b.order then + return a.order < b.order + end + return a.id < b.id + end) +end + +---@param tasks todo.Task[] +---@return string[] lines +---@return todo.LineMeta[] meta +function M.category_view(tasks) + local by_cat = {} + local cat_order = {} + local cat_seen = {} + local done_by_cat = {} + + for _, task in ipairs(tasks) do + local cat = task.category or config.get().default_category + if not cat_seen[cat] then + cat_seen[cat] = true + table.insert(cat_order, cat) + by_cat[cat] = {} + done_by_cat[cat] = {} + end + if task.status == 'done' then + table.insert(done_by_cat[cat], task) + else + table.insert(by_cat[cat], task) + end + end + + for _, cat in ipairs(cat_order) do + sort_tasks(by_cat[cat]) + sort_tasks(done_by_cat[cat]) + end + + local lines = {} + local meta = {} + + for i, cat in ipairs(cat_order) do + if i > 1 then + table.insert(lines, '') + table.insert(meta, { type = 'blank' }) + end + table.insert(lines, cat) + table.insert(meta, { type = 'header', category = cat }) + + local all = {} + for _, t in ipairs(by_cat[cat]) do + table.insert(all, t) + end + for _, t in ipairs(done_by_cat[cat]) do + table.insert(all, t) + end + + for _, task in ipairs(all) do + local prefix = '/' .. task.id .. '/' + local indent = ' ' + local prio = task.priority == 1 and '! ' or '' + local line = prefix .. indent .. prio .. task.description + table.insert(lines, line) + table.insert(meta, { + type = 'task', + id = task.id, + due = format_due(task.due), + raw_due = task.due, + status = task.status, + category = cat, + }) + end + end + + return lines, meta +end + +---@param tasks todo.Task[] +---@return string[] lines +---@return todo.LineMeta[] meta +function M.priority_view(tasks) + local pending = {} + local done = {} + + for _, task in ipairs(tasks) do + if task.status == 'done' then + table.insert(done, task) + else + table.insert(pending, task) + end + end + + sort_tasks_priority(pending) + sort_tasks_priority(done) + + local lines = {} + local meta = {} + + local all = {} + for _, t in ipairs(pending) do + table.insert(all, t) + end + for _, t in ipairs(done) do + table.insert(all, t) + end + + for _, task in ipairs(all) do + local prefix = '/' .. task.id .. '/' + local indent = ' ' + local prio = task.priority == 1 and '! ' or '' + local line = prefix .. indent .. prio .. task.description + table.insert(lines, line) + table.insert(meta, { + type = 'task', + id = task.id, + due = format_due(task.due), + raw_due = task.due, + status = task.status, + category = task.category, + }) + end + + return lines, meta +end + +return M From 049e77a4fb5d2213c2fc3483e3dee9c7f4a45c19 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 15:09:29 -0500 Subject: [PATCH 015/199] feat(buffer): add scratch buffer with concealment and extmarks Problem: need a buffer to display tasks with concealed IDs, virtual text due dates, strikethrough for done items, and auto-indent. Solution: add buffer module with acwrite scratch buffer, syntax conceal for /id/ prefixes, right-aligned due date extmarks, header/done highlighting, indentexpr, and view toggling. --- lua/todo/buffer.lua | 194 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 lua/todo/buffer.lua diff --git a/lua/todo/buffer.lua b/lua/todo/buffer.lua new file mode 100644 index 0000000..b059b6b --- /dev/null +++ b/lua/todo/buffer.lua @@ -0,0 +1,194 @@ +local config = require('todo.config') +local store = require('todo.store') +local views = require('todo.views') + +---@class todo.buffer +local M = {} + +---@type integer? +local task_bufnr = nil +local task_ns = vim.api.nvim_create_namespace('todo') +---@type 'category'|'priority'|nil +local current_view = nil +---@type todo.LineMeta[] +local _meta = {} + +---@return todo.LineMeta[] +function M.meta() + return _meta +end + +---@return integer? +function M.bufnr() + return task_bufnr +end + +---@return string? +function M.current_view_name() + return current_view +end + +---@param bufnr integer +local function set_buf_options(bufnr) + vim.bo[bufnr].buftype = 'acwrite' + vim.bo[bufnr].bufhidden = 'hide' + vim.bo[bufnr].swapfile = false + vim.bo[bufnr].filetype = 'todo' + vim.bo[bufnr].modifiable = true +end + +---@param winid integer +local function set_win_options(winid) + vim.wo[winid].conceallevel = 3 + vim.wo[winid].concealcursor = 'nvic' + vim.wo[winid].wrap = false + vim.wo[winid].number = false + vim.wo[winid].relativenumber = false + vim.wo[winid].signcolumn = 'no' + vim.wo[winid].foldcolumn = '0' + vim.wo[winid].spell = false + vim.wo[winid].cursorline = true +end + +---@param bufnr integer +local function setup_syntax(bufnr) + vim.api.nvim_buf_call(bufnr, function() + vim.cmd([[ + syntax clear + syntax match taskId /^\/\d\+\// conceal + syntax match taskHeader /^\S.*$/ contains=taskId + syntax match taskPriority /! / contained containedin=taskLine + syntax match taskLine /^\/\d\+\/ .*$/ contains=taskId,taskPriority + ]]) + end) +end + +---@param bufnr integer +local function setup_indentexpr(bufnr) + vim.bo[bufnr].indentexpr = 'v:lua.require("todo.buffer").get_indent()' +end + +---@return integer +function M.get_indent() + local lnum = vim.v.lnum + if lnum <= 1 then + return 0 + end + local prev = vim.fn.getline(lnum - 1) + if prev == '' or prev:match('^%S') then + return 0 + end + return 2 +end + +---@param bufnr integer +---@param line_meta todo.LineMeta[] +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.due then + vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, { + virt_text = { { m.due, 'TodoDue' } }, + virt_text_pos = 'right_align', + }) + 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+/')) + 2 or 0 + vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, col_start, { + end_col = #line, + hl_group = 'TodoDone', + }) + end + 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 = 'TodoHeader', + }) + end + end +end + +local function setup_highlights() + local function hl(name, opts) + if vim.fn.hlexists(name) == 0 or vim.tbl_isempty(vim.api.nvim_get_hl(0, { name = name })) then + vim.api.nvim_set_hl(0, name, opts) + end + end + hl('TodoHeader', { bold = true }) + hl('TodoDue', { fg = '#888888', italic = true }) + hl('TodoDone', { strikethrough = true, fg = '#666666' }) + hl('TodoPriority', { fg = '#e06c75', bold = true }) +end + +---@param bufnr? integer +function M.render(bufnr) + bufnr = bufnr or task_bufnr + if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then + return + end + + current_view = current_view or config.get().default_view + local tasks = store.active_tasks() + + local lines, line_meta + if current_view == 'priority' then + lines, line_meta = views.priority_view(tasks) + else + lines, line_meta = views.category_view(tasks) + end + + _meta = line_meta + + vim.bo[bufnr].modifiable = true + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + vim.bo[bufnr].modified = false + + setup_syntax(bufnr) + apply_extmarks(bufnr, line_meta) +end + +function M.toggle_view() + if current_view == 'category' then + current_view = 'priority' + else + current_view = 'category' + end + M.render() +end + +---@return integer bufnr +function M.open() + setup_highlights() + store.load() + + if task_bufnr and vim.api.nvim_buf_is_valid(task_bufnr) then + local wins = vim.fn.win_findbuf(task_bufnr) + if #wins > 0 then + vim.api.nvim_set_current_win(wins[1]) + M.render(task_bufnr) + return task_bufnr + end + vim.api.nvim_set_current_buf(task_bufnr) + set_win_options(vim.api.nvim_get_current_win()) + M.render(task_bufnr) + return task_bufnr + end + + task_bufnr = vim.api.nvim_create_buf(true, false) + vim.api.nvim_buf_set_name(task_bufnr, 'todo://') + + set_buf_options(task_bufnr) + setup_indentexpr(task_bufnr) + vim.api.nvim_set_current_buf(task_bufnr) + set_win_options(vim.api.nvim_get_current_win()) + + M.render(task_bufnr) + + return task_bufnr +end + +return M From a102092cca37c61432e885678293b3bf399d3406 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 15:09:29 -0500 Subject: [PATCH 016/199] feat(buffer): add scratch buffer with concealment and extmarks Problem: need a buffer to display tasks with concealed IDs, virtual text due dates, strikethrough for done items, and auto-indent. Solution: add buffer module with acwrite scratch buffer, syntax conceal for /id/ prefixes, right-aligned due date extmarks, header/done highlighting, indentexpr, and view toggling. --- lua/todo/buffer.lua | 194 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 lua/todo/buffer.lua diff --git a/lua/todo/buffer.lua b/lua/todo/buffer.lua new file mode 100644 index 0000000..b059b6b --- /dev/null +++ b/lua/todo/buffer.lua @@ -0,0 +1,194 @@ +local config = require('todo.config') +local store = require('todo.store') +local views = require('todo.views') + +---@class todo.buffer +local M = {} + +---@type integer? +local task_bufnr = nil +local task_ns = vim.api.nvim_create_namespace('todo') +---@type 'category'|'priority'|nil +local current_view = nil +---@type todo.LineMeta[] +local _meta = {} + +---@return todo.LineMeta[] +function M.meta() + return _meta +end + +---@return integer? +function M.bufnr() + return task_bufnr +end + +---@return string? +function M.current_view_name() + return current_view +end + +---@param bufnr integer +local function set_buf_options(bufnr) + vim.bo[bufnr].buftype = 'acwrite' + vim.bo[bufnr].bufhidden = 'hide' + vim.bo[bufnr].swapfile = false + vim.bo[bufnr].filetype = 'todo' + vim.bo[bufnr].modifiable = true +end + +---@param winid integer +local function set_win_options(winid) + vim.wo[winid].conceallevel = 3 + vim.wo[winid].concealcursor = 'nvic' + vim.wo[winid].wrap = false + vim.wo[winid].number = false + vim.wo[winid].relativenumber = false + vim.wo[winid].signcolumn = 'no' + vim.wo[winid].foldcolumn = '0' + vim.wo[winid].spell = false + vim.wo[winid].cursorline = true +end + +---@param bufnr integer +local function setup_syntax(bufnr) + vim.api.nvim_buf_call(bufnr, function() + vim.cmd([[ + syntax clear + syntax match taskId /^\/\d\+\// conceal + syntax match taskHeader /^\S.*$/ contains=taskId + syntax match taskPriority /! / contained containedin=taskLine + syntax match taskLine /^\/\d\+\/ .*$/ contains=taskId,taskPriority + ]]) + end) +end + +---@param bufnr integer +local function setup_indentexpr(bufnr) + vim.bo[bufnr].indentexpr = 'v:lua.require("todo.buffer").get_indent()' +end + +---@return integer +function M.get_indent() + local lnum = vim.v.lnum + if lnum <= 1 then + return 0 + end + local prev = vim.fn.getline(lnum - 1) + if prev == '' or prev:match('^%S') then + return 0 + end + return 2 +end + +---@param bufnr integer +---@param line_meta todo.LineMeta[] +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.due then + vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, { + virt_text = { { m.due, 'TodoDue' } }, + virt_text_pos = 'right_align', + }) + 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+/')) + 2 or 0 + vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, col_start, { + end_col = #line, + hl_group = 'TodoDone', + }) + end + 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 = 'TodoHeader', + }) + end + end +end + +local function setup_highlights() + local function hl(name, opts) + if vim.fn.hlexists(name) == 0 or vim.tbl_isempty(vim.api.nvim_get_hl(0, { name = name })) then + vim.api.nvim_set_hl(0, name, opts) + end + end + hl('TodoHeader', { bold = true }) + hl('TodoDue', { fg = '#888888', italic = true }) + hl('TodoDone', { strikethrough = true, fg = '#666666' }) + hl('TodoPriority', { fg = '#e06c75', bold = true }) +end + +---@param bufnr? integer +function M.render(bufnr) + bufnr = bufnr or task_bufnr + if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then + return + end + + current_view = current_view or config.get().default_view + local tasks = store.active_tasks() + + local lines, line_meta + if current_view == 'priority' then + lines, line_meta = views.priority_view(tasks) + else + lines, line_meta = views.category_view(tasks) + end + + _meta = line_meta + + vim.bo[bufnr].modifiable = true + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + vim.bo[bufnr].modified = false + + setup_syntax(bufnr) + apply_extmarks(bufnr, line_meta) +end + +function M.toggle_view() + if current_view == 'category' then + current_view = 'priority' + else + current_view = 'category' + end + M.render() +end + +---@return integer bufnr +function M.open() + setup_highlights() + store.load() + + if task_bufnr and vim.api.nvim_buf_is_valid(task_bufnr) then + local wins = vim.fn.win_findbuf(task_bufnr) + if #wins > 0 then + vim.api.nvim_set_current_win(wins[1]) + M.render(task_bufnr) + return task_bufnr + end + vim.api.nvim_set_current_buf(task_bufnr) + set_win_options(vim.api.nvim_get_current_win()) + M.render(task_bufnr) + return task_bufnr + end + + task_bufnr = vim.api.nvim_create_buf(true, false) + vim.api.nvim_buf_set_name(task_bufnr, 'todo://') + + set_buf_options(task_bufnr) + setup_indentexpr(task_bufnr) + vim.api.nvim_set_current_buf(task_bufnr) + set_win_options(vim.api.nvim_get_current_win()) + + M.render(task_bufnr) + + return task_bufnr +end + +return M From c03412837d9ed5e0ae60c50794107ff64343d41c Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 15:09:36 -0500 Subject: [PATCH 017/199] feat(diff): add buffer-to-store diff algorithm Problem: need to reconcile buffer edits against the JSON store on :w, handling creates, deletes, updates, reorders, and duplicate IDs from yank/paste. Solution: add diff module that parses buffer lines, matches against stored tasks by ID, creates new tasks for unknown or duplicate IDs, marks removed tasks as deleted, and updates changed fields. --- lua/todo/diff.lua | 149 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 lua/todo/diff.lua diff --git a/lua/todo/diff.lua b/lua/todo/diff.lua new file mode 100644 index 0000000..902c064 --- /dev/null +++ b/lua/todo/diff.lua @@ -0,0 +1,149 @@ +local config = require('todo.config') +local parse = require('todo.parse') +local store = require('todo.store') + +---@class todo.ParsedEntry +---@field type 'task'|'header'|'blank' +---@field id? integer +---@field description? string +---@field priority? integer +---@field category? string +---@field due? string +---@field lnum integer + +---@class todo.diff +local M = {} + +---@return string +local function timestamp() + return os.date('!%Y-%m-%dT%H:%M:%SZ') +end + +---@param lines string[] +---@return todo.ParsedEntry[] +function M.parse_buffer(lines) + local result = {} + local current_category = nil + + for i, line in ipairs(lines) do + if line == '' then + table.insert(result, { type = 'blank', lnum = i }) + elseif line:match('^%S') then + current_category = line + table.insert(result, { type = 'header', category = line, lnum = i }) + else + local id, body = line:match('^/(%d+)/( .+)$') + if not id then + body = line:match('^( .+)$') + end + if body then + local stripped = body:match('^ (.+)$') or body + local priority = 0 + if stripped:match('^! ') then + priority = 1 + stripped = stripped:sub(3) + end + local description, metadata = parse.body(stripped) + if description and description ~= '' then + table.insert(result, { + type = 'task', + id = id and tonumber(id) or nil, + description = description, + priority = priority, + category = metadata.cat or current_category or config.get().default_category, + due = metadata.due, + lnum = i, + }) + end + end + end + end + + return result +end + +---@param lines string[] +function M.apply(lines) + local parsed = M.parse_buffer(lines) + local now = timestamp() + local data = store.data() + + local old_by_id = {} + for _, task in ipairs(data.tasks) do + if task.status ~= 'deleted' then + old_by_id[task.id] = task + end + end + + local seen_ids = {} + local order_counter = 0 + + for _, entry in ipairs(parsed) do + if entry.type ~= 'task' then + goto continue + end + + order_counter = order_counter + 1 + + if entry.id and old_by_id[entry.id] then + if seen_ids[entry.id] then + store.add({ + description = entry.description, + category = entry.category, + priority = entry.priority, + due = entry.due, + order = order_counter, + }) + else + seen_ids[entry.id] = true + local task = old_by_id[entry.id] + local changed = false + if task.description ~= entry.description then + task.description = entry.description + changed = true + end + if task.category ~= entry.category then + task.category = entry.category + changed = true + end + if task.priority ~= entry.priority then + task.priority = entry.priority + changed = true + end + if task.due ~= entry.due then + task.due = entry.due + changed = true + end + if task.order ~= order_counter then + task.order = order_counter + changed = true + end + if changed then + task.modified = now + end + end + else + store.add({ + description = entry.description, + category = entry.category, + priority = entry.priority, + due = entry.due, + order = order_counter, + }) + end + + ::continue:: + end + + for id, task in pairs(old_by_id) do + if not seen_ids[id] then + task.status = 'deleted' + task['end'] = now + task.modified = now + end + end + + store.save() +end + +return M From 79cef994ce0c8fd94687012a99fb090a7e46b59b Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 15:09:36 -0500 Subject: [PATCH 018/199] feat(diff): add buffer-to-store diff algorithm Problem: need to reconcile buffer edits against the JSON store on :w, handling creates, deletes, updates, reorders, and duplicate IDs from yank/paste. Solution: add diff module that parses buffer lines, matches against stored tasks by ID, creates new tasks for unknown or duplicate IDs, marks removed tasks as deleted, and updates changed fields. --- lua/todo/diff.lua | 149 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 lua/todo/diff.lua diff --git a/lua/todo/diff.lua b/lua/todo/diff.lua new file mode 100644 index 0000000..902c064 --- /dev/null +++ b/lua/todo/diff.lua @@ -0,0 +1,149 @@ +local config = require('todo.config') +local parse = require('todo.parse') +local store = require('todo.store') + +---@class todo.ParsedEntry +---@field type 'task'|'header'|'blank' +---@field id? integer +---@field description? string +---@field priority? integer +---@field category? string +---@field due? string +---@field lnum integer + +---@class todo.diff +local M = {} + +---@return string +local function timestamp() + return os.date('!%Y-%m-%dT%H:%M:%SZ') +end + +---@param lines string[] +---@return todo.ParsedEntry[] +function M.parse_buffer(lines) + local result = {} + local current_category = nil + + for i, line in ipairs(lines) do + if line == '' then + table.insert(result, { type = 'blank', lnum = i }) + elseif line:match('^%S') then + current_category = line + table.insert(result, { type = 'header', category = line, lnum = i }) + else + local id, body = line:match('^/(%d+)/( .+)$') + if not id then + body = line:match('^( .+)$') + end + if body then + local stripped = body:match('^ (.+)$') or body + local priority = 0 + if stripped:match('^! ') then + priority = 1 + stripped = stripped:sub(3) + end + local description, metadata = parse.body(stripped) + if description and description ~= '' then + table.insert(result, { + type = 'task', + id = id and tonumber(id) or nil, + description = description, + priority = priority, + category = metadata.cat or current_category or config.get().default_category, + due = metadata.due, + lnum = i, + }) + end + end + end + end + + return result +end + +---@param lines string[] +function M.apply(lines) + local parsed = M.parse_buffer(lines) + local now = timestamp() + local data = store.data() + + local old_by_id = {} + for _, task in ipairs(data.tasks) do + if task.status ~= 'deleted' then + old_by_id[task.id] = task + end + end + + local seen_ids = {} + local order_counter = 0 + + for _, entry in ipairs(parsed) do + if entry.type ~= 'task' then + goto continue + end + + order_counter = order_counter + 1 + + if entry.id and old_by_id[entry.id] then + if seen_ids[entry.id] then + store.add({ + description = entry.description, + category = entry.category, + priority = entry.priority, + due = entry.due, + order = order_counter, + }) + else + seen_ids[entry.id] = true + local task = old_by_id[entry.id] + local changed = false + if task.description ~= entry.description then + task.description = entry.description + changed = true + end + if task.category ~= entry.category then + task.category = entry.category + changed = true + end + if task.priority ~= entry.priority then + task.priority = entry.priority + changed = true + end + if task.due ~= entry.due then + task.due = entry.due + changed = true + end + if task.order ~= order_counter then + task.order = order_counter + changed = true + end + if changed then + task.modified = now + end + end + else + store.add({ + description = entry.description, + category = entry.category, + priority = entry.priority, + due = entry.due, + order = order_counter, + }) + end + + ::continue:: + end + + for id, task in pairs(old_by_id) do + if not seen_ids[id] then + task.status = 'deleted' + task['end'] = now + task.modified = now + end + end + + store.save() +end + +return M From 5284ef6047fc187c263dca87c87e8e0ebdee78f3 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 15:09:43 -0500 Subject: [PATCH 019/199] feat: add commands, mappings, and plugin entry point Problem: need user-facing :Todo command, buffer-local keymaps, Plug mappings, completion toggle, help float, archive, and syntax highlighting. Solution: add init.lua with command dispatcher, toggle complete/ priority, date prompt, archive purge, and help float. Add plugin/todo.lua entry point with :Todo command and Plug mappings. Add syntax/todo.vim for conceal and priority highlighting. --- lua/todo/init.lua | 265 ++++++++++++++++++++++++++++++++++++++++++++++ plugin/todo.lua | 39 +++++++ syntax/todo.vim | 14 +++ 3 files changed, 318 insertions(+) create mode 100644 lua/todo/init.lua create mode 100644 plugin/todo.lua create mode 100644 syntax/todo.vim diff --git a/lua/todo/init.lua b/lua/todo/init.lua new file mode 100644 index 0000000..f084837 --- /dev/null +++ b/lua/todo/init.lua @@ -0,0 +1,265 @@ +local buffer = require('todo.buffer') +local diff = require('todo.diff') +local parse = require('todo.parse') +local store = require('todo.store') + +---@class task +local M = {} + +---@type todo.Task[]? +local undo_state = nil + +---@return integer bufnr +function M.open() + local bufnr = buffer.open() + M._setup_autocmds(bufnr) + M._setup_buf_mappings(bufnr) + return bufnr +end + +---@param bufnr integer +function M._setup_autocmds(bufnr) + local group = vim.api.nvim_create_augroup('TodoBuffer', { clear = true }) + vim.api.nvim_create_autocmd('BufWriteCmd', { + group = group, + buffer = bufnr, + callback = function() + M._on_write(bufnr) + end, + }) +end + +---@param bufnr integer +function M._setup_buf_mappings(bufnr) + local opts = { buffer = bufnr, silent = true } + 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) +end + +---@param bufnr integer +function M._on_write(bufnr) + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + undo_state = store.active_tasks() + diff.apply(lines) + buffer.render(bufnr) +end + +function M.toggle_complete() + local bufnr = buffer.bufnr() + if not bufnr 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 + local task = store.get(id) + if not task then + return + end + if task.status == 'done' then + store.update(id, { status = 'pending', ['end'] = vim.NIL }) + else + store.update(id, { status = 'done' }) + end + store.save() + buffer.render(bufnr) +end + +function M.toggle_priority() + local bufnr = buffer.bufnr() + if not bufnr 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 + local task = store.get(id) + if not task then + return + end + local new_priority = task.priority == 1 and 0 or 1 + store.update(id, { priority = new_priority }) + store.save() + buffer.render(bufnr) +end + +function M.prompt_date() + local bufnr = buffer.bufnr() + if not bufnr 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 + vim.ui.input({ prompt = 'Due date (YYYY-MM-DD): ' }, function(input) + if not input then + return + end + local due = input ~= '' and input or nil + if due then + local y, m, d = due:match('^(%d%d%d%d)-(%d%d)-(%d%d)$') + if not y then + vim.notify('Invalid date format. Use YYYY-MM-DD.', vim.log.levels.ERROR) + return + end + end + store.update(id, { due = due }) + store.save() + buffer.render(bufnr) + end) +end + +---@param text string +function M.add(text) + if not text or text == '' then + vim.notify('Usage: :Todo add ', vim.log.levels.ERROR) + return + end + store.load() + local description, metadata = parse.command_add(text) + if not description or description == '' then + vim.notify('Todo must have a description.', vim.log.levels.ERROR) + return + end + store.add({ + description = description, + category = metadata.cat, + due = metadata.due, + }) + store.save() + local bufnr = buffer.bufnr() + if bufnr and vim.api.nvim_buf_is_valid(bufnr) then + buffer.render(bufnr) + end + vim.notify('Todo added: ' .. description) +end + +function M.sync() + local ok, gcal = pcall(require, 'todo.sync.gcal') + if not ok then + vim.notify('Google Calendar sync module not available.', vim.log.levels.ERROR) + return + end + gcal.sync() +end + +---@param days? integer +function M.archive(days) + days = days or 30 + local cutoff = os.time() - (days * 86400) + local tasks = store.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$') + if y then + local t = os.time({ + year = tonumber(y), + month = tonumber(mo), + day = tonumber(d), + hour = tonumber(h), + min = tonumber(mi), + sec = tonumber(s), + }) + if t < cutoff then + archived = archived + 1 + goto skip + end + end + end + table.insert(kept, task) + ::skip:: + end + store.replace_tasks(kept) + store.save() + vim.notify('Archived ' .. archived .. ' tasks.') + local bufnr = buffer.bufnr() + if bufnr and vim.api.nvim_buf_is_valid(bufnr) then + buffer.render(bufnr) + end +end + +function M.show_help() + local cfg = require('todo.config').get() + local dk = cfg.date_syntax or 'due' + local lines = { + 'todo.nvim keybindings', + '', + ' Toggle complete/uncomplete', + ' Switch category/priority view', + 'o / O Add new task line', + 'dd Delete task (on :w)', + 'p / P Paste (duplicates get new IDs)', + ':w Save all changes', + '', + ':Todo add Quick-add task', + ':Todo add Cat: Quick-add with category', + ':Todo sync Push to Google Calendar', + ':Todo archive [days] Purge old done tasks', + '', + 'Inline metadata (on new lines before :w):', + ' ' .. dk .. ':YYYY-MM-DD Set due date', + ' cat:Name Set category', + '', + '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 = 50 + 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 + M.open() + return + end + local cmd, rest = args:match('^(%S+)%s*(.*)') + if cmd == 'add' then + M.add(rest) + elseif cmd == 'sync' then + M.sync() + elseif cmd == 'archive' then + local d = rest ~= '' and tonumber(rest) or nil + M.archive(d) + else + vim.notify('Unknown Todo subcommand: ' .. cmd, vim.log.levels.ERROR) + end +end + +return M diff --git a/plugin/todo.lua b/plugin/todo.lua new file mode 100644 index 0000000..d687923 --- /dev/null +++ b/plugin/todo.lua @@ -0,0 +1,39 @@ +if vim.g.loaded_todo then + return +end +vim.g.loaded_todo = true + +vim.api.nvim_create_user_command('Todo', function(opts) + require('todo').command(opts.args) +end, { + nargs = '*', + complete = function(arg_lead, cmd_line) + local subcmds = { 'add', 'sync', 'archive' } + if not cmd_line:match('^Todo%s+%S') then + return vim.tbl_filter(function(s) + return s:find(arg_lead, 1, true) == 1 + end, subcmds) + end + return {} + end, +}) + +vim.keymap.set('n', '(todo-open)', function() + require('todo').open() +end) + +vim.keymap.set('n', '(todo-toggle)', function() + require('todo').toggle_complete() +end) + +vim.keymap.set('n', '(todo-view)', function() + require('todo.buffer').toggle_view() +end) + +vim.keymap.set('n', '(todo-priority)', function() + require('todo').toggle_priority() +end) + +vim.keymap.set('n', '(todo-date)', function() + require('todo').prompt_date() +end) diff --git a/syntax/todo.vim b/syntax/todo.vim new file mode 100644 index 0000000..8f59b67 --- /dev/null +++ b/syntax/todo.vim @@ -0,0 +1,14 @@ +if exists('b:current_syntax') + finish +endif + +syntax match taskId /^\/\d\+\// conceal +syntax match taskHeader /^\S.*$/ contains=taskId +syntax match taskPriority /!\ze / contained +syntax match taskLine /^\/\d\+\/ .*$/ contains=taskId,taskPriority + +highlight default link taskHeader TodoHeader +highlight default link taskPriority TodoPriority +highlight default link taskLine Normal + +let b:current_syntax = 'task' From 9807c7af80b5d162be3b18c9cfdc31c075971f69 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 15:09:43 -0500 Subject: [PATCH 020/199] feat: add commands, mappings, and plugin entry point Problem: need user-facing :Todo command, buffer-local keymaps, Plug mappings, completion toggle, help float, archive, and syntax highlighting. Solution: add init.lua with command dispatcher, toggle complete/ priority, date prompt, archive purge, and help float. Add plugin/todo.lua entry point with :Todo command and Plug mappings. Add syntax/todo.vim for conceal and priority highlighting. --- lua/todo/init.lua | 265 ++++++++++++++++++++++++++++++++++++++++++++++ plugin/todo.lua | 39 +++++++ syntax/todo.vim | 14 +++ 3 files changed, 318 insertions(+) create mode 100644 lua/todo/init.lua create mode 100644 plugin/todo.lua create mode 100644 syntax/todo.vim diff --git a/lua/todo/init.lua b/lua/todo/init.lua new file mode 100644 index 0000000..f084837 --- /dev/null +++ b/lua/todo/init.lua @@ -0,0 +1,265 @@ +local buffer = require('todo.buffer') +local diff = require('todo.diff') +local parse = require('todo.parse') +local store = require('todo.store') + +---@class task +local M = {} + +---@type todo.Task[]? +local undo_state = nil + +---@return integer bufnr +function M.open() + local bufnr = buffer.open() + M._setup_autocmds(bufnr) + M._setup_buf_mappings(bufnr) + return bufnr +end + +---@param bufnr integer +function M._setup_autocmds(bufnr) + local group = vim.api.nvim_create_augroup('TodoBuffer', { clear = true }) + vim.api.nvim_create_autocmd('BufWriteCmd', { + group = group, + buffer = bufnr, + callback = function() + M._on_write(bufnr) + end, + }) +end + +---@param bufnr integer +function M._setup_buf_mappings(bufnr) + local opts = { buffer = bufnr, silent = true } + 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) +end + +---@param bufnr integer +function M._on_write(bufnr) + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + undo_state = store.active_tasks() + diff.apply(lines) + buffer.render(bufnr) +end + +function M.toggle_complete() + local bufnr = buffer.bufnr() + if not bufnr 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 + local task = store.get(id) + if not task then + return + end + if task.status == 'done' then + store.update(id, { status = 'pending', ['end'] = vim.NIL }) + else + store.update(id, { status = 'done' }) + end + store.save() + buffer.render(bufnr) +end + +function M.toggle_priority() + local bufnr = buffer.bufnr() + if not bufnr 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 + local task = store.get(id) + if not task then + return + end + local new_priority = task.priority == 1 and 0 or 1 + store.update(id, { priority = new_priority }) + store.save() + buffer.render(bufnr) +end + +function M.prompt_date() + local bufnr = buffer.bufnr() + if not bufnr 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 + vim.ui.input({ prompt = 'Due date (YYYY-MM-DD): ' }, function(input) + if not input then + return + end + local due = input ~= '' and input or nil + if due then + local y, m, d = due:match('^(%d%d%d%d)-(%d%d)-(%d%d)$') + if not y then + vim.notify('Invalid date format. Use YYYY-MM-DD.', vim.log.levels.ERROR) + return + end + end + store.update(id, { due = due }) + store.save() + buffer.render(bufnr) + end) +end + +---@param text string +function M.add(text) + if not text or text == '' then + vim.notify('Usage: :Todo add ', vim.log.levels.ERROR) + return + end + store.load() + local description, metadata = parse.command_add(text) + if not description or description == '' then + vim.notify('Todo must have a description.', vim.log.levels.ERROR) + return + end + store.add({ + description = description, + category = metadata.cat, + due = metadata.due, + }) + store.save() + local bufnr = buffer.bufnr() + if bufnr and vim.api.nvim_buf_is_valid(bufnr) then + buffer.render(bufnr) + end + vim.notify('Todo added: ' .. description) +end + +function M.sync() + local ok, gcal = pcall(require, 'todo.sync.gcal') + if not ok then + vim.notify('Google Calendar sync module not available.', vim.log.levels.ERROR) + return + end + gcal.sync() +end + +---@param days? integer +function M.archive(days) + days = days or 30 + local cutoff = os.time() - (days * 86400) + local tasks = store.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$') + if y then + local t = os.time({ + year = tonumber(y), + month = tonumber(mo), + day = tonumber(d), + hour = tonumber(h), + min = tonumber(mi), + sec = tonumber(s), + }) + if t < cutoff then + archived = archived + 1 + goto skip + end + end + end + table.insert(kept, task) + ::skip:: + end + store.replace_tasks(kept) + store.save() + vim.notify('Archived ' .. archived .. ' tasks.') + local bufnr = buffer.bufnr() + if bufnr and vim.api.nvim_buf_is_valid(bufnr) then + buffer.render(bufnr) + end +end + +function M.show_help() + local cfg = require('todo.config').get() + local dk = cfg.date_syntax or 'due' + local lines = { + 'todo.nvim keybindings', + '', + ' Toggle complete/uncomplete', + ' Switch category/priority view', + 'o / O Add new task line', + 'dd Delete task (on :w)', + 'p / P Paste (duplicates get new IDs)', + ':w Save all changes', + '', + ':Todo add Quick-add task', + ':Todo add Cat: Quick-add with category', + ':Todo sync Push to Google Calendar', + ':Todo archive [days] Purge old done tasks', + '', + 'Inline metadata (on new lines before :w):', + ' ' .. dk .. ':YYYY-MM-DD Set due date', + ' cat:Name Set category', + '', + '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 = 50 + 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 + M.open() + return + end + local cmd, rest = args:match('^(%S+)%s*(.*)') + if cmd == 'add' then + M.add(rest) + elseif cmd == 'sync' then + M.sync() + elseif cmd == 'archive' then + local d = rest ~= '' and tonumber(rest) or nil + M.archive(d) + else + vim.notify('Unknown Todo subcommand: ' .. cmd, vim.log.levels.ERROR) + end +end + +return M diff --git a/plugin/todo.lua b/plugin/todo.lua new file mode 100644 index 0000000..d687923 --- /dev/null +++ b/plugin/todo.lua @@ -0,0 +1,39 @@ +if vim.g.loaded_todo then + return +end +vim.g.loaded_todo = true + +vim.api.nvim_create_user_command('Todo', function(opts) + require('todo').command(opts.args) +end, { + nargs = '*', + complete = function(arg_lead, cmd_line) + local subcmds = { 'add', 'sync', 'archive' } + if not cmd_line:match('^Todo%s+%S') then + return vim.tbl_filter(function(s) + return s:find(arg_lead, 1, true) == 1 + end, subcmds) + end + return {} + end, +}) + +vim.keymap.set('n', '(todo-open)', function() + require('todo').open() +end) + +vim.keymap.set('n', '(todo-toggle)', function() + require('todo').toggle_complete() +end) + +vim.keymap.set('n', '(todo-view)', function() + require('todo.buffer').toggle_view() +end) + +vim.keymap.set('n', '(todo-priority)', function() + require('todo').toggle_priority() +end) + +vim.keymap.set('n', '(todo-date)', function() + require('todo').prompt_date() +end) diff --git a/syntax/todo.vim b/syntax/todo.vim new file mode 100644 index 0000000..8f59b67 --- /dev/null +++ b/syntax/todo.vim @@ -0,0 +1,14 @@ +if exists('b:current_syntax') + finish +endif + +syntax match taskId /^\/\d\+\// conceal +syntax match taskHeader /^\S.*$/ contains=taskId +syntax match taskPriority /!\ze / contained +syntax match taskLine /^\/\d\+\/ .*$/ contains=taskId,taskPriority + +highlight default link taskHeader TodoHeader +highlight default link taskPriority TodoPriority +highlight default link taskLine Normal + +let b:current_syntax = 'task' From 9eb29f8fe197bda79ef7b61ffd1fd06ec5ef21ce Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 15:09:50 -0500 Subject: [PATCH 021/199] feat(gcal): add one-way Google Calendar sync Problem: need to push tasks with due dates to Google Calendar as all-day events. Solution: add gcal module with OAuth 2.0 loopback flow, PKCE, token refresh, calendar creation, and event CRUD. Maps pending tasks to all-day events, deletes events on completion, and stores event IDs in task _extra for round-trip. --- lua/todo/sync/gcal.lua | 462 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 462 insertions(+) create mode 100644 lua/todo/sync/gcal.lua diff --git a/lua/todo/sync/gcal.lua b/lua/todo/sync/gcal.lua new file mode 100644 index 0000000..215294c --- /dev/null +++ b/lua/todo/sync/gcal.lua @@ -0,0 +1,462 @@ +local config = require('todo.config') +local store = require('todo.store') + +local M = {} + +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' + +local function gcal_config() + local cfg = config.get() + return cfg.gcal or {} +end + +local function token_path() + return vim.fn.stdpath('data') .. '/todo/gcal_tokens.json' +end + +local function credentials_path() + local gc = gcal_config() + return gc.credentials_path or (vim.fn.stdpath('data') .. '/todo/gcal_credentials.json') +end + +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 + +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 + +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 + end + return creds +end + +local function load_tokens() + return load_json_file(token_path()) +end + +local function save_tokens(tokens) + return save_json_file(token_path(), tokens) +end + +local function url_encode(str) + return str:gsub('([^%w%-%.%_%~])', function(c) + return string.format('%%%02X', string.byte(c)) + end) +end + +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 + +local function auth_headers(access_token) + return { + 'Authorization: Bearer ' .. access_token, + 'Content-Type: application/json', + } +end + +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 + tokens.expires_in = decoded.expires_in + tokens.obtained_at = os.time() + save_tokens(tokens) + return tokens +end + +local function get_access_token() + local creds = load_credentials() + if not creds then + vim.notify( + 'todo.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.authorize() + 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('todo.nvim: Failed to refresh access token.', vim.log.levels.ERROR) + return nil + end + end + return tokens.access_token +end + +function M.authorize() + local creds = load_credentials() + if not creds then + vim.notify( + 'todo.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_result = vim.system({ 'printf', '%s', code_verifier }, { text = true }):wait() + 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('todo.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 + +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('todo.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('todo.nvim: Invalid token response.', vim.log.levels.ERROR) + return + end + + decoded.obtained_at = os.time() + save_tokens(decoded) + vim.notify('todo.nvim: Google Calendar authorized successfully.') +end + +local function find_or_create_calendar(access_token) + local gc = gcal_config() + local cal_name = gc.calendar or 'Todos' + + local data, err = + curl_request('GET', BASE_URL .. '/users/me/calendarList', auth_headers(access_token)) + if err then + return nil, err + end + + for _, item in ipairs(data.items or {}) do + if item.summary == cal_name then + return item.id, nil + end + end + + local body = vim.json.encode({ summary = cal_name }) + local created, create_err = + curl_request('POST', BASE_URL .. '/calendars', auth_headers(access_token), body) + if create_err then + return nil, create_err + end + + return created.id, nil +end + +local function next_day(date_str) + local y, m, d = date_str:match('^(%d%d%d%d)-(%d%d)-(%d%d)$') + local t = os.time({ year = tonumber(y), month = tonumber(m), day = tonumber(d) }) + 86400 + return os.date('%Y-%m-%d', t) +end + +local function create_event(access_token, calendar_id, task) + local event = { + summary = task.description, + start = { date = task.due }, + ['end'] = { date = next_day(task.due) }, + transparency = 'transparent', + extendedProperties = { + private = { taskId = tostring(task.id) }, + }, + } + local data, err = curl_request( + 'POST', + BASE_URL .. '/calendars/' .. url_encode(calendar_id) .. '/events', + auth_headers(access_token), + vim.json.encode(event) + ) + if err then + return nil, err + end + return data.id, nil +end + +local function update_event(access_token, calendar_id, event_id, task) + local event = { + summary = task.description, + start = { date = task.due }, + ['end'] = { date = next_day(task.due) }, + } + local _, err = curl_request( + 'PATCH', + BASE_URL .. '/calendars/' .. url_encode(calendar_id) .. '/events/' .. url_encode(event_id), + auth_headers(access_token), + vim.json.encode(event) + ) + return err +end + +local function delete_event(access_token, calendar_id, event_id) + local _, err = curl_request( + 'DELETE', + BASE_URL .. '/calendars/' .. url_encode(calendar_id) .. '/events/' .. url_encode(event_id), + auth_headers(access_token) + ) + return err +end + +function M.sync() + local access_token = get_access_token() + if not access_token then + return + end + + local calendar_id, err = find_or_create_calendar(access_token) + if err then + vim.notify('todo.nvim: ' .. err, vim.log.levels.ERROR) + return + end + + local tasks = store.tasks() + 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 + + if (task.status == 'done' or task.status == 'deleted') and event_id then + local del_err = delete_event(access_token, calendar_id, event_id) + if not del_err then + extra._gcal_event_id = nil + if next(extra) == nil then + task._extra = nil + else + task._extra = extra + end + task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') + 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 = {} + end + task._extra._gcal_event_id = new_id + task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') + created = created + 1 + end + end + elseif task.status == 'pending' and not task.due and event_id then + local del_err = delete_event(access_token, calendar_id, event_id) + if not del_err then + extra._gcal_event_id = nil + if next(extra) == nil then + task._extra = nil + else + task._extra = extra + end + task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') + deleted = deleted + 1 + end + end + end + + store.save() + vim.notify( + string.format( + 'todo.nvim: Synced to Google Calendar (created: %d, updated: %d, deleted: %d)', + created, + updated, + deleted + ) + ) +end + +return M From 65fb1bb2582b8ae3e2cac834f9f00af0b751331c Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 15:09:50 -0500 Subject: [PATCH 022/199] feat(gcal): add one-way Google Calendar sync Problem: need to push tasks with due dates to Google Calendar as all-day events. Solution: add gcal module with OAuth 2.0 loopback flow, PKCE, token refresh, calendar creation, and event CRUD. Maps pending tasks to all-day events, deletes events on completion, and stores event IDs in task _extra for round-trip. --- lua/todo/sync/gcal.lua | 462 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 462 insertions(+) create mode 100644 lua/todo/sync/gcal.lua diff --git a/lua/todo/sync/gcal.lua b/lua/todo/sync/gcal.lua new file mode 100644 index 0000000..215294c --- /dev/null +++ b/lua/todo/sync/gcal.lua @@ -0,0 +1,462 @@ +local config = require('todo.config') +local store = require('todo.store') + +local M = {} + +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' + +local function gcal_config() + local cfg = config.get() + return cfg.gcal or {} +end + +local function token_path() + return vim.fn.stdpath('data') .. '/todo/gcal_tokens.json' +end + +local function credentials_path() + local gc = gcal_config() + return gc.credentials_path or (vim.fn.stdpath('data') .. '/todo/gcal_credentials.json') +end + +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 + +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 + +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 + end + return creds +end + +local function load_tokens() + return load_json_file(token_path()) +end + +local function save_tokens(tokens) + return save_json_file(token_path(), tokens) +end + +local function url_encode(str) + return str:gsub('([^%w%-%.%_%~])', function(c) + return string.format('%%%02X', string.byte(c)) + end) +end + +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 + +local function auth_headers(access_token) + return { + 'Authorization: Bearer ' .. access_token, + 'Content-Type: application/json', + } +end + +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 + tokens.expires_in = decoded.expires_in + tokens.obtained_at = os.time() + save_tokens(tokens) + return tokens +end + +local function get_access_token() + local creds = load_credentials() + if not creds then + vim.notify( + 'todo.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.authorize() + 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('todo.nvim: Failed to refresh access token.', vim.log.levels.ERROR) + return nil + end + end + return tokens.access_token +end + +function M.authorize() + local creds = load_credentials() + if not creds then + vim.notify( + 'todo.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_result = vim.system({ 'printf', '%s', code_verifier }, { text = true }):wait() + 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('todo.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 + +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('todo.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('todo.nvim: Invalid token response.', vim.log.levels.ERROR) + return + end + + decoded.obtained_at = os.time() + save_tokens(decoded) + vim.notify('todo.nvim: Google Calendar authorized successfully.') +end + +local function find_or_create_calendar(access_token) + local gc = gcal_config() + local cal_name = gc.calendar or 'Todos' + + local data, err = + curl_request('GET', BASE_URL .. '/users/me/calendarList', auth_headers(access_token)) + if err then + return nil, err + end + + for _, item in ipairs(data.items or {}) do + if item.summary == cal_name then + return item.id, nil + end + end + + local body = vim.json.encode({ summary = cal_name }) + local created, create_err = + curl_request('POST', BASE_URL .. '/calendars', auth_headers(access_token), body) + if create_err then + return nil, create_err + end + + return created.id, nil +end + +local function next_day(date_str) + local y, m, d = date_str:match('^(%d%d%d%d)-(%d%d)-(%d%d)$') + local t = os.time({ year = tonumber(y), month = tonumber(m), day = tonumber(d) }) + 86400 + return os.date('%Y-%m-%d', t) +end + +local function create_event(access_token, calendar_id, task) + local event = { + summary = task.description, + start = { date = task.due }, + ['end'] = { date = next_day(task.due) }, + transparency = 'transparent', + extendedProperties = { + private = { taskId = tostring(task.id) }, + }, + } + local data, err = curl_request( + 'POST', + BASE_URL .. '/calendars/' .. url_encode(calendar_id) .. '/events', + auth_headers(access_token), + vim.json.encode(event) + ) + if err then + return nil, err + end + return data.id, nil +end + +local function update_event(access_token, calendar_id, event_id, task) + local event = { + summary = task.description, + start = { date = task.due }, + ['end'] = { date = next_day(task.due) }, + } + local _, err = curl_request( + 'PATCH', + BASE_URL .. '/calendars/' .. url_encode(calendar_id) .. '/events/' .. url_encode(event_id), + auth_headers(access_token), + vim.json.encode(event) + ) + return err +end + +local function delete_event(access_token, calendar_id, event_id) + local _, err = curl_request( + 'DELETE', + BASE_URL .. '/calendars/' .. url_encode(calendar_id) .. '/events/' .. url_encode(event_id), + auth_headers(access_token) + ) + return err +end + +function M.sync() + local access_token = get_access_token() + if not access_token then + return + end + + local calendar_id, err = find_or_create_calendar(access_token) + if err then + vim.notify('todo.nvim: ' .. err, vim.log.levels.ERROR) + return + end + + local tasks = store.tasks() + 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 + + if (task.status == 'done' or task.status == 'deleted') and event_id then + local del_err = delete_event(access_token, calendar_id, event_id) + if not del_err then + extra._gcal_event_id = nil + if next(extra) == nil then + task._extra = nil + else + task._extra = extra + end + task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') + 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 = {} + end + task._extra._gcal_event_id = new_id + task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') + created = created + 1 + end + end + elseif task.status == 'pending' and not task.due and event_id then + local del_err = delete_event(access_token, calendar_id, event_id) + if not del_err then + extra._gcal_event_id = nil + if next(extra) == nil then + task._extra = nil + else + task._extra = extra + end + task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') + deleted = deleted + 1 + end + end + end + + store.save() + vim.notify( + string.format( + 'todo.nvim: Synced to Google Calendar (created: %d, updated: %d, deleted: %d)', + created, + updated, + deleted + ) + ) +end + +return M From 126724b93955c048bbeaac02f7d540d935031b90 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 15:09:58 -0500 Subject: [PATCH 023/199] feat(health): add checkhealth integration Problem: need a way to diagnose config, data file, and dependency issues. Solution: add health module reporting config values, data directory status, task count, and curl/openssl availability. --- lua/todo/health.lua | 55 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 lua/todo/health.lua diff --git a/lua/todo/health.lua b/lua/todo/health.lua new file mode 100644 index 0000000..72652f6 --- /dev/null +++ b/lua/todo/health.lua @@ -0,0 +1,55 @@ +local M = {} + +function M.check() + vim.health.start('todo.nvim') + + local ok, config = pcall(require, 'todo.config') + if not ok then + vim.health.error('Failed to load todo.config') + return + end + + local cfg = config.get() + vim.health.ok('Config loaded') + vim.health.info('Data path: ' .. cfg.data_path) + vim.health.info('Default view: ' .. cfg.default_view) + vim.health.info('Default category: ' .. cfg.default_category) + vim.health.info('Date format: ' .. cfg.date_format) + vim.health.info('Date syntax: ' .. cfg.date_syntax) + + local data_dir = vim.fn.fnamemodify(cfg.data_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, 'todo.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') + else + vim.health.error('Failed to load data file: ' .. tostring(err)) + end + end + else + 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)') + 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)') + end +end + +return M From dfe09ef721c1bb78aee58951bf46d96f39ca0332 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 15:09:58 -0500 Subject: [PATCH 024/199] feat(health): add checkhealth integration Problem: need a way to diagnose config, data file, and dependency issues. Solution: add health module reporting config values, data directory status, task count, and curl/openssl availability. --- lua/todo/health.lua | 55 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 lua/todo/health.lua diff --git a/lua/todo/health.lua b/lua/todo/health.lua new file mode 100644 index 0000000..72652f6 --- /dev/null +++ b/lua/todo/health.lua @@ -0,0 +1,55 @@ +local M = {} + +function M.check() + vim.health.start('todo.nvim') + + local ok, config = pcall(require, 'todo.config') + if not ok then + vim.health.error('Failed to load todo.config') + return + end + + local cfg = config.get() + vim.health.ok('Config loaded') + vim.health.info('Data path: ' .. cfg.data_path) + vim.health.info('Default view: ' .. cfg.default_view) + vim.health.info('Default category: ' .. cfg.default_category) + vim.health.info('Date format: ' .. cfg.date_format) + vim.health.info('Date syntax: ' .. cfg.date_syntax) + + local data_dir = vim.fn.fnamemodify(cfg.data_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, 'todo.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') + else + vim.health.error('Failed to load data file: ' .. tostring(err)) + end + end + else + 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)') + 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)') + end +end + +return M From 5055e4a36bcd6485c6f7275a35632168582fd696 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 15:10:09 -0500 Subject: [PATCH 025/199] test: add store, parse, and diff specs Problem: need test coverage for core data operations, inline metadata parsing, and buffer diff algorithm. Solution: add busted specs for store CRUD, round-trip preservation, parse body/command_add with configurable date syntax, and diff create/delete/update/copy/move operations. --- spec/diff_spec.lua | 139 ++++++++++++++++++++++++++++++++ spec/helpers.lua | 38 +++++++++ spec/parse_spec.lua | 117 +++++++++++++++++++++++++++ spec/store_spec.lua | 189 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 483 insertions(+) create mode 100644 spec/diff_spec.lua create mode 100644 spec/helpers.lua create mode 100644 spec/parse_spec.lua create mode 100644 spec/store_spec.lua diff --git a/spec/diff_spec.lua b/spec/diff_spec.lua new file mode 100644 index 0000000..f1b7cf8 --- /dev/null +++ b/spec/diff_spec.lua @@ -0,0 +1,139 @@ +require('spec.helpers') + +local config = require('todo.config') +local store = require('todo.store') + +describe('diff', function() + local tmpdir + local diff = require('todo.diff') + + before_each(function() + tmpdir = vim.fn.tempname() + vim.fn.mkdir(tmpdir, 'p') + vim.g.todo = { data_path = tmpdir .. '/tasks.json' } + config.reset() + store.unload() + store.load() + end) + + after_each(function() + vim.fn.delete(tmpdir, 'rf') + vim.g.todo = nil + config.reset() + end) + + describe('parse_buffer', function() + it('parses headers and tasks', function() + local lines = { + 'School', + '/1/ Do homework', + '/2/ ! Read chapter 5', + '', + 'Errands', + '/3/ Buy groceries', + } + local result = diff.parse_buffer(lines) + assert.are.equal(6, #result) + assert.are.equal('header', result[1].type) + assert.are.equal('School', result[1].category) + assert.are.equal('task', result[2].type) + assert.are.equal(1, result[2].id) + assert.are.equal('Do homework', result[2].description) + assert.are.equal('School', result[2].category) + assert.are.equal('task', result[3].type) + assert.are.equal(1, result[3].priority) + assert.are.equal('blank', result[4].type) + assert.are.equal('Errands', result[6].category) + end) + + it('handles new tasks without ids', function() + local lines = { + 'Inbox', + ' New task here', + } + local result = diff.parse_buffer(lines) + assert.are.equal(2, #result) + assert.are.equal('task', result[2].type) + assert.is_nil(result[2].id) + assert.are.equal('New task here', result[2].description) + end) + end) + + describe('apply', function() + it('creates new tasks from buffer lines', function() + local lines = { + 'Inbox', + ' First task', + ' Second task', + } + diff.apply(lines) + store.unload() + store.load() + local tasks = store.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() + local lines = { + 'Inbox', + '/1/ Keep me', + } + diff.apply(lines) + store.unload() + store.load() + local active = store.active_tasks() + assert.are.equal(1, #active) + assert.are.equal('Keep me', active[1].description) + local deleted = store.get(2) + assert.are.equal('deleted', deleted.status) + end) + + it('updates modified tasks', function() + store.add({ description = 'Original' }) + store.save() + local lines = { + 'Inbox', + '/1/ Renamed', + } + diff.apply(lines) + store.unload() + store.load() + local task = store.get(1) + assert.are.equal('Renamed', task.description) + end) + + it('handles duplicate ids as copies', function() + store.add({ description = 'Original' }) + store.save() + local lines = { + 'Inbox', + '/1/ Original', + '/1/ Copy of original', + } + diff.apply(lines) + store.unload() + store.load() + local tasks = store.active_tasks() + assert.are.equal(2, #tasks) + end) + + it('moves tasks between categories', function() + store.add({ description = 'Moving task', category = 'Inbox' }) + store.save() + local lines = { + 'Work', + '/1/ Moving task', + } + diff.apply(lines) + store.unload() + store.load() + local task = store.get(1) + assert.are.equal('Work', task.category) + end) + end) +end) diff --git a/spec/helpers.lua b/spec/helpers.lua new file mode 100644 index 0000000..2639c89 --- /dev/null +++ b/spec/helpers.lua @@ -0,0 +1,38 @@ +local plugin_dir = vim.fn.getcwd() +vim.opt.runtimepath:prepend(plugin_dir) +vim.opt.packpath = {} + +vim.cmd('filetype on') + +local M = {} + +---@param lines? string[] +---@return integer +function M.create_buffer(lines) + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines or {}) + return bufnr +end + +---@param bufnr integer +function M.delete_buffer(bufnr) + if bufnr and vim.api.nvim_buf_is_valid(bufnr) then + vim.api.nvim_buf_delete(bufnr, { force = true }) + end +end + +---@param bufnr integer +---@param ns integer +---@return table[] +function M.get_extmarks(bufnr, ns) + return vim.api.nvim_buf_get_extmarks(bufnr, ns, 0, -1, { details = true }) +end + +---@return string +function M.tmpdir() + local dir = vim.fn.tempname() + vim.fn.mkdir(dir, 'p') + return dir +end + +return M diff --git a/spec/parse_spec.lua b/spec/parse_spec.lua new file mode 100644 index 0000000..a76cf31 --- /dev/null +++ b/spec/parse_spec.lua @@ -0,0 +1,117 @@ +require('spec.helpers') + +local config = require('todo.config') + +describe('parse', function() + before_each(function() + vim.g.todo = nil + config.reset() + end) + + after_each(function() + vim.g.todo = nil + config.reset() + end) + + local parse = require('todo.parse') + + describe('body', function() + it('returns plain description when no metadata', function() + local desc, meta = parse.body('Buy groceries') + assert.are.equal('Buy groceries', desc) + assert.are.same({}, meta) + end) + + it('extracts due date', function() + local desc, meta = parse.body('Buy groceries due:2026-03-15') + assert.are.equal('Buy groceries', desc) + assert.are.equal('2026-03-15', meta.due) + end) + + it('extracts category', function() + local desc, meta = parse.body('Buy groceries cat:Errands') + assert.are.equal('Buy groceries', desc) + assert.are.equal('Errands', meta.cat) + end) + + it('extracts both due and cat', function() + local desc, meta = parse.body('Buy milk due:2026-03-15 cat:Errands') + assert.are.equal('Buy milk', desc) + assert.are.equal('2026-03-15', meta.due) + assert.are.equal('Errands', meta.cat) + end) + + it('extracts metadata in any order', function() + local desc, meta = parse.body('Buy milk cat:Errands due:2026-03-15') + assert.are.equal('Buy milk', desc) + assert.are.equal('2026-03-15', meta.due) + assert.are.equal('Errands', meta.cat) + end) + + it('stops at duplicate key', function() + local desc, meta = parse.body('Buy milk due:2026-03-15 due:2026-04-01') + assert.are.equal('Buy milk due:2026-03-15', desc) + assert.are.equal('2026-04-01', meta.due) + end) + + it('stops at non-meta token', function() + local desc, meta = parse.body('Buy milk for breakfast due:2026-03-15') + assert.are.equal('Buy milk for breakfast', desc) + assert.are.equal('2026-03-15', meta.due) + end) + + it('rejects invalid dates', function() + local desc, meta = parse.body('Buy milk due:2026-13-15') + assert.are.equal('Buy milk due:2026-13-15', desc) + assert.is_nil(meta.due) + end) + + it('preserves colons in description', function() + local desc, meta = parse.body('Meeting at 3:00pm') + assert.are.equal('Meeting at 3:00pm', desc) + assert.are.same({}, meta) + end) + + it('uses configurable date syntax', function() + vim.g.todo = { date_syntax = 'by' } + config.reset() + local desc, meta = parse.body('Buy milk by:2026-03-15') + assert.are.equal('Buy milk', desc) + assert.are.equal('2026-03-15', meta.due) + end) + + it('ignores old syntax when date_syntax is changed', function() + vim.g.todo = { date_syntax = 'by' } + config.reset() + local desc, meta = parse.body('Buy milk due:2026-03-15') + assert.are.equal('Buy milk due:2026-03-15', desc) + assert.is_nil(meta.due) + end) + end) + + describe('command_add', function() + it('parses simple text', function() + local desc, meta = parse.command_add('Buy milk') + assert.are.equal('Buy milk', desc) + assert.are.same({}, meta) + end) + + it('detects category prefix', function() + local desc, meta = parse.command_add('School: Do homework') + assert.are.equal('Do homework', desc) + assert.are.equal('School', meta.cat) + end) + + it('ignores lowercase prefix', function() + local desc, meta = parse.command_add('hello: world') + assert.are.equal('hello: world', desc) + end) + + it('combines category prefix with inline metadata', function() + local desc, meta = parse.command_add('School: Do homework due:2026-03-15') + assert.are.equal('Do homework', desc) + assert.are.equal('School', meta.cat) + assert.are.equal('2026-03-15', meta.due) + end) + end) +end) diff --git a/spec/store_spec.lua b/spec/store_spec.lua new file mode 100644 index 0000000..588a3ec --- /dev/null +++ b/spec/store_spec.lua @@ -0,0 +1,189 @@ +require('spec.helpers') + +local config = require('todo.config') +local store = require('todo.store') + +describe('store', function() + local tmpdir + + before_each(function() + tmpdir = vim.fn.tempname() + vim.fn.mkdir(tmpdir, 'p') + vim.g.todo = { data_path = tmpdir .. '/tasks.json' } + config.reset() + store.unload() + end) + + after_each(function() + vim.fn.delete(tmpdir, 'rf') + vim.g.todo = nil + config.reset() + end) + + describe('load', function() + it('returns empty data when no file exists', function() + local data = store.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 f = io.open(path, 'w') + f:write(vim.json.encode({ + version = 1, + next_id = 3, + tasks = { + { + id = 1, + description = 'Todo one', + status = 'pending', + entry = '2026-01-01T00:00:00Z', + modified = '2026-01-01T00:00:00Z', + }, + { + id = 2, + description = 'Todo two', + status = 'done', + entry = '2026-01-01T00:00:00Z', + modified = '2026-01-01T00:00:00Z', + }, + }, + })) + f:close() + local data = store.load() + assert.are.equal(3, data.next_id) + assert.are.equal(2, #data.tasks) + assert.are.equal('Todo one', data.tasks[1].description) + assert.are.equal('done', data.tasks[2].status) + end) + + it('preserves unknown fields', 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 = 'Todo', + status = 'pending', + entry = '2026-01-01T00:00:00Z', + modified = '2026-01-01T00:00:00Z', + custom_field = 'hello', + }, + }, + })) + f:close() + store.load() + local task = store.get(1) + assert.is_not_nil(task._extra) + assert.are.equal('hello', task._extra.custom_field) + end) + end) + + 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' }) + assert.are.equal(1, t1.id) + assert.are.equal(2, t2.id) + assert.are.equal('pending', t1.status) + assert.are.equal('Inbox', t1.category) + end) + + it('uses provided category', function() + store.load() + local t = store.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 original_modified = t.modified + store.update(t.id, { description = 'Updated' }) + local updated = store.get(t.id) + assert.are.equal('Updated', updated.description) + assert.is_not.equal(original_modified, updated.modified) + end) + + it('sets end timestamp on completion', function() + store.load() + local t = store.add({ description = 'Test' }) + assert.is_nil(t['end']) + store.update(t.id, { status = 'done' }) + local updated = store.get(t.id) + assert.is_not_nil(updated['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) + assert.are.equal('deleted', deleted.status) + assert.is_not_nil(deleted['end']) + end) + end) + + 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() + assert.are.equal(1, #tasks) + assert.are.equal('Persisted', tasks[1].description) + assert.are.equal('Work', tasks[1].category) + assert.are.equal(1, tasks[1].priority) + end) + + it('round-trips unknown fields', 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 = 'Todo', + status = 'pending', + entry = '2026-01-01T00:00:00Z', + modified = '2026-01-01T00:00:00Z', + _gcal_event_id = 'abc123', + }, + }, + })) + f:close() + store.load() + store.save() + store.unload() + store.load() + local task = store.get(1) + assert.are.equal('abc123', task._extra._gcal_event_id) + end) + end) + + 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() + assert.are.equal(1, #active) + assert.are.equal('Active', active[1].description) + end) + end) +end) From 8bd6bf8a6ab0aa94474e978f379baf37ca29bb70 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 15:10:09 -0500 Subject: [PATCH 026/199] test: add store, parse, and diff specs Problem: need test coverage for core data operations, inline metadata parsing, and buffer diff algorithm. Solution: add busted specs for store CRUD, round-trip preservation, parse body/command_add with configurable date syntax, and diff create/delete/update/copy/move operations. --- spec/diff_spec.lua | 139 ++++++++++++++++++++++++++++++++ spec/helpers.lua | 38 +++++++++ spec/parse_spec.lua | 117 +++++++++++++++++++++++++++ spec/store_spec.lua | 189 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 483 insertions(+) create mode 100644 spec/diff_spec.lua create mode 100644 spec/helpers.lua create mode 100644 spec/parse_spec.lua create mode 100644 spec/store_spec.lua diff --git a/spec/diff_spec.lua b/spec/diff_spec.lua new file mode 100644 index 0000000..f1b7cf8 --- /dev/null +++ b/spec/diff_spec.lua @@ -0,0 +1,139 @@ +require('spec.helpers') + +local config = require('todo.config') +local store = require('todo.store') + +describe('diff', function() + local tmpdir + local diff = require('todo.diff') + + before_each(function() + tmpdir = vim.fn.tempname() + vim.fn.mkdir(tmpdir, 'p') + vim.g.todo = { data_path = tmpdir .. '/tasks.json' } + config.reset() + store.unload() + store.load() + end) + + after_each(function() + vim.fn.delete(tmpdir, 'rf') + vim.g.todo = nil + config.reset() + end) + + describe('parse_buffer', function() + it('parses headers and tasks', function() + local lines = { + 'School', + '/1/ Do homework', + '/2/ ! Read chapter 5', + '', + 'Errands', + '/3/ Buy groceries', + } + local result = diff.parse_buffer(lines) + assert.are.equal(6, #result) + assert.are.equal('header', result[1].type) + assert.are.equal('School', result[1].category) + assert.are.equal('task', result[2].type) + assert.are.equal(1, result[2].id) + assert.are.equal('Do homework', result[2].description) + assert.are.equal('School', result[2].category) + assert.are.equal('task', result[3].type) + assert.are.equal(1, result[3].priority) + assert.are.equal('blank', result[4].type) + assert.are.equal('Errands', result[6].category) + end) + + it('handles new tasks without ids', function() + local lines = { + 'Inbox', + ' New task here', + } + local result = diff.parse_buffer(lines) + assert.are.equal(2, #result) + assert.are.equal('task', result[2].type) + assert.is_nil(result[2].id) + assert.are.equal('New task here', result[2].description) + end) + end) + + describe('apply', function() + it('creates new tasks from buffer lines', function() + local lines = { + 'Inbox', + ' First task', + ' Second task', + } + diff.apply(lines) + store.unload() + store.load() + local tasks = store.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() + local lines = { + 'Inbox', + '/1/ Keep me', + } + diff.apply(lines) + store.unload() + store.load() + local active = store.active_tasks() + assert.are.equal(1, #active) + assert.are.equal('Keep me', active[1].description) + local deleted = store.get(2) + assert.are.equal('deleted', deleted.status) + end) + + it('updates modified tasks', function() + store.add({ description = 'Original' }) + store.save() + local lines = { + 'Inbox', + '/1/ Renamed', + } + diff.apply(lines) + store.unload() + store.load() + local task = store.get(1) + assert.are.equal('Renamed', task.description) + end) + + it('handles duplicate ids as copies', function() + store.add({ description = 'Original' }) + store.save() + local lines = { + 'Inbox', + '/1/ Original', + '/1/ Copy of original', + } + diff.apply(lines) + store.unload() + store.load() + local tasks = store.active_tasks() + assert.are.equal(2, #tasks) + end) + + it('moves tasks between categories', function() + store.add({ description = 'Moving task', category = 'Inbox' }) + store.save() + local lines = { + 'Work', + '/1/ Moving task', + } + diff.apply(lines) + store.unload() + store.load() + local task = store.get(1) + assert.are.equal('Work', task.category) + end) + end) +end) diff --git a/spec/helpers.lua b/spec/helpers.lua new file mode 100644 index 0000000..2639c89 --- /dev/null +++ b/spec/helpers.lua @@ -0,0 +1,38 @@ +local plugin_dir = vim.fn.getcwd() +vim.opt.runtimepath:prepend(plugin_dir) +vim.opt.packpath = {} + +vim.cmd('filetype on') + +local M = {} + +---@param lines? string[] +---@return integer +function M.create_buffer(lines) + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines or {}) + return bufnr +end + +---@param bufnr integer +function M.delete_buffer(bufnr) + if bufnr and vim.api.nvim_buf_is_valid(bufnr) then + vim.api.nvim_buf_delete(bufnr, { force = true }) + end +end + +---@param bufnr integer +---@param ns integer +---@return table[] +function M.get_extmarks(bufnr, ns) + return vim.api.nvim_buf_get_extmarks(bufnr, ns, 0, -1, { details = true }) +end + +---@return string +function M.tmpdir() + local dir = vim.fn.tempname() + vim.fn.mkdir(dir, 'p') + return dir +end + +return M diff --git a/spec/parse_spec.lua b/spec/parse_spec.lua new file mode 100644 index 0000000..a76cf31 --- /dev/null +++ b/spec/parse_spec.lua @@ -0,0 +1,117 @@ +require('spec.helpers') + +local config = require('todo.config') + +describe('parse', function() + before_each(function() + vim.g.todo = nil + config.reset() + end) + + after_each(function() + vim.g.todo = nil + config.reset() + end) + + local parse = require('todo.parse') + + describe('body', function() + it('returns plain description when no metadata', function() + local desc, meta = parse.body('Buy groceries') + assert.are.equal('Buy groceries', desc) + assert.are.same({}, meta) + end) + + it('extracts due date', function() + local desc, meta = parse.body('Buy groceries due:2026-03-15') + assert.are.equal('Buy groceries', desc) + assert.are.equal('2026-03-15', meta.due) + end) + + it('extracts category', function() + local desc, meta = parse.body('Buy groceries cat:Errands') + assert.are.equal('Buy groceries', desc) + assert.are.equal('Errands', meta.cat) + end) + + it('extracts both due and cat', function() + local desc, meta = parse.body('Buy milk due:2026-03-15 cat:Errands') + assert.are.equal('Buy milk', desc) + assert.are.equal('2026-03-15', meta.due) + assert.are.equal('Errands', meta.cat) + end) + + it('extracts metadata in any order', function() + local desc, meta = parse.body('Buy milk cat:Errands due:2026-03-15') + assert.are.equal('Buy milk', desc) + assert.are.equal('2026-03-15', meta.due) + assert.are.equal('Errands', meta.cat) + end) + + it('stops at duplicate key', function() + local desc, meta = parse.body('Buy milk due:2026-03-15 due:2026-04-01') + assert.are.equal('Buy milk due:2026-03-15', desc) + assert.are.equal('2026-04-01', meta.due) + end) + + it('stops at non-meta token', function() + local desc, meta = parse.body('Buy milk for breakfast due:2026-03-15') + assert.are.equal('Buy milk for breakfast', desc) + assert.are.equal('2026-03-15', meta.due) + end) + + it('rejects invalid dates', function() + local desc, meta = parse.body('Buy milk due:2026-13-15') + assert.are.equal('Buy milk due:2026-13-15', desc) + assert.is_nil(meta.due) + end) + + it('preserves colons in description', function() + local desc, meta = parse.body('Meeting at 3:00pm') + assert.are.equal('Meeting at 3:00pm', desc) + assert.are.same({}, meta) + end) + + it('uses configurable date syntax', function() + vim.g.todo = { date_syntax = 'by' } + config.reset() + local desc, meta = parse.body('Buy milk by:2026-03-15') + assert.are.equal('Buy milk', desc) + assert.are.equal('2026-03-15', meta.due) + end) + + it('ignores old syntax when date_syntax is changed', function() + vim.g.todo = { date_syntax = 'by' } + config.reset() + local desc, meta = parse.body('Buy milk due:2026-03-15') + assert.are.equal('Buy milk due:2026-03-15', desc) + assert.is_nil(meta.due) + end) + end) + + describe('command_add', function() + it('parses simple text', function() + local desc, meta = parse.command_add('Buy milk') + assert.are.equal('Buy milk', desc) + assert.are.same({}, meta) + end) + + it('detects category prefix', function() + local desc, meta = parse.command_add('School: Do homework') + assert.are.equal('Do homework', desc) + assert.are.equal('School', meta.cat) + end) + + it('ignores lowercase prefix', function() + local desc, meta = parse.command_add('hello: world') + assert.are.equal('hello: world', desc) + end) + + it('combines category prefix with inline metadata', function() + local desc, meta = parse.command_add('School: Do homework due:2026-03-15') + assert.are.equal('Do homework', desc) + assert.are.equal('School', meta.cat) + assert.are.equal('2026-03-15', meta.due) + end) + end) +end) diff --git a/spec/store_spec.lua b/spec/store_spec.lua new file mode 100644 index 0000000..588a3ec --- /dev/null +++ b/spec/store_spec.lua @@ -0,0 +1,189 @@ +require('spec.helpers') + +local config = require('todo.config') +local store = require('todo.store') + +describe('store', function() + local tmpdir + + before_each(function() + tmpdir = vim.fn.tempname() + vim.fn.mkdir(tmpdir, 'p') + vim.g.todo = { data_path = tmpdir .. '/tasks.json' } + config.reset() + store.unload() + end) + + after_each(function() + vim.fn.delete(tmpdir, 'rf') + vim.g.todo = nil + config.reset() + end) + + describe('load', function() + it('returns empty data when no file exists', function() + local data = store.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 f = io.open(path, 'w') + f:write(vim.json.encode({ + version = 1, + next_id = 3, + tasks = { + { + id = 1, + description = 'Todo one', + status = 'pending', + entry = '2026-01-01T00:00:00Z', + modified = '2026-01-01T00:00:00Z', + }, + { + id = 2, + description = 'Todo two', + status = 'done', + entry = '2026-01-01T00:00:00Z', + modified = '2026-01-01T00:00:00Z', + }, + }, + })) + f:close() + local data = store.load() + assert.are.equal(3, data.next_id) + assert.are.equal(2, #data.tasks) + assert.are.equal('Todo one', data.tasks[1].description) + assert.are.equal('done', data.tasks[2].status) + end) + + it('preserves unknown fields', 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 = 'Todo', + status = 'pending', + entry = '2026-01-01T00:00:00Z', + modified = '2026-01-01T00:00:00Z', + custom_field = 'hello', + }, + }, + })) + f:close() + store.load() + local task = store.get(1) + assert.is_not_nil(task._extra) + assert.are.equal('hello', task._extra.custom_field) + end) + end) + + 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' }) + assert.are.equal(1, t1.id) + assert.are.equal(2, t2.id) + assert.are.equal('pending', t1.status) + assert.are.equal('Inbox', t1.category) + end) + + it('uses provided category', function() + store.load() + local t = store.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 original_modified = t.modified + store.update(t.id, { description = 'Updated' }) + local updated = store.get(t.id) + assert.are.equal('Updated', updated.description) + assert.is_not.equal(original_modified, updated.modified) + end) + + it('sets end timestamp on completion', function() + store.load() + local t = store.add({ description = 'Test' }) + assert.is_nil(t['end']) + store.update(t.id, { status = 'done' }) + local updated = store.get(t.id) + assert.is_not_nil(updated['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) + assert.are.equal('deleted', deleted.status) + assert.is_not_nil(deleted['end']) + end) + end) + + 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() + assert.are.equal(1, #tasks) + assert.are.equal('Persisted', tasks[1].description) + assert.are.equal('Work', tasks[1].category) + assert.are.equal(1, tasks[1].priority) + end) + + it('round-trips unknown fields', 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 = 'Todo', + status = 'pending', + entry = '2026-01-01T00:00:00Z', + modified = '2026-01-01T00:00:00Z', + _gcal_event_id = 'abc123', + }, + }, + })) + f:close() + store.load() + store.save() + store.unload() + store.load() + local task = store.get(1) + assert.are.equal('abc123', task._extra._gcal_event_id) + end) + end) + + 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() + assert.are.equal(1, #active) + assert.are.equal('Active', active[1].description) + end) + end) +end) From 6cb5ae9dda98f838bb3cc241cdf2a402e3666283 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 15:17:24 -0500 Subject: [PATCH 027/199] ci: format --- .pre-commit-config.yaml | 2 +- lua/todo/sync/gcal.lua | 17 +++++++---------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5d1f13f..fdff634 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,4 +1,4 @@ -minimum_pre_commit_version: '3.5.0' +minimum_pre_commit_version: "3.5.0" repos: - repo: https://github.com/JohnnyMorganz/StyLua diff --git a/lua/todo/sync/gcal.lua b/lua/todo/sync/gcal.lua index 215294c..6565bd6 100644 --- a/lua/todo/sync/gcal.lua +++ b/lua/todo/sync/gcal.lua @@ -201,16 +201,13 @@ function M.authorize() local sha_result = vim.system({ 'printf', '%s', code_verifier }, { text = true }):wait() local sha_pipe = vim - .system( - { - 'sh', - '-c', - 'printf "%s" "' - .. code_verifier - .. '" | openssl dgst -sha256 -binary | openssl base64 -A | tr "+/" "-_" | tr -d "="', - }, - { text = true } - ) + .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 '' From 41b06387beb903ed53146f2b1283238449c18b0a Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 15:17:24 -0500 Subject: [PATCH 028/199] ci: format --- .pre-commit-config.yaml | 2 +- lua/todo/sync/gcal.lua | 17 +++++++---------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5d1f13f..fdff634 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,4 +1,4 @@ -minimum_pre_commit_version: '3.5.0' +minimum_pre_commit_version: "3.5.0" repos: - repo: https://github.com/JohnnyMorganz/StyLua diff --git a/lua/todo/sync/gcal.lua b/lua/todo/sync/gcal.lua index 215294c..6565bd6 100644 --- a/lua/todo/sync/gcal.lua +++ b/lua/todo/sync/gcal.lua @@ -201,16 +201,13 @@ function M.authorize() local sha_result = vim.system({ 'printf', '%s', code_verifier }, { text = true }):wait() local sha_pipe = vim - .system( - { - 'sh', - '-c', - 'printf "%s" "' - .. code_verifier - .. '" | openssl dgst -sha256 -binary | openssl base64 -A | tr "+/" "-_" | tr -d "="', - }, - { text = true } - ) + .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 '' From edd16f6ecf9cda6a52c7a06a9ee518ce62a877d8 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 15:19:59 -0500 Subject: [PATCH 029/199] fix: correct buffer parser and timestamp test Problem: parse_buffer classified /id/-prefixed task lines as headers because '/' matches the '^%S' header pattern. Store timestamp test was flaky when add and update ran within the same second. Solution: check for task line patterns (id prefix or 2-space indent) before falling through to the header branch. Backdate the initial modified timestamp in the store update test. --- lua/todo/diff.lua | 48 ++++++++++++++++++++++----------------------- spec/store_spec.lua | 4 ++-- 2 files changed, 25 insertions(+), 27 deletions(-) diff --git a/lua/todo/diff.lua b/lua/todo/diff.lua index 902c064..81ec9b4 100644 --- a/lua/todo/diff.lua +++ b/lua/todo/diff.lua @@ -26,36 +26,34 @@ function M.parse_buffer(lines) local current_category = nil for i, line in ipairs(lines) do + local id, body = line:match('^/(%d+)/( .+)$') + if not id then + 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 priority = 0 + if stripped:match('^! ') then + priority = 1 + stripped = stripped:sub(3) + end + local description, metadata = parse.body(stripped) + if description and description ~= '' then + table.insert(result, { + type = 'task', + id = id and tonumber(id) or nil, + description = description, + priority = priority, + category = metadata.cat or current_category or config.get().default_category, + due = metadata.due, + lnum = i, + }) + end elseif line:match('^%S') then current_category = line table.insert(result, { type = 'header', category = line, lnum = i }) - else - local id, body = line:match('^/(%d+)/( .+)$') - if not id then - body = line:match('^( .+)$') - end - if body then - local stripped = body:match('^ (.+)$') or body - local priority = 0 - if stripped:match('^! ') then - priority = 1 - stripped = stripped:sub(3) - end - local description, metadata = parse.body(stripped) - if description and description ~= '' then - table.insert(result, { - type = 'task', - id = id and tonumber(id) or nil, - description = description, - priority = priority, - category = metadata.cat or current_category or config.get().default_category, - due = metadata.due, - lnum = i, - }) - end - end end end diff --git a/spec/store_spec.lua b/spec/store_spec.lua index 588a3ec..3bd7e40 100644 --- a/spec/store_spec.lua +++ b/spec/store_spec.lua @@ -106,11 +106,11 @@ describe('store', function() it('updates fields and sets modified', function() store.load() local t = store.add({ description = 'Original' }) - local original_modified = t.modified + t.modified = '2025-01-01T00:00:00Z' store.update(t.id, { description = 'Updated' }) local updated = store.get(t.id) assert.are.equal('Updated', updated.description) - assert.is_not.equal(original_modified, updated.modified) + assert.is_not.equal('2025-01-01T00:00:00Z', updated.modified) end) it('sets end timestamp on completion', function() From 6f7959cb1828b851b632126aef31e76c306af6cf Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 15:19:59 -0500 Subject: [PATCH 030/199] fix: correct buffer parser and timestamp test Problem: parse_buffer classified /id/-prefixed task lines as headers because '/' matches the '^%S' header pattern. Store timestamp test was flaky when add and update ran within the same second. Solution: check for task line patterns (id prefix or 2-space indent) before falling through to the header branch. Backdate the initial modified timestamp in the store update test. --- lua/todo/diff.lua | 48 ++++++++++++++++++++++----------------------- spec/store_spec.lua | 4 ++-- 2 files changed, 25 insertions(+), 27 deletions(-) diff --git a/lua/todo/diff.lua b/lua/todo/diff.lua index 902c064..81ec9b4 100644 --- a/lua/todo/diff.lua +++ b/lua/todo/diff.lua @@ -26,36 +26,34 @@ function M.parse_buffer(lines) local current_category = nil for i, line in ipairs(lines) do + local id, body = line:match('^/(%d+)/( .+)$') + if not id then + 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 priority = 0 + if stripped:match('^! ') then + priority = 1 + stripped = stripped:sub(3) + end + local description, metadata = parse.body(stripped) + if description and description ~= '' then + table.insert(result, { + type = 'task', + id = id and tonumber(id) or nil, + description = description, + priority = priority, + category = metadata.cat or current_category or config.get().default_category, + due = metadata.due, + lnum = i, + }) + end elseif line:match('^%S') then current_category = line table.insert(result, { type = 'header', category = line, lnum = i }) - else - local id, body = line:match('^/(%d+)/( .+)$') - if not id then - body = line:match('^( .+)$') - end - if body then - local stripped = body:match('^ (.+)$') or body - local priority = 0 - if stripped:match('^! ') then - priority = 1 - stripped = stripped:sub(3) - end - local description, metadata = parse.body(stripped) - if description and description ~= '' then - table.insert(result, { - type = 'task', - id = id and tonumber(id) or nil, - description = description, - priority = priority, - category = metadata.cat or current_category or config.get().default_category, - due = metadata.due, - lnum = i, - }) - end - end end end diff --git a/spec/store_spec.lua b/spec/store_spec.lua index 588a3ec..3bd7e40 100644 --- a/spec/store_spec.lua +++ b/spec/store_spec.lua @@ -106,11 +106,11 @@ describe('store', function() it('updates fields and sets modified', function() store.load() local t = store.add({ description = 'Original' }) - local original_modified = t.modified + t.modified = '2025-01-01T00:00:00Z' store.update(t.id, { description = 'Updated' }) local updated = store.get(t.id) assert.are.equal('Updated', updated.description) - assert.is_not.equal(original_modified, updated.modified) + assert.is_not.equal('2025-01-01T00:00:00Z', updated.modified) end) it('sets end timestamp on completion', function() From b00a4f01d427cd29591799d07ac32ac1e6c0ac25 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 15:20:06 -0500 Subject: [PATCH 031/199] fix: resolve selene lint warnings and errors Problem: unused variables (undo_state, sha_result, date match captures) and duplicate if/else branches in gcal sync triggered selene warnings and errors. Solution: prefix unused state with underscore, simplify date validation to a single pattern match, remove dead sha_result call, and merge duplicate event deletion branches into a single condition. --- lua/todo/init.lua | 7 +++---- lua/todo/sync/gcal.lua | 22 ++++++++-------------- spec/parse_spec.lua | 2 +- 3 files changed, 12 insertions(+), 19 deletions(-) diff --git a/lua/todo/init.lua b/lua/todo/init.lua index f084837..c126050 100644 --- a/lua/todo/init.lua +++ b/lua/todo/init.lua @@ -7,7 +7,7 @@ local store = require('todo.store') local M = {} ---@type todo.Task[]? -local undo_state = nil +local _undo_state = nil ---@return integer bufnr function M.open() @@ -46,7 +46,7 @@ end ---@param bufnr integer function M._on_write(bufnr) local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - undo_state = store.active_tasks() + _undo_state = store.active_tasks() diff.apply(lines) buffer.render(bufnr) end @@ -113,8 +113,7 @@ function M.prompt_date() end local due = input ~= '' and input or nil if due then - local y, m, d = due:match('^(%d%d%d%d)-(%d%d)-(%d%d)$') - if not y then + if 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) return end diff --git a/lua/todo/sync/gcal.lua b/lua/todo/sync/gcal.lua index 6565bd6..1406216 100644 --- a/lua/todo/sync/gcal.lua +++ b/lua/todo/sync/gcal.lua @@ -199,7 +199,6 @@ function M.authorize() end local code_verifier = table.concat(verifier) - local sha_result = vim.system({ 'printf', '%s', code_verifier }, { text = true }):wait() local sha_pipe = vim .system({ 'sh', @@ -401,7 +400,14 @@ function M.sync() local extra = task._extra or {} local event_id = extra._gcal_event_id - if (task.status == 'done' or task.status == 'deleted') and event_id then + local should_delete = event_id + and ( + task.status == 'done' + or task.status == 'deleted' + or (task.status == 'pending' and not task.due) + ) + + if should_delete then local del_err = delete_event(access_token, calendar_id, event_id) if not del_err then extra._gcal_event_id = nil @@ -430,18 +436,6 @@ function M.sync() created = created + 1 end end - elseif task.status == 'pending' and not task.due and event_id then - local del_err = delete_event(access_token, calendar_id, event_id) - if not del_err then - extra._gcal_event_id = nil - if next(extra) == nil then - task._extra = nil - else - task._extra = extra - end - task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') - deleted = deleted + 1 - end end end diff --git a/spec/parse_spec.lua b/spec/parse_spec.lua index a76cf31..f1b2641 100644 --- a/spec/parse_spec.lua +++ b/spec/parse_spec.lua @@ -103,7 +103,7 @@ describe('parse', function() end) it('ignores lowercase prefix', function() - local desc, meta = parse.command_add('hello: world') + local desc, _ = parse.command_add('hello: world') assert.are.equal('hello: world', desc) end) From 4f60433684e1233c72fd0053afd3ef4fc7de9bc4 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 15:20:06 -0500 Subject: [PATCH 032/199] fix: resolve selene lint warnings and errors Problem: unused variables (undo_state, sha_result, date match captures) and duplicate if/else branches in gcal sync triggered selene warnings and errors. Solution: prefix unused state with underscore, simplify date validation to a single pattern match, remove dead sha_result call, and merge duplicate event deletion branches into a single condition. --- lua/todo/init.lua | 7 +++---- lua/todo/sync/gcal.lua | 22 ++++++++-------------- spec/parse_spec.lua | 2 +- 3 files changed, 12 insertions(+), 19 deletions(-) diff --git a/lua/todo/init.lua b/lua/todo/init.lua index f084837..c126050 100644 --- a/lua/todo/init.lua +++ b/lua/todo/init.lua @@ -7,7 +7,7 @@ local store = require('todo.store') local M = {} ---@type todo.Task[]? -local undo_state = nil +local _undo_state = nil ---@return integer bufnr function M.open() @@ -46,7 +46,7 @@ end ---@param bufnr integer function M._on_write(bufnr) local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - undo_state = store.active_tasks() + _undo_state = store.active_tasks() diff.apply(lines) buffer.render(bufnr) end @@ -113,8 +113,7 @@ function M.prompt_date() end local due = input ~= '' and input or nil if due then - local y, m, d = due:match('^(%d%d%d%d)-(%d%d)-(%d%d)$') - if not y then + if 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) return end diff --git a/lua/todo/sync/gcal.lua b/lua/todo/sync/gcal.lua index 6565bd6..1406216 100644 --- a/lua/todo/sync/gcal.lua +++ b/lua/todo/sync/gcal.lua @@ -199,7 +199,6 @@ function M.authorize() end local code_verifier = table.concat(verifier) - local sha_result = vim.system({ 'printf', '%s', code_verifier }, { text = true }):wait() local sha_pipe = vim .system({ 'sh', @@ -401,7 +400,14 @@ function M.sync() local extra = task._extra or {} local event_id = extra._gcal_event_id - if (task.status == 'done' or task.status == 'deleted') and event_id then + local should_delete = event_id + and ( + task.status == 'done' + or task.status == 'deleted' + or (task.status == 'pending' and not task.due) + ) + + if should_delete then local del_err = delete_event(access_token, calendar_id, event_id) if not del_err then extra._gcal_event_id = nil @@ -430,18 +436,6 @@ function M.sync() created = created + 1 end end - elseif task.status == 'pending' and not task.due and event_id then - local del_err = delete_event(access_token, calendar_id, event_id) - if not del_err then - extra._gcal_event_id = nil - if next(extra) == nil then - task._extra = nil - else - task._extra = extra - end - task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') - deleted = deleted + 1 - end end end diff --git a/spec/parse_spec.lua b/spec/parse_spec.lua index a76cf31..f1b2641 100644 --- a/spec/parse_spec.lua +++ b/spec/parse_spec.lua @@ -103,7 +103,7 @@ describe('parse', function() end) it('ignores lowercase prefix', function() - local desc, meta = parse.command_add('hello: world') + local desc, _ = parse.command_add('hello: world') assert.are.equal('hello: world', desc) end) From e3416fee410751058df1293096b5011a49e18d57 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 15:20:12 -0500 Subject: [PATCH 033/199] build: rename rockspec to standard luarocks format Problem: rockspec file was named 'rockspec' instead of the luarocks-required '-.rockspec' format. Solution: rename to todo.nvim-scm-1.rockspec. --- rockspec => todo.nvim-scm-1.rockspec | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename rockspec => todo.nvim-scm-1.rockspec (100%) diff --git a/rockspec b/todo.nvim-scm-1.rockspec similarity index 100% rename from rockspec rename to todo.nvim-scm-1.rockspec From f45f22e2cf274b0c47e4365ba04352c025660d11 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 15:20:12 -0500 Subject: [PATCH 034/199] build: rename rockspec to standard luarocks format Problem: rockspec file was named 'rockspec' instead of the luarocks-required '-.rockspec' format. Solution: rename to todo.nvim-scm-1.rockspec. --- rockspec => todo.nvim-scm-1.rockspec | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename rockspec => todo.nvim-scm-1.rockspec (100%) diff --git a/rockspec b/todo.nvim-scm-1.rockspec similarity index 100% rename from rockspec rename to todo.nvim-scm-1.rockspec From c69a3957c8ad094247b7f69cabfbd7589522e22d Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 15:20:18 -0500 Subject: [PATCH 035/199] docs: add README Problem: repo had no documentation for users. Solution: add README covering usage, configuration, inline metadata syntax, Google Calendar sync, mappings, and data format. --- README.md | 146 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..b178317 --- /dev/null +++ b/README.md @@ -0,0 +1,146 @@ +# todo.nvim + +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 + +``` +School + ! Read chapter 5 Feb 28 + Submit homework Feb 25 + +Errands + Buy groceries Mar 01 + Clean apartment +``` + +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 + +``` +luarocks install todo.nvim +``` + +**lazy.nvim:** + +```lua +{ 'barrettruth/todo.nvim' } +``` + +Requires Neovim 0.10+. No external dependencies for local use. Google Calendar +sync requires `curl` and `openssl`. + +## Usage + +`:Todo` 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 +:Todo add Buy groceries due:2026-03-15 +:Todo add School: Submit homework +``` + +### Archive + +```vim +:Todo archive " purge done tasks older than 30 days +:Todo archive 7 " purge done tasks older than 7 days +``` + +## Configuration + +No `setup()` call required. Set `vim.g.todo` before the plugin loads: + +```lua +vim.g.todo = { + data_path = vim.fn.stdpath('data') .. '/todo/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.todo = { + gcal = { + calendar = 'Tasks', + credentials_path = '/path/to/client_secret.json', + }, +} +``` + +```vim +:Todo sync +``` + +On first run, a browser window opens for OAuth consent. The refresh token is +stored at `stdpath('data')/todo/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', '(todo-open)') +vim.keymap.set('n', 'T', '(todo-toggle)') +``` + +| Plug mapping | Action | +| ---------------------- | --------------------- | +| `(todo-open)` | Open task buffer | +| `(todo-toggle)` | Toggle complete | +| `(todo-view)` | Switch view | +| `(todo-priority)`| Toggle priority flag | +| `(todo-date)` | Prompt for due date | + +## Data format + +Tasks are stored as JSON at `stdpath('data')/todo/tasks.json`. The schema is +versioned and forward-compatible — unknown fields are preserved on round-trip. + +## Documentation + +```vim +:checkhealth todo +``` From 47a5c7a5e54c238e2441f88d40b04c302f0fb331 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 15:20:18 -0500 Subject: [PATCH 036/199] docs: add README Problem: repo had no documentation for users. Solution: add README covering usage, configuration, inline metadata syntax, Google Calendar sync, mappings, and data format. --- README.md | 146 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..b178317 --- /dev/null +++ b/README.md @@ -0,0 +1,146 @@ +# todo.nvim + +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 + +``` +School + ! Read chapter 5 Feb 28 + Submit homework Feb 25 + +Errands + Buy groceries Mar 01 + Clean apartment +``` + +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 + +``` +luarocks install todo.nvim +``` + +**lazy.nvim:** + +```lua +{ 'barrettruth/todo.nvim' } +``` + +Requires Neovim 0.10+. No external dependencies for local use. Google Calendar +sync requires `curl` and `openssl`. + +## Usage + +`:Todo` 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 +:Todo add Buy groceries due:2026-03-15 +:Todo add School: Submit homework +``` + +### Archive + +```vim +:Todo archive " purge done tasks older than 30 days +:Todo archive 7 " purge done tasks older than 7 days +``` + +## Configuration + +No `setup()` call required. Set `vim.g.todo` before the plugin loads: + +```lua +vim.g.todo = { + data_path = vim.fn.stdpath('data') .. '/todo/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.todo = { + gcal = { + calendar = 'Tasks', + credentials_path = '/path/to/client_secret.json', + }, +} +``` + +```vim +:Todo sync +``` + +On first run, a browser window opens for OAuth consent. The refresh token is +stored at `stdpath('data')/todo/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', '(todo-open)') +vim.keymap.set('n', 'T', '(todo-toggle)') +``` + +| Plug mapping | Action | +| ---------------------- | --------------------- | +| `(todo-open)` | Open task buffer | +| `(todo-toggle)` | Toggle complete | +| `(todo-view)` | Switch view | +| `(todo-priority)`| Toggle priority flag | +| `(todo-date)` | Prompt for due date | + +## Data format + +Tasks are stored as JSON at `stdpath('data')/todo/tasks.json`. The schema is +versioned and forward-compatible — unknown fields are preserved on round-trip. + +## Documentation + +```vim +:checkhealth todo +``` From 78a275d096cb77a297192053a94444554db63e6f Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 15:21:44 -0500 Subject: [PATCH 037/199] feat: rename --- .github/DISCUSSION_TEMPLATE/q-a.yaml | 4 +- .github/ISSUE_TEMPLATE/bug_report.yaml | 4 +- .github/ISSUE_TEMPLATE/config.yaml | 2 +- .github/ISSUE_TEMPLATE/feature_request.yaml | 2 +- README.md | 46 +++++++++---------- lua/{todo => pending}/buffer.lua | 36 +++++++-------- lua/{todo => pending}/config.lua | 16 +++---- lua/{todo => pending}/diff.lua | 12 ++--- lua/{todo => pending}/health.lua | 8 ++-- lua/{todo => pending}/init.lua | 34 +++++++------- lua/{todo => pending}/parse.lua | 4 +- lua/{todo => pending}/store.lua | 40 ++++++++-------- lua/{todo => pending}/sync/gcal.lua | 28 +++++------ lua/{todo => pending}/views.lua | 18 ++++---- ...-1.rockspec => pending.nvim-scm-1.rockspec | 6 +-- plugin/pending.lua | 39 ++++++++++++++++ plugin/todo.lua | 39 ---------------- spec/diff_spec.lua | 10 ++-- spec/parse_spec.lua | 12 ++--- spec/store_spec.lua | 18 ++++---- syntax/{todo.vim => pending.vim} | 4 +- 21 files changed, 191 insertions(+), 191 deletions(-) rename lua/{todo => pending}/buffer.lua (84%) rename lua/{todo => pending}/config.lua (71%) rename lua/{todo => pending}/diff.lua (94%) rename lua/{todo => pending}/health.lua (87%) rename lua/{todo => pending}/init.lua (87%) rename lua/{todo => pending}/parse.lua (97%) rename lua/{todo => pending}/store.lua (89%) rename lua/{todo => pending}/sync/gcal.lua (91%) rename lua/{todo => pending}/views.lua (93%) rename todo.nvim-scm-1.rockspec => pending.nvim-scm-1.rockspec (67%) create mode 100644 plugin/pending.lua delete mode 100644 plugin/todo.lua rename syntax/{todo.vim => pending.vim} (76%) diff --git a/.github/DISCUSSION_TEMPLATE/q-a.yaml b/.github/DISCUSSION_TEMPLATE/q-a.yaml index c2402b5..a65fd46 100644 --- a/.github/DISCUSSION_TEMPLATE/q-a.yaml +++ b/.github/DISCUSSION_TEMPLATE/q-a.yaml @@ -4,8 +4,8 @@ body: - type: markdown attributes: value: | - Use this space for questions, ideas, and general discussion about todo.nvim. - For bug reports, please [open an issue](https://github.com/barrettruth/todo.nvim/issues/new/choose) instead. + Use this space for questions, ideas, and general discussion about pending.nvim. + For bug reports, please [open an issue](https://github.com/barrettruth/pending.nvim/issues/new/choose) instead. - type: textarea attributes: label: Question or topic diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 7e10ffe..baae06b 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -9,7 +9,7 @@ body: options: - label: I have searched [existing - issues](https://github.com/barrettruth/todo.nvim/issues) + issues](https://github.com/barrettruth/pending.nvim/issues) required: true - label: I have updated to the latest version required: true @@ -69,7 +69,7 @@ body: require('lazy.nvim').setup({ spec = { { - 'barrettruth/todo.nvim', + 'barrettruth/pending.nvim', opts = {}, }, }, diff --git a/.github/ISSUE_TEMPLATE/config.yaml b/.github/ISSUE_TEMPLATE/config.yaml index e622f14..e03b8e3 100644 --- a/.github/ISSUE_TEMPLATE/config.yaml +++ b/.github/ISSUE_TEMPLATE/config.yaml @@ -1,5 +1,5 @@ blank_issues_enabled: false contact_links: - name: Questions - url: https://github.com/barrettruth/todo.nvim/discussions + url: https://github.com/barrettruth/pending.nvim/discussions about: Ask questions and discuss ideas diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index e243de1..cabb27c 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -9,7 +9,7 @@ body: options: - label: I have searched [existing - issues](https://github.com/barrettruth/todo.nvim/issues) + issues](https://github.com/barrettruth/pending.nvim/issues) required: true - type: textarea diff --git a/README.md b/README.md index b178317..3bd4a71 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# todo.nvim +# pending.nvim Edit tasks like text. `:w` saves them. @@ -27,13 +27,13 @@ concealed, and metadata is parsed from inline syntax on save. ## Install ``` -luarocks install todo.nvim +luarocks install pending.nvim ``` **lazy.nvim:** ```lua -{ 'barrettruth/todo.nvim' } +{ 'barrettruth/pending.nvim' } ``` Requires Neovim 0.10+. No external dependencies for local use. Google Calendar @@ -41,7 +41,7 @@ sync requires `curl` and `openssl`. ## Usage -`:Todo` opens the task buffer. From there, it's just vim: +`:Pending` opens the task buffer. From there, it's just vim: | Key | Action | | --------- | ------------------------------- | @@ -68,24 +68,24 @@ header. ### Quick add ```vim -:Todo add Buy groceries due:2026-03-15 -:Todo add School: Submit homework +:Pending add Buy groceries due:2026-03-15 +:Pending add School: Submit homework ``` ### Archive ```vim -:Todo archive " purge done tasks older than 30 days -:Todo archive 7 " purge done tasks older than 7 days +: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.todo` before the plugin loads: +No `setup()` call required. Set `vim.g.pending` before the plugin loads: ```lua -vim.g.todo = { - data_path = vim.fn.stdpath('data') .. '/todo/tasks.json', +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 @@ -101,7 +101,7 @@ One-way push of tasks with due dates to a dedicated Google Calendar as all-day events. ```lua -vim.g.todo = { +vim.g.pending = { gcal = { calendar = 'Tasks', credentials_path = '/path/to/client_secret.json', @@ -110,11 +110,11 @@ vim.g.todo = { ``` ```vim -:Todo sync +:Pending sync ``` On first run, a browser window opens for OAuth consent. The refresh token is -stored at `stdpath('data')/todo/gcal_tokens.json`. Completed or deleted tasks +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 @@ -122,25 +122,25 @@ have their calendar events removed. Due date changes update events in place. The plugin defines `` mappings for custom keybinds: ```lua -vim.keymap.set('n', 't', '(todo-open)') -vim.keymap.set('n', 'T', '(todo-toggle)') +vim.keymap.set('n', 't', '(pending-open)') +vim.keymap.set('n', 'T', '(pending-toggle)') ``` | Plug mapping | Action | | ---------------------- | --------------------- | -| `(todo-open)` | Open task buffer | -| `(todo-toggle)` | Toggle complete | -| `(todo-view)` | Switch view | -| `(todo-priority)`| Toggle priority flag | -| `(todo-date)` | Prompt for due date | +| `(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')/todo/tasks.json`. The schema is +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 todo +:checkhealth pending ``` diff --git a/lua/todo/buffer.lua b/lua/pending/buffer.lua similarity index 84% rename from lua/todo/buffer.lua rename to lua/pending/buffer.lua index b059b6b..e67b64b 100644 --- a/lua/todo/buffer.lua +++ b/lua/pending/buffer.lua @@ -1,19 +1,19 @@ -local config = require('todo.config') -local store = require('todo.store') -local views = require('todo.views') +local config = require('pending.config') +local store = require('pending.store') +local views = require('pending.views') ----@class todo.buffer +---@class pending.buffer local M = {} ---@type integer? local task_bufnr = nil -local task_ns = vim.api.nvim_create_namespace('todo') +local task_ns = vim.api.nvim_create_namespace('pending') ---@type 'category'|'priority'|nil local current_view = nil ----@type todo.LineMeta[] +---@type pending.LineMeta[] local _meta = {} ----@return todo.LineMeta[] +---@return pending.LineMeta[] function M.meta() return _meta end @@ -33,7 +33,7 @@ local function set_buf_options(bufnr) vim.bo[bufnr].buftype = 'acwrite' vim.bo[bufnr].bufhidden = 'hide' vim.bo[bufnr].swapfile = false - vim.bo[bufnr].filetype = 'todo' + vim.bo[bufnr].filetype = 'pending' vim.bo[bufnr].modifiable = true end @@ -65,7 +65,7 @@ end ---@param bufnr integer local function setup_indentexpr(bufnr) - vim.bo[bufnr].indentexpr = 'v:lua.require("todo.buffer").get_indent()' + vim.bo[bufnr].indentexpr = 'v:lua.require("pending.buffer").get_indent()' end ---@return integer @@ -82,7 +82,7 @@ function M.get_indent() end ---@param bufnr integer ----@param line_meta todo.LineMeta[] +---@param line_meta pending.LineMeta[] 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 @@ -90,7 +90,7 @@ local function apply_extmarks(bufnr, line_meta) if m.type == 'task' then if m.due then vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, { - virt_text = { { m.due, 'TodoDue' } }, + virt_text = { { m.due, 'PendingDue' } }, virt_text_pos = 'right_align', }) end @@ -99,14 +99,14 @@ local function apply_extmarks(bufnr, line_meta) local col_start = line:find('/%d+/') and select(2, line:find('/%d+/')) + 2 or 0 vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, col_start, { end_col = #line, - hl_group = 'TodoDone', + hl_group = 'PendingDone', }) end 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 = 'TodoHeader', + hl_group = 'PendingHeader', }) end end @@ -118,10 +118,10 @@ local function setup_highlights() vim.api.nvim_set_hl(0, name, opts) end end - hl('TodoHeader', { bold = true }) - hl('TodoDue', { fg = '#888888', italic = true }) - hl('TodoDone', { strikethrough = true, fg = '#666666' }) - hl('TodoPriority', { fg = '#e06c75', bold = true }) + hl('PendingHeader', { bold = true }) + hl('PendingDue', { fg = '#888888', italic = true }) + hl('PendingDone', { strikethrough = true, fg = '#666666' }) + hl('PendingPriority', { fg = '#e06c75', bold = true }) end ---@param bufnr? integer @@ -179,7 +179,7 @@ function M.open() end task_bufnr = vim.api.nvim_create_buf(true, false) - vim.api.nvim_buf_set_name(task_bufnr, 'todo://') + vim.api.nvim_buf_set_name(task_bufnr, 'pending://') set_buf_options(task_bufnr) setup_indentexpr(task_bufnr) diff --git a/lua/todo/config.lua b/lua/pending/config.lua similarity index 71% rename from lua/todo/config.lua rename to lua/pending/config.lua index 622b96c..d486690 100644 --- a/lua/todo/config.lua +++ b/lua/pending/config.lua @@ -1,8 +1,8 @@ ----@class todo.GcalConfig +---@class pending.GcalConfig ---@field calendar? string ---@field credentials_path? string ----@class todo.Config +---@class pending.Config ---@field data_path string ---@field default_view 'category'|'priority' ---@field default_category string @@ -10,27 +10,27 @@ ---@field date_syntax string ---@field gcal? task.GcalConfig ----@class todo.config +---@class pending.config local M = {} ----@type todo.Config +---@type pending.Config local defaults = { - data_path = vim.fn.stdpath('data') .. '/todo/tasks.json', + data_path = vim.fn.stdpath('data') .. '/pending/tasks.json', default_view = 'category', default_category = 'Inbox', date_format = '%b %d', date_syntax = 'due', } ----@type todo.Config? +---@type pending.Config? local _resolved = nil ----@return todo.Config +---@return pending.Config function M.get() if _resolved then return _resolved end - local user = vim.g.todo or {} + local user = vim.g.pending or {} _resolved = vim.tbl_deep_extend('force', defaults, user) return _resolved end diff --git a/lua/todo/diff.lua b/lua/pending/diff.lua similarity index 94% rename from lua/todo/diff.lua rename to lua/pending/diff.lua index 81ec9b4..6d6c648 100644 --- a/lua/todo/diff.lua +++ b/lua/pending/diff.lua @@ -1,8 +1,8 @@ -local config = require('todo.config') -local parse = require('todo.parse') -local store = require('todo.store') +local config = require('pending.config') +local parse = require('pending.parse') +local store = require('pending.store') ----@class todo.ParsedEntry +---@class pending.ParsedEntry ---@field type 'task'|'header'|'blank' ---@field id? integer ---@field description? string @@ -11,7 +11,7 @@ local store = require('todo.store') ---@field due? string ---@field lnum integer ----@class todo.diff +---@class pending.diff local M = {} ---@return string @@ -20,7 +20,7 @@ local function timestamp() end ---@param lines string[] ----@return todo.ParsedEntry[] +---@return pending.ParsedEntry[] function M.parse_buffer(lines) local result = {} local current_category = nil diff --git a/lua/todo/health.lua b/lua/pending/health.lua similarity index 87% rename from lua/todo/health.lua rename to lua/pending/health.lua index 72652f6..67c1c09 100644 --- a/lua/todo/health.lua +++ b/lua/pending/health.lua @@ -1,11 +1,11 @@ local M = {} function M.check() - vim.health.start('todo.nvim') + vim.health.start('pending.nvim') - local ok, config = pcall(require, 'todo.config') + local ok, config = pcall(require, 'pending.config') if not ok then - vim.health.error('Failed to load todo.config') + vim.health.error('Failed to load pending.config') return end @@ -25,7 +25,7 @@ function M.check() end if vim.fn.filereadable(cfg.data_path) == 1 then - local store_ok, store = pcall(require, 'todo.store') + local store_ok, store = pcall(require, 'pending.store') if store_ok then local load_ok, err = pcall(store.load) if load_ok then diff --git a/lua/todo/init.lua b/lua/pending/init.lua similarity index 87% rename from lua/todo/init.lua rename to lua/pending/init.lua index c126050..3c2d59b 100644 --- a/lua/todo/init.lua +++ b/lua/pending/init.lua @@ -1,12 +1,12 @@ -local buffer = require('todo.buffer') -local diff = require('todo.diff') -local parse = require('todo.parse') -local store = require('todo.store') +local buffer = require('pending.buffer') +local diff = require('pending.diff') +local parse = require('pending.parse') +local store = require('pending.store') ---@class task local M = {} ----@type todo.Task[]? +---@type pending.Task[]? local _undo_state = nil ---@return integer bufnr @@ -19,7 +19,7 @@ end ---@param bufnr integer function M._setup_autocmds(bufnr) - local group = vim.api.nvim_create_augroup('TodoBuffer', { clear = true }) + local group = vim.api.nvim_create_augroup('PendingBuffer', { clear = true }) vim.api.nvim_create_autocmd('BufWriteCmd', { group = group, buffer = bufnr, @@ -127,13 +127,13 @@ end ---@param text string function M.add(text) if not text or text == '' then - vim.notify('Usage: :Todo add ', vim.log.levels.ERROR) + vim.notify('Usage: :Pending add ', vim.log.levels.ERROR) return end store.load() local description, metadata = parse.command_add(text) if not description or description == '' then - vim.notify('Todo must have a description.', vim.log.levels.ERROR) + vim.notify('Pending must have a description.', vim.log.levels.ERROR) return end store.add({ @@ -146,11 +146,11 @@ function M.add(text) if bufnr and vim.api.nvim_buf_is_valid(bufnr) then buffer.render(bufnr) end - vim.notify('Todo added: ' .. description) + vim.notify('Pending added: ' .. description) end function M.sync() - local ok, gcal = pcall(require, 'todo.sync.gcal') + local ok, gcal = pcall(require, 'pending.sync.gcal') if not ok then vim.notify('Google Calendar sync module not available.', vim.log.levels.ERROR) return @@ -196,10 +196,10 @@ function M.archive(days) end function M.show_help() - local cfg = require('todo.config').get() + local cfg = require('pending.config').get() local dk = cfg.date_syntax or 'due' local lines = { - 'todo.nvim keybindings', + 'pending.nvim keybindings', '', ' Toggle complete/uncomplete', ' Switch category/priority view', @@ -208,10 +208,10 @@ function M.show_help() 'p / P Paste (duplicates get new IDs)', ':w Save all changes', '', - ':Todo add Quick-add task', - ':Todo add Cat: Quick-add with category', - ':Todo sync Push to Google Calendar', - ':Todo archive [days] Purge old done tasks', + ':Pending add Quick-add task', + ':Pending add Cat: Quick-add with category', + ':Pending sync Push to Google Calendar', + ':Pending archive [days] Purge old done tasks', '', 'Inline metadata (on new lines before :w):', ' ' .. dk .. ':YYYY-MM-DD Set due date', @@ -257,7 +257,7 @@ function M.command(args) local d = rest ~= '' and tonumber(rest) or nil M.archive(d) else - vim.notify('Unknown Todo subcommand: ' .. cmd, vim.log.levels.ERROR) + vim.notify('Unknown Pending subcommand: ' .. cmd, vim.log.levels.ERROR) end end diff --git a/lua/todo/parse.lua b/lua/pending/parse.lua similarity index 97% rename from lua/todo/parse.lua rename to lua/pending/parse.lua index 0e8660c..722ba50 100644 --- a/lua/todo/parse.lua +++ b/lua/pending/parse.lua @@ -1,6 +1,6 @@ -local config = require('todo.config') +local config = require('pending.config') ----@class todo.parse +---@class pending.parse local M = {} ---@param s string diff --git a/lua/todo/store.lua b/lua/pending/store.lua similarity index 89% rename from lua/todo/store.lua rename to lua/pending/store.lua index b352896..1ad3ad3 100644 --- a/lua/todo/store.lua +++ b/lua/pending/store.lua @@ -1,6 +1,6 @@ -local config = require('todo.config') +local config = require('pending.config') ----@class todo.Task +---@class pending.Task ---@field id integer ---@field description string ---@field status 'pending'|'done'|'deleted' @@ -13,20 +13,20 @@ local config = require('todo.config') ---@field order integer ---@field _extra? table ----@class todo.Data +---@class pending.Data ---@field version integer ---@field next_id integer ---@field tasks task.Task[] ----@class todo.store +---@class pending.store local M = {} local SUPPORTED_VERSION = 1 ----@type todo.Data? +---@type pending.Data? local _data = nil ----@return todo.Data +---@return pending.Data local function empty_data() return { version = SUPPORTED_VERSION, @@ -62,7 +62,7 @@ local known_fields = { order = true, } ----@param task todo.Task +---@param task pending.Task ---@return table local function task_to_table(task) local t = { @@ -96,7 +96,7 @@ local function task_to_table(task) end ---@param t table ----@return todo.Task +---@return pending.Task local function table_to_task(t) local task = { id = t.id, @@ -122,7 +122,7 @@ local function table_to_task(t) return task end ----@return todo.Data +---@return pending.Data function M.load() local path = config.get().data_path local f = io.open(path, 'r') @@ -138,11 +138,11 @@ function M.load() end local ok, decoded = pcall(vim.json.decode, content) if not ok then - error('todo.nvim: failed to parse ' .. path .. ': ' .. tostring(decoded)) + error('pending.nvim: failed to parse ' .. path .. ': ' .. tostring(decoded)) end if decoded.version and decoded.version > SUPPORTED_VERSION then error( - 'todo.nvim: data file version ' + 'pending.nvim: data file version ' .. decoded.version .. ' is newer than supported version ' .. SUPPORTED_VERSION @@ -177,13 +177,13 @@ function M.save() local encoded = vim.json.encode(out) local f = io.open(path, 'w') if not f then - error('todo.nvim: cannot write to ' .. path) + error('pending.nvim: cannot write to ' .. path) end f:write(encoded) f:close() end ----@return todo.Data +---@return pending.Data function M.data() if not _data then M.load() @@ -191,12 +191,12 @@ function M.data() return _data end ----@return todo.Task[] +---@return pending.Task[] function M.tasks() return M.data().tasks end ----@return todo.Task[] +---@return pending.Task[] function M.active_tasks() local result = {} for _, task in ipairs(M.tasks()) do @@ -208,7 +208,7 @@ function M.active_tasks() end ---@param id integer ----@return todo.Task? +---@return pending.Task? function M.get(id) for _, task in ipairs(M.tasks()) do if task.id == id then @@ -219,7 +219,7 @@ function M.get(id) end ---@param fields { description: string, status?: string, category?: string, priority?: integer, due?: string, order?: integer, _extra?: table } ----@return todo.Task +---@return pending.Task function M.add(fields) local data = M.data() local now = timestamp() @@ -243,7 +243,7 @@ end ---@param id integer ---@param fields table ----@return todo.Task? +---@return pending.Task? function M.update(id, fields) local task = M.get(id) if not task then @@ -263,7 +263,7 @@ function M.update(id, fields) end ---@param id integer ----@return todo.Task? +---@return pending.Task? function M.delete(id) return M.update(id, { status = 'deleted', ['end'] = timestamp() }) end @@ -279,7 +279,7 @@ function M.find_index(id) return nil end ----@param tasks todo.Task[] +---@param tasks pending.Task[] function M.replace_tasks(tasks) M.data().tasks = tasks end diff --git a/lua/todo/sync/gcal.lua b/lua/pending/sync/gcal.lua similarity index 91% rename from lua/todo/sync/gcal.lua rename to lua/pending/sync/gcal.lua index 1406216..30571cd 100644 --- a/lua/todo/sync/gcal.lua +++ b/lua/pending/sync/gcal.lua @@ -1,5 +1,5 @@ -local config = require('todo.config') -local store = require('todo.store') +local config = require('pending.config') +local store = require('pending.store') local M = {} @@ -14,12 +14,12 @@ local function gcal_config() end local function token_path() - return vim.fn.stdpath('data') .. '/todo/gcal_tokens.json' + return vim.fn.stdpath('data') .. '/pending/gcal_tokens.json' end local function credentials_path() local gc = gcal_config() - return gc.credentials_path or (vim.fn.stdpath('data') .. '/todo/gcal_credentials.json') + return gc.credentials_path or (vim.fn.stdpath('data') .. '/pending/gcal_credentials.json') end local function load_json_file(path) @@ -153,7 +153,7 @@ local function get_access_token() local creds = load_credentials() if not creds then vim.notify( - 'todo.nvim: No Google Calendar credentials found at ' .. credentials_path(), + 'pending.nvim: No Google Calendar credentials found at ' .. credentials_path(), vim.log.levels.ERROR ) return nil @@ -172,7 +172,7 @@ local function get_access_token() if now - obtained > expires - 60 then tokens = refresh_access_token(creds, tokens) if not tokens then - vim.notify('todo.nvim: Failed to refresh access token.', vim.log.levels.ERROR) + vim.notify('pending.nvim: Failed to refresh access token.', vim.log.levels.ERROR) return nil end end @@ -183,7 +183,7 @@ function M.authorize() local creds = load_credentials() if not creds then vim.notify( - 'todo.nvim: No Google Calendar credentials found at ' .. credentials_path(), + 'pending.nvim: No Google Calendar credentials found at ' .. credentials_path(), vim.log.levels.ERROR ) return @@ -225,7 +225,7 @@ function M.authorize() .. '&code_challenge_method=S256' vim.ui.open(auth_url) - vim.notify('todo.nvim: Opening browser for Google authorization...') + vim.notify('pending.nvim: Opening browser for Google authorization...') local server = vim.uv.new_tcp() server:bind('127.0.0.1', port) @@ -288,24 +288,24 @@ function M._exchange_code(creds, code, code_verifier, port) :wait() if result.code ~= 0 then - vim.notify('todo.nvim: Token exchange failed.', vim.log.levels.ERROR) + 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('todo.nvim: Invalid token response.', vim.log.levels.ERROR) + vim.notify('pending.nvim: Invalid token response.', vim.log.levels.ERROR) return end decoded.obtained_at = os.time() save_tokens(decoded) - vim.notify('todo.nvim: Google Calendar authorized successfully.') + vim.notify('pending.nvim: Google Calendar authorized successfully.') end local function find_or_create_calendar(access_token) local gc = gcal_config() - local cal_name = gc.calendar or 'Todos' + local cal_name = gc.calendar or 'Pendings' local data, err = curl_request('GET', BASE_URL .. '/users/me/calendarList', auth_headers(access_token)) @@ -389,7 +389,7 @@ function M.sync() local calendar_id, err = find_or_create_calendar(access_token) if err then - vim.notify('todo.nvim: ' .. err, vim.log.levels.ERROR) + vim.notify('pending.nvim: ' .. err, vim.log.levels.ERROR) return end @@ -442,7 +442,7 @@ function M.sync() store.save() vim.notify( string.format( - 'todo.nvim: Synced to Google Calendar (created: %d, updated: %d, deleted: %d)', + 'pending.nvim: Synced to Google Calendar (created: %d, updated: %d, deleted: %d)', created, updated, deleted diff --git a/lua/todo/views.lua b/lua/pending/views.lua similarity index 93% rename from lua/todo/views.lua rename to lua/pending/views.lua index 5330d86..6e6fc4f 100644 --- a/lua/todo/views.lua +++ b/lua/pending/views.lua @@ -1,6 +1,6 @@ -local config = require('todo.config') +local config = require('pending.config') ----@class todo.LineMeta +---@class pending.LineMeta ---@field type 'task'|'header'|'blank' ---@field id? integer ---@field due? string @@ -8,7 +8,7 @@ local config = require('todo.config') ---@field status? string ---@field category? string ----@class todo.views +---@class pending.views local M = {} ---@param due? string @@ -25,7 +25,7 @@ local function format_due(due) return os.date(config.get().date_format, t) end ----@param tasks todo.Task[] +---@param tasks pending.Task[] local function sort_tasks(tasks) table.sort(tasks, function(a, b) if a.priority ~= b.priority then @@ -38,7 +38,7 @@ local function sort_tasks(tasks) end) end ----@param tasks todo.Task[] +---@param tasks pending.Task[] local function sort_tasks_priority(tasks) table.sort(tasks, function(a, b) if a.priority ~= b.priority then @@ -62,9 +62,9 @@ local function sort_tasks_priority(tasks) end) end ----@param tasks todo.Task[] +---@param tasks pending.Task[] ---@return string[] lines ----@return todo.LineMeta[] meta +---@return pending.LineMeta[] meta function M.category_view(tasks) local by_cat = {} local cat_order = {} @@ -130,9 +130,9 @@ function M.category_view(tasks) return lines, meta end ----@param tasks todo.Task[] +---@param tasks pending.Task[] ---@return string[] lines ----@return todo.LineMeta[] meta +---@return pending.LineMeta[] meta function M.priority_view(tasks) local pending = {} local done = {} diff --git a/todo.nvim-scm-1.rockspec b/pending.nvim-scm-1.rockspec similarity index 67% rename from todo.nvim-scm-1.rockspec rename to pending.nvim-scm-1.rockspec index a1d17bf..22759fa 100644 --- a/todo.nvim-scm-1.rockspec +++ b/pending.nvim-scm-1.rockspec @@ -1,14 +1,14 @@ rockspec_format = '3.0' -package = 'todo.nvim' +package = 'pending.nvim' version = 'scm-1' source = { - url = 'git+https://github.com/barrettruth/todo.nvim.git', + url = 'git+https://github.com/barrettruth/pending.nvim.git', } description = { summary = 'Oil-like task management for Neovim', - homepage = 'https://github.com/barrettruth/todo.nvim', + homepage = 'https://github.com/barrettruth/pending.nvim', license = 'MIT', } diff --git a/plugin/pending.lua b/plugin/pending.lua new file mode 100644 index 0000000..7270825 --- /dev/null +++ b/plugin/pending.lua @@ -0,0 +1,39 @@ +if vim.g.loaded_pending then + return +end +vim.g.loaded_pending = true + +vim.api.nvim_create_user_command('Pending', function(opts) + require('pending').command(opts.args) +end, { + nargs = '*', + complete = function(arg_lead, cmd_line) + local subcmds = { 'add', 'sync', 'archive' } + 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) + end + return {} + end, +}) + +vim.keymap.set('n', '(pending-open)', function() + require('pending').open() +end) + +vim.keymap.set('n', '(pending-toggle)', function() + require('pending').toggle_complete() +end) + +vim.keymap.set('n', '(pending-view)', function() + require('pending.buffer').toggle_view() +end) + +vim.keymap.set('n', '(pending-priority)', function() + require('pending').toggle_priority() +end) + +vim.keymap.set('n', '(pending-date)', function() + require('pending').prompt_date() +end) diff --git a/plugin/todo.lua b/plugin/todo.lua deleted file mode 100644 index d687923..0000000 --- a/plugin/todo.lua +++ /dev/null @@ -1,39 +0,0 @@ -if vim.g.loaded_todo then - return -end -vim.g.loaded_todo = true - -vim.api.nvim_create_user_command('Todo', function(opts) - require('todo').command(opts.args) -end, { - nargs = '*', - complete = function(arg_lead, cmd_line) - local subcmds = { 'add', 'sync', 'archive' } - if not cmd_line:match('^Todo%s+%S') then - return vim.tbl_filter(function(s) - return s:find(arg_lead, 1, true) == 1 - end, subcmds) - end - return {} - end, -}) - -vim.keymap.set('n', '(todo-open)', function() - require('todo').open() -end) - -vim.keymap.set('n', '(todo-toggle)', function() - require('todo').toggle_complete() -end) - -vim.keymap.set('n', '(todo-view)', function() - require('todo.buffer').toggle_view() -end) - -vim.keymap.set('n', '(todo-priority)', function() - require('todo').toggle_priority() -end) - -vim.keymap.set('n', '(todo-date)', function() - require('todo').prompt_date() -end) diff --git a/spec/diff_spec.lua b/spec/diff_spec.lua index f1b7cf8..3d3c935 100644 --- a/spec/diff_spec.lua +++ b/spec/diff_spec.lua @@ -1,16 +1,16 @@ require('spec.helpers') -local config = require('todo.config') -local store = require('todo.store') +local config = require('pending.config') +local store = require('pending.store') describe('diff', function() local tmpdir - local diff = require('todo.diff') + local diff = require('pending.diff') before_each(function() tmpdir = vim.fn.tempname() vim.fn.mkdir(tmpdir, 'p') - vim.g.todo = { data_path = tmpdir .. '/tasks.json' } + vim.g.pending = { data_path = tmpdir .. '/tasks.json' } config.reset() store.unload() store.load() @@ -18,7 +18,7 @@ describe('diff', function() after_each(function() vim.fn.delete(tmpdir, 'rf') - vim.g.todo = nil + vim.g.pending = nil config.reset() end) diff --git a/spec/parse_spec.lua b/spec/parse_spec.lua index f1b2641..92b2239 100644 --- a/spec/parse_spec.lua +++ b/spec/parse_spec.lua @@ -1,19 +1,19 @@ require('spec.helpers') -local config = require('todo.config') +local config = require('pending.config') describe('parse', function() before_each(function() - vim.g.todo = nil + vim.g.pending = nil config.reset() end) after_each(function() - vim.g.todo = nil + vim.g.pending = nil config.reset() end) - local parse = require('todo.parse') + local parse = require('pending.parse') describe('body', function() it('returns plain description when no metadata', function() @@ -73,7 +73,7 @@ describe('parse', function() end) it('uses configurable date syntax', function() - vim.g.todo = { date_syntax = 'by' } + vim.g.pending = { date_syntax = 'by' } config.reset() local desc, meta = parse.body('Buy milk by:2026-03-15') assert.are.equal('Buy milk', desc) @@ -81,7 +81,7 @@ describe('parse', function() end) it('ignores old syntax when date_syntax is changed', function() - vim.g.todo = { date_syntax = 'by' } + vim.g.pending = { date_syntax = 'by' } config.reset() local desc, meta = parse.body('Buy milk due:2026-03-15') assert.are.equal('Buy milk due:2026-03-15', desc) diff --git a/spec/store_spec.lua b/spec/store_spec.lua index 3bd7e40..8fdf5f4 100644 --- a/spec/store_spec.lua +++ b/spec/store_spec.lua @@ -1,7 +1,7 @@ require('spec.helpers') -local config = require('todo.config') -local store = require('todo.store') +local config = require('pending.config') +local store = require('pending.store') describe('store', function() local tmpdir @@ -9,14 +9,14 @@ describe('store', function() before_each(function() tmpdir = vim.fn.tempname() vim.fn.mkdir(tmpdir, 'p') - vim.g.todo = { data_path = tmpdir .. '/tasks.json' } + vim.g.pending = { data_path = tmpdir .. '/tasks.json' } config.reset() store.unload() end) after_each(function() vim.fn.delete(tmpdir, 'rf') - vim.g.todo = nil + vim.g.pending = nil config.reset() end) @@ -37,14 +37,14 @@ describe('store', function() tasks = { { id = 1, - description = 'Todo one', + description = 'Pending one', status = 'pending', entry = '2026-01-01T00:00:00Z', modified = '2026-01-01T00:00:00Z', }, { id = 2, - description = 'Todo two', + description = 'Pending two', status = 'done', entry = '2026-01-01T00:00:00Z', modified = '2026-01-01T00:00:00Z', @@ -55,7 +55,7 @@ describe('store', function() local data = store.load() assert.are.equal(3, data.next_id) assert.are.equal(2, #data.tasks) - assert.are.equal('Todo one', data.tasks[1].description) + assert.are.equal('Pending one', data.tasks[1].description) assert.are.equal('done', data.tasks[2].status) end) @@ -68,7 +68,7 @@ describe('store', function() tasks = { { id = 1, - description = 'Todo', + description = 'Pending', status = 'pending', entry = '2026-01-01T00:00:00Z', modified = '2026-01-01T00:00:00Z', @@ -157,7 +157,7 @@ describe('store', function() tasks = { { id = 1, - description = 'Todo', + description = 'Pending', status = 'pending', entry = '2026-01-01T00:00:00Z', modified = '2026-01-01T00:00:00Z', diff --git a/syntax/todo.vim b/syntax/pending.vim similarity index 76% rename from syntax/todo.vim rename to syntax/pending.vim index 8f59b67..b5f3da1 100644 --- a/syntax/todo.vim +++ b/syntax/pending.vim @@ -7,8 +7,8 @@ syntax match taskHeader /^\S.*$/ contains=taskId syntax match taskPriority /!\ze / contained syntax match taskLine /^\/\d\+\/ .*$/ contains=taskId,taskPriority -highlight default link taskHeader TodoHeader -highlight default link taskPriority TodoPriority +highlight default link taskHeader PendingHeader +highlight default link taskPriority PendingPriority highlight default link taskLine Normal let b:current_syntax = 'task' From 53ab1cc0007c47f9070d9f6631da76074fe1a8da Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 15:21:44 -0500 Subject: [PATCH 038/199] feat: rename --- .github/DISCUSSION_TEMPLATE/q-a.yaml | 4 +- .github/ISSUE_TEMPLATE/bug_report.yaml | 4 +- .github/ISSUE_TEMPLATE/config.yaml | 2 +- .github/ISSUE_TEMPLATE/feature_request.yaml | 2 +- README.md | 46 +++++++++---------- lua/{todo => pending}/buffer.lua | 36 +++++++-------- lua/{todo => pending}/config.lua | 16 +++---- lua/{todo => pending}/diff.lua | 12 ++--- lua/{todo => pending}/health.lua | 8 ++-- lua/{todo => pending}/init.lua | 34 +++++++------- lua/{todo => pending}/parse.lua | 4 +- lua/{todo => pending}/store.lua | 40 ++++++++-------- lua/{todo => pending}/sync/gcal.lua | 28 +++++------ lua/{todo => pending}/views.lua | 18 ++++---- ...-1.rockspec => pending.nvim-scm-1.rockspec | 6 +-- plugin/pending.lua | 39 ++++++++++++++++ plugin/todo.lua | 39 ---------------- spec/diff_spec.lua | 10 ++-- spec/parse_spec.lua | 12 ++--- spec/store_spec.lua | 18 ++++---- syntax/{todo.vim => pending.vim} | 4 +- 21 files changed, 191 insertions(+), 191 deletions(-) rename lua/{todo => pending}/buffer.lua (84%) rename lua/{todo => pending}/config.lua (71%) rename lua/{todo => pending}/diff.lua (94%) rename lua/{todo => pending}/health.lua (87%) rename lua/{todo => pending}/init.lua (87%) rename lua/{todo => pending}/parse.lua (97%) rename lua/{todo => pending}/store.lua (89%) rename lua/{todo => pending}/sync/gcal.lua (91%) rename lua/{todo => pending}/views.lua (93%) rename todo.nvim-scm-1.rockspec => pending.nvim-scm-1.rockspec (67%) create mode 100644 plugin/pending.lua delete mode 100644 plugin/todo.lua rename syntax/{todo.vim => pending.vim} (76%) diff --git a/.github/DISCUSSION_TEMPLATE/q-a.yaml b/.github/DISCUSSION_TEMPLATE/q-a.yaml index c2402b5..a65fd46 100644 --- a/.github/DISCUSSION_TEMPLATE/q-a.yaml +++ b/.github/DISCUSSION_TEMPLATE/q-a.yaml @@ -4,8 +4,8 @@ body: - type: markdown attributes: value: | - Use this space for questions, ideas, and general discussion about todo.nvim. - For bug reports, please [open an issue](https://github.com/barrettruth/todo.nvim/issues/new/choose) instead. + Use this space for questions, ideas, and general discussion about pending.nvim. + For bug reports, please [open an issue](https://github.com/barrettruth/pending.nvim/issues/new/choose) instead. - type: textarea attributes: label: Question or topic diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 7e10ffe..baae06b 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -9,7 +9,7 @@ body: options: - label: I have searched [existing - issues](https://github.com/barrettruth/todo.nvim/issues) + issues](https://github.com/barrettruth/pending.nvim/issues) required: true - label: I have updated to the latest version required: true @@ -69,7 +69,7 @@ body: require('lazy.nvim').setup({ spec = { { - 'barrettruth/todo.nvim', + 'barrettruth/pending.nvim', opts = {}, }, }, diff --git a/.github/ISSUE_TEMPLATE/config.yaml b/.github/ISSUE_TEMPLATE/config.yaml index e622f14..e03b8e3 100644 --- a/.github/ISSUE_TEMPLATE/config.yaml +++ b/.github/ISSUE_TEMPLATE/config.yaml @@ -1,5 +1,5 @@ blank_issues_enabled: false contact_links: - name: Questions - url: https://github.com/barrettruth/todo.nvim/discussions + url: https://github.com/barrettruth/pending.nvim/discussions about: Ask questions and discuss ideas diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index e243de1..cabb27c 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -9,7 +9,7 @@ body: options: - label: I have searched [existing - issues](https://github.com/barrettruth/todo.nvim/issues) + issues](https://github.com/barrettruth/pending.nvim/issues) required: true - type: textarea diff --git a/README.md b/README.md index b178317..3bd4a71 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# todo.nvim +# pending.nvim Edit tasks like text. `:w` saves them. @@ -27,13 +27,13 @@ concealed, and metadata is parsed from inline syntax on save. ## Install ``` -luarocks install todo.nvim +luarocks install pending.nvim ``` **lazy.nvim:** ```lua -{ 'barrettruth/todo.nvim' } +{ 'barrettruth/pending.nvim' } ``` Requires Neovim 0.10+. No external dependencies for local use. Google Calendar @@ -41,7 +41,7 @@ sync requires `curl` and `openssl`. ## Usage -`:Todo` opens the task buffer. From there, it's just vim: +`:Pending` opens the task buffer. From there, it's just vim: | Key | Action | | --------- | ------------------------------- | @@ -68,24 +68,24 @@ header. ### Quick add ```vim -:Todo add Buy groceries due:2026-03-15 -:Todo add School: Submit homework +:Pending add Buy groceries due:2026-03-15 +:Pending add School: Submit homework ``` ### Archive ```vim -:Todo archive " purge done tasks older than 30 days -:Todo archive 7 " purge done tasks older than 7 days +: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.todo` before the plugin loads: +No `setup()` call required. Set `vim.g.pending` before the plugin loads: ```lua -vim.g.todo = { - data_path = vim.fn.stdpath('data') .. '/todo/tasks.json', +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 @@ -101,7 +101,7 @@ One-way push of tasks with due dates to a dedicated Google Calendar as all-day events. ```lua -vim.g.todo = { +vim.g.pending = { gcal = { calendar = 'Tasks', credentials_path = '/path/to/client_secret.json', @@ -110,11 +110,11 @@ vim.g.todo = { ``` ```vim -:Todo sync +:Pending sync ``` On first run, a browser window opens for OAuth consent. The refresh token is -stored at `stdpath('data')/todo/gcal_tokens.json`. Completed or deleted tasks +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 @@ -122,25 +122,25 @@ have their calendar events removed. Due date changes update events in place. The plugin defines `` mappings for custom keybinds: ```lua -vim.keymap.set('n', 't', '(todo-open)') -vim.keymap.set('n', 'T', '(todo-toggle)') +vim.keymap.set('n', 't', '(pending-open)') +vim.keymap.set('n', 'T', '(pending-toggle)') ``` | Plug mapping | Action | | ---------------------- | --------------------- | -| `(todo-open)` | Open task buffer | -| `(todo-toggle)` | Toggle complete | -| `(todo-view)` | Switch view | -| `(todo-priority)`| Toggle priority flag | -| `(todo-date)` | Prompt for due date | +| `(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')/todo/tasks.json`. The schema is +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 todo +:checkhealth pending ``` diff --git a/lua/todo/buffer.lua b/lua/pending/buffer.lua similarity index 84% rename from lua/todo/buffer.lua rename to lua/pending/buffer.lua index b059b6b..e67b64b 100644 --- a/lua/todo/buffer.lua +++ b/lua/pending/buffer.lua @@ -1,19 +1,19 @@ -local config = require('todo.config') -local store = require('todo.store') -local views = require('todo.views') +local config = require('pending.config') +local store = require('pending.store') +local views = require('pending.views') ----@class todo.buffer +---@class pending.buffer local M = {} ---@type integer? local task_bufnr = nil -local task_ns = vim.api.nvim_create_namespace('todo') +local task_ns = vim.api.nvim_create_namespace('pending') ---@type 'category'|'priority'|nil local current_view = nil ----@type todo.LineMeta[] +---@type pending.LineMeta[] local _meta = {} ----@return todo.LineMeta[] +---@return pending.LineMeta[] function M.meta() return _meta end @@ -33,7 +33,7 @@ local function set_buf_options(bufnr) vim.bo[bufnr].buftype = 'acwrite' vim.bo[bufnr].bufhidden = 'hide' vim.bo[bufnr].swapfile = false - vim.bo[bufnr].filetype = 'todo' + vim.bo[bufnr].filetype = 'pending' vim.bo[bufnr].modifiable = true end @@ -65,7 +65,7 @@ end ---@param bufnr integer local function setup_indentexpr(bufnr) - vim.bo[bufnr].indentexpr = 'v:lua.require("todo.buffer").get_indent()' + vim.bo[bufnr].indentexpr = 'v:lua.require("pending.buffer").get_indent()' end ---@return integer @@ -82,7 +82,7 @@ function M.get_indent() end ---@param bufnr integer ----@param line_meta todo.LineMeta[] +---@param line_meta pending.LineMeta[] 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 @@ -90,7 +90,7 @@ local function apply_extmarks(bufnr, line_meta) if m.type == 'task' then if m.due then vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, { - virt_text = { { m.due, 'TodoDue' } }, + virt_text = { { m.due, 'PendingDue' } }, virt_text_pos = 'right_align', }) end @@ -99,14 +99,14 @@ local function apply_extmarks(bufnr, line_meta) local col_start = line:find('/%d+/') and select(2, line:find('/%d+/')) + 2 or 0 vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, col_start, { end_col = #line, - hl_group = 'TodoDone', + hl_group = 'PendingDone', }) end 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 = 'TodoHeader', + hl_group = 'PendingHeader', }) end end @@ -118,10 +118,10 @@ local function setup_highlights() vim.api.nvim_set_hl(0, name, opts) end end - hl('TodoHeader', { bold = true }) - hl('TodoDue', { fg = '#888888', italic = true }) - hl('TodoDone', { strikethrough = true, fg = '#666666' }) - hl('TodoPriority', { fg = '#e06c75', bold = true }) + hl('PendingHeader', { bold = true }) + hl('PendingDue', { fg = '#888888', italic = true }) + hl('PendingDone', { strikethrough = true, fg = '#666666' }) + hl('PendingPriority', { fg = '#e06c75', bold = true }) end ---@param bufnr? integer @@ -179,7 +179,7 @@ function M.open() end task_bufnr = vim.api.nvim_create_buf(true, false) - vim.api.nvim_buf_set_name(task_bufnr, 'todo://') + vim.api.nvim_buf_set_name(task_bufnr, 'pending://') set_buf_options(task_bufnr) setup_indentexpr(task_bufnr) diff --git a/lua/todo/config.lua b/lua/pending/config.lua similarity index 71% rename from lua/todo/config.lua rename to lua/pending/config.lua index 622b96c..d486690 100644 --- a/lua/todo/config.lua +++ b/lua/pending/config.lua @@ -1,8 +1,8 @@ ----@class todo.GcalConfig +---@class pending.GcalConfig ---@field calendar? string ---@field credentials_path? string ----@class todo.Config +---@class pending.Config ---@field data_path string ---@field default_view 'category'|'priority' ---@field default_category string @@ -10,27 +10,27 @@ ---@field date_syntax string ---@field gcal? task.GcalConfig ----@class todo.config +---@class pending.config local M = {} ----@type todo.Config +---@type pending.Config local defaults = { - data_path = vim.fn.stdpath('data') .. '/todo/tasks.json', + data_path = vim.fn.stdpath('data') .. '/pending/tasks.json', default_view = 'category', default_category = 'Inbox', date_format = '%b %d', date_syntax = 'due', } ----@type todo.Config? +---@type pending.Config? local _resolved = nil ----@return todo.Config +---@return pending.Config function M.get() if _resolved then return _resolved end - local user = vim.g.todo or {} + local user = vim.g.pending or {} _resolved = vim.tbl_deep_extend('force', defaults, user) return _resolved end diff --git a/lua/todo/diff.lua b/lua/pending/diff.lua similarity index 94% rename from lua/todo/diff.lua rename to lua/pending/diff.lua index 81ec9b4..6d6c648 100644 --- a/lua/todo/diff.lua +++ b/lua/pending/diff.lua @@ -1,8 +1,8 @@ -local config = require('todo.config') -local parse = require('todo.parse') -local store = require('todo.store') +local config = require('pending.config') +local parse = require('pending.parse') +local store = require('pending.store') ----@class todo.ParsedEntry +---@class pending.ParsedEntry ---@field type 'task'|'header'|'blank' ---@field id? integer ---@field description? string @@ -11,7 +11,7 @@ local store = require('todo.store') ---@field due? string ---@field lnum integer ----@class todo.diff +---@class pending.diff local M = {} ---@return string @@ -20,7 +20,7 @@ local function timestamp() end ---@param lines string[] ----@return todo.ParsedEntry[] +---@return pending.ParsedEntry[] function M.parse_buffer(lines) local result = {} local current_category = nil diff --git a/lua/todo/health.lua b/lua/pending/health.lua similarity index 87% rename from lua/todo/health.lua rename to lua/pending/health.lua index 72652f6..67c1c09 100644 --- a/lua/todo/health.lua +++ b/lua/pending/health.lua @@ -1,11 +1,11 @@ local M = {} function M.check() - vim.health.start('todo.nvim') + vim.health.start('pending.nvim') - local ok, config = pcall(require, 'todo.config') + local ok, config = pcall(require, 'pending.config') if not ok then - vim.health.error('Failed to load todo.config') + vim.health.error('Failed to load pending.config') return end @@ -25,7 +25,7 @@ function M.check() end if vim.fn.filereadable(cfg.data_path) == 1 then - local store_ok, store = pcall(require, 'todo.store') + local store_ok, store = pcall(require, 'pending.store') if store_ok then local load_ok, err = pcall(store.load) if load_ok then diff --git a/lua/todo/init.lua b/lua/pending/init.lua similarity index 87% rename from lua/todo/init.lua rename to lua/pending/init.lua index c126050..3c2d59b 100644 --- a/lua/todo/init.lua +++ b/lua/pending/init.lua @@ -1,12 +1,12 @@ -local buffer = require('todo.buffer') -local diff = require('todo.diff') -local parse = require('todo.parse') -local store = require('todo.store') +local buffer = require('pending.buffer') +local diff = require('pending.diff') +local parse = require('pending.parse') +local store = require('pending.store') ---@class task local M = {} ----@type todo.Task[]? +---@type pending.Task[]? local _undo_state = nil ---@return integer bufnr @@ -19,7 +19,7 @@ end ---@param bufnr integer function M._setup_autocmds(bufnr) - local group = vim.api.nvim_create_augroup('TodoBuffer', { clear = true }) + local group = vim.api.nvim_create_augroup('PendingBuffer', { clear = true }) vim.api.nvim_create_autocmd('BufWriteCmd', { group = group, buffer = bufnr, @@ -127,13 +127,13 @@ end ---@param text string function M.add(text) if not text or text == '' then - vim.notify('Usage: :Todo add ', vim.log.levels.ERROR) + vim.notify('Usage: :Pending add ', vim.log.levels.ERROR) return end store.load() local description, metadata = parse.command_add(text) if not description or description == '' then - vim.notify('Todo must have a description.', vim.log.levels.ERROR) + vim.notify('Pending must have a description.', vim.log.levels.ERROR) return end store.add({ @@ -146,11 +146,11 @@ function M.add(text) if bufnr and vim.api.nvim_buf_is_valid(bufnr) then buffer.render(bufnr) end - vim.notify('Todo added: ' .. description) + vim.notify('Pending added: ' .. description) end function M.sync() - local ok, gcal = pcall(require, 'todo.sync.gcal') + local ok, gcal = pcall(require, 'pending.sync.gcal') if not ok then vim.notify('Google Calendar sync module not available.', vim.log.levels.ERROR) return @@ -196,10 +196,10 @@ function M.archive(days) end function M.show_help() - local cfg = require('todo.config').get() + local cfg = require('pending.config').get() local dk = cfg.date_syntax or 'due' local lines = { - 'todo.nvim keybindings', + 'pending.nvim keybindings', '', ' Toggle complete/uncomplete', ' Switch category/priority view', @@ -208,10 +208,10 @@ function M.show_help() 'p / P Paste (duplicates get new IDs)', ':w Save all changes', '', - ':Todo add Quick-add task', - ':Todo add Cat: Quick-add with category', - ':Todo sync Push to Google Calendar', - ':Todo archive [days] Purge old done tasks', + ':Pending add Quick-add task', + ':Pending add Cat: Quick-add with category', + ':Pending sync Push to Google Calendar', + ':Pending archive [days] Purge old done tasks', '', 'Inline metadata (on new lines before :w):', ' ' .. dk .. ':YYYY-MM-DD Set due date', @@ -257,7 +257,7 @@ function M.command(args) local d = rest ~= '' and tonumber(rest) or nil M.archive(d) else - vim.notify('Unknown Todo subcommand: ' .. cmd, vim.log.levels.ERROR) + vim.notify('Unknown Pending subcommand: ' .. cmd, vim.log.levels.ERROR) end end diff --git a/lua/todo/parse.lua b/lua/pending/parse.lua similarity index 97% rename from lua/todo/parse.lua rename to lua/pending/parse.lua index 0e8660c..722ba50 100644 --- a/lua/todo/parse.lua +++ b/lua/pending/parse.lua @@ -1,6 +1,6 @@ -local config = require('todo.config') +local config = require('pending.config') ----@class todo.parse +---@class pending.parse local M = {} ---@param s string diff --git a/lua/todo/store.lua b/lua/pending/store.lua similarity index 89% rename from lua/todo/store.lua rename to lua/pending/store.lua index b352896..1ad3ad3 100644 --- a/lua/todo/store.lua +++ b/lua/pending/store.lua @@ -1,6 +1,6 @@ -local config = require('todo.config') +local config = require('pending.config') ----@class todo.Task +---@class pending.Task ---@field id integer ---@field description string ---@field status 'pending'|'done'|'deleted' @@ -13,20 +13,20 @@ local config = require('todo.config') ---@field order integer ---@field _extra? table ----@class todo.Data +---@class pending.Data ---@field version integer ---@field next_id integer ---@field tasks task.Task[] ----@class todo.store +---@class pending.store local M = {} local SUPPORTED_VERSION = 1 ----@type todo.Data? +---@type pending.Data? local _data = nil ----@return todo.Data +---@return pending.Data local function empty_data() return { version = SUPPORTED_VERSION, @@ -62,7 +62,7 @@ local known_fields = { order = true, } ----@param task todo.Task +---@param task pending.Task ---@return table local function task_to_table(task) local t = { @@ -96,7 +96,7 @@ local function task_to_table(task) end ---@param t table ----@return todo.Task +---@return pending.Task local function table_to_task(t) local task = { id = t.id, @@ -122,7 +122,7 @@ local function table_to_task(t) return task end ----@return todo.Data +---@return pending.Data function M.load() local path = config.get().data_path local f = io.open(path, 'r') @@ -138,11 +138,11 @@ function M.load() end local ok, decoded = pcall(vim.json.decode, content) if not ok then - error('todo.nvim: failed to parse ' .. path .. ': ' .. tostring(decoded)) + error('pending.nvim: failed to parse ' .. path .. ': ' .. tostring(decoded)) end if decoded.version and decoded.version > SUPPORTED_VERSION then error( - 'todo.nvim: data file version ' + 'pending.nvim: data file version ' .. decoded.version .. ' is newer than supported version ' .. SUPPORTED_VERSION @@ -177,13 +177,13 @@ function M.save() local encoded = vim.json.encode(out) local f = io.open(path, 'w') if not f then - error('todo.nvim: cannot write to ' .. path) + error('pending.nvim: cannot write to ' .. path) end f:write(encoded) f:close() end ----@return todo.Data +---@return pending.Data function M.data() if not _data then M.load() @@ -191,12 +191,12 @@ function M.data() return _data end ----@return todo.Task[] +---@return pending.Task[] function M.tasks() return M.data().tasks end ----@return todo.Task[] +---@return pending.Task[] function M.active_tasks() local result = {} for _, task in ipairs(M.tasks()) do @@ -208,7 +208,7 @@ function M.active_tasks() end ---@param id integer ----@return todo.Task? +---@return pending.Task? function M.get(id) for _, task in ipairs(M.tasks()) do if task.id == id then @@ -219,7 +219,7 @@ function M.get(id) end ---@param fields { description: string, status?: string, category?: string, priority?: integer, due?: string, order?: integer, _extra?: table } ----@return todo.Task +---@return pending.Task function M.add(fields) local data = M.data() local now = timestamp() @@ -243,7 +243,7 @@ end ---@param id integer ---@param fields table ----@return todo.Task? +---@return pending.Task? function M.update(id, fields) local task = M.get(id) if not task then @@ -263,7 +263,7 @@ function M.update(id, fields) end ---@param id integer ----@return todo.Task? +---@return pending.Task? function M.delete(id) return M.update(id, { status = 'deleted', ['end'] = timestamp() }) end @@ -279,7 +279,7 @@ function M.find_index(id) return nil end ----@param tasks todo.Task[] +---@param tasks pending.Task[] function M.replace_tasks(tasks) M.data().tasks = tasks end diff --git a/lua/todo/sync/gcal.lua b/lua/pending/sync/gcal.lua similarity index 91% rename from lua/todo/sync/gcal.lua rename to lua/pending/sync/gcal.lua index 1406216..30571cd 100644 --- a/lua/todo/sync/gcal.lua +++ b/lua/pending/sync/gcal.lua @@ -1,5 +1,5 @@ -local config = require('todo.config') -local store = require('todo.store') +local config = require('pending.config') +local store = require('pending.store') local M = {} @@ -14,12 +14,12 @@ local function gcal_config() end local function token_path() - return vim.fn.stdpath('data') .. '/todo/gcal_tokens.json' + return vim.fn.stdpath('data') .. '/pending/gcal_tokens.json' end local function credentials_path() local gc = gcal_config() - return gc.credentials_path or (vim.fn.stdpath('data') .. '/todo/gcal_credentials.json') + return gc.credentials_path or (vim.fn.stdpath('data') .. '/pending/gcal_credentials.json') end local function load_json_file(path) @@ -153,7 +153,7 @@ local function get_access_token() local creds = load_credentials() if not creds then vim.notify( - 'todo.nvim: No Google Calendar credentials found at ' .. credentials_path(), + 'pending.nvim: No Google Calendar credentials found at ' .. credentials_path(), vim.log.levels.ERROR ) return nil @@ -172,7 +172,7 @@ local function get_access_token() if now - obtained > expires - 60 then tokens = refresh_access_token(creds, tokens) if not tokens then - vim.notify('todo.nvim: Failed to refresh access token.', vim.log.levels.ERROR) + vim.notify('pending.nvim: Failed to refresh access token.', vim.log.levels.ERROR) return nil end end @@ -183,7 +183,7 @@ function M.authorize() local creds = load_credentials() if not creds then vim.notify( - 'todo.nvim: No Google Calendar credentials found at ' .. credentials_path(), + 'pending.nvim: No Google Calendar credentials found at ' .. credentials_path(), vim.log.levels.ERROR ) return @@ -225,7 +225,7 @@ function M.authorize() .. '&code_challenge_method=S256' vim.ui.open(auth_url) - vim.notify('todo.nvim: Opening browser for Google authorization...') + vim.notify('pending.nvim: Opening browser for Google authorization...') local server = vim.uv.new_tcp() server:bind('127.0.0.1', port) @@ -288,24 +288,24 @@ function M._exchange_code(creds, code, code_verifier, port) :wait() if result.code ~= 0 then - vim.notify('todo.nvim: Token exchange failed.', vim.log.levels.ERROR) + 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('todo.nvim: Invalid token response.', vim.log.levels.ERROR) + vim.notify('pending.nvim: Invalid token response.', vim.log.levels.ERROR) return end decoded.obtained_at = os.time() save_tokens(decoded) - vim.notify('todo.nvim: Google Calendar authorized successfully.') + vim.notify('pending.nvim: Google Calendar authorized successfully.') end local function find_or_create_calendar(access_token) local gc = gcal_config() - local cal_name = gc.calendar or 'Todos' + local cal_name = gc.calendar or 'Pendings' local data, err = curl_request('GET', BASE_URL .. '/users/me/calendarList', auth_headers(access_token)) @@ -389,7 +389,7 @@ function M.sync() local calendar_id, err = find_or_create_calendar(access_token) if err then - vim.notify('todo.nvim: ' .. err, vim.log.levels.ERROR) + vim.notify('pending.nvim: ' .. err, vim.log.levels.ERROR) return end @@ -442,7 +442,7 @@ function M.sync() store.save() vim.notify( string.format( - 'todo.nvim: Synced to Google Calendar (created: %d, updated: %d, deleted: %d)', + 'pending.nvim: Synced to Google Calendar (created: %d, updated: %d, deleted: %d)', created, updated, deleted diff --git a/lua/todo/views.lua b/lua/pending/views.lua similarity index 93% rename from lua/todo/views.lua rename to lua/pending/views.lua index 5330d86..6e6fc4f 100644 --- a/lua/todo/views.lua +++ b/lua/pending/views.lua @@ -1,6 +1,6 @@ -local config = require('todo.config') +local config = require('pending.config') ----@class todo.LineMeta +---@class pending.LineMeta ---@field type 'task'|'header'|'blank' ---@field id? integer ---@field due? string @@ -8,7 +8,7 @@ local config = require('todo.config') ---@field status? string ---@field category? string ----@class todo.views +---@class pending.views local M = {} ---@param due? string @@ -25,7 +25,7 @@ local function format_due(due) return os.date(config.get().date_format, t) end ----@param tasks todo.Task[] +---@param tasks pending.Task[] local function sort_tasks(tasks) table.sort(tasks, function(a, b) if a.priority ~= b.priority then @@ -38,7 +38,7 @@ local function sort_tasks(tasks) end) end ----@param tasks todo.Task[] +---@param tasks pending.Task[] local function sort_tasks_priority(tasks) table.sort(tasks, function(a, b) if a.priority ~= b.priority then @@ -62,9 +62,9 @@ local function sort_tasks_priority(tasks) end) end ----@param tasks todo.Task[] +---@param tasks pending.Task[] ---@return string[] lines ----@return todo.LineMeta[] meta +---@return pending.LineMeta[] meta function M.category_view(tasks) local by_cat = {} local cat_order = {} @@ -130,9 +130,9 @@ function M.category_view(tasks) return lines, meta end ----@param tasks todo.Task[] +---@param tasks pending.Task[] ---@return string[] lines ----@return todo.LineMeta[] meta +---@return pending.LineMeta[] meta function M.priority_view(tasks) local pending = {} local done = {} diff --git a/todo.nvim-scm-1.rockspec b/pending.nvim-scm-1.rockspec similarity index 67% rename from todo.nvim-scm-1.rockspec rename to pending.nvim-scm-1.rockspec index a1d17bf..22759fa 100644 --- a/todo.nvim-scm-1.rockspec +++ b/pending.nvim-scm-1.rockspec @@ -1,14 +1,14 @@ rockspec_format = '3.0' -package = 'todo.nvim' +package = 'pending.nvim' version = 'scm-1' source = { - url = 'git+https://github.com/barrettruth/todo.nvim.git', + url = 'git+https://github.com/barrettruth/pending.nvim.git', } description = { summary = 'Oil-like task management for Neovim', - homepage = 'https://github.com/barrettruth/todo.nvim', + homepage = 'https://github.com/barrettruth/pending.nvim', license = 'MIT', } diff --git a/plugin/pending.lua b/plugin/pending.lua new file mode 100644 index 0000000..7270825 --- /dev/null +++ b/plugin/pending.lua @@ -0,0 +1,39 @@ +if vim.g.loaded_pending then + return +end +vim.g.loaded_pending = true + +vim.api.nvim_create_user_command('Pending', function(opts) + require('pending').command(opts.args) +end, { + nargs = '*', + complete = function(arg_lead, cmd_line) + local subcmds = { 'add', 'sync', 'archive' } + 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) + end + return {} + end, +}) + +vim.keymap.set('n', '(pending-open)', function() + require('pending').open() +end) + +vim.keymap.set('n', '(pending-toggle)', function() + require('pending').toggle_complete() +end) + +vim.keymap.set('n', '(pending-view)', function() + require('pending.buffer').toggle_view() +end) + +vim.keymap.set('n', '(pending-priority)', function() + require('pending').toggle_priority() +end) + +vim.keymap.set('n', '(pending-date)', function() + require('pending').prompt_date() +end) diff --git a/plugin/todo.lua b/plugin/todo.lua deleted file mode 100644 index d687923..0000000 --- a/plugin/todo.lua +++ /dev/null @@ -1,39 +0,0 @@ -if vim.g.loaded_todo then - return -end -vim.g.loaded_todo = true - -vim.api.nvim_create_user_command('Todo', function(opts) - require('todo').command(opts.args) -end, { - nargs = '*', - complete = function(arg_lead, cmd_line) - local subcmds = { 'add', 'sync', 'archive' } - if not cmd_line:match('^Todo%s+%S') then - return vim.tbl_filter(function(s) - return s:find(arg_lead, 1, true) == 1 - end, subcmds) - end - return {} - end, -}) - -vim.keymap.set('n', '(todo-open)', function() - require('todo').open() -end) - -vim.keymap.set('n', '(todo-toggle)', function() - require('todo').toggle_complete() -end) - -vim.keymap.set('n', '(todo-view)', function() - require('todo.buffer').toggle_view() -end) - -vim.keymap.set('n', '(todo-priority)', function() - require('todo').toggle_priority() -end) - -vim.keymap.set('n', '(todo-date)', function() - require('todo').prompt_date() -end) diff --git a/spec/diff_spec.lua b/spec/diff_spec.lua index f1b7cf8..3d3c935 100644 --- a/spec/diff_spec.lua +++ b/spec/diff_spec.lua @@ -1,16 +1,16 @@ require('spec.helpers') -local config = require('todo.config') -local store = require('todo.store') +local config = require('pending.config') +local store = require('pending.store') describe('diff', function() local tmpdir - local diff = require('todo.diff') + local diff = require('pending.diff') before_each(function() tmpdir = vim.fn.tempname() vim.fn.mkdir(tmpdir, 'p') - vim.g.todo = { data_path = tmpdir .. '/tasks.json' } + vim.g.pending = { data_path = tmpdir .. '/tasks.json' } config.reset() store.unload() store.load() @@ -18,7 +18,7 @@ describe('diff', function() after_each(function() vim.fn.delete(tmpdir, 'rf') - vim.g.todo = nil + vim.g.pending = nil config.reset() end) diff --git a/spec/parse_spec.lua b/spec/parse_spec.lua index f1b2641..92b2239 100644 --- a/spec/parse_spec.lua +++ b/spec/parse_spec.lua @@ -1,19 +1,19 @@ require('spec.helpers') -local config = require('todo.config') +local config = require('pending.config') describe('parse', function() before_each(function() - vim.g.todo = nil + vim.g.pending = nil config.reset() end) after_each(function() - vim.g.todo = nil + vim.g.pending = nil config.reset() end) - local parse = require('todo.parse') + local parse = require('pending.parse') describe('body', function() it('returns plain description when no metadata', function() @@ -73,7 +73,7 @@ describe('parse', function() end) it('uses configurable date syntax', function() - vim.g.todo = { date_syntax = 'by' } + vim.g.pending = { date_syntax = 'by' } config.reset() local desc, meta = parse.body('Buy milk by:2026-03-15') assert.are.equal('Buy milk', desc) @@ -81,7 +81,7 @@ describe('parse', function() end) it('ignores old syntax when date_syntax is changed', function() - vim.g.todo = { date_syntax = 'by' } + vim.g.pending = { date_syntax = 'by' } config.reset() local desc, meta = parse.body('Buy milk due:2026-03-15') assert.are.equal('Buy milk due:2026-03-15', desc) diff --git a/spec/store_spec.lua b/spec/store_spec.lua index 3bd7e40..8fdf5f4 100644 --- a/spec/store_spec.lua +++ b/spec/store_spec.lua @@ -1,7 +1,7 @@ require('spec.helpers') -local config = require('todo.config') -local store = require('todo.store') +local config = require('pending.config') +local store = require('pending.store') describe('store', function() local tmpdir @@ -9,14 +9,14 @@ describe('store', function() before_each(function() tmpdir = vim.fn.tempname() vim.fn.mkdir(tmpdir, 'p') - vim.g.todo = { data_path = tmpdir .. '/tasks.json' } + vim.g.pending = { data_path = tmpdir .. '/tasks.json' } config.reset() store.unload() end) after_each(function() vim.fn.delete(tmpdir, 'rf') - vim.g.todo = nil + vim.g.pending = nil config.reset() end) @@ -37,14 +37,14 @@ describe('store', function() tasks = { { id = 1, - description = 'Todo one', + description = 'Pending one', status = 'pending', entry = '2026-01-01T00:00:00Z', modified = '2026-01-01T00:00:00Z', }, { id = 2, - description = 'Todo two', + description = 'Pending two', status = 'done', entry = '2026-01-01T00:00:00Z', modified = '2026-01-01T00:00:00Z', @@ -55,7 +55,7 @@ describe('store', function() local data = store.load() assert.are.equal(3, data.next_id) assert.are.equal(2, #data.tasks) - assert.are.equal('Todo one', data.tasks[1].description) + assert.are.equal('Pending one', data.tasks[1].description) assert.are.equal('done', data.tasks[2].status) end) @@ -68,7 +68,7 @@ describe('store', function() tasks = { { id = 1, - description = 'Todo', + description = 'Pending', status = 'pending', entry = '2026-01-01T00:00:00Z', modified = '2026-01-01T00:00:00Z', @@ -157,7 +157,7 @@ describe('store', function() tasks = { { id = 1, - description = 'Todo', + description = 'Pending', status = 'pending', entry = '2026-01-01T00:00:00Z', modified = '2026-01-01T00:00:00Z', diff --git a/syntax/todo.vim b/syntax/pending.vim similarity index 76% rename from syntax/todo.vim rename to syntax/pending.vim index 8f59b67..b5f3da1 100644 --- a/syntax/todo.vim +++ b/syntax/pending.vim @@ -7,8 +7,8 @@ syntax match taskHeader /^\S.*$/ contains=taskId syntax match taskPriority /!\ze / contained syntax match taskLine /^\/\d\+\/ .*$/ contains=taskId,taskPriority -highlight default link taskHeader TodoHeader -highlight default link taskPriority TodoPriority +highlight default link taskHeader PendingHeader +highlight default link taskPriority PendingPriority highlight default link taskLine Normal let b:current_syntax = 'task' From 72cbdcc7757ecd737ed393a6650eac591fb230b6 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 15:23:37 -0500 Subject: [PATCH 039/199] ci: trigger fresh CI after repo rename From f66fb93923fa9425b42987a75ab13fb7e483d216 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 15:23:37 -0500 Subject: [PATCH 040/199] ci: trigger fresh CI after repo rename From 4a47758c9c026cb7e3df4fbe073e24bbaae27e44 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 15:23:43 -0500 Subject: [PATCH 041/199] ci: format --- README.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 3bd4a71..98e14d3 100644 --- a/README.md +++ b/README.md @@ -126,13 +126,13 @@ 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 | +| 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 From eeaeced74ad9ab93d5c0ebf2f6e3adfd8b9d7f7a Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 15:23:43 -0500 Subject: [PATCH 042/199] ci: format --- README.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 3bd4a71..98e14d3 100644 --- a/README.md +++ b/README.md @@ -126,13 +126,13 @@ 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 | +| 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 From 0727a03d4109200ed2780dccf7dcd9f2b459a5d1 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 15:24:34 -0500 Subject: [PATCH 043/199] ci: retrigger after cache purge From 7b97e9b840cb023848a09c8745895a23d269d147 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 15:24:34 -0500 Subject: [PATCH 044/199] ci: retrigger after cache purge From f21658f138af9e8a587c484f7e1775048c713d62 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Tue, 24 Feb 2026 18:33:07 -0500 Subject: [PATCH 045/199] feat: overdue highlighting, relative dates, undo write, buffer mappings (#1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(config): add category_order field Problem: category display order was always insertion order with no way to configure it. Solution: add category_order to config defaults so users can declare a preferred category ordering; unspecified categories append after. * feat(parse): add relative date resolution Problem: due dates required full YYYY-MM-DD input, adding friction for common cases like "today" or "next monday". Solution: add resolve_date() supporting today, tomorrow, +Nd, and weekday abbreviations; extend inline token parsing to resolve relative values before falling back to strict date validation. * feat(views): overdue flag, category in priority view, category ordering Problem: overdue tasks were visually indistinct from upcoming ones; priority view had no category context; category display order was not configurable. Solution: compute overdue meta flag for pending tasks past their due date; set show_category on priority view task meta; reorder categories according to config.category_order when present. * feat(buffer): overdue highlight, category virt text in priority view Problem: overdue tasks had no visual distinction; priority view showed no category context alongside due dates. Solution: add PendingOverdue highlight group; render category name as right-aligned virtual text in priority view, composited with the due date when both are present. * feat(init): undo write and buffer-local default mappings Problem: _undo_state was captured on every save but never consumed; toggle_priority and prompt_date had no buffer-local defaults, requiring manual configuration. Solution: implement undo_write() to restore pre-save task state; add !, d, and U as buffer-local defaults following fugitive's philosophy of owning the buffer; expose :Pending undo as a command alias. * test(views): add views spec Problem: views.lua had no test coverage. Solution: add 26 tests covering category_view and priority_view including sort order, line format, overdue detection, show_category meta, and category_order config behavior. * test(archive): add archive spec Problem: archive had no test coverage. Solution: add 9 tests covering cutoff logic, custom day counts, pending task preservation, deleted task cleanup, and notify output. * docs: add vimdoc Problem: no :help documentation existed. Solution: add doc/pending.txt covering all features — commands, mappings, views, configuration, Google Calendar sync, highlight groups, data format, and health check — following standard vimdoc conventions. * ci: format * fix: resolve lint and type check errors Problem: selene flagged unused variables in new spec files; LuaLS flagged os.date/os.time return type mismatches, integer? assignments, and stale task.Task/task.GcalConfig type references. Solution: prefix unused spec variables with _ or drop unnecessary assignments; add --[[@as string/integer]] casts for os.date and os.time calls; add category_order field to pending.Config annotation; fix task.GcalConfig -> pending.GcalConfig and task.Task[] -> pending.Task[]; add nil guards on meta[row].id before store calls; cast store.data() return to non-optional. * ci: format * fix: sync * ci: format --- doc/pending.txt | 415 ++++++++++++++++++++++++++++++++++++++ lua/pending/buffer.lua | 21 +- lua/pending/config.lua | 4 +- lua/pending/diff.lua | 2 +- lua/pending/init.lua | 52 ++++- lua/pending/parse.lua | 87 +++++++- lua/pending/store.lua | 6 +- lua/pending/sync/gcal.lua | 13 +- lua/pending/views.lua | 33 ++- spec/archive_spec.lua | 131 ++++++++++++ spec/views_spec.lua | 403 ++++++++++++++++++++++++++++++++++++ 11 files changed, 1137 insertions(+), 30 deletions(-) create mode 100644 doc/pending.txt create mode 100644 spec/archive_spec.lua create mode 100644 spec/views_spec.lua diff --git a/doc/pending.txt b/doc/pending.txt new file mode 100644 index 0000000..4986467 --- /dev/null +++ b/doc/pending.txt @@ -0,0 +1,415 @@ +*pending.txt* Buffer-centric task management for Neovim + +Author: Barrett Ruth +License: MIT + +============================================================================== +INTRODUCTION *pending.nvim* + +pending.nvim is a buffer-centric task manager for Neovim. Tasks live in a +plain, editable buffer — add with `o`, delete with `dd`, reorder with +`dd`/`p`, rename by typing. Writing the buffer with `:w` computes a diff +against the JSON store and applies only the changes. No floating windows, +no special UI, no abstraction between you and your tasks. + +The buffer looks like this: > + + School + ! Read chapter 5 Feb 28 + Submit homework Feb 25 + + Errands + Buy groceries Mar 01 + Clean apartment +< + +Category headers sit at column 0. Tasks are indented two spaces below them. +`!` marks a priority task. Due dates appear as right-aligned virtual text. +Completed tasks are rendered with strikethrough. Task IDs are embedded as +concealed tokens and are never visible during editing. + +Features: ~ +- Oil-style buffer editing: standard Vim motions for all task operations +- Inline metadata syntax: `due:` and `cat:` tokens parsed on `:w` +- Relative date input: `today`, `tomorrow`, `+Nd`, weekday names +- Two views: category (default) and priority flat list +- Single-level undo for the last `:w` save +- Quick-add from the command line with `:Pending add` +- Google Calendar one-way push via OAuth PKCE + +============================================================================== +REQUIREMENTS *pending-requirements* + +- Neovim 0.10+ +- No external dependencies for local use +- `curl` and `openssl` are required for Google Calendar sync + +============================================================================== +INSTALL *pending-install* + +Install with lazy.nvim: >lua + { 'barrettruth/pending.nvim' } +< + +Install with luarocks: >vim + luarocks install pending.nvim +< + +No `setup()` call is needed. The plugin loads automatically and works with +defaults. To customize behavior, set |vim.g.pending| before the plugin loads. +See |pending-config|. + +============================================================================== +USAGE *pending-usage* + +Open the task buffer: >vim + :Pending +< + +The buffer named `pending://` opens in the current window. From there, use +standard Vim editing: + +- `o` / `O` to add a new task line under or above the cursor +- `dd` to remove a task (deletion is applied on `:w`) +- `dd` + `p` to reorder tasks (pasted tasks receive new IDs) +- `:w` to save — all additions, deletions, and edits are diffed against the + store and committed atomically + +Buffer-local keys are set automatically when the buffer opens. See +|pending-mappings| for the full list. + +The buffer uses `buftype=acwrite` so `:w` always routes through pending.nvim's +write handler rather than writing to disk directly. The `pending://` buffer +persists across window switches; reopening with `:Pending` focuses the +existing window if one is open. + +============================================================================== +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: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` + `cat:Name` Move the task to the named category on save. + +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. + +Example: > + + Buy milk due:2026-03-15 cat:Errands +< + +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:` and one +`cat:` per task line are consumed. + +============================================================================== +COMMANDS *pending-commands* + + *:Pending* +:Pending + Open the task buffer. If the buffer is already displayed in a window, + focus that window. Equivalent to |(pending-open)|. + + *:Pending-add* +:Pending add {text} + Quick-add a task without opening the buffer. Inline metadata tokens in + {text} are parsed exactly as they are in the buffer. A `Category: ` prefix + (uppercase first letter, colon, space) assigns the category directly: >vim + :Pending add Buy groceries due:2026-03-15 + :Pending add School: Submit homework + :Pending add Errands: Pick up dry cleaning due:fri +< + If the buffer is currently open it is re-rendered after the add. + + *:Pending-archive* +:Pending archive [{days}] + Permanently remove done and deleted tasks whose completion timestamp is + older than {days} days. {days} defaults to 30 if not provided. >vim + :Pending archive " remove tasks completed more than 30 days ago + :Pending archive 7 " remove tasks completed more than 7 days ago +< + + *: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-undo* +:Pending undo + There is no `:Pending undo` subcommand. Use the `U` buffer-local key + (see |pending-mappings|) to undo the last `:w` save while the task buffer + is open. + +============================================================================== +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: ~ + + 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 + +Standard Vim keys `o`, `O`, `dd`, `p`, `P`, and `:w` work as expected. + + *(pending-open)* +(pending-open) + Open the task buffer. Maps to |:Pending| with no arguments. + + *(pending-toggle)* +(pending-toggle) + Toggle complete / uncomplete for the task under the cursor. + + *(pending-priority)* +(pending-priority) + Toggle the priority flag for the task under the cursor. + + *(pending-date)* +(pending-date) + Prompt for a due date for the task under the cursor. + + *(pending-view)* +(pending-view) + Switch between category view and priority view. + +Example configuration: >lua + vim.keymap.set('n', 't', '(pending-open)') + vim.keymap.set('n', 'T', '(pending-toggle)') +< + +============================================================================== +VIEWS *pending-views* + +Two views are available. Switch with `` or |(pending-view)|. + +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. + +Priority view: ~ *pending-view-priority* + 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. + +============================================================================== +CONFIGURATION *pending-config* + +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 = 'Inbox', + date_format = '%b %d', + date_syntax = 'due', + category_order = {}, + gcal = { + calendar = 'Tasks', + credentials_path = '/path/to/client_secret.json', + }, + } +< + +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. + Default: `stdpath('data') .. '/pending/tasks.json'`. + The directory is created automatically on first save. + + {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: 'Inbox') + Category assigned to new tasks when no `cat:` token + is present and no `Category: ` prefix is used with + `:Pending add`. + + {date_format} (string, default: '%b %d') + strftime format string used to render due dates as + virtual text in the buffer. Examples: `'%Y-%m-%d'` + for ISO dates, `'%d %b'` for day-first. + + {date_syntax} (string, default: 'due') + The token name for inline due-date metadata. Change + this to use a different keyword, for example `'by'` + to write `by:2026-03-15` instead of `due:2026-03-15`. + + {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. + + {gcal} (table, default: nil) + Google Calendar sync configuration. See + |pending.GcalConfig|. Omit this field entirely to + disable Google Calendar sync. + +============================================================================== +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. + +Configuration: >lua + vim.g.pending = { + gcal = { + calendar = 'Tasks', + credentials_path = '/path/to/client_secret.json', + }, + } +< + + *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. + + {credentials_path} (string) + Path to the 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. + +OAuth flow: ~ +On the first `:Pending sync` 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 +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 sync` 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. +- 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`. + +============================================================================== +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: bold. + + *PendingDue* +PendingDue Applied to the due date virtual text shown at the right + margin of each task line. + Default: fg=#888888, italic. + + *PendingDone* +PendingDone Applied to the text of completed tasks. + Default: strikethrough, fg=#666666. + + *PendingPriority* +PendingPriority Applied to the `! ` priority marker on priority tasks. + Default: fg=#e06c75, bold. + +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 +< + +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 +- Whether `curl` is available (required for Google Calendar sync) +- Whether `openssl` is available (required for OAuth PKCE) + +============================================================================== +DATA FORMAT *pending-data* + +Tasks are stored as JSON at `data_path`. 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. + +Schema: > + + { + "version": 1, + "next_id": 42, + "tasks": [ ... ] + } +< + +Task fields: ~ + {id} (integer) Unique, auto-incrementing task identifier. + {description} (string) Task text as shown in the buffer. + {status} (string) `'pending'`, `'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. + {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. + {order} (integer) Relative ordering within a category. + +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 +data loss. + +The `version` field is checked on load. If the file version is newer than the +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: diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index e67b64b..d7f6b4c 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -88,9 +88,25 @@ local function apply_extmarks(bufnr, line_meta) for i, m in ipairs(line_meta) do local row = i - 1 if m.type == 'task' then - if m.due 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 } } + end + if virt_text then + vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, { + virt_text = virt_text, + virt_text_pos = 'right_align', + }) + end + elseif m.due then vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, { - virt_text = { { m.due, 'PendingDue' } }, + virt_text = { { m.due, due_hl } }, virt_text_pos = 'right_align', }) end @@ -120,6 +136,7 @@ local function setup_highlights() end hl('PendingHeader', { bold = true }) hl('PendingDue', { fg = '#888888', italic = true }) + hl('PendingOverdue', { fg = '#e06c75', italic = true }) hl('PendingDone', { strikethrough = true, fg = '#666666' }) hl('PendingPriority', { fg = '#e06c75', bold = true }) end diff --git a/lua/pending/config.lua b/lua/pending/config.lua index d486690..d137acb 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -8,7 +8,8 @@ ---@field default_category string ---@field date_format string ---@field date_syntax string ----@field gcal? task.GcalConfig +---@field category_order? string[] +---@field gcal? pending.GcalConfig ---@class pending.config local M = {} @@ -20,6 +21,7 @@ local defaults = { default_category = 'Inbox', date_format = '%b %d', date_syntax = 'due', + category_order = {}, } ---@type pending.Config? diff --git a/lua/pending/diff.lua b/lua/pending/diff.lua index 6d6c648..9f5e577 100644 --- a/lua/pending/diff.lua +++ b/lua/pending/diff.lua @@ -16,7 +16,7 @@ local M = {} ---@return string local function timestamp() - return os.date('!%Y-%m-%dT%H:%M:%SZ') + return os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]] end ---@param lines string[] diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 3c2d59b..cb930d0 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -41,6 +41,15 @@ function M._setup_buf_mappings(bufnr) 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) end ---@param bufnr integer @@ -51,6 +60,17 @@ function M._on_write(bufnr) buffer.render(bufnr) end +function M.undo_write() + if not _undo_state then + vim.notify('Nothing to undo.', vim.log.levels.WARN) + return + end + store.replace_tasks(_undo_state) + store.save() + _undo_state = nil + buffer.render(buffer.bufnr()) +end + function M.toggle_complete() local bufnr = buffer.bufnr() if not bufnr then @@ -62,6 +82,9 @@ function M.toggle_complete() return end local id = meta[row].id + if not id then + return + end local task = store.get(id) if not task then return @@ -86,6 +109,9 @@ function M.toggle_priority() return end local id = meta[row].id + if not id then + return + end local task = store.get(id) if not task then return @@ -107,13 +133,19 @@ function M.prompt_date() return end local id = meta[row].id + if not id then + return + end vim.ui.input({ prompt = 'Due date (YYYY-MM-DD): ' }, function(input) if not input then return end local due = input ~= '' and input or nil if due then - if not due:match('^%d%d%d%d%-%d%d%-%d%d$') then + 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) return end @@ -170,12 +202,12 @@ function M.archive(days) local y, mo, d, h, mi, s = task['end']:match('^(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+)Z$') if y then local t = os.time({ - year = tonumber(y), - month = tonumber(mo), - day = tonumber(d), - hour = tonumber(h), - min = tonumber(mi), - sec = tonumber(s), + 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(s) --[[@as integer]], }) if t < cutoff then archived = archived + 1 @@ -203,6 +235,9 @@ function M.show_help() '', ' Toggle complete/uncomplete', ' Switch category/priority view', + '! Toggle priority', + 'd Set due date', + 'U Undo last write', 'o / O Add new task line', 'dd Delete task (on :w)', 'p / P Paste (duplicates get new IDs)', @@ -212,6 +247,7 @@ function M.show_help() ':Pending add Cat: Quick-add with category', ':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', @@ -256,6 +292,8 @@ function M.command(args) elseif cmd == 'archive' then local d = rest ~= '' and tonumber(rest) or nil M.archive(d) + elseif cmd == 'undo' then + M.undo_write() else vim.notify('Unknown Pending subcommand: ' .. cmd, vim.log.levels.ERROR) end diff --git a/lua/pending/parse.lua b/lua/pending/parse.lua index 722ba50..dfe9206 100644 --- a/lua/pending/parse.lua +++ b/lua/pending/parse.lua @@ -10,7 +10,10 @@ local function is_valid_date(s) if not y then return false end - y, m, d = tonumber(y), tonumber(m), tonumber(d) + y, m, d = + tonumber(y), --[[@as integer]] + tonumber(m), --[[@as integer]] + tonumber(d) --[[@as integer]] if m < 1 or m > 12 then return false end @@ -27,6 +30,60 @@ local function date_key() return config.get().date_syntax or 'due' end +local weekday_map = { + sun = 1, + mon = 2, + tue = 3, + wed = 4, + thu = 5, + fri = 6, + sat = 7, +} + +---@param text string +---@return string|nil +function M.resolve_date(text) + local lower = text:lower() + local today = os.date('*t') + + if lower == 'today' then + return os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day })) --[[@as string]] + 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]] + 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]] + 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]] + end + + return nil +end + ---@param text string ---@return string description ---@return { due?: string, cat?: string } metadata @@ -39,11 +96,12 @@ function M.body(text) local metadata = {} local i = #tokens local dk = date_key() - local date_pattern = '^' .. 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)$' + local date_pattern_any = '^' .. vim.pesc(dk) .. ':(.+)$' while i >= 1 do local token = tokens[i] - local due_val = token:match(date_pattern) + local due_val = token:match(date_pattern_strict) if due_val then if metadata.due then break @@ -54,15 +112,28 @@ function M.body(text) metadata.due = due_val i = i - 1 else - local cat_val = token:match('^cat:(%S+)$') - if cat_val then - if metadata.cat then + local raw_val = token:match(date_pattern_any) + if raw_val then + if metadata.due then break end - metadata.cat = cat_val + local resolved = M.resolve_date(raw_val) + if not resolved then + break + end + metadata.due = resolved i = i - 1 else - break + local cat_val = token:match('^cat:(%S+)$') + if cat_val then + if metadata.cat then + break + end + metadata.cat = cat_val + i = i - 1 + else + break + end end end end diff --git a/lua/pending/store.lua b/lua/pending/store.lua index 1ad3ad3..fae9e27 100644 --- a/lua/pending/store.lua +++ b/lua/pending/store.lua @@ -16,7 +16,7 @@ local config = require('pending.config') ---@class pending.Data ---@field version integer ---@field next_id integer ----@field tasks task.Task[] +---@field tasks pending.Task[] ---@class pending.store local M = {} @@ -45,7 +45,7 @@ end ---@return string local function timestamp() - return os.date('!%Y-%m-%dT%H:%M:%SZ') + return os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]] end ---@type table @@ -188,7 +188,7 @@ function M.data() if not _data then M.load() end - return _data + return _data --[[@as pending.Data]] end ---@return pending.Task[] diff --git a/lua/pending/sync/gcal.lua b/lua/pending/sync/gcal.lua index 30571cd..a5f57f3 100644 --- a/lua/pending/sync/gcal.lua +++ b/lua/pending/sync/gcal.lua @@ -313,7 +313,7 @@ local function find_or_create_calendar(access_token) return nil, err end - for _, item in ipairs(data.items or {}) do + for _, item in ipairs(data and data.items or {}) do if item.summary == cal_name then return item.id, nil end @@ -326,12 +326,13 @@ local function find_or_create_calendar(access_token) return nil, create_err end - return created.id, nil + return created and created.id, nil end local function next_day(date_str) local y, m, d = date_str:match('^(%d%d%d%d)-(%d%d)-(%d%d)$') - local t = os.time({ year = tonumber(y), month = tonumber(m), day = tonumber(d) }) + 86400 + 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) end @@ -354,7 +355,7 @@ local function create_event(access_token, calendar_id, task) if err then return nil, err end - return data.id, nil + return data and data.id, nil end local function update_event(access_token, calendar_id, event_id, task) @@ -416,7 +417,7 @@ function M.sync() else task._extra = extra end - task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') + task.modified = tostring(os.date('!%Y-%m-%dT%H:%M:%SZ')) deleted = deleted + 1 end elseif task.status == 'pending' and task.due then @@ -432,7 +433,7 @@ function M.sync() task._extra = {} end task._extra._gcal_event_id = new_id - task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') + task.modified = tostring(os.date('!%Y-%m-%dT%H:%M:%SZ')) created = created + 1 end end diff --git a/lua/pending/views.lua b/lua/pending/views.lua index 6e6fc4f..1e599f5 100644 --- a/lua/pending/views.lua +++ b/lua/pending/views.lua @@ -7,6 +7,8 @@ local config = require('pending.config') ---@field raw_due? string ---@field status? string ---@field category? string +---@field overdue? boolean +---@field show_category? boolean ---@class pending.views local M = {} @@ -21,8 +23,12 @@ local function format_due(due) if not y then return due end - local t = os.time({ year = tonumber(y), month = tonumber(m), day = tonumber(d) }) - return os.date(config.get().date_format, t) + local t = os.time({ + year = tonumber(y) --[[@as integer]], + month = tonumber(m) --[[@as integer]], + day = tonumber(d) --[[@as integer]], + }) + return os.date(config.get().date_format, t) --[[@as string]] end ---@param tasks pending.Task[] @@ -66,6 +72,7 @@ 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 = {} @@ -86,6 +93,24 @@ function M.category_view(tasks) end end + local cfg_order = config.get().category_order + if cfg_order and #cfg_order > 0 then + local ordered = {} + local seen = {} + for _, name in ipairs(cfg_order) do + if cat_seen[name] then + table.insert(ordered, name) + seen[name] = true + end + end + for _, name in ipairs(cat_order) do + if not seen[name] then + table.insert(ordered, name) + end + end + cat_order = ordered + end + for _, cat in ipairs(cat_order) do sort_tasks(by_cat[cat]) sort_tasks(done_by_cat[cat]) @@ -123,6 +148,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, }) end end @@ -134,6 +160,7 @@ 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 = {} @@ -172,6 +199,8 @@ 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, + show_category = true, }) end diff --git a/spec/archive_spec.lua b/spec/archive_spec.lua new file mode 100644 index 0000000..a71eee8 --- /dev/null +++ b/spec/archive_spec.lua @@ -0,0 +1,131 @@ +require('spec.helpers') + +local config = require('pending.config') +local store = require('pending.store') + +describe('archive', 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('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' }) + pending.archive() + assert.are.equal(0, #store.active_tasks()) + end) + + it('keeps done tasks completed fewer than 30 days ago', function() + 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 }) + pending.archive() + local active = store.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 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 }) + pending.archive(7) + assert.are.equal(0, #store.active_tasks()) + end) + + it('keeps tasks within the custom day cutoff', function() + 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 }) + pending.archive(7) + local active = store.active_tasks() + assert.are.equal(1, #active) + end) + + it('never archives pending tasks regardless of age', function() + store.add({ description = 'Still pending' }) + pending.archive() + local active = store.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' }) + pending.archive() + local all = store.tasks() + assert.are.equal(0, #all) + end) + + it('keeps deleted tasks within the cutoff', function() + 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 }) + pending.archive() + local all = store.tasks() + assert.are.equal(1, #all) + end) + + it('reports the correct count in vim.notify', function() + local messages = {} + local orig_notify = vim.notify + vim.notify = function(msg, ...) + table.insert(messages, msg) + 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' }) + + pending.archive() + + vim.notify = orig_notify + + local found = false + for _, msg in ipairs(messages) do + if msg:find('Archived 2') then + found = true + break + end + end + assert.is_true(found) + 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 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 }) + + pending.archive() + + local active = store.active_tasks() + assert.are.equal(2, #active) + local descs = {} + for _, task in ipairs(active) do + descs[task.description] = true + end + assert.is_true(descs['Keep pending']) + assert.is_true(descs['Keep recent done']) + end) +end) diff --git a/spec/views_spec.lua b/spec/views_spec.lua new file mode 100644 index 0000000..9ba12f9 --- /dev/null +++ b/spec/views_spec.lua @@ -0,0 +1,403 @@ +require('spec.helpers') + +local config = require('pending.config') +local store = require('pending.store') + +describe('views', function() + local tmpdir + 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() + 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()) + 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) + 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 pending_row, done_row + for i, m in ipairs(meta) do + if m.type == 'task' and m.status == 'pending' then + pending_row = i + elseif m.type == 'task' and m.status == 'done' then + done_row = i + end + end + assert.is_true(pending_row < done_row) + 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()) + local high_row, normal_row + for i, m in ipairs(meta) do + if m.type == 'task' then + local line = lines[i] + if line:find('High') then + high_row = i + elseif line:find('Normal') then + normal_row = i + end + end + end + assert.is_true(high_row < normal_row) + 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 high_row, normal_row + for i, m in ipairs(meta) do + if m.type == 'task' then + local line = lines[i] + if line:find('Done High') then + high_row = i + elseif line:find('Done Normal') then + normal_row = i + end + end + end + assert.is_true(high_row < normal_row) + 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()) + local headers = {} + local blank_found = false + for i, m in ipairs(meta) do + if m.type == 'header' then + table.insert(headers, lines[i]) + elseif m.type == 'blank' then + blank_found = true + end + end + assert.are.equal(2, #headers) + assert.is_true(blank_found) + 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()) + local task_line + for i, m in ipairs(meta) do + if m.type == 'task' then + task_line = lines[i] + end + end + assert.are.equal('/1/ My task', task_line) + 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()) + local task_line + for i, m in ipairs(meta) do + if m.type == 'task' then + task_line = lines[i] + end + end + assert.are.equal('/1/ ! Important', task_line) + 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()) + 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 task_meta + for _, m in ipairs(meta) do + if m.type == 'task' then + task_meta = m + end + end + assert.are.equal('task', task_meta.type) + assert.are.equal(t.id, task_meta.id) + assert.are.equal('pending', task_meta.status) + 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()) + local blank_meta + for _, m in ipairs(meta) do + if m.type == 'blank' then + blank_meta = m + break + end + end + assert.is_not_nil(blank_meta) + assert.are.equal('blank', blank_meta.type) + end) + + 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 task_meta + for _, m in ipairs(meta) do + if m.type == 'task' and m.id == t.id then + task_meta = m + end + end + assert.is_true(task_meta.overdue == true) + end) + + 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 task_meta + for _, m in ipairs(meta) do + if m.type == 'task' and m.id == t.id then + task_meta = m + end + end + assert.is_falsy(task_meta.overdue) + end) + + 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 task_meta + for _, m in ipairs(meta) do + if m.type == 'task' and m.id == t.id then + task_meta = m + end + end + assert.is_falsy(task_meta.overdue) + end) + + 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()) + local first_header, second_header + for i, m in ipairs(meta) do + if m.type == 'header' then + if not first_header then + first_header = lines[i] + else + second_header = lines[i] + end + end + end + assert.are.equal('Work', first_header) + assert.are.equal('Inbox', second_header) + end) + + 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()) + local headers = {} + for i, m in ipairs(meta) do + if m.type == 'header' then + table.insert(headers, lines[i]) + end + end + assert.are.equal('Work', headers[1]) + assert.are.equal('Errands', headers[2]) + 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()) + local headers = {} + for i, m in ipairs(meta) do + if m.type == 'header' then + table.insert(headers, lines[i]) + end + end + assert.are.equal('Alpha', headers[1]) + assert.are.equal('Beta', headers[2]) + end) + end) + + 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 last_pending_row, first_done_row + for i, m in ipairs(meta) do + if m.type == 'task' then + if m.status == 'pending' then + last_pending_row = i + elseif m.status == 'done' and not first_done_row then + first_done_row = i + end + end + end + assert.is_true(last_pending_row < first_done_row) + 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()) + local high_row, low_row + for i, m in ipairs(meta) do + if m.type == 'task' then + if lines[i]:find('High') then + high_row = i + elseif lines[i]:find('Low') then + low_row = i + end + end + end + assert.is_true(high_row < low_row) + 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()) + local due_row, nodue_row + for i, m in ipairs(meta) do + if m.type == 'task' then + if lines[i]:find('Has due') then + due_row = i + elseif lines[i]:find('No due') then + nodue_row = i + end + end + end + assert.is_true(due_row < nodue_row) + 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()) + local earlier_row, later_row + for i, m in ipairs(meta) do + if m.type == 'task' then + if lines[i]:find('Earlier') then + earlier_row = i + elseif lines[i]:find('Later') then + later_row = i + end + end + end + assert.is_true(earlier_row < later_row) + end) + + it('formats task lines as /ID/ description', function() + store.add({ description = 'My task', category = 'Inbox' }) + local lines, _ = views.priority_view(store.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()) + for _, m in ipairs(meta) do + if m.type == 'task' then + assert.is_true(m.show_category == true) + end + end + 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()) + local categories = {} + for i, m in ipairs(meta) do + if m.type == 'task' then + if lines[i]:find('Work task') then + categories['Work task'] = m.category + elseif lines[i]:find('Home task') then + categories['Home task'] = m.category + end + end + end + assert.are.equal('Work', categories['Work task']) + assert.are.equal('Home', categories['Home task']) + end) + + 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 task_meta + for _, m in ipairs(meta) do + if m.type == 'task' and m.id == t.id then + task_meta = m + end + end + assert.is_true(task_meta.overdue == true) + end) + + 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 task_meta + for _, m in ipairs(meta) do + if m.type == 'task' and m.id == t.id then + task_meta = m + end + end + assert.is_falsy(task_meta.overdue) + end) + + 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 task_meta + for _, m in ipairs(meta) do + if m.type == 'task' and m.id == t.id then + task_meta = m + end + end + assert.is_falsy(task_meta.overdue) + end) + end) +end) From 3a35fab6cfb7bf7d39d7f91464162a15359e360a Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Tue, 24 Feb 2026 18:33:07 -0500 Subject: [PATCH 046/199] feat: overdue highlighting, relative dates, undo write, buffer mappings (#1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(config): add category_order field Problem: category display order was always insertion order with no way to configure it. Solution: add category_order to config defaults so users can declare a preferred category ordering; unspecified categories append after. * feat(parse): add relative date resolution Problem: due dates required full YYYY-MM-DD input, adding friction for common cases like "today" or "next monday". Solution: add resolve_date() supporting today, tomorrow, +Nd, and weekday abbreviations; extend inline token parsing to resolve relative values before falling back to strict date validation. * feat(views): overdue flag, category in priority view, category ordering Problem: overdue tasks were visually indistinct from upcoming ones; priority view had no category context; category display order was not configurable. Solution: compute overdue meta flag for pending tasks past their due date; set show_category on priority view task meta; reorder categories according to config.category_order when present. * feat(buffer): overdue highlight, category virt text in priority view Problem: overdue tasks had no visual distinction; priority view showed no category context alongside due dates. Solution: add PendingOverdue highlight group; render category name as right-aligned virtual text in priority view, composited with the due date when both are present. * feat(init): undo write and buffer-local default mappings Problem: _undo_state was captured on every save but never consumed; toggle_priority and prompt_date had no buffer-local defaults, requiring manual configuration. Solution: implement undo_write() to restore pre-save task state; add !, d, and U as buffer-local defaults following fugitive's philosophy of owning the buffer; expose :Pending undo as a command alias. * test(views): add views spec Problem: views.lua had no test coverage. Solution: add 26 tests covering category_view and priority_view including sort order, line format, overdue detection, show_category meta, and category_order config behavior. * test(archive): add archive spec Problem: archive had no test coverage. Solution: add 9 tests covering cutoff logic, custom day counts, pending task preservation, deleted task cleanup, and notify output. * docs: add vimdoc Problem: no :help documentation existed. Solution: add doc/pending.txt covering all features — commands, mappings, views, configuration, Google Calendar sync, highlight groups, data format, and health check — following standard vimdoc conventions. * ci: format * fix: resolve lint and type check errors Problem: selene flagged unused variables in new spec files; LuaLS flagged os.date/os.time return type mismatches, integer? assignments, and stale task.Task/task.GcalConfig type references. Solution: prefix unused spec variables with _ or drop unnecessary assignments; add --[[@as string/integer]] casts for os.date and os.time calls; add category_order field to pending.Config annotation; fix task.GcalConfig -> pending.GcalConfig and task.Task[] -> pending.Task[]; add nil guards on meta[row].id before store calls; cast store.data() return to non-optional. * ci: format * fix: sync * ci: format --- doc/pending.txt | 415 ++++++++++++++++++++++++++++++++++++++ lua/pending/buffer.lua | 21 +- lua/pending/config.lua | 4 +- lua/pending/diff.lua | 2 +- lua/pending/init.lua | 52 ++++- lua/pending/parse.lua | 87 +++++++- lua/pending/store.lua | 6 +- lua/pending/sync/gcal.lua | 13 +- lua/pending/views.lua | 33 ++- spec/archive_spec.lua | 131 ++++++++++++ spec/views_spec.lua | 403 ++++++++++++++++++++++++++++++++++++ 11 files changed, 1137 insertions(+), 30 deletions(-) create mode 100644 doc/pending.txt create mode 100644 spec/archive_spec.lua create mode 100644 spec/views_spec.lua diff --git a/doc/pending.txt b/doc/pending.txt new file mode 100644 index 0000000..4986467 --- /dev/null +++ b/doc/pending.txt @@ -0,0 +1,415 @@ +*pending.txt* Buffer-centric task management for Neovim + +Author: Barrett Ruth +License: MIT + +============================================================================== +INTRODUCTION *pending.nvim* + +pending.nvim is a buffer-centric task manager for Neovim. Tasks live in a +plain, editable buffer — add with `o`, delete with `dd`, reorder with +`dd`/`p`, rename by typing. Writing the buffer with `:w` computes a diff +against the JSON store and applies only the changes. No floating windows, +no special UI, no abstraction between you and your tasks. + +The buffer looks like this: > + + School + ! Read chapter 5 Feb 28 + Submit homework Feb 25 + + Errands + Buy groceries Mar 01 + Clean apartment +< + +Category headers sit at column 0. Tasks are indented two spaces below them. +`!` marks a priority task. Due dates appear as right-aligned virtual text. +Completed tasks are rendered with strikethrough. Task IDs are embedded as +concealed tokens and are never visible during editing. + +Features: ~ +- Oil-style buffer editing: standard Vim motions for all task operations +- Inline metadata syntax: `due:` and `cat:` tokens parsed on `:w` +- Relative date input: `today`, `tomorrow`, `+Nd`, weekday names +- Two views: category (default) and priority flat list +- Single-level undo for the last `:w` save +- Quick-add from the command line with `:Pending add` +- Google Calendar one-way push via OAuth PKCE + +============================================================================== +REQUIREMENTS *pending-requirements* + +- Neovim 0.10+ +- No external dependencies for local use +- `curl` and `openssl` are required for Google Calendar sync + +============================================================================== +INSTALL *pending-install* + +Install with lazy.nvim: >lua + { 'barrettruth/pending.nvim' } +< + +Install with luarocks: >vim + luarocks install pending.nvim +< + +No `setup()` call is needed. The plugin loads automatically and works with +defaults. To customize behavior, set |vim.g.pending| before the plugin loads. +See |pending-config|. + +============================================================================== +USAGE *pending-usage* + +Open the task buffer: >vim + :Pending +< + +The buffer named `pending://` opens in the current window. From there, use +standard Vim editing: + +- `o` / `O` to add a new task line under or above the cursor +- `dd` to remove a task (deletion is applied on `:w`) +- `dd` + `p` to reorder tasks (pasted tasks receive new IDs) +- `:w` to save — all additions, deletions, and edits are diffed against the + store and committed atomically + +Buffer-local keys are set automatically when the buffer opens. See +|pending-mappings| for the full list. + +The buffer uses `buftype=acwrite` so `:w` always routes through pending.nvim's +write handler rather than writing to disk directly. The `pending://` buffer +persists across window switches; reopening with `:Pending` focuses the +existing window if one is open. + +============================================================================== +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: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` + `cat:Name` Move the task to the named category on save. + +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. + +Example: > + + Buy milk due:2026-03-15 cat:Errands +< + +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:` and one +`cat:` per task line are consumed. + +============================================================================== +COMMANDS *pending-commands* + + *:Pending* +:Pending + Open the task buffer. If the buffer is already displayed in a window, + focus that window. Equivalent to |(pending-open)|. + + *:Pending-add* +:Pending add {text} + Quick-add a task without opening the buffer. Inline metadata tokens in + {text} are parsed exactly as they are in the buffer. A `Category: ` prefix + (uppercase first letter, colon, space) assigns the category directly: >vim + :Pending add Buy groceries due:2026-03-15 + :Pending add School: Submit homework + :Pending add Errands: Pick up dry cleaning due:fri +< + If the buffer is currently open it is re-rendered after the add. + + *:Pending-archive* +:Pending archive [{days}] + Permanently remove done and deleted tasks whose completion timestamp is + older than {days} days. {days} defaults to 30 if not provided. >vim + :Pending archive " remove tasks completed more than 30 days ago + :Pending archive 7 " remove tasks completed more than 7 days ago +< + + *: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-undo* +:Pending undo + There is no `:Pending undo` subcommand. Use the `U` buffer-local key + (see |pending-mappings|) to undo the last `:w` save while the task buffer + is open. + +============================================================================== +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: ~ + + 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 + +Standard Vim keys `o`, `O`, `dd`, `p`, `P`, and `:w` work as expected. + + *(pending-open)* +(pending-open) + Open the task buffer. Maps to |:Pending| with no arguments. + + *(pending-toggle)* +(pending-toggle) + Toggle complete / uncomplete for the task under the cursor. + + *(pending-priority)* +(pending-priority) + Toggle the priority flag for the task under the cursor. + + *(pending-date)* +(pending-date) + Prompt for a due date for the task under the cursor. + + *(pending-view)* +(pending-view) + Switch between category view and priority view. + +Example configuration: >lua + vim.keymap.set('n', 't', '(pending-open)') + vim.keymap.set('n', 'T', '(pending-toggle)') +< + +============================================================================== +VIEWS *pending-views* + +Two views are available. Switch with `` or |(pending-view)|. + +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. + +Priority view: ~ *pending-view-priority* + 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. + +============================================================================== +CONFIGURATION *pending-config* + +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 = 'Inbox', + date_format = '%b %d', + date_syntax = 'due', + category_order = {}, + gcal = { + calendar = 'Tasks', + credentials_path = '/path/to/client_secret.json', + }, + } +< + +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. + Default: `stdpath('data') .. '/pending/tasks.json'`. + The directory is created automatically on first save. + + {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: 'Inbox') + Category assigned to new tasks when no `cat:` token + is present and no `Category: ` prefix is used with + `:Pending add`. + + {date_format} (string, default: '%b %d') + strftime format string used to render due dates as + virtual text in the buffer. Examples: `'%Y-%m-%d'` + for ISO dates, `'%d %b'` for day-first. + + {date_syntax} (string, default: 'due') + The token name for inline due-date metadata. Change + this to use a different keyword, for example `'by'` + to write `by:2026-03-15` instead of `due:2026-03-15`. + + {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. + + {gcal} (table, default: nil) + Google Calendar sync configuration. See + |pending.GcalConfig|. Omit this field entirely to + disable Google Calendar sync. + +============================================================================== +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. + +Configuration: >lua + vim.g.pending = { + gcal = { + calendar = 'Tasks', + credentials_path = '/path/to/client_secret.json', + }, + } +< + + *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. + + {credentials_path} (string) + Path to the 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. + +OAuth flow: ~ +On the first `:Pending sync` 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 +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 sync` 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. +- 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`. + +============================================================================== +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: bold. + + *PendingDue* +PendingDue Applied to the due date virtual text shown at the right + margin of each task line. + Default: fg=#888888, italic. + + *PendingDone* +PendingDone Applied to the text of completed tasks. + Default: strikethrough, fg=#666666. + + *PendingPriority* +PendingPriority Applied to the `! ` priority marker on priority tasks. + Default: fg=#e06c75, bold. + +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 +< + +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 +- Whether `curl` is available (required for Google Calendar sync) +- Whether `openssl` is available (required for OAuth PKCE) + +============================================================================== +DATA FORMAT *pending-data* + +Tasks are stored as JSON at `data_path`. 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. + +Schema: > + + { + "version": 1, + "next_id": 42, + "tasks": [ ... ] + } +< + +Task fields: ~ + {id} (integer) Unique, auto-incrementing task identifier. + {description} (string) Task text as shown in the buffer. + {status} (string) `'pending'`, `'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. + {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. + {order} (integer) Relative ordering within a category. + +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 +data loss. + +The `version` field is checked on load. If the file version is newer than the +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: diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index e67b64b..d7f6b4c 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -88,9 +88,25 @@ local function apply_extmarks(bufnr, line_meta) for i, m in ipairs(line_meta) do local row = i - 1 if m.type == 'task' then - if m.due 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 } } + end + if virt_text then + vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, { + virt_text = virt_text, + virt_text_pos = 'right_align', + }) + end + elseif m.due then vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, { - virt_text = { { m.due, 'PendingDue' } }, + virt_text = { { m.due, due_hl } }, virt_text_pos = 'right_align', }) end @@ -120,6 +136,7 @@ local function setup_highlights() end hl('PendingHeader', { bold = true }) hl('PendingDue', { fg = '#888888', italic = true }) + hl('PendingOverdue', { fg = '#e06c75', italic = true }) hl('PendingDone', { strikethrough = true, fg = '#666666' }) hl('PendingPriority', { fg = '#e06c75', bold = true }) end diff --git a/lua/pending/config.lua b/lua/pending/config.lua index d486690..d137acb 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -8,7 +8,8 @@ ---@field default_category string ---@field date_format string ---@field date_syntax string ----@field gcal? task.GcalConfig +---@field category_order? string[] +---@field gcal? pending.GcalConfig ---@class pending.config local M = {} @@ -20,6 +21,7 @@ local defaults = { default_category = 'Inbox', date_format = '%b %d', date_syntax = 'due', + category_order = {}, } ---@type pending.Config? diff --git a/lua/pending/diff.lua b/lua/pending/diff.lua index 6d6c648..9f5e577 100644 --- a/lua/pending/diff.lua +++ b/lua/pending/diff.lua @@ -16,7 +16,7 @@ local M = {} ---@return string local function timestamp() - return os.date('!%Y-%m-%dT%H:%M:%SZ') + return os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]] end ---@param lines string[] diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 3c2d59b..cb930d0 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -41,6 +41,15 @@ function M._setup_buf_mappings(bufnr) 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) end ---@param bufnr integer @@ -51,6 +60,17 @@ function M._on_write(bufnr) buffer.render(bufnr) end +function M.undo_write() + if not _undo_state then + vim.notify('Nothing to undo.', vim.log.levels.WARN) + return + end + store.replace_tasks(_undo_state) + store.save() + _undo_state = nil + buffer.render(buffer.bufnr()) +end + function M.toggle_complete() local bufnr = buffer.bufnr() if not bufnr then @@ -62,6 +82,9 @@ function M.toggle_complete() return end local id = meta[row].id + if not id then + return + end local task = store.get(id) if not task then return @@ -86,6 +109,9 @@ function M.toggle_priority() return end local id = meta[row].id + if not id then + return + end local task = store.get(id) if not task then return @@ -107,13 +133,19 @@ function M.prompt_date() return end local id = meta[row].id + if not id then + return + end vim.ui.input({ prompt = 'Due date (YYYY-MM-DD): ' }, function(input) if not input then return end local due = input ~= '' and input or nil if due then - if not due:match('^%d%d%d%d%-%d%d%-%d%d$') then + 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) return end @@ -170,12 +202,12 @@ function M.archive(days) local y, mo, d, h, mi, s = task['end']:match('^(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+)Z$') if y then local t = os.time({ - year = tonumber(y), - month = tonumber(mo), - day = tonumber(d), - hour = tonumber(h), - min = tonumber(mi), - sec = tonumber(s), + 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(s) --[[@as integer]], }) if t < cutoff then archived = archived + 1 @@ -203,6 +235,9 @@ function M.show_help() '', ' Toggle complete/uncomplete', ' Switch category/priority view', + '! Toggle priority', + 'd Set due date', + 'U Undo last write', 'o / O Add new task line', 'dd Delete task (on :w)', 'p / P Paste (duplicates get new IDs)', @@ -212,6 +247,7 @@ function M.show_help() ':Pending add Cat: Quick-add with category', ':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', @@ -256,6 +292,8 @@ function M.command(args) elseif cmd == 'archive' then local d = rest ~= '' and tonumber(rest) or nil M.archive(d) + elseif cmd == 'undo' then + M.undo_write() else vim.notify('Unknown Pending subcommand: ' .. cmd, vim.log.levels.ERROR) end diff --git a/lua/pending/parse.lua b/lua/pending/parse.lua index 722ba50..dfe9206 100644 --- a/lua/pending/parse.lua +++ b/lua/pending/parse.lua @@ -10,7 +10,10 @@ local function is_valid_date(s) if not y then return false end - y, m, d = tonumber(y), tonumber(m), tonumber(d) + y, m, d = + tonumber(y), --[[@as integer]] + tonumber(m), --[[@as integer]] + tonumber(d) --[[@as integer]] if m < 1 or m > 12 then return false end @@ -27,6 +30,60 @@ local function date_key() return config.get().date_syntax or 'due' end +local weekday_map = { + sun = 1, + mon = 2, + tue = 3, + wed = 4, + thu = 5, + fri = 6, + sat = 7, +} + +---@param text string +---@return string|nil +function M.resolve_date(text) + local lower = text:lower() + local today = os.date('*t') + + if lower == 'today' then + return os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day })) --[[@as string]] + 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]] + 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]] + 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]] + end + + return nil +end + ---@param text string ---@return string description ---@return { due?: string, cat?: string } metadata @@ -39,11 +96,12 @@ function M.body(text) local metadata = {} local i = #tokens local dk = date_key() - local date_pattern = '^' .. 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)$' + local date_pattern_any = '^' .. vim.pesc(dk) .. ':(.+)$' while i >= 1 do local token = tokens[i] - local due_val = token:match(date_pattern) + local due_val = token:match(date_pattern_strict) if due_val then if metadata.due then break @@ -54,15 +112,28 @@ function M.body(text) metadata.due = due_val i = i - 1 else - local cat_val = token:match('^cat:(%S+)$') - if cat_val then - if metadata.cat then + local raw_val = token:match(date_pattern_any) + if raw_val then + if metadata.due then break end - metadata.cat = cat_val + local resolved = M.resolve_date(raw_val) + if not resolved then + break + end + metadata.due = resolved i = i - 1 else - break + local cat_val = token:match('^cat:(%S+)$') + if cat_val then + if metadata.cat then + break + end + metadata.cat = cat_val + i = i - 1 + else + break + end end end end diff --git a/lua/pending/store.lua b/lua/pending/store.lua index 1ad3ad3..fae9e27 100644 --- a/lua/pending/store.lua +++ b/lua/pending/store.lua @@ -16,7 +16,7 @@ local config = require('pending.config') ---@class pending.Data ---@field version integer ---@field next_id integer ----@field tasks task.Task[] +---@field tasks pending.Task[] ---@class pending.store local M = {} @@ -45,7 +45,7 @@ end ---@return string local function timestamp() - return os.date('!%Y-%m-%dT%H:%M:%SZ') + return os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]] end ---@type table @@ -188,7 +188,7 @@ function M.data() if not _data then M.load() end - return _data + return _data --[[@as pending.Data]] end ---@return pending.Task[] diff --git a/lua/pending/sync/gcal.lua b/lua/pending/sync/gcal.lua index 30571cd..a5f57f3 100644 --- a/lua/pending/sync/gcal.lua +++ b/lua/pending/sync/gcal.lua @@ -313,7 +313,7 @@ local function find_or_create_calendar(access_token) return nil, err end - for _, item in ipairs(data.items or {}) do + for _, item in ipairs(data and data.items or {}) do if item.summary == cal_name then return item.id, nil end @@ -326,12 +326,13 @@ local function find_or_create_calendar(access_token) return nil, create_err end - return created.id, nil + return created and created.id, nil end local function next_day(date_str) local y, m, d = date_str:match('^(%d%d%d%d)-(%d%d)-(%d%d)$') - local t = os.time({ year = tonumber(y), month = tonumber(m), day = tonumber(d) }) + 86400 + 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) end @@ -354,7 +355,7 @@ local function create_event(access_token, calendar_id, task) if err then return nil, err end - return data.id, nil + return data and data.id, nil end local function update_event(access_token, calendar_id, event_id, task) @@ -416,7 +417,7 @@ function M.sync() else task._extra = extra end - task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') + task.modified = tostring(os.date('!%Y-%m-%dT%H:%M:%SZ')) deleted = deleted + 1 end elseif task.status == 'pending' and task.due then @@ -432,7 +433,7 @@ function M.sync() task._extra = {} end task._extra._gcal_event_id = new_id - task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') + task.modified = tostring(os.date('!%Y-%m-%dT%H:%M:%SZ')) created = created + 1 end end diff --git a/lua/pending/views.lua b/lua/pending/views.lua index 6e6fc4f..1e599f5 100644 --- a/lua/pending/views.lua +++ b/lua/pending/views.lua @@ -7,6 +7,8 @@ local config = require('pending.config') ---@field raw_due? string ---@field status? string ---@field category? string +---@field overdue? boolean +---@field show_category? boolean ---@class pending.views local M = {} @@ -21,8 +23,12 @@ local function format_due(due) if not y then return due end - local t = os.time({ year = tonumber(y), month = tonumber(m), day = tonumber(d) }) - return os.date(config.get().date_format, t) + local t = os.time({ + year = tonumber(y) --[[@as integer]], + month = tonumber(m) --[[@as integer]], + day = tonumber(d) --[[@as integer]], + }) + return os.date(config.get().date_format, t) --[[@as string]] end ---@param tasks pending.Task[] @@ -66,6 +72,7 @@ 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 = {} @@ -86,6 +93,24 @@ function M.category_view(tasks) end end + local cfg_order = config.get().category_order + if cfg_order and #cfg_order > 0 then + local ordered = {} + local seen = {} + for _, name in ipairs(cfg_order) do + if cat_seen[name] then + table.insert(ordered, name) + seen[name] = true + end + end + for _, name in ipairs(cat_order) do + if not seen[name] then + table.insert(ordered, name) + end + end + cat_order = ordered + end + for _, cat in ipairs(cat_order) do sort_tasks(by_cat[cat]) sort_tasks(done_by_cat[cat]) @@ -123,6 +148,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, }) end end @@ -134,6 +160,7 @@ 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 = {} @@ -172,6 +199,8 @@ 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, + show_category = true, }) end diff --git a/spec/archive_spec.lua b/spec/archive_spec.lua new file mode 100644 index 0000000..a71eee8 --- /dev/null +++ b/spec/archive_spec.lua @@ -0,0 +1,131 @@ +require('spec.helpers') + +local config = require('pending.config') +local store = require('pending.store') + +describe('archive', 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('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' }) + pending.archive() + assert.are.equal(0, #store.active_tasks()) + end) + + it('keeps done tasks completed fewer than 30 days ago', function() + 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 }) + pending.archive() + local active = store.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 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 }) + pending.archive(7) + assert.are.equal(0, #store.active_tasks()) + end) + + it('keeps tasks within the custom day cutoff', function() + 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 }) + pending.archive(7) + local active = store.active_tasks() + assert.are.equal(1, #active) + end) + + it('never archives pending tasks regardless of age', function() + store.add({ description = 'Still pending' }) + pending.archive() + local active = store.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' }) + pending.archive() + local all = store.tasks() + assert.are.equal(0, #all) + end) + + it('keeps deleted tasks within the cutoff', function() + 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 }) + pending.archive() + local all = store.tasks() + assert.are.equal(1, #all) + end) + + it('reports the correct count in vim.notify', function() + local messages = {} + local orig_notify = vim.notify + vim.notify = function(msg, ...) + table.insert(messages, msg) + 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' }) + + pending.archive() + + vim.notify = orig_notify + + local found = false + for _, msg in ipairs(messages) do + if msg:find('Archived 2') then + found = true + break + end + end + assert.is_true(found) + 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 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 }) + + pending.archive() + + local active = store.active_tasks() + assert.are.equal(2, #active) + local descs = {} + for _, task in ipairs(active) do + descs[task.description] = true + end + assert.is_true(descs['Keep pending']) + assert.is_true(descs['Keep recent done']) + end) +end) diff --git a/spec/views_spec.lua b/spec/views_spec.lua new file mode 100644 index 0000000..9ba12f9 --- /dev/null +++ b/spec/views_spec.lua @@ -0,0 +1,403 @@ +require('spec.helpers') + +local config = require('pending.config') +local store = require('pending.store') + +describe('views', function() + local tmpdir + 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() + 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()) + 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) + 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 pending_row, done_row + for i, m in ipairs(meta) do + if m.type == 'task' and m.status == 'pending' then + pending_row = i + elseif m.type == 'task' and m.status == 'done' then + done_row = i + end + end + assert.is_true(pending_row < done_row) + 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()) + local high_row, normal_row + for i, m in ipairs(meta) do + if m.type == 'task' then + local line = lines[i] + if line:find('High') then + high_row = i + elseif line:find('Normal') then + normal_row = i + end + end + end + assert.is_true(high_row < normal_row) + 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 high_row, normal_row + for i, m in ipairs(meta) do + if m.type == 'task' then + local line = lines[i] + if line:find('Done High') then + high_row = i + elseif line:find('Done Normal') then + normal_row = i + end + end + end + assert.is_true(high_row < normal_row) + 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()) + local headers = {} + local blank_found = false + for i, m in ipairs(meta) do + if m.type == 'header' then + table.insert(headers, lines[i]) + elseif m.type == 'blank' then + blank_found = true + end + end + assert.are.equal(2, #headers) + assert.is_true(blank_found) + 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()) + local task_line + for i, m in ipairs(meta) do + if m.type == 'task' then + task_line = lines[i] + end + end + assert.are.equal('/1/ My task', task_line) + 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()) + local task_line + for i, m in ipairs(meta) do + if m.type == 'task' then + task_line = lines[i] + end + end + assert.are.equal('/1/ ! Important', task_line) + 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()) + 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 task_meta + for _, m in ipairs(meta) do + if m.type == 'task' then + task_meta = m + end + end + assert.are.equal('task', task_meta.type) + assert.are.equal(t.id, task_meta.id) + assert.are.equal('pending', task_meta.status) + 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()) + local blank_meta + for _, m in ipairs(meta) do + if m.type == 'blank' then + blank_meta = m + break + end + end + assert.is_not_nil(blank_meta) + assert.are.equal('blank', blank_meta.type) + end) + + 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 task_meta + for _, m in ipairs(meta) do + if m.type == 'task' and m.id == t.id then + task_meta = m + end + end + assert.is_true(task_meta.overdue == true) + end) + + 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 task_meta + for _, m in ipairs(meta) do + if m.type == 'task' and m.id == t.id then + task_meta = m + end + end + assert.is_falsy(task_meta.overdue) + end) + + 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 task_meta + for _, m in ipairs(meta) do + if m.type == 'task' and m.id == t.id then + task_meta = m + end + end + assert.is_falsy(task_meta.overdue) + end) + + 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()) + local first_header, second_header + for i, m in ipairs(meta) do + if m.type == 'header' then + if not first_header then + first_header = lines[i] + else + second_header = lines[i] + end + end + end + assert.are.equal('Work', first_header) + assert.are.equal('Inbox', second_header) + end) + + 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()) + local headers = {} + for i, m in ipairs(meta) do + if m.type == 'header' then + table.insert(headers, lines[i]) + end + end + assert.are.equal('Work', headers[1]) + assert.are.equal('Errands', headers[2]) + 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()) + local headers = {} + for i, m in ipairs(meta) do + if m.type == 'header' then + table.insert(headers, lines[i]) + end + end + assert.are.equal('Alpha', headers[1]) + assert.are.equal('Beta', headers[2]) + end) + end) + + 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 last_pending_row, first_done_row + for i, m in ipairs(meta) do + if m.type == 'task' then + if m.status == 'pending' then + last_pending_row = i + elseif m.status == 'done' and not first_done_row then + first_done_row = i + end + end + end + assert.is_true(last_pending_row < first_done_row) + 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()) + local high_row, low_row + for i, m in ipairs(meta) do + if m.type == 'task' then + if lines[i]:find('High') then + high_row = i + elseif lines[i]:find('Low') then + low_row = i + end + end + end + assert.is_true(high_row < low_row) + 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()) + local due_row, nodue_row + for i, m in ipairs(meta) do + if m.type == 'task' then + if lines[i]:find('Has due') then + due_row = i + elseif lines[i]:find('No due') then + nodue_row = i + end + end + end + assert.is_true(due_row < nodue_row) + 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()) + local earlier_row, later_row + for i, m in ipairs(meta) do + if m.type == 'task' then + if lines[i]:find('Earlier') then + earlier_row = i + elseif lines[i]:find('Later') then + later_row = i + end + end + end + assert.is_true(earlier_row < later_row) + end) + + it('formats task lines as /ID/ description', function() + store.add({ description = 'My task', category = 'Inbox' }) + local lines, _ = views.priority_view(store.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()) + for _, m in ipairs(meta) do + if m.type == 'task' then + assert.is_true(m.show_category == true) + end + end + 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()) + local categories = {} + for i, m in ipairs(meta) do + if m.type == 'task' then + if lines[i]:find('Work task') then + categories['Work task'] = m.category + elseif lines[i]:find('Home task') then + categories['Home task'] = m.category + end + end + end + assert.are.equal('Work', categories['Work task']) + assert.are.equal('Home', categories['Home task']) + end) + + 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 task_meta + for _, m in ipairs(meta) do + if m.type == 'task' and m.id == t.id then + task_meta = m + end + end + assert.is_true(task_meta.overdue == true) + end) + + 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 task_meta + for _, m in ipairs(meta) do + if m.type == 'task' and m.id == t.id then + task_meta = m + end + end + assert.is_falsy(task_meta.overdue) + end) + + 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 task_meta + for _, m in ipairs(meta) do + if m.type == 'task' and m.id == t.id then + task_meta = m + end + end + assert.is_falsy(task_meta.overdue) + end) + end) +end) From 68dbea7d52c849cf21e70ff31a9e8a64b4b23f35 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Tue, 24 Feb 2026 18:40:46 -0500 Subject: [PATCH 047/199] fix(parse): cast os.date('*t') return to osdate (#3) Problem: LuaLS types os.date('*t') as string|osdate, causing type errors when accessing .year, .month, .day, .wday fields in is_valid_date and resolve_date. Solution: add --[[@as osdate]] casts on both os.date('*t') calls. --- lua/pending/parse.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lua/pending/parse.lua b/lua/pending/parse.lua index dfe9206..b72214f 100644 --- a/lua/pending/parse.lua +++ b/lua/pending/parse.lua @@ -21,7 +21,7 @@ local function is_valid_date(s) return false end local t = os.time({ year = y, month = m, day = d }) - local check = os.date('*t', t) + local check = os.date('*t', t) --[[@as osdate]] return check.year == y and check.month == m and check.day == d end @@ -44,7 +44,7 @@ local weekday_map = { ---@return string|nil function M.resolve_date(text) local lower = text:lower() - local today = os.date('*t') + 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]] From ce8decc2b0894837ecd2886af5d3af2248bde07c Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Tue, 24 Feb 2026 18:40:46 -0500 Subject: [PATCH 048/199] fix(parse): cast os.date('*t') return to osdate (#3) Problem: LuaLS types os.date('*t') as string|osdate, causing type errors when accessing .year, .month, .day, .wday fields in is_valid_date and resolve_date. Solution: add --[[@as osdate]] casts on both os.date('*t') calls. --- lua/pending/parse.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lua/pending/parse.lua b/lua/pending/parse.lua index dfe9206..b72214f 100644 --- a/lua/pending/parse.lua +++ b/lua/pending/parse.lua @@ -21,7 +21,7 @@ local function is_valid_date(s) return false end local t = os.time({ year = y, month = m, day = d }) - local check = os.date('*t', t) + local check = os.date('*t', t) --[[@as osdate]] return check.year == y and check.month == m and check.day == d end @@ -44,7 +44,7 @@ local weekday_map = { ---@return string|nil function M.resolve_date(text) local lower = text:lower() - local today = os.date('*t') + 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]] From fc45ca3fcd4af549017002bc25275507c0a9856c Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 18:29:46 -0500 Subject: [PATCH 049/199] fix(gcal): add LuaCATS annotations and resolve type errors Problem: gcal.lua had ~10 LuaLS errors from untyped credential and token tables, string|osdate casts, and untyped _gcal_event_id field access. Solution: add pending.GcalCredentials and pending.GcalTokens class definitions, annotate all local functions with @param/@return, add --[[@as string]] casts on os.date returns, and fix _gcal_event_id access to use bracket notation with casts. --- lua/pending/sync/gcal.lua | 92 ++++++++++++++++++++++++++++++++------- 1 file changed, 76 insertions(+), 16 deletions(-) diff --git a/lua/pending/sync/gcal.lua b/lua/pending/sync/gcal.lua index a5f57f3..f3c84e2 100644 --- a/lua/pending/sync/gcal.lua +++ b/lua/pending/sync/gcal.lua @@ -8,20 +8,36 @@ 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[] + +---@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.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 @@ -39,6 +55,9 @@ local function load_json_file(path) 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 @@ -54,31 +73,43 @@ local function save_json_file(path, data) 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 + return creds.installed --[[@as pending.GcalCredentials]] end - return creds + return creds --[[@as pending.GcalCredentials]] end +---@return pending.GcalTokens? local function load_tokens() - return load_json_file(token_path()) + 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 @@ -107,6 +138,8 @@ local function curl_request(method, url, headers, body) return decoded, nil end +---@param access_token string +---@return string[] local function auth_headers(access_token) return { 'Authorization: Bearer ' .. access_token, @@ -114,6 +147,9 @@ local function auth_headers(access_token) } 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) @@ -142,13 +178,14 @@ local function refresh_access_token(creds, tokens) if not ok or not decoded.access_token then return nil end - tokens.access_token = decoded.access_token - tokens.expires_in = decoded.expires_in + 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 @@ -260,6 +297,10 @@ function M.authorize() 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) @@ -303,6 +344,9 @@ function M._exchange_code(creds, code, code_verifier, port) 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 cal_name = gc.calendar or 'Pendings' @@ -329,18 +373,25 @@ local function find_or_create_calendar(access_token) return created and created.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 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) + return os.date('%Y-%m-%d', t) --[[@as string]] end +---@param access_token string +---@param calendar_id string +---@param task pending.Task +---@return string? event_id +---@return string? err local function create_event(access_token, calendar_id, task) local event = { summary = task.description, start = { date = task.due }, - ['end'] = { date = next_day(task.due) }, + ['end'] = { date = next_day(task.due or '') }, transparency = 'transparent', extendedProperties = { private = { taskId = tostring(task.id) }, @@ -358,11 +409,16 @@ local function create_event(access_token, calendar_id, task) return data and data.id, nil end +---@param access_token string +---@param calendar_id string +---@param event_id string +---@param task pending.Task +---@return string? err local function update_event(access_token, calendar_id, event_id, task) local event = { summary = task.description, start = { date = task.due }, - ['end'] = { date = next_day(task.due) }, + ['end'] = { date = next_day(task.due or '') }, } local _, err = curl_request( 'PATCH', @@ -373,6 +429,10 @@ local function update_event(access_token, calendar_id, event_id, task) return err end +---@param access_token string +---@param calendar_id string +---@param event_id string +---@return string? err local function delete_event(access_token, calendar_id, event_id) local _, err = curl_request( 'DELETE', @@ -399,25 +459,25 @@ function M.sync() for _, task in ipairs(tasks) do local extra = task._extra or {} - local event_id = extra._gcal_event_id + local event_id = extra['_gcal_event_id'] --[[@as string?]] - local should_delete = event_id + local should_delete = event_id ~= nil and ( task.status == 'done' or task.status == 'deleted' or (task.status == 'pending' and not task.due) ) - if should_delete then - local del_err = delete_event(access_token, calendar_id, event_id) + 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 + extra['_gcal_event_id'] = nil if next(extra) == nil then task._extra = nil else task._extra = extra end - task.modified = tostring(os.date('!%Y-%m-%dT%H:%M:%SZ')) + task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]] deleted = deleted + 1 end elseif task.status == 'pending' and task.due then @@ -432,8 +492,8 @@ function M.sync() if not task._extra then task._extra = {} end - task._extra._gcal_event_id = new_id - task.modified = tostring(os.date('!%Y-%m-%dT%H:%M:%SZ')) + task._extra['_gcal_event_id'] = new_id + task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]] created = created + 1 end end From 00ea92ce33e13e3b37f0ce4f2aa3ddd765ecbe66 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 18:29:46 -0500 Subject: [PATCH 050/199] fix(gcal): add LuaCATS annotations and resolve type errors Problem: gcal.lua had ~10 LuaLS errors from untyped credential and token tables, string|osdate casts, and untyped _gcal_event_id field access. Solution: add pending.GcalCredentials and pending.GcalTokens class definitions, annotate all local functions with @param/@return, add --[[@as string]] casts on os.date returns, and fix _gcal_event_id access to use bracket notation with casts. --- lua/pending/sync/gcal.lua | 92 ++++++++++++++++++++++++++++++++------- 1 file changed, 76 insertions(+), 16 deletions(-) diff --git a/lua/pending/sync/gcal.lua b/lua/pending/sync/gcal.lua index a5f57f3..f3c84e2 100644 --- a/lua/pending/sync/gcal.lua +++ b/lua/pending/sync/gcal.lua @@ -8,20 +8,36 @@ 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[] + +---@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.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 @@ -39,6 +55,9 @@ local function load_json_file(path) 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 @@ -54,31 +73,43 @@ local function save_json_file(path, data) 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 + return creds.installed --[[@as pending.GcalCredentials]] end - return creds + return creds --[[@as pending.GcalCredentials]] end +---@return pending.GcalTokens? local function load_tokens() - return load_json_file(token_path()) + 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 @@ -107,6 +138,8 @@ local function curl_request(method, url, headers, body) return decoded, nil end +---@param access_token string +---@return string[] local function auth_headers(access_token) return { 'Authorization: Bearer ' .. access_token, @@ -114,6 +147,9 @@ local function auth_headers(access_token) } 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) @@ -142,13 +178,14 @@ local function refresh_access_token(creds, tokens) if not ok or not decoded.access_token then return nil end - tokens.access_token = decoded.access_token - tokens.expires_in = decoded.expires_in + 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 @@ -260,6 +297,10 @@ function M.authorize() 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) @@ -303,6 +344,9 @@ function M._exchange_code(creds, code, code_verifier, port) 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 cal_name = gc.calendar or 'Pendings' @@ -329,18 +373,25 @@ local function find_or_create_calendar(access_token) return created and created.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 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) + return os.date('%Y-%m-%d', t) --[[@as string]] end +---@param access_token string +---@param calendar_id string +---@param task pending.Task +---@return string? event_id +---@return string? err local function create_event(access_token, calendar_id, task) local event = { summary = task.description, start = { date = task.due }, - ['end'] = { date = next_day(task.due) }, + ['end'] = { date = next_day(task.due or '') }, transparency = 'transparent', extendedProperties = { private = { taskId = tostring(task.id) }, @@ -358,11 +409,16 @@ local function create_event(access_token, calendar_id, task) return data and data.id, nil end +---@param access_token string +---@param calendar_id string +---@param event_id string +---@param task pending.Task +---@return string? err local function update_event(access_token, calendar_id, event_id, task) local event = { summary = task.description, start = { date = task.due }, - ['end'] = { date = next_day(task.due) }, + ['end'] = { date = next_day(task.due or '') }, } local _, err = curl_request( 'PATCH', @@ -373,6 +429,10 @@ local function update_event(access_token, calendar_id, event_id, task) return err end +---@param access_token string +---@param calendar_id string +---@param event_id string +---@return string? err local function delete_event(access_token, calendar_id, event_id) local _, err = curl_request( 'DELETE', @@ -399,25 +459,25 @@ function M.sync() for _, task in ipairs(tasks) do local extra = task._extra or {} - local event_id = extra._gcal_event_id + local event_id = extra['_gcal_event_id'] --[[@as string?]] - local should_delete = event_id + local should_delete = event_id ~= nil and ( task.status == 'done' or task.status == 'deleted' or (task.status == 'pending' and not task.due) ) - if should_delete then - local del_err = delete_event(access_token, calendar_id, event_id) + 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 + extra['_gcal_event_id'] = nil if next(extra) == nil then task._extra = nil else task._extra = extra end - task.modified = tostring(os.date('!%Y-%m-%dT%H:%M:%SZ')) + task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]] deleted = deleted + 1 end elseif task.status == 'pending' and task.due then @@ -432,8 +492,8 @@ function M.sync() if not task._extra then task._extra = {} end - task._extra._gcal_event_id = new_id - task.modified = tostring(os.date('!%Y-%m-%dT%H:%M:%SZ')) + task._extra['_gcal_event_id'] = new_id + task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]] created = created + 1 end end From 4e9ce2bc8a229f05165135235c4f4f61d4e3cefa Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 18:29:49 -0500 Subject: [PATCH 051/199] feat(store): add snapshot() and atomic json write Problem: the single-level undo used shallow references so mutations during diff.apply() corrupted the saved state. JSON writes were also non-atomic, risking partial writes on crash. Solution: add M.snapshot() which deep-copies active tasks (including _extra). Change M.save() to write a .tmp file then rename atomically. --- lua/pending/store.lua | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/lua/pending/store.lua b/lua/pending/store.lua index fae9e27..5838414 100644 --- a/lua/pending/store.lua +++ b/lua/pending/store.lua @@ -175,12 +175,18 @@ function M.save() table.insert(out.tasks, task_to_table(task)) end local encoded = vim.json.encode(out) - local f = io.open(path, 'w') + local tmp = path .. '.tmp' + local f = io.open(tmp, 'w') if not f then - error('pending.nvim: cannot write to ' .. path) + error('pending.nvim: cannot write to ' .. tmp) end f:write(encoded) f:close() + local ok, rename_err = os.rename(tmp, path) + if not ok then + os.remove(tmp) + error('pending.nvim: cannot rename ' .. tmp .. ' to ' .. path .. ': ' .. tostring(rename_err)) + end end ---@return pending.Data @@ -284,6 +290,27 @@ function M.replace_tasks(tasks) M.data().tasks = tasks end +---@return pending.Task[] +function M.snapshot() + local result = {} + for _, task in ipairs(M.active_tasks()) do + local copy = {} + for k, v in pairs(task) do + if k ~= '_extra' then + copy[k] = v + end + end + if task._extra then + copy._extra = {} + for k, v in pairs(task._extra) do + copy._extra[k] = v + end + end + table.insert(result, copy --[[@as pending.Task]]) + end + return result +end + ---@param id integer function M.set_next_id(id) M.data().next_id = id From c8c35ab534565f290a4457162666ff7333d71431 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 18:29:49 -0500 Subject: [PATCH 052/199] feat(store): add snapshot() and atomic json write Problem: the single-level undo used shallow references so mutations during diff.apply() corrupted the saved state. JSON writes were also non-atomic, risking partial writes on crash. Solution: add M.snapshot() which deep-copies active tasks (including _extra). Change M.save() to write a .tmp file then rename atomically. --- lua/pending/store.lua | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/lua/pending/store.lua b/lua/pending/store.lua index fae9e27..5838414 100644 --- a/lua/pending/store.lua +++ b/lua/pending/store.lua @@ -175,12 +175,18 @@ function M.save() table.insert(out.tasks, task_to_table(task)) end local encoded = vim.json.encode(out) - local f = io.open(path, 'w') + local tmp = path .. '.tmp' + local f = io.open(tmp, 'w') if not f then - error('pending.nvim: cannot write to ' .. path) + error('pending.nvim: cannot write to ' .. tmp) end f:write(encoded) f:close() + local ok, rename_err = os.rename(tmp, path) + if not ok then + os.remove(tmp) + error('pending.nvim: cannot rename ' .. tmp .. ' to ' .. path .. ': ' .. tostring(rename_err)) + end end ---@return pending.Data @@ -284,6 +290,27 @@ function M.replace_tasks(tasks) M.data().tasks = tasks end +---@return pending.Task[] +function M.snapshot() + local result = {} + for _, task in ipairs(M.active_tasks()) do + local copy = {} + for k, v in pairs(task) do + if k ~= '_extra' then + copy[k] = v + end + end + if task._extra then + copy._extra = {} + for k, v in pairs(task._extra) do + copy._extra[k] = v + end + end + table.insert(result, copy --[[@as pending.Task]]) + end + return result +end + ---@param id integer function M.set_next_id(id) M.data().next_id = id From 40ebd0ebb2e9c0ff105d30bf0a7b4f9edac4ba99 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 18:29:54 -0500 Subject: [PATCH 053/199] feat(buffer): add category folds via foldexpr Problem: category_view had no fold support, making it harder to focus on one category in large lists. Solution: add M.get_fold() returning '>1' for headers, '1' for task lines, and '0' for blanks. M.render() now sets foldmethod=expr (foldlevel=99) in category view and foldmethod=manual in priority. --- lua/pending/buffer.lua | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index d7f6b4c..e36972e 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -81,6 +81,22 @@ function M.get_indent() return 2 end +---@return string +function M.get_fold() + local lnum = vim.v.lnum + local m = _meta[lnum] + if not m then + return '0' + end + if m.type == 'header' then + return '>1' + elseif m.type == 'task' then + return '1' + else + return '0' + end +end + ---@param bufnr integer ---@param line_meta pending.LineMeta[] local function apply_extmarks(bufnr, line_meta) @@ -166,6 +182,18 @@ function M.render(bufnr) setup_syntax(bufnr) apply_extmarks(bufnr, line_meta) + + for _, winid in ipairs(vim.fn.win_findbuf(bufnr)) do + if current_view == 'category' 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 + else + vim.wo[winid].foldmethod = 'manual' + vim.wo[winid].foldenable = false + end + end end function M.toggle_view() From ebe9368fad43cfded1147e3ab2bc215f66f3d3f4 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 18:29:54 -0500 Subject: [PATCH 054/199] feat(buffer): add category folds via foldexpr Problem: category_view had no fold support, making it harder to focus on one category in large lists. Solution: add M.get_fold() returning '>1' for headers, '1' for task lines, and '0' for blanks. M.render() now sets foldmethod=expr (foldlevel=99) in category view and foldmethod=manual in priority. --- lua/pending/buffer.lua | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index d7f6b4c..e36972e 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -81,6 +81,22 @@ function M.get_indent() return 2 end +---@return string +function M.get_fold() + local lnum = vim.v.lnum + local m = _meta[lnum] + if not m then + return '0' + end + if m.type == 'header' then + return '>1' + elseif m.type == 'task' then + return '1' + else + return '0' + end +end + ---@param bufnr integer ---@param line_meta pending.LineMeta[] local function apply_extmarks(bufnr, line_meta) @@ -166,6 +182,18 @@ function M.render(bufnr) setup_syntax(bufnr) apply_extmarks(bufnr, line_meta) + + for _, winid in ipairs(vim.fn.win_findbuf(bufnr)) do + if current_view == 'category' 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 + else + vim.wo[winid].foldmethod = 'manual' + vim.wo[winid].foldenable = false + end + end end function M.toggle_view() From 87eedc8610151205f2a33f515019babf146229ba Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 18:30:00 -0500 Subject: [PATCH 055/199] feat(init): multi-level undo, quickfix due, BufEnter reload Problem: undo was single-level with shallow references; no way to query due/overdue tasks via quickfix; two instances sharing tasks.json would diverge silently. Solution: replace _undo_state with _undo_states[] (cap 20, deep copies via store.snapshot()); add M.due() which populates the quickfix list with overdue/due-today tasks; add BufEnter autocmd that reloads from disk when the buffer is unmodified; expand show_help() with folds, :Pending due, relative date syntax, PendingOverdue, and empty-input date clearing. --- lua/pending/init.lua | 89 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 79 insertions(+), 10 deletions(-) diff --git a/lua/pending/init.lua b/lua/pending/init.lua index cb930d0..5db6a0c 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -3,11 +3,12 @@ local diff = require('pending.diff') local parse = require('pending.parse') local store = require('pending.store') ----@class task +---@class pending.init local M = {} ----@type pending.Task[]? -local _undo_state = nil +---@type pending.Task[][] +local _undo_states = {} +local UNDO_MAX = 20 ---@return integer bufnr function M.open() @@ -27,6 +28,16 @@ function M._setup_autocmds(bufnr) M._on_write(bufnr) end, }) + vim.api.nvim_create_autocmd('BufEnter', { + group = group, + buffer = bufnr, + callback = function() + if not vim.bo[bufnr].modified then + store.load() + buffer.render(bufnr) + end + end, + }) end ---@param bufnr integer @@ -55,19 +66,23 @@ end ---@param bufnr integer function M._on_write(bufnr) local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - _undo_state = store.active_tasks() + local snapshot = store.snapshot() + table.insert(_undo_states, snapshot) + if #_undo_states > UNDO_MAX then + table.remove(_undo_states, 1) + end diff.apply(lines) buffer.render(bufnr) end function M.undo_write() - if not _undo_state then + if #_undo_states == 0 then vim.notify('Nothing to undo.', vim.log.levels.WARN) return end - store.replace_tasks(_undo_state) + local state = table.remove(_undo_states) + store.replace_tasks(state) store.save() - _undo_state = nil buffer.render(buffer.bufnr()) end @@ -227,6 +242,49 @@ function M.archive(days) end end +function M.due() + local today = os.date('%Y-%m-%d') --[[@as string]] + local bufnr = buffer.bufnr() + local is_valid = bufnr ~= nil and vim.api.nvim_buf_is_valid(bufnr) + local meta = is_valid and buffer.meta() or nil + local qf_items = {} + + 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 + local task = store.get(m.id or 0) + local label = m.raw_due < today and '[OVERDUE] ' or '[DUE] ' + table.insert(qf_items, { + bufnr = bufnr, + lnum = lnum, + col = 1, + text = label .. (task and task.description or ''), + }) + end + end + 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] ' + local text = label .. task.description + if task.category then + text = text .. ' [' .. task.category .. ']' + end + table.insert(qf_items, { text = text }) + end + end + end + + if #qf_items == 0 then + vim.notify('No due or overdue tasks.') + return + end + + vim.fn.setqflist(qf_items, 'r') + vim.cmd('copen') +end + function M.show_help() local cfg = require('pending.config').get() local dk = cfg.date_syntax or 'due' @@ -239,27 +297,36 @@ function M.show_help() 'd Set due date', 'U Undo last write', 'o / O Add new task line', - 'dd Delete task (on :w)', + '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', + ':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)', + '', '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 = 50 + local width = 54 local height = #lines local win = vim.api.nvim_open_win(buf, true, { relative = 'editor', @@ -292,6 +359,8 @@ function M.command(args) elseif cmd == 'archive' then local d = rest ~= '' and tonumber(rest) or nil M.archive(d) + elseif cmd == 'due' then + M.due() elseif cmd == 'undo' then M.undo_write() else From 0522cf3263d0825e66aaa8f37bec6eea1bdef648 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 18:30:00 -0500 Subject: [PATCH 056/199] feat(init): multi-level undo, quickfix due, BufEnter reload Problem: undo was single-level with shallow references; no way to query due/overdue tasks via quickfix; two instances sharing tasks.json would diverge silently. Solution: replace _undo_state with _undo_states[] (cap 20, deep copies via store.snapshot()); add M.due() which populates the quickfix list with overdue/due-today tasks; add BufEnter autocmd that reloads from disk when the buffer is unmodified; expand show_help() with folds, :Pending due, relative date syntax, PendingOverdue, and empty-input date clearing. --- lua/pending/init.lua | 89 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 79 insertions(+), 10 deletions(-) diff --git a/lua/pending/init.lua b/lua/pending/init.lua index cb930d0..5db6a0c 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -3,11 +3,12 @@ local diff = require('pending.diff') local parse = require('pending.parse') local store = require('pending.store') ----@class task +---@class pending.init local M = {} ----@type pending.Task[]? -local _undo_state = nil +---@type pending.Task[][] +local _undo_states = {} +local UNDO_MAX = 20 ---@return integer bufnr function M.open() @@ -27,6 +28,16 @@ function M._setup_autocmds(bufnr) M._on_write(bufnr) end, }) + vim.api.nvim_create_autocmd('BufEnter', { + group = group, + buffer = bufnr, + callback = function() + if not vim.bo[bufnr].modified then + store.load() + buffer.render(bufnr) + end + end, + }) end ---@param bufnr integer @@ -55,19 +66,23 @@ end ---@param bufnr integer function M._on_write(bufnr) local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - _undo_state = store.active_tasks() + local snapshot = store.snapshot() + table.insert(_undo_states, snapshot) + if #_undo_states > UNDO_MAX then + table.remove(_undo_states, 1) + end diff.apply(lines) buffer.render(bufnr) end function M.undo_write() - if not _undo_state then + if #_undo_states == 0 then vim.notify('Nothing to undo.', vim.log.levels.WARN) return end - store.replace_tasks(_undo_state) + local state = table.remove(_undo_states) + store.replace_tasks(state) store.save() - _undo_state = nil buffer.render(buffer.bufnr()) end @@ -227,6 +242,49 @@ function M.archive(days) end end +function M.due() + local today = os.date('%Y-%m-%d') --[[@as string]] + local bufnr = buffer.bufnr() + local is_valid = bufnr ~= nil and vim.api.nvim_buf_is_valid(bufnr) + local meta = is_valid and buffer.meta() or nil + local qf_items = {} + + 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 + local task = store.get(m.id or 0) + local label = m.raw_due < today and '[OVERDUE] ' or '[DUE] ' + table.insert(qf_items, { + bufnr = bufnr, + lnum = lnum, + col = 1, + text = label .. (task and task.description or ''), + }) + end + end + 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] ' + local text = label .. task.description + if task.category then + text = text .. ' [' .. task.category .. ']' + end + table.insert(qf_items, { text = text }) + end + end + end + + if #qf_items == 0 then + vim.notify('No due or overdue tasks.') + return + end + + vim.fn.setqflist(qf_items, 'r') + vim.cmd('copen') +end + function M.show_help() local cfg = require('pending.config').get() local dk = cfg.date_syntax or 'due' @@ -239,27 +297,36 @@ function M.show_help() 'd Set due date', 'U Undo last write', 'o / O Add new task line', - 'dd Delete task (on :w)', + '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', + ':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)', + '', '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 = 50 + local width = 54 local height = #lines local win = vim.api.nvim_open_win(buf, true, { relative = 'editor', @@ -292,6 +359,8 @@ function M.command(args) elseif cmd == 'archive' then local d = rest ~= '' and tonumber(rest) or nil M.archive(d) + elseif cmd == 'due' then + M.due() elseif cmd == 'undo' then M.undo_write() else From 2b73ab1cd0e5a61b5b68957c1f1772a65a02a319 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 18:49:18 -0500 Subject: [PATCH 057/199] fix: resolve remaining LuaLS type errors Problem: CI lua-typecheck-action reported three categories of errors: 1. parse.lua - multi-assignment of tonumber() results left y/m/d typed as number? rather than integer, failing os.time()'s field types 2. gcal.lua - url_encode returned str:gsub() which yields string+integer but the annotation declared @return string (redundant-return-value) 3. gcal.lua - calendar_id typed string? from find_or_create_calendar was passed to functions expecting string; the existing `if err` guard did not narrow the type for LuaLS Solution: replace the y/m/d multi-assignment with yn/mn/dn locals whose types resolve cleanly to integer; wrap the gsub return in parentheses to discard the count; add `or not calendar_id` to the error guard so LuaLS narrows calendar_id to string for the rest of the scope. --- lua/pending/parse.lua | 15 +++++++-------- lua/pending/sync/gcal.lua | 12 +++++++----- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/lua/pending/parse.lua b/lua/pending/parse.lua index b72214f..ebe909a 100644 --- a/lua/pending/parse.lua +++ b/lua/pending/parse.lua @@ -10,19 +10,18 @@ local function is_valid_date(s) if not y then return false end - y, m, d = - tonumber(y), --[[@as integer]] - tonumber(m), --[[@as integer]] - tonumber(d) --[[@as integer]] - if m < 1 or m > 12 then + local yn = tonumber(y) --[[@as integer]] + local mn = tonumber(m) --[[@as integer]] + local dn = tonumber(d) --[[@as integer]] + if mn < 1 or mn > 12 then return false end - if d < 1 or d > 31 then + if dn < 1 or dn > 31 then return false end - local t = os.time({ year = y, month = m, day = d }) + local t = os.time({ year = yn, month = mn, day = dn }) local check = os.date('*t', t) --[[@as osdate]] - return check.year == y and check.month == m and check.day == d + return check.year == yn and check.month == mn and check.day == dn end ---@return string diff --git a/lua/pending/sync/gcal.lua b/lua/pending/sync/gcal.lua index f3c84e2..6635575 100644 --- a/lua/pending/sync/gcal.lua +++ b/lua/pending/sync/gcal.lua @@ -99,9 +99,11 @@ 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) + return ( + str:gsub('([^%w%-%.%_%~])', function(c) + return string.format('%%%02X', string.byte(c)) + end) + ) end ---@param method string @@ -449,8 +451,8 @@ function M.sync() end local calendar_id, err = find_or_create_calendar(access_token) - if err then - vim.notify('pending.nvim: ' .. err, vim.log.levels.ERROR) + if err or not calendar_id then + vim.notify('pending.nvim: ' .. (err or 'calendar not found'), vim.log.levels.ERROR) return end From ebd61ba87073e5d6603086a5e71ca755854c4e09 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 18:49:18 -0500 Subject: [PATCH 058/199] fix: resolve remaining LuaLS type errors Problem: CI lua-typecheck-action reported three categories of errors: 1. parse.lua - multi-assignment of tonumber() results left y/m/d typed as number? rather than integer, failing os.time()'s field types 2. gcal.lua - url_encode returned str:gsub() which yields string+integer but the annotation declared @return string (redundant-return-value) 3. gcal.lua - calendar_id typed string? from find_or_create_calendar was passed to functions expecting string; the existing `if err` guard did not narrow the type for LuaLS Solution: replace the y/m/d multi-assignment with yn/mn/dn locals whose types resolve cleanly to integer; wrap the gsub return in parentheses to discard the count; add `or not calendar_id` to the error guard so LuaLS narrows calendar_id to string for the rest of the scope. --- lua/pending/parse.lua | 15 +++++++-------- lua/pending/sync/gcal.lua | 12 +++++++----- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/lua/pending/parse.lua b/lua/pending/parse.lua index b72214f..ebe909a 100644 --- a/lua/pending/parse.lua +++ b/lua/pending/parse.lua @@ -10,19 +10,18 @@ local function is_valid_date(s) if not y then return false end - y, m, d = - tonumber(y), --[[@as integer]] - tonumber(m), --[[@as integer]] - tonumber(d) --[[@as integer]] - if m < 1 or m > 12 then + local yn = tonumber(y) --[[@as integer]] + local mn = tonumber(m) --[[@as integer]] + local dn = tonumber(d) --[[@as integer]] + if mn < 1 or mn > 12 then return false end - if d < 1 or d > 31 then + if dn < 1 or dn > 31 then return false end - local t = os.time({ year = y, month = m, day = d }) + local t = os.time({ year = yn, month = mn, day = dn }) local check = os.date('*t', t) --[[@as osdate]] - return check.year == y and check.month == m and check.day == d + return check.year == yn and check.month == mn and check.day == dn end ---@return string diff --git a/lua/pending/sync/gcal.lua b/lua/pending/sync/gcal.lua index f3c84e2..6635575 100644 --- a/lua/pending/sync/gcal.lua +++ b/lua/pending/sync/gcal.lua @@ -99,9 +99,11 @@ 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) + return ( + str:gsub('([^%w%-%.%_%~])', function(c) + return string.format('%%%02X', string.byte(c)) + end) + ) end ---@param method string @@ -449,8 +451,8 @@ function M.sync() end local calendar_id, err = find_or_create_calendar(access_token) - if err then - vim.notify('pending.nvim: ' .. err, vim.log.levels.ERROR) + if err or not calendar_id then + vim.notify('pending.nvim: ' .. (err or 'calendar not found'), vim.log.levels.ERROR) return end From bef6c4ca1771f06767eb756578add39775f0f189 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 18:54:53 -0500 Subject: [PATCH 059/199] fix(health): remove superfluous config info lines --- lua/pending/health.lua | 4 ---- 1 file changed, 4 deletions(-) diff --git a/lua/pending/health.lua b/lua/pending/health.lua index 67c1c09..8a12da4 100644 --- a/lua/pending/health.lua +++ b/lua/pending/health.lua @@ -12,10 +12,6 @@ function M.check() local cfg = config.get() vim.health.ok('Config loaded') vim.health.info('Data path: ' .. cfg.data_path) - vim.health.info('Default view: ' .. cfg.default_view) - vim.health.info('Default category: ' .. cfg.default_category) - vim.health.info('Date format: ' .. cfg.date_format) - vim.health.info('Date syntax: ' .. cfg.date_syntax) local data_dir = vim.fn.fnamemodify(cfg.data_path, ':h') if vim.fn.isdirectory(data_dir) == 1 then From 760d2acdac2b33f590f471cdf1960b38576ef717 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 18:54:53 -0500 Subject: [PATCH 060/199] fix(health): remove superfluous config info lines --- lua/pending/health.lua | 4 ---- 1 file changed, 4 deletions(-) diff --git a/lua/pending/health.lua b/lua/pending/health.lua index 67c1c09..8a12da4 100644 --- a/lua/pending/health.lua +++ b/lua/pending/health.lua @@ -12,10 +12,6 @@ function M.check() local cfg = config.get() vim.health.ok('Config loaded') vim.health.info('Data path: ' .. cfg.data_path) - vim.health.info('Default view: ' .. cfg.default_view) - vim.health.info('Default category: ' .. cfg.default_category) - vim.health.info('Date format: ' .. cfg.date_format) - vim.health.info('Date syntax: ' .. cfg.date_syntax) local data_dir = vim.fn.fnamemodify(cfg.data_path, ':h') if vim.fn.isdirectory(data_dir) == 1 then From f0b58df31779a4729465d60c654a2da746b1cc3b Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 18:54:58 -0500 Subject: [PATCH 061/199] fix(init): remap date prompt from d to D Problem: mapping d for the date prompt intercepts dd before Vim can recognize it as a motion, so dd never deletes a line. Solution: move the date prompt to D, restoring full d-operator behaviour (dd, dw, d$, etc.) and updating the help popup to match. --- lua/pending/init.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 5db6a0c..b6d9988 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -55,7 +55,7 @@ function M._setup_buf_mappings(bufnr) vim.keymap.set('n', '!', function() M.toggle_priority() end, opts) - vim.keymap.set('n', 'd', function() + vim.keymap.set('n', 'D', function() M.prompt_date() end, opts) vim.keymap.set('n', 'U', function() @@ -294,7 +294,7 @@ function M.show_help() ' Toggle complete/uncomplete', ' Switch category/priority view', '! Toggle priority', - 'd Set due date', + 'D Set due date', 'U Undo last write', 'o / O Add new task line', 'dd Delete task line (on :w)', From 12c22c4226706d96a27e48dc804418edad9458b9 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 18:54:58 -0500 Subject: [PATCH 062/199] fix(init): remap date prompt from d to D Problem: mapping d for the date prompt intercepts dd before Vim can recognize it as a motion, so dd never deletes a line. Solution: move the date prompt to D, restoring full d-operator behaviour (dd, dw, d$, etc.) and updating the help popup to match. --- lua/pending/init.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 5db6a0c..b6d9988 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -55,7 +55,7 @@ function M._setup_buf_mappings(bufnr) vim.keymap.set('n', '!', function() M.toggle_priority() end, opts) - vim.keymap.set('n', 'd', function() + vim.keymap.set('n', 'D', function() M.prompt_date() end, opts) vim.keymap.set('n', 'U', function() @@ -294,7 +294,7 @@ function M.show_help() ' Toggle complete/uncomplete', ' Switch category/priority view', '! Toggle priority', - 'd Set due date', + 'D Set due date', 'U Undo last write', 'o / O Add new task line', 'dd Delete task line (on :w)', From 6b14a6bf90b1dd73a270b36ff8ad7db1b4084aa9 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 19:48:13 -0500 Subject: [PATCH 063/199] fix(buffer): link highlight groups to colorscheme via default = true Problem: highlight groups used hardcoded hex colours and a bespoke hlexists guard, ignoring the user's colorscheme and preventing overrides from working naturally. Solution: replace the guard wrapper with direct nvim_set_hl calls using default = true and link, so each group falls back to a semantically appropriate built-in group (Title, DiagnosticHint, DiagnosticError, Comment, DiagnosticWarn) unless the user has already defined them. --- lua/pending/buffer.lua | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index e36972e..bf598fc 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -145,16 +145,11 @@ local function apply_extmarks(bufnr, line_meta) end local function setup_highlights() - local function hl(name, opts) - if vim.fn.hlexists(name) == 0 or vim.tbl_isempty(vim.api.nvim_get_hl(0, { name = name })) then - vim.api.nvim_set_hl(0, name, opts) - end - end - hl('PendingHeader', { bold = true }) - hl('PendingDue', { fg = '#888888', italic = true }) - hl('PendingOverdue', { fg = '#e06c75', italic = true }) - hl('PendingDone', { strikethrough = true, fg = '#666666' }) - hl('PendingPriority', { fg = '#e06c75', bold = true }) + vim.api.nvim_set_hl(0, 'PendingHeader', { link = 'Title', default = true }) + vim.api.nvim_set_hl(0, 'PendingDue', { link = 'DiagnosticHint', default = true }) + vim.api.nvim_set_hl(0, 'PendingOverdue', { link = 'DiagnosticError', default = true }) + vim.api.nvim_set_hl(0, 'PendingDone', { link = 'Comment', default = true }) + vim.api.nvim_set_hl(0, 'PendingPriority', { link = 'DiagnosticWarn', default = true }) end ---@param bufnr? integer From fe10422a343818625d59eb5b6c1dbdfd8fc6937e Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 19:48:13 -0500 Subject: [PATCH 064/199] fix(buffer): link highlight groups to colorscheme via default = true Problem: highlight groups used hardcoded hex colours and a bespoke hlexists guard, ignoring the user's colorscheme and preventing overrides from working naturally. Solution: replace the guard wrapper with direct nvim_set_hl calls using default = true and link, so each group falls back to a semantically appropriate built-in group (Title, DiagnosticHint, DiagnosticError, Comment, DiagnosticWarn) unless the user has already defined them. --- lua/pending/buffer.lua | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index e36972e..bf598fc 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -145,16 +145,11 @@ local function apply_extmarks(bufnr, line_meta) end local function setup_highlights() - local function hl(name, opts) - if vim.fn.hlexists(name) == 0 or vim.tbl_isempty(vim.api.nvim_get_hl(0, { name = name })) then - vim.api.nvim_set_hl(0, name, opts) - end - end - hl('PendingHeader', { bold = true }) - hl('PendingDue', { fg = '#888888', italic = true }) - hl('PendingOverdue', { fg = '#e06c75', italic = true }) - hl('PendingDone', { strikethrough = true, fg = '#666666' }) - hl('PendingPriority', { fg = '#e06c75', bold = true }) + vim.api.nvim_set_hl(0, 'PendingHeader', { link = 'Title', default = true }) + vim.api.nvim_set_hl(0, 'PendingDue', { link = 'DiagnosticHint', default = true }) + vim.api.nvim_set_hl(0, 'PendingOverdue', { link = 'DiagnosticError', default = true }) + vim.api.nvim_set_hl(0, 'PendingDone', { link = 'Comment', default = true }) + vim.api.nvim_set_hl(0, 'PendingPriority', { link = 'DiagnosticWarn', default = true }) end ---@param bufnr? integer From d2c9eb1808fee9c8d780d10f82b6bdc59169c1c7 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 19:48:22 -0500 Subject: [PATCH 065/199] fix(init): reposition cursor after priority toggle re-render Problem: pressing ! re-sorts the view so the toggled task moves to the top of its category, but the cursor stays on the original line number and lands on a different task. Solution: after buffer.render(), iterate buffer.meta() to find the new line number for the toggled task's id and call nvim_win_set_cursor to follow it. --- lua/pending/init.lua | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lua/pending/init.lua b/lua/pending/init.lua index b6d9988..d03e454 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -135,6 +135,12 @@ function M.toggle_priority() store.update(id, { priority = new_priority }) store.save() 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 function M.prompt_date() From e19e1c3edd023501411ef4cea29acf5995857896 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 19:48:22 -0500 Subject: [PATCH 066/199] fix(init): reposition cursor after priority toggle re-render Problem: pressing ! re-sorts the view so the toggled task moves to the top of its category, but the cursor stays on the original line number and lands on a different task. Solution: after buffer.render(), iterate buffer.meta() to find the new line number for the toggled task's id and call nvim_win_set_cursor to follow it. --- lua/pending/init.lua | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lua/pending/init.lua b/lua/pending/init.lua index b6d9988..d03e454 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -135,6 +135,12 @@ function M.toggle_priority() store.update(id, { priority = new_priority }) store.save() 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 function M.prompt_date() From 3919e3f88f0d1d3109ee33b538c3ebeb7b36cf71 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 19:48:41 -0500 Subject: [PATCH 067/199] fix(buffer): replace indentexpr with dedicated o/O mappings Problem: setup_indentexpr always returned 0 because no task line starts with whitespace (the /ID/ prefix begins with /), so the return 2 branch was dead code. Pressing o or O opened a blank line at column 0 with no ID prefix, which the diff parser cannot recognise as a task. Solution: remove setup_indentexpr and M.get_indent() entirely; add M.open_line(above) which inserts a two-space stub line and enters insert mode at the end so the user types directly into the new task body. The diff layer already handles lines matching ^ .+ as new tasks. Add o and O buffer-local mappings in init.lua. --- lua/pending/buffer.lua | 26 +++++++++++--------------- lua/pending/init.lua | 6 ++++++ 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index bf598fc..93c0fd3 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -63,22 +63,19 @@ local function setup_syntax(bufnr) end) end ----@param bufnr integer -local function setup_indentexpr(bufnr) - vim.bo[bufnr].indentexpr = 'v:lua.require("pending.buffer").get_indent()' -end ----@return integer -function M.get_indent() - local lnum = vim.v.lnum - if lnum <= 1 then - return 0 +---@param above boolean +function M.open_line(above) + local bufnr = task_bufnr + if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then + return end - local prev = vim.fn.getline(lnum - 1) - if prev == '' or prev:match('^%S') then - return 0 - end - return 2 + local row = vim.api.nvim_win_get_cursor(0)[1] + 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, { ' ' }) + vim.api.nvim_win_set_cursor(0, { insert_row + 1, 2 }) + vim.cmd('startinsert!') end ---@return string @@ -222,7 +219,6 @@ function M.open() vim.api.nvim_buf_set_name(task_bufnr, 'pending://') set_buf_options(task_bufnr) - setup_indentexpr(task_bufnr) vim.api.nvim_set_current_buf(task_bufnr) set_win_options(vim.api.nvim_get_current_win()) diff --git a/lua/pending/init.lua b/lua/pending/init.lua index d03e454..86c1971 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -61,6 +61,12 @@ function M._setup_buf_mappings(bufnr) 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) end ---@param bufnr integer From 5d4f56807bf3b1c8857ee92e3193160babacaf37 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 19:48:41 -0500 Subject: [PATCH 068/199] fix(buffer): replace indentexpr with dedicated o/O mappings Problem: setup_indentexpr always returned 0 because no task line starts with whitespace (the /ID/ prefix begins with /), so the return 2 branch was dead code. Pressing o or O opened a blank line at column 0 with no ID prefix, which the diff parser cannot recognise as a task. Solution: remove setup_indentexpr and M.get_indent() entirely; add M.open_line(above) which inserts a two-space stub line and enters insert mode at the end so the user types directly into the new task body. The diff layer already handles lines matching ^ .+ as new tasks. Add o and O buffer-local mappings in init.lua. --- lua/pending/buffer.lua | 26 +++++++++++--------------- lua/pending/init.lua | 6 ++++++ 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index bf598fc..93c0fd3 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -63,22 +63,19 @@ local function setup_syntax(bufnr) end) end ----@param bufnr integer -local function setup_indentexpr(bufnr) - vim.bo[bufnr].indentexpr = 'v:lua.require("pending.buffer").get_indent()' -end ----@return integer -function M.get_indent() - local lnum = vim.v.lnum - if lnum <= 1 then - return 0 +---@param above boolean +function M.open_line(above) + local bufnr = task_bufnr + if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then + return end - local prev = vim.fn.getline(lnum - 1) - if prev == '' or prev:match('^%S') then - return 0 - end - return 2 + local row = vim.api.nvim_win_get_cursor(0)[1] + 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, { ' ' }) + vim.api.nvim_win_set_cursor(0, { insert_row + 1, 2 }) + vim.cmd('startinsert!') end ---@return string @@ -222,7 +219,6 @@ function M.open() vim.api.nvim_buf_set_name(task_bufnr, 'pending://') set_buf_options(task_bufnr) - setup_indentexpr(task_bufnr) vim.api.nvim_set_current_buf(task_bufnr) set_win_options(vim.api.nvim_get_current_win()) diff --git a/lua/pending/init.lua b/lua/pending/init.lua index d03e454..86c1971 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -61,6 +61,12 @@ function M._setup_buf_mappings(bufnr) 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) end ---@param bufnr integer From cee0560341e0b49b9b4096a734af21093d40fa2b Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 19:51:52 -0500 Subject: [PATCH 069/199] ci: format --- lua/pending/buffer.lua | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index 93c0fd3..3e98415 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -63,7 +63,6 @@ local function setup_syntax(bufnr) end) end - ---@param above boolean function M.open_line(above) local bufnr = task_bufnr @@ -142,11 +141,11 @@ local function apply_extmarks(bufnr, line_meta) end local function setup_highlights() - vim.api.nvim_set_hl(0, 'PendingHeader', { link = 'Title', default = true }) - vim.api.nvim_set_hl(0, 'PendingDue', { link = 'DiagnosticHint', default = true }) - vim.api.nvim_set_hl(0, 'PendingOverdue', { link = 'DiagnosticError', default = true }) - vim.api.nvim_set_hl(0, 'PendingDone', { link = 'Comment', default = true }) - vim.api.nvim_set_hl(0, 'PendingPriority', { link = 'DiagnosticWarn', default = true }) + vim.api.nvim_set_hl(0, 'PendingHeader', { link = 'Title', default = true }) + vim.api.nvim_set_hl(0, 'PendingDue', { link = 'DiagnosticHint', default = true }) + vim.api.nvim_set_hl(0, 'PendingOverdue', { link = 'DiagnosticError', default = true }) + vim.api.nvim_set_hl(0, 'PendingDone', { link = 'Comment', default = true }) + vim.api.nvim_set_hl(0, 'PendingPriority', { link = 'DiagnosticWarn', default = true }) end ---@param bufnr? integer From 5021cd2db0ed44c00ef6380f92c4e6ea0f8212cd Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 19:51:52 -0500 Subject: [PATCH 070/199] ci: format --- lua/pending/buffer.lua | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index 93c0fd3..3e98415 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -63,7 +63,6 @@ local function setup_syntax(bufnr) end) end - ---@param above boolean function M.open_line(above) local bufnr = task_bufnr @@ -142,11 +141,11 @@ local function apply_extmarks(bufnr, line_meta) end local function setup_highlights() - vim.api.nvim_set_hl(0, 'PendingHeader', { link = 'Title', default = true }) - vim.api.nvim_set_hl(0, 'PendingDue', { link = 'DiagnosticHint', default = true }) - vim.api.nvim_set_hl(0, 'PendingOverdue', { link = 'DiagnosticError', default = true }) - vim.api.nvim_set_hl(0, 'PendingDone', { link = 'Comment', default = true }) - vim.api.nvim_set_hl(0, 'PendingPriority', { link = 'DiagnosticWarn', default = true }) + vim.api.nvim_set_hl(0, 'PendingHeader', { link = 'Title', default = true }) + vim.api.nvim_set_hl(0, 'PendingDue', { link = 'DiagnosticHint', default = true }) + vim.api.nvim_set_hl(0, 'PendingOverdue', { link = 'DiagnosticError', default = true }) + vim.api.nvim_set_hl(0, 'PendingDone', { link = 'Comment', default = true }) + vim.api.nvim_set_hl(0, 'PendingPriority', { link = 'DiagnosticWarn', default = true }) end ---@param bufnr? integer From 586a8e81e3691c1ce80ac4d86da93003dea0d155 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 19:53:43 -0500 Subject: [PATCH 071/199] docs: update vimdoc to reflect current feature set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: doc/pending.txt was written before the undo stack, folds, :Pending due, D mapping, and BufEnter reload were added. Several entries were factually wrong (single-level undo, d vs D key, :Pending undo listed as non-existent) and highlight group defaults referenced stale hex colours. Solution: correct all factual errors and add missing entries — :Pending due command, :Pending undo command, zc/zo fold mappings, PendingOverdue highlight group, semantic link defaults for all groups, category fold docs, BufEnter auto-reload note, and multi-level undo description. --- doc/pending.txt | 41 +++++++++++++++++++++++++++++------------ 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/doc/pending.txt b/doc/pending.txt index 4986467..4eb8e40 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -33,8 +33,10 @@ Features: ~ - Inline metadata syntax: `due:` and `cat:` tokens parsed on `:w` - Relative date input: `today`, `tomorrow`, `+Nd`, weekday names - Two views: category (default) and priority flat list -- Single-level undo for the last `:w` save +- 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 - Google Calendar one-way push via OAuth PKCE ============================================================================== @@ -81,7 +83,8 @@ Buffer-local keys are set automatically when the buffer opens. See The buffer uses `buftype=acwrite` so `:w` always routes through pending.nvim's write handler rather than writing to disk directly. The `pending://` buffer persists across window switches; reopening with `:Pending` focuses the -existing window if one is open. +existing window if one is open. The buffer is automatically reloaded from +disk when entered unmodified. ============================================================================== INLINE METADATA *pending-metadata* @@ -143,6 +146,11 @@ COMMANDS *pending-commands* :Pending archive 7 " remove tasks completed more than 7 days ago < + *:Pending-due* +:Pending due + 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 Push pending tasks that have a due date to Google Calendar as all-day @@ -151,9 +159,9 @@ COMMANDS *pending-commands* *:Pending-undo* :Pending undo - There is no `:Pending undo` subcommand. Use the `U` buffer-local key - (see |pending-mappings|) to undo the last `:w` save while the task buffer - is open. + 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. ============================================================================== MAPPINGS *pending-mappings* @@ -167,12 +175,16 @@ Buffer-local keys: ~ ------- ------------------------------------------------ `` 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 + `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 + `zc` Fold the current category section (category view only) + `zo` Unfold the current category section (category view only) -Standard Vim keys `o`, `O`, `dd`, `p`, `P`, and `:w` work as expected. +`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. *(pending-open)* (pending-open) @@ -209,7 +221,8 @@ 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, pending tasks appear before done tasks. Priority tasks (`!`) are sorted - first within each group. + first within each group. Category sections are foldable with `zc` and + `zo`. Priority view: ~ *pending-view-priority* A flat list of all tasks sorted by priority, then by due date (tasks @@ -339,20 +352,24 @@ pending.nvim defines the following highlight groups. All groups are set with *PendingHeader* PendingHeader Applied to category header lines (text at column 0). - Default: bold. + Default: links to `Title`. *PendingDue* PendingDue Applied to the due date virtual text shown at the right margin of each task line. - Default: fg=#888888, italic. + 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: strikethrough, fg=#666666. + Default: links to `Comment`. *PendingPriority* PendingPriority Applied to the `! ` priority marker on priority tasks. - Default: fg=#e06c75, bold. + 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 }) From 20b8cb760f05527fd286b043a8b6d9040241bc42 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 19:53:43 -0500 Subject: [PATCH 072/199] docs: update vimdoc to reflect current feature set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: doc/pending.txt was written before the undo stack, folds, :Pending due, D mapping, and BufEnter reload were added. Several entries were factually wrong (single-level undo, d vs D key, :Pending undo listed as non-existent) and highlight group defaults referenced stale hex colours. Solution: correct all factual errors and add missing entries — :Pending due command, :Pending undo command, zc/zo fold mappings, PendingOverdue highlight group, semantic link defaults for all groups, category fold docs, BufEnter auto-reload note, and multi-level undo description. --- doc/pending.txt | 41 +++++++++++++++++++++++++++++------------ 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/doc/pending.txt b/doc/pending.txt index 4986467..4eb8e40 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -33,8 +33,10 @@ Features: ~ - Inline metadata syntax: `due:` and `cat:` tokens parsed on `:w` - Relative date input: `today`, `tomorrow`, `+Nd`, weekday names - Two views: category (default) and priority flat list -- Single-level undo for the last `:w` save +- 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 - Google Calendar one-way push via OAuth PKCE ============================================================================== @@ -81,7 +83,8 @@ Buffer-local keys are set automatically when the buffer opens. See The buffer uses `buftype=acwrite` so `:w` always routes through pending.nvim's write handler rather than writing to disk directly. The `pending://` buffer persists across window switches; reopening with `:Pending` focuses the -existing window if one is open. +existing window if one is open. The buffer is automatically reloaded from +disk when entered unmodified. ============================================================================== INLINE METADATA *pending-metadata* @@ -143,6 +146,11 @@ COMMANDS *pending-commands* :Pending archive 7 " remove tasks completed more than 7 days ago < + *:Pending-due* +:Pending due + 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 Push pending tasks that have a due date to Google Calendar as all-day @@ -151,9 +159,9 @@ COMMANDS *pending-commands* *:Pending-undo* :Pending undo - There is no `:Pending undo` subcommand. Use the `U` buffer-local key - (see |pending-mappings|) to undo the last `:w` save while the task buffer - is open. + 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. ============================================================================== MAPPINGS *pending-mappings* @@ -167,12 +175,16 @@ Buffer-local keys: ~ ------- ------------------------------------------------ `` 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 + `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 + `zc` Fold the current category section (category view only) + `zo` Unfold the current category section (category view only) -Standard Vim keys `o`, `O`, `dd`, `p`, `P`, and `:w` work as expected. +`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. *(pending-open)* (pending-open) @@ -209,7 +221,8 @@ 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, pending tasks appear before done tasks. Priority tasks (`!`) are sorted - first within each group. + first within each group. Category sections are foldable with `zc` and + `zo`. Priority view: ~ *pending-view-priority* A flat list of all tasks sorted by priority, then by due date (tasks @@ -339,20 +352,24 @@ pending.nvim defines the following highlight groups. All groups are set with *PendingHeader* PendingHeader Applied to category header lines (text at column 0). - Default: bold. + Default: links to `Title`. *PendingDue* PendingDue Applied to the due date virtual text shown at the right margin of each task line. - Default: fg=#888888, italic. + 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: strikethrough, fg=#666666. + Default: links to `Comment`. *PendingPriority* PendingPriority Applied to the `! ` priority marker on priority tasks. - Default: fg=#e06c75, bold. + 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 }) From aae6989a197f69d94eae018fe8b408833537593f Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 19:53:50 -0500 Subject: [PATCH 073/199] test: add top-priority missing test coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: several critical code paths had zero test coverage — parse.resolve_date (relative date resolution), store.snapshot (foundation of the undo stack), and the diff.apply invariant that unchanged tasks do not get their modified timestamp bumped. The diff.apply due/priority clearing paths were also untested. Solution: add six targeted test blocks across parse_spec, store_spec, and diff_spec: resolve_date happy/failure paths, parse.body with relative due tokens, snapshot copy-semantics and deleted-task exclusion, diff unchanged-modified invariant, due cleared on removal, priority cleared on ! removal. --- spec/diff_spec.lua | 46 ++++++++++++++++++++++++++++++++++ spec/parse_spec.lua | 61 +++++++++++++++++++++++++++++++++++++++++++++ spec/store_spec.lua | 27 ++++++++++++++++++++ 3 files changed, 134 insertions(+) diff --git a/spec/diff_spec.lua b/spec/diff_spec.lua index 3d3c935..b8fcfd9 100644 --- a/spec/diff_spec.lua +++ b/spec/diff_spec.lua @@ -135,5 +135,51 @@ describe('diff', function() local task = store.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() + 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) + 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() + local lines = { + 'Inbox', + '/1/ Pay bill', + } + diff.apply(lines) + store.unload() + store.load() + local task = store.get(1) + assert.is_nil(task.due) + end) + + it('clears priority when ! is removed from buffer line', function() + store.add({ description = 'Task name', priority = 1 }) + store.save() + local lines = { + 'Inbox', + '/1/ Task name', + } + diff.apply(lines) + store.unload() + store.load() + local task = store.get(1) + assert.are.equal(0, task.priority) + end) end) end) diff --git a/spec/parse_spec.lua b/spec/parse_spec.lua index 92b2239..b4442e9 100644 --- a/spec/parse_spec.lua +++ b/spec/parse_spec.lua @@ -87,6 +87,67 @@ describe('parse', function() assert.are.equal('Buy milk due:2026-03-15', desc) assert.is_nil(meta.due) end) + + it('resolves due:today to today date', function() + local desc, meta = parse.body('Buy milk due:today') + assert.are.equal('Buy milk', desc) + assert.are.equal(os.date('%Y-%m-%d'), meta.due) + end) + + it('resolves due:+2d to today plus 2 days', 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 desc, meta = parse.body('Task due:+2d') + assert.are.equal('Task', desc) + assert.are.equal(expected, meta.due) + end) + + it('leaves unresolvable due token in description', function() + local desc, meta = parse.body('Task due:garbage') + assert.is_nil(meta.due) + assert.truthy(desc:find('due:garbage', 1, true)) + end) + end) + + describe('parse.resolve_date', function() + it("returns today's date for 'today'", function() + local result = parse.resolve_date('today') + assert.are.equal(os.date('%Y-%m-%d'), result) + end) + + it("returns tomorrow's date for 'tomorrow'", function() + local expected = os.date('%Y-%m-%d', os.time() + 86400) + local result = parse.resolve_date('tomorrow') + assert.are.equal(expected, result) + end) + + it("returns today + 3 days for '+3d'", 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 + 3 })) + local result = parse.resolve_date('+3d') + assert.are.equal(expected, result) + end) + + it("returns today for '+0d'", function() + local result = parse.resolve_date('+0d') + assert.are.equal(os.date('%Y-%m-%d'), result) + end) + + it("returns a future Monday (or today) for 'mon'", function() + local result = parse.resolve_date('mon') + assert.is_not_nil(result) + assert.truthy(result:match('^%d%d%d%d%-%d%d%-%d%d$')) + end) + + it("returns nil for garbage input", function() + local result = parse.resolve_date('notadate') + assert.is_nil(result) + end) + + it("returns nil for empty string", function() + local result = parse.resolve_date('') + assert.is_nil(result) + end) end) describe('command_add', function() diff --git a/spec/store_spec.lua b/spec/store_spec.lua index 8fdf5f4..930fbc0 100644 --- a/spec/store_spec.lua +++ b/spec/store_spec.lua @@ -186,4 +186,31 @@ describe('store', function() assert.are.equal('Active', active[1].description) end) end) + + 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() + 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() + snap[1].description = 'Mutated' + local live = store.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() + assert.are.equal(0, #snap) + end) + end) end) From 39c98012d05319fa06fcf8c0929e95610fe01606 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 19:53:50 -0500 Subject: [PATCH 074/199] test: add top-priority missing test coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: several critical code paths had zero test coverage — parse.resolve_date (relative date resolution), store.snapshot (foundation of the undo stack), and the diff.apply invariant that unchanged tasks do not get their modified timestamp bumped. The diff.apply due/priority clearing paths were also untested. Solution: add six targeted test blocks across parse_spec, store_spec, and diff_spec: resolve_date happy/failure paths, parse.body with relative due tokens, snapshot copy-semantics and deleted-task exclusion, diff unchanged-modified invariant, due cleared on removal, priority cleared on ! removal. --- spec/diff_spec.lua | 46 ++++++++++++++++++++++++++++++++++ spec/parse_spec.lua | 61 +++++++++++++++++++++++++++++++++++++++++++++ spec/store_spec.lua | 27 ++++++++++++++++++++ 3 files changed, 134 insertions(+) diff --git a/spec/diff_spec.lua b/spec/diff_spec.lua index 3d3c935..b8fcfd9 100644 --- a/spec/diff_spec.lua +++ b/spec/diff_spec.lua @@ -135,5 +135,51 @@ describe('diff', function() local task = store.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() + 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) + 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() + local lines = { + 'Inbox', + '/1/ Pay bill', + } + diff.apply(lines) + store.unload() + store.load() + local task = store.get(1) + assert.is_nil(task.due) + end) + + it('clears priority when ! is removed from buffer line', function() + store.add({ description = 'Task name', priority = 1 }) + store.save() + local lines = { + 'Inbox', + '/1/ Task name', + } + diff.apply(lines) + store.unload() + store.load() + local task = store.get(1) + assert.are.equal(0, task.priority) + end) end) end) diff --git a/spec/parse_spec.lua b/spec/parse_spec.lua index 92b2239..b4442e9 100644 --- a/spec/parse_spec.lua +++ b/spec/parse_spec.lua @@ -87,6 +87,67 @@ describe('parse', function() assert.are.equal('Buy milk due:2026-03-15', desc) assert.is_nil(meta.due) end) + + it('resolves due:today to today date', function() + local desc, meta = parse.body('Buy milk due:today') + assert.are.equal('Buy milk', desc) + assert.are.equal(os.date('%Y-%m-%d'), meta.due) + end) + + it('resolves due:+2d to today plus 2 days', 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 desc, meta = parse.body('Task due:+2d') + assert.are.equal('Task', desc) + assert.are.equal(expected, meta.due) + end) + + it('leaves unresolvable due token in description', function() + local desc, meta = parse.body('Task due:garbage') + assert.is_nil(meta.due) + assert.truthy(desc:find('due:garbage', 1, true)) + end) + end) + + describe('parse.resolve_date', function() + it("returns today's date for 'today'", function() + local result = parse.resolve_date('today') + assert.are.equal(os.date('%Y-%m-%d'), result) + end) + + it("returns tomorrow's date for 'tomorrow'", function() + local expected = os.date('%Y-%m-%d', os.time() + 86400) + local result = parse.resolve_date('tomorrow') + assert.are.equal(expected, result) + end) + + it("returns today + 3 days for '+3d'", 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 + 3 })) + local result = parse.resolve_date('+3d') + assert.are.equal(expected, result) + end) + + it("returns today for '+0d'", function() + local result = parse.resolve_date('+0d') + assert.are.equal(os.date('%Y-%m-%d'), result) + end) + + it("returns a future Monday (or today) for 'mon'", function() + local result = parse.resolve_date('mon') + assert.is_not_nil(result) + assert.truthy(result:match('^%d%d%d%d%-%d%d%-%d%d$')) + end) + + it("returns nil for garbage input", function() + local result = parse.resolve_date('notadate') + assert.is_nil(result) + end) + + it("returns nil for empty string", function() + local result = parse.resolve_date('') + assert.is_nil(result) + end) end) describe('command_add', function() diff --git a/spec/store_spec.lua b/spec/store_spec.lua index 8fdf5f4..930fbc0 100644 --- a/spec/store_spec.lua +++ b/spec/store_spec.lua @@ -186,4 +186,31 @@ describe('store', function() assert.are.equal('Active', active[1].description) end) end) + + 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() + 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() + snap[1].description = 'Mutated' + local live = store.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() + assert.are.equal(0, #snap) + end) + end) end) From fc4a47a1ec0f7ebcd79b56cb4a1a61ccd1e63a03 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 22:01:21 -0500 Subject: [PATCH 075/199] feat(init): multi-level priority with / Problem: priority was binary (0 or 1), toggled with !, with no way to express finer gradations or use Vim's native increment idiom. Solution: replace toggle_priority with change_priority(delta) which clamps to floor 0. Display format changes from '! ' to '[N] ' so any integer level is representable. Parser updated to extract numeric level from the [N] prefix. Visual g/g apply the delta to all tasks in the selection. (pending-priority) replaced with (pending-priority-up) and (pending-priority-down). --- lua/pending/buffer.lua | 2 +- lua/pending/diff.lua | 7 +++--- lua/pending/init.lua | 53 +++++++++++++++++++++++++++++++++++++----- lua/pending/views.lua | 3 ++- plugin/pending.lua | 8 +++++-- 5 files changed, 60 insertions(+), 13 deletions(-) diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index 3e98415..2036357 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -57,7 +57,7 @@ local function setup_syntax(bufnr) syntax clear syntax match taskId /^\/\d\+\// conceal syntax match taskHeader /^\S.*$/ contains=taskId - syntax match taskPriority /! / contained containedin=taskLine + syntax match taskPriority /\[\d\+\] / contained containedin=taskLine syntax match taskLine /^\/\d\+\/ .*$/ contains=taskId,taskPriority ]]) end) diff --git a/lua/pending/diff.lua b/lua/pending/diff.lua index 9f5e577..1107b31 100644 --- a/lua/pending/diff.lua +++ b/lua/pending/diff.lua @@ -34,10 +34,11 @@ function M.parse_buffer(lines) table.insert(result, { type = 'blank', lnum = i }) elseif id or body then local stripped = body:match('^ (.+)$') or body + local prio_str = stripped:match('^%[(%d+)%] ') local priority = 0 - if stripped:match('^! ') then - priority = 1 - stripped = stripped:sub(3) + if prio_str then + priority = tonumber(prio_str) + stripped = stripped:sub(#prio_str + 4) end local description, metadata = parse.body(stripped) if description and description ~= '' then diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 86c1971..1593bc5 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -52,8 +52,17 @@ function M._setup_buf_mappings(bufnr) vim.keymap.set('n', 'g?', function() M.show_help() end, opts) - vim.keymap.set('n', '!', function() - M.toggle_priority() + vim.keymap.set('n', '', function() + M.change_priority(1) + end, opts) + vim.keymap.set('n', '', function() + M.change_priority(-1) + end, opts) + vim.keymap.set('v', 'g', function() + M.change_priority_visual(1) + end, opts) + vim.keymap.set('v', 'g', function() + M.change_priority_visual(-1) end, opts) vim.keymap.set('n', 'D', function() M.prompt_date() @@ -119,7 +128,8 @@ function M.toggle_complete() buffer.render(bufnr) end -function M.toggle_priority() +---@param delta integer +function M.change_priority(delta) local bufnr = buffer.bufnr() if not bufnr then return @@ -137,7 +147,7 @@ function M.toggle_priority() if not task then return end - local new_priority = task.priority == 1 and 0 or 1 + local new_priority = math.max(0, task.priority + delta) store.update(id, { priority = new_priority }) store.save() buffer.render(bufnr) @@ -149,6 +159,33 @@ function M.toggle_priority() end end +---@param delta integer +function M.change_priority_visual(delta) + local bufnr = buffer.bufnr() + if not bufnr then + return + end + local start_row = vim.fn.line("'<") + local end_row = vim.fn.line("'>") + local meta = buffer.meta() + local changed = false + for row = start_row, end_row do + local m = meta[row] + if m and m.type == 'task' and m.id then + local task = store.get(m.id) + if task then + local new_priority = math.max(0, task.priority + delta) + store.update(m.id, { priority = new_priority }) + changed = true + end + end + end + if changed then + store.save() + buffer.render(bufnr) + end +end + function M.prompt_date() local bufnr = buffer.bufnr() if not bufnr then @@ -305,7 +342,10 @@ function M.show_help() '', ' Toggle complete/uncomplete', ' Switch category/priority view', - '! Toggle priority', + ' Raise priority level', + ' Lower priority level', + 'g Raise priority for visual selection', + 'g Lower priority for visual selection', 'D Set due date', 'U Undo last write', 'o / O Add new task line', @@ -330,7 +370,8 @@ function M.show_help() ' Empty input clears due date', '', 'Highlights:', - ' PendingOverdue overdue tasks (red)', + ' PendingOverdue overdue tasks (red)', + ' PendingPriority [N] priority prefix', '', 'Press q or to close', } diff --git a/lua/pending/views.lua b/lua/pending/views.lua index 1e599f5..84567e9 100644 --- a/lua/pending/views.lua +++ b/lua/pending/views.lua @@ -9,6 +9,7 @@ local config = require('pending.config') ---@field category? string ---@field overdue? boolean ---@field show_category? boolean +---@field priority? integer ---@class pending.views local M = {} @@ -138,7 +139,7 @@ function M.category_view(tasks) for _, task in ipairs(all) do local prefix = '/' .. task.id .. '/' local indent = ' ' - local prio = task.priority == 1 and '! ' or '' + local prio = task.priority > 0 and ('[' .. task.priority .. '] ') or '' local line = prefix .. indent .. prio .. task.description table.insert(lines, line) table.insert(meta, { diff --git a/plugin/pending.lua b/plugin/pending.lua index 7270825..56dedc3 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -30,8 +30,12 @@ vim.keymap.set('n', '(pending-view)', function() require('pending.buffer').toggle_view() end) -vim.keymap.set('n', '(pending-priority)', function() - require('pending').toggle_priority() +vim.keymap.set('n', '(pending-priority-up)', function() + require('pending').change_priority(1) +end) + +vim.keymap.set('n', '(pending-priority-down)', function() + require('pending').change_priority(-1) end) vim.keymap.set('n', '(pending-date)', function() From fe15721c6ff373716a87d983fb9532243bd496c0 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 22:01:21 -0500 Subject: [PATCH 076/199] feat(init): multi-level priority with / Problem: priority was binary (0 or 1), toggled with !, with no way to express finer gradations or use Vim's native increment idiom. Solution: replace toggle_priority with change_priority(delta) which clamps to floor 0. Display format changes from '! ' to '[N] ' so any integer level is representable. Parser updated to extract numeric level from the [N] prefix. Visual g/g apply the delta to all tasks in the selection. (pending-priority) replaced with (pending-priority-up) and (pending-priority-down). --- lua/pending/buffer.lua | 2 +- lua/pending/diff.lua | 7 +++--- lua/pending/init.lua | 53 +++++++++++++++++++++++++++++++++++++----- lua/pending/views.lua | 3 ++- plugin/pending.lua | 8 +++++-- 5 files changed, 60 insertions(+), 13 deletions(-) diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index 3e98415..2036357 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -57,7 +57,7 @@ local function setup_syntax(bufnr) syntax clear syntax match taskId /^\/\d\+\// conceal syntax match taskHeader /^\S.*$/ contains=taskId - syntax match taskPriority /! / contained containedin=taskLine + syntax match taskPriority /\[\d\+\] / contained containedin=taskLine syntax match taskLine /^\/\d\+\/ .*$/ contains=taskId,taskPriority ]]) end) diff --git a/lua/pending/diff.lua b/lua/pending/diff.lua index 9f5e577..1107b31 100644 --- a/lua/pending/diff.lua +++ b/lua/pending/diff.lua @@ -34,10 +34,11 @@ function M.parse_buffer(lines) table.insert(result, { type = 'blank', lnum = i }) elseif id or body then local stripped = body:match('^ (.+)$') or body + local prio_str = stripped:match('^%[(%d+)%] ') local priority = 0 - if stripped:match('^! ') then - priority = 1 - stripped = stripped:sub(3) + if prio_str then + priority = tonumber(prio_str) + stripped = stripped:sub(#prio_str + 4) end local description, metadata = parse.body(stripped) if description and description ~= '' then diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 86c1971..1593bc5 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -52,8 +52,17 @@ function M._setup_buf_mappings(bufnr) vim.keymap.set('n', 'g?', function() M.show_help() end, opts) - vim.keymap.set('n', '!', function() - M.toggle_priority() + vim.keymap.set('n', '', function() + M.change_priority(1) + end, opts) + vim.keymap.set('n', '', function() + M.change_priority(-1) + end, opts) + vim.keymap.set('v', 'g', function() + M.change_priority_visual(1) + end, opts) + vim.keymap.set('v', 'g', function() + M.change_priority_visual(-1) end, opts) vim.keymap.set('n', 'D', function() M.prompt_date() @@ -119,7 +128,8 @@ function M.toggle_complete() buffer.render(bufnr) end -function M.toggle_priority() +---@param delta integer +function M.change_priority(delta) local bufnr = buffer.bufnr() if not bufnr then return @@ -137,7 +147,7 @@ function M.toggle_priority() if not task then return end - local new_priority = task.priority == 1 and 0 or 1 + local new_priority = math.max(0, task.priority + delta) store.update(id, { priority = new_priority }) store.save() buffer.render(bufnr) @@ -149,6 +159,33 @@ function M.toggle_priority() end end +---@param delta integer +function M.change_priority_visual(delta) + local bufnr = buffer.bufnr() + if not bufnr then + return + end + local start_row = vim.fn.line("'<") + local end_row = vim.fn.line("'>") + local meta = buffer.meta() + local changed = false + for row = start_row, end_row do + local m = meta[row] + if m and m.type == 'task' and m.id then + local task = store.get(m.id) + if task then + local new_priority = math.max(0, task.priority + delta) + store.update(m.id, { priority = new_priority }) + changed = true + end + end + end + if changed then + store.save() + buffer.render(bufnr) + end +end + function M.prompt_date() local bufnr = buffer.bufnr() if not bufnr then @@ -305,7 +342,10 @@ function M.show_help() '', ' Toggle complete/uncomplete', ' Switch category/priority view', - '! Toggle priority', + ' Raise priority level', + ' Lower priority level', + 'g Raise priority for visual selection', + 'g Lower priority for visual selection', 'D Set due date', 'U Undo last write', 'o / O Add new task line', @@ -330,7 +370,8 @@ function M.show_help() ' Empty input clears due date', '', 'Highlights:', - ' PendingOverdue overdue tasks (red)', + ' PendingOverdue overdue tasks (red)', + ' PendingPriority [N] priority prefix', '', 'Press q or to close', } diff --git a/lua/pending/views.lua b/lua/pending/views.lua index 1e599f5..84567e9 100644 --- a/lua/pending/views.lua +++ b/lua/pending/views.lua @@ -9,6 +9,7 @@ local config = require('pending.config') ---@field category? string ---@field overdue? boolean ---@field show_category? boolean +---@field priority? integer ---@class pending.views local M = {} @@ -138,7 +139,7 @@ function M.category_view(tasks) for _, task in ipairs(all) do local prefix = '/' .. task.id .. '/' local indent = ' ' - local prio = task.priority == 1 and '! ' or '' + local prio = task.priority > 0 and ('[' .. task.priority .. '] ') or '' local line = prefix .. indent .. prio .. task.description table.insert(lines, line) table.insert(meta, { diff --git a/plugin/pending.lua b/plugin/pending.lua index 7270825..56dedc3 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -30,8 +30,12 @@ vim.keymap.set('n', '(pending-view)', function() require('pending.buffer').toggle_view() end) -vim.keymap.set('n', '(pending-priority)', function() - require('pending').toggle_priority() +vim.keymap.set('n', '(pending-priority-up)', function() + require('pending').change_priority(1) +end) + +vim.keymap.set('n', '(pending-priority-down)', function() + require('pending').change_priority(-1) end) vim.keymap.set('n', '(pending-date)', function() From 7f0bd43b34e6c79b6c96f8fa76d33ef554dbc159 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 22:01:57 -0500 Subject: [PATCH 077/199] feat(buffer): preserve category fold state across re-renders Problem: pressing :w, toggling priority, or any other operation that calls buffer.render() reset foldlevel = 99, causing all manually collapsed category sections to snap back open. Solution: snapshot which categories are folded (per window) before nvim_buf_set_lines destroys the fold tree, then restore them after fold options are re-applied by calling normal! zc on each previously closed header line. State persists across all render call sites within a session. --- lua/pending/buffer.lua | 48 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index 2036357..861aca3 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -12,6 +12,8 @@ local task_ns = vim.api.nvim_create_namespace('pending') local current_view = nil ---@type pending.LineMeta[] local _meta = {} +---@type table> +local _fold_state = {} ---@return pending.LineMeta[] function M.meta() @@ -148,6 +150,50 @@ local function setup_highlights() vim.api.nvim_set_hl(0, 'PendingPriority', { link = 'DiagnosticWarn', default = true }) end +local function snapshot_folds(bufnr) + if current_view ~= 'category' then + return + end + for _, winid in ipairs(vim.fn.win_findbuf(bufnr)) do + 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) + _fold_state[winid] = state + end +end + +local function restore_folds(bufnr) + if current_view ~= 'category' then + return + end + for _, winid in ipairs(vim.fn.win_findbuf(bufnr)) do + local state = _fold_state[winid] + if not state or next(state) == nil then + goto continue + end + 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 + vim.api.nvim_win_set_cursor(0, { lnum, 0 }) + vim.cmd('normal! zc') + end + end + vim.api.nvim_win_set_cursor(0, saved) + end) + _fold_state[winid] = nil + ::continue:: + end +end + ---@param bufnr? integer function M.render(bufnr) bufnr = bufnr or task_bufnr @@ -167,6 +213,7 @@ function M.render(bufnr) _meta = line_meta + snapshot_folds(bufnr) vim.bo[bufnr].modifiable = true vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) vim.bo[bufnr].modified = false @@ -185,6 +232,7 @@ function M.render(bufnr) vim.wo[winid].foldenable = false end end + restore_folds(bufnr) end function M.toggle_view() From 2482df0f0b4401cb85492d187b90fd11b8157546 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 22:01:57 -0500 Subject: [PATCH 078/199] feat(buffer): preserve category fold state across re-renders Problem: pressing :w, toggling priority, or any other operation that calls buffer.render() reset foldlevel = 99, causing all manually collapsed category sections to snap back open. Solution: snapshot which categories are folded (per window) before nvim_buf_set_lines destroys the fold tree, then restore them after fold options are re-applied by calling normal! zc on each previously closed header line. State persists across all render call sites within a session. --- lua/pending/buffer.lua | 48 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index 2036357..861aca3 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -12,6 +12,8 @@ local task_ns = vim.api.nvim_create_namespace('pending') local current_view = nil ---@type pending.LineMeta[] local _meta = {} +---@type table> +local _fold_state = {} ---@return pending.LineMeta[] function M.meta() @@ -148,6 +150,50 @@ local function setup_highlights() vim.api.nvim_set_hl(0, 'PendingPriority', { link = 'DiagnosticWarn', default = true }) end +local function snapshot_folds(bufnr) + if current_view ~= 'category' then + return + end + for _, winid in ipairs(vim.fn.win_findbuf(bufnr)) do + 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) + _fold_state[winid] = state + end +end + +local function restore_folds(bufnr) + if current_view ~= 'category' then + return + end + for _, winid in ipairs(vim.fn.win_findbuf(bufnr)) do + local state = _fold_state[winid] + if not state or next(state) == nil then + goto continue + end + 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 + vim.api.nvim_win_set_cursor(0, { lnum, 0 }) + vim.cmd('normal! zc') + end + end + vim.api.nvim_win_set_cursor(0, saved) + end) + _fold_state[winid] = nil + ::continue:: + end +end + ---@param bufnr? integer function M.render(bufnr) bufnr = bufnr or task_bufnr @@ -167,6 +213,7 @@ function M.render(bufnr) _meta = line_meta + snapshot_folds(bufnr) vim.bo[bufnr].modifiable = true vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) vim.bo[bufnr].modified = false @@ -185,6 +232,7 @@ function M.render(bufnr) vim.wo[winid].foldenable = false end end + restore_folds(bufnr) end function M.toggle_view() From 8f9052bad1c70dcbd7ee1cf2ae300b4c5c60c4eb Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 22:25:00 -0500 Subject: [PATCH 079/199] ci: format --- lua/pending/buffer.lua | 28 +++++++++++++--------------- spec/parse_spec.lua | 14 ++++++++++---- 2 files changed, 23 insertions(+), 19 deletions(-) diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index 861aca3..7c730e6 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -175,22 +175,20 @@ local function restore_folds(bufnr) end for _, winid in ipairs(vim.fn.win_findbuf(bufnr)) do local state = _fold_state[winid] - if not state or next(state) == nil then - goto continue - end - 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 - vim.api.nvim_win_set_cursor(0, { lnum, 0 }) - vim.cmd('normal! zc') + if state and next(state) ~= nil then + 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 + vim.api.nvim_win_set_cursor(0, { lnum, 0 }) + vim.cmd('normal! zc') + end end - end - vim.api.nvim_win_set_cursor(0, saved) - end) - _fold_state[winid] = nil - ::continue:: + vim.api.nvim_win_set_cursor(0, saved) + end) + _fold_state[winid] = nil + end end end diff --git a/spec/parse_spec.lua b/spec/parse_spec.lua index b4442e9..ca8047c 100644 --- a/spec/parse_spec.lua +++ b/spec/parse_spec.lua @@ -96,7 +96,10 @@ describe('parse', function() it('resolves due:+2d to today plus 2 days', 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 expected = os.date( + '%Y-%m-%d', + os.time({ year = today.year, month = today.month, day = today.day + 2 }) + ) local desc, meta = parse.body('Task due:+2d') assert.are.equal('Task', desc) assert.are.equal(expected, meta.due) @@ -123,7 +126,10 @@ describe('parse', function() it("returns today + 3 days for '+3d'", 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 + 3 })) + local expected = os.date( + '%Y-%m-%d', + os.time({ year = today.year, month = today.month, day = today.day + 3 }) + ) local result = parse.resolve_date('+3d') assert.are.equal(expected, result) end) @@ -139,12 +145,12 @@ describe('parse', function() assert.truthy(result:match('^%d%d%d%d%-%d%d%-%d%d$')) end) - it("returns nil for garbage input", function() + it('returns nil for garbage input', function() local result = parse.resolve_date('notadate') assert.is_nil(result) end) - it("returns nil for empty string", function() + it('returns nil for empty string', function() local result = parse.resolve_date('') assert.is_nil(result) end) From 49c93f08fe5181a732bd63498d5f818deb11e7ce Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 22:25:00 -0500 Subject: [PATCH 080/199] ci: format --- lua/pending/buffer.lua | 28 +++++++++++++--------------- spec/parse_spec.lua | 14 ++++++++++---- 2 files changed, 23 insertions(+), 19 deletions(-) diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index 861aca3..7c730e6 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -175,22 +175,20 @@ local function restore_folds(bufnr) end for _, winid in ipairs(vim.fn.win_findbuf(bufnr)) do local state = _fold_state[winid] - if not state or next(state) == nil then - goto continue - end - 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 - vim.api.nvim_win_set_cursor(0, { lnum, 0 }) - vim.cmd('normal! zc') + if state and next(state) ~= nil then + 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 + vim.api.nvim_win_set_cursor(0, { lnum, 0 }) + vim.cmd('normal! zc') + end end - end - vim.api.nvim_win_set_cursor(0, saved) - end) - _fold_state[winid] = nil - ::continue:: + vim.api.nvim_win_set_cursor(0, saved) + end) + _fold_state[winid] = nil + end end end diff --git a/spec/parse_spec.lua b/spec/parse_spec.lua index b4442e9..ca8047c 100644 --- a/spec/parse_spec.lua +++ b/spec/parse_spec.lua @@ -96,7 +96,10 @@ describe('parse', function() it('resolves due:+2d to today plus 2 days', 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 expected = os.date( + '%Y-%m-%d', + os.time({ year = today.year, month = today.month, day = today.day + 2 }) + ) local desc, meta = parse.body('Task due:+2d') assert.are.equal('Task', desc) assert.are.equal(expected, meta.due) @@ -123,7 +126,10 @@ describe('parse', function() it("returns today + 3 days for '+3d'", 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 + 3 })) + local expected = os.date( + '%Y-%m-%d', + os.time({ year = today.year, month = today.month, day = today.day + 3 }) + ) local result = parse.resolve_date('+3d') assert.are.equal(expected, result) end) @@ -139,12 +145,12 @@ describe('parse', function() assert.truthy(result:match('^%d%d%d%d%-%d%d%-%d%d$')) end) - it("returns nil for garbage input", function() + it('returns nil for garbage input', function() local result = parse.resolve_date('notadate') assert.is_nil(result) end) - it("returns nil for empty string", function() + it('returns nil for empty string', function() local result = parse.resolve_date('') assert.is_nil(result) end) From 437944d441bf7e9cebb0e90cfa5a6ea9c28cf4d4 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 22:31:37 -0500 Subject: [PATCH 081/199] fix(diff): cast tonumber result to integer Problem: LuaLS infers priority as integer from the = 0 initialiser but tonumber returns number?, causing a cast-local-type diagnostic. Solution: inline --[[@as integer]] cast after the tonumber call. --- lua/pending/diff.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/pending/diff.lua b/lua/pending/diff.lua index 1107b31..607aef6 100644 --- a/lua/pending/diff.lua +++ b/lua/pending/diff.lua @@ -37,7 +37,7 @@ function M.parse_buffer(lines) local prio_str = stripped:match('^%[(%d+)%] ') local priority = 0 if prio_str then - priority = tonumber(prio_str) + priority = tonumber(prio_str) --[[@as integer]] stripped = stripped:sub(#prio_str + 4) end local description, metadata = parse.body(stripped) From ade541e6e3493624ee45e818245fee87fa9edfc4 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 22:31:37 -0500 Subject: [PATCH 082/199] fix(diff): cast tonumber result to integer Problem: LuaLS infers priority as integer from the = 0 initialiser but tonumber returns number?, causing a cast-local-type diagnostic. Solution: inline --[[@as integer]] cast after the tonumber call. --- lua/pending/diff.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/pending/diff.lua b/lua/pending/diff.lua index 1107b31..607aef6 100644 --- a/lua/pending/diff.lua +++ b/lua/pending/diff.lua @@ -37,7 +37,7 @@ function M.parse_buffer(lines) local prio_str = stripped:match('^%[(%d+)%] ') local priority = 0 if prio_str then - priority = tonumber(prio_str) + priority = tonumber(prio_str) --[[@as integer]] stripped = stripped:sub(#prio_str + 4) end local description, metadata = parse.body(stripped) From cfdffdadfeee5fd0c858eb8102f6ad467138a5fe Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 22:31:43 -0500 Subject: [PATCH 083/199] test: update priority format assertions from ! to [N] Problem: fc4a47a changed the priority display format from '! ' to '[N] ' in views.lua and diff.lua but left two existing test assertions and their descriptions using the old format, causing both to fail. Solution: update the input line in diff parse_buffer test, update the expected string and description names in views category_view test, and rename the diff.apply description to match the new idiom. --- spec/diff_spec.lua | 4 ++-- spec/views_spec.lua | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/spec/diff_spec.lua b/spec/diff_spec.lua index b8fcfd9..7a73c5d 100644 --- a/spec/diff_spec.lua +++ b/spec/diff_spec.lua @@ -27,7 +27,7 @@ describe('diff', function() local lines = { 'School', '/1/ Do homework', - '/2/ ! Read chapter 5', + '/2/ [1] Read chapter 5', '', 'Errands', '/3/ Buy groceries', @@ -168,7 +168,7 @@ describe('diff', function() assert.is_nil(task.due) end) - it('clears priority when ! is removed from buffer line', function() + it('clears priority when [N] is removed from buffer line', function() store.add({ description = 'Task name', priority = 1 }) store.save() local lines = { diff --git a/spec/views_spec.lua b/spec/views_spec.lua index 9ba12f9..0f5e5e5 100644 --- a/spec/views_spec.lua +++ b/spec/views_spec.lua @@ -116,7 +116,7 @@ describe('views', function() assert.are.equal('/1/ My task', task_line) end) - it('formats priority task lines as /ID/ ! description', function() + it('formats priority task lines as /ID/ [N] description', function() store.add({ description = 'Important', category = 'Inbox', priority = 1 }) local lines, meta = views.category_view(store.active_tasks()) local task_line @@ -125,7 +125,7 @@ describe('views', function() task_line = lines[i] end end - assert.are.equal('/1/ ! Important', task_line) + assert.are.equal('/1/ [1] Important', task_line) end) it('sets LineMeta type=header for header lines with correct category', function() From 0ede508af8819e6f16621aa0f6cb5abe1e5add39 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 22:31:43 -0500 Subject: [PATCH 084/199] test: update priority format assertions from ! to [N] Problem: fc4a47a changed the priority display format from '! ' to '[N] ' in views.lua and diff.lua but left two existing test assertions and their descriptions using the old format, causing both to fail. Solution: update the input line in diff parse_buffer test, update the expected string and description names in views category_view test, and rename the diff.apply description to match the new idiom. --- spec/diff_spec.lua | 4 ++-- spec/views_spec.lua | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/spec/diff_spec.lua b/spec/diff_spec.lua index b8fcfd9..7a73c5d 100644 --- a/spec/diff_spec.lua +++ b/spec/diff_spec.lua @@ -27,7 +27,7 @@ describe('diff', function() local lines = { 'School', '/1/ Do homework', - '/2/ ! Read chapter 5', + '/2/ [1] Read chapter 5', '', 'Errands', '/3/ Buy groceries', @@ -168,7 +168,7 @@ describe('diff', function() assert.is_nil(task.due) end) - it('clears priority when ! is removed from buffer line', function() + it('clears priority when [N] is removed from buffer line', function() store.add({ description = 'Task name', priority = 1 }) store.save() local lines = { diff --git a/spec/views_spec.lua b/spec/views_spec.lua index 9ba12f9..0f5e5e5 100644 --- a/spec/views_spec.lua +++ b/spec/views_spec.lua @@ -116,7 +116,7 @@ describe('views', function() assert.are.equal('/1/ My task', task_line) end) - it('formats priority task lines as /ID/ ! description', function() + it('formats priority task lines as /ID/ [N] description', function() store.add({ description = 'Important', category = 'Inbox', priority = 1 }) local lines, meta = views.category_view(store.active_tasks()) local task_line @@ -125,7 +125,7 @@ describe('views', function() task_line = lines[i] end end - assert.are.equal('/1/ ! Important', task_line) + assert.are.equal('/1/ [1] Important', task_line) end) it('sets LineMeta type=header for header lines with correct category', function() From 8e16744ebec6d2d8ed38ba4916250dd808801821 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Tue, 24 Feb 2026 22:33:13 -0500 Subject: [PATCH 085/199] test: add missing coverage (#19) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: add top-priority missing test coverage Problem: several critical code paths had zero test coverage — parse.resolve_date (relative date resolution), store.snapshot (foundation of the undo stack), and the diff.apply invariant that unchanged tasks do not get their modified timestamp bumped. The diff.apply due/priority clearing paths were also untested. Solution: add six targeted test blocks across parse_spec, store_spec, and diff_spec: resolve_date happy/failure paths, parse.body with relative due tokens, snapshot copy-semantics and deleted-task exclusion, diff unchanged-modified invariant, due cleared on removal, priority cleared on ! removal. * test: add second batch of missing test coverage Problem: six more gaps from the audit remained after the first batch — archive persistence verification, diff modified-on-rename, parse_buffer inline cat:/due: token parsing, and store.update immutability invariants. Solution: add six it() blocks across archive_spec, diff_spec, and store_spec: archive unload/reload persistence check, modified timestamp updated on description change, inline cat: overrides header category, inline due: token parsed from buffer line, id/entry fields immutable under store.update, and end timestamp not overwritten on second completion. --- spec/archive_spec.lua | 9 +++++++++ spec/diff_spec.lua | 38 ++++++++++++++++++++++++++++++++++++++ spec/store_spec.lua | 21 +++++++++++++++++++++ 3 files changed, 68 insertions(+) diff --git a/spec/archive_spec.lua b/spec/archive_spec.lua index a71eee8..df1a912 100644 --- a/spec/archive_spec.lua +++ b/spec/archive_spec.lua @@ -128,4 +128,13 @@ describe('archive', function() assert.is_true(descs['Keep pending']) assert.is_true(descs['Keep recent done']) 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' }) + pending.archive() + store.unload() + store.load() + assert.are.equal(0, #store.active_tasks()) + end) end) diff --git a/spec/diff_spec.lua b/spec/diff_spec.lua index 7a73c5d..3073879 100644 --- a/spec/diff_spec.lua +++ b/spec/diff_spec.lua @@ -57,6 +57,28 @@ describe('diff', function() assert.is_nil(result[2].id) assert.are.equal('New task here', result[2].description) end) + + it('inline cat: token overrides header category', function() + local lines = { + 'Inbox', + '/1/ Buy milk cat:Work', + } + local result = diff.parse_buffer(lines) + assert.are.equal(2, #result) + assert.are.equal('task', result[2].type) + assert.are.equal('Work', result[2].category) + end) + + it('inline due: token is parsed', function() + local lines = { + 'Inbox', + '/1/ Buy milk due:2026-03-15', + } + local result = diff.parse_buffer(lines) + assert.are.equal(2, #result) + assert.are.equal('task', result[2].type) + assert.are.equal('2026-03-15', result[2].due) + end) end) describe('apply', function() @@ -107,6 +129,22 @@ describe('diff', function() assert.are.equal('Renamed', task.description) end) + it('updates modified when description is renamed', function() + local t = store.add({ description = 'Original', category = 'Inbox' }) + t.modified = '2020-01-01T00:00:00Z' + store.save() + local lines = { + 'Inbox', + '/1/ Renamed', + } + diff.apply(lines) + store.unload() + store.load() + local task = store.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() diff --git a/spec/store_spec.lua b/spec/store_spec.lua index 930fbc0..25b8b7c 100644 --- a/spec/store_spec.lua +++ b/spec/store_spec.lua @@ -121,6 +121,27 @@ describe('store', function() local updated = store.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 original_id = t.id + local original_entry = t.entry + store.update(t.id, { id = 999, entry = 'x' }) + local updated = store.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) + assert.are.equal(first_end, task['end']) + end) end) describe('delete', function() From 114048298b8ec8cca8068812411fbdfcde2b35f7 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Tue, 24 Feb 2026 22:33:13 -0500 Subject: [PATCH 086/199] test: add missing coverage (#19) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: add top-priority missing test coverage Problem: several critical code paths had zero test coverage — parse.resolve_date (relative date resolution), store.snapshot (foundation of the undo stack), and the diff.apply invariant that unchanged tasks do not get their modified timestamp bumped. The diff.apply due/priority clearing paths were also untested. Solution: add six targeted test blocks across parse_spec, store_spec, and diff_spec: resolve_date happy/failure paths, parse.body with relative due tokens, snapshot copy-semantics and deleted-task exclusion, diff unchanged-modified invariant, due cleared on removal, priority cleared on ! removal. * test: add second batch of missing test coverage Problem: six more gaps from the audit remained after the first batch — archive persistence verification, diff modified-on-rename, parse_buffer inline cat:/due: token parsing, and store.update immutability invariants. Solution: add six it() blocks across archive_spec, diff_spec, and store_spec: archive unload/reload persistence check, modified timestamp updated on description change, inline cat: overrides header category, inline due: token parsed from buffer line, id/entry fields immutable under store.update, and end timestamp not overwritten on second completion. --- spec/archive_spec.lua | 9 +++++++++ spec/diff_spec.lua | 38 ++++++++++++++++++++++++++++++++++++++ spec/store_spec.lua | 21 +++++++++++++++++++++ 3 files changed, 68 insertions(+) diff --git a/spec/archive_spec.lua b/spec/archive_spec.lua index a71eee8..df1a912 100644 --- a/spec/archive_spec.lua +++ b/spec/archive_spec.lua @@ -128,4 +128,13 @@ describe('archive', function() assert.is_true(descs['Keep pending']) assert.is_true(descs['Keep recent done']) 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' }) + pending.archive() + store.unload() + store.load() + assert.are.equal(0, #store.active_tasks()) + end) end) diff --git a/spec/diff_spec.lua b/spec/diff_spec.lua index 7a73c5d..3073879 100644 --- a/spec/diff_spec.lua +++ b/spec/diff_spec.lua @@ -57,6 +57,28 @@ describe('diff', function() assert.is_nil(result[2].id) assert.are.equal('New task here', result[2].description) end) + + it('inline cat: token overrides header category', function() + local lines = { + 'Inbox', + '/1/ Buy milk cat:Work', + } + local result = diff.parse_buffer(lines) + assert.are.equal(2, #result) + assert.are.equal('task', result[2].type) + assert.are.equal('Work', result[2].category) + end) + + it('inline due: token is parsed', function() + local lines = { + 'Inbox', + '/1/ Buy milk due:2026-03-15', + } + local result = diff.parse_buffer(lines) + assert.are.equal(2, #result) + assert.are.equal('task', result[2].type) + assert.are.equal('2026-03-15', result[2].due) + end) end) describe('apply', function() @@ -107,6 +129,22 @@ describe('diff', function() assert.are.equal('Renamed', task.description) end) + it('updates modified when description is renamed', function() + local t = store.add({ description = 'Original', category = 'Inbox' }) + t.modified = '2020-01-01T00:00:00Z' + store.save() + local lines = { + 'Inbox', + '/1/ Renamed', + } + diff.apply(lines) + store.unload() + store.load() + local task = store.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() diff --git a/spec/store_spec.lua b/spec/store_spec.lua index 930fbc0..25b8b7c 100644 --- a/spec/store_spec.lua +++ b/spec/store_spec.lua @@ -121,6 +121,27 @@ describe('store', function() local updated = store.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 original_id = t.id + local original_entry = t.entry + store.update(t.id, { id = 999, entry = 'x' }) + local updated = store.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) + assert.are.equal(first_end, task['end']) + end) end) describe('delete', function() From 5db242a9cfa1043522baedc7fb841fa244fdca00 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Tue, 24 Feb 2026 23:21:55 -0500 Subject: [PATCH 087/199] refactor: adopt markdown-style checkbox buffer format (#20) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(config): change default category from Inbox to Todo * refactor(views): adopt markdown checkbox line format Problem: task lines used an opaque /ID/ [N] prefix format that was hard to read and inconsistent between category and priority views. Header lines had no visual marker distinguishing them from tasks. Solution: render headers as '## Cat', task lines as '/ID/- [x|!| ] description'. State encoding: [x]=done, [!]=urgent, [ ]=pending. Both views use the same construction. * refactor(diff): parse and reconcile markdown checkbox format Problem: parse_buffer matched the old ' text' indent pattern and detected headers via '^%S'. Priority was read from a '[N] ' prefix. apply() never reconciled status changes written into the buffer. Solution: match '- [.] text' for tasks and '^## ' for headers. Extract state char to derive priority (! -> 1) and status (x -> done). apply() now reconciles status from the buffer, setting/clearing 'end' timestamps — enabling the oil-style edit-checkbox-then-:w workflow. * refactor(buffer): update syntax, extmarks, and render for checkbox format Problem: syntax patterns matched the old indent/[N] format; right_align virtual text produced a broken layout in narrow windows; the done strikethrough skipped past the ' ' indent leaving '- [x] ' unstyled; render() added undo history entries so 'u' could undo a re-render. Solution: update taskHeader/taskLine patterns for '## '/'- [.]'; rename taskPriority -> taskCheckbox matching '[!]'; switch virt_text_pos to 'eol'; drop the +2 col_start offset so strikethrough covers '- [x] '; guard nvim_buf_set_lines with undolevels=-1 so renders are not undoable. Also fix open_line to insert '- [ ] ' and position cursor at col 6. * refactor(init): replace multi-level priority with binary toggle Problem: / overrode Vim's native number increment and the visual g/g variants added complexity for marginal value. toggle_complete() left the cursor on the wrong line after re-render. Solution: remove change_priority/change_priority_visual; add toggle_priority() (0<->1) mapped to '!', with cursor-follow after render matching the pattern already used in priority toggle. Add cursor-follow to toggle_complete() for the same reason. Update plugin plugs (priority-up/down -> priority) and add 'due'/'undo' to the :Pending completion list. Update help text accordingly. * feat(buffer): reflect current view in buffer name Problem: no way to tell at a glance which view (category vs priority) is active — the buffer was always named 'pending://'. Solution: update the buffer name to 'pending://category' or 'pending://priority' on every render, so the view is visible in the statusline/tabline without any extra UI. --- lua/pending/buffer.lua | 21 ++++++++------ lua/pending/config.lua | 2 +- lua/pending/diff.lua | 32 ++++++++++++++-------- lua/pending/init.lua | 58 ++++++++------------------------------- lua/pending/views.lua | 12 ++++---- plugin/pending.lua | 10 ++----- spec/diff_spec.lua | 62 +++++++++++++++++++++--------------------- spec/store_spec.lua | 2 +- spec/views_spec.lua | 24 ++++++++-------- syntax/pending.vim | 8 +++--- 10 files changed, 101 insertions(+), 130 deletions(-) diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index 7c730e6..8f9fb59 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -58,9 +58,9 @@ local function setup_syntax(bufnr) vim.cmd([[ syntax clear syntax match taskId /^\/\d\+\// conceal - syntax match taskHeader /^\S.*$/ contains=taskId - syntax match taskPriority /\[\d\+\] / contained containedin=taskLine - syntax match taskLine /^\/\d\+\/ .*$/ contains=taskId,taskPriority + syntax match taskHeader /^## .*$/ contains=taskId + syntax match taskCheckbox /\[!\]/ contained containedin=taskLine + syntax match taskLine /^\/\d\+\/- \[.\] .*$/ contains=taskId,taskCheckbox ]]) end) end @@ -74,8 +74,8 @@ function M.open_line(above) local row = vim.api.nvim_win_get_cursor(0)[1] 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, { ' ' }) - vim.api.nvim_win_set_cursor(0, { insert_row + 1, 2 }) + vim.api.nvim_buf_set_lines(bufnr, insert_row, insert_row, false, { '- [ ] ' }) + vim.api.nvim_win_set_cursor(0, { insert_row + 1, 6 }) vim.cmd('startinsert!') end @@ -115,18 +115,18 @@ local function apply_extmarks(bufnr, line_meta) if virt_text then vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, { virt_text = virt_text, - virt_text_pos = 'right_align', + 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_pos = 'right_align', + 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+/')) + 2 or 0 + 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', @@ -200,6 +200,7 @@ 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 tasks = store.active_tasks() local lines, line_meta @@ -213,8 +214,11 @@ function M.render(bufnr) snapshot_folds(bufnr) vim.bo[bufnr].modifiable = true + local saved = vim.bo[bufnr].undolevels + vim.bo[bufnr].undolevels = -1 vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) vim.bo[bufnr].modified = false + vim.bo[bufnr].undolevels = saved setup_syntax(bufnr) apply_extmarks(bufnr, line_meta) @@ -261,7 +265,6 @@ function M.open() end task_bufnr = vim.api.nvim_create_buf(true, false) - vim.api.nvim_buf_set_name(task_bufnr, 'pending://') set_buf_options(task_bufnr) vim.api.nvim_set_current_buf(task_bufnr) diff --git a/lua/pending/config.lua b/lua/pending/config.lua index d137acb..2e647e4 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -18,7 +18,7 @@ local M = {} local defaults = { 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', category_order = {}, diff --git a/lua/pending/diff.lua b/lua/pending/diff.lua index 607aef6..85f083c 100644 --- a/lua/pending/diff.lua +++ b/lua/pending/diff.lua @@ -7,6 +7,7 @@ local store = require('pending.store') ---@field id? integer ---@field description? string ---@field priority? integer +---@field status? string ---@field category? string ---@field due? string ---@field lnum integer @@ -26,20 +27,17 @@ function M.parse_buffer(lines) local current_category = nil for i, line in ipairs(lines) do - 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 prio_str = stripped:match('^%[(%d+)%] ') - local priority = 0 - if prio_str then - priority = tonumber(prio_str) --[[@as integer]] - stripped = stripped:sub(#prio_str + 4) - end + 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 description, metadata = parse.body(stripped) if description and description ~= '' then table.insert(result, { @@ -47,14 +45,15 @@ function M.parse_buffer(lines) id = id and tonumber(id) or nil, description = description, priority = priority, + status = status, category = metadata.cat or current_category or config.get().default_category, due = metadata.due, lnum = i, }) end - elseif line:match('^%S') then - current_category = line - table.insert(result, { type = 'header', category = line, lnum = i }) + elseif line:match('^## (.+)$') then + current_category = line:match('^## (.+)$') + table.insert(result, { type = 'header', category = current_category, lnum = i }) end end @@ -113,6 +112,15 @@ function M.apply(lines) task.due = entry.due changed = true end + if entry.status and task.status ~= entry.status then + task.status = entry.status + if entry.status == 'done' then + task['end'] = now + else + task['end'] = nil + end + changed = true + end if task.order ~= order_counter then task.order = order_counter changed = true diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 1593bc5..ec69d89 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -52,17 +52,8 @@ function M._setup_buf_mappings(bufnr) vim.keymap.set('n', 'g?', function() M.show_help() end, opts) - vim.keymap.set('n', '', function() - M.change_priority(1) - end, opts) - vim.keymap.set('n', '', function() - M.change_priority(-1) - end, opts) - vim.keymap.set('v', 'g', function() - M.change_priority_visual(1) - end, opts) - vim.keymap.set('v', 'g', function() - M.change_priority_visual(-1) + vim.keymap.set('n', '!', function() + M.toggle_priority() end, opts) vim.keymap.set('n', 'D', function() M.prompt_date() @@ -126,10 +117,15 @@ function M.toggle_complete() end store.save() 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 delta integer -function M.change_priority(delta) +function M.toggle_priority() local bufnr = buffer.bufnr() if not bufnr then return @@ -147,7 +143,7 @@ function M.change_priority(delta) if not task then return end - local new_priority = math.max(0, task.priority + delta) + local new_priority = task.priority > 0 and 0 or 1 store.update(id, { priority = new_priority }) store.save() buffer.render(bufnr) @@ -159,33 +155,6 @@ function M.change_priority(delta) end end ----@param delta integer -function M.change_priority_visual(delta) - local bufnr = buffer.bufnr() - if not bufnr then - return - end - local start_row = vim.fn.line("'<") - local end_row = vim.fn.line("'>") - local meta = buffer.meta() - local changed = false - for row = start_row, end_row do - local m = meta[row] - if m and m.type == 'task' and m.id then - local task = store.get(m.id) - if task then - local new_priority = math.max(0, task.priority + delta) - store.update(m.id, { priority = new_priority }) - changed = true - end - end - end - if changed then - store.save() - buffer.render(bufnr) - end -end - function M.prompt_date() local bufnr = buffer.bufnr() if not bufnr then @@ -342,10 +311,7 @@ function M.show_help() '', ' Toggle complete/uncomplete', ' Switch category/priority view', - ' Raise priority level', - ' Lower priority level', - 'g Raise priority for visual selection', - 'g Lower priority for visual selection', + '! Toggle urgent', 'D Set due date', 'U Undo last write', 'o / O Add new task line', @@ -371,7 +337,7 @@ function M.show_help() '', 'Highlights:', ' PendingOverdue overdue tasks (red)', - ' PendingPriority [N] priority prefix', + ' PendingPriority [!] urgent tasks', '', 'Press q or to close', } diff --git a/lua/pending/views.lua b/lua/pending/views.lua index 84567e9..7bcfaca 100644 --- a/lua/pending/views.lua +++ b/lua/pending/views.lua @@ -125,7 +125,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 = {} @@ -138,9 +138,8 @@ function M.category_view(tasks) for _, task in ipairs(all) do local prefix = '/' .. task.id .. '/' - local indent = ' ' - local prio = task.priority > 0 and ('[' .. task.priority .. '] ') or '' - local line = prefix .. indent .. prio .. task.description + local state = task.status == 'done' and 'x' or (task.priority > 0 and '!' or ' ') + local line = prefix .. '- [' .. state .. '] ' .. task.description table.insert(lines, line) table.insert(meta, { type = 'task', @@ -189,9 +188,8 @@ function M.priority_view(tasks) for _, task in ipairs(all) do local prefix = '/' .. task.id .. '/' - local indent = ' ' - local prio = task.priority == 1 and '! ' or '' - local line = prefix .. indent .. prio .. task.description + local state = task.status == 'done' and 'x' or (task.priority > 0 and '!' or ' ') + local line = prefix .. '- [' .. state .. '] ' .. task.description table.insert(lines, line) table.insert(meta, { type = 'task', diff --git a/plugin/pending.lua b/plugin/pending.lua index 56dedc3..465ee65 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -8,7 +8,7 @@ vim.api.nvim_create_user_command('Pending', function(opts) end, { nargs = '*', complete = function(arg_lead, cmd_line) - local subcmds = { 'add', 'sync', 'archive' } + local subcmds = { 'add', 'sync', 'archive', 'due', 'undo' } if not cmd_line:match('^Pending%s+%S') then return vim.tbl_filter(function(s) return s:find(arg_lead, 1, true) == 1 @@ -30,12 +30,8 @@ vim.keymap.set('n', '(pending-view)', function() require('pending.buffer').toggle_view() end) -vim.keymap.set('n', '(pending-priority-up)', function() - require('pending').change_priority(1) -end) - -vim.keymap.set('n', '(pending-priority-down)', function() - require('pending').change_priority(-1) +vim.keymap.set('n', '(pending-priority)', function() + require('pending').toggle_priority() end) vim.keymap.set('n', '(pending-date)', function() diff --git a/spec/diff_spec.lua b/spec/diff_spec.lua index 3073879..fda2165 100644 --- a/spec/diff_spec.lua +++ b/spec/diff_spec.lua @@ -25,12 +25,12 @@ describe('diff', function() describe('parse_buffer', function() it('parses headers and tasks', function() local lines = { - 'School', - '/1/ Do homework', - '/2/ [1] Read chapter 5', + '## School', + '/1/- [ ] Do homework', + '/2/- [!] Read chapter 5', '', - 'Errands', - '/3/ Buy groceries', + '## Errands', + '/3/- [ ] Buy groceries', } local result = diff.parse_buffer(lines) assert.are.equal(6, #result) @@ -48,8 +48,8 @@ describe('diff', function() it('handles new tasks without ids', function() local lines = { - 'Inbox', - ' New task here', + '## Inbox', + '- [ ] New task here', } local result = diff.parse_buffer(lines) assert.are.equal(2, #result) @@ -60,8 +60,8 @@ describe('diff', function() it('inline cat: token overrides header category', function() local lines = { - 'Inbox', - '/1/ Buy milk cat:Work', + '## Inbox', + '/1/- [ ] Buy milk cat:Work', } local result = diff.parse_buffer(lines) assert.are.equal(2, #result) @@ -71,8 +71,8 @@ describe('diff', function() it('inline due: token is parsed', function() local lines = { - 'Inbox', - '/1/ Buy milk due:2026-03-15', + '## Inbox', + '/1/- [ ] Buy milk due:2026-03-15', } local result = diff.parse_buffer(lines) assert.are.equal(2, #result) @@ -84,9 +84,9 @@ describe('diff', function() describe('apply', function() it('creates new tasks from buffer lines', function() local lines = { - 'Inbox', - ' First task', - ' Second task', + '## Inbox', + '- [ ] First task', + '- [ ] Second task', } diff.apply(lines) store.unload() @@ -102,8 +102,8 @@ describe('diff', function() store.add({ description = 'Delete me' }) store.save() local lines = { - 'Inbox', - '/1/ Keep me', + '## Inbox', + '/1/- [ ] Keep me', } diff.apply(lines) store.unload() @@ -119,8 +119,8 @@ describe('diff', function() store.add({ description = 'Original' }) store.save() local lines = { - 'Inbox', - '/1/ Renamed', + '## Inbox', + '/1/- [ ] Renamed', } diff.apply(lines) store.unload() @@ -134,8 +134,8 @@ describe('diff', function() t.modified = '2020-01-01T00:00:00Z' store.save() local lines = { - 'Inbox', - '/1/ Renamed', + '## Inbox', + '/1/- [ ] Renamed', } diff.apply(lines) store.unload() @@ -149,9 +149,9 @@ describe('diff', function() store.add({ description = 'Original' }) store.save() local lines = { - 'Inbox', - '/1/ Original', - '/1/ Copy of original', + '## Inbox', + '/1/- [ ] Original', + '/1/- [ ] Copy of original', } diff.apply(lines) store.unload() @@ -164,8 +164,8 @@ describe('diff', function() store.add({ description = 'Moving task', category = 'Inbox' }) store.save() local lines = { - 'Work', - '/1/ Moving task', + '## Work', + '/1/- [ ] Moving task', } diff.apply(lines) store.unload() @@ -178,8 +178,8 @@ describe('diff', function() store.add({ description = 'Stable task', category = 'Inbox' }) store.save() local lines = { - 'Inbox', - '/1/ Stable task', + '## Inbox', + '/1/- [ ] Stable task', } diff.apply(lines) store.unload() @@ -196,8 +196,8 @@ describe('diff', function() store.add({ description = 'Pay bill', due = '2026-03-15' }) store.save() local lines = { - 'Inbox', - '/1/ Pay bill', + '## Inbox', + '/1/- [ ] Pay bill', } diff.apply(lines) store.unload() @@ -210,8 +210,8 @@ describe('diff', function() store.add({ description = 'Task name', priority = 1 }) store.save() local lines = { - 'Inbox', - '/1/ Task name', + '## Inbox', + '/1/- [ ] Task name', } diff.apply(lines) store.unload() diff --git a/spec/store_spec.lua b/spec/store_spec.lua index 25b8b7c..bb6266d 100644 --- a/spec/store_spec.lua +++ b/spec/store_spec.lua @@ -92,7 +92,7 @@ describe('store', function() assert.are.equal(1, t1.id) assert.are.equal(2, t2.id) assert.are.equal('pending', t1.status) - assert.are.equal('Inbox', t1.category) + assert.are.equal('Todo', t1.category) end) it('uses provided category', function() diff --git a/spec/views_spec.lua b/spec/views_spec.lua index 0f5e5e5..4d91e06 100644 --- a/spec/views_spec.lua +++ b/spec/views_spec.lua @@ -27,7 +27,7 @@ describe('views', function() store.add({ description = 'Task A', category = 'Work' }) store.add({ description = 'Task B', category = 'Work' }) local lines, meta = views.category_view(store.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) @@ -113,10 +113,10 @@ describe('views', function() task_line = lines[i] end end - assert.are.equal('/1/ My task', task_line) + assert.are.equal('/1/- [ ] My task', task_line) end) - it('formats priority task lines as /ID/ [N] description', function() + 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()) local task_line @@ -125,7 +125,7 @@ describe('views', function() task_line = lines[i] end end - assert.are.equal('/1/ [1] Important', task_line) + assert.are.equal('/1/- [!] Important', task_line) end) it('sets LineMeta type=header for header lines with correct category', function() @@ -220,8 +220,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() @@ -236,8 +236,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() @@ -250,8 +250,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) @@ -325,10 +325,10 @@ describe('views', function() assert.is_true(earlier_row < later_row) end) - it('formats task lines as /ID/ description', function() + it('formats task lines as /ID/- [ ] description', function() store.add({ description = 'My task', category = 'Inbox' }) local lines, _ = views.priority_view(store.active_tasks()) - assert.are.equal('/1/ My task', lines[1]) + assert.are.equal('/1/- [ ] My task', lines[1]) end) it('sets show_category=true for all task meta entries', function() diff --git a/syntax/pending.vim b/syntax/pending.vim index b5f3da1..a8a0258 100644 --- a/syntax/pending.vim +++ b/syntax/pending.vim @@ -3,12 +3,12 @@ if exists('b:current_syntax') endif syntax match taskId /^\/\d\+\// conceal -syntax match taskHeader /^\S.*$/ contains=taskId -syntax match taskPriority /!\ze / contained -syntax match taskLine /^\/\d\+\/ .*$/ contains=taskId,taskPriority +syntax match taskHeader /^## .*$/ contains=taskId +syntax match taskCheckbox /\[!\]/ contained containedin=taskLine +syntax match taskLine /^\/\d\+\/- \[.\] .*$/ contains=taskId,taskCheckbox highlight default link taskHeader PendingHeader -highlight default link taskPriority PendingPriority +highlight default link taskCheckbox PendingPriority highlight default link taskLine Normal let b:current_syntax = 'task' From e04440bb3d69ec681226bd16fbef53785bdb9f5c Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Tue, 24 Feb 2026 23:21:55 -0500 Subject: [PATCH 088/199] refactor: adopt markdown-style checkbox buffer format (#20) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(config): change default category from Inbox to Todo * refactor(views): adopt markdown checkbox line format Problem: task lines used an opaque /ID/ [N] prefix format that was hard to read and inconsistent between category and priority views. Header lines had no visual marker distinguishing them from tasks. Solution: render headers as '## Cat', task lines as '/ID/- [x|!| ] description'. State encoding: [x]=done, [!]=urgent, [ ]=pending. Both views use the same construction. * refactor(diff): parse and reconcile markdown checkbox format Problem: parse_buffer matched the old ' text' indent pattern and detected headers via '^%S'. Priority was read from a '[N] ' prefix. apply() never reconciled status changes written into the buffer. Solution: match '- [.] text' for tasks and '^## ' for headers. Extract state char to derive priority (! -> 1) and status (x -> done). apply() now reconciles status from the buffer, setting/clearing 'end' timestamps — enabling the oil-style edit-checkbox-then-:w workflow. * refactor(buffer): update syntax, extmarks, and render for checkbox format Problem: syntax patterns matched the old indent/[N] format; right_align virtual text produced a broken layout in narrow windows; the done strikethrough skipped past the ' ' indent leaving '- [x] ' unstyled; render() added undo history entries so 'u' could undo a re-render. Solution: update taskHeader/taskLine patterns for '## '/'- [.]'; rename taskPriority -> taskCheckbox matching '[!]'; switch virt_text_pos to 'eol'; drop the +2 col_start offset so strikethrough covers '- [x] '; guard nvim_buf_set_lines with undolevels=-1 so renders are not undoable. Also fix open_line to insert '- [ ] ' and position cursor at col 6. * refactor(init): replace multi-level priority with binary toggle Problem: / overrode Vim's native number increment and the visual g/g variants added complexity for marginal value. toggle_complete() left the cursor on the wrong line after re-render. Solution: remove change_priority/change_priority_visual; add toggle_priority() (0<->1) mapped to '!', with cursor-follow after render matching the pattern already used in priority toggle. Add cursor-follow to toggle_complete() for the same reason. Update plugin plugs (priority-up/down -> priority) and add 'due'/'undo' to the :Pending completion list. Update help text accordingly. * feat(buffer): reflect current view in buffer name Problem: no way to tell at a glance which view (category vs priority) is active — the buffer was always named 'pending://'. Solution: update the buffer name to 'pending://category' or 'pending://priority' on every render, so the view is visible in the statusline/tabline without any extra UI. --- lua/pending/buffer.lua | 21 ++++++++------ lua/pending/config.lua | 2 +- lua/pending/diff.lua | 32 ++++++++++++++-------- lua/pending/init.lua | 58 ++++++++------------------------------- lua/pending/views.lua | 12 ++++---- plugin/pending.lua | 10 ++----- spec/diff_spec.lua | 62 +++++++++++++++++++++--------------------- spec/store_spec.lua | 2 +- spec/views_spec.lua | 24 ++++++++-------- syntax/pending.vim | 8 +++--- 10 files changed, 101 insertions(+), 130 deletions(-) diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index 7c730e6..8f9fb59 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -58,9 +58,9 @@ local function setup_syntax(bufnr) vim.cmd([[ syntax clear syntax match taskId /^\/\d\+\// conceal - syntax match taskHeader /^\S.*$/ contains=taskId - syntax match taskPriority /\[\d\+\] / contained containedin=taskLine - syntax match taskLine /^\/\d\+\/ .*$/ contains=taskId,taskPriority + syntax match taskHeader /^## .*$/ contains=taskId + syntax match taskCheckbox /\[!\]/ contained containedin=taskLine + syntax match taskLine /^\/\d\+\/- \[.\] .*$/ contains=taskId,taskCheckbox ]]) end) end @@ -74,8 +74,8 @@ function M.open_line(above) local row = vim.api.nvim_win_get_cursor(0)[1] 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, { ' ' }) - vim.api.nvim_win_set_cursor(0, { insert_row + 1, 2 }) + vim.api.nvim_buf_set_lines(bufnr, insert_row, insert_row, false, { '- [ ] ' }) + vim.api.nvim_win_set_cursor(0, { insert_row + 1, 6 }) vim.cmd('startinsert!') end @@ -115,18 +115,18 @@ local function apply_extmarks(bufnr, line_meta) if virt_text then vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, { virt_text = virt_text, - virt_text_pos = 'right_align', + 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_pos = 'right_align', + 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+/')) + 2 or 0 + 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', @@ -200,6 +200,7 @@ 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 tasks = store.active_tasks() local lines, line_meta @@ -213,8 +214,11 @@ function M.render(bufnr) snapshot_folds(bufnr) vim.bo[bufnr].modifiable = true + local saved = vim.bo[bufnr].undolevels + vim.bo[bufnr].undolevels = -1 vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) vim.bo[bufnr].modified = false + vim.bo[bufnr].undolevels = saved setup_syntax(bufnr) apply_extmarks(bufnr, line_meta) @@ -261,7 +265,6 @@ function M.open() end task_bufnr = vim.api.nvim_create_buf(true, false) - vim.api.nvim_buf_set_name(task_bufnr, 'pending://') set_buf_options(task_bufnr) vim.api.nvim_set_current_buf(task_bufnr) diff --git a/lua/pending/config.lua b/lua/pending/config.lua index d137acb..2e647e4 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -18,7 +18,7 @@ local M = {} local defaults = { 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', category_order = {}, diff --git a/lua/pending/diff.lua b/lua/pending/diff.lua index 607aef6..85f083c 100644 --- a/lua/pending/diff.lua +++ b/lua/pending/diff.lua @@ -7,6 +7,7 @@ local store = require('pending.store') ---@field id? integer ---@field description? string ---@field priority? integer +---@field status? string ---@field category? string ---@field due? string ---@field lnum integer @@ -26,20 +27,17 @@ function M.parse_buffer(lines) local current_category = nil for i, line in ipairs(lines) do - 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 prio_str = stripped:match('^%[(%d+)%] ') - local priority = 0 - if prio_str then - priority = tonumber(prio_str) --[[@as integer]] - stripped = stripped:sub(#prio_str + 4) - end + 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 description, metadata = parse.body(stripped) if description and description ~= '' then table.insert(result, { @@ -47,14 +45,15 @@ function M.parse_buffer(lines) id = id and tonumber(id) or nil, description = description, priority = priority, + status = status, category = metadata.cat or current_category or config.get().default_category, due = metadata.due, lnum = i, }) end - elseif line:match('^%S') then - current_category = line - table.insert(result, { type = 'header', category = line, lnum = i }) + elseif line:match('^## (.+)$') then + current_category = line:match('^## (.+)$') + table.insert(result, { type = 'header', category = current_category, lnum = i }) end end @@ -113,6 +112,15 @@ function M.apply(lines) task.due = entry.due changed = true end + if entry.status and task.status ~= entry.status then + task.status = entry.status + if entry.status == 'done' then + task['end'] = now + else + task['end'] = nil + end + changed = true + end if task.order ~= order_counter then task.order = order_counter changed = true diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 1593bc5..ec69d89 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -52,17 +52,8 @@ function M._setup_buf_mappings(bufnr) vim.keymap.set('n', 'g?', function() M.show_help() end, opts) - vim.keymap.set('n', '', function() - M.change_priority(1) - end, opts) - vim.keymap.set('n', '', function() - M.change_priority(-1) - end, opts) - vim.keymap.set('v', 'g', function() - M.change_priority_visual(1) - end, opts) - vim.keymap.set('v', 'g', function() - M.change_priority_visual(-1) + vim.keymap.set('n', '!', function() + M.toggle_priority() end, opts) vim.keymap.set('n', 'D', function() M.prompt_date() @@ -126,10 +117,15 @@ function M.toggle_complete() end store.save() 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 delta integer -function M.change_priority(delta) +function M.toggle_priority() local bufnr = buffer.bufnr() if not bufnr then return @@ -147,7 +143,7 @@ function M.change_priority(delta) if not task then return end - local new_priority = math.max(0, task.priority + delta) + local new_priority = task.priority > 0 and 0 or 1 store.update(id, { priority = new_priority }) store.save() buffer.render(bufnr) @@ -159,33 +155,6 @@ function M.change_priority(delta) end end ----@param delta integer -function M.change_priority_visual(delta) - local bufnr = buffer.bufnr() - if not bufnr then - return - end - local start_row = vim.fn.line("'<") - local end_row = vim.fn.line("'>") - local meta = buffer.meta() - local changed = false - for row = start_row, end_row do - local m = meta[row] - if m and m.type == 'task' and m.id then - local task = store.get(m.id) - if task then - local new_priority = math.max(0, task.priority + delta) - store.update(m.id, { priority = new_priority }) - changed = true - end - end - end - if changed then - store.save() - buffer.render(bufnr) - end -end - function M.prompt_date() local bufnr = buffer.bufnr() if not bufnr then @@ -342,10 +311,7 @@ function M.show_help() '', ' Toggle complete/uncomplete', ' Switch category/priority view', - ' Raise priority level', - ' Lower priority level', - 'g Raise priority for visual selection', - 'g Lower priority for visual selection', + '! Toggle urgent', 'D Set due date', 'U Undo last write', 'o / O Add new task line', @@ -371,7 +337,7 @@ function M.show_help() '', 'Highlights:', ' PendingOverdue overdue tasks (red)', - ' PendingPriority [N] priority prefix', + ' PendingPriority [!] urgent tasks', '', 'Press q or to close', } diff --git a/lua/pending/views.lua b/lua/pending/views.lua index 84567e9..7bcfaca 100644 --- a/lua/pending/views.lua +++ b/lua/pending/views.lua @@ -125,7 +125,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 = {} @@ -138,9 +138,8 @@ function M.category_view(tasks) for _, task in ipairs(all) do local prefix = '/' .. task.id .. '/' - local indent = ' ' - local prio = task.priority > 0 and ('[' .. task.priority .. '] ') or '' - local line = prefix .. indent .. prio .. task.description + local state = task.status == 'done' and 'x' or (task.priority > 0 and '!' or ' ') + local line = prefix .. '- [' .. state .. '] ' .. task.description table.insert(lines, line) table.insert(meta, { type = 'task', @@ -189,9 +188,8 @@ function M.priority_view(tasks) for _, task in ipairs(all) do local prefix = '/' .. task.id .. '/' - local indent = ' ' - local prio = task.priority == 1 and '! ' or '' - local line = prefix .. indent .. prio .. task.description + local state = task.status == 'done' and 'x' or (task.priority > 0 and '!' or ' ') + local line = prefix .. '- [' .. state .. '] ' .. task.description table.insert(lines, line) table.insert(meta, { type = 'task', diff --git a/plugin/pending.lua b/plugin/pending.lua index 56dedc3..465ee65 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -8,7 +8,7 @@ vim.api.nvim_create_user_command('Pending', function(opts) end, { nargs = '*', complete = function(arg_lead, cmd_line) - local subcmds = { 'add', 'sync', 'archive' } + local subcmds = { 'add', 'sync', 'archive', 'due', 'undo' } if not cmd_line:match('^Pending%s+%S') then return vim.tbl_filter(function(s) return s:find(arg_lead, 1, true) == 1 @@ -30,12 +30,8 @@ vim.keymap.set('n', '(pending-view)', function() require('pending.buffer').toggle_view() end) -vim.keymap.set('n', '(pending-priority-up)', function() - require('pending').change_priority(1) -end) - -vim.keymap.set('n', '(pending-priority-down)', function() - require('pending').change_priority(-1) +vim.keymap.set('n', '(pending-priority)', function() + require('pending').toggle_priority() end) vim.keymap.set('n', '(pending-date)', function() diff --git a/spec/diff_spec.lua b/spec/diff_spec.lua index 3073879..fda2165 100644 --- a/spec/diff_spec.lua +++ b/spec/diff_spec.lua @@ -25,12 +25,12 @@ describe('diff', function() describe('parse_buffer', function() it('parses headers and tasks', function() local lines = { - 'School', - '/1/ Do homework', - '/2/ [1] Read chapter 5', + '## School', + '/1/- [ ] Do homework', + '/2/- [!] Read chapter 5', '', - 'Errands', - '/3/ Buy groceries', + '## Errands', + '/3/- [ ] Buy groceries', } local result = diff.parse_buffer(lines) assert.are.equal(6, #result) @@ -48,8 +48,8 @@ describe('diff', function() it('handles new tasks without ids', function() local lines = { - 'Inbox', - ' New task here', + '## Inbox', + '- [ ] New task here', } local result = diff.parse_buffer(lines) assert.are.equal(2, #result) @@ -60,8 +60,8 @@ describe('diff', function() it('inline cat: token overrides header category', function() local lines = { - 'Inbox', - '/1/ Buy milk cat:Work', + '## Inbox', + '/1/- [ ] Buy milk cat:Work', } local result = diff.parse_buffer(lines) assert.are.equal(2, #result) @@ -71,8 +71,8 @@ describe('diff', function() it('inline due: token is parsed', function() local lines = { - 'Inbox', - '/1/ Buy milk due:2026-03-15', + '## Inbox', + '/1/- [ ] Buy milk due:2026-03-15', } local result = diff.parse_buffer(lines) assert.are.equal(2, #result) @@ -84,9 +84,9 @@ describe('diff', function() describe('apply', function() it('creates new tasks from buffer lines', function() local lines = { - 'Inbox', - ' First task', - ' Second task', + '## Inbox', + '- [ ] First task', + '- [ ] Second task', } diff.apply(lines) store.unload() @@ -102,8 +102,8 @@ describe('diff', function() store.add({ description = 'Delete me' }) store.save() local lines = { - 'Inbox', - '/1/ Keep me', + '## Inbox', + '/1/- [ ] Keep me', } diff.apply(lines) store.unload() @@ -119,8 +119,8 @@ describe('diff', function() store.add({ description = 'Original' }) store.save() local lines = { - 'Inbox', - '/1/ Renamed', + '## Inbox', + '/1/- [ ] Renamed', } diff.apply(lines) store.unload() @@ -134,8 +134,8 @@ describe('diff', function() t.modified = '2020-01-01T00:00:00Z' store.save() local lines = { - 'Inbox', - '/1/ Renamed', + '## Inbox', + '/1/- [ ] Renamed', } diff.apply(lines) store.unload() @@ -149,9 +149,9 @@ describe('diff', function() store.add({ description = 'Original' }) store.save() local lines = { - 'Inbox', - '/1/ Original', - '/1/ Copy of original', + '## Inbox', + '/1/- [ ] Original', + '/1/- [ ] Copy of original', } diff.apply(lines) store.unload() @@ -164,8 +164,8 @@ describe('diff', function() store.add({ description = 'Moving task', category = 'Inbox' }) store.save() local lines = { - 'Work', - '/1/ Moving task', + '## Work', + '/1/- [ ] Moving task', } diff.apply(lines) store.unload() @@ -178,8 +178,8 @@ describe('diff', function() store.add({ description = 'Stable task', category = 'Inbox' }) store.save() local lines = { - 'Inbox', - '/1/ Stable task', + '## Inbox', + '/1/- [ ] Stable task', } diff.apply(lines) store.unload() @@ -196,8 +196,8 @@ describe('diff', function() store.add({ description = 'Pay bill', due = '2026-03-15' }) store.save() local lines = { - 'Inbox', - '/1/ Pay bill', + '## Inbox', + '/1/- [ ] Pay bill', } diff.apply(lines) store.unload() @@ -210,8 +210,8 @@ describe('diff', function() store.add({ description = 'Task name', priority = 1 }) store.save() local lines = { - 'Inbox', - '/1/ Task name', + '## Inbox', + '/1/- [ ] Task name', } diff.apply(lines) store.unload() diff --git a/spec/store_spec.lua b/spec/store_spec.lua index 25b8b7c..bb6266d 100644 --- a/spec/store_spec.lua +++ b/spec/store_spec.lua @@ -92,7 +92,7 @@ describe('store', function() assert.are.equal(1, t1.id) assert.are.equal(2, t2.id) assert.are.equal('pending', t1.status) - assert.are.equal('Inbox', t1.category) + assert.are.equal('Todo', t1.category) end) it('uses provided category', function() diff --git a/spec/views_spec.lua b/spec/views_spec.lua index 0f5e5e5..4d91e06 100644 --- a/spec/views_spec.lua +++ b/spec/views_spec.lua @@ -27,7 +27,7 @@ describe('views', function() store.add({ description = 'Task A', category = 'Work' }) store.add({ description = 'Task B', category = 'Work' }) local lines, meta = views.category_view(store.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) @@ -113,10 +113,10 @@ describe('views', function() task_line = lines[i] end end - assert.are.equal('/1/ My task', task_line) + assert.are.equal('/1/- [ ] My task', task_line) end) - it('formats priority task lines as /ID/ [N] description', function() + 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()) local task_line @@ -125,7 +125,7 @@ describe('views', function() task_line = lines[i] end end - assert.are.equal('/1/ [1] Important', task_line) + assert.are.equal('/1/- [!] Important', task_line) end) it('sets LineMeta type=header for header lines with correct category', function() @@ -220,8 +220,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() @@ -236,8 +236,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() @@ -250,8 +250,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) @@ -325,10 +325,10 @@ describe('views', function() assert.is_true(earlier_row < later_row) end) - it('formats task lines as /ID/ description', function() + it('formats task lines as /ID/- [ ] description', function() store.add({ description = 'My task', category = 'Inbox' }) local lines, _ = views.priority_view(store.active_tasks()) - assert.are.equal('/1/ My task', lines[1]) + assert.are.equal('/1/- [ ] My task', lines[1]) end) it('sets show_category=true for all task meta entries', function() diff --git a/syntax/pending.vim b/syntax/pending.vim index b5f3da1..a8a0258 100644 --- a/syntax/pending.vim +++ b/syntax/pending.vim @@ -3,12 +3,12 @@ if exists('b:current_syntax') endif syntax match taskId /^\/\d\+\// conceal -syntax match taskHeader /^\S.*$/ contains=taskId -syntax match taskPriority /!\ze / contained -syntax match taskLine /^\/\d\+\/ .*$/ contains=taskId,taskPriority +syntax match taskHeader /^## .*$/ contains=taskId +syntax match taskCheckbox /\[!\]/ contained containedin=taskLine +syntax match taskLine /^\/\d\+\/- \[.\] .*$/ contains=taskId,taskCheckbox highlight default link taskHeader PendingHeader -highlight default link taskPriority PendingPriority +highlight default link taskCheckbox PendingPriority highlight default link taskLine Normal let b:current_syntax = 'task' From fbeb0e2bee725a1d8336b50b6e3529027594fa00 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 25 Feb 2026 09:34:17 -0500 Subject: [PATCH 089/199] feat(buffer): open as bottom-drawer split like fugitive (#23) * feat(buffer): open as bottom-drawer split like fugitive Problem: :Pending replaced the current buffer, making it impossible to view tasks alongside the file being edited. No way to close the drawer without :q or switching buffers manually. Solution: open the task buffer in a botright horizontal split instead of replacing the current buffer. Track the drawer window ID so re-opening focuses it rather than creating a second split. Set winfixheight so the drawer keeps its height when other windows open or close. Add q/ mappings to close the drawer, and a WinClosed autocmd to clear the tracked window ID when the user closes it manually. Add drawer_height config option (default 15). * fix(buffer): default to natural split height like fugitive Problem: hardcoded drawer_height=15 was too small and diverged from fugitive's model. Fugitive issues a plain botright split and lets Vim's own split rules (equalalways, winheight) divide the available space. Solution: remove the default height so the split sizes naturally. Only call nvim_win_set_height when the user sets drawer_height to a positive value, preserving the opt-in customization path. --- lua/pending/buffer.lua | 48 ++++++++++++++++++++++++++++++------------ lua/pending/config.lua | 1 + lua/pending/init.lua | 14 ++++++++++++ 3 files changed, 49 insertions(+), 14 deletions(-) diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index 8f9fb59..d11254b 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -7,6 +7,8 @@ local M = {} ---@type integer? local task_bufnr = nil +---@type integer? +local task_winid = nil local task_ns = vim.api.nvim_create_namespace('pending') ---@type 'category'|'priority'|nil local current_view = nil @@ -25,11 +27,27 @@ function M.bufnr() return task_bufnr end +---@return integer? +function M.winid() + return task_winid +end + ---@return string? function M.current_view_name() return current_view end +function M.clear_winid() + task_winid = nil +end + +function M.close() + if task_winid and vim.api.nvim_win_is_valid(task_winid) then + vim.api.nvim_win_close(task_winid, false) + end + task_winid = nil +end + ---@param bufnr integer local function set_buf_options(bufnr) vim.bo[bufnr].buftype = 'acwrite' @@ -50,6 +68,7 @@ local function set_win_options(winid) vim.wo[winid].foldcolumn = '0' vim.wo[winid].spell = false vim.wo[winid].cursorline = true + vim.wo[winid].winfixheight = true end ---@param bufnr integer @@ -251,24 +270,25 @@ function M.open() setup_highlights() store.load() - if task_bufnr and vim.api.nvim_buf_is_valid(task_bufnr) then - local wins = vim.fn.win_findbuf(task_bufnr) - if #wins > 0 then - vim.api.nvim_set_current_win(wins[1]) - M.render(task_bufnr) - return task_bufnr - end - vim.api.nvim_set_current_buf(task_bufnr) - set_win_options(vim.api.nvim_get_current_win()) + if task_winid and vim.api.nvim_win_is_valid(task_winid) then + vim.api.nvim_set_current_win(task_winid) M.render(task_bufnr) - return task_bufnr + return task_bufnr --[[@as integer]] end - task_bufnr = vim.api.nvim_create_buf(true, false) + 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) + end - set_buf_options(task_bufnr) - vim.api.nvim_set_current_buf(task_bufnr) - set_win_options(vim.api.nvim_get_current_win()) + vim.cmd('botright new') + task_winid = vim.api.nvim_get_current_win() + vim.api.nvim_win_set_buf(task_winid, task_bufnr) + local h = config.get().drawer_height + if h and h > 0 then + vim.api.nvim_win_set_height(task_winid, h) + end + set_win_options(task_winid) M.render(task_bufnr) diff --git a/lua/pending/config.lua b/lua/pending/config.lua index 2e647e4..b61f44a 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -9,6 +9,7 @@ ---@field date_format string ---@field date_syntax string ---@field category_order? string[] +---@field drawer_height? integer ---@field gcal? pending.GcalConfig ---@class pending.config diff --git a/lua/pending/init.lua b/lua/pending/init.lua index ec69d89..14b9c24 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -38,11 +38,25 @@ function M._setup_autocmds(bufnr) end end, }) + vim.api.nvim_create_autocmd('WinClosed', { + group = group, + callback = function(ev) + if tonumber(ev.match) == buffer.winid() then + buffer.clear_winid() + end + end, + }) end ---@param bufnr integer function M._setup_buf_mappings(bufnr) 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) From d1be0985b642fbb7b6205f7f359787f78e731323 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 25 Feb 2026 09:34:17 -0500 Subject: [PATCH 090/199] feat(buffer): open as bottom-drawer split like fugitive (#23) * feat(buffer): open as bottom-drawer split like fugitive Problem: :Pending replaced the current buffer, making it impossible to view tasks alongside the file being edited. No way to close the drawer without :q or switching buffers manually. Solution: open the task buffer in a botright horizontal split instead of replacing the current buffer. Track the drawer window ID so re-opening focuses it rather than creating a second split. Set winfixheight so the drawer keeps its height when other windows open or close. Add q/ mappings to close the drawer, and a WinClosed autocmd to clear the tracked window ID when the user closes it manually. Add drawer_height config option (default 15). * fix(buffer): default to natural split height like fugitive Problem: hardcoded drawer_height=15 was too small and diverged from fugitive's model. Fugitive issues a plain botright split and lets Vim's own split rules (equalalways, winheight) divide the available space. Solution: remove the default height so the split sizes naturally. Only call nvim_win_set_height when the user sets drawer_height to a positive value, preserving the opt-in customization path. --- lua/pending/buffer.lua | 48 ++++++++++++++++++++++++++++++------------ lua/pending/config.lua | 1 + lua/pending/init.lua | 14 ++++++++++++ 3 files changed, 49 insertions(+), 14 deletions(-) diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index 8f9fb59..d11254b 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -7,6 +7,8 @@ local M = {} ---@type integer? local task_bufnr = nil +---@type integer? +local task_winid = nil local task_ns = vim.api.nvim_create_namespace('pending') ---@type 'category'|'priority'|nil local current_view = nil @@ -25,11 +27,27 @@ function M.bufnr() return task_bufnr end +---@return integer? +function M.winid() + return task_winid +end + ---@return string? function M.current_view_name() return current_view end +function M.clear_winid() + task_winid = nil +end + +function M.close() + if task_winid and vim.api.nvim_win_is_valid(task_winid) then + vim.api.nvim_win_close(task_winid, false) + end + task_winid = nil +end + ---@param bufnr integer local function set_buf_options(bufnr) vim.bo[bufnr].buftype = 'acwrite' @@ -50,6 +68,7 @@ local function set_win_options(winid) vim.wo[winid].foldcolumn = '0' vim.wo[winid].spell = false vim.wo[winid].cursorline = true + vim.wo[winid].winfixheight = true end ---@param bufnr integer @@ -251,24 +270,25 @@ function M.open() setup_highlights() store.load() - if task_bufnr and vim.api.nvim_buf_is_valid(task_bufnr) then - local wins = vim.fn.win_findbuf(task_bufnr) - if #wins > 0 then - vim.api.nvim_set_current_win(wins[1]) - M.render(task_bufnr) - return task_bufnr - end - vim.api.nvim_set_current_buf(task_bufnr) - set_win_options(vim.api.nvim_get_current_win()) + if task_winid and vim.api.nvim_win_is_valid(task_winid) then + vim.api.nvim_set_current_win(task_winid) M.render(task_bufnr) - return task_bufnr + return task_bufnr --[[@as integer]] end - task_bufnr = vim.api.nvim_create_buf(true, false) + 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) + end - set_buf_options(task_bufnr) - vim.api.nvim_set_current_buf(task_bufnr) - set_win_options(vim.api.nvim_get_current_win()) + vim.cmd('botright new') + task_winid = vim.api.nvim_get_current_win() + vim.api.nvim_win_set_buf(task_winid, task_bufnr) + local h = config.get().drawer_height + if h and h > 0 then + vim.api.nvim_win_set_height(task_winid, h) + end + set_win_options(task_winid) M.render(task_bufnr) diff --git a/lua/pending/config.lua b/lua/pending/config.lua index 2e647e4..b61f44a 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -9,6 +9,7 @@ ---@field date_format string ---@field date_syntax string ---@field category_order? string[] +---@field drawer_height? integer ---@field gcal? pending.GcalConfig ---@class pending.config diff --git a/lua/pending/init.lua b/lua/pending/init.lua index ec69d89..14b9c24 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -38,11 +38,25 @@ function M._setup_autocmds(bufnr) end end, }) + vim.api.nvim_create_autocmd('WinClosed', { + group = group, + callback = function(ev) + if tonumber(ev.match) == buffer.winid() then + buffer.clear_winid() + end + end, + }) end ---@param bufnr integer function M._setup_buf_mappings(bufnr) 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) From fa1103ad4e089804ec3dd27e91a3d93378464187 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 25 Feb 2026 09:37:49 -0500 Subject: [PATCH 091/199] 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 092/199] 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 75e9b4a4179e12b7e446eb33688aecae32b0eea0 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 093/199] 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 59351246681dbd4250fddf963e10892e7ffefae6 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 094/199] 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 4f4048228efa0e527c1f52307a849f9bf9d9acb0 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 095/199] 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 d878b9346e3e3be5a796687a33f63bedc1e7bd31 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 096/199] 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 2e390d531cd635e6f0fafdac63f9390c57ab4346 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 097/199] 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 f689cac70b20b772b108691c698b38d3f93fedd8 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 098/199] 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 92c2c670c550e15a3ca045b30f6c26940509cdcb 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 099/199] 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 cd1cd1afd40b3b7db2ec148535074530b2825538 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 100/199] 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 85cf0d42edab6b41e0eb0ea55bdc9336de181a07 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 101/199] 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 306e11aee603d55a1485fa0e2f3afad4841be702 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 102/199] 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 836a53b541b8de929e28763b096428cbe75b5d45 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 103/199] 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 de0dc564cfe72f93423911308511ecf087ef0d66 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 104/199] 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 a2e0e296acacec93418e7d9c370cf4a838a25eac 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 105/199] 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 dbd76d675973a6cd870e42608ea9367f957bf755 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 106/199] 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 41bda245701e9e6ab9dbfc7486fb4bb9bf2c02ff 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 107/199] 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 103e2036c84aaa93b3a023c9d66a6c0a35622fd1 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 108/199] 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 c62172a58d6830d0eb0a5042f6d63a44887561bf Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Thu, 26 Feb 2026 20:00:49 -0500 Subject: [PATCH 109/199] 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 ef58f69f744c6d88a61796a3669a774883c456d6 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Thu, 26 Feb 2026 20:00:54 -0500 Subject: [PATCH 110/199] 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 0cdb4e6dd651e2ca9a51236575ce11878de2f817 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 111/199] 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 2b87337d78cfffca60abd74dfaf1d1836a7faacc 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 112/199] 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 5adf2341f3ee7aa0699f9f5278b6654534a09636 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 113/199] 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 1187f78924878561091326181c7ffec586c8454e Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 4 Mar 2026 14:07:04 -0500 Subject: [PATCH 114/199] 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 bc260e7ed09929ff3bf36e93603cd8874f40501d Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 4 Mar 2026 14:18:46 -0500 Subject: [PATCH 115/199] 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 76aa22472d2bd1a1f444ac0e4ebd13d1edfc1784 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 116/199] 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 cca43dc3285fbade87529837be48920f2ed061ab 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 117/199] 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 910c8d2d69cbf19c5325aabf8ceb808e86a50753 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 118/199] 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 a6be248cbf8dc4b556cc6eb6e07525937eebec04 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 119/199] 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 6910bdb1be96c728e3b8a0ca5469c452797df34a 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 120/199] 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 ee362f7785504f31fc315685f0d77e62d97c6fdf 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 121/199] 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 710cf562c9af153fcc9e62a76037fd09377b58d4 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 122/199] 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 715b6d4d129f1ca6f11dc2a6f02af6c8930d70c8 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 123/199] 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 517a6c0aed205f867600544882f85684bb2dee44 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 124/199] 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 34b8e1798abd454e959562abedbc939af844b99f 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 125/199] 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 1dd40c9a9f5dc3d26821fcbce34493073e93c1d5 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 126/199] 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 84437155bc626ea6dc5e114e1885f8edcf6f561e 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 127/199] 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 829afd25b46478068860d30b9bb7dcb8f80ef634 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 128/199] 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 d4e4d1499a07d7a33aa611d79175f75824cfdd58 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 129/199] 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 ea69384ea888177898d3b5a94597f324a4dae4ad 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 130/199] 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 356cc199fab16c7efe198bc5ab18b340ca180942 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 131/199] 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 25ad5a6d8845bf13e19bdc549e3f0c7c2ff81f5f 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 132/199] 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 9de53f2bb35932f06deffd43bd097f2964e36dc6 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 133/199] 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 bc902abd078c43070ca4174d5cea06297f2bf0c8 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 134/199] 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 0cf3d299725bfc04575f3e443fa92895bf3a6c24 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 135/199] 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 abf3e5227723251e47943f6b69756b0a90fe392d 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 136/199] 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 012bd9b043c4cb2f3bfbc912875cdf1f341eb1e0 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 137/199] 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 d8324e6a6df67d1ac91cd907825d932c4c389ada 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 138/199] 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 a99dbd6a68f73be240855ce0243a326f68622429 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 139/199] 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 2f98d0484f3416c4c317659e6661ea930f764dea Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 6 Mar 2026 21:36:46 -0500 Subject: [PATCH 140/199] 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 128d5e48fbb288815d711f7879d85b8bc5366b76 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 141/199] 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 bc822fa0abb60ce0d8ca8c51a89e522450e5bef7 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sat, 7 Mar 2026 01:38:24 -0500 Subject: [PATCH 142/199] 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 db837db2f3c7a958a0e750c7d723ab714b79c5f2 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sat, 7 Mar 2026 01:54:09 -0500 Subject: [PATCH 143/199] 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 2ca228aeb42f98a7fa30bf46899a45a7c608cb5d 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 144/199] 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 0bab8ce24f6067d543643e22683e9a8a13d4ed63 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 145/199] 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 61eeed58f72b7bf5b6ecafc1cb5b6d85c5e4c4dc Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sat, 7 Mar 2026 01:59:10 -0500 Subject: [PATCH 146/199] 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 22d0b4a3d40b7284ebc156021c0a5171e7325337 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 147/199] 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 f56de46b4d0787238aaad904cc7b60f1a117492a Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 8 Mar 2026 13:24:44 -0400 Subject: [PATCH 148/199] 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 5161ef00a0bb39e56ac7f0f68806b4b359b8790e 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 149/199] 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 0bbef5d0103647d0edca4b5518df1f80aa1027e4 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 150/199] 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 e973979ef34a0ddc9333564b8a003a00c528d294 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 151/199] 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 77e95b5772062a2f33d6560519e2474b3de33f93 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 152/199] 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 b66d40adc3b1f8adf4c53c4b4beaa5867be577e2 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 153/199] 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 98e916c541ca9e3408aea8628b53821516af29ba 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 154/199] 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 fda8c1208cf100d1e6ca49bef1cc4bd826714b48 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 155/199] 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 24bc1e395b6e287ffe0ace04dd5d42c43061bc1d Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Sun, 8 Mar 2026 19:44:03 -0400 Subject: [PATCH 156/199] feat: complete task editing coverage (#109) 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 From 34a68db6d0388c71eed80732c7f798e5937ecf83 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Sun, 8 Mar 2026 19:49:49 -0400 Subject: [PATCH 157/199] refactor(types): extract inline anonymous types into named classes (#110) Problem: several functions used inline `{...}` table types in their `@param` and `@return` annotations, making them hard to read and impossible to reference from other modules. Solution: extract each into a named `---@class`: `pending.Metadata`, `pending.TaskFields`, `pending.CompletionItem`, `pending.SystemResult`, and `pending.OAuthClientOpts`. --- lua/pending/complete.lua | 8 +++- lua/pending/parse.lua | 11 +++++- lua/pending/store.lua | 13 ++++++- lua/pending/sync/oauth.lua | 57 ++++++++------------------- lua/pending/sync/util.lua | 79 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 123 insertions(+), 45 deletions(-) create mode 100644 lua/pending/sync/util.lua diff --git a/lua/pending/complete.lua b/lua/pending/complete.lua index 9ed4971..6ee3320 100644 --- a/lua/pending/complete.lua +++ b/lua/pending/complete.lua @@ -1,5 +1,9 @@ local config = require('pending.config') +---@class pending.CompletionItem +---@field word string +---@field info string + ---@class pending.complete local M = {} @@ -32,7 +36,7 @@ local function get_categories() return result end ----@return { word: string, info: string }[] +---@return pending.CompletionItem[] local function date_completions() return { { word = 'today', info = "Today's date" }, @@ -89,7 +93,7 @@ local recur_descriptions = { ['2y'] = 'Every 2 years', } ----@return { word: string, info: string }[] +---@return pending.CompletionItem[] local function recur_completions() local recur = require('pending.recur') local list = recur.shorthand_list() diff --git a/lua/pending/parse.lua b/lua/pending/parse.lua index ea838f7..a0160f1 100644 --- a/lua/pending/parse.lua +++ b/lua/pending/parse.lua @@ -1,5 +1,12 @@ local config = require('pending.config') +---@class pending.Metadata +---@field due? string +---@field cat? string +---@field rec? string +---@field rec_mode? 'scheduled'|'completion' +---@field priority? integer + ---@class pending.parse local M = {} @@ -515,7 +522,7 @@ end ---@param text string ---@return string description ----@return { due?: string, cat?: string, rec?: string, rec_mode?: 'scheduled'|'completion' } metadata +---@return pending.Metadata metadata function M.body(text) local tokens = {} for token in text:gmatch('%S+') do @@ -608,7 +615,7 @@ end ---@param text string ---@return string description ----@return { due?: string, cat?: string, rec?: string, rec_mode?: 'scheduled'|'completion' } metadata +---@return pending.Metadata metadata function M.command_add(text) local cat_prefix = text:match('^(%S.-):%s') if cat_prefix then diff --git a/lua/pending/store.lua b/lua/pending/store.lua index 640e256..fcf420e 100644 --- a/lua/pending/store.lua +++ b/lua/pending/store.lua @@ -22,6 +22,17 @@ local config = require('pending.config') ---@field undo pending.Task[][] ---@field folded_categories string[] +---@class pending.TaskFields +---@field description string +---@field status? string +---@field category? string +---@field priority? integer +---@field due? string +---@field recur? string +---@field recur_mode? string +---@field order? integer +---@field _extra? table + ---@class pending.Store ---@field path string ---@field _data pending.Data? @@ -264,7 +275,7 @@ function Store:get(id) return nil end ----@param fields { description: string, status?: string, category?: string, priority?: integer, due?: string, recur?: string, recur_mode?: string, order?: integer, _extra?: table } +---@param fields pending.TaskFields ---@return pending.Task function Store:add(fields) local data = self:data() diff --git a/lua/pending/sync/oauth.lua b/lua/pending/sync/oauth.lua index dabbe2d..516a353 100644 --- a/lua/pending/sync/oauth.lua +++ b/lua/pending/sync/oauth.lua @@ -17,62 +17,39 @@ local BUNDLED_CLIENT_SECRET = 'PLACEHOLDER' ---@field expires_in? integer ---@field obtained_at? integer ----@class pending.OAuthClient +---@class pending.OAuthClientOpts ---@field name string ---@field scope string ---@field port integer ---@field config_key string + +---@class pending.OAuthClient : pending.OAuthClientOpts local OAuthClient = {} OAuthClient.__index = OAuthClient +local util = require('pending.sync.util') + local _active_close = nil -local _sync_in_flight = false ---@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 +M.system = util.system +M.async = util.async ---@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 already 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 + util.async(function() + util.with_guard(name, function() + local token = client:get_access_token() + if not token then + require('pending.log').warn(name .. ': Not authenticated — run :Pending auth.') + return + end + callback(token) + end) end) end @@ -547,7 +524,7 @@ function OAuthClient:clear_tokens() os.remove(self:token_path()) end ----@param opts { name: string, scope: string, port: integer, config_key: string } +---@param opts pending.OAuthClientOpts ---@return pending.OAuthClient function M.new(opts) return setmetatable({ diff --git a/lua/pending/sync/util.lua b/lua/pending/sync/util.lua new file mode 100644 index 0000000..3c5c644 --- /dev/null +++ b/lua/pending/sync/util.lua @@ -0,0 +1,79 @@ +local log = require('pending.log') + +---@class pending.SystemResult +---@field code integer +---@field stdout string +---@field stderr string + +---@class pending.sync.util +local M = {} + +local _sync_in_flight = false + +---@param fn fun(): nil +function M.async(fn) + coroutine.resume(coroutine.create(fn)) +end + +---@param args string[] +---@param opts? table +---@return pending.SystemResult +function M.system(args, opts) + local co = coroutine.running() + if not co then + return vim.system(args, opts or {}):wait() --[[@as pending.SystemResult]] + 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 name string +---@param fn fun(): nil +function M.with_guard(name, fn) + if _sync_in_flight then + log.warn(name .. ': Sync already in progress — please wait.') + return + end + _sync_in_flight = true + local ok, err = pcall(fn) + _sync_in_flight = false + if not ok then + log.error(name .. ': ' .. tostring(err)) + end +end + +---@return boolean +function M.sync_in_flight() + return _sync_in_flight +end + +---@param s pending.Store +function M.finish(s) + 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 +end + +---@param parts [integer, string][] +---@return string +function M.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 + +return M From d12838abbfbc083a8b846bd06cea529289ed1788 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Sun, 8 Mar 2026 19:53:42 -0400 Subject: [PATCH 158/199] feat: auth backend (#111) * refactor(types): extract inline anonymous types into named classes Problem: several functions used inline `{...}` table types in their `@param` and `@return` annotations, making them hard to read and impossible to reference from other modules. Solution: extract each into a named `---@class`: `pending.Metadata`, `pending.TaskFields`, `pending.CompletionItem`, `pending.SystemResult`, and `pending.OAuthClientOpts`. * refactor(sync): extract shared utilities into `sync/util.lua` Problem: sync epilogue code (`s:save()`, `_recompute_counts()`, `buffer.render()`) and `fmt_counts` were duplicated across `gcal.lua` and `gtasks.lua`. The concurrency guard lived in `oauth.lua`, coupling non-OAuth backends to the OAuth module. Solution: create `sync/util.lua` with `async`, `system`, `with_guard`, `finish`, and `fmt_counts`. Delegate from `oauth.lua` and replace duplicated code in both backends. Add per-backend `auth()` and `auth_complete()` methods to `gcal.lua` and `gtasks.lua`. * feat(sync): auto-discover backends, per-backend auth, S3 backend Problem: sync backends were hardcoded in `SYNC_BACKENDS` list in `init.lua`, auth routed directly through `oauth.google_client`, and adding a non-OAuth backend required editing multiple files. Solution: replace hardcoded list with `discover_backends()` that globs `lua/pending/sync/*.lua` at runtime. Rewrite `M.auth()` to dispatch to per-backend `auth()` methods with `vim.ui.select` fallback. Add `lua/pending/sync/s3.lua` with push/pull/sync via AWS CLI, per-task merge by `_s3_sync_id` (UUID), and `pending.S3Config` type. --- doc/pending.txt | 111 +++++++++- lua/pending/config.lua | 7 + lua/pending/init.lua | 102 ++++++--- lua/pending/sync/gcal.lua | 53 +++-- lua/pending/sync/gtasks.lua | 73 ++++--- lua/pending/sync/s3.lua | 407 ++++++++++++++++++++++++++++++++++++ lua/pending/sync/util.lua | 6 +- lua/pending/views.lua | 3 +- plugin/pending.lua | 27 ++- spec/s3_spec.lua | 311 +++++++++++++++++++++++++++ spec/sync_spec.lua | 71 +++++++ spec/sync_util_spec.lua | 101 +++++++++ spec/views_spec.lua | 8 +- 13 files changed, 1173 insertions(+), 107 deletions(-) create mode 100644 lua/pending/sync/s3.lua create mode 100644 spec/s3_spec.lua create mode 100644 spec/sync_util_spec.lua diff --git a/doc/pending.txt b/doc/pending.txt index f6179d3..0cf6000 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -66,8 +66,9 @@ CONTENTS *pending-contents* 18. Google Calendar .......................................... |pending-gcal| 19. Google Tasks ............................................ |pending-gtasks| 20. Google Authentication ......................... |pending-google-auth| - 21. Data Format .............................................. |pending-data| - 22. Health Check ........................................... |pending-health| + 21. S3 Sync ................................................... |pending-s3| + 22. Data Format .............................................. |pending-data| + 23. Health Check ........................................... |pending-health| ============================================================================== REQUIREMENTS *pending-requirements* @@ -1065,16 +1066,23 @@ 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 -backend is exposed as a top-level `:Pending` subcommand: >vim +Sync backends are Lua modules under `lua/pending/sync/.lua`. Backends +are auto-discovered at runtime — any module that exports a `name` field is +registered automatically. No hardcoded list or manual registration step is +required. Adding a backend is as simple as creating a new file. + +Each backend is exposed as a top-level `:Pending` subcommand: >vim :Pending gtasks {action} :Pending gcal {action} + :Pending s3 {action} < Each module returns a table conforming to the backend interface: >lua ---@class pending.SyncBackend ---@field name string + ---@field auth? fun(args?: string): nil + ---@field auth_complete? fun(arg_lead: string): string[] ---@field push? fun(): nil ---@field pull? fun(): nil ---@field sync? fun(): nil @@ -1085,14 +1093,28 @@ Required fields: ~ {name} Backend identifier (matches the filename). Optional fields: ~ + {auth} Per-backend authentication. Called by `:Pending auth `. + Receives an optional sub-action string (e.g. `"clear"`). + {auth_complete} Returns valid sub-action completions for tab completion + (e.g. `{ "clear", "reset" }`). {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|. +Modules without a `name` field (e.g. `oauth.lua`, `util.lua`) are ignored +by discovery and do not appear as backends. + +Shared utilities for backend authors are provided by `sync/util.lua`: + `util.async(fn)` Coroutine wrapper for async operations. + `util.system(args)` Coroutine-aware `vim.system` wrapper. + `util.with_guard(name, fn)` Concurrency guard — prevents overlapping + sync operations. Clears on return or error. + `util.finish(s)` Persist store, recompute counts, re-render + the buffer. Typical sync epilogue. + `util.fmt_counts(parts)` Format `{ {n, label}, ... }` into a + human-readable summary string. Backend-specific configuration goes under `sync.` in |pending-config|. @@ -1233,11 +1255,20 @@ 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. +`:Pending auth` dispatches to per-backend `auth()` methods. When called +without arguments, if multiple backends have auth methods, a +|vim.ui.select| prompt lets you choose. With an explicit backend name, +the call goes directly: >vim + :Pending auth gcal + :Pending auth gtasks + :Pending auth gcal clear + :Pending auth gtasks reset +< + +Sub-actions are backend-specific. Google backends support `clear` (remove +tokens) and `reset` (remove tokens and credentials). 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: @@ -1261,6 +1292,64 @@ Credentials are resolved in order for the `google` config key: The `installed` wrapper format from the Google Cloud Console is accepted. +============================================================================== +S3 SYNC *pending-s3* + +pending.nvim can sync the task store to an S3 bucket. This enables +whole-store synchronization between machines via the AWS CLI. + +Configuration: >lua + vim.g.pending = { + sync = { + s3 = { + bucket = 'my-tasks-bucket', + key = 'pending.json', -- optional, default "pending.json" + profile = 'personal', -- optional AWS CLI profile + region = 'us-east-1', -- optional region override + }, + }, + } +< + + *pending.S3Config* +Fields: ~ + {bucket} (string, required) + S3 bucket name. + + {key} (string, optional, default `"pending.json"`) + S3 object key (path within the bucket). + + {profile} (string, optional) + AWS CLI profile name. Maps to `--profile`. + + {region} (string, optional) + AWS region override. Maps to `--region`. + +Credential resolution: ~ +Delegates entirely to the AWS CLI credential chain (environment variables, +~/.aws/credentials, IAM roles, SSO, etc.). No credentials are stored by +pending.nvim. + +Auth flow: ~ +`:Pending auth s3` runs `aws sts get-caller-identity` to verify credentials. +If the profile uses SSO and the session has expired, it automatically runs +`aws sso login`. Sub-action `profile` prompts for a profile name. + +`:Pending s3 push` behavior: ~ +Assigns a `_s3_sync_id` (UUID) to each task that lacks one, serializes the +store to a temp file, and uploads it to S3 via `aws s3 cp`. + +`:Pending s3 pull` behavior: ~ +Downloads the remote store from S3, then merges per-task by `_s3_sync_id`: +- Remote task with a matching local task: the version with the newer + `modified` timestamp wins. +- Remote task with no local match: added to the local store. +- Local tasks not present in the remote: kept (local-only tasks are never + deleted by pull). + +`:Pending s3 sync` behavior: ~ +Pulls first (merge), then pushes the merged result. + ============================================================================== DATA FORMAT *pending-data* diff --git a/lua/pending/config.lua b/lua/pending/config.lua index 81e7168..599b050 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -27,10 +27,17 @@ ---@field client_id? string ---@field client_secret? string +---@class pending.S3Config +---@field bucket string +---@field key? string +---@field profile? string +---@field region? string + ---@class pending.SyncConfig ---@field remote_delete? boolean ---@field gcal? pending.GcalConfig ---@field gtasks? pending.GtasksConfig +---@field s3? pending.S3Config ---@class pending.Keymaps ---@field close? string|false diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 46c6bb7..f01f162 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -933,13 +933,30 @@ function M.add(text) log.info('Task added: ' .. description) end ----@type string[] -local SYNC_BACKENDS = { 'gcal', 'gtasks' } +---@type string[]? +local _sync_backends = nil ----@type table -local SYNC_BACKEND_SET = {} -for _, b in ipairs(SYNC_BACKENDS) do - SYNC_BACKEND_SET[b] = true +---@type table? +local _sync_backend_set = nil + +---@return string[], table +local function discover_backends() + if _sync_backends then + return _sync_backends, _sync_backend_set --[[@as table]] + end + _sync_backends = {} + _sync_backend_set = {} + local paths = vim.fn.globpath(vim.o.runtimepath, 'lua/pending/sync/*.lua', false, true) + for _, path in ipairs(paths) do + local name = vim.fn.fnamemodify(path, ':t:r') + local ok, mod = pcall(require, 'pending.sync.' .. name) + if ok and type(mod) == 'table' and mod.name then + table.insert(_sync_backends, mod.name) + _sync_backend_set[mod.name] = true + end + end + table.sort(_sync_backends) + return _sync_backends, _sync_backend_set end ---@param backend_name string @@ -954,7 +971,13 @@ 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) ~= '_' and k ~= 'health' then + if + type(v) == 'function' + and k:sub(1, 1) ~= '_' + and k ~= 'health' + and k ~= 'auth' + and k ~= 'auth_complete' + then table.insert(actions, k) end end @@ -1246,29 +1269,55 @@ end ---@param args? string ---@return nil function M.auth(args) - local oauth = require('pending.sync.oauth') 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 + + local backend_name = parts[1] + local sub_action = parts[2] + + local backends_list = discover_backends() + local auth_backends = {} + for _, name in ipairs(backends_list) do + local ok, mod = pcall(require, 'pending.sync.' .. name) + if ok and type(mod.auth) == 'function' then + table.insert(auth_backends, { name = name, mod = mod }) + end 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() + if backend_name then + local found = false + for _, b in ipairs(auth_backends) do + if b.name == backend_name then + b.mod.auth(sub_action) + found = true + break + end end + if not found then + log.error('No auth method for backend: ' .. backend_name) + end + elseif #auth_backends == 1 then + auth_backends[1].mod.auth() + elseif #auth_backends > 1 then + local names = {} + for _, b in ipairs(auth_backends) do + table.insert(names, b.name) + end + vim.ui.select(names, { prompt = 'Authenticate backend: ' }, function(choice) + if not choice then + return + end + for _, b in ipairs(auth_backends) do + if b.name == choice then + b.mod.auth() + break + end + end + end) + else + log.warn('No sync backends with auth support found.') end end @@ -1289,7 +1338,7 @@ function M.command(args) M.edit(id_str, edit_rest) elseif cmd == 'auth' then M.auth(rest) - elseif SYNC_BACKEND_SET[cmd] then + elseif select(2, discover_backends())[cmd] then local action = rest:match('^(%S+)') run_sync(cmd, action) elseif cmd == 'archive' then @@ -1307,12 +1356,13 @@ end ---@return string[] function M.sync_backends() - return SYNC_BACKENDS + return (discover_backends()) end ---@return table function M.sync_backend_set() - return SYNC_BACKEND_SET + local _, set = discover_backends() + return set end return M diff --git a/lua/pending/sync/gcal.lua b/lua/pending/sync/gcal.lua index 80802a7..2e50fd5 100644 --- a/lua/pending/sync/gcal.lua +++ b/lua/pending/sync/gcal.lua @@ -1,6 +1,7 @@ local config = require('pending.config') local log = require('pending.log') local oauth = require('pending.sync.oauth') +local util = require('pending.sync.util') local M = {} @@ -154,21 +155,6 @@ 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 - function M.push() oauth.with_token(oauth.google_client, 'gcal', function(access_token) local calendars, cal_err = get_all_calendars(access_token) @@ -246,13 +232,8 @@ function M.push() end end - 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('gcal push: ' .. fmt_counts({ + util.finish(s) + log.info('gcal push: ' .. util.fmt_counts({ { created, 'added' }, { updated, 'updated' }, { deleted, 'removed' }, @@ -261,6 +242,32 @@ function M.push() end) end +---@param args? string +---@return nil +function M.auth(args) + if args == 'clear' then + oauth.google_client:clear_tokens() + log.info('gcal: OAuth tokens cleared — run :Pending auth gcal to re-authenticate.') + elseif args == 'reset' then + oauth.google_client:_wipe() + log.info( + 'gcal: OAuth tokens and credentials cleared — run :Pending auth gcal 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 + +---@return string[] +function M.auth_complete() + return { 'clear', 'reset' } +end + ---@return nil function M.health() oauth.health(M.name) @@ -268,7 +275,7 @@ function M.health() if tokens and tokens.refresh_token then vim.health.ok('gcal tokens found') else - vim.health.info('no gcal tokens — run :Pending auth') + vim.health.info('no gcal tokens — run :Pending auth gcal') end end diff --git a/lua/pending/sync/gtasks.lua b/lua/pending/sync/gtasks.lua index 014e80a..9eade7d 100644 --- a/lua/pending/sync/gtasks.lua +++ b/lua/pending/sync/gtasks.lua @@ -1,6 +1,7 @@ local config = require('pending.config') local log = require('pending.log') local oauth = require('pending.sync.oauth') +local util = require('pending.sync.util') local M = {} @@ -195,21 +196,6 @@ 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) @@ -447,13 +433,8 @@ function M.push() local by_gtasks_id = build_id_index(s) 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('gtasks push: ' .. fmt_counts({ + util.finish(s) + log.info('gtasks push: ' .. util.fmt_counts({ { created, 'added' }, { updated, 'updated' }, { deleted, 'deleted' }, @@ -474,13 +455,8 @@ function M.pull() 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('gtasks pull: ' .. fmt_counts({ + util.finish(s) + log.info('gtasks pull: ' .. util.fmt_counts({ { created, 'added' }, { updated, 'updated' }, { unlinked, 'unlinked' }, @@ -503,18 +479,13 @@ function M.sync() 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') - if buffer.bufnr() and vim.api.nvim_buf_is_valid(buffer.bufnr()) then - buffer.render(buffer.bufnr()) - end - log.info('gtasks sync — push: ' .. fmt_counts({ + util.finish(s) + log.info('gtasks sync — push: ' .. util.fmt_counts({ { pushed_create, 'added' }, { pushed_update, 'updated' }, { pushed_delete, 'deleted' }, { pushed_failed, 'failed' }, - }) .. ' pull: ' .. fmt_counts({ + }) .. ' pull: ' .. util.fmt_counts({ { pulled_create, 'added' }, { pulled_update, 'updated' }, { unlinked, 'unlinked' }, @@ -533,6 +504,32 @@ M._push_pass = push_pass M._pull_pass = pull_pass M._detect_remote_deletions = detect_remote_deletions +---@param args? string +---@return nil +function M.auth(args) + if args == 'clear' then + oauth.google_client:clear_tokens() + log.info('gtasks: OAuth tokens cleared — run :Pending auth gtasks to re-authenticate.') + elseif args == 'reset' then + oauth.google_client:_wipe() + log.info( + 'gtasks: OAuth tokens and credentials cleared — run :Pending auth gtasks 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 + +---@return string[] +function M.auth_complete() + return { 'clear', 'reset' } +end + ---@return nil function M.health() oauth.health(M.name) @@ -540,7 +537,7 @@ function M.health() if tokens and tokens.refresh_token then vim.health.ok('gtasks tokens found') else - vim.health.info('no gtasks tokens — run :Pending auth') + vim.health.info('no gtasks tokens — run :Pending auth gtasks') end end diff --git a/lua/pending/sync/s3.lua b/lua/pending/sync/s3.lua new file mode 100644 index 0000000..f2f64bb --- /dev/null +++ b/lua/pending/sync/s3.lua @@ -0,0 +1,407 @@ +local log = require('pending.log') +local util = require('pending.sync.util') + +local M = {} + +M.name = 's3' + +---@return pending.S3Config? +local function get_config() + local cfg = require('pending.config').get() + return cfg.sync and cfg.sync.s3 +end + +---@return string[] +local function base_cmd() + local s3cfg = get_config() or {} + local cmd = { 'aws' } + if s3cfg.profile then + table.insert(cmd, '--profile') + table.insert(cmd, s3cfg.profile) + end + if s3cfg.region then + table.insert(cmd, '--region') + table.insert(cmd, s3cfg.region) + end + return cmd +end + +---@param task pending.Task +---@return string +local function ensure_sync_id(task) + if not task._extra then + task._extra = {} + end + local sync_id = task._extra['_s3_sync_id'] + if not sync_id then + local bytes = {} + math.randomseed(vim.uv.hrtime()) + for i = 1, 16 do + bytes[i] = math.random(0, 255) + end + bytes[7] = bit.bor(bit.band(bytes[7], 0x0f), 0x40) + bytes[9] = bit.bor(bit.band(bytes[9], 0x3f), 0x80) + sync_id = string.format( + '%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x', + bytes[1], bytes[2], bytes[3], bytes[4], + bytes[5], bytes[6], + bytes[7], bytes[8], + bytes[9], bytes[10], + bytes[11], bytes[12], bytes[13], bytes[14], bytes[15], bytes[16] + ) + task._extra['_s3_sync_id'] = sync_id + task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]] + end + return sync_id +end + +---@param args? string +---@return nil +function M.auth(args) + if args == 'profile' then + vim.ui.input({ prompt = 'AWS profile name: ' }, function(input) + if not input or input == '' then + local s3cfg = get_config() + if s3cfg and s3cfg.profile then + log.info('s3: current profile: ' .. s3cfg.profile) + else + log.info('s3: no profile configured (using default)') + end + return + end + log.info('s3: set profile in your config: sync = { s3 = { profile = "' .. input .. '" } }') + end) + return + end + + util.async(function() + local cmd = base_cmd() + vim.list_extend(cmd, { 'sts', 'get-caller-identity', '--output', 'json' }) + local result = util.system(cmd, { text = true }) + if result.code == 0 then + local ok, data = pcall(vim.json.decode, result.stdout or '') + if ok and data then + log.info('s3: authenticated as ' .. (data.Arn or data.Account or 'unknown')) + else + log.info('s3: credentials valid') + end + else + local stderr = result.stderr or '' + if stderr:find('SSO') or stderr:find('sso') then + log.info('s3: SSO session expired — running login...') + local login_cmd = base_cmd() + vim.list_extend(login_cmd, { 'sso', 'login' }) + local login_result = util.system(login_cmd, { text = true }) + if login_result.code == 0 then + log.info('s3: SSO login successful') + else + log.error('s3: SSO login failed — ' .. (login_result.stderr or '')) + end + elseif stderr:find('Unable to locate credentials') or stderr:find('NoCredentialProviders') then + log.error('s3: no AWS credentials configured. See :h pending-s3') + else + log.error('s3: ' .. stderr) + end + end + end) +end + +---@return string[] +function M.auth_complete() + return { 'profile' } +end + +function M.push() + util.async(function() + util.with_guard('s3', function() + local s3cfg = get_config() + if not s3cfg or not s3cfg.bucket then + log.error('s3: bucket is required. Set sync.s3.bucket in config.') + return + end + local key = s3cfg.key or 'pending.json' + local s = require('pending').store() + + for _, task in ipairs(s:tasks()) do + ensure_sync_id(task) + end + + local tmpfile = vim.fn.tempname() .. '.json' + s:save() + + local store = require('pending.store') + local tmp_store = store.new(s.path) + tmp_store:load() + + local f = io.open(s.path, 'r') + if not f then + log.error('s3: failed to read store file') + return + end + local content = f:read('*a') + f:close() + + local tf = io.open(tmpfile, 'w') + if not tf then + log.error('s3: failed to create temp file') + return + end + tf:write(content) + tf:close() + + local cmd = base_cmd() + vim.list_extend(cmd, { 's3', 'cp', tmpfile, 's3://' .. s3cfg.bucket .. '/' .. key }) + local result = util.system(cmd, { text = true }) + os.remove(tmpfile) + + if result.code ~= 0 then + log.error('s3 push: ' .. (result.stderr or 'unknown error')) + return + end + + util.finish(s) + log.info('s3 push: uploaded to s3://' .. s3cfg.bucket .. '/' .. key) + end) + end) +end + +function M.pull() + util.async(function() + util.with_guard('s3', function() + local s3cfg = get_config() + if not s3cfg or not s3cfg.bucket then + log.error('s3: bucket is required. Set sync.s3.bucket in config.') + return + end + local key = s3cfg.key or 'pending.json' + local tmpfile = vim.fn.tempname() .. '.json' + + local cmd = base_cmd() + vim.list_extend(cmd, { 's3', 'cp', 's3://' .. s3cfg.bucket .. '/' .. key, tmpfile }) + local result = util.system(cmd, { text = true }) + + if result.code ~= 0 then + os.remove(tmpfile) + log.error('s3 pull: ' .. (result.stderr or 'unknown error')) + return + end + + local store = require('pending.store') + local s_remote = store.new(tmpfile) + local load_ok = pcall(function() + s_remote:load() + end) + if not load_ok then + os.remove(tmpfile) + log.error('s3 pull: failed to parse remote store') + return + end + + local s = require('pending').store() + local created, updated, unchanged = 0, 0, 0 + + local local_by_sync_id = {} + for _, task in ipairs(s:tasks()) do + local extra = task._extra or {} + local sid = extra['_s3_sync_id'] + if sid then + local_by_sync_id[sid] = task + end + end + + for _, remote_task in ipairs(s_remote:tasks()) do + local r_extra = remote_task._extra or {} + local r_sid = r_extra['_s3_sync_id'] + if not r_sid then + goto continue + end + + local local_task = local_by_sync_id[r_sid] + if local_task then + local r_mod = remote_task.modified or '' + local l_mod = local_task.modified or '' + if r_mod > l_mod then + local_task.description = remote_task.description + local_task.status = remote_task.status + local_task.category = remote_task.category + local_task.priority = remote_task.priority + local_task.due = remote_task.due + local_task.recur = remote_task.recur + local_task.recur_mode = remote_task.recur_mode + local_task['end'] = remote_task['end'] + local_task._extra = local_task._extra or {} + local_task._extra['_s3_sync_id'] = r_sid + local_task.modified = remote_task.modified + updated = updated + 1 + else + unchanged = unchanged + 1 + end + else + s:add({ + description = remote_task.description, + status = remote_task.status, + category = remote_task.category, + priority = remote_task.priority, + due = remote_task.due, + recur = remote_task.recur, + recur_mode = remote_task.recur_mode, + _extra = { _s3_sync_id = r_sid }, + }) + created = created + 1 + end + + ::continue:: + end + + os.remove(tmpfile) + util.finish(s) + log.info('s3 pull: ' .. util.fmt_counts({ + { created, 'added' }, + { updated, 'updated' }, + { unchanged, 'unchanged' }, + })) + end) + end) +end + +function M.sync() + util.async(function() + util.with_guard('s3', function() + local s3cfg = get_config() + if not s3cfg or not s3cfg.bucket then + log.error('s3: bucket is required. Set sync.s3.bucket in config.') + return + end + local key = s3cfg.key or 'pending.json' + local tmpfile = vim.fn.tempname() .. '.json' + + local cmd = base_cmd() + vim.list_extend(cmd, { 's3', 'cp', 's3://' .. s3cfg.bucket .. '/' .. key, tmpfile }) + local result = util.system(cmd, { text = true }) + + local s = require('pending').store() + local created, updated = 0, 0 + + if result.code == 0 then + local store = require('pending.store') + local s_remote = store.new(tmpfile) + local load_ok = pcall(function() + s_remote:load() + end) + + if load_ok then + local local_by_sync_id = {} + for _, task in ipairs(s:tasks()) do + local extra = task._extra or {} + local sid = extra['_s3_sync_id'] + if sid then + local_by_sync_id[sid] = task + end + end + + for _, remote_task in ipairs(s_remote:tasks()) do + local r_extra = remote_task._extra or {} + local r_sid = r_extra['_s3_sync_id'] + if not r_sid then + goto continue + end + + local local_task = local_by_sync_id[r_sid] + if local_task then + local r_mod = remote_task.modified or '' + local l_mod = local_task.modified or '' + if r_mod > l_mod then + local_task.description = remote_task.description + local_task.status = remote_task.status + local_task.category = remote_task.category + local_task.priority = remote_task.priority + local_task.due = remote_task.due + local_task.recur = remote_task.recur + local_task.recur_mode = remote_task.recur_mode + local_task['end'] = remote_task['end'] + local_task._extra = local_task._extra or {} + local_task._extra['_s3_sync_id'] = r_sid + local_task.modified = remote_task.modified + updated = updated + 1 + end + else + s:add({ + description = remote_task.description, + status = remote_task.status, + category = remote_task.category, + priority = remote_task.priority, + due = remote_task.due, + recur = remote_task.recur, + recur_mode = remote_task.recur_mode, + _extra = { _s3_sync_id = r_sid }, + }) + created = created + 1 + end + + ::continue:: + end + end + end + os.remove(tmpfile) + + for _, task in ipairs(s:tasks()) do + ensure_sync_id(task) + end + s:save() + + local f = io.open(s.path, 'r') + if not f then + log.error('s3 sync: failed to read store file') + return + end + local content = f:read('*a') + f:close() + + local push_tmpfile = vim.fn.tempname() .. '.json' + local tf = io.open(push_tmpfile, 'w') + if not tf then + log.error('s3 sync: failed to create temp file') + return + end + tf:write(content) + tf:close() + + local push_cmd = base_cmd() + vim.list_extend(push_cmd, { 's3', 'cp', push_tmpfile, 's3://' .. s3cfg.bucket .. '/' .. key }) + local push_result = util.system(push_cmd, { text = true }) + os.remove(push_tmpfile) + + if push_result.code ~= 0 then + log.error('s3 sync push: ' .. (push_result.stderr or 'unknown error')) + util.finish(s) + return + end + + util.finish(s) + log.info('s3 sync: pull ' .. util.fmt_counts({ + { created, 'added' }, + { updated, 'updated' }, + }) .. ' | push uploaded') + end) + end) +end + +---@return nil +function M.health() + if vim.fn.executable('aws') == 1 then + vim.health.ok('aws CLI found') + else + vim.health.error('aws CLI not found (required for S3 sync)') + end + + local s3cfg = get_config() + if s3cfg and s3cfg.bucket then + vim.health.ok('S3 bucket configured: ' .. s3cfg.bucket) + else + vim.health.warn('S3 bucket not configured — set sync.s3.bucket') + end +end + +M._ensure_sync_id = ensure_sync_id + +return M diff --git a/lua/pending/sync/util.lua b/lua/pending/sync/util.lua index 3c5c644..176b91a 100644 --- a/lua/pending/sync/util.lua +++ b/lua/pending/sync/util.lua @@ -5,6 +5,10 @@ local log = require('pending.log') ---@field stdout string ---@field stderr string +---@class pending.CountPart +---@field [1] integer +---@field [2] string + ---@class pending.sync.util local M = {} @@ -61,7 +65,7 @@ function M.finish(s) end end ----@param parts [integer, string][] +---@param parts pending.CountPart[] ---@return string function M.fmt_counts(parts) local items = {} diff --git a/lua/pending/views.lua b/lua/pending/views.lua index d6c706b..8d4bda5 100644 --- a/lua/pending/views.lua +++ b/lua/pending/views.lua @@ -185,8 +185,7 @@ function M.category_view(tasks) status = task.status, category = cat, priority = task.priority, - overdue = task.status ~= 'done' 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, recur = task.recur, }) end diff --git a/plugin/pending.lua b/plugin/pending.lua index 9b67a59..be8bc38 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -183,7 +183,8 @@ end, { for word in after_filter:gmatch('%S+') do used[word] = true end - local candidates = { 'clear', 'overdue', 'today', 'priority', 'done', 'pending', 'wip', 'blocked' } + local candidates = + { 'clear', 'overdue', 'today', 'priority', 'done', 'pending', 'wip', 'blocked' } local store = require('pending.store') local s = store.new(store.resolve_path()) s:load() @@ -223,10 +224,22 @@ end, { 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' }) + local auth_names = {} + for _, b in ipairs(pending.sync_backends()) do + local ok, mod = pcall(require, 'pending.sync.' .. b) + if ok and type(mod.auth) == 'function' then + table.insert(auth_names, b) + end + end + return filter_candidates(arg_lead, auth_names) end + local backend_name = parts[1] if #parts == 1 or (#parts == 2 and not trailing) then - return filter_candidates(arg_lead, { 'clear', 'reset' }) + local ok, mod = pcall(require, 'pending.sync.' .. backend_name) + if ok and type(mod.auth_complete) == 'function' then + return filter_candidates(arg_lead, mod.auth_complete()) + end + return {} end return {} end @@ -243,7 +256,13 @@ end, { end local actions = {} for k, v in pairs(mod) do - if type(v) == 'function' and k:sub(1, 1) ~= '_' and k ~= 'health' then + if + type(v) == 'function' + and k:sub(1, 1) ~= '_' + and k ~= 'health' + and k ~= 'auth' + and k ~= 'auth_complete' + then table.insert(actions, k) end end diff --git a/spec/s3_spec.lua b/spec/s3_spec.lua new file mode 100644 index 0000000..5904196 --- /dev/null +++ b/spec/s3_spec.lua @@ -0,0 +1,311 @@ +require('spec.helpers') + +local config = require('pending.config') +local util = require('pending.sync.util') + +describe('s3', function() + local tmpdir + local pending + local s3 + local orig_system + + before_each(function() + tmpdir = vim.fn.tempname() + vim.fn.mkdir(tmpdir, 'p') + vim.g.pending = { + data_path = tmpdir .. '/tasks.json', + sync = { s3 = { bucket = 'test-bucket', key = 'test.json' } }, + } + config.reset() + package.loaded['pending'] = nil + package.loaded['pending.sync.s3'] = nil + pending = require('pending') + s3 = require('pending.sync.s3') + orig_system = util.system + end) + + after_each(function() + util.system = orig_system + vim.fn.delete(tmpdir, 'rf') + vim.g.pending = nil + config.reset() + package.loaded['pending'] = nil + package.loaded['pending.sync.s3'] = nil + end) + + it('has correct name', function() + assert.equals('s3', s3.name) + end) + + it('has auth function', function() + assert.equals('function', type(s3.auth)) + end) + + it('has auth_complete returning profile', function() + local completions = s3.auth_complete() + assert.is_true(vim.tbl_contains(completions, 'profile')) + end) + + it('has push, pull, sync functions', function() + assert.equals('function', type(s3.push)) + assert.equals('function', type(s3.pull)) + assert.equals('function', type(s3.sync)) + end) + + it('has health function', function() + assert.equals('function', type(s3.health)) + end) + + describe('ensure_sync_id', function() + it('assigns a UUID-like sync id', function() + local task = { _extra = nil, modified = '2026-01-01T00:00:00Z' } + local id = s3._ensure_sync_id(task) + assert.is_not_nil(id) + assert.truthy(id:match('^%x%x%x%x%x%x%x%x%-%x%x%x%x%-%x%x%x%x%-%x%x%x%x%-%x%x%x%x%x%x%x%x%x%x%x%x$')) + assert.equals(id, task._extra['_s3_sync_id']) + end) + + it('returns existing sync id without regenerating', function() + local task = { + _extra = { _s3_sync_id = 'existing-id' }, + modified = '2026-01-01T00:00:00Z', + } + local id = s3._ensure_sync_id(task) + assert.equals('existing-id', id) + end) + end) + + describe('auth', function() + it('reports success on valid credentials', function() + util.system = function(args) + if vim.tbl_contains(args, 'get-caller-identity') then + return { code = 0, stdout = '{"Account":"123456","Arn":"arn:aws:iam::user/test"}', stderr = '' } + end + return { code = 0, stdout = '', stderr = '' } + end + local msg + local orig_notify = vim.notify + vim.notify = function(m) + msg = m + end + s3.auth() + vim.notify = orig_notify + assert.truthy(msg and msg:find('authenticated')) + end) + + it('detects SSO expiry', function() + util.system = function(args) + if vim.tbl_contains(args, 'get-caller-identity') then + return { code = 1, stdout = '', stderr = 'Error: SSO session expired' } + end + return { code = 0, stdout = '', stderr = '' } + end + local msg + local orig_notify = vim.notify + vim.notify = function(m) + msg = m + end + s3.auth() + vim.notify = orig_notify + assert.truthy(msg and msg:find('SSO')) + end) + + it('detects missing credentials', function() + util.system = function() + return { code = 1, stdout = '', stderr = 'Unable to locate credentials' } + end + local msg + local orig_notify = vim.notify + vim.notify = function(m, level) + if level == vim.log.levels.ERROR then + msg = m + end + end + s3.auth() + vim.notify = orig_notify + assert.truthy(msg and msg:find('no AWS credentials')) + end) + end) + + describe('push', function() + it('uploads store to S3', function() + local s = pending.store() + s:load() + s:add({ description = 'Test task', status = 'pending', category = 'Work', priority = 0 }) + s:save() + + local captured_args + util.system = function(args) + if vim.tbl_contains(args, 's3') then + captured_args = args + return { code = 0, stdout = '', stderr = '' } + end + return { code = 0, stdout = '', stderr = '' } + end + + s3.push() + + assert.is_not_nil(captured_args) + local joined = table.concat(captured_args, ' ') + assert.truthy(joined:find('s3://test%-bucket/test%.json')) + end) + + it('errors when bucket is not configured', function() + vim.g.pending = { data_path = tmpdir .. '/tasks.json', sync = { s3 = {} } } + config.reset() + package.loaded['pending'] = nil + package.loaded['pending.sync.s3'] = nil + pending = require('pending') + s3 = require('pending.sync.s3') + + local msg + local orig_notify = vim.notify + vim.notify = function(m, level) + if level == vim.log.levels.ERROR then + msg = m + end + end + s3.push() + vim.notify = orig_notify + assert.truthy(msg and msg:find('bucket is required')) + end) + end) + + describe('pull merge', function() + it('merges remote tasks by sync_id', function() + local store_mod = require('pending.store') + local s = pending.store() + s:load() + local local_task = s:add({ + description = 'Local task', + status = 'pending', + category = 'Work', + priority = 0, + }) + local_task._extra = { _s3_sync_id = 'sync-1' } + local_task.modified = '2026-03-01T00:00:00Z' + s:save() + + local remote_path = tmpdir .. '/remote.json' + local remote_store = store_mod.new(remote_path) + remote_store:load() + local remote_task = remote_store:add({ + description = 'Updated remotely', + status = 'pending', + category = 'Work', + priority = 1, + }) + remote_task._extra = { _s3_sync_id = 'sync-1' } + remote_task.modified = '2026-03-05T00:00:00Z' + + local new_remote = remote_store:add({ + description = 'New remote task', + status = 'pending', + category = 'Personal', + priority = 0, + }) + new_remote._extra = { _s3_sync_id = 'sync-2' } + new_remote.modified = '2026-03-04T00:00:00Z' + remote_store:save() + + util.system = function(args) + if vim.tbl_contains(args, 's3') and vim.tbl_contains(args, 'cp') then + for i, arg in ipairs(args) do + if arg:match('^s3://') then + local dest = args[i + 1] + if dest and not dest:match('^s3://') then + local src = io.open(remote_path, 'r') + local content = src:read('*a') + src:close() + local f = io.open(dest, 'w') + f:write(content) + f:close() + end + break + end + end + return { code = 0, stdout = '', stderr = '' } + end + return { code = 0, stdout = '', stderr = '' } + end + + s3.pull() + + s:load() + local tasks = s:tasks() + assert.equals(2, #tasks) + + local found_updated = false + local found_new = false + for _, t in ipairs(tasks) do + if t._extra and t._extra['_s3_sync_id'] == 'sync-1' then + assert.equals('Updated remotely', t.description) + assert.equals(1, t.priority) + found_updated = true + end + if t._extra and t._extra['_s3_sync_id'] == 'sync-2' then + assert.equals('New remote task', t.description) + found_new = true + end + end + assert.is_true(found_updated) + assert.is_true(found_new) + end) + + it('keeps local version when local is newer', function() + local s = pending.store() + s:load() + local local_task = s:add({ + description = 'Local version', + status = 'pending', + category = 'Work', + priority = 0, + }) + local_task._extra = { _s3_sync_id = 'sync-3' } + local_task.modified = '2026-03-10T00:00:00Z' + s:save() + + local store_mod = require('pending.store') + local remote_path = tmpdir .. '/remote2.json' + local remote_store = store_mod.new(remote_path) + remote_store:load() + local remote_task = remote_store:add({ + description = 'Older remote', + status = 'pending', + category = 'Work', + priority = 0, + }) + remote_task._extra = { _s3_sync_id = 'sync-3' } + remote_task.modified = '2026-03-05T00:00:00Z' + remote_store:save() + + util.system = function(args) + if vim.tbl_contains(args, 's3') and vim.tbl_contains(args, 'cp') then + for i, arg in ipairs(args) do + if arg:match('^s3://') then + local dest = args[i + 1] + if dest and not dest:match('^s3://') then + local src = io.open(remote_path, 'r') + local content = src:read('*a') + src:close() + local f = io.open(dest, 'w') + f:write(content) + f:close() + end + break + end + end + return { code = 0, stdout = '', stderr = '' } + end + return { code = 0, stdout = '', stderr = '' } + end + + s3.pull() + + s:load() + local tasks = s:tasks() + assert.equals(1, #tasks) + assert.equals('Local version', tasks[1].description) + end) + end) +end) diff --git a/spec/sync_spec.lua b/spec/sync_spec.lua index 7771553..51156bf 100644 --- a/spec/sync_spec.lua +++ b/spec/sync_spec.lua @@ -110,5 +110,76 @@ describe('sync', function() local gcal = require('pending.sync.gcal') assert.are.equal('function', type(gcal.health)) end) + + it('has auth function', function() + local gcal = require('pending.sync.gcal') + assert.are.equal('function', type(gcal.auth)) + end) + + it('has auth_complete function', function() + local gcal = require('pending.sync.gcal') + local completions = gcal.auth_complete() + assert.is_true(vim.tbl_contains(completions, 'clear')) + assert.is_true(vim.tbl_contains(completions, 'reset')) + end) + end) + + describe('auto-discovery', function() + it('discovers gcal and gtasks backends', function() + local backends = pending.sync_backends() + assert.is_true(vim.tbl_contains(backends, 'gcal')) + assert.is_true(vim.tbl_contains(backends, 'gtasks')) + end) + + it('excludes modules without name field', function() + local set = pending.sync_backend_set() + assert.is_nil(set['oauth']) + assert.is_nil(set['util']) + end) + + it('populates backend set correctly', function() + local set = pending.sync_backend_set() + assert.is_true(set['gcal'] == true) + assert.is_true(set['gtasks'] == true) + end) + end) + + describe('auth dispatch', function() + it('routes auth to specific backend', function() + local called_with = nil + local gcal = require('pending.sync.gcal') + local orig_auth = gcal.auth + gcal.auth = function(args) + called_with = args or 'default' + end + pending.auth('gcal') + gcal.auth = orig_auth + assert.are.equal('default', called_with) + end) + + it('routes auth with sub-action', function() + local called_with = nil + local gcal = require('pending.sync.gcal') + local orig_auth = gcal.auth + gcal.auth = function(args) + called_with = args + end + pending.auth('gcal clear') + gcal.auth = orig_auth + assert.are.equal('clear', called_with) + 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.auth('nonexistent') + vim.notify = orig + assert.truthy(msg and msg:find('No auth method')) + end) end) end) diff --git a/spec/sync_util_spec.lua b/spec/sync_util_spec.lua new file mode 100644 index 0000000..aad4c85 --- /dev/null +++ b/spec/sync_util_spec.lua @@ -0,0 +1,101 @@ +require('spec.helpers') + +local config = require('pending.config') +local util = require('pending.sync.util') + +describe('sync util', function() + before_each(function() + config.reset() + end) + + after_each(function() + config.reset() + end) + + describe('fmt_counts', function() + it('returns nothing to do for empty counts', function() + assert.equals('nothing to do', util.fmt_counts({})) + end) + + it('returns nothing to do when all zero', function() + assert.equals('nothing to do', util.fmt_counts({ { 0, 'added' }, { 0, 'failed' } })) + end) + + it('formats single non-zero count', function() + assert.equals('3 added', util.fmt_counts({ { 3, 'added' }, { 0, 'failed' } })) + end) + + it('joins multiple non-zero counts with pipe', function() + local result = util.fmt_counts({ { 2, 'added' }, { 1, 'updated' }, { 0, 'failed' } }) + assert.equals('2 added | 1 updated', result) + end) + end) + + describe('with_guard', function() + it('prevents concurrent calls', function() + local inner_called = false + local blocked = false + + local msgs = {} + local orig = vim.notify + vim.notify = function(m, level) + if level == vim.log.levels.WARN then + table.insert(msgs, m) + end + end + + util.with_guard('test', function() + inner_called = true + util.with_guard('test2', function() + blocked = true + end) + end) + + vim.notify = orig + assert.is_true(inner_called) + assert.is_false(blocked) + assert.equals(1, #msgs) + assert.truthy(msgs[1]:find('Sync already in progress')) + end) + + it('clears guard after error', function() + pcall(util.with_guard, 'err-test', function() + error('boom') + end) + + assert.is_false(util.sync_in_flight()) + end) + + it('clears guard after success', function() + util.with_guard('ok-test', function() end) + assert.is_false(util.sync_in_flight()) + end) + end) + + describe('finish', function() + it('calls save and recompute', function() + local helpers = require('spec.helpers') + local store_mod = require('pending.store') + local tmpdir = helpers.tmpdir() + vim.g.pending = { data_path = tmpdir .. '/tasks.json' } + config.reset() + package.loaded['pending'] = nil + local pending = require('pending') + + local s = store_mod.new(tmpdir .. '/tasks.json') + s:load() + s:add({ description = 'Test', status = 'pending', category = 'Work', priority = 0 }) + + util.finish(s) + + local reloaded = store_mod.new(tmpdir .. '/tasks.json') + reloaded:load() + assert.equals(1, #reloaded:tasks()) + + vim.fn.delete(tmpdir, 'rf') + vim.g.pending = nil + config.reset() + package.loaded['pending'] = nil + end) + end) +end) diff --git a/spec/views_spec.lua b/spec/views_spec.lua index b09633f..ff8ad93 100644 --- a/spec/views_spec.lua +++ b/spec/views_spec.lua @@ -228,7 +228,10 @@ describe('views', function() end) it('respects category_order when set', function() - vim.g.pending = { data_path = tmpdir .. '/tasks.json', view = { 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 +251,8 @@ describe('views', function() end) it('appends categories not in category_order after ordered ones', function() - vim.g.pending = { data_path = tmpdir .. '/tasks.json', view = { 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 efb3021a22e451384f39574724620c24c57ae58b Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 8 Mar 2026 19:54:06 -0400 Subject: [PATCH 159/199] ci: format --- lua/pending/sync/s3.lua | 25 +++++++++++++++++++------ spec/s3_spec.lua | 10 ++++++++-- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/lua/pending/sync/s3.lua b/lua/pending/sync/s3.lua index f2f64bb..3c2cee3 100644 --- a/lua/pending/sync/s3.lua +++ b/lua/pending/sync/s3.lua @@ -43,11 +43,22 @@ local function ensure_sync_id(task) bytes[9] = bit.bor(bit.band(bytes[9], 0x3f), 0x80) sync_id = string.format( '%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x', - bytes[1], bytes[2], bytes[3], bytes[4], - bytes[5], bytes[6], - bytes[7], bytes[8], - bytes[9], bytes[10], - bytes[11], bytes[12], bytes[13], bytes[14], bytes[15], bytes[16] + bytes[1], + bytes[2], + bytes[3], + bytes[4], + bytes[5], + bytes[6], + bytes[7], + bytes[8], + bytes[9], + bytes[10], + bytes[11], + bytes[12], + bytes[13], + bytes[14], + bytes[15], + bytes[16] ) task._extra['_s3_sync_id'] = sync_id task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]] @@ -97,7 +108,9 @@ function M.auth(args) else log.error('s3: SSO login failed — ' .. (login_result.stderr or '')) end - elseif stderr:find('Unable to locate credentials') or stderr:find('NoCredentialProviders') then + elseif + stderr:find('Unable to locate credentials') or stderr:find('NoCredentialProviders') + then log.error('s3: no AWS credentials configured. See :h pending-s3') else log.error('s3: ' .. stderr) diff --git a/spec/s3_spec.lua b/spec/s3_spec.lua index 5904196..137b209 100644 --- a/spec/s3_spec.lua +++ b/spec/s3_spec.lua @@ -61,7 +61,9 @@ describe('s3', function() local task = { _extra = nil, modified = '2026-01-01T00:00:00Z' } local id = s3._ensure_sync_id(task) assert.is_not_nil(id) - assert.truthy(id:match('^%x%x%x%x%x%x%x%x%-%x%x%x%x%-%x%x%x%x%-%x%x%x%x%-%x%x%x%x%x%x%x%x%x%x%x%x$')) + assert.truthy( + id:match('^%x%x%x%x%x%x%x%x%-%x%x%x%x%-%x%x%x%x%-%x%x%x%x%-%x%x%x%x%x%x%x%x%x%x%x%x$') + ) assert.equals(id, task._extra['_s3_sync_id']) end) @@ -79,7 +81,11 @@ describe('s3', function() it('reports success on valid credentials', function() util.system = function(args) if vim.tbl_contains(args, 'get-caller-identity') then - return { code = 0, stdout = '{"Account":"123456","Arn":"arn:aws:iam::user/test"}', stderr = '' } + return { + code = 0, + stdout = '{"Account":"123456","Arn":"arn:aws:iam::user/test"}', + stderr = '', + } end return { code = 0, stdout = '', stderr = '' } end From 9b3abcb19afed41988a2b47b70c37c27cacc57d3 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Sun, 8 Mar 2026 20:20:16 -0400 Subject: [PATCH 160/199] feat(sync): s3 backend (#112) * feat(s3): create bucket interactively during auth when unconfigured Problem: when a user runs `:Pending s3 auth` with no bucket configured, auth succeeds but offers no way to create the bucket. The user must manually run `aws s3api create-bucket` and update their config. Solution: add `util.input()` coroutine-aware prompt wrapper and a `create_bucket()` flow in `s3.lua` that prompts for bucket name and region, handles the `us-east-1` LocationConstraint quirk, and logs a config snippet on success. Called automatically from `auth()` when `sync.s3.bucket` is absent. * ci: typing --- lua/pending/sync/s3.lua | 47 ++++++++ lua/pending/sync/util.lua | 15 +++ spec/s3_spec.lua | 241 ++++++++++++++++++++++++++++++++++++++ spec/sync_util_spec.lua | 1 - 4 files changed, 303 insertions(+), 1 deletion(-) diff --git a/lua/pending/sync/s3.lua b/lua/pending/sync/s3.lua index 3c2cee3..d3ad5b5 100644 --- a/lua/pending/sync/s3.lua +++ b/lua/pending/sync/s3.lua @@ -66,6 +66,49 @@ local function ensure_sync_id(task) return sync_id end +local function create_bucket() + local name = util.input({ prompt = 'S3 bucket name: ', default = 'pending.nvim' }) + if not name or name == '' then + log.info('s3: bucket creation cancelled') + return + end + + local region_cmd = base_cmd() + vim.list_extend(region_cmd, { 'configure', 'get', 'region' }) + local region_result = util.system(region_cmd, { text = true }) + local default_region = 'us-east-1' + if region_result.code == 0 and region_result.stdout then + local detected = vim.trim(region_result.stdout) + if detected ~= '' then + default_region = detected + end + end + + local region = util.input({ prompt = 'AWS region: ', default = default_region }) + if not region or region == '' then + region = 'us-east-1' + end + + local cmd = base_cmd() + vim.list_extend(cmd, { 's3api', 'create-bucket', '--bucket', name, '--region', region }) + if region ~= 'us-east-1' then + vim.list_extend(cmd, { '--create-bucket-configuration', 'LocationConstraint=' .. region }) + end + + local result = util.system(cmd, { text = true }) + if result.code == 0 then + log.info( + 's3: bucket created. Add to your config:\n sync = { s3 = { bucket = "' + .. name + .. '", region = "' + .. region + .. '" } }' + ) + else + log.error('s3: bucket creation failed — ' .. (result.stderr or 'unknown error')) + end +end + ---@param args? string ---@return nil function M.auth(args) @@ -96,6 +139,10 @@ function M.auth(args) else log.info('s3: credentials valid') end + local s3cfg = get_config() + if not s3cfg or not s3cfg.bucket then + create_bucket() + end else local stderr = result.stderr or '' if stderr:find('SSO') or stderr:find('sso') then diff --git a/lua/pending/sync/util.lua b/lua/pending/sync/util.lua index 176b91a..269acdf 100644 --- a/lua/pending/sync/util.lua +++ b/lua/pending/sync/util.lua @@ -35,6 +35,21 @@ function M.system(args, opts) return coroutine.yield() --[[@as { code: integer, stdout: string, stderr: string }]] end +---@param opts? {prompt?: string, default?: string} +---@return string? +function M.input(opts) + local co = coroutine.running() + if not co then + error('util.input() must be called inside a coroutine') + end + vim.ui.input(opts or {}, function(result) + vim.schedule(function() + coroutine.resume(co, result) + end) + end) + return coroutine.yield() --[[@as string?]] +end + ---@param name string ---@param fn fun(): nil function M.with_guard(name, fn) diff --git a/spec/s3_spec.lua b/spec/s3_spec.lua index 137b209..c1b4c68 100644 --- a/spec/s3_spec.lua +++ b/spec/s3_spec.lua @@ -99,6 +99,28 @@ describe('s3', function() assert.truthy(msg and msg:find('authenticated')) end) + it('skips bucket creation when bucket is configured', function() + util.system = function(args) + if vim.tbl_contains(args, 'get-caller-identity') then + return { + code = 0, + stdout = '{"Account":"123456","Arn":"arn:aws:iam::user/test"}', + stderr = '', + } + end + return { code = 0, stdout = '', stderr = '' } + end + local orig_input = util.input + local input_called = false + util.input = function() + input_called = true + return nil + end + s3.auth() + util.input = orig_input + assert.is_false(input_called) + end) + it('detects SSO expiry', function() util.system = function(args) if vim.tbl_contains(args, 'get-caller-identity') then @@ -133,6 +155,225 @@ describe('s3', function() end) end) + describe('auth bucket creation', function() + local orig_input + + before_each(function() + vim.g.pending = { data_path = tmpdir .. '/tasks.json', sync = { s3 = {} } } + config.reset() + package.loaded['pending'] = nil + package.loaded['pending.sync.s3'] = nil + pending = require('pending') + s3 = require('pending.sync.s3') + orig_input = util.input + end) + + after_each(function() + util.input = orig_input + end) + + it('prompts for bucket when none configured', function() + local input_calls = {} + util.input = function(opts) + table.insert(input_calls, opts) + if opts.prompt:find('bucket') then + return 'my-bucket' + end + return opts.default + end + local create_args + util.system = function(args) + if vim.tbl_contains(args, 'get-caller-identity') then + return { + code = 0, + stdout = '{"Account":"123","Arn":"arn:aws:iam::user/test"}', + stderr = '', + } + end + if vim.tbl_contains(args, 'configure') then + return { code = 0, stdout = 'us-west-2\n', stderr = '' } + end + if vim.tbl_contains(args, 'create-bucket') then + create_args = args + return { code = 0, stdout = '', stderr = '' } + end + return { code = 0, stdout = '', stderr = '' } + end + local msg + local orig_notify = vim.notify + vim.notify = function(m) + msg = m + end + s3.auth() + vim.notify = orig_notify + assert.equals(2, #input_calls) + assert.is_not_nil(create_args) + assert.truthy(vim.tbl_contains(create_args, 'my-bucket')) + assert.truthy(msg and msg:find('bucket created')) + end) + + it('cancels when user provides empty bucket name', function() + util.input = function(opts) + if opts.prompt:find('bucket') then + return nil + end + return opts.default + end + util.system = function(args) + if vim.tbl_contains(args, 'get-caller-identity') then + return { + code = 0, + stdout = '{"Account":"123","Arn":"arn:aws:iam::user/test"}', + stderr = '', + } + end + return { code = 0, stdout = '', stderr = '' } + end + local msg + local orig_notify = vim.notify + vim.notify = function(m) + msg = m + end + s3.auth() + vim.notify = orig_notify + assert.truthy(msg and msg:find('cancelled')) + end) + + it('omits LocationConstraint for us-east-1', function() + util.input = function(opts) + if opts.prompt:find('bucket') then + return 'my-bucket' + end + if opts.prompt:find('region') then + return 'us-east-1' + end + return opts.default + end + local create_args + util.system = function(args) + if vim.tbl_contains(args, 'get-caller-identity') then + return { + code = 0, + stdout = '{"Account":"123","Arn":"arn:aws:iam::user/test"}', + stderr = '', + } + end + if vim.tbl_contains(args, 'configure') then + return { code = 0, stdout = 'us-east-1\n', stderr = '' } + end + if vim.tbl_contains(args, 'create-bucket') then + create_args = args + return { code = 0, stdout = '', stderr = '' } + end + return { code = 0, stdout = '', stderr = '' } + end + s3.auth() + assert.is_not_nil(create_args) + local joined = table.concat(create_args, ' ') + assert.falsy(joined:find('LocationConstraint')) + end) + + it('includes LocationConstraint for non-us-east-1 regions', function() + util.input = function(opts) + if opts.prompt:find('bucket') then + return 'my-bucket' + end + if opts.prompt:find('region') then + return 'eu-west-1' + end + return opts.default + end + local create_args + util.system = function(args) + if vim.tbl_contains(args, 'get-caller-identity') then + return { + code = 0, + stdout = '{"Account":"123","Arn":"arn:aws:iam::user/test"}', + stderr = '', + } + end + if vim.tbl_contains(args, 'configure') then + return { code = 0, stdout = 'eu-west-1\n', stderr = '' } + end + if vim.tbl_contains(args, 'create-bucket') then + create_args = args + return { code = 0, stdout = '', stderr = '' } + end + return { code = 0, stdout = '', stderr = '' } + end + s3.auth() + assert.is_not_nil(create_args) + assert.truthy(vim.tbl_contains(create_args, 'LocationConstraint=eu-west-1')) + end) + + it('reports error on bucket creation failure', function() + util.input = function(opts) + if opts.prompt:find('bucket') then + return 'bad-bucket' + end + return opts.default + end + util.system = function(args) + if vim.tbl_contains(args, 'get-caller-identity') then + return { + code = 0, + stdout = '{"Account":"123","Arn":"arn:aws:iam::user/test"}', + stderr = '', + } + end + if vim.tbl_contains(args, 'configure') then + return { code = 0, stdout = 'us-east-1\n', stderr = '' } + end + if vim.tbl_contains(args, 'create-bucket') then + return { code = 1, stdout = '', stderr = 'BucketAlreadyExists' } + end + return { code = 0, stdout = '', stderr = '' } + end + local msg + local orig_notify = vim.notify + vim.notify = function(m, level) + if level == vim.log.levels.ERROR then + msg = m + end + end + s3.auth() + vim.notify = orig_notify + assert.truthy(msg and msg:find('bucket creation failed')) + end) + + it('defaults region to us-east-1 when aws configure returns nothing', function() + util.input = function(opts) + if opts.prompt:find('bucket') then + return 'my-bucket' + end + return '' + end + local create_args + util.system = function(args) + if vim.tbl_contains(args, 'get-caller-identity') then + return { + code = 0, + stdout = '{"Account":"123","Arn":"arn:aws:iam::user/test"}', + stderr = '', + } + end + if vim.tbl_contains(args, 'configure') then + return { code = 1, stdout = '', stderr = '' } + end + if vim.tbl_contains(args, 'create-bucket') then + create_args = args + return { code = 0, stdout = '', stderr = '' } + end + return { code = 0, stdout = '', stderr = '' } + end + s3.auth() + assert.is_not_nil(create_args) + assert.truthy(vim.tbl_contains(create_args, 'us-east-1')) + local joined = table.concat(create_args, ' ') + assert.falsy(joined:find('LocationConstraint')) + end) + end) + describe('push', function() it('uploads store to S3', function() local s = pending.store() diff --git a/spec/sync_util_spec.lua b/spec/sync_util_spec.lua index aad4c85..d3660fb 100644 --- a/spec/sync_util_spec.lua +++ b/spec/sync_util_spec.lua @@ -80,7 +80,6 @@ describe('sync util', function() vim.g.pending = { data_path = tmpdir .. '/tasks.json' } config.reset() package.loaded['pending'] = nil - local pending = require('pending') local s = store_mod.new(tmpdir .. '/tasks.json') s:load() From aa63b9bd6c5cb43a440e3858fc55680b6aa5934a Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Sun, 8 Mar 2026 20:28:06 -0400 Subject: [PATCH 161/199] feat(archive): duration syntax and confirmation prompt (#113) * feat(s3): create bucket interactively during auth when unconfigured Problem: when a user runs `:Pending s3 auth` with no bucket configured, auth succeeds but offers no way to create the bucket. The user must manually run `aws s3api create-bucket` and update their config. Solution: add `util.input()` coroutine-aware prompt wrapper and a `create_bucket()` flow in `s3.lua` that prompts for bucket name and region, handles the `us-east-1` LocationConstraint quirk, and logs a config snippet on success. Called automatically from `auth()` when `sync.s3.bucket` is absent. * ci: typing * feat(parse): add `parse_duration_to_days` for duration string conversion Problem: The archive command accepted only a bare integer for days, inconsistent with the `+Nd`/`+Nw`/`+Nm` duration syntax used elsewhere. Solution: Add `parse_duration_to_days()` supporting `Nd`, `Nw`, `Nm`, and bare integers. Returns nil on invalid input for caller error handling. * feat(archive): duration syntax and confirmation prompt Problem: `:Pending archive` accepted only a bare integer for days and silently deleted tasks with no confirmation, risking accidental data loss. Solution: Accept duration strings (`7d`, `3w`, `2m`) via `parse.parse_duration_to_days()`, show a `vim.ui.input` confirmation prompt before removing tasks, and skip the prompt when zero tasks match. --- doc/pending.txt | 21 ++++-- lua/pending/init.lua | 64 ++++++++++++----- lua/pending/parse.lua | 25 +++++++ plugin/pending.lua | 3 + spec/archive_spec.lua | 157 ++++++++++++++++++++++++++++++++++++++++-- spec/parse_spec.lua | 42 +++++++++++ 6 files changed, 287 insertions(+), 25 deletions(-) diff --git a/doc/pending.txt b/doc/pending.txt index 0cf6000..59da208 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -141,11 +141,24 @@ COMMANDS *pending-commands* after the add. *:Pending-archive* -:Pending archive [{days}] +:Pending archive [{duration}] Permanently remove done and deleted tasks whose completion timestamp is - older than {days} days. {days} defaults to 30 if not provided. >vim - :Pending archive " remove tasks completed more than 30 days ago - :Pending archive 7 " remove tasks completed more than 7 days ago + older than {duration}. {duration} defaults to 30 days if not provided. + + Supported duration formats: + `Nd` N days (e.g. `7d`) + `Nw` N weeks (e.g. `3w` → 21 days) + `Nm` N months (e.g. `2m` → 60 days, approximated as N×30) + `N` bare integer, treated as days (backwards-compatible) + + A confirmation prompt is shown before any tasks are removed. If no + tasks match the cutoff, a message is displayed and no prompt appears. +>vim + :Pending archive " 30-day default, with confirmation + :Pending archive 7d " tasks completed more than 7 days ago + :Pending archive 3w " tasks completed more than 21 days ago + :Pending archive 2m " tasks completed more than 60 days ago + :Pending archive 30 " bare integer, same as 30d < *:Pending-due* diff --git a/lua/pending/init.lua b/lua/pending/init.lua index f01f162..77959c9 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -992,35 +992,67 @@ local function run_sync(backend_name, action) backend[action]() end ----@param days? integer +---@param msg string +---@param callback fun() +local function confirm(msg, callback) + vim.ui.input({ prompt = msg .. ' [y/N]: ' }, function(input) + if input and input:lower() == 'y' then + callback() + end + end) +end + +---@param arg? string ---@return nil -function M.archive(days) - if days == nil then +function M.archive(arg) + local days + if arg and arg ~= '' then + days = parse.parse_duration_to_days(arg) + if not days then + log.error('Invalid duration: ' .. arg .. '. Use e.g. 7d, 2w, 3m, or a bare number.') + return + end + else 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 = {} + local count = 0 for _, task in ipairs(tasks) do if (task.status == 'done' or task.status == 'deleted') and task['end'] then if task['end'] < cutoff then - archived = archived + 1 - goto skip + count = count + 1 end end - table.insert(kept, task) - ::skip:: end - s:replace_tasks(kept) - _save_and_notify() - 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) + if count == 0 then + log.info('No tasks to archive.') + return end + confirm( + 'Archive ' .. count .. ' task' .. (count == 1 and '' or 's') .. ' completed/deleted more than ' .. days .. 'd ago?', + function() + local kept = {} + for _, task in ipairs(tasks) do + if (task.status == 'done' or task.status == 'deleted') and task['end'] then + if task['end'] < cutoff then + goto skip + end + end + table.insert(kept, task) + ::skip:: + end + s:replace_tasks(kept) + _save_and_notify() + log.info('Archived ' .. count .. ' task' .. (count == 1 and '' or 's') .. '.') + local bufnr = buffer.bufnr() + if bufnr and vim.api.nvim_buf_is_valid(bufnr) then + buffer.render(bufnr) + end + end + ) end ---@return nil @@ -1342,7 +1374,7 @@ function M.command(args) local action = rest:match('^(%S+)') run_sync(cmd, action) elseif cmd == 'archive' then - M.archive(tonumber(rest)) + M.archive(rest ~= '' and rest or nil) elseif cmd == 'due' then M.due() elseif cmd == 'filter' then diff --git a/lua/pending/parse.lua b/lua/pending/parse.lua index a0160f1..e8fdfab 100644 --- a/lua/pending/parse.lua +++ b/lua/pending/parse.lua @@ -667,4 +667,29 @@ function M.is_today(due) return time_part >= current_time end +---@param s? string +---@return integer? +function M.parse_duration_to_days(s) + if s == nil or s == '' then + return nil + end + local n = s:match('^(%d+)d$') + if n then + return tonumber(n) --[[@as integer]] + end + n = s:match('^(%d+)w$') + if n then + return tonumber(n) --[[@as integer]] * 7 + end + n = s:match('^(%d+)m$') + if n then + return tonumber(n) --[[@as integer]] * 30 + end + n = s:match('^(%d+)$') + if n then + return tonumber(n) --[[@as integer]] + end + return nil +end + return M diff --git a/plugin/pending.lua b/plugin/pending.lua index be8bc38..2d0d2be 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -203,6 +203,9 @@ end, { end return filtered end + if cmd_line:match('^Pending%s+archive%s') then + return filter_candidates(arg_lead, { '7d', '2w', '30d', '3m', '6m', '1y' }) + end if cmd_line:match('^Pending%s+done%s') then local store = require('pending.store') local s = store.new(store.resolve_path()) diff --git a/spec/archive_spec.lua b/spec/archive_spec.lua index e7046fa..94331b6 100644 --- a/spec/archive_spec.lua +++ b/spec/archive_spec.lua @@ -5,6 +5,7 @@ local config = require('pending.config') describe('archive', function() local tmpdir local pending + local ui_input_orig before_each(function() tmpdir = vim.fn.tempname() @@ -14,16 +15,31 @@ describe('archive', function() package.loaded['pending'] = nil pending = require('pending') pending.store():load() + ui_input_orig = vim.ui.input end) after_each(function() + vim.ui.input = ui_input_orig vim.fn.delete(tmpdir, 'rf') vim.g.pending = nil config.reset() package.loaded['pending'] = nil end) + local function auto_confirm_y() + vim.ui.input = function(opts, on_confirm) + on_confirm('y') + end + end + + local function auto_confirm_n() + vim.ui.input = function(opts, on_confirm) + on_confirm('n') + end + end + it('removes done tasks completed more than 30 days ago', function() + auto_confirm_y() local s = pending.store() local t = s:add({ description = 'Old done task' }) s:update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' }) @@ -32,6 +48,7 @@ describe('archive', function() end) it('keeps done tasks completed fewer than 30 days ago', function() + auto_confirm_y() local s = pending.store() local recent_end = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (5 * 86400)) local t = s:add({ description = 'Recent done task' }) @@ -42,26 +59,84 @@ describe('archive', function() assert.are.equal('Recent done task', active[1].description) end) - it('respects a custom day count', function() + it('respects duration string 7d', function() + auto_confirm_y() local s = pending.store() local eight_days_ago = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (8 * 86400)) local t = s:add({ description = 'Old for 7 days' }) s:update(t.id, { status = 'done', ['end'] = eight_days_ago }) - pending.archive(7) + pending.archive('7d') assert.are.equal(0, #s:active_tasks()) end) - it('keeps tasks within the custom day cutoff', function() + it('respects duration string 2w', function() + auto_confirm_y() + local s = pending.store() + local fifteen_days_ago = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (15 * 86400)) + local t = s:add({ description = 'Old for 2 weeks' }) + s:update(t.id, { status = 'done', ['end'] = fifteen_days_ago }) + pending.archive('2w') + assert.are.equal(0, #s:active_tasks()) + end) + + it('respects duration string 2m', function() + auto_confirm_y() + local s = pending.store() + local t = s:add({ description = 'Old for 2 months' }) + s:update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' }) + pending.archive('2m') + assert.are.equal(0, #s:active_tasks()) + end) + + it('respects bare integer as days (backwards compat)', function() + auto_confirm_y() + local s = pending.store() + local eight_days_ago = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (8 * 86400)) + 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, #s:active_tasks()) + end) + + it('keeps tasks within the custom duration cutoff', function() + auto_confirm_y() local s = pending.store() local five_days_ago = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (5 * 86400)) local t = s:add({ description = 'Recent for 7 days' }) s:update(t.id, { status = 'done', ['end'] = five_days_ago }) - pending.archive(7) + pending.archive('7d') local active = s:active_tasks() assert.are.equal(1, #active) end) + it('errors on invalid duration input', function() + local messages = {} + local orig_notify = vim.notify + vim.notify = function(msg, ...) + table.insert(messages, msg) + return orig_notify(msg, ...) + end + + local s = pending.store() + local t = s:add({ description = 'Task' }) + s:update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' }) + pending.archive('xyz') + + vim.notify = orig_notify + assert.are.equal(1, #s:tasks()) + + local found = false + for _, msg in ipairs(messages) do + if msg:find('Invalid duration') then + found = true + break + end + end + assert.is_true(found) + end) + it('never archives pending tasks regardless of age', function() + auto_confirm_y() local s = pending.store() s:add({ description = 'Still pending' }) pending.archive() @@ -71,6 +146,7 @@ describe('archive', function() end) it('removes deleted tasks past the cutoff', function() + auto_confirm_y() local s = pending.store() local t = s:add({ description = 'Old deleted task' }) s:update(t.id, { status = 'deleted', ['end'] = '2020-01-01T00:00:00Z' }) @@ -80,6 +156,7 @@ describe('archive', function() end) it('keeps deleted tasks within the cutoff', function() + auto_confirm_y() local s = pending.store() local recent_end = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (5 * 86400)) local t = s:add({ description = 'Recent deleted' }) @@ -89,7 +166,58 @@ describe('archive', function() assert.are.equal(1, #all) end) + it('skips confirmation and reports when no tasks match', function() + local input_called = false + vim.ui.input = function() + input_called = true + end + + local messages = {} + local orig_notify = vim.notify + vim.notify = function(msg, ...) + table.insert(messages, msg) + return orig_notify(msg, ...) + end + + local s = pending.store() + s:add({ description = 'Still pending' }) + pending.archive() + + vim.notify = orig_notify + assert.is_false(input_called) + + local found = false + for _, msg in ipairs(messages) do + if msg:find('No tasks to archive') then + found = true + break + end + end + assert.is_true(found) + end) + + it('does not archive when user declines confirmation', function() + auto_confirm_n() + 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(1, #s:tasks()) + end) + + it('does not archive when user cancels confirmation (nil)', function() + vim.ui.input = function(opts, on_confirm) + on_confirm(nil) + end + 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(1, #s:tasks()) + end) + it('reports the correct count in vim.notify', function() + auto_confirm_y() local s = pending.store() local messages = {} local orig_notify = vim.notify @@ -118,7 +246,8 @@ describe('archive', function() assert.is_true(found) end) - it('leaves only kept tasks in store.active_tasks after archive', function() + it('leaves only kept tasks in store after archive', function() + auto_confirm_y() local s = pending.store() local t1 = s:add({ description = 'Old done' }) s:add({ description = 'Keep pending' }) @@ -140,6 +269,7 @@ describe('archive', function() end) it('persists archived tasks to disk after unload/reload', function() + auto_confirm_y() local s = pending.store() local t = s:add({ description = 'Archived task' }) s:update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' }) @@ -147,4 +277,21 @@ describe('archive', function() s:load() assert.are.equal(0, #s:active_tasks()) end) + + it('includes the duration in the confirmation prompt', function() + local prompt_text + vim.ui.input = function(opts, on_confirm) + prompt_text = opts.prompt + on_confirm('n') + end + + local s = pending.store() + local t = s:add({ description = 'Old' }) + s:update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' }) + pending.archive('3w') + + assert.is_not_nil(prompt_text) + assert.truthy(prompt_text:find('21d')) + assert.truthy(prompt_text:find('1 task')) + end) end) diff --git a/spec/parse_spec.lua b/spec/parse_spec.lua index 0e6ac19..0820356 100644 --- a/spec/parse_spec.lua +++ b/spec/parse_spec.lua @@ -416,6 +416,48 @@ describe('parse', function() end) end) + describe('parse_duration_to_days', function() + it('parses days suffix', function() + assert.are.equal(7, parse.parse_duration_to_days('7d')) + end) + + it('parses weeks suffix', function() + assert.are.equal(21, parse.parse_duration_to_days('3w')) + end) + + it('parses months suffix (approximated as 30 days)', function() + assert.are.equal(60, parse.parse_duration_to_days('2m')) + end) + + it('parses bare integer as days', function() + assert.are.equal(30, parse.parse_duration_to_days('30')) + end) + + it('returns nil for nil input', function() + assert.is_nil(parse.parse_duration_to_days(nil)) + end) + + it('returns nil for empty string', function() + assert.is_nil(parse.parse_duration_to_days('')) + end) + + it('returns nil for unrecognized input', function() + assert.is_nil(parse.parse_duration_to_days('xyz')) + end) + + it('returns nil for negative numbers', function() + assert.is_nil(parse.parse_duration_to_days('-7d')) + end) + + it('handles single digit', function() + assert.are.equal(1, parse.parse_duration_to_days('1d')) + end) + + it('handles large numbers', function() + assert.are.equal(365, parse.parse_duration_to_days('365d')) + end) + end) + describe('input_date_formats', function() before_each(function() config.reset() From 6b23e6810e31298a21d445a684fe5503dec86f1c Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Sun, 8 Mar 2026 20:49:05 -0400 Subject: [PATCH 162/199] feat: add / keymaps for priority increment/decrement (#114) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(s3): create bucket interactively during auth when unconfigured Problem: when a user runs `:Pending s3 auth` with no bucket configured, auth succeeds but offers no way to create the bucket. The user must manually run `aws s3api create-bucket` and update their config. Solution: add `util.input()` coroutine-aware prompt wrapper and a `create_bucket()` flow in `s3.lua` that prompts for bucket name and region, handles the `us-east-1` LocationConstraint quirk, and logs a config snippet on success. Called automatically from `auth()` when `sync.s3.bucket` is absent. * ci: typing * feat(parse): add `parse_duration_to_days` for duration string conversion Problem: The archive command accepted only a bare integer for days, inconsistent with the `+Nd`/`+Nw`/`+Nm` duration syntax used elsewhere. Solution: Add `parse_duration_to_days()` supporting `Nd`, `Nw`, `Nm`, and bare integers. Returns nil on invalid input for caller error handling. * feat(archive): duration syntax and confirmation prompt Problem: `:Pending archive` accepted only a bare integer for days and silently deleted tasks with no confirmation, risking accidental data loss. Solution: Accept duration strings (`7d`, `3w`, `2m`) via `parse.parse_duration_to_days()`, show a `vim.ui.input` confirmation prompt before removing tasks, and skip the prompt when zero tasks match. * feat: add `` / `` keymaps for priority increment/decrement Problem: Priority could only be cycled with `g!` (0→1→2→3→0), with no way to directly increment or decrement. Solution: Add `adjust_priority()` with clamping at 0 and `max_priority`, exposed as `increment_priority()` / `decrement_priority()` on `` / ``. Includes `` mappings and vimdoc. * fix(s3): use parenthetical defaults in bucket creation prompts Problem: `util.input` with `default` pre-filled the input field, and the success message said "Add to your config" ambiguously. Solution: Show defaults in prompt text as `(default)` instead of pre-filling, and clarify the message to "Add to your pending.nvim config". * ci: format --- doc/pending.txt | 12 ++++++++ lua/pending/config.lua | 2 ++ lua/pending/init.lua | 64 ++++++++++++++++++++++++++++++++++++++++- lua/pending/parse.lua | 6 ++-- lua/pending/sync/s3.lua | 17 +++++++---- plugin/pending.lua | 8 ++++++ spec/s3_spec.lua | 12 ++++---- 7 files changed, 106 insertions(+), 15 deletions(-) diff --git a/doc/pending.txt b/doc/pending.txt index 59da208..739ca88 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -299,6 +299,8 @@ Default buffer-local keys: ~ `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`) + `` Increment priority (clamped at `max_priority`) (`priority_up`) + `` Decrement priority (clamped at 0) (`priority_down`) `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`) @@ -413,6 +415,16 @@ old keys to `false`: >lua Toggle blocked status for the task under the cursor. If the task is already `blocked`, reverts to `pending`. + *(pending-priority-up)* +(pending-priority-up) + Increment the priority level for the task under the cursor, clamped + at `max_priority`. Default key: ``. + + *(pending-priority-down)* +(pending-priority-down) + Decrement the priority level for the task under the cursor, clamped + at 0. Default key: ``. + *(pending-open-line)* (pending-open-line) Insert a correctly-formatted blank task line below the cursor. diff --git a/lua/pending/config.lua b/lua/pending/config.lua index 599b050..c947b40 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -137,6 +137,8 @@ local defaults = { move_up = 'K', wip = 'gw', blocked = 'gb', + priority_up = '', + priority_down = '', }, sync = {}, icons = { diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 77959c9..02587c2 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -363,6 +363,12 @@ function M._setup_buf_mappings(bufnr) blocked = function() M.toggle_status('blocked') end, + priority_up = function() + M.increment_priority() + end, + priority_down = function() + M.decrement_priority() + end, undo = function() M.undo_write() end, @@ -646,6 +652,56 @@ function M.toggle_priority() end end +---@param delta integer +---@return nil +local function adjust_priority(delta) + 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 + local max = require('pending.config').get().max_priority or 3 + local new_priority = math.max(0, math.min(max, task.priority + delta)) + if new_priority == task.priority then + return + end + s:update(id, { priority = new_priority }) + _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.increment_priority() + adjust_priority(1) +end + +---@return nil +function M.decrement_priority() + adjust_priority(-1) +end + ---@return nil function M.prompt_date() local bufnr = buffer.bufnr() @@ -1032,7 +1088,13 @@ function M.archive(arg) return end confirm( - 'Archive ' .. count .. ' task' .. (count == 1 and '' or 's') .. ' completed/deleted more than ' .. days .. 'd ago?', + 'Archive ' + .. count + .. ' task' + .. (count == 1 and '' or 's') + .. ' completed/deleted more than ' + .. days + .. 'd ago?', function() local kept = {} for _, task in ipairs(tasks) do diff --git a/lua/pending/parse.lua b/lua/pending/parse.lua index e8fdfab..5a705ef 100644 --- a/lua/pending/parse.lua +++ b/lua/pending/parse.lua @@ -679,11 +679,13 @@ function M.parse_duration_to_days(s) end n = s:match('^(%d+)w$') if n then - return tonumber(n) --[[@as integer]] * 7 + return tonumber(n) --[[@as integer]] + * 7 end n = s:match('^(%d+)m$') if n then - return tonumber(n) --[[@as integer]] * 30 + return tonumber(n) --[[@as integer]] + * 30 end n = s:match('^(%d+)$') if n then diff --git a/lua/pending/sync/s3.lua b/lua/pending/sync/s3.lua index d3ad5b5..373052a 100644 --- a/lua/pending/sync/s3.lua +++ b/lua/pending/sync/s3.lua @@ -67,11 +67,14 @@ local function ensure_sync_id(task) end local function create_bucket() - local name = util.input({ prompt = 'S3 bucket name: ', default = 'pending.nvim' }) - if not name or name == '' then + local name = util.input({ prompt = 'S3 bucket name (pending.nvim): ' }) + if not name then log.info('s3: bucket creation cancelled') return end + if name == '' then + name = 'pending.nvim' + end local region_cmd = base_cmd() vim.list_extend(region_cmd, { 'configure', 'get', 'region' }) @@ -84,9 +87,11 @@ local function create_bucket() end end - local region = util.input({ prompt = 'AWS region: ', default = default_region }) - if not region or region == '' then - region = 'us-east-1' + local region = util.input({ prompt = 'AWS region (' .. default_region .. '): ' }) + if not region then + region = default_region + elseif region == '' then + region = default_region end local cmd = base_cmd() @@ -98,7 +103,7 @@ local function create_bucket() local result = util.system(cmd, { text = true }) if result.code == 0 then log.info( - 's3: bucket created. Add to your config:\n sync = { s3 = { bucket = "' + 's3: bucket created. Add to your pending.nvim config:\n sync = { s3 = { bucket = "' .. name .. '", region = "' .. region diff --git a/plugin/pending.lua b/plugin/pending.lua index 2d0d2be..084f162 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -328,6 +328,14 @@ vim.keymap.set('n', '(pending-blocked)', function() require('pending').toggle_status('blocked') end) +vim.keymap.set('n', '(pending-priority-up)', function() + require('pending').increment_priority() +end) + +vim.keymap.set('n', '(pending-priority-down)', function() + require('pending').decrement_priority() +end) + vim.keymap.set('n', '(pending-filter)', function() vim.ui.input({ prompt = 'Filter: ' }, function(input) if input then diff --git a/spec/s3_spec.lua b/spec/s3_spec.lua index c1b4c68..a9b1dbd 100644 --- a/spec/s3_spec.lua +++ b/spec/s3_spec.lua @@ -179,7 +179,7 @@ describe('s3', function() if opts.prompt:find('bucket') then return 'my-bucket' end - return opts.default + return '' end local create_args util.system = function(args) @@ -212,12 +212,12 @@ describe('s3', function() assert.truthy(msg and msg:find('bucket created')) end) - it('cancels when user provides empty bucket name', function() + it('cancels when user provides nil bucket name', function() util.input = function(opts) if opts.prompt:find('bucket') then return nil end - return opts.default + return '' end util.system = function(args) if vim.tbl_contains(args, 'get-caller-identity') then @@ -247,7 +247,7 @@ describe('s3', function() if opts.prompt:find('region') then return 'us-east-1' end - return opts.default + return '' end local create_args util.system = function(args) @@ -281,7 +281,7 @@ describe('s3', function() if opts.prompt:find('region') then return 'eu-west-1' end - return opts.default + return '' end local create_args util.system = function(args) @@ -311,7 +311,7 @@ describe('s3', function() if opts.prompt:find('bucket') then return 'bad-bucket' end - return opts.default + return '' end util.system = function(args) if vim.tbl_contains(args, 'get-caller-identity') then From 541c3524304fb21e39c9d66e3390c2e91b198bbf Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Sun, 8 Mar 2026 20:56:22 -0400 Subject: [PATCH 163/199] fix(sync): normalize log prefixes and S3 prompt UX (#115) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(s3): create bucket interactively during auth when unconfigured Problem: when a user runs `:Pending s3 auth` with no bucket configured, auth succeeds but offers no way to create the bucket. The user must manually run `aws s3api create-bucket` and update their config. Solution: add `util.input()` coroutine-aware prompt wrapper and a `create_bucket()` flow in `s3.lua` that prompts for bucket name and region, handles the `us-east-1` LocationConstraint quirk, and logs a config snippet on success. Called automatically from `auth()` when `sync.s3.bucket` is absent. * ci: typing * feat(parse): add `parse_duration_to_days` for duration string conversion Problem: The archive command accepted only a bare integer for days, inconsistent with the `+Nd`/`+Nw`/`+Nm` duration syntax used elsewhere. Solution: Add `parse_duration_to_days()` supporting `Nd`, `Nw`, `Nm`, and bare integers. Returns nil on invalid input for caller error handling. * feat(archive): duration syntax and confirmation prompt Problem: `:Pending archive` accepted only a bare integer for days and silently deleted tasks with no confirmation, risking accidental data loss. Solution: Accept duration strings (`7d`, `3w`, `2m`) via `parse.parse_duration_to_days()`, show a `vim.ui.input` confirmation prompt before removing tasks, and skip the prompt when zero tasks match. * feat: add `` / `` keymaps for priority increment/decrement Problem: Priority could only be cycled with `g!` (0→1→2→3→0), with no way to directly increment or decrement. Solution: Add `adjust_priority()` with clamping at 0 and `max_priority`, exposed as `increment_priority()` / `decrement_priority()` on `` / ``. Includes `` mappings and vimdoc. * fix(s3): use parenthetical defaults in bucket creation prompts Problem: `util.input` with `default` pre-filled the input field, and the success message said "Add to your config" ambiguously. Solution: Show defaults in prompt text as `(default)` instead of pre-filling, and clarify the message to "Add to your pending.nvim config". * ci: format * ci(sync): normalize log prefix to `backend:` across all sync backends Problem: Sync log messages used inconsistent prefixes like `s3 push:`, `gtasks pull:`, `gtasks sync —` instead of the `backend: action` pattern used by auth messages. Solution: Normalize all sync backend logs to `backend: action ...` format across `s3.lua`, `gcal.lua`, and `gtasks.lua`. * ci: fix linter warnings in archive spec and s3 bucket creation --- lua/pending/sync/gcal.lua | 2 +- lua/pending/sync/gtasks.lua | 8 ++++---- lua/pending/sync/s3.lua | 22 ++++++++++------------ spec/archive_spec.lua | 6 +++--- 4 files changed, 18 insertions(+), 20 deletions(-) diff --git a/lua/pending/sync/gcal.lua b/lua/pending/sync/gcal.lua index 2e50fd5..d316375 100644 --- a/lua/pending/sync/gcal.lua +++ b/lua/pending/sync/gcal.lua @@ -233,7 +233,7 @@ function M.push() end util.finish(s) - log.info('gcal push: ' .. util.fmt_counts({ + log.info('gcal: push ' .. util.fmt_counts({ { created, 'added' }, { updated, 'updated' }, { deleted, 'removed' }, diff --git a/lua/pending/sync/gtasks.lua b/lua/pending/sync/gtasks.lua index 9eade7d..272bed6 100644 --- a/lua/pending/sync/gtasks.lua +++ b/lua/pending/sync/gtasks.lua @@ -434,7 +434,7 @@ function M.push() local created, updated, deleted, failed = push_pass(access_token, tasklists, s, now_ts, by_gtasks_id) util.finish(s) - log.info('gtasks push: ' .. util.fmt_counts({ + log.info('gtasks: push ' .. util.fmt_counts({ { created, 'added' }, { updated, 'updated' }, { deleted, 'deleted' }, @@ -456,7 +456,7 @@ function M.pull() 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) util.finish(s) - log.info('gtasks pull: ' .. util.fmt_counts({ + log.info('gtasks: pull ' .. util.fmt_counts({ { created, 'added' }, { updated, 'updated' }, { unlinked, 'unlinked' }, @@ -480,12 +480,12 @@ function M.sync() 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) util.finish(s) - log.info('gtasks sync — push: ' .. util.fmt_counts({ + log.info('gtasks: sync push ' .. util.fmt_counts({ { pushed_create, 'added' }, { pushed_update, 'updated' }, { pushed_delete, 'deleted' }, { pushed_failed, 'failed' }, - }) .. ' pull: ' .. util.fmt_counts({ + }) .. ' | pull ' .. util.fmt_counts({ { pulled_create, 'added' }, { pulled_update, 'updated' }, { unlinked, 'unlinked' }, diff --git a/lua/pending/sync/s3.lua b/lua/pending/sync/s3.lua index 373052a..64c4348 100644 --- a/lua/pending/sync/s3.lua +++ b/lua/pending/sync/s3.lua @@ -88,9 +88,7 @@ local function create_bucket() end local region = util.input({ prompt = 'AWS region (' .. default_region .. '): ' }) - if not region then - region = default_region - elseif region == '' then + if not region or region == '' then region = default_region end @@ -220,12 +218,12 @@ function M.push() os.remove(tmpfile) if result.code ~= 0 then - log.error('s3 push: ' .. (result.stderr or 'unknown error')) + log.error('s3: push failed — ' .. (result.stderr or 'unknown error')) return end util.finish(s) - log.info('s3 push: uploaded to s3://' .. s3cfg.bucket .. '/' .. key) + log.info('s3: push uploaded to s3://' .. s3cfg.bucket .. '/' .. key) end) end) end @@ -247,7 +245,7 @@ function M.pull() if result.code ~= 0 then os.remove(tmpfile) - log.error('s3 pull: ' .. (result.stderr or 'unknown error')) + log.error('s3: pull failed — ' .. (result.stderr or 'unknown error')) return end @@ -258,7 +256,7 @@ function M.pull() end) if not load_ok then os.remove(tmpfile) - log.error('s3 pull: failed to parse remote store') + log.error('s3: pull failed — could not parse remote store') return end @@ -320,7 +318,7 @@ function M.pull() os.remove(tmpfile) util.finish(s) - log.info('s3 pull: ' .. util.fmt_counts({ + log.info('s3: pull ' .. util.fmt_counts({ { created, 'added' }, { updated, 'updated' }, { unchanged, 'unchanged' }, @@ -416,7 +414,7 @@ function M.sync() local f = io.open(s.path, 'r') if not f then - log.error('s3 sync: failed to read store file') + log.error('s3: sync failed — could not read store file') return end local content = f:read('*a') @@ -425,7 +423,7 @@ function M.sync() local push_tmpfile = vim.fn.tempname() .. '.json' local tf = io.open(push_tmpfile, 'w') if not tf then - log.error('s3 sync: failed to create temp file') + log.error('s3: sync failed — could not create temp file') return end tf:write(content) @@ -437,13 +435,13 @@ function M.sync() os.remove(push_tmpfile) if push_result.code ~= 0 then - log.error('s3 sync push: ' .. (push_result.stderr or 'unknown error')) + log.error('s3: sync push failed — ' .. (push_result.stderr or 'unknown error')) util.finish(s) return end util.finish(s) - log.info('s3 sync: pull ' .. util.fmt_counts({ + log.info('s3: sync ' .. util.fmt_counts({ { created, 'added' }, { updated, 'updated' }, }) .. ' | push uploaded') diff --git a/spec/archive_spec.lua b/spec/archive_spec.lua index 94331b6..525b12e 100644 --- a/spec/archive_spec.lua +++ b/spec/archive_spec.lua @@ -27,13 +27,13 @@ describe('archive', function() end) local function auto_confirm_y() - vim.ui.input = function(opts, on_confirm) + vim.ui.input = function(_, on_confirm) on_confirm('y') end end local function auto_confirm_n() - vim.ui.input = function(opts, on_confirm) + vim.ui.input = function(_, on_confirm) on_confirm('n') end end @@ -206,7 +206,7 @@ describe('archive', function() end) it('does not archive when user cancels confirmation (nil)', function() - vim.ui.input = function(opts, on_confirm) + vim.ui.input = function(_, on_confirm) on_confirm(nil) end local s = pending.store() From 0c1f5091291b973aa4c2e7e15ede3ebe9f5110b0 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Mon, 9 Mar 2026 00:10:09 -0400 Subject: [PATCH 164/199] fix(config): update default keymaps to match vimdoc (#116) Problem: four keymap defaults in `config.lua` still used the old deprecated keys (`!`, `D`, `U`, `F`) while `doc/pending.txt` documents the `g`-prefixed replacements (`g!`, `gd`, `gz`, `gf`). Solution: update `priority`, `date`, `undo`, and `filter` defaults to `g!`, `gd`, `gz`, and `gf` respectively. --- lua/pending/config.lua | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lua/pending/config.lua b/lua/pending/config.lua index c947b40..1d4c00a 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -117,10 +117,10 @@ local defaults = { 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 979c4339b8820329c1c4179dd68425005bf69b72 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Mon, 9 Mar 2026 00:28:58 -0400 Subject: [PATCH 165/199] fix(buffer): correct extmark drift on `open_line` for done tasks (#118) * fix(config): update default keymaps to match vimdoc Problem: four keymap defaults in `config.lua` still used the old deprecated keys (`!`, `D`, `U`, `F`) while `doc/pending.txt` documents the `g`-prefixed replacements (`g!`, `gd`, `gz`, `gf`). Solution: update `priority`, `date`, `undo`, and `filter` defaults to `g!`, `gd`, `gz`, and `gf` respectively. * fix(buffer): correct extmark drift on `open_line` above/below done tasks Problem: `open_line` used `nvim_buf_set_lines` which triggered `on_bytes` with a `start_row` offset designed for native `o`/`O` keypresses. The `_meta` entry was inserted one position too late, causing the done task's `PendingDone` highlight to attach to the new blank line instead. Solution: suppress `on_bytes` during `open_line` by reusing the `_rendering` guard, insert the meta entry at the correct position, and immediately reapply inline extmarks for the affected rows. * fix(buffer): infer task status from line text in `reapply_dirty_inline` Problem: `on_bytes` inserts bare `{ type = 'task' }` meta entries with no `status` field for any new lines (paste, undo, native edits). When meta positions also shift incorrectly (e.g. `P` paste above), existing meta with the wrong status ends up on the wrong row. This causes done tasks to lose their `PendingDone` highlight and pending tasks to appear greyed out. Solution: always re-infer `status` from the actual buffer line text for dirty task rows before applying extmarks. The checkbox character (`[x]`, `[>]`, `[=]`, `[ ]`) is the source of truth, with fallback to the existing meta status if the line doesn't match a task pattern. --- lua/pending/buffer.lua | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index 5d18e1f..250ed8e 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -182,6 +182,23 @@ local function apply_inline_row(bufnr, row, m, icons) end end +---@param line string +---@return string? +local function infer_status(line) + local ch = line:match('^/%d+/- %[(.)%]') or line:match('^- %[(.)%]') + if not ch then + return nil + end + if ch == 'x' then + return 'done' + elseif ch == '>' then + return 'wip' + elseif ch == '=' then + return 'blocked' + end + return 'pending' +end + ---@param bufnr integer ---@return nil function M.reapply_dirty_inline(bufnr) @@ -191,6 +208,10 @@ function M.reapply_dirty_inline(bufnr) local icons = config.get().icons for row in pairs(_dirty_rows) do local m = _meta[row] + if m and m.type == 'task' then + local line = vim.api.nvim_buf_get_lines(bufnr, row - 1, row, false)[1] or '' + m.status = infer_status(line) or m.status + end if m then vim.api.nvim_buf_clear_namespace(bufnr, ns_inline, row - 1, row) apply_inline_row(bufnr, row - 1, m, icons) @@ -345,8 +366,25 @@ function M.open_line(above) end local row = vim.api.nvim_win_get_cursor(0)[1] local insert_row = above and (row - 1) or row + local meta_pos = insert_row + 1 + + _rendering = true vim.bo[bufnr].modifiable = true vim.api.nvim_buf_set_lines(bufnr, insert_row, insert_row, false, { '- [ ] ' }) + _rendering = false + + table.insert(_meta, meta_pos, { type = 'task' }) + + local icons = config.get().icons + local total = vim.api.nvim_buf_line_count(bufnr) + for r = meta_pos, math.min(meta_pos + 1, total) do + vim.api.nvim_buf_clear_namespace(bufnr, ns_inline, r - 1, r) + local m = _meta[r] + if m then + apply_inline_row(bufnr, r - 1, m, icons) + end + end + vim.api.nvim_win_set_cursor(0, { insert_row + 1, 6 }) vim.cmd('startinsert!') end From b9b0b233d928dbda98adbf95d175cf33c8333b03 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Mon, 9 Mar 2026 00:31:13 -0400 Subject: [PATCH 166/199] fix(buffer): escape hyphens in `infer_status` Lua patterns (#119) * fix(buffer): escape hyphens in `infer_status` Lua patterns Problem: `infer_status` used `/-` in its Lua patterns, which is a lazy quantifier on `/` rather than a literal hyphen. This caused the function to always return `nil` for lines with an `/id/` prefix, so status was never inferred from buffer text during `reapply_dirty_inline`. Solution: escape hyphens as `%-` in both patterns. Also add debug logging to `on_bytes`, `reapply_dirty_inline`, `apply_extmarks`, and the `TextChanged`/`TextChangedI`/`InsertLeave` autocmds. * ci: format --- lua/pending/buffer.lua | 33 +++++++++++++++++++++++++++++++-- lua/pending/init.lua | 3 +++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index 250ed8e..7f8d94c 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -185,7 +185,7 @@ end ---@param line string ---@return string? local function infer_status(line) - local ch = line:match('^/%d+/- %[(.)%]') or line:match('^- %[(.)%]') + local ch = line:match('^/%d+/%- %[(.)%]') or line:match('^%- %[(.)%]') if not ch then return nil end @@ -205,12 +205,22 @@ function M.reapply_dirty_inline(bufnr) if not next(_dirty_rows) then return end + log.debug(('reapply_dirty: rows=%s'):format(vim.inspect(vim.tbl_keys(_dirty_rows)))) local icons = config.get().icons for row in pairs(_dirty_rows) do local m = _meta[row] if m and m.type == 'task' then local line = vim.api.nvim_buf_get_lines(bufnr, row - 1, row, false)[1] or '' + local old_status = m.status m.status = infer_status(line) or m.status + log.debug( + ('reapply_dirty: row=%d line=%q old_status=%s new_status=%s'):format( + row, + line, + tostring(old_status), + tostring(m.status) + ) + ) end if m then vim.api.nvim_buf_clear_namespace(bufnr, ns_inline, row - 1, row) @@ -228,7 +238,7 @@ function M.attach_bytes(bufnr) end _on_bytes_active = true vim.api.nvim_buf_attach(bufnr, false, { - on_bytes = function(_, buf, _, start_row, _, _, old_end_row, _, _, new_end_row, _, _) + on_bytes = function(_, buf, _, start_row, start_col, _, old_end_row, _, _, new_end_row, _, _) if buf ~= task_bufnr then _on_bytes_active = false return true @@ -237,13 +247,24 @@ function M.attach_bytes(bufnr) return end local delta = new_end_row - old_end_row + log.debug( + ('on_bytes: start_row=%d start_col=%d old_end=%d new_end=%d delta=%d'):format( + start_row, + start_col, + old_end_row, + new_end_row, + delta + ) + ) if delta > 0 then for _ = 1, delta do + log.debug(('on_bytes: insert meta at %d'):format(start_row + 2)) table.insert(_meta, start_row + 2, { type = 'task' }) end elseif delta < 0 then for _ = 1, -delta do if _meta[start_row + 2] then + log.debug(('on_bytes: remove meta at %d'):format(start_row + 2)) table.remove(_meta, start_row + 2) end end @@ -251,6 +272,10 @@ function M.attach_bytes(bufnr) for r = start_row + 1, start_row + 1 + math.max(0, new_end_row) do _dirty_rows[r] = true end + log.debug(('on_bytes: dirty rows=%s'):format(vim.inspect(vim.tbl_keys(_dirty_rows)))) + for i, m in ipairs(_meta) do + log.debug(('on_bytes: _meta[%d] type=%s status=%s'):format(i, m.type, tostring(m.status))) + end end, }) end @@ -496,7 +521,11 @@ local function apply_extmarks(bufnr, line_meta) 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) + log.debug(('apply_extmarks: full render, %d lines'):format(#line_meta)) for i, m in ipairs(line_meta) do + log.debug( + ('apply_extmarks: row=%d type=%s status=%s'):format(i - 1, m.type, tostring(m.status)) + ) local row = i - 1 if m.type == 'task' then local virt_parts = build_eol_virt(eol_segments, m, icons) diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 02587c2..d372f3a 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -268,6 +268,7 @@ function M._setup_autocmds(bufnr) if not vim.bo[bufnr].modified then return end + log.debug('autocmd: TextChangedI') for row in pairs(buffer.dirty_rows()) do buffer.clear_inline_row(bufnr, row) end @@ -280,6 +281,7 @@ function M._setup_autocmds(bufnr) if not vim.bo[bufnr].modified then return end + log.debug('autocmd: TextChanged') buffer.reapply_dirty_inline(bufnr) end, }) @@ -288,6 +290,7 @@ function M._setup_autocmds(bufnr) buffer = bufnr, callback = function() if vim.bo[bufnr].modified then + log.debug('autocmd: InsertLeave') buffer.reapply_dirty_inline(bufnr) end end, From dbf0ab12210377f3cfb6368d0f1a12b25badd41c Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Tue, 10 Mar 2026 11:26:16 -0400 Subject: [PATCH 167/199] fix(sync): add backend name prefix to all OAuth log messages (#122) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(sync): add backend name prefix to all OAuth log messages (#121) Problem: four log messages in `oauth.lua` lacked the `self.name` backend prefix, producing generic notifications instead of identifying which backend (`gcal`/`gtasks`) triggered the message. Solution: prepend `self.name .. ': '` to the four unprefixed messages and drop the hardcoded "Google" from the browser prompt since `self.name` already identifies the service. * fix(sync): canonicalize all log prefixes across sync backends (#121) Problem: log messages in `oauth.lua`, `gcal.lua`, `gtasks.lua`, and `s3.lua` were inconsistent — some lacked a backend prefix, others used sentence-case or bare error strings without identifying the source. Solution: prefix all user-facing log messages with their backend name (`gcal:`, `gtasks:`, `S3:`, `Google:`). Capitalize `S3` and `Google` display names. Normalize casing and separator style (em dash) across all sync log sites. --- lua/pending/sync/gcal.lua | 10 +++---- lua/pending/sync/gtasks.lua | 10 +++---- lua/pending/sync/oauth.lua | 10 +++---- lua/pending/sync/s3.lua | 58 ++++++++++++++++++------------------- 4 files changed, 44 insertions(+), 44 deletions(-) diff --git a/lua/pending/sync/gcal.lua b/lua/pending/sync/gcal.lua index d316375..a0a7617 100644 --- a/lua/pending/sync/gcal.lua +++ b/lua/pending/sync/gcal.lua @@ -159,7 +159,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('gcal: ' .. (cal_err or 'failed to fetch calendars')) return end @@ -185,7 +185,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('Failed to delete calendar event: ' .. del_err) + log.warn('gcal: failed to delete calendar event — ' .. del_err) failed = failed + 1 else unlink_remote(task, extra, now_ts) @@ -203,7 +203,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('Failed to update calendar event: ' .. upd_err) + log.warn('gcal: failed to update calendar event — ' .. upd_err) failed = failed + 1 else updated = updated + 1 @@ -211,12 +211,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('Failed to create calendar: ' .. (lid_err or 'unknown')) + log.warn('gcal: 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('Failed to create calendar event: ' .. create_err) + log.warn('gcal: 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 272bed6..89b32b6 100644 --- a/lua/pending/sync/gtasks.lua +++ b/lua/pending/sync/gtasks.lua @@ -267,7 +267,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('Failed to delete remote task: ' .. err) + log.warn('gtasks: failed to delete remote task — ' .. err) failed = failed + 1 else unlink_remote(task, now_ts) @@ -286,7 +286,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('Failed to update remote task: ' .. err) + log.warn('gtasks: failed to update remote task — ' .. err) failed = failed + 1 else task._extra = task._extra or {} @@ -300,7 +300,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('Failed to create remote task: ' .. create_err) + log.warn('gtasks: failed to create remote task — ' .. create_err) failed = failed + 1 elseif new_id then if not task._extra then @@ -339,7 +339,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('Failed to fetch task list "' .. list_name .. '": ' .. err) + log.warn('gtasks: failed to fetch task list "' .. list_name .. '" — ' .. err) failed = failed + 1 else fetched_list_ids[list_id] = true @@ -414,7 +414,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('gtasks: ' .. (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 516a353..a64b984 100644 --- a/lua/pending/sync/oauth.lua +++ b/lua/pending/sync/oauth.lua @@ -447,12 +447,12 @@ function OAuthClient:auth(on_complete) end) vim.ui.open(auth_url) - log.info('Opening browser for Google authorization...') + log.info(self.name .. ': Opening browser for authorization...') vim.defer_fn(function() if not server_closed then close_server() - log.warn('OAuth callback timed out (120s).') + log.warn(self.name .. ': OAuth callback timed out (120s).') end end, 120000) end @@ -490,14 +490,14 @@ function OAuthClient:_exchange_code(creds, code, code_verifier, port, on_complet if result.code ~= 0 then self:clear_tokens() - log.error('Token exchange failed.') + log.error(self.name .. ': 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:clear_tokens() - log.error('Invalid token response.') + log.error(self.name .. ': Invalid token response.') return end @@ -540,7 +540,7 @@ M._BUNDLED_CLIENT_SECRET = BUNDLED_CLIENT_SECRET M.BUNDLED_CLIENT_ID = BUNDLED_CLIENT_ID M.google_client = M.new({ - name = 'google', + name = 'Google', scope = 'https://www.googleapis.com/auth/tasks' .. ' https://www.googleapis.com/auth/calendar', port = 18392, config_key = 'google', diff --git a/lua/pending/sync/s3.lua b/lua/pending/sync/s3.lua index 64c4348..0d669b3 100644 --- a/lua/pending/sync/s3.lua +++ b/lua/pending/sync/s3.lua @@ -69,7 +69,7 @@ end local function create_bucket() local name = util.input({ prompt = 'S3 bucket name (pending.nvim): ' }) if not name then - log.info('s3: bucket creation cancelled') + log.info('S3: bucket creation cancelled') return end if name == '' then @@ -108,7 +108,7 @@ local function create_bucket() .. '" } }' ) else - log.error('s3: bucket creation failed — ' .. (result.stderr or 'unknown error')) + log.error('S3: bucket creation failed — ' .. (result.stderr or 'unknown error')) end end @@ -120,13 +120,13 @@ function M.auth(args) if not input or input == '' then local s3cfg = get_config() if s3cfg and s3cfg.profile then - log.info('s3: current profile: ' .. s3cfg.profile) + log.info('S3: current profile: ' .. s3cfg.profile) else - log.info('s3: no profile configured (using default)') + log.info('S3: no profile configured (using default)') end return end - log.info('s3: set profile in your config: sync = { s3 = { profile = "' .. input .. '" } }') + log.info('S3: set profile in your config: sync = { s3 = { profile = "' .. input .. '" } }') end) return end @@ -138,9 +138,9 @@ function M.auth(args) if result.code == 0 then local ok, data = pcall(vim.json.decode, result.stdout or '') if ok and data then - log.info('s3: authenticated as ' .. (data.Arn or data.Account or 'unknown')) + log.info('S3: authenticated as ' .. (data.Arn or data.Account or 'unknown')) else - log.info('s3: credentials valid') + log.info('S3: credentials valid') end local s3cfg = get_config() if not s3cfg or not s3cfg.bucket then @@ -149,21 +149,21 @@ function M.auth(args) else local stderr = result.stderr or '' if stderr:find('SSO') or stderr:find('sso') then - log.info('s3: SSO session expired — running login...') + log.info('S3: SSO session expired — running login...') local login_cmd = base_cmd() vim.list_extend(login_cmd, { 'sso', 'login' }) local login_result = util.system(login_cmd, { text = true }) if login_result.code == 0 then - log.info('s3: SSO login successful') + log.info('S3: SSO login successful') else - log.error('s3: SSO login failed — ' .. (login_result.stderr or '')) + log.error('S3: SSO login failed — ' .. (login_result.stderr or '')) end elseif stderr:find('Unable to locate credentials') or stderr:find('NoCredentialProviders') then - log.error('s3: no AWS credentials configured. See :h pending-s3') + log.error('S3: no AWS credentials configured. See :h pending-s3') else - log.error('s3: ' .. stderr) + log.error('S3: ' .. stderr) end end end) @@ -176,10 +176,10 @@ end function M.push() util.async(function() - util.with_guard('s3', function() + util.with_guard('S3', function() local s3cfg = get_config() if not s3cfg or not s3cfg.bucket then - log.error('s3: bucket is required. Set sync.s3.bucket in config.') + log.error('S3: bucket is required. Set sync.s3.bucket in config.') return end local key = s3cfg.key or 'pending.json' @@ -198,7 +198,7 @@ function M.push() local f = io.open(s.path, 'r') if not f then - log.error('s3: failed to read store file') + log.error('S3: failed to read store file') return end local content = f:read('*a') @@ -206,7 +206,7 @@ function M.push() local tf = io.open(tmpfile, 'w') if not tf then - log.error('s3: failed to create temp file') + log.error('S3: failed to create temp file') return end tf:write(content) @@ -218,22 +218,22 @@ function M.push() os.remove(tmpfile) if result.code ~= 0 then - log.error('s3: push failed — ' .. (result.stderr or 'unknown error')) + log.error('S3: push failed — ' .. (result.stderr or 'unknown error')) return end util.finish(s) - log.info('s3: push uploaded to s3://' .. s3cfg.bucket .. '/' .. key) + log.info('S3: push uploaded to s3://' .. s3cfg.bucket .. '/' .. key) end) end) end function M.pull() util.async(function() - util.with_guard('s3', function() + util.with_guard('S3', function() local s3cfg = get_config() if not s3cfg or not s3cfg.bucket then - log.error('s3: bucket is required. Set sync.s3.bucket in config.') + log.error('S3: bucket is required. Set sync.s3.bucket in config.') return end local key = s3cfg.key or 'pending.json' @@ -245,7 +245,7 @@ function M.pull() if result.code ~= 0 then os.remove(tmpfile) - log.error('s3: pull failed — ' .. (result.stderr or 'unknown error')) + log.error('S3: pull failed — ' .. (result.stderr or 'unknown error')) return end @@ -256,7 +256,7 @@ function M.pull() end) if not load_ok then os.remove(tmpfile) - log.error('s3: pull failed — could not parse remote store') + log.error('S3: pull failed — could not parse remote store') return end @@ -318,7 +318,7 @@ function M.pull() os.remove(tmpfile) util.finish(s) - log.info('s3: pull ' .. util.fmt_counts({ + log.info('S3: pull ' .. util.fmt_counts({ { created, 'added' }, { updated, 'updated' }, { unchanged, 'unchanged' }, @@ -329,10 +329,10 @@ end function M.sync() util.async(function() - util.with_guard('s3', function() + util.with_guard('S3', function() local s3cfg = get_config() if not s3cfg or not s3cfg.bucket then - log.error('s3: bucket is required. Set sync.s3.bucket in config.') + log.error('S3: bucket is required. Set sync.s3.bucket in config.') return end local key = s3cfg.key or 'pending.json' @@ -414,7 +414,7 @@ function M.sync() local f = io.open(s.path, 'r') if not f then - log.error('s3: sync failed — could not read store file') + log.error('S3: sync failed — could not read store file') return end local content = f:read('*a') @@ -423,7 +423,7 @@ function M.sync() local push_tmpfile = vim.fn.tempname() .. '.json' local tf = io.open(push_tmpfile, 'w') if not tf then - log.error('s3: sync failed — could not create temp file') + log.error('S3: sync failed — could not create temp file') return end tf:write(content) @@ -435,13 +435,13 @@ function M.sync() os.remove(push_tmpfile) if push_result.code ~= 0 then - log.error('s3: sync push failed — ' .. (push_result.stderr or 'unknown error')) + log.error('S3: sync push failed — ' .. (push_result.stderr or 'unknown error')) util.finish(s) return end util.finish(s) - log.info('s3: sync ' .. util.fmt_counts({ + log.info('S3: sync ' .. util.fmt_counts({ { created, 'added' }, { updated, 'updated' }, }) .. ' | push uploaded') From 625ee010749c017ee88663c61003488e8f015bb6 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Tue, 10 Mar 2026 11:37:16 -0400 Subject: [PATCH 168/199] fix(sync): auto-trigger auth flow on unauthenticated sync actions (#120) (#123) Problem: running a sync action (e.g. `:Pending gtasks push`) without being authenticated would silently abort with a warning, requiring the user to manually run `:Pending auth` first. Solution: `oauth.with_token()` now auto-triggers the browser auth flow when no token exists (for non-bundled credentials) and resumes the original action on success. `auth()` and `_exchange_code()` now call `on_complete(ok)` on all exit paths. S3 backends run `aws sts get-caller-identity` before every sync action, auto-triggering SSO login on expired sessions. --- doc/pending.txt | 15 ++++++ lua/pending/sync/oauth.lua | 45 ++++++++++++++++-- lua/pending/sync/s3.lua | 39 ++++++++++++++++ spec/oauth_spec.lua | 94 ++++++++++++++++++++++++++++++++++++++ spec/s3_spec.lua | 68 +++++++++++++++++++++++++++ 5 files changed, 256 insertions(+), 5 deletions(-) diff --git a/doc/pending.txt b/doc/pending.txt index 739ca88..af6a663 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -1143,6 +1143,21 @@ Shared utilities for backend authors are provided by `sync/util.lua`: Backend-specific configuration goes under `sync.` in |pending-config|. +Auto-auth: ~ + *pending-sync-auto-auth* +Running a sync action (`:Pending push/pull/sync`) without valid +credentials automatically triggers authentication before proceeding: + +- OAuth backends (gcal, gtasks): if real credentials are configured but no + token exists, the browser-based auth flow starts automatically. On + success, the original action continues. Bundled placeholder credentials + cannot auto-auth and require the setup wizard via `:Pending auth`. +- S3: `aws sts get-caller-identity` runs before every sync action. If SSO + is expired, `aws sso login` is triggered automatically. Missing + credentials abort with an error pointing to |pending-s3|. + +On auth failure, the sync action is aborted with an error message. + ============================================================================== GOOGLE CALENDAR *pending-gcal* diff --git a/lua/pending/sync/oauth.lua b/lua/pending/sync/oauth.lua index a64b984..22f4803 100644 --- a/lua/pending/sync/oauth.lua +++ b/lua/pending/sync/oauth.lua @@ -45,8 +45,28 @@ function M.with_token(client, name, callback) util.with_guard(name, function() local token = client:get_access_token() if not token then - require('pending.log').warn(name .. ': Not authenticated — run :Pending auth.') - return + local creds = client:resolve_credentials() + if creds.client_id == BUNDLED_CLIENT_ID then + log.warn(name .. ': No credentials configured — run :Pending auth.') + return + end + log.info(name .. ': Not authenticated — starting auth flow...') + local co = coroutine.running() + client:auth(function(ok) + vim.schedule(function() + coroutine.resume(co, ok) + end) + end) + local auth_ok = coroutine.yield() + if not auth_ok then + log.error(name .. ': Authentication failed.') + return + end + token = client:get_access_token() + if not token then + log.error(name .. ': Still not authenticated after auth flow.') + return + end end callback(token) end) @@ -349,7 +369,7 @@ function OAuthClient:setup() end) end ----@param on_complete? fun(): nil +---@param on_complete? fun(ok: boolean): nil ---@return nil function OAuthClient:auth(on_complete) if _active_close then @@ -360,6 +380,9 @@ 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.') + if on_complete then + on_complete(false) + end return end local port = self.port @@ -411,6 +434,9 @@ function OAuthClient:auth(on_complete) 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.') + if on_complete then + on_complete(false) + end return end @@ -453,6 +479,9 @@ function OAuthClient:auth(on_complete) if not server_closed then close_server() log.warn(self.name .. ': OAuth callback timed out (120s).') + if on_complete then + on_complete(false) + end end end, 120000) end @@ -461,7 +490,7 @@ end ---@param code string ---@param code_verifier string ---@param port integer ----@param on_complete? fun(): nil +---@param on_complete? fun(ok: boolean): nil ---@return nil function OAuthClient:_exchange_code(creds, code, code_verifier, port, on_complete) local body = 'client_id=' @@ -491,6 +520,9 @@ function OAuthClient:_exchange_code(creds, code, code_verifier, port, on_complet if result.code ~= 0 then self:clear_tokens() log.error(self.name .. ': Token exchange failed.') + if on_complete then + on_complete(false) + end return end @@ -498,6 +530,9 @@ function OAuthClient:_exchange_code(creds, code, code_verifier, port, on_complet if not ok or not decoded.access_token then self:clear_tokens() log.error(self.name .. ': Invalid token response.') + if on_complete then + on_complete(false) + end return end @@ -505,7 +540,7 @@ function OAuthClient:_exchange_code(creds, code, code_verifier, port, on_complet self:save_tokens(decoded) log.info(self.name .. ': Authorized successfully.') if on_complete then - on_complete() + on_complete(true) end end diff --git a/lua/pending/sync/s3.lua b/lua/pending/sync/s3.lua index 0d669b3..91e52c1 100644 --- a/lua/pending/sync/s3.lua +++ b/lua/pending/sync/s3.lua @@ -66,6 +66,35 @@ local function ensure_sync_id(task) return sync_id end +---@return boolean +local function ensure_credentials() + local cmd = base_cmd() + vim.list_extend(cmd, { 'sts', 'get-caller-identity', '--output', 'json' }) + local result = util.system(cmd, { text = true }) + if result.code == 0 then + return true + end + local stderr = result.stderr or '' + if stderr:find('SSO') or stderr:find('sso') then + log.info('S3: SSO session expired — running login...') + local login_cmd = base_cmd() + vim.list_extend(login_cmd, { 'sso', 'login' }) + local login_result = util.system(login_cmd, { text = true }) + if login_result.code == 0 then + log.info('S3: SSO login successful') + return true + end + log.error('S3: SSO login failed — ' .. (login_result.stderr or '')) + return false + end + if stderr:find('Unable to locate credentials') or stderr:find('NoCredentialProviders') then + log.error('S3: no AWS credentials configured. See :h pending-s3') + else + log.error('S3: credential check failed — ' .. stderr) + end + return false +end + local function create_bucket() local name = util.input({ prompt = 'S3 bucket name (pending.nvim): ' }) if not name then @@ -177,6 +206,9 @@ end function M.push() util.async(function() util.with_guard('S3', function() + if not ensure_credentials() then + return + end local s3cfg = get_config() if not s3cfg or not s3cfg.bucket then log.error('S3: bucket is required. Set sync.s3.bucket in config.') @@ -231,6 +263,9 @@ end function M.pull() util.async(function() util.with_guard('S3', function() + if not ensure_credentials() then + return + end local s3cfg = get_config() if not s3cfg or not s3cfg.bucket then log.error('S3: bucket is required. Set sync.s3.bucket in config.') @@ -330,6 +365,9 @@ end function M.sync() util.async(function() util.with_guard('S3', function() + if not ensure_credentials() then + return + end local s3cfg = get_config() if not s3cfg or not s3cfg.bucket then log.error('S3: bucket is required. Set sync.s3.bucket in config.') @@ -466,5 +504,6 @@ function M.health() end M._ensure_sync_id = ensure_sync_id +M._ensure_credentials = ensure_credentials return M diff --git a/spec/oauth_spec.lua b/spec/oauth_spec.lua index a4a6f1d..d004b90 100644 --- a/spec/oauth_spec.lua +++ b/spec/oauth_spec.lua @@ -232,4 +232,98 @@ describe('oauth', function() assert.equals('test', c.config_key) end) end) + + describe('with_token', function() + it('auto-triggers auth when not authenticated', function() + local c = oauth.new({ name = 'test_auth', scope = 'x', port = 0, config_key = 'gtasks' }) + local call_count = 0 + c.get_access_token = function() + call_count = call_count + 1 + if call_count == 1 then + return nil + end + return 'new-token' + end + c.resolve_credentials = function() + return { client_id = 'real-id', client_secret = 'real-secret' } + end + local auth_called = false + c.auth = function(_, on_complete) + auth_called = true + vim.schedule(function() + on_complete(true) + end) + end + local received_token + oauth.with_token(c, 'test_auth', function(token) + received_token = token + end) + vim.wait(1000, function() + return received_token ~= nil + end) + assert.is_true(auth_called) + assert.equals('new-token', received_token) + end) + + it('bails on bundled credentials without calling auth', function() + local c = oauth.new({ name = 'test_bail', scope = 'x', port = 0, config_key = 'gtasks' }) + c.get_access_token = function() + return nil + end + c.resolve_credentials = function() + return { client_id = oauth.BUNDLED_CLIENT_ID, client_secret = 'x' } + end + local auth_called = false + c.auth = function() + auth_called = true + end + local callback_called = false + oauth.with_token(c, 'test_bail', function() + callback_called = true + end) + vim.wait(500, function() + return false + end) + assert.is_false(auth_called) + assert.is_false(callback_called) + end) + + it('stops when auth fails', function() + local c = oauth.new({ name = 'test_fail', scope = 'x', port = 0, config_key = 'gtasks' }) + c.get_access_token = function() + return nil + end + c.resolve_credentials = function() + return { client_id = 'real-id', client_secret = 'real-secret' } + end + c.auth = function(_, on_complete) + vim.schedule(function() + on_complete(false) + end) + end + local callback_called = false + oauth.with_token(c, 'test_fail', function() + callback_called = true + end) + vim.wait(500, function() + return false + end) + assert.is_false(callback_called) + end) + + it('proceeds directly when already authenticated', function() + local c = oauth.new({ name = 'test_ok', scope = 'x', port = 0, config_key = 'gtasks' }) + c.get_access_token = function() + return 'existing-token' + end + local received_token + oauth.with_token(c, 'test_ok', function(token) + received_token = token + end) + vim.wait(1000, function() + return received_token ~= nil + end) + assert.equals('existing-token', received_token) + end) + end) end) diff --git a/spec/s3_spec.lua b/spec/s3_spec.lua index a9b1dbd..47bdc49 100644 --- a/spec/s3_spec.lua +++ b/spec/s3_spec.lua @@ -374,6 +374,64 @@ describe('s3', function() end) end) + describe('ensure_credentials', function() + it('returns true on valid credentials', function() + util.system = function(args) + if vim.tbl_contains(args, 'get-caller-identity') then + return { code = 0, stdout = '{"Account":"123"}', stderr = '' } + end + return { code = 0, stdout = '', stderr = '' } + end + assert.is_true(s3._ensure_credentials()) + end) + + it('returns false on missing credentials', function() + util.system = function() + return { code = 1, stdout = '', stderr = 'Unable to locate credentials' } + end + local msg + local orig_notify = vim.notify + vim.notify = function(m, level) + if level == vim.log.levels.ERROR then + msg = m + end + end + assert.is_false(s3._ensure_credentials()) + vim.notify = orig_notify + assert.truthy(msg and msg:find('no AWS credentials')) + end) + + it('retries SSO login on expired session', function() + local calls = {} + util.system = function(args) + if vim.tbl_contains(args, 'get-caller-identity') then + return { code = 1, stdout = '', stderr = 'Error: SSO session expired' } + end + if vim.tbl_contains(args, 'sso') then + table.insert(calls, 'sso-login') + return { code = 0, stdout = '', stderr = '' } + end + return { code = 0, stdout = '', stderr = '' } + end + assert.is_true(s3._ensure_credentials()) + assert.equals(1, #calls) + assert.equals('sso-login', calls[1]) + end) + + it('returns false when SSO login fails', function() + util.system = function(args) + if vim.tbl_contains(args, 'get-caller-identity') then + return { code = 1, stdout = '', stderr = 'SSO token expired' } + end + if vim.tbl_contains(args, 'sso') then + return { code = 1, stdout = '', stderr = 'login failed' } + end + return { code = 0, stdout = '', stderr = '' } + end + assert.is_false(s3._ensure_credentials()) + end) + end) + describe('push', function() it('uploads store to S3', function() local s = pending.store() @@ -383,6 +441,9 @@ describe('s3', function() local captured_args util.system = function(args) + if vim.tbl_contains(args, 'get-caller-identity') then + return { code = 0, stdout = '{"Account":"123"}', stderr = '' } + end if vim.tbl_contains(args, 's3') then captured_args = args return { code = 0, stdout = '', stderr = '' } @@ -405,6 +466,13 @@ describe('s3', function() pending = require('pending') s3 = require('pending.sync.s3') + util.system = function(args) + if vim.tbl_contains(args, 'get-caller-identity') then + return { code = 0, stdout = '{"Account":"123"}', stderr = '' } + end + return { code = 0, stdout = '', stderr = '' } + end + local msg local orig_notify = vim.notify vim.notify = function(m, level) From 17d31c0fc232d739077a8b4de508bae6bdddeaa6 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Tue, 10 Mar 2026 11:43:51 -0400 Subject: [PATCH 169/199] fix(sync): include backend name in bundled-creds auth recommendation (#124) Problem: `with_token()` recommended the generic `:Pending auth` when credentials were missing, even though the backend was already known. Solution: append the backend name so the message reads e.g. `:Pending auth gtasks` instead of `:Pending auth`. --- lua/pending/sync/oauth.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/pending/sync/oauth.lua b/lua/pending/sync/oauth.lua index 22f4803..8e670c1 100644 --- a/lua/pending/sync/oauth.lua +++ b/lua/pending/sync/oauth.lua @@ -47,7 +47,7 @@ function M.with_token(client, name, callback) if not token then local creds = client:resolve_credentials() if creds.client_id == BUNDLED_CLIENT_ID then - log.warn(name .. ': No credentials configured — run :Pending auth.') + log.warn(name .. ': No credentials configured — run :Pending auth ' .. name) return end log.info(name .. ': Not authenticated — starting auth flow...') From 2d59868b827789ad575d3a13a55ae0ae2c3f4418 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Tue, 10 Mar 2026 14:31:48 -0400 Subject: [PATCH 170/199] docs: document S3 backend, auto-auth, and `:Pending done` command (#125) Problem: The S3 backend had no `:Pending s3` entry in the COMMANDS section, `:Pending auth` only mentioned Google, the `sync` config field omitted `s3`, `_s3_sync_id` was missing from the data format section, `:Pending done` was implemented but undocumented, and the README lacked a features overview. Solution: Add `:Pending s3` and `:Pending done` command docs, rewrite `:Pending auth` to cover all backends and sub-actions, update config and data format references, add `aws` CLI to requirements, and add a Features section to `README.md`. --- README.md | 19 +++++++++++- doc/pending.txt | 82 ++++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 86 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 3448941..cff442d 100644 --- a/README.md +++ b/README.md @@ -8,10 +8,27 @@ Oil-like task management for todos in Neovim, inspired by https://github.com/user-attachments/assets/f3898ecb-ec95-43fe-a71f-9c9f49628ba9 +## Features + +- Oil-style buffer editing: standard Vim motions for all task operations +- Inline metadata: `due:`, `cat:`, `rec:` tokens parsed on `:w` +- Rich date input: relative (`+3d`, `tomorrow`), weekdays, ordinals, custom formats, time suffixes +- Recurring tasks with automatic next-date spawning on completion +- Category and queue views with foldable sections +- Multi-level undo (up to 20 saves, persisted across sessions) +- Text objects (`at`/`it`/`aC`/`iC`) and motions (`]]`/`[[`/`]t`/`[t`) +- Omnifunc completion for `due:`, `cat:`, and `rec:` tokens +- Filters: `cat:X`, `overdue`, `today`, `priority`, `wip`, `blocked` +- Google Calendar one-way push via OAuth PKCE +- Google Tasks bidirectional sync via OAuth PKCE +- S3 whole-store sync via AWS CLI with cross-device merge +- Auto-authentication: sync actions trigger auth flows automatically + ## Requirements - Neovim 0.10+ -- (Optionally) `curl` for Google Calendar and Google Task sync +- (Optionally) `curl` for Google Calendar and Google Tasks sync +- (Optionally) `aws` CLI for S3 sync ## Installation diff --git a/doc/pending.txt b/doc/pending.txt index af6a663..f79961e 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -42,6 +42,7 @@ Features: ~ - Omnifunc completion for `cat:`, `due:`, and `rec:` tokens (``) - Google Calendar one-way push via OAuth PKCE - Google Tasks bidirectional sync via OAuth PKCE +- S3 whole-store sync via AWS CLI ============================================================================== CONTENTS *pending-contents* @@ -76,6 +77,7 @@ REQUIREMENTS *pending-requirements* - Neovim 0.10+ - No external dependencies for local use - `curl` is required for the `gcal` and `gtasks` sync backends +- `aws` CLI is required for the `s3` sync backend ============================================================================== INSTALL *pending-install* @@ -167,13 +169,34 @@ COMMANDS *pending-commands* 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 auth [{backend} [{sub-action}]] + Authenticate a sync backend. Without arguments, prompts with + |vim.ui.select| when multiple backends have auth methods. With an + explicit {backend} name, dispatches directly: >vim + :Pending auth gcal + :Pending auth gtasks + :Pending auth s3 + :Pending auth gcal clear + :Pending auth gtasks reset + :Pending auth s3 profile +< + Google backends (gcal, gtasks): ~ + Both share a single OAuth flow with combined scopes. If no credentials + are configured (bundled placeholders in use), the setup wizard runs + first to collect a client ID and secret. Sub-actions: + `clear` Remove OAuth tokens (forces re-authentication). + `reset` Remove tokens and credentials (full reset). + See |pending-google-auth| for details. + + S3 backend: ~ + Runs `aws sts get-caller-identity` to verify AWS credentials. If the + profile uses SSO and the session has expired, runs `aws sso login` + automatically. Sub-actions: + `profile` Prompt for an AWS profile name. + See |pending-s3| for details. + + Auth is triggered automatically when running sync actions without valid + credentials. See |pending-sync-auto-auth|. *:Pending-gtasks* :Pending gtasks {action} @@ -207,6 +230,34 @@ COMMANDS *pending-commands* Tab completion after `:Pending gcal ` lists available actions. See |pending-gcal| for full details. + *:Pending-s3* +:Pending s3 {action} + Run an S3 sync action. An explicit action is required. + + Actions: ~ + `sync` Pull remote changes then push merged result. + `push` Upload the local store to S3. + `pull` Download the remote store and merge into local. + + Examples: >vim + :Pending s3 sync " pull then push + :Pending s3 push " upload local → S3 + :Pending s3 pull " download S3 → local +< + + Tab completion after `:Pending s3 ` lists available actions. + See |pending-s3| for full details. + + *:Pending-done* +:Pending done [{id}] + Mark a task as done without opening the buffer. {id} is the numeric task + ID. When {id} is omitted and the task buffer is open, the task under the + cursor is used. Recurring tasks spawn a new pending instance with the + next due date. >vim + :Pending done 5 + :Pending done +< + *:Pending-filter* :Pending filter {predicates} Apply a filter to the task buffer. {predicates} is a space-separated list @@ -862,9 +913,11 @@ Fields: ~ {sync} (table, default: {}) *pending.SyncConfig* Sync backend configuration. Each key is a backend name and the value is the backend-specific config - table. Built-in backends: `gcal`, `gtasks`. Both - ship bundled OAuth credentials so no setup is - needed beyond `:Pending auth`. + table. Built-in backends: `gcal`, `gtasks`, `s3`. + Google backends ship bundled OAuth credentials so + no setup is needed beyond `:Pending auth`. The S3 + backend delegates to the AWS CLI credential chain. + See |pending-gcal|, |pending-gtasks|, |pending-s3|. {icons} (table) *pending.Icons* Icon characters displayed in the buffer. The @@ -1424,10 +1477,11 @@ Task fields: ~ {order} (integer) Relative ordering within a category. 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 Google Tasks IDs (`_gtasks_task_id`, -`_gtasks_list_id`), and allows third-party tooling to annotate tasks without -data loss. +save. This is used internally to store sync backend metadata: +- Google Calendar: `_gcal_event_id`, `_gcal_calendar_id` +- Google Tasks: `_gtasks_task_id`, `_gtasks_list_id` +- S3: `_s3_sync_id` (UUID for cross-device merge) +Third-party tooling can annotate tasks via `_extra` without data loss. The `version` field is checked on load. If the file version is newer than the version the plugin supports, loading is aborted with an error message asking From 4c0ddad39cbbc170ad807b08f3eea0f01bbfeaba Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Tue, 10 Mar 2026 19:28:44 -0400 Subject: [PATCH 171/199] feat(forge): inline overlay rendering for forge links (#126) * docs: document S3 backend, auto-auth, and `:Pending done` command Problem: The S3 backend had no `:Pending s3` entry in the COMMANDS section, `:Pending auth` only mentioned Google, the `sync` config field omitted `s3`, `_s3_sync_id` was missing from the data format section, `:Pending done` was implemented but undocumented, and the README lacked a features overview. Solution: Add `:Pending s3` and `:Pending done` command docs, rewrite `:Pending auth` to cover all backends and sub-actions, update config and data format references, add `aws` CLI to requirements, and add a Features section to `README.md`. * feat(forge): add forge link parser and metadata fetcher Problem: no way to associate tasks with GitHub, GitLab, or Codeberg issues/PRs, or to track their remote state. Solution: add `forge.lua` with shorthand (`gh:user/repo#42`) and full URL parsing, async metadata fetching via `curl`, label formatting, conceal pattern generation, token resolution, and `refresh()` for state pull (closed/merged -> done). * feat(config): add forge config defaults and `%l` eol specifier Problem: no configuration surface for forge link rendering, icons, issue format, or self-hosted instances. Solution: add `pending.ForgeConfig` class with per-forge `token`, `icon`, `issue_format`, and `instances` fields. Add `%l` to the default `eol_format` so forge labels render in virtual text. * feat(parse): extract forge refs from task body Problem: `parse.body()` had no awareness of forge link tokens, so `gh:user/repo#42` stayed in the description instead of metadata. Solution: add `forge_ref` field to `pending.Metadata` and extend the right-to-left token loop in `body()` to call `forge.parse_ref()` as the final fallback before breaking. * feat(diff): persist forge refs in store on write Problem: forge refs parsed from buffer lines were discarded during diff reconciliation and never stored in the JSON. Solution: thread `forge_ref` through `parse_buffer` entries into `diff.apply`, storing it in `task._extra._forge_ref` for both new and existing tasks. * feat(views): pass forge ref and cache to line metadata Problem: `LineMeta` had no forge fields, so `buffer.lua` could not render forge labels or apply forge-specific highlights. Solution: add `forge_ref` and `forge_cache` fields to `LineMeta`, populated from `task._extra` in both `category_view` and `priority_view`. * feat(buffer): render forge links as concealed text with eol virt text Problem: forge tokens were visible as raw text with no virtual text labels, and the eol separator logic collapsed all gaps when non-adjacent specifiers were absent. Solution: add forge conceal syntax patterns in `setup_syntax()`, add `PendingForge`/`PendingForgeClosed` highlight groups, handle the `%l` specifier in `build_eol_virt()`, fix separator collapsing to buffer one separator between present segments, and change `concealcursor` to `nc` (reveal in visual and insert mode). * feat(complete): add forge shorthand omnifunc completions Problem: no completion support for `gh:`, `gl:`, or `cb:` tokens, requiring users to type owner/repo from memory. Solution: extend `omnifunc` to detect `gh:`/`gl:`/`cb:` prefixes and complete with `owner/repo#` candidates from existing forge refs in the store. * feat: trigger forge refresh on buffer open Problem: forge metadata was never fetched, so virt text highlights could not reflect remote issue/PR state. Solution: call `forge.refresh()` in `M.open()` so metadata is fetched once per `:Pending` invocation rather than on every render. * test(forge): add forge parsing spec Problem: no test coverage for forge link shorthand parsing, URL parsing, label formatting, or API URL generation. Solution: add `spec/forge_spec.lua` covering `_parse_shorthand`, `parse_ref` for all three forges, full URL parsing including nested GitLab groups, `format_label`, and `_api_url`. * docs: document forge links feature Problem: no user-facing documentation for forge link syntax, configuration, or behavior. Solution: add forge links section to `README.md` and `pending.txt` covering shorthand/URL syntax, config options, virtual text rendering, state pull, and auth resolution. * feat(forge): add `find_refs()` inline token scanner Problem: forge tokens were extracted by `parse.body()` which stripped them from the description, making editing awkward and multi-ref lines impossible. Solution: add `find_refs(text)` that scans a string for all forge tokens by whitespace tokenization, returning byte offsets and parsed refs without modifying the input. Remove unused `conceal_patterns()`. * refactor: move forge ref detection from `parse.body()` to `diff` Problem: `parse.body()` stripped forge tokens from the description, losing the raw text. This made inline overlay rendering impossible since the token no longer existed in the buffer. Solution: remove the `forge.parse_ref()` branch from `parse.body()` and call `forge.find_refs()` in `diff.parse_buffer()` instead. The description now retains forge tokens verbatim; `_extra._forge_ref` is still populated from the first matched ref. * feat(buffer): render forge links as inline conceal overlays Problem: forge tokens were stripped from the buffer and shown as EOL virtual text via `%l`. The token disappeared from the editable line, and multi-ref tasks broke. Solution: compute `forge_spans` in `views.lua` with byte offsets for each forge token in the rendered line. In `apply_inline_row()`, place extmarks with `conceal=''` and `virt_text_pos='inline'` to visually replace each raw token with its formatted label. Clear stale `forge_spans` on dirty rows to prevent `end_col` out-of-range errors after edits like `dd`. * fix(config): remove `%l` from default `eol_format` Problem: forge links are now rendered inline, making the `%l` EOL specifier redundant in the default format. Solution: change default `eol_format` from `'%l %c %r %d'` to `'%c %r %d'`. The `%l` specifier remains functional for users who explicitly set it. * test(forge): update specs for inline forge refs Problem: existing tests asserted that `parse.body()` stripped forge tokens from the description and populated `meta.forge_ref`. The `conceal_patterns` test referenced a removed function. Solution: update `parse.body` integration tests to assert tokens stay in the description. Add `find_refs()` tests covering single/multiple refs, URLs, byte offsets, and empty cases. Remove `conceal_patterns` test. Update diff tests to assert description includes the token. * docs: update forge links for inline overlay rendering Problem: documentation described forge tokens as stripped from the description and rendered via EOL `%l` specifier by default. Solution: update forge links section to describe inline conceal overlay rendering. Update default `eol_format` reference. Change `issue_format` field description from "EOL label" to "inline overlay label". * ci: format --- README.md | 1 + doc/pending.txt | 123 ++++++++++- lua/pending/buffer.lua | 53 +++-- lua/pending/complete.lua | 21 ++ lua/pending/config.lua | 29 +++ lua/pending/diff.lua | 14 ++ lua/pending/forge.lua | 435 +++++++++++++++++++++++++++++++++++++++ lua/pending/init.lua | 2 + lua/pending/views.lua | 39 ++++ spec/forge_spec.lua | 377 +++++++++++++++++++++++++++++++++ 10 files changed, 1071 insertions(+), 23 deletions(-) create mode 100644 lua/pending/forge.lua create mode 100644 spec/forge_spec.lua diff --git a/README.md b/README.md index cff442d..1780cab 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ https://github.com/user-attachments/assets/f3898ecb-ec95-43fe-a71f-9c9f49628ba9 - Google Tasks bidirectional sync via OAuth PKCE - S3 whole-store sync via AWS CLI with cross-device merge - Auto-authentication: sync actions trigger auth flows automatically +- Forge links: reference GitHub/GitLab/Codeberg issues and PRs inline ## Requirements diff --git a/doc/pending.txt b/doc/pending.txt index f79961e..9ec9b34 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -43,6 +43,7 @@ Features: ~ - Google Calendar one-way push via OAuth PKCE - Google Tasks bidirectional sync via OAuth PKCE - S3 whole-store sync via AWS CLI +- Forge links: reference GitHub/GitLab/Codeberg issues and PRs inline ============================================================================== CONTENTS *pending-contents* @@ -68,8 +69,9 @@ CONTENTS *pending-contents* 19. Google Tasks ............................................ |pending-gtasks| 20. Google Authentication ......................... |pending-google-auth| 21. S3 Sync ................................................... |pending-s3| - 22. Data Format .............................................. |pending-data| - 23. Health Check ........................................... |pending-health| + 22. Forge Links ........................................... |pending-forge| + 23. Data Format .............................................. |pending-data| + 24. Health Check ........................................... |pending-health| ============================================================================== REQUIREMENTS *pending-requirements* @@ -738,7 +740,7 @@ loads: >lua max_priority = 3, view = { default = 'category', - eol_format = '%c %r %d', + eol_format = '%l %c %r %d', category = { order = {}, folding = true, @@ -840,6 +842,7 @@ Fields: ~ {eol_format} (string, default: '%c %r %d') Format string for end-of-line virtual text. Specifiers: + `%l` forge link label (`PendingForge`) `%c` category icon + name (`PendingHeader`) `%r` recurrence icon + pattern (`PendingRecur`) `%d` due icon + date (`PendingDue`/`PendingOverdue`) @@ -1009,6 +1012,15 @@ PendingFilter Applied to the `FILTER:` header line shown at the top of the buffer when a filter is active. Default: links to `DiagnosticWarn`. + *PendingForge* +PendingForge Applied to forge link virtual text (issue/PR reference). + Default: links to `DiagnosticInfo`. + + *PendingForgeClosed* +PendingForgeClosed Applied to forge link virtual text when the remote + issue/PR is closed or merged. + Default: links to `Comment`. + To override a group in your colorscheme or config: >lua vim.api.nvim_set_hl(0, 'PendingDue', { fg = '#aaaaaa', italic = true }) < @@ -1443,6 +1455,110 @@ Downloads the remote store from S3, then merges per-task by `_s3_sync_id`: `:Pending s3 sync` behavior: ~ Pulls first (merge), then pushes the merged result. +============================================================================== +FORGE LINKS *pending-forge* + +Tasks can reference remote issues, pull requests, and merge requests from +GitHub, GitLab, and Codeberg (or Gitea). References are parsed from inline +tokens, concealed in the buffer, and rendered as configurable virtual text. + +Inline syntax: ~ + +Two input forms, both parsed on `:w`: + + Shorthand: ~ + `gh:user/repo#42` GitHub issue or PR + `gl:group/project#15` GitLab issue or MR + `cb:user/repo#3` Codeberg issue or PR + + Full URL: ~ + `https://github.com/user/repo/issues/42` + `https://gitlab.com/group/project/-/merge_requests/15` + `https://codeberg.org/user/repo/issues/3` + +Example: > + Fix login bug gh:user/repo#42 due:friday +< + +On `:w`, the forge reference stays in the description and is also stored in +the task's `_extra._forge_ref` field. The raw token is visually replaced +inline with a formatted label using overlay extmarks (same technique as +checkbox icons). Multiple forge references in one line are each overlaid +independently. + +The `%l` specifier in `eol_format` is still supported for users who prefer +the link label in EOL virtual text, but it is no longer in the default +format (`'%c %r %d'`). + +Format string: ~ + *pending-forge-format* +Each forge has a configurable `issue_format` string with these placeholders: + `%i` Forge icon (nerd font) + `%o` Repository owner + `%r` Repository name + `%n` Issue/PR number + +Default: `'%i %o/%r#%n'` (e.g. ` user/repo#42`). + +Configuration: ~ + *pending.ForgeConfig* +>lua + vim.g.pending = { + forge = { + github = { + token = nil, + icon = '', + issue_format = '%i %o/%r#%n', + instances = {}, + }, + gitlab = { + token = nil, + icon = '', + issue_format = '%i %o/%r#%n', + instances = {}, + }, + codeberg = { + token = nil, + icon = '', + issue_format = '%i %o/%r#%n', + instances = {}, + }, + }, + } +< + +Fields (per forge): ~ + {token} (string, optional) API token for authenticated requests. + Falls back to CLI: `gh auth token` (GitHub), `glab auth + token` (GitLab). Codeberg uses token only. + {icon} (string) Nerd font icon used in virtual text. + {issue_format} (string) Format string for the inline overlay label. + {instances} (string[]) Additional hostnames for self-hosted instances + (e.g. `{ 'github.company.com' }`). + +Authentication: ~ +Token retrieval is CLI-preferred, config fallback: +1. GitHub: `gh auth token` stdout. Falls back to `forge.github.token`. +2. GitLab: `glab auth token` stdout. Falls back to `forge.gitlab.token`. +3. Codeberg: `forge.codeberg.token` only (no standard CLI). + +Unauthenticated requests work for public repositories. Private repositories +require a token. + +Metadata fetching: ~ +On buffer open, tasks with a `_forge_ref` whose cached metadata is older +than 5 minutes are re-fetched asynchronously. The buffer renders immediately +with cached data and updates extmarks when the fetch completes. + +State pull: ~ +After fetching, if the remote issue/PR is closed or merged and the local +task is pending/wip/blocked, the task is automatically marked as done. This +is one-way: local status changes do not push back to the forge. + +Highlight groups: ~ + |PendingForge| Open issue/PR link label + |PendingForgeClosed| Closed/merged issue/PR link label + ============================================================================== DATA FORMAT *pending-data* @@ -1481,6 +1597,7 @@ save. This is used internally to store sync backend metadata: - Google Calendar: `_gcal_event_id`, `_gcal_calendar_id` - Google Tasks: `_gtasks_task_id`, `_gtasks_list_id` - S3: `_s3_sync_id` (UUID for cross-device merge) +- Forge links: `_forge_ref` (parsed reference), `_forge_cache` (fetched state) Third-party tooling can annotate tasks via `_extra` without data loss. The `version` field is checked on load. If the file version is newer than the diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index 7f8d94c..e162f67 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -168,6 +168,19 @@ local function apply_inline_row(bufnr, row, m, icons) virt_text_pos = 'overlay', priority = 100, }) + if m.forge_spans then + local forge = require('pending.forge') + for _, span in ipairs(m.forge_spans) do + local label_text, hl_group = forge.format_label(span.ref, span.cache) + vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, span.col_start, { + end_col = span.col_end, + conceal = '', + virt_text = { { label_text, hl_group } }, + virt_text_pos = 'inline', + priority = 90, + }) + end + end 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, { @@ -213,6 +226,7 @@ function M.reapply_dirty_inline(bufnr) local line = vim.api.nvim_buf_get_lines(bufnr, row - 1, row, false)[1] or '' local old_status = m.status m.status = infer_status(line) or m.status + m.forge_spans = nil log.debug( ('reapply_dirty: row=%d line=%q old_status=%s new_status=%s'):format( row, @@ -365,7 +379,7 @@ end ---@param winid integer local function set_win_options(winid) vim.wo[winid].conceallevel = 3 - vim.wo[winid].concealcursor = 'nvic' + vim.wo[winid].concealcursor = 'nc' vim.wo[winid].winfixheight = true end @@ -432,7 +446,7 @@ end ---@class pending.EolSegment ---@field type 'specifier'|'literal' ----@field key? 'c'|'r'|'d' +---@field key? 'c'|'r'|'d'|'l' ---@field text? string ---@param fmt string @@ -444,7 +458,7 @@ local function parse_eol_format(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 + if key == 'c' or key == 'r' or key == 'd' or key == 'l' then table.insert(segments, { type = 'specifier', key = key }) pos = pos + 2 else @@ -471,7 +485,10 @@ local function build_eol_virt(segments, m, icons) 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 + if seg.key == 'l' and m.forge_ref then + local forge = require('pending.forge') + text, hl = forge.format_label(m.forge_ref, m.forge_cache) + elseif 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 @@ -488,26 +505,20 @@ local function build_eol_virt(segments, m, icons) end local virt_parts = {} - for i, r in ipairs(resolved) do + local pending_sep = nil + for _, 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 }) + if #virt_parts > 0 and not pending_sep then + pending_sep = { r.text, r.hl } end elseif r.present then + if pending_sep then + table.insert(virt_parts, pending_sep) + pending_sep = nil + end table.insert(virt_parts, { r.text, r.hl }) + else + pending_sep = nil end end return virt_parts @@ -552,6 +563,8 @@ local function setup_highlights() 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 }) + vim.api.nvim_set_hl(0, 'PendingForge', { link = 'DiagnosticInfo', default = true }) + vim.api.nvim_set_hl(0, 'PendingForgeClosed', { link = 'Comment', default = true }) end ---@return string diff --git a/lua/pending/complete.lua b/lua/pending/complete.lua index 6ee3320..135d1a4 100644 --- a/lua/pending/complete.lua +++ b/lua/pending/complete.lua @@ -128,6 +128,9 @@ function M.omnifunc(findstart, base) { vim.pesc(dk) .. ':([%S]*)$', dk }, { 'cat:([%S]*)$', 'cat' }, { vim.pesc(rk) .. ':([%S]*)$', rk }, + { 'gh:([%S]*)$', 'gh' }, + { 'gl:([%S]*)$', 'gl' }, + { 'cb:([%S]*)$', 'cb' }, } for _, check in ipairs(checks) do @@ -169,6 +172,24 @@ function M.omnifunc(findstart, base) table.insert(matches, { word = c.word, menu = '[' .. source .. ']', info = c.info }) end end + elseif source == 'gh' or source == 'gl' or source == 'cb' then + local s = require('pending.buffer').store() + if s then + local seen = {} + for _, task in ipairs(s:tasks()) do + if task._extra and task._extra._forge_ref then + local ref = task._extra._forge_ref + local key = ref.owner .. '/' .. ref.repo + if not seen[key] then + seen[key] = true + local word = key .. '#' + if base == '' or word:sub(1, #base) == base then + table.insert(matches, { word = word, menu = '[' .. source .. ']' }) + end + end + end + end + end end return matches diff --git a/lua/pending/config.lua b/lua/pending/config.lua index 1d4c00a..842dfc0 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -33,6 +33,17 @@ ---@field profile? string ---@field region? string +---@class pending.ForgeInstanceConfig +---@field token? string +---@field icon? string +---@field issue_format? string +---@field instances? string[] + +---@class pending.ForgeConfig +---@field github? pending.ForgeInstanceConfig +---@field gitlab? pending.ForgeInstanceConfig +---@field codeberg? pending.ForgeInstanceConfig + ---@class pending.SyncConfig ---@field remote_delete? boolean ---@field gcal? pending.GcalConfig @@ -90,6 +101,7 @@ ---@field view pending.ViewConfig ---@field max_priority? integer ---@field sync? pending.SyncConfig +---@field forge? pending.ForgeConfig ---@field icons pending.Icons ---@class pending.config @@ -141,6 +153,23 @@ local defaults = { priority_down = '', }, sync = {}, + forge = { + github = { + icon = '', + issue_format = '%i %o/%r#%n', + instances = {}, + }, + gitlab = { + icon = '', + issue_format = '%i %o/%r#%n', + instances = {}, + }, + codeberg = { + icon = '', + issue_format = '%i %o/%r#%n', + instances = {}, + }, + }, icons = { pending = ' ', done = 'x', diff --git a/lua/pending/diff.lua b/lua/pending/diff.lua index 723dee1..29c292b 100644 --- a/lua/pending/diff.lua +++ b/lua/pending/diff.lua @@ -1,4 +1,5 @@ local config = require('pending.config') +local forge = require('pending.forge') local parse = require('pending.parse') ---@class pending.ParsedEntry @@ -11,6 +12,7 @@ local parse = require('pending.parse') ---@field due? string ---@field rec? string ---@field rec_mode? string +---@field forge_ref? pending.ForgeRef ---@field lnum integer ---@class pending.diff @@ -55,6 +57,8 @@ function M.parse_buffer(lines) end local description, metadata = parse.body(stripped) if description and description ~= '' then + local refs = forge.find_refs(description) + local forge_ref = refs[1] and refs[1].ref or nil table.insert(result, { type = 'task', id = id and tonumber(id) or nil, @@ -65,6 +69,7 @@ function M.parse_buffer(lines) due = metadata.due, rec = metadata.rec, rec_mode = metadata.rec_mode, + forge_ref = forge_ref, lnum = i, }) end @@ -113,6 +118,7 @@ function M.apply(lines, s, hidden_ids) recur = entry.rec, recur_mode = entry.rec_mode, order = order_counter, + _extra = entry.forge_ref and { _forge_ref = entry.forge_ref } or nil, }) else seen_ids[entry.id] = true @@ -147,6 +153,13 @@ function M.apply(lines, s, hidden_ids) changed = true end end + if entry.forge_ref ~= nil then + if not task._extra then + task._extra = {} + end + task._extra._forge_ref = entry.forge_ref + changed = true + end if entry.status and task.status ~= entry.status then task.status = entry.status if entry.status == 'done' then @@ -173,6 +186,7 @@ function M.apply(lines, s, hidden_ids) recur = entry.rec, recur_mode = entry.rec_mode, order = order_counter, + _extra = entry.forge_ref and { _forge_ref = entry.forge_ref } or nil, }) end diff --git a/lua/pending/forge.lua b/lua/pending/forge.lua new file mode 100644 index 0000000..d2092e4 --- /dev/null +++ b/lua/pending/forge.lua @@ -0,0 +1,435 @@ +local config = require('pending.config') +local log = require('pending.log') + +---@class pending.ForgeRef +---@field forge 'github'|'gitlab'|'codeberg' +---@field owner string +---@field repo string +---@field type 'issue'|'pull_request'|'merge_request' +---@field number integer +---@field url string + +---@class pending.ForgeCache +---@field title? string +---@field state 'open'|'closed'|'merged' +---@field labels? string[] +---@field fetched_at string + +---@class pending.forge +local M = {} + +---@type table +local FORGE_HOSTS = { + ['github.com'] = 'github', + ['gitlab.com'] = 'gitlab', + ['codeberg.org'] = 'codeberg', +} + +---@type table +local FORGE_API_BASE = { + github = 'https://api.github.com', + gitlab = 'https://gitlab.com', + codeberg = 'https://codeberg.org', +} + +---@type table +local SHORTHAND_PREFIX = { + gh = 'github', + gl = 'gitlab', + cb = 'codeberg', +} + +---@param token string +---@return pending.ForgeRef? +function M._parse_shorthand(token) + local prefix, rest = token:match('^(%l%l):(.+)$') + if not prefix then + return nil + end + local forge = SHORTHAND_PREFIX[prefix] + if not forge then + return nil + end + local owner, repo, number = rest:match('^([%w%.%-_]+)/([%w%.%-_]+)#(%d+)$') + if not owner then + return nil + end + local num = tonumber(number) --[[@as integer]] + local host = forge == 'github' and 'github.com' + or forge == 'gitlab' and 'gitlab.com' + or 'codeberg.org' + local url = 'https://' .. host .. '/' .. owner .. '/' .. repo .. '/issues/' .. num + return { + forge = forge, + owner = owner, + repo = repo, + type = 'issue', + number = num, + url = url, + } +end + +---@param url string +---@return pending.ForgeRef? +function M._parse_github_url(url) + local host, owner, repo, kind, number = + url:match('^https?://([^/]+)/([%w%.%-_]+)/([%w%.%-_]+)/(%a+)/(%d+)$') + if not host then + return nil + end + if kind ~= 'issues' and kind ~= 'pull' then + return nil + end + local forge_name = FORGE_HOSTS[host] + if not forge_name then + local cfg = config.get().forge or {} + local gh_cfg = cfg.github or {} + for _, inst in ipairs(gh_cfg.instances or {}) do + if host == inst then + forge_name = 'github' + break + end + end + end + if forge_name ~= 'github' then + return nil + end + local num = tonumber(number) --[[@as integer]] + local ref_type = kind == 'pull' and 'pull_request' or 'issue' + return { + forge = 'github', + owner = owner, + repo = repo, + type = ref_type, + number = num, + url = url, + } +end + +---@param url string +---@return pending.ForgeRef? +function M._parse_gitlab_url(url) + local host, path, kind, number = url:match('^https?://([^/]+)/(.+)/%-/([%w_]+)/(%d+)$') + if not host then + return nil + end + if kind ~= 'issues' and kind ~= 'merge_requests' then + return nil + end + local forge_name = FORGE_HOSTS[host] + if not forge_name then + local cfg = config.get().forge or {} + local gl_cfg = cfg.gitlab or {} + for _, inst in ipairs(gl_cfg.instances or {}) do + if host == inst then + forge_name = 'gitlab' + break + end + end + end + if forge_name ~= 'gitlab' then + return nil + end + local owner, repo = path:match('^(.+)/([^/]+)$') + if not owner then + return nil + end + local num = tonumber(number) --[[@as integer]] + local ref_type = kind == 'merge_requests' and 'merge_request' or 'issue' + return { + forge = 'gitlab', + owner = owner, + repo = repo, + type = ref_type, + number = num, + url = url, + } +end + +---@param url string +---@return pending.ForgeRef? +function M._parse_codeberg_url(url) + local host, owner, repo, kind, number = + url:match('^https?://([^/]+)/([%w%.%-_]+)/([%w%.%-_]+)/(%a+)/(%d+)$') + if not host then + return nil + end + if kind ~= 'issues' and kind ~= 'pulls' then + return nil + end + local forge_name = FORGE_HOSTS[host] + if not forge_name then + local cfg = config.get().forge or {} + local cb_cfg = cfg.codeberg or {} + for _, inst in ipairs(cb_cfg.instances or {}) do + if host == inst then + forge_name = 'codeberg' + break + end + end + end + if forge_name ~= 'codeberg' then + return nil + end + local num = tonumber(number) --[[@as integer]] + local ref_type = kind == 'pulls' and 'pull_request' or 'issue' + return { + forge = 'codeberg', + owner = owner, + repo = repo, + type = ref_type, + number = num, + url = url, + } +end + +---@param token string +---@return pending.ForgeRef? +function M.parse_ref(token) + local short = M._parse_shorthand(token) + if short then + return short + end + if not token:match('^https?://') then + return nil + end + return M._parse_github_url(token) or M._parse_gitlab_url(token) or M._parse_codeberg_url(token) +end + +---@class pending.ForgeSpan +---@field ref pending.ForgeRef +---@field start_byte integer +---@field end_byte integer +---@field raw string + +---@param text string +---@return pending.ForgeSpan[] +function M.find_refs(text) + local results = {} + local pos = 1 + while pos <= #text do + local ws = text:find('%S', pos) + if not ws then + break + end + local token_end = text:find('%s', ws) + local token = token_end and text:sub(ws, token_end - 1) or text:sub(ws) + local ref = M.parse_ref(token) + if ref then + local eb = token_end and (token_end - 1) or #text + table.insert(results, { + ref = ref, + start_byte = ws - 1, + end_byte = eb, + raw = token, + }) + end + pos = token_end and token_end or (#text + 1) + end + return results +end + +---@param ref pending.ForgeRef +---@return string +function M._api_url(ref) + if ref.forge == 'github' then + return FORGE_API_BASE.github + .. '/repos/' + .. ref.owner + .. '/' + .. ref.repo + .. '/issues/' + .. ref.number + elseif ref.forge == 'gitlab' then + local encoded = (ref.owner .. '/' .. ref.repo):gsub('/', '%%2F') + local endpoint = ref.type == 'merge_request' and 'merge_requests' or 'issues' + return FORGE_API_BASE.gitlab + .. '/api/v4/projects/' + .. encoded + .. '/' + .. endpoint + .. '/' + .. ref.number + else + local endpoint = ref.type == 'pull_request' and 'pulls' or 'issues' + return FORGE_API_BASE.codeberg + .. '/api/v1/repos/' + .. ref.owner + .. '/' + .. ref.repo + .. '/' + .. endpoint + .. '/' + .. ref.number + end +end + +---@param ref pending.ForgeRef +---@param cache? pending.ForgeCache +---@return string text +---@return string hl_group +function M.format_label(ref, cache) + local cfg = config.get().forge or {} + local forge_cfg = cfg[ref.forge] or {} + local fmt = forge_cfg.issue_format or '%i %o/%r#%n' + local icon = forge_cfg.icon or '' + local text = fmt + :gsub('%%i', icon) + :gsub('%%o', ref.owner) + :gsub('%%r', ref.repo) + :gsub('%%n', tostring(ref.number)) + local hl = 'PendingForge' + if cache then + if cache.state == 'closed' or cache.state == 'merged' then + hl = 'PendingForgeClosed' + end + end + return text, hl +end + +---@param forge string +---@return string? +function M.get_token(forge) + local cfg = config.get().forge or {} + local forge_cfg = cfg[forge] or {} + if forge_cfg.token then + return forge_cfg.token + end + if forge == 'github' then + local result = vim.fn.system({ 'gh', 'auth', 'token' }) + if vim.v.shell_error == 0 and result and result ~= '' then + return vim.trim(result) + end + elseif forge == 'gitlab' then + local result = vim.fn.system({ 'glab', 'auth', 'token' }) + if vim.v.shell_error == 0 and result and result ~= '' then + return vim.trim(result) + end + end + return nil +end + +---@param ref pending.ForgeRef +---@param callback fun(cache: pending.ForgeCache?) +function M.fetch_metadata(ref, callback) + local token = M.get_token(ref.forge) + local url = M._api_url(ref) + local args = { 'curl', '-s', '-L' } + if token then + table.insert(args, '-H') + if ref.forge == 'gitlab' then + table.insert(args, 'PRIVATE-TOKEN: ' .. token) + else + table.insert(args, 'Authorization: Bearer ' .. token) + end + end + table.insert(args, '-H') + table.insert(args, 'Accept: application/json') + table.insert(args, url) + + vim.system(args, { text = true }, function(result) + if result.code ~= 0 or not result.stdout or result.stdout == '' then + vim.schedule(function() + callback(nil) + end) + return + end + local ok, decoded = pcall(vim.json.decode, result.stdout) + if not ok or not decoded then + vim.schedule(function() + callback(nil) + end) + return + end + local state = 'open' + if ref.forge == 'github' then + if decoded.pull_request and decoded.pull_request.merged_at then + state = 'merged' + elseif decoded.state == 'closed' then + state = 'closed' + end + elseif ref.forge == 'gitlab' then + if decoded.state == 'merged' then + state = 'merged' + elseif decoded.state == 'closed' then + state = 'closed' + end + else + if decoded.state == 'closed' then + state = 'closed' + end + end + local labels = {} + if decoded.labels then + for _, label in ipairs(decoded.labels) do + if type(label) == 'string' then + table.insert(labels, label) + elseif type(label) == 'table' and label.name then + table.insert(labels, label.name) + end + end + end + local cache = { + title = decoded.title, + state = state, + labels = labels, + fetched_at = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]], + } + vim.schedule(function() + callback(cache) + end) + end) +end + +---@param s pending.Store +function M.refresh(s) + local tasks = s:tasks() + local pending_fetches = 0 + local any_changed = false + local any_fetched = false + for _, task in ipairs(tasks) do + if task.status ~= 'deleted' and task._extra and task._extra._forge_ref then + local ref = task._extra._forge_ref --[[@as pending.ForgeRef]] + pending_fetches = pending_fetches + 1 + M.fetch_metadata(ref, function(cache) + pending_fetches = pending_fetches - 1 + if cache then + task._extra._forge_cache = cache + any_fetched = true + if + (cache.state == 'closed' or cache.state == 'merged') + and (task.status == 'pending' or task.status == 'wip' or task.status == 'blocked') + then + task.status = 'done' + task['end'] = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]] + task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]] + any_changed = true + end + else + task._extra._forge_cache = { + state = 'open', + fetched_at = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]], + } + end + if pending_fetches == 0 then + if any_changed then + s:save() + end + local buffer = require('pending.buffer') + if + (any_changed or any_fetched) + and buffer.bufnr() + and vim.api.nvim_buf_is_valid(buffer.bufnr()) + then + buffer.render() + end + end + end) + end + end + if pending_fetches == 0 then + log.info('No linked tasks to refresh.') + end +end + +return M diff --git a/lua/pending/init.lua b/lua/pending/init.lua index d372f3a..6c5eaee 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -189,6 +189,8 @@ function M.open() local bufnr = buffer.open() M._setup_autocmds(bufnr) M._setup_buf_mappings(bufnr) + local forge = require('pending.forge') + forge.refresh(s) return bufnr end diff --git a/lua/pending/views.lua b/lua/pending/views.lua index 8d4bda5..fd76a49 100644 --- a/lua/pending/views.lua +++ b/lua/pending/views.lua @@ -1,6 +1,13 @@ local config = require('pending.config') +local forge = require('pending.forge') local parse = require('pending.parse') +---@class pending.ForgeLineMeta +---@field ref pending.ForgeRef +---@field cache? pending.ForgeCache +---@field col_start integer +---@field col_end integer + ---@class pending.LineMeta ---@field type 'task'|'header'|'blank'|'filter' ---@field id? integer @@ -12,6 +19,9 @@ local parse = require('pending.parse') ---@field show_category? boolean ---@field priority? integer ---@field recur? string +---@field forge_ref? pending.ForgeRef +---@field forge_cache? pending.ForgeCache +---@field forge_spans? pending.ForgeLineMeta[] ---@class pending.views local M = {} @@ -41,6 +51,27 @@ local function format_due(due) return formatted end +---@param task pending.Task +---@param prefix_len integer +---@return pending.ForgeLineMeta[]? +local function compute_forge_spans(task, prefix_len) + local refs = forge.find_refs(task.description) + if #refs == 0 then + return nil + end + local cache = task._extra and task._extra._forge_cache or nil + local spans = {} + for _, r in ipairs(refs) do + table.insert(spans, { + ref = r.ref, + cache = cache, + col_start = prefix_len + r.start_byte, + col_end = prefix_len + r.end_byte, + }) + end + return spans +end + ---@type table local status_rank = { wip = 0, pending = 1, blocked = 2, done = 3 } @@ -176,6 +207,7 @@ function M.category_view(tasks) local prefix = '/' .. task.id .. '/' local state = state_char(task) local line = prefix .. '- [' .. state .. '] ' .. task.description + local prefix_len = #prefix + #('- [' .. state .. '] ') table.insert(lines, line) table.insert(meta, { type = 'task', @@ -187,6 +219,9 @@ function M.category_view(tasks) priority = task.priority, overdue = task.status ~= 'done' and task.due ~= nil and parse.is_overdue(task.due) or nil, recur = task.recur, + forge_ref = task._extra and task._extra._forge_ref or nil, + forge_cache = task._extra and task._extra._forge_cache or nil, + forge_spans = compute_forge_spans(task, prefix_len), }) end end @@ -227,6 +262,7 @@ function M.priority_view(tasks) local prefix = '/' .. task.id .. '/' local state = task.status == 'done' and 'x' or (task.priority > 0 and '!' or ' ') local line = prefix .. '- [' .. state .. '] ' .. task.description + local prefix_len = #prefix + #('- [' .. state .. '] ') table.insert(lines, line) table.insert(meta, { type = 'task', @@ -239,6 +275,9 @@ function M.priority_view(tasks) overdue = task.status ~= 'done' and task.due ~= nil and parse.is_overdue(task.due) or nil, show_category = true, recur = task.recur, + forge_ref = task._extra and task._extra._forge_ref or nil, + forge_cache = task._extra and task._extra._forge_cache or nil, + forge_spans = compute_forge_spans(task, prefix_len), }) end diff --git a/spec/forge_spec.lua b/spec/forge_spec.lua new file mode 100644 index 0000000..3d17374 --- /dev/null +++ b/spec/forge_spec.lua @@ -0,0 +1,377 @@ +require('spec.helpers') + +local forge = require('pending.forge') + +describe('forge', function() + describe('_parse_shorthand', function() + it('parses gh: shorthand', function() + local ref = forge._parse_shorthand('gh:user/repo#42') + assert.is_not_nil(ref) + assert.equals('github', ref.forge) + assert.equals('user', ref.owner) + assert.equals('repo', ref.repo) + assert.equals('issue', ref.type) + assert.equals(42, ref.number) + assert.equals('https://github.com/user/repo/issues/42', ref.url) + end) + + it('parses gl: shorthand', function() + local ref = forge._parse_shorthand('gl:group/project#15') + assert.is_not_nil(ref) + assert.equals('gitlab', ref.forge) + assert.equals('group', ref.owner) + assert.equals('project', ref.repo) + assert.equals(15, ref.number) + end) + + it('parses cb: shorthand', function() + local ref = forge._parse_shorthand('cb:user/repo#3') + assert.is_not_nil(ref) + assert.equals('codeberg', ref.forge) + assert.equals('user', ref.owner) + assert.equals('repo', ref.repo) + assert.equals(3, ref.number) + end) + + it('handles hyphens and dots in owner/repo', function() + local ref = forge._parse_shorthand('gh:my-org/my.repo#100') + assert.is_not_nil(ref) + assert.equals('my-org', ref.owner) + assert.equals('my.repo', ref.repo) + end) + + it('rejects invalid prefix', function() + assert.is_nil(forge._parse_shorthand('xx:user/repo#1')) + end) + + it('rejects missing number', function() + assert.is_nil(forge._parse_shorthand('gh:user/repo')) + end) + + it('rejects missing repo', function() + assert.is_nil(forge._parse_shorthand('gh:user#1')) + end) + end) + + describe('_parse_github_url', function() + it('parses issue URL', function() + local ref = forge._parse_github_url('https://github.com/user/repo/issues/42') + assert.is_not_nil(ref) + assert.equals('github', ref.forge) + assert.equals('user', ref.owner) + assert.equals('repo', ref.repo) + assert.equals('issue', ref.type) + assert.equals(42, ref.number) + end) + + it('parses pull request URL', function() + local ref = forge._parse_github_url('https://github.com/user/repo/pull/10') + assert.is_not_nil(ref) + assert.equals('pull_request', ref.type) + end) + + it('rejects non-github URL', function() + assert.is_nil(forge._parse_github_url('https://example.com/user/repo/issues/1')) + end) + end) + + describe('_parse_gitlab_url', function() + it('parses issue URL', function() + local ref = forge._parse_gitlab_url('https://gitlab.com/group/project/-/issues/15') + assert.is_not_nil(ref) + assert.equals('gitlab', ref.forge) + assert.equals('group', ref.owner) + assert.equals('project', ref.repo) + assert.equals('issue', ref.type) + assert.equals(15, ref.number) + end) + + it('parses merge request URL', function() + local ref = forge._parse_gitlab_url('https://gitlab.com/group/project/-/merge_requests/5') + assert.is_not_nil(ref) + assert.equals('merge_request', ref.type) + end) + + it('handles nested groups', function() + local ref = forge._parse_gitlab_url('https://gitlab.com/org/sub/project/-/issues/1') + assert.is_not_nil(ref) + assert.equals('org/sub', ref.owner) + assert.equals('project', ref.repo) + end) + end) + + describe('_parse_codeberg_url', function() + it('parses issue URL', function() + local ref = forge._parse_codeberg_url('https://codeberg.org/user/repo/issues/3') + assert.is_not_nil(ref) + assert.equals('codeberg', ref.forge) + assert.equals('user', ref.owner) + assert.equals('repo', ref.repo) + assert.equals('issue', ref.type) + assert.equals(3, ref.number) + end) + + it('parses pull URL', function() + local ref = forge._parse_codeberg_url('https://codeberg.org/user/repo/pulls/7') + assert.is_not_nil(ref) + assert.equals('pull_request', ref.type) + end) + end) + + describe('parse_ref', function() + it('dispatches shorthand', function() + local ref = forge.parse_ref('gh:user/repo#1') + assert.is_not_nil(ref) + assert.equals('github', ref.forge) + end) + + it('dispatches GitHub URL', function() + local ref = forge.parse_ref('https://github.com/user/repo/issues/1') + assert.is_not_nil(ref) + assert.equals('github', ref.forge) + end) + + it('dispatches GitLab URL', function() + local ref = forge.parse_ref('https://gitlab.com/group/project/-/issues/1') + assert.is_not_nil(ref) + assert.equals('gitlab', ref.forge) + end) + + it('returns nil for non-forge token', function() + assert.is_nil(forge.parse_ref('hello')) + assert.is_nil(forge.parse_ref('due:tomorrow')) + end) + end) + + describe('find_refs', function() + it('finds a single shorthand ref', function() + local refs = forge.find_refs('Fix bug gh:user/repo#42') + assert.equals(1, #refs) + assert.equals('github', refs[1].ref.forge) + assert.equals(42, refs[1].ref.number) + assert.equals('gh:user/repo#42', refs[1].raw) + assert.equals(8, refs[1].start_byte) + assert.equals(23, refs[1].end_byte) + end) + + it('finds multiple refs', function() + local refs = forge.find_refs('Fix gh:a/b#1 gh:c/d#2') + assert.equals(2, #refs) + assert.equals('a', refs[1].ref.owner) + assert.equals('c', refs[2].ref.owner) + end) + + it('finds full URL refs', function() + local refs = forge.find_refs('Fix https://github.com/user/repo/issues/10') + assert.equals(1, #refs) + assert.equals('github', refs[1].ref.forge) + assert.equals(10, refs[1].ref.number) + end) + + it('returns empty for no refs', function() + local refs = forge.find_refs('Fix the bug') + assert.equals(0, #refs) + end) + + it('skips invalid forge-like tokens', function() + local refs = forge.find_refs('Fix the gh: prefix handling') + assert.equals(0, #refs) + end) + + it('records correct byte offsets', function() + local refs = forge.find_refs('gh:a/b#1') + assert.equals(1, #refs) + assert.equals(0, refs[1].start_byte) + assert.equals(8, refs[1].end_byte) + end) + end) + + describe('_api_url', function() + it('builds GitHub API URL', function() + local url = forge._api_url({ + forge = 'github', + owner = 'user', + repo = 'repo', + type = 'issue', + number = 42, + url = '', + }) + assert.equals('https://api.github.com/repos/user/repo/issues/42', url) + end) + + it('builds GitLab API URL for issue', function() + local url = forge._api_url({ + forge = 'gitlab', + owner = 'group', + repo = 'project', + type = 'issue', + number = 15, + url = '', + }) + assert.equals('https://gitlab.com/api/v4/projects/group%2Fproject/issues/15', url) + end) + + it('builds GitLab API URL for merge request', function() + local url = forge._api_url({ + forge = 'gitlab', + owner = 'group', + repo = 'project', + type = 'merge_request', + number = 5, + url = '', + }) + assert.equals('https://gitlab.com/api/v4/projects/group%2Fproject/merge_requests/5', url) + end) + + it('builds Codeberg API URL', function() + local url = forge._api_url({ + forge = 'codeberg', + owner = 'user', + repo = 'repo', + type = 'issue', + number = 3, + url = '', + }) + assert.equals('https://codeberg.org/api/v1/repos/user/repo/issues/3', url) + end) + end) + + describe('format_label', function() + it('formats with default format', function() + local text, hl = forge.format_label({ + forge = 'github', + owner = 'user', + repo = 'repo', + type = 'issue', + number = 42, + url = '', + }, nil) + assert.truthy(text:find('user/repo#42')) + assert.equals('PendingForge', hl) + end) + + it('uses closed highlight for closed state', function() + local _, hl = forge.format_label({ + forge = 'github', + owner = 'user', + repo = 'repo', + type = 'issue', + number = 42, + url = '', + }, { state = 'closed', fetched_at = '2026-01-01T00:00:00Z' }) + assert.equals('PendingForgeClosed', hl) + end) + + it('uses closed highlight for merged state', function() + local _, hl = forge.format_label({ + forge = 'gitlab', + owner = 'group', + repo = 'project', + type = 'merge_request', + number = 5, + url = '', + }, { state = 'merged', fetched_at = '2026-01-01T00:00:00Z' }) + assert.equals('PendingForgeClosed', hl) + end) + end) +end) + +describe('forge parse.body integration', function() + local parse = require('pending.parse') + + it('keeps gh: shorthand in description', function() + local desc, meta = parse.body('Fix login bug gh:user/repo#42') + assert.equals('Fix login bug gh:user/repo#42', desc) + assert.is_nil(meta.forge_ref) + end) + + it('keeps gl: shorthand in description', function() + local desc, meta = parse.body('Update docs gl:group/project#15') + assert.equals('Update docs gl:group/project#15', desc) + assert.is_nil(meta.forge_ref) + end) + + it('keeps GitHub URL in description', function() + local desc, meta = parse.body('Fix bug https://github.com/user/repo/issues/10') + assert.equals('Fix bug https://github.com/user/repo/issues/10', desc) + assert.is_nil(meta.forge_ref) + end) + + it('extracts due date but keeps forge ref in description', function() + local desc, meta = parse.body('Fix bug gh:user/repo#42 due:tomorrow') + assert.equals('Fix bug gh:user/repo#42', desc) + assert.is_not_nil(meta.due) + end) + + it('extracts category but keeps forge ref in description', function() + local desc, meta = parse.body('Fix bug gh:user/repo#42 cat:Work') + assert.equals('Fix bug gh:user/repo#42', desc) + assert.equals('Work', meta.cat) + end) + + it('leaves non-forge tokens as description', function() + local desc, meta = parse.body('Fix the gh: prefix handling') + assert.equals('Fix the gh: prefix handling', desc) + assert.is_nil(meta.forge_ref) + end) +end) + +describe('forge diff integration', function() + local store = require('pending.store') + local diff = require('pending.diff') + + it('stores forge_ref in _extra on new task', function() + local tmp = os.tmpname() + local s = store.new(tmp) + s:load() + diff.apply({ '- [ ] Fix bug gh:user/repo#42' }, s) + local tasks = s:active_tasks() + assert.equals(1, #tasks) + assert.equals('Fix bug gh:user/repo#42', tasks[1].description) + assert.is_not_nil(tasks[1]._extra) + assert.is_not_nil(tasks[1]._extra._forge_ref) + assert.equals('github', tasks[1]._extra._forge_ref.forge) + assert.equals(42, tasks[1]._extra._forge_ref.number) + os.remove(tmp) + end) + + it('stores forge_ref in _extra on existing task', function() + local tmp = os.tmpname() + local s = store.new(tmp) + s:load() + local task = s:add({ description = 'Fix bug' }) + s:save() + diff.apply({ '/' .. task.id .. '/- [ ] Fix bug gh:user/repo#10' }, s) + local updated = s:get(task.id) + assert.equals('Fix bug gh:user/repo#10', updated.description) + assert.is_not_nil(updated._extra) + assert.is_not_nil(updated._extra._forge_ref) + assert.equals(10, updated._extra._forge_ref.number) + os.remove(tmp) + end) + + it('preserves existing forge_ref when not in parsed line', function() + local tmp = os.tmpname() + local s = store.new(tmp) + s:load() + local task = s:add({ + description = 'Fix bug', + _extra = { + _forge_ref = { + forge = 'github', + owner = 'a', + repo = 'b', + type = 'issue', + number = 1, + url = '', + }, + }, + }) + s:save() + diff.apply({ '/' .. task.id .. '/- [ ] Fix bug' }, s) + local updated = s:get(task.id) + assert.is_not_nil(updated._extra._forge_ref) + assert.equals(1, updated._extra._forge_ref.number) + os.remove(tmp) + end) +end) From b60e58336e6739dffef0cc226323e5e775316ab9 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Tue, 10 Mar 2026 20:01:10 -0400 Subject: [PATCH 172/199] feat(forge): inline overlay rendering for forge links (#127) * docs: document S3 backend, auto-auth, and `:Pending done` command Problem: The S3 backend had no `:Pending s3` entry in the COMMANDS section, `:Pending auth` only mentioned Google, the `sync` config field omitted `s3`, `_s3_sync_id` was missing from the data format section, `:Pending done` was implemented but undocumented, and the README lacked a features overview. Solution: Add `:Pending s3` and `:Pending done` command docs, rewrite `:Pending auth` to cover all backends and sub-actions, update config and data format references, add `aws` CLI to requirements, and add a Features section to `README.md`. * feat(forge): add forge link parser and metadata fetcher Problem: no way to associate tasks with GitHub, GitLab, or Codeberg issues/PRs, or to track their remote state. Solution: add `forge.lua` with shorthand (`gh:user/repo#42`) and full URL parsing, async metadata fetching via `curl`, label formatting, conceal pattern generation, token resolution, and `refresh()` for state pull (closed/merged -> done). * feat(config): add forge config defaults and `%l` eol specifier Problem: no configuration surface for forge link rendering, icons, issue format, or self-hosted instances. Solution: add `pending.ForgeConfig` class with per-forge `token`, `icon`, `issue_format`, and `instances` fields. Add `%l` to the default `eol_format` so forge labels render in virtual text. * feat(parse): extract forge refs from task body Problem: `parse.body()` had no awareness of forge link tokens, so `gh:user/repo#42` stayed in the description instead of metadata. Solution: add `forge_ref` field to `pending.Metadata` and extend the right-to-left token loop in `body()` to call `forge.parse_ref()` as the final fallback before breaking. * feat(diff): persist forge refs in store on write Problem: forge refs parsed from buffer lines were discarded during diff reconciliation and never stored in the JSON. Solution: thread `forge_ref` through `parse_buffer` entries into `diff.apply`, storing it in `task._extra._forge_ref` for both new and existing tasks. * feat(views): pass forge ref and cache to line metadata Problem: `LineMeta` had no forge fields, so `buffer.lua` could not render forge labels or apply forge-specific highlights. Solution: add `forge_ref` and `forge_cache` fields to `LineMeta`, populated from `task._extra` in both `category_view` and `priority_view`. * feat(buffer): render forge links as concealed text with eol virt text Problem: forge tokens were visible as raw text with no virtual text labels, and the eol separator logic collapsed all gaps when non-adjacent specifiers were absent. Solution: add forge conceal syntax patterns in `setup_syntax()`, add `PendingForge`/`PendingForgeClosed` highlight groups, handle the `%l` specifier in `build_eol_virt()`, fix separator collapsing to buffer one separator between present segments, and change `concealcursor` to `nc` (reveal in visual and insert mode). * feat(complete): add forge shorthand omnifunc completions Problem: no completion support for `gh:`, `gl:`, or `cb:` tokens, requiring users to type owner/repo from memory. Solution: extend `omnifunc` to detect `gh:`/`gl:`/`cb:` prefixes and complete with `owner/repo#` candidates from existing forge refs in the store. * feat: trigger forge refresh on buffer open Problem: forge metadata was never fetched, so virt text highlights could not reflect remote issue/PR state. Solution: call `forge.refresh()` in `M.open()` so metadata is fetched once per `:Pending` invocation rather than on every render. * test(forge): add forge parsing spec Problem: no test coverage for forge link shorthand parsing, URL parsing, label formatting, or API URL generation. Solution: add `spec/forge_spec.lua` covering `_parse_shorthand`, `parse_ref` for all three forges, full URL parsing including nested GitLab groups, `format_label`, and `_api_url`. * docs: document forge links feature Problem: no user-facing documentation for forge link syntax, configuration, or behavior. Solution: add forge links section to `README.md` and `pending.txt` covering shorthand/URL syntax, config options, virtual text rendering, state pull, and auth resolution. * feat(forge): add `find_refs()` inline token scanner Problem: forge tokens were extracted by `parse.body()` which stripped them from the description, making editing awkward and multi-ref lines impossible. Solution: add `find_refs(text)` that scans a string for all forge tokens by whitespace tokenization, returning byte offsets and parsed refs without modifying the input. Remove unused `conceal_patterns()`. * refactor: move forge ref detection from `parse.body()` to `diff` Problem: `parse.body()` stripped forge tokens from the description, losing the raw text. This made inline overlay rendering impossible since the token no longer existed in the buffer. Solution: remove the `forge.parse_ref()` branch from `parse.body()` and call `forge.find_refs()` in `diff.parse_buffer()` instead. The description now retains forge tokens verbatim; `_extra._forge_ref` is still populated from the first matched ref. * feat(buffer): render forge links as inline conceal overlays Problem: forge tokens were stripped from the buffer and shown as EOL virtual text via `%l`. The token disappeared from the editable line, and multi-ref tasks broke. Solution: compute `forge_spans` in `views.lua` with byte offsets for each forge token in the rendered line. In `apply_inline_row()`, place extmarks with `conceal=''` and `virt_text_pos='inline'` to visually replace each raw token with its formatted label. Clear stale `forge_spans` on dirty rows to prevent `end_col` out-of-range errors after edits like `dd`. * fix(config): remove `%l` from default `eol_format` Problem: forge links are now rendered inline, making the `%l` EOL specifier redundant in the default format. Solution: change default `eol_format` from `'%l %c %r %d'` to `'%c %r %d'`. The `%l` specifier remains functional for users who explicitly set it. * test(forge): update specs for inline forge refs Problem: existing tests asserted that `parse.body()` stripped forge tokens from the description and populated `meta.forge_ref`. The `conceal_patterns` test referenced a removed function. Solution: update `parse.body` integration tests to assert tokens stay in the description. Add `find_refs()` tests covering single/multiple refs, URLs, byte offsets, and empty cases. Remove `conceal_patterns` test. Update diff tests to assert description includes the token. * docs: update forge links for inline overlay rendering Problem: documentation described forge tokens as stripped from the description and rendered via EOL `%l` specifier by default. Solution: update forge links section to describe inline conceal overlay rendering. Update default `eol_format` reference. Change `issue_format` field description from "EOL label" to "inline overlay label". * ci: format * refactor(forge): remove `%l` eol specifier, add `auto_close` config, fix icons Problem: `%l` was dead code after inline overlays replaced EOL rendering. Auto-close was always on with no opt-out. Forge icon defaults were empty strings. Solution: remove `%l` from the eol format parser and renderer. Add `forge.auto_close` (default `false`) to gate state-pull. Set nerd font icons: `` (GitHub), `` (GitLab), `` (Codeberg). Keep conceal active in insert mode via `concealcursor = 'nic'`. * fix(config): set correct nerd font icons for forge defaults --- doc/pending.txt | 18 ++++++++++-------- lua/pending/buffer.lua | 11 ++++------- lua/pending/config.lua | 8 +++++--- lua/pending/forge.lua | 4 +++- lua/pending/views.lua | 4 ---- 5 files changed, 22 insertions(+), 23 deletions(-) diff --git a/doc/pending.txt b/doc/pending.txt index 9ec9b34..58ad4e0 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -842,7 +842,6 @@ Fields: ~ {eol_format} (string, default: '%c %r %d') Format string for end-of-line virtual text. Specifiers: - `%l` forge link label (`PendingForge`) `%c` category icon + name (`PendingHeader`) `%r` recurrence icon + pattern (`PendingRecur`) `%d` due icon + date (`PendingDue`/`PendingOverdue`) @@ -1486,10 +1485,6 @@ inline with a formatted label using overlay extmarks (same technique as checkbox icons). Multiple forge references in one line are each overlaid independently. -The `%l` specifier in `eol_format` is still supported for users who prefer -the link label in EOL virtual text, but it is no longer in the default -format (`'%c %r %d'`). - Format string: ~ *pending-forge-format* Each forge has a configurable `issue_format` string with these placeholders: @@ -1505,6 +1500,7 @@ Configuration: ~ >lua vim.g.pending = { forge = { + auto_close = false, github = { token = nil, icon = '', @@ -1527,6 +1523,11 @@ Configuration: ~ } < +Top-level fields: ~ + {auto_close} (boolean, default: false) When true, tasks linked to + closed/merged remote issues are automatically marked + done on buffer open. + Fields (per forge): ~ {token} (string, optional) API token for authenticated requests. Falls back to CLI: `gh auth token` (GitHub), `glab auth @@ -1551,9 +1552,10 @@ than 5 minutes are re-fetched asynchronously. The buffer renders immediately with cached data and updates extmarks when the fetch completes. State pull: ~ -After fetching, if the remote issue/PR is closed or merged and the local -task is pending/wip/blocked, the task is automatically marked as done. This -is one-way: local status changes do not push back to the forge. +Requires `forge.auto_close = true`. After fetching, if the remote issue/PR +is closed or merged and the local task is pending/wip/blocked, the task is +automatically marked as done. Disabled by default. One-way: local status +changes do not push back to the forge. Highlight groups: ~ |PendingForge| Open issue/PR link label diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index e162f67..8c0433e 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -379,7 +379,7 @@ end ---@param winid integer local function set_win_options(winid) vim.wo[winid].conceallevel = 3 - vim.wo[winid].concealcursor = 'nc' + vim.wo[winid].concealcursor = 'nic' vim.wo[winid].winfixheight = true end @@ -446,7 +446,7 @@ end ---@class pending.EolSegment ---@field type 'specifier'|'literal' ----@field key? 'c'|'r'|'d'|'l' +---@field key? 'c'|'r'|'d' ---@field text? string ---@param fmt string @@ -458,7 +458,7 @@ local function parse_eol_format(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' or key == 'l' then + if key == 'c' or key == 'r' or key == 'd' then table.insert(segments, { type = 'specifier', key = key }) pos = pos + 2 else @@ -485,10 +485,7 @@ local function build_eol_virt(segments, m, icons) for i, seg in ipairs(segments) do if seg.type == 'specifier' then local text, hl - if seg.key == 'l' and m.forge_ref then - local forge = require('pending.forge') - text, hl = forge.format_label(m.forge_ref, m.forge_cache) - elseif seg.key == 'c' and m.show_category and m.category then + 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 diff --git a/lua/pending/config.lua b/lua/pending/config.lua index 842dfc0..4c35348 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -40,6 +40,7 @@ ---@field instances? string[] ---@class pending.ForgeConfig +---@field auto_close? boolean ---@field github? pending.ForgeInstanceConfig ---@field gitlab? pending.ForgeInstanceConfig ---@field codeberg? pending.ForgeInstanceConfig @@ -154,18 +155,19 @@ local defaults = { }, sync = {}, forge = { + auto_close = false, github = { - icon = '', + icon = '', issue_format = '%i %o/%r#%n', instances = {}, }, gitlab = { - icon = '', + icon = '', issue_format = '%i %o/%r#%n', instances = {}, }, codeberg = { - icon = '', + icon = '', issue_format = '%i %o/%r#%n', instances = {}, }, diff --git a/lua/pending/forge.lua b/lua/pending/forge.lua index d2092e4..c7724f8 100644 --- a/lua/pending/forge.lua +++ b/lua/pending/forge.lua @@ -396,8 +396,10 @@ function M.refresh(s) if cache then task._extra._forge_cache = cache any_fetched = true + local forge_cfg = config.get().forge or {} if - (cache.state == 'closed' or cache.state == 'merged') + forge_cfg.auto_close + and (cache.state == 'closed' or cache.state == 'merged') and (task.status == 'pending' or task.status == 'wip' or task.status == 'blocked') then task.status = 'done' diff --git a/lua/pending/views.lua b/lua/pending/views.lua index fd76a49..b1e691e 100644 --- a/lua/pending/views.lua +++ b/lua/pending/views.lua @@ -19,8 +19,6 @@ local parse = require('pending.parse') ---@field show_category? boolean ---@field priority? integer ---@field recur? string ----@field forge_ref? pending.ForgeRef ----@field forge_cache? pending.ForgeCache ---@field forge_spans? pending.ForgeLineMeta[] ---@class pending.views @@ -219,8 +217,6 @@ function M.category_view(tasks) priority = task.priority, overdue = task.status ~= 'done' and task.due ~= nil and parse.is_overdue(task.due) or nil, recur = task.recur, - forge_ref = task._extra and task._extra._forge_ref or nil, - forge_cache = task._extra and task._extra._forge_cache or nil, forge_spans = compute_forge_spans(task, prefix_len), }) end From 5f50f45b55d3ed29f98f5894ac2646adf56d4cda Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Tue, 10 Mar 2026 22:16:25 -0400 Subject: [PATCH 173/199] refactor(forge): extract ForgeBackend class and registry (#129) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: document S3 backend, auto-auth, and `:Pending done` command Problem: The S3 backend had no `:Pending s3` entry in the COMMANDS section, `:Pending auth` only mentioned Google, the `sync` config field omitted `s3`, `_s3_sync_id` was missing from the data format section, `:Pending done` was implemented but undocumented, and the README lacked a features overview. Solution: Add `:Pending s3` and `:Pending done` command docs, rewrite `:Pending auth` to cover all backends and sub-actions, update config and data format references, add `aws` CLI to requirements, and add a Features section to `README.md`. * feat(forge): add forge link parser and metadata fetcher Problem: no way to associate tasks with GitHub, GitLab, or Codeberg issues/PRs, or to track their remote state. Solution: add `forge.lua` with shorthand (`gh:user/repo#42`) and full URL parsing, async metadata fetching via `curl`, label formatting, conceal pattern generation, token resolution, and `refresh()` for state pull (closed/merged -> done). * feat(config): add forge config defaults and `%l` eol specifier Problem: no configuration surface for forge link rendering, icons, issue format, or self-hosted instances. Solution: add `pending.ForgeConfig` class with per-forge `token`, `icon`, `issue_format`, and `instances` fields. Add `%l` to the default `eol_format` so forge labels render in virtual text. * feat(parse): extract forge refs from task body Problem: `parse.body()` had no awareness of forge link tokens, so `gh:user/repo#42` stayed in the description instead of metadata. Solution: add `forge_ref` field to `pending.Metadata` and extend the right-to-left token loop in `body()` to call `forge.parse_ref()` as the final fallback before breaking. * feat(diff): persist forge refs in store on write Problem: forge refs parsed from buffer lines were discarded during diff reconciliation and never stored in the JSON. Solution: thread `forge_ref` through `parse_buffer` entries into `diff.apply`, storing it in `task._extra._forge_ref` for both new and existing tasks. * feat(views): pass forge ref and cache to line metadata Problem: `LineMeta` had no forge fields, so `buffer.lua` could not render forge labels or apply forge-specific highlights. Solution: add `forge_ref` and `forge_cache` fields to `LineMeta`, populated from `task._extra` in both `category_view` and `priority_view`. * feat(buffer): render forge links as concealed text with eol virt text Problem: forge tokens were visible as raw text with no virtual text labels, and the eol separator logic collapsed all gaps when non-adjacent specifiers were absent. Solution: add forge conceal syntax patterns in `setup_syntax()`, add `PendingForge`/`PendingForgeClosed` highlight groups, handle the `%l` specifier in `build_eol_virt()`, fix separator collapsing to buffer one separator between present segments, and change `concealcursor` to `nc` (reveal in visual and insert mode). * feat(complete): add forge shorthand omnifunc completions Problem: no completion support for `gh:`, `gl:`, or `cb:` tokens, requiring users to type owner/repo from memory. Solution: extend `omnifunc` to detect `gh:`/`gl:`/`cb:` prefixes and complete with `owner/repo#` candidates from existing forge refs in the store. * feat: trigger forge refresh on buffer open Problem: forge metadata was never fetched, so virt text highlights could not reflect remote issue/PR state. Solution: call `forge.refresh()` in `M.open()` so metadata is fetched once per `:Pending` invocation rather than on every render. * test(forge): add forge parsing spec Problem: no test coverage for forge link shorthand parsing, URL parsing, label formatting, or API URL generation. Solution: add `spec/forge_spec.lua` covering `_parse_shorthand`, `parse_ref` for all three forges, full URL parsing including nested GitLab groups, `format_label`, and `_api_url`. * docs: document forge links feature Problem: no user-facing documentation for forge link syntax, configuration, or behavior. Solution: add forge links section to `README.md` and `pending.txt` covering shorthand/URL syntax, config options, virtual text rendering, state pull, and auth resolution. * feat(forge): add `find_refs()` inline token scanner Problem: forge tokens were extracted by `parse.body()` which stripped them from the description, making editing awkward and multi-ref lines impossible. Solution: add `find_refs(text)` that scans a string for all forge tokens by whitespace tokenization, returning byte offsets and parsed refs without modifying the input. Remove unused `conceal_patterns()`. * refactor: move forge ref detection from `parse.body()` to `diff` Problem: `parse.body()` stripped forge tokens from the description, losing the raw text. This made inline overlay rendering impossible since the token no longer existed in the buffer. Solution: remove the `forge.parse_ref()` branch from `parse.body()` and call `forge.find_refs()` in `diff.parse_buffer()` instead. The description now retains forge tokens verbatim; `_extra._forge_ref` is still populated from the first matched ref. * feat(buffer): render forge links as inline conceal overlays Problem: forge tokens were stripped from the buffer and shown as EOL virtual text via `%l`. The token disappeared from the editable line, and multi-ref tasks broke. Solution: compute `forge_spans` in `views.lua` with byte offsets for each forge token in the rendered line. In `apply_inline_row()`, place extmarks with `conceal=''` and `virt_text_pos='inline'` to visually replace each raw token with its formatted label. Clear stale `forge_spans` on dirty rows to prevent `end_col` out-of-range errors after edits like `dd`. * fix(config): remove `%l` from default `eol_format` Problem: forge links are now rendered inline, making the `%l` EOL specifier redundant in the default format. Solution: change default `eol_format` from `'%l %c %r %d'` to `'%c %r %d'`. The `%l` specifier remains functional for users who explicitly set it. * test(forge): update specs for inline forge refs Problem: existing tests asserted that `parse.body()` stripped forge tokens from the description and populated `meta.forge_ref`. The `conceal_patterns` test referenced a removed function. Solution: update `parse.body` integration tests to assert tokens stay in the description. Add `find_refs()` tests covering single/multiple refs, URLs, byte offsets, and empty cases. Remove `conceal_patterns` test. Update diff tests to assert description includes the token. * docs: update forge links for inline overlay rendering Problem: documentation described forge tokens as stripped from the description and rendered via EOL `%l` specifier by default. Solution: update forge links section to describe inline conceal overlay rendering. Update default `eol_format` reference. Change `issue_format` field description from "EOL label" to "inline overlay label". * ci: format * refactor(forge): remove `%l` eol specifier, add `auto_close` config, fix icons Problem: `%l` was dead code after inline overlays replaced EOL rendering. Auto-close was always on with no opt-out. Forge icon defaults were empty strings. Solution: remove `%l` from the eol format parser and renderer. Add `forge.auto_close` (default `false`) to gate state-pull. Set nerd font icons: `` (GitHub), `` (GitLab), `` (Codeberg). Keep conceal active in insert mode via `concealcursor = 'nic'`. * fix(config): set correct nerd font icons for forge defaults * refactor(forge): replace curl/token auth with CLI-native API calls Problem: Forge metadata fetching required manual token management — config fields, CLI token extraction, and curl with auth headers. Each forge had a different auth path, and Codeberg had no CLI support at all. Solution: Delete `get_token()` and `_api_url()`, replace with `_api_args()` that builds `gh api`, `glab api`, or `tea api` arg arrays. The CLIs handle auth internally. Add `warn_missing_cli` config (default true) that warns once per forge per session on failure. Add forge CLI checks to `:checkhealth`. Remove `token` from config/docs. * refactor(forge): extract ForgeBackend class and registry Problem: adding a new forge required touching 5 lookup tables (`FORGE_HOSTS`, `FORGE_CLI`, `FORGE_AUTH_CMD`, `SHORTHAND_PREFIX`, `_warned_forges`) and every branching site in `_api_args`, `fetch_metadata`, and `parse_ref`. Solution: introduce a `ForgeBackend` class with `parse_url`, `api_args`, and `parse_state` methods, plus a `register()` / `backends()` registry. New forges (Gitea, Forgejo) are a single `register()` call via the `gitea_backend()` convenience constructor. * ci: format --- doc/pending.txt | 28 ++- lua/pending/config.lua | 7 +- lua/pending/forge.lua | 515 ++++++++++++++++++++++++----------------- lua/pending/health.lua | 10 + spec/forge_spec.lua | 101 ++++++-- 5 files changed, 422 insertions(+), 239 deletions(-) diff --git a/doc/pending.txt b/doc/pending.txt index 58ad4e0..9195c76 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -1501,20 +1501,18 @@ Configuration: ~ vim.g.pending = { forge = { auto_close = false, + warn_missing_cli = true, github = { - token = nil, icon = '', issue_format = '%i %o/%r#%n', instances = {}, }, gitlab = { - token = nil, icon = '', issue_format = '%i %o/%r#%n', instances = {}, }, codeberg = { - token = nil, icon = '', issue_format = '%i %o/%r#%n', instances = {}, @@ -1524,27 +1522,27 @@ Configuration: ~ < Top-level fields: ~ - {auto_close} (boolean, default: false) When true, tasks linked to - closed/merged remote issues are automatically marked - done on buffer open. + {auto_close} (boolean, default: false) When true, tasks linked to + closed/merged remote issues are automatically marked + done on buffer open. + {warn_missing_cli} (boolean, default: true) When true, warns once per + forge per session if the CLI is missing or fails. Fields (per forge): ~ - {token} (string, optional) API token for authenticated requests. - Falls back to CLI: `gh auth token` (GitHub), `glab auth - token` (GitLab). Codeberg uses token only. {icon} (string) Nerd font icon used in virtual text. {issue_format} (string) Format string for the inline overlay label. {instances} (string[]) Additional hostnames for self-hosted instances (e.g. `{ 'github.company.com' }`). Authentication: ~ -Token retrieval is CLI-preferred, config fallback: -1. GitHub: `gh auth token` stdout. Falls back to `forge.github.token`. -2. GitLab: `glab auth token` stdout. Falls back to `forge.gitlab.token`. -3. Codeberg: `forge.codeberg.token` only (no standard CLI). +Forge metadata fetching uses each forge's native CLI. No tokens are +configured in pending.nvim — authenticate once in your shell: +1. GitHub: `gh auth login` +2. GitLab: `glab auth login` +3. Codeberg: `tea login add` -Unauthenticated requests work for public repositories. Private repositories -require a token. +Public repositories work without authentication. Private repositories +require a logged-in CLI session. Metadata fetching: ~ On buffer open, tasks with a `_forge_ref` whose cached metadata is older diff --git a/lua/pending/config.lua b/lua/pending/config.lua index 4c35348..b1ab639 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -34,16 +34,14 @@ ---@field region? string ---@class pending.ForgeInstanceConfig ----@field token? string ---@field icon? string ---@field issue_format? string ---@field instances? string[] ---@class pending.ForgeConfig ---@field auto_close? boolean ----@field github? pending.ForgeInstanceConfig ----@field gitlab? pending.ForgeInstanceConfig ----@field codeberg? pending.ForgeInstanceConfig +---@field warn_missing_cli? boolean +---@field [string] pending.ForgeInstanceConfig ---@class pending.SyncConfig ---@field remote_delete? boolean @@ -156,6 +154,7 @@ local defaults = { sync = {}, forge = { auto_close = false, + warn_missing_cli = true, github = { icon = '', issue_format = '%i %o/%r#%n', diff --git a/lua/pending/forge.lua b/lua/pending/forge.lua index c7724f8..9b32655 100644 --- a/lua/pending/forge.lua +++ b/lua/pending/forge.lua @@ -2,7 +2,7 @@ local config = require('pending.config') local log = require('pending.log') ---@class pending.ForgeRef ----@field forge 'github'|'gitlab'|'codeberg' +---@field forge string ---@field owner string ---@field repo string ---@field type 'issue'|'pull_request'|'merge_request' @@ -15,29 +15,66 @@ local log = require('pending.log') ---@field labels? string[] ---@field fetched_at string +---@class pending.ForgeBackend +---@field name string +---@field shorthand string +---@field default_host string +---@field cli string +---@field auth_cmd string +---@field default_icon string +---@field default_issue_format string +---@field _warned boolean +---@field parse_url fun(self: pending.ForgeBackend, url: string): pending.ForgeRef? +---@field api_args fun(self: pending.ForgeBackend, ref: pending.ForgeRef): string[] +---@field parse_state fun(self: pending.ForgeBackend, decoded: table): 'open'|'closed'|'merged' + ---@class pending.forge local M = {} ----@type table -local FORGE_HOSTS = { - ['github.com'] = 'github', - ['gitlab.com'] = 'gitlab', - ['codeberg.org'] = 'codeberg', -} +---@type pending.ForgeBackend[] +local _backends = {} ----@type table -local FORGE_API_BASE = { - github = 'https://api.github.com', - gitlab = 'https://gitlab.com', - codeberg = 'https://codeberg.org', -} +---@type table +local _by_name = {} ----@type table -local SHORTHAND_PREFIX = { - gh = 'github', - gl = 'gitlab', - cb = 'codeberg', -} +---@type table +local _by_shorthand = {} + +---@type table +local _by_host = {} + +---@type boolean +local _instances_resolved = false + +---@param backend pending.ForgeBackend +---@return nil +function M.register(backend) + backend._warned = false + table.insert(_backends, backend) + _by_name[backend.name] = backend + _by_shorthand[backend.shorthand] = backend + _by_host[backend.default_host] = backend + _instances_resolved = false +end + +---@return pending.ForgeBackend[] +function M.backends() + return _backends +end + +local function _ensure_instances() + if _instances_resolved then + return + end + _instances_resolved = true + local cfg = config.get().forge or {} + for _, backend in ipairs(_backends) do + local forge_cfg = cfg[backend.name] or {} + for _, inst in ipairs(forge_cfg.instances or {}) do + _by_host[inst] = backend + end + end +end ---@param token string ---@return pending.ForgeRef? @@ -46,8 +83,8 @@ function M._parse_shorthand(token) if not prefix then return nil end - local forge = SHORTHAND_PREFIX[prefix] - if not forge then + local backend = _by_shorthand[prefix] + if not backend then return nil end local owner, repo, number = rest:match('^([%w%.%-_]+)/([%w%.%-_]+)#(%d+)$') @@ -55,12 +92,9 @@ function M._parse_shorthand(token) return nil end local num = tonumber(number) --[[@as integer]] - local host = forge == 'github' and 'github.com' - or forge == 'gitlab' and 'gitlab.com' - or 'codeberg.org' - local url = 'https://' .. host .. '/' .. owner .. '/' .. repo .. '/issues/' .. num + local url = 'https://' .. backend.default_host .. '/' .. owner .. '/' .. repo .. '/issues/' .. num return { - forge = forge, + forge = backend.name, owner = owner, repo = repo, type = 'issue', @@ -72,115 +106,31 @@ end ---@param url string ---@return pending.ForgeRef? function M._parse_github_url(url) - local host, owner, repo, kind, number = - url:match('^https?://([^/]+)/([%w%.%-_]+)/([%w%.%-_]+)/(%a+)/(%d+)$') - if not host then + local backend = _by_name['github'] + if not backend then return nil end - if kind ~= 'issues' and kind ~= 'pull' then - return nil - end - local forge_name = FORGE_HOSTS[host] - if not forge_name then - local cfg = config.get().forge or {} - local gh_cfg = cfg.github or {} - for _, inst in ipairs(gh_cfg.instances or {}) do - if host == inst then - forge_name = 'github' - break - end - end - end - if forge_name ~= 'github' then - return nil - end - local num = tonumber(number) --[[@as integer]] - local ref_type = kind == 'pull' and 'pull_request' or 'issue' - return { - forge = 'github', - owner = owner, - repo = repo, - type = ref_type, - number = num, - url = url, - } + return backend:parse_url(url) end ---@param url string ---@return pending.ForgeRef? function M._parse_gitlab_url(url) - local host, path, kind, number = url:match('^https?://([^/]+)/(.+)/%-/([%w_]+)/(%d+)$') - if not host then + local backend = _by_name['gitlab'] + if not backend then return nil end - if kind ~= 'issues' and kind ~= 'merge_requests' then - return nil - end - local forge_name = FORGE_HOSTS[host] - if not forge_name then - local cfg = config.get().forge or {} - local gl_cfg = cfg.gitlab or {} - for _, inst in ipairs(gl_cfg.instances or {}) do - if host == inst then - forge_name = 'gitlab' - break - end - end - end - if forge_name ~= 'gitlab' then - return nil - end - local owner, repo = path:match('^(.+)/([^/]+)$') - if not owner then - return nil - end - local num = tonumber(number) --[[@as integer]] - local ref_type = kind == 'merge_requests' and 'merge_request' or 'issue' - return { - forge = 'gitlab', - owner = owner, - repo = repo, - type = ref_type, - number = num, - url = url, - } + return backend:parse_url(url) end ---@param url string ---@return pending.ForgeRef? function M._parse_codeberg_url(url) - local host, owner, repo, kind, number = - url:match('^https?://([^/]+)/([%w%.%-_]+)/([%w%.%-_]+)/(%a+)/(%d+)$') - if not host then + local backend = _by_name['codeberg'] + if not backend then return nil end - if kind ~= 'issues' and kind ~= 'pulls' then - return nil - end - local forge_name = FORGE_HOSTS[host] - if not forge_name then - local cfg = config.get().forge or {} - local cb_cfg = cfg.codeberg or {} - for _, inst in ipairs(cb_cfg.instances or {}) do - if host == inst then - forge_name = 'codeberg' - break - end - end - end - if forge_name ~= 'codeberg' then - return nil - end - local num = tonumber(number) --[[@as integer]] - local ref_type = kind == 'pulls' and 'pull_request' or 'issue' - return { - forge = 'codeberg', - owner = owner, - repo = repo, - type = ref_type, - number = num, - url = url, - } + return backend:parse_url(url) end ---@param token string @@ -193,7 +143,16 @@ function M.parse_ref(token) if not token:match('^https?://') then return nil end - return M._parse_github_url(token) or M._parse_gitlab_url(token) or M._parse_codeberg_url(token) + _ensure_instances() + local host = token:match('^https?://([^/]+)') + if not host then + return nil + end + local backend = _by_host[host] + if not backend then + return nil + end + return backend:parse_url(token) end ---@class pending.ForgeSpan @@ -230,38 +189,13 @@ function M.find_refs(text) end ---@param ref pending.ForgeRef ----@return string -function M._api_url(ref) - if ref.forge == 'github' then - return FORGE_API_BASE.github - .. '/repos/' - .. ref.owner - .. '/' - .. ref.repo - .. '/issues/' - .. ref.number - elseif ref.forge == 'gitlab' then - local encoded = (ref.owner .. '/' .. ref.repo):gsub('/', '%%2F') - local endpoint = ref.type == 'merge_request' and 'merge_requests' or 'issues' - return FORGE_API_BASE.gitlab - .. '/api/v4/projects/' - .. encoded - .. '/' - .. endpoint - .. '/' - .. ref.number - else - local endpoint = ref.type == 'pull_request' and 'pulls' or 'issues' - return FORGE_API_BASE.codeberg - .. '/api/v1/repos/' - .. ref.owner - .. '/' - .. ref.repo - .. '/' - .. endpoint - .. '/' - .. ref.number +---@return string[] +function M._api_args(ref) + local backend = _by_name[ref.forge] + if not backend then + return {} end + return backend:api_args(ref) end ---@param ref pending.ForgeRef @@ -271,8 +205,11 @@ end function M.format_label(ref, cache) local cfg = config.get().forge or {} local forge_cfg = cfg[ref.forge] or {} - local fmt = forge_cfg.issue_format or '%i %o/%r#%n' - local icon = forge_cfg.icon or '' + local backend = _by_name[ref.forge] + local default_icon = backend and backend.default_icon or '' + local default_fmt = backend and backend.default_issue_format or '%i %o/%r#%n' + local fmt = forge_cfg.issue_format or default_fmt + local icon = forge_cfg.icon or default_icon local text = fmt :gsub('%%i', icon) :gsub('%%o', ref.owner) @@ -287,49 +224,22 @@ function M.format_label(ref, cache) return text, hl end ----@param forge string ----@return string? -function M.get_token(forge) - local cfg = config.get().forge or {} - local forge_cfg = cfg[forge] or {} - if forge_cfg.token then - return forge_cfg.token - end - if forge == 'github' then - local result = vim.fn.system({ 'gh', 'auth', 'token' }) - if vim.v.shell_error == 0 and result and result ~= '' then - return vim.trim(result) - end - elseif forge == 'gitlab' then - local result = vim.fn.system({ 'glab', 'auth', 'token' }) - if vim.v.shell_error == 0 and result and result ~= '' then - return vim.trim(result) - end - end - return nil -end - ---@param ref pending.ForgeRef ---@param callback fun(cache: pending.ForgeCache?) function M.fetch_metadata(ref, callback) - local token = M.get_token(ref.forge) - local url = M._api_url(ref) - local args = { 'curl', '-s', '-L' } - if token then - table.insert(args, '-H') - if ref.forge == 'gitlab' then - table.insert(args, 'PRIVATE-TOKEN: ' .. token) - else - table.insert(args, 'Authorization: Bearer ' .. token) - end - end - table.insert(args, '-H') - table.insert(args, 'Accept: application/json') - table.insert(args, url) + local args = M._api_args(ref) vim.system(args, { text = true }, function(result) if result.code ~= 0 or not result.stdout or result.stdout == '' then vim.schedule(function() + local forge_cfg = config.get().forge or {} + local backend = _by_name[ref.forge] + if backend and forge_cfg.warn_missing_cli ~= false and not backend._warned then + backend._warned = true + log.warn( + ('%s not found or not authenticated — run `%s`'):format(backend.cli, backend.auth_cmd) + ) + end callback(nil) end) return @@ -341,24 +251,8 @@ function M.fetch_metadata(ref, callback) end) return end - local state = 'open' - if ref.forge == 'github' then - if decoded.pull_request and decoded.pull_request.merged_at then - state = 'merged' - elseif decoded.state == 'closed' then - state = 'closed' - end - elseif ref.forge == 'gitlab' then - if decoded.state == 'merged' then - state = 'merged' - elseif decoded.state == 'closed' then - state = 'closed' - end - else - if decoded.state == 'closed' then - state = 'closed' - end - end + local backend = _by_name[ref.forge] + local state = backend and backend:parse_state(decoded) or 'open' local labels = {} if decoded.labels then for _, label in ipairs(decoded.labels) do @@ -434,4 +328,211 @@ function M.refresh(s) end end +---@param opts {name: string, shorthand: string, default_host: string, cli?: string, auth_cmd?: string, default_icon?: string, default_issue_format?: string} +---@return pending.ForgeBackend +function M.gitea_backend(opts) + return { + name = opts.name, + shorthand = opts.shorthand, + default_host = opts.default_host, + cli = opts.cli or 'tea', + auth_cmd = opts.auth_cmd or 'tea login add', + default_icon = opts.default_icon or '', + default_issue_format = opts.default_issue_format or '%i %o/%r#%n', + _warned = false, + parse_url = function(self, url) + _ensure_instances() + local host, owner, repo, kind, number = + url:match('^https?://([^/]+)/([%w%.%-_]+)/([%w%.%-_]+)/(%a+)/(%d+)$') + if not host then + return nil + end + if kind ~= 'issues' and kind ~= 'pulls' then + return nil + end + if _by_host[host] ~= self then + return nil + end + local num = tonumber(number) --[[@as integer]] + local ref_type = kind == 'pulls' and 'pull_request' or 'issue' + return { + forge = self.name, + owner = owner, + repo = repo, + type = ref_type, + number = num, + url = url, + } + end, + api_args = function(self, ref) + local endpoint = ref.type == 'pull_request' and 'pulls' or 'issues' + return { + self.cli, + 'api', + '/repos/' .. ref.owner .. '/' .. ref.repo .. '/' .. endpoint .. '/' .. ref.number, + } + end, + parse_state = function(_, decoded) + if decoded.state == 'closed' then + return 'closed' + end + return 'open' + end, + } +end + +M.register({ + name = 'github', + shorthand = 'gh', + default_host = 'github.com', + cli = 'gh', + auth_cmd = 'gh auth login', + default_icon = '', + default_issue_format = '%i %o/%r#%n', + _warned = false, + parse_url = function(self, url) + _ensure_instances() + local host, owner, repo, kind, number = + url:match('^https?://([^/]+)/([%w%.%-_]+)/([%w%.%-_]+)/(%a+)/(%d+)$') + if not host then + return nil + end + if kind ~= 'issues' and kind ~= 'pull' then + return nil + end + if _by_host[host] ~= self then + return nil + end + local num = tonumber(number) --[[@as integer]] + local ref_type = kind == 'pull' and 'pull_request' or 'issue' + return { + forge = 'github', + owner = owner, + repo = repo, + type = ref_type, + number = num, + url = url, + } + end, + api_args = function(_, ref) + return { + 'gh', + 'api', + '/repos/' .. ref.owner .. '/' .. ref.repo .. '/issues/' .. ref.number, + } + end, + parse_state = function(_, decoded) + if decoded.pull_request and decoded.pull_request.merged_at then + return 'merged' + elseif decoded.state == 'closed' then + return 'closed' + end + return 'open' + end, +}) + +M.register({ + name = 'gitlab', + shorthand = 'gl', + default_host = 'gitlab.com', + cli = 'glab', + auth_cmd = 'glab auth login', + default_icon = '', + default_issue_format = '%i %o/%r#%n', + _warned = false, + parse_url = function(self, url) + _ensure_instances() + local host, path, kind, number = url:match('^https?://([^/]+)/(.+)/%-/([%w_]+)/(%d+)$') + if not host then + return nil + end + if kind ~= 'issues' and kind ~= 'merge_requests' then + return nil + end + if _by_host[host] ~= self then + return nil + end + local owner, repo = path:match('^(.+)/([^/]+)$') + if not owner then + return nil + end + local num = tonumber(number) --[[@as integer]] + local ref_type = kind == 'merge_requests' and 'merge_request' or 'issue' + return { + forge = 'gitlab', + owner = owner, + repo = repo, + type = ref_type, + number = num, + url = url, + } + end, + api_args = function(_, ref) + local encoded = (ref.owner .. '/' .. ref.repo):gsub('/', '%%2F') + local endpoint = ref.type == 'merge_request' and 'merge_requests' or 'issues' + return { + 'glab', + 'api', + '/projects/' .. encoded .. '/' .. endpoint .. '/' .. ref.number, + } + end, + parse_state = function(_, decoded) + if decoded.state == 'merged' then + return 'merged' + elseif decoded.state == 'closed' then + return 'closed' + end + return 'open' + end, +}) + +M.register({ + name = 'codeberg', + shorthand = 'cb', + default_host = 'codeberg.org', + cli = 'tea', + auth_cmd = 'tea login add', + default_icon = '', + default_issue_format = '%i %o/%r#%n', + _warned = false, + parse_url = function(self, url) + _ensure_instances() + local host, owner, repo, kind, number = + url:match('^https?://([^/]+)/([%w%.%-_]+)/([%w%.%-_]+)/(%a+)/(%d+)$') + if not host then + return nil + end + if kind ~= 'issues' and kind ~= 'pulls' then + return nil + end + if _by_host[host] ~= self then + return nil + end + local num = tonumber(number) --[[@as integer]] + local ref_type = kind == 'pulls' and 'pull_request' or 'issue' + return { + forge = 'codeberg', + owner = owner, + repo = repo, + type = ref_type, + number = num, + url = url, + } + end, + api_args = function(_, ref) + local endpoint = ref.type == 'pull_request' and 'pulls' or 'issues' + return { + 'tea', + 'api', + '/repos/' .. ref.owner .. '/' .. ref.repo .. '/' .. endpoint .. '/' .. ref.number, + } + end, + parse_state = function(_, decoded) + if decoded.state == 'closed' then + return 'closed' + end + return 'open' + end, +}) + return M diff --git a/lua/pending/health.lua b/lua/pending/health.lua index f819269..457eb67 100644 --- a/lua/pending/health.lua +++ b/lua/pending/health.lua @@ -46,6 +46,16 @@ function M.check() end end + vim.health.start('pending.nvim: forge') + local forge = require('pending.forge') + for _, backend in ipairs(forge.backends()) do + if vim.fn.executable(backend.cli) == 1 then + vim.health.ok(('%s found'):format(backend.cli)) + else + vim.health.warn(('%s not found — run `%s`'):format(backend.cli, backend.auth_cmd)) + end + end + 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') diff --git a/spec/forge_spec.lua b/spec/forge_spec.lua index 3d17374..fac8021 100644 --- a/spec/forge_spec.lua +++ b/spec/forge_spec.lua @@ -186,9 +186,9 @@ describe('forge', function() end) end) - describe('_api_url', function() - it('builds GitHub API URL', function() - local url = forge._api_url({ + describe('_api_args', function() + it('builds GitHub CLI args', function() + local args = forge._api_args({ forge = 'github', owner = 'user', repo = 'repo', @@ -196,11 +196,11 @@ describe('forge', function() number = 42, url = '', }) - assert.equals('https://api.github.com/repos/user/repo/issues/42', url) + assert.same({ 'gh', 'api', '/repos/user/repo/issues/42' }, args) end) - it('builds GitLab API URL for issue', function() - local url = forge._api_url({ + it('builds GitLab CLI args for issue', function() + local args = forge._api_args({ forge = 'gitlab', owner = 'group', repo = 'project', @@ -208,11 +208,11 @@ describe('forge', function() number = 15, url = '', }) - assert.equals('https://gitlab.com/api/v4/projects/group%2Fproject/issues/15', url) + assert.same({ 'glab', 'api', '/projects/group%2Fproject/issues/15' }, args) end) - it('builds GitLab API URL for merge request', function() - local url = forge._api_url({ + it('builds GitLab CLI args for merge request', function() + local args = forge._api_args({ forge = 'gitlab', owner = 'group', repo = 'project', @@ -220,11 +220,11 @@ describe('forge', function() number = 5, url = '', }) - assert.equals('https://gitlab.com/api/v4/projects/group%2Fproject/merge_requests/5', url) + assert.same({ 'glab', 'api', '/projects/group%2Fproject/merge_requests/5' }, args) end) - it('builds Codeberg API URL', function() - local url = forge._api_url({ + it('builds Codeberg CLI args', function() + local args = forge._api_args({ forge = 'codeberg', owner = 'user', repo = 'repo', @@ -232,7 +232,7 @@ describe('forge', function() number = 3, url = '', }) - assert.equals('https://codeberg.org/api/v1/repos/user/repo/issues/3', url) + assert.same({ 'tea', 'api', '/repos/user/repo/issues/3' }, args) end) end) @@ -316,6 +316,81 @@ describe('forge parse.body integration', function() end) end) +describe('forge registry', function() + it('backends() returns all registered backends', function() + local backends = forge.backends() + assert.is_true(#backends >= 3) + local names = {} + for _, b in ipairs(backends) do + names[b.name] = true + end + assert.is_true(names['github']) + assert.is_true(names['gitlab']) + assert.is_true(names['codeberg']) + end) + + it('register() with custom backend resolves URLs', function() + local custom = forge.gitea_backend({ + name = 'mygitea', + shorthand = 'mg', + default_host = 'gitea.example.com', + }) + forge.register(custom) + + local ref = forge.parse_ref('https://gitea.example.com/alice/proj/issues/7') + assert.is_not_nil(ref) + assert.equals('mygitea', ref.forge) + assert.equals('alice', ref.owner) + assert.equals('proj', ref.repo) + assert.equals('issue', ref.type) + assert.equals(7, ref.number) + end) + + it('register() with custom shorthand resolves', function() + local ref = forge._parse_shorthand('mg:alice/proj#7') + assert.is_not_nil(ref) + assert.equals('mygitea', ref.forge) + assert.equals('alice', ref.owner) + assert.equals('proj', ref.repo) + assert.equals(7, ref.number) + end) + + it('_api_args dispatches to custom backend', function() + local args = forge._api_args({ + forge = 'mygitea', + owner = 'alice', + repo = 'proj', + type = 'issue', + number = 7, + url = '', + }) + assert.same({ 'tea', 'api', '/repos/alice/proj/issues/7' }, args) + end) + + it('gitea_backend() creates a working backend', function() + local b = forge.gitea_backend({ + name = 'forgejo', + shorthand = 'fj', + default_host = 'forgejo.example.com', + cli = 'forgejo-cli', + auth_cmd = 'forgejo-cli login', + }) + assert.equals('forgejo', b.name) + assert.equals('fj', b.shorthand) + assert.equals('forgejo-cli', b.cli) + + local ref = b:parse_url('https://forgejo.example.com/bob/repo/pulls/3') + assert.is_nil(ref) + + forge.register(b) + ref = b:parse_url('https://forgejo.example.com/bob/repo/pulls/3') + assert.is_not_nil(ref) + assert.equals('forgejo', ref.forge) + assert.equals('pull_request', ref.type) + assert.equals(3, ref.number) + end) +end) + describe('forge diff integration', function() local store = require('pending.store') local diff = require('pending.diff') From c34adbeadd90118fc4d6d1fd68be49e40fbbeb56 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Tue, 10 Mar 2026 22:30:20 -0400 Subject: [PATCH 174/199] feat(diff): disallow editing done tasks by default (#132) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: document S3 backend, auto-auth, and `:Pending done` command Problem: The S3 backend had no `:Pending s3` entry in the COMMANDS section, `:Pending auth` only mentioned Google, the `sync` config field omitted `s3`, `_s3_sync_id` was missing from the data format section, `:Pending done` was implemented but undocumented, and the README lacked a features overview. Solution: Add `:Pending s3` and `:Pending done` command docs, rewrite `:Pending auth` to cover all backends and sub-actions, update config and data format references, add `aws` CLI to requirements, and add a Features section to `README.md`. * feat(forge): add forge link parser and metadata fetcher Problem: no way to associate tasks with GitHub, GitLab, or Codeberg issues/PRs, or to track their remote state. Solution: add `forge.lua` with shorthand (`gh:user/repo#42`) and full URL parsing, async metadata fetching via `curl`, label formatting, conceal pattern generation, token resolution, and `refresh()` for state pull (closed/merged -> done). * feat(config): add forge config defaults and `%l` eol specifier Problem: no configuration surface for forge link rendering, icons, issue format, or self-hosted instances. Solution: add `pending.ForgeConfig` class with per-forge `token`, `icon`, `issue_format`, and `instances` fields. Add `%l` to the default `eol_format` so forge labels render in virtual text. * feat(parse): extract forge refs from task body Problem: `parse.body()` had no awareness of forge link tokens, so `gh:user/repo#42` stayed in the description instead of metadata. Solution: add `forge_ref` field to `pending.Metadata` and extend the right-to-left token loop in `body()` to call `forge.parse_ref()` as the final fallback before breaking. * feat(diff): persist forge refs in store on write Problem: forge refs parsed from buffer lines were discarded during diff reconciliation and never stored in the JSON. Solution: thread `forge_ref` through `parse_buffer` entries into `diff.apply`, storing it in `task._extra._forge_ref` for both new and existing tasks. * feat(views): pass forge ref and cache to line metadata Problem: `LineMeta` had no forge fields, so `buffer.lua` could not render forge labels or apply forge-specific highlights. Solution: add `forge_ref` and `forge_cache` fields to `LineMeta`, populated from `task._extra` in both `category_view` and `priority_view`. * feat(buffer): render forge links as concealed text with eol virt text Problem: forge tokens were visible as raw text with no virtual text labels, and the eol separator logic collapsed all gaps when non-adjacent specifiers were absent. Solution: add forge conceal syntax patterns in `setup_syntax()`, add `PendingForge`/`PendingForgeClosed` highlight groups, handle the `%l` specifier in `build_eol_virt()`, fix separator collapsing to buffer one separator between present segments, and change `concealcursor` to `nc` (reveal in visual and insert mode). * feat(complete): add forge shorthand omnifunc completions Problem: no completion support for `gh:`, `gl:`, or `cb:` tokens, requiring users to type owner/repo from memory. Solution: extend `omnifunc` to detect `gh:`/`gl:`/`cb:` prefixes and complete with `owner/repo#` candidates from existing forge refs in the store. * feat: trigger forge refresh on buffer open Problem: forge metadata was never fetched, so virt text highlights could not reflect remote issue/PR state. Solution: call `forge.refresh()` in `M.open()` so metadata is fetched once per `:Pending` invocation rather than on every render. * test(forge): add forge parsing spec Problem: no test coverage for forge link shorthand parsing, URL parsing, label formatting, or API URL generation. Solution: add `spec/forge_spec.lua` covering `_parse_shorthand`, `parse_ref` for all three forges, full URL parsing including nested GitLab groups, `format_label`, and `_api_url`. * docs: document forge links feature Problem: no user-facing documentation for forge link syntax, configuration, or behavior. Solution: add forge links section to `README.md` and `pending.txt` covering shorthand/URL syntax, config options, virtual text rendering, state pull, and auth resolution. * feat(forge): add `find_refs()` inline token scanner Problem: forge tokens were extracted by `parse.body()` which stripped them from the description, making editing awkward and multi-ref lines impossible. Solution: add `find_refs(text)` that scans a string for all forge tokens by whitespace tokenization, returning byte offsets and parsed refs without modifying the input. Remove unused `conceal_patterns()`. * refactor: move forge ref detection from `parse.body()` to `diff` Problem: `parse.body()` stripped forge tokens from the description, losing the raw text. This made inline overlay rendering impossible since the token no longer existed in the buffer. Solution: remove the `forge.parse_ref()` branch from `parse.body()` and call `forge.find_refs()` in `diff.parse_buffer()` instead. The description now retains forge tokens verbatim; `_extra._forge_ref` is still populated from the first matched ref. * feat(buffer): render forge links as inline conceal overlays Problem: forge tokens were stripped from the buffer and shown as EOL virtual text via `%l`. The token disappeared from the editable line, and multi-ref tasks broke. Solution: compute `forge_spans` in `views.lua` with byte offsets for each forge token in the rendered line. In `apply_inline_row()`, place extmarks with `conceal=''` and `virt_text_pos='inline'` to visually replace each raw token with its formatted label. Clear stale `forge_spans` on dirty rows to prevent `end_col` out-of-range errors after edits like `dd`. * fix(config): remove `%l` from default `eol_format` Problem: forge links are now rendered inline, making the `%l` EOL specifier redundant in the default format. Solution: change default `eol_format` from `'%l %c %r %d'` to `'%c %r %d'`. The `%l` specifier remains functional for users who explicitly set it. * test(forge): update specs for inline forge refs Problem: existing tests asserted that `parse.body()` stripped forge tokens from the description and populated `meta.forge_ref`. The `conceal_patterns` test referenced a removed function. Solution: update `parse.body` integration tests to assert tokens stay in the description. Add `find_refs()` tests covering single/multiple refs, URLs, byte offsets, and empty cases. Remove `conceal_patterns` test. Update diff tests to assert description includes the token. * docs: update forge links for inline overlay rendering Problem: documentation described forge tokens as stripped from the description and rendered via EOL `%l` specifier by default. Solution: update forge links section to describe inline conceal overlay rendering. Update default `eol_format` reference. Change `issue_format` field description from "EOL label" to "inline overlay label". * ci: format * refactor(forge): remove `%l` eol specifier, add `auto_close` config, fix icons Problem: `%l` was dead code after inline overlays replaced EOL rendering. Auto-close was always on with no opt-out. Forge icon defaults were empty strings. Solution: remove `%l` from the eol format parser and renderer. Add `forge.auto_close` (default `false`) to gate state-pull. Set nerd font icons: `` (GitHub), `` (GitLab), `` (Codeberg). Keep conceal active in insert mode via `concealcursor = 'nic'`. * fix(config): set correct nerd font icons for forge defaults * refactor(forge): replace curl/token auth with CLI-native API calls Problem: Forge metadata fetching required manual token management — config fields, CLI token extraction, and curl with auth headers. Each forge had a different auth path, and Codeberg had no CLI support at all. Solution: Delete `get_token()` and `_api_url()`, replace with `_api_args()` that builds `gh api`, `glab api`, or `tea api` arg arrays. The CLIs handle auth internally. Add `warn_missing_cli` config (default true) that warns once per forge per session on failure. Add forge CLI checks to `:checkhealth`. Remove `token` from config/docs. * refactor(forge): extract ForgeBackend class and registry Problem: adding a new forge required touching 5 lookup tables (`FORGE_HOSTS`, `FORGE_CLI`, `FORGE_AUTH_CMD`, `SHORTHAND_PREFIX`, `_warned_forges`) and every branching site in `_api_args`, `fetch_metadata`, and `parse_ref`. Solution: introduce a `ForgeBackend` class with `parse_url`, `api_args`, and `parse_state` methods, plus a `register()` / `backends()` registry. New forges (Gitea, Forgejo) are a single `register()` call via the `gitea_backend()` convenience constructor. * ci: format * feat(diff): disallow editing done tasks by default Problem: Done tasks could be freely edited in the buffer, leading to accidental modifications of completed work. Solution: Add a `lock_done` config option (default `true`) and a guard in `diff.apply()` that rejects field changes to done tasks unless the user toggles the checkbox back to pending first. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- lua/pending/config.lua | 2 + lua/pending/diff.lua | 161 ++++++++++++++++++++++------------------- spec/diff_spec.lua | 61 ++++++++++++++++ 3 files changed, 148 insertions(+), 76 deletions(-) diff --git a/lua/pending/config.lua b/lua/pending/config.lua index b1ab639..6942c1b 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -100,6 +100,7 @@ ---@field view pending.ViewConfig ---@field max_priority? integer ---@field sync? pending.SyncConfig +---@field lock_done? boolean ---@field forge? pending.ForgeConfig ---@field icons pending.Icons @@ -114,6 +115,7 @@ local defaults = { date_syntax = 'due', recur_syntax = 'rec', someday_date = '9999-12-30', + lock_done = true, max_priority = 3, view = { default = 'category', diff --git a/lua/pending/diff.lua b/lua/pending/diff.lua index 29c292b..a213584 100644 --- a/lua/pending/diff.lua +++ b/lua/pending/diff.lua @@ -1,5 +1,6 @@ local config = require('pending.config') local forge = require('pending.forge') +local log = require('pending.log') local parse = require('pending.parse') ---@class pending.ParsedEntry @@ -102,14 +103,91 @@ function M.apply(lines, s, hidden_ids) local order_counter = 0 for _, entry in ipairs(parsed) do - if entry.type ~= 'task' then - goto continue - end + if entry.type == 'task' then + order_counter = order_counter + 1 - order_counter = order_counter + 1 - - if entry.id and old_by_id[entry.id] then - if seen_ids[entry.id] then + if entry.id and old_by_id[entry.id] then + if seen_ids[entry.id] then + s:add({ + description = entry.description, + category = entry.category, + priority = entry.priority, + due = entry.due, + recur = entry.rec, + recur_mode = entry.rec_mode, + order = order_counter, + _extra = entry.forge_ref and { _forge_ref = entry.forge_ref } or nil, + }) + else + seen_ids[entry.id] = true + local task = old_by_id[entry.id] + if + config.get().lock_done + and task.status == 'done' + and entry.status == 'done' + then + if task.order ~= order_counter then + task.order = order_counter + task.modified = now + end + log.warn('cannot edit done task — toggle status first') + else + local changed = false + if task.description ~= entry.description then + task.description = entry.description + changed = true + end + if task.category ~= entry.category then + task.category = entry.category + changed = true + end + 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 + if entry.due ~= nil and task.due ~= entry.due then + task.due = entry.due + changed = true + end + 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.forge_ref ~= nil then + if not task._extra then + task._extra = {} + end + task._extra._forge_ref = entry.forge_ref + changed = true + end + if entry.status and task.status ~= entry.status then + task.status = entry.status + if entry.status == 'done' then + task['end'] = now + else + task['end'] = nil + end + changed = true + end + if task.order ~= order_counter then + task.order = order_counter + changed = true + end + if changed then + task.modified = now + end + end + end + else s:add({ description = entry.description, category = entry.category, @@ -120,77 +198,8 @@ function M.apply(lines, s, hidden_ids) order = order_counter, _extra = entry.forge_ref and { _forge_ref = entry.forge_ref } or nil, }) - else - seen_ids[entry.id] = true - local task = old_by_id[entry.id] - local changed = false - if task.description ~= entry.description then - task.description = entry.description - changed = true - end - if task.category ~= entry.category then - task.category = entry.category - changed = true - end - 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 - if entry.due ~= nil and task.due ~= entry.due then - task.due = entry.due - changed = true - end - 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.forge_ref ~= nil then - if not task._extra then - task._extra = {} - end - task._extra._forge_ref = entry.forge_ref - changed = true - end - if entry.status and task.status ~= entry.status then - task.status = entry.status - if entry.status == 'done' then - task['end'] = now - else - task['end'] = nil - end - changed = true - end - if task.order ~= order_counter then - task.order = order_counter - changed = true - end - if changed then - task.modified = now - end end - else - s:add({ - description = entry.description, - category = entry.category, - priority = entry.priority, - due = entry.due, - recur = entry.rec, - recur_mode = entry.rec_mode, - order = order_counter, - _extra = entry.forge_ref and { _forge_ref = entry.forge_ref } or nil, - }) end - - ::continue:: end for id, task in pairs(old_by_id) do diff --git a/spec/diff_spec.lua b/spec/diff_spec.lua index 01d8aac..c897df1 100644 --- a/spec/diff_spec.lua +++ b/spec/diff_spec.lua @@ -287,5 +287,66 @@ describe('diff', function() local task = s:get(1) assert.are.equal(0, task.priority) end) + + it('rejects editing description of a done task', function() + local t = s:add({ description = 'Finished work', status = 'done' }) + t['end'] = '2026-03-01T00:00:00Z' + s:save() + local lines = { + '# Todo', + '/1/- [x] Changed description', + } + diff.apply(lines, s) + s:load() + local task = s:get(1) + assert.are.equal('Finished work', task.description) + assert.are.equal('done', task.status) + end) + + it('allows toggling done task back to pending', function() + local t = s:add({ description = 'Finished work', status = 'done' }) + t['end'] = '2026-03-01T00:00:00Z' + s:save() + local lines = { + '# Todo', + '/1/- [ ] Finished work', + } + diff.apply(lines, s) + s:load() + local task = s:get(1) + assert.are.equal('pending', task.status) + end) + + it('allows editing done task when lock_done is false', function() + local cfg = require('pending.config') + vim.g.pending = { lock_done = false } + cfg.reset() + local t = s:add({ description = 'Finished work', status = 'done' }) + t['end'] = '2026-03-01T00:00:00Z' + s:save() + local lines = { + '# Todo', + '/1/- [x] Changed description', + } + diff.apply(lines, s) + s:load() + local task = s:get(1) + assert.are.equal('Changed description', task.description) + vim.g.pending = {} + cfg.reset() + end) + + it('does not affect editing of pending tasks', function() + s:add({ description = 'Active task' }) + s:save() + local lines = { + '# Todo', + '/1/- [ ] Updated active task', + } + diff.apply(lines, s) + s:load() + local task = s:get(1) + assert.are.equal('Updated active task', task.description) + end) end) end) From 44541ce753e6c0c75b3ce60eeb52f05fb6ab7074 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Tue, 10 Mar 2026 22:30:42 -0400 Subject: [PATCH 175/199] feat(forge): support custom shorthand prefixes (#131) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: document S3 backend, auto-auth, and `:Pending done` command Problem: The S3 backend had no `:Pending s3` entry in the COMMANDS section, `:Pending auth` only mentioned Google, the `sync` config field omitted `s3`, `_s3_sync_id` was missing from the data format section, `:Pending done` was implemented but undocumented, and the README lacked a features overview. Solution: Add `:Pending s3` and `:Pending done` command docs, rewrite `:Pending auth` to cover all backends and sub-actions, update config and data format references, add `aws` CLI to requirements, and add a Features section to `README.md`. * feat(forge): add forge link parser and metadata fetcher Problem: no way to associate tasks with GitHub, GitLab, or Codeberg issues/PRs, or to track their remote state. Solution: add `forge.lua` with shorthand (`gh:user/repo#42`) and full URL parsing, async metadata fetching via `curl`, label formatting, conceal pattern generation, token resolution, and `refresh()` for state pull (closed/merged -> done). * feat(config): add forge config defaults and `%l` eol specifier Problem: no configuration surface for forge link rendering, icons, issue format, or self-hosted instances. Solution: add `pending.ForgeConfig` class with per-forge `token`, `icon`, `issue_format`, and `instances` fields. Add `%l` to the default `eol_format` so forge labels render in virtual text. * feat(parse): extract forge refs from task body Problem: `parse.body()` had no awareness of forge link tokens, so `gh:user/repo#42` stayed in the description instead of metadata. Solution: add `forge_ref` field to `pending.Metadata` and extend the right-to-left token loop in `body()` to call `forge.parse_ref()` as the final fallback before breaking. * feat(diff): persist forge refs in store on write Problem: forge refs parsed from buffer lines were discarded during diff reconciliation and never stored in the JSON. Solution: thread `forge_ref` through `parse_buffer` entries into `diff.apply`, storing it in `task._extra._forge_ref` for both new and existing tasks. * feat(views): pass forge ref and cache to line metadata Problem: `LineMeta` had no forge fields, so `buffer.lua` could not render forge labels or apply forge-specific highlights. Solution: add `forge_ref` and `forge_cache` fields to `LineMeta`, populated from `task._extra` in both `category_view` and `priority_view`. * feat(buffer): render forge links as concealed text with eol virt text Problem: forge tokens were visible as raw text with no virtual text labels, and the eol separator logic collapsed all gaps when non-adjacent specifiers were absent. Solution: add forge conceal syntax patterns in `setup_syntax()`, add `PendingForge`/`PendingForgeClosed` highlight groups, handle the `%l` specifier in `build_eol_virt()`, fix separator collapsing to buffer one separator between present segments, and change `concealcursor` to `nc` (reveal in visual and insert mode). * feat(complete): add forge shorthand omnifunc completions Problem: no completion support for `gh:`, `gl:`, or `cb:` tokens, requiring users to type owner/repo from memory. Solution: extend `omnifunc` to detect `gh:`/`gl:`/`cb:` prefixes and complete with `owner/repo#` candidates from existing forge refs in the store. * feat: trigger forge refresh on buffer open Problem: forge metadata was never fetched, so virt text highlights could not reflect remote issue/PR state. Solution: call `forge.refresh()` in `M.open()` so metadata is fetched once per `:Pending` invocation rather than on every render. * test(forge): add forge parsing spec Problem: no test coverage for forge link shorthand parsing, URL parsing, label formatting, or API URL generation. Solution: add `spec/forge_spec.lua` covering `_parse_shorthand`, `parse_ref` for all three forges, full URL parsing including nested GitLab groups, `format_label`, and `_api_url`. * docs: document forge links feature Problem: no user-facing documentation for forge link syntax, configuration, or behavior. Solution: add forge links section to `README.md` and `pending.txt` covering shorthand/URL syntax, config options, virtual text rendering, state pull, and auth resolution. * feat(forge): add `find_refs()` inline token scanner Problem: forge tokens were extracted by `parse.body()` which stripped them from the description, making editing awkward and multi-ref lines impossible. Solution: add `find_refs(text)` that scans a string for all forge tokens by whitespace tokenization, returning byte offsets and parsed refs without modifying the input. Remove unused `conceal_patterns()`. * refactor: move forge ref detection from `parse.body()` to `diff` Problem: `parse.body()` stripped forge tokens from the description, losing the raw text. This made inline overlay rendering impossible since the token no longer existed in the buffer. Solution: remove the `forge.parse_ref()` branch from `parse.body()` and call `forge.find_refs()` in `diff.parse_buffer()` instead. The description now retains forge tokens verbatim; `_extra._forge_ref` is still populated from the first matched ref. * feat(buffer): render forge links as inline conceal overlays Problem: forge tokens were stripped from the buffer and shown as EOL virtual text via `%l`. The token disappeared from the editable line, and multi-ref tasks broke. Solution: compute `forge_spans` in `views.lua` with byte offsets for each forge token in the rendered line. In `apply_inline_row()`, place extmarks with `conceal=''` and `virt_text_pos='inline'` to visually replace each raw token with its formatted label. Clear stale `forge_spans` on dirty rows to prevent `end_col` out-of-range errors after edits like `dd`. * fix(config): remove `%l` from default `eol_format` Problem: forge links are now rendered inline, making the `%l` EOL specifier redundant in the default format. Solution: change default `eol_format` from `'%l %c %r %d'` to `'%c %r %d'`. The `%l` specifier remains functional for users who explicitly set it. * test(forge): update specs for inline forge refs Problem: existing tests asserted that `parse.body()` stripped forge tokens from the description and populated `meta.forge_ref`. The `conceal_patterns` test referenced a removed function. Solution: update `parse.body` integration tests to assert tokens stay in the description. Add `find_refs()` tests covering single/multiple refs, URLs, byte offsets, and empty cases. Remove `conceal_patterns` test. Update diff tests to assert description includes the token. * docs: update forge links for inline overlay rendering Problem: documentation described forge tokens as stripped from the description and rendered via EOL `%l` specifier by default. Solution: update forge links section to describe inline conceal overlay rendering. Update default `eol_format` reference. Change `issue_format` field description from "EOL label" to "inline overlay label". * ci: format * refactor(forge): remove `%l` eol specifier, add `auto_close` config, fix icons Problem: `%l` was dead code after inline overlays replaced EOL rendering. Auto-close was always on with no opt-out. Forge icon defaults were empty strings. Solution: remove `%l` from the eol format parser and renderer. Add `forge.auto_close` (default `false`) to gate state-pull. Set nerd font icons: `` (GitHub), `` (GitLab), `` (Codeberg). Keep conceal active in insert mode via `concealcursor = 'nic'`. * fix(config): set correct nerd font icons for forge defaults * refactor(forge): replace curl/token auth with CLI-native API calls Problem: Forge metadata fetching required manual token management — config fields, CLI token extraction, and curl with auth headers. Each forge had a different auth path, and Codeberg had no CLI support at all. Solution: Delete `get_token()` and `_api_url()`, replace with `_api_args()` that builds `gh api`, `glab api`, or `tea api` arg arrays. The CLIs handle auth internally. Add `warn_missing_cli` config (default true) that warns once per forge per session on failure. Add forge CLI checks to `:checkhealth`. Remove `token` from config/docs. * refactor(forge): extract ForgeBackend class and registry Problem: adding a new forge required touching 5 lookup tables (`FORGE_HOSTS`, `FORGE_CLI`, `FORGE_AUTH_CMD`, `SHORTHAND_PREFIX`, `_warned_forges`) and every branching site in `_api_args`, `fetch_metadata`, and `parse_ref`. Solution: introduce a `ForgeBackend` class with `parse_url`, `api_args`, and `parse_state` methods, plus a `register()` / `backends()` registry. New forges (Gitea, Forgejo) are a single `register()` call via the `gitea_backend()` convenience constructor. * ci: format * feat(forge): support custom shorthand prefixes Problem: forge shorthand parsing hardcoded `%l%l` (exactly 2 lowercase letters), preventing custom prefixes like `github:`. Completions also hardcoded `gh:`, `gl:`, `cb:` patterns. Solution: iterate `_by_shorthand` keys dynamically in `_parse_shorthand` instead of matching a fixed pattern. Build completion patterns from `forge.backends()`. Add `shorthand` field to `ForgeInstanceConfig` so users can override prefixes via config, applied in `_ensure_instances()`. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- lua/pending/complete.lua | 20 +++++++++--- lua/pending/config.lua | 1 + lua/pending/forge.lua | 26 +++++++++++++--- spec/forge_spec.lua | 67 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 106 insertions(+), 8 deletions(-) diff --git a/lua/pending/complete.lua b/lua/pending/complete.lua index 135d1a4..98291ce 100644 --- a/lua/pending/complete.lua +++ b/lua/pending/complete.lua @@ -1,4 +1,5 @@ local config = require('pending.config') +local forge = require('pending.forge') ---@class pending.CompletionItem ---@field word string @@ -109,6 +110,17 @@ local function recur_completions() return result end +---@param source string +---@return boolean +function M._is_forge_source(source) + for _, b in ipairs(forge.backends()) do + if b.shorthand == source then + return true + end + end + return false +end + ---@type string? local _complete_source = nil @@ -128,10 +140,10 @@ function M.omnifunc(findstart, base) { vim.pesc(dk) .. ':([%S]*)$', dk }, { 'cat:([%S]*)$', 'cat' }, { vim.pesc(rk) .. ':([%S]*)$', rk }, - { 'gh:([%S]*)$', 'gh' }, - { 'gl:([%S]*)$', 'gl' }, - { 'cb:([%S]*)$', 'cb' }, } + for _, b in ipairs(forge.backends()) do + table.insert(checks, { vim.pesc(b.shorthand) .. ':([%S]*)$', b.shorthand }) + end for _, check in ipairs(checks) do local start = before:find(check[1]) @@ -172,7 +184,7 @@ function M.omnifunc(findstart, base) table.insert(matches, { word = c.word, menu = '[' .. source .. ']', info = c.info }) end end - elseif source == 'gh' or source == 'gl' or source == 'cb' then + elseif M._is_forge_source(source) then local s = require('pending.buffer').store() if s then local seen = {} diff --git a/lua/pending/config.lua b/lua/pending/config.lua index 6942c1b..1359f9a 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -37,6 +37,7 @@ ---@field icon? string ---@field issue_format? string ---@field instances? string[] +---@field shorthand? string ---@class pending.ForgeConfig ---@field auto_close? boolean diff --git a/lua/pending/forge.lua b/lua/pending/forge.lua index 9b32655..baebc0a 100644 --- a/lua/pending/forge.lua +++ b/lua/pending/forge.lua @@ -62,6 +62,14 @@ function M.backends() return _backends end +function M._reset_instances() + _instances_resolved = false + _by_shorthand = {} + for _, b in ipairs(_backends) do + _by_shorthand[b.shorthand] = b + end +end + local function _ensure_instances() if _instances_resolved then return @@ -73,17 +81,27 @@ local function _ensure_instances() for _, inst in ipairs(forge_cfg.instances or {}) do _by_host[inst] = backend end + if forge_cfg.shorthand and forge_cfg.shorthand ~= backend.shorthand then + _by_shorthand[backend.shorthand] = nil + backend.shorthand = forge_cfg.shorthand + _by_shorthand[backend.shorthand] = backend + end end end ---@param token string ---@return pending.ForgeRef? function M._parse_shorthand(token) - local prefix, rest = token:match('^(%l%l):(.+)$') - if not prefix then - return nil + _ensure_instances() + local backend, rest + for prefix, b in pairs(_by_shorthand) do + local candidate = token:match('^' .. vim.pesc(prefix) .. ':(.+)$') + if candidate then + backend = b + rest = candidate + break + end end - local backend = _by_shorthand[prefix] if not backend then return nil end diff --git a/spec/forge_spec.lua b/spec/forge_spec.lua index fac8021..8bd4162 100644 --- a/spec/forge_spec.lua +++ b/spec/forge_spec.lua @@ -391,6 +391,73 @@ describe('forge registry', function() end) end) +describe('custom forge prefixes', function() + local config = require('pending.config') + local complete = require('pending.complete') + + it('parses custom-length shorthand (3+ chars)', function() + local custom = forge.gitea_backend({ + name = 'customforge', + shorthand = 'cgf', + default_host = 'custom.example.com', + }) + forge.register(custom) + + local ref = forge._parse_shorthand('cgf:alice/proj#99') + assert.is_not_nil(ref) + assert.equals('customforge', ref.forge) + assert.equals('alice', ref.owner) + assert.equals('proj', ref.repo) + assert.equals(99, ref.number) + end) + + it('parse_ref dispatches custom-length shorthand', function() + local ref = forge.parse_ref('cgf:alice/proj#5') + assert.is_not_nil(ref) + assert.equals('customforge', ref.forge) + assert.equals(5, ref.number) + end) + + it('find_refs finds custom-length shorthand', function() + local refs = forge.find_refs('Fix cgf:alice/proj#12') + assert.equals(1, #refs) + assert.equals('customforge', refs[1].ref.forge) + assert.equals(12, refs[1].ref.number) + end) + + it('completion returns entries for custom backends', function() + assert.is_true(complete._is_forge_source('cgf')) + end) + + it('config shorthand override re-registers backend', function() + vim.g.pending = { + forge = { + github = { shorthand = 'github' }, + }, + } + config.reset() + forge._reset_instances() + + local ref = forge._parse_shorthand('github:user/repo#1') + assert.is_not_nil(ref) + assert.equals('github', ref.forge) + assert.equals('user', ref.owner) + assert.equals('repo', ref.repo) + assert.equals(1, ref.number) + + assert.is_nil(forge._parse_shorthand('gh:user/repo#1')) + + vim.g.pending = nil + config.reset() + for _, b in ipairs(forge.backends()) do + if b.name == 'github' then + b.shorthand = 'gh' + end + end + forge._reset_instances() + end) +end) + describe('forge diff integration', function() local store = require('pending.store') local diff = require('pending.diff') From dce409b2cc54fa15be7fedf2ae45c2ee36471f50 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Tue, 10 Mar 2026 22:45:07 -0400 Subject: [PATCH 176/199] Revert "feat(diff): disallow editing done tasks by default (#132)" (#133) This reverts commit 24e8741ae16118bd48efb8270ca869e1efe77ee0. --- lua/pending/config.lua | 2 - lua/pending/diff.lua | 161 +++++++++++++++++++---------------------- spec/diff_spec.lua | 61 ---------------- 3 files changed, 76 insertions(+), 148 deletions(-) diff --git a/lua/pending/config.lua b/lua/pending/config.lua index 1359f9a..5a139cb 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -101,7 +101,6 @@ ---@field view pending.ViewConfig ---@field max_priority? integer ---@field sync? pending.SyncConfig ----@field lock_done? boolean ---@field forge? pending.ForgeConfig ---@field icons pending.Icons @@ -116,7 +115,6 @@ local defaults = { date_syntax = 'due', recur_syntax = 'rec', someday_date = '9999-12-30', - lock_done = true, max_priority = 3, view = { default = 'category', diff --git a/lua/pending/diff.lua b/lua/pending/diff.lua index a213584..29c292b 100644 --- a/lua/pending/diff.lua +++ b/lua/pending/diff.lua @@ -1,6 +1,5 @@ local config = require('pending.config') local forge = require('pending.forge') -local log = require('pending.log') local parse = require('pending.parse') ---@class pending.ParsedEntry @@ -103,91 +102,14 @@ function M.apply(lines, s, hidden_ids) local order_counter = 0 for _, entry in ipairs(parsed) do - if entry.type == 'task' then - order_counter = order_counter + 1 + if entry.type ~= 'task' then + goto continue + end - if entry.id and old_by_id[entry.id] then - if seen_ids[entry.id] then - s:add({ - description = entry.description, - category = entry.category, - priority = entry.priority, - due = entry.due, - recur = entry.rec, - recur_mode = entry.rec_mode, - order = order_counter, - _extra = entry.forge_ref and { _forge_ref = entry.forge_ref } or nil, - }) - else - seen_ids[entry.id] = true - local task = old_by_id[entry.id] - if - config.get().lock_done - and task.status == 'done' - and entry.status == 'done' - then - if task.order ~= order_counter then - task.order = order_counter - task.modified = now - end - log.warn('cannot edit done task — toggle status first') - else - local changed = false - if task.description ~= entry.description then - task.description = entry.description - changed = true - end - if task.category ~= entry.category then - task.category = entry.category - changed = true - end - 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 - if entry.due ~= nil and task.due ~= entry.due then - task.due = entry.due - changed = true - end - 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.forge_ref ~= nil then - if not task._extra then - task._extra = {} - end - task._extra._forge_ref = entry.forge_ref - changed = true - end - if entry.status and task.status ~= entry.status then - task.status = entry.status - if entry.status == 'done' then - task['end'] = now - else - task['end'] = nil - end - changed = true - end - if task.order ~= order_counter then - task.order = order_counter - changed = true - end - if changed then - task.modified = now - end - end - end - else + order_counter = order_counter + 1 + + if entry.id and old_by_id[entry.id] then + if seen_ids[entry.id] then s:add({ description = entry.description, category = entry.category, @@ -198,8 +120,77 @@ function M.apply(lines, s, hidden_ids) order = order_counter, _extra = entry.forge_ref and { _forge_ref = entry.forge_ref } or nil, }) + else + seen_ids[entry.id] = true + local task = old_by_id[entry.id] + local changed = false + if task.description ~= entry.description then + task.description = entry.description + changed = true + end + if task.category ~= entry.category then + task.category = entry.category + changed = true + end + 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 + if entry.due ~= nil and task.due ~= entry.due then + task.due = entry.due + changed = true + end + 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.forge_ref ~= nil then + if not task._extra then + task._extra = {} + end + task._extra._forge_ref = entry.forge_ref + changed = true + end + if entry.status and task.status ~= entry.status then + task.status = entry.status + if entry.status == 'done' then + task['end'] = now + else + task['end'] = nil + end + changed = true + end + if task.order ~= order_counter then + task.order = order_counter + changed = true + end + if changed then + task.modified = now + end end + else + s:add({ + description = entry.description, + category = entry.category, + priority = entry.priority, + due = entry.due, + recur = entry.rec, + recur_mode = entry.rec_mode, + order = order_counter, + _extra = entry.forge_ref and { _forge_ref = entry.forge_ref } or nil, + }) end + + ::continue:: end for id, task in pairs(old_by_id) do diff --git a/spec/diff_spec.lua b/spec/diff_spec.lua index c897df1..01d8aac 100644 --- a/spec/diff_spec.lua +++ b/spec/diff_spec.lua @@ -287,66 +287,5 @@ describe('diff', function() local task = s:get(1) assert.are.equal(0, task.priority) end) - - it('rejects editing description of a done task', function() - local t = s:add({ description = 'Finished work', status = 'done' }) - t['end'] = '2026-03-01T00:00:00Z' - s:save() - local lines = { - '# Todo', - '/1/- [x] Changed description', - } - diff.apply(lines, s) - s:load() - local task = s:get(1) - assert.are.equal('Finished work', task.description) - assert.are.equal('done', task.status) - end) - - it('allows toggling done task back to pending', function() - local t = s:add({ description = 'Finished work', status = 'done' }) - t['end'] = '2026-03-01T00:00:00Z' - s:save() - local lines = { - '# Todo', - '/1/- [ ] Finished work', - } - diff.apply(lines, s) - s:load() - local task = s:get(1) - assert.are.equal('pending', task.status) - end) - - it('allows editing done task when lock_done is false', function() - local cfg = require('pending.config') - vim.g.pending = { lock_done = false } - cfg.reset() - local t = s:add({ description = 'Finished work', status = 'done' }) - t['end'] = '2026-03-01T00:00:00Z' - s:save() - local lines = { - '# Todo', - '/1/- [x] Changed description', - } - diff.apply(lines, s) - s:load() - local task = s:get(1) - assert.are.equal('Changed description', task.description) - vim.g.pending = {} - cfg.reset() - end) - - it('does not affect editing of pending tasks', function() - s:add({ description = 'Active task' }) - s:save() - local lines = { - '# Todo', - '/1/- [ ] Updated active task', - } - diff.apply(lines, s) - s:load() - local task = s:get(1) - assert.are.equal('Updated active task', task.description) - end) end) end) From a668986da0656a9980fb59b8aa9a017750b8bdd7 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Tue, 10 Mar 2026 23:18:20 -0400 Subject: [PATCH 177/199] refactor(forge): rename `auto_close` to `close` (#137) Problem: `auto_close` is verbose given it's already namespaced under `forge.` in the config table. Solution: Rename to `close` in config defaults, class annotation, `refresh()` usage, and vimdoc. --- doc/pending.txt | 6 +++--- lua/pending/config.lua | 4 ++-- lua/pending/forge.lua | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/doc/pending.txt b/doc/pending.txt index 9195c76..3078ea9 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -1500,7 +1500,7 @@ Configuration: ~ >lua vim.g.pending = { forge = { - auto_close = false, + close = false, warn_missing_cli = true, github = { icon = '', @@ -1522,7 +1522,7 @@ Configuration: ~ < Top-level fields: ~ - {auto_close} (boolean, default: false) When true, tasks linked to + {close} (boolean, default: false) When true, tasks linked to closed/merged remote issues are automatically marked done on buffer open. {warn_missing_cli} (boolean, default: true) When true, warns once per @@ -1550,7 +1550,7 @@ than 5 minutes are re-fetched asynchronously. The buffer renders immediately with cached data and updates extmarks when the fetch completes. State pull: ~ -Requires `forge.auto_close = true`. After fetching, if the remote issue/PR +Requires `forge.close = true`. After fetching, if the remote issue/PR is closed or merged and the local task is pending/wip/blocked, the task is automatically marked as done. Disabled by default. One-way: local status changes do not push back to the forge. diff --git a/lua/pending/config.lua b/lua/pending/config.lua index 5a139cb..60775fe 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -40,7 +40,7 @@ ---@field shorthand? string ---@class pending.ForgeConfig ----@field auto_close? boolean +---@field close? boolean ---@field warn_missing_cli? boolean ---@field [string] pending.ForgeInstanceConfig @@ -154,7 +154,7 @@ local defaults = { }, sync = {}, forge = { - auto_close = false, + close = false, warn_missing_cli = true, github = { icon = '', diff --git a/lua/pending/forge.lua b/lua/pending/forge.lua index baebc0a..43150c0 100644 --- a/lua/pending/forge.lua +++ b/lua/pending/forge.lua @@ -310,7 +310,7 @@ function M.refresh(s) any_fetched = true local forge_cfg = config.get().forge or {} if - forge_cfg.auto_close + forge_cfg.close and (cache.state == 'closed' or cache.state == 'merged') and (task.status == 'pending' or task.status == 'wip' or task.status == 'blocked') then From 494e26d7a1c6a796791525a4ba8cea6fd70855c8 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Tue, 10 Mar 2026 23:28:52 -0400 Subject: [PATCH 178/199] fix(forge): fix ghost extmarks, false auth warnings, and needless API calls (#136) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: document S3 backend, auto-auth, and `:Pending done` command Problem: The S3 backend had no `:Pending s3` entry in the COMMANDS section, `:Pending auth` only mentioned Google, the `sync` config field omitted `s3`, `_s3_sync_id` was missing from the data format section, `:Pending done` was implemented but undocumented, and the README lacked a features overview. Solution: Add `:Pending s3` and `:Pending done` command docs, rewrite `:Pending auth` to cover all backends and sub-actions, update config and data format references, add `aws` CLI to requirements, and add a Features section to `README.md`. * feat(forge): add forge link parser and metadata fetcher Problem: no way to associate tasks with GitHub, GitLab, or Codeberg issues/PRs, or to track their remote state. Solution: add `forge.lua` with shorthand (`gh:user/repo#42`) and full URL parsing, async metadata fetching via `curl`, label formatting, conceal pattern generation, token resolution, and `refresh()` for state pull (closed/merged -> done). * feat(config): add forge config defaults and `%l` eol specifier Problem: no configuration surface for forge link rendering, icons, issue format, or self-hosted instances. Solution: add `pending.ForgeConfig` class with per-forge `token`, `icon`, `issue_format`, and `instances` fields. Add `%l` to the default `eol_format` so forge labels render in virtual text. * feat(parse): extract forge refs from task body Problem: `parse.body()` had no awareness of forge link tokens, so `gh:user/repo#42` stayed in the description instead of metadata. Solution: add `forge_ref` field to `pending.Metadata` and extend the right-to-left token loop in `body()` to call `forge.parse_ref()` as the final fallback before breaking. * feat(diff): persist forge refs in store on write Problem: forge refs parsed from buffer lines were discarded during diff reconciliation and never stored in the JSON. Solution: thread `forge_ref` through `parse_buffer` entries into `diff.apply`, storing it in `task._extra._forge_ref` for both new and existing tasks. * feat(views): pass forge ref and cache to line metadata Problem: `LineMeta` had no forge fields, so `buffer.lua` could not render forge labels or apply forge-specific highlights. Solution: add `forge_ref` and `forge_cache` fields to `LineMeta`, populated from `task._extra` in both `category_view` and `priority_view`. * feat(buffer): render forge links as concealed text with eol virt text Problem: forge tokens were visible as raw text with no virtual text labels, and the eol separator logic collapsed all gaps when non-adjacent specifiers were absent. Solution: add forge conceal syntax patterns in `setup_syntax()`, add `PendingForge`/`PendingForgeClosed` highlight groups, handle the `%l` specifier in `build_eol_virt()`, fix separator collapsing to buffer one separator between present segments, and change `concealcursor` to `nc` (reveal in visual and insert mode). * feat(complete): add forge shorthand omnifunc completions Problem: no completion support for `gh:`, `gl:`, or `cb:` tokens, requiring users to type owner/repo from memory. Solution: extend `omnifunc` to detect `gh:`/`gl:`/`cb:` prefixes and complete with `owner/repo#` candidates from existing forge refs in the store. * feat: trigger forge refresh on buffer open Problem: forge metadata was never fetched, so virt text highlights could not reflect remote issue/PR state. Solution: call `forge.refresh()` in `M.open()` so metadata is fetched once per `:Pending` invocation rather than on every render. * test(forge): add forge parsing spec Problem: no test coverage for forge link shorthand parsing, URL parsing, label formatting, or API URL generation. Solution: add `spec/forge_spec.lua` covering `_parse_shorthand`, `parse_ref` for all three forges, full URL parsing including nested GitLab groups, `format_label`, and `_api_url`. * docs: document forge links feature Problem: no user-facing documentation for forge link syntax, configuration, or behavior. Solution: add forge links section to `README.md` and `pending.txt` covering shorthand/URL syntax, config options, virtual text rendering, state pull, and auth resolution. * feat(forge): add `find_refs()` inline token scanner Problem: forge tokens were extracted by `parse.body()` which stripped them from the description, making editing awkward and multi-ref lines impossible. Solution: add `find_refs(text)` that scans a string for all forge tokens by whitespace tokenization, returning byte offsets and parsed refs without modifying the input. Remove unused `conceal_patterns()`. * refactor: move forge ref detection from `parse.body()` to `diff` Problem: `parse.body()` stripped forge tokens from the description, losing the raw text. This made inline overlay rendering impossible since the token no longer existed in the buffer. Solution: remove the `forge.parse_ref()` branch from `parse.body()` and call `forge.find_refs()` in `diff.parse_buffer()` instead. The description now retains forge tokens verbatim; `_extra._forge_ref` is still populated from the first matched ref. * feat(buffer): render forge links as inline conceal overlays Problem: forge tokens were stripped from the buffer and shown as EOL virtual text via `%l`. The token disappeared from the editable line, and multi-ref tasks broke. Solution: compute `forge_spans` in `views.lua` with byte offsets for each forge token in the rendered line. In `apply_inline_row()`, place extmarks with `conceal=''` and `virt_text_pos='inline'` to visually replace each raw token with its formatted label. Clear stale `forge_spans` on dirty rows to prevent `end_col` out-of-range errors after edits like `dd`. * fix(config): remove `%l` from default `eol_format` Problem: forge links are now rendered inline, making the `%l` EOL specifier redundant in the default format. Solution: change default `eol_format` from `'%l %c %r %d'` to `'%c %r %d'`. The `%l` specifier remains functional for users who explicitly set it. * test(forge): update specs for inline forge refs Problem: existing tests asserted that `parse.body()` stripped forge tokens from the description and populated `meta.forge_ref`. The `conceal_patterns` test referenced a removed function. Solution: update `parse.body` integration tests to assert tokens stay in the description. Add `find_refs()` tests covering single/multiple refs, URLs, byte offsets, and empty cases. Remove `conceal_patterns` test. Update diff tests to assert description includes the token. * docs: update forge links for inline overlay rendering Problem: documentation described forge tokens as stripped from the description and rendered via EOL `%l` specifier by default. Solution: update forge links section to describe inline conceal overlay rendering. Update default `eol_format` reference. Change `issue_format` field description from "EOL label" to "inline overlay label". * ci: format * refactor(forge): remove `%l` eol specifier, add `auto_close` config, fix icons Problem: `%l` was dead code after inline overlays replaced EOL rendering. Auto-close was always on with no opt-out. Forge icon defaults were empty strings. Solution: remove `%l` from the eol format parser and renderer. Add `forge.auto_close` (default `false`) to gate state-pull. Set nerd font icons: `` (GitHub), `` (GitLab), `` (Codeberg). Keep conceal active in insert mode via `concealcursor = 'nic'`. * fix(config): set correct nerd font icons for forge defaults * refactor(forge): replace curl/token auth with CLI-native API calls Problem: Forge metadata fetching required manual token management — config fields, CLI token extraction, and curl with auth headers. Each forge had a different auth path, and Codeberg had no CLI support at all. Solution: Delete `get_token()` and `_api_url()`, replace with `_api_args()` that builds `gh api`, `glab api`, or `tea api` arg arrays. The CLIs handle auth internally. Add `warn_missing_cli` config (default true) that warns once per forge per session on failure. Add forge CLI checks to `:checkhealth`. Remove `token` from config/docs. * refactor(forge): extract ForgeBackend class and registry Problem: adding a new forge required touching 5 lookup tables (`FORGE_HOSTS`, `FORGE_CLI`, `FORGE_AUTH_CMD`, `SHORTHAND_PREFIX`, `_warned_forges`) and every branching site in `_api_args`, `fetch_metadata`, and `parse_ref`. Solution: introduce a `ForgeBackend` class with `parse_url`, `api_args`, and `parse_state` methods, plus a `register()` / `backends()` registry. New forges (Gitea, Forgejo) are a single `register()` call via the `gitea_backend()` convenience constructor. * ci: format * fix(forge): fix ghost extmarks, false auth warnings, and needless API calls Problem: extmarks ghosted after `cc`/undo on task lines, auth warnings fired even when CLIs were authenticated, and `refresh()` hit forge APIs on every buffer open regardless of `auto_close`. Solution: add `invalidate = true` to all extmarks so Neovim cleans them up on text deletion. Run `auth status` before warning to verify the CLI is actually unauthenticated. Gate `refresh()` behind `auto_close` config. * ci: typing and formatting --- doc/pending.txt | 2 +- lua/pending/buffer.lua | 8 ++++++++ lua/pending/forge.lua | 40 +++++++++++++++++++++++++++------------- 3 files changed, 36 insertions(+), 14 deletions(-) diff --git a/doc/pending.txt b/doc/pending.txt index 3078ea9..a7250ea 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -1522,7 +1522,7 @@ Configuration: ~ < Top-level fields: ~ - {close} (boolean, default: false) When true, tasks linked to + {close} (boolean, default: false) When true, tasks linked to closed/merged remote issues are automatically marked done on buffer open. {warn_missing_cli} (boolean, default: true) When true, warns once per diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index 8c0433e..b731262 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -128,6 +128,7 @@ local function apply_inline_row(bufnr, row, m, icons) vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, 0, { end_col = #line, hl_group = 'PendingFilter', + invalidate = true, }) elseif m.type == 'task' then if m.status == 'done' then @@ -136,6 +137,7 @@ local function apply_inline_row(bufnr, row, m, icons) vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, col_start, { end_col = #line, hl_group = 'PendingDone', + invalidate = true, }) elseif m.status == 'blocked' then local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or '' @@ -143,6 +145,7 @@ local function apply_inline_row(bufnr, row, m, icons) vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, col_start, { end_col = #line, hl_group = 'PendingBlocked', + invalidate = true, }) end local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or '' @@ -167,6 +170,7 @@ local function apply_inline_row(bufnr, row, m, icons) virt_text = { { '[' .. icon .. ']', icon_hl } }, virt_text_pos = 'overlay', priority = 100, + invalidate = true, }) if m.forge_spans then local forge = require('pending.forge') @@ -178,6 +182,7 @@ local function apply_inline_row(bufnr, row, m, icons) virt_text = { { label_text, hl_group } }, virt_text_pos = 'inline', priority = 90, + invalidate = true, }) end end @@ -186,11 +191,13 @@ local function apply_inline_row(bufnr, row, m, icons) vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, 0, { end_col = #line, hl_group = 'PendingHeader', + invalidate = true, }) vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, 0, { virt_text = { { icons.category .. ' ', 'PendingHeader' } }, virt_text_pos = 'overlay', priority = 100, + invalidate = true, }) end end @@ -541,6 +548,7 @@ local function apply_extmarks(bufnr, line_meta) vim.api.nvim_buf_set_extmark(bufnr, ns_eol, row, 0, { virt_text = virt_parts, virt_text_pos = 'eol', + invalidate = true, }) end end diff --git a/lua/pending/forge.lua b/lua/pending/forge.lua index 43150c0..145dd15 100644 --- a/lua/pending/forge.lua +++ b/lua/pending/forge.lua @@ -21,6 +21,7 @@ local log = require('pending.log') ---@field default_host string ---@field cli string ---@field auth_cmd string +---@field auth_status_args string[] ---@field default_icon string ---@field default_issue_format string ---@field _warned boolean @@ -249,17 +250,23 @@ function M.fetch_metadata(ref, callback) vim.system(args, { text = true }, function(result) if result.code ~= 0 or not result.stdout or result.stdout == '' then - vim.schedule(function() - local forge_cfg = config.get().forge or {} - local backend = _by_name[ref.forge] - if backend and forge_cfg.warn_missing_cli ~= false and not backend._warned then - backend._warned = true - log.warn( - ('%s not found or not authenticated — run `%s`'):format(backend.cli, backend.auth_cmd) - ) - end - callback(nil) - end) + local forge_cfg = config.get().forge or {} + local backend = _by_name[ref.forge] + if backend and forge_cfg.warn_missing_cli ~= false and not backend._warned then + backend._warned = true + vim.system(backend.auth_status_args, { text = true }, function(auth_result) + vim.schedule(function() + if auth_result.code ~= 0 then + log.warn(('%s not authenticated — run `%s`'):format(backend.cli, backend.auth_cmd)) + end + callback(nil) + end) + end) + else + vim.schedule(function() + callback(nil) + end) + end return end local ok, decoded = pcall(vim.json.decode, result.stdout) @@ -295,6 +302,10 @@ end ---@param s pending.Store function M.refresh(s) + local forge_cfg = config.get().forge or {} + if not forge_cfg.close then + return + end local tasks = s:tasks() local pending_fetches = 0 local any_changed = false @@ -308,7 +319,6 @@ function M.refresh(s) if cache then task._extra._forge_cache = cache any_fetched = true - local forge_cfg = config.get().forge or {} if forge_cfg.close and (cache.state == 'closed' or cache.state == 'merged') @@ -346,7 +356,7 @@ function M.refresh(s) end end ----@param opts {name: string, shorthand: string, default_host: string, cli?: string, auth_cmd?: string, default_icon?: string, default_issue_format?: string} +---@param opts {name: string, shorthand: string, default_host: string, cli?: string, auth_cmd?: string, auth_status_args?: string[], default_icon?: string, default_issue_format?: string} ---@return pending.ForgeBackend function M.gitea_backend(opts) return { @@ -355,6 +365,7 @@ function M.gitea_backend(opts) default_host = opts.default_host, cli = opts.cli or 'tea', auth_cmd = opts.auth_cmd or 'tea login add', + auth_status_args = opts.auth_status_args or { opts.cli or 'tea', 'login', 'list' }, default_icon = opts.default_icon or '', default_issue_format = opts.default_issue_format or '%i %o/%r#%n', _warned = false, @@ -405,6 +416,7 @@ M.register({ default_host = 'github.com', cli = 'gh', auth_cmd = 'gh auth login', + auth_status_args = { 'gh', 'auth', 'status' }, default_icon = '', default_issue_format = '%i %o/%r#%n', _warned = false, @@ -455,6 +467,7 @@ M.register({ default_host = 'gitlab.com', cli = 'glab', auth_cmd = 'glab auth login', + auth_status_args = { 'glab', 'auth', 'status' }, default_icon = '', default_issue_format = '%i %o/%r#%n', _warned = false, @@ -510,6 +523,7 @@ M.register({ default_host = 'codeberg.org', cli = 'tea', auth_cmd = 'tea login add', + auth_status_args = { 'tea', 'login', 'list' }, default_icon = '', default_issue_format = '%i %o/%r#%n', _warned = false, From a4f782a5fb8e36579301c2c50584d58905d5fba3 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 11 Mar 2026 12:23:20 -0400 Subject: [PATCH 179/199] feat(forge): add `validate` option for forge ref validation on write (#138) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: Typos in forge refs like `gh:user/repo#42` silently persist — there's no feedback when a ref points to a nonexistent issue. Solution: Add `forge.validate` config option. When enabled, `diff.apply()` returns new/changed `ForgeRef[]` and `forge.validate_refs()` fetches metadata for each, logging specific warnings for not-found, auth, or CLI-missing errors. --- doc/pending.txt | 5 +++ lua/pending/config.lua | 2 ++ lua/pending/diff.lua | 25 ++++++++++++- lua/pending/forge.lua | 82 +++++++++++++++++++++++++++++++++--------- lua/pending/init.lua | 6 +++- spec/diff_spec.lua | 62 ++++++++++++++++++++++++++++++++ 6 files changed, 163 insertions(+), 19 deletions(-) diff --git a/doc/pending.txt b/doc/pending.txt index a7250ea..e80349e 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -1501,6 +1501,7 @@ Configuration: ~ vim.g.pending = { forge = { close = false, + validate = false, warn_missing_cli = true, github = { icon = '', @@ -1525,6 +1526,10 @@ Top-level fields: ~ {close} (boolean, default: false) When true, tasks linked to closed/merged remote issues are automatically marked done on buffer open. + {validate} (boolean, default: false) When true, new or changed + forge refs are validated on `:w` by fetching metadata. + Logs a warning if the ref is not found, auth fails, or + the CLI is missing. {warn_missing_cli} (boolean, default: true) When true, warns once per forge per session if the CLI is missing or fails. diff --git a/lua/pending/config.lua b/lua/pending/config.lua index 60775fe..2ec13cc 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -41,6 +41,7 @@ ---@class pending.ForgeConfig ---@field close? boolean +---@field validate? boolean ---@field warn_missing_cli? boolean ---@field [string] pending.ForgeInstanceConfig @@ -155,6 +156,7 @@ local defaults = { sync = {}, forge = { close = false, + validate = false, warn_missing_cli = true, github = { icon = '', diff --git a/lua/pending/diff.lua b/lua/pending/diff.lua index 29c292b..9ec3043 100644 --- a/lua/pending/diff.lua +++ b/lua/pending/diff.lua @@ -82,14 +82,26 @@ function M.parse_buffer(lines) return result end +---@param a? pending.ForgeRef +---@param b? pending.ForgeRef +---@return boolean +local function refs_equal(a, b) + if not a or not b then + return false + end + return a.forge == b.forge and a.owner == b.owner + and a.repo == b.repo and a.number == b.number +end + ---@param lines string[] ---@param s pending.Store ---@param hidden_ids? table ----@return nil +---@return pending.ForgeRef[] function M.apply(lines, s, hidden_ids) local parsed = M.parse_buffer(lines) local now = timestamp() local data = s:data() + local new_refs = {} ---@type pending.ForgeRef[] local old_by_id = {} for _, task in ipairs(data.tasks) do @@ -120,6 +132,9 @@ function M.apply(lines, s, hidden_ids) order = order_counter, _extra = entry.forge_ref and { _forge_ref = entry.forge_ref } or nil, }) + if entry.forge_ref then + table.insert(new_refs, entry.forge_ref) + end else seen_ids[entry.id] = true local task = old_by_id[entry.id] @@ -154,6 +169,10 @@ function M.apply(lines, s, hidden_ids) end end if entry.forge_ref ~= nil then + local old_ref = task._extra and task._extra._forge_ref or nil + if not refs_equal(old_ref, entry.forge_ref) then + table.insert(new_refs, entry.forge_ref) + end if not task._extra then task._extra = {} end @@ -188,6 +207,9 @@ function M.apply(lines, s, hidden_ids) order = order_counter, _extra = entry.forge_ref and { _forge_ref = entry.forge_ref } or nil, }) + if entry.forge_ref then + table.insert(new_refs, entry.forge_ref) + end end ::continue:: @@ -202,6 +224,7 @@ function M.apply(lines, s, hidden_ids) end s:save() + return new_refs end return M diff --git a/lua/pending/forge.lua b/lua/pending/forge.lua index 145dd15..7f76d00 100644 --- a/lua/pending/forge.lua +++ b/lua/pending/forge.lua @@ -243,30 +243,51 @@ function M.format_label(ref, cache) return text, hl end +---@class pending.ForgeFetchError +---@field code 'not_found'|'auth'|'cli_missing'|'network' +---@field message string + ---@param ref pending.ForgeRef ----@param callback fun(cache: pending.ForgeCache?) +---@param callback fun(cache: pending.ForgeCache?, err: pending.ForgeFetchError?) function M.fetch_metadata(ref, callback) local args = M._api_args(ref) + local backend = _by_name[ref.forge] + + if backend and vim.fn.executable(backend.cli) == 0 then + vim.schedule(function() + local forge_cfg = config.get().forge or {} + if forge_cfg.warn_missing_cli ~= false and not backend._warned then + backend._warned = true + log.warn(('%s not installed'):format(backend.cli)) + end + callback(nil, { code = 'cli_missing', message = backend.cli .. ' not installed' }) + end) + return + end vim.system(args, { text = true }, function(result) if result.code ~= 0 or not result.stdout or result.stdout == '' then - local forge_cfg = config.get().forge or {} - local backend = _by_name[ref.forge] - if backend and forge_cfg.warn_missing_cli ~= false and not backend._warned then - backend._warned = true - vim.system(backend.auth_status_args, { text = true }, function(auth_result) - vim.schedule(function() - if auth_result.code ~= 0 then - log.warn(('%s not authenticated — run `%s`'):format(backend.cli, backend.auth_cmd)) - end - callback(nil) - end) - end) - else - vim.schedule(function() - callback(nil) - end) + local stderr = result.stderr or '' + local err_code = 'network' ---@type 'not_found'|'auth'|'network' + if stderr:find('404') or stderr:find('Not Found') then + err_code = 'not_found' + elseif stderr:find('401') or stderr:find('requires authentication') then + err_code = 'auth' end + vim.schedule(function() + local forge_cfg = config.get().forge or {} + if + backend + and forge_cfg.warn_missing_cli ~= false + and not backend._warned + then + backend._warned = true + log.warn( + ('%s not found or not authenticated — run `%s`'):format(backend.cli, backend.auth_cmd) + ) + end + callback(nil, { code = err_code, message = stderr }) + end) return end local ok, decoded = pcall(vim.json.decode, result.stdout) @@ -567,4 +588,31 @@ M.register({ end, }) +---@param refs pending.ForgeRef[] +function M.validate_refs(refs) + local forge_cfg = config.get().forge or {} + if not forge_cfg.validate then + return + end + for _, ref in ipairs(refs) do + M.fetch_metadata(ref, function(_, err) + if err then + local label = ref.owner .. '/' .. ref.repo .. '#' .. ref.number + local backend = _by_name[ref.forge] + if err.code == 'not_found' then + log.warn(('%s not found — check owner, repo, and number'):format(label)) + elseif err.code == 'auth' then + local cmd = backend and backend.auth_cmd or 'auth' + log.warn(('%s: not authenticated — run `%s`'):format(label, cmd)) + elseif err.code == 'cli_missing' then + local cli = backend and backend.cli or 'cli' + log.warn(('%s: %s not installed'):format(label, cli)) + else + log.warn(('%s: validation failed'):format(label)) + end + end + end) + end +end + return M diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 6c5eaee..9b642ed 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -489,9 +489,13 @@ function M._on_write(bufnr) if #stack > UNDO_MAX then table.remove(stack, 1) end - diff.apply(lines, s, hidden) + local new_refs = diff.apply(lines, s, hidden) M._recompute_counts() buffer.render(bufnr) + if new_refs and #new_refs > 0 then + local forge = require('pending.forge') + forge.validate_refs(new_refs) + end end ---@return nil diff --git a/spec/diff_spec.lua b/spec/diff_spec.lua index 01d8aac..e471e97 100644 --- a/spec/diff_spec.lua +++ b/spec/diff_spec.lua @@ -275,6 +275,68 @@ describe('diff', function() assert.are.equal('completion', tasks[1].recur_mode) end) + it('returns forge refs for new tasks', function() + local lines = { + '# Inbox', + '- [ ] Fix bug gh:user/repo#42', + } + local refs = diff.apply(lines, s) + assert.are.equal(1, #refs) + assert.are.equal('github', refs[1].forge) + assert.are.equal(42, refs[1].number) + end) + + it('returns forge refs for changed refs on existing tasks', function() + s:add({ description = 'Fix bug gh:user/repo#1', _extra = { + _forge_ref = { forge = 'github', owner = 'user', repo = 'repo', type = 'issue', number = 1, url = '' }, + } }) + s:save() + local lines = { + '# Todo', + '/1/- [ ] Fix bug gh:user/repo#99', + } + local refs = diff.apply(lines, s) + assert.are.equal(1, #refs) + assert.are.equal(99, refs[1].number) + end) + + it('returns empty when forge ref is unchanged', function() + s:add({ description = 'Fix bug gh:user/repo#42', _extra = { + _forge_ref = { forge = 'github', owner = 'user', repo = 'repo', type = 'issue', number = 42, url = '' }, + } }) + s:save() + local lines = { + '# Todo', + '/1/- [ ] Fix bug gh:user/repo#42', + } + local refs = diff.apply(lines, s) + assert.are.equal(0, #refs) + end) + + it('returns empty for tasks without forge refs', function() + local lines = { + '# Inbox', + '- [ ] Plain task', + } + local refs = diff.apply(lines, s) + assert.are.equal(0, #refs) + end) + + it('returns forge refs for duplicated tasks', function() + s:add({ description = 'Fix bug gh:user/repo#42', _extra = { + _forge_ref = { forge = 'github', owner = 'user', repo = 'repo', type = 'issue', number = 42, url = '' }, + } }) + s:save() + local lines = { + '# Todo', + '/1/- [ ] Fix bug gh:user/repo#42', + '/1/- [ ] Fix bug gh:user/repo#42', + } + local refs = diff.apply(lines, s) + assert.are.equal(1, #refs) + assert.are.equal(42, refs[1].number) + end) + it('clears priority when [N] is removed from buffer line', function() s:add({ description = 'Task name', priority = 1 }) s:save() From 646237016c322682044c7ecb2afd5c1a1bdaf036 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 11 Mar 2026 12:28:22 -0400 Subject: [PATCH 180/199] refactor(forge): simplify auth gating (#139) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: document S3 backend, auto-auth, and `:Pending done` command Problem: The S3 backend had no `:Pending s3` entry in the COMMANDS section, `:Pending auth` only mentioned Google, the `sync` config field omitted `s3`, `_s3_sync_id` was missing from the data format section, `:Pending done` was implemented but undocumented, and the README lacked a features overview. Solution: Add `:Pending s3` and `:Pending done` command docs, rewrite `:Pending auth` to cover all backends and sub-actions, update config and data format references, add `aws` CLI to requirements, and add a Features section to `README.md`. * feat(forge): add forge link parser and metadata fetcher Problem: no way to associate tasks with GitHub, GitLab, or Codeberg issues/PRs, or to track their remote state. Solution: add `forge.lua` with shorthand (`gh:user/repo#42`) and full URL parsing, async metadata fetching via `curl`, label formatting, conceal pattern generation, token resolution, and `refresh()` for state pull (closed/merged -> done). * feat(config): add forge config defaults and `%l` eol specifier Problem: no configuration surface for forge link rendering, icons, issue format, or self-hosted instances. Solution: add `pending.ForgeConfig` class with per-forge `token`, `icon`, `issue_format`, and `instances` fields. Add `%l` to the default `eol_format` so forge labels render in virtual text. * feat(parse): extract forge refs from task body Problem: `parse.body()` had no awareness of forge link tokens, so `gh:user/repo#42` stayed in the description instead of metadata. Solution: add `forge_ref` field to `pending.Metadata` and extend the right-to-left token loop in `body()` to call `forge.parse_ref()` as the final fallback before breaking. * feat(diff): persist forge refs in store on write Problem: forge refs parsed from buffer lines were discarded during diff reconciliation and never stored in the JSON. Solution: thread `forge_ref` through `parse_buffer` entries into `diff.apply`, storing it in `task._extra._forge_ref` for both new and existing tasks. * feat(views): pass forge ref and cache to line metadata Problem: `LineMeta` had no forge fields, so `buffer.lua` could not render forge labels or apply forge-specific highlights. Solution: add `forge_ref` and `forge_cache` fields to `LineMeta`, populated from `task._extra` in both `category_view` and `priority_view`. * feat(buffer): render forge links as concealed text with eol virt text Problem: forge tokens were visible as raw text with no virtual text labels, and the eol separator logic collapsed all gaps when non-adjacent specifiers were absent. Solution: add forge conceal syntax patterns in `setup_syntax()`, add `PendingForge`/`PendingForgeClosed` highlight groups, handle the `%l` specifier in `build_eol_virt()`, fix separator collapsing to buffer one separator between present segments, and change `concealcursor` to `nc` (reveal in visual and insert mode). * feat(complete): add forge shorthand omnifunc completions Problem: no completion support for `gh:`, `gl:`, or `cb:` tokens, requiring users to type owner/repo from memory. Solution: extend `omnifunc` to detect `gh:`/`gl:`/`cb:` prefixes and complete with `owner/repo#` candidates from existing forge refs in the store. * feat: trigger forge refresh on buffer open Problem: forge metadata was never fetched, so virt text highlights could not reflect remote issue/PR state. Solution: call `forge.refresh()` in `M.open()` so metadata is fetched once per `:Pending` invocation rather than on every render. * test(forge): add forge parsing spec Problem: no test coverage for forge link shorthand parsing, URL parsing, label formatting, or API URL generation. Solution: add `spec/forge_spec.lua` covering `_parse_shorthand`, `parse_ref` for all three forges, full URL parsing including nested GitLab groups, `format_label`, and `_api_url`. * docs: document forge links feature Problem: no user-facing documentation for forge link syntax, configuration, or behavior. Solution: add forge links section to `README.md` and `pending.txt` covering shorthand/URL syntax, config options, virtual text rendering, state pull, and auth resolution. * feat(forge): add `find_refs()` inline token scanner Problem: forge tokens were extracted by `parse.body()` which stripped them from the description, making editing awkward and multi-ref lines impossible. Solution: add `find_refs(text)` that scans a string for all forge tokens by whitespace tokenization, returning byte offsets and parsed refs without modifying the input. Remove unused `conceal_patterns()`. * refactor: move forge ref detection from `parse.body()` to `diff` Problem: `parse.body()` stripped forge tokens from the description, losing the raw text. This made inline overlay rendering impossible since the token no longer existed in the buffer. Solution: remove the `forge.parse_ref()` branch from `parse.body()` and call `forge.find_refs()` in `diff.parse_buffer()` instead. The description now retains forge tokens verbatim; `_extra._forge_ref` is still populated from the first matched ref. * feat(buffer): render forge links as inline conceal overlays Problem: forge tokens were stripped from the buffer and shown as EOL virtual text via `%l`. The token disappeared from the editable line, and multi-ref tasks broke. Solution: compute `forge_spans` in `views.lua` with byte offsets for each forge token in the rendered line. In `apply_inline_row()`, place extmarks with `conceal=''` and `virt_text_pos='inline'` to visually replace each raw token with its formatted label. Clear stale `forge_spans` on dirty rows to prevent `end_col` out-of-range errors after edits like `dd`. * fix(config): remove `%l` from default `eol_format` Problem: forge links are now rendered inline, making the `%l` EOL specifier redundant in the default format. Solution: change default `eol_format` from `'%l %c %r %d'` to `'%c %r %d'`. The `%l` specifier remains functional for users who explicitly set it. * test(forge): update specs for inline forge refs Problem: existing tests asserted that `parse.body()` stripped forge tokens from the description and populated `meta.forge_ref`. The `conceal_patterns` test referenced a removed function. Solution: update `parse.body` integration tests to assert tokens stay in the description. Add `find_refs()` tests covering single/multiple refs, URLs, byte offsets, and empty cases. Remove `conceal_patterns` test. Update diff tests to assert description includes the token. * docs: update forge links for inline overlay rendering Problem: documentation described forge tokens as stripped from the description and rendered via EOL `%l` specifier by default. Solution: update forge links section to describe inline conceal overlay rendering. Update default `eol_format` reference. Change `issue_format` field description from "EOL label" to "inline overlay label". * ci: format * refactor(forge): remove `%l` eol specifier, add `auto_close` config, fix icons Problem: `%l` was dead code after inline overlays replaced EOL rendering. Auto-close was always on with no opt-out. Forge icon defaults were empty strings. Solution: remove `%l` from the eol format parser and renderer. Add `forge.auto_close` (default `false`) to gate state-pull. Set nerd font icons: `` (GitHub), `` (GitLab), `` (Codeberg). Keep conceal active in insert mode via `concealcursor = 'nic'`. * fix(config): set correct nerd font icons for forge defaults * refactor(forge): replace curl/token auth with CLI-native API calls Problem: Forge metadata fetching required manual token management — config fields, CLI token extraction, and curl with auth headers. Each forge had a different auth path, and Codeberg had no CLI support at all. Solution: Delete `get_token()` and `_api_url()`, replace with `_api_args()` that builds `gh api`, `glab api`, or `tea api` arg arrays. The CLIs handle auth internally. Add `warn_missing_cli` config (default true) that warns once per forge per session on failure. Add forge CLI checks to `:checkhealth`. Remove `token` from config/docs. * refactor(forge): extract ForgeBackend class and registry Problem: adding a new forge required touching 5 lookup tables (`FORGE_HOSTS`, `FORGE_CLI`, `FORGE_AUTH_CMD`, `SHORTHAND_PREFIX`, `_warned_forges`) and every branching site in `_api_args`, `fetch_metadata`, and `parse_ref`. Solution: introduce a `ForgeBackend` class with `parse_url`, `api_args`, and `parse_state` methods, plus a `register()` / `backends()` registry. New forges (Gitea, Forgejo) are a single `register()` call via the `gitea_backend()` convenience constructor. * ci: format * fix(forge): fix ghost extmarks, false auth warnings, and needless API calls Problem: extmarks ghosted after `cc`/undo on task lines, auth warnings fired even when CLIs were authenticated, and `refresh()` hit forge APIs on every buffer open regardless of `auto_close`. Solution: add `invalidate = true` to all extmarks so Neovim cleans them up on text deletion. Run `auth status` before warning to verify the CLI is actually unauthenticated. Gate `refresh()` behind `auto_close` config. * ci: typing and formatting * refactor(forge): simplify auth gating and rename `gitea_backend` Problem: forge auth/warning logic was scattered through `fetch_metadata` — per-API-call auth status checks, `_warned` flags, and `warn_missing_cli` conditionals on every fetch. Solution: replace `_warned` with `_auth` (cached per session), add `is_configured()` to skip unconfigured forges entirely, extract `check_auth()` for one-time auth verification, and strip `fetch_metadata` to a pure API caller returning `ForgeFetchError`. Gate `refresh` and new `validate_refs` with both checks. Rename `gitea_backend` to `gitea_forge`. --- doc/pending.txt | 4 +- lua/pending/diff.lua | 3 +- lua/pending/forge.lua | 251 +++++++++++++++++++++++------------------ lua/pending/health.lua | 4 +- spec/diff_spec.lua | 48 ++++++-- spec/forge_spec.lua | 34 +++++- 6 files changed, 220 insertions(+), 124 deletions(-) diff --git a/doc/pending.txt b/doc/pending.txt index e80349e..90db8ee 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -1525,7 +1525,9 @@ Configuration: ~ Top-level fields: ~ {close} (boolean, default: false) When true, tasks linked to closed/merged remote issues are automatically marked - done on buffer open. + done on buffer open. Only forges with an explicit + per-forge key (e.g. `github = {}`) are checked; + unconfigured forges are skipped entirely. {validate} (boolean, default: false) When true, new or changed forge refs are validated on `:w` by fetching metadata. Logs a warning if the ref is not found, auth fails, or diff --git a/lua/pending/diff.lua b/lua/pending/diff.lua index 9ec3043..103ba6a 100644 --- a/lua/pending/diff.lua +++ b/lua/pending/diff.lua @@ -89,8 +89,7 @@ local function refs_equal(a, b) if not a or not b then return false end - return a.forge == b.forge and a.owner == b.owner - and a.repo == b.repo and a.number == b.number + return a.forge == b.forge and a.owner == b.owner and a.repo == b.repo and a.number == b.number end ---@param lines string[] diff --git a/lua/pending/forge.lua b/lua/pending/forge.lua index 7f76d00..78f6654 100644 --- a/lua/pending/forge.lua +++ b/lua/pending/forge.lua @@ -15,6 +15,9 @@ local log = require('pending.log') ---@field labels? string[] ---@field fetched_at string +---@class pending.ForgeFetchError +---@field kind 'not_found'|'auth'|'network' + ---@class pending.ForgeBackend ---@field name string ---@field shorthand string @@ -24,7 +27,7 @@ local log = require('pending.log') ---@field auth_status_args string[] ---@field default_icon string ---@field default_issue_format string ----@field _warned boolean +---@field _auth 'unknown'|'ok'|'failed' ---@field parse_url fun(self: pending.ForgeBackend, url: string): pending.ForgeRef? ---@field api_args fun(self: pending.ForgeBackend, ref: pending.ForgeRef): string[] ---@field parse_state fun(self: pending.ForgeBackend, decoded: table): 'open'|'closed'|'merged' @@ -50,7 +53,7 @@ local _instances_resolved = false ---@param backend pending.ForgeBackend ---@return nil function M.register(backend) - backend._warned = false + backend._auth = 'unknown' table.insert(_backends, backend) _by_name[backend.name] = backend _by_shorthand[backend.shorthand] = backend @@ -63,6 +66,53 @@ function M.backends() return _backends end +---@param forge_name string +---@return boolean +function M.is_configured(forge_name) + local raw = vim.g.pending + if not raw or not raw.forge then + return false + end + return raw.forge[forge_name] ~= nil +end + +---@param backend pending.ForgeBackend +---@param callback fun(ok: boolean) +function M.check_auth(backend, callback) + if backend._auth == 'ok' then + callback(true) + return + end + if backend._auth == 'failed' then + callback(false) + return + end + if vim.fn.executable(backend.cli) == 0 then + backend._auth = 'failed' + local forge_cfg = config.get().forge or {} + if forge_cfg.warn_missing_cli ~= false then + log.warn(('%s not found — run `%s`'):format(backend.cli, backend.auth_cmd)) + end + callback(false) + return + end + vim.system(backend.auth_status_args, { text = true }, function(result) + vim.schedule(function() + if result.code == 0 then + backend._auth = 'ok' + callback(true) + else + backend._auth = 'failed' + local forge_cfg = config.get().forge or {} + if forge_cfg.warn_missing_cli ~= false then + log.warn(('%s not authenticated — run `%s`'):format(backend.cli, backend.auth_cmd)) + end + callback(false) + end + end) + end) +end + function M._reset_instances() _instances_resolved = false _by_shorthand = {} @@ -243,57 +293,28 @@ function M.format_label(ref, cache) return text, hl end ----@class pending.ForgeFetchError ----@field code 'not_found'|'auth'|'cli_missing'|'network' ----@field message string - ---@param ref pending.ForgeRef ---@param callback fun(cache: pending.ForgeCache?, err: pending.ForgeFetchError?) function M.fetch_metadata(ref, callback) local args = M._api_args(ref) - local backend = _by_name[ref.forge] - - if backend and vim.fn.executable(backend.cli) == 0 then - vim.schedule(function() - local forge_cfg = config.get().forge or {} - if forge_cfg.warn_missing_cli ~= false and not backend._warned then - backend._warned = true - log.warn(('%s not installed'):format(backend.cli)) - end - callback(nil, { code = 'cli_missing', message = backend.cli .. ' not installed' }) - end) - return - end - vim.system(args, { text = true }, function(result) if result.code ~= 0 or not result.stdout or result.stdout == '' then + local kind = 'network' local stderr = result.stderr or '' - local err_code = 'network' ---@type 'not_found'|'auth'|'network' if stderr:find('404') or stderr:find('Not Found') then - err_code = 'not_found' - elseif stderr:find('401') or stderr:find('requires authentication') then - err_code = 'auth' + kind = 'not_found' + elseif stderr:find('401') or stderr:find('403') or stderr:find('auth') then + kind = 'auth' end vim.schedule(function() - local forge_cfg = config.get().forge or {} - if - backend - and forge_cfg.warn_missing_cli ~= false - and not backend._warned - then - backend._warned = true - log.warn( - ('%s not found or not authenticated — run `%s`'):format(backend.cli, backend.auth_cmd) - ) - end - callback(nil, { code = err_code, message = stderr }) + callback(nil, { kind = kind }) end) return end local ok, decoded = pcall(vim.json.decode, result.stdout) if not ok or not decoded then vim.schedule(function() - callback(nil) + callback(nil, { kind = 'network' }) end) return end @@ -328,58 +349,105 @@ function M.refresh(s) return end local tasks = s:tasks() - local pending_fetches = 0 - local any_changed = false - local any_fetched = false + local by_forge = {} ---@type table for _, task in ipairs(tasks) do if task.status ~= 'deleted' and task._extra and task._extra._forge_ref then - local ref = task._extra._forge_ref --[[@as pending.ForgeRef]] - pending_fetches = pending_fetches + 1 - M.fetch_metadata(ref, function(cache) - pending_fetches = pending_fetches - 1 - if cache then - task._extra._forge_cache = cache - any_fetched = true - if - forge_cfg.close - and (cache.state == 'closed' or cache.state == 'merged') - and (task.status == 'pending' or task.status == 'wip' or task.status == 'blocked') - then - task.status = 'done' - task['end'] = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]] - task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]] - any_changed = true - end - else - task._extra._forge_cache = { - state = 'open', - fetched_at = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]], - } + local fname = task._extra._forge_ref.forge + if not by_forge[fname] then + by_forge[fname] = {} + end + table.insert(by_forge[fname], task) + end + end + local any_work = false + for fname, forge_tasks in pairs(by_forge) do + if M.is_configured(fname) and _by_name[fname] then + any_work = true + M.check_auth(_by_name[fname], function(authed) + if not authed then + return end - if pending_fetches == 0 then - if any_changed then - s:save() - end - local buffer = require('pending.buffer') - if - (any_changed or any_fetched) - and buffer.bufnr() - and vim.api.nvim_buf_is_valid(buffer.bufnr()) - then - buffer.render() - end + local remaining = #forge_tasks + local any_changed = false + local any_fetched = false + for _, task in ipairs(forge_tasks) do + local ref = task._extra._forge_ref --[[@as pending.ForgeRef]] + M.fetch_metadata(ref, function(cache) + remaining = remaining - 1 + if cache then + task._extra._forge_cache = cache + any_fetched = true + if + (cache.state == 'closed' or cache.state == 'merged') + and (task.status == 'pending' or task.status == 'wip' or task.status == 'blocked') + then + task.status = 'done' + task['end'] = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]] + task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]] + any_changed = true + end + else + task._extra._forge_cache = { + state = 'open', + fetched_at = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]], + } + end + if remaining == 0 then + if any_changed then + s:save() + end + local buffer = require('pending.buffer') + if + (any_changed or any_fetched) + and buffer.bufnr() + and vim.api.nvim_buf_is_valid(buffer.bufnr()) + then + buffer.render() + end + end + end) end end) end end - if pending_fetches == 0 then + if not any_work then log.info('No linked tasks to refresh.') end end +---@param refs pending.ForgeRef[] +function M.validate_refs(refs) + local by_forge = {} ---@type table + for _, ref in ipairs(refs) do + local fname = ref.forge + if not by_forge[fname] then + by_forge[fname] = {} + end + table.insert(by_forge[fname], ref) + end + for fname, forge_refs in pairs(by_forge) do + if not M.is_configured(fname) or not _by_name[fname] then + goto continue + end + M.check_auth(_by_name[fname], function(authed) + if not authed then + return + end + for _, ref in ipairs(forge_refs) do + M.fetch_metadata(ref, function(_, err) + if err and err.kind == 'not_found' then + log.warn(('%s:%s/%s#%d not found'):format(ref.forge, ref.owner, ref.repo, ref.number)) + end + end) + end + end) + ::continue:: + end +end + ---@param opts {name: string, shorthand: string, default_host: string, cli?: string, auth_cmd?: string, auth_status_args?: string[], default_icon?: string, default_issue_format?: string} ---@return pending.ForgeBackend -function M.gitea_backend(opts) +function M.gitea_forge(opts) return { name = opts.name, shorthand = opts.shorthand, @@ -389,7 +457,6 @@ function M.gitea_backend(opts) auth_status_args = opts.auth_status_args or { opts.cli or 'tea', 'login', 'list' }, default_icon = opts.default_icon or '', default_issue_format = opts.default_issue_format or '%i %o/%r#%n', - _warned = false, parse_url = function(self, url) _ensure_instances() local host, owner, repo, kind, number = @@ -440,7 +507,6 @@ M.register({ auth_status_args = { 'gh', 'auth', 'status' }, default_icon = '', default_issue_format = '%i %o/%r#%n', - _warned = false, parse_url = function(self, url) _ensure_instances() local host, owner, repo, kind, number = @@ -491,7 +557,6 @@ M.register({ auth_status_args = { 'glab', 'auth', 'status' }, default_icon = '', default_issue_format = '%i %o/%r#%n', - _warned = false, parse_url = function(self, url) _ensure_instances() local host, path, kind, number = url:match('^https?://([^/]+)/(.+)/%-/([%w_]+)/(%d+)$') @@ -547,7 +612,6 @@ M.register({ auth_status_args = { 'tea', 'login', 'list' }, default_icon = '', default_issue_format = '%i %o/%r#%n', - _warned = false, parse_url = function(self, url) _ensure_instances() local host, owner, repo, kind, number = @@ -588,31 +652,4 @@ M.register({ end, }) ----@param refs pending.ForgeRef[] -function M.validate_refs(refs) - local forge_cfg = config.get().forge or {} - if not forge_cfg.validate then - return - end - for _, ref in ipairs(refs) do - M.fetch_metadata(ref, function(_, err) - if err then - local label = ref.owner .. '/' .. ref.repo .. '#' .. ref.number - local backend = _by_name[ref.forge] - if err.code == 'not_found' then - log.warn(('%s not found — check owner, repo, and number'):format(label)) - elseif err.code == 'auth' then - local cmd = backend and backend.auth_cmd or 'auth' - log.warn(('%s: not authenticated — run `%s`'):format(label, cmd)) - elseif err.code == 'cli_missing' then - local cli = backend and backend.cli or 'cli' - log.warn(('%s: %s not installed'):format(label, cli)) - else - log.warn(('%s: validation failed'):format(label)) - end - end - end) - end -end - return M diff --git a/lua/pending/health.lua b/lua/pending/health.lua index 457eb67..d00031b 100644 --- a/lua/pending/health.lua +++ b/lua/pending/health.lua @@ -49,7 +49,9 @@ function M.check() vim.health.start('pending.nvim: forge') local forge = require('pending.forge') for _, backend in ipairs(forge.backends()) do - if vim.fn.executable(backend.cli) == 1 then + if not forge.is_configured(backend.name) then + vim.health.info(('%s: not configured (skipped)'):format(backend.name)) + elseif vim.fn.executable(backend.cli) == 1 then vim.health.ok(('%s found'):format(backend.cli)) else vim.health.warn(('%s not found — run `%s`'):format(backend.cli, backend.auth_cmd)) diff --git a/spec/diff_spec.lua b/spec/diff_spec.lua index e471e97..791d7f6 100644 --- a/spec/diff_spec.lua +++ b/spec/diff_spec.lua @@ -287,9 +287,19 @@ describe('diff', function() end) it('returns forge refs for changed refs on existing tasks', function() - s:add({ description = 'Fix bug gh:user/repo#1', _extra = { - _forge_ref = { forge = 'github', owner = 'user', repo = 'repo', type = 'issue', number = 1, url = '' }, - } }) + s:add({ + description = 'Fix bug gh:user/repo#1', + _extra = { + _forge_ref = { + forge = 'github', + owner = 'user', + repo = 'repo', + type = 'issue', + number = 1, + url = '', + }, + }, + }) s:save() local lines = { '# Todo', @@ -301,9 +311,19 @@ describe('diff', function() end) it('returns empty when forge ref is unchanged', function() - s:add({ description = 'Fix bug gh:user/repo#42', _extra = { - _forge_ref = { forge = 'github', owner = 'user', repo = 'repo', type = 'issue', number = 42, url = '' }, - } }) + s:add({ + description = 'Fix bug gh:user/repo#42', + _extra = { + _forge_ref = { + forge = 'github', + owner = 'user', + repo = 'repo', + type = 'issue', + number = 42, + url = '', + }, + }, + }) s:save() local lines = { '# Todo', @@ -323,9 +343,19 @@ describe('diff', function() end) it('returns forge refs for duplicated tasks', function() - s:add({ description = 'Fix bug gh:user/repo#42', _extra = { - _forge_ref = { forge = 'github', owner = 'user', repo = 'repo', type = 'issue', number = 42, url = '' }, - } }) + s:add({ + description = 'Fix bug gh:user/repo#42', + _extra = { + _forge_ref = { + forge = 'github', + owner = 'user', + repo = 'repo', + type = 'issue', + number = 42, + url = '', + }, + }, + }) s:save() local lines = { '# Todo', diff --git a/spec/forge_spec.lua b/spec/forge_spec.lua index 8bd4162..067548e 100644 --- a/spec/forge_spec.lua +++ b/spec/forge_spec.lua @@ -330,7 +330,7 @@ describe('forge registry', function() end) it('register() with custom backend resolves URLs', function() - local custom = forge.gitea_backend({ + local custom = forge.gitea_forge({ name = 'mygitea', shorthand = 'mg', default_host = 'gitea.example.com', @@ -367,8 +367,8 @@ describe('forge registry', function() assert.same({ 'tea', 'api', '/repos/alice/proj/issues/7' }, args) end) - it('gitea_backend() creates a working backend', function() - local b = forge.gitea_backend({ + it('gitea_forge() creates a working backend', function() + local b = forge.gitea_forge({ name = 'forgejo', shorthand = 'fj', default_host = 'forgejo.example.com', @@ -396,7 +396,7 @@ describe('custom forge prefixes', function() local complete = require('pending.complete') it('parses custom-length shorthand (3+ chars)', function() - local custom = forge.gitea_backend({ + local custom = forge.gitea_forge({ name = 'customforge', shorthand = 'cgf', default_host = 'custom.example.com', @@ -458,6 +458,32 @@ describe('custom forge prefixes', function() end) end) +describe('is_configured', function() + it('returns false when vim.g.pending is nil', function() + vim.g.pending = nil + assert.is_false(forge.is_configured('github')) + end) + + it('returns false when forge key is absent', function() + vim.g.pending = { forge = { close = true } } + assert.is_false(forge.is_configured('github')) + vim.g.pending = nil + end) + + it('returns true when forge key is present', function() + vim.g.pending = { forge = { github = {} } } + assert.is_true(forge.is_configured('github')) + assert.is_false(forge.is_configured('gitlab')) + vim.g.pending = nil + end) + + it('returns true for non-empty forge config', function() + vim.g.pending = { forge = { gitlab = { icon = '' } } } + assert.is_true(forge.is_configured('gitlab')) + vim.g.pending = nil + end) +end) + describe('forge diff integration', function() local store = require('pending.store') local diff = require('pending.diff') From 5c53adb3ec44df71a2e0e5fd66baf71f0d3a358c Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 11 Mar 2026 12:34:17 -0400 Subject: [PATCH 181/199] feat(forge): support bare repo-level forge refs (#135) (#140) Problem: Forge refs required an issue/PR number (`gh:user/repo#42`). Users wanting to link a repo without a specific issue had no option. Solution: Accept `gh:user/repo` shorthand and `https://github.com/user/repo` URLs as `type='repo'` refs with `number=nil`. These conceal and render virtual text like numbered refs but skip all API calls (no validate, no fetch, no close). `format_label` strips `#%n` for bare refs. Omnifunc offers both `owner/repo#` and `owner/repo` completions. Closes #135 --- lua/pending/complete.lua | 12 ++- lua/pending/forge.lua | 220 ++++++++++++++++++++++++--------------- spec/forge_spec.lua | 117 ++++++++++++++++++++- 3 files changed, 261 insertions(+), 88 deletions(-) diff --git a/lua/pending/complete.lua b/lua/pending/complete.lua index 98291ce..26f8798 100644 --- a/lua/pending/complete.lua +++ b/lua/pending/complete.lua @@ -194,9 +194,15 @@ function M.omnifunc(findstart, base) local key = ref.owner .. '/' .. ref.repo if not seen[key] then seen[key] = true - local word = key .. '#' - if base == '' or word:sub(1, #base) == base then - table.insert(matches, { word = word, menu = '[' .. source .. ']' }) + local word_num = key .. '#' + if base == '' or word_num:sub(1, #base) == base then + table.insert(matches, { word = word_num, menu = '[' .. source .. ']' }) + end + if base == '' or key:sub(1, #base) == base then + table.insert( + matches, + { word = key, menu = '[' .. source .. ']', info = 'Bare repo link' } + ) end end end diff --git a/lua/pending/forge.lua b/lua/pending/forge.lua index 78f6654..6116a9f 100644 --- a/lua/pending/forge.lua +++ b/lua/pending/forge.lua @@ -5,8 +5,8 @@ local log = require('pending.log') ---@field forge string ---@field owner string ---@field repo string ----@field type 'issue'|'pull_request'|'merge_request' ----@field number integer +---@field type 'issue'|'pull_request'|'merge_request'|'repo' +---@field number? integer ---@field url string ---@class pending.ForgeCache @@ -27,7 +27,7 @@ local log = require('pending.log') ---@field auth_status_args string[] ---@field default_icon string ---@field default_issue_format string ----@field _auth 'unknown'|'ok'|'failed' +---@field _auth? 'unknown'|'ok'|'failed' ---@field parse_url fun(self: pending.ForgeBackend, url: string): pending.ForgeRef? ---@field api_args fun(self: pending.ForgeBackend, ref: pending.ForgeRef): string[] ---@field parse_state fun(self: pending.ForgeBackend, decoded: table): 'open'|'closed'|'merged' @@ -157,18 +157,35 @@ function M._parse_shorthand(token) return nil end local owner, repo, number = rest:match('^([%w%.%-_]+)/([%w%.%-_]+)#(%d+)$') + if owner then + local num = tonumber(number) --[[@as integer]] + local url = 'https://' + .. backend.default_host + .. '/' + .. owner + .. '/' + .. repo + .. '/issues/' + .. num + return { + forge = backend.name, + owner = owner, + repo = repo, + type = 'issue', + number = num, + url = url, + } + end + owner, repo = rest:match('^([%w%.%-_]+)/([%w%.%-_]+)$') if not owner then return nil end - local num = tonumber(number) --[[@as integer]] - local url = 'https://' .. backend.default_host .. '/' .. owner .. '/' .. repo .. '/issues/' .. num return { forge = backend.name, owner = owner, repo = repo, - type = 'issue', - number = num, - url = url, + type = 'repo', + url = 'https://' .. backend.default_host .. '/' .. owner .. '/' .. repo, } end @@ -261,7 +278,7 @@ end ---@return string[] function M._api_args(ref) local backend = _by_name[ref.forge] - if not backend then + if not backend or not ref.number then return {} end return backend:api_args(ref) @@ -278,12 +295,15 @@ function M.format_label(ref, cache) local default_icon = backend and backend.default_icon or '' local default_fmt = backend and backend.default_issue_format or '%i %o/%r#%n' local fmt = forge_cfg.issue_format or default_fmt + if ref.type == 'repo' then + fmt = fmt:gsub('#?%%n', ''):gsub('%s+$', '') + end local icon = forge_cfg.icon or default_icon local text = fmt :gsub('%%i', icon) :gsub('%%o', ref.owner) :gsub('%%r', ref.repo) - :gsub('%%n', tostring(ref.number)) + :gsub('%%n', ref.number and tostring(ref.number) or '') local hl = 'PendingForge' if cache then if cache.state == 'closed' or cache.state == 'merged' then @@ -296,6 +316,10 @@ end ---@param ref pending.ForgeRef ---@param callback fun(cache: pending.ForgeCache?, err: pending.ForgeFetchError?) function M.fetch_metadata(ref, callback) + if ref.type == 'repo' then + callback(nil) + return + end local args = M._api_args(ref) vim.system(args, { text = true }, function(result) if result.code ~= 0 or not result.stdout or result.stdout == '' then @@ -351,7 +375,12 @@ function M.refresh(s) local tasks = s:tasks() local by_forge = {} ---@type table for _, task in ipairs(tasks) do - if task.status ~= 'deleted' and task._extra and task._extra._forge_ref then + if + task.status ~= 'deleted' + and task._extra + and task._extra._forge_ref + and task._extra._forge_ref.type ~= 'repo' + then local fname = task._extra._forge_ref.forge if not by_forge[fname] then by_forge[fname] = {} @@ -419,11 +448,15 @@ end function M.validate_refs(refs) local by_forge = {} ---@type table for _, ref in ipairs(refs) do + if ref.type == 'repo' then + goto skip_ref + end local fname = ref.forge if not by_forge[fname] then by_forge[fname] = {} end table.insert(by_forge[fname], ref) + ::skip_ref:: end for fname, forge_refs in pairs(by_forge) do if not M.is_configured(fname) or not _by_name[fname] then @@ -461,25 +494,29 @@ function M.gitea_forge(opts) _ensure_instances() local host, owner, repo, kind, number = url:match('^https?://([^/]+)/([%w%.%-_]+)/([%w%.%-_]+)/(%a+)/(%d+)$') - if not host then - return nil + if host and (kind == 'issues' or kind == 'pulls') and _by_host[host] == self then + local num = tonumber(number) --[[@as integer]] + local ref_type = kind == 'pulls' and 'pull_request' or 'issue' + return { + forge = self.name, + owner = owner, + repo = repo, + type = ref_type, + number = num, + url = url, + } end - if kind ~= 'issues' and kind ~= 'pulls' then - return nil + host, owner, repo = url:match('^https?://([^/]+)/([%w%.%-_]+)/([%w%.%-_]+)/?$') + if host and _by_host[host] == self then + return { + forge = self.name, + owner = owner, + repo = repo, + type = 'repo', + url = url, + } end - if _by_host[host] ~= self then - return nil - end - local num = tonumber(number) --[[@as integer]] - local ref_type = kind == 'pulls' and 'pull_request' or 'issue' - return { - forge = self.name, - owner = owner, - repo = repo, - type = ref_type, - number = num, - url = url, - } + return nil end, api_args = function(self, ref) local endpoint = ref.type == 'pull_request' and 'pulls' or 'issues' @@ -511,25 +548,29 @@ M.register({ _ensure_instances() local host, owner, repo, kind, number = url:match('^https?://([^/]+)/([%w%.%-_]+)/([%w%.%-_]+)/(%a+)/(%d+)$') - if not host then - return nil + if host and (kind == 'issues' or kind == 'pull') and _by_host[host] == self then + local num = tonumber(number) --[[@as integer]] + local ref_type = kind == 'pull' and 'pull_request' or 'issue' + return { + forge = 'github', + owner = owner, + repo = repo, + type = ref_type, + number = num, + url = url, + } end - if kind ~= 'issues' and kind ~= 'pull' then - return nil + host, owner, repo = url:match('^https?://([^/]+)/([%w%.%-_]+)/([%w%.%-_]+)/?$') + if host and _by_host[host] == self then + return { + forge = 'github', + owner = owner, + repo = repo, + type = 'repo', + url = url, + } end - if _by_host[host] ~= self then - return nil - end - local num = tonumber(number) --[[@as integer]] - local ref_type = kind == 'pull' and 'pull_request' or 'issue' - return { - forge = 'github', - owner = owner, - repo = repo, - type = ref_type, - number = num, - url = url, - } + return nil end, api_args = function(_, ref) return { @@ -560,29 +601,38 @@ M.register({ parse_url = function(self, url) _ensure_instances() local host, path, kind, number = url:match('^https?://([^/]+)/(.+)/%-/([%w_]+)/(%d+)$') - if not host then - return nil + if host and (kind == 'issues' or kind == 'merge_requests') and _by_host[host] == self then + local owner, repo = path:match('^(.+)/([^/]+)$') + if owner then + local num = tonumber(number) --[[@as integer]] + local ref_type = kind == 'merge_requests' and 'merge_request' or 'issue' + return { + forge = 'gitlab', + owner = owner, + repo = repo, + type = ref_type, + number = num, + url = url, + } + end end - if kind ~= 'issues' and kind ~= 'merge_requests' then - return nil + host, path = url:match('^https?://([^/]+)/(.+)$') + if host and _by_host[host] == self then + local trimmed = path:gsub('/$', '') + if not trimmed:find('/%-/') then + local owner, repo = trimmed:match('^(.+)/([^/]+)$') + if owner then + return { + forge = 'gitlab', + owner = owner, + repo = repo, + type = 'repo', + url = url, + } + end + end end - if _by_host[host] ~= self then - return nil - end - local owner, repo = path:match('^(.+)/([^/]+)$') - if not owner then - return nil - end - local num = tonumber(number) --[[@as integer]] - local ref_type = kind == 'merge_requests' and 'merge_request' or 'issue' - return { - forge = 'gitlab', - owner = owner, - repo = repo, - type = ref_type, - number = num, - url = url, - } + return nil end, api_args = function(_, ref) local encoded = (ref.owner .. '/' .. ref.repo):gsub('/', '%%2F') @@ -616,25 +666,29 @@ M.register({ _ensure_instances() local host, owner, repo, kind, number = url:match('^https?://([^/]+)/([%w%.%-_]+)/([%w%.%-_]+)/(%a+)/(%d+)$') - if not host then - return nil + if host and (kind == 'issues' or kind == 'pulls') and _by_host[host] == self then + local num = tonumber(number) --[[@as integer]] + local ref_type = kind == 'pulls' and 'pull_request' or 'issue' + return { + forge = 'codeberg', + owner = owner, + repo = repo, + type = ref_type, + number = num, + url = url, + } end - if kind ~= 'issues' and kind ~= 'pulls' then - return nil + host, owner, repo = url:match('^https?://([^/]+)/([%w%.%-_]+)/([%w%.%-_]+)/?$') + if host and _by_host[host] == self then + return { + forge = 'codeberg', + owner = owner, + repo = repo, + type = 'repo', + url = url, + } end - if _by_host[host] ~= self then - return nil - end - local num = tonumber(number) --[[@as integer]] - local ref_type = kind == 'pulls' and 'pull_request' or 'issue' - return { - forge = 'codeberg', - owner = owner, - repo = repo, - type = ref_type, - number = num, - url = url, - } + return nil end, api_args = function(_, ref) local endpoint = ref.type == 'pull_request' and 'pulls' or 'issues' diff --git a/spec/forge_spec.lua b/spec/forge_spec.lua index 067548e..84c812c 100644 --- a/spec/forge_spec.lua +++ b/spec/forge_spec.lua @@ -44,12 +44,30 @@ describe('forge', function() assert.is_nil(forge._parse_shorthand('xx:user/repo#1')) end) - it('rejects missing number', function() - assert.is_nil(forge._parse_shorthand('gh:user/repo')) + it('parses bare gh: shorthand without number', function() + local ref = forge._parse_shorthand('gh:user/repo') + assert.is_not_nil(ref) + assert.equals('github', ref.forge) + assert.equals('user', ref.owner) + assert.equals('repo', ref.repo) + assert.equals('repo', ref.type) + assert.is_nil(ref.number) + assert.equals('https://github.com/user/repo', ref.url) + end) + + it('parses bare gl: shorthand without number', function() + local ref = forge._parse_shorthand('gl:group/project') + assert.is_not_nil(ref) + assert.equals('gitlab', ref.forge) + assert.equals('group', ref.owner) + assert.equals('project', ref.repo) + assert.equals('repo', ref.type) + assert.is_nil(ref.number) end) it('rejects missing repo', function() assert.is_nil(forge._parse_shorthand('gh:user#1')) + assert.is_nil(forge._parse_shorthand('gh:user')) end) end) @@ -73,6 +91,23 @@ describe('forge', function() it('rejects non-github URL', function() assert.is_nil(forge._parse_github_url('https://example.com/user/repo/issues/1')) end) + + it('parses bare repo URL', function() + local ref = forge._parse_github_url('https://github.com/user/repo') + assert.is_not_nil(ref) + assert.equals('github', ref.forge) + assert.equals('user', ref.owner) + assert.equals('repo', ref.repo) + assert.equals('repo', ref.type) + assert.is_nil(ref.number) + end) + + it('parses bare repo URL with trailing slash', function() + local ref = forge._parse_github_url('https://github.com/user/repo/') + assert.is_not_nil(ref) + assert.equals('repo', ref.type) + assert.is_nil(ref.number) + end) end) describe('_parse_gitlab_url', function() @@ -98,6 +133,16 @@ describe('forge', function() assert.equals('org/sub', ref.owner) assert.equals('project', ref.repo) end) + + it('parses bare repo URL', function() + local ref = forge._parse_gitlab_url('https://gitlab.com/group/project') + assert.is_not_nil(ref) + assert.equals('gitlab', ref.forge) + assert.equals('group', ref.owner) + assert.equals('project', ref.repo) + assert.equals('repo', ref.type) + assert.is_nil(ref.number) + end) end) describe('_parse_codeberg_url', function() @@ -116,6 +161,16 @@ describe('forge', function() assert.is_not_nil(ref) assert.equals('pull_request', ref.type) end) + + it('parses bare repo URL', function() + local ref = forge._parse_codeberg_url('https://codeberg.org/user/repo') + assert.is_not_nil(ref) + assert.equals('codeberg', ref.forge) + assert.equals('user', ref.owner) + assert.equals('repo', ref.repo) + assert.equals('repo', ref.type) + assert.is_nil(ref.number) + end) end) describe('parse_ref', function() @@ -141,6 +196,14 @@ describe('forge', function() assert.is_nil(forge.parse_ref('hello')) assert.is_nil(forge.parse_ref('due:tomorrow')) end) + + it('dispatches bare shorthand', function() + local ref = forge.parse_ref('gh:user/repo') + assert.is_not_nil(ref) + assert.equals('github', ref.forge) + assert.equals('repo', ref.type) + assert.is_nil(ref.number) + end) end) describe('find_refs', function() @@ -184,6 +247,17 @@ describe('forge', function() assert.equals(0, refs[1].start_byte) assert.equals(8, refs[1].end_byte) end) + + it('finds bare shorthand ref', function() + local refs = forge.find_refs('Fix gh:user/repo') + assert.equals(1, #refs) + assert.equals('github', refs[1].ref.forge) + assert.equals('repo', refs[1].ref.type) + assert.is_nil(refs[1].ref.number) + assert.equals('gh:user/repo', refs[1].raw) + assert.equals(4, refs[1].start_byte) + assert.equals(16, refs[1].end_byte) + end) end) describe('_api_args', function() @@ -262,6 +336,30 @@ describe('forge', function() assert.equals('PendingForgeClosed', hl) end) + it('formats bare repo ref without #N', function() + local text = forge.format_label({ + forge = 'github', + owner = 'user', + repo = 'repo', + type = 'repo', + url = '', + }, nil) + assert.truthy(text:find('user/repo')) + assert.is_nil(text:find('#')) + end) + + it('still formats numbered ref with #N', function() + local text = forge.format_label({ + forge = 'github', + owner = 'user', + repo = 'repo', + type = 'issue', + number = 42, + url = '', + }, nil) + assert.truthy(text:find('user/repo#42')) + end) + it('uses closed highlight for merged state', function() local _, hl = forge.format_label({ forge = 'gitlab', @@ -542,4 +640,19 @@ describe('forge diff integration', function() assert.equals(1, updated._extra._forge_ref.number) os.remove(tmp) end) + + it('stores bare forge_ref in _extra on new task', function() + local tmp = os.tmpname() + local s = store.new(tmp) + s:load() + diff.apply({ '- [ ] Check out gh:user/repo' }, s) + local tasks = s:active_tasks() + assert.equals(1, #tasks) + assert.is_not_nil(tasks[1]._extra) + assert.is_not_nil(tasks[1]._extra._forge_ref) + assert.equals('github', tasks[1]._extra._forge_ref.forge) + assert.equals('repo', tasks[1]._extra._forge_ref.type) + assert.is_nil(tasks[1]._extra._forge_ref.number) + os.remove(tmp) + end) end) From d26bdcb3a8de92bc0c23916f6fa5e0d40785af04 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 11 Mar 2026 12:55:36 -0400 Subject: [PATCH 182/199] refactor: tighten LuaCATS annotations and canonicalize metadata fields (#141) * refactor: tighten LuaCATS annotations across modules Problem: type annotations repeated inline unions with no aliases, used `table` where structured types exist, and had loose `string` where union types should be used. Solution: add `pending.TaskStatus`, `pending.RecurMode`, `pending.TaskExtra`, `pending.ForgeType`, `pending.ForgeState`, `pending.ForgeAuthStatus` aliases and `pending.SyncBackend` interface. Replace inline unions and loose types with the new aliases in `store.lua`, `forge.lua`, `config.lua`, `diff.lua`, `views.lua`, `parse.lua`, `init.lua`, and `oauth.lua`. * refactor: canonicalize internal metadata field names Problem: `pending.Metadata` used shorthand field names (`cat`, `rec`, `rec_mode`) matching user-facing token syntax, coupling internal representation to config. `RecurSpec.from_completion` used a boolean where a `pending.RecurMode` alias exists. `category_syntax` was hardcoded to `'cat'` with no config option. Solution: rename `Metadata` fields to `category`/`recur`/`recur_mode`, add `category_syntax` config option (default `'cat'`), rename `ParsedEntry` fields to match, replace `RecurSpec.from_completion` with `mode: pending.RecurMode`, and restore `[string]` indexer on `pending.ForgeConfig` alongside explicit fields. --- doc/pending.txt | 14 +++++++++++--- lua/pending/complete.lua | 10 ++++++---- lua/pending/config.lua | 5 +++++ lua/pending/diff.lua | 30 +++++++++++++++--------------- lua/pending/forge.lua | 12 ++++++++---- lua/pending/init.lua | 27 ++++++++++++++++++++------- lua/pending/parse.lua | 27 +++++++++++++++++---------- lua/pending/recur.lua | 30 +++++++++++++++--------------- lua/pending/store.lua | 24 ++++++++++++++++++------ lua/pending/sync/oauth.lua | 9 +++++++++ lua/pending/views.lua | 2 +- plugin/pending.lua | 12 ++++++++---- spec/diff_spec.lua | 6 +++--- spec/forge_spec.lua | 2 +- spec/parse_spec.lua | 10 +++++----- spec/recur_spec.lua | 4 ++-- 16 files changed, 144 insertions(+), 80 deletions(-) diff --git a/doc/pending.txt b/doc/pending.txt index 90db8ee..7270c8e 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -605,9 +605,10 @@ Supported tokens: ~ `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`. +The token name for categories defaults to `cat` and is configurable via +`category_syntax` in |pending-config|. The token name for due dates defaults +to `due` and is configurable via `date_syntax`. The token name for recurrence +defaults to `rec` and is configurable via `recur_syntax`. Example: > @@ -734,6 +735,7 @@ loads: >lua data_path = vim.fn.stdpath('data') .. '/pending/tasks.json', default_category = 'Todo', date_format = '%b %d', + category_syntax = 'cat', date_syntax = 'due', recur_syntax = 'rec', someday_date = '9999-12-30', @@ -817,6 +819,12 @@ Fields: ~ '%m/%d', -- 03/15 (year inferred) } < + {category_syntax} (string, default: 'cat') + The token name for inline category metadata. Change + this to use a different keyword, for example + `'category'` to write `category:Work` instead of + `cat:Work`. + {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/complete.lua b/lua/pending/complete.lua index 26f8798..480a488 100644 --- a/lua/pending/complete.lua +++ b/lua/pending/complete.lua @@ -136,9 +136,11 @@ function M.omnifunc(findstart, base) local dk = date_key() local rk = recur_key() + local ck = config.get().category_syntax or 'cat' + local checks = { { vim.pesc(dk) .. ':([%S]*)$', dk }, - { 'cat:([%S]*)$', 'cat' }, + { vim.pesc(ck) .. ':([%S]*)$', ck }, { vim.pesc(rk) .. ':([%S]*)$', rk }, } for _, b in ipairs(forge.backends()) do @@ -172,10 +174,10 @@ function M.omnifunc(findstart, base) table.insert(matches, { word = c.word, menu = '[' .. source .. ']', info = c.info }) end end - elseif source == 'cat' then + elseif source == (config.get().category_syntax or 'cat') then for _, c in ipairs(get_categories()) do if base == '' or c:sub(1, #base) == base then - table.insert(matches, { word = c, menu = '[cat]' }) + table.insert(matches, { word = c, menu = '[' .. source .. ']' }) end end elseif source == rk then @@ -190,7 +192,7 @@ function M.omnifunc(findstart, base) local seen = {} for _, task in ipairs(s:tasks()) do if task._extra and task._extra._forge_ref then - local ref = task._extra._forge_ref + local ref = task._extra._forge_ref --[[@as pending.ForgeRef]] local key = ref.owner .. '/' .. ref.repo if not seen[key] then seen[key] = true diff --git a/lua/pending/config.lua b/lua/pending/config.lua index 2ec13cc..4a5172e 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -43,6 +43,9 @@ ---@field close? boolean ---@field validate? boolean ---@field warn_missing_cli? boolean +---@field github? pending.ForgeInstanceConfig +---@field gitlab? pending.ForgeInstanceConfig +---@field codeberg? pending.ForgeInstanceConfig ---@field [string] pending.ForgeInstanceConfig ---@class pending.SyncConfig @@ -92,6 +95,7 @@ ---@field data_path string ---@field default_category string ---@field date_format string +---@field category_syntax string ---@field date_syntax string ---@field recur_syntax string ---@field someday_date string @@ -113,6 +117,7 @@ local defaults = { data_path = vim.fn.stdpath('data') .. '/pending/tasks.json', default_category = 'Todo', date_format = '%b %d', + category_syntax = 'cat', date_syntax = 'due', recur_syntax = 'rec', someday_date = '9999-12-30', diff --git a/lua/pending/diff.lua b/lua/pending/diff.lua index 103ba6a..24645a2 100644 --- a/lua/pending/diff.lua +++ b/lua/pending/diff.lua @@ -7,11 +7,11 @@ local parse = require('pending.parse') ---@field id? integer ---@field description? string ---@field priority? integer ----@field status? string +---@field status? pending.TaskStatus ---@field category? string ---@field due? string ----@field rec? string ----@field rec_mode? string +---@field recur? string +---@field recur_mode? pending.RecurMode ---@field forge_ref? pending.ForgeRef ---@field lnum integer @@ -65,10 +65,10 @@ function M.parse_buffer(lines) description = description, priority = priority, status = status, - category = metadata.cat or current_category or config.get().default_category, + category = metadata.category or current_category or config.get().default_category, due = metadata.due, - rec = metadata.rec, - rec_mode = metadata.rec_mode, + recur = metadata.recur, + recur_mode = metadata.recur_mode, forge_ref = forge_ref, lnum = i, }) @@ -126,8 +126,8 @@ function M.apply(lines, s, hidden_ids) category = entry.category, priority = entry.priority, due = entry.due, - recur = entry.rec, - recur_mode = entry.rec_mode, + recur = entry.recur, + recur_mode = entry.recur_mode, order = order_counter, _extra = entry.forge_ref and { _forge_ref = entry.forge_ref } or nil, }) @@ -157,13 +157,13 @@ function M.apply(lines, s, hidden_ids) task.due = entry.due changed = true end - if entry.rec ~= nil then - if task.recur ~= entry.rec then - task.recur = entry.rec + if entry.recur ~= nil then + if task.recur ~= entry.recur then + task.recur = entry.recur changed = true end - if task.recur_mode ~= entry.rec_mode then - task.recur_mode = entry.rec_mode + if task.recur_mode ~= entry.recur_mode then + task.recur_mode = entry.recur_mode changed = true end end @@ -201,8 +201,8 @@ function M.apply(lines, s, hidden_ids) category = entry.category, priority = entry.priority, due = entry.due, - recur = entry.rec, - recur_mode = entry.rec_mode, + recur = entry.recur, + recur_mode = entry.recur_mode, order = order_counter, _extra = entry.forge_ref and { _forge_ref = entry.forge_ref } or nil, }) diff --git a/lua/pending/forge.lua b/lua/pending/forge.lua index 6116a9f..28c173a 100644 --- a/lua/pending/forge.lua +++ b/lua/pending/forge.lua @@ -1,17 +1,21 @@ local config = require('pending.config') local log = require('pending.log') +---@alias pending.ForgeType 'issue'|'pull_request'|'merge_request'|'repo' +---@alias pending.ForgeState 'open'|'closed'|'merged' +---@alias pending.ForgeAuthStatus 'unknown'|'ok'|'failed' + ---@class pending.ForgeRef ---@field forge string ---@field owner string ---@field repo string ----@field type 'issue'|'pull_request'|'merge_request'|'repo' +---@field type pending.ForgeType ---@field number? integer ---@field url string ---@class pending.ForgeCache ---@field title? string ----@field state 'open'|'closed'|'merged' +---@field state pending.ForgeState ---@field labels? string[] ---@field fetched_at string @@ -27,10 +31,10 @@ local log = require('pending.log') ---@field auth_status_args string[] ---@field default_icon string ---@field default_issue_format string ----@field _auth? 'unknown'|'ok'|'failed' +---@field _auth? pending.ForgeAuthStatus ---@field parse_url fun(self: pending.ForgeBackend, url: string): pending.ForgeRef? ---@field api_args fun(self: pending.ForgeBackend, ref: pending.ForgeRef): string[] ----@field parse_state fun(self: pending.ForgeBackend, decoded: table): 'open'|'closed'|'merged' +---@field parse_state fun(self: pending.ForgeBackend, decoded: table): pending.ForgeState ---@class pending.forge local M = {} diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 9b642ed..aeba431 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -984,10 +984,10 @@ function M.add(text) end s:add({ description = description, - category = metadata.cat, + category = metadata.category, due = metadata.due, - recur = metadata.rec, - recur_mode = metadata.rec_mode, + recur = metadata.recur, + recur_mode = metadata.recur_mode, priority = metadata.priority, }) _save_and_notify() @@ -998,6 +998,14 @@ function M.add(text) log.info('Task added: ' .. description) end +---@class pending.SyncBackend +---@field name string +---@field auth fun(): nil +---@field push? fun(): nil +---@field pull? fun(): nil +---@field sync? fun(): nil +---@field health? fun(): nil + ---@type string[]? local _sync_backends = nil @@ -1186,6 +1194,7 @@ end local function parse_edit_token(token) local recur = require('pending.recur') local cfg = require('pending.config').get() + local ck = cfg.category_syntax or 'cat' local dk = cfg.date_syntax or 'due' local rk = cfg.recur_syntax or 'rec' @@ -1201,7 +1210,7 @@ local function parse_edit_token(token) if token == '-due' or token == '-' .. dk then return 'due', vim.NIL, nil end - if token == '-cat' then + if token == '-' .. ck then return 'category', vim.NIL, nil end if token == '-rec' or token == '-' .. rk then @@ -1223,7 +1232,7 @@ local function parse_edit_token(token) 'Invalid date: ' .. due_val .. '. Use YYYY-MM-DD, today, tomorrow, +Nd, weekday names, etc.' end - local cat_val = token:match('^cat:(.+)$') + local cat_val = token:match('^' .. vim.pesc(ck) .. ':(.+)$') if cat_val then return 'category', cat_val, nil end @@ -1248,11 +1257,15 @@ local function parse_edit_token(token) .. token .. '. Valid: ' .. dk - .. ':, cat:, ' + .. ':, ' + .. ck + .. ':, ' .. rk .. ':, +!, -!, -' .. dk - .. ', -cat, -' + .. ', -' + .. ck + .. ', -' .. rk end diff --git a/lua/pending/parse.lua b/lua/pending/parse.lua index 5a705ef..c38fa54 100644 --- a/lua/pending/parse.lua +++ b/lua/pending/parse.lua @@ -2,9 +2,9 @@ local config = require('pending.config') ---@class pending.Metadata ---@field due? string ----@field cat? string ----@field rec? string ----@field rec_mode? 'scheduled'|'completion' +---@field category? string +---@field recur? string +---@field recur_mode? pending.RecurMode ---@field priority? integer ---@class pending.parse @@ -107,6 +107,11 @@ local function is_valid_datetime(s) return is_valid_date(date_part) and is_valid_time(time_part) end +---@return string +local function category_key() + return config.get().category_syntax or 'cat' +end + ---@return string local function date_key() return config.get().date_syntax or 'due' @@ -531,8 +536,10 @@ function M.body(text) local metadata = {} local i = #tokens + local ck = category_key() local dk = date_key() local rk = recur_key() + local cat_pattern = '^' .. vim.pesc(ck) .. ':(%S+)$' 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+)$' @@ -562,12 +569,12 @@ function M.body(text) metadata.due = resolved i = i - 1 else - local cat_val = token:match('^cat:(%S+)$') + local cat_val = token:match(cat_pattern) if cat_val then - if metadata.cat then + if metadata.category then break end - metadata.cat = cat_val + metadata.category = cat_val i = i - 1 else local pri_bangs = token:match('^%+(!+)$') @@ -581,19 +588,19 @@ function M.body(text) else local rec_val = token:match(rec_pattern) if rec_val then - if metadata.rec then + if metadata.recur then break end local recur = require('pending.recur') local raw_spec = rec_val if raw_spec:sub(1, 1) == '!' then - metadata.rec_mode = 'completion' + metadata.recur_mode = 'completion' raw_spec = raw_spec:sub(2) end if not recur.validate(raw_spec) then break end - metadata.rec = raw_spec + metadata.recur = raw_spec i = i - 1 else break @@ -624,7 +631,7 @@ function M.command_add(text) local rest = text:sub(#cat_prefix + 2):match('^%s*(.+)$') if rest then local desc, meta = M.body(rest) - meta.cat = meta.cat or cat_prefix + meta.category = meta.category or cat_prefix return desc, meta end end diff --git a/lua/pending/recur.lua b/lua/pending/recur.lua index 9c647aa..8891381 100644 --- a/lua/pending/recur.lua +++ b/lua/pending/recur.lua @@ -2,7 +2,7 @@ ---@field freq 'daily'|'weekly'|'monthly'|'yearly' ---@field interval integer ---@field byday? string[] ----@field from_completion boolean +---@field mode pending.RecurMode ---@field _raw? string ---@class pending.recur @@ -10,29 +10,29 @@ local M = {} ---@type table local named = { - daily = { freq = 'daily', interval = 1, from_completion = false }, + daily = { freq = 'daily', interval = 1, mode = 'scheduled' }, weekdays = { freq = 'weekly', interval = 1, byday = { 'MO', 'TU', 'WE', 'TH', 'FR' }, - from_completion = false, + mode = 'scheduled', }, - 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 }, + weekly = { freq = 'weekly', interval = 1, mode = 'scheduled' }, + biweekly = { freq = 'weekly', interval = 2, mode = 'scheduled' }, + monthly = { freq = 'monthly', interval = 1, mode = 'scheduled' }, + quarterly = { freq = 'monthly', interval = 3, mode = 'scheduled' }, + yearly = { freq = 'yearly', interval = 1, mode = 'scheduled' }, + annual = { freq = 'yearly', interval = 1, mode = 'scheduled' }, } ---@param spec string ---@return pending.RecurSpec? function M.parse(spec) - local from_completion = false + local mode = 'scheduled' ---@type pending.RecurMode local s = spec if s:sub(1, 1) == '!' then - from_completion = true + mode = 'completion' s = s:sub(2) end @@ -44,7 +44,7 @@ function M.parse(spec) freq = base.freq, interval = base.interval, byday = base.byday, - from_completion = from_completion, + mode = mode, } end @@ -58,7 +58,7 @@ function M.parse(spec) return { freq = freq_map[unit], interval = num, - from_completion = from_completion, + mode = mode, } end @@ -66,7 +66,7 @@ function M.parse(spec) return { freq = 'daily', interval = 1, - from_completion = from_completion, + mode = mode, _raw = s, } end @@ -134,7 +134,7 @@ end ---@param base_date string ---@param spec string ----@param mode 'scheduled'|'completion' +---@param mode pending.RecurMode ---@return string function M.next_due(base_date, spec, mode) local parsed = M.parse(spec) diff --git a/lua/pending/store.lua b/lua/pending/store.lua index fcf420e..5870fc6 100644 --- a/lua/pending/store.lua +++ b/lua/pending/store.lua @@ -1,19 +1,31 @@ local config = require('pending.config') +---@alias pending.TaskStatus 'pending'|'done'|'deleted'|'wip'|'blocked' +---@alias pending.RecurMode 'scheduled'|'completion' + +---@class pending.TaskExtra +---@field _forge_ref? pending.ForgeRef +---@field _forge_cache? pending.ForgeCache +---@field _gtasks_task_id? string +---@field _gtasks_list_id? string +---@field _gcal_event_id? string +---@field _gcal_calendar_id? string +---@field [string] any + ---@class pending.Task ---@field id integer ---@field description string ----@field status 'pending'|'done'|'deleted'|'wip'|'blocked' +---@field status pending.TaskStatus ---@field category? string ---@field priority integer ---@field due? string ---@field recur? string ----@field recur_mode? 'scheduled'|'completion' +---@field recur_mode? pending.RecurMode ---@field entry string ---@field modified string ---@field end? string ---@field order integer ----@field _extra? table +---@field _extra? pending.TaskExtra ---@class pending.Data ---@field version integer @@ -24,14 +36,14 @@ local config = require('pending.config') ---@class pending.TaskFields ---@field description string ----@field status? string +---@field status? pending.TaskStatus ---@field category? string ---@field priority? integer ---@field due? string ---@field recur? string ----@field recur_mode? string +---@field recur_mode? pending.RecurMode ---@field order? integer ----@field _extra? table +---@field _extra? pending.TaskExtra ---@class pending.Store ---@field path string diff --git a/lua/pending/sync/oauth.lua b/lua/pending/sync/oauth.lua index 8e670c1..a49595c 100644 --- a/lua/pending/sync/oauth.lua +++ b/lua/pending/sync/oauth.lua @@ -24,6 +24,15 @@ local BUNDLED_CLIENT_SECRET = 'PLACEHOLDER' ---@field config_key string ---@class pending.OAuthClient : pending.OAuthClientOpts +---@field token_path fun(self: pending.OAuthClient): string +---@field resolve_credentials fun(self: pending.OAuthClient): pending.OAuthCredentials +---@field load_tokens fun(self: pending.OAuthClient): pending.OAuthTokens? +---@field save_tokens fun(self: pending.OAuthClient, tokens: pending.OAuthTokens): boolean +---@field refresh_access_token fun(self: pending.OAuthClient, creds: pending.OAuthCredentials, tokens: pending.OAuthTokens): pending.OAuthTokens? +---@field get_access_token fun(self: pending.OAuthClient): string? +---@field setup fun(self: pending.OAuthClient): nil +---@field auth fun(self: pending.OAuthClient, on_complete?: fun(ok: boolean): nil): nil +---@field clear_tokens fun(self: pending.OAuthClient): nil local OAuthClient = {} OAuthClient.__index = OAuthClient diff --git a/lua/pending/views.lua b/lua/pending/views.lua index b1e691e..6fd1739 100644 --- a/lua/pending/views.lua +++ b/lua/pending/views.lua @@ -13,7 +13,7 @@ local parse = require('pending.parse') ---@field id? integer ---@field due? string ---@field raw_due? string ----@field status? string +---@field status? pending.TaskStatus ---@field category? string ---@field overdue? boolean ---@field show_category? boolean diff --git a/plugin/pending.lua b/plugin/pending.lua index 084f162..394c064 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -6,18 +6,19 @@ vim.g.loaded_pending = true ---@return string[] local function edit_field_candidates() local cfg = require('pending.config').get() + local ck = cfg.category_syntax or 'cat' local dk = cfg.date_syntax or 'due' local rk = cfg.recur_syntax or 'rec' return { dk .. ':', - 'cat:', + ck .. ':', rk .. ':', '+!', '+!!', '+!!!', '-!', '-' .. dk, - '-cat', + '-' .. ck, '-' .. rk, } end @@ -135,7 +136,9 @@ local function complete_edit(arg_lead, cmd_line) return result end - local cat_prefix = arg_lead:match('^(cat:)(.*)$') + local ck = cfg.category_syntax or 'cat' + + local cat_prefix = arg_lead:match('^(' .. vim.pesc(ck) .. ':)(.*)$') if cat_prefix then local after_colon = arg_lead:sub(#cat_prefix + 1) local store = require('pending.store') @@ -192,7 +195,8 @@ end, { 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) + local ck = (require('pending.config').get().category_syntax or 'cat') + table.insert(candidates, ck .. ':' .. task.category) end end local filtered = {} diff --git a/spec/diff_spec.lua b/spec/diff_spec.lua index 791d7f6..355d2db 100644 --- a/spec/diff_spec.lua +++ b/spec/diff_spec.lua @@ -71,7 +71,7 @@ describe('diff', function() '/1/- [ ] Take trash out rec:weekly', } local result = diff.parse_buffer(lines) - assert.are.equal('weekly', result[2].rec) + assert.are.equal('weekly', result[2].recur) end) it('extracts rec: with completion mode', function() @@ -80,8 +80,8 @@ describe('diff', function() '/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) + assert.are.equal('daily', result[2].recur) + assert.are.equal('completion', result[2].recur_mode) end) it('inline due: token is parsed', function() diff --git a/spec/forge_spec.lua b/spec/forge_spec.lua index 84c812c..ab8d5c4 100644 --- a/spec/forge_spec.lua +++ b/spec/forge_spec.lua @@ -404,7 +404,7 @@ describe('forge parse.body integration', function() it('extracts category but keeps forge ref in description', function() local desc, meta = parse.body('Fix bug gh:user/repo#42 cat:Work') assert.equals('Fix bug gh:user/repo#42', desc) - assert.equals('Work', meta.cat) + assert.equals('Work', meta.category) end) it('leaves non-forge tokens as description', function() diff --git a/spec/parse_spec.lua b/spec/parse_spec.lua index 0820356..8f1135f 100644 --- a/spec/parse_spec.lua +++ b/spec/parse_spec.lua @@ -31,21 +31,21 @@ describe('parse', function() it('extracts category', function() local desc, meta = parse.body('Buy groceries cat:Errands') assert.are.equal('Buy groceries', desc) - assert.are.equal('Errands', meta.cat) + assert.are.equal('Errands', meta.category) end) it('extracts both due and cat', function() local desc, meta = parse.body('Buy milk due:2026-03-15 cat:Errands') assert.are.equal('Buy milk', desc) assert.are.equal('2026-03-15', meta.due) - assert.are.equal('Errands', meta.cat) + assert.are.equal('Errands', meta.category) end) it('extracts metadata in any order', function() local desc, meta = parse.body('Buy milk cat:Errands due:2026-03-15') assert.are.equal('Buy milk', desc) assert.are.equal('2026-03-15', meta.due) - assert.are.equal('Errands', meta.cat) + assert.are.equal('Errands', meta.category) end) it('stops at duplicate key', function() @@ -400,7 +400,7 @@ describe('parse', function() it('detects category prefix', function() local desc, meta = parse.command_add('School: Do homework') assert.are.equal('Do homework', desc) - assert.are.equal('School', meta.cat) + assert.are.equal('School', meta.category) end) it('ignores lowercase prefix', function() @@ -411,7 +411,7 @@ describe('parse', function() it('combines category prefix with inline metadata', function() local desc, meta = parse.command_add('School: Do homework due:2026-03-15') assert.are.equal('Do homework', desc) - assert.are.equal('School', meta.cat) + assert.are.equal('School', meta.category) assert.are.equal('2026-03-15', meta.due) end) end) diff --git a/spec/recur_spec.lua b/spec/recur_spec.lua index 53b7478..c072b7b 100644 --- a/spec/recur_spec.lua +++ b/spec/recur_spec.lua @@ -8,7 +8,7 @@ describe('recur', function() local r = recur.parse('daily') assert.are.equal('daily', r.freq) assert.are.equal(1, r.interval) - assert.is_false(r.from_completion) + assert.are.equal('scheduled', r.mode) end) it('parses weekdays', function() @@ -79,7 +79,7 @@ describe('recur', function() it('parses ! prefix as completion-based', function() local r = recur.parse('!weekly') assert.are.equal('weekly', r.freq) - assert.is_true(r.from_completion) + assert.are.equal('completion', r.mode) end) it('parses raw RRULE fragment', function() From 79343cac2e567537c1699619d2da9cb8e4ce601a Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 11 Mar 2026 13:02:55 -0400 Subject: [PATCH 183/199] fix(parse): skip forge refs in right-to-left metadata scan (#142) Problem: `parse.body()` scans tokens right-to-left and breaks on the first non-metadata token. Forge refs like `gl:a/b#12` halted the scan, preventing metadata tokens to their left (e.g. `due:tomorrow`) from being parsed. Additionally, `diff.parse_buffer()` ignored `metadata.priority` from `+!!` tokens and only used checkbox-derived priority, and priority updates between two non-zero values were silently skipped. Solution: Recognize forge ref tokens via `forge.parse_ref()` during the right-to-left scan and skip past them, re-appending them to the description so `forge.find_refs()` still works. Prefer `metadata.priority` over checkbox priority in `parse_buffer()`, and simplify the priority update condition to catch all value changes. --- lua/pending/diff.lua | 7 ++----- lua/pending/parse.lua | 8 ++++++++ spec/diff_spec.lua | 36 ++++++++++++++++++++++++++++++++++++ spec/parse_spec.lua | 28 ++++++++++++++++++++++++++++ 4 files changed, 74 insertions(+), 5 deletions(-) diff --git a/lua/pending/diff.lua b/lua/pending/diff.lua index 24645a2..ac38f7a 100644 --- a/lua/pending/diff.lua +++ b/lua/pending/diff.lua @@ -63,7 +63,7 @@ function M.parse_buffer(lines) type = 'task', id = id and tonumber(id) or nil, description = description, - priority = priority, + priority = metadata.priority or priority, status = status, category = metadata.category or current_category or config.get().default_category, due = metadata.due, @@ -146,10 +146,7 @@ function M.apply(lines, s, hidden_ids) task.category = entry.category changed = true end - if entry.priority == 0 and task.priority > 0 then - task.priority = 0 - changed = true - elseif entry.priority > 0 and task.priority == 0 then + if entry.priority ~= task.priority then task.priority = entry.priority changed = true end diff --git a/lua/pending/parse.lua b/lua/pending/parse.lua index c38fa54..1b36578 100644 --- a/lua/pending/parse.lua +++ b/lua/pending/parse.lua @@ -1,4 +1,5 @@ local config = require('pending.config') +local forge = require('pending.forge') ---@class pending.Metadata ---@field due? string @@ -543,6 +544,7 @@ function M.body(text) 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+)$' + local forge_indices = {} while i >= 1 do local token = tokens[i] @@ -602,6 +604,9 @@ function M.body(text) end metadata.recur = raw_spec i = i - 1 + elseif forge.parse_ref(token) then + table.insert(forge_indices, i) + i = i - 1 else break end @@ -615,6 +620,9 @@ function M.body(text) for j = 1, i do table.insert(desc_tokens, tokens[j]) end + for fi = #forge_indices, 1, -1 do + table.insert(desc_tokens, tokens[forge_indices[fi]]) + end local description = table.concat(desc_tokens, ' ') return description, metadata diff --git a/spec/diff_spec.lua b/spec/diff_spec.lua index 355d2db..b69bd5a 100644 --- a/spec/diff_spec.lua +++ b/spec/diff_spec.lua @@ -379,5 +379,41 @@ describe('diff', function() local task = s:get(1) assert.are.equal(0, task.priority) end) + + it('sets priority from +!! token', function() + local lines = { + '# Inbox', + '- [ ] Pay bills +!!', + } + diff.apply(lines, s) + s:load() + local task = s:get(1) + assert.are.equal(2, task.priority) + end) + + it('updates priority between non-zero values', function() + s:add({ description = 'Task name', priority = 2 }) + s:save() + local lines = { + '# Inbox', + '/1/- [!] Task name', + } + diff.apply(lines, s) + s:load() + local task = s:get(1) + assert.are.equal(1, task.priority) + end) + + it('parses metadata with forge ref on same line', function() + local lines = { + '# Inbox', + '- [ ] Fix bug due:2026-03-15 gh:user/repo#42', + } + diff.apply(lines, s) + s:load() + local task = s:get(1) + assert.are.equal('2026-03-15', task.due) + assert.is_not_nil(task._extra._forge_ref) + end) end) end) diff --git a/spec/parse_spec.lua b/spec/parse_spec.lua index 8f1135f..aebe0c7 100644 --- a/spec/parse_spec.lua +++ b/spec/parse_spec.lua @@ -110,6 +110,34 @@ describe('parse', function() assert.is_nil(meta.due) assert.truthy(desc:find('due:garbage', 1, true)) end) + + it('parses metadata before a forge ref', function() + local desc, meta = parse.body('Fix bug due:2026-03-15 gh:user/repo#42') + assert.are.equal('2026-03-15', meta.due) + assert.truthy(desc:find('gh:user/repo#42', 1, true)) + assert.truthy(desc:find('Fix bug', 1, true)) + end) + + it('parses metadata after a forge ref', function() + local desc, meta = parse.body('Fix bug gh:user/repo#42 due:2026-03-15') + assert.are.equal('2026-03-15', meta.due) + assert.truthy(desc:find('gh:user/repo#42', 1, true)) + assert.truthy(desc:find('Fix bug', 1, true)) + end) + + it('parses all metadata around forge ref', function() + local desc, meta = parse.body('Fix bug due:tomorrow gh:user/repo#42 cat:Work') + assert.are.equal(os.date('%Y-%m-%d', os.time() + 86400), meta.due) + assert.are.equal('Work', meta.category) + assert.truthy(desc:find('gh:user/repo#42', 1, true)) + end) + + it('parses forge ref between metadata tokens', function() + local desc, meta = parse.body('Fix bug cat:Work gl:a/b#12 due:2026-03-15') + assert.are.equal('2026-03-15', meta.due) + assert.are.equal('Work', meta.category) + assert.truthy(desc:find('gl:a/b#12', 1, true)) + end) end) describe('parse.resolve_date', function() From c4fa77820b26b9b7a4cca4843bb94af519e9e4b0 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 11 Mar 2026 13:08:29 -0400 Subject: [PATCH 184/199] feat(complete): add metadata completion for `:Pending add` (#144) Problem: `:Pending add` had no tab completion for inline metadata tokens, unlike `:Pending edit` which already completed `due:`, `rec:`, and `cat:` values. Solution: Add `complete_add()` that handles `due:`, `rec:`, and `cat:` prefix matching with the same value sources used by `complete_edit()`, and wire it into the command completion dispatcher. --- plugin/pending.lua | 62 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/plugin/pending.lua b/plugin/pending.lua index 394c064..63caadd 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -80,6 +80,65 @@ local function filter_candidates(lead, candidates) end, candidates) end +---@param arg_lead string +---@return string[] +local function complete_add(arg_lead) + local cfg = require('pending.config').get() + local dk = cfg.date_syntax or 'due' + local rk = cfg.recur_syntax or 'rec' + local ck = cfg.category_syntax or 'cat' + + local prefix = arg_lead:match('^(' .. vim.pesc(dk) .. ':)(.*)$') + if prefix then + local after_colon = arg_lead:sub(#prefix + 1) + local result = {} + for _, d in ipairs(edit_date_values()) 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 result = {} + for _, p in ipairs(edit_recur_values()) 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('^(' .. vim.pesc(ck) .. ':)(.*)$') + if cat_prefix then + local after_colon = arg_lead:sub(#cat_prefix + 1) + local store = require('pending.store') + local s = store.new(store.resolve_path()) + s:load() + local seen = {} + local cats = {} + 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) + 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 {} +end + ---@param arg_lead string ---@param cmd_line string ---@return string[] @@ -207,6 +266,9 @@ end, { end return filtered end + if cmd_line:match('^Pending%s+add%s') then + return complete_add(arg_lead) + end if cmd_line:match('^Pending%s+archive%s') then return filter_candidates(arg_lead, { '7d', '2w', '30d', '3m', '6m', '1y' }) end From 38b6252c2ce1640f80417966bd336ab6e887516d Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 12 Mar 2026 20:19:08 -0400 Subject: [PATCH 185/199] feat(priority): add `g` and `g` visual batch priority mappings (#151) Problem: Incrementing or decrementing priority required operating on one task at a time with ``/``, which is tedious when adjusting multiple tasks. Solution: Add `adjust_priority_visual(delta)` that iterates the visual selection range, updates every task line's priority in one pass, then re-renders once. Exposed as `increment_priority_visual()` / `decrement_priority_visual()` with `g` / `g` defaults, new `` mappings, and config keys `priority_up_visual` / `priority_down_visual`. --- doc/pending.txt | 2 ++ lua/pending/config.lua | 4 +++ lua/pending/init.lua | 67 ++++++++++++++++++++++++++++++++++++++++++ plugin/pending.lua | 10 +++++++ 4 files changed, 83 insertions(+) diff --git a/doc/pending.txt b/doc/pending.txt index 7270c8e..5bc8c37 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -354,6 +354,8 @@ Default buffer-local keys: ~ `O` Insert a new task line above (`open_line_above`) `` Increment priority (clamped at `max_priority`) (`priority_up`) `` Decrement priority (clamped at 0) (`priority_down`) + `g` Increment priority for visual selection (`priority_up_visual`) + `g` Decrement priority for visual selection (`priority_down_visual`) `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`) diff --git a/lua/pending/config.lua b/lua/pending/config.lua index 4a5172e..451aace 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -78,6 +78,8 @@ ---@field move_up? string|false ---@field wip? string|false ---@field blocked? string|false +---@field priority_up_visual? string|false +---@field priority_down_visual? string|false ---@class pending.CategoryViewConfig ---@field order? string[] @@ -157,6 +159,8 @@ local defaults = { blocked = 'gb', priority_up = '', priority_down = '', + priority_up_visual = 'g', + priority_down_visual = 'g', }, sync = {}, forge = { diff --git a/lua/pending/init.lua b/lua/pending/init.lua index aeba431..fe8fd6f 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -402,6 +402,26 @@ function M._setup_buf_mappings(bufnr) end end + ---@type table + local visual_actions = { + priority_up_visual = function() + M.increment_priority_visual() + end, + priority_down_visual = function() + M.decrement_priority_visual() + end, + } + + for name, fn in pairs(visual_actions) do + local key = km[name] + if key and key ~= false then + vim.keymap.set('x', key --[[@as string]], function() + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes('', true, false, true), 'nx', false) + fn() + end, opts) + end + end + local textobj = require('pending.textobj') ---@type table @@ -711,6 +731,53 @@ function M.decrement_priority() adjust_priority(-1) end +---@param delta integer +---@return nil +local function adjust_priority_visual(delta) + local bufnr = buffer.bufnr() + if not bufnr then + return + end + if not require_saved() then + return + end + local start_row = vim.fn.line("'<") + local end_row = vim.fn.line("'>") + local cursor = vim.api.nvim_win_get_cursor(0) + local meta = buffer.meta() + local s = get_store() + local max = require('pending.config').get().max_priority or 3 + local changed = false + for row = start_row, end_row do + if meta[row] and meta[row].type == 'task' and meta[row].id then + local task = s:get(meta[row].id) + if task then + local new_priority = math.max(0, math.min(max, task.priority + delta)) + if new_priority ~= task.priority then + s:update(meta[row].id, { priority = new_priority }) + changed = true + end + end + end + end + if not changed then + return + end + _save_and_notify() + buffer.render(bufnr) + pcall(vim.api.nvim_win_set_cursor, 0, cursor) +end + +---@return nil +function M.increment_priority_visual() + adjust_priority_visual(1) +end + +---@return nil +function M.decrement_priority_visual() + adjust_priority_visual(-1) +end + ---@return nil function M.prompt_date() local bufnr = buffer.bufnr() diff --git a/plugin/pending.lua b/plugin/pending.lua index 63caadd..62e2e89 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -402,6 +402,16 @@ vim.keymap.set('n', '(pending-priority-down)', function() require('pending').decrement_priority() end) +vim.keymap.set('x', '(pending-priority-up-visual)', function() + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes('', true, false, true), 'nx', false) + require('pending').increment_priority_visual() +end) + +vim.keymap.set('x', '(pending-priority-down-visual)', function() + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes('', true, false, true), 'nx', false) + require('pending').decrement_priority_visual() +end) + vim.keymap.set('n', '(pending-filter)', function() vim.ui.input({ prompt = 'Filter: ' }, function(input) if input then From 4596214c0e2e6666450fb8ea8828a2378765492a Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 12 Mar 2026 20:19:18 -0400 Subject: [PATCH 186/199] fix(init): preserve cursor column and position in mutation functions (#152) Problem: `toggle_complete()`, `toggle_priority()`, `adjust_priority()`, `toggle_status()`, and `move_task()` captured only the row from `nvim_win_get_cursor` and restored the cursor to column 0 after re-render. Additionally, `toggle_complete()` followed the toggled task to its new sorted position at the bottom of the category, which is disorienting when working through a list of tasks. Solution: Capture both row and column from the cursor, and restore the column in all five functions. For `toggle_complete()`, instead of chasing the task ID after render, clamp the cursor to the original row (or total lines if shorter) and advance to the nearest task line, similar to the `]t` motion in `textobj.lua`. --- lua/pending/init.lua | 45 +++++++++++++++++++++++++++++++------------- 1 file changed, 32 insertions(+), 13 deletions(-) diff --git a/lua/pending/init.lua b/lua/pending/init.lua index fe8fd6f..e21320d 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -544,7 +544,8 @@ function M.toggle_complete() if not require_saved() then return end - local row = vim.api.nvim_win_get_cursor(0)[1] + local cursor = vim.api.nvim_win_get_cursor(0) + local row, col = cursor[1], cursor[2] local meta = buffer.meta() if not meta[row] or meta[row].type ~= 'task' then return @@ -578,11 +579,25 @@ function M.toggle_complete() 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 + local new_meta = buffer.meta() + local total = #new_meta + local target = math.min(row, total) + if new_meta[target] and new_meta[target].type == 'task' then + vim.api.nvim_win_set_cursor(0, { target, col }) + else + for r = target, total do + if new_meta[r] and new_meta[r].type == 'task' then + vim.api.nvim_win_set_cursor(0, { r, col }) + return + end end + for r = target, 1, -1 do + if new_meta[r] and new_meta[r].type == 'task' then + vim.api.nvim_win_set_cursor(0, { r, col }) + return + end + end + vim.api.nvim_win_set_cursor(0, { target, col }) end end @@ -654,7 +669,8 @@ function M.toggle_priority() if not require_saved() then return end - local row = vim.api.nvim_win_get_cursor(0)[1] + local cursor = vim.api.nvim_win_get_cursor(0) + local row, col = cursor[1], cursor[2] local meta = buffer.meta() if not meta[row] or meta[row].type ~= 'task' then return @@ -675,7 +691,7 @@ function M.toggle_priority() 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 }) + vim.api.nvim_win_set_cursor(0, { lnum, col }) break end end @@ -691,7 +707,8 @@ local function adjust_priority(delta) if not require_saved() then return end - local row = vim.api.nvim_win_get_cursor(0)[1] + local cursor = vim.api.nvim_win_get_cursor(0) + local row, col = cursor[1], cursor[2] local meta = buffer.meta() if not meta[row] or meta[row].type ~= 'task' then return @@ -715,7 +732,7 @@ local function adjust_priority(delta) 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 }) + vim.api.nvim_win_set_cursor(0, { lnum, col }) break end end @@ -829,7 +846,8 @@ function M.toggle_status(target_status) if not require_saved() then return end - local row = vim.api.nvim_win_get_cursor(0)[1] + local cursor = vim.api.nvim_win_get_cursor(0) + local row, col = cursor[1], cursor[2] local meta = buffer.meta() if not meta[row] or meta[row].type ~= 'task' then return @@ -852,7 +870,7 @@ function M.toggle_status(target_status) 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 }) + vim.api.nvim_win_set_cursor(0, { lnum, col }) break end end @@ -868,7 +886,8 @@ function M.move_task(direction) if not require_saved() then return end - local row = vim.api.nvim_win_get_cursor(0)[1] + local cursor = vim.api.nvim_win_get_cursor(0) + local row, col = cursor[1], cursor[2] local meta = buffer.meta() if not meta[row] or meta[row].type ~= 'task' then return @@ -943,7 +962,7 @@ function M.move_task(direction) for lnum, m in ipairs(buffer.meta()) do if m.id == id then - vim.api.nvim_win_set_cursor(0, { lnum, 0 }) + vim.api.nvim_win_set_cursor(0, { lnum, col }) break end end From b9b12fd2a5d633b1d0abd737e7db87a9db476326 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 12 Mar 2026 20:19:27 -0400 Subject: [PATCH 187/199] feat(views): add `hide_done_categories` config option (#153) Problem: Categories where every task is done still render in the buffer, cluttering the view when entire categories are finished. Solution: Add `view.category.hide_done_categories` (boolean, default false). When enabled, `category_view()` skips categories whose tasks are all done/deleted, returns their IDs as `done_cat_hidden_ids`, and `_on_write` merges those IDs into `hidden_ids` passed to `diff.apply()` so hidden tasks are not mistakenly deleted on `:w`. --- doc/pending.txt | 10 ++++++ lua/pending/buffer.lua | 12 ++++++- lua/pending/config.lua | 2 ++ lua/pending/init.lua | 3 ++ lua/pending/views.lua | 21 +++++++++-- spec/views_spec.lua | 79 ++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 123 insertions(+), 4 deletions(-) diff --git a/doc/pending.txt b/doc/pending.txt index 5bc8c37..bf515e4 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -879,6 +879,16 @@ Fields: ~ `false` uses Vim's built-in foldtext. Folds only apply to category view. + {hide_done_categories} + (boolean, default: false) + When true, categories where every task is + done (or deleted) are hidden from the + rendered buffer. The tasks remain in the + store and reappear when any task in the + category is un-done or a new pending task + is added. Hidden tasks are protected from + deletion on `:w`. + {queue} (table) *pending.QueueViewConfig* Queue (priority) view settings. diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index b731262..0bf3d64 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -27,6 +27,8 @@ local _filter_predicates = {} ---@type table local _hidden_ids = {} ---@type table +local _done_cat_hidden_ids = {} +---@type table local _dirty_rows = {} ---@type boolean local _on_bytes_active = false @@ -74,6 +76,11 @@ function M.hidden_ids() return _hidden_ids end +---@return table +function M.done_cat_hidden_ids() + return _done_cat_hidden_ids +end + ---@param predicates string[] ---@param hidden table ---@return nil @@ -694,10 +701,13 @@ function M.render(bufnr) end local lines, line_meta + _done_cat_hidden_ids = {} if current_view == 'priority' then lines, line_meta = views.priority_view(tasks) else - lines, line_meta = views.category_view(tasks) + local done_cat_hidden + lines, line_meta, done_cat_hidden = views.category_view(tasks) + _done_cat_hidden_ids = done_cat_hidden end if #lines == 0 and #_filter_predicates == 0 then diff --git a/lua/pending/config.lua b/lua/pending/config.lua index 451aace..dfce286 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -84,6 +84,7 @@ ---@class pending.CategoryViewConfig ---@field order? string[] ---@field folding? boolean|pending.FoldingConfig +---@field hide_done_categories? boolean ---@class pending.QueueViewConfig @@ -130,6 +131,7 @@ local defaults = { category = { order = {}, folding = true, + hide_done_categories = false, }, queue = {}, }, diff --git a/lua/pending/init.lua b/lua/pending/init.lua index e21320d..c82aaec 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -509,6 +509,9 @@ function M._on_write(bufnr) if #stack > UNDO_MAX then table.remove(stack, 1) end + for id in pairs(buffer.done_cat_hidden_ids()) do + hidden[id] = true + end local new_refs = diff.apply(lines, s, hidden) M._recompute_counts() buffer.render(bufnr) diff --git a/lua/pending/views.lua b/lua/pending/views.lua index 6fd1739..1b8e303 100644 --- a/lua/pending/views.lua +++ b/lua/pending/views.lua @@ -138,6 +138,7 @@ end ---@param tasks pending.Task[] ---@return string[] lines ---@return pending.LineMeta[] meta +---@return table done_cat_hidden_ids function M.category_view(tasks) local by_cat = {} local cat_order = {} @@ -177,6 +178,9 @@ function M.category_view(tasks) cat_order = ordered end + local hide_done = config.get().view.category.hide_done_categories + local done_cat_hidden = {} ---@type table + for _, cat in ipairs(cat_order) do sort_tasks(by_cat[cat]) sort_tasks(done_by_cat[cat]) @@ -184,12 +188,21 @@ function M.category_view(tasks) local lines = {} local meta = {} + local rendered = 0 - for i, cat in ipairs(cat_order) do - if i > 1 then + for _, cat in ipairs(cat_order) do + if hide_done and #by_cat[cat] == 0 and #done_by_cat[cat] > 0 then + for _, t in ipairs(done_by_cat[cat]) do + done_cat_hidden[t.id] = true + end + goto next_cat + end + + if rendered > 0 then table.insert(lines, '') table.insert(meta, { type = 'blank' }) end + rendered = rendered + 1 table.insert(lines, '# ' .. cat) table.insert(meta, { type = 'header', category = cat }) @@ -220,9 +233,11 @@ function M.category_view(tasks) forge_spans = compute_forge_spans(task, prefix_len), }) end + + ::next_cat:: end - return lines, meta + return lines, meta, done_cat_hidden end ---@param tasks pending.Task[] diff --git a/spec/views_spec.lua b/spec/views_spec.lua index ff8ad93..115eb84 100644 --- a/spec/views_spec.lua +++ b/spec/views_spec.lua @@ -280,6 +280,85 @@ describe('views', function() assert.are.equal('# Alpha', headers[1]) assert.are.equal('# Beta', headers[2]) end) + + it('returns empty done_cat_hidden_ids when hide_done_categories is false', function() + local t1 = s:add({ description = 'Done task', category = 'Work' }) + s:update(t1.id, { status = 'done' }) + s:add({ description = 'Active', category = 'Personal' }) + local _, _, done_hidden = views.category_view(s:active_tasks()) + assert.are.same({}, done_hidden) + end) + + it('hides categories with only done tasks when hide_done_categories is true', function() + vim.g.pending = { + data_path = tmpdir .. '/tasks.json', + view = { category = { hide_done_categories = true } }, + } + config.reset() + local t1 = s:add({ description = 'Done task', category = 'Work' }) + s:update(t1.id, { status = 'done' }) + s:add({ description = 'Active', category = 'Personal' }) + local lines, meta, done_hidden = views.category_view(s:active_tasks()) + local headers = {} + for i, m in ipairs(meta) do + if m.type == 'header' then + table.insert(headers, lines[i]) + end + end + assert.are.equal(1, #headers) + assert.are.equal('# Personal', headers[1]) + assert.are.same({ [t1.id] = true }, done_hidden) + end) + + it('shows categories with a mix of done and pending tasks', function() + vim.g.pending = { + data_path = tmpdir .. '/tasks.json', + view = { category = { hide_done_categories = true } }, + } + config.reset() + local t1 = s:add({ description = 'Done task', category = 'Work' }) + s:update(t1.id, { status = 'done' }) + s:add({ description = 'Active task', category = 'Work' }) + local lines, meta, done_hidden = views.category_view(s:active_tasks()) + local headers = {} + for i, m in ipairs(meta) do + if m.type == 'header' then + table.insert(headers, lines[i]) + end + end + assert.are.equal(1, #headers) + assert.are.equal('# Work', headers[1]) + assert.are.same({}, done_hidden) + end) + + it('does not insert leading blank line when first category is hidden', function() + vim.g.pending = { + data_path = tmpdir .. '/tasks.json', + view = { category = { hide_done_categories = true } }, + } + config.reset() + local t1 = s:add({ description = 'Done task', category = 'Alpha' }) + s:update(t1.id, { status = 'done' }) + s:add({ description = 'Active', category = 'Beta' }) + local lines, meta = views.category_view(s:active_tasks()) + assert.are.equal('header', meta[1].type) + assert.are.equal('# Beta', lines[1]) + end) + + it('returns all done task ids from hidden categories', function() + vim.g.pending = { + data_path = tmpdir .. '/tasks.json', + view = { category = { hide_done_categories = true } }, + } + config.reset() + local t1 = s:add({ description = 'Done A', category = 'Work' }) + local t2 = s:add({ description = 'Done B', category = 'Work' }) + s:update(t1.id, { status = 'done' }) + s:update(t2.id, { status = 'done' }) + s:add({ description = 'Active', category = 'Personal' }) + local _, _, done_hidden = views.category_view(s:active_tasks()) + assert.are.same({ [t1.id] = true, [t2.id] = true }, done_hidden) + end) end) describe('priority_view', function() From 3b3cdc8965c26ff62cec604d09f49ccdd5d04a23 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 12 Mar 2026 20:19:36 -0400 Subject: [PATCH 188/199] feat(views): make queue view sort order configurable (#154) Problem: the queue/priority view sort in `sort_tasks_priority()` uses a hardcoded tiebreak chain (status, priority, due, order, id). Users who care more about due dates than priority have no way to reorder it. Solution: add `view.queue.sort` config field (string[]) that defines an ordered tiebreak chain. `build_queue_comparator()` maps each key to a comparison function and returns a single comparator. Unknown keys emit a `log.warn`. The default matches the previous hardcoded behavior. --- doc/pending.txt | 37 ++++++++++++++++---- lua/pending/config.lua | 5 ++- lua/pending/views.lua | 61 ++++++++++++++++++++++++++++++--- spec/views_spec.lua | 78 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 169 insertions(+), 12 deletions(-) diff --git a/doc/pending.txt b/doc/pending.txt index bf515e4..5cca48e 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -542,11 +542,12 @@ Category view (default): ~ *pending-view-category* `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 - text alongside the due date virtual text so tasks remain identifiable - across categories. The buffer is named `pending://queue`. + A flat list of all tasks sorted by a configurable tiebreak chain + (default: status → priority → due → order → id). See + `view.queue.sort` in |pending-config| for customization. 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`. ============================================================================== FILTERS *pending-filters* @@ -749,7 +750,9 @@ loads: >lua order = {}, folding = true, }, - queue = {}, + queue = { + sort = { 'status', 'priority', 'due', 'order', 'id' }, + }, }, keymaps = { close = 'q', @@ -891,6 +894,24 @@ Fields: ~ {queue} (table) *pending.QueueViewConfig* Queue (priority) view settings. + {sort} (string[], default: + `{ 'status', 'priority', 'due', + 'order', 'id' }`) + Ordered tiebreak chain for the + queue view sort. Each element is a + sort key; the comparator walks the + list and returns on the first + non-equal comparison. Valid keys: + `status` wip < pending < + blocked < done + `priority` higher number first + `due` sooner first, no-due + last + `order` ascending + `id` ascending + `age` alias for `id` + Unknown keys are ignored with a + warning. Examples: >lua vim.g.pending = { @@ -901,6 +922,10 @@ Fields: ~ order = { 'Work', 'Personal' }, folding = { foldtext = '%c: %n items' }, }, + queue = { + sort = { 'status', 'due', 'priority', + 'order', 'id' }, + }, }, } < diff --git a/lua/pending/config.lua b/lua/pending/config.lua index dfce286..71b0bc5 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -87,6 +87,7 @@ ---@field hide_done_categories? boolean ---@class pending.QueueViewConfig +---@field sort? string[] ---@class pending.ViewConfig ---@field default? 'category'|'priority' @@ -133,7 +134,9 @@ local defaults = { folding = true, hide_done_categories = false, }, - queue = {}, + queue = { + sort = { 'status', 'priority', 'due', 'order', 'id' }, + }, }, keymaps = { close = 'q', diff --git a/lua/pending/views.lua b/lua/pending/views.lua index 1b8e303..c31879e 100644 --- a/lua/pending/views.lua +++ b/lua/pending/views.lua @@ -106,17 +106,23 @@ local function sort_tasks(tasks) end) end ----@param tasks pending.Task[] -local function sort_tasks_priority(tasks) - table.sort(tasks, function(a, b) +---@type table +local sort_key_comparators = { + status = 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 + return nil + end, + priority = function(a, b) if a.priority ~= b.priority then return a.priority > b.priority end + return nil + end, + due = function(a, b) local a_due = a.due or '' local b_due = b.due or '' if a_due ~= b_due then @@ -128,11 +134,56 @@ local function sort_tasks_priority(tasks) end return a_due < b_due end + return nil + end, + order = function(a, b) if a.order ~= b.order then return a.order < b.order end - return a.id < b.id - end) + return nil + end, + id = function(a, b) + if a.id ~= b.id then + return a.id < b.id + end + return nil + end, + age = function(a, b) + if a.id ~= b.id then + return a.id < b.id + end + return nil + end, +} + +---@return fun(a: pending.Task, b: pending.Task): boolean +local function build_queue_comparator() + local log = require('pending.log') + local keys = config.get().view.queue.sort or { 'status', 'priority', 'due', 'order', 'id' } + local comparators = {} + for _, key in ipairs(keys) do + local cmp = sort_key_comparators[key] + if cmp then + table.insert(comparators, cmp) + else + log.warn('unknown queue sort key: ' .. key) + end + end + return function(a, b) + for _, cmp in ipairs(comparators) do + local result = cmp(a, b) + if result ~= nil then + return result + end + end + return false + end +end + +---@param tasks pending.Task[] +local function sort_tasks_priority(tasks) + local cmp = build_queue_comparator() + table.sort(tasks, cmp) end ---@param tasks pending.Task[] diff --git a/spec/views_spec.lua b/spec/views_spec.lua index 115eb84..1305afa 100644 --- a/spec/views_spec.lua +++ b/spec/views_spec.lua @@ -529,5 +529,83 @@ describe('views', function() end assert.is_nil(task_meta.recur) end) + + it('sorts by due before priority when sort config is reordered', function() + vim.g.pending = { + data_path = tmpdir .. '/tasks.json', + view = { queue = { sort = { 'status', 'due', 'priority', 'order', 'id' } } }, + } + config.reset() + s:add({ description = 'High no due', category = 'Work', priority = 2 }) + s:add({ description = 'Low with due', category = 'Work', priority = 0, due = '2050-01-01' }) + 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 + if lines[i]:find('Low with due') then + due_row = i + elseif lines[i]:find('High no due') then + nodue_row = i + end + end + end + assert.is_true(due_row < nodue_row) + end) + + it('uses default sort when config sort is nil', function() + vim.g.pending = { + data_path = tmpdir .. '/tasks.json', + view = { queue = {} }, + } + config.reset() + 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 + if lines[i]:find('High') then + high_row = i + elseif lines[i]:find('Low') then + low_row = i + end + end + end + assert.is_true(high_row < low_row) + end) + + it('ignores unknown sort keys with a warning', function() + vim.g.pending = { + data_path = tmpdir .. '/tasks.json', + view = { queue = { sort = { 'bogus', 'status', 'id' } } }, + } + config.reset() + s:add({ description = 'A', category = 'Work' }) + s:add({ description = 'B', category = 'Work' }) + local lines = views.priority_view(s:active_tasks()) + assert.is_true(#lines == 2) + end) + + it('supports age sort key as alias for id', function() + vim.g.pending = { + data_path = tmpdir .. '/tasks.json', + view = { queue = { sort = { 'age' } } }, + } + config.reset() + s:add({ description = 'Older', category = 'Work' }) + s:add({ description = 'Newer', category = 'Work' }) + local lines, meta = views.priority_view(s:active_tasks()) + local older_row, newer_row + for i, m in ipairs(meta) do + if m.type == 'task' then + if lines[i]:find('Older') then + older_row = i + elseif lines[i]:find('Newer') then + newer_row = i + end + end + end + assert.is_true(older_row < newer_row) + end) end) end) From 7c0ba178d07f7be7e02178e8c48f889459e8c2f6 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Thu, 12 Mar 2026 20:21:26 -0400 Subject: [PATCH 189/199] ci: format --- lua/pending/init.lua | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lua/pending/init.lua b/lua/pending/init.lua index c82aaec..529954b 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -416,7 +416,11 @@ function M._setup_buf_mappings(bufnr) local key = km[name] if key and key ~= false then vim.keymap.set('x', key --[[@as string]], function() - vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes('', true, false, true), 'nx', false) + vim.api.nvim_feedkeys( + vim.api.nvim_replace_termcodes('', true, false, true), + 'nx', + false + ) fn() end, opts) end From e6816d13ef0c420d45f545f44aea69f837b663f2 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Thu, 12 Mar 2026 20:41:32 -0400 Subject: [PATCH 190/199] Revert "feat(views): add `hide_done_categories` config option (#153)" This reverts commit 283f93eda10d10941b65891e12a35f7a8e30c9d4. --- doc/pending.txt | 10 ------ lua/pending/buffer.lua | 12 +------ lua/pending/config.lua | 2 -- lua/pending/init.lua | 3 -- lua/pending/views.lua | 21 ++--------- spec/views_spec.lua | 79 ------------------------------------------ 6 files changed, 4 insertions(+), 123 deletions(-) diff --git a/doc/pending.txt b/doc/pending.txt index 5cca48e..ee16bc8 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -882,16 +882,6 @@ Fields: ~ `false` uses Vim's built-in foldtext. Folds only apply to category view. - {hide_done_categories} - (boolean, default: false) - When true, categories where every task is - done (or deleted) are hidden from the - rendered buffer. The tasks remain in the - store and reappear when any task in the - category is un-done or a new pending task - is added. Hidden tasks are protected from - deletion on `:w`. - {queue} (table) *pending.QueueViewConfig* Queue (priority) view settings. {sort} (string[], default: diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index 0bf3d64..b731262 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -27,8 +27,6 @@ local _filter_predicates = {} ---@type table local _hidden_ids = {} ---@type table -local _done_cat_hidden_ids = {} ----@type table local _dirty_rows = {} ---@type boolean local _on_bytes_active = false @@ -76,11 +74,6 @@ function M.hidden_ids() return _hidden_ids end ----@return table -function M.done_cat_hidden_ids() - return _done_cat_hidden_ids -end - ---@param predicates string[] ---@param hidden table ---@return nil @@ -701,13 +694,10 @@ function M.render(bufnr) end local lines, line_meta - _done_cat_hidden_ids = {} if current_view == 'priority' then lines, line_meta = views.priority_view(tasks) else - local done_cat_hidden - lines, line_meta, done_cat_hidden = views.category_view(tasks) - _done_cat_hidden_ids = done_cat_hidden + lines, line_meta = views.category_view(tasks) end if #lines == 0 and #_filter_predicates == 0 then diff --git a/lua/pending/config.lua b/lua/pending/config.lua index 71b0bc5..171dd1a 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -84,7 +84,6 @@ ---@class pending.CategoryViewConfig ---@field order? string[] ---@field folding? boolean|pending.FoldingConfig ----@field hide_done_categories? boolean ---@class pending.QueueViewConfig ---@field sort? string[] @@ -132,7 +131,6 @@ local defaults = { category = { order = {}, folding = true, - hide_done_categories = false, }, queue = { sort = { 'status', 'priority', 'due', 'order', 'id' }, diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 529954b..8f1b9e4 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -513,9 +513,6 @@ function M._on_write(bufnr) if #stack > UNDO_MAX then table.remove(stack, 1) end - for id in pairs(buffer.done_cat_hidden_ids()) do - hidden[id] = true - end local new_refs = diff.apply(lines, s, hidden) M._recompute_counts() buffer.render(bufnr) diff --git a/lua/pending/views.lua b/lua/pending/views.lua index c31879e..f7dce4a 100644 --- a/lua/pending/views.lua +++ b/lua/pending/views.lua @@ -189,7 +189,6 @@ end ---@param tasks pending.Task[] ---@return string[] lines ---@return pending.LineMeta[] meta ----@return table done_cat_hidden_ids function M.category_view(tasks) local by_cat = {} local cat_order = {} @@ -229,9 +228,6 @@ function M.category_view(tasks) cat_order = ordered end - local hide_done = config.get().view.category.hide_done_categories - local done_cat_hidden = {} ---@type table - for _, cat in ipairs(cat_order) do sort_tasks(by_cat[cat]) sort_tasks(done_by_cat[cat]) @@ -239,21 +235,12 @@ function M.category_view(tasks) local lines = {} local meta = {} - local rendered = 0 - for _, cat in ipairs(cat_order) do - if hide_done and #by_cat[cat] == 0 and #done_by_cat[cat] > 0 then - for _, t in ipairs(done_by_cat[cat]) do - done_cat_hidden[t.id] = true - end - goto next_cat - end - - if rendered > 0 then + for i, cat in ipairs(cat_order) do + if i > 1 then table.insert(lines, '') table.insert(meta, { type = 'blank' }) end - rendered = rendered + 1 table.insert(lines, '# ' .. cat) table.insert(meta, { type = 'header', category = cat }) @@ -284,11 +271,9 @@ function M.category_view(tasks) forge_spans = compute_forge_spans(task, prefix_len), }) end - - ::next_cat:: end - return lines, meta, done_cat_hidden + return lines, meta end ---@param tasks pending.Task[] diff --git a/spec/views_spec.lua b/spec/views_spec.lua index 1305afa..e841deb 100644 --- a/spec/views_spec.lua +++ b/spec/views_spec.lua @@ -280,85 +280,6 @@ describe('views', function() assert.are.equal('# Alpha', headers[1]) assert.are.equal('# Beta', headers[2]) end) - - it('returns empty done_cat_hidden_ids when hide_done_categories is false', function() - local t1 = s:add({ description = 'Done task', category = 'Work' }) - s:update(t1.id, { status = 'done' }) - s:add({ description = 'Active', category = 'Personal' }) - local _, _, done_hidden = views.category_view(s:active_tasks()) - assert.are.same({}, done_hidden) - end) - - it('hides categories with only done tasks when hide_done_categories is true', function() - vim.g.pending = { - data_path = tmpdir .. '/tasks.json', - view = { category = { hide_done_categories = true } }, - } - config.reset() - local t1 = s:add({ description = 'Done task', category = 'Work' }) - s:update(t1.id, { status = 'done' }) - s:add({ description = 'Active', category = 'Personal' }) - local lines, meta, done_hidden = views.category_view(s:active_tasks()) - local headers = {} - for i, m in ipairs(meta) do - if m.type == 'header' then - table.insert(headers, lines[i]) - end - end - assert.are.equal(1, #headers) - assert.are.equal('# Personal', headers[1]) - assert.are.same({ [t1.id] = true }, done_hidden) - end) - - it('shows categories with a mix of done and pending tasks', function() - vim.g.pending = { - data_path = tmpdir .. '/tasks.json', - view = { category = { hide_done_categories = true } }, - } - config.reset() - local t1 = s:add({ description = 'Done task', category = 'Work' }) - s:update(t1.id, { status = 'done' }) - s:add({ description = 'Active task', category = 'Work' }) - local lines, meta, done_hidden = views.category_view(s:active_tasks()) - local headers = {} - for i, m in ipairs(meta) do - if m.type == 'header' then - table.insert(headers, lines[i]) - end - end - assert.are.equal(1, #headers) - assert.are.equal('# Work', headers[1]) - assert.are.same({}, done_hidden) - end) - - it('does not insert leading blank line when first category is hidden', function() - vim.g.pending = { - data_path = tmpdir .. '/tasks.json', - view = { category = { hide_done_categories = true } }, - } - config.reset() - local t1 = s:add({ description = 'Done task', category = 'Alpha' }) - s:update(t1.id, { status = 'done' }) - s:add({ description = 'Active', category = 'Beta' }) - local lines, meta = views.category_view(s:active_tasks()) - assert.are.equal('header', meta[1].type) - assert.are.equal('# Beta', lines[1]) - end) - - it('returns all done task ids from hidden categories', function() - vim.g.pending = { - data_path = tmpdir .. '/tasks.json', - view = { category = { hide_done_categories = true } }, - } - config.reset() - local t1 = s:add({ description = 'Done A', category = 'Work' }) - local t2 = s:add({ description = 'Done B', category = 'Work' }) - s:update(t1.id, { status = 'done' }) - s:update(t2.id, { status = 'done' }) - s:add({ description = 'Active', category = 'Personal' }) - local _, _, done_hidden = views.category_view(s:active_tasks()) - assert.are.same({ [t1.id] = true, [t2.id] = true }, done_hidden) - end) end) describe('priority_view', function() From e9f21c0f0bc0bf4618f6864e5410b9ecc5f75a80 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 12 Mar 2026 20:47:04 -0400 Subject: [PATCH 191/199] fix(views): pluralize unknown queue sort key warning (#157) Problem: multiple unknown sort keys each triggered a separate warning. Solution: collect unknown keys and emit a single warning with the correct singular/plural label, joined by `, `. --- lua/pending/views.lua | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lua/pending/views.lua b/lua/pending/views.lua index f7dce4a..12cbbc0 100644 --- a/lua/pending/views.lua +++ b/lua/pending/views.lua @@ -161,14 +161,19 @@ local function build_queue_comparator() local log = require('pending.log') local keys = config.get().view.queue.sort or { 'status', 'priority', 'due', 'order', 'id' } local comparators = {} + local unknown = {} for _, key in ipairs(keys) do local cmp = sort_key_comparators[key] if cmp then table.insert(comparators, cmp) else - log.warn('unknown queue sort key: ' .. key) + table.insert(unknown, key) end end + if #unknown > 0 then + local label = #unknown == 1 and 'unknown queue sort key: ' or 'unknown queue sort keys: ' + log.warn(label .. table.concat(unknown, ', ')) + end return function(a, b) for _, cmp in ipairs(comparators) do local result = cmp(a, b) From 2fd95e6dde99bcd0dcdca7a1968008d7e1c9537a Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 12 Mar 2026 20:55:21 -0400 Subject: [PATCH 192/199] feat: add `cancelled` task status with configurable state chars (#158) Problem: the task lifecycle only has `pending`, `wip`, `blocked`, and `done`. There is no way to mark a task as abandoned. Additionally, state characters (`>`, `=`) are hardcoded rather than reading from `config.icons`, so customizing them has no effect on rendering or parsing. Solution: add a `cancelled` status with default state char `c`, `g/` keymap, `PendingCancelled` highlight, filter predicate, and archive support. Unify state chars by making `state_char()`, `parse_buffer()`, and `infer_status()` read from `config.icons`. Change defaults to mnemonic chars: `w` (wip), `b` (blocked), `c` (cancelled). --- doc/pending.txt | 45 ++++++++++++++++++++++++++++----------- lua/pending/buffer.lua | 24 ++++++++++++++++----- lua/pending/config.lua | 8 +++++-- lua/pending/diff.lua | 15 +++++++------ lua/pending/init.lua | 18 +++++++++++----- lua/pending/store.lua | 4 ++-- lua/pending/sync/gcal.lua | 1 + lua/pending/views.lua | 33 ++++++++++++++++++---------- plugin/pending.lua | 6 +++++- 9 files changed, 109 insertions(+), 45 deletions(-) diff --git a/doc/pending.txt b/doc/pending.txt index ee16bc8..83d09b8 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -347,6 +347,7 @@ Default buffer-local keys: ~ `gr` Prompt for a recurrence pattern (`recur`) `gw` Toggle work-in-progress status (`wip`) `gb` Toggle blocked status (`blocked`) + `g/` Toggle cancelled status (`cancelled`) `gf` Prompt for filter predicates (`filter`) `` Switch between category / queue view (`view`) `gz` Undo the last `:w` save (`undo`) @@ -470,6 +471,12 @@ old keys to `false`: >lua Toggle blocked status for the task under the cursor. If the task is already `blocked`, reverts to `pending`. + *(pending-cancelled)* +(pending-cancelled) + Toggle cancelled status for the task under the cursor. + If the task is already `cancelled`, reverts to `pending`. + Toggling on a `done` task switches it to `cancelled`. + *(pending-priority-up)* (pending-priority-up) Increment the priority level for the task under the cursor, clamped @@ -537,14 +544,15 @@ 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, - tasks are sorted by status (wip → pending → blocked → done), then by + tasks are sorted by status (wip → pending → blocked → done → cancelled), 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 a configurable tiebreak chain (default: status → priority → due → order → id). See - `view.queue.sort` in |pending-config| for customization. Category + `view.queue.sort` in |pending-config| for customization. Status + order: wip → pending → blocked → done → cancelled. 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`. @@ -581,6 +589,8 @@ Available predicates: ~ `blocked` Show only tasks with status `blocked`. + `cancelled` Show only tasks with status `cancelled`. + `clear` Special value for |:Pending-filter| — clears the active filter and shows all tasks. @@ -778,6 +788,7 @@ loads: >lua move_up = 'K', wip = 'gw', blocked = 'gb', + cancelled = 'g/', }, sync = { gcal = {}, @@ -957,17 +968,21 @@ Fields: ~ See |pending-gcal|, |pending-gtasks|, |pending-s3|. {icons} (table) *pending.Icons* - Icon characters displayed in the buffer. The - {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: + Icon characters used for rendering and parsing + task checkboxes. The {pending}, {done}, + {priority}, {wip}, {blocked}, and {cancelled} + characters determine what is written inside + brackets (`[icon]`) in the buffer text and how + status is inferred on `:w`. Each must be a + single character. The {category} character + prefixes 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: '=' + {wip} Work-in-progress character. Default: 'w' + {blocked} Blocked task character. Default: 'b' + {cancelled} Cancelled task character. Default: 'c' {due} Due date prefix. Default: '.' {recur} Recurrence prefix. Default: '~' {category} Category prefix. Default: '#' @@ -1024,6 +1039,10 @@ PendingWip Applied to the checkbox icon of work-in-progress tasks. PendingBlocked Applied to the checkbox icon and text of blocked tasks. Default: links to `DiagnosticError`. + *PendingCancelled* +PendingCancelled Applied to the checkbox icon and text of cancelled tasks. + Default: links to `NonText`. + *PendingPriority* PendingPriority Applied to the checkbox icon of priority 1 tasks. Default: links to `DiagnosticWarn`. @@ -1593,8 +1612,8 @@ with cached data and updates extmarks when the fetch completes. State pull: ~ Requires `forge.close = true`. After fetching, if the remote issue/PR -is closed or merged and the local task is pending/wip/blocked, the task is -automatically marked as done. Disabled by default. One-way: local status +is closed or merged and the local task is pending/wip/blocked (not cancelled), +the task is automatically marked as done. Disabled by default. One-way: local status changes do not push back to the forge. Highlight groups: ~ @@ -1622,7 +1641,7 @@ Task fields: ~ {id} (integer) Unique, auto-incrementing task identifier. {description} (string) Task text as shown in the buffer. {status} (string) `'pending'`, `'wip'`, `'blocked'`, `'done'`, - or `'deleted'`. + `'cancelled'`, or `'deleted'`. {category} (string) Category name. Defaults to `default_category`. {priority} (integer) Priority level: `0` (none), `1`–`3` (or up to `max_priority`). Higher values sort first. diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index b731262..403205d 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -139,6 +139,14 @@ local function apply_inline_row(bufnr, row, m, icons) hl_group = 'PendingDone', invalidate = true, }) + elseif m.status == 'cancelled' 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 = 'PendingCancelled', + invalidate = true, + }) 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 @@ -153,10 +161,12 @@ local function apply_inline_row(bufnr, row, m, icons) local icon, icon_hl if m.status == 'done' then icon, icon_hl = icons.done, 'PendingDone' + elseif m.status == 'cancelled' then + icon, icon_hl = icons.cancelled, 'PendingCancelled' elseif m.status == 'wip' then - icon, icon_hl = icons.wip or '>', 'PendingWip' + icon, icon_hl = icons.wip, 'PendingWip' elseif m.status == 'blocked' then - icon, icon_hl = icons.blocked or '=', 'PendingBlocked' + icon, icon_hl = icons.blocked, 'PendingBlocked' elseif m.priority and m.priority >= 3 then icon, icon_hl = icons.priority, 'PendingPriority3' elseif m.priority and m.priority == 2 then @@ -209,11 +219,14 @@ local function infer_status(line) if not ch then return nil end - if ch == 'x' then + local icons = config.get().icons + if ch == icons.done then return 'done' - elseif ch == '>' then + elseif ch == icons.cancelled then + return 'cancelled' + elseif ch == icons.wip then return 'wip' - elseif ch == '=' then + elseif ch == icons.blocked then return 'blocked' end return 'pending' @@ -566,6 +579,7 @@ local function setup_highlights() 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, 'PendingCancelled', { link = 'NonText', 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, 'PendingForge', { link = 'DiagnosticInfo', default = true }) diff --git a/lua/pending/config.lua b/lua/pending/config.lua index 171dd1a..368cf21 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -11,6 +11,7 @@ ---@field priority string ---@field wip string ---@field blocked string +---@field cancelled string ---@field due string ---@field recur string ---@field category string @@ -80,6 +81,7 @@ ---@field blocked? string|false ---@field priority_up_visual? string|false ---@field priority_down_visual? string|false +---@field cancelled? string|false ---@class pending.CategoryViewConfig ---@field order? string[] @@ -160,6 +162,7 @@ local defaults = { move_up = 'K', wip = 'gw', blocked = 'gb', + cancelled = 'g/', priority_up = '', priority_down = '', priority_up_visual = 'g', @@ -190,8 +193,9 @@ local defaults = { pending = ' ', done = 'x', priority = '!', - wip = '>', - blocked = '=', + wip = 'w', + blocked = 'b', + cancelled = 'c', due = '.', recur = '~', category = '#', diff --git a/lua/pending/diff.lua b/lua/pending/diff.lua index ac38f7a..fd00c0e 100644 --- a/lua/pending/diff.lua +++ b/lua/pending/diff.lua @@ -43,14 +43,17 @@ function M.parse_buffer(lines) table.insert(result, { type = 'blank', lnum = i }) elseif id or body then local stripped = body:match('^- %[.?%] (.*)$') or body - local state_char = body:match('^- %[(.-)%]') or ' ' - local priority = state_char == '!' and 1 or 0 + local icons = config.get().icons + local state_char = body:match('^- %[(.-)%]') or icons.pending + local priority = state_char == icons.priority and 1 or 0 local status - if state_char == 'x' then + if state_char == icons.done then status = 'done' - elseif state_char == '>' then + elseif state_char == icons.cancelled then + status = 'cancelled' + elseif state_char == icons.wip then status = 'wip' - elseif state_char == '=' then + elseif state_char == icons.blocked then status = 'blocked' else status = 'pending' @@ -177,7 +180,7 @@ function M.apply(lines, s, hidden_ids) end if entry.status and task.status ~= entry.status then task.status = entry.status - if entry.status == 'done' then + if entry.status == 'done' or entry.status == 'cancelled' then task['end'] = now else task['end'] = nil diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 8f1b9e4..39c0bae 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 ~= 'done' and task.status ~= 'deleted' then + if task.status ~= 'done' and task.status ~= 'deleted' and task.status ~= 'cancelled' then pending = pending + 1 if task.priority > 0 then priority = priority + 1 @@ -173,6 +173,11 @@ local function compute_hidden_ids(tasks, predicates) visible = false break end + elseif pred == 'cancelled' then + if task.status ~= 'cancelled' then + visible = false + break + end end end if not visible then @@ -368,6 +373,9 @@ function M._setup_buf_mappings(bufnr) blocked = function() M.toggle_status('blocked') end, + cancelled = function() + M.toggle_status('cancelled') + end, priority_up = function() M.increment_priority() end, @@ -840,7 +848,7 @@ function M.prompt_date() end) end ----@param target_status 'wip'|'blocked' +---@param target_status 'wip'|'blocked'|'cancelled' ---@return nil function M.toggle_status(target_status) local bufnr = buffer.bufnr() @@ -866,7 +874,7 @@ function M.toggle_status(target_status) return end if task.status == target_status then - s:update(id, { status = 'pending' }) + s:update(id, { status = 'pending', ['end'] = vim.NIL }) else s:update(id, { status = target_status }) end @@ -1184,7 +1192,7 @@ function M.archive(arg) log.debug(('archive: days=%d cutoff=%s total_tasks=%d'):format(days, cutoff, #tasks)) local count = 0 for _, task in ipairs(tasks) do - if (task.status == 'done' or task.status == 'deleted') and task['end'] then + if (task.status == 'done' or task.status == 'deleted' or task.status == 'cancelled') and task['end'] then if task['end'] < cutoff then count = count + 1 end @@ -1205,7 +1213,7 @@ function M.archive(arg) function() local kept = {} for _, task in ipairs(tasks) do - if (task.status == 'done' or task.status == 'deleted') and task['end'] then + if (task.status == 'done' or task.status == 'deleted' or task.status == 'cancelled') and task['end'] then if task['end'] < cutoff then goto skip end diff --git a/lua/pending/store.lua b/lua/pending/store.lua index 5870fc6..7c43c0d 100644 --- a/lua/pending/store.lua +++ b/lua/pending/store.lua @@ -1,6 +1,6 @@ local config = require('pending.config') ----@alias pending.TaskStatus 'pending'|'done'|'deleted'|'wip'|'blocked' +---@alias pending.TaskStatus 'pending'|'done'|'deleted'|'wip'|'blocked'|'cancelled' ---@alias pending.RecurMode 'scheduled'|'completion' ---@class pending.TaskExtra @@ -331,7 +331,7 @@ function Store:update(id, fields) end end task.modified = now - if fields.status == 'done' or fields.status == 'deleted' then + if fields.status == 'done' or fields.status == 'deleted' or fields.status == 'cancelled' then task['end'] = task['end'] or now end return task diff --git a/lua/pending/sync/gcal.lua b/lua/pending/sync/gcal.lua index a0a7617..811105e 100644 --- a/lua/pending/sync/gcal.lua +++ b/lua/pending/sync/gcal.lua @@ -177,6 +177,7 @@ function M.push() and ( task.status == 'done' or task.status == 'deleted' + or task.status == 'cancelled' or (task.status == 'pending' and not task.due) ) diff --git a/lua/pending/views.lua b/lua/pending/views.lua index 12cbbc0..4321e64 100644 --- a/lua/pending/views.lua +++ b/lua/pending/views.lua @@ -71,21 +71,24 @@ local function compute_forge_spans(task, prefix_len) end ---@type table -local status_rank = { wip = 0, pending = 1, blocked = 2, done = 3 } +local status_rank = { wip = 0, pending = 1, blocked = 2, done = 3, cancelled = 4 } ---@param task pending.Task ---@return string local function state_char(task) + local icons = config.get().icons if task.status == 'done' then - return 'x' + return icons.done + elseif task.status == 'cancelled' then + return icons.cancelled elseif task.status == 'wip' then - return '>' + return icons.wip elseif task.status == 'blocked' then - return '=' + return icons.blocked elseif task.priority > 0 then - return '!' + return icons.priority end - return ' ' + return icons.pending end ---@param tasks pending.Task[] @@ -208,7 +211,7 @@ function M.category_view(tasks) by_cat[cat] = {} done_by_cat[cat] = {} end - if task.status == 'done' or task.status == 'deleted' then + if task.status == 'done' or task.status == 'deleted' or task.status == 'cancelled' then table.insert(done_by_cat[cat], task) else table.insert(by_cat[cat], task) @@ -271,7 +274,11 @@ function M.category_view(tasks) status = task.status, category = cat, priority = task.priority, - overdue = task.status ~= 'done' and task.due ~= nil and parse.is_overdue(task.due) or nil, + overdue = task.status ~= 'done' + and task.status ~= 'cancelled' + and task.due ~= nil + and parse.is_overdue(task.due) + or nil, recur = task.recur, forge_spans = compute_forge_spans(task, prefix_len), }) @@ -289,7 +296,7 @@ function M.priority_view(tasks) local done = {} for _, task in ipairs(tasks) do - if task.status == 'done' then + if task.status == 'done' or task.status == 'cancelled' then table.insert(done, task) else table.insert(pending, task) @@ -312,7 +319,7 @@ function M.priority_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 local prefix_len = #prefix + #('- [' .. state .. '] ') table.insert(lines, line) @@ -324,7 +331,11 @@ function M.priority_view(tasks) status = task.status, category = task.category, priority = task.priority, - overdue = task.status ~= 'done' and task.due ~= nil and parse.is_overdue(task.due) or nil, + overdue = task.status ~= 'done' + and task.status ~= 'cancelled' + and task.due ~= nil + and parse.is_overdue(task.due) + or nil, show_category = true, recur = task.recur, forge_ref = task._extra and task._extra._forge_ref or nil, diff --git a/plugin/pending.lua b/plugin/pending.lua index 62e2e89..d9420c6 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -246,7 +246,7 @@ end, { used[word] = true end local candidates = - { 'clear', 'overdue', 'today', 'priority', 'done', 'pending', 'wip', 'blocked' } + { 'clear', 'overdue', 'today', 'priority', 'done', 'pending', 'wip', 'blocked', 'cancelled' } local store = require('pending.store') local s = store.new(store.resolve_path()) s:load() @@ -394,6 +394,10 @@ vim.keymap.set('n', '(pending-blocked)', function() require('pending').toggle_status('blocked') end) +vim.keymap.set('n', '(pending-cancelled)', function() + require('pending').toggle_status('cancelled') +end) + vim.keymap.set('n', '(pending-priority-up)', function() require('pending').increment_priority() end) From 1a2ec7a5ec3355d024dc25ed2eb1535166507897 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 12 Mar 2026 21:01:00 -0400 Subject: [PATCH 193/199] fix(config): use `/` as default cancelled icon (#159) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: the cancelled icon defaulted to `c`, inconsistent with the `g/` keymap. Other statuses match: `gw` → `[w]`, `gb` → `[b]`. Solution: change `icons.cancelled` default from `c` to `/` so the keymap and state char are consistent. --- doc/pending.txt | 2 +- lua/pending/config.lua | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/pending.txt b/doc/pending.txt index 83d09b8..541f773 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -982,7 +982,7 @@ Fields: ~ {priority} Priority task character. Default: '!' {wip} Work-in-progress character. Default: 'w' {blocked} Blocked task character. Default: 'b' - {cancelled} Cancelled task character. Default: 'c' + {cancelled} Cancelled task character. Default: '/' {due} Due date prefix. Default: '.' {recur} Recurrence prefix. Default: '~' {category} Category prefix. Default: '#' diff --git a/lua/pending/config.lua b/lua/pending/config.lua index 368cf21..c282dbd 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -195,7 +195,7 @@ local defaults = { priority = '!', wip = 'w', blocked = 'b', - cancelled = 'c', + cancelled = '/', due = '.', recur = '~', category = '#', From ba8b550b8c0ac70887994891392c996326c31790 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Thu, 12 Mar 2026 21:01:11 -0400 Subject: [PATCH 194/199] ci: format --- lua/pending/init.lua | 10 ++++++++-- lua/pending/views.lua | 12 ++++++------ plugin/pending.lua | 13 +++++++++++-- 3 files changed, 25 insertions(+), 10 deletions(-) diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 39c0bae..38fdf50 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -1192,7 +1192,10 @@ function M.archive(arg) log.debug(('archive: days=%d cutoff=%s total_tasks=%d'):format(days, cutoff, #tasks)) local count = 0 for _, task in ipairs(tasks) do - if (task.status == 'done' or task.status == 'deleted' or task.status == 'cancelled') and task['end'] then + if + (task.status == 'done' or task.status == 'deleted' or task.status == 'cancelled') + and task['end'] + then if task['end'] < cutoff then count = count + 1 end @@ -1213,7 +1216,10 @@ function M.archive(arg) function() local kept = {} for _, task in ipairs(tasks) do - if (task.status == 'done' or task.status == 'deleted' or task.status == 'cancelled') and task['end'] then + if + (task.status == 'done' or task.status == 'deleted' or task.status == 'cancelled') + and task['end'] + then if task['end'] < cutoff then goto skip end diff --git a/lua/pending/views.lua b/lua/pending/views.lua index 4321e64..7afeeb7 100644 --- a/lua/pending/views.lua +++ b/lua/pending/views.lua @@ -275,9 +275,9 @@ function M.category_view(tasks) category = cat, priority = task.priority, overdue = task.status ~= 'done' - and task.status ~= 'cancelled' - and task.due ~= nil - and parse.is_overdue(task.due) + and task.status ~= 'cancelled' + and task.due ~= nil + and parse.is_overdue(task.due) or nil, recur = task.recur, forge_spans = compute_forge_spans(task, prefix_len), @@ -332,9 +332,9 @@ function M.priority_view(tasks) category = task.category, priority = task.priority, overdue = task.status ~= 'done' - and task.status ~= 'cancelled' - and task.due ~= nil - and parse.is_overdue(task.due) + and task.status ~= 'cancelled' + 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 d9420c6..8e2f633 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -245,8 +245,17 @@ end, { for word in after_filter:gmatch('%S+') do used[word] = true end - local candidates = - { 'clear', 'overdue', 'today', 'priority', 'done', 'pending', 'wip', 'blocked', 'cancelled' } + local candidates = { + 'clear', + 'overdue', + 'today', + 'priority', + 'done', + 'pending', + 'wip', + 'blocked', + 'cancelled', + } local store = require('pending.store') local s = store.new(store.resolve_path()) s:load() From 20506af84958d693572a14f8362035ce95113810 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Fri, 13 Mar 2026 08:22:04 -0400 Subject: [PATCH 195/199] feat: add markdown detail buffer for task notes (#162) Problem: tasks only have a one-line description. There is no way to attach extended notes, checklists, or context to a task. Solution: add `ge` keymap to open a `pending://task/` markdown buffer that replaces the task list in the same split. The buffer shows a read-only metadata header (status, priority, category, due, recurrence) rendered via extmarks, a `---` separator, and editable notes below. `:w` saves notes to a new top-level `notes` field on the task stored in the single `tasks.json`. `q` returns to the task list. --- doc/pending.txt | 32 +++++++ lua/pending/buffer.lua | 210 +++++++++++++++++++++++++++++++++++++++++ lua/pending/config.lua | 2 + lua/pending/init.lua | 43 +++++++++ lua/pending/store.lua | 6 ++ lua/pending/views.lua | 1 + plugin/pending.lua | 4 + 7 files changed, 298 insertions(+) diff --git a/doc/pending.txt b/doc/pending.txt index 541f773..7026922 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -348,6 +348,7 @@ Default buffer-local keys: ~ `gw` Toggle work-in-progress status (`wip`) `gb` Toggle blocked status (`blocked`) `g/` Toggle cancelled status (`cancelled`) + `ge` Open markdown detail buffer for task notes (`edit_notes`) `gf` Prompt for filter predicates (`filter`) `` Switch between category / queue view (`view`) `gz` Undo the last `:w` save (`undo`) @@ -487,6 +488,12 @@ old keys to `false`: >lua Decrement the priority level for the task under the cursor, clamped at 0. Default key: ``. + *(pending-edit-notes)* +(pending-edit-notes) + Open the markdown detail buffer for the task under the cursor. + Shows a read-only metadata header and editable notes below a `---` + separator. Press `q` to return to the task list. Default key: `ge`. + *(pending-open-line)* (pending-open-line) Insert a correctly-formatted blank task line below the cursor. @@ -557,6 +564,29 @@ Queue view: ~ *pending-view-queue* virtual text so tasks remain identifiable across categories. The buffer is named `pending://queue`. +============================================================================== +DETAIL BUFFER *pending-detail-buffer* + +Press `ge` (or `keymaps.edit_notes`) on a task to open a markdown detail +buffer named `pending://task/`. The buffer replaces the task list in +the same split. + +Layout: ~ + + Line 1: `# ` (task description as heading) + Lines 2-3: Read-only metadata (status, priority, category, due, + recurrence) rendered as virtual text overlays + Line 4: `---` separator + Line 5+: Free-form markdown notes (editable) + +The metadata header is not editable — it is rendered via extmarks on +empty buffer lines. To change metadata, return to the task list and use +the normal keymaps or `:Pending edit`. + +Write (`:w`) saves the notes content (everything below the `---` +separator) to the `notes` field in the task store. Press `q` to return +to the task list. + ============================================================================== FILTERS *pending-filters* @@ -789,6 +819,7 @@ loads: >lua wip = 'gw', blocked = 'gb', cancelled = 'g/', + edit_notes = 'ge', }, sync = { gcal = {}, @@ -1651,6 +1682,7 @@ Task fields: ~ {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. + {notes} (string) Free-form markdown notes (from detail buffer). {order} (integer) Relative ordering within a category. Any field not in the list above is preserved in `_extra` and written back on diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index 403205d..f65ebaa 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -584,6 +584,7 @@ local function setup_highlights() vim.api.nvim_set_hl(0, 'PendingFilter', { link = 'DiagnosticWarn', default = true }) vim.api.nvim_set_hl(0, 'PendingForge', { link = 'DiagnosticInfo', default = true }) vim.api.nvim_set_hl(0, 'PendingForgeClosed', { link = 'Comment', default = true }) + vim.api.nvim_set_hl(0, 'PendingDetailMeta', { link = 'Comment', default = true }) end ---@return string @@ -805,4 +806,213 @@ function M.open() return task_bufnr end +local ns_detail = vim.api.nvim_create_namespace('pending_detail') +local DETAIL_SEPARATOR = '---' + +---@type integer? +local _detail_bufnr = nil +---@type integer? +local _detail_task_id = nil + +---@return integer? +function M.detail_bufnr() + return _detail_bufnr +end + +---@return integer? +function M.detail_task_id() + return _detail_task_id +end + +---@param bufnr integer +---@param task pending.Task +---@return nil +local function apply_detail_extmarks(bufnr, task) + vim.api.nvim_buf_clear_namespace(bufnr, ns_detail, 0, -1) + local icons = config.get().icons + local parts = {} + local status_label = task.status or 'pending' + local icon_char = icons[status_label] or icons.pending + table.insert(parts, { 'Status: [' .. icon_char .. '] ' .. status_label, 'PendingDetailMeta' }) + if task.priority and task.priority > 0 then + table.insert(parts, { ' ', 'Normal' }) + table.insert( + parts, + { 'Priority: ' .. string.rep(icons.priority, task.priority), 'PendingDetailMeta' } + ) + end + local line2 = {} + if task.category then + table.insert(line2, { 'Category: ' .. task.category, 'PendingDetailMeta' }) + end + if task.due then + if #line2 > 0 then + table.insert(line2, { ' ', 'Normal' }) + end + local due_label = task.due + local y, mo, d = task.due:match('^(%d%d%d%d)%-(%d%d)%-(%d%d)') + if y then + local t = os.time({ + year = tonumber(y) --[[@as integer]], + month = tonumber(mo) --[[@as integer]], + day = tonumber(d) --[[@as integer]], + }) + due_label = os.date(config.get().date_format, t) --[[@as string]] + end + table.insert(line2, { 'Due: ' .. due_label, 'PendingDetailMeta' }) + end + if task.recur then + if #line2 > 0 then + table.insert(line2, { ' ', 'Normal' }) + end + table.insert(line2, { 'Recur: ' .. task.recur, 'PendingDetailMeta' }) + end + if #parts > 0 then + vim.api.nvim_buf_set_extmark(bufnr, ns_detail, 1, 0, { + virt_text = parts, + virt_text_pos = 'overlay', + }) + end + if #line2 > 0 then + vim.api.nvim_buf_set_extmark(bufnr, ns_detail, 2, 0, { + virt_text = line2, + virt_text_pos = 'overlay', + }) + end +end + +---@param task_id integer +---@return integer? bufnr +function M.open_detail(task_id) + if not _store then + return nil + end + if _detail_bufnr and vim.api.nvim_buf_is_valid(_detail_bufnr) then + if _detail_task_id == task_id then + return _detail_bufnr + end + vim.api.nvim_buf_delete(_detail_bufnr, { force = true }) + _detail_bufnr = nil + _detail_task_id = nil + end + local task = _store:get(task_id) + if not task then + log.warn('task not found: ' .. task_id) + return nil + end + + setup_highlights() + + local bufnr = vim.api.nvim_create_buf(true, false) + vim.api.nvim_buf_set_name(bufnr, 'pending://task/' .. task_id) + vim.bo[bufnr].buftype = 'acwrite' + vim.bo[bufnr].filetype = 'markdown' + vim.bo[bufnr].swapfile = false + + local lines = { + '# ' .. task.description, + '', + '', + DETAIL_SEPARATOR, + } + local notes = task.notes or '' + if notes ~= '' then + for note_line in (notes .. '\n'):gmatch('(.-)\n') do + table.insert(lines, note_line) + end + else + table.insert(lines, '') + end + + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + vim.bo[bufnr].modified = false + + apply_detail_extmarks(bufnr, task) + + local winid = task_winid + if winid and vim.api.nvim_win_is_valid(winid) then + vim.api.nvim_win_set_buf(winid, bufnr) + end + + vim.wo[winid].conceallevel = 0 + vim.wo[winid].foldmethod = 'manual' + vim.wo[winid].foldenable = false + + _detail_bufnr = bufnr + _detail_task_id = task_id + + local separator_row = 3 + local cursor_row = separator_row + 2 + local total = vim.api.nvim_buf_line_count(bufnr) + if cursor_row > total then + cursor_row = total + end + pcall(vim.api.nvim_win_set_cursor, winid, { cursor_row, 0 }) + + return bufnr +end + +---@return nil +function M.close_detail() + if _detail_bufnr and vim.api.nvim_buf_is_valid(_detail_bufnr) then + vim.api.nvim_buf_delete(_detail_bufnr, { force = true }) + end + _detail_bufnr = nil + _detail_task_id = nil + + if task_winid and vim.api.nvim_win_is_valid(task_winid) then + if task_bufnr and vim.api.nvim_buf_is_valid(task_bufnr) then + vim.api.nvim_win_set_buf(task_winid, task_bufnr) + set_win_options(task_winid) + M.render(task_bufnr) + end + end +end + +---@return nil +function M.save_detail() + if not _detail_bufnr or not _detail_task_id or not _store then + return + end + local task = _store:get(_detail_task_id) + if not task then + log.warn('task was deleted') + M.close_detail() + return + end + + local lines = vim.api.nvim_buf_get_lines(_detail_bufnr, 0, -1, false) + + local sep_row = nil + for i, line in ipairs(lines) do + if line == DETAIL_SEPARATOR then + sep_row = i + break + end + end + + local notes_text = '' + if sep_row and sep_row < #lines then + local note_lines = {} + for i = sep_row + 1, #lines do + table.insert(note_lines, lines[i]) + end + notes_text = table.concat(note_lines, '\n') + notes_text = notes_text:gsub('%s+$', '') + end + + if notes_text == '' then + _store:update(_detail_task_id, { notes = vim.NIL }) + else + _store:update(_detail_task_id, { notes = notes_text }) + end + _store:save() + + vim.bo[_detail_bufnr].modified = false + local updated = _store:get(_detail_task_id) + if updated then + apply_detail_extmarks(_detail_bufnr, updated) + end +end + return M diff --git a/lua/pending/config.lua b/lua/pending/config.lua index c282dbd..0015b37 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -82,6 +82,7 @@ ---@field priority_up_visual? string|false ---@field priority_down_visual? string|false ---@field cancelled? string|false +---@field edit_notes? string|false ---@class pending.CategoryViewConfig ---@field order? string[] @@ -163,6 +164,7 @@ local defaults = { wip = 'gw', blocked = 'gb', cancelled = 'g/', + edit_notes = 'ge', priority_up = '', priority_down = '', priority_up_visual = 'g', diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 38fdf50..5c28998 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -401,6 +401,9 @@ function M._setup_buf_mappings(bufnr) open_line_above = function() buffer.open_line(true) end, + edit_notes = function() + M.open_detail() + end, } for name, fn in pairs(actions) do @@ -888,6 +891,46 @@ function M.toggle_status(target_status) end end +---@return nil +function M.open_detail() + 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 detail_bufnr = buffer.open_detail(id) + if not detail_bufnr then + return + end + + local group = vim.api.nvim_create_augroup('PendingDetail', { clear = true }) + vim.api.nvim_create_autocmd('BufWriteCmd', { + group = group, + buffer = detail_bufnr, + callback = function() + buffer.save_detail() + end, + }) + + local km = require('pending.config').get().keymaps + vim.keymap.set('n', km.close or 'q', function() + vim.api.nvim_del_augroup_by_name('PendingDetail') + buffer.close_detail() + end, { buffer = detail_bufnr }) +end + ---@param direction 'up'|'down' ---@return nil function M.move_task(direction) diff --git a/lua/pending/store.lua b/lua/pending/store.lua index 7c43c0d..0938eda 100644 --- a/lua/pending/store.lua +++ b/lua/pending/store.lua @@ -24,6 +24,7 @@ local config = require('pending.config') ---@field entry string ---@field modified string ---@field end? string +---@field notes? string ---@field order integer ---@field _extra? pending.TaskExtra @@ -93,6 +94,7 @@ local known_fields = { entry = true, modified = true, ['end'] = true, + notes = true, order = true, } @@ -124,6 +126,9 @@ local function task_to_table(task) if task['end'] then t['end'] = task['end'] end + if task.notes then + t.notes = task.notes + end if task.order and task.order ~= 0 then t.order = task.order end @@ -150,6 +155,7 @@ local function table_to_task(t) entry = t.entry, modified = t.modified, ['end'] = t['end'], + notes = t.notes, order = t.order or 0, _extra = {}, } diff --git a/lua/pending/views.lua b/lua/pending/views.lua index 7afeeb7..3dbd06f 100644 --- a/lua/pending/views.lua +++ b/lua/pending/views.lua @@ -341,6 +341,7 @@ function M.priority_view(tasks) forge_ref = task._extra and task._extra._forge_ref or nil, forge_cache = task._extra and task._extra._forge_cache or nil, forge_spans = compute_forge_spans(task, prefix_len), + has_notes = task.notes ~= nil and task.notes ~= '', }) end diff --git a/plugin/pending.lua b/plugin/pending.lua index 8e2f633..9f25dd5 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -407,6 +407,10 @@ vim.keymap.set('n', '(pending-cancelled)', function() require('pending').toggle_status('cancelled') end) +vim.keymap.set('n', '(pending-edit-notes)', function() + require('pending').open_detail() +end) + vim.keymap.set('n', '(pending-priority-up)', function() require('pending').increment_priority() end) From 649f238683a643e10ea0f58f015ce504ee7f7bd3 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Fri, 13 Mar 2026 11:06:45 -0400 Subject: [PATCH 196/199] feat(detail): parse and validate editable frontmatter on save (#163) Problem: the detail buffer rendered metadata as read-only virtual text overlays. Users could not edit status, priority, category, due, or recurrence from the detail view. Solution: render frontmatter as real `Key: value` text lines highlighted via extmarks. On `:w`, `parse_detail_frontmatter()` validates every field (status, priority bounds, `resolve_date`, `recur.validate`) and aborts with `log.error()` on any invalid input. Removing a line clears the field; editing the `# title` updates the description. --- lua/pending/buffer.lua | 250 ++++++++++++++++++------- spec/detail_spec.lua | 402 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 585 insertions(+), 67 deletions(-) create mode 100644 spec/detail_spec.lua diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index f65ebaa..c98ebf9 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -824,59 +824,46 @@ function M.detail_task_id() return _detail_task_id end ----@param bufnr integer +local VALID_STATUSES = { + pending = true, + done = true, + wip = true, + blocked = true, + cancelled = true, +} + ---@param task pending.Task ----@return nil -local function apply_detail_extmarks(bufnr, task) - vim.api.nvim_buf_clear_namespace(bufnr, ns_detail, 0, -1) - local icons = config.get().icons - local parts = {} - local status_label = task.status or 'pending' - local icon_char = icons[status_label] or icons.pending - table.insert(parts, { 'Status: [' .. icon_char .. '] ' .. status_label, 'PendingDetailMeta' }) - if task.priority and task.priority > 0 then - table.insert(parts, { ' ', 'Normal' }) - table.insert( - parts, - { 'Priority: ' .. string.rep(icons.priority, task.priority), 'PendingDetailMeta' } - ) - end - local line2 = {} +---@return string[] +local function build_detail_frontmatter(task) + local lines = {} + table.insert(lines, 'Status: ' .. (task.status or 'pending')) + table.insert(lines, 'Priority: ' .. (task.priority or 0)) if task.category then - table.insert(line2, { 'Category: ' .. task.category, 'PendingDetailMeta' }) + table.insert(lines, 'Category: ' .. task.category) end if task.due then - if #line2 > 0 then - table.insert(line2, { ' ', 'Normal' }) - end - local due_label = task.due - local y, mo, d = task.due:match('^(%d%d%d%d)%-(%d%d)%-(%d%d)') - if y then - local t = os.time({ - year = tonumber(y) --[[@as integer]], - month = tonumber(mo) --[[@as integer]], - day = tonumber(d) --[[@as integer]], - }) - due_label = os.date(config.get().date_format, t) --[[@as string]] - end - table.insert(line2, { 'Due: ' .. due_label, 'PendingDetailMeta' }) + table.insert(lines, 'Due: ' .. task.due) end if task.recur then - if #line2 > 0 then - table.insert(line2, { ' ', 'Normal' }) + local recur_val = task.recur + if task.recur_mode == 'completion' then + recur_val = '!' .. recur_val end - table.insert(line2, { 'Recur: ' .. task.recur, 'PendingDetailMeta' }) + table.insert(lines, 'Recur: ' .. recur_val) end - if #parts > 0 then - vim.api.nvim_buf_set_extmark(bufnr, ns_detail, 1, 0, { - virt_text = parts, - virt_text_pos = 'overlay', - }) - end - if #line2 > 0 then - vim.api.nvim_buf_set_extmark(bufnr, ns_detail, 2, 0, { - virt_text = line2, - virt_text_pos = 'overlay', + return lines +end + +---@param bufnr integer +---@param sep_row integer +---@return nil +local function apply_detail_extmarks(bufnr, sep_row) + vim.api.nvim_buf_clear_namespace(bufnr, ns_detail, 0, -1) + for i = 1, sep_row - 1 do + vim.api.nvim_buf_set_extmark(bufnr, ns_detail, i, 0, { + end_row = i, + end_col = #(vim.api.nvim_buf_get_lines(bufnr, i, i + 1, false)[1] or ''), + hl_group = 'PendingDetailMeta', }) end end @@ -909,12 +896,12 @@ function M.open_detail(task_id) vim.bo[bufnr].filetype = 'markdown' vim.bo[bufnr].swapfile = false - local lines = { - '# ' .. task.description, - '', - '', - DETAIL_SEPARATOR, - } + local lines = { '# ' .. task.description } + local fm = build_detail_frontmatter(task) + for _, fl in ipairs(fm) do + table.insert(lines, fl) + end + table.insert(lines, DETAIL_SEPARATOR) local notes = task.notes or '' if notes ~= '' then for note_line in (notes .. '\n'):gmatch('(.-)\n') do @@ -927,7 +914,8 @@ function M.open_detail(task_id) vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) vim.bo[bufnr].modified = false - apply_detail_extmarks(bufnr, task) + local sep_row = #fm + 1 + apply_detail_extmarks(bufnr, sep_row) local winid = task_winid if winid and vim.api.nvim_win_is_valid(winid) then @@ -941,8 +929,7 @@ function M.open_detail(task_id) _detail_bufnr = bufnr _detail_task_id = task_id - local separator_row = 3 - local cursor_row = separator_row + 2 + local cursor_row = sep_row + 2 local total = vim.api.nvim_buf_line_count(bufnr) if cursor_row > total then cursor_row = total @@ -969,6 +956,124 @@ function M.close_detail() end end +---@param lines string[] +---@return integer? sep_row +---@return pending.DetailFields? fields +---@return string? err +local function parse_detail_frontmatter(lines) + local parse = require('pending.parse') + local recur = require('pending.recur') + local cfg = config.get() + + local sep_row = nil + for i, line in ipairs(lines) do + if line == DETAIL_SEPARATOR then + sep_row = i + break + end + end + if not sep_row then + return nil, nil, 'missing separator (---)' + end + + local desc = lines[1] and lines[1]:match('^# (.+)$') + if not desc or desc:match('^%s*$') then + return nil, nil, 'missing or empty title (first line must be # )' + end + + ---@class pending.DetailFields + ---@field description string + ---@field status pending.TaskStatus + ---@field priority integer + ---@field category? string|userdata + ---@field due? string|userdata + ---@field recur? string|userdata + ---@field recur_mode? pending.RecurMode|userdata + local fields = { + description = desc, + status = 'pending', + priority = 0, + category = vim.NIL, + due = vim.NIL, + recur = vim.NIL, + recur_mode = vim.NIL, + } + + local seen = {} ---@type table<string, boolean> + for i = 2, sep_row - 1 do + local line = lines[i] + if line:match('^%s*$') then + goto continue + end + local key, val = line:match('^(%S+):%s*(.*)$') + if not key then + return nil, nil, 'invalid frontmatter line: ' .. line + end + key = key:lower() + if seen[key] then + return nil, nil, 'duplicate field: ' .. key + end + seen[key] = true + + if key == 'status' then + val = val:lower() + if not VALID_STATUSES[val] then + return nil, nil, 'invalid status: ' .. val + end + fields.status = val --[[@as pending.TaskStatus]] + elseif key == 'priority' then + local n = tonumber(val) + if not n or n ~= math.floor(n) or n < 0 then + return nil, nil, 'invalid priority: ' .. val .. ' (must be integer >= 0)' + end + local max = cfg.max_priority or 3 + if n > max then + return nil, nil, 'invalid priority: ' .. val .. ' (max is ' .. max .. ')' + end + fields.priority = n --[[@as integer]] + elseif key == 'category' then + if val == '' then + return nil, nil, 'empty category value' + end + fields.category = val + elseif key == 'due' then + if val == '' then + return nil, nil, 'empty due value (remove the line to clear)' + end + local resolved = parse.resolve_date(val) + if resolved then + fields.due = resolved + elseif + val:match('^%d%d%d%d%-%d%d%-%d%d$') or val:match('^%d%d%d%d%-%d%d%-%d%dT%d%d:%d%d$') + then + fields.due = val + else + return nil, nil, 'invalid due date: ' .. val + end + elseif key == 'recur' then + if val == '' then + return nil, nil, 'empty recur value (remove the line to clear)' + end + local raw_spec = 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: ' .. val + end + fields.recur = raw_spec + fields.recur_mode = rec_mode or vim.NIL + else + return nil, nil, 'unknown field: ' .. key + end + ::continue:: + end + + return sep_row, fields, nil +end + ---@return nil function M.save_detail() if not _detail_bufnr or not _detail_task_id or not _store then @@ -983,16 +1088,16 @@ function M.save_detail() local lines = vim.api.nvim_buf_get_lines(_detail_bufnr, 0, -1, false) - local sep_row = nil - for i, line in ipairs(lines) do - if line == DETAIL_SEPARATOR then - sep_row = i - break - end + local sep_row, fields, err = parse_detail_frontmatter(lines) + if err then + log.error(err) + return end + ---@cast sep_row integer + ---@cast fields pending.DetailFields local notes_text = '' - if sep_row and sep_row < #lines then + if sep_row < #lines then local note_lines = {} for i = sep_row + 1, #lines do table.insert(note_lines, lines[i]) @@ -1001,18 +1106,29 @@ function M.save_detail() notes_text = notes_text:gsub('%s+$', '') end + local update = { + description = fields.description, + status = fields.status, + priority = fields.priority, + category = fields.category, + due = fields.due, + recur = fields.recur, + recur_mode = fields.recur_mode, + } if notes_text == '' then - _store:update(_detail_task_id, { notes = vim.NIL }) + update.notes = vim.NIL else - _store:update(_detail_task_id, { notes = notes_text }) + update.notes = notes_text end + + _store:update(_detail_task_id, update) _store:save() vim.bo[_detail_bufnr].modified = false - local updated = _store:get(_detail_task_id) - if updated then - apply_detail_extmarks(_detail_bufnr, updated) - end + apply_detail_extmarks(_detail_bufnr, sep_row - 1) end +M._parse_detail_frontmatter = parse_detail_frontmatter +M._build_detail_frontmatter = build_detail_frontmatter + return M diff --git a/spec/detail_spec.lua b/spec/detail_spec.lua new file mode 100644 index 0000000..50f7ae7 --- /dev/null +++ b/spec/detail_spec.lua @@ -0,0 +1,402 @@ +require('spec.helpers') + +local config = require('pending.config') + +describe('detail frontmatter', function() + local buffer + local tmpdir + + 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 + buffer = require('pending.buffer') + 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('build_detail_frontmatter', function() + it('renders status and priority for minimal task', function() + local lines = buffer._build_detail_frontmatter({ + id = 1, + description = 'Test', + status = 'pending', + priority = 0, + entry = '', + modified = '', + order = 0, + }) + assert.are.equal(2, #lines) + assert.are.equal('Status: pending', lines[1]) + assert.are.equal('Priority: 0', lines[2]) + end) + + it('renders all fields', function() + local lines = buffer._build_detail_frontmatter({ + id = 1, + description = 'Test', + status = 'wip', + priority = 2, + category = 'Work', + due = '2026-03-15', + recur = 'weekly', + entry = '', + modified = '', + order = 0, + }) + assert.are.equal(5, #lines) + assert.are.equal('Status: wip', lines[1]) + assert.are.equal('Priority: 2', lines[2]) + assert.are.equal('Category: Work', lines[3]) + assert.are.equal('Due: 2026-03-15', lines[4]) + assert.are.equal('Recur: weekly', lines[5]) + end) + + it('prefixes recur with ! for completion mode', function() + local lines = buffer._build_detail_frontmatter({ + id = 1, + description = 'Test', + status = 'pending', + priority = 0, + recur = 'daily', + recur_mode = 'completion', + entry = '', + modified = '', + order = 0, + }) + assert.are.equal('Recur: !daily', lines[3]) + end) + + it('omits optional fields when absent', function() + local lines = buffer._build_detail_frontmatter({ + id = 1, + description = 'Test', + status = 'done', + priority = 1, + entry = '', + modified = '', + order = 0, + }) + assert.are.equal(2, #lines) + assert.are.equal('Status: done', lines[1]) + assert.are.equal('Priority: 1', lines[2]) + end) + end) + + describe('parse_detail_frontmatter', function() + it('parses minimal frontmatter', function() + local lines = { + '# My task', + 'Status: pending', + 'Priority: 0', + '---', + 'some notes', + } + local sep, fields, err = buffer._parse_detail_frontmatter(lines) + assert.is_nil(err) + assert.are.equal(4, sep) + assert.are.equal('My task', fields.description) + assert.are.equal('pending', fields.status) + assert.are.equal(0, fields.priority) + end) + + it('parses all fields', function() + local lines = { + '# Fix the bug', + 'Status: wip', + 'Priority: 2', + 'Category: Work', + 'Due: 2026-03-15', + 'Recur: weekly', + '---', + } + local sep, fields, err = buffer._parse_detail_frontmatter(lines) + assert.is_nil(err) + assert.are.equal(7, sep) + assert.are.equal('Fix the bug', fields.description) + assert.are.equal('wip', fields.status) + assert.are.equal(2, fields.priority) + assert.are.equal('Work', fields.category) + assert.are.equal('2026-03-15', fields.due) + assert.are.equal('weekly', fields.recur) + end) + + it('resolves due date keywords', function() + local lines = { + '# Task', + 'Status: pending', + 'Priority: 0', + 'Due: tomorrow', + '---', + } + local _, fields, err = buffer._parse_detail_frontmatter(lines) + assert.is_nil(err) + 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, fields.due) + end) + + it('parses completion-mode recurrence', function() + local lines = { + '# Task', + 'Status: pending', + 'Priority: 0', + 'Recur: !daily', + '---', + } + local _, fields, err = buffer._parse_detail_frontmatter(lines) + assert.is_nil(err) + assert.are.equal('daily', fields.recur) + assert.are.equal('completion', fields.recur_mode) + end) + + it('clears optional fields when lines removed', function() + local lines = { + '# Task', + 'Status: done', + 'Priority: 1', + '---', + } + local _, fields, err = buffer._parse_detail_frontmatter(lines) + assert.is_nil(err) + assert.are.equal(vim.NIL, fields.category) + assert.are.equal(vim.NIL, fields.due) + assert.are.equal(vim.NIL, fields.recur) + end) + + it('skips blank lines in frontmatter', function() + local lines = { + '# Task', + 'Status: pending', + '', + 'Priority: 0', + '---', + } + local _, fields, err = buffer._parse_detail_frontmatter(lines) + assert.is_nil(err) + assert.are.equal('pending', fields.status) + assert.are.equal(0, fields.priority) + end) + + it('errors on missing separator', function() + local lines = { + '# Task', + 'Status: pending', + 'Priority: 0', + } + local _, _, err = buffer._parse_detail_frontmatter(lines) + assert.truthy(err:find('missing separator')) + end) + + it('errors on missing title', function() + local lines = { + '', + 'Status: pending', + '---', + } + local _, _, err = buffer._parse_detail_frontmatter(lines) + assert.truthy(err:find('missing or empty title')) + end) + + it('errors on empty title', function() + local lines = { + '# ', + 'Status: pending', + '---', + } + local _, _, err = buffer._parse_detail_frontmatter(lines) + assert.truthy(err:find('missing or empty title')) + end) + + it('errors on invalid status', function() + local lines = { + '# Task', + 'Status: bogus', + 'Priority: 0', + '---', + } + local _, _, err = buffer._parse_detail_frontmatter(lines) + assert.truthy(err:find('invalid status')) + end) + + it('errors on negative priority', function() + local lines = { + '# Task', + 'Status: pending', + 'Priority: -1', + '---', + } + local _, _, err = buffer._parse_detail_frontmatter(lines) + assert.truthy(err:find('invalid priority')) + end) + + it('errors on non-integer priority', function() + local lines = { + '# Task', + 'Status: pending', + 'Priority: 1.5', + '---', + } + local _, _, err = buffer._parse_detail_frontmatter(lines) + assert.truthy(err:find('invalid priority')) + end) + + it('errors on priority exceeding max', function() + local lines = { + '# Task', + 'Status: pending', + 'Priority: 4', + '---', + } + local _, _, err = buffer._parse_detail_frontmatter(lines) + assert.truthy(err:find('max is 3')) + end) + + it('errors on invalid due date', function() + local lines = { + '# Task', + 'Status: pending', + 'Priority: 0', + 'Due: notadate', + '---', + } + local _, _, err = buffer._parse_detail_frontmatter(lines) + assert.truthy(err:find('invalid due date')) + end) + + it('errors on empty due value', function() + local lines = { + '# Task', + 'Due: ', + '---', + } + local _, _, err = buffer._parse_detail_frontmatter(lines) + assert.truthy(err:find('empty due value')) + end) + + it('errors on invalid recurrence', function() + local lines = { + '# Task', + 'Recur: nope', + '---', + } + local _, _, err = buffer._parse_detail_frontmatter(lines) + assert.truthy(err:find('invalid recurrence')) + end) + + it('errors on empty recur value', function() + local lines = { + '# Task', + 'Recur: ', + '---', + } + local _, _, err = buffer._parse_detail_frontmatter(lines) + assert.truthy(err:find('empty recur value')) + end) + + it('errors on empty category value', function() + local lines = { + '# Task', + 'Category: ', + '---', + } + local _, _, err = buffer._parse_detail_frontmatter(lines) + assert.truthy(err:find('empty category')) + end) + + it('errors on unknown field', function() + local lines = { + '# Task', + 'Status: pending', + 'Foo: bar', + '---', + } + local _, _, err = buffer._parse_detail_frontmatter(lines) + assert.truthy(err:find('unknown field: foo')) + end) + + it('errors on duplicate field', function() + local lines = { + '# Task', + 'Status: pending', + 'Status: done', + '---', + } + local _, _, err = buffer._parse_detail_frontmatter(lines) + assert.truthy(err:find('duplicate field')) + end) + + it('errors on malformed frontmatter line', function() + local lines = { + '# Task', + 'not a key value pair', + '---', + } + local _, _, err = buffer._parse_detail_frontmatter(lines) + assert.truthy(err:find('invalid frontmatter line')) + end) + + it('is case-insensitive for field keys', function() + local lines = { + '# Task', + 'STATUS: wip', + 'PRIORITY: 1', + 'CATEGORY: Work', + '---', + } + local _, fields, err = buffer._parse_detail_frontmatter(lines) + assert.is_nil(err) + assert.are.equal('wip', fields.status) + assert.are.equal(1, fields.priority) + assert.are.equal('Work', fields.category) + end) + + it('accepts datetime due format', function() + local lines = { + '# Task', + 'Due: 2026-03-15T14:00', + '---', + } + local _, fields, err = buffer._parse_detail_frontmatter(lines) + assert.is_nil(err) + assert.are.equal('2026-03-15T14:00', fields.due) + end) + + it('respects custom max_priority', function() + vim.g.pending = { data_path = tmpdir .. '/tasks.json', max_priority = 5 } + config.reset() + local lines = { + '# Task', + 'Priority: 5', + '---', + } + local _, fields, err = buffer._parse_detail_frontmatter(lines) + assert.is_nil(err) + assert.are.equal(5, fields.priority) + end) + + it('updates description from title line', function() + local lines = { + '# Updated title', + 'Status: pending', + 'Priority: 0', + '---', + } + local _, fields, err = buffer._parse_detail_frontmatter(lines) + assert.is_nil(err) + assert.are.equal('Updated title', fields.description) + end) + end) +end) From 99294df1220884ecd24cb92112f96b65d86e5c48 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <br.barrettruth@gmail.com> Date: Fri, 13 Mar 2026 17:58:49 -0400 Subject: [PATCH 197/199] fix: revert dev --- lua/pending/health.lua | 8 +++- lua/pending/init.lua | 58 +++++++++++++++++++++++--- plugin/pending.lua | 12 +++--- spec/sync_spec.lua | 94 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 160 insertions(+), 12 deletions(-) diff --git a/lua/pending/health.lua b/lua/pending/health.lua index d00031b..7d95b5d 100644 --- a/lua/pending/health.lua +++ b/lua/pending/health.lua @@ -59,7 +59,7 @@ function M.check() end local sync_paths = vim.fn.globpath(vim.o.runtimepath, 'lua/pending/sync/*.lua', false, true) - if #sync_paths == 0 then + if #sync_paths == 0 and vim.tbl_isempty(require('pending').registered_backends()) then vim.health.info('No sync backends found') else for _, path in ipairs(sync_paths) do @@ -70,6 +70,12 @@ function M.check() backend.health() end end + for rname, rbackend in pairs(require('pending').registered_backends()) do + if type(rbackend.health) == 'function' then + vim.health.start('pending.nvim: sync/' .. rname) + rbackend.health() + end + end end end diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 5c28998..013533c 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -1141,18 +1141,60 @@ end ---@class pending.SyncBackend ---@field name string ----@field auth fun(): nil +---@field auth? fun(sub_action?: string): nil ---@field push? fun(): nil ---@field pull? fun(): nil ---@field sync? fun(): nil ---@field health? fun(): nil +---@type table<string, pending.SyncBackend> +local _registered_backends = {} + ---@type string[]? local _sync_backends = nil ---@type table<string, true>? local _sync_backend_set = nil +---@param name string +---@return pending.SyncBackend? +function M.resolve_backend(name) + if _registered_backends[name] then + return _registered_backends[name] + end + local ok, mod = pcall(require, 'pending.sync.' .. name) + if ok and type(mod) == 'table' and mod.name then + return mod + end + return nil +end + +---@param backend pending.SyncBackend +---@return nil +function M.register_backend(backend) + if type(backend) ~= 'table' or type(backend.name) ~= 'string' or backend.name == '' then + log.error('register_backend: backend must have a non-empty `name` field') + return + end + local builtin_ok, builtin = pcall(require, 'pending.sync.' .. backend.name) + if builtin_ok and type(builtin) == 'table' and builtin.name then + log.error('register_backend: backend `' .. backend.name .. '` already exists as a built-in') + return + end + if _registered_backends[backend.name] then + log.error('register_backend: backend `' .. backend.name .. '` is already registered') + return + end + _registered_backends[backend.name] = backend + _sync_backends = nil + _sync_backend_set = nil +end + +---@return table<string, pending.SyncBackend> +function M.registered_backends() + return _registered_backends +end + ---@return string[], table<string, true> local function discover_backends() if _sync_backends then @@ -1169,6 +1211,12 @@ local function discover_backends() _sync_backend_set[mod.name] = true end end + for name, _ in pairs(_registered_backends) do + if not _sync_backend_set[name] then + table.insert(_sync_backends, name) + _sync_backend_set[name] = true + end + end table.sort(_sync_backends) return _sync_backends, _sync_backend_set end @@ -1177,8 +1225,8 @@ end ---@param action? string ---@return nil local function run_sync(backend_name, action) - local ok, backend = pcall(require, 'pending.sync.' .. backend_name) - if not ok then + local backend = M.resolve_backend(backend_name) + if not backend then log.error('Unknown sync backend: ' .. backend_name) return end @@ -1543,8 +1591,8 @@ function M.auth(args) local backends_list = discover_backends() local auth_backends = {} for _, name in ipairs(backends_list) do - local ok, mod = pcall(require, 'pending.sync.' .. name) - if ok and type(mod.auth) == 'function' then + local mod = M.resolve_backend(name) + if mod and type(mod.auth) == 'function' then table.insert(auth_backends, { name = name, mod = mod }) end end diff --git a/plugin/pending.lua b/plugin/pending.lua index 9f25dd5..48ade42 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -304,8 +304,8 @@ end, { if #parts == 0 or (#parts == 1 and not trailing) then local auth_names = {} for _, b in ipairs(pending.sync_backends()) do - local ok, mod = pcall(require, 'pending.sync.' .. b) - if ok and type(mod.auth) == 'function' then + local mod = pending.resolve_backend(b) + if mod and type(mod.auth) == 'function' then table.insert(auth_names, b) end end @@ -313,8 +313,8 @@ end, { end local backend_name = parts[1] if #parts == 1 or (#parts == 2 and not trailing) then - local ok, mod = pcall(require, 'pending.sync.' .. backend_name) - if ok and type(mod.auth_complete) == 'function' then + local mod = pending.resolve_backend(backend_name) + if mod and type(mod.auth_complete) == 'function' then return filter_candidates(arg_lead, mod.auth_complete()) end return {} @@ -328,8 +328,8 @@ end, { if not after_backend then return {} end - local ok, mod = pcall(require, 'pending.sync.' .. matched_backend) - if not ok then + local mod = pending.resolve_backend(matched_backend) + if not mod then return {} end local actions = {} diff --git a/spec/sync_spec.lua b/spec/sync_spec.lua index 51156bf..b7dfe8d 100644 --- a/spec/sync_spec.lua +++ b/spec/sync_spec.lua @@ -124,6 +124,100 @@ describe('sync', function() end) end) + describe('register_backend', function() + it('registers a custom backend', function() + pending.register_backend({ name = 'custom', pull = function() end }) + local set = pending.sync_backend_set() + assert.is_true(set['custom'] == true) + assert.is_true(vim.tbl_contains(pending.sync_backends(), 'custom')) + end) + + it('rejects backend without name', function() + local msg + local orig = vim.notify + vim.notify = function(m, level) + if level == vim.log.levels.ERROR then + msg = m + end + end + pending.register_backend({}) + vim.notify = orig + assert.truthy(msg and msg:find('non%-empty')) + end) + + it('rejects backend with empty name', function() + local msg + local orig = vim.notify + vim.notify = function(m, level) + if level == vim.log.levels.ERROR then + msg = m + end + end + pending.register_backend({ name = '' }) + vim.notify = orig + assert.truthy(msg and msg:find('non%-empty')) + end) + + it('rejects duplicate of built-in 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.register_backend({ name = 'gcal' }) + vim.notify = orig + assert.truthy(msg and msg:find('already exists')) + end) + + it('rejects duplicate registered backend', function() + pending.register_backend({ name = 'dup_test', pull = function() end }) + local msg + local orig = vim.notify + vim.notify = function(m, level) + if level == vim.log.levels.ERROR then + msg = m + end + end + pending.register_backend({ name = 'dup_test' }) + vim.notify = orig + assert.truthy(msg and msg:find('already registered')) + end) + end) + + describe('resolve_backend', function() + it('resolves built-in backend', function() + local mod = pending.resolve_backend('gcal') + assert.is_not_nil(mod) + assert.are.equal('gcal', mod.name) + end) + + it('resolves registered backend', function() + local custom = { name = 'resolve_test', pull = function() end } + pending.register_backend(custom) + local mod = pending.resolve_backend('resolve_test') + assert.is_not_nil(mod) + assert.are.equal('resolve_test', mod.name) + end) + + it('returns nil for unknown backend', function() + assert.is_nil(pending.resolve_backend('nonexistent_xyz')) + end) + + it('dispatches command to registered backend', function() + local called = false + pending.register_backend({ + name = 'cmd_test', + pull = function() + called = true + end, + }) + pending.command('cmd_test pull') + assert.is_true(called) + end) + end) + describe('auto-discovery', function() it('discovers gcal and gtasks backends', function() local backends = pending.sync_backends() From 6e220797b9e72992b80de4d2d81a946ce9b764c1 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <br.barrettruth@gmail.com> Date: Fri, 13 Mar 2026 20:38:29 -0400 Subject: [PATCH 198/199] ci: some fixes --- doc/pending.txt | 15 +++-- lua/pending/parse.lua | 129 +++++++++++++++++++++--------------------- spec/parse_spec.lua | 44 +++++++++++++- 3 files changed, 111 insertions(+), 77 deletions(-) diff --git a/doc/pending.txt b/doc/pending.txt index 7026922..3052d15 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -140,9 +140,9 @@ COMMANDS *pending-commands* :Pending add Work: standup due:tomorrow rec:weekdays :Pending add Buy milk due:fri +!! < - Trailing `+!`, `+!!`, or `+!!!` tokens set the priority level (capped - at `max_priority`). If the buffer is currently open it is re-rendered - after the add. + `+!`, `+!!`, or `+!!!` tokens anywhere in the text 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 [{duration}] @@ -638,8 +638,8 @@ 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. +Metadata tokens may appear anywhere in a task line. On save, tokens are +extracted from any position and the remaining words form the description. Supported tokens: ~ @@ -663,9 +663,8 @@ 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. +Only the first occurrence of each metadata type is consumed — duplicate +tokens are silently dropped. Omnifunc completion is available for `due:`, `cat:`, and `rec:` token types. In insert mode, type the token prefix and press `<C-x><C-o>` to see diff --git a/lua/pending/parse.lua b/lua/pending/parse.lua index 1b36578..9fd179e 100644 --- a/lua/pending/parse.lua +++ b/lua/pending/parse.lua @@ -536,7 +536,6 @@ function M.body(text) end local metadata = {} - local i = #tokens local ck = category_key() local dk = date_key() local rk = recur_key() @@ -544,84 +543,82 @@ function M.body(text) 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+)$' - local forge_indices = {} + local desc_tokens = {} + local forge_tokens = {} + + for _, token in ipairs(tokens) do + local consumed = false - while i >= 1 do - local token = tokens[i] local due_val = token:match(date_pattern_strict) - if due_val then - if metadata.due then - break + if due_val and is_valid_datetime(due_val) then + if not metadata.due then + metadata.due = due_val end - if not is_valid_datetime(due_val) then - break - end - metadata.due = due_val - i = i - 1 - else + consumed = true + end + if not consumed then local raw_val = token:match(date_pattern_any) if raw_val then - if metadata.due then - break - end local resolved = M.resolve_date(raw_val) - if not resolved then - break - end - metadata.due = resolved - i = i - 1 - else - local cat_val = token:match(cat_pattern) - if cat_val then - if metadata.category then - break + if resolved then + if not metadata.due then + metadata.due = resolved end + consumed = true + end + end + end + + if not consumed then + local cat_val = token:match(cat_pattern) + if cat_val then + if not metadata.category then metadata.category = cat_val - i = i - 1 - else - local pri_bangs = token:match('^%+(!+)$') - if pri_bangs then - if metadata.priority then - break - end - local max = config.get().max_priority or 3 - metadata.priority = math.min(#pri_bangs, max) - i = i - 1 - else - local rec_val = token:match(rec_pattern) - if rec_val then - if metadata.recur then - break - end - local recur = require('pending.recur') - local raw_spec = rec_val - if raw_spec:sub(1, 1) == '!' then - metadata.recur_mode = 'completion' - raw_spec = raw_spec:sub(2) - end - if not recur.validate(raw_spec) then - break - end - metadata.recur = raw_spec - i = i - 1 - elseif forge.parse_ref(token) then - table.insert(forge_indices, i) - i = i - 1 - else - break - end - end end + consumed = true + end + end + + if not consumed then + local pri_bangs = token:match('^%+(!+)$') + if pri_bangs then + if not metadata.priority then + local max = config.get().max_priority or 3 + metadata.priority = math.min(#pri_bangs, max) + end + consumed = true + end + end + + if not consumed then + local rec_val = token:match(rec_pattern) + if rec_val then + local recur = require('pending.recur') + local raw_spec = rec_val + if raw_spec:sub(1, 1) == '!' then + raw_spec = raw_spec:sub(2) + end + if recur.validate(raw_spec) then + if not metadata.recur then + metadata.recur_mode = rec_val:sub(1, 1) == '!' and 'completion' or nil + metadata.recur = raw_spec + end + consumed = true + end + end + end + + if not consumed then + if forge.parse_ref(token) then + table.insert(forge_tokens, token) + else + table.insert(desc_tokens, token) end end end - local desc_tokens = {} - for j = 1, i do - table.insert(desc_tokens, tokens[j]) - end - for fi = #forge_indices, 1, -1 do - table.insert(desc_tokens, tokens[forge_indices[fi]]) + for _, ft in ipairs(forge_tokens) do + table.insert(desc_tokens, ft) end local description = table.concat(desc_tokens, ' ') diff --git a/spec/parse_spec.lua b/spec/parse_spec.lua index aebe0c7..e02f1dc 100644 --- a/spec/parse_spec.lua +++ b/spec/parse_spec.lua @@ -48,10 +48,16 @@ describe('parse', function() assert.are.equal('Errands', meta.category) end) - it('stops at duplicate key', function() + it('first occurrence wins for duplicate keys', function() local desc, meta = parse.body('Buy milk due:2026-03-15 due:2026-04-01') - assert.are.equal('Buy milk due:2026-03-15', desc) - assert.are.equal('2026-04-01', meta.due) + assert.are.equal('Buy milk', desc) + assert.are.equal('2026-03-15', meta.due) + end) + + it('drops identical duplicate metadata tokens', function() + local desc, meta = parse.body('Buy milk due:tomorrow due:tomorrow') + assert.are.equal('Buy milk', desc) + assert.are.equal(os.date('%Y-%m-%d', os.time() + 86400), meta.due) end) it('stops at non-meta token', function() @@ -138,6 +144,38 @@ describe('parse', function() assert.are.equal('Work', meta.category) assert.truthy(desc:find('gl:a/b#12', 1, true)) end) + + it('extracts leading metadata', function() + local desc, meta = parse.body('due:2026-03-15 Fix the bug') + assert.are.equal('Fix the bug', desc) + assert.are.equal('2026-03-15', meta.due) + end) + + it('extracts metadata from the middle', function() + local desc, meta = parse.body('Fix due:2026-03-15 the bug') + assert.are.equal('Fix the bug', desc) + assert.are.equal('2026-03-15', meta.due) + end) + + it('extracts multiple metadata from any position', function() + local desc, meta = parse.body('cat:Work Fix due:2026-03-15 the bug') + assert.are.equal('Fix the bug', desc) + assert.are.equal('2026-03-15', meta.due) + assert.are.equal('Work', meta.category) + end) + + it('extracts all metadata types from mixed positions', function() + local today = os.date('*t') --[[@as osdate]] + local tomorrow = os.date( + '%Y-%m-%d', + os.time({ year = today.year, month = today.month, day = today.day + 1 }) + ) + local desc, meta = parse.body('due:tomorrow cat:Work Fix the bug +!') + assert.are.equal('Fix the bug', desc) + assert.are.equal(tomorrow, meta.due) + assert.are.equal('Work', meta.category) + assert.are.equal(1, meta.priority) + end) end) describe('parse.resolve_date', function() From f3ef1ca0db3ebaa63a3319fcc9f5d005fc4003e5 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Fri, 13 Mar 2026 20:48:18 -0400 Subject: [PATCH 199/199] fix(parse): position-independent inline metadata parsing (#164) Problem: `parse.body()` scanned tokens right-to-left and broke on the first non-metadata token, so metadata only worked at the trailing end of a line. `due:tomorrow Fix the bug` silently failed to parse the due date. Solution: Replace the right-to-left `while` loop with a single left-to-right pass that extracts metadata tokens from any position. Duplicate metadata tokens are dropped with a `log.warn`. Update docs and tests accordingly. --- doc/pending.txt | 2 +- lua/pending/parse.lua | 11 +++++++++++ spec/parse_spec.lua | 23 +++++++++++++++++++++-- 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/doc/pending.txt b/doc/pending.txt index 3052d15..9a62c3d 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -664,7 +664,7 @@ On `:w`, the description becomes `Buy milk`, the due date is stored as placed under the `Errands` category header. Only the first occurrence of each metadata type is consumed — duplicate -tokens are silently dropped. +tokens are dropped with a warning. Omnifunc completion is available for `due:`, `cat:`, and `rec:` token types. In insert mode, type the token prefix and press `<C-x><C-o>` to see diff --git a/lua/pending/parse.lua b/lua/pending/parse.lua index 9fd179e..a85d7af 100644 --- a/lua/pending/parse.lua +++ b/lua/pending/parse.lua @@ -1,5 +1,6 @@ local config = require('pending.config') local forge = require('pending.forge') +local log = require('pending.log') ---@class pending.Metadata ---@field due? string @@ -553,6 +554,8 @@ function M.body(text) if due_val and is_valid_datetime(due_val) then if not metadata.due then metadata.due = due_val + else + log.warn('duplicate ' .. dk .. ': token ignored: ' .. token) end consumed = true end @@ -563,6 +566,8 @@ function M.body(text) if resolved then if not metadata.due then metadata.due = resolved + else + log.warn('duplicate ' .. dk .. ': token ignored: ' .. token) end consumed = true end @@ -574,6 +579,8 @@ function M.body(text) if cat_val then if not metadata.category then metadata.category = cat_val + else + log.warn('duplicate ' .. ck .. ': token ignored: ' .. token) end consumed = true end @@ -585,6 +592,8 @@ function M.body(text) if not metadata.priority then local max = config.get().max_priority or 3 metadata.priority = math.min(#pri_bangs, max) + else + log.warn('duplicate priority token ignored: ' .. token) end consumed = true end @@ -602,6 +611,8 @@ function M.body(text) if not metadata.recur then metadata.recur_mode = rec_val:sub(1, 1) == '!' and 'completion' or nil metadata.recur = raw_spec + else + log.warn('duplicate ' .. rk .. ': token ignored: ' .. token) end consumed = true end diff --git a/spec/parse_spec.lua b/spec/parse_spec.lua index e02f1dc..b0a3f8e 100644 --- a/spec/parse_spec.lua +++ b/spec/parse_spec.lua @@ -48,16 +48,35 @@ describe('parse', function() assert.are.equal('Errands', meta.category) end) - it('first occurrence wins for duplicate keys', function() + it('first occurrence wins for duplicate keys and warns', function() + local warnings = {} + local orig = vim.notify + vim.notify = function(m, level) + if level == vim.log.levels.WARN then + table.insert(warnings, m) + end + end local desc, meta = parse.body('Buy milk due:2026-03-15 due:2026-04-01') + vim.notify = orig assert.are.equal('Buy milk', desc) assert.are.equal('2026-03-15', meta.due) + assert.are.equal(1, #warnings) + assert.truthy(warnings[1]:find('duplicate', 1, true)) end) - it('drops identical duplicate metadata tokens', function() + it('drops identical duplicate metadata tokens and warns', function() + local warnings = {} + local orig = vim.notify + vim.notify = function(m, level) + if level == vim.log.levels.WARN then + table.insert(warnings, m) + end + end local desc, meta = parse.body('Buy milk due:tomorrow due:tomorrow') + vim.notify = orig assert.are.equal('Buy milk', desc) assert.are.equal(os.date('%Y-%m-%d', os.time() + 86400), meta.due) + assert.are.equal(1, #warnings) end) it('stops at non-meta token', function()