@iebh/tera-fy 1.0.16 → 1.0.18

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.
@@ -1,7 +1,8 @@
1
- import {cloneDeep, has as pathExists, set as pathSet} from 'lodash-es';
1
+ import {cloneDeep, has as pathExists, get as pathGet, set as pathSet} from 'lodash-es';
2
2
  import {diffApply, jsonPatchPathConverter as jsPatchConverter} from 'just-diff-apply';
3
- import {nanoid} from 'nanoid';
4
3
  import mixin from '#utils/mixin';
4
+ import {nanoid} from 'nanoid';
5
+ import promiseDefer from '#utils/pDefer';
5
6
  import Reflib from '@iebh/reflib';
6
7
 
7
8
  /**
@@ -21,7 +22,9 @@ export default class TeraFyServer {
21
22
  * @property {Number} subscribeTimeout Acceptable timeout period for subscribers to acklowledge a project change event, failing to respond will result in the subscriber being removed from the available subscriber list
22
23
  * @property {String} restrictOrigin URL to restrict communications to
23
24
  * @property {String} projectId The project to use as the default reference when calling various APIs
24
- * @property {Number} The current server mode matching `SERVERMODE_*`
25
+ * @property {Number} serverMode The current server mode matching `SERVERMODE_*`
26
+ * @property {String} siteUrl The main site absolute URL
27
+ * @property {String} sitePathLogin Either an absolute URL or the relative path (taken from `siteUrl`) when trying to log in the user
25
28
  */
26
29
  settings = {
27
30
  devMode: false,
@@ -29,12 +32,15 @@ export default class TeraFyServer {
29
32
  subscribeTimeout: 2000,
30
33
  projectId: null,
31
34
  serverMode: 0,
35
+ siteUrl: window.location.href,
36
+ sitePathLogin: '/login',
32
37
  };
33
38
 
34
39
  static SERVERMODE_NONE = 0;
35
40
  static SERVERMODE_EMBEDDED = 1;
36
- static SERVERMODE_WINDOW = 2;
37
- static SERVERMODE_TERA = 3; // Terafy is running as the main TERA site
41
+ static SERVERMODE_FRAME = 2;
42
+ static SERVERMODE_POPUP = 3;
43
+ static SERVERMODE_TERA = 4; // Terafy is running as the main TERA site
38
44
 
39
45
 
40
46
  // Contexts - createContext(), getClientContext(), messageEvent, senderRpc() {{{
@@ -94,7 +100,7 @@ export default class TeraFyServer {
94
100
  }
95
101
  },
96
102
  });
97
- case TeraFyServer.SERVERMODE_WINDOW:
103
+ case TeraFyServer.SERVERMODE_FRAME:
98
104
  // Server is the top-level window so we need to send messages to an embedded iFrame
99
105
  var iFrame = document.querySelector('iframe#external');
100
106
  if (!iFrame) throw new Error('Cannot locate TERA-FY top-level->iFrame#external');
@@ -114,6 +120,7 @@ export default class TeraFyServer {
114
120
  }
115
121
  },
116
122
  });
123
+ case TeraFyServer.SERVERMODE_POPUP:
117
124
  }
118
125
  }
119
126
 
@@ -202,10 +209,10 @@ export default class TeraFyServer {
202
209
  TERA: 1,
203
210
  ...cloneDeep(message), // Need to clone to resolve promise nasties
204
211
  };
205
- this.debug('INFO', 'Parent send', message, '<=>', payload);
212
+ this.debug('INFO', 'Dispatch response', message, '<=>', payload);
206
213
  (sendVia || globalThis.parent).postMessage(payload, this.settings.restrictOrigin);
207
214
  } catch (e) {
208
- this.debug('ERROR', 'Attempted to dispatch payload server->client', payload);
215
+ this.debug('ERROR', 'Attempted to dispatch response server->client', payload);
209
216
  this.debug('ERROR', 'Message compose server->client:', e);
210
217
  }
211
218
  }
@@ -221,8 +228,11 @@ export default class TeraFyServer {
221
228
  case 'embedded':
222
229
  this.settings.serverMode = TeraFyServer.SERVERMODE_EMBEDDED;
223
230
  break;
224
- case 'window':
225
- this.settings.serverMode = TeraFyServer.SERVERMODE_WINDOW;
231
+ case 'frame':
232
+ this.settings.serverMode = TeraFyServer.SERVERMODE_FRAME;
233
+ break;
234
+ case 'popup':
235
+ this.settings.serverMode = TeraFyServer.SERVERMODE_POPUP;
226
236
  break;
227
237
  default:
228
238
  throw new Error(`Unsupported server mode "${mode}"`);
@@ -330,9 +340,17 @@ export default class TeraFyServer {
330
340
  /**
331
341
  * Fetch the current session user
332
342
  *
343
+ * @param {Object} [options] Additional options to mutate behaviour
344
+ * @param {Boolean} [options.forceRetry=false] Forcabily try to refresh the user state
345
+ *
333
346
  * @returns {Promise<User>} The current logged in user or null if none
334
347
  */
335
- getUser() {
348
+ getUser(options) {
349
+ let settings = {
350
+ forceRetry: false,
351
+ ...options,
352
+ };
353
+
336
354
  let $auth = app.service('$auth');
337
355
  let $subscriptions = app.service('$subscriptions');
338
356
 
@@ -340,7 +358,11 @@ export default class TeraFyServer {
340
358
  $auth.promise(),
341
359
  $subscriptions.promise(),
342
360
  ])
343
- .then(()=> $auth.user.id ? {
361
+ .then(()=> {
362
+ if (!$auth.isLoggedIn && settings.forceRetry)
363
+ return $auth.restoreLogin();
364
+ })
365
+ .then(()=> $auth.user?.id ? {
344
366
  id: $auth.user.id,
345
367
  email: $auth.user.email,
346
368
  name: [
@@ -349,6 +371,10 @@ export default class TeraFyServer {
349
371
  ].filter(Boolean).join(' '),
350
372
  isSubscribed: $subscriptions.isSubscribed,
351
373
  } : null)
374
+ .catch(e => {
375
+ console.warn('Catch', e);
376
+ debugger;
377
+ })
352
378
  }
353
379
 
354
380
 
@@ -361,10 +387,91 @@ export default class TeraFyServer {
361
387
  */
362
388
  requireUser() {
363
389
  return this.getUser()
364
- .then(user => user || this.uiAlert('You must be logged in to <a href="https://tera-tools.com" target="_blank">TERA-tools.com</a> to use this tool', {
390
+ .then(user => {
391
+ this.debug('Current user is', user ? user : 'Not valid');
392
+ if (user) throw 'EXIT'; // Valid user? Escape promise chain
393
+ })
394
+ .then(async ()=> { // No user present - try to validate with other methods
395
+ switch (this.settings.serverMode) {
396
+ case TeraFyServer.SERVERMODE_EMBEDDED:
397
+ /* - Doesn't work because Kinde sets the CSP header `frame-ancestors 'self'` which prevents usage within an iFrame
398
+ const $auth = app.service('$auth');
399
+ return this.requestFocus(()=> $auth.login()
400
+ .then(()=> {
401
+ console.log('New user state', $auth.isLoggedIn);
402
+ })
403
+ );
404
+ */
405
+
406
+ var focusContent = document.createElement('div');
407
+ focusContent.innerHTML = '<div>Authenticate with <a href="https://tera-tools.com" target="_blank">TERA-tools.com</a></div>'
408
+ + '<div class="mt-2"><a class="btn btn-light">Open Popup...</a></div>';
409
+
410
+ // Attach click listner to internal button to re-popup the auth window (in case popups are blocked)
411
+ focusContent.querySelector('a.btn').addEventListener('click', ()=>
412
+ this.uiWindow(new URL(this.settings.sitePathLogin, this.settings.siteUrl))
413
+ );
414
+
415
+ // Create a deferred promise which will (eventually) resolve when the downstream window signals its ready
416
+ let waitOnWindowAuth = promiseDefer();
417
+
418
+ // Create a listener for the message from the downstream window to resolve the promise
419
+ let listenMessages = ({data}) => {
420
+ if (data.TERA && data.action == 'noop' && data.isLoggedIn) { // Signal sent from landing page - we're logged in, yey!
421
+ this.debug('Received ok message from popup window');
422
+ waitOnWindowAuth.resolve();
423
+ }
424
+ };
425
+ window.addEventListener('message', listenMessages);
426
+
427
+ // Go fullscreen, try to open the auth window + prompt the user to retry (if popups are blocked) and wait for resolution
428
+ await this.requestFocus(()=> {
429
+ // Try opening the popup automatically - this will likely fail if the user has popup blocking enabled
430
+ this.uiWindow(new URL(this.settings.sitePathLogin, this.settings.siteUrl));
431
+
432
+ // Display a message to the user, offering the ability to re-open the popup if it was originally denied
433
+ this.uiSplat(focusContent, {logo: true});
434
+
435
+ this.debug('Begin auth-check deferred wait...');
436
+ return waitOnWindowAuth.promise;
437
+ });
438
+
439
+ this.debug('Cleaning up popup auth');
440
+
441
+ // Remove message subscription
442
+ window.removeEventListener('message', listenMessages);
443
+
444
+ // Disable overlay content
445
+ this.uiSplat(false);
446
+
447
+ // Tell $auth to forcibly refresh its user data
448
+ await app.service('$auth').restoreLogin();
449
+
450
+ // ... then refresh the project list as we're likely going to need it
451
+ await app.service('$projects').refresh();
452
+
453
+ // Go back to start of auth checking loop and repull the user data
454
+ throw 'REDO';
455
+
456
+ break;
457
+ default:
458
+ // Pass - Implied - Cannot authenticate via other method so just fall through to scalding the user
459
+ }
460
+ })
461
+ .then(()=> this.uiAlert('You must be logged in to <a href="https://tera-tools.com" target="_blank">TERA-tools.com</a> to use this tool', {
365
462
  title: 'TERA-tools account needed',
366
463
  isHtml: true,
464
+ buttons: false,
367
465
  }))
466
+ .then(()=> { throw 'REDO' }) // Go into loop to keep requesting user data
467
+ .catch(e => {
468
+ if (e === 'EXIT') {
469
+ return; // Exit with a valid user
470
+ } else if (e == 'REDO') {
471
+ return this.requireUser();
472
+ }
473
+ throw e;
474
+ })
368
475
  }
369
476
 
370
477
  // }}}
@@ -495,7 +602,7 @@ export default class TeraFyServer {
495
602
  *
496
603
  * @param {Object} [options] Additional options to mutate behaviour
497
604
  * @param {String} [options.title="Select a project to work with"] The title of the dialog to display
498
- * @param {Boolean} [options.allowCancel=true] Advertise cancelling the operation, the dialog can still be cancelled by closing it
605
+ * @param {Boolean} [options.allowCancel=true] Allow cancelling the operation, will throw `'CANCEL'` if actioned
499
606
  * @param {Boolean} [options.setActive=false] Also set the project as active when selected
500
607
  *
501
608
  * @returns {Promise<Project>} The active project
@@ -526,7 +633,7 @@ export default class TeraFyServer {
526
633
 
527
634
  // }}}
528
635
 
529
- // Project State - getProjectState(), setProjectState(), saveProjectState(), replaceProjectState(), applyProjectStatePatch {{{
636
+ // Project State - getProjectState(), setProjectState(), saveProjectState(), replaceProjectState(), applyProjectStatePatch() {{{
530
637
 
531
638
  /**
532
639
  * Return the current, full snapshot state of the active project
@@ -576,16 +683,61 @@ export default class TeraFyServer {
576
683
  if (!app.service('$projects').active) throw new Error('No active project');
577
684
  if (typeof path != 'string' && !Array.isArray(path)) throw new Error('setProjectStateDefaults(path, value) - path must be a dotted string or array of path segments');
578
685
 
579
- pathSet(app.service('$projects').active, path, value)
686
+ this._pathSet(app.service('$projects').active, path, value);
580
687
 
581
688
  return (
582
- this.save && this.sync ? this.saveProjectState()
583
- : this.save ? void this.saveProjectState()
689
+ settings.save && settings.sync ? this.saveProjectState()
690
+ : settings.save ? void this.saveProjectState()
584
691
  : (()=> { throw new Error('setProjectState({sync: true, save: false}) makes no sense') })()
585
692
  );
586
693
  }
587
694
 
588
695
 
696
+ /**
697
+ * Internal recursive path setter used by setProjectState() / setProjectStateDefaults()
698
+ * The implementation defaults to _.set() unless overriden by a plugin
699
+ *
700
+ * @private
701
+ * @param {Object} subject The base subject to operate on
702
+ * @param {String|Array} path The path to set in dotted or array notation
703
+ * @param {*} value The value to set
704
+ *
705
+ * @returns {*} The set value
706
+ */
707
+ _pathSet(subject, path, value) {
708
+ return pathSet(subject, path, value);
709
+ }
710
+
711
+
712
+ /**
713
+ * Internal recursive path checker used by setProjectStateDefaults()
714
+ * The implementation defaults to _.has() unless overriden by a plugin
715
+ *
716
+ * @private
717
+ * @param {Object} subject The base subject to examine
718
+ * @param {String|Array} path The path to fetch in dotted or array notation
719
+ * @returns {Boolean} True if the given path already exists within the subject
720
+ */
721
+ _pathHas(subject, path) {
722
+ return pathExists(subject, path);
723
+ }
724
+
725
+
726
+ /**
727
+ * Internal recursive path fetcher
728
+ * The implementation defaults to _.get() unless overriden by a plugin
729
+ *
730
+ * @private
731
+ * @param {Object} subject The base subject to examine
732
+ * @param {String|Array} path The path to fetch in dotted or array notation
733
+ * @param {*} [fallback] Optional fallback to return if the end point does not exist
734
+ * @returns {*} True if the given path already exists within the subject
735
+ */
736
+ _pathGet(subject, path, fallback) {
737
+ return pathGet(subject, path, fallback);
738
+ }
739
+
740
+
589
741
  /**
590
742
  * Set a nested value within the project state - just like `setProjectState()` - but only if no value for that path exists
591
743
  *
@@ -599,7 +751,7 @@ export default class TeraFyServer {
599
751
  setProjectStateDefaults(path, value, options) {
600
752
  if (!app.service('$projects').active) throw new Error('No active project');
601
753
 
602
- if (!pathExists(app.service('$projects').active, path)) {
754
+ if (!this._pathHas(app.service('$projects').active, path)) {
603
755
  return this.setProjectState(path, value, options)
604
756
  .then(()=> true)
605
757
  } else {
@@ -654,7 +806,7 @@ export default class TeraFyServer {
654
806
  }
655
807
  // }}}
656
808
 
657
- // Project files - getProjectFiles() {{{
809
+ // Project files - selectProjectFile(), getProjectFiles() {{{
658
810
 
659
811
  /**
660
812
  * Data structure for a project file
@@ -674,6 +826,72 @@ export default class TeraFyServer {
674
826
  * @property {String} mime The associated mime type for the file
675
827
  */
676
828
 
829
+ /**
830
+ * Data structure for a file filter
831
+ * @class FileFilters
832
+ *
833
+ * @property {Boolean} [library=false] Restrict to library files only
834
+ * @property {String} [filename] CSV of @momsfriendlydevco/match expressions to filter the filename by (filenames are the basename sans extension)
835
+ * @property {String} [basename] CSV of @momsfriendlydevco/match expressions to filter the basename by
836
+ * @property {String} [ext] CSV of @momsfriendlydevco/match expressions to filter the file extension by
837
+ */
838
+
839
+ /**
840
+ * Prompt the user to select a library to operate on
841
+ *
842
+ * @param {Object} [options] Additional options to mutate behaviour
843
+ * @param {String} [options.title="Select a file"] The title of the dialog to display
844
+ * @param {String|Array<String>} [options.hint] Hints to identify the file to select in array order of preference
845
+ * @param {FileFilters} [options.filters] Optional file filters
846
+ * @param {Boolean} [options.allowUpload=true] Allow uploading new files
847
+ * @param {Boolean} [options.allowRefresh=true] Allow the user to manually refresh the file list
848
+ * @param {Boolean} [options.allowDownloadZip=true] Allow the user to download a Zip of all files
849
+ * @param {Boolean} [options.allowCancel=true] Allow cancelling the operation. Will throw `'CANCEL'` as the promise rejection if acationed
850
+ * @param {Boolean} [options.autoRequire=true] Run `requireProject()` automatically before continuing
851
+ * @param {FileFilters} [options.filter] Optional file filters
852
+ *
853
+ * @returns {Promise<ProjectFile>} The eventually selected file
854
+ */
855
+ selectProjectFile(options) {
856
+ let settings = {
857
+ title: 'Select a file',
858
+ hint: null,
859
+ filters: {},
860
+ allowUpload: true,
861
+ allowRefresh: true,
862
+ allowDownloadZip: true,
863
+ allowCancel: true,
864
+ autoRequire: true,
865
+ ...options,
866
+ };
867
+
868
+ return app.service('$projects').promise()
869
+ .then(()=> settings.autoRequire && this.requireProject())
870
+ .then(files => this.requestFocus(()=>
871
+ app.service('$prompt').dialog({
872
+ title: settings.title,
873
+ component: 'filesSelect',
874
+ componentProps: {
875
+ hint: settings.hint,
876
+ allowNavigate: false,
877
+ allowUpload: settings.allowUpload,
878
+ allowRefresh: settings.allowRefresh,
879
+ allowDownloadZip: settings.allowDownloadZip,
880
+ allowVerbs: false,
881
+ cardStyle: false,
882
+ filters: settings.filters,
883
+ },
884
+ componentEvents: {
885
+ fileSelect(file) {
886
+ app.service('$prompt').close(true, file);
887
+ },
888
+ },
889
+ modalDialogClass: 'modal-dialog-lg',
890
+ buttons: settings.allowCancel && ['cancel'],
891
+ })
892
+ ))
893
+ }
894
+
677
895
 
678
896
  /**
679
897
  * Fetch the files associated with a given project
@@ -682,7 +900,7 @@ export default class TeraFyServer {
682
900
  * @param {Boolean} [options.autoRequire=true] Run `requireProject()` automatically before continuing
683
901
  * @param {Boolean} [options.meta=true] Pull meta information for each file entity
684
902
  *
685
- * @returns {Promise<ProjectFile>} A collection of project files for the given project
903
+ * @returns {Promise<Array<ProjectFile>>} A collection of project files for the given project
686
904
  */
687
905
  getProjectFiles(options) {
688
906
  let settings = {
@@ -700,61 +918,81 @@ export default class TeraFyServer {
700
918
  }
701
919
  // }}}
702
920
 
703
- // Project Libraries - getProjectLibrary(), setProjectLibrary() {{{
921
+ // Project Libraries - selectProjectLibrary(), parseProjectLibrary(), setProjectLibrary() {{{
922
+
923
+ /**
924
+ * Prompt the user to select a library to operate on and return a array of references in a given format
925
+ *
926
+ * @param {Object} [options] Additional options to mutate behaviour
927
+ * @param {String} [options.title="Select a citation library"] The title of the dialog to display
928
+ * @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'
929
+ * @param {Boolean} [options.allowUpload=true] Allow uploading new files
930
+ * @param {Boolean} [options.allowRefresh=true] Allow the user to manually refresh the file list
931
+ * @param {Boolean} [options.allowDownloadZip=true] Allow the user to download a Zip of all files
932
+ * @param {Boolean} [options.allowCancel=true] Allow cancelling the operation. Will throw `'CANCEL'` as the promise rejection if acationed
933
+ * @param {Boolean} [options.autoRequire=true] Run `requireProject()` automatically before continuing
934
+ * @param {FileFilters} [options.filters] Optional file filters, defaults to citation library selection only
935
+ * @param {*} [options.*] Additional options - see `parseProjectLibrary()`
936
+ *
937
+ * @returns {Promise<Array<Ref>>} A collection of references from the selected file
938
+ */
939
+ selectProjectLibrary(options) {
940
+ let settings = {
941
+ title: 'Select a citation library',
942
+ hint: null,
943
+ allowUpload: true,
944
+ allowRefresh: true,
945
+ allowDownloadZip: true,
946
+ allowCancel: true,
947
+ autoRequire: true,
948
+ filters: {
949
+ library: true,
950
+ ...options?.filter,
951
+ },
952
+ ...options,
953
+ };
954
+
955
+ return app.service('$projects').promise()
956
+ .then(()=> this.selectProjectFile(settings))
957
+ .then(selectedFile => this.parseProjectLibrary(selectedFile.path, settings))
958
+ }
959
+
704
960
 
705
961
  /**
706
- * Fetch the active projects citation library
962
+ * Convert a project file into a library of citations
707
963
  *
708
- * @param {String} [path] Optional file path to use, if omitted the contents of `options` are used to guess at a suitable file
964
+ * @param {String} path File path to read, if omitted the contents of `options` are used to guess at a suitable file
709
965
  *
710
966
  * @param {Object} [options] Additional options to mutate behaviour
711
967
  * @param {String} [options.format='json'] Format for the file. ENUM: 'pojo' (return a parsed JS collection), 'blob' (raw JS Blob object), 'file' (named JS File object)
712
968
  * @param {Boolean} [options.autoRequire=true] Run `requireProject()` automatically before continuing
713
- * @param {Boolean} [options.multiple=false] Allow selection of multiple libraries
714
969
  * @param {Function} [options.filter] Optional async file filter, called each time as `(File:ProjectFile)`
715
970
  * @param {Function} [options.find] Optional async final stage file filter to reduce all candidates down to one subject file
716
- * @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'
717
971
  *
718
- * @returns {Promise<Array<ProjectFile>>} Collection of references for the selected library matching the given hint + filter, this could be a zero length array
972
+ * @returns {Promise<Array<Ref>>|Promise<*>} A collection of references (default bevahiour) or a whatever format was requested
719
973
  */
720
- getProjectLibrary(path, options) {
974
+ parseProjectLibrary(path, options) {
721
975
  let settings = {
722
- path,
723
976
  format: 'pojo',
724
977
  autoRequire: true,
725
- multiple: false,
726
- hint: null,
727
978
  filter: file => true,
728
979
  find: files => files.at(0),
729
- ...(typeof path == 'string' ? {path, ...options} : path),
980
+ ...options,
730
981
  };
731
982
 
732
983
  return Promise.resolve()
733
- .then(()=> {
734
- if (settings.path) { // Already have a file name picked
735
- if (!settings.path.startsWith('/')) throw new Error('All file names must start with a forward slash');
736
- return settings.path;
737
- } else { // Try to guess the file from the options structure
738
- return this.getProjectFiles({
739
- autoRequire: settings.autoRequire,
740
- })
741
- .then(files => files.filter(file =>
742
- settings.filter(file)
743
- ))
744
- .then(files => settings.find(files))
745
- .then(file => file.path)
746
- }
747
- })
748
- .then(filePath => app.service('$supabase').fileGet(filePath, {
984
+ .then(()=> settings.autoRequire && this.requireProject())
985
+ .then(()=> app.service('$supabase').fileGet(path, {
749
986
  toast: false,
750
987
  }))
751
988
  .then(blob => {
752
989
  switch (settings.format) {
990
+ // NOTE: Any updates to the format list should also extend setProjectLibrary()
753
991
  case 'pojo':
754
992
  return Reflib.uploadFile({
755
993
  file: new File(
756
994
  [blob],
757
- app.service('$supabase')._parsePath(settings.path).basename,
995
+ app.service('$supabase')._parsePath(path).basename,
758
996
  ),
759
997
  });
760
998
  case 'blob':
@@ -762,7 +1000,7 @@ export default class TeraFyServer {
762
1000
  case 'file':
763
1001
  return new File(
764
1002
  [blob],
765
- app.service('$supabase')._parsePath(settings.path).basename,
1003
+ app.service('$supabase')._parsePath(path).basename,
766
1004
  );
767
1005
  default:
768
1006
  throw new Error(`Unsupported library format "${settings.format}"`);
@@ -772,26 +1010,59 @@ export default class TeraFyServer {
772
1010
 
773
1011
 
774
1012
  /**
775
- * Save back a projects citation library
1013
+ * Save back a citation library from some input
776
1014
  *
1015
+ * @param {String} [path] File path to save back to
777
1016
  * @param {Array<RefLibRef>} Collection of references for the selected library
778
1017
  *
779
1018
  * @param {Object} [options] Additional options to mutate behaviour
1019
+ * @param {String} [options.format='json'] Input format used. ENUM: 'pojo' (return a parsed JS collection), 'blob' (raw JS Blob object), 'file' (named JS File object)
780
1020
  * @param {Boolean} [options.autoRequire=true] Run `requireProject()` automatically before continuing
781
1021
  * @param {String} [options.hint] Hint to store against the library. Generally corresponds to the current operation being performed - e.g. 'deduped'
1022
+ * @param {Boolean} [options.overwrite=true] Allow existing file upsert
1023
+ * @param {Object} [options.meta] Optional meta data to merge into the file data
782
1024
  *
783
1025
  * @returns {Promise} A promise which resolves when the save operation has completed
784
1026
  */
785
- setProjectLibrary(refs, options) {
1027
+ setProjectLibrary(path, refs, options) {
786
1028
  let settings = {
787
1029
  autoRequire: true,
788
1030
  hint: null,
1031
+ overwrite: true,
1032
+ meta: null,
789
1033
  ...options,
790
1034
  };
791
1035
 
792
1036
  return Promise.resolve()
793
1037
  .then(()=> settings.autoRequire && this.requireProject())
794
- // FIXME: Stub
1038
+ .then(()=> {
1039
+ switch (settings.format) {
1040
+ // NOTE: Any updates to the format list should also extend parseProjectLibrary()
1041
+ case 'pojo': // Use as is
1042
+ if (!Array.isArray(refs)) throw new Error('setProjectLibrary() with format=pojo requires an array of references');
1043
+ return refs;
1044
+ case 'blob':
1045
+ case 'file':
1046
+ return refs; // Following functions should pass thru these formats anyway
1047
+ default:
1048
+ throw new Error(`Unsupported library format "${settings.format}"`);
1049
+ }
1050
+ })
1051
+ .then(refs =>
1052
+ refs instanceof Blob ? new File([refs], app.service('$supabase')._parsePath(path).basename)
1053
+ : refs instanceof File ? refs
1054
+ : Reflib.downloadFile(refs, { // Get Reflib to encode for us
1055
+ filename: path,
1056
+ promptDownload: false, // Just return the fileBlob we hand to Supabase
1057
+ })
1058
+ )
1059
+ .then(fileBlob => this.$supabase.fileUpload(path, {
1060
+ file: fileBlob,
1061
+ mode: 'encoded',
1062
+ overwrite: true,
1063
+ meta: settings.meta,
1064
+ transcoders: false, // We can skip transcoders as we are supplying the meta information above anyway
1065
+ }))
795
1066
  }
796
1067
 
797
1068
  // }}}
@@ -825,6 +1096,7 @@ export default class TeraFyServer {
825
1096
  * @param {Object} [options] Additional options to mutate behaviour
826
1097
  * @param {String} [options.title='TERA'] The title of the alert box
827
1098
  * @param {Boolean} [options.isHtml=false] If falsy the text is rendered as plain-text otherwise it will be assumed as HTML content
1099
+ * @param {'ok'|false} [options.buttons='ok'] Button set to use or falsy to disable
828
1100
  *
829
1101
  * @returns {Promise} A promise which resolves when the alert has been dismissed
830
1102
  */
@@ -832,6 +1104,7 @@ export default class TeraFyServer {
832
1104
  let settings = {
833
1105
  title: 'TERA',
834
1106
  isHtml: false,
1107
+ buttons: 'ok',
835
1108
  ...options,
836
1109
  };
837
1110
 
@@ -839,12 +1112,100 @@ export default class TeraFyServer {
839
1112
  app.service('$prompt').dialog({
840
1113
  title: settings.title,
841
1114
  body: text,
842
- buttons: ['ok'],
1115
+ buttons:
1116
+ settings.buttons == 'ok' ? ['ok']
1117
+ : false,
843
1118
  isHtml: settings.isHtml,
844
1119
  dialogClose: 'resolve',
845
1120
  })
846
1121
  );
847
1122
  }
1123
+
1124
+
1125
+ /**
1126
+ * Open a popup window containing a new site
1127
+ *
1128
+ * @param {String} url The URL to open
1129
+ *
1130
+ * @param {Object} [options] Additional options to mutate behaviour
1131
+ * @param {Number} [options.width=500] The desired width of the window
1132
+ * @param {Number} [options.height=600] The desired height of the window
1133
+ * @param {Boolean} [options.center=true] Attempt to center the window on the screen
1134
+ * @param {Object} [options.permissions] Additional permissions to set on opening, defaults to a suitable set of permission for popups (see code)
1135
+ *
1136
+ * @returns {WindowProxy} The opened window object (if `noopener` is not set in permissions)
1137
+ */
1138
+ uiWindow(url, options) {
1139
+ let settings = {
1140
+ width: 500,
1141
+ height: 600,
1142
+ center: true,
1143
+ permissions: {
1144
+ popup: true,
1145
+ location: false,
1146
+ menubar: false,
1147
+ status: false,
1148
+ scrolbars: false,
1149
+ },
1150
+ ...options,
1151
+ };
1152
+
1153
+ return window.open(url, '_blank', Object.entries({
1154
+ ...settings.permissions,
1155
+ width: settings.width,
1156
+ height: settings.height,
1157
+ ...(settings.center && {
1158
+ left: screen.width/2 - settings.width/2,
1159
+ top: screen.height/2 - settings.height/2,
1160
+ }),
1161
+ })
1162
+ .map(([key, val]) => key + '=' + (
1163
+ typeof val == 'boolean' ? val ? '1' : '0' // Map booleans to 1/0
1164
+ : val
1165
+ ))
1166
+ .join(', ')
1167
+ );
1168
+ }
1169
+
1170
+
1171
+ /**
1172
+ * Display HTML content full-screen within TERA
1173
+ * This function is ideally called within a requestFocus() wrapper
1174
+ *
1175
+ * @param {DOMElement|String|false} content Either a prepared DOM element or string to compile, set to falsy to remove existing content
1176
+ *
1177
+ * @param {Object} [options] Additional options to mutate behaviour
1178
+ * @param {Boolean|String} [options.logo=false] Add a logo to the output, if boolean true the Tera-tools logo is used otherwise specify a path or URL
1179
+ */
1180
+ uiSplat(content, options) {
1181
+ let settings = {
1182
+ logo: false,
1183
+ ...options,
1184
+ };
1185
+
1186
+ if (!content) { // Remove content
1187
+ globalThis.document.body.querySelector('.tera-fy-uiSplat').remove();
1188
+ return;
1189
+ }
1190
+
1191
+ let compiledContent = typeof content == 'string'
1192
+ ? (()=> {
1193
+ let el = document.createElement('div')
1194
+ el.innerHTML = content;
1195
+ return el;
1196
+ })()
1197
+ : content;
1198
+
1199
+ compiledContent.classList.add('tera-fy-uiSplat');
1200
+
1201
+ if (settings.logo) {
1202
+ let logoEl = document.createElement('div');
1203
+ logoEl.innerHTML = `<img src="${typeof settings.logo == 'string' ? settings.logo : '/assets/logo/logo.svg'}" class="img-logo"/>`;
1204
+ compiledContent.prepend(logoEl);
1205
+ }
1206
+
1207
+ globalThis.document.body.append(compiledContent);
1208
+ }
848
1209
  // }}}
849
1210
 
850
1211
  // Utility - debug() {{{