@mcpher/gas-fakes 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +305 -0
- package/main.js +1 -0
- package/package.json +23 -0
- package/src/index.js +4 -0
- package/src/services/drive/app.js +31 -0
- package/src/services/drive/drapis.js +23 -0
- package/src/services/drive/fakedrive.js +568 -0
- package/src/services/drive/fakedrivehelpers.js +141 -0
- package/src/services/scriptapp/app.js +133 -0
- package/src/services/urlfetchapp/app.js +128 -0
- package/src/services/utilities/app.js +37 -0
- package/src/services/utilities/fakeblob.js +73 -0
- package/src/support/auth.js +154 -0
- package/src/support/constants.js +16 -0
- package/src/support/peeker.js +44 -0
- package/src/support/proxies.js +74 -0
- package/src/support/syncit.js +235 -0
- package/src/support/utils.js +144 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Bruce Mcpherson
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
# A proof of concept implementation of Apps Script Environment on Node
|
|
2
|
+
|
|
3
|
+
I use clasp/vscode to develop Google Apps Script (GAS) applications, but when using GAS native services, there's way too much back and fowards to the GAS IDE going while testing. I set myself the ambition of implementing fake version of the GAS runtime environment on Node so I could at least do some testing locally.
|
|
4
|
+
|
|
5
|
+
This is just a proof of concept so I've just implemented a very limited number of services and methods, but the tricky parts are all in place so all that's left is a load of busy work (to which I heartily invite any interested collaborators).
|
|
6
|
+
|
|
7
|
+
## getting started
|
|
8
|
+
|
|
9
|
+
You can get the package from npm
|
|
10
|
+
|
|
11
|
+
````
|
|
12
|
+
npm i @mcpher/gas-fakes
|
|
13
|
+
````
|
|
14
|
+
|
|
15
|
+
The idea is that you can run GAS services (so far implemented) locally on Node, and it will use various Google Workspace APIS to emulate what would happen if you were to run the same thing in the GAS environment.
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Cloud project
|
|
19
|
+
|
|
20
|
+
You don't have access to the GAS maintained cloud project, so you'll need to create a GCP project to use locally. In order to duplicate the OAuth management handled by GAS, we'll use Application Default Credentials. There are some scripts in this repo to set up and test these. Once you've set up a clud project go to the shells folder and add your project id to setaccount.sh and
|
|
21
|
+
|
|
22
|
+
### Testing
|
|
23
|
+
|
|
24
|
+
I recommend you use the test project included in the repo to make sure all is set up correctly. It uses a Fake DriveApp service to excercise Auth etc. Just change the fixtures to values present in your own Drive, then npm i && npm test. Note that I use a [unit tester](https://ramblings.mcpher.com/apps-script-test-runner-library-ported-to-node/) that runs in both GAS and Node, so the exact same tests will run in both environments.
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
### Pushing to GAS
|
|
28
|
+
|
|
29
|
+
The script togas.sh will move your files to gas - just set the SOURCE and TARGET folders in the script. Make sure you have an appsscript.json manifest in the SOURCE folder, as gas-fakes reads that to handle OAuth on Node.
|
|
30
|
+
|
|
31
|
+
You can write your project to run on Node and call GAS services, and it will also run on the GAS environment with no code changes.
|
|
32
|
+
|
|
33
|
+
## Approach
|
|
34
|
+
|
|
35
|
+
Google have not made details about the GAS run time public (as far as I know). What we do know is that it used to run on a Java based JavaScript emulator [Rhino](https://ramblings.mcpher.com/gassnippets2/what-javascript-engine-is-apps-script-running-on/) but a few years ago moved to a V8 runtime. Beyond that, we don't know anything much other than it runs on Google Servers somewhere.
|
|
36
|
+
|
|
37
|
+
There were 3 main sticky problems to overcome to get this working
|
|
38
|
+
- GAS is entirely synchronous, whereas the replacement calls to Workspace APIS on Node are all asynchrounous.
|
|
39
|
+
- GAS handles OAuth initialization from the manifest file automatically, whereas we need some additional coding or alternative approaches on Node.
|
|
40
|
+
- The service singletons (eg. DriveApp) are all intialized and available in the global space automatically, whereas in Node they need some post AUTH intialization, sequencing intialization and exposure.
|
|
41
|
+
- GAS iterators aren't the same as standard iterators, as they have a hasNext() method and don't behave in the same way.
|
|
42
|
+
|
|
43
|
+
Beyond that, implementation is just a lot of busy work. Here's how I've dealt with these 3 problems.
|
|
44
|
+
|
|
45
|
+
### Sync versus Async
|
|
46
|
+
|
|
47
|
+
Although Apps Script supports async/await/promise syntax, it operates in blocking mode. I didn't really want to have to insist on async coding in code targeted at GAS, so I needed to find a way to emulate what the GAS environment probably does.
|
|
48
|
+
|
|
49
|
+
Since asynchonicity is fundamental to Node, there's no real simple way to convert async to sync. However, there is such a thing as a [child-process](https://nodejs.org/api/child_process.html#child-process) which you can start up to run things, and it features an [execSync](https://nodejs.org/api/child_process.html#child_processexecsynccommand-options) method which delays the return from the child process until the promise queue is all settled. So the simplest solution is to run an async method in a child process, wait till it's done, and return the results synchronously. I found that [Sindre Sorhus](https://github.com/sindresorhus) uses this approach with [make-synchronous](https://github.com/sindresorhus/make-synchronous), so I'm using that.
|
|
50
|
+
|
|
51
|
+
Here's a simple example of how to get info on an access token made synchronous
|
|
52
|
+
````
|
|
53
|
+
/**
|
|
54
|
+
* a sync version of token checking
|
|
55
|
+
* @param {string} token the token to check
|
|
56
|
+
* @returns {object} access token info
|
|
57
|
+
*/
|
|
58
|
+
const fxCheckToken = (accessToken) => {
|
|
59
|
+
|
|
60
|
+
// now turn all that into a synchronous function - it runs as a subprocess, so we need to start from scratch
|
|
61
|
+
const fx = makeSynchronous(async accessToken => {
|
|
62
|
+
const { default: got } = await import('got')
|
|
63
|
+
const tokenInfo = await got(`https://www.googleapis.com/oauth2/v3/tokeninfo?access_token=${accessToken}`).json()
|
|
64
|
+
return tokenInfo
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
const result = fx(accessToken)
|
|
68
|
+
return result
|
|
69
|
+
}
|
|
70
|
+
````
|
|
71
|
+
### OAuth
|
|
72
|
+
|
|
73
|
+
There's 2 pieces to this solution.
|
|
74
|
+
|
|
75
|
+
#### Application default credentials (ADC)
|
|
76
|
+
|
|
77
|
+
In order to avoid a bunch of Node specific code and credentials, yet still handle OAuth, I figured that we could simply rely on ADC. This is a problem I already wrote about here [Application Default Credentials with Google Cloud and Workspace APIs](https://ramblings.mcpher.com/application-default-credentials-with-google-cloud-and-workspace-apis/)
|
|
78
|
+
|
|
79
|
+
To set this up, set your GCP project ID and the extra scopes you'll need in shells/setaccount.sh. In this example I'm retaining the usual ADC scopes, and adding an extra scope to be able to access Drive.
|
|
80
|
+
|
|
81
|
+
````
|
|
82
|
+
# project ID
|
|
83
|
+
P=YOUR_GCP_PROJECT_ID
|
|
84
|
+
|
|
85
|
+
# config to activate - multiple configs can each be named
|
|
86
|
+
# here we're working on the default project configuration
|
|
87
|
+
AC=default
|
|
88
|
+
|
|
89
|
+
# these are the ones it sets by default - take some of these out if you want to minimize access
|
|
90
|
+
DEFAULT_SCOPES="https://www.googleapis.com/auth/userinfo.email,https://www.googleapis.com/auth/drive,openid,https://www.googleapis.com/auth/cloud-platform,https://www.googleapis.com/auth/sqlservice.login"
|
|
91
|
+
|
|
92
|
+
# these are the ones we want to add (note comma at beginning)
|
|
93
|
+
EXTRA_SCOPES=",https://www.googleapis.com/auth/drive"
|
|
94
|
+
|
|
95
|
+
.....etc
|
|
96
|
+
````
|
|
97
|
+
Now you can execute this and it will set up your ADC to be able to run any services that require the scopes you add.
|
|
98
|
+
|
|
99
|
+
##### note
|
|
100
|
+
Although you may be tempted to add "https://www.googleapis.com/auth/script.external_request", it's not necessary for the ADC and in fact will generate an error. You will of course need it in your Apps script manifest.
|
|
101
|
+
|
|
102
|
+
##### testing ADC
|
|
103
|
+
|
|
104
|
+
shells/testtoken.sh can test that you can generate a token with sufficient scope. In this example, I'm checking that I can access a file I own. Change the id to one of your own.
|
|
105
|
+
````
|
|
106
|
+
# check tokens have scopes required for DRIVE access
|
|
107
|
+
# set below to a fileid on drive you have access to
|
|
108
|
+
FILE_ID=SOME_FILE_ID
|
|
109
|
+
|
|
110
|
+
....etc
|
|
111
|
+
````
|
|
112
|
+
|
|
113
|
+
I recommend you do this to make sure Auth it's all good before you start coding up your app.
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
#### Manifest file
|
|
117
|
+
|
|
118
|
+
gas-fakes reads the manifest file to see which scopes you need in your project, uses the Google Auth library to attempt to authorizes them and has ScriptApp.getOauthToken() return a sufficiently specced token, just as the GAS environment does. Just make sure you have an appsscript.json in the same folder as your main script.
|
|
119
|
+
|
|
120
|
+
### global intialization
|
|
121
|
+
|
|
122
|
+
This was a little problematic to sequence, but I wanted to make sure that any GAS services being imitated were available and initialized on the Node side, just as they are in GAS. At the time of writing (a subset of the methods of) these services are implemented.
|
|
123
|
+
|
|
124
|
+
v1.0.0 proof of concept for
|
|
125
|
+
|
|
126
|
+
- DriveApp
|
|
127
|
+
- ScriptApp
|
|
128
|
+
- UrlFetchApp
|
|
129
|
+
- Utilities
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
#### Proxies and globalThis
|
|
133
|
+
|
|
134
|
+
Each service has a FakeClass but I needed the Auth cycle to be initiated and done before making them public. Using a proxy was the simplest approach.
|
|
135
|
+
|
|
136
|
+
Here's the code for ScriptApp
|
|
137
|
+
|
|
138
|
+
````
|
|
139
|
+
/**
|
|
140
|
+
* adds to global space to mimic Apps Script behavior
|
|
141
|
+
*/
|
|
142
|
+
const name = "ScriptApp"
|
|
143
|
+
|
|
144
|
+
if (typeof globalThis[name] === typeof undefined) {
|
|
145
|
+
|
|
146
|
+
console.log ('setting script app to global')
|
|
147
|
+
|
|
148
|
+
const getApp = () => {
|
|
149
|
+
|
|
150
|
+
// if it hasn't been intialized yet then do that
|
|
151
|
+
if (!_app) {
|
|
152
|
+
|
|
153
|
+
// we also need to do the manifest scopes thing and the project id
|
|
154
|
+
const projectId = Syncit.fxGetProjectId()
|
|
155
|
+
const manifest = Syncit.fxGetManifest()
|
|
156
|
+
Auth.setProjectId (projectId)
|
|
157
|
+
Auth.setManifestScopes(manifest)
|
|
158
|
+
|
|
159
|
+
_app = {
|
|
160
|
+
getOAuthToken,
|
|
161
|
+
requireAllScopes,
|
|
162
|
+
requireScopes,
|
|
163
|
+
AuthMode: {
|
|
164
|
+
FULL: 'FULL'
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
}
|
|
170
|
+
// this is the actual driveApp we'll return from the proxy
|
|
171
|
+
return _app
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
Proxies.registerProxy(name, getApp)
|
|
176
|
+
|
|
177
|
+
}
|
|
178
|
+
````
|
|
179
|
+
|
|
180
|
+
Here's how the proxies are registered
|
|
181
|
+
````
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* diverts the property get to another object returned by the getApp function
|
|
185
|
+
* @param {function} a function to get the proxy object to substitutes
|
|
186
|
+
* @returns {function} a handler for a proxy
|
|
187
|
+
*/
|
|
188
|
+
const getAppHandler = (getApp) => {
|
|
189
|
+
return {
|
|
190
|
+
|
|
191
|
+
get(_, prop, receiver) {
|
|
192
|
+
// this will let the caller know we're not really running in Apps Script
|
|
193
|
+
return (prop === 'isFake') ? true : Reflect.get(getApp(), prop, receiver);
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
ownKeys(_) {
|
|
197
|
+
return Reflect.ownKeys(getApp())
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const registerProxy = (name, getApp) => {
|
|
203
|
+
const value = new Proxy({}, getAppHandler(getApp))
|
|
204
|
+
// add it to the global space to mimic what apps script does
|
|
205
|
+
Object.defineProperty(globalThis, name, {
|
|
206
|
+
value,
|
|
207
|
+
enumerable: true,
|
|
208
|
+
configurable: false,
|
|
209
|
+
writable: false,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
````
|
|
213
|
+
|
|
214
|
+
In short, the service us registered as an empty object, but when any attempt is made to access it actually returns a different object which handles the request. In the ScriptApp example, ScriptApp is an empty object, but accessing ScriptApp.getOAuthToken() returns an Fake ScriptApp object which has been initialized.
|
|
215
|
+
|
|
216
|
+
There's also a test available to see if you are running in GAS or on Node - ScriptApp.isFake
|
|
217
|
+
|
|
218
|
+
### Iterators
|
|
219
|
+
|
|
220
|
+
An iterator created by a generator does not have a hasNext() function, whereas GAS iterators do. To get round this, we can create a regular Node iterator, but introduce a wrapper so the constructor actually gets the first one, and next() uses the value we've already peeked at. Here's a wrapper to convert an iterator into a GAS style one.
|
|
221
|
+
````
|
|
222
|
+
import { Proxies } from './proxies.js'
|
|
223
|
+
/**
|
|
224
|
+
* this is a class to add a hasnext to a generator
|
|
225
|
+
* @class Peeker
|
|
226
|
+
*
|
|
227
|
+
*/
|
|
228
|
+
class Peeker {
|
|
229
|
+
/**
|
|
230
|
+
* @constructor
|
|
231
|
+
* @param {function} generator the generator function to add a hasNext() to
|
|
232
|
+
* @returns {Peeker}
|
|
233
|
+
*/
|
|
234
|
+
constructor(generator) {
|
|
235
|
+
this.generator = generator
|
|
236
|
+
// in order to be able to do a hasnext we have to actually get the value
|
|
237
|
+
// this is the next value stored
|
|
238
|
+
this.peeked = generator.next()
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* we see if there's a next if the peeked at is all over
|
|
243
|
+
* @returns {Boolean}
|
|
244
|
+
*/
|
|
245
|
+
hasNext () {
|
|
246
|
+
return !this.peeked.done
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* get the next value - actually its already got and storef in peeked
|
|
251
|
+
* @returns {object} {value, done}
|
|
252
|
+
*/
|
|
253
|
+
next () {
|
|
254
|
+
if (!this.hasNext()) {
|
|
255
|
+
// TODO find out what driveapp does
|
|
256
|
+
throw new Error ('iterator is exhausted - there is no more')
|
|
257
|
+
}
|
|
258
|
+
// instead of returning the next, we return the prepeeked next
|
|
259
|
+
const value = this.peeked.value
|
|
260
|
+
this.peeked = this.generator.next()
|
|
261
|
+
return value
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export const newPeeker = (...args) => Proxies.guard(new Peeker (...args))
|
|
266
|
+
````
|
|
267
|
+
|
|
268
|
+
And an example of usage, creating a parents iterator from a Drive API file.
|
|
269
|
+
````
|
|
270
|
+
/**
|
|
271
|
+
* this gets an intertor to fetch all the parents meta data
|
|
272
|
+
* @param {FakeDriveMeta} {file} the meta data
|
|
273
|
+
* @returns {object} {Peeker}
|
|
274
|
+
*/
|
|
275
|
+
const getParentsIterator = ({
|
|
276
|
+
file
|
|
277
|
+
}) => {
|
|
278
|
+
|
|
279
|
+
Utils.assertType(file, "object")
|
|
280
|
+
Utils.assertType(file.parents, "array")
|
|
281
|
+
|
|
282
|
+
function* filesink() {
|
|
283
|
+
// the result tank, we just get them all by id
|
|
284
|
+
let tank = file.parents.map(id => getFileById({ id, allow404: false }))
|
|
285
|
+
|
|
286
|
+
while (tank.length) {
|
|
287
|
+
yield newFakeDriveFolder(tank.splice(0, 1)[0])
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// create the iterator
|
|
292
|
+
const parentsIt = filesink()
|
|
293
|
+
|
|
294
|
+
// a regular iterator doesnt support the same methods
|
|
295
|
+
// as Apps Script so we'll fake that too
|
|
296
|
+
return newPeeker(parentsIt)
|
|
297
|
+
|
|
298
|
+
}
|
|
299
|
+
````
|
|
300
|
+
## Help
|
|
301
|
+
|
|
302
|
+
As I mentioned earlier, to take this further, I'm going to need a lot of help to extend the methods and services supported - so if you feel this would be useful to you, and would like to collaborate, please ping me on @bruce@mcpher.com and we'll talk.
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
|
package/main.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import './src/index.js'
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"dependencies": {
|
|
3
|
+
"get-stream": "^9.0.1",
|
|
4
|
+
"google-auth-library": "^9.15.0",
|
|
5
|
+
"googleapis": "^144.0.0",
|
|
6
|
+
"got": "^14.4.5",
|
|
7
|
+
"make-synchronous": "^1.0.0",
|
|
8
|
+
"mime": "^4.0.6",
|
|
9
|
+
"sleep-synchronously": "^2.0.0"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"scripts": {
|
|
13
|
+
"test": "node ./test/test.js",
|
|
14
|
+
"pub": "npm publish --access public"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"@mcpher/unit": "^1.1.6"
|
|
18
|
+
},
|
|
19
|
+
"name": "@mcpher/gas-fakes",
|
|
20
|
+
"version": "1.0.0",
|
|
21
|
+
"main": "main.js",
|
|
22
|
+
"description": "A proof of concept implementation of Apps Script Environment on Node"
|
|
23
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// fake Apps Script DriveApp
|
|
2
|
+
/**
|
|
3
|
+
* the idea here is to create a global entry for the singleton
|
|
4
|
+
* before we actually have everything we need to create it.
|
|
5
|
+
* We do this by using a proxy, intercepting calls to the
|
|
6
|
+
* initial sigleton and diverting them to a completed one
|
|
7
|
+
*/
|
|
8
|
+
import { newFakeDriveApp} from './fakedrive.js'
|
|
9
|
+
import { Proxies } from '../../support/proxies.js'
|
|
10
|
+
|
|
11
|
+
// This will eventually hold a proxy for DriveApp
|
|
12
|
+
let _app = null
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* adds to global space to mimic Apps Script behavior
|
|
16
|
+
*/
|
|
17
|
+
const name = "DriveApp"
|
|
18
|
+
if (typeof globalThis[name] === typeof undefined) {
|
|
19
|
+
|
|
20
|
+
const getApp = () => {
|
|
21
|
+
// if it hasne been intialized yet then do that
|
|
22
|
+
if (!_app) {
|
|
23
|
+
_app = newFakeDriveApp()
|
|
24
|
+
}
|
|
25
|
+
// this is the actual driveApp we'll return from the proxy
|
|
26
|
+
return _app
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
Proxies.registerProxy (name, getApp)
|
|
30
|
+
|
|
31
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { google } from "googleapis";
|
|
2
|
+
|
|
3
|
+
let driveClient = null
|
|
4
|
+
|
|
5
|
+
export const getDriveClient = (auth) => {
|
|
6
|
+
if (!driveClient) {
|
|
7
|
+
driveClient = google.drive({version: 'v3', auth});
|
|
8
|
+
}
|
|
9
|
+
return driveClient
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* we can't serialize a return object from drive api
|
|
14
|
+
* so we just select a few props from it
|
|
15
|
+
* @param {SyncDriveResponse} result
|
|
16
|
+
* @returns
|
|
17
|
+
*/
|
|
18
|
+
export const responseSyncify = (result) => ({
|
|
19
|
+
status: result.status,
|
|
20
|
+
statusText: result.statusText,
|
|
21
|
+
responseUrl: result.request?.responseURL
|
|
22
|
+
})
|
|
23
|
+
|