1/*
2 * RapidContext <https://www.rapidcontext.com/>
3 * Copyright (c) 2007-2026 Per Cederberg. All rights reserved.
4 *
5 * This program is free software: you can redistribute it and/or
6 * modify it under the terms of the BSD license.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
11 *
12 * See the RapidContext LICENSE for more details.
13 */
14
15/**
16 * The base RapidContext namespace.
17 * @namespace RapidContext
18 * @private
19 */
20if (typeof(RapidContext) == "undefined") {
21 RapidContext = {};
22}
23
24/**
25 * Provides functions for application bootstrap and server communication.
26 * @namespace RapidContext.App
27 */
28if (typeof(RapidContext.App) == "undefined") {
29 RapidContext.App = {};
30}
31
32/**
33 * Initializes the platform, API:s and RapidContext UI. If an app
34 * identifier is provided, the default platform UI will not be
35 * created. Instead the app will be launched with the root document
36 * as its UI container.
37 *
38 * @param {string} [app] the app id to start
39 *
40 * @return {Promise} a promise that will resolve when initialization has
41 * either completed or failed
42 */
43RapidContext.App.init = function (app) {
44 // Initialize UI
45 RapidContext.Log.context("RapidContext.App.init()");
46 console.info("Initializing RapidContext");
47 document.body.innerHTML = "";
48 document.body.append(new RapidContext.Widget.Overlay({ message: "Loading..." }));
49
50 // Load platform data (into cache)
51 const cachedData = [
52 RapidContext.App.callProc("system/status"),
53 RapidContext.App.callProc("system/session/current"),
54 RapidContext.App.callProc("system/app/list")
55 ];
56
57 // Launch app
58 return Promise.all(cachedData)
59 .then(() => {
60 const hourly = 60 * 60 * 1000;
61 setInterval(() => RapidContext.App.callProc("system/session/current"), hourly);
62 const isStart = !app || app === "start";
63 return RapidContext.App.startApp(app ?? "start")
64 .catch((err) => Promise.reject(RapidContext.UI.showError(err)))
65 .catch(() => isStart ? null : RapidContext.App.startApp("start"));
66 })
67 .catch((err) => Promise.reject(RapidContext.UI.showError(err)))
68 .finally(() => RapidContext.Log.context(null));
69};
70
71/**
72 * Returns an object with status information about the platform and
73 * currently loaded environment. The object returned is a copy of
74 * the internal data structure and can be modified without affecting
75 * the real data.
76 *
77 * @return {Object} the status data object
78 */
79RapidContext.App.status = function () {
80 return { ...RapidContext.App._Cache.status };
81};
82
83/**
84 * Returns an object with information about the user. The object
85 * returned is a copy of the internal data structure and can be
86 * modified without affecting the real data.
87 *
88 * @return {Object} the user data object
89 */
90RapidContext.App.user = function () {
91 return { ...RapidContext.App._Cache.user };
92};
93
94/**
95 * Returns an array with app launchers. The data returned is an
96 * internal data structure and should not be modified.
97 *
98 * @return {Array} the loaded app launchers (read-only)
99 */
100RapidContext.App.apps = function () {
101 return Object.values(RapidContext.App._Cache.apps);
102};
103
104/**
105 * Returns an array with running app instances.
106 *
107 * @return {Array} the array of app instances
108 */
109RapidContext.App._instances = function () {
110 let res = [];
111 const apps = RapidContext.App.apps();
112 for (let i = 0; i < apps.length; i++) {
113 res = res.concat(apps[i].instances ?? []);
114 }
115 return res;
116};
117
118/**
119 * Finds the app launcher from an app instance, class name or
120 * launcher. In the last case, the matching cached launcher will be
121 * returned.
122 *
123 * @param {string|Object} app the app id, instance, class name or
124 * launcher
125 *
126 * @return {Object} the read-only app launcher, or
127 * `null` if not found
128 */
129RapidContext.App.findApp = function (app) {
130 if (app == null) {
131 return null;
132 }
133 const apps = RapidContext.App.apps();
134 for (let i = 0; i < apps.length; i++) {
135 const l = apps[i];
136 if (l.id == app || l.className == app || l.id == app.id) {
137 return l;
138 }
139 }
140 return null;
141};
142
143/**
144 * Creates and starts an app instance. Normally apps are started in the default
145 * app tab container or in a provided widget DOM node. By specifying a newly
146 * created window object as the parent container, apps can also be launched
147 * into separate windows.
148 *
149 * Note that when a new window is used, the returned deferred will callback
150 * immediately with a `null` app instance (normal app communication is not
151 * possible cross-window).
152 *
153 * @param {string|Object} app the app id, class name or launcher
154 * @param {Widget/Window} [container] the app container widget or
155 * window, defaults to create a new pane in the app tab
156 * container
157 *
158 * @return {Promise} a `RapidContext.Async` promise that will
159 * resolve when the app has launched
160 *
161 * @example
162 * // Starts the help app in a new tab
163 * RapidContext.App.startApp('help');
164 *
165 * // Starts the help app in a new window
166 * RapidContext.App.startApp('help', window.open());
167 */
168RapidContext.App.startApp = function (app, container) {
169 function loadResource(res) {
170 const url = res.url ? new URL(res.url, document.baseURI) : null;
171 const jsonLoader = /\.json$/i.test(res.url) ? RapidContext.App.loadJSON : null;
172 const xmlLoader = /\.xml$/i.test(res.url) ? RapidContext.App.loadXML : null;
173 switch (res.type) {
174 case "code": return RapidContext.App.loadScript(res.url);
175 case "module": return import(url).then((mod) => mod.default ?? mod.create);
176 case "style": return RapidContext.App.loadStyles(res.url);
177 case "ui": return RapidContext.App.loadXML(res.url);
178 case "data": return (jsonLoader ?? xmlLoader ?? RapidContext.App.loadText)(res.url);
179 default: return Promise.resolve(res.url);
180 }
181 }
182 function load(launcher, config) {
183 console.info(`Loading app/${launcher.id} resources`, config.resources);
184 launcher.resource = {};
185 return Promise.all(config.resources.map(loadResource)).then((arr) => {
186 config.resources.forEach((res, i) => {
187 const val = arr[i];
188 if (res.type === "module") {
189 launcher.creator ??= val;
190 } else if (res.type === "ui") {
191 launcher.ui = val;
192 } else if (!["code", "module", "style", "icon", "ui"].includes(res.type) && val) {
193 if (res.id) {
194 launcher.resource[res.id] = val;
195 } else if (res.type) {
196 (launcher.resource[res.type] ??= []).push(val);
197 }
198 }
199 });
200 launcher.creator ??= window[launcher.className];
201 if (launcher.creator == null) {
202 const msg = `App constructor ${launcher.className} not defined`;
203 console.error(msg, launcher);
204 throw new Error(msg);
205 }
206 return config;
207 });
208 }
209 function buildUI(parent, ids, ui) {
210 const root = ui.documentElement;
211 for (let i = 0; i < root.attributes.length; i++) {
212 const attr = root.attributes[i];
213 if (typeof(parent.setAttrs) === "function") {
214 parent.setAttrs({ [attr.name]: attr.value });
215 } else if (attr.name === "class") {
216 attr.value.split(/\s+/g).forEach((cls) => parent.classList.add(cls));
217 } else {
218 parent.setAttribute(attr.name, attr.value);
219 }
220 }
221 const arr = Array.from(ui.documentElement.childNodes);
222 parent.append(...arr.map((o) => RapidContext.UI.create(o)).filter(Boolean));
223 parent.querySelectorAll("[id]").forEach((el) => ids[el.attributes.id.value] = el);
224 }
225 function launch(launcher, ui) {
226 RapidContext.Log.context(`RapidContext.App.startApp(${launcher.id})`);
227 return launcher.starter = RapidContext.App.callProc("system/app/launch", [launcher.id])
228 .then((config) => launcher.creator ? config : load(launcher, config))
229 .then((config) => {
230 console.info(`Starting app/${launcher.id}`, launcher);
231 /* eslint new-cap: "off" */
232 const instance = new launcher.creator();
233 Object.assign(instance, {
234 id: config.id,
235 name: config.name,
236 resource: launcher.resource,
237 proc: RapidContext.Procedure.mapAll(config.procedures),
238 ui: ui
239 });
240 launcher.instances.push(instance);
241 MochiKit.Signal.disconnectAll(ui.root, "onclose");
242 const halt = () => RapidContext.App.stopApp(instance);
243 MochiKit.Signal.connect(ui.root, "onclose", halt);
244 if (launcher.ui != null) {
245 buildUI(ui.root, ui, launcher.ui);
246 }
247 ui.overlay.setAttrs({ message: "Starting..." });
248 return RapidContext.Async.wait(0) // Wait for initial UI events
249 .then(() => RapidContext.App.callApp(instance, "start"));
250 })
251 .catch((err) => {
252 console.error("Failed to start app", err);
253 MochiKit.Signal.disconnectAll(ui.root, "onclose");
254 const div = document.createElement("div");
255 div.append(String(err));
256 ui.root.append(div);
257 if (err.url) {
258 const link = document.createElement("a");
259 link.className = "block mt-2";
260 link.setAttribute("href", err.url);
261 link.append(err.url);
262 ui.root.append(link);
263 }
264 return Promise.reject(err);
265 })
266 .finally(() => {
267 ui.overlay.hide();
268 ui.overlay.setAttrs({ message: "Working..." });
269 delete launcher.starter;
270 RapidContext.Log.context(null);
271 });
272 }
273 function moveAppToStart(instance, elems) {
274 const start = RapidContext.App.findApp("start").instances[0];
275 const opts = { title: instance.name, closeable: false, background: true };
276 const ui = start.initAppPane(null, opts);
277 ui.root.removeAll();
278 ui.root.addAll(elems);
279 }
280 return new RapidContext.Async((resolve, reject) => {
281 const launcher = RapidContext.App.findApp(app);
282 if (launcher == null) {
283 const msg = "No matching app launcher found";
284 console.error(msg, app);
285 throw new Error([msg, ": ", app].join(""));
286 }
287 const instances = RapidContext.App._instances();
288 const start = RapidContext.App.findApp("start");
289 if ($.isWindow(container)) {
290 // Launch app into separate window/tab
291 const href = `rapidcontext/app/${launcher.id}`;
292 container.location.href = new URL(href, document.baseURI).toString();
293 resolve();
294 } else if (start?.instances?.length > 0) {
295 // Launch app into start app tab
296 const paneOpts = { title: launcher.name, closeable: (launcher.launch != "once") };
297 const paneUi = start.instances[0].initAppPane(container, paneOpts);
298 resolve(launch(launcher, paneUi));
299 } else if (instances.length > 0) {
300 // Switch from single-app to multi-app mode
301 const elems = Array.from(document.body.childNodes);
302 const overlay = new RapidContext.Widget.Overlay({ message: "Loading..." });
303 document.body.insertBefore(overlay, document.body.childNodes[0]);
304 const ui = { root: document.body, overlay: overlay };
305 const move = () => moveAppToStart(instances[0], elems);
306 const recall = () => (launcher.id === "start") ? true : RapidContext.App.startApp(launcher.id);
307 resolve(launch(start, ui).then(move).then(recall));
308 } else {
309 // Launch single-app mode
310 const ui = { root: document.body, overlay: document.body.childNodes[0] };
311 resolve(launch(launcher, ui));
312 }
313 });
314};
315
316/**
317 * Stops an app instance. If only the class name or launcher is specified, the
318 * most recently created instance will be stopped.
319 *
320 * @param {string|Object} app the app id, instance, class name or launcher
321 *
322 * @return {Promise} a `RapidContext.Async` promise that will
323 * resolve when the app has been stopped
324 */
325RapidContext.App.stopApp = function (app) {
326 return new RapidContext.Async((resolve, reject) => {
327 const launcher = RapidContext.App.findApp(app);
328 if (!launcher || launcher.instances.length <= 0) {
329 const msg = "No running app instance found";
330 console.error(msg, app);
331 throw new Error([msg, ": ", app].join(""));
332 }
333 console.info(`Stopping app ${launcher.name}`);
334 const pos = launcher.instances.indexOf(app);
335 if (pos < 0) {
336 app = launcher.instances.pop();
337 } else {
338 launcher.instances.splice(pos, 1);
339 }
340 app.stop();
341 if (app.ui.root != null) {
342 RapidContext.Widget.destroyWidget(app.ui.root);
343 }
344 for (const k in app.ui) {
345 delete app.ui[k];
346 }
347 for (const n in app) {
348 delete app[n];
349 }
350 MochiKit.Signal.disconnectAllTo(app);
351 resolve();
352 });
353};
354
355/**
356 * Performs an asynchronous call to a method in an app. If only the class name
357 * or launcher is specified, the most recently created instance will be used.
358 * If no instance is running, one will be started. Also, before calling the app
359 * method, the app UI will be focused.
360 *
361 * @param {string|Object} app the app id, instance, class name or launcher
362 * @param {string} method the app method name
363 * @param {Mixed} [args] additional parameters sent to method
364 *
365 * @return {Promise} a `RapidContext.Async` promise that will
366 * resolve with the result of the call on success
367 */
368RapidContext.App.callApp = function (app, method) {
369 const args = Array.from(arguments).slice(2);
370 return new RapidContext.Async((resolve, reject) => {
371 const launcher = RapidContext.App.findApp(app);
372 if (launcher == null) {
373 const msg = "No matching app launcher found";
374 console.error(msg, app);
375 throw new Error([msg, ": ", app].join(""));
376 }
377 const starter = launcher.instances[0] ?? launcher.starter ?? RapidContext.App.startApp(app);
378 const promise = Promise.resolve(starter)
379 .then(() => {
380 RapidContext.Log.context(`RapidContext.App.callApp(${launcher.id},${method})`);
381 const pos = launcher.instances.indexOf(app);
382 const instance = (pos >= 0) ? app : launcher.instances[launcher.instances.length - 1];
383 const child = instance.ui.root;
384 const parent = child.parentNode.closest(".widget");
385 if (typeof(parent?.selectChild) == "function") {
386 parent.selectChild(child);
387 }
388 const methodName = `${launcher.className}.${method}`;
389 if (instance[method] == null) {
390 const msg = `No app method ${methodName} found`;
391 console.error(msg);
392 throw new Error(msg);
393 }
394 console.log(`Calling app method ${methodName}`, args);
395 try {
396 return instance[method](...args);
397 } catch (e) {
398 const reason = `Caught error in ${methodName}`;
399 console.error(reason, e);
400 throw new Error(`${reason}: ${e.toString()}`, { cause: e });
401 }
402 })
403 .finally(() => RapidContext.Log.context(null));
404 resolve(promise);
405 });
406};
407
408/**
409 * Performs an asynchronous procedure call.
410 *
411 * @param {string} name the procedure name
412 * @param {Array|Object} [args] the arguments array, dictionary, or `null`
413 * @param {Object} [opts] the procedure call options
414 * @param {boolean} [opts.session] the HTTP session required flag
415 * @param {boolean} [opts.trace] the procedure call trace flag
416 * @param {number} [opts.timeout] the timeout in milliseconds, default is 60s
417 *
418 * @return {Promise} a `RapidContext.Async` promise that will
419 * resolve with the response data on success
420 */
421RapidContext.App.callProc = function (name, args, opts) {
422 args = args ?? [];
423 opts = opts ?? {};
424 console.log(`Call request ${name}`, args);
425 let params = RapidContext.Data.map(RapidContext.Encode.toJSON, args);
426 if (Array.isArray(params)) {
427 params = RapidContext.Data.object(params.map((val, idx) => [`arg${idx}`, val]));
428 }
429 params["system:session"] = !!opts.session;
430 params["system:trace"] = !!opts.trace || ["all", "log"].includes(RapidContext.Log.level());
431 params["system:token"] = opts.token;
432 const url = `rapidcontext/procedure/${name}`;
433 const options = { method: "POST", timeout: opts.timeout ?? 60000 };
434 return RapidContext.App.loadJSON(url, params, options).then((res) => {
435 if (res.trace) {
436 console.log(`${name} trace:`, res.trace);
437 }
438 if (res.error) {
439 console.info(`${name} error:`, res.error);
440 RapidContext.App._Cache.handleError(res.error);
441 throw new Error(res.error);
442 } else {
443 console.log(`${name} response:`, res.data);
444 if (name.startsWith("system/")) {
445 RapidContext.App._Cache.update(name, res.data);
446 }
447 return res.data;
448 }
449 });
450};
451
452/**
453 * Performs an asynchronous login. If the current session is already bound to a
454 * user, that session will be terminated and a new one will be created. If an
455 * authentication token is specified, the login and password fields are not
456 * used (can be null).
457 *
458 * @param {string} login the user login name or email address
459 * @param {string} password the password to authenticate the user
460 * @param {string} [token] the authentication token to identify user/password
461 *
462 * @return {Promise} a `RapidContext.Async` promise that resolves when
463 * the authentication has either succeeded or failed
464 */
465RapidContext.App.login = function (login, password, token) {
466 function searchLogin() {
467 const proc = "system/user/search";
468 return RapidContext.App.callProc(proc, [login]).then((user) => {
469 if (user?.id) {
470 return login = user.id;
471 } else {
472 throw new Error("no user with that email address");
473 }
474 });
475 }
476 function getNonce() {
477 const proc = "system/session/current";
478 const opts = { session: true };
479 return RapidContext.App.callProc(proc, [], opts).then((session) => session.nonce);
480 }
481 function passwordAuth(nonce) {
482 const realm = RapidContext.App.status().realm;
483 let hash = CryptoJS.MD5(`${login}:${realm}:${password}`);
484 hash = CryptoJS.MD5(`${hash.toString()}:${nonce}`).toString();
485 const args = [login, nonce, hash];
486 return RapidContext.App.callProc("system/session/authenticate", args);
487 }
488 function tokenAuth() {
489 return RapidContext.App.callProc("system/session/authenticatetoken", [token]);
490 }
491 function verifyAuth(res) {
492 if (!res.success || res.error) {
493 console.info("login failed", login, res.error);
494 throw new Error(res.error ?? "authentication failed");
495 }
496 }
497 let promise = RapidContext.Async.wait(0);
498 const user = RapidContext.App.user();
499 if (user?.id) {
500 promise = promise.then(RapidContext.App.logout);
501 }
502 if (token) {
503 return promise.then(tokenAuth).then(verifyAuth);
504 } else {
505 if (/@/.test(login)) {
506 promise = promise.then(searchLogin);
507 }
508 return promise.then(getNonce).then(passwordAuth).then(verifyAuth);
509 }
510};
511
512/**
513 * Performs an asynchronous logout. This function terminates the current
514 * session and either reloads the browser window or returns a deferred object
515 * that will produce either a `callback` or an `errback` response.
516 *
517 * @param {boolean} [reload=true] the reload browser flag
518 *
519 * @return {Promise} a `RapidContext.Async` promise that will
520 * resolve when user is logged out
521 */
522RapidContext.App.logout = function (reload) {
523 const promise = RapidContext.App.callProc("system/session/terminate", [null]);
524 if (reload !== false) {
525 promise.then(() => window.location.reload());
526 }
527 return promise;
528};
529
530/**
531 * Performs an asynchronous HTTP request and parses the JSON response. The
532 * request parameters are automatically encoded to query string or JSON format,
533 * depending on the `Content-Type` header. The parameters will be sent either
534 * in the URL or as the request payload (depending on the HTTP `method`).
535 *
536 * @param {string} url the URL to request
537 * @param {Object} [params] the request parameters, or `null`
538 * @param {Object} [opts] the request options, or `null`
539 * @param {string} [opts.method] the HTTP method, default is `GET`
540 * @param {number} [opts.timeout] the timeout in milliseconds, default is 30s
541 * @param {Object} [opts.headers] the specific HTTP headers to use
542 *
543 * @return {Promise} a `RapidContext.Async` promise that will
544 * resolve with the parsed response JSON on success
545 */
546RapidContext.App.loadJSON = function (url, params, opts) {
547 opts = { responseType: "json", ...opts };
548 return RapidContext.App.loadXHR(url, params, opts).then((xhr) => xhr.response);
549};
550
551/**
552 * Performs an asynchronous HTTP request for a text document. The request
553 * parameters are automatically encoded to query string or JSON format,
554 * depending on the `Content-Type` header. The parameters will be sent either
555 * in the URL or as the request payload (depending on the HTTP `method`).
556 *
557 * @param {string} url the URL to request
558 * @param {Object} [params] the request parameters, or `null`
559 * @param {Object} [opts] the request options, or `null`
560 * @param {string} [opts.method] the HTTP method, "GET" or "POST"
561 * @param {number} [opts.timeout] the timeout in milliseconds, default is 30s
562 * @param {Object} [opts.headers] the specific HTTP headers to use
563 *
564 * @return {Promise} a `RapidContext.Async` promise that will
565 * resolve with the response text on success
566 */
567RapidContext.App.loadText = function (url, params, opts) {
568 opts = { responseType: "text", ...opts };
569 return RapidContext.App.loadXHR(url, params, opts).then((xhr) => xhr.response);
570};
571
572/**
573 * Performs an asynchronous HTTP request for an XML document. The request
574 * parameters are automatically encoded to query string or JSON format,
575 * depending on the `Content-Type` header. The parameters will be sent either
576 * in the URL or as the request payload (depending on the HTTP `method`).
577 *
578 * @param {string} url the URL to request
579 * @param {Object} [params] the request parameters, or `null`
580 * @param {Object} [opts] the request options, or `null`
581 * @param {string} [opts.method] the HTTP method, "GET" or "POST"
582 * @param {number} [opts.timeout] the timeout in milliseconds, default is 30s
583 * @param {Object} [opts.headers] the specific HTTP headers to use
584 *
585 * @return {Promise} a `RapidContext.Async` promise that will
586 * resolve with the parsed response XML document on success
587 */
588RapidContext.App.loadXML = function (url, params, opts) {
589 opts = { responseType: "document", ...opts };
590 return RapidContext.App.loadXHR(url, params, opts).then((xhr) => xhr.response);
591};
592
593/**
594 * Performs an asynchronous HTTP request. The request parameters are
595 * automatically encoded to query string or JSON format, depending on the
596 * `Content-Type` header. The parameters will be sent either in the URL or as
597 * the request payload (depending on the HTTP `method`).
598 *
599 * @param {string} url the URL to request
600 * @param {Object} [params] the request parameters, or `null`
601 * @param {Object} [opts] the request options, or `null`
602 * @param {string} [opts.method] the HTTP method, default is `GET`
603 * @param {number} [opts.timeout] the timeout in milliseconds, default is 30s
604 * @param {Object} [opts.headers] the specific HTTP headers to use
605 *
606 * @return {Promise} a `RapidContext.Async` promise that will
607 * resolve with the XMLHttpRequest instance on success
608 */
609RapidContext.App.loadXHR = function (url, params, opts) {
610 opts = { method: "GET", headers: {}, timeout: 30000, ...opts };
611 opts.timeout = (opts.timeout < 1000) ? opts.timeout * 1000 : opts.timeout;
612 const hasBody = params && ["PATCH", "POST", "PUT"].includes(opts.method);
613 const hasJsonBody = opts.headers["Content-Type"] === "application/json";
614 if (!hasBody) {
615 const op = url.includes("?") ? "&" : "?";
616 url += params ? `${op}${RapidContext.Encode.toUrlQuery(params)}` : "";
617 } else if (params && hasBody && hasJsonBody) {
618 opts.body = RapidContext.Encode.toJSON(params);
619 } else if (params && hasBody) {
620 opts.headers["Content-Type"] = "application/x-www-form-urlencoded";
621 opts.body = RapidContext.Encode.toUrlQuery(params);
622 }
623 console.log("Starting XHR loading", url, opts);
624 return RapidContext.Async.xhr(url, opts).then(
625 (res) => {
626 console.log("Completed XHR loading", url);
627 return res;
628 },
629 (err) => {
630 const logger = /timeout/i.test(err) ? console.info : console.warn;
631 logger("Failed XHR loading", url, err);
632 return Promise.reject(err);
633 }
634 );
635};
636
637/**
638 * Loads a JavaScript to the current page. The script is loaded by
639 * inserting a `<script>` tag in the document `<head>`. Function definitions
640 * and values must therefore be stored to global variables by the script to
641 * become accessible after loading. If the script is already loaded, the
642 * promise will resolve immediately.
643 *
644 * @param {string} url the URL to the script
645 *
646 * @return {Promise} a `RapidContext.Async` promise that will
647 * resolve when the script has loaded
648 *
649 * @see Use dynamic `import()` instead, if supported by the environment.
650 */
651RapidContext.App.loadScript = function (url) {
652 const selector = ["script[src*='", url, "']"].join("");
653 if (document.querySelectorAll(selector).length > 0) {
654 console.log("script already loaded, skipping", url);
655 return RapidContext.Async.wait(0);
656 } else {
657 console.log("loading script", url);
658 return RapidContext.Async.script(url);
659 }
660};
661
662/**
663 * Loads a CSS stylesheet to the current page asynchronously. The
664 * stylesheet is loaded by inserting a `<link>` tag in the document `head`.
665 * If the stylesheet is already loaded, the promise will resolve immediately.
666 *
667 * @param {string} url the URL to the stylesheet
668 *
669 * @return {Promise} a `RapidContext.Async` promise that will
670 * callback when the stylesheet has been loaded
671 */
672RapidContext.App.loadStyles = function (url) {
673 const selector = ["link[href*='", url, "']"].join("");
674 if (document.querySelectorAll(selector).length > 0) {
675 console.log("stylesheet already loaded, skipping", url);
676 return RapidContext.Async.wait(0);
677 } else {
678 console.log("loading stylesheet", url);
679 return RapidContext.Async.css(url);
680 }
681};
682
683/**
684 * Downloads a file to the user desktop. This works by creating a new
685 * window or an inner frame which downloads the file from the server.
686 * Due to `Content-Disposition` headers being set on the server, the
687 * web browser will popup a dialog for the user to save the file.
688 * This function can also be used for saving a file that doesn't
689 * exist by first posting the file (text) content to the server.
690 *
691 * @param {string} url the URL or filename to download
692 * @param {string} [data] the optional file data (if not available
693 * on the server-side)
694 */
695RapidContext.App.downloadFile = function (url, data) {
696 if (data == null) {
697 const op = url.includes("?") ? "&" : "?";
698 url += `${op}download`;
699 const attrs = {
700 src: url,
701 border: "0",
702 frameborder: "0",
703 height: "0",
704 width: "0"
705 };
706 const iframe = RapidContext.UI.create("iframe", attrs);
707 document.body.append(iframe);
708 } else {
709 const name = RapidContext.UI.INPUT({ name: "fileName", value: url });
710 const file = RapidContext.UI.INPUT({ name: "fileData", value: data });
711 const flag = RapidContext.UI.INPUT({ name: "download", value: "1" });
712 const attrs = {
713 action: "rapidcontext/download",
714 method: "POST",
715 target: "_blank",
716 style: { display: "none" }
717 };
718 const form = RapidContext.UI.FORM(attrs, name, file, flag);
719 document.body.append(form);
720 form.submit();
721 }
722};
723
724/**
725 * Uploads a file to the server. The upload is handled by an
726 * asynchronous HTTP request.
727 *
728 * @param {string} id the upload identifier to use
729 * @param {File} file the file object to upload
730 * @param {function} [onProgress] the progress event handler
731 */
732RapidContext.App.uploadFile = function (id, file, onProgress) {
733 const formData = new FormData();
734 formData.append("file", file);
735 const opts = {
736 method: "POST",
737 body: formData,
738 timeout: 300000,
739 progress: onProgress,
740 log: `${id} upload`
741 };
742 return RapidContext.Async.xhr(`rapidcontext/upload/${id}`, opts);
743};
744
745/**
746 * The application data cache. Contains the most recently retrieved
747 * data for some commonly used objects in the execution environment.
748 */
749RapidContext.App._Cache = {
750 version: new Date().getTime().toString(16).slice(-8),
751 status: null,
752 user: null,
753 apps: {},
754
755 // Normalizes an app manifest and its resources
756 _normalizeApp(app) {
757 function toType(type, url) {
758 const isJs = !type && /\.js$/i.test(url);
759 const isCss = !type && /\.css$/i.test(url);
760 if (["code", "js", "javascript"].includes(type) || isJs) {
761 return "code";
762 } else if (!type && /\.mjs$/i.test(url)) {
763 return "module";
764 } else if (["style", "css"].includes(type) || isCss) {
765 return "style";
766 } else if (!type && /\.json$/i.test(url)) {
767 return "json";
768 } else if (!type && /ui\.xml$/i.test(url)) {
769 return "ui";
770 } else if (!type && !app.icon && /\.(gif|jpg|jpeg|png|svg)$/i.test(url)) {
771 return "icon";
772 } else {
773 return type;
774 }
775 }
776 function toIcon(res) {
777 if (res.url) {
778 return $("<img/>").attr({ src: res.url }).addClass("-app-icon").get(0);
779 } else if (res.html) {
780 const node = $("<span/>").html(res.html).addClass("-app-icon").get(0);
781 return node.childNodes.length === 1 ? node.childNodes[0] : node;
782 } else if (res["class"]) {
783 return $("<i/>").addClass(res["class"]).addClass("-app-icon").get(0);
784 } else {
785 return null;
786 }
787 }
788 function toResource(res) {
789 res = (typeof(res) === "string") ? { url: res } : res;
790 res.type = toType(res.type, res.url);
791 if (!app.icon && res.type === "icon") {
792 app.icon = toIcon(res);
793 }
794 return res;
795 }
796 app = { ...app };
797 app.launch = app.launch ?? "manual";
798 app.resources = [].concat(app.resources).filter(Boolean).map(toResource);
799 if (!app.icon) {
800 app.icon = toIcon({ "class": "fa fa-4x fa-question-circle unimportant" });
801 }
802 return app;
803 },
804
805 // Updates the cache data with the procedure results.
806 update(proc, data) {
807 switch (proc) {
808 case "system/status":
809 this.status = { ...data };
810 console.log("Updated cached status", this.status);
811 break;
812 case "system/session/current":
813 if (this.user && this.user.id !== data?.user?.id) {
814 RapidContext.UI.Msg.error.loggedOut();
815 RapidContext.App.callProc = () => Promise.reject(new Error("logged out"));
816 } else if (!this.user && data?.user) {
817 const o = this.user = RapidContext.Data.clone(data.user);
818 o.longName = o.name ? `${o.name} (${o.id})` : o.id;
819 console.log("Updated cached user", this.user);
820 }
821 break;
822 case "system/app/list":
823 if (data) {
824 for (const o of data) {
825 const launcher = this._normalizeApp(o);
826 launcher.instances = this.apps[launcher.id]?.instances ?? [];
827 this.apps[launcher.id] = Object.assign(this.apps[launcher.id] ?? {}, launcher);
828 }
829 console.log("Updated cached apps", this.apps);
830 }
831 break;
832 }
833 },
834
835 // Updates the cache data on some procedure errors.
836 handleError(error) {
837 if (this.user && /permission denied/i.test(error)) {
838 setTimeout(() => RapidContext.App.callProc("system/session/current"));
839 }
840 }
841};
842
RapidContext
Access · Discovery · Insight
www.rapidcontext.com