diff --git a/public/src/modules/settings.js b/public/src/modules/settings.js index 272060d537..177a685137 100644 --- a/public/src/modules/settings.js +++ b/public/src/modules/settings.js @@ -1,68 +1,592 @@ -"use strict"; -/* global define, socket, app */ +define(function () { -/* - settings.js 2.0, because version 1: - - saved everything in "config" hash - - was hand-rolled (mm, salmon hand roll) - - Relied on app.config (!!) - This module should: - - Allow you to save to any specified hash - - Rely on jQuery - - Use sockets - - Be more awesome -*/ + const DEFAULT_PLUGINS = [ + 'settings/checkbox', + 'settings/textarea', + 'settings/select', + 'settings/array', + 'settings/key' + ]; -define(function() { - var Settings = {}; + var Settings, + onReady = [], + waitingJobs = 0, + helper; - Settings.load = function(hash, formEl, callback) { - socket.emit('admin.settings.get', { - hash: hash - }, function(err, values) { - if (!err) { - $(formEl).deserialize(values); - if (typeof callback === 'function') { - callback(); + /* + * Attributes of HTML-tags that get used by default plugins: + * + data-key: the key to save/load the value within configuration-object + * + data-type: highest priority type-definition to determine what kind of element it is or which plugin to hook + * + type: normal priority type-definition + * + data-empty: if 'false' or '0' then values that are assumed as empty turn into null. data-empty of arrays affect + * their child-elements + * + data-trim: if not 'false' or '0' then values will get trimmed as defined by the elements type + * + data-split: if set and the element doesn't belong to any plugin, it's value will get split and joined by its + * value into the input-field + * array-elements: + * + data-split: separator (HTML allowed) between the elements, defaults to ', ' + * + data-new: value to insert into new created elements + * + data-attributes: an object to set the attributes of the child HTML-elements. tagName as special key will set + * the tag-name of the child HTML-elements + * key-fields: + * + data-trim: if 'false' or '0' then the value will get saved as string else as object providing following + * properties: ctrl, alt, shift, meta, code, char + * + data-split: separator between different modifiers and the key-code of the value that gets saved + * (only takes effect if trimming) + * + data-short: if not 'false' or '0' then modifier-keys get saved as first uppercase character + * (only takes effect if trimming) + * select: + * + data-options: an array of {"text":"Displayed Text","value":"some_value"}-like objects + * + * The name of the HTML-tag is lowest priority type-definition + * + * Examples-HTML: + No! +
+ Yes! +
+ An array of checkboxes that are selected by default: +

+ A simple input-field of any common type: +
+ A simple textarea: +
+ Array of textareas: +

+ 2D-Array of numbers that persist even when empty (but not empty rows): +

+ Same with persisting empty rows, but not empty numbers, if no row is given null will get saved: +

+ Array of Key-shortcuts (new: Ctrl+Shift+7): +

+ Array of numbers (new: 42, step: 21): +

+ Select with dynamic options: +
+ Select that loads faster: + + * + * Matching configuration: + { + cfg1: false, + cfg2: true, + cfg3: [false, false, true], + cfg4: 'hello world', + cfg5: 'some\nlong\ntext', + cfg6: ['some\nlong\ntexts', 'and another one'], + cfg7: [[]], + cfg8: [[]], + cfg9: [], + cfg10: [], + cfg11: 3, + cfg12: 2 + } + */ + + /** + Returns the hook of given name that matches the given type or element. + @param type The type of the element to get the matching hook for, or the element itself. + @param name The name of the hook. + */ + function getHook(type, name) { + var hook, plugin; + if (typeof type !== 'string') { + type = $(type); + type = type.data('type') || type.attr('type') || type.prop('tagName'); + } + plugin = Settings.plugins[type.toLowerCase()]; + if (plugin == null) { + return void 0; + } + hook = plugin[name]; + if (typeof hook === 'function') { + return hook; + } else { + return null; + } + } + + helper = { + /** + @returns Object A deep clone of the given object. + */ + deepClone: function (obj) { + if (typeof obj === 'object') { + return JSON.parse(JSON.stringify(obj)); + } else { + return obj; + } + }, + /** + Creates a new Element with given data. + @param tagName The tag-name of the element to create. + @param data The attributes to set. + @param text The text to add into the element. + @returns HTMLElement The created element. + */ + createElement: function (tagName, data, text) { + var element = document.createElement(tagName); + for (var k in data) { + element.setAttribute(k, data[k]); + } + if (text) { + element.appendChild(document.createTextNode(text)); + } + return element; + }, + /** + Calls the init-hook of the given element. + @param element The element to initialize. + */ + initElement: function (element) { + var hook = getHook(element, 'init'); + if (hook != null) { + hook.call(Settings, $(element)); + } + }, + /** + Calls the destruct-hook of the given element. + @param element The element to destruct. + */ + destructElement: function (element) { + var hook = getHook(element, 'destruct'); + if (hook != null) { + hook.call(Settings, $(element)); + } + }, + /** + Creates and initializes a new element. + @param type The type of the new element. + @param tagName The tag-name of the new element. + @param data The data to forward to create-hook or use as attributes. + @returns JQuery The created element. + */ + createElementOfType: function (type, tagName, data) { + var element, hook = getHook(type, 'create'); + if (hook != null) { + element = $(hook.call(Settings, type, tagName, data)); + } else { + if (data == null) { + data = {}; + } + if (type != null) { + data.type = type; + } + element = $(helper.createElement(tagName || 'input', data)) + } + element.data('type', type); + helper.initElement(element); + return element; + }, + /** + Creates a new Array that contains values of given Array depending on trim and empty. + @param array The array to clean. + @param trim Whether to trim each value if it has a trim-function. + @param empty Whether empty values should get added. + @returns Array The filtered and/or modified Array. + */ + cleanArray: function (array, trim, empty) { + var cleaned = []; + if (!trim && empty) { + return array; + } + for (var i = 0; i < array.length; i++) { + var value = array[i]; + if (trim) { + value = value === true ? 1 : value === false ? 0 : typeof value.trim === 'function' ? value.trim() : value; + } + if (empty || (value != null ? value.length : void 0)) { + cleaned.push(value); + } + } + return cleaned; + }, + isTrue: function (value) { + return value === 'true' || +value === 1; + }, + isFalse: function (value) { + return value === 'false' || +value === 0; + }, + /** + Calls the get-hook of the given element and returns its result. + If no hook is specified it gets treated as input-field. + @param element The element of that the value should get read. + @returns Object The value of the element. + */ + readValue: function (element) { + var empty = !helper.isFalse(element.data('empty')), + trim = !helper.isFalse(element.data('trim')), + split = element.data('split'), + hook = getHook(element, 'get'), + value; + if (hook != null) { + return hook.call(Settings, element, trim, empty); + } + if (split != null) { + empty = helper.isTrue(element.data('empty')); // default empty-value is false for arrays + value = element.val(); + var array = (value != null ? value.split(split || ',') : void 0) || []; + return helper.cleanArray(array, trim, empty); + } else { + value = element.val(); + if (trim && value != null && typeof value.trim === 'function') { + value = value.trim(); + } + if (empty || value !== void 0 && (value == null || value.length !== 0)) { + return value; + } else { + return void 0; + } + } + }, + /** + Calls the set-hook of the given element. + If no hook is specified it gets treated as input-field. + @param element The JQuery-Object of the element to fill. + @param value The value to set. + */ + fillField: function (element, value) { + var hook = getHook(element, 'set'), + trim = element.data('trim'); + trim = trim !== 'false' && +trim !== 0; + if (hook != null) { + return hook.call(Settings, element, value, trim); + } + if (value instanceof Array) { + value = value.join(element.data('split') || (trim ? ', ' : ',')); + } + if (trim && value && typeof value.trim === 'function') { + value = value.trim(); + if (typeof value.toString === 'function') { + value = value.toString(); + } + } else if (value != null) { + if (typeof value.toString === 'function') { + value = value.toString(); + } + if (trim) { + value = value.trim(); + } + } else { + value = ''; + } + if (value !== void 0) { + element.val(value); + } + }, + /** + Calls the init-hook and {@link helper.fillField} on each field within wrapper-object. + @param wrapper The wrapper-element to set settings within. + */ + initFields: function (wrapper) { + $('[data-key]', wrapper).each(function (ignored, field) { + field = $(field); + var hook = getHook(field, 'init'), + keyParts = field.data('key').split('.'), + value = Settings.get(); + if (hook != null) { + hook.call(Settings, field); + } + for (var i = 0; i < keyParts.length; i++) { + var part = keyParts[i]; + if (part && value != null) { + value = value[part]; + } + } + helper.fillField(field, value); + }); + }, + /** + Increases the amount of jobs before settings are ready by given amount. + @param amount The amount of jobs to register. + */ + registerReadyJobs: function (amount) { + return waitingJobs += amount; + }, + /** + Decreases the amount of jobs before settings are ready by given amount or 1. + If the amount is less or equal 0 all callbacks registered by {@link helper.whenReady} get called. + @param amount The amount of jobs that finished. + */ + beforeReadyJobsDecreased: function (amount) { + if (amount == null) { + amount = 1; + } + if (waitingJobs > 0) { + waitingJobs -= amount; + if (waitingJobs <= 0) { + for (var i = 0; i < onReady.length; i++) { + onReady[i](); + } + onReady = []; } + } + }, + /** + Calls the given callback when the settings are ready. + @param callback The callback. + */ + whenReady: function (callback) { + if (waitingJobs <= 0) { + callback(); } else { - console.log('[settings] Unable to load settings for hash: ', hash); + onReady.push(callback); + } + }, + /** + Persists the given settings with given hash. + @param hash The hash to use as settings-id. + @param settings The settings-object to persist. + @param notify Whether to send notification when settings got saved. + @param callback The callback to call when done. + */ + persistSettings: function (hash, settings, notify, callback) { + if (settings != null && settings._settings != null && typeof settings._settings !== 'string') { + settings = helper.deepClone(settings); + settings._settings = JSON.stringify(settings._settings); } - }); + socket.emit('admin.settings.set', { + hash: hash, + values: settings + }, function (err) { + if (notify) { + if (err) { + app.alert({ + title: 'Settings Not Saved', + type: 'danger', + message: "NodeBB failed to save the settings.", + timeout: 5000 + }); + console.log('[settings] Unable to set settings for hash: ', hash); + } else { + app.alert({ + title: 'Settings Saved', + type: 'success', + timeout: 2500 + }); + } + } + if (typeof callback === 'function') { + callback(err); + } + }); + }, + /** + Sets the settings to use to given settings. + @param settings The settings to use. + */ + use: function (settings) { + try { + settings._settings = JSON.parse(settings._settings); + } catch (_error) {} + Settings.cfg = settings; + } }; - Settings.save = function(hash, formEl, callback) { - formEl = $(formEl); - if (formEl.length) { - var values = formEl.serializeObject(); + Settings = { + helper: helper, + plugins: {}, + cfg: {}, - // "Fix" checkbox values, so that unchecked options are not omitted - formEl.find('input[type="checkbox"]').each(function(idx, inputEl) { - inputEl = $(inputEl); - if (!inputEl.is(':checked')) { - values[inputEl.attr('id')] = 'off'; + /** + Returns the saved settings. + @returns Object The settings. + */ + get: function () { + if (Settings.cfg != null && Settings.cfg._settings !== void 0) { + return Settings.cfg._settings; + } + return Settings.cfg; + }, + /** + Registers a new plugin and calls its use-hook. + A plugin is an object containing a types-property to define its default bindings. + A plugin may also provide the following properties of type function with [return-value] (parameters): + use [void] - gets called when the Settings initializes the plugin. + init [void] (element) - gets called on page-load and every time after the create-hook. + ; element: The element to initialize. + create [JQuery-Object] (type, tagName, data) - gets called when a new HTML-instance needs to get created. + ; type: A string that identifies the plugin itself within this Settings-instance if set as data-type. + ; tagName: The tag-name that gets requested. + ; data: Additional data, plugin-dependent meaning. + destruct [void] (element) - gets called after a HTML-instance got removed from DOM + ; element: The element that got removed. + set [void] (element, value, trim) - gets called when the value of the element should be set to the given value. + ; element: The element to set its value. + ; value: The value to set. + ; trim: Whether the value is considered as trimmed. + get [value] (element, trim, empty) - gets called when the value of the given instance is needed. + ; element: The element to get its value. + ; trim: Whether the result should be trimmed. + ; empty: Whether considered as empty values should get saved too. + All passed elements are JQuery-Objects. + @param service The plugin to register. + @param types The types to bind the plugin to. + */ + registerPlugin: function (service, types) { + if (types == null) { + types = service.types; + } else { + service.types = types; + } + if (typeof service.use === 'function') { + service.use.call(Settings); + } + for (var i = 0; i < types.length; i++) { + var type = types[i].toLowerCase(); + if (Settings.plugins[type] == null) { + Settings.plugins[type] = service; } + } + }, + /** + Sets the settings to given ones, resets the fields within given wrapper and saves the settings server-side. + @param hash The hash to use as settings-id. + @param settings The settings to set. + @param wrapper The wrapper-element to find settings within. + @param callback The callback to call when done. + @param notify Whether to send notification when settings got saved. + */ + set: function (hash, settings, wrapper, callback, notify) { + if (notify == null) { + notify = true; + } + helper.whenReady(function () { + helper.use(settings); + helper.initFields(wrapper || 'form'); + helper.persistSettings(hash, settings, notify, callback); }); - - socket.emit('admin.settings.set', { - hash: hash, - values: values - }, function(err) { + }, + /** + Fetches the settings from server and calls {@link Settings.helper.initFields} once the settings are ready. + @param hash The hash to use as settings-id. + @param wrapper The wrapper-element to set settings within. + @param callback The callback to call when done. + */ + sync: function (hash, wrapper, callback) { + socket.emit('admin.settings.get', { + hash: hash + }, function (err, values) { + if (err) { + console.log('[settings] Unable to load settings for hash: ', hash); + if (typeof callback === 'function') { + callback(err); + } + } else { + helper.whenReady(function () { + helper.use(values); + helper.initFields(wrapper || 'form'); + if (typeof callback === 'function') { + callback(); + } + }); + } + }); + }, + /** + Reads the settings from fields and saves them server-side. + @param hash The hash to use as settings-id. + @param wrapper The wrapper-element to find settings within. + @param callback The callback to call when done. + @param notify Whether to send notification when settings got saved. + */ + persist: function (hash, wrapper, callback, notify) { + var notSaved = [], + fields = $('[data-key]', wrapper || 'form').toArray(); + if (notify == null) { + notify = true; + } + for (var i = 0; i < fields.length; i++) { + var field = $(fields[i]), + value = helper.readValue(field), + parentCfg = Settings.get(), + keyParts = field.data('key').split('.'), + lastKey = keyParts[keyParts.length - 1]; + if (keyParts.length > 1) { + for (var j = 0; j < keyParts.length - 1; j++) { + var part = keyParts[j]; + if (part && parentCfg != null) { + parentCfg = parentCfg[part]; + } + } + } + if (parentCfg != null) { + if (value != null) { + parentCfg[lastKey] = value; + } else { + delete parentCfg[lastKey]; + } + } else { + notSaved.push(field.data('key')); + } + } + if (notSaved.length) { app.alert({ - title: 'Settings Saved', - type: 'success', - timeout: 2500 + title: 'Attributes Not Saved', + message: "'" + (notSaved.join(', ')) + "' could not be saved. Please contact the plugin-author!", + type: 'danger', + timeout: 5000 }); - - if (typeof callback === 'function') { - callback(); + } + helper.persistSettings(hash, Settings.cfg, notify, callback); + }, + load: function (hash, formEl, callback) { + socket.emit('admin.settings.get', { + hash: hash + }, function (err, values) { + if (!err) { + $(formEl).deserialize(values); + if (typeof callback === 'function') { + callback(); + } + } else { + console.log('[settings] Unable to load settings for hash: ', hash); } }); - } else { - console.log('[settings] Form not found.'); + }, + save: function (hash, formEl, callback) { + formEl = $(formEl); + if (formEl.length) { + var values = formEl.serializeObject(); + // "Fix" checkbox values, so that unchecked options are not omitted + formEl.find('input[type="checkbox"]').each(function (idx, inputEl) { + inputEl = $(inputEl); + if (!inputEl.is(':checked')) { + values[inputEl.attr('id')] = 'off'; + } + }); + socket.emit('admin.settings.set', { + hash: hash, + values: values + }, function (err) { + app.alert({ + title: 'Settings Saved', + type: 'success', + timeout: 2500 + }); + if (typeof callback === 'function') { + callback(); + } + }); + } else { + console.log('[settings] Form not found.'); + } } }; + + helper.registerReadyJobs(1); + require(DEFAULT_PLUGINS, function () { + for (var i = 0; i < arguments.length; i++) { + Settings.registerPlugin(arguments[i]); + } + helper.beforeReadyJobsDecreased(); + }); + return Settings; + }); diff --git a/public/src/modules/settings/array.js b/public/src/modules/settings/array.js new file mode 100644 index 0000000000..eb511b9f7a --- /dev/null +++ b/public/src/modules/settings/array.js @@ -0,0 +1,147 @@ +define(function () { + + var Settings = null, + SettingsArray, + helper = null; + + /** + Creates a new button that removes itself and the given elements on click. + Calls {@link Settings.helper.destructElement} for each given field. + @param elements The elements to remove on click. + @returns JQuery The created remove-button. + */ + function createRemoveButton(elements) { + var rm = $(helper.createElement('button', { + "class": 'btn btn-xs btn-primary remove', + title: 'Remove Item' + }, '-')); + rm.click(function (event) { + event.preventDefault(); + elements.remove(); + rm.remove(); + elements.each(function (i, element) { + element = $(element); + if (element.is('[data-key]')) { + helper.destructElement(element); + } + }); + }); + return rm; + } + + /** + Creates a new child-element of given field with given data and calls given callback with elements to add. + @param field Any wrapper that contains all fields of the array. + @param key The key of the array. + @param attributes The attributes to call {@link Settings.helper.createElementOfType} with or to add as + element-attributes. + @param value The value to call {@link Settings.helper.fillField} with. + @param separator The separator to use. + @param insertCb The callback to insert the elements. + */ + function addArrayChildElement(field, key, attributes, value, separator, insertCb) { + attributes = helper.deepClone(attributes); + var type = attributes['data-type'] || attributes.type || 'text', + element = $(helper.createElementOfType(type, attributes.tagName, attributes)); + element.attr('data-parent', '_' + key); + delete attributes['data-type']; + delete attributes['tagName']; + for (var name in attributes) { + var val = attributes[name]; + if (name.search('data-') === 0) { + element.data(name.substring(5), val); + } else if (name.search('prop-') === 0) { + element.prop(name.substring(5), val); + } else { + element.attr(name, val); + } + } + helper.fillField(element, value); + if ($("[data-parent=\"_" + key + "\"]", field).length) { + insertCb(separator); + } + insertCb(element); + insertCb(createRemoveButton(element.add(separator))); + } + + /** + Adds a new button that adds a new child-element to given element on click. + @param element The element to insert the button. + @param key The key to forward to {@link addArrayChildElement}. + @param attributes The attributes to forward to {@link addArrayChildElement}. + @param separator The separator to forward to {@link addArrayChildElement}. + */ + function addAddButton(element, key, attributes, separator) { + var addSpace = $(document.createTextNode(' ')), + newValue = element.data('new') || '', + add = $(helper.createElement('button', { + "class": 'btn btn-sm btn-primary add', + title: 'Expand Array' + }, '+')); + add.click(function (event) { + event.preventDefault(); + addArrayChildElement(element, key, attributes, newValue, separator.clone(), function (el) { + addSpace.before(el); + }); + }); + element.append(addSpace); + element.append(add); + } + + + SettingsArray = { + types: ['array', 'div'], + use: function () { + helper = (Settings = this).helper; + }, + create: function (ignored, tagName) { + return helper.createElement(tagName || 'div'); + }, + set: function (element, value) { + var attributes = element.data('attributes'), + key = element.data('key') || element.data('parent'), + separator = element.data('split') || ', '; + separator = (function () { + try { + return $(separator); + } catch (_error) { + return $(document.createTextNode(separator)); + } + })(); + if (typeof attributes !== 'object') { + attributes = {}; + } + element.empty(); + if (!(value instanceof Array)) { + value = []; + } + for (var i = 0; i < value.length; i++) { + addArrayChildElement(element, key, attributes, value[i], separator.clone(), function (el) { + element.append(el); + }); + } + addAddButton(element, key, attributes, separator); + }, + get: function (element, trim, empty) { + var key = element.data('key') || element.data('parent'), + children = $("[data-parent=\"_" + key + "\"]", element), + values = []; + children.each(function (i, child) { + child = $(child); + var val = helper.readValue(child), + empty = helper.isTrue(child.data('empty')); + if (empty || val !== void 0 && (val == null || val.length !== 0)) { + return values.push(val); + } + }); + if (empty || values.length) { + return values; + } else { + return void 0; + } + } + }; + + return SettingsArray; + +}); diff --git a/public/src/modules/settings/checkbox.js b/public/src/modules/settings/checkbox.js new file mode 100644 index 0000000000..98d8767417 --- /dev/null +++ b/public/src/modules/settings/checkbox.js @@ -0,0 +1,36 @@ +define(function () { + + var Settings = null, + SettingsCheckbox; + + SettingsCheckbox = { + types: ['checkbox'], + use: function () { + Settings = this; + }, + create: function () { + return Settings.helper.createElement('input', { + type: 'checkbox' + }); + }, + set: function (element, value) { + element.prop('checked', value); + }, + get: function (element, trim, empty) { + var value = element.prop('checked'); + if (value == null) { + return void 0; + } + if (empty) { + return value || void 0; + } + if (trim) { + return value ? 1 : 0; + } + return value; + } + }; + + return SettingsCheckbox; + +}); diff --git a/public/src/modules/settings/key.js b/public/src/modules/settings/key.js new file mode 100644 index 0000000000..d405b65cf5 --- /dev/null +++ b/public/src/modules/settings/key.js @@ -0,0 +1,221 @@ +define(function () { + + var Settings = null, + SettingsKey, + helper = null, + lastKey = null, + oldKey = null, + keyMap = Object.freeze({ + 0: '', + 8: 'Backspace', + 9: 'Tab', + 13: 'Enter', + 27: 'Escape', + 32: 'Space', + 37: 'Left', + 38: 'Up', + 39: 'Right', + 40: 'Down', + 45: 'Insert', + 46: 'Delete', + 187: '=', + 189: '-', + 190: '.', + 191: '/', + 219: '[', + 220: '\\', + 221: ']' + }); + + function Key() { + this.c = false; + this.a = false; + this.s = false; + this.m = false; + this.code = 0; + this.char = ''; + } + + /** + Returns either a Key-Object representing the given event or null if only modification-keys got released. + @param event The event to inspect. + @returns Key | null The Key-Object the focused element should be set to. + */ + function getKey(event) { + var anyModChange = event.ctrlKey !== lastKey.c || event.altKey !== lastKey.a || event.shiftKey !== lastKey.s || event.metaKey !== lastKey.m, + modChange = event.ctrlKey + event.altKey + event.shiftKey + event.metaKey - lastKey.c - lastKey.a - lastKey.s - lastKey.m, + key = new Key(); + key.c = event.ctrlKey; + key.a = event.altKey; + key.s = event.shiftKey; + key.m = event.metaKey; + lastKey = key; + if (anyModChange) { + if (modChange < 0) { + return null; + } + key.code = oldKey.code; + key.char = oldKey.char; + } else { + key.code = event.which; + key.char = convertKeyCodeToChar(key.code); + } + return oldKey = key; + } + + /** + Returns the string that represents the given key-code. + @param code The key-code. + @returns String Representation of the given key-code. + */ + function convertKeyCodeToChar(code) { + code = +code; + if (code === 0) { + return ''; + } else if (code >= 48 && code <= 90) { + return String.fromCharCode(code).toUpperCase(); + } else if (code >= 112 && code <= 123) { + return "F" + (code - 111); + } else { + return keyMap[code] || ("#" + code); + } + } + + /** + Returns a string to identify a Key-Object. + @param key The Key-Object that should get identified. + @param human Whether to show 'Enter a key' when key-char is empty. + @param short Whether to shorten modification-names to first character. + @param separator The separator between modification-names and key-char. + @returns String The string to identify the given key-object the given way. + */ + function getKeyString(key, human, short, separator) { + var str = ''; + if (!(key instanceof Key)) { + return str; + } + if (!key.char) { + if (human) { + return 'Enter a key'; + } else { + return ''; + } + } + if (!separator || /CtrlAShifMea#/.test(separator)) { + separator = human ? ' + ' : '+'; + } + if (key.c) { + str += (short ? 'C' : 'Ctrl') + separator; + } + if (key.a) { + str += (short ? 'A' : 'Alt') + separator; + } + if (key.s) { + str += (short ? 'S' : 'Shift') + separator; + } + if (key.m) { + str += (short ? 'M' : 'Meta') + separator; + } + return str + (human ? key.char : key.code ? '#' + key.code : ''); + } + + /** + Parses the given string into a Key-Object. + @param str The string to parse. + @returns Key The Key-Object that got identified by the given string. + */ + function getKeyFromString(str) { + if (str instanceof Key) { + return str; + } + var key = new Key(), + sep = /([^CtrlAShifMea#\d]+)(?:#|\d)/.exec(str), + parts = sep != null ? str.split(sep[1]) : [str]; + for (var i = 0; i < parts.length; i++) { + var part = parts[i]; + switch (part) { + case 'C': + case 'Ctrl': + key.c = true; + break; + case 'A': + case 'Alt': + key.a = true; + break; + case 'S': + case 'Shift': + key.s = true; + break; + case 'M': + case 'Meta': + key.m = true; + break; + default: + var num = /\d+/.exec(part); + if (num != null) { + key.code = num[0]; + } + key.char = convertKeyCodeToChar(key.code); + } + } + return key; + } + + function handleEvent(element, event) { + event = event || window.event; + event.which = event.which || event.keyCode || event.key; + var key = getKey(event); + if (key != null) { + SettingsKey.set(element, key); + } + } + + + SettingsKey = { + types: ['key'], + use: function () { + helper = (Settings = this).helper; + }, + init: function (element) { + element.focus(function () { + oldKey = element.data('keyData') || new Key(); + lastKey = new Key(); + }).keydown(function (event) { + event.preventDefault(); + handleEvent(element, event); + }).keyup(function (event) { + handleEvent(element, event); + }); + return element; + }, + set: function (element, value) { + var key = getKeyFromString(value || ''); + element.data('keyData', key); + if (key.code) { + element.removeClass('alert-danger'); + } else { + element.addClass('alert-danger'); + } + element.val(getKeyString(key, true, false, ' + ')); + }, + get: function (element, trim, empty) { + var key = element.data('keyData'), + separator = element.data('split') || element.data('separator') || '+', + short = !helper.isFalse(element.data('short')); + if (trim) { + if (empty || (key != null && key.char)) { + return getKeyString(key, false, short, separator); + } else { + return void 0; + } + } else if (empty || (key != null && key.code)) { + return key; + } else { + return void 0; + } + } + }; + + return SettingsKey; + +}); diff --git a/public/src/modules/settings/select.js b/public/src/modules/settings/select.js new file mode 100644 index 0000000000..9ccff8ccb7 --- /dev/null +++ b/public/src/modules/settings/select.js @@ -0,0 +1,49 @@ +define(function () { + + var Settings = null, + SettingsSelect; + + function addOptions(element, options) { + for (var i = 0; i < options.length; i++) { + var optionData = options[i], + value = optionData.text || optionData.value; + delete optionData.text; + element.append($(Settings.helper.createElement('option', optionData)).text(value)); + } + } + + + SettingsSelect = { + types: ['select'], + use: function () { + Settings = this; + }, + create: function (ignore, ignored, data) { + var element = $(Settings.helper.createElement('select')); + // prevent data-options from being attached to DOM + addOptions(element, data['data-options']); + delete data['data-options']; + return element; + }, + init: function (element) { + var options = element.data('options'); + if (options != null) { + addOptions(element, options); + } + }, + set: function (element, value) { + element.val(value || ''); + }, + get: function (element, ignored, empty) { + var value = element.val(); + if (empty || value) { + return value; + } else { + return void 0; + } + } + }; + + return SettingsSelect; + +}); diff --git a/public/src/modules/settings/textarea.js b/public/src/modules/settings/textarea.js new file mode 100644 index 0000000000..ceeca49f99 --- /dev/null +++ b/public/src/modules/settings/textarea.js @@ -0,0 +1,35 @@ +define(function () { + + var Settings = null, + SettingsArea; + + SettingsArea = { + types: ['textarea'], + use: function () { + Settings = this; + }, + create: function () { + return Settings.helper.createElement('textarea'); + }, + set: function (element, value, trim) { + if (trim && value != null && typeof value.trim === 'function') { + value = value.trim(); + } + element.val(value || ''); + }, + get: function (element, trim, empty) { + var value = element.val(); + if (trim) { + value = value == null ? void 0 : value.trim(); + } + if (empty || value) { + return value; + } else { + return void 0; + } + } + }; + + return SettingsArea; + +}); diff --git a/src/settings.js b/src/settings.js new file mode 100644 index 0000000000..a8d8594658 --- /dev/null +++ b/src/settings.js @@ -0,0 +1,233 @@ +var meta = require('./meta'); + +function expandObjBy(obj1, obj2) { + var key, val1, val2; + for (key in obj2) { + val2 = obj2[key]; + val1 = obj1[key]; + if (!obj1.hasOwnProperty(key) || typeof val2 !== typeof val1) { + obj1[key] = val2; + } else if (typeof val2 === 'object') { + expandObjBy(val1, val2); + } + } +} + +function trim(obj1, obj2) { + var key, val1; + for (key in obj1) { + val1 = obj1[key]; + if (!obj2.hasOwnProperty(key)) { + delete obj1[key]; + } else if (typeof val1 === 'object') { + trim(val1, obj2[key]); + } + } +} + +function mergeSettings(cfg, defCfg) { + if (typeof cfg._settings !== typeof defCfg || typeof defCfg !== 'object') { + return cfg._settings = defCfg; + } else { + expandObjBy(cfg._settings, defCfg); + trim(cfg._settings, defCfg); + } +} + +/** + A class to manage Objects saved in {@link meta.settings} within property "_settings". + Constructor, synchronizes the settings and repairs them if version differs. + @param hash The hash to use for {@link meta.settings}. + @param version The version of the settings, used to determine whether the saved settings may be corrupt. + @param defCfg The default settings. + @param callback Gets called once the Settings-object is ready. + @param forceUpdate Whether to trigger structure-update even if the version doesn't differ from saved one. + Should be true while plugin-development to ensure structure-changes within settings persist. + @param reset Whether to reset the settings. + */ +function Settings(hash, version, defCfg, callback, forceUpdate, reset) { + this.hash = hash; + this.version = version || this.version; + this.defCfg = defCfg; + if (reset) { + this.reset(callback); + } else { + this.sync(function () { + this.checkStructure(callback, forceUpdate); + }); + } +} + +Settings.prototype.hash = ''; +Settings.prototype.defCfg = {}; +Settings.prototype.cfg = {}; +Settings.prototype.version = '0.0.0'; + +/** + Synchronizes the local object with the saved object (reverts changes). + @param callback Gets called when done. + */ +Settings.prototype.sync = function (callback) { + var _this = this; + meta.settings.get(this.hash, function (err, settings) { + try { + if (settings._settings) { + settings._settings = JSON.parse(settings._settings); + } + } catch (_error) {} + _this.cfg = settings; + if (typeof callback === 'function') { + callback.apply(_this, err); + } + }); +}; + +/** + Persists the local object. + @param callback Gets called when done. + */ +Settings.prototype.persist = function (callback) { + var conf = this.cfg._settings, + _this = this; + if (typeof conf === 'object') { + conf = JSON.stringify(conf); + } + meta.settings.set(this.hash, { + _settings: conf, + version: this.cfg.version + }, function () { + if (typeof callback === 'function') { + callback.apply(_this, arguments || []); + } + }); + return this; +}; + +/** + Persists the settings if no settings are saved. + @param callback Gets called when done. + */ +Settings.prototype.persistOnEmpty = function (callback) { + var _this = this; + meta.settings.get(this.hash, function (err, settings) { + if (!settings._settings) { + _this.persist(callback); + } else if (typeof callback === 'function') { + callback.call(_this); + } + }); + return this; +}; + +/** + Returns the setting of given key or default value if not set. + @param key The key of the setting to return. + @param def The default value, if not set global default value gets used. + @returns Object The setting to be used. + */ +Settings.prototype.get = function (key, def) { + var obj = this.cfg._settings, + parts = (key || '').split('.'), + part; + for (var i = 0; i < parts.length; i++) { + part = parts[i]; + if (part && obj != null) { + obj = obj[part]; + } + } + if (obj === void 0) { + if (def === void 0) { + def = this.defCfg; + for (var j = 0; j < parts.length; j++) { + part = parts[j]; + if (part && def != null) { + def = def[part]; + } + } + } + return def; + } + return obj; +}; + +/** + Returns the settings-wrapper object. + @returns Object The settings-wrapper. + */ +Settings.prototype.getWrapper = function () { + return this.cfg; +}; + +/** + Creates a new wrapper for the given settings with the given version. + @returns Object The new settings-wrapper. + */ +Settings.prototype.createWrapper = function (version, settings) { + return { + version: version, + _settings: settings + }; +}; + +/** + Creates a new wrapper for the default settings. + @returns Object The new settings-wrapper. + */ +Settings.prototype.createDefaultWrapper = function () { + return this.createWrapper(this.version, this.defCfg); +}; + +/** + Sets the setting of given key to given value. + @param key The key of the setting to set. + @param val The value to set. + */ +Settings.prototype.set = function (key, val) { + var part, obj, parts; + this.cfg.version = this.version; + if (val == null || !key) { + this.cfg._settings = val || key; + } else { + obj = this.cfg._settings; + parts = key.split('.'); + for (var i = 0, _len = parts.length - 1; i < _len; i++) { + if (part = parts[i]) { + if (!obj.hasOwnProperty(part)) { + obj[part] = {}; + } + obj = obj[part]; + } + } + obj[parts[parts.length - 1]] = val; + } + return this; +}; + +/** + Resets the saved settings to default settings. + @param callback Gets called when done. + */ +Settings.prototype.reset = function (callback) { + this.set(this.defCfg).persist(callback); + return this; +}; + +/** + If the version differs the settings get updated and persisted. + @param callback Gets called when done. + @param force Whether to update and persist the settings even if the versions ara equal. + */ +Settings.prototype.checkStructure = function (callback, force) { + if (!force && this.cfg.version === this.version) { + if (typeof callback === 'function') { + callback(); + } + } else { + mergeSettings(this.cfg, this.defCfg); + this.cfg.version = this.version; + this.persist(callback); + } + return this; +}; + +module.exports = Settings;