9f7c40b4d14f8713e4e178889dce09bb3e9ab582
[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 (let i = 0; i < items.length; i += 1) {
128 const option = document.createElement("OPTION");
129 option.innerHTML = items[i];
130 select.appendChild(option);
131 }
132
133 select.addEventListener("change", () => {
134 onselection(items[select.selectedIndex]);
135 });
136
137 if (select.selectedIndex >= 0) {
138 onselection(items[select.selectedIndex]);
139 }
140 }
141
142 // Change the translation of the entire document
143 function translate() {
144 const mapping = translations[config.language];
145 for (const tr in mapping) {
146 Array.from(document.getElementsByClassName(tr)).forEach((e) => {
147 e.innerText = mapping[tr];
148 });
149 }
150 }
151
152 function setupAutocompleteList(input, items, as_list, onbegin, onend) {
153 let currentFocus = -1;
154
155 // sort numbers and other characters separately
156 const collator = new Intl.Collator(undefined, {
157 numeric: true,
158 sensitivity: "base",
159 });
160
161 items.sort(collator.compare);
162
163 input.oninput = function () {
164 onbegin();
165
166 let offset = 0;
167 let value = this.value;
168 let value_list = [];
169
170 if (as_list) {
171 // automcomplete last text item
172 offset = this.value.lastIndexOf(" ") + 1;
173 value = this.value.substr(offset);
174 value_list = split(this.value.substr(0, offset));
175 }
176
177 // close any already open lists of autocompleted values
178 closeAllLists();
179
180 if (!value) {
181 return false;
182 }
183
184 // create a DIV element that will contain the items (values):
185 const list = document.createElement("DIV");
186 list.setAttribute("id", this.id + "-autocomplete-list");
187 list.setAttribute("class", "autocomplete-items");
188 // append the DIV element as a child of the autocomplete container:
189 this.parentNode.appendChild(list);
190
191 function normalize(s) {
192 return s.toUpperCase().replace(/[-_.]/g, " ");
193 }
194
195 const match = normalize(value);
196 let c = 0;
197 for (let i = 0; i < items.length; i += 1) {
198 const item = items[i];
199
200 // match
201 let j = normalize(item).indexOf(match);
202 if (j < 0) {
203 continue;
204 }
205
206 // do not offer a duplicate item
207 if (as_list && value_list.indexOf(item) != -1) {
208 continue;
209 }
210
211 c += 1;
212 if (c >= 15) {
213 let div = document.createElement("DIV");
214 div.innerHTML = "...";
215 list.appendChild(div);
216 break;
217 } else {
218 let div = document.createElement("DIV");
219 // make the matching letters bold:
220 div.innerHTML =
221 item.substr(0, j) +
222 "<strong>" +
223 item.substr(j, value.length) +
224 "</strong>" +
225 item.substr(j + value.length) +
226 '<input type="hidden" value="' +
227 item +
228 '">';
229
230 div.addEventListener("click", function () {
231 // include selected value
232 const selected = this.getElementsByTagName("input")[0].value;
233 if (as_list) {
234 input.value = value_list.join(" ") + " " + selected;
235 } else {
236 input.value = selected;
237 }
238 // close the list of autocompleted values,
239 closeAllLists();
240 onend(input);
241 });
242
243 list.appendChild(div);
244 }
245 }
246 };
247
248 input.onkeydown = function (e) {
249 let x = document.getElementById(this.id + "-autocomplete-list");
250 if (x) x = x.getElementsByTagName("div");
251 if (e.keyCode == 40) {
252 // key down
253 currentFocus += 1;
254 // and and make the current item more visible:
255 setActive(x);
256 } else if (e.keyCode == 38) {
257 // key up
258 currentFocus -= 1;
259 // and and make the current item more visible:
260 setActive(x);
261 } else if (e.keyCode == 13) {
262 // If the ENTER key is pressed, prevent the form from being submitted,
263 e.preventDefault();
264 if (currentFocus > -1) {
265 // and simulate a click on the 'active' item:
266 if (x) x[currentFocus].click();
267 }
268 }
269 };
270
271 input.onfocus = function () {
272 onend(input);
273 };
274
275 // focus lost
276 input.onblur = function () {
277 onend(input);
278 };
279
280 function setActive(x) {
281 // a function to classify an item as 'active':
282 if (!x) return false;
283 // start by removing the 'active' class on all items:
284 for (let i = 0; i < x.length; i++) {
285 x[i].classList.remove("autocomplete-active");
286 }
287 if (currentFocus >= x.length) currentFocus = 0;
288 if (currentFocus < 0) currentFocus = x.length - 1;
289 // add class 'autocomplete-active':
290 x[currentFocus].classList.add("autocomplete-active");
291 }
292
293 function closeAllLists(elmnt) {
294 // close all autocomplete lists in the document,
295 // except the one passed as an argument:
296 const x = document.getElementsByClassName("autocomplete-items");
297 for (let i = 0; i < x.length; i++) {
298 if (elmnt != x[i] && elmnt != input) {
299 x[i].parentNode.removeChild(x[i]);
300 }
301 }
302 }
303
304 // execute a function when someone clicks in the document:
305 document.addEventListener("click", (e) => {
306 closeAllLists(e.target);
307 });
308 }
309
310 // for attended sysupgrade
311 function updatePackageList(version, target) {
312 // set available packages
313 fetch(
314 config.asu_url +
315 "/" +
316 config.versions[version] +
317 "/" +
318 target +
319 "/index.json"
320 )
321 .then((response) => response.json())
322 .then((all_packages) => {
323 setupAutocompleteList(
324 $("#packages"),
325 all_packages,
326 true,
327 () => {},
328 (textarea) => {
329 textarea.value = split(textarea.value)
330 // make list unique, ignore minus
331 .filter((value, index, self) => {
332 const i = self.indexOf(value.replace(/^-/, ""));
333 return i === index || i < 0;
334 })
335 // limit to available packages, ignore minus
336 .filter(
337 (value) => all_packages.indexOf(value.replace(/^-/, "")) !== -1
338 )
339 .join(" ");
340 }
341 );
342 });
343 }
344
345 function updateImages(version, code, date, model, url, mobj, is_custom) {
346 // add download button for image
347 function addLink(type, file) {
348 const a = document.createElement("A");
349 a.classList.add("download-link");
350 a.href =
351 url.replace("{target}", mobj.target).replace("{version}", version) +
352 "/" +
353 file;
354 const span = document.createElement("SPAN");
355 span.appendChild(document.createTextNode(""));
356 a.appendChild(span);
357 a.appendChild(document.createTextNode(type.toUpperCase()));
358
359 if (config.showHelp) {
360 a.onmouseover = function () {
361 // hide all help texts
362 Array.from(document.getElementsByClassName("download-help")).forEach(
363 (e) => (e.style.display = "none")
364 );
365 const lc = type.toLowerCase();
366 if (lc.includes("sysupgrade")) {
367 show("#sysupgrade-help");
368 } else if (lc.includes("factory") || lc == "trx" || lc == "chk") {
369 show("#factory-help");
370 } else if (
371 lc.includes("kernel") ||
372 lc.includes("zimage") ||
373 lc.includes("uimage")
374 ) {
375 show("#kernel-help");
376 } else if (lc.includes("root")) {
377 show("#rootfs-help");
378 } else if (lc.includes("sdcard")) {
379 show("#sdcard-help");
380 } else if (lc.includes("tftp")) {
381 show("#tftp-help");
382 } else {
383 show("#other-help");
384 }
385 };
386 }
387
388 $("#download-links").appendChild(a);
389 }
390
391 function switchClass(query, from_class, to_class) {
392 $(query).classList.remove(from_class);
393 $(query).classList.add(to_class);
394 }
395
396 // remove all download links
397 Array.from(document.getElementsByClassName("download-link")).forEach((e) =>
398 e.remove()
399 );
400
401 // hide all help texts
402 Array.from(document.getElementsByClassName("download-help")).forEach(
403 (e) => (e.style.display = "none")
404 );
405
406 if (model && url && mobj) {
407 const target = mobj.target;
408 const images = mobj.images;
409
410 // change between "version" and "custom" title
411 if (is_custom) {
412 switchClass("#build-title", "tr-version-build", "tr-custom-build");
413 switchClass(
414 "#downloads-title",
415 "tr-version-downloads",
416 "tr-custom-downloads"
417 );
418 } else {
419 switchClass("#build-title", "tr-custom-build", "tr-version-build");
420 switchClass(
421 "#downloads-title",
422 "tr-custom-downloads",
423 "tr-version-downloads"
424 );
425 }
426
427 // update title translation
428 translate();
429
430 // fill out build info
431 $("#image-model").innerText = model;
432 $("#image-target").innerText = target;
433 $("#image-version").innerText = version;
434 $("#image-code").innerText = mobj["code"] || code;
435 $("#image-date").innerText = date;
436
437 images.sort((a, b) => a.name.localeCompare(b.name));
438
439 for (const i in images) {
440 addLink(images[i].type, images[i].name);
441 }
442
443 if (config.asu_url) {
444 updatePackageList(version, target);
445 }
446
447 show("#images");
448 } else {
449 hide("#images");
450 }
451 }
452
453 function init() {
454 let build_date = "unknown";
455 setupSelectList($("#versions"), Object.keys(config.versions), (version) => {
456 let url = config.versions[version];
457 if (config.asu_url) {
458 url = config.asu_url + "/" + url + "/profiles.json";
459 }
460 fetch(url)
461 .then((obj) => {
462 build_date = obj.headers.get("last-modified");
463 return obj.json();
464 })
465 .then((obj) => {
466 // handle native openwrt json format
467 if ("profiles" in obj) {
468 obj["models"] = {};
469 for (const [key, value] of Object.entries(obj["profiles"])) {
470 value["id"] = key;
471 obj["models"][get_model_titles(value.titles)] = value;
472 }
473 }
474 return obj;
475 })
476 .then((obj) => {
477 setupAutocompleteList(
478 $("#models"),
479 Object.keys(obj["models"]),
480 false,
481 updateImages,
482 (models) => {
483 const model = models.value;
484 if (model in obj["models"]) {
485 const url = obj.download_url || "unknown";
486 const code = obj.version_code || "unknown";
487 const mobj = obj["models"][model];
488 updateImages(version, code, build_date, model, url, mobj, false);
489 current_model = mobj;
490 } else {
491 updateImages();
492 current_model = {};
493 }
494 }
495 );
496
497 // trigger model update when selected version changes
498 $("#models").onfocus();
499 });
500 });
501
502 if (config.asu_url) {
503 show("#custom");
504 }
505
506 // hide fields
507 updateImages();
508
509 // default to browser language
510 const user_lang = (navigator.language || navigator.userLanguage).split(
511 "-"
512 )[0];
513 if (user_lang in translations) {
514 config.language = user_lang;
515 $("#language-selection").value = user_lang;
516 }
517
518 translate();
519
520 $("#language-selection").onclick = function () {
521 config.language = this.children[this.selectedIndex].value;
522 translate();
523 };
524 }