Compare commits

..

No commits in common. "87679e98571a8b125d7108201d436bcf7e97fb0a" and "e0e3af6787c948d879ed069c7e7e582bd72e591c" have entirely different histories.

15 changed files with 306 additions and 349 deletions

1
.gitignore vendored
View file

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

View file

@ -7,7 +7,7 @@ Edit tasks like text. `:w` saves them.
## Requirements ## Requirements
- Neovim 0.10+ - Neovim 0.10+
- (Optionally) `curl` for Google Calendar and Google Task sync - (Optionally) `curl` and `openssl` for Google Calendar and Google Task sync
## Installation ## Installation

View file

@ -73,7 +73,7 @@ REQUIREMENTS *pending-requirements*
- Neovim 0.10+ - Neovim 0.10+
- No external dependencies for local use - No external dependencies for local use
- `curl` is required for the `gcal` and `gtasks` sync backends - `curl` and `openssl` are required for the `gcal` and `gtasks` sync backends
============================================================================== ==============================================================================
INSTALL *pending-install* INSTALL *pending-install*
@ -149,17 +149,17 @@ COMMANDS *pending-commands*
Open the list with |:copen| to navigate to each task's category. Open the list with |:copen| to navigate to each task's category.
*:Pending-gtasks* *:Pending-gtasks*
:Pending gtasks {action} :Pending gtasks [{action}]
Run a Google Tasks action. An explicit action is required. Run a Google Tasks sync action. {action} defaults to `sync` when omitted.
Actions: ~ Actions: ~
`sync` Push local changes then pull remote changes. `sync` Push local changes then pull remote changes (default).
`push` Push local changes to Google Tasks only. `push` Push local changes to Google Tasks only.
`pull` Pull remote changes from Google Tasks only. `pull` Pull remote changes from Google Tasks only.
`auth` Run the OAuth authorization flow. `auth` Run the OAuth authorization flow.
Examples: >vim Examples: >vim
:Pending gtasks sync " push then pull :Pending gtasks " push then pull (default)
:Pending gtasks push " push local → Google Tasks :Pending gtasks push " push local → Google Tasks
:Pending gtasks pull " pull Google Tasks → local :Pending gtasks pull " pull Google Tasks → local
:Pending gtasks auth " authorize :Pending gtasks auth " authorize
@ -169,15 +169,16 @@ COMMANDS *pending-commands*
See |pending-gtasks| for full details. See |pending-gtasks| for full details.
*:Pending-gcal* *:Pending-gcal*
:Pending gcal {action} :Pending gcal [{action}]
Run a Google Calendar action. An explicit action is required. Run a Google Calendar sync action. {action} defaults to `sync` when
omitted.
Actions: ~ Actions: ~
`push` Push tasks with due dates to Google Calendar. `sync` Push tasks with due dates to Google Calendar (default).
`auth` Run the OAuth authorization flow. `auth` Run the OAuth authorization flow.
Examples: >vim Examples: >vim
:Pending gcal push " push to Google Calendar :Pending gcal " push to Google Calendar (default)
:Pending gcal auth " authorize :Pending gcal auth " authorize
< <
@ -605,7 +606,9 @@ loads: >lua
prev_task = '[t', prev_task = '[t',
}, },
sync = { sync = {
gcal = {}, gcal = {
calendar = 'Pendings',
},
gtasks = {}, gtasks = {},
}, },
} }
@ -890,8 +893,8 @@ SYNC BACKENDS *pending-sync-backend*
Sync backends are Lua modules under `lua/pending/sync/<name>.lua`. Each Sync backends are Lua modules under `lua/pending/sync/<name>.lua`. Each
backend is exposed as a top-level `:Pending` subcommand: >vim backend is exposed as a top-level `:Pending` subcommand: >vim
:Pending gtasks {action} :Pending gtasks [action]
:Pending gcal {action} :Pending gcal [action]
< <
Each module returns a table conforming to the backend interface: >lua Each module returns a table conforming to the backend interface: >lua
@ -899,9 +902,9 @@ Each module returns a table conforming to the backend interface: >lua
---@class pending.SyncBackend ---@class pending.SyncBackend
---@field name string ---@field name string
---@field auth fun(): nil ---@field auth fun(): nil
---@field sync fun(): nil
---@field push? fun(): nil ---@field push? fun(): nil
---@field pull? fun(): nil ---@field pull? fun(): nil
---@field sync? fun(): nil
---@field health? fun(): nil ---@field health? fun(): nil
< <
@ -921,15 +924,16 @@ Backend-specific configuration goes under `sync.<name>` in |pending-config|.
============================================================================== ==============================================================================
GOOGLE CALENDAR *pending-gcal* GOOGLE CALENDAR *pending-gcal*
pending.nvim can push tasks with due dates to Google Calendar as all-day pending.nvim can push tasks with due dates to a dedicated Google Calendar as
events. Each pending.nvim category maps to a Google Calendar of the same all-day events. This is a one-way push; changes made in Google Calendar are
name. Calendars are created automatically on first push. This is a one-way not pulled back into pending.nvim.
push; changes made in Google Calendar are not pulled back.
Configuration: >lua Configuration: >lua
vim.g.pending = { vim.g.pending = {
sync = { sync = {
gcal = {}, gcal = {
calendar = 'Pendings',
},
}, },
} }
< <
@ -939,6 +943,11 @@ used by default. Run `:Pending gcal auth` and the browser opens immediately.
*pending.GcalConfig* *pending.GcalConfig*
Fields: ~ Fields: ~
{calendar} (string, default: 'Pendings')
Name of the Google Calendar to sync to. If a calendar
with this name does not exist it is created
automatically on the first sync.
{client_id} (string, optional) {client_id} (string, optional)
OAuth client ID. When set together with OAuth client ID. When set together with
{client_secret}, these take priority over the {client_secret}, these take priority over the
@ -964,24 +973,26 @@ OAuth flow: ~
On the first `:Pending gcal` call the plugin detects that no refresh token On the first `:Pending gcal` call the plugin detects that no refresh token
exists and opens the Google authorization URL in the browser using exists and opens the Google authorization URL in the browser using
|vim.ui.open()|. A temporary local HTTP server listens on port 18392 for the |vim.ui.open()|. A temporary local HTTP server listens on port 18392 for the
OAuth redirect. The PKCE (Proof Key for Code Exchange) flow is used. After OAuth redirect. The PKCE (Proof Key for Code Exchange) flow is used
the user grants consent, the `openssl` generates the code challenge. After the user grants consent, the
authorization code is exchanged for tokens and the refresh token is stored at authorization code is exchanged for tokens and the refresh token is stored at
`stdpath('data')/pending/gcal_tokens.json` with mode `600`. Subsequent syncs `stdpath('data')/pending/gcal_tokens.json` with mode `600`. Subsequent syncs
use the stored refresh token and refresh the access token automatically when use the stored refresh token and refresh the access token automatically when
it is about to expire. it is about to expire.
`:Pending gcal push` behavior: ~ `:Pending gcal` behavior: ~
For each task in the store: For each task in the store:
- A pending task with a due date and no existing event: a new all-day event is - A pending task with a due date and no existing event: a new all-day event is
created in the calendar matching the task's category. The event ID and created and the event ID is stored in the task's `_extra` table.
calendar ID are stored in the task's `_extra` table.
- A pending task with a due date and an existing event: the event summary and - A pending task with a due date and an existing event: the event summary and
date are updated in place. date are updated in place.
- A done or deleted task with an existing event: the event is deleted. - A done or deleted task with an existing event: the event is deleted.
- A pending task with no due date that had an existing event: the event is - A pending task with no due date that had an existing event: the event is
deleted. deleted.
A summary notification is shown after sync: `created: N, updated: N,
deleted: N`.
============================================================================== ==============================================================================
GOOGLE TASKS *pending-gtasks* GOOGLE TASKS *pending-gtasks*

View file

@ -7,6 +7,7 @@
---@field category string ---@field category string
---@class pending.GcalConfig ---@class pending.GcalConfig
---@field calendar? string
---@field credentials_path? string ---@field credentials_path? string
---@field client_id? string ---@field client_id? string
---@field client_secret? string ---@field client_secret? string

View file

@ -121,19 +121,17 @@ function M.apply(lines, s, hidden_ids)
task.priority = entry.priority task.priority = entry.priority
changed = true changed = true
end end
if entry.due ~= nil and task.due ~= entry.due then if task.due ~= entry.due then
task.due = entry.due task.due = entry.due
changed = true changed = true
end end
if entry.rec ~= nil then if task.recur ~= entry.rec then
if task.recur ~= entry.rec then task.recur = entry.rec
task.recur = entry.rec changed = true
changed = true end
end if task.recur_mode ~= entry.rec_mode then
if task.recur_mode ~= entry.rec_mode then task.recur_mode = entry.rec_mode
task.recur_mode = entry.rec_mode changed = true
changed = true
end
end end
if entry.status and task.status ~= entry.status then if entry.status and task.status ~= entry.status then
task.status = entry.status task.status = entry.status

View file

@ -25,6 +25,13 @@ function M.check()
vim.health.info('(project-local store; global path: ' .. cfg.data_path .. ')') vim.health.info('(project-local store; global path: ' .. cfg.data_path .. ')')
end end
local data_dir = vim.fn.fnamemodify(resolved_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)
end
if vim.fn.filereadable(resolved_path) == 1 then if vim.fn.filereadable(resolved_path) == 1 then
local s = store.new(resolved_path) local s = store.new(resolved_path)
local load_ok, err = pcall(function() local load_ok, err = pcall(function()
@ -47,6 +54,8 @@ function M.check()
else else
vim.health.error('Failed to load data file: ' .. tostring(err)) vim.health.error('Failed to load data file: ' .. tostring(err))
end end
else
vim.health.info('No data file yet (will be created on first save)')
end end
local sync_paths = vim.fn.globpath(vim.o.runtimepath, 'lua/pending/sync/*.lua', false, true) local sync_paths = vim.fn.globpath(vim.o.runtimepath, 'lua/pending/sync/*.lua', false, true)

View file

@ -142,16 +142,6 @@ local function compute_hidden_ids(tasks, predicates)
visible = false visible = false
break break
end end
elseif pred == 'done' then
if task.status ~= 'done' then
visible = false
break
end
elseif pred == 'pending' then
if task.status ~= 'pending' then
visible = false
break
end
end end
end end
if not visible then if not visible then
@ -546,22 +536,12 @@ end
---@param action? string ---@param action? string
---@return nil ---@return nil
local function run_sync(backend_name, action) local function run_sync(backend_name, action)
action = (action and action ~= '') and action or 'sync'
local ok, backend = pcall(require, 'pending.sync.' .. backend_name) local ok, backend = pcall(require, 'pending.sync.' .. backend_name)
if not ok then if not ok then
vim.notify('Unknown sync backend: ' .. backend_name, vim.log.levels.ERROR) vim.notify('Unknown sync backend: ' .. backend_name, vim.log.levels.ERROR)
return return
end end
if not action or action == '' then
local actions = {}
for k, v in pairs(backend) do
if type(v) == 'function' and k:sub(1, 1) ~= '_' then
table.insert(actions, k)
end
end
table.sort(actions)
vim.notify(backend_name .. ' actions: ' .. table.concat(actions, ', '), vim.log.levels.INFO)
return
end
if type(backend[action]) ~= 'function' then if type(backend[action]) ~= 'function' then
vim.notify(backend_name .. " backend has no '" .. action .. "' action", vim.log.levels.ERROR) vim.notify(backend_name .. " backend has no '" .. action .. "' action", vim.log.levels.ERROR)
return return
@ -824,7 +804,7 @@ function M.edit(id_str, rest)
s:update(id, updates) s:update(id, updates)
_save_and_notify() s:save()
local bufnr = buffer.bufnr() local bufnr = buffer.bufnr()
if bufnr and vim.api.nvim_buf_is_valid(bufnr) then if bufnr and vim.api.nvim_buf_is_valid(bufnr) then
@ -861,7 +841,7 @@ function M.command(args)
local id_str, edit_rest = rest:match('^(%S+)%s*(.*)') local id_str, edit_rest = rest:match('^(%S+)%s*(.*)')
M.edit(id_str, edit_rest) M.edit(id_str, edit_rest)
elseif SYNC_BACKEND_SET[cmd] then elseif SYNC_BACKEND_SET[cmd] then
local action = rest:match('^(%S+)') local action = rest:match('^(%S+)') or 'sync'
run_sync(cmd, action) run_sync(cmd, action)
elseif cmd == 'archive' then elseif cmd == 'archive' then
local d = rest ~= '' and tonumber(rest) or nil local d = rest ~= '' and tonumber(rest) or nil

View file

@ -15,10 +15,13 @@ local client = oauth.new({
config_key = 'gcal', config_key = 'gcal',
}) })
---@param access_token string ---@return string? calendar_id
---@return table<string, string>? name_to_id
---@return string? err ---@return string? err
local function get_all_calendars(access_token) local function find_or_create_calendar(access_token)
local cfg = config.get()
local gc = (cfg.sync and cfg.sync.gcal) or {}
local cal_name = gc.calendar or 'Pendings'
local data, err = oauth.curl_request( local data, err = oauth.curl_request(
'GET', 'GET',
BASE_URL .. '/users/me/calendarList', BASE_URL .. '/users/me/calendarList',
@ -27,41 +30,27 @@ local function get_all_calendars(access_token)
if err then if err then
return nil, err return nil, err
end end
local result = {}
for _, item in ipairs(data and data.items or {}) do for _, item in ipairs(data and data.items or {}) do
if item.summary then if item.summary == cal_name then
result[item.summary] = item.id return item.id, nil
end end
end end
return result, nil
end
---@param access_token string local body = vim.json.encode({ summary = cal_name })
---@param name string local created, create_err =
---@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
local body = vim.json.encode({ summary = name })
local created, err =
oauth.curl_request('POST', BASE_URL .. '/calendars', oauth.auth_headers(access_token), body) oauth.curl_request('POST', BASE_URL .. '/calendars', oauth.auth_headers(access_token), body)
if err then if create_err then
return nil, err return nil, create_err
end end
local id = created and created.id
if id then return created and created.id, nil
existing[name] = id
end
return id, nil
end end
---@param date_str string ---@param date_str string
---@return string ---@return string
local function next_day(date_str) local function next_day(date_str)
local y, m, d = date_str:match('^(%d%d%d%d)-(%d%d)-(%d%d)') local y, m, d = date_str:match('^(%d%d%d%d)-(%d%d)-(%d%d)$')
local t = os.time({ year = tonumber(y) or 0, month = tonumber(m) or 0, day = tonumber(d) or 0 }) local t = os.time({ year = tonumber(y) or 0, month = tonumber(m) or 0, day = tonumber(d) or 0 })
+ 86400 + 86400
return os.date('%Y-%m-%d', t) --[[@as string]] return os.date('%Y-%m-%d', t) --[[@as string]]
@ -139,100 +128,74 @@ function M.auth()
client:auth() client:auth()
end end
function M.push() function M.sync()
oauth.async(function() local access_token = client:get_access_token()
local access_token = client:get_access_token() if not access_token then
if not access_token then return
return end
end
local calendars, cal_err = get_all_calendars(access_token) local calendar_id, err = find_or_create_calendar(access_token)
if cal_err or not calendars then if err or not calendar_id then
vim.notify('pending.nvim: ' .. (cal_err or 'failed to fetch calendars'), vim.log.levels.ERROR) vim.notify('pending.nvim: ' .. (err or 'calendar not found'), vim.log.levels.ERROR)
return return
end end
local s = require('pending').store() local tasks = require('pending').store():tasks()
local created, updated, deleted = 0, 0, 0 local created, updated, deleted = 0, 0, 0
for _, task in ipairs(s:tasks()) do for _, task in ipairs(tasks) do
local extra = task._extra or {} local extra = task._extra or {}
local event_id = extra['_gcal_event_id'] --[[@as string?]] local event_id = extra['_gcal_event_id'] --[[@as string?]]
local cal_id = extra['_gcal_calendar_id'] --[[@as string?]]
local should_delete = event_id ~= nil local should_delete = event_id ~= nil
and cal_id ~= nil and (
and ( task.status == 'done'
task.status == 'done' or task.status == 'deleted'
or task.status == 'deleted' or (task.status == 'pending' and not task.due)
or (task.status == 'pending' and not task.due) )
)
if should_delete then if should_delete and event_id then
local del_err = local del_err = delete_event(access_token, calendar_id, event_id) --[[@as string]]
delete_event(access_token, cal_id --[[@as string]], event_id --[[@as string]]) if not del_err then
if del_err then extra['_gcal_event_id'] = nil
vim.notify('pending.nvim: gcal delete failed: ' .. del_err, vim.log.levels.WARN) if next(extra) == nil then
task._extra = nil
else else
extra['_gcal_event_id'] = nil task._extra = extra
extra['_gcal_calendar_id'] = nil
if next(extra) == nil then
task._extra = nil
else
task._extra = extra
end
task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]]
deleted = deleted + 1
end end
elseif task.status == 'pending' and task.due then task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]]
local cat = task.category or config.get().default_category deleted = deleted + 1
if event_id and cal_id then end
local upd_err = update_event(access_token, cal_id, event_id, task) elseif task.status == 'pending' and task.due then
if upd_err then if event_id then
vim.notify('pending.nvim: gcal update failed: ' .. upd_err, vim.log.levels.WARN) local upd_err = update_event(access_token, calendar_id, event_id, task)
else if not upd_err then
updated = updated + 1 updated = updated + 1
end end
else else
local lid, lid_err = find_or_create_calendar(access_token, cat, calendars) local new_id, create_err = create_event(access_token, calendar_id, task)
if lid_err or not lid then if not create_err and new_id then
vim.notify( if not task._extra then
'pending.nvim: gcal calendar failed: ' .. (lid_err or 'unknown'), task._extra = {}
vim.log.levels.WARN
)
else
local new_id, create_err = create_event(access_token, lid, task)
if create_err then
vim.notify('pending.nvim: gcal create failed: ' .. create_err, vim.log.levels.WARN)
elseif new_id then
if not task._extra then
task._extra = {}
end
task._extra['_gcal_event_id'] = new_id
task._extra['_gcal_calendar_id'] = lid
task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]]
created = created + 1
end
end end
task._extra['_gcal_event_id'] = new_id
task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]]
created = created + 1
end end
end end
end end
end
s:save() require('pending').store():save()
require('pending')._recompute_counts() require('pending')._recompute_counts()
local buffer = require('pending.buffer') vim.notify(
if buffer.bufnr() and vim.api.nvim_buf_is_valid(buffer.bufnr()) then string.format(
buffer.render(buffer.bufnr()) 'pending.nvim: Synced to Google Calendar (created: %d, updated: %d, deleted: %d)',
end created,
vim.notify( updated,
string.format( deleted
'pending.nvim: Google Calendar pushed — +%d ~%d -%d',
created,
updated,
deleted
)
) )
end) )
end end
---@return nil ---@return nil

View file

@ -247,9 +247,7 @@ local function push_pass(access_token, tasklists, s, now_ts, by_gtasks_id)
if task.status == 'deleted' and gtid and list_id then if task.status == 'deleted' and gtid and list_id then
local err = delete_gtask(access_token, list_id, gtid) local err = delete_gtask(access_token, list_id, gtid)
if err then if not err then
vim.notify('pending.nvim: gtasks delete failed: ' .. err, vim.log.levels.WARN)
else
if not task._extra then if not task._extra then
task._extra = {} task._extra = {}
end end
@ -264,9 +262,7 @@ local function push_pass(access_token, tasklists, s, now_ts, by_gtasks_id)
elseif task.status ~= 'deleted' then elseif task.status ~= 'deleted' then
if gtid and list_id then if gtid and list_id then
local err = update_gtask(access_token, list_id, gtid, task_to_gtask(task)) local err = update_gtask(access_token, list_id, gtid, task_to_gtask(task))
if err then if not err then
vim.notify('pending.nvim: gtasks update failed: ' .. err, vim.log.levels.WARN)
else
updated = updated + 1 updated = updated + 1
end end
elseif task.status == 'pending' then elseif task.status == 'pending' then
@ -274,9 +270,7 @@ local function push_pass(access_token, tasklists, s, now_ts, by_gtasks_id)
local lid, err = find_or_create_tasklist(access_token, cat, tasklists) local lid, err = find_or_create_tasklist(access_token, cat, tasklists)
if not err and lid then if not err and lid then
local new_id, create_err = create_gtask(access_token, lid, task_to_gtask(task)) local new_id, create_err = create_gtask(access_token, lid, task_to_gtask(task))
if create_err then if not create_err and new_id then
vim.notify('pending.nvim: gtasks create failed: ' .. create_err, vim.log.levels.WARN)
elseif new_id then
if not task._extra then if not task._extra then
task._extra = {} task._extra = {}
end end
@ -363,79 +357,61 @@ function M.auth()
end end
function M.push() function M.push()
oauth.async(function() local access_token, tasklists, s, now_ts = sync_setup()
local access_token, tasklists, s, now_ts = sync_setup() if not access_token then
if not access_token then return
return end
end ---@cast tasklists table<string, string>
---@cast tasklists table<string, string> ---@cast s pending.Store
---@cast s pending.Store ---@cast now_ts string
---@cast now_ts string local by_gtasks_id = build_id_index(s)
local by_gtasks_id = build_id_index(s) local created, updated, deleted = push_pass(access_token, tasklists, s, now_ts, by_gtasks_id)
local created, updated, deleted = push_pass(access_token, tasklists, s, now_ts, by_gtasks_id) s:save()
s:save() require('pending')._recompute_counts()
require('pending')._recompute_counts() vim.notify(
local buffer = require('pending.buffer') string.format('pending.nvim: Google Tasks pushed — +%d ~%d -%d', created, updated, deleted)
if buffer.bufnr() and vim.api.nvim_buf_is_valid(buffer.bufnr()) then )
buffer.render(buffer.bufnr())
end
vim.notify(
string.format('pending.nvim: Google Tasks pushed — +%d ~%d -%d', created, updated, deleted)
)
end)
end end
function M.pull() function M.pull()
oauth.async(function() local access_token, tasklists, s, now_ts = sync_setup()
local access_token, tasklists, s, now_ts = sync_setup() if not access_token then
if not access_token then return
return end
end ---@cast tasklists table<string, string>
---@cast tasklists table<string, string> ---@cast s pending.Store
---@cast s pending.Store ---@cast now_ts string
---@cast now_ts string local by_gtasks_id = build_id_index(s)
local by_gtasks_id = build_id_index(s) local created, updated = pull_pass(access_token, tasklists, s, now_ts, by_gtasks_id)
local created, updated = pull_pass(access_token, tasklists, s, now_ts, by_gtasks_id) s:save()
s:save() require('pending')._recompute_counts()
require('pending')._recompute_counts() vim.notify(string.format('pending.nvim: Google Tasks pulled — +%d ~%d', created, updated))
local buffer = require('pending.buffer')
if buffer.bufnr() and vim.api.nvim_buf_is_valid(buffer.bufnr()) then
buffer.render(buffer.bufnr())
end
vim.notify(string.format('pending.nvim: Google Tasks pulled — +%d ~%d', created, updated))
end)
end end
function M.sync() function M.sync()
oauth.async(function() local access_token, tasklists, s, now_ts = sync_setup()
local access_token, tasklists, s, now_ts = sync_setup() if not access_token then
if not access_token then return
return end
end ---@cast tasklists table<string, string>
---@cast tasklists table<string, string> ---@cast s pending.Store
---@cast s pending.Store ---@cast now_ts string
---@cast now_ts string local by_gtasks_id = build_id_index(s)
local by_gtasks_id = build_id_index(s) local pushed_create, pushed_update, pushed_delete =
local pushed_create, pushed_update, pushed_delete = push_pass(access_token, tasklists, s, now_ts, by_gtasks_id)
push_pass(access_token, tasklists, s, now_ts, by_gtasks_id) local pulled_create, pulled_update = pull_pass(access_token, tasklists, s, now_ts, by_gtasks_id)
local pulled_create, pulled_update = pull_pass(access_token, tasklists, s, now_ts, by_gtasks_id) s:save()
s:save() require('pending')._recompute_counts()
require('pending')._recompute_counts() vim.notify(
local buffer = require('pending.buffer') string.format(
if buffer.bufnr() and vim.api.nvim_buf_is_valid(buffer.bufnr()) then 'pending.nvim: Google Tasks synced — push: +%d ~%d -%d, pull: +%d ~%d',
buffer.render(buffer.bufnr()) pushed_create,
end pushed_update,
vim.notify( pushed_delete,
string.format( pulled_create,
'pending.nvim: Google Tasks synced — push: +%d ~%d -%d, pull: +%d ~%d', pulled_update
pushed_create,
pushed_update,
pushed_delete,
pulled_create,
pulled_update
)
) )
end) )
end end
M._due_to_rfc3339 = due_to_rfc3339 M._due_to_rfc3339 = due_to_rfc3339

View file

@ -27,27 +27,6 @@ OAuthClient.__index = OAuthClient
---@class pending.oauth ---@class pending.oauth
local M = {} local M = {}
---@param args string[]
---@param opts? table
---@return { code: integer, stdout: string, stderr: string }
function M.system(args, opts)
local co = coroutine.running()
if not co then
return vim.system(args, opts or {}):wait() --[[@as { code: integer, stdout: string, stderr: string }]]
end
vim.system(args, opts or {}, function(result)
vim.schedule(function()
coroutine.resume(co, result)
end)
end)
return coroutine.yield() --[[@as { code: integer, stdout: string, stderr: string }]]
end
---@param fn fun(): nil
function M.async(fn)
coroutine.resume(coroutine.create(fn))
end
---@param str string ---@param str string
---@return string ---@return string
function M.url_encode(str) function M.url_encode(str)
@ -112,7 +91,7 @@ function M.curl_request(method, url, headers, body)
table.insert(args, body) table.insert(args, body)
end end
table.insert(args, url) table.insert(args, url)
local result = M.system(args, { text = true }) local result = vim.system(args, { text = true }):wait()
if result.code ~= 0 then if result.code ~= 0 then
return nil, 'curl failed: ' .. (result.stderr or '') return nil, 'curl failed: ' .. (result.stderr or '')
end end
@ -146,6 +125,11 @@ function M.health(backend_name)
else else
vim.health.warn('curl not found (needed for ' .. backend_name .. ' sync)') vim.health.warn('curl not found (needed for ' .. backend_name .. ' sync)')
end end
if vim.fn.executable('openssl') == 1 then
vim.health.ok('openssl found (required for ' .. backend_name .. ' OAuth PKCE)')
else
vim.health.warn('openssl not found (needed for ' .. backend_name .. ' OAuth)')
end
end end
---@return string ---@return string
@ -205,17 +189,19 @@ function OAuthClient:refresh_access_token(creds, tokens)
.. '&grant_type=refresh_token' .. '&grant_type=refresh_token'
.. '&refresh_token=' .. '&refresh_token='
.. M.url_encode(tokens.refresh_token) .. M.url_encode(tokens.refresh_token)
local result = M.system({ local result = vim
'curl', .system({
'-s', 'curl',
'-X', '-s',
'POST', '-X',
'-H', 'POST',
'Content-Type: application/x-www-form-urlencoded', '-H',
'-d', 'Content-Type: application/x-www-form-urlencoded',
body, '-d',
TOKEN_URL, body,
}, { text = true }) TOKEN_URL,
}, { text = true })
:wait()
if result.code ~= 0 then if result.code ~= 0 then
return nil return nil
end end
@ -261,18 +247,23 @@ function OAuthClient:auth()
local verifier_chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~' local verifier_chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'
local verifier = {} local verifier = {}
math.randomseed(vim.uv.hrtime()) math.randomseed(os.time())
for _ = 1, 64 do for _ = 1, 64 do
local idx = math.random(1, #verifier_chars) local idx = math.random(1, #verifier_chars)
table.insert(verifier, verifier_chars:sub(idx, idx)) table.insert(verifier, verifier_chars:sub(idx, idx))
end end
local code_verifier = table.concat(verifier) local code_verifier = table.concat(verifier)
local hex = vim.fn.sha256(code_verifier) local sha_pipe = vim
local binary = hex:gsub('..', function(h) .system({
return string.char(tonumber(h, 16)) 'sh',
end) '-c',
local code_challenge = vim.base64.encode(binary):gsub('+', '-'):gsub('/', '_'):gsub('=', '') '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 local auth_url = AUTH_URL
.. '?client_id=' .. '?client_id='
@ -292,15 +283,6 @@ function OAuthClient:auth()
vim.notify('pending.nvim: Opening browser for Google authorization...') vim.notify('pending.nvim: Opening browser for Google authorization...')
local server = vim.uv.new_tcp() local server = vim.uv.new_tcp()
local server_closed = false
local function close_server()
if server_closed then
return
end
server_closed = true
server:close()
end
server:bind('127.0.0.1', port) server:bind('127.0.0.1', port)
server:listen(1, function(err) server:listen(1, function(err)
if err then if err then
@ -310,8 +292,6 @@ function OAuthClient:auth()
server:accept(conn) server:accept(conn)
conn:read_start(function(read_err, data) conn:read_start(function(read_err, data)
if read_err or not data then if read_err or not data then
conn:close()
close_server()
return return
end end
local code = data:match('[?&]code=([^&%s]+)') local code = data:match('[?&]code=([^&%s]+)')
@ -325,7 +305,7 @@ function OAuthClient:auth()
conn:close() conn:close()
end) end)
end) end)
close_server() server:close()
if code then if code then
vim.schedule(function() vim.schedule(function()
self:_exchange_code(creds, code, code_verifier, port) self:_exchange_code(creds, code, code_verifier, port)
@ -333,13 +313,6 @@ function OAuthClient:auth()
end end
end) end)
end) end)
vim.defer_fn(function()
if not server_closed then
close_server()
vim.notify('pending.nvim: OAuth callback timed out (120s).', vim.log.levels.WARN)
end
end, 120000)
end end
---@param creds pending.OAuthCredentials ---@param creds pending.OAuthCredentials
@ -360,17 +333,19 @@ function OAuthClient:_exchange_code(creds, code, code_verifier, port)
.. '&redirect_uri=' .. '&redirect_uri='
.. M.url_encode('http://127.0.0.1:' .. port) .. M.url_encode('http://127.0.0.1:' .. port)
local result = M.system({ local result = vim
'curl', .system({
'-s', 'curl',
'-X', '-s',
'POST', '-X',
'-H', 'POST',
'Content-Type: application/x-www-form-urlencoded', '-H',
'-d', 'Content-Type: application/x-www-form-urlencoded',
body, '-d',
TOKEN_URL, body,
}, { text = true }) TOKEN_URL,
}, { text = true })
:wait()
if result.code ~= 0 then if result.code ~= 0 then
vim.notify('pending.nvim: Token exchange failed.', vim.log.levels.ERROR) vim.notify('pending.nvim: Token exchange failed.', vim.log.levels.ERROR)

View file

@ -181,7 +181,7 @@ end, {
for word in after_filter:gmatch('%S+') do for word in after_filter:gmatch('%S+') do
used[word] = true used[word] = true
end end
local candidates = { 'clear', 'overdue', 'today', 'priority', 'done', 'pending' } local candidates = { 'clear', 'overdue', 'today', 'priority' }
local store = require('pending.store') local store = require('pending.store')
local s = store.new(store.resolve_path()) local s = store.new(store.resolve_path())
s:load() s:load()

30
scripts/demo-init.lua Normal file
View file

@ -0,0 +1,30 @@
vim.opt.runtimepath:prepend(vim.fn.getcwd())
local tmpdir = vim.fn.tempname()
vim.fn.mkdir(tmpdir, 'p')
vim.g.pending = {
data_path = tmpdir .. '/tasks.json',
}
local store = require('pending.store')
store.load()
local today = os.date('%Y-%m-%d')
local yesterday = os.date('%Y-%m-%d', os.time() - 86400)
local tomorrow = os.date('%Y-%m-%d', os.time() + 86400)
store.add({
description = 'Finish quarterly report',
category = 'Work',
due = tomorrow,
recur = 'monthly',
priority = 1,
})
store.add({ description = 'Review pull requests', category = 'Work' })
store.add({ description = 'Update deployment docs', category = 'Work', status = 'done' })
store.add({ description = 'Buy groceries', category = 'Personal', due = today })
store.add({ description = 'Call dentist', category = 'Personal', due = yesterday, priority = 1 })
store.add({ description = 'Read chapter 5', category = 'Personal' })
store.add({ description = 'Learn a new language', category = 'Someday' })
store.add({ description = 'Plan hiking trip', category = 'Someday' })
store.save()

28
scripts/demo.tape Normal file
View file

@ -0,0 +1,28 @@
Output assets/demo.gif
Require nvim
Set Shell "bash"
Set FontSize 14
Set Width 900
Set Height 450
Type "nvim -u scripts/demo-init.lua -c 'autocmd VimEnter * Pending'"
Enter
Sleep 2s
Down
Down
Sleep 300ms
Down
Sleep 300ms
Enter
Sleep 500ms
Tab
Sleep 1s
Type "q"
Sleep 200ms

View file

@ -199,7 +199,7 @@ describe('diff', function()
assert.are.equal(modified_after_first, task.modified) assert.are.equal(modified_after_first, task.modified)
end) end)
it('preserves due when not present in buffer line', function() it('clears due when removed from buffer line', function()
s:add({ description = 'Pay bill', due = '2026-03-15' }) s:add({ description = 'Pay bill', due = '2026-03-15' })
s:save() s:save()
local lines = { local lines = {
@ -209,20 +209,7 @@ describe('diff', function()
diff.apply(lines, s) diff.apply(lines, s)
s:load() s:load()
local task = s:get(1) local task = s:get(1)
assert.are.equal('2026-03-15', task.due) assert.is_nil(task.due)
end)
it('updates due when inline token is present', function()
s:add({ description = 'Pay bill', due = '2026-03-15' })
s:save()
local lines = {
'# Inbox',
'/1/- [ ] Pay bill due:2026-04-01',
}
diff.apply(lines, s)
s:load()
local task = s:get(1)
assert.are.equal('2026-04-01', task.due)
end) end)
it('stores recur field on new tasks from buffer', function() it('stores recur field on new tasks from buffer', function()
@ -250,7 +237,7 @@ describe('diff', function()
assert.are.equal('weekly', task.recur) assert.are.equal('weekly', task.recur)
end) end)
it('preserves recur when not present in buffer line', function() it('clears recur when token removed from line', function()
s:add({ description = 'Task', recur = 'daily' }) s:add({ description = 'Task', recur = 'daily' })
s:save() s:save()
local lines = { local lines = {
@ -260,7 +247,7 @@ describe('diff', function()
diff.apply(lines, s) diff.apply(lines, s)
s:load() s:load()
local task = s:get(1) local task = s:get(1)
assert.are.equal('daily', task.recur) assert.is_nil(task.recur)
end) end)
it('parses rec: with completion mode prefix', function() it('parses rec: with completion mode prefix', function()

View file

@ -49,27 +49,27 @@ describe('sync', function()
assert.are.equal("gcal backend has no 'notreal' action", msg) assert.are.equal("gcal backend has no 'notreal' action", msg)
end) end)
it('lists actions when action is omitted', function() it('defaults to sync action 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 called = false
local gcal = require('pending.sync.gcal') local gcal = require('pending.sync.gcal')
local orig_push = gcal.push local orig_sync = gcal.sync
gcal.push = function() gcal.sync = function()
called = true called = true
end end
pending.command('gcal push') pending.command('gcal')
gcal.push = orig_push gcal.sync = orig_sync
assert.is_true(called)
end)
it('routes explicit sync action', function()
local called = false
local gcal = require('pending.sync.gcal')
local orig_sync = gcal.sync
gcal.sync = function()
called = true
end
pending.command('gcal sync')
gcal.sync = orig_sync
assert.is_true(called) assert.is_true(called)
end) end)
@ -90,10 +90,10 @@ describe('sync', function()
config.reset() config.reset()
vim.g.pending = { vim.g.pending = {
data_path = tmpdir .. '/tasks.json', data_path = tmpdir .. '/tasks.json',
sync = { gcal = { client_id = 'test-id' } }, sync = { gcal = { calendar = 'NewStyle' } },
} }
local cfg = config.get() local cfg = config.get()
assert.are.equal('test-id', cfg.sync.gcal.client_id) assert.are.equal('NewStyle', cfg.sync.gcal.calendar)
end) end)
describe('gcal module', function() describe('gcal module', function()
@ -107,9 +107,9 @@ describe('sync', function()
assert.are.equal('function', type(gcal.auth)) assert.are.equal('function', type(gcal.auth))
end) end)
it('has push function', function() it('has sync function', function()
local gcal = require('pending.sync.gcal') local gcal = require('pending.sync.gcal')
assert.are.equal('function', type(gcal.push)) assert.are.equal('function', type(gcal.sync))
end) end)
it('has health function', function() it('has health function', function()