미디어위키:Gadget-DB2.js: 두 판 사이의 차이

리버티게임, 모두가 만들어가는 자유로운 게임
imported>Senouis
편집 요약 없음
imported>Senouis
(Senouis의 265304판 편집을 되돌림)
1번째 줄: 1번째 줄:
/**
* punycode.js - 퓨니코드 처리 라이브러리
* DB2에서 미디어위키 옵션 저장소 키의 문자열 제한 우회를 위해 사용중
* 제작자: bestiejs - https://github.com/bestiejs/punycode.js
* 라이선스: MIT 라이선스
* 출처: CDNJS - https://cdnjs.cloudflare.com/ajax/libs/punycode/1.4.1/punycode.min.js
**/
!function(e){function o(e){throw new RangeError(T[e])}function n(e,o){for(var n=e.length,r=[];n--;)r[n]=o(e[n]);return r}function r(e,o){var r=e.split("@"),t="";r.length>1&&(t=r[0]+"@",e=r[1]),e=e.replace(S,".");var u=e.split("."),i=n(u,o).join(".");return t+i}function t(e){for(var o,n,r=[],t=0,u=e.length;u>t;)o=e.charCodeAt(t++),o>=55296&&56319>=o&&u>t?(n=e.charCodeAt(t++),56320==(64512&n)?r.push(((1023&o)<<10)+(1023&n)+65536):(r.push(o),t--)):r.push(o);return r}function u(e){return n(e,function(e){var o="";return e>65535&&(e-=65536,o+=P(e>>>10&1023|55296),e=56320|1023&e),o+=P(e)}).join("")}function i(e){return 10>e-48?e-22:26>e-65?e-65:26>e-97?e-97:b}function f(e,o){return e+22+75*(26>e)-((0!=o)<<5)}function c(e,o,n){var r=0;for(e=n?M(e/j):e>>1,e+=M(e/o);e>L*C>>1;r+=b)e=M(e/L);return M(r+(L+1)*e/(e+m))}function l(e){var n,r,t,f,l,s,d,a,p,h,v=[],g=e.length,w=0,m=I,j=A;for(r=e.lastIndexOf(E),0>r&&(r=0),t=0;r>t;++t)e.charCodeAt(t)>=128&&o("not-basic"),v.push(e.charCodeAt(t));for(f=r>0?r+1:0;g>f;){for(l=w,s=1,d=b;f>=g&&o("invalid-input"),a=i(e.charCodeAt(f++)),(a>=b||a>M((x-w)/s))&&o("overflow"),w+=a*s,p=j>=d?y:d>=j+C?C:d-j,!(p>a);d+=b)h=b-p,s>M(x/h)&&o("overflow"),s*=h;n=v.length+1,j=c(w-l,n,0==l),M(w/n)>x-m&&o("overflow"),m+=M(w/n),w%=n,v.splice(w++,0,m)}return u(v)}function s(e){var n,r,u,i,l,s,d,a,p,h,v,g,w,m,j,F=[];for(e=t(e),g=e.length,n=I,r=0,l=A,s=0;g>s;++s)v=e[s],128>v&&F.push(P(v));for(u=i=F.length,i&&F.push(E);g>u;){for(d=x,s=0;g>s;++s)v=e[s],v>=n&&d>v&&(d=v);for(w=u+1,d-n>M((x-r)/w)&&o("overflow"),r+=(d-n)*w,n=d,s=0;g>s;++s)if(v=e[s],n>v&&++r>x&&o("overflow"),v==n){for(a=r,p=b;h=l>=p?y:p>=l+C?C:p-l,!(h>a);p+=b)j=a-h,m=b-h,F.push(P(f(h+j%m,0))),a=M(j/m);F.push(P(f(a,0))),l=c(r,w,u==i),r=0,++u}++r,++n}return F.join("")}function d(e){return r(e,function(e){return F.test(e)?l(e.slice(4).toLowerCase()):e})}function a(e){return r(e,function(e){return O.test(e)?"xn--"+s(e):e})}var p="object"==typeof exports&&exports&&!exports.nodeType&&exports,h="object"==typeof module&&module&&!module.nodeType&&module,v="object"==typeof global&&global;v.global!==v&&v.window!==v&&v.self!==v||(e=v);var g,w,x=2147483647,b=36,y=1,C=26,m=38,j=700,A=72,I=128,E="-",F=/^xn--/,O=/[^\x20-\x7E]/,S=/[\x2E\u3002\uFF0E\uFF61]/g,T={overflow:"Overflow: input needs wider integers to process","not-basic":"Illegal input >= 0x80 (not a basic code point)","invalid-input":"Invalid input"},L=b-y,M=Math.floor,P=String.fromCharCode;if(g={version:"1.4.1",ucs2:{decode:t,encode:u},decode:l,encode:s,toASCII:a,toUnicode:d},"function"==typeof define&&"object"==typeof define.amd&&define.amd)define("punycode",function(){return g});else if(p&&h)if(module.exports==p)h.exports=g;else for(w in g)g.hasOwnProperty(w)&&(p[w]=g[w]);else e.punycode=g}(this);
//# sourceMappingURL=./punycode.min.js.map
/* punycode.js 끝 */
/** @function fetchScript
* 체크섬을 활용해 보다 안전하게 외부 스크립트를 가져올 수 있음
* @author hsl0
**/
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;
/** [[틀:DB2]]
/** [[틀:DB2]]
  * 제작자: [[사용자:hsl0]]
  * 제작자: [[사용자:hsl0]]
**/
**/
mw.loader.using([
var punycode = require('ext.gadget.punycode');
    'mediawiki.notification',
function specialPage(page, script, title) {
    'oojs-ui-core'
if(mw.config.get('wgPageName') === '특수:빈문서/' + page) {
]);
mw.loader.using(script);
if(title) document.getElementById('firstHeading').innerText = title;
document.getElementById('mw-content-text').innerText = '잠시만 기다려 주세요...';
document.title = title;
}
}
function notifyApiError(msg, option, code, object) {
option = option || {};
mw.notification.notify(
        code === 'http'?
            $('<span />')
                .append($('<p />', {class: 'api-errmsg'}).text(object.xhr.responseText).html(option.additionalMessage || ''))
                .append($('<code />', {class: 'api-errcode'}).text('HTTP ' + object.xhr.status)) :
            $('<span />')
                .append($('<p />', {class: 'api-errmsg'}).text(object.error.info).html(option.additionalMessage || ''))
                .append($('<code />', {class: 'api-errcode'}).text(code)),
        {
            title: msg,
            type: 'error',
            tag: option.tag,
            autoHideSeconds: 'long'
        }
    );
}
function enableDB2() {
function enableDB2() {
     var title = mw.config.get('wgPageName').split('/')[0].split(':');
     var title = mw.config.get('wgPageName').split('/')[0].split(':');
     title = title[0].replace(/talk|토론/gi, '') + ':' + title[1];
     title = title[0].replace(/talk|토론/gi, '') + ':' + title[1];
     var noti;
     var noti;
     var currentUrl = new mw.Title(mw.config.get('wgPageName')).getUrl(geturlSearch());
     var useCGIProtect = document.getElementsByClassName('protectCGI')[0];
 
    var currentSearch = useCGIProtect? JSON.parse(sessionStorage.getItem('protectCGI')) : geturlSearch();
    var currentTitle = useCGIProtect && currentSearch && currentSearch.title || mw.config.get('wgPageName');
   
    if(useCGIProtect) {
    if(currentSearch) delete currentSearch.title;
else return;
    } else delete currentSearch.title;
   
    var currentUrl = mw.util.getUrl(currentTitle, currentSearch);
    var handleError = notifyApiError.bind(null, '데이터 저장에 실패하였습니다.', {tag: 'gameDB', additionalMessage: '(<a href="##emergency-save">로컬에 임시 저장</a>)'});
    var instantDone = false;
    var temp = mw.user.isAnon()? null : localStorage.getItem('gamedb-temp-' + mw.config.get('wgUserName'));
    var api = mw.user.isAnon()? null : new mw.Api();
   
     /* option key 인코딩
     /* option key 인코딩
         퓨니코드 + url인코딩
         퓨니코드 + url인코딩
132번째 줄: 133번째 줄:
         user = mw.user.options[userjs-*]
         user = mw.user.options[userjs-*]
     */
     */
     window.hybridStorage = (function() {
     window.hybridStorage = (function(local) {
        var action;
        var api = new mw.Api();
         var storage, action;
         var storage, action;
         var saveOption = (function() {
         var saveOption = (function() {
154번째 줄: 153번째 줄:
             };
             };
         })();
         })();
         if(mw.user.isAnon()) {
         if(local) {
             storage = new Proxy(localStorage, {
             storage = new Proxy(localStorage, {
                 get: function(target, prop, receiver) {
                 get: function(target, prop, receiver) {
274번째 줄: 273번째 줄:
         }
         }
         return storage;
         return storage;
     })();
     })(mw.user.isAnon());
     // local + global 슈퍼셋 (hybridStorage[gamedb-*])
     // local + global 슈퍼셋 (hybridStorage[gamedb-*])
     var rootGameDB = setStoragePrefix(hybridStorage, 'gamedb-', {
     var rootGameDB = setStoragePrefix(hybridStorage, 'gamedb-', {
302번째 줄: 301번째 줄:
         needRefresh: rootGameDB.needRefresh
         needRefresh: rootGameDB.needRefresh
     });
     });
 
   
    if(currentSearch.action) {
    $('.gameDB-container').removeClass('gameDB-container');
    return;
    }
   
    if(temp) (function() {
    function save() {
    var noti = mw.notification.notify('DB2 데이터를 동기화하는 중입니다...', {
            autoHide: false,
            tag: 'gameDB',
            type: 'pending'
        });
       
        api.saveOptions(temp).then(function() {
        noti = mw.notification.notify('DB2 데이터를 동기화하였습니다. (데이터 날짜: ' + Date(temp['userjs-gamedb-timestamp']) + ')', {tag: 'gameDB'});
        localStorage.removeItem('gamedb-temp-' + mw.config.get('wgUserName'));
        rootGameDB.refresh();
        }, notifyApiError.bind(null, 'DB2 데이터 동기화에 실패하였습니다. 다음 접속에 다시 시도합니다.', {tag: 'gameDB'}));
    }
   
    temp = JSON.parse(temp);
   
    if(mw.user.options.get('userjs-gamedb-timestamp') > temp.timestamp) (function(rootGameDB) {
function DataSelectDialog(config) {
    DataSelectDialog.super.call(this, config);
}
OO.inheritClass(DataSelectDialog, OO.ui.ProcessDialog);
DataSelectDialog.static.name = 'DataSelectDialog';
DataSelectDialog.static.title = 'DB2 데이터 선택'
DataSelectDialog.static.actions = [
    {
        flags: ['primary', 'progressive'],
        label: '저장',
        action: 'save',
        disabled: true
    }
]
DataSelectDialog.prototype.initialize = function() {
    DataSelectDialog.super.prototype.initialize.call(this);
    this.panel = new OO.ui.PanelLayout({
        padded: true,
        expanded: false,
        scrollable: true
    });
    this.content = $('<form />', {id: 'gameDB-select'});
}
DataSelectDialog.prototype.getSetupProcess = function() {
    return DataSelectDialog.super.prototype.getSetupProcess.call(this).next(function() {
        function createRow(key) {
            var $wrapper = $('<div />', {id: 'gameDB-key-' + key, class: 'gameDB-compare'});
            // remote
            new OO.ui.RadioInputWidget({
                name: 'key-' + key,
                value: 'remote',
                classes: ['gameDB-remote-select']
            }).$element.appendTo($wrapper).click(function() {
                if($('form input').not(':checked').length) saveButton.setDisabled(false);
                $('#gameDB-remote-selectall input, #gameDB-local-selectall input').each(function() {
                    this.checked = false;
                });
            });
            $('<pre />', {class: 'gameDB-remote-content'}).text(mw.user.options.get(key)).appendTo($wrapper);
            var $name = $('<div />', {class: 'gameDB-keyname'}).text(decode(key.slice(14))).appendTo($wrapper);
            // local
            $('<pre />', {class: 'gameDB-local-content'}).text(local[key]).appendTo($wrapper);
            new OO.ui.RadioInputWidget({
                name: 'key-' + key,
                value: 'local',
                classes: ['gameDB-local-select']
            }).$element.appendTo($wrapper).click(function() {
                if($('form input').not(':checked').length) saveButton.setDisabled(false);
                $('#gameDB-remote-selectall input, #gameDB-local-selectall input').each(function() {
                    this.checked = false;
                });
            });
            return $wrapper;
        }
        var saveButton = this.attachedActions[0];
        var local = JSON.parse(localStorage.getItem('gamedb-temp-' + mw.config.get('wgUserName')));
        this.local = local;
        var localTime = local.timestamp;
        delete local.timestamp;
        var $header = $('<div />', {id: 'gameDB-compare-header'}).appendTo(this.content);
        // remote
        new OO.ui.RadioInputWidget({
            name: 'all',
            value: 'remote',
            id: 'gameDB-remote-selectall'
        }).$element.appendTo($header).click(function() {
            $('.gameDB-remote-select input').each(function() {
                this.checked = true;
            });
            saveButton.setDisabled(false);
        });
        $('<div />', {id: 'gameDB-legend-remote'})
            .append('<div class="gameDB-legend-title">서버에 저장됨</div>')
            .append($('<time />').text(Date(mw.user.options.get('userjs-gamedb-timestamp')).slice(0, -20)))
            .appendTo($header);
        var $name = $('<div />', {id: 'gameDB-legend-keyname'}).text('키').appendTo($header);
        // local
        $('<div />', {id: 'gameDB-legend-local'})
            .append('<div class="gameDB-legend-title">로컬에 저장됨</div>')
            .append($('<time />').text(Date(localTime).slice(0, -20)))
            .appendTo($header);
        new OO.ui.RadioInputWidget({
            name: 'all',
            value: 'local',
            id: 'gameDB-local-selectall'
        }).$element.appendTo($header).click(function() {
            $('.gameDB-local-select input').each(function() {
                this.checked = true;
            });
            saveButton.setDisabled(false);
        });
        for(var key in local) {
            this.content.append(createRow(key));
        }
        this.panel.$element.append(this.content);
        this.$body.append(this.panel.$element);
    }, this);
}
DataSelectDialog.prototype.getActionProcess = function(action) {
    if(action === 'save') {
        var dialog = this;
        var local = this.local;
        var change = {};
       
        new FormData(this.content[0]).forEach(function(choice, key) {
            if(key !== 'all') {
            key = key.slice(4);
            switch(choice) {
                case 'remote':
                    change[key] = mw.user.options.get(key);
                break;
                case 'local':
                    change[key] = local[key];
                break;
            }
            }
        });
        return new OO.ui.Process(api.saveOptions(change).then(function() {
        localStorage.removeItem('gamedb-temp-' + mw.config.get('wgUserName'));
        return rootGameDB.refresh();
        }).then(function() {
        dialog.close();
        }));
    }
}
var windowManager = new OO.ui.WindowManager();
windowManager.$element.appendTo(document.body);
var dialog = new DataSelectDialog();
windowManager.addWindows([dialog]);
windowManager.openWindow(dialog);
    })(rootGameDB);
    else save();
    })();
   
     function DataChange(href) {
     function DataChange(href) {
         this.params = geturlSearch(new URL(href, location));
         this.params = geturlSearch(new URL(href, location));
343번째 줄: 522번째 줄:
                 /*
                 /*
                     create: 새 키를 생성할 때만 저장
                     create: 새 키를 생성할 때만 저장
                    savetitle: title 키(제목) 포함 저장
                 */
                 */
              if(!('create' in data && key in base) && location.search) base[key] = JSON.stringify(geturlSearch());
                if(!('create' in data && key in base) && location.search) base[key] = JSON.stringify(Object.assign({}, currentSearch, 'savetitle' in data && {
                title: currentTitle
                }));
             break;
             break;


429번째 줄: 611번째 줄:
                     var base = this;
                     var base = this;
                         if(typeof args === 'object') Object.pairs(args).forEach(function(pair) {
                         if(typeof args === 'object') Object.pairs(args).forEach(function(pair) {
                             base[pair[0]] = params[pair[1]];
                             base[pair[0]] = pair[1] === 'title'? currentTitle : params[pair[1]];
                         });
                         });
                         else Array.from(arguments).forEach(function(key) {
                         else Array.from(arguments).forEach(function(key) {
                             base[key] = params[key];
                             base[key] = key === 'title'? currentTitle : params[key];
                         });
                         });
                     }
                     }
472번째 줄: 654번째 줄:
          
          
         if(promises.length && !mw.user.isAnon()) setTimeout(function() {
         if(promises.length && !mw.user.isAnon()) setTimeout(function() {
        if(yet) noti = mw.notification.notify('데이터를 저장하는 중입니다...', {
        if(yet) {
            autoHide: false,
        rootGameDB.setItem('timestamp', Date.now());
            tag: 'gameDB',
        noti = mw.notification.notify('데이터를 저장하는 중입니다...', {
            type: 'pending'
        autoHide: false,
        });
        tag: 'gameDB',
            type: 'pending'
        });
        }
    }, 10);
    }, 10);


         return promise;
         return promise;
     }
     };
 
   
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() {
     function instant() {
         var instant = new DataChange(location.href);
         var instant = new DataChange(location.href);


507번째 줄: 675번째 줄:
         });
         });


         instant.save().then(function() {
         return instant.save().then(function() {
             var url = new mw.Title(instant.params.title || mw.config.get('wgPageName')).getUrl(instant.params) + location.hash;
             var url = mw.util.getUrl(instant.params.title || currentTitle, instant.params) + location.hash;
            instantDone = true;
              
              
             if(currentUrl !== url && instant.paramChanged) location.href = url;
             if(currentUrl !== url && instant.paramChanged) location.href = url;
             else if(noti) noti.close();
             else if(noti) noti.close();
         }, handleError);
         }, handleError);
     })();
     }
    instant();


     // 링크
     // 링크
     (function() {
     function link() {
         function process(link, controllers, change) {
         function process(link, controllers, change) {
             controllers.each(function() {
             controllers.each(function() {
522번째 줄: 692번째 줄:
             });
             });
              
              
             var url = new mw.Title(change.params.title || mw.config.get('wgPageName')).getUrl(change.params) + new URL(link.href).hash;
             var url = mw.util.getUrl(change.params.title || currentTitle, change.params) + new URL(link.href).hash;
             if(change.paramChanged) link.href = url;
             if(change.paramChanged) link.href = url;
             else if(link.href === location.href) link.href = "";
             else if(link.href === location.href) link.href = "";
563번째 줄: 733번째 줄:


             $(this).data('change').save().then(function() {
             $(this).data('change').save().then(function() {
                 if(link.href !== location.href) {
                 if(link.href !== location.href && link.href + location.hash !== location.href) {
                     link.dataset.done = '';
                     link.dataset.done = '';
                     $(link).off('click', handler)[0].click();
                     $(link).off('click', handler)[0].click();
571번째 줄: 741번째 줄:
             }, handleError);
             }, handleError);
         });
         });
     })();
     }
    link();
   
    // 비상용 로컬에 임시 저장
    window.addEventListener('hashchange', function(event) {
    if(!mw.user.isAnon() && location.hash === '##emergency-save') {
    mw.notification.notify('긴급 저장 모드가 켜졌습니다. 저장하려던 링크를 다시 눌러 저장해 주세요.');
   
    DataChange.prototype.save = function() {
    var change = {};
        var key;
        if(title in this.root) {
            change['userjs-gamedb-' + encode(title)] = this.root[title];
        }
        for(key in this.local) {
            change['userjs-gamedb-' + encode(title + '/' + key)] = this.local[key];
        }
        for(key in this.global) {
            change['userjs-gamedb-' + encode('#' + key)] = this.global[key];
        }
       
        change.timestamp = Date.now();
       
        localStorage.setItem('gamedb-temp-' + mw.config.get('wgUserName'), JSON.stringify(change));
       
        mw.notification.notify('로컬에 데이터를 임시 저장하였습니다.', {
    tag: 'gameDB'
    });
   
        return Promise.resolve();
    };
   
    if(!instantDone) instant();
    link();
    }
    });
}
}
$(function() {
$(enableDB2);
mw.loader.using([
specialPage('DB2', 'ext.gadget.DB2-SpecialPage', 'DB2 데이터 관리');
    'mediawiki.notification',
    'oojs-ui-core'
]).then(enableDB2)['catch'](console.error);
})
/* [[틀:DB2]] 끝 */

2020년 12월 29일 (화) 19:18 판

/** [[틀:DB2]]
 * 제작자: [[사용자:hsl0]]
**/
var punycode = require('ext.gadget.punycode');
function specialPage(page, script, title) {
	if(mw.config.get('wgPageName') === '특수:빈문서/' + page) {
		mw.loader.using(script);
		if(title) document.getElementById('firstHeading').innerText = title;
		document.getElementById('mw-content-text').innerText = '잠시만 기다려 주세요...';
		document.title = title;
	}
}
function notifyApiError(msg, option, code, object) {
	option = option || {};
	mw.notification.notify(
        code === 'http'?
            $('<span />')
                .append($('<p />', {class: 'api-errmsg'}).text(object.xhr.responseText).html(option.additionalMessage || ''))
                .append($('<code />', {class: 'api-errcode'}).text('HTTP ' + object.xhr.status)) : 
            $('<span />')
                .append($('<p />', {class: 'api-errmsg'}).text(object.error.info).html(option.additionalMessage || ''))
                .append($('<code />', {class: 'api-errcode'}).text(code)),
        {
            title: msg,
            type: 'error',
            tag: option.tag,
            autoHideSeconds: 'long'
        }
    );
}
function enableDB2() {
    var title = mw.config.get('wgPageName').split('/')[0].split(':');
    title = title[0].replace(/talk|토론/gi, '') + ':' + title[1];
    var noti;
    var useCGIProtect = document.getElementsByClassName('protectCGI')[0];
    var currentSearch = useCGIProtect? JSON.parse(sessionStorage.getItem('protectCGI')) : geturlSearch();
    var currentTitle = useCGIProtect && currentSearch && currentSearch.title || mw.config.get('wgPageName');
    
    if(useCGIProtect) {
    	if(currentSearch) delete currentSearch.title;
		else return;
    } else delete currentSearch.title;
    
    var currentUrl = mw.util.getUrl(currentTitle, currentSearch);
    var handleError = notifyApiError.bind(null, '데이터 저장에 실패하였습니다.', {tag: 'gameDB', additionalMessage: '(<a href="##emergency-save">로컬에 임시 저장</a>)'});
    var instantDone = false;
    var temp = mw.user.isAnon()? null : localStorage.getItem('gamedb-temp-' + mw.config.get('wgUserName'));
    var api = mw.user.isAnon()? null : new mw.Api();
    
    /* 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, '_')));
    }

    function parseJSON(json) {
        try {
            return JSON.parse(json);
        } catch(e) {
            return;
        }
    }

    // hybridStorage 서브셋 생성
    window.setStoragePrefix = function(storage, prefix, except, needEncode) {
        if(mw.user.isAnon()) needEncode = false;

        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 + (needEncode? encode(key) : key));
                    },
                    key: function key(index) {
                        return decode(keys()[index].slice(prefix.length));
                    },
                    removeItem: function removeItem(key) {
                        return target.removeItem(prefix + (needEncode? encode(key) : key)).then(function() {
                            return spp;
                        });
                    },
                    setItem: function setItem(key, value) {
                        return target.setItem(prefix + (needEncode? encode(key) : key), value).then(function() {
                            return spp;
                        });
                    }
                }, except), prop, receiver) || target.getItem(prefix + (needEncode? encode(prop) : prop));
            },
            set: function(target, prop, value) {
                return target.setItem(prefix + (needEncode? encode(prop) : prop), value);
            },
            deleteProperty: function(target, prop) {
                return target.removeItem(prefix + (needEncode? encode(prop) : prop));
            },
            has: function(target, prop) {
                Reflect.has(target, prefix + (needEncode? encode(prop) : prop));
            },
            ownKeys: function(target) {
                return Reflect.ownKeys(target).filter(function(key) {
                    return key.startsWith(prefix);
                }).map(function(key) {
                    return decode(key.slice(prefix.length));
                });
            }
        });
        return spp;
    };

    /*
        anon = localStorage[*]
        user = mw.user.options[userjs-*]
    */
    window.hybridStorage = (function(local) {
        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() {
                    api.saveOptions(options).then(deferred.resolve, deferred.reject);
                    
                    options = {};
                    timeout = null;
                    deferred = new $.Deferred();
                }, 100);
                return deferred.promise();
            };
        })();
        if(local) {
            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);
                    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);
                    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() {
                            api.get({
                                action: 'query',
                                meta: 'userinfo',
                                uiprop: 'options'
                            }, {
                                cache: false
                            }).then(function(response) {
                                mw.user.options.values = response.query.userinfo.options;
                                return storage;
                            }).then(deferred.resolve, deferred.reject);
                            
                            timeout = null;
                            deferred = new $.Deferred();
                        }, 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-" + key);
                            else throw new TypeError("Failed to execute 'getItem' on 'Storage': 1 argument required, but only 0 present.");
                        },
                        key: function key(index) {
                            return keys()[index].slice(7);
                        },
                        removeItem: function removeItem(key) {
                        	if(!target.exists("userjs-" + key)) return Promise.resolve();
                            return action.removeItem(key).then(function() {
                            	Reflect.deleteProperty(target.values, "userjs-" + key);
                            });
                        },
                        setItem: function setItem(key, value) {
                        	if(target.get("userjs-" + key) === value) return Promise.resolve();
                            return action.setItem(key, value).then(function() {
                            	target.set("userjs-" + key, value);
                            });
                        },
                        refresh: action.refresh,
                        needRefresh: true
                    }, prop, receiver) || Reflect.get(target.values, "userjs-" + prop, receiver);
                },
                set: function(target, prop, value, receiver) {
                	if(Reflect.get(target.values, "userjs-" + prop, receiver) === value) return Promise.resolve();
                    return action.setItem(prop, value).then(function() {
                    	Reflect.set(target.values, "userjs-" + prop, value, receiver);
                    });
                },
                deleteProperty: function(target, prop) {
                	if(!Reflect.has(target.values, "userjs-" + prop)) return Promise.resolve();
                    return action.removeItem(prop).then(function() {
                    	Reflect.deleteProperty(target.values, "userjs-" + prop);
                    });
                },
                has: function(target, prop) {
                    return Reflect.has(target.values, "userjs-" + prop);
                },
                ownKeys: function(target) {
                    return Reflect.ownKeys(target.values).filter(function(key) {
                        return key.startsWith("userjs-");
                    }).map(function(key) {
                        return key.slice(7);
                    });
                }
            });
        }
        return storage;
    })(mw.user.isAnon());
    // local + global 슈퍼셋 (hybridStorage[gamedb-*])
    var rootGameDB = setStoragePrefix(hybridStorage, 'gamedb-', {
        refresh: function() {
            return hybridStorage.refresh().then(function() {
                return rootGameDB;
            });
        },
        needRefresh: hybridStorage.needRefresh
    }, true);
    // 게임별로 할당되는 영역 (rootGameDB[{게임}/*])
    window.localGameDB = setStoragePrefix(rootGameDB, title + '/', {
        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
    });
    
    if(currentSearch.action) {
    	$('.gameDB-container').removeClass('gameDB-container');
    	return;
    }
    
    if(temp) (function() {
    	function save() {
    		var noti = mw.notification.notify('DB2 데이터를 동기화하는 중입니다...', {
	            autoHide: false,
	            tag: 'gameDB',
	            type: 'pending'
	        });
	        
	        api.saveOptions(temp).then(function() {
	        	noti = mw.notification.notify('DB2 데이터를 동기화하였습니다. (데이터 날짜: ' + Date(temp['userjs-gamedb-timestamp']) + ')', {tag: 'gameDB'});
	        	localStorage.removeItem('gamedb-temp-' + mw.config.get('wgUserName'));
	        	rootGameDB.refresh();
	        }, notifyApiError.bind(null, 'DB2 데이터 동기화에 실패하였습니다. 다음 접속에 다시 시도합니다.', {tag: 'gameDB'}));
    	}
    	
    	temp = JSON.parse(temp);
    	
    	if(mw.user.options.get('userjs-gamedb-timestamp') > temp.timestamp) (function(rootGameDB) {
			function DataSelectDialog(config) {
			    DataSelectDialog.super.call(this, config);
			}
			OO.inheritClass(DataSelectDialog, OO.ui.ProcessDialog);
			
			DataSelectDialog.static.name = 'DataSelectDialog';
			DataSelectDialog.static.title = 'DB2 데이터 선택'
			DataSelectDialog.static.actions = [
			    {
			        flags: ['primary', 'progressive'],
			        label: '저장',
			        action: 'save',
			        disabled: true
			    }
			]
			
			DataSelectDialog.prototype.initialize = function() {
			    DataSelectDialog.super.prototype.initialize.call(this);
			
			    this.panel = new OO.ui.PanelLayout({
			        padded: true,
			        expanded: false,
			        scrollable: true
			    });
			
			    this.content = $('<form />', {id: 'gameDB-select'});
			}
			
			DataSelectDialog.prototype.getSetupProcess = function() {
			    return DataSelectDialog.super.prototype.getSetupProcess.call(this).next(function() {
			        function createRow(key) {
			            var $wrapper = $('<div />', {id: 'gameDB-key-' + key, class: 'gameDB-compare'});
			
			            // remote
			            new OO.ui.RadioInputWidget({
			                name: 'key-' + key,
			                value: 'remote',
			                classes: ['gameDB-remote-select']
			            }).$element.appendTo($wrapper).click(function() {
			                if($('form input').not(':checked').length) saveButton.setDisabled(false);
			                $('#gameDB-remote-selectall input, #gameDB-local-selectall input').each(function() {
			                    this.checked = false;
			                });
			            });
			            $('<pre />', {class: 'gameDB-remote-content'}).text(mw.user.options.get(key)).appendTo($wrapper);
			
			            var $name = $('<div />', {class: 'gameDB-keyname'}).text(decode(key.slice(14))).appendTo($wrapper);
			
			            // local
			            $('<pre />', {class: 'gameDB-local-content'}).text(local[key]).appendTo($wrapper);
			            new OO.ui.RadioInputWidget({
			                name: 'key-' + key,
			                value: 'local',
			                classes: ['gameDB-local-select']
			            }).$element.appendTo($wrapper).click(function() {
			                if($('form input').not(':checked').length) saveButton.setDisabled(false);
			                $('#gameDB-remote-selectall input, #gameDB-local-selectall input').each(function() {
			                    this.checked = false;
			                });
			            });
			
			            return $wrapper;
			        }
			
			        var saveButton = this.attachedActions[0];
			
			        var local = JSON.parse(localStorage.getItem('gamedb-temp-' + mw.config.get('wgUserName')));
			        this.local = local;
			        var localTime = local.timestamp;
			
			        delete local.timestamp;
			
			        var $header = $('<div />', {id: 'gameDB-compare-header'}).appendTo(this.content);
			
			        // remote
			        new OO.ui.RadioInputWidget({
			            name: 'all',
			            value: 'remote',
			            id: 'gameDB-remote-selectall'
			        }).$element.appendTo($header).click(function() {
			            $('.gameDB-remote-select input').each(function() {
			                this.checked = true;
			            });
			            saveButton.setDisabled(false);
			        });
			        $('<div />', {id: 'gameDB-legend-remote'})
			            .append('<div class="gameDB-legend-title">서버에 저장됨</div>')
			            .append($('<time />').text(Date(mw.user.options.get('userjs-gamedb-timestamp')).slice(0, -20)))
			            .appendTo($header);
			
			        var $name = $('<div />', {id: 'gameDB-legend-keyname'}).text('키').appendTo($header);
			
			        // local
			        $('<div />', {id: 'gameDB-legend-local'})
			            .append('<div class="gameDB-legend-title">로컬에 저장됨</div>')
			            .append($('<time />').text(Date(localTime).slice(0, -20)))
			            .appendTo($header);
			        new OO.ui.RadioInputWidget({
			            name: 'all',
			            value: 'local',
			            id: 'gameDB-local-selectall'
			        }).$element.appendTo($header).click(function() {
			            $('.gameDB-local-select input').each(function() {
			                this.checked = true;
			            });
			            saveButton.setDisabled(false);
			        });
			
			        for(var key in local) {
			            this.content.append(createRow(key));
			        }
			
			        this.panel.$element.append(this.content);
			        this.$body.append(this.panel.$element);
			    }, this);
			}
			
			DataSelectDialog.prototype.getActionProcess = function(action) {
			    if(action === 'save') {
			        var dialog = this;
			        var local = this.local;
			        var change = {};
			        
			        new FormData(this.content[0]).forEach(function(choice, key) {
			            if(key !== 'all') {
			            	key = key.slice(4);
				            switch(choice) {
				                case 'remote':
				                    change[key] = mw.user.options.get(key);
				                break;
				
				                case 'local':
				                    change[key] = local[key];
				                break;
				            }
			            }
			        });
					
			        return new OO.ui.Process(api.saveOptions(change).then(function() {
			        	localStorage.removeItem('gamedb-temp-' + mw.config.get('wgUserName'));
			        	return rootGameDB.refresh();
			        }).then(function() {
			        	dialog.close();
			        }));
			    }
			}
			
			var windowManager = new OO.ui.WindowManager();
			windowManager.$element.appendTo(document.body);
			
			var dialog = new DataSelectDialog();
			windowManager.addWindows([dialog]);
			windowManager.openWindow(dialog);
    	})(rootGameDB);
    	else save();
    })();
    
    function DataChange(href) {
        this.params = geturlSearch(new URL(href, location));
        this.local = {};
        this.global = {};
        this.root = {};
        this.refresh = false;
        this.paramChanged = false;
    }
    DataChange.prototype.control = function control(element) {
        var base, key, storage, params, val, paramChanged;
        var data = element.dataset;
        
        /* 저장할 키
            local: 키 지정
            global: 전역 키 지정
            local global: 키 지정
            (없음): 기본 키
        */
        if('local' in data && 'global' in data) {
        	throw new TypeError('전역키와 일반키가 동시에 지정되었습니다');
        } else 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':
                /*
                    create: 새 키를 생성할 때만 저장
                    savetitle: title 키(제목) 포함 저장
                */
                if(!('create' in data && key in base) && location.search) base[key] = JSON.stringify(Object.assign({}, currentSearch, 'savetitle' in data && {
                	title: currentTitle
                }));
            break;

            case '로드':
            case 'load':
                /*
                    safe: 파라미터가 있으면 불러오지 않음
                    fill: 없는 파라미터만 불러옴
                */
                if(!(location.search && 'safe' in data)) {
                	Object.assign(this.params, parseJSON(storage.getItem(key)), 'fill' in data && this.params);
                	this.paramChanged = true;
                }
            break;

            // 기본
            case '호출':
            case 'get':
            	/*
            		else: 저장된 데이터가 없을 때의 대체 텍스트
            	*/
                if(key in storage || data.else) {
                	this.params[data.arg] = storage.getItem(key) || data.else;
                	this.paramChanged = true;
                }
            break;

            case '수정':
            case 'set':
                /*
                    create: 새 키를 생성할 때만 저장
                */
               if(!('create' in data && key in base)) base[key] = data.arg;
            break;

            case '삭제':
            case '제거':
            case 'del':
                base[key] = null;
            break;

            // JSON
            case 'JSON':
            case 'json':
                /*
                    reset: 데이터 초기화
                */
                params = this.params;
                val = JSON.stringify(new CGI2Parser({
                    get: function(args) {
                    	var base = this;
                        if(typeof args === 'object') Object.pairs(args).forEach(function(pair) {
                            if(base[pair[1]]) {
                            	params[pair[0]] = base[pair[1]];
                            	paramChanged = true;
                            }
                        });
                        else Array.from(arguments).forEach(function(key) {
                            if(base[key]) {
                            	params[key] = base[key];
                            	paramChanged = true;
                            }
                        });
                    },
                    set: function(args) {
                    	var base = this;
                        Object.pairs(args).forEach(function(pair) {
                            base[pair[0]] = pair[1];
                        });
                    },
                    del: function() {
                    	var base = this;
                        Array.from(arguments).forEach(function(key) {
                            delete base[key];
                        });
                    },
            		def: function(args) {
                    	var base = this;
                        Object.pairs(args).forEach(function(pair) {
                            if(!(pair[0] in base)) base[pair[0]] = pair[1];
                        });
                    },
                    sav: function(args) {
                    	var base = this;
                        if(typeof args === 'object') Object.pairs(args).forEach(function(pair) {
                            base[pair[0]] = pair[1] === 'title'? currentTitle : params[pair[1]];
                        });
                        else Array.from(arguments).forEach(function(key) {
                            base[key] = key === 'title'? currentTitle : params[key];
                        });
                    }
                }).parse(parseJSON('reset' in data? '' : storage.getItem(key)) || {}, '[' + data.arg + ']'));
                if(val.length > 2) base[key] = val;
                Object.assign(this.params, params);
                if(paramChanged) this.paramChanged = true;
            break;

            default:
                $(element).addClass('error').text(data.action? "'" + data.action + "'은(는) 올바른 동작이 아닙니다" : '올바른 동작을 입력하지 않았습니다');
        }
    };

    /**
     * DB 저장
     * @function
     * @param {DataChange} change 
     */
    DataChange.prototype.save = function() {
        var key, promise;
        var yet = true;
        var promises = [];

        if(title in this.root) {
            promises.push(rootGameDB.setItem(title, this.root[title]));
        }
        for(key in this.local) {
            promises.push(localGameDB.setItem(key, this.local[key]));
        }
        for(key in this.global) {
            promises.push(globalGameDB.setItem(key, this.global[key]));
        }
        
        promise = $.when.apply(null, promises);
        promise.then(function() {
        	yet = false;
        });
        
        if(promises.length && !mw.user.isAnon()) setTimeout(function() {
	        if(yet) {
	        	rootGameDB.setItem('timestamp', Date.now());
	        	noti = mw.notification.notify('데이터를 저장하는 중입니다...', {
	        		autoHide: false,
	        		tag: 'gameDB',
	            	type: 'pending'
	        	});
	        }
	    }, 10);

        return promise;
    };
    
    // 즉시
    function instant() {
        var instant = new DataChange(location.href);

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

        return instant.save().then(function() {
            var url = mw.util.getUrl(instant.params.title || currentTitle, instant.params) + location.hash;
            instantDone = true;
            
            if(currentUrl !== url && instant.paramChanged) location.href = url;
            else if(noti) noti.close();
        }, handleError);
    }
    instant();

    // 링크
    function link() {
        function process(link, controllers, change) {
            controllers.each(function() {
                change.control(this);
            });
            
            var url = mw.util.getUrl(change.params.title || currentTitle, change.params) + new URL(link.href).hash;
            if(change.paramChanged) link.href = url;
            else if(link.href === location.href) link.href = "";
        }

        $('.gameDB-container').not(':has(a, .gameDB-container)').each(function() {
            var href;

            /*
                clear: 기존 파라미터 넘겨주지 않음
            */
            if('clear' in this.dataset) {
                href = new URL(location.href);
                href.search = '';
                href = href.href;
            } else href = location.href;

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

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

        $('.gameDB-link').on('click', function handler(event) {
            var link = this;

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

            $(this).data('change').save().then(function() {
                if(link.href !== location.href && link.href + location.hash !== location.href) {
                    link.dataset.done = '';
                    $(link).off('click', handler)[0].click();
                } else mw.notification.notify('데이터가 저장되었습니다.', {
                    tag: 'gameDB'
                });
            }, handleError);
        });
    }
    link();
    
    // 비상용 로컬에 임시 저장
    window.addEventListener('hashchange', function(event) {
    	if(!mw.user.isAnon() && location.hash === '##emergency-save') {
    		mw.notification.notify('긴급 저장 모드가 켜졌습니다. 저장하려던 링크를 다시 눌러 저장해 주세요.');
    		
		    DataChange.prototype.save = function() {
		    	var change = {};
		        var key;
		
		        if(title in this.root) {
		            change['userjs-gamedb-' + encode(title)] = this.root[title];
		        }
		        for(key in this.local) {
		            change['userjs-gamedb-' + encode(title + '/' + key)] = this.local[key];
		        }
		        for(key in this.global) {
		            change['userjs-gamedb-' + encode('#' + key)] = this.global[key];
		        }
		        
		        change.timestamp = Date.now();
		        
		        localStorage.setItem('gamedb-temp-' + mw.config.get('wgUserName'), JSON.stringify(change));
		        
		        mw.notification.notify('로컬에 데이터를 임시 저장하였습니다.', {
    				tag: 'gameDB'
    			});
    			
		        return Promise.resolve();
		    };
    		
    		if(!instantDone) instant();
    		link();
    	}
    });
}
$(enableDB2);
specialPage('DB2', 'ext.gadget.DB2-SpecialPage', 'DB2 데이터 관리');