runline 0.3.2 → 0.4.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.
- package/dist/commands/actions.d.ts +1 -0
- package/dist/commands/actions.js +17 -4
- package/dist/main.js +3 -2
- package/dist/plugins/gmail/src/index.js +13 -13
- package/dist/plugins/googleCalendar/src/index.js +795 -0
- package/dist/plugins/googleContacts/src/index.js +691 -0
- package/dist/plugins/googleDocs/src/index.js +669 -0
- package/dist/plugins/googleDrive/src/index.js +1161 -0
- package/dist/plugins/googleSheets/src/index.js +913 -0
- package/dist/plugins/googleSlides/src/index.js +319 -0
- package/dist/plugins/googleTasks/src/index.js +419 -0
- package/package.json +3 -2
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google Slides plugin for runline.
|
|
3
|
+
*
|
|
4
|
+
* OAuth2 user flow, same shape as the rest of the Google plugins.
|
|
5
|
+
* Scope: `auth/presentations` (full read/write on user's decks).
|
|
6
|
+
*
|
|
7
|
+
* Surface area:
|
|
8
|
+
* presentation.create / presentation.get
|
|
9
|
+
* presentation.listSlides
|
|
10
|
+
* presentation.replaceText (one or more find/replace pairs)
|
|
11
|
+
* presentation.batchUpdate (raw passthrough)
|
|
12
|
+
*
|
|
13
|
+
* page.get (a single slide by objectId)
|
|
14
|
+
* page.getThumbnail (returns a signed URL by default;
|
|
15
|
+
* set `savePath` to download to disk)
|
|
16
|
+
*
|
|
17
|
+
* Anything this plugin doesn't expose directly — layout edits,
|
|
18
|
+
* shape inserts, transform updates — goes through
|
|
19
|
+
* `presentation.batchUpdate`, which is a pass-through to Slides'
|
|
20
|
+
* `POST /v1/presentations/{id}:batchUpdate` endpoint.
|
|
21
|
+
*/
|
|
22
|
+
import { writeFileSync } from "node:fs";
|
|
23
|
+
// ─── OAuth ───────────────────────────────────────────────────────
|
|
24
|
+
const TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
|
|
25
|
+
const REFRESH_SKEW_MS = 60_000;
|
|
26
|
+
async function refreshAccessToken(ctx) {
|
|
27
|
+
const cfg = ctx.connection.config;
|
|
28
|
+
const { clientId, clientSecret, refreshToken } = cfg;
|
|
29
|
+
if (!clientId || !clientSecret || !refreshToken) {
|
|
30
|
+
throw new Error("googleSlides: missing clientId/clientSecret/refreshToken. Run the Slides OAuth helper to seed these.");
|
|
31
|
+
}
|
|
32
|
+
const body = new URLSearchParams({
|
|
33
|
+
client_id: clientId,
|
|
34
|
+
client_secret: clientSecret,
|
|
35
|
+
refresh_token: refreshToken,
|
|
36
|
+
grant_type: "refresh_token",
|
|
37
|
+
});
|
|
38
|
+
const res = await fetch(TOKEN_ENDPOINT, {
|
|
39
|
+
method: "POST",
|
|
40
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
41
|
+
body: body.toString(),
|
|
42
|
+
});
|
|
43
|
+
if (!res.ok) {
|
|
44
|
+
throw new Error(`googleSlides: token refresh failed (${res.status}): ${await res.text()}`);
|
|
45
|
+
}
|
|
46
|
+
const data = (await res.json());
|
|
47
|
+
const expiresAt = Date.now() + data.expires_in * 1000;
|
|
48
|
+
await ctx.updateConnection({
|
|
49
|
+
accessToken: data.access_token,
|
|
50
|
+
accessTokenExpiresAt: expiresAt,
|
|
51
|
+
});
|
|
52
|
+
return data.access_token;
|
|
53
|
+
}
|
|
54
|
+
async function accessToken(ctx) {
|
|
55
|
+
const cfg = ctx.connection.config;
|
|
56
|
+
if (cfg.accessToken &&
|
|
57
|
+
typeof cfg.accessTokenExpiresAt === "number" &&
|
|
58
|
+
Date.now() < cfg.accessTokenExpiresAt - REFRESH_SKEW_MS) {
|
|
59
|
+
return cfg.accessToken;
|
|
60
|
+
}
|
|
61
|
+
return refreshAccessToken(ctx);
|
|
62
|
+
}
|
|
63
|
+
// ─── Request ─────────────────────────────────────────────────────
|
|
64
|
+
const API_BASE = "https://slides.googleapis.com/v1";
|
|
65
|
+
async function slidesRequest(ctx, method, path, body, qs) {
|
|
66
|
+
const token = await accessToken(ctx);
|
|
67
|
+
const url = new URL(`${API_BASE}${path}`);
|
|
68
|
+
if (qs) {
|
|
69
|
+
for (const [k, v] of Object.entries(qs)) {
|
|
70
|
+
if (v === undefined || v === null)
|
|
71
|
+
continue;
|
|
72
|
+
if (Array.isArray(v)) {
|
|
73
|
+
for (const entry of v)
|
|
74
|
+
url.searchParams.append(k, String(entry));
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
url.searchParams.set(k, String(v));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const init = {
|
|
82
|
+
method,
|
|
83
|
+
headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
|
|
84
|
+
};
|
|
85
|
+
if (body && Object.keys(body).length > 0) {
|
|
86
|
+
init.headers["Content-Type"] = "application/json";
|
|
87
|
+
init.body = JSON.stringify(body);
|
|
88
|
+
}
|
|
89
|
+
const res = await fetch(url.toString(), init);
|
|
90
|
+
if (res.status === 204)
|
|
91
|
+
return { success: true };
|
|
92
|
+
const text = await res.text();
|
|
93
|
+
if (!res.ok) {
|
|
94
|
+
throw new Error(`googleSlides: ${method} ${path} → ${res.status} ${text}`);
|
|
95
|
+
}
|
|
96
|
+
return text ? JSON.parse(text) : { success: true };
|
|
97
|
+
}
|
|
98
|
+
// ─── Helpers ────────────────────────────────────────────────────
|
|
99
|
+
const PRES_URL_REGEX = /https:\/\/docs\.google\.com\/presentation\/d\/([a-zA-Z0-9-_]+)/;
|
|
100
|
+
/**
|
|
101
|
+
* Accept a bare presentation ID or a full docs.google.com URL.
|
|
102
|
+
* Falls through to the input unchanged if no URL is detected.
|
|
103
|
+
*/
|
|
104
|
+
function extractPresentationId(input) {
|
|
105
|
+
if (!input)
|
|
106
|
+
throw new Error("googleSlides: presentationId or URL is required");
|
|
107
|
+
const m = input.match(PRES_URL_REGEX);
|
|
108
|
+
return m ? m[1] : input;
|
|
109
|
+
}
|
|
110
|
+
// ─── Plugin ──────────────────────────────────────────────────────
|
|
111
|
+
const SCOPES = ["https://www.googleapis.com/auth/presentations"];
|
|
112
|
+
export default function googleSlides(rl) {
|
|
113
|
+
rl.setName("googleSlides");
|
|
114
|
+
rl.setVersion("0.1.0");
|
|
115
|
+
rl.setOAuth({
|
|
116
|
+
authUrl: "https://accounts.google.com/o/oauth2/v2/auth",
|
|
117
|
+
tokenUrl: "https://oauth2.googleapis.com/token",
|
|
118
|
+
scopes: SCOPES,
|
|
119
|
+
authParams: { access_type: "offline", prompt: "consent" },
|
|
120
|
+
setupHelp: [
|
|
121
|
+
"You need a Google Cloud OAuth client. Takes ~5 minutes, one time.",
|
|
122
|
+
"",
|
|
123
|
+
"1. Create or pick a Google Cloud project:",
|
|
124
|
+
" https://console.cloud.google.com/projectcreate",
|
|
125
|
+
"",
|
|
126
|
+
"2. Enable the Google Slides API:",
|
|
127
|
+
" https://console.cloud.google.com/apis/library/slides.googleapis.com",
|
|
128
|
+
"",
|
|
129
|
+
"3. Configure the OAuth consent screen:",
|
|
130
|
+
" https://console.cloud.google.com/apis/credentials/consent",
|
|
131
|
+
" • Audience: External",
|
|
132
|
+
"",
|
|
133
|
+
"4. Add yourself as a test user:",
|
|
134
|
+
" https://console.cloud.google.com/auth/audience",
|
|
135
|
+
"",
|
|
136
|
+
"5. Create the OAuth client:",
|
|
137
|
+
" https://console.cloud.google.com/apis/credentials",
|
|
138
|
+
" • + Create credentials → OAuth client ID",
|
|
139
|
+
" • Application type: Web application",
|
|
140
|
+
" • Authorized redirect URIs → + Add URI: {{redirectUri}}",
|
|
141
|
+
"",
|
|
142
|
+
"6. Paste the Client ID and Client Secret below, or export",
|
|
143
|
+
" GOOGLE_SLIDES_CLIENT_ID and GOOGLE_SLIDES_CLIENT_SECRET.",
|
|
144
|
+
],
|
|
145
|
+
});
|
|
146
|
+
rl.setConnectionSchema({
|
|
147
|
+
clientId: { type: "string", required: true, env: "GOOGLE_SLIDES_CLIENT_ID" },
|
|
148
|
+
clientSecret: { type: "string", required: true, env: "GOOGLE_SLIDES_CLIENT_SECRET" },
|
|
149
|
+
refreshToken: { type: "string", required: true, env: "GOOGLE_SLIDES_REFRESH_TOKEN" },
|
|
150
|
+
accessToken: { type: "string", required: false },
|
|
151
|
+
accessTokenExpiresAt: { type: "number", required: false },
|
|
152
|
+
});
|
|
153
|
+
// ── Presentation ──────────────────────────────────────
|
|
154
|
+
rl.registerAction("presentation.create", {
|
|
155
|
+
description: "Create a new empty presentation",
|
|
156
|
+
inputSchema: { title: { type: "string", required: true } },
|
|
157
|
+
async execute(input, ctx) {
|
|
158
|
+
const p = (input ?? {});
|
|
159
|
+
return slidesRequest(ctx, "POST", "/presentations", { title: p.title });
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
rl.registerAction("presentation.get", {
|
|
163
|
+
description: "Get a presentation. Accepts a bare ID or a docs.google.com/presentation URL.",
|
|
164
|
+
inputSchema: {
|
|
165
|
+
presentation: { type: "string", required: true },
|
|
166
|
+
fields: {
|
|
167
|
+
type: "string",
|
|
168
|
+
required: false,
|
|
169
|
+
description: "Fields projection (e.g. 'slides' to get slides only)",
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
async execute(input, ctx) {
|
|
173
|
+
const p = (input ?? {});
|
|
174
|
+
const id = extractPresentationId(p.presentation);
|
|
175
|
+
const qs = {};
|
|
176
|
+
if (p.fields)
|
|
177
|
+
qs.fields = p.fields;
|
|
178
|
+
return slidesRequest(ctx, "GET", `/presentations/${id}`, undefined, qs);
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
rl.registerAction("presentation.listSlides", {
|
|
182
|
+
description: "List slides in a presentation",
|
|
183
|
+
inputSchema: {
|
|
184
|
+
presentation: { type: "string", required: true },
|
|
185
|
+
limit: { type: "number", required: false, description: "Max number of slides to return" },
|
|
186
|
+
},
|
|
187
|
+
async execute(input, ctx) {
|
|
188
|
+
const p = (input ?? {});
|
|
189
|
+
const id = extractPresentationId(p.presentation);
|
|
190
|
+
const res = (await slidesRequest(ctx, "GET", `/presentations/${id}`, undefined, { fields: "slides" }));
|
|
191
|
+
const slides = res.slides ?? [];
|
|
192
|
+
if (typeof p.limit === "number")
|
|
193
|
+
return slides.slice(0, p.limit);
|
|
194
|
+
return slides;
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
rl.registerAction("presentation.replaceText", {
|
|
198
|
+
description: "Replace text in a presentation. Pass one or more {text, replaceText, matchCase?, pageObjectIds?} entries; each becomes a replaceAllText request in a single batchUpdate.",
|
|
199
|
+
inputSchema: {
|
|
200
|
+
presentation: { type: "string", required: true },
|
|
201
|
+
replacements: {
|
|
202
|
+
type: "array",
|
|
203
|
+
required: true,
|
|
204
|
+
description: "[{text, replaceText, matchCase?, pageObjectIds?}] — pageObjectIds limits the scope to specific slides",
|
|
205
|
+
},
|
|
206
|
+
revisionId: {
|
|
207
|
+
type: "string",
|
|
208
|
+
required: false,
|
|
209
|
+
description: "If set, request fails unless the presentation's current revisionId matches",
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
async execute(input, ctx) {
|
|
213
|
+
const p = (input ?? {});
|
|
214
|
+
const id = extractPresentationId(p.presentation);
|
|
215
|
+
const replacements = p.replacements;
|
|
216
|
+
if (!Array.isArray(replacements) || replacements.length === 0) {
|
|
217
|
+
throw new Error("googleSlides: replacements must be a non-empty array");
|
|
218
|
+
}
|
|
219
|
+
const requests = replacements.map((r) => ({
|
|
220
|
+
replaceAllText: {
|
|
221
|
+
replaceText: r.replaceText,
|
|
222
|
+
pageObjectIds: r.pageObjectIds ?? [],
|
|
223
|
+
containsText: {
|
|
224
|
+
text: r.text,
|
|
225
|
+
matchCase: r.matchCase === true,
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
}));
|
|
229
|
+
const body = { requests };
|
|
230
|
+
if (p.revisionId) {
|
|
231
|
+
body.writeControl = { requiredRevisionId: p.revisionId };
|
|
232
|
+
}
|
|
233
|
+
return slidesRequest(ctx, "POST", `/presentations/${id}:batchUpdate`, body);
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
rl.registerAction("presentation.batchUpdate", {
|
|
237
|
+
description: "Raw passthrough to presentations.batchUpdate — pass a full `requests` array for layout edits, shape inserts, transform updates, etc.",
|
|
238
|
+
inputSchema: {
|
|
239
|
+
presentation: { type: "string", required: true },
|
|
240
|
+
requests: { type: "array", required: true },
|
|
241
|
+
writeControl: {
|
|
242
|
+
type: "object",
|
|
243
|
+
required: false,
|
|
244
|
+
description: "{requiredRevisionId}",
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
async execute(input, ctx) {
|
|
248
|
+
const p = (input ?? {});
|
|
249
|
+
const id = extractPresentationId(p.presentation);
|
|
250
|
+
const body = { requests: p.requests };
|
|
251
|
+
if (p.writeControl)
|
|
252
|
+
body.writeControl = p.writeControl;
|
|
253
|
+
return slidesRequest(ctx, "POST", `/presentations/${id}:batchUpdate`, body);
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
// ── Page (slide) ──────────────────────────────────────
|
|
257
|
+
rl.registerAction("page.get", {
|
|
258
|
+
description: "Get a single slide (page) by objectId",
|
|
259
|
+
inputSchema: {
|
|
260
|
+
presentation: { type: "string", required: true },
|
|
261
|
+
pageObjectId: { type: "string", required: true },
|
|
262
|
+
},
|
|
263
|
+
async execute(input, ctx) {
|
|
264
|
+
const p = (input ?? {});
|
|
265
|
+
const id = extractPresentationId(p.presentation);
|
|
266
|
+
return slidesRequest(ctx, "GET", `/presentations/${id}/pages/${p.pageObjectId}`);
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
rl.registerAction("page.getThumbnail", {
|
|
270
|
+
description: "Get a thumbnail for a slide. Returns Slides' response { contentUrl, width, height } by default; set `savePath` to also download the PNG to disk, or `download=true` to return base64.",
|
|
271
|
+
inputSchema: {
|
|
272
|
+
presentation: { type: "string", required: true },
|
|
273
|
+
pageObjectId: { type: "string", required: true },
|
|
274
|
+
mimeType: {
|
|
275
|
+
type: "string",
|
|
276
|
+
required: false,
|
|
277
|
+
description: "PNG (default) — the only type Slides currently exposes",
|
|
278
|
+
},
|
|
279
|
+
thumbnailSize: {
|
|
280
|
+
type: "string",
|
|
281
|
+
required: false,
|
|
282
|
+
description: "THUMBNAIL_SIZE_UNSPECIFIED (default) | LARGE | MEDIUM | SMALL",
|
|
283
|
+
},
|
|
284
|
+
savePath: {
|
|
285
|
+
type: "string",
|
|
286
|
+
required: false,
|
|
287
|
+
description: "Write the PNG bytes to this filesystem path",
|
|
288
|
+
},
|
|
289
|
+
download: {
|
|
290
|
+
type: "boolean",
|
|
291
|
+
required: false,
|
|
292
|
+
description: "If true (and no savePath), include base64 bytes in the response",
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
async execute(input, ctx) {
|
|
296
|
+
const p = (input ?? {});
|
|
297
|
+
const id = extractPresentationId(p.presentation);
|
|
298
|
+
const qs = {};
|
|
299
|
+
if (p.mimeType)
|
|
300
|
+
qs["thumbnailProperties.mimeType"] = p.mimeType;
|
|
301
|
+
if (p.thumbnailSize)
|
|
302
|
+
qs["thumbnailProperties.thumbnailSize"] = p.thumbnailSize;
|
|
303
|
+
const res = (await slidesRequest(ctx, "GET", `/presentations/${id}/pages/${p.pageObjectId}/thumbnail`, undefined, qs));
|
|
304
|
+
if (!p.savePath && !p.download)
|
|
305
|
+
return res;
|
|
306
|
+
// contentUrl is a signed Google URL — fetch without auth headers.
|
|
307
|
+
const imgRes = await fetch(res.contentUrl);
|
|
308
|
+
if (!imgRes.ok) {
|
|
309
|
+
throw new Error(`googleSlides: thumbnail fetch failed (${imgRes.status}): ${await imgRes.text()}`);
|
|
310
|
+
}
|
|
311
|
+
const bytes = Buffer.from(await imgRes.arrayBuffer());
|
|
312
|
+
if (typeof p.savePath === "string") {
|
|
313
|
+
writeFileSync(p.savePath, bytes);
|
|
314
|
+
return { ...res, path: p.savePath, size: bytes.byteLength };
|
|
315
|
+
}
|
|
316
|
+
return { ...res, size: bytes.byteLength, contentBase64: bytes.toString("base64") };
|
|
317
|
+
},
|
|
318
|
+
});
|
|
319
|
+
}
|