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