Initial commit
This commit is contained in:
190
skills/google-workspace/SKILL.md
Normal file
190
skills/google-workspace/SKILL.md
Normal file
@@ -0,0 +1,190 @@
|
||||
---
|
||||
name: google-workspace
|
||||
description: Unified Google Workspace integration for managing email, calendar, files, and communication across multiple accounts
|
||||
triggers:
|
||||
# Gmail
|
||||
- "check email"
|
||||
- "read email"
|
||||
- "send email"
|
||||
- "search email"
|
||||
- "list emails"
|
||||
- "unread emails"
|
||||
- "inbox"
|
||||
# Calendar
|
||||
- "check calendar"
|
||||
- "schedule meeting"
|
||||
- "create event"
|
||||
- "what's on my calendar"
|
||||
- "free time"
|
||||
- "upcoming meetings"
|
||||
# Drive
|
||||
- "find file"
|
||||
- "search drive"
|
||||
- "list documents"
|
||||
- "open document"
|
||||
- "create document"
|
||||
# Docs/Sheets/Slides
|
||||
- "create doc"
|
||||
- "create spreadsheet"
|
||||
- "create presentation"
|
||||
- "edit document"
|
||||
# Tasks
|
||||
- "google tasks"
|
||||
- "task list"
|
||||
# Chat
|
||||
- "send chat"
|
||||
- "check chat"
|
||||
allowed-tools: Read, Bash
|
||||
version: 0.1.0
|
||||
---
|
||||
|
||||
# Google Workspace Skill
|
||||
|
||||
## Overview
|
||||
|
||||
Unified Google Workspace integration for managing email, calendar, files, and communication across three accounts:
|
||||
|
||||
| Alias | Purpose | Email |
|
||||
|-------|---------|-------|
|
||||
| `psd` | Work | PSD district email |
|
||||
| `kh` | Personal | Personal Gmail |
|
||||
| `hrg` | Business | Consulting & real estate |
|
||||
|
||||
## Account Selection
|
||||
|
||||
### Explicit
|
||||
- "check my **psd** email"
|
||||
- "send email from **hrg**"
|
||||
- "**kh** calendar for tomorrow"
|
||||
|
||||
### Inferred
|
||||
Geoffrey will infer the appropriate account from context:
|
||||
- Work-related → `psd`
|
||||
- Personal matters → `kh`
|
||||
- Business/real estate → `hrg`
|
||||
|
||||
## Available Operations
|
||||
|
||||
### Gmail
|
||||
|
||||
| Script | Description | Example |
|
||||
|--------|-------------|---------|
|
||||
| `list_messages.js` | List inbox, unread, by label | "show unread psd emails" |
|
||||
| `read_message.js` | Get full message content | "read that email" |
|
||||
| `send_message.js` | Compose and send | "send email to John about..." |
|
||||
| `search_messages.js` | Search with Gmail operators | "find emails from Sarah last week" |
|
||||
|
||||
### Calendar
|
||||
|
||||
| Script | Description | Example |
|
||||
|--------|-------------|---------|
|
||||
| `list_events.js` | Get upcoming events | "what's on my calendar today" |
|
||||
| `create_event.js` | Schedule new events | "schedule meeting tomorrow at 2pm" |
|
||||
| `update_event.js` | Modify existing events | "move that meeting to 3pm" |
|
||||
| `search_events.js` | Find by criteria | "find meetings with Mike" |
|
||||
|
||||
### Drive
|
||||
|
||||
| Script | Description | Example |
|
||||
|--------|-------------|---------|
|
||||
| `list_files.js` | Browse/search files | "find budget spreadsheet" |
|
||||
| `read_file.js` | Get file content | "show me that document" |
|
||||
| `create_file.js` | Create new docs/sheets | "create a new spreadsheet" |
|
||||
| `upload_file.js` | Upload local file | "upload this to drive" |
|
||||
|
||||
### Tasks
|
||||
|
||||
| Script | Description | Example |
|
||||
|--------|-------------|---------|
|
||||
| `list_tasks.js` | Get task lists | "show my google tasks" |
|
||||
| `create_task.js` | Add new task | "add task to google tasks" |
|
||||
| `complete_task.js` | Mark done | "complete that task" |
|
||||
|
||||
### Chat
|
||||
|
||||
| Script | Description | Example |
|
||||
|--------|-------------|---------|
|
||||
| `list_spaces.js` | Get available spaces | "list chat spaces" |
|
||||
| `send_message.js` | Post to space | "send message to team chat" |
|
||||
| `read_messages.js` | Get chat history | "show recent chat messages" |
|
||||
|
||||
## Usage Patterns
|
||||
|
||||
### Running Scripts
|
||||
|
||||
All scripts use the token_manager for authentication:
|
||||
|
||||
```javascript
|
||||
const { getAuthClient } = require('./auth/token_manager');
|
||||
|
||||
async function main() {
|
||||
const account = process.argv[2] || 'psd';
|
||||
const auth = await getAuthClient(account);
|
||||
|
||||
// Use auth with Google API
|
||||
const gmail = google.gmail({ version: 'v1', auth });
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Output Format
|
||||
|
||||
All scripts return JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"account": "psd",
|
||||
"data": { ... },
|
||||
"metadata": {
|
||||
"timestamp": "2024-01-15T10:30:00Z",
|
||||
"count": 5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Token expired",
|
||||
"account": "psd",
|
||||
"action": "Run: node token_manager.js refresh psd"
|
||||
}
|
||||
```
|
||||
|
||||
## Setup Required
|
||||
|
||||
Before using this skill:
|
||||
|
||||
1. Complete Google Cloud Console setup (see `auth/GOOGLE_CLOUD_SETUP.md`)
|
||||
2. Add credentials to `~/Library/Mobile Documents/com~apple~CloudDocs/Geoffrey/secrets/.env`
|
||||
3. Authenticate all three accounts
|
||||
4. For PSD account: allowlist OAuth app in Google Admin
|
||||
|
||||
## Cross-Account Operations
|
||||
|
||||
Some operations work across accounts:
|
||||
- "Forward this to my personal email"
|
||||
- "Copy this file to my work drive"
|
||||
- "Add to both calendars"
|
||||
|
||||
## Gmail Search Operators
|
||||
|
||||
Support standard Gmail search:
|
||||
- `from:` - sender
|
||||
- `to:` - recipient
|
||||
- `subject:` - subject line
|
||||
- `has:attachment` - with attachments
|
||||
- `after:` / `before:` - date range
|
||||
- `is:unread` - unread only
|
||||
- `label:` - by label
|
||||
|
||||
Example: "search psd email for `from:boss@psd.org after:2024-01-01 has:attachment`"
|
||||
|
||||
## Notes
|
||||
|
||||
- Access tokens expire after 1 hour (auto-refreshed)
|
||||
- Refresh tokens don't expire unless revoked
|
||||
- All API calls are rate-limited by Google
|
||||
- Keep API has limited availability (may not be enabled)
|
||||
180
skills/google-workspace/auth/GOOGLE_CLOUD_SETUP.md
Normal file
180
skills/google-workspace/auth/GOOGLE_CLOUD_SETUP.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# Google Cloud Console Setup Guide
|
||||
|
||||
## Step 1: Create Project
|
||||
|
||||
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
|
||||
2. Sign in with your **consulting account** (this will own the OAuth app)
|
||||
3. Click **Select a project** → **New Project**
|
||||
4. Name: `Geoffrey Google Workspace`
|
||||
5. Click **Create**
|
||||
|
||||
## Step 2: Enable APIs
|
||||
|
||||
Navigate to **APIs & Services → Library** and enable each:
|
||||
|
||||
### Required APIs
|
||||
- [ ] Gmail API
|
||||
- [ ] Google Calendar API
|
||||
- [ ] Google Drive API
|
||||
- [ ] Google Docs API
|
||||
- [ ] Google Sheets API
|
||||
- [ ] Google Slides API
|
||||
- [ ] Google Forms API
|
||||
- [ ] Google Chat API
|
||||
- [ ] Tasks API
|
||||
- [ ] People API (for user info)
|
||||
|
||||
### Optional APIs
|
||||
- [ ] Google Keep API (limited availability)
|
||||
- [ ] Gemini API (if using AI features)
|
||||
|
||||
**Tip:** Search for each API name and click **Enable**
|
||||
|
||||
## Step 3: Configure OAuth Consent Screen
|
||||
|
||||
1. Go to **APIs & Services → OAuth consent screen**
|
||||
2. Select **External** (unless all accounts are in same org)
|
||||
3. Click **Create**
|
||||
|
||||
### App Information
|
||||
- App name: `Geoffrey`
|
||||
- User support email: Your consulting email
|
||||
- Developer contact: Your consulting email
|
||||
|
||||
### Scopes
|
||||
Click **Add or Remove Scopes** and add:
|
||||
|
||||
```
|
||||
https://www.googleapis.com/auth/gmail.modify
|
||||
https://www.googleapis.com/auth/calendar
|
||||
https://www.googleapis.com/auth/drive
|
||||
https://www.googleapis.com/auth/documents
|
||||
https://www.googleapis.com/auth/spreadsheets
|
||||
https://www.googleapis.com/auth/presentations
|
||||
https://www.googleapis.com/auth/forms.body
|
||||
https://www.googleapis.com/auth/chat.messages
|
||||
https://www.googleapis.com/auth/tasks
|
||||
https://www.googleapis.com/auth/userinfo.email
|
||||
```
|
||||
|
||||
### Test Users
|
||||
Add all three email addresses:
|
||||
- Your PSD email
|
||||
- Your personal email
|
||||
- Your consulting email
|
||||
|
||||
**Note:** While in "Testing" mode, only these users can authorize.
|
||||
|
||||
## Step 4: Create OAuth Credentials
|
||||
|
||||
1. Go to **APIs & Services → Credentials**
|
||||
2. Click **Create Credentials → OAuth client ID**
|
||||
3. Application type: **Desktop app**
|
||||
4. Name: `Geoffrey CLI`
|
||||
5. Click **Create**
|
||||
6. Copy the **Client ID** and **Client Secret**
|
||||
7. Add to your iCloud secrets `.env` file:
|
||||
```
|
||||
~/Library/Mobile Documents/com~apple~CloudDocs/Geoffrey/secrets/.env
|
||||
```
|
||||
|
||||
Add these lines:
|
||||
```
|
||||
GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
|
||||
GOOGLE_CLIENT_SECRET=your-client-secret
|
||||
```
|
||||
|
||||
## Step 5: PSD Domain Allowlisting
|
||||
|
||||
Your PSD Google Workspace likely restricts third-party apps. To allow Geoffrey:
|
||||
|
||||
### If You're a Google Admin:
|
||||
|
||||
1. Go to [Google Admin Console](https://admin.google.com)
|
||||
2. Navigate to **Security → Access and data control → API controls**
|
||||
3. Click **Manage Third-Party App Access**
|
||||
4. Click **Add app → OAuth App Name Or Client ID**
|
||||
5. Enter your OAuth Client ID (from Step 4)
|
||||
6. Select **Trusted** access
|
||||
|
||||
### If You Need IT Approval:
|
||||
|
||||
Send this to your IT team:
|
||||
|
||||
```
|
||||
Subject: Request to Allow OAuth App for Personal Productivity Tool
|
||||
|
||||
I need to allowlist a personal productivity app that integrates with Google Workspace.
|
||||
|
||||
OAuth Client ID: [YOUR_CLIENT_ID_HERE]
|
||||
|
||||
Requested scopes:
|
||||
- Gmail (read/send)
|
||||
- Calendar (read/write)
|
||||
- Drive (read/write)
|
||||
- Docs/Sheets/Slides (read/write)
|
||||
- Tasks (read/write)
|
||||
|
||||
This is a local CLI tool that runs only on my machine.
|
||||
No data is sent to external servers.
|
||||
|
||||
Please add this client ID to the trusted apps list.
|
||||
```
|
||||
|
||||
## Step 6: Authenticate Each Account
|
||||
|
||||
Once credentials are in your .env:
|
||||
|
||||
```bash
|
||||
cd skills/google-workspace
|
||||
|
||||
# Install dependencies
|
||||
bun install
|
||||
|
||||
# Authenticate each account
|
||||
bun auth/oauth_setup.js psd # Will open browser, sign in with PSD account
|
||||
bun auth/oauth_setup.js kh # Will open browser, sign in with personal account
|
||||
bun auth/oauth_setup.js hrg # Will open browser, sign in with consulting account
|
||||
|
||||
# After each auth, store the tokens (copy the JSON output from oauth_setup)
|
||||
bun auth/token_manager.js store psd '<tokens-json-output>'
|
||||
bun auth/token_manager.js store kh '<tokens-json-output>'
|
||||
bun auth/token_manager.js store hrg '<tokens-json-output>'
|
||||
```
|
||||
|
||||
## Step 7: Verify Setup
|
||||
|
||||
```bash
|
||||
# List stored accounts
|
||||
bun auth/token_manager.js list
|
||||
|
||||
# Test token retrieval
|
||||
bun auth/token_manager.js get psd
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Access blocked: This app's request is invalid"
|
||||
- Check that redirect URI matches: `http://localhost:3000/oauth2callback`
|
||||
- Verify OAuth consent screen is configured
|
||||
|
||||
### "Access denied" for PSD account
|
||||
- App needs to be allowlisted in PSD Google Admin
|
||||
- Contact IT with the client ID
|
||||
|
||||
### "Refresh token is null"
|
||||
- Delete the app from your Google account's connected apps
|
||||
- Re-run oauth_setup.js with the account
|
||||
- The `prompt: 'consent'` should force a new refresh token
|
||||
|
||||
### Token expires quickly
|
||||
- Access tokens last 1 hour
|
||||
- token_manager.js auto-refreshes using the refresh token
|
||||
- Refresh tokens don't expire unless revoked
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Credentials stored in iCloud secrets `.env` (synced, but local to your devices)
|
||||
- Tokens stored in macOS Keychain (encrypted)
|
||||
- Each account has its own isolated tokens
|
||||
- Revoke access anytime from Google Account → Security → Third-party apps
|
||||
213
skills/google-workspace/auth/oauth_setup.js
Normal file
213
skills/google-workspace/auth/oauth_setup.js
Normal file
@@ -0,0 +1,213 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* OAuth2 Setup Script for Google Workspace
|
||||
*
|
||||
* Usage: node oauth_setup.js <account-alias>
|
||||
* Example: node oauth_setup.js psd
|
||||
*
|
||||
* This script:
|
||||
* 1. Starts a local server to receive OAuth callback
|
||||
* 2. Opens browser to Google consent page
|
||||
* 3. Exchanges auth code for tokens
|
||||
* 4. Outputs tokens as JSON (to be stored by token_manager.js)
|
||||
*
|
||||
* Prerequisites:
|
||||
* - .env file with GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET
|
||||
* - npm install googleapis open dotenv
|
||||
*/
|
||||
|
||||
const { google } = require('googleapis');
|
||||
const http = require('http');
|
||||
const url = require('url');
|
||||
const openModule = require('open');
|
||||
const open = openModule.default || openModule;
|
||||
const path = require('path');
|
||||
|
||||
// Load environment variables from iCloud secrets
|
||||
const os = require('os');
|
||||
const ENV_PATH = path.join(
|
||||
os.homedir(),
|
||||
'Library/Mobile Documents/com~apple~CloudDocs/Geoffrey/secrets/.env'
|
||||
);
|
||||
require('dotenv').config({ path: ENV_PATH });
|
||||
|
||||
// Configuration
|
||||
const REDIRECT_PORT = process.env.OAUTH_REDIRECT_PORT || 3000;
|
||||
const REDIRECT_URI = `http://localhost:${REDIRECT_PORT}/oauth2callback`;
|
||||
|
||||
// Full scope list for complete Google Workspace access
|
||||
const SCOPES = [
|
||||
// Gmail
|
||||
'https://www.googleapis.com/auth/gmail.readonly',
|
||||
'https://www.googleapis.com/auth/gmail.send',
|
||||
'https://www.googleapis.com/auth/gmail.compose',
|
||||
'https://www.googleapis.com/auth/gmail.modify',
|
||||
'https://www.googleapis.com/auth/gmail.labels',
|
||||
|
||||
// Calendar
|
||||
'https://www.googleapis.com/auth/calendar',
|
||||
'https://www.googleapis.com/auth/calendar.events',
|
||||
|
||||
// Drive
|
||||
'https://www.googleapis.com/auth/drive',
|
||||
'https://www.googleapis.com/auth/drive.file',
|
||||
|
||||
// Docs, Sheets, Slides
|
||||
'https://www.googleapis.com/auth/documents',
|
||||
'https://www.googleapis.com/auth/spreadsheets',
|
||||
'https://www.googleapis.com/auth/presentations',
|
||||
|
||||
// Forms
|
||||
'https://www.googleapis.com/auth/forms.body',
|
||||
'https://www.googleapis.com/auth/forms.responses.readonly',
|
||||
|
||||
// Chat
|
||||
'https://www.googleapis.com/auth/chat.spaces',
|
||||
'https://www.googleapis.com/auth/chat.messages',
|
||||
'https://www.googleapis.com/auth/chat.memberships.readonly',
|
||||
|
||||
// Tasks
|
||||
'https://www.googleapis.com/auth/tasks',
|
||||
|
||||
// Keep (Note: Keep API has limited availability)
|
||||
// 'https://www.googleapis.com/auth/keep',
|
||||
|
||||
// User info (to identify which account)
|
||||
'https://www.googleapis.com/auth/userinfo.email',
|
||||
'https://www.googleapis.com/auth/userinfo.profile',
|
||||
|
||||
// Admin Directory (to look up user names)
|
||||
'https://www.googleapis.com/auth/admin.directory.user.readonly',
|
||||
];
|
||||
|
||||
async function main() {
|
||||
const accountAlias = process.argv[2];
|
||||
|
||||
if (!accountAlias) {
|
||||
console.error(JSON.stringify({
|
||||
error: 'Missing account alias',
|
||||
usage: 'node oauth_setup.js <account-alias>',
|
||||
example: 'node oauth_setup.js psd'
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Load client credentials from environment
|
||||
const client_id = process.env.GOOGLE_CLIENT_ID;
|
||||
const client_secret = process.env.GOOGLE_CLIENT_SECRET;
|
||||
|
||||
if (!client_id || !client_secret) {
|
||||
console.error(JSON.stringify({
|
||||
error: 'Missing credentials in .env',
|
||||
instructions: [
|
||||
'1. Go to Google Cloud Console',
|
||||
'2. Create OAuth 2.0 Client ID (Desktop app)',
|
||||
'3. Copy client ID and secret to skills/google-workspace/.env',
|
||||
'4. See .env.example for format'
|
||||
]
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Create OAuth2 client
|
||||
const oauth2Client = new google.auth.OAuth2(
|
||||
client_id,
|
||||
client_secret,
|
||||
REDIRECT_URI
|
||||
);
|
||||
|
||||
// Generate auth URL
|
||||
const authUrl = oauth2Client.generateAuthUrl({
|
||||
access_type: 'offline',
|
||||
scope: SCOPES,
|
||||
prompt: 'consent', // Force to get refresh token
|
||||
});
|
||||
|
||||
// Start local server to receive callback
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
const parsedUrl = url.parse(req.url, true);
|
||||
|
||||
if (parsedUrl.pathname === '/oauth2callback') {
|
||||
const code = parsedUrl.query.code;
|
||||
|
||||
if (!code) {
|
||||
res.writeHead(400);
|
||||
res.end('Missing authorization code');
|
||||
return;
|
||||
}
|
||||
|
||||
// Exchange code for tokens
|
||||
const { tokens } = await oauth2Client.getToken(code);
|
||||
|
||||
// Get user info to confirm which account
|
||||
oauth2Client.setCredentials(tokens);
|
||||
const oauth2 = google.oauth2({ version: 'v2', auth: oauth2Client });
|
||||
const userInfo = await oauth2.userinfo.get();
|
||||
|
||||
// Send success response to browser
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end(`
|
||||
<html>
|
||||
<body style="font-family: sans-serif; padding: 40px; text-align: center;">
|
||||
<h1>✅ Authorization Successful</h1>
|
||||
<p>Account: <strong>${userInfo.data.email}</strong></p>
|
||||
<p>Alias: <strong>${accountAlias}</strong></p>
|
||||
<p>You can close this window.</p>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
// Output tokens as JSON
|
||||
console.log(JSON.stringify({
|
||||
success: true,
|
||||
account: accountAlias,
|
||||
email: userInfo.data.email,
|
||||
tokens: {
|
||||
access_token: tokens.access_token,
|
||||
refresh_token: tokens.refresh_token,
|
||||
token_type: tokens.token_type,
|
||||
expiry_date: tokens.expiry_date,
|
||||
}
|
||||
}, null, 2));
|
||||
|
||||
// Close server
|
||||
server.close();
|
||||
}
|
||||
} catch (error) {
|
||||
res.writeHead(500);
|
||||
res.end('Authorization failed');
|
||||
console.error(JSON.stringify({
|
||||
error: 'Token exchange failed',
|
||||
message: error.message
|
||||
}));
|
||||
server.close();
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(REDIRECT_PORT, async () => {
|
||||
console.error(`Opening browser for ${accountAlias} account authorization...`);
|
||||
console.error(`If browser doesn't open, visit: ${authUrl}`);
|
||||
await open(authUrl);
|
||||
});
|
||||
|
||||
// Timeout after 5 minutes
|
||||
setTimeout(() => {
|
||||
console.error(JSON.stringify({
|
||||
error: 'Authorization timeout',
|
||||
message: 'No response received within 5 minutes'
|
||||
}));
|
||||
server.close();
|
||||
process.exit(1);
|
||||
}, 5 * 60 * 1000);
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error(JSON.stringify({
|
||||
error: 'Setup failed',
|
||||
message: error.message
|
||||
}));
|
||||
process.exit(1);
|
||||
});
|
||||
236
skills/google-workspace/auth/token_manager.js
Normal file
236
skills/google-workspace/auth/token_manager.js
Normal file
@@ -0,0 +1,236 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Token Manager for Google Workspace
|
||||
*
|
||||
* Stores and retrieves OAuth tokens from macOS Keychain.
|
||||
*
|
||||
* Usage:
|
||||
* Store: node token_manager.js store <account> '<tokens-json>'
|
||||
* Retrieve: node token_manager.js get <account>
|
||||
* Refresh: node token_manager.js refresh <account>
|
||||
* List: node token_manager.js list
|
||||
* Delete: node token_manager.js delete <account>
|
||||
*
|
||||
* Examples:
|
||||
* node token_manager.js store psd '{"access_token":"...","refresh_token":"..."}'
|
||||
* node token_manager.js get psd
|
||||
* node token_manager.js refresh psd
|
||||
*/
|
||||
|
||||
const { execSync } = require('child_process');
|
||||
const { google } = require('googleapis');
|
||||
const path = require('path');
|
||||
|
||||
// Load environment variables from iCloud secrets
|
||||
const os = require('os');
|
||||
const ENV_PATH = path.join(
|
||||
os.homedir(),
|
||||
'Library/Mobile Documents/com~apple~CloudDocs/Geoffrey/secrets/.env'
|
||||
);
|
||||
require('dotenv').config({ path: ENV_PATH });
|
||||
|
||||
const SERVICE_NAME = 'geoffrey-google-workspace';
|
||||
|
||||
/**
|
||||
* Store tokens in Keychain
|
||||
*/
|
||||
function storeTokens(account, tokens) {
|
||||
const tokenString = typeof tokens === 'string' ? tokens : JSON.stringify(tokens);
|
||||
|
||||
// Delete existing entry if present
|
||||
try {
|
||||
execSync(
|
||||
`security delete-generic-password -s "${SERVICE_NAME}" -a "${account}" 2>/dev/null`,
|
||||
{ encoding: 'utf8' }
|
||||
);
|
||||
} catch (e) {
|
||||
// Entry might not exist, that's fine
|
||||
}
|
||||
|
||||
// Add new entry
|
||||
try {
|
||||
execSync(
|
||||
`security add-generic-password -s "${SERVICE_NAME}" -a "${account}" -w '${tokenString.replace(/'/g, "'\\''")}' -U`,
|
||||
{ encoding: 'utf8' }
|
||||
);
|
||||
return { success: true, account, message: 'Tokens stored in Keychain' };
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to store tokens: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve tokens from Keychain
|
||||
*/
|
||||
function getTokens(account) {
|
||||
try {
|
||||
const result = execSync(
|
||||
`security find-generic-password -s "${SERVICE_NAME}" -a "${account}" -w`,
|
||||
{ encoding: 'utf8' }
|
||||
).trim();
|
||||
return JSON.parse(result);
|
||||
} catch (error) {
|
||||
throw new Error(`No tokens found for account: ${account}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh access token using refresh token
|
||||
*/
|
||||
async function refreshTokens(account) {
|
||||
// Get current tokens
|
||||
const tokens = getTokens(account);
|
||||
|
||||
if (!tokens.refresh_token) {
|
||||
throw new Error('No refresh token available');
|
||||
}
|
||||
|
||||
// Load credentials from environment
|
||||
const client_id = process.env.GOOGLE_CLIENT_ID;
|
||||
const client_secret = process.env.GOOGLE_CLIENT_SECRET;
|
||||
|
||||
if (!client_id || !client_secret) {
|
||||
throw new Error('Missing GOOGLE_CLIENT_ID or GOOGLE_CLIENT_SECRET in .env');
|
||||
}
|
||||
|
||||
// Create OAuth2 client and refresh
|
||||
const oauth2Client = new google.auth.OAuth2(client_id, client_secret);
|
||||
oauth2Client.setCredentials(tokens);
|
||||
|
||||
const { credentials: newTokens } = await oauth2Client.refreshAccessToken();
|
||||
|
||||
// Store updated tokens (keep refresh_token if not returned)
|
||||
const updatedTokens = {
|
||||
...tokens,
|
||||
access_token: newTokens.access_token,
|
||||
expiry_date: newTokens.expiry_date,
|
||||
};
|
||||
|
||||
storeTokens(account, updatedTokens);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
account,
|
||||
access_token: newTokens.access_token,
|
||||
expiry_date: newTokens.expiry_date
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List all stored accounts
|
||||
*/
|
||||
function listAccounts() {
|
||||
try {
|
||||
const result = execSync(
|
||||
`security dump-keychain | grep -A 4 '"svce"<blob>="${SERVICE_NAME}"' | grep '"acct"' | sed 's/.*="\\(.*\\)"/\\1/'`,
|
||||
{ encoding: 'utf8' }
|
||||
);
|
||||
const accounts = result.trim().split('\n').filter(Boolean);
|
||||
return { accounts };
|
||||
} catch (error) {
|
||||
return { accounts: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete tokens for an account
|
||||
*/
|
||||
function deleteTokens(account) {
|
||||
try {
|
||||
execSync(
|
||||
`security delete-generic-password -s "${SERVICE_NAME}" -a "${account}"`,
|
||||
{ encoding: 'utf8' }
|
||||
);
|
||||
return { success: true, account, message: 'Tokens deleted' };
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to delete tokens for ${account}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an authenticated OAuth2 client for an account
|
||||
*/
|
||||
async function getAuthClient(account) {
|
||||
const tokens = getTokens(account);
|
||||
|
||||
const client_id = process.env.GOOGLE_CLIENT_ID;
|
||||
const client_secret = process.env.GOOGLE_CLIENT_SECRET;
|
||||
|
||||
if (!client_id || !client_secret) {
|
||||
throw new Error('Missing GOOGLE_CLIENT_ID or GOOGLE_CLIENT_SECRET in .env');
|
||||
}
|
||||
|
||||
const oauth2Client = new google.auth.OAuth2(client_id, client_secret);
|
||||
oauth2Client.setCredentials(tokens);
|
||||
|
||||
// Check if token needs refresh (expires in next 5 minutes)
|
||||
if (tokens.expiry_date && tokens.expiry_date < Date.now() + 5 * 60 * 1000) {
|
||||
const refreshed = await refreshTokens(account);
|
||||
oauth2Client.setCredentials({
|
||||
...tokens,
|
||||
access_token: refreshed.access_token
|
||||
});
|
||||
}
|
||||
|
||||
return oauth2Client;
|
||||
}
|
||||
|
||||
// CLI interface
|
||||
async function main() {
|
||||
const [command, account, data] = process.argv.slice(2);
|
||||
|
||||
try {
|
||||
let result;
|
||||
|
||||
switch (command) {
|
||||
case 'store':
|
||||
if (!account || !data) {
|
||||
throw new Error('Usage: token_manager.js store <account> <tokens-json>');
|
||||
}
|
||||
result = storeTokens(account, JSON.parse(data));
|
||||
break;
|
||||
|
||||
case 'get':
|
||||
if (!account) {
|
||||
throw new Error('Usage: token_manager.js get <account>');
|
||||
}
|
||||
result = getTokens(account);
|
||||
break;
|
||||
|
||||
case 'refresh':
|
||||
if (!account) {
|
||||
throw new Error('Usage: token_manager.js refresh <account>');
|
||||
}
|
||||
result = await refreshTokens(account);
|
||||
break;
|
||||
|
||||
case 'list':
|
||||
result = listAccounts();
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
if (!account) {
|
||||
throw new Error('Usage: token_manager.js delete <account>');
|
||||
}
|
||||
result = deleteTokens(account);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown command: ${command}\nCommands: store, get, refresh, list, delete`);
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} catch (error) {
|
||||
console.error(JSON.stringify({ error: error.message }));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Only run CLI if called directly
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
// Export for use by other scripts
|
||||
module.exports = { getAuthClient, getTokens, refreshTokens, storeTokens };
|
||||
182
skills/google-workspace/bun.lock
Normal file
182
skills/google-workspace/bun.lock
Normal file
@@ -0,0 +1,182 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "geoffrey-google-workspace",
|
||||
"dependencies": {
|
||||
"dotenv": "^16.3.1",
|
||||
"googleapis": "^128.0.0",
|
||||
"open": "^9.1.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||
|
||||
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||
|
||||
"big-integer": ["big-integer@1.6.52", "", {}, "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="],
|
||||
|
||||
"bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
|
||||
|
||||
"bplist-parser": ["bplist-parser@0.2.0", "", { "dependencies": { "big-integer": "^1.6.44" } }, "sha512-z0M+byMThzQmD9NILRniCUXYsYpjwnlO8N5uCFaCqIOpqRsJCrQL9NK3JsD67CN5a08nF5oIL2bD6loTdHOuKw=="],
|
||||
|
||||
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
|
||||
|
||||
"bundle-name": ["bundle-name@3.0.0", "", { "dependencies": { "run-applescript": "^5.0.0" } }, "sha512-PKA4BeSvBpQKQ8iPOGCSiell+N8P+Tf1DlwqmYhpe2gAhKPHn8EYOxVT+ShuGmhg8lN8XiSlS80yiExKXrURlw=="],
|
||||
|
||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||
|
||||
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"default-browser": ["default-browser@4.0.0", "", { "dependencies": { "bundle-name": "^3.0.0", "default-browser-id": "^3.0.0", "execa": "^7.1.1", "titleize": "^3.0.0" } }, "sha512-wX5pXO1+BrhMkSbROFsyxUm0i/cJEScyNhA4PPxc41ICuv05ZZB/MX28s8aZx6xjmatvebIapF6hLEKEcpneUA=="],
|
||||
|
||||
"default-browser-id": ["default-browser-id@3.0.0", "", { "dependencies": { "bplist-parser": "^0.2.0", "untildify": "^4.0.0" } }, "sha512-OZ1y3y0SqSICtE8DE4S8YOE9UZOJ8wO16fKWVP5J1Qz42kV9jcnMVFrEE/noXb/ss3Q4pZIH79kxofzyNNtUNA=="],
|
||||
|
||||
"define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="],
|
||||
|
||||
"dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
|
||||
|
||||
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||
|
||||
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
|
||||
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
|
||||
|
||||
"execa": ["execa@7.2.0", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.1", "human-signals": "^4.3.0", "is-stream": "^3.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^5.1.0", "onetime": "^6.0.0", "signal-exit": "^3.0.7", "strip-final-newline": "^3.0.0" } }, "sha512-UduyVP7TLB5IcAQl+OzLyLcS/l32W/GLg+AhHJ+ow40FOk2U3SAllPwR44v4vmdFwIWqpdwxxpQbF1n5ta9seA=="],
|
||||
|
||||
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"gaxios": ["gaxios@6.7.1", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "is-stream": "^2.0.0", "node-fetch": "^2.6.9", "uuid": "^9.0.1" } }, "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ=="],
|
||||
|
||||
"gcp-metadata": ["gcp-metadata@6.1.1", "", { "dependencies": { "gaxios": "^6.1.1", "google-logging-utils": "^0.0.2", "json-bigint": "^1.0.0" } }, "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A=="],
|
||||
|
||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
|
||||
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||
|
||||
"get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="],
|
||||
|
||||
"google-auth-library": ["google-auth-library@9.15.1", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^6.1.1", "gcp-metadata": "^6.1.0", "gtoken": "^7.0.0", "jws": "^4.0.0" } }, "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng=="],
|
||||
|
||||
"google-logging-utils": ["google-logging-utils@0.0.2", "", {}, "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ=="],
|
||||
|
||||
"googleapis": ["googleapis@128.0.0", "", { "dependencies": { "google-auth-library": "^9.0.0", "googleapis-common": "^7.0.0" } }, "sha512-+sLtVYNazcxaSD84N6rihVX4QiGoqRdnlz2SwmQQkadF31XonDfy4ufk3maMg27+FiySrH0rd7V8p+YJG6cknA=="],
|
||||
|
||||
"googleapis-common": ["googleapis-common@7.2.0", "", { "dependencies": { "extend": "^3.0.2", "gaxios": "^6.0.3", "google-auth-library": "^9.7.0", "qs": "^6.7.0", "url-template": "^2.0.8", "uuid": "^9.0.0" } }, "sha512-/fhDZEJZvOV3X5jmD+fKxMqma5q2Q9nZNSF3kn1F18tpxmA86BcTxAGBQdM0N89Z3bEaIs+HVznSmFJEAmMTjA=="],
|
||||
|
||||
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"gtoken": ["gtoken@7.1.0", "", { "dependencies": { "gaxios": "^6.0.0", "jws": "^4.0.0" } }, "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw=="],
|
||||
|
||||
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||
|
||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||
|
||||
"human-signals": ["human-signals@4.3.1", "", {}, "sha512-nZXjEF2nbo7lIw3mgYjItAfgQXog3OjJogSbKa2CQIIvSGWcKgeJnQlNXip6NglNzYH45nSRiEVimMvYL8DDqQ=="],
|
||||
|
||||
"is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
|
||||
|
||||
"is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="],
|
||||
|
||||
"is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
|
||||
|
||||
"is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
|
||||
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="],
|
||||
|
||||
"jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
|
||||
|
||||
"jws": ["jws@4.0.0", "", { "dependencies": { "jwa": "^2.0.0", "safe-buffer": "^5.0.1" } }, "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
"merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
|
||||
|
||||
"mimic-fn": ["mimic-fn@4.0.0", "", {}, "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
|
||||
|
||||
"npm-run-path": ["npm-run-path@5.3.0", "", { "dependencies": { "path-key": "^4.0.0" } }, "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ=="],
|
||||
|
||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||
|
||||
"onetime": ["onetime@6.0.0", "", { "dependencies": { "mimic-fn": "^4.0.0" } }, "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ=="],
|
||||
|
||||
"open": ["open@9.1.0", "", { "dependencies": { "default-browser": "^4.0.0", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "is-wsl": "^2.2.0" } }, "sha512-OS+QTnw1/4vrf+9hh1jc1jnYjzSG4ttTBB8UxOwAnInG3Uo4ssetzC1ihqaIHjLJnA5GGlRl6QlZXOTQhRBUvg=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="],
|
||||
|
||||
"run-applescript": ["run-applescript@5.0.0", "", { "dependencies": { "execa": "^5.0.0" } }, "sha512-XcT5rBksx1QdIhlFOCtgZkB99ZEouFZ1E2Kc2LHqNW13U3/74YGdkQRmThTwxy4QIyookibDKYZOPqX//6BlAg=="],
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
|
||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
||||
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
|
||||
|
||||
"side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
|
||||
|
||||
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
|
||||
|
||||
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
||||
|
||||
"signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
||||
|
||||
"strip-final-newline": ["strip-final-newline@3.0.0", "", {}, "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw=="],
|
||||
|
||||
"titleize": ["titleize@3.0.0", "", {}, "sha512-KxVu8EYHDPBdUYdKZdKtU2aj2XfEx9AfjXxE/Aj0vT06w2icA09Vus1rh6eSu1y01akYg6BjIK/hxyLJINoMLQ=="],
|
||||
|
||||
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
|
||||
|
||||
"untildify": ["untildify@4.0.0", "", {}, "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw=="],
|
||||
|
||||
"url-template": ["url-template@2.0.8", "", {}, "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw=="],
|
||||
|
||||
"uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="],
|
||||
|
||||
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
|
||||
|
||||
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"execa/is-stream": ["is-stream@3.0.0", "", {}, "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA=="],
|
||||
|
||||
"is-wsl/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
|
||||
|
||||
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
|
||||
|
||||
"run-applescript/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
|
||||
|
||||
"run-applescript/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
|
||||
|
||||
"run-applescript/execa/npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="],
|
||||
|
||||
"run-applescript/execa/onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
|
||||
|
||||
"run-applescript/execa/strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="],
|
||||
|
||||
"run-applescript/execa/onetime/mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
|
||||
}
|
||||
}
|
||||
163
skills/google-workspace/calendar/create_event.js
Normal file
163
skills/google-workspace/calendar/create_event.js
Normal file
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Create Calendar Event
|
||||
*
|
||||
* Usage: node create_event.js <account> --summary <title> --start <datetime> [options]
|
||||
*
|
||||
* Options:
|
||||
* --summary Event title (required)
|
||||
* --start Start datetime (required, ISO 8601 or natural)
|
||||
* --end End datetime (default: 1 hour after start)
|
||||
* --description Event description
|
||||
* --location Event location
|
||||
* --attendees Comma-separated email addresses
|
||||
* --all-day Create all-day event (use date format for start/end)
|
||||
*
|
||||
* Examples:
|
||||
* node create_event.js psd --summary "Team Meeting" --start "2024-01-15T14:00:00"
|
||||
* node create_event.js personal --summary "Dinner" --start "2024-01-15T18:00:00" --end "2024-01-15T20:00:00" --location "Restaurant"
|
||||
* node create_event.js consulting --summary "Client Call" --start "2024-01-15T10:00:00" --attendees "client@example.com"
|
||||
*/
|
||||
|
||||
const { google } = require('googleapis');
|
||||
const path = require('path');
|
||||
const { getAuthClient } = require(path.join(__dirname, '..', 'auth', 'token_manager'));
|
||||
|
||||
async function createEvent(account, options) {
|
||||
const auth = await getAuthClient(account);
|
||||
const calendar = google.calendar({ version: 'v3', auth });
|
||||
|
||||
// Parse start time
|
||||
const startDate = new Date(options.start);
|
||||
if (isNaN(startDate.getTime())) {
|
||||
throw new Error(`Invalid start date: ${options.start}`);
|
||||
}
|
||||
|
||||
// Calculate end time (default: 1 hour after start)
|
||||
let endDate;
|
||||
if (options.end) {
|
||||
endDate = new Date(options.end);
|
||||
if (isNaN(endDate.getTime())) {
|
||||
throw new Error(`Invalid end date: ${options.end}`);
|
||||
}
|
||||
} else {
|
||||
endDate = new Date(startDate);
|
||||
endDate.setHours(endDate.getHours() + 1);
|
||||
}
|
||||
|
||||
// Build event
|
||||
const event = {
|
||||
summary: options.summary,
|
||||
description: options.description,
|
||||
location: options.location,
|
||||
};
|
||||
|
||||
// Handle all-day vs timed events
|
||||
if (options.allDay) {
|
||||
event.start = { date: startDate.toISOString().split('T')[0] };
|
||||
event.end = { date: endDate.toISOString().split('T')[0] };
|
||||
} else {
|
||||
event.start = {
|
||||
dateTime: startDate.toISOString(),
|
||||
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
};
|
||||
event.end = {
|
||||
dateTime: endDate.toISOString(),
|
||||
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
};
|
||||
}
|
||||
|
||||
// Add attendees
|
||||
if (options.attendees) {
|
||||
event.attendees = options.attendees.split(',').map(email => ({
|
||||
email: email.trim(),
|
||||
}));
|
||||
}
|
||||
|
||||
const response = await calendar.events.insert({
|
||||
calendarId: 'primary',
|
||||
requestBody: event,
|
||||
sendUpdates: options.attendees ? 'all' : 'none',
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
account,
|
||||
event: {
|
||||
id: response.data.id,
|
||||
summary: response.data.summary,
|
||||
start: response.data.start.dateTime || response.data.start.date,
|
||||
end: response.data.end.dateTime || response.data.end.date,
|
||||
htmlLink: response.data.htmlLink,
|
||||
},
|
||||
metadata: {
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// CLI interface
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const account = args[0];
|
||||
|
||||
if (!account) {
|
||||
console.error(JSON.stringify({
|
||||
error: 'Missing account',
|
||||
usage: 'node create_event.js <account> --summary <title> --start <datetime> [options]'
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Parse options
|
||||
const options = {};
|
||||
for (let i = 1; i < args.length; i++) {
|
||||
switch (args[i]) {
|
||||
case '--summary':
|
||||
options.summary = args[++i];
|
||||
break;
|
||||
case '--start':
|
||||
options.start = args[++i];
|
||||
break;
|
||||
case '--end':
|
||||
options.end = args[++i];
|
||||
break;
|
||||
case '--description':
|
||||
options.description = args[++i];
|
||||
break;
|
||||
case '--location':
|
||||
options.location = args[++i];
|
||||
break;
|
||||
case '--attendees':
|
||||
options.attendees = args[++i];
|
||||
break;
|
||||
case '--all-day':
|
||||
options.allDay = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!options.summary || !options.start) {
|
||||
console.error(JSON.stringify({
|
||||
error: 'Missing required options: --summary, --start',
|
||||
usage: 'node create_event.js <account> --summary <title> --start <datetime>'
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await createEvent(account, options);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} catch (error) {
|
||||
console.error(JSON.stringify({
|
||||
error: error.message,
|
||||
account,
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
module.exports = { createEvent };
|
||||
124
skills/google-workspace/calendar/list_events.js
Normal file
124
skills/google-workspace/calendar/list_events.js
Normal file
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* List Calendar Events
|
||||
*
|
||||
* Usage: node list_events.js <account> [options]
|
||||
*
|
||||
* Options:
|
||||
* --days Number of days to look ahead (default: 7)
|
||||
* --today Show only today's events
|
||||
* --max Maximum events to return (default: 50)
|
||||
*
|
||||
* Examples:
|
||||
* node list_events.js psd
|
||||
* node list_events.js psd --today
|
||||
* node list_events.js personal --days 30
|
||||
*/
|
||||
|
||||
const { google } = require('googleapis');
|
||||
const path = require('path');
|
||||
const { getAuthClient } = require(path.join(__dirname, '..', 'auth', 'token_manager'));
|
||||
|
||||
async function listEvents(account, options = {}) {
|
||||
const auth = await getAuthClient(account);
|
||||
const calendar = google.calendar({ version: 'v3', auth });
|
||||
|
||||
// Calculate time range
|
||||
const now = new Date();
|
||||
const timeMin = new Date(now);
|
||||
timeMin.setHours(0, 0, 0, 0);
|
||||
|
||||
let timeMax;
|
||||
if (options.today) {
|
||||
timeMax = new Date(timeMin);
|
||||
timeMax.setDate(timeMax.getDate() + 1);
|
||||
} else {
|
||||
timeMax = new Date(timeMin);
|
||||
timeMax.setDate(timeMax.getDate() + (options.days || 7));
|
||||
}
|
||||
|
||||
const response = await calendar.events.list({
|
||||
calendarId: 'primary',
|
||||
timeMin: timeMin.toISOString(),
|
||||
timeMax: timeMax.toISOString(),
|
||||
maxResults: options.max || 50,
|
||||
singleEvents: true,
|
||||
orderBy: 'startTime',
|
||||
});
|
||||
|
||||
const events = (response.data.items || []).map(event => ({
|
||||
id: event.id,
|
||||
summary: event.summary,
|
||||
description: event.description,
|
||||
location: event.location,
|
||||
start: event.start.dateTime || event.start.date,
|
||||
end: event.end.dateTime || event.end.date,
|
||||
isAllDay: !event.start.dateTime,
|
||||
status: event.status,
|
||||
attendees: (event.attendees || []).map(a => ({
|
||||
email: a.email,
|
||||
name: a.displayName,
|
||||
responseStatus: a.responseStatus,
|
||||
})),
|
||||
hangoutLink: event.hangoutLink,
|
||||
htmlLink: event.htmlLink,
|
||||
}));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
account,
|
||||
events,
|
||||
metadata: {
|
||||
timestamp: new Date().toISOString(),
|
||||
count: events.length,
|
||||
timeMin: timeMin.toISOString(),
|
||||
timeMax: timeMax.toISOString(),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// CLI interface
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const account = args[0];
|
||||
|
||||
if (!account) {
|
||||
console.error(JSON.stringify({
|
||||
error: 'Missing account',
|
||||
usage: 'node list_events.js <account> [--today] [--days N] [--max N]'
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Parse options
|
||||
const options = {};
|
||||
for (let i = 1; i < args.length; i++) {
|
||||
switch (args[i]) {
|
||||
case '--today':
|
||||
options.today = true;
|
||||
break;
|
||||
case '--days':
|
||||
options.days = parseInt(args[++i], 10);
|
||||
break;
|
||||
case '--max':
|
||||
options.max = parseInt(args[++i], 10);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await listEvents(account, options);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} catch (error) {
|
||||
console.error(JSON.stringify({
|
||||
error: error.message,
|
||||
account,
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
module.exports = { listEvents };
|
||||
124
skills/google-workspace/calendar/search_events.js
Normal file
124
skills/google-workspace/calendar/search_events.js
Normal file
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Search Calendar Events
|
||||
*
|
||||
* Usage: node search_events.js <account> <query> [options]
|
||||
*
|
||||
* Options:
|
||||
* --days Number of days to search (default: 30)
|
||||
* --max Maximum events to return (default: 50)
|
||||
*
|
||||
* Examples:
|
||||
* node search_events.js psd "team meeting"
|
||||
* node search_events.js personal "dinner" --days 60
|
||||
* node search_events.js consulting "client"
|
||||
*/
|
||||
|
||||
const { google } = require('googleapis');
|
||||
const path = require('path');
|
||||
const { getAuthClient } = require(path.join(__dirname, '..', 'auth', 'token_manager'));
|
||||
|
||||
async function searchEvents(account, query, options = {}) {
|
||||
const auth = await getAuthClient(account);
|
||||
const calendar = google.calendar({ version: 'v3', auth });
|
||||
|
||||
// Calculate time range
|
||||
const now = new Date();
|
||||
const timeMin = new Date(now);
|
||||
timeMin.setDate(timeMin.getDate() - (options.days || 30)); // Look back too
|
||||
|
||||
const timeMax = new Date(now);
|
||||
timeMax.setDate(timeMax.getDate() + (options.days || 30));
|
||||
|
||||
const response = await calendar.events.list({
|
||||
calendarId: 'primary',
|
||||
q: query,
|
||||
timeMin: timeMin.toISOString(),
|
||||
timeMax: timeMax.toISOString(),
|
||||
maxResults: options.max || 50,
|
||||
singleEvents: true,
|
||||
orderBy: 'startTime',
|
||||
});
|
||||
|
||||
const events = (response.data.items || []).map(event => ({
|
||||
id: event.id,
|
||||
summary: event.summary,
|
||||
description: event.description,
|
||||
location: event.location,
|
||||
start: event.start.dateTime || event.start.date,
|
||||
end: event.end.dateTime || event.end.date,
|
||||
isAllDay: !event.start.dateTime,
|
||||
attendees: (event.attendees || []).map(a => ({
|
||||
email: a.email,
|
||||
name: a.displayName,
|
||||
responseStatus: a.responseStatus,
|
||||
})),
|
||||
htmlLink: event.htmlLink,
|
||||
}));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
account,
|
||||
query,
|
||||
events,
|
||||
metadata: {
|
||||
timestamp: new Date().toISOString(),
|
||||
count: events.length,
|
||||
timeMin: timeMin.toISOString(),
|
||||
timeMax: timeMax.toISOString(),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// CLI interface
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const account = args[0];
|
||||
|
||||
if (!account) {
|
||||
console.error(JSON.stringify({
|
||||
error: 'Missing account',
|
||||
usage: 'node search_events.js <account> <query> [--days N] [--max N]'
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Find query (first non-flag argument after account)
|
||||
let query = '';
|
||||
const options = {};
|
||||
|
||||
for (let i = 1; i < args.length; i++) {
|
||||
if (args[i] === '--days') {
|
||||
options.days = parseInt(args[++i], 10);
|
||||
} else if (args[i] === '--max') {
|
||||
options.max = parseInt(args[++i], 10);
|
||||
} else if (!args[i].startsWith('--')) {
|
||||
query = args[i];
|
||||
}
|
||||
}
|
||||
|
||||
if (!query) {
|
||||
console.error(JSON.stringify({
|
||||
error: 'Missing query',
|
||||
usage: 'node search_events.js <account> <query>'
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await searchEvents(account, query, options);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} catch (error) {
|
||||
console.error(JSON.stringify({
|
||||
error: error.message,
|
||||
account,
|
||||
query,
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
module.exports = { searchEvents };
|
||||
137
skills/google-workspace/calendar/update_event.js
Normal file
137
skills/google-workspace/calendar/update_event.js
Normal file
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Update Calendar Event
|
||||
*
|
||||
* Usage: node update_event.js <account> <event-id> [options]
|
||||
*
|
||||
* Options:
|
||||
* --summary New event title
|
||||
* --start New start datetime
|
||||
* --end New end datetime
|
||||
* --description New description
|
||||
* --location New location
|
||||
*
|
||||
* Examples:
|
||||
* node update_event.js psd abc123 --start "2024-01-15T15:00:00"
|
||||
* node update_event.js personal def456 --summary "Updated Meeting" --location "New Room"
|
||||
*/
|
||||
|
||||
const { google } = require('googleapis');
|
||||
const path = require('path');
|
||||
const { getAuthClient } = require(path.join(__dirname, '..', 'auth', 'token_manager'));
|
||||
|
||||
async function updateEvent(account, eventId, options) {
|
||||
const auth = await getAuthClient(account);
|
||||
const calendar = google.calendar({ version: 'v3', auth });
|
||||
|
||||
// Get existing event
|
||||
const existing = await calendar.events.get({
|
||||
calendarId: 'primary',
|
||||
eventId,
|
||||
});
|
||||
|
||||
const event = existing.data;
|
||||
|
||||
// Update fields
|
||||
if (options.summary) event.summary = options.summary;
|
||||
if (options.description) event.description = options.description;
|
||||
if (options.location) event.location = options.location;
|
||||
|
||||
if (options.start) {
|
||||
const startDate = new Date(options.start);
|
||||
if (isNaN(startDate.getTime())) {
|
||||
throw new Error(`Invalid start date: ${options.start}`);
|
||||
}
|
||||
event.start = {
|
||||
dateTime: startDate.toISOString(),
|
||||
timeZone: event.start.timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
};
|
||||
}
|
||||
|
||||
if (options.end) {
|
||||
const endDate = new Date(options.end);
|
||||
if (isNaN(endDate.getTime())) {
|
||||
throw new Error(`Invalid end date: ${options.end}`);
|
||||
}
|
||||
event.end = {
|
||||
dateTime: endDate.toISOString(),
|
||||
timeZone: event.end.timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
};
|
||||
}
|
||||
|
||||
const response = await calendar.events.update({
|
||||
calendarId: 'primary',
|
||||
eventId,
|
||||
requestBody: event,
|
||||
sendUpdates: event.attendees ? 'all' : 'none',
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
account,
|
||||
event: {
|
||||
id: response.data.id,
|
||||
summary: response.data.summary,
|
||||
start: response.data.start.dateTime || response.data.start.date,
|
||||
end: response.data.end.dateTime || response.data.end.date,
|
||||
htmlLink: response.data.htmlLink,
|
||||
},
|
||||
metadata: {
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// CLI interface
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const account = args[0];
|
||||
const eventId = args[1];
|
||||
|
||||
if (!account || !eventId) {
|
||||
console.error(JSON.stringify({
|
||||
error: 'Missing arguments',
|
||||
usage: 'node update_event.js <account> <event-id> [options]'
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Parse options
|
||||
const options = {};
|
||||
for (let i = 2; i < args.length; i++) {
|
||||
switch (args[i]) {
|
||||
case '--summary':
|
||||
options.summary = args[++i];
|
||||
break;
|
||||
case '--start':
|
||||
options.start = args[++i];
|
||||
break;
|
||||
case '--end':
|
||||
options.end = args[++i];
|
||||
break;
|
||||
case '--description':
|
||||
options.description = args[++i];
|
||||
break;
|
||||
case '--location':
|
||||
options.location = args[++i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await updateEvent(account, eventId, options);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} catch (error) {
|
||||
console.error(JSON.stringify({
|
||||
error: error.message,
|
||||
account,
|
||||
eventId,
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
module.exports = { updateEvent };
|
||||
91
skills/google-workspace/chat/list_spaces.js
Normal file
91
skills/google-workspace/chat/list_spaces.js
Normal file
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* List Google Chat Spaces
|
||||
*
|
||||
* Usage: node list_spaces.js <account>
|
||||
*
|
||||
* Examples:
|
||||
* node list_spaces.js psd
|
||||
* node list_spaces.js kh
|
||||
*/
|
||||
|
||||
const { google } = require('googleapis');
|
||||
const path = require('path');
|
||||
const { getAuthClient } = require(path.join(__dirname, '..', 'auth', 'token_manager'));
|
||||
|
||||
async function listSpaces(account) {
|
||||
const auth = await getAuthClient(account);
|
||||
const chat = google.chat({ version: 'v1', auth });
|
||||
|
||||
const response = await chat.spaces.list({
|
||||
pageSize: 100,
|
||||
});
|
||||
|
||||
// Get read state for each space to find unreads
|
||||
const spacesWithState = await Promise.all(
|
||||
(response.data.spaces || []).map(async space => {
|
||||
try {
|
||||
// Get space read state
|
||||
const stateResponse = await chat.spaces.spaceReadState.get({
|
||||
name: `${space.name}/spaceReadState`,
|
||||
});
|
||||
return {
|
||||
name: space.name,
|
||||
displayName: space.displayName,
|
||||
type: space.type,
|
||||
spaceType: space.spaceType,
|
||||
lastReadTime: stateResponse.data.lastReadTime,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
name: space.name,
|
||||
displayName: space.displayName,
|
||||
type: space.type,
|
||||
spaceType: space.spaceType,
|
||||
lastReadTime: null,
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const spaces = spacesWithState;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
account,
|
||||
spaces,
|
||||
metadata: {
|
||||
timestamp: new Date().toISOString(),
|
||||
count: spaces.length,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// CLI interface
|
||||
async function main() {
|
||||
const account = process.argv[2];
|
||||
|
||||
if (!account) {
|
||||
console.error(JSON.stringify({
|
||||
error: 'Missing account',
|
||||
usage: 'node list_spaces.js <account>'
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await listSpaces(account);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} catch (error) {
|
||||
console.error(JSON.stringify({
|
||||
error: error.message,
|
||||
account,
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
module.exports = { listSpaces };
|
||||
128
skills/google-workspace/chat/read_messages.js
Normal file
128
skills/google-workspace/chat/read_messages.js
Normal file
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Read Google Chat Messages from a Space
|
||||
*
|
||||
* Usage: node read_messages.js <account> <space-name> [options]
|
||||
*
|
||||
* Options:
|
||||
* --max Maximum messages to return (default: 50)
|
||||
*
|
||||
* Examples:
|
||||
* node read_messages.js psd spaces/AAAA1234567
|
||||
* node read_messages.js psd spaces/AAAA1234567 --max 100
|
||||
*/
|
||||
|
||||
const { google } = require('googleapis');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const { getAuthClient } = require(path.join(__dirname, '..', 'auth', 'token_manager'));
|
||||
|
||||
// Load user mapping from iCloud preferences
|
||||
let allUserMappings = {};
|
||||
const mappingPath = path.join(
|
||||
os.homedir(),
|
||||
'Library/Mobile Documents/com~apple~CloudDocs/Geoffrey/knowledge/chat_user_mapping.json'
|
||||
);
|
||||
if (fs.existsSync(mappingPath)) {
|
||||
try {
|
||||
allUserMappings = JSON.parse(fs.readFileSync(mappingPath, 'utf8'));
|
||||
} catch (e) {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
async function readMessages(account, spaceName, options = {}) {
|
||||
const auth = await getAuthClient(account);
|
||||
const chat = google.chat({ version: 'v1', auth });
|
||||
|
||||
// Get space members to build userId -> displayName mapping
|
||||
const membersResponse = await chat.spaces.members.list({
|
||||
parent: spaceName,
|
||||
pageSize: 100,
|
||||
});
|
||||
|
||||
const userNameMap = {};
|
||||
for (const membership of (membersResponse.data.memberships || [])) {
|
||||
if (membership.member && membership.member.name) {
|
||||
const userId = membership.member.name;
|
||||
userNameMap[userId] = membership.member.displayName || userId;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await chat.spaces.messages.list({
|
||||
parent: spaceName,
|
||||
pageSize: options.max || 50,
|
||||
orderBy: 'createTime desc',
|
||||
});
|
||||
|
||||
// Get account-specific user mapping
|
||||
const userMapping = allUserMappings[account] || {};
|
||||
|
||||
const messages = (response.data.messages || []).map(msg => {
|
||||
const senderId = msg.sender?.name;
|
||||
const bareId = senderId?.replace('users/', '');
|
||||
const senderName = userMapping[bareId] || userNameMap[senderId] || msg.sender?.displayName || senderId;
|
||||
|
||||
return {
|
||||
name: msg.name,
|
||||
sender: senderName,
|
||||
senderId: bareId,
|
||||
senderType: msg.sender?.type,
|
||||
text: msg.text,
|
||||
createTime: msg.createTime,
|
||||
threadName: msg.thread?.name,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
account,
|
||||
spaceName,
|
||||
messages,
|
||||
metadata: {
|
||||
timestamp: new Date().toISOString(),
|
||||
count: messages.length,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// CLI interface
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const account = args[0];
|
||||
const spaceName = args[1];
|
||||
|
||||
if (!account || !spaceName) {
|
||||
console.error(JSON.stringify({
|
||||
error: 'Missing arguments',
|
||||
usage: 'node read_messages.js <account> <space-name> [--max N]'
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Parse options
|
||||
const options = {};
|
||||
for (let i = 2; i < args.length; i++) {
|
||||
if (args[i] === '--max') {
|
||||
options.max = parseInt(args[++i], 10);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await readMessages(account, spaceName, options);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} catch (error) {
|
||||
console.error(JSON.stringify({
|
||||
error: error.message,
|
||||
account,
|
||||
spaceName,
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
module.exports = { readMessages };
|
||||
106
skills/google-workspace/chat/send_message.js
Normal file
106
skills/google-workspace/chat/send_message.js
Normal file
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Send Google Chat Message
|
||||
*
|
||||
* Usage: node send_message.js <account> <space-name> --text <message>
|
||||
*
|
||||
* Options:
|
||||
* --text Message text (required)
|
||||
* --thread Thread name to reply to (optional)
|
||||
*
|
||||
* Examples:
|
||||
* node send_message.js psd spaces/AAAA1234567 --text "Hello team!"
|
||||
* node send_message.js psd spaces/AAAA1234567 --text "Reply" --thread spaces/AAAA/threads/BBBB
|
||||
*/
|
||||
|
||||
const { google } = require('googleapis');
|
||||
const path = require('path');
|
||||
const { getAuthClient } = require(path.join(__dirname, '..', 'auth', 'token_manager'));
|
||||
|
||||
async function sendMessage(account, spaceName, options) {
|
||||
const auth = await getAuthClient(account);
|
||||
const chat = google.chat({ version: 'v1', auth });
|
||||
|
||||
const requestBody = {
|
||||
text: options.text,
|
||||
};
|
||||
|
||||
if (options.thread) {
|
||||
requestBody.thread = {
|
||||
name: options.thread,
|
||||
};
|
||||
}
|
||||
|
||||
const response = await chat.spaces.messages.create({
|
||||
parent: spaceName,
|
||||
requestBody,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
account,
|
||||
message: {
|
||||
name: response.data.name,
|
||||
text: response.data.text,
|
||||
createTime: response.data.createTime,
|
||||
space: spaceName,
|
||||
thread: response.data.thread?.name,
|
||||
},
|
||||
metadata: {
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// CLI interface
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const account = args[0];
|
||||
const spaceName = args[1];
|
||||
|
||||
if (!account || !spaceName) {
|
||||
console.error(JSON.stringify({
|
||||
error: 'Missing arguments',
|
||||
usage: 'node send_message.js <account> <space-name> --text <message>'
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Parse options
|
||||
const options = {};
|
||||
for (let i = 2; i < args.length; i++) {
|
||||
switch (args[i]) {
|
||||
case '--text':
|
||||
options.text = args[++i];
|
||||
break;
|
||||
case '--thread':
|
||||
options.thread = args[++i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!options.text) {
|
||||
console.error(JSON.stringify({
|
||||
error: 'Missing --text option',
|
||||
usage: 'node send_message.js <account> <space-name> --text <message>'
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await sendMessage(account, spaceName, options);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} catch (error) {
|
||||
console.error(JSON.stringify({
|
||||
error: error.message,
|
||||
account,
|
||||
spaceName,
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
module.exports = { sendMessage };
|
||||
155
skills/google-workspace/docs/create_doc.js
Normal file
155
skills/google-workspace/docs/create_doc.js
Normal file
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Create Google Doc with Markdown Formatting
|
||||
*
|
||||
* Usage: bun create_doc.js <account> --title <title> [options]
|
||||
*
|
||||
* Options:
|
||||
* --title Document title (required)
|
||||
* --content Markdown content
|
||||
* --folder Subfolder in Geoffrey (Research, Notes, Reports, Travel)
|
||||
* --raw Don't apply markdown formatting (plain text)
|
||||
*
|
||||
* Examples:
|
||||
* bun create_doc.js hrg --title "Meeting Notes" --folder Notes
|
||||
* bun create_doc.js psd --title "Report" --content "# Header\n\nContent" --folder Research
|
||||
*/
|
||||
|
||||
const { google } = require('googleapis');
|
||||
const path = require('path');
|
||||
const { getAuthClient } = require(path.join(__dirname, '..', 'auth', 'token_manager'));
|
||||
const { ensureGeoffreyFolders } = require(path.join(__dirname, '..', 'utils', 'folder_manager'));
|
||||
const { insertFormattedContent } = require(path.join(__dirname, '..', 'utils', 'markdown_to_docs'));
|
||||
|
||||
async function createDoc(account, options) {
|
||||
const auth = await getAuthClient(account);
|
||||
const docs = google.docs({ version: 'v1', auth });
|
||||
const drive = google.drive({ version: 'v3', auth });
|
||||
|
||||
// Get or create Geoffrey folder structure
|
||||
let folderId = null;
|
||||
if (options.folder) {
|
||||
const folderResult = await ensureGeoffreyFolders(account, options.folder);
|
||||
folderId = folderResult.targetFolder;
|
||||
}
|
||||
|
||||
// Create the document
|
||||
const createResponse = await docs.documents.create({
|
||||
requestBody: {
|
||||
title: options.title,
|
||||
},
|
||||
});
|
||||
|
||||
const documentId = createResponse.data.documentId;
|
||||
|
||||
// Move to Geoffrey folder if specified
|
||||
if (folderId) {
|
||||
// Get current parents
|
||||
const file = await drive.files.get({
|
||||
fileId: documentId,
|
||||
fields: 'parents',
|
||||
});
|
||||
|
||||
// Move to Geoffrey folder
|
||||
await drive.files.update({
|
||||
fileId: documentId,
|
||||
addParents: folderId,
|
||||
removeParents: file.data.parents.join(','),
|
||||
fields: 'id, parents',
|
||||
});
|
||||
}
|
||||
|
||||
// Add content if provided
|
||||
let formattingInfo = null;
|
||||
if (options.content) {
|
||||
if (options.raw) {
|
||||
// Plain text insertion
|
||||
await docs.documents.batchUpdate({
|
||||
documentId,
|
||||
requestBody: {
|
||||
requests: [{
|
||||
insertText: {
|
||||
location: { index: 1 },
|
||||
text: options.content,
|
||||
},
|
||||
}],
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Markdown formatted insertion
|
||||
formattingInfo = await insertFormattedContent(docs, documentId, options.content);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
account,
|
||||
document: {
|
||||
id: documentId,
|
||||
title: createResponse.data.title,
|
||||
url: `https://docs.google.com/document/d/${documentId}/edit`,
|
||||
folder: options.folder || 'root',
|
||||
},
|
||||
formatting: formattingInfo,
|
||||
metadata: {
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// CLI interface
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const account = args[0];
|
||||
|
||||
if (!account) {
|
||||
console.error(JSON.stringify({
|
||||
error: 'Missing account',
|
||||
usage: 'bun create_doc.js <account> --title <title> [--content <markdown>] [--folder <subfolder>]'
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Parse options
|
||||
const options = {};
|
||||
for (let i = 1; i < args.length; i++) {
|
||||
switch (args[i]) {
|
||||
case '--title':
|
||||
options.title = args[++i];
|
||||
break;
|
||||
case '--content':
|
||||
options.content = args[++i];
|
||||
break;
|
||||
case '--folder':
|
||||
options.folder = args[++i];
|
||||
break;
|
||||
case '--raw':
|
||||
options.raw = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!options.title) {
|
||||
console.error(JSON.stringify({
|
||||
error: 'Missing --title option',
|
||||
usage: 'bun create_doc.js <account> --title <title> [--content <markdown>] [--folder <subfolder>]'
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await createDoc(account, options);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} catch (error) {
|
||||
console.error(JSON.stringify({
|
||||
error: error.message,
|
||||
account,
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
module.exports = { createDoc };
|
||||
282
skills/google-workspace/docs/edit_doc.js
Normal file
282
skills/google-workspace/docs/edit_doc.js
Normal file
@@ -0,0 +1,282 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Edit Google Doc with Markdown Formatting
|
||||
*
|
||||
* Usage: bun edit_doc.js <account> <document-id> --append <markdown>
|
||||
* bun edit_doc.js <account> <document-id> --replace <old> --with <new>
|
||||
*
|
||||
* Options:
|
||||
* --raw Don't apply markdown formatting (plain text)
|
||||
*
|
||||
* Examples:
|
||||
* bun edit_doc.js psd DOC_ID --append "## New Section\n\nContent here"
|
||||
* bun edit_doc.js psd DOC_ID --append "Plain text" --raw
|
||||
*/
|
||||
|
||||
const { google } = require('googleapis');
|
||||
const path = require('path');
|
||||
const { getAuthClient } = require(path.join(__dirname, '..', 'auth', 'token_manager'));
|
||||
const { parseMarkdown } = require(path.join(__dirname, '..', 'utils', 'markdown_to_docs'));
|
||||
|
||||
/**
|
||||
* Parse markdown with custom start index (for appending)
|
||||
*/
|
||||
function parseMarkdownAtIndex(markdown, startIndex) {
|
||||
const requests = [];
|
||||
let plainText = '';
|
||||
let currentIndex = startIndex;
|
||||
|
||||
const lines = markdown.split('\n');
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
let line = lines[i];
|
||||
|
||||
// Horizontal rule
|
||||
if (line.match(/^---+$/)) {
|
||||
const ruleLine = '─'.repeat(50) + '\n';
|
||||
plainText += ruleLine;
|
||||
currentIndex += ruleLine.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Headers
|
||||
const headerMatch = line.match(/^(#{1,6})\s+(.+)$/);
|
||||
if (headerMatch) {
|
||||
const level = headerMatch[1].length;
|
||||
const headerContent = headerMatch[2];
|
||||
const { plainText: processedText, requests: inlineRequests } =
|
||||
processInlineFormattingAtIndex(headerContent, currentIndex);
|
||||
|
||||
const fullLine = processedText + '\n';
|
||||
const headingStyle = level === 1 ? 'HEADING_1' :
|
||||
level === 2 ? 'HEADING_2' :
|
||||
level === 3 ? 'HEADING_3' : 'HEADING_4';
|
||||
|
||||
requests.push({
|
||||
updateParagraphStyle: {
|
||||
range: { startIndex: currentIndex, endIndex: currentIndex + fullLine.length },
|
||||
paragraphStyle: { namedStyleType: headingStyle },
|
||||
fields: 'namedStyleType',
|
||||
},
|
||||
});
|
||||
requests.push(...inlineRequests);
|
||||
plainText += fullLine;
|
||||
currentIndex += fullLine.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Bullet lists
|
||||
const bulletMatch = line.match(/^[-*]\s+(.+)$/);
|
||||
if (bulletMatch) {
|
||||
const { plainText: processedText, requests: inlineRequests } =
|
||||
processInlineFormattingAtIndex(bulletMatch[1], currentIndex + 2);
|
||||
const fullLine = '• ' + processedText + '\n';
|
||||
plainText += fullLine;
|
||||
requests.push(...inlineRequests);
|
||||
currentIndex += fullLine.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Numbered lists
|
||||
const numberedMatch = line.match(/^(\d+)\.\s+(.+)$/);
|
||||
if (numberedMatch) {
|
||||
const prefix = numberedMatch[1] + '. ';
|
||||
const { plainText: processedText, requests: inlineRequests } =
|
||||
processInlineFormattingAtIndex(numberedMatch[2], currentIndex + prefix.length);
|
||||
const fullLine = prefix + processedText + '\n';
|
||||
plainText += fullLine;
|
||||
requests.push(...inlineRequests);
|
||||
currentIndex += fullLine.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular line
|
||||
const { plainText: processedText, requests: inlineRequests } =
|
||||
processInlineFormattingAtIndex(line, currentIndex);
|
||||
const fullLine = processedText + '\n';
|
||||
plainText += fullLine;
|
||||
requests.push(...inlineRequests);
|
||||
currentIndex += fullLine.length;
|
||||
}
|
||||
|
||||
return { text: plainText, requests };
|
||||
}
|
||||
|
||||
function processInlineFormattingAtIndex(text, startIndex) {
|
||||
const requests = [];
|
||||
let plainText = '';
|
||||
let charIndex = startIndex;
|
||||
let remaining = text;
|
||||
|
||||
while (remaining.length > 0) {
|
||||
const boldMatch = remaining.match(/^\*\*(.+?)\*\*/);
|
||||
if (boldMatch) {
|
||||
plainText += boldMatch[1];
|
||||
requests.push({
|
||||
updateTextStyle: {
|
||||
range: { startIndex: charIndex, endIndex: charIndex + boldMatch[1].length },
|
||||
textStyle: { bold: true },
|
||||
fields: 'bold',
|
||||
},
|
||||
});
|
||||
charIndex += boldMatch[1].length;
|
||||
remaining = remaining.substring(boldMatch[0].length);
|
||||
continue;
|
||||
}
|
||||
|
||||
const linkMatch = remaining.match(/^\[([^\]]+)\]\(([^)]+)\)/);
|
||||
if (linkMatch) {
|
||||
plainText += linkMatch[1];
|
||||
requests.push({
|
||||
updateTextStyle: {
|
||||
range: { startIndex: charIndex, endIndex: charIndex + linkMatch[1].length },
|
||||
textStyle: { link: { url: linkMatch[2] } },
|
||||
fields: 'link',
|
||||
},
|
||||
});
|
||||
charIndex += linkMatch[1].length;
|
||||
remaining = remaining.substring(linkMatch[0].length);
|
||||
continue;
|
||||
}
|
||||
|
||||
plainText += remaining[0];
|
||||
charIndex++;
|
||||
remaining = remaining.substring(1);
|
||||
}
|
||||
|
||||
return { plainText, requests };
|
||||
}
|
||||
|
||||
async function editDoc(account, documentId, options) {
|
||||
const auth = await getAuthClient(account);
|
||||
const docs = google.docs({ version: 'v1', auth });
|
||||
|
||||
if (options.append) {
|
||||
// Get document to find end index
|
||||
const doc = await docs.documents.get({ documentId });
|
||||
const endIndex = doc.data.body.content[doc.data.body.content.length - 1].endIndex - 1;
|
||||
|
||||
if (options.raw) {
|
||||
// Plain text append
|
||||
await docs.documents.batchUpdate({
|
||||
documentId,
|
||||
requestBody: {
|
||||
requests: [{
|
||||
insertText: {
|
||||
location: { index: endIndex },
|
||||
text: '\n' + options.append,
|
||||
},
|
||||
}],
|
||||
},
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
account,
|
||||
documentId,
|
||||
updates: 1,
|
||||
metadata: { timestamp: new Date().toISOString() }
|
||||
};
|
||||
}
|
||||
|
||||
// Formatted append
|
||||
const { text, requests } = parseMarkdownAtIndex(options.append, endIndex + 1);
|
||||
|
||||
// Insert text
|
||||
await docs.documents.batchUpdate({
|
||||
documentId,
|
||||
requestBody: {
|
||||
requests: [{
|
||||
insertText: {
|
||||
location: { index: endIndex },
|
||||
text: '\n' + text,
|
||||
},
|
||||
}],
|
||||
},
|
||||
});
|
||||
|
||||
// Apply formatting
|
||||
if (requests.length > 0) {
|
||||
await docs.documents.batchUpdate({
|
||||
documentId,
|
||||
requestBody: { requests },
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
account,
|
||||
documentId,
|
||||
updates: 1 + requests.length,
|
||||
formatting: { textLength: text.length, formattingRequests: requests.length },
|
||||
metadata: { timestamp: new Date().toISOString() }
|
||||
};
|
||||
}
|
||||
|
||||
if (options.replace && options.with) {
|
||||
await docs.documents.batchUpdate({
|
||||
documentId,
|
||||
requestBody: {
|
||||
requests: [{
|
||||
replaceAllText: {
|
||||
containsText: { text: options.replace, matchCase: true },
|
||||
replaceText: options.with,
|
||||
},
|
||||
}],
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
account,
|
||||
documentId,
|
||||
updates: 1,
|
||||
metadata: { timestamp: new Date().toISOString() }
|
||||
};
|
||||
}
|
||||
|
||||
return { success: false, error: 'No edit operation specified' };
|
||||
}
|
||||
|
||||
// CLI interface
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const account = args[0];
|
||||
const documentId = args[1];
|
||||
|
||||
if (!account || !documentId) {
|
||||
console.error(JSON.stringify({
|
||||
error: 'Missing arguments',
|
||||
usage: 'bun edit_doc.js <account> <document-id> --append <markdown>'
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const options = {};
|
||||
for (let i = 2; i < args.length; i++) {
|
||||
switch (args[i]) {
|
||||
case '--append': options.append = args[++i]; break;
|
||||
case '--replace': options.replace = args[++i]; break;
|
||||
case '--with': options.with = args[++i]; break;
|
||||
case '--raw': options.raw = true; break;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await editDoc(account, documentId, options);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} catch (error) {
|
||||
console.error(JSON.stringify({
|
||||
error: error.message,
|
||||
account,
|
||||
documentId,
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = { editDoc };
|
||||
87
skills/google-workspace/docs/read_doc.js
Normal file
87
skills/google-workspace/docs/read_doc.js
Normal file
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Read Google Doc Content
|
||||
*
|
||||
* Usage: node read_doc.js <account> <document-id>
|
||||
*
|
||||
* Examples:
|
||||
* node read_doc.js psd 1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms
|
||||
*/
|
||||
|
||||
const { google } = require('googleapis');
|
||||
const path = require('path');
|
||||
const { getAuthClient } = require(path.join(__dirname, '..', 'auth', 'token_manager'));
|
||||
|
||||
async function readDoc(account, documentId) {
|
||||
const auth = await getAuthClient(account);
|
||||
const docs = google.docs({ version: 'v1', auth });
|
||||
|
||||
const response = await docs.documents.get({
|
||||
documentId,
|
||||
});
|
||||
|
||||
const doc = response.data;
|
||||
|
||||
// Extract text content from document body
|
||||
let textContent = '';
|
||||
if (doc.body && doc.body.content) {
|
||||
for (const element of doc.body.content) {
|
||||
if (element.paragraph && element.paragraph.elements) {
|
||||
for (const textElement of element.paragraph.elements) {
|
||||
if (textElement.textRun && textElement.textRun.content) {
|
||||
textContent += textElement.textRun.content;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (element.table) {
|
||||
textContent += '[TABLE]\n';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
account,
|
||||
document: {
|
||||
id: doc.documentId,
|
||||
title: doc.title,
|
||||
textContent: textContent.trim(),
|
||||
revisionId: doc.revisionId,
|
||||
},
|
||||
metadata: {
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// CLI interface
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const account = args[0];
|
||||
const documentId = args[1];
|
||||
|
||||
if (!account || !documentId) {
|
||||
console.error(JSON.stringify({
|
||||
error: 'Missing arguments',
|
||||
usage: 'node read_doc.js <account> <document-id>'
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await readDoc(account, documentId);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} catch (error) {
|
||||
console.error(JSON.stringify({
|
||||
error: error.message,
|
||||
account,
|
||||
documentId,
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
module.exports = { readDoc };
|
||||
140
skills/google-workspace/drive/create_file.js
Normal file
140
skills/google-workspace/drive/create_file.js
Normal file
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Create Drive File (Google Docs, Sheets, Slides)
|
||||
*
|
||||
* Usage: node create_file.js <account> --name <name> --type <type> [options]
|
||||
*
|
||||
* Options:
|
||||
* --name File name (required)
|
||||
* --type File type: doc, sheet, slide (required)
|
||||
* --content Initial content (for docs)
|
||||
* --folder Parent folder ID
|
||||
*
|
||||
* Examples:
|
||||
* node create_file.js psd --name "Meeting Notes" --type doc
|
||||
* node create_file.js personal --name "Budget 2024" --type sheet
|
||||
* node create_file.js consulting --name "Proposal" --type slide --folder 1abc123
|
||||
*/
|
||||
|
||||
const { google } = require('googleapis');
|
||||
const path = require('path');
|
||||
const { getAuthClient } = require(path.join(__dirname, '..', 'auth', 'token_manager'));
|
||||
|
||||
const MIME_TYPES = {
|
||||
doc: 'application/vnd.google-apps.document',
|
||||
sheet: 'application/vnd.google-apps.spreadsheet',
|
||||
slide: 'application/vnd.google-apps.presentation',
|
||||
};
|
||||
|
||||
async function createFile(account, options) {
|
||||
const auth = await getAuthClient(account);
|
||||
const drive = google.drive({ version: 'v3', auth });
|
||||
|
||||
const mimeType = MIME_TYPES[options.type];
|
||||
if (!mimeType) {
|
||||
throw new Error(`Invalid type: ${options.type}. Use: doc, sheet, slide`);
|
||||
}
|
||||
|
||||
const fileMetadata = {
|
||||
name: options.name,
|
||||
mimeType,
|
||||
};
|
||||
|
||||
if (options.folder) {
|
||||
fileMetadata.parents = [options.folder];
|
||||
}
|
||||
|
||||
const response = await drive.files.create({
|
||||
requestBody: fileMetadata,
|
||||
fields: 'id, name, mimeType, webViewLink',
|
||||
});
|
||||
|
||||
const file = response.data;
|
||||
|
||||
// If content provided for doc, update it
|
||||
if (options.content && options.type === 'doc') {
|
||||
const docs = google.docs({ version: 'v1', auth });
|
||||
await docs.documents.batchUpdate({
|
||||
documentId: file.id,
|
||||
requestBody: {
|
||||
requests: [{
|
||||
insertText: {
|
||||
location: { index: 1 },
|
||||
text: options.content,
|
||||
}
|
||||
}]
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
account,
|
||||
file: {
|
||||
id: file.id,
|
||||
name: file.name,
|
||||
mimeType: file.mimeType,
|
||||
webLink: file.webViewLink,
|
||||
},
|
||||
metadata: {
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// CLI interface
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const account = args[0];
|
||||
|
||||
if (!account) {
|
||||
console.error(JSON.stringify({
|
||||
error: 'Missing account',
|
||||
usage: 'node create_file.js <account> --name <name> --type doc|sheet|slide [options]'
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Parse options
|
||||
const options = {};
|
||||
for (let i = 1; i < args.length; i++) {
|
||||
switch (args[i]) {
|
||||
case '--name':
|
||||
options.name = args[++i];
|
||||
break;
|
||||
case '--type':
|
||||
options.type = args[++i];
|
||||
break;
|
||||
case '--content':
|
||||
options.content = args[++i];
|
||||
break;
|
||||
case '--folder':
|
||||
options.folder = args[++i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!options.name || !options.type) {
|
||||
console.error(JSON.stringify({
|
||||
error: 'Missing required options: --name, --type',
|
||||
usage: 'node create_file.js <account> --name <name> --type doc|sheet|slide'
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await createFile(account, options);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} catch (error) {
|
||||
console.error(JSON.stringify({
|
||||
error: error.message,
|
||||
account,
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
module.exports = { createFile };
|
||||
92
skills/google-workspace/drive/download_file.js
Normal file
92
skills/google-workspace/drive/download_file.js
Normal file
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Download Drive File
|
||||
*
|
||||
* Downloads a file from Google Drive to local filesystem.
|
||||
*
|
||||
* Usage: bun download_file.js <account> <fileId> <outputPath>
|
||||
*/
|
||||
|
||||
const { google } = require('googleapis');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { getAuthClient } = require(path.join(__dirname, '..', 'auth', 'token_manager'));
|
||||
|
||||
async function downloadFile(account, fileId, outputPath) {
|
||||
const auth = await getAuthClient(account);
|
||||
const drive = google.drive({ version: 'v3', auth });
|
||||
|
||||
// Get file metadata first
|
||||
const fileInfo = await drive.files.get({
|
||||
fileId,
|
||||
fields: 'name, mimeType',
|
||||
supportsAllDrives: true
|
||||
});
|
||||
|
||||
// Download file content
|
||||
const response = await drive.files.get(
|
||||
{ fileId, alt: 'media', supportsAllDrives: true },
|
||||
{ responseType: 'stream' }
|
||||
);
|
||||
|
||||
// Ensure output directory exists
|
||||
const outputDir = path.dirname(outputPath);
|
||||
if (!fs.existsSync(outputDir)) {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Write to file
|
||||
const dest = fs.createWriteStream(outputPath);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
response.data
|
||||
.on('end', () => {
|
||||
resolve({
|
||||
success: true,
|
||||
account,
|
||||
file: {
|
||||
id: fileId,
|
||||
name: fileInfo.data.name,
|
||||
mimeType: fileInfo.data.mimeType,
|
||||
outputPath
|
||||
}
|
||||
});
|
||||
})
|
||||
.on('error', err => {
|
||||
reject(err);
|
||||
})
|
||||
.pipe(dest);
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const account = args[0];
|
||||
const fileId = args[1];
|
||||
const outputPath = args[2];
|
||||
|
||||
if (!account || !fileId || !outputPath) {
|
||||
console.error(JSON.stringify({
|
||||
error: 'Missing arguments',
|
||||
usage: 'bun download_file.js <account> <fileId> <outputPath>'
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await downloadFile(account, fileId, outputPath);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} catch (error) {
|
||||
console.error(JSON.stringify({
|
||||
error: error.message,
|
||||
account,
|
||||
fileId
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
module.exports = { downloadFile };
|
||||
133
skills/google-workspace/drive/list_files.js
Normal file
133
skills/google-workspace/drive/list_files.js
Normal file
@@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* List Drive Files
|
||||
*
|
||||
* Usage: node list_files.js <account> [options]
|
||||
*
|
||||
* Options:
|
||||
* --query Search query (Drive search syntax)
|
||||
* --type File type: doc, sheet, slide, pdf, folder
|
||||
* --max Maximum files to return (default: 20)
|
||||
* --folder Folder ID to list contents of
|
||||
*
|
||||
* Examples:
|
||||
* node list_files.js psd
|
||||
* node list_files.js psd --query "budget"
|
||||
* node list_files.js personal --type sheet
|
||||
* node list_files.js consulting --type pdf --query "contract"
|
||||
*/
|
||||
|
||||
const { google } = require('googleapis');
|
||||
const path = require('path');
|
||||
const { getAuthClient } = require(path.join(__dirname, '..', 'auth', 'token_manager'));
|
||||
|
||||
const MIME_TYPES = {
|
||||
doc: 'application/vnd.google-apps.document',
|
||||
sheet: 'application/vnd.google-apps.spreadsheet',
|
||||
slide: 'application/vnd.google-apps.presentation',
|
||||
pdf: 'application/pdf',
|
||||
folder: 'application/vnd.google-apps.folder',
|
||||
};
|
||||
|
||||
async function listFiles(account, options = {}) {
|
||||
const auth = await getAuthClient(account);
|
||||
const drive = google.drive({ version: 'v3', auth });
|
||||
|
||||
// Build query
|
||||
const queryParts = [];
|
||||
|
||||
if (options.query) {
|
||||
queryParts.push(`fullText contains '${options.query}'`);
|
||||
}
|
||||
|
||||
if (options.type && MIME_TYPES[options.type]) {
|
||||
queryParts.push(`mimeType = '${MIME_TYPES[options.type]}'`);
|
||||
}
|
||||
|
||||
if (options.folder) {
|
||||
queryParts.push(`'${options.folder}' in parents`);
|
||||
}
|
||||
|
||||
// Exclude trashed files
|
||||
queryParts.push('trashed = false');
|
||||
|
||||
const response = await drive.files.list({
|
||||
q: queryParts.join(' and '),
|
||||
pageSize: options.max || 20,
|
||||
fields: 'files(id, name, mimeType, size, createdTime, modifiedTime, webViewLink, parents)',
|
||||
orderBy: 'modifiedTime desc',
|
||||
supportsAllDrives: true,
|
||||
includeItemsFromAllDrives: true,
|
||||
});
|
||||
|
||||
const files = (response.data.files || []).map(file => ({
|
||||
id: file.id,
|
||||
name: file.name,
|
||||
mimeType: file.mimeType,
|
||||
size: file.size,
|
||||
created: file.createdTime,
|
||||
modified: file.modifiedTime,
|
||||
webLink: file.webViewLink,
|
||||
parents: file.parents,
|
||||
}));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
account,
|
||||
files,
|
||||
metadata: {
|
||||
timestamp: new Date().toISOString(),
|
||||
count: files.length,
|
||||
query: options.query || '(all)',
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// CLI interface
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const account = args[0];
|
||||
|
||||
if (!account) {
|
||||
console.error(JSON.stringify({
|
||||
error: 'Missing account',
|
||||
usage: 'node list_files.js <account> [--query "..."] [--type doc|sheet|slide|pdf|folder] [--max N]'
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Parse options
|
||||
const options = {};
|
||||
for (let i = 1; i < args.length; i++) {
|
||||
switch (args[i]) {
|
||||
case '--query':
|
||||
options.query = args[++i];
|
||||
break;
|
||||
case '--type':
|
||||
options.type = args[++i];
|
||||
break;
|
||||
case '--max':
|
||||
options.max = parseInt(args[++i], 10);
|
||||
break;
|
||||
case '--folder':
|
||||
options.folder = args[++i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await listFiles(account, options);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} catch (error) {
|
||||
console.error(JSON.stringify({
|
||||
error: error.message,
|
||||
account,
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
module.exports = { listFiles };
|
||||
130
skills/google-workspace/drive/read_file.js
Normal file
130
skills/google-workspace/drive/read_file.js
Normal file
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Read Drive File Content
|
||||
*
|
||||
* Usage: node read_file.js <account> <file-id> [options]
|
||||
*
|
||||
* Options:
|
||||
* --format Export format for Google Docs (text, html, pdf)
|
||||
*
|
||||
* Examples:
|
||||
* node read_file.js psd 1abc123def456
|
||||
* node read_file.js personal 1xyz789 --format html
|
||||
*/
|
||||
|
||||
const { google } = require('googleapis');
|
||||
const path = require('path');
|
||||
const { getAuthClient } = require(path.join(__dirname, '..', 'auth', 'token_manager'));
|
||||
|
||||
const EXPORT_FORMATS = {
|
||||
'application/vnd.google-apps.document': {
|
||||
text: 'text/plain',
|
||||
html: 'text/html',
|
||||
pdf: 'application/pdf',
|
||||
},
|
||||
'application/vnd.google-apps.spreadsheet': {
|
||||
csv: 'text/csv',
|
||||
pdf: 'application/pdf',
|
||||
},
|
||||
'application/vnd.google-apps.presentation': {
|
||||
text: 'text/plain',
|
||||
pdf: 'application/pdf',
|
||||
},
|
||||
};
|
||||
|
||||
async function readFile(account, fileId, options = {}) {
|
||||
const auth = await getAuthClient(account);
|
||||
const drive = google.drive({ version: 'v3', auth });
|
||||
|
||||
// Get file metadata
|
||||
const metadata = await drive.files.get({
|
||||
fileId,
|
||||
fields: 'id, name, mimeType, size, webViewLink',
|
||||
supportsAllDrives: true,
|
||||
});
|
||||
|
||||
const file = metadata.data;
|
||||
let content = '';
|
||||
|
||||
// Check if it's a Google Workspace file (needs export)
|
||||
if (file.mimeType.startsWith('application/vnd.google-apps.')) {
|
||||
const exportFormats = EXPORT_FORMATS[file.mimeType];
|
||||
if (exportFormats) {
|
||||
const format = options.format || 'text';
|
||||
const mimeType = exportFormats[format] || exportFormats.text || Object.values(exportFormats)[0];
|
||||
|
||||
const response = await drive.files.export({
|
||||
fileId,
|
||||
mimeType,
|
||||
}, { responseType: 'text' });
|
||||
|
||||
content = response.data;
|
||||
} else {
|
||||
content = '[Cannot export this file type]';
|
||||
}
|
||||
} else {
|
||||
// Regular file - download content
|
||||
const response = await drive.files.get({
|
||||
fileId,
|
||||
alt: 'media',
|
||||
supportsAllDrives: true,
|
||||
}, { responseType: 'text' });
|
||||
|
||||
content = response.data;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
account,
|
||||
file: {
|
||||
id: file.id,
|
||||
name: file.name,
|
||||
mimeType: file.mimeType,
|
||||
webLink: file.webViewLink,
|
||||
content,
|
||||
},
|
||||
metadata: {
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// CLI interface
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const account = args[0];
|
||||
const fileId = args[1];
|
||||
|
||||
if (!account || !fileId) {
|
||||
console.error(JSON.stringify({
|
||||
error: 'Missing arguments',
|
||||
usage: 'node read_file.js <account> <file-id> [--format text|html|pdf]'
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Parse options
|
||||
const options = {};
|
||||
for (let i = 2; i < args.length; i++) {
|
||||
if (args[i] === '--format') {
|
||||
options.format = args[++i];
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await readFile(account, fileId, options);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} catch (error) {
|
||||
console.error(JSON.stringify({
|
||||
error: error.message,
|
||||
account,
|
||||
fileId,
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
module.exports = { readFile };
|
||||
154
skills/google-workspace/gmail/list_messages.js
Normal file
154
skills/google-workspace/gmail/list_messages.js
Normal file
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* List Gmail Messages
|
||||
*
|
||||
* Usage: node list_messages.js <account> [options]
|
||||
*
|
||||
* Options:
|
||||
* --query Gmail search query (default: empty = all)
|
||||
* --label Filter by label (e.g., INBOX, UNREAD, SENT)
|
||||
* --max Maximum messages to return (default: 10)
|
||||
* --unread Show only unread messages
|
||||
*
|
||||
* Examples:
|
||||
* node list_messages.js psd
|
||||
* node list_messages.js psd --unread --max 5
|
||||
* node list_messages.js personal --query "from:amazon.com"
|
||||
* node list_messages.js consulting --label SENT
|
||||
*/
|
||||
|
||||
const { google } = require('googleapis');
|
||||
const path = require('path');
|
||||
const { getAuthClient } = require(path.join(__dirname, '..', 'auth', 'token_manager'));
|
||||
|
||||
async function listMessages(account, options = {}) {
|
||||
const auth = await getAuthClient(account);
|
||||
const gmail = google.gmail({ version: 'v1', auth });
|
||||
|
||||
// Build query
|
||||
let query = options.query || '';
|
||||
if (options.unread) {
|
||||
query = query ? `${query} is:unread` : 'is:unread';
|
||||
}
|
||||
|
||||
// List messages
|
||||
const listParams = {
|
||||
userId: 'me',
|
||||
maxResults: options.max || 10,
|
||||
};
|
||||
|
||||
if (query) {
|
||||
listParams.q = query;
|
||||
}
|
||||
|
||||
if (options.label) {
|
||||
listParams.labelIds = [options.label.toUpperCase()];
|
||||
}
|
||||
|
||||
const response = await gmail.users.messages.list(listParams);
|
||||
const messages = response.data.messages || [];
|
||||
|
||||
if (messages.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
account,
|
||||
messages: [],
|
||||
metadata: {
|
||||
timestamp: new Date().toISOString(),
|
||||
count: 0,
|
||||
query: query || '(all)',
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Get message details
|
||||
const messageDetails = await Promise.all(
|
||||
messages.map(async (msg) => {
|
||||
const detail = await gmail.users.messages.get({
|
||||
userId: 'me',
|
||||
id: msg.id,
|
||||
format: 'metadata',
|
||||
metadataHeaders: ['From', 'To', 'Subject', 'Date'],
|
||||
});
|
||||
|
||||
const headers = detail.data.payload.headers;
|
||||
const getHeader = (name) => {
|
||||
const header = headers.find(h => h.name.toLowerCase() === name.toLowerCase());
|
||||
return header ? header.value : '';
|
||||
};
|
||||
|
||||
return {
|
||||
id: msg.id,
|
||||
threadId: msg.threadId,
|
||||
from: getHeader('From'),
|
||||
to: getHeader('To'),
|
||||
subject: getHeader('Subject'),
|
||||
date: getHeader('Date'),
|
||||
snippet: detail.data.snippet,
|
||||
labels: detail.data.labelIds,
|
||||
isUnread: detail.data.labelIds.includes('UNREAD'),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
account,
|
||||
messages: messageDetails,
|
||||
metadata: {
|
||||
timestamp: new Date().toISOString(),
|
||||
count: messageDetails.length,
|
||||
query: query || '(all)',
|
||||
nextPageToken: response.data.nextPageToken,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// CLI interface
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const account = args[0];
|
||||
|
||||
if (!account) {
|
||||
console.error(JSON.stringify({
|
||||
error: 'Missing account',
|
||||
usage: 'node list_messages.js <account> [--unread] [--query "..."] [--max N] [--label LABEL]'
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Parse options
|
||||
const options = {};
|
||||
for (let i = 1; i < args.length; i++) {
|
||||
switch (args[i]) {
|
||||
case '--unread':
|
||||
options.unread = true;
|
||||
break;
|
||||
case '--query':
|
||||
options.query = args[++i];
|
||||
break;
|
||||
case '--max':
|
||||
options.max = parseInt(args[++i], 10);
|
||||
break;
|
||||
case '--label':
|
||||
options.label = args[++i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await listMessages(account, options);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} catch (error) {
|
||||
console.error(JSON.stringify({
|
||||
error: error.message,
|
||||
account,
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
module.exports = { listMessages };
|
||||
126
skills/google-workspace/gmail/read_message.js
Normal file
126
skills/google-workspace/gmail/read_message.js
Normal file
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Read Gmail Message
|
||||
*
|
||||
* Usage: node read_message.js <account> <message-id>
|
||||
*
|
||||
* Examples:
|
||||
* node read_message.js psd 18d1234567890abc
|
||||
*/
|
||||
|
||||
const { google } = require('googleapis');
|
||||
const path = require('path');
|
||||
const { getAuthClient } = require(path.join(__dirname, '..', 'auth', 'token_manager'));
|
||||
|
||||
async function readMessage(account, messageId) {
|
||||
const auth = await getAuthClient(account);
|
||||
const gmail = google.gmail({ version: 'v1', auth });
|
||||
|
||||
const response = await gmail.users.messages.get({
|
||||
userId: 'me',
|
||||
id: messageId,
|
||||
format: 'full',
|
||||
});
|
||||
|
||||
const message = response.data;
|
||||
const headers = message.payload.headers;
|
||||
|
||||
const getHeader = (name) => {
|
||||
const header = headers.find(h => h.name.toLowerCase() === name.toLowerCase());
|
||||
return header ? header.value : '';
|
||||
};
|
||||
|
||||
// Extract body
|
||||
let body = '';
|
||||
let htmlBody = '';
|
||||
|
||||
function extractBody(payload) {
|
||||
if (payload.body && payload.body.data) {
|
||||
const decoded = Buffer.from(payload.body.data, 'base64').toString('utf8');
|
||||
if (payload.mimeType === 'text/plain') {
|
||||
body = decoded;
|
||||
} else if (payload.mimeType === 'text/html') {
|
||||
htmlBody = decoded;
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.parts) {
|
||||
for (const part of payload.parts) {
|
||||
extractBody(part);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extractBody(message.payload);
|
||||
|
||||
// Get attachments info
|
||||
const attachments = [];
|
||||
function findAttachments(payload) {
|
||||
if (payload.filename && payload.body && payload.body.attachmentId) {
|
||||
attachments.push({
|
||||
filename: payload.filename,
|
||||
mimeType: payload.mimeType,
|
||||
size: payload.body.size,
|
||||
attachmentId: payload.body.attachmentId,
|
||||
});
|
||||
}
|
||||
if (payload.parts) {
|
||||
for (const part of payload.parts) {
|
||||
findAttachments(part);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
findAttachments(message.payload);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
account,
|
||||
message: {
|
||||
id: message.id,
|
||||
threadId: message.threadId,
|
||||
from: getHeader('From'),
|
||||
to: getHeader('To'),
|
||||
cc: getHeader('Cc'),
|
||||
subject: getHeader('Subject'),
|
||||
date: getHeader('Date'),
|
||||
body: body || htmlBody,
|
||||
isHtml: !body && !!htmlBody,
|
||||
labels: message.labelIds,
|
||||
attachments,
|
||||
},
|
||||
metadata: {
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// CLI interface
|
||||
async function main() {
|
||||
const [account, messageId] = process.argv.slice(2);
|
||||
|
||||
if (!account || !messageId) {
|
||||
console.error(JSON.stringify({
|
||||
error: 'Missing arguments',
|
||||
usage: 'node read_message.js <account> <message-id>'
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await readMessage(account, messageId);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} catch (error) {
|
||||
console.error(JSON.stringify({
|
||||
error: error.message,
|
||||
account,
|
||||
messageId,
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
module.exports = { readMessage };
|
||||
130
skills/google-workspace/gmail/search_messages.js
Normal file
130
skills/google-workspace/gmail/search_messages.js
Normal file
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Search Gmail Messages
|
||||
*
|
||||
* Usage: node search_messages.js <account> <query>
|
||||
*
|
||||
* Supports all Gmail search operators:
|
||||
* from: - Sender
|
||||
* to: - Recipient
|
||||
* subject: - Subject line
|
||||
* has:attachment - Has attachments
|
||||
* after: - After date (YYYY/MM/DD)
|
||||
* before: - Before date
|
||||
* is:unread - Unread only
|
||||
* is:starred - Starred only
|
||||
* label: - By label
|
||||
* filename: - Attachment filename
|
||||
*
|
||||
* Examples:
|
||||
* node search_messages.js psd "from:boss@psd.org"
|
||||
* node search_messages.js personal "subject:invoice after:2024/01/01"
|
||||
* node search_messages.js consulting "has:attachment from:client"
|
||||
*/
|
||||
|
||||
const { google } = require('googleapis');
|
||||
const path = require('path');
|
||||
const { getAuthClient } = require(path.join(__dirname, '..', 'auth', 'token_manager'));
|
||||
|
||||
async function searchMessages(account, query, maxResults = 20) {
|
||||
const auth = await getAuthClient(account);
|
||||
const gmail = google.gmail({ version: 'v1', auth });
|
||||
|
||||
const response = await gmail.users.messages.list({
|
||||
userId: 'me',
|
||||
q: query,
|
||||
maxResults,
|
||||
});
|
||||
|
||||
const messages = response.data.messages || [];
|
||||
|
||||
if (messages.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
account,
|
||||
query,
|
||||
messages: [],
|
||||
metadata: {
|
||||
timestamp: new Date().toISOString(),
|
||||
count: 0,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Get message details
|
||||
const messageDetails = await Promise.all(
|
||||
messages.map(async (msg) => {
|
||||
const detail = await gmail.users.messages.get({
|
||||
userId: 'me',
|
||||
id: msg.id,
|
||||
format: 'metadata',
|
||||
metadataHeaders: ['From', 'To', 'Subject', 'Date'],
|
||||
});
|
||||
|
||||
const headers = detail.data.payload.headers;
|
||||
const getHeader = (name) => {
|
||||
const header = headers.find(h => h.name.toLowerCase() === name.toLowerCase());
|
||||
return header ? header.value : '';
|
||||
};
|
||||
|
||||
return {
|
||||
id: msg.id,
|
||||
threadId: msg.threadId,
|
||||
from: getHeader('From'),
|
||||
to: getHeader('To'),
|
||||
subject: getHeader('Subject'),
|
||||
date: getHeader('Date'),
|
||||
snippet: detail.data.snippet,
|
||||
labels: detail.data.labelIds,
|
||||
isUnread: detail.data.labelIds.includes('UNREAD'),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
account,
|
||||
query,
|
||||
messages: messageDetails,
|
||||
metadata: {
|
||||
timestamp: new Date().toISOString(),
|
||||
count: messageDetails.length,
|
||||
nextPageToken: response.data.nextPageToken,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// CLI interface
|
||||
async function main() {
|
||||
const [account, ...queryParts] = process.argv.slice(2);
|
||||
const query = queryParts.join(' ');
|
||||
|
||||
if (!account || !query) {
|
||||
console.error(JSON.stringify({
|
||||
error: 'Missing arguments',
|
||||
usage: 'node search_messages.js <account> <query>',
|
||||
examples: [
|
||||
'node search_messages.js psd "from:boss@psd.org"',
|
||||
'node search_messages.js personal "subject:invoice after:2024/01/01"',
|
||||
]
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await searchMessages(account, query);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} catch (error) {
|
||||
console.error(JSON.stringify({
|
||||
error: error.message,
|
||||
account,
|
||||
query,
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
module.exports = { searchMessages };
|
||||
175
skills/google-workspace/gmail/send_message.js
Normal file
175
skills/google-workspace/gmail/send_message.js
Normal file
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Send Gmail Message
|
||||
*
|
||||
* Usage: node send_message.js <account> --to <email> --subject <subject> --body <body>
|
||||
*
|
||||
* Options:
|
||||
* --to Recipient email (required)
|
||||
* --subject Email subject (required)
|
||||
* --body Email body (required)
|
||||
* --cc CC recipients (comma-separated)
|
||||
* --bcc BCC recipients (comma-separated)
|
||||
* --reply-to Message ID to reply to
|
||||
*
|
||||
* Examples:
|
||||
* node send_message.js psd --to "john@example.com" --subject "Hello" --body "Message here"
|
||||
* node send_message.js personal --to "friend@gmail.com" --subject "Lunch?" --body "Are you free?" --cc "other@gmail.com"
|
||||
*/
|
||||
|
||||
const { google } = require('googleapis');
|
||||
const path = require('path');
|
||||
const { getAuthClient } = require(path.join(__dirname, '..', 'auth', 'token_manager'));
|
||||
|
||||
async function sendMessage(account, options) {
|
||||
const auth = await getAuthClient(account);
|
||||
const gmail = google.gmail({ version: 'v1', auth });
|
||||
|
||||
// Get sender email
|
||||
const profile = await gmail.users.getProfile({ userId: 'me' });
|
||||
const fromEmail = profile.data.emailAddress;
|
||||
|
||||
// Build email
|
||||
const emailLines = [
|
||||
`From: ${fromEmail}`,
|
||||
`To: ${options.to}`,
|
||||
];
|
||||
|
||||
if (options.cc) {
|
||||
emailLines.push(`Cc: ${options.cc}`);
|
||||
}
|
||||
|
||||
if (options.bcc) {
|
||||
emailLines.push(`Bcc: ${options.bcc}`);
|
||||
}
|
||||
|
||||
emailLines.push(
|
||||
`Subject: ${options.subject}`,
|
||||
'Content-Type: text/plain; charset=utf-8',
|
||||
'',
|
||||
options.body
|
||||
);
|
||||
|
||||
// If replying, add thread info
|
||||
if (options.replyTo) {
|
||||
const original = await gmail.users.messages.get({
|
||||
userId: 'me',
|
||||
id: options.replyTo,
|
||||
format: 'metadata',
|
||||
metadataHeaders: ['Message-ID', 'References'],
|
||||
});
|
||||
|
||||
const headers = original.data.payload.headers;
|
||||
const messageIdHeader = headers.find(h => h.name === 'Message-ID');
|
||||
const referencesHeader = headers.find(h => h.name === 'References');
|
||||
|
||||
if (messageIdHeader) {
|
||||
const references = referencesHeader
|
||||
? `${referencesHeader.value} ${messageIdHeader.value}`
|
||||
: messageIdHeader.value;
|
||||
emailLines.splice(2, 0, `In-Reply-To: ${messageIdHeader.value}`);
|
||||
emailLines.splice(3, 0, `References: ${references}`);
|
||||
}
|
||||
}
|
||||
|
||||
const email = emailLines.join('\r\n');
|
||||
const encodedEmail = Buffer.from(email).toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
|
||||
const sendParams = {
|
||||
userId: 'me',
|
||||
requestBody: {
|
||||
raw: encodedEmail,
|
||||
},
|
||||
};
|
||||
|
||||
if (options.replyTo) {
|
||||
const original = await gmail.users.messages.get({
|
||||
userId: 'me',
|
||||
id: options.replyTo,
|
||||
format: 'minimal',
|
||||
});
|
||||
sendParams.requestBody.threadId = original.data.threadId;
|
||||
}
|
||||
|
||||
const response = await gmail.users.messages.send(sendParams);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
account,
|
||||
sent: {
|
||||
id: response.data.id,
|
||||
threadId: response.data.threadId,
|
||||
to: options.to,
|
||||
subject: options.subject,
|
||||
},
|
||||
metadata: {
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// CLI interface
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const account = args[0];
|
||||
|
||||
if (!account) {
|
||||
console.error(JSON.stringify({
|
||||
error: 'Missing account',
|
||||
usage: 'node send_message.js <account> --to <email> --subject <subject> --body <body>'
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Parse options
|
||||
const options = {};
|
||||
for (let i = 1; i < args.length; i++) {
|
||||
switch (args[i]) {
|
||||
case '--to':
|
||||
options.to = args[++i];
|
||||
break;
|
||||
case '--subject':
|
||||
options.subject = args[++i];
|
||||
break;
|
||||
case '--body':
|
||||
options.body = args[++i];
|
||||
break;
|
||||
case '--cc':
|
||||
options.cc = args[++i];
|
||||
break;
|
||||
case '--bcc':
|
||||
options.bcc = args[++i];
|
||||
break;
|
||||
case '--reply-to':
|
||||
options.replyTo = args[++i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!options.to || !options.subject || !options.body) {
|
||||
console.error(JSON.stringify({
|
||||
error: 'Missing required options: --to, --subject, --body',
|
||||
usage: 'node send_message.js <account> --to <email> --subject <subject> --body <body>'
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await sendMessage(account, options);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} catch (error) {
|
||||
console.error(JSON.stringify({
|
||||
error: error.message,
|
||||
account,
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
module.exports = { sendMessage };
|
||||
17
skills/google-workspace/package.json
Normal file
17
skills/google-workspace/package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "geoffrey-google-workspace",
|
||||
"version": "1.0.0",
|
||||
"description": "Google Workspace integration for Geoffrey",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"setup:psd": "bun auth/oauth_setup.js psd",
|
||||
"setup:kh": "bun auth/oauth_setup.js kh",
|
||||
"setup:hrg": "bun auth/oauth_setup.js hrg",
|
||||
"list:accounts": "bun auth/token_manager.js list"
|
||||
},
|
||||
"dependencies": {
|
||||
"dotenv": "^16.3.1",
|
||||
"googleapis": "^128.0.0",
|
||||
"open": "^9.1.0"
|
||||
}
|
||||
}
|
||||
102
skills/google-workspace/sheets/create_sheet.js
Normal file
102
skills/google-workspace/sheets/create_sheet.js
Normal file
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Create Google Sheet
|
||||
*
|
||||
* Usage: node create_sheet.js <account> --title <title> [--sheets <sheet1,sheet2>]
|
||||
*
|
||||
* Examples:
|
||||
* node create_sheet.js psd --title "Budget 2025"
|
||||
* node create_sheet.js psd --title "Project Tracker" --sheets "Tasks,Timeline,Resources"
|
||||
*/
|
||||
|
||||
const { google } = require('googleapis');
|
||||
const path = require('path');
|
||||
const { getAuthClient } = require(path.join(__dirname, '..', 'auth', 'token_manager'));
|
||||
|
||||
async function createSheet(account, options) {
|
||||
const auth = await getAuthClient(account);
|
||||
const sheets = google.sheets({ version: 'v4', auth });
|
||||
|
||||
const requestBody = {
|
||||
properties: {
|
||||
title: options.title,
|
||||
},
|
||||
};
|
||||
|
||||
// Add custom sheets if specified
|
||||
if (options.sheets) {
|
||||
const sheetNames = options.sheets.split(',').map(s => s.trim());
|
||||
requestBody.sheets = sheetNames.map(name => ({
|
||||
properties: { title: name },
|
||||
}));
|
||||
}
|
||||
|
||||
const response = await sheets.spreadsheets.create({
|
||||
requestBody,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
account,
|
||||
spreadsheet: {
|
||||
id: response.data.spreadsheetId,
|
||||
title: response.data.properties.title,
|
||||
url: response.data.spreadsheetUrl,
|
||||
sheets: response.data.sheets.map(s => s.properties.title),
|
||||
},
|
||||
metadata: {
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// CLI interface
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const account = args[0];
|
||||
|
||||
if (!account) {
|
||||
console.error(JSON.stringify({
|
||||
error: 'Missing account',
|
||||
usage: 'node create_sheet.js <account> --title <title> [--sheets <sheet1,sheet2>]'
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Parse options
|
||||
const options = {};
|
||||
for (let i = 1; i < args.length; i++) {
|
||||
switch (args[i]) {
|
||||
case '--title':
|
||||
options.title = args[++i];
|
||||
break;
|
||||
case '--sheets':
|
||||
options.sheets = args[++i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!options.title) {
|
||||
console.error(JSON.stringify({
|
||||
error: 'Missing --title option',
|
||||
usage: 'node create_sheet.js <account> --title <title> [--sheets <sheet1,sheet2>]'
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await createSheet(account, options);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} catch (error) {
|
||||
console.error(JSON.stringify({
|
||||
error: error.message,
|
||||
account,
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
module.exports = { createSheet };
|
||||
99
skills/google-workspace/sheets/edit_sheet.js
Normal file
99
skills/google-workspace/sheets/edit_sheet.js
Normal file
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Edit Google Sheet
|
||||
*
|
||||
* Usage: node edit_sheet.js <account> <spreadsheet-id> --range <range> --values <json-array>
|
||||
*
|
||||
* Examples:
|
||||
* node edit_sheet.js psd SHEET_ID --range "A1" --values '[["Hello"]]'
|
||||
* node edit_sheet.js psd SHEET_ID --range "A1:B2" --values '[["Name","Score"],["Alice",100]]'
|
||||
* node edit_sheet.js psd SHEET_ID --range "Sheet2!A1" --values '[["Data"]]'
|
||||
*/
|
||||
|
||||
const { google } = require('googleapis');
|
||||
const path = require('path');
|
||||
const { getAuthClient } = require(path.join(__dirname, '..', 'auth', 'token_manager'));
|
||||
|
||||
async function editSheet(account, spreadsheetId, options) {
|
||||
const auth = await getAuthClient(account);
|
||||
const sheets = google.sheets({ version: 'v4', auth });
|
||||
|
||||
const values = JSON.parse(options.values);
|
||||
|
||||
const response = await sheets.spreadsheets.values.update({
|
||||
spreadsheetId,
|
||||
range: options.range,
|
||||
valueInputOption: 'USER_ENTERED',
|
||||
requestBody: {
|
||||
values,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
account,
|
||||
spreadsheetId,
|
||||
update: {
|
||||
range: response.data.updatedRange,
|
||||
rowsUpdated: response.data.updatedRows,
|
||||
columnsUpdated: response.data.updatedColumns,
|
||||
cellsUpdated: response.data.updatedCells,
|
||||
},
|
||||
metadata: {
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// CLI interface
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const account = args[0];
|
||||
const spreadsheetId = args[1];
|
||||
|
||||
if (!account || !spreadsheetId) {
|
||||
console.error(JSON.stringify({
|
||||
error: 'Missing arguments',
|
||||
usage: 'node edit_sheet.js <account> <spreadsheet-id> --range <range> --values <json-array>'
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Parse options
|
||||
const options = {};
|
||||
for (let i = 2; i < args.length; i++) {
|
||||
switch (args[i]) {
|
||||
case '--range':
|
||||
options.range = args[++i];
|
||||
break;
|
||||
case '--values':
|
||||
options.values = args[++i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!options.range || !options.values) {
|
||||
console.error(JSON.stringify({
|
||||
error: 'Missing --range or --values option',
|
||||
usage: 'node edit_sheet.js <account> <spreadsheet-id> --range <range> --values <json-array>'
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await editSheet(account, spreadsheetId, options);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} catch (error) {
|
||||
console.error(JSON.stringify({
|
||||
error: error.message,
|
||||
account,
|
||||
spreadsheetId,
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
module.exports = { editSheet };
|
||||
89
skills/google-workspace/sheets/read_sheet.js
Normal file
89
skills/google-workspace/sheets/read_sheet.js
Normal file
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Read Google Sheet Data
|
||||
*
|
||||
* Usage: node read_sheet.js <account> <spreadsheet-id> [--range <range>]
|
||||
*
|
||||
* Examples:
|
||||
* node read_sheet.js psd 1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms
|
||||
* node read_sheet.js psd SHEET_ID --range "Sheet1!A1:D10"
|
||||
*/
|
||||
|
||||
const { google } = require('googleapis');
|
||||
const path = require('path');
|
||||
const { getAuthClient } = require(path.join(__dirname, '..', 'auth', 'token_manager'));
|
||||
|
||||
async function readSheet(account, spreadsheetId, options = {}) {
|
||||
const auth = await getAuthClient(account);
|
||||
const sheets = google.sheets({ version: 'v4', auth });
|
||||
|
||||
// Get spreadsheet metadata
|
||||
const metadata = await sheets.spreadsheets.get({
|
||||
spreadsheetId,
|
||||
});
|
||||
|
||||
// Get values
|
||||
const range = options.range || metadata.data.sheets[0].properties.title;
|
||||
const response = await sheets.spreadsheets.values.get({
|
||||
spreadsheetId,
|
||||
range,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
account,
|
||||
spreadsheet: {
|
||||
id: spreadsheetId,
|
||||
title: metadata.data.properties.title,
|
||||
sheets: metadata.data.sheets.map(s => s.properties.title),
|
||||
},
|
||||
data: {
|
||||
range: response.data.range,
|
||||
values: response.data.values || [],
|
||||
rowCount: (response.data.values || []).length,
|
||||
},
|
||||
metadata: {
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// CLI interface
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const account = args[0];
|
||||
const spreadsheetId = args[1];
|
||||
|
||||
if (!account || !spreadsheetId) {
|
||||
console.error(JSON.stringify({
|
||||
error: 'Missing arguments',
|
||||
usage: 'node read_sheet.js <account> <spreadsheet-id> [--range <range>]'
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Parse options
|
||||
const options = {};
|
||||
for (let i = 2; i < args.length; i++) {
|
||||
if (args[i] === '--range') {
|
||||
options.range = args[++i];
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await readSheet(account, spreadsheetId, options);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} catch (error) {
|
||||
console.error(JSON.stringify({
|
||||
error: error.message,
|
||||
account,
|
||||
spreadsheetId,
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
module.exports = { readSheet };
|
||||
84
skills/google-workspace/slides/create_presentation.js
Normal file
84
skills/google-workspace/slides/create_presentation.js
Normal file
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Create Google Slides Presentation
|
||||
*
|
||||
* Usage: node create_presentation.js <account> --title <title>
|
||||
*
|
||||
* Examples:
|
||||
* node create_presentation.js psd --title "Q4 Review"
|
||||
*/
|
||||
|
||||
const { google } = require('googleapis');
|
||||
const path = require('path');
|
||||
const { getAuthClient } = require(path.join(__dirname, '..', 'auth', 'token_manager'));
|
||||
|
||||
async function createPresentation(account, options) {
|
||||
const auth = await getAuthClient(account);
|
||||
const slides = google.slides({ version: 'v1', auth });
|
||||
|
||||
const response = await slides.presentations.create({
|
||||
requestBody: {
|
||||
title: options.title,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
account,
|
||||
presentation: {
|
||||
id: response.data.presentationId,
|
||||
title: response.data.title,
|
||||
url: `https://docs.google.com/presentation/d/${response.data.presentationId}/edit`,
|
||||
slideCount: (response.data.slides || []).length,
|
||||
},
|
||||
metadata: {
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// CLI interface
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const account = args[0];
|
||||
|
||||
if (!account) {
|
||||
console.error(JSON.stringify({
|
||||
error: 'Missing account',
|
||||
usage: 'node create_presentation.js <account> --title <title>'
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Parse options
|
||||
const options = {};
|
||||
for (let i = 1; i < args.length; i++) {
|
||||
if (args[i] === '--title') {
|
||||
options.title = args[++i];
|
||||
}
|
||||
}
|
||||
|
||||
if (!options.title) {
|
||||
console.error(JSON.stringify({
|
||||
error: 'Missing --title option',
|
||||
usage: 'node create_presentation.js <account> --title <title>'
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await createPresentation(account, options);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} catch (error) {
|
||||
console.error(JSON.stringify({
|
||||
error: error.message,
|
||||
account,
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
module.exports = { createPresentation };
|
||||
174
skills/google-workspace/slides/edit_presentation.js
Normal file
174
skills/google-workspace/slides/edit_presentation.js
Normal file
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Edit Google Slides Presentation
|
||||
*
|
||||
* Usage: node edit_presentation.js <account> <presentation-id> <action> [options]
|
||||
*
|
||||
* Actions:
|
||||
* --add-slide Add a blank slide
|
||||
* --add-text-slide Add slide with title and body
|
||||
* --replace-text Replace text across presentation
|
||||
*
|
||||
* Examples:
|
||||
* node edit_presentation.js psd PRES_ID --add-slide
|
||||
* node edit_presentation.js psd PRES_ID --add-text-slide --title "Agenda" --body "Item 1\nItem 2"
|
||||
* node edit_presentation.js psd PRES_ID --replace-text --find "2024" --replace "2025"
|
||||
*/
|
||||
|
||||
const { google } = require('googleapis');
|
||||
const path = require('path');
|
||||
const { getAuthClient } = require(path.join(__dirname, '..', 'auth', 'token_manager'));
|
||||
|
||||
async function editPresentation(account, presentationId, options) {
|
||||
const auth = await getAuthClient(account);
|
||||
const slides = google.slides({ version: 'v1', auth });
|
||||
|
||||
const requests = [];
|
||||
|
||||
if (options.addSlide) {
|
||||
requests.push({
|
||||
createSlide: {
|
||||
slideLayoutReference: {
|
||||
predefinedLayout: 'BLANK',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (options.addTextSlide) {
|
||||
const slideId = `slide_${Date.now()}`;
|
||||
const titleId = `title_${Date.now()}`;
|
||||
const bodyId = `body_${Date.now()}`;
|
||||
|
||||
requests.push({
|
||||
createSlide: {
|
||||
objectId: slideId,
|
||||
slideLayoutReference: {
|
||||
predefinedLayout: 'TITLE_AND_BODY',
|
||||
},
|
||||
placeholderIdMappings: [
|
||||
{
|
||||
layoutPlaceholder: { type: 'TITLE' },
|
||||
objectId: titleId,
|
||||
},
|
||||
{
|
||||
layoutPlaceholder: { type: 'BODY' },
|
||||
objectId: bodyId,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (options.title) {
|
||||
requests.push({
|
||||
insertText: {
|
||||
objectId: titleId,
|
||||
text: options.title,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (options.body) {
|
||||
requests.push({
|
||||
insertText: {
|
||||
objectId: bodyId,
|
||||
text: options.body,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (options.replaceText && options.find && options.replace) {
|
||||
requests.push({
|
||||
replaceAllText: {
|
||||
containsText: {
|
||||
text: options.find,
|
||||
matchCase: true,
|
||||
},
|
||||
replaceText: options.replace,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (requests.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'No edit operation specified',
|
||||
};
|
||||
}
|
||||
|
||||
const response = await slides.presentations.batchUpdate({
|
||||
presentationId,
|
||||
requestBody: { requests },
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
account,
|
||||
presentationId,
|
||||
updates: response.data.replies.length,
|
||||
metadata: {
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// CLI interface
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const account = args[0];
|
||||
const presentationId = args[1];
|
||||
|
||||
if (!account || !presentationId) {
|
||||
console.error(JSON.stringify({
|
||||
error: 'Missing arguments',
|
||||
usage: 'node edit_presentation.js <account> <presentation-id> --add-slide'
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Parse options
|
||||
const options = {};
|
||||
for (let i = 2; i < args.length; i++) {
|
||||
switch (args[i]) {
|
||||
case '--add-slide':
|
||||
options.addSlide = true;
|
||||
break;
|
||||
case '--add-text-slide':
|
||||
options.addTextSlide = true;
|
||||
break;
|
||||
case '--replace-text':
|
||||
options.replaceText = true;
|
||||
break;
|
||||
case '--title':
|
||||
options.title = args[++i];
|
||||
break;
|
||||
case '--body':
|
||||
options.body = args[++i];
|
||||
break;
|
||||
case '--find':
|
||||
options.find = args[++i];
|
||||
break;
|
||||
case '--replace':
|
||||
options.replace = args[++i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await editPresentation(account, presentationId, options);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} catch (error) {
|
||||
console.error(JSON.stringify({
|
||||
error: error.message,
|
||||
account,
|
||||
presentationId,
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
module.exports = { editPresentation };
|
||||
93
skills/google-workspace/slides/read_presentation.js
Normal file
93
skills/google-workspace/slides/read_presentation.js
Normal file
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Read Google Slides Presentation
|
||||
*
|
||||
* Usage: node read_presentation.js <account> <presentation-id>
|
||||
*
|
||||
* Examples:
|
||||
* node read_presentation.js psd 1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms
|
||||
*/
|
||||
|
||||
const { google } = require('googleapis');
|
||||
const path = require('path');
|
||||
const { getAuthClient } = require(path.join(__dirname, '..', 'auth', 'token_manager'));
|
||||
|
||||
async function readPresentation(account, presentationId) {
|
||||
const auth = await getAuthClient(account);
|
||||
const slides = google.slides({ version: 'v1', auth });
|
||||
|
||||
const response = await slides.presentations.get({
|
||||
presentationId,
|
||||
});
|
||||
|
||||
const presentation = response.data;
|
||||
|
||||
// Extract slide content
|
||||
const slideContent = (presentation.slides || []).map((slide, index) => {
|
||||
const texts = [];
|
||||
|
||||
// Extract text from page elements
|
||||
for (const element of (slide.pageElements || [])) {
|
||||
if (element.shape && element.shape.text) {
|
||||
for (const textElement of (element.shape.text.textElements || [])) {
|
||||
if (textElement.textRun && textElement.textRun.content) {
|
||||
const text = textElement.textRun.content.trim();
|
||||
if (text) texts.push(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
slideNumber: index + 1,
|
||||
objectId: slide.objectId,
|
||||
texts,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
account,
|
||||
presentation: {
|
||||
id: presentation.presentationId,
|
||||
title: presentation.title,
|
||||
slideCount: (presentation.slides || []).length,
|
||||
slides: slideContent,
|
||||
},
|
||||
metadata: {
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// CLI interface
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const account = args[0];
|
||||
const presentationId = args[1];
|
||||
|
||||
if (!account || !presentationId) {
|
||||
console.error(JSON.stringify({
|
||||
error: 'Missing arguments',
|
||||
usage: 'node read_presentation.js <account> <presentation-id>'
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await readPresentation(account, presentationId);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} catch (error) {
|
||||
console.error(JSON.stringify({
|
||||
error: error.message,
|
||||
account,
|
||||
presentationId,
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
module.exports = { readPresentation };
|
||||
123
skills/google-workspace/utils/folder_manager.js
Normal file
123
skills/google-workspace/utils/folder_manager.js
Normal file
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Geoffrey Folder Manager
|
||||
*
|
||||
* Ensures Geoffrey folder structure exists in Google Drive.
|
||||
* Creates folders if they don't exist, returns folder IDs.
|
||||
*
|
||||
* Usage: bun folder_manager.js <account> [subfolder]
|
||||
*
|
||||
* Examples:
|
||||
* bun folder_manager.js hrg # Get/create Geoffrey folder
|
||||
* bun folder_manager.js hrg Research # Get/create Geoffrey/Research
|
||||
*/
|
||||
|
||||
const { google } = require('googleapis');
|
||||
const path = require('path');
|
||||
const { getAuthClient } = require(path.join(__dirname, '..', 'auth', 'token_manager'));
|
||||
|
||||
const GEOFFREY_FOLDER = 'Geoffrey';
|
||||
const SUBFOLDERS = ['Research', 'Notes', 'Reports', 'Travel'];
|
||||
|
||||
async function findFolder(drive, name, parentId = null) {
|
||||
let query = `name='${name}' and mimeType='application/vnd.google-apps.folder' and trashed=false`;
|
||||
if (parentId) {
|
||||
query += ` and '${parentId}' in parents`;
|
||||
}
|
||||
|
||||
const response = await drive.files.list({
|
||||
q: query,
|
||||
fields: 'files(id, name)',
|
||||
spaces: 'drive',
|
||||
});
|
||||
|
||||
return response.data.files[0] || null;
|
||||
}
|
||||
|
||||
async function createFolder(drive, name, parentId = null) {
|
||||
const fileMetadata = {
|
||||
name,
|
||||
mimeType: 'application/vnd.google-apps.folder',
|
||||
};
|
||||
|
||||
if (parentId) {
|
||||
fileMetadata.parents = [parentId];
|
||||
}
|
||||
|
||||
const response = await drive.files.create({
|
||||
requestBody: fileMetadata,
|
||||
fields: 'id, name',
|
||||
});
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function ensureGeoffreyFolders(account, subfolder = null) {
|
||||
const auth = await getAuthClient(account);
|
||||
const drive = google.drive({ version: 'v3', auth });
|
||||
|
||||
// Find or create main Geoffrey folder
|
||||
let geoffreyFolder = await findFolder(drive, GEOFFREY_FOLDER);
|
||||
if (!geoffreyFolder) {
|
||||
geoffreyFolder = await createFolder(drive, GEOFFREY_FOLDER);
|
||||
}
|
||||
|
||||
const result = {
|
||||
success: true,
|
||||
account,
|
||||
folders: {
|
||||
Geoffrey: geoffreyFolder.id,
|
||||
},
|
||||
metadata: {
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
};
|
||||
|
||||
// If specific subfolder requested, ensure it exists
|
||||
if (subfolder) {
|
||||
let subfolderObj = await findFolder(drive, subfolder, geoffreyFolder.id);
|
||||
if (!subfolderObj) {
|
||||
subfolderObj = await createFolder(drive, subfolder, geoffreyFolder.id);
|
||||
}
|
||||
result.folders[subfolder] = subfolderObj.id;
|
||||
result.targetFolder = subfolderObj.id;
|
||||
} else {
|
||||
result.targetFolder = geoffreyFolder.id;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// CLI interface
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const account = args[0];
|
||||
const subfolder = args[1];
|
||||
|
||||
if (!account) {
|
||||
console.error(JSON.stringify({
|
||||
error: 'Missing account',
|
||||
usage: 'bun folder_manager.js <account> [subfolder]',
|
||||
subfolders: SUBFOLDERS,
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await ensureGeoffreyFolders(account, subfolder);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} catch (error) {
|
||||
console.error(JSON.stringify({
|
||||
error: error.message,
|
||||
account,
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = { ensureGeoffreyFolders, findFolder, createFolder };
|
||||
256
skills/google-workspace/utils/markdown_to_docs.js
Normal file
256
skills/google-workspace/utils/markdown_to_docs.js
Normal file
@@ -0,0 +1,256 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Markdown to Google Docs Converter
|
||||
*
|
||||
* Converts markdown text to Google Docs API requests for proper formatting.
|
||||
*
|
||||
* Supports:
|
||||
* - Headers (# ## ###)
|
||||
* - Bold (**text**)
|
||||
* - Links [text](url)
|
||||
* - Bullet lists (- item)
|
||||
* - Numbered lists (1. item)
|
||||
* - Horizontal rules (---)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Process inline formatting (bold, links) and return plain text + formatting requests
|
||||
*/
|
||||
function processInlineFormatting(text, startIndex) {
|
||||
const requests = [];
|
||||
let plainText = '';
|
||||
let charIndex = startIndex;
|
||||
let remaining = text;
|
||||
|
||||
while (remaining.length > 0) {
|
||||
// Check for bold
|
||||
const boldMatch = remaining.match(/^\*\*(.+?)\*\*/);
|
||||
if (boldMatch) {
|
||||
const boldText = boldMatch[1];
|
||||
plainText += boldText;
|
||||
requests.push({
|
||||
updateTextStyle: {
|
||||
range: {
|
||||
startIndex: charIndex,
|
||||
endIndex: charIndex + boldText.length,
|
||||
},
|
||||
textStyle: {
|
||||
bold: true,
|
||||
},
|
||||
fields: 'bold',
|
||||
},
|
||||
});
|
||||
charIndex += boldText.length;
|
||||
remaining = remaining.substring(boldMatch[0].length);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for links
|
||||
const linkMatch = remaining.match(/^\[([^\]]+)\]\(([^)]+)\)/);
|
||||
if (linkMatch) {
|
||||
const linkText = linkMatch[1];
|
||||
const linkUrl = linkMatch[2];
|
||||
plainText += linkText;
|
||||
requests.push({
|
||||
updateTextStyle: {
|
||||
range: {
|
||||
startIndex: charIndex,
|
||||
endIndex: charIndex + linkText.length,
|
||||
},
|
||||
textStyle: {
|
||||
link: {
|
||||
url: linkUrl,
|
||||
},
|
||||
},
|
||||
fields: 'link',
|
||||
},
|
||||
});
|
||||
charIndex += linkText.length;
|
||||
remaining = remaining.substring(linkMatch[0].length);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular character
|
||||
plainText += remaining[0];
|
||||
charIndex++;
|
||||
remaining = remaining.substring(1);
|
||||
}
|
||||
|
||||
return { plainText, requests, endIndex: charIndex };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse markdown and return plain text + formatting requests
|
||||
* @param {string} markdown - The markdown text
|
||||
* @returns {Object} { text: string, requests: Array }
|
||||
*/
|
||||
function parseMarkdown(markdown) {
|
||||
const requests = [];
|
||||
let plainText = '';
|
||||
let currentIndex = 1; // Google Docs starts at index 1
|
||||
|
||||
const lines = markdown.split('\n');
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
let line = lines[i];
|
||||
|
||||
// Horizontal rule
|
||||
if (line.match(/^---+$/)) {
|
||||
const ruleLine = '─'.repeat(50) + '\n';
|
||||
plainText += ruleLine;
|
||||
currentIndex += ruleLine.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Headers
|
||||
const headerMatch = line.match(/^(#{1,6})\s+(.+)$/);
|
||||
if (headerMatch) {
|
||||
const level = headerMatch[1].length;
|
||||
const headerContent = headerMatch[2];
|
||||
|
||||
// Process inline formatting in header
|
||||
const { plainText: processedText, requests: inlineRequests } =
|
||||
processInlineFormatting(headerContent, currentIndex);
|
||||
|
||||
const fullLine = processedText + '\n';
|
||||
|
||||
const headingStyle = level === 1 ? 'HEADING_1' :
|
||||
level === 2 ? 'HEADING_2' :
|
||||
level === 3 ? 'HEADING_3' :
|
||||
level === 4 ? 'HEADING_4' :
|
||||
level === 5 ? 'HEADING_5' : 'HEADING_6';
|
||||
|
||||
requests.push({
|
||||
updateParagraphStyle: {
|
||||
range: {
|
||||
startIndex: currentIndex,
|
||||
endIndex: currentIndex + fullLine.length,
|
||||
},
|
||||
paragraphStyle: {
|
||||
namedStyleType: headingStyle,
|
||||
},
|
||||
fields: 'namedStyleType',
|
||||
},
|
||||
});
|
||||
|
||||
requests.push(...inlineRequests);
|
||||
plainText += fullLine;
|
||||
currentIndex += fullLine.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Bullet lists - process inline formatting within
|
||||
const bulletMatch = line.match(/^[-*]\s+(.+)$/);
|
||||
if (bulletMatch) {
|
||||
const bulletContent = bulletMatch[1];
|
||||
const { plainText: processedText, requests: inlineRequests } =
|
||||
processInlineFormatting(bulletContent, currentIndex + 2); // +2 for "• "
|
||||
|
||||
const fullLine = '• ' + processedText + '\n';
|
||||
plainText += fullLine;
|
||||
requests.push(...inlineRequests);
|
||||
currentIndex += fullLine.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Numbered lists - process inline formatting within
|
||||
const numberedMatch = line.match(/^(\d+)\.\s+(.+)$/);
|
||||
if (numberedMatch) {
|
||||
const num = numberedMatch[1];
|
||||
const listContent = numberedMatch[2];
|
||||
const prefix = num + '. ';
|
||||
const { plainText: processedText, requests: inlineRequests } =
|
||||
processInlineFormatting(listContent, currentIndex + prefix.length);
|
||||
|
||||
const fullLine = prefix + processedText + '\n';
|
||||
plainText += fullLine;
|
||||
requests.push(...inlineRequests);
|
||||
currentIndex += fullLine.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular line - process inline formatting
|
||||
const { plainText: processedText, requests: inlineRequests } =
|
||||
processInlineFormatting(line, currentIndex);
|
||||
|
||||
const fullLine = processedText + '\n';
|
||||
plainText += fullLine;
|
||||
requests.push(...inlineRequests);
|
||||
currentIndex += fullLine.length;
|
||||
}
|
||||
|
||||
return { text: plainText, requests };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a formatted Google Doc from markdown
|
||||
* @param {Object} docs - Google Docs API instance
|
||||
* @param {string} documentId - Document ID
|
||||
* @param {string} markdown - Markdown content
|
||||
*/
|
||||
async function insertFormattedContent(docs, documentId, markdown) {
|
||||
const { text, requests } = parseMarkdown(markdown);
|
||||
|
||||
// First, insert the plain text
|
||||
await docs.documents.batchUpdate({
|
||||
documentId,
|
||||
requestBody: {
|
||||
requests: [{
|
||||
insertText: {
|
||||
location: { index: 1 },
|
||||
text,
|
||||
},
|
||||
}],
|
||||
},
|
||||
});
|
||||
|
||||
// Then apply formatting if there are any requests
|
||||
if (requests.length > 0) {
|
||||
await docs.documents.batchUpdate({
|
||||
documentId,
|
||||
requestBody: { requests },
|
||||
});
|
||||
}
|
||||
|
||||
return { textLength: text.length, formattingRequests: requests.length };
|
||||
}
|
||||
|
||||
// CLI test
|
||||
async function main() {
|
||||
const testMarkdown = `# Main Title
|
||||
|
||||
## Section One
|
||||
|
||||
This is **bold text** and this is a [link](https://example.com).
|
||||
|
||||
- First bullet with [a link](https://test.com)
|
||||
- Second bullet with **bold**
|
||||
- Third bullet
|
||||
|
||||
### Subsection
|
||||
|
||||
1. Numbered item with [link](https://one.com)
|
||||
2. Numbered item two
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
- [Source One](https://source1.com)
|
||||
- [Source Two](https://source2.com)
|
||||
`;
|
||||
|
||||
const result = parseMarkdown(testMarkdown);
|
||||
console.log('Plain text:');
|
||||
console.log(result.text);
|
||||
console.log('\nFormatting requests:', result.requests.length);
|
||||
console.log('\nSample requests:');
|
||||
result.requests.slice(0, 3).forEach(r => console.log(JSON.stringify(r, null, 2)));
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = { parseMarkdown, insertFormattedContent };
|
||||
Reference in New Issue
Block a user