Raw Source
almaceleste / UserStyles.Org Tweaks

// ==UserScript==
// @name            UserStyles.Org Tweaks
// @namespace       almaceleste
// @version         0.2.1
// @description     some fixes and tweaks for userstyle.org
// @description:ru  несколько исправлений и твиков для userstyle.org
// @author          GNU Affero GPL 3.0 🄯 2020 almaceleste  (https://almaceleste.github.io)
// @license         AGPL-3.0-or-later; http://www.gnu.org/licenses/agpl.txt
// @icon            https://userstyles.org/ui/images/icons/favicon.png
// @icon64          https://userstyles.org/ui/images/icons/app_icon.png

// @homepageURL     https://greasyfork.org/en/users/174037-almaceleste
// @homepageURL     https://openuserjs.org/users/almaceleste
// @homepageURL     https://github.com/almaceleste/userscripts
// @supportURL      https://github.com/almaceleste/userscripts/issues
// @updateURL       https://github.com/almaceleste/userscripts/raw/master/src/UserStyles.Org_Tweaks.user.js
// @downloadURL     https://github.com/almaceleste/userscripts/raw/master/src/UserStyles.Org_Tweaks.user.js
// @downloadURL     https://openuserjs.org/install/almaceleste/UserStyles.Org_Tweaks.user.js

// @require         https://code.jquery.com/jquery-3.3.1.js
// @require         https://raw.githubusercontent.com/uzairfarooq/arrive/master/minified/arrive.min.js
// @require         https://openuserjs.org/src/libs/sizzle/GM_config.js
// @grant           GM_getValue
// @grant           GM_setValue
// @grant           GM_registerMenuCommand
// @grant           GM_openInTab
// @grant           GM_getResourceText

// @resource        css https://github.com/almaceleste/userscripts/raw/master/css/default.css

// @match           http*://*.userstyles.org/styles/new
// @match           http*://*.userstyles.org/d/styles/new
// @match           http*://*.userstyles.org/styles/*/edit
// @match           http*://*.userstyles.org/d/styles/*/edit
// ==/UserScript==

// ==OpenUserJS==
// @author almaceleste
// ==/OpenUserJS==

// script global variables
const offset = 50;
const frame = '#iframe';

const form = `body > .PageContent > form[action='/styles/create']`;
const newoptionbutton = `.new-option > input[type='button']`;
const newsettingbutton = `#new-setting > input[type='button']`;
const newdropdownbutton = `${newsettingbutton}:nth-of-type(1)`;
const newcolorbutton = `${newsettingbutton}:nth-of-type(2)`;
const newtextbutton = `${newsettingbutton}:nth-of-type(3)`;
const textarea = `${form} textarea`;
const img = `${form} img`;
const codeeditor = `${form} #enable-source-editor-code`;
const codetext = `${form} #css`;
const settingsection = `${form} #edit-style-settings`;
const dropdownsetting = `${settingsection} > .edit-dropdown-setting`;
const colorsetting = `${settingsection} > .edit-color-setting`;
const textsetting = `${settingsection} > .edit-text-setting`;
const settinglabel = `input:nth-of-type(1)`;
const settingkey = `input:nth-of-type(2)`;
const settingvalue = `input:nth-of-type(6)`;
const optionlabel = `input:nth-of-type(1)`;
const optionkey = `input:nth-of-type(2)`;
const optiondefault = `input:nth-of-type(3)`;
const optionvalue = `textarea`;
const quotes = "'" + '"' + '`';
// arrive.js options
const existing = {
    existing: true
};
const onceonly = {
    onceOnly: true,
    existing: false
}

// config settings
const configId = 'usotweaksCfg';
const iconUrl = GM_info.script.icon64;
const pattern = {};
pattern[`#${configId}`] = /#configId/g;
pattern[`${iconUrl}`] = /iconUrl/g;

let css = GM_getResourceText('css');
Object.keys(pattern).forEach((key) => {
    css = css.replace(pattern[key], key);
});
const windowcss = css;
const iframecss = `
    height: 255px;
    width: 435px;
    border: 1px solid;
    border-radius: 3px;
    position: fixed;
    z-index: 99999;
`;

GM_registerMenuCommand(`${GM_info.script.name} Settings`, () => {
	GM_config.open();
    GM_config.frame.style = iframecss;
});

GM_config.init({
    id: `${configId}`,
    title: `${GM_info.script.name} ${GM_info.script.version}`,
    fields: {
        frameWidth: {
            section: ['', 'New style and Edit pages'],
            label: 'frame width in px',
            labelPos: 'left',
            title: `editing frame width in pixels
    0 - default width`,
            type: 'int',
            default: 0,
        },
        fixframeHeight: {
            label: 'fix frame height on changing content',
            labelPos: 'right',
            title: `fix frame height when adding new settings and options or resizing text area`,
            type: 'checkbox',
            default: true,
        },
        fixtextareaWidth: {
            label: 'fix textarea width',
            labelPos: 'right',
            title: ``,
            type: 'checkbox',
            default: true,
        },
        parsecode: {
            label: 'add parse button to the new/edit page',
            labelPos: 'right',
            title: ``,
            type: 'checkbox',
            default: true,
        },
        parsetargets: {
            title: 'only variables for the uso preprocessor yet',
            type: 'multicheckbox',
            options: {
                // name: true,
                // description: true,
                variables: true,
            },
            default: {variables: true},
        },
        support: {
            section: ['', 'Support'],
            label: 'almaceleste.github.io',
            title: 'more info on almaceleste.github.io',
            type: 'button',
            click: () => {
                GM_openInTab('https://almaceleste.github.io', {
                    active: true,
                    insert: true,
                    setParent: true
                });
            }
        },
    },
    types: {
        multicheckbox: {
            default: {},
            toNode: function() {
                let field = this.settings,
                    values = this.value,
                    options = field.options,
                    id = this.id,
                    configId = this.configId,
                    labelPos = field.labelPos,
                    create = this.create;
                // console.log('toNode:', field, values, options);

                function addLabel(pos, labelEl, parentNode, beforeEl) {
                    if (!beforeEl) beforeEl = parentNode.firstChild;
                    switch (pos) {
                        case 'right': case 'below':
                            if (pos == 'below')
                                parentNode.appendChild(create('br', {}));
                            parentNode.appendChild(labelEl);
                            break;
                        default:
                            if (pos == 'above')
                                parentNode.insertBefore(create('br', {}), beforeEl);
                            parentNode.insertBefore(labelEl, beforeEl);
                    }
                }

                let retNode = create('div', {
                        className: 'config_var multicheckbox',
                        id: `${configId}_${id}_var`,
                        title: field.title || ''
                    }),
                    firstProp;

                // Retrieve the first prop
                for (let i in field) { firstProp = i; break; }

                let label = field.label ? create('label', {
                        className: 'field_label',
                        id: `${configId}_${id}_field_label`,
                        for: `${configId}_field_${id}`,
                    }, field.label) : null;
                let wrap = create('ul', {
                    id: `${configId}_field_${id}`
                });
                this.node = wrap;

                for (const key in values) {
                    // console.log('toNode:', key);
                    const inputId = `${configId}_${id}_${key}_checkbox`;
                    const li = wrap.appendChild(create('li', {
                    }));
                    li.appendChild(create('input', {
                        checked: values[key],
                        id: inputId,
                        type: 'checkbox',
                        value: key,
                    }));
                    li.appendChild(create('label', {
                        className: 'option_label',
                        for: inputId,
                    }, key));
                }

                retNode.appendChild(wrap);

                if (label) {
                    // If the label is passed first, insert it before the field
                    // else insert it after
                    if (!labelPos)
                        labelPos = firstProp == "label" ? "left" : "right";
                    addLabel(labelPos, label, retNode);
                }
                return retNode;
            },
            toValue: function() {
                let node = this.node,
                    id = node.id,
                    rval = {};
                // console.log('toValue:', node, this);

                if (!node) return rval;

                let nodelist = node.querySelectorAll(`#${id} input`);
                // console.log('nodelist:', document.querySelectorAll(`#${id} input:checked`), nodelist);
                nodelist.forEach((input) => {
                    // console.log('toValue:', input);
                    const value = input.checked;
                    const key = input.value;
                    rval[key] = value;
                });

                // console.log('toValue:', rval);
                return rval;
            },
            reset: function() {
                let node = this.node,
                    values = this.default;
                // console.log('reset:', node, values, Object.values(values));

                const inputs = node.getElementsByTagName('input');
                for (const index in inputs) {
                    const input = inputs[index];
                    input.checked = values[input.value];
                }
            }
        }
    },
    css: windowcss,
    events: {
        save: function() {
            GM_config.close();
        }
    },
});

// script code
function clicklistener(selector){
    $(form).arrive(selector, existing, (element) => {
        $(element).on({
            click: () => {
                fixframeHeight();
            }
        });
    });
}

function addinglistener(selector){
    $(form).arrive(selector, existing, () => {
        fixframeHeight();
    });
}

function resizelistener(selector){
    $(form).arrive(selector, existing, (element) => {
        const options = {once: true};
        $(element).on({
            mousedown: () => {
                window.addEventListener('mouseup', fixframeHeight, options);
            },
            mouseup: () => {
                fixframeHeight();
                window.removeEventListener('mouseup', fixframeHeight, options);
            }
        });
    });
}

function fixWidth(selector){
    $(document).arrive(selector, existing, (element) => {
        $(element).css({
            resize: 'vertical',
        })
    });
}

function fixframeHeight(){
    const height = $('body').height() + offset;
    const frame = window.parent.document.getElementById('iframe');
    $(frame).height(height);
}

function fixframeWidth(width){
    const frame = window.parent.document.getElementById('iframe');
    $(frame).css({
        left: '50%',
        position: 'relative',
        transform: 'translateX(-50%)',
    }).width(width);
}

function addparseButton() {
    const parseButton = $('<button></button>', {
        id: 'parseButton',
        text: 'Parse Code',
        type: 'button',
        on: {
            click: () => {
                parseCode();
            }
        }
    });
    $(codetext).after(parseButton);
}

function parseCode() {
    const parsetargets = GM_config.get('parsetargets');
    if (Object.keys(parsetargets).length > 0) {
        if ($(codeeditor).prop('checked')) {
            $(codeeditor).trigger('click');
        }
        // get css code from css textarea
        const code = $(codetext).val();
        // get metadata block from css code
        const meta = code.match(/\/\* ==UserStyle==[\s\S]*==\/UserStyle== \*\//)[0];
        if (parsetargets.variables) {
            // get preprocessor type from metadata
            const preprocessor = meta.match(/@preprocessor *(.*)(?![\s\S]*@preprocessor)/)[1];
            if (preprocessor == 'uso') {
                // get array of variables from metadata
                const variables = getVariables(meta);
                // console.log('parseCode:', preprocessor, variables);
                variables.forEach((variable) => {
                    parseVariable(variable);
                });
                // for (let index in variables) {
                //     parseVariable(variables[index]);
                // }
            }
        }
    }
}

function parseVariable(variable) {
    const varprops = variable.match(/@var[\s]+([^\s]+)[\s]+([^\s]+)[\s]+([^\s'"`]*|'.*'|".*"|`.*`)[\s]+([\s\S]+)/);
    // console.log('variable:', props);
    const type = varprops[1];
    const props = {
        key: varprops[2],
        label: trimChars(varprops[3], quotes),
        value: trimChars(varprops[4], quotes)
    };

    switch (type) {
        case 'text':
            fillSetting(newtextbutton, textsetting, props);
            break;
        case 'color':
            fillSetting(newcolorbutton, colorsetting, props);
            break;
        case 'checkbox':
            // not supported in uso
            break;
        case 'select':
            fillSetting(newdropdownbutton, dropdownsetting, props);
            break;
        case 'range':
            // not supported in uso
            break;
        case 'number':
            // not supported in uso
            break;
        default:
            break;
    }
}

function fillSetting(button, path, props) {
    // console.log('fillSetting:', props.value);
    $(button).one({
        click: () => {
            // console.log('click:', props);
            $(form).arrive(path, onceonly, (element) => {
                // console.log('arrive:', props);
                let setting = $(element);
                // console.log('fillSetting:', props.value.substr(0, 1), props.value.startsWith('{'), props);
                if (/^(\[|\{)/.test(props.value)) {
                // if (props.value.startsWith('{')) {
                    // console.log('fillSetting:', props.value.substr(0, 1), props.value.startsWith('{'), props);
                    setting = $(element).children('.edit-setting');
                    fillOptions($(element).find(newoptionbutton), $(element).children('.edit-style-options'), props.value);
                }
                else {
                    $(setting).children(settingvalue).val(props.value);
                }
                $(setting).children(settingkey).val(props.key);
                $(setting).children(settinglabel).val(props.label);
            });
        }
    }).click();
}

function fillOptions(button, path, options) {
    let isArray;
    switch (true) {
        case options.startsWith('['):
            options = trimChars(options, '\\[\\]\\s');
            isArray = true;
            break;
        case options.startsWith('{'):
            options = trimChars(options, '\\{\\}\\s');
            isArray = false;
            break;
        default:
            break;
    }
    const opts = options.split(/,[\s\n]*/);
    console.log('fillOptions:', options, opts);
    opts.forEach((opt, index) => {
        const props = {};
        if (isArray) {
            props.key = trimChars(opt, quotes);
            props.value = trimChars(props.key, '\\*');
        }
        else {
            // split option to key and value
            const keyvalue = opt.match(/(['"`])(.*)\1:[\s\n]*(['"`])([\s\S]*)\3/m);
            props.key = trimChars(keyvalue[2], quotes + '\\n');
            // console.log('fillOptions (split):', keyvalue, props.key);
            // props.key = props.key.match(/(\s)*([^\s][\S\s]*)/);
            props.value = trimChars(keyvalue.length > 1 ? keyvalue[4] : props.key, quotes + '\\n\\*');
            props.value = shiftTabs(props.value);
        }
        props.default = props.key.endsWith('*');    // check if this option is default
        props.key = trimChars(props.key, '\\*');
        // split key to key and label
        const keylabel = props.key.split(':', 2);
        props.key = keylabel[0];
        props.label = keylabel[keylabel.length > 1 ? 1 : 0];
        // console.log('fillOptions (forEach):', opt, props);

        const elementpath = `li:nth-of-type(${index + 1})`;
        if (index < 2) {
            const element = $(path).children(elementpath);
            fillOption(element, props);
        }
        else {
            // console.log('fillOptions (2+):', props);
            $(button).one({
                click: () => {
                    // console.log('click:', props);
                    $(path).arrive(elementpath, onceonly, (element) => {
                        // console.log('arrive:', props);
                        fillOption(element, props);
                        // $(path).unbindArrive(elementpath);
                    });
                }
            }).click();
        }
    });
}

function fillOption(element, props) {
    const option = $(element).children('.edit-option');
    // console.log('fillOption:', option, props);
    $(option).children(optionkey).val(props.key);
    $(option).children(optionlabel).val(props.label);
    $(option).children(optiondefault).prop('checked', props.default);
    $(element).children(optionvalue).val(props.value);
    fixframeHeight();
}

function getVariables(meta) {
    let variables = [];
    let result;
    const pattern = /(@var *[\s\S]*?)(?:\n\s*@|==\/UserStyle== \*\/)/g;
    while ((result = pattern.exec(meta)) !== null) {
        // console.log('while:', result);
        variables.push(result[1]);
        pattern.lastIndex--;
    }
    return variables;
}

function trimChars(string, chars) {
    const pattern = RegExp(`^[${chars}]*(.*?)[${chars}]*$`, 'm');
    string = string.replace(pattern, '$1');
    // console.log('trimChars:', pattern, string);
    return string;
}

function shiftTabs(string) {
    const tabs = string.match(/^(\s)*/)[1];
    const pattern = RegExp(`(\n)${tabs}`, 'g');
    string = string.replace(pattern, '$1');
    return string; //.trimLeft();
}

(function() {
    'use strict';

    $(document).ready(() => {
        if (!(window.self === window.top)) {
            const frameWidth = GM_config.get('frameWidth');
            if (frameWidth != '0') {
                fixframeWidth(frameWidth);
            }
            if (GM_config.get('fixframeHeight')) {
                clicklistener(newsettingbutton);
                clicklistener(newoptionbutton);
                resizelistener(textarea);
                addinglistener(img);
            }
            if (GM_config.get('fixtextareaWidth')) {
                fixWidth(textarea);
            }
            if (GM_config.get('parsecode')) {
                addparseButton();
            }
        }
    });
})();