mark.taiwangmail.com / Derpibooru Unified Userscript UI Utility

// ==UserScript==
// @namespace     https://github.com/marktaiwan/
// @exclude       *
// @author        Marker

// ==UserLibrary==
// @name          Derpibooru Unified Userscript UI Utility
// @description   A simple userscript library for script authors to implement user-changeable settings on Derpibooru
// @license       MIT
// @version       1.2.3

// ==/UserScript==

// ==/UserLibrary==


// Workaround for:
//    Error parsing header X-XSS-Protection: 1; mode=block; report=https://derpibooru.report-uri.com/r/d/xss/enforce: reporting URL
//    is not same scheme, host, and port as page at character position 22. The default protections will be applied.
//
//    Failed to read the 'localStorage' property from 'Window': The document is sandboxed and lacks the 'allow-same-origin' flag.
//
// This error occurs when script is executed inside an iframe, such as when the userscript didn't include the @noframes imperative.
if (window.self !== window.top) return;  // Exit when inside iframe

var ConfigManager = (function () {
  'use strict';

  const LIBRARY_NAME = 'Derpibooru Unified Userscript UI Utility';
  const LIBRARY_ID = 'derpi_four_u';
  const SETTINGS_PAGE = (document.querySelector('#js-setting-table') !== null);
  const SETTINGS_TAB_ID = 'userscript';
  const CSS = `
/*** This style is generated by ${LIBRARY_NAME} ***/
.${LIBRARY_ID}__container .block__header__item span {
  font-size: 14px;
  font-weight: bold;
}
.${LIBRARY_ID}--unsaved_warning {
  position: sticky;
  top: 0px;
  line-height: 2em;
  padding-top: 0px;
  padding-bottom: 0px;
  margin-top: -7px;
  border-top-width: 0px;
  opacity: 1;
  transition-property: opacity;
  transition-duration: 0.2s;
}
.${LIBRARY_ID}--unsaved_warning.${LIBRARY_ID}--hidden {
  opacity: 0;
}
.${LIBRARY_ID}--reset_button {
  font-size: 13px;
}
.${LIBRARY_ID}__container .block__subheader legend {
  font-size: 14px;
}
.${LIBRARY_ID}__section__description {
  padding-bottom: 8px;
}
.${LIBRARY_ID}__entry>input.input {
  padding: 2px 6px;
}
.${LIBRARY_ID}__entry input {
  vertical-align: middle;
}
.${LIBRARY_ID}__entry label {
  vertical-align: middle;
  margin-right: 4px;
}
.${LIBRARY_ID}__radio-button-container span {
  margin: 0px 4px;
}
.${LIBRARY_ID}__radio-button-container input {
  margin-right: 4px
}
`;

  // ==Util Functions==
  /** Modified from https://gist.github.com/MoOx/8614711
   * createElement() already taken, I dedicate this function name to Thesaurus.com
   */
  function composeElement(obj) {

    /** https://gist.github.com/youssman/745578062609e8acac9f
     * camelToDash('userId') => "user-id"
     */
    function camelToDash(str) {
      return str.replace(/([a-zA-Z])(?=[A-Z])/g, '$1-').toLowerCase();
    }

    let ele;

    if (obj.tag !== undefined) {
      ele = document.createElement(obj.tag);
      if (obj.attributes !== undefined) {
        for (const attr in obj.attributes) {
          if (obj.attributes.hasOwnProperty(attr)) {
            ele.setAttribute(camelToDash(attr), obj.attributes[attr]);
          }
        }
      }
    } else {
      ele = document.createDocumentFragment();
    }
    if (obj.html !== undefined) ele.innerHTML = obj.html;
    if (obj.text) ele.appendChild(document.createTextNode(obj.text));
    if (Array.isArray(obj.children)) {
      for (const child of obj.children) {
        ele.appendChild((child instanceof window.HTMLElement) ? child : composeElement(child));
      }
    }

    return ele;
  }

  function getQueryVariable(key) {
    let i;
    const array = window.location.search.substring(1).split('&');

    for (i = 0; i < array.length; i++) {
      if (key == array[i].split('=')[0]) return array[i].split('=')[1];
    }
  }

  // ==!Util Functions==


  function validateIdentifier(string) {
    if (!(/^(?=[^\d])(?=\w)[a-zA-Z\d_-]+$/).test(string)) {
      throw Error(`"${string}" is not a valid identifier`);
    }
  }

  // function takes in an array of required property names
  // and throws exception if any of them is undefined in obj
  function validateParameters(requiredParams, obj) {
    const array = [];

    for (const param of requiredParams) {
      if (obj[param] === undefined) {
        array.push(param);
      }
      // additional dependency for radio and dropdown input type
      if ((param == 'radio' || param == 'dropdown') &&
        (obj.selections === undefined || obj.selections.length <= 0)) {
          array.push('selections');
      }
    }

    if (array.length > 0) {
      throw {type: 'missing params', arr: array, o: obj};
    }
  }

  function initStorage() {
    if (!localStorage.getItem(LIBRARY_ID)) {
      const storage = {};
      storage[LIBRARY_ID] = {};
      setStorage(storage);
    }
  }

  function getStorage() {
    return JSON.parse(localStorage.getItem(LIBRARY_ID));
  }

  function setStorage(obj) {
    localStorage.setItem(LIBRARY_ID, JSON.stringify(obj));
  }

  function storeSettings(scriptId, key, value) {
    const storage = getStorage();
    storage[scriptId][key] = value;
    setStorage(storage);
  }

  function retrieveSettings(scriptId, key) {
    const storage = getStorage();
    return storage[scriptId][key];
  }

  /**
   * Display warning when one or more inputs had been changed.
   */
  function checkForUnsavedChanges() {
    const storage = getStorage();
    const userscriptTabContent = document.querySelector(`[data-tab="${SETTINGS_TAB_ID}"]`);
    const scriptContainers = userscriptTabContent.querySelectorAll('[data-script-id]');
    const warningBanner = document.querySelector(`.${LIBRARY_ID}--unsaved_warning`);
    let unsaved_changes = false;

    for (const container of scriptContainers) {
      const scriptId = container.dataset.scriptId;
      const inputElements = container.querySelectorAll('[data-entry-key]');

      for (const input of inputElements) {
        const key = input.dataset.entryKey;
        const propType = input.dataset.entryPropertyType;
        const storedValue = storage[scriptId][key];

        if (input[propType] !== storedValue) {
          unsaved_changes = true;
          break; // break out of loop early
        }
      }
      if (unsaved_changes) {
        break;
      }
    }

    if (unsaved_changes) {
      warningBanner.classList.remove(`${LIBRARY_ID}--hidden`);
    } else {
      warningBanner.classList.add(`${LIBRARY_ID}--hidden`);
    }
  }

  function bindSaveHandler(saveBtn) {
    saveBtn.addEventListener('click', function () {
      const storage = getStorage();
      const userscriptTabContent = document.querySelector(`[data-tab="${SETTINGS_TAB_ID}"]`);
      const scriptContainers = userscriptTabContent.querySelectorAll('[data-script-id]');

      for (const container of scriptContainers) {
        const scriptId = container.dataset.scriptId;
        const inputElements = container.querySelectorAll('[data-entry-key]');

        for (const input of inputElements) {
          const key = input.dataset.entryKey;
          const propType = input.dataset.entryPropertyType;
          const inputValue = input[propType];

          storage[scriptId][key] = inputValue;
        }
      }
      setStorage(storage);
    });
  }

  function bindResetHandler(resetBtn) {
    resetBtn.addEventListener('click', function (e) {
      e.preventDefault();

      const btn = e.target;
      const scriptId = btn.dataset.scriptId;
      let selector = '[data-default-value]';

      // modify selector to target only a single script container
      if (resetBtn.parentElement.dataset.resetAll !== '1') {
        selector = `.${LIBRARY_ID}__container[data-script-id="${scriptId}"] ${selector}`;
      }

      const userscriptTabContent = document.querySelector(`[data-tab="${SETTINGS_TAB_ID}"]`);
      const inputs = userscriptTabContent.querySelectorAll(selector);
      for (const input of inputs) {
        const propType = input.dataset.entryPropertyType;
        let defaultValue = input.dataset.defaultValue;

        //  input[type="checkbox"] accepts boolean values, but data-default-value stores 'true' 'false' strings.
        if (propType == 'checked') {
          defaultValue = (defaultValue == 'true');
        }
        //  input[type="number"] uses valueAsNumber property for reading and storing values.
        if (propType == 'valueAsNumber') {
          defaultValue = Number.parseFloat(defaultValue);
        }

        input[propType] = defaultValue;
      }
      checkForUnsavedChanges();
    });
  }

  function initSettingsTab() {
    const userscriptTabContent = document.querySelector(`[data-tab="${SETTINGS_TAB_ID}"]`);
    const settingTable = document.querySelector('#js-setting-table');

    if (!SETTINGS_PAGE || userscriptTabContent !== null) {
      return;
    }

    if (!document.getElementById(`${LIBRARY_ID}-style`)) {
      const styleElement = document.createElement('style');
      styleElement.setAttribute('type', 'text/css');
      styleElement.id = `${LIBRARY_ID}-style`;
      styleElement.innerHTML = CSS;
      document.body.insertAdjacentElement('afterend', styleElement);
    }

    // Create tab
    const tabHeader = composeElement({
      tag: 'a',
      attributes: {dataClickTab: SETTINGS_TAB_ID, href: '#'},
      text: 'Userscript'
    });

    // Create tab content
    const tabContent = composeElement({
      tag: 'div',
      attributes: {class: 'block__tab hidden', dataTab: SETTINGS_TAB_ID},
      children: [{
        tag: 'div',
        attributes: {class: 'block block--fixed block--primary flex'},
        children: [{
          tag: 'span',
          text: 'Settings on this tab are managed by installed userscripts and stored locally.'
        },{
          tag: 'div',
          attributes: {class: `flex__right ${LIBRARY_ID}--reset_button`, dataResetAll: '1'},
          children: [{
            tag: 'a',
            attributes: {href: '#'},
            text: 'Reset all settings'
          }]
        }]
      },{
        tag: 'div',
        attributes: {
          class: `block block--fixed block--warning ${LIBRARY_ID}--unsaved_warning ${LIBRARY_ID}--hidden`
        },
        text: 'You have unsaved changes.'
      }]
    });

    try {
      // 'input' used by original booru-on-rails
      // 'button' used by Philomena
      bindSaveHandler(document.querySelector('form[action="/settings"] button[type="submit"], form[action="/settings"] input[type="submit"]'));

      bindResetHandler(tabContent.querySelector(`.${LIBRARY_ID}--reset_button>a`));

      // Insert tab header and content
      settingTable.querySelector('.block__header--js-tabbed').appendChild(tabHeader);
      settingTable.querySelector('.block__tab:last-of-type').insertAdjacentElement('afterend', tabContent);
    } catch (e) {
      // Reset page in case of errors
      tabHeader.remove();
      tabContent.remove();
      console.log(e);
      return;
    }

    // Auto focus on tab if link is of the format "https://derpibooru.org/settings?active_tab=userscript"
    try {
      const activeTabId = getQueryVariable('active_tab');
      if (activeTabId !== undefined) {
        const activeTab = settingTable.querySelector(`[data-click-tab=${activeTabId}]`);
        const activeTabContent = settingTable.querySelector(`[data-tab=${activeTabId}]`);
        const visibleTab = settingTable.querySelector('.selected[data-click-tab]');
        const visibleTabContent = settingTable.querySelector('[data-tab]:not(.hidden)');

        if ([activeTab, activeTabContent, visibleTab, visibleTabContent].some(ele => ele === null)) {
          throw 'Missing tab element';
        }

        visibleTab.classList.remove('selected');
        visibleTabContent.classList.add('hidden');
        activeTab.classList.add('selected');
        activeTabContent.classList.remove('hidden');
      }
    } catch (e) {
      console.log(e);
    }
  }

  function appendScriptContainer(name, id, description) {
    const userscriptTabContent = document.querySelector(`[data-tab="${SETTINGS_TAB_ID}"]`);
    const ele = composeElement({
      tag: 'div',
      attributes: {class: `block ${LIBRARY_ID}__container`, dataScriptId: id},
      children: [{
        tag: 'div',
        attributes: {class: 'block__header block__header__item flex'},
        children: [{
          tag: 'span',
          text: name
        },{
          tag: 'div',
          attributes: {class: `flex__right ${LIBRARY_ID}--reset_button`, dataResetAll: '0'},
          children: [{
            tag: 'a',
            attributes: {href: '#', dataScriptId: id},
            text: 'Default'
          }]
        }]
      }, {
        tag: 'div',
        attributes: {class: 'block__content'}
      }]
    });
    bindResetHandler(ele.querySelector(`.${LIBRARY_ID}--reset_button>a`));

    appendDescription(ele.lastChild, description);
    ele.addEventListener('change', checkForUnsavedChanges); // attach handler to show warning when input value changed

    return userscriptTabContent.appendChild(ele).lastChild;
  }

  function appendFieldset(name, id, description, parent) {
    const ele = composeElement({
      tag: 'fieldset',
      attributes: {class: `field ${LIBRARY_ID}__subheader`, dataFieldId: id},
      children: [{
        tag: 'legend',
        text: name
      }]
    });
    appendDescription(ele, description);
    return parent.appendChild(ele);
  }

  function appendDescription(node, string) {
    if (string === undefined) return;

    const ele = composeElement({
      tag: 'div',
      attributes: {class: 'fieldlabel'},
      children: [{
        tag: 'i',
        text: string
      }]
    });

    // Headers and subheaders require additional styling, add class for CSS to target
    if ((node.parentElement && node.parentElement.classList.contains(`${LIBRARY_ID}__container`)) ||
      node.classList.contains(`${LIBRARY_ID}__subheader`)) {
        ele.classList.add(`${LIBRARY_ID}__section__description`);
    }

    return node.appendChild(ele);
  }

  function ConfigManager(scriptName, scriptId, scriptDescription) {
    validateIdentifier(scriptId);

    const config = new ConfigObject(scriptName, scriptId, scriptId, null, scriptDescription, appendScriptContainer);
    const storage = getStorage();
    // initialize key in setting storage
    if (storage[scriptId] === undefined) {
      storage[scriptId] = {};
      setStorage(storage);
    }
    return Object.freeze(config);
  }

  function ConfigObject(title, id, scriptId, parent, description, appendFn) {
    validateIdentifier(id);
    this.title = title;
    this.id = id;
    this.description = description;
    this.scriptId = scriptId;
    this.pageElement = (SETTINGS_PAGE) ? appendFn(title, id, description, parent) : null;
    this.parentElement = parent;
  }

  ConfigObject.prototype.addFieldset = function (title, id, fieldDescription) {
    return Object.freeze(
      new ConfigObject(title, id, this.scriptId, this.pageElement, fieldDescription, appendFieldset)
    );
  };

  ConfigObject.prototype.registerSetting = function (entryConfig) {
    try {
      validateParameters(['title', 'key', 'type', 'defaultValue'], entryConfig);
      const {title: entryTitle, key: entryKey, type, defaultValue, description, selections} = entryConfig;
      const scriptId = this.scriptId;
      let storedValue = retrieveSettings(scriptId, entryKey);
      if (storedValue === undefined) {
        storeSettings(scriptId, entryKey, defaultValue); // initialize key into storage
        storedValue = defaultValue;
      }

      /**
       * Basic workflow:
       *   - Build elements in memory
       *   - Display <input> elements based on storedValue
       *   - Attach elements to page
       */
      // prefix the element id and classes to minimize chance of conflict
      const namespacedKey = `${scriptId}__${entryKey.replace(/\s/g,'')}`;
      // entry container is common for all input types
      const ele = composeElement({
        tag: 'div',
        attributes: {class: `field ${LIBRARY_ID}__entry`, dataEntryId: namespacedKey}
      });
      switch (type) {
        case 'checkbox': {
          ele.appendChild(composeElement({
            children: [{
              tag: 'label',
              text: entryTitle,
              attributes: {for: namespacedKey}
            },{
              tag: 'input',
              attributes: {
                id: namespacedKey,
                type: 'checkbox',
                dataDefaultValue: defaultValue,
                dataEntryKey: entryKey,
                dataEntryPropertyType: 'checked'
              }
            }]
          }));
          break;
        }
        case 'text': {
          ele.appendChild(composeElement({
            children: [{
              tag: 'label',
              text: entryTitle,
              attributes: {for: namespacedKey}
            },{
              tag: 'input',
              attributes: {
                class: 'input',
                id: namespacedKey,
                type: 'text',
                autocomplete: 'off',
                dataDefaultValue: defaultValue,
                dataEntryKey: entryKey,
                dataEntryPropertyType: 'value'
              }
            }]
          }));
          break;
        }
        case 'number': {
          ele.appendChild(composeElement({
            children: [{
              tag: 'label',
              text: entryTitle,
              attributes: {for: namespacedKey}
            },{
              tag: 'input',
              attributes: {
                class: 'input',
                id: namespacedKey,
                type: 'number',
                dataDefaultValue: defaultValue,
                dataEntryKey: entryKey,
                dataEntryPropertyType: 'valueAsNumber'
              }
            }]
          }));
          break;
        }
        case 'radio': {
          ele.appendChild(composeElement({
            tag: 'label',
            text: entryTitle
          }));
          // Append radio buttons
          const buttonSet = ele.appendChild(composeElement({
            tag: 'span',
            attributes: {
              class: `${LIBRARY_ID}__radio-button-container`,
              dataDefaultValue: defaultValue,
              dataEntryKey: entryKey,
              dataEntryPropertyType: 'value'
            }
          }));

          /**
           *  Radio buttons behaves like checkboxes except that only one can be
           *  selected at a time, we make them act more like dropdown lists by assigning
           *  setter and getter to their containers to emulate the 'value' property
           */
          Object.defineProperty(buttonSet, 'value', {
            get: function () {
              return this.querySelector('input:checked').value;
            },
            set: function (val) {
              this.querySelector(`input[value="${val}"]`).checked = true;
            }
          });
          let n = 1;
          for (const selection of selections) {
            const selectionId = namespacedKey + '-' + n;  // Generate unique ID for each radio button
            n = n + 1;
            const span = composeElement({
              tag: 'span',
              children: [{
                tag: 'input',
                attributes: {
                  type: 'radio',
                  name: namespacedKey,
                  id: selectionId,
                  value: selection.value
                }
              }, {
                tag: 'label',
                attributes: {for: selectionId},
                text: selection.text
              }]
            });
            buttonSet.appendChild(span);
          }
          break;
        }
        case 'dropdown': {
          ele.appendChild(composeElement({
            tag: 'label',
            attributes: {for: namespacedKey},
            text: entryTitle
          }));
          // Append dropdown
          const selectElement = ele.appendChild(composeElement({
            tag: 'select',
            attributes: {
              class: `input ${LIBRARY_ID}__dropdown-list`,
              id: namespacedKey,
              dataDefaultValue: defaultValue,
              dataEntryKey: entryKey,
              dataEntryPropertyType: 'value'
            }
          }));
          for (const selection of selections) {
            selectElement.appendChild(composeElement({
              tag: 'option',
              attributes: {value: selection.value},
              text: selection.text
            }));
          }
          break;
        }
        default: {
          throw Error(`'${type}' does not match any supported input types`);
        }
      }
      appendDescription(ele, description);
      const inputElement = ele.querySelector('[data-default-value]');
      const propType = inputElement.dataset.entryPropertyType;
      inputElement[propType] = storedValue;

      return SETTINGS_PAGE ? this.pageElement.appendChild(ele) : ele;
    } catch (e) {
      // log the error
      if (e.type == 'missing params') {
        console.error(`Missing the following required parameters:\n\t[${e.arr.join(', ')}]\nin object:\n`, e.o);
      } else {
        console.error(e);
      }
    }
  };

  ConfigObject.prototype.setEntry = function (key, value) {
    storeSettings(this.scriptId, key, value);
  };

  ConfigObject.prototype.getEntry = function (key) {
    return retrieveSettings(this.scriptId, key);
  };

  ConfigObject.prototype.deleteEntry = function (key) {
    const storage = getStorage();
    const scriptId = this.scriptId;
    delete storage[scriptId][key];
    setStorage(storage);
  };

  initStorage();
  initSettingsTab();
  return ConfigManager;
})();