Source RapidContext_App.js

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