Wanikani Open Framework - Settings module

Settings module for Wanikani Open Framework

Dieses Skript sollte nicht direkt installiert werden. Es handelt sich hier um eine Bibliothek für andere Skripte, welche über folgenden Befehl in den Metadaten eines Skriptes eingebunden wird // @require https://update.greatest.deepsurf.us/scripts/38576/1091793/Wanikani%20Open%20Framework%20-%20Settings%20module.js

// ==UserScript==
// @name        Wanikani Open Framework - Settings module
// @namespace   rfindley
// @description Settings module for Wanikani Open Framework
// @version     1.0.20
// @copyright   2022+, Robin Findley
// @license     MIT; http://opensource.org/licenses/MIT
// ==/UserScript==

(function(global) {

	const publish_context = false; // Set to 'true' to make context public.

	//########################################################################
	//------------------------------
	// Constructor
	//------------------------------
	function Settings(config) {
		var 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;
	};

	global.wkof.Settings = Settings;
	Settings.save = save_settings;
	Settings.load = load_settings;
	Settings.background = {
		open: open_background,
		close: close_background,
	}
	//########################################################################

	wkof.settings = {};
	var ready = false;

	//========================================================================
	function deep_merge(...objects) {
		let merged = {};
		function recursive_merge(dest, src) {
			for (let 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 (let obj in objects) {
			recursive_merge(merged, objects[obj]);
		}
		return merged;
	}

	//------------------------------
	// Convert a config object to html dialog.
	//------------------------------
	function config_to_html(context) {
		context.config_list = {};
		var base = wkof.settings[context.cfg.script_id];
		if (base === undefined) wkof.settings[context.cfg.script_id] = base = {};

		var html = '', item, child_passback = {};
		var id = context.cfg.script_id+'_dialog';
		for (var name in context.cfg.content) {
			var item = context.cfg.content[name];
			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 '';
			var id = context.cfg.script_id+'_'+name;
			var 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="wkof_group"><legend>'+item.label+'</legend>'+html+non_page+'</fieldset>';
					break;

				case 'dropdown':
				case 'list':
					var classes = 'setting', 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':
					var 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) {
						var 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);
					var 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>');
			}
		}

		//============
		function assemble_pages(id, tabs, pages) {return '<div id="'+id+'" class="wkof_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) {var map={'<':'&lt','>':'&gt;'}; return map[ch];});}
		function escape_attr(text) {return text.replace(/"/g, '&quot;');}
		function to_title(tip) {if (!tip) return ''; return ' title="'+tip.replace(/"/g,'&quot;')+'"';}
	}

	//------------------------------
	// Open the settings dialog.
	//------------------------------
	function open(context) {
		if (!ready) return;
		if ($('#wkofs_'+context.cfg.script_id).length > 0) return;
		install_anchor();
		if (context.cfg.background !== false) open_background();
		var dialog = $('<div id="wkofs_'+context.cfg.script_id+'" class="wkof_settings" style="display:none;"></div>');
		dialog.html(config_to_html(context));

		var 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: '#wkof_ds',
			resize: resize.bind(context,context),
			close: close.bind(context,context)
		});
		$(dialog.dialog('widget')).css('position','fixed');
		dialog.parent().addClass('wkof_settings_dialog');

		$('.wkof_stabs').tabs({activate:tab_activated.bind(null,context)});
		var settings = wkof.settings[context.cfg.script_id];
		if (settings && settings.wkofs_active_tabs instanceof Array) {
			var active_tabs = settings.wkofs_active_tabs;
			for (var tab_idx = 0; tab_idx < active_tabs.length; tab_idx++) {
				var tab = $(active_tabs[tab_idx]);
				tab.closest('.ui-tabs').tabs({active:tab.index()});
			}
		}

		dialog.dialog('open');
		var dialog_elem = $('#wkofs_'+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({}, wkof.settings[context.cfg.script_id]);
		refresh(context);

		//============
		function tab_activated(context, event, ui) {
			var dialog = $('#wkofs_'+context.cfg.script_id);
			var 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){
			var dialog = $('#wkofs_'+context.cfg.script_id);
			var 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;
			var multi = $(e.currentTarget);
			var 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) {
			var name = e.target.attributes.name.value;
			var 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 save_settings(context) {
		var script_id = (typeof context === 'string' ? context : context.cfg.script_id);
		var settings = wkof.settings[script_id];
		if (!settings) return Promise.resolve();
		return wkof.file_cache.save('wkof.settings.'+script_id, settings);
	}

	//------------------------------
	// Open the settings dialog.
	//------------------------------
	function load_settings(context, defaults) {
		var script_id = (typeof context === 'string' ? context : context.cfg.script_id);
		return wkof.file_cache.load('wkof.settings.'+script_id)
		.then(finish, finish.bind(null,{}));

		function finish(settings) {
			if (defaults)
				wkof.settings[script_id] = deep_merge(defaults, settings);
			else
				wkof.settings[script_id] = settings;
			return wkof.settings[script_id];
		}
	}

	//------------------------------
	// Save button handler.
	//------------------------------
	function save_btn(context, e) {
		var script_id = context.cfg.script_id;
		var dialog = $('#wkofs_'+script_id);
		var settings = wkof.settings[script_id];
		if (settings) {
			var active_tabs = dialog.find('.ui-tabs-active').toArray().map(function(tab){return '#'+tab.attributes.id.value});
			if (active_tabs.length > 0) settings.wkofs_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(wkof.settings[context.cfg.script_id]);
		wkof.trigger('wkof.settings.save');
		context.keep_settings = true;
		dialog.dialog('close');
	}

	//------------------------------
	// Cancel button handler.
	//------------------------------
	function cancel_btn(context) {
		var dialog = $('#wkofs_'+context.cfg.script_id);
		dialog.dialog('close');
		if (typeof context.cfg.on_cancel === 'function') context.cfg.on_cancel(wkof.settings[context.cfg.script_id]);
	}

	//------------------------------
	// Close and destroy the dialog.
	//------------------------------
	function close(context, keep_settings) {
		var dialog = $('#wkofs_'+context.cfg.script_id);
		if (!context.keep_settings && keep_settings !== true) {
			// Revert settings
			wkof.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(wkof.settings[context.cfg.script_id]);
	}

	//------------------------------
	// Update the dialog to reflect changed settings.
	//------------------------------
	function refresh(context) {
		var script_id = context.cfg.script_id;
		var settings = wkof.settings[script_id];
		var dialog = $('#wkofs_'+script_id);
		for (var name in context.config_list) {
			var elem = dialog.find('#'+script_id+'_'+name);
			var config = context.config_list[name];
			var value = get_value(context, settings, name);
			switch (config.type) {
				case 'dropdown':
				case 'list':
					if (config.multi === true) {
						elem.find('option').each(function(i,e){
							var 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(wkof.settings[context.cfg.script_id]);
	}

	//------------------------------
	// Handler for live settings changes.  Handles built-in validation and user callbacks.
	//------------------------------
	function setting_changed(context, event) {
		var elem = $(event.currentTarget);
		var name = elem.attr('name');
		var item = context.config_list[name];
		var config;

		// Extract the value
		var value;
		var 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){
						var 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
		var 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)
				break;

			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
		var 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');
		}

		var script_id = context.cfg.script_id;
		var settings = wkof.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){
		var item = context.config_list[name];
		var evaluate = (item.path !== undefined);
		var 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) {
		var item = context.config_list[name];
		var evaluate = (item.path !== undefined);
		var 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() {
		var anchor = $('#wkof_ds');
		if (anchor.length === 0) {
			anchor = $('<div id="wkof_ds"></div></div>');
			$('body').prepend(anchor);
			$('#wkof_ds').on('keydown keyup keypress', '.wkof_settings_dialog', function(e) {
				// Stop keys from bubbling beyond the background overlay.
				e.stopPropagation();
			});
		}
		return anchor;
	}

	function open_background() {
		var anchor = install_anchor();
		var bkgd = anchor.find('> #wkofs_bkgd');
		if (bkgd.length === 0) {
			bkgd = $('<div id="wkofs_bkgd" refcnt="0"></div>');
			anchor.prepend(bkgd);
		}
		var refcnt = Number(bkgd.attr('refcnt'));
		bkgd.attr('refcnt', refcnt + 1);
	}

	function close_background() {
		var bkgd = $('#wkof_ds > #wkofs_bkgd');
		if (bkgd.length === 0) return;
		var refcnt = Number(bkgd.attr('refcnt'));
		if (refcnt <= 0) return;
		bkgd.attr('refcnt', refcnt - 1);
	}

	//------------------------------
	// Load jquery UI and the appropriate CSS based on location.
	//------------------------------
	var css_url = wkof.support_files['jqui_wkmain.css'];

	wkof.include('Jquery');
	wkof.ready('document, Jquery')
	.then(function(){
		return Promise.all([
			wkof.load_script(wkof.support_files['jquery_ui.js'], true /* cache */),
			wkof.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) {}

		// Notify listeners that we are ready.
		// Delay guarantees include() callbacks are called before ready() callbacks.
		setTimeout(function(){wkof.set_state('wkof.Settings', 'ready');},0);
	});

})(this);