@iebh/tera-fy 1.0.6 → 1.0.8
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/lib/terafy.client.js +140 -77
- package/lib/terafy.server.js +16 -5
- package/package.json +1 -1
- package/plugins/vue.js +60 -9
package/lib/terafy.client.js
CHANGED
|
@@ -15,13 +15,17 @@ export default class TeraFy {
|
|
|
15
15
|
*
|
|
16
16
|
* @type {Object}
|
|
17
17
|
* @property {Boolean} devMode Operate in devMode - i.e. force outer refresh when encountering an existing TeraFy instance
|
|
18
|
+
* @property {'detect'|'parent'|'child'} How to communicate with TERA. 'parent' assumes that the parent of the current document is TERA, 'child' spawns an iFrame and uses TERA there, 'detect' tries parent and fallsback to 'child'
|
|
19
|
+
* @property {Number} modeTimeout How long entities have in 'detect' mode to identify themselves
|
|
18
20
|
* @property {String} siteUrl The TERA URL to connect to
|
|
19
21
|
* @property {String} restrictOrigin URL to restrict communications to
|
|
20
22
|
*/
|
|
21
23
|
settings = {
|
|
22
24
|
devMode: true,
|
|
25
|
+
mode: 'detect',
|
|
26
|
+
modeTimeout: 300,
|
|
23
27
|
siteUrl: 'https://tera-tools.com/embed',
|
|
24
|
-
restrictOrigin: '*', //
|
|
28
|
+
restrictOrigin: '*', // FIXME: Need to restrict this to TERA site
|
|
25
29
|
};
|
|
26
30
|
|
|
27
31
|
|
|
@@ -112,7 +116,16 @@ export default class TeraFy {
|
|
|
112
116
|
id: message.id || nanoid(),
|
|
113
117
|
...cloneDeep(message), // Need to clone to resolve promise nasties
|
|
114
118
|
};
|
|
115
|
-
|
|
119
|
+
|
|
120
|
+
if (this.settings.mode == 'parent') {
|
|
121
|
+
window.parent.postMessage(payload, this.settings.restrictOrigin);
|
|
122
|
+
} else if (this.settings.mode == 'child') {
|
|
123
|
+
this.dom.iframe.contentWindow.postMessage(payload, this.settings.restrictOrigin);
|
|
124
|
+
} else if (this.settings.mode == 'detect') {
|
|
125
|
+
throw new Error('Call init() or detectMode() before trying to send data to determine the mode');
|
|
126
|
+
} else {
|
|
127
|
+
throw new Error(`Unknown TERA communication mode "${this.settings.mode}"`);
|
|
128
|
+
}
|
|
116
129
|
} catch (e) {
|
|
117
130
|
this.debug('ERROR', 'Message compose client->server:', e);
|
|
118
131
|
this.debug('ERROR', 'Attempted to dispatch payload client->server', payload);
|
|
@@ -144,6 +157,8 @@ export default class TeraFy {
|
|
|
144
157
|
* @param {MessageEvent} Raw message event to process
|
|
145
158
|
*/
|
|
146
159
|
acceptMessage(rawMessage) {
|
|
160
|
+
if (rawMessage.origin == window.location.origin) return; // Message came from us
|
|
161
|
+
|
|
147
162
|
let message = rawMessage.data;
|
|
148
163
|
if (!message.TERA || !message.id) return; // Ignore non-TERA signed messages
|
|
149
164
|
this.debug('Recieved', message);
|
|
@@ -200,23 +215,56 @@ export default class TeraFy {
|
|
|
200
215
|
|
|
201
216
|
/**
|
|
202
217
|
* Initalize the TERA client singleton
|
|
218
|
+
* This function can only be called once and will return the existing init() worker Promise if its called againt
|
|
203
219
|
*
|
|
204
220
|
* @returns {Promise<TeraFy>} An eventual promise which will resovle with this terafy instance
|
|
205
221
|
*/
|
|
206
222
|
init() {
|
|
223
|
+
if (this.init.promise) return this.init.promise; // Aleady been called - return init promise
|
|
224
|
+
|
|
207
225
|
window.addEventListener('message', this.acceptMessage.bind(this));
|
|
208
226
|
|
|
209
|
-
return Promise.
|
|
210
|
-
|
|
211
|
-
this.
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
227
|
+
return this.init.promise = Promise.resolve()
|
|
228
|
+
.then(()=> this.detectMode())
|
|
229
|
+
.then(mode => this.settings.mode = mode)
|
|
230
|
+
.then(()=> Promise.all([
|
|
231
|
+
// Init core functions async
|
|
232
|
+
this.injectComms(),
|
|
233
|
+
this.injectStylesheet(),
|
|
234
|
+
this.injectMethods(),
|
|
235
|
+
|
|
236
|
+
// Init all plugins
|
|
237
|
+
...this.plugins
|
|
238
|
+
.map(plugin => plugin.init()),
|
|
239
|
+
]))
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Populate `settings.mode`
|
|
246
|
+
* Try to communicate with a parent frame, if none assume we need to fallback to child mode
|
|
247
|
+
*
|
|
248
|
+
* @returns {Promise<String>} A promise which will resolve with the detected mode to use
|
|
249
|
+
*/
|
|
250
|
+
detectMode() {
|
|
251
|
+
if (this.settings.mode != 'detect') { // Dev has specified a forced mode to use
|
|
252
|
+
return this.settings.mode;
|
|
253
|
+
} else if (window.self === window.parent) { // This frame is already at the top
|
|
254
|
+
return 'child';
|
|
255
|
+
} else { // No idea - try messaging
|
|
256
|
+
return Promise.resolve()
|
|
257
|
+
.then(()=> this.settings.mode = 'parent') // Switch to parent mode...
|
|
258
|
+
.then(()=> new Promise((resolve, reject) => { // And try messaging with a timeout
|
|
259
|
+
let timeoutHandle = setTimeout(()=> reject(), this.settings.modeTimeout);
|
|
260
|
+
|
|
261
|
+
this.rpc('handshake')
|
|
262
|
+
.then(()=> clearTimeout(timeoutHandle))
|
|
263
|
+
.then(()=> resolve())
|
|
264
|
+
}))
|
|
265
|
+
.then(()=> 'parent')
|
|
266
|
+
.catch(()=> 'child')
|
|
267
|
+
}
|
|
220
268
|
}
|
|
221
269
|
|
|
222
270
|
|
|
@@ -226,23 +274,32 @@ export default class TeraFy {
|
|
|
226
274
|
* @returns {Promise} A promise which will resolve when the loading has completed and we have found a parent TERA instance or initiallized a child
|
|
227
275
|
*/
|
|
228
276
|
injectComms() { return new Promise(resolve => {
|
|
229
|
-
this.
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
277
|
+
switch (this.settings.mode) {
|
|
278
|
+
case 'child':
|
|
279
|
+
this.dom.el = document.createElement('div')
|
|
280
|
+
this.dom.el.id = 'tera-fy';
|
|
281
|
+
this.dom.el.classList.toggle('dev-mode', this.settings.devMode);
|
|
282
|
+
document.body.append(this.dom.el);
|
|
283
|
+
|
|
284
|
+
this.dom.iframe = document.createElement('iframe')
|
|
285
|
+
|
|
286
|
+
// Queue up event chain when document loads
|
|
287
|
+
this.dom.iframe.setAttribute('sandbox', 'allow-downloads allow-scripts allow-same-origin');
|
|
288
|
+
this.dom.iframe.addEventListener('load', ()=> {
|
|
289
|
+
this.debug('Embed frame ready');
|
|
290
|
+
resolve();
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// Start document load sequence + append to DOM
|
|
294
|
+
this.dom.iframe.src = this.settings.siteUrl;
|
|
295
|
+
this.dom.el.append(this.dom.iframe);
|
|
296
|
+
break;
|
|
297
|
+
case 'parent':
|
|
298
|
+
resolve();
|
|
299
|
+
break;
|
|
300
|
+
default:
|
|
301
|
+
throw new Error(`Unsupported mode "${this.settings.mode}" when calling injectComms()`);
|
|
302
|
+
}
|
|
246
303
|
})}
|
|
247
304
|
|
|
248
305
|
|
|
@@ -250,50 +307,58 @@ export default class TeraFy {
|
|
|
250
307
|
* Inject a local stylesheet to handle TERA server functionality
|
|
251
308
|
*/
|
|
252
309
|
injectStylesheet() {
|
|
253
|
-
this.
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
310
|
+
switch (this.settings.mode) {
|
|
311
|
+
case 'child':
|
|
312
|
+
this.dom.stylesheet = document.createElement('style');
|
|
313
|
+
this.dom.stylesheet.innerHTML = [
|
|
314
|
+
':root {',
|
|
315
|
+
'--TERA-accent: #4d659c;',
|
|
316
|
+
'}',
|
|
317
|
+
|
|
318
|
+
'#tera-fy {',
|
|
319
|
+
'display: none;',
|
|
320
|
+
'position: fixed;',
|
|
321
|
+
'right: 50px;',
|
|
322
|
+
'bottom: 50px;',
|
|
323
|
+
'width: 300px;',
|
|
324
|
+
'height: 150px;',
|
|
325
|
+
'background: transparent;',
|
|
326
|
+
|
|
327
|
+
'&.dev-mode {',
|
|
328
|
+
'display: flex;',
|
|
329
|
+
'border: 5px solid var(--TERA-accent);',
|
|
330
|
+
'background: #FFF;',
|
|
331
|
+
'}',
|
|
332
|
+
|
|
333
|
+
'& > iframe {',
|
|
334
|
+
'width: 100%;',
|
|
335
|
+
'height: 100%;',
|
|
336
|
+
'}',
|
|
337
|
+
'}',
|
|
338
|
+
|
|
339
|
+
// Fullscreen functionality {{{
|
|
340
|
+
'body.tera-fy-focus {',
|
|
341
|
+
'overflow: hidden;',
|
|
342
|
+
|
|
343
|
+
'& #tera-fy {',
|
|
344
|
+
'display: flex !important;',
|
|
345
|
+
'position: fixed !important;',
|
|
346
|
+
'top: 0px !important;',
|
|
347
|
+
'width: 100vw !important;',
|
|
348
|
+
'height: 100vh !important;',
|
|
349
|
+
'left: 0px !important;',
|
|
350
|
+
'z-index: 10000 !important;',
|
|
351
|
+
'}',
|
|
352
|
+
'}',
|
|
353
|
+
// }}}
|
|
354
|
+
].join('\n');
|
|
355
|
+
document.head.appendChild(this.dom.stylesheet);
|
|
356
|
+
break;
|
|
357
|
+
case 'parent':
|
|
358
|
+
break;
|
|
359
|
+
default:
|
|
360
|
+
throw new Error(`Unsupported mode "${this.settings.mode}" when injectStylesheet()`);
|
|
361
|
+
}
|
|
297
362
|
}
|
|
298
363
|
|
|
299
364
|
|
|
@@ -304,7 +369,6 @@ export default class TeraFy {
|
|
|
304
369
|
this.methods.forEach(method =>
|
|
305
370
|
this[method] = this.rpc.bind(this, method)
|
|
306
371
|
);
|
|
307
|
-
this.debug('Remote methods installed:', this.methods);
|
|
308
372
|
}
|
|
309
373
|
// }}}
|
|
310
374
|
|
|
@@ -314,7 +378,7 @@ export default class TeraFy {
|
|
|
314
378
|
* Debugging output function
|
|
315
379
|
* This function will only act if `settings.devMode` is truthy
|
|
316
380
|
*
|
|
317
|
-
* @param {'INFO'|'LOG'|'WARN'|'ERROR'} [status] Optional prefixing level to mark the message as. 'WARN' and 'ERROR' will always show reguardless of devMode being enabled
|
|
381
|
+
* @param {'VERBOSE'|'INFO'|'LOG'|'WARN'|'ERROR'} [status] Optional prefixing level to mark the message as. 'WARN' and 'ERROR' will always show reguardless of devMode being enabled
|
|
318
382
|
* @param {String} [msg...] Output to show
|
|
319
383
|
*/
|
|
320
384
|
debug(...msg) {
|
|
@@ -387,7 +451,6 @@ export default class TeraFy {
|
|
|
387
451
|
Object.getOwnPropertyNames(Object.getPrototypeOf(source))
|
|
388
452
|
.filter(prop => !['constructor', 'prototype', 'name'].includes(prop))
|
|
389
453
|
.forEach((prop) => {
|
|
390
|
-
console.log('Merge method', prop);
|
|
391
454
|
Object.defineProperty(
|
|
392
455
|
target,
|
|
393
456
|
prop,
|
package/lib/terafy.server.js
CHANGED
|
@@ -126,15 +126,17 @@ export default class TeraFyServer {
|
|
|
126
126
|
* Send raw message content to the client
|
|
127
127
|
*
|
|
128
128
|
* @param {Object} message Message object to send
|
|
129
|
+
* @param {Window} Window context to dispatch the message via if its not the same as the regular window
|
|
129
130
|
*/
|
|
130
|
-
sendRaw(message) {
|
|
131
|
+
sendRaw(message, sendVia) {
|
|
131
132
|
let payload;
|
|
132
133
|
try {
|
|
133
134
|
payload = {
|
|
134
135
|
TERA: 1,
|
|
135
136
|
...cloneDeep(message), // Need to clone to resolve promise nasties
|
|
136
137
|
};
|
|
137
|
-
|
|
138
|
+
this.debug('INFO', 'Parent reply', message, '<=>', payload);
|
|
139
|
+
(sendVia || globalThis.parent).postMessage(payload, this.settings.restrictOrigin);
|
|
138
140
|
} catch (e) {
|
|
139
141
|
this.debug('ERROR', 'Attempted to dispatch payload server->client', payload);
|
|
140
142
|
this.debug('ERROR', 'Message compose server->client:', e);
|
|
@@ -149,6 +151,8 @@ export default class TeraFyServer {
|
|
|
149
151
|
* @param {MessageEvent} Raw message event to process
|
|
150
152
|
*/
|
|
151
153
|
acceptMessage(rawMessage) {
|
|
154
|
+
if (rawMessage.origin == window.location.origin) return; // Message came from us
|
|
155
|
+
|
|
152
156
|
let message = rawMessage.data;
|
|
153
157
|
if (!message.TERA) return; // Ignore non-TERA signed messages
|
|
154
158
|
this.debug('Recieved', message);
|
|
@@ -169,11 +173,11 @@ export default class TeraFyServer {
|
|
|
169
173
|
throw new Error('Unknown message format');
|
|
170
174
|
}
|
|
171
175
|
})
|
|
172
|
-
.then(
|
|
176
|
+
.then(response => this.sendRaw({
|
|
173
177
|
id: message.id,
|
|
174
178
|
action: 'response',
|
|
175
|
-
response
|
|
176
|
-
}))
|
|
179
|
+
response,
|
|
180
|
+
}, rawMessage.source))
|
|
177
181
|
.catch(e => {
|
|
178
182
|
console.warn(`TERA-FY server threw on RPC:${message.method}:`, e);
|
|
179
183
|
this.sendRaw({
|
|
@@ -369,6 +373,7 @@ export default class TeraFyServer {
|
|
|
369
373
|
* @param {Object} [options] Additional options to mutate behaviour
|
|
370
374
|
* @param {String} [options.title="Select a project to work with"] The title of the dialog to display
|
|
371
375
|
* @param {Boolean} [options.allowCancel=true] Advertise cancelling the operation, the dialog can still be cancelled by closing it
|
|
376
|
+
* @param {Boolean} [options.setActive=false] Also set the project as active when selected
|
|
372
377
|
*
|
|
373
378
|
* @returns {Promise<Project>} The active project
|
|
374
379
|
*/
|
|
@@ -376,6 +381,7 @@ export default class TeraFyServer {
|
|
|
376
381
|
let settings = {
|
|
377
382
|
title: 'Select a project to work with',
|
|
378
383
|
allowCancel: true,
|
|
384
|
+
setActive: false,
|
|
379
385
|
...options,
|
|
380
386
|
};
|
|
381
387
|
|
|
@@ -387,6 +393,11 @@ export default class TeraFyServer {
|
|
|
387
393
|
buttons: settings.allowCancel && ['cancel'],
|
|
388
394
|
})
|
|
389
395
|
))
|
|
396
|
+
.then(project => settings.setActive
|
|
397
|
+
? this.setActiveProject(project)
|
|
398
|
+
.then(()=> project)
|
|
399
|
+
: project
|
|
400
|
+
)
|
|
390
401
|
}
|
|
391
402
|
|
|
392
403
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@iebh/tera-fy",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.8",
|
|
4
4
|
"description": "TERA website worker",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"dev": "esbuild --platform=browser --format=esm --bundle lib/terafy.client.js --outfile=dist/terafy.js --minify --sourcemap --serve --servedir=.",
|
package/plugins/vue.js
CHANGED
|
@@ -20,7 +20,7 @@ export default class TeraFyPluginVue extends TeraFyPluginBase {
|
|
|
20
20
|
* @param {Boolean} [options.write=true] Allow local reactivity to writes - send these to the server
|
|
21
21
|
* @param {Array<String>} Paths to subscribe to e.g. ['/users/'],
|
|
22
22
|
*
|
|
23
|
-
* @returns {
|
|
23
|
+
* @returns {Promie<Reactive<Object>>} A reactive object representing the project state
|
|
24
24
|
*/
|
|
25
25
|
bindProjectState(options) {
|
|
26
26
|
let settings = {
|
|
@@ -36,6 +36,8 @@ export default class TeraFyPluginVue extends TeraFyPluginBase {
|
|
|
36
36
|
paths: settings.paths,
|
|
37
37
|
}))
|
|
38
38
|
.then(snapshot => {
|
|
39
|
+
this.debug('Got project snapshot', snapshot);
|
|
40
|
+
|
|
39
41
|
// Create initial reactive
|
|
40
42
|
let stateReactive = reactive(snapshot);
|
|
41
43
|
|
|
@@ -63,6 +65,40 @@ export default class TeraFyPluginVue extends TeraFyPluginBase {
|
|
|
63
65
|
}
|
|
64
66
|
|
|
65
67
|
|
|
68
|
+
/**
|
|
69
|
+
* List of available projects for the current session
|
|
70
|
+
* @type {VueReactive<Array<Object>>}
|
|
71
|
+
*/
|
|
72
|
+
projects = reactive([]);
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The bound, reactive state of a Vue project
|
|
77
|
+
* When loaded this represents the state of a project as an object
|
|
78
|
+
* @type {Object}
|
|
79
|
+
*/
|
|
80
|
+
state = null;
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Promise used when binding to state
|
|
85
|
+
* @type {Promise}
|
|
86
|
+
*/
|
|
87
|
+
statePromisable = null;
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Utility function which returns an awaitable promise when the state is loading or being refreshed
|
|
92
|
+
* This is used in place of `statePromisable` as it has a slightly more logical syntax as a function
|
|
93
|
+
*
|
|
94
|
+
* @example Await the state loading
|
|
95
|
+
* await $tera.statePromise();
|
|
96
|
+
*/
|
|
97
|
+
statePromise() {
|
|
98
|
+
return this.statePromisable;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
66
102
|
/**
|
|
67
103
|
* Provide a Vue@3 compatible plugin
|
|
68
104
|
*/
|
|
@@ -75,6 +111,7 @@ export default class TeraFyPluginVue extends TeraFyPluginBase {
|
|
|
75
111
|
* Install into Vue as a generic Vue@3 plugin
|
|
76
112
|
*
|
|
77
113
|
* @param {Object} [options] Additional options to mutate behaviour
|
|
114
|
+
* @param {Boolean} [options.autoInit=true] Call Init() during the `statePromiseable` cycle if its not already been called
|
|
78
115
|
* @param {String} [options.globalName='$tera'] Globa property to allocate this service as
|
|
79
116
|
* @param {Objecct} [options.bindOptions] Options passed to `bindProjectState()`
|
|
80
117
|
*
|
|
@@ -82,23 +119,37 @@ export default class TeraFyPluginVue extends TeraFyPluginBase {
|
|
|
82
119
|
*/
|
|
83
120
|
install(app, options) {
|
|
84
121
|
let settings = {
|
|
122
|
+
autoInit: true,
|
|
85
123
|
globalName: '$tera',
|
|
86
124
|
stateOptions: {
|
|
87
|
-
autoRequire: true,
|
|
88
125
|
write: true,
|
|
89
126
|
},
|
|
90
127
|
...options,
|
|
91
128
|
};
|
|
92
129
|
|
|
130
|
+
// Bind $tera.state to the active project
|
|
131
|
+
// Initialize state to null
|
|
132
|
+
$tera.state = null;
|
|
133
|
+
|
|
134
|
+
// $tera.statePromisable becomes the promise we are waiting on to resolve
|
|
135
|
+
$tera.statePromisable = Promise.resolve()
|
|
136
|
+
.then(()=> settings.autoInit && $tera.init())
|
|
137
|
+
.then(()=> Promise.all([
|
|
138
|
+
// Bind available project and wait on it
|
|
139
|
+
$tera.bindProjectState(settings.stateOptions)
|
|
140
|
+
.then(state => $tera.state = state)
|
|
141
|
+
.then(()=> $tera.debug('INFO', 'Loaded project state', $tera.state)),
|
|
142
|
+
|
|
143
|
+
// Fetch available projects
|
|
144
|
+
// TODO: It would be nice if this was responsive to remote changes
|
|
145
|
+
$tera.getProjects()
|
|
146
|
+
.then(projects => $tera.projects = reactive(projects))
|
|
147
|
+
.then(()=> $tera.debug('INFO', 'Loaded projects', $tera.projects)),
|
|
148
|
+
]))
|
|
149
|
+
|
|
150
|
+
|
|
93
151
|
// Make this module available globally
|
|
94
152
|
app.config.globalProperties[settings.globalName] = $tera;
|
|
95
|
-
|
|
96
|
-
// Bind $tera.state to the active project
|
|
97
|
-
// TODO: context.bindProjectState(settings.stateOptions),
|
|
98
|
-
$tera.state = {
|
|
99
|
-
id: 'TERAPROJ',
|
|
100
|
-
name: 'A fake project',
|
|
101
|
-
};
|
|
102
153
|
},
|
|
103
154
|
|
|
104
155
|
};
|