Compare commits

..

No commits in common. "doc/minify" and "main" have entirely different histories.

44 changed files with 14600 additions and 1336 deletions

1
.gitignore vendored
View file

@ -1,5 +1,6 @@
doc/tags doc/tags
*.log *.log
minimal_init.lua
.*cache* .*cache*
CLAUDE.md CLAUDE.md

View file

@ -2,7 +2,14 @@
"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"],
"workspace.library": ["$VIMRUNTIME/lua", "${3rd}/luv/library"], "diagnostics.libraryFiles": "Disable",
"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"
} }

View file

@ -1,13 +1,35 @@
# pending.nvim # pending.nvim
Edit tasks like text. `:w` saves them. **Edit tasks like text.**
<!-- insert preview --> Oil-like task management for todos in Neovim, inspired by
[oil.nvim](https://github.com/stevearc/oil.nvim) and
[vim-fugitive](https://github.com/tpope/vim-fugitive)
https://github.com/user-attachments/assets/f3898ecb-ec95-43fe-a71f-9c9f49628ba9
## Features
- Oil-style buffer editing: standard Vim motions for all task operations
- Inline metadata: `due:`, `cat:`, `rec:` tokens parsed on `:w`
- Rich date input: relative (`+3d`, `tomorrow`), weekdays, ordinals, custom formats, time suffixes
- Recurring tasks with automatic next-date spawning on completion
- Category and queue views with foldable sections
- Multi-level undo (up to 20 saves, persisted across sessions)
- Text objects (`at`/`it`/`aC`/`iC`) and motions (`]]`/`[[`/`]t`/`[t`)
- Omnifunc completion for `due:`, `cat:`, and `rec:` tokens
- Filters: `cat:X`, `overdue`, `today`, `priority`, `wip`, `blocked`
- Google Calendar one-way push via OAuth PKCE
- Google Tasks bidirectional sync via OAuth PKCE
- S3 whole-store sync via AWS CLI with cross-device merge
- Auto-authentication: sync actions trigger auth flows automatically
- Forge links: reference GitHub/GitLab/Codeberg issues and PRs inline
## Requirements ## Requirements
- Neovim 0.10+ - Neovim 0.10+
- (Optionally) `curl` and `openssl` for Google Calendar and Google Task sync - (Optionally) `curl` for Google Calendar and Google Tasks sync
- (Optionally) `aws` CLI for S3 sync
## Installation ## Installation

File diff suppressed because it is too large Load diff

View file

@ -13,9 +13,12 @@
... ...
}: }:
let let
forEachSystem = f: nixpkgs.lib.genAttrs (import systems) (system: f nixpkgs.legacyPackages.${system}); forEachSystem =
f: nixpkgs.lib.genAttrs (import systems) (system: f nixpkgs.legacyPackages.${system});
in in
{ {
formatter = forEachSystem (pkgs: pkgs.nixfmt-tree);
devShells = forEachSystem (pkgs: { devShells = forEachSystem (pkgs: {
default = pkgs.mkShell { default = pkgs.mkShell {
packages = [ packages = [

File diff suppressed because it is too large Load diff

218
lua/pending/complete.lua Normal file
View file

@ -0,0 +1,218 @@
local config = require('pending.config')
local forge = require('pending.forge')
---@class pending.CompletionItem
---@field word string
---@field info string
---@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 pending.CompletionItem[]
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 pending.CompletionItem[]
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
---@param source string
---@return boolean
function M._is_forge_source(source)
for _, b in ipairs(forge.backends()) do
if b.shorthand == source then
return true
end
end
return false
end
---@type string?
local _complete_source = nil
---@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 ck = config.get().category_syntax or 'cat'
local checks = {
{ vim.pesc(dk) .. ':([%S]*)$', dk },
{ vim.pesc(ck) .. ':([%S]*)$', ck },
{ vim.pesc(rk) .. ':([%S]*)$', rk },
}
for _, b in ipairs(forge.backends()) do
table.insert(checks, { vim.pesc(b.shorthand) .. ':([%S]*)$', b.shorthand })
end
for _, check in ipairs(checks) do
local start = before:find(check[1])
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 == (config.get().category_syntax or 'cat') then
for _, c in ipairs(get_categories()) do
if base == '' or c:sub(1, #base) == base then
table.insert(matches, { word = c, menu = '[' .. source .. ']' })
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
elseif M._is_forge_source(source) then
local s = require('pending.buffer').store()
if s then
local seen = {}
for _, task in ipairs(s:tasks()) do
if task._extra and task._extra._forge_ref then
local ref = task._extra._forge_ref --[[@as pending.ForgeRef]]
local key = ref.owner .. '/' .. ref.repo
if not seen[key] then
seen[key] = true
local word_num = key .. '#'
if base == '' or word_num:sub(1, #base) == base then
table.insert(matches, { word = word_num, menu = '[' .. source .. ']' })
end
if base == '' or key:sub(1, #base) == base then
table.insert(
matches,
{ word = key, menu = '[' .. source .. ']', info = 'Bare repo link' }
)
end
end
end
end
end
end
return matches
end
return M

View file

@ -1,16 +1,119 @@
---@class pending.FoldingConfig
---@field foldtext? string|false
---@class pending.ResolvedFolding
---@field enabled boolean
---@field foldtext string|false
---@class pending.Icons
---@field pending string
---@field done string
---@field priority string
---@field wip string
---@field blocked string
---@field cancelled string
---@field due string
---@field recur string
---@field category string
---@class pending.GcalConfig ---@class pending.GcalConfig
---@field calendar? string ---@field remote_delete? boolean
---@field credentials_path? string ---@field credentials_path? string
---@field client_id? string
---@field client_secret? string
---@class pending.GtasksConfig
---@field remote_delete? boolean
---@field credentials_path? string
---@field client_id? string
---@field client_secret? string
---@class pending.S3Config
---@field bucket string
---@field key? string
---@field profile? string
---@field region? string
---@class pending.ForgeInstanceConfig
---@field icon? string
---@field issue_format? string
---@field instances? string[]
---@field shorthand? string
---@class pending.ForgeConfig
---@field close? boolean
---@field validate? boolean
---@field warn_missing_cli? boolean
---@field github? pending.ForgeInstanceConfig
---@field gitlab? pending.ForgeInstanceConfig
---@field codeberg? pending.ForgeInstanceConfig
---@field [string] pending.ForgeInstanceConfig
---@class pending.SyncConfig
---@field remote_delete? boolean
---@field gcal? pending.GcalConfig
---@field gtasks? pending.GtasksConfig
---@field s3? pending.S3Config
---@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
---@field category? string|false
---@field recur? string|false
---@field move_down? string|false
---@field move_up? string|false
---@field wip? string|false
---@field blocked? string|false
---@field priority_up_visual? string|false
---@field priority_down_visual? string|false
---@field cancelled? string|false
---@field edit_notes? string|false
---@class pending.CategoryViewConfig
---@field order? string[]
---@field folding? boolean|pending.FoldingConfig
---@class pending.QueueViewConfig
---@field sort? string[]
---@class pending.ViewConfig
---@field default? 'category'|'priority'
---@field eol_format? string
---@field category? pending.CategoryViewConfig
---@field queue? pending.QueueViewConfig
---@class pending.Config ---@class pending.Config
---@field data_path string ---@field data_path string
---@field default_view 'category'|'priority'
---@field default_category string ---@field default_category string
---@field date_format string ---@field date_format string
---@field category_syntax string
---@field date_syntax string ---@field date_syntax string
---@field category_order? string[] ---@field recur_syntax string
---@field someday_date string
---@field input_date_formats? string[]
---@field drawer_height? integer ---@field drawer_height? integer
---@field gcal? pending.GcalConfig ---@field debug? boolean
---@field keymaps pending.Keymaps
---@field view pending.ViewConfig
---@field max_priority? integer
---@field sync? pending.SyncConfig
---@field forge? pending.ForgeConfig
---@field icons pending.Icons
---@class pending.config ---@class pending.config
local M = {} local M = {}
@ -18,11 +121,87 @@ local M = {}
---@type pending.Config ---@type pending.Config
local defaults = { local defaults = {
data_path = vim.fn.stdpath('data') .. '/pending/tasks.json', data_path = vim.fn.stdpath('data') .. '/pending/tasks.json',
default_view = 'category',
default_category = 'Todo', default_category = 'Todo',
date_format = '%b %d', date_format = '%b %d',
category_syntax = 'cat',
date_syntax = 'due', date_syntax = 'due',
category_order = {}, recur_syntax = 'rec',
someday_date = '9999-12-30',
max_priority = 3,
view = {
default = 'category',
eol_format = '%c %r %d',
category = {
order = {},
folding = true,
},
queue = {
sort = { 'status', 'priority', 'due', 'order', 'id' },
},
},
keymaps = {
close = 'q',
toggle = '<CR>',
view = '<Tab>',
priority = 'g!',
date = 'gd',
undo = 'gz',
filter = 'gf',
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',
category = 'gc',
recur = 'gr',
move_down = 'J',
move_up = 'K',
wip = 'gw',
blocked = 'gb',
cancelled = 'g/',
edit_notes = 'ge',
priority_up = '<C-a>',
priority_down = '<C-x>',
priority_up_visual = 'g<C-a>',
priority_down_visual = 'g<C-x>',
},
sync = {},
forge = {
close = false,
validate = false,
warn_missing_cli = true,
github = {
icon = '',
issue_format = '%i %o/%r#%n',
instances = {},
},
gitlab = {
icon = '',
issue_format = '%i %o/%r#%n',
instances = {},
},
codeberg = {
icon = '',
issue_format = '%i %o/%r#%n',
instances = {},
},
},
icons = {
pending = ' ',
done = 'x',
priority = '!',
wip = 'w',
blocked = 'b',
cancelled = '/',
due = '.',
recur = '~',
category = '#',
},
} }
---@type pending.Config? ---@type pending.Config?
@ -38,8 +217,20 @@ function M.get()
return _resolved return _resolved
end end
---@return nil
function M.reset() function M.reset()
_resolved = nil _resolved = nil
end end
---@return pending.ResolvedFolding
function M.resolve_folding()
local raw = M.get().view.category.folding
if raw == false then
return { enabled = false, foldtext = false }
elseif raw == true or raw == nil then
return { enabled = true, foldtext = '%c (%n tasks)' }
end
return { enabled = true, foldtext = raw.foldtext or false }
end
return M return M

View file

@ -1,15 +1,18 @@
local config = require('pending.config') local config = require('pending.config')
local forge = require('pending.forge')
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'
---@field id? integer ---@field id? integer
---@field description? string ---@field description? string
---@field priority? integer ---@field priority? integer
---@field status? string ---@field status? pending.TaskStatus
---@field category? string ---@field category? string
---@field due? string ---@field due? string
---@field recur? string
---@field recur_mode? pending.RecurMode
---@field forge_ref? pending.ForgeRef
---@field lnum integer ---@field lnum integer
---@class pending.diff ---@class pending.diff
@ -25,34 +28,56 @@ 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, line in ipairs(lines) do for i = start, #lines do
local id, body = line:match('^/(%d+)/(- %[.%] .*)$') local line = lines[i]
local id, body = line:match('^/(%d+)/(- %[.?%] .*)$')
if not id then if not id then
body = line:match('^(- %[.%] .*)$') body = line:match('^(- %[.?%] .*)$')
end end
if line == '' then if line == '' then
table.insert(result, { type = 'blank', lnum = i }) table.insert(result, { type = 'blank', lnum = i })
elseif id or body then elseif id or body then
local stripped = body:match('^- %[.%] (.*)$') or body local stripped = body:match('^- %[.?%] (.*)$') or body
local state_char = body:match('^- %[(.-)%]') or ' ' local icons = config.get().icons
local priority = state_char == '!' and 1 or 0 local state_char = body:match('^- %[(.-)%]') or icons.pending
local status = state_char == 'x' and 'done' or 'pending' local priority = state_char == icons.priority and 1 or 0
local status
if state_char == icons.done then
status = 'done'
elseif state_char == icons.cancelled then
status = 'cancelled'
elseif state_char == icons.wip then
status = 'wip'
elseif state_char == icons.blocked then
status = 'blocked'
else
status = 'pending'
end
local description, metadata = parse.body(stripped) local description, metadata = parse.body(stripped)
if description and description ~= '' then if description and description ~= '' then
local refs = forge.find_refs(description)
local forge_ref = refs[1] and refs[1].ref or nil
table.insert(result, { table.insert(result, {
type = 'task', type = 'task',
id = id and tonumber(id) or nil, id = id and tonumber(id) or nil,
description = description, description = description,
priority = priority, priority = metadata.priority or priority,
status = status, status = status,
category = metadata.cat or current_category or config.get().default_category, category = metadata.category or current_category or config.get().default_category,
due = metadata.due, due = metadata.due,
recur = metadata.recur,
recur_mode = metadata.recur_mode,
forge_ref = forge_ref,
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
@ -60,11 +85,25 @@ function M.parse_buffer(lines)
return result return result
end end
---@param a? pending.ForgeRef
---@param b? pending.ForgeRef
---@return boolean
local function refs_equal(a, b)
if not a or not b then
return false
end
return a.forge == b.forge and a.owner == b.owner and a.repo == b.repo and a.number == b.number
end
---@param lines string[] ---@param lines string[]
function M.apply(lines) ---@param s pending.Store
---@param hidden_ids? table<integer, true>
---@return pending.ForgeRef[]
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 = store.data() local data = s:data()
local new_refs = {} ---@type pending.ForgeRef[]
local old_by_id = {} local old_by_id = {}
for _, task in ipairs(data.tasks) do for _, task in ipairs(data.tasks) do
@ -85,13 +124,19 @@ function M.apply(lines)
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
store.add({ s: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.recur,
recur_mode = entry.recur_mode,
order = order_counter, order = order_counter,
_extra = entry.forge_ref and { _forge_ref = entry.forge_ref } or nil,
}) })
if entry.forge_ref then
table.insert(new_refs, entry.forge_ref)
end
else else
seen_ids[entry.id] = true seen_ids[entry.id] = true
local task = old_by_id[entry.id] local task = old_by_id[entry.id]
@ -104,17 +149,38 @@ function M.apply(lines)
task.category = entry.category task.category = entry.category
changed = true changed = true
end end
if task.priority ~= entry.priority then if entry.priority ~= task.priority then
task.priority = entry.priority task.priority = entry.priority
changed = true changed = true
end end
if task.due ~= entry.due then if entry.due ~= nil and task.due ~= entry.due then
task.due = entry.due task.due = entry.due
changed = true changed = true
end end
if entry.recur ~= nil then
if task.recur ~= entry.recur then
task.recur = entry.recur
changed = true
end
if task.recur_mode ~= entry.recur_mode then
task.recur_mode = entry.recur_mode
changed = true
end
end
if entry.forge_ref ~= nil then
local old_ref = task._extra and task._extra._forge_ref or nil
if not refs_equal(old_ref, entry.forge_ref) then
table.insert(new_refs, entry.forge_ref)
end
if not task._extra then
task._extra = {}
end
task._extra._forge_ref = entry.forge_ref
changed = true
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' or entry.status == 'cancelled' then
task['end'] = now task['end'] = now
else else
task['end'] = nil task['end'] = nil
@ -130,27 +196,34 @@ function M.apply(lines)
end end
end end
else else
store.add({ s: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.recur,
recur_mode = entry.recur_mode,
order = order_counter, order = order_counter,
_extra = entry.forge_ref and { _forge_ref = entry.forge_ref } or nil,
}) })
if entry.forge_ref then
table.insert(new_refs, entry.forge_ref)
end
end end
::continue:: ::continue::
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] then if not seen_ids[id] and not (hidden_ids and hidden_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
store.save() s:save()
return new_refs
end end
return M return M

713
lua/pending/forge.lua Normal file
View file

@ -0,0 +1,713 @@
local config = require('pending.config')
local log = require('pending.log')
---@alias pending.ForgeType 'issue'|'pull_request'|'merge_request'|'repo'
---@alias pending.ForgeState 'open'|'closed'|'merged'
---@alias pending.ForgeAuthStatus 'unknown'|'ok'|'failed'
---@class pending.ForgeRef
---@field forge string
---@field owner string
---@field repo string
---@field type pending.ForgeType
---@field number? integer
---@field url string
---@class pending.ForgeCache
---@field title? string
---@field state pending.ForgeState
---@field labels? string[]
---@field fetched_at string
---@class pending.ForgeFetchError
---@field kind 'not_found'|'auth'|'network'
---@class pending.ForgeBackend
---@field name string
---@field shorthand string
---@field default_host string
---@field cli string
---@field auth_cmd string
---@field auth_status_args string[]
---@field default_icon string
---@field default_issue_format string
---@field _auth? pending.ForgeAuthStatus
---@field parse_url fun(self: pending.ForgeBackend, url: string): pending.ForgeRef?
---@field api_args fun(self: pending.ForgeBackend, ref: pending.ForgeRef): string[]
---@field parse_state fun(self: pending.ForgeBackend, decoded: table): pending.ForgeState
---@class pending.forge
local M = {}
---@type pending.ForgeBackend[]
local _backends = {}
---@type table<string, pending.ForgeBackend>
local _by_name = {}
---@type table<string, pending.ForgeBackend>
local _by_shorthand = {}
---@type table<string, pending.ForgeBackend>
local _by_host = {}
---@type boolean
local _instances_resolved = false
---@param backend pending.ForgeBackend
---@return nil
function M.register(backend)
backend._auth = 'unknown'
table.insert(_backends, backend)
_by_name[backend.name] = backend
_by_shorthand[backend.shorthand] = backend
_by_host[backend.default_host] = backend
_instances_resolved = false
end
---@return pending.ForgeBackend[]
function M.backends()
return _backends
end
---@param forge_name string
---@return boolean
function M.is_configured(forge_name)
local raw = vim.g.pending
if not raw or not raw.forge then
return false
end
return raw.forge[forge_name] ~= nil
end
---@param backend pending.ForgeBackend
---@param callback fun(ok: boolean)
function M.check_auth(backend, callback)
if backend._auth == 'ok' then
callback(true)
return
end
if backend._auth == 'failed' then
callback(false)
return
end
if vim.fn.executable(backend.cli) == 0 then
backend._auth = 'failed'
local forge_cfg = config.get().forge or {}
if forge_cfg.warn_missing_cli ~= false then
log.warn(('%s not found — run `%s`'):format(backend.cli, backend.auth_cmd))
end
callback(false)
return
end
vim.system(backend.auth_status_args, { text = true }, function(result)
vim.schedule(function()
if result.code == 0 then
backend._auth = 'ok'
callback(true)
else
backend._auth = 'failed'
local forge_cfg = config.get().forge or {}
if forge_cfg.warn_missing_cli ~= false then
log.warn(('%s not authenticated — run `%s`'):format(backend.cli, backend.auth_cmd))
end
callback(false)
end
end)
end)
end
function M._reset_instances()
_instances_resolved = false
_by_shorthand = {}
for _, b in ipairs(_backends) do
_by_shorthand[b.shorthand] = b
end
end
local function _ensure_instances()
if _instances_resolved then
return
end
_instances_resolved = true
local cfg = config.get().forge or {}
for _, backend in ipairs(_backends) do
local forge_cfg = cfg[backend.name] or {}
for _, inst in ipairs(forge_cfg.instances or {}) do
_by_host[inst] = backend
end
if forge_cfg.shorthand and forge_cfg.shorthand ~= backend.shorthand then
_by_shorthand[backend.shorthand] = nil
backend.shorthand = forge_cfg.shorthand
_by_shorthand[backend.shorthand] = backend
end
end
end
---@param token string
---@return pending.ForgeRef?
function M._parse_shorthand(token)
_ensure_instances()
local backend, rest
for prefix, b in pairs(_by_shorthand) do
local candidate = token:match('^' .. vim.pesc(prefix) .. ':(.+)$')
if candidate then
backend = b
rest = candidate
break
end
end
if not backend then
return nil
end
local owner, repo, number = rest:match('^([%w%.%-_]+)/([%w%.%-_]+)#(%d+)$')
if owner then
local num = tonumber(number) --[[@as integer]]
local url = 'https://'
.. backend.default_host
.. '/'
.. owner
.. '/'
.. repo
.. '/issues/'
.. num
return {
forge = backend.name,
owner = owner,
repo = repo,
type = 'issue',
number = num,
url = url,
}
end
owner, repo = rest:match('^([%w%.%-_]+)/([%w%.%-_]+)$')
if not owner then
return nil
end
return {
forge = backend.name,
owner = owner,
repo = repo,
type = 'repo',
url = 'https://' .. backend.default_host .. '/' .. owner .. '/' .. repo,
}
end
---@param url string
---@return pending.ForgeRef?
function M._parse_github_url(url)
local backend = _by_name['github']
if not backend then
return nil
end
return backend:parse_url(url)
end
---@param url string
---@return pending.ForgeRef?
function M._parse_gitlab_url(url)
local backend = _by_name['gitlab']
if not backend then
return nil
end
return backend:parse_url(url)
end
---@param url string
---@return pending.ForgeRef?
function M._parse_codeberg_url(url)
local backend = _by_name['codeberg']
if not backend then
return nil
end
return backend:parse_url(url)
end
---@param token string
---@return pending.ForgeRef?
function M.parse_ref(token)
local short = M._parse_shorthand(token)
if short then
return short
end
if not token:match('^https?://') then
return nil
end
_ensure_instances()
local host = token:match('^https?://([^/]+)')
if not host then
return nil
end
local backend = _by_host[host]
if not backend then
return nil
end
return backend:parse_url(token)
end
---@class pending.ForgeSpan
---@field ref pending.ForgeRef
---@field start_byte integer
---@field end_byte integer
---@field raw string
---@param text string
---@return pending.ForgeSpan[]
function M.find_refs(text)
local results = {}
local pos = 1
while pos <= #text do
local ws = text:find('%S', pos)
if not ws then
break
end
local token_end = text:find('%s', ws)
local token = token_end and text:sub(ws, token_end - 1) or text:sub(ws)
local ref = M.parse_ref(token)
if ref then
local eb = token_end and (token_end - 1) or #text
table.insert(results, {
ref = ref,
start_byte = ws - 1,
end_byte = eb,
raw = token,
})
end
pos = token_end and token_end or (#text + 1)
end
return results
end
---@param ref pending.ForgeRef
---@return string[]
function M._api_args(ref)
local backend = _by_name[ref.forge]
if not backend or not ref.number then
return {}
end
return backend:api_args(ref)
end
---@param ref pending.ForgeRef
---@param cache? pending.ForgeCache
---@return string text
---@return string hl_group
function M.format_label(ref, cache)
local cfg = config.get().forge or {}
local forge_cfg = cfg[ref.forge] or {}
local backend = _by_name[ref.forge]
local default_icon = backend and backend.default_icon or ''
local default_fmt = backend and backend.default_issue_format or '%i %o/%r#%n'
local fmt = forge_cfg.issue_format or default_fmt
if ref.type == 'repo' then
fmt = fmt:gsub('#?%%n', ''):gsub('%s+$', '')
end
local icon = forge_cfg.icon or default_icon
local text = fmt
:gsub('%%i', icon)
:gsub('%%o', ref.owner)
:gsub('%%r', ref.repo)
:gsub('%%n', ref.number and tostring(ref.number) or '')
local hl = 'PendingForge'
if cache then
if cache.state == 'closed' or cache.state == 'merged' then
hl = 'PendingForgeClosed'
end
end
return text, hl
end
---@param ref pending.ForgeRef
---@param callback fun(cache: pending.ForgeCache?, err: pending.ForgeFetchError?)
function M.fetch_metadata(ref, callback)
if ref.type == 'repo' then
callback(nil)
return
end
local args = M._api_args(ref)
vim.system(args, { text = true }, function(result)
if result.code ~= 0 or not result.stdout or result.stdout == '' then
local kind = 'network'
local stderr = result.stderr or ''
if stderr:find('404') or stderr:find('Not Found') then
kind = 'not_found'
elseif stderr:find('401') or stderr:find('403') or stderr:find('auth') then
kind = 'auth'
end
vim.schedule(function()
callback(nil, { kind = kind })
end)
return
end
local ok, decoded = pcall(vim.json.decode, result.stdout)
if not ok or not decoded then
vim.schedule(function()
callback(nil, { kind = 'network' })
end)
return
end
local backend = _by_name[ref.forge]
local state = backend and backend:parse_state(decoded) or 'open'
local labels = {}
if decoded.labels then
for _, label in ipairs(decoded.labels) do
if type(label) == 'string' then
table.insert(labels, label)
elseif type(label) == 'table' and label.name then
table.insert(labels, label.name)
end
end
end
local cache = {
title = decoded.title,
state = state,
labels = labels,
fetched_at = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]],
}
vim.schedule(function()
callback(cache)
end)
end)
end
---@param s pending.Store
function M.refresh(s)
local forge_cfg = config.get().forge or {}
if not forge_cfg.close then
return
end
local tasks = s:tasks()
local by_forge = {} ---@type table<string, pending.Task[]>
for _, task in ipairs(tasks) do
if
task.status ~= 'deleted'
and task._extra
and task._extra._forge_ref
and task._extra._forge_ref.type ~= 'repo'
then
local fname = task._extra._forge_ref.forge
if not by_forge[fname] then
by_forge[fname] = {}
end
table.insert(by_forge[fname], task)
end
end
local any_work = false
for fname, forge_tasks in pairs(by_forge) do
if M.is_configured(fname) and _by_name[fname] then
any_work = true
M.check_auth(_by_name[fname], function(authed)
if not authed then
return
end
local remaining = #forge_tasks
local any_changed = false
local any_fetched = false
for _, task in ipairs(forge_tasks) do
local ref = task._extra._forge_ref --[[@as pending.ForgeRef]]
M.fetch_metadata(ref, function(cache)
remaining = remaining - 1
if cache then
task._extra._forge_cache = cache
any_fetched = true
if
(cache.state == 'closed' or cache.state == 'merged')
and (task.status == 'pending' or task.status == 'wip' or task.status == 'blocked')
then
task.status = 'done'
task['end'] = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]]
task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]]
any_changed = true
end
else
task._extra._forge_cache = {
state = 'open',
fetched_at = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]],
}
end
if remaining == 0 then
if any_changed then
s:save()
end
local buffer = require('pending.buffer')
if
(any_changed or any_fetched)
and buffer.bufnr()
and vim.api.nvim_buf_is_valid(buffer.bufnr())
then
buffer.render()
end
end
end)
end
end)
end
end
if not any_work then
log.info('No linked tasks to refresh.')
end
end
---@param refs pending.ForgeRef[]
function M.validate_refs(refs)
local by_forge = {} ---@type table<string, pending.ForgeRef[]>
for _, ref in ipairs(refs) do
if ref.type == 'repo' then
goto skip_ref
end
local fname = ref.forge
if not by_forge[fname] then
by_forge[fname] = {}
end
table.insert(by_forge[fname], ref)
::skip_ref::
end
for fname, forge_refs in pairs(by_forge) do
if not M.is_configured(fname) or not _by_name[fname] then
goto continue
end
M.check_auth(_by_name[fname], function(authed)
if not authed then
return
end
for _, ref in ipairs(forge_refs) do
M.fetch_metadata(ref, function(_, err)
if err and err.kind == 'not_found' then
log.warn(('%s:%s/%s#%d not found'):format(ref.forge, ref.owner, ref.repo, ref.number))
end
end)
end
end)
::continue::
end
end
---@param opts {name: string, shorthand: string, default_host: string, cli?: string, auth_cmd?: string, auth_status_args?: string[], default_icon?: string, default_issue_format?: string}
---@return pending.ForgeBackend
function M.gitea_forge(opts)
return {
name = opts.name,
shorthand = opts.shorthand,
default_host = opts.default_host,
cli = opts.cli or 'tea',
auth_cmd = opts.auth_cmd or 'tea login add',
auth_status_args = opts.auth_status_args or { opts.cli or 'tea', 'login', 'list' },
default_icon = opts.default_icon or '',
default_issue_format = opts.default_issue_format or '%i %o/%r#%n',
parse_url = function(self, url)
_ensure_instances()
local host, owner, repo, kind, number =
url:match('^https?://([^/]+)/([%w%.%-_]+)/([%w%.%-_]+)/(%a+)/(%d+)$')
if host and (kind == 'issues' or kind == 'pulls') and _by_host[host] == self then
local num = tonumber(number) --[[@as integer]]
local ref_type = kind == 'pulls' and 'pull_request' or 'issue'
return {
forge = self.name,
owner = owner,
repo = repo,
type = ref_type,
number = num,
url = url,
}
end
host, owner, repo = url:match('^https?://([^/]+)/([%w%.%-_]+)/([%w%.%-_]+)/?$')
if host and _by_host[host] == self then
return {
forge = self.name,
owner = owner,
repo = repo,
type = 'repo',
url = url,
}
end
return nil
end,
api_args = function(self, ref)
local endpoint = ref.type == 'pull_request' and 'pulls' or 'issues'
return {
self.cli,
'api',
'/repos/' .. ref.owner .. '/' .. ref.repo .. '/' .. endpoint .. '/' .. ref.number,
}
end,
parse_state = function(_, decoded)
if decoded.state == 'closed' then
return 'closed'
end
return 'open'
end,
}
end
M.register({
name = 'github',
shorthand = 'gh',
default_host = 'github.com',
cli = 'gh',
auth_cmd = 'gh auth login',
auth_status_args = { 'gh', 'auth', 'status' },
default_icon = '',
default_issue_format = '%i %o/%r#%n',
parse_url = function(self, url)
_ensure_instances()
local host, owner, repo, kind, number =
url:match('^https?://([^/]+)/([%w%.%-_]+)/([%w%.%-_]+)/(%a+)/(%d+)$')
if host and (kind == 'issues' or kind == 'pull') and _by_host[host] == self then
local num = tonumber(number) --[[@as integer]]
local ref_type = kind == 'pull' and 'pull_request' or 'issue'
return {
forge = 'github',
owner = owner,
repo = repo,
type = ref_type,
number = num,
url = url,
}
end
host, owner, repo = url:match('^https?://([^/]+)/([%w%.%-_]+)/([%w%.%-_]+)/?$')
if host and _by_host[host] == self then
return {
forge = 'github',
owner = owner,
repo = repo,
type = 'repo',
url = url,
}
end
return nil
end,
api_args = function(_, ref)
return {
'gh',
'api',
'/repos/' .. ref.owner .. '/' .. ref.repo .. '/issues/' .. ref.number,
}
end,
parse_state = function(_, decoded)
if decoded.pull_request and decoded.pull_request.merged_at then
return 'merged'
elseif decoded.state == 'closed' then
return 'closed'
end
return 'open'
end,
})
M.register({
name = 'gitlab',
shorthand = 'gl',
default_host = 'gitlab.com',
cli = 'glab',
auth_cmd = 'glab auth login',
auth_status_args = { 'glab', 'auth', 'status' },
default_icon = '',
default_issue_format = '%i %o/%r#%n',
parse_url = function(self, url)
_ensure_instances()
local host, path, kind, number = url:match('^https?://([^/]+)/(.+)/%-/([%w_]+)/(%d+)$')
if host and (kind == 'issues' or kind == 'merge_requests') and _by_host[host] == self then
local owner, repo = path:match('^(.+)/([^/]+)$')
if owner then
local num = tonumber(number) --[[@as integer]]
local ref_type = kind == 'merge_requests' and 'merge_request' or 'issue'
return {
forge = 'gitlab',
owner = owner,
repo = repo,
type = ref_type,
number = num,
url = url,
}
end
end
host, path = url:match('^https?://([^/]+)/(.+)$')
if host and _by_host[host] == self then
local trimmed = path:gsub('/$', '')
if not trimmed:find('/%-/') then
local owner, repo = trimmed:match('^(.+)/([^/]+)$')
if owner then
return {
forge = 'gitlab',
owner = owner,
repo = repo,
type = 'repo',
url = url,
}
end
end
end
return nil
end,
api_args = function(_, ref)
local encoded = (ref.owner .. '/' .. ref.repo):gsub('/', '%%2F')
local endpoint = ref.type == 'merge_request' and 'merge_requests' or 'issues'
return {
'glab',
'api',
'/projects/' .. encoded .. '/' .. endpoint .. '/' .. ref.number,
}
end,
parse_state = function(_, decoded)
if decoded.state == 'merged' then
return 'merged'
elseif decoded.state == 'closed' then
return 'closed'
end
return 'open'
end,
})
M.register({
name = 'codeberg',
shorthand = 'cb',
default_host = 'codeberg.org',
cli = 'tea',
auth_cmd = 'tea login add',
auth_status_args = { 'tea', 'login', 'list' },
default_icon = '',
default_issue_format = '%i %o/%r#%n',
parse_url = function(self, url)
_ensure_instances()
local host, owner, repo, kind, number =
url:match('^https?://([^/]+)/([%w%.%-_]+)/([%w%.%-_]+)/(%a+)/(%d+)$')
if host and (kind == 'issues' or kind == 'pulls') and _by_host[host] == self then
local num = tonumber(number) --[[@as integer]]
local ref_type = kind == 'pulls' and 'pull_request' or 'issue'
return {
forge = 'codeberg',
owner = owner,
repo = repo,
type = ref_type,
number = num,
url = url,
}
end
host, owner, repo = url:match('^https?://([^/]+)/([%w%.%-_]+)/([%w%.%-_]+)/?$')
if host and _by_host[host] == self then
return {
forge = 'codeberg',
owner = owner,
repo = repo,
type = 'repo',
url = url,
}
end
return nil
end,
api_args = function(_, ref)
local endpoint = ref.type == 'pull_request' and 'pulls' or 'issues'
return {
'tea',
'api',
'/repos/' .. ref.owner .. '/' .. ref.repo .. '/' .. endpoint .. '/' .. ref.number,
}
end,
parse_state = function(_, decoded)
if decoded.state == 'closed' then
return 'closed'
end
return 'open'
end,
})
return M

View file

@ -1,5 +1,6 @@
local M = {} local M = {}
---@return nil
function M.check() function M.check()
vim.health.start('pending.nvim') vim.health.start('pending.nvim')
@ -9,42 +10,72 @@ function M.check()
return return
end end
local cfg = config.get() config.get()
vim.health.ok('Config loaded') vim.health.ok('Config loaded')
vim.health.info('Data path: ' .. cfg.data_path)
local data_dir = vim.fn.fnamemodify(cfg.data_path, ':h') local store_ok, store = pcall(require, 'pending.store')
if vim.fn.isdirectory(data_dir) == 1 then if not store_ok then
vim.health.ok('Data directory exists: ' .. data_dir) vim.health.error('Failed to load pending.store')
else return
vim.health.warn('Data directory does not exist yet: ' .. data_dir)
end end
if vim.fn.filereadable(cfg.data_path) == 1 then local resolved_path = store.resolve_path()
local store_ok, store = pcall(require, 'pending.store') vim.health.info('Store path: ' .. resolved_path)
if store_ok then
local load_ok, err = pcall(store.load) 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 if load_ok then
local tasks = store.tasks() local tasks = s:tasks()
vim.health.ok('Data file loaded: ' .. #tasks .. ' 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 else
vim.health.error('Failed to load data file: ' .. tostring(err)) vim.health.error('Failed to load data file: ' .. tostring(err))
end end
end end
vim.health.start('pending.nvim: forge')
local forge = require('pending.forge')
for _, backend in ipairs(forge.backends()) do
if not forge.is_configured(backend.name) then
vim.health.info(('%s: not configured (skipped)'):format(backend.name))
elseif vim.fn.executable(backend.cli) == 1 then
vim.health.ok(('%s found'):format(backend.cli))
else else
vim.health.info('No data file yet (will be created on first save)') vim.health.warn(('%s not found — run `%s`'):format(backend.cli, backend.auth_cmd))
end
end end
if vim.fn.executable('curl') == 1 then local sync_paths = vim.fn.globpath(vim.o.runtimepath, 'lua/pending/sync/*.lua', false, true)
vim.health.ok('curl found (required for Google Calendar sync)') if #sync_paths == 0 and vim.tbl_isempty(require('pending').registered_backends()) then
vim.health.info('No sync backends found')
else else
vim.health.warn('curl not found (needed for Google Calendar sync)') for _, path in ipairs(sync_paths) do
local name = vim.fn.fnamemodify(path, ':t:r')
local bok, backend = pcall(require, 'pending.sync.' .. name)
if bok and backend.name and type(backend.health) == 'function' then
vim.health.start('pending.nvim: sync/' .. name)
backend.health()
end
end
for rname, rbackend in pairs(require('pending').registered_backends()) do
if type(rbackend.health) == 'function' then
vim.health.start('pending.nvim: sync/' .. rname)
rbackend.health()
end
end end
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

File diff suppressed because it is too large Load diff

30
lua/pending/log.lua Normal file
View file

@ -0,0 +1,30 @@
---@class pending.log
local M = {}
local PREFIX = '[pending.nvim]: '
---@param msg string
function M.info(msg)
vim.notify(PREFIX .. msg)
end
---@param msg string
function M.warn(msg)
vim.notify(PREFIX .. msg, vim.log.levels.WARN)
end
---@param msg string
function M.error(msg)
vim.notify(PREFIX .. msg, vim.log.levels.ERROR)
end
---@param msg string
---@param override? boolean
function M.debug(msg, override)
local cfg = require('pending.config').get()
if cfg.debug or override then
vim.notify(PREFIX .. msg, vim.log.levels.DEBUG)
end
end
return M

View file

@ -1,4 +1,13 @@
local config = require('pending.config') local config = require('pending.config')
local forge = require('pending.forge')
local log = require('pending.log')
---@class pending.Metadata
---@field due? string
---@field category? string
---@field recur? string
---@field recur_mode? pending.RecurMode
---@field priority? integer
---@class pending.parse ---@class pending.parse
local M = {} local M = {}
@ -24,11 +33,97 @@ 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
local function category_key()
return config.get().category_syntax or 'cat'
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,
@ -39,26 +134,259 @@ local weekday_map = {
sat = 7, sat = 7,
} }
---@param text string local month_map = {
---@return string|nil jan = 1,
function M.resolve_date(text) feb = 2,
local lower = text:lower() mar = 3,
local today = os.date('*t') --[[@as osdate]] apr = 4,
may = 5,
jun = 6,
jul = 7,
aug = 8,
sep = 9,
oct = 10,
nov = 11,
dec = 12,
}
if lower == 'today' then ---@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]] return os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day })) --[[@as string]]
end 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 name string
---@return integer?
local function month_name_to_num(name)
return month_map[name:lower():sub(1, 3)]
end
---@param fmt string
---@return string, string[]
local function input_format_to_pattern(fmt)
local fields = {}
local parts = {}
local i = 1
while i <= #fmt do
local c = fmt:sub(i, i)
if c == '%' and i < #fmt then
local spec = fmt:sub(i + 1, i + 1)
if spec == '%' then
parts[#parts + 1] = '%%'
i = i + 2
elseif spec == 'Y' then
fields[#fields + 1] = 'year'
parts[#parts + 1] = '(%d%d%d%d)'
i = i + 2
elseif spec == 'y' then
fields[#fields + 1] = 'year2'
parts[#parts + 1] = '(%d%d)'
i = i + 2
elseif spec == 'm' then
fields[#fields + 1] = 'month_num'
parts[#parts + 1] = '(%d%d?)'
i = i + 2
elseif spec == 'd' or spec == 'e' then
fields[#fields + 1] = 'day'
parts[#parts + 1] = '(%d%d?)'
i = i + 2
elseif spec == 'b' or spec == 'B' then
fields[#fields + 1] = 'month_name'
parts[#parts + 1] = '(%a+)'
i = i + 2
else
parts[#parts + 1] = vim.pesc(c)
i = i + 1
end
else
parts[#parts + 1] = vim.pesc(c)
i = i + 1
end
end
return '^' .. table.concat(parts) .. '$', fields
end
---@param date_input string
---@param time_suffix? string
---@return string?
local function try_input_date_formats(date_input, time_suffix)
local fmts = config.get().input_date_formats
if not fmts or #fmts == 0 then
return nil
end
local today = os.date('*t') --[[@as osdate]]
for _, fmt in ipairs(fmts) do
local pat, fields = input_format_to_pattern(fmt)
local caps = { date_input:match(pat) }
if caps[1] ~= nil then
local year, month, day
for j = 1, #fields do
local field = fields[j]
local val = caps[j]
if field == 'year' then
year = tonumber(val)
elseif field == 'year2' then
local y = tonumber(val) --[[@as integer]]
year = y + (y >= 70 and 1900 or 2000)
elseif field == 'month_num' then
month = tonumber(val)
elseif field == 'day' then
day = tonumber(val)
elseif field == 'month_name' then
month = month_name_to_num(val)
end
end
if month and day then
if not year then
year = today.year
if month < today.month or (month == today.month and day < today.day) then
year = year + 1
end
end
local t = os.time({ year = year, month = month, day = day })
local check = os.date('*t', t) --[[@as osdate]]
if check.year == year and check.month == month and check.day == day then
return append_time(os.date('%Y-%m-%d', t) --[[@as string]], time_suffix)
end
end
end
end
return nil
end
---@param text string
---@return string|nil
function M.resolve_date(text)
local date_input, time_suffix = text:match('^(.+)@(.+)$')
if time_suffix then
time_suffix = normalize_time(time_suffix)
if not time_suffix then
return nil
end
else
date_input = text
end
local dt = date_input:match('^(%d%d%d%d%-%d%d%-%d%dT%d%d:%d%d)$')
if dt then
local dp, tp = dt:match('^(.+)T(.+)$')
if is_valid_date(dp) and is_valid_time(tp) then
return dt
end
return nil
end
if is_valid_date(date_input) then
return append_time(date_input, time_suffix)
end
local lower = date_input:lower()
local today = os.date('*t') --[[@as osdate]]
if lower == 'today' or lower == 'eod' then
return append_time(today_str(today), time_suffix)
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
if lower == 'tomorrow' then if lower == 'tomorrow' then
return os.date( return append_time(
os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day + 1 })) --[[@as string]],
time_suffix
)
end
if lower == 'sow' then
local delta = -((today.wday - 2) % 7)
return append_time(
os.date(
'%Y-%m-%d', '%Y-%m-%d',
os.time({ year = today.year, month = today.month, day = today.day + 1 }) os.time({ year = today.year, month = today.month, day = today.day + delta })
) --[[@as string]] ) --[[@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 os.date( return append_time(
os.date(
'%Y-%m-%d', '%Y-%m-%d',
os.time({ os.time({
year = today.year, year = today.year,
@ -67,25 +395,141 @@ function M.resolve_date(text)
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 os.date( return append_time(
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 try_input_date_formats(date_input, time_suffix)
end end
---@param text string ---@param text string
---@return string description ---@return string description
---@return { due?: string, cat?: string } metadata ---@return pending.Metadata 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
@ -93,53 +537,99 @@ function M.body(text)
end end
local metadata = {} local metadata = {}
local i = #tokens local ck = category_key()
local dk = date_key() local dk = date_key()
local date_pattern_strict = '^' .. vim.pesc(dk) .. ':(%d%d%d%d%-%d%d%-%d%d)$' local rk = recur_key()
local cat_pattern = '^' .. vim.pesc(ck) .. ':(%S+)$'
local date_pattern_strict = '^' .. vim.pesc(dk) .. ':(%d%d%d%d%-%d%d%-%d%d[T%d:]*)$'
local date_pattern_any = '^' .. vim.pesc(dk) .. ':(.+)$' local date_pattern_any = '^' .. vim.pesc(dk) .. ':(.+)$'
local rec_pattern = '^' .. vim.pesc(rk) .. ':(%S+)$'
local desc_tokens = {}
local forge_tokens = {}
for _, token in ipairs(tokens) do
local consumed = false
while i >= 1 do
local token = tokens[i]
local due_val = token:match(date_pattern_strict) local due_val = token:match(date_pattern_strict)
if due_val then if due_val and is_valid_datetime(due_val) then
if metadata.due then if not metadata.due then
break
end
if not is_valid_date(due_val) then
break
end
metadata.due = due_val metadata.due = due_val
i = i - 1
else else
log.warn('duplicate ' .. dk .. ': token ignored: ' .. token)
end
consumed = true
end
if not consumed then
local raw_val = token:match(date_pattern_any) local raw_val = token:match(date_pattern_any)
if raw_val then if raw_val then
if metadata.due then
break
end
local resolved = M.resolve_date(raw_val) local resolved = M.resolve_date(raw_val)
if not resolved then if resolved then
break if not metadata.due then
end
metadata.due = resolved metadata.due = resolved
i = i - 1
else else
local cat_val = token:match('^cat:(%S+)$') log.warn('duplicate ' .. dk .. ': token ignored: ' .. token)
if cat_val then
if metadata.cat then
break
end
metadata.cat = cat_val
i = i - 1
else
break
end end
consumed = true
end end
end end
end end
local desc_tokens = {} if not consumed then
for j = 1, i do local cat_val = token:match(cat_pattern)
table.insert(desc_tokens, tokens[j]) if cat_val then
if not metadata.category then
metadata.category = cat_val
else
log.warn('duplicate ' .. ck .. ': token ignored: ' .. token)
end
consumed = true
end
end
if not consumed then
local pri_bangs = token:match('^%+(!+)$')
if pri_bangs then
if not metadata.priority then
local max = config.get().max_priority or 3
metadata.priority = math.min(#pri_bangs, max)
else
log.warn('duplicate priority token ignored: ' .. token)
end
consumed = true
end
end
if not consumed then
local rec_val = token:match(rec_pattern)
if rec_val then
local recur = require('pending.recur')
local raw_spec = rec_val
if raw_spec:sub(1, 1) == '!' then
raw_spec = raw_spec:sub(2)
end
if recur.validate(raw_spec) then
if not metadata.recur then
metadata.recur_mode = rec_val:sub(1, 1) == '!' and 'completion' or nil
metadata.recur = raw_spec
else
log.warn('duplicate ' .. rk .. ': token ignored: ' .. token)
end
consumed = true
end
end
end
if not consumed then
if forge.parse_ref(token) then
table.insert(forge_tokens, token)
else
table.insert(desc_tokens, token)
end
end
end
for _, ft in ipairs(forge_tokens) do
table.insert(desc_tokens, ft)
end end
local description = table.concat(desc_tokens, ' ') local description = table.concat(desc_tokens, ' ')
@ -148,7 +638,7 @@ end
---@param text string ---@param text string
---@return string description ---@return string description
---@return { due?: string, cat?: string } metadata ---@return pending.Metadata 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
@ -157,7 +647,7 @@ function M.command_add(text)
local rest = text:sub(#cat_prefix + 2):match('^%s*(.+)$') local rest = text:sub(#cat_prefix + 2):match('^%s*(.+)$')
if rest then if rest then
local desc, meta = M.body(rest) local desc, meta = M.body(rest)
meta.cat = meta.cat or cat_prefix meta.category = meta.category or cat_prefix
return desc, meta return desc, meta
end end
end end
@ -165,4 +655,66 @@ 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
---@param s? string
---@return integer?
function M.parse_duration_to_days(s)
if s == nil or s == '' then
return nil
end
local n = s:match('^(%d+)d$')
if n then
return tonumber(n) --[[@as integer]]
end
n = s:match('^(%d+)w$')
if n then
return tonumber(n) --[[@as integer]]
* 7
end
n = s:match('^(%d+)m$')
if n then
return tonumber(n) --[[@as integer]]
* 30
end
n = s:match('^(%d+)$')
if n then
return tonumber(n) --[[@as integer]]
end
return nil
end
return M return M

188
lua/pending/recur.lua Normal file
View file

@ -0,0 +1,188 @@
---@class pending.RecurSpec
---@field freq 'daily'|'weekly'|'monthly'|'yearly'
---@field interval integer
---@field byday? string[]
---@field mode pending.RecurMode
---@field _raw? string
---@class pending.recur
local M = {}
---@type table<string, pending.RecurSpec>
local named = {
daily = { freq = 'daily', interval = 1, mode = 'scheduled' },
weekdays = {
freq = 'weekly',
interval = 1,
byday = { 'MO', 'TU', 'WE', 'TH', 'FR' },
mode = 'scheduled',
},
weekly = { freq = 'weekly', interval = 1, mode = 'scheduled' },
biweekly = { freq = 'weekly', interval = 2, mode = 'scheduled' },
monthly = { freq = 'monthly', interval = 1, mode = 'scheduled' },
quarterly = { freq = 'monthly', interval = 3, mode = 'scheduled' },
yearly = { freq = 'yearly', interval = 1, mode = 'scheduled' },
annual = { freq = 'yearly', interval = 1, mode = 'scheduled' },
}
---@param spec string
---@return pending.RecurSpec?
function M.parse(spec)
local mode = 'scheduled' ---@type pending.RecurMode
local s = spec
if s:sub(1, 1) == '!' then
mode = 'completion'
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,
mode = mode,
}
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,
mode = mode,
}
end
if s:match('^FREQ=') then
return {
freq = 'daily',
interval = 1,
mode = mode,
_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 pending.RecurMode
---@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

View file

@ -1,37 +1,70 @@
local config = require('pending.config') local config = require('pending.config')
---@alias pending.TaskStatus 'pending'|'done'|'deleted'|'wip'|'blocked'|'cancelled'
---@alias pending.RecurMode 'scheduled'|'completion'
---@class pending.TaskExtra
---@field _forge_ref? pending.ForgeRef
---@field _forge_cache? pending.ForgeCache
---@field _gtasks_task_id? string
---@field _gtasks_list_id? string
---@field _gcal_event_id? string
---@field _gcal_calendar_id? string
---@field [string] any
---@class pending.Task ---@class pending.Task
---@field id integer ---@field id integer
---@field description string ---@field description string
---@field status 'pending'|'done'|'deleted' ---@field status pending.TaskStatus
---@field category? string ---@field category? string
---@field priority integer ---@field priority integer
---@field due? string ---@field due? string
---@field recur? string
---@field recur_mode? pending.RecurMode
---@field entry string ---@field entry string
---@field modified string ---@field modified string
---@field end? string ---@field end? string
---@field notes? string
---@field order integer ---@field order integer
---@field _extra? table<string, any> ---@field _extra? pending.TaskExtra
---@class pending.Data ---@class pending.Data
---@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[][]
---@field folded_categories string[]
---@class pending.TaskFields
---@field description string
---@field status? pending.TaskStatus
---@field category? string
---@field priority? integer
---@field due? string
---@field recur? string
---@field recur_mode? pending.RecurMode
---@field order? integer
---@field _extra? pending.TaskExtra
---@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 = {},
folded_categories = {},
} }
end end
@ -56,9 +89,12 @@ 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,
notes = true,
order = true, order = true,
} }
@ -81,9 +117,18 @@ 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
if task.notes then
t.notes = task.notes
end
if task.order and task.order ~= 0 then if task.order and task.order ~= 0 then
t.order = task.order t.order = task.order
end end
@ -105,9 +150,12 @@ 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'],
notes = t.notes,
order = t.order or 0, order = t.order or 0,
_extra = {}, _extra = {},
} }
@ -123,18 +171,18 @@ local function table_to_task(t)
end end
---@return pending.Data ---@return pending.Data
function M.load() function Store:load()
local path = config.get().data_path local path = self.path
local f = io.open(path, 'r') local f = io.open(path, 'r')
if not f then if not f then
_data = empty_data() self._data = empty_data()
return _data return self._data
end end
local content = f:read('*a') local content = f:read('*a')
f:close() f:close()
if content == '' then if content == '' then
_data = empty_data() self._data = empty_data()
return _data return self._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
@ -149,31 +197,52 @@ function M.load()
.. '. Please update the plugin.' .. '. Please update the plugin.'
) )
end end
_data = { self._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 = {},
folded_categories = decoded.folded_categories or {},
} }
for _, t in ipairs(decoded.tasks or {}) do for _, t in ipairs(decoded.tasks or {}) do
table.insert(_data.tasks, table_to_task(t)) table.insert(self._data.tasks, table_to_task(t))
end end
return _data for _, snapshot in ipairs(decoded.undo or {}) do
if type(snapshot) == 'table' then
local tasks = {}
for _, raw in ipairs(snapshot) do
table.insert(tasks, table_to_task(raw))
end
table.insert(self._data.undo, tasks)
end
end
return self._data
end end
function M.save() ---@return nil
if not _data then function Store:save()
if not self._data then
return return
end end
local path = config.get().data_path local path = self.path
ensure_dir(path) ensure_dir(path)
local out = { local out = {
version = _data.version, version = self._data.version,
next_id = _data.next_id, next_id = self._data.next_id,
tasks = {}, tasks = {},
undo = {},
folded_categories = self._data.folded_categories,
} }
for _, task in ipairs(_data.tasks) do for _, task in ipairs(self._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')
@ -190,22 +259,22 @@ function M.save()
end end
---@return pending.Data ---@return pending.Data
function M.data() function Store:data()
if not _data then if not self._data then
M.load() self:load()
end end
return _data --[[@as pending.Data]] return self._data --[[@as pending.Data]]
end end
---@return pending.Task[] ---@return pending.Task[]
function M.tasks() function Store:tasks()
return M.data().tasks return self:data().tasks
end end
---@return pending.Task[] ---@return pending.Task[]
function M.active_tasks() function Store:active_tasks()
local result = {} local result = {}
for _, task in ipairs(M.tasks()) do for _, task in ipairs(self:tasks()) do
if task.status ~= 'deleted' then if task.status ~= 'deleted' then
table.insert(result, task) table.insert(result, task)
end end
@ -215,8 +284,8 @@ end
---@param id integer ---@param id integer
---@return pending.Task? ---@return pending.Task?
function M.get(id) function Store:get(id)
for _, task in ipairs(M.tasks()) do for _, task in ipairs(self:tasks()) do
if task.id == id then if task.id == id then
return task return task
end end
@ -224,10 +293,10 @@ function M.get(id)
return nil return nil
end end
---@param fields { description: string, status?: string, category?: string, priority?: integer, due?: string, order?: integer, _extra?: table } ---@param fields pending.TaskFields
---@return pending.Task ---@return pending.Task
function M.add(fields) function Store:add(fields)
local data = M.data() local data = self:data()
local now = timestamp() local now = timestamp()
local task = { local task = {
id = data.next_id, id = data.next_id,
@ -236,6 +305,8 @@ function M.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,
@ -250,19 +321,23 @@ end
---@param id integer ---@param id integer
---@param fields table<string, any> ---@param fields table<string, any>
---@return pending.Task? ---@return pending.Task?
function M.update(id, fields) function Store:update(id, fields)
local task = M.get(id) local task = self: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] = nil
else
task[k] = v task[k] = v
end end
end end
end
task.modified = now task.modified = now
if fields.status == 'done' or fields.status == 'deleted' then if fields.status == 'done' or fields.status == 'deleted' or fields.status == 'cancelled' then
task['end'] = task['end'] or now task['end'] = task['end'] or now
end end
return task return task
@ -270,14 +345,14 @@ end
---@param id integer ---@param id integer
---@return pending.Task? ---@return pending.Task?
function M.delete(id) function Store:delete(id)
return M.update(id, { status = 'deleted', ['end'] = timestamp() }) return self:update(id, { status = 'deleted', ['end'] = timestamp() })
end end
---@param id integer ---@param id integer
---@return integer? ---@return integer?
function M.find_index(id) function Store:find_index(id)
for i, task in ipairs(M.tasks()) do for i, task in ipairs(self:tasks()) do
if task.id == id then if task.id == id then
return i return i
end end
@ -286,14 +361,15 @@ function M.find_index(id)
end end
---@param tasks pending.Task[] ---@param tasks pending.Task[]
function M.replace_tasks(tasks) ---@return nil
M.data().tasks = tasks function Store:replace_tasks(tasks)
self:data().tasks = tasks
end end
---@return pending.Task[] ---@return pending.Task[]
function M.snapshot() function Store:snapshot()
local result = {} local result = {}
for _, task in ipairs(M.active_tasks()) do for _, task in ipairs(self: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
@ -311,13 +387,48 @@ function M.snapshot()
return result return result
end end
---@param id integer ---@return pending.Task[][]
function M.set_next_id(id) function Store:undo_stack()
M.data().next_id = id return self:data().undo
end end
function M.unload() ---@param stack pending.Task[][]
_data = nil ---@return nil
function Store:set_undo_stack(stack)
self:data().undo = stack
end
---@param id integer
---@return nil
function Store:set_next_id(id)
self:data().next_id = id
end
---@return string[]
function Store:get_folded_categories()
return self:data().folded_categories
end
---@param cats string[]
---@return nil
function Store:set_folded_categories(cats)
self:data().folded_categories = cats
end
---@return nil
function Store:unload()
self._data = nil
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()
return config.get().data_path
end end
return M return M

View file

@ -1,384 +1,61 @@
local config = require('pending.config') local config = require('pending.config')
local store = require('pending.store') local log = require('pending.log')
local oauth = require('pending.sync.oauth')
local util = require('pending.sync.util')
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'
---@class pending.GcalCredentials
---@field client_id string
---@field client_secret string
---@field redirect_uris? string[]
---@class pending.GcalTokens
---@field access_token string
---@field refresh_token string
---@field expires_in? integer
---@field obtained_at? integer
---@return table<string, any>
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)
)
end
---@param method string
---@param url string
---@param headers? string[]
---@param body? string
---@return table? result
---@return string? err
local function curl_request(method, url, headers, body)
local args = { 'curl', '-s', '-X', method }
for _, h in ipairs(headers or {}) do
table.insert(args, '-H')
table.insert(args, h)
end
if body then
table.insert(args, '-d')
table.insert(args, body)
end
table.insert(args, url)
local result = vim.system(args, { text = true }):wait()
if result.code ~= 0 then
return nil, 'curl failed: ' .. (result.stderr or '')
end
if not result.stdout or result.stdout == '' then
return {}, nil
end
local ok, decoded = pcall(vim.json.decode, result.stdout)
if not ok then
return nil, 'failed to parse response: ' .. result.stdout
end
if decoded.error then
return nil, 'API error: ' .. (decoded.error.message or vim.json.encode(decoded.error))
end
return decoded, nil
end
---@param access_token string ---@param access_token string
---@return string[] ---@return table<string, string>? name_to_id
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
---@param access_token string
---@return string? calendar_id
---@return string? err ---@return string? err
local function find_or_create_calendar(access_token) local function get_all_calendars(access_token)
local gc = gcal_config() local data, err = oauth.curl_request(
local cal_name = gc.calendar or 'Pendings' 'GET',
BASE_URL .. '/users/me/calendarList',
local data, err = oauth.auth_headers(access_token)
curl_request('GET', BASE_URL .. '/users/me/calendarList', auth_headers(access_token)) )
if err then if err then
return nil, err return nil, err
end end
local result = {}
for _, item in ipairs(data and data.items or {}) do for _, item in ipairs(data and data.items or {}) do
if item.summary == cal_name then if item.summary then
return item.id, nil result[item.summary] = item.id
end end
end end
return result, nil
end
local body = vim.json.encode({ summary = cal_name }) ---@param access_token string
local created, create_err = ---@param name string
curl_request('POST', BASE_URL .. '/calendars', auth_headers(access_token), body) ---@param existing table<string, string>
if create_err then ---@return string? calendar_id
return nil, create_err ---@return string? err
local function find_or_create_calendar(access_token, name, existing)
if existing[name] then
return existing[name], nil
end end
local body = vim.json.encode({ summary = name })
return created and created.id, nil local created, err =
oauth.curl_request('POST', BASE_URL .. '/calendars', oauth.auth_headers(access_token), body)
if err then
return nil, err
end
local id = created and created.id
if id then
existing[name] = id
end
return id, nil
end 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]]
@ -399,10 +76,10 @@ local function create_event(access_token, calendar_id, task)
private = { taskId = tostring(task.id) }, private = { taskId = tostring(task.id) },
}, },
} }
local data, err = curl_request( local data, err = oauth.curl_request(
'POST', 'POST',
BASE_URL .. '/calendars/' .. url_encode(calendar_id) .. '/events', BASE_URL .. '/calendars/' .. oauth.url_encode(calendar_id) .. '/events',
auth_headers(access_token), oauth.auth_headers(access_token),
vim.json.encode(event) vim.json.encode(event)
) )
if err then if err then
@ -421,11 +98,16 @@ local function update_event(access_token, calendar_id, event_id, task)
summary = task.description, summary = task.description,
start = { date = task.due }, start = { date = task.due },
['end'] = { date = next_day(task.due or '') }, ['end'] = { date = next_day(task.due or '') },
transparency = 'transparent',
} }
local _, err = curl_request( local _, err = oauth.curl_request(
'PATCH', 'PATCH',
BASE_URL .. '/calendars/' .. url_encode(calendar_id) .. '/events/' .. url_encode(event_id), BASE_URL
auth_headers(access_token), .. '/calendars/'
.. 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
@ -436,81 +118,166 @@ 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 = curl_request( local _, err = oauth.curl_request(
'DELETE', 'DELETE',
BASE_URL .. '/calendars/' .. url_encode(calendar_id) .. '/events/' .. url_encode(event_id), BASE_URL
auth_headers(access_token) .. '/calendars/'
.. oauth.url_encode(calendar_id)
.. '/events/'
.. oauth.url_encode(event_id),
oauth.auth_headers(access_token)
) )
return err return err
end end
function M.sync() ---@return boolean
local access_token = get_access_token() local function allow_remote_delete()
if not access_token then local cfg = config.get()
return local sync = cfg.sync or {}
local per = (sync.gcal or {}) --[[@as pending.GcalConfig]]
if per.remote_delete ~= nil then
return per.remote_delete == true
end
return sync.remote_delete == true
end end
local calendar_id, err = find_or_create_calendar(access_token) ---@param task pending.Task
if err or not calendar_id then ---@param extra table
vim.notify('pending.nvim: ' .. (err or 'calendar not found'), vim.log.levels.ERROR) ---@param now_ts string
return local function unlink_remote(task, extra, now_ts)
end
local tasks = store.tasks()
local created, updated, deleted = 0, 0, 0
for _, task in ipairs(tasks) do
local extra = task._extra or {}
local event_id = extra['_gcal_event_id'] --[[@as string?]]
local should_delete = event_id ~= nil
and (
task.status == 'done'
or task.status == 'deleted'
or (task.status == 'pending' and not task.due)
)
if should_delete and event_id then
local del_err = delete_event(access_token, calendar_id, event_id) --[[@as string]]
if not del_err then
extra['_gcal_event_id'] = nil extra['_gcal_event_id'] = nil
extra['_gcal_calendar_id'] = nil
if next(extra) == nil then if next(extra) == nil then
task._extra = nil task._extra = nil
else else
task._extra = extra task._extra = extra
end end
task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]] task.modified = now_ts
end
function M.push()
oauth.with_token(oauth.google_client, 'gcal', function(access_token)
local calendars, cal_err = get_all_calendars(access_token)
if cal_err or not calendars then
log.error('gcal: ' .. (cal_err or 'failed to fetch calendars'))
return
end
local s = require('pending').store()
local now_ts = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]]
local created, updated, deleted, failed = 0, 0, 0, 0
for _, task in ipairs(s:tasks()) do
local extra = task._extra or {}
local event_id = extra['_gcal_event_id'] --[[@as string?]]
local cal_id = extra['_gcal_calendar_id'] --[[@as string?]]
local should_delete = event_id ~= nil
and cal_id ~= nil
and (
task.status == 'done'
or task.status == 'deleted'
or task.status == 'cancelled'
or (task.status == 'pending' and not task.due)
)
if should_delete then
if allow_remote_delete() then
local del_err =
delete_event(access_token, cal_id --[[@as string]], event_id --[[@as string]])
if del_err then
log.warn('gcal: failed to delete calendar event — ' .. del_err)
failed = failed + 1
else
unlink_remote(task, extra, now_ts)
deleted = deleted + 1
end
else
log.debug(
'gcal: remote delete skipped (remote_delete disabled) — unlinked task #' .. task.id
)
unlink_remote(task, extra, now_ts)
deleted = deleted + 1 deleted = deleted + 1
end end
elseif task.status == 'pending' and task.due then elseif task.status == 'pending' and task.due then
if event_id then local cat = task.category or config.get().default_category
local upd_err = update_event(access_token, calendar_id, event_id, task) if event_id and cal_id then
if not upd_err then local upd_err = update_event(access_token, cal_id, event_id, task)
if upd_err then
log.warn('gcal: failed to update calendar event — ' .. upd_err)
failed = failed + 1
else
updated = updated + 1 updated = updated + 1
end end
else else
local new_id, create_err = create_event(access_token, calendar_id, task) local lid, lid_err = find_or_create_calendar(access_token, cat, calendars)
if not create_err and new_id then if lid_err or not lid then
log.warn('gcal: failed to create calendar — ' .. (lid_err or 'unknown'))
failed = failed + 1
else
local new_id, create_err = create_event(access_token, lid, task)
if create_err then
log.warn('gcal: failed to create calendar event — ' .. create_err)
failed = failed + 1
elseif new_id then
if not task._extra then if not task._extra then
task._extra = {} task._extra = {}
end end
task._extra['_gcal_event_id'] = new_id task._extra['_gcal_event_id'] = new_id
task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]] task._extra['_gcal_calendar_id'] = lid
task.modified = now_ts
created = created + 1 created = created + 1
end end
end end
end end
end end
end
store.save() util.finish(s)
vim.notify( log.info('gcal: push ' .. util.fmt_counts({
string.format( { created, 'added' },
'pending.nvim: Synced to Google Calendar (created: %d, updated: %d, deleted: %d)', { updated, 'updated' },
created, { deleted, 'removed' },
updated, { failed, 'failed' },
deleted }))
) end)
end
---@param args? string
---@return nil
function M.auth(args)
if args == 'clear' then
oauth.google_client:clear_tokens()
log.info('gcal: OAuth tokens cleared — run :Pending auth gcal to re-authenticate.')
elseif args == 'reset' then
oauth.google_client:_wipe()
log.info(
'gcal: OAuth tokens and credentials cleared — run :Pending auth gcal to set up from scratch.'
) )
else
local creds = oauth.google_client:resolve_credentials()
if creds.client_id == oauth.BUNDLED_CLIENT_ID then
oauth.google_client:setup()
else
oauth.google_client:auth()
end
end
end
---@return string[]
function M.auth_complete()
return { 'clear', 'reset' }
end
---@return nil
function M.health()
oauth.health(M.name)
local tokens = oauth.google_client:load_tokens()
if tokens and tokens.refresh_token then
vim.health.ok('gcal tokens found')
else
vim.health.info('no gcal tokens — run :Pending auth gcal')
end
end end
return M return M

544
lua/pending/sync/gtasks.lua Normal file
View file

@ -0,0 +1,544 @@
local config = require('pending.config')
local log = require('pending.log')
local oauth = require('pending.sync.oauth')
local util = require('pending.sync.util')
local M = {}
M.name = 'gtasks'
local BASE_URL = 'https://tasks.googleapis.com/tasks/v1'
---@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
---@return boolean
local function allow_remote_delete()
local cfg = config.get()
local sync = cfg.sync or {}
local per = (sync.gtasks or {}) --[[@as pending.GtasksConfig]]
if per.remote_delete ~= nil then
return per.remote_delete == true
end
return sync.remote_delete == true
end
---@param task pending.Task
---@param now_ts string
local function unlink_remote(task, now_ts)
task._extra['_gtasks_task_id'] = nil
task._extra['_gtasks_list_id'] = nil
task._extra['_gtasks_synced_at'] = nil
if next(task._extra) == nil then
task._extra = nil
end
task.modified = now_ts
end
---@param task pending.Task
---@return table
local function task_to_gtask(task)
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
---@return integer failed
local function push_pass(access_token, tasklists, s, now_ts, by_gtasks_id)
local created, updated, deleted, failed = 0, 0, 0, 0
for _, task in ipairs(s:tasks()) do
local extra = task._extra or {}
local gtid = extra['_gtasks_task_id'] --[[@as string?]]
local list_id = extra['_gtasks_list_id'] --[[@as string?]]
if task.status == 'deleted' and gtid and list_id then
if allow_remote_delete() then
local err = delete_gtask(access_token, list_id, gtid)
if err then
log.warn('gtasks: failed to delete remote task — ' .. err)
failed = failed + 1
else
unlink_remote(task, now_ts)
deleted = deleted + 1
end
else
log.debug(
'gtasks: remote delete skipped (remote_delete disabled) — unlinked task #' .. task.id
)
unlink_remote(task, now_ts)
deleted = deleted + 1
end
elseif task.status ~= 'deleted' then
if gtid and list_id then
local synced_at = extra['_gtasks_synced_at'] --[[@as string?]]
if not synced_at or task.modified > synced_at then
local err = update_gtask(access_token, list_id, gtid, task_to_gtask(task))
if err then
log.warn('gtasks: failed to update remote task — ' .. err)
failed = failed + 1
else
task._extra = task._extra or {}
task._extra['_gtasks_synced_at'] = now_ts
updated = updated + 1
end
end
elseif task.status == 'pending' then
local cat = task.category or config.get().default_category
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
log.warn('gtasks: failed to create remote task — ' .. create_err)
failed = failed + 1
elseif new_id then
if not task._extra then
task._extra = {}
end
task._extra['_gtasks_task_id'] = new_id
task._extra['_gtasks_list_id'] = lid
task._extra['_gtasks_synced_at'] = now_ts
task.modified = now_ts
by_gtasks_id[new_id] = task
created = created + 1
end
end
end
end
end
return created, updated, deleted, failed
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 failed
---@return table<string, true> seen_remote_ids
---@return table<string, true> fetched_list_ids
local function pull_pass(access_token, tasklists, s, now_ts, by_gtasks_id)
local created, updated, failed = 0, 0, 0
---@type table<string, true>
local seen_remote_ids = {}
---@type table<string, true>
local fetched_list_ids = {}
for list_name, list_id in pairs(tasklists) do
local items, err = list_gtasks(access_token, list_id)
if err then
log.warn('gtasks: failed to fetch task list "' .. list_name .. '" — ' .. err)
failed = failed + 1
else
fetched_list_ids[list_id] = true
for _, gtask in ipairs(items or {}) do
seen_remote_ids[gtask.id] = true
local local_task = by_gtasks_id[gtask.id]
if local_task then
local gtask_updated = gtask.updated or ''
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._extra = local_task._extra or {}
local_task._extra['_gtasks_synced_at'] = now_ts
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,
_gtasks_synced_at = now_ts,
}
local new_task = s:add(fields)
by_gtasks_id[gtask.id] = new_task
created = created + 1
end
end
end
end
return created, updated, failed, seen_remote_ids, fetched_list_ids
end
---@param s pending.Store
---@param seen_remote_ids table<string, true>
---@param fetched_list_ids table<string, true>
---@param now_ts string
---@return integer unlinked
local function detect_remote_deletions(s, seen_remote_ids, fetched_list_ids, now_ts)
local unlinked = 0
for _, task in ipairs(s:tasks()) do
local extra = task._extra or {}
local gtid = extra['_gtasks_task_id']
local list_id = extra['_gtasks_list_id']
if
task.status ~= 'deleted'
and gtid
and list_id
and fetched_list_ids[list_id]
and not seen_remote_ids[gtid]
then
task._extra['_gtasks_task_id'] = nil
task._extra['_gtasks_list_id'] = nil
task._extra['_gtasks_synced_at'] = nil
if next(task._extra) == nil then
task._extra = nil
end
task.modified = now_ts
unlinked = unlinked + 1
end
end
return unlinked
end
---@param access_token string
---@return table<string, string>? tasklists
---@return pending.Store? s
---@return string? now_ts
local function sync_setup(access_token)
local tasklists, tl_err = get_all_tasklists(access_token)
if tl_err or not tasklists then
log.error('gtasks: ' .. (tl_err or 'failed to fetch task lists'))
return nil, nil, nil
end
local s = require('pending').store()
local now_ts = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]]
return tasklists, s, now_ts
end
function M.push()
oauth.with_token(oauth.google_client, 'gtasks', function(access_token)
local tasklists, s, now_ts = sync_setup(access_token)
if not tasklists then
return
end
---@cast s pending.Store
---@cast now_ts string
local by_gtasks_id = build_id_index(s)
local created, updated, deleted, failed =
push_pass(access_token, tasklists, s, now_ts, by_gtasks_id)
util.finish(s)
log.info('gtasks: push ' .. util.fmt_counts({
{ created, 'added' },
{ updated, 'updated' },
{ deleted, 'deleted' },
{ failed, 'failed' },
}))
end)
end
function M.pull()
oauth.with_token(oauth.google_client, 'gtasks', function(access_token)
local tasklists, s, now_ts = sync_setup(access_token)
if not tasklists then
return
end
---@cast s pending.Store
---@cast now_ts string
local by_gtasks_id = build_id_index(s)
local created, updated, failed, seen_remote_ids, fetched_list_ids =
pull_pass(access_token, tasklists, s, now_ts, by_gtasks_id)
local unlinked = detect_remote_deletions(s, seen_remote_ids, fetched_list_ids, now_ts)
util.finish(s)
log.info('gtasks: pull ' .. util.fmt_counts({
{ created, 'added' },
{ updated, 'updated' },
{ unlinked, 'unlinked' },
{ failed, 'failed' },
}))
end)
end
function M.sync()
oauth.with_token(oauth.google_client, 'gtasks', function(access_token)
local tasklists, s, now_ts = sync_setup(access_token)
if not tasklists then
return
end
---@cast s pending.Store
---@cast now_ts string
local by_gtasks_id = build_id_index(s)
local pushed_create, pushed_update, pushed_delete, pushed_failed =
push_pass(access_token, tasklists, s, now_ts, by_gtasks_id)
local pulled_create, pulled_update, pulled_failed, seen_remote_ids, fetched_list_ids =
pull_pass(access_token, tasklists, s, now_ts, by_gtasks_id)
local unlinked = detect_remote_deletions(s, seen_remote_ids, fetched_list_ids, now_ts)
util.finish(s)
log.info('gtasks: sync push ' .. util.fmt_counts({
{ pushed_create, 'added' },
{ pushed_update, 'updated' },
{ pushed_delete, 'deleted' },
{ pushed_failed, 'failed' },
}) .. ' | pull ' .. util.fmt_counts({
{ pulled_create, 'added' },
{ pulled_update, 'updated' },
{ unlinked, 'unlinked' },
{ pulled_failed, 'failed' },
}))
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
M._push_pass = push_pass
M._pull_pass = pull_pass
M._detect_remote_deletions = detect_remote_deletions
---@param args? string
---@return nil
function M.auth(args)
if args == 'clear' then
oauth.google_client:clear_tokens()
log.info('gtasks: OAuth tokens cleared — run :Pending auth gtasks to re-authenticate.')
elseif args == 'reset' then
oauth.google_client:_wipe()
log.info(
'gtasks: OAuth tokens and credentials cleared — run :Pending auth gtasks to set up from scratch.'
)
else
local creds = oauth.google_client:resolve_credentials()
if creds.client_id == oauth.BUNDLED_CLIENT_ID then
oauth.google_client:setup()
else
oauth.google_client:auth()
end
end
end
---@return string[]
function M.auth_complete()
return { 'clear', 'reset' }
end
---@return nil
function M.health()
oauth.health(M.name)
local tokens = oauth.google_client:load_tokens()
if tokens and tokens.refresh_token then
vim.health.ok('gtasks tokens found')
else
vim.health.info('no gtasks tokens — run :Pending auth gtasks')
end
end
return M

593
lua/pending/sync/oauth.lua Normal file
View file

@ -0,0 +1,593 @@
local config = require('pending.config')
local log = require('pending.log')
local TOKEN_URL = 'https://oauth2.googleapis.com/token'
local AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'
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.OAuthClientOpts
---@field name string
---@field scope string
---@field port integer
---@field config_key string
---@class pending.OAuthClient : pending.OAuthClientOpts
---@field token_path fun(self: pending.OAuthClient): string
---@field resolve_credentials fun(self: pending.OAuthClient): pending.OAuthCredentials
---@field load_tokens fun(self: pending.OAuthClient): pending.OAuthTokens?
---@field save_tokens fun(self: pending.OAuthClient, tokens: pending.OAuthTokens): boolean
---@field refresh_access_token fun(self: pending.OAuthClient, creds: pending.OAuthCredentials, tokens: pending.OAuthTokens): pending.OAuthTokens?
---@field get_access_token fun(self: pending.OAuthClient): string?
---@field setup fun(self: pending.OAuthClient): nil
---@field auth fun(self: pending.OAuthClient, on_complete?: fun(ok: boolean): nil): nil
---@field clear_tokens fun(self: pending.OAuthClient): nil
local OAuthClient = {}
OAuthClient.__index = OAuthClient
local util = require('pending.sync.util')
local _active_close = nil
---@class pending.oauth
local M = {}
M.system = util.system
M.async = util.async
---@param client pending.OAuthClient
---@param name string
---@param callback fun(access_token: string): nil
function M.with_token(client, name, callback)
util.async(function()
util.with_guard(name, function()
local token = client:get_access_token()
if not token then
local creds = client:resolve_credentials()
if creds.client_id == BUNDLED_CLIENT_ID then
log.warn(name .. ': No credentials configured — run :Pending auth ' .. name)
return
end
log.info(name .. ': Not authenticated — starting auth flow...')
local co = coroutine.running()
client:auth(function(ok)
vim.schedule(function()
coroutine.resume(co, ok)
end)
end)
local auth_ok = coroutine.yield()
if not auth_ok then
log.error(name .. ': Authentication failed.')
return
end
token = client:get_access_token()
if not token then
log.error(name .. ': Still not authenticated after auth flow.')
return
end
end
callback(token)
end)
end)
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 data_dir = vim.fn.stdpath('data') .. '/pending/'
local cred_paths = {}
if backend_cfg.credentials_path then
table.insert(cred_paths, backend_cfg.credentials_path)
end
table.insert(cred_paths, data_dir .. self.name .. '_credentials.json')
table.insert(cred_paths, data_dir .. 'google_credentials.json')
for _, cred_path in ipairs(cred_paths) do
if cred_path then
local creds = M.load_json_file(cred_path)
if creds then
if creds.installed then
creds = creds.installed
end
if creds.client_id and creds.client_secret then
return creds --[[@as pending.OAuthCredentials]]
end
end
end
end
return {
client_id = BUNDLED_CLIENT_ID,
client_secret = BUNDLED_CLIENT_SECRET,
}
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
return nil
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
log.error(self.name .. ': Token refresh failed — re-authenticating...')
return nil
end
end
return tokens.access_token
end
---@return nil
function OAuthClient:setup()
local choice = vim.fn.inputlist({
self.name .. ' setup:',
'1. Enter client ID and secret',
'2. Load from JSON file path',
})
vim.cmd.redraw()
local id, secret
if choice == 1 then
while true do
id = vim.trim(vim.fn.input(self.name .. ' client ID: '))
if id == '' then
return
end
if id:match('^%d+%-[%w_]+%.apps%.googleusercontent%.com$') then
break
end
vim.cmd.redraw()
vim.api.nvim_echo({
{
'invalid client ID — expected <numbers>-<hash>.apps.googleusercontent.com',
'ErrorMsg',
},
}, false, {})
end
while true do
secret = vim.trim(vim.fn.inputsecret(self.name .. ' client secret: '))
if secret == '' then
return
end
if secret:match('^GOCSPX%-') then
break
end
vim.cmd.redraw()
vim.api.nvim_echo(
{ { 'invalid client secret — expected GOCSPX-...', 'ErrorMsg' } },
false,
{}
)
end
elseif choice == 2 then
local fpath
while true do
fpath = vim.trim(vim.fn.input(self.name .. ' credentials file: ', '', 'file'))
if fpath == '' then
return
end
fpath = vim.fn.expand(fpath)
local creds = M.load_json_file(fpath)
if creds then
if creds.installed then
creds = creds.installed
end
if creds.client_id and creds.client_secret then
id = creds.client_id
secret = creds.client_secret
break
end
end
vim.cmd.redraw()
vim.api.nvim_echo(
{ { 'could not read client_id/client_secret from ' .. fpath, 'ErrorMsg' } },
false,
{}
)
end
else
return
end
vim.schedule(function()
local path = vim.fn.stdpath('data') .. '/pending/google_credentials.json'
local ok = M.save_json_file(path, { client_id = id, client_secret = secret })
if not ok then
log.error(self.name .. ': Failed to save credentials.')
return
end
log.info(self.name .. ': Credentials saved, starting authorization...')
self:auth()
end)
end
---@param on_complete? fun(ok: boolean): nil
---@return nil
function OAuthClient:auth(on_complete)
if _active_close then
_active_close()
_active_close = nil
end
local creds = self:resolve_credentials()
if creds.client_id == BUNDLED_CLIENT_ID then
log.error(self.name .. ': No credentials configured — run :Pending auth.')
if on_complete then
on_complete(false)
end
return
end
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=select_account%20consent'
.. '&code_challenge='
.. M.url_encode(code_challenge)
.. '&code_challenge_method=S256'
local server = vim.uv.new_tcp()
local server_closed = false
local function close_server()
if server_closed then
return
end
server_closed = true
if _active_close == close_server then
_active_close = nil
end
server:close()
end
_active_close = close_server
local bind_ok, bind_err = pcall(server.bind, server, '127.0.0.1', port)
if not bind_ok or bind_err == nil then
close_server()
log.error(self.name .. ': Port ' .. port .. ' already in use — try again in a moment.')
if on_complete then
on_complete(false)
end
return
end
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, on_complete)
end)
end
end)
end)
vim.ui.open(auth_url)
log.info(self.name .. ': Opening browser for authorization...')
vim.defer_fn(function()
if not server_closed then
close_server()
log.warn(self.name .. ': OAuth callback timed out (120s).')
if on_complete then
on_complete(false)
end
end
end, 120000)
end
---@param creds pending.OAuthCredentials
---@param code string
---@param code_verifier string
---@param port integer
---@param on_complete? fun(ok: boolean): nil
---@return nil
function OAuthClient:_exchange_code(creds, code, code_verifier, port, on_complete)
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
self:clear_tokens()
log.error(self.name .. ': Token exchange failed.')
if on_complete then
on_complete(false)
end
return
end
local ok, decoded = pcall(vim.json.decode, result.stdout or '')
if not ok or not decoded.access_token then
self:clear_tokens()
log.error(self.name .. ': Invalid token response.')
if on_complete then
on_complete(false)
end
return
end
decoded.obtained_at = os.time()
self:save_tokens(decoded)
log.info(self.name .. ': Authorized successfully.')
if on_complete then
on_complete(true)
end
end
---@return nil
function OAuthClient:_wipe()
os.remove(self:token_path())
os.remove(vim.fn.stdpath('data') .. '/pending/google_credentials.json')
end
---@return nil
function OAuthClient:clear_tokens()
if _active_close then
_active_close()
_active_close = nil
end
os.remove(self:token_path())
end
---@param opts pending.OAuthClientOpts
---@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
M.BUNDLED_CLIENT_ID = BUNDLED_CLIENT_ID
M.google_client = M.new({
name = 'Google',
scope = 'https://www.googleapis.com/auth/tasks' .. ' https://www.googleapis.com/auth/calendar',
port = 18392,
config_key = 'google',
})
return M

509
lua/pending/sync/s3.lua Normal file
View file

@ -0,0 +1,509 @@
local log = require('pending.log')
local util = require('pending.sync.util')
local M = {}
M.name = 's3'
---@return pending.S3Config?
local function get_config()
local cfg = require('pending.config').get()
return cfg.sync and cfg.sync.s3
end
---@return string[]
local function base_cmd()
local s3cfg = get_config() or {}
local cmd = { 'aws' }
if s3cfg.profile then
table.insert(cmd, '--profile')
table.insert(cmd, s3cfg.profile)
end
if s3cfg.region then
table.insert(cmd, '--region')
table.insert(cmd, s3cfg.region)
end
return cmd
end
---@param task pending.Task
---@return string
local function ensure_sync_id(task)
if not task._extra then
task._extra = {}
end
local sync_id = task._extra['_s3_sync_id']
if not sync_id then
local bytes = {}
math.randomseed(vim.uv.hrtime())
for i = 1, 16 do
bytes[i] = math.random(0, 255)
end
bytes[7] = bit.bor(bit.band(bytes[7], 0x0f), 0x40)
bytes[9] = bit.bor(bit.band(bytes[9], 0x3f), 0x80)
sync_id = string.format(
'%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x',
bytes[1],
bytes[2],
bytes[3],
bytes[4],
bytes[5],
bytes[6],
bytes[7],
bytes[8],
bytes[9],
bytes[10],
bytes[11],
bytes[12],
bytes[13],
bytes[14],
bytes[15],
bytes[16]
)
task._extra['_s3_sync_id'] = sync_id
task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]]
end
return sync_id
end
---@return boolean
local function ensure_credentials()
local cmd = base_cmd()
vim.list_extend(cmd, { 'sts', 'get-caller-identity', '--output', 'json' })
local result = util.system(cmd, { text = true })
if result.code == 0 then
return true
end
local stderr = result.stderr or ''
if stderr:find('SSO') or stderr:find('sso') then
log.info('S3: SSO session expired — running login...')
local login_cmd = base_cmd()
vim.list_extend(login_cmd, { 'sso', 'login' })
local login_result = util.system(login_cmd, { text = true })
if login_result.code == 0 then
log.info('S3: SSO login successful')
return true
end
log.error('S3: SSO login failed — ' .. (login_result.stderr or ''))
return false
end
if stderr:find('Unable to locate credentials') or stderr:find('NoCredentialProviders') then
log.error('S3: no AWS credentials configured. See :h pending-s3')
else
log.error('S3: credential check failed — ' .. stderr)
end
return false
end
local function create_bucket()
local name = util.input({ prompt = 'S3 bucket name (pending.nvim): ' })
if not name then
log.info('S3: bucket creation cancelled')
return
end
if name == '' then
name = 'pending.nvim'
end
local region_cmd = base_cmd()
vim.list_extend(region_cmd, { 'configure', 'get', 'region' })
local region_result = util.system(region_cmd, { text = true })
local default_region = 'us-east-1'
if region_result.code == 0 and region_result.stdout then
local detected = vim.trim(region_result.stdout)
if detected ~= '' then
default_region = detected
end
end
local region = util.input({ prompt = 'AWS region (' .. default_region .. '): ' })
if not region or region == '' then
region = default_region
end
local cmd = base_cmd()
vim.list_extend(cmd, { 's3api', 'create-bucket', '--bucket', name, '--region', region })
if region ~= 'us-east-1' then
vim.list_extend(cmd, { '--create-bucket-configuration', 'LocationConstraint=' .. region })
end
local result = util.system(cmd, { text = true })
if result.code == 0 then
log.info(
's3: bucket created. Add to your pending.nvim config:\n sync = { s3 = { bucket = "'
.. name
.. '", region = "'
.. region
.. '" } }'
)
else
log.error('S3: bucket creation failed — ' .. (result.stderr or 'unknown error'))
end
end
---@param args? string
---@return nil
function M.auth(args)
if args == 'profile' then
vim.ui.input({ prompt = 'AWS profile name: ' }, function(input)
if not input or input == '' then
local s3cfg = get_config()
if s3cfg and s3cfg.profile then
log.info('S3: current profile: ' .. s3cfg.profile)
else
log.info('S3: no profile configured (using default)')
end
return
end
log.info('S3: set profile in your config: sync = { s3 = { profile = "' .. input .. '" } }')
end)
return
end
util.async(function()
local cmd = base_cmd()
vim.list_extend(cmd, { 'sts', 'get-caller-identity', '--output', 'json' })
local result = util.system(cmd, { text = true })
if result.code == 0 then
local ok, data = pcall(vim.json.decode, result.stdout or '')
if ok and data then
log.info('S3: authenticated as ' .. (data.Arn or data.Account or 'unknown'))
else
log.info('S3: credentials valid')
end
local s3cfg = get_config()
if not s3cfg or not s3cfg.bucket then
create_bucket()
end
else
local stderr = result.stderr or ''
if stderr:find('SSO') or stderr:find('sso') then
log.info('S3: SSO session expired — running login...')
local login_cmd = base_cmd()
vim.list_extend(login_cmd, { 'sso', 'login' })
local login_result = util.system(login_cmd, { text = true })
if login_result.code == 0 then
log.info('S3: SSO login successful')
else
log.error('S3: SSO login failed — ' .. (login_result.stderr or ''))
end
elseif
stderr:find('Unable to locate credentials') or stderr:find('NoCredentialProviders')
then
log.error('S3: no AWS credentials configured. See :h pending-s3')
else
log.error('S3: ' .. stderr)
end
end
end)
end
---@return string[]
function M.auth_complete()
return { 'profile' }
end
function M.push()
util.async(function()
util.with_guard('S3', function()
if not ensure_credentials() then
return
end
local s3cfg = get_config()
if not s3cfg or not s3cfg.bucket then
log.error('S3: bucket is required. Set sync.s3.bucket in config.')
return
end
local key = s3cfg.key or 'pending.json'
local s = require('pending').store()
for _, task in ipairs(s:tasks()) do
ensure_sync_id(task)
end
local tmpfile = vim.fn.tempname() .. '.json'
s:save()
local store = require('pending.store')
local tmp_store = store.new(s.path)
tmp_store:load()
local f = io.open(s.path, 'r')
if not f then
log.error('S3: failed to read store file')
return
end
local content = f:read('*a')
f:close()
local tf = io.open(tmpfile, 'w')
if not tf then
log.error('S3: failed to create temp file')
return
end
tf:write(content)
tf:close()
local cmd = base_cmd()
vim.list_extend(cmd, { 's3', 'cp', tmpfile, 's3://' .. s3cfg.bucket .. '/' .. key })
local result = util.system(cmd, { text = true })
os.remove(tmpfile)
if result.code ~= 0 then
log.error('S3: push failed — ' .. (result.stderr or 'unknown error'))
return
end
util.finish(s)
log.info('S3: push uploaded to s3://' .. s3cfg.bucket .. '/' .. key)
end)
end)
end
function M.pull()
util.async(function()
util.with_guard('S3', function()
if not ensure_credentials() then
return
end
local s3cfg = get_config()
if not s3cfg or not s3cfg.bucket then
log.error('S3: bucket is required. Set sync.s3.bucket in config.')
return
end
local key = s3cfg.key or 'pending.json'
local tmpfile = vim.fn.tempname() .. '.json'
local cmd = base_cmd()
vim.list_extend(cmd, { 's3', 'cp', 's3://' .. s3cfg.bucket .. '/' .. key, tmpfile })
local result = util.system(cmd, { text = true })
if result.code ~= 0 then
os.remove(tmpfile)
log.error('S3: pull failed — ' .. (result.stderr or 'unknown error'))
return
end
local store = require('pending.store')
local s_remote = store.new(tmpfile)
local load_ok = pcall(function()
s_remote:load()
end)
if not load_ok then
os.remove(tmpfile)
log.error('S3: pull failed — could not parse remote store')
return
end
local s = require('pending').store()
local created, updated, unchanged = 0, 0, 0
local local_by_sync_id = {}
for _, task in ipairs(s:tasks()) do
local extra = task._extra or {}
local sid = extra['_s3_sync_id']
if sid then
local_by_sync_id[sid] = task
end
end
for _, remote_task in ipairs(s_remote:tasks()) do
local r_extra = remote_task._extra or {}
local r_sid = r_extra['_s3_sync_id']
if not r_sid then
goto continue
end
local local_task = local_by_sync_id[r_sid]
if local_task then
local r_mod = remote_task.modified or ''
local l_mod = local_task.modified or ''
if r_mod > l_mod then
local_task.description = remote_task.description
local_task.status = remote_task.status
local_task.category = remote_task.category
local_task.priority = remote_task.priority
local_task.due = remote_task.due
local_task.recur = remote_task.recur
local_task.recur_mode = remote_task.recur_mode
local_task['end'] = remote_task['end']
local_task._extra = local_task._extra or {}
local_task._extra['_s3_sync_id'] = r_sid
local_task.modified = remote_task.modified
updated = updated + 1
else
unchanged = unchanged + 1
end
else
s:add({
description = remote_task.description,
status = remote_task.status,
category = remote_task.category,
priority = remote_task.priority,
due = remote_task.due,
recur = remote_task.recur,
recur_mode = remote_task.recur_mode,
_extra = { _s3_sync_id = r_sid },
})
created = created + 1
end
::continue::
end
os.remove(tmpfile)
util.finish(s)
log.info('S3: pull ' .. util.fmt_counts({
{ created, 'added' },
{ updated, 'updated' },
{ unchanged, 'unchanged' },
}))
end)
end)
end
function M.sync()
util.async(function()
util.with_guard('S3', function()
if not ensure_credentials() then
return
end
local s3cfg = get_config()
if not s3cfg or not s3cfg.bucket then
log.error('S3: bucket is required. Set sync.s3.bucket in config.')
return
end
local key = s3cfg.key or 'pending.json'
local tmpfile = vim.fn.tempname() .. '.json'
local cmd = base_cmd()
vim.list_extend(cmd, { 's3', 'cp', 's3://' .. s3cfg.bucket .. '/' .. key, tmpfile })
local result = util.system(cmd, { text = true })
local s = require('pending').store()
local created, updated = 0, 0
if result.code == 0 then
local store = require('pending.store')
local s_remote = store.new(tmpfile)
local load_ok = pcall(function()
s_remote:load()
end)
if load_ok then
local local_by_sync_id = {}
for _, task in ipairs(s:tasks()) do
local extra = task._extra or {}
local sid = extra['_s3_sync_id']
if sid then
local_by_sync_id[sid] = task
end
end
for _, remote_task in ipairs(s_remote:tasks()) do
local r_extra = remote_task._extra or {}
local r_sid = r_extra['_s3_sync_id']
if not r_sid then
goto continue
end
local local_task = local_by_sync_id[r_sid]
if local_task then
local r_mod = remote_task.modified or ''
local l_mod = local_task.modified or ''
if r_mod > l_mod then
local_task.description = remote_task.description
local_task.status = remote_task.status
local_task.category = remote_task.category
local_task.priority = remote_task.priority
local_task.due = remote_task.due
local_task.recur = remote_task.recur
local_task.recur_mode = remote_task.recur_mode
local_task['end'] = remote_task['end']
local_task._extra = local_task._extra or {}
local_task._extra['_s3_sync_id'] = r_sid
local_task.modified = remote_task.modified
updated = updated + 1
end
else
s:add({
description = remote_task.description,
status = remote_task.status,
category = remote_task.category,
priority = remote_task.priority,
due = remote_task.due,
recur = remote_task.recur,
recur_mode = remote_task.recur_mode,
_extra = { _s3_sync_id = r_sid },
})
created = created + 1
end
::continue::
end
end
end
os.remove(tmpfile)
for _, task in ipairs(s:tasks()) do
ensure_sync_id(task)
end
s:save()
local f = io.open(s.path, 'r')
if not f then
log.error('S3: sync failed — could not read store file')
return
end
local content = f:read('*a')
f:close()
local push_tmpfile = vim.fn.tempname() .. '.json'
local tf = io.open(push_tmpfile, 'w')
if not tf then
log.error('S3: sync failed — could not create temp file')
return
end
tf:write(content)
tf:close()
local push_cmd = base_cmd()
vim.list_extend(push_cmd, { 's3', 'cp', push_tmpfile, 's3://' .. s3cfg.bucket .. '/' .. key })
local push_result = util.system(push_cmd, { text = true })
os.remove(push_tmpfile)
if push_result.code ~= 0 then
log.error('S3: sync push failed — ' .. (push_result.stderr or 'unknown error'))
util.finish(s)
return
end
util.finish(s)
log.info('S3: sync ' .. util.fmt_counts({
{ created, 'added' },
{ updated, 'updated' },
}) .. ' | push uploaded')
end)
end)
end
---@return nil
function M.health()
if vim.fn.executable('aws') == 1 then
vim.health.ok('aws CLI found')
else
vim.health.error('aws CLI not found (required for S3 sync)')
end
local s3cfg = get_config()
if s3cfg and s3cfg.bucket then
vim.health.ok('S3 bucket configured: ' .. s3cfg.bucket)
else
vim.health.warn('S3 bucket not configured — set sync.s3.bucket')
end
end
M._ensure_sync_id = ensure_sync_id
M._ensure_credentials = ensure_credentials
return M

98
lua/pending/sync/util.lua Normal file
View file

@ -0,0 +1,98 @@
local log = require('pending.log')
---@class pending.SystemResult
---@field code integer
---@field stdout string
---@field stderr string
---@class pending.CountPart
---@field [1] integer
---@field [2] string
---@class pending.sync.util
local M = {}
local _sync_in_flight = false
---@param fn fun(): nil
function M.async(fn)
coroutine.resume(coroutine.create(fn))
end
---@param args string[]
---@param opts? table
---@return pending.SystemResult
function M.system(args, opts)
local co = coroutine.running()
if not co then
return vim.system(args, opts or {}):wait() --[[@as pending.SystemResult]]
end
vim.system(args, opts or {}, function(result)
vim.schedule(function()
coroutine.resume(co, result)
end)
end)
return coroutine.yield() --[[@as { code: integer, stdout: string, stderr: string }]]
end
---@param opts? {prompt?: string, default?: string}
---@return string?
function M.input(opts)
local co = coroutine.running()
if not co then
error('util.input() must be called inside a coroutine')
end
vim.ui.input(opts or {}, function(result)
vim.schedule(function()
coroutine.resume(co, result)
end)
end)
return coroutine.yield() --[[@as string?]]
end
---@param name string
---@param fn fun(): nil
function M.with_guard(name, fn)
if _sync_in_flight then
log.warn(name .. ': Sync already in progress — please wait.')
return
end
_sync_in_flight = true
local ok, err = pcall(fn)
_sync_in_flight = false
if not ok then
log.error(name .. ': ' .. tostring(err))
end
end
---@return boolean
function M.sync_in_flight()
return _sync_in_flight
end
---@param s pending.Store
function M.finish(s)
s:save()
require('pending')._recompute_counts()
local buffer = require('pending.buffer')
if buffer.bufnr() and vim.api.nvim_buf_is_valid(buffer.bufnr()) then
buffer.render(buffer.bufnr())
end
end
---@param parts pending.CountPart[]
---@return string
function M.fmt_counts(parts)
local items = {}
for _, p in ipairs(parts) do
if p[1] > 0 then
table.insert(items, p[1] .. ' ' .. p[2])
end
end
if #items == 0 then
return 'nothing to do'
end
return table.concat(items, ' | ')
end
return M

383
lua/pending/textobj.lua Normal file
View file

@ -0,0 +1,383 @@
local buffer = require('pending.buffer')
local config = require('pending.config')
local log = require('pending.log')
---@class pending.textobj
local M = {}
---@param ... any
---@return nil
local function dbg(...)
log.debug(string.format(...))
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

View file

@ -1,15 +1,25 @@
local config = require('pending.config') local config = require('pending.config')
local forge = require('pending.forge')
local parse = require('pending.parse')
---@class pending.ForgeLineMeta
---@field ref pending.ForgeRef
---@field cache? pending.ForgeCache
---@field col_start integer
---@field col_end integer
---@class pending.LineMeta ---@class pending.LineMeta
---@field type 'task'|'header'|'blank' ---@field type 'task'|'header'|'blank'|'filter'
---@field id? integer ---@field id? integer
---@field due? string ---@field due? string
---@field raw_due? string ---@field raw_due? string
---@field status? string ---@field status? pending.TaskStatus
---@field category? string ---@field category? string
---@field overdue? boolean ---@field overdue? boolean
---@field show_category? boolean ---@field show_category? boolean
---@field priority? integer ---@field priority? integer
---@field recur? string
---@field forge_spans? pending.ForgeLineMeta[]
---@class pending.views ---@class pending.views
local M = {} local M = {}
@ -20,7 +30,10 @@ local function format_due(due)
if not due then if not due then
return nil return nil
end end
local y, m, d = due:match('^(%d%d%d%d)-(%d%d)-(%d%d)$') local y, m, d, hh, mm = due:match('^(%d%d%d%d)-(%d%d)-(%d%d)T(%d%d):(%d%d)$')
if not y then
y, m, d = due:match('^(%d%d%d%d)-(%d%d)-(%d%d)$')
end
if not y then if not y then
return due return due
end end
@ -29,12 +42,63 @@ 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]],
}) })
return os.date(config.get().date_format, t) --[[@as string]] local formatted = os.date(config.get().date_format, t) --[[@as string]]
if hh then
formatted = formatted .. ' ' .. hh .. ':' .. mm
end
return formatted
end
---@param task pending.Task
---@param prefix_len integer
---@return pending.ForgeLineMeta[]?
local function compute_forge_spans(task, prefix_len)
local refs = forge.find_refs(task.description)
if #refs == 0 then
return nil
end
local cache = task._extra and task._extra._forge_cache or nil
local spans = {}
for _, r in ipairs(refs) do
table.insert(spans, {
ref = r.ref,
cache = cache,
col_start = prefix_len + r.start_byte,
col_end = prefix_len + r.end_byte,
})
end
return spans
end
---@type table<string, integer>
local status_rank = { wip = 0, pending = 1, blocked = 2, done = 3, cancelled = 4 }
---@param task pending.Task
---@return string
local function state_char(task)
local icons = config.get().icons
if task.status == 'done' then
return icons.done
elseif task.status == 'cancelled' then
return icons.cancelled
elseif task.status == 'wip' then
return icons.wip
elseif task.status == 'blocked' then
return icons.blocked
elseif task.priority > 0 then
return icons.priority
end
return icons.pending
end end
---@param tasks pending.Task[] ---@param tasks pending.Task[]
local function sort_tasks(tasks) local function sort_tasks(tasks)
table.sort(tasks, function(a, b) table.sort(tasks, function(a, b)
local ra = status_rank[a.status] or 1
local rb = status_rank[b.status] or 1
if ra ~= rb then
return ra < rb
end
if a.priority ~= b.priority then if a.priority ~= b.priority then
return a.priority > b.priority return a.priority > b.priority
end end
@ -45,12 +109,23 @@ local function sort_tasks(tasks)
end) end)
end end
---@param tasks pending.Task[] ---@type table<string, fun(a: pending.Task, b: pending.Task): boolean?>
local function sort_tasks_priority(tasks) local sort_key_comparators = {
table.sort(tasks, function(a, b) status = function(a, b)
local ra = status_rank[a.status] or 1
local rb = status_rank[b.status] or 1
if ra ~= rb then
return ra < rb
end
return nil
end,
priority = function(a, b)
if a.priority ~= b.priority then if a.priority ~= b.priority then
return a.priority > b.priority return a.priority > b.priority
end end
return nil
end,
due = function(a, b)
local a_due = a.due or '' local a_due = a.due or ''
local b_due = b.due or '' local b_due = b.due or ''
if a_due ~= b_due then if a_due ~= b_due then
@ -62,18 +137,67 @@ local function sort_tasks_priority(tasks)
end end
return a_due < b_due return a_due < b_due
end end
return nil
end,
order = function(a, b)
if a.order ~= b.order then if a.order ~= b.order then
return a.order < b.order return a.order < b.order
end end
return nil
end,
id = function(a, b)
if a.id ~= b.id then
return a.id < b.id return a.id < b.id
end) end
return nil
end,
age = function(a, b)
if a.id ~= b.id then
return a.id < b.id
end
return nil
end,
}
---@return fun(a: pending.Task, b: pending.Task): boolean
local function build_queue_comparator()
local log = require('pending.log')
local keys = config.get().view.queue.sort or { 'status', 'priority', 'due', 'order', 'id' }
local comparators = {}
local unknown = {}
for _, key in ipairs(keys) do
local cmp = sort_key_comparators[key]
if cmp then
table.insert(comparators, cmp)
else
table.insert(unknown, key)
end
end
if #unknown > 0 then
local label = #unknown == 1 and 'unknown queue sort key: ' or 'unknown queue sort keys: '
log.warn(label .. table.concat(unknown, ', '))
end
return function(a, b)
for _, cmp in ipairs(comparators) do
local result = cmp(a, b)
if result ~= nil then
return result
end
end
return false
end
end
---@param tasks pending.Task[]
local function sort_tasks_priority(tasks)
local cmp = build_queue_comparator()
table.sort(tasks, cmp)
end end
---@param tasks pending.Task[] ---@param tasks pending.Task[]
---@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 = {}
@ -87,14 +211,14 @@ function M.category_view(tasks)
by_cat[cat] = {} by_cat[cat] = {}
done_by_cat[cat] = {} done_by_cat[cat] = {}
end end
if task.status == 'done' then if task.status == 'done' or task.status == 'deleted' or task.status == 'cancelled' then
table.insert(done_by_cat[cat], task) table.insert(done_by_cat[cat], task)
else else
table.insert(by_cat[cat], task) table.insert(by_cat[cat], task)
end end
end end
local cfg_order = config.get().category_order local cfg_order = config.get().view.category.order
if cfg_order and #cfg_order > 0 then if cfg_order and #cfg_order > 0 then
local ordered = {} local ordered = {}
local seen = {} local seen = {}
@ -125,7 +249,7 @@ function M.category_view(tasks)
table.insert(lines, '') table.insert(lines, '')
table.insert(meta, { type = 'blank' }) table.insert(meta, { type = 'blank' })
end end
table.insert(lines, '## ' .. cat) table.insert(lines, '# ' .. cat)
table.insert(meta, { type = 'header', category = cat }) table.insert(meta, { type = 'header', category = cat })
local all = {} local all = {}
@ -138,8 +262,9 @@ function M.category_view(tasks)
for _, task in ipairs(all) do for _, task in ipairs(all) do
local prefix = '/' .. task.id .. '/' local prefix = '/' .. task.id .. '/'
local state = task.status == 'done' and 'x' or (task.priority > 0 and '!' or ' ') local state = state_char(task)
local line = prefix .. '- [' .. state .. '] ' .. task.description local line = prefix .. '- [' .. state .. '] ' .. task.description
local prefix_len = #prefix + #('- [' .. state .. '] ')
table.insert(lines, line) table.insert(lines, line)
table.insert(meta, { table.insert(meta, {
type = 'task', type = 'task',
@ -148,7 +273,14 @@ 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 task.due < today or nil, priority = task.priority,
overdue = task.status ~= 'done'
and task.status ~= 'cancelled'
and task.due ~= nil
and parse.is_overdue(task.due)
or nil,
recur = task.recur,
forge_spans = compute_forge_spans(task, prefix_len),
}) })
end end
end end
@ -160,12 +292,11 @@ 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 = {}
for _, task in ipairs(tasks) do for _, task in ipairs(tasks) do
if task.status == 'done' then if task.status == 'done' or task.status == 'cancelled' then
table.insert(done, task) table.insert(done, task)
else else
table.insert(pending, task) table.insert(pending, task)
@ -188,8 +319,9 @@ function M.priority_view(tasks)
for _, task in ipairs(all) do for _, task in ipairs(all) do
local prefix = '/' .. task.id .. '/' local prefix = '/' .. task.id .. '/'
local state = task.status == 'done' and 'x' or (task.priority > 0 and '!' or ' ') local state = state_char(task)
local line = prefix .. '- [' .. state .. '] ' .. task.description local line = prefix .. '- [' .. state .. '] ' .. task.description
local prefix_len = #prefix + #('- [' .. state .. '] ')
table.insert(lines, line) table.insert(lines, line)
table.insert(meta, { table.insert(meta, {
type = 'task', type = 'task',
@ -198,8 +330,18 @@ 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 task.due < today or nil, priority = task.priority,
overdue = task.status ~= 'done'
and task.status ~= 'cancelled'
and task.due ~= nil
and parse.is_overdue(task.due)
or nil,
show_category = true, show_category = true,
recur = task.recur,
forge_ref = task._extra and task._extra._forge_ref or nil,
forge_cache = task._extra and task._extra._forge_cache or nil,
forge_spans = compute_forge_spans(task, prefix_len),
has_notes = task.notes ~= nil and task.notes ~= '',
}) })
end end

View file

@ -3,16 +3,349 @@ 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 ck = cfg.category_syntax or 'cat'
local dk = cfg.date_syntax or 'due'
local rk = cfg.recur_syntax or 'rec'
return {
dk .. ':',
ck .. ':',
rk .. ':',
'+!',
'+!!',
'+!!!',
'-!',
'-' .. dk,
'-' .. ck,
'-' .. 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
---@return string[]
local function complete_add(arg_lead)
local cfg = require('pending.config').get()
local dk = cfg.date_syntax or 'due'
local rk = cfg.recur_syntax or 'rec'
local ck = cfg.category_syntax or 'cat'
local prefix = arg_lead:match('^(' .. vim.pesc(dk) .. ':)(.*)$')
if prefix then
local after_colon = arg_lead:sub(#prefix + 1)
local result = {}
for _, d in ipairs(edit_date_values()) do
if d:find(after_colon, 1, true) == 1 then
table.insert(result, prefix .. d)
end
end
return result
end
local rec_prefix = arg_lead:match('^(' .. vim.pesc(rk) .. ':)(.*)$')
if rec_prefix then
local after_colon = arg_lead:sub(#rec_prefix + 1)
local result = {}
for _, p in ipairs(edit_recur_values()) do
if p:find(after_colon, 1, true) == 1 then
table.insert(result, rec_prefix .. p)
end
end
return result
end
local cat_prefix = arg_lead:match('^(' .. vim.pesc(ck) .. ':)(.*)$')
if cat_prefix then
local after_colon = arg_lead:sub(#cat_prefix + 1)
local store = require('pending.store')
local s = store.new(store.resolve_path())
s:load()
local seen = {}
local cats = {}
for _, task in ipairs(s:active_tasks()) do
if task.category and not seen[task.category] then
seen[task.category] = true
table.insert(cats, task.category)
end
end
table.sort(cats)
local result = {}
for _, c in ipairs(cats) do
if c:find(after_colon, 1, true) == 1 then
table.insert(result, cat_prefix .. c)
end
end
return result
end
return {}
end
---@param arg_lead string
---@param cmd_line string
---@return string[]
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 ck = cfg.category_syntax or 'cat'
local cat_prefix = arg_lead:match('^(' .. vim.pesc(ck) .. ':)(.*)$')
if cat_prefix then
local after_colon = arg_lead:sub(#cat_prefix + 1)
local store = require('pending.store')
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 subcmds = { 'add', 'sync', 'archive', 'due', 'undo' } local pending = require('pending')
local subcmds = { 'add', 'archive', 'auth', 'done', 'due', 'edit', 'filter', '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 vim.tbl_filter(function(s) return filter_candidates(arg_lead, subcmds)
return s:find(arg_lead, 1, true) == 1 end
end, subcmds) if cmd_line:match('^Pending%s+filter') then
local after_filter = cmd_line:match('^Pending%s+filter%s+(.*)') or ''
local used = {}
for word in after_filter:gmatch('%S+') do
used[word] = true
end
local candidates = {
'clear',
'overdue',
'today',
'priority',
'done',
'pending',
'wip',
'blocked',
'cancelled',
}
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
local ck = (require('pending.config').get().category_syntax or 'cat')
table.insert(candidates, ck .. ':' .. 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+add%s') then
return complete_add(arg_lead)
end
if cmd_line:match('^Pending%s+archive%s') then
return filter_candidates(arg_lead, { '7d', '2w', '30d', '3m', '6m', '1y' })
end
if cmd_line:match('^Pending%s+done%s') then
local store = require('pending.store')
local s = store.new(store.resolve_path())
s:load()
local ids = {}
for _, task in ipairs(s:active_tasks()) do
table.insert(ids, tostring(task.id))
end
return filter_candidates(arg_lead, ids)
end
if cmd_line:match('^Pending%s+edit') then
return complete_edit(arg_lead, cmd_line)
end
if cmd_line:match('^Pending%s+auth') then
local after_auth = cmd_line:match('^Pending%s+auth%s+(.*)') or ''
local parts = {}
for w in after_auth:gmatch('%S+') do
table.insert(parts, w)
end
local trailing = after_auth:match('%s$')
if #parts == 0 or (#parts == 1 and not trailing) then
local auth_names = {}
for _, b in ipairs(pending.sync_backends()) do
local mod = pending.resolve_backend(b)
if mod and type(mod.auth) == 'function' then
table.insert(auth_names, b)
end
end
return filter_candidates(arg_lead, auth_names)
end
local backend_name = parts[1]
if #parts == 1 or (#parts == 2 and not trailing) then
local mod = pending.resolve_backend(backend_name)
if mod and type(mod.auth_complete) == 'function' then
return filter_candidates(arg_lead, mod.auth_complete())
end
return {}
end
return {}
end
local backend_set = pending.sync_backend_set()
local matched_backend = cmd_line:match('^Pending%s+(%S+)')
if matched_backend and backend_set[matched_backend] then
local after_backend = cmd_line:match('^Pending%s+%S+%s+(.*)')
if not after_backend then
return {}
end
local mod = pending.resolve_backend(matched_backend)
if not mod then
return {}
end
local actions = {}
for k, v in pairs(mod) do
if
type(v) == 'function'
and k:sub(1, 1) ~= '_'
and k ~= 'health'
and k ~= 'auth'
and k ~= 'auth_complete'
then
table.insert(actions, k)
end
end
table.sort(actions)
return filter_candidates(arg_lead, actions)
end end
return {} return {}
end, end,
@ -22,6 +355,10 @@ 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)
@ -37,3 +374,115 @@ 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-category)', function()
require('pending').prompt_category()
end)
vim.keymap.set('n', '<Plug>(pending-recur)', function()
require('pending').prompt_recur()
end)
vim.keymap.set('n', '<Plug>(pending-move-down)', function()
require('pending').move_task('down')
end)
vim.keymap.set('n', '<Plug>(pending-move-up)', function()
require('pending').move_task('up')
end)
vim.keymap.set('n', '<Plug>(pending-wip)', function()
require('pending').toggle_status('wip')
end)
vim.keymap.set('n', '<Plug>(pending-blocked)', function()
require('pending').toggle_status('blocked')
end)
vim.keymap.set('n', '<Plug>(pending-cancelled)', function()
require('pending').toggle_status('cancelled')
end)
vim.keymap.set('n', '<Plug>(pending-edit-notes)', function()
require('pending').open_detail()
end)
vim.keymap.set('n', '<Plug>(pending-priority-up)', function()
require('pending').increment_priority()
end)
vim.keymap.set('n', '<Plug>(pending-priority-down)', function()
require('pending').decrement_priority()
end)
vim.keymap.set('x', '<Plug>(pending-priority-up-visual)', function()
vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes('<Esc>', true, false, true), 'nx', false)
require('pending').increment_priority_visual()
end)
vim.keymap.set('x', '<Plug>(pending-priority-down-visual)', function()
vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes('<Esc>', true, false, true), 'nx', false)
require('pending').decrement_priority_visual()
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, {})

10
scripts/ci.sh Executable file
View file

@ -0,0 +1,10 @@
#!/bin/sh
set -eu
nix develop --command stylua --check .
git ls-files '*.lua' | xargs nix develop --command selene --display-style quiet
nix develop --command prettier --check .
nix fmt
git diff --exit-code -- '*.nix'
nix develop --command lua-language-server --check lua --configpath "$(pwd)/.luarc.json" --checklevel=Warning
nix develop --command busted

View file

@ -1,87 +1,115 @@
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 = require('pending') local pending
local ui_input_orig
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()
store.unload() package.loaded['pending'] = nil
store.load() pending = require('pending')
pending.store():load()
ui_input_orig = vim.ui.input
end) end)
after_each(function() after_each(function()
vim.ui.input = ui_input_orig
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)
local function auto_confirm_y()
vim.ui.input = function(_, on_confirm)
on_confirm('y')
end
end
local function auto_confirm_n()
vim.ui.input = function(_, on_confirm)
on_confirm('n')
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 t = store.add({ description = 'Old done task' }) auto_confirm_y()
store.update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' }) local s = pending.store()
local t = s:add({ description = 'Old done task' })
s:update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
pending.archive() pending.archive()
assert.are.equal(0, #store.active_tasks()) assert.are.equal(0, #s: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()
auto_confirm_y()
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 = store.add({ description = 'Recent done task' }) local t = s:add({ description = 'Recent done task' })
store.update(t.id, { status = 'done', ['end'] = recent_end }) s:update(t.id, { status = 'done', ['end'] = recent_end })
pending.archive() pending.archive()
local active = store.active_tasks() local active = s: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 duration string 7d', function()
auto_confirm_y()
local s = pending.store()
local eight_days_ago = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (8 * 86400)) local eight_days_ago = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (8 * 86400))
local t = store.add({ description = 'Old for 7 days' }) local t = s:add({ description = 'Old for 7 days' })
store.update(t.id, { status = 'done', ['end'] = eight_days_ago }) s:update(t.id, { status = 'done', ['end'] = eight_days_ago })
pending.archive(7) pending.archive('7d')
assert.are.equal(0, #store.active_tasks()) assert.are.equal(0, #s:active_tasks())
end) end)
it('keeps tasks within the custom day cutoff', function() it('respects duration string 2w', function()
auto_confirm_y()
local s = pending.store()
local fifteen_days_ago = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (15 * 86400))
local t = s:add({ description = 'Old for 2 weeks' })
s:update(t.id, { status = 'done', ['end'] = fifteen_days_ago })
pending.archive('2w')
assert.are.equal(0, #s:active_tasks())
end)
it('respects duration string 2m', function()
auto_confirm_y()
local s = pending.store()
local t = s:add({ description = 'Old for 2 months' })
s:update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
pending.archive('2m')
assert.are.equal(0, #s:active_tasks())
end)
it('respects bare integer as days (backwards compat)', function()
auto_confirm_y()
local s = pending.store()
local eight_days_ago = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (8 * 86400))
local t = s:add({ description = 'Old for 7 days' })
s:update(t.id, { status = 'done', ['end'] = eight_days_ago })
pending.archive('7')
assert.are.equal(0, #s:active_tasks())
end)
it('keeps tasks within the custom duration cutoff', function()
auto_confirm_y()
local s = pending.store()
local five_days_ago = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (5 * 86400)) local five_days_ago = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (5 * 86400))
local t = store.add({ description = 'Recent for 7 days' }) local t = s:add({ description = 'Recent for 7 days' })
store.update(t.id, { status = 'done', ['end'] = five_days_ago }) s:update(t.id, { status = 'done', ['end'] = five_days_ago })
pending.archive(7) pending.archive('7d')
local active = store.active_tasks() local active = s:active_tasks()
assert.are.equal(1, #active) assert.are.equal(1, #active)
end) end)
it('never archives pending tasks regardless of age', function() it('errors on invalid duration input', function()
store.add({ description = 'Still pending' })
pending.archive()
local active = store.active_tasks()
assert.are.equal(1, #active)
assert.are.equal('pending', active[1].status)
end)
it('removes deleted tasks past the cutoff', function()
local t = store.add({ description = 'Old deleted task' })
store.update(t.id, { status = 'deleted', ['end'] = '2020-01-01T00:00:00Z' })
pending.archive()
local all = store.tasks()
assert.are.equal(0, #all)
end)
it('keeps deleted tasks within the cutoff', function()
local recent_end = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (5 * 86400))
local t = store.add({ description = 'Recent deleted' })
store.update(t.id, { status = 'deleted', ['end'] = recent_end })
pending.archive()
local all = store.tasks()
assert.are.equal(1, #all)
end)
it('reports the correct count in vim.notify', function()
local messages = {} local messages = {}
local orig_notify = vim.notify local orig_notify = vim.notify
vim.notify = function(msg, ...) vim.notify = function(msg, ...)
@ -89,11 +117,120 @@ describe('archive', function()
return orig_notify(msg, ...) return orig_notify(msg, ...)
end end
local t1 = store.add({ description = 'Old 1' }) local s = pending.store()
local t2 = store.add({ description = 'Old 2' }) local t = s:add({ description = 'Task' })
store.add({ description = 'Keep' }) s:update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
store.update(t1.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' }) pending.archive('xyz')
store.update(t2.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
vim.notify = orig_notify
assert.are.equal(1, #s:tasks())
local found = false
for _, msg in ipairs(messages) do
if msg:find('Invalid duration') then
found = true
break
end
end
assert.is_true(found)
end)
it('never archives pending tasks regardless of age', function()
auto_confirm_y()
local s = pending.store()
s:add({ description = 'Still pending' })
pending.archive()
local active = s:active_tasks()
assert.are.equal(1, #active)
assert.are.equal('pending', active[1].status)
end)
it('removes deleted tasks past the cutoff', function()
auto_confirm_y()
local s = pending.store()
local t = s:add({ description = 'Old deleted task' })
s:update(t.id, { status = 'deleted', ['end'] = '2020-01-01T00:00:00Z' })
pending.archive()
local all = s:tasks()
assert.are.equal(0, #all)
end)
it('keeps deleted tasks within the cutoff', function()
auto_confirm_y()
local s = pending.store()
local recent_end = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (5 * 86400))
local t = s:add({ description = 'Recent deleted' })
s:update(t.id, { status = 'deleted', ['end'] = recent_end })
pending.archive()
local all = s:tasks()
assert.are.equal(1, #all)
end)
it('skips confirmation and reports when no tasks match', function()
local input_called = false
vim.ui.input = function()
input_called = true
end
local messages = {}
local orig_notify = vim.notify
vim.notify = function(msg, ...)
table.insert(messages, msg)
return orig_notify(msg, ...)
end
local s = pending.store()
s:add({ description = 'Still pending' })
pending.archive()
vim.notify = orig_notify
assert.is_false(input_called)
local found = false
for _, msg in ipairs(messages) do
if msg:find('No tasks to archive') then
found = true
break
end
end
assert.is_true(found)
end)
it('does not archive when user declines confirmation', function()
auto_confirm_n()
local s = pending.store()
local t = s:add({ description = 'Old done task' })
s:update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
pending.archive()
assert.are.equal(1, #s:tasks())
end)
it('does not archive when user cancels confirmation (nil)', function()
vim.ui.input = function(_, on_confirm)
on_confirm(nil)
end
local s = pending.store()
local t = s:add({ description = 'Old done task' })
s:update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
pending.archive()
assert.are.equal(1, #s:tasks())
end)
it('reports the correct count in vim.notify', function()
auto_confirm_y()
local s = pending.store()
local messages = {}
local orig_notify = vim.notify
vim.notify = function(msg, ...)
table.insert(messages, msg)
return orig_notify(msg, ...)
end
local t1 = s:add({ description = 'Old 1' })
local t2 = s:add({ description = 'Old 2' })
s:add({ description = 'Keep' })
s:update(t1.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
s:update(t2.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
pending.archive() pending.archive()
@ -109,17 +246,19 @@ describe('archive', function()
assert.is_true(found) assert.is_true(found)
end) end)
it('leaves only kept tasks in store.active_tasks after archive', function() it('leaves only kept tasks in store after archive', function()
local t1 = store.add({ description = 'Old done' }) auto_confirm_y()
store.add({ description = 'Keep pending' }) local s = pending.store()
local t1 = s:add({ description = 'Old done' })
s:add({ description = 'Keep pending' })
local recent_end = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (5 * 86400)) local recent_end = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (5 * 86400))
local t3 = store.add({ description = 'Keep recent done' }) local t3 = s:add({ description = 'Keep recent done' })
store.update(t1.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' }) s:update(t1.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
store.update(t3.id, { status = 'done', ['end'] = recent_end }) s:update(t3.id, { status = 'done', ['end'] = recent_end })
pending.archive() pending.archive()
local active = store.active_tasks() local active = s: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
@ -130,11 +269,29 @@ 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 t = store.add({ description = 'Archived task' }) auto_confirm_y()
store.update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' }) local s = pending.store()
local t = s:add({ description = 'Archived task' })
s:update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
pending.archive() pending.archive()
store.unload() s:load()
store.load() assert.are.equal(0, #s:active_tasks())
assert.are.equal(0, #store.active_tasks()) end)
it('includes the duration in the confirmation prompt', function()
local prompt_text
vim.ui.input = function(opts, on_confirm)
prompt_text = opts.prompt
on_confirm('n')
end
local s = pending.store()
local t = s:add({ description = 'Old' })
s:update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
pending.archive('3w')
assert.is_not_nil(prompt_text)
assert.truthy(prompt_text:find('21d'))
assert.truthy(prompt_text:find('1 task'))
end) end)
end) end)

173
spec/complete_spec.lua Normal file
View file

@ -0,0 +1,173 @@
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)

402
spec/detail_spec.lua Normal file
View file

@ -0,0 +1,402 @@
require('spec.helpers')
local config = require('pending.config')
describe('detail frontmatter', function()
local buffer
local tmpdir
before_each(function()
tmpdir = vim.fn.tempname()
vim.fn.mkdir(tmpdir, 'p')
vim.g.pending = { data_path = tmpdir .. '/tasks.json' }
config.reset()
package.loaded['pending'] = nil
package.loaded['pending.buffer'] = nil
buffer = require('pending.buffer')
end)
after_each(function()
vim.fn.delete(tmpdir, 'rf')
vim.g.pending = nil
config.reset()
package.loaded['pending'] = nil
package.loaded['pending.buffer'] = nil
end)
describe('build_detail_frontmatter', function()
it('renders status and priority for minimal task', function()
local lines = buffer._build_detail_frontmatter({
id = 1,
description = 'Test',
status = 'pending',
priority = 0,
entry = '',
modified = '',
order = 0,
})
assert.are.equal(2, #lines)
assert.are.equal('Status: pending', lines[1])
assert.are.equal('Priority: 0', lines[2])
end)
it('renders all fields', function()
local lines = buffer._build_detail_frontmatter({
id = 1,
description = 'Test',
status = 'wip',
priority = 2,
category = 'Work',
due = '2026-03-15',
recur = 'weekly',
entry = '',
modified = '',
order = 0,
})
assert.are.equal(5, #lines)
assert.are.equal('Status: wip', lines[1])
assert.are.equal('Priority: 2', lines[2])
assert.are.equal('Category: Work', lines[3])
assert.are.equal('Due: 2026-03-15', lines[4])
assert.are.equal('Recur: weekly', lines[5])
end)
it('prefixes recur with ! for completion mode', function()
local lines = buffer._build_detail_frontmatter({
id = 1,
description = 'Test',
status = 'pending',
priority = 0,
recur = 'daily',
recur_mode = 'completion',
entry = '',
modified = '',
order = 0,
})
assert.are.equal('Recur: !daily', lines[3])
end)
it('omits optional fields when absent', function()
local lines = buffer._build_detail_frontmatter({
id = 1,
description = 'Test',
status = 'done',
priority = 1,
entry = '',
modified = '',
order = 0,
})
assert.are.equal(2, #lines)
assert.are.equal('Status: done', lines[1])
assert.are.equal('Priority: 1', lines[2])
end)
end)
describe('parse_detail_frontmatter', function()
it('parses minimal frontmatter', function()
local lines = {
'# My task',
'Status: pending',
'Priority: 0',
'---',
'some notes',
}
local sep, fields, err = buffer._parse_detail_frontmatter(lines)
assert.is_nil(err)
assert.are.equal(4, sep)
assert.are.equal('My task', fields.description)
assert.are.equal('pending', fields.status)
assert.are.equal(0, fields.priority)
end)
it('parses all fields', function()
local lines = {
'# Fix the bug',
'Status: wip',
'Priority: 2',
'Category: Work',
'Due: 2026-03-15',
'Recur: weekly',
'---',
}
local sep, fields, err = buffer._parse_detail_frontmatter(lines)
assert.is_nil(err)
assert.are.equal(7, sep)
assert.are.equal('Fix the bug', fields.description)
assert.are.equal('wip', fields.status)
assert.are.equal(2, fields.priority)
assert.are.equal('Work', fields.category)
assert.are.equal('2026-03-15', fields.due)
assert.are.equal('weekly', fields.recur)
end)
it('resolves due date keywords', function()
local lines = {
'# Task',
'Status: pending',
'Priority: 0',
'Due: tomorrow',
'---',
}
local _, fields, err = buffer._parse_detail_frontmatter(lines)
assert.is_nil(err)
local today = os.date('*t') --[[@as osdate]]
local expected = os.date(
'%Y-%m-%d',
os.time({ year = today.year, month = today.month, day = today.day + 1 })
)
assert.are.equal(expected, fields.due)
end)
it('parses completion-mode recurrence', function()
local lines = {
'# Task',
'Status: pending',
'Priority: 0',
'Recur: !daily',
'---',
}
local _, fields, err = buffer._parse_detail_frontmatter(lines)
assert.is_nil(err)
assert.are.equal('daily', fields.recur)
assert.are.equal('completion', fields.recur_mode)
end)
it('clears optional fields when lines removed', function()
local lines = {
'# Task',
'Status: done',
'Priority: 1',
'---',
}
local _, fields, err = buffer._parse_detail_frontmatter(lines)
assert.is_nil(err)
assert.are.equal(vim.NIL, fields.category)
assert.are.equal(vim.NIL, fields.due)
assert.are.equal(vim.NIL, fields.recur)
end)
it('skips blank lines in frontmatter', function()
local lines = {
'# Task',
'Status: pending',
'',
'Priority: 0',
'---',
}
local _, fields, err = buffer._parse_detail_frontmatter(lines)
assert.is_nil(err)
assert.are.equal('pending', fields.status)
assert.are.equal(0, fields.priority)
end)
it('errors on missing separator', function()
local lines = {
'# Task',
'Status: pending',
'Priority: 0',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('missing separator'))
end)
it('errors on missing title', function()
local lines = {
'',
'Status: pending',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('missing or empty title'))
end)
it('errors on empty title', function()
local lines = {
'# ',
'Status: pending',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('missing or empty title'))
end)
it('errors on invalid status', function()
local lines = {
'# Task',
'Status: bogus',
'Priority: 0',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('invalid status'))
end)
it('errors on negative priority', function()
local lines = {
'# Task',
'Status: pending',
'Priority: -1',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('invalid priority'))
end)
it('errors on non-integer priority', function()
local lines = {
'# Task',
'Status: pending',
'Priority: 1.5',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('invalid priority'))
end)
it('errors on priority exceeding max', function()
local lines = {
'# Task',
'Status: pending',
'Priority: 4',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('max is 3'))
end)
it('errors on invalid due date', function()
local lines = {
'# Task',
'Status: pending',
'Priority: 0',
'Due: notadate',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('invalid due date'))
end)
it('errors on empty due value', function()
local lines = {
'# Task',
'Due: ',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('empty due value'))
end)
it('errors on invalid recurrence', function()
local lines = {
'# Task',
'Recur: nope',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('invalid recurrence'))
end)
it('errors on empty recur value', function()
local lines = {
'# Task',
'Recur: ',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('empty recur value'))
end)
it('errors on empty category value', function()
local lines = {
'# Task',
'Category: ',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('empty category'))
end)
it('errors on unknown field', function()
local lines = {
'# Task',
'Status: pending',
'Foo: bar',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('unknown field: foo'))
end)
it('errors on duplicate field', function()
local lines = {
'# Task',
'Status: pending',
'Status: done',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('duplicate field'))
end)
it('errors on malformed frontmatter line', function()
local lines = {
'# Task',
'not a key value pair',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('invalid frontmatter line'))
end)
it('is case-insensitive for field keys', function()
local lines = {
'# Task',
'STATUS: wip',
'PRIORITY: 1',
'CATEGORY: Work',
'---',
}
local _, fields, err = buffer._parse_detail_frontmatter(lines)
assert.is_nil(err)
assert.are.equal('wip', fields.status)
assert.are.equal(1, fields.priority)
assert.are.equal('Work', fields.category)
end)
it('accepts datetime due format', function()
local lines = {
'# Task',
'Due: 2026-03-15T14:00',
'---',
}
local _, fields, err = buffer._parse_detail_frontmatter(lines)
assert.is_nil(err)
assert.are.equal('2026-03-15T14:00', fields.due)
end)
it('respects custom max_priority', function()
vim.g.pending = { data_path = tmpdir .. '/tasks.json', max_priority = 5 }
config.reset()
local lines = {
'# Task',
'Priority: 5',
'---',
}
local _, fields, err = buffer._parse_detail_frontmatter(lines)
assert.is_nil(err)
assert.are.equal(5, fields.priority)
end)
it('updates description from title line', function()
local lines = {
'# Updated title',
'Status: pending',
'Priority: 0',
'---',
}
local _, fields, err = buffer._parse_detail_frontmatter(lines)
assert.is_nil(err)
assert.are.equal('Updated title', fields.description)
end)
end)
end)

View file

@ -1,35 +1,31 @@
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')
vim.g.pending = { data_path = tmpdir .. '/tasks.json' } s = store.new(tmpdir .. '/tasks.json')
config.reset() s:load()
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)
@ -48,7 +44,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)
@ -60,7 +56,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)
@ -69,9 +65,28 @@ 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].recur)
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].recur)
assert.are.equal('completion', result[2].recur_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)
@ -84,140 +99,321 @@ 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) diff.apply(lines, s)
store.unload() s:load()
store.load() local tasks = s:active_tasks()
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()
store.add({ description = 'Keep me' }) s:add({ description = 'Keep me' })
store.add({ description = 'Delete me' }) s:add({ description = 'Delete me' })
store.save() s:save()
local lines = { local lines = {
'## Inbox', '# Inbox',
'/1/- [ ] Keep me', '/1/- [ ] Keep me',
} }
diff.apply(lines) diff.apply(lines, s)
store.unload() s:load()
store.load() local active = s:active_tasks()
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 = store.get(2) local deleted = s: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()
store.add({ description = 'Original' }) s:add({ description = 'Original' })
store.save() s:save()
local lines = { local lines = {
'## Inbox', '# Inbox',
'/1/- [ ] Renamed', '/1/- [ ] Renamed',
} }
diff.apply(lines) diff.apply(lines, s)
store.unload() s:load()
store.load() local task = s:get(1)
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 = store.add({ description = 'Original', category = 'Inbox' }) local t = s:add({ description = 'Original', category = 'Inbox' })
t.modified = '2020-01-01T00:00:00Z' t.modified = '2020-01-01T00:00:00Z'
store.save() s:save()
local lines = { local lines = {
'## Inbox', '# Inbox',
'/1/- [ ] Renamed', '/1/- [ ] Renamed',
} }
diff.apply(lines) diff.apply(lines, s)
store.unload() s:load()
store.load() local task = s:get(1)
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()
store.add({ description = 'Original' }) s:add({ description = 'Original' })
store.save() s:save()
local lines = { local lines = {
'## Inbox', '# Inbox',
'/1/- [ ] Original', '/1/- [ ] Original',
'/1/- [ ] Copy of original', '/1/- [ ] Copy of original',
} }
diff.apply(lines) diff.apply(lines, s)
store.unload() s:load()
store.load() local tasks = s:active_tasks()
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()
store.add({ description = 'Moving task', category = 'Inbox' }) s:add({ description = 'Moving task', category = 'Inbox' })
store.save() s:save()
local lines = { local lines = {
'## Work', '# Work',
'/1/- [ ] Moving task', '/1/- [ ] Moving task',
} }
diff.apply(lines) diff.apply(lines, s)
store.unload() s:load()
store.load() local task = s:get(1)
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()
store.add({ description = 'Stable task', category = 'Inbox' }) s:add({ description = 'Stable task', category = 'Inbox' })
store.save() s:save()
local lines = { local lines = {
'## Inbox', '# Inbox',
'/1/- [ ] Stable task', '/1/- [ ] Stable task',
} }
diff.apply(lines) diff.apply(lines, s)
store.unload() s:load()
store.load() local modified_after_first = s:get(1).modified
local modified_after_first = store.get(1).modified 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(modified_after_first, task.modified) assert.are.equal(modified_after_first, task.modified)
end) end)
it('clears due when removed from buffer line', function() it('preserves due when not present in buffer line', function()
store.add({ description = 'Pay bill', due = '2026-03-15' }) s:add({ description = 'Pay bill', due = '2026-03-15' })
store.save() s:save()
local lines = { local lines = {
'## Inbox', '# Inbox',
'/1/- [ ] Pay bill', '/1/- [ ] Pay bill',
} }
diff.apply(lines) diff.apply(lines, s)
store.unload() s:load()
store.load() local task = s:get(1)
local task = store.get(1) assert.are.equal('2026-03-15', task.due)
assert.is_nil(task.due) end)
it('updates due when inline token is present', function()
s:add({ description = 'Pay bill', due = '2026-03-15' })
s:save()
local lines = {
'# Inbox',
'/1/- [ ] Pay bill due:2026-04-01',
}
diff.apply(lines, s)
s:load()
local task = s:get(1)
assert.are.equal('2026-04-01', task.due)
end)
it('stores recur field on new tasks from buffer', function()
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)
it('returns forge refs for new tasks', function()
local lines = {
'# Inbox',
'- [ ] Fix bug gh:user/repo#42',
}
local refs = diff.apply(lines, s)
assert.are.equal(1, #refs)
assert.are.equal('github', refs[1].forge)
assert.are.equal(42, refs[1].number)
end)
it('returns forge refs for changed refs on existing tasks', function()
s:add({
description = 'Fix bug gh:user/repo#1',
_extra = {
_forge_ref = {
forge = 'github',
owner = 'user',
repo = 'repo',
type = 'issue',
number = 1,
url = '',
},
},
})
s:save()
local lines = {
'# Todo',
'/1/- [ ] Fix bug gh:user/repo#99',
}
local refs = diff.apply(lines, s)
assert.are.equal(1, #refs)
assert.are.equal(99, refs[1].number)
end)
it('returns empty when forge ref is unchanged', function()
s:add({
description = 'Fix bug gh:user/repo#42',
_extra = {
_forge_ref = {
forge = 'github',
owner = 'user',
repo = 'repo',
type = 'issue',
number = 42,
url = '',
},
},
})
s:save()
local lines = {
'# Todo',
'/1/- [ ] Fix bug gh:user/repo#42',
}
local refs = diff.apply(lines, s)
assert.are.equal(0, #refs)
end)
it('returns empty for tasks without forge refs', function()
local lines = {
'# Inbox',
'- [ ] Plain task',
}
local refs = diff.apply(lines, s)
assert.are.equal(0, #refs)
end)
it('returns forge refs for duplicated tasks', function()
s:add({
description = 'Fix bug gh:user/repo#42',
_extra = {
_forge_ref = {
forge = 'github',
owner = 'user',
repo = 'repo',
type = 'issue',
number = 42,
url = '',
},
},
})
s:save()
local lines = {
'# Todo',
'/1/- [ ] Fix bug gh:user/repo#42',
'/1/- [ ] Fix bug gh:user/repo#42',
}
local refs = diff.apply(lines, s)
assert.are.equal(1, #refs)
assert.are.equal(42, refs[1].number)
end) end)
it('clears priority when [N] is removed from buffer line', function() it('clears priority when [N] is removed from buffer line', function()
store.add({ description = 'Task name', priority = 1 }) s:add({ description = 'Task name', priority = 1 })
store.save() s:save()
local lines = { local lines = {
'## Inbox', '# Inbox',
'/1/- [ ] Task name', '/1/- [ ] Task name',
} }
diff.apply(lines) diff.apply(lines, s)
store.unload() s:load()
store.load() local task = s:get(1)
local task = store.get(1)
assert.are.equal(0, task.priority) assert.are.equal(0, task.priority)
end) end)
it('sets priority from +!! token', function()
local lines = {
'# Inbox',
'- [ ] Pay bills +!!',
}
diff.apply(lines, s)
s:load()
local task = s:get(1)
assert.are.equal(2, task.priority)
end)
it('updates priority between non-zero values', function()
s:add({ description = 'Task name', priority = 2 })
s:save()
local lines = {
'# Inbox',
'/1/- [!] Task name',
}
diff.apply(lines, s)
s:load()
local task = s:get(1)
assert.are.equal(1, task.priority)
end)
it('parses metadata with forge ref on same line', function()
local lines = {
'# Inbox',
'- [ ] Fix bug due:2026-03-15 gh:user/repo#42',
}
diff.apply(lines, s)
s:load()
local task = s:get(1)
assert.are.equal('2026-03-15', task.due)
assert.is_not_nil(task._extra._forge_ref)
end)
end) end)
end) end)

329
spec/edit_spec.lua Normal file
View file

@ -0,0 +1,329 @@
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)

292
spec/filter_spec.lua Normal file
View file

@ -0,0 +1,292 @@
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)

658
spec/forge_spec.lua Normal file
View file

@ -0,0 +1,658 @@
require('spec.helpers')
local forge = require('pending.forge')
describe('forge', function()
describe('_parse_shorthand', function()
it('parses gh: shorthand', function()
local ref = forge._parse_shorthand('gh:user/repo#42')
assert.is_not_nil(ref)
assert.equals('github', ref.forge)
assert.equals('user', ref.owner)
assert.equals('repo', ref.repo)
assert.equals('issue', ref.type)
assert.equals(42, ref.number)
assert.equals('https://github.com/user/repo/issues/42', ref.url)
end)
it('parses gl: shorthand', function()
local ref = forge._parse_shorthand('gl:group/project#15')
assert.is_not_nil(ref)
assert.equals('gitlab', ref.forge)
assert.equals('group', ref.owner)
assert.equals('project', ref.repo)
assert.equals(15, ref.number)
end)
it('parses cb: shorthand', function()
local ref = forge._parse_shorthand('cb:user/repo#3')
assert.is_not_nil(ref)
assert.equals('codeberg', ref.forge)
assert.equals('user', ref.owner)
assert.equals('repo', ref.repo)
assert.equals(3, ref.number)
end)
it('handles hyphens and dots in owner/repo', function()
local ref = forge._parse_shorthand('gh:my-org/my.repo#100')
assert.is_not_nil(ref)
assert.equals('my-org', ref.owner)
assert.equals('my.repo', ref.repo)
end)
it('rejects invalid prefix', function()
assert.is_nil(forge._parse_shorthand('xx:user/repo#1'))
end)
it('parses bare gh: shorthand without number', function()
local ref = forge._parse_shorthand('gh:user/repo')
assert.is_not_nil(ref)
assert.equals('github', ref.forge)
assert.equals('user', ref.owner)
assert.equals('repo', ref.repo)
assert.equals('repo', ref.type)
assert.is_nil(ref.number)
assert.equals('https://github.com/user/repo', ref.url)
end)
it('parses bare gl: shorthand without number', function()
local ref = forge._parse_shorthand('gl:group/project')
assert.is_not_nil(ref)
assert.equals('gitlab', ref.forge)
assert.equals('group', ref.owner)
assert.equals('project', ref.repo)
assert.equals('repo', ref.type)
assert.is_nil(ref.number)
end)
it('rejects missing repo', function()
assert.is_nil(forge._parse_shorthand('gh:user#1'))
assert.is_nil(forge._parse_shorthand('gh:user'))
end)
end)
describe('_parse_github_url', function()
it('parses issue URL', function()
local ref = forge._parse_github_url('https://github.com/user/repo/issues/42')
assert.is_not_nil(ref)
assert.equals('github', ref.forge)
assert.equals('user', ref.owner)
assert.equals('repo', ref.repo)
assert.equals('issue', ref.type)
assert.equals(42, ref.number)
end)
it('parses pull request URL', function()
local ref = forge._parse_github_url('https://github.com/user/repo/pull/10')
assert.is_not_nil(ref)
assert.equals('pull_request', ref.type)
end)
it('rejects non-github URL', function()
assert.is_nil(forge._parse_github_url('https://example.com/user/repo/issues/1'))
end)
it('parses bare repo URL', function()
local ref = forge._parse_github_url('https://github.com/user/repo')
assert.is_not_nil(ref)
assert.equals('github', ref.forge)
assert.equals('user', ref.owner)
assert.equals('repo', ref.repo)
assert.equals('repo', ref.type)
assert.is_nil(ref.number)
end)
it('parses bare repo URL with trailing slash', function()
local ref = forge._parse_github_url('https://github.com/user/repo/')
assert.is_not_nil(ref)
assert.equals('repo', ref.type)
assert.is_nil(ref.number)
end)
end)
describe('_parse_gitlab_url', function()
it('parses issue URL', function()
local ref = forge._parse_gitlab_url('https://gitlab.com/group/project/-/issues/15')
assert.is_not_nil(ref)
assert.equals('gitlab', ref.forge)
assert.equals('group', ref.owner)
assert.equals('project', ref.repo)
assert.equals('issue', ref.type)
assert.equals(15, ref.number)
end)
it('parses merge request URL', function()
local ref = forge._parse_gitlab_url('https://gitlab.com/group/project/-/merge_requests/5')
assert.is_not_nil(ref)
assert.equals('merge_request', ref.type)
end)
it('handles nested groups', function()
local ref = forge._parse_gitlab_url('https://gitlab.com/org/sub/project/-/issues/1')
assert.is_not_nil(ref)
assert.equals('org/sub', ref.owner)
assert.equals('project', ref.repo)
end)
it('parses bare repo URL', function()
local ref = forge._parse_gitlab_url('https://gitlab.com/group/project')
assert.is_not_nil(ref)
assert.equals('gitlab', ref.forge)
assert.equals('group', ref.owner)
assert.equals('project', ref.repo)
assert.equals('repo', ref.type)
assert.is_nil(ref.number)
end)
end)
describe('_parse_codeberg_url', function()
it('parses issue URL', function()
local ref = forge._parse_codeberg_url('https://codeberg.org/user/repo/issues/3')
assert.is_not_nil(ref)
assert.equals('codeberg', ref.forge)
assert.equals('user', ref.owner)
assert.equals('repo', ref.repo)
assert.equals('issue', ref.type)
assert.equals(3, ref.number)
end)
it('parses pull URL', function()
local ref = forge._parse_codeberg_url('https://codeberg.org/user/repo/pulls/7')
assert.is_not_nil(ref)
assert.equals('pull_request', ref.type)
end)
it('parses bare repo URL', function()
local ref = forge._parse_codeberg_url('https://codeberg.org/user/repo')
assert.is_not_nil(ref)
assert.equals('codeberg', ref.forge)
assert.equals('user', ref.owner)
assert.equals('repo', ref.repo)
assert.equals('repo', ref.type)
assert.is_nil(ref.number)
end)
end)
describe('parse_ref', function()
it('dispatches shorthand', function()
local ref = forge.parse_ref('gh:user/repo#1')
assert.is_not_nil(ref)
assert.equals('github', ref.forge)
end)
it('dispatches GitHub URL', function()
local ref = forge.parse_ref('https://github.com/user/repo/issues/1')
assert.is_not_nil(ref)
assert.equals('github', ref.forge)
end)
it('dispatches GitLab URL', function()
local ref = forge.parse_ref('https://gitlab.com/group/project/-/issues/1')
assert.is_not_nil(ref)
assert.equals('gitlab', ref.forge)
end)
it('returns nil for non-forge token', function()
assert.is_nil(forge.parse_ref('hello'))
assert.is_nil(forge.parse_ref('due:tomorrow'))
end)
it('dispatches bare shorthand', function()
local ref = forge.parse_ref('gh:user/repo')
assert.is_not_nil(ref)
assert.equals('github', ref.forge)
assert.equals('repo', ref.type)
assert.is_nil(ref.number)
end)
end)
describe('find_refs', function()
it('finds a single shorthand ref', function()
local refs = forge.find_refs('Fix bug gh:user/repo#42')
assert.equals(1, #refs)
assert.equals('github', refs[1].ref.forge)
assert.equals(42, refs[1].ref.number)
assert.equals('gh:user/repo#42', refs[1].raw)
assert.equals(8, refs[1].start_byte)
assert.equals(23, refs[1].end_byte)
end)
it('finds multiple refs', function()
local refs = forge.find_refs('Fix gh:a/b#1 gh:c/d#2')
assert.equals(2, #refs)
assert.equals('a', refs[1].ref.owner)
assert.equals('c', refs[2].ref.owner)
end)
it('finds full URL refs', function()
local refs = forge.find_refs('Fix https://github.com/user/repo/issues/10')
assert.equals(1, #refs)
assert.equals('github', refs[1].ref.forge)
assert.equals(10, refs[1].ref.number)
end)
it('returns empty for no refs', function()
local refs = forge.find_refs('Fix the bug')
assert.equals(0, #refs)
end)
it('skips invalid forge-like tokens', function()
local refs = forge.find_refs('Fix the gh: prefix handling')
assert.equals(0, #refs)
end)
it('records correct byte offsets', function()
local refs = forge.find_refs('gh:a/b#1')
assert.equals(1, #refs)
assert.equals(0, refs[1].start_byte)
assert.equals(8, refs[1].end_byte)
end)
it('finds bare shorthand ref', function()
local refs = forge.find_refs('Fix gh:user/repo')
assert.equals(1, #refs)
assert.equals('github', refs[1].ref.forge)
assert.equals('repo', refs[1].ref.type)
assert.is_nil(refs[1].ref.number)
assert.equals('gh:user/repo', refs[1].raw)
assert.equals(4, refs[1].start_byte)
assert.equals(16, refs[1].end_byte)
end)
end)
describe('_api_args', function()
it('builds GitHub CLI args', function()
local args = forge._api_args({
forge = 'github',
owner = 'user',
repo = 'repo',
type = 'issue',
number = 42,
url = '',
})
assert.same({ 'gh', 'api', '/repos/user/repo/issues/42' }, args)
end)
it('builds GitLab CLI args for issue', function()
local args = forge._api_args({
forge = 'gitlab',
owner = 'group',
repo = 'project',
type = 'issue',
number = 15,
url = '',
})
assert.same({ 'glab', 'api', '/projects/group%2Fproject/issues/15' }, args)
end)
it('builds GitLab CLI args for merge request', function()
local args = forge._api_args({
forge = 'gitlab',
owner = 'group',
repo = 'project',
type = 'merge_request',
number = 5,
url = '',
})
assert.same({ 'glab', 'api', '/projects/group%2Fproject/merge_requests/5' }, args)
end)
it('builds Codeberg CLI args', function()
local args = forge._api_args({
forge = 'codeberg',
owner = 'user',
repo = 'repo',
type = 'issue',
number = 3,
url = '',
})
assert.same({ 'tea', 'api', '/repos/user/repo/issues/3' }, args)
end)
end)
describe('format_label', function()
it('formats with default format', function()
local text, hl = forge.format_label({
forge = 'github',
owner = 'user',
repo = 'repo',
type = 'issue',
number = 42,
url = '',
}, nil)
assert.truthy(text:find('user/repo#42'))
assert.equals('PendingForge', hl)
end)
it('uses closed highlight for closed state', function()
local _, hl = forge.format_label({
forge = 'github',
owner = 'user',
repo = 'repo',
type = 'issue',
number = 42,
url = '',
}, { state = 'closed', fetched_at = '2026-01-01T00:00:00Z' })
assert.equals('PendingForgeClosed', hl)
end)
it('formats bare repo ref without #N', function()
local text = forge.format_label({
forge = 'github',
owner = 'user',
repo = 'repo',
type = 'repo',
url = '',
}, nil)
assert.truthy(text:find('user/repo'))
assert.is_nil(text:find('#'))
end)
it('still formats numbered ref with #N', function()
local text = forge.format_label({
forge = 'github',
owner = 'user',
repo = 'repo',
type = 'issue',
number = 42,
url = '',
}, nil)
assert.truthy(text:find('user/repo#42'))
end)
it('uses closed highlight for merged state', function()
local _, hl = forge.format_label({
forge = 'gitlab',
owner = 'group',
repo = 'project',
type = 'merge_request',
number = 5,
url = '',
}, { state = 'merged', fetched_at = '2026-01-01T00:00:00Z' })
assert.equals('PendingForgeClosed', hl)
end)
end)
end)
describe('forge parse.body integration', function()
local parse = require('pending.parse')
it('keeps gh: shorthand in description', function()
local desc, meta = parse.body('Fix login bug gh:user/repo#42')
assert.equals('Fix login bug gh:user/repo#42', desc)
assert.is_nil(meta.forge_ref)
end)
it('keeps gl: shorthand in description', function()
local desc, meta = parse.body('Update docs gl:group/project#15')
assert.equals('Update docs gl:group/project#15', desc)
assert.is_nil(meta.forge_ref)
end)
it('keeps GitHub URL in description', function()
local desc, meta = parse.body('Fix bug https://github.com/user/repo/issues/10')
assert.equals('Fix bug https://github.com/user/repo/issues/10', desc)
assert.is_nil(meta.forge_ref)
end)
it('extracts due date but keeps forge ref in description', function()
local desc, meta = parse.body('Fix bug gh:user/repo#42 due:tomorrow')
assert.equals('Fix bug gh:user/repo#42', desc)
assert.is_not_nil(meta.due)
end)
it('extracts category but keeps forge ref in description', function()
local desc, meta = parse.body('Fix bug gh:user/repo#42 cat:Work')
assert.equals('Fix bug gh:user/repo#42', desc)
assert.equals('Work', meta.category)
end)
it('leaves non-forge tokens as description', function()
local desc, meta = parse.body('Fix the gh: prefix handling')
assert.equals('Fix the gh: prefix handling', desc)
assert.is_nil(meta.forge_ref)
end)
end)
describe('forge registry', function()
it('backends() returns all registered backends', function()
local backends = forge.backends()
assert.is_true(#backends >= 3)
local names = {}
for _, b in ipairs(backends) do
names[b.name] = true
end
assert.is_true(names['github'])
assert.is_true(names['gitlab'])
assert.is_true(names['codeberg'])
end)
it('register() with custom backend resolves URLs', function()
local custom = forge.gitea_forge({
name = 'mygitea',
shorthand = 'mg',
default_host = 'gitea.example.com',
})
forge.register(custom)
local ref = forge.parse_ref('https://gitea.example.com/alice/proj/issues/7')
assert.is_not_nil(ref)
assert.equals('mygitea', ref.forge)
assert.equals('alice', ref.owner)
assert.equals('proj', ref.repo)
assert.equals('issue', ref.type)
assert.equals(7, ref.number)
end)
it('register() with custom shorthand resolves', function()
local ref = forge._parse_shorthand('mg:alice/proj#7')
assert.is_not_nil(ref)
assert.equals('mygitea', ref.forge)
assert.equals('alice', ref.owner)
assert.equals('proj', ref.repo)
assert.equals(7, ref.number)
end)
it('_api_args dispatches to custom backend', function()
local args = forge._api_args({
forge = 'mygitea',
owner = 'alice',
repo = 'proj',
type = 'issue',
number = 7,
url = '',
})
assert.same({ 'tea', 'api', '/repos/alice/proj/issues/7' }, args)
end)
it('gitea_forge() creates a working backend', function()
local b = forge.gitea_forge({
name = 'forgejo',
shorthand = 'fj',
default_host = 'forgejo.example.com',
cli = 'forgejo-cli',
auth_cmd = 'forgejo-cli login',
})
assert.equals('forgejo', b.name)
assert.equals('fj', b.shorthand)
assert.equals('forgejo-cli', b.cli)
local ref = b:parse_url('https://forgejo.example.com/bob/repo/pulls/3')
assert.is_nil(ref)
forge.register(b)
ref = b:parse_url('https://forgejo.example.com/bob/repo/pulls/3')
assert.is_not_nil(ref)
assert.equals('forgejo', ref.forge)
assert.equals('pull_request', ref.type)
assert.equals(3, ref.number)
end)
end)
describe('custom forge prefixes', function()
local config = require('pending.config')
local complete = require('pending.complete')
it('parses custom-length shorthand (3+ chars)', function()
local custom = forge.gitea_forge({
name = 'customforge',
shorthand = 'cgf',
default_host = 'custom.example.com',
})
forge.register(custom)
local ref = forge._parse_shorthand('cgf:alice/proj#99')
assert.is_not_nil(ref)
assert.equals('customforge', ref.forge)
assert.equals('alice', ref.owner)
assert.equals('proj', ref.repo)
assert.equals(99, ref.number)
end)
it('parse_ref dispatches custom-length shorthand', function()
local ref = forge.parse_ref('cgf:alice/proj#5')
assert.is_not_nil(ref)
assert.equals('customforge', ref.forge)
assert.equals(5, ref.number)
end)
it('find_refs finds custom-length shorthand', function()
local refs = forge.find_refs('Fix cgf:alice/proj#12')
assert.equals(1, #refs)
assert.equals('customforge', refs[1].ref.forge)
assert.equals(12, refs[1].ref.number)
end)
it('completion returns entries for custom backends', function()
assert.is_true(complete._is_forge_source('cgf'))
end)
it('config shorthand override re-registers backend', function()
vim.g.pending = {
forge = {
github = { shorthand = 'github' },
},
}
config.reset()
forge._reset_instances()
local ref = forge._parse_shorthand('github:user/repo#1')
assert.is_not_nil(ref)
assert.equals('github', ref.forge)
assert.equals('user', ref.owner)
assert.equals('repo', ref.repo)
assert.equals(1, ref.number)
assert.is_nil(forge._parse_shorthand('gh:user/repo#1'))
vim.g.pending = nil
config.reset()
for _, b in ipairs(forge.backends()) do
if b.name == 'github' then
b.shorthand = 'gh'
end
end
forge._reset_instances()
end)
end)
describe('is_configured', function()
it('returns false when vim.g.pending is nil', function()
vim.g.pending = nil
assert.is_false(forge.is_configured('github'))
end)
it('returns false when forge key is absent', function()
vim.g.pending = { forge = { close = true } }
assert.is_false(forge.is_configured('github'))
vim.g.pending = nil
end)
it('returns true when forge key is present', function()
vim.g.pending = { forge = { github = {} } }
assert.is_true(forge.is_configured('github'))
assert.is_false(forge.is_configured('gitlab'))
vim.g.pending = nil
end)
it('returns true for non-empty forge config', function()
vim.g.pending = { forge = { gitlab = { icon = '' } } }
assert.is_true(forge.is_configured('gitlab'))
vim.g.pending = nil
end)
end)
describe('forge diff integration', function()
local store = require('pending.store')
local diff = require('pending.diff')
it('stores forge_ref in _extra on new task', function()
local tmp = os.tmpname()
local s = store.new(tmp)
s:load()
diff.apply({ '- [ ] Fix bug gh:user/repo#42' }, s)
local tasks = s:active_tasks()
assert.equals(1, #tasks)
assert.equals('Fix bug gh:user/repo#42', tasks[1].description)
assert.is_not_nil(tasks[1]._extra)
assert.is_not_nil(tasks[1]._extra._forge_ref)
assert.equals('github', tasks[1]._extra._forge_ref.forge)
assert.equals(42, tasks[1]._extra._forge_ref.number)
os.remove(tmp)
end)
it('stores forge_ref in _extra on existing task', function()
local tmp = os.tmpname()
local s = store.new(tmp)
s:load()
local task = s:add({ description = 'Fix bug' })
s:save()
diff.apply({ '/' .. task.id .. '/- [ ] Fix bug gh:user/repo#10' }, s)
local updated = s:get(task.id)
assert.equals('Fix bug gh:user/repo#10', updated.description)
assert.is_not_nil(updated._extra)
assert.is_not_nil(updated._extra._forge_ref)
assert.equals(10, updated._extra._forge_ref.number)
os.remove(tmp)
end)
it('preserves existing forge_ref when not in parsed line', function()
local tmp = os.tmpname()
local s = store.new(tmp)
s:load()
local task = s:add({
description = 'Fix bug',
_extra = {
_forge_ref = {
forge = 'github',
owner = 'a',
repo = 'b',
type = 'issue',
number = 1,
url = '',
},
},
})
s:save()
diff.apply({ '/' .. task.id .. '/- [ ] Fix bug' }, s)
local updated = s:get(task.id)
assert.is_not_nil(updated._extra._forge_ref)
assert.equals(1, updated._extra._forge_ref.number)
os.remove(tmp)
end)
it('stores bare forge_ref in _extra on new task', function()
local tmp = os.tmpname()
local s = store.new(tmp)
s:load()
diff.apply({ '- [ ] Check out gh:user/repo' }, s)
local tasks = s:active_tasks()
assert.equals(1, #tasks)
assert.is_not_nil(tasks[1]._extra)
assert.is_not_nil(tasks[1]._extra._forge_ref)
assert.equals('github', tasks[1]._extra._forge_ref.forge)
assert.equals('repo', tasks[1]._extra._forge_ref.type)
assert.is_nil(tasks[1]._extra._forge_ref.number)
os.remove(tmp)
end)
end)

368
spec/gtasks_spec.lua Normal file
View file

@ -0,0 +1,368 @@
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)
describe('gtasks push_pass _gtasks_synced_at', function()
local helpers = require('spec.helpers')
local store_mod = require('pending.store')
local oauth = require('pending.sync.oauth')
local s
local orig_curl
before_each(function()
local dir = helpers.tmpdir()
s = store_mod.new(dir .. '/pending.json')
s:load()
orig_curl = oauth.curl_request
end)
after_each(function()
oauth.curl_request = orig_curl
end)
it('sets _gtasks_synced_at after push create', function()
local task =
s:add({ description = 'New task', status = 'pending', category = 'Work', priority = 0 })
oauth.curl_request = function(method, url, _headers, _body)
if method == 'POST' and url:find('/tasks$') then
return { id = 'gtask-new-1' }, nil
end
return {}, nil
end
local now_ts = '2026-03-05T10:00:00Z'
local tasklists = { Work = 'list-1' }
local by_id = {}
gtasks._push_pass('fake-token', tasklists, s, now_ts, by_id)
assert.is_not_nil(task._extra)
assert.equals('2026-03-05T10:00:00Z', task._extra['_gtasks_synced_at'])
end)
it('skips update when modified <= _gtasks_synced_at', function()
local task =
s:add({ description = 'Existing task', status = 'pending', category = 'Work', priority = 0 })
task._extra = {
_gtasks_task_id = 'remote-1',
_gtasks_list_id = 'list-1',
_gtasks_synced_at = '2026-03-05T10:00:00Z',
}
task.modified = '2026-03-05T09:00:00Z'
local patch_called = false
oauth.curl_request = function(method, _url, _headers, _body)
if method == 'PATCH' then
patch_called = true
end
return {}, nil
end
local now_ts = '2026-03-05T11:00:00Z'
local tasklists = { Work = 'list-1' }
local by_id = { ['remote-1'] = task }
gtasks._push_pass('fake-token', tasklists, s, now_ts, by_id)
assert.is_false(patch_called)
end)
it('pushes update when modified > _gtasks_synced_at', function()
local task =
s:add({ description = 'Changed task', status = 'pending', category = 'Work', priority = 0 })
task._extra = {
_gtasks_task_id = 'remote-2',
_gtasks_list_id = 'list-1',
_gtasks_synced_at = '2026-03-05T08:00:00Z',
}
task.modified = '2026-03-05T09:00:00Z'
local patch_called = false
oauth.curl_request = function(method, _url, _headers, _body)
if method == 'PATCH' then
patch_called = true
end
return {}, nil
end
local now_ts = '2026-03-05T11:00:00Z'
local tasklists = { Work = 'list-1' }
local by_id = { ['remote-2'] = task }
gtasks._push_pass('fake-token', tasklists, s, now_ts, by_id)
assert.is_true(patch_called)
end)
it('pushes update when no _gtasks_synced_at (backwards compat)', function()
local task =
s:add({ description = 'Old task', status = 'pending', category = 'Work', priority = 0 })
task._extra = {
_gtasks_task_id = 'remote-3',
_gtasks_list_id = 'list-1',
}
task.modified = '2026-01-01T00:00:00Z'
local patch_called = false
oauth.curl_request = function(method, _url, _headers, _body)
if method == 'PATCH' then
patch_called = true
end
return {}, nil
end
local now_ts = '2026-03-05T11:00:00Z'
local tasklists = { Work = 'list-1' }
local by_id = { ['remote-3'] = task }
gtasks._push_pass('fake-token', tasklists, s, now_ts, by_id)
assert.is_true(patch_called)
end)
end)
describe('gtasks detect_remote_deletions', function()
local helpers = require('spec.helpers')
local store_mod = require('pending.store')
local s
before_each(function()
local dir = helpers.tmpdir()
s = store_mod.new(dir .. '/pending.json')
s:load()
end)
it('clears remote IDs when list was fetched but task ID is absent', function()
local task =
s:add({ description = 'Gone remote', status = 'pending', category = 'Work', priority = 0 })
task._extra = {
_gtasks_task_id = 'old-remote-id',
_gtasks_list_id = 'list-1',
_gtasks_synced_at = '2026-01-01T00:00:00Z',
}
local seen = {}
local fetched = { ['list-1'] = true }
local now_ts = '2026-03-05T10:00:00Z'
local unlinked = gtasks._detect_remote_deletions(s, seen, fetched, now_ts)
assert.equals(1, unlinked)
assert.is_nil(task._extra)
assert.equals('2026-03-05T10:00:00Z', task.modified)
end)
it('leaves task untouched when its list fetch failed', function()
local task = s:add({
description = 'Unknown list task',
status = 'pending',
category = 'Work',
priority = 0,
})
task._extra = {
_gtasks_task_id = 'remote-id',
_gtasks_list_id = 'list-unfetched',
}
local seen = {}
local fetched = {}
local now_ts = '2026-03-05T10:00:00Z'
local unlinked = gtasks._detect_remote_deletions(s, seen, fetched, now_ts)
assert.equals(0, unlinked)
assert.is_not_nil(task._extra)
assert.equals('remote-id', task._extra['_gtasks_task_id'])
end)
it('skips tasks with status == deleted', function()
local task =
s:add({ description = 'Deleted task', status = 'deleted', category = 'Work', priority = 0 })
task._extra = {
_gtasks_task_id = 'remote-del',
_gtasks_list_id = 'list-1',
}
local seen = {}
local fetched = { ['list-1'] = true }
local now_ts = '2026-03-05T10:00:00Z'
local unlinked = gtasks._detect_remote_deletions(s, seen, fetched, now_ts)
assert.equals(0, unlinked)
assert.is_not_nil(task._extra)
assert.equals('remote-del', task._extra['_gtasks_task_id'])
end)
end)

56
spec/icons_spec.lua Normal file
View file

@ -0,0 +1,56 @@
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)

329
spec/oauth_spec.lua Normal file
View file

@ -0,0 +1,329 @@
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 orig_load = oauth.load_json_file
oauth.load_json_file = function()
return nil
end
local c = oauth.new({ name = 'gtasks', scope = 'x', port = 0, config_key = 'gtasks' })
local creds = c:resolve_credentials()
oauth.load_json_file = orig_load
assert.equals(oauth._BUNDLED_CLIENT_ID, creds.client_id)
assert.equals(oauth._BUNDLED_CLIENT_SECRET, creds.client_secret)
end)
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)
describe('with_token', function()
it('auto-triggers auth when not authenticated', function()
local c = oauth.new({ name = 'test_auth', scope = 'x', port = 0, config_key = 'gtasks' })
local call_count = 0
c.get_access_token = function()
call_count = call_count + 1
if call_count == 1 then
return nil
end
return 'new-token'
end
c.resolve_credentials = function()
return { client_id = 'real-id', client_secret = 'real-secret' }
end
local auth_called = false
c.auth = function(_, on_complete)
auth_called = true
vim.schedule(function()
on_complete(true)
end)
end
local received_token
oauth.with_token(c, 'test_auth', function(token)
received_token = token
end)
vim.wait(1000, function()
return received_token ~= nil
end)
assert.is_true(auth_called)
assert.equals('new-token', received_token)
end)
it('bails on bundled credentials without calling auth', function()
local c = oauth.new({ name = 'test_bail', scope = 'x', port = 0, config_key = 'gtasks' })
c.get_access_token = function()
return nil
end
c.resolve_credentials = function()
return { client_id = oauth.BUNDLED_CLIENT_ID, client_secret = 'x' }
end
local auth_called = false
c.auth = function()
auth_called = true
end
local callback_called = false
oauth.with_token(c, 'test_bail', function()
callback_called = true
end)
vim.wait(500, function()
return false
end)
assert.is_false(auth_called)
assert.is_false(callback_called)
end)
it('stops when auth fails', function()
local c = oauth.new({ name = 'test_fail', scope = 'x', port = 0, config_key = 'gtasks' })
c.get_access_token = function()
return nil
end
c.resolve_credentials = function()
return { client_id = 'real-id', client_secret = 'real-secret' }
end
c.auth = function(_, on_complete)
vim.schedule(function()
on_complete(false)
end)
end
local callback_called = false
oauth.with_token(c, 'test_fail', function()
callback_called = true
end)
vim.wait(500, function()
return false
end)
assert.is_false(callback_called)
end)
it('proceeds directly when already authenticated', function()
local c = oauth.new({ name = 'test_ok', scope = 'x', port = 0, config_key = 'gtasks' })
c.get_access_token = function()
return 'existing-token'
end
local received_token
oauth.with_token(c, 'test_ok', function(token)
received_token = token
end)
vim.wait(1000, function()
return received_token ~= nil
end)
assert.equals('existing-token', received_token)
end)
end)
end)

View file

@ -31,27 +31,52 @@ describe('parse', function()
it('extracts category', function() it('extracts category', function()
local desc, meta = parse.body('Buy groceries cat:Errands') local desc, meta = parse.body('Buy groceries cat:Errands')
assert.are.equal('Buy groceries', desc) assert.are.equal('Buy groceries', desc)
assert.are.equal('Errands', meta.cat) assert.are.equal('Errands', meta.category)
end) end)
it('extracts both due and cat', function() it('extracts both due and cat', function()
local desc, meta = parse.body('Buy milk due:2026-03-15 cat:Errands') local desc, meta = parse.body('Buy milk due:2026-03-15 cat:Errands')
assert.are.equal('Buy milk', desc) assert.are.equal('Buy milk', desc)
assert.are.equal('2026-03-15', meta.due) assert.are.equal('2026-03-15', meta.due)
assert.are.equal('Errands', meta.cat) assert.are.equal('Errands', meta.category)
end) end)
it('extracts metadata in any order', function() it('extracts metadata in any order', function()
local desc, meta = parse.body('Buy milk cat:Errands due:2026-03-15') local desc, meta = parse.body('Buy milk cat:Errands due:2026-03-15')
assert.are.equal('Buy milk', desc) assert.are.equal('Buy milk', desc)
assert.are.equal('2026-03-15', meta.due) assert.are.equal('2026-03-15', meta.due)
assert.are.equal('Errands', meta.cat) assert.are.equal('Errands', meta.category)
end) end)
it('stops at duplicate key', function() it('first occurrence wins for duplicate keys and warns', function()
local warnings = {}
local orig = vim.notify
vim.notify = function(m, level)
if level == vim.log.levels.WARN then
table.insert(warnings, m)
end
end
local desc, meta = parse.body('Buy milk due:2026-03-15 due:2026-04-01') local desc, meta = parse.body('Buy milk due:2026-03-15 due:2026-04-01')
assert.are.equal('Buy milk due:2026-03-15', desc) vim.notify = orig
assert.are.equal('2026-04-01', meta.due) assert.are.equal('Buy milk', desc)
assert.are.equal('2026-03-15', meta.due)
assert.are.equal(1, #warnings)
assert.truthy(warnings[1]:find('duplicate', 1, true))
end)
it('drops identical duplicate metadata tokens and warns', function()
local warnings = {}
local orig = vim.notify
vim.notify = function(m, level)
if level == vim.log.levels.WARN then
table.insert(warnings, m)
end
end
local desc, meta = parse.body('Buy milk due:tomorrow due:tomorrow')
vim.notify = orig
assert.are.equal('Buy milk', desc)
assert.are.equal(os.date('%Y-%m-%d', os.time() + 86400), meta.due)
assert.are.equal(1, #warnings)
end) end)
it('stops at non-meta token', function() it('stops at non-meta token', function()
@ -110,6 +135,66 @@ describe('parse', function()
assert.is_nil(meta.due) assert.is_nil(meta.due)
assert.truthy(desc:find('due:garbage', 1, true)) assert.truthy(desc:find('due:garbage', 1, true))
end) end)
it('parses metadata before a forge ref', function()
local desc, meta = parse.body('Fix bug due:2026-03-15 gh:user/repo#42')
assert.are.equal('2026-03-15', meta.due)
assert.truthy(desc:find('gh:user/repo#42', 1, true))
assert.truthy(desc:find('Fix bug', 1, true))
end)
it('parses metadata after a forge ref', function()
local desc, meta = parse.body('Fix bug gh:user/repo#42 due:2026-03-15')
assert.are.equal('2026-03-15', meta.due)
assert.truthy(desc:find('gh:user/repo#42', 1, true))
assert.truthy(desc:find('Fix bug', 1, true))
end)
it('parses all metadata around forge ref', function()
local desc, meta = parse.body('Fix bug due:tomorrow gh:user/repo#42 cat:Work')
assert.are.equal(os.date('%Y-%m-%d', os.time() + 86400), meta.due)
assert.are.equal('Work', meta.category)
assert.truthy(desc:find('gh:user/repo#42', 1, true))
end)
it('parses forge ref between metadata tokens', function()
local desc, meta = parse.body('Fix bug cat:Work gl:a/b#12 due:2026-03-15')
assert.are.equal('2026-03-15', meta.due)
assert.are.equal('Work', meta.category)
assert.truthy(desc:find('gl:a/b#12', 1, true))
end)
it('extracts leading metadata', function()
local desc, meta = parse.body('due:2026-03-15 Fix the bug')
assert.are.equal('Fix the bug', desc)
assert.are.equal('2026-03-15', meta.due)
end)
it('extracts metadata from the middle', function()
local desc, meta = parse.body('Fix due:2026-03-15 the bug')
assert.are.equal('Fix the bug', desc)
assert.are.equal('2026-03-15', meta.due)
end)
it('extracts multiple metadata from any position', function()
local desc, meta = parse.body('cat:Work Fix due:2026-03-15 the bug')
assert.are.equal('Fix the bug', desc)
assert.are.equal('2026-03-15', meta.due)
assert.are.equal('Work', meta.category)
end)
it('extracts all metadata types from mixed positions', function()
local today = os.date('*t') --[[@as osdate]]
local tomorrow = os.date(
'%Y-%m-%d',
os.time({ year = today.year, month = today.month, day = today.day + 1 })
)
local desc, meta = parse.body('due:tomorrow cat:Work Fix the bug +!')
assert.are.equal('Fix the bug', desc)
assert.are.equal(tomorrow, meta.due)
assert.are.equal('Work', meta.category)
assert.are.equal(1, meta.priority)
end)
end) end)
describe('parse.resolve_date', function() describe('parse.resolve_date', function()
@ -154,6 +239,240 @@ 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()
@ -166,7 +485,7 @@ describe('parse', function()
it('detects category prefix', function() it('detects category prefix', function()
local desc, meta = parse.command_add('School: Do homework') local desc, meta = parse.command_add('School: Do homework')
assert.are.equal('Do homework', desc) assert.are.equal('Do homework', desc)
assert.are.equal('School', meta.cat) assert.are.equal('School', meta.category)
end) end)
it('ignores lowercase prefix', function() it('ignores lowercase prefix', function()
@ -177,7 +496,118 @@ describe('parse', function()
it('combines category prefix with inline metadata', function() it('combines category prefix with inline metadata', function()
local desc, meta = parse.command_add('School: Do homework due:2026-03-15') local desc, meta = parse.command_add('School: Do homework due:2026-03-15')
assert.are.equal('Do homework', desc) assert.are.equal('Do homework', desc)
assert.are.equal('School', meta.cat) assert.are.equal('School', meta.category)
assert.are.equal('2026-03-15', meta.due)
end)
end)
describe('parse_duration_to_days', function()
it('parses days suffix', function()
assert.are.equal(7, parse.parse_duration_to_days('7d'))
end)
it('parses weeks suffix', function()
assert.are.equal(21, parse.parse_duration_to_days('3w'))
end)
it('parses months suffix (approximated as 30 days)', function()
assert.are.equal(60, parse.parse_duration_to_days('2m'))
end)
it('parses bare integer as days', function()
assert.are.equal(30, parse.parse_duration_to_days('30'))
end)
it('returns nil for nil input', function()
assert.is_nil(parse.parse_duration_to_days(nil))
end)
it('returns nil for empty string', function()
assert.is_nil(parse.parse_duration_to_days(''))
end)
it('returns nil for unrecognized input', function()
assert.is_nil(parse.parse_duration_to_days('xyz'))
end)
it('returns nil for negative numbers', function()
assert.is_nil(parse.parse_duration_to_days('-7d'))
end)
it('handles single digit', function()
assert.are.equal(1, parse.parse_duration_to_days('1d'))
end)
it('handles large numbers', function()
assert.are.equal(365, parse.parse_duration_to_days('365d'))
end)
end)
describe('input_date_formats', function()
before_each(function()
config.reset()
end)
after_each(function()
vim.g.pending = nil
config.reset()
end)
it('parses MM/DD/YYYY format', function()
vim.g.pending = { input_date_formats = { '%m/%d/%Y' } }
config.reset()
local result = parse.resolve_date('03/15/2026')
assert.are.equal('2026-03-15', result)
end)
it('parses DD-Mon-YYYY format', function()
vim.g.pending = { input_date_formats = { '%d-%b-%Y' } }
config.reset()
local result = parse.resolve_date('15-Mar-2026')
assert.are.equal('2026-03-15', result)
end)
it('parses month name case-insensitively', function()
vim.g.pending = { input_date_formats = { '%d-%b-%Y' } }
config.reset()
local result = parse.resolve_date('15-MARCH-2026')
assert.are.equal('2026-03-15', result)
end)
it('parses two-digit year', function()
vim.g.pending = { input_date_formats = { '%m/%d/%y' } }
config.reset()
local result = parse.resolve_date('03/15/26')
assert.are.equal('2026-03-15', result)
end)
it('infers year when format has no year field', function()
vim.g.pending = { input_date_formats = { '%m/%d' } }
config.reset()
local result = parse.resolve_date('12/31')
assert.is_not_nil(result)
assert.truthy(result:match('^%d%d%d%d%-12%-31$'))
end)
it('returns nil for non-matching input', function()
vim.g.pending = { input_date_formats = { '%m/%d/%Y' } }
config.reset()
local result = parse.resolve_date('not-a-date')
assert.is_nil(result)
end)
it('tries formats in order, returns first match', function()
vim.g.pending = { input_date_formats = { '%d/%m/%Y', '%m/%d/%Y' } }
config.reset()
local result = parse.resolve_date('01/03/2026')
assert.are.equal('2026-03-01', result)
end)
it('works with body() for inline due token', function()
vim.g.pending = { input_date_formats = { '%m/%d/%Y' } }
config.reset()
local desc, meta = parse.body('Pay rent due:03/15/2026')
assert.are.equal('Pay rent', desc)
assert.are.equal('2026-03-15', meta.due) assert.are.equal('2026-03-15', meta.due)
end) end)
end) end)

223
spec/recur_spec.lua Normal file
View file

@ -0,0 +1,223 @@
require('spec.helpers')
describe('recur', function()
local recur = require('pending.recur')
describe('parse', function()
it('parses daily', function()
local r = recur.parse('daily')
assert.are.equal('daily', r.freq)
assert.are.equal(1, r.interval)
assert.are.equal('scheduled', r.mode)
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.are.equal('completion', r.mode)
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)

626
spec/s3_spec.lua Normal file
View file

@ -0,0 +1,626 @@
require('spec.helpers')
local config = require('pending.config')
local util = require('pending.sync.util')
describe('s3', function()
local tmpdir
local pending
local s3
local orig_system
before_each(function()
tmpdir = vim.fn.tempname()
vim.fn.mkdir(tmpdir, 'p')
vim.g.pending = {
data_path = tmpdir .. '/tasks.json',
sync = { s3 = { bucket = 'test-bucket', key = 'test.json' } },
}
config.reset()
package.loaded['pending'] = nil
package.loaded['pending.sync.s3'] = nil
pending = require('pending')
s3 = require('pending.sync.s3')
orig_system = util.system
end)
after_each(function()
util.system = orig_system
vim.fn.delete(tmpdir, 'rf')
vim.g.pending = nil
config.reset()
package.loaded['pending'] = nil
package.loaded['pending.sync.s3'] = nil
end)
it('has correct name', function()
assert.equals('s3', s3.name)
end)
it('has auth function', function()
assert.equals('function', type(s3.auth))
end)
it('has auth_complete returning profile', function()
local completions = s3.auth_complete()
assert.is_true(vim.tbl_contains(completions, 'profile'))
end)
it('has push, pull, sync functions', function()
assert.equals('function', type(s3.push))
assert.equals('function', type(s3.pull))
assert.equals('function', type(s3.sync))
end)
it('has health function', function()
assert.equals('function', type(s3.health))
end)
describe('ensure_sync_id', function()
it('assigns a UUID-like sync id', function()
local task = { _extra = nil, modified = '2026-01-01T00:00:00Z' }
local id = s3._ensure_sync_id(task)
assert.is_not_nil(id)
assert.truthy(
id:match('^%x%x%x%x%x%x%x%x%-%x%x%x%x%-%x%x%x%x%-%x%x%x%x%-%x%x%x%x%x%x%x%x%x%x%x%x$')
)
assert.equals(id, task._extra['_s3_sync_id'])
end)
it('returns existing sync id without regenerating', function()
local task = {
_extra = { _s3_sync_id = 'existing-id' },
modified = '2026-01-01T00:00:00Z',
}
local id = s3._ensure_sync_id(task)
assert.equals('existing-id', id)
end)
end)
describe('auth', function()
it('reports success on valid credentials', function()
util.system = function(args)
if vim.tbl_contains(args, 'get-caller-identity') then
return {
code = 0,
stdout = '{"Account":"123456","Arn":"arn:aws:iam::user/test"}',
stderr = '',
}
end
return { code = 0, stdout = '', stderr = '' }
end
local msg
local orig_notify = vim.notify
vim.notify = function(m)
msg = m
end
s3.auth()
vim.notify = orig_notify
assert.truthy(msg and msg:find('authenticated'))
end)
it('skips bucket creation when bucket is configured', function()
util.system = function(args)
if vim.tbl_contains(args, 'get-caller-identity') then
return {
code = 0,
stdout = '{"Account":"123456","Arn":"arn:aws:iam::user/test"}',
stderr = '',
}
end
return { code = 0, stdout = '', stderr = '' }
end
local orig_input = util.input
local input_called = false
util.input = function()
input_called = true
return nil
end
s3.auth()
util.input = orig_input
assert.is_false(input_called)
end)
it('detects SSO expiry', function()
util.system = function(args)
if vim.tbl_contains(args, 'get-caller-identity') then
return { code = 1, stdout = '', stderr = 'Error: SSO session expired' }
end
return { code = 0, stdout = '', stderr = '' }
end
local msg
local orig_notify = vim.notify
vim.notify = function(m)
msg = m
end
s3.auth()
vim.notify = orig_notify
assert.truthy(msg and msg:find('SSO'))
end)
it('detects missing credentials', function()
util.system = function()
return { code = 1, stdout = '', stderr = 'Unable to locate credentials' }
end
local msg
local orig_notify = vim.notify
vim.notify = function(m, level)
if level == vim.log.levels.ERROR then
msg = m
end
end
s3.auth()
vim.notify = orig_notify
assert.truthy(msg and msg:find('no AWS credentials'))
end)
end)
describe('auth bucket creation', function()
local orig_input
before_each(function()
vim.g.pending = { data_path = tmpdir .. '/tasks.json', sync = { s3 = {} } }
config.reset()
package.loaded['pending'] = nil
package.loaded['pending.sync.s3'] = nil
pending = require('pending')
s3 = require('pending.sync.s3')
orig_input = util.input
end)
after_each(function()
util.input = orig_input
end)
it('prompts for bucket when none configured', function()
local input_calls = {}
util.input = function(opts)
table.insert(input_calls, opts)
if opts.prompt:find('bucket') then
return 'my-bucket'
end
return ''
end
local create_args
util.system = function(args)
if vim.tbl_contains(args, 'get-caller-identity') then
return {
code = 0,
stdout = '{"Account":"123","Arn":"arn:aws:iam::user/test"}',
stderr = '',
}
end
if vim.tbl_contains(args, 'configure') then
return { code = 0, stdout = 'us-west-2\n', stderr = '' }
end
if vim.tbl_contains(args, 'create-bucket') then
create_args = args
return { code = 0, stdout = '', stderr = '' }
end
return { code = 0, stdout = '', stderr = '' }
end
local msg
local orig_notify = vim.notify
vim.notify = function(m)
msg = m
end
s3.auth()
vim.notify = orig_notify
assert.equals(2, #input_calls)
assert.is_not_nil(create_args)
assert.truthy(vim.tbl_contains(create_args, 'my-bucket'))
assert.truthy(msg and msg:find('bucket created'))
end)
it('cancels when user provides nil bucket name', function()
util.input = function(opts)
if opts.prompt:find('bucket') then
return nil
end
return ''
end
util.system = function(args)
if vim.tbl_contains(args, 'get-caller-identity') then
return {
code = 0,
stdout = '{"Account":"123","Arn":"arn:aws:iam::user/test"}',
stderr = '',
}
end
return { code = 0, stdout = '', stderr = '' }
end
local msg
local orig_notify = vim.notify
vim.notify = function(m)
msg = m
end
s3.auth()
vim.notify = orig_notify
assert.truthy(msg and msg:find('cancelled'))
end)
it('omits LocationConstraint for us-east-1', function()
util.input = function(opts)
if opts.prompt:find('bucket') then
return 'my-bucket'
end
if opts.prompt:find('region') then
return 'us-east-1'
end
return ''
end
local create_args
util.system = function(args)
if vim.tbl_contains(args, 'get-caller-identity') then
return {
code = 0,
stdout = '{"Account":"123","Arn":"arn:aws:iam::user/test"}',
stderr = '',
}
end
if vim.tbl_contains(args, 'configure') then
return { code = 0, stdout = 'us-east-1\n', stderr = '' }
end
if vim.tbl_contains(args, 'create-bucket') then
create_args = args
return { code = 0, stdout = '', stderr = '' }
end
return { code = 0, stdout = '', stderr = '' }
end
s3.auth()
assert.is_not_nil(create_args)
local joined = table.concat(create_args, ' ')
assert.falsy(joined:find('LocationConstraint'))
end)
it('includes LocationConstraint for non-us-east-1 regions', function()
util.input = function(opts)
if opts.prompt:find('bucket') then
return 'my-bucket'
end
if opts.prompt:find('region') then
return 'eu-west-1'
end
return ''
end
local create_args
util.system = function(args)
if vim.tbl_contains(args, 'get-caller-identity') then
return {
code = 0,
stdout = '{"Account":"123","Arn":"arn:aws:iam::user/test"}',
stderr = '',
}
end
if vim.tbl_contains(args, 'configure') then
return { code = 0, stdout = 'eu-west-1\n', stderr = '' }
end
if vim.tbl_contains(args, 'create-bucket') then
create_args = args
return { code = 0, stdout = '', stderr = '' }
end
return { code = 0, stdout = '', stderr = '' }
end
s3.auth()
assert.is_not_nil(create_args)
assert.truthy(vim.tbl_contains(create_args, 'LocationConstraint=eu-west-1'))
end)
it('reports error on bucket creation failure', function()
util.input = function(opts)
if opts.prompt:find('bucket') then
return 'bad-bucket'
end
return ''
end
util.system = function(args)
if vim.tbl_contains(args, 'get-caller-identity') then
return {
code = 0,
stdout = '{"Account":"123","Arn":"arn:aws:iam::user/test"}',
stderr = '',
}
end
if vim.tbl_contains(args, 'configure') then
return { code = 0, stdout = 'us-east-1\n', stderr = '' }
end
if vim.tbl_contains(args, 'create-bucket') then
return { code = 1, stdout = '', stderr = 'BucketAlreadyExists' }
end
return { code = 0, stdout = '', stderr = '' }
end
local msg
local orig_notify = vim.notify
vim.notify = function(m, level)
if level == vim.log.levels.ERROR then
msg = m
end
end
s3.auth()
vim.notify = orig_notify
assert.truthy(msg and msg:find('bucket creation failed'))
end)
it('defaults region to us-east-1 when aws configure returns nothing', function()
util.input = function(opts)
if opts.prompt:find('bucket') then
return 'my-bucket'
end
return ''
end
local create_args
util.system = function(args)
if vim.tbl_contains(args, 'get-caller-identity') then
return {
code = 0,
stdout = '{"Account":"123","Arn":"arn:aws:iam::user/test"}',
stderr = '',
}
end
if vim.tbl_contains(args, 'configure') then
return { code = 1, stdout = '', stderr = '' }
end
if vim.tbl_contains(args, 'create-bucket') then
create_args = args
return { code = 0, stdout = '', stderr = '' }
end
return { code = 0, stdout = '', stderr = '' }
end
s3.auth()
assert.is_not_nil(create_args)
assert.truthy(vim.tbl_contains(create_args, 'us-east-1'))
local joined = table.concat(create_args, ' ')
assert.falsy(joined:find('LocationConstraint'))
end)
end)
describe('ensure_credentials', function()
it('returns true on valid credentials', function()
util.system = function(args)
if vim.tbl_contains(args, 'get-caller-identity') then
return { code = 0, stdout = '{"Account":"123"}', stderr = '' }
end
return { code = 0, stdout = '', stderr = '' }
end
assert.is_true(s3._ensure_credentials())
end)
it('returns false on missing credentials', function()
util.system = function()
return { code = 1, stdout = '', stderr = 'Unable to locate credentials' }
end
local msg
local orig_notify = vim.notify
vim.notify = function(m, level)
if level == vim.log.levels.ERROR then
msg = m
end
end
assert.is_false(s3._ensure_credentials())
vim.notify = orig_notify
assert.truthy(msg and msg:find('no AWS credentials'))
end)
it('retries SSO login on expired session', function()
local calls = {}
util.system = function(args)
if vim.tbl_contains(args, 'get-caller-identity') then
return { code = 1, stdout = '', stderr = 'Error: SSO session expired' }
end
if vim.tbl_contains(args, 'sso') then
table.insert(calls, 'sso-login')
return { code = 0, stdout = '', stderr = '' }
end
return { code = 0, stdout = '', stderr = '' }
end
assert.is_true(s3._ensure_credentials())
assert.equals(1, #calls)
assert.equals('sso-login', calls[1])
end)
it('returns false when SSO login fails', function()
util.system = function(args)
if vim.tbl_contains(args, 'get-caller-identity') then
return { code = 1, stdout = '', stderr = 'SSO token expired' }
end
if vim.tbl_contains(args, 'sso') then
return { code = 1, stdout = '', stderr = 'login failed' }
end
return { code = 0, stdout = '', stderr = '' }
end
assert.is_false(s3._ensure_credentials())
end)
end)
describe('push', function()
it('uploads store to S3', function()
local s = pending.store()
s:load()
s:add({ description = 'Test task', status = 'pending', category = 'Work', priority = 0 })
s:save()
local captured_args
util.system = function(args)
if vim.tbl_contains(args, 'get-caller-identity') then
return { code = 0, stdout = '{"Account":"123"}', stderr = '' }
end
if vim.tbl_contains(args, 's3') then
captured_args = args
return { code = 0, stdout = '', stderr = '' }
end
return { code = 0, stdout = '', stderr = '' }
end
s3.push()
assert.is_not_nil(captured_args)
local joined = table.concat(captured_args, ' ')
assert.truthy(joined:find('s3://test%-bucket/test%.json'))
end)
it('errors when bucket is not configured', function()
vim.g.pending = { data_path = tmpdir .. '/tasks.json', sync = { s3 = {} } }
config.reset()
package.loaded['pending'] = nil
package.loaded['pending.sync.s3'] = nil
pending = require('pending')
s3 = require('pending.sync.s3')
util.system = function(args)
if vim.tbl_contains(args, 'get-caller-identity') then
return { code = 0, stdout = '{"Account":"123"}', stderr = '' }
end
return { code = 0, stdout = '', stderr = '' }
end
local msg
local orig_notify = vim.notify
vim.notify = function(m, level)
if level == vim.log.levels.ERROR then
msg = m
end
end
s3.push()
vim.notify = orig_notify
assert.truthy(msg and msg:find('bucket is required'))
end)
end)
describe('pull merge', function()
it('merges remote tasks by sync_id', function()
local store_mod = require('pending.store')
local s = pending.store()
s:load()
local local_task = s:add({
description = 'Local task',
status = 'pending',
category = 'Work',
priority = 0,
})
local_task._extra = { _s3_sync_id = 'sync-1' }
local_task.modified = '2026-03-01T00:00:00Z'
s:save()
local remote_path = tmpdir .. '/remote.json'
local remote_store = store_mod.new(remote_path)
remote_store:load()
local remote_task = remote_store:add({
description = 'Updated remotely',
status = 'pending',
category = 'Work',
priority = 1,
})
remote_task._extra = { _s3_sync_id = 'sync-1' }
remote_task.modified = '2026-03-05T00:00:00Z'
local new_remote = remote_store:add({
description = 'New remote task',
status = 'pending',
category = 'Personal',
priority = 0,
})
new_remote._extra = { _s3_sync_id = 'sync-2' }
new_remote.modified = '2026-03-04T00:00:00Z'
remote_store:save()
util.system = function(args)
if vim.tbl_contains(args, 's3') and vim.tbl_contains(args, 'cp') then
for i, arg in ipairs(args) do
if arg:match('^s3://') then
local dest = args[i + 1]
if dest and not dest:match('^s3://') then
local src = io.open(remote_path, 'r')
local content = src:read('*a')
src:close()
local f = io.open(dest, 'w')
f:write(content)
f:close()
end
break
end
end
return { code = 0, stdout = '', stderr = '' }
end
return { code = 0, stdout = '', stderr = '' }
end
s3.pull()
s:load()
local tasks = s:tasks()
assert.equals(2, #tasks)
local found_updated = false
local found_new = false
for _, t in ipairs(tasks) do
if t._extra and t._extra['_s3_sync_id'] == 'sync-1' then
assert.equals('Updated remotely', t.description)
assert.equals(1, t.priority)
found_updated = true
end
if t._extra and t._extra['_s3_sync_id'] == 'sync-2' then
assert.equals('New remote task', t.description)
found_new = true
end
end
assert.is_true(found_updated)
assert.is_true(found_new)
end)
it('keeps local version when local is newer', function()
local s = pending.store()
s:load()
local local_task = s:add({
description = 'Local version',
status = 'pending',
category = 'Work',
priority = 0,
})
local_task._extra = { _s3_sync_id = 'sync-3' }
local_task.modified = '2026-03-10T00:00:00Z'
s:save()
local store_mod = require('pending.store')
local remote_path = tmpdir .. '/remote2.json'
local remote_store = store_mod.new(remote_path)
remote_store:load()
local remote_task = remote_store:add({
description = 'Older remote',
status = 'pending',
category = 'Work',
priority = 0,
})
remote_task._extra = { _s3_sync_id = 'sync-3' }
remote_task.modified = '2026-03-05T00:00:00Z'
remote_store:save()
util.system = function(args)
if vim.tbl_contains(args, 's3') and vim.tbl_contains(args, 'cp') then
for i, arg in ipairs(args) do
if arg:match('^s3://') then
local dest = args[i + 1]
if dest and not dest:match('^s3://') then
local src = io.open(remote_path, 'r')
local content = src:read('*a')
src:close()
local f = io.open(dest, 'w')
f:write(content)
f:close()
end
break
end
end
return { code = 0, stdout = '', stderr = '' }
end
return { code = 0, stdout = '', stderr = '' }
end
s3.pull()
s:load()
local tasks = s:tasks()
assert.equals(1, #tasks)
assert.equals('Local version', tasks[1].description)
end)
end)
end)

260
spec/status_spec.lua Normal file
View file

@ -0,0 +1,260 @@
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)

View file

@ -5,31 +5,30 @@ 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')
vim.g.pending = { data_path = tmpdir .. '/tasks.json' } s = store.new(tmpdir .. '/tasks.json')
config.reset() s:load()
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 = store.load() local data = s: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 = config.get().data_path local path = tmpdir .. '/tasks.json'
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,
@ -52,7 +51,7 @@ describe('store', function()
}, },
})) }))
f:close() f:close()
local data = store.load() local data = s: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)
@ -60,7 +59,7 @@ describe('store', function()
end) end)
it('preserves unknown fields', function() it('preserves unknown fields', function()
local path = config.get().data_path local path = tmpdir .. '/tasks.json'
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,
@ -77,8 +76,8 @@ describe('store', function()
}, },
})) }))
f:close() f:close()
store.load() s:load()
local task = store.get(1) local task = s: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)
@ -86,9 +85,8 @@ 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()
store.load() local t1 = s:add({ description = 'First' })
local t1 = store.add({ description = 'First' }) local t2 = s:add({ description = 'Second' })
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)
@ -96,60 +94,54 @@ describe('store', function()
end) end)
it('uses provided category', function() it('uses provided category', function()
store.load() local t = s:add({ description = 'Test', category = 'Work' })
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()
store.load() local t = s:add({ description = 'Original' })
local t = store.add({ description = 'Original' })
t.modified = '2025-01-01T00:00:00Z' t.modified = '2025-01-01T00:00:00Z'
store.update(t.id, { description = 'Updated' }) s:update(t.id, { description = 'Updated' })
local updated = store.get(t.id) local updated = s: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()
store.load() local t = s:add({ description = 'Test' })
local t = store.add({ description = 'Test' })
assert.is_nil(t['end']) assert.is_nil(t['end'])
store.update(t.id, { status = 'done' }) s:update(t.id, { status = 'done' })
local updated = store.get(t.id) local updated = s: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()
store.load() local t = s:add({ description = 'Immutable fields' })
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
store.update(t.id, { id = 999, entry = 'x' }) s:update(t.id, { id = 999, entry = 'x' })
local updated = store.get(original_id) local updated = s: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()
store.load() local t = s:add({ description = 'Complete twice' })
local t = store.add({ description = 'Complete twice' }) s:update(t.id, { status = 'done', ['end'] = '2026-01-15T10:00:00Z' })
store.update(t.id, { status = 'done', ['end'] = '2026-01-15T10:00:00Z' }) local first_end = s:get(t.id)['end']
local first_end = store.get(t.id)['end'] s:update(t.id, { status = 'done' })
store.update(t.id, { status = 'done' }) local task = s:get(t.id)
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()
store.load() local t = s:add({ description = 'To delete' })
local t = store.add({ description = 'To delete' }) s:delete(t.id)
store.delete(t.id) local deleted = s:get(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)
@ -157,12 +149,10 @@ 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()
store.load() s:add({ description = 'Persisted', category = 'Work', priority = 1 })
store.add({ description = 'Persisted', category = 'Work', priority = 1 }) s:save()
store.save() s:load()
store.unload() local tasks = s:active_tasks()
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)
@ -170,7 +160,7 @@ describe('store', function()
end) end)
it('round-trips unknown fields', function() it('round-trips unknown fields', function()
local path = config.get().data_path local path = tmpdir .. '/tasks.json'
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,
@ -187,22 +177,78 @@ describe('store', function()
}, },
})) }))
f:close() f:close()
store.load() s:load()
store.save() s:save()
store.unload() s:load()
store.load() local task = s:get(1)
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('folded_categories', function()
it('defaults to empty table when missing from JSON', function()
local path = tmpdir .. '/tasks.json'
local f = io.open(path, 'w')
f:write(vim.json.encode({
version = 1,
next_id = 1,
tasks = {},
}))
f:close()
s:load()
assert.are.same({}, s:get_folded_categories())
end)
it('round-trips folded categories through save and load', function()
s:set_folded_categories({ 'Work', 'Home' })
s:save()
s:load()
assert.are.same({ 'Work', 'Home' }, s:get_folded_categories())
end)
it('persists empty list', function()
s:set_folded_categories({})
s:save()
s:load()
assert.are.same({}, s:get_folded_categories())
end)
end)
describe('active_tasks', function() describe('active_tasks', function()
it('excludes deleted tasks', function() it('excludes deleted tasks', function()
store.load() s:add({ description = 'Active' })
store.add({ description = 'Active' }) local t2 = s:add({ description = 'To delete' })
local t2 = store.add({ description = 'To delete' }) s:delete(t2.id)
store.delete(t2.id) local active = s:active_tasks()
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)
@ -210,27 +256,24 @@ describe('store', function()
describe('snapshot', function() describe('snapshot', function()
it('returns a table of tasks', function() it('returns a table of tasks', function()
store.load() s:add({ description = 'Snap one' })
store.add({ description = 'Snap one' }) s:add({ description = 'Snap two' })
store.add({ description = 'Snap two' }) local snap = s:snapshot()
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()
store.load() local t = s:add({ description = 'Original' })
local t = store.add({ description = 'Original' }) local snap = s:snapshot()
local snap = store.snapshot()
snap[1].description = 'Mutated' snap[1].description = 'Mutated'
local live = store.get(t.id) local live = s: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()
store.load() local t = s:add({ description = 'Will be deleted' })
local t = store.add({ description = 'Will be deleted' }) s:delete(t.id)
store.delete(t.id) local snap = s:snapshot()
local snap = store.snapshot()
assert.are.equal(0, #snap) assert.are.equal(0, #snap)
end) end)
end) end)

279
spec/sync_spec.lua Normal file
View file

@ -0,0 +1,279 @@
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('[pending.nvim]: Unknown 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("[pending.nvim]: gcal: 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 command', function()
local called = false
local orig_auth = pending.auth
pending.auth = function()
called = true
end
pending.command('auth')
pending.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 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)
it('has auth function', function()
local gcal = require('pending.sync.gcal')
assert.are.equal('function', type(gcal.auth))
end)
it('has auth_complete function', function()
local gcal = require('pending.sync.gcal')
local completions = gcal.auth_complete()
assert.is_true(vim.tbl_contains(completions, 'clear'))
assert.is_true(vim.tbl_contains(completions, 'reset'))
end)
end)
describe('register_backend', function()
it('registers a custom backend', function()
pending.register_backend({ name = 'custom', pull = function() end })
local set = pending.sync_backend_set()
assert.is_true(set['custom'] == true)
assert.is_true(vim.tbl_contains(pending.sync_backends(), 'custom'))
end)
it('rejects backend without name', function()
local msg
local orig = vim.notify
vim.notify = function(m, level)
if level == vim.log.levels.ERROR then
msg = m
end
end
pending.register_backend({})
vim.notify = orig
assert.truthy(msg and msg:find('non%-empty'))
end)
it('rejects backend with empty name', function()
local msg
local orig = vim.notify
vim.notify = function(m, level)
if level == vim.log.levels.ERROR then
msg = m
end
end
pending.register_backend({ name = '' })
vim.notify = orig
assert.truthy(msg and msg:find('non%-empty'))
end)
it('rejects duplicate of built-in backend', function()
local msg
local orig = vim.notify
vim.notify = function(m, level)
if level == vim.log.levels.ERROR then
msg = m
end
end
pending.register_backend({ name = 'gcal' })
vim.notify = orig
assert.truthy(msg and msg:find('already exists'))
end)
it('rejects duplicate registered backend', function()
pending.register_backend({ name = 'dup_test', pull = function() end })
local msg
local orig = vim.notify
vim.notify = function(m, level)
if level == vim.log.levels.ERROR then
msg = m
end
end
pending.register_backend({ name = 'dup_test' })
vim.notify = orig
assert.truthy(msg and msg:find('already registered'))
end)
end)
describe('resolve_backend', function()
it('resolves built-in backend', function()
local mod = pending.resolve_backend('gcal')
assert.is_not_nil(mod)
assert.are.equal('gcal', mod.name)
end)
it('resolves registered backend', function()
local custom = { name = 'resolve_test', pull = function() end }
pending.register_backend(custom)
local mod = pending.resolve_backend('resolve_test')
assert.is_not_nil(mod)
assert.are.equal('resolve_test', mod.name)
end)
it('returns nil for unknown backend', function()
assert.is_nil(pending.resolve_backend('nonexistent_xyz'))
end)
it('dispatches command to registered backend', function()
local called = false
pending.register_backend({
name = 'cmd_test',
pull = function()
called = true
end,
})
pending.command('cmd_test pull')
assert.is_true(called)
end)
end)
describe('auto-discovery', function()
it('discovers gcal and gtasks backends', function()
local backends = pending.sync_backends()
assert.is_true(vim.tbl_contains(backends, 'gcal'))
assert.is_true(vim.tbl_contains(backends, 'gtasks'))
end)
it('excludes modules without name field', function()
local set = pending.sync_backend_set()
assert.is_nil(set['oauth'])
assert.is_nil(set['util'])
end)
it('populates backend set correctly', function()
local set = pending.sync_backend_set()
assert.is_true(set['gcal'] == true)
assert.is_true(set['gtasks'] == true)
end)
end)
describe('auth dispatch', function()
it('routes auth to specific backend', function()
local called_with = nil
local gcal = require('pending.sync.gcal')
local orig_auth = gcal.auth
gcal.auth = function(args)
called_with = args or 'default'
end
pending.auth('gcal')
gcal.auth = orig_auth
assert.are.equal('default', called_with)
end)
it('routes auth with sub-action', function()
local called_with = nil
local gcal = require('pending.sync.gcal')
local orig_auth = gcal.auth
gcal.auth = function(args)
called_with = args
end
pending.auth('gcal clear')
gcal.auth = orig_auth
assert.are.equal('clear', called_with)
end)
it('errors on unknown backend', function()
local msg
local orig = vim.notify
vim.notify = function(m, level)
if level == vim.log.levels.ERROR then
msg = m
end
end
pending.auth('nonexistent')
vim.notify = orig
assert.truthy(msg and msg:find('No auth method'))
end)
end)
end)

100
spec/sync_util_spec.lua Normal file
View file

@ -0,0 +1,100 @@
require('spec.helpers')
local config = require('pending.config')
local util = require('pending.sync.util')
describe('sync util', function()
before_each(function()
config.reset()
end)
after_each(function()
config.reset()
end)
describe('fmt_counts', function()
it('returns nothing to do for empty counts', function()
assert.equals('nothing to do', util.fmt_counts({}))
end)
it('returns nothing to do when all zero', function()
assert.equals('nothing to do', util.fmt_counts({ { 0, 'added' }, { 0, 'failed' } }))
end)
it('formats single non-zero count', function()
assert.equals('3 added', util.fmt_counts({ { 3, 'added' }, { 0, 'failed' } }))
end)
it('joins multiple non-zero counts with pipe', function()
local result = util.fmt_counts({ { 2, 'added' }, { 1, 'updated' }, { 0, 'failed' } })
assert.equals('2 added | 1 updated', result)
end)
end)
describe('with_guard', function()
it('prevents concurrent calls', function()
local inner_called = false
local blocked = false
local msgs = {}
local orig = vim.notify
vim.notify = function(m, level)
if level == vim.log.levels.WARN then
table.insert(msgs, m)
end
end
util.with_guard('test', function()
inner_called = true
util.with_guard('test2', function()
blocked = true
end)
end)
vim.notify = orig
assert.is_true(inner_called)
assert.is_false(blocked)
assert.equals(1, #msgs)
assert.truthy(msgs[1]:find('Sync already in progress'))
end)
it('clears guard after error', function()
pcall(util.with_guard, 'err-test', function()
error('boom')
end)
assert.is_false(util.sync_in_flight())
end)
it('clears guard after success', function()
util.with_guard('ok-test', function() end)
assert.is_false(util.sync_in_flight())
end)
end)
describe('finish', function()
it('calls save and recompute', function()
local helpers = require('spec.helpers')
local store_mod = require('pending.store')
local tmpdir = helpers.tmpdir()
vim.g.pending = { data_path = tmpdir .. '/tasks.json' }
config.reset()
package.loaded['pending'] = nil
local s = store_mod.new(tmpdir .. '/tasks.json')
s:load()
s:add({ description = 'Test', status = 'pending', category = 'Work', priority = 0 })
util.finish(s)
local reloaded = store_mod.new(tmpdir .. '/tasks.json')
reloaded:load()
assert.equals(1, #reloaded:tasks())
vim.fn.delete(tmpdir, 'rf')
vim.g.pending = nil
config.reset()
package.loaded['pending'] = nil
end)
end)
end)

194
spec/textobj_spec.lua Normal file
View file

@ -0,0 +1,194 @@
require('spec.helpers')
local config = require('pending.config')
describe('textobj', function()
local textobj = require('pending.textobj')
before_each(function()
vim.g.pending = nil
config.reset()
end)
after_each(function()
vim.g.pending = nil
config.reset()
end)
describe('inner_task_range', function()
it('returns description range for task with id prefix', function()
local s, e = textobj.inner_task_range('/1/- [ ] Buy groceries')
assert.are.equal(10, s)
assert.are.equal(22, e)
end)
it('returns description range for task without id prefix', function()
local s, e = textobj.inner_task_range('- [ ] Buy groceries')
assert.are.equal(7, s)
assert.are.equal(19, e)
end)
it('excludes trailing due: token', function()
local s, e = textobj.inner_task_range('/1/- [ ] Buy groceries due:2026-03-15')
assert.are.equal(10, s)
assert.are.equal(22, e)
end)
it('excludes trailing cat: token', function()
local s, e = textobj.inner_task_range('/1/- [ ] Buy groceries cat:Errands')
assert.are.equal(10, s)
assert.are.equal(22, e)
end)
it('excludes trailing rec: token', function()
local s, e = textobj.inner_task_range('/1/- [ ] Take out trash rec:weekly')
assert.are.equal(10, s)
assert.are.equal(23, e)
end)
it('excludes multiple trailing metadata tokens', function()
local s, e =
textobj.inner_task_range('/1/- [ ] Buy milk due:2026-03-15 cat:Errands rec:weekly')
assert.are.equal(10, s)
assert.are.equal(17, e)
end)
it('handles priority checkbox', function()
local s, e = textobj.inner_task_range('/1/- [!] Important task')
assert.are.equal(10, s)
assert.are.equal(23, e)
end)
it('handles done checkbox', function()
local s, e = textobj.inner_task_range('/1/- [x] Finished task')
assert.are.equal(10, s)
assert.are.equal(22, e)
end)
it('handles multi-digit task ids', function()
local s, e = textobj.inner_task_range('/123/- [ ] Some task')
assert.are.equal(12, s)
assert.are.equal(20, e)
end)
it('does not strip non-metadata tokens', function()
local s, e = textobj.inner_task_range('/1/- [ ] Buy groceries for dinner')
assert.are.equal(10, s)
assert.are.equal(33, e)
end)
it('stops stripping at first non-metadata token from right', function()
local s, e = textobj.inner_task_range('/1/- [ ] Buy groceries for dinner due:2026-03-15')
assert.are.equal(10, s)
assert.are.equal(33, e)
end)
it('respects custom date_syntax', function()
vim.g.pending = { date_syntax = 'by' }
config.reset()
local s, e = textobj.inner_task_range('/1/- [ ] Buy groceries by:2026-03-15')
assert.are.equal(10, s)
assert.are.equal(22, e)
end)
it('respects custom recur_syntax', function()
vim.g.pending = { recur_syntax = 'repeat' }
config.reset()
local s, e = textobj.inner_task_range('/1/- [ ] Take trash repeat:weekly')
assert.are.equal(10, s)
assert.are.equal(19, e)
end)
it('handles task with only metadata after description', function()
local s, e = textobj.inner_task_range('/1/- [ ] X due:tomorrow')
assert.are.equal(10, s)
assert.are.equal(10, e)
end)
end)
describe('category_bounds', function()
it('returns header and last row for single category', function()
---@type pending.LineMeta[]
local meta = {
{ type = 'header', category = 'Work' },
{ type = 'task', id = 1 },
{ type = 'task', id = 2 },
}
local h, l = textobj.category_bounds(2, meta)
assert.are.equal(1, h)
assert.are.equal(3, l)
end)
it('returns bounds for first category with trailing blank', function()
---@type pending.LineMeta[]
local meta = {
{ type = 'header', category = 'Work' },
{ type = 'task', id = 1 },
{ type = 'blank' },
{ type = 'header', category = 'Personal' },
{ type = 'task', id = 2 },
}
local h, l = textobj.category_bounds(2, meta)
assert.are.equal(1, h)
assert.are.equal(3, l)
end)
it('returns bounds for second category', function()
---@type pending.LineMeta[]
local meta = {
{ type = 'header', category = 'Work' },
{ type = 'task', id = 1 },
{ type = 'blank' },
{ type = 'header', category = 'Personal' },
{ type = 'task', id = 2 },
{ type = 'task', id = 3 },
}
local h, l = textobj.category_bounds(5, meta)
assert.are.equal(4, h)
assert.are.equal(6, l)
end)
it('returns bounds when cursor is on header', function()
---@type pending.LineMeta[]
local meta = {
{ type = 'header', category = 'Work' },
{ type = 'task', id = 1 },
}
local h, l = textobj.category_bounds(1, meta)
assert.are.equal(1, h)
assert.are.equal(2, l)
end)
it('returns nil for blank line with no preceding header', function()
---@type pending.LineMeta[]
local meta = {
{ type = 'blank' },
{ type = 'header', category = 'Work' },
{ type = 'task', id = 1 },
}
local h, l = textobj.category_bounds(1, meta)
assert.is_nil(h)
assert.is_nil(l)
end)
it('returns nil for empty meta', function()
local h, l = textobj.category_bounds(1, {})
assert.is_nil(h)
assert.is_nil(l)
end)
it('includes blank between header and next header in bounds', function()
---@type pending.LineMeta[]
local meta = {
{ type = 'header', category = 'Work' },
{ type = 'task', id = 1 },
{ type = 'blank' },
{ type = 'header', category = 'Home' },
{ type = 'task', id = 2 },
}
local h, l = textobj.category_bounds(1, meta)
assert.are.equal(1, h)
assert.are.equal(3, l)
end)
end)
end)

View file

@ -5,39 +5,38 @@ 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()
store.unload() s = store.new(tmpdir .. '/tasks.json')
store.load() s: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()
store.add({ description = 'Task A', category = 'Work' }) s:add({ description = 'Task A', category = 'Work' })
store.add({ description = 'Task B', category = 'Work' }) s:add({ description = 'Task B', category = 'Work' })
local lines, meta = views.category_view(store.active_tasks()) local lines, meta = views.category_view(s:active_tasks())
assert.are.equal('## Work', lines[1]) assert.are.equal('# Work', lines[1])
assert.are.equal('header', meta[1].type) assert.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 = store.add({ description = 'Done task', category = 'Work' }) local t1 = s:add({ description = 'Done task', category = 'Work' })
store.add({ description = 'Pending task', category = 'Work' }) s:add({ description = 'Pending task', category = 'Work' })
store.update(t1.id, { status = 'done' }) s:update(t1.id, { status = 'done' })
local _, meta = views.category_view(store.active_tasks()) local _, meta = views.category_view(s: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
@ -50,9 +49,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()
store.add({ description = 'Normal', category = 'Work', priority = 0 }) s:add({ description = 'Normal', category = 'Work', priority = 0 })
store.add({ description = 'High', category = 'Work', priority = 1 }) s:add({ description = 'High', category = 'Work', priority = 1 })
local lines, meta = views.category_view(store.active_tasks()) local lines, meta = views.category_view(s: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
@ -68,11 +67,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 = store.add({ description = 'Done Normal', category = 'Work', priority = 0 }) local t1 = s:add({ description = 'Done Normal', category = 'Work', priority = 0 })
local t2 = store.add({ description = 'Done High', category = 'Work', priority = 1 }) local t2 = s:add({ description = 'Done High', category = 'Work', priority = 1 })
store.update(t1.id, { status = 'done' }) s:update(t1.id, { status = 'done' })
store.update(t2.id, { status = 'done' }) s:update(t2.id, { status = 'done' })
local lines, meta = views.category_view(store.active_tasks()) local lines, meta = views.category_view(s: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
@ -88,9 +87,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()
store.add({ description = 'Task A', category = 'Work' }) s:add({ description = 'Task A', category = 'Work' })
store.add({ description = 'Task B', category = 'Personal' }) s:add({ description = 'Task B', category = 'Personal' })
local lines, meta = views.category_view(store.active_tasks()) local lines, meta = views.category_view(s: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
@ -105,8 +104,8 @@ describe('views', function()
end) end)
it('formats task lines as /ID/ description', function() it('formats task lines as /ID/ description', function()
store.add({ description = 'My task', category = 'Inbox' }) s:add({ description = 'My task', category = 'Inbox' })
local lines, meta = views.category_view(store.active_tasks()) local lines, meta = views.category_view(s: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
@ -117,8 +116,8 @@ describe('views', function()
end) end)
it('formats priority task lines as /ID/- [!] description', function() it('formats priority task lines as /ID/- [!] description', function()
store.add({ description = 'Important', category = 'Inbox', priority = 1 }) s:add({ description = 'Important', category = 'Inbox', priority = 1 })
local lines, meta = views.category_view(store.active_tasks()) local lines, meta = views.category_view(s: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
@ -129,15 +128,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()
store.add({ description = 'T', category = 'School' }) s:add({ description = 'T', category = 'School' })
local _, meta = views.category_view(store.active_tasks()) local _, meta = views.category_view(s: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 = store.add({ description = 'Do something', category = 'Inbox' }) local t = s:add({ description = 'Do something', category = 'Inbox' })
local _, meta = views.category_view(store.active_tasks()) local _, meta = views.category_view(s: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
@ -150,9 +149,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()
store.add({ description = 'A', category = 'Work' }) s:add({ description = 'A', category = 'Work' })
store.add({ description = 'B', category = 'Home' }) s:add({ description = 'B', category = 'Home' })
local _, meta = views.category_view(store.active_tasks()) local _, meta = views.category_view(s: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
@ -166,8 +165,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 = store.add({ description = 'Overdue task', category = 'Inbox', due = yesterday }) local t = s:add({ description = 'Overdue task', category = 'Inbox', due = yesterday })
local _, meta = views.category_view(store.active_tasks()) local _, meta = views.category_view(s: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
@ -179,8 +178,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 = store.add({ description = 'Future task', category = 'Inbox', due = tomorrow }) local t = s:add({ description = 'Future task', category = 'Inbox', due = tomorrow })
local _, meta = views.category_view(store.active_tasks()) local _, meta = views.category_view(s: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
@ -192,9 +191,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 = store.add({ description = 'Done late', category = 'Inbox', due = yesterday }) local t = s:add({ description = 'Done late', category = 'Inbox', due = yesterday })
store.update(t.id, { status = 'done' }) s:update(t.id, { status = 'done' })
local _, meta = views.category_view(store.active_tasks()) local _, meta = views.category_view(s: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
@ -204,12 +203,39 @@ 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',
view = { category = { order = { 'Work', 'Inbox' } } },
}
config.reset() config.reset()
store.add({ description = 'Inbox task', category = 'Inbox' }) s:add({ description = 'Inbox task', category = 'Inbox' })
store.add({ description = 'Work task', category = 'Work' }) s:add({ description = 'Work task', category = 'Work' })
local lines, meta = views.category_view(store.active_tasks()) local lines, meta = views.category_view(s: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
@ -220,47 +246,48 @@ 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', view = { category = { order = { 'Work' } } } }
config.reset() config.reset()
store.add({ description = 'Errand', category = 'Errands' }) s:add({ description = 'Errand', category = 'Errands' })
store.add({ description = 'Work task', category = 'Work' }) s:add({ description = 'Work task', category = 'Work' })
local lines, meta = views.category_view(store.active_tasks()) local lines, meta = views.category_view(s: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()
store.add({ description = 'Alpha task', category = 'Alpha' }) s:add({ description = 'Alpha task', category = 'Alpha' })
store.add({ description = 'Beta task', category = 'Beta' }) s:add({ description = 'Beta task', category = 'Beta' })
local lines, meta = views.category_view(store.active_tasks()) local lines, meta = views.category_view(s: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 = store.add({ description = 'Done A', category = 'Work' }) local t1 = s:add({ description = 'Done A', category = 'Work' })
store.add({ description = 'Pending B', category = 'Work' }) s:add({ description = 'Pending B', category = 'Work' })
store.update(t1.id, { status = 'done' }) s:update(t1.id, { status = 'done' })
local _, meta = views.priority_view(store.active_tasks()) local _, meta = views.priority_view(s: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
@ -275,9 +302,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()
store.add({ description = 'Low', category = 'Work', priority = 0 }) s:add({ description = 'Low', category = 'Work', priority = 0 })
store.add({ description = 'High', category = 'Work', priority = 1 }) s:add({ description = 'High', category = 'Work', priority = 1 })
local lines, meta = views.priority_view(store.active_tasks()) local lines, meta = views.priority_view(s: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
@ -292,9 +319,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()
store.add({ description = 'No due', category = 'Work' }) s:add({ description = 'No due', category = 'Work' })
store.add({ description = 'Has due', category = 'Work', due = '2099-12-31' }) s:add({ description = 'Has due', category = 'Work', due = '2099-12-31' })
local lines, meta = views.priority_view(store.active_tasks()) local lines, meta = views.priority_view(s: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
@ -309,9 +336,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()
store.add({ description = 'Later', category = 'Work', due = '2099-12-31' }) s:add({ description = 'Later', category = 'Work', due = '2099-12-31' })
store.add({ description = 'Earlier', category = 'Work', due = '2050-01-01' }) s:add({ description = 'Earlier', category = 'Work', due = '2050-01-01' })
local lines, meta = views.priority_view(store.active_tasks()) local lines, meta = views.priority_view(s: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
@ -326,15 +353,15 @@ describe('views', function()
end) end)
it('formats task lines as /ID/- [ ] description', function() it('formats task lines as /ID/- [ ] description', function()
store.add({ description = 'My task', category = 'Inbox' }) s:add({ description = 'My task', category = 'Inbox' })
local lines, _ = views.priority_view(store.active_tasks()) local lines, _ = views.priority_view(s: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()
store.add({ description = 'T1', category = 'Work' }) s:add({ description = 'T1', category = 'Work' })
store.add({ description = 'T2', category = 'Personal' }) s:add({ description = 'T2', category = 'Personal' })
local _, meta = views.priority_view(store.active_tasks()) local _, meta = views.priority_view(s: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)
@ -343,9 +370,9 @@ describe('views', function()
end) end)
it('sets meta.category correctly for each task', function() it('sets meta.category correctly for each task', function()
store.add({ description = 'Work task', category = 'Work' }) s:add({ description = 'Work task', category = 'Work' })
store.add({ description = 'Home task', category = 'Home' }) s:add({ description = 'Home task', category = 'Home' })
local lines, meta = views.priority_view(store.active_tasks()) local lines, meta = views.priority_view(s: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
@ -362,8 +389,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 = store.add({ description = 'Overdue', category = 'Inbox', due = yesterday }) local t = s:add({ description = 'Overdue', category = 'Inbox', due = yesterday })
local _, meta = views.priority_view(store.active_tasks()) local _, meta = views.priority_view(s: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
@ -375,8 +402,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 = store.add({ description = 'Future', category = 'Inbox', due = tomorrow }) local t = s:add({ description = 'Future', category = 'Inbox', due = tomorrow })
local _, meta = views.priority_view(store.active_tasks()) local _, meta = views.priority_view(s: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
@ -388,9 +415,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 = store.add({ description = 'Done late', category = 'Inbox', due = yesterday }) local t = s:add({ description = 'Done late', category = 'Inbox', due = yesterday })
store.update(t.id, { status = 'done' }) s:update(t.id, { status = 'done' })
local _, meta = views.priority_view(store.active_tasks()) local _, meta = views.priority_view(s: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
@ -399,5 +426,107 @@ 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)
it('sorts by due before priority when sort config is reordered', function()
vim.g.pending = {
data_path = tmpdir .. '/tasks.json',
view = { queue = { sort = { 'status', 'due', 'priority', 'order', 'id' } } },
}
config.reset()
s:add({ description = 'High no due', category = 'Work', priority = 2 })
s:add({ description = 'Low with due', category = 'Work', priority = 0, due = '2050-01-01' })
local lines, meta = views.priority_view(s:active_tasks())
local due_row, nodue_row
for i, m in ipairs(meta) do
if m.type == 'task' then
if lines[i]:find('Low with due') then
due_row = i
elseif lines[i]:find('High no due') then
nodue_row = i
end
end
end
assert.is_true(due_row < nodue_row)
end)
it('uses default sort when config sort is nil', function()
vim.g.pending = {
data_path = tmpdir .. '/tasks.json',
view = { queue = {} },
}
config.reset()
s:add({ description = 'Low', category = 'Work', priority = 0 })
s:add({ description = 'High', category = 'Work', priority = 1 })
local lines, meta = views.priority_view(s:active_tasks())
local high_row, low_row
for i, m in ipairs(meta) do
if m.type == 'task' then
if lines[i]:find('High') then
high_row = i
elseif lines[i]:find('Low') then
low_row = i
end
end
end
assert.is_true(high_row < low_row)
end)
it('ignores unknown sort keys with a warning', function()
vim.g.pending = {
data_path = tmpdir .. '/tasks.json',
view = { queue = { sort = { 'bogus', 'status', 'id' } } },
}
config.reset()
s:add({ description = 'A', category = 'Work' })
s:add({ description = 'B', category = 'Work' })
local lines = views.priority_view(s:active_tasks())
assert.is_true(#lines == 2)
end)
it('supports age sort key as alias for id', function()
vim.g.pending = {
data_path = tmpdir .. '/tasks.json',
view = { queue = { sort = { 'age' } } },
}
config.reset()
s:add({ description = 'Older', category = 'Work' })
s:add({ description = 'Newer', category = 'Work' })
local lines, meta = views.priority_view(s:active_tasks())
local older_row, newer_row
for i, m in ipairs(meta) do
if m.type == 'task' then
if lines[i]:find('Older') then
older_row = i
elseif lines[i]:find('Newer') then
newer_row = i
end
end
end
assert.is_true(older_row < newer_row)
end)
end) end)
end) end)