/**
* The Settings page.
*
* - Model: what /api/settings returns (categories > sections > settings) plus the edits not saved yet (`pending`).
* Nothing is sent until Save; then every edit goes in one request, and the server saves all of them or none.
* - Controls: one entry per setting type, each able to render its input, read it back and check a value.
* - Conditions: a setting or section can depend on another setting ("only used when database_type is mysql"). They
* are checked against the value on screen, so switching hardcore mode on shows its settings straight away.
* - Settings that only the .ini files or the environment can set (the database connection, the dashboard's keys) are
* shown read-only, with where to change them.
*/
(function () {
'use strict';
var el = {
page: document.getElementById('settingsPage'),
nav: document.getElementById('settingsNav'),
body: document.getElementById('settingsBody'),
search: document.getElementById('settingsSearch'),
filter: document.getElementById('settingsFilter'),
showUnused: document.getElementById('showUnused'),
saveBar: document.getElementById('settingsSaveBar'),
saveCount: document.getElementById('settingsSaveCount'),
save: document.getElementById('settingsSave'),
discard: document.getElementById('settingsDiscard'),
restartNotice: document.getElementById('restartNotice'),
restartList: document.getElementById('restartList')
};
var state = {
categories: [], sections: [], settings: [], files: [], names: {},
byKey: {}, // "file/name" -> setting
controllers: {}, // keys other settings depend on
pending: {}, // "file/name" -> { value: string | null (clear the value set here), webWins: bool }
errors: {}, // "file/name" -> message
category: null,
query: '',
filter: 'all', // all | changed | restart
saving: false,
options: {}, // "zone" | "reward_code" -> [{id, label, detail}] from the client database
openPicker: null // { key, filter } kept open across redraws
};
// Lists whose values can be picked from the client database instead of typed
var PICKABLE = { zone: 'Zones', reward_code: 'Reward codes' };
// ---------------------------------------------------------------- model helpers
function keyOf(s) { return s.file + '/' + s.name; }
function domId(key) { return 'setting-' + key.replace(/[^a-z0-9_]/gi, '-'); }
// The value shown now: an unsaved edit, else what the servers use
function currentValue(s) {
var p = state.pending[keyOf(s)];
if (p) return p.value === null ? fallbackValue(s) : p.value;
return s.value === undefined ? '' : String(s.value);
}
// What applies once the value set here is removed: the file's, else the default
function fallbackValue(s) {
if (s.fileSource && s.fileValue !== null && s.fileValue !== undefined && s.fileValue !== '') return String(s.fileValue);
return s['default'] || '';
}
// What the page shows for a choice's value, e.g. "Shared loot (like live)" for 0
function choiceLabel(s, value) {
var i = s.choices ? s.choices.indexOf(value) : -1;
return i >= 0 && s.choiceLabels ? s.choiceLabels[i] : value;
}
function isChanged(s) { return s.source !== 'default' && (s.secret || String(s.value) !== String(s['default'])); }
function isPending(s) { return Object.prototype.hasOwnProperty.call(state.pending, keyOf(s)); }
function conditionMet(condition) {
if (!condition) return true;
var controller = state.byKey[condition.file + '/' + condition.key];
if (!controller) return true;
var value = currentValue(controller) || controller['default'];
return condition.values.indexOf(value) !== -1;
}
function describeCondition(condition) {
var controller = state.byKey[condition.file + '/' + condition.key];
if (!controller) return condition.key + ' is ' + condition.values.join(' or ');
var shown = condition.values.map(function (v) { return controller.type === 'bool' ? (v === '1' ? 'on' : 'off') : choiceLabel(controller, v); });
return controller.type === 'bool' && condition.values.length === 1
? '' + esc(controller.title) + ' is ' + shown[0]
: '' + esc(controller.title) + ' is ' + esc(shown.join(' or '));
}
function matchesFilters(s) {
if (s.unused && !el.showUnused.checked && !isChanged(s) && !isPending(s)) return false;
if (state.filter === 'changed' && !isChanged(s) && !isPending(s)) return false;
if (state.filter === 'restart' && !s.restart) return false;
if (!state.query) return true;
return queryMatches(s.name + ' ' + s.title + ' ' + s.description + ' ' + s.section + ' ' + s.file);
}
// ---------------------------------------------------------------- fuzzy search
// "rent, ugc icon": commas (or |) separate searches, any of which may match; within one search every word must
// match somewhere, in any order. A word matches a word of the setting when it is part of it, when its letters
// appear in it in order (e.g. "dbnc" -> "debounce"), or with one typo (words of 4+ letters).
function searchGroups(query) {
return query.split(/[,|]/).map(function (group) {
return group.split(/[\s_.\-/]+/).filter(Boolean);
}).filter(function (words) { return words.length; });
}
function isSubsequence(needle, word) {
var i = 0;
for (var j = 0; j < word.length && i < needle.length; j++) if (word[j] === needle[i]) i++;
return i === needle.length;
}
// At most one insertion, deletion or substitution between needle and the start of word (so "unsed" finds "unused").
function withinOneEdit(needle, word) {
var candidates = [word.slice(0, needle.length - 1), word.slice(0, needle.length), word.slice(0, needle.length + 1)];
return candidates.some(function (c) {
var a = needle, b = c, edits = 0, i = 0, j = 0;
while (i < a.length && j < b.length) {
if (a[i] === b[j]) { i++; j++; continue; }
if (++edits > 1) return false;
if (a[i] === b[j + 1] && a[i + 1] === b[j]) { i += 2; j += 2; } // two letters swapped
else if (a.length > b.length) i++;
else if (b.length > a.length) j++;
else { i++; j++; }
}
return edits + (a.length - i) + (b.length - j) <= 1;
});
}
function wordMatches(needle, text, words) {
if (text.indexOf(needle) !== -1) return true;
return words.some(function (w) {
if (needle.length >= 3 && needle[0] === w[0] && isSubsequence(needle, w)) return true;
return needle.length >= 4 && withinOneEdit(needle, w);
});
}
function queryMatches(haystack) {
var text = haystack.toLowerCase().replace(/[_.\-/]+/g, ' ');
var words = text.split(/\s+/).filter(Boolean);
return searchGroups(state.query).some(function (group) {
return group.every(function (needle) { return wordMatches(needle, text, words); });
});
}
// ---------------------------------------------------------------- controls, one per setting type
function numberError(s, value) {
var n = Number(value);
if (!isFinite(n) || (s.type === 'int' && Math.floor(n) !== n)) return s.type === 'int' ? 'Enter a whole number' : 'Enter a number';
if (s.min !== undefined && n < s.min) return 'At least ' + s.min;
if (s.max !== undefined && n > s.max) return 'At most ' + s.max;
return '';
}
function textError(s, value) {
if (s.format === 'url' && !/^https?:\/\//.test(value)) return 'Start with http:// or https://';
if (s.format === 'email' && (value.indexOf('@') === -1 || /[\s,;]/.test(value))) return 'Enter one email address';
if (s.format === 'host' && /[\s\/]/.test(value)) return 'A host name or address, without spaces or slashes';
if (!s.multiline && /[\r\n]/.test(value)) return 'Use one line';
return '';
}
// Wraps an input with the setting's unit, e.g. [ 30 | days ]
function withUnit(s, input) {
return s.unit ? '
' + input + '' + esc(s.unit) + '
' : input;
}
function attrs(s) {
return ' id="' + domId(keyOf(s)) + '" data-input="' + esc(keyOf(s)) + '"';
}
var Controls = {
bool: {
render: function (s, value) {
return '';
},
read: function (input) { return input.checked ? '1' : '0'; },
check: function () { return ''; }
},
choice: {
render: function (s, value) {
return '';
},
read: function (input) { return input.value; },
check: function () { return ''; }
},
int: {
render: function (s, value) {
return withUnit(s, '');
},
read: function (input) { return input.value.trim(); },
check: numberError
},
text: {
render: function (s, value) {
if (s.multiline) return '';
var type = s.format === 'url' ? 'url' : s.format === 'email' ? 'email' : 'text';
var mono = s.format === 'path' || s.format === 'host' ? ' font-monospace' : '';
// Suggested values for a free text setting (e.g. model IDs): offered, not enforced
var list = s.choices && s.choices.length ? domId(keyOf(s)) + 'Choices' : '';
return withUnit(s, '' +
(list ? '' : ''));
},
read: function (input, s) { return s.multiline ? input.value : input.value.trim(); },
check: textError
},
secret: {
render: function (s) {
var p = state.pending[keyOf(s)];
return '';
},
read: function (input) { return input.value; },
check: function () { return ''; }
},
int_list: {
render: function (s, value) {
var placeholder = { zone: 'Zone IDs, e.g. 1000, 1100', lot: 'Item or enemy LOTs, e.g. 6086', reward_code: 'Reward code IDs, e.g. 4, 30' }[s.listOf] || 'Numbers separated by commas';
return '' + listNames(s, value);
},
read: function (input) { return input.value.split(',').map(function (v) { return v.trim(); }).filter(Boolean).join(','); },
check: function (s, value) { return value.split(',').some(function (v) { return !/^\d+$/.test(v); }) ? 'Use whole numbers separated by commas' : ''; }
}
};
Controls['float'] = Controls.int;
// Chips for what's chosen, and a "Choose..." panel listing everything from the client database
Controls.pick_list = {
render: function (s, value) {
var key = keyOf(s);
var ids = value.split(',').filter(Boolean);
var chips = ids.map(function (id) {
return '' + esc(pickLabel(s, id)) +
'';
}).join('');
return '' +
'
' + (chips || 'None') + '
' +
'Choose…' +
'
' +
'
Loading…
';
},
read: function (input) { return input.value; },
check: function (s, value) { return Controls.int_list.check(s, value); }
};
function pickLabel(s, id) {
var option = (state.options[s.listOf] || []).filter(function (o) { return String(o.id) === String(id); })[0];
var name = option ? option.label : (state.names[keyOf(s)] || {})[id];
return id + (name ? ' \u00b7 ' + name : '');
}
function loadOptions(of) {
if (state.options[of]) return Promise.resolve(state.options[of]);
return api.get('/api/settings/options?of=' + of).then(function (d) { return (state.options[of] = d.success ? d.options : []); });
}
function fillPicker(details) {
var s = state.byKey[details.dataset.picker];
var chosen = currentValue(s).split(',').filter(Boolean);
var filter = details.querySelector('[data-pick-filter]').value.trim().toLowerCase();
loadOptions(s.listOf).then(function (options) {
var shown = options.filter(function (o) { return !filter || (o.id + ' ' + o.label + ' ' + o.detail).toLowerCase().indexOf(filter) !== -1; });
details.querySelector('[data-pick-list]').innerHTML = shown.length ? shown.map(function (o) {
return '';
}).join('') : '
Nothing matches.
';
});
}
// Add or remove one value, keeping the picker open through the redraw
function pick(key, id, add) {
var s = state.byKey[key];
var ids = currentValue(s).split(',').filter(Boolean).filter(function (v) { return v !== String(id); });
if (add) ids.push(String(id));
ids.sort(function (a, b) { return Number(a) - Number(b); });
var details = el.body.querySelector('[data-picker="' + CSS.escape(key) + '"]');
state.openPicker = details && details.open ? { key: key, filter: details.querySelector('[data-pick-filter]').value } : null;
edit(key, ids.join(','));
render();
}
function control(s) {
if (s.type === 'int_list' && PICKABLE[s.listOf]) return Controls.pick_list;
return Controls[s.type] || Controls.text;
}
function checkValue(s, value) {
if (value === '' || value === null) return '';
return control(s).check(s, value);
}
// Zone, item or reward code names under a list, e.g. [1000 Venture Explorer] [1100 Avant Gardens]
function listNames(s, value) {
if (!s.listOf || s.listOf === 'number' || !value) return '';
var names = state.names[keyOf(s)] || {};
return '
';
}
function refreshNames(s, input) {
var value = control(s).read(input, s);
if (!s.listOf || s.listOf === 'number' || !value) return;
api.get('/api/settings/names?of=' + s.listOf + '&ids=' + encodeURIComponent(value)).then(function (d) {
if (!d.success) return;
state.names[keyOf(s)] = d.names;
var box = input.parentElement.querySelector('[data-names]');
if (box) box.outerHTML = listNames(s, value);
});
}
// ---------------------------------------------------------------- rendering
function sourceText(s) {
var fileLabel = '' + esc(s.file) + '';
switch (s.source) {
case 'web': return 'Set here' + (s.updatedBy ? ' by ' + esc(s.updatedBy) + ' ' + esc(fmt.unix(s.updatedAt)) : '') + (s.webWins ? ', overriding the ' + (s.fileSource === 'env' ? 'environment' : 'file') : '');
case 'env': return 'From the ' + esc(s.env) + ' environment variable';
case 'file': return 'From ' + fileLabel;
default: return 'Default' + (s['default'] !== '' && s.type !== 'secret' ? ': ' + esc(shownValue(s, s['default'])) : '');
}
}
function shownValue(s, value) {
if (s.type === 'bool') return value === '1' ? 'on' : 'off';
if (value === '' || value === undefined || value === null) return '(empty)';
if (s.type === 'choice') return choiceLabel(s, String(value));
return String(value) + (s.unit ? ' ' + s.unit : '');
}
// Read-only: the database connection and the dashboard's keys can't come from the database
function lockedControl(s) {
var value = s.secret ? (s.isSet ? 'set' : 'not set') : shownValue(s, s.value);
return '
🔒' + esc(value) + '
' +
'
Change it in ' + esc(s.file) + ' or the ' + esc(s.env) + ' environment variable, then restart. ' +
(s.name.indexOf('mysql_') === 0 || s.name.indexOf('sqlite_') === 0 || s.name === 'database_type'
? 'The servers need it to reach the database, so they can\'t read it from the database.'
: 'The dashboard needs it before it can read settings from the database.') + '
';
}
function settingHtml(s, compact) {
var key = keyOf(s), value = currentValue(s), pending = state.pending[key];
var classes = 'setting' + (pending ? ' is-changed' : '') + (state.errors[key] ? ' has-error' : '');
var tags = (s.restart ? 'restart' : '') +
(s.unused ? 'unused' : '') +
(!s.known ? 'custom' : '');
var label = '' + tags;
// Anything ever changed here has a history (and undo)
var history = s.updatedAt ? ' · History' : '';
var meta = '
A value set here is ignored because the ' + (s.fileSource === 'env' ? 'environment' : 'file') + ' sets this.
';
}
var actions = '';
if (pending) actions += '';
else if (s.hasWebValue && !s.fileOnly) actions += '';
var input = s.fileOnly ? lockedControl(s) : control(s).render(s, value);
var error = '
' + esc(state.errors[key] || '') + '
';
if (compact) {
return '
' +
input + error + (actions ? '
' + actions + '
' : '') + '
';
}
return '
' +
'
' + label + (s.description ? '
' + esc(s.description) + '
' : '') + meta + '
' +
'
' + input + error + notes + (actions ? '
' + actions + '
' : '') + '
';
}
function sectionHtml(section, settings, heading) {
var title = heading ? esc(heading) + ' › ' + esc(section.name) : esc(section.name);
if (!conditionMet(section.condition) && !state.query) {
return '
' + title + ' · used when ' + describeCondition(section.condition) + '
';
}
var shown = settings.filter(function (s) { return conditionMet(s.condition) || state.query; });
var hidden = settings.length - shown.length;
var body;
if (section.layout === 'grid') {
body = '