Compare commits
9 commits
refactor/m
...
doc/minify
| Author | SHA1 | Date | |
|---|---|---|---|
| 8433d92857 | |||
| fa1103ad4e | |||
|
|
fbeb0e2bee | ||
|
|
5db242a9cf | ||
|
|
8e16744ebe | ||
| cfdffdadfe | |||
| 437944d441 | |||
| 8f9052bad1 | |||
| 7f0bd43b34 |
17 changed files with 295 additions and 286 deletions
2
.github/DISCUSSION_TEMPLATE/q-a.yaml
vendored
2
.github/DISCUSSION_TEMPLATE/q-a.yaml
vendored
|
|
@ -1,4 +1,4 @@
|
||||||
title: 'Q&A'
|
title: "Q&A"
|
||||||
labels: []
|
labels: []
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
|
|
|
||||||
17
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
17
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
|
|
@ -1,14 +1,13 @@
|
||||||
name: Bug Report
|
name: Bug Report
|
||||||
description: Report a bug
|
description: Report a bug
|
||||||
title: 'bug: '
|
title: "bug: "
|
||||||
labels: [bug]
|
labels: [bug]
|
||||||
body:
|
body:
|
||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
attributes:
|
attributes:
|
||||||
label: Prerequisites
|
label: Prerequisites
|
||||||
options:
|
options:
|
||||||
- label:
|
- label: I have searched [existing
|
||||||
I have searched [existing
|
|
||||||
issues](https://github.com/barrettruth/pending.nvim/issues)
|
issues](https://github.com/barrettruth/pending.nvim/issues)
|
||||||
required: true
|
required: true
|
||||||
- label: I have updated to the latest version
|
- label: I have updated to the latest version
|
||||||
|
|
@ -16,16 +15,16 @@ body:
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: 'Neovim version'
|
label: "Neovim version"
|
||||||
description: 'Output of `nvim --version`'
|
description: "Output of `nvim --version`"
|
||||||
render: text
|
render: text
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: input
|
- type: input
|
||||||
attributes:
|
attributes:
|
||||||
label: 'Operating system'
|
label: "Operating system"
|
||||||
placeholder: 'e.g. Arch Linux, macOS 15, Ubuntu 24.04'
|
placeholder: "e.g. Arch Linux, macOS 15, Ubuntu 24.04"
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
|
@ -49,8 +48,8 @@ body:
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: 'Health check'
|
label: "Health check"
|
||||||
description: 'Output of `:checkhealth task`'
|
description: "Output of `:checkhealth task`"
|
||||||
render: text
|
render: text
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
|
|
|
||||||
5
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
5
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
|
|
@ -1,14 +1,13 @@
|
||||||
name: Feature Request
|
name: Feature Request
|
||||||
description: Suggest a feature
|
description: Suggest a feature
|
||||||
title: 'feat: '
|
title: "feat: "
|
||||||
labels: [enhancement]
|
labels: [enhancement]
|
||||||
body:
|
body:
|
||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
attributes:
|
attributes:
|
||||||
label: Prerequisites
|
label: Prerequisites
|
||||||
options:
|
options:
|
||||||
- label:
|
- label: I have searched [existing
|
||||||
I have searched [existing
|
|
||||||
issues](https://github.com/barrettruth/pending.nvim/issues)
|
issues](https://github.com/barrettruth/pending.nvim/issues)
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
|
|
||||||
2
.github/workflows/luarocks.yaml
vendored
2
.github/workflows/luarocks.yaml
vendored
|
|
@ -3,7 +3,7 @@ name: luarocks
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- "v*"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
quality:
|
quality:
|
||||||
|
|
|
||||||
143
README.md
143
README.md
|
|
@ -2,145 +2,30 @@
|
||||||
|
|
||||||
Edit tasks like text. `:w` saves them.
|
Edit tasks like text. `:w` saves them.
|
||||||
|
|
||||||
A buffer-centric task manager for Neovim. Tasks live in a plain buffer — add
|
<!-- insert preview -->
|
||||||
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
|
||||||
|
|
||||||
```
|
- Neovim 0.10+
|
||||||
School
|
- (Optionally) `curl` and `openssl` for Google Calendar and Google Task sync
|
||||||
! Read chapter 5 Feb 28
|
|
||||||
Submit homework Feb 25
|
|
||||||
|
|
||||||
Errands
|
## Installation
|
||||||
Buy groceries Mar 01
|
|
||||||
Clean apartment
|
|
||||||
```
|
|
||||||
|
|
||||||
Category headers sit at column 0. Tasks are indented below them. `!` marks
|
Install with your package manager of choice or via
|
||||||
priority. Due dates appear as right-aligned virtual text. Done tasks get
|
[luarocks](https://luarocks.org/modules/barrettruth/pending.nvim):
|
||||||
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
|
luarocks install pending.nvim
|
||||||
```
|
```
|
||||||
|
|
||||||
**lazy.nvim:**
|
|
||||||
|
|
||||||
```lua
|
|
||||||
{ 'barrettruth/pending.nvim' }
|
|
||||||
```
|
|
||||||
|
|
||||||
Requires Neovim 0.10+. No external dependencies for local use. Google Calendar
|
|
||||||
sync requires `curl` and `openssl`.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
`:Pending` opens the task buffer. From there, it's just vim:
|
|
||||||
|
|
||||||
| Key | Action |
|
|
||||||
| --------- | ------------------------------- |
|
|
||||||
| `o` / `O` | Add a new task |
|
|
||||||
| `dd` | Delete a task (on `:w`) |
|
|
||||||
| `p` | Paste (duplicates get new IDs) |
|
|
||||||
| `:w` | Save all changes |
|
|
||||||
| `<CR>` | Toggle complete (immediate) |
|
|
||||||
| `<Tab>` | Switch category / priority view |
|
|
||||||
| `g?` | Show keybind help |
|
|
||||||
|
|
||||||
### Inline metadata
|
|
||||||
|
|
||||||
Type metadata tokens at the end of a task line before saving:
|
|
||||||
|
|
||||||
```
|
|
||||||
Buy milk due:2026-03-15 cat:Errands
|
|
||||||
```
|
|
||||||
|
|
||||||
On `:w`, the date and category are extracted. The description becomes `Buy milk`,
|
|
||||||
the due date renders as virtual text, and the task moves under the `Errands`
|
|
||||||
header.
|
|
||||||
|
|
||||||
### Quick add
|
|
||||||
|
|
||||||
```vim
|
|
||||||
:Pending add Buy groceries due:2026-03-15
|
|
||||||
:Pending add School: Submit homework
|
|
||||||
```
|
|
||||||
|
|
||||||
### Archive
|
|
||||||
|
|
||||||
```vim
|
|
||||||
:Pending archive " purge done tasks older than 30 days
|
|
||||||
:Pending archive 7 " purge done tasks older than 7 days
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
No `setup()` call required. Set `vim.g.pending` before the plugin loads:
|
|
||||||
|
|
||||||
```lua
|
|
||||||
vim.g.pending = {
|
|
||||||
data_path = vim.fn.stdpath('data') .. '/pending/tasks.json',
|
|
||||||
default_view = 'category', -- 'category' or 'priority'
|
|
||||||
default_category = 'Inbox',
|
|
||||||
date_format = '%b %d', -- strftime format for virtual text
|
|
||||||
date_syntax = 'due', -- inline token name (e.g. 'by' for by:2026-03-15)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
All fields are optional. Absent keys use the defaults shown above.
|
|
||||||
|
|
||||||
## Google Calendar sync
|
|
||||||
|
|
||||||
One-way push of tasks with due dates to a dedicated Google Calendar as all-day
|
|
||||||
events.
|
|
||||||
|
|
||||||
```lua
|
|
||||||
vim.g.pending = {
|
|
||||||
gcal = {
|
|
||||||
calendar = 'Tasks',
|
|
||||||
credentials_path = '/path/to/client_secret.json',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```vim
|
|
||||||
:Pending sync
|
|
||||||
```
|
|
||||||
|
|
||||||
On first run, a browser window opens for OAuth consent. The refresh token is
|
|
||||||
stored at `stdpath('data')/pending/gcal_tokens.json`. Completed or deleted tasks
|
|
||||||
have their calendar events removed. Due date changes update events in place.
|
|
||||||
|
|
||||||
## Mappings
|
|
||||||
|
|
||||||
The plugin defines `<Plug>` mappings for custom keybinds:
|
|
||||||
|
|
||||||
```lua
|
|
||||||
vim.keymap.set('n', '<leader>t', '<Plug>(pending-open)')
|
|
||||||
vim.keymap.set('n', '<leader>T', '<Plug>(pending-toggle)')
|
|
||||||
```
|
|
||||||
|
|
||||||
| Plug mapping | Action |
|
|
||||||
| -------------------------- | -------------------- |
|
|
||||||
| `<Plug>(pending-open)` | Open task buffer |
|
|
||||||
| `<Plug>(pending-toggle)` | Toggle complete |
|
|
||||||
| `<Plug>(pending-view)` | Switch view |
|
|
||||||
| `<Plug>(pending-priority)` | Toggle priority flag |
|
|
||||||
| `<Plug>(pending-date)` | Prompt for due date |
|
|
||||||
|
|
||||||
## Data format
|
|
||||||
|
|
||||||
Tasks are stored as JSON at `stdpath('data')/pending/tasks.json`. The schema is
|
|
||||||
versioned and forward-compatible — unknown fields are preserved on round-trip.
|
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
```vim
|
```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)
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,15 @@ local M = {}
|
||||||
|
|
||||||
---@type integer?
|
---@type integer?
|
||||||
local task_bufnr = nil
|
local task_bufnr = nil
|
||||||
|
---@type integer?
|
||||||
|
local task_winid = nil
|
||||||
local task_ns = vim.api.nvim_create_namespace('pending')
|
local task_ns = vim.api.nvim_create_namespace('pending')
|
||||||
---@type 'category'|'priority'|nil
|
---@type 'category'|'priority'|nil
|
||||||
local current_view = nil
|
local current_view = nil
|
||||||
---@type pending.LineMeta[]
|
---@type pending.LineMeta[]
|
||||||
local _meta = {}
|
local _meta = {}
|
||||||
|
---@type table<integer, table<string, boolean>>
|
||||||
|
local _fold_state = {}
|
||||||
|
|
||||||
---@return pending.LineMeta[]
|
---@return pending.LineMeta[]
|
||||||
function M.meta()
|
function M.meta()
|
||||||
|
|
@ -23,11 +27,27 @@ function M.bufnr()
|
||||||
return task_bufnr
|
return task_bufnr
|
||||||
end
|
end
|
||||||
|
|
||||||
|
---@return integer?
|
||||||
|
function M.winid()
|
||||||
|
return task_winid
|
||||||
|
end
|
||||||
|
|
||||||
---@return string?
|
---@return string?
|
||||||
function M.current_view_name()
|
function M.current_view_name()
|
||||||
return current_view
|
return current_view
|
||||||
end
|
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
|
---@param bufnr integer
|
||||||
local function set_buf_options(bufnr)
|
local function set_buf_options(bufnr)
|
||||||
vim.bo[bufnr].buftype = 'acwrite'
|
vim.bo[bufnr].buftype = 'acwrite'
|
||||||
|
|
@ -48,6 +68,7 @@ local function set_win_options(winid)
|
||||||
vim.wo[winid].foldcolumn = '0'
|
vim.wo[winid].foldcolumn = '0'
|
||||||
vim.wo[winid].spell = false
|
vim.wo[winid].spell = false
|
||||||
vim.wo[winid].cursorline = true
|
vim.wo[winid].cursorline = true
|
||||||
|
vim.wo[winid].winfixheight = true
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param bufnr integer
|
---@param bufnr integer
|
||||||
|
|
@ -56,9 +77,9 @@ local function setup_syntax(bufnr)
|
||||||
vim.cmd([[
|
vim.cmd([[
|
||||||
syntax clear
|
syntax clear
|
||||||
syntax match taskId /^\/\d\+\// conceal
|
syntax match taskId /^\/\d\+\// conceal
|
||||||
syntax match taskHeader /^\S.*$/ contains=taskId
|
syntax match taskHeader /^## .*$/ contains=taskId
|
||||||
syntax match taskPriority /\[\d\+\] / contained containedin=taskLine
|
syntax match taskCheckbox /\[!\]/ contained containedin=taskLine
|
||||||
syntax match taskLine /^\/\d\+\/ .*$/ contains=taskId,taskPriority
|
syntax match taskLine /^\/\d\+\/- \[.\] .*$/ contains=taskId,taskCheckbox
|
||||||
]])
|
]])
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
@ -72,8 +93,8 @@ function M.open_line(above)
|
||||||
local row = vim.api.nvim_win_get_cursor(0)[1]
|
local row = vim.api.nvim_win_get_cursor(0)[1]
|
||||||
local insert_row = above and (row - 1) or row
|
local insert_row = above and (row - 1) or row
|
||||||
vim.bo[bufnr].modifiable = true
|
vim.bo[bufnr].modifiable = true
|
||||||
vim.api.nvim_buf_set_lines(bufnr, insert_row, insert_row, false, { ' ' })
|
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_win_set_cursor(0, { insert_row + 1, 6 })
|
||||||
vim.cmd('startinsert!')
|
vim.cmd('startinsert!')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -113,18 +134,18 @@ local function apply_extmarks(bufnr, line_meta)
|
||||||
if virt_text then
|
if virt_text then
|
||||||
vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, {
|
vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, {
|
||||||
virt_text = virt_text,
|
virt_text = virt_text,
|
||||||
virt_text_pos = 'right_align',
|
virt_text_pos = 'eol',
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
elseif m.due then
|
elseif m.due then
|
||||||
vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, {
|
vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, {
|
||||||
virt_text = { { m.due, due_hl } },
|
virt_text = { { m.due, due_hl } },
|
||||||
virt_text_pos = 'right_align',
|
virt_text_pos = 'eol',
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
if m.status == 'done' then
|
if m.status == 'done' then
|
||||||
local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or ''
|
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, {
|
vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, col_start, {
|
||||||
end_col = #line,
|
end_col = #line,
|
||||||
hl_group = 'PendingDone',
|
hl_group = 'PendingDone',
|
||||||
|
|
@ -148,6 +169,48 @@ local function setup_highlights()
|
||||||
vim.api.nvim_set_hl(0, 'PendingPriority', { link = 'DiagnosticWarn', default = true })
|
vim.api.nvim_set_hl(0, 'PendingPriority', { link = 'DiagnosticWarn', default = true })
|
||||||
end
|
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
|
---@param bufnr? integer
|
||||||
function M.render(bufnr)
|
function M.render(bufnr)
|
||||||
bufnr = bufnr or task_bufnr
|
bufnr = bufnr or task_bufnr
|
||||||
|
|
@ -156,6 +219,7 @@ function M.render(bufnr)
|
||||||
end
|
end
|
||||||
|
|
||||||
current_view = current_view or config.get().default_view
|
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 tasks = store.active_tasks()
|
||||||
|
|
||||||
local lines, line_meta
|
local lines, line_meta
|
||||||
|
|
@ -167,9 +231,13 @@ function M.render(bufnr)
|
||||||
|
|
||||||
_meta = line_meta
|
_meta = line_meta
|
||||||
|
|
||||||
|
snapshot_folds(bufnr)
|
||||||
vim.bo[bufnr].modifiable = true
|
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.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
|
||||||
vim.bo[bufnr].modified = false
|
vim.bo[bufnr].modified = false
|
||||||
|
vim.bo[bufnr].undolevels = saved
|
||||||
|
|
||||||
setup_syntax(bufnr)
|
setup_syntax(bufnr)
|
||||||
apply_extmarks(bufnr, line_meta)
|
apply_extmarks(bufnr, line_meta)
|
||||||
|
|
@ -185,6 +253,7 @@ function M.render(bufnr)
|
||||||
vim.wo[winid].foldenable = false
|
vim.wo[winid].foldenable = false
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
restore_folds(bufnr)
|
||||||
end
|
end
|
||||||
|
|
||||||
function M.toggle_view()
|
function M.toggle_view()
|
||||||
|
|
@ -201,25 +270,25 @@ function M.open()
|
||||||
setup_highlights()
|
setup_highlights()
|
||||||
store.load()
|
store.load()
|
||||||
|
|
||||||
if task_bufnr and vim.api.nvim_buf_is_valid(task_bufnr) then
|
if task_winid and vim.api.nvim_win_is_valid(task_winid) then
|
||||||
local wins = vim.fn.win_findbuf(task_bufnr)
|
vim.api.nvim_set_current_win(task_winid)
|
||||||
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)
|
M.render(task_bufnr)
|
||||||
return task_bufnr
|
return task_bufnr --[[@as integer]]
|
||||||
end
|
end
|
||||||
|
|
||||||
task_bufnr = vim.api.nvim_create_buf(true, false)
|
if not (task_bufnr and vim.api.nvim_buf_is_valid(task_bufnr)) then
|
||||||
vim.api.nvim_buf_set_name(task_bufnr, 'pending://')
|
task_bufnr = vim.api.nvim_create_buf(true, false)
|
||||||
|
set_buf_options(task_bufnr)
|
||||||
|
end
|
||||||
|
|
||||||
set_buf_options(task_bufnr)
|
vim.cmd('botright new')
|
||||||
vim.api.nvim_set_current_buf(task_bufnr)
|
task_winid = vim.api.nvim_get_current_win()
|
||||||
set_win_options(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)
|
M.render(task_bufnr)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
---@field date_format string
|
---@field date_format string
|
||||||
---@field date_syntax string
|
---@field date_syntax string
|
||||||
---@field category_order? string[]
|
---@field category_order? string[]
|
||||||
|
---@field drawer_height? integer
|
||||||
---@field gcal? pending.GcalConfig
|
---@field gcal? pending.GcalConfig
|
||||||
|
|
||||||
---@class pending.config
|
---@class pending.config
|
||||||
|
|
@ -18,7 +19,7 @@ local M = {}
|
||||||
local defaults = {
|
local defaults = {
|
||||||
data_path = vim.fn.stdpath('data') .. '/pending/tasks.json',
|
data_path = vim.fn.stdpath('data') .. '/pending/tasks.json',
|
||||||
default_view = 'category',
|
default_view = 'category',
|
||||||
default_category = 'Inbox',
|
default_category = 'Todo',
|
||||||
date_format = '%b %d',
|
date_format = '%b %d',
|
||||||
date_syntax = 'due',
|
date_syntax = 'due',
|
||||||
category_order = {},
|
category_order = {},
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ local store = require('pending.store')
|
||||||
---@field id? integer
|
---@field id? integer
|
||||||
---@field description? string
|
---@field description? string
|
||||||
---@field priority? integer
|
---@field priority? integer
|
||||||
|
---@field status? string
|
||||||
---@field category? string
|
---@field category? string
|
||||||
---@field due? string
|
---@field due? string
|
||||||
---@field lnum integer
|
---@field lnum integer
|
||||||
|
|
@ -26,20 +27,17 @@ function M.parse_buffer(lines)
|
||||||
local current_category = nil
|
local current_category = nil
|
||||||
|
|
||||||
for i, line in ipairs(lines) do
|
for i, line in ipairs(lines) do
|
||||||
local id, body = line:match('^/(%d+)/( .+)$')
|
local id, body = line:match('^/(%d+)/(- %[.%] .*)$')
|
||||||
if not id then
|
if not id then
|
||||||
body = line:match('^( .+)$')
|
body = line:match('^(- %[.%] .*)$')
|
||||||
end
|
end
|
||||||
if line == '' then
|
if line == '' then
|
||||||
table.insert(result, { type = 'blank', lnum = i })
|
table.insert(result, { type = 'blank', lnum = i })
|
||||||
elseif id or body then
|
elseif id or body then
|
||||||
local stripped = body:match('^ (.+)$') or body
|
local stripped = body:match('^- %[.%] (.*)$') or body
|
||||||
local prio_str = stripped:match('^%[(%d+)%] ')
|
local state_char = body:match('^- %[(.-)%]') or ' '
|
||||||
local priority = 0
|
local priority = state_char == '!' and 1 or 0
|
||||||
if prio_str then
|
local status = state_char == 'x' and 'done' or 'pending'
|
||||||
priority = tonumber(prio_str)
|
|
||||||
stripped = stripped:sub(#prio_str + 4)
|
|
||||||
end
|
|
||||||
local description, metadata = parse.body(stripped)
|
local description, metadata = parse.body(stripped)
|
||||||
if description and description ~= '' then
|
if description and description ~= '' then
|
||||||
table.insert(result, {
|
table.insert(result, {
|
||||||
|
|
@ -47,14 +45,15 @@ function M.parse_buffer(lines)
|
||||||
id = id and tonumber(id) or nil,
|
id = id and tonumber(id) or nil,
|
||||||
description = description,
|
description = description,
|
||||||
priority = priority,
|
priority = priority,
|
||||||
|
status = status,
|
||||||
category = metadata.cat or current_category or config.get().default_category,
|
category = metadata.cat or current_category or config.get().default_category,
|
||||||
due = metadata.due,
|
due = metadata.due,
|
||||||
lnum = i,
|
lnum = i,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
elseif line:match('^%S') then
|
elseif line:match('^## (.+)$') then
|
||||||
current_category = line
|
current_category = line:match('^## (.+)$')
|
||||||
table.insert(result, { type = 'header', category = line, lnum = i })
|
table.insert(result, { type = 'header', category = current_category, lnum = i })
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -113,6 +112,15 @@ function M.apply(lines)
|
||||||
task.due = entry.due
|
task.due = entry.due
|
||||||
changed = true
|
changed = true
|
||||||
end
|
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
|
if task.order ~= order_counter then
|
||||||
task.order = order_counter
|
task.order = order_counter
|
||||||
changed = true
|
changed = true
|
||||||
|
|
|
||||||
|
|
@ -38,11 +38,25 @@ function M._setup_autocmds(bufnr)
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
||||||
---@param bufnr integer
|
---@param bufnr integer
|
||||||
function M._setup_buf_mappings(bufnr)
|
function M._setup_buf_mappings(bufnr)
|
||||||
local opts = { buffer = bufnr, silent = true }
|
local opts = { buffer = bufnr, silent = true }
|
||||||
|
vim.keymap.set('n', 'q', function()
|
||||||
|
buffer.close()
|
||||||
|
end, opts)
|
||||||
|
vim.keymap.set('n', '<Esc>', function()
|
||||||
|
buffer.close()
|
||||||
|
end, opts)
|
||||||
vim.keymap.set('n', '<CR>', function()
|
vim.keymap.set('n', '<CR>', function()
|
||||||
M.toggle_complete()
|
M.toggle_complete()
|
||||||
end, opts)
|
end, opts)
|
||||||
|
|
@ -52,17 +66,8 @@ function M._setup_buf_mappings(bufnr)
|
||||||
vim.keymap.set('n', 'g?', function()
|
vim.keymap.set('n', 'g?', function()
|
||||||
M.show_help()
|
M.show_help()
|
||||||
end, opts)
|
end, opts)
|
||||||
vim.keymap.set('n', '<C-a>', function()
|
vim.keymap.set('n', '!', function()
|
||||||
M.change_priority(1)
|
M.toggle_priority()
|
||||||
end, opts)
|
|
||||||
vim.keymap.set('n', '<C-x>', function()
|
|
||||||
M.change_priority(-1)
|
|
||||||
end, opts)
|
|
||||||
vim.keymap.set('v', 'g<C-a>', function()
|
|
||||||
M.change_priority_visual(1)
|
|
||||||
end, opts)
|
|
||||||
vim.keymap.set('v', 'g<C-x>', function()
|
|
||||||
M.change_priority_visual(-1)
|
|
||||||
end, opts)
|
end, opts)
|
||||||
vim.keymap.set('n', 'D', function()
|
vim.keymap.set('n', 'D', function()
|
||||||
M.prompt_date()
|
M.prompt_date()
|
||||||
|
|
@ -126,10 +131,15 @@ function M.toggle_complete()
|
||||||
end
|
end
|
||||||
store.save()
|
store.save()
|
||||||
buffer.render(bufnr)
|
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
|
end
|
||||||
|
|
||||||
---@param delta integer
|
function M.toggle_priority()
|
||||||
function M.change_priority(delta)
|
|
||||||
local bufnr = buffer.bufnr()
|
local bufnr = buffer.bufnr()
|
||||||
if not bufnr then
|
if not bufnr then
|
||||||
return
|
return
|
||||||
|
|
@ -147,7 +157,7 @@ function M.change_priority(delta)
|
||||||
if not task then
|
if not task then
|
||||||
return
|
return
|
||||||
end
|
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.update(id, { priority = new_priority })
|
||||||
store.save()
|
store.save()
|
||||||
buffer.render(bufnr)
|
buffer.render(bufnr)
|
||||||
|
|
@ -159,33 +169,6 @@ function M.change_priority(delta)
|
||||||
end
|
end
|
||||||
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()
|
function M.prompt_date()
|
||||||
local bufnr = buffer.bufnr()
|
local bufnr = buffer.bufnr()
|
||||||
if not bufnr then
|
if not bufnr then
|
||||||
|
|
@ -342,10 +325,7 @@ function M.show_help()
|
||||||
'',
|
'',
|
||||||
'<CR> Toggle complete/uncomplete',
|
'<CR> Toggle complete/uncomplete',
|
||||||
'<Tab> Switch category/priority view',
|
'<Tab> Switch category/priority view',
|
||||||
'<C-a> Raise priority level',
|
'! Toggle urgent',
|
||||||
'<C-x> Lower priority level',
|
|
||||||
'g<C-a> Raise priority for visual selection',
|
|
||||||
'g<C-x> Lower priority for visual selection',
|
|
||||||
'D Set due date',
|
'D Set due date',
|
||||||
'U Undo last write',
|
'U Undo last write',
|
||||||
'o / O Add new task line',
|
'o / O Add new task line',
|
||||||
|
|
@ -371,7 +351,7 @@ function M.show_help()
|
||||||
'',
|
'',
|
||||||
'Highlights:',
|
'Highlights:',
|
||||||
' PendingOverdue overdue tasks (red)',
|
' PendingOverdue overdue tasks (red)',
|
||||||
' PendingPriority [N] priority prefix',
|
' PendingPriority [!] urgent tasks',
|
||||||
'',
|
'',
|
||||||
'Press q or <Esc> to close',
|
'Press q or <Esc> to close',
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -125,7 +125,7 @@ function M.category_view(tasks)
|
||||||
table.insert(lines, '')
|
table.insert(lines, '')
|
||||||
table.insert(meta, { type = 'blank' })
|
table.insert(meta, { type = 'blank' })
|
||||||
end
|
end
|
||||||
table.insert(lines, cat)
|
table.insert(lines, '## ' .. cat)
|
||||||
table.insert(meta, { type = 'header', category = cat })
|
table.insert(meta, { type = 'header', category = cat })
|
||||||
|
|
||||||
local all = {}
|
local all = {}
|
||||||
|
|
@ -138,9 +138,8 @@ function M.category_view(tasks)
|
||||||
|
|
||||||
for _, task in ipairs(all) do
|
for _, task in ipairs(all) do
|
||||||
local prefix = '/' .. task.id .. '/'
|
local prefix = '/' .. task.id .. '/'
|
||||||
local indent = ' '
|
local state = task.status == 'done' and 'x' or (task.priority > 0 and '!' or ' ')
|
||||||
local prio = task.priority > 0 and ('[' .. task.priority .. '] ') or ''
|
local line = prefix .. '- [' .. state .. '] ' .. task.description
|
||||||
local line = prefix .. indent .. prio .. task.description
|
|
||||||
table.insert(lines, line)
|
table.insert(lines, line)
|
||||||
table.insert(meta, {
|
table.insert(meta, {
|
||||||
type = 'task',
|
type = 'task',
|
||||||
|
|
@ -189,9 +188,8 @@ function M.priority_view(tasks)
|
||||||
|
|
||||||
for _, task in ipairs(all) do
|
for _, task in ipairs(all) do
|
||||||
local prefix = '/' .. task.id .. '/'
|
local prefix = '/' .. task.id .. '/'
|
||||||
local indent = ' '
|
local state = task.status == 'done' and 'x' or (task.priority > 0 and '!' or ' ')
|
||||||
local prio = task.priority == 1 and '! ' or ''
|
local line = prefix .. '- [' .. state .. '] ' .. task.description
|
||||||
local line = prefix .. indent .. prio .. task.description
|
|
||||||
table.insert(lines, line)
|
table.insert(lines, line)
|
||||||
table.insert(meta, {
|
table.insert(meta, {
|
||||||
type = 'task',
|
type = 'task',
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ vim.api.nvim_create_user_command('Pending', function(opts)
|
||||||
end, {
|
end, {
|
||||||
nargs = '*',
|
nargs = '*',
|
||||||
complete = function(arg_lead, cmd_line)
|
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
|
if not cmd_line:match('^Pending%s+%S') then
|
||||||
return vim.tbl_filter(function(s)
|
return vim.tbl_filter(function(s)
|
||||||
return s:find(arg_lead, 1, true) == 1
|
return s:find(arg_lead, 1, true) == 1
|
||||||
|
|
@ -30,12 +30,8 @@ vim.keymap.set('n', '<Plug>(pending-view)', function()
|
||||||
require('pending.buffer').toggle_view()
|
require('pending.buffer').toggle_view()
|
||||||
end)
|
end)
|
||||||
|
|
||||||
vim.keymap.set('n', '<Plug>(pending-priority-up)', function()
|
vim.keymap.set('n', '<Plug>(pending-priority)', function()
|
||||||
require('pending').change_priority(1)
|
require('pending').toggle_priority()
|
||||||
end)
|
|
||||||
|
|
||||||
vim.keymap.set('n', '<Plug>(pending-priority-down)', function()
|
|
||||||
require('pending').change_priority(-1)
|
|
||||||
end)
|
end)
|
||||||
|
|
||||||
vim.keymap.set('n', '<Plug>(pending-date)', function()
|
vim.keymap.set('n', '<Plug>(pending-date)', function()
|
||||||
|
|
|
||||||
|
|
@ -128,4 +128,13 @@ describe('archive', function()
|
||||||
assert.is_true(descs['Keep pending'])
|
assert.is_true(descs['Keep pending'])
|
||||||
assert.is_true(descs['Keep recent done'])
|
assert.is_true(descs['Keep recent done'])
|
||||||
end)
|
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)
|
end)
|
||||||
|
|
|
||||||
|
|
@ -25,12 +25,12 @@ describe('diff', function()
|
||||||
describe('parse_buffer', function()
|
describe('parse_buffer', function()
|
||||||
it('parses headers and tasks', function()
|
it('parses headers and tasks', function()
|
||||||
local lines = {
|
local lines = {
|
||||||
'School',
|
'## School',
|
||||||
'/1/ Do homework',
|
'/1/- [ ] Do homework',
|
||||||
'/2/ ! Read chapter 5',
|
'/2/- [!] Read chapter 5',
|
||||||
'',
|
'',
|
||||||
'Errands',
|
'## Errands',
|
||||||
'/3/ Buy groceries',
|
'/3/- [ ] Buy groceries',
|
||||||
}
|
}
|
||||||
local result = diff.parse_buffer(lines)
|
local result = diff.parse_buffer(lines)
|
||||||
assert.are.equal(6, #result)
|
assert.are.equal(6, #result)
|
||||||
|
|
@ -48,8 +48,8 @@ describe('diff', function()
|
||||||
|
|
||||||
it('handles new tasks without ids', function()
|
it('handles new tasks without ids', function()
|
||||||
local lines = {
|
local lines = {
|
||||||
'Inbox',
|
'## Inbox',
|
||||||
' New task here',
|
'- [ ] New task here',
|
||||||
}
|
}
|
||||||
local result = diff.parse_buffer(lines)
|
local result = diff.parse_buffer(lines)
|
||||||
assert.are.equal(2, #result)
|
assert.are.equal(2, #result)
|
||||||
|
|
@ -57,14 +57,36 @@ describe('diff', function()
|
||||||
assert.is_nil(result[2].id)
|
assert.is_nil(result[2].id)
|
||||||
assert.are.equal('New task here', result[2].description)
|
assert.are.equal('New task here', result[2].description)
|
||||||
end)
|
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)
|
end)
|
||||||
|
|
||||||
describe('apply', function()
|
describe('apply', function()
|
||||||
it('creates new tasks from buffer lines', function()
|
it('creates new tasks from buffer lines', function()
|
||||||
local lines = {
|
local lines = {
|
||||||
'Inbox',
|
'## Inbox',
|
||||||
' First task',
|
'- [ ] First task',
|
||||||
' Second task',
|
'- [ ] Second task',
|
||||||
}
|
}
|
||||||
diff.apply(lines)
|
diff.apply(lines)
|
||||||
store.unload()
|
store.unload()
|
||||||
|
|
@ -80,8 +102,8 @@ describe('diff', function()
|
||||||
store.add({ description = 'Delete me' })
|
store.add({ description = 'Delete me' })
|
||||||
store.save()
|
store.save()
|
||||||
local lines = {
|
local lines = {
|
||||||
'Inbox',
|
'## Inbox',
|
||||||
'/1/ Keep me',
|
'/1/- [ ] Keep me',
|
||||||
}
|
}
|
||||||
diff.apply(lines)
|
diff.apply(lines)
|
||||||
store.unload()
|
store.unload()
|
||||||
|
|
@ -97,8 +119,8 @@ describe('diff', function()
|
||||||
store.add({ description = 'Original' })
|
store.add({ description = 'Original' })
|
||||||
store.save()
|
store.save()
|
||||||
local lines = {
|
local lines = {
|
||||||
'Inbox',
|
'## Inbox',
|
||||||
'/1/ Renamed',
|
'/1/- [ ] Renamed',
|
||||||
}
|
}
|
||||||
diff.apply(lines)
|
diff.apply(lines)
|
||||||
store.unload()
|
store.unload()
|
||||||
|
|
@ -107,13 +129,29 @@ describe('diff', function()
|
||||||
assert.are.equal('Renamed', task.description)
|
assert.are.equal('Renamed', task.description)
|
||||||
end)
|
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()
|
it('handles duplicate ids as copies', function()
|
||||||
store.add({ description = 'Original' })
|
store.add({ description = 'Original' })
|
||||||
store.save()
|
store.save()
|
||||||
local lines = {
|
local lines = {
|
||||||
'Inbox',
|
'## Inbox',
|
||||||
'/1/ Original',
|
'/1/- [ ] Original',
|
||||||
'/1/ Copy of original',
|
'/1/- [ ] Copy of original',
|
||||||
}
|
}
|
||||||
diff.apply(lines)
|
diff.apply(lines)
|
||||||
store.unload()
|
store.unload()
|
||||||
|
|
@ -126,8 +164,8 @@ describe('diff', function()
|
||||||
store.add({ description = 'Moving task', category = 'Inbox' })
|
store.add({ description = 'Moving task', category = 'Inbox' })
|
||||||
store.save()
|
store.save()
|
||||||
local lines = {
|
local lines = {
|
||||||
'Work',
|
'## Work',
|
||||||
'/1/ Moving task',
|
'/1/- [ ] Moving task',
|
||||||
}
|
}
|
||||||
diff.apply(lines)
|
diff.apply(lines)
|
||||||
store.unload()
|
store.unload()
|
||||||
|
|
@ -140,8 +178,8 @@ describe('diff', function()
|
||||||
store.add({ description = 'Stable task', category = 'Inbox' })
|
store.add({ description = 'Stable task', category = 'Inbox' })
|
||||||
store.save()
|
store.save()
|
||||||
local lines = {
|
local lines = {
|
||||||
'Inbox',
|
'## Inbox',
|
||||||
'/1/ Stable task',
|
'/1/- [ ] Stable task',
|
||||||
}
|
}
|
||||||
diff.apply(lines)
|
diff.apply(lines)
|
||||||
store.unload()
|
store.unload()
|
||||||
|
|
@ -158,8 +196,8 @@ describe('diff', function()
|
||||||
store.add({ description = 'Pay bill', due = '2026-03-15' })
|
store.add({ description = 'Pay bill', due = '2026-03-15' })
|
||||||
store.save()
|
store.save()
|
||||||
local lines = {
|
local lines = {
|
||||||
'Inbox',
|
'## Inbox',
|
||||||
'/1/ Pay bill',
|
'/1/- [ ] Pay bill',
|
||||||
}
|
}
|
||||||
diff.apply(lines)
|
diff.apply(lines)
|
||||||
store.unload()
|
store.unload()
|
||||||
|
|
@ -168,12 +206,12 @@ describe('diff', function()
|
||||||
assert.is_nil(task.due)
|
assert.is_nil(task.due)
|
||||||
end)
|
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.add({ description = 'Task name', priority = 1 })
|
||||||
store.save()
|
store.save()
|
||||||
local lines = {
|
local lines = {
|
||||||
'Inbox',
|
'## Inbox',
|
||||||
'/1/ Task name',
|
'/1/- [ ] Task name',
|
||||||
}
|
}
|
||||||
diff.apply(lines)
|
diff.apply(lines)
|
||||||
store.unload()
|
store.unload()
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,10 @@ describe('parse', function()
|
||||||
|
|
||||||
it('resolves due:+2d to today plus 2 days', function()
|
it('resolves due:+2d to today plus 2 days', function()
|
||||||
local today = os.date('*t') --[[@as osdate]]
|
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')
|
local desc, meta = parse.body('Task due:+2d')
|
||||||
assert.are.equal('Task', desc)
|
assert.are.equal('Task', desc)
|
||||||
assert.are.equal(expected, meta.due)
|
assert.are.equal(expected, meta.due)
|
||||||
|
|
@ -123,7 +126,10 @@ describe('parse', function()
|
||||||
|
|
||||||
it("returns today + 3 days for '+3d'", function()
|
it("returns today + 3 days for '+3d'", function()
|
||||||
local today = os.date('*t') --[[@as osdate]]
|
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')
|
local result = parse.resolve_date('+3d')
|
||||||
assert.are.equal(expected, result)
|
assert.are.equal(expected, result)
|
||||||
end)
|
end)
|
||||||
|
|
@ -139,12 +145,12 @@ describe('parse', function()
|
||||||
assert.truthy(result:match('^%d%d%d%d%-%d%d%-%d%d$'))
|
assert.truthy(result:match('^%d%d%d%d%-%d%d%-%d%d$'))
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it("returns nil for garbage input", function()
|
it('returns nil for garbage input', function()
|
||||||
local result = parse.resolve_date('notadate')
|
local result = parse.resolve_date('notadate')
|
||||||
assert.is_nil(result)
|
assert.is_nil(result)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it("returns nil for empty string", function()
|
it('returns nil for empty string', function()
|
||||||
local result = parse.resolve_date('')
|
local result = parse.resolve_date('')
|
||||||
assert.is_nil(result)
|
assert.is_nil(result)
|
||||||
end)
|
end)
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,7 @@ describe('store', function()
|
||||||
assert.are.equal(1, t1.id)
|
assert.are.equal(1, t1.id)
|
||||||
assert.are.equal(2, t2.id)
|
assert.are.equal(2, t2.id)
|
||||||
assert.are.equal('pending', t1.status)
|
assert.are.equal('pending', t1.status)
|
||||||
assert.are.equal('Inbox', t1.category)
|
assert.are.equal('Todo', t1.category)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('uses provided category', function()
|
it('uses provided category', function()
|
||||||
|
|
@ -121,6 +121,27 @@ describe('store', function()
|
||||||
local updated = store.get(t.id)
|
local updated = store.get(t.id)
|
||||||
assert.is_not_nil(updated['end'])
|
assert.is_not_nil(updated['end'])
|
||||||
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)
|
end)
|
||||||
|
|
||||||
describe('delete', function()
|
describe('delete', function()
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ describe('views', function()
|
||||||
store.add({ description = 'Task A', category = 'Work' })
|
store.add({ description = 'Task A', category = 'Work' })
|
||||||
store.add({ description = 'Task B', category = 'Work' })
|
store.add({ description = 'Task B', category = 'Work' })
|
||||||
local lines, meta = views.category_view(store.active_tasks())
|
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.are.equal('header', meta[1].type)
|
||||||
assert.is_true(lines[2]:find('Task A') ~= nil)
|
assert.is_true(lines[2]:find('Task A') ~= nil)
|
||||||
assert.is_true(lines[3]:find('Task B') ~= nil)
|
assert.is_true(lines[3]:find('Task B') ~= nil)
|
||||||
|
|
@ -113,10 +113,10 @@ describe('views', function()
|
||||||
task_line = lines[i]
|
task_line = lines[i]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
assert.are.equal('/1/ My task', task_line)
|
assert.are.equal('/1/- [ ] My task', task_line)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('formats priority task lines as /ID/ ! description', function()
|
it('formats priority task lines as /ID/- [!] description', function()
|
||||||
store.add({ description = 'Important', category = 'Inbox', priority = 1 })
|
store.add({ description = 'Important', category = 'Inbox', priority = 1 })
|
||||||
local lines, meta = views.category_view(store.active_tasks())
|
local lines, meta = views.category_view(store.active_tasks())
|
||||||
local task_line
|
local task_line
|
||||||
|
|
@ -125,7 +125,7 @@ describe('views', function()
|
||||||
task_line = lines[i]
|
task_line = lines[i]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
assert.are.equal('/1/ ! Important', task_line)
|
assert.are.equal('/1/- [!] Important', task_line)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('sets LineMeta type=header for header lines with correct category', function()
|
it('sets LineMeta type=header for header lines with correct category', function()
|
||||||
|
|
@ -220,8 +220,8 @@ describe('views', function()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
assert.are.equal('Work', first_header)
|
assert.are.equal('## Work', first_header)
|
||||||
assert.are.equal('Inbox', second_header)
|
assert.are.equal('## Inbox', second_header)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('appends categories not in category_order after ordered ones', function()
|
it('appends categories not in category_order after ordered ones', function()
|
||||||
|
|
@ -236,8 +236,8 @@ describe('views', function()
|
||||||
table.insert(headers, lines[i])
|
table.insert(headers, lines[i])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
assert.are.equal('Work', headers[1])
|
assert.are.equal('## Work', headers[1])
|
||||||
assert.are.equal('Errands', headers[2])
|
assert.are.equal('## Errands', headers[2])
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('preserves insertion order when category_order is empty', function()
|
it('preserves insertion order when category_order is empty', function()
|
||||||
|
|
@ -250,8 +250,8 @@ describe('views', function()
|
||||||
table.insert(headers, lines[i])
|
table.insert(headers, lines[i])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
assert.are.equal('Alpha', headers[1])
|
assert.are.equal('## Alpha', headers[1])
|
||||||
assert.are.equal('Beta', headers[2])
|
assert.are.equal('## Beta', headers[2])
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
|
@ -325,10 +325,10 @@ describe('views', function()
|
||||||
assert.is_true(earlier_row < later_row)
|
assert.is_true(earlier_row < later_row)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('formats task lines as /ID/ description', function()
|
it('formats task lines as /ID/- [ ] description', function()
|
||||||
store.add({ description = 'My task', category = 'Inbox' })
|
store.add({ description = 'My task', category = 'Inbox' })
|
||||||
local lines, _ = views.priority_view(store.active_tasks())
|
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)
|
end)
|
||||||
|
|
||||||
it('sets show_category=true for all task meta entries', function()
|
it('sets show_category=true for all task meta entries', function()
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,12 @@ if exists('b:current_syntax')
|
||||||
endif
|
endif
|
||||||
|
|
||||||
syntax match taskId /^\/\d\+\// conceal
|
syntax match taskId /^\/\d\+\// conceal
|
||||||
syntax match taskHeader /^\S.*$/ contains=taskId
|
syntax match taskHeader /^## .*$/ contains=taskId
|
||||||
syntax match taskPriority /!\ze / contained
|
syntax match taskCheckbox /\[!\]/ contained containedin=taskLine
|
||||||
syntax match taskLine /^\/\d\+\/ .*$/ contains=taskId,taskPriority
|
syntax match taskLine /^\/\d\+\/- \[.\] .*$/ contains=taskId,taskCheckbox
|
||||||
|
|
||||||
highlight default link taskHeader PendingHeader
|
highlight default link taskHeader PendingHeader
|
||||||
highlight default link taskPriority PendingPriority
|
highlight default link taskCheckbox PendingPriority
|
||||||
highlight default link taskLine Normal
|
highlight default link taskLine Normal
|
||||||
|
|
||||||
let b:current_syntax = 'task'
|
let b:current_syntax = 'task'
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue