Delicious Userscript Library

A library for userscripts on AnimeBytes which implements a settings page, among other things. Made by momentary0 on github (https://github.com/momentary0/AB-Userscripts/). I just uploaded here for use in my script since I couldn't get greasyfork to approve my require even with integrity checks.

Ce script ne devrait pas être installé directement. C'est une librairie créée pour d'autres scripts. Elle doit être inclus avec la commande // @require https://update.greasyfork.org/scripts/456220/1125927/Delicious%20Userscript%20Library.js

/**
 * @file    Library for userscripts on AnimeBytes.
 * @author  TheFallingMan
 * @version 1.1.0
 * @license GPL-3.0
 *
 * Exports `delicious`, containing `delicious.settings` and
 * `delicious.utilities`.
 *
 * This implements settings, providing functions for storing and setting
 * values, and methods to create an organised userscript settings page within
 * the user's profile settings.
 *
 * Additionally, provides several (hopefully) useful functions through
 * `delicious.utilities`.
 */

/* global GM_setValue:false, GM_getValue:false */

/**
 * @namespace
 * Root namespace for the delicious library.
 */
var delicious = (function ABDeliciousLibrary(){ // eslint-disable-line no-unused-vars
    "use strict";

    /**
     * A helper function for creating a HTML element, defining some properties
     * on it and appending child nodes.
     *
     * @param {string} tagName The type of element to create.
     * @param {Object.<string, any>} properties
     * An object containing properties to set on the new element.
     * Note: does not support nested elements (e.g. "style.width" does _not_ work).
     * @param {(Node[]|string[])} children Child nodes and/or text to append.
     */
    function newElement(tagName, properties, children) {
        var elem = document.createElement(tagName);
        if (properties) {
            for (var key in properties) {
                if (properties.hasOwnProperty(key)) {
                    elem[key] = properties[key];
                }
            }
        }
        if (children) {
            for (var i = 0; i < children.length; i++) {
                if (typeof children[i] === 'string') {
                    elem.appendChild(document.createTextNode(children[i]));
                } else {
                    elem.appendChild(children[i]);
                }
            }
        }
        return elem;
    }

    /**
     * Logs a message to the debug console, prefixing it if it is a string.
     *
     * @param {any} message
     */
    function log(message) {
        console.debug(
            typeof message === 'string' ? ('[Delicious] '+message) : message
        );
    }

    /**
     * Uesful Javascript functions related to AnimeBytes.
     */
    var utilities = {
        /**
         * Click handler for those triangles which drop down menus. Toggles
         * displaying the associated submenu.
         *
         * @param {MouseEvent} ev
         */
        toggleSubnav: function(ev) {
            // Begin at the bound element.
            var current = ev.target;
            // Keep traversing up the node's parents until we find an
            // adjacent .subnav element.
            while (current
                    && !(current.nextSibling && current.nextSibling.classList.contains('subnav'))) {
                current = current.parentNode;
            }
            if (!current)
                return;
            var subnav = current.nextSibling;

            // Remove already open menus.
            var l = document.querySelectorAll('ul.subnav');
            for (var i = 0; i < l.length; i++) {
                if (l[i] === subnav)
                    continue;
                l[i].style.display = 'none';
            }
            var k = document.querySelectorAll('li.navmenu.selected');
            for (var j = 0; j < k.length; j++) {
                k[j].classList.remove('selected');
            }

            // Logic to toggle visibility.
            var willShow = (subnav.style.display==='none');
            subnav.style.display = willShow?'block':'none';
            if (willShow)
                subnav.parentNode.classList.add('selected');
            else
                subnav.parentNode.classList.remove('selected');

            ev.stopPropagation();
            ev.preventDefault();
            return false;
        },

        /**
         * Applies default options to an object containing possibly
         * incomplete options.
         *
         * @param {Object.<string, any>} options User-specified options.
         * @param {Object.<string, any>} defaults Default options.
         * @returns {Object.<string, any>} Object containing user-specified
         * option if it is present, else the default.
         */
        applyDefaults: function(options, defaults) {
            if (!options)
                return defaults;
            var newObject = {};
            for (var key in defaults) {
                if (defaults.hasOwnProperty(key)) {
                    if (options.hasOwnProperty(key))
                        newObject[key] = options[key];
                    else
                        newObject[key] = defaults[key];
                }
            }
            for (var key2 in options) {
                if (!newObject.hasOwnProperty(key2)) {
                    newObject[key2] = options[key2];
                }
            }
            return newObject;
        },

        /**
         * Makes the given text suitable for inserting into HTML as text.
         *
         * @param {string} text Bare text.
         * @returns {string} HTML escaped text.
         */
        htmlEscape: function(text) {
            return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
        },

        /** A non-breaking space character. */
        nbsp: '\xa0',

        _bytes_units: ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'],
        _bytes_base: 1024,

        /**
         * Parses a string containing a number of bytes (e.g. "4.25 GiB") and
         * returns the number of bytes.
         *
         * Note: uses IEC prefixes (KiB, MiB, etc.).
         *
         * @param {string} bytesString Bytes as string.
         * @returns {number} Number of bytes.
         */
        parseBytes: function(bytesString) {
            var split = bytesString.split(/\s+/);
            var significand = parseFloat(split[0]);
            var magnitude = this._bytes_units.indexOf(split[1]);
            if (magnitude === -1)
                throw 'Bytes unit not recognised. Make sure you are using IEC prefixes (KiB, MiB, etc.)';
            return significand * Math.pow(this._bytes_base, magnitude);
        },

        /**
         * Formats a number of bytes as a string with an appropriate unit.
         *
         * @param {number} numBytes Number of bytes
         * @param {number} [decimals=2] Number of decimal places to use.
         * @returns {string} Bytes formatted as string.
         */
        formatBytes: function(numBytes, decimals) {
            // Adapted from https://stackoverflow.com/a/18650828
            if (numBytes === 0)
                return '0 ' + this._bytes_units[0];
            if (decimals === undefined)
                decimals = 2;
            var magnitude = Math.floor(Math.log(numBytes) / Math.log(this._bytes_base));
            // Extra parseFloat is so trailing 0's are removed.
            return parseFloat(
                (numBytes / Math.pow(this._bytes_base, magnitude)).toFixed(decimals)
            ) + ' ' + this._bytes_units[magnitude];
        },

        /**
         * Given a element.dataset property name in camelCase, returns the corresponding
         * data- attribute name with hyphens.
         * @param {string} str JS `dataset` name.
         * @returns {string} HTML `data-` name.
         */
        toDataAttr: function(str) {
            return 'data-'+str.replace(/[A-Z]/g, function(a){return '-'+a.toLowerCase();});
        }
    };

    var _isSettingsPage = window.location.href.indexOf('/user.php?action=edit') !== -1;

    /**
     * Container for all setting-related functions.
     */
    var settings = {
        /** Prefix used when setting element ID attributes. */
        _idPrefix: 'setting_',
        /** Event type used when saving. */
        _eventName: 'deliciousSave',
        /** Data attribute JS name for primary keys. */
        _settingKey: 'settingKey',
        /** Data attribute JS name for subkeys. */
        _settingSubkey: 'settingSubkey',

        /** HTML attribute name for primary keys. */
        _dataSettingKey: utilities.toDataAttr('settingKey'),
        /** HTML attribute name for primary subkeys. */
        _dataSettingSubkey: utilities.toDataAttr('settingSubkey'),

        /** Whether this page is a user settings page. */
        isSettingsPage: _isSettingsPage,

        /**
         * Creates the delicious settings `div`.
         * @returns {HTMLDivElement}
         */
        _createDeliciousPage: function() {
            log('Creating settings page...');
            var settingsDiv = document.createElement('div');
            settingsDiv.id = 'delicious_settings';

            var header = document.createElement('div');
            header.className = 'head colhead_dark strong';
            header.textContent = 'Userscript Settings';
            settingsDiv.appendChild(header);

            var settingsList = document.createElement('ul');
            settingsList.className = 'nobullet ue_list';

            var simpleSection = document.createElement('div');
            simpleSection.id = 'delicious_basic_settings';
            settingsList.appendChild(simpleSection);

            settingsDiv.appendChild(settingsList);

            return settingsDiv;
        },

        /**
         * Click handler for user profile tab links. Displays the clicked page
         * and hides any other page.
         *
         * @param {MouseEvent} ev
         */
        _tabLinkClick: function(ev) {
            log('Clicked tab link: ' + ev.target.textContent);
            var clickedId = ev.target.getAttribute('href').replace(/^#/, '');
            document.querySelector('.ue_tabs .selected').classList.remove('selected');
            var tabs = document.querySelectorAll('#tabs > div');
            for (var i = 0; i < tabs.length; i++) {
                tabs[i].style.display = (tabs[i].id === clickedId)?'block':'none';
            }

            ev.target.classList.add('selected');
            ev.stopPropagation();
            ev.preventDefault();
            return false;
        },

        /**
         * Attaches our click handler to the existing tab links.
         */
        _relinkClickHandlers: function() {
            log('Rebinding tab click handlers...');
            var tabLinks = document.querySelectorAll('.ue_tabs a');
            for (var i = 0; i < tabLinks.length; i++) {
                tabLinks[i].addEventListener('click', this._tabLinkClick);
            }
        },

        /**
         * Inserts the given settings div into the user settings page.
         * @param {string} label Name to display for this page.
         * @param {HTMLDivElement} settingsPage Element containing the page.
         */
        insertSettingsPage: function(label, settingsPage) {
            log('Inserting a settings page...');
            var linkItem = document.createElement('li');
            linkItem.appendChild(document.createTextNode('•'));

            var link = document.createElement('a');
            link.href = '#' + settingsPage.id;
            link.textContent = label;
            linkItem.appendChild(link);

            document.querySelector('.ue_tabs').appendChild(linkItem);
            this._relinkClickHandlers();

            settingsPage.style.display = 'none';
            var tabs = document.querySelector('#tabs');
            tabs.insertBefore(settingsPage, tabs.lastElementChild);
        },

        /**
         * Inserts the delicious settings page. Attaches a listener to the
         * form `submit` event, and temporarily disables the default `onsubmit`
         * attribute which is set.
         */
        _insertDeliciousSettings: function() {
            this.insertSettingsPage('Userscript Settings',
                this._createDeliciousPage());

            var userform = document.querySelector('form#userform');
            userform.addEventListener('submit', this._deliciousSaveAndSubmit);

            if (userform.hasAttribute('onsubmit')) {
                userform.dataset['onsubmit'] = userform.getAttribute('onsubmit');
                userform.removeAttribute('onsubmit');
                log('Previous onsubmit: ' + userform.dataset['onsubmit']);
            }
        },

        _settingsInserted: !!document.getElementById('delicious_settings'),

        /**
         * Ensures the settings page has been inserted, creating and inserting
         * it if the page is a user settings page.
         *
         * Returns true if on the user settings page, false otherwise.
         */
        ensureSettingsInserted: function() {
            if (!this.isSettingsPage) {
                log('Not a profile settings page; doing nothing...');
                if (!this.rootSettingsList) {
                    this._basicSection = newElement('div',
                        {id: 'delicious_basic_settings',
                            className: 'dummy'});
                    this.rootSettingsList = newElement('ul',
                        {className: 'dummy nobullet ue_list'},
                        [this._basicSection]);
                }
                return false;
            } else {
                if (!this._settingsInserted) {
                    log('Settings not yet inserted; inserting...');
                    this._settingsInserted = true;

                    this._insertDeliciousSettings();
                } else {
                    log('Settings already inserted; continuing...');
                }
                if (!this.rootSettingsList) {
                    log('Locating settings div...');
                    this.rootSettingsList = document.querySelector('#delicious_settings .ue_list');
                    this._basicSection = this.rootSettingsList.querySelector('#delicious_basic_settings');
                }
                return true;
            }
        },

        /**
         * Saves the settings and submits the rest of the user settings form.
         * @param {Event} ev Form element.
         */
        _deliciousSaveAndSubmit: function(ev) {
            if (settings.saveAllSettings(ev.target)) {
                ev.target.removeEventListener('submit', settings._deliciousSaveAndSubmit);
                if (ev.target.dataset['onsubmit'])
                    ev.target.setAttribute('onsubmit', ev.target.dataset['onsubmit']);
                ev.target.submit();
            } else {
                var errorBox = document.querySelector('.error_message');
                if (errorBox)
                    errorBox.scrollIntoView();
                ev.stopPropagation();
                ev.preventDefault();
            }
        },

        /**
         * Sends the save event to all elements contained within `rootElement`
         * and with the appropriate `data-` settings attribute set.
         * @param {HTMLElement} rootElement Root element.
         * @returns {boolean} True if all elements saved successfully, false otherwise.
         */
        saveAllSettings: function(rootElement) {
            log('Saving all settings...');
            var cancelled = false;
            var settingsItems = rootElement.querySelectorAll('['+this._dataSettingKey+']');
            for (var i = 0; i < settingsItems.length; i++) {
                log('Sending save event for setting key: ' + settingsItems[i].dataset[this._settingKey]);
                var saveEvent = new Event(this._eventName, {cancelable: true});
                if (!settingsItems[i].dispatchEvent(saveEvent)) {
                    cancelled = true;
                }
            }
            log('Form submit cancelled: ' + cancelled);
            return !cancelled;
        },

        /**
         * Saves an element, reading the key from its dataset and
         * the value from its `property` attribute.
         * @param {HTMLElement} element
         * @param {string} property
         */
        saveOneElement: function(element, property) {
            if (element.dataset[this._settingKey])
                this.set(element.dataset[this._settingKey], element[property]);
            else
                log('Skipping blank: ' + element.outerHTML);
        },

        /**
         * If `key` is not set, set it to `defaultValue` and returns `defaultValue`.
         * Otherwise, returns the stored value.
         * @param {string} key
         * @param {any} defaultValue
         * @returns {any}
         */
        init: function(key, defaultValue) {
            var value = this.get(key, undefined);
            if (value === undefined) {
                this.set(key, defaultValue);
                return defaultValue;
            } else {
                return value;
            }
        },

        /**
         * Sets `key` to `value`. Currently uses GM_setValue, storing internally
         * as JSON.
         */
        set: function(key, value) {
            GM_setValue(key, JSON.stringify(value));
        },

        /**
         * Gets `key`, returns `defaultValue` if it is not set.
         * Currently uses GM_getValue, storing internally as JSON.
         */
        get: function(key, defaultValue) {
            var value = GM_getValue(key, undefined);
            if (value !== undefined) {
                return JSON.parse(value);
            } else {
                return defaultValue;
            }
        },

        /**
         * Migrates a string stored in `key` as a bare string to a
         * JSON encoded string.
         * @param {string} key Setting key.
         * @returns {any} String value.
         */
        _migrateStringSetting: function(key) {
            var val;
            try {
                val = this.get(key);
            } catch (exc) {
                if (exc instanceof SyntaxError
                    && GM_getValue(key, undefined) !== undefined) {
                    // Assume the current variable is a bare string.
                    // Re-store it as a JSON string.
                    val = GM_getValue(key);
                    this.set(key, val);
                } else {
                    throw exc; // Something else happened
                }
            }
            return val;
        },

        /**
         * Inserts `newElement` as a chlid of `rootElement` sorted, by comparing
         * `newText` to each element's textContent.
         *
         * If `refElement` is specified, will start _after_ `refElement`.
         *
         * @param {HTMLElement} newElement Element to insert.
         * @param {HTMLElement} rootElement Parent element to insert `newElement` into.
         * @param {HTMLElement} [refElement] Reference element to insert after this or later.
         */
        _insertSorted: function(newElement, rootElement, refElement) {
            var current = rootElement.firstElementChild;
            if (refElement) {
                if (refElement.parentNode !== rootElement)
                    throw 'refElement is not a direct child of rootElement';
                current = refElement.nextElementSibling;
            }
            while (current && (current.textContent <= newElement.textContent)) {
                current = current.nextElementSibling;
            }
            if (current) {
                rootElement.insertBefore(newElement, current);
            } else {
                rootElement.appendChild(newElement);
            }
        },

        /**
         * Inserts a checkbox with the given parameters to the basic settings
         * section. Returns true if the stored `key` value is true, false otherwise.
         *
         * @param {string} key Setting key.
         * @param {string} label Label for setting, placed in left column.
         * @param {string} description Description for setting, placed right of checkbox.
         * @returns {boolean} Value of the `key` setting.
         * @example
         * // Very basic enable/disable script setting.
         * if (!delicious.settings.basicScriptCheckbox('EnableHideTreats', 'Hides Treats', 'Hide those hideous treats!')) {
         *      return;
         * }
         * // Rest of userscript here.
         */
        basicScriptCheckbox: function(key, label, description) {
            this.init(key, true);
            if (this.ensureSettingsInserted()) {
                this.addBasicCheckbox(key, label, description);
            }
            return this.get(key);
        },

        /**
         * Inserts a checkbox to the basic section and returns it.
         *
         * @param {string} key Setting key.
         * @param {string} label Left label.
         * @param {string} description Right description.
         * @param {Object.<string, any>} options Further options for the checkbox.
         * @see {settings.createCheckbox} for accepted `options`.
         */
        addBasicCheckbox: function(key, label, description, options) {
            var checkboxLI = this.createCheckbox(
                key, label, description, options);
            this.insertBasicSetting(checkboxLI);
            return checkboxLI;
        },

        /**
         * Inserts an element containing a basic setting to the basic settings
         * section, above the individual script sections.
         * @param {HTMLElement} setting Setting element.
         */
        insertBasicSetting: function(setting) {
            this._insertSorted(setting, this._basicSection);
        },

        /**
         * Creates, inserts and returns a script section to the settings page.
         * Inserts an Enable/Disable checkbox associated with `key` into
         * the section.
         * @param {string} key Setting key.
         * @param {string} title Section title.
         * @param {string} description Basic description.
         * @param {Object.<string, any>} options Further options for the checkbox.
         */
        addScriptSection: function(key, title, description, options) {
            options = utilities.applyDefaults(options, {
                checkbox: false
            });

            var section = this.createSection(title);

            if (options['checkbox']) {
                var enableBox = this.createCheckbox(key, 'Enable/Disable', description, options);
                section.appendChild(enableBox);
            }

            this._insertSorted(section, this.rootSettingsList,
                this._basicSection);
            return section;
        },

        /**
         * Inserts a section into the settings page, placing it after the
         * basic settings and sorting it alphabetically.
         * @param {HTMLElement} section Setting section.
         */
        insertSection: function(section) {
            this._insertSorted(section, this.rootSettingsList,
                this._basicSection);
        },

        _createSettingLI: function(label, rightElements) {
            return newElement('li', {}, [
                newElement('span', {className: 'ue_left strong'}, [label]),
                newElement('span', {className: 'ue_right'}, rightElements),
            ]);
        },

        /**
         * @param {string} key Setting key.
         * @param {string} label Label text.
         * @param {string} description Short description.
         * @param {Object.<string, any>} options Further options (see source code).
         */
        createCheckbox: function(key, label, description, options) {
            options = utilities.applyDefaults(options, {
                default: true, // Default state of checkbox.
                onSave: function(ev) {
                    settings.saveOneElement(ev.target, 'checked');
                }
            });

            var checkbox = newElement('input', {type: 'checkbox'});
            checkbox.dataset[this._settingKey] = key;
            checkbox.id = this._idPrefix + key;

            var currentValue = options['default'];
            if (this.get(key, currentValue))
                checkbox.setAttribute('checked', 'checked');

            if (options['onSave'] !== null) {
                checkbox.addEventListener(this._eventName, options['onSave']);
            }

            var li = this._createSettingLI(label, [
                checkbox,
                ' ',
                newElement('label', {htmlFor: this._idPrefix+key}, [description]),
            ]);

            return li;
        },

        /**
         * Event handler attached to h3 section heading. Toggles visibility
         * of associated section body.
         */
        _toggleSection: function(ev) {
            var sectionBody = ev.currentTarget.parentNode.parentNode.nextElementSibling;
            var willShow = sectionBody.style.display === 'none';
            sectionBody.style.display = willShow ? 'block' : 'none';

            var toggleTriangle = ev.currentTarget.firstElementChild;
            toggleTriangle.textContent = willShow ? '▼' : '▶';

            ev.preventDefault();
            ev.stopPropagation();
        },

        /**
         * Creates a collapsible script section with the given title.
         * Clicking the section heading will toggle the visibility of the
         * section's settings.
         *
         * **Important.** Appending directly into the returned div element will
         * not work. You must append to the section's body div.
         *
         * Correct example:
         *
         *    var section = delicious.settings.createCollapsibleSection('Script Name');
         *    var s = section.querySelector('.settings_section_body');
         *    s.appendChild(delicious.settings.createCheckbox(...));
         *    delicious.setttings.insertSection(section);
         *
         * Incorrect example:
         *
         *    var section = delicious.settings.createCollapsibleSection('Script Name');
         *    // This will not be able to collapse/expand the section correctly!
         *    section.appendChild(delicious.settings.createCheckbox(...));
         *    delicious.setttings.insertSection(section);
         *
         *
         * @param {string} title Script title.
         * @param {boolean} defaultState If true, the section will be expanded by default.
         * @returns {HTMLDivElement} Script section.
         */
        createCollapsibleSection: function(title, defaultState) {
            var toggleTriangle = newElement('a',
                {textContent: (defaultState ? '▼' : '▶')});
            var heading = newElement('h3', {}, [toggleTriangle, ' ', title]);
            heading.style.cursor = 'pointer';
            heading.addEventListener('click', this._toggleSection);

            var sectionHeading = newElement('div', {className: 'settings_section_heading'},
                [newElement('li', {}, [heading]) ]);
            sectionHeading.style.marginBottom = '20px';

            var sectionBody = newElement('div', {className: 'settings_section_body'});
            sectionBody.style.display = defaultState ? 'block' : 'none';

            var section = newElement('div', {className: 'delicious_settings_section'},
                [sectionHeading, sectionBody]);
            section.style.marginTop = '30px';
            return section;
        },

        /**
         * Creates a setting section, returns it but does not insert it into
         * the page.
         */
        createSection: function(title) {
            var heading = newElement('h3', {}, [title]);
            var section = newElement('div', {className: 'delicious_settings_section'}, [
                newElement('li', {}, [heading])
            ]);
            section.style.marginTop = '30px';
            return section;
        },

        /**
         * @param {string} key Setting key.
         * @param {string} label Label text.
         * @param {string} description Short description.
         * @param {Object.<string, any>} options Further options (see source code).
         */
        createTextSetting: function(key, label, description, options) {
            options = utilities.applyDefaults(options, {
                width: null, // CSS 'width' for the text box.
                lineBreak: false, // Whether to place the description on its own line.
                default: '', // Default text.
                required: false, // If true, text cannot be blank.
                onSave: function(ev) {
                    settings.saveOneElement(ev.target, 'value');
                }
            });

            var inputElem = newElement('input', {
                type: 'text',
                id: this._idPrefix+key
            });
            inputElem.value = this.get(key, options['default']);
            inputElem.dataset[this._settingKey] = key;
            inputElem.style.width = options['width'];
            inputElem.required = options['required'];

            var li = this._createSettingLI(label, [
                inputElem,
                (options['lineBreak'] && description) ? newElement('br') : ' ',
                newElement('label', {htmlFor: this._idPrefix+key}, [description])
            ]);

            if (options['onSave'] !== null) {
                inputElem.addEventListener(this._eventName, options['onSave']);
            }

            return li;
        },

        /**
         * Creates and returns a drop-down setting.
         *
         * `valuesArray` must contain 2-tuples of strings; values will
         * be stored as strings.
         *
         * The default value specified in `options` must be identical to a
         * setting value in `valuesArray`.
         * @example
         * // Creates a drop-down with 2 options, and the second option default.
         * delicious.settings.createDropdown('TimeUnit', 'Select time',
         *      'Select a time unit to use', [['Hour', '1'], ['Day', '24']],
         *      {default: '24'})
         * @param {string} key Setting key.
         * @param {string} label Left label.
         * @param {string} description Right description.
         * @param {[string, string][]} valuesArray Array of 2-tuples [text, setting value].
         * @param {Object.<string, any>} options Further options.
         */
        createDropDown: function(key, label, description, valuesArray, options) {
            options = utilities.applyDefaults(options, {
                lineBreak: false, // Whether to place the description on its own line.
                default: null, // Default value.
                onSave: function(ev) {
                    settings.saveOneElement(ev.target, 'value');
                }
            });

            var select = newElement('select');
            select.dataset[this._settingKey] = key;
            select.id = this._idPrefix+key;

            var currentValue = this.get(key, options['default']);

            for (var i = 0; i < valuesArray .length; i++) {
                var newOption = newElement('option', {
                    value: valuesArray[i][1],
                    textContent: valuesArray[i][0]
                });
                if (valuesArray[i][1] === currentValue)
                    newOption.setAttribute('selected', 'selected');
                select.appendChild(newOption);
            }

            var li = this._createSettingLI(label, [
                select,
                (options['lineBreak'] && description) ? newElement('br') : ' ',
                newElement('label', {htmlFor: this._idPrefix+key}, [description])
            ]);

            if (options['onSave'] !== null) {
                select.addEventListener(this._eventName, options['onSave']);
            }

            return li;
        },

        /**
         * Returns a number setting element. Value is stored as a number.
         * Note that an empty input is stored as `null`. Empty input can be
         * disallowed by specifying `{required: true}` in `options`.
         *
         * @param {string} key Setting key.
         * @param {string} label Label text.
         * @param {string} description Short description.
         * @param {Object.<string, any>} options Further options (see source code).
         */
        createNumberInput: function(key, label, description, options) {
            options = utilities.applyDefaults(options, {
                lineBreak: false, // Whether to place the description on its own line.
                default: '', // Default value.
                allowDecimal: true,
                allowNegative: false,
                required: false, // If true, input cannot be blank.
                onSave: function(ev) {
                    settings.set(key, parseFloat(ev.target.value));
                }
            });

            var input = newElement('input');
            input.id = this._idPrefix+key;
            input.dataset[this._settingKey] = key;
            input.type = 'number';
            if (options['allowDecimal'])
                input.step = 'any';
            if (!options['allowNegative'])
                input.min = '0';
            input.required = options['required'];
            input.value = this.get(key, options['default']);

            var li = this._createSettingLI(label, [
                input,
                (options['lineBreak'] && description) ? newElement('br') : ' ',
                newElement('label', {htmlFor: this._idPrefix+key}, [description])
            ]);

            if (options['onSave'] !== null) {
                input.addEventListener(this._eventName, options['onSave']);
            }

            return li;
        },

        /**
         * Creates a setting containing many checkboxes. Stores the value as
         * an object, with subkeys as keys and true/false as values.
         *
         * @example
         * // Creates a setting with 2 checkboxes,
         * delicious.settings.createFieldSetSetting('FLPoolLocations',
         *      'Freeleech status locations',
         *      [['Navbar', 'navbar'], ['User menu', 'usermenu']]);
         * // Example stored value
         * delicious.settings.get('FLPoolLocations') == {
         *      'navbar': true,
         *      'usermenu': false
         * };
         *
         * @param {string} key Root setting key.
         * @param {string} label Label text.
         * @param {[string, string][]} fields Array of 2-tuples of [text, subkey].
         * @param {string} description Short description.
         * @param {Object.<string, any>} options Further options (see source code).
         */
        createFieldSetSetting: function(key, label, fields, description, options) {
            options = utilities.applyDefaults(options, {
                default: [],
                onSave: function(ev) {
                    var obj = {};
                    var checkboxes = ev.target.querySelectorAll('['+settings._dataSettingSubkey+']');
                    for (var i = 0; i < checkboxes.length; i++) {
                        obj[checkboxes[i].dataset[settings._settingSubkey]] = checkboxes[i].checked;
                    }
                    settings.set(ev.target.dataset[settings._settingKey], obj);
                }
            });

            var fieldset = newElement('span');
            fieldset.dataset[this._settingKey] = key;

            var currentSettings = this.get(key, {});

            for (var i = 0; i < fields.length; i++) {
                var checkbox = newElement('input');
                checkbox.type = 'checkbox';
                checkbox.id = this._idPrefix+key+'_'+fields[i][1];
                checkbox.dataset[this._settingSubkey] = fields[i][1];

                var current = currentSettings[fields[i][1]];
                if (current === undefined)
                    current = options['default'].indexOf(fields[i][1]) !== -1;

                if (current)
                    checkbox.checked = true;

                var newLabel = newElement('label', {htmlFor: this._idPrefix+key+'_'+fields[i][1]}, [
                    checkbox, ' ', fields[i][0]
                ]);
                newLabel.style.marginRight = '15px';

                fieldset.appendChild(newLabel);
            }

            if (options['onSave'] !== null) {
                fieldset.addEventListener(this._eventName, options['onSave']);
            }

            var children = [fieldset];
            if (description) {
                children.push(newElement('br'));
                children.push(description);
            }

            var li = this._createSettingLI(label, children);

            return li;
        },

        /**
         * Event handler to move the containing row up one.
         */
        _moveRowUp: function(ev) {
            var thisRow = ev.target.parentNode;
            if (thisRow.previousElementSibling) {
                thisRow.parentNode.insertBefore(
                    thisRow,
                    thisRow.previousElementSibling
                );
            }
            if (ev.preventDefault) {
                ev.preventDefault();
                ev.stopPropagation();
            }
        },

        /**
         * Event handler to move the containing row down one.
         * Implemented by moving the row underneath this one up one.
         */
        _moveRowDown: function(ev) {
            var thisRow = ev.target.parentNode;
            if (thisRow.nextElementSibling) {
                settings._moveRowUp({target: thisRow.nextElementSibling.firstElementChild});
            }
            ev.preventDefault();
            ev.stopPropagation();
        },

        /**
         * Event handler to delete a row.
         */
        _deleteRow: function(ev) {
            var row = ev.target.parentNode;
            row.parentNode.removeChild(row);
            ev.preventDefault();
            ev.stopPropagation();
        },

        /**
         * Creates a new row for a multi-row setting. Used when clicking
         * the new row button.
         */
        _createRow: function(values, columns, allowSort, allowDelete) {
            var row = newElement('div', {className: 'setting_row'});
            row.style.marginBottom = '2px';

            if (allowSort === undefined || allowSort) {
                var upButton = newElement('button', {textContent: '▲',
                    title: 'Move up'});
                upButton.addEventListener('click', this._moveRowUp);
                row.appendChild(upButton);
                row.appendChild(document.createTextNode(' '));

                var downButton = newElement('button', {textContent: '▼',
                    title: 'Move down'});
                downButton.addEventListener('click', this._moveRowDown);
                row.appendChild(downButton);
                row.appendChild(document.createTextNode(' '));
            }

            for (var i = 0; i < columns.length; i++) {
                var cell = newElement('input', {type: columns[i][2]});
                if (cell.type === 'number') {
                    cell.min = '0';
                    cell.step = 'any';
                }
                var subkey = columns[i][1];
                cell.dataset[this._settingSubkey] = subkey;
                cell.placeholder = columns[i][0];
                if (values[subkey] !== undefined) {
                    cell.value = values[subkey];
                }
                row.appendChild(cell);
                row.appendChild(document.createTextNode(' '));
            }

            if (allowDelete === undefined || allowDelete) {
                var delButton = newElement('button', {textContent: '✖',
                    title: 'Delete'});
                delButton.addEventListener('click', this._deleteRow);
                row.appendChild(delButton);
            }

            return row;
        },

        /**
         * Creates and returns a multi-row setting. That is, a setting with
         * certain columns and a variable number of rows.
         *
         * Setting is stored as an array of objects. Every input type except
         * number is stored as a string. Number input allows any non-negative number,
         * possibly blank. If blank, a number input will be stored as null.
         *
         * @example
         * // Returns a row setting with one row by default.
         * delicious.settings.createRowSetting('QuickLinks', 'Quick Links',
         *      [['Label', 'label', 'text'], ['Link', 'href', 'text']],
         *      {default: [{label: 'Home', href: 'https://animebytes.tv'}]})
         *
         * // Example stored value.
         * delicious.settings.get('QuickLinks') == [
         *      {label: 'Home', href: 'https://animebytes.tv'}
         * ];
         *
         * @param {string} key Root setting key.
         * @param {string | HTMLElement} label Left label.
         * @param {string[][]} columns Array of 3-tuples which are
         * [column label, subkey, input type]. Input type is the `type` attribute
         * of the cell's `<input>` element.
         * @param {string | HTMLElement} description Short description, placed above rows.
         * @param {Object} options Further options (see source code).
         */
        createRowSetting: function(key, label, columns, description, options) {
            options = utilities.applyDefaults(options, {
                default: [],
                newButtonText: '+',
                allowSort: true,
                allowDelete: true,
                allowNew: true,
                onSave: function(ev) {
                    var list = [];
                    var rows = ev.target.querySelectorAll('.setting_row');
                    for (var i = 0; i < rows.length; i++) {
                        var obj = {};
                        var columns = rows[i].querySelectorAll('['+settings._dataSettingSubkey+']');
                        for (var j = 0; j < columns.length; j++) {
                            var val = columns[j].value;
                            if (columns[j].type === 'number')
                                val = parseFloat(val);
                            obj[columns[j].dataset[settings._settingSubkey]] = val;
                        }
                        list.push(obj);
                    }
                    settings.set(key, list);
                }
            });

            var children;
            if (description) {
                children = [description, newElement('br')];
            } else {
                children = undefined;
            }
            var rowDiv = newElement('div', {className: 'ue_right'}, children);

            var rowContainer = newElement('div');
            rowContainer.dataset[this._settingKey] = key;
            rowContainer.className = 'row_container';
            if (options['onSave'] !== null)
                rowContainer.addEventListener(this._eventName, options['onSave']);
            if (description)
                rowContainer.style.marginTop = '5px';
            rowDiv.appendChild(rowContainer);

            var current = this.get(key, options['default']);
            for (var i = 0; i < current.length; i++) {
                rowContainer.appendChild(
                    this._createRow(current[i], columns, options['allowSort'],
                        options['allowDelete']));
            }

            if (options['allowNew']) {
                var newButton = newElement('button', {textContent: options['newButtonText'], title: 'New'});
                newButton.style.marginTop = '8px';
                newButton.addEventListener('click', function(ev) {
                    rowContainer.appendChild(
                        settings._createRow({}, columns,
                            options['allowSort'],
                            options['allowDelete']));
                    ev.preventDefault();
                    ev.stopPropagation();
                });
                rowDiv.appendChild(newButton);
            }

            var li = newElement('li', {}, [
                newElement('span', {className: 'ue_left strong'}, [label]),
                rowDiv
            ]);

            return li;
        },

        /**
         * Returns a colour input, with optional checkbox to enable/disable
         * the whole setting and reset to default value.
         *
         * If the checkbox is unchecked, a `null` will be stored as the value.
         * Else, the colour will be stored as #rrggbb.
         *
         * @param {string} key Setting key.
         * @param {string} label Left label.
         * @param {string} description Short description on right.
         * @param {Object.<string, any>} options Further options (see source code).
         */
        createColourSetting: function(key, label, description, options) {
            options = utilities.applyDefaults(options, {
                default: '#000000',
                checkbox: true,
                resetButton: true,
                onSave: function(ev) {
                    if (options['checkbox'] && !checkbox.checked) {
                        settings.set(key, null);
                    } else {
                        settings.set(key, ev.target.value);
                    }
                }
            });

            var currentColour = this.get(key, options['default']);

            var disabled = currentColour === null && options['checkbox'];
            if (options['checkbox']) {
                var checkbox = newElement('input',
                    {type: 'checkbox', checked: !disabled});
                checkbox.addEventListener('change', function(ev) {
                    colour.disabled = !ev.target.checked;
                    if (reset)
                        reset.disabled = !ev.target.checked;
                    ev.stopPropagation();
                });
            }

            var colour = newElement('input', {type: 'color'});
            colour.dataset[this._settingKey] = key;
            colour.id = this._idPrefix+key;
            colour.disabled = disabled;

            if (currentColour !== null)
                colour.value = currentColour;
            else
                colour.value = options['default'];

            if (options['onSave'] !== null)
                colour.addEventListener(this._eventName, options['onSave']);

            if (options['resetButton']) {
                var reset = newElement('button', {textContent: 'Reset'});
                reset.addEventListener('click', function(ev) {
                    colour.value = options['default'];
                    ev.preventDefault();
                    ev.stopPropagation();
                });
                reset.disabled = disabled;
            }

            var right = [];
            if (options['checkbox']) {
                right.push(checkbox);
                right.push(' ');
            }
            right.push(colour);
            right.push(' ');
            if (options['resetButton']) {
                right.push(reset);
                right.push(' ');
            }
            right.push(newElement('label', {htmlFor: this._idPrefix+key},
                [description]));
            return this._createSettingLI(label, right);
        },


        /**
         * Shows an error message in a friendly red box near the top of the
         * page.
         *
         * `errorId` should be a unique string identifying the type of error.
         * It is used to remove previous errors of the same type before
         * displaying the new error.
         *
         * @param {string | HTMLElement} message
         * @param {string} errorId
         */
        showErrorMessage: function(message, errorId) {
            var errorDiv = newElement('div', {className: 'error_message'},
                [message]);
            if (errorId) {
                errorDiv.dataset['errorId'] = errorId;
                var existing = document.querySelector('[data-error-id="'+errorId+'"');
                if (existing)
                    existing.parentNode.removeChild(existing);
            }
            var thinDiv = document.querySelector('div.thin');
            thinDiv.parentNode.insertBefore(errorDiv, thinDiv);
            return errorDiv;
        },
    };

    return {
        settings: settings,
        utilities: utilities
    };
})();