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;