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