@kamilio/wire 0.1.2 → 0.1.4
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/README.md +1 -1
- package/bin/wire-mcp.mjs +1 -1
- package/dist/adapters/root.d.ts +5 -2
- package/dist/adapters/root.d.ts.map +1 -1
- package/dist/adapters/root.js +58 -30
- package/dist/adapters/root.js.map +1 -1
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +9 -36
- package/dist/auth.js.map +1 -1
- package/dist/cli.js +2 -2
- package/dist/cli.js.map +1 -1
- package/dist/executable.d.ts +3 -1
- package/dist/executable.d.ts.map +1 -1
- package/dist/executable.js +38 -12
- package/dist/executable.js.map +1 -1
- package/dist/hooks.d.ts.map +1 -1
- package/dist/hooks.js +22 -7
- package/dist/hooks.js.map +1 -1
- package/node_modules/provider-asana/dist/asana-project.d.ts.map +1 -1
- package/node_modules/provider-asana/dist/asana-project.js +3 -0
- package/node_modules/provider-asana/dist/asana-project.js.map +1 -1
- package/node_modules/provider-asana/src/asana-project.ts +2 -0
- package/node_modules/provider-chatgpt/dist/chatgpt.d.ts.map +1 -1
- package/node_modules/provider-chatgpt/dist/chatgpt.js +24 -3
- package/node_modules/provider-chatgpt/dist/chatgpt.js.map +1 -1
- package/node_modules/provider-chatgpt/src/chatgpt.ts +21 -3
- package/node_modules/provider-gmail/dist/gmail.d.ts.map +1 -1
- package/node_modules/provider-gmail/dist/gmail.js +8 -2
- package/node_modules/provider-gmail/dist/gmail.js.map +1 -1
- package/node_modules/provider-gmail/src/gmail.ts +8 -2
- package/node_modules/provider-google-docs/dist/google-docs.d.ts.map +1 -1
- package/node_modules/provider-google-docs/dist/google-docs.js +373 -9
- package/node_modules/provider-google-docs/dist/google-docs.js.map +1 -1
- package/node_modules/provider-google-docs/dist/google-forms.d.ts +28 -0
- package/node_modules/provider-google-docs/dist/google-forms.d.ts.map +1 -0
- package/node_modules/provider-google-docs/dist/google-forms.js +191 -0
- package/node_modules/provider-google-docs/dist/google-forms.js.map +1 -0
- package/node_modules/provider-google-docs/dist/index.d.ts +26 -0
- package/node_modules/provider-google-docs/dist/index.d.ts.map +1 -1
- package/node_modules/provider-google-docs/dist/index.js +3 -1
- package/node_modules/provider-google-docs/dist/index.js.map +1 -1
- package/node_modules/provider-google-docs/src/google-docs.ts +342 -10
- package/node_modules/provider-google-docs/src/google-forms.ts +179 -0
- package/node_modules/provider-google-docs/src/index.ts +3 -1
- package/node_modules/provider-notion/dist/notion-sync.d.ts +2 -2
- package/node_modules/provider-notion/dist/notion-sync.d.ts.map +1 -1
- package/node_modules/provider-notion/dist/notion-sync.js +89 -38
- package/node_modules/provider-notion/dist/notion-sync.js.map +1 -1
- package/node_modules/provider-notion/dist/notion.js +1 -1
- package/node_modules/provider-notion/dist/notion.js.map +1 -1
- package/node_modules/provider-notion/src/notion-sync.ts +90 -35
- package/node_modules/provider-notion/src/notion.ts +1 -1
- package/node_modules/provider-slack/dist/slack.d.ts.map +1 -1
- package/node_modules/provider-slack/dist/slack.js +14 -7
- package/node_modules/provider-slack/dist/slack.js.map +1 -1
- package/node_modules/provider-slack/src/slack.ts +14 -5
- package/node_modules/provider-zoom/dist/zoom-hub.d.ts.map +1 -1
- package/node_modules/provider-zoom/dist/zoom-hub.js +42 -35
- package/node_modules/provider-zoom/dist/zoom-hub.js.map +1 -1
- package/node_modules/provider-zoom/src/zoom-hub.ts +40 -35
- package/node_modules/wire-core/dist/core/model.d.ts +1 -1
- package/node_modules/wire-core/dist/core/model.d.ts.map +1 -1
- package/node_modules/wire-core/dist/core/transform.d.ts +1 -0
- package/node_modules/wire-core/dist/core/transform.d.ts.map +1 -1
- package/node_modules/wire-core/dist/core/transform.js +4 -0
- package/node_modules/wire-core/dist/core/transform.js.map +1 -1
- package/node_modules/wire-core/dist/index.d.ts +3 -2
- package/node_modules/wire-core/dist/index.d.ts.map +1 -1
- package/node_modules/wire-core/dist/index.js +2 -2
- package/node_modules/wire-core/dist/index.js.map +1 -1
- package/node_modules/wire-core/dist/operations.d.ts +3 -0
- package/node_modules/wire-core/dist/operations.d.ts.map +1 -1
- package/node_modules/wire-core/dist/operations.js +50 -9
- package/node_modules/wire-core/dist/operations.js.map +1 -1
- package/node_modules/wire-core/dist/ports.d.ts +1 -0
- package/node_modules/wire-core/dist/ports.d.ts.map +1 -1
- package/node_modules/wire-core/dist/runtime/chrome.d.ts +1 -0
- package/node_modules/wire-core/dist/runtime/chrome.d.ts.map +1 -1
- package/node_modules/wire-core/dist/runtime/chrome.js +91 -83
- package/node_modules/wire-core/dist/runtime/chrome.js.map +1 -1
- package/node_modules/wire-core/dist/runtime/cookies.d.ts.map +1 -1
- package/node_modules/wire-core/dist/runtime/cookies.js +7 -2
- package/node_modules/wire-core/dist/runtime/cookies.js.map +1 -1
- package/node_modules/wire-core/dist/runtime/node.d.ts.map +1 -1
- package/node_modules/wire-core/dist/runtime/node.js +4 -0
- package/node_modules/wire-core/dist/runtime/node.js.map +1 -1
- package/node_modules/wire-core/dist/storage/registry.d.ts.map +1 -1
- package/node_modules/wire-core/dist/storage/registry.js +95 -208
- package/node_modules/wire-core/dist/storage/registry.js.map +1 -1
- package/node_modules/wire-core/dist/storage/workspace.d.ts +3 -0
- package/node_modules/wire-core/dist/storage/workspace.d.ts.map +1 -1
- package/node_modules/wire-core/dist/storage/workspace.js +10 -1
- package/node_modules/wire-core/dist/storage/workspace.js.map +1 -1
- package/node_modules/wire-core/package.json +4 -0
- package/node_modules/wire-core/src/core/model.ts +1 -1
- package/node_modules/wire-core/src/core/transform.ts +5 -1
- package/node_modules/wire-core/src/index.ts +3 -2
- package/node_modules/wire-core/src/operations.ts +56 -10
- package/node_modules/wire-core/src/ports.ts +1 -0
- package/node_modules/wire-core/src/runtime/chrome.ts +99 -80
- package/node_modules/wire-core/src/runtime/cookies.ts +4 -2
- package/node_modules/wire-core/src/runtime/node.ts +4 -0
- package/node_modules/wire-core/src/storage/registry.ts +48 -15
- package/node_modules/wire-core/src/storage/workspace.ts +10 -1
- package/package.json +1 -1
- package/src/adapters/root.ts +51 -31
- package/src/auth.ts +10 -29
- package/src/cli.ts +2 -2
- package/src/executable.ts +38 -12
- package/src/hooks.ts +21 -6
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { defineService } from "wire-core";
|
|
2
|
+
import { inflateRawSync } from "node:zlib";
|
|
2
3
|
import type { FetchedDocument, JsonObject, JsonValue, RuntimeCapabilities, Source } from "wire-core";
|
|
3
4
|
|
|
4
5
|
async function cookieHeader(runtime: RuntimeCapabilities): Promise<string> {
|
|
@@ -17,7 +18,7 @@ function filenameTitle(value: string, label: string): string {
|
|
|
17
18
|
const bare = /filename=([^;]+)/i.exec(value);
|
|
18
19
|
if (encoded === null && quoted === null && bare === null) throw new Error(`Google ${label} did not include a filename`);
|
|
19
20
|
const filename = encoded !== null ? decodeURIComponent(encoded[1]!) : quoted !== null ? quoted[1]! : bare![1]!.trim();
|
|
20
|
-
return filename.replace(/\.(csv|html|md|txt|xlsx)$/i, "");
|
|
21
|
+
return filename.replace(/\.(csv|html|md|pptx|txt|xlsx)$/i, "");
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
async function googleExport(runtime: RuntimeCapabilities, url: string, label: string): Promise<Readonly<{ title: string; text: string }>> {
|
|
@@ -29,6 +30,15 @@ async function googleExport(runtime: RuntimeCapabilities, url: string, label: st
|
|
|
29
30
|
return Object.freeze({ title: filenameTitle(disposition, label), text: await response.text() });
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
async function googleExportBytes(runtime: RuntimeCapabilities, url: string, label: string): Promise<Readonly<{ title: string; bytes: Uint8Array }>> {
|
|
34
|
+
const response = await runtime.http.request(url, { headers: { Cookie: await cookieHeader(runtime) } });
|
|
35
|
+
if (response.status === 401 || response.status === 403) throw cookieAuthenticationError();
|
|
36
|
+
if (!response.ok) throw new Error(`Google ${label} failed: HTTP ${response.status}`);
|
|
37
|
+
const disposition = response.headers.get("content-disposition");
|
|
38
|
+
if (disposition === null) throw cookieAuthenticationError();
|
|
39
|
+
return Object.freeze({ title: filenameTitle(disposition, label), bytes: new Uint8Array(await response.arrayBuffer()) });
|
|
40
|
+
}
|
|
41
|
+
|
|
32
42
|
async function googleText(runtime: RuntimeCapabilities, url: string, label: string): Promise<string> {
|
|
33
43
|
const response = await runtime.http.request(url, { headers: { Cookie: await cookieHeader(runtime) } });
|
|
34
44
|
if (response.status === 401 || response.status === 403) throw cookieAuthenticationError();
|
|
@@ -174,6 +184,268 @@ function documentTab(source: Source): string | null {
|
|
|
174
184
|
return typeof tab === "string" && tab !== "" ? tab : null;
|
|
175
185
|
}
|
|
176
186
|
|
|
187
|
+
type XmlChild = XmlNode | string;
|
|
188
|
+
type XmlNode = Readonly<{ name: string; attributes: ReadonlyMap<string, string>; children: readonly XmlChild[] }>;
|
|
189
|
+
|
|
190
|
+
function decodeXml(value: string): string {
|
|
191
|
+
return value.replace(/&(#x[0-9a-fA-F]+|#\d+|amp|lt|gt|quot|apos);/g, (_match, entity: string) => {
|
|
192
|
+
if (entity.startsWith("#x")) return String.fromCodePoint(Number.parseInt(entity.slice(2), 16));
|
|
193
|
+
if (entity.startsWith("#")) return String.fromCodePoint(Number.parseInt(entity.slice(1), 10));
|
|
194
|
+
if (entity === "amp") return "&";
|
|
195
|
+
if (entity === "lt") return "<";
|
|
196
|
+
if (entity === "gt") return ">";
|
|
197
|
+
if (entity === "quot") return "\"";
|
|
198
|
+
if (entity === "apos") return "'";
|
|
199
|
+
throw new Error(`PPTX XML unknown entity: ${entity}`);
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function xmlTagEnd(xml: string, start: number): number {
|
|
204
|
+
let quote: string | null = null;
|
|
205
|
+
for (let index = start + 1; index < xml.length; index += 1) {
|
|
206
|
+
const char = xml[index]!;
|
|
207
|
+
if (quote === null && char === ">") return index;
|
|
208
|
+
if (quote === null && (char === "\"" || char === "'")) quote = char;
|
|
209
|
+
else if (quote === char) quote = null;
|
|
210
|
+
}
|
|
211
|
+
throw new Error("PPTX XML tag is unterminated");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function parseAttributes(value: string): ReadonlyMap<string, string> {
|
|
215
|
+
const attributes = new Map<string, string>();
|
|
216
|
+
let index = 0;
|
|
217
|
+
while (index < value.length) {
|
|
218
|
+
while (index < value.length && /\s/.test(value[index]!)) index += 1;
|
|
219
|
+
if (index >= value.length) break;
|
|
220
|
+
const nameStart = index;
|
|
221
|
+
while (index < value.length && !/[\s=]/.test(value[index]!)) index += 1;
|
|
222
|
+
const name = value.slice(nameStart, index);
|
|
223
|
+
while (index < value.length && /\s/.test(value[index]!)) index += 1;
|
|
224
|
+
if (value[index] !== "=") throw new Error(`PPTX XML attribute ${name} is invalid`);
|
|
225
|
+
index += 1;
|
|
226
|
+
while (index < value.length && /\s/.test(value[index]!)) index += 1;
|
|
227
|
+
const quote = value[index]!;
|
|
228
|
+
if (quote !== "\"" && quote !== "'") throw new Error(`PPTX XML attribute ${name} is unquoted`);
|
|
229
|
+
index += 1;
|
|
230
|
+
const textStart = index;
|
|
231
|
+
while (index < value.length && value[index] !== quote) index += 1;
|
|
232
|
+
attributes.set(name, decodeXml(value.slice(textStart, index)));
|
|
233
|
+
index += 1;
|
|
234
|
+
}
|
|
235
|
+
return attributes;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function parseXml(xml: string): XmlNode {
|
|
239
|
+
const root: { name: string; attributes: ReadonlyMap<string, string>; children: XmlChild[] } = { name: "#document", attributes: new Map(), children: [] };
|
|
240
|
+
const stack: { name: string; attributes: ReadonlyMap<string, string>; children: XmlChild[] }[] = [root];
|
|
241
|
+
let index = 0;
|
|
242
|
+
while (index < xml.length) {
|
|
243
|
+
if (xml.startsWith("<?", index)) {
|
|
244
|
+
index = xml.indexOf("?>", index) + 2;
|
|
245
|
+
} else if (xml.startsWith("<!--", index)) {
|
|
246
|
+
index = xml.indexOf("-->", index) + 3;
|
|
247
|
+
} else if (xml.startsWith("<![CDATA[", index)) {
|
|
248
|
+
const end = xml.indexOf("]]>", index);
|
|
249
|
+
stack[stack.length - 1]!.children.push(xml.slice(index + 9, end));
|
|
250
|
+
index = end + 3;
|
|
251
|
+
} else if (xml.startsWith("</", index)) {
|
|
252
|
+
const end = xmlTagEnd(xml, index);
|
|
253
|
+
const name = xml.slice(index + 2, end).trim();
|
|
254
|
+
const node = stack.pop()!;
|
|
255
|
+
if (node.name !== name) throw new Error(`PPTX XML closing tag mismatch: ${name}`);
|
|
256
|
+
stack[stack.length - 1]!.children.push(Object.freeze({ name: node.name, attributes: node.attributes, children: Object.freeze(node.children) }));
|
|
257
|
+
index = end + 1;
|
|
258
|
+
} else if (xml[index] === "<") {
|
|
259
|
+
const end = xmlTagEnd(xml, index);
|
|
260
|
+
const raw = xml.slice(index + 1, end).trim();
|
|
261
|
+
const selfClosing = raw.endsWith("/");
|
|
262
|
+
const tag = selfClosing ? raw.slice(0, -1).trimEnd() : raw;
|
|
263
|
+
const nameEnd = tag.search(/\s/);
|
|
264
|
+
const name = nameEnd === -1 ? tag : tag.slice(0, nameEnd);
|
|
265
|
+
const attributes = parseAttributes(nameEnd === -1 ? "" : tag.slice(nameEnd + 1));
|
|
266
|
+
if (selfClosing) stack[stack.length - 1]!.children.push(Object.freeze({ name, attributes, children: Object.freeze([]) }));
|
|
267
|
+
else stack.push({ name, attributes, children: [] });
|
|
268
|
+
index = end + 1;
|
|
269
|
+
} else {
|
|
270
|
+
const end = xml.indexOf("<", index);
|
|
271
|
+
const text = decodeXml(xml.slice(index, end === -1 ? xml.length : end));
|
|
272
|
+
if (text !== "") stack[stack.length - 1]!.children.push(text);
|
|
273
|
+
index = end === -1 ? xml.length : end;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (stack.length !== 1) throw new Error("PPTX XML document is unclosed");
|
|
277
|
+
return Object.freeze({ name: root.name, attributes: root.attributes, children: Object.freeze(root.children) });
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function isXmlNode(value: XmlChild): value is XmlNode {
|
|
281
|
+
return typeof value !== "string";
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function xmlChildren(node: XmlNode, name: string): readonly XmlNode[] {
|
|
285
|
+
return node.children.filter(isXmlNode).filter((child) => child.name === name);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function xmlDescendants(node: XmlNode, name: string): readonly XmlNode[] {
|
|
289
|
+
return node.children.filter(isXmlNode).flatMap((child) => child.name === name ? [child, ...xmlDescendants(child, name)] : xmlDescendants(child, name));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function xmlText(node: XmlNode): string {
|
|
293
|
+
return node.children.map((child) => typeof child === "string" ? child : xmlText(child)).join("");
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function xmlAttribute(node: XmlNode, name: string): string {
|
|
297
|
+
const value = node.attributes.get(name);
|
|
298
|
+
if (value === undefined) throw new Error(`PPTX XML missing ${name}`);
|
|
299
|
+
return value;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function zipEndOfCentralDirectory(bytes: Uint8Array): number {
|
|
303
|
+
for (let index = bytes.length - 22; index >= Math.max(0, bytes.length - 65557); index -= 1) {
|
|
304
|
+
if (bytes[index] === 0x50 && bytes[index + 1] === 0x4b && bytes[index + 2] === 0x05 && bytes[index + 3] === 0x06) return index;
|
|
305
|
+
}
|
|
306
|
+
throw new Error("PPTX ZIP missing central directory");
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function unzipFiles(bytes: Uint8Array): ReadonlyMap<string, Uint8Array> {
|
|
310
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
311
|
+
const end = zipEndOfCentralDirectory(bytes);
|
|
312
|
+
const totalEntries = view.getUint16(end + 10, true);
|
|
313
|
+
let centralOffset = view.getUint32(end + 16, true);
|
|
314
|
+
const files = new Map<string, Uint8Array>();
|
|
315
|
+
const decoder = new TextDecoder();
|
|
316
|
+
for (let entryIndex = 0; entryIndex < totalEntries; entryIndex += 1) {
|
|
317
|
+
if (view.getUint32(centralOffset, true) !== 0x02014b50) throw new Error("PPTX ZIP central directory entry is invalid");
|
|
318
|
+
const method = view.getUint16(centralOffset + 10, true);
|
|
319
|
+
const compressedSize = view.getUint32(centralOffset + 20, true);
|
|
320
|
+
const nameLength = view.getUint16(centralOffset + 28, true);
|
|
321
|
+
const extraLength = view.getUint16(centralOffset + 30, true);
|
|
322
|
+
const commentLength = view.getUint16(centralOffset + 32, true);
|
|
323
|
+
const localOffset = view.getUint32(centralOffset + 42, true);
|
|
324
|
+
const name = decoder.decode(bytes.subarray(centralOffset + 46, centralOffset + 46 + nameLength));
|
|
325
|
+
if (view.getUint32(localOffset, true) !== 0x04034b50) throw new Error("PPTX ZIP local file entry is invalid");
|
|
326
|
+
const localNameLength = view.getUint16(localOffset + 26, true);
|
|
327
|
+
const localExtraLength = view.getUint16(localOffset + 28, true);
|
|
328
|
+
const dataStart = localOffset + 30 + localNameLength + localExtraLength;
|
|
329
|
+
const compressed = bytes.subarray(dataStart, dataStart + compressedSize);
|
|
330
|
+
if (method === 0) files.set(name, compressed);
|
|
331
|
+
else if (method === 8) files.set(name, inflateRawSync(compressed));
|
|
332
|
+
else throw new Error(`PPTX ZIP compression method is unsupported: ${method}`);
|
|
333
|
+
centralOffset += 46 + nameLength + extraLength + commentLength;
|
|
334
|
+
}
|
|
335
|
+
return files;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function zipText(files: ReadonlyMap<string, Uint8Array>, name: string): string {
|
|
339
|
+
const bytes = files.get(name);
|
|
340
|
+
if (bytes === undefined) throw new Error(`PPTX missing ${name}`);
|
|
341
|
+
return new TextDecoder().decode(bytes);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function relationshipTargets(xml: string): ReadonlyMap<string, string> {
|
|
345
|
+
const relationships = new Map<string, string>();
|
|
346
|
+
for (const relationship of xmlDescendants(parseXml(xml), "Relationship")) relationships.set(xmlAttribute(relationship, "Id"), xmlAttribute(relationship, "Target"));
|
|
347
|
+
return relationships;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function resolvePartPath(basePath: string, target: string): string {
|
|
351
|
+
const parts = basePath.split("/");
|
|
352
|
+
parts.pop();
|
|
353
|
+
for (const segment of target.split("/")) {
|
|
354
|
+
if (segment === "..") parts.pop();
|
|
355
|
+
else if (segment !== ".") parts.push(segment);
|
|
356
|
+
}
|
|
357
|
+
return parts.join("/");
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function slidePartPaths(files: ReadonlyMap<string, Uint8Array>): readonly string[] {
|
|
361
|
+
const presentation = parseXml(zipText(files, "ppt/presentation.xml"));
|
|
362
|
+
const relationships = relationshipTargets(zipText(files, "ppt/_rels/presentation.xml.rels"));
|
|
363
|
+
const slides = xmlDescendants(presentation, "p:sldId");
|
|
364
|
+
if (slides.length === 0) throw new Error("PPTX presentation has no slides");
|
|
365
|
+
return slides.map((slide) => {
|
|
366
|
+
const id = xmlAttribute(slide, "r:id");
|
|
367
|
+
const target = relationships.get(id);
|
|
368
|
+
if (target === undefined) throw new Error(`PPTX presentation missing slide relationship ${id}`);
|
|
369
|
+
return resolvePartPath("ppt/presentation.xml", target);
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function slideRelationshipsPath(slidePath: string): string {
|
|
374
|
+
const parts = slidePath.split("/");
|
|
375
|
+
const filename = parts.pop();
|
|
376
|
+
if (filename === undefined) throw new Error("PPTX slide path is invalid");
|
|
377
|
+
parts.push("_rels", `${filename}.rels`);
|
|
378
|
+
return parts.join("/");
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function markdownSpan(value: string, left: string, right: string): string {
|
|
382
|
+
const leading = /^[ \t]*/.exec(value)![0];
|
|
383
|
+
const trailing = /[ \t]*$/.exec(value)![0];
|
|
384
|
+
const core = value.slice(leading.length, value.length - trailing.length);
|
|
385
|
+
return core === "" ? value : `${leading}${left}${core}${right}${trailing}`;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function linkMarkdown(value: string, target: string): string {
|
|
389
|
+
const leading = /^[ \t]*/.exec(value)![0];
|
|
390
|
+
const trailing = /[ \t]*$/.exec(value)![0];
|
|
391
|
+
const core = value.slice(leading.length, value.length - trailing.length);
|
|
392
|
+
return core === "" ? value : `${leading}[${core.replace(/[[\]\\]/g, "\\$&")}](${target.replace(/\)/g, "%29")})${trailing}`;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function formattedRunMarkdown(run: XmlNode, relationships: ReadonlyMap<string, string>): string {
|
|
396
|
+
const text = xmlDescendants(run, "a:t").map(xmlText).join("");
|
|
397
|
+
const runProperties = xmlChildren(run, "a:rPr")[0];
|
|
398
|
+
if (runProperties === undefined) return text;
|
|
399
|
+
const hyperlink = xmlDescendants(runProperties, "a:hlinkClick")[0];
|
|
400
|
+
let markdown = text;
|
|
401
|
+
if (runProperties.attributes.get("u") !== undefined && runProperties.attributes.get("u") !== "none" && hyperlink === undefined) markdown = markdownSpan(markdown, "<u>", "</u>");
|
|
402
|
+
if (runProperties.attributes.get("strike") !== undefined && runProperties.attributes.get("strike") !== "noStrike") markdown = markdownSpan(markdown, "~~", "~~");
|
|
403
|
+
if (runProperties.attributes.get("b") === "1" && runProperties.attributes.get("i") === "1") markdown = markdownSpan(markdown, "***", "***");
|
|
404
|
+
else if (runProperties.attributes.get("b") === "1") markdown = markdownSpan(markdown, "**", "**");
|
|
405
|
+
else if (runProperties.attributes.get("i") === "1") markdown = markdownSpan(markdown, "_", "_");
|
|
406
|
+
if (hyperlink !== undefined) {
|
|
407
|
+
const id = xmlAttribute(hyperlink, "r:id");
|
|
408
|
+
const target = relationships.get(id);
|
|
409
|
+
if (target === undefined) throw new Error(`PPTX slide missing hyperlink relationship ${id}`);
|
|
410
|
+
markdown = linkMarkdown(markdown, target);
|
|
411
|
+
}
|
|
412
|
+
return markdown;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function paragraphMarkdown(paragraph: XmlNode, relationships: ReadonlyMap<string, string>): Readonly<{ text: string; list: "bullet" | "number" | null; level: number }> {
|
|
416
|
+
const pPr = xmlChildren(paragraph, "a:pPr")[0];
|
|
417
|
+
const list = pPr === undefined || xmlChildren(pPr, "a:buNone").length !== 0 ? null : xmlChildren(pPr, "a:buAutoNum").length !== 0 ? "number" : xmlChildren(pPr, "a:buChar").length !== 0 ? "bullet" : null;
|
|
418
|
+
const levelValue = pPr === undefined ? undefined : pPr.attributes.get("lvl");
|
|
419
|
+
const level = levelValue === undefined ? 0 : Number(levelValue);
|
|
420
|
+
const pieces: string[] = [];
|
|
421
|
+
for (const child of paragraph.children.filter(isXmlNode)) {
|
|
422
|
+
if (child.name === "a:br") pieces.push("\n");
|
|
423
|
+
if (child.name === "a:r" || child.name === "a:fld") pieces.push(formattedRunMarkdown(child, relationships));
|
|
424
|
+
}
|
|
425
|
+
return Object.freeze({ text: pieces.join("").replace(/[ \t]+$/gm, ""), list, level });
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function slideMarkdown(slide: XmlNode, relationships: ReadonlyMap<string, string>): string {
|
|
429
|
+
const paragraphs = xmlDescendants(slide, "a:p").map((paragraph) => paragraphMarkdown(paragraph, relationships)).filter((paragraph) => paragraph.text.trim() !== "");
|
|
430
|
+
const lines = paragraphs.map((paragraph, index) => {
|
|
431
|
+
if (index === 0 && paragraph.list === null) return `## ${paragraph.text.trim()}`;
|
|
432
|
+
if (paragraph.list === "bullet") return `${" ".repeat(paragraph.level)}- ${paragraph.text.trim()}`;
|
|
433
|
+
if (paragraph.list === "number") return `${" ".repeat(paragraph.level)}1. ${paragraph.text.trim()}`;
|
|
434
|
+
return paragraph.text.trim();
|
|
435
|
+
});
|
|
436
|
+
return lines.join("\n").replace(/\n{3,}/g, "\n\n");
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function slidesMarkdown(bytes: Uint8Array): string {
|
|
440
|
+
const files = unzipFiles(bytes);
|
|
441
|
+
const slides = slidePartPaths(files).map((slidePath) => {
|
|
442
|
+
const slide = parseXml(zipText(files, slidePath));
|
|
443
|
+
const relationships = relationshipTargets(zipText(files, slideRelationshipsPath(slidePath)));
|
|
444
|
+
return slideMarkdown(slide, relationships);
|
|
445
|
+
});
|
|
446
|
+
return `${slides.join("\n\n---\n\n")}\n`;
|
|
447
|
+
}
|
|
448
|
+
|
|
177
449
|
function urlParam(url: URL, name: string): string | null {
|
|
178
450
|
const query = url.searchParams.get(name);
|
|
179
451
|
const hash = new URLSearchParams(url.hash.slice(1)).get(name);
|
|
@@ -284,20 +556,60 @@ function docMarkdownEqual(left: string, right: string): boolean {
|
|
|
284
556
|
return comparableDocMarkdown(left) === comparableDocMarkdown(right);
|
|
285
557
|
}
|
|
286
558
|
|
|
287
|
-
|
|
559
|
+
type TextEdit = Readonly<{ base: string; local: string; before: string; localBefore: string; after: string; localAfter: string }>;
|
|
560
|
+
type MarkdownSpan = Readonly<{ start: number; end: number }>;
|
|
561
|
+
|
|
562
|
+
function textEdit(base: string, local: string): TextEdit {
|
|
288
563
|
let start = 0;
|
|
289
564
|
while (start < base.length && start < local.length && base[start] === local[start]) start += 1;
|
|
290
565
|
for (let window = 80; window >= 10; window -= 10) {
|
|
291
566
|
for (let baseEnd = start; baseEnd <= Math.min(base.length - window, start + 2000); baseEnd += 1) {
|
|
292
567
|
const anchor = base.slice(baseEnd, baseEnd + window);
|
|
293
568
|
const localEnd = local.indexOf(anchor, start);
|
|
294
|
-
if (localEnd !== -1 && localEnd <= start + 2000) return Object.freeze({ base: base.slice(start, baseEnd), local: local.slice(start, localEnd), before: base.slice(0, start), after: base.slice(baseEnd) });
|
|
569
|
+
if (localEnd !== -1 && localEnd <= start + 2000) return Object.freeze({ base: base.slice(start, baseEnd), local: local.slice(start, localEnd), before: base.slice(0, start), localBefore: "", after: base.slice(baseEnd), localAfter: local.slice(localEnd) });
|
|
295
570
|
}
|
|
296
571
|
}
|
|
297
572
|
let baseEnd = base.length;
|
|
298
573
|
let localEnd = local.length;
|
|
299
574
|
while (baseEnd > start && localEnd > start && base[baseEnd - 1] === local[localEnd - 1]) { baseEnd -= 1; localEnd -= 1; }
|
|
300
|
-
return Object.freeze({ base: base.slice(start, baseEnd), local: local.slice(start, localEnd), before: base.slice(0, start), after: base.slice(baseEnd) });
|
|
575
|
+
return Object.freeze({ base: base.slice(start, baseEnd), local: local.slice(start, localEnd), before: base.slice(0, start), localBefore: "", after: base.slice(baseEnd), localAfter: local.slice(localEnd) });
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function textEdits(base: string, local: string): readonly TextEdit[] {
|
|
579
|
+
const edits: TextEdit[] = [];
|
|
580
|
+
let basePrefix = "";
|
|
581
|
+
let localPrefix = "";
|
|
582
|
+
let baseRest = base;
|
|
583
|
+
let localRest = local;
|
|
584
|
+
while (baseRest !== localRest) {
|
|
585
|
+
const edit = textEdit(baseRest, localRest);
|
|
586
|
+
const before = `${basePrefix}${edit.before}`;
|
|
587
|
+
const localBefore = `${localPrefix}${edit.before}`;
|
|
588
|
+
edits.push(Object.freeze({ ...edit, before, localBefore }));
|
|
589
|
+
basePrefix = base.slice(0, base.length - edit.after.length);
|
|
590
|
+
localPrefix = local.slice(0, local.length - edit.localAfter.length);
|
|
591
|
+
baseRest = edit.after;
|
|
592
|
+
localRest = edit.localAfter;
|
|
593
|
+
}
|
|
594
|
+
return edits;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function inlineMarkdownSpans(value: string): readonly MarkdownSpan[] {
|
|
598
|
+
const spans: MarkdownSpan[] = [];
|
|
599
|
+
for (const pattern of [/\[[^\]\n]+\]\([^)]+\)/g, /!\[[^\]\n]*\]\([^)]+\)/g, /`[^`\n]+`/g, /~~[^~\n]+~~/g, /\*\*[^*\n]+\*\*/g, /\*[^*\n]+\*/g, /(^|[^\w])__[^_\n]+__(?=[^\w]|$)/g, /(^|[^\w])_[^_\n]+_(?=[^\w]|$)/g, /<((?:https?|mailto):[^>\s]+)>/g, /<\/?(?:u|sup|sub|mark|span)(?:\s+[^>]*)?>/gi]) {
|
|
600
|
+
let match = pattern.exec(value);
|
|
601
|
+
while (match !== null) {
|
|
602
|
+
spans.push(Object.freeze({ start: match.index, end: match.index + match[0]!.length }));
|
|
603
|
+
match = pattern.exec(value);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
return spans;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function editTouchesMarkdownSpan(before: string, value: string, edited: string): boolean {
|
|
610
|
+
const start = before.length;
|
|
611
|
+
const end = start + edited.length;
|
|
612
|
+
return inlineMarkdownSpans(value).some((span) => start === end ? start > span.start && start < span.end : start < span.end && end > span.start);
|
|
301
613
|
}
|
|
302
614
|
|
|
303
615
|
function context(value: string, side: "before" | "after", length: number): string {
|
|
@@ -368,15 +680,16 @@ function docTextRange(text: string, edit: Readonly<{ base: string; before: strin
|
|
|
368
680
|
}
|
|
369
681
|
|
|
370
682
|
async function uploadDocText(runtime: RuntimeCapabilities, documentId: string, key: string | null, tab: string, baseMarkdown: string, localMarkdown: string): Promise<void> {
|
|
371
|
-
const
|
|
683
|
+
const edits = textEdits(baseMarkdown, localMarkdown);
|
|
684
|
+
if (edits.some((edit) => editTouchesMarkdownSpan(edit.before, baseMarkdown, edit.base) || editTouchesMarkdownSpan(edit.localBefore, localMarkdown, edit.local))) throw new Error("Google Docs sync cannot preserve formatting in edited text");
|
|
372
685
|
const editUrl = docEditUrl(documentId, key, tab);
|
|
373
686
|
const session = docSession(await googleText(runtime, editUrl, "Docs editor"));
|
|
374
|
-
const
|
|
687
|
+
const ranges = edits.map((edit) => ({ edit, range: docTextRange(session.text, edit) })).sort((left, right) => right.range.start - left.range.start);
|
|
375
688
|
const sid = runtime.clock.now().getTime().toString(16).padStart(16, "0").slice(-16);
|
|
376
|
-
const commands = [
|
|
689
|
+
const commands = ranges.flatMap(({ edit, range }) => [
|
|
377
690
|
...(range.start === range.end ? [] : [{ ty: "ds", si: range.start + 1, ei: range.end }]),
|
|
378
691
|
...(edit.local === "" ? [] : [{ ty: "is", ibi: range.start + 1, s: markdownText(edit.local) }]),
|
|
379
|
-
];
|
|
692
|
+
]);
|
|
380
693
|
const params = new URLSearchParams({ id: documentId, sid, vc: "1", c: "1", w: "1", flr: "0", smv: "2147483647", smb: "[2147483647, AAE=]", token: session.token, ouid: session.ouid, includes_info_params: "true", cros_files: "false", nded: "false", tab });
|
|
381
694
|
const body = new URLSearchParams({ rev: String(session.revision), bundles: JSON.stringify([{ commands, sid, reqId: 0 }]) });
|
|
382
695
|
const response = await runtime.http.request(`https://docs.google.com/document/d/${encodeURIComponent(documentId)}/save?${params}`, { method: "POST", headers: { Cookie: await cookieHeader(runtime), "content-type": "application/x-www-form-urlencoded;charset=UTF-8", origin: "https://docs.google.com", referer: editUrl }, body: body.toString() });
|
|
@@ -401,6 +714,12 @@ async function fetchGoogleDocument(runtime: RuntimeCapabilities, source: Source)
|
|
|
401
714
|
exportUrl = `https://docs.google.com/spreadsheets/d/${encodeURIComponent(documentId)}/export?${query}`;
|
|
402
715
|
label = "Sheets CSV export";
|
|
403
716
|
sheetGid = gid;
|
|
717
|
+
} else if (source.type === "presentation") {
|
|
718
|
+
const query = new URLSearchParams({ format: "pptx" });
|
|
719
|
+
const key = resourceKey(source);
|
|
720
|
+
if (key !== null) query.set("resourcekey", key);
|
|
721
|
+
exportUrl = `https://docs.google.com/presentation/d/${encodeURIComponent(documentId)}/export?${query}`;
|
|
722
|
+
label = "Slides PPTX export";
|
|
404
723
|
} else {
|
|
405
724
|
const query = new URLSearchParams({ format: "md" });
|
|
406
725
|
const key = resourceKey(source);
|
|
@@ -410,6 +729,11 @@ async function fetchGoogleDocument(runtime: RuntimeCapabilities, source: Source)
|
|
|
410
729
|
exportUrl = `https://docs.google.com/document/d/${encodeURIComponent(documentId)}/export?${query}`;
|
|
411
730
|
label = "Docs Markdown export";
|
|
412
731
|
}
|
|
732
|
+
if (source.type === "presentation") {
|
|
733
|
+
const exported = await googleExportBytes(runtime, exportUrl, label);
|
|
734
|
+
const markdown = slidesMarkdown(exported.bytes);
|
|
735
|
+
return Object.freeze({ title: exported.title, markdown, data: { document_id: documentId, title: exported.title, output_path: null, sheet_gid: sheetGid, markdown, rows: null, presentation: true } });
|
|
736
|
+
}
|
|
413
737
|
const exported = await googleExport(runtime, exportUrl, label);
|
|
414
738
|
const rows = source["sheet_gid"] === undefined ? null : parseCsv(exported.text);
|
|
415
739
|
const markdown = rows === null ? exported.text : rowsMarkdown(rows);
|
|
@@ -419,6 +743,13 @@ async function fetchGoogleDocument(runtime: RuntimeCapabilities, source: Source)
|
|
|
419
743
|
|
|
420
744
|
async function synchronizeGoogleDocument(runtime: RuntimeCapabilities, _url: string, source: Source, base: JsonValue, markdown: string): Promise<FetchedDocument> {
|
|
421
745
|
const remote = await fetchGoogleDocument(runtime, source);
|
|
746
|
+
if (source.type === "presentation") {
|
|
747
|
+
const baseMarkdown = objectValue(base)["markdown"];
|
|
748
|
+
if (typeof baseMarkdown !== "string") throw new Error("Google sync base must include markdown");
|
|
749
|
+
if (markdown === baseMarkdown || markdown === remote.markdown) return remote;
|
|
750
|
+
if (remote.markdown === baseMarkdown) throw new Error("Google Slides sync is download-only. Revert local edits or use `wire download <url>` for a fresh copy.");
|
|
751
|
+
throw new Error("Google Slides changed remotely and locally. Resolve the conflict in Google Slides or the local Markdown file before syncing again.");
|
|
752
|
+
}
|
|
422
753
|
const baseMarkdown = objectValue(base)["markdown"];
|
|
423
754
|
const label = source["sheet_gid"] === undefined ? "Google Docs" : "Google Sheets";
|
|
424
755
|
if (typeof baseMarkdown !== "string") throw new Error("Google sync base must include markdown");
|
|
@@ -448,12 +779,13 @@ async function synchronizeGoogleDocument(runtime: RuntimeCapabilities, _url: str
|
|
|
448
779
|
|
|
449
780
|
export const googleDocsService = defineService<RuntimeCapabilities>({
|
|
450
781
|
name: "google-docs",
|
|
451
|
-
matches: (url) => url.hostname === "docs.google.com" && /^\/(document|spreadsheets)(?:\/u\/\d+)?\/d\/[^/]+(?:\/.*)?$/.test(url.pathname),
|
|
782
|
+
matches: (url) => url.hostname === "docs.google.com" && /^\/(document|presentation|spreadsheets)(?:\/u\/\d+)?\/d\/[^/]+(?:\/.*)?$/.test(url.pathname),
|
|
452
783
|
parse: (url) => {
|
|
453
|
-
const match = /^\/(document|spreadsheets)(?:\/u\/\d+)?\/d\/([^/]+)(?:\/.*)?$/.exec(url.pathname)!;
|
|
784
|
+
const match = /^\/(document|presentation|spreadsheets)(?:\/u\/\d+)?\/d\/([^/]+)(?:\/.*)?$/.exec(url.pathname)!;
|
|
454
785
|
const documentId = match[2]!;
|
|
455
786
|
const key = urlParam(url, "resourcekey");
|
|
456
787
|
const resource_key = key === null || key === "" ? {} : { resource_key: key };
|
|
788
|
+
if (match[1] === "presentation") return Object.freeze({ service: "google-docs", identifier: documentId, type: "presentation", ...resource_key });
|
|
457
789
|
if (match[1] === "spreadsheets") {
|
|
458
790
|
const queryGid = url.searchParams.get("gid");
|
|
459
791
|
const hashGid = new URLSearchParams(url.hash.slice(1)).get("gid");
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { defineService } from "wire-core";
|
|
2
|
+
import type { FetchedDocument, JsonObject, JsonValue, RuntimeCapabilities, Source } from "wire-core";
|
|
3
|
+
|
|
4
|
+
type FormAnswer = Readonly<{ questionId: string; text: string }>;
|
|
5
|
+
|
|
6
|
+
function formId(url: URL): string {
|
|
7
|
+
return /^\/forms(?:\/u\/\d+)?\/d\/([^/]+)(?:\/.*)?$/.exec(url.pathname)![1]!;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function formUrl(id: string): string {
|
|
11
|
+
return `https://docs.google.com/forms/d/${encodeURIComponent(id)}/edit`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function formsAuthError(): Error {
|
|
15
|
+
return new Error("Google Forms API authentication is missing or expired. Set GOOGLE_FORMS_TOKEN_FILE to an OAuth token with Forms scopes, then retry.");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function apiDisabledError(body: JsonObject): Error | null {
|
|
19
|
+
const error = body["error"] as JsonObject | undefined;
|
|
20
|
+
if (error === undefined) return null;
|
|
21
|
+
const details = Array.isArray(error["details"]) ? error["details"] as readonly JsonObject[] : [];
|
|
22
|
+
const serviceDisabled = details.find((detail) => {
|
|
23
|
+
const metadata = detail["metadata"] as JsonObject | undefined;
|
|
24
|
+
return metadata !== undefined && metadata["service"] === "forms.googleapis.com";
|
|
25
|
+
});
|
|
26
|
+
if (serviceDisabled === undefined) return null;
|
|
27
|
+
const metadata = serviceDisabled["metadata"] as JsonObject;
|
|
28
|
+
const activationUrl = typeof metadata["activationUrl"] === "string" ? metadata["activationUrl"] : typeof metadata["containerInfo"] === "string" ? `https://console.developers.google.com/apis/api/forms.googleapis.com/overview?project=${metadata["containerInfo"]}` : "https://console.developers.google.com/apis/api/forms.googleapis.com/overview";
|
|
29
|
+
return new Error(`Google Forms API is disabled. Enable it at ${activationUrl} then retry.`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function formsJson(runtime: RuntimeCapabilities, url: string, label: string): Promise<JsonObject> {
|
|
33
|
+
const token = await runtime.googleFormsTokens.load();
|
|
34
|
+
const response = await runtime.http.request(url, { headers: { authorization: `Bearer ${token.token}` } });
|
|
35
|
+
const text = await response.text();
|
|
36
|
+
const body = text === "" ? {} : JSON.parse(text) as JsonObject;
|
|
37
|
+
if (response.status === 401) throw formsAuthError();
|
|
38
|
+
if (!response.ok) {
|
|
39
|
+
const disabled = apiDisabledError(body);
|
|
40
|
+
if (disabled !== null) throw disabled;
|
|
41
|
+
const error = body["error"] as JsonObject | undefined;
|
|
42
|
+
const message = typeof error?.["message"] === "string" ? error["message"] : text;
|
|
43
|
+
if (response.status === 403 && /insufficient authentication scopes/i.test(message)) throw new Error("Google Forms API token is missing required scopes. Regenerate GOOGLE_FORMS_TOKEN_FILE with forms.body and forms.responses.readonly scopes.");
|
|
44
|
+
throw new Error(`Google Forms API ${label} failed: HTTP ${response.status}${message === "" ? "" : ` ${message}`}`);
|
|
45
|
+
}
|
|
46
|
+
return body;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function optionalString(value: JsonValue | undefined): string | null {
|
|
50
|
+
return typeof value === "string" && value !== "" ? value : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function jsonObject(value: JsonValue): value is JsonObject {
|
|
54
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function optionsMarkdown(options: JsonValue | undefined): string | null {
|
|
58
|
+
if (!Array.isArray(options)) return null;
|
|
59
|
+
return options.map((option) => {
|
|
60
|
+
const object = option as JsonObject;
|
|
61
|
+
return object["value"] as string;
|
|
62
|
+
}).join(", ");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function questionMarkdown(item: JsonObject): readonly string[] {
|
|
66
|
+
const lines = [`- ${item["title"] as string}`];
|
|
67
|
+
const questionItem = item["questionItem"] as JsonObject | undefined;
|
|
68
|
+
if (questionItem === undefined) return lines;
|
|
69
|
+
const question = questionItem["question"] as JsonObject;
|
|
70
|
+
lines.push(` - itemId: ${item["itemId"] as string}`);
|
|
71
|
+
lines.push(` - questionId: ${question["questionId"] as string}`);
|
|
72
|
+
if (question["required"] !== undefined) lines.push(` - required: ${String(question["required"])}`);
|
|
73
|
+
const textQuestion = question["textQuestion"] as JsonObject | undefined;
|
|
74
|
+
if (textQuestion !== undefined) lines.push(` - type: ${textQuestion["paragraph"] === true ? "paragraph" : "short_text"}`);
|
|
75
|
+
const choiceQuestion = question["choiceQuestion"] as JsonObject | undefined;
|
|
76
|
+
if (choiceQuestion !== undefined) {
|
|
77
|
+
lines.push(` - type: ${choiceQuestion["type"] as string}`);
|
|
78
|
+
const options = optionsMarkdown(choiceQuestion["options"]);
|
|
79
|
+
if (options !== null) lines.push(` - options: ${options}`);
|
|
80
|
+
}
|
|
81
|
+
const scaleQuestion = question["scaleQuestion"] as JsonObject | undefined;
|
|
82
|
+
if (scaleQuestion !== undefined) {
|
|
83
|
+
lines.push(" - type: scale");
|
|
84
|
+
lines.push(` - range: ${String(scaleQuestion["low"])} to ${String(scaleQuestion["high"])}`);
|
|
85
|
+
const lowLabel = optionalString(scaleQuestion["lowLabel"]);
|
|
86
|
+
const highLabel = optionalString(scaleQuestion["highLabel"]);
|
|
87
|
+
if (lowLabel !== null) lines.push(` - lowLabel: ${lowLabel}`);
|
|
88
|
+
if (highLabel !== null) lines.push(` - highLabel: ${highLabel}`);
|
|
89
|
+
}
|
|
90
|
+
if (question["dateQuestion"] !== undefined) lines.push(" - type: date");
|
|
91
|
+
if (question["timeQuestion"] !== undefined) lines.push(" - type: time");
|
|
92
|
+
const ratingQuestion = question["ratingQuestion"] as JsonObject | undefined;
|
|
93
|
+
if (ratingQuestion !== undefined) {
|
|
94
|
+
lines.push(" - type: rating");
|
|
95
|
+
lines.push(` - level: ${String(ratingQuestion["ratingScaleLevel"])}`);
|
|
96
|
+
lines.push(` - icon: ${String(ratingQuestion["iconType"])}`);
|
|
97
|
+
}
|
|
98
|
+
if (question["fileUploadQuestion"] !== undefined) lines.push(" - type: file_upload");
|
|
99
|
+
if (question["rowQuestion"] !== undefined) lines.push(" - type: row");
|
|
100
|
+
return lines;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function answerTexts(answer: JsonObject): readonly string[] {
|
|
104
|
+
const textAnswers = answer["textAnswers"] as JsonObject | undefined;
|
|
105
|
+
if (textAnswers !== undefined && Array.isArray(textAnswers["answers"])) return (textAnswers["answers"] as readonly JsonObject[]).map((value) => value["value"] as string);
|
|
106
|
+
return [JSON.stringify(answer)];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function formAnswers(response: JsonObject): readonly FormAnswer[] {
|
|
110
|
+
const answers = response["answers"] as JsonObject | undefined;
|
|
111
|
+
if (answers === undefined) return [];
|
|
112
|
+
return Object.entries(answers).flatMap(([questionId, answer]) => answerTexts(answer as JsonObject).map((text) => ({ questionId, text })));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function formMarkdown(form: JsonObject, responses: readonly JsonObject[]): string {
|
|
116
|
+
const info = form["info"] as JsonObject;
|
|
117
|
+
const title = info["title"] as string;
|
|
118
|
+
const id = form["formId"] as string;
|
|
119
|
+
const lines = [`# ${title}`, "", `- Form ID: ${id}`, `- Edit: ${formUrl(id)}`];
|
|
120
|
+
const responder = optionalString(form["responderUri"]);
|
|
121
|
+
if (responder !== null) lines.push(`- Responder: ${responder}`);
|
|
122
|
+
const publish = (form["publishSettings"] as JsonObject | undefined)?.["publishState"] as JsonObject | undefined;
|
|
123
|
+
if (publish !== undefined) {
|
|
124
|
+
lines.push(`- Published: ${String(publish["isPublished"])}`);
|
|
125
|
+
lines.push(`- Accepting responses: ${String(publish["isAcceptingResponses"])}`);
|
|
126
|
+
}
|
|
127
|
+
lines.push("", "## Items");
|
|
128
|
+
const items = form["items"] as readonly JsonObject[] | undefined;
|
|
129
|
+
if (items === undefined || items.length === 0) lines.push("No items.");
|
|
130
|
+
else for (const item of items) lines.push(...questionMarkdown(item));
|
|
131
|
+
lines.push("", "## Responses", "", `Response count: ${responses.length}`);
|
|
132
|
+
for (const response of responses) {
|
|
133
|
+
lines.push("", `### ${response["responseId"] as string}`);
|
|
134
|
+
const createTime = optionalString(response["createTime"]);
|
|
135
|
+
const submitted = optionalString(response["lastSubmittedTime"]);
|
|
136
|
+
if (createTime !== null) lines.push(`- Created: ${createTime}`);
|
|
137
|
+
if (submitted !== null) lines.push(`- Submitted: ${submitted}`);
|
|
138
|
+
for (const answer of formAnswers(response)) lines.push(`- ${answer.questionId}: ${answer.text}`);
|
|
139
|
+
}
|
|
140
|
+
return `${lines.join("\n")}\n`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function allResponses(runtime: RuntimeCapabilities, id: string): Promise<readonly JsonObject[]> {
|
|
144
|
+
const responses: JsonObject[] = [];
|
|
145
|
+
let pageToken: string | undefined;
|
|
146
|
+
for (;;) {
|
|
147
|
+
const url = new URL(`https://forms.googleapis.com/v1/forms/${encodeURIComponent(id)}/responses`);
|
|
148
|
+
if (pageToken !== undefined) url.searchParams.set("pageToken", pageToken);
|
|
149
|
+
const body = await formsJson(runtime, url.toString(), "responses.list");
|
|
150
|
+
if (Array.isArray(body["responses"])) responses.push(...body["responses"] as readonly JsonObject[]);
|
|
151
|
+
if (typeof body["nextPageToken"] !== "string") break;
|
|
152
|
+
pageToken = body["nextPageToken"];
|
|
153
|
+
}
|
|
154
|
+
return responses;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function fetchGoogleForm(runtime: RuntimeCapabilities, source: Source): Promise<FetchedDocument> {
|
|
158
|
+
const form = await formsJson(runtime, `https://forms.googleapis.com/v1/forms/${encodeURIComponent(source.identifier)}`, "forms.get");
|
|
159
|
+
const responses = await allResponses(runtime, source.identifier);
|
|
160
|
+
const info = form["info"] as JsonObject;
|
|
161
|
+
const markdown = formMarkdown(form, responses);
|
|
162
|
+
return Object.freeze({
|
|
163
|
+
title: info["title"] as string,
|
|
164
|
+
markdown,
|
|
165
|
+
data: { form, responses, markdown },
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export const googleFormsService = defineService<RuntimeCapabilities>({
|
|
170
|
+
name: "google-forms",
|
|
171
|
+
matches: (url) => url.hostname === "docs.google.com" && /^\/forms(?:\/u\/\d+)?\/d\/[^/]+(?:\/.*)?$/.test(url.pathname),
|
|
172
|
+
parse: (url) => Object.freeze({ service: "google-forms", identifier: formId(url), type: "form" }),
|
|
173
|
+
fetch: (runtime, _url, source) => fetchGoogleForm(runtime, source),
|
|
174
|
+
synchronize: async (runtime, _url, source, base, markdown) => {
|
|
175
|
+
const baseMarkdown = jsonObject(base) && typeof base["markdown"] === "string" ? base["markdown"] : null;
|
|
176
|
+
if (baseMarkdown !== null && markdown !== baseMarkdown) throw new Error("Google Forms sync is download-only. Revert local edits or use `wire download <url>` for a fresh copy.");
|
|
177
|
+
return fetchGoogleForm(runtime, source);
|
|
178
|
+
},
|
|
179
|
+
});
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { googleDocsService } from "./google-docs.js";
|
|
2
|
+
import { googleFormsService } from "./google-forms.js";
|
|
2
3
|
|
|
3
4
|
export { googleDocsService } from "./google-docs.js";
|
|
5
|
+
export { googleFormsService } from "./google-forms.js";
|
|
4
6
|
export const googleDocsProvider = Object.freeze({
|
|
5
|
-
services: [googleDocsService] as const,
|
|
7
|
+
services: [googleFormsService, googleDocsService] as const,
|
|
6
8
|
});
|
|
@@ -34,7 +34,7 @@ export type NotionDiffSummary = Readonly<{
|
|
|
34
34
|
}>;
|
|
35
35
|
type UserMentions = Readonly<Record<string, string>>;
|
|
36
36
|
export declare function parseNotionMarkdown(markdown: string): readonly NotionBlock[];
|
|
37
|
-
export declare function buildNotionCreateOperations(blocks: readonly NotionBlock[], pageId: string, spaceId: string, userId: string, currentTime: number, createId?: () => string): Readonly<{
|
|
37
|
+
export declare function buildNotionCreateOperations(blocks: readonly NotionBlock[], pageId: string, spaceId: string, userId: string, currentTime: number, createId?: () => string, initialTableColumnOrder?: readonly string[]): Readonly<{
|
|
38
38
|
operations: readonly NotionOperation[];
|
|
39
39
|
topLevelIds: readonly string[];
|
|
40
40
|
}>;
|
|
@@ -51,6 +51,6 @@ export declare function diffNotionBlockTrees(remoteTree: NotionTree, localBlocks
|
|
|
51
51
|
export declare function renderNotionTreeToMarkdown(tree: NotionTree, mentions?: UserMentions): string;
|
|
52
52
|
export declare function fetchNotionDocument(runtime: RuntimeCapabilities, url: string, source: Source): Promise<FetchedDocument>;
|
|
53
53
|
export declare function uploadNotionDocument(runtime: RuntimeCapabilities, markdown: string, _markdownPath: string): Promise<UploadedDocument>;
|
|
54
|
-
export declare function synchronizeNotionDocument(runtime: RuntimeCapabilities, url: string, base: JsonValue, markdown: string, _markdownPath: string): Promise<FetchedDocument>;
|
|
54
|
+
export declare function synchronizeNotionDocument(runtime: RuntimeCapabilities, url: string, source: Source, base: JsonValue, markdown: string, _markdownPath: string): Promise<FetchedDocument>;
|
|
55
55
|
export {};
|
|
56
56
|
//# sourceMappingURL=notion-sync.d.ts.map
|