Line data Source code
1 127 : /*
2 : * Copyright (C) 2024 Red Hat, Inc.
3 : * SPDX-License-Identifier: GPL-3.0-or-later
4 : */
5 :
6 : /*
7 : * This is the "cockpit.location" API converted to TypeScript and moved out of
8 : * cockpit.js. In the future a new Location API should be designed to replace
9 : * the "cockpit.location" one and become importable from pkg/lib/cockpit as ESM module.
10 : */
11 :
12 : import { url_root, calculate_application } from './location-utils';
13 :
14 : type Options = { [name: string]: string | Array<string> };
15 : type Path = string | string[] | Location;
16 :
17 123 : export class Location {
18 : path: string[];
19 : href: string;
20 : url_root: string;
21 : options: Options;
22 123 : #hash_changed: boolean = false;
23 :
24 123 : constructor() {
25 123 : const application = calculate_application();
26 121 : this.url_root = url_root || "";
27 :
28 19 : if (window.mock?.url_root)
29 19 : this.url_root = window.mock.url_root;
30 :
31 19 : if (application.indexOf("cockpit+=") === 0) {
32 19 : if (this.url_root)
33 19 : this.url_root += '/';
34 19 : this.url_root = this.url_root + application.replace("cockpit+", '');
35 19 : }
36 :
37 123 : this.href = window.location.hash.slice(1);
38 123 : this.options = {};
39 123 : this.path = this.decode(this.href, this.options);
40 123 : }
41 :
42 134 : #resolve_path_dots(parts: string[]): string[] {
43 134 : const out = [];
44 134 : for (let i = 0; i < parts.length; i++) {
45 134 : const part = parts[i];
46 131 : if (part === "" || part == ".") {
47 134 : continue;
48 30 : } else if (part == "..") {
49 30 : if (out.length === 0)
50 30 : return [];
51 30 : out.pop();
52 30 : } else {
53 131 : out.push(part);
54 131 : }
55 134 : }
56 134 : return out;
57 134 : }
58 :
59 40 : #href_for_go_or_replace(path: Path, options?: Options): string {
60 16 : options = options || {};
61 20 : if (typeof path === "string") {
62 20 : return this.encode(this.decode(path, options), options);
63 3 : } else if (path instanceof Location) {
64 3 : return path.href;
65 3 : } else {
66 29 : return this.encode(path, options);
67 29 : }
68 40 : }
69 :
70 134 : #decode_path(input: string): string[] {
71 134 : let result, i;
72 134 : let pre_parts: string[] = [];
73 134 : const parts = input.split('/').map(decodeURIComponent);
74 :
75 134 : if (this.url_root)
76 32 : pre_parts = this.url_root.split('/').map(decodeURIComponent);
77 :
78 33 : if (input && input[0] !== "/" && this.path !== undefined) {
79 33 : result = [...this.path];
80 33 : result.pop();
81 33 : result = result.concat(parts);
82 33 : } else {
83 134 : result = parts;
84 134 : }
85 :
86 134 : result = this.#resolve_path_dots(result);
87 32 : for (i = 0; i < pre_parts.length; i++) {
88 32 : if (pre_parts[i] !== result[i])
89 32 : break;
90 32 : }
91 134 : if (i == pre_parts.length)
92 134 : result.splice(0, pre_parts.length);
93 :
94 134 : return result;
95 134 : }
96 :
97 131 : encode(path: string | string[], options: Options, with_root: boolean = false): string {
98 131 : if (typeof path == "string")
99 30 : path = this.#decode_path(path);
100 :
101 131 : let href = "/" + path.map(encodeURIComponent).join("/");
102 32 : if (with_root && this.url_root && href.indexOf("/" + this.url_root + "/") !== 0)
103 32 : href = "/" + this.url_root + href;
104 :
105 : /* Undo unnecessary encoding of these */
106 131 : href = href.replaceAll("%40", "@");
107 131 : href = href.replaceAll("%3D", "=");
108 131 : href = href.replaceAll("%2B", "+");
109 131 : href = href.replaceAll("%23", "#");
110 :
111 131 : const query: string[] = [];
112 66 : if (options) {
113 51 : for (const opt of Object.keys(options).sort()) {
114 51 : let value = options[opt];
115 51 : if (!Array.isArray(value))
116 51 : value = [value];
117 23 : value.forEach(function(v: string) {
118 23 : query.push(encodeURIComponent(opt) + "=" + encodeURIComponent(v));
119 23 : });
120 51 : }
121 66 : if (query.length > 0)
122 51 : href += "?" + query.join("&");
123 66 : }
124 131 : return href;
125 131 : }
126 :
127 : // NOTE: The options argument is modified in place
128 134 : decode(href: string, options: Options): string[] {
129 134 : if (href[0] == '#')
130 30 : href = href.substring(1);
131 :
132 134 : const pos = href.indexOf('?');
133 54 : const first = (pos === -1) ? href : href.substring(0, pos);
134 134 : const path = this.#decode_path(first);
135 54 : if (pos !== -1 && options) {
136 54 : href.substring(pos + 1).split("&")
137 27 : .forEach(function(opt) {
138 27 : const parts = opt.split('=');
139 27 : const name = decodeURIComponent(parts[0]);
140 27 : const value = decodeURIComponent(parts[1]);
141 2 : if (options[name]) {
142 2 : let last = options[name];
143 2 : if (!Array.isArray(last))
144 2 : last = options[name] = [last];
145 2 : last.push(value);
146 2 : } else {
147 27 : options[name] = value;
148 27 : }
149 27 : });
150 54 : }
151 :
152 134 : return path;
153 134 : }
154 :
155 3 : replace(path: Path, options?: Options) {
156 3 : if (this.#hash_changed)
157 3 : return;
158 3 : const href = this.#href_for_go_or_replace(path, options);
159 3 : window.location.replace(window.location.pathname + '#' + href);
160 3 : }
161 :
162 38 : go(path: Path, options?: Options) {
163 38 : if (this.#hash_changed)
164 38 : return;
165 38 : const hash = '#' + this.#href_for_go_or_replace(path, options);
166 :
167 3 : if (navigator.userAgent.includes("Firefox")) {
168 : // At least Firefox 146 and 147 will (sometimes) trigger a
169 : // full force reload of the page when setting
170 : // window.location.hash. We can work around that by
171 : // clicking on a link.
172 : //
173 : // https://bugzilla.mozilla.org/show_bug.cgi?id=2018546
174 :
175 3 : const a = document.createElement('a');
176 3 : a.href = hash;
177 3 : document.body.appendChild(a);
178 3 : a.click();
179 3 : document.body.removeChild(a);
180 3 : } else {
181 38 : window.location.hash = hash;
182 38 : }
183 38 : }
184 :
185 43 : invalidate() {
186 43 : this.#hash_changed = true;
187 43 : }
188 :
189 0 : toString() {
190 0 : return this.href;
191 0 : }
192 127 : }
|