Build options page programatically

Until now we manually created the options page layout, but we want to
add custom elements before each option (for the kill switch
functionality), and this is very cumbersome to do it manually. Thus,
this CL builds the options page layout programatically to be able to
easilly inject a custom component before each option.

Change-Id: Ib110679971fa70c9933be911c4750b7fafa1d40e
diff --git a/src/options/optionsCommon.js b/src/options/optionsCommon.js
new file mode 100644
index 0000000..c552da2
--- /dev/null
+++ b/src/options/optionsCommon.js
@@ -0,0 +1,255 @@
+import {getExtVersion, isFirefox, isReleaseVersion} from '../common/extUtils.js';
+import {cleanUpOptions, optionsPrototype, specialOptions} from '../common/optionsUtils.js';
+import optionsPage from './optionsPage.json5';
+
+var savedSuccessfullyTimeout = null;
+
+const exclusiveOptions = [['thread', 'threadall']];
+
+// Get a URL to a document which is part of the extension documentation (using
+// |ref| as the Git ref).
+function getDocURLWithRef(doc, ref) {
+  return 'https://gerrit.avm99963.com/plugins/gitiles/infinitegforums/+/' +
+      ref + '/docs/' + doc;
+}
+
+// Get a URL to a document which is part of the extension documentation
+// (autodetect the appropriate Git ref)
+function getDocURL(doc) {
+  if (!isReleaseVersion()) return getDocURLWithRef(doc, 'HEAD');
+
+  var version = getExtVersion();
+  return getDocURLWithRef(doc, 'refs/tags/v' + version);
+}
+
+// Get the value of the option set in the options/experiments page
+function getOptionValue(opt) {
+  if (specialOptions.includes(opt)) {
+    switch (opt) {
+      case 'profileindicatoralt_months':
+        return document.getElementById(opt).value || 12;
+
+      case 'ccdarktheme_mode':
+        return document.getElementById(opt).value || 'switch';
+
+      case 'ccdragndropfix':
+        return document.getElementById(opt).checked || false;
+
+      default:
+        console.warn('Unrecognized option: ' + opt);
+        return undefined;
+    }
+  }
+
+  return document.getElementById(opt).checked || false;
+}
+
+// Returns whether the option is included in the current context
+function isOptionShown(opt) {
+  if (!optionsPrototype.hasOwnProperty(opt)) return false;
+  return optionsPrototype[opt].context == window.CONTEXT;
+}
+
+function save(e) {
+  // Validation checks before saving
+  if (isOptionShown('profileindicatoralt_months')) {
+    var months = document.getElementById('profileindicatoralt_months');
+    if (!months.checkValidity()) {
+      console.warn(months.validationMessage);
+      return;
+    }
+  }
+
+  e.preventDefault();
+
+  chrome.storage.sync.get(null, function(items) {
+    var options = cleanUpOptions(items, true);
+
+    // Save
+    Object.keys(options).forEach(function(opt) {
+      if (!isOptionShown(opt)) return;
+      options[opt] = getOptionValue(opt);
+    });
+
+    chrome.storage.sync.set(options, function() {
+      window.close();
+
+      // In browsers like Firefox window.close is not supported:
+      if (savedSuccessfullyTimeout !== null)
+        window.clearTimeout(savedSuccessfullyTimeout);
+
+      document.getElementById('save-indicator').innerText =
+          '✓ ' + chrome.i18n.getMessage('options_saved');
+      savedSuccessfullyTimeout = window.setTimeout(_ => {
+        document.getElementById('save-indicator').innerText = '';
+      }, 3699);
+    });
+  });
+}
+
+function i18n() {
+  document.querySelectorAll('[data-i18n]')
+      .forEach(
+          el => el.innerHTML = chrome.i18n.getMessage(
+              'options_' + el.getAttribute('data-i18n')));
+}
+
+window.addEventListener('load', function() {
+  if (window.CONTEXT == 'options') {
+    if (!isReleaseVersion()) {
+      var experimentsLink = document.querySelector('.experiments-link');
+      experimentsLink.removeAttribute('hidden');
+      experimentsLink.addEventListener('click', _ => chrome.tabs.create({
+        url: chrome.runtime.getURL('options/experiments.html'),
+      }));
+    }
+
+    // Add options to page
+    let optionsContainer = document.getElementById('options-container');
+    for (let section of optionsPage.sections) {
+      if (section?.name) {
+        let sectionHeader = document.createElement('h4');
+        sectionHeader.setAttribute('data-i18n', section.name);
+        optionsContainer.append(sectionHeader);
+      }
+
+      if (section?.options) {
+        for (let option of section.options) {
+          if (option?.customHTML) {
+            optionsContainer.insertAdjacentHTML('beforeend', option.customHTML);
+            continue;
+          }
+
+          let optionEl = document.createElement('div');
+          optionEl.classList.add('option');
+
+          let checkbox = document.createElement('input');
+          checkbox.setAttribute('type', 'checkbox');
+          checkbox.id = option.codename;
+
+          let label = document.createElement('label');
+          label.setAttribute('for', checkbox.id);
+          label.setAttribute('data-i18n', option.codename);
+
+          optionEl.append(checkbox, ' ', label);
+
+          if (option?.experimental) {
+            let experimental = document.createElement('span');
+            experimental.classList.add('experimental-label');
+            experimental.setAttribute('data-i18n', 'experimental_label');
+
+            optionEl.append(experimental);
+          }
+
+          optionsContainer.append(optionEl);
+        }
+      }
+
+      if (section?.footerHTML) {
+        optionsContainer.insertAdjacentHTML('beforeend', section.footerHTML);
+      }
+    }
+
+    var featuresLink = document.querySelector('.features-link');
+    featuresLink.href = getDocURL('features.md');
+
+    var profileIndicatorLink =
+        document.getElementById('profileIndicatorMoreInfo');
+    profileIndicatorLink.href = getDocURL('op_indicator.md');
+  }
+
+  i18n();
+
+  chrome.storage.sync.get(null, function(items) {
+    items = cleanUpOptions(items, false);
+
+    // If some features have been force disabled, communicate this to the user.
+    if (items?._forceDisabledFeatures &&
+        items._forceDisabledFeatures.length > 0) {
+      if (window.CONTEXT == 'options') {
+        document.getElementById('kill-switch-warning')
+            .removeAttribute('hidden');
+      }
+
+      // TODO(avm99963): show a message above each option that has been force
+      // disabled
+    }
+
+    for (var entry of Object.entries(optionsPrototype)) {
+      var opt = entry[0];
+      var optMeta = entry[1];
+
+      if (!isOptionShown(opt)) continue;
+
+      if (specialOptions.includes(opt)) {
+        switch (opt) {
+          case 'profileindicatoralt_months':
+            var input = document.createElement('input');
+            input.type = 'number';
+            input.id = 'profileindicatoralt_months';
+            input.max = '12';
+            input.min = '1';
+            input.value = items[opt];
+            input.required = true;
+            document.getElementById('profileindicatoralt_months--container')
+                .appendChild(input);
+            break;
+
+          case 'ccdarktheme_mode':
+            var select = document.createElement('select');
+            select.id = 'ccdarktheme_mode';
+
+            const modes = ['switch', 'system'];
+            for (const mode of modes) {
+              var modeOption = document.createElement('option');
+              modeOption.value = mode;
+              modeOption.textContent =
+                  chrome.i18n.getMessage('options_ccdarktheme_mode_' + mode);
+              if (items.ccdarktheme_mode == mode) modeOption.selected = true;
+              select.appendChild(modeOption);
+            }
+
+            document.getElementById('ccdarktheme_mode--container')
+                .appendChild(select);
+            break;
+
+          // Firefox doesn't support drag and dropping bookmarks into the text
+          // editor while preserving the bookmark title.
+          case 'ccdragndropfix':
+            var showOption = !isFirefox();
+            if (showOption) {
+              document.getElementById('dragndrop-wrapper')
+                  .removeAttribute('hidden');
+
+              if (items[opt] === true)
+                document.getElementById(opt).checked = true;
+            }
+            break;
+
+          default:
+            console.warn('Unrecognized option: ' + opt);
+            break;
+        }
+        continue;
+      }
+
+      if (items[opt] === true) document.getElementById(opt).checked = true;
+    }
+
+    exclusiveOptions.forEach(exclusive => {
+      if (!isOptionShown(exclusive[0]) || !isOptionShown(exclusive[1])) return;
+
+      exclusive.forEach(
+          el => document.getElementById(el).addEventListener('change', e => {
+            if (document.getElementById(exclusive[0]).checked &&
+                document.getElementById(exclusive[1]).checked) {
+              document
+                  .getElementById(
+                      exclusive[(e.currentTarget.id == exclusive[0] ? 1 : 0)])
+                  .checked = false;
+            }
+          }));
+    });
+    document.querySelector('#save').addEventListener('click', save);
+  });
+});
diff --git a/src/options/optionsPage.json5 b/src/options/optionsPage.json5
new file mode 100644
index 0000000..694c7dc
--- /dev/null
+++ b/src/options/optionsPage.json5
@@ -0,0 +1,40 @@
+// This file defines the sections of the options page, in which section each
+// option appears in, whether the "experimental" label should appear next to the
+// option label, and whether each option should use custom HTML.
+{
+  sections: [
+    {
+      name: null,
+      options: [
+        {codename: 'list'},
+        {codename: 'thread'},
+        {codename: 'threadall'},
+      ],
+    },
+    {
+      name: 'enhancements',
+      options: [
+        {codename: 'fixedtoolbar'},
+        {codename: 'redirect'},
+        {codename: 'history'},
+        {codename: 'loaddrafts', experimental: true},
+        {codename: 'increasecontrast'},
+        {codename: 'stickysidebarheaders'},
+        {codename: 'ccdarktheme'},
+        {codename: 'ccforcehidedrawer'},
+        {codename: 'ccdragndropfix', customHTML: '<div id="dragndrop-wrapper" class="option" hidden><input type="checkbox" id="ccdragndropfix"> <label for="ccdragndropfix" data-i18n="ccdragndropfix"></label></div>'},
+        {codename: 'batchlock'},
+        {codename: 'enhancedannouncementsdot'},
+        {codename: 'repositionexpandthread', experimental: true},
+      ],
+    },
+    {
+      name: 'profileindicator_header',
+      options: [
+        {codename: 'profileindicator'},
+        {codename: 'profileindicatoralt'},
+      ],
+      footerHTML: '<div class="option"><a id="profileIndicatorMoreInfo" target="_blank" rel="noreferrer noopener" data-i18n="profileindicator_moreinfo"></a></div>',
+    },
+  ],
+}