diff --git a/.github/DISCUSSION_TEMPLATE/q-a.yaml b/.github/DISCUSSION_TEMPLATE/q-a.yaml index 0e657eb..a65fd46 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 0796c39..baae06b 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -1,13 +1,14 @@ 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 @@ -15,16 +16,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 @@ -48,8 +49,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 f4c02eb..cabb27c 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -1,13 +1,14 @@ 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 9f934a5..9b6664e 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 df7f3dd..98e14d3 100644 --- a/README.md +++ b/README.md @@ -2,30 +2,145 @@ 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. -## Requirements +## How it works -- Neovim 0.10+ -- (Optionally) `curl` and `openssl` for Google Calendar and Google Task sync +``` +School + ! Read chapter 5 Feb 28 + Submit homework Feb 25 -## Installation +Errands + Buy groceries Mar 01 + Clean apartment +``` -Install with your package manager of choice or via -[luarocks](https://luarocks.org/modules/barrettruth/pending.nvim): +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 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 -:help pending.nvim +:checkhealth pending ``` - -## 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) diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index d11254b..b60d633 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -7,15 +7,11 @@ 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 ---@type pending.LineMeta[] local _meta = {} ----@type table> -local _fold_state = {} ---@return pending.LineMeta[] function M.meta() @@ -27,27 +23,11 @@ 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' @@ -68,7 +48,6 @@ 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 @@ -169,48 +148,6 @@ 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 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 - vim.api.nvim_win_set_cursor(0, saved) - end) - _fold_state[winid] = nil - end - end -end - ---@param bufnr? integer function M.render(bufnr) bufnr = bufnr or task_bufnr @@ -219,7 +156,6 @@ 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 @@ -231,7 +167,6 @@ function M.render(bufnr) _meta = line_meta - snapshot_folds(bufnr) vim.bo[bufnr].modifiable = true local saved = vim.bo[bufnr].undolevels vim.bo[bufnr].undolevels = -1 @@ -253,7 +188,6 @@ function M.render(bufnr) vim.wo[winid].foldenable = false end end - restore_folds(bufnr) end function M.toggle_view() @@ -270,25 +204,25 @@ function M.open() setup_highlights() store.load() - if task_winid and vim.api.nvim_win_is_valid(task_winid) then - vim.api.nvim_set_current_win(task_winid) + 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 --[[@as integer]] + return task_bufnr end - 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 + task_bufnr = vim.api.nvim_create_buf(true, false) + vim.api.nvim_buf_set_name(task_bufnr, 'pending://') - 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) + set_buf_options(task_bufnr) + vim.api.nvim_set_current_buf(task_bufnr) + set_win_options(vim.api.nvim_get_current_win()) M.render(task_bufnr) diff --git a/lua/pending/config.lua b/lua/pending/config.lua index b61f44a..2e647e4 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -9,7 +9,6 @@ ---@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 14b9c24..ec69d89 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -38,25 +38,11 @@ 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) diff --git a/spec/archive_spec.lua b/spec/archive_spec.lua index df1a912..a71eee8 100644 --- a/spec/archive_spec.lua +++ b/spec/archive_spec.lua @@ -128,13 +128,4 @@ 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 fda2165..7e7a9cb 100644 --- a/spec/diff_spec.lua +++ b/spec/diff_spec.lua @@ -57,28 +57,6 @@ 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() @@ -129,22 +107,6 @@ 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() @@ -206,7 +168,7 @@ describe('diff', function() assert.is_nil(task.due) end) - it('clears priority when [N] is removed from buffer line', function() + it('clears priority when ! is removed from buffer line', function() store.add({ description = 'Task name', priority = 1 }) store.save() local lines = { diff --git a/spec/parse_spec.lua b/spec/parse_spec.lua index ca8047c..b4442e9 100644 --- a/spec/parse_spec.lua +++ b/spec/parse_spec.lua @@ -96,10 +96,7 @@ 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) @@ -126,10 +123,7 @@ 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) @@ -145,12 +139,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) diff --git a/spec/store_spec.lua b/spec/store_spec.lua index bb6266d..a2e72aa 100644 --- a/spec/store_spec.lua +++ b/spec/store_spec.lua @@ -121,27 +121,6 @@ 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()