local config = require('pending.config') local log = require('pending.log') local oauth = require('pending.sync.oauth') local M = {} M.name = 'gtasks' local BASE_URL = 'https://tasks.googleapis.com/tasks/v1' local SCOPE = 'https://www.googleapis.com/auth/tasks' local client = oauth.new({ name = 'gtasks', scope = SCOPE, port = 18393, config_key = 'gtasks', }) ---@param access_token string ---@return table? 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 ---@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 ---@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 local function build_id_index(s) ---@type table 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 ---@param s pending.Store ---@param now_ts string ---@param by_gtasks_id table ---@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 err then log.warn('gtasks delete failed: ' .. err) else 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 err then log.warn('gtasks update failed: ' .. err) else 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 create_err then log.warn('gtasks create failed: ' .. create_err) 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.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 ---@param s pending.Store ---@param now_ts string ---@param by_gtasks_id table ---@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 log.warn('error fetching list ' .. list_name .. ': ' .. err) 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 ---@param access_token string ---@return table? 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(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 ---@param callback fun(access_token: string): nil local function with_token(callback) oauth.async(function() local token = client:get_access_token() if not token then client:auth(function() oauth.async(function() local fresh = client:get_access_token() if fresh then callback(fresh) end end) end) return end callback(token) end) end function M.setup() client:setup() end function M.auth() client:auth() end function M.push() with_token(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 = push_pass(access_token, tasklists, s, now_ts, by_gtasks_id) 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 log.info(string.format('Google Tasks pushed — +%d ~%d -%d', created, updated, deleted)) end) end function M.pull() with_token(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 = pull_pass(access_token, tasklists, s, now_ts, by_gtasks_id) 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 log.info(string.format('Google Tasks pulled — +%d ~%d', created, updated)) end) end function M.sync() with_token(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 = 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() local buffer = require('pending.buffer') if buffer.bufnr() and vim.api.nvim_buf_is_valid(buffer.bufnr()) then buffer.render(buffer.bufnr()) end log.info( string.format( 'Google Tasks synced — push: +%d ~%d -%d, pull: +%d ~%d', pushed_create, pushed_update, pushed_delete, pulled_create, pulled_update ) ) 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 ---@return nil function M.health() oauth.health(M.name) local tokens = 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 gtasks auth') end end return M