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