diff --git a/doc/pending.txt b/doc/pending.txt index 2465ba3..994afc6 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -65,8 +65,9 @@ CONTENTS *pending-contents* 17. Sync Backends ................................... |pending-sync-backend| 18. Google Calendar .......................................... |pending-gcal| 19. Google Tasks ............................................ |pending-gtasks| - 20. Data Format .............................................. |pending-data| - 21. Health Check ........................................... |pending-health| + 20. Google Authentication ......................... |pending-google-auth| + 21. Data Format .............................................. |pending-data| + 22. Health Check ........................................... |pending-health| ============================================================================== REQUIREMENTS *pending-requirements* @@ -148,6 +149,15 @@ COMMANDS *pending-commands* Populate the quickfix list with all tasks that are overdue or due today. Open the list with |:copen| to navigate to each task's category. + *:Pending-auth* +:Pending auth + Authorize pending.nvim to access Google services (Tasks and Calendar). + Prompts with |vim.ui.select| to choose gtasks, gcal, or both — all + options run the same combined OAuth flow and produce a single shared + token file. If no credentials are configured, the setup wizard runs + first to collect a client ID and secret. + See |pending-google-auth| for full details. + *:Pending-gtasks* :Pending gtasks {action} Run a Google Tasks action. An explicit action is required. @@ -156,13 +166,11 @@ COMMANDS *pending-commands* `sync` Push local changes then pull remote changes. `push` Push local changes to Google Tasks only. `pull` Pull remote changes from Google Tasks only. - `auth` Run the OAuth authorization flow. Examples: >vim :Pending gtasks sync " push then pull :Pending gtasks push " push local → Google Tasks :Pending gtasks pull " pull Google Tasks → local - :Pending gtasks auth " authorize < Tab completion after `:Pending gtasks ` lists available actions. @@ -174,11 +182,9 @@ COMMANDS *pending-commands* Actions: ~ `push` Push tasks with due dates to Google Calendar. - `auth` Run the OAuth authorization flow. Examples: >vim :Pending gcal push " push to Google Calendar - :Pending gcal auth " authorize < Tab completion after `:Pending gcal ` lists available actions. @@ -920,7 +926,6 @@ Each module returns a table conforming to the backend interface: >lua ---@class pending.SyncBackend ---@field name string - ---@field auth fun(): nil ---@field push? fun(): nil ---@field pull? fun(): nil ---@field sync? fun(): nil @@ -929,15 +934,17 @@ Each module returns a table conforming to the backend interface: >lua Required fields: ~ {name} Backend identifier (matches the filename). - {sync} Main sync action. Called by `:Pending `. - {auth} Authorization flow. Called by `:Pending auth`. Optional fields: ~ {push} Push-only action. Called by `:Pending push`. {pull} Pull-only action. Called by `:Pending pull`. + {sync} Main sync action. Called by `:Pending sync`. {health} Called by `:checkhealth pending` to report backend-specific diagnostics (e.g. checking for external tools). +Note: authorization is not a per-backend action. Use `:Pending auth` to +authenticate all Google backends at once. See |pending-google-auth|. + Backend-specific configuration goes under `sync.` in |pending-config|. ============================================================================== @@ -957,7 +964,7 @@ Configuration: >lua < No configuration is required to get started — bundled OAuth credentials are -used by default. Run `:Pending gcal auth` and the browser opens immediately. +used by default. Run `:Pending auth` and the browser opens immediately. *pending.GcalConfig* Fields: ~ @@ -983,15 +990,8 @@ Credentials are resolved in order: 3. Bundled credentials shipped with the plugin (always available). OAuth flow: ~ -On the first `:Pending gcal` call the plugin detects that no refresh token -exists and opens the Google authorization URL in the browser using -|vim.ui.open()|. A temporary local HTTP server listens on port 18392 for the -OAuth redirect. The PKCE (Proof Key for Code Exchange) flow is used. After -the user grants consent, the -authorization code is exchanged for tokens and the refresh token is stored at -`stdpath('data')/pending/gcal_tokens.json` with mode `600`. Subsequent syncs -use the stored refresh token and refresh the access token automatically when -it is about to expire. +See |pending-google-auth|. Tokens are shared with the gtasks backend and +stored at `stdpath('data')/pending/google_tokens.json`. `:Pending gcal push` behavior: ~ For each task in the store: @@ -1020,7 +1020,7 @@ Configuration: >lua < No configuration is required to get started — bundled OAuth credentials are -used by default. Run `:Pending gtasks auth` and the browser opens immediately. +used by default. Run `:Pending auth` and the browser opens immediately. *pending.GtasksConfig* Fields: ~ @@ -1043,9 +1043,8 @@ Credential resolution: ~ Same three-tier resolution as the gcal backend (see |pending-gcal|). OAuth flow: ~ -Same PKCE flow as the gcal backend; listens on port 18393. Tokens are stored -at `stdpath('data')/pending/gtasks_tokens.json`. Run `:Pending gtasks auth` -to authorize; subsequent syncs refresh the token automatically. +See |pending-google-auth|. Tokens are shared with the gcal backend and +stored at `stdpath('data')/pending/google_tokens.json`. `:Pending gtasks` actions: ~ @@ -1077,6 +1076,42 @@ the remainder is ignored. Recurrence (`rec:`) is stored in notes for round-tripping but is not expanded by Google Tasks (GTasks has no recurrence API). +============================================================================== +GOOGLE AUTHENTICATION *pending-google-auth* + +Both the gcal and gtasks backends share a single OAuth client with combined +scopes (`tasks` + `calendar`). One authorization flow covers both services +and produces one token file. + +:Pending auth ~ +Prompts with |vim.ui.select| offering three options: `gtasks`, `gcal`, and +`both`. All three options run the identical combined OAuth flow — the choice +is informational only. If no real credentials are configured (i.e. bundled +placeholders are in use), the setup wizard runs first to collect a client ID +and client secret before opening the browser. + +OAuth flow: ~ +A PKCE (Proof Key for Code Exchange) flow is used: +1. A random 64-character `code_verifier` is generated. +2. Its SHA-256 hash is base64url-encoded as the `code_challenge`. +3. The Google authorization URL is opened in the browser via |vim.ui.open()|. +4. A temporary TCP server on port 18392 waits up to 120 seconds for the + OAuth redirect. +5. The authorization code is exchanged for tokens via `curl`. +6. The refresh token is written to + `stdpath('data')/pending/google_tokens.json` with mode `600`. +7. Subsequent syncs refresh the access token automatically when it is about + to expire (within 60 seconds of the `expires_in` window). + +Credential resolution: ~ +Credentials are resolved in order for the `google` config key: +1. `client_id` + `client_secret` under `sync.google` (highest priority). +2. JSON file at `sync.google.credentials_path` or the default path + `stdpath('data')/pending/google_credentials.json`. +3. Bundled placeholder credentials (always available; trigger setup wizard). + +The `installed` wrapper format from the Google Cloud Console is accepted. + ============================================================================== DATA FORMAT *pending-data* diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 1e05c36..446d375 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -828,6 +828,24 @@ function M.edit(id_str, rest) log.info('Task #' .. id .. ' updated: ' .. table.concat(feedback, ', ')) end +---@return nil +function M.auth() + local oauth = require('pending.sync.oauth') + vim.ui.select({ 'gtasks', 'gcal', 'both' }, { + prompt = 'Authenticate:', + }, function(choice) + if not choice then + return + end + local creds = oauth.google_client:resolve_credentials() + if creds.client_id == oauth.BUNDLED_CLIENT_ID then + oauth.google_client:setup() + else + oauth.google_client:auth() + end + end) +end + ---@param args string ---@return nil function M.command(args) @@ -841,6 +859,8 @@ 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 == 'auth' then + M.auth() elseif SYNC_BACKEND_SET[cmd] then local action = rest:match('^(%S+)') run_sync(cmd, action) diff --git a/lua/pending/sync/gcal.lua b/lua/pending/sync/gcal.lua index bddb461..99b9e76 100644 --- a/lua/pending/sync/gcal.lua +++ b/lua/pending/sync/gcal.lua @@ -7,14 +7,6 @@ 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? name_to_id @@ -139,15 +131,15 @@ end ---@param callback fun(access_token: string): nil local function with_token(callback) oauth.async(function() - local token = client:get_access_token() + local token = oauth.google_client:get_access_token() if not token then - client:auth(function() + oauth.google_client:auth(function() oauth.async(function() - local fresh = client:get_access_token() + local fresh = oauth.google_client:get_access_token() if fresh then callback(fresh) else - log.error(client.name .. ': authorization failed or was cancelled') + log.error(oauth.google_client.name .. ': authorization failed or was cancelled') end end) end) @@ -157,14 +149,6 @@ local function with_token(callback) end) end -function M.setup() - client:setup() -end - -function M.auth() - client:auth() -end - function M.push() with_token(function(access_token) local calendars, cal_err = get_all_calendars(access_token) diff --git a/lua/pending/sync/gtasks.lua b/lua/pending/sync/gtasks.lua index d1ae10f..d747c51 100644 --- a/lua/pending/sync/gtasks.lua +++ b/lua/pending/sync/gtasks.lua @@ -7,14 +7,6 @@ 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 @@ -355,15 +347,15 @@ end ---@param callback fun(access_token: string): nil local function with_token(callback) oauth.async(function() - local token = client:get_access_token() + local token = oauth.google_client:get_access_token() if not token then - client:auth(function() + oauth.google_client:auth(function() oauth.async(function() - local fresh = client:get_access_token() + local fresh = oauth.google_client:get_access_token() if fresh then callback(fresh) else - log.error(client.name .. ': authorization failed or was cancelled') + log.error(oauth.google_client.name .. ': authorization failed or was cancelled') end end) end) @@ -373,14 +365,6 @@ local function with_token(callback) 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) @@ -462,11 +446,11 @@ M._gtask_to_fields = gtask_to_fields ---@return nil function M.health() oauth.health(M.name) - local tokens = client:load_tokens() + local tokens = oauth.google_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') + vim.health.info('no gtasks tokens — run :Pending auth') end end diff --git a/lua/pending/sync/oauth.lua b/lua/pending/sync/oauth.lua index cb490e4..887769c 100644 --- a/lua/pending/sync/oauth.lua +++ b/lua/pending/sync/oauth.lua @@ -501,5 +501,13 @@ end M._BUNDLED_CLIENT_ID = BUNDLED_CLIENT_ID M._BUNDLED_CLIENT_SECRET = BUNDLED_CLIENT_SECRET +M.BUNDLED_CLIENT_ID = BUNDLED_CLIENT_ID + +M.google_client = M.new({ + name = 'google', + scope = 'https://www.googleapis.com/auth/tasks' .. ' https://www.googleapis.com/auth/calendar', + port = 18392, + config_key = 'google', +}) return M diff --git a/plugin/pending.lua b/plugin/pending.lua index f6ed6bb..cba4916 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -167,7 +167,7 @@ end, { nargs = '*', complete = function(arg_lead, cmd_line) local pending = require('pending') - local subcmds = { 'add', 'archive', 'due', 'edit', 'filter', 'undo' } + local subcmds = { 'add', 'archive', 'auth', 'due', 'edit', 'filter', 'undo' } for _, b in ipairs(pending.sync_backends()) do table.insert(subcmds, b) end diff --git a/spec/sync_spec.lua b/spec/sync_spec.lua index 20a85c1..a491dd3 100644 --- a/spec/sync_spec.lua +++ b/spec/sync_spec.lua @@ -73,15 +73,14 @@ describe('sync', function() assert.is_true(called) end) - it('routes auth action', function() + it('routes auth command', function() local called = false - local gcal = require('pending.sync.gcal') - local orig_auth = gcal.auth - gcal.auth = function() + local orig_auth = pending.auth + pending.auth = function() called = true end - pending.command('gcal auth') - gcal.auth = orig_auth + pending.command('auth') + pending.auth = orig_auth assert.is_true(called) end) end) @@ -102,11 +101,6 @@ describe('sync', function() assert.are.equal('gcal', gcal.name) end) - it('has auth function', function() - local gcal = require('pending.sync.gcal') - assert.are.equal('function', type(gcal.auth)) - end) - it('has push function', function() local gcal = require('pending.sync.gcal') assert.are.equal('function', type(gcal.push))