KameSame Open Framework - Settings module

Settings module for KameSame Open Framework

Version vom 06.10.2022. Aktuellste Version

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/451521/1101524/KameSame%20Open%20Framework%20-%20Settings%20module.js

// ==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 = { '<': '&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) {
        // 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);