Compare commits
103 commits
doc/minify
...
feat/detai
| Author | SHA1 | Date | |
|---|---|---|---|
| d7d79b5b87 | |||
| 0b0b64fc3d | |||
|
|
c04057dd9f | ||
|
|
7c3ba31c43 | ||
|
|
4a37cb64e4 | ||
| 5ab0aa78a1 | |||
| b2456580b5 | |||
|
|
969dbd299f | ||
|
|
283f93eda1 | ||
|
|
ea59bbae96 | ||
|
|
9593ab7fe8 | ||
|
|
d35f34d8e0 | ||
|
|
c9790ed3bf | ||
|
|
939251f629 | ||
|
|
46b5d52b60 | ||
|
|
1064b7535a | ||
|
|
6f71ab14ad | ||
|
|
ff9f601f68 | ||
|
|
0d62cd9e40 | ||
|
|
343dbb202b | ||
|
|
26b8bb4beb | ||
|
|
1bd2ef914b | ||
|
|
0ccfb2da4b | ||
|
|
2998585587 | ||
|
|
07024671eb | ||
|
|
0d4d3fead6 | ||
|
|
fec03b3dcd | ||
|
|
be3d9b777e | ||
|
|
914633235a | ||
|
|
1eb2e49096 | ||
|
|
a38be10e67 | ||
|
|
96577890cb | ||
|
|
c37cf7cc3a | ||
|
|
9672af7c08 | ||
|
|
dc365e266b | ||
|
|
7640241cf2 | ||
| ee75e6844e | |||
|
|
fe4c1d0e31 | ||
|
|
ac02526cf1 | ||
|
|
b06249f101 | ||
|
|
073541424e | ||
|
|
e534e869a8 | ||
|
|
36a469e964 | ||
|
|
a43f769383 | ||
|
|
91cce0a82e | ||
|
|
c9471ebe90 | ||
|
|
ab06cfcf69 | ||
| d06731a7fd | |||
|
|
9af6086959 | ||
| 26d43688d0 | |||
|
|
0176592ae2 | ||
|
|
09757a593b | ||
| 79aeeba9bb | |||
| 522daf3a21 | |||
|
|
d176ccccd1 | ||
| 5fe6dcecad | |||
|
|
559ab863a8 | ||
|
|
c392874311 | ||
|
|
12b9295c34 | ||
|
|
874ff381f9 | ||
|
|
b641c93a0a | ||
|
|
ff73164b61 | ||
|
|
991ac5b467 | ||
|
|
1e2196fe2e | ||
|
|
2929b4d8fa | ||
|
|
7ad27f6fca | ||
|
|
55e83644b3 | ||
|
|
0e64aa59f1 | ||
|
|
6e381c0d5f | ||
|
|
ad59e894c7 | ||
|
|
87d8bf0896 | ||
|
|
f78f8e42fa | ||
|
|
0163941a2b | ||
|
|
7fb3289b21 | ||
|
|
b7ce1c05ec | ||
|
|
e0e3af6787 | ||
|
|
21628abe53 | ||
|
|
3e8fd0a6a3 | ||
|
|
ee8b660f7c | ||
|
|
7718ebed42 | ||
| 627100eb8c | |||
| 51508285ac | |||
|
|
a24521ee4e | ||
|
|
e0b192a88a | ||
|
|
4612960b9a | ||
| 3ee26112a6 | |||
| 59479ddb0d | |||
|
|
8c90d0ddd1 | ||
|
|
0e0568769d | ||
|
|
64b19360b1 | ||
|
|
1748e5caa1 | ||
|
|
994294393c | ||
|
|
dcb6a4781d | ||
|
|
3da23c924a | ||
|
|
8d3d21b330 | ||
|
|
e62e09f609 | ||
|
|
302bf8126f | ||
|
|
c57cc0845b | ||
|
|
72dbf037c7 | ||
|
|
b76c680e1f | ||
|
|
379e281ecd | ||
|
|
7d93c4bb45 | ||
|
|
6911c091f6 |
47 changed files with 13863 additions and 1446 deletions
2
.github/DISCUSSION_TEMPLATE/q-a.yaml
vendored
2
.github/DISCUSSION_TEMPLATE/q-a.yaml
vendored
|
|
@ -1,4 +1,4 @@
|
|||
title: 'Q&A'
|
||||
title: "Q&A"
|
||||
labels: []
|
||||
body:
|
||||
- type: markdown
|
||||
|
|
|
|||
17
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
17
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
|
|
@ -1,14 +1,13 @@
|
|||
name: Bug Report
|
||||
description: Report a bug
|
||||
title: 'bug: '
|
||||
title: "bug: "
|
||||
labels: [bug]
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Prerequisites
|
||||
options:
|
||||
- label:
|
||||
I have searched [existing
|
||||
- label: I have searched [existing
|
||||
issues](https://github.com/barrettruth/pending.nvim/issues)
|
||||
required: true
|
||||
- label: I have updated to the latest version
|
||||
|
|
@ -16,16 +15,16 @@ body:
|
|||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: 'Neovim version'
|
||||
description: 'Output of `nvim --version`'
|
||||
label: "Neovim version"
|
||||
description: "Output of `nvim --version`"
|
||||
render: text
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
attributes:
|
||||
label: 'Operating system'
|
||||
placeholder: 'e.g. Arch Linux, macOS 15, Ubuntu 24.04'
|
||||
label: "Operating system"
|
||||
placeholder: "e.g. Arch Linux, macOS 15, Ubuntu 24.04"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
|
@ -49,8 +48,8 @@ body:
|
|||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: 'Health check'
|
||||
description: 'Output of `:checkhealth task`'
|
||||
label: "Health check"
|
||||
description: "Output of `:checkhealth task`"
|
||||
render: text
|
||||
|
||||
- type: textarea
|
||||
|
|
|
|||
5
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
5
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
|
|
@ -1,14 +1,13 @@
|
|||
name: Feature Request
|
||||
description: Suggest a feature
|
||||
title: 'feat: '
|
||||
title: "feat: "
|
||||
labels: [enhancement]
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Prerequisites
|
||||
options:
|
||||
- label:
|
||||
I have searched [existing
|
||||
- label: I have searched [existing
|
||||
issues](https://github.com/barrettruth/pending.nvim/issues)
|
||||
required: true
|
||||
|
||||
|
|
|
|||
2
.github/workflows/luarocks.yaml
vendored
2
.github/workflows/luarocks.yaml
vendored
|
|
@ -3,7 +3,7 @@ name: luarocks
|
|||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,5 +1,6 @@
|
|||
doc/tags
|
||||
*.log
|
||||
minimal_init.lua
|
||||
|
||||
.*cache*
|
||||
CLAUDE.md
|
||||
|
|
|
|||
|
|
@ -2,7 +2,14 @@
|
|||
"runtime.version": "LuaJIT",
|
||||
"runtime.path": ["lua/?.lua", "lua/?/init.lua"],
|
||||
"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.ignoreDir": [".direnv"],
|
||||
"completion.callSnippet": "Replace"
|
||||
}
|
||||
|
|
|
|||
165
README.md
165
README.md
|
|
@ -1,146 +1,53 @@
|
|||
# pending.nvim
|
||||
|
||||
Edit tasks like text. `:w` saves them.
|
||||
**Edit tasks like text.**
|
||||
|
||||
A buffer-centric task manager for Neovim. Tasks live in a plain buffer — add
|
||||
with `o`, delete with `dd`, reorder with `dd`/`p`, rename by editing. Write the
|
||||
buffer and the diff is computed against a JSON store. No UI chrome, no floating
|
||||
windows, no abstractions between you and your tasks.
|
||||
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)
|
||||
|
||||
## How it works
|
||||
https://github.com/user-attachments/assets/f3898ecb-ec95-43fe-a71f-9c9f49628ba9
|
||||
|
||||
```
|
||||
School
|
||||
! Read chapter 5 Feb 28
|
||||
Submit homework Feb 25
|
||||
## Features
|
||||
|
||||
Errands
|
||||
Buy groceries Mar 01
|
||||
Clean apartment
|
||||
```
|
||||
- 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
|
||||
|
||||
Category headers sit at column 0. Tasks are indented below them. `!` marks
|
||||
priority. Due dates appear as right-aligned virtual text. Done tasks get
|
||||
strikethrough. Everything you see is editable buffer text — the IDs are
|
||||
concealed, and metadata is parsed from inline syntax on save.
|
||||
## Requirements
|
||||
|
||||
## Install
|
||||
- Neovim 0.10+
|
||||
- (Optionally) `curl` for Google Calendar and Google Tasks sync
|
||||
- (Optionally) `aws` CLI for S3 sync
|
||||
|
||||
## Installation
|
||||
|
||||
Install with your package manager of choice or via
|
||||
[luarocks](https://luarocks.org/modules/barrettruth/pending.nvim):
|
||||
|
||||
```
|
||||
luarocks install pending.nvim
|
||||
```
|
||||
|
||||
**lazy.nvim:**
|
||||
|
||||
```lua
|
||||
{ 'barrettruth/pending.nvim' }
|
||||
```
|
||||
|
||||
Requires Neovim 0.10+. No external dependencies for local use. Google Calendar
|
||||
sync requires `curl` and `openssl`.
|
||||
|
||||
## Usage
|
||||
|
||||
`:Pending` opens the task buffer. From there, it's just vim:
|
||||
|
||||
| Key | Action |
|
||||
| --------- | ------------------------------- |
|
||||
| `o` / `O` | Add a new task |
|
||||
| `dd` | Delete a task (on `:w`) |
|
||||
| `p` | Paste (duplicates get new IDs) |
|
||||
| `:w` | Save all changes |
|
||||
| `<CR>` | Toggle complete (immediate) |
|
||||
| `<Tab>` | Switch category / priority view |
|
||||
| `g?` | Show keybind help |
|
||||
|
||||
### Inline metadata
|
||||
|
||||
Type metadata tokens at the end of a task line before saving:
|
||||
|
||||
```
|
||||
Buy milk due:2026-03-15 cat:Errands
|
||||
```
|
||||
|
||||
On `:w`, the date and category are extracted. The description becomes `Buy milk`,
|
||||
the due date renders as virtual text, and the task moves under the `Errands`
|
||||
header.
|
||||
|
||||
### Quick add
|
||||
|
||||
```vim
|
||||
:Pending add Buy groceries due:2026-03-15
|
||||
:Pending add School: Submit homework
|
||||
```
|
||||
|
||||
### Archive
|
||||
|
||||
```vim
|
||||
:Pending archive " purge done tasks older than 30 days
|
||||
:Pending archive 7 " purge done tasks older than 7 days
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
No `setup()` call required. Set `vim.g.pending` before the plugin loads:
|
||||
|
||||
```lua
|
||||
vim.g.pending = {
|
||||
data_path = vim.fn.stdpath('data') .. '/pending/tasks.json',
|
||||
default_view = 'category', -- 'category' or 'priority'
|
||||
default_category = 'Inbox',
|
||||
date_format = '%b %d', -- strftime format for virtual text
|
||||
date_syntax = 'due', -- inline token name (e.g. 'by' for by:2026-03-15)
|
||||
}
|
||||
```
|
||||
|
||||
All fields are optional. Absent keys use the defaults shown above.
|
||||
|
||||
## Google Calendar sync
|
||||
|
||||
One-way push of tasks with due dates to a dedicated Google Calendar as all-day
|
||||
events.
|
||||
|
||||
```lua
|
||||
vim.g.pending = {
|
||||
gcal = {
|
||||
calendar = 'Tasks',
|
||||
credentials_path = '/path/to/client_secret.json',
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
```vim
|
||||
:Pending sync
|
||||
```
|
||||
|
||||
On first run, a browser window opens for OAuth consent. The refresh token is
|
||||
stored at `stdpath('data')/pending/gcal_tokens.json`. Completed or deleted tasks
|
||||
have their calendar events removed. Due date changes update events in place.
|
||||
|
||||
## Mappings
|
||||
|
||||
The plugin defines `<Plug>` mappings for custom keybinds:
|
||||
|
||||
```lua
|
||||
vim.keymap.set('n', '<leader>t', '<Plug>(pending-open)')
|
||||
vim.keymap.set('n', '<leader>T', '<Plug>(pending-toggle)')
|
||||
```
|
||||
|
||||
| Plug mapping | Action |
|
||||
| -------------------------- | -------------------- |
|
||||
| `<Plug>(pending-open)` | Open task buffer |
|
||||
| `<Plug>(pending-toggle)` | Toggle complete |
|
||||
| `<Plug>(pending-view)` | Switch view |
|
||||
| `<Plug>(pending-priority)` | Toggle priority flag |
|
||||
| `<Plug>(pending-date)` | Prompt for due date |
|
||||
|
||||
## Data format
|
||||
|
||||
Tasks are stored as JSON at `stdpath('data')/pending/tasks.json`. The schema is
|
||||
versioned and forward-compatible — unknown fields are preserved on round-trip.
|
||||
|
||||
## Documentation
|
||||
|
||||
```vim
|
||||
:checkhealth pending
|
||||
:help pending.nvim
|
||||
```
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
- [dooing](https://github.com/atiladefreitas/dooing)
|
||||
- [todo-comments.nvim](https://github.com/folke/todo-comments.nvim)
|
||||
- [todotxt.nvim](https://github.com/arnarg/todotxt.nvim)
|
||||
|
|
|
|||
1591
doc/pending.txt
1591
doc/pending.txt
File diff suppressed because it is too large
Load diff
|
|
@ -13,9 +13,12 @@
|
|||
...
|
||||
}:
|
||||
let
|
||||
forEachSystem = f: nixpkgs.lib.genAttrs (import systems) (system: f nixpkgs.legacyPackages.${system});
|
||||
forEachSystem =
|
||||
f: nixpkgs.lib.genAttrs (import systems) (system: f nixpkgs.legacyPackages.${system});
|
||||
in
|
||||
{
|
||||
formatter = forEachSystem (pkgs: pkgs.nixfmt-tree);
|
||||
|
||||
devShells = forEachSystem (pkgs: {
|
||||
default = pkgs.mkShell {
|
||||
packages = [
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
218
lua/pending/complete.lua
Normal file
218
lua/pending/complete.lua
Normal 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
|
||||
|
|
@ -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
|
||||
---@field calendar? string
|
||||
---@field remote_delete? boolean
|
||||
---@field credentials_path? string
|
||||
---@field client_id? string
|
||||
---@field client_secret? string
|
||||
|
||||
---@class pending.GtasksConfig
|
||||
---@field remote_delete? boolean
|
||||
---@field credentials_path? string
|
||||
---@field client_id? string
|
||||
---@field client_secret? string
|
||||
|
||||
---@class pending.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
|
||||
---@field data_path string
|
||||
---@field default_view 'category'|'priority'
|
||||
---@field default_category string
|
||||
---@field date_format string
|
||||
---@field category_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 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
|
||||
local M = {}
|
||||
|
|
@ -18,11 +121,87 @@ local M = {}
|
|||
---@type pending.Config
|
||||
local defaults = {
|
||||
data_path = vim.fn.stdpath('data') .. '/pending/tasks.json',
|
||||
default_view = 'category',
|
||||
default_category = 'Todo',
|
||||
date_format = '%b %d',
|
||||
category_syntax = 'cat',
|
||||
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?
|
||||
|
|
@ -38,8 +217,20 @@ function M.get()
|
|||
return _resolved
|
||||
end
|
||||
|
||||
---@return nil
|
||||
function M.reset()
|
||||
_resolved = nil
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,15 +1,18 @@
|
|||
local config = require('pending.config')
|
||||
local forge = require('pending.forge')
|
||||
local parse = require('pending.parse')
|
||||
local store = require('pending.store')
|
||||
|
||||
---@class pending.ParsedEntry
|
||||
---@field type 'task'|'header'|'blank'
|
||||
---@field id? integer
|
||||
---@field description? string
|
||||
---@field priority? integer
|
||||
---@field status? string
|
||||
---@field status? pending.TaskStatus
|
||||
---@field category? string
|
||||
---@field due? string
|
||||
---@field recur? string
|
||||
---@field recur_mode? pending.RecurMode
|
||||
---@field forge_ref? pending.ForgeRef
|
||||
---@field lnum integer
|
||||
|
||||
---@class pending.diff
|
||||
|
|
@ -25,34 +28,56 @@ end
|
|||
function M.parse_buffer(lines)
|
||||
local result = {}
|
||||
local current_category = nil
|
||||
local start = 1
|
||||
if lines[1] and lines[1]:match('^FILTER:') then
|
||||
start = 2
|
||||
end
|
||||
|
||||
for i, line in ipairs(lines) do
|
||||
local id, body = line:match('^/(%d+)/(- %[.%] .*)$')
|
||||
for i = start, #lines do
|
||||
local line = lines[i]
|
||||
local id, body = line:match('^/(%d+)/(- %[.?%] .*)$')
|
||||
if not id then
|
||||
body = line:match('^(- %[.%] .*)$')
|
||||
body = line:match('^(- %[.?%] .*)$')
|
||||
end
|
||||
if line == '' then
|
||||
table.insert(result, { type = 'blank', lnum = i })
|
||||
elseif id or body then
|
||||
local stripped = body:match('^- %[.%] (.*)$') or body
|
||||
local state_char = body:match('^- %[(.-)%]') or ' '
|
||||
local priority = state_char == '!' and 1 or 0
|
||||
local status = state_char == 'x' and 'done' or 'pending'
|
||||
local stripped = body:match('^- %[.?%] (.*)$') or body
|
||||
local icons = config.get().icons
|
||||
local state_char = body:match('^- %[(.-)%]') or icons.pending
|
||||
local priority = state_char == icons.priority and 1 or 0
|
||||
local status
|
||||
if state_char == 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)
|
||||
if description and description ~= '' then
|
||||
local refs = forge.find_refs(description)
|
||||
local forge_ref = refs[1] and refs[1].ref or nil
|
||||
table.insert(result, {
|
||||
type = 'task',
|
||||
id = id and tonumber(id) or nil,
|
||||
description = description,
|
||||
priority = priority,
|
||||
priority = metadata.priority or priority,
|
||||
status = status,
|
||||
category = metadata.cat or current_category or config.get().default_category,
|
||||
category = metadata.category or current_category or config.get().default_category,
|
||||
due = metadata.due,
|
||||
recur = metadata.recur,
|
||||
recur_mode = metadata.recur_mode,
|
||||
forge_ref = forge_ref,
|
||||
lnum = i,
|
||||
})
|
||||
end
|
||||
elseif line:match('^## (.+)$') then
|
||||
current_category = line:match('^## (.+)$')
|
||||
elseif line:match('^# (.+)$') then
|
||||
current_category = line:match('^# (.+)$')
|
||||
table.insert(result, { type = 'header', category = current_category, lnum = i })
|
||||
end
|
||||
end
|
||||
|
|
@ -60,11 +85,25 @@ function M.parse_buffer(lines)
|
|||
return result
|
||||
end
|
||||
|
||||
---@param a? pending.ForgeRef
|
||||
---@param b? pending.ForgeRef
|
||||
---@return boolean
|
||||
local function refs_equal(a, b)
|
||||
if not a or not b then
|
||||
return false
|
||||
end
|
||||
return a.forge == b.forge and a.owner == b.owner and a.repo == b.repo and a.number == b.number
|
||||
end
|
||||
|
||||
---@param lines string[]
|
||||
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 now = timestamp()
|
||||
local data = store.data()
|
||||
local data = s:data()
|
||||
local new_refs = {} ---@type pending.ForgeRef[]
|
||||
|
||||
local old_by_id = {}
|
||||
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 seen_ids[entry.id] then
|
||||
store.add({
|
||||
s:add({
|
||||
description = entry.description,
|
||||
category = entry.category,
|
||||
priority = entry.priority,
|
||||
due = entry.due,
|
||||
recur = entry.recur,
|
||||
recur_mode = entry.recur_mode,
|
||||
order = order_counter,
|
||||
_extra = entry.forge_ref and { _forge_ref = entry.forge_ref } or nil,
|
||||
})
|
||||
if entry.forge_ref then
|
||||
table.insert(new_refs, entry.forge_ref)
|
||||
end
|
||||
else
|
||||
seen_ids[entry.id] = true
|
||||
local task = old_by_id[entry.id]
|
||||
|
|
@ -104,17 +149,38 @@ function M.apply(lines)
|
|||
task.category = entry.category
|
||||
changed = true
|
||||
end
|
||||
if task.priority ~= entry.priority then
|
||||
if entry.priority ~= task.priority then
|
||||
task.priority = entry.priority
|
||||
changed = true
|
||||
end
|
||||
if task.due ~= entry.due then
|
||||
if entry.due ~= nil and task.due ~= entry.due then
|
||||
task.due = entry.due
|
||||
changed = true
|
||||
end
|
||||
if 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
|
||||
task.status = entry.status
|
||||
if entry.status == 'done' then
|
||||
if entry.status == 'done' or entry.status == 'cancelled' then
|
||||
task['end'] = now
|
||||
else
|
||||
task['end'] = nil
|
||||
|
|
@ -130,27 +196,34 @@ function M.apply(lines)
|
|||
end
|
||||
end
|
||||
else
|
||||
store.add({
|
||||
s:add({
|
||||
description = entry.description,
|
||||
category = entry.category,
|
||||
priority = entry.priority,
|
||||
due = entry.due,
|
||||
recur = entry.recur,
|
||||
recur_mode = entry.recur_mode,
|
||||
order = order_counter,
|
||||
_extra = entry.forge_ref and { _forge_ref = entry.forge_ref } or nil,
|
||||
})
|
||||
if entry.forge_ref then
|
||||
table.insert(new_refs, entry.forge_ref)
|
||||
end
|
||||
end
|
||||
|
||||
::continue::
|
||||
end
|
||||
|
||||
for id, task in pairs(old_by_id) do
|
||||
if not seen_ids[id] then
|
||||
if not seen_ids[id] and not (hidden_ids and hidden_ids[id]) then
|
||||
task.status = 'deleted'
|
||||
task['end'] = now
|
||||
task.modified = now
|
||||
end
|
||||
end
|
||||
|
||||
store.save()
|
||||
s:save()
|
||||
return new_refs
|
||||
end
|
||||
|
||||
return M
|
||||
|
|
|
|||
713
lua/pending/forge.lua
Normal file
713
lua/pending/forge.lua
Normal 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
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
local M = {}
|
||||
|
||||
---@return nil
|
||||
function M.check()
|
||||
vim.health.start('pending.nvim')
|
||||
|
||||
|
|
@ -9,42 +10,66 @@ function M.check()
|
|||
return
|
||||
end
|
||||
|
||||
local cfg = config.get()
|
||||
config.get()
|
||||
vim.health.ok('Config loaded')
|
||||
vim.health.info('Data path: ' .. cfg.data_path)
|
||||
|
||||
local data_dir = vim.fn.fnamemodify(cfg.data_path, ':h')
|
||||
if vim.fn.isdirectory(data_dir) == 1 then
|
||||
vim.health.ok('Data directory exists: ' .. data_dir)
|
||||
else
|
||||
vim.health.warn('Data directory does not exist yet: ' .. data_dir)
|
||||
local store_ok, store = pcall(require, 'pending.store')
|
||||
if not store_ok then
|
||||
vim.health.error('Failed to load pending.store')
|
||||
return
|
||||
end
|
||||
|
||||
if vim.fn.filereadable(cfg.data_path) == 1 then
|
||||
local store_ok, store = pcall(require, 'pending.store')
|
||||
if store_ok then
|
||||
local load_ok, err = pcall(store.load)
|
||||
if load_ok then
|
||||
local tasks = store.tasks()
|
||||
vim.health.ok('Data file loaded: ' .. #tasks .. ' tasks')
|
||||
else
|
||||
vim.health.error('Failed to load data file: ' .. tostring(err))
|
||||
local resolved_path = store.resolve_path()
|
||||
vim.health.info('Store path: ' .. resolved_path)
|
||||
|
||||
if vim.fn.filereadable(resolved_path) == 1 then
|
||||
local s = store.new(resolved_path)
|
||||
local load_ok, err = pcall(function()
|
||||
s:load()
|
||||
end)
|
||||
if load_ok then
|
||||
local tasks = s:tasks()
|
||||
vim.health.ok('Data file loaded: ' .. #tasks .. ' tasks')
|
||||
local recur = require('pending.recur')
|
||||
local invalid_count = 0
|
||||
for _, task in ipairs(tasks) do
|
||||
if task.recur and not recur.validate(task.recur) then
|
||||
invalid_count = invalid_count + 1
|
||||
vim.health.warn('Task ' .. task.id .. ' has invalid recurrence spec: ' .. task.recur)
|
||||
end
|
||||
end
|
||||
if invalid_count == 0 then
|
||||
vim.health.ok('All recurrence specs are valid')
|
||||
end
|
||||
else
|
||||
vim.health.error('Failed to load data file: ' .. tostring(err))
|
||||
end
|
||||
end
|
||||
|
||||
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
|
||||
vim.health.warn(('%s not found — run `%s`'):format(backend.cli, backend.auth_cmd))
|
||||
end
|
||||
end
|
||||
|
||||
local sync_paths = vim.fn.globpath(vim.o.runtimepath, 'lua/pending/sync/*.lua', false, true)
|
||||
if #sync_paths == 0 then
|
||||
vim.health.info('No sync backends found')
|
||||
else
|
||||
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
|
||||
else
|
||||
vim.health.info('No data file yet (will be created on first save)')
|
||||
end
|
||||
|
||||
if vim.fn.executable('curl') == 1 then
|
||||
vim.health.ok('curl found (required for Google Calendar sync)')
|
||||
else
|
||||
vim.health.warn('curl not found (needed for Google Calendar sync)')
|
||||
end
|
||||
|
||||
if vim.fn.executable('openssl') == 1 then
|
||||
vim.health.ok('openssl found (required for OAuth PKCE)')
|
||||
else
|
||||
vim.health.warn('openssl not found (needed for Google Calendar OAuth)')
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
1613
lua/pending/init.lua
1613
lua/pending/init.lua
File diff suppressed because it is too large
Load diff
30
lua/pending/log.lua
Normal file
30
lua/pending/log.lua
Normal 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
|
||||
|
|
@ -1,4 +1,12 @@
|
|||
local config = require('pending.config')
|
||||
local forge = require('pending.forge')
|
||||
|
||||
---@class pending.Metadata
|
||||
---@field due? string
|
||||
---@field category? string
|
||||
---@field recur? string
|
||||
---@field recur_mode? pending.RecurMode
|
||||
---@field priority? integer
|
||||
|
||||
---@class pending.parse
|
||||
local M = {}
|
||||
|
|
@ -24,11 +32,97 @@ local function is_valid_date(s)
|
|||
return check.year == yn and check.month == mn and check.day == dn
|
||||
end
|
||||
|
||||
---@param s string
|
||||
---@return boolean
|
||||
local function is_valid_time(s)
|
||||
local h, m = s:match('^(%d%d):(%d%d)$')
|
||||
if not h then
|
||||
return false
|
||||
end
|
||||
local hn = tonumber(h) --[[@as integer]]
|
||||
local mn = tonumber(m) --[[@as integer]]
|
||||
return hn >= 0 and hn <= 23 and mn >= 0 and mn <= 59
|
||||
end
|
||||
|
||||
---@param s string
|
||||
---@return string|nil
|
||||
local function normalize_time(s)
|
||||
local h, m, period
|
||||
|
||||
h, m, period = s:match('^(%d+):(%d%d)([ap]m)$')
|
||||
if not h then
|
||||
h, period = s:match('^(%d+)([ap]m)$')
|
||||
if h then
|
||||
m = '00'
|
||||
end
|
||||
end
|
||||
if not h then
|
||||
h, m = s:match('^(%d%d):(%d%d)$')
|
||||
end
|
||||
if not h then
|
||||
h, m = s:match('^(%d):(%d%d)$')
|
||||
end
|
||||
if not h then
|
||||
h = s:match('^(%d+)$')
|
||||
if h then
|
||||
m = '00'
|
||||
end
|
||||
end
|
||||
|
||||
if not h then
|
||||
return nil
|
||||
end
|
||||
|
||||
local hn = tonumber(h) --[[@as integer]]
|
||||
local mn = tonumber(m) --[[@as integer]]
|
||||
|
||||
if period then
|
||||
if hn < 1 or hn > 12 then
|
||||
return nil
|
||||
end
|
||||
if period == 'am' then
|
||||
hn = hn == 12 and 0 or hn
|
||||
else
|
||||
hn = hn == 12 and 12 or hn + 12
|
||||
end
|
||||
else
|
||||
if hn < 0 or hn > 23 then
|
||||
return nil
|
||||
end
|
||||
end
|
||||
|
||||
if mn < 0 or mn > 59 then
|
||||
return nil
|
||||
end
|
||||
|
||||
return string.format('%02d:%02d', hn, mn)
|
||||
end
|
||||
|
||||
---@param s string
|
||||
---@return boolean
|
||||
local function is_valid_datetime(s)
|
||||
local date_part, time_part = s:match('^(.+)T(.+)$')
|
||||
if not date_part then
|
||||
return is_valid_date(s)
|
||||
end
|
||||
return is_valid_date(date_part) and is_valid_time(time_part)
|
||||
end
|
||||
|
||||
---@return string
|
||||
local function category_key()
|
||||
return config.get().category_syntax or 'cat'
|
||||
end
|
||||
|
||||
---@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
|
||||
|
||||
local weekday_map = {
|
||||
sun = 1,
|
||||
mon = 2,
|
||||
|
|
@ -39,53 +133,402 @@ local weekday_map = {
|
|||
sat = 7,
|
||||
}
|
||||
|
||||
local month_map = {
|
||||
jan = 1,
|
||||
feb = 2,
|
||||
mar = 3,
|
||||
apr = 4,
|
||||
may = 5,
|
||||
jun = 6,
|
||||
jul = 7,
|
||||
aug = 8,
|
||||
sep = 9,
|
||||
oct = 10,
|
||||
nov = 11,
|
||||
dec = 12,
|
||||
}
|
||||
|
||||
---@param today osdate
|
||||
---@return string
|
||||
local function today_str(today)
|
||||
return os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day })) --[[@as string]]
|
||||
end
|
||||
|
||||
---@param 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 lower = text:lower()
|
||||
local date_input, time_suffix = text:match('^(.+)@(.+)$')
|
||||
if time_suffix then
|
||||
time_suffix = normalize_time(time_suffix)
|
||||
if not time_suffix then
|
||||
return nil
|
||||
end
|
||||
else
|
||||
date_input = text
|
||||
end
|
||||
|
||||
local dt = date_input:match('^(%d%d%d%d%-%d%d%-%d%dT%d%d:%d%d)$')
|
||||
if dt then
|
||||
local dp, tp = dt:match('^(.+)T(.+)$')
|
||||
if is_valid_date(dp) and is_valid_time(tp) then
|
||||
return dt
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
if is_valid_date(date_input) then
|
||||
return append_time(date_input, time_suffix)
|
||||
end
|
||||
|
||||
local lower = date_input:lower()
|
||||
local today = os.date('*t') --[[@as osdate]]
|
||||
|
||||
if lower == 'today' then
|
||||
return os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day })) --[[@as string]]
|
||||
if lower == 'today' or lower == 'eod' then
|
||||
return 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
|
||||
return os.date(
|
||||
'%Y-%m-%d',
|
||||
os.time({ year = today.year, month = today.month, day = today.day + 1 })
|
||||
) --[[@as string]]
|
||||
return append_time(
|
||||
os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day + 1 })) --[[@as string]],
|
||||
time_suffix
|
||||
)
|
||||
end
|
||||
|
||||
if lower == 'sow' then
|
||||
local delta = -((today.wday - 2) % 7)
|
||||
return append_time(
|
||||
os.date(
|
||||
'%Y-%m-%d',
|
||||
os.time({ year = today.year, month = today.month, day = today.day + delta })
|
||||
) --[[@as string]],
|
||||
time_suffix
|
||||
)
|
||||
end
|
||||
|
||||
if lower == 'eow' then
|
||||
local delta = (1 - today.wday) % 7
|
||||
return append_time(
|
||||
os.date(
|
||||
'%Y-%m-%d',
|
||||
os.time({ year = today.year, month = today.month, day = today.day + delta })
|
||||
) --[[@as string]],
|
||||
time_suffix
|
||||
)
|
||||
end
|
||||
|
||||
if lower == 'som' then
|
||||
return append_time(
|
||||
os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = 1 })) --[[@as string]],
|
||||
time_suffix
|
||||
)
|
||||
end
|
||||
|
||||
if lower == 'eom' then
|
||||
return append_time(
|
||||
os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month + 1, day = 0 })) --[[@as string]],
|
||||
time_suffix
|
||||
)
|
||||
end
|
||||
|
||||
if lower == 'soq' then
|
||||
local q = math.ceil(today.month / 3)
|
||||
local first_month = (q - 1) * 3 + 1
|
||||
return append_time(
|
||||
os.date('%Y-%m-%d', os.time({ year = today.year, month = first_month, day = 1 })) --[[@as string]],
|
||||
time_suffix
|
||||
)
|
||||
end
|
||||
|
||||
if lower == 'eoq' then
|
||||
local q = math.ceil(today.month / 3)
|
||||
local last_month = q * 3
|
||||
return append_time(
|
||||
os.date('%Y-%m-%d', os.time({ year = today.year, month = last_month + 1, day = 0 })) --[[@as string]],
|
||||
time_suffix
|
||||
)
|
||||
end
|
||||
|
||||
if lower == 'soy' then
|
||||
return append_time(
|
||||
os.date('%Y-%m-%d', os.time({ year = today.year, month = 1, day = 1 })) --[[@as string]],
|
||||
time_suffix
|
||||
)
|
||||
end
|
||||
|
||||
if lower == 'eoy' then
|
||||
return append_time(
|
||||
os.date('%Y-%m-%d', os.time({ year = today.year, month = 12, day = 31 })) --[[@as string]],
|
||||
time_suffix
|
||||
)
|
||||
end
|
||||
|
||||
if lower == 'later' or lower == 'someday' then
|
||||
return append_time(config.get().someday_date, time_suffix)
|
||||
end
|
||||
|
||||
local n = lower:match('^%+(%d+)d$')
|
||||
if n then
|
||||
return os.date(
|
||||
'%Y-%m-%d',
|
||||
os.time({
|
||||
year = today.year,
|
||||
month = today.month,
|
||||
day = today.day + (
|
||||
tonumber(n) --[[@as integer]]
|
||||
),
|
||||
})
|
||||
) --[[@as string]]
|
||||
return append_time(
|
||||
os.date(
|
||||
'%Y-%m-%d',
|
||||
os.time({
|
||||
year = today.year,
|
||||
month = today.month,
|
||||
day = today.day + (
|
||||
tonumber(n) --[[@as integer]]
|
||||
),
|
||||
})
|
||||
) --[[@as string]],
|
||||
time_suffix
|
||||
)
|
||||
end
|
||||
|
||||
n = lower:match('^%+(%d+)w$')
|
||||
if n then
|
||||
return 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
|
||||
|
||||
local target_wday = weekday_map[lower]
|
||||
if target_wday then
|
||||
local current_wday = today.wday
|
||||
local delta = (target_wday - current_wday) % 7
|
||||
return os.date(
|
||||
'%Y-%m-%d',
|
||||
os.time({ year = today.year, month = today.month, day = today.day + delta })
|
||||
) --[[@as string]]
|
||||
return append_time(
|
||||
os.date(
|
||||
'%Y-%m-%d',
|
||||
os.time({ year = today.year, month = today.month, day = today.day + delta })
|
||||
) --[[@as string]],
|
||||
time_suffix
|
||||
)
|
||||
end
|
||||
|
||||
return nil
|
||||
return try_input_date_formats(date_input, time_suffix)
|
||||
end
|
||||
|
||||
---@param text string
|
||||
---@return string description
|
||||
---@return { due?: string, cat?: string } metadata
|
||||
---@return pending.Metadata metadata
|
||||
function M.body(text)
|
||||
local tokens = {}
|
||||
for token in text:gmatch('%S+') do
|
||||
|
|
@ -94,9 +537,14 @@ function M.body(text)
|
|||
|
||||
local metadata = {}
|
||||
local i = #tokens
|
||||
local ck = category_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 rec_pattern = '^' .. vim.pesc(rk) .. ':(%S+)$'
|
||||
local forge_indices = {}
|
||||
|
||||
while i >= 1 do
|
||||
local token = tokens[i]
|
||||
|
|
@ -105,7 +553,7 @@ function M.body(text)
|
|||
if metadata.due then
|
||||
break
|
||||
end
|
||||
if not is_valid_date(due_val) then
|
||||
if not is_valid_datetime(due_val) then
|
||||
break
|
||||
end
|
||||
metadata.due = due_val
|
||||
|
|
@ -123,15 +571,46 @@ function M.body(text)
|
|||
metadata.due = resolved
|
||||
i = i - 1
|
||||
else
|
||||
local cat_val = token:match('^cat:(%S+)$')
|
||||
local cat_val = token:match(cat_pattern)
|
||||
if cat_val then
|
||||
if metadata.cat then
|
||||
if metadata.category then
|
||||
break
|
||||
end
|
||||
metadata.cat = cat_val
|
||||
metadata.category = cat_val
|
||||
i = i - 1
|
||||
else
|
||||
break
|
||||
local pri_bangs = token:match('^%+(!+)$')
|
||||
if pri_bangs then
|
||||
if metadata.priority then
|
||||
break
|
||||
end
|
||||
local max = config.get().max_priority or 3
|
||||
metadata.priority = math.min(#pri_bangs, max)
|
||||
i = i - 1
|
||||
else
|
||||
local rec_val = token:match(rec_pattern)
|
||||
if rec_val then
|
||||
if metadata.recur then
|
||||
break
|
||||
end
|
||||
local recur = require('pending.recur')
|
||||
local raw_spec = rec_val
|
||||
if raw_spec:sub(1, 1) == '!' then
|
||||
metadata.recur_mode = 'completion'
|
||||
raw_spec = raw_spec:sub(2)
|
||||
end
|
||||
if not recur.validate(raw_spec) then
|
||||
break
|
||||
end
|
||||
metadata.recur = raw_spec
|
||||
i = i - 1
|
||||
elseif forge.parse_ref(token) then
|
||||
table.insert(forge_indices, i)
|
||||
i = i - 1
|
||||
else
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -141,6 +620,9 @@ function M.body(text)
|
|||
for j = 1, i do
|
||||
table.insert(desc_tokens, tokens[j])
|
||||
end
|
||||
for fi = #forge_indices, 1, -1 do
|
||||
table.insert(desc_tokens, tokens[forge_indices[fi]])
|
||||
end
|
||||
local description = table.concat(desc_tokens, ' ')
|
||||
|
||||
return description, metadata
|
||||
|
|
@ -148,7 +630,7 @@ end
|
|||
|
||||
---@param text string
|
||||
---@return string description
|
||||
---@return { due?: string, cat?: string } metadata
|
||||
---@return pending.Metadata metadata
|
||||
function M.command_add(text)
|
||||
local cat_prefix = text:match('^(%S.-):%s')
|
||||
if cat_prefix then
|
||||
|
|
@ -157,7 +639,7 @@ function M.command_add(text)
|
|||
local rest = text:sub(#cat_prefix + 2):match('^%s*(.+)$')
|
||||
if rest then
|
||||
local desc, meta = M.body(rest)
|
||||
meta.cat = meta.cat or cat_prefix
|
||||
meta.category = meta.category or cat_prefix
|
||||
return desc, meta
|
||||
end
|
||||
end
|
||||
|
|
@ -165,4 +647,66 @@ function M.command_add(text)
|
|||
return M.body(text)
|
||||
end
|
||||
|
||||
---@param due string
|
||||
---@return boolean
|
||||
function M.is_overdue(due)
|
||||
local now = os.date('*t') --[[@as osdate]]
|
||||
local today = os.date('%Y-%m-%d') --[[@as string]]
|
||||
local date_part, time_part = due:match('^(.+)T(.+)$')
|
||||
if not date_part then
|
||||
return due < today
|
||||
end
|
||||
if date_part < today then
|
||||
return true
|
||||
end
|
||||
if date_part > today then
|
||||
return false
|
||||
end
|
||||
local current_time = string.format('%02d:%02d', now.hour, now.min)
|
||||
return time_part < current_time
|
||||
end
|
||||
|
||||
---@param due string
|
||||
---@return boolean
|
||||
function M.is_today(due)
|
||||
local now = os.date('*t') --[[@as osdate]]
|
||||
local today = os.date('%Y-%m-%d') --[[@as string]]
|
||||
local date_part, time_part = due:match('^(.+)T(.+)$')
|
||||
if not date_part then
|
||||
return due == today
|
||||
end
|
||||
if date_part ~= today then
|
||||
return false
|
||||
end
|
||||
local current_time = string.format('%02d:%02d', now.hour, now.min)
|
||||
return time_part >= current_time
|
||||
end
|
||||
|
||||
---@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
|
||||
|
|
|
|||
188
lua/pending/recur.lua
Normal file
188
lua/pending/recur.lua
Normal 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
|
||||
|
|
@ -1,37 +1,70 @@
|
|||
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
|
||||
---@field id integer
|
||||
---@field description string
|
||||
---@field status 'pending'|'done'|'deleted'
|
||||
---@field status pending.TaskStatus
|
||||
---@field category? string
|
||||
---@field priority integer
|
||||
---@field due? string
|
||||
---@field recur? string
|
||||
---@field recur_mode? pending.RecurMode
|
||||
---@field entry string
|
||||
---@field modified string
|
||||
---@field end? string
|
||||
---@field notes? string
|
||||
---@field order integer
|
||||
---@field _extra? table<string, any>
|
||||
---@field _extra? pending.TaskExtra
|
||||
|
||||
---@class pending.Data
|
||||
---@field version integer
|
||||
---@field next_id integer
|
||||
---@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
|
||||
local M = {}
|
||||
|
||||
local SUPPORTED_VERSION = 1
|
||||
|
||||
---@type pending.Data?
|
||||
local _data = nil
|
||||
|
||||
---@return pending.Data
|
||||
local function empty_data()
|
||||
return {
|
||||
version = SUPPORTED_VERSION,
|
||||
next_id = 1,
|
||||
tasks = {},
|
||||
undo = {},
|
||||
folded_categories = {},
|
||||
}
|
||||
end
|
||||
|
||||
|
|
@ -56,9 +89,12 @@ local known_fields = {
|
|||
category = true,
|
||||
priority = true,
|
||||
due = true,
|
||||
recur = true,
|
||||
recur_mode = true,
|
||||
entry = true,
|
||||
modified = true,
|
||||
['end'] = true,
|
||||
notes = true,
|
||||
order = true,
|
||||
}
|
||||
|
||||
|
|
@ -81,9 +117,18 @@ local function task_to_table(task)
|
|||
if task.due then
|
||||
t.due = task.due
|
||||
end
|
||||
if task.recur then
|
||||
t.recur = task.recur
|
||||
end
|
||||
if task.recur_mode then
|
||||
t.recur_mode = task.recur_mode
|
||||
end
|
||||
if task['end'] then
|
||||
t['end'] = task['end']
|
||||
end
|
||||
if task.notes then
|
||||
t.notes = task.notes
|
||||
end
|
||||
if task.order and task.order ~= 0 then
|
||||
t.order = task.order
|
||||
end
|
||||
|
|
@ -105,9 +150,12 @@ local function table_to_task(t)
|
|||
category = t.category,
|
||||
priority = t.priority or 0,
|
||||
due = t.due,
|
||||
recur = t.recur,
|
||||
recur_mode = t.recur_mode,
|
||||
entry = t.entry,
|
||||
modified = t.modified,
|
||||
['end'] = t['end'],
|
||||
notes = t.notes,
|
||||
order = t.order or 0,
|
||||
_extra = {},
|
||||
}
|
||||
|
|
@ -123,18 +171,18 @@ local function table_to_task(t)
|
|||
end
|
||||
|
||||
---@return pending.Data
|
||||
function M.load()
|
||||
local path = config.get().data_path
|
||||
function Store:load()
|
||||
local path = self.path
|
||||
local f = io.open(path, 'r')
|
||||
if not f then
|
||||
_data = empty_data()
|
||||
return _data
|
||||
self._data = empty_data()
|
||||
return self._data
|
||||
end
|
||||
local content = f:read('*a')
|
||||
f:close()
|
||||
if content == '' then
|
||||
_data = empty_data()
|
||||
return _data
|
||||
self._data = empty_data()
|
||||
return self._data
|
||||
end
|
||||
local ok, decoded = pcall(vim.json.decode, content)
|
||||
if not ok then
|
||||
|
|
@ -149,31 +197,52 @@ function M.load()
|
|||
.. '. Please update the plugin.'
|
||||
)
|
||||
end
|
||||
_data = {
|
||||
self._data = {
|
||||
version = decoded.version or SUPPORTED_VERSION,
|
||||
next_id = decoded.next_id or 1,
|
||||
tasks = {},
|
||||
undo = {},
|
||||
folded_categories = decoded.folded_categories or {},
|
||||
}
|
||||
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
|
||||
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
|
||||
|
||||
function M.save()
|
||||
if not _data then
|
||||
---@return nil
|
||||
function Store:save()
|
||||
if not self._data then
|
||||
return
|
||||
end
|
||||
local path = config.get().data_path
|
||||
local path = self.path
|
||||
ensure_dir(path)
|
||||
local out = {
|
||||
version = _data.version,
|
||||
next_id = _data.next_id,
|
||||
version = self._data.version,
|
||||
next_id = self._data.next_id,
|
||||
tasks = {},
|
||||
undo = {},
|
||||
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))
|
||||
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 tmp = path .. '.tmp'
|
||||
local f = io.open(tmp, 'w')
|
||||
|
|
@ -190,22 +259,22 @@ function M.save()
|
|||
end
|
||||
|
||||
---@return pending.Data
|
||||
function M.data()
|
||||
if not _data then
|
||||
M.load()
|
||||
function Store:data()
|
||||
if not self._data then
|
||||
self:load()
|
||||
end
|
||||
return _data --[[@as pending.Data]]
|
||||
return self._data --[[@as pending.Data]]
|
||||
end
|
||||
|
||||
---@return pending.Task[]
|
||||
function M.tasks()
|
||||
return M.data().tasks
|
||||
function Store:tasks()
|
||||
return self:data().tasks
|
||||
end
|
||||
|
||||
---@return pending.Task[]
|
||||
function M.active_tasks()
|
||||
function Store:active_tasks()
|
||||
local result = {}
|
||||
for _, task in ipairs(M.tasks()) do
|
||||
for _, task in ipairs(self:tasks()) do
|
||||
if task.status ~= 'deleted' then
|
||||
table.insert(result, task)
|
||||
end
|
||||
|
|
@ -215,8 +284,8 @@ end
|
|||
|
||||
---@param id integer
|
||||
---@return pending.Task?
|
||||
function M.get(id)
|
||||
for _, task in ipairs(M.tasks()) do
|
||||
function Store:get(id)
|
||||
for _, task in ipairs(self:tasks()) do
|
||||
if task.id == id then
|
||||
return task
|
||||
end
|
||||
|
|
@ -224,10 +293,10 @@ function M.get(id)
|
|||
return nil
|
||||
end
|
||||
|
||||
---@param fields { description: string, status?: string, category?: string, priority?: integer, due?: string, order?: integer, _extra?: table }
|
||||
---@param fields pending.TaskFields
|
||||
---@return pending.Task
|
||||
function M.add(fields)
|
||||
local data = M.data()
|
||||
function Store:add(fields)
|
||||
local data = self:data()
|
||||
local now = timestamp()
|
||||
local task = {
|
||||
id = data.next_id,
|
||||
|
|
@ -236,6 +305,8 @@ function M.add(fields)
|
|||
category = fields.category or config.get().default_category,
|
||||
priority = fields.priority or 0,
|
||||
due = fields.due,
|
||||
recur = fields.recur,
|
||||
recur_mode = fields.recur_mode,
|
||||
entry = now,
|
||||
modified = now,
|
||||
['end'] = nil,
|
||||
|
|
@ -250,19 +321,23 @@ end
|
|||
---@param id integer
|
||||
---@param fields table<string, any>
|
||||
---@return pending.Task?
|
||||
function M.update(id, fields)
|
||||
local task = M.get(id)
|
||||
function Store:update(id, fields)
|
||||
local task = self:get(id)
|
||||
if not task then
|
||||
return nil
|
||||
end
|
||||
local now = timestamp()
|
||||
for k, v in pairs(fields) do
|
||||
if k ~= 'id' and k ~= 'entry' then
|
||||
task[k] = v
|
||||
if v == vim.NIL then
|
||||
task[k] = nil
|
||||
else
|
||||
task[k] = v
|
||||
end
|
||||
end
|
||||
end
|
||||
task.modified = now
|
||||
if fields.status == 'done' or fields.status == 'deleted' then
|
||||
if fields.status == 'done' or fields.status == 'deleted' or fields.status == 'cancelled' then
|
||||
task['end'] = task['end'] or now
|
||||
end
|
||||
return task
|
||||
|
|
@ -270,14 +345,14 @@ end
|
|||
|
||||
---@param id integer
|
||||
---@return pending.Task?
|
||||
function M.delete(id)
|
||||
return M.update(id, { status = 'deleted', ['end'] = timestamp() })
|
||||
function Store:delete(id)
|
||||
return self:update(id, { status = 'deleted', ['end'] = timestamp() })
|
||||
end
|
||||
|
||||
---@param id integer
|
||||
---@return integer?
|
||||
function M.find_index(id)
|
||||
for i, task in ipairs(M.tasks()) do
|
||||
function Store:find_index(id)
|
||||
for i, task in ipairs(self:tasks()) do
|
||||
if task.id == id then
|
||||
return i
|
||||
end
|
||||
|
|
@ -286,14 +361,15 @@ function M.find_index(id)
|
|||
end
|
||||
|
||||
---@param tasks pending.Task[]
|
||||
function M.replace_tasks(tasks)
|
||||
M.data().tasks = tasks
|
||||
---@return nil
|
||||
function Store:replace_tasks(tasks)
|
||||
self:data().tasks = tasks
|
||||
end
|
||||
|
||||
---@return pending.Task[]
|
||||
function M.snapshot()
|
||||
function Store:snapshot()
|
||||
local result = {}
|
||||
for _, task in ipairs(M.active_tasks()) do
|
||||
for _, task in ipairs(self:active_tasks()) do
|
||||
local copy = {}
|
||||
for k, v in pairs(task) do
|
||||
if k ~= '_extra' then
|
||||
|
|
@ -311,13 +387,48 @@ function M.snapshot()
|
|||
return result
|
||||
end
|
||||
|
||||
---@param id integer
|
||||
function M.set_next_id(id)
|
||||
M.data().next_id = id
|
||||
---@return pending.Task[][]
|
||||
function Store:undo_stack()
|
||||
return self:data().undo
|
||||
end
|
||||
|
||||
function M.unload()
|
||||
_data = nil
|
||||
---@param stack pending.Task[][]
|
||||
---@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
|
||||
|
||||
return M
|
||||
|
|
|
|||
|
|
@ -1,384 +1,61 @@
|
|||
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 = {}
|
||||
|
||||
M.name = 'gcal'
|
||||
|
||||
local BASE_URL = 'https://www.googleapis.com/calendar/v3'
|
||||
local TOKEN_URL = 'https://oauth2.googleapis.com/token'
|
||||
local AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'
|
||||
local SCOPE = 'https://www.googleapis.com/auth/calendar'
|
||||
|
||||
---@class pending.GcalCredentials
|
||||
---@field client_id string
|
||||
---@field client_secret string
|
||||
---@field redirect_uris? string[]
|
||||
|
||||
---@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)
|
||||
---@param access_token string
|
||||
---@return table<string, string>? name_to_id
|
||||
---@return string? err
|
||||
local function get_all_calendars(access_token)
|
||||
local data, err = oauth.curl_request(
|
||||
'GET',
|
||||
BASE_URL .. '/users/me/calendarList',
|
||||
oauth.auth_headers(access_token)
|
||||
)
|
||||
end
|
||||
|
||||
---@param method string
|
||||
---@param url string
|
||||
---@param headers? string[]
|
||||
---@param body? string
|
||||
---@return table? result
|
||||
---@return string? err
|
||||
local function curl_request(method, url, headers, body)
|
||||
local args = { 'curl', '-s', '-X', method }
|
||||
for _, h in ipairs(headers or {}) do
|
||||
table.insert(args, '-H')
|
||||
table.insert(args, h)
|
||||
end
|
||||
if body then
|
||||
table.insert(args, '-d')
|
||||
table.insert(args, body)
|
||||
end
|
||||
table.insert(args, url)
|
||||
local result = vim.system(args, { text = true }):wait()
|
||||
if result.code ~= 0 then
|
||||
return nil, 'curl failed: ' .. (result.stderr or '')
|
||||
end
|
||||
if not result.stdout or result.stdout == '' then
|
||||
return {}, nil
|
||||
end
|
||||
local ok, decoded = pcall(vim.json.decode, result.stdout)
|
||||
if not ok then
|
||||
return nil, 'failed to parse response: ' .. result.stdout
|
||||
end
|
||||
if decoded.error then
|
||||
return nil, 'API error: ' .. (decoded.error.message or vim.json.encode(decoded.error))
|
||||
end
|
||||
return decoded, nil
|
||||
end
|
||||
|
||||
---@param access_token string
|
||||
---@return string[]
|
||||
local function auth_headers(access_token)
|
||||
return {
|
||||
'Authorization: Bearer ' .. access_token,
|
||||
'Content-Type: application/json',
|
||||
}
|
||||
end
|
||||
|
||||
---@param creds pending.GcalCredentials
|
||||
---@param tokens pending.GcalTokens
|
||||
---@return pending.GcalTokens?
|
||||
local function refresh_access_token(creds, tokens)
|
||||
local body = 'client_id='
|
||||
.. url_encode(creds.client_id)
|
||||
.. '&client_secret='
|
||||
.. url_encode(creds.client_secret)
|
||||
.. '&grant_type=refresh_token'
|
||||
.. '&refresh_token='
|
||||
.. url_encode(tokens.refresh_token)
|
||||
local result = vim
|
||||
.system({
|
||||
'curl',
|
||||
'-s',
|
||||
'-X',
|
||||
'POST',
|
||||
'-H',
|
||||
'Content-Type: application/x-www-form-urlencoded',
|
||||
'-d',
|
||||
body,
|
||||
TOKEN_URL,
|
||||
}, { text = true })
|
||||
:wait()
|
||||
if result.code ~= 0 then
|
||||
return nil
|
||||
end
|
||||
local ok, decoded = pcall(vim.json.decode, result.stdout or '')
|
||||
if not ok or not decoded.access_token then
|
||||
return nil
|
||||
end
|
||||
tokens.access_token = decoded.access_token --[[@as string]]
|
||||
tokens.expires_in = decoded.expires_in --[[@as integer?]]
|
||||
tokens.obtained_at = os.time()
|
||||
save_tokens(tokens)
|
||||
return tokens
|
||||
end
|
||||
|
||||
---@return string?
|
||||
local function get_access_token()
|
||||
local creds = load_credentials()
|
||||
if not creds then
|
||||
vim.notify(
|
||||
'pending.nvim: No Google Calendar credentials found at ' .. credentials_path(),
|
||||
vim.log.levels.ERROR
|
||||
)
|
||||
return nil
|
||||
end
|
||||
local tokens = load_tokens()
|
||||
if not tokens or not tokens.refresh_token then
|
||||
M.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
|
||||
local function find_or_create_calendar(access_token)
|
||||
local gc = gcal_config()
|
||||
local cal_name = gc.calendar or 'Pendings'
|
||||
|
||||
local data, err =
|
||||
curl_request('GET', BASE_URL .. '/users/me/calendarList', auth_headers(access_token))
|
||||
if err then
|
||||
return nil, err
|
||||
end
|
||||
|
||||
local result = {}
|
||||
for _, item in ipairs(data and data.items or {}) do
|
||||
if item.summary == cal_name then
|
||||
return item.id, nil
|
||||
if item.summary then
|
||||
result[item.summary] = item.id
|
||||
end
|
||||
end
|
||||
return result, nil
|
||||
end
|
||||
|
||||
local body = vim.json.encode({ summary = cal_name })
|
||||
local created, create_err =
|
||||
curl_request('POST', BASE_URL .. '/calendars', auth_headers(access_token), body)
|
||||
if create_err then
|
||||
return nil, create_err
|
||||
---@param access_token string
|
||||
---@param name string
|
||||
---@param existing table<string, string>
|
||||
---@return string? calendar_id
|
||||
---@return string? err
|
||||
local function find_or_create_calendar(access_token, name, existing)
|
||||
if existing[name] then
|
||||
return existing[name], nil
|
||||
end
|
||||
|
||||
return created and created.id, nil
|
||||
local body = vim.json.encode({ summary = name })
|
||||
local created, err =
|
||||
oauth.curl_request('POST', BASE_URL .. '/calendars', oauth.auth_headers(access_token), body)
|
||||
if err then
|
||||
return nil, err
|
||||
end
|
||||
local id = created and created.id
|
||||
if id then
|
||||
existing[name] = id
|
||||
end
|
||||
return id, nil
|
||||
end
|
||||
|
||||
---@param date_str string
|
||||
---@return string
|
||||
local function next_day(date_str)
|
||||
local y, m, d = date_str:match('^(%d%d%d%d)-(%d%d)-(%d%d)$')
|
||||
local y, m, d = date_str:match('^(%d%d%d%d)-(%d%d)-(%d%d)')
|
||||
local t = os.time({ year = tonumber(y) or 0, month = tonumber(m) or 0, day = tonumber(d) or 0 })
|
||||
+ 86400
|
||||
return os.date('%Y-%m-%d', t) --[[@as string]]
|
||||
|
|
@ -399,10 +76,10 @@ local function create_event(access_token, calendar_id, task)
|
|||
private = { taskId = tostring(task.id) },
|
||||
},
|
||||
}
|
||||
local data, err = curl_request(
|
||||
local data, err = oauth.curl_request(
|
||||
'POST',
|
||||
BASE_URL .. '/calendars/' .. url_encode(calendar_id) .. '/events',
|
||||
auth_headers(access_token),
|
||||
BASE_URL .. '/calendars/' .. oauth.url_encode(calendar_id) .. '/events',
|
||||
oauth.auth_headers(access_token),
|
||||
vim.json.encode(event)
|
||||
)
|
||||
if err then
|
||||
|
|
@ -421,11 +98,16 @@ local function update_event(access_token, calendar_id, event_id, task)
|
|||
summary = task.description,
|
||||
start = { date = task.due },
|
||||
['end'] = { date = next_day(task.due or '') },
|
||||
transparency = 'transparent',
|
||||
}
|
||||
local _, err = curl_request(
|
||||
local _, err = oauth.curl_request(
|
||||
'PATCH',
|
||||
BASE_URL .. '/calendars/' .. url_encode(calendar_id) .. '/events/' .. url_encode(event_id),
|
||||
auth_headers(access_token),
|
||||
BASE_URL
|
||||
.. '/calendars/'
|
||||
.. oauth.url_encode(calendar_id)
|
||||
.. '/events/'
|
||||
.. oauth.url_encode(event_id),
|
||||
oauth.auth_headers(access_token),
|
||||
vim.json.encode(event)
|
||||
)
|
||||
return err
|
||||
|
|
@ -436,81 +118,166 @@ end
|
|||
---@param event_id string
|
||||
---@return string? err
|
||||
local function delete_event(access_token, calendar_id, event_id)
|
||||
local _, err = curl_request(
|
||||
local _, err = oauth.curl_request(
|
||||
'DELETE',
|
||||
BASE_URL .. '/calendars/' .. url_encode(calendar_id) .. '/events/' .. url_encode(event_id),
|
||||
auth_headers(access_token)
|
||||
BASE_URL
|
||||
.. '/calendars/'
|
||||
.. oauth.url_encode(calendar_id)
|
||||
.. '/events/'
|
||||
.. oauth.url_encode(event_id),
|
||||
oauth.auth_headers(access_token)
|
||||
)
|
||||
return err
|
||||
end
|
||||
|
||||
function M.sync()
|
||||
local access_token = get_access_token()
|
||||
if not access_token then
|
||||
return
|
||||
---@return boolean
|
||||
local function allow_remote_delete()
|
||||
local cfg = config.get()
|
||||
local sync = cfg.sync or {}
|
||||
local per = (sync.gcal or {}) --[[@as pending.GcalConfig]]
|
||||
if per.remote_delete ~= nil then
|
||||
return per.remote_delete == true
|
||||
end
|
||||
return sync.remote_delete == true
|
||||
end
|
||||
|
||||
local calendar_id, err = find_or_create_calendar(access_token)
|
||||
if err or not calendar_id then
|
||||
vim.notify('pending.nvim: ' .. (err or 'calendar not found'), vim.log.levels.ERROR)
|
||||
return
|
||||
---@param task pending.Task
|
||||
---@param extra table
|
||||
---@param now_ts string
|
||||
local function unlink_remote(task, extra, now_ts)
|
||||
extra['_gcal_event_id'] = nil
|
||||
extra['_gcal_calendar_id'] = nil
|
||||
if next(extra) == nil then
|
||||
task._extra = nil
|
||||
else
|
||||
task._extra = extra
|
||||
end
|
||||
task.modified = now_ts
|
||||
end
|
||||
|
||||
local tasks = store.tasks()
|
||||
local created, updated, deleted = 0, 0, 0
|
||||
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
|
||||
|
||||
for _, task in ipairs(tasks) do
|
||||
local extra = task._extra or {}
|
||||
local event_id = extra['_gcal_event_id'] --[[@as string?]]
|
||||
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
|
||||
|
||||
local should_delete = event_id ~= nil
|
||||
and (
|
||||
task.status == 'done'
|
||||
or task.status == 'deleted'
|
||||
or (task.status == 'pending' and not task.due)
|
||||
)
|
||||
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?]]
|
||||
|
||||
if should_delete and event_id then
|
||||
local del_err = delete_event(access_token, calendar_id, event_id) --[[@as string]]
|
||||
if not del_err then
|
||||
extra['_gcal_event_id'] = nil
|
||||
if next(extra) == nil then
|
||||
task._extra = nil
|
||||
else
|
||||
task._extra = extra
|
||||
end
|
||||
task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]]
|
||||
deleted = deleted + 1
|
||||
end
|
||||
elseif task.status == 'pending' and task.due then
|
||||
if event_id then
|
||||
local upd_err = update_event(access_token, calendar_id, event_id, task)
|
||||
if not upd_err then
|
||||
updated = updated + 1
|
||||
end
|
||||
else
|
||||
local new_id, create_err = create_event(access_token, calendar_id, task)
|
||||
if not create_err and new_id then
|
||||
if not task._extra then
|
||||
task._extra = {}
|
||||
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
|
||||
end
|
||||
elseif task.status == 'pending' and task.due then
|
||||
local cat = task.category or config.get().default_category
|
||||
if event_id and cal_id then
|
||||
local upd_err = update_event(access_token, cal_id, event_id, task)
|
||||
if upd_err then
|
||||
log.warn('gcal: failed to update calendar event — ' .. upd_err)
|
||||
failed = failed + 1
|
||||
else
|
||||
updated = updated + 1
|
||||
end
|
||||
else
|
||||
local lid, lid_err = find_or_create_calendar(access_token, cat, calendars)
|
||||
if lid_err or not lid then
|
||||
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
|
||||
task._extra = {}
|
||||
end
|
||||
task._extra['_gcal_event_id'] = new_id
|
||||
task._extra['_gcal_calendar_id'] = lid
|
||||
task.modified = now_ts
|
||||
created = created + 1
|
||||
end
|
||||
end
|
||||
task._extra['_gcal_event_id'] = new_id
|
||||
task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]]
|
||||
created = created + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
store.save()
|
||||
vim.notify(
|
||||
string.format(
|
||||
'pending.nvim: Synced to Google Calendar (created: %d, updated: %d, deleted: %d)',
|
||||
created,
|
||||
updated,
|
||||
deleted
|
||||
util.finish(s)
|
||||
log.info('gcal: push ' .. util.fmt_counts({
|
||||
{ created, 'added' },
|
||||
{ updated, 'updated' },
|
||||
{ deleted, 'removed' },
|
||||
{ failed, 'failed' },
|
||||
}))
|
||||
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
|
||||
|
||||
return M
|
||||
|
|
|
|||
544
lua/pending/sync/gtasks.lua
Normal file
544
lua/pending/sync/gtasks.lua
Normal 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
593
lua/pending/sync/oauth.lua
Normal 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
509
lua/pending/sync/s3.lua
Normal 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
98
lua/pending/sync/util.lua
Normal 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
383
lua/pending/textobj.lua
Normal 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
|
||||
|
|
@ -1,15 +1,25 @@
|
|||
local config = require('pending.config')
|
||||
local forge = require('pending.forge')
|
||||
local parse = require('pending.parse')
|
||||
|
||||
---@class pending.ForgeLineMeta
|
||||
---@field ref pending.ForgeRef
|
||||
---@field cache? pending.ForgeCache
|
||||
---@field col_start integer
|
||||
---@field col_end integer
|
||||
|
||||
---@class pending.LineMeta
|
||||
---@field type 'task'|'header'|'blank'
|
||||
---@field type 'task'|'header'|'blank'|'filter'
|
||||
---@field id? integer
|
||||
---@field due? string
|
||||
---@field raw_due? string
|
||||
---@field status? string
|
||||
---@field status? pending.TaskStatus
|
||||
---@field category? string
|
||||
---@field overdue? boolean
|
||||
---@field show_category? boolean
|
||||
---@field priority? integer
|
||||
---@field recur? string
|
||||
---@field forge_spans? pending.ForgeLineMeta[]
|
||||
|
||||
---@class pending.views
|
||||
local M = {}
|
||||
|
|
@ -20,7 +30,10 @@ local function format_due(due)
|
|||
if not due then
|
||||
return nil
|
||||
end
|
||||
local y, m, d = due:match('^(%d%d%d%d)-(%d%d)-(%d%d)$')
|
||||
local y, m, d, hh, mm = due:match('^(%d%d%d%d)-(%d%d)-(%d%d)T(%d%d):(%d%d)$')
|
||||
if not y then
|
||||
y, m, d = due:match('^(%d%d%d%d)-(%d%d)-(%d%d)$')
|
||||
end
|
||||
if not y then
|
||||
return due
|
||||
end
|
||||
|
|
@ -29,12 +42,63 @@ local function format_due(due)
|
|||
month = tonumber(m) --[[@as integer]],
|
||||
day = tonumber(d) --[[@as integer]],
|
||||
})
|
||||
return os.date(config.get().date_format, t) --[[@as string]]
|
||||
local formatted = os.date(config.get().date_format, t) --[[@as string]]
|
||||
if hh then
|
||||
formatted = formatted .. ' ' .. hh .. ':' .. mm
|
||||
end
|
||||
return formatted
|
||||
end
|
||||
|
||||
---@param 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
|
||||
|
||||
---@param tasks pending.Task[]
|
||||
local function sort_tasks(tasks)
|
||||
table.sort(tasks, function(a, b)
|
||||
local ra = status_rank[a.status] or 1
|
||||
local rb = status_rank[b.status] or 1
|
||||
if ra ~= rb then
|
||||
return ra < rb
|
||||
end
|
||||
if a.priority ~= b.priority then
|
||||
return a.priority > b.priority
|
||||
end
|
||||
|
|
@ -45,12 +109,23 @@ local function sort_tasks(tasks)
|
|||
end)
|
||||
end
|
||||
|
||||
---@param tasks pending.Task[]
|
||||
local function sort_tasks_priority(tasks)
|
||||
table.sort(tasks, function(a, b)
|
||||
---@type table<string, fun(a: pending.Task, b: pending.Task): boolean?>
|
||||
local sort_key_comparators = {
|
||||
status = function(a, b)
|
||||
local ra = status_rank[a.status] or 1
|
||||
local rb = status_rank[b.status] or 1
|
||||
if ra ~= rb then
|
||||
return ra < rb
|
||||
end
|
||||
return nil
|
||||
end,
|
||||
priority = function(a, b)
|
||||
if a.priority ~= b.priority then
|
||||
return a.priority > b.priority
|
||||
end
|
||||
return nil
|
||||
end,
|
||||
due = function(a, b)
|
||||
local a_due = a.due or ''
|
||||
local b_due = b.due or ''
|
||||
if a_due ~= b_due then
|
||||
|
|
@ -62,18 +137,67 @@ local function sort_tasks_priority(tasks)
|
|||
end
|
||||
return a_due < b_due
|
||||
end
|
||||
return nil
|
||||
end,
|
||||
order = function(a, b)
|
||||
if a.order ~= b.order then
|
||||
return a.order < b.order
|
||||
end
|
||||
return a.id < b.id
|
||||
end)
|
||||
return nil
|
||||
end,
|
||||
id = function(a, b)
|
||||
if a.id ~= b.id then
|
||||
return a.id < b.id
|
||||
end
|
||||
return nil
|
||||
end,
|
||||
age = function(a, b)
|
||||
if a.id ~= b.id then
|
||||
return a.id < b.id
|
||||
end
|
||||
return nil
|
||||
end,
|
||||
}
|
||||
|
||||
---@return fun(a: pending.Task, b: pending.Task): boolean
|
||||
local function build_queue_comparator()
|
||||
local log = require('pending.log')
|
||||
local keys = config.get().view.queue.sort or { 'status', 'priority', 'due', 'order', 'id' }
|
||||
local comparators = {}
|
||||
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
|
||||
|
||||
---@param tasks pending.Task[]
|
||||
---@return string[] lines
|
||||
---@return pending.LineMeta[] meta
|
||||
function M.category_view(tasks)
|
||||
local today = os.date('%Y-%m-%d') --[[@as string]]
|
||||
local by_cat = {}
|
||||
local cat_order = {}
|
||||
local cat_seen = {}
|
||||
|
|
@ -87,14 +211,14 @@ function M.category_view(tasks)
|
|||
by_cat[cat] = {}
|
||||
done_by_cat[cat] = {}
|
||||
end
|
||||
if task.status == 'done' then
|
||||
if task.status == 'done' or task.status == 'deleted' or task.status == 'cancelled' then
|
||||
table.insert(done_by_cat[cat], task)
|
||||
else
|
||||
table.insert(by_cat[cat], task)
|
||||
end
|
||||
end
|
||||
|
||||
local cfg_order = config.get().category_order
|
||||
local cfg_order = config.get().view.category.order
|
||||
if cfg_order and #cfg_order > 0 then
|
||||
local ordered = {}
|
||||
local seen = {}
|
||||
|
|
@ -125,7 +249,7 @@ function M.category_view(tasks)
|
|||
table.insert(lines, '')
|
||||
table.insert(meta, { type = 'blank' })
|
||||
end
|
||||
table.insert(lines, '## ' .. cat)
|
||||
table.insert(lines, '# ' .. cat)
|
||||
table.insert(meta, { type = 'header', category = cat })
|
||||
|
||||
local all = {}
|
||||
|
|
@ -138,8 +262,9 @@ function M.category_view(tasks)
|
|||
|
||||
for _, task in ipairs(all) do
|
||||
local prefix = '/' .. task.id .. '/'
|
||||
local state = task.status == 'done' and 'x' or (task.priority > 0 and '!' or ' ')
|
||||
local state = state_char(task)
|
||||
local line = prefix .. '- [' .. state .. '] ' .. task.description
|
||||
local prefix_len = #prefix + #('- [' .. state .. '] ')
|
||||
table.insert(lines, line)
|
||||
table.insert(meta, {
|
||||
type = 'task',
|
||||
|
|
@ -148,7 +273,14 @@ function M.category_view(tasks)
|
|||
raw_due = task.due,
|
||||
status = task.status,
|
||||
category = cat,
|
||||
overdue = task.status == 'pending' and task.due ~= nil and task.due < today or nil,
|
||||
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
|
||||
|
|
@ -160,12 +292,11 @@ end
|
|||
---@return string[] lines
|
||||
---@return pending.LineMeta[] meta
|
||||
function M.priority_view(tasks)
|
||||
local today = os.date('%Y-%m-%d') --[[@as string]]
|
||||
local pending = {}
|
||||
local done = {}
|
||||
|
||||
for _, task in ipairs(tasks) do
|
||||
if task.status == 'done' then
|
||||
if task.status == 'done' or task.status == 'cancelled' then
|
||||
table.insert(done, task)
|
||||
else
|
||||
table.insert(pending, task)
|
||||
|
|
@ -188,8 +319,9 @@ function M.priority_view(tasks)
|
|||
|
||||
for _, task in ipairs(all) do
|
||||
local prefix = '/' .. task.id .. '/'
|
||||
local state = task.status == 'done' and 'x' or (task.priority > 0 and '!' or ' ')
|
||||
local state = state_char(task)
|
||||
local line = prefix .. '- [' .. state .. '] ' .. task.description
|
||||
local prefix_len = #prefix + #('- [' .. state .. '] ')
|
||||
table.insert(lines, line)
|
||||
table.insert(meta, {
|
||||
type = 'task',
|
||||
|
|
@ -198,8 +330,18 @@ function M.priority_view(tasks)
|
|||
raw_due = task.due,
|
||||
status = task.status,
|
||||
category = task.category,
|
||||
overdue = task.status == 'pending' and task.due ~= nil and task.due < today or nil,
|
||||
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,
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -3,16 +3,349 @@ if vim.g.loaded_pending then
|
|||
end
|
||||
vim.g.loaded_pending = true
|
||||
|
||||
---@return string[]
|
||||
local function edit_field_candidates()
|
||||
local cfg = require('pending.config').get()
|
||||
local 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)
|
||||
require('pending').command(opts.args)
|
||||
end, {
|
||||
bar = true,
|
||||
nargs = '*',
|
||||
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
|
||||
return vim.tbl_filter(function(s)
|
||||
return s:find(arg_lead, 1, true) == 1
|
||||
end, subcmds)
|
||||
return filter_candidates(arg_lead, subcmds)
|
||||
end
|
||||
if cmd_line:match('^Pending%s+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 ok, mod = pcall(require, 'pending.sync.' .. b)
|
||||
if ok and type(mod.auth) == 'function' then
|
||||
table.insert(auth_names, b)
|
||||
end
|
||||
end
|
||||
return filter_candidates(arg_lead, auth_names)
|
||||
end
|
||||
local backend_name = parts[1]
|
||||
if #parts == 1 or (#parts == 2 and not trailing) then
|
||||
local ok, mod = pcall(require, 'pending.sync.' .. backend_name)
|
||||
if ok and type(mod.auth_complete) == 'function' then
|
||||
return filter_candidates(arg_lead, mod.auth_complete())
|
||||
end
|
||||
return {}
|
||||
end
|
||||
return {}
|
||||
end
|
||||
local backend_set = pending.sync_backend_set()
|
||||
local matched_backend = cmd_line:match('^Pending%s+(%S+)')
|
||||
if matched_backend and backend_set[matched_backend] then
|
||||
local after_backend = cmd_line:match('^Pending%s+%S+%s+(.*)')
|
||||
if not after_backend then
|
||||
return {}
|
||||
end
|
||||
local ok, mod = pcall(require, 'pending.sync.' .. matched_backend)
|
||||
if not ok then
|
||||
return {}
|
||||
end
|
||||
local actions = {}
|
||||
for k, v in pairs(mod) do
|
||||
if
|
||||
type(v) == 'function'
|
||||
and k:sub(1, 1) ~= '_'
|
||||
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
|
||||
return {}
|
||||
end,
|
||||
|
|
@ -22,6 +355,10 @@ vim.keymap.set('n', '<Plug>(pending-open)', function()
|
|||
require('pending').open()
|
||||
end)
|
||||
|
||||
vim.keymap.set('n', '<Plug>(pending-close)', function()
|
||||
require('pending.buffer').close()
|
||||
end)
|
||||
|
||||
vim.keymap.set('n', '<Plug>(pending-toggle)', function()
|
||||
require('pending').toggle_complete()
|
||||
end)
|
||||
|
|
@ -37,3 +374,115 @@ end)
|
|||
vim.keymap.set('n', '<Plug>(pending-date)', function()
|
||||
require('pending').prompt_date()
|
||||
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
10
scripts/ci.sh
Executable 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
|
||||
|
|
@ -1,87 +1,115 @@
|
|||
require('spec.helpers')
|
||||
|
||||
local config = require('pending.config')
|
||||
local store = require('pending.store')
|
||||
|
||||
describe('archive', function()
|
||||
local tmpdir
|
||||
local pending = require('pending')
|
||||
local pending
|
||||
local ui_input_orig
|
||||
|
||||
before_each(function()
|
||||
tmpdir = vim.fn.tempname()
|
||||
vim.fn.mkdir(tmpdir, 'p')
|
||||
vim.g.pending = { data_path = tmpdir .. '/tasks.json' }
|
||||
config.reset()
|
||||
store.unload()
|
||||
store.load()
|
||||
package.loaded['pending'] = nil
|
||||
pending = require('pending')
|
||||
pending.store():load()
|
||||
ui_input_orig = vim.ui.input
|
||||
end)
|
||||
|
||||
after_each(function()
|
||||
vim.ui.input = ui_input_orig
|
||||
vim.fn.delete(tmpdir, 'rf')
|
||||
vim.g.pending = nil
|
||||
config.reset()
|
||||
package.loaded['pending'] = nil
|
||||
end)
|
||||
|
||||
local function auto_confirm_y()
|
||||
vim.ui.input = function(_, 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()
|
||||
local t = store.add({ description = 'Old done task' })
|
||||
store.update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
|
||||
auto_confirm_y()
|
||||
local s = pending.store()
|
||||
local t = s:add({ description = 'Old done task' })
|
||||
s:update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
|
||||
pending.archive()
|
||||
assert.are.equal(0, #store.active_tasks())
|
||||
assert.are.equal(0, #s:active_tasks())
|
||||
end)
|
||||
|
||||
it('keeps done tasks completed fewer than 30 days ago', function()
|
||||
auto_confirm_y()
|
||||
local s = pending.store()
|
||||
local recent_end = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (5 * 86400))
|
||||
local t = store.add({ description = 'Recent done task' })
|
||||
store.update(t.id, { status = 'done', ['end'] = recent_end })
|
||||
local t = s:add({ description = 'Recent done task' })
|
||||
s:update(t.id, { status = 'done', ['end'] = recent_end })
|
||||
pending.archive()
|
||||
local active = store.active_tasks()
|
||||
local active = s:active_tasks()
|
||||
assert.are.equal(1, #active)
|
||||
assert.are.equal('Recent done task', active[1].description)
|
||||
end)
|
||||
|
||||
it('respects a custom day count', function()
|
||||
it('respects duration string 7d', function()
|
||||
auto_confirm_y()
|
||||
local s = pending.store()
|
||||
local eight_days_ago = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (8 * 86400))
|
||||
local t = store.add({ description = 'Old for 7 days' })
|
||||
store.update(t.id, { status = 'done', ['end'] = eight_days_ago })
|
||||
pending.archive(7)
|
||||
assert.are.equal(0, #store.active_tasks())
|
||||
local t = s:add({ description = 'Old for 7 days' })
|
||||
s:update(t.id, { status = 'done', ['end'] = eight_days_ago })
|
||||
pending.archive('7d')
|
||||
assert.are.equal(0, #s:active_tasks())
|
||||
end)
|
||||
|
||||
it('keeps tasks within the custom day cutoff', function()
|
||||
it('respects duration string 2w', function()
|
||||
auto_confirm_y()
|
||||
local s = pending.store()
|
||||
local fifteen_days_ago = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (15 * 86400))
|
||||
local t = s:add({ description = 'Old for 2 weeks' })
|
||||
s:update(t.id, { status = 'done', ['end'] = fifteen_days_ago })
|
||||
pending.archive('2w')
|
||||
assert.are.equal(0, #s:active_tasks())
|
||||
end)
|
||||
|
||||
it('respects duration string 2m', function()
|
||||
auto_confirm_y()
|
||||
local s = pending.store()
|
||||
local t = s:add({ description = 'Old for 2 months' })
|
||||
s:update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
|
||||
pending.archive('2m')
|
||||
assert.are.equal(0, #s:active_tasks())
|
||||
end)
|
||||
|
||||
it('respects bare integer as days (backwards compat)', function()
|
||||
auto_confirm_y()
|
||||
local s = pending.store()
|
||||
local eight_days_ago = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (8 * 86400))
|
||||
local t = s:add({ description = 'Old for 7 days' })
|
||||
s:update(t.id, { status = 'done', ['end'] = eight_days_ago })
|
||||
pending.archive('7')
|
||||
assert.are.equal(0, #s:active_tasks())
|
||||
end)
|
||||
|
||||
it('keeps tasks within the custom duration cutoff', function()
|
||||
auto_confirm_y()
|
||||
local s = pending.store()
|
||||
local five_days_ago = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (5 * 86400))
|
||||
local t = store.add({ description = 'Recent for 7 days' })
|
||||
store.update(t.id, { status = 'done', ['end'] = five_days_ago })
|
||||
pending.archive(7)
|
||||
local active = store.active_tasks()
|
||||
local t = s:add({ description = 'Recent for 7 days' })
|
||||
s:update(t.id, { status = 'done', ['end'] = five_days_ago })
|
||||
pending.archive('7d')
|
||||
local active = s:active_tasks()
|
||||
assert.are.equal(1, #active)
|
||||
end)
|
||||
|
||||
it('never archives pending tasks regardless of age', function()
|
||||
store.add({ description = 'Still pending' })
|
||||
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()
|
||||
it('errors on invalid duration input', function()
|
||||
local messages = {}
|
||||
local orig_notify = vim.notify
|
||||
vim.notify = function(msg, ...)
|
||||
|
|
@ -89,11 +117,120 @@ describe('archive', function()
|
|||
return orig_notify(msg, ...)
|
||||
end
|
||||
|
||||
local t1 = store.add({ description = 'Old 1' })
|
||||
local t2 = store.add({ description = 'Old 2' })
|
||||
store.add({ description = 'Keep' })
|
||||
store.update(t1.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
|
||||
store.update(t2.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
|
||||
local s = pending.store()
|
||||
local t = s:add({ description = 'Task' })
|
||||
s:update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
|
||||
pending.archive('xyz')
|
||||
|
||||
vim.notify = orig_notify
|
||||
assert.are.equal(1, #s:tasks())
|
||||
|
||||
local found = false
|
||||
for _, msg in ipairs(messages) do
|
||||
if msg:find('Invalid duration') then
|
||||
found = true
|
||||
break
|
||||
end
|
||||
end
|
||||
assert.is_true(found)
|
||||
end)
|
||||
|
||||
it('never archives pending tasks regardless of age', function()
|
||||
auto_confirm_y()
|
||||
local s = pending.store()
|
||||
s:add({ description = 'Still pending' })
|
||||
pending.archive()
|
||||
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()
|
||||
|
||||
|
|
@ -109,17 +246,19 @@ describe('archive', function()
|
|||
assert.is_true(found)
|
||||
end)
|
||||
|
||||
it('leaves only kept tasks in store.active_tasks after archive', function()
|
||||
local t1 = store.add({ description = 'Old done' })
|
||||
store.add({ description = 'Keep pending' })
|
||||
it('leaves only kept tasks in store after archive', function()
|
||||
auto_confirm_y()
|
||||
local s = pending.store()
|
||||
local t1 = s:add({ description = 'Old done' })
|
||||
s:add({ description = 'Keep pending' })
|
||||
local recent_end = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (5 * 86400))
|
||||
local t3 = store.add({ description = 'Keep recent done' })
|
||||
store.update(t1.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
|
||||
store.update(t3.id, { status = 'done', ['end'] = recent_end })
|
||||
local t3 = s:add({ description = 'Keep recent done' })
|
||||
s:update(t1.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
|
||||
s:update(t3.id, { status = 'done', ['end'] = recent_end })
|
||||
|
||||
pending.archive()
|
||||
|
||||
local active = store.active_tasks()
|
||||
local active = s:active_tasks()
|
||||
assert.are.equal(2, #active)
|
||||
local descs = {}
|
||||
for _, task in ipairs(active) do
|
||||
|
|
@ -130,11 +269,29 @@ describe('archive', function()
|
|||
end)
|
||||
|
||||
it('persists archived tasks to disk after unload/reload', function()
|
||||
local t = store.add({ description = 'Archived task' })
|
||||
store.update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
|
||||
auto_confirm_y()
|
||||
local s = pending.store()
|
||||
local t = s:add({ description = 'Archived task' })
|
||||
s:update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
|
||||
pending.archive()
|
||||
store.unload()
|
||||
store.load()
|
||||
assert.are.equal(0, #store.active_tasks())
|
||||
s:load()
|
||||
assert.are.equal(0, #s:active_tasks())
|
||||
end)
|
||||
|
||||
it('includes the duration in the confirmation prompt', function()
|
||||
local prompt_text
|
||||
vim.ui.input = function(opts, on_confirm)
|
||||
prompt_text = opts.prompt
|
||||
on_confirm('n')
|
||||
end
|
||||
|
||||
local s = pending.store()
|
||||
local t = s:add({ description = 'Old' })
|
||||
s:update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
|
||||
pending.archive('3w')
|
||||
|
||||
assert.is_not_nil(prompt_text)
|
||||
assert.truthy(prompt_text:find('21d'))
|
||||
assert.truthy(prompt_text:find('1 task'))
|
||||
end)
|
||||
end)
|
||||
|
|
|
|||
173
spec/complete_spec.lua
Normal file
173
spec/complete_spec.lua
Normal 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)
|
||||
|
|
@ -1,35 +1,31 @@
|
|||
require('spec.helpers')
|
||||
|
||||
local config = require('pending.config')
|
||||
local store = require('pending.store')
|
||||
|
||||
describe('diff', function()
|
||||
local tmpdir
|
||||
local s
|
||||
local diff = require('pending.diff')
|
||||
|
||||
before_each(function()
|
||||
tmpdir = vim.fn.tempname()
|
||||
vim.fn.mkdir(tmpdir, 'p')
|
||||
vim.g.pending = { data_path = tmpdir .. '/tasks.json' }
|
||||
config.reset()
|
||||
store.unload()
|
||||
store.load()
|
||||
s = store.new(tmpdir .. '/tasks.json')
|
||||
s:load()
|
||||
end)
|
||||
|
||||
after_each(function()
|
||||
vim.fn.delete(tmpdir, 'rf')
|
||||
vim.g.pending = nil
|
||||
config.reset()
|
||||
end)
|
||||
|
||||
describe('parse_buffer', function()
|
||||
it('parses headers and tasks', function()
|
||||
local lines = {
|
||||
'## School',
|
||||
'# School',
|
||||
'/1/- [ ] Do homework',
|
||||
'/2/- [!] Read chapter 5',
|
||||
'',
|
||||
'## Errands',
|
||||
'# Errands',
|
||||
'/3/- [ ] Buy groceries',
|
||||
}
|
||||
local result = diff.parse_buffer(lines)
|
||||
|
|
@ -48,7 +44,7 @@ describe('diff', function()
|
|||
|
||||
it('handles new tasks without ids', function()
|
||||
local lines = {
|
||||
'## Inbox',
|
||||
'# Inbox',
|
||||
'- [ ] New task here',
|
||||
}
|
||||
local result = diff.parse_buffer(lines)
|
||||
|
|
@ -60,7 +56,7 @@ describe('diff', function()
|
|||
|
||||
it('inline cat: token overrides header category', function()
|
||||
local lines = {
|
||||
'## Inbox',
|
||||
'# Inbox',
|
||||
'/1/- [ ] Buy milk cat:Work',
|
||||
}
|
||||
local result = diff.parse_buffer(lines)
|
||||
|
|
@ -69,9 +65,28 @@ describe('diff', function()
|
|||
assert.are.equal('Work', result[2].category)
|
||||
end)
|
||||
|
||||
it('extracts rec: token from buffer line', function()
|
||||
local lines = {
|
||||
'# Inbox',
|
||||
'/1/- [ ] Take trash out rec:weekly',
|
||||
}
|
||||
local result = diff.parse_buffer(lines)
|
||||
assert.are.equal('weekly', result[2].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()
|
||||
local lines = {
|
||||
'## Inbox',
|
||||
'# Inbox',
|
||||
'/1/- [ ] Buy milk due:2026-03-15',
|
||||
}
|
||||
local result = diff.parse_buffer(lines)
|
||||
|
|
@ -84,140 +99,321 @@ describe('diff', function()
|
|||
describe('apply', function()
|
||||
it('creates new tasks from buffer lines', function()
|
||||
local lines = {
|
||||
'## Inbox',
|
||||
'# Inbox',
|
||||
'- [ ] First task',
|
||||
'- [ ] Second task',
|
||||
}
|
||||
diff.apply(lines)
|
||||
store.unload()
|
||||
store.load()
|
||||
local tasks = store.active_tasks()
|
||||
diff.apply(lines, s)
|
||||
s:load()
|
||||
local tasks = s:active_tasks()
|
||||
assert.are.equal(2, #tasks)
|
||||
assert.are.equal('First task', tasks[1].description)
|
||||
assert.are.equal('Second task', tasks[2].description)
|
||||
end)
|
||||
|
||||
it('deletes tasks removed from buffer', function()
|
||||
store.add({ description = 'Keep me' })
|
||||
store.add({ description = 'Delete me' })
|
||||
store.save()
|
||||
s:add({ description = 'Keep me' })
|
||||
s:add({ description = 'Delete me' })
|
||||
s:save()
|
||||
local lines = {
|
||||
'## Inbox',
|
||||
'# Inbox',
|
||||
'/1/- [ ] Keep me',
|
||||
}
|
||||
diff.apply(lines)
|
||||
store.unload()
|
||||
store.load()
|
||||
local active = store.active_tasks()
|
||||
diff.apply(lines, s)
|
||||
s:load()
|
||||
local active = s:active_tasks()
|
||||
assert.are.equal(1, #active)
|
||||
assert.are.equal('Keep me', active[1].description)
|
||||
local deleted = store.get(2)
|
||||
local deleted = s:get(2)
|
||||
assert.are.equal('deleted', deleted.status)
|
||||
end)
|
||||
|
||||
it('updates modified tasks', function()
|
||||
store.add({ description = 'Original' })
|
||||
store.save()
|
||||
s:add({ description = 'Original' })
|
||||
s:save()
|
||||
local lines = {
|
||||
'## Inbox',
|
||||
'# Inbox',
|
||||
'/1/- [ ] Renamed',
|
||||
}
|
||||
diff.apply(lines)
|
||||
store.unload()
|
||||
store.load()
|
||||
local task = store.get(1)
|
||||
diff.apply(lines, s)
|
||||
s:load()
|
||||
local task = s:get(1)
|
||||
assert.are.equal('Renamed', task.description)
|
||||
end)
|
||||
|
||||
it('updates modified when description is renamed', function()
|
||||
local t = store.add({ description = 'Original', category = 'Inbox' })
|
||||
local t = s:add({ description = 'Original', category = 'Inbox' })
|
||||
t.modified = '2020-01-01T00:00:00Z'
|
||||
store.save()
|
||||
s:save()
|
||||
local lines = {
|
||||
'## Inbox',
|
||||
'# Inbox',
|
||||
'/1/- [ ] Renamed',
|
||||
}
|
||||
diff.apply(lines)
|
||||
store.unload()
|
||||
store.load()
|
||||
local task = store.get(1)
|
||||
diff.apply(lines, s)
|
||||
s:load()
|
||||
local task = s:get(1)
|
||||
assert.are.equal('Renamed', task.description)
|
||||
assert.is_not.equal('2020-01-01T00:00:00Z', task.modified)
|
||||
end)
|
||||
|
||||
it('handles duplicate ids as copies', function()
|
||||
store.add({ description = 'Original' })
|
||||
store.save()
|
||||
s:add({ description = 'Original' })
|
||||
s:save()
|
||||
local lines = {
|
||||
'## Inbox',
|
||||
'# Inbox',
|
||||
'/1/- [ ] Original',
|
||||
'/1/- [ ] Copy of original',
|
||||
}
|
||||
diff.apply(lines)
|
||||
store.unload()
|
||||
store.load()
|
||||
local tasks = store.active_tasks()
|
||||
diff.apply(lines, s)
|
||||
s:load()
|
||||
local tasks = s:active_tasks()
|
||||
assert.are.equal(2, #tasks)
|
||||
end)
|
||||
|
||||
it('moves tasks between categories', function()
|
||||
store.add({ description = 'Moving task', category = 'Inbox' })
|
||||
store.save()
|
||||
s:add({ description = 'Moving task', category = 'Inbox' })
|
||||
s:save()
|
||||
local lines = {
|
||||
'## Work',
|
||||
'# Work',
|
||||
'/1/- [ ] Moving task',
|
||||
}
|
||||
diff.apply(lines)
|
||||
store.unload()
|
||||
store.load()
|
||||
local task = store.get(1)
|
||||
diff.apply(lines, s)
|
||||
s:load()
|
||||
local task = s:get(1)
|
||||
assert.are.equal('Work', task.category)
|
||||
end)
|
||||
|
||||
it('does not update modified when task is unchanged', function()
|
||||
store.add({ description = 'Stable task', category = 'Inbox' })
|
||||
store.save()
|
||||
s:add({ description = 'Stable task', category = 'Inbox' })
|
||||
s:save()
|
||||
local lines = {
|
||||
'## Inbox',
|
||||
'# Inbox',
|
||||
'/1/- [ ] Stable task',
|
||||
}
|
||||
diff.apply(lines)
|
||||
store.unload()
|
||||
store.load()
|
||||
local modified_after_first = store.get(1).modified
|
||||
diff.apply(lines)
|
||||
store.unload()
|
||||
store.load()
|
||||
local task = store.get(1)
|
||||
diff.apply(lines, s)
|
||||
s:load()
|
||||
local modified_after_first = s:get(1).modified
|
||||
diff.apply(lines, s)
|
||||
s:load()
|
||||
local task = s:get(1)
|
||||
assert.are.equal(modified_after_first, task.modified)
|
||||
end)
|
||||
|
||||
it('clears due when removed from buffer line', function()
|
||||
store.add({ description = 'Pay bill', due = '2026-03-15' })
|
||||
store.save()
|
||||
it('preserves due when not present in buffer line', function()
|
||||
s:add({ description = 'Pay bill', due = '2026-03-15' })
|
||||
s:save()
|
||||
local lines = {
|
||||
'## Inbox',
|
||||
'# Inbox',
|
||||
'/1/- [ ] Pay bill',
|
||||
}
|
||||
diff.apply(lines)
|
||||
store.unload()
|
||||
store.load()
|
||||
local task = store.get(1)
|
||||
assert.is_nil(task.due)
|
||||
diff.apply(lines, s)
|
||||
s:load()
|
||||
local task = s:get(1)
|
||||
assert.are.equal('2026-03-15', task.due)
|
||||
end)
|
||||
|
||||
it('updates due when inline token is present', function()
|
||||
s:add({ description = 'Pay bill', due = '2026-03-15' })
|
||||
s:save()
|
||||
local lines = {
|
||||
'# Inbox',
|
||||
'/1/- [ ] Pay bill due:2026-04-01',
|
||||
}
|
||||
diff.apply(lines, s)
|
||||
s:load()
|
||||
local task = s:get(1)
|
||||
assert.are.equal('2026-04-01', task.due)
|
||||
end)
|
||||
|
||||
it('stores recur field on new tasks from buffer', function()
|
||||
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)
|
||||
|
||||
it('clears priority when [N] is removed from buffer line', function()
|
||||
store.add({ description = 'Task name', priority = 1 })
|
||||
store.save()
|
||||
s:add({ description = 'Task name', priority = 1 })
|
||||
s:save()
|
||||
local lines = {
|
||||
'## Inbox',
|
||||
'# Inbox',
|
||||
'/1/- [ ] Task name',
|
||||
}
|
||||
diff.apply(lines)
|
||||
store.unload()
|
||||
store.load()
|
||||
local task = store.get(1)
|
||||
diff.apply(lines, s)
|
||||
s:load()
|
||||
local task = s:get(1)
|
||||
assert.are.equal(0, task.priority)
|
||||
end)
|
||||
|
||||
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)
|
||||
|
|
|
|||
329
spec/edit_spec.lua
Normal file
329
spec/edit_spec.lua
Normal 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
292
spec/filter_spec.lua
Normal 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
658
spec/forge_spec.lua
Normal 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
368
spec/gtasks_spec.lua
Normal 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
56
spec/icons_spec.lua
Normal 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
329
spec/oauth_spec.lua
Normal 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)
|
||||
|
|
@ -31,21 +31,21 @@ describe('parse', function()
|
|||
it('extracts category', function()
|
||||
local desc, meta = parse.body('Buy groceries cat:Errands')
|
||||
assert.are.equal('Buy groceries', desc)
|
||||
assert.are.equal('Errands', meta.cat)
|
||||
assert.are.equal('Errands', meta.category)
|
||||
end)
|
||||
|
||||
it('extracts both due and cat', function()
|
||||
local desc, meta = parse.body('Buy milk due:2026-03-15 cat:Errands')
|
||||
assert.are.equal('Buy milk', desc)
|
||||
assert.are.equal('2026-03-15', meta.due)
|
||||
assert.are.equal('Errands', meta.cat)
|
||||
assert.are.equal('Errands', meta.category)
|
||||
end)
|
||||
|
||||
it('extracts metadata in any order', function()
|
||||
local desc, meta = parse.body('Buy milk cat:Errands due:2026-03-15')
|
||||
assert.are.equal('Buy milk', desc)
|
||||
assert.are.equal('2026-03-15', meta.due)
|
||||
assert.are.equal('Errands', meta.cat)
|
||||
assert.are.equal('Errands', meta.category)
|
||||
end)
|
||||
|
||||
it('stops at duplicate key', function()
|
||||
|
|
@ -110,6 +110,34 @@ describe('parse', function()
|
|||
assert.is_nil(meta.due)
|
||||
assert.truthy(desc:find('due:garbage', 1, true))
|
||||
end)
|
||||
|
||||
it('parses metadata before a forge ref', function()
|
||||
local desc, meta = parse.body('Fix bug due:2026-03-15 gh:user/repo#42')
|
||||
assert.are.equal('2026-03-15', meta.due)
|
||||
assert.truthy(desc:find('gh:user/repo#42', 1, true))
|
||||
assert.truthy(desc:find('Fix bug', 1, true))
|
||||
end)
|
||||
|
||||
it('parses metadata after a forge ref', function()
|
||||
local desc, meta = parse.body('Fix bug gh:user/repo#42 due:2026-03-15')
|
||||
assert.are.equal('2026-03-15', meta.due)
|
||||
assert.truthy(desc:find('gh:user/repo#42', 1, true))
|
||||
assert.truthy(desc:find('Fix bug', 1, true))
|
||||
end)
|
||||
|
||||
it('parses all metadata around forge ref', function()
|
||||
local desc, meta = parse.body('Fix bug due:tomorrow gh:user/repo#42 cat:Work')
|
||||
assert.are.equal(os.date('%Y-%m-%d', os.time() + 86400), meta.due)
|
||||
assert.are.equal('Work', meta.category)
|
||||
assert.truthy(desc:find('gh:user/repo#42', 1, true))
|
||||
end)
|
||||
|
||||
it('parses forge ref between metadata tokens', function()
|
||||
local desc, meta = parse.body('Fix bug cat:Work gl:a/b#12 due:2026-03-15')
|
||||
assert.are.equal('2026-03-15', meta.due)
|
||||
assert.are.equal('Work', meta.category)
|
||||
assert.truthy(desc:find('gl:a/b#12', 1, true))
|
||||
end)
|
||||
end)
|
||||
|
||||
describe('parse.resolve_date', function()
|
||||
|
|
@ -154,6 +182,240 @@ describe('parse', function()
|
|||
local result = parse.resolve_date('')
|
||||
assert.is_nil(result)
|
||||
end)
|
||||
|
||||
it("returns yesterday's date for 'yesterday'", function()
|
||||
local expected = os.date('%Y-%m-%d', os.time() - 86400)
|
||||
local result = parse.resolve_date('yesterday')
|
||||
assert.are.equal(expected, result)
|
||||
end)
|
||||
|
||||
it("returns today's date for 'eod'", function()
|
||||
local result = parse.resolve_date('eod')
|
||||
assert.are.equal(os.date('%Y-%m-%d'), result)
|
||||
end)
|
||||
|
||||
it('returns Monday of current week for sow', function()
|
||||
local result = parse.resolve_date('sow')
|
||||
assert.is_not_nil(result)
|
||||
local y, m, d = result:match('^(%d+)-(%d+)-(%d+)$')
|
||||
local t = os.time({ year = tonumber(y), month = tonumber(m), day = tonumber(d) })
|
||||
local wday = os.date('*t', t).wday
|
||||
assert.are.equal(2, wday)
|
||||
end)
|
||||
|
||||
it('returns Sunday of current week for eow', function()
|
||||
local result = parse.resolve_date('eow')
|
||||
assert.is_not_nil(result)
|
||||
local y, m, d = result:match('^(%d+)-(%d+)-(%d+)$')
|
||||
local t = os.time({ year = tonumber(y), month = tonumber(m), day = tonumber(d) })
|
||||
local wday = os.date('*t', t).wday
|
||||
assert.are.equal(1, wday)
|
||||
end)
|
||||
|
||||
it('returns first day of current month for som', function()
|
||||
local today = os.date('*t') --[[@as osdate]]
|
||||
local expected = string.format('%04d-%02d-01', today.year, today.month)
|
||||
local result = parse.resolve_date('som')
|
||||
assert.are.equal(expected, result)
|
||||
end)
|
||||
|
||||
it('returns last day of current month for eom', function()
|
||||
local today = os.date('*t') --[[@as osdate]]
|
||||
local expected =
|
||||
os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month + 1, day = 0 }))
|
||||
local result = parse.resolve_date('eom')
|
||||
assert.are.equal(expected, result)
|
||||
end)
|
||||
|
||||
it('returns first day of current quarter for soq', function()
|
||||
local today = os.date('*t') --[[@as osdate]]
|
||||
local q = math.ceil(today.month / 3)
|
||||
local first_month = (q - 1) * 3 + 1
|
||||
local expected = string.format('%04d-%02d-01', today.year, first_month)
|
||||
local result = parse.resolve_date('soq')
|
||||
assert.are.equal(expected, result)
|
||||
end)
|
||||
|
||||
it('returns last day of current quarter for eoq', function()
|
||||
local today = os.date('*t') --[[@as osdate]]
|
||||
local q = math.ceil(today.month / 3)
|
||||
local last_month = q * 3
|
||||
local expected =
|
||||
os.date('%Y-%m-%d', os.time({ year = today.year, month = last_month + 1, day = 0 }))
|
||||
local result = parse.resolve_date('eoq')
|
||||
assert.are.equal(expected, result)
|
||||
end)
|
||||
|
||||
it('returns Jan 1 of current year for soy', function()
|
||||
local today = os.date('*t') --[[@as osdate]]
|
||||
local expected = string.format('%04d-01-01', today.year)
|
||||
local result = parse.resolve_date('soy')
|
||||
assert.are.equal(expected, result)
|
||||
end)
|
||||
|
||||
it('returns Dec 31 of current year for eoy', function()
|
||||
local today = os.date('*t') --[[@as osdate]]
|
||||
local expected = string.format('%04d-12-31', today.year)
|
||||
local result = parse.resolve_date('eoy')
|
||||
assert.are.equal(expected, result)
|
||||
end)
|
||||
|
||||
it('resolves +2w to 14 days from today', function()
|
||||
local today = os.date('*t') --[[@as osdate]]
|
||||
local expected = os.date(
|
||||
'%Y-%m-%d',
|
||||
os.time({ year = today.year, month = today.month, day = today.day + 14 })
|
||||
)
|
||||
local result = parse.resolve_date('+2w')
|
||||
assert.are.equal(expected, result)
|
||||
end)
|
||||
|
||||
it('resolves +3m to 3 months from today', function()
|
||||
local today = os.date('*t') --[[@as osdate]]
|
||||
local expected = os.date(
|
||||
'%Y-%m-%d',
|
||||
os.time({ year = today.year, month = today.month + 3, day = today.day })
|
||||
)
|
||||
local result = parse.resolve_date('+3m')
|
||||
assert.are.equal(expected, result)
|
||||
end)
|
||||
|
||||
it('resolves -2d to 2 days ago', function()
|
||||
local today = os.date('*t') --[[@as osdate]]
|
||||
local expected = os.date(
|
||||
'%Y-%m-%d',
|
||||
os.time({ year = today.year, month = today.month, day = today.day - 2 })
|
||||
)
|
||||
local result = parse.resolve_date('-2d')
|
||||
assert.are.equal(expected, result)
|
||||
end)
|
||||
|
||||
it('resolves -1w to 7 days ago', function()
|
||||
local today = os.date('*t') --[[@as osdate]]
|
||||
local expected = os.date(
|
||||
'%Y-%m-%d',
|
||||
os.time({ year = today.year, month = today.month, day = today.day - 7 })
|
||||
)
|
||||
local result = parse.resolve_date('-1w')
|
||||
assert.are.equal(expected, result)
|
||||
end)
|
||||
|
||||
it("resolves 'later' to someday_date", function()
|
||||
local result = parse.resolve_date('later')
|
||||
assert.are.equal('9999-12-30', result)
|
||||
end)
|
||||
|
||||
it("resolves 'someday' to someday_date", function()
|
||||
local result = parse.resolve_date('someday')
|
||||
assert.are.equal('9999-12-30', result)
|
||||
end)
|
||||
|
||||
it('resolves 15th to next 15th of month', function()
|
||||
local result = parse.resolve_date('15th')
|
||||
assert.is_not_nil(result)
|
||||
local _, _, d = result:match('^(%d+)-(%d+)-(%d+)$')
|
||||
assert.are.equal('15', d)
|
||||
end)
|
||||
|
||||
it('resolves 1st to next 1st of month', function()
|
||||
local result = parse.resolve_date('1st')
|
||||
assert.is_not_nil(result)
|
||||
local _, _, d = result:match('^(%d+)-(%d+)-(%d+)$')
|
||||
assert.are.equal('01', d)
|
||||
end)
|
||||
|
||||
it('resolves jan to next January 1st', function()
|
||||
local today = os.date('*t') --[[@as osdate]]
|
||||
local result = parse.resolve_date('jan')
|
||||
assert.is_not_nil(result)
|
||||
local y, m, d = result:match('^(%d+)-(%d+)-(%d+)$')
|
||||
assert.are.equal('01', m)
|
||||
assert.are.equal('01', d)
|
||||
if today.month >= 1 then
|
||||
assert.are.equal(tostring(today.year + 1), y)
|
||||
end
|
||||
end)
|
||||
|
||||
it('resolves dec to next December 1st', function()
|
||||
local today = os.date('*t') --[[@as osdate]]
|
||||
local result = parse.resolve_date('dec')
|
||||
assert.is_not_nil(result)
|
||||
local y, m, d = result:match('^(%d+)-(%d+)-(%d+)$')
|
||||
assert.are.equal('12', m)
|
||||
assert.are.equal('01', d)
|
||||
if today.month >= 12 then
|
||||
assert.are.equal(tostring(today.year + 1), y)
|
||||
else
|
||||
assert.are.equal(tostring(today.year), y)
|
||||
end
|
||||
end)
|
||||
end)
|
||||
|
||||
describe('resolve_date with time suffix', function()
|
||||
local today = os.date('*t') --[[@as osdate]]
|
||||
local tomorrow_str =
|
||||
os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day + 1 })) --[[@as string]]
|
||||
|
||||
it('resolves bare hour to T09:00', function()
|
||||
local result = parse.resolve_date('tomorrow@9')
|
||||
assert.are.equal(tomorrow_str .. 'T09:00', result)
|
||||
end)
|
||||
|
||||
it('resolves bare military hour to T14:00', function()
|
||||
local result = parse.resolve_date('tomorrow@14')
|
||||
assert.are.equal(tomorrow_str .. 'T14:00', result)
|
||||
end)
|
||||
|
||||
it('resolves H:MM to T09:30', function()
|
||||
local result = parse.resolve_date('tomorrow@9:30')
|
||||
assert.are.equal(tomorrow_str .. 'T09:30', result)
|
||||
end)
|
||||
|
||||
it('resolves HH:MM (existing format) to T09:30', function()
|
||||
local result = parse.resolve_date('tomorrow@09:30')
|
||||
assert.are.equal(tomorrow_str .. 'T09:30', result)
|
||||
end)
|
||||
|
||||
it('resolves 2pm to T14:00', function()
|
||||
local result = parse.resolve_date('tomorrow@2pm')
|
||||
assert.are.equal(tomorrow_str .. 'T14:00', result)
|
||||
end)
|
||||
|
||||
it('resolves 9am to T09:00', function()
|
||||
local result = parse.resolve_date('tomorrow@9am')
|
||||
assert.are.equal(tomorrow_str .. 'T09:00', result)
|
||||
end)
|
||||
|
||||
it('resolves 9:30pm to T21:30', function()
|
||||
local result = parse.resolve_date('tomorrow@9:30pm')
|
||||
assert.are.equal(tomorrow_str .. 'T21:30', result)
|
||||
end)
|
||||
|
||||
it('resolves 12am to T00:00', function()
|
||||
local result = parse.resolve_date('tomorrow@12am')
|
||||
assert.are.equal(tomorrow_str .. 'T00:00', result)
|
||||
end)
|
||||
|
||||
it('resolves 12pm to T12:00', function()
|
||||
local result = parse.resolve_date('tomorrow@12pm')
|
||||
assert.are.equal(tomorrow_str .. 'T12:00', result)
|
||||
end)
|
||||
|
||||
it('rejects hour 24', function()
|
||||
assert.is_nil(parse.resolve_date('tomorrow@24'))
|
||||
end)
|
||||
|
||||
it('rejects 13am', function()
|
||||
assert.is_nil(parse.resolve_date('tomorrow@13am'))
|
||||
end)
|
||||
|
||||
it('rejects minute 60', function()
|
||||
assert.is_nil(parse.resolve_date('tomorrow@9:60'))
|
||||
end)
|
||||
|
||||
it('rejects alphabetic garbage', function()
|
||||
assert.is_nil(parse.resolve_date('tomorrow@abc'))
|
||||
end)
|
||||
end)
|
||||
|
||||
describe('command_add', function()
|
||||
|
|
@ -166,7 +428,7 @@ describe('parse', function()
|
|||
it('detects category prefix', function()
|
||||
local desc, meta = parse.command_add('School: Do homework')
|
||||
assert.are.equal('Do homework', desc)
|
||||
assert.are.equal('School', meta.cat)
|
||||
assert.are.equal('School', meta.category)
|
||||
end)
|
||||
|
||||
it('ignores lowercase prefix', function()
|
||||
|
|
@ -177,7 +439,118 @@ describe('parse', function()
|
|||
it('combines category prefix with inline metadata', function()
|
||||
local desc, meta = parse.command_add('School: Do homework due:2026-03-15')
|
||||
assert.are.equal('Do homework', desc)
|
||||
assert.are.equal('School', meta.cat)
|
||||
assert.are.equal('School', meta.category)
|
||||
assert.are.equal('2026-03-15', meta.due)
|
||||
end)
|
||||
end)
|
||||
|
||||
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)
|
||||
end)
|
||||
end)
|
||||
|
|
|
|||
223
spec/recur_spec.lua
Normal file
223
spec/recur_spec.lua
Normal 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
626
spec/s3_spec.lua
Normal 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
260
spec/status_spec.lua
Normal 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)
|
||||
|
|
@ -5,31 +5,30 @@ local store = require('pending.store')
|
|||
|
||||
describe('store', function()
|
||||
local tmpdir
|
||||
local s
|
||||
|
||||
before_each(function()
|
||||
tmpdir = vim.fn.tempname()
|
||||
vim.fn.mkdir(tmpdir, 'p')
|
||||
vim.g.pending = { data_path = tmpdir .. '/tasks.json' }
|
||||
config.reset()
|
||||
store.unload()
|
||||
s = store.new(tmpdir .. '/tasks.json')
|
||||
s:load()
|
||||
end)
|
||||
|
||||
after_each(function()
|
||||
vim.fn.delete(tmpdir, 'rf')
|
||||
vim.g.pending = nil
|
||||
config.reset()
|
||||
end)
|
||||
|
||||
describe('load', function()
|
||||
it('returns empty data when no file exists', function()
|
||||
local data = store.load()
|
||||
local data = s:load()
|
||||
assert.are.equal(1, data.version)
|
||||
assert.are.equal(1, data.next_id)
|
||||
assert.are.same({}, data.tasks)
|
||||
end)
|
||||
|
||||
it('loads existing data', function()
|
||||
local path = config.get().data_path
|
||||
local path = tmpdir .. '/tasks.json'
|
||||
local f = io.open(path, 'w')
|
||||
f:write(vim.json.encode({
|
||||
version = 1,
|
||||
|
|
@ -52,7 +51,7 @@ describe('store', function()
|
|||
},
|
||||
}))
|
||||
f:close()
|
||||
local data = store.load()
|
||||
local data = s:load()
|
||||
assert.are.equal(3, data.next_id)
|
||||
assert.are.equal(2, #data.tasks)
|
||||
assert.are.equal('Pending one', data.tasks[1].description)
|
||||
|
|
@ -60,7 +59,7 @@ describe('store', function()
|
|||
end)
|
||||
|
||||
it('preserves unknown fields', function()
|
||||
local path = config.get().data_path
|
||||
local path = tmpdir .. '/tasks.json'
|
||||
local f = io.open(path, 'w')
|
||||
f:write(vim.json.encode({
|
||||
version = 1,
|
||||
|
|
@ -77,8 +76,8 @@ describe('store', function()
|
|||
},
|
||||
}))
|
||||
f:close()
|
||||
store.load()
|
||||
local task = store.get(1)
|
||||
s:load()
|
||||
local task = s:get(1)
|
||||
assert.is_not_nil(task._extra)
|
||||
assert.are.equal('hello', task._extra.custom_field)
|
||||
end)
|
||||
|
|
@ -86,9 +85,8 @@ describe('store', function()
|
|||
|
||||
describe('add', function()
|
||||
it('creates a task with incremented id', function()
|
||||
store.load()
|
||||
local t1 = store.add({ description = 'First' })
|
||||
local t2 = store.add({ description = 'Second' })
|
||||
local t1 = s:add({ description = 'First' })
|
||||
local t2 = s:add({ description = 'Second' })
|
||||
assert.are.equal(1, t1.id)
|
||||
assert.are.equal(2, t2.id)
|
||||
assert.are.equal('pending', t1.status)
|
||||
|
|
@ -96,60 +94,54 @@ describe('store', function()
|
|||
end)
|
||||
|
||||
it('uses provided category', function()
|
||||
store.load()
|
||||
local t = store.add({ description = 'Test', category = 'Work' })
|
||||
local t = s:add({ description = 'Test', category = 'Work' })
|
||||
assert.are.equal('Work', t.category)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe('update', function()
|
||||
it('updates fields and sets modified', function()
|
||||
store.load()
|
||||
local t = store.add({ description = 'Original' })
|
||||
local t = s:add({ description = 'Original' })
|
||||
t.modified = '2025-01-01T00:00:00Z'
|
||||
store.update(t.id, { description = 'Updated' })
|
||||
local updated = store.get(t.id)
|
||||
s:update(t.id, { description = 'Updated' })
|
||||
local updated = s:get(t.id)
|
||||
assert.are.equal('Updated', updated.description)
|
||||
assert.is_not.equal('2025-01-01T00:00:00Z', updated.modified)
|
||||
end)
|
||||
|
||||
it('sets end timestamp on completion', function()
|
||||
store.load()
|
||||
local t = store.add({ description = 'Test' })
|
||||
local t = s:add({ description = 'Test' })
|
||||
assert.is_nil(t['end'])
|
||||
store.update(t.id, { status = 'done' })
|
||||
local updated = store.get(t.id)
|
||||
s:update(t.id, { status = 'done' })
|
||||
local updated = s:get(t.id)
|
||||
assert.is_not_nil(updated['end'])
|
||||
end)
|
||||
|
||||
it('does not overwrite id or entry', function()
|
||||
store.load()
|
||||
local t = store.add({ description = 'Immutable fields' })
|
||||
local t = s:add({ description = 'Immutable fields' })
|
||||
local original_id = t.id
|
||||
local original_entry = t.entry
|
||||
store.update(t.id, { id = 999, entry = 'x' })
|
||||
local updated = store.get(original_id)
|
||||
s:update(t.id, { id = 999, entry = 'x' })
|
||||
local updated = s:get(original_id)
|
||||
assert.are.equal(original_id, updated.id)
|
||||
assert.are.equal(original_entry, updated.entry)
|
||||
end)
|
||||
|
||||
it('does not overwrite end on second completion', function()
|
||||
store.load()
|
||||
local t = store.add({ description = 'Complete twice' })
|
||||
store.update(t.id, { status = 'done', ['end'] = '2026-01-15T10:00:00Z' })
|
||||
local first_end = store.get(t.id)['end']
|
||||
store.update(t.id, { status = 'done' })
|
||||
local task = store.get(t.id)
|
||||
local t = s:add({ description = 'Complete twice' })
|
||||
s:update(t.id, { status = 'done', ['end'] = '2026-01-15T10:00:00Z' })
|
||||
local first_end = s:get(t.id)['end']
|
||||
s:update(t.id, { status = 'done' })
|
||||
local task = s:get(t.id)
|
||||
assert.are.equal(first_end, task['end'])
|
||||
end)
|
||||
end)
|
||||
|
||||
describe('delete', function()
|
||||
it('marks task as deleted', function()
|
||||
store.load()
|
||||
local t = store.add({ description = 'To delete' })
|
||||
store.delete(t.id)
|
||||
local deleted = store.get(t.id)
|
||||
local t = s:add({ description = 'To delete' })
|
||||
s:delete(t.id)
|
||||
local deleted = s:get(t.id)
|
||||
assert.are.equal('deleted', deleted.status)
|
||||
assert.is_not_nil(deleted['end'])
|
||||
end)
|
||||
|
|
@ -157,12 +149,10 @@ describe('store', function()
|
|||
|
||||
describe('save and round-trip', function()
|
||||
it('persists and reloads correctly', function()
|
||||
store.load()
|
||||
store.add({ description = 'Persisted', category = 'Work', priority = 1 })
|
||||
store.save()
|
||||
store.unload()
|
||||
store.load()
|
||||
local tasks = store.active_tasks()
|
||||
s:add({ description = 'Persisted', category = 'Work', priority = 1 })
|
||||
s:save()
|
||||
s:load()
|
||||
local tasks = s:active_tasks()
|
||||
assert.are.equal(1, #tasks)
|
||||
assert.are.equal('Persisted', tasks[1].description)
|
||||
assert.are.equal('Work', tasks[1].category)
|
||||
|
|
@ -170,7 +160,7 @@ describe('store', function()
|
|||
end)
|
||||
|
||||
it('round-trips unknown fields', function()
|
||||
local path = config.get().data_path
|
||||
local path = tmpdir .. '/tasks.json'
|
||||
local f = io.open(path, 'w')
|
||||
f:write(vim.json.encode({
|
||||
version = 1,
|
||||
|
|
@ -187,22 +177,78 @@ describe('store', function()
|
|||
},
|
||||
}))
|
||||
f:close()
|
||||
store.load()
|
||||
store.save()
|
||||
store.unload()
|
||||
store.load()
|
||||
local task = store.get(1)
|
||||
s:load()
|
||||
s:save()
|
||||
s:load()
|
||||
local task = s:get(1)
|
||||
assert.are.equal('abc123', task._extra._gcal_event_id)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe('recurrence fields', function()
|
||||
it('persists recur and recur_mode through round-trip', function()
|
||||
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()
|
||||
it('excludes deleted tasks', function()
|
||||
store.load()
|
||||
store.add({ description = 'Active' })
|
||||
local t2 = store.add({ description = 'To delete' })
|
||||
store.delete(t2.id)
|
||||
local active = store.active_tasks()
|
||||
s:add({ description = 'Active' })
|
||||
local t2 = s:add({ description = 'To delete' })
|
||||
s:delete(t2.id)
|
||||
local active = s:active_tasks()
|
||||
assert.are.equal(1, #active)
|
||||
assert.are.equal('Active', active[1].description)
|
||||
end)
|
||||
|
|
@ -210,27 +256,24 @@ describe('store', function()
|
|||
|
||||
describe('snapshot', function()
|
||||
it('returns a table of tasks', function()
|
||||
store.load()
|
||||
store.add({ description = 'Snap one' })
|
||||
store.add({ description = 'Snap two' })
|
||||
local snap = store.snapshot()
|
||||
s:add({ description = 'Snap one' })
|
||||
s:add({ description = 'Snap two' })
|
||||
local snap = s:snapshot()
|
||||
assert.are.equal(2, #snap)
|
||||
end)
|
||||
|
||||
it('returns a copy that does not affect the store', function()
|
||||
store.load()
|
||||
local t = store.add({ description = 'Original' })
|
||||
local snap = store.snapshot()
|
||||
local t = s:add({ description = 'Original' })
|
||||
local snap = s:snapshot()
|
||||
snap[1].description = 'Mutated'
|
||||
local live = store.get(t.id)
|
||||
local live = s:get(t.id)
|
||||
assert.are.equal('Original', live.description)
|
||||
end)
|
||||
|
||||
it('excludes deleted tasks', function()
|
||||
store.load()
|
||||
local t = store.add({ description = 'Will be deleted' })
|
||||
store.delete(t.id)
|
||||
local snap = store.snapshot()
|
||||
local t = s:add({ description = 'Will be deleted' })
|
||||
s:delete(t.id)
|
||||
local snap = s:snapshot()
|
||||
assert.are.equal(0, #snap)
|
||||
end)
|
||||
end)
|
||||
|
|
|
|||
185
spec/sync_spec.lua
Normal file
185
spec/sync_spec.lua
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
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('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
100
spec/sync_util_spec.lua
Normal 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
194
spec/textobj_spec.lua
Normal 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)
|
||||
|
|
@ -5,39 +5,38 @@ local store = require('pending.store')
|
|||
|
||||
describe('views', function()
|
||||
local tmpdir
|
||||
local s
|
||||
local views = require('pending.views')
|
||||
|
||||
before_each(function()
|
||||
tmpdir = vim.fn.tempname()
|
||||
vim.fn.mkdir(tmpdir, 'p')
|
||||
vim.g.pending = { data_path = tmpdir .. '/tasks.json' }
|
||||
config.reset()
|
||||
store.unload()
|
||||
store.load()
|
||||
s = store.new(tmpdir .. '/tasks.json')
|
||||
s:load()
|
||||
end)
|
||||
|
||||
after_each(function()
|
||||
vim.fn.delete(tmpdir, 'rf')
|
||||
vim.g.pending = nil
|
||||
config.reset()
|
||||
end)
|
||||
|
||||
describe('category_view', function()
|
||||
it('groups tasks under their category header', function()
|
||||
store.add({ description = 'Task A', category = 'Work' })
|
||||
store.add({ description = 'Task B', category = 'Work' })
|
||||
local lines, meta = views.category_view(store.active_tasks())
|
||||
assert.are.equal('## Work', lines[1])
|
||||
s:add({ description = 'Task A', category = 'Work' })
|
||||
s:add({ description = 'Task B', category = 'Work' })
|
||||
local lines, meta = views.category_view(s:active_tasks())
|
||||
assert.are.equal('# Work', lines[1])
|
||||
assert.are.equal('header', meta[1].type)
|
||||
assert.is_true(lines[2]:find('Task A') ~= nil)
|
||||
assert.is_true(lines[3]:find('Task B') ~= nil)
|
||||
end)
|
||||
|
||||
it('places pending tasks before done tasks within a category', function()
|
||||
local t1 = store.add({ description = 'Done task', category = 'Work' })
|
||||
store.add({ description = 'Pending task', category = 'Work' })
|
||||
store.update(t1.id, { status = 'done' })
|
||||
local _, meta = views.category_view(store.active_tasks())
|
||||
local t1 = s:add({ description = 'Done task', category = 'Work' })
|
||||
s:add({ description = 'Pending task', category = 'Work' })
|
||||
s:update(t1.id, { status = 'done' })
|
||||
local _, meta = views.category_view(s:active_tasks())
|
||||
local pending_row, done_row
|
||||
for i, m in ipairs(meta) do
|
||||
if m.type == 'task' and m.status == 'pending' then
|
||||
|
|
@ -50,9 +49,9 @@ describe('views', function()
|
|||
end)
|
||||
|
||||
it('sorts high-priority tasks before normal tasks within pending group', function()
|
||||
store.add({ description = 'Normal', category = 'Work', priority = 0 })
|
||||
store.add({ description = 'High', category = 'Work', priority = 1 })
|
||||
local lines, meta = views.category_view(store.active_tasks())
|
||||
s:add({ description = 'Normal', category = 'Work', priority = 0 })
|
||||
s:add({ description = 'High', category = 'Work', priority = 1 })
|
||||
local lines, meta = views.category_view(s:active_tasks())
|
||||
local high_row, normal_row
|
||||
for i, m in ipairs(meta) do
|
||||
if m.type == 'task' then
|
||||
|
|
@ -68,11 +67,11 @@ describe('views', function()
|
|||
end)
|
||||
|
||||
it('sorts high-priority tasks before normal tasks within done group', function()
|
||||
local t1 = store.add({ description = 'Done Normal', category = 'Work', priority = 0 })
|
||||
local t2 = store.add({ description = 'Done High', category = 'Work', priority = 1 })
|
||||
store.update(t1.id, { status = 'done' })
|
||||
store.update(t2.id, { status = 'done' })
|
||||
local lines, meta = views.category_view(store.active_tasks())
|
||||
local t1 = s:add({ description = 'Done Normal', category = 'Work', priority = 0 })
|
||||
local t2 = s:add({ description = 'Done High', category = 'Work', priority = 1 })
|
||||
s:update(t1.id, { status = 'done' })
|
||||
s:update(t2.id, { status = 'done' })
|
||||
local lines, meta = views.category_view(s:active_tasks())
|
||||
local high_row, normal_row
|
||||
for i, m in ipairs(meta) do
|
||||
if m.type == 'task' then
|
||||
|
|
@ -88,9 +87,9 @@ describe('views', function()
|
|||
end)
|
||||
|
||||
it('gives each category its own header with blank lines between them', function()
|
||||
store.add({ description = 'Task A', category = 'Work' })
|
||||
store.add({ description = 'Task B', category = 'Personal' })
|
||||
local lines, meta = views.category_view(store.active_tasks())
|
||||
s:add({ description = 'Task A', category = 'Work' })
|
||||
s:add({ description = 'Task B', category = 'Personal' })
|
||||
local lines, meta = views.category_view(s:active_tasks())
|
||||
local headers = {}
|
||||
local blank_found = false
|
||||
for i, m in ipairs(meta) do
|
||||
|
|
@ -105,8 +104,8 @@ describe('views', function()
|
|||
end)
|
||||
|
||||
it('formats task lines as /ID/ description', function()
|
||||
store.add({ description = 'My task', category = 'Inbox' })
|
||||
local lines, meta = views.category_view(store.active_tasks())
|
||||
s:add({ description = 'My task', category = 'Inbox' })
|
||||
local lines, meta = views.category_view(s:active_tasks())
|
||||
local task_line
|
||||
for i, m in ipairs(meta) do
|
||||
if m.type == 'task' then
|
||||
|
|
@ -117,8 +116,8 @@ describe('views', function()
|
|||
end)
|
||||
|
||||
it('formats priority task lines as /ID/- [!] description', function()
|
||||
store.add({ description = 'Important', category = 'Inbox', priority = 1 })
|
||||
local lines, meta = views.category_view(store.active_tasks())
|
||||
s:add({ description = 'Important', category = 'Inbox', priority = 1 })
|
||||
local lines, meta = views.category_view(s:active_tasks())
|
||||
local task_line
|
||||
for i, m in ipairs(meta) do
|
||||
if m.type == 'task' then
|
||||
|
|
@ -129,15 +128,15 @@ describe('views', function()
|
|||
end)
|
||||
|
||||
it('sets LineMeta type=header for header lines with correct category', function()
|
||||
store.add({ description = 'T', category = 'School' })
|
||||
local _, meta = views.category_view(store.active_tasks())
|
||||
s:add({ description = 'T', category = 'School' })
|
||||
local _, meta = views.category_view(s:active_tasks())
|
||||
assert.are.equal('header', meta[1].type)
|
||||
assert.are.equal('School', meta[1].category)
|
||||
end)
|
||||
|
||||
it('sets LineMeta type=task with correct id and status', function()
|
||||
local t = store.add({ description = 'Do something', category = 'Inbox' })
|
||||
local _, meta = views.category_view(store.active_tasks())
|
||||
local t = s:add({ description = 'Do something', category = 'Inbox' })
|
||||
local _, meta = views.category_view(s:active_tasks())
|
||||
local task_meta
|
||||
for _, m in ipairs(meta) do
|
||||
if m.type == 'task' then
|
||||
|
|
@ -150,9 +149,9 @@ describe('views', function()
|
|||
end)
|
||||
|
||||
it('sets LineMeta type=blank for blank separator lines', function()
|
||||
store.add({ description = 'A', category = 'Work' })
|
||||
store.add({ description = 'B', category = 'Home' })
|
||||
local _, meta = views.category_view(store.active_tasks())
|
||||
s:add({ description = 'A', category = 'Work' })
|
||||
s:add({ description = 'B', category = 'Home' })
|
||||
local _, meta = views.category_view(s:active_tasks())
|
||||
local blank_meta
|
||||
for _, m in ipairs(meta) do
|
||||
if m.type == 'blank' then
|
||||
|
|
@ -166,8 +165,8 @@ describe('views', function()
|
|||
|
||||
it('marks overdue pending tasks with meta.overdue=true', function()
|
||||
local yesterday = os.date('%Y-%m-%d', os.time() - 86400)
|
||||
local t = store.add({ description = 'Overdue task', category = 'Inbox', due = yesterday })
|
||||
local _, meta = views.category_view(store.active_tasks())
|
||||
local t = s:add({ description = 'Overdue task', category = 'Inbox', due = yesterday })
|
||||
local _, meta = views.category_view(s:active_tasks())
|
||||
local task_meta
|
||||
for _, m in ipairs(meta) do
|
||||
if m.type == 'task' and m.id == t.id then
|
||||
|
|
@ -179,8 +178,8 @@ describe('views', function()
|
|||
|
||||
it('does not mark future pending tasks as overdue', function()
|
||||
local tomorrow = os.date('%Y-%m-%d', os.time() + 86400)
|
||||
local t = store.add({ description = 'Future task', category = 'Inbox', due = tomorrow })
|
||||
local _, meta = views.category_view(store.active_tasks())
|
||||
local t = s:add({ description = 'Future task', category = 'Inbox', due = tomorrow })
|
||||
local _, meta = views.category_view(s:active_tasks())
|
||||
local task_meta
|
||||
for _, m in ipairs(meta) do
|
||||
if m.type == 'task' and m.id == t.id then
|
||||
|
|
@ -192,9 +191,9 @@ describe('views', function()
|
|||
|
||||
it('does not mark done tasks with overdue due dates as overdue', function()
|
||||
local yesterday = os.date('%Y-%m-%d', os.time() - 86400)
|
||||
local t = store.add({ description = 'Done late', category = 'Inbox', due = yesterday })
|
||||
store.update(t.id, { status = 'done' })
|
||||
local _, meta = views.category_view(store.active_tasks())
|
||||
local t = s:add({ description = 'Done late', category = 'Inbox', due = yesterday })
|
||||
s:update(t.id, { status = 'done' })
|
||||
local _, meta = views.category_view(s:active_tasks())
|
||||
local task_meta
|
||||
for _, m in ipairs(meta) do
|
||||
if m.type == 'task' and m.id == t.id then
|
||||
|
|
@ -204,12 +203,39 @@ describe('views', function()
|
|||
assert.is_falsy(task_meta.overdue)
|
||||
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()
|
||||
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()
|
||||
store.add({ description = 'Inbox task', category = 'Inbox' })
|
||||
store.add({ description = 'Work task', category = 'Work' })
|
||||
local lines, meta = views.category_view(store.active_tasks())
|
||||
s:add({ description = 'Inbox task', category = 'Inbox' })
|
||||
s:add({ description = 'Work task', category = 'Work' })
|
||||
local lines, meta = views.category_view(s:active_tasks())
|
||||
local first_header, second_header
|
||||
for i, m in ipairs(meta) do
|
||||
if m.type == 'header' then
|
||||
|
|
@ -220,47 +246,48 @@ describe('views', function()
|
|||
end
|
||||
end
|
||||
end
|
||||
assert.are.equal('## Work', first_header)
|
||||
assert.are.equal('## Inbox', second_header)
|
||||
assert.are.equal('# Work', first_header)
|
||||
assert.are.equal('# Inbox', second_header)
|
||||
end)
|
||||
|
||||
it('appends categories not in category_order after ordered ones', function()
|
||||
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()
|
||||
store.add({ description = 'Errand', category = 'Errands' })
|
||||
store.add({ description = 'Work task', category = 'Work' })
|
||||
local lines, meta = views.category_view(store.active_tasks())
|
||||
s:add({ description = 'Errand', category = 'Errands' })
|
||||
s:add({ description = 'Work task', category = 'Work' })
|
||||
local lines, meta = views.category_view(s:active_tasks())
|
||||
local headers = {}
|
||||
for i, m in ipairs(meta) do
|
||||
if m.type == 'header' then
|
||||
table.insert(headers, lines[i])
|
||||
end
|
||||
end
|
||||
assert.are.equal('## Work', headers[1])
|
||||
assert.are.equal('## Errands', headers[2])
|
||||
assert.are.equal('# Work', headers[1])
|
||||
assert.are.equal('# Errands', headers[2])
|
||||
end)
|
||||
|
||||
it('preserves insertion order when category_order is empty', function()
|
||||
store.add({ description = 'Alpha task', category = 'Alpha' })
|
||||
store.add({ description = 'Beta task', category = 'Beta' })
|
||||
local lines, meta = views.category_view(store.active_tasks())
|
||||
s:add({ description = 'Alpha task', category = 'Alpha' })
|
||||
s:add({ description = 'Beta task', category = 'Beta' })
|
||||
local lines, meta = views.category_view(s:active_tasks())
|
||||
local headers = {}
|
||||
for i, m in ipairs(meta) do
|
||||
if m.type == 'header' then
|
||||
table.insert(headers, lines[i])
|
||||
end
|
||||
end
|
||||
assert.are.equal('## Alpha', headers[1])
|
||||
assert.are.equal('## Beta', headers[2])
|
||||
assert.are.equal('# Alpha', headers[1])
|
||||
assert.are.equal('# Beta', headers[2])
|
||||
end)
|
||||
end)
|
||||
|
||||
describe('priority_view', function()
|
||||
it('places all pending tasks before done tasks', function()
|
||||
local t1 = store.add({ description = 'Done A', category = 'Work' })
|
||||
store.add({ description = 'Pending B', category = 'Work' })
|
||||
store.update(t1.id, { status = 'done' })
|
||||
local _, meta = views.priority_view(store.active_tasks())
|
||||
local t1 = s:add({ description = 'Done A', category = 'Work' })
|
||||
s:add({ description = 'Pending B', category = 'Work' })
|
||||
s:update(t1.id, { status = 'done' })
|
||||
local _, meta = views.priority_view(s:active_tasks())
|
||||
local last_pending_row, first_done_row
|
||||
for i, m in ipairs(meta) do
|
||||
if m.type == 'task' then
|
||||
|
|
@ -275,9 +302,9 @@ describe('views', function()
|
|||
end)
|
||||
|
||||
it('sorts pending tasks by priority desc within pending group', function()
|
||||
store.add({ description = 'Low', category = 'Work', priority = 0 })
|
||||
store.add({ description = 'High', category = 'Work', priority = 1 })
|
||||
local lines, meta = views.priority_view(store.active_tasks())
|
||||
s:add({ description = 'Low', category = 'Work', priority = 0 })
|
||||
s:add({ description = 'High', category = 'Work', priority = 1 })
|
||||
local lines, meta = views.priority_view(s:active_tasks())
|
||||
local high_row, low_row
|
||||
for i, m in ipairs(meta) do
|
||||
if m.type == 'task' then
|
||||
|
|
@ -292,9 +319,9 @@ describe('views', function()
|
|||
end)
|
||||
|
||||
it('sorts pending tasks with due dates before those without', function()
|
||||
store.add({ description = 'No due', category = 'Work' })
|
||||
store.add({ description = 'Has due', category = 'Work', due = '2099-12-31' })
|
||||
local lines, meta = views.priority_view(store.active_tasks())
|
||||
s:add({ description = 'No due', category = 'Work' })
|
||||
s:add({ description = 'Has due', category = 'Work', due = '2099-12-31' })
|
||||
local lines, meta = views.priority_view(s:active_tasks())
|
||||
local due_row, nodue_row
|
||||
for i, m in ipairs(meta) do
|
||||
if m.type == 'task' then
|
||||
|
|
@ -309,9 +336,9 @@ describe('views', function()
|
|||
end)
|
||||
|
||||
it('sorts pending tasks with earlier due dates before later due dates', function()
|
||||
store.add({ description = 'Later', category = 'Work', due = '2099-12-31' })
|
||||
store.add({ description = 'Earlier', category = 'Work', due = '2050-01-01' })
|
||||
local lines, meta = views.priority_view(store.active_tasks())
|
||||
s:add({ description = 'Later', category = 'Work', due = '2099-12-31' })
|
||||
s:add({ description = 'Earlier', category = 'Work', due = '2050-01-01' })
|
||||
local lines, meta = views.priority_view(s:active_tasks())
|
||||
local earlier_row, later_row
|
||||
for i, m in ipairs(meta) do
|
||||
if m.type == 'task' then
|
||||
|
|
@ -326,15 +353,15 @@ describe('views', function()
|
|||
end)
|
||||
|
||||
it('formats task lines as /ID/- [ ] description', function()
|
||||
store.add({ description = 'My task', category = 'Inbox' })
|
||||
local lines, _ = views.priority_view(store.active_tasks())
|
||||
s:add({ description = 'My task', category = 'Inbox' })
|
||||
local lines, _ = views.priority_view(s:active_tasks())
|
||||
assert.are.equal('/1/- [ ] My task', lines[1])
|
||||
end)
|
||||
|
||||
it('sets show_category=true for all task meta entries', function()
|
||||
store.add({ description = 'T1', category = 'Work' })
|
||||
store.add({ description = 'T2', category = 'Personal' })
|
||||
local _, meta = views.priority_view(store.active_tasks())
|
||||
s:add({ description = 'T1', category = 'Work' })
|
||||
s:add({ description = 'T2', category = 'Personal' })
|
||||
local _, meta = views.priority_view(s:active_tasks())
|
||||
for _, m in ipairs(meta) do
|
||||
if m.type == 'task' then
|
||||
assert.is_true(m.show_category == true)
|
||||
|
|
@ -343,9 +370,9 @@ describe('views', function()
|
|||
end)
|
||||
|
||||
it('sets meta.category correctly for each task', function()
|
||||
store.add({ description = 'Work task', category = 'Work' })
|
||||
store.add({ description = 'Home task', category = 'Home' })
|
||||
local lines, meta = views.priority_view(store.active_tasks())
|
||||
s:add({ description = 'Work task', category = 'Work' })
|
||||
s:add({ description = 'Home task', category = 'Home' })
|
||||
local lines, meta = views.priority_view(s:active_tasks())
|
||||
local categories = {}
|
||||
for i, m in ipairs(meta) do
|
||||
if m.type == 'task' then
|
||||
|
|
@ -362,8 +389,8 @@ describe('views', function()
|
|||
|
||||
it('marks overdue pending tasks with meta.overdue=true', function()
|
||||
local yesterday = os.date('%Y-%m-%d', os.time() - 86400)
|
||||
local t = store.add({ description = 'Overdue', category = 'Inbox', due = yesterday })
|
||||
local _, meta = views.priority_view(store.active_tasks())
|
||||
local t = s:add({ description = 'Overdue', category = 'Inbox', due = yesterday })
|
||||
local _, meta = views.priority_view(s:active_tasks())
|
||||
local task_meta
|
||||
for _, m in ipairs(meta) do
|
||||
if m.type == 'task' and m.id == t.id then
|
||||
|
|
@ -375,8 +402,8 @@ describe('views', function()
|
|||
|
||||
it('does not mark future pending tasks as overdue', function()
|
||||
local tomorrow = os.date('%Y-%m-%d', os.time() + 86400)
|
||||
local t = store.add({ description = 'Future', category = 'Inbox', due = tomorrow })
|
||||
local _, meta = views.priority_view(store.active_tasks())
|
||||
local t = s:add({ description = 'Future', category = 'Inbox', due = tomorrow })
|
||||
local _, meta = views.priority_view(s:active_tasks())
|
||||
local task_meta
|
||||
for _, m in ipairs(meta) do
|
||||
if m.type == 'task' and m.id == t.id then
|
||||
|
|
@ -388,9 +415,9 @@ describe('views', function()
|
|||
|
||||
it('does not mark done tasks with overdue due dates as overdue', function()
|
||||
local yesterday = os.date('%Y-%m-%d', os.time() - 86400)
|
||||
local t = store.add({ description = 'Done late', category = 'Inbox', due = yesterday })
|
||||
store.update(t.id, { status = 'done' })
|
||||
local _, meta = views.priority_view(store.active_tasks())
|
||||
local t = s:add({ description = 'Done late', category = 'Inbox', due = yesterday })
|
||||
s:update(t.id, { status = 'done' })
|
||||
local _, meta = views.priority_view(s:active_tasks())
|
||||
local task_meta
|
||||
for _, m in ipairs(meta) do
|
||||
if m.type == 'task' and m.id == t.id then
|
||||
|
|
@ -399,5 +426,107 @@ describe('views', function()
|
|||
end
|
||||
assert.is_falsy(task_meta.overdue)
|
||||
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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue