@mcpher/gas-fakes 2.0.7 → 2.0.9
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 +7 -8
- package/package.json +4 -4
- package/src/cli/app.js +11 -3
- package/src/index.js +1 -2
- package/src/services/formapp/fakeform.js +57 -1
- package/src/services/formapp/fakeformresponse.js +48 -20
- package/src/services/spreadsheetapp/faketextfinder.js +4 -1
- package/src/support/auth.js +3 -0
- package/src/support/env-loader.js +34 -0
- package/src/support/sxretry.js +7 -3
- package/src/support/sxtoken.js +21 -1
- package/src/support/syncit.js +6 -1
- package/debug_form.js +0 -43
package/README.md
CHANGED
|
@@ -129,15 +129,13 @@ It contains a cloud logging query that will display any logging done in this ses
|
|
|
129
129
|
|
|
130
130
|
You will have used the gas-fakes init command to create a .env file, containing the LOG_DESTINATION setting. You can change any of the settings in the .env file manually if you want to.
|
|
131
131
|
|
|
132
|
-
If you want to set an initial LOG_DESTINATION using that .env file, you have to let gas-fakes know where to find it. Let's assume it's in the same folder as your main script.
|
|
132
|
+
If you want to set an initial LOG_DESTINATION using that .env file, you have to let gas-fakes know where to find it. Let's assume it's in the same folder as your main script.
|
|
133
133
|
```env
|
|
134
|
-
node
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
```javascript
|
|
138
|
-
import dotenv from 'dotenv'
|
|
139
|
-
dotenv.config({ path: '/custom/path/to/.env' })
|
|
134
|
+
node yourapp.js
|
|
135
|
+
# or if your .env is somewhere else
|
|
136
|
+
node --env-file pathtoenv yourapp.js
|
|
140
137
|
```
|
|
138
|
+
|
|
141
139
|
Alternatively, instead of putting it in an env file, you can export it in your shell environment.
|
|
142
140
|
```sh
|
|
143
141
|
export LOG_DESTINATION="BOTH"
|
|
@@ -170,7 +168,8 @@ As I mentioned earlier, to take this further, I'm going to need a lot of help to
|
|
|
170
168
|
- [getting started](GETTING_STARTED.md) - how to handle authentication for restricted scopes.
|
|
171
169
|
- [readme](README.md)
|
|
172
170
|
- [gas fakes cli](gas-fakes-cli.md)
|
|
173
|
-
- [running gas-fakes on google cloud run](
|
|
171
|
+
- [running gas-fakes on google cloud run](https://github.com/brucemcpherson/gas-fakes-containers)
|
|
172
|
+
- [running gas-fakes on google kubernetes engine](https://github.com/brucemcpherson/gas-fakes-containers)
|
|
174
173
|
- [initial idea and thoughts](https://ramblings.mcpher.com/a-proof-of-concept-implementation-of-apps-script-environment-on-node/)
|
|
175
174
|
- [Inside the volatile world of a Google Document](https://ramblings.mcpher.com/inside-the-volatile-world-of-a-google-document/)
|
|
176
175
|
- [Apps Script Services on Node – using apps script libraries](https://ramblings.mcpher.com/apps-script-services-on-node-using-apps-script-libraries/)
|
package/package.json
CHANGED
|
@@ -13,16 +13,16 @@
|
|
|
13
13
|
"dotenv": "^17.3.1",
|
|
14
14
|
"fast-xml-parser": "^5.3.6",
|
|
15
15
|
"get-stream": "^9.0.1",
|
|
16
|
-
"googleapis": "^
|
|
16
|
+
"googleapis": "^171.4.0",
|
|
17
17
|
"got": "^14.6.6",
|
|
18
|
-
"into-stream": "^
|
|
18
|
+
"into-stream": "^9.1.0",
|
|
19
19
|
"keyv": "^5.6.0",
|
|
20
20
|
"keyv-file": "^5.3.3",
|
|
21
21
|
"mime": "^4.1.0",
|
|
22
22
|
"prompts": "^2.4.2",
|
|
23
23
|
"sleep-synchronously": "^2.0.0",
|
|
24
24
|
"unzipper": "^0.12.3",
|
|
25
|
-
"zod": "^3.
|
|
25
|
+
"zod": "^4.3.6"
|
|
26
26
|
},
|
|
27
27
|
"type": "module",
|
|
28
28
|
"scripts": {
|
|
@@ -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.9",
|
|
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",
|
package/src/cli/app.js
CHANGED
|
@@ -82,9 +82,17 @@ export async function main() {
|
|
|
82
82
|
return;
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
85
|
+
// Load environment variables.
|
|
86
|
+
// If --env-file is used, we skip the default .env to avoid conflicts,
|
|
87
|
+
// but if the user explicitly provided -e, we use that.
|
|
88
|
+
const hasEnvFileFlag = process.execArgv.some(arg => arg.startsWith('--env-file'));
|
|
89
|
+
const isDefaultEnv = env === "./.env";
|
|
90
|
+
|
|
91
|
+
if (!hasEnvFileFlag || !isDefaultEnv) {
|
|
92
|
+
const envPath = path.resolve(process.cwd(), env);
|
|
93
|
+
console.log(`...using env file in ${envPath}`);
|
|
94
|
+
dotenv.config({ path: envPath, quiet: true });
|
|
95
|
+
}
|
|
88
96
|
|
|
89
97
|
const settingsPath = path.resolve(process.cwd(), gfsettings);
|
|
90
98
|
console.log(`...using gasfakes settings file in ${settingsPath}`);
|
package/src/index.js
CHANGED
|
@@ -627,7 +627,7 @@ export class FakeForm {
|
|
|
627
627
|
* @returns {string} The form URL.
|
|
628
628
|
*/
|
|
629
629
|
getPublishedUrl() {
|
|
630
|
-
return `https://docs.google.com/forms/d
|
|
630
|
+
return `https://docs.google.com/forms/d/${this.getId()}/viewform`;
|
|
631
631
|
}
|
|
632
632
|
|
|
633
633
|
/**
|
|
@@ -638,6 +638,62 @@ export class FakeForm {
|
|
|
638
638
|
return this.__resource.responderUri;
|
|
639
639
|
}
|
|
640
640
|
|
|
641
|
+
/**
|
|
642
|
+
* Internal method to get scraped metadata from the published form with caching.
|
|
643
|
+
* @returns {object} The scraped metadata or null.
|
|
644
|
+
*/
|
|
645
|
+
__getScrapedMetadata() {
|
|
646
|
+
if (this.__scrapedMetadata) return this.__scrapedMetadata;
|
|
647
|
+
|
|
648
|
+
const formFile = this.__file;
|
|
649
|
+
|
|
650
|
+
// 1. Capture original state
|
|
651
|
+
const originalAccess = formFile.getSharingAccess();
|
|
652
|
+
const originalPermission = formFile.getSharingPermission();
|
|
653
|
+
|
|
654
|
+
try {
|
|
655
|
+
// 2. Open access temporarily
|
|
656
|
+
formFile.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW);
|
|
657
|
+
|
|
658
|
+
// 3. Fetch the HTML
|
|
659
|
+
const publishUrl = this.getPublishedUrl();
|
|
660
|
+
const formHtml = UrlFetchApp.fetch(publishUrl).getContentText();
|
|
661
|
+
|
|
662
|
+
const metadata = { entryMap: {} };
|
|
663
|
+
const fbzxMatch = formHtml.match(/name="fbzx" value="([^"]+)"/);
|
|
664
|
+
if (fbzxMatch) metadata.fbzx = fbzxMatch[1];
|
|
665
|
+
|
|
666
|
+
const historyMatch = formHtml.match(/name="pageHistory" value="([^"]+)"/);
|
|
667
|
+
if (historyMatch) metadata.pageHistory = historyMatch[1];
|
|
668
|
+
|
|
669
|
+
// Scrape entry IDs. They are usually in a script block like FB_PUBLIC_LOAD_DATA_
|
|
670
|
+
// or just search for entry.ID in the HTML.
|
|
671
|
+
// A common pattern is [123456789, "Question Title", ...]
|
|
672
|
+
const entryMatches = formHtml.matchAll(/entry\.(\d+)/g);
|
|
673
|
+
for (const match of entryMatches) {
|
|
674
|
+
// This is a bit crude but might help identify all available entry IDs
|
|
675
|
+
metadata.entryMap[match[1]] = true;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
this.__scrapedMetadata = metadata;
|
|
679
|
+
return metadata;
|
|
680
|
+
|
|
681
|
+
} catch (e) {
|
|
682
|
+
console.warn(`[gas-fakes] Failed to fetch form metadata: ${e.message}`);
|
|
683
|
+
return null;
|
|
684
|
+
} finally {
|
|
685
|
+
// 4. Reset to original state
|
|
686
|
+
formFile.setSharing(originalAccess, originalPermission);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* Internal method to clear the scraped metadata cache.
|
|
692
|
+
*/
|
|
693
|
+
__clearScrapedMetadata() {
|
|
694
|
+
this.__scrapedMetadata = null;
|
|
695
|
+
}
|
|
696
|
+
|
|
641
697
|
/**
|
|
642
698
|
* Gets the URL to respond to the form
|
|
643
699
|
* https://github.com/brucemcpherson/gas-fakes/issues/111
|
|
@@ -115,21 +115,54 @@ export class FakeFormResponse {
|
|
|
115
115
|
const responderUri = this.__form.__getResponderUri();
|
|
116
116
|
const url = responderUri.replace('/viewform', '/formResponse');
|
|
117
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
118
|
|
|
125
119
|
this.getItemResponses().forEach(itemResponse => {
|
|
126
120
|
const item = itemResponse.getItem();
|
|
127
|
-
const
|
|
128
|
-
|
|
121
|
+
const response = itemResponse.getResponse();
|
|
122
|
+
const itemType = item.getType().toString();
|
|
123
|
+
|
|
124
|
+
if ((itemType === 'CHECKBOX_GRID' || itemType === 'GRID') && Array.isArray(response)) {
|
|
125
|
+
const gridRows = item.__resource.questionGroupItem?.questions || [];
|
|
126
|
+
|
|
127
|
+
response.forEach((rowResponse, rowIndex) => {
|
|
128
|
+
if (rowResponse && (Array.isArray(rowResponse) ? rowResponse.length > 0 : true)) {
|
|
129
|
+
const rowQuestionIdHex = gridRows[rowIndex]?.questionId;
|
|
130
|
+
if (rowQuestionIdHex) {
|
|
131
|
+
const rowQuestionIdDecimal = Utils.fromHex(rowQuestionIdHex);
|
|
132
|
+
payload[`entry.${rowQuestionIdDecimal}`] = rowResponse;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
} else {
|
|
137
|
+
const questionIdHex = item.__resource.questionItem?.question?.questionId;
|
|
138
|
+
if (questionIdHex) {
|
|
139
|
+
const questionIdDecimal = Utils.fromHex(questionIdHex);
|
|
140
|
+
payload[`entry.${questionIdDecimal}`] = response;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
129
143
|
});
|
|
130
144
|
|
|
131
145
|
if (Object.keys(payload).length > 0) {
|
|
132
|
-
|
|
146
|
+
// Dynamic page history based on actual form structure
|
|
147
|
+
const pageCount = this.__form.getItems(FormApp.ItemType.PAGE_BREAK).length + 1;
|
|
148
|
+
payload.pageHistory = Array.from({length: pageCount}, (_, i) => i).join(',');
|
|
149
|
+
payload.fvv = '1';
|
|
150
|
+
|
|
151
|
+
// Use cached metadata if available
|
|
152
|
+
const metadata = this.__form.__getScrapedMetadata();
|
|
153
|
+
if (metadata?.fbzx) payload.fbzx = metadata.fbzx;
|
|
154
|
+
|
|
155
|
+
// Build the payload string manually to handle multiple values for the same key (checkboxes)
|
|
156
|
+
const payloadParts = [];
|
|
157
|
+
Object.keys(payload).forEach(key => {
|
|
158
|
+
const value = payload[key];
|
|
159
|
+
if (Array.isArray(value)) {
|
|
160
|
+
value.forEach(v => payloadParts.push(`${encodeURIComponent(key)}=${encodeURIComponent(v)}`));
|
|
161
|
+
} else {
|
|
162
|
+
payloadParts.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
const payloadString = payloadParts.join('&');
|
|
133
166
|
|
|
134
167
|
// we need to do many things here to allowe access to the form as there are no formapp methods to add reponses.
|
|
135
168
|
// first save the current file permissions
|
|
@@ -144,29 +177,24 @@ export class FakeFormResponse {
|
|
|
144
177
|
|
|
145
178
|
try {
|
|
146
179
|
|
|
147
|
-
// ---
|
|
148
|
-
console.log("Form is temporarily public. Submitting...");
|
|
180
|
+
// --- SUBMISSION LOGIC ---
|
|
149
181
|
const response = UrlFetchApp.fetch(url, {
|
|
150
182
|
method: 'post',
|
|
151
|
-
payload,
|
|
183
|
+
payload: payloadString,
|
|
184
|
+
contentType: 'application/x-www-form-urlencoded',
|
|
152
185
|
muteHttpExceptions: true
|
|
153
186
|
});
|
|
154
187
|
|
|
155
|
-
if (response.getContentText().includes("recorded")) {
|
|
156
|
-
console.log("Success! Data pushed to Google Sheets.");
|
|
157
|
-
}
|
|
158
|
-
|
|
159
188
|
if (response?.getResponseCode() !== 200) {
|
|
160
189
|
throw new Error(`Failed to submit form response: ${response.getResponseCode()}`);
|
|
161
190
|
}
|
|
162
191
|
|
|
163
|
-
|
|
164
|
-
|
|
192
|
+
// Successful submission, clear the scraped metadata cache to get a fresh fbzx next time
|
|
193
|
+
this.__form.__clearScrapedMetadata();
|
|
194
|
+
|
|
165
195
|
} finally {
|
|
166
196
|
// 3. Reset to exactly how it was before
|
|
167
|
-
// This closes the 401/403 hole immediately
|
|
168
197
|
formFile.setSharing(originalAccess, originalPermission);
|
|
169
|
-
console.log("Permissions reset to original state.");
|
|
170
198
|
}
|
|
171
199
|
}
|
|
172
200
|
|
|
@@ -60,7 +60,7 @@ export class FakeTextFinder {
|
|
|
60
60
|
}
|
|
61
61
|
return (
|
|
62
62
|
this.searchResults[
|
|
63
|
-
|
|
63
|
+
this.count == 0 ? this.searchResults.length - 1 : --this.count
|
|
64
64
|
] || null
|
|
65
65
|
);
|
|
66
66
|
}
|
|
@@ -189,6 +189,9 @@ export class FakeTextFinder {
|
|
|
189
189
|
target = c.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
|
|
190
190
|
}
|
|
191
191
|
if (!matchCase) {
|
|
192
|
+
if (typeof c !== 'string') {
|
|
193
|
+
c = String(c);
|
|
194
|
+
}
|
|
192
195
|
target = c.toLowerCase();
|
|
193
196
|
this.text = this.text.toLowerCase();
|
|
194
197
|
}
|
package/src/support/auth.js
CHANGED
|
@@ -225,6 +225,9 @@ const setAuth = async (scopes = [], mcpLoading = false) => {
|
|
|
225
225
|
this._expiresAt = 0
|
|
226
226
|
this.credentials = null
|
|
227
227
|
}
|
|
228
|
+
_authClient.refreshAccessToken = function () {
|
|
229
|
+
return this.getAccessToken()
|
|
230
|
+
}
|
|
228
231
|
|
|
229
232
|
mayLog(`...using Domain-Wide Delegation for user: ${userEmail}`)
|
|
230
233
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import dotenv from 'dotenv';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join, dirname } from 'node:path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Conditionally loads environment variables from a .env file.
|
|
7
|
+
*
|
|
8
|
+
* If Node.js was started with the --env-file flag, this loader does nothing
|
|
9
|
+
* to allow the native Node.js environment file handling to take precedence.
|
|
10
|
+
*
|
|
11
|
+
* Otherwise, it attempts to load a .env file from the directory where the
|
|
12
|
+
* main script is located. If not found, it falls back to the default
|
|
13
|
+
* dotenv behavior (searching the current working directory).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// Check if node was run with --env-file
|
|
17
|
+
const hasEnvFileFlag = process.execArgv.some(arg => arg.startsWith('--env-file'));
|
|
18
|
+
|
|
19
|
+
if (!hasEnvFileFlag) {
|
|
20
|
+
const mainScript = process.argv[1];
|
|
21
|
+
if (mainScript) {
|
|
22
|
+
// Try to find .env in the same directory as the main entry point script
|
|
23
|
+
const envPath = join(dirname(mainScript), '.env');
|
|
24
|
+
if (existsSync(envPath)) {
|
|
25
|
+
dotenv.config({ path: envPath });
|
|
26
|
+
} else {
|
|
27
|
+
// Fallback to default dotenv behavior (CWD)
|
|
28
|
+
dotenv.config();
|
|
29
|
+
}
|
|
30
|
+
} else {
|
|
31
|
+
// Fallback if mainScript is not available (e.g. REPL)
|
|
32
|
+
dotenv.config();
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/support/sxretry.js
CHANGED
|
@@ -17,7 +17,7 @@ const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
|
17
17
|
*/
|
|
18
18
|
export const sxRetry = async (Auth, tag, func, options = {}) => {
|
|
19
19
|
const {
|
|
20
|
-
maxRetries =
|
|
20
|
+
maxRetries = 9,
|
|
21
21
|
initialDelay = 1777,
|
|
22
22
|
extraRetryCheck = () => false,
|
|
23
23
|
skipLog = () => false
|
|
@@ -63,16 +63,20 @@ export const sxRetry = async (Auth, tag, func, options = {}) => {
|
|
|
63
63
|
retryReason = 'Rate Limit';
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
if (!retryReason && error?.message?.toLowerCase().includes('no refresh token')) {
|
|
67
|
+
retryReason = 'No Refresh Token';
|
|
68
|
+
}
|
|
69
|
+
|
|
66
70
|
const isRetryable = !!retryReason || extraRetryCheck(error, response);
|
|
67
71
|
if (isRetryable && !retryReason) retryReason = 'Extra Check';
|
|
68
72
|
|
|
69
73
|
if (isRetryable && i < maxRetries - 1) {
|
|
70
|
-
const isAuthError = error?.code === 401 || status === 401;
|
|
74
|
+
const isAuthError = error?.code === 401 || status === 401 || retryReason === 'No Refresh Token';
|
|
71
75
|
if (isAuthError) {
|
|
72
76
|
// Only retry auth error once
|
|
73
77
|
if (i > 0) break;
|
|
74
78
|
Auth.invalidateToken();
|
|
75
|
-
syncWarn(`Authentication error (
|
|
79
|
+
syncWarn(`Authentication error (${status || retryReason}) on ${tag}. Invalidated token and retrying immediately...`);
|
|
76
80
|
} else {
|
|
77
81
|
const jitter = Math.floor(Math.random() * 1000);
|
|
78
82
|
const totalDelay = delay + jitter;
|
package/src/support/sxtoken.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Auth } from './auth.js'
|
|
2
|
+
import { sxRetry } from './sxretry.js'
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* fetch effective user access token
|
|
@@ -12,4 +13,23 @@ export const sxGetAccessTokenInfo = async () => {
|
|
|
12
13
|
}
|
|
13
14
|
export const sxGetSourceAccessTokenInfo = async () => {
|
|
14
15
|
return await Auth.getSourceAccessTokenInfo()
|
|
15
|
-
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* For testing sxRetry logic
|
|
20
|
+
*/
|
|
21
|
+
export const sxTestRetry = async (Auth, { errorMessage }) => {
|
|
22
|
+
let callCount = 0;
|
|
23
|
+
const mockFunc = async () => {
|
|
24
|
+
callCount++;
|
|
25
|
+
if (callCount === 1) {
|
|
26
|
+
throw new Error(errorMessage);
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
status: 200,
|
|
30
|
+
data: { success: true, callCount }
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
return await sxRetry(Auth, 'TestRetry', mockFunc);
|
|
35
|
+
};
|
package/src/support/syncit.js
CHANGED
|
@@ -381,6 +381,10 @@ const fxGetSourceAccessTokenInfo = () => {
|
|
|
381
381
|
return callSync("sxGetSourceAccessTokenInfo");
|
|
382
382
|
};
|
|
383
383
|
|
|
384
|
+
const fxTestRetry = (errorMessage) => {
|
|
385
|
+
return callSync("sxTestRetry", { errorMessage });
|
|
386
|
+
};
|
|
387
|
+
|
|
384
388
|
const fxSheets = (args) =>
|
|
385
389
|
fxGeneric({
|
|
386
390
|
...args,
|
|
@@ -447,5 +451,6 @@ export const Syncit = {
|
|
|
447
451
|
fxDriveExport,
|
|
448
452
|
fxGetAccessToken,
|
|
449
453
|
fxGetAccessTokenInfo,
|
|
450
|
-
fxGetSourceAccessTokenInfo
|
|
454
|
+
fxGetSourceAccessTokenInfo,
|
|
455
|
+
fxTestRetry
|
|
451
456
|
}
|
package/debug_form.js
DELETED
|
@@ -1,43 +0,0 @@
|
|
|
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();
|