feat: Google Tasks bidirectional sync and CLI refactor (#59)
* feat(gtasks): add Google Tasks bidirectional sync
Problem: pending.nvim only supported one-way push to Google Calendar.
Users who use Google Tasks had no way to sync tasks bidirectionally.
Solution: add `lua/pending/sync/gtasks.lua` backend with OAuth PKCE
auth, push/pull/sync actions, and field mapping between pending tasks
and Google Tasks (category↔tasklist, `priority`/`recur` via notes).
* refactor(cli): promote sync backends to top-level subcommands
Problem: `:Pending sync gtasks auth` required an extra `sync` keyword
that added no value and made the command unnecessarily verbose.
Solution: route `gtasks` and `gcal` as top-level `:Pending` subcommands
via `SYNC_BACKEND_SET` lookup. Tab completion introspects backend
modules for available actions instead of hardcoding `{ 'auth', 'sync' }`.
* docs(gtasks): document Google Tasks backend and CLI changes
Problem: vimdoc had no coverage for the gtasks backend and still
referenced the old `:Pending sync <backend>` command form.
Solution: add `:Pending-gtasks` and `:Pending-gcal` command sections
with per-action docs, update sync backend interface, and add gtasks
config example.
* ci: format
This commit is contained in:
parent
910c8d2d69
commit
a6be248cbf
7 changed files with 1114 additions and 85 deletions
|
|
@ -10,8 +10,12 @@
|
|||
---@field calendar? string
|
||||
---@field credentials_path? string
|
||||
|
||||
---@class pending.GtasksConfig
|
||||
---@field credentials_path? string
|
||||
|
||||
---@class pending.SyncConfig
|
||||
---@field gcal? pending.GcalConfig
|
||||
---@field gtasks? pending.GtasksConfig
|
||||
|
||||
---@class pending.Keymaps
|
||||
---@field close? string|false
|
||||
|
|
|
|||
|
|
@ -523,14 +523,19 @@ function M.add(text)
|
|||
vim.notify('Pending added: ' .. description)
|
||||
end
|
||||
|
||||
---@type string[]
|
||||
local SYNC_BACKENDS = { 'gcal', 'gtasks' }
|
||||
|
||||
---@type table<string, true>
|
||||
local SYNC_BACKEND_SET = {}
|
||||
for _, b in ipairs(SYNC_BACKENDS) do
|
||||
SYNC_BACKEND_SET[b] = true
|
||||
end
|
||||
|
||||
---@param backend_name string
|
||||
---@param action? string
|
||||
---@return nil
|
||||
function M.sync(backend_name, action)
|
||||
if not backend_name or backend_name == '' then
|
||||
vim.notify('Usage: :Pending sync <backend> [action]', vim.log.levels.ERROR)
|
||||
return
|
||||
end
|
||||
local function run_sync(backend_name, action)
|
||||
action = (action and action ~= '') and action or 'sync'
|
||||
local ok, backend = pcall(require, 'pending.sync.' .. backend_name)
|
||||
if not ok then
|
||||
|
|
@ -835,9 +840,9 @@ function M.command(args)
|
|||
elseif cmd == 'edit' then
|
||||
local id_str, edit_rest = rest:match('^(%S+)%s*(.*)')
|
||||
M.edit(id_str, edit_rest)
|
||||
elseif cmd == 'sync' then
|
||||
local backend, action = rest:match('^(%S+)%s*(.*)')
|
||||
M.sync(backend, action)
|
||||
elseif SYNC_BACKEND_SET[cmd] then
|
||||
local action = rest:match('^(%S+)') or 'sync'
|
||||
run_sync(cmd, action)
|
||||
elseif cmd == 'archive' then
|
||||
local d = rest ~= '' and tonumber(rest) or nil
|
||||
M.archive(d)
|
||||
|
|
@ -854,4 +859,14 @@ function M.command(args)
|
|||
end
|
||||
end
|
||||
|
||||
---@return string[]
|
||||
function M.sync_backends()
|
||||
return SYNC_BACKENDS
|
||||
end
|
||||
|
||||
---@return table<string, true>
|
||||
function M.sync_backend_set()
|
||||
return SYNC_BACKEND_SET
|
||||
end
|
||||
|
||||
return M
|
||||
|
|
|
|||
767
lua/pending/sync/gtasks.lua
Normal file
767
lua/pending/sync/gtasks.lua
Normal file
|
|
@ -0,0 +1,767 @@
|
|||
local config = require('pending.config')
|
||||
|
||||
local M = {}
|
||||
|
||||
M.name = 'gtasks'
|
||||
|
||||
local BASE_URL = 'https://tasks.googleapis.com/tasks/v1'
|
||||
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/tasks'
|
||||
|
||||
---@class pending.GtasksCredentials
|
||||
---@field client_id string
|
||||
---@field client_secret string
|
||||
---@field redirect_uris? string[]
|
||||
|
||||
---@class pending.GtasksTokens
|
||||
---@field access_token string
|
||||
---@field refresh_token string
|
||||
---@field expires_in? integer
|
||||
---@field obtained_at? integer
|
||||
|
||||
---@return table<string, any>
|
||||
local function gtasks_config()
|
||||
local cfg = config.get()
|
||||
return (cfg.sync and cfg.sync.gtasks) or {}
|
||||
end
|
||||
|
||||
---@return string
|
||||
local function token_path()
|
||||
return vim.fn.stdpath('data') .. '/pending/gtasks_tokens.json'
|
||||
end
|
||||
|
||||
---@return string
|
||||
local function credentials_path()
|
||||
local gc = gtasks_config()
|
||||
return gc.credentials_path or (vim.fn.stdpath('data') .. '/pending/gtasks_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.GtasksCredentials?
|
||||
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.GtasksCredentials]]
|
||||
end
|
||||
return creds --[[@as pending.GtasksCredentials]]
|
||||
end
|
||||
|
||||
---@return pending.GtasksTokens?
|
||||
local function load_tokens()
|
||||
return load_json_file(token_path()) --[[@as pending.GtasksTokens?]]
|
||||
end
|
||||
|
||||
---@param tokens pending.GtasksTokens
|
||||
---@return boolean
|
||||
local function save_tokens(tokens)
|
||||
return save_json_file(token_path(), tokens)
|
||||
end
|
||||
|
||||
---@param str string
|
||||
---@return string
|
||||
local function url_encode(str)
|
||||
return (
|
||||
str:gsub('([^%w%-%.%_%~])', function(c)
|
||||
return string.format('%%%02X', string.byte(c))
|
||||
end)
|
||||
)
|
||||
end
|
||||
|
||||
---@param method string
|
||||
---@param url string
|
||||
---@param headers? string[]
|
||||
---@param body? string
|
||||
---@return table? result
|
||||
---@return string? err
|
||||
local function curl_request(method, url, headers, body)
|
||||
local args = { 'curl', '-s', '-X', method }
|
||||
for _, h in ipairs(headers or {}) do
|
||||
table.insert(args, '-H')
|
||||
table.insert(args, h)
|
||||
end
|
||||
if body then
|
||||
table.insert(args, '-d')
|
||||
table.insert(args, body)
|
||||
end
|
||||
table.insert(args, url)
|
||||
local result = vim.system(args, { text = true }):wait()
|
||||
if result.code ~= 0 then
|
||||
return nil, 'curl failed: ' .. (result.stderr or '')
|
||||
end
|
||||
if not result.stdout or result.stdout == '' then
|
||||
return {}, nil
|
||||
end
|
||||
local ok, decoded = pcall(vim.json.decode, result.stdout)
|
||||
if not ok then
|
||||
return nil, 'failed to parse response: ' .. result.stdout
|
||||
end
|
||||
if decoded.error then
|
||||
return nil, 'API error: ' .. (decoded.error.message or vim.json.encode(decoded.error))
|
||||
end
|
||||
return decoded, nil
|
||||
end
|
||||
|
||||
---@param access_token string
|
||||
---@return string[]
|
||||
local function auth_headers(access_token)
|
||||
return {
|
||||
'Authorization: Bearer ' .. access_token,
|
||||
'Content-Type: application/json',
|
||||
}
|
||||
end
|
||||
|
||||
---@param creds pending.GtasksCredentials
|
||||
---@param tokens pending.GtasksTokens
|
||||
---@return pending.GtasksTokens?
|
||||
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 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.auth()
|
||||
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.auth()
|
||||
local creds = load_credentials()
|
||||
if not creds then
|
||||
vim.notify(
|
||||
'pending.nvim: No Google credentials found at ' .. credentials_path(),
|
||||
vim.log.levels.ERROR
|
||||
)
|
||||
return
|
||||
end
|
||||
|
||||
local port = 18393
|
||||
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.GtasksCredentials
|
||||
---@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 Tasks authorized successfully.')
|
||||
end
|
||||
|
||||
---@param access_token string
|
||||
---@return table<string, string>? name_to_id
|
||||
---@return string? err
|
||||
local function get_all_tasklists(access_token)
|
||||
local data, err = curl_request('GET', BASE_URL .. '/users/@me/lists', 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 =
|
||||
curl_request('POST', BASE_URL .. '/users/@me/lists', 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/'
|
||||
.. url_encode(list_id)
|
||||
.. '/tasks?showCompleted=true&showHidden=true'
|
||||
local data, err = curl_request('GET', url, 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 = curl_request(
|
||||
'POST',
|
||||
BASE_URL .. '/lists/' .. url_encode(list_id) .. '/tasks',
|
||||
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 = curl_request(
|
||||
'PATCH',
|
||||
BASE_URL .. '/lists/' .. url_encode(list_id) .. '/tasks/' .. url_encode(task_id),
|
||||
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 = curl_request(
|
||||
'DELETE',
|
||||
BASE_URL .. '/lists/' .. url_encode(list_id) .. '/tasks/' .. url_encode(task_id),
|
||||
auth_headers(access_token)
|
||||
)
|
||||
return err
|
||||
end
|
||||
|
||||
---@param due string YYYY-MM-DD or YYYY-MM-DDThh:mm
|
||||
---@return string RFC 3339
|
||||
local function due_to_rfc3339(due)
|
||||
local date = due:match('^(%d%d%d%d%-%d%d%-%d%d)')
|
||||
return (date or due) .. 'T00:00:00.000Z'
|
||||
end
|
||||
|
||||
---@param rfc string RFC 3339 from GTasks
|
||||
---@return string YYYY-MM-DD
|
||||
local function rfc3339_to_date(rfc)
|
||||
return rfc:match('^(%d%d%d%d%-%d%d%-%d%d)') or rfc
|
||||
end
|
||||
|
||||
---@param task pending.Task
|
||||
---@return string?
|
||||
local function build_notes(task)
|
||||
local parts = {}
|
||||
if task.priority and task.priority > 0 then
|
||||
table.insert(parts, 'pri:' .. task.priority)
|
||||
end
|
||||
if task.recur then
|
||||
local spec = task.recur
|
||||
if task.recur_mode == 'completion' then
|
||||
spec = '!' .. spec
|
||||
end
|
||||
table.insert(parts, 'rec:' .. spec)
|
||||
end
|
||||
if #parts == 0 then
|
||||
return nil
|
||||
end
|
||||
return table.concat(parts, ' ')
|
||||
end
|
||||
|
||||
---@param notes string?
|
||||
---@return integer priority
|
||||
---@return string? recur
|
||||
---@return string? recur_mode
|
||||
local function parse_notes(notes)
|
||||
if not notes then
|
||||
return 0, nil, nil
|
||||
end
|
||||
local priority = 0
|
||||
local recur = nil
|
||||
local recur_mode = nil
|
||||
local pri = notes:match('pri:(%d+)')
|
||||
if pri then
|
||||
priority = tonumber(pri) or 0
|
||||
end
|
||||
local rec = notes:match('rec:(!?[%w]+)')
|
||||
if rec then
|
||||
if rec:sub(1, 1) == '!' then
|
||||
recur = rec:sub(2)
|
||||
recur_mode = 'completion'
|
||||
else
|
||||
recur = rec
|
||||
end
|
||||
end
|
||||
return priority, recur, recur_mode
|
||||
end
|
||||
|
||||
---@param task pending.Task
|
||||
---@return table
|
||||
local function task_to_gtask(task)
|
||||
local body = {
|
||||
title = task.description,
|
||||
status = task.status == 'done' and 'completed' or 'needsAction',
|
||||
}
|
||||
if task.due then
|
||||
body.due = due_to_rfc3339(task.due)
|
||||
end
|
||||
local notes = build_notes(task)
|
||||
if notes then
|
||||
body.notes = notes
|
||||
end
|
||||
return body
|
||||
end
|
||||
|
||||
---@param gtask table
|
||||
---@param category string
|
||||
---@return table fields for store:add / store:update
|
||||
local function gtask_to_fields(gtask, category)
|
||||
local priority, recur, recur_mode = parse_notes(gtask.notes)
|
||||
local fields = {
|
||||
description = gtask.title or '',
|
||||
category = category,
|
||||
status = gtask.status == 'completed' and 'done' or 'pending',
|
||||
priority = priority,
|
||||
recur = recur,
|
||||
recur_mode = recur_mode,
|
||||
}
|
||||
if gtask.due then
|
||||
fields.due = rfc3339_to_date(gtask.due)
|
||||
end
|
||||
return fields
|
||||
end
|
||||
|
||||
---@param s pending.Store
|
||||
---@return table<string, pending.Task>
|
||||
local function build_id_index(s)
|
||||
---@type table<string, pending.Task>
|
||||
local index = {}
|
||||
for _, task in ipairs(s:tasks()) do
|
||||
local extra = task._extra or {}
|
||||
local gtid = extra['_gtasks_task_id'] --[[@as string?]]
|
||||
if gtid then
|
||||
index[gtid] = task
|
||||
end
|
||||
end
|
||||
return index
|
||||
end
|
||||
|
||||
---@param access_token string
|
||||
---@param tasklists table<string, string>
|
||||
---@param s pending.Store
|
||||
---@param now_ts string
|
||||
---@param by_gtasks_id table<string, pending.Task>
|
||||
---@return integer created
|
||||
---@return integer updated
|
||||
---@return integer deleted
|
||||
local function push_pass(access_token, tasklists, s, now_ts, by_gtasks_id)
|
||||
local created, updated, deleted = 0, 0, 0
|
||||
for _, task in ipairs(s:tasks()) do
|
||||
local extra = task._extra or {}
|
||||
local gtid = extra['_gtasks_task_id'] --[[@as string?]]
|
||||
local list_id = extra['_gtasks_list_id'] --[[@as string?]]
|
||||
|
||||
if task.status == 'deleted' and gtid and list_id then
|
||||
local err = delete_gtask(access_token, list_id, gtid)
|
||||
if not err then
|
||||
if not task._extra then
|
||||
task._extra = {}
|
||||
end
|
||||
task._extra['_gtasks_task_id'] = nil
|
||||
task._extra['_gtasks_list_id'] = nil
|
||||
if next(task._extra) == nil then
|
||||
task._extra = nil
|
||||
end
|
||||
task.modified = now_ts
|
||||
deleted = deleted + 1
|
||||
end
|
||||
elseif task.status ~= 'deleted' then
|
||||
if gtid and list_id then
|
||||
local err = update_gtask(access_token, list_id, gtid, task_to_gtask(task))
|
||||
if not err then
|
||||
updated = updated + 1
|
||||
end
|
||||
elseif task.status == 'pending' then
|
||||
local cat = task.category or config.get().default_category
|
||||
local lid, err = find_or_create_tasklist(access_token, cat, tasklists)
|
||||
if not err and lid then
|
||||
local new_id, create_err = create_gtask(access_token, lid, task_to_gtask(task))
|
||||
if not create_err and new_id then
|
||||
if not task._extra then
|
||||
task._extra = {}
|
||||
end
|
||||
task._extra['_gtasks_task_id'] = new_id
|
||||
task._extra['_gtasks_list_id'] = lid
|
||||
task.modified = now_ts
|
||||
by_gtasks_id[new_id] = task
|
||||
created = created + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return created, updated, deleted
|
||||
end
|
||||
|
||||
---@param access_token string
|
||||
---@param tasklists table<string, string>
|
||||
---@param s pending.Store
|
||||
---@param now_ts string
|
||||
---@param by_gtasks_id table<string, pending.Task>
|
||||
---@return integer created
|
||||
---@return integer updated
|
||||
local function pull_pass(access_token, tasklists, s, now_ts, by_gtasks_id)
|
||||
local created, updated = 0, 0
|
||||
for list_name, list_id in pairs(tasklists) do
|
||||
local items, err = list_gtasks(access_token, list_id)
|
||||
if err then
|
||||
vim.notify(
|
||||
'pending.nvim: error fetching list ' .. list_name .. ': ' .. err,
|
||||
vim.log.levels.WARN
|
||||
)
|
||||
else
|
||||
for _, gtask in ipairs(items or {}) do
|
||||
local local_task = by_gtasks_id[gtask.id]
|
||||
if local_task then
|
||||
local gtask_updated = gtask.updated or ''
|
||||
local local_modified = local_task.modified or ''
|
||||
if gtask_updated > local_modified then
|
||||
local fields = gtask_to_fields(gtask, list_name)
|
||||
for k, v in pairs(fields) do
|
||||
local_task[k] = v
|
||||
end
|
||||
local_task.modified = now_ts
|
||||
updated = updated + 1
|
||||
end
|
||||
else
|
||||
local fields = gtask_to_fields(gtask, list_name)
|
||||
fields._extra = {
|
||||
_gtasks_task_id = gtask.id,
|
||||
_gtasks_list_id = list_id,
|
||||
}
|
||||
local new_task = s:add(fields)
|
||||
by_gtasks_id[gtask.id] = new_task
|
||||
created = created + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return created, updated
|
||||
end
|
||||
|
||||
---@return string? access_token
|
||||
---@return table<string, string>? tasklists
|
||||
---@return pending.Store? store
|
||||
---@return string? now_ts
|
||||
local function sync_setup()
|
||||
local access_token = get_access_token()
|
||||
if not access_token then
|
||||
return nil
|
||||
end
|
||||
local tasklists, tl_err = get_all_tasklists(access_token)
|
||||
if tl_err or not tasklists then
|
||||
vim.notify('pending.nvim: ' .. (tl_err or 'failed to fetch task lists'), vim.log.levels.ERROR)
|
||||
return nil
|
||||
end
|
||||
local s = require('pending').store()
|
||||
local now_ts = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]]
|
||||
return access_token, tasklists, s, now_ts
|
||||
end
|
||||
|
||||
function M.push()
|
||||
local access_token, tasklists, s, now_ts = sync_setup()
|
||||
if not access_token then
|
||||
return
|
||||
end
|
||||
---@cast tasklists table<string, string>
|
||||
---@cast s pending.Store
|
||||
---@cast now_ts string
|
||||
local by_gtasks_id = build_id_index(s)
|
||||
local created, updated, deleted = push_pass(access_token, tasklists, s, now_ts, by_gtasks_id)
|
||||
s:save()
|
||||
require('pending')._recompute_counts()
|
||||
vim.notify(
|
||||
string.format('pending.nvim: Google Tasks pushed — +%d ~%d -%d', created, updated, deleted)
|
||||
)
|
||||
end
|
||||
|
||||
function M.pull()
|
||||
local access_token, tasklists, s, now_ts = sync_setup()
|
||||
if not access_token then
|
||||
return
|
||||
end
|
||||
---@cast tasklists table<string, string>
|
||||
---@cast s pending.Store
|
||||
---@cast now_ts string
|
||||
local by_gtasks_id = build_id_index(s)
|
||||
local created, updated = pull_pass(access_token, tasklists, s, now_ts, by_gtasks_id)
|
||||
s:save()
|
||||
require('pending')._recompute_counts()
|
||||
vim.notify(string.format('pending.nvim: Google Tasks pulled — +%d ~%d', created, updated))
|
||||
end
|
||||
|
||||
function M.sync()
|
||||
local access_token, tasklists, s, now_ts = sync_setup()
|
||||
if not access_token then
|
||||
return
|
||||
end
|
||||
---@cast tasklists table<string, string>
|
||||
---@cast s pending.Store
|
||||
---@cast now_ts string
|
||||
local by_gtasks_id = build_id_index(s)
|
||||
local pushed_create, pushed_update, pushed_delete =
|
||||
push_pass(access_token, tasklists, s, now_ts, by_gtasks_id)
|
||||
local pulled_create, pulled_update = pull_pass(access_token, tasklists, s, now_ts, by_gtasks_id)
|
||||
s:save()
|
||||
require('pending')._recompute_counts()
|
||||
vim.notify(
|
||||
string.format(
|
||||
'pending.nvim: Google Tasks synced — push: +%d ~%d -%d, pull: +%d ~%d',
|
||||
pushed_create,
|
||||
pushed_update,
|
||||
pushed_delete,
|
||||
pulled_create,
|
||||
pulled_update
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
M._due_to_rfc3339 = due_to_rfc3339
|
||||
M._rfc3339_to_date = rfc3339_to_date
|
||||
M._build_notes = build_notes
|
||||
M._parse_notes = parse_notes
|
||||
M._task_to_gtask = task_to_gtask
|
||||
M._gtask_to_fields = gtask_to_fields
|
||||
|
||||
---@return nil
|
||||
function M.health()
|
||||
if vim.fn.executable('curl') == 1 then
|
||||
vim.health.ok('curl found (required for gtasks sync)')
|
||||
else
|
||||
vim.health.warn('curl not found (needed for gtasks sync)')
|
||||
end
|
||||
if vim.fn.executable('openssl') == 1 then
|
||||
vim.health.ok('openssl found (required for gtasks OAuth PKCE)')
|
||||
else
|
||||
vim.health.warn('openssl not found (needed for gtasks OAuth)')
|
||||
end
|
||||
local tokens = load_tokens()
|
||||
if tokens and tokens.refresh_token then
|
||||
vim.health.ok('gtasks tokens found')
|
||||
else
|
||||
vim.health.info('no gtasks tokens — run :Pending gtasks auth')
|
||||
end
|
||||
end
|
||||
|
||||
return M
|
||||
Loading…
Add table
Add a link
Reference in a new issue