main
  1#!/usr/bin/env node
  2
  3import {
  4  DEFAULT_VERSIONS,
  5  authorize,
  6  formatScopes,
  7  getGoogleApis,
  8} from "./common.js";
  9
 10function printHelp() {
 11  console.log(`Google Workspace API helper
 12
 13Usage:
 14  node workspace.js call <service> <method.path> [params-json] [--version vX] [--scopes s1,s2]
 15  node workspace.js calendar-today [calendarId]
 16  node workspace.js calendar-upcoming [days] [calendarId]
 17  node workspace.js drive-search <query>
 18  node workspace.js gmail-search <query>
 19  node workspace.js gmail-read <messageId>
 20
 21Examples:
 22  node workspace.js call drive files.list '{"pageSize":5,"fields":"files(id,name)"}'
 23  node workspace.js call calendar events.list '{"calendarId":"primary","maxResults":10,"singleEvents":true,"orderBy":"startTime"}'
 24  node workspace.js drive-search "name contains 'Roadmap' and trashed=false"
 25  node workspace.js gmail-search "from:alice@example.com newer_than:7d"
 26  node workspace.js gmail-read 18e1234abcd5678
 27  node workspace.js calendar-upcoming 3
 28`);
 29}
 30
 31function parseOptions(argv) {
 32  const positional = [];
 33  const options = {
 34    version: undefined,
 35    scopes: undefined,
 36  };
 37
 38  for (let i = 0; i < argv.length; i += 1) {
 39    const arg = argv[i];
 40    if (arg === "--version") {
 41      options.version = argv[i + 1];
 42      i += 1;
 43      continue;
 44    }
 45    if (arg === "--scopes") {
 46      options.scopes = formatScopes(argv[i + 1]);
 47      i += 1;
 48      continue;
 49    }
 50    positional.push(arg);
 51  }
 52
 53  return { positional, options };
 54}
 55
 56function resolveMethod(root, methodPath) {
 57  const parts = methodPath.split(".").filter(Boolean);
 58  if (parts.length === 0) {
 59    throw new Error("method.path is empty");
 60  }
 61
 62  let parent = root;
 63  for (let i = 0; i < parts.length - 1; i += 1) {
 64    parent = parent?.[parts[i]];
 65    if (!parent) {
 66      throw new Error(`Invalid method path (missing segment: ${parts[i]})`);
 67    }
 68  }
 69
 70  const methodName = parts[parts.length - 1];
 71  const method = parent?.[methodName];
 72
 73  if (typeof method !== "function") {
 74    throw new Error(
 75      `Invalid method path: ${methodPath}. Final segment is not callable.`
 76    );
 77  }
 78
 79  return { parent, method };
 80}
 81
 82function parseJsonObject(raw, label) {
 83  if (!raw) {
 84    return {};
 85  }
 86
 87  let parsed;
 88  try {
 89    parsed = JSON.parse(raw);
 90  } catch (error) {
 91    throw new Error(`${label} is not valid JSON: ${error.message}`);
 92  }
 93
 94  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
 95    throw new Error(`${label} must be a JSON object`);
 96  }
 97
 98  return parsed;
 99}
100
101async function callApi({ service, methodPath, params, version, scopes }) {
102  const google = getGoogleApis();
103  const factory = google[service];
104  if (typeof factory !== "function") {
105    throw new Error(`Unknown Google API service: ${service}`);
106  }
107
108  const auth = await authorize({
109    interactive: true,
110    scopes: scopes && scopes.length > 0 ? scopes : undefined,
111  });
112
113  const api = factory({
114    version: version || DEFAULT_VERSIONS[service] || "v1",
115    auth,
116  });
117
118  const { parent, method } = resolveMethod(api, methodPath);
119  const response = await method.call(parent, params);
120  return response?.data ?? response;
121}
122
123// --- Convenience commands ---
124
125async function cmdCall(args, options) {
126  const [service, methodPath, paramsRaw] = args;
127
128  if (!service || !methodPath) {
129    throw new Error("Usage: call <service> <method.path> [params-json]");
130  }
131
132  const params = parseJsonObject(paramsRaw, "params-json");
133  const data = await callApi({
134    service,
135    methodPath,
136    params,
137    version: options.version,
138    scopes: options.scopes,
139  });
140
141  console.log(JSON.stringify(data, null, 2));
142}
143
144async function cmdCalendarToday(args, options) {
145  const calendarId = args[0] || "primary";
146
147  const start = new Date();
148  start.setHours(0, 0, 0, 0);
149
150  const end = new Date(start);
151  end.setDate(end.getDate() + 1);
152
153  const data = await callApi({
154    service: "calendar",
155    methodPath: "events.list",
156    version: options.version,
157    scopes: options.scopes,
158    params: {
159      calendarId,
160      timeMin: start.toISOString(),
161      timeMax: end.toISOString(),
162      singleEvents: true,
163      orderBy: "startTime",
164    },
165  });
166
167  console.log(JSON.stringify(data, null, 2));
168}
169
170async function cmdCalendarUpcoming(args, options) {
171  const days = parseInt(args[0], 10) || 7;
172  const calendarId = args[1] || "primary";
173
174  const start = new Date();
175  const end = new Date(start);
176  end.setDate(end.getDate() + days);
177
178  const data = await callApi({
179    service: "calendar",
180    methodPath: "events.list",
181    version: options.version,
182    scopes: options.scopes,
183    params: {
184      calendarId,
185      timeMin: start.toISOString(),
186      timeMax: end.toISOString(),
187      singleEvents: true,
188      orderBy: "startTime",
189    },
190  });
191
192  console.log(JSON.stringify(data, null, 2));
193}
194
195async function cmdDriveSearch(args, options) {
196  const query = args.join(" ").trim();
197  if (!query) {
198    throw new Error("Usage: drive-search <query>");
199  }
200
201  const data = await callApi({
202    service: "drive",
203    methodPath: "files.list",
204    version: options.version,
205    scopes: options.scopes,
206    params: {
207      q: query,
208      pageSize: 20,
209      fields:
210        "nextPageToken, files(id,name,mimeType,modifiedTime,webViewLink)",
211    },
212  });
213
214  console.log(JSON.stringify(data, null, 2));
215}
216
217async function cmdGmailSearch(args, options) {
218  const query = args.join(" ").trim();
219  if (!query) {
220    throw new Error("Usage: gmail-search <query>");
221  }
222
223  const list = await callApi({
224    service: "gmail",
225    methodPath: "users.messages.list",
226    version: options.version,
227    scopes: options.scopes,
228    params: {
229      userId: "me",
230      q: query,
231      maxResults: 20,
232    },
233  });
234
235  const messages = list.messages || [];
236  const details = [];
237
238  for (const message of messages.slice(0, 10)) {
239    const full = await callApi({
240      service: "gmail",
241      methodPath: "users.messages.get",
242      version: options.version,
243      scopes: options.scopes,
244      params: {
245        userId: "me",
246        id: message.id,
247        format: "metadata",
248        metadataHeaders: ["From", "To", "Subject", "Date"],
249      },
250    });
251
252    details.push({
253      id: message.id,
254      threadId: message.threadId,
255      snippet: full.snippet,
256      payload: full.payload,
257    });
258  }
259
260  console.log(
261    JSON.stringify(
262      {
263        resultCount: messages.length,
264        messages: details,
265      },
266      null,
267      2
268    )
269  );
270}
271
272async function cmdGmailRead(args, options) {
273  const messageId = args[0];
274  if (!messageId) {
275    throw new Error("Usage: gmail-read <messageId>");
276  }
277
278  const data = await callApi({
279    service: "gmail",
280    methodPath: "users.messages.get",
281    version: options.version,
282    scopes: options.scopes,
283    params: {
284      userId: "me",
285      id: messageId,
286      format: "full",
287    },
288  });
289
290  // Extract headers
291  const headers = {};
292  if (data.payload && data.payload.headers) {
293    for (const h of data.payload.headers) {
294      headers[h.name] = h.value;
295    }
296  }
297
298  // Extract body text
299  let body = "";
300  function extractText(part) {
301    if (part.mimeType === "text/plain" && part.body && part.body.data) {
302      body += Buffer.from(part.body.data, "base64url").toString("utf8");
303    }
304    if (part.parts) {
305      for (const p of part.parts) {
306        extractText(p);
307      }
308    }
309  }
310
311  if (data.payload) {
312    extractText(data.payload);
313  }
314
315  console.log(
316    JSON.stringify(
317      {
318        id: data.id,
319        threadId: data.threadId,
320        from: headers["From"],
321        to: headers["To"],
322        subject: headers["Subject"],
323        date: headers["Date"],
324        snippet: data.snippet,
325        body: body || "(no plain text body)",
326      },
327      null,
328      2
329    )
330  );
331}
332
333// --- Main ---
334
335async function main() {
336  const { positional, options } = parseOptions(process.argv.slice(2));
337  const [command, ...args] = positional;
338
339  if (
340    !command ||
341    command === "help" ||
342    command === "--help" ||
343    command === "-h"
344  ) {
345    printHelp();
346    return;
347  }
348
349  if (command === "call") {
350    await cmdCall(args, options);
351    return;
352  }
353
354  if (command === "calendar-today") {
355    await cmdCalendarToday(args, options);
356    return;
357  }
358
359  if (command === "calendar-upcoming") {
360    await cmdCalendarUpcoming(args, options);
361    return;
362  }
363
364  if (command === "drive-search") {
365    await cmdDriveSearch(args, options);
366    return;
367  }
368
369  if (command === "gmail-search") {
370    await cmdGmailSearch(args, options);
371    return;
372  }
373
374  if (command === "gmail-read") {
375    await cmdGmailRead(args, options);
376    return;
377  }
378
379  throw new Error(`Unknown command: ${command}`);
380}
381
382main().catch((error) => {
383  console.error(`${error.message}`);
384  process.exit(1);
385});