@iebh/tera-fy 1.0.7 → 1.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.
@@ -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: '*', // DEBUG: Need to restrict this to TERA site
28
+ restrictOrigin: '*', // FIXME: Need to restrict this to TERA site
25
29
  };
26
30
 
27
31
 
@@ -60,6 +64,9 @@ export default class TeraFy {
60
64
  'getProjectState', 'applyProjectStatePatch',
61
65
  // bindProjectState() - See below
62
66
 
67
+ // Project files
68
+ 'getProjectFiles',
69
+
63
70
  // Project Libraries
64
71
  'getProjectLibrary', 'setProjectLibrary',
65
72
  ];
@@ -112,7 +119,16 @@ export default class TeraFy {
112
119
  id: message.id || nanoid(),
113
120
  ...cloneDeep(message), // Need to clone to resolve promise nasties
114
121
  };
115
- this.dom.iframe.contentWindow.postMessage(payload, this.settings.restrictOrigin);
122
+
123
+ if (this.settings.mode == 'parent') {
124
+ window.parent.postMessage(payload, this.settings.restrictOrigin);
125
+ } else if (this.settings.mode == 'child') {
126
+ this.dom.iframe.contentWindow.postMessage(payload, this.settings.restrictOrigin);
127
+ } else if (this.settings.mode == 'detect') {
128
+ throw new Error('Call init() or detectMode() before trying to send data to determine the mode');
129
+ } else {
130
+ throw new Error(`Unknown TERA communication mode "${this.settings.mode}"`);
131
+ }
116
132
  } catch (e) {
117
133
  this.debug('ERROR', 'Message compose client->server:', e);
118
134
  this.debug('ERROR', 'Attempted to dispatch payload client->server', payload);
@@ -144,6 +160,8 @@ export default class TeraFy {
144
160
  * @param {MessageEvent} Raw message event to process
145
161
  */
146
162
  acceptMessage(rawMessage) {
163
+ if (rawMessage.origin == window.location.origin) return; // Message came from us
164
+
147
165
  let message = rawMessage.data;
148
166
  if (!message.TERA || !message.id) return; // Ignore non-TERA signed messages
149
167
  this.debug('Recieved', message);
@@ -200,23 +218,56 @@ export default class TeraFy {
200
218
 
201
219
  /**
202
220
  * Initalize the TERA client singleton
221
+ * This function can only be called once and will return the existing init() worker Promise if its called againt
203
222
  *
204
223
  * @returns {Promise<TeraFy>} An eventual promise which will resovle with this terafy instance
205
224
  */
206
225
  init() {
226
+ if (this.init.promise) return this.init.promise; // Aleady been called - return init promise
227
+
207
228
  window.addEventListener('message', this.acceptMessage.bind(this));
208
229
 
209
- return Promise.all([
210
- // Init core functions async
211
- this.injectComms(),
212
- this.injectStylesheet(),
213
- this.injectMethods(),
214
-
215
- // Init all plugins
216
- ...this.plugins
217
- .map(plugin => plugin.init()),
218
- ])
219
- .then(()=> this)
230
+ return this.init.promise = Promise.resolve()
231
+ .then(()=> this.detectMode())
232
+ .then(mode => this.settings.mode = mode)
233
+ .then(()=> Promise.all([
234
+ // Init core functions async
235
+ this.injectComms(),
236
+ this.injectStylesheet(),
237
+ this.injectMethods(),
238
+
239
+ // Init all plugins
240
+ ...this.plugins
241
+ .map(plugin => plugin.init()),
242
+ ]))
243
+ }
244
+
245
+
246
+
247
+ /**
248
+ * Populate `settings.mode`
249
+ * Try to communicate with a parent frame, if none assume we need to fallback to child mode
250
+ *
251
+ * @returns {Promise<String>} A promise which will resolve with the detected mode to use
252
+ */
253
+ detectMode() {
254
+ if (this.settings.mode != 'detect') { // Dev has specified a forced mode to use
255
+ return this.settings.mode;
256
+ } else if (window.self === window.parent) { // This frame is already at the top
257
+ return 'child';
258
+ } else { // No idea - try messaging
259
+ return Promise.resolve()
260
+ .then(()=> this.settings.mode = 'parent') // Switch to parent mode...
261
+ .then(()=> new Promise((resolve, reject) => { // And try messaging with a timeout
262
+ let timeoutHandle = setTimeout(()=> reject(), this.settings.modeTimeout);
263
+
264
+ this.rpc('handshake')
265
+ .then(()=> clearTimeout(timeoutHandle))
266
+ .then(()=> resolve())
267
+ }))
268
+ .then(()=> 'parent')
269
+ .catch(()=> 'child')
270
+ }
220
271
  }
221
272
 
222
273
 
@@ -226,23 +277,32 @@ export default class TeraFy {
226
277
  * @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
278
  */
228
279
  injectComms() { return new Promise(resolve => {
229
- this.dom.el = document.createElement('div')
230
- this.dom.el.id = 'tera-fy';
231
- this.dom.el.classList.toggle('dev-mode', this.settings.devMode);
232
- document.body.append(this.dom.el);
233
-
234
- this.dom.iframe = document.createElement('iframe')
235
-
236
- // Queue up event chain when document loads
237
- this.dom.iframe.setAttribute('sandbox', 'allow-downloads allow-scripts allow-same-origin');
238
- this.dom.iframe.addEventListener('load', ()=> {
239
- this.debug('Embed frame ready');
240
- resolve();
241
- });
242
-
243
- // Start document load sequence + append to DOM
244
- this.dom.iframe.src = this.settings.siteUrl;
245
- this.dom.el.append(this.dom.iframe);
280
+ switch (this.settings.mode) {
281
+ case 'child':
282
+ this.dom.el = document.createElement('div')
283
+ this.dom.el.id = 'tera-fy';
284
+ this.dom.el.classList.toggle('dev-mode', this.settings.devMode);
285
+ document.body.append(this.dom.el);
286
+
287
+ this.dom.iframe = document.createElement('iframe')
288
+
289
+ // Queue up event chain when document loads
290
+ this.dom.iframe.setAttribute('sandbox', 'allow-downloads allow-scripts allow-same-origin');
291
+ this.dom.iframe.addEventListener('load', ()=> {
292
+ this.debug('Embed frame ready');
293
+ resolve();
294
+ });
295
+
296
+ // Start document load sequence + append to DOM
297
+ this.dom.iframe.src = this.settings.siteUrl;
298
+ this.dom.el.append(this.dom.iframe);
299
+ break;
300
+ case 'parent':
301
+ resolve();
302
+ break;
303
+ default:
304
+ throw new Error(`Unsupported mode "${this.settings.mode}" when calling injectComms()`);
305
+ }
246
306
  })}
247
307
 
248
308
 
@@ -250,50 +310,58 @@ export default class TeraFy {
250
310
  * Inject a local stylesheet to handle TERA server functionality
251
311
  */
252
312
  injectStylesheet() {
253
- this.dom.stylesheet = document.createElement('style');
254
- this.dom.stylesheet.innerHTML = [
255
- ':root {',
256
- '--TERA-accent: #4d659c;',
257
- '}',
258
-
259
- '#tera-fy {',
260
- 'display: none;',
261
- 'position: fixed;',
262
- 'right: 50px;',
263
- 'bottom: 50px;',
264
- 'width: 300px;',
265
- 'height: 150px;',
266
- 'background: transparent;',
267
-
268
- '&.dev-mode {',
269
- 'display: flex;',
270
- 'border: 5px solid var(--TERA-accent);',
271
- 'background: #FFF;',
272
- '}',
273
-
274
- '& > iframe {',
275
- 'width: 100%;',
276
- 'height: 100%;',
277
- '}',
278
- '}',
279
-
280
- // Fullscreen functionality {{{
281
- 'body.tera-fy-focus {',
282
- 'overflow: hidden;',
283
-
284
- '& #tera-fy {',
285
- 'display: flex !important;',
286
- 'position: fixed !important;',
287
- 'top: 0px !important;',
288
- 'width: 100vw !important;',
289
- 'height: 100vh !important;',
290
- 'left: 0px !important;',
291
- 'z-index: 10000 !important;',
292
- '}',
293
- '}',
294
- // }}}
295
- ].join('\n');
296
- document.head.appendChild(this.dom.stylesheet);
313
+ switch (this.settings.mode) {
314
+ case 'child':
315
+ this.dom.stylesheet = document.createElement('style');
316
+ this.dom.stylesheet.innerHTML = [
317
+ ':root {',
318
+ '--TERA-accent: #4d659c;',
319
+ '}',
320
+
321
+ '#tera-fy {',
322
+ 'display: none;',
323
+ 'position: fixed;',
324
+ 'right: 50px;',
325
+ 'bottom: 50px;',
326
+ 'width: 300px;',
327
+ 'height: 150px;',
328
+ 'background: transparent;',
329
+
330
+ '&.dev-mode {',
331
+ 'display: flex;',
332
+ 'border: 5px solid var(--TERA-accent);',
333
+ 'background: #FFF;',
334
+ '}',
335
+
336
+ '& > iframe {',
337
+ 'width: 100%;',
338
+ 'height: 100%;',
339
+ '}',
340
+ '}',
341
+
342
+ // Fullscreen functionality {{{
343
+ 'body.tera-fy-focus {',
344
+ 'overflow: hidden;',
345
+
346
+ '& #tera-fy {',
347
+ 'display: flex !important;',
348
+ 'position: fixed !important;',
349
+ 'top: 0px !important;',
350
+ 'width: 100vw !important;',
351
+ 'height: 100vh !important;',
352
+ 'left: 0px !important;',
353
+ 'z-index: 10000 !important;',
354
+ '}',
355
+ '}',
356
+ // }}}
357
+ ].join('\n');
358
+ document.head.appendChild(this.dom.stylesheet);
359
+ break;
360
+ case 'parent':
361
+ break;
362
+ default:
363
+ throw new Error(`Unsupported mode "${this.settings.mode}" when injectStylesheet()`);
364
+ }
297
365
  }
298
366
 
299
367
 
@@ -386,7 +454,6 @@ export default class TeraFy {
386
454
  Object.getOwnPropertyNames(Object.getPrototypeOf(source))
387
455
  .filter(prop => !['constructor', 'prototype', 'name'].includes(prop))
388
456
  .forEach((prop) => {
389
- console.log('Merge method', prop);
390
457
  Object.defineProperty(
391
458
  target,
392
459
  prop,
@@ -126,8 +126,9 @@ 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 = {
@@ -135,7 +136,7 @@ export default class TeraFyServer {
135
136
  ...cloneDeep(message), // Need to clone to resolve promise nasties
136
137
  };
137
138
  this.debug('INFO', 'Parent reply', message, '<=>', payload);
138
- globalThis.parent.postMessage(payload, this.settings.restrictOrigin);
139
+ (sendVia || globalThis.parent).postMessage(payload, this.settings.restrictOrigin);
139
140
  } catch (e) {
140
141
  this.debug('ERROR', 'Attempted to dispatch payload server->client', payload);
141
142
  this.debug('ERROR', 'Message compose server->client:', e);
@@ -150,6 +151,8 @@ export default class TeraFyServer {
150
151
  * @param {MessageEvent} Raw message event to process
151
152
  */
152
153
  acceptMessage(rawMessage) {
154
+ if (rawMessage.origin == window.location.origin) return; // Message came from us
155
+
153
156
  let message = rawMessage.data;
154
157
  if (!message.TERA) return; // Ignore non-TERA signed messages
155
158
  this.debug('Recieved', message);
@@ -174,7 +177,7 @@ export default class TeraFyServer {
174
177
  id: message.id,
175
178
  action: 'response',
176
179
  response,
177
- }))
180
+ }, rawMessage.source))
178
181
  .catch(e => {
179
182
  console.warn(`TERA-FY server threw on RPC:${message.method}:`, e);
180
183
  this.sendRaw({
@@ -370,6 +373,7 @@ export default class TeraFyServer {
370
373
  * @param {Object} [options] Additional options to mutate behaviour
371
374
  * @param {String} [options.title="Select a project to work with"] The title of the dialog to display
372
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
373
377
  *
374
378
  * @returns {Promise<Project>} The active project
375
379
  */
@@ -377,6 +381,7 @@ export default class TeraFyServer {
377
381
  let settings = {
378
382
  title: 'Select a project to work with',
379
383
  allowCancel: true,
384
+ setActive: false,
380
385
  ...options,
381
386
  };
382
387
 
@@ -388,6 +393,11 @@ export default class TeraFyServer {
388
393
  buttons: settings.allowCancel && ['cancel'],
389
394
  })
390
395
  ))
396
+ .then(project => settings.setActive
397
+ ? this.setActiveProject(project)
398
+ .then(()=> project)
399
+ : project
400
+ )
391
401
  }
392
402
 
393
403
 
@@ -424,6 +434,63 @@ export default class TeraFyServer {
424
434
  this.debug('Applying sever state patch', {patch});
425
435
  }
426
436
 
437
+
438
+
439
+ /**
440
+ * Subscribers to server project state changes
441
+ * @type {Array<Object>}}
442
+ * @property {String} id A unique ID for this subscriber state
443
+ * @property {String} source The human readable source for each subscriber
444
+ * @property {Function} sendPatch Function used to send a patch to a subscriber
445
+ * @property {Function} unsubscribe Function to release the client subscription
446
+ */
447
+ projectStateSubscribers = [];
448
+ // }}}
449
+
450
+ // Project files - getProjectFiles() {{{
451
+
452
+ /**
453
+ * Data structure for a project file
454
+ * @class ProjectFile
455
+ *
456
+ * @property {String} id A UUID string representing the unique ID of the file
457
+ * @property {String} name Relative name path (can contain prefix directories) for the human readable file name
458
+ * @property {Object} parsedName An object representing meta file parts of a file name
459
+ * @property {String} parsedName.basename The filename + extention (i.e. everything without directory name)
460
+ * @property {String} parsedName.filename The file portion of the name (basename without the extension)
461
+ * @property {String} parsedName.ext The extension portion of the name (always lower case)
462
+ * @property {String} parsedName.dirName The directory path portion of the name
463
+ * @property {Date} created A date representing when the file was created
464
+ * @property {Date} modified A date representing when the file was created
465
+ * @property {Date} accessed A date representing when the file was last accessed
466
+ * @property {Number} size Size, in bytes, of the file
467
+ * @property {String} mime The associated mime type for the file
468
+ */
469
+
470
+
471
+ /**
472
+ * Fetch the files associated with a given project
473
+ *
474
+ * @param {Object} options Options which mutate behaviour
475
+ * @param {Boolean} [options.autoRequire=true] Run `requireProject()` automatically before continuing
476
+ * @param {Boolean} [options.meta=true] Pull meta information for each file entity
477
+ *
478
+ * @returns {Promise<ProjectFile>} A collection of project files for the given project
479
+ */
480
+ getProjectFiles(options) {
481
+ let settings = {
482
+ autoRequire: true,
483
+ meta: true,
484
+ ...options,
485
+ };
486
+
487
+ return Promise.resolve()
488
+ .then(()=> app.service('$projects').promise())
489
+ .then(()=> settings.autoRequire && this.requireProject())
490
+ .then(project => app.service('$supabase').fileList(`/projects/${project.id}`, {
491
+ meta: settings.meta,
492
+ }))
493
+ }
427
494
  // }}}
428
495
 
429
496
  // Project Libraries - getProjectLibrary(), setProjectLibrary() {{{
@@ -431,24 +498,47 @@ export default class TeraFyServer {
431
498
  /**
432
499
  * Fetch the active projects citation library
433
500
  *
501
+ * @param {String} [path] Optional file path to use, if omitted the contents of `options` are used to guess at a suitable file
434
502
  * @param {Object} [options] Additional options to mutate behaviour
435
503
  * @param {Boolean} [options.autoRequire=true] Run `requireProject()` automatically before continuing
436
504
  * @param {Boolean} [options.multiple=false] Allow selection of multiple libraries
505
+ * @param {Function} [options.filter] Optional async file filter, called each time as `(File:ProjectFile)`
506
+ * @param {Function} [options.find] Optional async final stage file filter to reduce all candidates down to one subject file
437
507
  * @param {String|Array<String>} [options.hint] Hints to identify the library to select in array order of preference. Generally corresponds to the previous stage - e.g. 'deduped', 'review1', 'review2', 'dedisputed'
438
508
  *
439
- * @returns {Promise<Array<RefLibRef>>} Collection of references for the selected library
509
+ * @returns {Promise<Array<ProjectFile>>} Collection of references for the selected library matching the given hint + filter, this could be a zero length array
440
510
  */
441
- getProjectLibrary(options) {
511
+ getProjectLibrary(path, options) {
442
512
  let settings = {
513
+ path,
443
514
  autoRequire: true,
444
515
  multiple: false,
445
516
  hint: null,
446
- ...options,
517
+ filter: file => true,
518
+ find: files => files.at(0),
519
+ ...(typeof path == 'string' ? {path, ...options} : path),
447
520
  };
448
521
 
449
522
  return Promise.resolve()
450
- .then(()=> settings.autoRequire && this.requireProject())
451
- // FIXME: Stub
523
+ .then(()=> {
524
+ if (settings.path) { // Already have a file name picked
525
+ if (!settings.path.startsWith('/')) throw new Error('All file names must start with a forward slash');
526
+ return settings.path;
527
+ } else { // Try to guess the file from the options structure
528
+ return this.getProjectFiles({
529
+ autoRequire: settings.autoRequire,
530
+ })
531
+ .then(files => files.filter(file =>
532
+ settings.filter(file)
533
+ ))
534
+ .then(files => settings.find(files))
535
+ .then(file => file.path)
536
+ }
537
+ })
538
+ .then(filePath => app.service('$supabase').fileGet(filePath, {
539
+ json: true,
540
+ toast: false,
541
+ }))
452
542
  }
453
543
 
454
544
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iebh/tera-fy",
3
- "version": "1.0.7",
3
+ "version": "1.0.9",
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
@@ -111,6 +111,7 @@ export default class TeraFyPluginVue extends TeraFyPluginBase {
111
111
  * Install into Vue as a generic Vue@3 plugin
112
112
  *
113
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
114
115
  * @param {String} [options.globalName='$tera'] Globa property to allocate this service as
115
116
  * @param {Objecct} [options.bindOptions] Options passed to `bindProjectState()`
116
117
  *
@@ -118,9 +119,9 @@ export default class TeraFyPluginVue extends TeraFyPluginBase {
118
119
  */
119
120
  install(app, options) {
120
121
  let settings = {
122
+ autoInit: true,
121
123
  globalName: '$tera',
122
124
  stateOptions: {
123
- autoRequire: true,
124
125
  write: true,
125
126
  },
126
127
  ...options,
@@ -131,19 +132,20 @@ export default class TeraFyPluginVue extends TeraFyPluginBase {
131
132
  $tera.state = null;
132
133
 
133
134
  // $tera.statePromisable becomes the promise we are waiting on to resolve
134
- $tera.statePromisable = Promise.all([
135
-
136
- // Bind available project and wait on it
137
- $tera.bindProjectState(settings.stateOptions)
138
- .then(state => $tera.state = state)
139
- .then(()=> $tera.debug('INFO', 'Loaded project state', $tera.state)),
140
-
141
- // Fetch available projects
142
- // TODO: It would be nice if this was responsive to remote changes
143
- $tera.getProjects()
144
- .then(projects => $tera.projects = projects)
145
- .then(()=> $tera.debug('INFO', 'Loaded projects', $tera.list)),
146
- ])
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
+ ]))
147
149
 
148
150
 
149
151
  // Make this module available globally