* 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.
234 lines
6.4 KiB
Lua
234 lines
6.4 KiB
Lua
local config = require('pending.config')
|
|
local log = require('pending.log')
|
|
local oauth = require('pending.sync.oauth')
|
|
|
|
local M = {}
|
|
|
|
M.name = 'gcal'
|
|
|
|
local BASE_URL = 'https://www.googleapis.com/calendar/v3'
|
|
local SCOPE = 'https://www.googleapis.com/auth/calendar'
|
|
|
|
local client = oauth.new({
|
|
name = 'gcal',
|
|
scope = SCOPE,
|
|
port = 18392,
|
|
config_key = 'gcal',
|
|
})
|
|
|
|
---@param access_token string
|
|
---@return table<string, string>? name_to_id
|
|
---@return string? err
|
|
local function get_all_calendars(access_token)
|
|
local data, err = oauth.curl_request(
|
|
'GET',
|
|
BASE_URL .. '/users/me/calendarList',
|
|
oauth.auth_headers(access_token)
|
|
)
|
|
if err then
|
|
return nil, err
|
|
end
|
|
local result = {}
|
|
for _, item in ipairs(data and data.items or {}) do
|
|
if item.summary then
|
|
result[item.summary] = item.id
|
|
end
|
|
end
|
|
return result, nil
|
|
end
|
|
|
|
---@param access_token string
|
|
---@param name string
|
|
---@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)
|
|
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 date_str string
|
|
---@return string
|
|
local function next_day(date_str)
|
|
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 })
|
|
+ 86400
|
|
return os.date('%Y-%m-%d', t) --[[@as string]]
|
|
end
|
|
|
|
---@param access_token string
|
|
---@param calendar_id string
|
|
---@param task pending.Task
|
|
---@return string? event_id
|
|
---@return string? err
|
|
local function create_event(access_token, calendar_id, task)
|
|
local event = {
|
|
summary = task.description,
|
|
start = { date = task.due },
|
|
['end'] = { date = next_day(task.due or '') },
|
|
transparency = 'transparent',
|
|
extendedProperties = {
|
|
private = { taskId = tostring(task.id) },
|
|
},
|
|
}
|
|
local data, err = oauth.curl_request(
|
|
'POST',
|
|
BASE_URL .. '/calendars/' .. oauth.url_encode(calendar_id) .. '/events',
|
|
oauth.auth_headers(access_token),
|
|
vim.json.encode(event)
|
|
)
|
|
if err then
|
|
return nil, err
|
|
end
|
|
return data and data.id, nil
|
|
end
|
|
|
|
---@param access_token string
|
|
---@param calendar_id string
|
|
---@param event_id string
|
|
---@param task pending.Task
|
|
---@return string? err
|
|
local function update_event(access_token, calendar_id, event_id, task)
|
|
local event = {
|
|
summary = task.description,
|
|
start = { date = task.due },
|
|
['end'] = { date = next_day(task.due or '') },
|
|
}
|
|
local _, err = oauth.curl_request(
|
|
'PATCH',
|
|
BASE_URL
|
|
.. '/calendars/'
|
|
.. oauth.url_encode(calendar_id)
|
|
.. '/events/'
|
|
.. oauth.url_encode(event_id),
|
|
oauth.auth_headers(access_token),
|
|
vim.json.encode(event)
|
|
)
|
|
return err
|
|
end
|
|
|
|
---@param access_token string
|
|
---@param calendar_id string
|
|
---@param event_id string
|
|
---@return string? err
|
|
local function delete_event(access_token, calendar_id, event_id)
|
|
local _, err = oauth.curl_request(
|
|
'DELETE',
|
|
BASE_URL
|
|
.. '/calendars/'
|
|
.. oauth.url_encode(calendar_id)
|
|
.. '/events/'
|
|
.. oauth.url_encode(event_id),
|
|
oauth.auth_headers(access_token)
|
|
)
|
|
return err
|
|
end
|
|
|
|
function M.auth()
|
|
client:auth()
|
|
end
|
|
|
|
function M.push()
|
|
oauth.async(function()
|
|
local access_token = client:get_access_token()
|
|
if not access_token then
|
|
return
|
|
end
|
|
|
|
local calendars, cal_err = get_all_calendars(access_token)
|
|
if cal_err or not calendars then
|
|
log.error(cal_err or 'failed to fetch calendars')
|
|
return
|
|
end
|
|
|
|
local s = require('pending').store()
|
|
local created, updated, deleted = 0, 0, 0
|
|
|
|
for _, task in ipairs(s:tasks()) do
|
|
local extra = task._extra or {}
|
|
local event_id = extra['_gcal_event_id'] --[[@as string?]]
|
|
local cal_id = extra['_gcal_calendar_id'] --[[@as string?]]
|
|
|
|
local should_delete = event_id ~= nil
|
|
and cal_id ~= nil
|
|
and (
|
|
task.status == 'done'
|
|
or task.status == 'deleted'
|
|
or (task.status == 'pending' and not task.due)
|
|
)
|
|
|
|
if should_delete then
|
|
local del_err =
|
|
delete_event(access_token, cal_id --[[@as string]], event_id --[[@as string]])
|
|
if del_err then
|
|
log.warn('gcal delete failed: ' .. del_err)
|
|
else
|
|
extra['_gcal_event_id'] = nil
|
|
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
|
|
elseif task.status == 'pending' and task.due then
|
|
local cat = task.category or config.get().default_category
|
|
if event_id and cal_id then
|
|
local upd_err = update_event(access_token, cal_id, event_id, task)
|
|
if upd_err then
|
|
log.warn('gcal update failed: ' .. upd_err)
|
|
else
|
|
updated = updated + 1
|
|
end
|
|
else
|
|
local lid, lid_err = find_or_create_calendar(access_token, cat, calendars)
|
|
if lid_err or not lid then
|
|
log.warn('gcal calendar failed: ' .. (lid_err or 'unknown'))
|
|
else
|
|
local new_id, create_err = create_event(access_token, lid, task)
|
|
if create_err then
|
|
log.warn('gcal create failed: ' .. create_err)
|
|
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
|
|
end
|
|
end
|
|
|
|
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 Calendar pushed — +%d ~%d -%d', created, updated, deleted))
|
|
end)
|
|
end
|
|
|
|
---@return nil
|
|
function M.health()
|
|
oauth.health(M.name)
|
|
end
|
|
|
|
return M
|