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