@iebh/tera-fy 2.0.21 → 2.2.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.
Files changed (75) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/api.md +68 -66
  3. package/dist/lib/projectFile.d.ts +182 -0
  4. package/dist/lib/projectFile.js +157 -0
  5. package/dist/lib/projectFile.js.map +1 -0
  6. package/dist/lib/syncro/entities.d.ts +28 -0
  7. package/dist/lib/syncro/entities.js +203 -0
  8. package/dist/lib/syncro/entities.js.map +1 -0
  9. package/dist/lib/syncro/keyed.d.ts +95 -0
  10. package/dist/lib/syncro/keyed.js +286 -0
  11. package/dist/lib/syncro/keyed.js.map +1 -0
  12. package/dist/lib/syncro/syncro.d.ts +328 -0
  13. package/dist/lib/syncro/syncro.js +633 -0
  14. package/dist/lib/syncro/syncro.js.map +1 -0
  15. package/dist/lib/terafy.bootstrapper.d.ts +42 -0
  16. package/dist/lib/terafy.bootstrapper.js +130 -0
  17. package/dist/lib/terafy.bootstrapper.js.map +1 -0
  18. package/dist/lib/terafy.client.d.ts +532 -0
  19. package/dist/lib/terafy.client.js +1110 -0
  20. package/dist/lib/terafy.client.js.map +1 -0
  21. package/dist/lib/terafy.proxy.d.ts +66 -0
  22. package/dist/lib/terafy.proxy.js +123 -0
  23. package/dist/lib/terafy.proxy.js.map +1 -0
  24. package/dist/lib/terafy.server.d.ts +607 -0
  25. package/dist/lib/terafy.server.js +1774 -0
  26. package/dist/lib/terafy.server.js.map +1 -0
  27. package/dist/plugin.vue2.es2019.js +30 -13
  28. package/dist/plugins/base.d.ts +20 -0
  29. package/dist/plugins/base.js +21 -0
  30. package/dist/plugins/base.js.map +1 -0
  31. package/dist/plugins/firebase.d.ts +62 -0
  32. package/dist/plugins/firebase.js +111 -0
  33. package/dist/plugins/firebase.js.map +1 -0
  34. package/dist/plugins/vite.d.ts +12 -0
  35. package/dist/plugins/vite.js +22 -0
  36. package/dist/plugins/vite.js.map +1 -0
  37. package/dist/plugins/vue2.d.ts +68 -0
  38. package/dist/plugins/vue2.js +96 -0
  39. package/dist/plugins/vue2.js.map +1 -0
  40. package/dist/plugins/vue3.d.ts +64 -0
  41. package/dist/plugins/vue3.js +96 -0
  42. package/dist/plugins/vue3.js.map +1 -0
  43. package/dist/terafy.bootstrapper.es2019.js +2 -2
  44. package/dist/terafy.bootstrapper.js +2 -2
  45. package/dist/terafy.es2019.js +2 -2
  46. package/dist/terafy.js +1 -1
  47. package/dist/utils/mixin.d.ts +11 -0
  48. package/dist/utils/mixin.js +15 -0
  49. package/dist/utils/mixin.js.map +1 -0
  50. package/dist/utils/pDefer.d.ts +12 -0
  51. package/dist/utils/pDefer.js +14 -0
  52. package/dist/utils/pDefer.js.map +1 -0
  53. package/dist/utils/pathTools.d.ts +70 -0
  54. package/dist/utils/pathTools.js +120 -0
  55. package/dist/utils/pathTools.js.map +1 -0
  56. package/eslint.config.js +44 -8
  57. package/lib/{projectFile.js → projectFile.ts} +83 -40
  58. package/lib/syncro/entities.ts +288 -0
  59. package/lib/syncro/{keyed.js → keyed.ts} +114 -57
  60. package/lib/syncro/{syncro.js → syncro.ts} +204 -169
  61. package/lib/{terafy.bootstrapper.js → terafy.bootstrapper.ts} +49 -31
  62. package/lib/{terafy.client.js → terafy.client.ts} +94 -86
  63. package/lib/{terafy.proxy.js → terafy.proxy.ts} +43 -16
  64. package/lib/{terafy.server.js → terafy.server.ts} +364 -223
  65. package/package.json +65 -26
  66. package/plugins/{base.js → base.ts} +3 -1
  67. package/plugins/{firebase.js → firebase.ts} +34 -16
  68. package/plugins/{vite.js → vite.ts} +3 -3
  69. package/plugins/{vue2.js → vue2.ts} +17 -10
  70. package/plugins/{vue3.js → vue3.ts} +11 -9
  71. package/tsconfig.json +30 -0
  72. package/utils/{mixin.js → mixin.ts} +1 -1
  73. package/utils/{pDefer.js → pDefer.ts} +10 -3
  74. package/utils/{pathTools.js → pathTools.ts} +11 -9
  75. package/lib/syncro/entities.js +0 -232
@@ -0,0 +1,1774 @@
1
+ import { cloneDeep } from 'lodash-es';
2
+ import mixin from '#utils/mixin';
3
+ import { nanoid } from 'nanoid';
4
+ import pathTools from '#utils/pathTools';
5
+ import promiseDefer from '#utils/pDefer';
6
+ // @ts-ignore
7
+ import Reflib from '@iebh/reflib';
8
+ import { reactive } from 'vue';
9
+ /**
10
+ * Server-side functions available to the Tera-Fy client library
11
+ *
12
+ * @class TeraFyServer
13
+ */
14
+ /* globals globalThis, app */
15
+ class TeraFyServer {
16
+ // Contexts - createContext(), getClientContext(), messageEvent, senderRpc() {{{
17
+ /**
18
+ * Create a context based on a shallow copy of this instance + additional functionality for the incoming MessageEvent
19
+ * This is used by acceptMessage to provide a means to reply / send messages to the originator
20
+ *
21
+ * @param {MessageEvent} e Original message event to base the new context on
22
+ *
23
+ * @returns {Object} A context, which is this instance extended with additional properties
24
+ */
25
+ createContext(e) {
26
+ // Construct wrapper for sendRaw for this client
27
+ return mixin(this, {
28
+ messageEvent: e,
29
+ sendRaw(message) {
30
+ let payload;
31
+ try {
32
+ payload = {
33
+ TERA: 1,
34
+ ...cloneDeep(message), // Need to clone to resolve promise nasties
35
+ };
36
+ // Use type assertion assuming e.source is a WindowProxy or similar
37
+ e.source.postMessage(payload, this.settings.restrictOrigin);
38
+ }
39
+ catch (err) { // Changed variable name e -> err
40
+ this.debug('ERROR', 1, 'Attempted to dispatch payload server(via reply)->client', { payload, e: err });
41
+ throw err;
42
+ }
43
+ },
44
+ });
45
+ }
46
+ /**
47
+ * Create a new client context from the server to the client even if the client hasn't requested the communication
48
+ * This function is used to send unsolicited communications from the server->client in contrast to createContext() which _replies_ from client->server->client
49
+ *
50
+ * @returns {Object} A context, which is this instance extended with additional properties
51
+ */
52
+ getClientContext() {
53
+ switch (this.settings.serverMode) {
54
+ case TeraFyServer.SERVERMODE_NONE:
55
+ throw new Error('Client has not yet initiated communication');
56
+ case TeraFyServer.SERVERMODE_EMBEDDED:
57
+ // Server is inside an iFrame so we need to send messages to the window parent
58
+ return mixin(this, {
59
+ sendRaw(message) {
60
+ let payload;
61
+ try {
62
+ payload = {
63
+ TERA: 1,
64
+ ...cloneDeep(message), // Need to clone to resolve promise nasties
65
+ };
66
+ window.parent.postMessage(payload, this.settings.restrictOrigin);
67
+ }
68
+ catch (e) {
69
+ this.debug('ERROR', 1, 'Attempted to dispatch payload server(iframe)->cient(top level window)', { payload, e });
70
+ throw e;
71
+ }
72
+ },
73
+ });
74
+ case TeraFyServer.SERVERMODE_TERA:
75
+ case TeraFyServer.SERVERMODE_FRAME: {
76
+ // Server is the top-level window so we need to send messages to an embedded iFrame
77
+ let iFrame = document.querySelector('iframe#external');
78
+ if (!iFrame) {
79
+ this.debug('INFO', 2, 'Cannot locate TERA-FY top-level->iFrame#external - maybe there is none');
80
+ return mixin(this, {
81
+ sendRaw(message) {
82
+ this.debug('INFO', 2, 'Sending broadcast to zero listening clients', { message });
83
+ },
84
+ });
85
+ }
86
+ return mixin(this, {
87
+ sendRaw(message) {
88
+ let payload;
89
+ try {
90
+ payload = {
91
+ TERA: 1,
92
+ ...cloneDeep(message), // Need to clone to resolve promise nasties
93
+ };
94
+ // Check if contentWindow exists before posting
95
+ iFrame.contentWindow?.postMessage(payload, this.settings.restrictOrigin);
96
+ }
97
+ catch (e) {
98
+ this.debug('ERROR', 1, 'Attempted to dispatch payload server(top level window)->cient(iframe)', { payload, e });
99
+ throw e;
100
+ }
101
+ },
102
+ });
103
+ }
104
+ case TeraFyServer.SERVERMODE_POPUP:
105
+ // FIXME: Need implementation for POPUP mode?
106
+ throw new Error('SERVERMODE_POPUP getClientContext not implemented');
107
+ }
108
+ }
109
+ /**
110
+ * Request an RPC call from the original sender of a mesasge
111
+ * This function only works if the context was sub-classed via `createContext()`
112
+ *
113
+ * @param {String} method The method name to call
114
+ * @param {...*} [args] Optional arguments to pass to the function
115
+ *
116
+ * @returns {Promise<*>} The resolved output of the server function
117
+ */
118
+ senderRpc(method, ...args) {
119
+ if (!this.messageEvent)
120
+ throw new Error('senderRpc() can only be used if given a context from `createContext()`');
121
+ // Create a context specific to this event to use its sendRaw
122
+ const context = this.createContext(this.messageEvent);
123
+ return context.send({
124
+ action: 'rpc',
125
+ method,
126
+ args,
127
+ });
128
+ }
129
+ // }}}
130
+ // Messages - handshake(), send(), sendRaw(), setServerMode(), acceptMessage(), requestFocus(), emitClients() {{{
131
+ /**
132
+ * Return basic server information as a form of validation
133
+ *
134
+ * @returns {Promise<Object>} Basic promise result
135
+ * @property {Date} date Server date
136
+ */
137
+ handshake() {
138
+ return Promise.resolve({
139
+ date: new Date(),
140
+ });
141
+ }
142
+ /**
143
+ * Send a message + wait for a response object
144
+ * This method should likely be part of the context returned by createContext
145
+ * Assuming it's intended to work on the base class referencing a stored messageEvent
146
+ *
147
+ * @param {Object} message Message object to send
148
+ * @returns {Promise<*>} A promise which resolves when the operation has completed with the remote reply
149
+ */
150
+ send(message) {
151
+ if (!this.messageEvent?.source)
152
+ throw new Error('send() requires a messageEvent with a source');
153
+ let id = nanoid();
154
+ this.acceptPostboxes[id] = {}; // Stub for the deferred promise
155
+ this.acceptPostboxes[id].promise = new Promise((resolve, reject) => {
156
+ Object.assign(this.acceptPostboxes[id], {
157
+ resolve, reject,
158
+ });
159
+ // Use sendRaw with the specific source from the stored messageEvent
160
+ this.sendRaw({
161
+ id,
162
+ ...message,
163
+ }, this.messageEvent?.source); // Pass the source explicitly
164
+ });
165
+ return this.acceptPostboxes[id].promise;
166
+ }
167
+ /**
168
+ * Send raw message content to the client
169
+ * Unlike send() this method does not expect any response
170
+ *
171
+ * @param {Object} message Message object to send
172
+ * @param {Window} sendVia Window context to dispatch the message via if its not the same as the regular window
173
+ */
174
+ sendRaw(message, sendVia) {
175
+ let payload;
176
+ try {
177
+ payload = {
178
+ TERA: 1,
179
+ ...cloneDeep(message), // Need to clone to resolve promise nasties
180
+ };
181
+ this.debug('INFO', 3, 'Dispatch response', message, '<=>', payload);
182
+ // Default to parent if sendVia is not provided, but check if it exists
183
+ const target = sendVia || (typeof globalThis !== 'undefined' ? globalThis.parent : undefined);
184
+ if (target) {
185
+ target.postMessage(payload, this.settings.restrictOrigin);
186
+ }
187
+ else {
188
+ this.debug('WARN', 1, 'Cannot sendRaw, no target window (sendVia or parent) found.');
189
+ }
190
+ }
191
+ catch (e) {
192
+ this.debug('ERROR', 2, 'Attempted to dispatch response server->client', payload);
193
+ this.debug('ERROR', 2, 'Message compose server->client:', e);
194
+ }
195
+ }
196
+ /**
197
+ * Setter to translate between string inputs and the server modes in SERVERMODE_*
198
+ *
199
+ * @param {String} mode The server mode to set to
200
+ */
201
+ setServerMode(mode) {
202
+ switch (mode) {
203
+ case 'embedded':
204
+ this.settings.serverMode = TeraFyServer.SERVERMODE_EMBEDDED;
205
+ break;
206
+ case 'frame':
207
+ this.settings.serverMode = TeraFyServer.SERVERMODE_FRAME;
208
+ break;
209
+ case 'popup':
210
+ this.settings.serverMode = TeraFyServer.SERVERMODE_POPUP;
211
+ break;
212
+ default:
213
+ throw new Error(`Unsupported server mode "${mode}"`);
214
+ }
215
+ }
216
+ /**
217
+ * Accept a message from the parent event listener
218
+ *
219
+ * @param {MessageEvent} rawMessage Raw message event to process
220
+ */
221
+ acceptMessage(rawMessage) {
222
+ // Ignore messages from the same origin (potential loops)
223
+ if (typeof window !== 'undefined' && rawMessage.origin === window.location.origin)
224
+ return;
225
+ let message = rawMessage.data;
226
+ // Ensure message is an object and has TERA property
227
+ if (typeof message !== 'object' || message === null || !message.TERA)
228
+ return;
229
+ this.debug('INFO', 3, 'Recieved message', message);
230
+ Promise.resolve()
231
+ .then(() => {
232
+ if (message?.action == 'response' && message.id && this.acceptPostboxes[message.id]) { // Postbox waiting for reply
233
+ if (message.isError === true) {
234
+ this.acceptPostboxes[message.id].reject(message.response);
235
+ }
236
+ else {
237
+ this.acceptPostboxes[message.id].resolve(message.response);
238
+ }
239
+ delete this.acceptPostboxes[message.id]; // Clean up postbox
240
+ }
241
+ else if (message.action == 'rpc' && typeof message.method === 'string') { // Relay RPC calls
242
+ const method = message.method;
243
+ // Use type assertion for dynamic method call
244
+ if (typeof this[method] === 'function') {
245
+ // Create context for this specific message event
246
+ const context = this.createContext(rawMessage);
247
+ // Store the event temporarily for potential use in send() called by the RPC method
248
+ context.messageEvent = rawMessage;
249
+ return this[method].apply(context, message.args || []);
250
+ }
251
+ else {
252
+ throw new Error(`Unknown RPC method "${method}"`);
253
+ }
254
+ }
255
+ else {
256
+ this.debug('ERROR', 2, 'Unexpected incoming TERA-FY SERVER message', { message });
257
+ // Don't throw, just ignore unknown formats silently? Or throw?
258
+ // throw new Error('Unknown message format');
259
+ }
260
+ })
261
+ .then(response => {
262
+ // Only send response if it was an RPC call that returned something
263
+ if (message.action === 'rpc' && rawMessage.source) {
264
+ this.sendRaw({
265
+ id: message.id,
266
+ action: 'response',
267
+ response,
268
+ }, rawMessage.source);
269
+ }
270
+ })
271
+ .catch(e => {
272
+ console.warn(`TERA-FY server threw on RPC:${message.method}:`, e);
273
+ // Send error response back if possible
274
+ if (message.action === 'rpc' && message.id && rawMessage.source) {
275
+ this.sendRaw({
276
+ id: message.id,
277
+ action: 'response',
278
+ isError: true,
279
+ response: e instanceof Error ? e.message : String(e), // Return error message to requester
280
+ }, rawMessage.source);
281
+ }
282
+ else {
283
+ console.warn(`Unable to respond with errored RPC:${message.method} as reply postbox is invalid`);
284
+ }
285
+ });
286
+ }
287
+ /**
288
+ * Wrapper function which runs a callback after the frontend UI has obtained focus
289
+ * This is to fix the issue where the front-end needs to switch between a regular webpage and a focused TERA iFrame wrapper
290
+ * Any use of $prompt or other UI calls should be wrapped here
291
+ *
292
+ * @param {Function} cb Async function to run in focused mode
293
+ *
294
+ * @returns {Promise<*>} A promise which resolves with the resulting inner callback payload
295
+ */
296
+ requestFocus(cb) {
297
+ // Ensure messageEvent is set before calling senderRpc
298
+ if (!this.messageEvent && this.settings.serverMode != TeraFyServer.SERVERMODE_TERA) {
299
+ console.warn("requestFocus called without a messageEvent context. Cannot toggle focus.");
300
+ // Proceed without toggling focus if no context is available
301
+ return Promise.resolve().then(() => cb.call(this));
302
+ }
303
+ return Promise.resolve()
304
+ // Only toggle focus if not in TERA mode and messageEvent is available
305
+ .then(() => this.settings.serverMode != TeraFyServer.SERVERMODE_TERA && this.messageEvent && this.senderRpc('toggleFocus', true))
306
+ .then(() => cb.call(this))
307
+ // Only toggle focus back if not in TERA mode and messageEvent is available
308
+ .finally(() => this.settings.serverMode != TeraFyServer.SERVERMODE_TERA && this.messageEvent && this.senderRpc('toggleFocus', false));
309
+ }
310
+ /**
311
+ * Emit messages down into all connected clients
312
+ * Note that emitted messages have no response - they are sent to clients only with no return value
313
+ *
314
+ * @param {String} event The event name to emit
315
+ * @param {...*} [args] Optional event payload to send
316
+ * @returns {Promise} A promise which resolves when the transmission has completed
317
+ */
318
+ emitClients(event, ...args) {
319
+ // Use getClientContext to get the appropriate sendRaw method
320
+ const context = this.getClientContext();
321
+ return context.sendRaw({
322
+ action: 'event',
323
+ id: nanoid(),
324
+ event,
325
+ payload: args,
326
+ });
327
+ }
328
+ /**
329
+ * RPC callback to set the server verbostiy level
330
+ *
331
+ * @param {Number} verbosity The desired server verbosity level
332
+ */
333
+ setServerVerbosity(verbosity) {
334
+ this.settings.verbosity = +verbosity;
335
+ this.debug('INFO', 1, 'Server verbosity set to', this.settings.verbosity);
336
+ }
337
+ // }}}
338
+ // Session / User - getUser(), requireUser() {{{
339
+ /**
340
+ * User / active session within TERA
341
+ * @class User
342
+ * @property {String} id Unique identifier of the user
343
+ * @property {String} email The email address of the current user
344
+ * @property {String} name The provided full name of the user
345
+ * @property {Boolean} isSubscribed Whether the active user has a TERA subscription
346
+ */
347
+ /**
348
+ * Fetch the current session user
349
+ *
350
+ * @param {Object} [options] Additional options to mutate behaviour
351
+ * @param {Boolean} [options.forceRetry=false] Forcabily try to refresh the user state
352
+ * @param {Boolean} [options.waitPromises=true] Wait for $auth + $subscriptions to resolve before fetching the user (mainly internal use)
353
+ *
354
+ * @returns {Promise<User>} The current logged in user or null if none
355
+ */
356
+ getUser(options) {
357
+ let settings = {
358
+ forceRetry: false,
359
+ waitPromises: true,
360
+ ...options,
361
+ };
362
+ let $auth = app.service('$auth');
363
+ let $subscriptions = app.service('$subscriptions');
364
+ return Promise.resolve()
365
+ .then(() => settings.waitPromises && Promise.all([
366
+ $auth.promise(),
367
+ $subscriptions.promise(),
368
+ ]))
369
+ .then(() => {
370
+ if (!$auth.isLoggedIn && settings.forceRetry)
371
+ return $auth.restoreLogin();
372
+ })
373
+ .then(() => $auth.user?.id
374
+ ? {
375
+ id: $auth.user.id,
376
+ email: $auth.user.email,
377
+ name: [
378
+ $auth.user.given_name,
379
+ $auth.user.family_name,
380
+ ].filter(Boolean).join(' '),
381
+ isSubscribed: $subscriptions.isSubscribed,
382
+ credits: $auth.active?.credits ?? 0,
383
+ }
384
+ : null)
385
+ .catch((e) => {
386
+ console.warn('getUser() catch', e);
387
+ return null; // Return null on error
388
+ });
389
+ }
390
+ /**
391
+ * Require a user login to TERA
392
+ * If there is no user OR they are not logged in a prompt is shown to go and do so
393
+ * This is an pre-requisite step for requireProject()
394
+ *
395
+ * @returns {Promise<User>} A promise which will resolve if the there is a user and they are logged in
396
+ */
397
+ requireUser() {
398
+ let user; // Last getUser() response
399
+ return Promise.resolve() // NOTE: This promise is upside down, it only continues down the chain if the user is NOT valid, otherwise it throws to exit
400
+ .then(() => this.getUser())
401
+ .then(res => user = res)
402
+ .then(() => {
403
+ if (user) {
404
+ this.debug('INFO', 2, 'requireUser() + Current user IS valid');
405
+ throw 'EXIT'; // Valid user? Escape promise chain
406
+ }
407
+ else {
408
+ this.debug('INFO', 2, 'requireUser() + Current user is NOT valid');
409
+ }
410
+ })
411
+ .then(async () => {
412
+ switch (this.settings.serverMode) {
413
+ case TeraFyServer.SERVERMODE_EMBEDDED:
414
+ /* - Doesn't work because Kinde sets the CSP header `frame-ancestors 'self'` which prevents usage within an iFrame
415
+ const $auth = app.service('$auth');
416
+ return this.requestFocus(()=> $auth.login()
417
+ .then(()=> {
418
+ console.log('New user state', $auth.isLoggedIn);
419
+ })
420
+ );
421
+ */
422
+ // Try to restore state via Popup workaround
423
+ if (this.settings.embedWorkaround) {
424
+ await this.getUserViaEmbedWorkaround();
425
+ this.settings.embedWorkaround = false; // Disable workaround so we don't get stuck in a loop
426
+ // Go back to start of auth checking loop and repull the user data
427
+ throw 'REDO';
428
+ }
429
+ default:
430
+ // Pass - Implied - Cannot authenticate via other method so just fall through to scalding the user
431
+ }
432
+ })
433
+ .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', {
434
+ title: 'TERA-tools account needed',
435
+ isHtml: true,
436
+ buttons: false,
437
+ }))
438
+ .then(() => { throw 'REDO'; }) // Go into loop to keep requesting user data
439
+ .catch(e => {
440
+ if (e === 'EXIT') {
441
+ return user; // Exit with a valid user
442
+ }
443
+ else if (e == 'REDO') {
444
+ return this.requireUser();
445
+ }
446
+ throw e;
447
+ });
448
+ }
449
+ /**
450
+ * Provide an object of credentials for 3rd party services like Firebase/Supabase
451
+ *
452
+ * @returns {Object} An object containing 3rd party service credentials
453
+ */
454
+ getCredentials() {
455
+ return app.service('$auth').credentials;
456
+ }
457
+ /**
458
+ * In embed mode only - create a popup window and try to auth via that
459
+ *
460
+ * When in embed mode we can't store local state (Cookies without SameSite + LocalStorage etc.) so the only way to auth the user in the restricted envionment:
461
+ *
462
+ * 1. Try to read state from LocalStorage (if so, skip everything else)
463
+ * 2. Create a popup - which can escape the security container - and trigger a login
464
+ * 3. Listen locally for a message from the popup which it will transmit the authed user to its original window opener
465
+ * 3. Stash the state in LocalStorage to avoid this in future
466
+ *
467
+ * This workaround is only needed when developing with TERA in an embed window - i.e. local dev / stand alone websites
468
+ * Its annoying but I've tried everything else as a security method to get Non-Same-Origin sites to talk to each other
469
+ * - MC 2024-04-03
470
+ *
471
+ * @returns {Promise} A promise which resolves when the operation has completed
472
+ */
473
+ async getUserViaEmbedWorkaround() {
474
+ this.debug('INFO', 4, 'Attempting to use getUserViaEmbedWorkaround()');
475
+ let lsState = window.localStorage.getItem('tera.embedUser');
476
+ if (lsState) {
477
+ this.debug('INFO', 4, 'Using localStorage state');
478
+ try {
479
+ lsState = JSON.parse(lsState);
480
+ let $auth = app.service('$auth');
481
+ $auth.state = 'user';
482
+ $auth.ready = true;
483
+ $auth.isLoggedIn = true;
484
+ $auth.user = lsState;
485
+ this.debug('INFO', 3, 'Restored local user state from LocalStorage', { '$auth.user': $auth.user });
486
+ // Force $auth.onUpdate() to run with our partially restored user
487
+ await app.service('$auth').onUpdate($auth.user);
488
+ // Force refresh projects against the new user
489
+ await app.service('$projects').refresh();
490
+ return;
491
+ }
492
+ catch (e) {
493
+ throw new Error(`Failed to decode local dev state - ${e.toString()}`);
494
+ }
495
+ }
496
+ this.debug('INFO', 4, 'localStorage failed - using popup auth instead');
497
+ let focusContent = document.createElement('div');
498
+ focusContent.innerHTML = '<div>Authenticate with <a href="https://tera-tools.com" target="_blank">TERA-tools.com</a></div>'
499
+ + '<div class="mt-2"><a class="btn btn-light">Open Popup...</a></div>';
500
+ // Attach click listner to internal button to re-popup the auth window (in case popups are blocked)
501
+ focusContent.querySelector('a.btn')?.addEventListener('click', () => this.uiWindow(new URL(this.settings.sitePathLogin, this.settings.siteUrl).toString()));
502
+ // Create a deferred promise which will (eventually) resolve when the downstream window signals its ready
503
+ let waitOnWindowAuth = promiseDefer();
504
+ // Create a listener for the message from the downstream window to resolve the promise
505
+ let listenMessages = ({ data }) => {
506
+ this.debug('INFO', 3, 'Recieved message from popup window', { data });
507
+ if (data.TERA && data.action == 'popupUserState' && data.user) { // Signal sent from landing page - we're logged in, yey!
508
+ let $auth = app.service('$auth');
509
+ // Accept user polyfill from opener
510
+ $auth.state = 'user';
511
+ $auth.ready = true;
512
+ $auth.isLoggedIn = true;
513
+ $auth.user = data.user;
514
+ this.debug('INFO', 3, 'Received user auth from popup window', { '$auth.user': $auth.user });
515
+ // Store local copy of user image - this only applies to dev mode (localhost connecting to embed) so we can ignore the security implications here
516
+ Promise.resolve()
517
+ .then(() => this.getUser({
518
+ forceRetry: false, // Avoid loops
519
+ waitPromises: false, // We have a partially resolved state so we don't care about outer promises resolving
520
+ }))
521
+ .then(userState => window.localStorage.setItem('tera.embedUser', JSON.stringify(userState)))
522
+ .then(() => waitOnWindowAuth.resolve()); // Signal we are ready by resolving the deferred promise
523
+ }
524
+ };
525
+ window.addEventListener('message', listenMessages);
526
+ // Go fullscreen, try to open the auth window + prompt the user to retry (if popups are blocked) and wait for resolution
527
+ await this.requestFocus(async () => {
528
+ // Try opening the popup automatically - this will likely fail if the user has popup blocking enabled
529
+ this.uiWindow(new URL(this.settings.sitePathLogin, this.settings.siteUrl).toString());
530
+ // Display a message to the user, offering the ability to re-open the popup if it was originally denied
531
+ this.uiSplat(focusContent, { logo: true });
532
+ this.debug('INFO', 4, 'Begin auth-check deferred wait...');
533
+ return waitOnWindowAuth.promise;
534
+ });
535
+ this.debug('INFO', 4, 'Cleaning up popup auth');
536
+ // Remove message subscription
537
+ window.removeEventListener('message', listenMessages);
538
+ // Disable overlay content
539
+ this.uiSplat(false);
540
+ // ... then refresh the project list as we're likely going to need it
541
+ await app.service('$projects').refresh();
542
+ }
543
+ // }}}
544
+ // Projects - getProject(), getProjects(), requireProject(), selectProject() {{{
545
+ /**
546
+ * Project entry within TERA
547
+ * @class Project
548
+ * @property {String} id The Unique ID of the project
549
+ * @property {String} name The name of the project
550
+ * @property {String} created The creation date of the project as an ISO string
551
+ * @property {Boolean} isOwner Whether the current session user is the owner of the project
552
+ */
553
+ /**
554
+ * Get the currently active project, if any
555
+ *
556
+ * @returns {Promise<Project|null>} The currently active project, if any
557
+ */
558
+ getProject() {
559
+ let $projects = app.service('$projects');
560
+ return $projects.promise()
561
+ .then(() => $projects.active
562
+ ? {
563
+ id: $projects.active.id,
564
+ name: $projects.active.name,
565
+ created: $projects.active.created,
566
+ isOwner: $projects.active.$isOwner,
567
+ }
568
+ : null);
569
+ }
570
+ /**
571
+ * Get a list of projects the current session user has access to
572
+ *
573
+ * @returns {Promise<Array<Project>>} Collection of projects the user has access to
574
+ */
575
+ getProjects() {
576
+ let $projects = app.service('$projects');
577
+ return $projects.promise()
578
+ .then(() => $projects.list.map((project) => ({
579
+ id: project.id,
580
+ name: project.name,
581
+ created: project.created,
582
+ isOwner: project.$isOwner,
583
+ })));
584
+ }
585
+ /**
586
+ * Set the currently active project within TERA
587
+ *
588
+ * @param {Object|String} project The project to set as active - either the full Project object or its ID
589
+ * @returns {Promise} A promise which resolves when the operation has completed
590
+ */
591
+ setActiveProject(project) {
592
+ return app.service('$projects').setActive(project);
593
+ }
594
+ /**
595
+ * Ask the user to select a project from those available - if one isn't already active
596
+ * Note that this function will percist in asking the uesr even if they try to cancel
597
+ *
598
+ * @param {Object} [options] Additional options to mutate behaviour
599
+ * @param {Boolean} [options.autoRequireUser=true] Automatically call `requireUser()` before trying to fetch a list of projects
600
+ * @param {Boolean} [options.autoSetActiveProject=true] After selecting a project set that project as active in TERA
601
+ * @param {String} [options.title="Select a project to work with"] The title of the dialog to display
602
+ * @param {String} [options.noSelectTitle='Select project'] Dialog title when warning the user they need to select something
603
+ * @param {String} [options.noSelectBody='A project needs to be selected to continue'] Dialog body when warning the user they need to select something
604
+ *
605
+ * @returns {Promise<Project>} The active project
606
+ */
607
+ requireProject(options) {
608
+ let settings = {
609
+ autoRequireUser: true,
610
+ autoSetActiveProject: true,
611
+ title: 'Select a project to work with',
612
+ noSelectTitle: 'Select project',
613
+ noSelectBody: 'A project needs to be selected to continue',
614
+ ...options,
615
+ };
616
+ return Promise.resolve()
617
+ .then(() => settings.autoRequireUser && this.requireUser())
618
+ .then(() => this.getProject())
619
+ .then(active => {
620
+ if (active)
621
+ return active; // Use active project
622
+ return new Promise((resolve, reject) => {
623
+ let askProject = () => Promise.resolve()
624
+ .then(() => this.selectProject({
625
+ allowCancel: false,
626
+ }))
627
+ .then(project => resolve(project))
628
+ .catch(e => {
629
+ if (e == 'cancel' || e === 'CANCEL') { // Handle string 'cancel' or rejected 'CANCEL'
630
+ return this.requestFocus(() => app.service('$prompt').dialog({
631
+ title: settings.noSelectTitle,
632
+ body: settings.noSelectBody,
633
+ buttons: ['ok'],
634
+ }))
635
+ .then(() => askProject())
636
+ .catch(reject);
637
+ }
638
+ else {
639
+ reject(e);
640
+ }
641
+ });
642
+ askProject(); // Kick off intial project loop
643
+ })
644
+ .then(async (project) => {
645
+ if (settings.autoSetActiveProject)
646
+ await this.setActiveProject(project);
647
+ return project;
648
+ });
649
+ });
650
+ }
651
+ /**
652
+ * Prompt the user to select a project from those available
653
+ *
654
+ * @param {Object} [options] Additional options to mutate behaviour
655
+ * @param {String} [options.title="Select a project to work with"] The title of the dialog to display
656
+ * @param {Boolean} [options.allowCancel=true] Allow cancelling the operation, will throw `'CANCEL'` if actioned
657
+ * @param {Boolean} [options.setActive=false] Also set the project as active when selected
658
+ *
659
+ * @returns {Promise<Project>} The active project
660
+ */
661
+ selectProject(options) {
662
+ let settings = {
663
+ title: 'Select a project to work with',
664
+ allowCancel: true,
665
+ setActive: false,
666
+ ...options,
667
+ };
668
+ return app.service('$projects').promise()
669
+ .then(() => this.requestFocus(() => app.service('$prompt').dialog({
670
+ title: settings.title,
671
+ component: 'projectsSelect',
672
+ buttons: settings.allowCancel ? ['cancel'] : [],
673
+ })))
674
+ .then((project) => settings.setActive
675
+ ? this.setActiveProject(project)
676
+ .then(() => project)
677
+ : project);
678
+ }
679
+ // }}}
680
+ // Project namespaces - getNamespace(), setNamespace(), listNamespaces() {{{
681
+ /**
682
+ * Get a one-off snapshot of a namespace without mounting it
683
+ * This can be used for simpler apps which don't have their own reactive / observer equivelent
684
+ *
685
+ * @param {String} name The alias of the namespace, this should be alphanumeric + hyphens + underscores
686
+ *
687
+ * @returns {Promise<Object>} A promise which resolves to the namespace POJO state
688
+ */
689
+ getNamespace(name) {
690
+ if (!/^[\w-]+$/.test(name))
691
+ throw new Error('Namespaces must be alphanumeric + hyphens + underscores');
692
+ return app.service('$sync').getSnapshot(`project_namespaces::${app.service('$projects').active.id}::${name}`);
693
+ }
694
+ /**
695
+ * Set (or merge by default) a one-off snapshot over an existing namespace
696
+ * This can be used for simpler apps which don't have their own reactive / observer equivelent and just want to quickly set something
697
+ *
698
+ * @param {String} name The name of the namespace
699
+ * @param {Object} state The state to merge
700
+ * @param {Object} [options] Additional options to mutate behaviour
701
+ * @param {'merge'|'set'} [options.method='merge'] How to handle the state. 'merge' (merge a partial state over the existing namespace state), 'set' (completely overwrite the existing namespace)
702
+ *
703
+ * @returns {Promise<Object>} A promise which resolves to the namespace POJO state
704
+ */
705
+ setNamespace(name, state, options) {
706
+ if (!/^[\w-]+$/.test(name))
707
+ throw new Error('Namespaces must be alphanumeric + hyphens + underscores');
708
+ if (typeof state != 'object')
709
+ throw new Error('State must be an object');
710
+ return app.service('$sync').setSnapshot(`project_namespaces::${app.service('$projects').active.id}::${name}`, state, {
711
+ method: options?.method ?? 'merge',
712
+ });
713
+ }
714
+ /**
715
+ * Return a list of namespaces available to the current project
716
+ *
717
+ * @returns {Promise<Array<Object>>} Collection of available namespaces for the current project
718
+ * @property {String} name The name of the namespace
719
+ */
720
+ listNamespaces() {
721
+ return app.service('$projects').listNamespaces();
722
+ }
723
+ // }}}
724
+ // Project State - getProjectState(), setProjectState(), setProjectStateDefaults() {{{
725
+ /**
726
+ * Return the current, full snapshot state of the active project
727
+ *
728
+ * @param {Object} [options] Additional options to mutate behaviour
729
+ * @param {Boolean} [options.autoRequire=true] Run `requireProject()` automatically before continuing
730
+ * @param {Array<String>} [options.paths] Paths to subscribe to e.g. ['/users/'],
731
+ *
732
+ * @returns {Promise<Object>} The current project state snapshot
733
+ */
734
+ getProjectState(options) {
735
+ let settings = {
736
+ autoRequire: true,
737
+ paths: null,
738
+ ...options,
739
+ };
740
+ return Promise.resolve()
741
+ .then(() => settings.autoRequire && this.requireProject())
742
+ .then(() => app.service('$projects').active);
743
+ }
744
+ /**
745
+ * Set a nested value within the project state
746
+ *
747
+ * Paths can be any valid Lodash.set() value such as:
748
+ * - Dotted notation - e.g. `foo.bar.1.baz`
749
+ * - Array path segments e.g. `['foo', 'bar', 1, 'baz']`
750
+ *
751
+ * Conflict strategies (copied from utils/pathTools @ `set()`)
752
+ * - 'set' / 'overwrite' - Just overwrite any existing value
753
+ * - 'merge' - Merge existing values using Lodash.merge()
754
+ * - 'defaults' - Merge existing values using Lodash.defaultsDeep()
755
+ *
756
+ * @param {String|Array<String>} path The sub-path within the project state to set
757
+ * @param {*} value The value to set, this is set using the conflict strategy
758
+ *
759
+ * @param {Object} [options] Additional options to mutate behaviour
760
+ * @param {'set'} [options.strategy='set'] A PathTools.set strategy to handle existing values, if any
761
+ *
762
+ * @returns {Promise<*>} A promise which resolves to `value` when the operation has been dispatched to the server and saved
763
+ */
764
+ setProjectState(path, value, options) {
765
+ let settings = {
766
+ strategy: 'set',
767
+ ...options,
768
+ };
769
+ if (!app.service('$projects').active)
770
+ throw new Error('No active project');
771
+ if (typeof path != 'string' && !Array.isArray(path))
772
+ throw new Error('setProjectStateDefaults(path, value) - path must be a dotted string or array of path segments');
773
+ if (path === ''
774
+ || (Array.isArray(path)
775
+ && path.length == 0))
776
+ throw new Error('setProjectState path is required');
777
+ pathTools.set(app.service('$projects').active, path, value, {
778
+ strategy: settings.strategy,
779
+ });
780
+ // Sync functionality for the moment but could be async in the future
781
+ return Promise.resolve(value);
782
+ }
783
+ /**
784
+ * Set a nested value within the project state - just like `setProjectState()` but applying the 'defaults' strategy by default
785
+ *
786
+ * @see setProjectState()
787
+ * @param {String|Array<String>} [path] The sub-path within the project state to set, if unspecifed the entire target is used as a target and a save operation is forced
788
+ * @param {*} value The value to set as the default structure
789
+ * @param {Object} [options] Additional options to mutate behaviour, see setProjectState() for the full list of supported options
790
+ *
791
+ * @returns {Promise<*>} A promise which resolves to the eventual input value after defaults have been applied
792
+ */
793
+ setProjectStateDefaults(path, value, options) {
794
+ let settings = { ...options }; // Initialize settings from the third argument if present
795
+ if (!app.service('$projects').active)
796
+ throw new Error('No active project');
797
+ let target = app.service('$projects').active;
798
+ let actualValue;
799
+ if (typeof path == 'string' || Array.isArray(path)) { // Called as (path, value, options?) Set sub-object
800
+ actualValue = value;
801
+ return this.setProjectState(path, actualValue, {
802
+ strategy: 'defaults',
803
+ ...settings, // Pass options from the third argument
804
+ })
805
+ .then(() => pathTools.get(target, path));
806
+ }
807
+ else { // Called as (value, options?) - Populate entire project layout
808
+ actualValue = path; // The first argument is the value
809
+ settings = { ...value }; // The second argument holds the options
810
+ pathTools.defaults(target, actualValue);
811
+ this.debug('INFO', 1, 'setProjectStateDefaults', {
812
+ defaults: actualValue,
813
+ newState: cloneDeep(target),
814
+ });
815
+ return Promise.resolve(target); // Resolve with the modified target state
816
+ }
817
+ }
818
+ /**
819
+ * Force refetching the remote project state into local
820
+ *
821
+ * @returns {Promise} A promise which resolves when the operation has completed
822
+ */
823
+ setProjectStateRefresh() {
824
+ this.debug('INFO', 1, 'Force project state refresh!');
825
+ if (!app.service('$projects').active)
826
+ throw new Error('No active project');
827
+ return app.service('$projects').active.$read({ force: true })
828
+ .then(() => this.debug('INFO', 2, 'Forced project state refresh!', { state: app.service('$projects').active }))
829
+ .then(() => null);
830
+ }
831
+ // }}}
832
+ // Project files - selectProjectFile(), getProjectFiles(), getProjectFile(), createProjectFile(), deleteProjectFile(), setProjectFileContents() {{{
833
+ /**
834
+ * Data structure for a project file
835
+ * @class ProjectFile
836
+ *
837
+ * @property {String} id A UUID string representing the unique ID of the file
838
+ * @property {String} name Relative name path (can contain prefix directories) for the human readable file name
839
+ * @property {Object} parsedName An object representing meta file parts of a file name
840
+ * @property {String} parsedName.basename The filename + extention (i.e. everything without directory name)
841
+ * @property {String} parsedName.filename The file portion of the name (basename without the extension)
842
+ * @property {String} parsedName.ext The extension portion of the name (always lower case)
843
+ * @property {String} parsedName.dirName The directory path portion of the name
844
+ * @property {Date} created A date representing when the file was created
845
+ * @property {Date} modified A date representing when the file was created
846
+ * @property {Date} accessed A date representing when the file was last accessed
847
+ * @property {Number} size Size, in bytes, of the file
848
+ * @property {String} mime The associated mime type for the file
849
+ */
850
+ /**
851
+ * Data structure for a file filter
852
+ * @class FileFilters
853
+ *
854
+ * @property {Boolean} [library=false] Restrict to library files only
855
+ * @property {String} [filename] CSV of @momsfriendlydevco/match expressions to filter the filename by (filenames are the basename sans extension)
856
+ * @property {String} [basename] CSV of @momsfriendlydevco/match expressions to filter the basename by
857
+ * @property {String} [ext] CSV of @momsfriendlydevco/match expressions to filter the file extension by
858
+ */
859
+ /**
860
+ * Prompt the user to select a library to operate on
861
+ *
862
+ * @param {Object} [options] Additional options to mutate behaviour
863
+ * @param {String} [options.title="Select a file"] The title of the dialog to display
864
+ * @param {String|Array<String>} [options.hint] Hints to identify the file to select in array order of preference
865
+ * @param {Boolean} [options.save=false] Set to truthy if saving a new file, UI will adjust to allowing overwrite OR new file name input
866
+ * @param {String} [options.saveFilename] File name to save as, if omitted the hinting system is used otherwise 'My File.unknown' is assumed
867
+ * @param {FileFilters} [options.filters] Optional file filters
868
+ * @param {Boolean} [options.allowUpload=true] Allow uploading new files
869
+ * @param {Boolean} [options.allowRefresh=true] Allow the user to manually refresh the file list
870
+ * @param {Boolean} [options.allowDownloadZip=true] Allow the user to download a Zip of all files
871
+ * @param {Boolean} [options.allowCancel=true] Allow cancelling the operation. Will throw `'CANCEL'` as the promise rejection if acationed
872
+ * @param {Boolean} [options.autoRequire=true] Run `requireProject()` automatically before continuing
873
+ * @param {FileFilters} [options.filter] Optional file filters
874
+ *
875
+ * @returns {Promise<ProjectFile>} The eventually selected file, if in save mode new files are created as stubs
876
+ */
877
+ selectProjectFile(options) {
878
+ let settings = {
879
+ title: 'Select a file',
880
+ hint: null,
881
+ save: false,
882
+ saveFilename: null,
883
+ filters: {},
884
+ allowUpload: true,
885
+ allowRefresh: true,
886
+ allowDownloadZip: true,
887
+ allowCancel: true,
888
+ autoRequire: true,
889
+ ...options,
890
+ };
891
+ return app.service('$projects').promise()
892
+ .then(() => settings.autoRequire && this.requireProject())
893
+ .then(() => this.requestFocus(() => app.service('$prompt').dialog({
894
+ title: settings.title,
895
+ component: settings.save ? 'filesSave' : 'filesOpen',
896
+ componentProps: {
897
+ hint: settings.hint,
898
+ saveFilename: settings.saveFilename,
899
+ allowNavigate: false,
900
+ allowUpload: settings.allowUpload,
901
+ allowRefresh: settings.allowRefresh,
902
+ allowDownloadZip: settings.allowDownloadZip,
903
+ allowVerbs: false,
904
+ cardStyle: false,
905
+ filters: settings.filters,
906
+ },
907
+ componentEvents: {
908
+ fileSave(file) {
909
+ app.service('$prompt').close(true, file);
910
+ },
911
+ fileSelect(file) {
912
+ app.service('$prompt').close(true, file);
913
+ },
914
+ },
915
+ modalDialogClass: 'modal-dialog-lg',
916
+ buttons: settings.allowCancel ? ['cancel'] : [],
917
+ })));
918
+ }
919
+ /**
920
+ * Fetch the files associated with a given project
921
+ *
922
+ * @param {Object} options Options which mutate behaviour
923
+ * @param {Boolean} [options.autoRequire=true] Run `requireProject()` automatically before continuing
924
+ * @param {Boolean} [options.lazy=true] If true, use the fastest method to retrieve the file list such as the cache. If false, force a refresh each time
925
+ * @param {Boolean} [options.meta=true] Pull meta information for each file entity
926
+ *
927
+ * @returns {Promise<Array<ProjectFile>>} A collection of project files for the given project
928
+ */
929
+ getProjectFiles(options) {
930
+ let settings = {
931
+ autoRequire: true,
932
+ lazy: true,
933
+ meta: true,
934
+ ...options,
935
+ };
936
+ return Promise.resolve()
937
+ .then(() => app.service('$projects').promise())
938
+ .then(() => settings.autoRequire && this.requireProject())
939
+ .then(() => app.service('$projects').activeFiles.length == 0 // If we have no files in the cache
940
+ || !settings.lazy // OR lazy/cache use is disabled
941
+ ? app.service('$projects').refreshFiles({
942
+ lazy: false,
943
+ })
944
+ : app.service('$projects').activeFiles // Otherwise use file cache
945
+ );
946
+ }
947
+ /**
948
+ * Fetch a project file by its name
949
+ *
950
+ * @param {String} name The name + relative directory path component
951
+ *
952
+ * @param {Object|String} [options] Additional options to mutate behaviour, if a string is given `options.subkey` is assumed
953
+ * @param {String} [options.subkey] If specified only the extracted subkey is returned rather than the full object
954
+ * @param {Boolean} [options.cache=true] Use the existing file cache if possible, set to false to force a refresh of files from the server first
955
+ *
956
+ * @returns {Promise<ProjectFile>} The eventual fetched ProjectFile (or requested subkey)
957
+ */
958
+ getProjectFile(name, options) {
959
+ let settings = {
960
+ subkey: null,
961
+ cache: true,
962
+ ...(typeof options == 'string' ? { subkey: options } : options),
963
+ };
964
+ return Promise.resolve()
965
+ .then(() => !app.service('$projects').activeFiles // If active files is null/undefined
966
+ || app.service('$projects').activeFiles.length == 0 // OR we have no files in the cache
967
+ || !settings.cache // OR caching is disabled
968
+ ? app.service('$projects').refreshFiles({
969
+ lazy: false,
970
+ })
971
+ : app.service('$projects').activeFiles // Otherwise use file cache
972
+ )
973
+ .then((files) => files.find((file) => file.name == name))
974
+ .then((file) => file && settings.subkey ? file[settings.subkey] : file);
975
+ }
976
+ /**
977
+ * Fetch the raw contents of a file by its ID
978
+ *
979
+ * @param {String} [id] File ID to retrieve the contents of
980
+ *
981
+ * @param {Object} [options] Additional options to mutate behaviour
982
+ * @param {'blob'|'json'} [options.format='blob'] The format to retrieve the file in. If `json` the raw output is run via JSON.parse() first
983
+ *
984
+ * @returns {*} The file contents in the requested format
985
+ */
986
+ getProjectFileContents(id, options) {
987
+ let settings = {
988
+ format: 'blob',
989
+ ...options,
990
+ };
991
+ return app.service('$supabase').fileGet(app.service('$projects').decodeFilePath(id), {
992
+ json: settings.format == 'json',
993
+ toast: false,
994
+ });
995
+ }
996
+ /**
997
+ * Create a new file
998
+ * This creates an empty file which can then be written to
999
+ * This function also forces a local file list cache update
1000
+ *
1001
+ * @param {String} name The name + relative directory path component
1002
+ * @returns {Promise<ProjectFile>} The eventual ProjectFile created
1003
+ */
1004
+ createProjectFile(name) {
1005
+ return Promise.resolve()
1006
+ .then(() => app.service('$supabase').fileUpload(app.service('$projects').convertRelativePath(name), {
1007
+ file: new Blob([''], { type: 'text/plain' }),
1008
+ mode: 'encoded',
1009
+ overwrite: false,
1010
+ multiple: false,
1011
+ toast: false,
1012
+ transcoders: false,
1013
+ }))
1014
+ .then(() => this.getProjectFile(name, {
1015
+ cache: false, // Force cache to update, as this is a new file
1016
+ }))
1017
+ .then((file) => file || Promise.reject(`Could not create new file "${name}"`));
1018
+ }
1019
+ /**
1020
+ * Remove a project file by its ID
1021
+ *
1022
+ * @param {String} id The File ID to remove
1023
+ *
1024
+ * @returns {Promise} A promise which resolves when the operation has completed
1025
+ */
1026
+ deleteProjectFile(id) {
1027
+ return app.service('$supabase').fileRemove(app.service('$projects').decodeFilePath(id))
1028
+ .then(() => app.service('$projects').refreshFiles({
1029
+ lazy: false,
1030
+ }))
1031
+ .then(() => null);
1032
+ }
1033
+ /**
1034
+ * Save (or overwrite) a file within a project
1035
+ *
1036
+ * @param {String|ProjectFile} [id] ProjectFile or ID of the same to overwrite, if omitted a file is prompted for
1037
+ * @param {File|Blob|FormData|Object|Array} contents The new file contents
1038
+ * @param {Object} [options] Additional options to mutate behaviour
1039
+ * @param {String|ProjectFile} [options.id] Alternate method to specify the file ID to save as, if omitted one will be prompted for
1040
+ * @param {Boolean} [options.autoRequire=true] Run `requireProject()` automatically before continuing
1041
+ * @param {String|Array<String>} [options.hint] Hint(s) to store against the library. Generally corresponds to the current operation being performed - e.g. 'deduped'
1042
+ * @param {String} [options.filename] Suggested filename if `id` is unspecified
1043
+ * @param {String} [options.title='Save citation library'] Dialog title if `id` is unspecified and a prompt is necessary
1044
+ * @param {Object} [options.meta] Optional meta data to merge into the file data
1045
+ *
1046
+ * @returns {Promise} A promise which will resolve when the write operation has completed
1047
+ */
1048
+ setProjectFileContents(id, contents, options) {
1049
+ // Argument Mangling Logic (Simplified)
1050
+ let fileId = null;
1051
+ let fileContents;
1052
+ let mergedOptions;
1053
+ if (typeof id === 'string') {
1054
+ fileId = id;
1055
+ fileContents = contents;
1056
+ mergedOptions = { ...options };
1057
+ }
1058
+ else if (id !== null && typeof id === 'object' && !(id instanceof Blob) && !(id instanceof File) && !(id instanceof FormData) && !Array.isArray(id)) {
1059
+ // Assuming called as (optionsObject)
1060
+ mergedOptions = { ...id };
1061
+ fileId = mergedOptions.id ?? null;
1062
+ fileContents = mergedOptions.contents;
1063
+ }
1064
+ else {
1065
+ // Assuming called as (contents, options)
1066
+ fileId = options?.id ?? null; // Check options for id if provided
1067
+ fileContents = id; // First arg is contents
1068
+ mergedOptions = { ...contents }; // Second arg is options
1069
+ }
1070
+ if (fileContents === undefined)
1071
+ throw new Error('setProjectFileContents requires contents to save.');
1072
+ let settings = {
1073
+ id: fileId,
1074
+ autoRequire: true,
1075
+ hint: null,
1076
+ filename: null,
1077
+ title: 'Save file',
1078
+ meta: null,
1079
+ ...mergedOptions, // Apply options derived from mangling
1080
+ };
1081
+ return Promise.resolve()
1082
+ .then(() => {
1083
+ settings.autoRequire && this.requireProject();
1084
+ })
1085
+ .then(() => {
1086
+ if (settings.id) {
1087
+ // Validate the provided ID exists? Optional, but good practice.
1088
+ // For now, just return it assuming it's valid.
1089
+ return Promise.resolve(settings.id);
1090
+ }
1091
+ // Prompt for a save filename
1092
+ return this.selectProjectFile({
1093
+ title: settings.title,
1094
+ save: true,
1095
+ hint: settings.hint,
1096
+ saveFilename: settings.filename,
1097
+ autoRequire: false, // Handled above anyway
1098
+ })
1099
+ .then((file) => {
1100
+ if (!file || !file.id)
1101
+ throw new Error('File selection cancelled or failed.');
1102
+ return file.id; // Return the selected file ID
1103
+ });
1104
+ })
1105
+ .then((resolvedFileId) => {
1106
+ settings.id = resolvedFileId; // Update settings.id with the resolved/validated ID
1107
+ if (!settings.id)
1108
+ throw new Error("Could not determine file ID to save to."); // Final check
1109
+ return app.service('$supabase').fileSet(app.service('$projects').decodeFilePath(settings.id), fileContents, {
1110
+ overwrite: true,
1111
+ toast: false,
1112
+ // TODO: Handle settings.meta if $supabase.fileSet supports it
1113
+ });
1114
+ })
1115
+ .then(() => null);
1116
+ }
1117
+ // }}}
1118
+ // Project Libraries - selectProjectLibrary(), getProjectLibrary(), setProjectLibrary() {{{
1119
+ /**
1120
+ * Prompt the user to select a library to operate on and return a array of references in a given format
1121
+ *
1122
+ * @param {Object} [options] Additional options to mutate behaviour - see `getProjectLibrary()` for parent list of options
1123
+ * @param {String} [options.title="Select a citation library"] The title of the dialog to display
1124
+ * @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'
1125
+ * @param {Boolean} [options.allowUpload=true] Allow uploading new files
1126
+ * @param {Boolean} [options.allowRefresh=true] Allow the user to manually refresh the file list
1127
+ * @param {Boolean} [options.allowDownloadZip=true] Allow the user to download a Zip of all files
1128
+ * @param {Boolean} [options.allowCancel=true] Allow cancelling the operation. Will throw `'CANCEL'` as the promise rejection if acationed
1129
+ * @param {Boolean} [options.autoRequire=true] Run `requireProject()` automatically before continuing
1130
+ * @param {FileFilters} [options.filters] Optional file filters, defaults to citation library selection only
1131
+ *
1132
+ * @returns {Promise<Array<Ref>>} A collection of references from the selected file
1133
+ */
1134
+ selectProjectLibrary(options) {
1135
+ let settings = {
1136
+ title: 'Select a citation library',
1137
+ hint: null,
1138
+ allowUpload: true,
1139
+ allowRefresh: true,
1140
+ allowDownloadZip: true,
1141
+ allowCancel: true,
1142
+ autoRequire: true,
1143
+ filters: {
1144
+ library: true,
1145
+ ...(options?.filters ?? {}), // Use filters from options if provided
1146
+ },
1147
+ ...options,
1148
+ };
1149
+ return app.service('$projects').promise()
1150
+ .then(() => this.selectProjectFile(settings)) // Pass merged settings
1151
+ .then((selectedFile) => {
1152
+ if (!selectedFile || !selectedFile.id)
1153
+ throw new Error('Library selection failed or was cancelled.');
1154
+ // Pass relevant options down to getProjectLibrary
1155
+ return this.getProjectLibrary(selectedFile.id, settings);
1156
+ });
1157
+ }
1158
+ /**
1159
+ * Fetch + convert a project file into a library of citations
1160
+ *
1161
+ * @param {String} id File ID to read
1162
+ *
1163
+ * @param {Object} [options] Additional options to mutate behaviour
1164
+ * @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)
1165
+ * @param {Boolean} [options.autoRequire=true] Run `requireProject()` automatically before continuing
1166
+ * @param {Function} [options.filter] Optional async file filter, called each time as `(File:ProjectFile)`
1167
+ * @param {Function} [options.find] Optional async final stage file filter to reduce all candidates down to one subject file
1168
+ *
1169
+ * @returns {Promise<Array<Ref>>|Promise<*>} A collection of references (default bevahiour) or a whatever format was requested
1170
+ */
1171
+ getProjectLibrary(id, options) {
1172
+ let settings = {
1173
+ format: 'pojo',
1174
+ autoRequire: true,
1175
+ filter: (file) => true, // Default filter
1176
+ find: (files) => files.at(0), // Default find
1177
+ ...options,
1178
+ };
1179
+ let filePath = app.service('$projects').decodeFilePath(id);
1180
+ return Promise.resolve()
1181
+ .then(() => settings.autoRequire && this.requireProject())
1182
+ .then(() => app.service('$supabase').fileGet(filePath, {
1183
+ toast: false,
1184
+ }))
1185
+ .then(blob => {
1186
+ if (!blob)
1187
+ throw new Error(`File not found or empty: ${filePath}`);
1188
+ switch (settings.format) {
1189
+ // NOTE: Any updates to the format list should also extend setProjectLibrary()
1190
+ case 'pojo':
1191
+ return Reflib.uploadFile({
1192
+ file: new File([blob], app.service('$supabase')._parsePath(filePath).basename),
1193
+ });
1194
+ case 'blob':
1195
+ return blob;
1196
+ case 'file':
1197
+ return new File([blob], app.service('$supabase')._parsePath(filePath).basename);
1198
+ default:
1199
+ throw new Error(`Unsupported library format "${settings.format}"`);
1200
+ }
1201
+ });
1202
+ }
1203
+ /**
1204
+ * Save back a citation library from some input
1205
+ *
1206
+ * @param {String} [id] File ID to save back to, if omitted a file will be prompted for
1207
+ * @param {Array<RefLibRef>|Blob|File} [refs] Collection of references for the selected library or the raw Blob/File
1208
+ *
1209
+ * @param {Object} [options] Additional options to mutate behaviour
1210
+ * @param {String} [options.id] Alternate method to specify the file ID to save as, if omitted one will be prompted for
1211
+ * @param {Array<RefLibRef>|Blob|File} [options.refs] Alternate method to specify the refs to save as an array or raw Blob/File
1212
+ * @param {String} [options.format='auto'] Input format used. ENUM: 'auto' (try to figure it out from context), 'pojo' (JS array of RefLib references), 'blob' (raw JS Blob object), 'file' (named JS File object)
1213
+ * @param {Boolean} [options.autoRequire=true] Run `requireProject()` automatically before continuing
1214
+ * @param {String|Array<String>} [options.hint] Hint(s) to store against the library. Generally corresponds to the current operation being performed - e.g. 'deduped'
1215
+ * @param {String} [options.filename] Suggested filename if `id` is unspecified
1216
+ * @param {String} [options.title='Save citation library'] Dialog title if `id` is unspecified and a prompt is necessary
1217
+ * @param {Boolean} [options.overwrite=true] Allow existing file upsert
1218
+ * @param {Object} [options.meta] Optional meta data to merge into the file data
1219
+ *
1220
+ * @returns {Promise} A promise which resolves when the save operation has completed
1221
+ */
1222
+ setProjectLibrary(id, refs, options) {
1223
+ // Argument Mangling Logic (Simplified)
1224
+ let fileId = null;
1225
+ let libraryRefs;
1226
+ let mergedOptions;
1227
+ if (typeof id === 'string') {
1228
+ fileId = id;
1229
+ libraryRefs = refs;
1230
+ mergedOptions = { ...options };
1231
+ }
1232
+ else if (id !== null && typeof id === 'object' && !(id instanceof Blob) && !(id instanceof File) && !Array.isArray(id)) {
1233
+ // Assuming called as (optionsObject)
1234
+ mergedOptions = { ...id };
1235
+ fileId = mergedOptions.id ?? null;
1236
+ libraryRefs = mergedOptions.refs;
1237
+ }
1238
+ else {
1239
+ // Assuming called as (refs, options)
1240
+ fileId = options?.id ?? null; // Check options for id if provided
1241
+ libraryRefs = id; // First arg is refs
1242
+ mergedOptions = { ...refs }; // Second arg is options
1243
+ }
1244
+ if (libraryRefs === undefined)
1245
+ throw new Error('setProjectLibrary requires refs to save.');
1246
+ let settings = {
1247
+ id: fileId,
1248
+ refs: libraryRefs,
1249
+ format: 'auto',
1250
+ autoRequire: true,
1251
+ hint: null,
1252
+ filename: null,
1253
+ title: 'Save citation library',
1254
+ overwrite: true,
1255
+ meta: null,
1256
+ ...mergedOptions // Apply options derived from mangling
1257
+ };
1258
+ let filePath; // Eventual Supabase path to use
1259
+ return Promise.resolve()
1260
+ .then(() => settings.autoRequire && this.requireProject())
1261
+ .then(() => {
1262
+ if (settings.id) {
1263
+ // Optional: Validate settings.id exists?
1264
+ return Promise.resolve(settings.id);
1265
+ }
1266
+ // Prompt for a save filename
1267
+ return this.selectProjectFile({
1268
+ title: settings.title,
1269
+ save: true,
1270
+ hint: settings.hint,
1271
+ saveFilename: settings.filename,
1272
+ filters: {
1273
+ library: true,
1274
+ },
1275
+ autoRequire: false, // Handled above anyway
1276
+ })
1277
+ .then((file) => {
1278
+ if (!file || !file.id)
1279
+ throw new Error('File selection cancelled or failed.');
1280
+ return file.id; // Return selected file ID
1281
+ });
1282
+ })
1283
+ .then((resolvedFileId) => {
1284
+ settings.id = resolvedFileId; // Update settings.id
1285
+ if (!settings.id)
1286
+ throw new Error("Could not determine file ID to save library to.");
1287
+ filePath = app.service('$projects').decodeFilePath(settings.id);
1288
+ })
1289
+ .then(() => {
1290
+ // Mutate settings.refs -> Blob or File format needed by Supabase
1291
+ if (settings.format == 'auto') {
1292
+ settings.format =
1293
+ Array.isArray(settings.refs) ? 'pojo'
1294
+ : settings.refs instanceof Blob ? 'blob'
1295
+ : settings.refs instanceof File ? 'file'
1296
+ : (() => { throw new Error('Unable to guess input format for setLibaryFormat()'); })();
1297
+ }
1298
+ switch (settings.format) {
1299
+ // NOTE: Any updates to the format list should also extend getProjectLibrary()
1300
+ case 'pojo': // Use as is
1301
+ if (!Array.isArray(settings.refs))
1302
+ throw new Error('setProjectLibrary() with format=pojo requires an array of references');
1303
+ // Get Reflib to encode the POJO into a Blob/File
1304
+ return Reflib.downloadFile(settings.refs, {
1305
+ filename: app.service('$supabase')._parsePath(filePath).basename,
1306
+ promptDownload: false, // Just return the fileBlob we hand to Supabase
1307
+ });
1308
+ case 'blob':
1309
+ if (!(settings.refs instanceof Blob))
1310
+ throw new Error("setProjectLibrary({format: 'blob'} but non-Blob provided as `refs`");
1311
+ return new File([settings.refs], app.service('$supabase')._parsePath(filePath).basename);
1312
+ case 'file':
1313
+ if (!(settings.refs instanceof File))
1314
+ throw new Error("setProjectLibrary({format: 'file'} but non-File provided as `refs`");
1315
+ return settings.refs;
1316
+ default:
1317
+ throw new Error(`Unsupported library format "${settings.format}"`);
1318
+ }
1319
+ })
1320
+ .then((fileBlob) => app.service('$supabase').fileUpload(filePath, {
1321
+ file: fileBlob,
1322
+ overwrite: settings.overwrite,
1323
+ mode: 'encoded',
1324
+ // TODO: Handle settings.meta if $supabase.fileUpload supports it
1325
+ }))
1326
+ .then(() => null);
1327
+ }
1328
+ // }}}
1329
+ // Project Logging - projectLog() {{{
1330
+ /**
1331
+ * Create a log entry for the currently active project
1332
+ *
1333
+ * The required log object can be of various forms. See https://tera-tools.com/api/logs.json for the full list
1334
+ *
1335
+ * @param {Object} log The log entry to create
1336
+ * @returns {Promise} A promise which resolves when the operation has completed
1337
+ */
1338
+ projectLog(log) {
1339
+ return app.service('$projects').log(log);
1340
+ }
1341
+ // }}}
1342
+ // Webpages - setPage() {{{
1343
+ /**
1344
+ * Set an active tools URL or other context information so that it survives a refresh
1345
+ * This only really makes a difference to tools within the tera-tools.com site where the tool is working as an embed
1346
+ *
1347
+ * @param {Object|String} options Context information about the page, if this is a string, its assumed to popupate `url`
1348
+ * @param {String} [options.path] The URL path segment to restore on next refresh
1349
+ * @param {String} [options.title] The page title associated with the path
1350
+ */
1351
+ setPage(options) {
1352
+ app.service('$projects').setPage(options);
1353
+ }
1354
+ // }}}
1355
+ // Init - constructor(), init() {{{
1356
+ /**
1357
+ * Setup the TERA-fy client singleton
1358
+ *
1359
+ * @param {Object} [options] Additional options to merge into `settings`
1360
+ */
1361
+ constructor(options) {
1362
+ /**
1363
+ * Various settings to configure behaviour
1364
+ *
1365
+ * @type {Object}
1366
+ * @property {Boolean} devMode Operate in devMode - i.e. force outer refresh when encountering an existing TeraFy instance
1367
+ * @property {Number} verbosity Verbosity level, the higher the more chatty TeraFY will be. Set to zero to disable all `debug()` call output
1368
+ * @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
1369
+ * @property {String} restrictOrigin URL to restrict communications to
1370
+ * @property {String} projectId The project to use as the default reference when calling various APIs
1371
+ * @property {Number} serverMode The current server mode matching `SERVERMODE_*`
1372
+ * @property {String} siteUrl The main site absolute URL
1373
+ * @property {String} sitePathLogin Either an absolute URL or the relative path (taken from `siteUrl`) when trying to log in the user
1374
+ * @property {Boolean} embedWorkaround Try to use `getUserViaEmbedWorkaround()` to force a login via popup if the user is running in local mode (see function docs for more details). This is toggled to false after the first run
1375
+ */
1376
+ this.settings = {
1377
+ devMode: false,
1378
+ verbosity: 9,
1379
+ restrictOrigin: '*',
1380
+ subscribeTimeout: 2000,
1381
+ projectId: null,
1382
+ serverMode: 0,
1383
+ siteUrl: window.location.href,
1384
+ sitePathLogin: '/login',
1385
+ embedWorkaround: true,
1386
+ };
1387
+ /**
1388
+ * MessageEvent context
1389
+ * Only available if the context was created via `createContext()`
1390
+ *
1391
+ * @type {MessageEvent}
1392
+ */
1393
+ this.messageEvent = null;
1394
+ /**
1395
+ * Listening postboxes, these correspond to outgoing message IDs that expect a response
1396
+ */
1397
+ this.acceptPostboxes = {};
1398
+ this._uiProgress = {
1399
+ options: null,
1400
+ promise: null,
1401
+ };
1402
+ Object.assign(this.settings, options);
1403
+ }
1404
+ /**
1405
+ * Initialize the browser listener
1406
+ */
1407
+ init() {
1408
+ // Ensure this only runs in a browser context
1409
+ if (typeof window !== 'undefined' && typeof globalThis !== 'undefined') {
1410
+ globalThis.addEventListener('message', this.acceptMessage.bind(this));
1411
+ this.debug('INFO', 1, 'Ready');
1412
+ }
1413
+ }
1414
+ // }}}
1415
+ // UI - uiAlert(), uiConfirm(), uiProgress(), uiPrompt(), uiThrow(), uiWindow(), uiSplat() {{{
1416
+ /**
1417
+ * Display simple text within TERA
1418
+ *
1419
+ * @param {String} [text] Text to display, if specified this populates `options.body`
1420
+ *
1421
+ * @param {Object} [options] Additional options to mutate behaviour
1422
+ * @param {String} [options.body="Alert!"] The body text to display
1423
+ * @param {Boolean} [options.isHtml=false] If falsy the text is rendered as plain-text otherwise it will be assumed as HTML content
1424
+ * @param {String} [options.title='TERA'] The title of the alert box
1425
+ * @param {'ok'|false} [options.buttons='ok'] Button set to use or falsy to disable
1426
+ *
1427
+ * @returns {Promise} A promise which resolves when the alert has been dismissed
1428
+ */
1429
+ uiAlert(text, options) {
1430
+ let settings = {
1431
+ body: 'Alert!',
1432
+ isHtml: false,
1433
+ title: 'TERA',
1434
+ buttons: 'ok',
1435
+ ...(typeof text == 'string' ? { body: text, ...options }
1436
+ : typeof text == 'object' ? text
1437
+ : options),
1438
+ };
1439
+ return this.requestFocus(() => app.service('$prompt').dialog({
1440
+ title: settings.title,
1441
+ body: settings.body,
1442
+ buttons: settings.buttons == 'ok' ? ['ok']
1443
+ : settings.buttons === false ? []
1444
+ : settings.buttons, // Allow passing custom button arrays
1445
+ isHtml: settings.isHtml,
1446
+ dialogClose: 'resolve', // Resolve promise when closed
1447
+ }));
1448
+ }
1449
+ /**
1450
+ * Present a simple ok/cancel dialog to the user
1451
+ *
1452
+ * @param {String} [text] Text to display, if specified this populates `options.body`
1453
+ *
1454
+ * @param {Object} [options] Additional options to mutate behaviour
1455
+ * @param {String} [options.body="Confirm?"] The body text to display
1456
+ * @param {Boolean} [options.isHtml=false] If falsy the text is rendered as plain-text otherwise it will be assumed as HTML content
1457
+ * @param {String} [options.title='TERA'] The title of the confirmation box
1458
+ *
1459
+ * @returns {Promise} A promise which resolves with `Promise.resolve('OK')` or rejects with `Promise.reject('CANCEL')`
1460
+ */
1461
+ uiConfirm(text, options) {
1462
+ let settings = {
1463
+ body: 'Confirm?',
1464
+ isHtml: false,
1465
+ title: 'TERA',
1466
+ ...(typeof text == 'string' ? { body: text, ...options }
1467
+ : typeof text == 'object' ? text
1468
+ : options),
1469
+ };
1470
+ return this.requestFocus(() => app.service('$prompt').dialog({
1471
+ title: settings.title,
1472
+ body: settings.body,
1473
+ isHtml: settings.isHtml,
1474
+ buttons: [
1475
+ {
1476
+ title: 'OK',
1477
+ class: 'btn btn-success',
1478
+ click: 'resolve', // Resolve promise with default value (usually true or button index)
1479
+ },
1480
+ {
1481
+ title: 'Cancel',
1482
+ class: 'btn btn-danger',
1483
+ click: 'reject', // Reject promise
1484
+ },
1485
+ ],
1486
+ })
1487
+ .then(() => 'OK') // Resolve with 'OK' if OK button clicked
1488
+ .catch(() => Promise.reject('CANCEL')) // Reject with 'CANCEL' if Cancel button clicked or closed
1489
+ );
1490
+ }
1491
+ /**
1492
+ * Trigger a fatal error, killing the outer TERA site
1493
+ *
1494
+ * @function uiPanic
1495
+ * @param {String} [text] Text to display
1496
+ */
1497
+ uiPanic(text) {
1498
+ // Ensure window context exists
1499
+ if (typeof window !== 'undefined' && typeof window.panic === 'function') {
1500
+ window.panic(text);
1501
+ }
1502
+ else {
1503
+ console.error("PANIC (window.panic not available):", text);
1504
+ // Fallback behavior if window.panic doesn't exist
1505
+ alert(`PANIC: ${text}`);
1506
+ }
1507
+ }
1508
+ /**
1509
+ * Display, update or dispose of windows for long running tasks
1510
+ * All options are cumulative - i.e. they are merged with other options previously provided
1511
+ *
1512
+ * @param {Object|Boolean} [options] Additional options to mutate behaviour, if boolean false `close: true` is assumed
1513
+ * @param {String} [options.body=''] Window body text
1514
+ * @param {Boolean} [options.bodyHtml=false] If truthy, treat the body as HTML
1515
+ * @param {String} [options.title='TERA'] Window title, can only be set on the initial call
1516
+ * @param {Boolean} [options.close=false] Close the existing dialog, if true the dialog is disposed and options reset
1517
+ * @param {Number} [options.progress] The current progress of the task being conducted, this is assumed to be a value less than `progressMax`
1518
+ * @param {Number} [options.progressMax] The maximum value that the progress can be
1519
+ *
1520
+ * @returns {Promise} A promise which resolves when the dialog has been updated
1521
+ */
1522
+ uiProgress(options) {
1523
+ let currentOptions = options === false ? { close: true } : options || {};
1524
+ if (currentOptions.close) { // Asked to close the dialog
1525
+ const closePromise = this._uiProgress.promise
1526
+ ? app.service('$prompt').close(true) // Assume close takes 1 arg
1527
+ : Promise.resolve();
1528
+ return closePromise.then(() => {
1529
+ this._uiProgress.options = null;
1530
+ this._uiProgress.promise = null;
1531
+ });
1532
+ }
1533
+ else if (!this._uiProgress.promise) { // Not created the dialog yet
1534
+ // Initialize options if they don't exist
1535
+ this._uiProgress.options = reactive({
1536
+ body: '',
1537
+ bodyHtml: false,
1538
+ title: 'TERA',
1539
+ close: false,
1540
+ progress: 0,
1541
+ progressMax: 0,
1542
+ backdrop: true, // Default backdrop
1543
+ ...currentOptions, // Apply initial options
1544
+ });
1545
+ this._uiProgress.promise = this.requestFocus(() => app.service('$prompt').dialog({
1546
+ title: this._uiProgress.options?.title,
1547
+ backdrop: this._uiProgress.options?.backdrop ?? true,
1548
+ component: 'uiProgress',
1549
+ componentProps: this._uiProgress.options, // Pass reactive object
1550
+ closeable: false,
1551
+ keyboard: false,
1552
+ }));
1553
+ return Promise.resolve(); // Dialog creation is async via requestFocus
1554
+ }
1555
+ else if (this._uiProgress.options) { // Dialog exists, merge options
1556
+ Object.assign(this._uiProgress.options, currentOptions);
1557
+ return Promise.resolve(); // Updates handled by reactivity
1558
+ }
1559
+ else {
1560
+ // Should not happen if initialized correctly
1561
+ console.warn("uiProgress called in unexpected state");
1562
+ return Promise.resolve();
1563
+ }
1564
+ }
1565
+ /**
1566
+ * Prompt the user for an input, responding with a Promisable value
1567
+ *
1568
+ * @param {String} [text] Text to display, if specified this populates `options.body`
1569
+ *
1570
+ * @param {Object} [options] Additional options to mutate behaviour
1571
+ * @param {String} [options.body] Optional additional body text
1572
+ * @param {Boolean} [options.isHtml=false] If truthy, treat the body as HTML
1573
+ * @param {String} [options.value] Current or default value to display pre-filled
1574
+ * @param {String} [options.title='Input required'] The dialog title to display
1575
+ * @param {String} [options.placeholder] Optional placeholder text
1576
+ * @param {Boolean} [options.required=true] Treat nullish or empty inputs as a cancel operation
1577
+ *
1578
+ * @returns {Promise<*>} Either the eventual user value or a throw with `Promise.reject('CANCEL')`
1579
+ */
1580
+ uiPrompt(text, options) {
1581
+ let settings = {
1582
+ body: '',
1583
+ isHtml: false,
1584
+ title: 'Input required',
1585
+ value: '',
1586
+ placeholder: '',
1587
+ required: true,
1588
+ ...(typeof text == 'string' ? { body: text, ...options }
1589
+ : typeof text == 'object' ? text
1590
+ : options),
1591
+ };
1592
+ return this.requestFocus(() => app.service('$prompt').dialog({
1593
+ title: settings.title,
1594
+ closable: true, // Allow closing via backdrop click (will reject)
1595
+ component: 'UiPrompt',
1596
+ componentProps: {
1597
+ body: settings.body,
1598
+ isHtml: settings.isHtml,
1599
+ placeholder: settings.placeholder,
1600
+ value: settings.value,
1601
+ },
1602
+ buttons: [
1603
+ {
1604
+ class: 'btn btn-success',
1605
+ icon: 'fas fa-check',
1606
+ title: 'Ok',
1607
+ click() {
1608
+ // Assuming 'this' is the component instance with 'newValue' property
1609
+ // And $prompt service is available globally via 'app'
1610
+ app.service('$prompt').close(true, this.newValue); // Use app.$prompt.close
1611
+ },
1612
+ },
1613
+ 'cancel', // Standard cancel button that rejects
1614
+ ],
1615
+ }))
1616
+ .then((answer) => {
1617
+ // Check if the answer is non-empty or if required is false
1618
+ if (answer || !settings.required) {
1619
+ return answer;
1620
+ }
1621
+ else {
1622
+ // If required and answer is empty/nullish, treat as cancel
1623
+ return Promise.reject('CANCEL');
1624
+ }
1625
+ })
1626
+ // Catch rejection from 'cancel' button or closing the dialog
1627
+ .catch(() => Promise.reject('CANCEL'));
1628
+ }
1629
+ /**
1630
+ * Catch an error using the TERA error handler
1631
+ *
1632
+ * @param {Error|Object|String} error Error to handle, generally an Error object but can be a POJO or a scalar string
1633
+ *
1634
+ * @returns {Void} This function is fatal
1635
+ */
1636
+ uiThrow(error) {
1637
+ return this.requestFocus(() => app.service('$errors').catch(error));
1638
+ }
1639
+ /**
1640
+ * Open a popup window containing a new site
1641
+ *
1642
+ * @param {String} url The URL to open
1643
+ *
1644
+ * @param {Object} [options] Additional options to mutate behaviour
1645
+ * @param {Number} [options.width=500] The desired width of the window
1646
+ * @param {Number} [options.height=600] The desired height of the window
1647
+ * @param {Boolean} [options.center=true] Attempt to center the window on the screen
1648
+ * @param {Object} [options.permissions] Additional permissions to set on opening, defaults to a suitable set of permission for popups (see code)
1649
+ *
1650
+ * @returns {WindowProxy} The opened window object (if `noopener` is not set in permissions)
1651
+ */
1652
+ uiWindow(url, options) {
1653
+ // Ensure this runs only in browser context
1654
+ if (typeof window === 'undefined' || typeof screen === 'undefined')
1655
+ return null;
1656
+ let settings = {
1657
+ width: 500,
1658
+ height: 600,
1659
+ center: true,
1660
+ permissions: {
1661
+ popup: true,
1662
+ location: false,
1663
+ menubar: false,
1664
+ status: false,
1665
+ scrollbars: false,
1666
+ },
1667
+ ...options,
1668
+ };
1669
+ const urlString = typeof url === 'string' ? url : url.toString();
1670
+ const features = Object.entries({
1671
+ ...settings.permissions,
1672
+ width: settings.width,
1673
+ height: settings.height,
1674
+ ...(settings.center && {
1675
+ left: screen.width / 2 - settings.width / 2,
1676
+ top: screen.height / 2 - settings.height / 2,
1677
+ }),
1678
+ })
1679
+ .map(([key, val]) => `${key}=${typeof val === 'boolean' ? (val ? 'yes' : 'no') : val}`) // Use yes/no for booleans
1680
+ .join(',');
1681
+ return window.open(urlString, '_blank', features);
1682
+ }
1683
+ /**
1684
+ * Display HTML content full-screen within TERA
1685
+ * This function is ideally called within a requestFocus() wrapper
1686
+ *
1687
+ * @param {DOMElement|String|false} content Either a prepared DOM element or string to compile, set to falsy to remove existing content
1688
+ *
1689
+ * @param {Object} [options] Additional options to mutate behaviour
1690
+ * @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
1691
+ */
1692
+ uiSplat(content, options) {
1693
+ // Ensure this runs only in browser context
1694
+ if (typeof window === 'undefined' || typeof document === 'undefined')
1695
+ return;
1696
+ let settings = {
1697
+ logo: false,
1698
+ ...options,
1699
+ };
1700
+ // Remove existing splat first
1701
+ const existingSplat = globalThis.document.body.querySelector('.tera-fy-uiSplat');
1702
+ if (existingSplat) {
1703
+ existingSplat.remove();
1704
+ }
1705
+ if (!content) { // If content is false, just remove and return
1706
+ return;
1707
+ }
1708
+ let compiledContent;
1709
+ if (typeof content == 'string') {
1710
+ let el = document.createElement('div');
1711
+ el.innerHTML = content;
1712
+ // If the string contained multiple top-level elements, wrap them
1713
+ compiledContent = el.children.length === 1 ? el.firstElementChild : el;
1714
+ }
1715
+ else {
1716
+ compiledContent = content;
1717
+ }
1718
+ compiledContent.classList.add('tera-fy-uiSplat');
1719
+ if (settings.logo) {
1720
+ let logoEl = document.createElement('div');
1721
+ logoEl.innerHTML = `<img src="${typeof settings.logo == 'string' ? settings.logo : '/assets/logo/logo.svg'}" class="img-logo"/>`;
1722
+ // Prepend logo within the content element
1723
+ compiledContent.prepend(logoEl);
1724
+ }
1725
+ globalThis.document.body.append(compiledContent);
1726
+ }
1727
+ // }}}
1728
+ // Utility - debug() {{{
1729
+ /* eslint-disable jsdoc/check-param-names */
1730
+ /**
1731
+ * Debugging output function
1732
+ * This function will only act if `settings.devMode` is truthy
1733
+ *
1734
+ * @param {'INFO'|'LOG'|'WARN'|'ERROR'} [method='LOG'] Logging method to use
1735
+ * @param {Number} [verboseLevel=1] The verbosity level to trigger at. If `settings.verbosity` is lower than this, the message is ignored
1736
+ * @param {...*} [msg] Output to show
1737
+ */
1738
+ debug(...inputArgs) {
1739
+ // Ensure console exists
1740
+ if (typeof console === 'undefined')
1741
+ return;
1742
+ if (!this.settings.devMode || this.settings.verbosity < 1)
1743
+ return; // Debugging is disabled
1744
+ let method = 'log'; // Default method
1745
+ let verboseLevel = 1;
1746
+ let msgArgs = [...inputArgs]; // Copy args to modify
1747
+ // Argument mangling for prefix method + verbosity level {{{
1748
+ if (typeof msgArgs[0] == 'string' && ['INFO', 'LOG', 'WARN', 'ERROR'].includes(msgArgs[0].toUpperCase())) {
1749
+ const potentialMethod = msgArgs.shift().toLowerCase();
1750
+ // Check if it's a valid console method
1751
+ if (potentialMethod in console) {
1752
+ method = potentialMethod;
1753
+ }
1754
+ else {
1755
+ msgArgs.unshift(potentialMethod); // Put it back if not a valid method
1756
+ }
1757
+ }
1758
+ if (typeof msgArgs[0] == 'number') {
1759
+ verboseLevel = msgArgs.shift();
1760
+ }
1761
+ // }}}
1762
+ if (this.settings.verbosity < verboseLevel)
1763
+ return; // Called but this output is too verbose for our settings - skip
1764
+ // Use type assertion for dynamic console method call
1765
+ console[method]('%c[TERA-FY SERVER]', 'font-weight: bold; color: #4d659c;', ...msgArgs);
1766
+ }
1767
+ }
1768
+ TeraFyServer.SERVERMODE_NONE = 0;
1769
+ TeraFyServer.SERVERMODE_EMBEDDED = 1;
1770
+ TeraFyServer.SERVERMODE_FRAME = 2;
1771
+ TeraFyServer.SERVERMODE_POPUP = 3;
1772
+ TeraFyServer.SERVERMODE_TERA = 4; // Terafy is running as the main TERA site
1773
+ export default TeraFyServer;
1774
+ //# sourceMappingURL=terafy.server.js.map