@iebh/tera-fy 2.3.11 → 2.3.12

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