runline 0.3.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,913 @@
1
+ /**
2
+ * Google Sheets plugin for runline.
3
+ *
4
+ * OAuth2 user flow, same shape as the other Google plugins. Scope:
5
+ * `auth/spreadsheets` (full read/write on user's sheets).
6
+ *
7
+ * Rows are passed in one of two shapes a code caller actually wants:
8
+ *
9
+ * rows as arrays of arrays: [["a","b"], ["c","d"]]
10
+ * rows as objects: [{name:"a", age:30}, {name:"b", age:31}]
11
+ *
12
+ * Actions:
13
+ * spreadsheet.create / spreadsheet.get / spreadsheet.delete
14
+ *
15
+ * sheet.addTab / sheet.deleteTab / sheet.listTabs
16
+ *
17
+ * sheet.read — array-of-arrays, or objects keyed by header row
18
+ * sheet.append — rows to the bottom
19
+ * sheet.update — update rows matched by a key column (or row_number)
20
+ * sheet.appendOrUpdate — upsert
21
+ * sheet.clear — whole sheet / rows / columns / range, optional keepFirstRow
22
+ * sheet.deleteDimension — drop N rows or columns starting at an index
23
+ * sheet.batchUpdate — raw passthrough to spreadsheets:batchUpdate
24
+ *
25
+ * Features: auto-column-add on update/upsert, multi-row
26
+ * batchUpdate, RAW vs USER_ENTERED value input, appending via
27
+ * values:append or PUT at lastRow+1, keepFirstRow on clear.
28
+ * Filtering is intentionally the caller's job — `read` returns
29
+ * raw rows / objects and callers post-filter.
30
+ */
31
+ const ROW_NUMBER = "row_number";
32
+ // ─── OAuth ───────────────────────────────────────────────────────
33
+ const TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
34
+ const REFRESH_SKEW_MS = 60_000;
35
+ async function refreshAccessToken(ctx) {
36
+ const cfg = ctx.connection.config;
37
+ const { clientId, clientSecret, refreshToken } = cfg;
38
+ if (!clientId || !clientSecret || !refreshToken) {
39
+ throw new Error("googleSheets: missing clientId/clientSecret/refreshToken. Run the Sheets OAuth helper to seed these.");
40
+ }
41
+ const body = new URLSearchParams({
42
+ client_id: clientId,
43
+ client_secret: clientSecret,
44
+ refresh_token: refreshToken,
45
+ grant_type: "refresh_token",
46
+ });
47
+ const res = await fetch(TOKEN_ENDPOINT, {
48
+ method: "POST",
49
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
50
+ body: body.toString(),
51
+ });
52
+ if (!res.ok) {
53
+ throw new Error(`googleSheets: token refresh failed (${res.status}): ${await res.text()}`);
54
+ }
55
+ const data = (await res.json());
56
+ const expiresAt = Date.now() + data.expires_in * 1000;
57
+ await ctx.updateConnection({
58
+ accessToken: data.access_token,
59
+ accessTokenExpiresAt: expiresAt,
60
+ });
61
+ return data.access_token;
62
+ }
63
+ async function accessToken(ctx) {
64
+ const cfg = ctx.connection.config;
65
+ if (cfg.accessToken &&
66
+ typeof cfg.accessTokenExpiresAt === "number" &&
67
+ Date.now() < cfg.accessTokenExpiresAt - REFRESH_SKEW_MS) {
68
+ return cfg.accessToken;
69
+ }
70
+ return refreshAccessToken(ctx);
71
+ }
72
+ // ─── Request ─────────────────────────────────────────────────────
73
+ const SHEETS_BASE = "https://sheets.googleapis.com";
74
+ async function sheetsRequest(ctx, method, path, body, qs, baseOverride) {
75
+ const token = await accessToken(ctx);
76
+ const url = new URL(`${baseOverride ?? SHEETS_BASE}${path}`);
77
+ if (qs) {
78
+ for (const [k, v] of Object.entries(qs)) {
79
+ if (v === undefined || v === null)
80
+ continue;
81
+ if (Array.isArray(v)) {
82
+ for (const entry of v)
83
+ url.searchParams.append(k, String(entry));
84
+ }
85
+ else {
86
+ url.searchParams.set(k, String(v));
87
+ }
88
+ }
89
+ }
90
+ const init = {
91
+ method,
92
+ headers: {
93
+ Authorization: `Bearer ${token}`,
94
+ Accept: "application/json",
95
+ },
96
+ };
97
+ if (body && Object.keys(body).length > 0) {
98
+ init.headers["Content-Type"] = "application/json";
99
+ init.body = JSON.stringify(body);
100
+ }
101
+ const res = await fetch(url.toString(), init);
102
+ if (res.status === 204)
103
+ return { success: true };
104
+ const text = await res.text();
105
+ if (!res.ok) {
106
+ throw new Error(`googleSheets: ${method} ${path} → ${res.status} ${text}`);
107
+ }
108
+ return text ? JSON.parse(text) : { success: true };
109
+ }
110
+ // ─── A1 helpers ─────────────────────────────────────────────────
111
+ /**
112
+ * Encode a range like `Sheet1!A1:B5` for use in a URL. Google's
113
+ * docs are explicit that the sheet-name portion needs URL-encoding
114
+ * (for non-ASCII / spaces) but the range portion should not be, so
115
+ * we split on `!` and encode only the left side. A bare sheet name
116
+ * gets wrapped in single quotes so Sheets doesn't try to parse
117
+ * something like `ABC` as an A1 range.
118
+ */
119
+ function encodeA1(range) {
120
+ if (range.includes("!")) {
121
+ const [sheet, ranges] = range.split("!");
122
+ return `${encodeURIComponent(sheet)}!${ranges}`;
123
+ }
124
+ return encodeURIComponent(`'${range}'`);
125
+ }
126
+ function columnNumberToLetter(n) {
127
+ let s = "";
128
+ let x = n;
129
+ while (x > 0) {
130
+ const rem = (x - 1) % 26;
131
+ s = String.fromCharCode(65 + rem) + s;
132
+ x = Math.floor((x - 1) / 26);
133
+ }
134
+ return s || "A";
135
+ }
136
+ function columnLetterToNumber(col) {
137
+ let n = 0;
138
+ for (const ch of col.toUpperCase()) {
139
+ const code = ch.charCodeAt(0) - 64;
140
+ if (code < 1 || code > 26)
141
+ throw new Error(`googleSheets: invalid column "${col}"`);
142
+ n = n * 26 + code;
143
+ }
144
+ return n;
145
+ }
146
+ /**
147
+ * Convert a row of objects into a 2D array, aligned to `columns`.
148
+ * Missing/null/undefined cells become empty strings; object values
149
+ * are JSON-stringified so they survive the round trip.
150
+ */
151
+ function objectsToRows(rows, columns) {
152
+ return rows.map((row) => {
153
+ return columns.map((col) => {
154
+ const v = row[col];
155
+ if (v === undefined || v === null)
156
+ return "";
157
+ if (typeof v === "object") {
158
+ try {
159
+ return JSON.stringify(v);
160
+ }
161
+ catch {
162
+ return String(v);
163
+ }
164
+ }
165
+ return String(v);
166
+ });
167
+ });
168
+ }
169
+ /**
170
+ * Pair a 2D array with a header row into an array of objects.
171
+ * Blank header cells are replaced with synthetic `col_N` keys so
172
+ * callers still see the data. Columns shorter than the header row
173
+ * are padded with empty strings.
174
+ */
175
+ function rowsToObjects(rows, headerRow) {
176
+ const keys = headerRow.map((h, i) => (h && h.length > 0 ? h : `col_${i}`));
177
+ return rows.map((row) => {
178
+ const obj = {};
179
+ for (let i = 0; i < keys.length; i++) {
180
+ obj[keys[i]] = row[i] ?? "";
181
+ }
182
+ return obj;
183
+ });
184
+ }
185
+ // ─── Sheet operations (shared helpers) ──────────────────────────
186
+ async function getValues(ctx, spreadsheetId, range, valueRenderOption = "FORMATTED_VALUE", dateTimeRenderOption = "FORMATTED_STRING") {
187
+ const res = (await sheetsRequest(ctx, "GET", `/v4/spreadsheets/${spreadsheetId}/values/${encodeA1(range)}`, undefined, { valueRenderOption, dateTimeRenderOption }));
188
+ return res.values ?? [];
189
+ }
190
+ async function getSheetProperties(ctx, spreadsheetId) {
191
+ const res = (await sheetsRequest(ctx, "GET", `/v4/spreadsheets/${spreadsheetId}`, undefined, { fields: "sheets.properties" }));
192
+ return res.sheets.map((s) => s.properties);
193
+ }
194
+ async function resolveSheetId(ctx, spreadsheetId, sheetNameOrId) {
195
+ if (typeof sheetNameOrId === "number")
196
+ return sheetNameOrId;
197
+ const asNumber = Number(sheetNameOrId);
198
+ const all = await getSheetProperties(ctx, spreadsheetId);
199
+ if (!Number.isNaN(asNumber) && all.some((p) => p.sheetId === asNumber)) {
200
+ return asNumber;
201
+ }
202
+ const match = all.find((p) => p.title === sheetNameOrId);
203
+ if (!match)
204
+ throw new Error(`googleSheets: sheet "${sheetNameOrId}" not found`);
205
+ return match.sheetId;
206
+ }
207
+ async function batchValuesUpdate(ctx, spreadsheetId, data, valueInputOption) {
208
+ return sheetsRequest(ctx, "POST", `/v4/spreadsheets/${spreadsheetId}/values:batchUpdate`, { data, valueInputOption });
209
+ }
210
+ async function spreadsheetBatchUpdate(ctx, spreadsheetId, requests) {
211
+ return sheetsRequest(ctx, "POST", `/v4/spreadsheets/${spreadsheetId}:batchUpdate`, { requests });
212
+ }
213
+ async function appendEmptyRow(ctx, spreadsheetId, sheetId) {
214
+ // Called before every non-API-append write to protect the
215
+ // PUT-at-lastRow path from "runs past end of sheet" errors. It's
216
+ // a no-op cost when the sheet already has blank rows below the
217
+ // data.
218
+ await spreadsheetBatchUpdate(ctx, spreadsheetId, [
219
+ { appendDimension: { sheetId, dimension: "ROWS", length: 1 } },
220
+ ]);
221
+ }
222
+ /**
223
+ * Turn a set of input rows (objects) into ranged cell updates plus
224
+ * (in upsert mode) a leftover set that should be appended.
225
+ *
226
+ * Options:
227
+ * - `matchKey`: column name used to locate the target row, or the
228
+ * literal `"row_number"` to use a synthetic 1-indexed row field.
229
+ * - `upsert`: when true, rows whose match value isn't found (or
230
+ * whose match key is missing) are collected into `appendData`.
231
+ * - `handlingExtraData`: how to treat input keys that don't exist
232
+ * as columns yet. "ignore" drops them, "error" throws,
233
+ * "insertInNewColumn" extends the header row and records the
234
+ * added columns in `newColumns`.
235
+ */
236
+ function prepareUpdateOrUpsert(inputRows, headerRow, keyColumnValues, dataStartRowIndex, matchKey, opts = {}) {
237
+ const { upsert = false, handlingExtraData = "insertInNewColumn" } = opts;
238
+ const updateData = [];
239
+ const appendData = [];
240
+ const newColumnsSet = new Set();
241
+ const columns = [...headerRow];
242
+ const keyIndex = matchKey === ROW_NUMBER ? -1 : columns.indexOf(matchKey);
243
+ if (matchKey !== ROW_NUMBER && keyIndex === -1 && !upsert) {
244
+ throw new Error(`googleSheets: match column "${matchKey}" not found in header row`);
245
+ }
246
+ for (const row of inputRows) {
247
+ if (handlingExtraData !== "ignore") {
248
+ for (const key of Object.keys(row)) {
249
+ if (key === ROW_NUMBER)
250
+ continue;
251
+ if (!columns.includes(key)) {
252
+ if (handlingExtraData === "error") {
253
+ throw new Error(`googleSheets: unexpected column "${key}" in input`);
254
+ }
255
+ newColumnsSet.add(key);
256
+ columns.push(key);
257
+ }
258
+ }
259
+ }
260
+ // ── Row-number match ────────────────────────────────
261
+ if (matchKey === ROW_NUMBER) {
262
+ const rowNumber = row[ROW_NUMBER];
263
+ if (rowNumber === undefined || rowNumber === null) {
264
+ if (upsert) {
265
+ appendData.push(row);
266
+ continue;
267
+ }
268
+ throw new Error("googleSheets: missing row_number on input row");
269
+ }
270
+ const rowNum = Number(rowNumber);
271
+ for (const name of columns) {
272
+ if (name === ROW_NUMBER)
273
+ continue;
274
+ const v = row[name];
275
+ if (v === undefined || v === null)
276
+ continue;
277
+ const colIdx = columns.indexOf(name);
278
+ const colLetter = columnNumberToLetter(colIdx + 1);
279
+ updateData.push({
280
+ range: `${colLetter}${rowNum}`,
281
+ values: [[stringifyCell(v)]],
282
+ });
283
+ }
284
+ continue;
285
+ }
286
+ // ── Key-column match ───────────────────────────────
287
+ const inputKey = row[matchKey];
288
+ if (inputKey === undefined || inputKey === null) {
289
+ if (upsert)
290
+ appendData.push(row);
291
+ continue;
292
+ }
293
+ const rowIdx = keyColumnValues.findIndex((v) => v !== undefined && String(v) === String(inputKey));
294
+ if (rowIdx === -1) {
295
+ if (upsert)
296
+ appendData.push(row);
297
+ continue;
298
+ }
299
+ const sheetRow = rowIdx + dataStartRowIndex + 1; // 1-indexed A1 row
300
+ for (const name of columns) {
301
+ if (name === matchKey)
302
+ continue;
303
+ if (name === ROW_NUMBER)
304
+ continue;
305
+ const v = row[name];
306
+ if (v === undefined || v === null)
307
+ continue;
308
+ const colIdx = columns.indexOf(name);
309
+ const colLetter = columnNumberToLetter(colIdx + 1);
310
+ updateData.push({
311
+ range: `${colLetter}${sheetRow}`,
312
+ values: [[stringifyCell(v)]],
313
+ });
314
+ }
315
+ }
316
+ return {
317
+ updateData,
318
+ appendData,
319
+ newColumns: [...newColumnsSet],
320
+ };
321
+ }
322
+ function stringifyCell(v) {
323
+ if (v === undefined || v === null)
324
+ return "";
325
+ if (typeof v === "object") {
326
+ try {
327
+ return JSON.stringify(v);
328
+ }
329
+ catch {
330
+ return String(v);
331
+ }
332
+ }
333
+ return String(v);
334
+ }
335
+ function hexToRgb(hex) {
336
+ const m = hex.replace(/^#/, "").match(/^([0-9a-f]{6})$/i);
337
+ if (!m)
338
+ return null;
339
+ const n = parseInt(m[1], 16);
340
+ return {
341
+ red: ((n >> 16) & 0xff) / 255,
342
+ green: ((n >> 8) & 0xff) / 255,
343
+ blue: (n & 0xff) / 255,
344
+ };
345
+ }
346
+ // ─── Plugin ──────────────────────────────────────────────────────
347
+ const SCOPES = [
348
+ "https://www.googleapis.com/auth/spreadsheets",
349
+ "https://www.googleapis.com/auth/drive.file",
350
+ ];
351
+ export default function googleSheets(rl) {
352
+ rl.setName("googleSheets");
353
+ rl.setVersion("0.1.0");
354
+ rl.setOAuth({
355
+ authUrl: "https://accounts.google.com/o/oauth2/v2/auth",
356
+ tokenUrl: "https://oauth2.googleapis.com/token",
357
+ scopes: SCOPES,
358
+ authParams: { access_type: "offline", prompt: "consent" },
359
+ setupHelp: [
360
+ "You need a Google Cloud OAuth client. Takes ~5 minutes, one time.",
361
+ "",
362
+ "1. Create or pick a Google Cloud project:",
363
+ " https://console.cloud.google.com/projectcreate",
364
+ "",
365
+ "2. Enable the Google Sheets API (and Drive API, required for",
366
+ " spreadsheet.delete):",
367
+ " https://console.cloud.google.com/apis/library/sheets.googleapis.com",
368
+ " https://console.cloud.google.com/apis/library/drive.googleapis.com",
369
+ "",
370
+ "3. Configure the OAuth consent screen:",
371
+ " https://console.cloud.google.com/apis/credentials/consent",
372
+ " • Audience: External",
373
+ "",
374
+ "4. Add yourself as a test user:",
375
+ " https://console.cloud.google.com/auth/audience",
376
+ "",
377
+ "5. Create the OAuth client:",
378
+ " https://console.cloud.google.com/apis/credentials",
379
+ " • + Create credentials → OAuth client ID",
380
+ " • Application type: Web application",
381
+ " • Authorized redirect URIs → + Add URI: {{redirectUri}}",
382
+ "",
383
+ "6. Paste the Client ID and Client Secret below, or export",
384
+ " GOOGLE_SHEETS_CLIENT_ID and GOOGLE_SHEETS_CLIENT_SECRET.",
385
+ ],
386
+ });
387
+ rl.setConnectionSchema({
388
+ clientId: {
389
+ type: "string",
390
+ required: true,
391
+ description: "Google OAuth2 client ID",
392
+ env: "GOOGLE_SHEETS_CLIENT_ID",
393
+ },
394
+ clientSecret: {
395
+ type: "string",
396
+ required: true,
397
+ description: "Google OAuth2 client secret",
398
+ env: "GOOGLE_SHEETS_CLIENT_SECRET",
399
+ },
400
+ refreshToken: {
401
+ type: "string",
402
+ required: true,
403
+ description: "OAuth2 refresh token",
404
+ env: "GOOGLE_SHEETS_REFRESH_TOKEN",
405
+ },
406
+ accessToken: { type: "string", required: false },
407
+ accessTokenExpiresAt: { type: "number", required: false },
408
+ });
409
+ // ── Spreadsheet ───────────────────────────────────────
410
+ rl.registerAction("spreadsheet.create", {
411
+ description: "Create a new spreadsheet",
412
+ inputSchema: {
413
+ title: { type: "string", required: true },
414
+ sheets: {
415
+ type: "array",
416
+ required: false,
417
+ description: "[{title, hidden?}] — initial sheet tabs",
418
+ },
419
+ locale: { type: "string", required: false, description: "e.g. en_US" },
420
+ autoRecalc: {
421
+ type: "string",
422
+ required: false,
423
+ description: "ON_CHANGE | MINUTE | HOUR",
424
+ },
425
+ },
426
+ async execute(input, ctx) {
427
+ const p = (input ?? {});
428
+ const body = {
429
+ properties: {
430
+ title: p.title,
431
+ ...(p.locale ? { locale: p.locale } : {}),
432
+ ...(p.autoRecalc ? { autoRecalc: p.autoRecalc } : {}),
433
+ },
434
+ };
435
+ if (Array.isArray(p.sheets) && p.sheets.length > 0) {
436
+ body.sheets = p.sheets.map((s) => ({
437
+ properties: s,
438
+ }));
439
+ }
440
+ return sheetsRequest(ctx, "POST", "/v4/spreadsheets", body);
441
+ },
442
+ });
443
+ rl.registerAction("spreadsheet.get", {
444
+ description: "Get spreadsheet metadata (sheets, properties, optional grid data)",
445
+ inputSchema: {
446
+ spreadsheetId: { type: "string", required: true },
447
+ includeGridData: { type: "boolean", required: false },
448
+ ranges: {
449
+ type: "array",
450
+ required: false,
451
+ description: "Limit the response to specific A1 ranges",
452
+ },
453
+ fields: { type: "string", required: false },
454
+ },
455
+ async execute(input, ctx) {
456
+ const p = (input ?? {});
457
+ const qs = {};
458
+ if (p.includeGridData)
459
+ qs.includeGridData = p.includeGridData;
460
+ if (Array.isArray(p.ranges))
461
+ qs.ranges = p.ranges;
462
+ if (p.fields)
463
+ qs.fields = p.fields;
464
+ return sheetsRequest(ctx, "GET", `/v4/spreadsheets/${p.spreadsheetId}`, undefined, qs);
465
+ },
466
+ });
467
+ rl.registerAction("spreadsheet.delete", {
468
+ description: "Delete a spreadsheet (via Drive API — requires drive.file scope)",
469
+ inputSchema: { spreadsheetId: { type: "string", required: true } },
470
+ async execute(input, ctx) {
471
+ const p = (input ?? {});
472
+ await sheetsRequest(ctx, "DELETE", `/drive/v3/files/${p.spreadsheetId}`, undefined, undefined, "https://www.googleapis.com");
473
+ return { success: true };
474
+ },
475
+ });
476
+ // ── Sheet tabs ────────────────────────────────────────
477
+ rl.registerAction("sheet.listTabs", {
478
+ description: "List sheet tabs in a spreadsheet",
479
+ inputSchema: { spreadsheetId: { type: "string", required: true } },
480
+ async execute(input, ctx) {
481
+ const p = (input ?? {});
482
+ return getSheetProperties(ctx, p.spreadsheetId);
483
+ },
484
+ });
485
+ rl.registerAction("sheet.addTab", {
486
+ description: "Add a new sheet tab",
487
+ inputSchema: {
488
+ spreadsheetId: { type: "string", required: true },
489
+ title: { type: "string", required: true },
490
+ index: { type: "number", required: false, description: "Position within the spreadsheet" },
491
+ sheetId: { type: "number", required: false, description: "Custom tab ID (non-negative)" },
492
+ hidden: { type: "boolean", required: false },
493
+ rightToLeft: { type: "boolean", required: false },
494
+ tabColor: { type: "string", required: false, description: "Hex RGB (e.g. #0aa55c)" },
495
+ },
496
+ async execute(input, ctx) {
497
+ const p = (input ?? {});
498
+ const properties = { title: p.title };
499
+ if (p.index !== undefined)
500
+ properties.index = p.index;
501
+ if (p.sheetId !== undefined)
502
+ properties.sheetId = p.sheetId;
503
+ if (p.hidden !== undefined)
504
+ properties.hidden = p.hidden;
505
+ if (p.rightToLeft !== undefined)
506
+ properties.rightToLeft = p.rightToLeft;
507
+ if (typeof p.tabColor === "string") {
508
+ const rgb = hexToRgb(p.tabColor);
509
+ if (rgb)
510
+ properties.tabColor = rgb;
511
+ }
512
+ const res = (await spreadsheetBatchUpdate(ctx, p.spreadsheetId, [{ addSheet: { properties } }]));
513
+ return res.replies?.[0]?.addSheet?.properties ?? res;
514
+ },
515
+ });
516
+ rl.registerAction("sheet.deleteTab", {
517
+ description: "Delete a sheet tab",
518
+ inputSchema: {
519
+ spreadsheetId: { type: "string", required: true },
520
+ sheet: {
521
+ type: "string",
522
+ required: true,
523
+ description: "Tab title or numeric sheetId",
524
+ },
525
+ },
526
+ async execute(input, ctx) {
527
+ const p = (input ?? {});
528
+ const sheetId = await resolveSheetId(ctx, p.spreadsheetId, p.sheet);
529
+ await spreadsheetBatchUpdate(ctx, p.spreadsheetId, [
530
+ { deleteSheet: { sheetId } },
531
+ ]);
532
+ return { success: true, sheetId };
533
+ },
534
+ });
535
+ // ── Read ──────────────────────────────────────────────
536
+ rl.registerAction("sheet.read", {
537
+ description: "Read values from a range. Returns a 2D array by default; set `asObjects=true` to pair rows with a header row and return objects.",
538
+ inputSchema: {
539
+ spreadsheetId: { type: "string", required: true },
540
+ range: {
541
+ type: "string",
542
+ required: true,
543
+ description: "Sheet tab name, or full A1 range (e.g. 'Sheet1!A1:C10')",
544
+ },
545
+ asObjects: { type: "boolean", required: false },
546
+ headerRow: {
547
+ type: "number",
548
+ required: false,
549
+ description: "1-indexed header row (default: 1, used with asObjects)",
550
+ },
551
+ dataStartRow: {
552
+ type: "number",
553
+ required: false,
554
+ description: "1-indexed first data row (default: headerRow + 1)",
555
+ },
556
+ valueRenderOption: {
557
+ type: "string",
558
+ required: false,
559
+ description: "FORMATTED_VALUE (default) | UNFORMATTED_VALUE | FORMULA",
560
+ },
561
+ dateTimeRenderOption: {
562
+ type: "string",
563
+ required: false,
564
+ description: "FORMATTED_STRING (default) | SERIAL_NUMBER",
565
+ },
566
+ includeRowNumber: {
567
+ type: "boolean",
568
+ required: false,
569
+ description: "Attach `row_number` to each object (asObjects only)",
570
+ },
571
+ },
572
+ async execute(input, ctx) {
573
+ const p = (input ?? {});
574
+ const rows = await getValues(ctx, p.spreadsheetId, p.range, p.valueRenderOption ?? "FORMATTED_VALUE", p.dateTimeRenderOption ?? "FORMATTED_STRING");
575
+ if (!p.asObjects)
576
+ return rows;
577
+ const headerRowIdx = Math.max(1, p.headerRow ?? 1) - 1;
578
+ const dataStartIdx = (p.dataStartRow ?? headerRowIdx + 2) - 1;
579
+ const header = rows[headerRowIdx] ?? [];
580
+ const dataRows = rows.slice(dataStartIdx);
581
+ const objects = rowsToObjects(dataRows, header);
582
+ if (p.includeRowNumber) {
583
+ return objects.map((o, i) => ({
584
+ [ROW_NUMBER]: dataStartIdx + i + 1,
585
+ ...o,
586
+ }));
587
+ }
588
+ return objects;
589
+ },
590
+ });
591
+ // ── Append ────────────────────────────────────────────
592
+ rl.registerAction("sheet.append", {
593
+ description: "Append rows to the bottom. Pass `rows` as arrays (matching columns) or objects (keyed by header names; extra keys become new columns by default).",
594
+ inputSchema: {
595
+ spreadsheetId: { type: "string", required: true },
596
+ sheet: {
597
+ type: "string",
598
+ required: true,
599
+ description: "Tab name or numeric sheetId",
600
+ },
601
+ rows: {
602
+ type: "array",
603
+ required: true,
604
+ description: "Array of arrays, or array of objects",
605
+ },
606
+ headerRow: {
607
+ type: "number",
608
+ required: false,
609
+ description: "1-indexed header row (default: 1, used when rows are objects)",
610
+ },
611
+ valueInputOption: {
612
+ type: "string",
613
+ required: false,
614
+ description: "USER_ENTERED (default) | RAW",
615
+ },
616
+ handlingExtraData: {
617
+ type: "string",
618
+ required: false,
619
+ description: "insertInNewColumn (default) | ignore | error",
620
+ },
621
+ useAppend: {
622
+ type: "boolean",
623
+ required: false,
624
+ description: "Use values:append (safer w/ formulas but rewrites filters) instead of PUT at lastRow+1",
625
+ },
626
+ },
627
+ async execute(input, ctx) {
628
+ const p = (input ?? {});
629
+ const spreadsheetId = p.spreadsheetId;
630
+ const sheetName = typeof p.sheet === "number"
631
+ ? (await getSheetProperties(ctx, spreadsheetId)).find((s) => s.sheetId === p.sheet)?.title
632
+ : p.sheet;
633
+ if (!sheetName)
634
+ throw new Error(`googleSheets: sheet "${p.sheet}" not found`);
635
+ const rows = p.rows;
636
+ if (!Array.isArray(rows) || rows.length === 0) {
637
+ return { updatedRange: null, updatedRows: 0 };
638
+ }
639
+ const valueInputOption = p.valueInputOption ?? "USER_ENTERED";
640
+ const headerRowIdx = Math.max(1, p.headerRow ?? 1) - 1;
641
+ let values;
642
+ if (Array.isArray(rows[0])) {
643
+ values = rows;
644
+ }
645
+ else {
646
+ // Object rows: project onto header row, optionally extending it.
647
+ const sheetData = await getValues(ctx, spreadsheetId, sheetName, "FORMATTED_VALUE");
648
+ let headers = sheetData[headerRowIdx] ?? [];
649
+ const handling = p.handlingExtraData ??
650
+ "insertInNewColumn";
651
+ const newCols = [];
652
+ for (const row of rows) {
653
+ for (const k of Object.keys(row)) {
654
+ if (k === ROW_NUMBER)
655
+ continue;
656
+ if (!headers.includes(k)) {
657
+ if (handling === "error") {
658
+ throw new Error(`googleSheets: unexpected column "${k}" in input`);
659
+ }
660
+ if (handling === "insertInNewColumn" && !newCols.includes(k)) {
661
+ newCols.push(k);
662
+ }
663
+ }
664
+ }
665
+ }
666
+ if (newCols.length > 0) {
667
+ headers = [...headers, ...newCols];
668
+ // Write the extended header row back to the sheet.
669
+ await sheetsRequest(ctx, "PUT", `/v4/spreadsheets/${spreadsheetId}/values/${encodeA1(`${sheetName}!A${headerRowIdx + 1}`)}`, { range: `${sheetName}!A${headerRowIdx + 1}`, values: [headers] }, { valueInputOption });
670
+ }
671
+ values = objectsToRows(rows, headers);
672
+ }
673
+ if (p.useAppend) {
674
+ return sheetsRequest(ctx, "POST", `/v4/spreadsheets/${spreadsheetId}/values/${encodeA1(sheetName)}:append`, { range: sheetName, values }, { valueInputOption, insertDataOption: "INSERT_ROWS" });
675
+ }
676
+ // PUT at lastRow+1: calculate lastRow from current sheet contents.
677
+ const existing = await getValues(ctx, spreadsheetId, sheetName, "UNFORMATTED_VALUE");
678
+ const lastRow = existing.length;
679
+ const sheetId = await resolveSheetId(ctx, spreadsheetId, sheetName);
680
+ await appendEmptyRow(ctx, spreadsheetId, sheetId);
681
+ const targetRange = `${sheetName}!${lastRow + 1}:${lastRow + values.length}`;
682
+ return sheetsRequest(ctx, "PUT", `/v4/spreadsheets/${spreadsheetId}/values/${encodeA1(targetRange)}`, { range: targetRange, values }, { valueInputOption });
683
+ },
684
+ });
685
+ // ── Update ────────────────────────────────────────────
686
+ rl.registerAction("sheet.update", {
687
+ description: "Update rows matched by a key column (or by the synthetic 'row_number' field). Rows are objects; undefined/null values are skipped.",
688
+ inputSchema: {
689
+ spreadsheetId: { type: "string", required: true },
690
+ sheet: { type: "string", required: true },
691
+ rows: { type: "array", required: true, description: "Array of objects" },
692
+ matchKey: {
693
+ type: "string",
694
+ required: true,
695
+ description: "Column name to match on, or 'row_number'",
696
+ },
697
+ headerRow: { type: "number", required: false, description: "1-indexed (default: 1)" },
698
+ dataStartRow: { type: "number", required: false },
699
+ valueInputOption: { type: "string", required: false },
700
+ handlingExtraData: { type: "string", required: false },
701
+ },
702
+ async execute(input, ctx) {
703
+ const p = (input ?? {});
704
+ return runUpdateOrUpsert(ctx, p, false);
705
+ },
706
+ });
707
+ rl.registerAction("sheet.appendOrUpdate", {
708
+ description: "Upsert rows. Rows whose match value is found are updated in place; rows with a missing or unknown match value are appended.",
709
+ inputSchema: {
710
+ spreadsheetId: { type: "string", required: true },
711
+ sheet: { type: "string", required: true },
712
+ rows: { type: "array", required: true, description: "Array of objects" },
713
+ matchKey: { type: "string", required: true },
714
+ headerRow: { type: "number", required: false },
715
+ dataStartRow: { type: "number", required: false },
716
+ valueInputOption: { type: "string", required: false },
717
+ handlingExtraData: { type: "string", required: false },
718
+ useAppend: { type: "boolean", required: false },
719
+ },
720
+ async execute(input, ctx) {
721
+ const p = (input ?? {});
722
+ return runUpdateOrUpsert(ctx, p, true);
723
+ },
724
+ });
725
+ // ── Clear ─────────────────────────────────────────────
726
+ rl.registerAction("sheet.clear", {
727
+ description: "Clear values from a sheet. Modes: wholeSheet (optional keepFirstRow) | rows | columns | range.",
728
+ inputSchema: {
729
+ spreadsheetId: { type: "string", required: true },
730
+ sheet: { type: "string", required: true },
731
+ mode: {
732
+ type: "string",
733
+ required: false,
734
+ description: "wholeSheet (default) | rows | columns | range",
735
+ },
736
+ startRow: { type: "number", required: false, description: "1-indexed, for mode=rows" },
737
+ rowCount: { type: "number", required: false, description: "for mode=rows" },
738
+ startColumn: { type: "string", required: false, description: "A1 letter, for mode=columns" },
739
+ columnCount: { type: "number", required: false, description: "for mode=columns" },
740
+ range: { type: "string", required: false, description: "A1 region, for mode=range" },
741
+ keepFirstRow: {
742
+ type: "boolean",
743
+ required: false,
744
+ description: "mode=wholeSheet only — preserve row 1",
745
+ },
746
+ },
747
+ async execute(input, ctx) {
748
+ const p = (input ?? {});
749
+ const sheetName = typeof p.sheet === "number"
750
+ ? (await getSheetProperties(ctx, p.spreadsheetId)).find((s) => s.sheetId === p.sheet)?.title
751
+ : p.sheet;
752
+ if (!sheetName)
753
+ throw new Error(`googleSheets: sheet "${p.sheet}" not found`);
754
+ const mode = p.mode ?? "wholeSheet";
755
+ let range;
756
+ if (mode === "rows") {
757
+ const start = p.startRow ?? 1;
758
+ const count = p.rowCount ?? 1;
759
+ const end = count === 1 ? start : start + count - 1;
760
+ range = `${sheetName}!${start}:${end}`;
761
+ }
762
+ else if (mode === "columns") {
763
+ const startCol = p.startColumn ?? "A";
764
+ const count = p.columnCount ?? 1;
765
+ const startN = columnLetterToNumber(startCol);
766
+ const endN = count === 1 ? startN : startN + count - 1;
767
+ range = `${sheetName}!${startCol}:${columnNumberToLetter(endN)}`;
768
+ }
769
+ else if (mode === "range") {
770
+ const region = String(p.range ?? "");
771
+ range = region.includes("!") ? `${sheetName}!${region.split("!")[1]}` : `${sheetName}!${region}`;
772
+ }
773
+ else {
774
+ range = sheetName;
775
+ }
776
+ if (mode === "wholeSheet" && p.keepFirstRow) {
777
+ const firstRow = await getValues(ctx, p.spreadsheetId, `${sheetName}!1:1`, "FORMATTED_VALUE");
778
+ await sheetsRequest(ctx, "POST", `/v4/spreadsheets/${p.spreadsheetId}/values/${encodeA1(range)}:clear`, {});
779
+ if (firstRow.length > 0) {
780
+ await sheetsRequest(ctx, "PUT", `/v4/spreadsheets/${p.spreadsheetId}/values/${encodeA1(`${sheetName}!1:1`)}`, { range: `${sheetName}!1:1`, values: firstRow }, { valueInputOption: "RAW" });
781
+ }
782
+ return { success: true };
783
+ }
784
+ return sheetsRequest(ctx, "POST", `/v4/spreadsheets/${p.spreadsheetId}/values/${encodeA1(range)}:clear`, {});
785
+ },
786
+ });
787
+ // ── Delete rows/columns ───────────────────────────────
788
+ rl.registerAction("sheet.deleteDimension", {
789
+ description: "Delete a range of rows or columns",
790
+ inputSchema: {
791
+ spreadsheetId: { type: "string", required: true },
792
+ sheet: { type: "string", required: true },
793
+ dimension: { type: "string", required: true, description: "ROWS | COLUMNS" },
794
+ startIndex: {
795
+ type: "number",
796
+ required: true,
797
+ description: "1-indexed (row number) or column letter position",
798
+ },
799
+ count: { type: "number", required: false, description: "How many to delete (default: 1)" },
800
+ },
801
+ async execute(input, ctx) {
802
+ const p = (input ?? {});
803
+ const sheetId = await resolveSheetId(ctx, p.spreadsheetId, p.sheet);
804
+ const start = (p.startIndex ?? 1) - 1; // Sheets API is 0-indexed
805
+ const count = p.count ?? 1;
806
+ await spreadsheetBatchUpdate(ctx, p.spreadsheetId, [
807
+ {
808
+ deleteDimension: {
809
+ range: {
810
+ sheetId,
811
+ dimension: String(p.dimension).toUpperCase(),
812
+ startIndex: start,
813
+ endIndex: start + count,
814
+ },
815
+ },
816
+ },
817
+ ]);
818
+ return { success: true };
819
+ },
820
+ });
821
+ // ── Raw batchUpdate ───────────────────────────────────
822
+ rl.registerAction("sheet.batchUpdate", {
823
+ description: "Raw passthrough to spreadsheets:batchUpdate for anything this plugin doesn't expose directly (formatting, merges, conditional rules, …).",
824
+ inputSchema: {
825
+ spreadsheetId: { type: "string", required: true },
826
+ requests: { type: "array", required: true },
827
+ },
828
+ async execute(input, ctx) {
829
+ const p = (input ?? {});
830
+ return spreadsheetBatchUpdate(ctx, p.spreadsheetId, p.requests);
831
+ },
832
+ });
833
+ }
834
+ /**
835
+ * Shared implementation for `sheet.update` and `sheet.appendOrUpdate`.
836
+ * Reads current headers + the key column, decides per row whether
837
+ * it's an update or an append, issues one `values:batchUpdate` for
838
+ * the updates, and (in upsert mode) falls through to the append
839
+ * path for the rest.
840
+ */
841
+ async function runUpdateOrUpsert(ctx, p, upsert) {
842
+ const spreadsheetId = p.spreadsheetId;
843
+ const sheetIdOrName = p.sheet;
844
+ const rows = p.rows;
845
+ if (!Array.isArray(rows) || rows.length === 0) {
846
+ return { updated: 0, appended: 0 };
847
+ }
848
+ const matchKey = p.matchKey;
849
+ if (!matchKey)
850
+ throw new Error("googleSheets: matchKey is required");
851
+ const valueInputOption = p.valueInputOption ?? "USER_ENTERED";
852
+ const allProps = await getSheetProperties(ctx, spreadsheetId);
853
+ const prop = allProps.find((s) => s.title === sheetIdOrName || String(s.sheetId) === String(sheetIdOrName));
854
+ if (!prop)
855
+ throw new Error(`googleSheets: sheet "${sheetIdOrName}" not found`);
856
+ const sheetName = prop.title;
857
+ const sheetId = prop.sheetId;
858
+ const headerRowIdx = Math.max(1, p.headerRow ?? 1) - 1;
859
+ const dataStartRowIdx = Math.max(headerRowIdx + 1, (p.dataStartRow ?? headerRowIdx + 2) - 1);
860
+ const handlingExtraData = p.handlingExtraData ?? "insertInNewColumn";
861
+ // Headers come from FORMATTED_VALUE (cosmetic strings). The match
862
+ // column needs UNFORMATTED_VALUE so "1,234" in input matches 1234
863
+ // on the sheet (issue a second fetch with UNFORMATTED_VALUE).
864
+ const sheetData = await getValues(ctx, spreadsheetId, sheetName, "FORMATTED_VALUE");
865
+ let headers = sheetData[headerRowIdx] ?? [];
866
+ let keyColumnValues = [];
867
+ if (matchKey !== ROW_NUMBER) {
868
+ const idx = headers.indexOf(matchKey);
869
+ if (idx === -1) {
870
+ if (!upsert)
871
+ throw new Error(`googleSheets: match column "${matchKey}" not found`);
872
+ }
873
+ else {
874
+ const unformatted = await getValues(ctx, spreadsheetId, sheetName, "UNFORMATTED_VALUE");
875
+ keyColumnValues = unformatted.slice(dataStartRowIdx).map((r) => String(r[idx] ?? ""));
876
+ }
877
+ }
878
+ const prepared = prepareUpdateOrUpsert(rows, headers, keyColumnValues, dataStartRowIdx, matchKey, { upsert, handlingExtraData });
879
+ // If we added new columns, extend the header row on the sheet first.
880
+ if (prepared.newColumns.length > 0) {
881
+ headers = [...headers, ...prepared.newColumns];
882
+ await sheetsRequest(ctx, "PUT", `/v4/spreadsheets/${spreadsheetId}/values/${encodeA1(`${sheetName}!${headerRowIdx + 1}:${headerRowIdx + 1}`)}`, {
883
+ range: `${sheetName}!${headerRowIdx + 1}:${headerRowIdx + 1}`,
884
+ values: [headers],
885
+ }, { valueInputOption });
886
+ }
887
+ // Qualify update ranges with the sheet name (prepareUpdateOrUpsert
888
+ // returned bare A1 cells like "C5").
889
+ const updateData = prepared.updateData.map((d) => ({
890
+ range: `${sheetName}!${d.range}`,
891
+ values: d.values,
892
+ }));
893
+ if (updateData.length > 0) {
894
+ await batchValuesUpdate(ctx, spreadsheetId, updateData, valueInputOption);
895
+ }
896
+ if (upsert && prepared.appendData.length > 0) {
897
+ const values = objectsToRows(prepared.appendData, headers);
898
+ if (p.useAppend) {
899
+ await sheetsRequest(ctx, "POST", `/v4/spreadsheets/${spreadsheetId}/values/${encodeA1(sheetName)}:append`, { range: sheetName, values }, { valueInputOption, insertDataOption: "INSERT_ROWS" });
900
+ }
901
+ else {
902
+ const lastRow = sheetData.length;
903
+ await appendEmptyRow(ctx, spreadsheetId, sheetId);
904
+ const targetRange = `${sheetName}!${lastRow + 1}:${lastRow + values.length}`;
905
+ await sheetsRequest(ctx, "PUT", `/v4/spreadsheets/${spreadsheetId}/values/${encodeA1(targetRange)}`, { range: targetRange, values }, { valueInputOption });
906
+ }
907
+ }
908
+ return {
909
+ updated: prepared.updateData.length,
910
+ appended: prepared.appendData.length,
911
+ newColumns: prepared.newColumns,
912
+ };
913
+ }