* 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.
455 lines
13 KiB
Lua
455 lines
13 KiB
Lua
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<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
|
|
|
|
---@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 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<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
|
|
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
|
|
|
|
---@return string? access_token
|
|
---@return table<string, string>? tasklists
|
|
---@return pending.Store? store
|
|
---@return string? now_ts
|
|
local function sync_setup()
|
|
local access_token = client: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
|
|
log.error(tl_err or 'failed to fetch task lists')
|
|
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.auth()
|
|
client:auth()
|
|
end
|
|
|
|
function M.push()
|
|
oauth.async(function()
|
|
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()
|
|
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()
|
|
oauth.async(function()
|
|
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()
|
|
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()
|
|
oauth.async(function()
|
|
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()
|
|
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
|