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