* fix(diff): preserve due/rec when absent from buffer line Problem: `diff.apply` overwrites `task.due` and `task.recur` with `nil` whenever those fields aren't present as inline tokens in the buffer line. Because metadata is rendered as virtual text (never in the line text), every description edit silently clears due dates and recurrence rules. Solution: Only update `due`, `recur`, and `recur_mode` in the existing- task branch when the parsed entry actually contains them (non-nil). Users can still set/change these inline by typing `due:<date>` or `rec:<rule>`; clearing them requires `:Pending edit <id> -due`. * refactor: remove project-local store discovery Problem: `store.resolve_path()` searched upward for `.pending.json`, silently splitting task data across multiple files depending on CWD. Solution: `resolve_path()` now always returns `config.get().data_path`. Remove `M.init()` and the `:Pending init` command and tab-completion entry. Remove the project-local health message. * refactor: extract log.lua, standardise [pending.nvim]: prefix Problem: Notifications were scattered across files using bare `vim.notify` with inconsistent `pending.nvim: ` prefixes, and the `debug` guard in `textobj.lua` and `init.lua` was duplicated inline. Solution: Add `lua/pending/log.lua` with `info`, `warn`, `error`, and `debug` functions (prefix `[pending.nvim]: `). `log.debug` only fires when `config.debug = true` or the optional `override` param is `true`. Replace all `vim.notify` callsites and remove inline debug guards. * feat(parse): configurable input date formats Problem: `due:` only accepted ISO `YYYY-MM-DD` and built-in keywords; users expecting locale-style dates like `03/15/2026` or `15-Mar-2026` had no way to configure alternative input formats. Solution: Add `input_date_formats` config field (string[]). Each entry is a strftime-like format string supporting `%Y`, `%y`, `%m`, `%d`, `%e`, `%b`, `%B`. Formats are tried in order after built-in keywords fail. When no year specifier is present the current or next year is inferred. Update vimdoc and add 8 parse_spec tests.
406 lines
10 KiB
Lua
406 lines
10 KiB
Lua
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.OAuthClient
|
|
---@field name string
|
|
---@field scope string
|
|
---@field port integer
|
|
---@field config_key string
|
|
local OAuthClient = {}
|
|
OAuthClient.__index = OAuthClient
|
|
|
|
---@class pending.oauth
|
|
local M = {}
|
|
|
|
---@param args string[]
|
|
---@param opts? table
|
|
---@return { code: integer, stdout: string, stderr: string }
|
|
function M.system(args, opts)
|
|
local co = coroutine.running()
|
|
if not co then
|
|
return vim.system(args, opts or {}):wait() --[[@as { code: integer, stdout: string, stderr: string }]]
|
|
end
|
|
vim.system(args, opts or {}, function(result)
|
|
vim.schedule(function()
|
|
coroutine.resume(co, result)
|
|
end)
|
|
end)
|
|
return coroutine.yield() --[[@as { code: integer, stdout: string, stderr: string }]]
|
|
end
|
|
|
|
---@param fn fun(): nil
|
|
function M.async(fn)
|
|
coroutine.resume(coroutine.create(fn))
|
|
end
|
|
|
|
---@param str string
|
|
---@return string
|
|
function M.url_encode(str)
|
|
return (
|
|
str:gsub('([^%w%-%.%_%~])', function(c)
|
|
return string.format('%%%02X', string.byte(c))
|
|
end)
|
|
)
|
|
end
|
|
|
|
---@param path string
|
|
---@return table?
|
|
function M.load_json_file(path)
|
|
local f = io.open(path, 'r')
|
|
if not f then
|
|
return nil
|
|
end
|
|
local content = f:read('*a')
|
|
f:close()
|
|
if content == '' then
|
|
return nil
|
|
end
|
|
local ok, decoded = pcall(vim.json.decode, content)
|
|
if not ok then
|
|
return nil
|
|
end
|
|
return decoded
|
|
end
|
|
|
|
---@param path string
|
|
---@param data table
|
|
---@return boolean
|
|
function M.save_json_file(path, data)
|
|
local dir = vim.fn.fnamemodify(path, ':h')
|
|
if vim.fn.isdirectory(dir) == 0 then
|
|
vim.fn.mkdir(dir, 'p')
|
|
end
|
|
local f = io.open(path, 'w')
|
|
if not f then
|
|
return false
|
|
end
|
|
f:write(vim.json.encode(data))
|
|
f:close()
|
|
vim.fn.setfperm(path, 'rw-------')
|
|
return true
|
|
end
|
|
|
|
---@param method string
|
|
---@param url string
|
|
---@param headers? string[]
|
|
---@param body? string
|
|
---@return table? result
|
|
---@return string? err
|
|
function M.curl_request(method, url, headers, body)
|
|
local args = { 'curl', '-s', '-X', method }
|
|
for _, h in ipairs(headers or {}) do
|
|
table.insert(args, '-H')
|
|
table.insert(args, h)
|
|
end
|
|
if body then
|
|
table.insert(args, '-d')
|
|
table.insert(args, body)
|
|
end
|
|
table.insert(args, url)
|
|
local result = M.system(args, { text = true })
|
|
if result.code ~= 0 then
|
|
return nil, 'curl failed: ' .. (result.stderr or '')
|
|
end
|
|
if not result.stdout or result.stdout == '' then
|
|
return {}, nil
|
|
end
|
|
local ok, decoded = pcall(vim.json.decode, result.stdout)
|
|
if not ok then
|
|
return nil, 'failed to parse response: ' .. result.stdout
|
|
end
|
|
if decoded.error then
|
|
return nil, 'API error: ' .. (decoded.error.message or vim.json.encode(decoded.error))
|
|
end
|
|
return decoded, nil
|
|
end
|
|
|
|
---@param access_token string
|
|
---@return string[]
|
|
function M.auth_headers(access_token)
|
|
return {
|
|
'Authorization: Bearer ' .. access_token,
|
|
'Content-Type: application/json',
|
|
}
|
|
end
|
|
|
|
---@param backend_name string
|
|
---@return nil
|
|
function M.health(backend_name)
|
|
if vim.fn.executable('curl') == 1 then
|
|
vim.health.ok('curl found (required for ' .. backend_name .. ' sync)')
|
|
else
|
|
vim.health.warn('curl not found (needed for ' .. backend_name .. ' sync)')
|
|
end
|
|
end
|
|
|
|
---@return string
|
|
function OAuthClient:token_path()
|
|
return vim.fn.stdpath('data') .. '/pending/' .. self.name .. '_tokens.json'
|
|
end
|
|
|
|
---@return pending.OAuthCredentials
|
|
function OAuthClient:resolve_credentials()
|
|
local cfg = config.get()
|
|
local backend_cfg = (cfg.sync and cfg.sync[self.config_key]) or {}
|
|
|
|
if backend_cfg.client_id and backend_cfg.client_secret then
|
|
return {
|
|
client_id = backend_cfg.client_id,
|
|
client_secret = backend_cfg.client_secret,
|
|
}
|
|
end
|
|
|
|
local cred_path = backend_cfg.credentials_path
|
|
or (vim.fn.stdpath('data') .. '/pending/' .. self.name .. '_credentials.json')
|
|
local creds = M.load_json_file(cred_path)
|
|
if creds then
|
|
if creds.installed then
|
|
creds = creds.installed
|
|
end
|
|
if creds.client_id and creds.client_secret then
|
|
return creds --[[@as pending.OAuthCredentials]]
|
|
end
|
|
end
|
|
|
|
return {
|
|
client_id = BUNDLED_CLIENT_ID,
|
|
client_secret = BUNDLED_CLIENT_SECRET,
|
|
}
|
|
end
|
|
|
|
---@return pending.OAuthTokens?
|
|
function OAuthClient:load_tokens()
|
|
return M.load_json_file(self:token_path()) --[[@as pending.OAuthTokens?]]
|
|
end
|
|
|
|
---@param tokens pending.OAuthTokens
|
|
---@return boolean
|
|
function OAuthClient:save_tokens(tokens)
|
|
return M.save_json_file(self:token_path(), tokens)
|
|
end
|
|
|
|
---@param creds pending.OAuthCredentials
|
|
---@param tokens pending.OAuthTokens
|
|
---@return pending.OAuthTokens?
|
|
function OAuthClient:refresh_access_token(creds, tokens)
|
|
local body = 'client_id='
|
|
.. M.url_encode(creds.client_id)
|
|
.. '&client_secret='
|
|
.. M.url_encode(creds.client_secret)
|
|
.. '&grant_type=refresh_token'
|
|
.. '&refresh_token='
|
|
.. M.url_encode(tokens.refresh_token)
|
|
local result = M.system({
|
|
'curl',
|
|
'-s',
|
|
'-X',
|
|
'POST',
|
|
'-H',
|
|
'Content-Type: application/x-www-form-urlencoded',
|
|
'-d',
|
|
body,
|
|
TOKEN_URL,
|
|
}, { text = true })
|
|
if result.code ~= 0 then
|
|
return nil
|
|
end
|
|
local ok, decoded = pcall(vim.json.decode, result.stdout or '')
|
|
if not ok or not decoded.access_token then
|
|
return nil
|
|
end
|
|
tokens.access_token = decoded.access_token --[[@as string]]
|
|
tokens.expires_in = decoded.expires_in --[[@as integer?]]
|
|
tokens.obtained_at = os.time()
|
|
self:save_tokens(tokens)
|
|
return tokens
|
|
end
|
|
|
|
---@return string?
|
|
function OAuthClient:get_access_token()
|
|
local creds = self:resolve_credentials()
|
|
local tokens = self:load_tokens()
|
|
if not tokens or not tokens.refresh_token then
|
|
self:auth()
|
|
tokens = self:load_tokens()
|
|
if not tokens then
|
|
return nil
|
|
end
|
|
end
|
|
local now = os.time()
|
|
local obtained = tokens.obtained_at or 0
|
|
local expires = tokens.expires_in or 3600
|
|
if now - obtained > expires - 60 then
|
|
tokens = self:refresh_access_token(creds, tokens)
|
|
if not tokens then
|
|
log.error('Failed to refresh access token.')
|
|
return nil
|
|
end
|
|
end
|
|
return tokens.access_token
|
|
end
|
|
|
|
---@return nil
|
|
function OAuthClient:auth()
|
|
local creds = self:resolve_credentials()
|
|
local port = self.port
|
|
|
|
local verifier_chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'
|
|
local verifier = {}
|
|
math.randomseed(vim.uv.hrtime())
|
|
for _ = 1, 64 do
|
|
local idx = math.random(1, #verifier_chars)
|
|
table.insert(verifier, verifier_chars:sub(idx, idx))
|
|
end
|
|
local code_verifier = table.concat(verifier)
|
|
|
|
local hex = vim.fn.sha256(code_verifier)
|
|
local binary = hex:gsub('..', function(h)
|
|
return string.char(tonumber(h, 16))
|
|
end)
|
|
local code_challenge = vim.base64.encode(binary):gsub('+', '-'):gsub('/', '_'):gsub('=', '')
|
|
|
|
local auth_url = AUTH_URL
|
|
.. '?client_id='
|
|
.. M.url_encode(creds.client_id)
|
|
.. '&redirect_uri='
|
|
.. M.url_encode('http://127.0.0.1:' .. port)
|
|
.. '&response_type=code'
|
|
.. '&scope='
|
|
.. M.url_encode(self.scope)
|
|
.. '&access_type=offline'
|
|
.. '&prompt=consent'
|
|
.. '&code_challenge='
|
|
.. M.url_encode(code_challenge)
|
|
.. '&code_challenge_method=S256'
|
|
|
|
vim.ui.open(auth_url)
|
|
log.info('Opening browser for Google authorization...')
|
|
|
|
local server = vim.uv.new_tcp()
|
|
local server_closed = false
|
|
local function close_server()
|
|
if server_closed then
|
|
return
|
|
end
|
|
server_closed = true
|
|
server:close()
|
|
end
|
|
|
|
server:bind('127.0.0.1', port)
|
|
server:listen(1, function(err)
|
|
if err then
|
|
return
|
|
end
|
|
local conn = vim.uv.new_tcp()
|
|
server:accept(conn)
|
|
conn:read_start(function(read_err, data)
|
|
if read_err or not data then
|
|
conn:close()
|
|
close_server()
|
|
return
|
|
end
|
|
local code = data:match('[?&]code=([^&%s]+)')
|
|
local response_body = code
|
|
and '<html><body><h1>Authorization successful</h1><p>You can close this tab.</p></body></html>'
|
|
or '<html><body><h1>Authorization failed</h1></body></html>'
|
|
local http_response = 'HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n'
|
|
.. response_body
|
|
conn:write(http_response, function()
|
|
conn:shutdown(function()
|
|
conn:close()
|
|
end)
|
|
end)
|
|
close_server()
|
|
if code then
|
|
vim.schedule(function()
|
|
self:_exchange_code(creds, code, code_verifier, port)
|
|
end)
|
|
end
|
|
end)
|
|
end)
|
|
|
|
vim.defer_fn(function()
|
|
if not server_closed then
|
|
close_server()
|
|
log.warn('OAuth callback timed out (120s).')
|
|
end
|
|
end, 120000)
|
|
end
|
|
|
|
---@param creds pending.OAuthCredentials
|
|
---@param code string
|
|
---@param code_verifier string
|
|
---@param port integer
|
|
---@return nil
|
|
function OAuthClient:_exchange_code(creds, code, code_verifier, port)
|
|
local body = 'client_id='
|
|
.. M.url_encode(creds.client_id)
|
|
.. '&client_secret='
|
|
.. M.url_encode(creds.client_secret)
|
|
.. '&code='
|
|
.. M.url_encode(code)
|
|
.. '&code_verifier='
|
|
.. M.url_encode(code_verifier)
|
|
.. '&grant_type=authorization_code'
|
|
.. '&redirect_uri='
|
|
.. M.url_encode('http://127.0.0.1:' .. port)
|
|
|
|
local result = M.system({
|
|
'curl',
|
|
'-s',
|
|
'-X',
|
|
'POST',
|
|
'-H',
|
|
'Content-Type: application/x-www-form-urlencoded',
|
|
'-d',
|
|
body,
|
|
TOKEN_URL,
|
|
}, { text = true })
|
|
|
|
if result.code ~= 0 then
|
|
log.error('Token exchange failed.')
|
|
return
|
|
end
|
|
|
|
local ok, decoded = pcall(vim.json.decode, result.stdout or '')
|
|
if not ok or not decoded.access_token then
|
|
log.error('Invalid token response.')
|
|
return
|
|
end
|
|
|
|
decoded.obtained_at = os.time()
|
|
self:save_tokens(decoded)
|
|
log.info(self.name .. ' authorized successfully.')
|
|
end
|
|
|
|
---@param opts { name: string, scope: string, port: integer, config_key: string }
|
|
---@return pending.OAuthClient
|
|
function M.new(opts)
|
|
return setmetatable({
|
|
name = opts.name,
|
|
scope = opts.scope,
|
|
port = opts.port,
|
|
config_key = opts.config_key,
|
|
}, OAuthClient)
|
|
end
|
|
|
|
M._BUNDLED_CLIENT_ID = BUNDLED_CLIENT_ID
|
|
M._BUNDLED_CLIENT_SECRET = BUNDLED_CLIENT_SECRET
|
|
|
|
return M
|