// ==UserScript==
// @name KameSame Open Framework - Settings module
// @namespace timberpile
// @description Settings module for KameSame Open Framework
// @version 0.2
// @copyright 2022+, Robin Findley, Timberpile
// @license MIT; http://opensource.org/licenses/MIT
// ==/UserScript==
(function (global) {
const publish_context = false; // Set to 'true' to make context public.
const ksof = global.ksof;
//########################################################################
//------------------------------
// Constructor
//------------------------------
class Settings {
constructor(config) {
const context = {
self: this,
cfg: config,
};
if (!config.content)
config.content = config.settings;
if (publish_context)
this.context = context;
// Create public methods bound to context.
this.cancel = cancel_btn.bind(context, context);
this.open = open.bind(context, context);
this.close = close.bind(context, context);
this.load = load_settings.bind(context, context);
this.save = save_settings.bind(context, context);
this.refresh = refresh.bind(context, context);
this.background = Settings.background;
}
//------------------------------
// Open the settings dialog.
//------------------------------
save_settings(context) {
const script_id = (typeof context === 'string' ? context : context.cfg.script_id);
const settings = ksof.settings[script_id];
if (!settings)
return Promise.resolve();
return ksof.file_cache.save('ksof.settings.' + script_id, settings);
}
}
global.ksof.Settings = Settings;
global.ksof.settings = {};
// Settings.save = save_settings;
Settings.load = load_settings;
Settings.background = {
open: open_background,
close: close_background,
};
//########################################################################
let ready = false;
//========================================================================
function deep_merge(...objects) {
const merged = {};
function recursive_merge(dest, src) {
for (const prop in src) {
if (typeof src[prop] === 'object' && src[prop] !== null) {
if (Array.isArray(src[prop])) {
dest[prop] = src[prop].slice();
}
else {
dest[prop] = dest[prop] || {};
recursive_merge(dest[prop], src[prop]);
}
}
else {
dest[prop] = src[prop];
}
}
return dest;
}
for (const obj in objects) {
recursive_merge(merged, objects[obj]);
}
return merged;
}
//------------------------------
// Convert a config object to html dialog.
//------------------------------
/* eslint-disable no-case-declarations */
function config_to_html(context) {
context.config_list = {};
let base = ksof.settings[context.cfg.script_id];
if (base === undefined)
ksof.settings[context.cfg.script_id] = base = {};
let html = '';
const child_passback = {};
const id = context.cfg.script_id + '_dialog';
for (const name in context.cfg.content) {
html += parse_item(name, context.cfg.content[name], child_passback);
}
if (child_passback.tabs)
html = assemble_pages(id, child_passback.tabs, child_passback.pages) + html;
return '<form>' + html + '</form>';
//============
function parse_item(name, item, passback) {
if (typeof item.type !== 'string')
return '';
const id = context.cfg.script_id + '_' + name;
let cname, html = '', value, child_passback, non_page = '';
switch (item.type) {
case 'tabset':
child_passback = {};
for (cname in item.content)
non_page += parse_item(cname, item.content[cname], child_passback);
if (child_passback.tabs)
html = assemble_pages(id, child_passback.tabs, child_passback.pages);
break;
case 'page':
if (typeof item.content !== 'object')
item.content = {};
if (!passback.tabs) {
passback.tabs = [];
passback.pages = [];
}
passback.tabs.push('<li id="' + id + '_tab"' + to_title(item.hover_tip) + '><a href="#' + id + '">' + item.label + '</a></li>');
child_passback = {};
for (cname in item.content)
non_page += parse_item(cname, item.content[cname], child_passback);
if (child_passback.tabs)
html = assemble_pages(id, child_passback.tabs, child_passback.pages);
passback.pages.push('<div id="' + id + '">' + html + non_page + '</div>');
passback.is_page = true;
html = '';
break;
case 'group':
if (typeof item.content !== 'object')
item.content = {};
child_passback = {};
for (cname in item.content)
non_page += parse_item(cname, item.content[cname], child_passback);
if (child_passback.tabs)
html = assemble_pages(id, child_passback.tabs, child_passback.pages);
html = '<fieldset id="' + id + '" class="ksof_group"><legend>' + item.label + '</legend>' + html + non_page + '</fieldset>';
break;
case 'dropdown':
case 'list':
let classes = 'setting';
let attribs = '';
context.config_list[name] = item;
value = get_value(context, base, name);
if (value === undefined) {
if (item.default !== undefined) {
value = item.default;
}
else {
if (item.multi === true) {
value = {};
Object.keys(item.content).forEach(function (key) {
value[key] = false;
});
}
else {
value = Object.keys(item.content)[0];
}
}
set_value(context, base, name, value);
}
if (item.type === 'list') {
classes += ' list';
attribs += ' size="' + (item.size || Object.keys(item.content).length || 4) + '"';
if (item.multi === true)
attribs += ' multiple';
}
html = '<select id="' + id + '" name="' + name + '" class="' + classes + '"' + attribs + to_title(item.hover_tip) + '>';
for (cname in item.content)
html += '<option name="' + cname + '">' + escape_text(item.content[cname]) + '</option>';
html += '</select>';
html = make_label(item) + wrap_right(html);
html = wrap_row(html, item.full_width, item.hover_tip);
break;
case 'checkbox':
context.config_list[name] = item;
html = make_label(item);
value = get_value(context, base, name);
if (value === undefined) {
value = (item.default || false);
set_value(context, base, name, value);
}
html += wrap_right('<input id="' + id + '" class="setting" type="checkbox" name="' + name + '">');
html = wrap_row(html, item.full_width, item.hover_tip);
break;
case 'input':
case 'number':
case 'text':
const itype = item.type;
if (itype === 'input')
itype = item.subtype || 'text';
context.config_list[name] = item;
html += make_label(item);
value = get_value(context, base, name);
if (value === undefined) {
const is_number = (item.type === 'number' || item.subtype === 'number');
value = (item.default || (is_number === 'number' ? 0 : ''));
set_value(context, base, name, value);
}
html += wrap_right('<input id="' + id + '" class="setting" type="' + itype + '" name="' + name + '"' + (item.placeholder ? ' placeholder="' + escape_attr(item.placeholder) + '"' : '') + '>');
html = wrap_row(html, item.full_width, item.hover_tip);
break;
case 'color':
context.config_list[name] = item;
html += make_label(item);
value = get_value(context, base, name);
if (value === undefined) {
value = (item.default || '#000000');
set_value(context, base, name, value);
}
html += wrap_right('<input id="' + id + '" class="setting" type="color" name="' + name + '">');
html = wrap_row(html, item.full_width, item.hover_tip);
break;
case 'button':
context.config_list[name] = item;
html += make_label(item);
const text = escape_text(item.text || 'Click');
html += wrap_right('<button type="button" class="setting" name="' + name + '">' + text + '</button>');
html = wrap_row(html, item.full_width, item.hover_tip);
break;
case 'divider':
html += '<hr>';
break;
case 'section':
html += '<section>' + (item.label || '') + '</section>';
break;
case 'html':
html += make_label(item);
html += item.html;
switch (item.wrapper) {
case 'row':
html = wrap_row(html, null, item.hover_tip);
break;
case 'left':
html = wrap_left(html);
break;
case 'right':
html = wrap_right(html);
break;
}
break;
}
return html;
function make_label(item) {
if (typeof item.label !== 'string')
return '';
return wrap_left('<label for="' + id + '">' + item.label + '</label>');
}
}
/* eslint-enable no-case-declarations */
//============
function assemble_pages(id, tabs, pages) { return '<div id="' + id + '" class="ksof_stabs"><ul>' + tabs.join('') + '</ul>' + pages.join('') + '</div>'; }
function wrap_row(html, full, hover_tip) { return '<div class="row' + (full ? ' full' : '') + '"' + to_title(hover_tip) + '>' + html + '</div>'; }
function wrap_left(html) { return '<div class="left">' + html + '</div>'; }
function wrap_right(html) { return '<div class="right">' + html + '</div>'; }
function escape_text(text) { return text.replace(/[<>]/g, function (ch) { const map = { '<': '<', '>': '>' }; return map[ch]; }); }
function escape_attr(text) { return text.replace(/"/g, '"'); }
function to_title(tip) { if (!tip)
return ''; return ' title="' + tip.replace(/"/g, '"') + '"'; }
}
//------------------------------
// Open the settings dialog.
//------------------------------
function open(context) {
// todo investigate why the dialog doesn't render properly
if (!ready)
return;
if ($('#ksofs_' + context.cfg.script_id).length > 0)
return;
install_anchor();
if (context.cfg.background !== false)
open_background();
const dialog = $('<div id="ksofs_' + context.cfg.script_id + '" class="ksof_settings" style="display:none;"></div>');
dialog.html(config_to_html(context));
let width = 500;
if (window.innerWidth < 510) {
width = 280;
dialog.addClass('narrow');
}
dialog.dialog({
title: context.cfg.title,
buttons: [
{ text: 'Save', click: save_btn.bind(context, context) },
{ text: 'Cancel', click: cancel_btn.bind(context, context) }
],
width: width,
maxHeight: document.body.clientHeight,
modal: false,
autoOpen: false,
appendTo: '#ksof_ds',
resize: resize.bind(context, context),
close: close.bind(context, context)
});
$(dialog.dialog('widget')).css('position', 'fixed');
dialog.parent().addClass('ksof_settings_dialog');
$('.ksof_stabs').tabs({ activate: tab_activated.bind(null, context) });
const settings = ksof.settings[context.cfg.script_id];
if (settings && settings.ksofs_active_tabs instanceof Array) {
const active_tabs = settings.ksofs_active_tabs;
for (let tab_idx = 0; tab_idx < active_tabs.length; tab_idx++) {
const tab = $(active_tabs[tab_idx]);
tab.closest('.ui-tabs').tabs({ active: tab.index() });
}
}
dialog.dialog('open');
const dialog_elem = $('#ksofs_' + context.cfg.script_id);
dialog_elem.find('.setting[multiple]').on('mousedown', toggle_multi.bind(null, context));
dialog_elem.find('.setting').on('change', setting_changed.bind(null, context));
dialog_elem.find('form').on('submit', function () { return false; });
dialog_elem.find('button.setting').on('click', setting_button_clicked.bind(null, context));
if (typeof context.cfg.pre_open === 'function')
context.cfg.pre_open(dialog);
context.reversions = deep_merge({}, ksof.settings[context.cfg.script_id]);
refresh(context);
//============
function tab_activated(context, event, ui) {
const dialog = $('#ksofs_' + context.cfg.script_id);
const wrapper = $(dialog.dialog('widget'));
if (wrapper.outerHeight() + wrapper.position().top > document.body.clientHeight) {
dialog.dialog('option', 'maxHeight', document.body.clientHeight);
}
}
function resize(context, event, ui) {
const dialog = $('#ksofs_' + context.cfg.script_id);
const is_narrow = dialog.hasClass('narrow');
if (is_narrow && ui.size.width >= 510)
dialog.removeClass('narrow');
else if (!is_narrow && ui.size.width < 490)
dialog.addClass('narrow');
}
function toggle_multi(context, e) {
if (e.button != 0)
return true;
const multi = $(e.currentTarget);
const scroll = e.currentTarget.scrollTop;
e.target.selected = !e.target.selected;
setTimeout(function () {
e.currentTarget.scrollTop = scroll;
multi.focus();
}, 0);
return setting_changed(context, e);
}
function setting_button_clicked(context, e) {
const name = e.target.attributes.name.value;
const item = context.config_list[name];
if (typeof item.on_click === 'function')
item.on_click.call(e, name, item, setting_changed.bind(context, context, e));
}
}
//------------------------------
// Open the settings dialog.
//------------------------------
function load_settings(context, defaults) {
const script_id = (typeof context === 'string' ? context : context.cfg.script_id);
return ksof.file_cache.load('ksof.settings.' + script_id)
.then(finish, finish.bind(null, {}));
function finish(settings) {
if (defaults)
ksof.settings[script_id] = deep_merge(defaults, settings);
else
ksof.settings[script_id] = settings;
return ksof.settings[script_id];
}
}
//------------------------------
// Save button handler.
//------------------------------
function save_btn(context, e) {
const script_id = context.cfg.script_id;
const dialog = $('#ksofs_' + script_id);
const settings = ksof.settings[script_id];
if (settings) {
const active_tabs = dialog.find('.ui-tabs-active').toArray().map(function (tab) { return '#' + tab.attributes.id.value; });
if (active_tabs.length > 0)
settings.ksofs_active_tabs = active_tabs;
}
if (context.cfg.autosave === undefined || context.cfg.autosave === true)
save_settings(context);
if (typeof context.cfg.on_save === 'function')
context.cfg.on_save(ksof.settings[context.cfg.script_id]);
ksof.trigger('ksof.settings.save');
context.keep_settings = true;
dialog.dialog('close');
}
//------------------------------
// Cancel button handler.
//------------------------------
function cancel_btn(context) {
const dialog = $('#ksofs_' + context.cfg.script_id);
dialog.dialog('close');
if (typeof context.cfg.on_cancel === 'function')
context.cfg.on_cancel(ksof.settings[context.cfg.script_id]);
}
//------------------------------
// Close and destroy the dialog.
//------------------------------
function close(context, keep_settings) {
const dialog = $('#ksofs_' + context.cfg.script_id);
if (!context.keep_settings && keep_settings !== true) {
// Revert settings
ksof.settings[context.cfg.script_id] = deep_merge({}, context.reversions);
delete context.reversions;
}
delete context.keep_settings;
dialog.dialog('destroy');
if (context.cfg.background !== false)
close_background();
if (typeof context.cfg.on_close === 'function')
context.cfg.on_close(ksof.settings[context.cfg.script_id]);
}
//------------------------------
// Update the dialog to reflect changed settings.
//------------------------------
function refresh(context) {
const script_id = context.cfg.script_id;
const settings = ksof.settings[script_id];
const dialog = $('#ksofs_' + script_id);
for (const name in context.config_list) {
const elem = dialog.find('#' + script_id + '_' + name);
const config = context.config_list[name];
const value = get_value(context, settings, name);
switch (config.type) {
case 'dropdown':
case 'list':
if (config.multi === true) {
elem.find('option').each(function (i, e) {
const opt_name = e.getAttribute('name') || '#' + e.index;
e.selected = value[opt_name];
});
}
else {
elem.find('option[name="' + value + '"]').prop('selected', true);
}
break;
case 'checkbox':
elem.prop('checked', value);
break;
default:
elem.val(value);
break;
}
}
if (typeof context.cfg.on_refresh === 'function')
context.cfg.on_refresh(ksof.settings[context.cfg.script_id]);
}
//------------------------------
// Handler for live settings changes. Handles built-in validation and user callbacks.
//------------------------------
function setting_changed(context, event) {
const elem = $(event.currentTarget);
const name = elem.attr('name');
const item = context.config_list[name];
// Extract the value
let value;
const itype = ((item.type === 'input' && item.subtype === 'number') ? 'number' : item.type);
switch (itype) {
case 'dropdown':
case 'list':
if (item.multi === true) {
value = {};
elem.find('option').each(function (i, e) {
const opt_name = e.getAttribute('name') || '#' + e.index;
value[opt_name] = e.selected;
});
}
else {
value = elem.find(':checked').attr('name');
}
break;
case 'checkbox':
value = elem.is(':checked');
break;
case 'number':
value = Number(elem.val());
break;
default:
value = elem.val();
break;
}
// Validation
let valid = { valid: true, msg: '' };
if (typeof item.validate === 'function')
valid = item.validate.call(event.target, value, item);
if (typeof valid === 'boolean')
valid = { valid: valid, msg: '' };
else if (typeof valid === 'string')
valid = { valid: false, msg: valid };
else if (valid === undefined)
valid = { valid: true, msg: '' };
switch (itype) {
case 'number':
if (typeof item.min === 'number' && Number(value) < item.min) {
valid.valid = false;
if (valid.msg.length === 0) {
if (typeof item.max === 'number')
valid.msg = 'Must be between ' + item.min + ' and ' + item.max;
else
valid.msg = 'Must be ' + item.min + ' or higher';
}
}
else if (typeof item.max === 'number' && Number(value) > item.max) {
valid.valid = false;
if (valid.msg.length === 0) {
if (typeof item.min === 'number')
valid.msg = 'Must be between ' + item.min + ' and ' + item.max;
else
valid.msg = 'Must be ' + item.max + ' or lower';
}
}
if (!valid) // TODO intended?
break;
// eslint-disable-next-line no-fallthrough
case 'text':
if (item.match !== undefined && value.match(item.match) === null) {
valid.valid = false;
if (valid.msg.length === 0)
valid.msg = item.error_msg || 'Invalid value';
}
break;
}
// Style for valid/invalid
const parent = elem.closest('.right');
parent.find('.note').remove();
if (typeof valid.msg === 'string' && valid.msg.length > 0)
parent.append('<div class="note' + (valid.valid ? '' : ' error') + '">' + valid.msg + '</div>');
if (!valid.valid) {
elem.addClass('invalid');
}
else {
elem.removeClass('invalid');
}
const script_id = context.cfg.script_id;
const settings = ksof.settings[script_id];
if (valid.valid) {
if (item.no_save !== true)
set_value(context, settings, name, value);
if (typeof item.on_change === 'function')
item.on_change.call(event.target, name, value, item);
if (typeof context.cfg.on_change === 'function')
context.cfg.on_change.call(event.target, name, value, item);
if (item.refresh_on_change === true)
refresh(context);
}
return false;
}
function get_value(context, base, name) {
const item = context.config_list[name];
const evaluate = (item.path !== undefined);
const path = (item.path || name);
try {
if (!evaluate)
return base[path];
return eval(path.replace(/@/g, 'base.'));
}
catch (e) {
return;
}
}
function set_value(context, base, name, value) {
const item = context.config_list[name];
const evaluate = (item.path !== undefined);
const path = (item.path || name);
try {
if (!evaluate)
return base[path] = value;
var depth = 0, new_path = '', param, c;
for (var idx = 0; idx < path.length; idx++) {
c = path[idx];
if (c === '[') {
if (depth++ === 0) {
new_path += '[';
param = '';
}
else {
param += '[';
}
}
else if (c === ']') {
if (--depth === 0) {
new_path += JSON.stringify(eval(param)) + ']';
}
else {
param += ']';
}
}
else {
if (c === '@')
c = 'base.';
if (depth === 0)
new_path += c;
else
param += c;
}
}
eval(new_path + '=value');
}
catch (e) {
return;
}
}
function install_anchor() {
const anchor = $('#ksof_ds');
if (anchor.length === 0) {
anchor = $('<div id="ksof_ds"></div></div>');
$('body').prepend(anchor);
$('#ksof_ds').on('keydown keyup keypress', '.ksof_settings_dialog', function (e) {
// Stop keys from bubbling beyond the background overlay.
e.stopPropagation();
});
}
return anchor;
}
function open_background() {
const anchor = install_anchor();
let bkgd = anchor.find('> #ksofs_bkgd');
if (bkgd.length === 0) {
bkgd = $('<div id="ksofs_bkgd" refcnt="0"></div>');
anchor.prepend(bkgd);
}
const refcnt = Number(bkgd.attr('refcnt'));
bkgd.attr('refcnt', refcnt + 1);
}
function close_background() {
const bkgd = $('#ksof_ds > #ksofs_bkgd');
if (bkgd.length === 0)
return;
const refcnt = Number(bkgd.attr('refcnt'));
if (refcnt <= 0)
return;
bkgd.attr('refcnt', refcnt - 1);
}
//------------------------------
// Load jquery UI and the appropriate CSS based on location.
//------------------------------
const css_url = ksof.support_files['jqui_ksmain.css'];
ksof.include('Jquery');
ksof.ready('document, Jquery')
.then(function () {
return Promise.all([
ksof.load_script(ksof.support_files['jquery_ui.js'], true /* cache */),
ksof.load_css(css_url, true /* cache */)
]);
})
.then(function (data) {
ready = true;
// Workaround... https://community.wanikani.com/t/19984/55
try {
delete $.fn.autocomplete;
}
catch (e) {
// do nothing
}
// Notify listeners that we are ready.
// Delay guarantees include() callbacks are called before ready() callbacks.
setTimeout(function () { ksof.set_state('ksof.Settings', 'ready'); }, 0);
});
})(this);