@mcpher/gas-fakes 2.0.5 → 2.0.7
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 -0
- package/debug_form.js +43 -0
- package/package.json +12 -12
- package/src/index.js +1 -0
- package/src/services/advdrive/fakeadvdrivepermissions.js +15 -0
- package/src/services/chartsapp/app.js +8 -0
- package/src/services/chartsapp/fakechartsapp.js +64 -0
- package/src/services/driveapp/fakedriveapp.js +3 -2
- package/src/services/driveapp/fakedrivemeta.js +142 -17
- package/src/services/enums/chartsenums.js +24 -0
- package/src/services/enums/driveenums.js +19 -0
- package/src/services/enums/sheetsenums.js +1 -1
- package/src/services/formapp/fakeform.js +25 -0
- package/src/services/formapp/fakeformitem.js +3 -1
- package/src/services/formapp/fakeformresponse.js +95 -2
- package/src/services/spreadsheetapp/fakeembeddedchart.js +87 -0
- package/src/services/spreadsheetapp/fakeembeddedchartbuilder.js +154 -0
- package/src/services/spreadsheetapp/fakesheet.js +59 -4
- package/src/services/urlfetchapp/app.js +12 -1
- package/src/support/auth.js +10 -2
- package/src/support/fileiterators.js +1 -1
- package/src/support/sxfetch.js +32 -3
- package/src/support/sxretry.js +50 -18
package/README.md
CHANGED
|
@@ -181,6 +181,7 @@ As I mentioned earlier, to take this further, I'm going to need a lot of help to
|
|
|
181
181
|
- [oddities](oddities.md) - a collection of oddities uncovered during this project
|
|
182
182
|
- [named colors](named-colors.md)
|
|
183
183
|
- [sandbox](sandbox.md)
|
|
184
|
+
- [senstive scopes](senstive_scopes.md)
|
|
184
185
|
- [using apps script libraries with gas-fakes](libraries.md)
|
|
185
186
|
- [how libhandler works](libhandler.md)
|
|
186
187
|
- [article:using apps script libraries with gas-fakes](https://ramblings.mcpher.com/how-to-use-apps-script-libraries-directly-from-node/)
|
package/debug_form.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import './main.js';
|
|
2
|
+
import { Syncit } from './src/support/syncit.js';
|
|
3
|
+
|
|
4
|
+
async function debug() {
|
|
5
|
+
Syncit.fxInit();
|
|
6
|
+
|
|
7
|
+
try {
|
|
8
|
+
const resource = Forms.Form.create({
|
|
9
|
+
info: {
|
|
10
|
+
title: "Debug Form with Items"
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
const formId = resource.formId;
|
|
14
|
+
|
|
15
|
+
// Add a text item
|
|
16
|
+
const updateRequest = {
|
|
17
|
+
requests: [{
|
|
18
|
+
createItem: {
|
|
19
|
+
item: {
|
|
20
|
+
title: "Question 1",
|
|
21
|
+
questionItem: {
|
|
22
|
+
question: {
|
|
23
|
+
textQuestion: { paragraph: false }
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
location: { index: 0 }
|
|
28
|
+
}
|
|
29
|
+
}]
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const updateResponse = Forms.Form.batchUpdate(updateRequest, formId);
|
|
33
|
+
console.log("Update Response:", JSON.stringify(updateResponse, null, 2));
|
|
34
|
+
|
|
35
|
+
const fullForm = Forms.Form.get(formId);
|
|
36
|
+
console.log("Full Form Resource:", JSON.stringify(fullForm, null, 2));
|
|
37
|
+
|
|
38
|
+
} catch (e) {
|
|
39
|
+
console.error("Error:", e);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
debug();
|
package/package.json
CHANGED
|
@@ -4,21 +4,21 @@
|
|
|
4
4
|
},
|
|
5
5
|
"dependencies": {
|
|
6
6
|
"@mcpher/fake-gasenum": "^1.0.6",
|
|
7
|
-
"@mcpher/gas-flex-cache": "^1.1.
|
|
8
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
9
|
-
"@sindresorhus/is": "^7.0
|
|
7
|
+
"@mcpher/gas-flex-cache": "^1.1.5",
|
|
8
|
+
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
9
|
+
"@sindresorhus/is": "^7.2.0",
|
|
10
10
|
"acorn": "^8.15.0",
|
|
11
11
|
"archiver": "^7.0.1",
|
|
12
|
-
"commander": "^14.0.
|
|
13
|
-
"dotenv": "^17.
|
|
14
|
-
"fast-xml-parser": "^5.3.
|
|
12
|
+
"commander": "^14.0.3",
|
|
13
|
+
"dotenv": "^17.3.1",
|
|
14
|
+
"fast-xml-parser": "^5.3.6",
|
|
15
15
|
"get-stream": "^9.0.1",
|
|
16
16
|
"googleapis": "^170.1.0",
|
|
17
|
-
"got": "^14.
|
|
17
|
+
"got": "^14.6.6",
|
|
18
18
|
"into-stream": "^8.0.1",
|
|
19
|
-
"keyv": "^5.
|
|
20
|
-
"keyv-file": "^5.
|
|
21
|
-
"mime": "^4.0
|
|
19
|
+
"keyv": "^5.6.0",
|
|
20
|
+
"keyv-file": "^5.3.3",
|
|
21
|
+
"mime": "^4.1.0",
|
|
22
22
|
"prompts": "^2.4.2",
|
|
23
23
|
"sleep-synchronously": "^2.0.0",
|
|
24
24
|
"unzipper": "^0.12.3",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
},
|
|
34
34
|
"name": "@mcpher/gas-fakes",
|
|
35
35
|
"author": "bruce mcpherson",
|
|
36
|
-
"version": "2.0.
|
|
36
|
+
"version": "2.0.7",
|
|
37
37
|
"license": "MIT",
|
|
38
38
|
"main": "main.js",
|
|
39
39
|
"description": "An implementation of the Google Workspace Apps Script runtime: Run native App Script Code on Node and Cloud Run",
|
|
@@ -42,4 +42,4 @@
|
|
|
42
42
|
"bin": {
|
|
43
43
|
"gas-fakes": "gas-fakes.js"
|
|
44
44
|
}
|
|
45
|
-
}
|
|
45
|
+
}
|
package/src/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import './services/utilities/app.js'
|
|
|
8
8
|
import './services/spreadsheetapp/app.js'
|
|
9
9
|
import './services/gmailapp/app.js'
|
|
10
10
|
import './services/calendarapp/app.js'
|
|
11
|
+
import './services/chartsapp/app.js'
|
|
11
12
|
import './services/session/app.js'
|
|
12
13
|
import './services/advdrive/app.js'
|
|
13
14
|
import './services/advsheets/app.js'
|
|
@@ -31,6 +31,21 @@ class FakeAdvDrivePermissions extends FakeAdvResource {
|
|
|
31
31
|
return data;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
update(resource, fileId, permissionId, optionalArgs) {
|
|
35
|
+
const { nargs, matchThrow } = signatureArgs(arguments, "Drive.Permissions.update");
|
|
36
|
+
if (nargs < 3 || nargs > 4) matchThrow();
|
|
37
|
+
|
|
38
|
+
ScriptApp.__behavior.isAccessible(fileId, 'Drive', 'write');
|
|
39
|
+
const params = {
|
|
40
|
+
resource,
|
|
41
|
+
fileId,
|
|
42
|
+
permissionId,
|
|
43
|
+
...(optionalArgs || {})
|
|
44
|
+
};
|
|
45
|
+
const { data } = this._call('update', params);
|
|
46
|
+
return data;
|
|
47
|
+
}
|
|
48
|
+
|
|
34
49
|
delete(fileId, permissionId, optionalArgs) {
|
|
35
50
|
const { nargs, matchThrow } = signatureArgs(arguments, "Drive.Permissions.delete");
|
|
36
51
|
if (nargs < 2 || nargs > 3) matchThrow();
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* the idea here is to create an empty global entry for the singleton
|
|
3
|
+
* but only load it when it is actually used.
|
|
4
|
+
*/
|
|
5
|
+
import { lazyLoaderApp } from '../common/lazyloader.js'
|
|
6
|
+
import { newFakeChartsApp as maker } from './fakechartsapp.js'
|
|
7
|
+
let _app = null;
|
|
8
|
+
_app = lazyLoaderApp(_app, 'Charts', maker)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { Proxies } from "../../support/proxies.js";
|
|
2
|
+
import { notYetImplemented } from "../../support/helpers.js";
|
|
3
|
+
import * as Enums from "../enums/chartsenums.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* create a new FakeChartsApp instance
|
|
7
|
+
* @param {...any} args
|
|
8
|
+
* @returns {FakeChartsApp}
|
|
9
|
+
*/
|
|
10
|
+
export const newFakeChartsApp = (...args) => {
|
|
11
|
+
return Proxies.guard(new FakeChartsApp(...args));
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* basic fake FakeChartsApp
|
|
16
|
+
* @class FakeChartsApp
|
|
17
|
+
* @returns {FakeChartsApp}
|
|
18
|
+
*/
|
|
19
|
+
export class FakeChartsApp {
|
|
20
|
+
constructor() {
|
|
21
|
+
const enumProps = [
|
|
22
|
+
"ChartType", // ChartType An enumeration of the possible chart types.
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
// import all known enums as props of chartsapp
|
|
26
|
+
enumProps.forEach((f) => {
|
|
27
|
+
this[f] = Enums[f];
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const props = [
|
|
31
|
+
"newAreaChart",
|
|
32
|
+
"newBarChart",
|
|
33
|
+
"newColumnChart",
|
|
34
|
+
"newComboChart",
|
|
35
|
+
"newHistogramChart",
|
|
36
|
+
"newLineChart",
|
|
37
|
+
"newPieChart",
|
|
38
|
+
"newScatterChart",
|
|
39
|
+
"newSteppedAreaChart",
|
|
40
|
+
"newWaterfallChart",
|
|
41
|
+
"newScorecardChart",
|
|
42
|
+
"newRadarChart",
|
|
43
|
+
"newGaugeChart",
|
|
44
|
+
"newOrgChart",
|
|
45
|
+
"newTimelineChart",
|
|
46
|
+
"newTreeMapChart",
|
|
47
|
+
"newTableChart",
|
|
48
|
+
"newCandlestickChart",
|
|
49
|
+
"newGeoMapChart",
|
|
50
|
+
"newBubbleChart",
|
|
51
|
+
"newDataTable",
|
|
52
|
+
"newTextStyle",
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
props.forEach((f) => {
|
|
56
|
+
this[f] = () => {
|
|
57
|
+
return notYetImplemented(f);
|
|
58
|
+
};
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
toString() {
|
|
62
|
+
return "Charts";
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -4,6 +4,7 @@ import { newFakeFolderApp } from './fakefolderapp.js'
|
|
|
4
4
|
import { notYetImplemented, isFolder } from '../../support/helpers.js'
|
|
5
5
|
import { Proxies } from '../../support/proxies.js'
|
|
6
6
|
import { Utils } from '../../support/utils.js'
|
|
7
|
+
import { Access, Permission } from '../enums/driveenums.js'
|
|
7
8
|
const { is } = Utils
|
|
8
9
|
|
|
9
10
|
|
|
@@ -157,10 +158,10 @@ export class FakeDriveApp {
|
|
|
157
158
|
return notYetImplemented('enforceSingleParent')
|
|
158
159
|
}
|
|
159
160
|
get Access() {
|
|
160
|
-
return
|
|
161
|
+
return Access
|
|
161
162
|
}
|
|
162
163
|
get Permission() {
|
|
163
|
-
return
|
|
164
|
+
return Permission
|
|
164
165
|
}
|
|
165
166
|
|
|
166
167
|
}
|
|
@@ -10,10 +10,12 @@
|
|
|
10
10
|
|
|
11
11
|
import is from '@sindresorhus/is';
|
|
12
12
|
import { isFolder, notYetImplemented, isFakeFolder, signatureArgs } from '../../support/helpers.js'
|
|
13
|
+
import { Access, Permission } from '../enums/driveenums.js'
|
|
13
14
|
import { getParentsIterator } from './driveiterators.js';
|
|
15
|
+
import { getPermissionIterator } from '../../support/fileiterators.js';
|
|
14
16
|
import { improveFileCache } from "../../support/filecache.js"
|
|
15
17
|
import { getSharers } from '../../support/filesharers.js';
|
|
16
|
-
import {slogger } from "../../support/slogger.js";
|
|
18
|
+
import { slogger } from "../../support/slogger.js";
|
|
17
19
|
/**
|
|
18
20
|
* basic fake File meta data
|
|
19
21
|
* these are shared between folders and files
|
|
@@ -34,13 +36,13 @@ export class FakeDriveMeta {
|
|
|
34
36
|
|
|
35
37
|
__preventRootDamage = (operation) => {
|
|
36
38
|
if (this.__isRoot) {
|
|
37
|
-
slogger.error
|
|
39
|
+
slogger.error(`Can't do ${operation} on root folder`)
|
|
38
40
|
throw new Error("Access denied: DriveApp")
|
|
39
41
|
}
|
|
40
42
|
}
|
|
41
|
-
get __isRoot
|
|
43
|
+
get __isRoot() {
|
|
42
44
|
const parents = this.__getDecorated("parents")
|
|
43
|
-
return is.null
|
|
45
|
+
return is.null(parents)
|
|
44
46
|
}
|
|
45
47
|
/**
|
|
46
48
|
* for enhancing the file with fields not retrieved by default
|
|
@@ -60,7 +62,7 @@ export class FakeDriveMeta {
|
|
|
60
62
|
const newMeta = Drive.Files.get(this.getId(), { fields }, { allow404: false })
|
|
61
63
|
// need to merge this with already known fields
|
|
62
64
|
this.meta = { ...this.meta, ...newMeta }
|
|
63
|
-
improveFileCache
|
|
65
|
+
improveFileCache(this.getId(), this.meta, fields)
|
|
64
66
|
return this
|
|
65
67
|
}
|
|
66
68
|
|
|
@@ -89,7 +91,7 @@ export class FakeDriveMeta {
|
|
|
89
91
|
__updateMeta(prop, value, type, ...args) {
|
|
90
92
|
|
|
91
93
|
// cant update any meta on root folder
|
|
92
|
-
this.__preventRootDamage
|
|
94
|
+
this.__preventRootDamage(`set ${prop}`)
|
|
93
95
|
const { matchThrow } = signatureArgs(arguments, "update")
|
|
94
96
|
|
|
95
97
|
if (!is[type](value)) {
|
|
@@ -99,9 +101,9 @@ export class FakeDriveMeta {
|
|
|
99
101
|
file[prop] = value
|
|
100
102
|
|
|
101
103
|
const data = Drive.Files.update(file, this.getId(), null, prop)
|
|
102
|
-
this.meta = {...this.meta, ...data}
|
|
103
|
-
improveFileCache
|
|
104
|
-
|
|
104
|
+
this.meta = { ...this.meta, ...data }
|
|
105
|
+
improveFileCache(this.getId(), data)
|
|
106
|
+
|
|
105
107
|
return this
|
|
106
108
|
}
|
|
107
109
|
|
|
@@ -206,14 +208,14 @@ export class FakeDriveMeta {
|
|
|
206
208
|
moveTo(destination) {
|
|
207
209
|
// prepare for any arg errors
|
|
208
210
|
const { matchThrow } = signatureArgs(arguments, "moveTo", "DriveApp.Folder")
|
|
209
|
-
|
|
211
|
+
|
|
210
212
|
if (!isFakeFolder(destination)) {
|
|
211
213
|
matchThrow()
|
|
212
214
|
}
|
|
213
215
|
// pick up parents for destination if not already known
|
|
214
216
|
const newParent = destination.getId()
|
|
215
217
|
if (!is.nonEmptyString(newParent)) {
|
|
216
|
-
throw new Error
|
|
218
|
+
throw new Error(`expected to find destination id as a string but got ${newParent}`)
|
|
217
219
|
}
|
|
218
220
|
|
|
219
221
|
// cant move the root folder
|
|
@@ -262,6 +264,84 @@ export class FakeDriveMeta {
|
|
|
262
264
|
return this.__updateMeta("writersCanShare", value, "boolean", arguments)
|
|
263
265
|
}
|
|
264
266
|
|
|
267
|
+
/**
|
|
268
|
+
* Sets the sharing permission and access for the Folder/File.
|
|
269
|
+
* @param {import('../enums/driveenums.js').Access} access the access level
|
|
270
|
+
* @param {import('../enums/driveenums.js').Permission} permission the permission level
|
|
271
|
+
* @returns {FakeDriveFile|FakeDriveFolder} this self
|
|
272
|
+
*/
|
|
273
|
+
setSharing(access, permission) {
|
|
274
|
+
const { nargs, matchThrow } = signatureArgs(arguments, "setSharing");
|
|
275
|
+
if (nargs !== 2) matchThrow();
|
|
276
|
+
|
|
277
|
+
// Mapping Appsscript Access/Permission to Drive API role/type
|
|
278
|
+
// This is a simplified version, as setSharing usually affects "anyone" or "domain" permissions.
|
|
279
|
+
// In Drive API v3, it involves managing permissions with types 'anyone' or 'domain'.
|
|
280
|
+
|
|
281
|
+
// 1. Determine type based on access
|
|
282
|
+
let type;
|
|
283
|
+
let role;
|
|
284
|
+
let allowFileDiscovery = false;
|
|
285
|
+
|
|
286
|
+
if (access === Access.ANYONE || access === Access.ANYONE_WITH_LINK) {
|
|
287
|
+
type = 'anyone';
|
|
288
|
+
allowFileDiscovery = (access === Access.ANYONE);
|
|
289
|
+
} else if (access === Access.DOMAIN || access === Access.DOMAIN_WITH_LINK) {
|
|
290
|
+
type = 'domain';
|
|
291
|
+
allowFileDiscovery = (access === Access.DOMAIN);
|
|
292
|
+
} else if (access === Access.PRIVATE) {
|
|
293
|
+
// For PRIVATE, we typically remove any 'anyone' or 'domain' permissions.
|
|
294
|
+
const { permissions } = Drive.Permissions.list(this.getId());
|
|
295
|
+
permissions.forEach(p => {
|
|
296
|
+
if (p.type === 'anyone' || p.type === 'domain') {
|
|
297
|
+
Drive.Permissions.delete(this.getId(), p.id);
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
return this;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// 2. Determine role based on permission
|
|
304
|
+
if (permission === Permission.VIEW || permission === Permission.READ) {
|
|
305
|
+
role = 'reader';
|
|
306
|
+
} else if (permission === Permission.COMMENT) {
|
|
307
|
+
role = 'commenter';
|
|
308
|
+
} else if (permission === Permission.EDIT) {
|
|
309
|
+
role = 'writer';
|
|
310
|
+
} else {
|
|
311
|
+
throw new Error(`Unsupported permission level for setSharing: ${permission}`);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// 3. Find existing permission of this type or create new
|
|
315
|
+
const { permissions } = Drive.Permissions.list(this.getId(), {
|
|
316
|
+
fields: "permissions(id,role,type,allowFileDiscovery,domain)"
|
|
317
|
+
});
|
|
318
|
+
const existing = permissions.find(p => p.type === type);
|
|
319
|
+
|
|
320
|
+
if (existing) {
|
|
321
|
+
// If the identity fields (type, allowFileDiscovery, domain) have changed, we must delete and recreate
|
|
322
|
+
const domain = type === 'domain' ? Session.getActiveUser().getDomain() : undefined;
|
|
323
|
+
const identityChanged = existing.allowFileDiscovery !== allowFileDiscovery ||
|
|
324
|
+
(type === 'domain' && existing.domain !== domain);
|
|
325
|
+
|
|
326
|
+
if (identityChanged) {
|
|
327
|
+
Drive.Permissions.delete(this.getId(), existing.id);
|
|
328
|
+
const resource = { role, type, allowFileDiscovery };
|
|
329
|
+
if (type === 'domain') resource.domain = domain;
|
|
330
|
+
Drive.Permissions.create(resource, this.getId());
|
|
331
|
+
} else {
|
|
332
|
+
// Only role is writable in update
|
|
333
|
+
Drive.Permissions.update({ role }, this.getId(), existing.id);
|
|
334
|
+
}
|
|
335
|
+
} else {
|
|
336
|
+
const resource = { role, type, allowFileDiscovery };
|
|
337
|
+
if (type === 'domain') resource.domain = Session.getActiveUser().getDomain();
|
|
338
|
+
Drive.Permissions.create(resource, this.getId());
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
improveFileCache(this.getId(), null);
|
|
342
|
+
return this;
|
|
343
|
+
}
|
|
344
|
+
|
|
265
345
|
/**
|
|
266
346
|
* Determines whether users with edit permissions to the Folder/File are allowed to share with other users or change the permissions
|
|
267
347
|
* @returns {Boolean}
|
|
@@ -320,12 +400,60 @@ export class FakeDriveMeta {
|
|
|
320
400
|
// TODO-----------
|
|
321
401
|
|
|
322
402
|
getSharingPermission() {
|
|
323
|
-
|
|
324
|
-
|
|
403
|
+
const pit = getPermissionIterator({ id: this.getId() });
|
|
404
|
+
let highest = Permission.NONE;
|
|
405
|
+
|
|
406
|
+
const rank = (p) => {
|
|
407
|
+
if (p === Permission.OWNER) return 6;
|
|
408
|
+
if (p === Permission.EDIT) return 5;
|
|
409
|
+
if (p === Permission.COMMENT) return 4;
|
|
410
|
+
if (p === Permission.VIEW || p === Permission.READ) return 3;
|
|
411
|
+
return 0;
|
|
412
|
+
}
|
|
325
413
|
|
|
414
|
+
while (pit.hasNext()) {
|
|
415
|
+
const p = pit.next();
|
|
416
|
+
let current = Permission.NONE;
|
|
417
|
+
if (p.type === 'anyone' || p.type === 'domain') {
|
|
418
|
+
if (p.role === 'owner') current = Permission.OWNER;
|
|
419
|
+
else if (p.role === 'writer') current = Permission.EDIT;
|
|
420
|
+
else if (p.role === 'commenter') current = Permission.COMMENT;
|
|
421
|
+
else if (p.role === 'reader') current = Permission.VIEW;
|
|
422
|
+
|
|
423
|
+
if (rank(current) > rank(highest)) {
|
|
424
|
+
highest = current;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
return highest;
|
|
429
|
+
}
|
|
326
430
|
|
|
327
431
|
getSharingAccess() {
|
|
328
|
-
|
|
432
|
+
const pit = getPermissionIterator({ id: this.getId() });
|
|
433
|
+
let highest = Access.PRIVATE;
|
|
434
|
+
|
|
435
|
+
const rank = (a) => {
|
|
436
|
+
if (a === Access.ANYONE) return 4;
|
|
437
|
+
if (a === Access.ANYONE_WITH_LINK) return 3;
|
|
438
|
+
if (a === Access.DOMAIN) return 2;
|
|
439
|
+
if (a === Access.DOMAIN_WITH_LINK) return 1;
|
|
440
|
+
return 0;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
while (pit.hasNext()) {
|
|
444
|
+
const p = pit.next();
|
|
445
|
+
let current = Access.PRIVATE;
|
|
446
|
+
if (p.type === 'anyone') {
|
|
447
|
+
current = p.allowFileDiscovery ? Access.ANYONE : Access.ANYONE_WITH_LINK;
|
|
448
|
+
} else if (p.type === 'domain') {
|
|
449
|
+
current = p.allowFileDiscovery ? Access.DOMAIN : Access.DOMAIN_WITH_LINK;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (rank(current) > rank(highest)) {
|
|
453
|
+
highest = current;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return highest;
|
|
329
457
|
}
|
|
330
458
|
|
|
331
459
|
|
|
@@ -333,9 +461,6 @@ export class FakeDriveMeta {
|
|
|
333
461
|
return notYetImplemented('getResourceKey')
|
|
334
462
|
}
|
|
335
463
|
|
|
336
|
-
setSharing() {
|
|
337
|
-
return notYetImplemented('setSharing')
|
|
338
|
-
}
|
|
339
464
|
getSecurityUpdateEligible() {
|
|
340
465
|
return notYetImplemented('getSecurityUpdateEligible')
|
|
341
466
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { newFakeGasenum } from "@mcpher/fake-gasenum";
|
|
2
|
+
|
|
3
|
+
export const ChartType = newFakeGasenum([
|
|
4
|
+
"AREA", // Enum Area chart.
|
|
5
|
+
"BAR", // Enum Bar chart.
|
|
6
|
+
"COLUMN", // Enum Column chart.
|
|
7
|
+
"COMBO", // Enum Combo chart.
|
|
8
|
+
"HISTOGRAM", // Enum Histogram chart.
|
|
9
|
+
"LINE", // Enum Line chart.
|
|
10
|
+
"PIE", // Enum Pie chart.
|
|
11
|
+
"SCATTER", // Enum Scatter chart.
|
|
12
|
+
"STEPPED_AREA", // Enum Stepped area chart.
|
|
13
|
+
"WATERFALL", // Enum Waterfall chart.
|
|
14
|
+
"SCORECARD", // Enum Scorecard chart.
|
|
15
|
+
"RADAR", // Enum Radar chart.
|
|
16
|
+
"GAUGE", // Enum Gauge chart.
|
|
17
|
+
"ORG", // Enum Org chart.
|
|
18
|
+
"TIMELINE", // Enum Timeline chart.
|
|
19
|
+
"TREE_MAP", // Enum Tree map chart.
|
|
20
|
+
"TABLE", // Enum Table chart.
|
|
21
|
+
"CANDLESTICK", // Enum Candlestick chart.
|
|
22
|
+
"GEOMAP", // Enum Geo map chart.
|
|
23
|
+
"BUBBLE", // Enum Bubble chart.
|
|
24
|
+
]);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { newFakeGasenum } from "@mcpher/fake-gasenum";
|
|
2
|
+
|
|
3
|
+
export const Access = newFakeGasenum([
|
|
4
|
+
"ANYONE",
|
|
5
|
+
"ANYONE_WITH_LINK",
|
|
6
|
+
"DOMAIN",
|
|
7
|
+
"DOMAIN_WITH_LINK",
|
|
8
|
+
"PRIVATE"
|
|
9
|
+
])
|
|
10
|
+
|
|
11
|
+
export const Permission = newFakeGasenum([
|
|
12
|
+
"COMMENT",
|
|
13
|
+
"EDIT",
|
|
14
|
+
"NONE",
|
|
15
|
+
"ORGANIZER",
|
|
16
|
+
"OWNER",
|
|
17
|
+
"READ",
|
|
18
|
+
"VIEW"
|
|
19
|
+
])
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
import { newFakeGasenum} from "@mcpher/fake-gasenum";
|
|
2
|
+
import { newFakeGasenum } from "@mcpher/fake-gasenum";
|
|
3
3
|
export const AutoFillSeries = newFakeGasenum(["DEFAULT_SERIES", "ALTERNATE_SERIES"])
|
|
4
4
|
export const BandingTheme = newFakeGasenum([
|
|
5
5
|
"LIGHT_GREY", // Enum A light grey banding theme.
|
|
@@ -629,6 +629,15 @@ export class FakeForm {
|
|
|
629
629
|
getPublishedUrl() {
|
|
630
630
|
return `https://docs.google.com/forms/d/e/${this.getId()}/viewform`;
|
|
631
631
|
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Internal method to get the correct responder URI from the API resource.
|
|
635
|
+
* @returns {string} The responder URI.
|
|
636
|
+
*/
|
|
637
|
+
__getResponderUri() {
|
|
638
|
+
return this.__resource.responderUri;
|
|
639
|
+
}
|
|
640
|
+
|
|
632
641
|
/**
|
|
633
642
|
* Gets the URL to respond to the form
|
|
634
643
|
* https://github.com/brucemcpherson/gas-fakes/issues/111
|
|
@@ -645,6 +654,22 @@ export class FakeForm {
|
|
|
645
654
|
return this.getPublishedUrl()
|
|
646
655
|
}
|
|
647
656
|
|
|
657
|
+
/**
|
|
658
|
+
* Creates a new response to the form.
|
|
659
|
+
* @returns {import('./fakeformresponse.js').FakeFormResponse} The newly created form response.
|
|
660
|
+
*/
|
|
661
|
+
createResponse() {
|
|
662
|
+
return newFakeFormResponse(this, { answers: {} });
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Deletes all responses from the form.
|
|
667
|
+
* @returns {FakeForm} The form, for chaining.
|
|
668
|
+
*/
|
|
669
|
+
deleteAllResponses() {
|
|
670
|
+
throw new Error('deleteAllResponses is not yet implemented in the fake environment because the Google Forms API v1 does not support deleting responses.');
|
|
671
|
+
}
|
|
672
|
+
|
|
648
673
|
toString() {
|
|
649
674
|
return 'Form';
|
|
650
675
|
}
|
|
@@ -114,7 +114,9 @@ export class FakeFormItem {
|
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
getId() {
|
|
117
|
-
|
|
117
|
+
const resource = this.__resource;
|
|
118
|
+
const hexId = resource.questionItem?.question?.questionId || this.__itemId;
|
|
119
|
+
return Utils.fromHex(hexId);
|
|
118
120
|
}
|
|
119
121
|
|
|
120
122
|
getIndex() {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Proxies } from '../../support/proxies.js';
|
|
2
2
|
import { newFakeItemResponse } from './fakeitemresponse.js';
|
|
3
3
|
import { Utils } from '../../support/utils.js';
|
|
4
|
+
import { formsCacher } from '../../support/formscacher.js';
|
|
4
5
|
|
|
5
6
|
export const newFakeFormResponse = (...args) => {
|
|
6
7
|
return Proxies.guard(new FakeFormResponse(...args));
|
|
@@ -20,7 +21,9 @@ export class FakeFormResponse {
|
|
|
20
21
|
this.__form = form;
|
|
21
22
|
this.__resource = resource;
|
|
22
23
|
}
|
|
23
|
-
|
|
24
|
+
toString() {
|
|
25
|
+
return 'FormResponse';
|
|
26
|
+
}
|
|
24
27
|
/**
|
|
25
28
|
* Gets the email address of the respondent.
|
|
26
29
|
* @returns {string} the respondent's email
|
|
@@ -83,4 +86,94 @@ export class FakeFormResponse {
|
|
|
83
86
|
// Finally, sort the responses based on the item's index in the form.
|
|
84
87
|
return itemResponses.sort((a, b) => a.getItem().getIndex() - b.getItem().getIndex());
|
|
85
88
|
}
|
|
86
|
-
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Adds an item response to this form response.
|
|
92
|
+
* @param {import('./fakeitemresponse.js').FakeItemResponse} itemResponse The item response to add.
|
|
93
|
+
* @returns {FakeFormResponse} This form response, for chaining.
|
|
94
|
+
*/
|
|
95
|
+
withItemResponse(itemResponse) {
|
|
96
|
+
if (!this.__resource.answers) {
|
|
97
|
+
this.__resource.answers = {};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// itemResponse.__answers is the internal array of answers.
|
|
101
|
+
// In the resource, answers are keyed by questionId.
|
|
102
|
+
itemResponse.__answers.forEach(answer => {
|
|
103
|
+
this.__resource.answers[answer.questionId] = answer;
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
return this;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Submits the response.
|
|
111
|
+
* @returns {FakeFormResponse} This form response, for chaining.
|
|
112
|
+
*/
|
|
113
|
+
submit() {
|
|
114
|
+
const formId = this.__form.getId();
|
|
115
|
+
const responderUri = this.__form.__getResponderUri();
|
|
116
|
+
const url = responderUri.replace('/viewform', '/formResponse');
|
|
117
|
+
const payload = {};
|
|
118
|
+
console.log('url', url)
|
|
119
|
+
console.log('edit url', this.__form.getEditUrl())
|
|
120
|
+
console.log('responder uri', this.__form.__getResponderUri())
|
|
121
|
+
console.log('published url', this.__form.getPublishedUrl())
|
|
122
|
+
console.log('form id', this.__form.getId())
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
this.getItemResponses().forEach(itemResponse => {
|
|
126
|
+
const item = itemResponse.getItem();
|
|
127
|
+
const id = item.getId();
|
|
128
|
+
payload[`entry.${id}`] = itemResponse.getResponse();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
if (Object.keys(payload).length > 0) {
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
// we need to do many things here to allowe access to the form as there are no formapp methods to add reponses.
|
|
135
|
+
// first save the current file permissions
|
|
136
|
+
const formFile = this.__form.__file;
|
|
137
|
+
|
|
138
|
+
// 1. Capture the original state
|
|
139
|
+
const originalAccess = formFile.getSharingAccess();
|
|
140
|
+
const originalPermission = formFile.getSharingPermission();
|
|
141
|
+
// temporarily make the form public so we can submit a response
|
|
142
|
+
// 2. Open access: Anyone with the link can view (needed for submission)
|
|
143
|
+
formFile.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW);
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
|
|
147
|
+
// --- YOUR SUBMISSION LOGIC HERE ---
|
|
148
|
+
console.log("Form is temporarily public. Submitting...");
|
|
149
|
+
const response = UrlFetchApp.fetch(url, {
|
|
150
|
+
method: 'post',
|
|
151
|
+
payload,
|
|
152
|
+
muteHttpExceptions: true
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
if (response.getContentText().includes("recorded")) {
|
|
156
|
+
console.log("Success! Data pushed to Google Sheets.");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (response?.getResponseCode() !== 200) {
|
|
160
|
+
throw new Error(`Failed to submit form response: ${response.getResponseCode()}`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
} catch (e) {
|
|
164
|
+
console.error("Submission failed: " + e.toString());
|
|
165
|
+
} finally {
|
|
166
|
+
// 3. Reset to exactly how it was before
|
|
167
|
+
// This closes the 401/403 hole immediately
|
|
168
|
+
formFile.setSharing(originalAccess, originalPermission);
|
|
169
|
+
console.log("Permissions reset to original state.");
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Invalidate the form cache because a new response has been submitted
|
|
174
|
+
formsCacher.clear(formId);
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
return this;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { Proxies } from "../../support/proxies.js";
|
|
2
|
+
import { notYetImplemented, signatureArgs } from "../../support/helpers.js";
|
|
3
|
+
import { batchUpdate } from "./sheetrangehelpers.js";
|
|
4
|
+
import { newFakeEmbeddedChartBuilder } from "./fakeembeddedchartbuilder.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @returns {FakeEmbeddedChart}
|
|
8
|
+
*/
|
|
9
|
+
export const newFakeEmbeddedChart = (apiChart, sheet) => {
|
|
10
|
+
return Proxies.guard(new FakeEmbeddedChart(apiChart, sheet));
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Represents a chart that has been embedded into a spreadsheet.
|
|
15
|
+
*/
|
|
16
|
+
export class FakeEmbeddedChart {
|
|
17
|
+
/**
|
|
18
|
+
* @param {object} apiChart The EmbeddedChart object from Sheets API
|
|
19
|
+
* @param {FakeSheet} sheet The parent sheet
|
|
20
|
+
*/
|
|
21
|
+
constructor(apiChart, sheet) {
|
|
22
|
+
this.__apiChart = apiChart;
|
|
23
|
+
this.__sheet = sheet;
|
|
24
|
+
|
|
25
|
+
const props = [
|
|
26
|
+
"getAs",
|
|
27
|
+
"getBlob",
|
|
28
|
+
"getContainerInfo",
|
|
29
|
+
];
|
|
30
|
+
props.forEach((f) => {
|
|
31
|
+
this[f] = () => notYetImplemented(f);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Returns the ID of this chart.
|
|
37
|
+
* @returns {number}
|
|
38
|
+
*/
|
|
39
|
+
getChartId() {
|
|
40
|
+
return this.__apiChart.chartId;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Returns the ID of this chart (alias for getChartId).
|
|
45
|
+
* @returns {string}
|
|
46
|
+
*/
|
|
47
|
+
getId() {
|
|
48
|
+
return this.getChartId().toString();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Returns the options of this chart.
|
|
53
|
+
* @returns {object}
|
|
54
|
+
*/
|
|
55
|
+
getOptions() {
|
|
56
|
+
return {
|
|
57
|
+
get: (key) => {
|
|
58
|
+
if (key === "title") return this.__apiChart.spec.title;
|
|
59
|
+
return this.__apiChart.spec.basicChart?.options?.[key];
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Returns a builder to modify this chart.
|
|
66
|
+
* @returns {FakeEmbeddedChartBuilder}
|
|
67
|
+
*/
|
|
68
|
+
modify() {
|
|
69
|
+
return newFakeEmbeddedChartBuilder(this.__sheet).setChartId(this.getChartId()).setApiChart(this.__apiChart);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Deletes the chart from the sheet.
|
|
74
|
+
*/
|
|
75
|
+
remove() {
|
|
76
|
+
const request = {
|
|
77
|
+
deleteEmbeddedObject: {
|
|
78
|
+
objectId: this.getChartId(),
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
batchUpdate({ spreadsheet: this.__sheet.getParent(), requests: [request] });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
toString() {
|
|
85
|
+
return "EmbeddedChart";
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { Proxies } from "../../support/proxies.js";
|
|
2
|
+
import { signatureArgs } from "../../support/helpers.js";
|
|
3
|
+
import { Utils } from "../../support/utils.js";
|
|
4
|
+
import { makeSheetsGridRange } from "./sheetrangehelpers.js";
|
|
5
|
+
import { newFakeEmbeddedChart } from "./fakeembeddedchart.js";
|
|
6
|
+
|
|
7
|
+
const { is } = Utils;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @returns {FakeEmbeddedChartBuilder}
|
|
11
|
+
*/
|
|
12
|
+
export const newFakeEmbeddedChartBuilder = (sheet) => {
|
|
13
|
+
return Proxies.guard(new FakeEmbeddedChartBuilder(sheet));
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Builder for embedded charts.
|
|
18
|
+
*/
|
|
19
|
+
export class FakeEmbeddedChartBuilder {
|
|
20
|
+
constructor(sheet) {
|
|
21
|
+
this.__sheet = sheet;
|
|
22
|
+
this.__apiChart = {
|
|
23
|
+
spec: {
|
|
24
|
+
title: "",
|
|
25
|
+
basicChart: {
|
|
26
|
+
chartType: "COLUMN", // Default
|
|
27
|
+
legendPosition: "RIGHT_LEGEND",
|
|
28
|
+
axis: [],
|
|
29
|
+
domains: [],
|
|
30
|
+
series: [],
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
position: {
|
|
34
|
+
overlayPosition: {
|
|
35
|
+
anchorCell: {
|
|
36
|
+
sheetId: sheet.getSheetId(),
|
|
37
|
+
rowIndex: 0,
|
|
38
|
+
columnIndex: 0,
|
|
39
|
+
},
|
|
40
|
+
offsetXPixels: 0,
|
|
41
|
+
offsetYPixels: 0,
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
setChartId(id) {
|
|
48
|
+
this.__apiChart.chartId = id;
|
|
49
|
+
return this;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
setApiChart(apiChart) {
|
|
53
|
+
this.__apiChart = JSON.parse(JSON.stringify(apiChart));
|
|
54
|
+
return this;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
addRange(range) {
|
|
58
|
+
const { nargs, matchThrow } = signatureArgs(arguments, "EmbeddedChartBuilder.addRange");
|
|
59
|
+
if (nargs !== 1) matchThrow();
|
|
60
|
+
|
|
61
|
+
const gridRange = makeSheetsGridRange(range);
|
|
62
|
+
|
|
63
|
+
// In Sheets API, ranges are usually mapped to domains or series.
|
|
64
|
+
// Simplifying: if the range has multiple columns, split it into separate sources if it's a series.
|
|
65
|
+
// But for this fake, we'll just encourage the user to use single-column ranges or handle it simply.
|
|
66
|
+
|
|
67
|
+
if (this.__apiChart.spec.basicChart.domains.length === 0) {
|
|
68
|
+
this.__apiChart.spec.basicChart.domains.push({
|
|
69
|
+
domain: { sourceRange: { sources: [gridRange] } },
|
|
70
|
+
});
|
|
71
|
+
} else {
|
|
72
|
+
// Check if gridRange has multiple columns/rows and split if necessary?
|
|
73
|
+
// For now, just add it. The test will be updated to use single column ranges.
|
|
74
|
+
this.__apiChart.spec.basicChart.series.push({
|
|
75
|
+
series: { sourceRange: { sources: [gridRange] } },
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
return this;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
setChartType(type) {
|
|
82
|
+
const { nargs, matchThrow } = signatureArgs(arguments, "EmbeddedChartBuilder.setChartType");
|
|
83
|
+
if (nargs !== 1) matchThrow();
|
|
84
|
+
|
|
85
|
+
// type is Charts.ChartType enum (which should match API strings in our fakes)
|
|
86
|
+
this.__apiChart.spec.basicChart.chartType = type.toString();
|
|
87
|
+
return this;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Returns the chart type.
|
|
92
|
+
* @returns {ChartType}
|
|
93
|
+
*/
|
|
94
|
+
getChartType() {
|
|
95
|
+
const spec = this.__apiChart.spec;
|
|
96
|
+
const chartType = Charts.ChartType;
|
|
97
|
+
if (spec.basicChart) return chartType[spec.basicChart.chartType];
|
|
98
|
+
if (spec.pieChart) return chartType.PIE;
|
|
99
|
+
if (spec.bubbleChart) return chartType.BUBBLE;
|
|
100
|
+
if (spec.candlestickChart) return chartType.CANDLESTICK;
|
|
101
|
+
if (spec.orgChart) return chartType.ORG;
|
|
102
|
+
if (spec.waterfallChart) return chartType.WATERFALL;
|
|
103
|
+
if (spec.treemapChart) return chartType.TREE_MAP;
|
|
104
|
+
if (spec.scorecardChart) return chartType.SCORECARD;
|
|
105
|
+
if (spec.histogramChart) return chartType.HISTOGRAM;
|
|
106
|
+
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
setPosition(anchorRowPos, anchorColPos, offsetX, offsetY) {
|
|
111
|
+
const { nargs, matchThrow } = signatureArgs(arguments, "EmbeddedChartBuilder.setPosition");
|
|
112
|
+
if (nargs !== 4) matchThrow();
|
|
113
|
+
|
|
114
|
+
this.__apiChart.position.overlayPosition.anchorCell.rowIndex = anchorRowPos - 1;
|
|
115
|
+
this.__apiChart.position.overlayPosition.anchorCell.columnIndex = anchorColPos - 1;
|
|
116
|
+
this.__apiChart.position.overlayPosition.offsetXPixels = offsetX;
|
|
117
|
+
this.__apiChart.position.overlayPosition.offsetYPixels = offsetY;
|
|
118
|
+
return this;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
setOption(option, value) {
|
|
122
|
+
const { nargs, matchThrow } = signatureArgs(arguments, "EmbeddedChartBuilder.setOption");
|
|
123
|
+
if (nargs !== 2) matchThrow();
|
|
124
|
+
|
|
125
|
+
if (option === "title") {
|
|
126
|
+
this.__apiChart.spec.title = value;
|
|
127
|
+
} else {
|
|
128
|
+
// For other options, we might want to store them in a general options object if needed,
|
|
129
|
+
// but for now, we'll just focus on "title" as requested.
|
|
130
|
+
// SpreadsheetApp.EmbeddedChartBuilder.setOption(option, value)
|
|
131
|
+
if (!this.__apiChart.spec.basicChart.options) {
|
|
132
|
+
this.__apiChart.spec.basicChart.options = {};
|
|
133
|
+
}
|
|
134
|
+
this.__apiChart.spec.basicChart.options[option] = value;
|
|
135
|
+
}
|
|
136
|
+
return this;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
build() {
|
|
140
|
+
const { nargs, matchThrow } = signatureArgs(arguments, "EmbeddedChartBuilder.build");
|
|
141
|
+
if (nargs !== 0) matchThrow();
|
|
142
|
+
|
|
143
|
+
// In a real GAS, build() returns an EmbeddedChart that is NOT yet inserted.
|
|
144
|
+
// Here we return the api object wrapped in a FakeEmbeddedChart if needed,
|
|
145
|
+
// or just the raw object that insertChart will use.
|
|
146
|
+
// Actually, gas.Sheet.insertChart expects an EmbeddedChart object.
|
|
147
|
+
return newFakeEmbeddedChart(this.__apiChart, this.__sheet);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
toString() {
|
|
151
|
+
const hex = Math.floor(Math.random() * 0xffffffff).toString(16).padStart(8, "0");
|
|
152
|
+
return `com.google.apps.maestro.server.beans.trix.impl.ChartPropertyApiEmbeddedChartBuilder@${hex}`;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -14,6 +14,8 @@ import { FakeTextFinder, newFakeTextFinder } from "./faketextfinder.js";
|
|
|
14
14
|
import { newFakeNamedRange } from "./fakenamedrange.js";
|
|
15
15
|
import { newFakeProtection } from "./fakeprotection.js";
|
|
16
16
|
import { newFakeOverGridImage } from "./fakeovergridimage.js";
|
|
17
|
+
import { newFakeEmbeddedChart } from "./fakeembeddedchart.js";
|
|
18
|
+
import { newFakeEmbeddedChartBuilder } from "./fakeembeddedchartbuilder.js";
|
|
17
19
|
|
|
18
20
|
import { XMLParser } from "fast-xml-parser";
|
|
19
21
|
import { slogger } from "../../support/slogger.js";
|
|
@@ -34,10 +36,6 @@ export class FakeSheet {
|
|
|
34
36
|
this.__parent = parent;
|
|
35
37
|
|
|
36
38
|
const props = [
|
|
37
|
-
"getCharts",
|
|
38
|
-
"insertChart",
|
|
39
|
-
"removeChart",
|
|
40
|
-
"updateChart",
|
|
41
39
|
// "getImages",
|
|
42
40
|
"insertImage",
|
|
43
41
|
"removeImage",
|
|
@@ -399,6 +397,63 @@ export class FakeSheet {
|
|
|
399
397
|
return pivotTables;
|
|
400
398
|
}
|
|
401
399
|
|
|
400
|
+
getCharts() {
|
|
401
|
+
const { nargs, matchThrow } = signatureArgs(arguments, "Sheet.getCharts");
|
|
402
|
+
if (nargs !== 0) matchThrow();
|
|
403
|
+
|
|
404
|
+
const meta = this.getParent().__getMetaProps(
|
|
405
|
+
`sheets(charts,properties.sheetId)`
|
|
406
|
+
);
|
|
407
|
+
const sheetMeta = meta.sheets.find(
|
|
408
|
+
(s) => s.properties.sheetId === this.getSheetId()
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
return (sheetMeta?.charts || []).map((c) => newFakeEmbeddedChart(c, this));
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
insertChart(chart) {
|
|
415
|
+
const { nargs, matchThrow } = signatureArgs(arguments, "Sheet.insertChart");
|
|
416
|
+
if (nargs !== 1 || !is.object(chart)) matchThrow();
|
|
417
|
+
|
|
418
|
+
const request = {
|
|
419
|
+
addChart: {
|
|
420
|
+
chart: chart.__apiChart,
|
|
421
|
+
},
|
|
422
|
+
};
|
|
423
|
+
batchUpdate({ spreadsheet: this.getParent(), requests: [request] });
|
|
424
|
+
return this;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
newChart() {
|
|
428
|
+
const { nargs, matchThrow } = signatureArgs(arguments, "Sheet.newChart");
|
|
429
|
+
if (nargs !== 0) matchThrow();
|
|
430
|
+
|
|
431
|
+
return newFakeEmbeddedChartBuilder(this);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
removeChart(chart) {
|
|
435
|
+
const { nargs, matchThrow } = signatureArgs(arguments, "Sheet.removeChart");
|
|
436
|
+
if (nargs !== 1 || !is.object(chart)) matchThrow();
|
|
437
|
+
|
|
438
|
+
chart.remove();
|
|
439
|
+
return this;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
updateChart(chart) {
|
|
443
|
+
const { nargs, matchThrow } = signatureArgs(arguments, "Sheet.updateChart");
|
|
444
|
+
if (nargs !== 1 || !is.object(chart)) matchThrow();
|
|
445
|
+
|
|
446
|
+
const request = {
|
|
447
|
+
updateEmbeddedObjectPosition: {
|
|
448
|
+
objectId: chart.getChartId(),
|
|
449
|
+
newPosition: chart.__apiChart.position,
|
|
450
|
+
fields: "*",
|
|
451
|
+
},
|
|
452
|
+
};
|
|
453
|
+
batchUpdate({ spreadsheet: this.getParent(), requests: [request] });
|
|
454
|
+
return this;
|
|
455
|
+
}
|
|
456
|
+
|
|
402
457
|
getBandings() {
|
|
403
458
|
const meta = this.getParent().__getMetaProps(
|
|
404
459
|
`sheets(bandedRanges,properties.sheetId)`
|
|
@@ -13,12 +13,23 @@ import { Proxies } from '../../support/proxies.js'
|
|
|
13
13
|
* @return {FakeHTTPResponse} UrlFetchApp flavor
|
|
14
14
|
*/
|
|
15
15
|
const responsify = (response) => {
|
|
16
|
+
if (!response) {
|
|
17
|
+
// Return a dummy response that identifies as an error
|
|
18
|
+
return {
|
|
19
|
+
getAllHeaders: () => ({}),
|
|
20
|
+
getResponseCode: () => 500,
|
|
21
|
+
getContentText: () => "UrlFetchApp: No response data available.",
|
|
22
|
+
getHeaders: () => ({}),
|
|
23
|
+
getBlob: () => null,
|
|
24
|
+
getContent: () => []
|
|
25
|
+
}
|
|
26
|
+
}
|
|
16
27
|
|
|
17
28
|
// getAllHeaders() Object Returns an attribute/value map of headers for the HTTP response, with headers that have multiple values returned as arrays.
|
|
18
29
|
const getAllHeaders = () => fixHeaders(response)
|
|
19
30
|
|
|
20
31
|
// getResponseCode() Integer Get the HTTP status code (200 for OK, etc.) of an HTTP response
|
|
21
|
-
const getResponseCode = () => response.statusCode
|
|
32
|
+
const getResponseCode = () => response.statusCode || response.status || 500
|
|
22
33
|
|
|
23
34
|
// getContentText() String Gets the content of an HTTP response encoded as a string.
|
|
24
35
|
const getContentText = () => blobify(response).getDataAsString()
|
package/src/support/auth.js
CHANGED
|
@@ -197,6 +197,8 @@ const setAuth = async (scopes = [], mcpLoading = false) => {
|
|
|
197
197
|
|
|
198
198
|
if (!tokenResponse.ok) {
|
|
199
199
|
const errorText = await tokenResponse.text()
|
|
200
|
+
console.log ('... it looks like you forgot to enable domain-wide delegation for the service account')
|
|
201
|
+
console.log ('... rerun gas-fakes auth and check the instructions about enabling domain-wide delegation')
|
|
200
202
|
throw new Error(`Failed to exchange JWT for token: ${errorText}`)
|
|
201
203
|
}
|
|
202
204
|
|
|
@@ -333,6 +335,7 @@ export const responseSyncify = (result) => {
|
|
|
333
335
|
if (!result) {
|
|
334
336
|
return {
|
|
335
337
|
status: 503, // Service Unavailable, a good representation for a worker-level failure
|
|
338
|
+
statusCode: 503,
|
|
336
339
|
statusText: "Worker Error: No response object received from API call",
|
|
337
340
|
error: {
|
|
338
341
|
message: "Worker Error: No response object received from API call",
|
|
@@ -340,10 +343,15 @@ export const responseSyncify = (result) => {
|
|
|
340
343
|
};
|
|
341
344
|
}
|
|
342
345
|
return {
|
|
343
|
-
status: result.status,
|
|
346
|
+
status: result.status || result.statusCode,
|
|
347
|
+
statusCode: result.status || result.statusCode,
|
|
344
348
|
statusText: result.statusText,
|
|
345
|
-
responseUrl: result.request?.responseURL,
|
|
349
|
+
responseUrl: result.request?.responseURL || result.url,
|
|
346
350
|
error: result.data?.error,
|
|
351
|
+
rawHeaders: result.rawHeaders,
|
|
352
|
+
headers: result.headers,
|
|
353
|
+
body: result.body,
|
|
354
|
+
rawBody: result.rawBody
|
|
347
355
|
};
|
|
348
356
|
};
|
|
349
357
|
|
|
@@ -24,7 +24,7 @@ export const getPermissionIterator = ({
|
|
|
24
24
|
do {
|
|
25
25
|
// if nothing in the tank, fill it upFdrive
|
|
26
26
|
if (!tank.length) {
|
|
27
|
-
const data = Drive.Permissions.list(id, { fields: "nextPageToken,permissions(role,type,emailAddress,photoLink,domain,displayName)" })
|
|
27
|
+
const data = Drive.Permissions.list(id, { fields: "nextPageToken,permissions(id,role,type,emailAddress,photoLink,domain,displayName,allowFileDiscovery)" })
|
|
28
28
|
const { permissions, nextPageToken } = data
|
|
29
29
|
|
|
30
30
|
// the presence of a nextPageToken is the signal that there's more to come
|
package/src/support/sxfetch.js
CHANGED
|
@@ -12,8 +12,9 @@ const fixOptions = (options) => {
|
|
|
12
12
|
if (options) {
|
|
13
13
|
fixedOptions = { ...options }
|
|
14
14
|
Object.keys(fixedOptions).forEach(k => {
|
|
15
|
-
if (k.match(/Content-Type/i)) {
|
|
16
|
-
fixedOptions.
|
|
15
|
+
if (k.match(/Content-Type|contentType/i)) {
|
|
16
|
+
fixedOptions.headers = fixedOptions.headers || {}
|
|
17
|
+
fixedOptions.headers['Content-Type'] = fixedOptions[k]
|
|
17
18
|
delete fixedOptions[k]
|
|
18
19
|
}
|
|
19
20
|
if (k.match(/payload/i)) {
|
|
@@ -25,6 +26,23 @@ const fixOptions = (options) => {
|
|
|
25
26
|
delete fixedOptions[k]
|
|
26
27
|
}
|
|
27
28
|
})
|
|
29
|
+
|
|
30
|
+
// Apps Script UrlFetchApp behavior:
|
|
31
|
+
// If the payload is an object and no content type is specified,
|
|
32
|
+
// it defaults to application/x-www-form-urlencoded.
|
|
33
|
+
if (fixedOptions.body && typeof fixedOptions.body === 'object' && !fixedOptions.contentType) {
|
|
34
|
+
// If it's a Buffer or Stream, we shouldn't convert it.
|
|
35
|
+
// But here we check for plain objects.
|
|
36
|
+
if (!(fixedOptions.body instanceof Buffer)) {
|
|
37
|
+
const params = new URLSearchParams();
|
|
38
|
+
for (const [key, value] of Object.entries(fixedOptions.body)) {
|
|
39
|
+
params.append(key, value);
|
|
40
|
+
}
|
|
41
|
+
fixedOptions.body = params.toString();
|
|
42
|
+
fixedOptions.headers = fixedOptions.headers || {};
|
|
43
|
+
fixedOptions.headers['Content-Type'] = 'application/x-www-form-urlencoded';
|
|
44
|
+
}
|
|
45
|
+
}
|
|
28
46
|
}
|
|
29
47
|
return fixedOptions
|
|
30
48
|
}
|
|
@@ -52,6 +70,17 @@ export const sxFetch = async (Auth, url, options, responseFields) => {
|
|
|
52
70
|
const response = await got(url, {
|
|
53
71
|
...fixedOptions,
|
|
54
72
|
responseType: 'buffer'
|
|
73
|
+
}).catch(err => {
|
|
74
|
+
if (err.response) {
|
|
75
|
+
const data = responseFields.reduce((p, c) => {
|
|
76
|
+
p[c] = err.response[c]
|
|
77
|
+
return p
|
|
78
|
+
}, {})
|
|
79
|
+
if (data.rawBody) data.rawBody = Array.from(data.rawBody);
|
|
80
|
+
if (data.body && Buffer.isBuffer(data.body)) data.body = Array.from(data.body);
|
|
81
|
+
err.data = data;
|
|
82
|
+
}
|
|
83
|
+
throw err;
|
|
55
84
|
})
|
|
56
85
|
|
|
57
86
|
// we cant return the response from this as it cant be serialized
|
|
@@ -93,4 +122,4 @@ export const sxFetchAll = async (Auth, requests, responseFields) => {
|
|
|
93
122
|
if (!isString) delete options.url
|
|
94
123
|
return sxFetch(Auth, url, options, responseFields)
|
|
95
124
|
}))
|
|
96
|
-
}
|
|
125
|
+
}
|
package/src/support/sxretry.js
CHANGED
|
@@ -36,31 +36,47 @@ export const sxRetry = async (Auth, tag, func, options = {}) => {
|
|
|
36
36
|
response = err.response;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
const redoCodes = [429, 500, 503, 408, 401]
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
39
|
+
const redoCodes = [429, 500, 503, 408, 401];
|
|
40
|
+
const networkErrorCodes = [
|
|
41
|
+
'ETIMEDOUT',
|
|
42
|
+
'ECONNRESET',
|
|
43
|
+
'ENETDOWN',
|
|
44
|
+
'ENETUNREACH',
|
|
45
|
+
'ECONNREFUSED',
|
|
46
|
+
'EPIPE',
|
|
47
|
+
'EAI_AGAIN',
|
|
48
|
+
'EHOSTUNREACH'
|
|
49
|
+
];
|
|
50
|
+
const status = response?.status || response?.statusCode;
|
|
51
|
+
|
|
52
|
+
let retryReason = redoCodes.includes(error?.code) ? error.code :
|
|
53
|
+
redoCodes.includes(status) ? status :
|
|
54
|
+
networkErrorCodes.includes(error?.code) ? error.code :
|
|
55
|
+
networkErrorCodes.includes(error?.cause?.code) ? error.cause.code :
|
|
56
|
+
networkErrorCodes.find(code => error?.message?.includes(code));
|
|
57
|
+
|
|
58
|
+
if (!retryReason && status === 403 && (
|
|
59
|
+
error?.message?.toLowerCase().includes('usage limit') ||
|
|
60
|
+
error?.message?.toLowerCase().includes('rate limit') ||
|
|
61
|
+
error?.errors?.some(e => ['rateLimitExceeded', 'userRateLimitExceeded', 'calendarUsageLimitsExceeded'].includes(e.reason))
|
|
62
|
+
)) {
|
|
63
|
+
retryReason = 'Rate Limit';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const isRetryable = !!retryReason || extraRetryCheck(error, response);
|
|
67
|
+
if (isRetryable && !retryReason) retryReason = 'Extra Check';
|
|
54
68
|
|
|
55
69
|
if (isRetryable && i < maxRetries - 1) {
|
|
56
|
-
const isAuthError = error?.code === 401 ||
|
|
70
|
+
const isAuthError = error?.code === 401 || status === 401;
|
|
57
71
|
if (isAuthError) {
|
|
72
|
+
// Only retry auth error once
|
|
73
|
+
if (i > 0) break;
|
|
58
74
|
Auth.invalidateToken();
|
|
59
75
|
syncWarn(`Authentication error (401) on ${tag}. Invalidated token and retrying immediately...`);
|
|
60
76
|
} else {
|
|
61
77
|
const jitter = Math.floor(Math.random() * 1000);
|
|
62
78
|
const totalDelay = delay + jitter;
|
|
63
|
-
syncWarn(`Retryable error on ${tag} (status: ${
|
|
79
|
+
syncWarn(`Retryable error on ${tag} (status: ${status || error?.code}, reason: ${retryReason}). Retrying in ${totalDelay}ms...`);
|
|
64
80
|
await sleep(totalDelay);
|
|
65
81
|
delay *= 2;
|
|
66
82
|
}
|
|
@@ -68,10 +84,26 @@ export const sxRetry = async (Auth, tag, func, options = {}) => {
|
|
|
68
84
|
}
|
|
69
85
|
|
|
70
86
|
if (error) {
|
|
87
|
+
if (isRetryable && i === maxRetries - 1) {
|
|
88
|
+
// We've exhausted retries. Mark the error message to indicate this.
|
|
89
|
+
const msg = `Max retries reached (${maxRetries}) for reason ${retryReason}: ${error.message}`;
|
|
90
|
+
if (!response) {
|
|
91
|
+
response = {
|
|
92
|
+
status: 504,
|
|
93
|
+
statusText: msg,
|
|
94
|
+
data: { error: { message: msg } }
|
|
95
|
+
};
|
|
96
|
+
} else {
|
|
97
|
+
response.data = response.data || {};
|
|
98
|
+
response.data.error = response.data.error || { message: error.message };
|
|
99
|
+
response.data.error.message = `Max retries reached (${maxRetries}) for reason ${retryReason}: ${response.data.error.message}`;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
71
103
|
if (!skipLog(error, response)) {
|
|
72
104
|
syncError(`Failed in ${tag}`, error);
|
|
73
105
|
}
|
|
74
|
-
return { data: null, response: responseSyncify(response) };
|
|
106
|
+
return { data: error.data || null, response: responseSyncify(response) };
|
|
75
107
|
}
|
|
76
108
|
|
|
77
109
|
return { data: response.data, response: responseSyncify(response) };
|