Compare commits
2 commits
fix/sync-h
...
doc/minify
| Author | SHA1 | Date | |
|---|---|---|---|
| 8433d92857 | |||
| fa1103ad4e |
36 changed files with 1112 additions and 7016 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,6 +1,5 @@
|
||||||
doc/tags
|
doc/tags
|
||||||
*.log
|
*.log
|
||||||
minimal_init.lua
|
|
||||||
|
|
||||||
.*cache*
|
.*cache*
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,7 @@
|
||||||
"runtime.version": "LuaJIT",
|
"runtime.version": "LuaJIT",
|
||||||
"runtime.path": ["lua/?.lua", "lua/?/init.lua"],
|
"runtime.path": ["lua/?.lua", "lua/?/init.lua"],
|
||||||
"diagnostics.globals": ["vim", "jit"],
|
"diagnostics.globals": ["vim", "jit"],
|
||||||
"diagnostics.libraryFiles": "Disable",
|
"workspace.library": ["$VIMRUNTIME/lua", "${3rd}/luv/library"],
|
||||||
"workspace.library": [
|
|
||||||
"$VIMRUNTIME/lua",
|
|
||||||
"${3rd}/luv/library",
|
|
||||||
"${3rd}/busted/library",
|
|
||||||
"${3rd}/luassert/library"
|
|
||||||
],
|
|
||||||
"workspace.checkThirdParty": false,
|
"workspace.checkThirdParty": false,
|
||||||
"workspace.ignoreDir": [".direnv"],
|
|
||||||
"completion.callSnippet": "Replace"
|
"completion.callSnippet": "Replace"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
19
README.md
19
README.md
|
|
@ -2,12 +2,12 @@
|
||||||
|
|
||||||
Edit tasks like text. `:w` saves them.
|
Edit tasks like text. `:w` saves them.
|
||||||
|
|
||||||

|
<!-- insert preview -->
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Neovim 0.10+
|
- Neovim 0.10+
|
||||||
- (Optionally) `curl` for Google Calendar and Google Task sync
|
- (Optionally) `curl` and `openssl` for Google Calendar and Google Task sync
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
|
@ -24,21 +24,6 @@ luarocks install pending.nvim
|
||||||
:help 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
|
## Acknowledgements
|
||||||
|
|
||||||
- [dooing](https://github.com/atiladefreitas/dooing)
|
- [dooing](https://github.com/atiladefreitas/dooing)
|
||||||
|
|
|
||||||
930
doc/pending.txt
930
doc/pending.txt
File diff suppressed because it is too large
Load diff
|
|
@ -13,12 +13,9 @@
|
||||||
...
|
...
|
||||||
}:
|
}:
|
||||||
let
|
let
|
||||||
forEachSystem =
|
forEachSystem = f: nixpkgs.lib.genAttrs (import systems) (system: f nixpkgs.legacyPackages.${system});
|
||||||
f: nixpkgs.lib.genAttrs (import systems) (system: f nixpkgs.legacyPackages.${system});
|
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
formatter = forEachSystem (pkgs: pkgs.nixfmt-tree);
|
|
||||||
|
|
||||||
devShells = forEachSystem (pkgs: {
|
devShells = forEachSystem (pkgs: {
|
||||||
default = pkgs.mkShell {
|
default = pkgs.mkShell {
|
||||||
packages = [
|
packages = [
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,10 @@
|
||||||
local config = require('pending.config')
|
local config = require('pending.config')
|
||||||
|
local store = require('pending.store')
|
||||||
local views = require('pending.views')
|
local views = require('pending.views')
|
||||||
|
|
||||||
---@class pending.buffer
|
---@class pending.buffer
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
---@type pending.Store?
|
|
||||||
local _store = nil
|
|
||||||
|
|
||||||
---@type integer?
|
---@type integer?
|
||||||
local task_bufnr = nil
|
local task_bufnr = nil
|
||||||
---@type integer?
|
---@type integer?
|
||||||
|
|
@ -18,10 +16,6 @@ local current_view = nil
|
||||||
local _meta = {}
|
local _meta = {}
|
||||||
---@type table<integer, table<string, boolean>>
|
---@type table<integer, table<string, boolean>>
|
||||||
local _fold_state = {}
|
local _fold_state = {}
|
||||||
---@type string[]
|
|
||||||
local _filter_predicates = {}
|
|
||||||
---@type table<integer, true>
|
|
||||||
local _hidden_ids = {}
|
|
||||||
|
|
||||||
---@return pending.LineMeta[]
|
---@return pending.LineMeta[]
|
||||||
function M.meta()
|
function M.meta()
|
||||||
|
|
@ -43,50 +37,12 @@ function M.current_view_name()
|
||||||
return current_view
|
return current_view
|
||||||
end
|
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
|
|
||||||
end
|
|
||||||
|
|
||||||
---@return table<integer, true>
|
|
||||||
function M.hidden_ids()
|
|
||||||
return _hidden_ids
|
|
||||||
end
|
|
||||||
|
|
||||||
---@param predicates string[]
|
|
||||||
---@param hidden table<integer, true>
|
|
||||||
---@return nil
|
|
||||||
function M.set_filter(predicates, hidden)
|
|
||||||
_filter_predicates = predicates
|
|
||||||
_hidden_ids = hidden
|
|
||||||
end
|
|
||||||
|
|
||||||
---@return nil
|
|
||||||
function M.clear_winid()
|
function M.clear_winid()
|
||||||
task_winid = nil
|
task_winid = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
---@return nil
|
|
||||||
function M.close()
|
function M.close()
|
||||||
if not task_winid or not vim.api.nvim_win_is_valid(task_winid) then
|
if task_winid and 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)
|
vim.api.nvim_win_close(task_winid, false)
|
||||||
end
|
end
|
||||||
task_winid = nil
|
task_winid = nil
|
||||||
|
|
@ -99,13 +55,19 @@ local function set_buf_options(bufnr)
|
||||||
vim.bo[bufnr].swapfile = false
|
vim.bo[bufnr].swapfile = false
|
||||||
vim.bo[bufnr].filetype = 'pending'
|
vim.bo[bufnr].filetype = 'pending'
|
||||||
vim.bo[bufnr].modifiable = true
|
vim.bo[bufnr].modifiable = true
|
||||||
vim.bo[bufnr].omnifunc = 'v:lua.require("pending.complete").omnifunc'
|
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param winid integer
|
---@param winid integer
|
||||||
local function set_win_options(winid)
|
local function set_win_options(winid)
|
||||||
vim.wo[winid].conceallevel = 3
|
vim.wo[winid].conceallevel = 3
|
||||||
vim.wo[winid].concealcursor = 'nvic'
|
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
|
vim.wo[winid].winfixheight = true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -115,7 +77,7 @@ 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 /^# .*$/ contains=taskId
|
syntax match taskHeader /^## .*$/ contains=taskId
|
||||||
syntax match taskCheckbox /\[!\]/ contained containedin=taskLine
|
syntax match taskCheckbox /\[!\]/ contained containedin=taskLine
|
||||||
syntax match taskLine /^\/\d\+\/- \[.\] .*$/ contains=taskId,taskCheckbox
|
syntax match taskLine /^\/\d\+\/- \[.\] .*$/ contains=taskId,taskCheckbox
|
||||||
]])
|
]])
|
||||||
|
|
@ -123,7 +85,6 @@ local function setup_syntax(bufnr)
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param above boolean
|
---@param above boolean
|
||||||
---@return nil
|
|
||||||
function M.open_line(above)
|
function M.open_line(above)
|
||||||
local bufnr = task_bufnr
|
local bufnr = task_bufnr
|
||||||
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then
|
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then
|
||||||
|
|
@ -156,34 +117,29 @@ end
|
||||||
---@param bufnr integer
|
---@param bufnr integer
|
||||||
---@param line_meta pending.LineMeta[]
|
---@param line_meta pending.LineMeta[]
|
||||||
local function apply_extmarks(bufnr, line_meta)
|
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, task_ns, 0, -1)
|
||||||
for i, m in ipairs(line_meta) do
|
for i, m in ipairs(line_meta) do
|
||||||
local row = i - 1
|
local row = i - 1
|
||||||
if m.type == 'filter' then
|
if m.type == 'task' 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 due_hl = m.overdue and 'PendingOverdue' or 'PendingDue'
|
||||||
local virt_parts = {}
|
if m.show_category then
|
||||||
if m.show_category and m.category then
|
local virt_text
|
||||||
table.insert(virt_parts, { icons.category .. ' ' .. m.category, 'PendingHeader' })
|
if m.category and m.due then
|
||||||
end
|
virt_text = { { m.category .. ' ', 'PendingHeader' }, { m.due, due_hl } }
|
||||||
if m.recur then
|
elseif m.category then
|
||||||
table.insert(virt_parts, { icons.recur .. ' ' .. m.recur, 'PendingRecur' })
|
virt_text = { { m.category, 'PendingHeader' } }
|
||||||
end
|
elseif m.due then
|
||||||
if m.due then
|
virt_text = { { m.due, due_hl } }
|
||||||
table.insert(virt_parts, { icons.due .. ' ' .. 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
|
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, {
|
vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, {
|
||||||
virt_text = virt_parts,
|
virt_text = { { m.due, due_hl } },
|
||||||
virt_text_pos = 'eol',
|
virt_text_pos = 'eol',
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
@ -195,32 +151,12 @@ local function apply_extmarks(bufnr, line_meta)
|
||||||
hl_group = 'PendingDone',
|
hl_group = 'PendingDone',
|
||||||
})
|
})
|
||||||
end
|
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
|
elseif m.type == 'header' 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 ''
|
||||||
vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, {
|
vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, {
|
||||||
end_col = #line,
|
end_col = #line,
|
||||||
hl_group = 'PendingHeader',
|
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
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -231,8 +167,6 @@ local function setup_highlights()
|
||||||
vim.api.nvim_set_hl(0, 'PendingOverdue', { link = 'DiagnosticError', 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, 'PendingDone', { link = 'Comment', default = true })
|
||||||
vim.api.nvim_set_hl(0, 'PendingPriority', { link = 'DiagnosticWarn', 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
|
end
|
||||||
|
|
||||||
local function snapshot_folds(bufnr)
|
local function snapshot_folds(bufnr)
|
||||||
|
|
@ -278,7 +212,6 @@ local function restore_folds(bufnr)
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param bufnr? integer
|
---@param bufnr? integer
|
||||||
---@return nil
|
|
||||||
function M.render(bufnr)
|
function M.render(bufnr)
|
||||||
bufnr = bufnr or task_bufnr
|
bufnr = bufnr or task_bufnr
|
||||||
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then
|
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then
|
||||||
|
|
@ -286,15 +219,8 @@ function M.render(bufnr)
|
||||||
end
|
end
|
||||||
|
|
||||||
current_view = current_view or config.get().default_view
|
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://' .. current_view)
|
||||||
vim.api.nvim_buf_set_name(bufnr, 'pending://' .. view_label)
|
local 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
|
|
||||||
table.insert(tasks, task)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
local lines, line_meta
|
local lines, line_meta
|
||||||
if current_view == 'priority' then
|
if current_view == 'priority' then
|
||||||
|
|
@ -303,11 +229,6 @@ function M.render(bufnr)
|
||||||
lines, line_meta = views.category_view(tasks)
|
lines, line_meta = views.category_view(tasks)
|
||||||
end
|
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
|
_meta = line_meta
|
||||||
|
|
||||||
snapshot_folds(bufnr)
|
snapshot_folds(bufnr)
|
||||||
|
|
@ -335,7 +256,6 @@ function M.render(bufnr)
|
||||||
restore_folds(bufnr)
|
restore_folds(bufnr)
|
||||||
end
|
end
|
||||||
|
|
||||||
---@return nil
|
|
||||||
function M.toggle_view()
|
function M.toggle_view()
|
||||||
if current_view == 'category' then
|
if current_view == 'category' then
|
||||||
current_view = 'priority'
|
current_view = 'priority'
|
||||||
|
|
@ -348,9 +268,7 @@ end
|
||||||
---@return integer bufnr
|
---@return integer bufnr
|
||||||
function M.open()
|
function M.open()
|
||||||
setup_highlights()
|
setup_highlights()
|
||||||
if _store then
|
store.load()
|
||||||
_store:load()
|
|
||||||
end
|
|
||||||
|
|
||||||
if task_winid and vim.api.nvim_win_is_valid(task_winid) then
|
if task_winid and vim.api.nvim_win_is_valid(task_winid) then
|
||||||
vim.api.nvim_set_current_win(task_winid)
|
vim.api.nvim_set_current_win(task_winid)
|
||||||
|
|
|
||||||
|
|
@ -1,173 +0,0 @@
|
||||||
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 s = require('pending.buffer').store()
|
|
||||||
if not s then
|
|
||||||
return {}
|
|
||||||
end
|
|
||||||
local seen = {}
|
|
||||||
local result = {}
|
|
||||||
for _, task in ipairs(s: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 { word: string, info: string }[]
|
|
||||||
local function date_completions()
|
|
||||||
return {
|
|
||||||
{ 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
|
|
||||||
|
|
||||||
---@type table<string, string>
|
|
||||||
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
|
|
||||||
local desc = recur_descriptions[s] or s
|
|
||||||
table.insert(result, { word = s, info = desc })
|
|
||||||
end
|
|
||||||
for _, s in ipairs(list) do
|
|
||||||
local desc = recur_descriptions[s] or s
|
|
||||||
table.insert(result, { word = '!' .. s, info = desc .. ' (from completion date)' })
|
|
||||||
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 matches = {}
|
|
||||||
local source = _complete_source or ''
|
|
||||||
|
|
||||||
local dk = date_key()
|
|
||||||
local rk = recur_key()
|
|
||||||
|
|
||||||
if source == dk then
|
|
||||||
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
|
|
||||||
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
|
|
||||||
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
|
|
||||||
|
|
||||||
return matches
|
|
||||||
end
|
|
||||||
|
|
||||||
return M
|
|
||||||
|
|
@ -1,43 +1,6 @@
|
||||||
---@class pending.Icons
|
|
||||||
---@field pending string
|
|
||||||
---@field done string
|
|
||||||
---@field priority string
|
|
||||||
---@field due string
|
|
||||||
---@field recur string
|
|
||||||
---@field category string
|
|
||||||
|
|
||||||
---@class pending.GcalConfig
|
---@class pending.GcalConfig
|
||||||
|
---@field calendar? string
|
||||||
---@field credentials_path? 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
|
|
||||||
---@field gtasks? pending.GtasksConfig
|
|
||||||
|
|
||||||
---@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 filter? 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
|
---@class pending.Config
|
||||||
---@field data_path string
|
---@field data_path string
|
||||||
|
|
@ -45,14 +8,9 @@
|
||||||
---@field default_category string
|
---@field default_category string
|
||||||
---@field date_format string
|
---@field date_format string
|
||||||
---@field date_syntax string
|
---@field date_syntax string
|
||||||
---@field recur_syntax string
|
|
||||||
---@field someday_date string
|
|
||||||
---@field category_order? string[]
|
---@field category_order? string[]
|
||||||
---@field drawer_height? integer
|
---@field drawer_height? integer
|
||||||
---@field debug? boolean
|
---@field gcal? pending.GcalConfig
|
||||||
---@field keymaps pending.Keymaps
|
|
||||||
---@field sync? pending.SyncConfig
|
|
||||||
---@field icons pending.Icons
|
|
||||||
|
|
||||||
---@class pending.config
|
---@class pending.config
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
@ -64,37 +22,7 @@ local defaults = {
|
||||||
default_category = 'Todo',
|
default_category = 'Todo',
|
||||||
date_format = '%b %d',
|
date_format = '%b %d',
|
||||||
date_syntax = 'due',
|
date_syntax = 'due',
|
||||||
recur_syntax = 'rec',
|
|
||||||
someday_date = '9999-12-30',
|
|
||||||
category_order = {},
|
category_order = {},
|
||||||
keymaps = {
|
|
||||||
close = 'q',
|
|
||||||
toggle = '<CR>',
|
|
||||||
view = '<Tab>',
|
|
||||||
priority = '!',
|
|
||||||
date = 'D',
|
|
||||||
undo = 'U',
|
|
||||||
filter = 'F',
|
|
||||||
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',
|
|
||||||
},
|
|
||||||
sync = {},
|
|
||||||
icons = {
|
|
||||||
pending = ' ',
|
|
||||||
done = 'x',
|
|
||||||
priority = '!',
|
|
||||||
due = '.',
|
|
||||||
recur = '~',
|
|
||||||
category = '#',
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
---@type pending.Config?
|
---@type pending.Config?
|
||||||
|
|
@ -110,7 +38,6 @@ function M.get()
|
||||||
return _resolved
|
return _resolved
|
||||||
end
|
end
|
||||||
|
|
||||||
---@return nil
|
|
||||||
function M.reset()
|
function M.reset()
|
||||||
_resolved = nil
|
_resolved = nil
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
local config = require('pending.config')
|
local config = require('pending.config')
|
||||||
local parse = require('pending.parse')
|
local parse = require('pending.parse')
|
||||||
|
local store = require('pending.store')
|
||||||
|
|
||||||
---@class pending.ParsedEntry
|
---@class pending.ParsedEntry
|
||||||
---@field type 'task'|'header'|'blank'
|
---@field type 'task'|'header'|'blank'
|
||||||
|
|
@ -9,8 +10,6 @@ local parse = require('pending.parse')
|
||||||
---@field status? string
|
---@field status? string
|
||||||
---@field category? string
|
---@field category? string
|
||||||
---@field due? string
|
---@field due? string
|
||||||
---@field rec? string
|
|
||||||
---@field rec_mode? string
|
|
||||||
---@field lnum integer
|
---@field lnum integer
|
||||||
|
|
||||||
---@class pending.diff
|
---@class pending.diff
|
||||||
|
|
@ -26,13 +25,8 @@ end
|
||||||
function M.parse_buffer(lines)
|
function M.parse_buffer(lines)
|
||||||
local result = {}
|
local result = {}
|
||||||
local current_category = nil
|
local current_category = nil
|
||||||
local start = 1
|
|
||||||
if lines[1] and lines[1]:match('^FILTER:') then
|
|
||||||
start = 2
|
|
||||||
end
|
|
||||||
|
|
||||||
for i = start, #lines do
|
for i, line in ipairs(lines) do
|
||||||
local line = lines[i]
|
|
||||||
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('^(- %[.%] .*)$')
|
||||||
|
|
@ -54,13 +48,11 @@ function M.parse_buffer(lines)
|
||||||
status = status,
|
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,
|
||||||
rec = metadata.rec,
|
|
||||||
rec_mode = metadata.rec_mode,
|
|
||||||
lnum = i,
|
lnum = i,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
elseif line:match('^# (.+)$') then
|
elseif line:match('^## (.+)$') then
|
||||||
current_category = line:match('^# (.+)$')
|
current_category = line:match('^## (.+)$')
|
||||||
table.insert(result, { type = 'header', category = current_category, lnum = i })
|
table.insert(result, { type = 'header', category = current_category, lnum = i })
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -69,13 +61,10 @@ function M.parse_buffer(lines)
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param lines string[]
|
---@param lines string[]
|
||||||
---@param s pending.Store
|
function M.apply(lines)
|
||||||
---@param hidden_ids? table<integer, true>
|
|
||||||
---@return nil
|
|
||||||
function M.apply(lines, s, hidden_ids)
|
|
||||||
local parsed = M.parse_buffer(lines)
|
local parsed = M.parse_buffer(lines)
|
||||||
local now = timestamp()
|
local now = timestamp()
|
||||||
local data = s:data()
|
local data = store.data()
|
||||||
|
|
||||||
local old_by_id = {}
|
local old_by_id = {}
|
||||||
for _, task in ipairs(data.tasks) do
|
for _, task in ipairs(data.tasks) do
|
||||||
|
|
@ -96,13 +85,11 @@ function M.apply(lines, s, hidden_ids)
|
||||||
|
|
||||||
if entry.id and old_by_id[entry.id] then
|
if entry.id and old_by_id[entry.id] then
|
||||||
if seen_ids[entry.id] then
|
if seen_ids[entry.id] then
|
||||||
s:add({
|
store.add({
|
||||||
description = entry.description,
|
description = entry.description,
|
||||||
category = entry.category,
|
category = entry.category,
|
||||||
priority = entry.priority,
|
priority = entry.priority,
|
||||||
due = entry.due,
|
due = entry.due,
|
||||||
recur = entry.rec,
|
|
||||||
recur_mode = entry.rec_mode,
|
|
||||||
order = order_counter,
|
order = order_counter,
|
||||||
})
|
})
|
||||||
else
|
else
|
||||||
|
|
@ -121,20 +108,10 @@ function M.apply(lines, s, hidden_ids)
|
||||||
task.priority = entry.priority
|
task.priority = entry.priority
|
||||||
changed = true
|
changed = true
|
||||||
end
|
end
|
||||||
if entry.due ~= nil and task.due ~= entry.due then
|
if task.due ~= entry.due then
|
||||||
task.due = entry.due
|
task.due = entry.due
|
||||||
changed = true
|
changed = true
|
||||||
end
|
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.status and task.status ~= entry.status then
|
if entry.status and task.status ~= entry.status then
|
||||||
task.status = entry.status
|
task.status = entry.status
|
||||||
if entry.status == 'done' then
|
if entry.status == 'done' then
|
||||||
|
|
@ -153,13 +130,11 @@ function M.apply(lines, s, hidden_ids)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
s:add({
|
store.add({
|
||||||
description = entry.description,
|
description = entry.description,
|
||||||
category = entry.category,
|
category = entry.category,
|
||||||
priority = entry.priority,
|
priority = entry.priority,
|
||||||
due = entry.due,
|
due = entry.due,
|
||||||
recur = entry.rec,
|
|
||||||
recur_mode = entry.rec_mode,
|
|
||||||
order = order_counter,
|
order = order_counter,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
@ -168,14 +143,14 @@ function M.apply(lines, s, hidden_ids)
|
||||||
end
|
end
|
||||||
|
|
||||||
for id, task in pairs(old_by_id) do
|
for id, task in pairs(old_by_id) do
|
||||||
if not seen_ids[id] and not (hidden_ids and hidden_ids[id]) then
|
if not seen_ids[id] then
|
||||||
task.status = 'deleted'
|
task.status = 'deleted'
|
||||||
task['end'] = now
|
task['end'] = now
|
||||||
task.modified = now
|
task.modified = now
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
s:save()
|
store.save()
|
||||||
end
|
end
|
||||||
|
|
||||||
return M
|
return M
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
---@return nil
|
|
||||||
function M.check()
|
function M.check()
|
||||||
vim.health.start('pending.nvim')
|
vim.health.start('pending.nvim')
|
||||||
|
|
||||||
|
|
@ -12,55 +11,40 @@ function M.check()
|
||||||
|
|
||||||
local cfg = config.get()
|
local cfg = config.get()
|
||||||
vim.health.ok('Config loaded')
|
vim.health.ok('Config loaded')
|
||||||
|
vim.health.info('Data path: ' .. cfg.data_path)
|
||||||
|
|
||||||
local store_ok, store = pcall(require, 'pending.store')
|
local data_dir = vim.fn.fnamemodify(cfg.data_path, ':h')
|
||||||
if not store_ok then
|
if vim.fn.isdirectory(data_dir) == 1 then
|
||||||
vim.health.error('Failed to load pending.store')
|
vim.health.ok('Data directory exists: ' .. data_dir)
|
||||||
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
|
|
||||||
|
|
||||||
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
|
|
||||||
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
|
|
||||||
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')
|
|
||||||
else
|
else
|
||||||
for _, path in ipairs(sync_paths) do
|
vim.health.warn('Data directory does not exist yet: ' .. data_dir)
|
||||||
local name = vim.fn.fnamemodify(path, ':t:r')
|
end
|
||||||
local bok, backend = pcall(require, 'pending.sync.' .. name)
|
|
||||||
if bok and backend.name and type(backend.health) == 'function' then
|
if vim.fn.filereadable(cfg.data_path) == 1 then
|
||||||
vim.health.start('pending.nvim: sync/' .. name)
|
local store_ok, store = pcall(require, 'pending.store')
|
||||||
backend.health()
|
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
|
||||||
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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,200 +3,22 @@ local diff = require('pending.diff')
|
||||||
local parse = require('pending.parse')
|
local parse = require('pending.parse')
|
||||||
local store = require('pending.store')
|
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
|
---@class pending.init
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
|
---@type pending.Task[][]
|
||||||
|
local _undo_states = {}
|
||||||
local UNDO_MAX = 20
|
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()
|
|
||||||
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(get_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()
|
|
||||||
get_store():save()
|
|
||||||
M._recompute_counts()
|
|
||||||
end
|
|
||||||
|
|
||||||
---@return pending.Counts
|
|
||||||
function M.counts()
|
|
||||||
if not _counts then
|
|
||||||
get_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
|
|
||||||
|
|
||||||
---@param tasks pending.Task[]
|
|
||||||
---@param predicates string[]
|
|
||||||
---@return table<integer, true>
|
|
||||||
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
|
|
||||||
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
|
|
||||||
hidden[task.id] = true
|
|
||||||
end
|
|
||||||
end
|
|
||||||
return hidden
|
|
||||||
end
|
|
||||||
|
|
||||||
---@return integer bufnr
|
---@return integer bufnr
|
||||||
function M.open()
|
function M.open()
|
||||||
local s = get_store()
|
|
||||||
buffer.set_store(s)
|
|
||||||
local bufnr = buffer.open()
|
local bufnr = buffer.open()
|
||||||
M._setup_autocmds(bufnr)
|
M._setup_autocmds(bufnr)
|
||||||
M._setup_buf_mappings(bufnr)
|
M._setup_buf_mappings(bufnr)
|
||||||
return bufnr
|
return bufnr
|
||||||
end
|
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 = get_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
|
---@param bufnr integer
|
||||||
---@return nil
|
|
||||||
function M._setup_autocmds(bufnr)
|
function M._setup_autocmds(bufnr)
|
||||||
local group = vim.api.nvim_create_augroup('PendingBuffer', { clear = true })
|
local group = vim.api.nvim_create_augroup('PendingBuffer', { clear = true })
|
||||||
vim.api.nvim_create_autocmd('BufWriteCmd', {
|
vim.api.nvim_create_autocmd('BufWriteCmd', {
|
||||||
|
|
@ -211,7 +33,7 @@ function M._setup_autocmds(bufnr)
|
||||||
buffer = bufnr,
|
buffer = bufnr,
|
||||||
callback = function()
|
callback = function()
|
||||||
if not vim.bo[bufnr].modified then
|
if not vim.bo[bufnr].modified then
|
||||||
get_store():load()
|
store.load()
|
||||||
buffer.render(bufnr)
|
buffer.render(bufnr)
|
||||||
end
|
end
|
||||||
end,
|
end,
|
||||||
|
|
@ -227,166 +49,63 @@ function M._setup_autocmds(bufnr)
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param bufnr integer
|
---@param bufnr integer
|
||||||
---@return nil
|
|
||||||
function M._setup_buf_mappings(bufnr)
|
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 }
|
||||||
|
vim.keymap.set('n', 'q', function()
|
||||||
---@type table<string, fun()>
|
buffer.close()
|
||||||
local actions = {
|
end, opts)
|
||||||
close = function()
|
vim.keymap.set('n', '<Esc>', function()
|
||||||
buffer.close()
|
buffer.close()
|
||||||
end,
|
end, opts)
|
||||||
toggle = function()
|
vim.keymap.set('n', '<CR>', function()
|
||||||
M.toggle_complete()
|
M.toggle_complete()
|
||||||
end,
|
end, opts)
|
||||||
view = function()
|
vim.keymap.set('n', '<Tab>', function()
|
||||||
buffer.toggle_view()
|
buffer.toggle_view()
|
||||||
end,
|
end, opts)
|
||||||
priority = function()
|
vim.keymap.set('n', 'g?', function()
|
||||||
M.toggle_priority()
|
M.show_help()
|
||||||
end,
|
end, opts)
|
||||||
date = function()
|
vim.keymap.set('n', '!', function()
|
||||||
M.prompt_date()
|
M.toggle_priority()
|
||||||
end,
|
end, opts)
|
||||||
undo = function()
|
vim.keymap.set('n', 'D', function()
|
||||||
M.undo_write()
|
M.prompt_date()
|
||||||
end,
|
end, opts)
|
||||||
filter = function()
|
vim.keymap.set('n', 'U', function()
|
||||||
vim.ui.input({ prompt = 'Filter: ' }, function(input)
|
M.undo_write()
|
||||||
if input then
|
end, opts)
|
||||||
M.filter(input)
|
vim.keymap.set('n', 'o', function()
|
||||||
end
|
buffer.open_line(false)
|
||||||
end)
|
end, opts)
|
||||||
end,
|
vim.keymap.set('n', 'O', function()
|
||||||
open_line = function()
|
buffer.open_line(true)
|
||||||
buffer.open_line(false)
|
end, opts)
|
||||||
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
|
|
||||||
|
|
||||||
local textobj = require('pending.textobj')
|
|
||||||
|
|
||||||
---@type table<string, { modes: string[], fn: fun(count: integer), visual_fn?: fun(count: integer) }>
|
|
||||||
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<string, fun(count: integer)>
|
|
||||||
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
|
end
|
||||||
|
|
||||||
---@param bufnr integer
|
---@param bufnr integer
|
||||||
---@return nil
|
|
||||||
function M._on_write(bufnr)
|
function M._on_write(bufnr)
|
||||||
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
|
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
|
||||||
local predicates = buffer.filter_predicates()
|
local snapshot = store.snapshot()
|
||||||
if lines[1] and lines[1]:match('^FILTER:') then
|
table.insert(_undo_states, snapshot)
|
||||||
local pred_str = lines[1]:match('^FILTER:%s*(.*)$') or ''
|
if #_undo_states > UNDO_MAX then
|
||||||
predicates = {}
|
table.remove(_undo_states, 1)
|
||||||
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
|
end
|
||||||
local s = get_store()
|
diff.apply(lines)
|
||||||
local tasks = s:active_tasks()
|
|
||||||
local hidden = compute_hidden_ids(tasks, predicates)
|
|
||||||
buffer.set_filter(predicates, hidden)
|
|
||||||
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, s, hidden)
|
|
||||||
M._recompute_counts()
|
|
||||||
buffer.render(bufnr)
|
buffer.render(bufnr)
|
||||||
end
|
end
|
||||||
|
|
||||||
---@return nil
|
|
||||||
function M.undo_write()
|
function M.undo_write()
|
||||||
local s = get_store()
|
if #_undo_states == 0 then
|
||||||
local stack = s:undo_stack()
|
|
||||||
if #stack == 0 then
|
|
||||||
vim.notify('Nothing to undo.', vim.log.levels.WARN)
|
vim.notify('Nothing to undo.', vim.log.levels.WARN)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local state = table.remove(stack)
|
local state = table.remove(_undo_states)
|
||||||
s:replace_tasks(state)
|
store.replace_tasks(state)
|
||||||
_save_and_notify()
|
store.save()
|
||||||
buffer.render(buffer.bufnr())
|
buffer.render(buffer.bufnr())
|
||||||
end
|
end
|
||||||
|
|
||||||
---@return nil
|
|
||||||
function M.toggle_complete()
|
function M.toggle_complete()
|
||||||
local bufnr = buffer.bufnr()
|
local bufnr = buffer.bufnr()
|
||||||
if not bufnr then
|
if not bufnr then
|
||||||
|
|
@ -401,30 +120,16 @@ function M.toggle_complete()
|
||||||
if not id then
|
if not id then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local s = get_store()
|
local task = store.get(id)
|
||||||
local task = s:get(id)
|
|
||||||
if not task then
|
if not task then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if task.status == 'done' then
|
if task.status == 'done' then
|
||||||
s:update(id, { status = 'pending', ['end'] = vim.NIL })
|
store.update(id, { status = 'pending', ['end'] = vim.NIL })
|
||||||
else
|
else
|
||||||
if task.recur and task.due then
|
store.update(id, { status = 'done' })
|
||||||
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
|
end
|
||||||
_save_and_notify()
|
store.save()
|
||||||
buffer.render(bufnr)
|
buffer.render(bufnr)
|
||||||
for lnum, m in ipairs(buffer.meta()) do
|
for lnum, m in ipairs(buffer.meta()) do
|
||||||
if m.id == id then
|
if m.id == id then
|
||||||
|
|
@ -434,7 +139,6 @@ function M.toggle_complete()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
---@return nil
|
|
||||||
function M.toggle_priority()
|
function M.toggle_priority()
|
||||||
local bufnr = buffer.bufnr()
|
local bufnr = buffer.bufnr()
|
||||||
if not bufnr then
|
if not bufnr then
|
||||||
|
|
@ -449,14 +153,13 @@ function M.toggle_priority()
|
||||||
if not id then
|
if not id then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local s = get_store()
|
local task = store.get(id)
|
||||||
local task = s:get(id)
|
|
||||||
if not task then
|
if not task then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local new_priority = task.priority > 0 and 0 or 1
|
local new_priority = task.priority > 0 and 0 or 1
|
||||||
s:update(id, { priority = new_priority })
|
store.update(id, { priority = new_priority })
|
||||||
_save_and_notify()
|
store.save()
|
||||||
buffer.render(bufnr)
|
buffer.render(bufnr)
|
||||||
for lnum, m in ipairs(buffer.meta()) do
|
for lnum, m in ipairs(buffer.meta()) do
|
||||||
if m.id == id then
|
if m.id == id then
|
||||||
|
|
@ -466,7 +169,6 @@ function M.toggle_priority()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
---@return nil
|
|
||||||
function M.prompt_date()
|
function M.prompt_date()
|
||||||
local bufnr = buffer.bufnr()
|
local bufnr = buffer.bufnr()
|
||||||
if not bufnr then
|
if not bufnr then
|
||||||
|
|
@ -481,7 +183,7 @@ function M.prompt_date()
|
||||||
if not id then
|
if not id then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
vim.ui.input({ prompt = 'Due date (today, +3d, fri@2pm, etc.): ' }, function(input)
|
vim.ui.input({ prompt = 'Due date (YYYY-MM-DD): ' }, function(input)
|
||||||
if not input then
|
if not input then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
@ -490,42 +192,35 @@ function M.prompt_date()
|
||||||
local resolved = parse.resolve_date(due)
|
local resolved = parse.resolve_date(due)
|
||||||
if resolved then
|
if resolved then
|
||||||
due = resolved
|
due = resolved
|
||||||
elseif
|
elseif not due:match('^%d%d%d%d%-%d%d%-%d%d$') then
|
||||||
not due:match('^%d%d%d%d%-%d%d%-%d%d$')
|
vim.notify('Invalid date format. Use YYYY-MM-DD.', vim.log.levels.ERROR)
|
||||||
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
|
return
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
get_store():update(id, { due = due })
|
store.update(id, { due = due })
|
||||||
_save_and_notify()
|
store.save()
|
||||||
buffer.render(bufnr)
|
buffer.render(bufnr)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param text string
|
---@param text string
|
||||||
---@return nil
|
|
||||||
function M.add(text)
|
function M.add(text)
|
||||||
if not text or text == '' then
|
if not text or text == '' then
|
||||||
vim.notify('Usage: :Pending add <description>', vim.log.levels.ERROR)
|
vim.notify('Usage: :Pending add <description>', vim.log.levels.ERROR)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local s = get_store()
|
store.load()
|
||||||
s:load()
|
|
||||||
local description, metadata = parse.command_add(text)
|
local description, metadata = parse.command_add(text)
|
||||||
if not description or description == '' then
|
if not description or description == '' then
|
||||||
vim.notify('Pending must have a description.', vim.log.levels.ERROR)
|
vim.notify('Pending must have a description.', vim.log.levels.ERROR)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
s:add({
|
store.add({
|
||||||
description = description,
|
description = description,
|
||||||
category = metadata.cat,
|
category = metadata.cat,
|
||||||
due = metadata.due,
|
due = metadata.due,
|
||||||
recur = metadata.rec,
|
|
||||||
recur_mode = metadata.rec_mode,
|
|
||||||
})
|
})
|
||||||
_save_and_notify()
|
store.save()
|
||||||
local bufnr = buffer.bufnr()
|
local bufnr = buffer.bufnr()
|
||||||
if bufnr and vim.api.nvim_buf_is_valid(bufnr) then
|
if bufnr and vim.api.nvim_buf_is_valid(bufnr) then
|
||||||
buffer.render(bufnr)
|
buffer.render(bufnr)
|
||||||
|
|
@ -533,54 +228,25 @@ function M.add(text)
|
||||||
vim.notify('Pending added: ' .. description)
|
vim.notify('Pending added: ' .. description)
|
||||||
end
|
end
|
||||||
|
|
||||||
---@type string[]
|
function M.sync()
|
||||||
local SYNC_BACKENDS = { 'gcal', 'gtasks' }
|
local ok, gcal = pcall(require, 'pending.sync.gcal')
|
||||||
|
|
||||||
---@type table<string, true>
|
|
||||||
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
|
|
||||||
local function run_sync(backend_name, action)
|
|
||||||
local ok, backend = pcall(require, 'pending.sync.' .. backend_name)
|
|
||||||
if not ok then
|
if not ok then
|
||||||
vim.notify('Unknown sync backend: ' .. backend_name, vim.log.levels.ERROR)
|
vim.notify('Google Calendar sync module not available.', vim.log.levels.ERROR)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if not action or action == '' then
|
gcal.sync()
|
||||||
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
|
|
||||||
end
|
|
||||||
backend[action]()
|
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param days? integer
|
---@param days? integer
|
||||||
---@return nil
|
|
||||||
function M.archive(days)
|
function M.archive(days)
|
||||||
days = days or 30
|
days = days or 30
|
||||||
local cutoff = os.time() - (days * 86400)
|
local cutoff = os.time() - (days * 86400)
|
||||||
local s = get_store()
|
local tasks = store.tasks()
|
||||||
local tasks = s:tasks()
|
|
||||||
local archived = 0
|
local archived = 0
|
||||||
local kept = {}
|
local kept = {}
|
||||||
for _, task in ipairs(tasks) do
|
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') and task['end'] then
|
||||||
local y, mo, d, h, mi, sec = task['end']:match('^(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+)Z$')
|
local y, mo, d, h, mi, s = task['end']:match('^(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+)Z$')
|
||||||
if y then
|
if y then
|
||||||
local t = os.time({
|
local t = os.time({
|
||||||
year = tonumber(y) --[[@as integer]],
|
year = tonumber(y) --[[@as integer]],
|
||||||
|
|
@ -588,7 +254,7 @@ function M.archive(days)
|
||||||
day = tonumber(d) --[[@as integer]],
|
day = tonumber(d) --[[@as integer]],
|
||||||
hour = tonumber(h) --[[@as integer]],
|
hour = tonumber(h) --[[@as integer]],
|
||||||
min = tonumber(mi) --[[@as integer]],
|
min = tonumber(mi) --[[@as integer]],
|
||||||
sec = tonumber(sec) --[[@as integer]],
|
sec = tonumber(s) --[[@as integer]],
|
||||||
})
|
})
|
||||||
if t < cutoff then
|
if t < cutoff then
|
||||||
archived = archived + 1
|
archived = archived + 1
|
||||||
|
|
@ -599,8 +265,8 @@ function M.archive(days)
|
||||||
table.insert(kept, task)
|
table.insert(kept, task)
|
||||||
::skip::
|
::skip::
|
||||||
end
|
end
|
||||||
s:replace_tasks(kept)
|
store.replace_tasks(kept)
|
||||||
_save_and_notify()
|
store.save()
|
||||||
vim.notify('Archived ' .. archived .. ' tasks.')
|
vim.notify('Archived ' .. archived .. ' tasks.')
|
||||||
local bufnr = buffer.bufnr()
|
local bufnr = buffer.bufnr()
|
||||||
if bufnr and vim.api.nvim_buf_is_valid(bufnr) then
|
if bufnr and vim.api.nvim_buf_is_valid(bufnr) then
|
||||||
|
|
@ -608,8 +274,8 @@ function M.archive(days)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
---@return nil
|
|
||||||
function M.due()
|
function M.due()
|
||||||
|
local today = os.date('%Y-%m-%d') --[[@as string]]
|
||||||
local bufnr = buffer.bufnr()
|
local bufnr = buffer.bufnr()
|
||||||
local is_valid = bufnr ~= nil and vim.api.nvim_buf_is_valid(bufnr)
|
local is_valid = bufnr ~= nil and vim.api.nvim_buf_is_valid(bufnr)
|
||||||
local meta = is_valid and buffer.meta() or nil
|
local meta = is_valid and buffer.meta() or nil
|
||||||
|
|
@ -617,14 +283,9 @@ function M.due()
|
||||||
|
|
||||||
if meta and bufnr then
|
if meta and bufnr then
|
||||||
for lnum, m in ipairs(meta) do
|
for lnum, m in ipairs(meta) do
|
||||||
if
|
if m.type == 'task' and m.raw_due and m.status ~= 'done' and m.raw_due <= today then
|
||||||
m.type == 'task'
|
local task = store.get(m.id or 0)
|
||||||
and m.raw_due
|
local label = m.raw_due < today and '[OVERDUE] ' or '[DUE] '
|
||||||
and m.status ~= 'done'
|
|
||||||
and (parse.is_overdue(m.raw_due) or parse.is_today(m.raw_due))
|
|
||||||
then
|
|
||||||
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, {
|
table.insert(qf_items, {
|
||||||
bufnr = bufnr,
|
bufnr = bufnr,
|
||||||
lnum = lnum,
|
lnum = lnum,
|
||||||
|
|
@ -634,15 +295,10 @@ function M.due()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
local s = get_store()
|
store.load()
|
||||||
s:load()
|
for _, task in ipairs(store.active_tasks()) do
|
||||||
for _, task in ipairs(s:active_tasks()) do
|
if task.status == 'pending' and task.due and task.due <= today then
|
||||||
if
|
local label = task.due < today and '[OVERDUE] ' or '[DUE] '
|
||||||
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
|
local text = label .. task.description
|
||||||
if task.category then
|
if task.category then
|
||||||
text = text .. ' [' .. task.category .. ']'
|
text = text .. ' [' .. task.category .. ']'
|
||||||
|
|
@ -661,194 +317,68 @@ function M.due()
|
||||||
vim.cmd('copen')
|
vim.cmd('copen')
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param token string
|
function M.show_help()
|
||||||
---@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 cfg = require('pending.config').get()
|
||||||
local dk = cfg.date_syntax or 'due'
|
local dk = cfg.date_syntax or 'due'
|
||||||
local rk = cfg.recur_syntax or 'rec'
|
local lines = {
|
||||||
|
'pending.nvim keybindings',
|
||||||
if token == '+!' then
|
'',
|
||||||
return 'priority', 1, nil
|
'<CR> Toggle complete/uncomplete',
|
||||||
end
|
'<Tab> Switch category/priority view',
|
||||||
if token == '-!' then
|
'! Toggle urgent',
|
||||||
return 'priority', 0, nil
|
'D Set due date',
|
||||||
end
|
'U Undo last write',
|
||||||
if token == '-due' or token == '-' .. dk then
|
'o / O Add new task line',
|
||||||
return 'due', vim.NIL, nil
|
'dd Delete task line (on :w)',
|
||||||
end
|
'p / P Paste (duplicates get new IDs)',
|
||||||
if token == '-cat' then
|
'zc / zo Fold/unfold category (category view)',
|
||||||
return 'category', vim.NIL, nil
|
':w Save all changes',
|
||||||
end
|
'',
|
||||||
if token == '-rec' or token == '-' .. rk then
|
':Pending add <text> Quick-add task',
|
||||||
return 'recur', vim.NIL, nil
|
':Pending add Cat: <text> Quick-add with category',
|
||||||
end
|
':Pending due Show overdue/due qflist',
|
||||||
local due_val = token:match('^' .. vim.pesc(dk) .. ':(.+)$')
|
':Pending sync Push to Google Calendar',
|
||||||
if due_val then
|
':Pending archive [days] Purge old done tasks',
|
||||||
local resolved = parse.resolve_date(due_val)
|
':Pending undo Undo last write',
|
||||||
if resolved then
|
'',
|
||||||
return 'due', resolved, nil
|
'Inline metadata (on new lines before :w):',
|
||||||
end
|
' ' .. dk .. ':YYYY-MM-DD Set due date',
|
||||||
if
|
' cat:Name Set category',
|
||||||
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
|
'Due date input:',
|
||||||
return 'due', due_val, nil
|
' today, tomorrow, +Nd, mon-sun',
|
||||||
end
|
' Empty input clears due date',
|
||||||
return nil,
|
'',
|
||||||
nil,
|
'Highlights:',
|
||||||
'Invalid date: ' .. due_val .. '. Use YYYY-MM-DD, today, tomorrow, +Nd, weekday names, etc.'
|
' PendingOverdue overdue tasks (red)',
|
||||||
end
|
' PendingPriority [!] urgent tasks',
|
||||||
|
'',
|
||||||
local cat_val = token:match('^cat:(.+)$')
|
'Press q or <Esc> to close',
|
||||||
if cat_val then
|
}
|
||||||
return 'category', cat_val, nil
|
local buf = vim.api.nvim_create_buf(false, true)
|
||||||
end
|
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
|
||||||
|
vim.bo[buf].modifiable = false
|
||||||
local rec_val = token:match('^' .. vim.pesc(rk) .. ':(.+)$')
|
vim.bo[buf].bufhidden = 'wipe'
|
||||||
if rec_val then
|
local width = 54
|
||||||
local raw_spec = rec_val
|
local height = #lines
|
||||||
local rec_mode = nil
|
local win = vim.api.nvim_open_win(buf, true, {
|
||||||
if raw_spec:sub(1, 1) == '!' then
|
relative = 'editor',
|
||||||
rec_mode = 'completion'
|
width = width,
|
||||||
raw_spec = raw_spec:sub(2)
|
height = height,
|
||||||
end
|
col = math.floor((vim.o.columns - width) / 2),
|
||||||
if not recur.validate(raw_spec) then
|
row = math.floor((vim.o.lines - height) / 2),
|
||||||
return nil, nil, 'Invalid recurrence pattern: ' .. rec_val
|
style = 'minimal',
|
||||||
end
|
border = 'rounded',
|
||||||
return 'recur', { spec = raw_spec, mode = rec_mode }, nil
|
})
|
||||||
end
|
vim.keymap.set('n', 'q', function()
|
||||||
|
vim.api.nvim_win_close(win, true)
|
||||||
return nil,
|
end, { buffer = buf, silent = true })
|
||||||
nil,
|
vim.keymap.set('n', '<Esc>', function()
|
||||||
'Unknown operation: '
|
vim.api.nvim_win_close(win, true)
|
||||||
.. token
|
end, { buffer = buf, silent = true })
|
||||||
.. '. Valid: '
|
|
||||||
.. dk
|
|
||||||
.. ':<date>, cat:<name>, '
|
|
||||||
.. rk
|
|
||||||
.. ':<pattern>, +!, -!, -'
|
|
||||||
.. 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 <id> [due:<date>] [cat:<name>] [rec:<pattern>] [+!] [-!] [-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
|
|
||||||
|
|
||||||
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
|
|
||||||
end
|
|
||||||
|
|
||||||
if not rest or rest == '' then
|
|
||||||
vim.notify(
|
|
||||||
'Usage: :Pending edit <id> [due:<date>] [cat:<name>] [rec:<pattern>] [+!] [-!] [-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 = s:snapshot()
|
|
||||||
local stack = s:undo_stack()
|
|
||||||
table.insert(stack, snapshot)
|
|
||||||
if #stack > UNDO_MAX then
|
|
||||||
table.remove(stack, 1)
|
|
||||||
end
|
|
||||||
|
|
||||||
s:update(id, updates)
|
|
||||||
|
|
||||||
_save_and_notify()
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
---@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
|
end
|
||||||
|
|
||||||
---@param args string
|
---@param args string
|
||||||
---@return nil
|
|
||||||
function M.command(args)
|
function M.command(args)
|
||||||
if not args or args == '' then
|
if not args or args == '' then
|
||||||
M.open()
|
M.open()
|
||||||
|
|
@ -857,36 +387,18 @@ function M.command(args)
|
||||||
local cmd, rest = args:match('^(%S+)%s*(.*)')
|
local cmd, rest = args:match('^(%S+)%s*(.*)')
|
||||||
if cmd == 'add' then
|
if cmd == 'add' then
|
||||||
M.add(rest)
|
M.add(rest)
|
||||||
elseif cmd == 'edit' then
|
elseif cmd == 'sync' then
|
||||||
local id_str, edit_rest = rest:match('^(%S+)%s*(.*)')
|
M.sync()
|
||||||
M.edit(id_str, edit_rest)
|
|
||||||
elseif SYNC_BACKEND_SET[cmd] then
|
|
||||||
local action = rest:match('^(%S+)')
|
|
||||||
run_sync(cmd, action)
|
|
||||||
elseif cmd == 'archive' then
|
elseif cmd == 'archive' then
|
||||||
local d = rest ~= '' and tonumber(rest) or nil
|
local d = rest ~= '' and tonumber(rest) or nil
|
||||||
M.archive(d)
|
M.archive(d)
|
||||||
elseif cmd == 'due' then
|
elseif cmd == 'due' then
|
||||||
M.due()
|
M.due()
|
||||||
elseif cmd == 'filter' then
|
|
||||||
M.filter(rest)
|
|
||||||
elseif cmd == 'undo' then
|
elseif cmd == 'undo' then
|
||||||
M.undo_write()
|
M.undo_write()
|
||||||
elseif cmd == 'init' then
|
|
||||||
M.init()
|
|
||||||
else
|
else
|
||||||
vim.notify('Unknown Pending subcommand: ' .. cmd, vim.log.levels.ERROR)
|
vim.notify('Unknown Pending subcommand: ' .. cmd, vim.log.levels.ERROR)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
---@return string[]
|
|
||||||
function M.sync_backends()
|
|
||||||
return SYNC_BACKENDS
|
|
||||||
end
|
|
||||||
|
|
||||||
---@return table<string, true>
|
|
||||||
function M.sync_backend_set()
|
|
||||||
return SYNC_BACKEND_SET
|
|
||||||
end
|
|
||||||
|
|
||||||
return M
|
return M
|
||||||
|
|
|
||||||
|
|
@ -24,92 +24,11 @@ local function is_valid_date(s)
|
||||||
return check.year == yn and check.month == mn and check.day == dn
|
return check.year == yn and check.month == mn and check.day == dn
|
||||||
end
|
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
|
---@return string
|
||||||
local function date_key()
|
local function date_key()
|
||||||
return config.get().date_syntax or 'due'
|
return config.get().date_syntax or 'due'
|
||||||
end
|
end
|
||||||
|
|
||||||
---@return string
|
|
||||||
local function recur_key()
|
|
||||||
return config.get().recur_syntax or 'rec'
|
|
||||||
end
|
|
||||||
|
|
||||||
local weekday_map = {
|
local weekday_map = {
|
||||||
sun = 1,
|
sun = 1,
|
||||||
mon = 2,
|
mon = 2,
|
||||||
|
|
@ -120,295 +39,45 @@ local weekday_map = {
|
||||||
sat = 7,
|
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 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
|
---@param text string
|
||||||
---@return string|nil
|
---@return string|nil
|
||||||
function M.resolve_date(text)
|
function M.resolve_date(text)
|
||||||
local date_input, time_suffix = text:match('^(.+)@(.+)$')
|
local lower = text:lower()
|
||||||
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]]
|
local today = os.date('*t') --[[@as osdate]]
|
||||||
|
|
||||||
if lower == 'today' or lower == 'eod' then
|
if lower == 'today' then
|
||||||
return append_time(today_str(today), time_suffix)
|
return os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day })) --[[@as string]]
|
||||||
end
|
|
||||||
|
|
||||||
if lower == 'yesterday' then
|
|
||||||
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
|
end
|
||||||
|
|
||||||
if lower == 'tomorrow' then
|
if lower == 'tomorrow' then
|
||||||
return append_time(
|
return os.date(
|
||||||
os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day + 1 })) --[[@as string]],
|
'%Y-%m-%d',
|
||||||
time_suffix
|
os.time({ year = today.year, month = today.month, day = today.day + 1 })
|
||||||
)
|
) --[[@as string]]
|
||||||
end
|
|
||||||
|
|
||||||
if lower == 'sow' then
|
|
||||||
local delta = -((today.wday - 2) % 7)
|
|
||||||
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 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 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 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 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 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 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 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 append_time(config.get().someday_date, time_suffix)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
local n = lower:match('^%+(%d+)d$')
|
local n = lower:match('^%+(%d+)d$')
|
||||||
if n then
|
if n then
|
||||||
return append_time(
|
return os.date(
|
||||||
os.date(
|
'%Y-%m-%d',
|
||||||
'%Y-%m-%d',
|
os.time({
|
||||||
os.time({
|
year = today.year,
|
||||||
year = today.year,
|
month = today.month,
|
||||||
month = today.month,
|
day = today.day + (
|
||||||
day = today.day + (
|
tonumber(n) --[[@as integer]]
|
||||||
tonumber(n) --[[@as integer]]
|
),
|
||||||
),
|
})
|
||||||
})
|
) --[[@as string]]
|
||||||
) --[[@as string]],
|
|
||||||
time_suffix
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
n = lower:match('^%+(%d+)w$')
|
|
||||||
if n then
|
|
||||||
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 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 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 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]$')
|
|
||||||
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 append_time(os.date('%Y-%m-%d', t) --[[@as string]], time_suffix)
|
|
||||||
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 append_time(os.date('%Y-%m-%d', t) --[[@as string]], time_suffix)
|
|
||||||
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 append_time(
|
|
||||||
os.date('%Y-%m-%d', os.time({ year = y, month = target_month, day = 1 })) --[[@as string]],
|
|
||||||
time_suffix
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
local target_wday = weekday_map[lower]
|
local target_wday = weekday_map[lower]
|
||||||
if target_wday then
|
if target_wday then
|
||||||
local current_wday = today.wday
|
local current_wday = today.wday
|
||||||
local delta = (target_wday - current_wday) % 7
|
local delta = (target_wday - current_wday) % 7
|
||||||
return append_time(
|
return os.date(
|
||||||
os.date(
|
'%Y-%m-%d',
|
||||||
'%Y-%m-%d',
|
os.time({ year = today.year, month = today.month, day = today.day + delta })
|
||||||
os.time({ year = today.year, month = today.month, day = today.day + delta })
|
) --[[@as string]]
|
||||||
) --[[@as string]],
|
|
||||||
time_suffix
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -416,7 +85,7 @@ end
|
||||||
|
|
||||||
---@param text string
|
---@param text string
|
||||||
---@return string description
|
---@return string description
|
||||||
---@return { due?: string, cat?: string, rec?: string, rec_mode?: 'scheduled'|'completion' } metadata
|
---@return { due?: string, cat?: string } metadata
|
||||||
function M.body(text)
|
function M.body(text)
|
||||||
local tokens = {}
|
local tokens = {}
|
||||||
for token in text:gmatch('%S+') do
|
for token in text:gmatch('%S+') do
|
||||||
|
|
@ -426,10 +95,8 @@ function M.body(text)
|
||||||
local metadata = {}
|
local metadata = {}
|
||||||
local i = #tokens
|
local i = #tokens
|
||||||
local dk = date_key()
|
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 date_pattern_any = '^' .. vim.pesc(dk) .. ':(.+)$'
|
||||||
local rec_pattern = '^' .. vim.pesc(rk) .. ':(%S+)$'
|
|
||||||
|
|
||||||
while i >= 1 do
|
while i >= 1 do
|
||||||
local token = tokens[i]
|
local token = tokens[i]
|
||||||
|
|
@ -438,7 +105,7 @@ function M.body(text)
|
||||||
if metadata.due then
|
if metadata.due then
|
||||||
break
|
break
|
||||||
end
|
end
|
||||||
if not is_valid_datetime(due_val) then
|
if not is_valid_date(due_val) then
|
||||||
break
|
break
|
||||||
end
|
end
|
||||||
metadata.due = due_val
|
metadata.due = due_val
|
||||||
|
|
@ -464,25 +131,7 @@ function M.body(text)
|
||||||
metadata.cat = cat_val
|
metadata.cat = cat_val
|
||||||
i = i - 1
|
i = i - 1
|
||||||
else
|
else
|
||||||
local rec_val = token:match(rec_pattern)
|
break
|
||||||
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
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -499,7 +148,7 @@ end
|
||||||
|
|
||||||
---@param text string
|
---@param text string
|
||||||
---@return string description
|
---@return string description
|
||||||
---@return { due?: string, cat?: string, rec?: string, rec_mode?: 'scheduled'|'completion' } metadata
|
---@return { due?: string, cat?: string } metadata
|
||||||
function M.command_add(text)
|
function M.command_add(text)
|
||||||
local cat_prefix = text:match('^(%S.-):%s')
|
local cat_prefix = text:match('^(%S.-):%s')
|
||||||
if cat_prefix then
|
if cat_prefix then
|
||||||
|
|
@ -516,39 +165,4 @@ function M.command_add(text)
|
||||||
return M.body(text)
|
return M.body(text)
|
||||||
end
|
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
|
return M
|
||||||
|
|
|
||||||
|
|
@ -1,188 +0,0 @@
|
||||||
---@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<string, pending.RecurSpec>
|
|
||||||
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 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 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
|
|
||||||
result = os.date('%Y-%m-%d', os.time({ year = yn, month = mn, day = dn + interval })) --[[@as string]]
|
|
||||||
elseif freq == 'weekly' then
|
|
||||||
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
|
|
||||||
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]])
|
|
||||||
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]])
|
|
||||||
result = os.date('%Y-%m-%d', os.time({ year = new_y, month = mn, day = clamped_d })) --[[@as string]]
|
|
||||||
else
|
|
||||||
return base_date
|
|
||||||
end
|
|
||||||
|
|
||||||
if time_part then
|
|
||||||
return result .. 'T' .. time_part
|
|
||||||
end
|
|
||||||
return result
|
|
||||||
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]]
|
|
||||||
local _, time_part = split_datetime(base_date)
|
|
||||||
|
|
||||||
if mode == 'completion' then
|
|
||||||
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)
|
|
||||||
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
|
|
||||||
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
|
|
||||||
|
|
@ -7,8 +7,6 @@ local config = require('pending.config')
|
||||||
---@field category? string
|
---@field category? string
|
||||||
---@field priority integer
|
---@field priority integer
|
||||||
---@field due? string
|
---@field due? string
|
||||||
---@field recur? string
|
|
||||||
---@field recur_mode? 'scheduled'|'completion'
|
|
||||||
---@field entry string
|
---@field entry string
|
||||||
---@field modified string
|
---@field modified string
|
||||||
---@field end? string
|
---@field end? string
|
||||||
|
|
@ -19,26 +17,21 @@ local config = require('pending.config')
|
||||||
---@field version integer
|
---@field version integer
|
||||||
---@field next_id integer
|
---@field next_id integer
|
||||||
---@field tasks pending.Task[]
|
---@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
|
---@class pending.store
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
local SUPPORTED_VERSION = 1
|
local SUPPORTED_VERSION = 1
|
||||||
|
|
||||||
|
---@type pending.Data?
|
||||||
|
local _data = nil
|
||||||
|
|
||||||
---@return pending.Data
|
---@return pending.Data
|
||||||
local function empty_data()
|
local function empty_data()
|
||||||
return {
|
return {
|
||||||
version = SUPPORTED_VERSION,
|
version = SUPPORTED_VERSION,
|
||||||
next_id = 1,
|
next_id = 1,
|
||||||
tasks = {},
|
tasks = {},
|
||||||
undo = {},
|
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -63,8 +56,6 @@ local known_fields = {
|
||||||
category = true,
|
category = true,
|
||||||
priority = true,
|
priority = true,
|
||||||
due = true,
|
due = true,
|
||||||
recur = true,
|
|
||||||
recur_mode = true,
|
|
||||||
entry = true,
|
entry = true,
|
||||||
modified = true,
|
modified = true,
|
||||||
['end'] = true,
|
['end'] = true,
|
||||||
|
|
@ -90,12 +81,6 @@ local function task_to_table(task)
|
||||||
if task.due then
|
if task.due then
|
||||||
t.due = task.due
|
t.due = task.due
|
||||||
end
|
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
|
if task['end'] then
|
||||||
t['end'] = task['end']
|
t['end'] = task['end']
|
||||||
end
|
end
|
||||||
|
|
@ -120,8 +105,6 @@ local function table_to_task(t)
|
||||||
category = t.category,
|
category = t.category,
|
||||||
priority = t.priority or 0,
|
priority = t.priority or 0,
|
||||||
due = t.due,
|
due = t.due,
|
||||||
recur = t.recur,
|
|
||||||
recur_mode = t.recur_mode,
|
|
||||||
entry = t.entry,
|
entry = t.entry,
|
||||||
modified = t.modified,
|
modified = t.modified,
|
||||||
['end'] = t['end'],
|
['end'] = t['end'],
|
||||||
|
|
@ -140,18 +123,18 @@ local function table_to_task(t)
|
||||||
end
|
end
|
||||||
|
|
||||||
---@return pending.Data
|
---@return pending.Data
|
||||||
function Store:load()
|
function M.load()
|
||||||
local path = self.path
|
local path = config.get().data_path
|
||||||
local f = io.open(path, 'r')
|
local f = io.open(path, 'r')
|
||||||
if not f then
|
if not f then
|
||||||
self._data = empty_data()
|
_data = empty_data()
|
||||||
return self._data
|
return _data
|
||||||
end
|
end
|
||||||
local content = f:read('*a')
|
local content = f:read('*a')
|
||||||
f:close()
|
f:close()
|
||||||
if content == '' then
|
if content == '' then
|
||||||
self._data = empty_data()
|
_data = empty_data()
|
||||||
return self._data
|
return _data
|
||||||
end
|
end
|
||||||
local ok, decoded = pcall(vim.json.decode, content)
|
local ok, decoded = pcall(vim.json.decode, content)
|
||||||
if not ok then
|
if not ok then
|
||||||
|
|
@ -166,50 +149,31 @@ function Store:load()
|
||||||
.. '. Please update the plugin.'
|
.. '. Please update the plugin.'
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
self._data = {
|
_data = {
|
||||||
version = decoded.version or SUPPORTED_VERSION,
|
version = decoded.version or SUPPORTED_VERSION,
|
||||||
next_id = decoded.next_id or 1,
|
next_id = decoded.next_id or 1,
|
||||||
tasks = {},
|
tasks = {},
|
||||||
undo = {},
|
|
||||||
}
|
}
|
||||||
for _, t in ipairs(decoded.tasks or {}) do
|
for _, t in ipairs(decoded.tasks or {}) do
|
||||||
table.insert(self._data.tasks, table_to_task(t))
|
table.insert(_data.tasks, table_to_task(t))
|
||||||
end
|
end
|
||||||
for _, snapshot in ipairs(decoded.undo or {}) do
|
return _data
|
||||||
if type(snapshot) == 'table' then
|
|
||||||
local tasks = {}
|
|
||||||
for _, raw in ipairs(snapshot) do
|
|
||||||
table.insert(tasks, table_to_task(raw))
|
|
||||||
end
|
|
||||||
table.insert(self._data.undo, tasks)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
return self._data
|
|
||||||
end
|
end
|
||||||
|
|
||||||
---@return nil
|
function M.save()
|
||||||
function Store:save()
|
if not _data then
|
||||||
if not self._data then
|
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local path = self.path
|
local path = config.get().data_path
|
||||||
ensure_dir(path)
|
ensure_dir(path)
|
||||||
local out = {
|
local out = {
|
||||||
version = self._data.version,
|
version = _data.version,
|
||||||
next_id = self._data.next_id,
|
next_id = _data.next_id,
|
||||||
tasks = {},
|
tasks = {},
|
||||||
undo = {},
|
|
||||||
}
|
}
|
||||||
for _, task in ipairs(self._data.tasks) do
|
for _, task in ipairs(_data.tasks) do
|
||||||
table.insert(out.tasks, task_to_table(task))
|
table.insert(out.tasks, task_to_table(task))
|
||||||
end
|
end
|
||||||
for _, snapshot in ipairs(self._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 encoded = vim.json.encode(out)
|
||||||
local tmp = path .. '.tmp'
|
local tmp = path .. '.tmp'
|
||||||
local f = io.open(tmp, 'w')
|
local f = io.open(tmp, 'w')
|
||||||
|
|
@ -226,22 +190,22 @@ function Store:save()
|
||||||
end
|
end
|
||||||
|
|
||||||
---@return pending.Data
|
---@return pending.Data
|
||||||
function Store:data()
|
function M.data()
|
||||||
if not self._data then
|
if not _data then
|
||||||
self:load()
|
M.load()
|
||||||
end
|
end
|
||||||
return self._data --[[@as pending.Data]]
|
return _data --[[@as pending.Data]]
|
||||||
end
|
end
|
||||||
|
|
||||||
---@return pending.Task[]
|
---@return pending.Task[]
|
||||||
function Store:tasks()
|
function M.tasks()
|
||||||
return self:data().tasks
|
return M.data().tasks
|
||||||
end
|
end
|
||||||
|
|
||||||
---@return pending.Task[]
|
---@return pending.Task[]
|
||||||
function Store:active_tasks()
|
function M.active_tasks()
|
||||||
local result = {}
|
local result = {}
|
||||||
for _, task in ipairs(self:tasks()) do
|
for _, task in ipairs(M.tasks()) do
|
||||||
if task.status ~= 'deleted' then
|
if task.status ~= 'deleted' then
|
||||||
table.insert(result, task)
|
table.insert(result, task)
|
||||||
end
|
end
|
||||||
|
|
@ -251,8 +215,8 @@ end
|
||||||
|
|
||||||
---@param id integer
|
---@param id integer
|
||||||
---@return pending.Task?
|
---@return pending.Task?
|
||||||
function Store:get(id)
|
function M.get(id)
|
||||||
for _, task in ipairs(self:tasks()) do
|
for _, task in ipairs(M.tasks()) do
|
||||||
if task.id == id then
|
if task.id == id then
|
||||||
return task
|
return task
|
||||||
end
|
end
|
||||||
|
|
@ -260,10 +224,10 @@ function Store:get(id)
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param fields { description: string, status?: string, category?: string, priority?: integer, due?: string, recur?: string, recur_mode?: string, order?: integer, _extra?: table }
|
---@param fields { description: string, status?: string, category?: string, priority?: integer, due?: string, order?: integer, _extra?: table }
|
||||||
---@return pending.Task
|
---@return pending.Task
|
||||||
function Store:add(fields)
|
function M.add(fields)
|
||||||
local data = self:data()
|
local data = M.data()
|
||||||
local now = timestamp()
|
local now = timestamp()
|
||||||
local task = {
|
local task = {
|
||||||
id = data.next_id,
|
id = data.next_id,
|
||||||
|
|
@ -272,8 +236,6 @@ function Store:add(fields)
|
||||||
category = fields.category or config.get().default_category,
|
category = fields.category or config.get().default_category,
|
||||||
priority = fields.priority or 0,
|
priority = fields.priority or 0,
|
||||||
due = fields.due,
|
due = fields.due,
|
||||||
recur = fields.recur,
|
|
||||||
recur_mode = fields.recur_mode,
|
|
||||||
entry = now,
|
entry = now,
|
||||||
modified = now,
|
modified = now,
|
||||||
['end'] = nil,
|
['end'] = nil,
|
||||||
|
|
@ -288,19 +250,15 @@ end
|
||||||
---@param id integer
|
---@param id integer
|
||||||
---@param fields table<string, any>
|
---@param fields table<string, any>
|
||||||
---@return pending.Task?
|
---@return pending.Task?
|
||||||
function Store:update(id, fields)
|
function M.update(id, fields)
|
||||||
local task = self:get(id)
|
local task = M.get(id)
|
||||||
if not task then
|
if not task then
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
local now = timestamp()
|
local now = timestamp()
|
||||||
for k, v in pairs(fields) do
|
for k, v in pairs(fields) do
|
||||||
if k ~= 'id' and k ~= 'entry' then
|
if k ~= 'id' and k ~= 'entry' then
|
||||||
if v == vim.NIL then
|
task[k] = v
|
||||||
task[k] = nil
|
|
||||||
else
|
|
||||||
task[k] = v
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
task.modified = now
|
task.modified = now
|
||||||
|
|
@ -312,14 +270,14 @@ end
|
||||||
|
|
||||||
---@param id integer
|
---@param id integer
|
||||||
---@return pending.Task?
|
---@return pending.Task?
|
||||||
function Store:delete(id)
|
function M.delete(id)
|
||||||
return self:update(id, { status = 'deleted', ['end'] = timestamp() })
|
return M.update(id, { status = 'deleted', ['end'] = timestamp() })
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param id integer
|
---@param id integer
|
||||||
---@return integer?
|
---@return integer?
|
||||||
function Store:find_index(id)
|
function M.find_index(id)
|
||||||
for i, task in ipairs(self:tasks()) do
|
for i, task in ipairs(M.tasks()) do
|
||||||
if task.id == id then
|
if task.id == id then
|
||||||
return i
|
return i
|
||||||
end
|
end
|
||||||
|
|
@ -328,15 +286,14 @@ function Store:find_index(id)
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param tasks pending.Task[]
|
---@param tasks pending.Task[]
|
||||||
---@return nil
|
function M.replace_tasks(tasks)
|
||||||
function Store:replace_tasks(tasks)
|
M.data().tasks = tasks
|
||||||
self:data().tasks = tasks
|
|
||||||
end
|
end
|
||||||
|
|
||||||
---@return pending.Task[]
|
---@return pending.Task[]
|
||||||
function Store:snapshot()
|
function M.snapshot()
|
||||||
local result = {}
|
local result = {}
|
||||||
for _, task in ipairs(self:active_tasks()) do
|
for _, task in ipairs(M.active_tasks()) do
|
||||||
local copy = {}
|
local copy = {}
|
||||||
for k, v in pairs(task) do
|
for k, v in pairs(task) do
|
||||||
if k ~= '_extra' then
|
if k ~= '_extra' then
|
||||||
|
|
@ -354,45 +311,13 @@ function Store:snapshot()
|
||||||
return result
|
return result
|
||||||
end
|
end
|
||||||
|
|
||||||
---@return pending.Task[][]
|
|
||||||
function Store:undo_stack()
|
|
||||||
return self:data().undo
|
|
||||||
end
|
|
||||||
|
|
||||||
---@param stack pending.Task[][]
|
|
||||||
---@return nil
|
|
||||||
function Store:set_undo_stack(stack)
|
|
||||||
self:data().undo = stack
|
|
||||||
end
|
|
||||||
|
|
||||||
---@param id integer
|
---@param id integer
|
||||||
---@return nil
|
function M.set_next_id(id)
|
||||||
function Store:set_next_id(id)
|
M.data().next_id = id
|
||||||
self:data().next_id = id
|
|
||||||
end
|
end
|
||||||
|
|
||||||
---@return nil
|
function M.unload()
|
||||||
function Store:unload()
|
_data = nil
|
||||||
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
|
end
|
||||||
|
|
||||||
return M
|
return M
|
||||||
|
|
|
||||||
|
|
@ -1,67 +1,384 @@
|
||||||
local config = require('pending.config')
|
local config = require('pending.config')
|
||||||
local oauth = require('pending.sync.oauth')
|
local store = require('pending.store')
|
||||||
|
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
M.name = 'gcal'
|
|
||||||
|
|
||||||
local BASE_URL = 'https://www.googleapis.com/calendar/v3'
|
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 SCOPE = 'https://www.googleapis.com/auth/calendar'
|
||||||
|
|
||||||
local client = oauth.new({
|
---@class pending.GcalCredentials
|
||||||
name = 'gcal',
|
---@field client_id string
|
||||||
scope = SCOPE,
|
---@field client_secret string
|
||||||
port = 18392,
|
---@field redirect_uris? string[]
|
||||||
config_key = 'gcal',
|
|
||||||
})
|
|
||||||
|
|
||||||
---@param access_token string
|
---@class pending.GcalTokens
|
||||||
---@return table<string, string>? name_to_id
|
---@field access_token string
|
||||||
---@return string? err
|
---@field refresh_token string
|
||||||
local function get_all_calendars(access_token)
|
---@field expires_in? integer
|
||||||
local data, err = oauth.curl_request(
|
---@field obtained_at? integer
|
||||||
'GET',
|
|
||||||
BASE_URL .. '/users/me/calendarList',
|
---@return table<string, any>
|
||||||
oauth.auth_headers(access_token)
|
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
|
||||||
|
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)
|
||||||
)
|
)
|
||||||
if err then
|
end
|
||||||
return nil, err
|
|
||||||
|
---@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
|
end
|
||||||
local result = {}
|
if body then
|
||||||
for _, item in ipairs(data and data.items or {}) do
|
table.insert(args, '-d')
|
||||||
if item.summary then
|
table.insert(args, body)
|
||||||
result[item.summary] = item.id
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
return result, nil
|
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.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('pending.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(
|
||||||
|
'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 '<html><body><h1>Authorization successful</h1><p>You can close this tab.</p></body></html>'
|
||||||
|
or '<html><body><h1>Authorization failed</h1></body></html>'
|
||||||
|
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
|
end
|
||||||
|
|
||||||
---@param access_token string
|
---@param access_token string
|
||||||
---@param name string
|
|
||||||
---@param existing table<string, string>
|
|
||||||
---@return string? calendar_id
|
---@return string? calendar_id
|
||||||
---@return string? err
|
---@return string? err
|
||||||
local function find_or_create_calendar(access_token, name, existing)
|
local function find_or_create_calendar(access_token)
|
||||||
if existing[name] then
|
local gc = gcal_config()
|
||||||
return existing[name], nil
|
local cal_name = gc.calendar or 'Pendings'
|
||||||
end
|
|
||||||
local body = vim.json.encode({ summary = name })
|
local data, err =
|
||||||
local created, err =
|
curl_request('GET', BASE_URL .. '/users/me/calendarList', auth_headers(access_token))
|
||||||
oauth.curl_request('POST', BASE_URL .. '/calendars', oauth.auth_headers(access_token), body)
|
|
||||||
if err then
|
if err then
|
||||||
return nil, err
|
return nil, err
|
||||||
end
|
end
|
||||||
local id = created and created.id
|
|
||||||
if id then
|
for _, item in ipairs(data and data.items or {}) do
|
||||||
existing[name] = id
|
if item.summary == cal_name then
|
||||||
|
return item.id, nil
|
||||||
|
end
|
||||||
end
|
end
|
||||||
return id, nil
|
|
||||||
|
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 and created.id, nil
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param date_str string
|
---@param date_str string
|
||||||
---@return string
|
---@return string
|
||||||
local function next_day(date_str)
|
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 })
|
local t = os.time({ year = tonumber(y) or 0, month = tonumber(m) or 0, day = tonumber(d) or 0 })
|
||||||
+ 86400
|
+ 86400
|
||||||
return os.date('%Y-%m-%d', t) --[[@as string]]
|
return os.date('%Y-%m-%d', t) --[[@as string]]
|
||||||
|
|
@ -82,10 +399,10 @@ local function create_event(access_token, calendar_id, task)
|
||||||
private = { taskId = tostring(task.id) },
|
private = { taskId = tostring(task.id) },
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
local data, err = oauth.curl_request(
|
local data, err = curl_request(
|
||||||
'POST',
|
'POST',
|
||||||
BASE_URL .. '/calendars/' .. oauth.url_encode(calendar_id) .. '/events',
|
BASE_URL .. '/calendars/' .. url_encode(calendar_id) .. '/events',
|
||||||
oauth.auth_headers(access_token),
|
auth_headers(access_token),
|
||||||
vim.json.encode(event)
|
vim.json.encode(event)
|
||||||
)
|
)
|
||||||
if err then
|
if err then
|
||||||
|
|
@ -105,14 +422,10 @@ local function update_event(access_token, calendar_id, event_id, task)
|
||||||
start = { date = task.due },
|
start = { date = task.due },
|
||||||
['end'] = { date = next_day(task.due or '') },
|
['end'] = { date = next_day(task.due or '') },
|
||||||
}
|
}
|
||||||
local _, err = oauth.curl_request(
|
local _, err = curl_request(
|
||||||
'PATCH',
|
'PATCH',
|
||||||
BASE_URL
|
BASE_URL .. '/calendars/' .. url_encode(calendar_id) .. '/events/' .. url_encode(event_id),
|
||||||
.. '/calendars/'
|
auth_headers(access_token),
|
||||||
.. oauth.url_encode(calendar_id)
|
|
||||||
.. '/events/'
|
|
||||||
.. oauth.url_encode(event_id),
|
|
||||||
oauth.auth_headers(access_token),
|
|
||||||
vim.json.encode(event)
|
vim.json.encode(event)
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
|
|
@ -123,121 +436,81 @@ end
|
||||||
---@param event_id string
|
---@param event_id string
|
||||||
---@return string? err
|
---@return string? err
|
||||||
local function delete_event(access_token, calendar_id, event_id)
|
local function delete_event(access_token, calendar_id, event_id)
|
||||||
local _, err = oauth.curl_request(
|
local _, err = curl_request(
|
||||||
'DELETE',
|
'DELETE',
|
||||||
BASE_URL
|
BASE_URL .. '/calendars/' .. url_encode(calendar_id) .. '/events/' .. url_encode(event_id),
|
||||||
.. '/calendars/'
|
auth_headers(access_token)
|
||||||
.. oauth.url_encode(calendar_id)
|
|
||||||
.. '/events/'
|
|
||||||
.. oauth.url_encode(event_id),
|
|
||||||
oauth.auth_headers(access_token)
|
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
end
|
end
|
||||||
|
|
||||||
function M.auth()
|
function M.sync()
|
||||||
client:auth()
|
local access_token = get_access_token()
|
||||||
end
|
if not access_token then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
function M.push()
|
local calendar_id, err = find_or_create_calendar(access_token)
|
||||||
oauth.async(function()
|
if err or not calendar_id then
|
||||||
local access_token = client:get_access_token()
|
vim.notify('pending.nvim: ' .. (err or 'calendar not found'), vim.log.levels.ERROR)
|
||||||
if not access_token then
|
return
|
||||||
return
|
end
|
||||||
end
|
|
||||||
|
|
||||||
local calendars, cal_err = get_all_calendars(access_token)
|
local tasks = store.tasks()
|
||||||
if cal_err or not calendars then
|
local created, updated, deleted = 0, 0, 0
|
||||||
vim.notify('pending.nvim: ' .. (cal_err or 'failed to fetch calendars'), vim.log.levels.ERROR)
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
local s = require('pending').store()
|
for _, task in ipairs(tasks) do
|
||||||
local created, updated, deleted = 0, 0, 0
|
local extra = task._extra or {}
|
||||||
|
local event_id = extra['_gcal_event_id'] --[[@as string?]]
|
||||||
|
|
||||||
for _, task in ipairs(s:tasks()) do
|
local should_delete = event_id ~= nil
|
||||||
local extra = task._extra or {}
|
and (
|
||||||
local event_id = extra['_gcal_event_id'] --[[@as string?]]
|
task.status == 'done'
|
||||||
local cal_id = extra['_gcal_calendar_id'] --[[@as string?]]
|
or task.status == 'deleted'
|
||||||
|
or (task.status == 'pending' and not task.due)
|
||||||
|
)
|
||||||
|
|
||||||
local should_delete = event_id ~= nil
|
if should_delete and event_id then
|
||||||
and cal_id ~= nil
|
local del_err = delete_event(access_token, calendar_id, event_id) --[[@as string]]
|
||||||
and (
|
if not del_err then
|
||||||
task.status == 'done'
|
extra['_gcal_event_id'] = nil
|
||||||
or task.status == 'deleted'
|
if next(extra) == nil then
|
||||||
or (task.status == 'pending' and not task.due)
|
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
|
else
|
||||||
extra['_gcal_event_id'] = nil
|
task._extra = extra
|
||||||
extra['_gcal_calendar_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') --[[@as string]]
|
|
||||||
deleted = deleted + 1
|
|
||||||
end
|
end
|
||||||
elseif task.status == 'pending' and task.due then
|
task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]]
|
||||||
local cat = task.category or config.get().default_category
|
deleted = deleted + 1
|
||||||
if event_id and cal_id then
|
end
|
||||||
local upd_err = update_event(access_token, cal_id, event_id, task)
|
elseif task.status == 'pending' and task.due then
|
||||||
if upd_err then
|
if event_id then
|
||||||
vim.notify('pending.nvim: gcal update failed: ' .. upd_err, vim.log.levels.WARN)
|
local upd_err = update_event(access_token, calendar_id, event_id, task)
|
||||||
else
|
if not upd_err then
|
||||||
updated = updated + 1
|
updated = updated + 1
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
local lid, lid_err = find_or_create_calendar(access_token, cat, calendars)
|
local new_id, create_err = create_event(access_token, calendar_id, task)
|
||||||
if lid_err or not lid then
|
if not create_err and new_id then
|
||||||
vim.notify(
|
if not task._extra then
|
||||||
'pending.nvim: gcal calendar failed: ' .. (lid_err or 'unknown'),
|
task._extra = {}
|
||||||
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
|
||||||
|
task._extra['_gcal_event_id'] = new_id
|
||||||
|
task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]]
|
||||||
|
created = created + 1
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
s:save()
|
store.save()
|
||||||
require('pending')._recompute_counts()
|
vim.notify(
|
||||||
local buffer = require('pending.buffer')
|
string.format(
|
||||||
if buffer.bufnr() and vim.api.nvim_buf_is_valid(buffer.bufnr()) then
|
'pending.nvim: Synced to Google Calendar (created: %d, updated: %d, deleted: %d)',
|
||||||
buffer.render(buffer.bufnr())
|
created,
|
||||||
end
|
updated,
|
||||||
vim.notify(
|
deleted
|
||||||
string.format(
|
|
||||||
'pending.nvim: Google Calendar pushed — +%d ~%d -%d',
|
|
||||||
created,
|
|
||||||
updated,
|
|
||||||
deleted
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
end)
|
)
|
||||||
end
|
|
||||||
|
|
||||||
---@return nil
|
|
||||||
function M.health()
|
|
||||||
oauth.health(M.name)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
return M
|
return M
|
||||||
|
|
|
||||||
|
|
@ -1,459 +0,0 @@
|
||||||
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 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<string, string>? name_to_id
|
|
||||||
---@return string? err
|
|
||||||
local function get_all_tasklists(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
|
|
||||||
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<string, string>
|
|
||||||
---@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 = oauth.curl_request(
|
|
||||||
'POST',
|
|
||||||
BASE_URL .. '/users/@me/lists',
|
|
||||||
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 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/'
|
|
||||||
.. oauth.url_encode(list_id)
|
|
||||||
.. '/tasks?showCompleted=true&showHidden=true'
|
|
||||||
local data, err = oauth.curl_request('GET', url, oauth.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 = oauth.curl_request(
|
|
||||||
'POST',
|
|
||||||
BASE_URL .. '/lists/' .. oauth.url_encode(list_id) .. '/tasks',
|
|
||||||
oauth.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 = oauth.curl_request(
|
|
||||||
'PATCH',
|
|
||||||
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
|
|
||||||
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 = oauth.curl_request(
|
|
||||||
'DELETE',
|
|
||||||
BASE_URL .. '/lists/' .. oauth.url_encode(list_id) .. '/tasks/' .. oauth.url_encode(task_id),
|
|
||||||
oauth.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<string, pending.Task>
|
|
||||||
local function build_id_index(s)
|
|
||||||
---@type table<string, pending.Task>
|
|
||||||
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<string, string>
|
|
||||||
---@param s pending.Store
|
|
||||||
---@param now_ts string
|
|
||||||
---@param by_gtasks_id table<string, pending.Task>
|
|
||||||
---@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 err then
|
|
||||||
vim.notify('pending.nvim: gtasks delete failed: ' .. err, vim.log.levels.WARN)
|
|
||||||
else
|
|
||||||
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 err then
|
|
||||||
vim.notify('pending.nvim: gtasks update failed: ' .. err, vim.log.levels.WARN)
|
|
||||||
else
|
|
||||||
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 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
|
|
||||||
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<string, string>
|
|
||||||
---@param s pending.Store
|
|
||||||
---@param now_ts string
|
|
||||||
---@param by_gtasks_id table<string, pending.Task>
|
|
||||||
---@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<string, string>? tasklists
|
|
||||||
---@return pending.Store? store
|
|
||||||
---@return string? now_ts
|
|
||||||
local function sync_setup()
|
|
||||||
local access_token = client: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.auth()
|
|
||||||
client:auth()
|
|
||||||
end
|
|
||||||
|
|
||||||
function M.push()
|
|
||||||
oauth.async(function()
|
|
||||||
local access_token, tasklists, s, now_ts = sync_setup()
|
|
||||||
if not access_token then
|
|
||||||
return
|
|
||||||
end
|
|
||||||
---@cast tasklists table<string, string>
|
|
||||||
---@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()
|
|
||||||
oauth.async(function()
|
|
||||||
local access_token, tasklists, s, now_ts = sync_setup()
|
|
||||||
if not access_token then
|
|
||||||
return
|
|
||||||
end
|
|
||||||
---@cast tasklists table<string, string>
|
|
||||||
---@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()
|
|
||||||
oauth.async(function()
|
|
||||||
local access_token, tasklists, s, now_ts = sync_setup()
|
|
||||||
if not access_token then
|
|
||||||
return
|
|
||||||
end
|
|
||||||
---@cast tasklists table<string, string>
|
|
||||||
---@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
|
|
||||||
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()
|
|
||||||
oauth.health(M.name)
|
|
||||||
local tokens = 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')
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
return M
|
|
||||||
|
|
@ -1,405 +0,0 @@
|
||||||
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 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)
|
|
||||||
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 = M.system(args, { text = true })
|
|
||||||
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
|
|
||||||
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 = 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
|
|
||||||
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(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 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='
|
|
||||||
.. 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()
|
|
||||||
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
|
|
||||||
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
|
|
||||||
conn:close()
|
|
||||||
close_server()
|
|
||||||
return
|
|
||||||
end
|
|
||||||
local code = data:match('[?&]code=([^&%s]+)')
|
|
||||||
local response_body = code
|
|
||||||
and '<html><body><h1>Authorization successful</h1><p>You can close this tab.</p></body></html>'
|
|
||||||
or '<html><body><h1>Authorization failed</h1></body></html>'
|
|
||||||
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)
|
|
||||||
close_server()
|
|
||||||
if code then
|
|
||||||
vim.schedule(function()
|
|
||||||
self:_exchange_code(creds, code, code_verifier, port)
|
|
||||||
end)
|
|
||||||
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
|
|
||||||
---@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 = 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)
|
|
||||||
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
|
|
||||||
|
|
@ -1,384 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
local config = require('pending.config')
|
local config = require('pending.config')
|
||||||
local parse = require('pending.parse')
|
|
||||||
|
|
||||||
---@class pending.LineMeta
|
---@class pending.LineMeta
|
||||||
---@field type 'task'|'header'|'blank'|'filter'
|
---@field type 'task'|'header'|'blank'
|
||||||
---@field id? integer
|
---@field id? integer
|
||||||
---@field due? string
|
---@field due? string
|
||||||
---@field raw_due? string
|
---@field raw_due? string
|
||||||
|
|
@ -11,7 +10,6 @@ local parse = require('pending.parse')
|
||||||
---@field overdue? boolean
|
---@field overdue? boolean
|
||||||
---@field show_category? boolean
|
---@field show_category? boolean
|
||||||
---@field priority? integer
|
---@field priority? integer
|
||||||
---@field recur? string
|
|
||||||
|
|
||||||
---@class pending.views
|
---@class pending.views
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
@ -22,10 +20,7 @@ local function format_due(due)
|
||||||
if not due then
|
if not due then
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
local y, m, d, hh, mm = due:match('^(%d%d%d%d)-(%d%d)-(%d%d)T(%d%d):(%d%d)$')
|
local y, m, d = due:match('^(%d%d%d%d)-(%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
|
if not y then
|
||||||
return due
|
return due
|
||||||
end
|
end
|
||||||
|
|
@ -34,11 +29,7 @@ local function format_due(due)
|
||||||
month = tonumber(m) --[[@as integer]],
|
month = tonumber(m) --[[@as integer]],
|
||||||
day = tonumber(d) --[[@as integer]],
|
day = tonumber(d) --[[@as integer]],
|
||||||
})
|
})
|
||||||
local formatted = os.date(config.get().date_format, t) --[[@as string]]
|
return os.date(config.get().date_format, t) --[[@as string]]
|
||||||
if hh then
|
|
||||||
formatted = formatted .. ' ' .. hh .. ':' .. mm
|
|
||||||
end
|
|
||||||
return formatted
|
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param tasks pending.Task[]
|
---@param tasks pending.Task[]
|
||||||
|
|
@ -82,6 +73,7 @@ end
|
||||||
---@return string[] lines
|
---@return string[] lines
|
||||||
---@return pending.LineMeta[] meta
|
---@return pending.LineMeta[] meta
|
||||||
function M.category_view(tasks)
|
function M.category_view(tasks)
|
||||||
|
local today = os.date('%Y-%m-%d') --[[@as string]]
|
||||||
local by_cat = {}
|
local by_cat = {}
|
||||||
local cat_order = {}
|
local cat_order = {}
|
||||||
local cat_seen = {}
|
local cat_seen = {}
|
||||||
|
|
@ -133,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 = {}
|
||||||
|
|
@ -156,9 +148,7 @@ function M.category_view(tasks)
|
||||||
raw_due = task.due,
|
raw_due = task.due,
|
||||||
status = task.status,
|
status = task.status,
|
||||||
category = cat,
|
category = cat,
|
||||||
overdue = task.status == 'pending' and task.due ~= nil and parse.is_overdue(task.due)
|
overdue = task.status == 'pending' and task.due ~= nil and task.due < today or nil,
|
||||||
or nil,
|
|
||||||
recur = task.recur,
|
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -170,6 +160,7 @@ end
|
||||||
---@return string[] lines
|
---@return string[] lines
|
||||||
---@return pending.LineMeta[] meta
|
---@return pending.LineMeta[] meta
|
||||||
function M.priority_view(tasks)
|
function M.priority_view(tasks)
|
||||||
|
local today = os.date('%Y-%m-%d') --[[@as string]]
|
||||||
local pending = {}
|
local pending = {}
|
||||||
local done = {}
|
local done = {}
|
||||||
|
|
||||||
|
|
@ -207,9 +198,8 @@ function M.priority_view(tasks)
|
||||||
raw_due = task.due,
|
raw_due = task.due,
|
||||||
status = task.status,
|
status = task.status,
|
||||||
category = task.category,
|
category = task.category,
|
||||||
overdue = task.status == 'pending' and task.due ~= nil and parse.is_overdue(task.due) or nil,
|
overdue = task.status == 'pending' and task.due ~= nil and task.due < today or nil,
|
||||||
show_category = true,
|
show_category = true,
|
||||||
recur = task.recur,
|
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,225 +3,16 @@ if vim.g.loaded_pending then
|
||||||
end
|
end
|
||||||
vim.g.loaded_pending = true
|
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')
|
|
||||||
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
|
|
||||||
|
|
||||||
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')
|
|
||||||
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 filter_candidates(arg_lead, edit_field_candidates())
|
|
||||||
end
|
|
||||||
|
|
||||||
vim.api.nvim_create_user_command('Pending', function(opts)
|
vim.api.nvim_create_user_command('Pending', function(opts)
|
||||||
require('pending').command(opts.args)
|
require('pending').command(opts.args)
|
||||||
end, {
|
end, {
|
||||||
bar = true,
|
|
||||||
nargs = '*',
|
nargs = '*',
|
||||||
complete = function(arg_lead, cmd_line)
|
complete = function(arg_lead, cmd_line)
|
||||||
local pending = require('pending')
|
local subcmds = { 'add', 'sync', 'archive', 'due', 'undo' }
|
||||||
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
|
if not cmd_line:match('^Pending%s+%S') then
|
||||||
return filter_candidates(arg_lead, subcmds)
|
return vim.tbl_filter(function(s)
|
||||||
end
|
return s:find(arg_lead, 1, true) == 1
|
||||||
if cmd_line:match('^Pending%s+filter') then
|
end, subcmds)
|
||||||
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', 'done', 'pending' }
|
|
||||||
local store = require('pending.store')
|
|
||||||
local s = store.new(store.resolve_path())
|
|
||||||
s:load()
|
|
||||||
local seen = {}
|
|
||||||
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)
|
|
||||||
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
|
|
||||||
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 ok, mod = pcall(require, 'pending.sync.' .. matched_backend)
|
|
||||||
if not ok then
|
|
||||||
return {}
|
|
||||||
end
|
|
||||||
local actions = {}
|
|
||||||
for k, v in pairs(mod) do
|
|
||||||
if type(v) == 'function' and k:sub(1, 1) ~= '_' then
|
|
||||||
table.insert(actions, k)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
table.sort(actions)
|
|
||||||
return filter_candidates(arg_lead, actions)
|
|
||||||
end
|
end
|
||||||
return {}
|
return {}
|
||||||
end,
|
end,
|
||||||
|
|
@ -231,10 +22,6 @@ vim.keymap.set('n', '<Plug>(pending-open)', function()
|
||||||
require('pending').open()
|
require('pending').open()
|
||||||
end)
|
end)
|
||||||
|
|
||||||
vim.keymap.set('n', '<Plug>(pending-close)', function()
|
|
||||||
require('pending.buffer').close()
|
|
||||||
end)
|
|
||||||
|
|
||||||
vim.keymap.set('n', '<Plug>(pending-toggle)', function()
|
vim.keymap.set('n', '<Plug>(pending-toggle)', function()
|
||||||
require('pending').toggle_complete()
|
require('pending').toggle_complete()
|
||||||
end)
|
end)
|
||||||
|
|
@ -250,65 +37,3 @@ end)
|
||||||
vim.keymap.set('n', '<Plug>(pending-date)', function()
|
vim.keymap.set('n', '<Plug>(pending-date)', function()
|
||||||
require('pending').prompt_date()
|
require('pending').prompt_date()
|
||||||
end)
|
end)
|
||||||
|
|
||||||
vim.keymap.set('n', '<Plug>(pending-undo)', function()
|
|
||||||
require('pending').undo_write()
|
|
||||||
end)
|
|
||||||
|
|
||||||
vim.keymap.set('n', '<Plug>(pending-filter)', function()
|
|
||||||
vim.ui.input({ prompt = 'Filter: ' }, function(input)
|
|
||||||
if input then
|
|
||||||
require('pending').filter(input)
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
end)
|
|
||||||
|
|
||||||
vim.keymap.set('n', '<Plug>(pending-open-line)', function()
|
|
||||||
require('pending.buffer').open_line(false)
|
|
||||||
end)
|
|
||||||
|
|
||||||
vim.keymap.set('n', '<Plug>(pending-open-line-above)', function()
|
|
||||||
require('pending.buffer').open_line(true)
|
|
||||||
end)
|
|
||||||
|
|
||||||
vim.keymap.set({ 'o', 'x' }, '<Plug>(pending-a-task)', function()
|
|
||||||
require('pending.textobj').a_task(vim.v.count1)
|
|
||||||
end)
|
|
||||||
|
|
||||||
vim.keymap.set({ 'o', 'x' }, '<Plug>(pending-i-task)', function()
|
|
||||||
require('pending.textobj').i_task(vim.v.count1)
|
|
||||||
end)
|
|
||||||
|
|
||||||
vim.keymap.set({ 'o', 'x' }, '<Plug>(pending-a-category)', function()
|
|
||||||
require('pending.textobj').a_category(vim.v.count1)
|
|
||||||
end)
|
|
||||||
|
|
||||||
vim.keymap.set({ 'o', 'x' }, '<Plug>(pending-i-category)', function()
|
|
||||||
require('pending.textobj').i_category(vim.v.count1)
|
|
||||||
end)
|
|
||||||
|
|
||||||
vim.keymap.set({ 'n', 'x', 'o' }, '<Plug>(pending-next-header)', function()
|
|
||||||
require('pending.textobj').next_header(vim.v.count1)
|
|
||||||
end)
|
|
||||||
|
|
||||||
vim.keymap.set({ 'n', 'x', 'o' }, '<Plug>(pending-prev-header)', function()
|
|
||||||
require('pending.textobj').prev_header(vim.v.count1)
|
|
||||||
end)
|
|
||||||
|
|
||||||
vim.keymap.set({ 'n', 'x', 'o' }, '<Plug>(pending-next-task)', function()
|
|
||||||
require('pending.textobj').next_task(vim.v.count1)
|
|
||||||
end)
|
|
||||||
|
|
||||||
vim.keymap.set({ 'n', 'x', 'o' }, '<Plug>(pending-prev-task)', function()
|
|
||||||
require('pending.textobj').prev_task(vim.v.count1)
|
|
||||||
end)
|
|
||||||
|
|
||||||
vim.keymap.set('n', '<Plug>(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, {})
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
#!/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 lua --configpath "$(pwd)/.luarc.json" --checklevel=Warning
|
|
||||||
nix develop --command busted
|
|
||||||
|
|
@ -1,96 +1,87 @@
|
||||||
require('spec.helpers')
|
require('spec.helpers')
|
||||||
|
|
||||||
local config = require('pending.config')
|
local config = require('pending.config')
|
||||||
|
local store = require('pending.store')
|
||||||
|
|
||||||
describe('archive', function()
|
describe('archive', function()
|
||||||
local tmpdir
|
local tmpdir
|
||||||
local pending
|
local pending = require('pending')
|
||||||
|
|
||||||
before_each(function()
|
before_each(function()
|
||||||
tmpdir = vim.fn.tempname()
|
tmpdir = vim.fn.tempname()
|
||||||
vim.fn.mkdir(tmpdir, 'p')
|
vim.fn.mkdir(tmpdir, 'p')
|
||||||
vim.g.pending = { data_path = tmpdir .. '/tasks.json' }
|
vim.g.pending = { data_path = tmpdir .. '/tasks.json' }
|
||||||
config.reset()
|
config.reset()
|
||||||
package.loaded['pending'] = nil
|
store.unload()
|
||||||
pending = require('pending')
|
store.load()
|
||||||
pending.store():load()
|
|
||||||
end)
|
end)
|
||||||
|
|
||||||
after_each(function()
|
after_each(function()
|
||||||
vim.fn.delete(tmpdir, 'rf')
|
vim.fn.delete(tmpdir, 'rf')
|
||||||
vim.g.pending = nil
|
vim.g.pending = nil
|
||||||
config.reset()
|
config.reset()
|
||||||
package.loaded['pending'] = nil
|
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('removes done tasks completed more than 30 days ago', function()
|
it('removes done tasks completed more than 30 days ago', function()
|
||||||
local s = pending.store()
|
local t = store.add({ description = 'Old done task' })
|
||||||
local t = s:add({ description = 'Old done task' })
|
store.update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
|
||||||
s:update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
|
|
||||||
pending.archive()
|
pending.archive()
|
||||||
assert.are.equal(0, #s:active_tasks())
|
assert.are.equal(0, #store.active_tasks())
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('keeps done tasks completed fewer than 30 days ago', function()
|
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 recent_end = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (5 * 86400))
|
||||||
local t = s:add({ description = 'Recent done task' })
|
local t = store.add({ description = 'Recent done task' })
|
||||||
s:update(t.id, { status = 'done', ['end'] = recent_end })
|
store.update(t.id, { status = 'done', ['end'] = recent_end })
|
||||||
pending.archive()
|
pending.archive()
|
||||||
local active = s:active_tasks()
|
local active = store.active_tasks()
|
||||||
assert.are.equal(1, #active)
|
assert.are.equal(1, #active)
|
||||||
assert.are.equal('Recent done task', active[1].description)
|
assert.are.equal('Recent done task', active[1].description)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('respects a custom day count', function()
|
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 eight_days_ago = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (8 * 86400))
|
||||||
local t = s:add({ description = 'Old for 7 days' })
|
local t = store.add({ description = 'Old for 7 days' })
|
||||||
s:update(t.id, { status = 'done', ['end'] = eight_days_ago })
|
store.update(t.id, { status = 'done', ['end'] = eight_days_ago })
|
||||||
pending.archive(7)
|
pending.archive(7)
|
||||||
assert.are.equal(0, #s:active_tasks())
|
assert.are.equal(0, #store.active_tasks())
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('keeps tasks within the custom day cutoff', function()
|
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 five_days_ago = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (5 * 86400))
|
||||||
local t = s:add({ description = 'Recent for 7 days' })
|
local t = store.add({ description = 'Recent for 7 days' })
|
||||||
s:update(t.id, { status = 'done', ['end'] = five_days_ago })
|
store.update(t.id, { status = 'done', ['end'] = five_days_ago })
|
||||||
pending.archive(7)
|
pending.archive(7)
|
||||||
local active = s:active_tasks()
|
local active = store.active_tasks()
|
||||||
assert.are.equal(1, #active)
|
assert.are.equal(1, #active)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('never archives pending tasks regardless of age', function()
|
it('never archives pending tasks regardless of age', function()
|
||||||
local s = pending.store()
|
store.add({ description = 'Still pending' })
|
||||||
s:add({ description = 'Still pending' })
|
|
||||||
pending.archive()
|
pending.archive()
|
||||||
local active = s:active_tasks()
|
local active = store.active_tasks()
|
||||||
assert.are.equal(1, #active)
|
assert.are.equal(1, #active)
|
||||||
assert.are.equal('pending', active[1].status)
|
assert.are.equal('pending', active[1].status)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('removes deleted tasks past the cutoff', function()
|
it('removes deleted tasks past the cutoff', function()
|
||||||
local s = pending.store()
|
local t = store.add({ description = 'Old deleted task' })
|
||||||
local t = s:add({ description = 'Old deleted task' })
|
store.update(t.id, { status = 'deleted', ['end'] = '2020-01-01T00:00:00Z' })
|
||||||
s:update(t.id, { status = 'deleted', ['end'] = '2020-01-01T00:00:00Z' })
|
|
||||||
pending.archive()
|
pending.archive()
|
||||||
local all = s:tasks()
|
local all = store.tasks()
|
||||||
assert.are.equal(0, #all)
|
assert.are.equal(0, #all)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('keeps deleted tasks within the cutoff', function()
|
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 recent_end = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (5 * 86400))
|
||||||
local t = s:add({ description = 'Recent deleted' })
|
local t = store.add({ description = 'Recent deleted' })
|
||||||
s:update(t.id, { status = 'deleted', ['end'] = recent_end })
|
store.update(t.id, { status = 'deleted', ['end'] = recent_end })
|
||||||
pending.archive()
|
pending.archive()
|
||||||
local all = s:tasks()
|
local all = store.tasks()
|
||||||
assert.are.equal(1, #all)
|
assert.are.equal(1, #all)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('reports the correct count in vim.notify', function()
|
it('reports the correct count in vim.notify', function()
|
||||||
local s = pending.store()
|
|
||||||
local messages = {}
|
local messages = {}
|
||||||
local orig_notify = vim.notify
|
local orig_notify = vim.notify
|
||||||
vim.notify = function(msg, ...)
|
vim.notify = function(msg, ...)
|
||||||
|
|
@ -98,11 +89,11 @@ describe('archive', function()
|
||||||
return orig_notify(msg, ...)
|
return orig_notify(msg, ...)
|
||||||
end
|
end
|
||||||
|
|
||||||
local t1 = s:add({ description = 'Old 1' })
|
local t1 = store.add({ description = 'Old 1' })
|
||||||
local t2 = s:add({ description = 'Old 2' })
|
local t2 = store.add({ description = 'Old 2' })
|
||||||
s:add({ description = 'Keep' })
|
store.add({ description = 'Keep' })
|
||||||
s:update(t1.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
|
store.update(t1.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
|
||||||
s:update(t2.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
|
store.update(t2.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
|
||||||
|
|
||||||
pending.archive()
|
pending.archive()
|
||||||
|
|
||||||
|
|
@ -119,17 +110,16 @@ describe('archive', function()
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('leaves only kept tasks in store.active_tasks after archive', function()
|
it('leaves only kept tasks in store.active_tasks after archive', function()
|
||||||
local s = pending.store()
|
local t1 = store.add({ description = 'Old done' })
|
||||||
local t1 = s:add({ description = 'Old done' })
|
store.add({ description = 'Keep pending' })
|
||||||
s:add({ description = 'Keep pending' })
|
|
||||||
local recent_end = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (5 * 86400))
|
local recent_end = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (5 * 86400))
|
||||||
local t3 = s:add({ description = 'Keep recent done' })
|
local t3 = store.add({ description = 'Keep recent done' })
|
||||||
s:update(t1.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
|
store.update(t1.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
|
||||||
s:update(t3.id, { status = 'done', ['end'] = recent_end })
|
store.update(t3.id, { status = 'done', ['end'] = recent_end })
|
||||||
|
|
||||||
pending.archive()
|
pending.archive()
|
||||||
|
|
||||||
local active = s:active_tasks()
|
local active = store.active_tasks()
|
||||||
assert.are.equal(2, #active)
|
assert.are.equal(2, #active)
|
||||||
local descs = {}
|
local descs = {}
|
||||||
for _, task in ipairs(active) do
|
for _, task in ipairs(active) do
|
||||||
|
|
@ -140,11 +130,11 @@ describe('archive', function()
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('persists archived tasks to disk after unload/reload', function()
|
it('persists archived tasks to disk after unload/reload', function()
|
||||||
local s = pending.store()
|
local t = store.add({ description = 'Archived task' })
|
||||||
local t = s:add({ description = 'Archived task' })
|
store.update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
|
||||||
s:update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
|
|
||||||
pending.archive()
|
pending.archive()
|
||||||
s:load()
|
store.unload()
|
||||||
assert.are.equal(0, #s:active_tasks())
|
store.load()
|
||||||
|
assert.are.equal(0, #store.active_tasks())
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
|
|
|
||||||
|
|
@ -1,173 +0,0 @@
|
||||||
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')
|
|
||||||
config.reset()
|
|
||||||
s = store.new(tmpdir .. '/tasks.json')
|
|
||||||
s:load()
|
|
||||||
buffer.set_store(s)
|
|
||||||
end)
|
|
||||||
|
|
||||||
after_each(function()
|
|
||||||
vim.fn.delete(tmpdir, 'rf')
|
|
||||||
config.reset()
|
|
||||||
buffer.set_store(nil)
|
|
||||||
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()
|
|
||||||
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)
|
|
||||||
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()
|
|
||||||
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)
|
|
||||||
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)
|
|
||||||
|
|
@ -1,31 +1,35 @@
|
||||||
require('spec.helpers')
|
require('spec.helpers')
|
||||||
|
|
||||||
|
local config = require('pending.config')
|
||||||
local store = require('pending.store')
|
local store = require('pending.store')
|
||||||
|
|
||||||
describe('diff', function()
|
describe('diff', function()
|
||||||
local tmpdir
|
local tmpdir
|
||||||
local s
|
|
||||||
local diff = require('pending.diff')
|
local diff = require('pending.diff')
|
||||||
|
|
||||||
before_each(function()
|
before_each(function()
|
||||||
tmpdir = vim.fn.tempname()
|
tmpdir = vim.fn.tempname()
|
||||||
vim.fn.mkdir(tmpdir, 'p')
|
vim.fn.mkdir(tmpdir, 'p')
|
||||||
s = store.new(tmpdir .. '/tasks.json')
|
vim.g.pending = { data_path = tmpdir .. '/tasks.json' }
|
||||||
s:load()
|
config.reset()
|
||||||
|
store.unload()
|
||||||
|
store.load()
|
||||||
end)
|
end)
|
||||||
|
|
||||||
after_each(function()
|
after_each(function()
|
||||||
vim.fn.delete(tmpdir, 'rf')
|
vim.fn.delete(tmpdir, 'rf')
|
||||||
|
vim.g.pending = nil
|
||||||
|
config.reset()
|
||||||
end)
|
end)
|
||||||
|
|
||||||
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)
|
||||||
|
|
@ -44,7 +48,7 @@ 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)
|
||||||
|
|
@ -56,7 +60,7 @@ describe('diff', function()
|
||||||
|
|
||||||
it('inline cat: token overrides header category', function()
|
it('inline cat: token overrides header category', function()
|
||||||
local lines = {
|
local lines = {
|
||||||
'# Inbox',
|
'## Inbox',
|
||||||
'/1/- [ ] Buy milk cat:Work',
|
'/1/- [ ] Buy milk cat:Work',
|
||||||
}
|
}
|
||||||
local result = diff.parse_buffer(lines)
|
local result = diff.parse_buffer(lines)
|
||||||
|
|
@ -65,28 +69,9 @@ describe('diff', function()
|
||||||
assert.are.equal('Work', result[2].category)
|
assert.are.equal('Work', result[2].category)
|
||||||
end)
|
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()
|
it('inline due: token is parsed', function()
|
||||||
local lines = {
|
local lines = {
|
||||||
'# Inbox',
|
'## Inbox',
|
||||||
'/1/- [ ] Buy milk due:2026-03-15',
|
'/1/- [ ] Buy milk due:2026-03-15',
|
||||||
}
|
}
|
||||||
local result = diff.parse_buffer(lines)
|
local result = diff.parse_buffer(lines)
|
||||||
|
|
@ -99,192 +84,139 @@ describe('diff', function()
|
||||||
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, s)
|
diff.apply(lines)
|
||||||
s:load()
|
store.unload()
|
||||||
local tasks = s:active_tasks()
|
store.load()
|
||||||
|
local tasks = store.active_tasks()
|
||||||
assert.are.equal(2, #tasks)
|
assert.are.equal(2, #tasks)
|
||||||
assert.are.equal('First task', tasks[1].description)
|
assert.are.equal('First task', tasks[1].description)
|
||||||
assert.are.equal('Second task', tasks[2].description)
|
assert.are.equal('Second task', tasks[2].description)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('deletes tasks removed from buffer', function()
|
it('deletes tasks removed from buffer', function()
|
||||||
s:add({ description = 'Keep me' })
|
store.add({ description = 'Keep me' })
|
||||||
s:add({ description = 'Delete me' })
|
store.add({ description = 'Delete me' })
|
||||||
s:save()
|
store.save()
|
||||||
local lines = {
|
local lines = {
|
||||||
'# Inbox',
|
'## Inbox',
|
||||||
'/1/- [ ] Keep me',
|
'/1/- [ ] Keep me',
|
||||||
}
|
}
|
||||||
diff.apply(lines, s)
|
diff.apply(lines)
|
||||||
s:load()
|
store.unload()
|
||||||
local active = s:active_tasks()
|
store.load()
|
||||||
|
local active = store.active_tasks()
|
||||||
assert.are.equal(1, #active)
|
assert.are.equal(1, #active)
|
||||||
assert.are.equal('Keep me', active[1].description)
|
assert.are.equal('Keep me', active[1].description)
|
||||||
local deleted = s:get(2)
|
local deleted = store.get(2)
|
||||||
assert.are.equal('deleted', deleted.status)
|
assert.are.equal('deleted', deleted.status)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('updates modified tasks', function()
|
it('updates modified tasks', function()
|
||||||
s:add({ description = 'Original' })
|
store.add({ description = 'Original' })
|
||||||
s:save()
|
store.save()
|
||||||
local lines = {
|
local lines = {
|
||||||
'# Inbox',
|
'## Inbox',
|
||||||
'/1/- [ ] Renamed',
|
'/1/- [ ] Renamed',
|
||||||
}
|
}
|
||||||
diff.apply(lines, s)
|
diff.apply(lines)
|
||||||
s:load()
|
store.unload()
|
||||||
local task = s:get(1)
|
store.load()
|
||||||
|
local task = store.get(1)
|
||||||
assert.are.equal('Renamed', task.description)
|
assert.are.equal('Renamed', task.description)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('updates modified when description is renamed', function()
|
it('updates modified when description is renamed', function()
|
||||||
local t = s:add({ description = 'Original', category = 'Inbox' })
|
local t = store.add({ description = 'Original', category = 'Inbox' })
|
||||||
t.modified = '2020-01-01T00:00:00Z'
|
t.modified = '2020-01-01T00:00:00Z'
|
||||||
s:save()
|
store.save()
|
||||||
local lines = {
|
local lines = {
|
||||||
'# Inbox',
|
'## Inbox',
|
||||||
'/1/- [ ] Renamed',
|
'/1/- [ ] Renamed',
|
||||||
}
|
}
|
||||||
diff.apply(lines, s)
|
diff.apply(lines)
|
||||||
s:load()
|
store.unload()
|
||||||
local task = s:get(1)
|
store.load()
|
||||||
|
local task = store.get(1)
|
||||||
assert.are.equal('Renamed', task.description)
|
assert.are.equal('Renamed', task.description)
|
||||||
assert.is_not.equal('2020-01-01T00:00:00Z', task.modified)
|
assert.is_not.equal('2020-01-01T00:00:00Z', task.modified)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('handles duplicate ids as copies', function()
|
it('handles duplicate ids as copies', function()
|
||||||
s:add({ description = 'Original' })
|
store.add({ description = 'Original' })
|
||||||
s:save()
|
store.save()
|
||||||
local lines = {
|
local lines = {
|
||||||
'# Inbox',
|
'## Inbox',
|
||||||
'/1/- [ ] Original',
|
'/1/- [ ] Original',
|
||||||
'/1/- [ ] Copy of original',
|
'/1/- [ ] Copy of original',
|
||||||
}
|
}
|
||||||
diff.apply(lines, s)
|
diff.apply(lines)
|
||||||
s:load()
|
store.unload()
|
||||||
local tasks = s:active_tasks()
|
store.load()
|
||||||
|
local tasks = store.active_tasks()
|
||||||
assert.are.equal(2, #tasks)
|
assert.are.equal(2, #tasks)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('moves tasks between categories', function()
|
it('moves tasks between categories', function()
|
||||||
s:add({ description = 'Moving task', category = 'Inbox' })
|
store.add({ description = 'Moving task', category = 'Inbox' })
|
||||||
s:save()
|
store.save()
|
||||||
local lines = {
|
local lines = {
|
||||||
'# Work',
|
'## Work',
|
||||||
'/1/- [ ] Moving task',
|
'/1/- [ ] Moving task',
|
||||||
}
|
}
|
||||||
diff.apply(lines, s)
|
diff.apply(lines)
|
||||||
s:load()
|
store.unload()
|
||||||
local task = s:get(1)
|
store.load()
|
||||||
|
local task = store.get(1)
|
||||||
assert.are.equal('Work', task.category)
|
assert.are.equal('Work', task.category)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('does not update modified when task is unchanged', function()
|
it('does not update modified when task is unchanged', function()
|
||||||
s:add({ description = 'Stable task', category = 'Inbox' })
|
store.add({ description = 'Stable task', category = 'Inbox' })
|
||||||
s:save()
|
store.save()
|
||||||
local lines = {
|
local lines = {
|
||||||
'# Inbox',
|
'## Inbox',
|
||||||
'/1/- [ ] Stable task',
|
'/1/- [ ] Stable task',
|
||||||
}
|
}
|
||||||
diff.apply(lines, s)
|
diff.apply(lines)
|
||||||
s:load()
|
store.unload()
|
||||||
local modified_after_first = s:get(1).modified
|
store.load()
|
||||||
diff.apply(lines, s)
|
local modified_after_first = store.get(1).modified
|
||||||
s:load()
|
diff.apply(lines)
|
||||||
local task = s:get(1)
|
store.unload()
|
||||||
|
store.load()
|
||||||
|
local task = store.get(1)
|
||||||
assert.are.equal(modified_after_first, task.modified)
|
assert.are.equal(modified_after_first, task.modified)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('preserves due when not present in buffer line', function()
|
it('clears due when removed from buffer line', function()
|
||||||
s:add({ description = 'Pay bill', due = '2026-03-15' })
|
store.add({ description = 'Pay bill', due = '2026-03-15' })
|
||||||
s:save()
|
store.save()
|
||||||
local lines = {
|
local lines = {
|
||||||
'# Inbox',
|
'## Inbox',
|
||||||
'/1/- [ ] Pay bill',
|
'/1/- [ ] Pay bill',
|
||||||
}
|
}
|
||||||
diff.apply(lines, s)
|
diff.apply(lines)
|
||||||
s:load()
|
store.unload()
|
||||||
local task = s:get(1)
|
store.load()
|
||||||
assert.are.equal('2026-03-15', task.due)
|
local task = store.get(1)
|
||||||
end)
|
assert.is_nil(task.due)
|
||||||
|
|
||||||
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()
|
|
||||||
local lines = {
|
|
||||||
'# Inbox',
|
|
||||||
'- [ ] Take out trash rec:weekly',
|
|
||||||
}
|
|
||||||
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()
|
|
||||||
s:add({ description = 'Task', recur = 'daily' })
|
|
||||||
s:save()
|
|
||||||
local lines = {
|
|
||||||
'# Todo',
|
|
||||||
'/1/- [ ] Task rec:weekly',
|
|
||||||
}
|
|
||||||
diff.apply(lines, s)
|
|
||||||
s:load()
|
|
||||||
local task = s:get(1)
|
|
||||||
assert.are.equal('weekly', task.recur)
|
|
||||||
end)
|
|
||||||
|
|
||||||
it('preserves recur when not present in buffer line', function()
|
|
||||||
s:add({ description = 'Task', recur = 'daily' })
|
|
||||||
s:save()
|
|
||||||
local lines = {
|
|
||||||
'# Todo',
|
|
||||||
'/1/- [ ] Task',
|
|
||||||
}
|
|
||||||
diff.apply(lines, s)
|
|
||||||
s:load()
|
|
||||||
local task = s:get(1)
|
|
||||||
assert.are.equal('daily', task.recur)
|
|
||||||
end)
|
|
||||||
|
|
||||||
it('parses rec: with completion mode prefix', function()
|
|
||||||
local lines = {
|
|
||||||
'# Inbox',
|
|
||||||
'- [ ] Water plants rec:!weekly',
|
|
||||||
}
|
|
||||||
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)
|
end)
|
||||||
|
|
||||||
it('clears priority when [N] is removed from buffer line', function()
|
it('clears priority when [N] is removed from buffer line', function()
|
||||||
s:add({ description = 'Task name', priority = 1 })
|
store.add({ description = 'Task name', priority = 1 })
|
||||||
s:save()
|
store.save()
|
||||||
local lines = {
|
local lines = {
|
||||||
'# Inbox',
|
'## Inbox',
|
||||||
'/1/- [ ] Task name',
|
'/1/- [ ] Task name',
|
||||||
}
|
}
|
||||||
diff.apply(lines, s)
|
diff.apply(lines)
|
||||||
s:load()
|
store.unload()
|
||||||
local task = s:get(1)
|
store.load()
|
||||||
|
local task = store.get(1)
|
||||||
assert.are.equal(0, task.priority)
|
assert.are.equal(0, task.priority)
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
|
|
|
||||||
|
|
@ -1,329 +0,0 @@
|
||||||
require('spec.helpers')
|
|
||||||
|
|
||||||
local config = require('pending.config')
|
|
||||||
|
|
||||||
describe('edit', 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()
|
|
||||||
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 s = pending.store()
|
|
||||||
local t = s:add({ description = 'Task one' })
|
|
||||||
s:save()
|
|
||||||
pending.edit(tostring(t.id), 'due:tomorrow')
|
|
||||||
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 }))
|
|
||||||
assert.are.equal(expected, updated.due)
|
|
||||||
end)
|
|
||||||
|
|
||||||
it('sets due date with literal YYYY-MM-DD', function()
|
|
||||||
local s = pending.store()
|
|
||||||
local t = s:add({ description = 'Task one' })
|
|
||||||
s:save()
|
|
||||||
pending.edit(tostring(t.id), 'due:2026-06-15')
|
|
||||||
local updated = s:get(t.id)
|
|
||||||
assert.are.equal('2026-06-15', updated.due)
|
|
||||||
end)
|
|
||||||
|
|
||||||
it('sets category', function()
|
|
||||||
local s = pending.store()
|
|
||||||
local t = s:add({ description = 'Task one' })
|
|
||||||
s:save()
|
|
||||||
pending.edit(tostring(t.id), 'cat:Work')
|
|
||||||
local updated = s:get(t.id)
|
|
||||||
assert.are.equal('Work', updated.category)
|
|
||||||
end)
|
|
||||||
|
|
||||||
it('adds priority', function()
|
|
||||||
local s = pending.store()
|
|
||||||
local t = s:add({ description = 'Task one' })
|
|
||||||
s:save()
|
|
||||||
pending.edit(tostring(t.id), '+!')
|
|
||||||
local updated = s:get(t.id)
|
|
||||||
assert.are.equal(1, updated.priority)
|
|
||||||
end)
|
|
||||||
|
|
||||||
it('removes priority', function()
|
|
||||||
local s = pending.store()
|
|
||||||
local t = s:add({ description = 'Task one', priority = 1 })
|
|
||||||
s:save()
|
|
||||||
pending.edit(tostring(t.id), '-!')
|
|
||||||
local updated = s:get(t.id)
|
|
||||||
assert.are.equal(0, updated.priority)
|
|
||||||
end)
|
|
||||||
|
|
||||||
it('removes due date', function()
|
|
||||||
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 = s:get(t.id)
|
|
||||||
assert.is_nil(updated.due)
|
|
||||||
end)
|
|
||||||
|
|
||||||
it('removes category', function()
|
|
||||||
local s = pending.store()
|
|
||||||
local t = s:add({ description = 'Task one', category = 'Work' })
|
|
||||||
s:save()
|
|
||||||
pending.edit(tostring(t.id), '-cat')
|
|
||||||
local updated = s:get(t.id)
|
|
||||||
assert.is_nil(updated.category)
|
|
||||||
end)
|
|
||||||
|
|
||||||
it('sets recurrence', function()
|
|
||||||
local s = pending.store()
|
|
||||||
local t = s:add({ description = 'Task one' })
|
|
||||||
s:save()
|
|
||||||
pending.edit(tostring(t.id), 'rec:weekly')
|
|
||||||
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 s = pending.store()
|
|
||||||
local t = s:add({ description = 'Task one' })
|
|
||||||
s:save()
|
|
||||||
pending.edit(tostring(t.id), 'rec:!daily')
|
|
||||||
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 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 = s:get(t.id)
|
|
||||||
assert.is_nil(updated.recur)
|
|
||||||
assert.is_nil(updated.recur_mode)
|
|
||||||
end)
|
|
||||||
|
|
||||||
it('applies multiple operations at once', function()
|
|
||||||
local s = pending.store()
|
|
||||||
local t = s:add({ description = 'Task one' })
|
|
||||||
s:save()
|
|
||||||
pending.edit(tostring(t.id), 'due:today cat:Errands +!')
|
|
||||||
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 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, #s:undo_stack())
|
|
||||||
end)
|
|
||||||
|
|
||||||
it('persists changes to disk', function()
|
|
||||||
local s = pending.store()
|
|
||||||
local t = s:add({ description = 'Task one' })
|
|
||||||
s:save()
|
|
||||||
pending.edit(tostring(t.id), 'cat:Work')
|
|
||||||
s:load()
|
|
||||||
local updated = s:get(t.id)
|
|
||||||
assert.are.equal('Work', updated.category)
|
|
||||||
end)
|
|
||||||
|
|
||||||
it('errors on unknown task ID', function()
|
|
||||||
local s = pending.store()
|
|
||||||
s:add({ description = 'Task one' })
|
|
||||||
s: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 s = pending.store()
|
|
||||||
local t = s:add({ description = 'Task one' })
|
|
||||||
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), '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 s = pending.store()
|
|
||||||
local t = s:add({ description = 'Task one' })
|
|
||||||
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), '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 s = pending.store()
|
|
||||||
local t = s:add({ description = 'Task one' })
|
|
||||||
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), '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 s = pending.store()
|
|
||||||
local t = s:add({ description = 'Task one' })
|
|
||||||
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), '')
|
|
||||||
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 s = pending.store()
|
|
||||||
local t = s:add({ description = 'Task one' })
|
|
||||||
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), '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()
|
|
||||||
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 = 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 }))
|
|
||||||
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()
|
|
||||||
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 = s:get(t.id)
|
|
||||||
assert.are.equal('weekly', updated.recur)
|
|
||||||
end)
|
|
||||||
|
|
||||||
it('does not modify store on error', function()
|
|
||||||
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 = 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 s = pending.store()
|
|
||||||
local t = s:add({ description = 'Task one' })
|
|
||||||
s:save()
|
|
||||||
pending.edit(tostring(t.id), 'due:tomorrow@14:00')
|
|
||||||
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 }))
|
|
||||||
assert.are.equal(expected .. 'T14:00', updated.due)
|
|
||||||
end)
|
|
||||||
end)
|
|
||||||
|
|
@ -1,292 +0,0 @@
|
||||||
require('spec.helpers')
|
|
||||||
|
|
||||||
local config = require('pending.config')
|
|
||||||
local diff = require('pending.diff')
|
|
||||||
|
|
||||||
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()
|
|
||||||
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()
|
|
||||||
package.loaded['pending'] = nil
|
|
||||||
package.loaded['pending.buffer'] = nil
|
|
||||||
end)
|
|
||||||
|
|
||||||
describe('filter predicates', function()
|
|
||||||
it('cat: hides tasks with non-matching category', function()
|
|
||||||
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 = s: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()
|
|
||||||
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 = s: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()
|
|
||||||
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 = s: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()
|
|
||||||
local s = pending.store()
|
|
||||||
local today = os.date('%Y-%m-%d') --[[@as string]]
|
|
||||||
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 = s: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()
|
|
||||||
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 = s: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()
|
|
||||||
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 = s: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()
|
|
||||||
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')
|
|
||||||
assert.are.equal(0, #buffer.filter_predicates())
|
|
||||||
assert.are.same({}, buffer.hidden_ids())
|
|
||||||
end)
|
|
||||||
|
|
||||||
it('filter empty string clears filter', function()
|
|
||||||
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('')
|
|
||||||
assert.are.equal(0, #buffer.filter_predicates())
|
|
||||||
end)
|
|
||||||
|
|
||||||
it('filter predicates persist across set_filter calls', function()
|
|
||||||
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 = s: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()
|
|
||||||
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
|
|
||||||
hidden_task = t
|
|
||||||
end
|
|
||||||
end
|
|
||||||
local hidden_ids = { [hidden_task.id] = true }
|
|
||||||
local lines = {
|
|
||||||
'/1/- [ ] Visible task',
|
|
||||||
}
|
|
||||||
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()
|
|
||||||
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
|
|
||||||
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, s, {})
|
|
||||||
s:load()
|
|
||||||
local deleted = s:get(delete_task.id)
|
|
||||||
assert.are.equal('deleted', deleted.status)
|
|
||||||
end)
|
|
||||||
|
|
||||||
it('strips FILTER: line before parsing', function()
|
|
||||||
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, s, {})
|
|
||||||
s:load()
|
|
||||||
local t = s: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)
|
|
||||||
|
|
@ -1,178 +0,0 @@
|
||||||
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)
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
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('x', icons.done)
|
|
||||||
assert.equals('!', icons.priority)
|
|
||||||
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 = '+' } }
|
|
||||||
config.reset()
|
|
||||||
local icons = config.get().icons
|
|
||||||
assert.equals('*', icons.pending)
|
|
||||||
assert.equals('+', icons.done)
|
|
||||||
assert.equals('!', icons.priority)
|
|
||||||
assert.equals('#', icons.category)
|
|
||||||
end)
|
|
||||||
|
|
||||||
it('allows overriding all icons', function()
|
|
||||||
vim.g.pending = {
|
|
||||||
icons = {
|
|
||||||
pending = '-',
|
|
||||||
done = '+',
|
|
||||||
priority = '*',
|
|
||||||
due = '@',
|
|
||||||
recur = '^',
|
|
||||||
category = '&',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
config.reset()
|
|
||||||
local icons = config.get().icons
|
|
||||||
assert.equals('-', icons.pending)
|
|
||||||
assert.equals('+', icons.done)
|
|
||||||
assert.equals('*', icons.priority)
|
|
||||||
assert.equals('@', icons.due)
|
|
||||||
assert.equals('^', icons.recur)
|
|
||||||
assert.equals('&', icons.category)
|
|
||||||
end)
|
|
||||||
end)
|
|
||||||
|
|
@ -1,230 +0,0 @@
|
||||||
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)
|
|
||||||
|
|
@ -154,240 +154,6 @@ describe('parse', function()
|
||||||
local result = parse.resolve_date('')
|
local result = parse.resolve_date('')
|
||||||
assert.is_nil(result)
|
assert.is_nil(result)
|
||||||
end)
|
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('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)
|
end)
|
||||||
|
|
||||||
describe('command_add', function()
|
describe('command_add', function()
|
||||||
|
|
|
||||||
|
|
@ -1,223 +0,0 @@
|
||||||
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)
|
|
||||||
|
|
@ -1,260 +0,0 @@
|
||||||
require('spec.helpers')
|
|
||||||
|
|
||||||
local config = require('pending.config')
|
|
||||||
local parse = require('pending.parse')
|
|
||||||
|
|
||||||
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()
|
|
||||||
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)
|
|
||||||
|
|
||||||
describe('counts', function()
|
|
||||||
it('returns zeroes for empty store', function()
|
|
||||||
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()
|
|
||||||
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()
|
|
||||||
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()
|
|
||||||
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()
|
|
||||||
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()
|
|
||||||
local s = pending.store()
|
|
||||||
local today = os.date('%Y-%m-%d') --[[@as string]]
|
|
||||||
s:add({ description = 'Today task', due = today })
|
|
||||||
s: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()
|
|
||||||
local s = pending.store()
|
|
||||||
local today = os.date('%Y-%m-%d') --[[@as string]]
|
|
||||||
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)
|
|
||||||
assert.are.equal(1, c.today)
|
|
||||||
end)
|
|
||||||
|
|
||||||
it('excludes done tasks', function()
|
|
||||||
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)
|
|
||||||
assert.are.equal(0, c.pending)
|
|
||||||
end)
|
|
||||||
|
|
||||||
it('excludes deleted tasks', function()
|
|
||||||
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)
|
|
||||||
assert.are.equal(0, c.pending)
|
|
||||||
end)
|
|
||||||
|
|
||||||
it('excludes someday sentinel', function()
|
|
||||||
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)
|
|
||||||
assert.are.equal(0, c.today)
|
|
||||||
assert.are.equal(1, c.pending)
|
|
||||||
end)
|
|
||||||
|
|
||||||
it('picks earliest future date as next_due', function()
|
|
||||||
local s = pending.store()
|
|
||||||
local today = os.date('%Y-%m-%d') --[[@as string]]
|
|
||||||
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)
|
|
||||||
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()
|
|
||||||
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()
|
|
||||||
local s = pending.store()
|
|
||||||
s:save()
|
|
||||||
pending._recompute_counts()
|
|
||||||
assert.are.equal('', pending.statusline())
|
|
||||||
end)
|
|
||||||
|
|
||||||
it('formats overdue only', function()
|
|
||||||
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()
|
|
||||||
local s = pending.store()
|
|
||||||
local today = os.date('%Y-%m-%d') --[[@as string]]
|
|
||||||
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()
|
|
||||||
local s = pending.store()
|
|
||||||
local today = os.date('%Y-%m-%d') --[[@as string]]
|
|
||||||
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)
|
|
||||||
end)
|
|
||||||
|
|
||||||
describe('has_due', function()
|
|
||||||
it('returns false when nothing due', function()
|
|
||||||
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()
|
|
||||||
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()
|
|
||||||
local s = pending.store()
|
|
||||||
local today = os.date('%Y-%m-%d') --[[@as string]]
|
|
||||||
s:add({ description = 'Now', due = today })
|
|
||||||
s: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)
|
|
||||||
|
|
@ -5,30 +5,31 @@ local store = require('pending.store')
|
||||||
|
|
||||||
describe('store', function()
|
describe('store', function()
|
||||||
local tmpdir
|
local tmpdir
|
||||||
local s
|
|
||||||
|
|
||||||
before_each(function()
|
before_each(function()
|
||||||
tmpdir = vim.fn.tempname()
|
tmpdir = vim.fn.tempname()
|
||||||
vim.fn.mkdir(tmpdir, 'p')
|
vim.fn.mkdir(tmpdir, 'p')
|
||||||
s = store.new(tmpdir .. '/tasks.json')
|
vim.g.pending = { data_path = tmpdir .. '/tasks.json' }
|
||||||
s:load()
|
config.reset()
|
||||||
|
store.unload()
|
||||||
end)
|
end)
|
||||||
|
|
||||||
after_each(function()
|
after_each(function()
|
||||||
vim.fn.delete(tmpdir, 'rf')
|
vim.fn.delete(tmpdir, 'rf')
|
||||||
|
vim.g.pending = nil
|
||||||
config.reset()
|
config.reset()
|
||||||
end)
|
end)
|
||||||
|
|
||||||
describe('load', function()
|
describe('load', function()
|
||||||
it('returns empty data when no file exists', function()
|
it('returns empty data when no file exists', function()
|
||||||
local data = s:load()
|
local data = store.load()
|
||||||
assert.are.equal(1, data.version)
|
assert.are.equal(1, data.version)
|
||||||
assert.are.equal(1, data.next_id)
|
assert.are.equal(1, data.next_id)
|
||||||
assert.are.same({}, data.tasks)
|
assert.are.same({}, data.tasks)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('loads existing data', function()
|
it('loads existing data', function()
|
||||||
local path = tmpdir .. '/tasks.json'
|
local path = config.get().data_path
|
||||||
local f = io.open(path, 'w')
|
local f = io.open(path, 'w')
|
||||||
f:write(vim.json.encode({
|
f:write(vim.json.encode({
|
||||||
version = 1,
|
version = 1,
|
||||||
|
|
@ -51,7 +52,7 @@ describe('store', function()
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
f:close()
|
f:close()
|
||||||
local data = s:load()
|
local data = store.load()
|
||||||
assert.are.equal(3, data.next_id)
|
assert.are.equal(3, data.next_id)
|
||||||
assert.are.equal(2, #data.tasks)
|
assert.are.equal(2, #data.tasks)
|
||||||
assert.are.equal('Pending one', data.tasks[1].description)
|
assert.are.equal('Pending one', data.tasks[1].description)
|
||||||
|
|
@ -59,7 +60,7 @@ describe('store', function()
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('preserves unknown fields', function()
|
it('preserves unknown fields', function()
|
||||||
local path = tmpdir .. '/tasks.json'
|
local path = config.get().data_path
|
||||||
local f = io.open(path, 'w')
|
local f = io.open(path, 'w')
|
||||||
f:write(vim.json.encode({
|
f:write(vim.json.encode({
|
||||||
version = 1,
|
version = 1,
|
||||||
|
|
@ -76,8 +77,8 @@ describe('store', function()
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
f:close()
|
f:close()
|
||||||
s:load()
|
store.load()
|
||||||
local task = s:get(1)
|
local task = store.get(1)
|
||||||
assert.is_not_nil(task._extra)
|
assert.is_not_nil(task._extra)
|
||||||
assert.are.equal('hello', task._extra.custom_field)
|
assert.are.equal('hello', task._extra.custom_field)
|
||||||
end)
|
end)
|
||||||
|
|
@ -85,8 +86,9 @@ describe('store', function()
|
||||||
|
|
||||||
describe('add', function()
|
describe('add', function()
|
||||||
it('creates a task with incremented id', function()
|
it('creates a task with incremented id', function()
|
||||||
local t1 = s:add({ description = 'First' })
|
store.load()
|
||||||
local t2 = s:add({ description = 'Second' })
|
local t1 = store.add({ description = 'First' })
|
||||||
|
local t2 = store.add({ description = 'Second' })
|
||||||
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)
|
||||||
|
|
@ -94,54 +96,60 @@ describe('store', function()
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('uses provided category', function()
|
it('uses provided category', function()
|
||||||
local t = s:add({ description = 'Test', category = 'Work' })
|
store.load()
|
||||||
|
local t = store.add({ description = 'Test', category = 'Work' })
|
||||||
assert.are.equal('Work', t.category)
|
assert.are.equal('Work', t.category)
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
describe('update', function()
|
describe('update', function()
|
||||||
it('updates fields and sets modified', function()
|
it('updates fields and sets modified', function()
|
||||||
local t = s:add({ description = 'Original' })
|
store.load()
|
||||||
|
local t = store.add({ description = 'Original' })
|
||||||
t.modified = '2025-01-01T00:00:00Z'
|
t.modified = '2025-01-01T00:00:00Z'
|
||||||
s:update(t.id, { description = 'Updated' })
|
store.update(t.id, { description = 'Updated' })
|
||||||
local updated = s:get(t.id)
|
local updated = store.get(t.id)
|
||||||
assert.are.equal('Updated', updated.description)
|
assert.are.equal('Updated', updated.description)
|
||||||
assert.is_not.equal('2025-01-01T00:00:00Z', updated.modified)
|
assert.is_not.equal('2025-01-01T00:00:00Z', updated.modified)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('sets end timestamp on completion', function()
|
it('sets end timestamp on completion', function()
|
||||||
local t = s:add({ description = 'Test' })
|
store.load()
|
||||||
|
local t = store.add({ description = 'Test' })
|
||||||
assert.is_nil(t['end'])
|
assert.is_nil(t['end'])
|
||||||
s:update(t.id, { status = 'done' })
|
store.update(t.id, { status = 'done' })
|
||||||
local updated = s: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()
|
it('does not overwrite id or entry', function()
|
||||||
local t = s:add({ description = 'Immutable fields' })
|
store.load()
|
||||||
|
local t = store.add({ description = 'Immutable fields' })
|
||||||
local original_id = t.id
|
local original_id = t.id
|
||||||
local original_entry = t.entry
|
local original_entry = t.entry
|
||||||
s:update(t.id, { id = 999, entry = 'x' })
|
store.update(t.id, { id = 999, entry = 'x' })
|
||||||
local updated = s:get(original_id)
|
local updated = store.get(original_id)
|
||||||
assert.are.equal(original_id, updated.id)
|
assert.are.equal(original_id, updated.id)
|
||||||
assert.are.equal(original_entry, updated.entry)
|
assert.are.equal(original_entry, updated.entry)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('does not overwrite end on second completion', function()
|
it('does not overwrite end on second completion', function()
|
||||||
local t = s:add({ description = 'Complete twice' })
|
store.load()
|
||||||
s:update(t.id, { status = 'done', ['end'] = '2026-01-15T10:00:00Z' })
|
local t = store.add({ description = 'Complete twice' })
|
||||||
local first_end = s:get(t.id)['end']
|
store.update(t.id, { status = 'done', ['end'] = '2026-01-15T10:00:00Z' })
|
||||||
s:update(t.id, { status = 'done' })
|
local first_end = store.get(t.id)['end']
|
||||||
local task = s:get(t.id)
|
store.update(t.id, { status = 'done' })
|
||||||
|
local task = store.get(t.id)
|
||||||
assert.are.equal(first_end, task['end'])
|
assert.are.equal(first_end, task['end'])
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
describe('delete', function()
|
describe('delete', function()
|
||||||
it('marks task as deleted', function()
|
it('marks task as deleted', function()
|
||||||
local t = s:add({ description = 'To delete' })
|
store.load()
|
||||||
s:delete(t.id)
|
local t = store.add({ description = 'To delete' })
|
||||||
local deleted = s:get(t.id)
|
store.delete(t.id)
|
||||||
|
local deleted = store.get(t.id)
|
||||||
assert.are.equal('deleted', deleted.status)
|
assert.are.equal('deleted', deleted.status)
|
||||||
assert.is_not_nil(deleted['end'])
|
assert.is_not_nil(deleted['end'])
|
||||||
end)
|
end)
|
||||||
|
|
@ -149,10 +157,12 @@ describe('store', function()
|
||||||
|
|
||||||
describe('save and round-trip', function()
|
describe('save and round-trip', function()
|
||||||
it('persists and reloads correctly', function()
|
it('persists and reloads correctly', function()
|
||||||
s:add({ description = 'Persisted', category = 'Work', priority = 1 })
|
store.load()
|
||||||
s:save()
|
store.add({ description = 'Persisted', category = 'Work', priority = 1 })
|
||||||
s:load()
|
store.save()
|
||||||
local tasks = s:active_tasks()
|
store.unload()
|
||||||
|
store.load()
|
||||||
|
local tasks = store.active_tasks()
|
||||||
assert.are.equal(1, #tasks)
|
assert.are.equal(1, #tasks)
|
||||||
assert.are.equal('Persisted', tasks[1].description)
|
assert.are.equal('Persisted', tasks[1].description)
|
||||||
assert.are.equal('Work', tasks[1].category)
|
assert.are.equal('Work', tasks[1].category)
|
||||||
|
|
@ -160,7 +170,7 @@ describe('store', function()
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('round-trips unknown fields', function()
|
it('round-trips unknown fields', function()
|
||||||
local path = tmpdir .. '/tasks.json'
|
local path = config.get().data_path
|
||||||
local f = io.open(path, 'w')
|
local f = io.open(path, 'w')
|
||||||
f:write(vim.json.encode({
|
f:write(vim.json.encode({
|
||||||
version = 1,
|
version = 1,
|
||||||
|
|
@ -177,49 +187,22 @@ describe('store', function()
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
f:close()
|
f:close()
|
||||||
s:load()
|
store.load()
|
||||||
s:save()
|
store.save()
|
||||||
s:load()
|
store.unload()
|
||||||
local task = s:get(1)
|
store.load()
|
||||||
|
local task = store.get(1)
|
||||||
assert.are.equal('abc123', task._extra._gcal_event_id)
|
assert.are.equal('abc123', task._extra._gcal_event_id)
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
describe('recurrence fields', function()
|
|
||||||
it('persists recur and recur_mode through round-trip', function()
|
|
||||||
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()
|
|
||||||
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()
|
|
||||||
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)
|
|
||||||
end)
|
|
||||||
|
|
||||||
describe('active_tasks', function()
|
describe('active_tasks', function()
|
||||||
it('excludes deleted tasks', function()
|
it('excludes deleted tasks', function()
|
||||||
s:add({ description = 'Active' })
|
store.load()
|
||||||
local t2 = s:add({ description = 'To delete' })
|
store.add({ description = 'Active' })
|
||||||
s:delete(t2.id)
|
local t2 = store.add({ description = 'To delete' })
|
||||||
local active = s:active_tasks()
|
store.delete(t2.id)
|
||||||
|
local active = store.active_tasks()
|
||||||
assert.are.equal(1, #active)
|
assert.are.equal(1, #active)
|
||||||
assert.are.equal('Active', active[1].description)
|
assert.are.equal('Active', active[1].description)
|
||||||
end)
|
end)
|
||||||
|
|
@ -227,24 +210,27 @@ describe('store', function()
|
||||||
|
|
||||||
describe('snapshot', function()
|
describe('snapshot', function()
|
||||||
it('returns a table of tasks', function()
|
it('returns a table of tasks', function()
|
||||||
s:add({ description = 'Snap one' })
|
store.load()
|
||||||
s:add({ description = 'Snap two' })
|
store.add({ description = 'Snap one' })
|
||||||
local snap = s:snapshot()
|
store.add({ description = 'Snap two' })
|
||||||
|
local snap = store.snapshot()
|
||||||
assert.are.equal(2, #snap)
|
assert.are.equal(2, #snap)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('returns a copy that does not affect the store', function()
|
it('returns a copy that does not affect the store', function()
|
||||||
local t = s:add({ description = 'Original' })
|
store.load()
|
||||||
local snap = s:snapshot()
|
local t = store.add({ description = 'Original' })
|
||||||
|
local snap = store.snapshot()
|
||||||
snap[1].description = 'Mutated'
|
snap[1].description = 'Mutated'
|
||||||
local live = s:get(t.id)
|
local live = store.get(t.id)
|
||||||
assert.are.equal('Original', live.description)
|
assert.are.equal('Original', live.description)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('excludes deleted tasks', function()
|
it('excludes deleted tasks', function()
|
||||||
local t = s:add({ description = 'Will be deleted' })
|
store.load()
|
||||||
s:delete(t.id)
|
local t = store.add({ description = 'Will be deleted' })
|
||||||
local snap = s:snapshot()
|
store.delete(t.id)
|
||||||
|
local snap = store.snapshot()
|
||||||
assert.are.equal(0, #snap)
|
assert.are.equal(0, #snap)
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
|
|
|
||||||
|
|
@ -1,120 +0,0 @@
|
||||||
require('spec.helpers')
|
|
||||||
|
|
||||||
local config = require('pending.config')
|
|
||||||
|
|
||||||
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()
|
|
||||||
package.loaded['pending'] = nil
|
|
||||||
pending = require('pending')
|
|
||||||
end)
|
|
||||||
|
|
||||||
after_each(function()
|
|
||||||
vim.fn.delete(tmpdir, 'rf')
|
|
||||||
vim.g.pending = nil
|
|
||||||
config.reset()
|
|
||||||
package.loaded['pending'] = nil
|
|
||||||
end)
|
|
||||||
|
|
||||||
describe('dispatch', function()
|
|
||||||
it('errors on unknown subcommand', function()
|
|
||||||
local msg
|
|
||||||
local orig = vim.notify
|
|
||||||
vim.notify = function(m, level)
|
|
||||||
if level == vim.log.levels.ERROR then
|
|
||||||
msg = m
|
|
||||||
end
|
|
||||||
end
|
|
||||||
pending.command('notreal')
|
|
||||||
vim.notify = orig
|
|
||||||
assert.are.equal('Unknown Pending subcommand: 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.command('gcal notreal')
|
|
||||||
vim.notify = orig
|
|
||||||
assert.are.equal("gcal backend has no 'notreal' action", msg)
|
|
||||||
end)
|
|
||||||
|
|
||||||
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')
|
|
||||||
vim.notify = orig
|
|
||||||
assert.is_not_nil(msg)
|
|
||||||
assert.is_truthy(msg:find('push'))
|
|
||||||
end)
|
|
||||||
|
|
||||||
it('routes explicit push action', function()
|
|
||||||
local called = false
|
|
||||||
local gcal = require('pending.sync.gcal')
|
|
||||||
local orig_push = gcal.push
|
|
||||||
gcal.push = function()
|
|
||||||
called = true
|
|
||||||
end
|
|
||||||
pending.command('gcal push')
|
|
||||||
gcal.push = orig_push
|
|
||||||
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.command('gcal auth')
|
|
||||||
gcal.auth = orig_auth
|
|
||||||
assert.is_true(called)
|
|
||||||
end)
|
|
||||||
end)
|
|
||||||
|
|
||||||
it('works with sync.gcal config', function()
|
|
||||||
config.reset()
|
|
||||||
vim.g.pending = {
|
|
||||||
data_path = tmpdir .. '/tasks.json',
|
|
||||||
sync = { gcal = { client_id = 'test-id' } },
|
|
||||||
}
|
|
||||||
local cfg = config.get()
|
|
||||||
assert.are.equal('test-id', cfg.sync.gcal.client_id)
|
|
||||||
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 push function', function()
|
|
||||||
local gcal = require('pending.sync.gcal')
|
|
||||||
assert.are.equal('function', type(gcal.push))
|
|
||||||
end)
|
|
||||||
|
|
||||||
it('has health function', function()
|
|
||||||
local gcal = require('pending.sync.gcal')
|
|
||||||
assert.are.equal('function', type(gcal.health))
|
|
||||||
end)
|
|
||||||
end)
|
|
||||||
end)
|
|
||||||
|
|
@ -1,194 +0,0 @@
|
||||||
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)
|
|
||||||
|
|
@ -5,38 +5,39 @@ local store = require('pending.store')
|
||||||
|
|
||||||
describe('views', function()
|
describe('views', function()
|
||||||
local tmpdir
|
local tmpdir
|
||||||
local s
|
|
||||||
local views = require('pending.views')
|
local views = require('pending.views')
|
||||||
|
|
||||||
before_each(function()
|
before_each(function()
|
||||||
tmpdir = vim.fn.tempname()
|
tmpdir = vim.fn.tempname()
|
||||||
vim.fn.mkdir(tmpdir, 'p')
|
vim.fn.mkdir(tmpdir, 'p')
|
||||||
|
vim.g.pending = { data_path = tmpdir .. '/tasks.json' }
|
||||||
config.reset()
|
config.reset()
|
||||||
s = store.new(tmpdir .. '/tasks.json')
|
store.unload()
|
||||||
s:load()
|
store.load()
|
||||||
end)
|
end)
|
||||||
|
|
||||||
after_each(function()
|
after_each(function()
|
||||||
vim.fn.delete(tmpdir, 'rf')
|
vim.fn.delete(tmpdir, 'rf')
|
||||||
|
vim.g.pending = nil
|
||||||
config.reset()
|
config.reset()
|
||||||
end)
|
end)
|
||||||
|
|
||||||
describe('category_view', function()
|
describe('category_view', function()
|
||||||
it('groups tasks under their category header', function()
|
it('groups tasks under their category header', function()
|
||||||
s:add({ description = 'Task A', category = 'Work' })
|
store.add({ description = 'Task A', category = 'Work' })
|
||||||
s:add({ description = 'Task B', category = 'Work' })
|
store.add({ description = 'Task B', category = 'Work' })
|
||||||
local lines, meta = views.category_view(s: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)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('places pending tasks before done tasks within a category', function()
|
it('places pending tasks before done tasks within a category', function()
|
||||||
local t1 = s:add({ description = 'Done task', category = 'Work' })
|
local t1 = store.add({ description = 'Done task', category = 'Work' })
|
||||||
s:add({ description = 'Pending task', category = 'Work' })
|
store.add({ description = 'Pending task', category = 'Work' })
|
||||||
s:update(t1.id, { status = 'done' })
|
store.update(t1.id, { status = 'done' })
|
||||||
local _, meta = views.category_view(s:active_tasks())
|
local _, meta = views.category_view(store.active_tasks())
|
||||||
local pending_row, done_row
|
local pending_row, done_row
|
||||||
for i, m in ipairs(meta) do
|
for i, m in ipairs(meta) do
|
||||||
if m.type == 'task' and m.status == 'pending' then
|
if m.type == 'task' and m.status == 'pending' then
|
||||||
|
|
@ -49,9 +50,9 @@ describe('views', function()
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('sorts high-priority tasks before normal tasks within pending group', function()
|
it('sorts high-priority tasks before normal tasks within pending group', function()
|
||||||
s:add({ description = 'Normal', category = 'Work', priority = 0 })
|
store.add({ description = 'Normal', category = 'Work', priority = 0 })
|
||||||
s:add({ description = 'High', category = 'Work', priority = 1 })
|
store.add({ description = 'High', category = 'Work', priority = 1 })
|
||||||
local lines, meta = views.category_view(s:active_tasks())
|
local lines, meta = views.category_view(store.active_tasks())
|
||||||
local high_row, normal_row
|
local high_row, normal_row
|
||||||
for i, m in ipairs(meta) do
|
for i, m in ipairs(meta) do
|
||||||
if m.type == 'task' then
|
if m.type == 'task' then
|
||||||
|
|
@ -67,11 +68,11 @@ describe('views', function()
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('sorts high-priority tasks before normal tasks within done group', function()
|
it('sorts high-priority tasks before normal tasks within done group', function()
|
||||||
local t1 = s:add({ description = 'Done Normal', category = 'Work', priority = 0 })
|
local t1 = store.add({ description = 'Done Normal', category = 'Work', priority = 0 })
|
||||||
local t2 = s:add({ description = 'Done High', category = 'Work', priority = 1 })
|
local t2 = store.add({ description = 'Done High', category = 'Work', priority = 1 })
|
||||||
s:update(t1.id, { status = 'done' })
|
store.update(t1.id, { status = 'done' })
|
||||||
s:update(t2.id, { status = 'done' })
|
store.update(t2.id, { status = 'done' })
|
||||||
local lines, meta = views.category_view(s:active_tasks())
|
local lines, meta = views.category_view(store.active_tasks())
|
||||||
local high_row, normal_row
|
local high_row, normal_row
|
||||||
for i, m in ipairs(meta) do
|
for i, m in ipairs(meta) do
|
||||||
if m.type == 'task' then
|
if m.type == 'task' then
|
||||||
|
|
@ -87,9 +88,9 @@ describe('views', function()
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('gives each category its own header with blank lines between them', function()
|
it('gives each category its own header with blank lines between them', function()
|
||||||
s:add({ description = 'Task A', category = 'Work' })
|
store.add({ description = 'Task A', category = 'Work' })
|
||||||
s:add({ description = 'Task B', category = 'Personal' })
|
store.add({ description = 'Task B', category = 'Personal' })
|
||||||
local lines, meta = views.category_view(s:active_tasks())
|
local lines, meta = views.category_view(store.active_tasks())
|
||||||
local headers = {}
|
local headers = {}
|
||||||
local blank_found = false
|
local blank_found = false
|
||||||
for i, m in ipairs(meta) do
|
for i, m in ipairs(meta) do
|
||||||
|
|
@ -104,8 +105,8 @@ describe('views', function()
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('formats task lines as /ID/ description', function()
|
it('formats task lines as /ID/ description', function()
|
||||||
s:add({ description = 'My task', category = 'Inbox' })
|
store.add({ description = 'My task', category = 'Inbox' })
|
||||||
local lines, meta = views.category_view(s:active_tasks())
|
local lines, meta = views.category_view(store.active_tasks())
|
||||||
local task_line
|
local task_line
|
||||||
for i, m in ipairs(meta) do
|
for i, m in ipairs(meta) do
|
||||||
if m.type == 'task' then
|
if m.type == 'task' then
|
||||||
|
|
@ -116,8 +117,8 @@ describe('views', function()
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('formats priority task lines as /ID/- [!] description', function()
|
it('formats priority task lines as /ID/- [!] description', function()
|
||||||
s:add({ description = 'Important', category = 'Inbox', priority = 1 })
|
store.add({ description = 'Important', category = 'Inbox', priority = 1 })
|
||||||
local lines, meta = views.category_view(s:active_tasks())
|
local lines, meta = views.category_view(store.active_tasks())
|
||||||
local task_line
|
local task_line
|
||||||
for i, m in ipairs(meta) do
|
for i, m in ipairs(meta) do
|
||||||
if m.type == 'task' then
|
if m.type == 'task' then
|
||||||
|
|
@ -128,15 +129,15 @@ describe('views', function()
|
||||||
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()
|
||||||
s:add({ description = 'T', category = 'School' })
|
store.add({ description = 'T', category = 'School' })
|
||||||
local _, meta = views.category_view(s:active_tasks())
|
local _, meta = views.category_view(store.active_tasks())
|
||||||
assert.are.equal('header', meta[1].type)
|
assert.are.equal('header', meta[1].type)
|
||||||
assert.are.equal('School', meta[1].category)
|
assert.are.equal('School', meta[1].category)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('sets LineMeta type=task with correct id and status', function()
|
it('sets LineMeta type=task with correct id and status', function()
|
||||||
local t = s:add({ description = 'Do something', category = 'Inbox' })
|
local t = store.add({ description = 'Do something', category = 'Inbox' })
|
||||||
local _, meta = views.category_view(s:active_tasks())
|
local _, meta = views.category_view(store.active_tasks())
|
||||||
local task_meta
|
local task_meta
|
||||||
for _, m in ipairs(meta) do
|
for _, m in ipairs(meta) do
|
||||||
if m.type == 'task' then
|
if m.type == 'task' then
|
||||||
|
|
@ -149,9 +150,9 @@ describe('views', function()
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('sets LineMeta type=blank for blank separator lines', function()
|
it('sets LineMeta type=blank for blank separator lines', function()
|
||||||
s:add({ description = 'A', category = 'Work' })
|
store.add({ description = 'A', category = 'Work' })
|
||||||
s:add({ description = 'B', category = 'Home' })
|
store.add({ description = 'B', category = 'Home' })
|
||||||
local _, meta = views.category_view(s:active_tasks())
|
local _, meta = views.category_view(store.active_tasks())
|
||||||
local blank_meta
|
local blank_meta
|
||||||
for _, m in ipairs(meta) do
|
for _, m in ipairs(meta) do
|
||||||
if m.type == 'blank' then
|
if m.type == 'blank' then
|
||||||
|
|
@ -165,8 +166,8 @@ describe('views', function()
|
||||||
|
|
||||||
it('marks overdue pending tasks with meta.overdue=true', function()
|
it('marks overdue pending tasks with meta.overdue=true', function()
|
||||||
local yesterday = os.date('%Y-%m-%d', os.time() - 86400)
|
local yesterday = os.date('%Y-%m-%d', os.time() - 86400)
|
||||||
local t = s:add({ description = 'Overdue task', category = 'Inbox', due = yesterday })
|
local t = store.add({ description = 'Overdue task', category = 'Inbox', due = yesterday })
|
||||||
local _, meta = views.category_view(s:active_tasks())
|
local _, meta = views.category_view(store.active_tasks())
|
||||||
local task_meta
|
local task_meta
|
||||||
for _, m in ipairs(meta) do
|
for _, m in ipairs(meta) do
|
||||||
if m.type == 'task' and m.id == t.id then
|
if m.type == 'task' and m.id == t.id then
|
||||||
|
|
@ -178,8 +179,8 @@ describe('views', function()
|
||||||
|
|
||||||
it('does not mark future pending tasks as overdue', function()
|
it('does not mark future pending tasks as overdue', function()
|
||||||
local tomorrow = os.date('%Y-%m-%d', os.time() + 86400)
|
local tomorrow = os.date('%Y-%m-%d', os.time() + 86400)
|
||||||
local t = s:add({ description = 'Future task', category = 'Inbox', due = tomorrow })
|
local t = store.add({ description = 'Future task', category = 'Inbox', due = tomorrow })
|
||||||
local _, meta = views.category_view(s:active_tasks())
|
local _, meta = views.category_view(store.active_tasks())
|
||||||
local task_meta
|
local task_meta
|
||||||
for _, m in ipairs(meta) do
|
for _, m in ipairs(meta) do
|
||||||
if m.type == 'task' and m.id == t.id then
|
if m.type == 'task' and m.id == t.id then
|
||||||
|
|
@ -191,9 +192,9 @@ describe('views', function()
|
||||||
|
|
||||||
it('does not mark done tasks with overdue due dates as overdue', 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 yesterday = os.date('%Y-%m-%d', os.time() - 86400)
|
||||||
local t = s:add({ description = 'Done late', category = 'Inbox', due = yesterday })
|
local t = store.add({ description = 'Done late', category = 'Inbox', due = yesterday })
|
||||||
s:update(t.id, { status = 'done' })
|
store.update(t.id, { status = 'done' })
|
||||||
local _, meta = views.category_view(s:active_tasks())
|
local _, meta = views.category_view(store.active_tasks())
|
||||||
local task_meta
|
local task_meta
|
||||||
for _, m in ipairs(meta) do
|
for _, m in ipairs(meta) do
|
||||||
if m.type == 'task' and m.id == t.id then
|
if m.type == 'task' and m.id == t.id then
|
||||||
|
|
@ -203,36 +204,12 @@ describe('views', function()
|
||||||
assert.is_falsy(task_meta.overdue)
|
assert.is_falsy(task_meta.overdue)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('includes recur in LineMeta for recurring tasks', function()
|
|
||||||
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
|
|
||||||
task_meta = m
|
|
||||||
end
|
|
||||||
end
|
|
||||||
assert.are.equal('weekly', task_meta.recur)
|
|
||||||
end)
|
|
||||||
|
|
||||||
it('has nil recur in LineMeta for non-recurring tasks', function()
|
|
||||||
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
|
|
||||||
task_meta = m
|
|
||||||
end
|
|
||||||
end
|
|
||||||
assert.is_nil(task_meta.recur)
|
|
||||||
end)
|
|
||||||
|
|
||||||
it('respects category_order when set', function()
|
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', category_order = { 'Work', 'Inbox' } }
|
||||||
config.reset()
|
config.reset()
|
||||||
s:add({ description = 'Inbox task', category = 'Inbox' })
|
store.add({ description = 'Inbox task', category = 'Inbox' })
|
||||||
s:add({ description = 'Work task', category = 'Work' })
|
store.add({ description = 'Work task', category = 'Work' })
|
||||||
local lines, meta = views.category_view(s:active_tasks())
|
local lines, meta = views.category_view(store.active_tasks())
|
||||||
local first_header, second_header
|
local first_header, second_header
|
||||||
for i, m in ipairs(meta) do
|
for i, m in ipairs(meta) do
|
||||||
if m.type == 'header' then
|
if m.type == 'header' then
|
||||||
|
|
@ -243,47 +220,47 @@ 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()
|
||||||
vim.g.pending = { data_path = tmpdir .. '/tasks.json', category_order = { 'Work' } }
|
vim.g.pending = { data_path = tmpdir .. '/tasks.json', category_order = { 'Work' } }
|
||||||
config.reset()
|
config.reset()
|
||||||
s:add({ description = 'Errand', category = 'Errands' })
|
store.add({ description = 'Errand', category = 'Errands' })
|
||||||
s:add({ description = 'Work task', category = 'Work' })
|
store.add({ description = 'Work task', category = 'Work' })
|
||||||
local lines, meta = views.category_view(s:active_tasks())
|
local lines, meta = views.category_view(store.active_tasks())
|
||||||
local headers = {}
|
local headers = {}
|
||||||
for i, m in ipairs(meta) do
|
for i, m in ipairs(meta) do
|
||||||
if m.type == 'header' then
|
if m.type == 'header' then
|
||||||
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()
|
||||||
s:add({ description = 'Alpha task', category = 'Alpha' })
|
store.add({ description = 'Alpha task', category = 'Alpha' })
|
||||||
s:add({ description = 'Beta task', category = 'Beta' })
|
store.add({ description = 'Beta task', category = 'Beta' })
|
||||||
local lines, meta = views.category_view(s:active_tasks())
|
local lines, meta = views.category_view(store.active_tasks())
|
||||||
local headers = {}
|
local headers = {}
|
||||||
for i, m in ipairs(meta) do
|
for i, m in ipairs(meta) do
|
||||||
if m.type == 'header' then
|
if m.type == 'header' then
|
||||||
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)
|
||||||
|
|
||||||
describe('priority_view', function()
|
describe('priority_view', function()
|
||||||
it('places all pending tasks before done tasks', function()
|
it('places all pending tasks before done tasks', function()
|
||||||
local t1 = s:add({ description = 'Done A', category = 'Work' })
|
local t1 = store.add({ description = 'Done A', category = 'Work' })
|
||||||
s:add({ description = 'Pending B', category = 'Work' })
|
store.add({ description = 'Pending B', category = 'Work' })
|
||||||
s:update(t1.id, { status = 'done' })
|
store.update(t1.id, { status = 'done' })
|
||||||
local _, meta = views.priority_view(s:active_tasks())
|
local _, meta = views.priority_view(store.active_tasks())
|
||||||
local last_pending_row, first_done_row
|
local last_pending_row, first_done_row
|
||||||
for i, m in ipairs(meta) do
|
for i, m in ipairs(meta) do
|
||||||
if m.type == 'task' then
|
if m.type == 'task' then
|
||||||
|
|
@ -298,9 +275,9 @@ describe('views', function()
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('sorts pending tasks by priority desc within pending group', function()
|
it('sorts pending tasks by priority desc within pending group', function()
|
||||||
s:add({ description = 'Low', category = 'Work', priority = 0 })
|
store.add({ description = 'Low', category = 'Work', priority = 0 })
|
||||||
s:add({ description = 'High', category = 'Work', priority = 1 })
|
store.add({ description = 'High', category = 'Work', priority = 1 })
|
||||||
local lines, meta = views.priority_view(s:active_tasks())
|
local lines, meta = views.priority_view(store.active_tasks())
|
||||||
local high_row, low_row
|
local high_row, low_row
|
||||||
for i, m in ipairs(meta) do
|
for i, m in ipairs(meta) do
|
||||||
if m.type == 'task' then
|
if m.type == 'task' then
|
||||||
|
|
@ -315,9 +292,9 @@ describe('views', function()
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('sorts pending tasks with due dates before those without', function()
|
it('sorts pending tasks with due dates before those without', function()
|
||||||
s:add({ description = 'No due', category = 'Work' })
|
store.add({ description = 'No due', category = 'Work' })
|
||||||
s:add({ description = 'Has due', category = 'Work', due = '2099-12-31' })
|
store.add({ description = 'Has due', category = 'Work', due = '2099-12-31' })
|
||||||
local lines, meta = views.priority_view(s:active_tasks())
|
local lines, meta = views.priority_view(store.active_tasks())
|
||||||
local due_row, nodue_row
|
local due_row, nodue_row
|
||||||
for i, m in ipairs(meta) do
|
for i, m in ipairs(meta) do
|
||||||
if m.type == 'task' then
|
if m.type == 'task' then
|
||||||
|
|
@ -332,9 +309,9 @@ describe('views', function()
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('sorts pending tasks with earlier due dates before later due dates', function()
|
it('sorts pending tasks with earlier due dates before later due dates', function()
|
||||||
s:add({ description = 'Later', category = 'Work', due = '2099-12-31' })
|
store.add({ description = 'Later', category = 'Work', due = '2099-12-31' })
|
||||||
s:add({ description = 'Earlier', category = 'Work', due = '2050-01-01' })
|
store.add({ description = 'Earlier', category = 'Work', due = '2050-01-01' })
|
||||||
local lines, meta = views.priority_view(s:active_tasks())
|
local lines, meta = views.priority_view(store.active_tasks())
|
||||||
local earlier_row, later_row
|
local earlier_row, later_row
|
||||||
for i, m in ipairs(meta) do
|
for i, m in ipairs(meta) do
|
||||||
if m.type == 'task' then
|
if m.type == 'task' then
|
||||||
|
|
@ -349,15 +326,15 @@ describe('views', function()
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('formats task lines as /ID/- [ ] description', function()
|
it('formats task lines as /ID/- [ ] description', function()
|
||||||
s:add({ description = 'My task', category = 'Inbox' })
|
store.add({ description = 'My task', category = 'Inbox' })
|
||||||
local lines, _ = views.priority_view(s: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()
|
||||||
s:add({ description = 'T1', category = 'Work' })
|
store.add({ description = 'T1', category = 'Work' })
|
||||||
s:add({ description = 'T2', category = 'Personal' })
|
store.add({ description = 'T2', category = 'Personal' })
|
||||||
local _, meta = views.priority_view(s:active_tasks())
|
local _, meta = views.priority_view(store.active_tasks())
|
||||||
for _, m in ipairs(meta) do
|
for _, m in ipairs(meta) do
|
||||||
if m.type == 'task' then
|
if m.type == 'task' then
|
||||||
assert.is_true(m.show_category == true)
|
assert.is_true(m.show_category == true)
|
||||||
|
|
@ -366,9 +343,9 @@ describe('views', function()
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('sets meta.category correctly for each task', function()
|
it('sets meta.category correctly for each task', function()
|
||||||
s:add({ description = 'Work task', category = 'Work' })
|
store.add({ description = 'Work task', category = 'Work' })
|
||||||
s:add({ description = 'Home task', category = 'Home' })
|
store.add({ description = 'Home task', category = 'Home' })
|
||||||
local lines, meta = views.priority_view(s:active_tasks())
|
local lines, meta = views.priority_view(store.active_tasks())
|
||||||
local categories = {}
|
local categories = {}
|
||||||
for i, m in ipairs(meta) do
|
for i, m in ipairs(meta) do
|
||||||
if m.type == 'task' then
|
if m.type == 'task' then
|
||||||
|
|
@ -385,8 +362,8 @@ describe('views', function()
|
||||||
|
|
||||||
it('marks overdue pending tasks with meta.overdue=true', function()
|
it('marks overdue pending tasks with meta.overdue=true', function()
|
||||||
local yesterday = os.date('%Y-%m-%d', os.time() - 86400)
|
local yesterday = os.date('%Y-%m-%d', os.time() - 86400)
|
||||||
local t = s:add({ description = 'Overdue', category = 'Inbox', due = yesterday })
|
local t = store.add({ description = 'Overdue', category = 'Inbox', due = yesterday })
|
||||||
local _, meta = views.priority_view(s:active_tasks())
|
local _, meta = views.priority_view(store.active_tasks())
|
||||||
local task_meta
|
local task_meta
|
||||||
for _, m in ipairs(meta) do
|
for _, m in ipairs(meta) do
|
||||||
if m.type == 'task' and m.id == t.id then
|
if m.type == 'task' and m.id == t.id then
|
||||||
|
|
@ -398,8 +375,8 @@ describe('views', function()
|
||||||
|
|
||||||
it('does not mark future pending tasks as overdue', function()
|
it('does not mark future pending tasks as overdue', function()
|
||||||
local tomorrow = os.date('%Y-%m-%d', os.time() + 86400)
|
local tomorrow = os.date('%Y-%m-%d', os.time() + 86400)
|
||||||
local t = s:add({ description = 'Future', category = 'Inbox', due = tomorrow })
|
local t = store.add({ description = 'Future', category = 'Inbox', due = tomorrow })
|
||||||
local _, meta = views.priority_view(s:active_tasks())
|
local _, meta = views.priority_view(store.active_tasks())
|
||||||
local task_meta
|
local task_meta
|
||||||
for _, m in ipairs(meta) do
|
for _, m in ipairs(meta) do
|
||||||
if m.type == 'task' and m.id == t.id then
|
if m.type == 'task' and m.id == t.id then
|
||||||
|
|
@ -411,9 +388,9 @@ describe('views', function()
|
||||||
|
|
||||||
it('does not mark done tasks with overdue due dates as overdue', 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 yesterday = os.date('%Y-%m-%d', os.time() - 86400)
|
||||||
local t = s:add({ description = 'Done late', category = 'Inbox', due = yesterday })
|
local t = store.add({ description = 'Done late', category = 'Inbox', due = yesterday })
|
||||||
s:update(t.id, { status = 'done' })
|
store.update(t.id, { status = 'done' })
|
||||||
local _, meta = views.priority_view(s:active_tasks())
|
local _, meta = views.priority_view(store.active_tasks())
|
||||||
local task_meta
|
local task_meta
|
||||||
for _, m in ipairs(meta) do
|
for _, m in ipairs(meta) do
|
||||||
if m.type == 'task' and m.id == t.id then
|
if m.type == 'task' and m.id == t.id then
|
||||||
|
|
@ -422,29 +399,5 @@ describe('views', function()
|
||||||
end
|
end
|
||||||
assert.is_falsy(task_meta.overdue)
|
assert.is_falsy(task_meta.overdue)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('includes recur in LineMeta for recurring tasks', function()
|
|
||||||
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
|
|
||||||
task_meta = m
|
|
||||||
end
|
|
||||||
end
|
|
||||||
assert.are.equal('daily', task_meta.recur)
|
|
||||||
end)
|
|
||||||
|
|
||||||
it('has nil recur in LineMeta for non-recurring tasks', function()
|
|
||||||
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
|
|
||||||
task_meta = m
|
|
||||||
end
|
|
||||||
end
|
|
||||||
assert.is_nil(task_meta.recur)
|
|
||||||
end)
|
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue