🏡 index : quine-global-labs/mirror-cockpit-project-cockpit.git

/*
 * Copyright (C) 2024 Red Hat, Inc.
 * SPDX-License-Identifier: GPL-3.0-or-later
 */

/*
 * This is the "cockpit.location" API converted to TypeScript and moved out of
 * cockpit.js. In the future a new Location API should be designed to replace
 * the "cockpit.location" one and become importable from pkg/lib/cockpit as ESM module.
 */

import { url_root, calculate_application } from './location-utils';

type Options = { [name: string]: string | Array<string> };
type Path = string | string[] | Location;

export class Location {
    path: string[];
    href: string;
    url_root: string;
    options: Options;
    #hash_changed: boolean = false;

    constructor() {
        const application = calculate_application();
        this.url_root = url_root || "";

        if (window.mock?.url_root)
            this.url_root = window.mock.url_root;

        if (application.indexOf("cockpit+=") === 0) {
            if (this.url_root)
                this.url_root += '/';
            this.url_root = this.url_root + application.replace("cockpit+", '');
        }

        this.href = window.location.hash.slice(1);
        this.options = {};
        this.path = this.decode(this.href, this.options);
    }

    #resolve_path_dots(parts: string[]): string[] {
        const out = [];
        for (let i = 0; i < parts.length; i++) {
            const part = parts[i];
            if (part === "" || part === ".") {
                continue;
            } else if (part === "..") {
                if (out.length === 0)
                    return [];
                out.pop();
            } else {
                out.push(part);
            }
        }
        return out;
    }

    #href_for_go_or_replace(path: Path, options?: Options): string {
        options = options || {};
        if (typeof path === "string") {
            return this.encode(this.decode(path, options), options);
        } else if (path instanceof Location) {
            return path.href;
        } else {
            return this.encode(path, options);
        }
    }

    #decode_path(input: string): string[] {
        let result, i;
        let pre_parts: string[] = [];
        const parts = input.split('/').map(decodeURIComponent);

        if (this.url_root)
            pre_parts = this.url_root.split('/').map(decodeURIComponent);

        if (input && input[0] !== "/" && this.path !== undefined) {
            result = [...this.path];
            result.pop();
            result = result.concat(parts);
        } else {
            result = parts;
        }

        result = this.#resolve_path_dots(result);
        for (i = 0; i < pre_parts.length; i++) {
            if (pre_parts[i] !== result[i])
                break;
        }
        if (i === pre_parts.length)
            result.splice(0, pre_parts.length);

        return result;
    }

    encode(path: string | string[], options: Options, with_root: boolean = false): string {
        if (typeof path == "string")
            path = this.#decode_path(path);

        let href = "/" + path.map(encodeURIComponent).join("/");
        if (with_root && this.url_root && href.indexOf("/" + this.url_root + "/") !== 0)
            href = "/" + this.url_root + href;

        /* Undo unnecessary encoding of these */
        href = href.replaceAll("%40", "@");
        href = href.replaceAll("%3D", "=");
        href = href.replaceAll("%2B", "+");
        href = href.replaceAll("%23", "#");

        const query: string[] = [];
        if (options) {
            for (const opt of Object.keys(options).sort()) {
                let value = options[opt];
                if (!Array.isArray(value))
                    value = [value];
                value.forEach(function(v: string) {
                    query.push(encodeURIComponent(opt) + "=" + encodeURIComponent(v));
                });
            }
            if (query.length > 0)
                href += "?" + query.join("&");
        }
        return href;
    }

    // NOTE: The options argument is modified in place
    decode(href: string, options: Options): string[] {
        if (href[0] === '#')
            href = href.substring(1);

        const pos = href.indexOf('?');
        const first = (pos === -1) ? href : href.substring(0, pos);
        const path = this.#decode_path(first);
        if (pos !== -1 && options) {
            href.substring(pos + 1).split("&")
                    .forEach(function(opt) {
                        const parts = opt.split('=');
                        const name = decodeURIComponent(parts[0]);
                        const value = decodeURIComponent(parts[1]);
                        if (options[name]) {
                            let last = options[name];
                            if (!Array.isArray(last))
                                last = options[name] = [last];
                            last.push(value);
                        } else {
                            options[name] = value;
                        }
                    });
        }

        return path;
    }

    replace(path: Path, options?: Options) {
        if (this.#hash_changed)
            return;
        const href = this.#href_for_go_or_replace(path, options);
        window.location.replace(window.location.pathname + '#' + href);
    }

    go(path: Path, options?: Options) {
        if (this.#hash_changed)
            return;
        const hash = '#' + this.#href_for_go_or_replace(path, options);

        if (navigator.userAgent.includes("Firefox")) {
            // At least Firefox 146 and 147 will (sometimes) trigger a
            // full force reload of the page when setting
            // window.location.hash.  We can work around that by
            // clicking on a link.
            //
            // https://bugzilla.mozilla.org/show_bug.cgi?id=2018546

            const a = document.createElement('a');
            a.href = hash;
            document.body.appendChild(a);
            a.click();
            document.body.removeChild(a);
        } else {
            window.location.hash = hash;
        }
    }

    invalidate() {
        this.#hash_changed = true;
    }

    toString() {
        return this.href;
    }
}