www/index.js: allow deep linking
[web/firmware-selector-openwrt-org.git] / www / index.js
index c033ebaf1ddb25d8120b69c8ec764ba4e7806e8c..4258eccef251c5fe150569251d6bd06166295dbe 100644 (file)
+/* global translations, config */
+/* exported build_asu_request, init */
 
-function loadFile(url, callback) {
-  var xmlhttp = new XMLHttpRequest();
-  xmlhttp.onreadystatechange = function() {
-    if (xmlhttp.readyState == 4 && xmlhttp.status == 200) {
-      callback(xmlhttp.responseText, url);
+let current_model = {};
+let url_params = undefined;
+
+function $(query) {
+  if (typeof query === "string") {
+    return document.querySelector(query);
+  } else {
+    return query;
+  }
+}
+
+function show(query) {
+  $(query).style.display = "block";
+}
+
+function hide(query) {
+  $(query).style.display = "none";
+}
+
+function split(str) {
+  return str.match(/[^\s,]+/g) || [];
+}
+
+function get_model_titles(titles) {
+  return titles
+    .map((e) => {
+      if (e.title) {
+        return e.title;
+      } else {
+        return (
+          (e.vendor || "") +
+          " " +
+          (e.model || "") +
+          " " +
+          (e.variant || "")
+        ).trim();
+      }
+    })
+    .join(" / ");
+}
+
+function build_asu_request() {
+  if (!current_model || !current_model.id) {
+    alert("bad profile");
+    return;
+  }
+
+  function showStatus(message, url) {
+    show("#buildstatus");
+    const tr = message.startsWith("tr-") ? message : "";
+    if (url) {
+      $("#buildstatus").innerHTML =
+        '<a href="' + url + '" class="' + tr + '">' + message + "</a>";
+    } else {
+      $("#buildstatus").innerHTML = '<span class="' + tr + '"></span>';
     }
+    translate();
+  }
+
+  // hide image view
+  updateImages();
+
+  show("#buildspinner");
+  showStatus("tr-request-image");
+
+  const request_data = {
+    target: current_model.target,
+    profile: current_model.id,
+    packages: split($("#packages").value),
+    version: $("#versions").value,
   };
-  xmlhttp.open('GET', url, true);
-  xmlhttp.send();
+
+  fetch(config.asu_url + "/api/build", {
+    method: "POST",
+    headers: { "Content-Type": "application/json" },
+    body: JSON.stringify(request_data),
+  })
+    .then((response) => {
+      switch (response.status) {
+        case 200:
+          hide("#buildspinner");
+          showStatus("tr-build-successful");
+
+          response.json().then((mobj) => {
+            const download_url = config.asu_url + "/store/" + mobj.bin_dir;
+            showStatus("tr-build-successful", download_url + "/buildlog.txt");
+            updateImages(
+              mobj.version_number,
+              mobj.version_code,
+              mobj.build_at,
+              get_model_titles(mobj.titles),
+              download_url,
+              mobj,
+              true
+            );
+          });
+          break;
+        case 202:
+          showStatus("tr-check-again");
+          setTimeout(() => {
+            build_asu_request();
+          }, 5000);
+          break;
+        case 400: // bad request
+        case 422: // bad package
+        case 500: // build failed
+          hide("#buildspinner");
+          response.json().then((mobj) => {
+            const message = mobj["message"] || "tr-build-failed";
+            const url = mobj.buildlog
+              ? config.asu_url + "/store/" + mobj.bin_dir + "/buildlog.txt"
+              : undefined;
+            showStatus(message, url);
+          });
+          break;
+      }
+    })
+    .catch((err) => {
+      hide("#buildspinner");
+      showStatus(err);
+    });
 }
 
 function setupSelectList(select, items, onselection) {
-  for (var i = 0; i < items.length; i += 1) {
-    var option = document.createElement("OPTION");
-    option.innerHTML = items[i];
+  for (const item of items.sort().reverse()) {
+    const option = document.createElement("OPTION");
+    option.innerHTML = item;
     select.appendChild(option);
   }
 
-  select.addEventListener("change", function(e) {
+  // pre-select version from URL or config.json
+  const preselect = url_params.get("version") || config.default_version;
+  if (preselect) {
+    $("#versions").value = preselect;
+  }
+
+  select.addEventListener("change", () => {
     onselection(items[select.selectedIndex]);
   });
 
@@ -27,77 +147,102 @@ function setupSelectList(select, items, onselection) {
 }
 
 // Change the translation of the entire document
-function changeLanguage(language) {
-  var mapping = translations[language];
-  if (mapping) {
-    for (var id in mapping) {
-      var elements = document.getElementsByClassName(id);
-      for (var i in elements) {
-        if (elements.hasOwnProperty(i)) {
-          elements[i].innerHTML = mapping[id];
-        }
-      }
-    }
+function translate() {
+  const mapping = translations[config.language];
+  for (const tr in mapping) {
+    Array.from(document.getElementsByClassName(tr)).forEach((e) => {
+      e.innerText = mapping[tr];
+    });
   }
 }
 
-function setupAutocompleteList(input, items, onselection) {
-  // the setupAutocompleteList function takes two arguments,
-  // the text field element and an array of possible autocompleted values:
-  var currentFocus = -1;
+function setupAutocompleteList(input, items, as_list, onbegin, onend) {
+  let currentFocus = -1;
+
+  // sort numbers and other characters separately
+  const collator = new Intl.Collator(undefined, {
+    numeric: true,
+    sensitivity: "base",
+  });
+
+  items.sort(collator.compare);
 
-  // execute a function when someone writes in the text field:
+  input.oninput = function () {
+    onbegin();
 
-  input.oninput = function(e) {
-    console.log("input");
-    // clear images
-    updateImages();
+    let offset = 0;
+    let value = this.value;
+    let value_list = [];
+
+    if (as_list) {
+      // automcomplete last text item
+      offset = this.value.lastIndexOf(" ") + 1;
+      value = this.value.substr(offset);
+      value_list = split(this.value.substr(0, offset));
+    }
 
-    var value = this.value;
     // close any already open lists of autocompleted values
     closeAllLists();
-    if (!value) { return false; }
+
+    if (!value) {
+      return false;
+    }
 
     // create a DIV element that will contain the items (values):
-    var list = document.createElement("DIV");
+    const list = document.createElement("DIV");
     list.setAttribute("id", this.id + "-autocomplete-list");
     list.setAttribute("class", "autocomplete-items");
     // append the DIV element as a child of the autocomplete container:
     this.parentNode.appendChild(list);
 
-    // for each item in the array...
-    var c = 0;
-    for (var i = 0; i < items.length; i += 1) {
-      var item = items[i];
+    function normalize(s) {
+      return s.toUpperCase().replace(/[-_.]/g, " ");
+    }
 
+    const match = normalize(value);
+    let c = 0;
+    for (const item of items) {
       // match
-      var j = item.toUpperCase().indexOf(value.toUpperCase());
+      let j = normalize(item).indexOf(match);
       if (j < 0) {
         continue;
       }
 
+      // do not offer a duplicate item
+      if (as_list && value_list.indexOf(item) != -1) {
+        continue;
+      }
+
       c += 1;
-      if (c >= 10) {
-        var div = document.createElement("DIV");
+      if (c >= 15) {
+        let div = document.createElement("DIV");
         div.innerHTML = "...";
         list.appendChild(div);
         break;
       } else {
-        var div = document.createElement("DIV");
+        let div = document.createElement("DIV");
         // make the matching letters bold:
-        div.innerHTML = item.substr(0, j)
-          + "<strong>" + item.substr(j, value.length) + "</strong>"
-          + item.substr(j + value.length)
-          + "<input type='hidden' value='" + item + "'>";
-
-        div.addEventListener("click", function(e) {
-          // set text field to selected value
-          input.value = this.getElementsByTagName("input")[0].value;
+        div.innerHTML =
+          item.substr(0, j) +
+          "<strong>" +
+          item.substr(j, value.length) +
+          "</strong>" +
+          item.substr(j + value.length) +
+          '<input type="hidden" value="' +
+          item +
+          '">';
+
+        div.addEventListener("click", function () {
+          // include selected value
+          const selected = this.getElementsByTagName("input")[0].value;
+          if (as_list) {
+            input.value = value_list.join(" ") + " " + selected;
+          } else {
+            input.value = selected;
+          }
           // close the list of autocompleted values,
-          // (or any other open lists of autocompleted values:
           closeAllLists();
-          // callback
-          onselection(input.value);
+          onend(input);
         });
 
         list.appendChild(div);
@@ -105,171 +250,330 @@ function setupAutocompleteList(input, items, onselection) {
     }
   };
 
-  input.onkeydown = function(e) {
-      console.log("keydown " + e.keyCode);
-      var x = document.getElementById(this.id + "-autocomplete-list");
-      if (x) x = x.getElementsByTagName("div");
-      if (e.keyCode == 40) {
-        // key down
-        currentFocus += 1;
-        // and and make the current item more visible:
-        setActive(x);
-      } else if (e.keyCode == 38) {
-        // key up
-        currentFocus -= 1;
-        // and and make the current item more visible:
-        setActive(x);
-      } else if (e.keyCode == 13) {
-        // If the ENTER key is pressed, prevent the form from being submitted,
-        e.preventDefault();
-        if (currentFocus > -1) {
-          // and simulate a click on the "active" item:
-          if (x) x[currentFocus].click();
-        }
+  input.onkeydown = function (e) {
+    let x = document.getElementById(this.id + "-autocomplete-list");
+    if (x) x = x.getElementsByTagName("div");
+    if (e.keyCode == 40) {
+      // key down
+      currentFocus += 1;
+      // and and make the current item more visible:
+      setActive(x);
+    } else if (e.keyCode == 38) {
+      // key up
+      currentFocus -= 1;
+      // and and make the current item more visible:
+      setActive(x);
+    } else if (e.keyCode == 13) {
+      // If the ENTER key is pressed, prevent the form from being submitted,
+      e.preventDefault();
+      if (currentFocus > -1) {
+        // and simulate a click on the 'active' item:
+        if (x) x[currentFocus].click();
       }
+    }
   };
 
-  input.onfocus = function() {
-    onselection(input.value);
-  }
+  input.onfocus = function () {
+    onend(input);
+  };
+
+  // focus lost
+  input.onblur = function () {
+    onend(input);
+  };
 
-  function setActive(x) {
-    // a function to classify an item as "active":
-    if (!x) return false;
-    // start by removing the "active" class on all items:
-    for (var i = 0; i < x.length; i++) {
-      x[i].classList.remove("autocomplete-active");
+  function setActive(xs) {
+    // a function to classify an item as 'active':
+    if (!xs) return false;
+    // start by removing the 'active' class on all items:
+    for (const x of xs) {
+      x.classList.remove("autocomplete-active");
     }
-    if (currentFocus >= x.length) currentFocus = 0;
-    if (currentFocus < 0) currentFocus = (x.length - 1);
-    // add class "autocomplete-active":
-    x[currentFocus].classList.add("autocomplete-active");
+    if (currentFocus >= xs.length) currentFocus = 0;
+    if (currentFocus < 0) currentFocus = xs.length - 1;
+    // add class 'autocomplete-active':
+    xs[currentFocus].classList.add("autocomplete-active");
   }
 
   function closeAllLists(elmnt) {
     // close all autocomplete lists in the document,
     // except the one passed as an argument:
-    var x = document.getElementsByClassName("autocomplete-items");
-    for (var i = 0; i < x.length; i++) {
-      if (elmnt != x[i] && elmnt != input) {
-        x[i].parentNode.removeChild(x[i]);
+    const xs = document.getElementsByClassName("autocomplete-items");
+    for (const x of xs) {
+      if (elmnt != x && elmnt != input) {
+        x.parentNode.removeChild(x);
       }
     }
   }
 
   // execute a function when someone clicks in the document:
-  document.addEventListener("click", function (e) {
-      closeAllLists(e.target);
+  document.addEventListener("click", (e) => {
+    closeAllLists(e.target);
   });
 }
 
-function $(id) {
-  return document.getElementById(id);
+// for attended sysupgrade
+function updatePackageList(version, target) {
+  // set available packages
+  fetch(
+    config.asu_url +
+      "/" +
+      config.versions[version] +
+      "/" +
+      target +
+      "/index.json"
+  )
+    .then((response) => response.json())
+    .then((all_packages) => {
+      setupAutocompleteList(
+        $("#packages"),
+        all_packages,
+        true,
+        () => {},
+        (textarea) => {
+          textarea.value = split(textarea.value)
+            // make list unique, ignore minus
+            .filter((value, index, self) => {
+              const i = self.indexOf(value.replace(/^-/, ""));
+              return i === index || i < 0;
+            })
+            // limit to available packages, ignore minus
+            .filter(
+              (value) => all_packages.indexOf(value.replace(/^-/, "")) !== -1
+            )
+            .join(" ");
+        }
+      );
+    });
 }
 
-function extractImageType(name) {
-  var m = /-(sysupgrade|factory|rootfs|kernel|tftp)[-.]/.exec(name);
-  return m ? m[1] : 'factory';
-}
+function updateImages(version, code, date, model, url, mobj, is_custom) {
+  // add download button for image
+  function addLink(type, file) {
+    const a = document.createElement("A");
+    a.classList.add("download-link");
+    a.href =
+      url.replace("{target}", mobj.target).replace("{version}", version) +
+      "/" +
+      file;
+    const span = document.createElement("SPAN");
+    span.appendChild(document.createTextNode(""));
+    a.appendChild(span);
+    a.appendChild(document.createTextNode(type.toUpperCase()));
 
-function updateImages(model, target, release, commit, images) {
-  var types = ['sysupgrade', 'factory', 'rootfs', 'kernel', 'tftp'];
+    if (config.showHelp) {
+      a.onmouseover = function () {
+        // hide all help texts
+        Array.from(document.getElementsByClassName("download-help")).forEach(
+          (e) => (e.style.display = "none")
+        );
+        const lc = type.toLowerCase();
+        if (lc.includes("sysupgrade")) {
+          show("#sysupgrade-help");
+        } else if (lc.includes("factory") || lc == "trx" || lc == "chk") {
+          show("#factory-help");
+        } else if (
+          lc.includes("kernel") ||
+          lc.includes("zimage") ||
+          lc.includes("uimage")
+        ) {
+          show("#kernel-help");
+        } else if (lc.includes("root")) {
+          show("#rootfs-help");
+        } else if (lc.includes("sdcard")) {
+          show("#sdcard-help");
+        } else if (lc.includes("tftp")) {
+          show("#tftp-help");
+        } else {
+          show("#other-help");
+        }
+      };
+    }
 
-  function hideLinks() {
-    types.forEach(function(type) {
-      $(type + '-image').style.display = 'none';
-    });
+    $("#download-links").appendChild(a);
   }
 
-  function hideHelps() {
-    types.forEach(function(type) {
-      $(type + '-help').style.display = 'none';
-    });
+  function switchClass(query, from_class, to_class) {
+    $(query).classList.remove(from_class);
+    $(query).classList.add(to_class);
   }
 
-  function showLink(type, path) {
-    var e = $(type + '-image');
-    e.href = path;
-    e.style.display = 'inline-flex';
-    if (config.showHelp) {
-      e.onmouseover = function() {
-        hideHelps();
-        $(type + '-help').style.display = 'block';
-      };
+  // remove all download links
+  Array.from(document.getElementsByClassName("download-link")).forEach((e) =>
+    e.remove()
+  );
+
+  // hide all help texts
+  Array.from(document.getElementsByClassName("download-help")).forEach(
+    (e) => (e.style.display = "none")
+  );
+
+  if (model && url && mobj) {
+    const target = mobj.target;
+    const images = mobj.images;
+
+    // change between "version" and "custom" title
+    if (is_custom) {
+      switchClass("#build-title", "tr-version-build", "tr-custom-build");
+      switchClass(
+        "#downloads-title",
+        "tr-version-downloads",
+        "tr-custom-downloads"
+      );
+    } else {
+      switchClass("#build-title", "tr-custom-build", "tr-version-build");
+      switchClass(
+        "#downloads-title",
+        "tr-custom-downloads",
+        "tr-version-downloads"
+      );
     }
-  }
 
-  hideLinks();
-  hideHelps();
+    // update title translation
+    translate();
 
-  if (model && target && release && commit && images) {
     // fill out build info
-    $('image-model').innerText = model;
-    $('image-target').innerText = target;
-    $('image-release').innerText = release;
-    $('image-commit').innerText = commit;
-
-    // show links to images
-    for(var i in images) {
-      var file = images[i];
-      var path = config.downloadLink
-        .replace('%target', target)
-        .replace('%release', release)
-        .replace('%file', file)
-        .replace('%commit', commit);
-      var type = extractImageType(file);
-
-      if (types.includes(type)) {
-        showLink(type, path);
-      }
+    $("#image-model").innerText = model;
+    $("#image-target").innerText = target;
+    $("#image-version").innerText = version;
+    $("#image-code").innerText = mobj["code"] || code;
+    $("#image-date").innerText = date;
+
+    images.sort((a, b) => a.name.localeCompare(b.name));
+
+    for (const i in images) {
+      addLink(images[i].type, images[i].name);
     }
 
-    $('images').style.display = 'block';
+    if (config.asu_url) {
+      updatePackageList(version, target);
+    }
+
+    // set current selection in URL
+    history.pushState(
+      null,
+      null,
+      document.location.href.split("?")[0] +
+        "?version=" +
+        encodeURIComponent(version) +
+        "&id=" +
+        encodeURIComponent(mobj["id"])
+    );
+
+    show("#images");
   } else {
-    $('images').style.display = 'none';
+    hide("#images");
   }
 }
 
-// hide fields
-updateImages();
-changeLanguage(config.language);
-
-function parseData(data) {
-  var obj = JSON.parse(data);
-  var out = {};
-  for (var release in obj) {
-    var entries = obj[release]['models'];
-    var commit  = obj[release]['commit']
-    var models = {};
-    for (var i = 0; i < entries.length; i += 1) {
-      var entry = entries[i];
-      var name = (entry[0] + " " + entry[1] + " " + entry[2]).trim();
-      var target = entry[3];
-      var images = entry[4];
-      models[name] = {'name': name, 'target': target, 'commit': commit, 'images': images};
+// Update model title in search box.
+// Device id and model title might change between releases.
+function setModel(obj, id, model) {
+  if (id) {
+    for (const mobj of Object.values(obj["models"])) {
+      if (mobj["id"] == id) {
+        $("#models").value = mobj["model"];
+        return;
+      }
+    }
+  }
+
+  if (model) {
+    for (const mobj of Object.values(obj["models"])) {
+      if (mobj["model"].toLowerCase() == model.toLowerCase()) {
+        $("#models").value = mobj["model"];
+        return;
+      }
     }
-    out[release] = models;
   }
-  return out;
 }
 
-loadFile(config.data, function(data) {
-    var obj = parseData(data);
-    setupSelectList($("releases"), Object.keys(obj), function(release) {
-      console.log("release: " + release);
-      setupAutocompleteList($("models"), Object.keys(obj[release]), function(model) {
-        console.log("clicked " + model);
-        if (model in obj[release]) {
-          var target = obj[release][model].target;
-          var commit = obj[release][model].commit;
-          var images = obj[release][model].images;
-          updateImages(model, target, release, commit, images);
-        } else {
-          updateImages();
+function init() {
+  url_params = new URLSearchParams(window.location.search);
+  let build_date = "unknown";
+
+  setupSelectList($("#versions"), Object.keys(config.versions), (version) => {
+    // A new version was selected
+    let url = config.versions[version];
+    if (config.asu_url) {
+      url = config.asu_url + "/" + url + "/profiles.json";
+    }
+
+    fetch(url)
+      .then((obj) => {
+        build_date = obj.headers.get("last-modified");
+        return obj.json();
+      })
+      .then((obj) => {
+        // handle native openwrt json format
+        if ("profiles" in obj) {
+          obj["models"] = {};
+          for (const [key, value] of Object.entries(obj["profiles"])) {
+            value["id"] = key;
+            obj["models"][get_model_titles(value.titles)] = value;
+          }
         }
+
+        // add key (title) to each model object
+        for (const [title, mobj] of Object.entries(obj["models"])) {
+          mobj["model"] = title;
+        }
+
+        return obj;
+      })
+      .then((obj) => {
+        setupAutocompleteList(
+          $("#models"),
+          Object.keys(obj["models"]),
+          false,
+          updateImages,
+          (models) => {
+            const model = models.value;
+            if (model in obj["models"]) {
+              const url = obj.download_url || "unknown";
+              const code = obj.version_code || "unknown";
+              const mobj = obj["models"][model];
+              updateImages(version, code, build_date, model, url, mobj, false);
+              current_model = mobj;
+            } else {
+              updateImages();
+              current_model = {};
+            }
+          }
+        );
+
+        // set model when selected version changes
+        setModel(
+          obj,
+          current_model["id"] || url_params.get("id"),
+          current_model["model"] || url_params.get("model")
+        );
+
+        // trigger update of current selected model
+        $("#models").onfocus();
       });
+  });
 
-      // trigger model update when selected release changes
-      $("models").onfocus();
-    });
-})
+  if (config.asu_url) {
+    show("#custom");
+  }
+
+  // hide fields
+  updateImages();
+
+  // default to browser language
+  const user_lang = (navigator.language || navigator.userLanguage).split(
+    "-"
+  )[0];
+  if (user_lang in translations) {
+    config.language = user_lang;
+    $("#language-selection").value = user_lang;
+  }
+
+  translate();
+
+  $("#language-selection").onclick = function () {
+    config.language = this.children[this.selectedIndex].value;
+    translate();
+  };
+}