www/index.js: allow deep linking
[web/firmware-selector-openwrt-org.git] / www / index.js
1 /* global translations, config */
2 /* exported build_asu_request, init */
3
4 let current_model = {};
5 let url_params = undefined;
6
7 function $(query) {
8 if (typeof query === "string") {
9 return document.querySelector(query);
10 } else {
11 return query;
12 }
13 }
14
15 function show(query) {
16 $(query).style.display = "block";
17 }
18
19 function hide(query) {
20 $(query).style.display = "none";
21 }
22
23 function split(str) {
24 return str.match(/[^\s,]+/g) || [];
25 }
26
27 function get_model_titles(titles) {
28 return titles
29 .map((e) => {
30 if (e.title) {
31 return e.title;
32 } else {
33 return (
34 (e.vendor || "") +
35 " " +
36 (e.model || "") +
37 " " +
38 (e.variant || "")
39 ).trim();
40 }
41 })
42 .join(" / ");
43 }
44
45 function build_asu_request() {
46 if (!current_model || !current_model.id) {
47 alert("bad profile");
48 return;
49 }
50
51 function showStatus(message, url) {
52 show("#buildstatus");
53 const tr = message.startsWith("tr-") ? message : "";
54 if (url) {
55 $("#buildstatus").innerHTML =
56 '<a href="' + url + '" class="' + tr + '">' + message + "</a>";
57 } else {
58 $("#buildstatus").innerHTML = '<span class="' + tr + '"></span>';
59 }
60 translate();
61 }
62
63 // hide image view
64 updateImages();
65
66 show("#buildspinner");
67 showStatus("tr-request-image");
68
69 const request_data = {
70 target: current_model.target,
71 profile: current_model.id,
72 packages: split($("#packages").value),
73 version: $("#versions").value,
74 };
75
76 fetch(config.asu_url + "/api/build", {
77 method: "POST",
78 headers: { "Content-Type": "application/json" },
79 body: JSON.stringify(request_data),
80 })
81 .then((response) => {
82 switch (response.status) {
83 case 200:
84 hide("#buildspinner");
85 showStatus("tr-build-successful");
86
87 response.json().then((mobj) => {
88 const download_url = config.asu_url + "/store/" + mobj.bin_dir;
89 showStatus("tr-build-successful", download_url + "/buildlog.txt");
90 updateImages(
91 mobj.version_number,
92 mobj.version_code,
93 mobj.build_at,
94 get_model_titles(mobj.titles),
95 download_url,
96 mobj,
97 true
98 );
99 });
100 break;
101 case 202:
102 showStatus("tr-check-again");
103 setTimeout(() => {
104 build_asu_request();
105 }, 5000);
106 break;
107 case 400: // bad request
108 case 422: // bad package
109 case 500: // build failed
110 hide("#buildspinner");
111 response.json().then((mobj) => {
112 const message = mobj["message"] || "tr-build-failed";
113 const url = mobj.buildlog
114 ? config.asu_url + "/store/" + mobj.bin_dir + "/buildlog.txt"
115 : undefined;
116 showStatus(message, url);
117 });
118 break;
119 }
120 })
121 .catch((err) => {
122 hide("#buildspinner");
123 showStatus(err);
124 });
125 }
126
127 function setupSelectList(select, items, onselection) {
128 for (const item of items.sort().reverse()) {
129 const option = document.createElement("OPTION");
130 option.innerHTML = item;
131 select.appendChild(option);
132 }
133
134 // pre-select version from URL or config.json
135 const preselect = url_params.get("version") || config.default_version;
136 if (preselect) {
137 $("#versions").value = preselect;
138 }
139
140 select.addEventListener("change", () => {
141 onselection(items[select.selectedIndex]);
142 });
143
144 if (select.selectedIndex >= 0) {
145 onselection(items[select.selectedIndex]);
146 }
147 }
148
149 // Change the translation of the entire document
150 function translate() {
151 const mapping = translations[config.language];
152 for (const tr in mapping) {
153 Array.from(document.getElementsByClassName(tr)).forEach((e) => {
154 e.innerText = mapping[tr];
155 });
156 }
157 }
158
159 function setupAutocompleteList(input, items, as_list, onbegin, onend) {
160 let currentFocus = -1;
161
162 // sort numbers and other characters separately
163 const collator = new Intl.Collator(undefined, {
164 numeric: true,
165 sensitivity: "base",
166 });
167
168 items.sort(collator.compare);
169
170 input.oninput = function () {
171 onbegin();
172
173 let offset = 0;
174 let value = this.value;
175 let value_list = [];
176
177 if (as_list) {
178 // automcomplete last text item
179 offset = this.value.lastIndexOf(" ") + 1;
180 value = this.value.substr(offset);
181 value_list = split(this.value.substr(0, offset));
182 }
183
184 // close any already open lists of autocompleted values
185 closeAllLists();
186
187 if (!value) {
188 return false;
189 }
190
191 // create a DIV element that will contain the items (values):
192 const list = document.createElement("DIV");
193 list.setAttribute("id", this.id + "-autocomplete-list");
194 list.setAttribute("class", "autocomplete-items");
195 // append the DIV element as a child of the autocomplete container:
196 this.parentNode.appendChild(list);
197
198 function normalize(s) {
199 return s.toUpperCase().replace(/[-_.]/g, " ");
200 }
201
202 const match = normalize(value);
203 let c = 0;
204 for (const item of items) {
205 // match
206 let j = normalize(item).indexOf(match);
207 if (j < 0) {
208 continue;
209 }
210
211 // do not offer a duplicate item
212 if (as_list && value_list.indexOf(item) != -1) {
213 continue;
214 }
215
216 c += 1;
217 if (c >= 15) {
218 let div = document.createElement("DIV");
219 div.innerHTML = "...";
220 list.appendChild(div);
221 break;
222 } else {
223 let div = document.createElement("DIV");
224 // make the matching letters bold:
225 div.innerHTML =
226 item.substr(0, j) +
227 "<strong>" +
228 item.substr(j, value.length) +
229 "</strong>" +
230 item.substr(j + value.length) +
231 '<input type="hidden" value="' +
232 item +
233 '">';
234
235 div.addEventListener("click", function () {
236 // include selected value
237 const selected = this.getElementsByTagName("input")[0].value;
238 if (as_list) {
239 input.value = value_list.join(" ") + " " + selected;
240 } else {
241 input.value = selected;
242 }
243 // close the list of autocompleted values,
244 closeAllLists();
245 onend(input);
246 });
247
248 list.appendChild(div);
249 }
250 }
251 };
252
253 input.onkeydown = function (e) {
254 let x = document.getElementById(this.id + "-autocomplete-list");
255 if (x) x = x.getElementsByTagName("div");
256 if (e.keyCode == 40) {
257 // key down
258 currentFocus += 1;
259 // and and make the current item more visible:
260 setActive(x);
261 } else if (e.keyCode == 38) {
262 // key up
263 currentFocus -= 1;
264 // and and make the current item more visible:
265 setActive(x);
266 } else if (e.keyCode == 13) {
267 // If the ENTER key is pressed, prevent the form from being submitted,
268 e.preventDefault();
269 if (currentFocus > -1) {
270 // and simulate a click on the 'active' item:
271 if (x) x[currentFocus].click();
272 }
273 }
274 };
275
276 input.onfocus = function () {
277 onend(input);
278 };
279
280 // focus lost
281 input.onblur = function () {
282 onend(input);
283 };
284
285 function setActive(xs) {
286 // a function to classify an item as 'active':
287 if (!xs) return false;
288 // start by removing the 'active' class on all items:
289 for (const x of xs) {
290 x.classList.remove("autocomplete-active");
291 }
292 if (currentFocus >= xs.length) currentFocus = 0;
293 if (currentFocus < 0) currentFocus = xs.length - 1;
294 // add class 'autocomplete-active':
295 xs[currentFocus].classList.add("autocomplete-active");
296 }
297
298 function closeAllLists(elmnt) {
299 // close all autocomplete lists in the document,
300 // except the one passed as an argument:
301 const xs = document.getElementsByClassName("autocomplete-items");
302 for (const x of xs) {
303 if (elmnt != x && elmnt != input) {
304 x.parentNode.removeChild(x);
305 }
306 }
307 }
308
309 // execute a function when someone clicks in the document:
310 document.addEventListener("click", (e) => {
311 closeAllLists(e.target);
312 });
313 }
314
315 // for attended sysupgrade
316 function updatePackageList(version, target) {
317 // set available packages
318 fetch(
319 config.asu_url +
320 "/" +
321 config.versions[version] +
322 "/" +
323 target +
324 "/index.json"
325 )
326 .then((response) => response.json())
327 .then((all_packages) => {
328 setupAutocompleteList(
329 $("#packages"),
330 all_packages,
331 true,
332 () => {},
333 (textarea) => {
334 textarea.value = split(textarea.value)
335 // make list unique, ignore minus
336 .filter((value, index, self) => {
337 const i = self.indexOf(value.replace(/^-/, ""));
338 return i === index || i < 0;
339 })
340 // limit to available packages, ignore minus
341 .filter(
342 (value) => all_packages.indexOf(value.replace(/^-/, "")) !== -1
343 )
344 .join(" ");
345 }
346 );
347 });
348 }
349
350 function updateImages(version, code, date, model, url, mobj, is_custom) {
351 // add download button for image
352 function addLink(type, file) {
353 const a = document.createElement("A");
354 a.classList.add("download-link");
355 a.href =
356 url.replace("{target}", mobj.target).replace("{version}", version) +
357 "/" +
358 file;
359 const span = document.createElement("SPAN");
360 span.appendChild(document.createTextNode(""));
361 a.appendChild(span);
362 a.appendChild(document.createTextNode(type.toUpperCase()));
363
364 if (config.showHelp) {
365 a.onmouseover = function () {
366 // hide all help texts
367 Array.from(document.getElementsByClassName("download-help")).forEach(
368 (e) => (e.style.display = "none")
369 );
370 const lc = type.toLowerCase();
371 if (lc.includes("sysupgrade")) {
372 show("#sysupgrade-help");
373 } else if (lc.includes("factory") || lc == "trx" || lc == "chk") {
374 show("#factory-help");
375 } else if (
376 lc.includes("kernel") ||
377 lc.includes("zimage") ||
378 lc.includes("uimage")
379 ) {
380 show("#kernel-help");
381 } else if (lc.includes("root")) {
382 show("#rootfs-help");
383 } else if (lc.includes("sdcard")) {
384 show("#sdcard-help");
385 } else if (lc.includes("tftp")) {
386 show("#tftp-help");
387 } else {
388 show("#other-help");
389 }
390 };
391 }
392
393 $("#download-links").appendChild(a);
394 }
395
396 function switchClass(query, from_class, to_class) {
397 $(query).classList.remove(from_class);
398 $(query).classList.add(to_class);
399 }
400
401 // remove all download links
402 Array.from(document.getElementsByClassName("download-link")).forEach((e) =>
403 e.remove()
404 );
405
406 // hide all help texts
407 Array.from(document.getElementsByClassName("download-help")).forEach(
408 (e) => (e.style.display = "none")
409 );
410
411 if (model && url && mobj) {
412 const target = mobj.target;
413 const images = mobj.images;
414
415 // change between "version" and "custom" title
416 if (is_custom) {
417 switchClass("#build-title", "tr-version-build", "tr-custom-build");
418 switchClass(
419 "#downloads-title",
420 "tr-version-downloads",
421 "tr-custom-downloads"
422 );
423 } else {
424 switchClass("#build-title", "tr-custom-build", "tr-version-build");
425 switchClass(
426 "#downloads-title",
427 "tr-custom-downloads",
428 "tr-version-downloads"
429 );
430 }
431
432 // update title translation
433 translate();
434
435 // fill out build info
436 $("#image-model").innerText = model;
437 $("#image-target").innerText = target;
438 $("#image-version").innerText = version;
439 $("#image-code").innerText = mobj["code"] || code;
440 $("#image-date").innerText = date;
441
442 images.sort((a, b) => a.name.localeCompare(b.name));
443
444 for (const i in images) {
445 addLink(images[i].type, images[i].name);
446 }
447
448 if (config.asu_url) {
449 updatePackageList(version, target);
450 }
451
452 // set current selection in URL
453 history.pushState(
454 null,
455 null,
456 document.location.href.split("?")[0] +
457 "?version=" +
458 encodeURIComponent(version) +
459 "&id=" +
460 encodeURIComponent(mobj["id"])
461 );
462
463 show("#images");
464 } else {
465 hide("#images");
466 }
467 }
468
469 // Update model title in search box.
470 // Device id and model title might change between releases.
471 function setModel(obj, id, model) {
472 if (id) {
473 for (const mobj of Object.values(obj["models"])) {
474 if (mobj["id"] == id) {
475 $("#models").value = mobj["model"];
476 return;
477 }
478 }
479 }
480
481 if (model) {
482 for (const mobj of Object.values(obj["models"])) {
483 if (mobj["model"].toLowerCase() == model.toLowerCase()) {
484 $("#models").value = mobj["model"];
485 return;
486 }
487 }
488 }
489 }
490
491 function init() {
492 url_params = new URLSearchParams(window.location.search);
493 let build_date = "unknown";
494
495 setupSelectList($("#versions"), Object.keys(config.versions), (version) => {
496 // A new version was selected
497 let url = config.versions[version];
498 if (config.asu_url) {
499 url = config.asu_url + "/" + url + "/profiles.json";
500 }
501
502 fetch(url)
503 .then((obj) => {
504 build_date = obj.headers.get("last-modified");
505 return obj.json();
506 })
507 .then((obj) => {
508 // handle native openwrt json format
509 if ("profiles" in obj) {
510 obj["models"] = {};
511 for (const [key, value] of Object.entries(obj["profiles"])) {
512 value["id"] = key;
513 obj["models"][get_model_titles(value.titles)] = value;
514 }
515 }
516
517 // add key (title) to each model object
518 for (const [title, mobj] of Object.entries(obj["models"])) {
519 mobj["model"] = title;
520 }
521
522 return obj;
523 })
524 .then((obj) => {
525 setupAutocompleteList(
526 $("#models"),
527 Object.keys(obj["models"]),
528 false,
529 updateImages,
530 (models) => {
531 const model = models.value;
532 if (model in obj["models"]) {
533 const url = obj.download_url || "unknown";
534 const code = obj.version_code || "unknown";
535 const mobj = obj["models"][model];
536 updateImages(version, code, build_date, model, url, mobj, false);
537 current_model = mobj;
538 } else {
539 updateImages();
540 current_model = {};
541 }
542 }
543 );
544
545 // set model when selected version changes
546 setModel(
547 obj,
548 current_model["id"] || url_params.get("id"),
549 current_model["model"] || url_params.get("model")
550 );
551
552 // trigger update of current selected model
553 $("#models").onfocus();
554 });
555 });
556
557 if (config.asu_url) {
558 show("#custom");
559 }
560
561 // hide fields
562 updateImages();
563
564 // default to browser language
565 const user_lang = (navigator.language || navigator.userLanguage).split(
566 "-"
567 )[0];
568 if (user_lang in translations) {
569 config.language = user_lang;
570 $("#language-selection").value = user_lang;
571 }
572
573 translate();
574
575 $("#language-selection").onclick = function () {
576 config.language = this.children[this.selectedIndex].value;
577 translate();
578 };
579 }