사용자:Hsl0/common.js

리버티게임, 모두가 만들어가는 자유로운 게임
< 사용자:Hsl0
리버티게임>Hsl0님의 2020년 1월 25일 (토) 20:29 판

참고: 설정을 저장한 후에 바뀐 점을 확인하기 위해서는 브라우저의 캐시를 새로 고쳐야 합니다.

  • 파이어폭스 / 사파리: Shift 키를 누르면서 새로 고침을 클릭하거나, Ctrl-F5 또는 Ctrl-R을 입력 (Mac에서는 ⌘-R)
  • 구글 크롬: Ctrl-Shift-R키를 입력 (Mac에서는 ⌘-Shift-R)
  • 인터넷 익스플로러 / 엣지: Ctrl 키를 누르면서 새로 고침을 클릭하거나, Ctrl-F5를 입력.
  • 오페라: Ctrl-F5를 입력.
var fetchScript = fetch? function fetchScript(url, integrity) {
    return fetch(url, {
        header: {
            Accept: [
                'application/javascript',
                'application/ecmascript',
                'text/javascript',
                'application/x-javascript',
                '*/*'
            ]
        },
        integrity: integrity
    }).then(function(res) {
        return res.text().then(function(text) {
        	new Function(text)();
        	return new $.Deferred().resolve(text, res.statusText, res).promise();
        });
    });
} : $.getScript;

Promise.all([
    mw.loader.using([
        'mediawiki.api.options',
        'mediawiki.notification',
        'oojs-ui-core'
    ]),
    fetchScript('https://cdnjs.cloudflare.com/ajax/libs/punycode/1.4.1/punycode.min.js', 'sha256-I5XOWZu6gbewMSB9UR88y0GmiJi9AsQcCzUpA/MBNnA=')
]).then(function() {
    var title = mw.config.get('wgPageName').split('/')[0];
    var noti;

    /* option key 인코딩
        퓨니코드 + url인코딩
        % = _
        _ = __
    */
    function encode(key) {
        return encodeURIComponent(punycode.toASCII(key))
        .replace(/\./g, '%2E')
        .replace(/!/g, '%21')
        .replace(/~/g, '%7E')
        .replace(/\*/g, '%2A')
        .replace(/'/g, '%27')
        .replace(/\(/g, '%28')
        .replace(/\)/g, '%29')
        .replace(/_/g, '__')
        .replace(/%/g, '_');
    }
    function decode(key) {
        return punycode.toUnicode(decodeURIComponent(key.replace(/_(?=[a-zA-Z0-9]{2})/g, '%').replace(/__/g, '_')));
    }

    // hybridStorage 서브셋 생성
    window.setStoragePrefix = function(storage, prefix, except) {
        var spp = new Proxy(storage, {
            get: function(target, prop, receiver) {
                function keys() {
                    return Object.keys(target).filter(function(key) {
                        return key.startsWith(prefix);
                    });
                }
                
                return Reflect.get(Object.assign({
                    length: (prop === 'length') && keys().length,
                    getItem: function getItem(key) {
                        return target.getItem(prefix + key);
                    },
                    key: function key(index) {
                        return keys()[index].slice(prefix.length);
                    },
                    removeItem: function removeItem(key) {
                        return target.removeItem(prefix + key).then(function() {
                            return spp;
                        });
                    },
                    setItem: function setItem(key, value) {
                        return target.setItem(prefix + key, value).then(function() {
                            return spp;
                        });
                    }
                }, except), prop, receiver) || target.getItem(prefix + prop);
            },
            set: function(target, prop, value) {
                return target.setItem(prefix + prop, value);
            },
            deleteProperty: function(target, prop) {
                return target.removeItem(prefix + prop);
            },
            has: function(target, prop) {
                Reflect.has(target, prefix + prop);
            },
            ownKeys: function(target) {
                return Reflect.ownKeys(target).filter(function(key) {
                    return key.startsWith(prefix);
                }).map(function(key) {
                    return key.slice(prefix.length);
                });
            }
        });
        return spp;
    };

    /*
        anon = localStorage[*]
        user = mw.user.options[userjs-*]
    */
    window.hybridStorage = (function() {
        var action;
        var api = new mw.Api();
        var storage, action;
        var saveOption = (function() {
            var options = {};
            var deferred = new $.Deferred();
            var timeout = null;
            
            return function saveOption(key, value) {
                if(timeout) clearTimeout(timeout);
                options[key] = value;
                timeout = setTimeout(function() {
                    deferred.resolve(api.saveOptions(options));
                    
                    options = {};
                    timeout = null;
                    deferred = new $.Deferred();
                }, 100);
                return deferred.promise();
            };
        })();
        if(mw.user.isAnon()) {
            storage = new Proxy(localStorage, {
                get: function(target, prop, receiver) {
                    var value = Reflect.get({
                        length: target.length,
                        getItem: target.getItem,
                        key: target.key,
                        removeItem: function removeItem(key) {
                            return Promise.resolve(target.removeItem(key));
                        },
                        setItem: function setItem(key, value) {
                            return Promise.resolve(target.setItem(key, value));
                        },
                        refresh: function refresh() {
                            return Promise.resolve(storage);
                        },
                        needRefresh: false
                    }, prop, receiver) || target.getItem(prop);
                    return (value === null)? undefined : value;
                },
                set: function(target, prop, value, receiver) {
                    return Promise.resolve(target.setItem(prop, value));
                },
                deleteProperty: function(target, prop) {
                    return Promise.resolve(target.removeItem(prop));
                }
            });
        } else {
            action = {
                removeItem: function removeItem(key) {
                    if(key) return saveOption("userjs-" + key, null).then(action.refresh);
                    else throw new TypeError("Failed to execute 'removeItem' on 'Storage': 1 argument required, but only 0 present.");
                },
                setItem: function setItem(key, value) {
                    if(key) return saveOption("userjs-" + key, value).then(action.refresh);
                    else throw new TypeError("Failed to execute 'removeItem' on 'Storage': 1 argument required, but only 0 present.");
                },
                refresh: (function() {
                    var deferred = new $.Deferred();
                    var timeout = null;
                    
                    return function refresh() {
                        if(timeout) clearTimeout(timeout);
                        timeout = setTimeout(function() {
                            setTimeout(function() {
                                deferred.resolve(api.get({
                                    action: 'query',
                                    meta: 'userinfo',
                                    uiprop: 'options'
                                }, {
                                    cache: false
                                }).then(function(response) {
                                    mw.user.options.values = response.query.userinfo.options;
                                    return storage;
                                }));
                                
                                timeout = null;
                                deferred = new $.Deferred();
                            }, 300);
                        }, 100);
                        return deferred.promise();
                    };
                })()
            };
            storage = new Proxy(mw.user.options, {
                get: function(target, prop, receiver) {
                    function keys() {
                        return Object.keys(target.values).filter(function(key) {
                            return key.startsWith('userjs-');
                        });
                    }
                    
                    return Reflect.get({
                        length: prop === 'length' && keys().length,
                        getItem: function getItem(key) {
                            if(key) return target.get("userjs-" + encode(key));
                            else throw new TypeError("Failed to execute 'getItem' on 'Storage': 1 argument required, but only 0 present.");
                        },
                        key: function key(index) {
                            return decode(keys()[index].slice(7));
                        },
                        removeItem: function removeItem(key) {
                            key = encode(key);
                            Reflect.deleteProperty(target.values, key);
                            return action.removeItem(key);
                        },
                        setItem: function setItem(key, value) {
                            key = encode(key);
                            target.set(key, value);
                            return action.setItem(key, value);
                        },
                        refresh: action.refresh,
                        needRefresh: true
                    }, prop, receiver) || Reflect.get(target.values, "userjs-" + encode(prop), receiver);
                },
                set: function(target, prop, value, receiver) {
                    prop = encode(prop);
                    Reflect.set(target.values, prop, value, receiver);
                    return action.setItem(encode(prop, value));
                },
                deleteProperty: function(target, prop) {
                    prop = encode(prop);
                    Reflect.deleteProperty(target.values, prop);
                    return action.removeItem(prop);
                },
                has: function(target, prop) {
                    return Reflect.has(target.values, "userjs-" + encode(prop));
                },
                ownKeys: function(target) {
                    return Reflect.ownKeys(target.values).filter(function(key) {
                        return key.startsWith("userjs-");
                    }).map(function(key) {
                        return decode(key.slice(7));
                    });
                }
            });
        }
        return storage;
    })();
    // local + global 슈퍼셋 (hybridStorage[gamedb-*])
    var rootGameDB = setStoragePrefix(hybridStorage, 'gamedb-', {
        refresh: function() {
            return hybridStorage.refresh().then(function() {
                return rootGameDB;
            });
        },
        needRefresh: hybridStorage.needRefresh
    });
    // 게임별로 할당되는 영역 (rootGameDB[{게임}/*])
    window.localGameDB = setStoragePrefix(rootGameDB, mw.config.get('wgPageName').split('/')[0] + '/', {
        refresh: function() {
            return rootGameDB.refresh().then(function() {
                return localGameDB;
            });
        },
        needRefresh: rootGameDB.needRefresh
    });
    // 모든 게임이 공유하는 영역 (rootGameDB[#*])
    window.globalGameDB = setStoragePrefix(rootGameDB, '#', {
        refresh: function() {
            return rootGameDB.refresh().then(function() {
                return globalGameDB;
            });
        },
        needRefresh: rootGameDB.needRefresh
    });

    function DataChange() {
        this.params = geturlSearch();
        this.local = {};
        this.global = {};
        this.root = {};
        this.refresh = false;
    }
    DataChange.prototype.control = function control(element) {
        var base, key, storage, params;
        var data = element.dataset;
        
        /* 저장할 키
            local: 키 지정
            global: 전역 키 지정
            local global: 키 지정
            (없음): 기본 키
        */
        if('local' in data) {
            storage = localGameDB;
            base = this.local;
            key = data.local;
        } else if('global' in data) {
            storage = globalGameDB;
            base = this.global;
            key = data.global;
        } else {
            storage = rootGameDB;
            base = this.root;
            key = title;
        }
        
        switch(data.action) {
            // 호환
            case '저장':
            case 'save':
                /*
                    reset: DB 덮어쓰기
                        true: 기존 데이터를 지우고 현재 상태를 그대로 저장
                        false: 현재 상태를 저장하되, 없는 키는 존치
                */
                if(!location.search) base[key] = JSON.stringify('reset' in data? geturlSearch() : Object.assign(JSON.parse(storage.getItem(key)), geturlSearch()));
            break;

            case '로드':
            case 'load':
                /*
                    live: 항상 최신 데이터 사용
                        true: 링크 누를때 동기화
                        false: 항상 캐시된 데이터 사용
                    reset: 파라미터 강제 덮어쓰기
                        ture: 파라미터가 있어도 강제로 불러와 덮어쓰기
                        false: 파라미터가 있으면 아무 동작도 하지 않음
                */
                if('live' in data) this.refresh = true;
                if(!location.search || 'reset' in data) Object.assign(this.params, JSON.parse(storage.getItem(key)));
            break;

            // 기본
            case '호출':
            case 'load':
                /*
                    live: 항상 최신 데이터 사용
                        true: 링크 누를때 동기화
                        false: 항상 캐시된 데이터 사용
                */
                if('live' in data) this.refresh = true;
                this.params[data.arg] = storage.getItem(key);
            break;

            case '수정':
            case 'set':
                base[key] = data.arg;
            break;

            case '삭제':
            case 'del':
                delete base[key];
            break;

            // JSON
            case 'JSON':
            case 'json':
                /*
                    live: 항상 최신 데이터 사용
                        true: 링크 누를때 동기화
                        false: 항상 캐시된 데이터 사용
                    reset: 파라미터 덮어쓰기
                        true: 파라미터를 초기화하고 새로 가져온 값으로 덮어쓰기
                        false: 변동된 파라미터만 수정하기
                */
                if('live' in data) this.refresh = true;
                if('reset' in data) this.params = {};
                this.data[key] = JSON.stringify(new CGI2Parser({
                    get: function(args) {
                        if(typeof args === 'object') Object.pairs(args).forEach(function(pair) {
                            params[pair[0]] = this[pair[1]];
                        });
                        else Array.from(arguments).forEach(function(key) {
                            params[key] = this[key];
                        });
                    },
                    set: function(args) {
                        Object.pairs(args).forEach(function(pair) {
                            this[pair[0]] = pair[1];
                        });
                    },
                    del: function() {
                        Array.from(arguments).forEach(function(key) {
                            delete this[key];
                        });
                    },
                    def: function(args) {
                        Object.pairs(args).forEach(function(pair) {
                            if(!(pair[0] in params)) params[pair[0]] = this[pair[1]];
                        });
                    },
                    sav: function(args) {
                        if(typeof args === 'object') Object.pairs(args).forEach(function(pair) {
                            this[pair[0]] = params[pair[1]];
                        });
                        else Array.from(arguments).forEach(function(key) {
                            this[key] = params[key];
                        });
                    }
                }).parse(JSON.parse(base.getItem(key)), data.arg));
                Object.assign(this.params, params);
            break;
        }
    };

    // TODO: .gameDB-control, .gameDB-container, .gameDB-link click 이벤트 리스너 구현
    /**
     * DB 저장
     * @function
     * @param {DataChange} change 
     */
    function save(change) {
        var key;
        var promises = [];

        if(title in change.root) {
            promises.push(rootGameDB.setItem(key, change.root[title]));
        }
        for(key in change.local) {
            promises.push(localGameDB.setItem(key, change.local[key]));
        }
        for(key in change.global) {
            promises.push(globalGameDB.setItem(key, change.global[key]));
        }

        if(promises.length && !mw.user.isAnon()) noti = mw.notification.notify('데이터를 저장하는 중입니다...', {
            autoHide: false,
            tag: 'gameDB',
            type: 'pending'
        });

        return Promise.all(promises);
    }

    function handleError(code, object) {
        mw.notification.notify(
            code === 'http'?
                $('<span />')
                    .append($('<p />', {class: 'gameDB-noti-errmsg'}).text(object.xhr.responseText))
                    .append($('<code />', {class: 'gameDB-noti-errcode'}).text('HTTP ' + object.xhr.status)) : 
                $('<span />')
                    .append($('<p />', {class: 'gameDB-noti-errmsg'}).text(object.error.info))
                    .append($('<code />', {class: 'gameDB-noti-errcode'}).text(code)),
            {
                title: '데이터 저장에 실패하였습니다.',
                type: 'error',
                tag: 'gameDB'
            }
        );
    }

    // 즉시
    (function() {
        var instant = new DataChange();

        $('.gameDB-control').each(function() {
            instant.control(this);
        });

        save(instant).then(function() {
            instant.params = searchParamToString(instant.params);

            if(instant.params.length > 1) Promise.all(promises).then(function() {
                location.search = instant.params;
            });
            else noti.close();
        }, handleError);
    })();

    // 링크
    (function() {
        function process(link, controllers, change) {
            var href = new URL(link.href, location);

            controllers.each(function() {
                change.control(this);
            });

            href.search = searchParamToString(change.params);
            link.href = href.href;
        }

        $('.gameDB-container').not(':has(a, .gameDB-container)').each(function() {
            $(this).html($('<a />').text(this.innerText));
        });
        
        $('.gameDB-container a').each(function() {
            var change = new DataChange();
            var controllers = $(this).parents().filter('.gameDB-container');

            process(this, controllers.not('[data-live]'), change);

            $(this).data('change', change);
            $(this).addClass('gameDB-link');
        });

        $('.gameDB-link').click(function(event) {
            var link = this;
            var parents = $(this).parents().filter('.gameDB-container[data-live]');
            var change = $(this).data('change');
            var promise;

            if(!('done' in this.dataset)) event.preventDefault();

            if(parents.length) {
                mw.notification.notify('데이터를 가져오는 중입니다...', {
                    tag: 'gameDB',
                    type: 'pending'
                });
                promise = hybridStorage.refresh().then(function() {
                    process(link, parents, change);
                    return save(change);
                });
            } else promise = save(change);

            promise.then(function() {
                link.dataset.done = '';

                if(link.href) $(link).click();
                else mw.notification.notify('데이터가 저장되었습니다.', {
                    tag: 'gameDB'
                });
            }, handleError);
        });
    })();
})['catch'](console.error);